Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment on lines 112 to +118

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep structural backspace blocked on non-empty items

This branch now treats deleteFrom === null as “effectively empty”, but findPreviousLiveCharPos() only looks at text before the caret. When the caret is at the start of a non-empty list item in suggesting mode, deleteFrom is also null, so pressing Backspace will apply the original ReplaceAroundStep untracked and lift/outdent the item, dropping list/paragraph properties instead of preserving them as tracked text changes. The direct structural step needs an additional check that the whole block/list item is already fully deleted, not just that there is no live character before the cursor.

Useful? React with 👍 / 👎.

map.appendMap(step.getMap());
}
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
});
});

Expand Down
Loading