Skip to content

feat(super-editor): disable toolbar mutations inside content-locked SDTs (SD-3274)#3534

Merged
caio-pizzol merged 55 commits into
luccas/sd-3116-bug-image-set-as-presetcontent-in-sdt-field-does-not-persistfrom
luccas/sd-3274-disable-styling-toolbar-when-selection-includes-a-locked-sdt
May 27, 2026
Merged

feat(super-editor): disable toolbar mutations inside content-locked SDTs (SD-3274)#3534
caio-pizzol merged 55 commits into
luccas/sd-3116-bug-image-set-as-presetcontent-in-sdt-field-does-not-persistfrom
luccas/sd-3274-disable-styling-toolbar-when-selection-includes-a-locked-sdt

Conversation

@luccas-harbour
Copy link
Copy Markdown
Contributor

Summary

  • When the selection touches a structured content (SDT) node with lockMode: contentLocked or sdtContentLocked, the headless toolbar now reports every mutation control (bold, italic, underline, strike, font-size, font-family, text-color, highlight, link, copy-format, alignment, line-height, linked-styles, lists, table actions, document operations, track-changes accept/reject) as disabled and their state derivers short-circuit before reading editor state.
  • Hardens execute() on createHeadlessToolbar: any command the current snapshot reports as disabled is refused, and for commands not present in the configured registry the controller still evaluates the registry entry against the live snapshot so callers can't bypass the lock by invoking an id that isn't surfaced as a button. View-only controls (undo, redo, ruler, formatting-marks, zoom, document-mode) are explicitly exempted so they remain usable inside locked content.
  • Adds a single hasContentLockedStructuredContentSelection predicate in helpers/context.ts that walks the selection ancestry, NodeSelection target, and nodesBetween range so range selections that cross into a content-locked SDT are also caught.
  • Extracts STRUCTURED_CONTENT_NODE_TYPES / isStructuredContentNodeType into a shared module under extensions/structured-content/nodeTypes.js, reused by the view-mode selection helper and by the new toolbar context predicate to keep the set of recognized SDT node types in one place.
  • Splits the existing isCommandDisabled (viewing-mode gate) from a new isMutationCommandDisabled (viewing-mode or content-locked SDT selection), and routes every mutation deriver through the new helper while leaving non-mutation derivers on the original gate.

…locked SDTs

Add a hasContentLockedStructuredContentSelection helper that walks
the selection's ancestor chain, NodeSelection target, and range, then
gate formatting, paragraph, list, table, link, image, and
track-changes derivers through a new isMutationCommandDisabled. View
controls (undo/redo, ruler, zoom, document-mode, formatting marks)
keep their existing rules. Covered by unit tests across lock modes
and selection shapes plus a behavior spec.
@luccas-harbour luccas-harbour requested a review from a team as a code owner May 27, 2026 17:04
@linear-code
Copy link
Copy Markdown

linear-code Bot commented May 27, 2026

SD-3274

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

cubic analysis

1 issue found across 14 files

Linked issue analysis

Linked issue: SD-3274: Disable styling toolbar when selection includes a locked SDT

