Skip to content

Commit 018ffb1

Browse files
authored
fix: ensure one arrow key press to move out of sdt (#2972)
* fix: ensure one arrow key press to move out of sdt * fix: use \ufffc for inline leaf nodes
1 parent 0af690d commit 018ffb1

3 files changed

Lines changed: 82 additions & 2 deletions

File tree

packages/layout-engine/painters/dom/src/styles.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,8 @@ const SDT_CONTAINER_STYLES = `
517517
border: 1px solid transparent;
518518
position: relative;
519519
display: inline;
520+
font-size: initial;
521+
line-height: normal;
520522
z-index: 10;
521523
}
522524

packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { Plugin, TextSelection } from 'prosemirror-state';
22

33
import { applyEditableSlotAtInlineBoundary } from '@helpers/ensure-editable-slot-inline-boundary.js';
44

5+
const INLINE_LEAF_TEXT = '\ufffc';
6+
57
/**
68
* Select-all-on-click plugin for inline StructuredContent nodes.
79
*
@@ -24,6 +26,7 @@ export function createStructuredContentSelectPlugin(editor) {
2426

2527
const { state } = view;
2628
const { selection } = state;
29+
const isEditableSlotText = (text) => text.replace(/\u200B/g, '').length === 0;
2730

2831
const resolveBoundaryExit = ($pos) => {
2932
for (let depth = $pos.depth; depth > 0; depth -= 1) {
@@ -38,10 +41,18 @@ export function createStructuredContentSelectPlugin(editor) {
3841

3942
// Empty selection: exit only at exact boundaries.
4043
if (selection.empty) {
44+
const trailingSlice = state.doc.textBetween(selection.from, contentTo, '', INLINE_LEAF_TEXT);
45+
const leadingSlice = state.doc.textBetween(contentFrom, selection.from, '', INLINE_LEAF_TEXT);
46+
const onlyTrailingEditableSlots = trailingSlice.length > 0 && isEditableSlotText(trailingSlice);
47+
const onlyLeadingEditableSlots = leadingSlice.length > 0 && isEditableSlotText(leadingSlice);
4148
// Be tolerant by 1 position to avoid requiring a second key press
4249
// when PM lands just inside boundary positions.
43-
if (event.key === 'ArrowRight' && selection.from >= contentTo - 1) return afterPos;
44-
if (event.key === 'ArrowLeft' && selection.from <= contentFrom + 1) return beforePos;
50+
if (event.key === 'ArrowRight' && (selection.from >= contentTo - 1 || onlyTrailingEditableSlots)) {
51+
return afterPos;
52+
}
53+
if (event.key === 'ArrowLeft' && (selection.from <= contentFrom + 1 || onlyLeadingEditableSlots)) {
54+
return beforePos;
55+
}
4556
return null;
4657
}
4758

packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.test.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@ describe('StructuredContentSelectPlugin', () => {
4040
);
4141
}
4242

43+
function pressArrow(key) {
44+
const event = new KeyboardEvent('keydown', { key, bubbles: true });
45+
let handled = false;
46+
editor.view.someProp('handleKeyDown', (handler) => {
47+
handled = handler(editor.view, event);
48+
return handled;
49+
});
50+
return handled;
51+
}
52+
4353
it('selects inline SDT content on first click in editing mode', () => {
4454
const inlineSdt = schema.nodes.structuredContent.create({ id: 'inline-1' }, schema.text('Field'));
4555
const paragraph = schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt, schema.text(' Z')]);
@@ -161,6 +171,63 @@ describe('StructuredContentSelectPlugin', () => {
161171
expect((text.match(/\u200B/g) ?? []).length).toBe(1);
162172
});
163173

174+
it('exits inline SDT with one ArrowRight when only trailing ZWSP slots remain', () => {
175+
const inlineSdt = schema.nodes.structuredContent.create({ id: 'inline-1' }, schema.text('Field\u200B\u200B'));
176+
const paragraph = schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt, schema.text(' Z')]);
177+
applyDoc(schema.nodes.doc.create(null, [paragraph]));
178+
179+
const sdt = findNode(editor.state.doc, 'structuredContent');
180+
expect(sdt).not.toBeNull();
181+
182+
const contentFrom = sdt.pos + 1;
183+
const afterVisibleText = contentFrom + 'Field'.length;
184+
const afterSdt = sdt.pos + sdt.node.nodeSize;
185+
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, afterVisibleText)));
186+
187+
const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true });
188+
editor.view.someProp('handleKeyDown', (handler) => handler(editor.view, event));
189+
190+
expect(editor.state.selection.empty).toBe(true);
191+
expect(editor.state.selection.from).toBe(afterSdt);
192+
expect(editor.state.selection.to).toBe(afterSdt);
193+
});
194+
195+
it('does not exit inline SDT with ArrowRight when a trailing inline leaf remains before ZWSP slots', () => {
196+
const image = schema.nodes.image.create({ src: 'image.png' });
197+
const inlineSdt = schema.nodes.structuredContent.create({ id: 'inline-1' }, [image, schema.text('\u200B')]);
198+
const paragraph = schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt, schema.text(' Z')]);
199+
applyDoc(schema.nodes.doc.create(null, [paragraph]));
200+
201+
const sdt = findNode(editor.state.doc, 'structuredContent');
202+
expect(sdt).not.toBeNull();
203+
204+
const contentFrom = sdt.pos + 1;
205+
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, contentFrom)));
206+
const beforePos = editor.state.selection.from;
207+
208+
expect(pressArrow('ArrowRight')).toBe(false);
209+
expect(editor.state.selection.empty).toBe(true);
210+
expect(editor.state.selection.from).toBe(beforePos);
211+
});
212+
213+
it('does not exit inline SDT with ArrowLeft when a leading inline leaf remains after ZWSP slots', () => {
214+
const image = schema.nodes.image.create({ src: 'image.png' });
215+
const inlineSdt = schema.nodes.structuredContent.create({ id: 'inline-1' }, [schema.text('\u200B'), image]);
216+
const paragraph = schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt, schema.text(' Z')]);
217+
applyDoc(schema.nodes.doc.create(null, [paragraph]));
218+
219+
const sdt = findNode(editor.state.doc, 'structuredContent');
220+
expect(sdt).not.toBeNull();
221+
222+
const contentTo = sdt.pos + sdt.node.nodeSize - 1;
223+
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, contentTo)));
224+
const beforePos = editor.state.selection.from;
225+
226+
expect(pressArrow('ArrowLeft')).toBe(false);
227+
expect(editor.state.selection.empty).toBe(true);
228+
expect(editor.state.selection.from).toBe(beforePos);
229+
});
230+
164231
it('ArrowLeft exit does not insert zero-width text before inline SDT', () => {
165232
const inlineSdt = schema.nodes.structuredContent.create({ id: 'inline-1' }, schema.text('Field'));
166233
const paragraph = schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt, schema.text(' Z')]);

0 commit comments

Comments
 (0)