Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0a127ef
ENG-2228 Add link support to tldraw text shapes in Obsidian
trangdoan982 Sep 14, 2026
cafe7bb
Restrict text links to obsidian://open, and fix icon placement
trangdoan982 Sep 14, 2026
f45e008
Hoist link icon to icons.ts, and replay app migrations for schema-les…
trangdoan982 Sep 17, 2026
eaaf620
Use one schema fallback on both canvas load paths
trangdoan982 Sep 17, 2026
d058f3d
Merge remote-tracking branch 'origin/main' into eng-2228-add-link-sup…
trangdoan982 Sep 30, 2026
18080b4
Store text-shape links in meta and render them from an overlay
trangdoan982 Sep 30, 2026
53b7726
Trim two comments to the two-line limit
trangdoan982 Oct 1, 2026
931cfbd
Move the overlay's static styles out of JSX
trangdoan982 Oct 1, 2026
5253da0
Normalise text-link URLs and stop page links reaching the OS handler
trangdoan982 Oct 1, 2026
52dd9c9
Prototype: patch tldraw so geo shapes get page links too
trangdoan982 Oct 1, 2026
5cbaa2b
Prototype C: one dialog hint for every shape type
trangdoan982 Oct 1, 2026
e197200
Merge remote-tracking branch 'origin/main' into eng-2228-add-link-sup…
trangdoan982 Oct 8, 2026
97dabab
Drop the vendored link icon now that tldraw's button is used
trangdoan982 Oct 8, 2026
23ce157
Fix review findings on the link patch and listener
trangdoan982 Oct 9, 2026
1da399e
Match stock edit-link guard and clear the text resize handle
trangdoan982 Oct 9, 2026
605d9ee
Keep page links off bookmark shapes
trangdoan982 Oct 9, 2026
18048be
Reject page links for bookmarks before the https fallback
trangdoan982 Oct 9, 2026
e6380c5
Keep a page link pasted after the dialog's prefilled https://
trangdoan982 Oct 9, 2026
c0d00c2
Keep every malformed page link away from the https fallback
trangdoan982 Oct 9, 2026
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
228 changes: 228 additions & 0 deletions apps/obsidian/src/components/canvas/TextLinkDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<string, { url: string }>;

// 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 } };
Comment thread
trangdoan982 marked this conversation as resolved.

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 <a href>, 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 (
<TextLinkDialogInner
key={selectedShape.id}
onClose={onClose}
selectedShape={selectedShape as ShapeWithUrl}
/>
);
});

const TextLinkDialogInner = track(
({
onClose,
selectedShape,
}: TextLinkDialogProps & { selectedShape: ShapeWithUrl }) => {
const editor = useEditor();
const rInput = useRef<HTMLInputElement | null>(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 (
<>
<TldrawUiDialogHeader>
<TldrawUiDialogTitle>Edit link</TldrawUiDialogTitle>
<TldrawUiDialogCloseButton />
</TldrawUiDialogHeader>
<TldrawUiDialogBody>
<div className="tlui-edit-link-dialog">
<TldrawUiInput
ref={rInput}
className="tlui-edit-link-dialog__input"
label="edit-link-dialog.url"
autoFocus
autoSelect
placeholder="https://example.com"
value={urlInputState.actual}
onValueChange={handleChange}
onComplete={handleComplete}
onCancel={handleCancel}
/>
<div>
{!urlInputState.valid
? "Invalid URL"
: acceptsPageLinks(shapeType)
? "Enter a URL, or an obsidian:// link to a page."
: "Enter a URL."}
</div>
</div>
</TldrawUiDialogBody>
<TldrawUiDialogFooter className="tlui-dialog__footer__actions">
<TldrawUiButton
type="normal"
onClick={handleCancel}
onTouchEnd={handleCancel}
>
<TldrawUiButtonLabel>Cancel</TldrawUiButtonLabel>
</TldrawUiButton>
{isRemoving ? (
<TldrawUiButton
type="danger"
onClick={handleClear}
onTouchEnd={handleClear}
>
<TldrawUiButtonLabel>Clear</TldrawUiButtonLabel>
</TldrawUiButton>
) : (
<TldrawUiButton
type="primary"
disabled={!urlInputState.valid}
onClick={handleComplete}
onTouchEnd={handleComplete}
>
<TldrawUiButtonLabel>Save</TldrawUiButtonLabel>
</TldrawUiButton>
)}
</TldrawUiDialogFooter>
</>
);
},
);
27 changes: 27 additions & 0 deletions apps/obsidian/src/components/canvas/TldrawViewComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -541,6 +567,7 @@ export const TldrawPreviewComponent = ({
InFrontOfTheCanvas: () => (
<>
<DragHandleOverlay plugin={plugin} file={file} />
<TextLinkOverlay />
</>
),
}}
Expand Down
59 changes: 59 additions & 0 deletions apps/obsidian/src/components/canvas/overlays/TextLinkOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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<TextLink[]>(
"textShapeLinks",
() => {
if (editor.getZoomLevel() < HIDE_BELOW_ZOOM) return [];
const viewport = editor.getViewportPageBounds();
return editor.getCurrentPageShapes().flatMap<TextLink>((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 [];
Comment thread
trangdoan982 marked this conversation as resolved.
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 (
<div className="dg-text-link-overlay">
{links.map(({ id, url, left, top }) => (
<div
key={id}
className="dg-text-link-anchor"
style={{ left: `${left}px`, top: `${top}px` }}
>
<HyperlinkButton url={url} />
</div>
))}
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -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;
};
Expand All @@ -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);
};
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down
Loading
Loading