Status Acceptance criteria Notes
Inline text-styling controls (bold, italic, underline, font size/family, text color, highlight, link, copy-format) are reported disabled when the selection includes a content-locked SDT Formatting derivers were routed through the new mutation-gate and now return disabled when a content-locked SDT is present. Unit tests and an e2e UI test assert the buttons/states are disabled.
Other mutation controls (lists, table actions, copy/clear-formatting, linked styles, track-changes accept/reject, etc.) are reported disabled when selection includes a content-locked SDT Registry entries for mutation commands are now gated by the same predicate; tests cover representative mutation commands (table-insert, copy-format, clear-formatting, linked-style, track-changes acceptance) and assert they are disabled.
Commands that are reported disabled cannot be executed (execute() refuses disabled commands and does not call underlying command implementations) createHeadlessToolbar now checks command execution eligibility (including content-lock) before invoking; unit test ensures controller.execute returns false and underlying command mocks are not called.
Selections that are NodeSelections, collapsed cursors inside locked block SDTs, or range selections that span into locked SDT content are detected and cause mutation controls to disable A single predicate (hasContentLockedStructuredContentSelection) walks selection ancestry, node selection target, and nodesBetween range to detect locked SDTs; tests cover node selection, collapsed cursor in block SDT, and span selections crossing into locked SDT.
View-only / document-level controls remain governed by existing rules and are not blocked by content-lock (e.g., undo/redo, ruler, formatting-marks, zoom, document-mode) createHeadlessToolbar exempts these IDs from content-lock execution blocking and tests assert these controls remain available per their normal rules.

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread packages/super-editor/src/headless-toolbar/helpers/paragraph.ts
Text alignment is a read-only state indicator that should remain visible
even when the selection sits inside a content-locked SDT. Switch the
text-align state deriver to isCommandDisabled so it only disables on a
truly read-only context, not on the mutation-blocked one.
Structured-content chrome labels share pm-start/pm-end ranges with their
underlying content. Without exclusion, DomPositionIndex could return the
label element for caret lookups, breaking hover and click-to-place
interactions over block SDTs.
Adds moveIntoBlockSdtBeforeTextBlockStart to the backspace chain so
that Backspace at the start of a textblock following a block SDT moves
the caret to the last text position inside the SDT instead of deleting
into protected content. Lifts findFirstTextPosInNode / findLastTextPosInNode
out of the table boundary navigation plugin into a shared helper module.
Adds moveIntoBlockSdtAfterTextBlockEnd to the delete chain so that
Delete at the end of a textblock preceding a block SDT moves the caret
to the first text position inside the SDT instead of deleting into
protected content. Mirrors the existing backspace-side handler.
resolveInsertionBoundary now sorts candidate boundaries by distance from
the requested target and falls back to side-of-bias only as a tie-break.
The drop path passes the mapped source start as a position to avoid, so
the moved node lands on the next nearest valid boundary instead of
snapping back to where it came from.
… labels

Consolidates structured content label rules so block and inline SDT
labels share a single declaration for size, padding, border, background,
and a new drag-handle ::before indicator. Scope-specific rules now only
carry positioning, border-radius, and the inline display: inline-flex
override.
luccas-harbour and others added 28 commits May 27, 2026 14:56
Undo/redo transactions were being blocked by the structured-content lock
plugin, preventing recovery of SDT content after deletion. Bypass the
lock guard when the transaction is a history undo or redo.
When backspace or delete targets an sdtContentLocked structured-content
SDT, collapse the selection to the wrapper boundary instead of letting
the keystroke fall through. Prevents the locked inline content from
being mutated while keeping the caret in a usable position for the next
edit.
Replace the invisible 8px spacer with a full "Click or tap here to enter text"
placeholder for both inline and block structured-content controls. The
placeholder is layout-only (no document text), styled via CSS pseudo-element,
and selected-node highlight inherits the system Highlight color.

Wire the new chrome into pointer mapping, caret geometry, and the input
manager so clicks on the placeholder land inside the SDT instead of snapping
to the wrapper boundary. ArrowLeft from the trailing boundary now re-enters
an empty inline SDT, and Backspace/Delete inside an unlocked inline SDT no
longer escapes into surrounding text.
Route empty block-SDT paragraphs through paragraphToFlowBlocks so the
placeholder run picks up the paragraph's resolved font, size, and color
instead of falling back to the document defaults. The painter now applies
those run styles to the placeholder span, keeping "Click or tap here to
enter text" visually consistent with the surrounding paragraph chrome.
Use `max-content` with `max-width: 130px` instead of stretching block SDT
labels to the chrome width, so short labels no longer span the entire
block. Inner span now flexes with `min-width: 0` to keep ellipsis behavior.
Stop maxing the measured placeholder width with the fallback — for empty
SDTs we now trust the measured value and only fall back when measurement
returns zero. Also treat empty-SDT placeholder runs as visible content so
chrome geometry (--sd-sdt-chrome-left/width) is emitted for them.
…int modes

Remove CSS rules that blanked the ::before content for empty SDT placeholders
in viewing and print modes so the placeholder prompt stays visible. Update
tests to assert the rules are absent.
…ments

fix(sdt): empty SDT placeholder text + cursor/keyboard interactions (SD-3237)
fix(super-editor): repair caret, keyboard, and drag interactions around block SDTs (SD-3237)
@caio-pizzol caio-pizzol merged commit 20fdcea into luccas/sd-3116-bug-image-set-as-presetcontent-in-sdt-field-does-not-persist May 27, 2026
5 checks passed
@caio-pizzol caio-pizzol deleted the luccas/sd-3274-disable-styling-toolbar-when-selection-includes-a-locked-sdt branch May 27, 2026 23:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants