Skip to content

Copy text across node boundaries #278

Closed

Description

@malibio

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 / clipboardData handlers exist anywhere in packages/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: none on node content. Readonly node content sets user-select: text (base-node.svelte:1098) and view content sets cursor: 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/plain and, 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 (contextmenu has zero hits in src/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

  1. changed the title [-]Implement cross-node selection and copy/paste[/-] [+]Copy text across node boundaries[/+] on Aug 3, 2026
  2. mstomar125 commented on Aug 10, 2026

    @mstomar125
    Contributor

    Part 1 merged (PR #2021, main ff221ba9) — Scope §1 (preserve the selection on drag) + the role="button"→role="textbox"/aria-readonly a11y 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):

    • Attach a copy listener on the viewer scroll container (base-node-viewer.svelte, onMount/onDestroy).
    • On copy: 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.
    • Per spanned node: source content from sharedNodeStore.getNode(id); depth from the viewer's flattenNodes (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 way extractTextWithLineBreaks does (text length + 1 per <br>) up to (range.startContainer, startOffset), then mapViewPositionToEditPosition(renderedOffset, viewText, content) (view-edit-mapper.ts) → source offset → content.slice(...). Middle nodes: full source content.
    • Assemble with hierarchy indentation (depth → markdown indent), join with newlines; e.preventDefault() + clipboardData.setData('text/plain', …).
    • Reuse isActiveTextSelection from Part 1. ⚠The rendered↔source offset consistency across <br> boundaries is the delicate part — worth thorough tests.

    Keeping this issue open for Part 2.

  3. self-assigned this
    on Aug 11, 2026
  4. mstomar125 commented on Aug 11, 2026

    @mstomar125
    Contributor

    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 copy listener 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):

    • Detects a multi-node span via the .node[data-node-id] roots of the range endpoints; a single-node selection is left to native copy.
    • Clips the first/last node to the selection offsets by mapping the rendered offset (counted like extractTextWithLineBreaks, +1 per <br>) to a source offset via mapViewPositionToEditPosition — the same mapping click-to-edit uses. Middle nodes are copied whole.
    • Preserves nesting as indentation relative to the shallowest node in the range.
    • Isolated in a pure, DOM-tested module (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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions