diff --git a/apps/obsidian/src/components/canvas/TextLinkDialog.tsx b/apps/obsidian/src/components/canvas/TextLinkDialog.tsx new file mode 100644 index 0000000000..de1f573e2a --- /dev/null +++ b/apps/obsidian/src/components/canvas/TextLinkDialog.tsx @@ -0,0 +1,228 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + TLBaseShape, + TLShape, + TldrawUiButton, + TldrawUiButtonLabel, + TldrawUiDialogBody, + TldrawUiDialogCloseButton, + TldrawUiDialogFooter, + TldrawUiDialogHeader, + TldrawUiDialogTitle, + TldrawUiInput, + track, + useEditor, +} from "tldraw"; +import { + getTextShapeLinkUrl, + isAllowedTextLinkUrl, + isObsidianUrl, +} from "~/components/canvas/utils/textShapeLink"; + +type ShapeWithUrl = TLBaseShape; + +// Text shapes keep their link in `meta` so the stock props schema is untouched; +// every other shape type uses tldraw's own `props.url`. +const isTextShape = (shape: TLShape): boolean => shape.type === "text"; + +const linkPatch = (shape: TLShape, url: string) => + isTextShape(shape) ? { meta: { url } } : { props: { url } }; + +const readLinkUrl = (shape: TLShape): string => + isTextShape(shape) + ? getTextShapeLinkUrl(shape) + : (((shape.props as { url?: unknown }).url as string | undefined) ?? ""); + +// Declared as a function property rather than reusing TLUiDialogProps, whose +// method-shorthand onClose trips @typescript-eslint/unbound-method. +type TextLinkDialogProps = { onClose: () => void }; + +type UrlValidity = { isValid: boolean; hasProtocol: boolean }; + +// Bookmarks link their title with a raw , not HyperlinkButton, so a +// page link there would reach the OS handler and skip the vault check. +const acceptsPageLinks = (shapeType: string): boolean => + shapeType !== "bookmark"; + +// Every shape type uses tldraw's linkUrl, patched to accept obsidian://open links. +const validateUrl = (url: string, shapeType: string): UrlValidity => { + // Kept away from the https:// fallback, which would accept `https://obsidian://…`. + if (isObsidianUrl(url)) { + const isValid = acceptsPageLinks(shapeType) && isAllowedTextLinkUrl(url); + return { isValid, hasProtocol: isValid }; + } + if (isAllowedTextLinkUrl(url)) return { isValid: true, hasProtocol: true }; + if (isAllowedTextLinkUrl(`https://${url}`)) + return { isValid: true, hasProtocol: false }; + return { isValid: false, hasProtocol: false }; +}; + +// A fork of tldraw's EditLinkDialog, which is not exported. Copy differs: the +// original's msg() keys are inlined, as this app has no i18n layer. +export const TextLinkDialog = track(({ onClose }: TextLinkDialogProps) => { + const editor = useEditor(); + const selectedShape = editor.getOnlySelectedShape(); + + const canEditLink = + selectedShape && + (isTextShape(selectedShape) || + ("url" in selectedShape.props && + typeof selectedShape.props.url === "string")); + + if (!canEditLink) return null; + + return ( + + ); +}); + +const TextLinkDialogInner = track( + ({ + onClose, + selectedShape, + }: TextLinkDialogProps & { selectedShape: ShapeWithUrl }) => { + const editor = useEditor(); + const rInput = useRef(null); + const shapeType = selectedShape.type; + + useEffect(() => { + editor.timers.requestAnimationFrame(() => rInput.current?.focus()); + }, [editor]); + + const rInitialValue = useRef(readLinkUrl(selectedShape)); + + const [urlInputState, setUrlInputState] = useState(() => { + const initialUrl = readLinkUrl(selectedShape); + const result = validateUrl(initialUrl, shapeType); + const initialValue = result.isValid + ? result.hasProtocol + ? initialUrl + : `https://${initialUrl}` + : "https://"; + return { actual: initialValue, safe: initialValue, valid: true }; + }); + + const handleChange = useCallback( + (rawValue: string) => { + // Drop the prefilled https:// when a full URL is pasted after it. + const fixedRawValue = rawValue.replace( + /https?:\/\/((?:https?|obsidian):\/\/)/i, + (_match, arg1: string) => arg1, + ); + const result = validateUrl(fixedRawValue, shapeType); + const safeValue = result.isValid + ? result.hasProtocol + ? fixedRawValue + : `https://${fixedRawValue}` + : "https://"; + + setUrlInputState({ + actual: fixedRawValue, + safe: safeValue, + valid: result.isValid, + }); + }, + [shapeType], + ); + + const handleClear = useCallback(() => { + const onlySelectedShape = editor.getOnlySelectedShape(); + if (!onlySelectedShape) return; + editor.updateShapes([ + { + id: onlySelectedShape.id, + type: onlySelectedShape.type, + ...linkPatch(onlySelectedShape, ""), + }, + ]); + onClose(); + }, [editor, onClose]); + + const handleComplete = useCallback(() => { + // Enter reaches this even while Save is disabled, and the fallback + // "https://" fails both validators. + if (!urlInputState.valid) return; + const onlySelectedShape = editor.getOnlySelectedShape(); + if (!onlySelectedShape) return; + if (readLinkUrl(onlySelectedShape) !== urlInputState.safe) { + editor.updateShapes([ + { + id: onlySelectedShape.id, + type: onlySelectedShape.type, + ...linkPatch(onlySelectedShape, urlInputState.safe), + }, + ]); + } + onClose(); + }, [editor, onClose, urlInputState]); + + const handleCancel = useCallback(() => { + onClose(); + }, [onClose]); + + const isRemoving = rInitialValue.current && !urlInputState.valid; + + return ( + <> + + Edit link + + + +
+ +
+ {!urlInputState.valid + ? "Invalid URL" + : acceptsPageLinks(shapeType) + ? "Enter a URL, or an obsidian:// link to a page." + : "Enter a URL."} +
+
+
+ + + Cancel + + {isRemoving ? ( + + Clear + + ) : ( + + Save + + )} + + + ); + }, +); diff --git a/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx index 00cfea40b2..867849e108 100644 --- a/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx +++ b/apps/obsidian/src/components/canvas/TldrawViewComponent.tsx @@ -51,6 +51,9 @@ import { DragHandleOverlay } from "./overlays/DragHandleOverlay"; import { NodeCardContextMenu } from "./NodeCardContextMenu"; 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, @@ -129,6 +132,11 @@ export const TldrawPreviewComponent = ({ return () => clearTimeout(timer); }, []); + useEffect(() => { + if (!isEditorMounted || !editorRef.current) return; + return registerLinkNavigation(editorRef.current, plugin); + }, [isEditorMounted, plugin]); + // Add keyboard event listener for Meta+Alt+Enter when editor is mounted useEffect(() => { if (!isEditorMounted || !editorRef.current) return; @@ -463,6 +471,24 @@ export const TldrawPreviewComponent = ({ }, }} overrides={{ + // Swap only edit-link's dialog; the menu item, icon, placement + // and eligibility gate stay stock. + actions: (editor, actions, helpers) => { + const editLink = actions["edit-link"]; + if (editLink) { + actions["edit-link"] = { + ...editLink, + onSelect: () => { + // Same guard as stock: no-op outside the select tool. + if (!editor.isIn("select")) return; + if (!editor.getOnlySelectedShape()) return; + editor.markHistoryStoppingPoint("edit-link"); + helpers.addDialog({ component: TextLinkDialog }); + }, + }; + } + return actions; + }, tools: (editor, tools) => { tools["discourse-node"] = { id: "discourse-node", @@ -541,6 +567,7 @@ export const TldrawPreviewComponent = ({ InFrontOfTheCanvas: () => ( <> + ), }} diff --git a/apps/obsidian/src/components/canvas/overlays/TextLinkOverlay.tsx b/apps/obsidian/src/components/canvas/overlays/TextLinkOverlay.tsx new file mode 100644 index 0000000000..8982e1fcf1 --- /dev/null +++ b/apps/obsidian/src/components/canvas/overlays/TextLinkOverlay.tsx @@ -0,0 +1,59 @@ +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 }; + +// 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 = () => { + const editor = useEditor(); + + const links = useValue( + "textShapeLinks", + () => { + if (editor.getZoomLevel() < HIDE_BELOW_ZOOM) return []; + const viewport = editor.getViewportPageBounds(); + return editor.getCurrentPageShapes().flatMap((shape) => { + if (shape.type !== "text" || editor.isShapeHidden(shape)) return []; + const url = getTextShapeLinkUrl(shape); + if (!url) return []; + const bounds = editor.getShapePageBounds(shape.id); + if (!bounds || !viewport.includes(bounds)) return []; + const topRight = editor.pageToViewport({ + x: bounds.maxX, + y: bounds.minY, + }); + const bottomRight = editor.pageToViewport({ + x: bounds.maxX, + y: bounds.maxY, + }); + return [ + { + id: shape.id, + url, + left: topRight.x, + top: (topRight.y + bottomRight.y) / 2, + }, + ]; + }); + }, + [editor], + ); + + return ( +
+ {links.map(({ id, url, left, top }) => ( +
+ +
+ ))} +
+ ); +}; diff --git a/apps/obsidian/src/components/canvas/utils/externalContentHandlers.ts b/apps/obsidian/src/components/canvas/utils/externalContentHandlers.ts index 643fb2de4e..9db8c342fd 100644 --- a/apps/obsidian/src/components/canvas/utils/externalContentHandlers.ts +++ b/apps/obsidian/src/components/canvas/utils/externalContentHandlers.ts @@ -52,7 +52,7 @@ export const parseObsidianOpenUrl = (url: string): ParsedObsidianUrl | null => { * Resolve an obsidian URL to a TFile in the current vault. * Returns null if the URL points to another vault or the file is not found. */ -const resolveObsidianUrlToFile = ( +export const resolveObsidianUrlToFile = ( plugin: DiscourseGraphPlugin, parsed: ParsedObsidianUrl, ): TFile | null => { @@ -63,9 +63,7 @@ const resolveObsidianUrlToFile = ( let abstract = plugin.app.vault.getAbstractFileByPath(parsed.filePath); if (!(abstract instanceof TFile) && !parsed.filePath.endsWith(".md")) { - abstract = plugin.app.vault.getAbstractFileByPath( - `${parsed.filePath}.md`, - ); + abstract = plugin.app.vault.getAbstractFileByPath(`${parsed.filePath}.md`); } return abstract instanceof TFile ? abstract : null; }; @@ -80,7 +78,8 @@ const isDiscourseNodeFile = ( ): boolean => { if (!file.path.endsWith(".md")) return false; const frontmatter = getFrontmatterForFile(plugin.app, file); - const nodeTypeId = (frontmatter as { nodeTypeId?: string } | null)?.nodeTypeId; + const nodeTypeId = (frontmatter as { nodeTypeId?: string } | null) + ?.nodeTypeId; if (!nodeTypeId || typeof nodeTypeId !== "string") return false; return !!getNodeTypeById(plugin, nodeTypeId); }; @@ -116,7 +115,9 @@ export const handleExternalUrlContent = async ({ if (url.startsWith(OBSIDIAN_URL_PREFIX)) { const parsed = parseObsidianOpenUrl(url); if (!parsed) { - new Notice("Invalid Obsidian link. Only discourse nodes can be dropped on the canvas."); + new Notice( + "Invalid Obsidian link. Only discourse nodes can be dropped on the canvas.", + ); return; } @@ -182,7 +183,9 @@ const createDiscourseNodeShapeAtPoint = async ({ if (existing) { editor.setSelectedShapes([existing.id]); - editor.zoomToSelection({ animation: { duration: editor.options.animationMediumMs } }); + editor.zoomToSelection({ + animation: { duration: editor.options.animationMediumMs }, + }); return; } 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..3edb4eed23 --- /dev/null +++ b/apps/obsidian/src/components/canvas/utils/linkNavigation.ts @@ -0,0 +1,66 @@ +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; + 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(); + + // The validator parses with `new URL`, so geo links may be stored as + // `Obsidian://…` or with leading spaces; the parser expects the normal form. + const parsed = parseObsidianOpenUrl(new URL(url).toString()); + 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 new file mode 100644 index 0000000000..4c9159fdf0 --- /dev/null +++ b/apps/obsidian/src/components/canvas/utils/textShapeLink.ts @@ -0,0 +1,26 @@ +import { T, type TLShape } from "tldraw"; + +// 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 { + return new URL(url).protocol.toLowerCase() === "obsidian:"; + } catch { + return false; + } +}; + +// Returns the parsed form: `new URL` strips leading control characters, so a +// 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 ""; + try { + return new URL(raw).toString(); + } catch { + return ""; + } +}; diff --git a/apps/obsidian/styles.css b/apps/obsidian/styles.css index 7f2fed184b..b3c625e247 100644 --- a/apps/obsidian/styles.css +++ b/apps/obsidian/styles.css @@ -136,3 +136,19 @@ body.dg-hide-frontmatter-ids .metadata-property[data-property-key^="rel_" i] { border-radius: 0; color: var(--interactive-accent); } + +/* 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-anchor { + position: absolute; + /* Clears tldraw's edge resize handle, which reaches 6px past the shape. */ + margin-left: 8px; + width: 44px; + height: 44px; + transform: translateY(-50%); + pointer-events: all; +} 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 new file mode 100644 index 0000000000..fa472e7fe1 --- /dev/null +++ b/patches/tldraw@3.14.2.patch @@ -0,0 +1,201 @@ +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 = (0, import_react.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 ++++ b/dist-cjs/lib/ui/hooks/menu-hooks.js +@@ -155,7 +155,7 @@ function useHasLinkShapeSelected() { + "hasLinkShapeSelected", + () => { + const onlySelectedShape = editor.getOnlySelectedShape(); +- return !!(onlySelectedShape && onlySelectedShape.type !== "embed" && "url" in onlySelectedShape.props && !onlySelectedShape.isLocked); ++ return !!(onlySelectedShape && onlySelectedShape.type !== "embed" && ("url" in onlySelectedShape.props || onlySelectedShape.type === "text") && !onlySelectedShape.isLocked); + }, + [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 ++++ b/dist-esm/lib/ui/hooks/menu-hooks.mjs +@@ -124,7 +124,7 @@ function useHasLinkShapeSelected() { + "hasLinkShapeSelected", + () => { + const onlySelectedShape = editor.getOnlySelectedShape(); +- return !!(onlySelectedShape && onlySelectedShape.type !== "embed" && "url" in onlySelectedShape.props && !onlySelectedShape.isLocked); ++ return !!(onlySelectedShape && onlySelectedShape.type !== "embed" && ("url" in onlySelectedShape.props || onlySelectedShape.type === "text") && !onlySelectedShape.isLocked); + }, + [editor] + ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eebdf276ac..258ec6b81c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,9 +85,15 @@ 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: 1dc6e0d5a14876d58a705c3446a937e7811078b17a0918da85f9faa4b096d68a path: patches/tldraw@2.4.6.patch + tldraw@3.14.2: + hash: 89e8db00d251ba3863ff631f54ad49dd5aacb63620fc75c26a31a25e59699949 + path: patches/tldraw@3.14.2.patch importers: @@ -159,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(@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=89e8db00d251ba3863ff631f54ad49dd5aacb63620fc75c26a31a25e59699949)(@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 @@ -16199,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 @@ -16324,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) @@ -16345,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 @@ -23767,7 +23773,7 @@ snapshots: - '@types/react' - '@types/react-dom' - tldraw@3.14.2(@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=89e8db00d251ba3863ff631f54ad49dd5aacb63620fc75c26a31a25e59699949)(@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 2a125d6cec..4c9737d99b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -75,6 +75,8 @@ patchedDependencies: "@tldraw/editor@2.4.6": patches/@tldraw__editor@2.4.6.patch "@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: