From 3e5377e7d93d6b64d9f0a3128425cc638c7650e1 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 4 Feb 2026 12:19:09 -0300 Subject: [PATCH 1/5] feat(super-editor): add w:lock support for StructuredContent nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement ECMA-376 ยง17.5.2.23 w:lock support for StructuredContent and StructuredContentBlock nodes. This enables template variables to enforce read-only behavior based on lock modes. Lock modes: - unlocked: no restrictions (default) - sdtLocked: SDT wrapper cannot be deleted, content editable - contentLocked: content read-only, SDT can be deleted - sdtContentLocked: fully locked (wrapper and content) Changes: - Add lockMode attribute to StructuredContent/Block extensions - Parse w:lock element on DOCX import - Export w:lock element on DOCX save - Add lock enforcement plugin (prevents deletion of locked SDTs) - Add NodeView methods for content editability - Add visual styling matching Word's appearance (presentation mode) - Add TypeScript types for lock modes - Add unit tests for import, export, and lock behavior --- packages/layout-engine/contracts/src/index.ts | 3 + .../painters/dom/src/renderer.ts | 2 + .../layout-engine/painters/dom/src/styles.ts | 37 +++ .../painters/dom/src/utils/sdt-helpers.ts | 17 +- .../layout-engine/style-engine/src/index.ts | 25 ++ .../helpers/handle-structured-content-node.js | 7 + .../handle-structured-content-node.test.js | 98 +++++++ .../helpers/translate-structured-content.js | 8 +- .../translate-structured-content.test.js | 104 +++++++ .../StructuredContentBlockView.js | 4 + .../StructuredContentInlineView.js | 4 + .../StructuredContentViewBase.js | 28 ++ .../structured-content-block.js | 9 + .../structured-content-lock-plugin.js | 36 +++ .../structured-content-lock-plugin.test.js | 271 ++++++++++++++++++ .../structured-content/structured-content.js | 14 + .../src/extensions/types/node-attributes.ts | 4 + 17 files changed, 666 insertions(+), 5 deletions(-) create mode 100644 packages/super-editor/src/extensions/structured-content/structured-content-lock-plugin.js create mode 100644 packages/super-editor/src/extensions/structured-content/structured-content-lock-plugin.test.js 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..d7a73d07ec 100644 --- a/packages/layout-engine/style-engine/src/index.ts +++ b/packages/layout-engine/style-engine/src/index.ts @@ -247,10 +247,35 @@ function normalizeStructuredContentMetadata( id: toNullableString(attrs.id), tag: toOptionalString(attrs.tag), alias: toOptionalString(attrs.alias), + lockMode: normalizeLockMode(attrs.lockMode), sdtPr: attrs.sdtPr, }; } +function normalizeLockMode(value: unknown): StructuredContentMetadata['lockMode'] { + if (typeof value !== 'string') return undefined; + const normalized = value.toLowerCase(); + if ( + normalized === 'unlocked' || + normalized === 'sdtlocked' || + normalized === 'contentlocked' || + normalized === 'sdtcontentlocked' + ) { + // Normalize to proper camelCase format + switch (normalized) { + case 'sdtlocked': + return 'sdtLocked'; + case 'contentlocked': + return 'contentLocked'; + case 'sdtcontentlocked': + return 'sdtContentLocked'; + default: + return 'unlocked'; + } + } + return undefined; +} + function normalizeDocumentSectionMetadata(attrs: Record): DocumentSectionMetadata { return { type: 'documentSection', 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..88fe2a2c3a 100644 --- a/packages/super-editor/src/extensions/structured-content/StructuredContentBlockView.js +++ b/packages/super-editor/src/extensions/structured-content/StructuredContentBlockView.js @@ -39,11 +39,15 @@ export class StructuredContentBlockView extends StructuredContentViewBase { element.prepend(dragHandle); element.addEventListener('dragstart', (e) => this.onDragStart(e)); this.root = element; + this.updateContentEditability(); + this.updateLockStateClasses(); } updateView() { const domAttrs = Attribute.mergeAttributes(this.htmlAttributes); updateDOMAttributes(this.dom, { ...domAttrs }); + this.updateContentEditability(); + this.updateLockStateClasses(); } 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..e9cadb85fc 100644 --- a/packages/super-editor/src/extensions/structured-content/StructuredContentInlineView.js +++ b/packages/super-editor/src/extensions/structured-content/StructuredContentInlineView.js @@ -39,11 +39,15 @@ export class StructuredContentInlineView extends StructuredContentViewBase { element.prepend(dragHandle); element.addEventListener('dragstart', (e) => this.onDragStart(e)); this.root = element; + this.updateContentEditability(); + this.updateLockStateClasses(); } updateView() { const domAttrs = Attribute.mergeAttributes(this.htmlAttributes); updateDOMAttributes(this.dom, { ...domAttrs }); + this.updateContentEditability(); + this.updateLockStateClasses(); } 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..385da03e7c 100644 --- a/packages/super-editor/src/extensions/structured-content/StructuredContentViewBase.js +++ b/packages/super-editor/src/extensions/structured-content/StructuredContentViewBase.js @@ -189,6 +189,34 @@ 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() { + if (this.contentDOM) { + this.contentDOM.setAttribute('contenteditable', this.isContentLocked() ? 'false' : 'true'); + } + } + + updateLockStateClasses() { + const lockMode = this.node.attrs.lockMode || 'unlocked'; + this.dom.classList.toggle( + 'sd-structured-content--content-locked', + lockMode === 'contentLocked' || lockMode === 'sdtContentLocked', + ); + this.dom.classList.toggle( + 'sd-structured-content--sdt-locked', + lockMode === 'sdtLocked' || lockMode === 'sdtContentLocked', + ); + } + 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..6092440ee2 --- /dev/null +++ b/packages/super-editor/src/extensions/structured-content/structured-content-lock-plugin.js @@ -0,0 +1,36 @@ +import { Plugin, PluginKey } from 'prosemirror-state'; + +export const STRUCTURED_CONTENT_LOCK_KEY = new PluginKey('structuredContentLock'); + +export function createStructuredContentLockPlugin() { + return new Plugin({ + key: STRUCTURED_CONTENT_LOCK_KEY, + + filterTransaction(tr, state) { + if (!tr.docChanged) return true; + + // Find all SDT-locked nodes in old state + const lockedPositions = []; + state.doc.descendants((node, pos) => { + if ( + (node.type.name === 'structuredContent' || node.type.name === 'structuredContentBlock') && + (node.attrs.lockMode === 'sdtLocked' || node.attrs.lockMode === 'sdtContentLocked') + ) { + lockedPositions.push({ pos, end: pos + node.nodeSize }); + } + }); + + if (lockedPositions.length === 0) return true; + + // Check if any locked node would be deleted + for (const { pos, end } of lockedPositions) { + const mappedPos = tr.mapping.mapResult(pos); + const mappedEnd = tr.mapping.mapResult(end); + if (mappedPos.deleted || mappedEnd.deleted) { + return false; // Block transaction + } + } + 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..44549e382a --- /dev/null +++ b/packages/super-editor/src/extensions/structured-content/structured-content-lock-plugin.test.js @@ -0,0 +1,271 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { EditorState } from 'prosemirror-state'; +import { initTestEditor } from '@tests/helpers/helpers.js'; + +describe('StructuredContentLockPlugin', () => { + let editor; + let schema; + + beforeEach(() => { + ({ editor } = initTestEditor()); + ({ schema } = editor); + }); + + afterEach(() => { + editor?.destroy(); + editor = null; + schema = null; + }); + + const createDocWithStructuredContent = (lockMode, type = 'structuredContent') => { + const text = schema.text('Locked content'); + let node; + let doc; + + if (type === 'structuredContent') { + node = schema.nodes.structuredContent.create({ id: '123', lockMode }, text); + const paragraph = schema.nodes.paragraph.create(null, [node]); + doc = schema.nodes.doc.create(null, [paragraph]); + } else { + const innerParagraph = schema.nodes.paragraph.create(null, text); + node = schema.nodes.structuredContentBlock.create({ id: '123', lockMode }, [innerParagraph]); + doc = schema.nodes.doc.create(null, [node]); + } + + const nextState = EditorState.create({ schema, doc, plugins: editor.state.plugins }); + editor.setState(nextState); + return node; + }; + + describe('sdtLocked mode', () => { + it('prevents deletion of sdtLocked inline structured content', () => { + createDocWithStructuredContent('sdtLocked', 'structuredContent'); + + // Find the structured content node position + let nodePos = null; + let nodeSize = null; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'structuredContent') { + nodePos = pos; + nodeSize = node.nodeSize; + return false; + } + }); + + expect(nodePos).not.toBeNull(); + + // Try to delete the node + const tr = editor.state.tr.delete(nodePos, nodePos + nodeSize); + const newState = editor.state.apply(tr); + + // The document should remain unchanged (deletion blocked) + expect(newState.doc.textContent).toBe('Locked content'); + }); + + it('prevents deletion of sdtLocked block structured content', () => { + createDocWithStructuredContent('sdtLocked', 'structuredContentBlock'); + + // Find the structured content block position + let nodePos = null; + let nodeSize = null; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'structuredContentBlock') { + nodePos = pos; + nodeSize = node.nodeSize; + return false; + } + }); + + expect(nodePos).not.toBeNull(); + + // Try to delete the node + const tr = editor.state.tr.delete(nodePos, nodePos + nodeSize); + const newState = editor.state.apply(tr); + + // The document should remain unchanged (deletion blocked) + expect(newState.doc.textContent).toBe('Locked content'); + }); + }); + + describe('sdtContentLocked mode', () => { + it('prevents deletion of sdtContentLocked inline structured content', () => { + createDocWithStructuredContent('sdtContentLocked', 'structuredContent'); + + // Find the structured content node position + let nodePos = null; + let nodeSize = null; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'structuredContent') { + nodePos = pos; + nodeSize = node.nodeSize; + return false; + } + }); + + expect(nodePos).not.toBeNull(); + + // Try to delete the node + const tr = editor.state.tr.delete(nodePos, nodePos + nodeSize); + const newState = editor.state.apply(tr); + + // The document should remain unchanged (deletion blocked) + expect(newState.doc.textContent).toBe('Locked content'); + }); + + it('prevents deletion of sdtContentLocked block structured content', () => { + createDocWithStructuredContent('sdtContentLocked', 'structuredContentBlock'); + + // Find the structured content block position + let nodePos = null; + let nodeSize = null; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'structuredContentBlock') { + nodePos = pos; + nodeSize = node.nodeSize; + return false; + } + }); + + expect(nodePos).not.toBeNull(); + + // Try to delete the node + const tr = editor.state.tr.delete(nodePos, nodePos + nodeSize); + const newState = editor.state.apply(tr); + + // The document should remain unchanged (deletion blocked) + expect(newState.doc.textContent).toBe('Locked content'); + }); + }); + + describe('contentLocked mode', () => { + it('allows deletion of contentLocked inline structured content', () => { + createDocWithStructuredContent('contentLocked', 'structuredContent'); + + // Find the structured content node position + let nodePos = null; + let nodeSize = null; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'structuredContent') { + nodePos = pos; + nodeSize = node.nodeSize; + return false; + } + }); + + expect(nodePos).not.toBeNull(); + + // Try to delete the node + const tr = editor.state.tr.delete(nodePos, nodePos + nodeSize); + const newState = editor.state.apply(tr); + + // The node should be deleted + let foundNode = false; + newState.doc.descendants((node) => { + if (node.type.name === 'structuredContent') { + foundNode = true; + return false; + } + }); + + expect(foundNode).toBe(false); + }); + + it('allows deletion of contentLocked block structured content', () => { + createDocWithStructuredContent('contentLocked', 'structuredContentBlock'); + + // Find the structured content block position + let nodePos = null; + let nodeSize = null; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'structuredContentBlock') { + nodePos = pos; + nodeSize = node.nodeSize; + return false; + } + }); + + expect(nodePos).not.toBeNull(); + + // Try to delete the node + const tr = editor.state.tr.delete(nodePos, nodePos + nodeSize); + const newState = editor.state.apply(tr); + + // The node should be deleted + let foundNode = false; + newState.doc.descendants((node) => { + if (node.type.name === 'structuredContentBlock') { + foundNode = true; + return false; + } + }); + + expect(foundNode).toBe(false); + }); + }); + + describe('unlocked mode', () => { + it('allows deletion of unlocked inline structured content', () => { + createDocWithStructuredContent('unlocked', 'structuredContent'); + + // Find the structured content node position + let nodePos = null; + let nodeSize = null; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'structuredContent') { + nodePos = pos; + nodeSize = node.nodeSize; + return false; + } + }); + + expect(nodePos).not.toBeNull(); + + // Try to delete the node + const tr = editor.state.tr.delete(nodePos, nodePos + nodeSize); + const newState = editor.state.apply(tr); + + // The node should be deleted + let foundNode = false; + newState.doc.descendants((node) => { + if (node.type.name === 'structuredContent') { + foundNode = true; + return false; + } + }); + + expect(foundNode).toBe(false); + }); + + it('allows deletion of unlocked block structured content', () => { + createDocWithStructuredContent('unlocked', 'structuredContentBlock'); + + // Find the structured content block position + let nodePos = null; + let nodeSize = null; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'structuredContentBlock') { + nodePos = pos; + nodeSize = node.nodeSize; + return false; + } + }); + + expect(nodePos).not.toBeNull(); + + // Try to delete the node + const tr = editor.state.tr.delete(nodePos, nodePos + nodeSize); + const newState = editor.state.apply(tr); + + // The node should be deleted + let foundNode = false; + newState.doc.descendants((node) => { + if (node.type.name === 'structuredContentBlock') { + foundNode = true; + return false; + } + }); + + expect(foundNode).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; } // ============================================ From d04f6c6ae1458ab97b2f56a36da9a81846ecab0a Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 4 Feb 2026 13:51:22 -0300 Subject: [PATCH 2/5] perf(super-editor): optimize lock plugin to check only changed ranges Replace state.doc.descendants() with nodesBetween() to avoid iterating the entire document on every transaction. Now only checks nodes within the affected ranges. Also simplify normalizeLockMode in style-engine since lockMode values are already validated at import time. --- .../layout-engine/style-engine/src/index.ts | 26 +------ .../structured-content-lock-plugin.js | 72 +++++++++++++------ 2 files changed, 53 insertions(+), 45 deletions(-) diff --git a/packages/layout-engine/style-engine/src/index.ts b/packages/layout-engine/style-engine/src/index.ts index d7a73d07ec..419d2424ba 100644 --- a/packages/layout-engine/style-engine/src/index.ts +++ b/packages/layout-engine/style-engine/src/index.ts @@ -247,35 +247,11 @@ function normalizeStructuredContentMetadata( id: toNullableString(attrs.id), tag: toOptionalString(attrs.tag), alias: toOptionalString(attrs.alias), - lockMode: normalizeLockMode(attrs.lockMode), + lockMode: attrs.lockMode as StructuredContentMetadata['lockMode'], sdtPr: attrs.sdtPr, }; } -function normalizeLockMode(value: unknown): StructuredContentMetadata['lockMode'] { - if (typeof value !== 'string') return undefined; - const normalized = value.toLowerCase(); - if ( - normalized === 'unlocked' || - normalized === 'sdtlocked' || - normalized === 'contentlocked' || - normalized === 'sdtcontentlocked' - ) { - // Normalize to proper camelCase format - switch (normalized) { - case 'sdtlocked': - return 'sdtLocked'; - case 'contentlocked': - return 'contentLocked'; - case 'sdtcontentlocked': - return 'sdtContentLocked'; - default: - return 'unlocked'; - } - } - return undefined; -} - function normalizeDocumentSectionMetadata(attrs: Record): DocumentSectionMetadata { return { type: 'documentSection', 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 index 6092440ee2..d69ac9e30a 100644 --- 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 @@ -2,6 +2,37 @@ import { Plugin, PluginKey } from 'prosemirror-state'; export const STRUCTURED_CONTENT_LOCK_KEY = new PluginKey('structuredContentLock'); +/** + * Collects the ranges affected by a transaction, based on the document BEFORE the change. + * @param {import('prosemirror-state').Transaction} tr + * @returns {Array<{ from: number, to: number }>} + */ +const collectChangedRanges = (tr) => { + const ranges = []; + tr.mapping.maps.forEach((map) => { + map.forEach((oldStart, oldEnd) => { + const from = Math.min(oldStart, oldEnd); + const to = Math.max(oldStart, oldEnd); + if (from !== to) { + ranges.push({ from, to }); + } + }); + }); + return ranges; +}; + +/** + * Checks if a node is a locked SDT (sdtLocked or sdtContentLocked). + * @param {import('prosemirror-model').Node} node + * @returns {boolean} + */ +const isLockedSdt = (node) => { + return ( + (node.type.name === 'structuredContent' || node.type.name === 'structuredContentBlock') && + (node.attrs.lockMode === 'sdtLocked' || node.attrs.lockMode === 'sdtContentLocked') + ); +}; + export function createStructuredContentLockPlugin() { return new Plugin({ key: STRUCTURED_CONTENT_LOCK_KEY, @@ -9,27 +40,28 @@ export function createStructuredContentLockPlugin() { filterTransaction(tr, state) { if (!tr.docChanged) return true; - // Find all SDT-locked nodes in old state - const lockedPositions = []; - state.doc.descendants((node, pos) => { - if ( - (node.type.name === 'structuredContent' || node.type.name === 'structuredContentBlock') && - (node.attrs.lockMode === 'sdtLocked' || node.attrs.lockMode === 'sdtContentLocked') - ) { - lockedPositions.push({ pos, end: pos + node.nodeSize }); - } - }); - - if (lockedPositions.length === 0) return true; - - // Check if any locked node would be deleted - for (const { pos, end } of lockedPositions) { - const mappedPos = tr.mapping.mapResult(pos); - const mappedEnd = tr.mapping.mapResult(end); - if (mappedPos.deleted || mappedEnd.deleted) { - return false; // Block transaction - } + // Get only the ranges affected by this transaction + const changedRanges = collectChangedRanges(tr); + if (changedRanges.length === 0) return true; + + // Check only nodes within the changed ranges for locked SDTs + for (const { from, to } of changedRanges) { + // Use nodesBetween to only traverse affected range + let hasLockedNode = false; + state.doc.nodesBetween(from, to, (node, pos) => { + if (isLockedSdt(node)) { + // Check if this locked node would be deleted + const mappedPos = tr.mapping.mapResult(pos); + const mappedEnd = tr.mapping.mapResult(pos + node.nodeSize); + if (mappedPos.deleted || mappedEnd.deleted) { + hasLockedNode = true; + return false; // Stop traversal + } + } + }); + if (hasLockedNode) return false; // Block transaction } + return true; }, }); From 786e5b779a6b7b40f063c1823d7924fa4de02662 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 4 Feb 2026 13:54:55 -0300 Subject: [PATCH 3/5] fix(super-editor): clamp nodesBetween range to valid document bounds --- .../structured-content/structured-content-lock-plugin.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 index d69ac9e30a..1c91b14ccb 100644 --- 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 @@ -44,11 +44,18 @@ export function createStructuredContentLockPlugin() { const changedRanges = collectChangedRanges(tr); if (changedRanges.length === 0) return true; + const docSize = state.doc.content.size; + // Check only nodes within the changed ranges for locked SDTs for (const { from, to } of changedRanges) { + // Clamp range to valid document bounds + const safeFrom = Math.max(0, Math.min(from, docSize)); + const safeTo = Math.max(0, Math.min(to, docSize)); + if (safeFrom >= safeTo) continue; + // Use nodesBetween to only traverse affected range let hasLockedNode = false; - state.doc.nodesBetween(from, to, (node, pos) => { + state.doc.nodesBetween(safeFrom, safeTo, (node, pos) => { if (isLockedSdt(node)) { // Check if this locked node would be deleted const mappedPos = tr.mapping.mapResult(pos); From 9137b9c5d305925e3b53355c5779e4b744a62e03 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 4 Feb 2026 14:15:06 -0300 Subject: [PATCH 4/5] refactor(super-editor): remove unused lock state methods and CSS class toggling Remove isSdtLocked() method that was never called - SDT deletion prevention is handled by the lock plugin instead. Remove updateLockStateClasses() and its calls - the CSS classes it toggled had no corresponding CSS rules. Presentation mode uses data-lock-mode attributes with CSS in styles.ts instead. --- .../StructuredContentBlockView.js | 2 -- .../StructuredContentInlineView.js | 2 -- .../StructuredContentViewBase.js | 17 ----------------- 3 files changed, 21 deletions(-) diff --git a/packages/super-editor/src/extensions/structured-content/StructuredContentBlockView.js b/packages/super-editor/src/extensions/structured-content/StructuredContentBlockView.js index 88fe2a2c3a..39adb2f18a 100644 --- a/packages/super-editor/src/extensions/structured-content/StructuredContentBlockView.js +++ b/packages/super-editor/src/extensions/structured-content/StructuredContentBlockView.js @@ -40,14 +40,12 @@ export class StructuredContentBlockView extends StructuredContentViewBase { element.addEventListener('dragstart', (e) => this.onDragStart(e)); this.root = element; this.updateContentEditability(); - this.updateLockStateClasses(); } updateView() { const domAttrs = Attribute.mergeAttributes(this.htmlAttributes); updateDOMAttributes(this.dom, { ...domAttrs }); this.updateContentEditability(); - this.updateLockStateClasses(); } 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 e9cadb85fc..d58e4a7b3b 100644 --- a/packages/super-editor/src/extensions/structured-content/StructuredContentInlineView.js +++ b/packages/super-editor/src/extensions/structured-content/StructuredContentInlineView.js @@ -40,14 +40,12 @@ export class StructuredContentInlineView extends StructuredContentViewBase { element.addEventListener('dragstart', (e) => this.onDragStart(e)); this.root = element; this.updateContentEditability(); - this.updateLockStateClasses(); } updateView() { const domAttrs = Attribute.mergeAttributes(this.htmlAttributes); updateDOMAttributes(this.dom, { ...domAttrs }); this.updateContentEditability(); - this.updateLockStateClasses(); } 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 385da03e7c..b67a517764 100644 --- a/packages/super-editor/src/extensions/structured-content/StructuredContentViewBase.js +++ b/packages/super-editor/src/extensions/structured-content/StructuredContentViewBase.js @@ -194,29 +194,12 @@ export class StructuredContentViewBase { return lockMode === 'contentLocked' || lockMode === 'sdtContentLocked'; } - isSdtLocked() { - const lockMode = this.node.attrs.lockMode; - return lockMode === 'sdtLocked' || lockMode === 'sdtContentLocked'; - } - updateContentEditability() { if (this.contentDOM) { this.contentDOM.setAttribute('contenteditable', this.isContentLocked() ? 'false' : 'true'); } } - updateLockStateClasses() { - const lockMode = this.node.attrs.lockMode || 'unlocked'; - this.dom.classList.toggle( - 'sd-structured-content--content-locked', - lockMode === 'contentLocked' || lockMode === 'sdtContentLocked', - ); - this.dom.classList.toggle( - 'sd-structured-content--sdt-locked', - lockMode === 'sdtLocked' || lockMode === 'sdtContentLocked', - ); - } - onDragStart(event) { const { view } = this.editor; const target = event.target; From fb4a38c0cd5de15a79148e02d556e0dcda0dba2d Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Wed, 4 Feb 2026 15:18:47 -0300 Subject: [PATCH 5/5] fix(super-editor): allow cursor movement in locked SDT content Change lock enforcement strategy to use plugin-only defense instead of contentEditable='false'. This allows users to: - Move cursor within locked content nodes - Select text for copying - Navigate smoothly through the document The lock plugin now handles all edit blocking through: - handleKeyDown: Block Delete/Backspace/Cut before transaction - handleTextInput: Block typing in content-locked nodes - filterTransaction: Safety net for paste, drag-drop, programmatic changes NodeView now only adds CSS classes for visual feedback without disabling cursor interaction. Also adds comprehensive test suite with 35 tests covering all lock modes and adds research documentation in .tupizz/docs/. --- .../StructuredContentViewBase.js | 15 +- .../structured-content-lock-plugin.js | 200 +++++-- .../structured-content-lock-plugin.test.js | 547 +++++++++++------- 3 files changed, 506 insertions(+), 256 deletions(-) diff --git a/packages/super-editor/src/extensions/structured-content/StructuredContentViewBase.js b/packages/super-editor/src/extensions/structured-content/StructuredContentViewBase.js index b67a517764..708b933daf 100644 --- a/packages/super-editor/src/extensions/structured-content/StructuredContentViewBase.js +++ b/packages/super-editor/src/extensions/structured-content/StructuredContentViewBase.js @@ -194,9 +194,20 @@ export class StructuredContentViewBase { return lockMode === 'contentLocked' || lockMode === 'sdtContentLocked'; } + isSdtLocked() { + const lockMode = this.node.attrs.lockMode; + return lockMode === 'sdtLocked' || lockMode === 'sdtContentLocked'; + } + updateContentEditability() { - if (this.contentDOM) { - this.contentDOM.setAttribute('contenteditable', this.isContentLocked() ? 'false' : 'true'); + // 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()); } } 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 index 1c91b14ccb..e3c900307f 100644 --- 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 @@ -3,70 +3,164 @@ import { Plugin, PluginKey } from 'prosemirror-state'; export const STRUCTURED_CONTENT_LOCK_KEY = new PluginKey('structuredContentLock'); /** - * Collects the ranges affected by a transaction, based on the document BEFORE the change. - * @param {import('prosemirror-state').Transaction} tr - * @returns {Array<{ from: number, to: number }>} + * 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 */ -const collectChangedRanges = (tr) => { - const ranges = []; - tr.mapping.maps.forEach((map) => { - map.forEach((oldStart, oldEnd) => { - const from = Math.min(oldStart, oldEnd); - const to = Math.max(oldStart, oldEnd); - if (from !== to) { - ranges.push({ from, to }); - } - }); + +/** + * 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 ranges; -}; + return sdtNodes; +} /** - * Checks if a node is a locked SDT (sdtLocked or sdtContentLocked). - * @param {import('prosemirror-model').Node} node - * @returns {boolean} + * Check if a range [from, to] would violate any lock rules + * Returns { blocked: boolean, reason?: string } */ -const isLockedSdt = (node) => { - return ( - (node.type.name === 'structuredContent' || node.type.name === 'structuredContentBlock') && - (node.attrs.lockMode === 'sdtLocked' || node.attrs.lockMode === 'sdtContentLocked') - ); -}; +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, - filterTransaction(tr, state) { - if (!tr.docChanged) return true; - - // Get only the ranges affected by this transaction - const changedRanges = collectChangedRanges(tr); - if (changedRanges.length === 0) return true; - - const docSize = state.doc.content.size; - - // Check only nodes within the changed ranges for locked SDTs - for (const { from, to } of changedRanges) { - // Clamp range to valid document bounds - const safeFrom = Math.max(0, Math.min(from, docSize)); - const safeTo = Math.max(0, Math.min(to, docSize)); - if (safeFrom >= safeTo) continue; - - // Use nodesBetween to only traverse affected range - let hasLockedNode = false; - state.doc.nodesBetween(safeFrom, safeTo, (node, pos) => { - if (isLockedSdt(node)) { - // Check if this locked node would be deleted - const mappedPos = tr.mapping.mapResult(pos); - const mappedEnd = tr.mapping.mapResult(pos + node.nodeSize); - if (mappedPos.deleted || mappedEnd.deleted) { - hasLockedNode = true; - return false; // Stop traversal - } + 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; } - }); - if (hasLockedNode) return false; // Block transaction + } + + 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 index 44549e382a..a6752efc33 100644 --- 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 @@ -1,7 +1,34 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { EditorState } from 'prosemirror-state'; +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; @@ -17,255 +44,373 @@ describe('StructuredContentLockPlugin', () => { schema = null; }); - const createDocWithStructuredContent = (lockMode, type = 'structuredContent') => { - const text = schema.text('Locked content'); - let node; - let doc; - - if (type === 'structuredContent') { - node = schema.nodes.structuredContent.create({ id: '123', lockMode }, text); - const paragraph = schema.nodes.paragraph.create(null, [node]); - doc = schema.nodes.doc.create(null, [paragraph]); - } else { - const innerParagraph = schema.nodes.paragraph.create(null, text); - node = schema.nodes.structuredContentBlock.create({ id: '123', lockMode }, [innerParagraph]); - doc = schema.nodes.doc.create(null, [node]); - } - - const nextState = EditorState.create({ schema, doc, plugins: editor.state.plugins }); - editor.setState(nextState); - return node; - }; - - describe('sdtLocked mode', () => { - it('prevents deletion of sdtLocked inline structured content', () => { - createDocWithStructuredContent('sdtLocked', 'structuredContent'); - - // Find the structured content node position - let nodePos = null; - let nodeSize = null; - editor.state.doc.descendants((node, pos) => { - if (node.type.name === 'structuredContent') { - nodePos = pos; - nodeSize = node.nodeSize; - return false; - } - }); + // Factory to create document with SDT node + function createDocWithSDT(lockMode, nodeType = 'structuredContent') { + const text = schema.text('Test content'); - expect(nodePos).not.toBeNull(); + 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]); + } - // Try to delete the node - const tr = editor.state.tr.delete(nodePos, nodePos + nodeSize); - const newState = editor.state.apply(tr); + 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]); + } - // The document should remain unchanged (deletion blocked) - expect(newState.doc.textContent).toBe('Locked content'); + 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); }); + }); - it('prevents deletion of sdtLocked block structured content', () => { - createDocWithStructuredContent('sdtLocked', 'structuredContentBlock'); - - // Find the structured content block position - let nodePos = null; - let nodeSize = null; - editor.state.doc.descendants((node, pos) => { - if (node.type.name === 'structuredContentBlock') { - nodePos = pos; - nodeSize = node.nodeSize; - return false; - } - }); - - expect(nodePos).not.toBeNull(); - - // Try to delete the node - const tr = editor.state.tr.delete(nodePos, nodePos + nodeSize); - const newState = editor.state.apply(tr); + 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); + }); + }); - // The document should remain unchanged (deletion blocked) - expect(newState.doc.textContent).toBe('Locked content'); + 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('sdtContentLocked mode', () => { - it('prevents deletion of sdtContentLocked inline structured content', () => { - createDocWithStructuredContent('sdtContentLocked', 'structuredContent'); + 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; - // Find the structured content node position - let nodePos = null; - let nodeSize = null; - editor.state.doc.descendants((node, pos) => { - if (node.type.name === 'structuredContent') { - nodePos = pos; - nodeSize = node.nodeSize; - return false; - } - }); + // Act + const tr = state.tr.insertText('NEW', insertPos); + const newState = state.apply(tr); - expect(nodePos).not.toBeNull(); + // 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; - // Try to delete the node - const tr = editor.state.tr.delete(nodePos, nodePos + nodeSize); - const newState = editor.state.apply(tr); + // Act + const tr = state.tr.insertText('NEW', insertPos); + const newState = state.apply(tr); - // The document should remain unchanged (deletion blocked) - expect(newState.doc.textContent).toBe('Locked content'); + // Assert + expect(newState.doc.textContent).toContain('NEW'); }); - it('prevents deletion of sdtContentLocked block structured content', () => { - createDocWithStructuredContent('sdtContentLocked', 'structuredContentBlock'); + 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; - // Find the structured content block position - let nodePos = null; - let nodeSize = null; - editor.state.doc.descendants((node, pos) => { - if (node.type.name === 'structuredContentBlock') { - nodePos = pos; - nodeSize = node.nodeSize; - return false; - } - }); + // Act + const tr = state.tr.insertText('NEW', insertPos); + const newState = state.apply(tr); + + // Assert: content should be unchanged + expect(newState.doc.textContent).toBe(originalContent); + }); - expect(nodePos).not.toBeNull(); + 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; - // Try to delete the node - const tr = editor.state.tr.delete(nodePos, nodePos + nodeSize); - const newState = editor.state.apply(tr); + // Act + const tr = state.tr.insertText('NEW', insertPos); + const newState = state.apply(tr); - // The document should remain unchanged (deletion blocked) - expect(newState.doc.textContent).toBe('Locked content'); + // Assert: content should be unchanged + expect(newState.doc.textContent).toBe(originalContent); }); }); - describe('contentLocked mode', () => { - it('allows deletion of contentLocked inline structured content', () => { - createDocWithStructuredContent('contentLocked', 'structuredContent'); - - // Find the structured content node position - let nodePos = null; - let nodeSize = null; - editor.state.doc.descendants((node, pos) => { - if (node.type.name === 'structuredContent') { - nodePos = pos; - nodeSize = node.nodeSize; - return false; - } - }); - - expect(nodePos).not.toBeNull(); + 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]); + } - // Try to delete the node - const tr = editor.state.tr.delete(nodePos, nodePos + nodeSize); - const newState = editor.state.apply(tr); + it('allows deletion of unlocked SDT while preserving locked SDT in same document', () => { + // Arrange + const doc = createDocWithMultipleSDTs(); + const state = applyDocToEditor(doc); - // The node should be deleted - let foundNode = false; - newState.doc.descendants((node) => { - if (node.type.name === 'structuredContent') { - foundNode = true; + // 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(); - expect(foundNode).toBe(false); + // 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('allows deletion of contentLocked block structured content', () => { - createDocWithStructuredContent('contentLocked', 'structuredContentBlock'); + it('blocks deletion that would affect locked SDT even when unlocked SDT is also selected', () => { + // Arrange + const doc = createDocWithMultipleSDTs(); + const state = applyDocToEditor(doc); - // Find the structured content block position - let nodePos = null; - let nodeSize = null; - editor.state.doc.descendants((node, pos) => { - if (node.type.name === 'structuredContentBlock') { - nodePos = pos; - nodeSize = node.nodeSize; - return false; + // 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); - expect(nodePos).not.toBeNull(); - - // Try to delete the node - const tr = editor.state.tr.delete(nodePos, nodePos + nodeSize); - const newState = editor.state.apply(tr); - - // The node should be deleted - let foundNode = false; - newState.doc.descendants((node) => { - if (node.type.name === 'structuredContentBlock') { - foundNode = true; - return false; - } - }); + // 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); - expect(foundNode).toBe(false); + // Assert: locked SDT should still exist + expect(newState.doc.textContent).toContain('Locked text'); }); }); - describe('unlocked mode', () => { - it('allows deletion of unlocked inline structured content', () => { - createDocWithStructuredContent('unlocked', 'structuredContent'); + 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); - // Find the structured content node position - let nodePos = null; - let nodeSize = null; - editor.state.doc.descendants((node, pos) => { - if (node.type.name === 'structuredContent') { - nodePos = pos; - nodeSize = node.nodeSize; - return false; - } - }); + // Act + const tr = state.tr.delete(2, 5); + const newState = state.apply(tr); - expect(nodePos).not.toBeNull(); + // Assert: deletion should proceed + expect(newState.doc.textContent).not.toBe(state.doc.textContent); + }); - // Try to delete the node - const tr = editor.state.tr.delete(nodePos, nodePos + nodeSize); - const newState = editor.state.apply(tr); + it('allows non-document-changing transactions', () => { + // Arrange + const doc = createDocWithSDT('sdtContentLocked', 'structuredContent'); + const state = applyDocToEditor(doc); - // The node should be deleted - let foundNode = false; - newState.doc.descendants((node) => { - if (node.type.name === 'structuredContent') { - foundNode = true; - return false; - } - }); + // Act: create selection-only transaction + const tr = state.tr.setSelection(TextSelection.create(state.doc, 1)); + const newState = state.apply(tr); - expect(foundNode).toBe(false); + // Assert: should not throw, selection should change + expect(newState.selection.from).toBe(1); }); - it('allows deletion of unlocked block structured content', () => { - createDocWithStructuredContent('unlocked', 'structuredContentBlock'); + it('handles deletion at document boundaries gracefully', () => { + // Arrange + const doc = createDocWithSDT('unlocked', 'structuredContent'); + const state = applyDocToEditor(doc); - // Find the structured content block position - let nodePos = null; - let nodeSize = null; - editor.state.doc.descendants((node, pos) => { - if (node.type.name === 'structuredContentBlock') { - nodePos = pos; - nodeSize = node.nodeSize; - return false; - } - }); - - expect(nodePos).not.toBeNull(); + // Act: delete from start of document + const tr = state.tr.delete(0, 2); + const newState = state.apply(tr); - // Try to delete the node - const tr = editor.state.tr.delete(nodePos, nodePos + nodeSize); - const newState = editor.state.apply(tr); + // Assert: should handle gracefully (exact behavior depends on schema) + expect(newState).toBeDefined(); + }); + }); - // The node should be deleted - let foundNode = false; - newState.doc.descendants((node) => { - if (node.type.name === 'structuredContentBlock') { - foundNode = true; - return false; - } - }); + 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); + }); - expect(foundNode).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); }); }); });