From 3e5377e7d93d6b64d9f0a3128425cc638c7650e1 Mon Sep 17 00:00:00 2001
From: Tadeu Tupinamba
Date: Wed, 4 Feb 2026 12:19:09 -0300
Subject: [PATCH 01/18] 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 02/18] 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 03/18] 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 04/18] 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 05/18] 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);
});
});
});
From 0646a2f4514e01d3080d2602a0a2f20a8d6feee3 Mon Sep 17 00:00:00 2001
From: Tadeu Tupinamba
Date: Sat, 7 Feb 2026 10:10:24 -0300
Subject: [PATCH 06/18] feat(super-editor): enhance SDT boundary handling and
hover functionality
- Introduced SdtGroupedHover class to manage hover states for multi-fragment SDT blocks, allowing simultaneous highlighting of all fragments.
- Updated shouldRebuildForSdtBoundary function to improve checks for SDT boundary changes, ensuring stale attributes are removed and boundaries are correctly validated.
- Adjusted DOM rendering logic to incorporate new hover functionality and boundary checks.
- Modified styles for SDT container labels to improve visibility and interaction during hover states.
---
.../painters/dom/src/renderer.ts | 38 +++++----
.../layout-engine/painters/dom/src/styles.ts | 54 +++++--------
.../painters/dom/src/utils/sdt-helpers.ts | 22 ++++++
.../painters/dom/src/utils/sdt-hover.ts | 79 +++++++++++++++++++
4 files changed, 145 insertions(+), 48 deletions(-)
create mode 100644 packages/layout-engine/painters/dom/src/utils/sdt-hover.ts
diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts
index 97e89fa6a2..3abbb218aa 100644
--- a/packages/layout-engine/painters/dom/src/renderer.ts
+++ b/packages/layout-engine/painters/dom/src/renderer.ts
@@ -68,7 +68,13 @@ import { DOM_CLASS_NAMES } from './constants.js';
import { sanitizeHref, encodeTooltip } from '@superdoc/url-validation';
import { renderTableFragment as renderTableFragmentElement } from './table/renderTableFragment.js';
import { assertPmPositions, assertFragmentPmPositions } from './pm-position-validation.js';
-import { applySdtContainerStyling, getSdtContainerKey, type SdtBoundaryOptions } from './utils/sdt-helpers.js';
+import {
+ applySdtContainerStyling,
+ getSdtContainerKey,
+ shouldRebuildForSdtBoundary,
+ type SdtBoundaryOptions,
+} from './utils/sdt-helpers.js';
+import { SdtGroupedHover } from './utils/sdt-hover.js';
import {
generateRulerDefinitionFromPx,
createRulerElement,
@@ -822,6 +828,7 @@ export class DomPainter {
private onScrollHandler: ((e: Event) => void) | null = null;
private onWindowScrollHandler: ((e: Event) => void) | null = null;
private onResizeHandler: ((e: Event) => void) | null = null;
+ private sdtHover = new SdtGroupedHover();
/** The currently active/selected comment ID for highlighting */
private activeCommentId: string | null = null;
@@ -1181,6 +1188,8 @@ export class DomPainter {
};
win.addEventListener('resize', this.onResizeHandler);
}
+
+ this.sdtHover.bind(mount);
}
private computeVirtualMetrics(): void {
@@ -1361,6 +1370,8 @@ export class DomPainter {
// Clear changed blocks now that current visible pages are patched
this.changedBlocks.clear();
this.processedLayoutVersion = this.layoutVersion;
+
+ this.sdtHover.reapply();
}
private updateSpacers(start: number, end: number): void {
@@ -1722,6 +1733,7 @@ export class DomPainter {
this.onScrollHandler = null;
this.onWindowScrollHandler = null;
this.onResizeHandler = null;
+ this.sdtHover.destroy();
this.layoutVersion = 0;
this.processedLayoutVersion = -1;
}
@@ -1796,10 +1808,20 @@ export class DomPainter {
if (current) {
existing.delete(key);
const sdtBoundaryMismatch = shouldRebuildForSdtBoundary(current.element, sdtBoundary);
+ // Verify the position mapping is reliable: if mapping the old pmStart doesn't produce
+ // the expected new pmStart, the mapping is degenerate (e.g. full-document paste) and
+ // we must rebuild to get correct span position attributes.
+ const newPmStart = (fragment as { pmStart?: number }).pmStart;
+ const mappingUnreliable =
+ this.currentMapping != null &&
+ newPmStart != null &&
+ current.element.dataset.pmStart != null &&
+ this.currentMapping.map(Number(current.element.dataset.pmStart)) !== newPmStart;
const needsRebuild =
this.changedBlocks.has(fragment.blockId) ||
current.signature !== fragmentSignature(fragment, this.blockLookup) ||
- sdtBoundaryMismatch;
+ sdtBoundaryMismatch ||
+ mappingUnreliable;
if (needsRebuild) {
const replacement = this.renderFragment(fragment, contextBase, sdtBoundary);
@@ -5281,18 +5303,6 @@ const computeSdtBoundaries = (
return boundaries;
};
-const shouldRebuildForSdtBoundary = (element: HTMLElement, boundary: SdtBoundaryOptions | undefined): boolean => {
- if (!boundary) return false;
- const startAttr = element.dataset.sdtContainerStart;
- const endAttr = element.dataset.sdtContainerEnd;
- const expectedStart = String(boundary.isStart ?? true);
- const expectedEnd = String(boundary.isEnd ?? true);
- if (startAttr === undefined || endAttr === undefined) {
- return true;
- }
- return startAttr !== expectedStart || endAttr !== expectedEnd;
-};
-
const fragmentKey = (fragment: Fragment): string => {
if (fragment.kind === 'para') {
return `para:${fragment.blockId}:${fragment.fromLine}:${fragment.toLine}`;
diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts
index c14e16f5b0..98e16f71f7 100644
--- a/packages/layout-engine/painters/dom/src/styles.ts
+++ b/packages/layout-engine/painters/dom/src/styles.ts
@@ -358,21 +358,21 @@ const SDT_CONTAINER_STYLES = `
/* Structured content drag handle/label - positioned above */
.superdoc-structured-content__label {
- font-size: 10px;
+ font-size: 11px;
align-items: center;
justify-content: center;
position: absolute;
left: 2px;
top: -19px;
width: calc(100% - 4px);
- max-width: 110px;
+ max-width: 130px;
min-width: 0;
height: 18px;
padding: 0 4px;
border: 1px solid #629be7;
border-bottom: none;
border-radius: 6px 6px 0 0;
- background-color: #629be7dd;
+ background-color: #629be7ee;
box-sizing: border-box;
z-index: 10;
display: none;
@@ -386,7 +386,7 @@ const SDT_CONTAINER_STYLES = `
text-overflow: ellipsis;
}
-.superdoc-structured-content-block:hover .superdoc-structured-content__label {
+.superdoc-structured-content-block.sdt-hover .superdoc-structured-content__label {
display: inline-flex;
}
@@ -438,9 +438,9 @@ const SDT_CONTAINER_STYLES = `
bottom: calc(100% + 2px);
left: 50%;
transform: translateX(-50%);
- font-size: 10px;
- padding: 2px 6px;
- background-color: #629be7dd;
+ font-size: 11px;
+ padding: 0 4px;
+ background-color: #629be7ee;
color: white;
border-radius: 4px;
white-space: nowrap;
@@ -456,38 +456,24 @@ const SDT_CONTAINER_STYLES = `
/* 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;
+/* Lock mode: hide border by default, show on hover.
+ * Use border-color (not border shorthand) to preserve continuation rules
+ * that remove border-top/border-bottom on multi-fragment SDT containers. */
+.superdoc-structured-content-block[data-lock-mode],
+.superdoc-structured-content-inline[data-lock-mode] {
+ border-color: 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].sdt-hover {
+ border-color: #629be7;
+ background-color: rgba(98, 155, 231, 0.05);
+ z-index: 99;
}
-/* 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;
+ z-index: 99;
}
/* Viewing mode: remove structured content affordances */
@@ -536,7 +522,7 @@ const FIELD_ANNOTATION_STYLES = `
.superdoc-layout .annotation *::selection {
background: transparent;
}
-
+
.superdoc-layout .annotation::-moz-selection,
.superdoc-layout .annotation *::-moz-selection {
background: transparent;
diff --git a/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts b/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts
index 867b065ffe..507acbe225 100644
--- a/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts
+++ b/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts
@@ -280,3 +280,25 @@ export function applySdtContainerStyling(
container.appendChild(labelEl);
}
}
+
+/**
+ * Checks whether a fragment element needs rebuilding due to SDT boundary changes.
+ *
+ * Handles two cases:
+ * 1. Element was in an SDT but no longer is (stale attributes need removal)
+ * 2. Element's start/end boundary flags don't match expected values
+ */
+export function shouldRebuildForSdtBoundary(element: HTMLElement, boundary: SdtBoundaryOptions | undefined): boolean {
+ if (!boundary) {
+ // Rebuild if element has stale SDT container attributes that should be removed
+ return element.dataset.sdtContainerStart !== undefined;
+ }
+ const startAttr = element.dataset.sdtContainerStart;
+ const endAttr = element.dataset.sdtContainerEnd;
+ const expectedStart = String(boundary.isStart ?? true);
+ const expectedEnd = String(boundary.isEnd ?? true);
+ if (startAttr === undefined || endAttr === undefined) {
+ return true;
+ }
+ return startAttr !== expectedStart || endAttr !== expectedEnd;
+}
diff --git a/packages/layout-engine/painters/dom/src/utils/sdt-hover.ts b/packages/layout-engine/painters/dom/src/utils/sdt-hover.ts
new file mode 100644
index 0000000000..e19bd82e43
--- /dev/null
+++ b/packages/layout-engine/painters/dom/src/utils/sdt-hover.ts
@@ -0,0 +1,79 @@
+/**
+ * Grouped hover for multi-fragment SDT blocks.
+ *
+ * When a block SDT spans multiple paragraphs, each renders as a separate DOM element.
+ * This class uses event delegation to highlight ALL fragments of the same SDT
+ * simultaneously via the `.sdt-hover` CSS class.
+ */
+
+const SDT_BLOCK_SELECTOR = '.superdoc-structured-content-block[data-sdt-id]';
+const HOVER_CLASS = 'sdt-hover';
+
+function sdtElementsById(root: HTMLElement, sdtId: string): NodeListOf {
+ return root.querySelectorAll(`.superdoc-structured-content-block[data-sdt-id="${sdtId}"]`);
+}
+
+export class SdtGroupedHover {
+ private hoveredSdtId: string | null = null;
+ private mount: HTMLElement | null = null;
+ private onMouseOver: ((e: Event) => void) | null = null;
+ private onMouseLeave: (() => void) | null = null;
+
+ /** Attach hover listeners to the mount element. Safe to call again on remount. */
+ bind(mount: HTMLElement): void {
+ this.destroy();
+ this.mount = mount;
+
+ this.onMouseOver = (e: Event) => {
+ const target = (e.target as HTMLElement).closest?.(SDT_BLOCK_SELECTOR) as HTMLElement | null;
+ const sdtId = target?.dataset.sdtId ?? null;
+
+ if (this.hoveredSdtId && this.hoveredSdtId !== sdtId) {
+ sdtElementsById(mount, this.hoveredSdtId).forEach((el) => el.classList.remove(HOVER_CLASS));
+ }
+
+ this.hoveredSdtId = sdtId;
+
+ if (sdtId) {
+ sdtElementsById(mount, sdtId).forEach((el) => el.classList.add(HOVER_CLASS));
+ }
+ };
+
+ this.onMouseLeave = () => {
+ if (this.hoveredSdtId) {
+ sdtElementsById(mount, this.hoveredSdtId).forEach((el) => el.classList.remove(HOVER_CLASS));
+ this.hoveredSdtId = null;
+ }
+ };
+
+ mount.addEventListener('mouseover', this.onMouseOver);
+ mount.addEventListener('mouseleave', this.onMouseLeave);
+ }
+
+ /** Re-apply hover class after render. New/rebuilt elements lose the class. */
+ reapply(): void {
+ if (this.hoveredSdtId && this.mount) {
+ sdtElementsById(this.mount, this.hoveredSdtId).forEach((el) => el.classList.add(HOVER_CLASS));
+ }
+ }
+
+ /** Remove listeners and reset state. */
+ destroy(): void {
+ if (this.mount) {
+ if (this.onMouseOver) {
+ try {
+ this.mount.removeEventListener('mouseover', this.onMouseOver);
+ } catch {}
+ }
+ if (this.onMouseLeave) {
+ try {
+ this.mount.removeEventListener('mouseleave', this.onMouseLeave);
+ } catch {}
+ }
+ }
+ this.mount = null;
+ this.onMouseOver = null;
+ this.onMouseLeave = null;
+ this.hoveredSdtId = null;
+ }
+}
From e377ab95a60cea1637e801b681e4c74338fa255f Mon Sep 17 00:00:00 2001
From: Tadeu Tupinamba
Date: Tue, 10 Feb 2026 19:28:36 -0300
Subject: [PATCH 07/18] feat(super-editor): add inline SDT wrapping for
geometry rendering path and visual test story
- Route tab, image, field annotation, and text elements through inline SDT
wrapper in the geometry-based rendering path (previously only the run-based
path had SDT wrapping)
- Add `sdt` metadata to TabRun contract type and pm-adapter tab converter
- Unify hover styles for block and inline SDTs (consistent colors, !important
to override lock-mode transparent borders and continuation rules)
- Remove unnecessary try/catch around removeEventListener in SdtGroupedHover
- Add performance optimization TODO to collectSDTNodes in lock plugin
- Re-export StructuredContentLockMode from contracts instead of duplicating
- Add visual testing story (sdt-lock-modes) demonstrating all lock modes,
SDT creation/update commands, and keyboard interactions
---
.../structured-content/sdt-lock-modes.ts | 280 ++++++++++++++++++
packages/layout-engine/contracts/src/index.ts | 2 +
.../painters/dom/src/renderer.ts | 96 +++++-
.../layout-engine/painters/dom/src/styles.ts | 27 +-
.../painters/dom/src/utils/sdt-hover.ts | 8 +-
.../src/converters/inline-converters/tab.ts | 5 +
.../structured-content-lock-plugin.js | 6 +-
.../src/extensions/types/node-attributes.ts | 3 +-
8 files changed, 403 insertions(+), 24 deletions(-)
create mode 100644 devtools/visual-testing/tests/interactions/stories/structured-content/sdt-lock-modes.ts
diff --git a/devtools/visual-testing/tests/interactions/stories/structured-content/sdt-lock-modes.ts b/devtools/visual-testing/tests/interactions/stories/structured-content/sdt-lock-modes.ts
new file mode 100644
index 0000000000..4160b731a9
--- /dev/null
+++ b/devtools/visual-testing/tests/interactions/stories/structured-content/sdt-lock-modes.ts
@@ -0,0 +1,280 @@
+import { defineStory } from '@superdoc-testing/helpers';
+import type { Page } from '@playwright/test';
+
+const WAIT_MS = 400;
+
+/**
+ * Find an SDT node position by its id attribute.
+ * Returns { pos, size } for the first matching structuredContent or structuredContentBlock node.
+ */
+async function findSdtPosition(page: Page, id: string): Promise<{ pos: number; size: number } | null> {
+ return page.evaluate((sdtId) => {
+ const editor = (window as unknown as { editor?: { state?: { doc?: { descendants?: Function } } } }).editor;
+ if (!editor?.state?.doc?.descendants) return null;
+
+ let result: { pos: number; size: number } | null = null;
+ editor.state.doc.descendants(
+ (node: { type: { name: string }; attrs: Record; nodeSize: number }, pos: number) => {
+ if (result) return false;
+ if (
+ (node.type.name === 'structuredContent' || node.type.name === 'structuredContentBlock') &&
+ String(node.attrs.id) === sdtId
+ ) {
+ result = { pos, size: node.nodeSize };
+ return false;
+ }
+ return true;
+ },
+ );
+ return result;
+ }, id);
+}
+
+/**
+ * Set the cursor position in the editor.
+ */
+async function setCursorPosition(page: Page, pos: number): Promise {
+ await page.evaluate((p) => {
+ const editor = (
+ window as unknown as {
+ editor?: { commands?: { setTextSelection?: (sel: { from: number; to: number }) => void; focus?: () => void } };
+ }
+ ).editor;
+ editor?.commands?.setTextSelection?.({ from: p, to: p });
+ editor?.commands?.focus?.();
+ }, pos);
+}
+
+/**
+ * Insert an inline structured content node via the editor command.
+ */
+async function insertInlineSdt(
+ page: Page,
+ attrs: { id: string; alias: string; lockMode: string },
+ text: string,
+): Promise {
+ await page.evaluate(
+ ({ attrs, text }) => {
+ const editor = (
+ window as unknown as {
+ editor?: {
+ commands?: {
+ insertStructuredContentInline?: (opts: { attrs: typeof attrs; text: string }) => boolean;
+ };
+ };
+ }
+ ).editor;
+ if (!editor?.commands?.insertStructuredContentInline) {
+ throw new Error('insertStructuredContentInline command not available');
+ }
+ editor.commands.insertStructuredContentInline({ attrs, text });
+ },
+ { attrs, text },
+ );
+}
+
+/**
+ * Demonstrates SDT (Structured Document Tag) lock modes via programmatic
+ * commands and keyboard interactions.
+ *
+ * Lock modes:
+ * - unlocked: wrapper deletable, content editable
+ * - sdtLocked: wrapper NOT deletable, content editable
+ * - contentLocked: wrapper deletable, content NOT editable
+ * - sdtContentLocked: wrapper NOT deletable, content NOT editable
+ *
+ * This story exercises insertStructuredContentInline, insertStructuredContentBlock,
+ * updateStructuredContentById, cursor placement inside SDTs, and demonstrates
+ * lock enforcement by attempting keyboard interactions in locked SDTs.
+ */
+export default defineStory({
+ name: 'sdt-lock-modes',
+ description: 'Create SDTs with various lock modes, interact with keyboard, demonstrate lock enforcement',
+ startDocument: null,
+ hideCaret: false,
+
+ async run(page, helpers): Promise {
+ const { step, focus, type, press, waitForStable, milestone } = helpers;
+
+ // -----------------------------------------------------------------
+ // Step 1 – Insert inline SDTs with different lock modes
+ // -----------------------------------------------------------------
+ await step('Insert inline SDTs', async () => {
+ await focus();
+
+ // Line 1: unlocked inline SDT
+ await type('Unlocked inline: ');
+ await waitForStable(WAIT_MS);
+ await insertInlineSdt(page, { id: '100', alias: 'Unlocked Field', lockMode: 'unlocked' }, 'editable value');
+ await waitForStable(WAIT_MS);
+
+ // Line 2: sdtLocked inline SDT
+ await press('End');
+ await press('Enter');
+ await type('SDT-locked inline: ');
+ await waitForStable(WAIT_MS);
+ await insertInlineSdt(page, { id: '200', alias: 'SDT Locked', lockMode: 'sdtLocked' }, 'cannot delete wrapper');
+ await waitForStable(WAIT_MS);
+
+ // Line 3: contentLocked inline SDT
+ await press('End');
+ await press('Enter');
+ await type('Content-locked inline: ');
+ await waitForStable(WAIT_MS);
+ await insertInlineSdt(
+ page,
+ { id: '300', alias: 'Content Locked', lockMode: 'contentLocked' },
+ 'read-only content',
+ );
+ await waitForStable(WAIT_MS);
+
+ await milestone('inline-sdts-created', 'Three inline SDTs: unlocked, sdtLocked, contentLocked');
+ });
+
+ // -----------------------------------------------------------------
+ // Step 2 – Insert a block SDT with sdtContentLocked
+ // -----------------------------------------------------------------
+ await step('Insert block SDT (sdtContentLocked)', async () => {
+ await press('End');
+ await press('Enter');
+ await press('Enter');
+ await waitForStable(WAIT_MS);
+
+ await page.evaluate(() => {
+ const editor = (
+ window as unknown as {
+ editor?: {
+ commands?: {
+ insertStructuredContentBlock?: (opts: {
+ attrs: { id: string; alias: string; lockMode: string };
+ html: string;
+ }) => boolean;
+ };
+ };
+ }
+ ).editor;
+ if (!editor?.commands?.insertStructuredContentBlock) {
+ throw new Error('insertStructuredContentBlock command not available');
+ }
+ editor.commands.insertStructuredContentBlock({
+ attrs: { id: '400', alias: 'Fully Locked Block', lockMode: 'sdtContentLocked' },
+ html: 'This block is fully locked (sdtContentLocked).
',
+ });
+ });
+ await waitForStable(WAIT_MS);
+
+ await milestone('block-sdt-created', 'Block SDT with sdtContentLocked created');
+ });
+
+ // -----------------------------------------------------------------
+ // Step 3 – Place cursor inside sdtLocked inline and type
+ // (content is editable — sdtLocked only protects the wrapper)
+ // -----------------------------------------------------------------
+ await step('Type inside sdtLocked inline (content editable)', async () => {
+ const sdt = await findSdtPosition(page, '200');
+ if (!sdt) throw new Error('sdtLocked SDT (id=200) not found');
+
+ // Place cursor inside the SDT text
+ await setCursorPosition(page, sdt.pos + 2);
+ await waitForStable(WAIT_MS);
+
+ await type(' ADDED');
+ await waitForStable(WAIT_MS);
+
+ await milestone('sdt-locked-typed', 'Typed " ADDED" inside sdtLocked inline — content is editable');
+ });
+
+ // -----------------------------------------------------------------
+ // Step 4 – Place cursor inside contentLocked inline and try typing
+ // (content is NOT editable)
+ // -----------------------------------------------------------------
+ await step('Try typing inside contentLocked inline', async () => {
+ const sdt = await findSdtPosition(page, '300');
+ if (!sdt) throw new Error('contentLocked SDT (id=300) not found');
+
+ await setCursorPosition(page, sdt.pos + 2);
+ await waitForStable(WAIT_MS);
+
+ // Attempt to type — should be blocked by contentLocked
+ await type('BLOCKED');
+ await waitForStable(WAIT_MS);
+
+ await milestone('content-locked-typing-blocked', 'Typing inside contentLocked SDT — should be blocked');
+ });
+
+ // -----------------------------------------------------------------
+ // Step 5 – Place cursor inside contentLocked and try Backspace
+ // (content deletion should also be blocked)
+ // -----------------------------------------------------------------
+ await step('Try Backspace inside contentLocked inline', async () => {
+ const sdt = await findSdtPosition(page, '300');
+ if (!sdt) throw new Error('contentLocked SDT (id=300) not found');
+
+ // Place cursor at end of SDT content
+ await setCursorPosition(page, sdt.pos + sdt.size - 2);
+ await waitForStable(WAIT_MS);
+
+ await press('Backspace');
+ await press('Backspace');
+ await press('Backspace');
+ await waitForStable(WAIT_MS);
+
+ await milestone('content-locked-backspace-blocked', 'Backspace inside contentLocked SDT — should be blocked');
+ });
+
+ // -----------------------------------------------------------------
+ // Step 6 – Update lock mode via updateStructuredContentById
+ // Change the unlocked inline (id=100) to contentLocked
+ // -----------------------------------------------------------------
+ await step('Update lock mode: unlocked → contentLocked', async () => {
+ await page.evaluate(() => {
+ const editor = (
+ window as unknown as {
+ editor?: {
+ commands?: {
+ updateStructuredContentById?: (id: string, opts: { attrs: { lockMode: string } }) => boolean;
+ };
+ };
+ }
+ ).editor;
+ if (!editor?.commands?.updateStructuredContentById) {
+ throw new Error('updateStructuredContentById command not available');
+ }
+ editor.commands.updateStructuredContentById('100', {
+ attrs: { lockMode: 'contentLocked' },
+ });
+ });
+ await waitForStable(WAIT_MS);
+
+ await milestone('lock-mode-updated', 'Updated id=100 from unlocked → contentLocked');
+ });
+
+ // -----------------------------------------------------------------
+ // Step 7 – Verify updated lock — try typing inside formerly unlocked SDT
+ // -----------------------------------------------------------------
+ await step('Try typing in updated contentLocked SDT', async () => {
+ const sdt = await findSdtPosition(page, '100');
+ if (!sdt) throw new Error('Updated SDT (id=100) not found');
+
+ await setCursorPosition(page, sdt.pos + 2);
+ await waitForStable(WAIT_MS);
+
+ // Attempt to type — should now be blocked
+ await type('SHOULD FAIL');
+ await waitForStable(WAIT_MS);
+
+ await milestone('updated-lock-enforced', 'Typing in updated contentLocked SDT — should be blocked');
+ });
+
+ // -----------------------------------------------------------------
+ // Step 8 – Final state
+ // -----------------------------------------------------------------
+ await step('Final state', async () => {
+ await focus();
+ await setCursorPosition(page, 1);
+ await waitForStable(WAIT_MS);
+
+ await milestone('final-state', 'Final document state with all SDT lock modes');
+ });
+ },
+});
diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts
index e524f78a8a..77bd061260 100644
--- a/packages/layout-engine/contracts/src/index.ts
+++ b/packages/layout-engine/contracts/src/index.ts
@@ -219,6 +219,8 @@ export type TabRun = RunMarks & {
indent?: ParagraphIndent;
pmStart?: number;
pmEnd?: number;
+ /** SDT metadata if tab is inside a structured document tag. */
+ sdt?: SdtMetadata;
};
export type LineBreakRun = {
diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts
index 6f5b10a0b8..6453da286c 100644
--- a/packages/layout-engine/painters/dom/src/renderer.ts
+++ b/packages/layout-engine/painters/dom/src/renderer.ts
@@ -4650,6 +4650,80 @@ export class DomPainter {
return undefined;
};
+ // Inline SDT wrapping for geometry path (absolute-positioned elements).
+ // Same concept as the run-based path's SDT wrapper, but here elements use
+ // position:absolute so the wrapper itself must be absolutely positioned to
+ // span from the leftmost to rightmost child element.
+ let geoSdtWrapper: HTMLElement | null = null;
+ let geoSdtId: string | null = null;
+ let geoSdtWrapperLeft = 0;
+ let geoSdtMaxRight = 0;
+
+ const closeGeoSdtWrapper = () => {
+ if (geoSdtWrapper) {
+ geoSdtWrapper.style.width = `${geoSdtMaxRight - geoSdtWrapperLeft}px`;
+ el.appendChild(geoSdtWrapper);
+ geoSdtWrapper = null;
+ geoSdtId = null;
+ }
+ };
+
+ /**
+ * Append an element to the line, routing through an inline SDT wrapper
+ * when the run has inline structuredContent metadata.
+ */
+ const appendToLineGeo = (elem: HTMLElement, runForSdt: Run, elemLeftPx: number, elemWidthPx: number) => {
+ const runSdt = (runForSdt as TextRun).sdt;
+ const isInlineSdt = runSdt?.type === 'structuredContent' && runSdt?.scope === 'inline';
+ const thisRunSdtId = isInlineSdt && runSdt?.id ? String(runSdt.id) : null;
+
+ if (thisRunSdtId !== geoSdtId) {
+ closeGeoSdtWrapper();
+ }
+
+ if (isInlineSdt && thisRunSdtId && this.doc) {
+ if (!geoSdtWrapper) {
+ geoSdtWrapper = this.doc.createElement('span');
+ geoSdtWrapper.className = DOM_CLASS_NAMES.INLINE_SDT_WRAPPER;
+ geoSdtWrapper.dataset.layoutEpoch = String(this.layoutEpoch);
+ geoSdtId = thisRunSdtId;
+ this.applySdtDataset(geoSdtWrapper, runSdt!);
+ geoSdtWrapperLeft = elemLeftPx;
+ geoSdtMaxRight = elemLeftPx;
+ geoSdtWrapper.style.position = 'absolute';
+ geoSdtWrapper.style.left = `${elemLeftPx}px`;
+ geoSdtWrapper.style.top = '0px';
+ geoSdtWrapper.style.height = `${line.lineHeight}px`;
+ const alias = (runSdt as { alias?: string })?.alias || 'Inline content';
+ const labelEl = this.doc.createElement('span');
+ labelEl.className = `${DOM_CLASS_NAMES.INLINE_SDT_WRAPPER}__label`;
+ labelEl.textContent = alias;
+ geoSdtWrapper.appendChild(labelEl);
+ }
+ // Adjust element left to be relative to wrapper
+ elem.style.left = `${elemLeftPx - geoSdtWrapperLeft}px`;
+ geoSdtMaxRight = Math.max(geoSdtMaxRight, elemLeftPx + elemWidthPx);
+ // Track PM positions on wrapper to span all contained runs
+ const pmStart = (runForSdt as TextRun).pmStart;
+ const pmEnd = (runForSdt as TextRun).pmEnd;
+ if (pmStart != null) {
+ const curStart = geoSdtWrapper.dataset.pmStart;
+ if (!curStart || pmStart < parseInt(curStart, 10)) {
+ geoSdtWrapper.dataset.pmStart = String(pmStart);
+ }
+ }
+ if (pmEnd != null) {
+ const curEnd = geoSdtWrapper.dataset.pmEnd;
+ if (!curEnd || pmEnd > parseInt(curEnd, 10)) {
+ geoSdtWrapper.dataset.pmEnd = String(pmEnd);
+ }
+ }
+ geoSdtWrapper.appendChild(elem);
+ } else {
+ el.appendChild(elem);
+ }
+ };
+
for (let runIndex = line.fromRun; runIndex <= line.toRun; runIndex += 1) {
const baseRun = block.runs[runIndex];
if (!baseRun) continue;
@@ -4703,7 +4777,7 @@ export class DomPainter {
if (baseRun.pmStart != null) tabEl.dataset.pmStart = String(baseRun.pmStart);
if (baseRun.pmEnd != null) tabEl.dataset.pmEnd = String(baseRun.pmEnd);
tabEl.dataset.layoutEpoch = String(this.layoutEpoch);
- el.appendChild(tabEl);
+ appendToLineGeo(tabEl, baseRun, tabStartX + indentOffset, actualTabWidth);
// Update cumulativeX to where the next content begins
// This ensures proper positioning for subsequent elements
@@ -4727,7 +4801,7 @@ export class DomPainter {
(runSegments && runSegments[0]?.width !== undefined ? runSegments[0].width : elem.offsetWidth) ?? 0;
elem.style.position = 'absolute';
elem.style.left = `${segX}px`;
- el.appendChild(elem);
+ appendToLineGeo(elem, baseRun, segX, segWidth);
cumulativeX = baseSegX + segWidth;
}
continue;
@@ -4758,7 +4832,7 @@ export class DomPainter {
const segWidth = (runSegments && runSegments[0]?.width !== undefined ? runSegments[0].width : 0) ?? 0;
elem.style.position = 'absolute';
elem.style.left = `${segX}px`;
- el.appendChild(elem);
+ appendToLineGeo(elem, baseRun, segX, segWidth);
cumulativeX = baseSegX + segWidth;
}
continue;
@@ -4805,7 +4879,7 @@ export class DomPainter {
elem.style.position = 'absolute';
elem.style.left = `${xPos}px`;
- el.appendChild(elem);
+ appendToLineGeo(elem, segmentRun, xPos, segment.width ?? 0);
// Update cumulative X for next segment by measuring this element's width
// This applies to ALL segments (both with and without explicit X)
@@ -4822,9 +4896,15 @@ export class DomPainter {
this.doc.body.removeChild(measureEl);
}
cumulativeX = baseX + width;
+ // Update SDT wrapper width if actual measured width differs from initial estimate
+ if (geoSdtWrapper) {
+ geoSdtMaxRight = Math.max(geoSdtMaxRight, xPos + width);
+ }
}
});
}
+ // Close any remaining SDT wrapper at end of geometry rendering
+ closeGeoSdtWrapper();
} else {
// Use run-based rendering for normal text flow
// Track current inline SDT wrapper to group adjacent runs with the same SDT id
@@ -4851,6 +4931,7 @@ export class DomPainter {
}
// Special handling for TabRuns (e.g., signature lines with underlines)
+ let elem: HTMLElement | null = null;
if (run.kind === 'tab') {
const tabEl = this.doc!.createElement('span');
tabEl.classList.add('superdoc-tab');
@@ -4889,11 +4970,10 @@ export class DomPainter {
if (run.pmEnd != null) tabEl.dataset.pmEnd = String(run.pmEnd);
tabEl.dataset.layoutEpoch = String(this.layoutEpoch);
- el.appendChild(tabEl);
- return;
+ elem = tabEl;
+ } else {
+ elem = this.renderRun(run, context, trackedConfig);
}
-
- const elem = this.renderRun(run, context, trackedConfig);
if (elem) {
if (styleId) {
elem.setAttribute('styleid', styleId);
diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts
index 316da3f302..8b3c8bb4f5 100644
--- a/packages/layout-engine/painters/dom/src/styles.ts
+++ b/packages/layout-engine/painters/dom/src/styles.ts
@@ -394,6 +394,13 @@ const SDT_CONTAINER_STYLES = `
text-overflow: ellipsis;
}
+/* Hover effect for block structured content (via event delegation class) */
+.superdoc-structured-content-block.sdt-hover {
+ border-color: #629be7 !important;
+ background-color: rgba(98, 155, 231, 0.08);
+ z-index: 9999999;
+}
+
.superdoc-structured-content-block.sdt-hover .superdoc-structured-content__label {
display: inline-flex;
}
@@ -436,8 +443,9 @@ const SDT_CONTAINER_STYLES = `
/* Hover effect for inline structured content */
.superdoc-structured-content-inline:hover {
- background-color: rgba(98, 155, 231, 0.15);
- border-color: #4a8ad9;
+ border-color: #629be7 !important;
+ background-color: rgba(98, 155, 231, 0.08);
+ z-index: 9999999;
}
/* Inline structured content label - shown on hover */
@@ -472,16 +480,19 @@ const SDT_CONTAINER_STYLES = `
border-color: transparent;
}
-/* Show blue border on hover for all lock modes */
+/* Show blue border on hover for all lock modes.
+ * Use !important on border-color to override the transparent default above
+ * and any continuation rules that remove border-top/border-bottom. */
.superdoc-structured-content-block[data-lock-mode].sdt-hover {
- border-color: #629be7;
- background-color: rgba(98, 155, 231, 0.05);
- z-index: 99;
+ border-color: #629be7 !important;
+ background-color: rgba(98, 155, 231, 0.08);
+ z-index: 9999999;
}
.superdoc-structured-content-inline[data-lock-mode]:hover {
- border-color: #629be7;
- z-index: 99;
+ border-color: #629be7 !important;
+ background-color: rgba(98, 155, 231, 0.08);
+ z-index: 9999999;
}
/* Viewing mode: remove structured content affordances */
diff --git a/packages/layout-engine/painters/dom/src/utils/sdt-hover.ts b/packages/layout-engine/painters/dom/src/utils/sdt-hover.ts
index e19bd82e43..05c685bf5f 100644
--- a/packages/layout-engine/painters/dom/src/utils/sdt-hover.ts
+++ b/packages/layout-engine/painters/dom/src/utils/sdt-hover.ts
@@ -61,14 +61,10 @@ export class SdtGroupedHover {
destroy(): void {
if (this.mount) {
if (this.onMouseOver) {
- try {
- this.mount.removeEventListener('mouseover', this.onMouseOver);
- } catch {}
+ this.mount.removeEventListener('mouseover', this.onMouseOver);
}
if (this.onMouseLeave) {
- try {
- this.mount.removeEventListener('mouseleave', this.onMouseLeave);
- } catch {}
+ this.mount.removeEventListener('mouseleave', this.onMouseLeave);
}
}
this.mount = null;
diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.ts
index 357aa4788f..dfde920094 100644
--- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.ts
+++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/tab.ts
@@ -18,6 +18,7 @@ export function tabNodeToRun({
tabOrdinal,
paragraphAttrs,
inheritedMarks,
+ sdtMetadata,
}: InlineConverterParams): Run | null {
const pos = positions.get(node);
if (!pos) return null;
@@ -34,6 +35,10 @@ export function tabNodeToRun({
leader: (node.attrs?.leader as TabRun['leader']) ?? null,
};
+ if (sdtMetadata) {
+ run.sdt = sdtMetadata;
+ }
+
// Apply marks (e.g., underline) to the tab run
const marks = [...(node.marks ?? []), ...(inheritedMarks ?? [])];
if (marks.length > 0) {
diff --git a/packages/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 e3c900307f..7f192f4272 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
@@ -17,7 +17,11 @@ export const STRUCTURED_CONTENT_LOCK_KEY = new PluginKey('structuredContentLock'
*/
/**
- * Collect all SDT nodes from the document
+ * Collect all SDT nodes from the document.
+ *
+ * TODO: For large documents, consider caching SDT nodes in plugin state
+ * (rebuild on docChanged only), early-exit on unlocked nodes, or limiting
+ * the search to nodes near the current selection for key/input handlers.
*/
function collectSDTNodes(doc) {
const sdtNodes = [];
diff --git a/packages/super-editor/src/extensions/types/node-attributes.ts b/packages/super-editor/src/extensions/types/node-attributes.ts
index eaa77e292a..1261f081fc 100644
--- a/packages/super-editor/src/extensions/types/node-attributes.ts
+++ b/packages/super-editor/src/extensions/types/node-attributes.ts
@@ -14,6 +14,7 @@ import type {
InlineNodeAttributes,
ShapeNodeAttributes,
} from '../../core/types/NodeCategories.js';
+import type { StructuredContentLockMode } from '@superdoc/contracts';
// ============================================
// SHARED TYPES
@@ -593,7 +594,7 @@ export interface HardBreakAttrs extends InlineNodeAttributes {
// STRUCTURED CONTENT
// ============================================
-export type StructuredContentLockMode = 'unlocked' | 'sdtLocked' | 'contentLocked' | 'sdtContentLocked';
+export type { StructuredContentLockMode };
/** Structured content node attributes */
export interface StructuredContentAttrs extends BlockNodeAttributes {
From 0fb2bd753cf56c14427818a96bf94e454151d32c Mon Sep 17 00:00:00 2001
From: Tadeu Tupinamba
Date: Wed, 11 Feb 2026 09:45:57 -0300
Subject: [PATCH 08/18] chore: revert unrelated formatting changes in demos/cdn
From e632da1a739b038cf975c32ee6c43123f64e0828 Mon Sep 17 00:00:00 2001
From: Tadeu Tupinamba
Date: Wed, 11 Feb 2026 09:48:00 -0300
Subject: [PATCH 09/18] chore: revert unrelated formatting changes in demos/cdn
---
demos/cdn/demo-config.json | 9 ++++++---
demos/cdn/file-upload.css | 18 +++++++++---------
demos/cdn/file-upload.js | 8 ++++----
3 files changed, 19 insertions(+), 16 deletions(-)
diff --git a/demos/cdn/demo-config.json b/demos/cdn/demo-config.json
index 2dceccd1fd..e4220552a4 100644
--- a/demos/cdn/demo-config.json
+++ b/demos/cdn/demo-config.json
@@ -1,4 +1,7 @@
{
- "tags": ["editing", "viewing"],
- "title": "CDN"
-}
+ "tags": [
+ "editing",
+ "viewing"
+ ],
+ "title": "CDN"
+}
\ No newline at end of file
diff --git a/demos/cdn/file-upload.css b/demos/cdn/file-upload.css
index 61ba20361d..8e5aa64cc5 100644
--- a/demos/cdn/file-upload.css
+++ b/demos/cdn/file-upload.css
@@ -1,10 +1,10 @@
.file-upload-button {
- cursor: pointer;
- padding: 8px 12px;
- border-radius: 8px;
- margin: 10px;
- outline: none;
- border: none;
- background-color: #1355ff;
- color: white;
-}
+ cursor: pointer;
+ padding: 8px 12px;
+ border-radius: 8px;
+ margin: 10px;
+ outline: none;
+ border: none;
+ background-color: #1355ff;
+ color: white;
+}
\ No newline at end of file
diff --git a/demos/cdn/file-upload.js b/demos/cdn/file-upload.js
index c0e5879ad6..519d52dacb 100644
--- a/demos/cdn/file-upload.js
+++ b/demos/cdn/file-upload.js
@@ -3,7 +3,7 @@ const fileInput = document.querySelector('.file-upload-input');
uploadBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (event) => {
- const file = event.target.files?.[0];
- const uploadEvent = new CustomEvent('file-upload', { detail: file });
- if (file) window.dispatchEvent(uploadEvent);
-});
+ const file = event.target.files?.[0];
+ const uploadEvent = new CustomEvent("file-upload", { detail: file });
+ if (file) window.dispatchEvent(uploadEvent);
+});
\ No newline at end of file
From d600ffaccd4298d2ce60ee3684ac056180a2801e Mon Sep 17 00:00:00 2001
From: Tadeu Tupinamba
Date: Wed, 11 Feb 2026 09:49:57 -0300
Subject: [PATCH 10/18] chore: revert unrelated formatting changes in demos/
---
.../chrome-extension/background.js | 86 +-
.../chrome-extension/content.js | 210 +-
.../chrome-extension/docx-validator.js | 272 +-
.../chrome-extension/lib/style.css | 5346 ++++++++---------
4 files changed, 2695 insertions(+), 3219 deletions(-)
diff --git a/demos/chrome-extension/chrome-extension/background.js b/demos/chrome-extension/chrome-extension/background.js
index cc54872e6f..ad128903ab 100644
--- a/demos/chrome-extension/chrome-extension/background.js
+++ b/demos/chrome-extension/chrome-extension/background.js
@@ -8,12 +8,12 @@ importScripts('dist/docx-validator.bundle.js');
function updateIcon(enabled) {
const suffix = enabled ? '' : '-disabled';
const iconPath = {
- 16: `icons/icon-16x16${suffix}.png`,
- 19: `icons/icon-19x19${suffix}.png`,
- 48: `icons/icon-48x48${suffix}.png`,
- 128: `icons/icon-128x128${suffix}.png`,
+ "16": `icons/icon-16x16${suffix}.png`,
+ "19": `icons/icon-19x19${suffix}.png`,
+ "48": `icons/icon-48x48${suffix}.png`,
+ "128": `icons/icon-128x128${suffix}.png`
};
-
+
chrome.action.setIcon({ path: iconPath });
}
@@ -26,9 +26,9 @@ chrome.storage.sync.get(['extensionEnabled'], (result) => {
// Create context menu on installation for selecting text
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
- id: 'openSelectedInSuperdoc',
- title: 'Open selected content in SuperDoc',
- contexts: ['selection'],
+ id: "openSelectedInSuperdoc",
+ title: "Open selected content in SuperDoc",
+ contexts: ["selection"]
});
});
@@ -55,13 +55,13 @@ async function handleDownloadFile(request, _sender, sendResponse) {
const downloadId = await chrome.downloads.download({
url: request.url,
filename: request.filename,
- saveAs: true,
+ saveAs: true
});
// Track this download to ignore it if downloaded from viewer
viewerDownloadIds.add(downloadId);
console.log('File download initiated:', request.filename, 'ID:', downloadId);
-
+
sendResponse({ success: true, downloadId: downloadId });
} catch (error) {
console.error('Error downloading file:', error);
@@ -72,8 +72,8 @@ async function handleDownloadFile(request, _sender, sendResponse) {
// Action to handler mapping
const messageHandlers = {
- toggleExtension: handleToggleExtension,
- downloadFile: handleDownloadFile,
+ 'toggleExtension': handleToggleExtension,
+ 'downloadFile': handleDownloadFile,
};
// Listen for messages
@@ -87,13 +87,13 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
// Handle context menu clicks
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
- if (!(info.menuItemId === 'openSelectedInSuperdoc' && info.selectionText)) return;
+ if (!(info.menuItemId === "openSelectedInSuperdoc" && info.selectionText)) return;
// Send message to content script to capture HTML and open in SuperDoc
try {
await chrome.tabs.sendMessage(tab.id, {
action: 'captureSelectedHTML',
- selectedText: info.selectionText,
+ selectedText: info.selectionText
});
} catch (error) {
console.error('Error sending message to content script:', error);
@@ -109,14 +109,14 @@ chrome.downloads.onChanged.addListener(async (downloadDelta) => {
console.log('Extension disabled, ignoring download');
return;
}
-
+
// Check if this is a download from viewer - if so, ignore it, otherwise we get endless loop of opening modals
if (viewerDownloadIds.has(downloadDelta.id)) {
viewerDownloadIds.delete(downloadDelta.id);
console.log('Ignoring viewer download completion:', downloadDelta.id);
return;
}
-
+
try {
await processDownload(downloadDelta.id);
} catch (error) {
@@ -131,7 +131,7 @@ async function sendMessageToActiveTab(action, payload) {
await chrome.tabs.sendMessage(tabs[0].id, {
action,
- ...payload,
+ ...payload
});
} catch (error) {
console.error('Error sending message to content script:', error);
@@ -144,19 +144,19 @@ async function processDownload(downloadId) {
const download = downloads[0];
const filename = download.filename.toLowerCase();
-
+
// File type handlers
// We will handle markdown like html, since they are interoperable (to a point)
const fileHandlers = {
'.docx': processDocxFile,
'.md': processMarkdownFile,
- '.markdown': processMarkdownFile,
+ '.markdown': processMarkdownFile
};
const extension = filename.substring(filename.lastIndexOf('.'));
const handler = fileHandlers[extension];
if (!handler) throw new Error(`No handler for file type: ${extension}`);
-
+
await handler(download);
}
@@ -165,7 +165,7 @@ async function processDocxFile(download) {
// fetch and stringify (actual blob was getting dropped on message to viewer.js)
const response = await fetch(`file://${download.filename}`); // background scripts let us do cool stuff like this
const blob = await response.blob();
-
+
// Validate and correct the DOCX file
// Some DOCX files are generate with little to no style info or a poor schema,
// here we try to fill in the blanks.
@@ -178,7 +178,7 @@ async function processDocxFile(download) {
console.error('Error validating DOCX:', error);
// Continue with original blob if validation fails
}
-
+
// convert to b64, actual blobs were getting dropped on message to content script
const base64Data = await new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -186,15 +186,15 @@ async function processDocxFile(download) {
reader.onerror = reject;
reader.readAsDataURL(correctedBlob);
});
-
+
// Send message to content script to display modal
await sendMessageToActiveTab('displayDOCX', {
data: {
filename: download.filename,
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
fileSize: correctedBlob.size,
- base64Data,
- },
+ base64Data
+ }
});
}
@@ -202,62 +202,62 @@ async function processMarkdownFile(download) {
// Fetch the markdown file content
const response = await fetch(`file://${download.filename}`);
const markdownText = await response.text();
-
+
// Convert markdown to HTML
const htmlContent = markdownToHtml(markdownText);
-
+
// Send message to content script with HTML content
await sendMessageToActiveTab('displayMarkdown', {
data: {
filename: download.filename,
htmlContent: htmlContent,
- originalMarkdown: markdownText,
- },
+ originalMarkdown: markdownText
+ }
});
}
// Simple markdown to HTML converter
function markdownToHtml(markdown) {
let html = markdown;
-
+
// Headers
html = html.replace(/^### (.*$)/gim, '$1
');
html = html.replace(/^## (.*$)/gim, '$1
');
html = html.replace(/^# (.*$)/gim, '$1
');
-
+
// Bold
html = html.replace(/\*\*(.*?)\*\*/gim, '$1');
html = html.replace(/__(.*?)__/gim, '$1');
-
+
// Italic
html = html.replace(/\*(.*?)\*/gim, '$1');
html = html.replace(/_(.+?)_/gim, '$1');
-
+
// Code blocks
html = html.replace(/```([\s\S]*?)```/gim, '$1
');
-
+
// Inline code
html = html.replace(/`(.*?)`/gim, '$1');
-
+
// Links
html = html.replace(/\[([^\]]*)\]\(([^\)]*)\)/gim, '$1');
-
+
// Images
html = html.replace(/!\[([^\]]*)\]\(([^\)]*)\)/gim, '
');
-
+
// Lists
html = html.replace(/^\* (.*$)/gim, '$1');
html = html.replace(/^\- (.*$)/gim, '$1');
html = html.replace(/^\+ (.*$)/gim, '$1');
-
+
// Wrap consecutive list items in ul tags
html = html.replace(/(.*<\/li>)/gims, '');
html = html.replace(/<\/ul>\s*/gim, '');
-
+
// Line breaks
html = html.replace(/\n\n/gim, '
');
html = html.replace(/\n/gim, '
');
-
+
// Clean up empty paragraphs
html = html.replace(/
<\/p>/gim, '');
html = html.replace(/
()/gim, '$1');
@@ -266,9 +266,9 @@ function markdownToHtml(markdown) {
html = html.replace(/(<\/ul>)<\/p>/gim, '$1');
html = html.replace(/(
)/gim, '$1');
html = html.replace(/(<\/pre>)<\/p>/gim, '$1');
-
+
// Wrap in paragraphs
html = '' + html + '
';
-
+
return html;
-}
+}
\ No newline at end of file
diff --git a/demos/chrome-extension/chrome-extension/content.js b/demos/chrome-extension/chrome-extension/content.js
index d583ac7a80..8203ca33c4 100644
--- a/demos/chrome-extension/chrome-extension/content.js
+++ b/demos/chrome-extension/chrome-extension/content.js
@@ -21,7 +21,7 @@ async function loadModalHTML() {
// Inject CSS for modal
function injectModalCSS() {
if (document.getElementById(`${ID_PREFIX}modal-css`)) return;
-
+
const style = document.createElement('style');
style.id = `${ID_PREFIX}modal-css`;
style.textContent = `
@@ -47,7 +47,7 @@ async function loadSuperDoc() {
cssLink.rel = 'stylesheet';
cssLink.href = chrome.runtime.getURL('lib/style.css');
document.head.appendChild(cssLink);
-
+
// Check if SuperDoc library is available
if (!window.SuperDocLibrary) {
throw new Error('SuperDocLibrary not found - should be loaded via content script');
@@ -57,63 +57,63 @@ async function loadSuperDoc() {
// Create modal
async function createModal() {
if (modalContainer) return modalContainer;
-
+
injectModalCSS();
-
+
// Load external modal CSS
const modalCssLink = document.createElement('link');
modalCssLink.rel = 'stylesheet';
modalCssLink.href = chrome.runtime.getURL('modal.css');
document.head.appendChild(modalCssLink);
-
+
// Load modal HTML from file
const modalHTML = await loadModalHTML();
if (!modalHTML) {
console.error('Failed to load modal HTML');
return null;
}
-
+
const div = document.createElement('div');
div.innerHTML = modalHTML;
modalContainer = div.firstElementChild;
-
+
// Set the logo source after loading the HTML
const logoImg = modalContainer.querySelector(`#${ID_PREFIX}logo`);
if (logoImg) {
// Try to get the page's favicon from gstatic first
const currentDomain = window.location.hostname;
const faviconUrl = `https://www.google.com/s2/favicons?domain=${currentDomain}&sz=32`;
-
+
logoImg.src = faviconUrl;
-
+
// Fallback to extension logo if favicon fails to load
logoImg.onerror = () => {
logoImg.src = chrome.runtime.getURL('icons/logo.webp');
};
}
-
+
// Set the document title
const titleElement = modalContainer.querySelector(`#${ID_PREFIX}document-title`);
if (titleElement && currentFileData) {
const filename = currentFileData.filename.split('/').pop(); // Get just the filename
- const title = filename.replace(/\.[^/.]+$/, ''); // Remove file extension
- titleElement.textContent = title || 'Untitled Document';
+ const title = filename.replace(/\.[^/.]+$/, ""); // Remove file extension
+ titleElement.textContent = title || "Untitled Document";
}
-
+
document.body.appendChild(modalContainer);
-
+
// Setup event listeners
const closeBtn = modalContainer.querySelector(`#${ID_PREFIX}close-btn`);
const downloadBtn = modalContainer.querySelector(`#${ID_PREFIX}download-btn`);
const downloadDropdown = modalContainer.querySelector(`#${ID_PREFIX}download-dropdown`);
const downloadMarkdownBtn = modalContainer.querySelector(`#${ID_PREFIX}download-markdown`);
const downloadHtmlBtn = modalContainer.querySelector(`#${ID_PREFIX}download-html`);
-
+
closeBtn.addEventListener('click', closeModal);
downloadBtn.addEventListener('click', handleDownloadClick);
downloadMarkdownBtn.addEventListener('click', () => exportMarkdown());
downloadHtmlBtn.addEventListener('click', () => exportHTML());
-
+
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest(`#${ID_PREFIX}download-wrapper`)) {
@@ -127,21 +127,21 @@ async function createModal() {
closeModal();
}
});
-
+
// Close on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modalContainer.style.display !== 'none') {
closeModal();
}
});
-
+
return modalContainer;
}
// Close modal
function closeModal() {
if (!modalContainer) return;
-
+
superdoc = null;
// Remove modal from DOM completely
@@ -153,25 +153,26 @@ function closeModal() {
// Show modal
function showModal() {
if (!modalContainer) return;
-
+
modalContainer.style.display = 'flex';
}
+
// Initialize SuperDoc in modal
async function initSuperdocWithDOCX(data) {
console.log('Initializing SuperDoc in modal');
-
+
try {
if (!window.SuperDocLibrary?.SuperDoc) {
console.error('SuperDocLibrary not available');
showFallback(data);
return;
}
-
+
const file = new File([data.blob], data.filename, { type: data.mimeType });
const fileUrl = URL.createObjectURL(file);
const superdocFile = await SuperDocLibrary.getFileObject(fileUrl, data.filename, data.mimeType);
-
+
const config = {
selector: `#${ID_PREFIX}docx-viewer`,
toolbar: `#${ID_PREFIX}toolbar`,
@@ -180,9 +181,9 @@ async function initSuperdocWithDOCX(data) {
rulers: true,
document: superdocFile,
onReady: () => console.log('SuperDoc ready in modal'),
- onEditorCreate: () => console.log('Editor created in modal'),
+ onEditorCreate: () => console.log('Editor created in modal')
};
-
+
superdoc = new SuperDocLibrary.SuperDoc(config);
// unhide selector
const viewerElement = modalContainer.querySelector(`#${ID_PREFIX}docx-viewer`);
@@ -190,6 +191,7 @@ async function initSuperdocWithDOCX(data) {
viewerElement.style.display = 'flex';
}
console.log('SuperDoc initialized in modal');
+
} catch (error) {
console.error('Error:', error.message);
showFallback(data);
@@ -201,7 +203,7 @@ async function handleDownloadClick() {
const markdownViewer = document.getElementById(`${ID_PREFIX}markdown-viewer`);
const docxViewer = document.getElementById(`${ID_PREFIX}docx-viewer`);
const downloadDropdown = document.getElementById(`${ID_PREFIX}download-dropdown`);
-
+
// Check if this is a markdown file
if (markdownViewer && markdownViewer.style.display !== 'none') {
// Show dropdown for markdown files
@@ -228,7 +230,7 @@ async function downloadCurrentFile() {
await exportMarkdown();
return;
}
-
+
// Export the current document from SuperDoc editor (DOCX files)
const blobToDownload = await superdoc.activeEditor.exportDocx();
@@ -245,12 +247,12 @@ async function downloadCurrentFile() {
if (fileName.includes('/') || fileName.includes('\\')) {
fileName = fileName.split('/').pop().split('\\').pop();
}
-
+
// Send download request to background script
const response = await chrome.runtime.sendMessage({
action: 'downloadFile',
url: dataUrl,
- filename: fileName,
+ filename: fileName
});
if (!response || !response.success) {
@@ -270,8 +272,8 @@ function showFallback(data) {
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
- const formattedSize = bytes === 0 ? '0 B' : Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
-
+ const formattedSize = bytes === 0 ? '0 B' : Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
+
container.innerHTML = `
File: ${data.filename}
@@ -284,14 +286,14 @@ function showFallback(data) {
// Initialize SuperDoc with HTML content for markdown files
async function initSuperdocWithHTML(data) {
console.log('Initializing SuperDoc with HTML content');
-
+
try {
if (!window.SuperDocLibrary?.SuperDoc) {
console.error('SuperDocLibrary not available');
showMarkdownFallback(data);
return;
}
-
+
// Create a simple HTML document structure
const htmlContent = `
@@ -312,7 +314,8 @@ async function initSuperdocWithHTML(data) {