fix(sdt): SDT field deletion and editing contract (SD-3218, SD-3165 and SD-3257)#3489
Merged
caio-pizzol merged 140 commits intoMay 27, 2026
Conversation
…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.
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)
6b84f17
into
luccas/sd-3237-bug-sdt-hover-and-click-to-place-cursor-interactions-are
6 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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 aTextSelectionover the SDT's inner content instead of letting run-aware deletion scan into the locked field.keymap.js).contentLockedmodes the next keystroke deletes the selected content; forsdtLocked/sdtContentLockedthe command consumes the keystroke and the boundary stays put.TextSelection(not aNodeSelectionover the chrome), so labels/handles never get caught in the selection. ASELECT_INLINE_SDT_BEFORE_RUN_START_METAmeta flag tells the structured-content select plugin not to collapse this selection back to aTextSelectionover the wrapper. (SD-3165)contentLocked wrapper deletion
The lock plugin previously promoted exact-content selections covering a
contentLockedSDT to aNodeSelection, requiring a second keystroke to delete. It now deletes the wrapper directly on Backspace/Delete. Cut still promotes toNodeSelectionso 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:
TextRun.visualPlaceholder = 'emptyInlineSdt'and anisEmptyInlineSdtPlaceholderRunguard.sliceRunsForLinepreserves placeholders that have no chars.structuredContentnodes with empty/missing content.mergeAdjacentRunsrefuses to merge placeholders into neighbors.appearance='hidden') without taking the empty-paragraph code path.<span class="superdoc-empty-inline-sdt-placeholder" aria-hidden="true">inside the SDT wrapper and tag the wrapperdata-empty="true". CSS gives the wrapper a visible border without inflating line-box height (styles.ts).height: 0) and always to its left edge.beforeinput insertText at inline SDT boundaries
When the caret sits directly before or after an inline SDT, native
beforeinputhandling can drift the inserted character into the wrapper.editable.jsnow forces the manualinsertTextpath for collapsed selections at those boundaries so the character lands on the outer side.sdBlockRevon ancestors of inline editsText changes inside an inline SDT weren't bumping the containing paragraph's
sdBlockRevbecausenodesBetweenover the replace range didn't always visit the ancestor block.block-node.jsnow walks the ancestor chain at each changed range's boundaries.The plugin's own metadata transaction is tagged with
BLOCK_NODE_METADATA_UPDATE_METAso it doesn't re-triggerappendTransactionand the lock plugin doesn't filter it out.