Block-based editing: selection, drag-and-drop, and actions menu#14
Open
jondkinney wants to merge 213 commits into
Open
Block-based editing: selection, drag-and-drop, and actions menu#14jondkinney wants to merge 213 commits into
jondkinney wants to merge 213 commits into
Conversation
Replaced try/finally with isAttached() check before getWritable(). If the node was deleted, the callback simply skips the state mutation — no throw, and #revokePreviewSrc always runs unconditionally afterward.
Uploads an image with delayed blob response, deletes the attachment, then releases the response
550b212 to
502e84a
Compare
Clear formatting
Keep local preview URL until server response
`registerListener` wraps the native `addEventListener` to return a convinience `deregisterListener` function. The deregister function does not prevent GC of either the element or the callback (which would potentially pin the entire class context via `this`). Calling the deregister function muliple times or when the element or listener have been GC'd is a harmless noop.
A convenience wrapper around an array of handlers that responds to the common `dispose()` clean-up API.
Same pattern as the other two getListItemNode replacements. The method now reads top-to-bottom as 'find the list item, then check parent highlight against it' instead of a walk-up loop.
Playwright on webkit doesn't route a click on a figure's padding/ caption region to the inner img's Lexical DecoratorNode selection handler, so figure.node--selected never appears and the tests time out with "Expected: 1, Received: 0". Chromium and Firefox happened to hit the img anyway because their hit-test for a figure click lands on the center img; webkit picks a different node when the figure has its own padding. Match the pattern used everywhere else in the attachment test suite (gallery.test.js:382, attachments.test.js:62, etc.) and click figure.attachment img directly. Verified locally on both chromium and webkit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4810164 to
d7225a7
Compare
Upstream's Lexical ListItemNode now serializes `value="N"` on every rendered <li>, reflecting its internal counter state. Our block-editing test suite was written before that change and asserts on bare <li> structure. Strip the attribute in stripDynamicAttrs so it sits alongside the other transient-state attrs it already scrubs (data-bullet-depth, data-list-item-type, data-block-movement-wrapped) — the block tests care about structure, not list position. Apply stripDynamicAttrs to the three raw-HTML callers in block_actions_menu.test.js and table_block_select.test.js that skip assertBlockHtml (toContain / toMatch against editor.value() directly). All 103 block_editing tests pass on chromium and webkit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The check queried for `lexxy-prompt[open]`, but lexxy-prompt never sets an `[open]` attribute — its `get open()` is derived from the popover's `.lexxy-prompt-menu--visible` class. So the check always returned false, and pressing Escape to dismiss a prompt popover fell through to block-select activation (instead of being a no-op for block-select). The popoverElement is appended to the lexxy-editor element (not inside lexxy-prompt), so the correct selector is just `.lexxy-prompt-menu--visible` scoped to the editor element. Symptom: after typing `#` to open a remote-filter prompt and pressing Escape to dismiss, the editor silently entered block-select mode. Subsequent typing didn't land as text. Manifested only on firefox and webkit in CI — on chromium the event order happened to let the prompt's DOM handler close the popover in time for a different code path, masking the bug. Fixes markdown_heading_remote_filter_prompt.test.js "space during cached popover reload" on firefox/webkit and mentions.test.js "popover stays within viewport" on webkit (same underlying cause). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Clicking an attachment img to select it (for the list-format-while- attachment-selected regression tests) is flaky across browsers because the floating attachment-controls overlay appears on Playwright's hover- before-click and intercepts the click. Webkit misses the click entirely; chromium clicks the overlay's Remove button. Hide lexxy-attachment-controls via addStyleTag in beforeEach. The tests are about Lexical's list commands running on a NodeSelection, not about the controls overlay. Verified stable on chromium/firefox/webkit with --repeat-each 3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The test sets a 150px-wide editor in a 400px viewport to verify the popover stays inside the viewport when the trigger is near the right edge. With block-handles enabled (the branch default), the 62px left/ right gutter shrinks the content area to ~26px — too narrow for the popover-positioning logic to resolve within viewport bounds on strict layout engines (webkit on Linux in CI). Set block-handles="false" on the editor for this test only so it uses the 1ch content padding the test was designed around. This preserves the test's intent (right-edge popover clamping) without relying on block-handle layout. Verified stable on chromium/firefox/webkit with --repeat-each 3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous 400px viewport × 150px editor setup left the @ trigger at an unstable x-position on webkit/Linux CI: the 26px of usable content (with block-handles enabled) forced "Some text @" to wrap, and the resulting cursor.x — derived via rect.left - rootRect.left in selection.js — sometimes came out negative, which the popover positioner fed into inset-inline-start, producing rect.left ≈ -24. Widening to 600px viewport × 300px editor still anchors the editor's right edge at the viewport right edge (margin-left: auto), so the test still exercises the edge-clamping logic. But now "Some text @" fits on one line in any block-handles config, cursor.x stays positive, and the popover positioning math has room to clamp within the viewport. Dropped the per-test block-handles="false" override — no longer needed with the wider viewport and keeps the test exercising the real default config. Verified 9/9 across chromium/firefox/webkit with --repeat-each 3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The modal's .lexxy-preview-modal__image used max-width/max-height: 100% with object-fit: contain. For bitmap images the intrinsic size (4000px- wide PNG, etc.) clamps down to the container width and fills. SVGs report their tiny viewBox size as naturalWidth/Height (e.g. 200×200), so max-* never triggers and the SVG renders at its declared viewBox size inside a ~1400px-wide modal. Switching to width: 100%; height: 100%; object-fit: contain gives the img a size to fit into, and contain still letterboxes aspect ratios. min-height: 0 keeps the img from enforcing a minimum intrinsic height inside the flex content container. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two entry points opened the preview modal for an attachment:
- attachment_controls.js #openPreview — eye button in the floating
controls. Read src from figure.querySelector("img").src.
- action_text_attachment_node.js #handlePreviewClick — dblclick on the
figure. Read src from this.src (the Lexical node's model property).
For SVG attachments the two paths diverge in a way that matters:
cachedSvgObjectUrl swaps the inline <img>'s src to a blob: object URL
at render time, because ActiveStorage serves image/svg+xml with
Content-Disposition: attachment (so the raw URL won't render in a
modal <img>). The eye-button path saw the swapped URL and rendered;
the dblclick path handed the modal the raw URL and the SVG came up
blank.
Extract attachmentPreviewDetail(figure) / dispatchAttachmentPreview(
figure) into src/helpers/attachment_preview_helper.js and call it from
both sites. Both now read from the live DOM, so the swap result (and
any future figure-level state) is automatically visible to the modal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The block-editing PLAN drifted from the code during rebases and
refactors. Bring it back in sync:
- Branch stats: 97 files, 16,757 insertions, 386 deletions (was 62
files, ~12,800 lines).
- File inventory: reflect the src/editor/block_selection/ split
(drag_and_drop subdir + 4 sibling modules), add attachment_controls,
preview/playback_sync, per-type blob partials, SVG icon assets.
Drop attachment_icon_helper.rb (deleted; labels now extension.upcase
inline in partials).
- Line counts: updated for files that grew or shrank.
- contents.js: #findParentListItem is gone — now uses getListItemNode
from src/helpers/lexical_helper.js.
- Preview module paths: src/preview/* → src/elements/preview/*.
- Sanitization allowlists: dropped embed, download, target, title;
added value (upstream) and the full SVG element/attribute set.
- Show-page blob template: dispatcher + per-type partials.
- Test coverage: 14 test files (was 8); added attachment_block_select,
hr_block_select, single_undo_correctness, table_block_select,
wrapped_block_origin, wrapped_block_outdent; dropped drop_debug.
- Known limitations: trimmed to the one remaining caveat (drop at
very deep nesting where the handle isn't viewport-reachable); the
other two ("undo after group ops", "stale keys after create/destroy")
are now covered by single_undo_correctness and wrapped_block_*
tests respectively.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the active selection is a decorator or table block, list and quote
"Turn into" options wrap the block rather than convert it. Switch the
button label ("Bullet list" → "Wrap in bullet list") so users can see
before clicking whether the action converts or wraps. Text-content
blocks still read as direct conversions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Movement: - #moveTopLevelBlock now lands the block as a sibling of the adjacent list item on first movement; nesting under that item happens on the next press. Matches the regression tests' two-phase entry expectation. - #moveRootLevelGroup skips empty separator paragraphs so group moves near decorator nodes advance visibly (paralleling the skip already in the single-block and same-parent paths). - #moveGroupAtomically now splits a root-level list when only a subset of its items are selected, so a group move doesn't drag along unselected siblings. Selection sync: - #syncSelectionGroupClasses drops the parent-fill exception so regular LIs following a wrapper-with-selected-children flat-connect into the same continuous band. - #isInMixedList matches the CSS's own "mixed list" classification: only lists with direct wrapped children as siblings of text bullets. Wrapped content inside a structural wrapper no longer flags the outer list as mixed, so select-mid/first/last styling can apply to outer text bullets even when a wrapper with nested wrapped content sits between them. Parent height: - #syncParentSelectionHeight uses wrapper.getBoundingClientRect().bottom when the last selected descendant is the wrapper's last li, so any trapped margin on the deepest wrapped block (figure's 16px etc.) is covered by the highlight instead of leaving the margin area visually unhighlighted below the child. Tests: - wrapped_block_origin.test.js updated press counts to reflect the new sibling-first entry pattern (+1 press to nest, -1 press to exit). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously, selecting a parent LI whose wrapper contained nested
content would tighten the flow layout: a conditional
\`margin-top: 0\` on the wrapper-when-children-selected rule collapsed
a 6px gap to 0, making the nested block visibly jump on selection.
Zeroing \`margin-block\` on wrapped children inside lists (to contain
margin collapse through empty wrapper ancestors) also left baseline
layouts too tight.
Three changes:
1. display: flow-root on li.lexxy-nested-listitem establishes a block
formatting context at the wrapper boundary. Nested wrapped
content's margins (figure 16px bottom, heading 16px bottom, etc.)
stay trapped inside the wrapper instead of collapsing up through
the empty ul/li chain and inflating the outer list rhythm. Outer
lists keep their tight 6px Notion-style cadence.
2. Removed the selection-conditional \`margin-top: 0\` on the wrapper
rule so the wrapper's 6px margin-top is preserved regardless of
selection state. No flow property toggles on .selected now — the
absolute ::after (whose height is computed via
--parent-selection-height) covers the full wrapper area, so the
highlight is visually continuous without the layout shifting.
3. Zeroed \`margin-block\` on wrapped blocks inside list items (li >
h1-h6, blockquote, figure, .attachment, .attachment-gallery, pre,
code[data-language]). Without this, each wrapped block's natural
16px bottom margin collapses with the li's 12px margin-bottom and
widens the inter-li gap to 16px, producing an 8px visible gap in
selection (16 − 4 − 4) instead of the intended 4px mixed-list
rhythm. Applying the same treatment to every wrapped block type
gives uniform 4px gaps between every pair in a mixed list, whether
both siblings are text bullets, both are wrapped blocks, or one of
each.
Also removed the now-dead wrapper-inner-ul margin-block: 0 override,
the \`+ li { margin-top: 6px !important }\` inner-sibling rule, and
the triple-class -2px ::after inset override for wrapper's inner lis:
these were workarounds for the old zeroed-margin approach, now
handled by the combination of flow-root + restored natural margins.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Updates the plan's status section to reflect the movement, selection- sync, parent-height, and spacing-rhythm work landed in recent commits on this branch. No scope changes — just keeping the tracking doc current with what's shipped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…er insets When a selected LI has no selected siblings in its list AND no selected ancestor wrapper, its ::after uses the static pair-rhythm insets (-2/-2 tight, -4/-4 mixed). Those produce the intended 4px visible gap only when the adjacent item is also selected. For a solo-selected leaf inside a wrapper chain, the actual distance to the nearest unselected block above/below can be much larger (wrapper boundaries, trapped margins, cross-list transitions): e.g. 12px above Mike → 8px visible gap, and 18px below Mike → 14px visible gap. The highlight visually floats, gapped from its surroundings by much more than the 4px rhythm a mixed-list band would show. New #syncIsolatedLeafInsets method detects this case, walks out to the nearest structural sibling on each side (crossing ancestor ULs and wrapper boundaries in document order), and computes --leaf-top-inset / --leaf-bottom-inset so the ::after's visible top/bottom land 4px away from those adjacent blocks' edges. Only applies when the target isn't itself selected — if it is, the default pair-rhythm insets are correct and any extension would overlap the target's own highlight. Also adds --isolated-leaf class and a CSS rule consuming both vars with !important to beat position-sensitive first/last and mixed-list ::after inset rules at the same specificity tier. The fix only adjusts the absolute ::after overlay — no flow property changes, no layout shift on selection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…selected The initial isolated-leaf fix skipped extension whenever the adjacent block was selected or contained a selected descendant, on the theory that the neighbor had its own highlight doing the spacing. That's true when the neighbor is a LIST sibling (pair rhythm handles it), but fails when the neighbor sits across a wrapper boundary — e.g. Lima (inside wrapper2) and Mike (outside wrapper2) are document-order adjacent but structurally separated by 24px of trapped-margin + list rhythm. With neither side extending, the visible gap stays at the full structural distance (~20px). Revised logic: detect whether the adjacent block is itself selected or contains a selected descendant. If so, treat that selection as the measurement target and extend HALFWAY so each leaf claims (distance − 4) / 2 of the space. Both sides arrive with 4px between their highlight edges, producing the desired mixed-list rhythm across wrapper boundaries. If the adjacent is fully unselected, extend fully toward its edge leaving a 4px gap (unchanged behavior). Both branches use the same !important CSS rule on .isolated-leaf::after; only the math differs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ght heights Previously, the isolated-leaf extension branched on whether the adjacent block was selected — extending fully toward unselected neighbors (4px gap) and halfway toward selected ones. That produced the tightest possible gap in every case, but changed a leaf's highlight height as selection grew around it. Screenshots showed Mike's and Lima's highlights shrinking/growing depending on whether the neighbor was also selected, and a layout inconsistency where the gap between Lima and Mike measured 8px in some selection states and 4px in others. New approach: pre-determined halfway-to-neighbor reach. Each leaf's ::after top and bottom are computed purely from the distance to its adjacent block in document order (walking through wrapper boundaries and descending into nested-listitems to the actual content leaf on the facing side). Reach = (distance − 4) / 2 on each side. Trade-off: - Both adjacent selected → each extends half, meeting with 4px gap. - Only one selected → that one extends to the midpoint; visible gap to the unselected neighbor is (distance + 4) / 2 (larger than 4 but consistent regardless of neighbor state). Parent-selection-height now uses lastSelectedChild.bottom + lastSelectedChild.bottomReach — same reach math as the leaf would use individually. So switching between "parent + deep child" and "only deep child" leaves the fill's bottom edge at exactly the same spot instead of shifting. Drop the old isolated-leaf "isolated" guard (hasSelectedListSibling) and the neighbor-selection branching (prevSelected/nextSelected). The new reach is selection-agnostic, so all selected leaves receive consistent insets. Also drop the previous parent-bottom special- casing on wrapper.bottom vs lastChild.bottom — the reach-based formula subsumes both. Renamed #syncIsolatedLeafInsets → #syncLeafInsets to reflect the broader scope. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root-level attachments (both open/preview and closed/file cards) use the generic ::after highlight with attachment-specific insets (-7 all around to compensate for the card's 0.75ch padding). Two adjacent attachments at root would produce variable 2–4px gaps depending on which attachment types were involved and how their natural margins collapsed — visibly inconsistent. Extend the halfway-to-neighbor leaf-reach system to cover: - FIGURE.attachment (open/preview and closed/file attachments) - .attachment-gallery - LI (already covered) Explicitly exclude: - .lexxy-nested-listitem (structural wrapper — handled by parent-selection-height) - .horizontal-divider (fixed inset pinned to the figure's padding box) - .lexxy-content__table-wrapper (uses background-color on the box; ::after is display:none) CSS rule rewritten: - Drops the `li` selector prefix so it applies to any element with the isolated-leaf class. - Uses `[style*="--leaf-top-inset"]` attribute selector to scope each override to the specific direction JS actually set — this preserves the element's default bottom (or top) inset when only one side has a computed reach, which matters for attachments whose defaults are -7 (not the generic -2). Attachment-to-attachment gaps now uniformly measure 4px: each attachment extends halfway to its neighbor, meeting in the middle at the 4px target regardless of attachment type combinations. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Math.round on (distance - 4) / 2 could flip the target gap from 4px to 3px on odd-pixel distances (e.g. subpixel box positions from getBoundingClientRect). Each side rounded independently, and when both rounded up, the combined extension ate an extra pixel. Letting the reach stay fractional (e.g. 6.5px) keeps the math exact: each side extends the same fractional amount, meeting at the exact 4px target. Browsers handle subpixel ::after positions fine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t blocks Lexical auto-inserts empty P.provisional-paragraph.hidden separator elements between adjacent decorator nodes (attachments, HRs, etc.). The halfway-reach walking logic was treating these separators as the adjacent neighbor, measuring reach against a zero-/near-zero-height invisible box instead of the actual visible adjacent attachment. For MP3→GIF specifically, it also combined with the -5.5px margin-top compensation rule on attachments-after-hidden-separators (from the placeholder-height correction): MP3's bottomReach was computed toward the hidden separator 16px away, while GIF's topReach came back null because the hidden separator sat BELOW GIF.top (negative margin pulled GIF up past it). Result: MP3 extended 6px down, GIF extended 0px up, leaving a ~3px visible gap instead of 4px. Skip elements with .hidden class when walking for adjacent blocks. Both sides now find each other as each other's neighbor (not the invisible separator between them), and both halves compute the same distance for symmetric reach. Visible gap lands at exactly 4.00px. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the constant 4px TARGET_GAP in #computeLeafReach with a pair- based classifier (#targetGapBetween) so plain-text rhythm lines up at 2px and mixed-list rhythm lines up at 4px. Rules: * Same parent list, both plain-text LIs → 2px (Tango↔Uniform at the outer OL; Papa↔Quebec inside a wrapper's inner ul when both are plain text). * Same parent list, anything wrapped → 4px (Papa with h3 next to Quebec keeps the mixed rhythm inside the wrapper). * Different parents (crossing a structural wrapper boundary) → 4px by default so an individually-selected deep leaf keeps its wrapped rhythm when meeting the next outer-level sibling. * Different parents + wrapper's outer-level owner is currently parent- taken-over (owner selected AND wrapper has selected descendants) → classify as owner↔outer-sibling. When both are plain-text LIs in the same outer list, use 2px so the unified parent-takeover highlight lands 2px above the next plain-text sibling's highlight. Both sides of the parent-takeover pair — lastChild.bottomReach (used by #syncParentSelectionHeight to position the owner's unified bottom) and the outer sibling's topReach (from #syncLeafInsets) — run through the same classifier, so they agree on the target and meet at the intended gap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…caveat Update stats (98 files / 17,080 ins / 387 del, extension at 4,457 lines, CSS deltas at +2,005 / +431), add #syncLeafInsets to the DOM- sync section, note that parent-takeover bottom anchors on the last child's bottomReach so heights stay stable as selection grows from leaf → group → parent, document layout-shift-free selection + the halfway-reach invariant in the CSS architecture section, and call out Lexical's hidden provisional paragraphs between decorators as a reconciler caveat for adjacency math. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Update stats (17,165 insertions, extension at 4,535 lines) and replace the "always 4px" language with the per-pair classification: 2px for plain-text LIs sharing a parent list (tight Notion rhythm), 4px for mixed-list and cross-wrapper pairs, except cross-wrapper pairs whose outer-level owner is in a parent-takeover reclassify as owner↔outer-sibling and pull tight to 2px. Both sides of any adjacent selected pair run through the same classifier so the reaches agree on the target. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ance - previewModal is on by default (src/config/lexxy.js:9); update the opt-in table, the `Lexxy.configure` call in application.js, and the "default is false for both" note to reflect that apps opt OUT, not in. - Soften the blob-partial guidance: the per-type partials are independently overridable, so "delete your custom _blob.html.erb" is the test-app shortcut, not a hard requirement. Document the preferred per-type override path and the full-override escape hatch (with `lexxy_attachment_actions` for keeping the action buttons). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- command_dispatcher.js: +121 → +187, and expand the description to match the current surface (unified COMMANDS map, scroll preservation now wraps undo/redo + every format command, Tab handling now covers lists as well as code). - block_actions_menu.js: 658 → 671. - attachment_controls.js: 208 → 186 (shrunk via DRY extraction). Other inventoried files and core file deltas re-verified and still match; no action needed there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pper Both methods were zero-arg aliases for #extractContentToRoot(), each called from exactly one `case "remove-*"` arm of #handleBlockAction. Fallthrough the two arms directly to #extractContentToRoot, delete the two wrapper methods, and drop a stale method-description comment that predated the current "strip the full wrapper chain" semantics. Drops 10 LOC without changing behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extension shrunk to 4,525 lines after c351634; branch total drops to 17,161 insertions. Three spots updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Playwright's default click lands on the element's geometric center, which can fall on a decorator node (image, HR figure, attachment) when the editor has mid-document decorators. Clicking a decorator promotes the editor into block-select / node-selection mode before the caller's subsequent select()/send()/paste() runs — the resulting state isn't what the test set up. This surfaced as a chromium+webkit CI failure on block_actions_menu's "Remove Bullet on a nested wrapped block extracts it all the way to root" test after the selection-highlight layout-shift fix compacted the contenteditable's height enough that its center now falls on a nested HR figure. Replace the center-click with a DOM-level walk to the last non-empty text node: plant a zero-width range at its end, focus the editable. That matches what click-the-text would have done in an editor without mid-document decorators. Respect any selection the caller already set (e.g. node-selection from clicking an attachment) — focus without disturbing the range. Fall back to click() if the editor is truly empty. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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