Skip to content

Commit 3c549c9

Browse files
authored
fix: remove block sdt on delete press when cursor is on the first position (#3246)
* fix: remove block sdt on delete press when cursor is on the first position * fix: return false from command for sdtLocked
1 parent 6aa70b5 commit 3c549c9

5 files changed

Lines changed: 208 additions & 0 deletions

File tree

packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ type CoreCommandNames =
6262
| 'backspaceNextToRun'
6363
| 'backspaceAcrossRuns'
6464
| 'backspaceAtomBefore'
65+
| 'deleteBlockSdtAtTextBlockStart'
6566
| 'deleteSkipEmptyRun'
6667
| 'deleteNextToRun'
6768
| 'deleteAtomAfter'
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { Selection } from 'prosemirror-state';
2+
3+
function isSdtContentFullyLocked(node) {
4+
return node.attrs.lockMode === 'sdtContentLocked';
5+
}
6+
7+
function findAncestorDepth($pos, predicate) {
8+
for (let depth = $pos.depth; depth > 0; depth -= 1) {
9+
if (predicate($pos.node(depth))) return depth;
10+
}
11+
return null;
12+
}
13+
14+
/**
15+
* Deletes the block SDT wrapper from the start of its first paragraph.
16+
*
17+
* @returns {import('@core/commands/types').Command}
18+
*/
19+
export const deleteBlockSdtAtTextBlockStart =
20+
() =>
21+
({ state, dispatch }) => {
22+
const { selection } = state;
23+
if (!selection.empty) return false;
24+
25+
const { $from } = selection;
26+
const sdtDepth = findAncestorDepth($from, (node) => node.type.name === 'structuredContentBlock');
27+
if (sdtDepth == null) return false;
28+
29+
const textblockDepth = findAncestorDepth($from, (node) => node.isTextblock);
30+
if (textblockDepth !== sdtDepth + 1) return false;
31+
if ($from.node(textblockDepth).type.name !== 'paragraph') return false;
32+
if ($from.pos !== $from.start(textblockDepth)) return false;
33+
if ($from.before(textblockDepth) !== $from.start(sdtDepth)) return false;
34+
35+
const sdtNode = $from.node(sdtDepth);
36+
const lockMode = sdtNode.attrs.lockMode;
37+
// Wrapper deletion is blocked for sdtLocked / sdtContentLocked (see createStructuredContentLockPlugin).
38+
// For sdtLocked, content edits must still work — returning true here consumed Delete without
39+
// dispatching, so the first character of the first paragraph was undeletable at this caret.
40+
if (lockMode === 'sdtLocked') return false;
41+
if (isSdtContentFullyLocked(sdtNode)) return true;
42+
43+
if (dispatch) {
44+
const from = $from.before(sdtDepth);
45+
const tr = state.tr.delete(from, from + sdtNode.nodeSize);
46+
const selectionPos = Math.min(from, tr.doc.content.size);
47+
dispatch(tr.setSelection(Selection.near(tr.doc.resolve(selectionPos), -1)).scrollIntoView());
48+
}
49+
50+
return true;
51+
};
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { Schema } from 'prosemirror-model';
3+
import { EditorState, TextSelection } from 'prosemirror-state';
4+
import { deleteBlockSdtAtTextBlockStart } from './deleteBlockSdtAtTextBlockStart.js';
5+
6+
const makeSchema = () =>
7+
new Schema({
8+
nodes: {
9+
doc: { content: 'block+' },
10+
paragraph: { group: 'block', content: 'inline*' },
11+
structuredContentBlock: {
12+
group: 'block',
13+
content: 'block*',
14+
isolating: true,
15+
attrs: {
16+
lockMode: { default: 'unlocked' },
17+
},
18+
},
19+
text: { group: 'inline' },
20+
},
21+
marks: {},
22+
});
23+
24+
const makeDoc = (schema, lockMode = 'unlocked', sdtContent = [schema.node('paragraph', null, schema.text('Inner'))]) =>
25+
schema.node('doc', null, [
26+
schema.node('paragraph', null, schema.text('Before')),
27+
schema.node('structuredContentBlock', { lockMode }, sdtContent),
28+
schema.node('paragraph', null, schema.text('After')),
29+
]);
30+
31+
const findBlockSdt = (doc) => {
32+
let result = null;
33+
doc.descendants((node, pos) => {
34+
if (node.type.name === 'structuredContentBlock') {
35+
result = { node, pos, end: pos + node.nodeSize };
36+
return false;
37+
}
38+
return true;
39+
});
40+
return result;
41+
};
42+
43+
const paragraphStartInSdt = (doc, index = 0) => {
44+
const sdt = findBlockSdt(doc);
45+
expect(sdt).not.toBeNull();
46+
47+
let seen = 0;
48+
let start = null;
49+
sdt.node.descendants((node, offset) => {
50+
if (node.type.name !== 'paragraph') return true;
51+
if (seen === index) {
52+
start = sdt.pos + 1 + offset + 1;
53+
return false;
54+
}
55+
seen += 1;
56+
return true;
57+
});
58+
59+
expect(start).not.toBeNull();
60+
return start;
61+
};
62+
63+
describe('deleteBlockSdtAtTextBlockStart', () => {
64+
it('deletes an unlocked block SDT when the caret is at the start of its first paragraph', () => {
65+
const schema = makeSchema();
66+
const doc = makeDoc(schema);
67+
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, paragraphStartInSdt(doc)) });
68+
69+
let dispatched;
70+
const ok = deleteBlockSdtAtTextBlockStart()({
71+
state,
72+
dispatch: (tr) => {
73+
dispatched = tr;
74+
},
75+
});
76+
77+
expect(ok).toBe(true);
78+
expect(dispatched).toBeDefined();
79+
expect(findBlockSdt(dispatched.doc)).toBeNull();
80+
expect(dispatched.doc.textContent).toBe('BeforeAfter');
81+
});
82+
83+
it('returns false for sdtLocked so Delete can fall through for in-SDT content edits', () => {
84+
const schema = makeSchema();
85+
const doc = makeDoc(schema, 'sdtLocked');
86+
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, paragraphStartInSdt(doc)) });
87+
const dispatch = vi.fn();
88+
89+
const ok = deleteBlockSdtAtTextBlockStart()({ state, dispatch });
90+
91+
expect(ok).toBe(false);
92+
expect(dispatch).not.toHaveBeenCalled();
93+
});
94+
95+
it('consumes sdtContentLocked block SDT wrapper delete without dispatching', () => {
96+
const schema = makeSchema();
97+
const doc = makeDoc(schema, 'sdtContentLocked');
98+
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, paragraphStartInSdt(doc)) });
99+
const dispatch = vi.fn();
100+
101+
const ok = deleteBlockSdtAtTextBlockStart()({ state, dispatch });
102+
103+
expect(ok).toBe(true);
104+
expect(dispatch).not.toHaveBeenCalled();
105+
});
106+
107+
it('returns false when the caret is not at the paragraph start', () => {
108+
const schema = makeSchema();
109+
const doc = makeDoc(schema);
110+
const state = EditorState.create({
111+
schema,
112+
doc,
113+
selection: TextSelection.create(doc, paragraphStartInSdt(doc) + 1),
114+
});
115+
const dispatch = vi.fn();
116+
117+
const ok = deleteBlockSdtAtTextBlockStart()({ state, dispatch });
118+
119+
expect(ok).toBe(false);
120+
expect(dispatch).not.toHaveBeenCalled();
121+
});
122+
123+
it('returns false from later paragraphs inside the same block SDT', () => {
124+
const schema = makeSchema();
125+
const doc = makeDoc(schema, 'unlocked', [
126+
schema.node('paragraph', null, schema.text('First')),
127+
schema.node('paragraph', null, schema.text('Second')),
128+
]);
129+
const state = EditorState.create({
130+
schema,
131+
doc,
132+
selection: TextSelection.create(doc, paragraphStartInSdt(doc, 1)),
133+
});
134+
const dispatch = vi.fn();
135+
136+
const ok = deleteBlockSdtAtTextBlockStart()({ state, dispatch });
137+
138+
expect(ok).toBe(false);
139+
expect(dispatch).not.toHaveBeenCalled();
140+
});
141+
142+
it('returns false outside a block SDT', () => {
143+
const schema = makeSchema();
144+
const doc = makeDoc(schema);
145+
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, 1) });
146+
const dispatch = vi.fn();
147+
148+
const ok = deleteBlockSdtAtTextBlockStart()({ state, dispatch });
149+
150+
expect(ok).toBe(false);
151+
expect(dispatch).not.toHaveBeenCalled();
152+
});
153+
});

