diff --git a/e2e-tests/test-data/structured-content/sdt-basic.docx b/e2e-tests/test-data/structured-content/sdt-basic.docx new file mode 100644 index 0000000000..cb164a8838 Binary files /dev/null and b/e2e-tests/test-data/structured-content/sdt-basic.docx differ diff --git a/e2e-tests/tests/visuals/layout-engine.spec.js b/e2e-tests/tests/visuals/layout-engine.spec.js index 514b71466e..c0876ee0a9 100644 --- a/e2e-tests/tests/visuals/layout-engine.spec.js +++ b/e2e-tests/tests/visuals/layout-engine.spec.js @@ -42,5 +42,70 @@ if (!shouldRun) { }); }); }); + + const loadStructuredContentDocument = async (page) => { + const superEditor = await goToPageAndWaitForEditor(page, { layout: 1 }); + const fileInput = page.locator('input[type="file"]'); + + await fileInput.setInputFiles('./test-data/structured-content/sdt-basic.docx'); + + await page.waitForFunction(() => window.superdoc !== undefined && window.editor !== undefined, null, { + polling: 100, + timeout: 10_000, + }); + + await page.waitForFunction(() => { + const toolbar = document.querySelector('#toolbar'); + return toolbar && toolbar.children.length > 0; + }); + + return superEditor; + }; + + test('structured content: inline selection (sdt-basic.docx)', async ({ page }) => { + const superEditor = await loadStructuredContentDocument(page); + const inlineStructuredContent = page.locator('.superdoc-structured-content-inline').first(); + + await expect(inlineStructuredContent).toBeVisible(); + await inlineStructuredContent.scrollIntoViewIfNeeded(); + await inlineStructuredContent.hover(); + await inlineStructuredContent.click({ force: true }); + await expect(inlineStructuredContent).toHaveClass(/ProseMirror-selectednode/); + await page.waitForFunction(() => { + return document.querySelectorAll('.superdoc-structured-content-block.sdt-group-hover').length === 0; + }); + const inlineEditorBox = await superEditor.boundingBox(); + if (inlineEditorBox) { + await page.mouse.move(inlineEditorBox.x - 10, inlineEditorBox.y - 10); + } + + await expect(superEditor).toHaveScreenshot(); + }); + + test('structured content: block selection (sdt-basic.docx)', async ({ page }) => { + const superEditor = await loadStructuredContentDocument(page); + const blockStructuredContent = page.locator('.superdoc-structured-content-block').first(); + + await expect(blockStructuredContent).toBeVisible(); + await blockStructuredContent.scrollIntoViewIfNeeded(); + await blockStructuredContent.hover(); + await blockStructuredContent.click({ force: true }); + await expect(blockStructuredContent).toHaveClass(/ProseMirror-selectednode/); + const blockEditorBox = await superEditor.boundingBox(); + if (blockEditorBox) { + await page.mouse.move(blockEditorBox.x - 10, blockEditorBox.y - 10); + } + await page.waitForFunction( + () => { + const block = document.querySelector('.superdoc-structured-content-block'); + if (block?.matches(':hover')) return false; + return document.querySelectorAll('.superdoc-structured-content-block.sdt-group-hover').length === 0; + }, + null, + { timeout: 2_000 }, + ); + + await expect(superEditor).toHaveScreenshot(); + }); }); } diff --git a/e2e-tests/tests/visuals/layout-engine.spec.js-snapshots/layout-engine-visuals-layout-1-structured-content-block-selection-sdt-basic-docx-1-chromium-linux.png b/e2e-tests/tests/visuals/layout-engine.spec.js-snapshots/layout-engine-visuals-layout-1-structured-content-block-selection-sdt-basic-docx-1-chromium-linux.png new file mode 100644 index 0000000000..5fdfeefa8d Binary files /dev/null and b/e2e-tests/tests/visuals/layout-engine.spec.js-snapshots/layout-engine-visuals-layout-1-structured-content-block-selection-sdt-basic-docx-1-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/layout-engine.spec.js-snapshots/layout-engine-visuals-layout-1-structured-content-inline-selection-sdt-basic-docx-1-chromium-linux.png b/e2e-tests/tests/visuals/layout-engine.spec.js-snapshots/layout-engine-visuals-layout-1-structured-content-inline-selection-sdt-basic-docx-1-chromium-linux.png new file mode 100644 index 0000000000..7a28a2a954 Binary files /dev/null and b/e2e-tests/tests/visuals/layout-engine.spec.js-snapshots/layout-engine-visuals-layout-1-structured-content-inline-selection-sdt-basic-docx-1-chromium-linux.png differ diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 013b44e3ab..f00c29e3a6 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -787,6 +787,7 @@ export class DomPainter { private footerProvider?: PageDecorationProvider; private totalPages = 0; private linkIdCounter = 0; // Counter for generating unique link IDs + private sdtLabelsRendered = new Set(); // Tracks SDT labels rendered across pages /** * WeakMap storing tooltip data for hyperlink elements before DOM insertion. @@ -1007,6 +1008,7 @@ export class DomPainter { throw new Error('DomPainter.paint requires a DOM-like document'); } this.doc = doc; + this.sdtLabelsRendered.clear(); // Reset SDT label tracking for new render cycle // Simple transaction gate: only use position mapping optimization for single-step transactions. // Complex transactions (paste, multi-step replace, etc.) fall back to full rebuild. @@ -1440,7 +1442,7 @@ export class DomPainter { pageNumberText: page.numberText, }; - const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup); + const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup, this.sdtLabelsRendered); page.fragments.forEach((fragment, index) => { const sdtBoundary = sdtBoundaries.get(index); @@ -1747,7 +1749,7 @@ export class DomPainter { const existing = new Map(state.fragments.map((frag) => [frag.key, frag])); const nextFragments: FragmentDomState[] = []; - const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup); + const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup, this.sdtLabelsRendered); const contextBase: FragmentRenderContext = { pageNumber: page.number, @@ -1883,7 +1885,7 @@ export class DomPainter { section: 'body', }; - const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup); + const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup, this.sdtLabelsRendered); const fragments: FragmentDomState[] = page.fragments.map((fragment, index) => { const sdtBoundary = sdtBoundaries.get(index); @@ -5175,9 +5177,45 @@ const getFragmentSdtContainerKey = (fragment: Fragment, blockLookup: BlockLookup return null; }; +const getFragmentHeight = (fragment: Fragment, blockLookup: BlockLookup): number => { + if (fragment.kind === 'table' || fragment.kind === 'image' || fragment.kind === 'drawing') { + return fragment.height; + } + + const lookup = blockLookup.get(fragment.blockId); + if (!lookup) return 0; + + if (fragment.kind === 'para' && lookup.measure.kind === 'paragraph') { + const measure = lookup.measure; + const lines = fragment.lines ?? measure.lines.slice(fragment.fromLine, fragment.toLine); + if (lines.length === 0) return 0; + let totalHeight = 0; + for (const line of lines) { + totalHeight += line.lineHeight ?? 0; + } + return totalHeight; + } + + if (fragment.kind === 'list-item' && lookup.measure.kind === 'list') { + const listMeasure = lookup.measure as ListMeasure; + const item = listMeasure.items.find((it) => it.itemId === fragment.itemId); + if (!item) return 0; + const lines = item.paragraph.lines.slice(fragment.fromLine, fragment.toLine); + if (lines.length === 0) return 0; + let totalHeight = 0; + for (const line of lines) { + totalHeight += line.lineHeight ?? 0; + } + return totalHeight; + } + + return 0; +}; + const computeSdtBoundaries = ( fragments: readonly Fragment[], blockLookup: BlockLookup, + sdtLabelsRendered: Set, ): Map => { const boundaries = new Map(); const containerKeys = fragments.map((fragment) => getFragmentSdtContainerKey(fragment, blockLookup)); @@ -5203,10 +5241,31 @@ const computeSdtBoundaries = ( for (let k = i; k <= j; k += 1) { const fragment = fragments[k]; + const isStart = k === i; + const isEnd = k === j; + + let paddingBottomOverride: number | undefined; + if (!isEnd) { + const nextFragment = fragments[k + 1]; + const currentHeight = getFragmentHeight(fragment, blockLookup); + const currentBottom = fragment.y + currentHeight; + const gapToNext = nextFragment.y - currentBottom; + if (gapToNext > 0) { + paddingBottomOverride = gapToNext; + } + } + + const showLabel = isStart && !sdtLabelsRendered.has(currentKey); + if (showLabel) { + sdtLabelsRendered.add(currentKey); + } + boundaries.set(k, { - isStart: k === i, - isEnd: k === j, + isStart, + isEnd, widthOverride: groupRight - fragment.x, + paddingBottomOverride, + showLabel, }); } diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 0ea2ce6e88..26b62dd04a 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -352,10 +352,26 @@ const SDT_CONTAINER_STYLES = ` padding: 1px; box-sizing: border-box; border-radius: 4px; - border: 1px solid #629be7; + border: 1px solid transparent; position: relative; } +.superdoc-structured-content-block:not(.ProseMirror-selectednode):hover { + background-color: #f2f2f2; + border-color: transparent; +} + +/* Group hover (JavaScript-coordinated) */ +.superdoc-structured-content-block.sdt-group-hover:not(.ProseMirror-selectednode) { + background-color: #f2f2f2; + border-color: transparent; +} + +.superdoc-structured-content-block.ProseMirror-selectednode { + border-color: #629be7; + outline: none; +} + /* Structured content drag handle/label - positioned above */ .superdoc-structured-content__label { font-size: 10px; @@ -376,7 +392,9 @@ const SDT_CONTAINER_STYLES = ` box-sizing: border-box; z-index: 10; display: none; - pointer-events: none; + pointer-events: auto; + cursor: pointer; + user-select: none; } .superdoc-structured-content__label span { @@ -386,10 +404,14 @@ const SDT_CONTAINER_STYLES = ` text-overflow: ellipsis; } -.superdoc-structured-content-block:hover .superdoc-structured-content__label { +.superdoc-structured-content-block.ProseMirror-selectednode .superdoc-structured-content__label { display: inline-flex; } +.superdoc-structured-content-block:not(.ProseMirror-selectednode):hover .superdoc-structured-content__label { + display: none; +} + /* Continuation styling for structured content blocks */ /* Single fragment (both start and end): full border radius */ .superdoc-structured-content-block[data-sdt-container-start="true"][data-sdt-container-end="true"] { @@ -420,16 +442,22 @@ const SDT_CONTAINER_STYLES = ` padding: 1px; box-sizing: border-box; border-radius: 4px; - border: 1px solid #629be7; + border: 1px solid transparent; position: relative; display: inline; z-index: 10; } /* Hover effect for inline structured content */ -.superdoc-structured-content-inline:hover { - background-color: rgba(98, 155, 231, 0.15); - border-color: #4a8ad9; +.superdoc-structured-content-inline:not(.ProseMirror-selectednode):hover { + background-color: #f2f2f2; + border-color: transparent; +} + +.superdoc-structured-content-inline.ProseMirror-selectednode { + border-color: #629be7; + outline: none; + background-color: transparent; } /* Inline structured content label - shown on hover */ @@ -446,13 +474,19 @@ const SDT_CONTAINER_STYLES = ` white-space: nowrap; z-index: 100; display: none; - pointer-events: none; + pointer-events: auto; + cursor: pointer; + user-select: none; } -.superdoc-structured-content-inline:hover .superdoc-structured-content-inline__label { +.superdoc-structured-content-inline.ProseMirror-selectednode .superdoc-structured-content-inline__label { display: block; } +.superdoc-structured-content-inline:not(.ProseMirror-selectednode):hover .superdoc-structured-content-inline__label { + display: none; +} + /* Viewing mode: remove structured content affordances */ .presentation-editor--viewing .superdoc-structured-content-block, .presentation-editor--viewing .superdoc-structured-content-inline { @@ -461,6 +495,11 @@ const SDT_CONTAINER_STYLES = ` padding: 0; } +.presentation-editor--viewing .superdoc-structured-content-block:hover { + background: none; + border: none; +} + .presentation-editor--viewing .superdoc-structured-content-inline:hover { background: none; border: none; diff --git a/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts b/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts index 807cfd42e6..78eb8e0eb6 100644 --- a/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts +++ b/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts @@ -1,28 +1,7 @@ -/** - * SDT Helper Utilities - * - * Provides type guards and helper functions for working with SDT (Structured Document Tag) metadata - * in the DOM painter. These utilities ensure type-safe access to SDT properties and reduce code - * duplication across rendering logic. - */ - import type { SdtMetadata } from '@superdoc/contracts'; /** - * Type guard for StructuredContentMetadata with specific properties. - * - * Validates that the metadata object has the expected structure for structured content - * and narrows the type to allow safe property access. - * - * @param sdt - The SDT metadata to check - * @returns True if the metadata is a structured content object with valid properties - * - * @example - * ```typescript - * if (isStructuredContentMetadata(block.attrs?.sdt)) { - * console.log(sdt.alias); // Type-safe access - * } - * ``` + * Type guard for StructuredContentMetadata. */ export function isStructuredContentMetadata( sdt: SdtMetadata | null | undefined, @@ -33,20 +12,7 @@ export function isStructuredContentMetadata( } /** - * Type guard for DocumentSectionMetadata with specific properties. - * - * Validates that the metadata object has the expected structure for document sections - * and narrows the type to allow safe property access. - * - * @param sdt - The SDT metadata to check - * @returns True if the metadata is a document section object with valid properties - * - * @example - * ```typescript - * if (isDocumentSectionMetadata(block.attrs?.sdt)) { - * console.log(sdt.title); // Type-safe access - * } - * ``` + * Type guard for DocumentSectionMetadata. */ export function isDocumentSectionMetadata( sdt: SdtMetadata | null | undefined, @@ -132,10 +98,7 @@ export function getSdtContainerConfig(sdt: SdtMetadata | null | undefined): SdtC } /** - * Return the SDT metadata that should drive container styling. - * - * Prefers the primary `sdt` when it resolves to a container type, otherwise - * falls back to `containerSdt` (e.g., docPart paragraphs inside a documentSection). + * Returns the SDT metadata for container styling, preferring `sdt` over `containerSdt`. */ export function getSdtContainerMetadata( sdt?: SdtMetadata | null, @@ -147,9 +110,7 @@ export function getSdtContainerMetadata( } /** - * Returns a stable key for a block-level SDT container, or null if unavailable. - * - * The key is used to detect consecutive fragments that belong to the same SDT. + * Returns a stable key for grouping consecutive fragments in the same SDT container. */ export function getSdtContainerKey(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): string | null { const metadata = getSdtContainerMetadata(sdt, containerSdt); @@ -187,6 +148,10 @@ export type SdtBoundaryOptions = { isEnd?: boolean; /** Optional width override for the SDT container element */ widthOverride?: number; + /** Optional padding bottom override for filling gaps between fragments */ + paddingBottomOverride?: number; + /** Whether to show the label (overrides isStart check if provided) */ + showLabel?: boolean; }; /** @@ -208,6 +173,7 @@ export type SdtBoundaryOptions = { * - Data attributes for continuation detection (`data-sdt-container-start/end`) * - Overflow visible to allow labels to appear above content * - Label/tooltip element created and appended to container when isStart=true + * - Padding bottom applied if paddingBottomOverride is provided (for filling gaps) * * **Label Element Structure:** * ```html @@ -241,7 +207,6 @@ export function applySdtContainerStyling( containerSdt?: SdtMetadata | null | undefined, boundaryOptions?: SdtBoundaryOptions, ): void { - // Try primary sdt first, fall back to containerSdt let config = getSdtContainerConfig(sdt); if (!config && containerSdt) { config = getSdtContainerConfig(containerSdt); @@ -251,7 +216,6 @@ export function applySdtContainerStyling( const isStart = boundaryOptions?.isStart ?? config.isStart; const isEnd = boundaryOptions?.isEnd ?? config.isEnd; - // Apply container class and data attributes container.classList.add(config.className); container.dataset.sdtContainerStart = String(isStart); container.dataset.sdtContainerEnd = String(isEnd); @@ -261,8 +225,13 @@ export function applySdtContainerStyling( container.style.width = `${boundaryOptions.widthOverride}px`; } - // Only create label on the first fragment of a multi-fragment container - if (isStart) { + if (boundaryOptions?.paddingBottomOverride != null && boundaryOptions.paddingBottomOverride > 0) { + container.style.paddingBottom = `${boundaryOptions.paddingBottomOverride}px`; + } + + const shouldShowLabel = boundaryOptions?.showLabel ?? isStart; + + if (shouldShowLabel) { const labelEl = doc.createElement('div'); labelEl.className = config.labelClassName; const labelText = doc.createElement('span'); diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 98e7bdfcc4..bf2c059539 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -293,6 +293,18 @@ export class PresentationEditor extends EventEmitter { element: HTMLElement; pmStart: number; } | null = null; + #lastSelectedStructuredContentBlock: { + id: string | null; + elements: HTMLElement[]; + } | null = null; + #lastSelectedStructuredContentInline: { + id: string | null; + elements: HTMLElement[]; + } | null = null; + #lastHoveredStructuredContentBlock: { + id: string | null; + elements: HTMLElement[]; + } | null = null; // Remote cursor/presence state management /** Manager for remote cursor rendering and awareness subscriptions */ @@ -378,6 +390,11 @@ export class PresentationEditor extends EventEmitter { this.#painterHost.className = 'presentation-editor__pages'; this.#painterHost.style.transformOrigin = 'top left'; this.#viewportHost.appendChild(this.#painterHost); + + // Add event listeners for structured content hover coordination + this.#painterHost.addEventListener('mouseover', this.#handleStructuredContentBlockMouseEnter); + this.#painterHost.addEventListener('mouseout', this.#handleStructuredContentBlockMouseLeave); + const win = this.#visibleHost?.ownerDocument?.defaultView ?? window; this.#domIndexObserverManager = new DomPositionIndexObserverManager({ windowRoot: win, @@ -3191,6 +3208,243 @@ export class PresentationEditor extends EventEmitter { this.#setSelectedFieldAnnotationClass(element, pmStart); } + #clearSelectedStructuredContentBlockClass() { + if (!this.#lastSelectedStructuredContentBlock) return; + this.#lastSelectedStructuredContentBlock.elements.forEach((element) => { + element.classList.remove('ProseMirror-selectednode'); + }); + this.#lastSelectedStructuredContentBlock = null; + } + + #setSelectedStructuredContentBlockClass(elements: HTMLElement[], id: string | null) { + if ( + this.#lastSelectedStructuredContentBlock && + this.#lastSelectedStructuredContentBlock.id === id && + this.#lastSelectedStructuredContentBlock.elements.length === elements.length && + this.#lastSelectedStructuredContentBlock.elements.every((el) => elements.includes(el)) + ) { + return; + } + + this.#clearSelectedStructuredContentBlockClass(); + elements.forEach((element) => element.classList.add('ProseMirror-selectednode')); + this.#lastSelectedStructuredContentBlock = { id, elements }; + } + + #syncSelectedStructuredContentBlockClass(selection: Selection | null | undefined) { + if (!selection) { + this.#clearSelectedStructuredContentBlockClass(); + return; + } + + let node: ProseMirrorNode | null = null; + let id: string | null = null; + + if (selection instanceof NodeSelection) { + if (selection.node?.type?.name !== 'structuredContentBlock') { + this.#clearSelectedStructuredContentBlockClass(); + return; + } + node = selection.node; + } else { + const $pos = selection.$from; + for (let depth = $pos.depth; depth > 0; depth--) { + const candidate = $pos.node(depth); + if (candidate.type?.name === 'structuredContentBlock') { + node = candidate; + break; + } + } + if (!node) { + this.#clearSelectedStructuredContentBlockClass(); + return; + } + } + + if (!this.#painterHost) { + this.#clearSelectedStructuredContentBlockClass(); + return; + } + + const rawId = (node.attrs as { id?: unknown } | null | undefined)?.id; + id = rawId != null ? String(rawId) : null; + let elements: HTMLElement[] = []; + + if (id) { + const escapedId = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id.replace(/"/g, '\\"'); + elements = Array.from( + this.#painterHost.querySelectorAll(`.superdoc-structured-content-block[data-sdt-id="${escapedId}"]`), + ) as HTMLElement[]; + } + + if (elements.length === 0) { + const elementAtPos = this.getElementAtPos(selection.from, { fallbackToCoords: true }); + const container = elementAtPos?.closest?.('.superdoc-structured-content-block') as HTMLElement | null; + if (container) { + elements = [container]; + } + } + + if (elements.length === 0) { + this.#clearSelectedStructuredContentBlockClass(); + return; + } + + this.#setSelectedStructuredContentBlockClass(elements, id); + } + + #handleStructuredContentBlockMouseEnter = (event: MouseEvent) => { + const target = event.target as HTMLElement; + const block = target.closest('.superdoc-structured-content-block'); + + if (!block || !(block instanceof HTMLElement)) return; + + // Don't show hover effect if already selected + if (block.classList.contains('ProseMirror-selectednode')) return; + + const rawId = block.dataset.sdtId; + if (!rawId) return; + + this.#setHoveredStructuredContentBlockClass(rawId); + }; + + #handleStructuredContentBlockMouseLeave = (event: MouseEvent) => { + const target = event.target as HTMLElement; + const block = target.closest('.superdoc-structured-content-block') as HTMLElement | null; + + if (!block) return; + + const relatedTarget = event.relatedTarget as HTMLElement | null; + if ( + relatedTarget && + block.dataset.sdtId && + relatedTarget.closest(`.superdoc-structured-content-block[data-sdt-id="${block.dataset.sdtId}"]`) + ) { + return; + } + + this.#clearHoveredStructuredContentBlockClass(); + }; + + #clearHoveredStructuredContentBlockClass() { + if (!this.#lastHoveredStructuredContentBlock) return; + this.#lastHoveredStructuredContentBlock.elements.forEach((element) => { + element.classList.remove('sdt-group-hover'); + }); + this.#lastHoveredStructuredContentBlock = null; + } + + #setHoveredStructuredContentBlockClass(id: string) { + if (this.#lastHoveredStructuredContentBlock?.id === id) return; + + this.#clearHoveredStructuredContentBlockClass(); + + if (!this.#painterHost) return; + + const escapedId = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id.replace(/"/g, '\\"'); + const elements = Array.from( + this.#painterHost.querySelectorAll(`.superdoc-structured-content-block[data-sdt-id="${escapedId}"]`), + ) as HTMLElement[]; + + if (elements.length === 0) return; + + elements.forEach((element) => { + if (!element.classList.contains('ProseMirror-selectednode')) { + element.classList.add('sdt-group-hover'); + } + }); + + this.#lastHoveredStructuredContentBlock = { id, elements }; + } + + #clearSelectedStructuredContentInlineClass() { + if (!this.#lastSelectedStructuredContentInline) return; + this.#lastSelectedStructuredContentInline.elements.forEach((element) => { + element.classList.remove('ProseMirror-selectednode'); + }); + this.#lastSelectedStructuredContentInline = null; + } + + #setSelectedStructuredContentInlineClass(elements: HTMLElement[], id: string | null) { + if ( + this.#lastSelectedStructuredContentInline && + this.#lastSelectedStructuredContentInline.id === id && + this.#lastSelectedStructuredContentInline.elements.length === elements.length && + this.#lastSelectedStructuredContentInline.elements.every((el) => elements.includes(el)) + ) { + return; + } + + this.#clearSelectedStructuredContentInlineClass(); + elements.forEach((element) => element.classList.add('ProseMirror-selectednode')); + this.#lastSelectedStructuredContentInline = { id, elements }; + } + + #syncSelectedStructuredContentInlineClass(selection: Selection | null | undefined) { + if (!selection) { + this.#clearSelectedStructuredContentInlineClass(); + return; + } + + let node: ProseMirrorNode | null = null; + let id: string | null = null; + let pos: number | null = null; + + if (selection instanceof NodeSelection) { + if (selection.node?.type?.name !== 'structuredContent') { + this.#clearSelectedStructuredContentInlineClass(); + return; + } + node = selection.node; + pos = selection.from; + } else { + const $pos = selection.$from; + for (let depth = $pos.depth; depth > 0; depth--) { + const candidate = $pos.node(depth); + if (candidate.type?.name === 'structuredContent') { + node = candidate; + pos = $pos.before(depth); + break; + } + } + if (!node || pos == null) { + this.#clearSelectedStructuredContentInlineClass(); + return; + } + } + + if (!this.#painterHost) { + this.#clearSelectedStructuredContentInlineClass(); + return; + } + + const rawId = (node.attrs as { id?: unknown } | null | undefined)?.id; + id = rawId != null ? String(rawId) : null; + let elements: HTMLElement[] = []; + + if (id) { + const escapedId = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id.replace(/"/g, '\\"'); + elements = Array.from( + this.#painterHost.querySelectorAll(`.superdoc-structured-content-inline[data-sdt-id="${escapedId}"]`), + ) as HTMLElement[]; + } + + if (elements.length === 0) { + const elementAtPos = this.getElementAtPos(pos, { fallbackToCoords: true }); + const container = elementAtPos?.closest?.('.superdoc-structured-content-inline') as HTMLElement | null; + if (container) { + elements = [container]; + } + } + + if (elements.length === 0) { + this.#clearSelectedStructuredContentInlineClass(); + return; + } + + this.#setSelectedStructuredContentInlineClass(elements, id); + } + /** * Updates the visual cursor/selection overlay to match the current editor selection. * @@ -3251,6 +3505,8 @@ export class PresentationEditor extends EventEmitter { if (!selection) { try { this.#clearSelectedFieldAnnotationClass(); + this.#clearSelectedStructuredContentBlockClass(); + this.#clearSelectedStructuredContentInlineClass(); this.#localSelectionLayer.innerHTML = ''; } catch (error) { if (process.env.NODE_ENV === 'development') { @@ -3274,6 +3530,8 @@ export class PresentationEditor extends EventEmitter { } this.#syncSelectedFieldAnnotationClass(selection); + this.#syncSelectedStructuredContentBlockClass(selection); + this.#syncSelectedStructuredContentInlineClass(selection); // Ensure selection endpoints remain mounted under virtualization so DOM-first // caret/selection rendering stays available during cross-page selection. diff --git a/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts index fb65106e26..27d09a6cad 100644 --- a/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -57,6 +57,13 @@ export type LayoutState = { measures: Measure[]; }; +type StructuredContentSelection = { + node: ProseMirrorNode; + pos: number; + start: number; + end: number; +}; + /** * Dependencies injected from PresentationEditor. */ @@ -617,6 +624,38 @@ export class EditorInputManager { event.preventDefault(); } + const inlineStructuredContentLabel = target?.closest?.( + '.superdoc-structured-content-inline__label', + ) as HTMLElement | null; + if (inlineStructuredContentLabel && doc) { + const resolved = this.#resolveStructuredContentInlineFromElement(doc, inlineStructuredContentLabel); + if (resolved) { + try { + const tr = editor.state.tr.setSelection(TextSelection.create(doc, resolved.start, resolved.end)); + editor.view?.dispatch(tr); + } catch {} + + this.#callbacks.scheduleSelectionUpdate?.(); + this.#focusEditor(); + return; + } + } + + const structuredContentLabel = target?.closest?.('.superdoc-structured-content__label') as HTMLElement | null; + if (structuredContentLabel && doc) { + const resolved = this.#resolveStructuredContentBlockFromElement(doc, structuredContentLabel); + if (resolved) { + try { + const tr = editor.state.tr.setSelection(TextSelection.create(doc, resolved.start, resolved.end)); + editor.view?.dispatch(tr); + } catch {} + + this.#callbacks.scheduleSelectionUpdate?.(); + this.#focusEditor(); + return; + } + } + // Handle click outside text content if (!rawHit) { this.#focusEditorAtFirstPosition(); @@ -705,7 +744,6 @@ export class EditorInputManager { } } - // Set selection for single click if (!handledByDepth) { try { let nextSelection: Selection = TextSelection.create(doc, hit.pos); @@ -928,6 +966,124 @@ export class EditorInputManager { } } + #findStructuredContentBlockAtPos(doc: ProseMirrorNode, pos: number): StructuredContentSelection | null { + if (!Number.isFinite(pos)) return null; + + const $pos = doc.resolve(pos); + for (let depth = $pos.depth; depth > 0; depth--) { + const node = $pos.node(depth); + if (node.type?.name === 'structuredContentBlock') { + return { + node, + pos: $pos.before(depth), + start: $pos.start(depth), + end: $pos.end(depth), + }; + } + } + + return null; + } + + #findStructuredContentBlockById(doc: ProseMirrorNode, id: string): StructuredContentSelection | null { + let found: StructuredContentSelection | null = null; + doc.descendants((node, pos) => { + if (node.type?.name !== 'structuredContentBlock') return true; + const nodeId = (node.attrs as { id?: unknown } | null | undefined)?.id; + if (String(nodeId ?? '') !== id) return true; + + found = { + node, + pos, + start: pos + 1, + end: pos + node.nodeSize - 1, + }; + return false; + }); + return found; + } + + #findStructuredContentInlineAtPos(doc: ProseMirrorNode, pos: number): StructuredContentSelection | null { + if (!Number.isFinite(pos)) return null; + + const $pos = doc.resolve(pos); + for (let depth = $pos.depth; depth > 0; depth--) { + const node = $pos.node(depth); + if (node.type?.name === 'structuredContent') { + return { + node, + pos: $pos.before(depth), + start: $pos.start(depth), + end: $pos.end(depth), + }; + } + } + + return null; + } + + #findStructuredContentInlineById(doc: ProseMirrorNode, id: string): StructuredContentSelection | null { + let found: StructuredContentSelection | null = null; + doc.descendants((node, pos) => { + if (node.type?.name !== 'structuredContent') return true; + const nodeId = (node.attrs as { id?: unknown } | null | undefined)?.id; + if (String(nodeId ?? '') !== id) return true; + + found = { + node, + pos, + start: pos + 1, + end: pos + node.nodeSize - 1, + }; + return false; + }); + return found; + } + + #resolveStructuredContentBlockFromElement( + doc: ProseMirrorNode, + element: HTMLElement, + ): StructuredContentSelection | null { + const container = element.closest?.('.superdoc-structured-content-block') as HTMLElement | null; + if (!container) return null; + + const sdtId = container.dataset?.sdtId; + if (sdtId) { + const match = this.#findStructuredContentBlockById(doc, sdtId); + if (match) return match; + } + + const pmStartRaw = container.dataset?.pmStart; + const pmStart = pmStartRaw != null ? Number(pmStartRaw) : NaN; + if (Number.isFinite(pmStart)) { + return this.#findStructuredContentBlockAtPos(doc, pmStart); + } + + return null; + } + + #resolveStructuredContentInlineFromElement( + doc: ProseMirrorNode, + element: HTMLElement, + ): StructuredContentSelection | null { + const container = element.closest?.('.superdoc-structured-content-inline') as HTMLElement | null; + if (!container) return null; + + const sdtId = container.dataset?.sdtId; + if (sdtId) { + const match = this.#findStructuredContentInlineById(doc, sdtId); + if (match) return match; + } + + const pmStartRaw = container.dataset?.pmStart; + const pmStart = pmStartRaw != null ? Number(pmStartRaw) : NaN; + if (Number.isFinite(pmStart)) { + return this.#findStructuredContentInlineAtPos(doc, pmStart); + } + + return null; + } + #handleClickWithoutLayout(event: PointerEvent, isDraggableAnnotation: boolean): void { if (!isDraggableAnnotation) { event.preventDefault(); diff --git a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts index 3369105fc9..96f6cbcc14 100644 --- a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts @@ -160,7 +160,14 @@ vi.mock('../../Editor', () => { getJSON: vi.fn(() => ({ type: 'doc', content: [] })), isEditable: true, state: { - selection: { from: 0, to: 0 }, + selection: { + from: 0, + to: 0, + $from: { + depth: 0, + node: vi.fn(), + }, + }, doc: { nodeSize: 100, content: { @@ -2963,7 +2970,14 @@ describe('PresentationEditor', () => { // Wait for initial render to complete so timers/RAF have settled. await new Promise((resolve) => setTimeout(resolve, 100)); - mockEditorInstance.state.selection = { from: 5, to: 5 }; + mockEditorInstance.state.selection = { + from: 5, + to: 5, + $from: { + depth: 0, + node: vi.fn(), + }, + }; const onCalls = mockEditorInstance.on as unknown as Mock; const selectionUpdateCall = onCalls.mock.calls.find((call) => call[0] === 'selectionUpdate'); @@ -2996,7 +3010,14 @@ describe('PresentationEditor', () => { await new Promise((resolve) => setTimeout(resolve, 100)); - mockEditorInstance.state.selection = { from: 1, to: 6 }; + mockEditorInstance.state.selection = { + from: 1, + to: 6, + $from: { + depth: 0, + node: vi.fn(), + }, + }; (mockEditorInstance.state.doc as unknown as { textBetween?: () => string }).textBetween = () => 'Hello world'; const onCalls = mockEditorInstance.on as unknown as Mock;