Skip to content

feat(super-editor): add w:lock support for StructuredContent nodes#1939

Open
tupizz wants to merge 5 commits intomainfrom
feat/sdt-lock-support
Open

feat(super-editor): add w:lock support for StructuredContent nodes#1939
tupizz wants to merge 5 commits intomainfrom
feat/sdt-lock-support

Conversation

@tupizz
Copy link
Contributor

@tupizz tupizz commented Feb 4, 2026

Summary

Add ECMA-376 w:lock support for StructuredContent (inline) and StructuredContentBlock (block) nodes, enabling template variables to be read-only per the OOXML specification (§17.5.2.23).

Lock Modes

Lock Mode SDT Wrapper Content Use Case
unlocked Can delete Can edit Default, no restrictions
sdtLocked Cannot delete Can edit Protect field structure
contentLocked Can delete Cannot edit Read-only value, removable
sdtContentLocked Cannot delete Cannot edit Fully protected field

Visual Comparison (Word vs SuperDoc)

CleanShot 2026-02-04 at 13 57 37@2x

Border behavior: Blue border (#629be7) appears on hover only, matching Word's behavior.


Architecture Decisions

1. Plugin-Only Defense (No contentEditable='false')

Decision: Block edits through the lock plugin instead of setting contentEditable='false' on locked content.

Why: Setting contentEditable='false' completely prevents cursor placement inside the node. This is poor UX because users:

  • Can't select text to copy it
  • Can't navigate through the document smoothly
  • Experience jarring cursor behavior around locked regions

Solution: Keep content editable at the DOM level and rely on the plugin to block actual edits. Users can move cursor and select text freely while being prevented from modifying content.

2. Three-Layer Defense Strategy

The lock plugin uses three mechanisms to enforce locks:

Layer Hook Purpose
1 handleKeyDown Block Delete/Backspace/Cut before transaction is created
2 handleTextInput Block typing in content-locked nodes
3 filterTransaction Safety net for paste, drag-drop, programmatic changes

Why handleKeyDown instead of just filterTransaction?

ProseMirror's transaction flow is:

User action → Browser event → handleKeyDown → Transaction created → filterTransaction → Applied

When filterTransaction blocks a transaction, the browser may have already modified DOM selection, causing the cursor to jump unexpectedly. By intercepting in handleKeyDown and calling event.preventDefault(), we prevent the browser from processing the event at all.

3. Step Relationship Analysis

To detect lock violations, we analyze the geometric relationship between a step's affected range and each SDT node:

Document: [----SDT----]
               pos    end

1. containsSDT: Would fully delete the SDT
   Step:    [================]
   SDT:        [--------]
   → Blocked for sdtLocked/sdtContentLocked

2. insideSDT: Modification entirely within SDT (but not deleting it)
   Step:          [--]
   SDT:        [--------]
   → Blocked for contentLocked/sdtContentLocked

3. crossesStart/crossesEnd: Step crosses SDT boundary
   Step:    [=======]        or        [=======]
   SDT:        [--------]         [--------]
   → Blocked for sdtLocked/sdtContentLocked (damages wrapper)

4. NodeView Provides Visual Feedback Only

The NodeView adds CSS classes for styling but does NOT set contentEditable:

updateContentEditability() {
  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());
  }
}

Changes

Import/Export

  • Parse w:lock element from w:sdtPr on DOCX import
  • Export w:lock element to w:sdtPr on DOCX save
  • Round-trip preservation of lock mode values

Editor Behavior

  • Add lockMode attribute to StructuredContent and StructuredContentBlock extensions
  • Add structuredContentLockPlugin with 3-layer defense:
    • handleKeyDown: Block Delete/Backspace/Cut before transaction
    • handleTextInput: Block typing in content-locked nodes
    • filterTransaction: Safety net for programmatic changes
  • NodeView adds CSS classes for visual feedback (allows cursor movement)