packages/super-editor/src/editors/v1/core/commands/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export * from './backspaceSkipEmptyRun.js';
5252
export * from './backspaceNextToRun.js';
5353
export * from './backspaceAcrossRuns.js';
5454
export * from './backspaceAtomBefore.js';
55+
export * from './deleteBlockSdtAtTextBlockStart.js';
5556
export * from './deleteSkipEmptyRun.js';
5657
export * from './deleteNextToRun.js';
5758
export * from './deleteAtomAfter.js';

packages/super-editor/src/editors/v1/core/extensions/keymap.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export const handleBackspace = (editor) => {
3737
tr.setMeta('inputType', 'deleteContentBackward');
3838
return false;
3939
},
40+
() => commands.deleteBlockSdtAtTextBlockStart(),
4041
() => commands.backspaceEmptyRunParagraph(),
4142
() => commands.backspaceSkipEmptyRun(),
4243
() => commands.backspaceAtomBefore(),
@@ -55,6 +56,7 @@ export const handleDelete = (editor) => {
5556
dispatchHistoryBoundary(view);
5657

5758
return editor.commands.first(({ commands }) => [
59+
() => commands.deleteBlockSdtAtTextBlockStart(),
5860
() => commands.deleteSkipEmptyRun(),
5961
() => commands.deleteAtomAfter(),
6062
() => commands.deleteNextToRun(),

0 commit comments

Comments
 (0)