diff --git a/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts b/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts index 1a2e13e361..1f2bedffa0 100644 --- a/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts +++ b/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts @@ -64,6 +64,8 @@ type CoreCommandNames = | 'backspaceAtomBefore' | 'selectInlineSdtBeforeRunStart' | 'selectInlineSdtAfterRunEnd' + | 'selectBlockSdtBeforeTextBlockStart' + | 'selectBlockSdtAfterTextBlockEnd' | 'deleteBlockSdtAtTextBlockStart' | 'moveIntoBlockSdtBeforeTextBlockStart' | 'moveIntoBlockSdtAfterTextBlockEnd' diff --git a/packages/super-editor/src/editors/v1/core/commands/core-command-map.test.ts b/packages/super-editor/src/editors/v1/core/commands/core-command-map.test.ts index 29aeb75e6a..e621cfd2ea 100644 --- a/packages/super-editor/src/editors/v1/core/commands/core-command-map.test.ts +++ b/packages/super-editor/src/editors/v1/core/commands/core-command-map.test.ts @@ -11,6 +11,8 @@ describe('core command map types', () => { expect(declaration).toContain("| 'selectInlineSdtBeforeRunStart'"); expect(declaration).toContain("| 'selectInlineSdtAfterRunEnd'"); + expect(declaration).toContain("| 'selectBlockSdtBeforeTextBlockStart'"); + expect(declaration).toContain("| 'selectBlockSdtAfterTextBlockEnd'"); expect(declaration).toContain("| 'moveIntoBlockSdtBeforeTextBlockStart'"); expect(declaration).toContain("| 'moveIntoBlockSdtAfterTextBlockEnd'"); }); diff --git a/packages/super-editor/src/editors/v1/core/commands/index.js b/packages/super-editor/src/editors/v1/core/commands/index.js index 82ee55614d..8e8a325d8d 100644 --- a/packages/super-editor/src/editors/v1/core/commands/index.js +++ b/packages/super-editor/src/editors/v1/core/commands/index.js @@ -53,6 +53,7 @@ export * from './backspaceNextToRun.js'; export * from './backspaceAcrossRuns.js'; export * from './backspaceAtomBefore.js'; export * from './selectInlineSdtBeforeRunStart.js'; +export * from './selectBlockSdtAtTextBlockBoundary.js'; export * from './deleteBlockSdtAtTextBlockStart.js'; export * from './moveIntoBlockSdtBeforeTextBlockStart.js'; export * from './moveIntoBlockSdtAfterTextBlockEnd.js'; diff --git a/packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.js b/packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.js new file mode 100644 index 0000000000..6d0941b41d --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.js @@ -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'); diff --git a/packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.test.js b/packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.test.js new file mode 100644 index 0000000000..6e21d81266 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.test.js @@ -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)); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js b/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js index 98538477e7..40ba420e99 100644 --- a/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js +++ b/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js @@ -38,6 +38,7 @@ describe('handleBackspace chain ordering', () => { undoInputRule: make('undoInputRule'), deleteBlockSdtAtTextBlockStart: make('deleteBlockSdtAtTextBlockStart'), selectInlineSdtBeforeRunStart: make('selectInlineSdtBeforeRunStart'), + selectBlockSdtBeforeTextBlockStart: make('selectBlockSdtBeforeTextBlockStart'), moveIntoBlockSdtBeforeTextBlockStart: make('moveIntoBlockSdtBeforeTextBlockStart'), backspaceEmptyRunParagraph: make('backspaceEmptyRunParagraph'), backspaceSkipEmptyRun: make('backspaceSkipEmptyRun'), @@ -76,6 +77,7 @@ describe('handleBackspace chain ordering', () => { // step 2 sets inputType meta and returns false (no command call) 'deleteBlockSdtAtTextBlockStart', 'selectInlineSdtBeforeRunStart', + 'selectBlockSdtBeforeTextBlockStart', 'moveIntoBlockSdtBeforeTextBlockStart', 'backspaceEmptyRunParagraph', 'backspaceSkipEmptyRun', @@ -107,7 +109,8 @@ describe('handleBackspace chain ordering', () => { expect(callLog[0]).toBe('undoInputRule'); expect(callLog[1]).toBe('deleteBlockSdtAtTextBlockStart'); expect(callLog[2]).toBe('selectInlineSdtBeforeRunStart'); - expect(callLog[3]).toBe('moveIntoBlockSdtBeforeTextBlockStart'); + expect(callLog[3]).toBe('selectBlockSdtBeforeTextBlockStart'); + expect(callLog[4]).toBe('moveIntoBlockSdtBeforeTextBlockStart'); }); it('places mixedBidiBackspace after backspaceAcrossRuns and before deleteSelection', () => { @@ -179,6 +182,7 @@ describe('handleDelete chain ordering', () => { const commands = { deleteBlockSdtAtTextBlockStart: make('deleteBlockSdtAtTextBlockStart'), selectInlineSdtAfterRunEnd: make('selectInlineSdtAfterRunEnd'), + selectBlockSdtAfterTextBlockEnd: make('selectBlockSdtAfterTextBlockEnd'), moveIntoBlockSdtAfterTextBlockEnd: make('moveIntoBlockSdtAfterTextBlockEnd'), deleteSkipEmptyRun: make('deleteSkipEmptyRun'), deleteAtomAfter: make('deleteAtomAfter'), @@ -212,6 +216,7 @@ describe('handleDelete chain ordering', () => { expect(callLog).toEqual([ 'deleteBlockSdtAtTextBlockStart', 'selectInlineSdtAfterRunEnd', + 'selectBlockSdtAfterTextBlockEnd', 'moveIntoBlockSdtAfterTextBlockEnd', 'deleteSkipEmptyRun', 'deleteAtomAfter', diff --git a/packages/super-editor/src/editors/v1/core/extensions/keymap.js b/packages/super-editor/src/editors/v1/core/extensions/keymap.js index 20422b5383..2867757f1a 100644 --- a/packages/super-editor/src/editors/v1/core/extensions/keymap.js +++ b/packages/super-editor/src/editors/v1/core/extensions/keymap.js @@ -39,6 +39,7 @@ export const handleBackspace = (editor) => { }, () => commands.deleteBlockSdtAtTextBlockStart(), () => commands.selectInlineSdtBeforeRunStart(), + () => commands.selectBlockSdtBeforeTextBlockStart(), () => commands.moveIntoBlockSdtBeforeTextBlockStart(), () => commands.backspaceEmptyRunParagraph(), () => commands.backspaceSkipEmptyRun(), @@ -61,6 +62,7 @@ export const handleDelete = (editor) => { return editor.commands.first(({ commands }) => [ () => commands.deleteBlockSdtAtTextBlockStart(), () => commands.selectInlineSdtAfterRunEnd(), + () => commands.selectBlockSdtAfterTextBlockEnd(), () => commands.moveIntoBlockSdtAfterTextBlockEnd(), () => commands.deleteSkipEmptyRun(), () => commands.deleteAtomAfter(), diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js index c5e3afb21e..2dcfc34f53 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { base64ToFile, getBase64FileMeta } from './handleBase64.js'; @@ -26,10 +26,10 @@ describe('handleBase64', () => { }); it('uses shared url-validation helpers directly instead of converter helpers', () => { - const source = readFileSync( - resolve(process.cwd(), 'src/editors/v1/extensions/image/imageHelpers/handleBase64.js'), - 'utf8', - ); + const packageRelativePath = 'src/editors/v1/extensions/image/imageHelpers/handleBase64.js'; + const rootRelativePath = `packages/super-editor/${packageRelativePath}`; + const sourcePath = existsSync(resolve(process.cwd(), packageRelativePath)) ? packageRelativePath : rootRelativePath; + const source = readFileSync(resolve(process.cwd(), sourcePath), 'utf8'); expect(source).toContain("from '@superdoc/url-validation'"); expect(source).not.toContain("from '@converter/helpers/mediaHelpers.js'"); diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js index b999f0ddc4..5f7d265ed3 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js @@ -1,5 +1,9 @@ import { NodeSelection, Plugin, PluginKey, TextSelection } from 'prosemirror-state'; import { ySyncPluginKey } from 'y-prosemirror'; +import { + findFirstContentCursorPosInNode, + findLastContentCursorPosInNode, +} from '@core/commands/helpers/textPositions.js'; import { BLOCK_NODE_METADATA_UPDATE_META } from '../block-node/block-node.js'; export const STRUCTURED_CONTENT_LOCK_KEY = new PluginKey('structuredContentLock'); @@ -27,6 +31,7 @@ function collectSDTNodes(doc) { if (node.type.name === 'structuredContent' || node.type.name === 'structuredContentBlock') { sdtNodes.push({ type: node.type.name, + node, lockMode: node.attrs.lockMode, pos, end: pos + node.nodeSize, @@ -91,6 +96,16 @@ function isAtBlockSdtWrapperDeletePosition(state, sdt, pos) { return $pos.before(textblockDepth) === $pos.start(sdtDepth); } +function selectionCoversSdtContent(sdt, from, to) { + if (from === sdt.pos + 1 && to === sdt.end - 1) return true; + + if (sdt.type !== 'structuredContentBlock') return false; + + const contentStart = findFirstContentCursorPosInNode(sdt.node, sdt.pos); + const contentEnd = findLastContentCursorPosInNode(sdt.node, sdt.pos); + return contentStart != null && contentEnd != null && from === contentStart && to === contentEnd; +} + export function createStructuredContentLockPlugin() { return new Plugin({ key: STRUCTURED_CONTENT_LOCK_KEY, @@ -138,7 +153,7 @@ export function createStructuredContentLockPlugin() { // modes, let the normal command chain delete the selected content while // preserving the SDT wrapper. if (from !== to && !(selection instanceof NodeSelection)) { - const exactContentSDT = sdtNodes.find((s) => from === s.pos + 1 && to === s.end - 1); + const exactContentSDT = sdtNodes.find((s) => selectionCoversSdtContent(s, from, to)); if (exactContentSDT) { const isContentLocked = exactContentSDT.lockMode === 'contentLocked' || exactContentSDT.lockMode === 'sdtContentLocked'; diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js index f2c9b31974..f5759ca8d6 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js @@ -4,6 +4,10 @@ import { Slice } from 'prosemirror-model'; import { ySyncPluginKey } from 'y-prosemirror'; import { initTestEditor } from '@tests/helpers/helpers.js'; import { handleBackspace, handleDelete } from '@core/extensions/keymap.js'; +import { + findFirstContentCursorPosInNode, + findLastContentCursorPosInNode, +} from '@core/commands/helpers/textPositions.js'; import { STRUCTURED_CONTENT_LOCK_KEY } from './structured-content-lock-plugin.js'; /** @@ -33,6 +37,18 @@ function sdtNodeExists(doc, nodeType = 'structuredContent') { return findSDTNode(doc, nodeType) !== null; } +function 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; + }); + return found; +} + describe('StructuredContentLockPlugin', () => { let editor; let schema; @@ -636,6 +652,65 @@ describe('StructuredContentLockPlugin', () => { expect(sdtNodeExists(editor.state.doc, 'structuredContent')).toBe(false); }); + it('contentLocked + Backspace at the start of the following paragraph selects block SDT content, then deletes the wrapper', () => { + const doc = createDocWithSDTAndSurroundingText('contentLocked', 'structuredContentBlock'); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContentBlock'); + const afterStart = findTextPos(state.doc, ' After'); + expect(afterStart).not.toBeNull(); + + placeCaretAt(state, afterStart); + + handleBackspace(editor); + + let selection = editor.state.selection; + expect(selection).toBeInstanceOf(TextSelection); + expect(selection.from).toBe(findFirstContentCursorPosInNode(sdtInfo.node, sdtInfo.pos)); + expect(selection.to).toBe(findLastContentCursorPosInNode(sdtInfo.node, sdtInfo.pos)); + + expect(invokeLockHandleKeyDown('Backspace').handled).toBe(true); + expect(sdtNodeExists(editor.state.doc, 'structuredContentBlock')).toBe(false); + }); + + it.each(['contentLocked', 'sdtLocked', 'sdtContentLocked'])( + '%s + Delete at the end of the preceding paragraph lets keymap select block SDT content', + (lockMode) => { + const doc = createDocWithSDTAndSurroundingText(lockMode, 'structuredContentBlock'); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContentBlock'); + const beforeEnd = findTextPos(state.doc, 'Before ', 'Before '.length); + expect(beforeEnd).not.toBeNull(); + + placeCaretAt(state, beforeEnd); + + const lockResult = invokeLockHandleKeyDown('Delete'); + expect(lockResult.handled).toBe(false); + expect(lockResult.prevented).toBe(false); + + handleDelete(editor); + + const selection = editor.state.selection; + expect(selection).toBeInstanceOf(TextSelection); + expect(selection.from).toBe(findFirstContentCursorPosInNode(sdtInfo.node, sdtInfo.pos)); + expect(selection.to).toBe(findLastContentCursorPosInNode(sdtInfo.node, sdtInfo.pos)); + }, + ); + + it('contentLocked + Delete at selected block SDT content deletes the wrapper', () => { + const doc = createDocWithSDTAndSurroundingText('contentLocked', 'structuredContentBlock'); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContentBlock'); + const contentStart = findFirstContentCursorPosInNode(sdtInfo.node, sdtInfo.pos); + const contentEnd = findLastContentCursorPosInNode(sdtInfo.node, sdtInfo.pos); + expect(contentStart).not.toBeNull(); + expect(contentEnd).not.toBeNull(); + + setSelection(state, TextSelection.create(state.doc, contentStart, contentEnd)); + + expect(invokeLockHandleKeyDown('Delete').handled).toBe(true); + expect(sdtNodeExists(editor.state.doc, 'structuredContentBlock')).toBe(false); + }); + it.each([ ['unlocked', 'Backspace', true], ['unlocked', 'Delete', true], diff --git a/tests/behavior/tests/sdt/sd-3237-sdt-interactions.spec.ts b/tests/behavior/tests/sdt/sd-3237-sdt-interactions.spec.ts index 64904e85c8..cb3596aefd 100644 --- a/tests/behavior/tests/sdt/sd-3237-sdt-interactions.spec.ts +++ b/tests/behavior/tests/sdt/sd-3237-sdt-interactions.spec.ts @@ -515,10 +515,10 @@ test.describe('SD-3237 structured content interactions', () => { }); }); - test('Backspace at paragraph after block SDT table moves into SDT without deleting following text', async ({ + test('Backspace at paragraph after block SDT table selects SDT content without deleting following text', async ({ superdoc, }) => { - const { afterStart, b2End } = await loadBlockSdtTableBackspaceFixture(superdoc.page); + const { afterStart, a1Start, b2End } = await loadBlockSdtTableBackspaceFixture(superdoc.page); await superdoc.waitForStable(); await superdoc.setTextSelection(afterStart); @@ -544,17 +544,17 @@ test.describe('SD-3237 structured content interactions', () => { expect(result).toMatchObject({ text: 'BeforeA1B1A2B2After', - from: b2End, + from: a1Start, to: b2End, - empty: true, + empty: false, }); expect(result.parentTypes).toContain('structuredContentBlock'); }); - test('Delete at paragraph before block SDT table moves into SDT without deleting preceding text', async ({ + test('Delete at paragraph before block SDT table selects SDT content without deleting preceding text', async ({ superdoc, }) => { - const { beforeEnd, a1Start } = await loadBlockSdtTableBackspaceFixture(superdoc.page); + const { beforeEnd, a1Start, b2End } = await loadBlockSdtTableBackspaceFixture(superdoc.page); await superdoc.waitForStable(); await superdoc.setTextSelection(beforeEnd); @@ -581,8 +581,8 @@ test.describe('SD-3237 structured content interactions', () => { expect(result).toMatchObject({ text: 'BeforeA1B1A2B2After', from: a1Start, - to: a1Start, - empty: true, + to: b2End, + empty: false, }); expect(result.parentTypes).toContain('structuredContentBlock'); });