Skip to content

Commit 3a8f9c8

Browse files
fix: skip inline atom markers when detecting table boundary for arrow key navigation
Paragraphs in imported documents can contain invisible inline markers (bookmarkStart, bookmarkEnd, permEnd, commentRangeEnd, etc.) adjacent to runs. The boundary detection functions treated these as meaningful content, preventing ArrowRight/ArrowLeft from exiting or entering the table when the cursor was at the edge of a run followed or preceded by such markers. Replace the strict last-child/first-child index check with allInlineMarkersBetween, which skips over inline non-run nodes that carry no text content.
1 parent 32102a7 commit 3a8f9c8

2 files changed

Lines changed: 143 additions & 3 deletions

File tree

packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.js

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,39 @@ function findParagraphDepth($pos) {
3939
return findAncestorDepth($pos, (node) => node.type.name === 'paragraph');
4040
}
4141

42+
/**
43+
* Returns true when every sibling of the paragraph between `fromIndex`
44+
* (inclusive) and `toIndex` (exclusive) is an invisible inline marker
45+
* (e.g. bookmarkStart, bookmarkEnd, permEnd, commentRangeEnd). These are
46+
* zero-width nodes the cursor should not stop at, so they should not
47+
* prevent boundary detection.
48+
*
49+
* A node is considered an invisible marker when it is inline, not a run,
50+
* and carries no text content.
51+
*
52+
* @param {import('prosemirror-model').Node} paragraph
53+
* @param {number} fromIndex
54+
* @param {number} toIndex
55+
* @returns {boolean}
56+
*/
57+
function allInlineMarkersBetween(paragraph, fromIndex, toIndex) {
58+
for (let i = fromIndex; i < toIndex; i += 1) {
59+
const child = paragraph.child(i);
60+
if (child.type.name === 'run') return false;
61+
if (!child.isInline) return false;
62+
if (child.textContent !== '') return false;
63+
}
64+
return true;
65+
}
66+
4267
/**
4368
* Returns true when the caret should be treated as being at the effective end
4469
* of the paragraph for horizontal navigation purposes.
4570
*
4671
* This is run-aware: the end of the final run in the paragraph counts as the
4772
* end of the text block even when the selection has not advanced to the raw
48-
* paragraph boundary position yet.
73+
* paragraph boundary position yet. Trailing inline atoms (bookmarks,
74+
* permission markers, etc.) are ignored.
4975
*
5076
* @param {import('prosemirror-model').ResolvedPos} $head
5177
* @returns {boolean}
@@ -63,13 +89,16 @@ export function isAtEffectiveParagraphEnd($head) {
6389
if (runDepth < 0) return false;
6490
if ($head.pos !== $head.end(runDepth)) return false;
6591

66-
return $head.index(paragraphDepth) === paragraph.childCount - 1;
92+
const runIndex = $head.index(paragraphDepth);
93+
return allInlineMarkersBetween(paragraph, runIndex + 1, paragraph.childCount);
6794
}
6895

6996
/**
7097
* Returns true when the caret should be treated as being at the effective start
7198
* of the paragraph for horizontal navigation purposes.
7299
*
100+
* Leading inline atoms (bookmarks, permission markers, etc.) are ignored.
101+
*
73102
* @param {import('prosemirror-model').ResolvedPos} $head
74103
* @returns {boolean}
75104
*/
@@ -86,7 +115,8 @@ export function isAtEffectiveParagraphStart($head) {
86115
if (runDepth < 0) return false;
87116
if ($head.pos !== $head.start(runDepth)) return false;
88117

89-
return $head.index(paragraphDepth) === 0;
118+
const runIndex = $head.index(paragraphDepth);
119+
return allInlineMarkersBetween(paragraph, 0, runIndex);
90120
}
91121

