Skip to content

Commit e86c905

Browse files
mimecuvaloclaude
andauthored
feat(tldraw): show a loading placeholder for pasted bookmarks (tldraw#8663)
In order to make pasting URLs feel responsive even when bookmark metadata is slow to fetch, this PR creates the bookmark shape immediately as a placeholder and hydrates it with metadata in the background. The placeholder shows a loading spinner overlay (similar to image loading) so the loading state is visible at the paste location, and once metadata resolves the asset is patched in. If the fetch fails, the bookmark gracefully falls back to a URL-only card. <img width="2272" height="1396" alt="Kapture 2026-06-18 at 12 28 53" src="https://github.com/user-attachments/assets/f0b9516c-59b9-4cec-a9a8-755c0be0693d" /> Closes tldraw#8653 ### Change type - [x] `feature` ### Test plan 1. Run `yarn dev`, open the examples app, and paste a URL onto the canvas — a bookmark placeholder should appear immediately at the paste location. 2. Throttle the network in DevTools and paste again — the placeholder with its loading spinner should be visible while metadata loads, then resolve into the full bookmark preview. 3. Paste a recognized embed URL (e.g. a YouTube link) — it should still create an embed shape, not a bookmark placeholder. 4. Paste a URL that fails to unfurl — the bookmark should remain visible as a URL-only card rather than disappearing. 5. Paste a URL, then immediately undo — the placeholder should disappear cleanly without leaving an extra "metadata loaded" undo step. - [x] Unit tests - [ ] End to end tests ### Release notes - Pasted bookmarks now show an immediate loading placeholder at the paste location, with a loading spinner, instead of waiting for metadata before appearing. ### API changes - BookmarkShapeUtil now uses `getGeometry` to explicitly define its geometry. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8d86bc3 commit e86c905

4 files changed

Lines changed: 379 additions & 46 deletions

File tree

packages/tldraw/api-report.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,8 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
534534
// (undocumented)
535535
getDefaultProps(): TLBookmarkShape['props'];
536536
// (undocumented)
537+
getGeometry(shape: TLBookmarkShape): Rectangle2d;
538+
// (undocumented)
537539
getIndicatorPath(shape: TLBookmarkShape): Path2D;
538540
// (undocumented)
539541
getInterpolatedProps(startShape: TLBookmarkShape, endShape: TLBookmarkShape, t: number): TLBookmarkShapeProps;

packages/tldraw/src/lib/shapes/bookmark/BookmarkShapeUtil.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import {
33
BaseBoxShapeUtil,
44
HTMLContainer,
5+
Rectangle2d,
56
T,
67
TLAssetId,
78
TLBookmarkAsset,
@@ -24,7 +25,9 @@ import { getRotatedBoxShadow } from '../shared/rotated-box-shadow'
2425
import {
2526
BOOKMARK_HEIGHT,
2627
BOOKMARK_WIDTH,
28+
getBookmarkShapeHeight,
2729
getHumanReadableAddress,
30+
getResolvedBookmarkAssetId,
2831
setBookmarkHeight,
2932
updateBookmarkAssetOnUrlChange,
3033
} from './bookmarks'
@@ -87,16 +90,25 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
8790
}
8891
}
8992

93+
override getGeometry(shape: TLBookmarkShape) {
94+
return new Rectangle2d({
95+
width: shape.props.w,
96+
height: getBookmarkShapeHeight(this.editor, shape),
97+
isFilled: true,
98+
})
99+
}
100+
90101
override component(shape: TLBookmarkShape) {
91-
const { assetId, url, h } = shape.props
102+
const { assetId, url } = shape.props
103+
const h = getBookmarkShapeHeight(this.editor, shape)
92104
const rotation = this.editor.getShapePageTransform(shape)!.rotation()
93105

94106
return <BookmarkShapeComponent assetId={assetId} url={url} h={h} rotation={rotation} />
95107
}
96108

97109
override getIndicatorPath(shape: TLBookmarkShape): Path2D {
98110
const path = new Path2D()
99-
path.rect(0, 0, shape.props.w, shape.props.h)
111+
path.rect(0, 0, shape.props.w, getBookmarkShapeHeight(this.editor, shape))
100112
return path
101113
}
102114

@@ -147,7 +159,8 @@ export function BookmarkShapeComponent({
147159
}) {
148160
const editor = useEditor()
149161

150-
const asset = assetId ? (editor.getAsset(assetId) as TLBookmarkAsset) : null
162+
const resolvedAssetId = getResolvedBookmarkAssetId(editor, assetId, url)
163+
const asset = resolvedAssetId ? (editor.getAsset(resolvedAssetId) as TLBookmarkAsset) : null
151164

152165
const isSafariExport = !!useSvgExportContext() && tlenv.isSafari
153166

packages/tldraw/src/lib/shapes/bookmark/bookmarks.ts

Lines changed: 105 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
TLAssetId,
66
TLBookmarkAsset,
77
TLBookmarkShape,
8+
TLShapeId,
89
TLShapePartial,
910
createShapeId,
1011
debounce,
@@ -84,6 +85,44 @@ export function updateBookmarkAssetOnUrlChange(editor: Editor, shape: TLBookmark
8485
}
8586
}
8687

88+
/**
89+
* Resolve the asset id to render for a bookmark. Bookmark assets are keyed
90+
* deterministically by URL, so if the shape has no `assetId` but an asset for
91+
* its URL already exists in the store, use that.
92+
*
93+
* This keeps a bookmark from staying stuck on its placeholder after the shape
94+
* is recreated outside of the hydration flow — e.g. on redo, where the
95+
* placeholder is restored with a null `assetId` but its hydrated asset (created
96+
* with `history: 'ignore'`) still lives in the store.
97+
*/
98+
export function getResolvedBookmarkAssetId(
99+
editor: Editor,
100+
assetId: TLAssetId | null,
101+
url: string
102+
): TLAssetId | null {
103+
if (assetId) return assetId
104+
if (!url) return null
105+
106+
const derivedId: TLAssetId = AssetRecordType.createId(getHashForString(url))
107+
return editor.getAsset(derivedId) ? derivedId : null
108+
}
109+
110+
/**
111+
* The effective height to render and measure a bookmark at.
112+
*
113+
* Normally this is the stored `props.h`. But when the shape's `assetId` resolves
114+
* to a different asset than it stores — e.g. a placeholder restored on redo with
115+
* a null `assetId` whose asset already exists in the store — `props.h` is stale
116+
* (it still holds the placeholder height). In that case recompute the height
117+
* from the resolved asset so rendering, the indicator, and the geometry/selection
118+
* bounds all stay in sync.
119+
*/
120+
export function getBookmarkShapeHeight(editor: Editor, shape: TLBookmarkShape): number {
121+
const resolvedAssetId = getResolvedBookmarkAssetId(editor, shape.props.assetId, shape.props.url)
122+
if (resolvedAssetId === shape.props.assetId) return shape.props.h
123+
return getBookmarkHeight(editor, resolvedAssetId)
124+
}
125+
87126
async function _createBookmarkAssetOnUrlChange(editor: Editor, shape: TLBookmarkShape) {
88127
if (editor.isDisposed) return
89128

@@ -128,12 +167,17 @@ function createBookmarkAssetOnUrlChange(editor: Editor, shape: TLBookmarkShape)
128167
}
129168