Visual Styling (Presentation Mode)

  • Add StructuredContentLockMode type to contracts
  • Add lockMode to StructuredContentMetadata in style-engine
  • Add data-lock-mode attribute rendering in DomPainter
  • Add CSS styles matching Word's appearance for each lock mode

Documentation

  • Add comprehensive research document at .tupizz/docs/prosemirror-lock-plugin-research.md
  • Documents ProseMirror transaction flow, plugin hooks, and implementation learnings

Test Coverage

35 tests covering all lock modes and operations:

  • Wrapper deletion tests (4 modes × 2 node types = 8 tests)
  • Content modification tests (4 modes × 2 node types = 8 tests)
  • Boundary crossing tests (crosses start/end of SDT)
  • Edge cases (empty document, multiple SDTs, collapsed selection)
Test Files  1 passed (1)
     Tests  35 passed (35)

Files Changed

Package Files
contracts src/index.ts - Add StructuredContentLockMode type
style-engine src/index.ts - Add lockMode normalization
painter-dom renderer.ts, styles.ts, sdt-helpers.ts - Render lock mode styling
super-editor Extensions, NodeViews, import/export handlers, lock plugin, tests

Test Plan

  • Unit tests for import parsing (all 4 lock modes)
  • Unit tests for export generation
  • Unit tests for lock plugin (35 tests covering all scenarios)
  • Cursor movement works in locked content
  • Manual test with DOCX containing locked SDTs
  • Verify visual styling matches Word

Related

  • IT-336

Demo

CleanShot.2026-02-04.at.15.53.52.mp4

@github-actions
Copy link
Contributor

github-actions bot commented Feb 4, 2026

Based on my review of the code and my knowledge of the ECMA-376 specification, I can provide a detailed review:

Status: FAIL

The implementation has spec violations in the w:lock element handling:

Issues Found

1. Invalid enumeration values (Critical)

Files:

  • packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.js:25
  • packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-structured-content.js:62

Problem: The code uses 'unlocked' as a valid value for w:lock/@w:val, but this is not a valid ECMA-376 enumeration value.

According to ECMA-376 Part 1 §17.5.2.20 (CT_Lock), the w:lock element's w:val attribute accepts ST_Lock enumeration values:

  • sdtLocked - SDT cannot be deleted
  • contentLocked - SDT contents cannot be edited
  • sdtContentLocked - Both SDT and contents are locked
  • unlocked is NOT a valid value in the spec

Spec violation: When an SDT is unlocked, the w:lock element should be omitted entirely, not set to 'unlocked'.

2. Incorrect default handling (Major)

File: packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.js:26

Problem: The code defaults missing w:lock elements to 'unlocked':

const lockMode = validModes.includes(lockValue) ? lockValue : 'unlocked';

Correct behavior: When w:lock is absent, the lock mode should be represented as undefined or null internally, and no w:lock element should be written on export.

3. Correct export logic (passes validation)

File: packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-structured-content.js:69

Good news: The export logic correctly omits the w:lock element when lockMode === 'unlocked':

if (attrs.lockMode && attrs.lockMode !== 'unlocked') resultElements.push(lock);

This partially compensates for issue #1, but the internal representation still uses an invalid spec value.

Recommendations

  1. Remove 'unlocked' from valid modes - Use null/undefined to represent unlocked state
  2. Update the valid modes list to only include the three actual ECMA-376 values:
    const validModes = ['sdtLocked', 'contentLocked', 'sdtContentLocked'];
    const lockMode = validModes.includes(lockValue) ? lockValue : null;
  3. Update TypeScript types in packages/layout-engine/contracts/src/index.ts:59 to make lockMode nullable or remove 'unlocked' from the union

Reference

See ECMA-376 Part 1 §17.5.2.20 for full specification: https://ooxml.dev/spec?q=w:lock

