Skip to content

ENG-2228 Add link support to tldraw text shapes in Obsidian - #1442

Open
trangdoan982 wants to merge 19 commits into
mainfrom
eng-2228-add-link-support-to-tldraw-text-shapes-in-obsidian
Open

trangdoan982 wants to merge 19 commits into
mainfrom
eng-2228-add-link-support-to-tldraw-text-shapes-in-obsidian

Conversation

@trangdoan982

@trangdoan982 trangdoan982 commented Sep 14, 2026 •

Copy link
Copy Markdown
Member

Reviewer brief

Result: Text shapes get tldraw's Edit link action. Text, geo, note, image and video shapes can all hold an obsidian://open?file=… page link, and clicking it opens the note inside Obsidian. Web links work as before.

This follows the decision on the approach comparison: patch tldraw so every shape shares one URL rule and one link button, rather than giving text shapes their own. The comparison branch was #1504, now closed.

Follow a link from the menu item to the click. Orange is patched tldraw, blue is our code.

flowchart TD
  A["text shape selected"] --> B{"patches/tldraw@3.14.2.patch<br/>useHasLinkShapeSelected: url in props OR type === text"}
  B --> C["stock Edit link item<br/>TldrawViewComponent.tsx swaps onSelect only"]
  C --> D["TextLinkDialog.tsx<br/>one validator for every shape"]
  D --> E{"patches/@tldraw__validate@3.14.2.patch<br/>linkUrl accepts obsidian://open?file="}
  E -->|text| F["shape.meta.url<br/>TextLinkOverlay.tsx draws tldraw's button"]
  E -->|geo, note, image, video| G["shape.props.url<br/>tldraw draws its own button"]
  F --> H{"patched HyperlinkButton<br/>no href for obsidian:, fires tldraw.navigate-link"}
  G --> H
  H --> I["utils/linkNavigation.ts<br/>one listener: same-vault check, then sidebar / Cmd new tab / Cmd+Alt split"]
  classDef patched fill:#fed7aa,stroke:#c2410c,color:#111
  classDef app fill:#bfdbfe,stroke:#1d4ed8,color:#111
  class B,E,H patched
  class C,D,F,G,I app
Loading

Review focus:

  • The validator patch is permanent once users save page links on non-text shapes. props.url is checked every time a canvas loads. A tldraw without patches/@tldraw__validate@3.14.2.patch rejects these shapes and can't load the canvas. That includes older plugin versions and any future upgrade that drops the patch. Text links avoid this by living in meta, which tldraw never checks. This is the trade-off accepted in choosing this approach.
  • The validator patch edits the check body, not the protocol list. Only obsidian://open with a file param passes. Adding obsidian: to validLinkProtocols would also admit action URIs such as advanced-uri?commandid=. The scenario matrix pins both rejections.
  • Page links never reach the OS. The patched button leaves href off for non-web URLs, so middle-click and "open link" can't hand them to the OS handler. linkNavigation.ts cancels the event and resolves the file inside this vault only.
  • Bookmarks don't take page links. A bookmark links its title with a raw <a href>, not HyperlinkButton, so a page link there would reach the OS handler. TextLinkDialog.tsx rejects obsidian: input for bookmarks, and keeps every obsidian: input away from its https:// fallback, which would otherwise parse https://obsidian://… as a web URL.
  • The text-link button is an overlay, not a custom shape util, so tldraw's text shape is never subclassed. Costs: it doesn't rotate with the shape, isn't clipped by frames, and draws above shapes stacked on the text.

Size: 682 changed lines excluding the lockfile; 241 of them are the two patch files. It doesn't split usefully: the validator patch, the button patch and the listener only work together, and without all three a page link either can't be saved or escapes to the OS. Review order: patches/@tldraw__validate@3.14.2.patch, then patches/tldraw@3.14.2.patch, utils/linkNavigation.ts, overlays/TextLinkOverlay.tsx, TextLinkDialog.tsx. Testing path: add a text shape from the toolbar, right-click, Edit, Edit link, paste a page's Obsidian URL.

