diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index eaebd6444c..701695b3aa 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; }; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 5b69abb9be..97e89fa6a2 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -5113,6 +5113,7 @@ export class DomPainter { 'sdtScope', 'sdtTag', 'sdtAlias', + 'lockMode', 'sdtSectionTitle', 'sdtSectionType', 'sdtSectionLocked', @@ -5169,6 +5170,7 @@ export class DomPainter { this.setDatasetString(el, 'sdtScope', metadata.scope); this.setDatasetString(el, 'sdtTag', metadata.tag); this.setDatasetString(el, 'sdtAlias', metadata.alias); + this.setDatasetString(el, 'lockMode', metadata.lockMode || 'unlocked'); } else if (metadata.type === 'documentSection') { this.setDatasetString(el, 'sdtSectionTitle', metadata.title); this.setDatasetString(el, 'sdtSectionType', metadata.sectionType); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 0ea2ce6e88..c14e16f5b0 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -453,6 +453,43 @@ const SDT_CONTAINER_STYLES = ` display: block; } +/* Lock mode styles for structured content - matches Word appearance exactly */ +/* Default: background color only, no border. Border appears on hover/focus */ + +/* unlocked: light mint green - fully editable and deletable */ +.superdoc-structured-content-block[data-lock-mode="unlocked"], +.superdoc-structured-content-inline[data-lock-mode="unlocked"] { + background-color: #e6f4ea; + border: 1px solid transparent; +} + +/* sdtLocked: golden yellow - SDT cannot be deleted but content can be edited */ +.superdoc-structured-content-block[data-lock-mode="sdtLocked"], +.superdoc-structured-content-inline[data-lock-mode="sdtLocked"] { + background-color: #fff3cd; + border: 1px solid transparent; +} + +/* contentLocked: light blue/lavender - content is read-only but SDT can be deleted */ +.superdoc-structured-content-block[data-lock-mode="contentLocked"], +.superdoc-structured-content-inline[data-lock-mode="contentLocked"] { + background-color: #e8f0f8; + border: 1px solid transparent; +} + +/* sdtContentLocked: light peach/salmon - fully locked */ +.superdoc-structured-content-block[data-lock-mode="sdtContentLocked"], +.superdoc-structured-content-inline[data-lock-mode="sdtContentLocked"] { + background-color: #ffe8e0; + border: 1px solid transparent; +} + +/* Show blue border on hover for all lock modes */ +.superdoc-structured-content-block[data-lock-mode]:hover, +.superdoc-structured-content-inline[data-lock-mode]:hover { + border-color: #629be7; +} + /* Viewing mode: remove structured content affordances */ .presentation-editor--viewing .superdoc-structured-content-block, .presentation-editor--viewing .superdoc-structured-content-inline { 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..867b065ffe 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`; } 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..e3c900307f --- /dev/null +++ b/packages/super-editor/src/extensions/structured-content/structured-content-lock-plugin.js @@ -0,0 +1,169 @@ +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, + + 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 = collectSDTNodes(state.doc); + 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 + 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 = collectSDTNodes(view.state.doc); + 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 = collectSDTNodes(state.doc); + if (sdtNodes.length === 0) { + return true; + } + + for (const step of tr.steps) { + 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.js b/packages/super-editor/src/extensions/structured-content/structured-content.js index 355663a570..8a33cd3422 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,6 @@ import { Node, Attribute } from '@core/index'; import { StructuredContentInlineView } from './StructuredContentInlineView'; +import { createStructuredContentLockPlugin } from './structured-content-lock-plugin'; export const structuredContentClass = 'sd-structured-content'; export const structuredContentInnerClass = 'sd-structured-content__content'; @@ -84,6 +85,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 +114,10 @@ export const StructuredContent = Node.create({ ]; }, + addPmPlugins() { + return [createStructuredContentLockPlugin()]; + }, + 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..eaa77e292a 100644 --- a/packages/super-editor/src/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/extensions/types/node-attributes.ts @@ -593,6 +593,8 @@ export interface HardBreakAttrs extends InlineNodeAttributes { // STRUCTURED CONTENT // ============================================ +export type StructuredContentLockMode = 'unlocked' | 'sdtLocked' | 'contentLocked' | 'sdtContentLocked'; + /** Structured content node attributes */ export interface StructuredContentAttrs extends BlockNodeAttributes { /** Unique identifier */ @@ -607,6 +609,8 @@ export interface StructuredContentAttrs extends BlockNodeAttributes { description?: string; /** Whether the content is locked */ isLocked?: boolean; + /** Lock mode */ + lockMode?: StructuredContentLockMode; } // ============================================