-
Notifications
You must be signed in to change notification settings - Fork 191
fix(super-editor): deletion of block SDTs #3555
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
a35c636
feat(super-editor): select adjacent block SDT content at textblock bo…
luccas-harbour fb01147
test(super-editor): update SDT keymap chain coverage
luccas-harbour a752dd2
test(behavior): expect block SDT boundary selection
luccas-harbour 6761ddb
test(super-editor): cover nested block SDT boundary selection
luccas-harbour 2013eb2
test(super-editor): cover locked block SDT Delete selection
luccas-harbour b9ebc98
test(super-editor): resolve handleBase64 source path from package or …
luccas-harbour f4d5b20
fix(super-editor): skip empty block SDT content selection
luccas-harbour File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
89 changes: 89 additions & 0 deletions
89
packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| import { TextSelection } from 'prosemirror-state'; | ||
| import { | ||
| findFirstContentCursorPosInNode, | ||
| findLastContentCursorPosInNode, | ||
| isZeroWidthMarker, | ||
| } from './helpers/textPositions.js'; | ||
|
|
||
| function findAncestorDepth($pos, predicate) { | ||
| for (let depth = $pos.depth; depth > 0; depth -= 1) { | ||
| if (predicate($pos.node(depth))) return depth; | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| function findSiblingAcrossHiddenMarkers(doc, pos, direction) { | ||
| let currentPos = pos; | ||
| let node = direction === 'before' ? doc.resolve(currentPos).nodeBefore : doc.resolve(currentPos).nodeAfter; | ||
|
|
||
| while (node && isZeroWidthMarker(node)) { | ||
| currentPos += direction === 'before' ? -node.nodeSize : node.nodeSize; | ||
| node = direction === 'before' ? doc.resolve(currentPos).nodeBefore : doc.resolve(currentPos).nodeAfter; | ||
| } | ||
|
|
||
| return { | ||
| node, | ||
| nodePos: direction === 'before' && node ? currentPos - node.nodeSize : currentPos, | ||
| }; | ||
| } | ||
|
|
||
| function isAtTextBlockBoundary($from, direction) { | ||
| const textblockDepth = findAncestorDepth($from, (node) => node.isTextblock); | ||
| if (textblockDepth == null) return null; | ||
|
|
||
| const textblock = $from.node(textblockDepth); | ||
| const textblockPos = $from.before(textblockDepth); | ||
| const boundary = | ||
| direction === 'before' | ||
| ? (findFirstContentCursorPosInNode(textblock, textblockPos) ?? $from.start(textblockDepth)) | ||
| : (findLastContentCursorPosInNode(textblock, textblockPos) ?? $from.end(textblockDepth)); | ||
| if ($from.pos !== boundary) return null; | ||
|
|
||
| return { | ||
| textblockDepth, | ||
| textblockPos, | ||
| }; | ||
| } | ||
|
|
||
| function selectAdjacentBlockSdtContent(direction) { | ||
| return ({ state, dispatch }) => { | ||
| const { selection } = state; | ||
| if (!selection.empty) return false; | ||
|
|
||
| const boundary = isAtTextBlockBoundary(selection.$from, direction); | ||
| if (!boundary) return false; | ||
|
|
||
| const siblingBoundaryPos = | ||
| direction === 'before' ? boundary.textblockPos : selection.$from.after(boundary.textblockDepth); | ||
| const { node, nodePos } = findSiblingAcrossHiddenMarkers(state.doc, siblingBoundaryPos, direction); | ||
| if (node?.type.name !== 'structuredContentBlock') return false; | ||
| if (node.content.size === 0) return false; | ||
|
|
||
| const contentStart = findFirstContentCursorPosInNode(node, nodePos); | ||
| const contentEnd = findLastContentCursorPosInNode(node, nodePos); | ||
| if (contentStart == null || contentEnd == null) return false; | ||
| if (contentStart >= contentEnd) return false; | ||
|
|
||
| if (dispatch) { | ||
| dispatch(state.tr.setSelection(TextSelection.create(state.doc, contentStart, contentEnd)).scrollIntoView()); | ||
| } | ||
|
|
||
| return true; | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Selects previous block SDT content when Backspace is pressed at the start of | ||
| * the following textblock. | ||
| * | ||
| * @returns {import('@core/commands/types').Command} | ||
| */ | ||
| export const selectBlockSdtBeforeTextBlockStart = () => selectAdjacentBlockSdtContent('before'); | ||
|
|
||
| /** | ||
| * Selects next block SDT content when Delete is pressed at the end of the | ||
| * preceding textblock. | ||
| * | ||
| * @returns {import('@core/commands/types').Command} | ||
| */ | ||
| export const selectBlockSdtAfterTextBlockEnd = () => selectAdjacentBlockSdtContent('after'); | ||
222 changes: 222 additions & 0 deletions
222
packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.test.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,222 @@ | ||
| import { describe, it, expect, vi } from 'vitest'; | ||
| import { Schema } from 'prosemirror-model'; | ||
| import { EditorState, TextSelection } from 'prosemirror-state'; | ||
| import { | ||
| selectBlockSdtAfterTextBlockEnd, | ||
| selectBlockSdtBeforeTextBlockStart, | ||
| } from './selectBlockSdtAtTextBlockBoundary.js'; | ||
| import { findFirstContentCursorPosInNode, findLastContentCursorPosInNode } from './helpers/textPositions.js'; | ||
|
|
||
| const makeSchema = () => | ||
| new Schema({ | ||
| nodes: { | ||
| doc: { content: 'block+' }, | ||
| paragraph: { group: 'block', content: 'inline*' }, | ||
| run: { inline: true, group: 'inline', content: 'inline*' }, | ||
| structuredContentBlock: { | ||
| group: 'block', | ||
| content: 'block*', | ||
| isolating: true, | ||
| attrs: { | ||
| lockMode: { default: 'unlocked' }, | ||
| }, | ||
| }, | ||
| mathBlock: { group: 'block', atom: true }, | ||
| image: { inline: true, group: 'inline', atom: true }, | ||
| text: { group: 'inline' }, | ||
| }, | ||
| marks: {}, | ||
| }); | ||
|
|
||
| const run = (schema, text) => schema.nodes.run.create(null, schema.text(text)); | ||
| const paragraph = (schema, text) => schema.nodes.paragraph.create(null, run(schema, text)); | ||
|
|
||
| const findTextPos = (doc, text, offset = 0) => { | ||
| let found = null; | ||
| doc.descendants((node, pos) => { | ||
| if (!node.isText || found != null) return found == null; | ||
| const index = node.text.indexOf(text); | ||
| if (index === -1) return true; | ||
| found = pos + index + offset; | ||
| return false; | ||
| }); | ||
| expect(found).not.toBeNull(); | ||
| return found; | ||
| }; | ||
|
|
||
| const findBlockSdt = (doc) => { | ||
| let result = null; | ||
| doc.descendants((node, pos) => { | ||
| if (node.type.name !== 'structuredContentBlock') return true; | ||
| result = { node, pos, end: pos + node.nodeSize }; | ||
| return false; | ||
| }); | ||
| expect(result).not.toBeNull(); | ||
| return result; | ||
| }; | ||
|
|
||
| const findBlockSdtByText = (doc, text) => { | ||
| let result = null; | ||
| doc.descendants((node, pos) => { | ||
| if (node.type.name !== 'structuredContentBlock' || node.textContent !== text) return true; | ||
| result = { node, pos, end: pos + node.nodeSize }; | ||
| return false; | ||
| }); | ||
| expect(result).not.toBeNull(); | ||
| return result; | ||
| }; | ||
|
|
||
| const makeDoc = (schema, lockMode = 'contentLocked') => { | ||
| const imageRun = schema.nodes.run.create(null, schema.nodes.image.create()); | ||
| const sdt = schema.nodes.structuredContentBlock.create({ lockMode }, [ | ||
| paragraph(schema, 'Inner text'), | ||
| schema.nodes.paragraph.create(null, imageRun), | ||
| ]); | ||
| return schema.node('doc', null, [paragraph(schema, 'Before'), sdt, paragraph(schema, 'After')]); | ||
| }; | ||
|
|
||
| const makeNestedDoc = (schema, lockMode = 'contentLocked') => { | ||
| const innerSdt = schema.nodes.structuredContentBlock.create({ lockMode }, [paragraph(schema, 'Nested text')]); | ||
| const outerSdt = schema.nodes.structuredContentBlock.create({ lockMode: 'unlocked' }, [ | ||
| paragraph(schema, 'Outer before'), | ||
| innerSdt, | ||
| paragraph(schema, 'Outer after'), | ||
| ]); | ||
| return schema.node('doc', null, [paragraph(schema, 'Before'), outerSdt, paragraph(schema, 'After')]); | ||
| }; | ||
|
|
||
| const makeEmptyBlockSdtDoc = (schema) => { | ||
| const sdt = schema.nodes.structuredContentBlock.create({ lockMode: 'contentLocked' }, [ | ||
| schema.nodes.paragraph.create(), | ||
| ]); | ||
| return schema.node('doc', null, [paragraph(schema, 'Before'), sdt, paragraph(schema, 'After')]); | ||
| }; | ||
|
|
||
| const makeAtomBlockSdtDoc = (schema) => { | ||
| const sdt = schema.nodes.structuredContentBlock.create({ lockMode: 'contentLocked' }, [ | ||
| schema.nodes.mathBlock.create(), | ||
| ]); | ||
| return schema.node('doc', null, [paragraph(schema, 'Before'), sdt, paragraph(schema, 'After')]); | ||
| }; | ||
|
|
||
| describe('selectBlockSdtBeforeTextBlockStart', () => { | ||
| it.each(['unlocked', 'sdtLocked', 'contentLocked', 'sdtContentLocked'])( | ||
| 'selects the previous %s block SDT content from the following textblock start', | ||
| (lockMode) => { | ||
| const schema = makeSchema(); | ||
| const doc = makeDoc(schema, lockMode); | ||
| const sdt = findBlockSdt(doc); | ||
| const afterStart = findTextPos(doc, 'After'); | ||
| const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); | ||
|
|
||
| let dispatched; | ||
| const ok = selectBlockSdtBeforeTextBlockStart()({ state, dispatch: (tr) => (dispatched = tr) }); | ||
|
|
||
| expect(ok).toBe(true); | ||
| expect(dispatched).toBeDefined(); | ||
| expect(dispatched.selection).toBeInstanceOf(TextSelection); | ||
| expect(dispatched.selection.from).toBe(findFirstContentCursorPosInNode(sdt.node, sdt.pos)); | ||
| expect(dispatched.selection.to).toBe(findLastContentCursorPosInNode(sdt.node, sdt.pos)); | ||
| }, | ||
| ); | ||
|
|
||
| it('returns false away from the following textblock start', () => { | ||
| const schema = makeSchema(); | ||
| const doc = makeDoc(schema); | ||
| const state = EditorState.create({ | ||
| schema, | ||
| doc, | ||
| selection: TextSelection.create(doc, findTextPos(doc, 'After', 1)), | ||
| }); | ||
| const dispatch = vi.fn(); | ||
|
|
||
| const ok = selectBlockSdtBeforeTextBlockStart()({ state, dispatch }); | ||
|
|
||
| expect(ok).toBe(false); | ||
| expect(dispatch).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it.each([ | ||
| ['empty paragraph', makeEmptyBlockSdtDoc], | ||
| ['block atom', makeAtomBlockSdtDoc], | ||
| ])('returns false for previous block SDT with %s content', (_, makeAdjacentDoc) => { | ||
| const schema = makeSchema(); | ||
| const doc = makeAdjacentDoc(schema); | ||
| const afterStart = findTextPos(doc, 'After'); | ||
| const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); | ||
| const dispatch = vi.fn(); | ||
|
|
||
| const ok = selectBlockSdtBeforeTextBlockStart()({ state, dispatch }); | ||
|
|
||
| expect(ok).toBe(false); | ||
| expect(dispatch).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('selects nested previous block SDT content from the following nested textblock start', () => { | ||
| const schema = makeSchema(); | ||
| const doc = makeNestedDoc(schema); | ||
| const nestedSdt = findBlockSdtByText(doc, 'Nested text'); | ||
| const outerAfterStart = findTextPos(doc, 'Outer after'); | ||
| const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, outerAfterStart) }); | ||
|
|
||
| let dispatched; | ||
| const ok = selectBlockSdtBeforeTextBlockStart()({ state, dispatch: (tr) => (dispatched = tr) }); | ||
|
|
||
| expect(ok).toBe(true); | ||
| expect(dispatched.selection.from).toBe(findFirstContentCursorPosInNode(nestedSdt.node, nestedSdt.pos)); | ||
| expect(dispatched.selection.to).toBe(findLastContentCursorPosInNode(nestedSdt.node, nestedSdt.pos)); | ||
| }); | ||
| }); | ||
|
|
||
| describe('selectBlockSdtAfterTextBlockEnd', () => { | ||
| it.each(['unlocked', 'sdtLocked', 'contentLocked', 'sdtContentLocked'])( | ||
| 'selects the next %s block SDT content from the preceding textblock end', | ||
| (lockMode) => { | ||
| const schema = makeSchema(); | ||
| const doc = makeDoc(schema, lockMode); | ||
| const sdt = findBlockSdt(doc); | ||
| const beforeEnd = findTextPos(doc, 'Before', 'Before'.length); | ||
| const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); | ||
|
|
||
| let dispatched; | ||
| const ok = selectBlockSdtAfterTextBlockEnd()({ state, dispatch: (tr) => (dispatched = tr) }); | ||
|
|
||
| expect(ok).toBe(true); | ||
| expect(dispatched).toBeDefined(); | ||
| expect(dispatched.selection).toBeInstanceOf(TextSelection); | ||
| expect(dispatched.selection.from).toBe(findFirstContentCursorPosInNode(sdt.node, sdt.pos)); | ||
| expect(dispatched.selection.to).toBe(findLastContentCursorPosInNode(sdt.node, sdt.pos)); | ||
| }, | ||
| ); | ||
|
|
||
| it.each([ | ||
| ['empty paragraph', makeEmptyBlockSdtDoc], | ||
| ['block atom', makeAtomBlockSdtDoc], | ||
| ])('returns false for next block SDT with %s content', (_, makeAdjacentDoc) => { | ||
| const schema = makeSchema(); | ||
| const doc = makeAdjacentDoc(schema); | ||
| const beforeEnd = findTextPos(doc, 'Before', 'Before'.length); | ||
| const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); | ||
| const dispatch = vi.fn(); | ||
|
|
||
| const ok = selectBlockSdtAfterTextBlockEnd()({ state, dispatch }); | ||
|
|
||
| expect(ok).toBe(false); | ||
| expect(dispatch).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('selects nested next block SDT content from the preceding nested textblock end', () => { | ||
| const schema = makeSchema(); | ||
| const doc = makeNestedDoc(schema); | ||
| const nestedSdt = findBlockSdtByText(doc, 'Nested text'); | ||
| const outerBeforeEnd = findTextPos(doc, 'Outer before', 'Outer before'.length); | ||
| const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, outerBeforeEnd) }); | ||
|
|
||
| let dispatched; | ||
| const ok = selectBlockSdtAfterTextBlockEnd()({ state, dispatch: (tr) => (dispatched = tr) }); | ||
|
|
||
| expect(ok).toBe(true); | ||
| expect(dispatched.selection.from).toBe(findFirstContentCursorPosInNode(nestedSdt.node, nestedSdt.pos)); | ||
| expect(dispatched.selection.to).toBe(findLastContentCursorPosInNode(nestedSdt.node, nestedSdt.pos)); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.