From 56f1a3805fec75f25e8578564675f178c5438aa7 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Sat, 7 Feb 2026 13:37:52 -0300 Subject: [PATCH 1/7] fix(super-editor): allow Backspace to delete empty paragraphs in suggesting mode --- .../trackChangesHelpers/replaceStep.js | 20 ++++++++ .../trackChangesHelpers/replaceStep.test.js | 47 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceStep.js b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceStep.js index 5c40536ed..924f39db8 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceStep.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceStep.js @@ -21,6 +21,26 @@ import { findMarkPosition } from './documentHelpers.js'; * @param {number} options.originalStepIndex Original step index. */ export const replaceStep = ({ state, tr, step, newTr, map, user, date, originalStep, originalStepIndex }) => { + // Handle pure deletion of empty block nodes (e.g., Backspace on empty paragraph). + // When there's no content being inserted and no inline content in the deletion range, + // there's nothing to track as a change — apply the step directly. + if (step.from !== step.to && step.slice.content.size === 0) { + let hasInlineContent = false; + newTr.doc.nodesBetween(step.from, step.to, (node) => { + if (node.isInline) { + hasInlineContent = true; + return false; + } + }); + + if (!hasInlineContent) { + if (!newTr.maybeStep(step).failed) { + map.appendMap(step.getMap()); + } + return; + } + } + const trTemp = state.apply(newTr).tr; // Default: insert replacement after the selected range (Word-like replace behavior). diff --git a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceStep.test.js b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceStep.test.js index 86704c5db..8b3af9ff3 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceStep.test.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceStep.test.js @@ -371,6 +371,53 @@ describe('trackChangesHelpers replaceStep', () => { expect(text).not.toContain('Flattened Fallback'); }); + it('deletes empty paragraph on Backspace in suggesting mode', () => { + // When the cursor is inside an empty paragraph and the user presses Backspace, + // ProseMirror creates a ReplaceStep that removes the empty paragraph node. + // The track changes system should allow this deletion to proceed since there's + // no inline content to track. + + // Create doc with:

Hello

+ const run = schema.nodes.run.create({}, [schema.text('Hello')]); + const para1 = schema.nodes.paragraph.create({}, run); + const para2 = schema.nodes.paragraph.create(); + const doc = schema.nodes.doc.create({}, [para1, para2]); + let state = createState(doc); + + // Find empty paragraph position dynamically + let emptyParaOffset = null; + state.doc.forEach((node, offset) => { + if (node.type.name === 'paragraph' && node.content.size === 0) { + emptyParaOffset = offset; + } + }); + expect(emptyParaOffset).not.toBeNull(); + + // Cursor inside empty paragraph (offset + 1 for the opening position) + state = state.apply(state.tr.setSelection(TextSelection.create(state.doc, emptyParaOffset + 1))); + + // Simulate Backspace: joinBackward creates a ReplaceStep that removes the empty paragraph + const tr = state.tr.delete(emptyParaOffset, emptyParaOffset + para2.nodeSize); + tr.setMeta('inputType', 'deleteContentBackward'); + + const tracked = trackedTransaction({ tr, state, user }); + const finalState = state.apply(tracked); + + // The empty paragraph should be deleted — only one paragraph should remain + let paragraphCount = 0; + finalState.doc.forEach((node) => { + if (node.type.name === 'paragraph') paragraphCount++; + }); + expect(paragraphCount).toBe(1); + + // The remaining paragraph should contain "Hello" + let textContent = ''; + finalState.doc.descendants((node) => { + if (node.isText) textContent += node.text; + }); + expect(textContent).toBe('Hello'); + }); + it('tracks replace even when selection contains existing deletions and links', () => { const linkMark = schema.marks.link.create({ href: 'https://example.com' }); const existingDeletion = schema.marks[TrackDeleteMarkName].create({ From f9b5e3711e4539653e0b8dee59e535496ec22a75 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Sat, 7 Feb 2026 16:17:15 -0300 Subject: [PATCH 2/7] feat(super-editor): add tests for keymap history functionality - Introduced a new test suite for keymap history grouping, validating the behavior of the Enter and space keys in creating undo boundaries. - Enhanced the handleEnter and handleBackspace functions to dispatch closeHistory, ensuring proper history management during text input. - Added tests to confirm that undo operations correctly restore text and manage undo group boundaries in various scenarios. --- .../core/extensions/keymap-history.test.js | 121 ++++++++++++++++++ .../src/core/extensions/keymap.js | 10 ++ 2 files changed, 131 insertions(+) create mode 100644 packages/super-editor/src/core/extensions/keymap-history.test.js diff --git a/packages/super-editor/src/core/extensions/keymap-history.test.js b/packages/super-editor/src/core/extensions/keymap-history.test.js new file mode 100644 index 000000000..7b7033b56 --- /dev/null +++ b/packages/super-editor/src/core/extensions/keymap-history.test.js @@ -0,0 +1,121 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { closeHistory, undoDepth } from 'prosemirror-history'; +import { initTestEditor } from '@tests/helpers/helpers.js'; +import { handleEnter } from './keymap.js'; + +describe('keymap history grouping', () => { + let editor; + + afterEach(() => { + editor?.destroy(); + editor = null; + }); + + const insertText = (ed, text) => { + const { from } = ed.state.selection; + ed.view.dispatch(ed.state.tr.insertText(text, from)); + }; + + /** Simulate closeHistoryOnly (space / Opt+Backspace handler). */ + const closeHistoryGroup = (ed) => { + ed.view.dispatch(closeHistory(ed.view.state.tr)); + }; + + it('Enter creates a new undo group boundary', () => { + ({ editor } = initTestEditor({ mode: 'text', content: '

' })); + + insertText(editor, 'hello'); + const depthAfterFirstText = undoDepth(editor.state); + + handleEnter(editor); + + insertText(editor, 'world'); + const depthAfterSecondText = undoDepth(editor.state); + + expect(depthAfterSecondText).toBeGreaterThan(depthAfterFirstText); + }); + + it('undo after Enter restores text before Enter', () => { + ({ editor } = initTestEditor({ mode: 'text', content: '

' })); + + insertText(editor, 'hello'); + handleEnter(editor); + insertText(editor, 'world'); + + const textBefore = editor.state.doc.textContent; + expect(textBefore).toContain('hello'); + expect(textBefore).toContain('world'); + + editor.commands.undo(); + const textAfterUndo = editor.state.doc.textContent; + expect(textAfterUndo).toContain('hello'); + }); + + it('Enter creates boundary in suggesting mode', () => { + ({ editor } = initTestEditor({ + mode: 'text', + content: '

', + user: { name: 'Tester', email: 'test@test.com' }, + })); + + editor.commands.enableTrackChanges?.(); + + insertText(editor, 'hello'); + const depthAfterFirstText = undoDepth(editor.state); + + handleEnter(editor); + + insertText(editor, 'world'); + const depthAfterSecondText = undoDepth(editor.state); + + expect(depthAfterSecondText).toBeGreaterThan(depthAfterFirstText); + }); + + it('space creates a word-level undo boundary', () => { + ({ editor } = initTestEditor({ mode: 'text', content: '

' })); + + insertText(editor, 'hello'); + const depthAfterFirstWord = undoDepth(editor.state); + + // Simulate space handler: closeHistory then type space + closeHistoryGroup(editor); + insertText(editor, ' '); + + insertText(editor, 'world'); + const depthAfterSecondWord = undoDepth(editor.state); + + expect(depthAfterSecondWord).toBeGreaterThan(depthAfterFirstWord); + }); + + it('undo after space removes only the last word', () => { + ({ editor } = initTestEditor({ mode: 'text', content: '

' })); + + insertText(editor, 'hello'); + closeHistoryGroup(editor); + insertText(editor, ' world'); + + expect(editor.state.doc.textContent).toBe('hello world'); + + editor.commands.undo(); + expect(editor.state.doc.textContent).toBe('hello'); + }); + + it('closeHistory before deletion creates its own undo step', () => { + ({ editor } = initTestEditor({ mode: 'text', content: '

' })); + + insertText(editor, 'hello world'); + const depthAfterTyping = undoDepth(editor.state); + + // Simulate Opt+Backspace: closeHistory then delete last word + closeHistoryGroup(editor); + const { from } = editor.state.selection; + editor.view.dispatch(editor.state.tr.delete(from - 5, from)); + const depthAfterDelete = undoDepth(editor.state); + + expect(depthAfterDelete).toBeGreaterThan(depthAfterTyping); + + // Undo should restore the deleted word + editor.commands.undo(); + expect(editor.state.doc.textContent).toBe('hello world'); + }); +}); diff --git a/packages/super-editor/src/core/extensions/keymap.js b/packages/super-editor/src/core/extensions/keymap.js index 2ad353bc0..e2a29602c 100644 --- a/packages/super-editor/src/core/extensions/keymap.js +++ b/packages/super-editor/src/core/extensions/keymap.js @@ -1,8 +1,12 @@ +import { closeHistory } from 'prosemirror-history'; import { Extension } from '../Extension.js'; import { isIOS } from '../utilities/isIOS.js'; import { isMacOS } from '../utilities/isMacOS.js'; export const handleEnter = (editor) => { + const { view } = editor; + view?.dispatch?.(closeHistory(view?.state?.tr)); + return editor.commands.first(({ commands }) => [ () => commands.splitRunToParagraph(), () => commands.newlineInCode(), @@ -13,6 +17,9 @@ export const handleEnter = (editor) => { }; export const handleBackspace = (editor) => { + const { view } = editor; + view?.dispatch?.(closeHistory(view?.state?.tr)); + return editor.commands.first(({ commands, tr }) => [ () => commands.undoInputRule(), () => { @@ -30,6 +37,9 @@ export const handleBackspace = (editor) => { }; export const handleDelete = (editor) => { + const { view } = editor; + editor?.view?.dispatch?.(closeHistory(view?.state?.tr)); + return editor.commands.first(({ commands }) => [ () => commands.deleteSkipEmptyRun(), () => commands.deleteNextToRun(), From 34fc6bd753ac2078a94ba67bbc77e9c0bea84a7b Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sun, 8 Feb 2026 08:00:54 -0800 Subject: [PATCH 3/7] test(super-editor): add regression test for paragraph-join tracking in suggesting mode --- .../trackChangesHelpers/replaceStep.test.js | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceStep.test.js b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceStep.test.js index 8b3af9ff3..9bfdd957a 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceStep.test.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceStep.test.js @@ -418,6 +418,53 @@ describe('trackChangesHelpers replaceStep', () => { expect(textContent).toBe('Hello'); }); + it('tracks Backspace when joining paragraphs', () => { + // Create doc with:

Hello

World

+ const para1 = schema.nodes.paragraph.create({}, schema.nodes.run.create({}, [schema.text('Hello')])); + const para2 = schema.nodes.paragraph.create({}, schema.nodes.run.create({}, [schema.text('World')])); + const doc = schema.nodes.doc.create({}, [para1, para2]); + let state = createState(doc); + + // Backspace at start of second paragraph joins blocks. + let joinPos = null; + let secondParaOffset = null; + state.doc.forEach((node, offset, index) => { + if (index === 0) { + joinPos = offset + node.nodeSize; + } else if (index === 1) { + secondParaOffset = offset; + } + }); + expect(joinPos).not.toBeNull(); + expect(secondParaOffset).not.toBeNull(); + + // Put cursor at the start of the second paragraph to mirror Backspace semantics. + state = state.apply(state.tr.setSelection(TextSelection.create(state.doc, secondParaOffset + 1))); + + const tr = state.tr.join(joinPos); + tr.setMeta('inputType', 'deleteContentBackward'); + + // Sanity check: this is a pure boundary deletion with no inline descendants in range. + const joinStep = tr.steps[0]; + expect(joinStep?.from).toBeTypeOf('number'); + expect(joinStep?.to).toBeTypeOf('number'); + expect(joinStep?.slice?.content?.size).toBe(0); + let hasInlineContent = false; + state.doc.nodesBetween(joinStep.from, joinStep.to, (node) => { + if (node.isInline) { + hasInlineContent = true; + return false; + } + }); + expect(hasInlineContent).toBe(false); + + const tracked = trackedTransaction({ tr, state, user }); + const meta = tracked.getMeta(TrackChangesBasePluginKey); + + // Regression guard: these deletes must not bypass track-change annotation. + expect(meta?.deletionMark).toBeDefined(); + }); + it('tracks replace even when selection contains existing deletions and links', () => { const linkMark = schema.marks.link.create({ href: 'https://example.com' }); const existingDeletion = schema.marks[TrackDeleteMarkName].create({ From 4bc27501639cdc07d503c7e6c0900a5b7b0af454 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Mon, 9 Feb 2026 14:15:39 -0300 Subject: [PATCH 4/7] Fix block node ID generation to prevent duplicate IDs --- .../src/extensions/block-node/block-node.js | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/extensions/block-node/block-node.js b/packages/super-editor/src/extensions/block-node/block-node.js index ec1585729..0c67c6c78 100644 --- a/packages/super-editor/src/extensions/block-node/block-node.js +++ b/packages/super-editor/src/extensions/block-node/block-node.js @@ -258,14 +258,17 @@ export const BlockNode = Extension.create({ if (!hasInitialized) { // Initial pass: assign IDs to all block nodes in document + const seenIds = new Set(); newState.doc.descendants((node, pos) => { if (!nodeAllowsSdBlockIdAttr(node) && !nodeAllowsSdBlockRevAttr(node)) return; const nextAttrs = { ...node.attrs }; let nodeChanged = false; - if (nodeAllowsSdBlockIdAttr(node) && nodeNeedsSdBlockId(node)) { + const currentId = node.attrs?.sdBlockId; + if (nodeAllowsSdBlockIdAttr(node) && (nodeNeedsSdBlockId(node) || seenIds.has(currentId))) { nextAttrs.sdBlockId = uuidv4(); nodeChanged = true; } + if (currentId) seenIds.add(currentId); if (nodeAllowsSdBlockRevAttr(node)) { const rev = ensureBlockRev(node); if (nextAttrs.sdBlockRev !== rev) { @@ -331,6 +334,9 @@ export const BlockNode = Extension.create({ const docSize = newState.doc.content.size; const mergedRanges = mergeRanges(rangesToCheck, docSize); + // Track seen sdBlockIds across all ranges to detect duplicates + // (e.g., when tr.split() copies the original paragraph's sdBlockId to the new one). + const seenBlockIds = new Set(); for (const { from, to } of mergedRanges) { const clampedRange = clampRange(from, to, docSize); @@ -347,10 +353,12 @@ export const BlockNode = Extension.create({ if (updatedPositions.has(pos)) return; const nextAttrs = { ...node.attrs }; let nodeChanged = false; - if (nodeAllowsSdBlockIdAttr(node) && nodeNeedsSdBlockId(node)) { + const currentId = node.attrs?.sdBlockId; + if (nodeAllowsSdBlockIdAttr(node) && (nodeNeedsSdBlockId(node) || seenBlockIds.has(currentId))) { nextAttrs.sdBlockId = uuidv4(); nodeChanged = true; } + if (currentId) seenBlockIds.add(currentId); if (nodeAllowsSdBlockRevAttr(node)) { nextAttrs.sdBlockRev = getNextBlockRev(node); nodeChanged = true; @@ -369,14 +377,17 @@ export const BlockNode = Extension.create({ } if (shouldFallbackToFullTraversal) { + const fallbackSeenIds = new Set(); newState.doc.descendants((node, pos) => { if (!nodeAllowsSdBlockIdAttr(node) && !nodeAllowsSdBlockRevAttr(node)) return; const nextAttrs = { ...node.attrs }; let nodeChanged = false; - if (nodeAllowsSdBlockIdAttr(node) && nodeNeedsSdBlockId(node)) { + const currentId = node.attrs?.sdBlockId; + if (nodeAllowsSdBlockIdAttr(node) && (nodeNeedsSdBlockId(node) || fallbackSeenIds.has(currentId))) { nextAttrs.sdBlockId = uuidv4(); nodeChanged = true; } + if (currentId) fallbackSeenIds.add(currentId); if (nodeAllowsSdBlockRevAttr(node)) { nextAttrs.sdBlockRev = getNextBlockRev(node); nodeChanged = true; From 3d37af6c9e4205e22af48053f41ef69df45b8848 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Mon, 9 Feb 2026 21:37:08 -0300 Subject: [PATCH 5/7] fix(super-editor): handle structural deletions (joins) in suggesting mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The replaceStep guard now applies any pure deletion with no inline content directly, covering both empty paragraph removal and paragraph joins. markDeletion cannot represent structural changes (block boundary tokens) as inline marks, so these steps were silently swallowed. Fixes the Enter→Enter→Backspace→Backspace flow in suggesting mode where the second Backspace (paragraph join) did nothing. --- ...ckspace-empty-paragraph-suggesting-mode.md | 178 ++++++++++++++++++ .../trackChangesHelpers/replaceStep.js | 6 +- .../trackChangesHelpers/replaceStep.test.js | 42 ++--- 3 files changed, 193 insertions(+), 33 deletions(-) create mode 100644 .tupizz/docs/sd-1810-backspace-empty-paragraph-suggesting-mode.md diff --git a/.tupizz/docs/sd-1810-backspace-empty-paragraph-suggesting-mode.md b/.tupizz/docs/sd-1810-backspace-empty-paragraph-suggesting-mode.md new file mode 100644 index 000000000..73b55a834 --- /dev/null +++ b/.tupizz/docs/sd-1810-backspace-empty-paragraph-suggesting-mode.md @@ -0,0 +1,178 @@ +# SD-1810: Backspace Doesn't Delete Empty Paragraph in Suggesting Mode + +## The Issue + +In suggesting mode, pressing Backspace on an empty paragraph does nothing. The full repro: + +1. Type text into a paragraph (e.g., Lorem Ipsum) +2. Switch to Suggesting mode +3. Click in the middle of the paragraph +4. Press Enter twice (creates 3 paragraphs, middle one empty) +5. Press Backspace (should delete empty paragraph) +6. Press Backspace again (should join the two remaining paragraphs) + +**Before the fix**: Both Backspace presses did nothing. The empty paragraph stayed, and the join was silently swallowed. + +## How Suggesting Mode Works Behind the Scenes + +### Transaction Interception + +When track changes is active (suggesting mode), every ProseMirror transaction goes through a special pipeline: + +``` +User action (keydown, paste, etc.) + | + v +ProseMirror creates Transaction with Steps + | + v +Editor.#dispatchTransaction() + | + v +trackedTransaction({ tr, state, user }) <-- intercepts here + | + v +For each step: + - ReplaceStep --> replaceStep() handler + - AddMarkStep --> addMarkStep() handler + - RemoveMarkStep --> removeMarkStep() handler + | + v +Returns modified transaction with track-insert/track-delete marks +``` + +### The `replaceStep()` Function + +This is the core handler. It takes the user's original step and rewrites it as a tracked change: + +1. **Inverts** the original step (to preserve existing content) +2. **Inserts** the new content with a `track-insert` mark +3. **Marks deletion** on the old content range with `track-delete` marks +4. Returns the rewritten transaction + +### Key Files + +| File | Purpose | +|------|---------| +| `track-changes/trackChangesHelpers/trackedTransaction.js` | Entry point, routes steps to handlers | +| `track-changes/trackChangesHelpers/replaceStep.js` | Handles ReplaceStep (deletion, insertion, join) | +| `track-changes/trackChangesHelpers/markDeletion.js` | Applies track-delete marks to inline nodes | +| `track-changes/trackChangesHelpers/markInsertion.js` | Applies track-insert marks to inline nodes | +| `core/extensions/keymap.js` | Keyboard shortcuts (Enter, Backspace, Delete) | + +## Root Cause + +### Problem 1: Empty Paragraph Deletion + +When Backspace removes an empty paragraph, ProseMirror creates: + +``` +ReplaceStep(from=emptyParaStart, to=emptyParaEnd, slice=Slice.empty) +``` + +The `replaceStep()` handler: +1. Inverts the step (no-op since content is empty) +2. Tries `markDeletion(from, to)` on the range +3. `markDeletion` iterates `nodesBetween(from, to)` looking for **inline nodes** to mark +4. An empty paragraph has **zero inline nodes** +5. Nothing gets marked, and the deletion step is never applied to the transaction +6. Result: the empty paragraph stays + +### Problem 2: Paragraph Join + +When Backspace at the start of a paragraph creates a join, ProseMirror creates: + +``` +ReplaceStep(from=para1End-1, to=para2Start+1, slice=Slice.empty) +``` + +The step range `[from, to]` spans only 2 positions (the closing `

` token and opening `

` token). Same issue: + +1. `markDeletion(from, to)` scans for inline nodes in range +2. The range contains only block boundary tokens, no inline nodes +3. Nothing gets marked, join is silently swallowed +4. Result: paragraphs stay separate + +### The Common Pattern + +Both cases share the same root cause: `markDeletion` operates on **inline content only** (text nodes, images, etc.). It cannot represent structural changes (removing block boundaries, deleting empty blocks) because there's nothing to attach a mark to. + +``` + Inline content? + / \ + YES NO + / \ + markDeletion markDeletion + adds marks finds nothing + (works!) (silent no-op!) +``` + +## The Fix + +Added an early guard at the top of `replaceStep()`: + +```javascript +// Handle structural deletions with no inline content (e.g., empty paragraph removal, +// paragraph joins). When there's no content being inserted and no inline content in +// the deletion range, markDeletion has nothing to mark -- apply the step directly. +if (step.from !== step.to && step.slice.content.size === 0) { + let hasInlineContent = false; + newTr.doc.nodesBetween(step.from, step.to, (node) => { + if (node.isInline) { + hasInlineContent = true; + return false; + } + }); + + if (!hasInlineContent) { + if (!newTr.maybeStep(step).failed) { + map.appendMap(step.getMap()); + } + return; + } +} +``` + +The logic: + +1. **Is this a pure deletion?** `step.from !== step.to && step.slice.content.size === 0` +2. **Does the range contain any inline content?** Scan with `nodesBetween` +3. **If no inline content**: Apply the step directly (bypass tracking flow) + +This handles both empty paragraph deletion AND paragraph joins, because both are structural operations where `markDeletion` has nothing to work with. + +### Why Not Track These Operations? + +The track changes system represents changes as **marks on inline nodes** (text, images). Structural changes (paragraph boundaries) have no inline node to attach marks to. Properly tracking them would require: + +- A new mark type for paragraph boundary deletions +- New decoration rendering for visual feedback +- Changes across the entire track changes accept/reject system + +This is significant architectural work. The pragmatic solution: apply structural operations directly when they can't be represented as tracked changes. This matches the actual behavior before the fix (the operations were already untracked -- they were just silently lost instead of applied). + +## Additional Changes in This PR + +### `keymap.js`: History Group Boundaries + +Added `closeHistory` dispatch before Enter, Backspace, and Delete handlers. This ensures each structural operation creates a separate undo group, so Ctrl+Z undoes them individually. + +### `block-node.js`: Duplicate sdBlockId Prevention + +When `tr.split()` creates a new paragraph (Enter key), ProseMirror copies ALL attributes including `sdBlockId`. The `FlowBlockCache` uses `sdBlockId` as its sole cache key, so duplicates cause garbled rendering. + +Fix: Track `seenBlockIds` in the `appendTransaction` handler. When a duplicate ID is found, assign a new UUID. + +## Testing + +### Unit Tests + +- `replaceStep.test.js`: Tests empty paragraph deletion and paragraph join +- `keymap-history.test.js`: Tests undo group boundaries for Enter/Space + +### Manual Browser Verification + +1. Open `localhost:9097`, type text, switch to Suggesting mode +2. Click in middle of paragraph +3. Enter, Enter, Backspace, Backspace +4. Verify: returns to 1 paragraph with all text intact diff --git a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceStep.js b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceStep.js index 924f39db8..eeac53660 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceStep.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceStep.js @@ -21,9 +21,9 @@ import { findMarkPosition } from './documentHelpers.js'; * @param {number} options.originalStepIndex Original step index. */ export const replaceStep = ({ state, tr, step, newTr, map, user, date, originalStep, originalStepIndex }) => { - // Handle pure deletion of empty block nodes (e.g., Backspace on empty paragraph). - // When there's no content being inserted and no inline content in the deletion range, - // there's nothing to track as a change — apply the step directly. + // Handle structural deletions with no inline content (e.g., empty paragraph removal, + // paragraph joins). When there's no content being inserted and no inline content in + // the deletion range, markDeletion has nothing to mark — apply the step directly. if (step.from !== step.to && step.slice.content.size === 0) { let hasInlineContent = false; newTr.doc.nodesBetween(step.from, step.to, (node) => { diff --git a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceStep.test.js b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceStep.test.js index 9bfdd957a..d442fbbb0 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceStep.test.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceStep.test.js @@ -418,51 +418,33 @@ describe('trackChangesHelpers replaceStep', () => { expect(textContent).toBe('Hello'); }); - it('tracks Backspace when joining paragraphs', () => { - // Create doc with:

Hello

World

+ it('applies paragraph join directly in suggesting mode (no inline content to track)', () => { + // Paragraph joins have no inline content in their step range (only block boundary + // tokens), so markDeletion has nothing to mark. The join is applied directly. const para1 = schema.nodes.paragraph.create({}, schema.nodes.run.create({}, [schema.text('Hello')])); const para2 = schema.nodes.paragraph.create({}, schema.nodes.run.create({}, [schema.text('World')])); const doc = schema.nodes.doc.create({}, [para1, para2]); let state = createState(doc); - // Backspace at start of second paragraph joins blocks. let joinPos = null; - let secondParaOffset = null; state.doc.forEach((node, offset, index) => { - if (index === 0) { - joinPos = offset + node.nodeSize; - } else if (index === 1) { - secondParaOffset = offset; - } + if (index === 0) joinPos = offset + node.nodeSize; }); expect(joinPos).not.toBeNull(); - expect(secondParaOffset).not.toBeNull(); - - // Put cursor at the start of the second paragraph to mirror Backspace semantics. - state = state.apply(state.tr.setSelection(TextSelection.create(state.doc, secondParaOffset + 1))); const tr = state.tr.join(joinPos); tr.setMeta('inputType', 'deleteContentBackward'); - // Sanity check: this is a pure boundary deletion with no inline descendants in range. - const joinStep = tr.steps[0]; - expect(joinStep?.from).toBeTypeOf('number'); - expect(joinStep?.to).toBeTypeOf('number'); - expect(joinStep?.slice?.content?.size).toBe(0); - let hasInlineContent = false; - state.doc.nodesBetween(joinStep.from, joinStep.to, (node) => { - if (node.isInline) { - hasInlineContent = true; - return false; - } - }); - expect(hasInlineContent).toBe(false); - const tracked = trackedTransaction({ tr, state, user }); - const meta = tracked.getMeta(TrackChangesBasePluginKey); + const finalState = state.apply(tracked); - // Regression guard: these deletes must not bypass track-change annotation. - expect(meta?.deletionMark).toBeDefined(); + // The join should be applied — only one paragraph remains + let paragraphCount = 0; + finalState.doc.forEach(() => paragraphCount++); + expect(paragraphCount).toBe(1); + + // Both texts should be merged + expect(finalState.doc.textContent).toBe('HelloWorld'); }); it('tracks replace even when selection contains existing deletions and links', () => { From dcd5c9a75bc8e1518e8fac5c3c81f7db3c513df3 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 11 Feb 2026 15:12:05 -0300 Subject: [PATCH 6/7] chore: remove docs file from branch --- ...ckspace-empty-paragraph-suggesting-mode.md | 178 ------------------ 1 file changed, 178 deletions(-) delete mode 100644 .tupizz/docs/sd-1810-backspace-empty-paragraph-suggesting-mode.md diff --git a/.tupizz/docs/sd-1810-backspace-empty-paragraph-suggesting-mode.md b/.tupizz/docs/sd-1810-backspace-empty-paragraph-suggesting-mode.md deleted file mode 100644 index 73b55a834..000000000 --- a/.tupizz/docs/sd-1810-backspace-empty-paragraph-suggesting-mode.md +++ /dev/null @@ -1,178 +0,0 @@ -# SD-1810: Backspace Doesn't Delete Empty Paragraph in Suggesting Mode - -## The Issue - -In suggesting mode, pressing Backspace on an empty paragraph does nothing. The full repro: - -1. Type text into a paragraph (e.g., Lorem Ipsum) -2. Switch to Suggesting mode -3. Click in the middle of the paragraph -4. Press Enter twice (creates 3 paragraphs, middle one empty) -5. Press Backspace (should delete empty paragraph) -6. Press Backspace again (should join the two remaining paragraphs) - -**Before the fix**: Both Backspace presses did nothing. The empty paragraph stayed, and the join was silently swallowed. - -## How Suggesting Mode Works Behind the Scenes - -### Transaction Interception - -When track changes is active (suggesting mode), every ProseMirror transaction goes through a special pipeline: - -``` -User action (keydown, paste, etc.) - | - v -ProseMirror creates Transaction with Steps - | - v -Editor.#dispatchTransaction() - | - v -trackedTransaction({ tr, state, user }) <-- intercepts here - | - v -For each step: - - ReplaceStep --> replaceStep() handler - - AddMarkStep --> addMarkStep() handler - - RemoveMarkStep --> removeMarkStep() handler - | - v -Returns modified transaction with track-insert/track-delete marks -``` - -### The `replaceStep()` Function - -This is the core handler. It takes the user's original step and rewrites it as a tracked change: - -1. **Inverts** the original step (to preserve existing content) -2. **Inserts** the new content with a `track-insert` mark -3. **Marks deletion** on the old content range with `track-delete` marks -4. Returns the rewritten transaction - -### Key Files - -| File | Purpose | -|------|---------| -| `track-changes/trackChangesHelpers/trackedTransaction.js` | Entry point, routes steps to handlers | -| `track-changes/trackChangesHelpers/replaceStep.js` | Handles ReplaceStep (deletion, insertion, join) | -| `track-changes/trackChangesHelpers/markDeletion.js` | Applies track-delete marks to inline nodes | -| `track-changes/trackChangesHelpers/markInsertion.js` | Applies track-insert marks to inline nodes | -| `core/extensions/keymap.js` | Keyboard shortcuts (Enter, Backspace, Delete) | - -## Root Cause - -### Problem 1: Empty Paragraph Deletion - -When Backspace removes an empty paragraph, ProseMirror creates: - -``` -ReplaceStep(from=emptyParaStart, to=emptyParaEnd, slice=Slice.empty) -``` - -The `replaceStep()` handler: -1. Inverts the step (no-op since content is empty) -2. Tries `markDeletion(from, to)` on the range -3. `markDeletion` iterates `nodesBetween(from, to)` looking for **inline nodes** to mark -4. An empty paragraph has **zero inline nodes** -5. Nothing gets marked, and the deletion step is never applied to the transaction -6. Result: the empty paragraph stays - -### Problem 2: Paragraph Join - -When Backspace at the start of a paragraph creates a join, ProseMirror creates: - -``` -ReplaceStep(from=para1End-1, to=para2Start+1, slice=Slice.empty) -``` - -The step range `[from, to]` spans only 2 positions (the closing `

` token and opening `

` token). Same issue: - -1. `markDeletion(from, to)` scans for inline nodes in range -2. The range contains only block boundary tokens, no inline nodes -3. Nothing gets marked, join is silently swallowed -4. Result: paragraphs stay separate - -### The Common Pattern - -Both cases share the same root cause: `markDeletion` operates on **inline content only** (text nodes, images, etc.). It cannot represent structural changes (removing block boundaries, deleting empty blocks) because there's nothing to attach a mark to. - -``` - Inline content? - / \ - YES NO - / \ - markDeletion markDeletion - adds marks finds nothing - (works!) (silent no-op!) -``` - -## The Fix - -Added an early guard at the top of `replaceStep()`: - -```javascript -// Handle structural deletions with no inline content (e.g., empty paragraph removal, -// paragraph joins). When there's no content being inserted and no inline content in -// the deletion range, markDeletion has nothing to mark -- apply the step directly. -if (step.from !== step.to && step.slice.content.size === 0) { - let hasInlineContent = false; - newTr.doc.nodesBetween(step.from, step.to, (node) => { - if (node.isInline) { - hasInlineContent = true; - return false; - } - }); - - if (!hasInlineContent) { - if (!newTr.maybeStep(step).failed) { - map.appendMap(step.getMap()); - } - return; - } -} -``` - -The logic: - -1. **Is this a pure deletion?** `step.from !== step.to && step.slice.content.size === 0` -2. **Does the range contain any inline content?** Scan with `nodesBetween` -3. **If no inline content**: Apply the step directly (bypass tracking flow) - -This handles both empty paragraph deletion AND paragraph joins, because both are structural operations where `markDeletion` has nothing to work with. - -### Why Not Track These Operations? - -The track changes system represents changes as **marks on inline nodes** (text, images). Structural changes (paragraph boundaries) have no inline node to attach marks to. Properly tracking them would require: - -- A new mark type for paragraph boundary deletions -- New decoration rendering for visual feedback -- Changes across the entire track changes accept/reject system - -This is significant architectural work. The pragmatic solution: apply structural operations directly when they can't be represented as tracked changes. This matches the actual behavior before the fix (the operations were already untracked -- they were just silently lost instead of applied). - -## Additional Changes in This PR - -### `keymap.js`: History Group Boundaries - -Added `closeHistory` dispatch before Enter, Backspace, and Delete handlers. This ensures each structural operation creates a separate undo group, so Ctrl+Z undoes them individually. - -### `block-node.js`: Duplicate sdBlockId Prevention - -When `tr.split()` creates a new paragraph (Enter key), ProseMirror copies ALL attributes including `sdBlockId`. The `FlowBlockCache` uses `sdBlockId` as its sole cache key, so duplicates cause garbled rendering. - -Fix: Track `seenBlockIds` in the `appendTransaction` handler. When a duplicate ID is found, assign a new UUID. - -## Testing - -### Unit Tests - -- `replaceStep.test.js`: Tests empty paragraph deletion and paragraph join -- `keymap-history.test.js`: Tests undo group boundaries for Enter/Space - -### Manual Browser Verification - -1. Open `localhost:9097`, type text, switch to Suggesting mode -2. Click in middle of paragraph -3. Enter, Enter, Backspace, Backspace -4. Verify: returns to 1 paragraph with all text intact From 8f90140eeb5e6dcf82e6477757de72915a60f8c5 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 11 Feb 2026 15:44:58 -0300 Subject: [PATCH 7/7] fix(super-editor): address PR review feedback - Fix editor?.view?.dispatch inconsistency in handleDelete (use view?.dispatch) - Add comments explaining closeHistory no-op edge case in keymap handlers - Add tests for handleBackspace/handleDelete closeHistory behavior - Extract ensureUniqueSdBlockId() helper to deduplicate seenIds logic in block-node - Add comment explaining TrackDelete-only paragraph edge case in replaceStep - Add visual testing story for SD-1810 backspace empty paragraph in suggesting mode --- .../backspace-empty-paragraph-suggesting.ts | 70 ++++++++++++++++ .../core/extensions/keymap-history.test.js | 83 ++++++++++++++++++- .../src/core/extensions/keymap.js | 8 +- .../src/extensions/block-node/block-node.js | 44 +++++----- .../trackChangesHelpers/replaceStep.js | 6 ++ 5 files changed, 188 insertions(+), 23 deletions(-) create mode 100644 devtools/visual-testing/tests/interactions/stories/comments-tcs/backspace-empty-paragraph-suggesting.ts diff --git a/devtools/visual-testing/tests/interactions/stories/comments-tcs/backspace-empty-paragraph-suggesting.ts b/devtools/visual-testing/tests/interactions/stories/comments-tcs/backspace-empty-paragraph-suggesting.ts new file mode 100644 index 000000000..d4dca56c6 --- /dev/null +++ b/devtools/visual-testing/tests/interactions/stories/comments-tcs/backspace-empty-paragraph-suggesting.ts @@ -0,0 +1,70 @@ +import { defineStory } from '@superdoc-testing/helpers'; + +const WAIT_MS = 300; + +/** + * SD-1810: Backspace doesn't delete empty paragraph in suggesting mode + * + * Bug: When a user creates a new paragraph (Enter) in suggesting mode and + * immediately presses Backspace, nothing happens — the empty paragraph stays. + * + * Root cause: The track changes system intercepts the ReplaceStep via + * replaceStep() → markDeletion(). An empty paragraph has no inline content, + * so markDeletion finds nothing to mark and the step is silently swallowed. + * + * This test recreates the exact bug scenario: + * 1. Type text, press Enter to create an empty paragraph + * 2. Press Backspace — empty paragraph should be removed + * 3. Also tests the Enter → Enter → Backspace → Backspace flow (paragraph join) + */ +export default defineStory({ + name: 'backspace-empty-paragraph-suggesting', + description: 'Test Backspace on empty paragraphs and paragraph joins in suggesting mode', + tickets: ['SD-1810'], + startDocument: null, + layout: true, + comments: 'off', + hideCaret: false, + hideSelection: false, + + async run(_page, helpers): Promise { + const { type, press, setDocumentMode, waitForStable, milestone } = helpers; + + // Step 1: Type initial content + await type('Hello World'); + await waitForStable(WAIT_MS); + await milestone('initial-text', 'Document with initial text'); + + // Step 2: Switch to suggesting mode + await setDocumentMode('suggesting'); + await waitForStable(WAIT_MS); + await milestone('suggesting-mode', 'Switched to suggesting mode'); + + // Step 3: Press Enter to create an empty paragraph + await press('Enter'); + await waitForStable(WAIT_MS); + await milestone('after-enter', 'New empty paragraph created below'); + + // Step 4: Press Backspace — should delete the empty paragraph + await press('Backspace'); + await waitForStable(WAIT_MS); + await milestone('after-backspace', 'Empty paragraph removed, cursor back at end of "Hello World"'); + + // Step 5: Test Enter → Enter → Backspace → Backspace (join flow) + await press('Enter'); + await waitForStable(WAIT_MS); + await press('Enter'); + await waitForStable(WAIT_MS); + await milestone('after-two-enters', 'Two new paragraphs created'); + + // Step 6: First Backspace — removes the second empty paragraph + await press('Backspace'); + await waitForStable(WAIT_MS); + await milestone('after-first-backspace', 'One empty paragraph removed'); + + // Step 7: Second Backspace — joins back with the original paragraph + await press('Backspace'); + await waitForStable(WAIT_MS); + await milestone('after-second-backspace', 'Joined back to original paragraph — cursor at end of "Hello World"'); + }, +}); diff --git a/packages/super-editor/src/core/extensions/keymap-history.test.js b/packages/super-editor/src/core/extensions/keymap-history.test.js index 7b7033b56..c9afa2420 100644 --- a/packages/super-editor/src/core/extensions/keymap-history.test.js +++ b/packages/super-editor/src/core/extensions/keymap-history.test.js @@ -1,7 +1,7 @@ import { describe, it, expect, afterEach } from 'vitest'; import { closeHistory, undoDepth } from 'prosemirror-history'; import { initTestEditor } from '@tests/helpers/helpers.js'; -import { handleEnter } from './keymap.js'; +import { handleEnter, handleBackspace, handleDelete } from './keymap.js'; describe('keymap history grouping', () => { let editor; @@ -118,4 +118,85 @@ describe('keymap history grouping', () => { editor.commands.undo(); expect(editor.state.doc.textContent).toBe('hello world'); }); + + it('Backspace creates a new undo group boundary', () => { + ({ editor } = initTestEditor({ mode: 'text', content: '

' })); + + // Create two paragraphs: type, Enter, type + insertText(editor, 'hello'); + handleEnter(editor); + insertText(editor, 'world'); + const depthBeforeBackspace = undoDepth(editor.state); + + // Move cursor to start of second paragraph so joinBackward succeeds + let secondParaStart = null; + editor.state.doc.forEach((_node, offset, index) => { + if (index === 1) secondParaStart = offset + 1; + }); + editor.view.dispatch( + editor.state.tr.setSelection(editor.state.selection.constructor.create(editor.state.doc, secondParaStart)), + ); + + // Backspace at start of second paragraph → joins paragraphs + handleBackspace(editor); + + insertText(editor, ' after'); + const depthAfterBackspace = undoDepth(editor.state); + + expect(depthAfterBackspace).toBeGreaterThan(depthBeforeBackspace); + }); + + it('undo after Backspace join restores paragraph break', () => { + ({ editor } = initTestEditor({ mode: 'text', content: '

' })); + + insertText(editor, 'hello'); + handleEnter(editor); + insertText(editor, 'world'); + + expect(editor.state.doc.childCount).toBe(2); + + // Move cursor to start of second paragraph + let secondParaStart = null; + editor.state.doc.forEach((_node, offset, index) => { + if (index === 1) secondParaStart = offset + 1; + }); + editor.view.dispatch( + editor.state.tr.setSelection(editor.state.selection.constructor.create(editor.state.doc, secondParaStart)), + ); + + // Backspace joins paragraphs + handleBackspace(editor); + expect(editor.state.doc.childCount).toBe(1); + + // Undo should restore the paragraph break + editor.commands.undo(); + expect(editor.state.doc.childCount).toBe(2); + }); + + it('Delete creates a new undo group boundary', () => { + ({ editor } = initTestEditor({ mode: 'text', content: '

' })); + + // Create two paragraphs + insertText(editor, 'hello'); + handleEnter(editor); + insertText(editor, 'world'); + const depthBeforeDelete = undoDepth(editor.state); + + // Move cursor to end of first paragraph so joinForward succeeds + let firstParaEnd = null; + editor.state.doc.forEach((node, offset, index) => { + if (index === 0) firstParaEnd = offset + node.nodeSize - 1; + }); + editor.view.dispatch( + editor.state.tr.setSelection(editor.state.selection.constructor.create(editor.state.doc, firstParaEnd)), + ); + + // Delete at end of first paragraph → joins paragraphs + handleDelete(editor); + + insertText(editor, ' after'); + const depthAfterDelete = undoDepth(editor.state); + + expect(depthAfterDelete).toBeGreaterThan(depthBeforeDelete); + }); }); diff --git a/packages/super-editor/src/core/extensions/keymap.js b/packages/super-editor/src/core/extensions/keymap.js index e2a29602c..dbb467ed8 100644 --- a/packages/super-editor/src/core/extensions/keymap.js +++ b/packages/super-editor/src/core/extensions/keymap.js @@ -5,6 +5,10 @@ import { isMacOS } from '../utilities/isMacOS.js'; export const handleEnter = (editor) => { const { view } = editor; + // Close the current undo group so this structural action becomes its own undo step. + // Note: this fires before the command chain, so if no command succeeds (rare — e.g. + // Enter with no valid split target) an empty undo boundary is created. Acceptable + // trade-off vs. the complexity of post-hoc closeHistory after commands.first. view?.dispatch?.(closeHistory(view?.state?.tr)); return editor.commands.first(({ commands }) => [ @@ -18,6 +22,7 @@ export const handleEnter = (editor) => { export const handleBackspace = (editor) => { const { view } = editor; + // Close undo group — see comment in handleEnter. view?.dispatch?.(closeHistory(view?.state?.tr)); return editor.commands.first(({ commands, tr }) => [ @@ -38,7 +43,8 @@ export const handleBackspace = (editor) => { export const handleDelete = (editor) => { const { view } = editor; - editor?.view?.dispatch?.(closeHistory(view?.state?.tr)); + // Close undo group — see comment in handleEnter. + view?.dispatch?.(closeHistory(view?.state?.tr)); return editor.commands.first(({ commands }) => [ () => commands.deleteSkipEmptyRun(), diff --git a/packages/super-editor/src/extensions/block-node/block-node.js b/packages/super-editor/src/extensions/block-node/block-node.js index 0c67c6c78..d6777ed6e 100644 --- a/packages/super-editor/src/extensions/block-node/block-node.js +++ b/packages/super-editor/src/extensions/block-node/block-node.js @@ -242,6 +242,26 @@ export const BlockNode = Extension.create({ tr.setNodeMarkup(pos, undefined, nextAttrs, node.marks); }; + /** + * Ensures a block node has a unique sdBlockId, assigning a new UUID if the + * current ID is missing or already seen. Tracks seen IDs in the provided Set + * to detect duplicates (e.g., when tr.split() copies the original paragraph's ID). + * @param {ProseMirrorNode} node - The node to check. + * @param {Object} nextAttrs - Mutable attrs object to update. + * @param {Set} seenIds - Set of IDs already encountered in this traversal. + * @returns {boolean} True if the sdBlockId was changed. + */ + const ensureUniqueSdBlockId = (node, nextAttrs, seenIds) => { + const currentId = node.attrs?.sdBlockId; + let changed = false; + if (nodeAllowsSdBlockIdAttr(node) && (nodeNeedsSdBlockId(node) || seenIds.has(currentId))) { + nextAttrs.sdBlockId = uuidv4(); + changed = true; + } + if (currentId) seenIds.add(currentId); + return changed; + }; + return [ new Plugin({ key: BlockNodePluginKey, @@ -262,13 +282,7 @@ export const BlockNode = Extension.create({ newState.doc.descendants((node, pos) => { if (!nodeAllowsSdBlockIdAttr(node) && !nodeAllowsSdBlockRevAttr(node)) return; const nextAttrs = { ...node.attrs }; - let nodeChanged = false; - const currentId = node.attrs?.sdBlockId; - if (nodeAllowsSdBlockIdAttr(node) && (nodeNeedsSdBlockId(node) || seenIds.has(currentId))) { - nextAttrs.sdBlockId = uuidv4(); - nodeChanged = true; - } - if (currentId) seenIds.add(currentId); + let nodeChanged = ensureUniqueSdBlockId(node, nextAttrs, seenIds); if (nodeAllowsSdBlockRevAttr(node)) { const rev = ensureBlockRev(node); if (nextAttrs.sdBlockRev !== rev) { @@ -352,13 +366,7 @@ export const BlockNode = Extension.create({ if (!nodeAllowsSdBlockIdAttr(node) && !nodeAllowsSdBlockRevAttr(node)) return; if (updatedPositions.has(pos)) return; const nextAttrs = { ...node.attrs }; - let nodeChanged = false; - const currentId = node.attrs?.sdBlockId; - if (nodeAllowsSdBlockIdAttr(node) && (nodeNeedsSdBlockId(node) || seenBlockIds.has(currentId))) { - nextAttrs.sdBlockId = uuidv4(); - nodeChanged = true; - } - if (currentId) seenBlockIds.add(currentId); + let nodeChanged = ensureUniqueSdBlockId(node, nextAttrs, seenBlockIds); if (nodeAllowsSdBlockRevAttr(node)) { nextAttrs.sdBlockRev = getNextBlockRev(node); nodeChanged = true; @@ -381,13 +389,7 @@ export const BlockNode = Extension.create({ newState.doc.descendants((node, pos) => { if (!nodeAllowsSdBlockIdAttr(node) && !nodeAllowsSdBlockRevAttr(node)) return; const nextAttrs = { ...node.attrs }; - let nodeChanged = false; - const currentId = node.attrs?.sdBlockId; - if (nodeAllowsSdBlockIdAttr(node) && (nodeNeedsSdBlockId(node) || fallbackSeenIds.has(currentId))) { - nextAttrs.sdBlockId = uuidv4(); - nodeChanged = true; - } - if (currentId) fallbackSeenIds.add(currentId); + let nodeChanged = ensureUniqueSdBlockId(node, nextAttrs, fallbackSeenIds); if (nodeAllowsSdBlockRevAttr(node)) { nextAttrs.sdBlockRev = getNextBlockRev(node); nodeChanged = true; diff --git a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceStep.js b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceStep.js index eeac53660..36e0e4e2d 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceStep.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceStep.js @@ -24,6 +24,12 @@ export const replaceStep = ({ state, tr, step, newTr, map, user, date, originalS // Handle structural deletions with no inline content (e.g., empty paragraph removal, // paragraph joins). When there's no content being inserted and no inline content in // the deletion range, markDeletion has nothing to mark — apply the step directly. + // + // Edge case: if a paragraph contains only TrackDelete-marked text, hasInlineContent + // returns true and the normal tracking flow runs. markDeletion skips already-deleted + // nodes, but the join still applies through the replace machinery — the delete is + // not swallowed. This is correct: the structural join merges the blocks while + // preserving the existing deletion marks on the text content. if (step.from !== step.to && step.slice.content.size === 0) { let hasInlineContent = false; newTr.doc.nodesBetween(step.from, step.to, (node) => {