Skip to content

Selected words can be made into a link, and stay words - #38

Merged
boubou666 merged 2 commits into
mainfrom
link-into-selected-text
Aug 9, 2026
Merged

boubou666 merged 2 commits into
mainfrom
link-into-selected-text

Conversation

@boubou666

@boubou666 boubou666 commented Aug 9, 2026 •

Copy link
Copy Markdown
Owner

Select some text while editing — double-click a word, or drag across it — and a Link control appears on the bar above. Press it, give an address, and the words point there.

Where a labelled link lives

A label and an address are two things, and the field they live in is one string — so the string holds [our roadmap](https://…), in markdown's spelling because that is the one people already know and already type. The store is unchanged: still a plain string, still no markup, still a recogniser on the way out. core/links.js knows one more shape of link.

The one rule it costs

What is on screen is no longer what is in the document. Every edit reads a field back through innerText, and innerText over a labelled link is its label — so one keystroke anywhere in a card would write the labels down and throw away every address in it.

So focusing a field puts its own characters back in it, and leaving it draws the links again. Editing shows the source, reading shows the words, and the round trip is exact at every moment in between.

The caret is placed before the focus lands, for the same reason: revealing a source moves every character after it along the line, so a position taken afterwards is a position in a line nobody saw — which is how a double-click after a link ends up putting the caret inside its address.

Measuring the selection

platform/text-link.js puts a marker at each end of the selection, reads innerText once with them in, and takes them out again. The alternative is a DOM walk that re-implements innerText — a second opinion about the same string, which would disagree with the first one the day somebody pressed Enter. A test drives exactly that: a second line made with the Enter key, living in a <div> the store knows nothing about.

What comes back is two offsets into the string the store holds, so the change itself is arithmetic in core/ applied as one recorded set — undo, autosave and the other people on the board all follow from that and none of them had to be arranged. An answer arriving for a string the field no longer holds is dropped rather than cutting somebody else's sentence in half.

The control

On the format bar rather than in a bar of its own, since that bar is already what appears above the thing you are working on. Not offered for a selection that is already a link — the answer to "make this a link" is that it is one — nor for one holding a ], which is where a label ends and so the one character a label cannot hold. The address is asked for in the page's own dialog, where a bare example.com counts and in prose deliberately does not; one that cannot be opened is asked again, keeping what was typed.

Found while building it

  • A double-click inside the field being edited was collapsing the browser's own word selection, so there was no way to select a word on a card at all. That one is left to the caret now.
  • The PNG export drew the brackets, because it wraps the raw string. It goes through displayText, so a picture of a board says what the board says.

Not in this change

  • No ⌘K. Keys pressed inside a field are that field's, deliberately — it is what lets a board be named "My board" — so a shortcut means a listener on the editing layer and a route from there to the question, which is the format bar's to ask. Worth doing; written down as a limit.
  • A selection holding a ] is not offered a link, since those words cannot be written down as a label at all. Selecting round it works.

Checks

915 tests, all passing, 96.6% line coverage. src/platform/text-link.js at 100%. 18 new browser tests over the real gestures, plus core unit tests for the recogniser, the address parsing and the string edit.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PTizw7U35rC2MPquwr4Fv5

Summary by CodeRabbit

  • New Features
    • Create labelled links by selecting text and using the new Link control.
    • Enter and validate HTTP(S) addresses, with support for retrying invalid entries or dismissing the dialog.
    • Support Markdown-style labelled links alongside bare URLs.
    • Preserve selections and caret position while editing linked text.
  • Bug Fixes
    • Linked text exports using its display label rather than its underlying address.
    • Existing links, stale selections, and invalid input are safely protected from unintended changes.
  • Documentation
    • Added guidance covering link creation, editing, exporting, and current limitations.

Select some text while editing, press "Link" on the bar above it, and give an
address: the words point there. A label and an address are two things and the
field they live in is one string, so the string holds `[our roadmap](https://…)`
— markdown's spelling, because it is the one people already know and already
type. The store is unchanged: still a plain string, still no markup, still a
recogniser on the way out. `core/links.js` knows one more shape of link.

**That costs the one rule everything here has had for free: what is on screen is
what is in the document.** Every edit reads a field back through `innerText`, and
`innerText` over a labelled link is its label — so a single keystroke anywhere in
a card would write the labels down and throw away every address in it. Focusing a
field therefore puts its own characters back in it, and leaving it draws the
links again. Editing shows the source, reading shows the words, and the round
trip is exact at every moment in between.

The caret is placed *before* the focus lands, for the same reason: revealing a
source moves every character after it along the line, so a position taken
afterwards is a position in a line nobody saw — which is how a double-click after
a link ends up putting the caret inside its address.

**Where the selection is in the string is measured with the browser's own
answer.** `platform/text-link.js` puts a marker at each end of the selection,
reads `innerText` once with them in and takes them out again. The alternative is
a DOM walk that re-implements `innerText` — a second opinion about the same
string, which would disagree with the first one the day somebody pressed Enter. A
test drives exactly that: a second line made with the Enter key, living in a
`<div>` the store knows nothing about.