The implementation is close but needs the "unlocked" value removed from the internal data model to fully comply with OOXML.

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
@tupizz tupizz force-pushed the feat/sdt-lock-support branch from 0234ac9 to 3e5377e Compare February 4, 2026 15:23
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.
…s 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.
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/.
@tupizz tupizz force-pushed the feat/sdt-lock-support branch from 270cf66 to fb4a38c Compare February 4, 2026 18:29
@tupizz tupizz marked this pull request as ready for review February 4, 2026 19:03
Copilot AI review requested due to automatic review settings February 4, 2026 19:03
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds comprehensive support for ECMA-376 w:lock functionality to StructuredContent nodes, enabling four lock modes (unlocked, sdtLocked, contentLocked, sdtContentLocked) that control whether the SDT wrapper can be deleted and whether the content can be edited. The implementation uses a three-layer defense strategy in the editor, full round-trip import/export support, and visual styling that differentiates lock modes.

Changes:

  • Added lockMode attribute to StructuredContent and StructuredContentBlock nodes with DOM parsing/rendering
  • Implemented lock enforcement plugin with handleKeyDown, handleTextInput, and filterTransaction hooks
  • Added DOCX import parsing and export generation for w:lock elements
  • Implemented visual styling with distinct background colors for each lock mode

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/super-editor/src/extensions/types/node-attributes.ts Added StructuredContentLockMode type definition
packages/super-editor/src/extensions/structured-content/structured-content.js Added lockMode attribute and registered lock plugin
packages/super-editor/src/extensions/structured-content/structured-content-block.js Added lockMode attribute definition
packages/super-editor/src/extensions/structured-content/structured-content-lock-plugin.js Implemented three-layer lock enforcement with step relationship analysis
packages/super-editor/src/extensions/structured-content/structured-content-lock-plugin.test.js Comprehensive test suite with 35 tests covering all lock modes and scenarios
packages/super-editor/src/extensions/structured-content/StructuredContentViewBase.js Added lock detection and CSS class application methods
packages/super-editor/src/extensions/structured-content/StructuredContentInlineView.js Integrated updateContentEditability in view lifecycle
packages/super-editor/src/extensions/structured-content/StructuredContentBlockView.js Integrated updateContentEditability in view lifecycle
packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.js Added w:lock parsing with validation of lock mode values
packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.test.js Tests for parsing all lock mode values and defaults
packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-structured-content.js Added w:lock export generation and deduplication
packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-structured-content.test.js Tests for exporting lock modes and preventing duplication
packages/layout-engine/style-engine/src/index.ts Added lockMode to normalized StructuredContentMetadata
packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts Added lockMode data attribute rendering in SDT containers
packages/layout-engine/painters/dom/src/renderer.ts Added lockMode to dataset attributes and rendering
packages/layout-engine/painters/dom/src/styles.ts Added CSS styles for each lock mode with hover effects
packages/layout-engine/contracts/src/index.ts Added StructuredContentLockMode type to contracts

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +456 to +491
/* 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;
}
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.
Comment on lines +200 to +201
// Assert: check if SDT still exists (boundary crossing damages wrapper)
const sdtStillIntact = sdtNodeExists(newState.doc, 'structuredContent');
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.

Unused variable sdtStillIntact.

Suggested change
// Assert: check if SDT still exists (boundary crossing damages wrapper)
const sdtStillIntact = sdtNodeExists(newState.doc, 'structuredContent');
// Assert: check whether the document content changed

Copilot uses AI. Check for mistakes.
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fb4a38c0cd

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +149 to +153
const sdtNodes = collectSDTNodes(state.doc);
if (sdtNodes.length === 0) {
return true;
}

Choose a reason for hiding this comment

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

P1 Badge Map step positions when checking lock violations

The filterTransaction guard collects SDT ranges from the pre-transaction document, but each step.from/step.to is expressed in the document after prior steps have applied. In multi-step transactions (e.g., a programmatic command that inserts before an SDT and then deletes inside it), the later step’s coordinates will be shifted while the SDT ranges are not, so checkLockViolation can miss a deletion inside locked content. To keep the safety net accurate, map step positions back to the original doc or map SDT ranges forward for each step (e.g., via tr.mapping).

Useful? React with 👍 / 👎.

@tupizz tupizz self-assigned this Feb 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant