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
64 changes: 26 additions & 38 deletions apps/obsidian/src/components/canvas/TextLinkDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
T,
TLBaseShape,
TLShape,
TldrawUiButton,
Expand Down Expand Up @@ -39,16 +38,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 (isAllowedTextLinkUrl(url)) return { isValid: true, hasProtocol: true };
if (isAllowedTextLinkUrl(`https://${url}`))
return { isValid: true, hasProtocol: false };
return { isValid: false, hasProtocol: false };
};

Expand Down Expand Up @@ -82,7 +76,6 @@ const TextLinkDialogInner = track(
}: TextLinkDialogProps & { selectedShape: ShapeWithUrl }) => {
const editor = useEditor();
const rInput = useRef<HTMLInputElement | null>(null);
const shapeType = selectedShape.type;

useEffect(() => {
editor.timers.requestAnimationFrame(() => rInput.current?.focus());
Expand All @@ -92,7 +85,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
Expand All @@ -101,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 = validateUrlForShape(fixedRawValue, shapeType);
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();
Expand Down Expand Up @@ -184,9 +174,7 @@ const TextLinkDialogInner = track(
<div>
{!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."}
</div>
</div>
</TldrawUiDialogBody>
Expand Down
8 changes: 7 additions & 1 deletion apps/obsidian/src/components/canvas/TldrawViewComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -567,7 +573,7 @@ export const TldrawPreviewComponent = ({
<>
<RelationsOverlay plugin={plugin} file={file} />
<DragHandleOverlay plugin={plugin} file={file} />
<TextLinkOverlay plugin={plugin} />
<TextLinkOverlay />
</>
),
}}
Expand Down
113 changes: 13 additions & 100 deletions apps/obsidian/src/components/canvas/overlays/TextLinkOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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<TextLink[]>(
Expand Down Expand Up @@ -66,81 +43,17 @@ export const TextLinkOverlay = ({ plugin }: TextLinkOverlayProps) => {
[editor],
);

const openLink = useCallback(
(event: React.MouseEvent<HTMLAnchorElement>, 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<HTMLAnchorElement>) => {
if (!editor.inputs.shiftKey) stopEventPropagation(event);
},
[editor],
);

return (
<div
className="dg-text-link-overlay"
style={
{ "--dg-external-link-icon": ICON_MASK_URL } as React.CSSProperties
}
>
{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 (
<a
key={id}
className="tl-hyperlink-button"
style={{ left: `${left}px`, top: `${top}px` }}
href={isPageLink ? undefined : url}
target={isPageLink ? undefined : "_blank"}
rel={isPageLink ? undefined : "noopener noreferrer"}
role={isPageLink ? "link" : undefined}
tabIndex={isPageLink ? 0 : undefined}
onClick={(event) => openLink(event, url)}
onKeyDown={(event) => {
if (isPageLink && (event.key === "Enter" || event.key === " ")) {
openLink(
event as unknown as React.MouseEvent<HTMLAnchorElement>,
url,
);
}
}}
onPointerDown={stopUnlessShift}
onPointerUp={stopUnlessShift}
title={url}
draggable={false}
>
<div className="tl-hyperlink__icon" />
</a>
);
})}
<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>
);
};
64 changes: 64 additions & 0 deletions apps/obsidian/src/components/canvas/utils/linkNavigation.ts
Original file line number Diff line number Diff line change
@@ -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);
};
29 changes: 6 additions & 23 deletions apps/obsidian/src/components/canvas/utils/textShapeLink.ts
Original file line number Diff line number Diff line change
@@ -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 <a href>, 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 {
Expand All @@ -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 "";
Expand Down
12 changes: 4 additions & 8 deletions apps/obsidian/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading
Loading