Repository navigation
Description
Problem
Text cannot be copied across node boundaries. Selecting a passage that starts partway through one node and ends partway through another, then pressing Cmd+C, does not reliably produce that text — the selection is collapsed before the copy happens.
The goal is ordinary prose copying: select a stretch of writing that happens to span several nodes, and get it on the clipboard.
Scope decision: text-level, not block-level
This issue previously left open "text-level selection or block-level (checkbox) selection?". Resolved: text-level.
Block-level selection copies whole nodes and cannot express a range that begins mid-node. That is a different feature — bulk operations on a set of nodes — and is not what this issue is for. See the note on the superseded issues below.
Current state
The rendering model already supports this; the interaction does not.
Only one node is a textarea at a time. packages/desktop-app/src/lib/design/components/base-node.svelte:855 branches on isEditing, derived from FocusManager (:92-95), which is true for a single node in a single pane. Every other node renders as a plain <div> wrapping ViewModeRenderer (:934). A page is therefore ordinary DOM, not a stack of textareas, and native cross-node selection is structurally possible.
Nothing currently intercepts selection or copy.
- No
copy/paste/clipboardDatahandlers exist anywhere inpackages/desktop-app/src/lib. The only clipboard code is three isolated copy-to-clipboard buttons (design/components/code-block-node.svelte:261,components/chat/chat-message.svelte:16,components/collaboration/collaboration-view.svelte:166). - No
user-select: noneon node content. Readonly node content setsuser-select: text(base-node.svelte:1098) and view content setscursor: text(:1090).
The blocker
The view container steals the selection on drag. base-node.svelte:872-908 puts an onclick on the view <div> (which also carries role="button"). On every click it reconstructs a mock element, maps the click coordinate to a character index, translates view-position → edit-position to account for stripped markdown syntax, and calls focusManager.focusNodeAtPosition(...) — swapping the div for a textarea.
The handler does not distinguish a click from the end of a drag. So a click-and-drag text selection ends by focusing a textarea and collapsing the selection.
This is the core fix and it sits in delicate code: the same handler is what makes click-to-edit land the caret on the exact character the user clicked. A drag guard must not regress that.
While in there: role="button" on selectable prose is also an accessibility problem worth correcting.
Scope
1. Preserve the selection on drag
- Guard the view-container click handler so it only focuses a node on a genuine click, not on the mouseup that ends a drag selection (e.g. no focus when the resulting selection is non-empty, or when the pointer moved beyond a small threshold between mousedown and mouseup)
- Click-to-edit-at-exact-character must continue to work unchanged for real clicks
- Revisit
role="button"on the view container
2. Copy handler
A copy listener that produces useful markdown rather than the browser default.
Default DOM serialization yields the rendered view text — markdown syntax already stripped by ViewModeRenderer — concatenated with no node boundaries. That is usually not what the user wants when copying from a markdown editor.
- Detect a selection spanning more than one node
- Identify the spanned nodes and whether each is fully or partially covered
- Assemble clipboard content from the underlying node
content(with syntax) rather than rendered text, clipping the first and last nodes to the selection offsets - Set both
text/plainand, where sensible, a richer flavor - A selection inside a single node keeps native behavior — do not intercept what already works
3. Hierarchy
Copying across parent/child boundaries should preserve nesting in the output as markdown indentation. Decide and document the treatment of a selection that starts in a child and ends in an unrelated subtree.
Out of scope
- Paste. Parsing pasted markdown back into nodes — node-type inference, hierarchy reconstruction, splitting into multiple nodes — is a materially larger problem and is deliberately excluded. File separately if wanted.
- Block/checkbox selection of whole nodes and bulk operations (delete, indent, move) on a selected set. There is currently no UI surface for node operations at all: no context menu (
contextmenuhas zero hits insrc/lib), no toolbar, and deletion exists only as a side effect of Backspace in the editor (base-node-viewer.svelte:809-905). That is its own feature, not a prerequisite for copying text. - Cross-pane selection.
Acceptance criteria
- Click-and-drag selects text across node boundaries without the selection collapsing
- Click-to-edit still places the caret on the clicked character, unchanged
- Cmd+C on a multi-node selection copies markdown source, not stripped rendered text
- First and last nodes in the range are clipped to the selection offsets
- Nesting is preserved as indentation for selections spanning parent/child
- Single-node selections retain native copy behavior
- Selection behavior is unaffected while a node is in edit mode (that node is a textarea)
- Tests cover: partial-first/partial-last ranges, full-node ranges, cross-hierarchy ranges, and single-node passthrough
History
Supersedes #35, #36, #37, #38 — a 2025 chain specifying a block-level id-set selection model for bulk operations. Those were written against a file layout that no longer exists (BaseNode.svelte, NodeTree.svelte, src/lib/design/tokens/, Svelte 4 stores, in-repo docs/, LanceDB) and targeted a different feature than copying text.
The dependency this issue originally noted, the textarea editor migration (#274), is closed.
Notes
Standards: bun run test (never bun test), createLogger rather than console.*, no lint suppression, Svelte 5 runes, kebab-case filenames.
Activity
[-]Implement cross-node selection and copy/paste[/-][+]Copy text across node boundaries[/+]Preserve cross-node text selection on drag (#278 part 1) (#2021)
mstomar125 commentedon Aug 10, 2026
Part 1 merged (PR #2021, main
ff221ba9) — Scope §1 (preserve the selection on drag) + therole="button"→role="textbox"/aria-readonlya11y correction.The view-container click handler now skips focusing when there's an active (non-collapsed) text selection (
isActiveTextSelection, new$lib/utils/text-selection.ts), so a cross-node drag-select survives instead of collapsing; click-to-edit-at-character is unchanged (collapsed selection → focus proceeds). Reviewed APPROVE — verified click-to-edit non-regression and that Part 1 alone is a safe, net-positive intermediate state (selection now survives → native Cmd+C yields stripped text, strictly more than the nothing it produced before). Noted behavior change: shift/double/triple-click leave the node unfocused (selection gestures, intended).Remaining — Part 2 (§2 copy handler + §3 hierarchy). Design (ready to implement):
copylistener on the viewer scroll container (base-node-viewer.svelte, onMount/onDestroy).window.getSelection(); find the start/end[data-node-id](closest ancestor of the range endpoints). Same node → return (native single-node copy untouched). Different → collect the spanned[data-node-id]elements in document order.contentfromsharedNodeStore.getNode(id); depth from the viewer'sflattenNodes(for indentation). Clip the first node from the selection start and the last to the selection end: compute the rendered offset by walking the node's view element counting chars the same wayextractTextWithLineBreaksdoes (text length + 1 per<br>) up to(range.startContainer, startOffset), thenmapViewPositionToEditPosition(renderedOffset, viewText, content)(view-edit-mapper.ts) → source offset →content.slice(...). Middle nodes: full source content.e.preventDefault()+clipboardData.setData('text/plain', …).isActiveTextSelectionfrom Part 1. ⚠The rendered↔source offset consistency across<br>boundaries is the delicate part — worth thorough tests.Keeping this issue open for Part 2.
Copy markdown source across node boundaries (#278 part 2) (#2026)
mstomar125 commentedon Aug 11, 2026
Part 2 resolved in #2026 (squash-merged to
main). With Part 1 (#2021) this completes all in-scope work — closing.Part 2 (§2 copy handler + §3 hierarchy): a
copylistener on the viewer container turns a selection spanning more than one node into the underlying node source markdown (with syntax), instead of the browser default (rendered text, syntax stripped, no node boundaries):.node[data-node-id]roots of the range endpoints; a single-node selection is left to native copy.extractTextWithLineBreaks, +1 per<br>) to a source offset viamapViewPositionToEditPosition— the same mapping click-to-edit uses. Middle nodes are copied whole.cross-node-copy.ts).Adversarial review caught (and we fixed) a real bug — inline references render as
<a class="ns-noderef" data-node-id>inside a node, so the scan had to be scoped to.node[data-node-id]node roots to avoid phantom lines / wrong-node clipping.Known limitations (both confined to a single clip boundary; whole/middle nodes are always exact source): a boundary falling after a reference within the first/last node, or inside a paired inline marker, can map imprecisely / slice a marker mid-pair.
Out of scope, as noted in the issue (file separately if wanted): paste (parsing markdown back into nodes), block/checkbox selection + bulk node operations, and cross-pane selection.