From 52dd9c91c3f3ccd831ed3a6c24f110c7e713f380 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 1 Oct 2026 14:06:04 -0400 Subject: [PATCH 1/2] Prototype: patch tldraw so geo shapes get page links too Not for merge -- a comparison branch against 5253da0d, 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 Entire-Checkpoint: 01M3WAA741724YSM5FFQ91Q97J --- .../src/components/canvas/TextLinkDialog.tsx | 19 +- .../components/canvas/TldrawViewComponent.tsx | 8 +- .../canvas/overlays/TextLinkOverlay.tsx | 113 ++--------- .../components/canvas/utils/linkNavigation.ts | 64 +++++++ .../components/canvas/utils/textShapeLink.ts | 29 +-- apps/obsidian/styles.css | 12 +- patches/@tldraw__validate@3.14.2.patch | 40 ++++ patches/tldraw@3.14.2.patch | 175 ++++++++++++++++++ pnpm-lock.yaml | 15 +- pnpm-workspace.yaml | 1 + 10 files changed, 326 insertions(+), 150 deletions(-) create mode 100644 apps/obsidian/src/components/canvas/utils/linkNavigation.ts create mode 100644 patches/@tldraw__validate@3.14.2.patch diff --git a/apps/obsidian/src/components/canvas/TextLinkDialog.tsx b/apps/obsidian/src/components/canvas/TextLinkDialog.tsx index dfc90a3c69..5e89f2554e 100644 --- a/apps/obsidian/src/components/canvas/TextLinkDialog.tsx +++ b/apps/obsidian/src/components/canvas/TextLinkDialog.tsx @@ -39,16 +39,11 @@ type TextLinkDialogProps = { onClose: () => void }; type UrlValidity = { isValid: boolean; hasProtocol: boolean }; -// Only text shapes carry the widened url validator. Geo and friends keep -// tldraw's T.linkUrl, so accepting obsidian:// for them would throw on save. -const validateUrlForShape = (url: string, shapeType: string): UrlValidity => { - const isAllowed = - shapeType === "text" - ? (value: string) => isAllowedTextLinkUrl(value) - : (value: string) => T.linkUrl.isValid(value); - - if (isAllowed(url)) return { isValid: true, hasProtocol: true }; - if (isAllowed(`https://${url}`)) return { isValid: true, hasProtocol: false }; +// One validator for every shape type now that tldraw's own accepts obsidian://. +const validateUrl = (url: string): UrlValidity => { + if (T.linkUrl.isValid(url)) return { isValid: true, hasProtocol: true }; + if (T.linkUrl.isValid(`https://${url}`)) + return { isValid: true, hasProtocol: false }; return { isValid: false, hasProtocol: false }; }; @@ -92,7 +87,7 @@ const TextLinkDialogInner = track( const [urlInputState, setUrlInputState] = useState(() => { const initialUrl = readLinkUrl(selectedShape); - const result = validateUrlForShape(initialUrl, shapeType); + const result = validateUrl(initialUrl); const initialValue = result.isValid ? result.hasProtocol ? initialUrl @@ -108,7 +103,7 @@ const TextLinkDialogInner = track( /https?:\/\/(https?:\/\/)/, (_match, arg1: string) => arg1, ); - const result = validateUrlForShape(fixedRawValue, shapeType); + const result = validateUrl(fixedRawValue); const safeValue = result.isValid ? result.hasProtocol ? fixedRawValue diff --git a/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx index 3716bfd46f..041472cc5c 100644 --- a/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx +++ b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx @@ -54,6 +54,7 @@ import { WHITE_LOGO_SVG } from "~/icons"; import { CustomContextMenu } from "./CustomContextMenu"; import { TextLinkDialog } from "~/components/canvas/TextLinkDialog"; import { TextLinkOverlay } from "~/components/canvas/overlays/TextLinkOverlay"; +import { registerLinkNavigation } from "~/components/canvas/utils/linkNavigation"; import { openFileInSidebar, openFileInNewTab, @@ -133,6 +134,11 @@ export const TldrawPreviewComponent = ({ }, []); // Add keyboard event listener for Meta+Alt+Enter when editor is mounted + useEffect(() => { + if (!isEditorMounted || !editorRef.current) return; + return registerLinkNavigation(editorRef.current, plugin); + }, [isEditorMounted, plugin]); + useEffect(() => { if (!isEditorMounted || !editorRef.current) return; @@ -567,7 +573,7 @@ export const TldrawPreviewComponent = ({ <> - + ), }} diff --git a/apps/obsidian/src/components/canvas/overlays/TextLinkOverlay.tsx b/apps/obsidian/src/components/canvas/overlays/TextLinkOverlay.tsx index 4c80c1e6a6..8982e1fcf1 100644 --- a/apps/obsidian/src/components/canvas/overlays/TextLinkOverlay.tsx +++ b/apps/obsidian/src/components/canvas/overlays/TextLinkOverlay.tsx @@ -1,37 +1,14 @@ -import React, { useCallback } from "react"; -import { useEditor, useValue, stopEventPropagation, TLShapeId } from "tldraw"; -import DiscourseGraphPlugin from "~/index"; -import { EXTERNAL_LINK_ICON_SVG } from "~/icons"; -import { - getTextShapeLinkUrl, - isObsidianUrl, -} from "~/components/canvas/utils/textShapeLink"; -import { - parseObsidianOpenUrl, - resolveObsidianUrlToFile, -} from "~/components/canvas/utils/externalContentHandlers"; -import { - openFileInNewLeaf, - openFileInNewTab, - openFileInSidebar, -} from "~/components/canvas/utils/openFileUtils"; -import { showToast } from "~/components/canvas/utils/toastUtils"; - -// The icon lives in CSS; only the per-shape coordinates can be inline. -const ICON_MASK_URL = `url("data:image/svg+xml;utf8,${encodeURIComponent( - EXTERNAL_LINK_ICON_SVG, -)}")`; +import { HyperlinkButton, useEditor, useValue, TLShapeId } from "tldraw"; +import { getTextShapeLinkUrl } from "~/components/canvas/utils/textShapeLink"; // Matches tldraw's own HyperlinkButton, which hides itself when zoomed out. const HIDE_BELOW_ZOOM = 0.32; type TextLink = { id: TLShapeId; url: string; left: number; top: number }; -type TextLinkOverlayProps = { plugin: DiscourseGraphPlugin }; - // Rendered here, not on a custom shape util, so the stock text shape stays // untouched -- at the cost of rotation, frame clipping and stacking fidelity. -export const TextLinkOverlay = ({ plugin }: TextLinkOverlayProps) => { +export const TextLinkOverlay = () => { const editor = useEditor(); const links = useValue( @@ -66,81 +43,17 @@ export const TextLinkOverlay = ({ plugin }: TextLinkOverlayProps) => { [editor], ); - const openLink = useCallback( - (event: React.MouseEvent, url: string) => { - if (!isObsidianUrl(url)) return; - event.preventDefault(); - - const parsed = parseObsidianOpenUrl(url); - const file = parsed ? resolveObsidianUrlToFile(plugin, parsed) : null; - if (!file) { - showToast({ - severity: "warning", - title: "Cannot open link", - description: "The linked file is not in this vault", - }); - return; - } - - // Mirrors the discourse-node gestures in TldrawViewComponent. - const open = event.altKey - ? openFileInNewLeaf - : event.metaKey || event.ctrlKey - ? openFileInNewTab - : openFileInSidebar; - void open(plugin.app, file); - editor.selectNone(); - }, - [editor, plugin], - ); - - // Upstream lets shift-click through so the canvas can still select the shape. - const stopUnlessShift = useCallback( - (event: React.PointerEvent) => { - if (!editor.inputs.shiftKey) stopEventPropagation(event); - }, - [editor], - ); - return ( -
- {links.map(({ id, url, left, top }) => { - // A page link never goes in href: middle-click and the context menu - // bypass onClick and would hand the URI to the OS handler. - const isPageLink = isObsidianUrl(url); - return ( - openLink(event, url)} - onKeyDown={(event) => { - if (isPageLink && (event.key === "Enter" || event.key === " ")) { - openLink( - event as unknown as React.MouseEvent, - url, - ); - } - }} - onPointerDown={stopUnlessShift} - onPointerUp={stopUnlessShift} - title={url} - draggable={false} - > -
- - ); - })} +
+ {links.map(({ id, url, left, top }) => ( +
+ +
+ ))}
); }; diff --git a/apps/obsidian/src/components/canvas/utils/linkNavigation.ts b/apps/obsidian/src/components/canvas/utils/linkNavigation.ts new file mode 100644 index 0000000000..3e5b881de1 --- /dev/null +++ b/apps/obsidian/src/components/canvas/utils/linkNavigation.ts @@ -0,0 +1,64 @@ +import type { Editor } from "tldraw"; +import DiscourseGraphPlugin from "~/index"; +import { + parseObsidianOpenUrl, + resolveObsidianUrlToFile, +} from "~/components/canvas/utils/externalContentHandlers"; +import { + openFileInNewLeaf, + openFileInNewTab, + openFileInSidebar, +} from "~/components/canvas/utils/openFileUtils"; +import { showToast } from "~/components/canvas/utils/toastUtils"; +import { isObsidianUrl } from "~/components/canvas/utils/textShapeLink"; + +type NavigateLinkDetail = { + url: string; + metaKey: boolean; + ctrlKey: boolean; + altKey: boolean; +}; + +// Patched tldraw dispatches this from every HyperlinkButton, so one listener +// covers text-shape links and geo/note/image links alike. +export const NAVIGATE_LINK_EVENT = "tldraw.navigate-link"; + +export const registerLinkNavigation = ( + editor: Editor, + plugin: DiscourseGraphPlugin, +): (() => void) => { + const container = editor.getContainer(); + + const onNavigate = (event: Event) => { + const { url, metaKey, ctrlKey, altKey } = (event as CustomEvent).detail as + | NavigateLinkDetail + | undefined as NavigateLinkDetail; + if (!isObsidianUrl(url)) return; + // Cancelling tells tldraw to preventDefault, so the URI never reaches the + // OS handler and we keep the cross-vault check below. + event.preventDefault(); + + const parsed = parseObsidianOpenUrl(url); + const file = parsed ? resolveObsidianUrlToFile(plugin, parsed) : null; + if (!file) { + showToast({ + severity: "warning", + title: "Cannot open link", + description: "The linked file is not in this vault", + }); + return; + } + + // Mirrors the discourse-node gestures in TldrawViewComponent. + const open = altKey + ? openFileInNewLeaf + : metaKey || ctrlKey + ? openFileInNewTab + : openFileInSidebar; + void open(plugin.app, file); + editor.selectNone(); + }; + + container.addEventListener(NAVIGATE_LINK_EVENT, onNavigate); + return () => container.removeEventListener(NAVIGATE_LINK_EVENT, onNavigate); +}; diff --git a/apps/obsidian/src/components/canvas/utils/textShapeLink.ts b/apps/obsidian/src/components/canvas/utils/textShapeLink.ts index 3751a2259d..4c9159fdf0 100644 --- a/apps/obsidian/src/components/canvas/utils/textShapeLink.ts +++ b/apps/obsidian/src/components/canvas/utils/textShapeLink.ts @@ -1,26 +1,9 @@ -import type { TLShape } from "tldraw"; +import { T, type TLShape } from "tldraw"; -// An allowlist, not a parse check: the link renders as , so admitting -// arbitrary protocols would make `javascript:` an XSS vector. -const ALLOWED_WEB_PROTOCOLS = new Set(["http:", "https:", "mailto:"]); - -// Only obsidian://open?...file=... -- a bare `obsidian:` check would admit -// action URIs like advanced-uri's commandid from a shared canvas. -const isAllowedObsidianUrl = (url: URL): boolean => - // obsidian: is a non-special scheme, so WHATWG leaves the host case alone. - url.host.toLowerCase() === "open" && !!url.searchParams.get("file"); - -export const isAllowedTextLinkUrl = (value: string): boolean => { - if (value === "") return true; - try { - const url = new URL(value); - const protocol = url.protocol.toLowerCase(); - if (protocol === "obsidian:") return isAllowedObsidianUrl(url); - return ALLOWED_WEB_PROTOCOLS.has(protocol); - } catch { - return false; - } -}; +// The patched tldraw validator is the single definition of a valid link, so geo +// shapes and text shapes agree on what obsidian:// forms are accepted. +export const isAllowedTextLinkUrl = (value: string): boolean => + T.linkUrl.isValid(value); export const isObsidianUrl = (url: string): boolean => { try { @@ -31,7 +14,7 @@ export const isObsidianUrl = (url: string): boolean => { }; // Returns the parsed form: `new URL` strips leading control characters, so a -// raw value could pass this allowlist and fail a prefix test further on. +// raw value could pass the validator and fail a prefix test further on. export const getTextShapeLinkUrl = (shape: TLShape): string => { const raw = (shape.meta as { url?: unknown }).url; if (typeof raw !== "string" || !isAllowedTextLinkUrl(raw)) return ""; diff --git a/apps/obsidian/styles.css b/apps/obsidian/styles.css index 02a5deb08c..fac3a3b84e 100644 --- a/apps/obsidian/styles.css +++ b/apps/obsidian/styles.css @@ -137,20 +137,16 @@ body.dg-hide-frontmatter-ids .metadata-property[data-property-key^="rel_" i] { color: var(--interactive-accent); } -/* tldraw's HyperlinkButton is not exported; these mirror it for the text-link - overlay, which positions each icon with inline left/top only. */ +/* The overlay positions a wrapper; tldraw's own button fills it. */ .dg-text-link-overlay { position: absolute; inset: 0; pointer-events: none; } -.dg-text-link-overlay .tl-hyperlink-button { +.dg-text-link-anchor { position: absolute; - right: auto; + width: 44px; + height: 44px; transform: translateY(-50%); pointer-events: all; } -.dg-text-link-overlay .tl-hyperlink__icon { - mask: var(--dg-external-link-icon) center 100% / 100% no-repeat; - -webkit-mask: var(--dg-external-link-icon) center 100% / 100% no-repeat; -} diff --git a/patches/@tldraw__validate@3.14.2.patch b/patches/@tldraw__validate@3.14.2.patch new file mode 100644 index 0000000000..4158b01ded --- /dev/null +++ b/patches/@tldraw__validate@3.14.2.patch @@ -0,0 +1,40 @@ +diff --git a/dist-cjs/lib/validation.js b/dist-cjs/lib/validation.js +index 368a20ff3d175196c190b667b65d16b7a482fe49..7810b007e2d05239359ce8b75410aaed1f7b81bc 100644 +--- a/dist-cjs/lib/validation.js ++++ b/dist-cjs/lib/validation.js +@@ -696,6 +696,15 @@ const validLinkProtocols = /* @__PURE__ */ new Set(["http:", "https:", "mailto:" + const linkUrl = string.check((value) => { + if (value === "") return; + const url = parseUrl(value); ++ const protocol = url.protocol.toLowerCase(); ++ if (protocol === "obsidian:") { ++ if (url.host.toLowerCase() !== "open" || !url.searchParams.get("file")) { ++ throw new ValidationError( ++ `Expected a valid url, got ${JSON.stringify(value)} (obsidian links must be open?file=)` ++ ); ++ } ++ return; ++ } + if (!validLinkProtocols.has(url.protocol.toLowerCase())) { + throw new ValidationError( + `Expected a valid url, got ${JSON.stringify(value)} (invalid protocol)` +diff --git a/dist-esm/lib/validation.mjs b/dist-esm/lib/validation.mjs +index 4ed200ee79e8339ed3c454ff2744e35479dd9c73..754ce54eb58213f20423c63fa5a7acb0f354f990 100644 +--- a/dist-esm/lib/validation.mjs ++++ b/dist-esm/lib/validation.mjs +@@ -643,6 +643,15 @@ const validLinkProtocols = /* @__PURE__ */ new Set(["http:", "https:", "mailto:" + const linkUrl = string.check((value) => { + if (value === "") return; + const url = parseUrl(value); ++ const protocol = url.protocol.toLowerCase(); ++ if (protocol === "obsidian:") { ++ if (url.host.toLowerCase() !== "open" || !url.searchParams.get("file")) { ++ throw new ValidationError( ++ `Expected a valid url, got ${JSON.stringify(value)} (obsidian links must be open?file=)` ++ ); ++ } ++ return; ++ } + if (!validLinkProtocols.has(url.protocol.toLowerCase())) { + throw new ValidationError( + `Expected a valid url, got ${JSON.stringify(value)} (invalid protocol)` diff --git a/patches/tldraw@3.14.2.patch b/patches/tldraw@3.14.2.patch index a67c97190c..ac886ad200 100644 --- a/patches/tldraw@3.14.2.patch +++ b/patches/tldraw@3.14.2.patch @@ -1,3 +1,90 @@ +diff --git a/dist-cjs/index.d.ts b/dist-cjs/index.d.ts +index 494fc8cf2bcf3b3eac5ef1dd4151d10637bc64f4..d6bcac283a9264ac53cfed0c56c1a492452e0ead 100644 +--- a/dist-cjs/index.d.ts ++++ b/dist-cjs/index.d.ts +@@ -2384,6 +2384,9 @@ export declare const RichTextArea: React_3.ForwardRefExoticComponent; + + /** @public */ +diff --git a/dist-cjs/index.js b/dist-cjs/index.js +index 0147c606b112a6deb5c327f871948b879d05331b..9f33578afbac1cdad85483afa8e8b004168df61a 100644 +--- a/dist-cjs/index.js ++++ b/dist-cjs/index.js +@@ -165,6 +165,7 @@ __export(index_exports, { + ReorderMenuSubmenu: () => import_menu_items.ReorderMenuSubmenu, + RhombusToolbarItem: () => import_DefaultToolbarContent.RhombusToolbarItem, + RichTextArea: () => import_RichTextArea.RichTextArea, ++ HyperlinkButton: () => import_HyperlinkButton.HyperlinkButton, + RichTextLabel: () => import_RichTextLabel.RichTextLabel, + RichTextSVG: () => import_RichTextLabel.RichTextSVG, + RotateCWMenuItem: () => import_DefaultActionsMenuContent.RotateCWMenuItem, +@@ -412,6 +413,7 @@ var import_default_shape_constants = require("./lib/shapes/shared/default-shape- + var import_defaultFonts = require("./lib/shapes/shared/defaultFonts"); + var import_PlainTextLabel = require("./lib/shapes/shared/PlainTextLabel"); + var import_RichTextLabel = require("./lib/shapes/shared/RichTextLabel"); ++var import_HyperlinkButton = require("./lib/shapes/shared/HyperlinkButton.js"); + var import_useDefaultColorTheme = require("./lib/shapes/shared/useDefaultColorTheme"); + var import_useEditablePlainText = require("./lib/shapes/shared/useEditablePlainText"); + var import_useEditableRichText = require("./lib/shapes/shared/useEditableRichText"); +diff --git a/dist-cjs/lib/shapes/shared/HyperlinkButton.js b/dist-cjs/lib/shapes/shared/HyperlinkButton.js +index 17be37d15741ae5ebdd78d123382129a3c6f4fdb..dd75ac28d29e730388c56e0662ee32e210cab6f0 100644 +--- a/dist-cjs/lib/shapes/shared/HyperlinkButton.js ++++ b/dist-cjs/lib/shapes/shared/HyperlinkButton.js +@@ -36,6 +36,14 @@ var import_editor = require("@tldraw/editor"); + var import_classnames = __toESM(require("classnames")); + var import_react = require("react"); + const LINK_ICON = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' fill='none'%3E%3Cpath stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M13 5H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6M19 5h6m0 0v6m0-6L13 17'/%3E%3C/svg%3E"; ++const WEB_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:", "mailto:"]); ++function isWebUrl(url) { ++ try { ++ return WEB_PROTOCOLS.has(new URL(url).protocol.toLowerCase()); ++ } catch { ++ return false; ++ } ++} + function HyperlinkButton({ url }) { + const editor = (0, import_editor.useEditor)(); + const hideButton = (0, import_editor.useValue)("zoomLevel", () => editor.getZoomLevel() < 0.32, [editor]); +@@ -45,15 +53,30 @@ function HyperlinkButton({ url }) { + }, + [editor] + ); ++ const web = isWebUrl(url); ++ const onClick = useCallback( ++ (e) => { ++ const ev = new CustomEvent("tldraw.navigate-link", { ++ bubbles: true, ++ cancelable: true, ++ detail: { url, metaKey: e.metaKey, ctrlKey: e.ctrlKey, altKey: e.altKey, shiftKey: e.shiftKey } ++ }); ++ if (!e.currentTarget.dispatchEvent(ev) || !web) e.preventDefault(); ++ }, ++ [url, web] ++ ); + return /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + "a", + { + className: (0, import_classnames.default)("tl-hyperlink-button", { + "tl-hyperlink-button__hidden": hideButton + }), +- href: url, +- target: "_blank", +- rel: "noopener noreferrer", ++ href: web ? url : void 0, ++ target: web ? "_blank" : void 0, ++ rel: web ? "noopener noreferrer" : void 0, ++ role: web ? void 0 : "link", ++ tabIndex: web ? void 0 : 0, ++ onClick, + onPointerDown: useStopPropagationOnShiftKey, + onPointerUp: useStopPropagationOnShiftKey, + title: url, diff --git a/dist-cjs/lib/ui/hooks/menu-hooks.js b/dist-cjs/lib/ui/hooks/menu-hooks.js index a3289bc10d59b071bc093bd4a681ddef53631d38..a4a2ed4ce58e417816a62f4359daa3d554afcf41 100644 --- a/dist-cjs/lib/ui/hooks/menu-hooks.js @@ -11,6 +98,94 @@ index a3289bc10d59b071bc093bd4a681ddef53631d38..a4a2ed4ce58e417816a62f4359daa3d5 }, [editor] ); +diff --git a/dist-esm/index.d.mts b/dist-esm/index.d.mts +index 494fc8cf2bcf3b3eac5ef1dd4151d10637bc64f4..d6bcac283a9264ac53cfed0c56c1a492452e0ead 100644 +--- a/dist-esm/index.d.mts ++++ b/dist-esm/index.d.mts +@@ -2384,6 +2384,9 @@ export declare const RichTextArea: React_3.ForwardRefExoticComponent; + + /** @public */ +diff --git a/dist-esm/index.mjs b/dist-esm/index.mjs +index b704ca9de912cf561e35c254af1b3c1c3782044b..ea81756d289322953ee1bbe3fd0f10db4fb8750b 100644 +--- a/dist-esm/index.mjs ++++ b/dist-esm/index.mjs +@@ -103,6 +103,8 @@ import { + RichTextLabel, + RichTextSVG + } from "./lib/shapes/shared/RichTextLabel.mjs"; ++/** patched export for custom link handling */ ++import { HyperlinkButton } from "./lib/shapes/shared/HyperlinkButton.mjs"; + import { useDefaultColorTheme } from "./lib/shapes/shared/useDefaultColorTheme.mjs"; + import { useEditablePlainText, useEditableText } from "./lib/shapes/shared/useEditablePlainText.mjs"; + import { useEditableRichText } from "./lib/shapes/shared/useEditableRichText.mjs"; +@@ -630,6 +632,7 @@ export { + ReorderMenuSubmenu, + RhombusToolbarItem, + RichTextArea, ++ HyperlinkButton, + RichTextLabel, + RichTextSVG, + RotateCWMenuItem, +diff --git a/dist-esm/lib/shapes/shared/HyperlinkButton.mjs b/dist-esm/lib/shapes/shared/HyperlinkButton.mjs +index 9395646868e44c2ed1537dadaf4129a87d6cd2aa..2ab231604efae4b4bedd95f361038220abe819db 100644 +--- a/dist-esm/lib/shapes/shared/HyperlinkButton.mjs ++++ b/dist-esm/lib/shapes/shared/HyperlinkButton.mjs +@@ -3,6 +3,14 @@ import { stopEventPropagation, useEditor, useValue } from "@tldraw/editor"; + import classNames from "classnames"; + import { useCallback } from "react"; + const LINK_ICON = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' fill='none'%3E%3Cpath stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M13 5H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6M19 5h6m0 0v6m0-6L13 17'/%3E%3C/svg%3E"; ++const WEB_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:", "mailto:"]); ++function isWebUrl(url) { ++ try { ++ return WEB_PROTOCOLS.has(new URL(url).protocol.toLowerCase()); ++ } catch { ++ return false; ++ } ++} + function HyperlinkButton({ url }) { + const editor = useEditor(); + const hideButton = useValue("zoomLevel", () => editor.getZoomLevel() < 0.32, [editor]); +@@ -12,15 +20,30 @@ function HyperlinkButton({ url }) { + }, + [editor] + ); ++ const web = isWebUrl(url); ++ const onClick = useCallback( ++ (e) => { ++ const ev = new CustomEvent("tldraw.navigate-link", { ++ bubbles: true, ++ cancelable: true, ++ detail: { url, metaKey: e.metaKey, ctrlKey: e.ctrlKey, altKey: e.altKey, shiftKey: e.shiftKey } ++ }); ++ if (!e.currentTarget.dispatchEvent(ev) || !web) e.preventDefault(); ++ }, ++ [url, web] ++ ); + return /* @__PURE__ */ jsx( + "a", + { + className: classNames("tl-hyperlink-button", { + "tl-hyperlink-button__hidden": hideButton + }), +- href: url, +- target: "_blank", +- rel: "noopener noreferrer", ++ href: web ? url : void 0, ++ target: web ? "_blank" : void 0, ++ rel: web ? "noopener noreferrer" : void 0, ++ role: web ? void 0 : "link", ++ tabIndex: web ? void 0 : 0, ++ onClick, + onPointerDown: useStopPropagationOnShiftKey, + onPointerUp: useStopPropagationOnShiftKey, + title: url, diff --git a/dist-esm/lib/ui/hooks/menu-hooks.mjs b/dist-esm/lib/ui/hooks/menu-hooks.mjs index aec3d2936032f118aa70afb33872d3b0f3e9887c..5bcde1a144b5be958821eb110b7940d88b4de5dd 100644 --- a/dist-esm/lib/ui/hooks/menu-hooks.mjs diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e1363aaf4..87a530a3ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,11 +85,14 @@ patchedDependencies: '@tldraw/state@2.4.6': hash: b5bbaa41bf2edae401d2bcd96dfa3c24086b564f297ef2df16a3b3156cd51ecc path: patches/@tldraw__state@2.4.6.patch + '@tldraw/validate@3.14.2': + hash: b72329ddf0ca9e87247bcc1f2518a8c4462328bc24a83f2d17fc87a8fd5908e1 + path: patches/@tldraw__validate@3.14.2.patch tldraw@2.4.6: hash: 56e196052862c9a58a11b43e5e121384cd1d6548416afa0f16e9fbfbf0e4080d path: patches/tldraw@2.4.6.patch tldraw@3.14.2: - hash: d7bd538b65fc2ca5ffa25603126f399a213fb97640d0e60c489971b5cece724b + hash: bbc8f62d0f23c0ec7c109cd4ce8b4182670ea4bde02243c3cfee80b94038a37a path: patches/tldraw@3.14.2.patch importers: @@ -162,7 +165,7 @@ importers: version: 1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.20.0)(typescript@5.5.4))) tldraw: specifier: 3.14.2 - version: 3.14.2(patch_hash=d7bd538b65fc2ca5ffa25603126f399a213fb97640d0e60c489971b5cece724b)(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 3.14.2(patch_hash=bbc8f62d0f23c0ec7c109cd4ce8b4182670ea4bde02243c3cfee80b94038a37a)(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) devDependencies: '@octokit/core': specifier: ^6.1.2 @@ -16202,7 +16205,7 @@ snapshots: '@tldraw/store': 3.14.2(react@18.2.0) '@tldraw/tlschema': 3.14.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@tldraw/utils': 3.14.2 - '@tldraw/validate': 3.14.2 + '@tldraw/validate': 3.14.2(patch_hash=b72329ddf0ca9e87247bcc1f2518a8c4462328bc24a83f2d17fc87a8fd5908e1) '@types/core-js': 2.5.8 '@use-gesture/react': 10.3.1(react@18.2.0) classnames: 2.5.1 @@ -16327,7 +16330,7 @@ snapshots: '@tldraw/state': 3.14.2 '@tldraw/store': 3.14.2(react@18.2.0) '@tldraw/utils': 3.14.2 - '@tldraw/validate': 3.14.2 + '@tldraw/validate': 3.14.2(patch_hash=b72329ddf0ca9e87247bcc1f2518a8c4462328bc24a83f2d17fc87a8fd5908e1) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -16348,7 +16351,7 @@ snapshots: dependencies: '@tldraw/utils': 2.4.6 - '@tldraw/validate@3.14.2': + '@tldraw/validate@3.14.2(patch_hash=b72329ddf0ca9e87247bcc1f2518a8c4462328bc24a83f2d17fc87a8fd5908e1)': dependencies: '@tldraw/utils': 3.14.2 @@ -23770,7 +23773,7 @@ snapshots: - '@types/react' - '@types/react-dom' - tldraw@3.14.2(patch_hash=d7bd538b65fc2ca5ffa25603126f399a213fb97640d0e60c489971b5cece724b)(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + tldraw@3.14.2(patch_hash=bbc8f62d0f23c0ec7c109cd4ce8b4182670ea4bde02243c3cfee80b94038a37a)(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@tiptap/core': 2.26.2(@tiptap/pm@2.26.2) '@tiptap/extension-code': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 25d2253579..4c9737d99b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -76,6 +76,7 @@ patchedDependencies: "@tldraw/state@2.4.6": patches/@tldraw__state@2.4.6.patch tldraw@2.4.6: patches/tldraw@2.4.6.patch tldraw@3.14.2: patches/tldraw@3.14.2.patch + "@tldraw/validate@3.14.2": patches/@tldraw__validate@3.14.2.patch peerDependencyRules: allowedVersions: From 5cbaa2be99e751f0430b97e3a3ce1c9284fe194a Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 1 Oct 2026 15:06:34 -0400 Subject: [PATCH 2/2] Prototype C: one dialog hint for every shape type 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 --- .../src/components/canvas/TextLinkDialog.tsx | 51 ++++++++----------- 1 file changed, 22 insertions(+), 29 deletions(-) diff --git a/apps/obsidian/src/components/canvas/TextLinkDialog.tsx b/apps/obsidian/src/components/canvas/TextLinkDialog.tsx index 5e89f2554e..447d7fd418 100644 --- a/apps/obsidian/src/components/canvas/TextLinkDialog.tsx +++ b/apps/obsidian/src/components/canvas/TextLinkDialog.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { - T, TLBaseShape, TLShape, TldrawUiButton, @@ -41,8 +40,8 @@ type UrlValidity = { isValid: boolean; hasProtocol: boolean }; // One validator for every shape type now that tldraw's own accepts obsidian://. const validateUrl = (url: string): UrlValidity => { - if (T.linkUrl.isValid(url)) return { isValid: true, hasProtocol: true }; - if (T.linkUrl.isValid(`https://${url}`)) + if (isAllowedTextLinkUrl(url)) return { isValid: true, hasProtocol: true }; + if (isAllowedTextLinkUrl(`https://${url}`)) return { isValid: true, hasProtocol: false }; return { isValid: false, hasProtocol: false }; }; @@ -77,7 +76,6 @@ const TextLinkDialogInner = track( }: TextLinkDialogProps & { selectedShape: ShapeWithUrl }) => { const editor = useEditor(); const rInput = useRef(null); - const shapeType = selectedShape.type; useEffect(() => { editor.timers.requestAnimationFrame(() => rInput.current?.focus()); @@ -96,28 +94,25 @@ const TextLinkDialogInner = track( return { actual: initialValue, safe: initialValue, valid: true }; }); - const handleChange = useCallback( - (rawValue: string) => { - // Auto-correct a doubled https:// from a bad paste. - const fixedRawValue = rawValue.replace( - /https?:\/\/(https?:\/\/)/, - (_match, arg1: string) => arg1, - ); - const result = validateUrl(fixedRawValue); - const safeValue = result.isValid - ? result.hasProtocol - ? fixedRawValue - : `https://${fixedRawValue}` - : "https://"; - - setUrlInputState({ - actual: fixedRawValue, - safe: safeValue, - valid: result.isValid, - }); - }, - [shapeType], - ); + const handleChange = useCallback((rawValue: string) => { + // Auto-correct a doubled https:// from a bad paste. + const fixedRawValue = rawValue.replace( + /https?:\/\/(https?:\/\/)/, + (_match, arg1: string) => arg1, + ); + const result = validateUrl(fixedRawValue); + const safeValue = result.isValid + ? result.hasProtocol + ? fixedRawValue + : `https://${fixedRawValue}` + : "https://"; + + setUrlInputState({ + actual: fixedRawValue, + safe: safeValue, + valid: result.isValid, + }); + }, []); const handleClear = useCallback(() => { const onlySelectedShape = editor.getOnlySelectedShape(); @@ -179,9 +174,7 @@ const TextLinkDialogInner = track(
{!urlInputState.valid ? "Invalid URL" - : shapeType === "text" - ? "Enter a URL, or an obsidian:// link to a page." - : "Enter a URL."} + : "Enter a URL, or an obsidian:// link to a page."}