Skip to content

fix(sdt): SDT field deletion and editing contract (SD-3218, SD-3165 and SD-3257)#3489

Merged
caio-pizzol merged 140 commits into
luccas/sd-3237-bug-sdt-hover-and-click-to-place-cursor-interactions-arefrom
luccas/sd-3218-sdt-field-deletion-contract
May 27, 2026
Merged

fix(sdt): SDT field deletion and editing contract (SD-3218, SD-3165 and SD-3257)#3489
caio-pizzol merged 140 commits into
luccas/sd-3237-bug-sdt-hover-and-click-to-place-cursor-interactions-arefrom
luccas/sd-3218-sdt-field-deletion-contract

Conversation

@luccas-harbour
Copy link
Copy Markdown
Contributor

@luccas-harbour luccas-harbour commented May 25, 2026

Note

This PR fixes SD-3218, SD-3165 and SD-3257

Summary

Hardens the deletion / typing / caret contract for inline structured-content (SDT) fields so editing across the wrapper boundary behaves consistently with Word and stops leaking into locked content.

Stacked on top of luccas/sd-3237-bug-sdt-hover-and-click-to-place-cursor-interactions-are; review against that branch.

What changes

Inline SDT deletion across the wrapper boundary

  • New commands selectInlineSdtBeforeRunStart / selectInlineSdtAfterRunEnd (packages/super-editor/src/editors/v1/core/commands/selectInlineSdtBeforeRunStart.js). When the caret sits at the start of the run after an inline SDT (Backspace) or the end of the run before one (Delete), the command installs a TextSelection over the SDT's inner content instead of letting run-aware deletion scan into the locked field.
  • Wired into the Backspace and Delete keymap chains (keymap.js).
  • For unlocked / contentLocked modes the next keystroke deletes the selected content; for sdtLocked / sdtContentLocked the command consumes the keystroke and the boundary stays put.
  • Selection is applied uniformly across all lock modes as a TextSelection (not a NodeSelection over the chrome), so labels/handles never get caught in the selection. A SELECT_INLINE_SDT_BEFORE_RUN_START_META meta flag tells the structured-content select plugin not to collapse this selection back to a TextSelection over the wrapper. (SD-3165)

contentLocked wrapper deletion

The lock plugin previously promoted exact-content selections covering a contentLocked SDT to a NodeSelection, requiring a second keystroke to delete. It now deletes the wrapper directly on Backspace/Delete. Cut still promotes to NodeSelection so PM's clipboard handler can serialize the wrapper in one keystroke (structured-content-lock-plugin.js).

Empty inline SDT placeholder

Empty inline SDTs were filtered out of layout entirely, leaving a zero-width invisible wrapper with no caret target. Introduces a layout-only placeholder run that flows end-to-end:

  • contracts: TextRun.visualPlaceholder = 'emptyInlineSdt' and an isEmptyInlineSdtPlaceholderRun guard. sliceRunsForLine preserves placeholders that have no chars.
  • pm-adapter: emit a placeholder run for inline structuredContent nodes with empty/missing content. mergeAdjacentRuns refuses to merge placeholders into neighbors.
  • measuring/dom: reserve an 8px inline box (0px for appearance='hidden') without taking the empty-paragraph code path.
  • painters/dom: render <span class="superdoc-empty-inline-sdt-placeholder" aria-hidden="true"> inside the SDT wrapper and tag the wrapper data-empty="true". CSS gives the wrapper a visible border without inflating line-box height (styles.ts).
  • DomSelectionGeometry: anchor the caret to the line's Y (the placeholder is height: 0) and always to its left edge.
  • lock plugin: collapsed Backspace/Delete inside an empty inline SDT deletes the wrapper when the lock mode allows.

beforeinput insertText at inline SDT boundaries

When the caret sits directly before or after an inline SDT, native beforeinput handling can drift the inserted character into the wrapper. editable.js now forces the manual insertText path for collapsed selections at those boundaries so the character lands on the outer side.

sdBlockRev on ancestors of inline edits

Text changes inside an inline SDT weren't bumping the containing paragraph's sdBlockRev because nodesBetween over the replace range didn't always visit the ancestor block. block-node.js now walks the ancestor chain at each changed range's boundaries.

The plugin's own metadata transaction is tagged with BLOCK_NODE_METADATA_UPDATE_META so it doesn't re-trigger appendTransaction and the lock plugin doesn't filter it out.

…ng run (SD-3165)

Adds a new selectInlineSdtBeforeRunStart command in the Backspace chain.
When the caret is at the start of a run whose previous sibling is an
inline structuredContent wrapper, Backspace now selects the wrapper as
a NodeSelection (for unlocked / contentLocked modes) so a subsequent
Backspace deletes it. For sdtLocked / sdtContentLocked wrappers the
command consumes the keystroke without changing the selection. The
structured-content select plugin ignores selections produced via the
new meta flag so it does not collapse the NodeSelection back to a
TextSelection.
Switch selectInlineSdtBeforeRunStart from a NodeSelection over the
whole wrapper to a TextSelection over the inner content, and apply
it uniformly across all lock modes. This avoids selecting the SDT
chrome and keeps Backspace inside the field boundary.
When text inside an inline structured-content field changes, the
containing paragraph's sdBlockRev was not incremented because
nodesBetween over the replace range did not always visit the
ancestor block. Walk up the ancestor chain at each changed range's
boundaries so the block-level paragraph gets a fresh revision.

Tag the plugin's own metadata transaction with a meta key so it
neither re-triggers the block-node appendTransaction nor gets
filtered out by the structured-content lock plugin.
When an exact content selection covers a contentLocked structured
content field and the user presses Backspace/Delete, delete the
wrapper directly instead of first promoting to a NodeSelection that
required a second keystroke. Cut still promotes to NodeSelection so
the browser can serialize the wrapper.

Tests are updated to assert single-step deletion and now cover Delete
in addition to Backspace.
Empty inline structured content used to be filtered out of the layout
runs entirely, so the wrapper had no width, no caret target, and the
field was effectively invisible. Introduce an `emptyInlineSdt` visual
placeholder run that flows end-to-end through the pipeline:

- contracts: `TextRun.visualPlaceholder` and an `isEmptyInlineSdtPlaceholderRun`
  guard; `sliceRunsForLine` preserves placeholders that have no chars.
- pm-adapter: emit a placeholder run for inline `structuredContent` nodes
  with empty/missing content, and skip merging it into neighboring text.
- measuring/dom: reserve an 8px inline box (0px for hidden-appearance)
  without taking the empty-paragraph code path.
- painters/dom: render a `<span class="superdoc-empty-inline-sdt-placeholder">`
  inside the inline SDT wrapper, tagged `data-empty="true"`, and add
  styles so the wrapper gets a visible affordance without inflating
  line-box height.
- DomSelectionGeometry: anchor the caret to the line's Y (the placeholder
  is height: 0) and always to its left edge.
- structured-content lock plugin: Backspace/Delete inside an empty inline
  SDT deletes the wrapper when its lock mode allows it.
…ndaries

When the caret sits directly before or after an inline structured content
node, native beforeinput handling can drift the inserted character into
the SDT. Force the manual insertText path for collapsed selections at
those boundaries so the character lands on the outer side of the wrapper.
Mirrors selectInlineSdtBeforeRunStart for forward deletion: when Delete
is pressed at the end of a run preceding an inline SDT, select the SDT
content as a TextSelection instead of falling through to generic
deletion. The structured-content lock plugin no longer consumes Delete
at the leading inline SDT boundary so the keymap can handle it.
…ragraph geometry

Move block SDT border styling onto a ::after pseudo-element with pointer-events:
none, and drop the 1px padding so the chrome no longer changes fragment
geometry. Default inline image verticalAlign to 'top' so the image box stays
within the measured line height.
When a paragraph line contains an inline image, set surrounding normal
text runs to lineHeight: 'normal' and verticalAlign: 'bottom' so they sit
beside the top-aligned image. Runs with explicit vertical positioning
(vertAlign, baselineShift) are left untouched.
Compute content bounds from rendered lines (honoring paragraph alignment)
and expose --sd-sdt-chrome-left/--sd-sdt-chrome-width on the fragment.
Hover background moves to a ::before pseudo-element; both ::before and
::after use the chrome vars so the frame hugs the content instead of
spanning the full fragment width.
luccas-harbour and others added 28 commits May 27, 2026 17:11
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)
…ling-toolbar-when-selection-includes-a-locked-sdt

feat(super-editor): disable toolbar mutations inside content-locked SDTs (SD-3274)
…et-as-presetcontent-in-sdt-field-does-not-persist

fix(super-editor): persist data-URI images set as SDT preset content (SD-3116)
…esizing-inside-structured-content

fix(sdt): block image resize inside locked SDTs and align chrome to content (SD-3258)
@caio-pizzol caio-pizzol merged commit 6b84f17 into luccas/sd-3237-bug-sdt-hover-and-click-to-place-cursor-interactions-are May 27, 2026
6 checks passed
@caio-pizzol caio-pizzol deleted the luccas/sd-3218-sdt-field-deletion-contract branch May 27, 2026 23:14
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.

3 participants