92122
/**

packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.test.js

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,70 @@ const DOC_WITH_PROTECTED_TRAILING_PARAGRAPH = {
108108
],
109109
};
110110

111+
/**
112+
* Same table layout as DOC, but the first cell has a leading bookmarkStart
113+
* and the last cell has a trailing bookmarkEnd. This simulates imported
114+
* documents where inline atom markers sit at paragraph edges.
115+
*/
116+
const DOC_WITH_INLINE_ATOMS = {
117+
type: 'doc',
118+
content: [
119+
{
120+
type: 'paragraph',
121+
content: [{ type: 'run', content: [{ type: 'text', text: 'Before' }] }],
122+
},
123+
{
124+
type: 'table',
125+
attrs: {
126+
tableProperties: {},
127+
grid: [{ col: 1500 }],
128+
},
129+
content: [
130+
{
131+
type: 'tableRow',
132+
content: [
133+
{
134+
type: 'tableCell',
135+
attrs: { colspan: 1, rowspan: 1, colwidth: [150] },
136+
content: [
137+
{
138+
type: 'paragraph',
139+
content: [
140+
{ type: 'bookmarkStart', attrs: { id: '0', name: 'bm1' } },
141+
{ type: 'run', content: [{ type: 'text', text: 'First' }] },
142+
],
143+
},
144+
],
145+
},
146+
],
147+
},
148+
{
149+
type: 'tableRow',
150+
content: [
151+
{
152+
type: 'tableCell',
153+
attrs: { colspan: 1, rowspan: 1, colwidth: [150] },
154+
content: [
155+
{
156+
type: 'paragraph',
157+
content: [
158+
{ type: 'run', content: [{ type: 'text', text: 'Last' }] },
159+
{ type: 'bookmarkEnd', attrs: { id: '0' } },
160+
],
161+
},
162+
],
163+
},
164+
],
165+
},
166+
],
167+
},
168+
{
169+
type: 'paragraph',
170+
content: [{ type: 'run', content: [{ type: 'text', text: 'After' }] }],
171+
},
172+
],
173+
};
174+
111175
function findTextPos(doc, search) {
112176
let found = null;
113177
doc.descendants((node, pos) => {
@@ -281,4 +345,50 @@ describe('tableBoundaryNavigation', () => {
281345

282346
expect(handled).toBe(true);
283347
});
348+
349+
describe('inline atom markers at paragraph edges', () => {
350+
let atomEditor;
351+
let atomDoc;
352+
353+
beforeEach(() => {
354+
({ editor: atomEditor } = initTestEditor({ loadFromSchema: true, content: DOC_WITH_INLINE_ATOMS }));
355+
atomDoc = atomEditor.state.doc;
356+
});
357+
358+
it('treats the end of the last run as paragraph end even with a trailing bookmarkEnd', () => {
359+
const lastPos = findTextPos(atomDoc, 'Last');
360+
const endOfLast = lastPos + 'Last'.length;
361+
const state = atomEditor.state.apply(atomEditor.state.tr.setSelection(TextSelection.create(atomDoc, endOfLast)));
362+
363+
expect(isAtEffectiveParagraphEnd(state.selection.$head)).toBe(true);
364+
});
365+
366+
it('treats the start of the first run as paragraph start even with a leading bookmarkStart', () => {
367+
const firstPos = findTextPos(atomDoc, 'First');
368+
const state = atomEditor.state.apply(atomEditor.state.tr.setSelection(TextSelection.create(atomDoc, firstPos)));
369+
370+
expect(isAtEffectiveParagraphStart(state.selection.$head)).toBe(true);
371+
});
372+
373+
it('exits the table rightward from the last cell despite a trailing bookmarkEnd', () => {
374+
const lastPos = findTextPos(atomDoc, 'Last');
375+
const endOfLast = lastPos + 'Last'.length;
376+
const afterPos = findTextPos(atomDoc, 'After');
377+
const state = atomEditor.state.apply(atomEditor.state.tr.setSelection(TextSelection.create(atomDoc, endOfLast)));
378+
379+
const nextSelection = getTableBoundaryExitSelection(state, 1);
380+
expect(nextSelection).not.toBeNull();
381+
expect(nextSelection.from).toBe(afterPos);
382+
});
383+
384+
it('exits the table leftward from the first cell despite a leading bookmarkStart', () => {
385+
const firstPos = findTextPos(atomDoc, 'First');
386+
const beforeEnd = findTextPos(atomDoc, 'Before') + 'Before'.length;
387+
const state = atomEditor.state.apply(atomEditor.state.tr.setSelection(TextSelection.create(atomDoc, firstPos)));
388+
389+
const nextSelection = getTableBoundaryExitSelection(state, -1);
390+
expect(nextSelection).not.toBeNull();
391+
expect(nextSelection.from).toBe(beforeEnd);
392+
});
393+
});
284394
});

0 commit comments

Comments
 (0)