Skip to content

Block-based editing: selection, drag-and-drop, and actions menu - #14

Open
jondkinney wants to merge 288 commits into
mainfrom
block-editing-standalone
Open

Block-based editing: selection, drag-and-drop, and actions menu#14
jondkinney wants to merge 288 commits into
mainfrom
block-editing-standalone

Conversation

@jondkinney

@jondkinney jondkinney commented Apr 3, 2026

Copy link
Copy Markdown
Member

Summary

Block-based editing extension for Lexxy, adding Notion-style block selection, drag-and-drop, and a block actions menu.

Block selection

  • Click drag handle or Cmd+click to select blocks
  • Notion-style translucent highlight on selected blocks
  • Inline format shortcuts (Cmd+B/I/U) and Cmd+K suppression in block mode

Drag and drop

  • Depth-aware drops with visual drop indicators
  • Drag handle aligned with first line of text for all block types
  • Ghost width matches source block

Block actions menu

  • Cmd+/ to open block actions menu
  • Duplicate (Cmd+D), delete, turn-into submenu
  • Scroll anchoring, viewport clamping

Indent/outdent and movement

  • Tab/Shift+Tab indent/outdent for selected blocks
  • Keyboard block movement (Cmd+Shift+Arrow)
  • Max nesting depth (10)

Highlight color inheritance

  • Cascade to children on color, indent, move, drop
  • Bullet marker color sync
  • Enter on wrapped list items creates sibling with inherited color

Turn-into for wrapped blocks

  • Wraps content in-place when inside list items
  • Code block and table exit creates list item sibling when wrapped

Test plan

  • Click drag handle — block selected with highlight
  • Drag block to new position — drops correctly
  • Cmd+/ opens block actions menu
  • Cmd+D duplicates block
  • Tab indents selected block, Shift+Tab outdents
  • Cmd+Shift+Arrow moves block up/down
  • Highlight color cascades to children on indent/move/drop
  • Build passes, lint clean, browser tests pass

Block Editing: Architecture & Implementation

Notion-style block selection, movement, drag-and-drop, and formatting for Lexxy.
Branch: block-editing-standalone — 98 files changed, 17,161 insertions, 387 deletions against origin/main.

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 in src/extensions/block_selection_extension.js) with supporting modules under src/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)

