Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion apps/roam/src/components/canvas/Tldraw.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -172,6 +173,14 @@ const setActiveCanvas = ({
}
};

// InFrontOfTheCanvas takes one component; module scope keeps its identity stable across renders.
const CanvasOverlays = (): JSX.Element => (
<>
<DragHandleOverlay />
<TextLinkOverlay />
</>
);

export const DEFAULT_WIDTH = 160;
export const DEFAULT_HEIGHT = 64;
export const MAX_WIDTH = "400px";
Expand Down Expand Up @@ -1017,7 +1026,7 @@ const TldrawCanvasShared = ({
const editorComponents: TLEditorComponents = {
...defaultEditorComponents,
OnTheCanvas: ToastListener,
InFrontOfTheCanvas: DragHandleOverlay,
InFrontOfTheCanvas: CanvasOverlays,
};
const customUiComponents: TLUiComponents = createUiComponents({
allNodes,
Expand Down
81 changes: 81 additions & 0 deletions apps/roam/src/components/canvas/overlays/TextLinkOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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<TextLink[]>(
"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 (
<div className="pointer-events-none absolute inset-0">
{links.map(({ id, url, left, top }) => (
<div
key={id}
className="absolute"
style={{
Comment thread
trangdoan982 marked this conversation as resolved.
left: `${left}px`,
top: `${top}px`,
width: `${BUTTON_SIZE}px`,
height: `${BUTTON_SIZE}px`,
}}
>
<HyperlinkButton url={url} zoomLevel={zoomLevel} />
</div>
))}
</div>
);
};
101 changes: 101 additions & 0 deletions apps/roam/src/utils/__tests__/textLinkOverlay.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
} = {}): 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<string, TLShape> })
.value[shape.id];
expect(loaded.meta.url).toBe("https://example.com");
});
});
104 changes: 96 additions & 8 deletions patches/tldraw@2.4.6.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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<DefaultToolbarProps>;

Expand All @@ -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<TranslationKey extends string = string, Ic
@@ -2243,6 +2259,7 @@ export declare interface TLUiToolItem<TranslationKey extends string = string, Ic
meta?: {
[key: string]: any;
};
Expand All @@ -33,43 +37,114 @@ index ec996d145b83f637e0d43ad0db1f0e3ab87efb70..b033a8df604153626c203f3c808267a8

/** @public */
diff --git a/dist-esm/index.mjs b/dist-esm/index.mjs
index 4dfb8bc221bed3552682312efc6779af5a653d57..4adab6b34d9468e6296669da363dfc5be0c03ee0 100644
index 4dfb8bc221bed3552682312efc6779af5a653d57..cdeb5843f3ec76f38bcb00001ffccc3d0353a641 100644
--- a/dist-esm/index.mjs
+++ b/dist-esm/index.mjs
@@ -146,6 +146,9 @@ import {
@@ -146,6 +146,10 @@ import {
import {
DefaultToolbar
} from "./lib/ui/components/Toolbar/DefaultToolbar.mjs";
+import { OverflowingToolbar } from "./lib/ui/components/Toolbar/OverflowingToolbar.mjs";
+import { ToggleToolLockedButton } from "./lib/ui/components/Toolbar/ToggleToolLockedButton.mjs";
+import { MobileStylePanel } from "./lib/ui/components/MobileStylePanel.mjs";
+import { HyperlinkButton } from "./lib/shapes/shared/HyperlinkButton.mjs";
import {
ArrowDownToolbarItem,
ArrowLeftToolbarItem,
@@ -458,6 +461,7 @@ export {
@@ -448,6 +452,7 @@ export {
HighlightShapeTool,
HighlightShapeUtil,
HighlightToolbarItem,
+ HyperlinkButton,
ImageShapeUtil,
KeyboardShortcutsMenuItem,
LABEL_FONT_SIZES,
@@ -458,6 +463,7 @@ export {
LineShapeUtil,
LineToolbarItem,
MiscMenuGroup,
+ MobileStylePanel,
MoveToPageMenu,
NoteShapeTool,
NoteShapeUtil,
@@ -465,6 +469,7 @@ export {
@@ -465,6 +471,7 @@ export {
OfflineIndicator,
OpacitySlider,
OvalToolbarItem,
+ OverflowingToolbar,
PORTRAIT_BREAKPOINT,
PageItemInput,
PageItemSubmenu,
@@ -550,6 +555,7 @@ export {
@@ -550,6 +557,7 @@ export {
ToggleReduceMotionItem,
ToggleSnapModeItem,
ToggleToolLockItem,
+ ToggleToolLockedButton,
ToggleTransparentBgMenuItem,
ToggleWrapModeItem,
ToolbarItem,
diff --git a/dist-esm/lib/ui/components/EditLinkDialog.mjs b/dist-esm/lib/ui/components/EditLinkDialog.mjs
index 9291d30acbc029f1da9fbab4319017ba6c237139..f7f3e2d1bad3f7a5e594ede253c04a0156c76cb2 100644
--- a/dist-esm/lib/ui/components/EditLinkDialog.mjs
+++ b/dist-esm/lib/ui/components/EditLinkDialog.mjs
@@ -21,10 +21,13 @@ function validateUrl(url) {
}
return { isValid: false, hasProtocol: false };
}
+// Discourse Graphs: text shapes keep their link in meta so the stock text schema is unchanged.
+const getShapeLinkUrl = (shape) => 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
Expand Down Expand Up @@ -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]
);
Loading
Loading