From b7497dc5484611fcc8220b9140bd1e31644b4680 Mon Sep 17 00:00:00 2001 From: Artem Nistuley Date: Mon, 18 May 2026 13:16:33 +0300 Subject: [PATCH] fix: rtl list boundary arrow navigation near markers --- .../paragraph/listBoundaryNavigationPlugin.js | 4 +- .../listBoundaryNavigationPlugin.test.js | 160 ++++++++++++++++++ 2 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/extensions/paragraph/listBoundaryNavigationPlugin.test.js diff --git a/packages/super-editor/src/editors/v1/extensions/paragraph/listBoundaryNavigationPlugin.js b/packages/super-editor/src/editors/v1/extensions/paragraph/listBoundaryNavigationPlugin.js index c0dd53d949..a720b35987 100644 --- a/packages/super-editor/src/editors/v1/extensions/paragraph/listBoundaryNavigationPlugin.js +++ b/packages/super-editor/src/editors/v1/extensions/paragraph/listBoundaryNavigationPlugin.js @@ -89,12 +89,12 @@ export function createListBoundaryNavigationPlugin() { const paragraph = getParagraphContext(selection.$from); if (!paragraph || !isListParagraph(paragraph.node)) return false; - if (isRtlParagraph(paragraph.node)) return false; const bounds = getParagraphTextBounds(paragraph.node, paragraph.start); if (!bounds) return false; - const direction = event.key === 'ArrowLeft' ? -1 : 1; + const isRtl = isRtlParagraph(paragraph.node); + const direction = isRtl ? (event.key === 'ArrowRight' ? -1 : 1) : event.key === 'ArrowLeft' ? -1 : 1; const atLeftBoundary = direction < 0 && selection.from <= bounds.first; const atRightBoundary = direction > 0 && selection.from >= bounds.last; if (!atLeftBoundary && !atRightBoundary) return false; diff --git a/packages/super-editor/src/editors/v1/extensions/paragraph/listBoundaryNavigationPlugin.test.js b/packages/super-editor/src/editors/v1/extensions/paragraph/listBoundaryNavigationPlugin.test.js new file mode 100644 index 0000000000..50c1622681 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/paragraph/listBoundaryNavigationPlugin.test.js @@ -0,0 +1,160 @@ +// @ts-check +import { describe, it, expect, vi } from 'vitest'; +import { Schema } from 'prosemirror-model'; +import { EditorState, TextSelection } from 'prosemirror-state'; +import { createListBoundaryNavigationPlugin } from './listBoundaryNavigationPlugin.js'; + +const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { + content: 'text*', + group: 'block', + attrs: { + paragraphProperties: { default: null }, + listRendering: { default: null }, + }, + toDOM: () => ['p', 0], + parseDOM: [{ tag: 'p' }], + }, + text: { group: 'inline' }, + }, +}); + +function makeListParagraph(text, rightToLeft = false) { + return schema.nodes.paragraph.create( + { + paragraphProperties: { + numberingProperties: { numId: '1', ilvl: 0 }, + rightToLeft, + }, + listRendering: { markerText: '1.', numberingType: 'decimal', path: [1] }, + }, + text ? [schema.text(text)] : [], + ); +} + +function findTextStart(doc, text) { + let found = null; + doc.descendants((node, pos) => { + if (found != null) return false; + if (node.isText && node.text === text) { + found = pos; + return false; + } + return true; + }); + if (found == null) throw new Error(`Text "${text}" not found`); + return found; +} + +function findTextEnd(doc, text) { + const start = findTextStart(doc, text); + return start + text.length; +} + +function createViewWithState(state) { + return { + state, + dispatch(tr) { + this.state = this.state.apply(tr); + }, + }; +} + +function createArrowEvent(key) { + return { + key, + shiftKey: false, + altKey: false, + ctrlKey: false, + metaKey: false, + preventDefault: vi.fn(), + }; +} + +describe('listBoundaryNavigationPlugin', () => { + it('moves backward across list boundary on ArrowLeft in LTR', () => { + const doc = schema.nodes.doc.create(null, [makeListParagraph('alpha', false), makeListParagraph('beta', false)]); + const secondStart = findTextStart(doc, 'beta'); + const firstEnd = findTextEnd(doc, 'alpha'); + const plugin = createListBoundaryNavigationPlugin(); + const state = EditorState.create({ + schema, + doc, + plugins: [plugin], + selection: TextSelection.create(doc, secondStart), + }); + const view = createViewWithState(state); + const event = createArrowEvent('ArrowLeft'); + + const handled = plugin.props.handleKeyDown(view, event); + + expect(handled).toBe(true); + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(view.state.selection.from).toBe(firstEnd); + }); + + it('moves backward across list boundary on ArrowRight in RTL', () => { + const doc = schema.nodes.doc.create(null, [makeListParagraph('alpha', true), makeListParagraph('beta', true)]); + const secondStart = findTextStart(doc, 'beta'); + const firstEnd = findTextEnd(doc, 'alpha'); + const plugin = createListBoundaryNavigationPlugin(); + const state = EditorState.create({ + schema, + doc, + plugins: [plugin], + selection: TextSelection.create(doc, secondStart), + }); + const view = createViewWithState(state); + const event = createArrowEvent('ArrowRight'); + + const handled = plugin.props.handleKeyDown(view, event); + + expect(handled).toBe(true); + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(view.state.selection.from).toBe(firstEnd); + }); + + it('moves forward across list boundary on ArrowRight in LTR', () => { + const doc = schema.nodes.doc.create(null, [makeListParagraph('alpha', false), makeListParagraph('beta', false)]); + const firstEnd = findTextEnd(doc, 'alpha'); + const secondStart = findTextStart(doc, 'beta'); + const plugin = createListBoundaryNavigationPlugin(); + const state = EditorState.create({ + schema, + doc, + plugins: [plugin], + selection: TextSelection.create(doc, firstEnd), + }); + const view = createViewWithState(state); + const event = createArrowEvent('ArrowRight'); + + const handled = plugin.props.handleKeyDown(view, event); + + expect(handled).toBe(true); + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(view.state.selection.from).toBe(secondStart); + }); + + it('moves forward across list boundary on ArrowLeft in RTL', () => { + const doc = schema.nodes.doc.create(null, [makeListParagraph('alpha', true), makeListParagraph('beta', true)]); + const firstEnd = findTextEnd(doc, 'alpha'); + const secondStart = findTextStart(doc, 'beta'); + const plugin = createListBoundaryNavigationPlugin(); + const state = EditorState.create({ + schema, + doc, + plugins: [plugin], + selection: TextSelection.create(doc, firstEnd), + }); + const view = createViewWithState(state); + const event = createArrowEvent('ArrowLeft'); + + const handled = plugin.props.handleKeyDown(view, event); + + expect(handled).toBe(true); + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(view.state.selection.from).toBe(secondStart); + }); +});