Skip to content

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

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

feat(super-editor): add w:lock support for StructuredContent nodes#1939
tupizz wants to merge 7 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).

Also adds grouped hover for multi-fragment block SDTs, stale fragment rebuild detection, and SDT utility extraction for better code organization.

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

Grouped Hover for Multi-Fragment Block SDTs

When a block SDT spans multiple paragraphs, each renders as a separate DOM fragment. Previously, CSS :hover only highlighted the individual fragment under the cursor. Now all fragments of the same SDT highlight simultaneously via JavaScript event delegation:

  • SdtGroupedHover class (utils/sdt-hover.ts): Event delegation on mount container using mouseover/mouseleave. Finds all fragments sharing the same data-sdt-id and toggles .sdt-hover CSS class on all of them.
  • Post-render re-apply: After DomPainter re-renders pages (e.g. during editing), newly created DOM elements lose the .sdt-hover class. SdtGroupedHover.reapply() is called at the end of updateVirtualWindow() to restore the class on all matching fragments.
  • CSS: Block SDT rules changed from :hover to .sdt-hover selector. Inline SDTs remain :hover-based (single element, no grouping needed).

Stale Fragment Rebuild Detection

  • shouldRebuildForSdtBoundary (moved to utils/sdt-helpers.ts): Detects when a fragment's SDT boundary attributes (data-sdt-container-start/end) are stale and need rebuilding. Now correctly handles the case where a fragment leaves an SDT group (attributes present but should be removed).
  • mappingUnreliable check: After a full-document paste of identical content, ProseMirror's transaction mapping becomes degenerate — it maps all old positions to the insertion end. This corrupted data-pm-start/data-pm-end attributes on reused spans, breaking cursor/click navigation. The new check verifies mapping.map(oldPmStart) === fragment.pmStart and forces a full fragment rebuild when the mapping is unreliable.

Code Organization

  • Extracted SdtGroupedHover class from renderer.ts into utils/sdt-hover.ts
  • Moved shouldRebuildForSdtBoundary from renderer.ts into utils/sdt-helpers.ts
  • Reduced renderer.ts SDT hover footprint from ~65 lines to 4 one-liner calls

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 — Grouped hover binding, post-render reapply, mapping reliability check
painter-dom styles.ts — Block SDT hover uses .sdt-hover class, updated label styling
painter-dom utils/sdt-helpers.ts — Add shouldRebuildForSdtBoundary, fix stale attribute handling
painter-dom utils/sdt-hover.tsNew SdtGroupedHover class for multi-fragment hover
super-editor Extensions — lockMode attribute for inline and block nodes
super-editor NodeViews — CSS class toggling for lock state visual feedback
super-editor Lock plugin — structured-content-lock-plugin.js (169 lines)
super-editor Lock tests — structured-content-lock-plugin.test.js (416 lines, 35 tests)
super-editor Import/Export — Parse and generate w:lock element
super-editor Import/Export tests — Round-trip tests for lock modes
super-editor Types — node-attributes.ts — Add lockMode to type interface

Commit History

Commit Description
3e5377e7 feat: Initial w:lock support — attribute, import/export, lock plugin, NodeView, styling, tests
d04f6c6a perf: Optimize lock plugin to use nodesBetween() on changed ranges instead of full descendants()
786e5b77 fix: Clamp nodesBetween range to valid document bounds
9137b9c5 refactor: Remove unused isSdtLocked() method and updateLockStateClasses() (no CSS rules existed)
fb4a38c0 fix: Switch from contentEditable=false to plugin-only defense for cursor movement UX
0646a2f4 feat: Grouped hover for multi-fragment SDTs, stale fragment rebuild detection, code extraction

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
  • Grouped hover highlights all fragments of multi-line block SDTs
  • Hover clears correctly on mouse leave
  • Editing inside hovered SDT preserves hover on new fragments
  • Full-document paste (Cmd+A, Cmd+C, Cmd+V) preserves cursor navigation

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.

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".

@tupizz tupizz self-assigned this Feb 4, 2026
…lity

- 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.
# Conflicts:
#	packages/layout-engine/painters/dom/src/renderer.ts
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