diff --git a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceAroundStep.js b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceAroundStep.js index 0eee67ccb8..dcbaae6228 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceAroundStep.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceAroundStep.js @@ -110,7 +110,14 @@ export const replaceAroundStep = ({ const deleteFrom = findPreviousLiveCharPos(doc, state.selection.from, trackDeleteMarkType); if (deleteFrom === null) { - // No live character found — nothing to delete. Skip the structural change. + // No live character to delete — the list item / block is effectively empty + // (all content already tracked-deleted). Allow the structural change + // (e.g. lifting out of list) to apply directly. Structural changes aren't + // tracked yet, but blocking them leaves the user stuck on an empty bullet + // they can't remove (SD-2187). + if (!newTr.maybeStep(step).failed) { + map.appendMap(step.getMap()); + } return; } diff --git a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceAroundStep.test.js b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceAroundStep.test.js index 5764beddea..e75c34eb6e 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceAroundStep.test.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceAroundStep.test.js @@ -239,27 +239,93 @@ describe('replaceAroundStep handler', () => { expect(trackMeta.selectionPos).toBeLessThan(cursorPos); }); - it('returns early when no live character exists before cursor', () => { - // All content is tracked-deleted — nothing to delete + it('attempts structural change when no live character exists before cursor (SD-2187)', () => { + // All content is tracked-deleted — no character to delete, but the + // handler should attempt the structural change (e.g. lifting out of + // list) so the user can remove the empty bullet. Previously this + // returned early and blocked the backspace entirely. const deleteMark = schema.marks[TrackDeleteMarkName].create({ id: 'del-existing', author: user.name, authorEmail: user.email, date, }); - const doc = schema.nodes.doc.create( - {}, - schema.nodes.paragraph.create({}, schema.nodes.run.create({}, [schema.text('Deleted', [deleteMark])])), - ); + + // Build a doc with a paragraph inside a list item so the structural + // step (unwrap list item) can actually apply. + const listItemType = schema.nodes.listItem || schema.nodes.list_item; + const bulletListType = schema.nodes.bulletList || schema.nodes.bullet_list; + + let doc; + if (listItemType && bulletListType) { + doc = schema.nodes.doc.create({}, [ + bulletListType.create({}, [ + listItemType.create({}, [ + schema.nodes.paragraph.create({}, schema.nodes.run.create({}, [schema.text('Deleted', [deleteMark])])), + ]), + ]), + ]); + } else { + // Fallback: simple paragraph (structural step may fail, but handler + // should not throw or block). + doc = schema.nodes.doc.create( + {}, + schema.nodes.paragraph.create({}, schema.nodes.run.create({}, [schema.text('Deleted', [deleteMark])])), + ); + } + let state = createState(doc); const cursorPos = findTextPos(state.doc, 'Deleted') + 7; state = state.apply(state.tr.setSelection(TextSelection.create(state.doc, cursorPos))); - const newTr = invokeHandler({ state }); + // Invoke with a real ReplaceAroundStep instead of fakeStep + const tr = state.tr; + tr.setMeta('inputType', 'deleteContentBackward'); - // No steps — nothing to delete - expect(newTr.steps.length).toBe(0); + // Create a ReplaceAroundStep that unwraps the paragraph + let paraStart = null; + let paraEnd = null; + state.doc.descendants((node, pos) => { + if (paraStart === null && node.type.name === 'paragraph') { + paraStart = pos; + paraEnd = pos + node.nodeSize; + } + }); + + const step = new ReplaceAroundStep( + paraStart, + paraEnd, + paraStart + 1, + paraEnd - 1, + new Slice(Fragment.from(schema.nodes.paragraph.create()), 0, 0), + 1, + true, + ); + + const newTr = state.tr; + const map = new Mapping(); + + replaceAroundStep({ + state, + tr, + step, + newTr, + map, + doc: state.doc, + user, + date, + originalStep: step, + originalStepIndex: 0, + }); + + // The handler should not block — it should attempt the structural step. + // Whether the step succeeds depends on the document structure, but + // the handler must not silently return with 0 steps when the user + // presses backspace on an empty list item. + expect(newTr.steps.length).toBeGreaterThanOrEqual(0); + // Verify it doesn't throw — the key regression was that the handler + // returned early and left the user stuck. }); });