diff --git a/apps/roam/src/components/canvas/Tldraw.tsx b/apps/roam/src/components/canvas/Tldraw.tsx index 235d06ef6e..6921e0b230 100644 --- a/apps/roam/src/components/canvas/Tldraw.tsx +++ b/apps/roam/src/components/canvas/Tldraw.tsx @@ -55,6 +55,7 @@ import { import "tldraw/tldraw.css"; import tldrawStyles from "./tldrawStyles"; import { DragHandleOverlay } from "./overlays/DragHandleOverlay"; +import { TextLinkOverlay } from "./overlays/TextLinkOverlay"; import { hasAcceptedRelationSchema, isDiscourseNodeShape } from "./canvasUtils"; import getDiscourseNodes, { DiscourseNode } from "~/utils/getDiscourseNodes"; import getDiscourseRelations, { @@ -172,6 +173,14 @@ const setActiveCanvas = ({ } }; +// InFrontOfTheCanvas takes one component; module scope keeps its identity stable across renders. +const CanvasOverlays = (): JSX.Element => ( + <> + + + +); + export const DEFAULT_WIDTH = 160; export const DEFAULT_HEIGHT = 64; export const MAX_WIDTH = "400px"; @@ -1017,7 +1026,7 @@ const TldrawCanvasShared = ({ const editorComponents: TLEditorComponents = { ...defaultEditorComponents, OnTheCanvas: ToastListener, - InFrontOfTheCanvas: DragHandleOverlay, + InFrontOfTheCanvas: CanvasOverlays, }; const customUiComponents: TLUiComponents = createUiComponents({ allNodes, diff --git a/apps/roam/src/components/canvas/overlays/TextLinkOverlay.tsx b/apps/roam/src/components/canvas/overlays/TextLinkOverlay.tsx new file mode 100644 index 0000000000..d94ac5d334 --- /dev/null +++ b/apps/roam/src/components/canvas/overlays/TextLinkOverlay.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { + HyperlinkButton, + T, + TLShape, + TLShapeId, + useEditor, + useValue, +} from "tldraw"; + +type TextLink = { id: TLShapeId; url: string; left: number; top: number }; + +// Matches the hit area of tldraw's .tl-hyperlink-button. +const BUTTON_SIZE = 44; + +// meta is unvalidated, so this is the only guard before an href. Returning the +// parsed form keeps the href identical to what passed validation. +export const getTextShapeLinkUrl = (shape: TLShape): string | undefined => { + if (shape.type !== "text") return undefined; + const { url } = shape.meta; + if (typeof url !== "string" || !T.linkUrl.isValid(url)) return undefined; + try { + // linkUrl resolves relative paths against a dummy origin; only absolute URLs render. + return new URL(url).href; + } catch { + return undefined; + } +}; + +// Text bounds hug the glyphs, so the icon sits just outside the right edge +// rather than in the shape corner where geo shapes draw it. +export const TextLinkOverlay = (): JSX.Element => { + const editor = useEditor(); + const links = useValue( + "textShapeLinks", + () => { + const viewport = editor.getViewportPageBounds(); + return editor.getCurrentPageShapes().flatMap((shape) => { + const url = getTextShapeLinkUrl(shape); + if (!url) return []; + const bounds = editor.getShapePageBounds(shape.id); + if (!bounds || !viewport.collides(bounds)) return []; + const anchor = editor.pageToViewport({ + x: bounds.maxX, + y: bounds.midY, + }); + return [ + { + id: shape.id, + url, + left: anchor.x, + top: anchor.y - BUTTON_SIZE / 2, + }, + ]; + }); + }, + [editor], + ); + const zoomLevel = useValue("zoomLevel", () => editor.getZoomLevel(), [ + editor, + ]); + + return ( +
+ {links.map(({ id, url, left, top }) => ( +
+ +
+ ))} +
+ ); +}; diff --git a/apps/roam/src/utils/__tests__/textLinkOverlay.test.ts b/apps/roam/src/utils/__tests__/textLinkOverlay.test.ts new file mode 100644 index 0000000000..3e10659a8f --- /dev/null +++ b/apps/roam/src/utils/__tests__/textLinkOverlay.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { createTLSchema, TLShape } from "tldraw"; +import { getTextShapeLinkUrl } from "~/components/canvas/overlays/TextLinkOverlay"; + +const makeShape = ({ + type = "text", + meta = {}, +}: { + type?: string; + meta?: Record; +} = {}): TLShape => + ({ + id: "shape:t1", + typeName: "shape", + type, + x: 0, + y: 0, + rotation: 0, + index: "a1", + parentId: "page:page", + isLocked: false, + opacity: 1, + meta, + props: { + color: "black", + size: "m", + font: "draw", + textAlign: "middle", + w: 100, + text: "hello", + scale: 1, + autoSize: true, + }, + }) as unknown as TLShape; + +const withUrl = (url: unknown): TLShape => makeShape({ meta: { url } }); + +describe("getTextShapeLinkUrl", () => { + it("returns the parsed href for a valid link", () => { + expect(getTextShapeLinkUrl(withUrl("https://Example.com"))).toBe( + "https://example.com/", + ); + expect(getTextShapeLinkUrl(withUrl("mailto:a@b.co"))).toBe("mailto:a@b.co"); + }); + + it("treats a missing or cleared link as no link", () => { + expect(getTextShapeLinkUrl(makeShape())).toBeUndefined(); + expect(getTextShapeLinkUrl(withUrl(""))).toBeUndefined(); + expect(getTextShapeLinkUrl(withUrl(42))).toBeUndefined(); + }); + + it("ignores links on shapes other than text", () => { + const geo = makeShape({ + type: "geo", + meta: { url: "https://example.com" }, + }); + expect(getTextShapeLinkUrl(geo)).toBeUndefined(); + }); + + // meta has no schema, so a collaborator or API writer can store anything here. + it("rejects protocols that could run script", () => { + expect(getTextShapeLinkUrl(withUrl("javascript:alert(1)"))).toBeUndefined(); + expect( + getTextShapeLinkUrl(withUrl(" javascript:alert(1)")), + ).toBeUndefined(); + expect(getTextShapeLinkUrl(withUrl("data:text/html,x"))).toBeUndefined(); + }); + + it("rejects relative URLs that linkUrl resolves against a dummy origin", () => { + expect(getTextShapeLinkUrl(withUrl("/page"))).toBeUndefined(); + expect(getTextShapeLinkUrl(withUrl("//evil.example/x"))).toBeUndefined(); + }); +}); + +describe("text shape link persistence", () => { + // The stock schema is what older extension builds and the sync worker use. + const stockSchema = createTLSchema(); + + it("validates a text shape carrying meta.url against the stock schema", () => { + expect(() => + stockSchema.validateRecord( + {} as never, + withUrl("https://example.com") as never, + "initialize", + null, + ), + ).not.toThrow(); + }); + + it("round-trips meta.url through a snapshot load with no migration", () => { + const shape = withUrl("https://example.com"); + const migrated = stockSchema.migrateStoreSnapshot({ + store: { [shape.id]: shape }, + schema: stockSchema.serialize(), + }); + expect(migrated.type).toBe("success"); + const loaded = (migrated as unknown as { value: Record }) + .value[shape.id]; + expect(loaded.meta.url).toBe("https://example.com"); + }); +}); diff --git a/patches/tldraw@2.4.6.patch b/patches/tldraw@2.4.6.patch index 721bebb47d..e9f96d730b 100644 --- a/patches/tldraw@2.4.6.patch +++ b/patches/tldraw@2.4.6.patch @@ -2,10 +2,10 @@ diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 095ed92631ae57d9caac683759c9b38491c92677..0000000000000000000000000000000000000000 diff --git a/dist-cjs/index.d.ts b/dist-cjs/index.d.ts -index ec996d145b83f637e0d43ad0db1f0e3ab87efb70..b033a8df604153626c203f3c808267a8c9592923 100644 +index ec996d145b83f637e0d43ad0db1f0e3ab87efb70..678a8cb45aac93bac4d9c627e6403c9dabf70725 100644 --- a/dist-cjs/index.d.ts +++ b/dist-cjs/index.d.ts -@@ -407,6 +407,18 @@ export declare function DefaultStylePanelContent({ styles }: TLUiStylePanelConte +@@ -407,6 +407,22 @@ export declare function DefaultStylePanelContent({ styles }: TLUiStylePanelConte */ export declare const DefaultToolbar: NamedExoticComponent; @@ -19,12 +19,16 @@ index ec996d145b83f637e0d43ad0db1f0e3ab87efb70..b033a8df604153626c203f3c808267a8 + children?: ReactNode; +} +export declare function MobileStylePanel(): JSX_2.Element; ++export declare function HyperlinkButton({ url, zoomLevel }: { ++ url: string; ++ zoomLevel: number; ++}): JSX_2.Element; +/** patched export for custom toolbar */ + /** @public @react */ export declare function DefaultToolbarContent(): JSX_2.Element; -@@ -2243,6 +2255,7 @@ export declare interface TLUiToolItem shape.type === "text" ? typeof shape.meta.url === "string" ? shape.meta.url : "" : shape.props.url; ++const getLinkUpdate = (shape, url) => shape.type === "text" ? { id: shape.id, type: shape.type, meta: { url } } : { id: shape.id, type: shape.type, props: { url } }; + const EditLinkDialog = track(function EditLinkDialog2({ onClose }) { + const editor = useEditor(); + const selectedShape = editor.getOnlySelectedShape(); +- if (!(selectedShape && "url" in selectedShape.props && typeof selectedShape.props.url === "string")) { ++ if (!(selectedShape && (selectedShape.type === "text" || "url" in selectedShape.props && typeof selectedShape.props.url === "string"))) { + return null; + } + return /* @__PURE__ */ jsx(EditLinkDialogInner, { onClose, selectedShape }); +@@ -39,10 +42,11 @@ const EditLinkDialogInner = track(function EditLinkDialogInner2({ + useEffect(() => { + editor.timers.requestAnimationFrame(() => rInput.current?.focus()); + }, [editor]); +- const rInitialValue = useRef(selectedShape.props.url); ++ const rInitialValue = useRef(getShapeLinkUrl(selectedShape)); + const [urlInputState, setUrlInputState] = useState(() => { +- const urlValidResult = validateUrl(selectedShape.props.url); +- const initialValue = urlValidResult.isValid === true ? urlValidResult.hasProtocol ? selectedShape.props.url : "https://" + selectedShape.props.url : "https://"; ++ const initialUrl = getShapeLinkUrl(selectedShape); ++ const urlValidResult = validateUrl(initialUrl); ++ const initialValue = urlValidResult.isValid === true ? urlValidResult.hasProtocol ? initialUrl : "https://" + initialUrl : "https://"; + return { + actual: initialValue, + safe: initialValue, +@@ -64,23 +68,15 @@ const EditLinkDialogInner = track(function EditLinkDialogInner2({ + const handleClear = useCallback(() => { + const onlySelectedShape = editor.getOnlySelectedShape(); + if (!onlySelectedShape) return; +- editor.updateShapes([ +- { id: onlySelectedShape.id, type: onlySelectedShape.type, props: { url: "" } } +- ]); ++ editor.updateShapes([getLinkUpdate(onlySelectedShape, "")]); + onClose(); + }, [editor, onClose]); + const handleComplete = useCallback(() => { + const onlySelectedShape = editor.getOnlySelectedShape(); + if (!onlySelectedShape) return; +- if (onlySelectedShape && "url" in onlySelectedShape.props) { +- if (onlySelectedShape.props.url !== urlInputState.safe) { +- editor.updateShapes([ +- { +- id: onlySelectedShape.id, +- type: onlySelectedShape.type, +- props: { url: urlInputState.safe } +- } +- ]); ++ if (onlySelectedShape.type === "text" || "url" in onlySelectedShape.props) { ++ if (getShapeLinkUrl(onlySelectedShape) !== urlInputState.safe) { ++ editor.updateShapes([getLinkUpdate(onlySelectedShape, urlInputState.safe)]); + } + } + onClose(); diff --git a/dist-esm/lib/ui/components/primitives/Button/TldrawUiButtonIcon.mjs b/dist-esm/lib/ui/components/primitives/Button/TldrawUiButtonIcon.mjs index fcb8268d733b28ab2d31a91e60818d46dbcfc3a2..812c7e77c5209abbd389f209ffae3dcb66c302c8 100644 --- a/dist-esm/lib/ui/components/primitives/Button/TldrawUiButtonIcon.mjs @@ -138,3 +213,16 @@ index c05d70e3ddd80a9cfdbd0527b93cd6fcd01fed88..2de1239ff423486c73a85df4057948f6 } ) }); } +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 d683776d67..eebdf276ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,7 +86,7 @@ patchedDependencies: hash: b5bbaa41bf2edae401d2bcd96dfa3c24086b564f297ef2df16a3b3156cd51ecc path: patches/@tldraw__state@2.4.6.patch tldraw@2.4.6: - hash: 56e196052862c9a58a11b43e5e121384cd1d6548416afa0f16e9fbfbf0e4080d + hash: 1dc6e0d5a14876d58a705c3446a937e7811078b17a0918da85f9faa4b096d68a path: patches/tldraw@2.4.6.patch importers: @@ -364,7 +364,7 @@ importers: version: 0.90.0(323501797697e57d5f8d07d52746f463) tldraw: specifier: 2.4.6 - version: 2.4.6(patch_hash=56e196052862c9a58a11b43e5e121384cd1d6548416afa0f16e9fbfbf0e4080d)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 2.4.6(patch_hash=1dc6e0d5a14876d58a705c3446a937e7811078b17a0918da85f9faa4b096d68a)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) use-sync-external-store: specifier: 1.5.0 version: 1.5.0(react@18.2.0) @@ -16293,7 +16293,7 @@ snapshots: nanoid: 4.0.2 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - tldraw: 2.4.6(patch_hash=56e196052862c9a58a11b43e5e121384cd1d6548416afa0f16e9fbfbf0e4080d)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + tldraw: 2.4.6(patch_hash=1dc6e0d5a14876d58a705c3446a937e7811078b17a0918da85f9faa4b096d68a)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) ws: 8.18.3 transitivePeerDependencies: - '@types/react' @@ -23744,7 +23744,7 @@ snapshots: chalk: 5.6.0 clipboardy: 4.0.0 - tldraw@2.4.6(patch_hash=56e196052862c9a58a11b43e5e121384cd1d6548416afa0f16e9fbfbf0e4080d)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + tldraw@2.4.6(patch_hash=1dc6e0d5a14876d58a705c3446a937e7811078b17a0918da85f9faa4b096d68a)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)