From 4482642a88b5e80db16ea0764d2ce95d56ef1805 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 30 Jan 2026 15:20:52 -0300 Subject: [PATCH 01/21] feat: add plugin for handling vertical arrow navigation --- packages/super-editor/src/extensions/index.js | 2 ++ .../extensions/vertical-navigation/index.js | 1 + .../vertical-navigation.js | 25 +++++++++++++++++++ 3 files changed, 28 insertions(+) create mode 100644 packages/super-editor/src/extensions/vertical-navigation/index.js create mode 100644 packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js diff --git a/packages/super-editor/src/extensions/index.js b/packages/super-editor/src/extensions/index.js index 366c92ed31..98d69876ed 100644 --- a/packages/super-editor/src/extensions/index.js +++ b/packages/super-editor/src/extensions/index.js @@ -70,6 +70,7 @@ import { Search } from './search/index.js'; import { NodeResizer } from './noderesizer/index.js'; import { CustomSelection } from './custom-selection/index.js'; import { PermissionRanges } from './permission-ranges/index.js'; +import { VerticalNavigation } from './vertical-navigation/index.js'; // Permissions import { PermStart } from './perm-start/index.js'; @@ -194,6 +195,7 @@ const getStarterExtensions = () => { PermStart, PermEnd, PermissionRanges, + VerticalNavigation, PassthroughInline, PassthroughBlock, ]; diff --git a/packages/super-editor/src/extensions/vertical-navigation/index.js b/packages/super-editor/src/extensions/vertical-navigation/index.js new file mode 100644 index 0000000000..163bdb26a1 --- /dev/null +++ b/packages/super-editor/src/extensions/vertical-navigation/index.js @@ -0,0 +1 @@ +export { VerticalNavigation, VerticalNavigationPluginKey } from './vertical-navigation.js'; diff --git a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js new file mode 100644 index 0000000000..53565906a6 --- /dev/null +++ b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js @@ -0,0 +1,25 @@ +import { Extension } from '@core/Extension.js'; +import { Plugin, PluginKey } from 'prosemirror-state'; + +export const VerticalNavigationPluginKey = new PluginKey('verticalNavigation'); + +export const VerticalNavigation = Extension.create({ + name: 'verticalNavigation', + + addPmPlugins() { + if (this.editor.options?.isHeaderOrFooter) return []; + if (this.editor.options?.isHeadless) return []; + + const plugin = new Plugin({ + key: VerticalNavigationPluginKey, + props: { + handleKeyDown(_view, event) { + if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return false; + return false; + }, + }, + }); + + return [plugin]; + }, +}); From 4a19ac6e26ceb0387ee8f6270c4395a9cef3ed6d Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 30 Jan 2026 15:38:36 -0300 Subject: [PATCH 02/21] feat: keep track of X position during vertical navigation --- .../vertical-navigation.js | 96 ++++++++++++++++++- 1 file changed, 95 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js index 53565906a6..6b4957b47e 100644 --- a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js +++ b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js @@ -3,6 +3,11 @@ import { Plugin, PluginKey } from 'prosemirror-state'; export const VerticalNavigationPluginKey = new PluginKey('verticalNavigation'); +const createDefaultState = () => ({ + goalX: null, + isHandlingVerticalMove: false, +}); + export const VerticalNavigation = Extension.create({ name: 'verticalNavigation', @@ -10,16 +15,105 @@ export const VerticalNavigation = Extension.create({ if (this.editor.options?.isHeaderOrFooter) return []; if (this.editor.options?.isHeadless) return []; + const editor = this.editor; const plugin = new Plugin({ key: VerticalNavigationPluginKey, + state: { + init: () => createDefaultState(), + apply(tr, value) { + const meta = tr.getMeta(VerticalNavigationPluginKey); + if (meta?.type === 'vertical-move') { + return { + goalX: meta.goalX ?? value.goalX ?? null, + isHandlingVerticalMove: false, + }; + } + if (meta?.type === 'set-goal-x') { + return { + ...value, + goalX: meta.goalX ?? null, + }; + } + if (meta?.type === 'reset-goal-x') { + return { + ...value, + goalX: null, + }; + } + if (tr.selectionSet) { + return { + ...value, + goalX: null, + }; + } + return value; + }, + }, props: { - handleKeyDown(_view, event) { + handleKeyDown(view, event) { + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight' || event.key === 'Home' || event.key === 'End') { + view.dispatch(view.state.tr.setMeta(VerticalNavigationPluginKey, { type: 'reset-goal-x' })); + return false; + } + if (event.key === 'PageUp' || event.key === 'PageDown') { + view.dispatch(view.state.tr.setMeta(VerticalNavigationPluginKey, { type: 'reset-goal-x' })); + return false; + } if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return false; + + if (!isPresenting(editor)) { + return false; + } + + const pluginState = VerticalNavigationPluginKey.getState(view.state); + let goalX = pluginState?.goalX; + const coords = getCurrentCoords(editor, view.state.selection.head); + if (goalX == null) { + goalX = coords?.x; + if (!Number.isFinite(goalX)) return false; + view.dispatch( + view.state.tr.setMeta(VerticalNavigationPluginKey, { type: 'set-goal-x', goalX }), + ); + } return false; }, + handleDOMEvents: { + mousedown: (view) => { + view.dispatch(view.state.tr.setMeta(VerticalNavigationPluginKey, { type: 'reset-goal-x' })); + return false; + }, + touchstart: (view) => { + view.dispatch(view.state.tr.setMeta(VerticalNavigationPluginKey, { type: 'reset-goal-x' })); + return false; + }, + compositionstart: (view) => { + view.dispatch(view.state.tr.setMeta(VerticalNavigationPluginKey, { type: 'reset-goal-x' })); + return false; + }, + }, }, }); return [plugin]; }, }); + +function isPresenting(editor) { + const presentationCtx = editor?.presentationEditor; + if (!presentationCtx) return false; + const activeEditor = presentationCtx.getActiveEditor?.(); + return activeEditor === editor; +} + +function getCurrentCoords(editor, pos) { + const presentationEditor = editor.presentationEditor; + const coords = presentationEditor.coordsAtPos(pos); + if (!coords) return null; + + const layoutSpaceCoords = presentationEditor.normalizeClientPoint(coords.left, coords.top); + if (!layoutSpaceCoords) return null; + + return { clientX: coords.left, clientY: coords.top, x: layoutSpaceCoords.x, y: layoutSpaceCoords.y }; +} + +} From 58239dc1d5543796e509d044757e233afb89391a Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 30 Jan 2026 16:01:43 -0300 Subject: [PATCH 03/21] feat: compute next Y position after vertical navigation --- .../vertical-navigation.js | 73 ++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js index 6b4957b47e..e3947a3f29 100644 --- a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js +++ b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js @@ -1,5 +1,6 @@ import { Extension } from '@core/Extension.js'; import { Plugin, PluginKey } from 'prosemirror-state'; +import { DOM_CLASS_NAMES } from '@superdoc/painter-dom'; export const VerticalNavigationPluginKey = new PluginKey('verticalNavigation'); @@ -75,6 +76,8 @@ export const VerticalNavigation = Extension.create({ view.state.tr.setMeta(VerticalNavigationPluginKey, { type: 'set-goal-x', goalX }), ); } + const newY = getAdjacentLineClientY(editor, coords, event.key === 'ArrowUp' ? -1 : 1); + if (!Number.isFinite(newY)) return false; return false; }, handleDOMEvents: { @@ -113,7 +116,75 @@ function getCurrentCoords(editor, pos) { const layoutSpaceCoords = presentationEditor.normalizeClientPoint(coords.left, coords.top); if (!layoutSpaceCoords) return null; - return { clientX: coords.left, clientY: coords.top, x: layoutSpaceCoords.x, y: layoutSpaceCoords.y }; + return { + clientX: coords.left, + clientY: coords.top, + height: coords.height, + x: layoutSpaceCoords.x, + y: layoutSpaceCoords.y, + }; } +function getAdjacentLineClientY(editor, coords, direction) { + const presentationEditor = editor.presentationEditor; + const doc = presentationEditor.visibleHost?.ownerDocument ?? document; + const caretX = coords.clientX; + const caretY = coords.clientY + coords.height / 2; + const currentLine = findLineElementAtPoint(doc, caretX, caretY); + if (!currentLine) return null; + const adjacentLine = findAdjacentLineElement(currentLine, direction); + if (!adjacentLine) return null; + const rect = adjacentLine.getBoundingClientRect(); + return rect.top + rect.height / 2; +} + +function findLineElementAtPoint(doc, x, y) { + if (typeof doc?.elementsFromPoint !== 'function') return null; + const chain = doc.elementsFromPoint(x, y) ?? []; + for (const el of chain) { + if (el?.classList?.contains?.(DOM_CLASS_NAMES.LINE)) return el; + } + return null; +} + +function findAdjacentLineElement(currentLine, direction) { + const lineClass = DOM_CLASS_NAMES.LINE; + const fragmentClass = DOM_CLASS_NAMES.FRAGMENT; + const pageClass = DOM_CLASS_NAMES.PAGE; + const fragment = currentLine.closest?.(`.${fragmentClass}`); + const page = currentLine.closest?.(`.${pageClass}`); + if (!fragment || !page) return null; + + const lineEls = Array.from(fragment.querySelectorAll(`.${lineClass}`)); + const index = lineEls.indexOf(currentLine); + if (index !== -1) { + const nextInFragment = lineEls[index + direction]; + if (nextInFragment) return nextInFragment; + } + + const fragments = Array.from(page.querySelectorAll(`.${fragmentClass}`)); + const fragmentIndex = fragments.indexOf(fragment); + if (fragmentIndex !== -1) { + const nextFragment = fragments[fragmentIndex + direction]; + const fallbackLine = getEdgeLineFromFragment(nextFragment, direction); + if (fallbackLine) return fallbackLine; + } + + const pages = Array.from(page.parentElement?.querySelectorAll?.(`.${pageClass}`) ?? []); + const pageIndex = pages.indexOf(page); + if (pageIndex === -1) return null; + const nextPage = pages[pageIndex + direction]; + if (!nextPage) return null; + const pageFragments = Array.from(nextPage.querySelectorAll(`.${fragmentClass}`)); + if (direction > 0) { + return getEdgeLineFromFragment(pageFragments[0], direction); + } + return getEdgeLineFromFragment(pageFragments[pageFragments.length - 1], direction); +} + +function getEdgeLineFromFragment(fragment, direction) { + if (!fragment) return null; + const lineEls = Array.from(fragment.querySelectorAll(`.${DOM_CLASS_NAMES.LINE}`)); + if (lineEls.length === 0) return null; + return direction > 0 ? lineEls[0] : lineEls[lineEls.length - 1]; } From 7e92d8e9135c0d56b2aba92109b70fbc21f39e5c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 30 Jan 2026 16:09:10 -0300 Subject: [PATCH 04/21] feat: add helper to convert back to client-space coordinates --- .../presentation-editor/PresentationEditor.ts | 19 +++++++++- .../dom/PointerNormalization.ts | 35 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 9a2e699984..b3be7a55b1 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -16,7 +16,10 @@ import { convertPageLocalToOverlayCoords as convertPageLocalToOverlayCoordsFromTransform, getPageOffsetX as getPageOffsetXFromTransform, } from './dom/CoordinateTransform.js'; -import { normalizeClientPoint as normalizeClientPointFromPointer } from './dom/PointerNormalization.js'; +import { + normalizeClientPoint as normalizeClientPointFromPointer, + denormalizeClientPoint as denormalizeClientPointFromPointer, +} from './dom/PointerNormalization.js'; import { getPageElementByIndex } from './dom/PageDom.js'; import { inchesToPx, parseColumns } from './layout/LayoutOptionParsing.js'; import { createLayoutMetrics as createLayoutMetricsFromHelper } from './layout/PresentationLayoutMetrics.js'; @@ -4519,6 +4522,20 @@ export class PresentationEditor extends EventEmitter { ); } + denormalizeClientPoint(layoutX: number, layoutY: number, pageIndex?: number): { x: number; y: number } | null { + return denormalizeClientPointFromPointer( + { + viewportHost: this.#viewportHost, + visibleHost: this.#visibleHost, + zoom: this.#layoutOptions.zoom ?? 1, + getPageOffsetX: (pageIndex) => this.#getPageOffsetX(pageIndex), + }, + layoutX, + layoutY, + pageIndex, + ); + } + /** * Computes caret layout rectangle using geometry-based calculations. * diff --git a/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.ts b/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.ts index 2adbea8cbb..7f898c0bb0 100644 --- a/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.ts +++ b/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.ts @@ -43,3 +43,38 @@ export function normalizeClientPoint( y: baseY, }; } + +export function denormalizeClientPoint( + options: { + viewportHost: HTMLElement; + visibleHost: HTMLElement; + zoom: number; + getPageOffsetX: (pageIndex: number) => number | null; + }, + layoutX: number, + layoutY: number, + pageIndex?: number, +): { x: number; y: number } | null { + if (!Number.isFinite(layoutX) || !Number.isFinite(layoutY)) { + return null; + } + + const rect = options.viewportHost.getBoundingClientRect(); + const scrollLeft = options.visibleHost.scrollLeft ?? 0; + const scrollTop = options.visibleHost.scrollTop ?? 0; + + // Convert from layout coordinates to screen coordinates by multiplying by zoom + // and reversing the scroll/viewport offsets. + let baseX = layoutX; + if (Number.isFinite(pageIndex)) { + const pageOffsetX = options.getPageOffsetX(pageIndex); + if (pageOffsetX != null) { + baseX = layoutX + pageOffsetX; + } + } + + return { + x: baseX * options.zoom - scrollLeft + rect.left, + y: layoutY * options.zoom - scrollTop + rect.top, + }; +} From 158291cf3eb519bc1a44e526ef4340258f0a017f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 2 Feb 2026 16:20:37 -0300 Subject: [PATCH 05/21] fix: compute caret position based on closest entry --- .../dom/DomPositionIndex.ts | 45 ++++++++ .../dom/DomSelectionGeometry.ts | 2 +- .../tests/DomPositionIndex.test.ts | 104 ++++++++++++++++++ .../tests/DomSelectionGeometry.test.ts | 8 +- 4 files changed, 156 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/core/presentation-editor/dom/DomPositionIndex.ts b/packages/super-editor/src/core/presentation-editor/dom/DomPositionIndex.ts index ae58172797..5e04bd602d 100644 --- a/packages/super-editor/src/core/presentation-editor/dom/DomPositionIndex.ts +++ b/packages/super-editor/src/core/presentation-editor/dom/DomPositionIndex.ts @@ -224,6 +224,51 @@ export class DomPositionIndex { return entry; } + /** + * Finds the index entry that either contains the given position, + * or is the closest entry before or after it. + * @param pos - The ProseMirror position to look up + * @returns The closest entry to this position, or null if the index is empty + * @remarks + * This method first attempts to find an entry that contains the position. + * If none is found, it then finds the closest entry before or after the position. + * If the index is empty, it returns null. + */ + findEntryClosestToPosition(pos: number): DomPositionIndexEntry | null { + if (!Number.isFinite(pos)) return null; + const entries = this.#entries; + if (entries.length === 0) return null; + + const entryAtPos = this.findEntryAtPosition(pos); + if (entryAtPos) return entryAtPos; + + // Upper-bound search for pmStart <= pos + let lo = 0; + let hi = entries.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (entries[mid].pmStart <= pos) { + lo = mid + 1; + } else { + hi = mid; + } + } + + const idx = lo - 1; + + const beforeEntry = idx >= 0 ? entries[idx] : null; + const afterEntry = lo < entries.length ? entries[lo] : null; + + if (beforeEntry && afterEntry) { + const distBefore = pos - beforeEntry.pmEnd; + const distAfter = afterEntry.pmStart - pos; + return distBefore <= distAfter ? beforeEntry : afterEntry; + } + if (beforeEntry) return beforeEntry; + if (afterEntry) return afterEntry; + return null; + } + findElementAtPosition(pos: number): HTMLElement | null { return this.findEntryAtPosition(pos)?.el ?? null; } diff --git a/packages/super-editor/src/core/presentation-editor/dom/DomSelectionGeometry.ts b/packages/super-editor/src/core/presentation-editor/dom/DomSelectionGeometry.ts index d0beafebab..ca7e5482e4 100644 --- a/packages/super-editor/src/core/presentation-editor/dom/DomSelectionGeometry.ts +++ b/packages/super-editor/src/core/presentation-editor/dom/DomSelectionGeometry.ts @@ -559,7 +559,7 @@ export function computeDomCaretPageLocal( options.rebuildDomPositionIndex(); } - let entry = options.domPositionIndex.findEntryAtPosition(pos); + let entry = options.domPositionIndex.findEntryClosestToPosition(pos); if (entry && !entry.el.isConnected) { options.rebuildDomPositionIndex(); entry = options.domPositionIndex.findEntryAtPosition(pos); diff --git a/packages/super-editor/src/core/presentation-editor/tests/DomPositionIndex.test.ts b/packages/super-editor/src/core/presentation-editor/tests/DomPositionIndex.test.ts index d93fe300ce..112a06fca4 100644 --- a/packages/super-editor/src/core/presentation-editor/tests/DomPositionIndex.test.ts +++ b/packages/super-editor/src/core/presentation-editor/tests/DomPositionIndex.test.ts @@ -600,6 +600,110 @@ describe('DomPositionIndex', () => { }); }); + describe('findEntryClosestToPosition', () => { + it('returns null for non-finite positions', () => { + const container = document.createElement('div'); + container.innerHTML = ` +
+ test +
+ `; + + const index = new DomPositionIndex(); + index.rebuild(container); + + expect(index.findEntryClosestToPosition(NaN)).toBe(null); + expect(index.findEntryClosestToPosition(Infinity)).toBe(null); + expect(index.findEntryClosestToPosition(-Infinity)).toBe(null); + }); + + it('returns entry that contains the position', () => { + const container = document.createElement('div'); + container.innerHTML = ` +
+ alpha + beta +
+ `; + + const index = new DomPositionIndex(); + index.rebuild(container); + + expect(index.findEntryClosestToPosition(3)?.el.textContent).toBe('alpha'); + expect(index.findEntryClosestToPosition(12)?.el.textContent).toBe('beta'); + }); + + it('returns closest entry before the position when after all entries', () => { + const container = document.createElement('div'); + container.innerHTML = ` +
+ first + second +
+ `; + + const index = new DomPositionIndex(); + index.rebuild(container); + + expect(index.findEntryClosestToPosition(20)?.el.textContent).toBe('second'); + }); + + it('returns closest entry after the position when before all entries', () => { + const container = document.createElement('div'); + container.innerHTML = ` +
+ first + second +
+ `; + + const index = new DomPositionIndex(); + index.rebuild(container); + + expect(index.findEntryClosestToPosition(3)?.el.textContent).toBe('first'); + }); + + it('returns the closer entry when position is between ranges', () => { + const container = document.createElement('div'); + container.innerHTML = ` +
+ first + second +
+ `; + + const index = new DomPositionIndex(); + index.rebuild(container); + + expect(index.findEntryClosestToPosition(8)?.el.textContent).toBe('first'); + expect(index.findEntryClosestToPosition(18)?.el.textContent).toBe('second'); + }); + + it('prefers the previous entry when equidistant between ranges', () => { + const container = document.createElement('div'); + container.innerHTML = ` +
+ first + second +
+ `; + + const index = new DomPositionIndex(); + index.rebuild(container); + + // Distance to first: 8 - 5 = 3, distance to second: 11 - 8 = 3 + expect(index.findEntryClosestToPosition(8)?.el.textContent).toBe('first'); + }); + + it('returns null when index is empty', () => { + const container = document.createElement('div'); + const index = new DomPositionIndex(); + index.rebuild(container); + + expect(index.findEntryClosestToPosition(5)).toBe(null); + }); + }); + describe('edge cases - findElementsInRange', () => { it('returns empty array for NaN from parameter', () => { const container = document.createElement('div'); diff --git a/packages/super-editor/src/core/presentation-editor/tests/DomSelectionGeometry.test.ts b/packages/super-editor/src/core/presentation-editor/tests/DomSelectionGeometry.test.ts index fdd9774e23..d8d396d5e3 100644 --- a/packages/super-editor/src/core/presentation-editor/tests/DomSelectionGeometry.test.ts +++ b/packages/super-editor/src/core/presentation-editor/tests/DomSelectionGeometry.test.ts @@ -1569,7 +1569,7 @@ describe('computeDomCaretPageLocal', () => { expect(caret).toBe(null); }); - it('returns null when no entry found for position', () => { + it('returns the closest valid caret when position is out of bounds', () => { painterHost.innerHTML = `
@@ -1583,7 +1583,11 @@ describe('computeDomCaretPageLocal', () => { const options = createCaretOptions(); const caret = computeDomCaretPageLocal(options, 999); - expect(caret).toBe(null); + expect(caret).toMatchObject({ + pageIndex: 0, + x: expect.any(Number), + y: expect.any(Number), + }); }); it('returns null when element is not within a page', () => { From 754fa3c131409413d2a81fbbed5cf5b2a7487c9b Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 2 Feb 2026 16:21:05 -0300 Subject: [PATCH 06/21] feat: expose caret layout computation method --- .../src/core/presentation-editor/PresentationEditor.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index b3be7a55b1..6fa1422db0 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -4619,6 +4619,10 @@ export class PresentationEditor extends EventEmitter { return geometry; } + computeCaretLayoutRect(pos: number): { pageIndex: number; x: number; y: number; height: number } | null { + return this.#computeCaretLayoutRect(pos); + } + #getCurrentPageIndex(): number { const session = this.#headerFooterSession?.session; if (session && session.mode !== 'body') { From 266cf82784cc17edf7efe09af032b094a9325361 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 30 Jan 2026 16:14:16 -0300 Subject: [PATCH 07/21] feat: use caret computation to determine current coordinates --- .../vertical-navigation.js | 68 +++++++++++++------ 1 file changed, 48 insertions(+), 20 deletions(-) diff --git a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js index e3947a3f29..d1ce4d788a 100644 --- a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js +++ b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js @@ -6,7 +6,6 @@ export const VerticalNavigationPluginKey = new PluginKey('verticalNavigation'); const createDefaultState = () => ({ goalX: null, - isHandlingVerticalMove: false, }); export const VerticalNavigation = Extension.create({ @@ -26,7 +25,6 @@ export const VerticalNavigation = Extension.create({ if (meta?.type === 'vertical-move') { return { goalX: meta.goalX ?? value.goalX ?? null, - isHandlingVerticalMove: false, }; } if (meta?.type === 'set-goal-x') { @@ -52,6 +50,7 @@ export const VerticalNavigation = Extension.create({ }, props: { handleKeyDown(view, event) { + if (view.composing || !editor.isEditable) return false; if (event.key === 'ArrowLeft' || event.key === 'ArrowRight' || event.key === 'Home' || event.key === 'End') { view.dispatch(view.state.tr.setMeta(VerticalNavigationPluginKey, { type: 'reset-goal-x' })); return false; @@ -68,7 +67,8 @@ export const VerticalNavigation = Extension.create({ const pluginState = VerticalNavigationPluginKey.getState(view.state); let goalX = pluginState?.goalX; - const coords = getCurrentCoords(editor, view.state.selection.head); + const coords = getCurrentCoords(editor, view.state.selection); + if (!coords) return false; if (goalX == null) { goalX = coords?.x; if (!Number.isFinite(goalX)) return false; @@ -76,9 +76,17 @@ export const VerticalNavigation = Extension.create({ view.state.tr.setMeta(VerticalNavigationPluginKey, { type: 'set-goal-x', goalX }), ); } - const newY = getAdjacentLineClientY(editor, coords, event.key === 'ArrowUp' ? -1 : 1); - if (!Number.isFinite(newY)) return false; - return false; + const adjacent = getAdjacentLineClientTarget(editor, coords, event.key === 'ArrowUp' ? -1 : 1); + if (!adjacent) return false; + + const hit = getHitFromLayoutCoords(editor, goalX, adjacent.clientY, coords, adjacent.pageIndex); + if (!hit || !Number.isFinite(hit.pos)) return false; + view.dispatch( + view.state.tr + .setMeta(VerticalNavigationPluginKey, { type: 'vertical-move', goalX }) + .setSelection(view.state.selection.constructor.near(view.state.doc.resolve(hit.pos))), + ); + return true; }, handleDOMEvents: { mousedown: (view) => { @@ -108,24 +116,21 @@ function isPresenting(editor) { return activeEditor === editor; } -function getCurrentCoords(editor, pos) { - const presentationEditor = editor.presentationEditor; - const coords = presentationEditor.coordsAtPos(pos); - if (!coords) return null; - - const layoutSpaceCoords = presentationEditor.normalizeClientPoint(coords.left, coords.top); - if (!layoutSpaceCoords) return null; +function getCurrentCoords(editor, selection) { + const presentationEditor = editor.presentationEditor; + const layoutSpaceCoords = presentationEditor.computeCaretLayoutRect(selection.head); + const clientCoords = presentationEditor.denormalizeClientPoint(layoutSpaceCoords.x, layoutSpaceCoords.y, layoutSpaceCoords.pageIndex) return { - clientX: coords.left, - clientY: coords.top, - height: coords.height, + clientX: clientCoords.x, + clientY: clientCoords.y, + height: layoutSpaceCoords.height, x: layoutSpaceCoords.x, y: layoutSpaceCoords.y, }; } -function getAdjacentLineClientY(editor, coords, direction) { +function getAdjacentLineClientTarget(editor, coords, direction) { const presentationEditor = editor.presentationEditor; const doc = presentationEditor.visibleHost?.ownerDocument ?? document; const caretX = coords.clientX; @@ -134,8 +139,23 @@ function getAdjacentLineClientY(editor, coords, direction) { if (!currentLine) return null; const adjacentLine = findAdjacentLineElement(currentLine, direction); if (!adjacentLine) return null; + const pageEl = adjacentLine.closest?.(`.${DOM_CLASS_NAMES.PAGE}`); + const pageIndex = pageEl ? Number(pageEl.dataset.pageIndex ?? 'NaN') : null; const rect = adjacentLine.getBoundingClientRect(); - return rect.top + rect.height / 2; + const clientY = rect.top + rect.height / 2; + if (!Number.isFinite(clientY)) return null; + return { + clientY: rect.top + rect.height / 2, + pageIndex: Number.isFinite(pageIndex) ? pageIndex : undefined, + }; +} + +function getHitFromLayoutCoords(editor, goalX, clientY, coords, pageIndex) { + const presentationEditor = editor.presentationEditor; + const clientPoint = presentationEditor.denormalizeClientPoint(goalX, coords.y, pageIndex); + const clientX = clientPoint?.x; + if (!Number.isFinite(clientX)) return null; + return presentationEditor.hitTest(clientX, clientY); } function findLineElementAtPoint(doc, x, y) { @@ -151,6 +171,8 @@ function findAdjacentLineElement(currentLine, direction) { const lineClass = DOM_CLASS_NAMES.LINE; const fragmentClass = DOM_CLASS_NAMES.FRAGMENT; const pageClass = DOM_CLASS_NAMES.PAGE; + const headerClass = 'superdoc-page-header'; + const footerClass = 'superdoc-page-footer'; const fragment = currentLine.closest?.(`.${fragmentClass}`); const page = currentLine.closest?.(`.${pageClass}`); if (!fragment || !page) return null; @@ -162,7 +184,10 @@ function findAdjacentLineElement(currentLine, direction) { if (nextInFragment) return nextInFragment; } - const fragments = Array.from(page.querySelectorAll(`.${fragmentClass}`)); + const fragments = Array.from(page.querySelectorAll(`.${fragmentClass}`)).filter((frag) => { + const parent = frag.closest?.(`.${headerClass}, .${footerClass}`); + return !parent; + }); const fragmentIndex = fragments.indexOf(fragment); if (fragmentIndex !== -1) { const nextFragment = fragments[fragmentIndex + direction]; @@ -175,7 +200,10 @@ function findAdjacentLineElement(currentLine, direction) { if (pageIndex === -1) return null; const nextPage = pages[pageIndex + direction]; if (!nextPage) return null; - const pageFragments = Array.from(nextPage.querySelectorAll(`.${fragmentClass}`)); + const pageFragments = Array.from(nextPage.querySelectorAll(`.${fragmentClass}`)).filter((frag) => { + const parent = frag.closest?.(`.${headerClass}, .${footerClass}`); + return !parent; + }); if (direction > 0) { return getEdgeLineFromFragment(pageFragments[0], direction); } From 4eb55620779305d00628e57cadc0100790fd2030 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 30 Jan 2026 16:17:19 -0300 Subject: [PATCH 08/21] feat: change selection after navigation --- .../vertical-navigation.js | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js index d1ce4d788a..2961ec9b2b 100644 --- a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js +++ b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js @@ -1,6 +1,7 @@ import { Extension } from '@core/Extension.js'; -import { Plugin, PluginKey } from 'prosemirror-state'; +import { Plugin, PluginKey, TextSelection, NodeSelection } from 'prosemirror-state'; import { DOM_CLASS_NAMES } from '@superdoc/painter-dom'; +import { CellSelection } from 'prosemirror-tables'; export const VerticalNavigationPluginKey = new PluginKey('verticalNavigation'); @@ -81,11 +82,9 @@ export const VerticalNavigation = Extension.create({ const hit = getHitFromLayoutCoords(editor, goalX, adjacent.clientY, coords, adjacent.pageIndex); if (!hit || !Number.isFinite(hit.pos)) return false; - view.dispatch( - view.state.tr - .setMeta(VerticalNavigationPluginKey, { type: 'vertical-move', goalX }) - .setSelection(view.state.selection.constructor.near(view.state.doc.resolve(hit.pos))), - ); + const selection = buildSelection(view.state, hit.pos, event.shiftKey); + if (!selection) return false; + view.dispatch(view.state.tr.setMeta(VerticalNavigationPluginKey, { type: 'vertical-move', goalX }).setSelection(selection)); return true; }, handleDOMEvents: { @@ -158,6 +157,19 @@ function getHitFromLayoutCoords(editor, goalX, clientY, coords, pageIndex) { return presentationEditor.hitTest(clientX, clientY); } +function buildSelection(state, pos, extend) { + const { doc, selection } = state; + if (selection instanceof NodeSelection || selection instanceof CellSelection) { + return null; + } + const clamped = Math.max(0, Math.min(pos, doc.content.size)); + if (extend) { + const anchor = selection.anchor ?? selection.from; + return TextSelection.create(doc, clamped, anchor); + } + return TextSelection.create(doc, clamped); +} + function findLineElementAtPoint(doc, x, y) { if (typeof doc?.elementsFromPoint !== 'function') return null; const chain = doc.elementsFromPoint(x, y) ?? []; From 4b4b523d44f7206d3c68b03040300979cf5e0a81 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 2 Feb 2026 16:01:44 -0300 Subject: [PATCH 09/21] feat: add function for computing page Y offset --- .../dom/CoordinateTransform.ts | 23 ++++ .../tests/CoordinateTransform.test.ts | 121 +++++++++++++++++- 2 files changed, 142 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/core/presentation-editor/dom/CoordinateTransform.ts b/packages/super-editor/src/core/presentation-editor/dom/CoordinateTransform.ts index de7e0f8324..b2f5819128 100644 --- a/packages/super-editor/src/core/presentation-editor/dom/CoordinateTransform.ts +++ b/packages/super-editor/src/core/presentation-editor/dom/CoordinateTransform.ts @@ -66,6 +66,29 @@ export function getPageOffsetX(options: { return getPageOffsets(options)?.x ?? null; } +export function getPageOffsetY(options: { + painterHost: HTMLElement | null; + viewportHost: HTMLElement | null; + zoom: number; + pageIndex: number; +}): number | null { + if (!options.painterHost || !options.viewportHost) { + return null; + } + + const pageEl = options.painterHost.querySelector( + `.superdoc-page[data-page-index="${options.pageIndex}"]`, + ) as HTMLElement | null; + if (!pageEl) return null; + + const pageRect = pageEl.getBoundingClientRect(); + const viewportRect = options.viewportHost.getBoundingClientRect(); + + const offsetY = (pageRect.top - viewportRect.top) / options.zoom; + + return offsetY; +} + /** * Converts page-local coordinates to overlay-absolute coordinates. * diff --git a/packages/super-editor/src/core/presentation-editor/tests/CoordinateTransform.test.ts b/packages/super-editor/src/core/presentation-editor/tests/CoordinateTransform.test.ts index c960ccd802..c91a7df39d 100644 --- a/packages/super-editor/src/core/presentation-editor/tests/CoordinateTransform.test.ts +++ b/packages/super-editor/src/core/presentation-editor/tests/CoordinateTransform.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { convertPageLocalToOverlayCoords, getPageOffsetX } from '../dom/CoordinateTransform.js'; +import { convertPageLocalToOverlayCoords, getPageOffsetX, getPageOffsetY } from '../dom/CoordinateTransform.js'; describe('CoordinateTransform', () => { let mockDom: { @@ -90,6 +90,123 @@ describe('CoordinateTransform', () => { }); }); + describe('getPageOffsetY', () => { + it('returns null when painterHost is null', () => { + const result = getPageOffsetY({ + painterHost: null, + viewportHost: mockDom.viewportHost, + zoom: 1, + pageIndex: 0, + }); + + expect(result).toBe(null); + }); + + it('returns null when viewportHost is null', () => { + const result = getPageOffsetY({ + painterHost: mockDom.painterHost, + viewportHost: null, + zoom: 1, + pageIndex: 0, + }); + + expect(result).toBe(null); + }); + + it('returns null when page element not found', () => { + const result = getPageOffsetY({ + painterHost: mockDom.painterHost, + viewportHost: mockDom.viewportHost, + zoom: 1, + pageIndex: 99, + }); + + expect(result).toBe(null); + }); + + it('calculates page offset Y correctly', () => { + const pageEl = mockDom.painterHost.querySelector('.superdoc-page') as HTMLElement; + + vi.spyOn(pageEl, 'getBoundingClientRect').mockReturnValue({ + top: 140, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect); + + vi.spyOn(mockDom.viewportHost, 'getBoundingClientRect').mockReturnValue({ + top: 20, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect); + + const result = getPageOffsetY({ + painterHost: mockDom.painterHost, + viewportHost: mockDom.viewportHost, + zoom: 1, + pageIndex: 0, + }); + + expect(result).toBe(120); + }); + + it('accounts for zoom in offset calculation', () => { + const pageEl = mockDom.painterHost.querySelector('.superdoc-page') as HTMLElement; + + vi.spyOn(pageEl, 'getBoundingClientRect').mockReturnValue({ + top: 140, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect); + + vi.spyOn(mockDom.viewportHost, 'getBoundingClientRect').mockReturnValue({ + top: 20, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect); + + const resultZoom1 = getPageOffsetY({ + painterHost: mockDom.painterHost, + viewportHost: mockDom.viewportHost, + zoom: 1, + pageIndex: 0, + }); + + const resultZoom2 = getPageOffsetY({ + painterHost: mockDom.painterHost, + viewportHost: mockDom.viewportHost, + zoom: 2, + pageIndex: 0, + }); + + expect(resultZoom1).toBe(120); + expect(resultZoom2).toBe(60); + }); + }); + describe('convertPageLocalToOverlayCoords', () => { it('returns null for invalid pageIndex (negative)', () => { const result = convertPageLocalToOverlayCoords({ From 53f92871e656b238d1592791fd70ff581500d035 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 2 Feb 2026 16:02:24 -0300 Subject: [PATCH 10/21] fix: adjust coordinates denormalization to account for Y offset --- .../presentation-editor/PresentationEditor.ts | 11 ++ .../dom/PointerNormalization.test.ts | 153 ++++++++++++++++++ .../dom/PointerNormalization.ts | 6 + .../vertical-navigation.js | 1 + 4 files changed, 171 insertions(+) create mode 100644 packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.test.ts diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 6fa1422db0..0ef2f8a16e 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -15,6 +15,7 @@ import { import { convertPageLocalToOverlayCoords as convertPageLocalToOverlayCoordsFromTransform, getPageOffsetX as getPageOffsetXFromTransform, + getPageOffsetY as getPageOffsetYFromTransform, } from './dom/CoordinateTransform.js'; import { normalizeClientPoint as normalizeClientPointFromPointer, @@ -4450,6 +4451,15 @@ export class PresentationEditor extends EventEmitter { }); } + #getPageOffsetY(pageIndex: number): number | null { + return getPageOffsetYFromTransform({ + painterHost: this.#painterHost, + viewportHost: this.#viewportHost, + zoom: this.#layoutOptions.zoom ?? 1, + pageIndex, + }); + } + #convertPageLocalToOverlayCoords( pageIndex: number, pageLocalX: number, @@ -4529,6 +4539,7 @@ export class PresentationEditor extends EventEmitter { visibleHost: this.#visibleHost, zoom: this.#layoutOptions.zoom ?? 1, getPageOffsetX: (pageIndex) => this.#getPageOffsetX(pageIndex), + getPageOffsetY: (pageIndex) => this.#getPageOffsetY(pageIndex), }, layoutX, layoutY, diff --git a/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.test.ts b/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.test.ts new file mode 100644 index 0000000000..6106b0db46 --- /dev/null +++ b/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.test.ts @@ -0,0 +1,153 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { denormalizeClientPoint, normalizeClientPoint } from './PointerNormalization.js'; + +describe('PointerNormalization', () => { + const makeHosts = () => { + const viewportHost = document.createElement('div'); + const visibleHost = document.createElement('div'); + viewportHost.appendChild(visibleHost); + + vi.spyOn(viewportHost, 'getBoundingClientRect').mockReturnValue({ + left: 20, + top: 10, + right: 0, + bottom: 0, + width: 0, + height: 0, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect); + + (visibleHost as HTMLElement & { scrollLeft: number; scrollTop: number }).scrollLeft = 30; + (visibleHost as HTMLElement & { scrollLeft: number; scrollTop: number }).scrollTop = 40; + + return { viewportHost, visibleHost }; + }; + + afterEach(() => { + vi.restoreAllMocks(); + delete (document as Document & { elementsFromPoint?: Document['elementsFromPoint'] }).elementsFromPoint; + }); + + describe('normalizeClientPoint', () => { + it('returns null for non-finite client coordinates', () => { + const { viewportHost, visibleHost } = makeHosts(); + + const options = { + viewportHost, + visibleHost, + zoom: 1, + getPageOffsetX: () => 0, + }; + + expect(normalizeClientPoint(options, NaN, 0)).toBe(null); + expect(normalizeClientPoint(options, 0, Infinity)).toBe(null); + expect(normalizeClientPoint(options, -Infinity, 0)).toBe(null); + }); + + it('normalizes client coordinates to layout coordinates with zoom and scroll', () => { + const { viewportHost, visibleHost } = makeHosts(); + + const options = { + viewportHost, + visibleHost, + zoom: 2, + getPageOffsetX: () => 0, + }; + + const result = normalizeClientPoint(options, 200, 150); + expect(result).toEqual({ x: 105, y: 90 }); + }); + + it('adjusts X when the pointer is over a page with a known offset', () => { + const { viewportHost, visibleHost } = makeHosts(); + const pageEl = document.createElement('div'); + pageEl.className = 'superdoc-page'; + pageEl.dataset.pageIndex = '2'; + + (document as Document & { elementsFromPoint: Document['elementsFromPoint'] }).elementsFromPoint = vi + .fn() + .mockReturnValue([pageEl]); + + const options = { + viewportHost, + visibleHost, + zoom: 2, + getPageOffsetX: (pageIndex: number) => (pageIndex === 2 ? 12 : null), + }; + + const result = normalizeClientPoint(options, 200, 150); + expect(result).toEqual({ x: 93, y: 90 }); + }); + + it('does not adjust X when page offset is unavailable', () => { + const { viewportHost, visibleHost } = makeHosts(); + const pageEl = document.createElement('div'); + pageEl.className = 'superdoc-page'; + pageEl.dataset.pageIndex = '3'; + + (document as Document & { elementsFromPoint: Document['elementsFromPoint'] }).elementsFromPoint = vi + .fn() + .mockReturnValue([pageEl]); + + const options = { + viewportHost, + visibleHost, + zoom: 2, + getPageOffsetX: () => null, + }; + + const result = normalizeClientPoint(options, 200, 150); + expect(result).toEqual({ x: 105, y: 90 }); + }); + }); + + describe('denormalizeClientPoint', () => { + it('returns null for non-finite layout coordinates', () => { + const { viewportHost, visibleHost } = makeHosts(); + + const options = { + viewportHost, + visibleHost, + zoom: 1, + getPageOffsetX: () => 0, + getPageOffsetY: () => 0, + }; + + expect(denormalizeClientPoint(options, NaN, 0)).toBe(null); + expect(denormalizeClientPoint(options, 0, Infinity)).toBe(null); + }); + + it('denormalizes layout coordinates to client coordinates', () => { + const { viewportHost, visibleHost } = makeHosts(); + + const options = { + viewportHost, + visibleHost, + zoom: 2, + getPageOffsetX: () => 0, + getPageOffsetY: () => 0, + }; + + const result = denormalizeClientPoint(options, 50, 60); + expect(result).toEqual({ x: 90, y: 90 }); + }); + + it('applies page offsets when a page index is provided', () => { + const { viewportHost, visibleHost } = makeHosts(); + + const options = { + viewportHost, + visibleHost, + zoom: 2, + getPageOffsetX: () => 15, + getPageOffsetY: () => 25, + }; + + const result = denormalizeClientPoint(options, 50, 60, 3); + expect(result).toEqual({ x: 120, y: 140 }); + }); + }); +}); diff --git a/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.ts b/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.ts index 7f898c0bb0..483a66349c 100644 --- a/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.ts +++ b/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.ts @@ -50,6 +50,7 @@ export function denormalizeClientPoint( visibleHost: HTMLElement; zoom: number; getPageOffsetX: (pageIndex: number) => number | null; + getPageOffsetY: (pageIndex: number) => number | null; }, layoutX: number, layoutY: number, @@ -71,6 +72,11 @@ export function denormalizeClientPoint( if (pageOffsetX != null) { baseX = layoutX + pageOffsetX; } + + const pageOffsetY = options.getPageOffsetY(pageIndex); + if (pageOffsetY != null) { + layoutY += pageOffsetY; + } } return { diff --git a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js index 2961ec9b2b..ee4febf414 100644 --- a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js +++ b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js @@ -119,6 +119,7 @@ function getCurrentCoords(editor, selection) { const presentationEditor = editor.presentationEditor; const layoutSpaceCoords = presentationEditor.computeCaretLayoutRect(selection.head); + if (!layoutSpaceCoords) return null; const clientCoords = presentationEditor.denormalizeClientPoint(layoutSpaceCoords.x, layoutSpaceCoords.y, layoutSpaceCoords.pageIndex) return { clientX: clientCoords.x, From 240c2b19f9c3a623ee597001f71c7ac6e80f98a4 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 2 Feb 2026 16:38:41 -0300 Subject: [PATCH 11/21] docs: add documentation to vertical navigation plugin --- .../dom/CoordinateTransform.ts | 6 + .../dom/PointerNormalization.ts | 25 ++++ .../vertical-navigation.js | 112 ++++++++++++++++++ 3 files changed, 143 insertions(+) diff --git a/packages/super-editor/src/core/presentation-editor/dom/CoordinateTransform.ts b/packages/super-editor/src/core/presentation-editor/dom/CoordinateTransform.ts index b2f5819128..3717835b78 100644 --- a/packages/super-editor/src/core/presentation-editor/dom/CoordinateTransform.ts +++ b/packages/super-editor/src/core/presentation-editor/dom/CoordinateTransform.ts @@ -66,6 +66,12 @@ export function getPageOffsetX(options: { return getPageOffsets(options)?.x ?? null; } +/** + * Calculates the vertical offset of a page element within the viewport. + * + * @param options - Configuration object containing DOM elements and page information. + * @returns The Y offset in layout-space units, or null if calculation fails. + */ export function getPageOffsetY(options: { painterHost: HTMLElement | null; viewportHost: HTMLElement | null; diff --git a/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.ts b/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.ts index 483a66349c..45eb05c697 100644 --- a/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.ts +++ b/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.ts @@ -1,3 +1,15 @@ +/** + * Convert a client (screen) point into layout coordinates relative to the + * document content. + * + * The result accounts for the viewport's scroll offsets, zoom factor, and + * optional per-page X offsets when the pointer is over a page element. + * + * @param options - Context needed to map between client and layout coordinates. + * @param clientX - The client X position, in screen pixels. + * @param clientY - The client Y position, in screen pixels. + * @returns The normalized layout point, or null if inputs are not finite. + */ export function normalizeClientPoint( options: { viewportHost: HTMLElement; @@ -44,6 +56,19 @@ export function normalizeClientPoint( }; } +/** + * Convert layout coordinates back into client (screen) coordinates. + * + * The result accounts for zoom, viewport scroll, and optional per-page offsets. + * When a height is provided, it is scaled by the zoom factor. + * + * @param options - Context needed to map between layout and client coordinates. + * @param layoutX - The layout X position, in document units. + * @param layoutY - The layout Y position, in document units. + * @param pageIndex - Optional page index used to apply per-page offsets. + * @param height - Optional layout height to scale into screen pixels. + * @returns The client-space point (and optional height), or null if inputs are not finite. + */ export function denormalizeClientPoint( options: { viewportHost: HTMLElement; diff --git a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js index ee4febf414..2a7e2e6467 100644 --- a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js +++ b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js @@ -5,13 +5,27 @@ import { CellSelection } from 'prosemirror-tables'; export const VerticalNavigationPluginKey = new PluginKey('verticalNavigation'); +/** + * Creates the default plugin state for vertical navigation. + * @returns {{ goalX: number | null }} State with no goal X position set. + */ const createDefaultState = () => ({ goalX: null, }); +/** + * Enables vertical caret navigation in presentation mode by preserving a goal X + * column and translating Up/Down arrow presses into layout-engine hit tests. + * This keeps the caret aligned across wrapped lines, fragments, and pages while + * respecting selection extension and avoiding non-text selections. + */ export const VerticalNavigation = Extension.create({ name: 'verticalNavigation', + /** + * Registers ProseMirror plugins used for vertical navigation. + * @returns {import('prosemirror-state').Plugin[]} Plugin list, empty when disabled. + */ addPmPlugins() { if (this.editor.options?.isHeaderOrFooter) return []; if (this.editor.options?.isHeadless) return []; @@ -20,7 +34,17 @@ export const VerticalNavigation = Extension.create({ const plugin = new Plugin({ key: VerticalNavigationPluginKey, state: { + /** + * Initializes plugin state. + * @returns {{ goalX: number | null }} Initial plugin state. + */ init: () => createDefaultState(), + /** + * Updates plugin state based on transaction metadata and selection changes. + * @param {import('prosemirror-state').Transaction} tr + * @param {{ goalX: number | null }} value + * @returns {{ goalX: number | null }} + */ apply(tr, value) { const meta = tr.getMeta(VerticalNavigationPluginKey); if (meta?.type === 'vertical-move') { @@ -50,7 +74,15 @@ export const VerticalNavigation = Extension.create({ }, }, props: { + /** + * Handles vertical navigation key presses while presenting. + * @param {import('prosemirror-view').EditorView} view + * @param {KeyboardEvent} event + * @returns {boolean} Whether the event was handled. + */ handleKeyDown(view, event) { + + // Guard clauses if (view.composing || !editor.isEditable) return false; if (event.key === 'ArrowLeft' || event.key === 'ArrowRight' || event.key === 'Home' || event.key === 'End') { view.dispatch(view.state.tr.setMeta(VerticalNavigationPluginKey, { type: 'reset-goal-x' })); @@ -66,6 +98,13 @@ export const VerticalNavigation = Extension.create({ return false; } + // Basic logic: + // 1. On first vertical move, record goal X from current caret position (in layout space coordinates). + // 2. Find adjacent line element in the desired direction. + // 3. Perform hit test at (goal X, adjacent line center Y) to find target position. + // 4. Move selection to target position, extending if Shift is held. + + // 1. Get or set goal X const pluginState = VerticalNavigationPluginKey.getState(view.state); let goalX = pluginState?.goalX; const coords = getCurrentCoords(editor, view.state.selection); @@ -77,25 +116,45 @@ export const VerticalNavigation = Extension.create({ view.state.tr.setMeta(VerticalNavigationPluginKey, { type: 'set-goal-x', goalX }), ); } + + // 2. Find adjacent line const adjacent = getAdjacentLineClientTarget(editor, coords, event.key === 'ArrowUp' ? -1 : 1); if (!adjacent) return false; + // 3. Hit test at (goal X, adjacent line center Y) const hit = getHitFromLayoutCoords(editor, goalX, adjacent.clientY, coords, adjacent.pageIndex); if (!hit || !Number.isFinite(hit.pos)) return false; + + // 4. Move selection const selection = buildSelection(view.state, hit.pos, event.shiftKey); if (!selection) return false; view.dispatch(view.state.tr.setMeta(VerticalNavigationPluginKey, { type: 'vertical-move', goalX }).setSelection(selection)); return true; }, handleDOMEvents: { + /** + * Resets goal X on pointer-driven selection changes. + * @param {import('prosemirror-view').EditorView} view + * @returns {boolean} + */ mousedown: (view) => { view.dispatch(view.state.tr.setMeta(VerticalNavigationPluginKey, { type: 'reset-goal-x' })); return false; }, + /** + * Resets goal X on touch-driven selection changes. + * @param {import('prosemirror-view').EditorView} view + * @returns {boolean} + */ touchstart: (view) => { view.dispatch(view.state.tr.setMeta(VerticalNavigationPluginKey, { type: 'reset-goal-x' })); return false; }, + /** + * Resets goal X when IME composition starts. + * @param {import('prosemirror-view').EditorView} view + * @returns {boolean} + */ compositionstart: (view) => { view.dispatch(view.state.tr.setMeta(VerticalNavigationPluginKey, { type: 'reset-goal-x' })); return false; @@ -108,6 +167,11 @@ export const VerticalNavigation = Extension.create({ }, }); +/** + * Determines whether the editor is the active presentation editor. + * @param {Object} editor + * @returns {boolean} + */ function isPresenting(editor) { const presentationCtx = editor?.presentationEditor; if (!presentationCtx) return false; @@ -115,6 +179,12 @@ function isPresenting(editor) { return activeEditor === editor; } +/** + * Gets the current caret coordinates in both layout and client space. + * @param {Object} editor + * @param {import('prosemirror-state').Selection} selection + * @returns {{ clientX: number, clientY: number, height: number, x: number, y: number } | null} + */ function getCurrentCoords(editor, selection) { const presentationEditor = editor.presentationEditor; @@ -130,6 +200,13 @@ function getCurrentCoords(editor, selection) { }; } +/** + * Finds the adjacent line center Y in client space and associated page index. + * @param {Object} editor + * @param {{ clientX: number, clientY: number, height: number }} coords + * @param {number} direction -1 for up, 1 for down. + * @returns {{ clientY: number, pageIndex?: number } | null} + */ function getAdjacentLineClientTarget(editor, coords, direction) { const presentationEditor = editor.presentationEditor; const doc = presentationEditor.visibleHost?.ownerDocument ?? document; @@ -150,6 +227,15 @@ function getAdjacentLineClientTarget(editor, coords, direction) { }; } +/** + * Converts layout coords to client coords and performs a hit test. + * @param {Object} editor + * @param {number} goalX + * @param {number} clientY + * @param {{ y: number }} coords + * @param {number | undefined} pageIndex + * @returns {{ pos: number } | null} + */ function getHitFromLayoutCoords(editor, goalX, clientY, coords, pageIndex) { const presentationEditor = editor.presentationEditor; const clientPoint = presentationEditor.denormalizeClientPoint(goalX, coords.y, pageIndex); @@ -158,6 +244,13 @@ function getHitFromLayoutCoords(editor, goalX, clientY, coords, pageIndex) { return presentationEditor.hitTest(clientX, clientY); } +/** + * Builds a text selection for the target position, optionally extending. + * @param {import('prosemirror-state').EditorState} state + * @param {number} pos + * @param {boolean} extend + * @returns {import('prosemirror-state').Selection | null} + */ function buildSelection(state, pos, extend) { const { doc, selection } = state; if (selection instanceof NodeSelection || selection instanceof CellSelection) { @@ -171,6 +264,13 @@ function buildSelection(state, pos, extend) { return TextSelection.create(doc, clamped); } +/** + * Finds a line element at the given client point. + * @param {Document} doc + * @param {number} x + * @param {number} y + * @returns {Element | null} + */ function findLineElementAtPoint(doc, x, y) { if (typeof doc?.elementsFromPoint !== 'function') return null; const chain = doc.elementsFromPoint(x, y) ?? []; @@ -180,6 +280,12 @@ function findLineElementAtPoint(doc, x, y) { return null; } +/** + * Locates the next or previous line element across fragments/pages. + * @param {Element} currentLine + * @param {number} direction -1 for up, 1 for down. + * @returns {Element | null} + */ function findAdjacentLineElement(currentLine, direction) { const lineClass = DOM_CLASS_NAMES.LINE; const fragmentClass = DOM_CLASS_NAMES.FRAGMENT; @@ -223,6 +329,12 @@ function findAdjacentLineElement(currentLine, direction) { return getEdgeLineFromFragment(pageFragments[pageFragments.length - 1], direction); } +/** + * Returns the first or last line in a fragment, depending on direction. + * @param {Element | null | undefined} fragment + * @param {number} direction + * @returns {Element | null} + */ function getEdgeLineFromFragment(fragment, direction) { if (!fragment) return null; const lineEls = Array.from(fragment.querySelectorAll(`.${DOM_CLASS_NAMES.LINE}`)); From 529c0cdca7d20812e284608f80f306f8279a33f5 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 2 Feb 2026 17:40:41 -0300 Subject: [PATCH 12/21] test: add tests for the new plugin --- .../vertical-navigation.test.js | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.test.js diff --git a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.test.js b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.test.js new file mode 100644 index 0000000000..92b8c360a9 --- /dev/null +++ b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.test.js @@ -0,0 +1,174 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Schema } from 'prosemirror-model'; +import { EditorState, TextSelection } from 'prosemirror-state'; + +import { Extension } from '@core/Extension.js'; +import { DOM_CLASS_NAMES } from '@superdoc/painter-dom'; +import { VerticalNavigation, VerticalNavigationPluginKey } from './vertical-navigation.js'; + +const createSchema = () => { + const nodes = { + doc: { content: 'block+' }, + paragraph: { content: 'inline*', group: 'block', toDOM: () => ['p', 0], parseDOM: [{ tag: 'p' }] }, + text: { group: 'inline' }, + }; + return new Schema({ nodes, marks: {} }); +}; + +const createDomStructure = () => { + const page = document.createElement('div'); + page.className = DOM_CLASS_NAMES.PAGE; + page.dataset.pageIndex = '0'; + + const fragment = document.createElement('div'); + fragment.className = DOM_CLASS_NAMES.FRAGMENT; + page.appendChild(fragment); + + const line1 = document.createElement('div'); + line1.className = DOM_CLASS_NAMES.LINE; + fragment.appendChild(line1); + + const line2 = document.createElement('div'); + line2.className = DOM_CLASS_NAMES.LINE; + fragment.appendChild(line2); + + document.body.appendChild(page); + + return { line1, line2 }; +}; + +const createEnvironment = ({ presenting = true, selection = null, overrides = {} } = {}) => { + const schema = createSchema(); + const doc = schema.node('doc', null, [schema.node('paragraph', null, [schema.text('hello world')])]); + const initialSelection = selection ?? TextSelection.create(doc, 1, 1); + + const visibleHost = document.createElement('div'); + document.body.appendChild(visibleHost); + + const editor = { + options: { isHeaderOrFooter: false, isHeadless: false }, + isEditable: true, + presentationEditor: null, + }; + + const presentationEditor = { + visibleHost, + getActiveEditor: vi.fn(() => (presenting ? editor : null)), + computeCaretLayoutRect: vi.fn(() => ({ x: 75, y: 40, height: 10, pageIndex: 0 })), + denormalizeClientPoint: vi.fn((x, y) => ({ x: x + 1, y: y + 2 })), + hitTest: vi.fn(() => ({ pos: 5 })), + }; + + editor.presentationEditor = presentationEditor; + + const extension = Extension.create(VerticalNavigation.config); + extension.editor = editor; + extension.addPmPlugins = VerticalNavigation.config.addPmPlugins.bind(extension); + + const plugin = extension.addPmPlugins()[0]; + let state = EditorState.create({ schema, doc, selection: initialSelection, plugins: [plugin] }); + + const view = { + state, + composing: false, + dispatch: vi.fn((tr) => { + state = state.apply(tr); + view.state = state; + }), + }; + + Object.defineProperty(editor, 'state', { + get() { + return view.state; + }, + }); + editor.view = view; + + Object.assign(editor, overrides.editor ?? {}); + Object.assign(presentationEditor, overrides.presentationEditor ?? {}); + if (overrides.view) Object.assign(view, overrides.view); + + return { editor, plugin, view, presentationEditor }; +}; + +afterEach(() => { + vi.restoreAllMocks(); + delete document.elementsFromPoint; + document.body.innerHTML = ''; +}); + +describe('VerticalNavigation', () => { + it('returns false when editor is not presenting', () => { + const { plugin, view } = createEnvironment({ presenting: false }); + + const handled = plugin.props.handleKeyDown(view, { key: 'ArrowDown', shiftKey: false }); + expect(handled).toBe(false); + expect(view.dispatch).not.toHaveBeenCalled(); + }); + + it('moves selection on ArrowDown and sets goalX on first move', () => { + const { line1, line2 } = createDomStructure(); + vi.spyOn(line2, 'getBoundingClientRect').mockReturnValue({ + top: 200, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 20, + x: 0, + y: 0, + toJSON: () => ({}), + }); + + document.elementsFromPoint = vi.fn(() => [line1]); + + const { plugin, view, presentationEditor } = createEnvironment(); + presentationEditor.hitTest.mockReturnValue({ pos: 4 }); + presentationEditor.denormalizeClientPoint.mockReturnValue({ x: 111, y: 0 }); + + const handled = plugin.props.handleKeyDown(view, { key: 'ArrowDown', shiftKey: false }); + + expect(handled).toBe(true); + expect(presentationEditor.hitTest).toHaveBeenCalledWith(111, 210); + expect(view.state.selection.head).toBe(4); + + const pluginState = VerticalNavigationPluginKey.getState(view.state); + expect(pluginState.goalX).toBe(75); + }); + + it('extends selection when shift is held', () => { + const { line1, line2 } = createDomStructure(); + vi.spyOn(line2, 'getBoundingClientRect').mockReturnValue({ + top: 300, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 20, + x: 0, + y: 0, + toJSON: () => ({}), + }); + + document.elementsFromPoint = vi.fn(() => [line1]); + + const { plugin, view, presentationEditor } = createEnvironment(); + presentationEditor.hitTest.mockReturnValue({ pos: 6 }); + + const handled = plugin.props.handleKeyDown(view, { key: 'ArrowDown', shiftKey: true }); + + expect(handled).toBe(true); + expect(view.state.selection.from).toBe(1); + expect(view.state.selection.to).toBe(6); + }); + + it('resets goalX on pointer-driven selection changes', () => { + const { plugin, view } = createEnvironment(); + + plugin.props.handleDOMEvents.mousedown(view); + expect(view.dispatch).toHaveBeenCalled(); + + const dispatchedTr = view.dispatch.mock.calls[0][0]; + expect(dispatchedTr.getMeta(VerticalNavigationPluginKey)).toMatchObject({ type: 'reset-goal-x' }); + }); +}); From d84a95a4b1e9e6c821fc76b4e707027367be6dbc Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 3 Feb 2026 11:02:49 -0300 Subject: [PATCH 13/21] fix: account for zomm during height computation --- .../presentation-editor/PresentationEditor.ts | 3 ++- .../dom/PointerNormalization.test.ts | 15 +++++++++++++++ .../dom/PointerNormalization.ts | 10 ++++++++-- .../vertical-navigation/vertical-navigation.js | 4 ++-- 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 0ef2f8a16e..5846070aa0 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -4532,7 +4532,7 @@ export class PresentationEditor extends EventEmitter { ); } - denormalizeClientPoint(layoutX: number, layoutY: number, pageIndex?: number): { x: number; y: number } | null { + denormalizeClientPoint(layoutX: number, layoutY: number, pageIndex?: number, height?: number): { x: number; y: number, height?: number } | null { return denormalizeClientPointFromPointer( { viewportHost: this.#viewportHost, @@ -4544,6 +4544,7 @@ export class PresentationEditor extends EventEmitter { layoutX, layoutY, pageIndex, + height, ); } diff --git a/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.test.ts b/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.test.ts index 6106b0db46..b94c6a8fb6 100644 --- a/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.test.ts +++ b/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.test.ts @@ -149,5 +149,20 @@ describe('PointerNormalization', () => { const result = denormalizeClientPoint(options, 50, 60, 3); expect(result).toEqual({ x: 120, y: 140 }); }); + + it('scales height based on the zoom level when provided', () => { + const { viewportHost, visibleHost } = makeHosts(); + + const options = { + viewportHost, + visibleHost, + zoom: 1.5, + getPageOffsetX: () => 0, + getPageOffsetY: () => 0, + }; + + const result = denormalizeClientPoint(options, 10, 12, undefined, 8); + expect(result).toEqual({ x: 5, y: -12, height: 12 }); + }); }); }); diff --git a/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.ts b/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.ts index 45eb05c697..fb3aa25eab 100644 --- a/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.ts +++ b/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.ts @@ -80,7 +80,8 @@ export function denormalizeClientPoint( layoutX: number, layoutY: number, pageIndex?: number, -): { x: number; y: number } | null { + height?: number, +): { x: number; y: number, height?: number } | null { if (!Number.isFinite(layoutX) || !Number.isFinite(layoutY)) { return null; } @@ -104,8 +105,13 @@ export function denormalizeClientPoint( } } - return { + const result = { x: baseX * options.zoom - scrollLeft + rect.left, y: layoutY * options.zoom - scrollTop + rect.top, }; + if (Number.isFinite(height)) { + result['height'] = height * options.zoom; + } + return result; + } diff --git a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js index 2a7e2e6467..b6ce369830 100644 --- a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js +++ b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js @@ -190,11 +190,11 @@ function getCurrentCoords(editor, selection) { const presentationEditor = editor.presentationEditor; const layoutSpaceCoords = presentationEditor.computeCaretLayoutRect(selection.head); if (!layoutSpaceCoords) return null; - const clientCoords = presentationEditor.denormalizeClientPoint(layoutSpaceCoords.x, layoutSpaceCoords.y, layoutSpaceCoords.pageIndex) + const clientCoords = presentationEditor.denormalizeClientPoint(layoutSpaceCoords.x, layoutSpaceCoords.y, layoutSpaceCoords.pageIndex, layoutSpaceCoords.height); return { clientX: clientCoords.x, clientY: clientCoords.y, - height: layoutSpaceCoords.height, + height: clientCoords.height, x: layoutSpaceCoords.x, y: layoutSpaceCoords.y, }; From 9cecef387a8c74eb0b563e222883acf3e5e93114 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 4 Feb 2026 15:00:53 -0300 Subject: [PATCH 14/21] refactor: retrieve page element using existing helper --- .../src/core/presentation-editor/PresentationEditor.ts | 4 +--- .../presentation-editor/dom/CoordinateTransform.ts | 10 ++++------ .../presentation-editor/selection/CaretGeometry.ts | 3 ++- .../core/presentation-editor/utils/AnchorNavigation.ts | 3 ++- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 5846070aa0..1708c3678a 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -1705,9 +1705,7 @@ export class PresentationEditor extends EventEmitter { const isLeftMargin = marginLeft > 0 && x < marginLeft; const isRightMargin = marginRight > 0 && x > pageWidth - marginRight; - const pageEl = this.#viewportHost.querySelector( - `.superdoc-page[data-page-index="${pageIndex}"]`, - ) as HTMLElement | null; + const pageEl = getPageElementByIndex(this.#viewportHost, pageIndex); if (!pageEl) { return null; } diff --git a/packages/super-editor/src/core/presentation-editor/dom/CoordinateTransform.ts b/packages/super-editor/src/core/presentation-editor/dom/CoordinateTransform.ts index 3717835b78..6008eacdde 100644 --- a/packages/super-editor/src/core/presentation-editor/dom/CoordinateTransform.ts +++ b/packages/super-editor/src/core/presentation-editor/dom/CoordinateTransform.ts @@ -1,3 +1,5 @@ +import { getPageElementByIndex } from './PageDom.js'; + /** * Calculates the offset of a page element within the viewport. * @@ -37,9 +39,7 @@ function getPageOffsets(options: { return null; } - const pageEl = options.painterHost.querySelector( - `.superdoc-page[data-page-index="${options.pageIndex}"]`, - ) as HTMLElement | null; + const pageEl = getPageElementByIndex(options.painterHost, options.pageIndex); if (!pageEl) return null; const pageRect = pageEl.getBoundingClientRect(); @@ -82,9 +82,7 @@ export function getPageOffsetY(options: { return null; } - const pageEl = options.painterHost.querySelector( - `.superdoc-page[data-page-index="${options.pageIndex}"]`, - ) as HTMLElement | null; + const pageEl = getPageElementByIndex(options.painterHost, options.pageIndex); if (!pageEl) return null; const pageRect = pageEl.getBoundingClientRect(); diff --git a/packages/super-editor/src/core/presentation-editor/selection/CaretGeometry.ts b/packages/super-editor/src/core/presentation-editor/selection/CaretGeometry.ts index 961361bf07..8c305d437a 100644 --- a/packages/super-editor/src/core/presentation-editor/selection/CaretGeometry.ts +++ b/packages/super-editor/src/core/presentation-editor/selection/CaretGeometry.ts @@ -19,6 +19,7 @@ import type { TableMeasure, } from '@superdoc/contracts'; import { computeTableCaretLayoutRectFromDom } from '../tables/TableCaretDomGeometry.js'; +import { getPageElementByIndex } from '../dom/PageDom.js'; /** * Represents the geometric layout information for a caret position. @@ -207,7 +208,7 @@ export function computeCaretLayoutRectGeometry( }; // DOM fallback for accurate caret positioning - const pageEl = painterHost?.querySelector(`.superdoc-page[data-page-index="${hit.pageIndex}"]`) as HTMLElement | null; + const pageEl = getPageElementByIndex(painterHost ?? null, hit.pageIndex); const pageRect = pageEl?.getBoundingClientRect(); // Find span containing this pos and measure actual DOM position diff --git a/packages/super-editor/src/core/presentation-editor/utils/AnchorNavigation.ts b/packages/super-editor/src/core/presentation-editor/utils/AnchorNavigation.ts index 052e3e947f..9901ff6fdc 100644 --- a/packages/super-editor/src/core/presentation-editor/utils/AnchorNavigation.ts +++ b/packages/super-editor/src/core/presentation-editor/utils/AnchorNavigation.ts @@ -1,6 +1,7 @@ import { selectionToRects, type PageGeometryHelper } from '@superdoc/layout-bridge'; import type { FlowBlock, Layout, Measure } from '@superdoc/contracts'; import type { Editor } from '../../Editor.js'; +import { getPageElementByIndex } from '../dom/PageDom.js'; /** * Build an anchor map (bookmark name -> page index) using fragment PM ranges. @@ -160,7 +161,7 @@ export async function goToAnchor({ await waitForPageMount(pageIndex, timeoutMs); // Scroll the page element into view - const pageEl = painterHost.querySelector(`[data-page-index="${pageIndex}"]`) as HTMLElement | null; + const pageEl = getPageElementByIndex(painterHost, pageIndex); if (pageEl) { pageEl.scrollIntoView({ behavior: 'instant', block: 'start' }); } From f656d53cd36874815cd172b2b5f70785fac0a0bb Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 4 Feb 2026 15:03:43 -0300 Subject: [PATCH 15/21] refactor: simplify getPageOffsetY to call existing helper --- .../presentation-editor/dom/CoordinateTransform.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/packages/super-editor/src/core/presentation-editor/dom/CoordinateTransform.ts b/packages/super-editor/src/core/presentation-editor/dom/CoordinateTransform.ts index 6008eacdde..f334897fa2 100644 --- a/packages/super-editor/src/core/presentation-editor/dom/CoordinateTransform.ts +++ b/packages/super-editor/src/core/presentation-editor/dom/CoordinateTransform.ts @@ -78,19 +78,7 @@ export function getPageOffsetY(options: { zoom: number; pageIndex: number; }): number | null { - if (!options.painterHost || !options.viewportHost) { - return null; - } - - const pageEl = getPageElementByIndex(options.painterHost, options.pageIndex); - if (!pageEl) return null; - - const pageRect = pageEl.getBoundingClientRect(); - const viewportRect = options.viewportHost.getBoundingClientRect(); - - const offsetY = (pageRect.top - viewportRect.top) / options.zoom; - - return offsetY; + return getPageOffsets(options)?.y ?? null; } /** From 1b2d1580253edaa9ecc8f33185b7b81ec40701a4 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 4 Feb 2026 15:14:50 -0300 Subject: [PATCH 16/21] refactor: use lodash for binary search --- .../dom/DomPositionIndex.ts | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/packages/super-editor/src/core/presentation-editor/dom/DomPositionIndex.ts b/packages/super-editor/src/core/presentation-editor/dom/DomPositionIndex.ts index 5e04bd602d..ab34c2a057 100644 --- a/packages/super-editor/src/core/presentation-editor/dom/DomPositionIndex.ts +++ b/packages/super-editor/src/core/presentation-editor/dom/DomPositionIndex.ts @@ -1,5 +1,5 @@ import { DOM_CLASS_NAMES } from '@superdoc/painter-dom'; - +import { sortedIndexBy } from 'lodash'; import { debugLog, getSelectionDebugConfig } from '../selection/SelectionDebug.js'; /** @@ -242,22 +242,10 @@ export class DomPositionIndex { const entryAtPos = this.findEntryAtPosition(pos); if (entryAtPos) return entryAtPos; - // Upper-bound search for pmStart <= pos - let lo = 0; - let hi = entries.length; - while (lo < hi) { - const mid = (lo + hi) >>> 1; - if (entries[mid].pmStart <= pos) { - lo = mid + 1; - } else { - hi = mid; - } - } - - const idx = lo - 1; + const idx = sortedIndexBy(entries, { pmStart: pos } as never, 'pmStart') - 1; const beforeEntry = idx >= 0 ? entries[idx] : null; - const afterEntry = lo < entries.length ? entries[lo] : null; + const afterEntry = idx < entries.length - 1 ? entries[idx + 1] : null; if (beforeEntry && afterEntry) { const distBefore = pos - beforeEntry.pmEnd; From 5bc11898a90b4f0e5da2f0d88e7008f1c012ab31 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 4 Feb 2026 15:43:48 -0300 Subject: [PATCH 17/21] chore: add lodash as a dependency --- packages/super-editor/package.json | 1 + pnpm-lock.yaml | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/package.json b/packages/super-editor/package.json index 0de7398ddd..9ffc0ec3f6 100644 --- a/packages/super-editor/package.json +++ b/packages/super-editor/package.json @@ -81,6 +81,7 @@ "eventemitter3": "catalog:", "he": "catalog:", "jszip": "catalog:", + "lodash": "^4.17.21", "marked": "catalog:", "prosemirror-commands": "catalog:", "prosemirror-dropcursor": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de8e483eb2..5e7a903979 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -901,6 +901,9 @@ importers: jszip: specifier: 'catalog:' version: 3.10.1 + lodash: + specifier: ^4.17.21 + version: 4.17.23 marked: specifier: 'catalog:' version: 16.4.2 @@ -17657,7 +17660,7 @@ snapshots: dependencies: graceful-fs: 4.2.11 is-promise: 2.2.2 - lodash: 4.17.21 + lodash: 4.17.23 pify: 3.0.0 steno: 0.4.4 From e8cf464862f088d99a22d8fb7b0e5c97b95efbae Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 4 Feb 2026 17:06:38 -0300 Subject: [PATCH 18/21] refactor: reuse variable in adjacent line computation --- .../vertical-navigation.js | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js index b6ce369830..5c28fa5e12 100644 --- a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js +++ b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js @@ -81,7 +81,6 @@ export const VerticalNavigation = Extension.create({ * @returns {boolean} Whether the event was handled. */ handleKeyDown(view, event) { - // Guard clauses if (view.composing || !editor.isEditable) return false; if (event.key === 'ArrowLeft' || event.key === 'ArrowRight' || event.key === 'Home' || event.key === 'End') { @@ -112,9 +111,7 @@ export const VerticalNavigation = Extension.create({ if (goalX == null) { goalX = coords?.x; if (!Number.isFinite(goalX)) return false; - view.dispatch( - view.state.tr.setMeta(VerticalNavigationPluginKey, { type: 'set-goal-x', goalX }), - ); + view.dispatch(view.state.tr.setMeta(VerticalNavigationPluginKey, { type: 'set-goal-x', goalX })); } // 2. Find adjacent line @@ -128,7 +125,11 @@ export const VerticalNavigation = Extension.create({ // 4. Move selection const selection = buildSelection(view.state, hit.pos, event.shiftKey); if (!selection) return false; - view.dispatch(view.state.tr.setMeta(VerticalNavigationPluginKey, { type: 'vertical-move', goalX }).setSelection(selection)); + view.dispatch( + view.state.tr + .setMeta(VerticalNavigationPluginKey, { type: 'vertical-move', goalX }) + .setSelection(selection), + ); return true; }, handleDOMEvents: { @@ -186,11 +187,15 @@ function isPresenting(editor) { * @returns {{ clientX: number, clientY: number, height: number, x: number, y: number } | null} */ function getCurrentCoords(editor, selection) { - const presentationEditor = editor.presentationEditor; const layoutSpaceCoords = presentationEditor.computeCaretLayoutRect(selection.head); if (!layoutSpaceCoords) return null; - const clientCoords = presentationEditor.denormalizeClientPoint(layoutSpaceCoords.x, layoutSpaceCoords.y, layoutSpaceCoords.pageIndex, layoutSpaceCoords.height); + const clientCoords = presentationEditor.denormalizeClientPoint( + layoutSpaceCoords.x, + layoutSpaceCoords.y, + layoutSpaceCoords.pageIndex, + layoutSpaceCoords.height, + ); return { clientX: clientCoords.x, clientY: clientCoords.y, @@ -222,7 +227,7 @@ function getAdjacentLineClientTarget(editor, coords, direction) { const clientY = rect.top + rect.height / 2; if (!Number.isFinite(clientY)) return null; return { - clientY: rect.top + rect.height / 2, + clientY, pageIndex: Number.isFinite(pageIndex) ? pageIndex : undefined, }; } From 16f2ee20d3a5c09330b0b41ab8e30a978c1a1087 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 4 Feb 2026 17:09:43 -0300 Subject: [PATCH 19/21] fix: selection computation --- .../src/extensions/vertical-navigation/vertical-navigation.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js index 5c28fa5e12..d378ee9918 100644 --- a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js +++ b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js @@ -264,7 +264,7 @@ function buildSelection(state, pos, extend) { const clamped = Math.max(0, Math.min(pos, doc.content.size)); if (extend) { const anchor = selection.anchor ?? selection.from; - return TextSelection.create(doc, clamped, anchor); + return TextSelection.create(doc, anchor, clamped); } return TextSelection.create(doc, clamped); } From d4fd979936971ac510638a011a59f71836678310 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 5 Feb 2026 13:00:39 -0300 Subject: [PATCH 20/21] fix: always use findEntryClosestToPosition when computing caret position --- .../src/core/presentation-editor/dom/DomSelectionGeometry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/super-editor/src/core/presentation-editor/dom/DomSelectionGeometry.ts b/packages/super-editor/src/core/presentation-editor/dom/DomSelectionGeometry.ts index ca7e5482e4..6048962fba 100644 --- a/packages/super-editor/src/core/presentation-editor/dom/DomSelectionGeometry.ts +++ b/packages/super-editor/src/core/presentation-editor/dom/DomSelectionGeometry.ts @@ -562,7 +562,7 @@ export function computeDomCaretPageLocal( let entry = options.domPositionIndex.findEntryClosestToPosition(pos); if (entry && !entry.el.isConnected) { options.rebuildDomPositionIndex(); - entry = options.domPositionIndex.findEntryAtPosition(pos); + entry = options.domPositionIndex.findEntryClosestToPosition(pos); } if (!entry) return null; From 2c62bd0b7c5c05d2446843b3f3f677b6358d5939 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 5 Feb 2026 14:49:47 -0300 Subject: [PATCH 21/21] fix: account for page offset when normalizing point --- .../presentation-editor/PresentationEditor.ts | 12 ++++--- .../dom/PointerNormalization.test.ts | 6 +++- .../dom/PointerNormalization.ts | 36 +++++++++---------- 3 files changed, 31 insertions(+), 23 deletions(-) diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 1708c3678a..f7134fcc69 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -2970,9 +2970,7 @@ export class PresentationEditor extends EventEmitter { previousMeasures, ); const incrementalLayoutEnd = perfNow(); - perfLog( - `[Perf] incrementalLayout: ${(incrementalLayoutEnd - incrementalLayoutStart).toFixed(2)}ms`, - ); + perfLog(`[Perf] incrementalLayout: ${(incrementalLayoutEnd - incrementalLayoutStart).toFixed(2)}ms`); // Type guard: validate incrementalLayout return value if (!result || typeof result !== 'object') { @@ -4524,13 +4522,19 @@ export class PresentationEditor extends EventEmitter { visibleHost: this.#visibleHost, zoom: this.#layoutOptions.zoom ?? 1, getPageOffsetX: (pageIndex) => this.#getPageOffsetX(pageIndex), + getPageOffsetY: (pageIndex) => this.#getPageOffsetY(pageIndex), }, clientX, clientY, ); } - denormalizeClientPoint(layoutX: number, layoutY: number, pageIndex?: number, height?: number): { x: number; y: number, height?: number } | null { + denormalizeClientPoint( + layoutX: number, + layoutY: number, + pageIndex?: number, + height?: number, + ): { x: number; y: number; height?: number } | null { return denormalizeClientPointFromPointer( { viewportHost: this.#viewportHost, diff --git a/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.test.ts b/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.test.ts index b94c6a8fb6..46d4a75cec 100644 --- a/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.test.ts +++ b/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.test.ts @@ -40,6 +40,7 @@ describe('PointerNormalization', () => { visibleHost, zoom: 1, getPageOffsetX: () => 0, + getPageOffsetY: () => 0, }; expect(normalizeClientPoint(options, NaN, 0)).toBe(null); @@ -55,6 +56,7 @@ describe('PointerNormalization', () => { visibleHost, zoom: 2, getPageOffsetX: () => 0, + getPageOffsetY: () => 0, }; const result = normalizeClientPoint(options, 200, 150); @@ -76,10 +78,11 @@ describe('PointerNormalization', () => { visibleHost, zoom: 2, getPageOffsetX: (pageIndex: number) => (pageIndex === 2 ? 12 : null), + getPageOffsetY: (pageIndex: number) => (pageIndex === 2 ? 8 : null), }; const result = normalizeClientPoint(options, 200, 150); - expect(result).toEqual({ x: 93, y: 90 }); + expect(result).toEqual({ x: 93, y: 82 }); }); it('does not adjust X when page offset is unavailable', () => { @@ -97,6 +100,7 @@ describe('PointerNormalization', () => { visibleHost, zoom: 2, getPageOffsetX: () => null, + getPageOffsetY: () => null, }; const result = normalizeClientPoint(options, 200, 150); diff --git a/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.ts b/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.ts index fb3aa25eab..52c7c5a23b 100644 --- a/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.ts +++ b/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.ts @@ -16,6 +16,7 @@ export function normalizeClientPoint( visibleHost: HTMLElement; zoom: number; getPageOffsetX: (pageIndex: number) => number | null; + getPageOffsetY: (pageIndex: number) => number | null; }, clientX: number, clientY: number, @@ -35,6 +36,7 @@ export function normalizeClientPoint( // Adjust X by the actual page offset if the pointer is over a page. This keeps // geometry-based hit testing aligned with the centered page content. let adjustedX = baseX; + let adjustedY = baseY; const doc = options.visibleHost.ownerDocument ?? document; const hitChain = typeof doc.elementsFromPoint === 'function' ? doc.elementsFromPoint(clientX, clientY) : []; const pageEl = Array.isArray(hitChain) @@ -47,12 +49,16 @@ export function normalizeClientPoint( if (pageOffsetX != null) { adjustedX = baseX - pageOffsetX; } + const pageOffsetY = options.getPageOffsetY(pageIndex); + if (pageOffsetY != null) { + adjustedY = baseY - pageOffsetY; + } } } return { x: adjustedX, - y: baseY, + y: adjustedY, }; } @@ -81,37 +87,31 @@ export function denormalizeClientPoint( layoutY: number, pageIndex?: number, height?: number, -): { x: number; y: number, height?: number } | null { +): { x: number; y: number; height?: number } | null { if (!Number.isFinite(layoutX) || !Number.isFinite(layoutY)) { return null; } - const rect = options.viewportHost.getBoundingClientRect(); - const scrollLeft = options.visibleHost.scrollLeft ?? 0; - const scrollTop = options.visibleHost.scrollTop ?? 0; + let pageOffsetX = 0; + let pageOffsetY = 0; // Convert from layout coordinates to screen coordinates by multiplying by zoom // and reversing the scroll/viewport offsets. - let baseX = layoutX; if (Number.isFinite(pageIndex)) { - const pageOffsetX = options.getPageOffsetX(pageIndex); - if (pageOffsetX != null) { - baseX = layoutX + pageOffsetX; - } + pageOffsetX = options.getPageOffsetX(Number(pageIndex)) ?? 0; - const pageOffsetY = options.getPageOffsetY(pageIndex); - if (pageOffsetY != null) { - layoutY += pageOffsetY; - } + pageOffsetY = options.getPageOffsetY(Number(pageIndex)) ?? 0; } - const result = { - x: baseX * options.zoom - scrollLeft + rect.left, - y: layoutY * options.zoom - scrollTop + rect.top, + const rect = options.viewportHost.getBoundingClientRect(); + const scrollLeft = options.visibleHost.scrollLeft ?? 0; + const scrollTop = options.visibleHost.scrollTop ?? 0; + const result: { x: number; y: number; height?: number } = { + x: (layoutX + pageOffsetX) * options.zoom + rect.left - scrollLeft, + y: (layoutY + pageOffsetY) * options.zoom + rect.top - scrollTop, }; if (Number.isFinite(height)) { result['height'] = height * options.zoom; } return result; - }