Risk or follow-up:

  • Every tldraw upgrade must carry both patches over. Install fails loudly if one no longer applies (allowUnusedPatches: false).
  • The link listener keeps the editor it was registered with. After the save-failure path swaps the store, selectNone() targets the old editor; the link still opens. The existing Meta+Alt+Enter effect has the same dependency pattern.
  • obsidian:// links in the button can't be activated with Enter, because an anchor without href gets no keyboard activation. Stock tldraw never accepted these links, so this isn't a regression.
  • Convert To drops the link: convertToDiscourseNode copies neither props nor meta. This predates the PR.
  • No unit tests. See Standards check.

Verification

pnpm install --frozen-lockfile and pnpm ci:validate pass with the turbo cache forced off (0 of 14 tasks cached). Prettier is clean on every changed file. ESLint reports no warnings on added lines; CI's lint-changed-files fails on warnings, not just errors.

Driven in Obsidian over CDP with dg-obsidian-cdp-verify against this head:

Scenario Input Expected Actual Pass
Toolbar text shape is untouched stock text tool, click canvas, type 'linked note', Escape no url in props, empty meta: stock text schema props=[autoSize,color,font,richText,scale,size,textAlign,w] meta=[] yes
Edit link appears for a text shape select the text shape, right-click, open the Edit submenu Edit link listed for a shape with no link yet Edit link present yes
Text link renders tldraw's own button beside the text set meta.url on a text shape one stock hyperlink button, clear of the text, vertically centred 1 button(s), clearOfText=true, centred=true yes
Selected text shape: button clears the resize handle linked text shape, click to select it, measure the right-edge resize target the button's hit area starts past tldraw's resize target resize target ends at x=916.5, button starts at x=919.9 yes
Text link: click opens the page, Cmd+click opens a new tab obsidian:// link on a text shape; click the button, then Cmd+click it no href; page opens in the sidebar, then Cmd+click adds a root tab href omitted=true, plain click opened=true, root tabs 1→2 on Cmd+click yes
Text link persists across close and reopen set a link, let autosave run, close and reopen the canvas markdown holds meta.url, props has no url, button returns meta=["https://persist.example.com"] props=[null] buttonsAfterReopen=1 yes
Geo shape accepts and opens a page link rectangle with an obsidian://open link; click its stock button schema accepts it, no href, page opens in the app schema=accepted, href omitted=true, opened=true yes
Geo link stored as Obsidian:// (mixed case) still opens rectangle with Obsidian://open?… (the validator accepts it); click the button page opens in the app, no 'not in this vault' toast schema=accepted, opened=true yes
Note shape accepts and opens a page link sticky note with an obsidian://open link; click its stock button schema accepts it, no href, page opens in the app schema=accepted, href omitted=true, opened=true yes
Validator still rejects obsidian action URIs rectangle with obsidian://advanced-uri?commandid=…, and with obsidian://open without file both rejected by the patched validator advanced-uri: REJECTED: At shape(type = geo).props.url: Expected a valid url, got "obsidian://advanced-uri?vault=v&commandid | open without file: REJECTED: At shape(type = geo).props.url: Expected a valid url, got "obsidian://open?vault=v" (obsidian links yes
Edit link dialog: bookmark rejects a page link bookmark shape, Edit link, type an obsidian://open URL Invalid URL, Save disabled: a bookmark's title link would hand it to the OS hint="Invalid URL", Save enabled=false yes
Edit link dialog: geo accepts a page link rectangle, Edit link, type an obsidian://open URL page-link hint stays, Save enabled hint="Enter a URL, or an obsidian:// link to a page.", Save enabled=true yes
Edit link dialog: page link pasted after the prefilled https:// is kept rectangle, Edit link, caret after the prefilled https://, type an obsidian://open URL, Save stored url is the obsidian:// link, not https://obsidian//… stored=obsidian://open?vault=testVault2&file=templates%2FEvidence%20template.md yes
Edit link dialog: malformed page link is invalid, not rewritten to https rectangle, Edit link, type obsidian://open?vault=V (no file) Invalid URL, Save disabled hint="Invalid URL", Save enabled=false yes
Scenario Screenshot
Toolbar text shape is untouched stock Toolbar text shape is untouched stock
Edit link appears for a text shape Edit link appears for a text shape
Text link renders tldraw's own button beside the text Text link renders tldraw's own button beside the text
Selected text shape: button clears the resize handle Selected text shape: button clears the resize handle
Text link: click opens the page, Cmd+click opens a new tab Text link: click opens the page, Cmd+click opens a new tab
Text link persists across close and reopen Text link persists across close and reopen
Geo shape accepts and opens a page link Geo shape accepts and opens a page link
Geo link stored as Obsidian:// (mixed case) still opens Geo link stored as Obsidian:// (mixed case) still opens
Note shape accepts and opens a page link Note shape accepts and opens a page link
Validator still rejects obsidian action URIs Validator still rejects obsidian action URIs
Edit link dialog: bookmark rejects a page link Edit link dialog: bookmark rejects a page link
Edit link dialog: geo accepts a page link Edit link dialog: geo accepts a page link
Edit link dialog: page link pasted after the prefilled https:// is kept Edit link dialog: page link pasted after the prefilled https:// is kept
Edit link dialog: malformed page link is invalid, not rewritten to https Edit link dialog: malformed page link is invalid, not rewritten to https

