Skip to content

fix(collapsible): allow multi-paragraph drag-selection in Chromium#10584

Merged
adoriandoran merged 1 commit into
mainfrom
fix/collapsible-selection-chromium
Jul 23, 2026
Merged

fix(collapsible): allow multi-paragraph drag-selection in Chromium#10584
adoriandoran merged 1 commit into
mainfrom
fix/collapsible-selection-chromium

Conversation

@eliandoran

@eliandoran eliandoran commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Problem

In the editor, dragging the mouse to select text inside a collapsible block (<details>) is capped to a single paragraph on Chromium/Electron. Firefox is unaffected.

Root-caused as a Chromium bug: inside a contenteditable, native mouse drag-selection cannot cross the ::details-content slot boundary when the body blocks are direct children of <details>. Keyboard selection (Shift+↓) and programmatic selection cross it fine — only the drag algorithm doesn't, which is why the model/cache never saw a problem. It reproduces on Chromium 150 (well ahead of stable), and no CSS (user-select, ::details-content { … }, display, content-visibility, …) works around it.

Fix

Wrap the body blocks in a <div class="trilium-collapsible-content"> in the editing view (elementToStructure + slots). A single wrapping container removes the selection boundary. <summary> stays a direct child of <details> (native collapse must keep it visible), so only the non-summary blocks are wrapped.

  • HTML signature unchanged — the data downcast stays flat <details><summary>…</summary><p>…</p></details>. The wrapper exists only in the editing view; nothing new is saved, no migration, existing notes load identically.
  • Open-state preservationelementToStructure reconverts the <details> on every body-block change, which rebuilds it closed and would collapse a block mid-edit. A WeakMap of open state (fed by the arrow toggle, setDetailsOpen, and auto-open) is read back in the downcast view() to re-apply the open attribute. A post-render fix-up doesn't work here because CKEditor fires its render event before the DOM is reconciled — setting the attribute in view() lets the renderer apply it correctly.
  • CSS: the body-placeholder-hide and last-child-margin rules were retargeted to the wrapper (editing view only; read/share view untouched).

Validation

  • 30/30 package tests pass (28 existing + 2 new: wrapper structure, and open-state survives a body-block change / reconversion). Typecheck + lint clean.
  • Verified the actual fix in real Chromium 150 against the exact DOM the plugin emits: selecting across all body paragraphs works, and dragging from inside the collapsible out across the <details> boundary works too.

Note: validated at the unit + isolated-browser level, not a full running-editor build — worth a quick manual smoke test that collapse/expand still feels right while editing a block's body.

🤖 Generated with Claude Code

