Skip to content

Commit 4ee24ac

Browse files
caio-pizzolsuperdoc-oss-port[bot]
authored andcommitted
fix(super-editor): reject overlapping metadata anchors and restore selection after remove (IT-1230, IT-1231)
Reject attaching an anchored-metadata range that overlaps an existing anchor (INVALID_TARGET) instead of open-slicing it into a duplicate id/tag; normalize the selection on remove so a stale NodeSelection cannot strand the caret; store the canonical run-wrapped SDT content shape. Regression-tested against a real editor and schema. Ported-From-Source-Repo: superdoc/orbit Ported-From-Source-Commit: e50e2de7398852f9cd9a60c1593013c677553b84 Ported-Public-Prefix: superdoc/public
1 parent 8d6c32a commit 4ee24ac

2 files changed

Lines changed: 417 additions & 12 deletions

File tree

Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
import { afterEach, describe, expect, it } from 'vitest';
2+
import type { Node as ProseMirrorNode } from 'prosemirror-model';
3+
import { NodeSelection, TextSelection } from 'prosemirror-state';
4+
import type { SelectionTarget } from '@superdoc/document-api';
5+
import type { Editor } from '../../core/Editor.js';
6+
import { resolveSelectionTarget } from '../helpers/selection-target-resolver.js';
7+
import { metadataAttachWrapper, metadataListWrapper, metadataRemoveWrapper } from './anchored-metadata-wrappers.js';
8+
import { registerBuiltInExecutors } from './register-executors.js';
9+
import { initTestEditor } from '@tests/helpers/helpers.js';
10+
11+
registerBuiltInExecutors();
12+
13+
const BLOCK_ID = 'metadata-anchor-p1';
14+
const NAMESPACE = 'urn:test:metadata';
15+
16+
type FoundAnchor = {
17+
node: ProseMirrorNode;
18+
pos: number;
19+
};
20+
21+
function makeEditor(text = 'abcdefghij'): Editor {
22+
return initTestEditor({
23+
loadFromSchema: true,
24+
content: {
25+
type: 'doc',
26+
content: [
27+
{
28+
type: 'paragraph',
29+
attrs: { paraId: BLOCK_ID, sdBlockId: BLOCK_ID },
30+
content: [{ type: 'run', attrs: {}, content: [{ type: 'text', text }] }],
31+
},
32+
],
33+
},
34+
user: { name: 'Integration User', email: 'integration@example.com' },
35+
}).editor as Editor;
36+
}
37+
38+
function textTarget(start: number, end: number): SelectionTarget {
39+
return {
40+
kind: 'selection',
41+
start: { kind: 'text', blockId: BLOCK_ID, offset: start },
42+
end: { kind: 'text', blockId: BLOCK_ID, offset: end },
43+
};
44+
}
45+
46+
function findInlineAnchors(editor: Editor): FoundAnchor[] {
47+
const anchors: FoundAnchor[] = [];
48+
editor.state.doc.descendants((node, pos) => {
49+
if (node.type.name === 'structuredContent') anchors.push({ node, pos });
50+
return true;
51+
});
52+
return anchors;
53+
}
54+
55+
// Headless test editors have no mounted view; dispatch mirrors the
56+
// adapter's own view-or-editor fallback.
57+
function dispatchTr(editor: Editor, tr: ReturnType<Editor['state']['tr']['setSelection']>): void {
58+
if (editor.view?.dispatch) {
59+
editor.view.dispatch(tr);
60+
return;
61+
}
62+
editor.dispatch(tr);
63+
}
64+
65+
function selectFormerAnchorRange(editor: Editor, target: SelectionTarget): void {
66+
const { absFrom, absTo } = resolveSelectionTarget(editor, target);
67+
dispatchTr(editor, editor.state.tr.setSelection(TextSelection.create(editor.state.doc, absFrom, absTo)));
68+
}
69+
70+
describe('anchored metadata anchors', () => {
71+
let editor: Editor | undefined;
72+
73+
afterEach(() => {
74+
editor?.destroy();
75+
editor = undefined;
76+
});
77+
78+
it('preserves an unrelated selection when removing an anchor by id', () => {
79+
editor = makeEditor();
80+
81+
const attached = metadataAttachWrapper(editor, {
82+
id: 'meta-a',
83+
target: textTarget(6, 9),
84+
namespace: NAMESPACE,
85+
payload: { label: 'A' },
86+
});
87+
expect(attached.success).toBe(true);
88+
89+
// Park the user's selection well before the anchor.
90+
dispatchTr(editor, editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 2, 4)));
91+
const before = editor.state.selection;
92+
93+
const removed = metadataRemoveWrapper(editor, { id: 'meta-a' });
94+
expect(removed.success).toBe(true);
95+
96+
// Removal by id must not steal a selection that never touched the anchor.
97+
expect(editor.state.selection).toBeInstanceOf(TextSelection);
98+
expect(editor.state.selection.from).toBe(before.from);
99+
expect(editor.state.selection.to).toBe(before.to);
100+
});
101+
102+
it('does not reject attach over a foreign content control whose tag collides with a metadata id', () => {
103+
editor = makeEditor();
104+
105+
const attached = metadataAttachWrapper(editor, {
106+
id: 'meta-a',
107+
target: textTarget(1, 3),
108+
namespace: NAMESPACE,
109+
payload: { label: 'A' },
110+
});
111+
expect(attached.success).toBe(true);
112+
113+
// Forge a foreign inline content control elsewhere whose w:tag equals the
114+
// stored metadata id but which was not created by metadata.attach (no
115+
// 'Anchored metadata' alias, no hidden appearance).
116+
const anchors = findInlineAnchors(editor);
117+
expect(anchors.length).toBe(1);
118+
const sdtType = anchors[0]!.node.type;
119+
const { absFrom, absTo } = resolveSelectionTarget(editor, textTarget(6, 7));
120+
const foreign = sdtType.create(
121+
{ id: '999999', tag: 'meta-a', alias: 'Customer control', controlType: 'richText', type: 'richText' },
122+
editor.state.schema.text('x'),
123+
);
124+
dispatchTr(editor, editor.state.tr.replaceWith(absFrom, absTo, foreign));
125+
126+
// Attaching over the foreign control must not be misclassified as an
127+
// overlap with our own anchor.
128+
const second = metadataAttachWrapper(editor, {
129+
id: 'meta-b',
130+
target: textTarget(5, 8),
131+
namespace: NAMESPACE,
132+
payload: { label: 'B' },
133+
});
134+
expect(second.success).toBe(true);
135+
});
136+
137+
it('rejects a partially overlapping attach without duplicating the existing anchor', () => {
138+
editor = makeEditor();
139+
140+
const first = metadataAttachWrapper(editor, {
141+
id: 'meta-a',
142+
target: textTarget(2, 6),
143+
namespace: NAMESPACE,
144+
payload: { label: 'A' },
145+
});
146+
expect(first.success).toBe(true);
147+
const before = editor.state.doc.toJSON();
148+
149+
const crossing = metadataAttachWrapper(editor, {
150+
id: 'meta-b',
151+
target: textTarget(4, 8),
152+
namespace: NAMESPACE,
153+
payload: { label: 'B' },
154+
});
155+
156+
expect(crossing).toMatchObject({ success: false, failure: { code: 'INVALID_TARGET' } });
157+
expect(editor.state.doc.toJSON()).toEqual(before);
158+
159+
const anchors = findInlineAnchors(editor);
160+
expect(anchors).toHaveLength(1);
161+
expect(anchors[0].node.attrs.tag).toBe('meta-a');
162+
expect(anchors[0].node.textContent).toBe('cdef');
163+
expect(new Set(anchors.map((anchor) => anchor.node.attrs.tag)).size).toBe(anchors.length);
164+
expect(new Set(anchors.map((anchor) => anchor.node.attrs.id)).size).toBe(anchors.length);
165+
expect(metadataListWrapper(editor).items.map((item) => item.id)).toEqual(['meta-a']);
166+
});
167+
168+
it.each([
169+
{ label: 'inside an existing anchor', target: textTarget(3, 5) },
170+
{ label: 'around an existing anchor', target: textTarget(1, 8) },
171+
])('rejects an attach $label without mutating the existing anchor', ({ target }) => {
172+
editor = makeEditor();
173+
174+
const first = metadataAttachWrapper(editor, {
175+
id: 'meta-a',
176+
target: textTarget(2, 6),
177+
namespace: NAMESPACE,
178+
payload: { label: 'A' },
179+
});
180+
expect(first.success).toBe(true);
181+
const before = editor.state.doc.toJSON();
182+
183+
const overlapping = metadataAttachWrapper(editor, {
184+
id: 'meta-b',
185+
target,
186+
namespace: NAMESPACE,
187+
payload: { label: 'B' },
188+
});
189+
190+
expect(overlapping).toMatchObject({ success: false, failure: { code: 'INVALID_TARGET' } });
191+
expect(editor.state.doc.toJSON()).toEqual(before);
192+
193+
const anchors = findInlineAnchors(editor);
194+
expect(anchors).toHaveLength(1);
195+
expect(anchors[0].node.attrs.tag).toBe('meta-a');
196+
expect(anchors[0].node.textContent).toBe('cdef');
197+
expect(metadataListWrapper(editor).items.map((item) => item.id)).toEqual(['meta-a']);
198+
});
199+
200+
it('attach dry-run does not mutate the document or metadata storage', () => {
201+
editor = makeEditor();
202+
const before = editor.state.doc.toJSON();
203+
204+
const preview = metadataAttachWrapper(
205+
editor,
206+
{
207+
id: 'meta-dry',
208+
target: textTarget(2, 6),
209+
namespace: NAMESPACE,
210+
payload: { label: 'Preview' },
211+
},
212+
{ changeMode: 'direct', dryRun: true },
213+
);
214+
215+
expect(preview).toMatchObject({ success: true, id: 'meta-dry', partName: 'customXml/item1.xml' });
216+
expect(editor.state.doc.toJSON()).toEqual(before);
217+
expect(findInlineAnchors(editor)).toHaveLength(0);
218+
expect(metadataListWrapper(editor).total).toBe(0);
219+
});
220+
221+
it('remove unwraps the anchor and leaves a collapsed TextSelection that can select the former range', () => {
222+
editor = makeEditor();
223+
const target = textTarget(2, 6);
224+
225+
expect(
226+
metadataAttachWrapper(editor, {
227+
id: 'meta-remove',
228+
target,
229+
namespace: NAMESPACE,
230+
payload: { label: 'Remove' },
231+
}).success,
232+
).toBe(true);
233+
234+
const [anchor] = findInlineAnchors(editor);
235+
expect(anchor).toBeDefined();
236+
selectFormerAnchorRange(editor, target);
237+
238+
const removed = metadataRemoveWrapper(editor, { id: 'meta-remove' }, { changeMode: 'direct' });
239+
240+
expect(removed).toEqual({ success: true, id: 'meta-remove' });
241+
expect(findInlineAnchors(editor)).toHaveLength(0);
242+
expect(editor.state.selection).toBeInstanceOf(TextSelection);
243+
expect(editor.state.selection.from).toBe(anchor.pos);
244+
expect(editor.state.selection.to).toBe(anchor.pos);
245+
246+
selectFormerAnchorRange(editor, target);
247+
expect(editor.state.selection).toBeInstanceOf(TextSelection);
248+
expect(editor.state.selection.from).toBeLessThan(editor.state.selection.to);
249+
expect(editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to)).toBe('cdef');
250+
});
251+
252+
it('remove normalizes a NodeSelection on the anchor before dispatching', () => {
253+
editor = makeEditor();
254+
255+
expect(
256+
metadataAttachWrapper(editor, {
257+
id: 'meta-node-selection',
258+
target: textTarget(2, 6),
259+
namespace: NAMESPACE,
260+
payload: { label: 'NodeSelection' },
261+
}).success,
262+
).toBe(true);
263+
264+
const [anchor] = findInlineAnchors(editor);
265+
expect(anchor).toBeDefined();
266+
dispatchTr(editor, editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, anchor.pos)));
267+
expect(editor.state.selection).toBeInstanceOf(NodeSelection);
268+
269+
const removed = metadataRemoveWrapper(editor, { id: 'meta-node-selection' }, { changeMode: 'direct' });
270+
271+
expect(removed).toEqual({ success: true, id: 'meta-node-selection' });
272+
expect(findInlineAnchors(editor)).toHaveLength(0);
273+
expect(editor.state.selection).toBeInstanceOf(TextSelection);
274+
expect(editor.state.selection.from).toBe(anchor.pos);
275+
expect(editor.state.selection.to).toBe(anchor.pos);
276+
277+
selectFormerAnchorRange(editor, textTarget(2, 6));
278+
expect(editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to)).toBe('cdef');
279+
});
280+
281+
it('leaves a collapsed caret sitting exactly on the anchor end boundary untouched when removing by id', () => {
282+
editor = makeEditor();
283+
284+
expect(
285+
metadataAttachWrapper(editor, {
286+
id: 'meta-end-boundary',
287+
target: textTarget(2, 6),
288+
namespace: NAMESPACE,
289+
payload: { label: 'End boundary' },
290+
}).success,
291+
).toBe(true);
292+
293+
const [anchor] = findInlineAnchors(editor);
294+
expect(anchor).toBeDefined();
295+
const from = anchor.pos;
296+
const endBoundary = anchor.pos + anchor.node.nodeSize;
297+
const contentSize = anchor.node.content.size;
298+
299+
// Park a collapsed caret exactly on the wrapper's end boundary. It is
300+
// adjacent to the wrapper, not inside it, so removal must not move it.
301+
dispatchTr(editor, editor.state.tr.setSelection(TextSelection.create(editor.state.doc, endBoundary)));
302+
expect(editor.state.selection.empty).toBe(true);
303+
304+
const removed = metadataRemoveWrapper(editor, { id: 'meta-end-boundary' }, { changeMode: 'direct' });
305+
306+
expect(removed).toEqual({ success: true, id: 'meta-end-boundary' });
307+
expect(findInlineAnchors(editor)).toHaveLength(0);
308+
expect(editor.state.selection).toBeInstanceOf(TextSelection);
309+
expect(editor.state.selection.empty).toBe(true);
310+
311+
// The caret maps to the end of the reinserted content (right after 'f'),
312+
// its original text location, and does NOT jump to the anchor start.
313+
expect(editor.state.selection.from).toBe(from + contentSize);
314+
expect(editor.state.selection.from).not.toBe(from);
315+
expect(editor.state.doc.textBetween(from, editor.state.selection.from)).toBe('cdef');
316+
});
317+
318+
it('wraps same-run selected content in a run inside the metadata SDT', () => {
319+
editor = makeEditor();
320+
321+
expect(
322+
metadataAttachWrapper(editor, {
323+
id: 'meta-run-shape',
324+
target: textTarget(2, 6),
325+
namespace: NAMESPACE,
326+
payload: { label: 'Run shape' },
327+
}).success,
328+
).toBe(true);
329+
330+
const [anchor] = findInlineAnchors(editor);
331+
expect(anchor).toBeDefined();
332+
expect(anchor.node.childCount).toBe(1);
333+
expect(anchor.node.firstChild?.type.name).toBe('run');
334+
expect(anchor.node.firstChild?.textContent).toBe('cdef');
335+
});
336+
});

0 commit comments

Comments
 (0)