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