What comes back is two offsets into the string the store holds, so the change
itself is arithmetic in `core/` applied as one recorded `set` — undo, autosave
and the other people on the board all follow from that and none of them had to be
arranged. An answer that arrives for a string the field no longer holds is
dropped rather than cutting somebody else's sentence in half.

The control is on the format bar rather than in a bar of its own, since that bar
is already what appears above the thing you are working on. It is not offered for
a selection that is already a link — the answer to "make this a link" is that it
is one — nor for one holding a `]`, which is where a label ends and therefore the
one character a label cannot hold. The address is asked for in the page's own
dialog, where a bare `example.com` counts and in prose deliberately does not; one
that cannot be opened is asked again, keeping what was typed.

Two things found by building it. A double-click inside the field being edited was
collapsing the browser's own word selection, so there was no way to select a word
on a card at all — that one is left to the caret now. And the PNG export drew the
brackets, because it wraps the raw string; it goes through `displayText`, so a
picture of a board says what the board says.

915 tests, 96.6% lines. `platform/text-link.js` at 100%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PTizw7U35rC2MPquwr4Fv5
@coderabbitai

coderabbitai Bot commented Aug 9, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: cb015257-09de-4704-bc19-719fd9d9f900

📥 Commits

Reviewing files that changed from the base of the PR and between 628e001 and 6e87ede.

📒 Files selected for processing (8)
  • README.md
  • src/components/BoardCanvas.jsx
  • src/components/FormatBar.jsx
  • src/core/links.js
  • src/platform/text-link.js
  • src/routes/BoardPage.jsx
  • test/browser/text-link.test.js
  • test/node/links.test.js
🚧 Files skipped from review as they are similar to previous changes (6)
  • test/node/links.test.js
  • src/platform/text-link.js
  • README.md
  • test/browser/text-link.test.js
  • src/components/FormatBar.jsx
  • src/core/links.js

📝 Walkthrough

Walkthrough

The application now supports labelled Markdown links. It parses and renders labels, restores source text during editing, creates links from selections through the format bar, validates destinations, preserves caret offsets, updates the store, and exports display text.

Changes

Labelled link workflow

Layer / File(s) Summary
Link parsing and creation contracts
src/core/links.js, test/node/links.test.js, README.md
Link parsing preserves labelled-link source text and exposes helpers for display text, address validation, selection linking, and label validation.
Source-preserving editing and export
src/platform/views.js, src/platform/input.js, src/platform/export-png.js, README.md
Rendered links retain their source for editing. Input handling restores caret positions. PNG export uses link display text.
Selection capture and store updates
src/platform/text-link.js, src/app.js, test/browser/text-link.test.js, README.md
Selections are measured as text offsets, captured with object context, checked for stale content, and applied through one undoable store update.
Format-bar link action
src/components/FormatBar.jsx, src/components/BoardCanvas.jsx, src/routes/BoardPage.jsx, src/styles/canvas.css, README.md
The format bar detects eligible selections, prompts for validated HTTP(S) destinations, retries invalid input, reports link-lost, and inserts labelled links.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Editor
  participant FormatBar
  participant createTextLinks
  participant useAsk
  participant store
  Editor->>FormatBar: expose eligible text selection
  FormatBar->>createTextLinks: capture selection
  FormatBar->>useAsk: prompt for link address
  useAsk-->>FormatBar: return typed address
  FormatBar->>createTextLinks: insert validated href
  createTextLinks->>store: apply one undoable set update
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: converting selected words into links while preserving their displayed text.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch link-into-selected-text

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/core/links.js (1)

204-205: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Consider encoding an unbalanced ) in the href.

linkSource writes the href verbatim. LABELLED accepts only one level of balanced parentheses in the address, so an href holding an unbalanced ) — for example https://a.test/x) — reparses to a shorter address and leaves a stray ) as prose. linkFrom can return such an href because URL keeps ) in a path.

Encoding just the closing parenthesis keeps the round trip exact and stays readable.