Tests added: none. Rerun the scenarios with:

node ~/.claude/skills/dg-obsidian-cdp-verify/scripts/run.mjs --marker "tldraw.navigate-link" --scenario <scenarios.mjs>

Not verified:

  • Image and video shapes weren't clicked. They use the same validator, button and listener as the geo and note rows.
  • An older plugin build opening a canvas with a geo page link wasn't run end to end. Unpatched @tldraw/validate rejecting the record was checked directly.
  • Rotated text shapes, text inside frames, and overlapping stacked shapes.

Loom video

pending

Scope check

  • Ran $scope-check against ENG-2228 and the final diff.
  • Scope beyond Done When: obsidian://open?file= page links, a link format the ticket lists as out of scope; the same page links on geo, note, image and video shapes, which already had web links; pnpm patches to tldraw and @tldraw/validate, including a behaviour change to tldraw's shared link button; and click gestures matched to discourse nodes.
  • Required now: the ticket's Notes name page links as the primary expected target, and tldraw's linkUrl can't express one. Once tldraw's validator accepts page links, every shape type that uses it accepts them, so the other shape types follow from the first change. The patches and gestures were requested in review.
  • Anyone affected or consulted: Yes. @mdroidian asked for the comparison and chose this approach over text-only links.
  • Decision: comparison in ENG-2228 Add link support to tldraw text shapes in Obsidian (prototype C: reuse tldraw's link button) #1504, closed in favour of this PR. Not yet recorded on the Linear ticket.

One Done When item is not met: "Unit tests cover link persistence and the text-shape eligibility behavior."

Standards check

  • Ran $dg-pr-adherence-check against the final diff and PR metadata.

Outstanding:

  • No unit tests, at the author's request. STYLE_GUIDE.md asks for unit tests for new functionality, and textShapeLink.ts is pure logic. This also leaves the ticket's fourth Done When unmet.
  • Loom video pending.
  • 682 changed lines, above the 400 guideline. The split justification and review order are in Reviewer brief.
  • externalContentHandlers.ts carries three formatting-only hunks. The pre-commit hook runs Prettier over the whole file once resolveObsidianUrlToFile is exported.

Local delegated full review

  • Ran a comprehensive review of the entire final diff in a subagent with a fresh context.

Six rounds; each fix restarted the checks. The final round, on this head, found nothing new at medium or above.

Fixed in this PR:

  • The patch's CJS HyperlinkButton called a bare useCallback that file never imports. The plugin bundle loads the ESM build, so it didn't fail at runtime.
  • A geo link stored as Obsidian://… or with leading whitespace passed the validator but failed the listener's raw prefix check, showing "not in this vault". The listener now normalises with new URL first.
  • The text-link button covered the selected text shape's right-edge resize target. It now starts past it (pinned in the matrix).
  • The edit-link override switched tools where stock is a no-op outside the select tool.
  • Bookmarks could save a page link that their title anchor hands to the OS.
  • A page link pasted after the dialog's prefilled https:// saved as https://obsidian//….
  • A malformed page link, such as obsidian://open?vault=V with no file, fell through to the https:// fallback and saved as a web link. Every obsidian: input now stays out of that fallback.
  • A misplaced comment in TldrawViewComponent.tsx, and a double cast in linkNavigation.ts.

Raised and not acted on, with reasons in Reviewer brief: older builds and an unpatched tldraw can't load canvases with page links on non-text shapes (the accepted trade-off), Enter doesn't activate a page-link button, and the listener's stale editor after a store swap. Also not acted on: TextLinkOverlay returns a new array on every camera change, so it re-renders on each pan even with no text links. Perf only; a follow-up can return a stable empty value.

Raised and found incorrect against tldraw 3.14.2's source: updateShapes replacing all of meta (it merges key by key), and Box.includes requiring full containment (it's collides-or-contains). The two matching Codex threads are answered and resolved.

🤖 Generated with Claude Code

Text shapes could not carry a link, so a canvas narrative could not open the
source its text stands for. tldraw gates its Edit link action on the duck-type
`'url' in shape.props`, and TLTextShape has no such prop -- so this is a schema
change, not UI wiring. Adding the prop lights up the stock menu item, and a
retroactive migration backfills `url: ""` on pre-existing text records, which is
required both for validation and for those shapes to become link-eligible.

The ticket names page links as the primary target, so the prop uses a wider
protocol allowlist than tldraw's T.linkUrl (which permits only http/https/
mailto) and tldraw's EditLinkDialog is forked, since it rejects non-http
protocols on input before the schema sees them. The fork keeps geo shapes on the
strict validator, as their url prop is still stock. Clicking an obsidian:// link
resolves it in-app rather than via the OS protocol handler.

Both shape-util registration sites now share baseShapeUtils: the schema and the
editor are built from separate arrays and nothing asserts they agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Sep 14, 2026

Copy link
Copy Markdown

ENG-2228

@supabase

supabase Bot commented Sep 14, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project zytfjzqyijgagqxrzbmz because there are no changes detected in packages/database/supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@vercel

vercel Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
discourse-graph Ready Ready Preview Oct 9, 2026 8:09pm UTC

Request Review

@devin-ai-integration devin-ai-integration 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.

