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/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts
index 9a2e699984..f7134fcc69 100644
--- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts
+++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts
@@ -15,8 +15,12 @@ import {
import {
convertPageLocalToOverlayCoords as convertPageLocalToOverlayCoordsFromTransform,
getPageOffsetX as getPageOffsetXFromTransform,
+ getPageOffsetY as getPageOffsetYFromTransform,
} 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';
@@ -1701,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;
}
@@ -2968,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') {
@@ -4447,6 +4447,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,
@@ -4513,12 +4522,34 @@ 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 {
+ return denormalizeClientPointFromPointer(
+ {
+ viewportHost: this.#viewportHost,
+ visibleHost: this.#visibleHost,
+ zoom: this.#layoutOptions.zoom ?? 1,
+ getPageOffsetX: (pageIndex) => this.#getPageOffsetX(pageIndex),
+ getPageOffsetY: (pageIndex) => this.#getPageOffsetY(pageIndex),
+ },
+ layoutX,
+ layoutY,
+ pageIndex,
+ height,
+ );
+ }
+
/**
* Computes caret layout rectangle using geometry-based calculations.
*
@@ -4602,6 +4633,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') {
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..f334897fa2 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();
@@ -66,6 +66,21 @@ 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;
+ zoom: number;
+ pageIndex: number;
+}): number | null {
+ return getPageOffsets(options)?.y ?? null;
+}
+
/**
* Converts page-local coordinates to overlay-absolute coordinates.
*
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..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';
/**
@@ -224,6 +224,39 @@ 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;
+
+ const idx = sortedIndexBy(entries, { pmStart: pos } as never, 'pmStart') - 1;
+
+ const beforeEntry = idx >= 0 ? entries[idx] : null;
+ const afterEntry = idx < entries.length - 1 ? entries[idx + 1] : 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..6048962fba 100644
--- a/packages/super-editor/src/core/presentation-editor/dom/DomSelectionGeometry.ts
+++ b/packages/super-editor/src/core/presentation-editor/dom/DomSelectionGeometry.ts
@@ -559,10 +559,10 @@ 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);
+ entry = options.domPositionIndex.findEntryClosestToPosition(pos);
}
if (!entry) return null;
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..46d4a75cec
--- /dev/null
+++ b/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.test.ts
@@ -0,0 +1,172 @@
+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,
+ getPageOffsetY: () => 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,
+ getPageOffsetY: () => 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),
+ getPageOffsetY: (pageIndex: number) => (pageIndex === 2 ? 8 : null),
+ };
+
+ const result = normalizeClientPoint(options, 200, 150);
+ expect(result).toEqual({ x: 93, y: 82 });
+ });
+
+ 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,
+ getPageOffsetY: () => 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 });
+ });
+
+ 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 2adbea8cbb..52c7c5a23b 100644
--- a/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.ts
+++ b/packages/super-editor/src/core/presentation-editor/dom/PointerNormalization.ts
@@ -1,9 +1,22 @@
+/**
+ * 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;
visibleHost: HTMLElement;
zoom: number;
getPageOffsetX: (pageIndex: number) => number | null;
+ getPageOffsetY: (pageIndex: number) => number | null;
},
clientX: number,
clientY: number,
@@ -23,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)
@@ -35,11 +49,69 @@ 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,
};
}
+
+/**
+ * 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;
+ visibleHost: HTMLElement;
+ zoom: number;
+ getPageOffsetX: (pageIndex: number) => number | null;
+ getPageOffsetY: (pageIndex: number) => number | null;
+ },
+ layoutX: number,
+ layoutY: number,
+ pageIndex?: number,
+ height?: number,
+): { x: number; y: number; height?: number } | null {
+ if (!Number.isFinite(layoutX) || !Number.isFinite(layoutY)) {
+ return null;
+ }
+
+ let pageOffsetX = 0;
+ let pageOffsetY = 0;
+
+ // Convert from layout coordinates to screen coordinates by multiplying by zoom
+ // and reversing the scroll/viewport offsets.
+ if (Number.isFinite(pageIndex)) {
+ pageOffsetX = options.getPageOffsetX(Number(pageIndex)) ?? 0;
+
+ pageOffsetY = options.getPageOffsetY(Number(pageIndex)) ?? 0;
+ }
+
+ 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;
+}
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/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({
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', () => {
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' });
}
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..d378ee9918
--- /dev/null
+++ b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js
@@ -0,0 +1,348 @@
+import { Extension } from '@core/Extension.js';
+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');
+
+/**
+ * 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 [];
+
+ const editor = this.editor;
+ 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') {
+ return {
+ goalX: meta.goalX ?? value.goalX ?? null,
+ };
+ }
+ 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: {
+ /**
+ * 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' }));
+ 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;
+ }
+
+ // 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);
+ if (!coords) return false;
+ if (goalX == null) {
+ goalX = coords?.x;
+ if (!Number.isFinite(goalX)) return false;
+ view.dispatch(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;
+ },
+ },
+ },
+ });
+
+ return [plugin];
+ },
+});
+
+/**
+ * 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;
+ const activeEditor = presentationCtx.getActiveEditor?.();
+ 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;
+ const layoutSpaceCoords = presentationEditor.computeCaretLayoutRect(selection.head);
+ if (!layoutSpaceCoords) return null;
+ const clientCoords = presentationEditor.denormalizeClientPoint(
+ layoutSpaceCoords.x,
+ layoutSpaceCoords.y,
+ layoutSpaceCoords.pageIndex,
+ layoutSpaceCoords.height,
+ );
+ return {
+ clientX: clientCoords.x,
+ clientY: clientCoords.y,
+ height: clientCoords.height,
+ x: layoutSpaceCoords.x,
+ y: layoutSpaceCoords.y,
+ };
+}
+
+/**
+ * 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;
+ 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 pageEl = adjacentLine.closest?.(`.${DOM_CLASS_NAMES.PAGE}`);
+ const pageIndex = pageEl ? Number(pageEl.dataset.pageIndex ?? 'NaN') : null;
+ const rect = adjacentLine.getBoundingClientRect();
+ const clientY = rect.top + rect.height / 2;
+ if (!Number.isFinite(clientY)) return null;
+ return {
+ clientY,
+ pageIndex: Number.isFinite(pageIndex) ? pageIndex : undefined,
+ };
+}
+
+/**
+ * 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);
+ const clientX = clientPoint?.x;
+ if (!Number.isFinite(clientX)) return null;
+ 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) {
+ 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, anchor, clamped);
+ }
+ 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) ?? [];
+ for (const el of chain) {
+ if (el?.classList?.contains?.(DOM_CLASS_NAMES.LINE)) return el;
+ }
+ 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;
+ 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;
+
+ 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}`)).filter((frag) => {
+ const parent = frag.closest?.(`.${headerClass}, .${footerClass}`);
+ return !parent;
+ });
+ 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}`)).filter((frag) => {
+ const parent = frag.closest?.(`.${headerClass}, .${footerClass}`);
+ return !parent;
+ });
+ if (direction > 0) {
+ return getEdgeLineFromFragment(pageFragments[0], 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}`));
+ if (lineEls.length === 0) return null;
+ return direction > 0 ? lineEls[0] : lineEls[lineEls.length - 1];
+}
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' });
+ });
+});
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