Block-based editing: selection, drag-and-drop, and actions menu - #14
Open
jondkinney wants to merge 288 commits into
Open
Block-based editing: selection, drag-and-drop, and actions menu#14jondkinney wants to merge 288 commits into
jondkinney wants to merge 288 commits into
Conversation
jondkinney
force-pushed
the
block-editing-standalone
branch
6 times, most recently
from
April 3, 2026 18:28
550b212 to
502e84a
Compare
* Bump the lexical group with 16 updates Bumps the lexical group with 16 updates: | Package | From | To | | --- | --- | --- | | [@lexical/clipboard](https://github.com/facebook/lexical/tree/HEAD/packages/lexical-clipboard) | `0.41.0` | `0.42.0` | | [@lexical/code](https://github.com/facebook/lexical/tree/HEAD/packages/lexical-code) | `0.41.0` | `0.42.0` | | [@lexical/extension](https://github.com/facebook/lexical/tree/HEAD/packages/lexical-extension) | `0.41.0` | `0.42.0` | | [@lexical/history](https://github.com/facebook/lexical/tree/HEAD/packages/lexical-history) | `0.41.0` | `0.42.0` | | [@lexical/html](https://github.com/facebook/lexical/tree/HEAD/packages/lexical-html) | `0.41.0` | `0.42.0` | | [@lexical/link](https://github.com/facebook/lexical/tree/HEAD/packages/lexical-link) | `0.41.0` | `0.42.0` | | [@lexical/list](https://github.com/facebook/lexical/tree/HEAD/packages/lexical-list) | `0.41.0` | `0.42.0` | | [@lexical/markdown](https://github.com/facebook/lexical/tree/HEAD/packages/lexical-markdown) | `0.41.0` | `0.42.0` | | [@lexical/plain-text](https://github.com/facebook/lexical/tree/HEAD/packages/lexical-plain-text) | `0.41.0` | `0.42.0` | | [@lexical/rich-text](https://github.com/facebook/lexical/tree/HEAD/packages/lexical-rich-text) | `0.41.0` | `0.42.0` | | [@lexical/selection](https://github.com/facebook/lexical/tree/HEAD/packages/lexical-selection) | `0.41.0` | `0.42.0` | | [@lexical/table](https://github.com/facebook/lexical/tree/HEAD/packages/lexical-table) | `0.41.0` | `0.42.0` | | [@lexical/utils](https://github.com/facebook/lexical/tree/HEAD/packages/lexical-utils) | `0.41.0` | `0.42.0` | | [lexical](https://github.com/facebook/lexical/tree/HEAD/packages/lexical) | `0.41.0` | `0.42.0` | | [@lexical/dragon](https://github.com/facebook/lexical/tree/HEAD/packages/lexical-dragon) | `0.41.0` | `0.42.0` | | [@lexical/text](https://github.com/facebook/lexical/tree/HEAD/packages/lexical-text) | `0.41.0` | `0.42.0` | Updates `@lexical/clipboard` from 0.41.0 to 0.42.0 - [Release notes](https://github.com/facebook/lexical/releases) - [Changelog](https://github.com/facebook/lexical/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/lexical/commits/v0.42.0/packages/lexical-clipboard) Updates `@lexical/code` from 0.41.0 to 0.42.0 - [Release notes](https://github.com/facebook/lexical/releases) - [Changelog](https://github.com/facebook/lexical/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/lexical/commits/v0.42.0/packages/lexical-code) Updates `@lexical/extension` from 0.41.0 to 0.42.0 - [Release notes](https://github.com/facebook/lexical/releases) - [Changelog](https://github.com/facebook/lexical/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/lexical/commits/v0.42.0/packages/lexical-extension) Updates `@lexical/history` from 0.41.0 to 0.42.0 - [Release notes](https://github.com/facebook/lexical/releases) - [Changelog](https://github.com/facebook/lexical/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/lexical/commits/v0.42.0/packages/lexical-history) Updates `@lexical/html` from 0.41.0 to 0.42.0 - [Release notes](https://github.com/facebook/lexical/releases) - [Changelog](https://github.com/facebook/lexical/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/lexical/commits/v0.42.0/packages/lexical-html) Updates `@lexical/link` from 0.41.0 to 0.42.0 - [Release notes](https://github.com/facebook/lexical/releases) - [Changelog](https://github.com/facebook/lexical/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/lexical/commits/v0.42.0/packages/lexical-link) Updates `@lexical/list` from 0.41.0 to 0.42.0 - [Release notes](https://github.com/facebook/lexical/releases) - [Changelog](https://github.com/facebook/lexical/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/lexical/commits/v0.42.0/packages/lexical-list) Updates `@lexical/markdown` from 0.41.0 to 0.42.0 - [Release notes](https://github.com/facebook/lexical/releases) - [Changelog](https://github.com/facebook/lexical/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/lexical/commits/v0.42.0/packages/lexical-markdown) Updates `@lexical/plain-text` from 0.41.0 to 0.42.0 - [Release notes](https://github.com/facebook/lexical/releases) - [Changelog](https://github.com/facebook/lexical/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/lexical/commits/v0.42.0/packages/lexical-plain-text) Updates `@lexical/rich-text` from 0.41.0 to 0.42.0 - [Release notes](https://github.com/facebook/lexical/releases) - [Changelog](https://github.com/facebook/lexical/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/lexical/commits/v0.42.0/packages/lexical-rich-text) Updates `@lexical/selection` from 0.41.0 to 0.42.0 - [Release notes](https://github.com/facebook/lexical/releases) - [Changelog](https://github.com/facebook/lexical/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/lexical/commits/v0.42.0/packages/lexical-selection) Updates `@lexical/table` from 0.41.0 to 0.42.0 - [Release notes](https://github.com/facebook/lexical/releases) - [Changelog](https://github.com/facebook/lexical/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/lexical/commits/v0.42.0/packages/lexical-table) Updates `@lexical/utils` from 0.41.0 to 0.42.0 - [Release notes](https://github.com/facebook/lexical/releases) - [Changelog](https://github.com/facebook/lexical/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/lexical/commits/v0.42.0/packages/lexical-utils) Updates `lexical` from 0.41.0 to 0.42.0 - [Release notes](https://github.com/facebook/lexical/releases) - [Changelog](https://github.com/facebook/lexical/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/lexical/commits/v0.42.0/packages/lexical) Updates `@lexical/dragon` from 0.41.0 to 0.42.0 - [Release notes](https://github.com/facebook/lexical/releases) - [Changelog](https://github.com/facebook/lexical/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/lexical/commits/v0.42.0/packages/lexical-dragon) Updates `@lexical/text` from 0.41.0 to 0.42.0 - [Release notes](https://github.com/facebook/lexical/releases) - [Changelog](https://github.com/facebook/lexical/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/lexical/commits/v0.42.0/packages/lexical-text) --- updated-dependencies: - dependency-name: "@lexical/clipboard" dependency-version: 0.42.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: lexical - dependency-name: "@lexical/code" dependency-version: 0.42.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: lexical - dependency-name: "@lexical/extension" dependency-version: 0.42.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: lexical - dependency-name: "@lexical/history" dependency-version: 0.42.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: lexical - dependency-name: "@lexical/html" dependency-version: 0.42.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: lexical - dependency-name: "@lexical/link" dependency-version: 0.42.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: lexical - dependency-name: "@lexical/list" dependency-version: 0.42.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: lexical - dependency-name: "@lexical/markdown" dependency-version: 0.42.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: lexical - dependency-name: "@lexical/plain-text" dependency-version: 0.42.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: lexical - dependency-name: "@lexical/rich-text" dependency-version: 0.42.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: lexical - dependency-name: "@lexical/selection" dependency-version: 0.42.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: lexical - dependency-name: "@lexical/table" dependency-version: 0.42.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: lexical - dependency-name: "@lexical/utils" dependency-version: 0.42.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: lexical - dependency-name: lexical dependency-version: 0.42.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: lexical - dependency-name: "@lexical/dragon" dependency-version: 0.42.0 dependency-type: indirect update-type: version-update:semver-minor dependency-group: lexical - dependency-name: "@lexical/text" dependency-version: 0.42.0 dependency-type: indirect update-type: version-update:semver-minor dependency-group: lexical ... Signed-off-by: dependabot[bot] <support@github.com> * Upgrade CI Node.js from 20 to 22 Lexical 0.42.0 uses Iterator helpers (Map.entries().map()) in its table module, which requires Node 22+. This was causing JS unit test failures in CI on Node 20. --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jorge Manrubia <jorge@37signals.com>
Unwrap link dialog form
Bugs filed against Lexxy sometimes have their root cause in the host app (BC5 modal behavior, server-side preview rendering, Turbo frame interactions). Updated the Fixing Bugs workflow to classify these separately and redirect to the /bugs-fix skill from basecamp/coworker.
Tweak editor styling in docs
PRs from the bug-fixing workflow should be created as drafts so they can be reviewed and validated before being marked ready for merge.
`tag` can be an array, at which point PASTE_TAG would not be detected with simple equality
Move DOM insertion via insertAtCursor
Th class will respond to Selection's insertNodes for interchangeability. Then, the inserters can self-declare if they handle a selection scenario
Allows RangeSelection insertion to fall back to default insertion
When pasting non-inline nodes, Lexical will split the receiving node and insert them. For QuoteNode, we want to simply insert the nodes into the QuoteNode after the selection.
Selecting multiple lines and pressing the Code block button was creating one code block per line instead of a single code block containing all selected lines. The old implementation used $setBlocksType which wraps each block individually. Rewrite toggleCodeBlock() to collect all top-level elements in the selection and merge them into a single CodeNode separated by line breaks, mirroring how toggleBlockquote() already works. Toggle-off splits the code block content at line breaks back into separate paragraphs.
When toggling a code block on a blockquote (or list) containing multiple paragraphs, the content was collapsed into a single line because #applyCodeBlockFormat only iterated top-level elements without descending into containers. Two fixes: - In toggleCodeBlock, unwrap non-paragraph/non-code container elements (using the existing #unwrap method) and re-gather top-level elements before merging into a code block. - In hasSelectedWordsInSingleLine, detect when anchor and focus are in different block-level children of the same top-level element (e.g. two paragraphs inside a blockquote) so the code button correctly triggers code block mode instead of inline code.
Essentially, don't make a code node out of top-level decorators
Theory: toggling format is simply moving existing nodes into a new node of the target format... no difference from pasting them etc. This makes the code toggle do just that: use a CodeNodeInserter and just insert the block-level selected nodes into a new CodeNode
show() short-circuits when the indicator would move less than 5px in both axes, avoiding flicker between adjacent "after A" / "before B" targets. The early-return came before the writes that update data-depth and data-listType — CSS keys off these attributes ([data-list-type="ol"] renders #., [data-depth="0"] hides the bullet circle), so when the cursor crosses a UL→OL boundary at the same visual Y, the marker glyph remained stale until the next 5px move. Reorder the body so style.right, --indicator-gap, dataset.depth, dataset.listType, and the visible class always update; only top/left rewrites stay gated by the small-move guard.
#buildColorSubmenu rendered the entire panel via innerHTML string interpolation. The "Last used" branch interpolated last.style, last.value, and last.label — all read from localStorage. Any prior same-origin XSS or any code path that wrote user-controlled values to that key would persist a payload that fires every time the menu was built, escalating a transient XSS to persistent. Even absent an attacker, a label containing " or </button> would corrupt the panel's DOM. Rewrite the function to build buttons with createElement + textContent + dataset + style.setProperty so untrusted values escape by construction. Extract #colorButton and #colorSectionLabel helpers to keep the structure readable.
The header's download anchor and the generic-fallback download link both passed `download: fileName || true` to createElement, which forwarded to setAttribute. setAttribute string-coerces booleans, so the rendered HTML became download="true" when fileName was empty — the browser then saved the file as "true" with no extension. Use download: fileName || "" instead. The empty-string form keeps the attribute (download semantics preserved) and lets the browser fall back to Content-Disposition / URL path for the actual filename.
Two call sites passed `{ tag: "history-push" }` as a string literal
instead of HISTORY_PUSH_TAG, which the file already imports from
lexical and uses correctly elsewhere. The literal value matches the
constant today (history-push), but using the constant guards against
a future Lexical release silently changing the internal string.
defineElements() unconditionally called document.body.appendChild(...) at the end of the function. If a host app loads Lexxy from <head> without defer/module — uncommon for Lexxy directly but reachable via app-manifest setups that include the bundle before body parses — document.body is null at execution time and the call throws TypeError, preventing every other Lexxy custom element from registering for the rest of the document. Guard with a DOMContentLoaded fallback so head-loaded scripts work too.
$wrapInlineQuoteChildren originally treated every non-ElementNode as inline (push to run, wrap in a paragraph). That mis-classifies in two directions. Inline ElementNodes — LinkNode, AutoLinkNode, MarkNode — report \$isElementNode === true but isInline() === true. The old check treated them as block separators, so a markdown shortcut like \`> [foo](http://x)\` left the LinkNode as a direct quote child, unwrapped, and Enter still split the quote (the bug the transform was meant to fix). DecoratorNodes — attachments, images, HRs, embeds — are leaf nodes ($isElementNode === false), so the old check pushed them into a run and wrapped them in a paragraph. Block-rendered decorator figures aren't valid paragraph children: dragging an attachment into a quote threw Lexical error #14 from the reconciler. Replace the predicate with an explicit three-arm classification: DecoratorNode → block separator, ElementNode → use isInline(), leaf → inline. Both shapes now resolve correctly: Link/Mark get wrapped alongside surrounding text; decorators stay as direct children.
Dragging an attachment down through a blockquote left a stray <br> inside the quote: the browser inserts a literal <br> into the <blockquote> as a side effect of contenteditable drop, and Lexical accepts it as a LineBreakNode direct child of the QuoteNode. Soft breaks belong inside a paragraph (Shift+Enter creates them there), so a LineBreakNode at the quote-direct-child level is always a structural artifact. Strip them at the top of $wrapInlineQuoteChildren — before the wrap loop and the empty-children paragraph backfill — so the quote returns to a clean state on the next reconciliation. Don't wrap the LineBreakNode in a paragraph: that converts the stray <br> into <p><br></p>, also visible as an empty line. Removal is strictly correct because the transform doesn't descend into paragraphs, so real soft breaks the user typed (always inside a paragraph) aren't touched.
The editor's data-blob-url-template was generated with rails_service_blob_url, which embeds default_url_options[:host] in the resulting URL. New attachments saved via Lexxy then carried that host (and port) into the persisted <action-text-attachment url=...> HTML. Moving the host app to a different port or host (dev → staging, :3003 → :3000) made every saved attachment 404 on the edit page, because the JS editor reads the stored url verbatim into <img src>. Show pages weren't affected — Action Text re-renders attachments via the _blob.html.erb partial at request time, regenerating URLs from the SGID against the current host. Switch to rails_service_blob_path. The browser resolves the relative path against the current origin, so saved URLs survive env moves. Existing rows need a one-shot backfill (separate; see runner) to strip already-baked hosts.
Dragging an attachment down through a blockquote leaves a stray <br>
as a direct child of the <blockquote>. It isn't in Lexical's
model — $isLineBreakNode doesn't see it, exportDOM serializes
clean — so this is a transient render-only artifact, likely a
reconciler placeholder. Saved HTML is unaffected.
display:none alone isn't enough: the existing
"> :last-child { margin-block-end: 0 }" rule depends on :last-child
matching the real content block, but with a stray <br> appended,
:last-child matches the <br>. The preceding <p> then keeps its 16px
bottom margin, leaving a visible gap that looks like an extra empty
paragraph.
Pair display:none on the <br> with :has(+ br:last-child) on the real
last content block to flatten its bottom margin too. The two rules
together collapse the gap without touching the contenteditable DOM
(which Lexical owns; fighting its reconciler with our own observer
risks loops).
#moveTopLevelBlock had a "skip empty separator paragraphs" loop that fired only for DecoratorNodes — true for HR/image/attachment, but not for WrappedTableNode (an ElementNode that overrides canInsertTextBefore/After to return false). Lexical's ProvisionalParagraphNode.neededBetween triggers on any gap whose endpoints both fail \`$isSelectableElement\`, not just decorator- decorator gaps; so a Table next to an Attachment gets a separator between them. Without the skip, moving the Table only swaps it past the invisible separator, the editor re-inserts a fresh separator on the other side, and the user perceives Cmd+Shift+Down as frozen. Generalize the predicate from $isDecoratorNode(node) to "$isDecoratorNode(node) OR ($isElementNode(node) && !canInsertTextBefore() && !canInsertTextAfter())". The skip-loop body stays the same.
WrappedOriginTracker stored only the listItem's key, but Lexical
clones nodes via getWritable() during nearly every tree mutation
(insertBefore, append, remove). The post-clone listItem has a fresh
key; the marker on the original orphan-key got dropped by the next
resync(), and isUser() fell through to "treat as user-wrapped" —
breaking transient pass-through scenarios like a Table or Attachment
moving through a list and exiting the other side. Symptom shape: only
some nodes in a multi-node group appeared to lose their tracking,
because some hit Lexical's "already writable" fast path while others
reliably triggered cloning.
Two compounding mechanisms drove this:
1. The replace rule ListItemNode → EarlyEscapeListItemNode assigns
a fresh key the first time the new listItem is appended. The
existing #pendingKeyResolutions mechanism handled selection /
anchor / focus rewrites but didn't transfer wrappedOrigins
membership.
2. Subsequent generic getWritable() calls during arrow-movement
mutations clone the listItem again with another new key. No hook
fired to migrate the marker.
Two layered fixes:
- replaceKey(oldKey, newKey) on the tracker, called from
#resolvePendingKeys alongside the existing selection rewrites.
This handles the post-replacement transfer.
- Inner-content-key fallback (#userInnerKeys / #movementInnerKeys).
Per-tracked listItem we ALSO record its first non-list child's
key. The inner block (heading, table, attachment) doesn't get
cloned when its parent listItem is, so the inner key stays valid
across arbitrary copy-on-write chains. isUser / isWrapped /
hasMovementKey consult the inner-key set when the listItem's own
key has gone stale.
API change: trackUser, trackMovement, untrack, hasMovementKey now
accept a ListItemNode (preferred) or a key string (legacy). Node
calls populate both the outer- and inner-key sets; key calls fall
back to a $getNodeByKey lookup where possible.
#promoteListItem's nested-list-to-root-promotion branch called #promoteWrappedBlockThroughRoot for any wrapped block, which skips the root-level sibling-between position to either nest directly into the adjacent sibling or exit the list. That's the right behavior for USER-wrapped items (Turn-into headings/quotes/etc. — preserving the list's visual rhythm of bullets-only) but wrong for MOVEMENT-wrapped items (a transient attachment drifting through a list — the user expects to pause at every position). Symptom: a single attachment Cmd+Shift+Down through [One, Two, Three, Four] skipped the sibling-between positions, jumping straight from "nested under N" to "nested under N+1". Regular paragraphs and groups containing the attachment alternated correctly. Gate on isUser instead. Movement-wrapped items now fall through to the standard promotion path (listParent.insertAfter(node)), which inserts at the parent list level — the sibling-between position the user expects.
A group of root-level blocks (e.g., [attachment, h2]) entering a
blockquote went through code paths that did node.remove() +
target.append(node) / firstChild.insertBefore(node) per item without
rewriting #selectedBlockKeys / #anchorKey / #focusKey / wrappedOrigins
to the post-COW keys. The remove/append cycle triggers Lexical's
getWritable() cloning; the JS reference still points at the original
(now-orphaned) instance with its stale key, so the selection class
landed on a node that no longer exists in the tree.
Symptom: in a multi-node group, one node visibly lost its highlight
while peers kept theirs. Which node "lost" depended on which one hit
Lexical's already-writable fast path versus reliably triggering a
clone — DecoratorNodes (attachments) were especially prone.
Add a #rewriteGroupKeysAfterMove(group, oldKeys, skipIndices) helper
that captures pre-move keys, then uses node.getLatest().getKey() to
resolve each post-COW key and rewrites the four pieces of session
state. Apply at three sites:
- #moveGroupAtomically root-level group entering a quote (bare
detach-and-place loop).
- #enterGroupIntoQuote targetList branch (merging into existing
same-type list inside the quote).
- #enterGroupIntoQuote lazy-list branch, with skipIndices for
entries that took the unwrap-to-inner-block path (those already
migrate keys themselves LI → inner-block).
The Notion-style wrapped-quote case (LI containing a Quote) reaches
#enterGroupIntoQuote via #nestListItemUnderSibling, so this fix
covers both the regular root-level quote and the wrapped-quote
container.
#movementWrappedInnerBlockToUnwrap decided whether to "unwrap" a movement-wrapped LI's contents into a quote based on \`every(c => !$isElementNode(c) || c.isInline())\`. DecoratorNodes (attachments, images, HRs) are leaf nodes, so !$isElementNode is true — the predicate falsely classified them as inline content. The function then wrapped the attachment in a <paragraph>, producing <blockquote><p><figure>...</figure></p></blockquote> — invalid HTML. The browser auto-closes the <p> before the <figure>, leaving an empty <p> (which holds the selection class) and the figure as a sibling. Visual symptom: in [attachment, h2] entering a quote, the attachment looks deselected while the h2 stays selected. Early-return null when any child is a DecoratorNode. The LI keeps its block decorator wrapped and falls through to the keep-as-list- item branch in #enterGroupIntoQuote, which places it in a fresh sub- list inside the quote. The LI gets the selection class, which visually covers the figure. Same misclassification trap as $wrapInlineQuoteChildren — see lexical-inline-classification-decorator-trap skill for the audit checklist.
The OL drop indicator's "#." marker mirrors li[data-wrapper-marker= "number"]::before — same 2em right-aligned box, absolute positioning, inline-flex centering — so the period lines up vertically and horizontally with the wrapped-block markers above it. The JS side leaves bulletLeft at the LI's left edge for OL targets so the CSS 2em box renders inside the counter column instead of floating to the left of the rendered "1." / "2." / "3.". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The drop indicator's bullet vs "#." glyph should match the marker the dropped item visually follows from — the LI directly ABOVE the drop position, not the resolvedBlock itself. For "before X", that's X's previous LI sibling, not X. Without this, dropping at the boundary between a wrapped block (wrapper-marker="number") and a regular text LI in a UL flickered between "#." and "•" depending on which side of the gap the hit-tester resolved to. Pass position through #effectiveListType so both paths target the same item and return the same glyph. Reads data-wrapper-marker on the target LI as the source of truth when set, else falls back to the closest list's tag — text LIs in a UL show "•", text LIs in an OL show "#.". Walking back through siblings would lie about what marker the dropped item inherits, so it's deliberately not done. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the user drags an item and the snap resolves to "after X" where X is directly above the dragged item, the drop is a no-op (same sibling position the item already occupies). Showing the indicator there is misleading. Hide it unless the gesture is a multi-level outdent (snap.depth < targetDepth), which is still meaningful even on the own-row Y. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The drag ghost's OL builds a fresh list whose counter restarts at 1,
so the rendered "1." has no relationship to the dragged item's
original position ("2.", "3.", etc.) and lying to the user about
where it'll land. Replace the native counter with "#." aligned in
the same 2em right-aligned column the in-editor OL counter uses, so
the ghost matches the indicator's "#." semantics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Turn into items always render — never hidden — so the menu has a
stable, predictable layout across selections. Items that don't apply
in the current state render as disabled (already that wrapper, or
the combination is unsupported).
Plain text paragraphs at root use plain labels ("Bullet list",
"Numbered list", "Quote") since the conversion just changes content
type — no inner block survives. Other blocks (headings, code, etc.)
use "Wrap in X" labels because the inner block is preserved.
The top-level "Remove Bullet" / "Remove Numbered" button now arms
for any list item, plain text or wrapped — both eject through the
same #extractContentToRoot path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t LI Lexxy lists are homogeneous — every item shares the parent's marker style — so swapping a single text LI's type via setListItemType without changing the parent UL/OL produces no visible change. Match the wrapped-LI branch by setting the parent list's type for plain text items too. Mixed-typed lists (per-LI data-list-item-type) can refine this later; until then a single swap propagates to the whole list, which is the intuitive outcome under homogeneous semantics. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same-type list command on an LI is now a no-op — the top-level "Remove Bullet"/"Remove Numbered" button is the canonical way to eject. The same-state peel-out branch was unsafe under multi- selection: the first item's swap mutated the parent list to the new type, then subsequent items in that list saw "same type" and took the peel-out path, emptying the list mid-iteration and triggering Lexical error basecamp#66 from a stale rootList.insertAfter. Drops the now-unused #unwrapWrappedLiToRootInPlace shim. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Source list item (text or wrapped) becomes a plain paragraph at root, splitting the surrounding list around it so siblings stay in place. Wrapped LIs (heading/code/decorator inside) first get their wrapper stripped so the inline content lives in the LI, then the LI is ejected via #splitAndExtractToRoot. #splitAndExtractToRoot gets a text-LI fallback: when the focused LI has no wrapped child, wrap its inline children in a fresh ParagraphNode and use that as the extracted block. This single change also lights up Remove Bullet/Numbered for plain text LIs (the existing #extractContentToRoot path delegates to the same helper). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…removed When the focused LI is the only remaining descendant of its root list (after trailing siblings were detached into the after-list), node.remove() empties the root list. ListNode.canBeEmpty() is false, so Lexical's cascade auto-detaches the empty root list — leaving the helper's snapshot of `rootList` parentless. The subsequent rootList.insertAfter(extracted) then throws Lexical error basecamp#66 from getParentOrThrow. Capture rootList's parent and next-sibling BEFORE node.remove() and fall back to placing the extracted content via the next-sibling anchor (or appending to root) when rootList itself has been cascaded away. Surfaces under multi-selection Turn into → Text and Remove Bullet, where ejecting items can drain the source list to empty in one update. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Was driven by #focusKey only — multi-selection only ejected the focused (last-selected) item. Now iterates every key in #selectedBlockKeys in document order, ejecting each list item to root via #splitAndExtractToRoot. Items that aren't list items are silently skipped (matches the "remove bullet from anything that has one, leave others alone" intent for mixed selections). Per-item peel still loops up to 5 layers so quote-wrapping-an-LI cases unwrap fully. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…over A wrapped LI's "#." / "•" ::before glyph flips from opacity 0 to 1 when the LI gets --selected (rule at the bottom of the wrapped-bullet section). Inside a parent-takeover, where the parent's rectangle absorbs descendant identity, that flip leaves the FIRST descendant visibly emphasized layered on the unified takeover. The existing descendant-suppression rules cover ::after and box-shadow but not ::before — add the parallel rule. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#selectRange built selectedKeys from the linear navigable order between anchor and focus. Lexxy's block model puts an LI's "children" in a sibling structural-wrapper LI, not under the LI in Lexical's tree, so a parent's children are NOT necessarily LINEARLY between anchor and focus. Without compensation, the range loop dropped the parent's subtree on every shift-extension across a parent boundary. Enforce the atomic-parent invariant: after building the linear range, iterate every in-range LI and call #collectChildKeys to expand its subtree. Same #collectChildKeys helper #selectBlock uses for the initial click — applied to every range entry instead of just the anchor. Symmetric in both directions: - Anchor on parent (Echo), focus moves out (Delta) — Echo is in range, expand its subtree. - Anchor on child (Foxtrot), focus moves to parent (Echo) — same: Echo is in range, expand its subtree. - Range crosses through a parent — every parent in range drags along. An anchor-only re-add was insufficient because when the anchor is a child and the focus moves to the parent, the parent isn't the anchor — so the anchor-only rule missed it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Undo/redo visibly jumped the page. Lexical's history plugin reverts the tree and its reconciler then calls scrollIntoView on the restored selection — which scrolls wherever that selection happens to live, not where the user was looking when they pressed the key. Register a second pair of UNDO/REDO handlers at HIGH priority that run BEFORE the revert. They claim nothing (return false) and only record window.scrollX/scrollY. The existing LOW-priority handlers, which already run after the reconciler, then restore that scroll position inside the same requestAnimationFrame that restores block-selection state, so the two land in one frame and the jump never paints. Also restore scroll when the parallel stack is empty: an undo with no block-selection snapshot still triggers the reconciler's scrollIntoView, so it needs the same suppression. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8ktrVcVRNXn4SPAmMzT9d
Undoing a drag left the moved item unhighlighted at its restored location. Lexical reverted the tree, but the JS-side block selection (selectedBlockKeys, anchor, focus) stayed at its post-drop value — and any key that changed during the move then resolved to nothing against the reverted tree. Push a block-selection snapshot before the drop's editor.update so the parallel undo stack has a pre-move state to restore. Drag-and-drop lives in its own module and cannot reach the extension's private #selectionHistory, so expose the push as a public method rather than widening the field's visibility. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8ktrVcVRNXn4SPAmMzT9d
Dragging only ever moved the grabbed block. Selecting several blocks and then grabbing one of their handles collapsed the selection back to that single block and moved it alone, so there was no way to reposition a group. Carry the whole selection through the drag: - Grabbing a handle that is already part of a multi-selection no longer calls enterBlockSelectMode, which would collapse the selection the user just made. The same snapshot suppresses the post-drop re-select that would otherwise narrow the selection down to the one dropped block. - The ghost stacks a clone of every selected block, and every participating block dims in place, so the user can see the full extent of what is moving rather than just the grabbed row. Ghost clones skip elements already contained in another selected element's subtree, which would otherwise appear twice. - Drop delegates to performMultiDrop, which moves the outermost selected items (those whose block-parent is not itself selected) together with their structural-wrapper subtrees, so hierarchy survives the move and nested items follow their root. Dropping list items onto a non-list target coerces rather than refuses: each wrapped LI contributes its inner block at root level, while runs of plain-text LIs are re-parented into freshly created lists, preserving document order across the runs. performMultiDrop returns false rather than throwing whenever it cannot own the move — a single outermost item, a target inside the selection, non-LI items onto an LI target, or a failed insertion — and the caller falls back to the existing single-block path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8ktrVcVRNXn4SPAmMzT9d
The deferred cleanup that removes empty list items left behind by a drop runs inside a setTimeout, so anything it throws surfaces as an uncaught error with no caller to catch it — and that killed the handlers for every interaction after it. Lexical reconciliation can throw error basecamp#40 from $getTopListNode when the post-cleanup tree leaves a list item without a ListNode parent. Wrap the update and warn instead. The document may be left slightly inconsistent, which is a far better outcome than an editor that has stopped responding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8ktrVcVRNXn4SPAmMzT9d
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.
Summary
Block-based editing extension for Lexxy, adding Notion-style block selection, drag-and-drop, and a block actions menu.
Block selection
Drag and drop
Block actions menu
Indent/outdent and movement
Highlight color inheritance
Turn-into for wrapped blocks
Test plan
Block Editing: Architecture & Implementation
Overview
This branch adds a complete block editing system: multi-block selection with keyboard navigation, drag-and-drop reordering, block-level formatting (turn-into, highlight colors), and Cmd+Shift+Up/Down movement through arbitrarily nested list structures. The system is implemented primarily as an extension (
BlockSelectionExtension, coordinator insrc/extensions/block_selection_extension.js) with supporting modules undersrc/editor/block_selection/and changes to core lexxy infrastructure where required.The design goal is Notion-style block semantics: every visible element (paragraph, heading, list item, table, code block, HR) is an individually selectable, movable block. Selected blocks highlight with a subtle fill, can be dragged as a group, and move through nested list hierarchies one step at a time.
File inventory
New files (extension layer)
src/extensions/block_selection_extension.jssrc/editor/block_selection/drag_and_drop/index.jssrc/editor/block_selection/drag_and_drop/autoscroll.jsAutoScrollclass (edge-proximity scroll while dragging)src/editor/block_selection/drag_and_drop/ghost.jsDragGhostclass (translucent clone of the dragged block)src/editor/block_selection/drag_and_drop/drop_indicator.jsDropIndicatorDOM lifecyclesrc/editor/block_selection/drag_and_drop/geometry.jssrc/editor/block_selection/wrapped_origin.jsWrappedOriginTrackerclass (user-vs-movement wrap origin)src/editor/block_selection/selection_history.jsSelectionHistoryclass (snapshot/restore for undo/redo)src/editor/block_selection/highlight_css.jsextract/merge/removeHighlightFromCSSutilities for inline-style manipulationsrc/editor/block_selection/bullet_color_sync.jsregisterBulletMarkerColorSync(keep bullet markers colored to match text)src/elements/block_actions_menu.jssrc/elements/attachment_controls.jsnode_delete_button.jssrc/elements/attachment_icons.jssrc/elements/preview/dialog_builder.jssrc/elements/preview/playback_sync.jsattachPlaybackSync+installPauseOthers— media playback coordination between inline players and the modalsrc/preview/content_preview.jslexxy-content-preview.js— the show-page script host apps import on pages that render ActionText content; thin wrapper around the shared preview modulessrc/editor/block_helpers.js$isStructuralWrapper(), andgetNodeKeyFromElement()helperssrc/nodes/wrapped_table_node.jssrc/editor/markdown/list_heading_shortcut.js#,##,>) inside list items → wrapped blockslib/lexxy/attachment_helper.rb_blob*.html.erb:lexxy_attachment_actions(preview/download buttons),lexxy_attachment_preview_caption,lexxy_attachment_file_caption,lexxy_inline_svg(reads fromapp/assets/images/lexxy/)app/views/active_storage/blobs/_blob_{audio,file,image,inline_image,video}.html.erb_blob.html.erbis now a dispatcher that routes to the right partial based onblob.video?/blob.audio?/ content type. Keeps each type's markup small and independently overridableapp/assets/images/lexxy/{preview,download}.svglexxy_inline_svgtest/browser/tests/block_editing/*.test.jsModified core files
src/editor/command_dispatcher.jsCOMMANDSmap (merged the oldBLOCK_FORMAT_COMMANDS); scroll preservation for every format command plus undo/redo (wraps handlers sowindow.scrollYis restored after Lexical'sscrollIntoViewIfNeeded);SELECT_ALL_COMMANDescalation to block mode; Tab handling for both code blocks and lists.src/editor/contents.js#applyHeadingFormat,#applyCodeBlockFormat,#applyQuoteBlockFormatnow wrap content inside list items instead of replacing them. UsesgetListItemNodefromsrc/helpers/lexical_helper.jsplus a new#wrapListItemInBlockhelper.src/elements/editor.jsBlockSelectionExtensionregistration,block-handlesattribute,selectAllBlocks()public API,#applyCodeSettings(), extension lifecycle init/dispose.block-handlesattribute andselectAllBlocks()API must be on the editor element. Extension registration is standard.#applyCodeSettings()for--lexxy-code-tab-sizeCSS variable could arguably stay in the extension.src/extensions/tables_extension.js$handleWrappedBlockEscapeInList(),$isAtVisualEdge().COMMAND_PRIORITY_CRITICALwhich works fine from an extension.src/elements/table/table_controller.js#isListNestedInCell()distinguishes wrapper-list from cell-nested-list.src/nodes/early_escape_code_node.jssrc/elements/code_language_picker.js#monitorForCodeBlockHover().src/elements/toolbar.jssrc/elements/toolbar_dropdown.jsqueueMicrotask.src/elements/dropdown/highlight.jssaveLastUsedColor()(fromsrc/helpers/storage_helper.js).src/elements/dropdown/link.jsconnectedCallback→initialize().src/config/lexxy.jsmarkdown: trueto default config.src/helpers/lexical_helper.jsgetListItemNode()utility.src/extensions/highlight_extension.jsdata-pad-start/data-pad-endon<mark>elements). Corresponding CSS rules added tolexxy-content.css.app/assets/stylesheets/lexxy-editor.cssapp/assets/stylesheets/lexxy-content.css::beforepseudo-elements) was necessary to enable block selection's left-gutter highlighting. The code block and attachment changes are independent improvements.Changes that could potentially be kept in the extension
Based on the analysis above, candidates for moving back to the extension (or splitting into separate PRs):
toolbar_dropdown.jslifecycle fix — Generic bug fix, not block-editing specific. Could be a separate PR.link.jsrename — Consistency refactor, separate PR.config/lexxy.jsmarkdown default — Config change for list shortcuts, could ship independently.code_language_picker.jscopy button + hover — Code block UX improvements, independent feature.highlight_extension.jsmark padding — Visual polish, independent feature. CSS rules now added tolexxy-content.css(complete on this branch).editor.js#applyCodeSettings()— The CSS variable for code tab-size could be set by the extension'sinitializeEditor()hook instead.Architecture
Extension subsystems
The
BlockSelectionExtension(4,525 lines insrc/extensions/block_selection_extension.js) has 13 interconnected subsystems. Independent concerns (drag-and-drop, wrapped-origin tracking, selection history, highlight CSS parsing, bullet color sync) live as sibling modules undersrc/editor/block_selection/— see the inventory above.1. Mode management
Dual-mode system:
"edit"(normal text editing) and"block-select"(block-level operations). Escape toggles between them. Entering block-select addsblock-selection-activeto the editor root (hides caret, disables text selection via CSS). Exiting removes it and commits any pending highlight color changes.2. Selection state
#selectedBlockKeys(Set) — currently selected node keys#anchorKey— first block selected (range anchor for Shift+Arrow)#focusKey— last block in selection (current focus)#selectBlock(key, extend)— select/toggle a single block#selectRange(from, to)— select contiguous range#getNavigableBlockKeys()— all top-level selectable blocks (excludes ListNode containers)3. DOM synchronization
#syncSelectionClasses()diffs#selectedBlockKeysagainst#previousSelectedKeysand applies/removesblock--selectedandblock--focusedCSS classes. Diff-based for performance.#syncSelectionGroupClasses()identifies contiguous runs of selected items and appliesblock--select-first,block--select-mid,block--select-lastfor flattened-edge group styling.#syncLeafInsets()writes per-block--leaf-top-inset/--leaf-bottom-insetCSS variables computed by#findAdjacentBlocks()and#computeLeafReach(). Each selected leaf's::afterreaches halfway to its nearest visible neighbor (skipping.hiddenprovisional separator paragraphs). The target visible gap is resolved per-pair by#targetGapBetween(): 2px for plain-text LIs sharing the same parent list (tight Notion rhythm — Tango↔Uniform at the outer OL), 4px otherwise (mixed-list rhythm — Papa↔Quebec inside a wrapper's inner ul). Cross-wrapper pairs default to 4px but switch to 2px when the wrapper's outer-level owner is itself in a parent-takeover state, so the unified parent-takeover highlight lands 2px above the next plain-text sibling. Both sides of any adjacent selected pair run through the same classifier, guaranteeing the two reaches agree on the target.#syncParentSelectionHeight()sets--parent-selection-heighton parent items so the parent's::afterextends to cover all nested children as a unified highlight. The bottom uses the last child's computedbottomReachso the parent-takeover highlight ends exactly where that last child's individual highlight would have — heights stay stable as the selection grows from leaf → group → parent.4. Keyboard handling
Global
keydownlistener active only in block-select mode. Keybindings:5. Block movement (single item)
#moveSingleBlock→#moveListItem→ dispatches to:#nestListItemUnderSibling— nest under adjacent sibling (depth-first traversal)#promoteListItem— promote one nesting level#promoteWrappedBlockThroughRoot— wrapped blocks skip root list level and exit as standalone elements6. Block movement (atomic groups)
#moveGroupAtomicallyhandles multi-block moves. Flow:Key concepts:
#filterToRootKeysidentifies the top-level items in the selection; children travel with their root via structural wrappers#exitGroupFromListcreates a temporary ParagraphNode as a stable reference point, places extracted items relative to it, then removes it (or keeps it as a separator to prevent Lexical's adjacent-list merge). When a retained separator is itself a decorator paragraph, subsequent group moves skip over it when computing the target index.7. Drag-and-drop
BlockDragAndDrop(src/editor/block_selection/drag_and_drop/index.js, 1,973 lines plus 4 sibling modules totaling ~380 more lines) manages:#.rather than a bullet circle8. Block actions menu
BlockActionsMenu(src/elements/block_actions_menu.js, 658 lines) — floating context menu opened via Cmd+/ or right-click:9. Highlight color management
Sophisticated color propagation system for nested list highlights:
#savedHighlightStylespreserves original colors before parent propagation#applyOrRestoreParentHighlightresolves whether to keep inherited color or revert to saved originalstyle=""attribute manipulation10. Block type conversion
#convertBlockType(command)converts selected blocks between types. When blocks are inside list items, uses#wrapListItemContentto create wrapped blocks (heading-in-list, quote-in-list) rather than replacing the list item.11. Indent / outdent
Tab/Shift+Tab in block-select mode calls
#handleIndentOutdent. For wrapped blocks (headings/quotes/code inside list items), uses special#indentWrappedBlock/#outdentWrappedBlockthat manipulate the structural wrapper nesting. When a root-level item can't outdent further,#flattenChildrenOneLevelpromotes children to siblings.Shift+Tab on mixed root-level lists (wrapped blocks + regular bullets) extracts each wrapped item in place by splitting the list around it. Regular bullets stay in the naturally-formed list segments, preserving interleaved document order.
#exitGroupFromListis still used for Cmd+Shift+Up boundary exit, but no longer for root-level outdent.12. Wrapped block escape
Tables and code blocks inside list items need special arrow-key escape. Registered in
tables_extension.jsatCOMMAND_PRIORITY_CRITICAL:13. Bootstrap deferral
Every block-editing feature that doesn't affect initial render is deferred until the user actually touches the editor. On construction the extension wires a pair of one-shot listeners —
mouseenteron the editor element andfocusinon the root — and only on the first fire does it register itsregisterCommands, keyboard handlers, and instantiateBlockDragAndDrop. The only work that stays in the bootstrap path is the bullet-marker node transform, because it needs to run during initial content reconciliation to style pre-existing lists.Impact: removes 14
registerCommandcalls, 1registerNodeTransform, and severaladdEventListenercalls per editor from the bootstrap path. This is load-bearing for thebootstrap-many-editorsbenchmark and why the per-editor cost stays close to the pre-branch baseline.Lexical list structure
$isStructuralWrapper(node)— ListItemNode whose only children are ListNodes#isWrappedBlockvia content heuristic, tracked key set, and DOM attribute fallback.CSS architecture
Selection highlighting uses
::afterpseudo-elements atz-index: -1as the primary mechanism, with per-block-type overrides:::afterpseudo-elementz-index: auto+ outline + box-shadow tint::afterpaint below it::afterwould be hidden::beforebar at z-index: 1 over::afterfill::afterwithmin-height: unsetContiguous group styling: Adjacent selected items get
block--select-first/mid/lastclasses for flattened-edge visual bands. Mixed lists (containing wrapped blocks) skip mid-styling because 4px gaps are too wide for flat-edge merging.Parent fill: When a parent item and its children are all selected, the parent's
::afterextends to cover the entire subtree via--parent-selection-heightCSS variable. Children's individual::afterelements are hidden to avoid double-painting.Layout-shift-free selection: Selection CSS must never toggle flow properties (margin, padding, height). Only
::aftergeometry and background/color respond to.selected. Wrapper list items (lexxy-nested-listitem) establish a block formatting context viadisplay: flow-rootso nested wrapped-block margins stay trapped inside the wrapper rather than collapsing up through empty ancestors and inflating the outer list.Halfway-reach invariant for isolated leaves: Every selected
li/figure.attachment/.attachment-gallerygets--leaf-top-insetand--leaf-bottom-insetpre-computed as halfway-to-neighbor. The target visible gap is pair-classified: 2px for plain-text LIs in the same parent list (tight rhythm), 4px for mixed-list and cross-wrapper pairs, except a cross-wrapper pair whose outer-level owner is itself parent-taken-over is re-classified as owner↔outer-sibling and pulls tight to 2px. Solo selections reach to the midpoint on each side..hiddenprovisional paragraphs (Lexical's decorator separators) are skipped during adjacency walks so inter-decorator pairs reach each other directly instead of landing on invisible separators.List bullet redesign: Browser default markers were replaced with
::beforepseudo-elements using radial-gradient bullets and CSS counter numbers. This was necessary because block selection's left-gutter highlight extends beyond the bullet position, and browser markers can't be styled to integrate with the highlight fill.Code block hover controls: The copy button and language picker are hidden while block-select mode is active or a drag is in progress, so they don't paint on top of the block highlight or interfere with the drop target.
Lexical reconciler caveats
ListNodes of the same type during DOM reconciliation. Any operation that places two same-type lists next to each other will have them merged. Prevent by: (a) batching items into a single list, (b) keeping a ParagraphNode separator between lists.editor.update().#resyncWrappedKeyshandles some cases but stale keys can persist after group operations that create/destroy nodes.:has()nesting: Browsers silently ignore nested:has()selectors.ul:has(> li:has(> h2))is dropped — must flatten toul:has(> li > h2).<p class="hidden">separator paragraphs between adjacentDecoratorNodes so the cursor can land between them. Any adjacency math over editor DOM must skip these or it measures to an invisible box (with a-5.5pxcompensation margin) and computes the wrong neighbor distance.Public API additions
editor.hasBlockSelectioneditor.jsgettereditor.selectAllBlocks()editor.jsmethod<lexxy-editor block-handles="true">Attachment rendering & media features
Show page rendering (blob template)
Lexxy ships
app/views/active_storage/blobs/_blob.html.erbas a dispatcher plus per-type partials (_blob_audio,_blob_file,_blob_image,_blob_inline_image,_blob_video)._blob.html.erbpicks the right partial based onblob.video?/blob.audio?/ content type. Each per-type partial stays small and is independently overridable. Out-of-the-box rendering:<video controls><audio controls>player below<img>using direct blob URL (preserves animation/vector data —blob.representationwould strip animation or rasterize SVG)<img>via Active Storage representation (resize-to-limit)The
blob.video?andblob.audio?checks match anyvideo/*oraudio/*content type, so unusual variants (mkv, flac, etc.) are handled automatically. The icon-label helper falls back to the uppercased extension for any type not inICON_LABELS, so file cards always have a sensible label without needing a code change.All attachment types render with Open (eye icon) and download action buttons that appear on hover. Open dispatches
lexxy:preview-attachmentto the modal; download triggers the browser's download flow. The eye button is hidden by default and only shown afterlexxy-content-preview.jsaddslexxy-content-preview-enabledto<html>— apps that skip the import get a download-only action bar and can roll their own preview UI.Preview modal (
lexxy-content-preview.js)Standalone script for show pages. Source at
src/preview/content_preview.js; rollup emitsapp/assets/javascript/lexxy-content-preview.jsalongsidelexxy.js. The editor's<lexxy-preview-modal>custom element and this script share all modal-building logic viasrc/elements/preview/dialog_builder.jsandsrc/elements/preview/playback_sync.js— both modals render and behave identically.Provides:
<dialog>modal with header (icon, caption, filename, file size) + content areacurrentTime; closing syncs it back. If the page media was playing, modal continues from the same spot.installPauseOthers()fromplayback_sync.js)turbo:loadlistenerHost app integration — minimal:
The
previewModal: trueflag for the editor modal is now the default — noconfigure()call needed. Apps that want to opt out can setpreviewModal: false.If the app has a custom
app/views/active_storage/blobs/_blob.html.erb, delete it (and delete any per-type partials) to use Lexxy's. The Lexxy gem ships:lib/lexxy/attachment_helper.rb(lexxy_attachment_actions— preview/download button row,lexxy_attachment_preview_caption,lexxy_attachment_file_caption,lexxy_inline_svg— reads fromapp/assets/images/lexxy/)The helper module is auto-included into
ActionView::BasebyLexxy::Engine, so the partials just call its methods. Icon labels are justextension.upcaseinline in the partials — no separate Ruby icon-label map.Attachment state persistence
Two new data attributes on
action-text-attachmentelements persist editor state to the rendered page:data-collapseddata-caption-hiddenPipeline for persistence:
collapsed,captionHidden)exportDOM()→ data attributes onaction-text-attachmentelementActionText::Attachment::ATTRIBUTES(so attributes survive server-side re-serialization)ActionText::ContentHelper.allowed_attributes(so attributes survive render-time sanitization)Editor attachment controls (
<lexxy-attachment-controls>)Custom element at
src/elements/attachment_controls.js(formerlynode_delete_button.js— renamed since it grew well beyond the delete button). Floating controls appear on hover and selection. All buttons have native browser tooltips viatitleplus matchingaria-label. Button order:Naming note: We use "Open" for the eye icon to disambiguate from the inline "preview" (which the collapse button toggles). Internally the code/event names still use "preview" (
#openPreview,lexxy:preview-attachment,PreviewModal, etc.) — only user-visible strings changed.Audio preview in editor: Audio attachments render as a file card with an inline
<audio>player below (the sameattachment--audiolayout as the show page). Collapse hides the player; expand shows it. The collapsed state persists to the show page.Inline video preview in editor: Video attachments render as a
<video controls>player (using the derived blob URL viarepresentationToBlobUrl()instorage_helper.js). Collapse switches to the file card.PDF preview in editor: PDFs render with their thumbnail image (representation URL). Collapse switches to the file card.
Inline name editing: Click any file attachment's filename (
.attachment__name) to edit it inline. Enter saves (auto-enables caption display). Escape clears and resets to original filename. The edited name is stored as thecaptionattribute. The click-to-edit listener lives on#createNameTag()itself, so every layout that renders a name (file cards, audio preview, collapsed card view) gets the behaviour for free.Gallery ejection: Collapsed images are automatically ejected from image galleries since
ImageGalleryNode.isValidChild()excludes collapsed nodes. The gallery'ssplitAroundInvalidChildtransform handles the ejection.Shared building blocks (DRY composition)
ActionTextAttachmentNode#createDOMcomposes a small set of reusable methods:#createIconLabel()#createNameTag()<strong>with click-to-rename wired in#createFileCaption()<figcaption>with name + size — used by file cards and the audio preview's file-info row#createEditableCaption()<textarea>caption for image/video previews#createDOMForImage()/#createAudioPlayer()/#createVideoPlayer()#createCardView()The same
playbackUrlgetter resolves the right URL for inline players and the modal —blobUrl(if known), otherwiserepresentationToBlobUrl(src), otherwisesrc. The blob URL is stored on the figure'sdata-captionattribute (viaupdateDOM) so the preview button can read the authoritative caption regardless of caption-hidden state.Sanitization configuration (all in
engine.rb)Three layers must agree for new attributes to survive the round-trip:
If you add a new persisted attribute on
action-text-attachment, update all three layers or it will silently disappear.Icon color system
Per-extension icon colors using CSS custom properties (
--lexxy-attachment-icon-bg,--lexxy-attachment-icon-border,--lexxy-attachment-icon-text). Defined in three places:lexxy-editor.cssinside:where(lexxy-editor)— editor file cards and collapsed cardslexxy-editor.cssoutside:where()— preview modal icons (appended todocument.body)lexxy-content.css— show page renderingDrag-and-drop improvements
Block handle drag
.lexxy-block-dragging .attachment:hovernarrowed with:not(.node--selected):not(.lexxy-dragging)so selected and dragged attachments keep their blue outlines#cancelDragWithSnapBack()animates the ghost from cursor position back to the original element with a 200ms ease transition + fade, then cleans up#startDrag()removesnode--selectedfrom all elements except the one being dragged.attachment:hover &) in addition to selectionAttachment native drag (
AttachmentDragAndDrop)The native HTML5 drag system remains active for gallery operations (merging images, reordering within galleries). Block-level moves are handled by
BlockDragAndDrop.Host app integration (testing uploads & previews)
Everything in this section is what a Rails app needs so a human can manually drive the editor through the full matrix of attachment types and the preview modal.
test/dummy/in this repo is a working reference; copy from it when bootstrapping a new test app.The engine takes care of the server side
Lexxy::Engineauto-wires these when the gem is mounted — the host app doesn't touch sanitizer config itself:ActionText::Attachment::ATTRIBUTESgainsdata-caption-hiddenanddata-collapsedso the editor's UI state persists through the save → render → re-edit round-trip.ActionText::ContentHelper.allowed_tagsgainsvideo,audio,source,table,tbody,tr,th,td, plus the full SVG-primitive set (svg path circle ellipse line polyline polygon rect g defs use text tspan title desc linearGradient radialGradient stop clipPath mask pattern symbol marker).ActionText::ContentHelper.allowed_attributesgains the core editor attributes (controls poster data-language style value autoplay loop muted playsinline preload), the data attributes the editor persists (data-collapsed data-caption-hidden), and the SVG attribute set (viewBox xmlns d fill aria-label cx cy r rx ry x y x1 y1 x2 y2 points transform stroke stroke-*and related). The full list is inlib/lexxy/engine.rb.Loofah::HTML5::SafeList::ALLOWED_CSS_FUNCTIONSgainsvarso--lexxy-*CSS variables survive sanitization.ActiveStorage::Blob#as_jsonis patched to includepreviewable: trueand aurlpointing at a resized representation (ActiveStorage::BlobWithPreviewUrl). This is the signal the editor uses to decide preview-view vs. file-card rendering for PDFs, videos, etc.rich_text_areaform helper is installed viaLexxy::FormHelper/FormBuilder.Preview modal has two separate switches
Lexxy.configure({ global: { previewModal: false } })before any editors connect.<lexxy-preview-modal>and appends one todocument.body. With this off, the editor's preview button dispatcheslexxy:preview-attachmentbut nothing handles it and the click is a no-op.<action-text-attachment>)import "lexxy-content-preview"inapplication.js(and a corresponding importmap pin)The editor-side modal is on by default; the show-page modal is opt-in. For a test app that exercises this PR's full feature set, import
lexxy-content-preview.Minimum Gemfile
Then run
bin/rails action_text:installandbin/rails active_storage:install, and run the resulting migrations.JavaScript wiring
config/importmap.rb:app/javascript/application.js:Stylesheet
<%= stylesheet_link_tag "lexxy" %>in the layout — that file@importslexxy-content.css(show-page rules),lexxy-editor.css(editor chrome + block editing styles), andlexxy-variables.css(custom properties).Show page template
Lexxy ships
app/views/active_storage/blobs/_blob.html.erb(a dispatcher) plus per-type partials (_blob_audio,_blob_file,_blob_image,_blob_inline_image,_blob_video) with rendering for video, audio, GIF, image representations, and generic file cards plus preview + download action buttons. Host apps can customize the markup at two grains:app/views/active_storage/blobs/_blob_image.html.erb). Rails' view lookup prefers the host app's copy for that type while Lexxy's dispatcher + other per-type partials keep working._blob.html.erb. You lose Lexxy's dispatcher, per-type partials, and the attachment action buttons unless you renderlexxy_attachment_actions(blob)yourself — only worth doing when you need wholly custom markup.For the test app walkthrough below, the path of least resistance is to delete any pre-existing custom
_blob.html.erbso Lexxy's full chain renders. Rendering stays a normal<%= @post.body %>.System dependencies for attachment types
blob.previewable?drives whether an upload gets the preview-view DOM or falls back to a file card. It depends on processors being installed on the machine running the test app:brew install poppler) or mupdf<audio>Set the processor explicitly in
config/environments/development.rb:Also set
Rails.application.routes.default_url_options = { host: "localhost", port: 3000 }so representations resolve to absolute URLs in the show-page preview modal.Model + form
Manual test matrix
With the above wired up, walk through:
.png,.gif,.pdf,.mp4,.mp3,.txt,.zip. Confirm image/GIF/video/PDF all render as preview-view with a real thumbnail, audio renders as a file card with an inline<audio>player, and the rest render as file cards with an extension label.data-collapsedattribute survives and the card renders there too.data-caption-hiddenround-trips.<dialog>'s native handling.lexxy-content-previewis wired).block-handles="true"on the editor — drag handle appears on hover over each block; verify Cmd+Shift+↑/↓ movement, Cmd+/ block actions menu, and drag-and-drop all work with attachments as blocks.Test coverage
14 Playwright test files in
test/browser/tests/block_editing/:block_selection.test.jsblock_api.test.jsselectAllBlocks(),hasBlockSelectionblock_actions_menu.test.jsblock_drag_and_drop.test.jsblock_movement_hierarchy.test.jsdrop_edge.test.jsdrop_freeze.test.jsdrop_reparent.test.jsattachment_block_select.test.jshr_block_select.test.jstable_block_select.test.jssingle_undo_correctness.test.jswrapped_block_origin.test.jswrapped_block_outdent.test.js