File Lines Purpose
src/extensions/block_selection_extension.js 4,525 Coordinator: selection state, keyboard navigation, block movement, formatting, highlight propagation — delegates drag-and-drop and a few independent concerns to the modules below
src/editor/block_selection/drag_and_drop/index.js 1,973 Drag-and-drop coordinator: drag handles, drop indicators, drag ghosts, hover detection
src/editor/block_selection/drag_and_drop/autoscroll.js 158 AutoScroll class (edge-proximity scroll while dragging)
src/editor/block_selection/drag_and_drop/ghost.js 103 DragGhost class (translucent clone of the dragged block)
src/editor/block_selection/drag_and_drop/drop_indicator.js 57 DropIndicator DOM lifecycle
src/editor/block_selection/drag_and_drop/geometry.js 59 Snap-point / nesting-depth geometry helpers
src/editor/block_selection/wrapped_origin.js 172 WrappedOriginTracker class (user-vs-movement wrap origin)
src/editor/block_selection/selection_history.js 57 SelectionHistory class (snapshot/restore for undo/redo)
src/editor/block_selection/highlight_css.js 47 extract/merge/removeHighlightFromCSS utilities for inline-style manipulation
src/editor/block_selection/bullet_color_sync.js 60 registerBulletMarkerColorSync (keep bullet markers colored to match text)
src/elements/block_actions_menu.js 671 Floating context menu: turn-into, highlight colors, delete
src/elements/attachment_controls.js 186 Floating on-hover controls for attachments (open / collapse / edit / caption-toggle / delete); formerly node_delete_button.js
src/elements/attachment_icons.js 41 Shared SVG icon strings for attachment floating controls and preview modal chrome
src/elements/preview/dialog_builder.js 214 Shared preview-modal DOM builder used by the editor element and the standalone show-page script
src/elements/preview/playback_sync.js 53 attachPlaybackSync + installPauseOthers — media playback coordination between inline players and the modal
src/preview/content_preview.js 52 Rollup entry for lexxy-content-preview.js — the show-page script host apps import on pages that render ActionText content; thin wrapper around the shared preview modules
src/editor/block_helpers.js 38 Shared CSS-class constants, $isStructuralWrapper(), and getNodeKeyFromElement() helpers
src/nodes/wrapped_table_node.js 78 TableNode subclass for tables inside list items; provisional escape item tracking
src/editor/markdown/list_heading_shortcut.js 102 Markdown shortcuts (# , ## , > ) inside list items → wrapped blocks
lib/lexxy/attachment_helper.rb 110 Helpers used by _blob*.html.erb: lexxy_attachment_actions (preview/download buttons), lexxy_attachment_preview_caption, lexxy_attachment_file_caption, lexxy_inline_svg (reads from app/assets/images/lexxy/)
app/views/active_storage/blobs/_blob_{audio,file,image,inline_image,video}.html.erb ~30 ea Per-type partials. _blob.html.erb is now a dispatcher that routes to the right partial based on blob.video? / blob.audio? / content type. Keeps each type's markup small and independently overridable
app/assets/images/lexxy/{preview,download}.svg SVG assets for the per-attachment action buttons, read via lexxy_inline_svg
test/browser/tests/block_editing/*.test.js 14 Playwright test files covering selection, drag-and-drop, movement, actions menu, wrapped blocks, undo correctness (see Test coverage below)

Modified core files

File Delta What changed Could it live in the extension?
src/editor/command_dispatcher.js +187 Unified COMMANDS map (merged the old BLOCK_FORMAT_COMMANDS); scroll preservation for every format command plus undo/redo (wraps handlers so window.scrollY is restored after Lexical's scrollIntoViewIfNeeded); SELECT_ALL_COMMAND escalation to block mode; Tab handling for both code blocks and lists. No. These are command routing changes that affect all editors, not just block-select mode. Scroll preservation prevents page jumps during any heading/list/quote/code conversion.
src/editor/contents.js +73 Wrapped block creation: #applyHeadingFormat, #applyCodeBlockFormat, #applyQuoteBlockFormat now wrap content inside list items instead of replacing them. Uses getListItemNode from src/helpers/lexical_helper.js plus a new #wrapListItemInBlock helper. No. Determines how block formats interact with list structure — fundamental data model behavior.
src/elements/editor.js +37 BlockSelectionExtension registration, block-handles attribute, selectAllBlocks() public API, #applyCodeSettings(), extension lifecycle init/dispose. Partially. The block-handles attribute and selectAllBlocks() API must be on the editor element. Extension registration is standard. #applyCodeSettings() for --lexxy-code-tab-size CSS variable could arguably stay in the extension.
src/extensions/tables_extension.js +227 Arrow-key escape from wrapped tables/code in lists; provisional ListItemNode creation/cleanup; $handleWrappedBlockEscapeInList(), $isAtVisualEdge(). Mostly yes. Table escape logic is closely tied to the wrapped-block concept introduced by block editing. The escape handlers register at COMMAND_PRIORITY_CRITICAL which works fine from an extension.
src/elements/table/table_controller.js +30 Enter key in wrapped tables creates rows (not bailing for wrapper lists); #isListNestedInCell() distinguishes wrapper-list from cell-nested-list. No. Enter key behavior inside tables is core table UX. The distinction between "table wrapped in a list item" vs "list nested in a table cell" must be handled in the controller.
src/nodes/early_escape_code_node.js +12 Code block exit creates sibling ListItemNode (not paragraph) when inside a list item. No. Node-level behavior must be in the node class.
src/elements/code_language_picker.js +99 Copy button, hover-based visibility, #monitorForCodeBlockHover(). Could be. The copy button and hover monitoring are independent of block selection. These are code block UX improvements that happened alongside this branch but aren't architecturally dependent on it.
src/elements/toolbar.js +38 Clears toolbar pressed states during block-select mode. Could be. The extension could listen for mode changes and clear toolbar state, but it's simpler in toolbar.js since the toolbar already updates on selection changes.
src/elements/toolbar_dropdown.js +32 Deferred initialization via queueMicrotask. Yes. This is a lifecycle bug fix for dynamic toolbar creation, not specific to block editing.
src/elements/dropdown/highlight.js +9 Saves last-used color via saveLastUsedColor() (from src/helpers/storage_helper.js). Could be. Cross-component color tracking, but touching extension UI code.
src/elements/dropdown/link.js +4 Renamed connectedCallbackinitialize(). Yes. Consistency refactor, not block-editing specific.
src/config/lexxy.js +6 Added markdown: true to default config. Yes. Enables list-heading shortcuts but is a config default, not structural.
src/helpers/lexical_helper.js +6 Added getListItemNode() utility. Could be. Small helper, but useful beyond block editing.
src/extensions/highlight_extension.js +27 Mark padding sync (data-pad-start/data-pad-end on <mark> elements). Corresponding CSS rules added to lexxy-content.css. Yes. Visual polish for highlights, independent of block editing.
app/assets/stylesheets/lexxy-editor.css +2,005 All block selection visual styling. No. Editor-level CSS must ship with the editor, not be injected by an extension.
app/assets/stylesheets/lexxy-content.css +431 Custom bullet rendering (radial-gradient markers), list margin/padding restructuring, code block spacing, attachment icon sizing. Partially. The list bullet redesign (replacing browser markers with ::before pseudo-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):

  1. toolbar_dropdown.js lifecycle fix — Generic bug fix, not block-editing specific. Could be a separate PR.
  2. link.js rename — Consistency refactor, separate PR.
  3. config/lexxy.js markdown default — Config change for list shortcuts, could ship independently.
  4. code_language_picker.js copy button + hover — Code block UX improvements, independent feature.
  5. highlight_extension.js mark padding — Visual polish, independent feature. CSS rules now added to lexxy-content.css (complete on this branch).
  6. editor.js #applyCodeSettings() — The CSS variable for code tab-size could be set by the extension's initializeEditor() hook instead.

Architecture

Extension subsystems

The BlockSelectionExtension (4,525 lines in src/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 under src/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 adds block-selection-active to 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)
  • Selection history maintained in parallel undo/redo stacks, synced with Lexical's history commands

3. DOM synchronization

#syncSelectionClasses() diffs #selectedBlockKeys against #previousSelectedKeys and applies/removes block--selected and block--focused CSS classes. Diff-based for performance.

#syncSelectionGroupClasses() identifies contiguous runs of selected items and applies block--select-first, block--select-mid, block--select-last for flattened-edge group styling.

#syncLeafInsets() writes per-block --leaf-top-inset / --leaf-bottom-inset CSS variables computed by #findAdjacentBlocks() and #computeLeafReach(). Each selected leaf's ::after reaches halfway to its nearest visible neighbor (skipping .hidden provisional 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-height on parent items so the parent's ::after extends to cover all nested children as a unified highlight. The bottom uses the last child's computed bottomReach so 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 keydown listener active only in block-select mode. Keybindings:

Key Action
Arrow Up/Down Navigate selection (Shift to extend range)
Cmd+Shift+Up/Down Move selected blocks
Enter Enter edit mode on focused block
Escape Exit block-select → blur editor
Tab / Shift+Tab Indent / outdent
Backspace / Delete Remove selected blocks
Cmd+A Select all blocks
Cmd+D Duplicate selected blocks
Cmd+B/I/U Bold / italic / underline across selection
Cmd+Shift+X Strikethrough
Cmd+Shift+H Apply last-used highlight color
Cmd+/ Open block actions menu

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 elements

6. Block movement (atomic groups)

#moveGroupAtomically handles multi-block moves. Flow:

Cmd+Shift+Up/Down with group:
  ├─ Different parents?
  │   ├─ Any outside list or in root list → #moveRootLevelGroup (swap/enter)
  │   └─ Different depths in same hierarchy → #normalizeGroupDepth
  ├─ Has sibling in direction → #nestGroupUnderSibling
  └─ No sibling (boundary):
      ├─ Group IS entire root-level list → swap list as unit / enter adjacent list
      ├─ Root-level list → #exitGroupFromList
      └─ Nested list → #promoteGroupOneLevel

Key concepts:

  • Root keys: #filterToRootKeys identifies the top-level items in the selection; children travel with their root via structural wrappers
  • Structural wrappers: ListItemNodes containing only nested ListNodes — they carry child content when a parent item moves
  • Cursor approach: #exitGroupFromList creates 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.
  • Batch exit: consecutive regular items are collected into a single standalone list to prevent merge-induced infinite loops

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:

  • Drag handle element with 6-dot grip icon, positioned on hover
  • Add-block button (plus icon)
  • Drop indicator: depth-aware positioning, gap resolution, center-aligned on the target edge; OL drop targets render #. rather than a bullet circle
  • Drag ghost (translucent clone of dragged block, width matches source)
  • Auto-scroll when dragging near container edges
  • Multi-block drag (Shift+click, Cmd+click for range/toggle selection)
  • Post-drop cleanup: removes empty ListItemNodes left behind by Lexical's normalization after an unwrap

8. Block actions menu

BlockActionsMenu (src/elements/block_actions_menu.js, 658 lines) — floating context menu opened via Cmd+/ or right-click:

  • Turn into: paragraph, H2, H3, H4, bullets, numbers, quote, code
  • Color: 9 highlight colors × 2 styles (text color, background color), plus last-used quick access
  • Delete: remove selected blocks
  • Full keyboard navigation (arrow keys, Enter, Escape)

9. Highlight color management

Sophisticated color propagation system for nested list highlights:

  • #savedHighlightStyles preserves original colors before parent propagation
  • When a parent item gets a highlight, children inherit it
  • When entering edit mode, new blocks don't inherit parent highlight
  • #applyOrRestoreParentHighlight resolves whether to keep inherited color or revert to saved original
  • CSS string parsing/merging handles complex style="" attribute manipulation

10. Block type conversion

#convertBlockType(command) converts selected blocks between types. When blocks are inside list items, uses #wrapListItemContent to 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 / #outdentWrappedBlock that manipulate the structural wrapper nesting. When a root-level item can't outdent further, #flattenChildrenOneLevel promotes 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. #exitGroupFromList is 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.js at COMMAND_PRIORITY_CRITICAL:

  • Arrow Up at top of table/code → creates provisional sibling ListItemNode above
  • Arrow Down at bottom → creates provisional sibling below
  • Provisional items auto-remove on selection change if left empty
  • Backspace in a provisional returns focus to the adjacent table/code

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 — mouseenter on the editor element and focusin on the root — and only on the first fire does it register its registerCommands, keyboard handlers, and instantiate BlockDragAndDrop. 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 registerCommand calls, 1 registerNodeTransform, and several addEventListener calls per editor from the bootstrap path. This is load-bearing for the bootstrap-many-editors benchmark and why the per-editor cost stays close to the pre-branch baseline.

Lexical list structure

ListNode (ul/ol)
  ListItemNode "Parent text"          ← content item (selectable block)
  ListItemNode [structural wrapper]   ← has class "lexxy-nested-listitem"
    ListNode (ul/ol)
      ListItemNode "Child text"       ← content item
      ListItemNode [heading wrapper]  ← wrapped block (contains H2)
  • $isStructuralWrapper(node) — ListItemNode whose only children are ListNodes
  • Wrapped blocks — ListItemNodes containing non-text block content (h1-h6, blockquote, code, table, HR, attachments). Detected by #isWrappedBlock via content heuristic, tracked key set, and DOM attribute fallback.
  • When selecting a parent, its structural wrapper's children are automatically included in the selection

CSS architecture

Selection highlighting uses ::after pseudo-elements at z-index: -1 as the primary mechanism, with per-block-type overrides:

Block type Technique Why
Most blocks ::after pseudo-element Standard approach, paints behind content
Code blocks z-index: auto + outline + box-shadow tint Code bg is opaque; breaking the stacking context lets ::after paint below it
Tables Direct background on wrapper + cells Cell backgrounds are opaque; ::after would be hidden
Blockquotes ::before bar at z-index: 1 over ::after fill Vertical bar must render above the highlight fill
HR Compact ::after with min-height: unset Thin element needs minimal highlight

Contiguous group styling: Adjacent selected items get block--select-first/mid/last classes 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 ::after extends to cover the entire subtree via --parent-selection-height CSS variable. Children's individual ::after elements are hidden to avoid double-painting.

Layout-shift-free selection: Selection CSS must never toggle flow properties (margin, padding, height). Only ::after geometry and background/color respond to .selected. Wrapper list items (lexxy-nested-listitem) establish a block formatting context via display: flow-root so 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-gallery gets --leaf-top-inset and --leaf-bottom-inset pre-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. .hidden provisional 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 ::before pseudo-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

  • Adjacent-list merge: Lexical silently merges adjacent 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.
  • Copy-on-write keys: Lexical may change node keys during editor.update(). #resyncWrappedKeys handles some cases but stale keys can persist after group operations that create/destroy nodes.
  • CSS :has() nesting: Browsers silently ignore nested :has() selectors. ul:has(> li:has(> h2)) is dropped — must flatten to ul:has(> li > h2).
  • Provisional paragraphs between decorators: Lexical inserts empty <p class="hidden"> separator paragraphs between adjacent DecoratorNodes 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.5px compensation margin) and computes the wrong neighbor distance.

Public API additions

API Where Purpose
editor.hasBlockSelection editor.js getter Check if block-select mode is active
editor.selectAllBlocks() editor.js method Enter block-select mode with all blocks selected
<lexxy-editor block-handles="true"> HTML attribute Enable/disable drag handles at runtime

Attachment rendering & media features

Show page rendering (blob template)

Lexxy ships app/views/active_storage/blobs/_blob.html.erb as a dispatcher plus per-type partials (_blob_audio, _blob_file, _blob_image, _blob_inline_image, _blob_video). _blob.html.erb picks the right partial based on blob.video? / blob.audio? / content type. Each per-type partial stays small and is independently overridable. Out-of-the-box rendering:

Content type Rendering
Video (mp4, webm, mov, mkv, avi) <video controls>
Audio (mp3, wav, ogg, flac, m4a, aac) File card with inline <audio controls> player below
GIF, animated WebP/AVIF, SVG <img> using direct blob URL (preserves animation/vector data — blob.representation would strip animation or rasterize SVG)
Other images (png, jpg, etc.) <img> via Active Storage representation (resize-to-limit)
PDF Image thumbnail representation (first-page preview)
Other files File card with icon + filename + size

The blob.video? and blob.audio? checks match any video/* or audio/* 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 in ICON_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-attachment to the modal; download triggers the browser's download flow. The eye button is hidden by default and only shown after lexxy-content-preview.js adds lexxy-content-preview-enabled to <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 emits app/assets/javascript/lexxy-content-preview.js alongside lexxy.js. The editor's <lexxy-preview-modal> custom element and this script share all modal-building logic via src/elements/preview/dialog_builder.js and src/elements/preview/playback_sync.js — both modals render and behave identically.

Provides:

  • Full-screen <dialog> modal with header (icon, caption, filename, file size) + content area
  • Content-type detection: image zoom, video player, audio player, PDF iframe, generic download fallback
  • Two-line header: shows custom caption as title with filename + size as subtitle (or filename as title with size below)
  • Playback time sync: opening modal from an inline player carries over currentTime; closing syncs it back. If the page media was playing, modal continues from the same spot.
  • Pause-others: playing any media element pauses all other video/audio on the page (installPauseOthers() from playback_sync.js)
  • Turbo-compatible via turbo:load listener

Host app integration — minimal:

# config/importmap.rb
pin "lexxy", to: "lexxy.js"
pin "lexxy-content-preview", to: "lexxy-content-preview.js"  # only for show-page modal
// app/javascript/application.js
import "lexxy";
import "lexxy-content-preview"; // optional; opts into show-page modal & preview button

The previewModal: true flag for the editor modal is now the default — no configure() call needed. Apps that want to opt out can set previewModal: 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 from app/assets/images/lexxy/)

The helper module is auto-included into ActionView::Base by Lexxy::Engine, so the partials just call its methods. Icon labels are just extension.upcase inline in the partials — no separate Ruby icon-label map.

Attachment state persistence

Two new data attributes on action-text-attachment elements persist editor state to the rendered page:

Attribute Effect in editor Effect on show page
data-collapsed Hides preview, shows file card Hides media, renders as file card with icon + filename + size
data-caption-hidden Hides caption on images; shows filename instead of custom name on files Same behavior via CSS + JS

Pipeline for persistence:

  1. Editor → Lexical node properties (collapsed, captionHidden)
  2. exportDOM() → data attributes on action-text-attachment element
  3. DOMPurify client-side allowlist (so attributes survive value serialization)
  4. ActionText::Attachment::ATTRIBUTES (so attributes survive server-side re-serialization)
  5. ActionText::ContentHelper.allowed_attributes (so attributes survive render-time sanitization)
  6. CSS attribute selectors + JS on the show page apply the visual state

Editor attachment controls (<lexxy-attachment-controls>)

Custom element at src/elements/attachment_controls.js (formerly node_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 via title plus matching aria-label. Button order:

Button Preview attachments File attachments Tooltip
Open (eye) Opens modal Opens modal "Open"
Collapse (chevron) Toggles preview ↔ card — (already a card) "Collapse preview" / "Expand preview"
Edit (pencil) Focuses caption editor (textarea or click-to-rename name) Click-to-edit inline filename "Edit name"
Caption toggle (text lines) Show/hide caption below media Toggle between custom name and original filename "Hide caption" / "Show caption"
Delete (trash) Remove attachment Remove attachment "Remove"

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 same attachment--audio layout 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 via representationToBlobUrl() in storage_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 the caption attribute. 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's splitAroundInvalidChild transform handles the ejection.

Shared building blocks (DRY composition)

ActionTextAttachmentNode#createDOM composes a small set of reusable methods:

Method Purpose
#createIconLabel() Extension badge (MP4, PDF, XLS, etc.)
#createNameTag() Display name <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() The media element itself
#createCardView() Collapsed-state card (icon + name + subtitle)

The same playbackUrl getter resolves the right URL for inline players and the modal — blobUrl (if known), otherwise representationToBlobUrl(src), otherwise src. The blob URL is stored on the figure's data-caption attribute (via updateDOM) 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:

# 1. Render-time sanitization (server-side, every page render)
ActionText::ContentHelper.allowed_tags += %w[
  video audio source table tbody tr th td
  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_attributes += %w[
  controls poster data-language style value autoplay loop muted playsinline preload
  viewBox xmlns d fill aria-label
  cx cy r rx ry x y x1 y1 x2 y2 points transform stroke stroke-width stroke-linecap stroke-linejoin
  stroke-dasharray stroke-dashoffset stroke-opacity fill-opacity opacity offset stop-color stop-opacity
  gradientUnits gradientTransform spreadMethod patternUnits patternTransform clip-path mask
  font-family font-size font-weight text-anchor dominant-baseline preserveAspectRatio
  data-collapsed data-caption-hidden
]

# Narrowed from an earlier iteration that also allowed `embed`, `download`,
# `target`, and `title` — all dropped to reduce XSS surface since nothing
# the editor emits needs them.

# Also needed for CSS var() references inside style="" (highlight colors etc.)
Loofah::HTML5::SafeList::ALLOWED_CSS_FUNCTIONS << "var"

# 2. ActionText attachment re-serialization (server-side, on save)
ActionText::Attachment::ATTRIBUTES.push("data-collapsed", "data-caption-hidden")
// 3. Client-side DOMPurify (src/config/dom_purify.js)
// Without this the editor's exported HTML loses these attributes before
// reaching the server.
ALLOWED_HTML_ATTRIBUTES includes: data-collapsed, data-caption-hidden

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:

  1. lexxy-editor.css inside :where(lexxy-editor) — editor file cards and collapsed cards
  2. lexxy-editor.css outside :where() — preview modal icons (appended to document.body)
  3. lexxy-content.css — show page rendering

Drag-and-drop improvements

Block handle drag

  • Preserved selection outlines: CSS rule .lexxy-block-dragging .attachment:hover narrowed with :not(.node--selected):not(.lexxy-dragging) so selected and dragged attachments keep their blue outlines
  • Esc snap-back animation: #cancelDragWithSnapBack() animates the ghost from cursor position back to the original element with a 200ms ease transition + fade, then cleans up
  • Clear non-dragged selection: #startDrag() removes node--selected from all elements except the one being dragged
  • Hover controls: Attachment floating controls now appear on hover (.attachment:hover &) in addition to selection

Attachment 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::Engine auto-wires these when the gem is mounted — the host app doesn't touch sanitizer config itself:

  • ActionText::Attachment::ATTRIBUTES gains data-caption-hidden and data-collapsed so the editor's UI state persists through the save → render → re-edit round-trip.
  • ActionText::ContentHelper.allowed_tags gains video, 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_attributes gains 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 in lib/lexxy/engine.rb.
  • Loofah::HTML5::SafeList::ALLOWED_CSS_FUNCTIONS gains var so --lexxy-* CSS variables survive sanitization.
  • ActiveStorage::Blob#as_json is patched to include previewable: true and a url pointing 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_area form helper is installed via Lexxy::FormHelper / FormBuilder.

Preview modal has two separate switches

Where How to enable Scope
Inside the editor (double-click or eye button on an attachment while editing) Enabled by default. Opt out with Lexxy.configure({ global: { previewModal: false } }) before any editors connect. Registers <lexxy-preview-modal> and appends one to document.body. With this off, the editor's preview button dispatches lexxy:preview-attachment but nothing handles it and the click is a no-op.
On rendered show pages (eye button on rendered <action-text-attachment>) import "lexxy-content-preview" in application.js (and a corresponding importmap pin) Standalone script that attaches modal behaviour to rendered content. Independent from the editor-side switch — apps can enable one, the other, both, or neither.

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

gem "rails"
gem "propshaft"             # or sprockets
gem "importmap-rails"       # or jsbundling — lexxy supports either
gem "turbo-rails"
gem "actiontext", require: "action_text"
gem "activestorage"
gem "image_processing"      # needed for Active Storage representations
gem "lexxy"

Then run bin/rails action_text:install and bin/rails active_storage:install, and run the resulting migrations.

JavaScript wiring

config/importmap.rb:

pin "application"
pin "@rails/actiontext",        to: "actiontext.esm.js"
pin "@rails/activestorage",     to: "activestorage.esm.js"
pin "@hotwired/turbo-rails",    to: "turbo.min.js"
pin "lexxy",                    to: "lexxy.js"
pin "lexxy-content-preview",    to: "lexxy-content-preview.js"  # only if show pages should open previews

app/javascript/application.js:

import "@rails/actiontext";
import * as ActiveStorage from "@rails/activestorage";
ActiveStorage.start();
import "@hotwired/turbo-rails";
import "lexxy";
import "lexxy-content-preview"; // show-page modal (optional)
// Editor-side modal (`previewModal`) is on by default.
// Call `Lexxy.configure({ global: { previewModal: false } })` here to opt out.

Stylesheet

<%= stylesheet_link_tag "lexxy" %> in the layout — that file @imports lexxy-content.css (show-page rules), lexxy-editor.css (editor chrome + block editing styles), and lexxy-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:

  • Per-type override (preferred): copy just the partial for the type you want to customize into your app (e.g. 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.
  • Full override: supply your own _blob.html.erb. You lose Lexxy's dispatcher, per-type partials, and the attachment action buttons unless you render lexxy_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.erb so 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:

Attachment type Needs What you lose without it
PNG / JPG / WEBP libvips (preferred) or ImageMagick Representations; editor falls back to the direct blob URL
Animated GIF Nothing extra
PDF poppler (brew install poppler) or mupdf Preview thumbnail — falls back to file card
Video (mp4 / webm / mov) ffmpeg Poster frame — falls back to file card
Audio (mp3 / wav / ogg) ffmpeg Metadata; audio still plays inline via <audio>
Other files (txt / zip / xlsx …) Nothing extra

Set the processor explicitly in config/environments/development.rb:

config.active_storage.variant_processor = :vips   # or :mini_magick

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

class Post < ApplicationRecord
  has_rich_text :body
end
<%= form_with model: @post do |f| %>
  <%= f.rich_text_area :body %>                                   <%# default editor %>
  <%# or, to exercise block editing: %>
  <%# <%= f.rich_text_area :body, data: { block_handles: "true" } %> %>
<% end %>

Manual test matrix

With the above wired up, walk through:

  1. Upload each: .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.
  2. Hover an attachment — confirm all five floating controls appear (preview, collapse, edit, caption toggle, delete).
  3. Click the collapse button on an image — it flips to card view; reload the show page — the data-collapsed attribute survives and the card renders there too.
  4. Click the caption toggle — caption hides/shows; verify data-caption-hidden round-trips.
  5. Click the preview (eye) button — modal opens; video/audio playback time carries over from inline to modal and back; Escape and Space work via <dialog>'s native handling.
  6. Open the show page, click the preview button on a rendered attachment — the show-page modal opens (proves lexxy-content-preview is wired).
  7. Toggle 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/:

Test file What it covers
block_selection.test.js Multi-block selection via keyboard and click
block_api.test.js Public API: selectAllBlocks(), hasBlockSelection
block_actions_menu.test.js Context menu: turn-into, colors, delete
block_drag_and_drop.test.js Drag handle positioning, drop targeting, reordering
block_movement_hierarchy.test.js Nested list movement: nest, promote, exit, re-enter
drop_edge.test.js Edge cases: empty lists, single items
drop_freeze.test.js Drag state freezing
drop_reparent.test.js Re-parenting blocks across list hierarchies
attachment_block_select.test.js Attachment interactions while in block-select mode
hr_block_select.test.js HR (horizontal rule) as a first-class block: select, wrap in list, move
table_block_select.test.js Tables as wrappable blocks: multi-block wrap, Shift+Tab extract, group move
single_undo_correctness.test.js One Cmd+Z reverts the full pre-action shape for Turn Into, Remove, and group moves (guards against HISTORY_MERGE_TAG trap)
wrapped_block_origin.test.js User-wrapped vs movement-wrapped origin: wrapped heading stays wrapped after move; movement-wrapped auto-unwraps on arrow-back
wrapped_block_outdent.test.js Shift+Tab at root: split-through extract of mixed wrapped/plain items, single-heading unwrap, single-quote unwrap

@jondkinney
jondkinney force-pushed the block-editing-standalone branch 6 times, most recently from 550b212 to 502e84a Compare April 3, 2026 18:28
zoltanhosszu and others added 24 commits April 7, 2026 14:15
* 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>
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.
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
jondkinney and others added 30 commits April 27, 2026 16:52
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants