Skip to content

Commit 27869f8

Browse files
authored
Merge pull request #3555 from superdoc-dev/luccas/delete-image-content-locked-sdt
fix(super-editor): deletion of block SDTs
2 parents 1ee44eb + f4d5b20 commit 27869f8

11 files changed

Lines changed: 428 additions & 15 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ type CoreCommandNames =
6464
| 'backspaceAtomBefore'
6565
| 'selectInlineSdtBeforeRunStart'
6666
| 'selectInlineSdtAfterRunEnd'
67+
| 'selectBlockSdtBeforeTextBlockStart'
68+
| 'selectBlockSdtAfterTextBlockEnd'
6769
| 'deleteBlockSdtAtTextBlockStart'
6870
| 'moveIntoBlockSdtBeforeTextBlockStart'
6971
| 'moveIntoBlockSdtAfterTextBlockEnd'

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ describe('core command map types', () => {
1111

1212
expect(declaration).toContain("| 'selectInlineSdtBeforeRunStart'");
1313
expect(declaration).toContain("| 'selectInlineSdtAfterRunEnd'");
14+
expect(declaration).toContain("| 'selectBlockSdtBeforeTextBlockStart'");
15+
expect(declaration).toContain("| 'selectBlockSdtAfterTextBlockEnd'");
1416
expect(declaration).toContain("| 'moveIntoBlockSdtBeforeTextBlockStart'");
1517
expect(declaration).toContain("| 'moveIntoBlockSdtAfterTextBlockEnd'");
1618
});

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export * from './backspaceNextToRun.js';
5353
export * from './backspaceAcrossRuns.js';
5454
export * from './backspaceAtomBefore.js';
5555
export * from './selectInlineSdtBeforeRunStart.js';
56+
export * from './selectBlockSdtAtTextBlockBoundary.js';
5657
export * from './deleteBlockSdtAtTextBlockStart.js';
5758
export * from './moveIntoBlockSdtBeforeTextBlockStart.js';
5859
export * from './moveIntoBlockSdtAfterTextBlockEnd.js';
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { TextSelection } from 'prosemirror-state';
2+
import {
3+
findFirstContentCursorPosInNode,
4+
findLastContentCursorPosInNode,
5+
isZeroWidthMarker,
6+
} from './helpers/textPositions.js';
7+
8+
function findAncestorDepth($pos, predicate) {
9+
for (let depth = $pos.depth; depth > 0; depth -= 1) {
10+
if (predicate($pos.node(depth))) return depth;
11+
}
12+
return null;
13+
}
14+
15+
function findSiblingAcrossHiddenMarkers(doc, pos, direction) {
16+
let currentPos = pos;
17+
let node = direction === 'before' ? doc.resolve(currentPos).nodeBefore : doc.resolve(currentPos).nodeAfter;
18+
19+
while (node && isZeroWidthMarker(node)) {
20+
currentPos += direction === 'before' ? -node.nodeSize : node.nodeSize;
21+
node = direction === 'before' ? doc.resolve(currentPos).nodeBefore : doc.resolve(currentPos).nodeAfter;
22+
}
23+
24+
return {
25+
node,
26+
nodePos: direction === 'before' && node ? currentPos - node.nodeSize : currentPos,
27+
};
28+
}
29+
30+
function isAtTextBlockBoundary($from, direction) {
31+
const textblockDepth = findAncestorDepth($from, (node) => node.isTextblock);
32+
if (textblockDepth == null) return null;
33+
34+
const textblock = $from.node(textblockDepth);
35+
const textblockPos = $from.before(textblockDepth);
36+
const boundary =
37+
direction === 'before'
38+
? (findFirstContentCursorPosInNode(textblock, textblockPos) ?? $from.start(textblockDepth))
39+
: (findLastContentCursorPosInNode(textblock, textblockPos) ?? $from.end(textblockDepth));
40+
if ($from.pos !== boundary) return null;
41+
42+
return {
43+
textblockDepth,
44+
textblockPos,
45+
};
46+
}
47+
48+
function selectAdjacentBlockSdtContent(direction) {
49+
return ({ state, dispatch }) => {
50+
const { selection } = state;
51+
if (!selection.empty) return false;
52+
53+
const boundary = isAtTextBlockBoundary(selection.$from, direction);
54+
if (!boundary) return false;
55+
56+
const siblingBoundaryPos =
57+
direction === 'before' ? boundary.textblockPos : selection.$from.after(boundary.textblockDepth);
58+
const { node, nodePos } = findSiblingAcrossHiddenMarkers(state.doc, siblingBoundaryPos, direction);
59+
if (node?.type.name !== 'structuredContentBlock') return false;
60+
if (node.content.size === 0) return false;
61+
62+
const contentStart = findFirstContentCursorPosInNode(node, nodePos);
63+
const contentEnd = findLastContentCursorPosInNode(node, nodePos);
64+
if (contentStart == null || contentEnd == null) return false;
65+
if (contentStart >= contentEnd) return false;
66+
67+
if (dispatch) {
68+
dispatch(state.tr.setSelection(TextSelection.create(state.doc, contentStart, contentEnd)).scrollIntoView());
69+
}
70+
71+
return true;
72+
};
73+
}
74+
75+
/**
76+
* Selects previous block SDT content when Backspace is pressed at the start of
77+
* the following textblock.
78+
*
79+
* @returns {import('@core/commands/types').Command}
80+
*/
81+
export const selectBlockSdtBeforeTextBlockStart = () => selectAdjacentBlockSdtContent('before');
82+
83+
/**
84+
* Selects next block SDT content when Delete is pressed at the end of the
85+
* preceding textblock.
86+
*
87+
* @returns {import('@core/commands/types').Command}
88+
*/
89+
export const selectBlockSdtAfterTextBlockEnd = () => selectAdjacentBlockSdtContent('after');
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { Schema } from 'prosemirror-model';
3+
import { EditorState, TextSelection } from 'prosemirror-state';
4+
import {
5+
selectBlockSdtAfterTextBlockEnd,
6+
selectBlockSdtBeforeTextBlockStart,
7+
} from './selectBlockSdtAtTextBlockBoundary.js';
8+
import { findFirstContentCursorPosInNode, findLastContentCursorPosInNode } from './helpers/textPositions.js';
9+
10+
const makeSchema = () =>
11+
new Schema({
12+
nodes: {
13+
doc: { content: 'block+' },
14+
paragraph: { group: 'block', content: 'inline*' },
15+
run: { inline: true, group: 'inline', content: 'inline*' },
16+
structuredContentBlock: {
17+
group: 'block',
18+
content: 'block*',
19+
isolating: true,
20+
attrs: {
21+
lockMode: { default: 'unlocked' },
22+
},
23+
},
24+
mathBlock: { group: 'block', atom: true },
25+
image: { inline: true, group: 'inline', atom: true },
26+
text: { group: 'inline' },
27+
},
28+
marks: {},
29+
});
30+
31+
const run = (schema, text) => schema.nodes.run.create(null, schema.text(text));
32+
const paragraph = (schema, text) => schema.nodes.paragraph.create(null, run(schema, text));
33+
34+
const findTextPos = (doc, text, offset = 0) => {
35+
let found = null;
36+
doc.descendants((node, pos) => {
37+
if (!node.isText || found != null) return found == null;
38+
const index = node.text.indexOf(text);
39+
if (index === -1) return true;
40+
found = pos + index + offset;
41+
return false;
42+
});
43+
expect(found).not.toBeNull();
44+
return found;
45+
};
46+
47+
const findBlockSdt = (doc) => {
48+
let result = null;
49+
doc.descendants((node, pos) => {
50+
if (node.type.name !== 'structuredContentBlock') return true;
51+
result = { node, pos, end: pos + node.nodeSize };
52+
return false;
53+
});
54+
expect(result).not.toBeNull();
55+
return result;
56+
};
57+
58+
const findBlockSdtByText = (doc, text) => {
59+
let result = null;
60+
doc.descendants((node, pos) => {
61+
if (node.type.name !== 'structuredContentBlock' || node.textContent !== text) return true;
62+
result = { node, pos, end: pos + node.nodeSize };
63+
return false;
64+
});
65+
expect(result).not.toBeNull();
66+
return result;
67+
};
68+
69+
const makeDoc = (schema, lockMode = 'contentLocked') => {
70+
const imageRun = schema.nodes.run.create(null, schema.nodes.image.create());
71+
const sdt = schema.nodes.structuredContentBlock.create({ lockMode }, [
72+
paragraph(schema, 'Inner text'),
73+
schema.nodes.paragraph.create(null, imageRun),
74+
]);
75+
return schema.node('doc', null, [paragraph(schema, 'Before'), sdt, paragraph(schema, 'After')]);
76+
};
77+
78+
const makeNestedDoc = (schema, lockMode = 'contentLocked') => {
79+
const innerSdt = schema.nodes.structuredContentBlock.create({ lockMode }, [paragraph(schema, 'Nested text')]);
80+
const outerSdt = schema.nodes.structuredContentBlock.create({ lockMode: 'unlocked' }, [
81+
paragraph(schema, 'Outer before'),
82+
innerSdt,
83+
paragraph(schema, 'Outer after'),
84+
]);
85+
return schema.node('doc', null, [paragraph(schema, 'Before'), outerSdt, paragraph(schema, 'After')]);
86+
};
87+
88+
const makeEmptyBlockSdtDoc = (schema) => {
89+
const sdt = schema.nodes.structuredContentBlock.create({ lockMode: 'contentLocked' }, [
90+
schema.nodes.paragraph.create(),
91+
]);
92+
return schema.node('doc', null, [paragraph(schema, 'Before'), sdt, paragraph(schema, 'After')]);
93+
};
94+
95+
const makeAtomBlockSdtDoc = (schema) => {
96+
const sdt = schema.nodes.structuredContentBlock.create({ lockMode: 'contentLocked' }, [
97+
schema.nodes.mathBlock.create(),
98+
]);
99+
return schema.node('doc', null, [paragraph(schema, 'Before'), sdt, paragraph(schema, 'After')]);
100+
};
101+
102+
describe('selectBlockSdtBeforeTextBlockStart', () => {
103+
it.each(['unlocked', 'sdtLocked', 'contentLocked', 'sdtContentLocked'])(
104+
'selects the previous %s block SDT content from the following textblock start',
105+
(lockMode) => {
106+
const schema = makeSchema();
107+
const doc = makeDoc(schema, lockMode);
108+
const sdt = findBlockSdt(doc);
109+
const afterStart = findTextPos(doc, 'After');
110+
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) });
111+
112+
let dispatched;
113+
const ok = selectBlockSdtBeforeTextBlockStart()({ state, dispatch: (tr) => (dispatched = tr) });
114+
115+
expect(ok).toBe(true);
116+
expect(dispatched).toBeDefined();
117+
expect(dispatched.selection).toBeInstanceOf(TextSelection);
118+
expect(dispatched.selection.from).toBe(findFirstContentCursorPosInNode(sdt.node, sdt.pos));
119+
expect(dispatched.selection.to).toBe(findLastContentCursorPosInNode(sdt.node, sdt.pos));
120+
},
121+
);
122+
123+
it('returns false away from the following textblock start', () => {
124+
const schema = makeSchema();
125+
const doc = makeDoc(schema);
126+
const state = EditorState.create({
127+
schema,
128+
doc,
129+
selection: TextSelection.create(doc, findTextPos(doc, 'After', 1)),
130+
});
131+
const dispatch = vi.fn();
132+
133+
const ok = selectBlockSdtBeforeTextBlockStart()({ state, dispatch });
134+
135+
expect(ok).toBe(false);
136+
expect(dispatch).not.toHaveBeenCalled();
137+
});
138+
139+
it.each([
140+
['empty paragraph', makeEmptyBlockSdtDoc],
141+
['block atom', makeAtomBlockSdtDoc],
142+
])('returns false for previous block SDT with %s content', (_, makeAdjacentDoc) => {
143+
const schema = makeSchema();
144+
const doc = makeAdjacentDoc(schema);
145+
const afterStart = findTextPos(doc, 'After');
146+
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) });
147+
const dispatch = vi.fn();
148+
149+
const ok = selectBlockSdtBeforeTextBlockStart()({ state, dispatch });
150+
151+
expect(ok).toBe(false);
152+
expect(dispatch).not.toHaveBeenCalled();
153+
});
154+
155+
it('selects nested previous block SDT content from the following nested textblock start', () => {
156+
const schema = makeSchema();
157+
const doc = makeNestedDoc(schema);
158+
const nestedSdt = findBlockSdtByText(doc, 'Nested text');
159+
const outerAfterStart = findTextPos(doc, 'Outer after');
160+
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, outerAfterStart) });
161+
162+
let dispatched;
163+
const ok = selectBlockSdtBeforeTextBlockStart()({ state, dispatch: (tr) => (dispatched = tr) });
164+
165+
expect(ok).toBe(true);
166+
expect(dispatched.selection.from).toBe(findFirstContentCursorPosInNode(nestedSdt.node, nestedSdt.pos));
167+
expect(dispatched.selection.to).toBe(findLastContentCursorPosInNode(nestedSdt.node, nestedSdt.pos));
168+
});
169+
});
170+
171+
describe('selectBlockSdtAfterTextBlockEnd', () => {
172+
it.each(['unlocked', 'sdtLocked', 'contentLocked', 'sdtContentLocked'])(
173+
'selects the next %s block SDT content from the preceding textblock end',
174+
(lockMode) => {
175+
const schema = makeSchema();
176+
const doc = makeDoc(schema, lockMode);
177+
const sdt = findBlockSdt(doc);
178+
const beforeEnd = findTextPos(doc, 'Before', 'Before'.length);
179+
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) });
180+
181+
let dispatched;
182+
const ok = selectBlockSdtAfterTextBlockEnd()({ state, dispatch: (tr) => (dispatched = tr) });
183+
184+
expect(ok).toBe(true);
185+
expect(dispatched).toBeDefined();
186+
expect(dispatched.selection).toBeInstanceOf(TextSelection);
187+
expect(dispatched.selection.from).toBe(findFirstContentCursorPosInNode(sdt.node, sdt.pos));
188+
expect(dispatched.selection.to).toBe(findLastContentCursorPosInNode(sdt.node, sdt.pos));
189+
},
190+
);
191+
192+
it.each([
193+
['empty paragraph', makeEmptyBlockSdtDoc],
194+
['block atom', makeAtomBlockSdtDoc],
195+
])('returns false for next block SDT with %s content', (_, makeAdjacentDoc) => {
196+
const schema = makeSchema();
197+
const doc = makeAdjacentDoc(schema);
198+
const beforeEnd = findTextPos(doc, 'Before', 'Before'.length);
199+
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) });
200+
const dispatch = vi.fn();
201+
202+
const ok = selectBlockSdtAfterTextBlockEnd()({ state, dispatch });
203+
204+
expect(ok).toBe(false);
205+
expect(dispatch).not.toHaveBeenCalled();
206+
});
207+
208+
it('selects nested next block SDT content from the preceding nested textblock end', () => {
209+
const schema = makeSchema();
210+
const doc = makeNestedDoc(schema);
211+
const nestedSdt = findBlockSdtByText(doc, 'Nested text');
212+
const outerBeforeEnd = findTextPos(doc, 'Outer before', 'Outer before'.length);
213+
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, outerBeforeEnd) });
214+
215+
let dispatched;
216+
const ok = selectBlockSdtAfterTextBlockEnd()({ state, dispatch: (tr) => (dispatched = tr) });
217+
218+
expect(ok).toBe(true);
219+
expect(dispatched.selection.from).toBe(findFirstContentCursorPosInNode(nestedSdt.node, nestedSdt.pos));
220+
expect(dispatched.selection.to).toBe(findLastContentCursorPosInNode(nestedSdt.node, nestedSdt.pos));
221+
});
222+
});

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ describe('handleBackspace chain ordering', () => {
3838
undoInputRule: make('undoInputRule'),
3939
deleteBlockSdtAtTextBlockStart: make('deleteBlockSdtAtTextBlockStart'),
4040
selectInlineSdtBeforeRunStart: make('selectInlineSdtBeforeRunStart'),
41+
selectBlockSdtBeforeTextBlockStart: make('selectBlockSdtBeforeTextBlockStart'),
4142
moveIntoBlockSdtBeforeTextBlockStart: make('moveIntoBlockSdtBeforeTextBlockStart'),
4243
backspaceEmptyRunParagraph: make('backspaceEmptyRunParagraph'),
4344
backspaceSkipEmptyRun: make('backspaceSkipEmptyRun'),
@@ -76,6 +77,7 @@ describe('handleBackspace chain ordering', () => {
7677
// step 2 sets inputType meta and returns false (no command call)
7778
'deleteBlockSdtAtTextBlockStart',
7879
'selectInlineSdtBeforeRunStart',
80+
'selectBlockSdtBeforeTextBlockStart',
7981
'moveIntoBlockSdtBeforeTextBlockStart',
8082
'backspaceEmptyRunParagraph',
8183
'backspaceSkipEmptyRun',
@@ -107,7 +109,8 @@ describe('handleBackspace chain ordering', () => {
107109
expect(callLog[0]).toBe('undoInputRule');
108110
expect(callLog[1]).toBe('deleteBlockSdtAtTextBlockStart');
109111
expect(callLog[2]).toBe('selectInlineSdtBeforeRunStart');
110-
expect(callLog[3]).toBe('moveIntoBlockSdtBeforeTextBlockStart');
112+
expect(callLog[3]).toBe('selectBlockSdtBeforeTextBlockStart');
113+
expect(callLog[4]).toBe('moveIntoBlockSdtBeforeTextBlockStart');
111114
});
112115

113116
it('places mixedBidiBackspace after backspaceAcrossRuns and before deleteSelection', () => {
@@ -179,6 +182,7 @@ describe('handleDelete chain ordering', () => {
179182
const commands = {
180183
deleteBlockSdtAtTextBlockStart: make('deleteBlockSdtAtTextBlockStart'),
181184
selectInlineSdtAfterRunEnd: make('selectInlineSdtAfterRunEnd'),
185+
selectBlockSdtAfterTextBlockEnd: make('selectBlockSdtAfterTextBlockEnd'),
182186
moveIntoBlockSdtAfterTextBlockEnd: make('moveIntoBlockSdtAfterTextBlockEnd'),
183187
deleteSkipEmptyRun: make('deleteSkipEmptyRun'),
184188
deleteAtomAfter: make('deleteAtomAfter'),
@@ -212,6 +216,7 @@ describe('handleDelete chain ordering', () => {
212216
expect(callLog).toEqual([
213217
'deleteBlockSdtAtTextBlockStart',
214218
'selectInlineSdtAfterRunEnd',
219+
'selectBlockSdtAfterTextBlockEnd',
215220
'moveIntoBlockSdtAfterTextBlockEnd',
216221
'deleteSkipEmptyRun',
217222
'deleteAtomAfter',

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export const handleBackspace = (editor) => {
3939
},
4040
() => commands.deleteBlockSdtAtTextBlockStart(),
4141
() => commands.selectInlineSdtBeforeRunStart(),
42+
() => commands.selectBlockSdtBeforeTextBlockStart(),
4243
() => commands.moveIntoBlockSdtBeforeTextBlockStart(),
4344
() => commands.backspaceEmptyRunParagraph(),
4445
() => commands.backspaceSkipEmptyRun(),
@@ -61,6 +62,7 @@ export const handleDelete = (editor) => {
6162
return editor.commands.first(({ commands }) => [
6263
() => commands.deleteBlockSdtAtTextBlockStart(),
6364
() => commands.selectInlineSdtAfterRunEnd(),
65+
() => commands.selectBlockSdtAfterTextBlockEnd(),
6466
() => commands.moveIntoBlockSdtAfterTextBlockEnd(),
6567
() => commands.deleteSkipEmptyRun(),
6668
() => commands.deleteAtomAfter(),

0 commit comments

Comments
 (0)