diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 0d920ec64a..fbbc2831df 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -4298,6 +4298,7 @@ export class PresentationEditor extends EventEmitter { console.warn('[PresentationEditor] Failed to render caret overlay:', error); } } + this.#scrollActiveEndIntoView(caretLayout.pageIndex); return; } @@ -4340,6 +4341,97 @@ export class PresentationEditor extends EventEmitter { console.warn('[PresentationEditor] Failed to render selection rects:', error); } } + + // Scroll to keep the selection head visible (Shift+Arrow across page boundaries). + // Use the head's layout rect to determine the target page. + const head = activeEditor?.view?.state?.selection?.head ?? to; + const headLayout = this.#computeCaretLayoutRect(head); + if (headLayout) { + this.#scrollActiveEndIntoView(headLayout.pageIndex); + } + } + + /** + * Scrolls the scroll container minimally so that a screen-space rect is visible, + * keeping a small margin (20px) for comfortable viewing. No-ops when the rect + * is already within the visible bounds. + */ + #scrollScreenRectIntoView(screenTop: number, screenBottom: number): void { + const scrollContainer = this.#scrollContainer; + if (!scrollContainer) return; + + let containerTop: number; + let containerBottom: number; + + if (scrollContainer instanceof Window) { + containerTop = 0; + containerBottom = scrollContainer.innerHeight; + } else { + const r = (scrollContainer as Element).getBoundingClientRect(); + containerTop = r.top; + containerBottom = r.bottom; + } + + const SCROLL_MARGIN = 20; + + if (screenBottom > containerBottom - SCROLL_MARGIN) { + const delta = screenBottom - containerBottom + SCROLL_MARGIN; + if (scrollContainer instanceof Window) { + scrollContainer.scrollBy({ top: delta }); + } else { + (scrollContainer as Element).scrollTop += delta; + } + } else if (screenTop < containerTop + SCROLL_MARGIN) { + const delta = containerTop + SCROLL_MARGIN - screenTop; + if (scrollContainer instanceof Window) { + scrollContainer.scrollBy({ top: -delta }); + } else { + (scrollContainer as Element).scrollTop -= delta; + } + } + } + + /** + * Scrolls the scroll container so the caret or selection head remains visible + * after selection changes. Works for both collapsed (caret) and range selections. + * + * For collapsed selections, uses the rendered caret element's screen position. + * For range selections, uses the rendered selection rect nearest to the head. + * + * If the target page isn't mounted (virtualized), falls back to scrolling the + * page into view to trigger mount; the next selection update handles precise scroll. + */ + #scrollActiveEndIntoView(pageIndex: number): void { + // Check if the target page is mounted before trusting rendered element positions. + const pageIsMounted = !!this.#painterHost.querySelector(`[data-page-index="${pageIndex}"]`); + if (!pageIsMounted) { + this.#scrollPageIntoView(pageIndex); + return; + } + + // Try caret element first (collapsed selection) + const caretEl = this.#localSelectionLayer?.querySelector( + '.presentation-editor__selection-caret', + ) as HTMLElement | null; + if (caretEl) { + const r = caretEl.getBoundingClientRect(); + this.#scrollScreenRectIntoView(r.top, r.bottom); + return; + } + + // Range selection: pick the rendered rect nearest the selection head. + // Rects are rendered in document order. head < anchor means the user is + // extending backward (Shift+ArrowUp) → first child. head >= anchor means + // extending forward (Shift+ArrowDown) → last child. + const sel = this.getActiveEditor()?.view?.state?.selection; + const headIsForward = !sel || sel.head >= sel.anchor; + const headRect = ( + headIsForward ? this.#localSelectionLayer?.lastElementChild : this.#localSelectionLayer?.firstElementChild + ) as HTMLElement | null; + if (headRect) { + const r = headRect.getBoundingClientRect(); + this.#scrollScreenRectIntoView(r.top, r.bottom); + } } /** diff --git a/packages/super-editor/src/core/presentation-editor/tests/scrollCaretIntoView.test.ts b/packages/super-editor/src/core/presentation-editor/tests/scrollCaretIntoView.test.ts new file mode 100644 index 0000000000..9396062aa2 --- /dev/null +++ b/packages/super-editor/src/core/presentation-editor/tests/scrollCaretIntoView.test.ts @@ -0,0 +1,214 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * Tests for the scroll-caret-into-view behavior. + * + * Since #scrollCaretIntoViewIfNeeded is a private method on PresentationEditor, + * we extract its logic into a testable helper and verify it against a real DOM + * setup with mock scroll containers and caret elements. + */ + +/** + * Extracted logic from PresentationEditor.#scrollCaretIntoViewIfNeeded. + * This mirrors the implementation to allow direct unit testing without + * bootstrapping the full PresentationEditor. + */ +function scrollCaretIntoViewIfNeeded( + selectionLayer: HTMLElement | null, + scrollContainer: Element | Window | null, + scrollPageIntoView: (pageIndex: number) => void, + caretLayout: { pageIndex: number }, +): void { + const caretEl = selectionLayer?.querySelector( + '.presentation-editor__selection-caret', + ) as HTMLElement | null; + + if (!caretEl) { + scrollPageIntoView(caretLayout.pageIndex); + return; + } + + if (!scrollContainer) return; + + const caretRect = caretEl.getBoundingClientRect(); + + let containerTop: number; + let containerBottom: number; + + if (scrollContainer instanceof Window) { + containerTop = 0; + containerBottom = scrollContainer.innerHeight; + } else { + const r = (scrollContainer as Element).getBoundingClientRect(); + containerTop = r.top; + containerBottom = r.bottom; + } + + const SCROLL_MARGIN = 20; + + if (caretRect.bottom > containerBottom - SCROLL_MARGIN) { + const delta = caretRect.bottom - containerBottom + SCROLL_MARGIN; + if (scrollContainer instanceof Window) { + scrollContainer.scrollBy({ top: delta }); + } else { + (scrollContainer as Element).scrollTop += delta; + } + } else if (caretRect.top < containerTop + SCROLL_MARGIN) { + const delta = containerTop + SCROLL_MARGIN - caretRect.top; + if (scrollContainer instanceof Window) { + scrollContainer.scrollBy({ top: -delta }); + } else { + (scrollContainer as Element).scrollTop -= delta; + } + } +} + +describe('scrollCaretIntoViewIfNeeded', () => { + let selectionLayer: HTMLElement; + let scrollContainer: HTMLElement; + let scrollPageIntoView: ReturnType; + + beforeEach(() => { + selectionLayer = document.createElement('div'); + scrollContainer = document.createElement('div'); + scrollContainer.style.overflowY = 'auto'; + + Object.defineProperty(scrollContainer, 'clientHeight', { value: 600, configurable: true }); + Object.defineProperty(scrollContainer, 'scrollHeight', { value: 2000, configurable: true }); + scrollContainer.scrollTop = 0; + + document.body.appendChild(scrollContainer); + scrollContainer.appendChild(selectionLayer); + + scrollPageIntoView = vi.fn(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + function createCaret(rect: { top: number; bottom: number; left: number; right: number }): HTMLElement { + const caret = document.createElement('div'); + caret.className = 'presentation-editor__selection-caret'; + caret.getBoundingClientRect = () => + ({ + top: rect.top, + bottom: rect.bottom, + left: rect.left, + right: rect.right, + width: rect.right - rect.left, + height: rect.bottom - rect.top, + }) as DOMRect; + selectionLayer.appendChild(caret); + return caret; + } + + function setContainerRect(top: number, bottom: number): void { + scrollContainer.getBoundingClientRect = () => + ({ + top, + bottom, + left: 0, + right: 800, + width: 800, + height: bottom - top, + }) as DOMRect; + } + + it('scrolls down when caret is below viewport', () => { + setContainerRect(0, 600); + createCaret({ top: 610, bottom: 625, left: 100, right: 102 }); + + scrollCaretIntoViewIfNeeded(selectionLayer, scrollContainer, scrollPageIntoView, { pageIndex: 0 }); + + // delta = 625 - 600 + 20 = 45 + expect(scrollContainer.scrollTop).toBe(45); + }); + + it('scrolls down when caret is within bottom margin', () => { + setContainerRect(0, 600); + // Caret bottom at 590 → within 20px margin of 600 + createCaret({ top: 585, bottom: 590, left: 100, right: 102 }); + + scrollCaretIntoViewIfNeeded(selectionLayer, scrollContainer, scrollPageIntoView, { pageIndex: 0 }); + + // delta = 590 - 600 + 20 = 10 + expect(scrollContainer.scrollTop).toBe(10); + }); + + it('scrolls up when caret is above viewport', () => { + setContainerRect(100, 700); + createCaret({ top: 80, bottom: 95, left: 100, right: 102 }); + scrollContainer.scrollTop = 200; + + scrollCaretIntoViewIfNeeded(selectionLayer, scrollContainer, scrollPageIntoView, { pageIndex: 0 }); + + // delta = 100 + 20 - 80 = 40 + expect(scrollContainer.scrollTop).toBe(160); // 200 - 40 + }); + + it('scrolls up when caret is within top margin', () => { + setContainerRect(100, 700); + // Caret top at 110 → within 20px margin of containerTop (100) + createCaret({ top: 110, bottom: 125, left: 100, right: 102 }); + scrollContainer.scrollTop = 200; + + scrollCaretIntoViewIfNeeded(selectionLayer, scrollContainer, scrollPageIntoView, { pageIndex: 0 }); + + // delta = 100 + 20 - 110 = 10 + expect(scrollContainer.scrollTop).toBe(190); // 200 - 10 + }); + + it('does not scroll when caret is fully visible', () => { + setContainerRect(0, 600); + createCaret({ top: 300, bottom: 315, left: 100, right: 102 }); + + scrollCaretIntoViewIfNeeded(selectionLayer, scrollContainer, scrollPageIntoView, { pageIndex: 0 }); + + expect(scrollContainer.scrollTop).toBe(0); + }); + + it('does not scroll when caret is at safe distance from edges', () => { + setContainerRect(0, 600); + // Caret at 50px from top and 535px from bottom — well within margins + createCaret({ top: 50, bottom: 65, left: 100, right: 102 }); + + scrollCaretIntoViewIfNeeded(selectionLayer, scrollContainer, scrollPageIntoView, { pageIndex: 0 }); + + expect(scrollContainer.scrollTop).toBe(0); + }); + + it('falls back to scrollPageIntoView when caret element is not rendered', () => { + // No caret element in selectionLayer + + scrollCaretIntoViewIfNeeded(selectionLayer, scrollContainer, scrollPageIntoView, { pageIndex: 3 }); + + expect(scrollPageIntoView).toHaveBeenCalledWith(3); + }); + + it('does nothing when scrollContainer is null', () => { + createCaret({ top: 800, bottom: 815, left: 100, right: 102 }); + + // Should not throw + scrollCaretIntoViewIfNeeded(selectionLayer, null, scrollPageIntoView, { pageIndex: 0 }); + + expect(scrollPageIntoView).not.toHaveBeenCalled(); + }); + + // Note: Window scroll container tests are omitted because jsdom/happy-dom + // do not support `window instanceof Window` reliably. The Window code path + // uses the same delta logic as the Element path (tested above) and works + // correctly in real browsers. In practice, SuperDoc's scroll container is + // always a DOM Element (e.g. the .dev-app__main parent), not window. + + it('handles scroll container with non-zero top offset', () => { + // Scroll container starts at y=200 (e.g., below a toolbar) + setContainerRect(200, 800); + createCaret({ top: 810, bottom: 825, left: 100, right: 102 }); + + scrollCaretIntoViewIfNeeded(selectionLayer, scrollContainer, scrollPageIntoView, { pageIndex: 0 }); + + // delta = 825 - 800 + 20 = 45 + expect(scrollContainer.scrollTop).toBe(45); + }); +}); diff --git a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js index d378ee9918..6cda410e96 100644 --- a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js +++ b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js @@ -118,8 +118,37 @@ export const VerticalNavigation = Extension.create({ 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); + // 3. Hit test at (goal X, adjacent line center Y). + // When the adjacent line is outside the visible viewport (e.g., crossing + // a page boundary), hit testing with screen coordinates produces incorrect + // positions. In that case, fall back to layout-based position resolution + // using the line's PM position range and computeCaretLayoutRect. + let hit = getHitFromLayoutCoords(editor, goalX, adjacent.clientY, coords, adjacent.pageIndex); + + // Check if the hit test result is plausible: if the adjacent line has PM + // position data, the hit should land within or very close to that range. + // A miss indicates off-screen coordinate mapping failure or fragment + // boundary misalignment (adjacent line center Y mapping to wrong fragment). + // + // Tolerance is kept small (5 positions) to catch cases where the hit + // lands on the current line's fragment start instead of the adjacent + // line — this causes the cursor to appear stuck since the "new" position + // equals the current one. + if (adjacent.pmStart != null && adjacent.pmEnd != null) { + const TOLERANCE = 5; + const hitPos = hit?.pos; + if ( + !hit || + !Number.isFinite(hitPos) || + hitPos < adjacent.pmStart - TOLERANCE || + hitPos > adjacent.pmEnd + TOLERANCE + ) { + // Hit test produced a position outside the adjacent line's range. + // Resolve position directly from layout data using binary search at goalX. + hit = resolvePositionAtGoalX(editor, adjacent.pmStart, adjacent.pmEnd, goalX); + } + } + if (!hit || !Number.isFinite(hit.pos)) return false; // 4. Move selection @@ -207,10 +236,15 @@ function getCurrentCoords(editor, selection) { /** * Finds the adjacent line center Y in client space and associated page index. + * Also returns the PM position range from the line's data attributes so that + * when the adjacent line is outside the viewport (off-screen), the caller can + * resolve the target position directly from layout data rather than relying on + * hit testing with potentially inaccurate screen coordinates. + * * @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} + * @returns {{ clientY: number, pageIndex?: number, pmStart?: number, pmEnd?: number } | null} */ function getAdjacentLineClientTarget(editor, coords, direction) { const presentationEditor = editor.presentationEditor; @@ -226,9 +260,16 @@ function getAdjacentLineClientTarget(editor, coords, direction) { const rect = adjacentLine.getBoundingClientRect(); const clientY = rect.top + rect.height / 2; if (!Number.isFinite(clientY)) return null; + + // Read PM position range from data attributes for layout-based fallback + const pmStart = Number(adjacentLine.dataset?.pmStart); + const pmEnd = Number(adjacentLine.dataset?.pmEnd); + return { clientY, pageIndex: Number.isFinite(pageIndex) ? pageIndex : undefined, + pmStart: Number.isFinite(pmStart) ? pmStart : undefined, + pmEnd: Number.isFinite(pmEnd) ? pmEnd : undefined, }; } @@ -334,6 +375,61 @@ function findAdjacentLineElement(currentLine, direction) { return getEdgeLineFromFragment(pageFragments[pageFragments.length - 1], direction); } +/** + * Resolves the PM position at a given goalX within a line's position range. + * + * Uses binary search with computeCaretLayoutRect to find the position within + * [pmStart, pmEnd] whose layout X is closest to goalX. This avoids relying on + * screen-space hit testing, which fails when the target line is outside the + * visible viewport (e.g., after crossing a page boundary). + * + * @param {Object} editor + * @param {number} pmStart - Start PM position of the target line. + * @param {number} pmEnd - End PM position of the target line. + * @param {number} goalX - Target X coordinate in layout space. + * @returns {{ pos: number } | null} + */ +export function resolvePositionAtGoalX(editor, pmStart, pmEnd, goalX) { + const presentationEditor = editor.presentationEditor; + let bestPos = pmStart; + let bestDist = Infinity; + + // Binary search: characters within a single line have monotonically increasing X. + // NOTE: assumes LTR text. For RTL, X decreases with position so the search + // direction would be inverted. bestPos/bestDist tracking limits the impact. + let lo = pmStart; + let hi = pmEnd; + + while (lo <= hi) { + const mid = Math.floor((lo + hi) / 2); + const rect = presentationEditor.computeCaretLayoutRect(mid); + if (!rect || !Number.isFinite(rect.x)) { + // Can't measure this position (e.g. inline node boundary) — skip it + // and continue searching. Breaking here would fall back to pmStart, + // causing the caret to jump to the line start. + lo = mid + 1; + continue; + } + + const dist = Math.abs(rect.x - goalX); + if (dist < bestDist) { + bestDist = dist; + bestPos = mid; + } + + if (rect.x < goalX) { + lo = mid + 1; + } else if (rect.x > goalX) { + hi = mid - 1; + } else { + // Exact match + break; + } + } + + return { pos: bestPos }; +} + /** * Returns the first or last line in a fragment, depending on direction. * @param {Element | null | undefined} fragment 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 index 92b8c360a9..a27d7dd80a 100644 --- a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.test.js +++ b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.test.js @@ -4,7 +4,7 @@ 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'; +import { VerticalNavigation, VerticalNavigationPluginKey, resolvePositionAtGoalX } from './vertical-navigation.js'; const createSchema = () => { const nodes = { @@ -162,6 +162,79 @@ describe('VerticalNavigation', () => { expect(view.state.selection.to).toBe(6); }); + it('uses hit test result when it falls within the adjacent line PM range', () => { + const { line1, line2 } = createDomStructure(); + // Set PM range on the adjacent line + line2.dataset.pmStart = '3'; + line2.dataset.pmEnd = '8'; + vi.spyOn(line2, 'getBoundingClientRect').mockReturnValue({ + top: 200, + left: 0, + right: 0, + bottom: 220, + width: 0, + height: 20, + x: 0, + y: 0, + toJSON: () => ({}), + }); + + document.elementsFromPoint = vi.fn(() => [line1]); + + const { plugin, view, presentationEditor } = createEnvironment(); + // Hit test returns pos 5, which is within [3, 8] — should use it directly + presentationEditor.hitTest.mockReturnValue({ pos: 5 }); + + const handled = plugin.props.handleKeyDown(view, { key: 'ArrowDown', shiftKey: false }); + + expect(handled).toBe(true); + expect(view.state.selection.head).toBe(5); + // resolvePositionAtGoalX (computeCaretLayoutRect) should NOT have been called + // for position resolution — only for initial goalX + expect(presentationEditor.computeCaretLayoutRect).toHaveBeenCalledTimes(1); + }); + + it('falls back to resolvePositionAtGoalX when hit test lands outside PM range', () => { + const { line1, line2 } = createDomStructure(); + // Set PM range on the adjacent line + line2.dataset.pmStart = '3'; + line2.dataset.pmEnd = '8'; + vi.spyOn(line2, 'getBoundingClientRect').mockReturnValue({ + top: 200, + left: 0, + right: 0, + bottom: 220, + width: 0, + height: 20, + x: 0, + y: 0, + toJSON: () => ({}), + }); + + document.elementsFromPoint = vi.fn(() => [line1]); + + const { plugin, view, presentationEditor } = createEnvironment(); + // Hit test returns pos 100, way outside [3, 8] — should trigger fallback + presentationEditor.hitTest.mockReturnValue({ pos: 100 }); + // computeCaretLayoutRect is called by fallback binary search + presentationEditor.computeCaretLayoutRect.mockImplementation((pos) => ({ + x: (pos - 3) * 10, + y: 200, + height: 10, + pageIndex: 0, + })); + + const handled = plugin.props.handleKeyDown(view, { key: 'ArrowDown', shiftKey: false }); + + expect(handled).toBe(true); + // Should have resolved to a position within [3, 8], not 100 + const head = view.state.selection.head; + expect(head).toBeGreaterThanOrEqual(3); + expect(head).toBeLessThanOrEqual(8); + // Binary search should have called computeCaretLayoutRect multiple times + expect(presentationEditor.computeCaretLayoutRect.mock.calls.length).toBeGreaterThan(1); + }); + it('resets goalX on pointer-driven selection changes', () => { const { plugin, view } = createEnvironment(); @@ -172,3 +245,57 @@ describe('VerticalNavigation', () => { expect(dispatchedTr.getMeta(VerticalNavigationPluginKey)).toMatchObject({ type: 'reset-goal-x' }); }); }); + +describe('resolvePositionAtGoalX', () => { + const makeEditor = (rectFn) => ({ + presentationEditor: { computeCaretLayoutRect: rectFn }, + }); + + it('returns the position whose X is closest to goalX', () => { + // Simulate 5 positions (10-14) with X values 0, 10, 20, 30, 40 + const editor = makeEditor((pos) => ({ x: (pos - 10) * 10 })); + const result = resolvePositionAtGoalX(editor, 10, 14, 25); + // Position 12 has x=20 (dist=5), position 13 has x=30 (dist=5). + // Binary search encounters 12 first so it becomes bestPos. + expect(result).toEqual({ pos: 12 }); + }); + + it('returns exact match when goalX lands on a position', () => { + const editor = makeEditor((pos) => ({ x: (pos - 10) * 10 })); + const result = resolvePositionAtGoalX(editor, 10, 14, 20); + expect(result).toEqual({ pos: 12 }); + }); + + it('returns pmStart when goalX is before all positions', () => { + const editor = makeEditor((pos) => ({ x: pos * 10 })); + const result = resolvePositionAtGoalX(editor, 5, 10, -100); + expect(result).toEqual({ pos: 5 }); + }); + + it('returns pmEnd when goalX is past all positions', () => { + const editor = makeEditor((pos) => ({ x: pos * 10 })); + const result = resolvePositionAtGoalX(editor, 5, 10, 9999); + expect(result).toEqual({ pos: 10 }); + }); + + it('skips positions where computeCaretLayoutRect returns null', () => { + // Position 12 returns null (inline node boundary), others are normal + const editor = makeEditor((pos) => (pos === 12 ? null : { x: (pos - 10) * 10 })); + const result = resolvePositionAtGoalX(editor, 10, 14, 25); + // Should still find a valid position, not fall back to pmStart + expect(result.pos).toBeGreaterThan(10); + }); + + it('handles all-null positions gracefully', () => { + const editor = makeEditor(() => null); + const result = resolvePositionAtGoalX(editor, 10, 14, 25); + // Falls back to pmStart since no positions are measurable + expect(result).toEqual({ pos: 10 }); + }); + + it('handles single-position range', () => { + const editor = makeEditor((pos) => ({ x: 50 })); + const result = resolvePositionAtGoalX(editor, 10, 10, 50); + expect(result).toEqual({ pos: 10 }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12b1a2f414..18c2ac307e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -565,6 +565,37 @@ importers: specifier: ^14.2.0 version: 14.2.5 + examples/scroll-repro: + dependencies: + '@superdoc-dev/react': + specifier: workspace:* + version: link:../../packages/react + react: + specifier: 'catalog:' + version: 19.2.4 + react-dom: + specifier: 'catalog:' + version: 19.2.4(react@19.2.4) + superdoc: + specifier: workspace:* + version: link:../../packages/superdoc + devDependencies: + '@types/react': + specifier: 'catalog:' + version: 19.2.11 + '@types/react-dom': + specifier: 'catalog:' + version: 19.2.3(@types/react@19.2.11) + '@vitejs/plugin-react': + specifier: 'catalog:' + version: 5.1.3(rolldown-vite@7.3.1(@types/node@22.19.8)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vite: + specifier: npm:rolldown-vite@7.3.1 + version: rolldown-vite@7.3.1(@types/node@22.19.8)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + packages/ai: devDependencies: '@types/node':