diff --git a/devtools/visual-testing/tests/interactions/stories/structured-content/sdt-lock-modes.ts b/devtools/visual-testing/tests/interactions/stories/structured-content/sdt-lock-modes.ts new file mode 100644 index 0000000000..4160b731a9 --- /dev/null +++ b/devtools/visual-testing/tests/interactions/stories/structured-content/sdt-lock-modes.ts @@ -0,0 +1,280 @@ +import { defineStory } from '@superdoc-testing/helpers'; +import type { Page } from '@playwright/test'; + +const WAIT_MS = 400; + +/** + * Find an SDT node position by its id attribute. + * Returns { pos, size } for the first matching structuredContent or structuredContentBlock node. + */ +async function findSdtPosition(page: Page, id: string): Promise<{ pos: number; size: number } | null> { + return page.evaluate((sdtId) => { + const editor = (window as unknown as { editor?: { state?: { doc?: { descendants?: Function } } } }).editor; + if (!editor?.state?.doc?.descendants) return null; + + let result: { pos: number; size: number } | null = null; + editor.state.doc.descendants( + (node: { type: { name: string }; attrs: Record; nodeSize: number }, pos: number) => { + if (result) return false; + if ( + (node.type.name === 'structuredContent' || node.type.name === 'structuredContentBlock') && + String(node.attrs.id) === sdtId + ) { + result = { pos, size: node.nodeSize }; + return false; + } + return true; + }, + ); + return result; + }, id); +} + +/** + * Set the cursor position in the editor. + */ +async function setCursorPosition(page: Page, pos: number): Promise { + await page.evaluate((p) => { + const editor = ( + window as unknown as { + editor?: { commands?: { setTextSelection?: (sel: { from: number; to: number }) => void; focus?: () => void } }; + } + ).editor; + editor?.commands?.setTextSelection?.({ from: p, to: p }); + editor?.commands?.focus?.(); + }, pos); +} + +/** + * Insert an inline structured content node via the editor command. + */ +async function insertInlineSdt( + page: Page, + attrs: { id: string; alias: string; lockMode: string }, + text: string, +): Promise { + await page.evaluate( + ({ attrs, text }) => { + const editor = ( + window as unknown as { + editor?: { + commands?: { + insertStructuredContentInline?: (opts: { attrs: typeof attrs; text: string }) => boolean; + }; + }; + } + ).editor; + if (!editor?.commands?.insertStructuredContentInline) { + throw new Error('insertStructuredContentInline command not available'); + } + editor.commands.insertStructuredContentInline({ attrs, text }); + }, + { attrs, text }, + ); +} + +/** + * Demonstrates SDT (Structured Document Tag) lock modes via programmatic + * commands and keyboard interactions. + * + * Lock modes: + * - unlocked: wrapper deletable, content editable + * - sdtLocked: wrapper NOT deletable, content editable + * - contentLocked: wrapper deletable, content NOT editable + * - sdtContentLocked: wrapper NOT deletable, content NOT editable + * + * This story exercises insertStructuredContentInline, insertStructuredContentBlock, + * updateStructuredContentById, cursor placement inside SDTs, and demonstrates + * lock enforcement by attempting keyboard interactions in locked SDTs. + */ +export default defineStory({ + name: 'sdt-lock-modes', + description: 'Create SDTs with various lock modes, interact with keyboard, demonstrate lock enforcement', + startDocument: null, + hideCaret: false, + + async run(page, helpers): Promise { + const { step, focus, type, press, waitForStable, milestone } = helpers; + + // ----------------------------------------------------------------- + // Step 1 – Insert inline SDTs with different lock modes + // ----------------------------------------------------------------- + await step('Insert inline SDTs', async () => { + await focus(); + + // Line 1: unlocked inline SDT + await type('Unlocked inline: '); + await waitForStable(WAIT_MS); + await insertInlineSdt(page, { id: '100', alias: 'Unlocked Field', lockMode: 'unlocked' }, 'editable value'); + await waitForStable(WAIT_MS); + + // Line 2: sdtLocked inline SDT + await press('End'); + await press('Enter'); + await type('SDT-locked inline: '); + await waitForStable(WAIT_MS); + await insertInlineSdt(page, { id: '200', alias: 'SDT Locked', lockMode: 'sdtLocked' }, 'cannot delete wrapper'); + await waitForStable(WAIT_MS); + + // Line 3: contentLocked inline SDT + await press('End'); + await press('Enter'); + await type('Content-locked inline: '); + await waitForStable(WAIT_MS); + await insertInlineSdt( + page, + { id: '300', alias: 'Content Locked', lockMode: 'contentLocked' }, + 'read-only content', + ); + await waitForStable(WAIT_MS); + + await milestone('inline-sdts-created', 'Three inline SDTs: unlocked, sdtLocked, contentLocked'); + }); + + // ----------------------------------------------------------------- + // Step 2 – Insert a block SDT with sdtContentLocked + // ----------------------------------------------------------------- + await step('Insert block SDT (sdtContentLocked)', async () => { + await press('End'); + await press('Enter'); + await press('Enter'); + await waitForStable(WAIT_MS); + + await page.evaluate(() => { + const editor = ( + window as unknown as { + editor?: { + commands?: { + insertStructuredContentBlock?: (opts: { + attrs: { id: string; alias: string; lockMode: string }; + html: string; + }) => boolean; + }; + }; + } + ).editor; + if (!editor?.commands?.insertStructuredContentBlock) { + throw new Error('insertStructuredContentBlock command not available'); + } + editor.commands.insertStructuredContentBlock({ + attrs: { id: '400', alias: 'Fully Locked Block', lockMode: 'sdtContentLocked' }, + html: '

This block is fully locked (sdtContentLocked).

', + }); + }); + await waitForStable(WAIT_MS); + + await milestone('block-sdt-created', 'Block SDT with sdtContentLocked created'); + }); + + // ----------------------------------------------------------------- + // Step 3 – Place cursor inside sdtLocked inline and type + // (content is editable — sdtLocked only protects the wrapper) + // ----------------------------------------------------------------- + await step('Type inside sdtLocked inline (content editable)', async () => { + const sdt = await findSdtPosition(page, '200'); + if (!sdt) throw new Error('sdtLocked SDT (id=200) not found'); + + // Place cursor inside the SDT text + await setCursorPosition(page, sdt.pos + 2); + await waitForStable(WAIT_MS); + + await type(' ADDED'); + await waitForStable(WAIT_MS); + + await milestone('sdt-locked-typed', 'Typed " ADDED" inside sdtLocked inline — content is editable'); + }); + + // ----------------------------------------------------------------- + // Step 4 – Place cursor inside contentLocked inline and try typing + // (content is NOT editable) + // ----------------------------------------------------------------- + await step('Try typing inside contentLocked inline', async () => { + const sdt = await findSdtPosition(page, '300'); + if (!sdt) throw new Error('contentLocked SDT (id=300) not found'); + + await setCursorPosition(page, sdt.pos + 2); + await waitForStable(WAIT_MS); + + // Attempt to type — should be blocked by contentLocked + await type('BLOCKED'); + await waitForStable(WAIT_MS); + + await milestone('content-locked-typing-blocked', 'Typing inside contentLocked SDT — should be blocked'); + }); + + // ----------------------------------------------------------------- + // Step 5 – Place cursor inside contentLocked and try Backspace + // (content deletion should also be blocked) + // ----------------------------------------------------------------- + await step('Try Backspace inside contentLocked inline', async () => { + const sdt = await findSdtPosition(page, '300'); + if (!sdt) throw new Error('contentLocked SDT (id=300) not found'); + + // Place cursor at end of SDT content + await setCursorPosition(page, sdt.pos + sdt.size - 2); + await waitForStable(WAIT_MS); + + await press('Backspace'); + await press('Backspace'); + await press('Backspace'); + await waitForStable(WAIT_MS); + + await milestone('content-locked-backspace-blocked', 'Backspace inside contentLocked SDT — should be blocked'); + }); + + // ----------------------------------------------------------------- + // Step 6 – Update lock mode via updateStructuredContentById + // Change the unlocked inline (id=100) to contentLocked + // ----------------------------------------------------------------- + await step('Update lock mode: unlocked → contentLocked', async () => { + await page.evaluate(() => { + const editor = ( + window as unknown as { + editor?: { + commands?: { + updateStructuredContentById?: (id: string, opts: { attrs: { lockMode: string } }) => boolean; + }; + }; + } + ).editor; + if (!editor?.commands?.updateStructuredContentById) { + throw new Error('updateStructuredContentById command not available'); + } + editor.commands.updateStructuredContentById('100', { + attrs: { lockMode: 'contentLocked' }, + }); + }); + await waitForStable(WAIT_MS); + + await milestone('lock-mode-updated', 'Updated id=100 from unlocked → contentLocked'); + }); + + // ----------------------------------------------------------------- + // Step 7 – Verify updated lock — try typing inside formerly unlocked SDT + // ----------------------------------------------------------------- + await step('Try typing in updated contentLocked SDT', async () => { + const sdt = await findSdtPosition(page, '100'); + if (!sdt) throw new Error('Updated SDT (id=100) not found'); + + await setCursorPosition(page, sdt.pos + 2); + await waitForStable(WAIT_MS); + + // Attempt to type — should now be blocked + await type('SHOULD FAIL'); + await waitForStable(WAIT_MS); + + await milestone('updated-lock-enforced', 'Typing in updated contentLocked SDT — should be blocked'); + }); + + // ----------------------------------------------------------------- + // Step 8 – Final state + // ----------------------------------------------------------------- + await step('Final state', async () => { + await focus(); + await setCursorPosition(page, 1); + await waitForStable(WAIT_MS); + + await milestone('final-state', 'Final document state with all SDT lock modes'); + }); + }, +}); diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 943aa4ab95..77bd061260 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -56,12 +56,15 @@ export type FieldAnnotationMetadata = { marks?: Record; }; +export type StructuredContentLockMode = 'unlocked' | 'sdtLocked' | 'contentLocked' | 'sdtContentLocked'; + export type StructuredContentMetadata = { type: 'structuredContent'; scope: 'inline' | 'block'; id?: string | null; tag?: string | null; alias?: string | null; + lockMode?: StructuredContentLockMode; sdtPr?: unknown; }; @@ -216,6 +219,8 @@ export type TabRun = RunMarks & { indent?: ParagraphIndent; pmStart?: number; pmEnd?: number; + /** SDT metadata if tab is inside a structured document tag. */ + sdt?: SdtMetadata; }; export type LineBreakRun = { diff --git a/packages/layout-engine/painters/dom/src/constants.ts b/packages/layout-engine/painters/dom/src/constants.ts index b5adb992ec..67b17b8219 100644 --- a/packages/layout-engine/painters/dom/src/constants.ts +++ b/packages/layout-engine/painters/dom/src/constants.ts @@ -55,6 +55,12 @@ export const DOM_CLASS_NAMES = { * Class name for document section containers. */ DOCUMENT_SECTION: 'superdoc-document-section', + + /** + * Class name added to block SDT fragments on hover via event delegation. + * Applied/removed by SdtGroupedHover to highlight all fragments of the same SDT. + */ + SDT_HOVER: 'sdt-hover', } as const; /** diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 42774924a6..c06538fbd7 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -68,7 +68,13 @@ import { DOM_CLASS_NAMES } from './constants.js'; import { sanitizeHref, encodeTooltip } from '@superdoc/url-validation'; import { renderTableFragment as renderTableFragmentElement } from './table/renderTableFragment.js'; import { assertPmPositions, assertFragmentPmPositions } from './pm-position-validation.js'; -import { applySdtContainerStyling, getSdtContainerKey, type SdtBoundaryOptions } from './utils/sdt-helpers.js'; +import { + applySdtContainerStyling, + getSdtContainerKey, + shouldRebuildForSdtBoundary, + type SdtBoundaryOptions, +} from './utils/sdt-helpers.js'; +import { SdtGroupedHover } from './utils/sdt-hover.js'; import { generateRulerDefinitionFromPx, createRulerElement, ensureRulerStyles } from './ruler/index.js'; import { toCssFontFamily } from '@superdoc/font-utils'; import { @@ -817,6 +823,7 @@ export class DomPainter { private onScrollHandler: ((e: Event) => void) | null = null; private onWindowScrollHandler: ((e: Event) => void) | null = null; private onResizeHandler: ((e: Event) => void) | null = null; + private sdtHover = new SdtGroupedHover(); /** The currently active/selected comment ID for highlighting */ private activeCommentId: string | null = null; @@ -1176,6 +1183,8 @@ export class DomPainter { }; win.addEventListener('resize', this.onResizeHandler); } + + this.sdtHover.bind(mount); } private computeVirtualMetrics(): void { @@ -1356,6 +1365,8 @@ export class DomPainter { // Clear changed blocks now that current visible pages are patched this.changedBlocks.clear(); this.processedLayoutVersion = this.layoutVersion; + + this.sdtHover.reapply(); } private updateSpacers(start: number, end: number): void { @@ -1720,6 +1731,7 @@ export class DomPainter { this.onScrollHandler = null; this.onWindowScrollHandler = null; this.onResizeHandler = null; + this.sdtHover.destroy(); this.layoutVersion = 0; this.processedLayoutVersion = -1; } @@ -1794,10 +1806,20 @@ export class DomPainter { if (current) { existing.delete(key); const sdtBoundaryMismatch = shouldRebuildForSdtBoundary(current.element, sdtBoundary); + // Verify the position mapping is reliable: if mapping the old pmStart doesn't produce + // the expected new pmStart, the mapping is degenerate (e.g. full-document paste) and + // we must rebuild to get correct span position attributes. + const newPmStart = (fragment as { pmStart?: number }).pmStart; + const mappingUnreliable = + this.currentMapping != null && + newPmStart != null && + current.element.dataset.pmStart != null && + this.currentMapping.map(Number(current.element.dataset.pmStart)) !== newPmStart; const needsRebuild = this.changedBlocks.has(fragment.blockId) || current.signature !== fragmentSignature(fragment, this.blockLookup) || - sdtBoundaryMismatch; + sdtBoundaryMismatch || + mappingUnreliable; if (needsRebuild) { const replacement = this.renderFragment(fragment, contextBase, sdtBoundary); @@ -4628,6 +4650,57 @@ export class DomPainter { return undefined; }; + // Inline SDT wrapping for geometry path (absolute-positioned elements). + // Same concept as the run-based path's SDT wrapper, but here elements use + // position:absolute so the wrapper itself must be absolutely positioned to + // span from the leftmost to rightmost child element. + let geoSdtWrapper: HTMLElement | null = null; + let geoSdtId: string | null = null; + let geoSdtWrapperLeft = 0; + let geoSdtMaxRight = 0; + + const closeGeoSdtWrapper = () => { + if (geoSdtWrapper) { + geoSdtWrapper.style.width = `${geoSdtMaxRight - geoSdtWrapperLeft}px`; + el.appendChild(geoSdtWrapper); + geoSdtWrapper = null; + geoSdtId = null; + } + }; + + /** + * Append an element to the line, routing through an inline SDT wrapper + * when the run has inline structuredContent metadata. + */ + const appendToLineGeo = (elem: HTMLElement, runForSdt: Run, elemLeftPx: number, elemWidthPx: number) => { + const resolved = this.resolveRunSdtId(runForSdt); + const thisRunSdtId = resolved?.sdtId ?? null; + + if (thisRunSdtId !== geoSdtId) { + closeGeoSdtWrapper(); + } + + if (resolved && this.doc) { + if (!geoSdtWrapper) { + geoSdtWrapper = this.createInlineSdtWrapper(resolved.sdt); + geoSdtId = thisRunSdtId; + geoSdtWrapperLeft = elemLeftPx; + geoSdtMaxRight = elemLeftPx; + geoSdtWrapper.style.position = 'absolute'; + geoSdtWrapper.style.left = `${elemLeftPx}px`; + geoSdtWrapper.style.top = '0px'; + geoSdtWrapper.style.height = `${line.lineHeight}px`; + } + // Adjust element left to be relative to wrapper + elem.style.left = `${elemLeftPx - geoSdtWrapperLeft}px`; + geoSdtMaxRight = Math.max(geoSdtMaxRight, elemLeftPx + elemWidthPx); + this.expandSdtWrapperPmRange(geoSdtWrapper, (runForSdt as TextRun).pmStart, (runForSdt as TextRun).pmEnd); + geoSdtWrapper.appendChild(elem); + } else { + el.appendChild(elem); + } + }; + for (let runIndex = line.fromRun; runIndex <= line.toRun; runIndex += 1) { const baseRun = block.runs[runIndex]; if (!baseRun) continue; @@ -4681,7 +4754,7 @@ export class DomPainter { if (baseRun.pmStart != null) tabEl.dataset.pmStart = String(baseRun.pmStart); if (baseRun.pmEnd != null) tabEl.dataset.pmEnd = String(baseRun.pmEnd); tabEl.dataset.layoutEpoch = String(this.layoutEpoch); - el.appendChild(tabEl); + appendToLineGeo(tabEl, baseRun, tabStartX + indentOffset, actualTabWidth); // Update cumulativeX to where the next content begins // This ensures proper positioning for subsequent elements @@ -4705,7 +4778,7 @@ export class DomPainter { (runSegments && runSegments[0]?.width !== undefined ? runSegments[0].width : elem.offsetWidth) ?? 0; elem.style.position = 'absolute'; elem.style.left = `${segX}px`; - el.appendChild(elem); + appendToLineGeo(elem, baseRun, segX, segWidth); cumulativeX = baseSegX + segWidth; } continue; @@ -4736,7 +4809,7 @@ export class DomPainter { const segWidth = (runSegments && runSegments[0]?.width !== undefined ? runSegments[0].width : 0) ?? 0; elem.style.position = 'absolute'; elem.style.left = `${segX}px`; - el.appendChild(elem); + appendToLineGeo(elem, baseRun, segX, segWidth); cumulativeX = baseSegX + segWidth; } continue; @@ -4783,7 +4856,7 @@ export class DomPainter { elem.style.position = 'absolute'; elem.style.left = `${xPos}px`; - el.appendChild(elem); + appendToLineGeo(elem, segmentRun, xPos, segment.width ?? 0); // Update cumulative X for next segment by measuring this element's width // This applies to ALL segments (both with and without explicit X) @@ -4800,9 +4873,15 @@ export class DomPainter { this.doc.body.removeChild(measureEl); } cumulativeX = baseX + width; + // Update SDT wrapper width if actual measured width differs from initial estimate + if (geoSdtWrapper) { + geoSdtMaxRight = Math.max(geoSdtMaxRight, xPos + width); + } } }); } + // Close any remaining SDT wrapper at end of geometry rendering + closeGeoSdtWrapper(); } else { // Use run-based rendering for normal text flow // Track current inline SDT wrapper to group adjacent runs with the same SDT id @@ -4819,9 +4898,8 @@ export class DomPainter { runsForLine.forEach((run) => { // Check if this run has inline structuredContent SDT - const runSdt = (run as TextRun).sdt; - const isInlineSdt = runSdt?.type === 'structuredContent' && runSdt?.scope === 'inline'; - const runSdtId = isInlineSdt && runSdt?.id ? String(runSdt.id) : null; + const resolved = this.resolveRunSdtId(run); + const runSdtId = resolved?.sdtId ?? null; // If SDT context changed, close the current wrapper if (runSdtId !== currentInlineSdtId) { @@ -4829,6 +4907,7 @@ export class DomPainter { } // Special handling for TabRuns (e.g., signature lines with underlines) + let elem: HTMLElement | null = null; if (run.kind === 'tab') { const tabEl = this.doc!.createElement('span'); tabEl.classList.add('superdoc-tab'); @@ -4867,46 +4946,22 @@ export class DomPainter { if (run.pmEnd != null) tabEl.dataset.pmEnd = String(run.pmEnd); tabEl.dataset.layoutEpoch = String(this.layoutEpoch); - el.appendChild(tabEl); - return; + elem = tabEl; + } else { + elem = this.renderRun(run, context, trackedConfig); } - - const elem = this.renderRun(run, context, trackedConfig); if (elem) { if (styleId) { elem.setAttribute('styleid', styleId); } // If this run has inline SDT, add to or create wrapper - if (isInlineSdt && runSdtId && this.doc) { + if (resolved && this.doc) { if (!currentInlineSdtWrapper) { - // Create new wrapper for this SDT group - currentInlineSdtWrapper = this.doc.createElement('span'); - currentInlineSdtWrapper.className = DOM_CLASS_NAMES.INLINE_SDT_WRAPPER; - currentInlineSdtWrapper.dataset.layoutEpoch = String(this.layoutEpoch); + currentInlineSdtWrapper = this.createInlineSdtWrapper(resolved.sdt); currentInlineSdtId = runSdtId; - // Apply SDT metadata to wrapper - this.applySdtDataset(currentInlineSdtWrapper, runSdt); - // Add label element for hover display - const alias = (runSdt as { alias?: string })?.alias || 'Inline content'; - const labelEl = this.doc.createElement('span'); - labelEl.className = `${DOM_CLASS_NAMES.INLINE_SDT_WRAPPER}__label`; - labelEl.textContent = alias; - currentInlineSdtWrapper.appendChild(labelEl); - } - // Update PM positions on wrapper to span all contained runs - const wrapperPmStart = currentInlineSdtWrapper.dataset.pmStart; - const wrapperPmEnd = currentInlineSdtWrapper.dataset.pmEnd; - if (run.pmStart != null) { - if (!wrapperPmStart || run.pmStart < parseInt(wrapperPmStart, 10)) { - currentInlineSdtWrapper.dataset.pmStart = String(run.pmStart); - } - } - if (run.pmEnd != null) { - if (!wrapperPmEnd || run.pmEnd > parseInt(wrapperPmEnd, 10)) { - currentInlineSdtWrapper.dataset.pmEnd = String(run.pmEnd); - } } + this.expandSdtWrapperPmRange(currentInlineSdtWrapper, run.pmStart, run.pmEnd); currentInlineSdtWrapper.appendChild(elem); } else { el.appendChild(elem); @@ -5111,6 +5166,7 @@ export class DomPainter { 'sdtScope', 'sdtTag', 'sdtAlias', + 'lockMode', 'sdtSectionTitle', 'sdtSectionType', 'sdtSectionLocked', @@ -5137,6 +5193,52 @@ export class DomPainter { } } + /** + * Resolve the inline SDT id from a run, or null if the run is not inside an inline SDT. + */ + private resolveRunSdtId(run: Run): { sdtId: string; sdt: SdtMetadata } | null { + const sdt = (run as TextRun).sdt; + if (sdt?.type === 'structuredContent' && sdt?.scope === 'inline' && sdt?.id) { + return { sdtId: String(sdt.id), sdt }; + } + return null; + } + + /** + * Create an inline SDT wrapper `` with className, layoutEpoch, dataset, and label. + * Shared by both the geometry and run-based rendering paths. + */ + private createInlineSdtWrapper(sdt: SdtMetadata): HTMLElement { + const wrapper = this.doc!.createElement('span'); + wrapper.className = DOM_CLASS_NAMES.INLINE_SDT_WRAPPER; + wrapper.dataset.layoutEpoch = String(this.layoutEpoch); + this.applySdtDataset(wrapper, sdt); + const alias = (sdt as { alias?: string })?.alias || 'Inline content'; + const labelEl = this.doc!.createElement('span'); + labelEl.className = `${DOM_CLASS_NAMES.INLINE_SDT_WRAPPER}__label`; + labelEl.textContent = alias; + wrapper.appendChild(labelEl); + return wrapper; + } + + /** + * Expand the PM position range tracked on an SDT wrapper to include a new run's range. + */ + private expandSdtWrapperPmRange(wrapper: HTMLElement, pmStart?: number | null, pmEnd?: number | null): void { + if (pmStart != null) { + const cur = wrapper.dataset.pmStart; + if (!cur || pmStart < parseInt(cur, 10)) { + wrapper.dataset.pmStart = String(pmStart); + } + } + if (pmEnd != null) { + const cur = wrapper.dataset.pmEnd; + if (!cur || pmEnd > parseInt(cur, 10)) { + wrapper.dataset.pmEnd = String(pmEnd); + } + } + } + /** * Applies SDT (Structured Document Tag) metadata to an element's dataset as data-sdt-* attributes. * Supports field annotations, structured content, document sections, and doc parts. @@ -5167,6 +5269,8 @@ export class DomPainter { this.setDatasetString(el, 'sdtScope', metadata.scope); this.setDatasetString(el, 'sdtTag', metadata.tag); this.setDatasetString(el, 'sdtAlias', metadata.alias); + // Always set lockMode (defaulting to 'unlocked') so CSS can target all SDTs uniformly. + this.setDatasetString(el, 'lockMode', metadata.lockMode || 'unlocked'); } else if (metadata.type === 'documentSection') { this.setDatasetString(el, 'sdtSectionTitle', metadata.title); this.setDatasetString(el, 'sdtSectionType', metadata.sectionType); @@ -5277,18 +5381,6 @@ const computeSdtBoundaries = ( return boundaries; }; -const shouldRebuildForSdtBoundary = (element: HTMLElement, boundary: SdtBoundaryOptions | undefined): boolean => { - if (!boundary) return false; - const startAttr = element.dataset.sdtContainerStart; - const endAttr = element.dataset.sdtContainerEnd; - const expectedStart = String(boundary.isStart ?? true); - const expectedEnd = String(boundary.isEnd ?? true); - if (startAttr === undefined || endAttr === undefined) { - return true; - } - return startAttr !== expectedStart || endAttr !== expectedEnd; -}; - const fragmentKey = (fragment: Fragment): string => { if (fragment.kind === 'para') { return `para:${fragment.blockId}:${fragment.fromLine}:${fragment.toLine}`; diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index b61016001a..10dad860be 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -366,21 +366,21 @@ const SDT_CONTAINER_STYLES = ` /* Structured content drag handle/label - positioned above */ .superdoc-structured-content__label { - font-size: 10px; + font-size: 11px; align-items: center; justify-content: center; position: absolute; left: 2px; top: -19px; width: calc(100% - 4px); - max-width: 110px; + max-width: 130px; min-width: 0; height: 18px; padding: 0 4px; border: 1px solid #629be7; border-bottom: none; border-radius: 6px 6px 0 0; - background-color: #629be7dd; + background-color: #629be7ee; box-sizing: border-box; z-index: 10; display: none; @@ -394,7 +394,9 @@ const SDT_CONTAINER_STYLES = ` text-overflow: ellipsis; } -.superdoc-structured-content-block:hover .superdoc-structured-content__label { +/* Hover effect for block structured content (via event delegation class). + * Shows label on hover — border reveal is handled by the lock-mode hover rule below. */ +.superdoc-structured-content-block.sdt-hover .superdoc-structured-content__label { display: inline-flex; } @@ -423,6 +425,13 @@ const SDT_CONTAINER_STYLES = ` border-bottom: none; } +/* Collapse double borders between adjacent SDT blocks */ +.superdoc-structured-content-block + .superdoc-structured-content-block { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + /* Structured Content Inline - Inline wrapper with blue border */ .superdoc-structured-content-inline { padding: 1px; @@ -434,21 +443,15 @@ const SDT_CONTAINER_STYLES = ` 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; -} - /* Inline structured content label - shown on hover */ .superdoc-structured-content-inline__label { position: absolute; bottom: calc(100% + 2px); left: 50%; transform: translateX(-50%); - font-size: 10px; - padding: 2px 6px; - background-color: #629be7dd; + font-size: 11px; + padding: 0 4px; + background-color: #629be7ee; color: white; border-radius: 4px; white-space: nowrap; @@ -461,6 +464,17 @@ const SDT_CONTAINER_STYLES = ` display: block; } +/* Hover highlight for SDT containers. + * Blue border is always visible (set in base rules above). + * Hover adds background highlight and z-index boost. + * Block SDTs use .sdt-hover class (event delegation for multi-fragment coordination). + * Inline SDTs use :hover (single element, no coordination needed). */ +.superdoc-structured-content-block[data-lock-mode].sdt-hover, +.superdoc-structured-content-inline[data-lock-mode]:hover { + background-color: rgba(98, 155, 231, 0.08); + z-index: 9999999; +} + /* Viewing mode: remove structured content affordances */ .presentation-editor--viewing .superdoc-structured-content-block, .presentation-editor--viewing .superdoc-structured-content-inline { @@ -507,7 +521,7 @@ const FIELD_ANNOTATION_STYLES = ` .superdoc-layout .annotation *::selection { background: transparent; } - + .superdoc-layout .annotation::-moz-selection, .superdoc-layout .annotation *::-moz-selection { background: transparent; 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..507acbe225 100644 --- a/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts +++ b/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts @@ -6,7 +6,7 @@ * duplication across rendering logic. */ -import type { SdtMetadata } from '@superdoc/contracts'; +import type { SdtMetadata, StructuredContentLockMode } from '@superdoc/contracts'; /** * Type guard for StructuredContentMetadata with specific properties. @@ -24,9 +24,12 @@ import type { SdtMetadata } from '@superdoc/contracts'; * } * ``` */ -export function isStructuredContentMetadata( - sdt: SdtMetadata | null | undefined, -): sdt is { type: 'structuredContent'; scope: 'inline' | 'block'; alias?: string | null } { +export function isStructuredContentMetadata(sdt: SdtMetadata | null | undefined): sdt is { + type: 'structuredContent'; + scope: 'inline' | 'block'; + alias?: string | null; + lockMode?: StructuredContentLockMode; +} { return ( sdt !== null && sdt !== undefined && typeof sdt === 'object' && 'type' in sdt && sdt.type === 'structuredContent' ); @@ -257,6 +260,12 @@ export function applySdtContainerStyling( container.dataset.sdtContainerEnd = String(isEnd); container.style.overflow = 'visible'; // Allow label to show above + if (isStructuredContentMetadata(sdt)) { + container.dataset.lockMode = sdt.lockMode || 'unlocked'; + } else if (isStructuredContentMetadata(containerSdt)) { + container.dataset.lockMode = containerSdt.lockMode || 'unlocked'; + } + if (boundaryOptions?.widthOverride != null) { container.style.width = `${boundaryOptions.widthOverride}px`; } @@ -271,3 +280,25 @@ export function applySdtContainerStyling( container.appendChild(labelEl); } } + +/** + * Checks whether a fragment element needs rebuilding due to SDT boundary changes. + * + * Handles two cases: + * 1. Element was in an SDT but no longer is (stale attributes need removal) + * 2. Element's start/end boundary flags don't match expected values + */ +export function shouldRebuildForSdtBoundary(element: HTMLElement, boundary: SdtBoundaryOptions | undefined): boolean { + if (!boundary) { + // Rebuild if element has stale SDT container attributes that should be removed + return element.dataset.sdtContainerStart !== undefined; + } + const startAttr = element.dataset.sdtContainerStart; + const endAttr = element.dataset.sdtContainerEnd; + const expectedStart = String(boundary.isStart ?? true); + const expectedEnd = String(boundary.isEnd ?? true); + if (startAttr === undefined || endAttr === undefined) { + return true; + } + return startAttr !== expectedStart || endAttr !== expectedEnd; +} diff --git a/packages/layout-engine/painters/dom/src/utils/sdt-hover.ts b/packages/layout-engine/painters/dom/src/utils/sdt-hover.ts new file mode 100644 index 0000000000..ce758ca047 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/utils/sdt-hover.ts @@ -0,0 +1,79 @@ +/** + * Grouped hover for multi-fragment SDT blocks. + * + * When a block SDT spans multiple paragraphs, each renders as a separate DOM element. + * This class uses event delegation to highlight ALL fragments of the same SDT + * simultaneously via the `.sdt-hover` CSS class. + */ + +import { DOM_CLASS_NAMES } from '../constants.js'; + +const SDT_BLOCK_SELECTOR = `.${DOM_CLASS_NAMES.BLOCK_SDT}[data-sdt-id]`; +const HOVER_CLASS = DOM_CLASS_NAMES.SDT_HOVER; + +function sdtElementsById(root: HTMLElement, sdtId: string): NodeListOf { + return root.querySelectorAll(`.${DOM_CLASS_NAMES.BLOCK_SDT}[data-sdt-id="${sdtId}"]`); +} + +export class SdtGroupedHover { + private hoveredSdtId: string | null = null; + private mount: HTMLElement | null = null; + private onMouseOver: ((e: Event) => void) | null = null; + private onMouseLeave: (() => void) | null = null; + + /** Attach hover listeners to the mount element. Safe to call again on remount. */ + bind(mount: HTMLElement): void { + this.destroy(); + this.mount = mount; + + this.onMouseOver = (e: Event) => { + const target = (e.target as HTMLElement).closest?.(SDT_BLOCK_SELECTOR) as HTMLElement | null; + const sdtId = target?.dataset.sdtId ?? null; + + if (sdtId === this.hoveredSdtId) return; + + if (this.hoveredSdtId) { + sdtElementsById(mount, this.hoveredSdtId).forEach((el) => el.classList.remove(HOVER_CLASS)); + } + + this.hoveredSdtId = sdtId; + + if (sdtId) { + sdtElementsById(mount, sdtId).forEach((el) => el.classList.add(HOVER_CLASS)); + } + }; + + this.onMouseLeave = () => { + if (this.hoveredSdtId) { + sdtElementsById(mount, this.hoveredSdtId).forEach((el) => el.classList.remove(HOVER_CLASS)); + this.hoveredSdtId = null; + } + }; + + mount.addEventListener('mouseover', this.onMouseOver); + mount.addEventListener('mouseleave', this.onMouseLeave); + } + + /** Re-apply hover class after render. New/rebuilt elements lose the class. */ + reapply(): void { + if (this.hoveredSdtId && this.mount) { + sdtElementsById(this.mount, this.hoveredSdtId).forEach((el) => el.classList.add(HOVER_CLASS)); + } + } + + /** Remove listeners and reset state. */ + destroy(): void { + if (this.mount) { + if (this.onMouseOver) { + this.mount.removeEventListener('mouseover', this.onMouseOver); + } + if (this.onMouseLeave) { + this.mount.removeEventListener('mouseleave', this.onMouseLeave); + } + } + this.mount = null; + this.onMouseOver = null; + this.onMouseLeave = null; + this.hoveredSdtId = null; + } +} diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.ts index 357aa4788f..dfde920094 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.ts @@ -18,6 +18,7 @@ export function tabNodeToRun({ tabOrdinal, paragraphAttrs, inheritedMarks, + sdtMetadata, }: InlineConverterParams): Run | null { const pos = positions.get(node); if (!pos) return null; @@ -34,6 +35,10 @@ export function tabNodeToRun({ leader: (node.attrs?.leader as TabRun['leader']) ?? null, }; + if (sdtMetadata) { + run.sdt = sdtMetadata; + } + // Apply marks (e.g., underline) to the tab run const marks = [...(node.marks ?? []), ...(inheritedMarks ?? [])]; if (marks.length > 0) { diff --git a/packages/layout-engine/style-engine/src/index.ts b/packages/layout-engine/style-engine/src/index.ts index 7a801ff319..419d2424ba 100644 --- a/packages/layout-engine/style-engine/src/index.ts +++ b/packages/layout-engine/style-engine/src/index.ts @@ -247,6 +247,7 @@ function normalizeStructuredContentMetadata( id: toNullableString(attrs.id), tag: toOptionalString(attrs.tag), alias: toOptionalString(attrs.alias), + lockMode: attrs.lockMode as StructuredContentMetadata['lockMode'], sdtPr: attrs.sdtPr, }; } diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.js index 79dac4cc1b..1e233dbd64 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.js @@ -19,6 +19,12 @@ export function handleStructuredContentNode(params) { const tag = sdtPr?.elements?.find((el) => el.name === 'w:tag'); const alias = sdtPr?.elements?.find((el) => el.name === 'w:alias'); + // Get the lock tag and value + const lockTag = sdtPr?.elements?.find((el) => el.name === 'w:lock'); + const lockValue = lockTag?.attributes?.['w:val']; + const validModes = ['unlocked', 'sdtLocked', 'contentLocked', 'sdtContentLocked']; + const lockMode = validModes.includes(lockValue) ? lockValue : 'unlocked'; + if (!sdtContent) { return null; } @@ -43,6 +49,7 @@ export function handleStructuredContentNode(params) { id: id?.attributes?.['w:val'] || null, tag: tag?.attributes?.['w:val'] || null, alias: alias?.attributes?.['w:val'] || null, + lockMode, sdtPr, }, }; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.test.js index 855eaba735..ebae9ef5a5 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.test.js @@ -126,4 +126,102 @@ describe('handleStructuredContentNode', () => { expect(result.attrs.sdtPr).toEqual(sdtPr); }); + + describe('w:lock parsing', () => { + it('parses sdtLocked lock mode', () => { + const sdtPrElements = [{ name: 'w:lock', attributes: { 'w:val': 'sdtLocked' } }]; + const node = createNode(sdtPrElements, [{ name: 'w:r', text: 'content' }]); + + const params = { + nodes: [node], + nodeListHandler: mockNodeListHandler, + }; + + parseAnnotationMarks.mockReturnValue({ marks: [] }); + + const result = handleStructuredContentNode(params); + + expect(result.attrs.lockMode).toBe('sdtLocked'); + }); + + it('parses contentLocked lock mode', () => { + const sdtPrElements = [{ name: 'w:lock', attributes: { 'w:val': 'contentLocked' } }]; + const node = createNode(sdtPrElements, [{ name: 'w:r', text: 'content' }]); + + const params = { + nodes: [node], + nodeListHandler: mockNodeListHandler, + }; + + parseAnnotationMarks.mockReturnValue({ marks: [] }); + + const result = handleStructuredContentNode(params); + + expect(result.attrs.lockMode).toBe('contentLocked'); + }); + + it('parses sdtContentLocked lock mode', () => { + const sdtPrElements = [{ name: 'w:lock', attributes: { 'w:val': 'sdtContentLocked' } }]; + const node = createNode(sdtPrElements, [{ name: 'w:r', text: 'content' }]); + + const params = { + nodes: [node], + nodeListHandler: mockNodeListHandler, + }; + + parseAnnotationMarks.mockReturnValue({ marks: [] }); + + const result = handleStructuredContentNode(params); + + expect(result.attrs.lockMode).toBe('sdtContentLocked'); + }); + + it('defaults to unlocked when w:lock element is missing', () => { + const sdtPrElements = [{ name: 'w:tag', attributes: { 'w:val': 'test' } }]; + const node = createNode(sdtPrElements, [{ name: 'w:r', text: 'content' }]); + + const params = { + nodes: [node], + nodeListHandler: mockNodeListHandler, + }; + + parseAnnotationMarks.mockReturnValue({ marks: [] }); + + const result = handleStructuredContentNode(params); + + expect(result.attrs.lockMode).toBe('unlocked'); + }); + + it('defaults to unlocked for invalid lock mode values', () => { + const sdtPrElements = [{ name: 'w:lock', attributes: { 'w:val': 'invalidMode' } }]; + const node = createNode(sdtPrElements, [{ name: 'w:r', text: 'content' }]); + + const params = { + nodes: [node], + nodeListHandler: mockNodeListHandler, + }; + + parseAnnotationMarks.mockReturnValue({ marks: [] }); + + const result = handleStructuredContentNode(params); + + expect(result.attrs.lockMode).toBe('unlocked'); + }); + + it('parses unlocked lock mode explicitly', () => { + const sdtPrElements = [{ name: 'w:lock', attributes: { 'w:val': 'unlocked' } }]; + const node = createNode(sdtPrElements, [{ name: 'w:r', text: 'content' }]); + + const params = { + nodes: [node], + nodeListHandler: mockNodeListHandler, + }; + + parseAnnotationMarks.mockReturnValue({ marks: [] }); + + const result = handleStructuredContentNode(params); + + expect(result.attrs.lockMode).toBe('unlocked'); + }); + }); }); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-structured-content.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-structured-content.js index 4740426c75..0363b91054 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-structured-content.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-structured-content.js @@ -56,15 +56,21 @@ function generateSdtPrTagForStructuredContent({ node }) { type: 'element', attributes: { 'w:val': attrs.tag }, }; + const lock = { + name: 'w:lock', + type: 'element', + attributes: { 'w:val': attrs.lockMode }, + }; const resultElements = []; if (attrs.id) resultElements.push(id); if (attrs.alias) resultElements.push(alias); if (attrs.tag) resultElements.push(tag); + if (attrs.lockMode && attrs.lockMode !== 'unlocked') resultElements.push(lock); if (attrs.sdtPr) { const elements = attrs.sdtPr.elements || []; - const elementsToExclude = ['w:id', 'w:alias', 'w:tag']; + const elementsToExclude = ['w:id', 'w:alias', 'w:tag', 'w:lock']; const restElements = elements.filter((el) => !elementsToExclude.includes(el.name)); const result = { name: 'w:sdtPr', diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-structured-content.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-structured-content.test.js index f2bb30ca9f..5edc18de1a 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-structured-content.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-structured-content.test.js @@ -95,4 +95,108 @@ describe('translateStructuredContent', () => { expect(translateChildNodes).toHaveBeenCalledWith({ ...params, node }); expect(result).toEqual(childElements[0]); }); + + describe('w:lock export', () => { + it('exports w:lock element for sdtLocked mode', () => { + const node = { + content: [{ type: 'text', text: 'Test' }], + attrs: { id: '123', lockMode: 'sdtLocked' }, + }; + const params = { node }; + + const result = translateStructuredContent(params); + + const sdtPr = result.elements.find((el) => el.name === 'w:sdtPr'); + const lockElement = sdtPr.elements.find((el) => el.name === 'w:lock'); + + expect(lockElement).toBeDefined(); + expect(lockElement.attributes['w:val']).toBe('sdtLocked'); + }); + + it('exports w:lock element for contentLocked mode', () => { + const node = { + content: [{ type: 'text', text: 'Test' }], + attrs: { id: '123', lockMode: 'contentLocked' }, + }; + const params = { node }; + + const result = translateStructuredContent(params); + + const sdtPr = result.elements.find((el) => el.name === 'w:sdtPr'); + const lockElement = sdtPr.elements.find((el) => el.name === 'w:lock'); + + expect(lockElement).toBeDefined(); + expect(lockElement.attributes['w:val']).toBe('contentLocked'); + }); + + it('exports w:lock element for sdtContentLocked mode', () => { + const node = { + content: [{ type: 'text', text: 'Test' }], + attrs: { id: '123', lockMode: 'sdtContentLocked' }, + }; + const params = { node }; + + const result = translateStructuredContent(params); + + const sdtPr = result.elements.find((el) => el.name === 'w:sdtPr'); + const lockElement = sdtPr.elements.find((el) => el.name === 'w:lock'); + + expect(lockElement).toBeDefined(); + expect(lockElement.attributes['w:val']).toBe('sdtContentLocked'); + }); + + it('does not export w:lock element for unlocked mode', () => { + const node = { + content: [{ type: 'text', text: 'Test' }], + attrs: { id: '123', lockMode: 'unlocked' }, + }; + const params = { node }; + + const result = translateStructuredContent(params); + + const sdtPr = result.elements.find((el) => el.name === 'w:sdtPr'); + const lockElement = sdtPr.elements.find((el) => el.name === 'w:lock'); + + expect(lockElement).toBeUndefined(); + }); + + it('does not export w:lock element when lockMode is not set', () => { + const node = { + content: [{ type: 'text', text: 'Test' }], + attrs: { id: '123' }, + }; + const params = { node }; + + const result = translateStructuredContent(params); + + const sdtPr = result.elements.find((el) => el.name === 'w:sdtPr'); + const lockElement = sdtPr.elements.find((el) => el.name === 'w:lock'); + + expect(lockElement).toBeUndefined(); + }); + + it('excludes w:lock from passthrough sdtPr elements to avoid duplication', () => { + const originalSdtPr = { + name: 'w:sdtPr', + elements: [ + { name: 'w:lock', attributes: { 'w:val': 'contentLocked' } }, + { name: 'w:placeholder', elements: [] }, + ], + }; + const node = { + content: [{ type: 'text', text: 'Test' }], + attrs: { id: '123', lockMode: 'sdtContentLocked', sdtPr: originalSdtPr }, + }; + const params = { node }; + + const result = translateStructuredContent(params); + + const sdtPr = result.elements.find((el) => el.name === 'w:sdtPr'); + const lockElements = sdtPr.elements.filter((el) => el.name === 'w:lock'); + + // Should only have one w:lock element with the new value + expect(lockElements.length).toBe(1); + expect(lockElements[0].attributes['w:val']).toBe('sdtContentLocked'); + }); + }); }); diff --git a/packages/super-editor/src/extensions/structured-content/StructuredContentBlockView.js b/packages/super-editor/src/extensions/structured-content/StructuredContentBlockView.js index 0af6e3f3ca..39adb2f18a 100644 --- a/packages/super-editor/src/extensions/structured-content/StructuredContentBlockView.js +++ b/packages/super-editor/src/extensions/structured-content/StructuredContentBlockView.js @@ -39,11 +39,13 @@ export class StructuredContentBlockView extends StructuredContentViewBase { element.prepend(dragHandle); element.addEventListener('dragstart', (e) => this.onDragStart(e)); this.root = element; + this.updateContentEditability(); } updateView() { const domAttrs = Attribute.mergeAttributes(this.htmlAttributes); updateDOMAttributes(this.dom, { ...domAttrs }); + this.updateContentEditability(); } update(node, decorations, innerDecorations) { diff --git a/packages/super-editor/src/extensions/structured-content/StructuredContentInlineView.js b/packages/super-editor/src/extensions/structured-content/StructuredContentInlineView.js index 1f647d5565..d58e4a7b3b 100644 --- a/packages/super-editor/src/extensions/structured-content/StructuredContentInlineView.js +++ b/packages/super-editor/src/extensions/structured-content/StructuredContentInlineView.js @@ -39,11 +39,13 @@ export class StructuredContentInlineView extends StructuredContentViewBase { element.prepend(dragHandle); element.addEventListener('dragstart', (e) => this.onDragStart(e)); this.root = element; + this.updateContentEditability(); } updateView() { const domAttrs = Attribute.mergeAttributes(this.htmlAttributes); updateDOMAttributes(this.dom, { ...domAttrs }); + this.updateContentEditability(); } update(node, decorations, innerDecorations) { diff --git a/packages/super-editor/src/extensions/structured-content/StructuredContentViewBase.js b/packages/super-editor/src/extensions/structured-content/StructuredContentViewBase.js index cd7a0f1b6d..708b933daf 100644 --- a/packages/super-editor/src/extensions/structured-content/StructuredContentViewBase.js +++ b/packages/super-editor/src/extensions/structured-content/StructuredContentViewBase.js @@ -189,6 +189,28 @@ export class StructuredContentViewBase { return dragHandle; } + isContentLocked() { + const lockMode = this.node.attrs.lockMode; + return lockMode === 'contentLocked' || lockMode === 'sdtContentLocked'; + } + + isSdtLocked() { + const lockMode = this.node.attrs.lockMode; + return lockMode === 'sdtLocked' || lockMode === 'sdtContentLocked'; + } + + updateContentEditability() { + // Note: We intentionally do NOT set contentEditable='false' for locked content. + // This allows cursor movement and selection within locked nodes. + // The lock plugin (structured-content-lock-plugin.js) handles blocking actual edits + // via handleKeyDown, handleTextInput, and filterTransaction. + // We only add CSS classes for visual feedback. + if (this.dom) { + this.dom.classList.toggle('sd-structured-content--content-locked', this.isContentLocked()); + this.dom.classList.toggle('sd-structured-content--sdt-locked', this.isSdtLocked()); + } + } + onDragStart(event) { const { view } = this.editor; const target = event.target; diff --git a/packages/super-editor/src/extensions/structured-content/structured-content-block.js b/packages/super-editor/src/extensions/structured-content/structured-content-block.js index 18ef2755dc..42ee58cbfd 100644 --- a/packages/super-editor/src/extensions/structured-content/structured-content-block.js +++ b/packages/super-editor/src/extensions/structured-content/structured-content-block.js @@ -77,6 +77,15 @@ export const StructuredContentBlock = Node.create({ }, }, + lockMode: { + default: 'unlocked', + parseDOM: (elem) => elem.getAttribute('data-lock-mode') || 'unlocked', + renderDOM: (attrs) => { + if (!attrs.lockMode || attrs.lockMode === 'unlocked') return {}; + return { 'data-lock-mode': attrs.lockMode }; + }, + }, + sdtPr: { rendered: false, }, diff --git a/packages/super-editor/src/extensions/structured-content/structured-content-lock-plugin.js b/packages/super-editor/src/extensions/structured-content/structured-content-lock-plugin.js new file mode 100644 index 0000000000..578cd6bc85 --- /dev/null +++ b/packages/super-editor/src/extensions/structured-content/structured-content-lock-plugin.js @@ -0,0 +1,184 @@ +import { Plugin, PluginKey } from 'prosemirror-state'; + +export const STRUCTURED_CONTENT_LOCK_KEY = new PluginKey('structuredContentLock'); + +/** + * Lock enforcement plugin for StructuredContent nodes. + * + * Lock modes (ECMA-376 w:lock): + * - unlocked: No restrictions + * - sdtLocked: Cannot delete the SDT wrapper (content editable) + * - contentLocked: Cannot edit content (can delete wrapper) + * - sdtContentLocked: Cannot delete wrapper OR edit content + * + * Strategy: + * 1. handleKeyDown - Intercept keys BEFORE transaction to prevent browser selection issues + * 2. filterTransaction - Safety net to catch programmatic changes + */ + +/** + * Collect all SDT nodes from the document. + */ +function collectSDTNodes(doc) { + const sdtNodes = []; + doc.descendants((node, pos) => { + if (node.type.name === 'structuredContent' || node.type.name === 'structuredContentBlock') { + sdtNodes.push({ + type: node.type.name, + lockMode: node.attrs.lockMode, + pos, + end: pos + node.nodeSize, + }); + } + }); + return sdtNodes; +} + +/** + * Check if a range [from, to] would violate any lock rules + * Returns { blocked: boolean, reason?: string } + */ +function checkLockViolation(sdtNodes, from, to) { + for (const sdt of sdtNodes) { + const overlaps = from < sdt.end && to > sdt.pos; + if (!overlaps) continue; + + // Calculate relationship + const containsSDT = from <= sdt.pos && to >= sdt.end; + const insideSDT = from >= sdt.pos && to <= sdt.end; + const crossesStart = from < sdt.pos && to > sdt.pos && to < sdt.end; + const crossesEnd = from > sdt.pos && from < sdt.end && to > sdt.end; + + const wouldDamageWrapper = containsSDT || crossesStart || crossesEnd; + // Content modification: inside SDT but NOT deleting the entire wrapper + const wouldModifyContent = insideSDT && !containsSDT; + + const isSdtLocked = sdt.lockMode === 'sdtLocked' || sdt.lockMode === 'sdtContentLocked'; + const isContentLocked = sdt.lockMode === 'contentLocked' || sdt.lockMode === 'sdtContentLocked'; + + if (isSdtLocked && wouldDamageWrapper) { + return { blocked: true, reason: `Cannot delete SDT wrapper (${sdt.lockMode})` }; + } + + if (isContentLocked && wouldModifyContent) { + return { blocked: true, reason: `Cannot modify content (${sdt.lockMode})` }; + } + } + return { blocked: false }; +} + +export function createStructuredContentLockPlugin() { + return new Plugin({ + key: STRUCTURED_CONTENT_LOCK_KEY, + + state: { + init(_, editorState) { + return collectSDTNodes(editorState.doc); + }, + apply(tr, cachedSDTNodes, _oldState, newState) { + if (!tr.docChanged) return cachedSDTNodes; + return collectSDTNodes(newState.doc); + }, + }, + + props: { + /** + * Intercept key events BEFORE any transaction is created. + * This prevents the browser selection from getting out of sync. + */ + handleKeyDown(view, event) { + const { state } = view; + const { selection } = state; + const { from, to } = selection; + + // Only intercept destructive keys + const isDelete = event.key === 'Delete'; + const isBackspace = event.key === 'Backspace'; + const isCut = (event.metaKey || event.ctrlKey) && event.key === 'x'; + + if (!isDelete && !isBackspace && !isCut) { + return false; // Let other handlers process + } + + const sdtNodes = STRUCTURED_CONTENT_LOCK_KEY.getState(state); + if (sdtNodes.length === 0) { + return false; + } + + // Calculate the range that would be affected + let affectedFrom = from; + let affectedTo = to; + + // If selection is collapsed, backspace/delete affects adjacent position. + // Note: this is a single-character approximation. joinBackward at paragraph + // boundaries can span wider ranges, but filterTransaction catches the real + // step range as a safety net (with a possible brief cursor jump). + if (from === to) { + if (isBackspace && from > 0) { + affectedFrom = from - 1; + } else if (isDelete && to < state.doc.content.size) { + affectedTo = to + 1; + } + } + + const result = checkLockViolation(sdtNodes, affectedFrom, affectedTo); + + if (result.blocked) { + event.preventDefault(); + return true; // Stop event propagation + } + + return false; + }, + + /** + * Handle text input (typing) for content-locked nodes + */ + handleTextInput(view, from, to, _text) { + const sdtNodes = STRUCTURED_CONTENT_LOCK_KEY.getState(view.state); + if (sdtNodes.length === 0) { + return false; + } + + const result = checkLockViolation(sdtNodes, from, to); + + if (result.blocked) { + return true; // Prevent the input + } + + return false; + }, + }, + + /** + * Safety net: filter transactions that slip through + * (e.g., programmatic changes, paste, drag-drop) + */ + filterTransaction(tr, state) { + if (!tr.docChanged) { + return true; + } + + const sdtNodes = STRUCTURED_CONTENT_LOCK_KEY.getState(state); + if (sdtNodes.length === 0) { + return true; + } + + for (const step of tr.steps) { + // Skip steps without from/to (AttrStep, AddNodeMarkStep, RemoveNodeMarkStep) — + // these change metadata, not content, so they can't violate lock rules. + if (step.from === undefined || step.to === undefined) { + continue; + } + + const result = checkLockViolation(sdtNodes, step.from, step.to); + + if (result.blocked) { + return false; + } + } + + return true; + }, + }); +} diff --git a/packages/super-editor/src/extensions/structured-content/structured-content-lock-plugin.test.js b/packages/super-editor/src/extensions/structured-content/structured-content-lock-plugin.test.js new file mode 100644 index 0000000000..a6752efc33 --- /dev/null +++ b/packages/super-editor/src/extensions/structured-content/structured-content-lock-plugin.test.js @@ -0,0 +1,416 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { EditorState, TextSelection } from 'prosemirror-state'; +import { initTestEditor } from '@tests/helpers/helpers.js'; + +/** + * Test suite for StructuredContentLockPlugin + * + * Tests ECMA-376 w:lock behavior for StructuredContent nodes: + * - unlocked: No restrictions (can delete wrapper, can edit content) + * - sdtLocked: Cannot delete wrapper, CAN edit content + * - contentLocked: CAN delete wrapper, cannot edit content + * - sdtContentLocked: Cannot delete wrapper, cannot edit content + */ + +// Helper to find SDT node position in document +function findSDTNode(doc, nodeType = 'structuredContent') { + let result = null; + doc.descendants((node, pos) => { + if (node.type.name === nodeType) { + result = { node, pos, end: pos + node.nodeSize }; + return false; + } + }); + return result; +} + +// Helper to check if SDT node exists in document +function sdtNodeExists(doc, nodeType = 'structuredContent') { + return findSDTNode(doc, nodeType) !== null; +} + +describe('StructuredContentLockPlugin', () => { + let editor; + let schema; + + beforeEach(() => { + ({ editor } = initTestEditor()); + ({ schema } = editor); + }); + + afterEach(() => { + editor?.destroy(); + editor = null; + schema = null; + }); + + // Factory to create document with SDT node + function createDocWithSDT(lockMode, nodeType = 'structuredContent') { + const text = schema.text('Test content'); + + if (nodeType === 'structuredContent') { + const sdt = schema.nodes.structuredContent.create({ id: 'test-123', lockMode }, text); + const paragraph = schema.nodes.paragraph.create(null, [sdt]); + return schema.nodes.doc.create(null, [paragraph]); + } + + const innerParagraph = schema.nodes.paragraph.create(null, text); + const sdt = schema.nodes.structuredContentBlock.create({ id: 'test-123', lockMode }, [innerParagraph]); + return schema.nodes.doc.create(null, [sdt]); + } + + // Factory to create doc with text before and after SDT (for boundary tests) + function createDocWithSDTAndSurroundingText(lockMode, nodeType = 'structuredContent') { + const beforeText = schema.text('Before '); + const sdtText = schema.text('SDT content'); + const afterText = schema.text(' After'); + + if (nodeType === 'structuredContent') { + const sdt = schema.nodes.structuredContent.create({ id: 'test-123', lockMode }, sdtText); + const paragraph = schema.nodes.paragraph.create(null, [beforeText, sdt, afterText]); + return schema.nodes.doc.create(null, [paragraph]); + } + + const beforePara = schema.nodes.paragraph.create(null, beforeText); + const innerPara = schema.nodes.paragraph.create(null, sdtText); + const sdt = schema.nodes.structuredContentBlock.create({ id: 'test-123', lockMode }, [innerPara]); + const afterPara = schema.nodes.paragraph.create(null, afterText); + return schema.nodes.doc.create(null, [beforePara, sdt, afterPara]); + } + + // Apply document to editor and return state + function applyDocToEditor(doc) { + const state = EditorState.create({ schema, doc, plugins: editor.state.plugins }); + editor.setState(state); + return state; + } + + describe('wrapper deletion (sdtLocked behavior)', () => { + const wrapperDeletionCases = [ + // [lockMode, nodeType, shouldBlock, description] + ['unlocked', 'structuredContent', false, 'allows deletion of unlocked inline SDT'], + ['unlocked', 'structuredContentBlock', false, 'allows deletion of unlocked block SDT'], + ['sdtLocked', 'structuredContent', true, 'blocks deletion of sdtLocked inline SDT'], + ['sdtLocked', 'structuredContentBlock', true, 'blocks deletion of sdtLocked block SDT'], + ['contentLocked', 'structuredContent', false, 'allows deletion of contentLocked inline SDT'], + ['contentLocked', 'structuredContentBlock', false, 'allows deletion of contentLocked block SDT'], + ['sdtContentLocked', 'structuredContent', true, 'blocks deletion of sdtContentLocked inline SDT'], + ['sdtContentLocked', 'structuredContentBlock', true, 'blocks deletion of sdtContentLocked block SDT'], + ]; + + it.each(wrapperDeletionCases)('%s %s: %s', (lockMode, nodeType, shouldBlock) => { + // Arrange + const doc = createDocWithSDT(lockMode, nodeType); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, nodeType); + expect(sdtInfo).not.toBeNull(); + + // Act: attempt to delete the entire SDT node + const tr = state.tr.delete(sdtInfo.pos, sdtInfo.end); + const newState = state.apply(tr); + + // Assert + const sdtStillExists = sdtNodeExists(newState.doc, nodeType); + expect(sdtStillExists).toBe(shouldBlock); + }); + }); + + describe('content modification (contentLocked behavior)', () => { + const contentModificationCases = [ + // [lockMode, nodeType, shouldBlock, description] + ['unlocked', 'structuredContent', false, 'allows content modification in unlocked inline SDT'], + ['unlocked', 'structuredContentBlock', false, 'allows content modification in unlocked block SDT'], + ['sdtLocked', 'structuredContent', false, 'allows content modification in sdtLocked inline SDT'], + ['sdtLocked', 'structuredContentBlock', false, 'allows content modification in sdtLocked block SDT'], + ['contentLocked', 'structuredContent', true, 'blocks content modification in contentLocked inline SDT'], + ['contentLocked', 'structuredContentBlock', true, 'blocks content modification in contentLocked block SDT'], + ['sdtContentLocked', 'structuredContent', true, 'blocks content modification in sdtContentLocked inline SDT'], + ['sdtContentLocked', 'structuredContentBlock', true, 'blocks content modification in sdtContentLocked block SDT'], + ]; + + it.each(contentModificationCases)('%s %s: %s', (lockMode, nodeType, shouldBlock) => { + // Arrange + const doc = createDocWithSDT(lockMode, nodeType); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, nodeType); + expect(sdtInfo).not.toBeNull(); + + // Calculate position inside the SDT content + const contentStart = sdtInfo.pos + 1; // +1 to enter the node + const contentEnd = sdtInfo.end - 1; // -1 to stay inside + + // Act: attempt to delete content inside SDT + const tr = state.tr.delete(contentStart, contentEnd); + const newState = state.apply(tr); + + // Assert: check if content was modified + const originalContent = state.doc.textContent; + const newContent = newState.doc.textContent; + const contentWasModified = originalContent !== newContent; + + expect(contentWasModified).toBe(!shouldBlock); + }); + }); + + describe('boundary crossing (protects SDT structure)', () => { + const boundaryCrossingCases = [ + // [lockMode, crossType, shouldBlock, description] + ['sdtLocked', 'crossesStart', true, 'blocks deletion that crosses into sdtLocked SDT from before'], + ['sdtLocked', 'crossesEnd', true, 'blocks deletion that crosses out of sdtLocked SDT'], + ['sdtContentLocked', 'crossesStart', true, 'blocks deletion that crosses into sdtContentLocked SDT from before'], + ['sdtContentLocked', 'crossesEnd', true, 'blocks deletion that crosses out of sdtContentLocked SDT'], + [ + 'contentLocked', + 'crossesStart', + false, + 'allows deletion that crosses into contentLocked SDT (wrapper deletable)', + ], + [ + 'contentLocked', + 'crossesEnd', + false, + 'allows deletion that crosses out of contentLocked SDT (wrapper deletable)', + ], + ['unlocked', 'crossesStart', false, 'allows deletion that crosses into unlocked SDT'], + ['unlocked', 'crossesEnd', false, 'allows deletion that crosses out of unlocked SDT'], + ]; + + it.each(boundaryCrossingCases)('%s %s: %s', (lockMode, crossType, shouldBlock) => { + // Arrange + const doc = createDocWithSDTAndSurroundingText(lockMode, 'structuredContent'); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContent'); + expect(sdtInfo).not.toBeNull(); + + // Act: create deletion that crosses SDT boundary + let deleteFrom, deleteTo; + if (crossType === 'crossesStart') { + // Delete from before SDT into SDT content + deleteFrom = Math.max(0, sdtInfo.pos - 3); + deleteTo = sdtInfo.pos + 3; + } else { + // Delete from inside SDT to after SDT + deleteFrom = sdtInfo.end - 3; + deleteTo = Math.min(state.doc.content.size, sdtInfo.end + 3); + } + + const tr = state.tr.delete(deleteFrom, deleteTo); + const newState = state.apply(tr); + + // Assert: check if SDT still exists (boundary crossing damages wrapper) + const sdtStillIntact = sdtNodeExists(newState.doc, 'structuredContent'); + const contentUnchanged = state.doc.textContent === newState.doc.textContent; + + if (shouldBlock) { + // Transaction should be blocked - document unchanged + expect(contentUnchanged).toBe(true); + } else { + // Transaction should proceed - something changed + expect(contentUnchanged).toBe(false); + } + }); + }); + + describe('insertion operations', () => { + it('allows text insertion in unlocked SDT', () => { + // Arrange + const doc = createDocWithSDT('unlocked', 'structuredContent'); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContent'); + const insertPos = sdtInfo.pos + 2; + + // Act + const tr = state.tr.insertText('NEW', insertPos); + const newState = state.apply(tr); + + // Assert + expect(newState.doc.textContent).toContain('NEW'); + }); + + it('allows text insertion in sdtLocked SDT (content is editable)', () => { + // Arrange + const doc = createDocWithSDT('sdtLocked', 'structuredContent'); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContent'); + const insertPos = sdtInfo.pos + 2; + + // Act + const tr = state.tr.insertText('NEW', insertPos); + const newState = state.apply(tr); + + // Assert + expect(newState.doc.textContent).toContain('NEW'); + }); + + it('blocks text insertion in contentLocked SDT', () => { + // Arrange + const doc = createDocWithSDT('contentLocked', 'structuredContent'); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContent'); + const insertPos = sdtInfo.pos + 2; + const originalContent = state.doc.textContent; + + // Act + const tr = state.tr.insertText('NEW', insertPos); + const newState = state.apply(tr); + + // Assert: content should be unchanged + expect(newState.doc.textContent).toBe(originalContent); + }); + + it('blocks text insertion in sdtContentLocked SDT', () => { + // Arrange + const doc = createDocWithSDT('sdtContentLocked', 'structuredContent'); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContent'); + const insertPos = sdtInfo.pos + 2; + const originalContent = state.doc.textContent; + + // Act + const tr = state.tr.insertText('NEW', insertPos); + const newState = state.apply(tr); + + // Assert: content should be unchanged + expect(newState.doc.textContent).toBe(originalContent); + }); + }); + + describe('multiple SDT nodes', () => { + function createDocWithMultipleSDTs() { + const text1 = schema.text('Unlocked text'); + const text2 = schema.text('Locked text'); + const sdt1 = schema.nodes.structuredContent.create({ id: 'sdt-1', lockMode: 'unlocked' }, text1); + const sdt2 = schema.nodes.structuredContent.create({ id: 'sdt-2', lockMode: 'sdtLocked' }, text2); + const space = schema.text(' '); + const paragraph = schema.nodes.paragraph.create(null, [sdt1, space, sdt2]); + return schema.nodes.doc.create(null, [paragraph]); + } + + it('allows deletion of unlocked SDT while preserving locked SDT in same document', () => { + // Arrange + const doc = createDocWithMultipleSDTs(); + const state = applyDocToEditor(doc); + + // Find the unlocked SDT (first one) + let unlockedSDT = null; + state.doc.descendants((node, pos) => { + if (node.type.name === 'structuredContent' && node.attrs.lockMode === 'unlocked') { + unlockedSDT = { pos, end: pos + node.nodeSize }; + return false; + } + }); + expect(unlockedSDT).not.toBeNull(); + + // Act: delete the unlocked SDT + const tr = state.tr.delete(unlockedSDT.pos, unlockedSDT.end); + const newState = state.apply(tr); + + // Assert: unlocked SDT deleted, locked SDT preserved + expect(newState.doc.textContent).not.toContain('Unlocked text'); + expect(newState.doc.textContent).toContain('Locked text'); + }); + + it('blocks deletion that would affect locked SDT even when unlocked SDT is also selected', () => { + // Arrange + const doc = createDocWithMultipleSDTs(); + const state = applyDocToEditor(doc); + + // Find both SDTs + const sdts = []; + state.doc.descendants((node, pos) => { + if (node.type.name === 'structuredContent') { + sdts.push({ pos, end: pos + node.nodeSize, lockMode: node.attrs.lockMode }); + } + }); + expect(sdts.length).toBe(2); + + // Act: try to delete everything (both SDTs) + const deleteFrom = sdts[0].pos; + const deleteTo = sdts[1].end; + const tr = state.tr.delete(deleteFrom, deleteTo); + const newState = state.apply(tr); + + // Assert: locked SDT should still exist + expect(newState.doc.textContent).toContain('Locked text'); + }); + }); + + describe('edge cases', () => { + it('allows transaction when document has no SDT nodes', () => { + // Arrange: create doc without SDT + const text = schema.text('Regular paragraph'); + const paragraph = schema.nodes.paragraph.create(null, [text]); + const doc = schema.nodes.doc.create(null, [paragraph]); + const state = applyDocToEditor(doc); + + // Act + const tr = state.tr.delete(2, 5); + const newState = state.apply(tr); + + // Assert: deletion should proceed + expect(newState.doc.textContent).not.toBe(state.doc.textContent); + }); + + it('allows non-document-changing transactions', () => { + // Arrange + const doc = createDocWithSDT('sdtContentLocked', 'structuredContent'); + const state = applyDocToEditor(doc); + + // Act: create selection-only transaction + const tr = state.tr.setSelection(TextSelection.create(state.doc, 1)); + const newState = state.apply(tr); + + // Assert: should not throw, selection should change + expect(newState.selection.from).toBe(1); + }); + + it('handles deletion at document boundaries gracefully', () => { + // Arrange + const doc = createDocWithSDT('unlocked', 'structuredContent'); + const state = applyDocToEditor(doc); + + // Act: delete from start of document + const tr = state.tr.delete(0, 2); + const newState = state.apply(tr); + + // Assert: should handle gracefully (exact behavior depends on schema) + expect(newState).toBeDefined(); + }); + }); + + describe('lock mode attribute validation', () => { + it('treats missing lockMode as unlocked', () => { + // Arrange: create SDT without explicit lockMode (defaults to unlocked) + const text = schema.text('Default lock'); + const sdt = schema.nodes.structuredContent.create({ id: 'test-123' }, text); + const paragraph = schema.nodes.paragraph.create(null, [sdt]); + const doc = schema.nodes.doc.create(null, [paragraph]); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContent'); + + // Act: attempt to delete + const tr = state.tr.delete(sdtInfo.pos, sdtInfo.end); + const newState = state.apply(tr); + + // Assert: should be deletable (unlocked behavior) + expect(sdtNodeExists(newState.doc, 'structuredContent')).toBe(false); + }); + + it('treats invalid lockMode as unlocked', () => { + // Arrange: create SDT with invalid lockMode + const text = schema.text('Invalid lock'); + const sdt = schema.nodes.structuredContent.create({ id: 'test-123', lockMode: 'invalidMode' }, text); + const paragraph = schema.nodes.paragraph.create(null, [sdt]); + const doc = schema.nodes.doc.create(null, [paragraph]); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContent'); + + // Act: attempt to delete + const tr = state.tr.delete(sdtInfo.pos, sdtInfo.end); + const newState = state.apply(tr); + + // Assert: should be deletable (treated as unlocked) + expect(sdtNodeExists(newState.doc, 'structuredContent')).toBe(false); + }); + }); +}); diff --git a/packages/super-editor/src/extensions/structured-content/structured-content-select-plugin.js b/packages/super-editor/src/extensions/structured-content/structured-content-select-plugin.js new file mode 100644 index 0000000000..fa6fa1eeec --- /dev/null +++ b/packages/super-editor/src/extensions/structured-content/structured-content-select-plugin.js @@ -0,0 +1,58 @@ +import { Plugin, TextSelection } from 'prosemirror-state'; + +/** + * Select-all-on-click plugin for inline StructuredContent nodes. + * + * When a click places a collapsed cursor inside an inline SDT and the previous + * selection was outside that SDT, the entire SDT content is selected. This + * matches Word's content control behavior: first click selects all for easy + * replacement, second click (cursor already inside) allows normal positioning. + * + * Uses appendTransaction so it works in both editing mode (PM DOM clicks) and + * presentation mode (PresentationEditor dispatched selections). + */ +export function createStructuredContentSelectPlugin() { + return new Plugin({ + appendTransaction(transactions, oldState, newState) { + const { selection } = newState; + + // Only for collapsed selections (cursor placement, not range selections) + if (!selection.empty) return null; + + // Only when selection actually changed + if (oldState.selection.eq(newState.selection)) return null; + + // Only for selection-only transactions (no doc changes — filters out + // typing, paste, etc. that also move the cursor) + if (transactions.some((tr) => tr.docChanged)) return null; + + const $pos = selection.$from; + + // Walk up to find an enclosing inline structuredContent node + for (let d = $pos.depth; d > 0; d--) { + const node = $pos.node(d); + if (node.type.name === 'structuredContent') { + const sdtStart = $pos.before(d); + const contentFrom = $pos.start(d); + const contentTo = $pos.end(d); + + // Don't select empty content + if (contentFrom === contentTo) return null; + + // If old selection was already inside this same SDT, allow normal + // cursor placement (second click / arrow navigation within SDT) + const old$pos = oldState.selection.$from; + for (let od = old$pos.depth; od > 0; od--) { + if (old$pos.node(od).type.name === 'structuredContent' && old$pos.before(od) === sdtStart) { + return null; + } + } + + return newState.tr.setSelection(TextSelection.create(newState.doc, contentFrom, contentTo)); + } + } + + return null; + }, + }); +} diff --git a/packages/super-editor/src/extensions/structured-content/structured-content.js b/packages/super-editor/src/extensions/structured-content/structured-content.js index 355663a570..eaa495d6eb 100644 --- a/packages/super-editor/src/extensions/structured-content/structured-content.js +++ b/packages/super-editor/src/extensions/structured-content/structured-content.js @@ -1,5 +1,7 @@ import { Node, Attribute } from '@core/index'; import { StructuredContentInlineView } from './StructuredContentInlineView'; +import { createStructuredContentLockPlugin } from './structured-content-lock-plugin'; +import { createStructuredContentSelectPlugin } from './structured-content-select-plugin'; export const structuredContentClass = 'sd-structured-content'; export const structuredContentInnerClass = 'sd-structured-content__content'; @@ -84,6 +86,15 @@ export const StructuredContent = Node.create({ }, }, + lockMode: { + default: 'unlocked', + parseDOM: (elem) => elem.getAttribute('data-lock-mode') || 'unlocked', + renderDOM: (attrs) => { + if (!attrs.lockMode || attrs.lockMode === 'unlocked') return {}; + return { 'data-lock-mode': attrs.lockMode }; + }, + }, + sdtPr: { rendered: false, }, @@ -104,6 +115,10 @@ export const StructuredContent = Node.create({ ]; }, + addPmPlugins() { + return [createStructuredContentLockPlugin(), createStructuredContentSelectPlugin()]; + }, + addNodeView() { return (props) => { return new StructuredContentInlineView({ ...props }); diff --git a/packages/super-editor/src/extensions/types/node-attributes.ts b/packages/super-editor/src/extensions/types/node-attributes.ts index dbb95aa8fc..1261f081fc 100644 --- a/packages/super-editor/src/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/extensions/types/node-attributes.ts @@ -14,6 +14,7 @@ import type { InlineNodeAttributes, ShapeNodeAttributes, } from '../../core/types/NodeCategories.js'; +import type { StructuredContentLockMode } from '@superdoc/contracts'; // ============================================ // SHARED TYPES @@ -593,6 +594,8 @@ export interface HardBreakAttrs extends InlineNodeAttributes { // STRUCTURED CONTENT // ============================================ +export type { StructuredContentLockMode }; + /** Structured content node attributes */ export interface StructuredContentAttrs extends BlockNodeAttributes { /** Unique identifier */ @@ -607,6 +610,8 @@ export interface StructuredContentAttrs extends BlockNodeAttributes { description?: string; /** Whether the content is locked */ isLocked?: boolean; + /** Lock mode */ + lockMode?: StructuredContentLockMode; } // ============================================