Chromium caps a native mouse drag-selection to a single block when the
body blocks are direct children of a <details> inside a contenteditable:
its ::details-content slot acts as a hard selection boundary. Keyboard
and programmatic selection cross it fine, only the drag algorithm doesn't,
and Firefox is unaffected. It reproduces on Chromium 150 (ahead of stable),
and no CSS works around it (issue #10570).

Wrap the body blocks in a <div class="trilium-collapsible-content"> in the
editing view via elementToStructure + slots; one wrapping container removes
the boundary. <summary> stays a direct child so native collapse keeps it
visible, so only the non-summary blocks are wrapped.

The wrapper is editing-only: the data downcast stays flat
(<details><summary>...</summary><blocks>) so the saved HTML signature is
unchanged and existing notes load identically.

elementToStructure reconverts the <details> on every body-block change,
rebuilding it closed and collapsing a block mid-edit. Track open state in a
WeakMap (fed by the arrow toggle, setDetailsOpen and auto-open) that the
downcast view() reads back to re-apply the `open` attribute — a post-render
fix-up can't, because the view's "render" event fires before the DOM is
reconciled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dosubot dosubot Bot added the size:M This PR changes 30-99 lines, ignoring generated files. label Jul 20, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an editing-only wrapper (.trilium-collapsible-content) around the body blocks of collapsible elements (<details>) in CKEditor 5. This wrapper resolves a Chromium limitation where native mouse drag-selection is restricted to a single block when blocks are direct children of a <details> element. Additionally, the PR introduces a WeakMap (detailsOpenState) to track and preserve the open/collapsed state of the collapsible elements during reconversions triggered by editing. Tests and CSS rules have been updated accordingly. I have no feedback to provide as there are no review comments.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

@greptile-apps

greptile-apps Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a Chromium-specific bug where mouse drag-selection inside a <details> collapsible block is capped to a single paragraph. The fix wraps non-summary body blocks in a <div class=\"trilium-collapsible-content\"> in the editing view only, removing the ::details-content slot boundary that Chromium's drag algorithm cannot cross. Since elementToStructure reconverts the entire <details> DOM element on every child change (resetting it to closed), a WeakMap<model, boolean> tracks each block's open/closed state so the downcast view() can restore the open attribute before the renderer reconciles the DOM.

  • Editing-view structure change: body blocks are wrapped in .trilium-collapsible-content; <summary> stays a direct child; the data downcast and saved HTML are completely unchanged.
  • Open-state tracking: detailsOpenState is populated by setDetailsOpen() and the toggle DOM event handler; the downcast view() reads it to emit open=\"\" on the rebuilt <details> element, preventing the reconversion from silently collapsing an open block.
  • CSS: body-placeholder and last-child-margin rules in the editing scope are retargeted to the new wrapper.

Confidence Score: 4/5

Safe to merge after a quick manual smoke-test of collapse/expand while editing body content — the unit tests cover the core state-preservation path but cannot exercise the full editor rendering pipeline.

The detailsOpenState WeakMap correctly threads open state through CKEditor's reconversion cycle across all write paths (setDetailsOpen, toggle event, auto-open). The data downcast and saved HTML are untouched so no migration risk exists. The only gaps are hiddenBodyPostFixer reading DOM directly instead of isDetailsOpen() (harmless today but creates future drift risk) and a missing test for the Ctrl+Enter reconversion flow. Both are non-blocking quality items.

packages/ckeditor5-collapsible/src/collapsible-editing.ts — specifically the hiddenBodyPostFixer method and the onDomKeydown Ctrl+Enter handler, which are the two places that interact with open state outside the new isDetailsOpen/setDetailsOpen abstraction.

Important Files Changed

Filename Overview
packages/ckeditor5-collapsible/src/collapsible-editing.ts Core logic change: switches editing downcast from elementToElement to elementToStructure with slot-based body wrapper; adds detailsOpenState WeakMap to survive reconversion; updates setDetailsOpen, isDetailsOpen, and onDetailsToggle; hiddenBodyPostFixer still reads DOM directly instead of isDetailsOpen(), which is a minor inconsistency but not a functional bug.
packages/ckeditor5-collapsible/tests/conversion.ts Adds two new tests: wrapper structure assertion and open-state preservation across reconversion; simulates toggle via dispatchEvent which correctly exercises the onDetailsToggle handler. No test for Ctrl+Enter reconversion path.
packages/ckeditor5-collapsible/theme/collapsible.css Retargets body-placeholder :has rule and last-child margin rule to the new .trilium-collapsible-content wrapper; shared rule now harmlessly lands on the wrapper (no inherent margin) while the new editor-specific rule correctly zeros the last body block.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[User action: arrow click / Ctrl+Enter / auto-open] --> B{Action type}
    B -- Arrow click --> C[dom.open = !dom.open]
    B -- Ctrl+Enter --> D[dom.open = !dom.open]
    B -- Auto-open / drag restore --> E[setDetailsOpen node, bool]
    C --> F[Native toggle event fires]
    D --> F
    F --> G[onDetailsToggle]
    G --> H[detailsOpenState.set model, dom.open]
    E --> H
    H --> I{Body block change?}
    I -- Yes --> J[elementToStructure reconversion triggered]
    J --> K[view called]
    K --> L{detailsOpenState.get modelElement}
    L -- true --> M[attributes.open = empty string]
    L -- false / undefined --> N[no open attribute]
    M --> O[CKEditor renders new details DOM with open]
    N --> P[CKEditor renders new details DOM closed]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[User action: arrow click / Ctrl+Enter / auto-open] --> B{Action type}
    B -- Arrow click --> C[dom.open = !dom.open]
    B -- Ctrl+Enter --> D[dom.open = !dom.open]
    B -- Auto-open / drag restore --> E[setDetailsOpen node, bool]
    C --> F[Native toggle event fires]
    D --> F
    F --> G[onDetailsToggle]
    G --> H[detailsOpenState.set model, dom.open]
    E --> H
    H --> I{Body block change?}
    I -- Yes --> J[elementToStructure reconversion triggered]
    J --> K[view called]
    K --> L{detailsOpenState.get modelElement}
    L -- true --> M[attributes.open = empty string]
    L -- false / undefined --> N[no open attribute]
    M --> O[CKEditor renders new details DOM with open]
    N --> P[CKEditor renders new details DOM closed]
Loading

Comments Outside Diff (1)

  1. packages/ckeditor5-collapsible/src/collapsible-editing.ts, line 1351-1354 (link)

    P2 hiddenBodyPostFixer bypasses isDetailsOpen() abstraction

    hiddenBodyPostFixer reads dom.open directly while the rest of the open-state logic was specifically centralised into isDetailsOpen() to handle the mid-reconversion window where the rebuilt DOM element is momentarily closed. Post-fixers run before the view reconversion, so the DOM still reflects the correct prior state here, but this is the one place in the file that doesn't go through the new abstraction. If getDom() returns null (DOM not yet rendered for a freshly inserted block), the block is treated as "open" and the caret is left alone — the same semantics as isDetailsOpen(), so the fallback is consistent. The gap is mainly a future maintenance risk: a reader might not notice the subtlety and replicate the raw DOM check elsewhere, accumulating drift from isDetailsOpen().

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

    Fix in Claude Code

Fix All in Claude Code

Reviews (1): Last reviewed commit: "fix(collapsible): allow multi-paragraph ..." | Re-trigger Greptile

Comment on lines +72 to +95
it("preserves the open state across the reconversion a body-block change triggers", () => {
editor.setData("<details><summary>Title</summary><p>First</p></details>");
const root = editor.editing.view.getDomRoot();
const selector = "details.trilium-collapsible";
const detailsBefore = root?.querySelector<HTMLDetailsElement>(selector);
// Simulate the user opening the block: flip `open` and fire the native
// `toggle` the plugin listens for (the arrow's click handler does the same).
if (detailsBefore) {
detailsBefore.open = true;
detailsBefore.dispatchEvent(new Event("toggle"));
}

// Add a second body paragraph — this changes the <details> children and
// makes elementToStructure rebuild its DOM (fresh, hence closed).
editor.model.change((writer) => {
const details = editor.model.document.getRoot()?.getChild(0);
if (details?.is("element", "details")) {
writer.insertElement("paragraph", details, "end");
}
});

const detailsAfter = root?.querySelector<HTMLDetailsElement>(selector);
expect(detailsAfter?.open).toBe(true);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No test coverage for Ctrl+Enter → reconversion open-state path

The new test covers the arrow-toggle → reconversion path (setting open directly + dispatching toggle). A symmetric scenario is missing: Ctrl+Enter in onDomKeydown sets dom.open = !dom.open directly rather than calling setDetailsOpen(), relying on the native toggle event propagating to onDetailsToggle to update detailsOpenState. This chain works correctly in practice, but a test that fires a synthetic keydown with Ctrl+Enter and then adds a body block would guard against a regression if the Ctrl+Enter handler is ever refactored to not emit a toggle event.

Fix in Claude Code

@codecov

codecov Bot commented Jul 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

📢 Thoughts on this report? Let us know!

@codecov

codecov Bot commented Jul 20, 2026

Copy link
Copy Markdown

Bundle Report

Changes will increase total bundle size by 738 bytes (0.0%) ⬆️. This is within the configured threshold ✅

Detailed changes
Bundle name Size Change
client-esm 49.53MB 738 bytes (0.0%) ⬆️

Affected Assets, Files, and Routes:

view changes for bundle: client-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
src/dist-*.js 26 bytes 82 bytes 46.43% ⚠️
src/dist-*.js -26 bytes 56 bytes -31.71%
src/src-*.js 603 bytes 555.23kB 0.11%
src/src-*.js -70 bytes 139 bytes -33.49%
src/src-*.js 70 bytes 209 bytes 50.36% ⚠️
src/src-*.css 135 bytes 252.98kB 0.05%

@eliandoran eliandoran added this to the v0.105.0 milestone Jul 21, 2026
@adoriandoran
adoriandoran merged commit 89a0054 into main Jul 23, 2026
16 checks passed
@adoriandoran
adoriandoran deleted the fix/collapsible-selection-chromium branch July 23, 2026 07:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M This PR changes 30-99 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants