Repository navigation
ENG-2228 Add link support to tldraw text shapes in Obsidian #1442
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
trangdoan982
wants to merge
19
commits into
main
Choose a base branch
from
eng-2228-add-link-support-to-tldraw-text-shapes-in-obsidian
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
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 cafe7bb
Restrict text links to obsidian://open, and fix icon placement
trangdoan982 f45e008
Hoist link icon to icons.ts, and replay app migrations for schema-les…
trangdoan982 eaaf620
Use one schema fallback on both canvas load paths
trangdoan982 d058f3d
Merge remote-tracking branch 'origin/main' into eng-2228-add-link-sup…
trangdoan982 18080b4
Store text-shape links in meta and render them from an overlay
trangdoan982 53b7726
Trim two comments to the two-line limit
trangdoan982 931cfbd
Move the overlay's static styles out of JSX
trangdoan982 5253da0
Normalise text-link URLs and stop page links reaching the OS handler
trangdoan982 52dd9c9
Prototype: patch tldraw so geo shapes get page links too
trangdoan982 5cbaa2b
Prototype C: one dialog hint for every shape type
trangdoan982 e197200
Merge remote-tracking branch 'origin/main' into eng-2228-add-link-sup…
trangdoan982 97dabab
Drop the vendored link icon now that tldraw's button is used
trangdoan982 23ce157
Fix review findings on the link patch and listener
trangdoan982 1da399e
Match stock edit-link guard and clear the text resize handle
trangdoan982 605d9ee
Keep page links off bookmark shapes
trangdoan982 18048be
Reject page links for bookmarks before the https fallback
trangdoan982 e6380c5
Keep a page link pasted after the dialog's prefilled https://
trangdoan982 c0d00c2
Keep every malformed page link away from the https fallback
trangdoan982 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 } }; | ||
|
|
||
| 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> | ||
| </> | ||
| ); | ||
| }, | ||
| ); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
59 changes: 59 additions & 0 deletions
59
apps/obsidian/src/components/canvas/overlays/TextLinkOverlay.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 []; | ||
|
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> | ||
| ); | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.