130169
/**
131-
* Creates a bookmark shape from a URL with unfurled metadata.
170+
* Creates a bookmark shape from a URL.
171+
*
172+
* The shape is created immediately as a placeholder so the user gets visible
173+
* feedback at the paste location, and the bookmark metadata (title, description,
174+
* image, favicon) is fetched in the background. Once metadata resolves, the
175+
* shape is patched with the resulting asset. If the fetch fails, the shape is
176+
* left as a URL-only bookmark.
132177
*
133178
* @returns A Result containing the created bookmark shape or an error
134179
* @public
135180
*/
136-
137181
export async function createBookmarkFromUrl(
138182
editor: Editor,
139183
{
@@ -145,10 +189,12 @@ export async function createBookmarkFromUrl(
145189
}
146190
): Promise<Result<TLBookmarkShape, string>> {
147191
try {
148-
// Create the bookmark asset with unfurled metadata
149-
const asset = await editor.getAssetForExternalContent({ type: 'url', url })
192+
// If we already have a bookmark asset for this URL (e.g. another bookmark
193+
// shape was created from the same URL earlier), use it immediately rather
194+
// than re-fetching.
195+
const expectedAssetId: TLAssetId = AssetRecordType.createId(getHashForString(url))
196+
const existingAsset = editor.getAsset(expectedAssetId) as TLBookmarkAsset | null
150197

151-
// Create the bookmark shape
152198
const shapeId = createShapeId()
153199
const shapePartial: TLShapePartial<TLBookmarkShape> = {
154200
id: shapeId,
@@ -159,26 +205,70 @@ export async function createBookmarkFromUrl(
159205
opacity: 1,
160206
props: {
161207
url,
162-
assetId: asset?.id || null,
208+
assetId: existingAsset?.id ?? null,
163209
w: BOOKMARK_WIDTH,
164-
h: getBookmarkHeight(editor, asset?.id),
210+
h: getBookmarkHeight(editor, existingAsset?.id),
165211
},
166212
}
167213

168214
editor.run(() => {
169-
// Create the asset if we have one
170-
if (asset) {
171-
editor.createAssets([asset])
172-
}
173-
174-
// Create the shape
175215
editor.createShapes([shapePartial])
176216
})
177217

178-
// Get the created shape
179-
const createdShape = editor.getShape(shapeId) as TLBookmarkShape
218+
const createdShape = editor.getShape<TLBookmarkShape>(shapeId)
219+
if (!createdShape) {
220+
return Result.err('Failed to create bookmark shape')
221+
}
222+
223+
// If we already had the asset, we're done — no metadata fetch needed.
224+
if (existingAsset) {
225+
return Result.ok(createdShape)
226+
}
227+
228+
// Otherwise kick off the metadata fetch in the background. The shape is
229+
// already visible as a placeholder; once the asset resolves we'll patch
230+
// the shape with its assetId.
231+
void hydrateBookmarkShape(editor, shapeId, url)
232+
180233
return Result.ok(createdShape)
181234
} catch (error) {
182235
return Result.err(error instanceof Error ? error.message : 'Failed to create bookmark')
183236
}
184237
}
238+
239+
async function hydrateBookmarkShape(editor: Editor, shapeId: TLShapeId, url: string) {
240+
let asset
241+
try {
242+
asset = await editor.getAssetForExternalContent({ type: 'url', url })
243+
} catch (error) {
244+
console.error(error)
245+
return
246+
}
247+
248+
if (editor.isDisposed) return
249+
if (!asset) return
250+
251+
// The shape may have been deleted (e.g. via undo), had its URL changed, or
252+
// had its asset replaced before the fetch resolved. In any of those cases we
253+
// don't want to attach metadata for the old URL.
254+
const shape = editor.getShape<TLBookmarkShape>(shapeId)
255+
if (!shape) return
256+
if (shape.props.url !== url) return
257+
if (shape.props.assetId) return
258+
259+
editor.run(
260+
() => {
261+
if (!editor.getAsset(asset.id)) {
262+
editor.createAssets([asset])
263+
}
264+
editor.updateShapes([
265+
{
266+
id: shapeId,
267+
type: 'bookmark',
268+
props: { assetId: asset.id },
269+
},
270+
])
271+
},
272+
{ history: 'ignore' }
273+
)
274+
}

0 commit comments

Comments
 (0)