Devin Review found 1 potential issue.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Comment thread apps/obsidian/src/components/canvas/shapes/TextShapeWithLinkUtil.tsx Outdated
The protocol allowlist admitted any obsidian: URI, and the click handler only
called preventDefault once it had parsed an open URL -- so anything else
(advanced-uri's commandid, new?file=&content=) fell through to the anchor and
reached the OS handler, letting a shared canvas act on the reader's vault in one
click. Only obsidian://open with a file param is accepted now, and any obsidian:
href is swallowed regardless of whether it parses.

The link icon sat on the text: tldraw parks it in a geo shape's empty top-right
corner, but a text shape is exactly as big as its text. It now sits just past the
right edge. The override needs both class names -- styles.css is concatenated
ahead of the vendored tldraw CSS, so one class ties on specificity and loses on
source order, which had silently dropped the zoomed-out hide too.

Also restores the two guards the edit-link override had dropped, so the dialog
can no longer open empty; stops an invalid Enter writing the "https://" fallback
that both validators reject; scopes the obsidian:// hint to text shapes, since
the dialog also serves geo; and makes the shape-util swap assert rather than
silently no-op.

Removes the unit tests and vitest setup at the author's request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s files

The icon SVG now follows the TOOL_ARROW_ICON_SVG pattern and is applied
inline the way tldraw's own HyperlinkButton does, so the vendored
tl-hyperlink__icon class supplies size and color and the data URI is no
longer duplicated in styles.css.

processInitialData's schema fallback now drops only this plugin's own
sequence ids. Substituting the full current schema told loadSnapshot the
records were already migrated, so the url backfill was skipped and
store.put threw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
readDocumentRecords, the path that runs when the backing markdown changes
on disk, had its own copy of the fallback and still substituted the full
current schema. Both call sites now share schemaBeforeAppMigrations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@mdroidian mdroidian left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This needs a deeper review because it changes the built-in text shape’s schema, adds a migration, and introduces custom link components.

Before proceeding, I’d like us to explore storing the link in meta.url so we can keep the existing props schema and avoid backfilling existing text shapes. I’d lean toward that if it reduces the amount of custom code we need to maintain.

Please also investigate how much of tldraw’s existing link button and dialog we can reuse, including whether we can extend URL support to accept obsidian:// consistently for both text and geo shapes. I’d consider a small pnpm patch if it lets us reuse those components instead of maintaining separate copies. (Here's a simple example of us extending the hostnames for embeds using patch package)

The goal is to stay closer to the stock text shape and link UI. Please compare that approach with the current implementation before we commit to the schema change.

Regardless of your choice, could you include a more detailed Loom video testing and showing different possible surface areas, like adding a text shape via the toolbar, adding the initial link to the text shape, converting the text shape to a discourse node, etc.

Lastly, could you compare the click behavior to existing click behavior of Discourse nodes? We should mirror those as much as possible (I acknowledge that there are two separate behaviors we would need to balance here: existing dg links and geo shape links). If a user clicks a Discourse node and they go directly to that node or shift-click to open the sidebar, but they have different behavior when they click a text shape, that would be unexpected and poor UX, for example.

Comment thread apps/obsidian/src/components/canvas/utils/canvasFileSync.ts Outdated
Comment thread apps/obsidian/src/components/canvas/utils/tldraw.ts Outdated
Comment thread apps/obsidian/src/components/canvas/utils/tldraw.ts Outdated
trangdoan982 and others added 2 commits September 29, 2026 23:40
Michael asked us to compare meta.url against the props schema change before
committing to it, and to see how much stock tldraw we could reuse. Both point
the same way.

meta is validated as T.jsonValue, so existing canvases load untouched: the
migration, the schema fallback and its two call sites are gone, along with the
code flagged in review. The deciding argument is compatibility, not code volume
-- a file written with props.url and opened by an older build throws
Unexpected property and renders a blank canvas, while meta.url loads, is
ignored, and round-trips intact. Canvas files are markdown synced between
machines on different plugin versions, so that mattered more than keeping the
props-gated menu item for free.

Rendering the icon from InFrontOfTheCanvas, next to the existing Relations and
DragHandle overlays, means the stock text shape is never subclassed at all, so
the custom shape util and the two-shape-util-lists hazard go with it. The
trade is that the icon does not rotate with the shape, is not clipped by a
frame, and draws above shapes stacked over the text.

A two-line patch keeps tldraw's own Edit link item: useHasLinkShapeSelected
gates on 'url' in props, so it now also accepts text shapes -- always, the way
geo qualifies through a props.url that defaults to "". Gating on the presence
of meta.url instead would have made the first link unaddable.

Click gestures now match discourse nodes: plain opens the sidebar, cmd opens a
new tab, cmd+alt splits. Since meta is unvalidated, the URL allowlist is
re-applied when reading it rather than relying on the schema.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
trangdoan982 and others added 3 commits September 30, 2026 21:10
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
apps/obsidian/AGENTS.md forbids hardcoded inline styles. Only the per-shape
left/top can be inline; the container, the button placement and the icon mask
are now classes, with the mask data URI passed as a custom property.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A delegated review found the allowlist and the click guard disagreeing. `new URL`
strips leading control characters, so " obsidian://open?vault=Other&file=x"
passed `isAllowedTextLinkUrl` while a raw prefix test said it was not an
obsidian URL -- so preventDefault never ran and the anchor handed the URI to the
OS handler, skipping the cross-vault check. getTextShapeLinkUrl now returns the
parsed form and every protocol test derives from the parser. Host comparison is
case-insensitive too: obsidian: is a non-special scheme, so WHATWG left
obsidian://Open uppercased and the link was rejected outright.

A page link is no longer placed in href at all. Middle-click and the context
menu bypass onClick, so the URI escaped that way regardless of the guard.

Also from the review: the wrong-tool branch of the edit-link override returned
instead of falling through, so invoking it from a non-select tool switched tool
and silently did nothing; upstream opens the dialog. The dialog's
selection-changed guard compared types and could never fire, because the tracked
parent re-renders with the new shape -- keying the inner component by shape id
resets its state instead. The overlay now culls to the viewport and skips hidden
shapes rather than scanning the whole page each camera frame.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: 01M3TGQGPWAVEG55KD1P9NKX28
@trangdoan982

Copy link
Copy Markdown
Member Author

@mdroidian the loom and PR body is updated with the new approach you suggested.

@mdroidian

This comment was marked as resolved.

@chatgpt-codex-connector

This comment was marked as resolved.

@mdroidian

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-01T02:56:20.593972Z 5253da0 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5253da0dc7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/obsidian/src/components/canvas/TextLinkDialog.tsx
Comment thread apps/obsidian/src/components/canvas/overlays/TextLinkOverlay.tsx

@mdroidian mdroidian left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Here I asked "Please also investigate how much of tldraw’s existing link button and dialog we can reuse ... I’d consider a small pnpm patch if it lets us reuse those components instead of maintaining separate copies."

I see a new TextLinkDialog.tsx but I didn't see a reason/explanation why we wouldn't reuse the existing dialog. Did you investigate reusing the existing dialog (eg: exporting it via a patch)?

If yes, please add the findings/explanation of why we chose to not use this method.
If no, please investigate and report your findings.

@mdroidian

Copy link
Copy Markdown
Member

@trangdoan982 also, re:

Lastly, could you compare the click behavior to existing click behavior of Discourse nodes? We should mirror those as much as possible (I acknowledge that there are two separate behaviors we would need to balance here: existing dg links and geo shape links). If a user clicks a Discourse node and they go directly to that node or shift-click to open the sidebar, but they have different behavior when they click a text shape, that would be unexpected and poor UX, for example.

I see gesture alignment mentioned in the PR body, but I’m not sure where to find the comparison. Did you compare text-link click behavior with existing Discourse node and geo shape links?

Could you summarize any changes made to match those behaviors and any decisions about differences that should remain? If that’s already documented, please point me to it. Thanks!

trangdoan982 added a commit that referenced this pull request Oct 1, 2026
Michael's review of the Obsidian twin (ENG-2228, #1442) asked to store the
link in meta.url and reuse tldraw's own link UI instead of changing the text
shape schema, migrating, and copying components. The same reasoning applies
here more strongly: a Roam graph has collaborators on different extension
versions, and each sequence bump locks out whoever has not updated yet.

This removes the custom text shape util, the shared util list, migration v6
and the sync worker change. The meta-based implementation follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
trangdoan982 and others added 2 commits October 1, 2026 14:06
Not for merge -- a comparison branch against 5253da0, which ships a two-line
eligibility patch and keeps a vendored hyperlink button.

Patches @tldraw/validate so linkUrl accepts obsidian://open?file=, editing the
check body rather than the protocol Set so advanced-uri's commandid stays
rejected. Patches tldraw to export HyperlinkButton, dispatch a cancelable
tldraw.navigate-link event from it, and keep non-web URIs out of href. One
listener in the app then serves every link button tldraw renders, so geo, note
and image shapes get the same in-app open and cross-vault check as text.

App code drops 477 -> 434 lines: the vendored button and our own allowlist go,
and the dialog's text-vs-geo validator split collapses. Patch surface grows
26 -> 241 lines across two packages, and becomes a behaviour patch on a shared
component rather than a predicate flip.

Verified over CDP: the five text-link scenarios still pass, and a geo shape now
accepts an obsidian page link, omits href, and opens it in the sidebar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: 01M3WAA741724YSM5FFQ91Q97J
Validation was unified across shape types but the hint still told geo
shapes "Enter a URL." Also drops the unused T import and the never-narrowed
isValid call that tripped two lint warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@trangdoan982

Copy link
Copy Markdown
Member Author

@mdroidian i created this Loom to discuss 2 options on how much we patch to unify the experience for text and other tldraw shapes, re: your question
| "Please also investigate how much of tldraw’s existing link button and dialog we can reuse ... I’d consider a small pnpm patch if it lets us reuse those components instead of maintaining separate copies."

https://www.loom.com/share/3b3e00004560475db042ee5604647e2a

more detailed considerations if you prefer text: https://claude.ai/artifact/PF19PibST9JmHjkVuNWrxF?sk=aqEDLbGKXhtBeuYz4H0mOw

let me know what you think.

trangdoan982 added a commit that referenced this pull request Oct 5, 2026
* ENG-2227 Add link support to tldraw text shapes in Roam

Text shapes could not carry a link, so a canvas narrative could not point at
its detailed source. tldraw gates its built-in Edit link action on
`'url' in shape.props`, and the stock text shape has no such prop, so the fix
is schema-level rather than UI wiring.

Subclass TextShapeUtil to add a `url` prop and render the hyperlink button,
and replace the stock util through a shared list so all four stores agree.
Existing text shapes are backfilled by migration v6 of the repo's own
sequence; without it they fail validation and the canvas will not open.

The sync worker validates `text` against stock props, so the client now
declares it alongside the custom shape types.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Fix sync room schema and move the text link icon out of the glyphs

Declaring "text" to the sync worker as if it were a custom shape type blanked
its schema entry to {}, which dropped textShapeMigrations. The room then
reported com.tldraw.shape.text version 0 against every client's 2, so
getMigrationsSince returned 'Incompatible schema?' and every client was
rejected on connect while existing rooms failed to load. The worker now
extends the default text schema instead, keeping its migrations, and refuses
to blank any default shape type. Covered by syncWorkerRoomSchema.test.ts.

A text shape's bounds hug its glyphs, so tldraw's in-bounds link button landed
on the last word. Sit it just outside the right edge instead.

Also fix a missing separator that merged the two hyperlink button class names
into one invalid token, guard isTextShapeRecord against a null props object,
and assert that the text util was actually replaced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Remove unnecessary type assertions in sync worker schema test

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Revert props-schema approach for text shape links

Michael's review of the Obsidian twin (ENG-2228, #1442) asked to store the
link in meta.url and reuse tldraw's own link UI instead of changing the text
shape schema, migrating, and copying components. The same reasoning applies
here more strongly: a Roam graph has collaborators on different extension
versions, and each sequence bump locks out whoever has not updated yet.

This removes the custom text shape util, the shared util list, migration v6
and the sync worker change. The meta-based implementation follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ENG-2227 Store text shape links in meta and reuse tldraw's link UI

Text shapes keep their link in shape.meta.url, so the stock text schema is
unchanged: no migration, no sync worker change, and a canvas saved by this
build still opens in an older one, which ignores the link and round-trips it.

The existing tldraw patch now makes text shapes eligible for the stock Edit
link action unconditionally (gating on meta.url would stop a shape ever
getting its first link), has EditLinkDialog read and write meta.url for text,
and exports HyperlinkButton. An InFrontOfTheCanvas overlay renders that button
just outside each linked text shape, since text bounds hug their glyphs.

meta has no schema, so the overlay validates with T.linkUrl and renders the
parsed URL; relative paths linkUrl would resolve against a dummy origin are
rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Co-locate the text link guard with its only caller

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entire-Checkpoint: 01M3TK0B5ZF8BWDS8PXDES5Y8Y

* Hide the text link icon while its shape is selected

The overlay renders above the selection foreground, so a 44px button at the
right edge swallowed drags on the right resize and rotate handles and opened
the link instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entire-Checkpoint: 01M3TX7QGKXMB58AK5840S5CQ8

* Hide text link icons that overlap the selection handles

The overlay renders above every shape and handle, so hiding the icon only
for its own shape still let it cover a neighbouring selection's resize,
rotate, and relation drag handles. Hide any icon within handle reach of the
selection box instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entire-Checkpoint: 01M3W9CHZ0FXJKXJVVRP73J6NE

* Keep the text link icon visible while the shape is selected

Hiding the icon near the selection kept it off the resize, rotate, and
relation handles, but the icon disappearing on selection was the larger
cost. The icon now always shows and stays clickable, so a drag that starts
under it on a short text shape opens the link instead of resizing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entire-Checkpoint: 01M3WDDCGRQEZ0D8949E9E23XB

* Use Tailwind for the text link overlay's static styles

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entire-Checkpoint: 01M46AF6E7M6V0KQY6S76ZEX9Q

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@mdroidian

Copy link
Copy Markdown
Member

@mdroidian i created this Loom to discuss 2 options on how much we patch to unify the experience for text and other tldraw shapes, re: your question | "Please also investigate how much of tldraw’s existing link button and dialog we can reuse ... I’d consider a small pnpm patch if it lets us reuse those components instead of maintaining separate copies."

https://www.loom.com/share/3b3e00004560475db042ee5604647e2a

more detailed considerations if you prefer text: https://claude.ai/artifact/PF19PibST9JmHjkVuNWrxF?sk=aqEDLbGKXhtBeuYz4H0mOw

let me know what you think.

Discussed and decided on Option 2 PR #1504

trangdoan982 and others added 8 commits October 8, 2026 19:53
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CJS hunk called a bare useCallback the file never imports. The listener
now normalises stored geo links before parsing, since the patched validator
accepts `Obsidian://` and leading whitespace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stock edit-link is a no-op outside the select tool; the override switched
tools instead. The text-link button sat on the shape's right-edge resize
target, so a press there opened the link rather than resizing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entire-Checkpoint: 01M4H0A8S531N9Q5XZTXNYKCNC
A bookmark links its title with a raw href rather than HyperlinkButton, so a
page link there would reach the OS handler and skip the same-vault check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entire-Checkpoint: 01M4H0NW3MN4QX44TBKQFRF6J6
The missing-protocol fallback parsed `https://obsidian://open?…` as a valid
https URL, so a bookmark still saved a mangled link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entire-Checkpoint: 01M4H0R1R4MVE9PNY1BE3QF3YM
The input starts as https://, so pasting a page link after it produced
https://obsidian://…, which parses as a web URL and saved silently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entire-Checkpoint: 01M4H3VPANDJZBD31YAET7ZWGM
An obsidian:// value the validator rejects fell through to the
missing-protocol fallback and saved as https://obsidian://… on every shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entire-Checkpoint: 01M4H47KFXF427H52MWP79Q6ZT

This branch was successfully deployed

1 active deployment
Preview — c0d00c20 Deployed Oct 9, 2026 by vercel[bot]
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