diff --git a/packages/layout-engine/layout-bridge/src/cache.ts b/packages/layout-engine/layout-bridge/src/cache.ts index d12fdb1299..e466578526 100644 --- a/packages/layout-engine/layout-bridge/src/cache.ts +++ b/packages/layout-engine/layout-bridge/src/cache.ts @@ -141,6 +141,17 @@ const hashRuns = (block: FlowBlock): string => { const cellBlocks = cell.blocks ?? (cell.paragraph ? [cell.paragraph] : []); for (const cellBlock of cellBlocks) { + if (cellBlock?.kind === 'table') { + // Intentional split with renderer.ts: cache.ts hashes nested tables only, + // while renderer.ts also hashes non-paragraph cell blocks. Keep both + // in sync when adjusting nested-block invalidation behavior. + // Nested tables inside table cells must contribute to the parent + // table cache key, otherwise edits can be missed until a later + // broader invalidation. + cellHashes.push(`nt:${hashRuns(cellBlock as FlowBlock)}`); + continue; + } + const paragraphBlock = cellBlock as ParagraphBlock; // Safety: Check that runs array exists before iterating diff --git a/packages/layout-engine/layout-bridge/test/cache.test.ts b/packages/layout-engine/layout-bridge/test/cache.test.ts index a72dea5829..67f3db1f13 100644 --- a/packages/layout-engine/layout-bridge/test/cache.test.ts +++ b/packages/layout-engine/layout-bridge/test/cache.test.ts @@ -334,6 +334,78 @@ describe('MeasureCache', () => { expect(cache.get(table2, 800, 600)).toEqual({ totalHeight: 120 }); }); + it('invalidates when nested table content changes inside a cell block', () => { + const nestedTable = (id: string, text: string): TableBlock => ({ + kind: 'table', + id, + rows: [ + { + id: `${id}-row-0`, + cells: [ + { + id: `${id}-cell-0-0`, + blocks: [ + { + kind: 'paragraph', + id: `${id}-para-0`, + runs: [{ text, fontFamily: 'Arial', fontSize: 12 }], + }, + ], + }, + ], + }, + ], + }); + + const parentTable1: TableBlock = { + kind: 'table', + id: 'parent-table', + rows: [ + { + id: 'parent-row-0', + cells: [ + { + id: 'parent-cell-0-0', + blocks: [ + { + kind: 'paragraph', + id: 'parent-cell-0-0-para', + runs: [{ text: 'Host', fontFamily: 'Arial', fontSize: 12 }], + }, + nestedTable('nested-table', 'Nested A'), + ], + }, + ], + }, + ], + }; + + const parentTable2: TableBlock = { + ...parentTable1, + rows: [ + { + ...parentTable1.rows[0], + cells: [ + { + ...parentTable1.rows[0].cells[0], + blocks: [ + { + kind: 'paragraph', + id: 'parent-cell-0-0-para', + runs: [{ text: 'Host', fontFamily: 'Arial', fontSize: 12 }], + }, + nestedTable('nested-table', 'Nested B'), + ], + }, + ], + }, + ], + }; + + cache.set(parentTable1, 800, 600, { totalHeight: 130 }); + expect(cache.get(parentTable2, 800, 600)).toBeUndefined(); + }); + it('handles legacy single paragraph cells', () => { const table1 = tableBlock( 'table-1', diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index c0c85996fa..e4b805f6ac 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -7412,6 +7412,13 @@ const deriveBlockVersion = (block: FlowBlock): string => { hash = hashString(hash, getRunStringProp(run, 'vertAlign')); hash = hashNumber(hash, getRunNumberProp(run, 'baselineShift')); } + } else if (cellBlock?.kind) { + // Keep this broader than layout-bridge cache.ts on purpose: + // renderer hashes any non-paragraph cell block, while cache.ts hashes + // nested tables only. If you tighten one side, review the other. + // Include nested non-paragraph blocks (notably nested tables inside + // table cells) so edits there invalidate this parent table version. + hash = hashString(hash, deriveBlockVersion(cellBlock as FlowBlock)); } } } diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts index a9e90d5b1f..fb5d17840b 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -1290,9 +1290,13 @@ export class EditorInputManager { if (!handledByDepth) { try { // SD-1584: clicking inside a block SDT selects the node (NodeSelection). + // Exception: clicks inside tables nested in this SDT should use text + // selection so caret placement/editing inside table cells works. const sdtBlock = clickDepth === 1 ? this.#findStructuredContentBlockAtPos(doc, hit.pos) : null; let nextSelection: Selection; - if (sdtBlock) { + const insideTableInSdt = + !!sdtBlock && this.#isInsideTableWithinStructuredContentBlock(doc, hit.pos, sdtBlock.pos); + if (sdtBlock && !insideTableInSdt) { nextSelection = NodeSelection.create(doc, sdtBlock.pos); } else { nextSelection = TextSelection.create(doc, hit.pos); @@ -1597,6 +1601,34 @@ export class EditorInputManager { return null; } + #isInsideTableWithinStructuredContentBlock(doc: ProseMirrorNode, pos: number, sdtPos: number): boolean { + if (!Number.isFinite(pos) || !Number.isFinite(sdtPos)) return false; + + try { + const $pos = doc.resolve(pos); + let tableDepth = -1; + let blockDepth = -1; + + for (let depth = $pos.depth; depth > 0; depth--) { + const nodeName = $pos.node(depth)?.type?.name; + if (tableDepth === -1 && nodeName === 'table') { + tableDepth = depth; + } + if (nodeName === 'structuredContentBlock') { + const candidatePos = $pos.before(depth); + if (candidatePos === sdtPos) { + blockDepth = depth; + break; + } + } + } + + return tableDepth !== -1 && blockDepth !== -1 && tableDepth > blockDepth; + } catch { + return false; + } + } + #findStructuredContentBlockById(doc: ProseMirrorNode, id: string): StructuredContentSelection | null { let found: StructuredContentSelection | null = null; doc.descendants((node, pos) => { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.structuredContent.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.structuredContent.test.ts new file mode 100644 index 0000000000..d01f0c7a78 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.structuredContent.test.ts @@ -0,0 +1,255 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; + +import { resolvePointerPositionHit } from '../input/PositionHitResolver.js'; + +const { mockTextSelectionCreate, mockNodeSelectionCreate } = vi.hoisted(() => ({ + mockTextSelectionCreate: vi.fn(), + mockNodeSelectionCreate: vi.fn(), +})); + +vi.mock('../input/PositionHitResolver.js', () => ({ + resolvePointerPositionHit: vi.fn(() => ({ + pos: 12, + layoutEpoch: 1, + pageIndex: 0, + blockId: 'body-1', + column: 0, + lineIndex: 0, + })), +})); + +vi.mock('@superdoc/layout-bridge', () => ({ + getFragmentAtPosition: vi.fn(() => null), +})); + +vi.mock('prosemirror-state', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + TextSelection: { + ...original.TextSelection, + create: mockTextSelectionCreate, + }, + NodeSelection: { + ...original.NodeSelection, + create: mockNodeSelectionCreate, + }, + Selection: { + ...original.Selection, + near: vi.fn(() => ({ + empty: true, + $from: { parent: { inlineContent: true } }, + })), + }, + }; +}); + +function getPointerEventImpl(): typeof PointerEvent | typeof MouseEvent { + return ( + (globalThis as unknown as { PointerEvent?: typeof PointerEvent; MouseEvent: typeof MouseEvent }).PointerEvent ?? + globalThis.MouseEvent + ); +} + +function createMockDoc(mode: 'tableInSdt' | 'plainSdt') { + return { + content: { size: 200 }, + nodeAt: vi.fn(() => ({ nodeSize: 20 })), + resolve: vi.fn((_pos: number) => { + if (mode === 'tableInSdt') { + return { + depth: 2, + node: (depth: number) => { + if (depth === 2) return { type: { name: 'table' } }; + if (depth === 1) return { type: { name: 'structuredContentBlock' } }; + return { type: { name: 'doc' } }; + }, + before: (depth: number) => (depth === 1 ? 10 : 11), + start: (depth: number) => (depth === 1 ? 11 : 12), + end: (depth: number) => (depth === 1 ? 30 : 29), + }; + } + return { + depth: 1, + node: (depth: number) => { + if (depth === 1) return { type: { name: 'structuredContentBlock' } }; + return { type: { name: 'doc' } }; + }, + before: (_depth: number) => 10, + start: (_depth: number) => 11, + end: (_depth: number) => 30, + }; + }), + nodesBetween: vi.fn((_from: number, _to: number, cb: (node: unknown, pos: number) => void) => { + cb({ isTextblock: true }, 0); + }), + }; +} + +describe('EditorInputManager structuredContentBlock table exception', () => { + let EditorInputManagerClass: + | (new () => { + setDependencies: (deps: unknown) => void; + setCallbacks: (callbacks: unknown) => void; + bind: () => void; + destroy: () => void; + }) + | null = null; + let manager: InstanceType>; + let viewportHost: HTMLElement; + let visibleHost: HTMLElement; + let mountRoot: HTMLElement; + let mockEditor: { + isEditable: boolean; + state: { + doc: ReturnType; + tr: { setSelection: Mock; setStoredMarks: Mock }; + selection: { $anchor: null }; + storedMarks: null; + }; + view: { + dispatch: Mock; + dom: HTMLElement; + focus: Mock; + hasFocus: Mock; + }; + on: Mock; + off: Mock; + emit: Mock; + }; + let mockHitTestTable: Mock; + + function mountWithDoc(mode: 'tableInSdt' | 'plainSdt') { + mockEditor.state.doc = createMockDoc(mode); + } + + beforeEach(async () => { + mockTextSelectionCreate.mockReset(); + mockNodeSelectionCreate.mockReset(); + mockTextSelectionCreate.mockReturnValue({ + empty: true, + $from: { parent: { inlineContent: true } }, + }); + mockNodeSelectionCreate.mockReturnValue({ + empty: false, + }); + + viewportHost = document.createElement('div'); + visibleHost = document.createElement('div'); + visibleHost.appendChild(viewportHost); + mountRoot = document.createElement('div'); + mountRoot.appendChild(visibleHost); + document.body.appendChild(mountRoot); + + mockEditor = { + isEditable: true, + state: { + doc: createMockDoc('plainSdt'), + tr: { + setSelection: vi.fn().mockReturnThis(), + setStoredMarks: vi.fn().mockReturnThis(), + }, + selection: { $anchor: null }, + storedMarks: null, + }, + view: { + dispatch: vi.fn(), + dom: document.createElement('div'), + focus: vi.fn(), + hasFocus: vi.fn(() => false), + }, + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + }; + + if (!EditorInputManagerClass) { + const mod = await import('../pointer-events/EditorInputManager.js'); + EditorInputManagerClass = mod.EditorInputManager as typeof EditorInputManagerClass; + } + + manager = new EditorInputManagerClass!(); + manager.setDependencies({ + getActiveEditor: vi.fn(() => mockEditor), + getEditor: vi.fn(() => mockEditor), + getLayoutState: vi.fn(() => ({ layout: {} as any, blocks: [], measures: [] })), + getEpochMapper: vi.fn(() => ({ + mapPosFromLayoutToCurrentDetailed: vi.fn(() => ({ ok: true, pos: 12, toEpoch: 1 })), + })), + getViewportHost: vi.fn(() => viewportHost), + getVisibleHost: vi.fn(() => visibleHost), + getLayoutMode: vi.fn(() => 'vertical'), + getHeaderFooterSession: vi.fn(() => null), + getPageGeometryHelper: vi.fn(() => null), + getZoom: vi.fn(() => 1), + isViewLocked: vi.fn(() => false), + getDocumentMode: vi.fn(() => 'editing'), + getPageElement: vi.fn(() => null), + isSelectionAwareVirtualizationEnabled: vi.fn(() => false), + }); + manager.setCallbacks({ + normalizeClientPoint: vi.fn((clientX: number, clientY: number) => ({ x: clientX, y: clientY })), + scheduleSelectionUpdate: vi.fn(), + updateSelectionDebugHud: vi.fn(), + hitTestTable: (mockHitTestTable = vi.fn(() => null)), + }); + manager.bind(); + }); + + afterEach(() => { + manager.destroy(); + mountRoot.remove(); + vi.clearAllMocks(); + }); + + it('uses TextSelection when click lands inside table within structuredContentBlock', () => { + mountWithDoc('tableInSdt'); + mockHitTestTable.mockReturnValue({ + block: { id: 'table-1' }, + cellRowIndex: 0, + cellColIndex: 0, + }); + const tableFragment = document.createElement('div'); + tableFragment.className = 'superdoc-table-fragment'; + const target = document.createElement('span'); + tableFragment.appendChild(target); + viewportHost.appendChild(tableFragment); + + const PointerEventImpl = getPointerEventImpl(); + target.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 20, + clientY: 20, + } as PointerEventInit), + ); + + expect(resolvePointerPositionHit as unknown as Mock).toHaveBeenCalled(); + expect(mockTextSelectionCreate).toHaveBeenCalled(); + expect(mockNodeSelectionCreate).not.toHaveBeenCalled(); + }); + + it('uses NodeSelection for plain structuredContentBlock click (non-table)', () => { + mountWithDoc('plainSdt'); + const target = document.createElement('span'); + viewportHost.appendChild(target); + + const PointerEventImpl = getPointerEventImpl(); + target.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 24, + clientY: 24, + } as PointerEventInit), + ); + + expect(resolvePointerPositionHit as unknown as Mock).toHaveBeenCalled(); + expect(mockNodeSelectionCreate).toHaveBeenCalled(); + }); +});