From 7c9cb653cbf52293aa93e91b2be535d78d2ad2be Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 3 Mar 2026 12:30:47 -0800 Subject: [PATCH] fix(super-editor): backspace across run boundaries without splitting list items --- .../src/core/commands/backspaceAcrossRuns.js | 44 +++++ .../core/commands/backspaceAcrossRuns.test.js | 172 ++++++++++++++++++ .../src/core/commands/backspaceNextToRun.js | 11 +- .../commands/findPreviousTextDeleteRange.js | 18 ++ .../findPreviousTextDeleteRange.test.js | 129 +++++++++++++ .../super-editor/src/core/commands/index.js | 1 + .../src/core/extensions/keymap.js | 1 + 7 files changed, 366 insertions(+), 10 deletions(-) create mode 100644 packages/super-editor/src/core/commands/backspaceAcrossRuns.js create mode 100644 packages/super-editor/src/core/commands/backspaceAcrossRuns.test.js create mode 100644 packages/super-editor/src/core/commands/findPreviousTextDeleteRange.js create mode 100644 packages/super-editor/src/core/commands/findPreviousTextDeleteRange.test.js diff --git a/packages/super-editor/src/core/commands/backspaceAcrossRuns.js b/packages/super-editor/src/core/commands/backspaceAcrossRuns.js new file mode 100644 index 0000000000..9cbe38b7f0 --- /dev/null +++ b/packages/super-editor/src/core/commands/backspaceAcrossRuns.js @@ -0,0 +1,44 @@ +import { Selection } from 'prosemirror-state'; +import { findPreviousTextDeleteRange } from './findPreviousTextDeleteRange.js'; + +/** + * Fallback backspace for fragmented run structures. + * + * Prevents the browser from handling backspace natively inside run-based + * paragraphs, where the hidden ProseMirror DOM is too fragmented for + * reliable contenteditable editing. Scans backward across all runs in the + * containing paragraph to find and delete one character via a PM transaction. + * + * Placed after the specialized handlers (backspaceSkipEmptyRun, + * backspaceNextToRun) in the keymap chain so it only fires when they bail. + * + * @returns {import('@core/commands/types').Command} + */ +export const backspaceAcrossRuns = + () => + ({ state, tr, dispatch }) => { + const sel = state.selection; + if (!sel.empty) return false; + + const $pos = sel.$from; + const runType = state.schema.nodes.run; + + const insideRun = $pos.parent.type === runType; + const betweenRuns = $pos.nodeBefore?.type === runType && !insideRun; + + if (!insideRun && !betweenRuns) return false; + + // Determine the containing paragraph so we can scan its full width. + const paraDepth = insideRun ? $pos.depth - 1 : $pos.depth; + const paraStart = $pos.start(paraDepth); + + const deleteRange = findPreviousTextDeleteRange(state.doc, $pos.pos, paraStart); + if (!deleteRange) return false; + + if (dispatch) { + tr.delete(deleteRange.from, deleteRange.to); + tr.setSelection(Selection.near(tr.doc.resolve(deleteRange.from))); + dispatch(tr.scrollIntoView()); + } + return true; + }; diff --git a/packages/super-editor/src/core/commands/backspaceAcrossRuns.test.js b/packages/super-editor/src/core/commands/backspaceAcrossRuns.test.js new file mode 100644 index 0000000000..95f735d346 --- /dev/null +++ b/packages/super-editor/src/core/commands/backspaceAcrossRuns.test.js @@ -0,0 +1,172 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Schema } from 'prosemirror-model'; +import { EditorState, TextSelection } from 'prosemirror-state'; +import { backspaceAcrossRuns } from './backspaceAcrossRuns.js'; + +const makeSchema = () => + new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { group: 'block', content: 'inline*' }, + run: { inline: true, group: 'inline', content: 'inline*' }, + text: { group: 'inline' }, + }, + marks: {}, + }); + +const posInsideRun = (doc, runText, offset) => { + let target = null; + doc.descendants((node, pos) => { + if (node.type.name === 'run' && node.textContent === runText) { + target = pos + 1 + offset; // +1 for run open, +offset into text + return false; + } + return true; + }); + return target; +}; + +const posBetweenRuns = (doc, firstRunText) => { + let boundary = null; + doc.descendants((node, pos) => { + if (node.type.name === 'run' && node.textContent === firstRunText) { + boundary = pos + node.nodeSize; + return false; + } + return true; + }); + return boundary; +}; + +describe('backspaceAcrossRuns', () => { + it('deletes the character before the cursor when mid-text in a run', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.node('paragraph', null, [schema.node('run', null, schema.text('ABC'))]), + ]); + + const cursorPos = posInsideRun(doc, 'ABC', 2); // after "B" + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, cursorPos) }); + + let dispatched; + const ok = backspaceAcrossRuns()({ state, tr: state.tr, dispatch: (t) => (dispatched = t) }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.doc.textContent).toBe('AC'); + }); + + it('deletes across an empty sibling run when cursor is at run start', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.node('paragraph', null, [ + schema.node('run', null, schema.text('A')), + schema.node('run'), // empty run + schema.node('run', null, schema.text('B')), + ]), + ]); + + const cursorPos = posInsideRun(doc, 'B', 0); // start of third run + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, cursorPos) }); + + let dispatched; + const ok = backspaceAcrossRuns()({ state, tr: state.tr, dispatch: (t) => (dispatched = t) }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.doc.textContent).toBe('B'); + }); + + it('deletes when cursor is between runs at the paragraph level', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.node('paragraph', null, [ + schema.node('run', null, schema.text('A')), + schema.node('run', null, schema.text('B')), + ]), + ]); + + const cursorPos = posBetweenRuns(doc, 'A'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, cursorPos) }); + + let dispatched; + const ok = backspaceAcrossRuns()({ state, tr: state.tr, dispatch: (t) => (dispatched = t) }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.doc.textContent).toBe('B'); + }); + + it('returns false when no text exists before the cursor in the paragraph', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.node('paragraph', null, [ + schema.node('run'), // empty + schema.node('run', null, schema.text('B')), + ]), + ]); + + const cursorPos = posInsideRun(doc, 'B', 0); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, cursorPos) }); + const dispatch = vi.fn(); + + const ok = backspaceAcrossRuns()({ state, tr: state.tr, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('returns false when cursor is not inside or adjacent to a run', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [schema.node('paragraph', null, schema.text('AB'))]); + + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, 2) }); + const dispatch = vi.fn(); + + const ok = backspaceAcrossRuns()({ state, tr: state.tr, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('returns false when selection is not empty', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.node('paragraph', null, [schema.node('run', null, schema.text('ABC'))]), + ]); + + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, 3, 5), // "BC" selected + }); + const dispatch = vi.fn(); + + const ok = backspaceAcrossRuns()({ state, tr: state.tr, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('does not delete text from a previous paragraph', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.node('paragraph', null, [schema.node('run', null, schema.text('First'))]), + schema.node('paragraph', null, [ + schema.node('run'), // empty — first run of second paragraph + schema.node('run', null, schema.text('Second')), + ]), + ]); + + // Cursor at start of second paragraph's second run + const cursorPos = posInsideRun(doc, 'Second', 0); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, cursorPos) }); + const dispatch = vi.fn(); + + const ok = backspaceAcrossRuns()({ state, tr: state.tr, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + expect(state.doc.textContent).toBe('FirstSecond'); + }); +}); diff --git a/packages/super-editor/src/core/commands/backspaceNextToRun.js b/packages/super-editor/src/core/commands/backspaceNextToRun.js index 24584049fc..6047f6b841 100644 --- a/packages/super-editor/src/core/commands/backspaceNextToRun.js +++ b/packages/super-editor/src/core/commands/backspaceNextToRun.js @@ -1,14 +1,5 @@ import { Selection } from 'prosemirror-state'; - -const findPreviousTextDeleteRange = (doc, cursorPos, minPos) => { - for (let pos = cursorPos - 1; pos >= minPos; pos -= 1) { - const $probe = doc.resolve(pos); - const nodeBefore = $probe.nodeBefore; - if (!nodeBefore?.isText || !nodeBefore.text?.length) continue; - return { from: pos - 1, to: pos }; - } - return null; -}; +import { findPreviousTextDeleteRange } from './findPreviousTextDeleteRange.js'; /** * Backspaces a single character when the cursor sits adjacent to a run boundary. diff --git a/packages/super-editor/src/core/commands/findPreviousTextDeleteRange.js b/packages/super-editor/src/core/commands/findPreviousTextDeleteRange.js new file mode 100644 index 0000000000..8bdab0240e --- /dev/null +++ b/packages/super-editor/src/core/commands/findPreviousTextDeleteRange.js @@ -0,0 +1,18 @@ +/** + * Scans backward from `cursorPos` to `minPos` for the nearest text character + * and returns the range needed to delete it. + * + * @param {import('prosemirror-model').Node} doc + * @param {number} cursorPos - Position to start scanning backward from. + * @param {number} minPos - Earliest position to consider (e.g. paragraph start). + * @returns {{ from: number, to: number } | null} + */ +export const findPreviousTextDeleteRange = (doc, cursorPos, minPos) => { + for (let pos = cursorPos; pos >= minPos; pos -= 1) { + const $probe = doc.resolve(pos); + const nodeBefore = $probe.nodeBefore; + if (!nodeBefore?.isText || !nodeBefore.text?.length) continue; + return { from: pos - 1, to: pos }; + } + return null; +}; diff --git a/packages/super-editor/src/core/commands/findPreviousTextDeleteRange.test.js b/packages/super-editor/src/core/commands/findPreviousTextDeleteRange.test.js new file mode 100644 index 0000000000..fb314af407 --- /dev/null +++ b/packages/super-editor/src/core/commands/findPreviousTextDeleteRange.test.js @@ -0,0 +1,129 @@ +import { describe, it, expect } from 'vitest'; +import { Schema } from 'prosemirror-model'; +import { findPreviousTextDeleteRange } from './findPreviousTextDeleteRange.js'; + +const makeSchema = () => + new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { group: 'block', content: 'inline*' }, + run: { inline: true, group: 'inline', content: 'inline*' }, + bookmarkEnd: { inline: true, group: 'inline', atom: true }, + text: { group: 'inline' }, + }, + marks: {}, + }); + +describe('findPreviousTextDeleteRange', () => { + it('finds the character immediately before the cursor in plain text', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.node('paragraph', null, [schema.node('run', null, schema.text('ABC'))]), + ]); + + // Cursor after "C" — should target "C" + const cursorPos = 5; // doc(0) > para(1) > run(2) > A(3) B(4) C(5) + const paraStart = 2; // start of run content inside paragraph + + const range = findPreviousTextDeleteRange(doc, cursorPos, paraStart); + + expect(range).toEqual({ from: 4, to: 5 }); + }); + + it('finds text across an empty sibling run', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.node('paragraph', null, [ + schema.node('run', null, schema.text('A')), + schema.node('run'), // empty run + schema.node('run', null, schema.text('B')), + ]), + ]); + + // Cursor at start of third run's content — scan should skip empty run and find "A" + let thirdRunContentStart = null; + doc.descendants((node, pos) => { + if (node.type.name === 'run' && node.textContent === 'B') { + thirdRunContentStart = pos + 1; // content start inside the run + return false; + } + return true; + }); + + const paraStart = 2; + const range = findPreviousTextDeleteRange(doc, thirdRunContentStart, paraStart); + + expect(range).not.toBeNull(); + expect(doc.textBetween(range.from, range.to)).toBe('A'); + }); + + it('skips non-text inline nodes', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.node('paragraph', null, [schema.node('run', null, [schema.text('A'), schema.node('bookmarkEnd')])]), + ]); + + // Cursor after the bookmarkEnd — should skip it and find "A" + let runEnd = null; + doc.descendants((node, pos) => { + if (node.type.name === 'run') { + runEnd = pos + node.nodeSize - 1; + return false; + } + return true; + }); + + const paraStart = 2; + const range = findPreviousTextDeleteRange(doc, runEnd, paraStart); + + expect(range).not.toBeNull(); + expect(doc.textBetween(range.from, range.to)).toBe('A'); + }); + + it('returns null when no text exists in the scan range', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.node('paragraph', null, [schema.node('run'), schema.node('run', null, schema.text('B'))]), + ]); + + // Scan only within the empty run + let emptyRunEnd = null; + doc.descendants((node, pos) => { + if (node.type.name === 'run' && node.content.size === 0) { + emptyRunEnd = pos + node.nodeSize - 1; + return false; + } + return true; + }); + + const emptyRunStart = 2; + const range = findPreviousTextDeleteRange(doc, emptyRunEnd, emptyRunStart); + + expect(range).toBeNull(); + }); + + it('respects minPos and does not scan past it', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.node('paragraph', null, [ + schema.node('run', null, schema.text('A')), + schema.node('run', null, schema.text('B')), + ]), + ]); + + // Set minPos to the start of the second run so "A" is out of range + let secondRunStart = null; + doc.descendants((node, pos) => { + if (node.type.name === 'run' && node.textContent === 'B') { + secondRunStart = pos + 1; + return false; + } + return true; + }); + + // Cursor at start of second run's content — scanning only within that run + const range = findPreviousTextDeleteRange(doc, secondRunStart, secondRunStart); + + expect(range).toBeNull(); + }); +}); diff --git a/packages/super-editor/src/core/commands/index.js b/packages/super-editor/src/core/commands/index.js index c5bdaa8729..6313a91bf0 100644 --- a/packages/super-editor/src/core/commands/index.js +++ b/packages/super-editor/src/core/commands/index.js @@ -49,6 +49,7 @@ export * from './lineHeight.js'; export * from './backspaceEmptyRunParagraph.js'; export * from './backspaceSkipEmptyRun.js'; export * from './backspaceNextToRun.js'; +export * from './backspaceAcrossRuns.js'; export * from './deleteSkipEmptyRun.js'; export * from './deleteNextToRun.js'; export * from './skipTab.js'; diff --git a/packages/super-editor/src/core/extensions/keymap.js b/packages/super-editor/src/core/extensions/keymap.js index dbb467ed8d..49dbed4f8b 100644 --- a/packages/super-editor/src/core/extensions/keymap.js +++ b/packages/super-editor/src/core/extensions/keymap.js @@ -34,6 +34,7 @@ export const handleBackspace = (editor) => { () => commands.backspaceEmptyRunParagraph(), () => commands.backspaceSkipEmptyRun(), () => commands.backspaceNextToRun(), + () => commands.backspaceAcrossRuns(), () => commands.deleteSelection(), () => commands.removeNumberingProperties(), () => commands.joinBackward(),