Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,15 @@ export type FieldAnnotationMetadata = {
marks?: Record<string, unknown>;
};

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;
};

Expand Down
2 changes: 2 additions & 0 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5113,6 +5113,7 @@ export class DomPainter {
'sdtScope',
'sdtTag',
'sdtAlias',
'lockMode',
'sdtSectionTitle',
'sdtSectionType',
'sdtSectionLocked',
Expand Down Expand Up @@ -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);
Expand Down
37 changes: 37 additions & 0 deletions packages/layout-engine/painters/dom/src/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines +456 to +491
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CSS specificity conflict: The new lock mode styles may conflict with existing hover styles. For inline structured content, line 423 sets border: 1px solid #629be7 and line 430-432 sets hover styles with background-color and border-color. The new lock mode styles at lines 459-485 set border: 1px solid transparent which will override the base blue border, and the hover styles at lines 488-491 only set border-color without setting background-color.

This means:

  1. For inline SDTs without lock mode data attribute, the existing blue border and hover background will work correctly
  2. For inline SDTs with lock mode data attributes, the transparent border will replace the blue border, and on hover, only the border color changes but the lock mode background color persists

This may not be the intended behavior. Consider whether lock mode styles should only apply to block-level SDTs, or if the inline styles need to be adjusted to work harmoniously with the existing hover behavior. The PR description mentions "matching Word's behavior" but it's unclear if Word shows these lock mode colors for inline SDTs or only for block SDTs.

Copilot uses AI. Check for mistakes.

/* Viewing mode: remove structured content affordances */
.presentation-editor--viewing .superdoc-structured-content-block,
.presentation-editor--viewing .superdoc-structured-content-inline {
Expand Down
17 changes: 13 additions & 4 deletions packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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'
);
Expand Down Expand Up @@ -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`;
}
Expand Down
1 change: 1 addition & 0 deletions packages/layout-engine/style-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ function normalizeStructuredContentMetadata(
id: toNullableString(attrs.id),
tag: toOptionalString(attrs.tag),
alias: toOptionalString(attrs.alias),
lockMode: attrs.lockMode as StructuredContentMetadata['lockMode'],
sdtPr: attrs.sdtPr,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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,
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,13 @@ export class StructuredContentBlockView extends StructuredContentViewBase {
element.prepend(dragHandle);
element.addEventListener('dragstart', (e) => this.onDragStart(e));
this.root = element;
this.updateContentEditability();
}

updateView() {
const domAttrs = Attribute.mergeAttributes(this.htmlAttributes);
updateDOMAttributes(this.dom, { ...domAttrs });
this.updateContentEditability();
}

update(node, decorations, innerDecorations) {
Expand Down
Loading
Loading