♻️ Proposed change
-const linkSource = (label, href) => `[${label}](${href})`;
+const linkSource = (label, href) => `[${label}](${href.replace(/\)/g, '%29')})`;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/links.js` around lines 204 - 205, Update linkSource to encode
closing parentheses in href values before interpolating them into the Markdown
link, while preserving other URL characters and readable output. Ensure hrefs
such as URLs with an unbalanced “)” round-trip through LABELLED without leaving
trailing prose; linkFrom and label handling should remain unchanged.
src/platform/text-link.js (1)

72-101: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Remove the marks in a finally block.

measure inserts two private-use characters into the live field. If insertNode or the innerText read throws, the marks stay in the DOM. The next input event reads the field through innerText, so a \uE000 would be written into the store and broadcast to other editors.

A finally block makes the cleanup unconditional.

♻️ Proposed change
   const measure = (field, range) => {
     const text = field.innerText;
     if (text.includes(MARK)) return null;
 
     const head = document.createTextNode(MARK);
     const tail = document.createTextNode(MARK);
 
-    const to = range.cloneRange();
-    to.collapse(false);
-    to.insertNode(tail);
-
-    const from = range.cloneRange();
-    from.collapse(true);
-    from.insertNode(head);
-
-    const marked = field.innerText;
-
-    head.remove();
-    tail.remove();
-    // The marks left split text nodes either side of where they were.
-    field.normalize();
+    let marked;
+    try {
+      const to = range.cloneRange();
+      to.collapse(false);
+      to.insertNode(tail);
+
+      const from = range.cloneRange();
+      from.collapse(true);
+      from.insertNode(head);
+
+      marked = field.innerText;
+    } finally {
+      head.remove();
+      tail.remove();
+      // The marks left split text nodes either side of where they were.
+      field.normalize();
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/platform/text-link.js` around lines 72 - 101, Update measure so removal
of the head and tail marker nodes, followed by field.normalize(), occurs in a
finally block surrounding the range insertion and marked innerText read.
Preserve the existing validation and return logic while ensuring cleanup runs if
either insertNode or innerText throws.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/FormatBar.jsx`:
- Around line 183-187: Handle the boolean result from chosen.insert(href) in the
addLink flow: when insertion returns false, keep the dialog open and surface an
appropriate failure message instead of silently returning; only proceed with
closing or success behavior when insertion succeeds, consistent with the
existing commands.exportPng outcome handling.

---

Nitpick comments:
In `@src/core/links.js`:
- Around line 204-205: Update linkSource to encode closing parentheses in href
values before interpolating them into the Markdown link, while preserving other
URL characters and readable output. Ensure hrefs such as URLs with an unbalanced
“)” round-trip through LABELLED without leaving trailing prose; linkFrom and
label handling should remain unchanged.

In `@src/platform/text-link.js`:
- Around line 72-101: Update measure so removal of the head and tail marker
nodes, followed by field.normalize(), occurs in a finally block surrounding the
range insertion and marked innerText read. Preserve the existing validation and
return logic while ensuring cleanup runs if either insertNode or innerText
throws.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fe2f11ef-c1e8-432a-a18e-2a5a178bf073

📥 Commits

Reviewing files that changed from the base of the PR and between 5f24e15 and 628e001.

📒 Files selected for processing (11)
  • README.md
  • src/app.js
  • src/components/FormatBar.jsx
  • src/core/links.js
  • src/platform/export-png.js
  • src/platform/input.js
  • src/platform/text-link.js
  • src/platform/views.js
  • src/styles/canvas.css
  • test/browser/text-link.test.js
  • test/node/links.test.js

Comment thread src/components/FormatBar.jsx
… bracket

- **An insertion that came to nothing said nothing.** `insert` answers false when
  the field no longer holds the string the words were measured in — another
  editor's change landing while the question is open — and the answer was
  dropped on the floor, so somebody typed an address and watched the dialog
  close. Asking again would be no use, since those offsets are gone whatever is
  typed next, so it goes out as a problem code and the shell writes the
  sentence, exactly as a refused image paste does.

- **The marks come out in a `finally`.** They are two private-use characters in
  a live field, and the next thing typed reads that field back through
  `innerText` — so a mark left behind by a throw would have been written into
  the store and broadcast to everyone else on the board. `remove()` on a node
  that was never inserted does nothing, which covers the case where the first
  `insertNode` is what threw.

- **An address whose brackets cannot be read back has them encoded.** The
  recogniser follows one level of balanced brackets, which is what keeps
  `Ruby_(gem)` legible — but `https://a.test/p)` was being written down as
  `https://a.test/p` with a stray bracket left as prose, which is a link
  pointing somewhere nobody chose. `linkSource` asks the recogniser now and
  percent-encodes only what it cannot read back, so the readable case stays
  readable and the other one stays right.

918 tests, 96.6% lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PTizw7U35rC2MPquwr4Fv5
@boubou666

Copy link
Copy Markdown
Owner Author

Both nitpicks are in too, in the same commit.

The marks come out in a finally. They are two private-use characters in a live field and the next thing typed reads that field back through innerText, so one left behind by a throw would have been written into the store and broadcast. remove() on a node that was never inserted does nothing, which covers the case where the first insertNode is what threw.

The bracket one is real and got a slightly different fix. Encoding ) unconditionally would have cost Ruby_(gem) its legibility — and an unbalanced ( breaks the round trip too, which the suggestion did not cover. So linkSource writes the source, reads it back through linkRuns, and only percent-encodes the brackets of an address the recogniser could not return intact. Readable addresses stay readable; https://a.test/p) and https://a.test/(a(b)c) come back whole. Pinned by two node tests that assert the round trip rather than the characters.

@boubou666
boubou666 merged commit 9d6ac08 into main Aug 9, 2026
3 checks passed
@boubou666
boubou666 deleted the link-into-selected-text branch August 9, 2026 11:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants