tldraw 4.1.0-canary.e23ee15a46bc → 4.1.0-canary.e259b517a450

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist-cjs/index.d.ts +16 -2
  2. package/dist-cjs/index.js +3 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/defaultEmbedDefinitions.js +2 -1
  5. package/dist-cjs/lib/defaultEmbedDefinitions.js.map +2 -2
  6. package/dist-cjs/lib/defaultExternalContentHandlers.js +8 -31
  7. package/dist-cjs/lib/defaultExternalContentHandlers.js.map +2 -2
  8. package/dist-cjs/lib/shapes/bookmark/BookmarkShapeUtil.js +8 -82
  9. package/dist-cjs/lib/shapes/bookmark/BookmarkShapeUtil.js.map +2 -2
  10. package/dist-cjs/lib/shapes/bookmark/bookmarks.js +137 -0
  11. package/dist-cjs/lib/shapes/bookmark/bookmarks.js.map +7 -0
  12. package/dist-cjs/lib/tools/SelectTool/childStates/DraggingHandle.js +9 -1
  13. package/dist-cjs/lib/tools/SelectTool/childStates/DraggingHandle.js.map +2 -2
  14. package/dist-cjs/lib/ui/components/SharePanel/PeopleMenu.js +6 -2
  15. package/dist-cjs/lib/ui/components/SharePanel/PeopleMenu.js.map +2 -2
  16. package/dist-cjs/lib/ui/components/StylePanel/StylePanelButtonPicker.js +1 -1
  17. package/dist-cjs/lib/ui/components/StylePanel/StylePanelButtonPicker.js.map +1 -1
  18. package/dist-cjs/lib/ui/context/actions.js +23 -29
  19. package/dist-cjs/lib/ui/context/actions.js.map +2 -2
  20. package/dist-cjs/lib/ui/version.js +3 -3
  21. package/dist-cjs/lib/ui/version.js.map +1 -1
  22. package/dist-esm/index.d.mts +16 -2
  23. package/dist-esm/index.mjs +3 -1
  24. package/dist-esm/index.mjs.map +2 -2
  25. package/dist-esm/lib/defaultEmbedDefinitions.mjs +2 -1
  26. package/dist-esm/lib/defaultEmbedDefinitions.mjs.map +2 -2
  27. package/dist-esm/lib/defaultExternalContentHandlers.mjs +8 -31
  28. package/dist-esm/lib/defaultExternalContentHandlers.mjs.map +2 -2
  29. package/dist-esm/lib/shapes/bookmark/BookmarkShapeUtil.mjs +10 -81
  30. package/dist-esm/lib/shapes/bookmark/BookmarkShapeUtil.mjs.map +2 -2
  31. package/dist-esm/lib/shapes/bookmark/bookmarks.mjs +123 -0
  32. package/dist-esm/lib/shapes/bookmark/bookmarks.mjs.map +7 -0
  33. package/dist-esm/lib/tools/SelectTool/childStates/DraggingHandle.mjs +11 -2
  34. package/dist-esm/lib/tools/SelectTool/childStates/DraggingHandle.mjs.map +2 -2
  35. package/dist-esm/lib/ui/components/SharePanel/PeopleMenu.mjs +6 -2
  36. package/dist-esm/lib/ui/components/SharePanel/PeopleMenu.mjs.map +2 -2
  37. package/dist-esm/lib/ui/components/StylePanel/StylePanelButtonPicker.mjs +1 -1
  38. package/dist-esm/lib/ui/components/StylePanel/StylePanelButtonPicker.mjs.map +1 -1
  39. package/dist-esm/lib/ui/context/actions.mjs +23 -29
  40. package/dist-esm/lib/ui/context/actions.mjs.map +2 -2
  41. package/dist-esm/lib/ui/version.mjs +3 -3
  42. package/dist-esm/lib/ui/version.mjs.map +1 -1
  43. package/package.json +3 -3
  44. package/src/index.ts +1 -0
  45. package/src/lib/defaultEmbedDefinitions.ts +2 -1
  46. package/src/lib/defaultExternalContentHandlers.ts +10 -35
  47. package/src/lib/shapes/bookmark/BookmarkShapeUtil.tsx +9 -112
  48. package/src/lib/shapes/bookmark/bookmarks.ts +170 -0
  49. package/src/lib/tools/SelectTool/childStates/DraggingHandle.tsx +13 -1
  50. package/src/lib/ui/components/SharePanel/PeopleMenu.tsx +6 -2
  51. package/src/lib/ui/components/StylePanel/StylePanelButtonPicker.tsx +1 -1
  52. package/src/lib/ui/context/actions.tsx +27 -31
  53. package/src/lib/ui/version.ts +3 -3
  54. package/src/lib/utils/embeds/embeds.test.ts +16 -0
  55. package/src/test/bookmark-shapes.test.ts +123 -1
  56. package/src/test/customSnapping.test.tsx +55 -11
@@ -1,17 +1,12 @@
1
1
  import {
2
- AssetRecordType,
3
2
  BaseBoxShapeUtil,
4
- Editor,
5
3
  HTMLContainer,
6
4
  T,
7
- TLAssetId,
8
5
  TLBookmarkAsset,
9
6
  TLBookmarkShape,
10
7
  TLBookmarkShapeProps,
11
8
  bookmarkShapeMigrations,
12
9
  bookmarkShapeProps,
13
- debounce,
14
- getHashForString,
15
10
  lerp,
16
11
  tlenv,
17
12
  toDomPrecision,
@@ -24,11 +19,13 @@ import { convertCommonTitleHTMLEntities } from '../../utils/text/text'
24
19
  import { HyperlinkButton } from '../shared/HyperlinkButton'
25
20
  import { LINK_ICON } from '../shared/icons-editor'
26
21
  import { getRotatedBoxShadow } from '../shared/rotated-box-shadow'
27
-
28
- const BOOKMARK_WIDTH = 300
29
- const BOOKMARK_HEIGHT = 320
30
- const BOOKMARK_JUST_URL_HEIGHT = 46
31
- const SHORT_BOOKMARK_HEIGHT = 101
22
+ import {
23
+ BOOKMARK_HEIGHT,
24
+ BOOKMARK_WIDTH,
25
+ getHumanReadableAddress,
26
+ setBookmarkHeight,
27
+ updateBookmarkAssetOnUrlChange,
28
+ } from './bookmarks'
32
29
 
33
30
  /** @public */
34
31
  export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
@@ -86,7 +83,7 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
86
83
  }
87
84
 
88
85
  override onBeforeCreate(next: TLBookmarkShape) {
89
- return getBookmarkSize(this.editor, next)
86
+ return setBookmarkHeight(this.editor, next)
90
87
  }
91
88
 
92
89
  override onBeforeUpdate(prev: TLBookmarkShape, shape: TLBookmarkShape) {
@@ -99,7 +96,7 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
99
96
  }
100
97
 
101
98
  if (prev.props.assetId !== shape.props.assetId) {
102
- return getBookmarkSize(this.editor, shape)
99
+ return setBookmarkHeight(this.editor, shape)
103
100
  }
104
101
  }
105
102
  override getInterpolatedProps(
@@ -218,103 +215,3 @@ function BookmarkShapeComponent({ shape }: { shape: TLBookmarkShape }) {
218
215
  </HTMLContainer>
219
216
  )
220
217
  }
221
-
222
- function getBookmarkSize(editor: Editor, shape: TLBookmarkShape) {
223
- const asset = (
224
- shape.props.assetId ? editor.getAsset(shape.props.assetId) : null
225
- ) as TLBookmarkAsset
226
-
227
- let h = BOOKMARK_HEIGHT
228
-
229
- if (asset) {
230
- if (!asset.props.image) {
231
- if (!asset.props.title) {
232
- h = BOOKMARK_JUST_URL_HEIGHT
233
- } else {
234
- h = SHORT_BOOKMARK_HEIGHT
235
- }
236
- }
237
- }
238
-
239
- return {
240
- ...shape,
241
- props: {
242
- ...shape.props,
243
- h,
244
- },
245
- }
246
- }
247
-
248
- /** @internal */
249
- export const getHumanReadableAddress = (shape: TLBookmarkShape) => {
250
- try {
251
- const url = new URL(shape.props.url)
252
- // we want the hostname without any www
253
- return url.hostname.replace(/^www\./, '')
254
- } catch {
255
- return shape.props.url
256
- }
257
- }
258
-
259
- function updateBookmarkAssetOnUrlChange(editor: Editor, shape: TLBookmarkShape) {
260
- const { url } = shape.props
261
-
262
- // Derive the asset id from the URL
263
- const assetId: TLAssetId = AssetRecordType.createId(getHashForString(url))
264
-
265
- if (editor.getAsset(assetId)) {
266
- // Existing asset for this URL?
267
- if (shape.props.assetId !== assetId) {
268
- editor.updateShapes<TLBookmarkShape>([
269
- {
270
- id: shape.id,
271
- type: shape.type,
272
- props: { assetId },
273
- },
274
- ])
275
- }
276
- } else {
277
- // No asset for this URL?
278
-
279
- // First, clear out the existing asset reference
280
- editor.updateShapes<TLBookmarkShape>([
281
- {
282
- id: shape.id,
283
- type: shape.type,
284
- props: { assetId: null },
285
- },
286
- ])
287
-
288
- // Then try to asyncronously create a new one
289
- createBookmarkAssetOnUrlChange(editor, shape)
290
- }
291
- }
292
-
293
- const createBookmarkAssetOnUrlChange = debounce(async (editor: Editor, shape: TLBookmarkShape) => {
294
- if (editor.isDisposed) return
295
-
296
- const { url } = shape.props
297
-
298
- // Create the asset using the external content manager's createAssetFromUrl method.
299
- // This may be overwritten by the user (for example, we overwrite it on tldraw.com)
300
- const asset = await editor.getAssetForExternalContent({ type: 'url', url })
301
-
302
- if (!asset) {
303
- // No asset? Just leave the bookmark as a null assetId.
304
- return
305
- }
306
-
307
- editor.run(() => {
308
- // Create the new asset
309
- editor.createAssets([asset])
310
-
311
- // And update the shape
312
- editor.updateShapes<TLBookmarkShape>([
313
- {
314
- id: shape.id,
315
- type: shape.type,
316
- props: { assetId: asset.id },
317
- },
318
- ])
319
- })
320
- }, 500)
@@ -0,0 +1,170 @@
1
+ import {
2
+ AssetRecordType,
3
+ Editor,
4
+ Result,
5
+ TLAssetId,
6
+ TLBookmarkAsset,
7
+ TLBookmarkShape,
8
+ TLShapePartial,
9
+ createShapeId,
10
+ debounce,
11
+ getHashForString,
12
+ } from '@tldraw/editor'
13
+
14
+ export const BOOKMARK_WIDTH = 300
15
+ export const BOOKMARK_HEIGHT = 320
16
+ const BOOKMARK_JUST_URL_HEIGHT = 46
17
+ const SHORT_BOOKMARK_HEIGHT = 101
18
+
19
+ export function getBookmarkHeight(editor: Editor, assetId?: TLAssetId | null) {
20
+ const asset = (assetId ? editor.getAsset(assetId) : null) as TLBookmarkAsset | null
21
+
22
+ if (asset) {
23
+ if (!asset.props.image) {
24
+ if (!asset.props.title) {
25
+ return BOOKMARK_JUST_URL_HEIGHT
26
+ } else {
27
+ return SHORT_BOOKMARK_HEIGHT
28
+ }
29
+ }
30
+ }
31
+
32
+ return BOOKMARK_HEIGHT
33
+ }
34
+
35
+ export function setBookmarkHeight(editor: Editor, shape: TLBookmarkShape) {
36
+ return {
37
+ ...shape,
38
+ props: { ...shape.props, h: getBookmarkHeight(editor, shape.props.assetId) },
39
+ }
40
+ }
41
+
42
+ /** @internal */
43
+ export const getHumanReadableAddress = (shape: TLBookmarkShape) => {
44
+ try {
45
+ const url = new URL(shape.props.url)
46
+ // we want the hostname without any www
47
+ return url.hostname.replace(/^www\./, '')
48
+ } catch {
49
+ return shape.props.url
50
+ }
51
+ }
52
+
53
+ export function updateBookmarkAssetOnUrlChange(editor: Editor, shape: TLBookmarkShape) {
54
+ const { url } = shape.props
55
+
56
+ // Derive the asset id from the URL
57
+ const assetId: TLAssetId = AssetRecordType.createId(getHashForString(url))
58
+
59
+ if (editor.getAsset(assetId)) {
60
+ // Existing asset for this URL?
61
+ if (shape.props.assetId !== assetId) {
62
+ editor.updateShapes<TLBookmarkShape>([
63
+ {
64
+ id: shape.id,
65
+ type: shape.type,
66
+ props: { assetId },
67
+ },
68
+ ])
69
+ }
70
+ } else {
71
+ // No asset for this URL?
72
+
73
+ // First, clear out the existing asset reference
74
+ editor.updateShapes<TLBookmarkShape>([
75
+ {
76
+ id: shape.id,
77
+ type: shape.type,
78
+ props: { assetId: null },
79
+ },
80
+ ])
81
+
82
+ // Then try to asyncronously create a new one
83
+ createBookmarkAssetOnUrlChange(editor, shape)
84
+ }
85
+ }
86
+
87
+ const createBookmarkAssetOnUrlChange = debounce(async (editor: Editor, shape: TLBookmarkShape) => {
88
+ if (editor.isDisposed) return
89
+
90
+ const { url } = shape.props
91
+
92
+ // Create the asset using the external content manager's createAssetFromUrl method.
93
+ // This may be overwritten by the user (for example, we overwrite it on tldraw.com)
94
+ const asset = await editor.getAssetForExternalContent({ type: 'url', url })
95
+
96
+ if (!asset) {
97
+ // No asset? Just leave the bookmark as a null assetId.
98
+ return
99
+ }
100
+
101
+ editor.run(() => {
102
+ // Create the new asset
103
+ editor.createAssets([asset])
104
+
105
+ // And update the shape
106
+ editor.updateShapes<TLBookmarkShape>([
107
+ {
108
+ id: shape.id,
109
+ type: shape.type,
110
+ props: { assetId: asset.id },
111
+ },
112
+ ])
113
+ })
114
+ }, 500)
115
+
116
+ /**
117
+ * Creates a bookmark shape from a URL with unfurled metadata.
118
+ *
119
+ * @returns A Result containing the created bookmark shape or an error
120
+ * @public
121
+ */
122
+
123
+ export async function createBookmarkFromUrl(
124
+ editor: Editor,
125
+ {
126
+ url,
127
+ center = editor.getViewportPageBounds().center,
128
+ }: {
129
+ url: string
130
+ center?: { x: number; y: number }
131
+ }
132
+ ): Promise<Result<TLBookmarkShape, string>> {
133
+ try {
134
+ // Create the bookmark asset with unfurled metadata
135
+ const asset = await editor.getAssetForExternalContent({ type: 'url', url })
136
+
137
+ // Create the bookmark shape
138
+ const shapeId = createShapeId()
139
+ const shapePartial: TLShapePartial<TLBookmarkShape> = {
140
+ id: shapeId,
141
+ type: 'bookmark',
142
+ x: center.x - BOOKMARK_WIDTH / 2,
143
+ y: center.y - BOOKMARK_HEIGHT / 2,
144
+ rotation: 0,
145
+ opacity: 1,
146
+ props: {
147
+ url,
148
+ assetId: asset?.id || null,
149
+ w: BOOKMARK_WIDTH,
150
+ h: getBookmarkHeight(editor, asset?.id),
151
+ },
152
+ }
153
+
154
+ editor.run(() => {
155
+ // Create the asset if we have one
156
+ if (asset) {
157
+ editor.createAssets([asset])
158
+ }
159
+
160
+ // Create the shape
161
+ editor.createShapes([shapePartial])
162
+ })
163
+
164
+ // Get the created shape
165
+ const createdShape = editor.getShape(shapeId) as TLBookmarkShape
166
+ return Result.ok(createdShape)
167
+ } catch (error) {
168
+ return Result.err(error instanceof Error ? error.message : 'Failed to create bookmark')
169
+ }
170
+ }
@@ -12,6 +12,7 @@ import {
12
12
  snapAngle,
13
13
  sortByIndex,
14
14
  structuredClone,
15
+ warnOnce,
15
16
  } from '@tldraw/editor'
16
17
  import { ArrowShapeUtil } from '../../../shapes/arrow/ArrowShapeUtil'
17
18
  import { clearArrowTargetState } from '../../../shapes/arrow/arrowTargetState'
@@ -294,7 +295,18 @@ export class DraggingHandle extends StateNode {
294
295
 
295
296
  let nextHandle = { ...initialHandle, x: point.x, y: point.y }
296
297
 
297
- if (initialHandle.canSnap && (isSnapMode ? !ctrlKey : ctrlKey)) {
298
+ let canSnap = false
299
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
300
+ if (initialHandle.canSnap && initialHandle.snapType) {
301
+ warnOnce(
302
+ 'canSnap is deprecated. Cannot use both canSnap and snapType together - snapping disabled. Please use only snapType.'
303
+ )
304
+ } else {
305
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
306
+ canSnap = initialHandle.canSnap || initialHandle.snapType !== undefined
307
+ }
308
+
309
+ if (canSnap && (isSnapMode ? !ctrlKey : ctrlKey)) {
298
310
  // We're snapping
299
311
  const pageTransform = editor.getShapePageTransform(shape.id)
300
312
  if (!pageTransform) throw Error('Expected a page transform')
@@ -1,6 +1,8 @@
1
1
  import { useContainer, useEditor, usePeerIds, useValue } from '@tldraw/editor'
2
2
  import { Popover as _Popover } from 'radix-ui'
3
3
  import { ReactNode } from 'react'
4
+ import { PORTRAIT_BREAKPOINT } from '../../constants'
5
+ import { useBreakpoint } from '../../context/breakpoints'
4
6
  import { useMenuIsOpen } from '../../hooks/useMenuIsOpen'
5
7
  import { useTranslation } from '../../hooks/useTranslation/useTranslation'
6
8
  import { PeopleMenuAvatar } from './PeopleMenuAvatar'
@@ -25,6 +27,8 @@ export function PeopleMenu({ children }: PeopleMenuProps) {
25
27
  const userName = useValue('user', () => editor.user.getName(), [editor])
26
28
 
27
29
  const [isOpen, onOpenChange] = useMenuIsOpen('people menu')
30
+ const breakpoint = useBreakpoint()
31
+ const maxAvatars = breakpoint <= PORTRAIT_BREAKPOINT.MOBILE_XS ? 1 : 5
28
32
 
29
33
  if (!userIds.length) return null
30
34
 
@@ -33,7 +37,7 @@ export function PeopleMenu({ children }: PeopleMenuProps) {
33
37
  <_Popover.Trigger dir="ltr" asChild>
34
38
  <button className="tlui-people-menu__avatars-button" title={msg('people-menu.title')}>
35
39
  <div className="tlui-people-menu__avatars">
36
- {userIds.slice(-5).map((userId) => (
40
+ {userIds.slice(-maxAvatars).map((userId) => (
37
41
  <PeopleMenuAvatar key={userId} userId={userId} />
38
42
  ))}
39
43
  {userIds.length > 0 && (
@@ -46,7 +50,7 @@ export function PeopleMenu({ children }: PeopleMenuProps) {
46
50
  {userName?.[0] ?? ''}
47
51
  </div>
48
52
  )}
49
- {userIds.length > 5 && <PeopleMenuMore count={userIds.length - 5} />}
53
+ {userIds.length > maxAvatars && <PeopleMenuMore count={userIds.length - maxAvatars} />}
50
54
  </div>
51
55
  </button>
52
56
  </_Popover.Trigger>
@@ -132,7 +132,7 @@ export const StylePanelButtonPicker = memo(function StylePanelButtonPicker<T ext
132
132
  <TldrawUiToolbarToggleGroup
133
133
  data-testid={`style.${uiType}`}
134
134
  type="single"
135
- value={value.type === 'shared' ? value.value : undefined}
135
+ value={value.type === 'shared' ? value.value : null}
136
136
  asChild
137
137
  >
138
138
  <Layout>
@@ -5,6 +5,7 @@ import {
5
5
  Editor,
6
6
  HALF_PI,
7
7
  PageRecordType,
8
+ Result,
8
9
  TLBookmarkShape,
9
10
  TLEmbedShape,
10
11
  TLFrameShape,
@@ -24,6 +25,7 @@ import {
24
25
  useMaybeEditor,
25
26
  } from '@tldraw/editor'
26
27
  import * as React from 'react'
28
+ import { createBookmarkFromUrl } from '../../shapes/bookmark/bookmarks'
27
29
  import { fitFrameToContent, removeFrame } from '../../utils/frames/frames'
28
30
  import { generateShapeAnnouncementMessage } from '../components/A11y'
29
31
  import { EditLinkDialog } from '../components/EditLinkDialog'
@@ -387,45 +389,39 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
387
389
  {
388
390
  id: 'convert-to-bookmark',
389
391
  label: 'action.convert-to-bookmark',
390
- onSelect(source) {
392
+ async onSelect(source) {
391
393
  if (!canApplySelectionAction()) return
392
394
  if (mustGoBackToSelectToolFirst()) return
393
395
 
394
- editor.run(() => {
395
- trackEvent('convert-to-bookmark', { source })
396
- const shapes = editor.getSelectedShapes()
396
+ trackEvent('convert-to-bookmark', { source })
397
+ const shapes = editor.getSelectedShapes()
397
398
 
398
- const createList: TLShapePartial[] = []
399
- const deleteList: TLShapeId[] = []
400
- for (const shape of shapes) {
401
- if (!shape || !editor.isShapeOfType<TLEmbedShape>(shape, 'embed') || !shape.props.url)
402
- continue
399
+ const markId = editor.markHistoryStoppingPoint('convert shapes to bookmark')
403
400
 
404
- const newPos = new Vec(shape.x, shape.y)
405
- newPos.rot(-shape.rotation)
406
- newPos.add(new Vec(shape.props.w / 2 - 300 / 2, shape.props.h / 2 - 320 / 2)) // see bookmark shape util
407
- newPos.rot(shape.rotation)
408
- const partial: TLShapePartial<TLBookmarkShape> = {
409
- id: createShapeId(),
410
- type: 'bookmark',
411
- rotation: shape.rotation,
412
- x: newPos.x,
413
- y: newPos.y,
414
- opacity: 1,
415
- props: {
416
- url: shape.props.url,
417
- },
418
- }
401
+ const creationPromises: Promise<Result<any, any>>[] = []
419
402
 
420
- createList.push(partial)
421
- deleteList.push(shape.id)
422
- }
403
+ for (const shape of shapes) {
404
+ if (!shape || !editor.isShapeOfType<TLEmbedShape>(shape, 'embed') || !shape.props.url)
405
+ continue
423
406
 
424
- editor.markHistoryStoppingPoint('convert shapes to bookmark')
407
+ const center = editor.getShapePageBounds(shape)?.center
425
408
 
426
- // Should be able to create the shape since we're about to delete the other other
427
- editor.deleteShapes(deleteList)
428
- editor.createShapes(createList)
409
+ if (!center) continue
410
+ editor.deleteShapes([shape.id])
411
+
412
+ creationPromises.push(
413
+ createBookmarkFromUrl(editor, { url: shape.props.url, center }).then((res) => {
414
+ if (!res.ok) {
415
+ throw new Error(res.error)
416
+ }
417
+ return res
418
+ })
419
+ )
420
+ }
421
+
422
+ await Promise.all(creationPromises).catch((error) => {
423
+ editor.bailToMark(markId)
424
+ console.error(error)
429
425
  })
430
426
  },
431
427
  },
@@ -1,9 +1,9 @@
1
1
  // This file is automatically generated by internal/scripts/refresh-assets.ts.
2
2
  // Do not edit manually. Or do, I'm a comment, not a cop.
3
3
 
4
- export const version = '4.1.0-canary.e23ee15a46bc'
4
+ export const version = '4.1.0-canary.e259b517a450'
5
5
  export const publishDates = {
6
6
  major: '2025-09-18T14:39:22.803Z',
7
- minor: '2025-10-06T13:44:26.248Z',
8
- patch: '2025-10-06T13:44:26.248Z',
7
+ minor: '2025-10-13T13:40:12.972Z',
8
+ patch: '2025-10-13T13:40:12.972Z',
9
9
  }
@@ -275,6 +275,14 @@ const MATCH_URL_TEST_URLS: (MatchUrlTestNoMatchDef | MatchUrlTestMatchDef)[] = [
275
275
  embedUrl: `https://replit.com/@omar/Blob-Generator?embed=true`,
276
276
  },
277
277
  },
278
+ {
279
+ url: 'https://replit.com/@omar/Blob-Generator#index.html',
280
+ match: true,
281
+ output: {
282
+ type: 'replit',
283
+ embedUrl: `https://replit.com/@omar/Blob-Generator?embed=true#index.html`,
284
+ },
285
+ },
278
286
  {
279
287
  url: 'https://replit.com/foobar',
280
288
  match: false,
@@ -599,6 +607,14 @@ const MATCH_EMBED_TEST_URLS: (MatchEmbedTestMatchDef | MatchEmbedTestNoMatchDef)
599
607
  url: `https://replit.com/@omar/Blob-Generator`,
600
608
  },
601
609
  },
610
+ {
611
+ embedUrl: 'https://replit.com/@omar/Blob-Generator?embed=true#index.html',
612
+ match: true,
613
+ output: {
614
+ type: 'replit',
615
+ url: `https://replit.com/@omar/Blob-Generator#index.html`,
616
+ },
617
+ },
602
618
  {
603
619
  embedUrl: 'https://replit.com/@omar/Blob-Generator',
604
620
  match: false,
@@ -1,5 +1,6 @@
1
1
  import { TLBookmarkShape, createShapeId } from '@tldraw/editor'
2
- import { getHumanReadableAddress } from '../lib/shapes/bookmark/BookmarkShapeUtil'
2
+ import { vi } from 'vitest'
3
+ import { createBookmarkFromUrl, getHumanReadableAddress } from '../lib/shapes/bookmark/bookmarks'
3
4
  import { TestEditor } from './TestEditor'
4
5
 
5
6
  let editor: TestEditor
@@ -132,3 +133,124 @@ describe('The URL formatter', () => {
132
133
  expect(newBookmark.props.h).toBe(320)
133
134
  })
134
135
  })
136
+
137
+ describe('createBookmarkFromUrl', () => {
138
+ it('creates a bookmark shape with unfurled metadata', async () => {
139
+ const url = 'https://example.com'
140
+ const center = { x: 100, y: 200 }
141
+
142
+ // Mock the asset creation to return a test asset
143
+ const mockAsset = {
144
+ id: 'asset:test-asset-id' as any,
145
+ typeName: 'asset' as const,
146
+ type: 'bookmark' as const,
147
+ props: {
148
+ src: url,
149
+ title: 'Example Site',
150
+ description: 'An example website',
151
+ image: 'https://example.com/image.jpg',
152
+ favicon: 'https://example.com/favicon.ico',
153
+ },
154
+ meta: {},
155
+ }
156
+
157
+ // Mock the getAssetForExternalContent method
158
+ vi.spyOn(editor, 'getAssetForExternalContent').mockResolvedValue(mockAsset)
159
+
160
+ const result = await createBookmarkFromUrl(editor, { url, center })
161
+
162
+ assert(result.ok, 'Failed to create bookmark')
163
+ const shape = result.value
164
+ expect(shape.type).toBe('bookmark')
165
+ expect(shape.props.url).toBe(url)
166
+ expect(shape.props.assetId).toBe('asset:test-asset-id')
167
+ expect(shape.props.w).toBe(300)
168
+ expect(shape.props.h).toBe(320)
169
+ expect(shape.x).toBe(center.x - 150) // BOOKMARK_WIDTH / 2
170
+ expect(shape.y).toBe(center.y - 160) // BOOKMARK_HEIGHT / 2
171
+
172
+ // Verify the shape was created in the editor
173
+ const createdShape = editor.getShape(result.value.id)
174
+ expect(createdShape).toBeDefined()
175
+ expect(createdShape?.type).toBe('bookmark')
176
+
177
+ // Verify the asset was created
178
+ const createdAsset = editor.getAsset('asset:test-asset-id' as any)
179
+ expect(createdAsset).toBeDefined()
180
+ expect(createdAsset?.type).toBe('bookmark')
181
+ })
182
+
183
+ it('creates a bookmark shape with default center when no center provided', async () => {
184
+ const url = 'https://example.com'
185
+ const viewportCenter = { x: 500, y: 300 }
186
+
187
+ // Mock getViewportPageBounds to return a known center
188
+ vi.spyOn(editor, 'getViewportPageBounds').mockReturnValue({
189
+ x: 0,
190
+ y: 0,
191
+ w: 1000,
192
+ h: 600,
193
+ center: viewportCenter,
194
+ } as any)
195
+
196
+ const mockAsset = {
197
+ id: 'asset:test-asset-id' as any,
198
+ typeName: 'asset' as const,
199
+ type: 'bookmark' as const,
200
+ props: {
201
+ src: url,
202
+ title: 'Example Site',
203
+ description: 'An example website',
204
+ image: '',
205
+ favicon: '',
206
+ },
207
+ meta: {},
208
+ }
209
+
210
+ vi.spyOn(editor, 'getAssetForExternalContent').mockResolvedValue(mockAsset)
211
+
212
+ const result = await createBookmarkFromUrl(editor, { url })
213
+
214
+ assert(result.ok, 'Failed to create bookmark')
215
+ const shape = result.value
216
+ expect(shape.x).toBe(viewportCenter.x - 150)
217
+ expect(shape.y).toBe(viewportCenter.y - 160)
218
+ })
219
+
220
+ it('handles asset creation failure gracefully', async () => {
221
+ const url = 'https://invalid-url.com'
222
+ const center = { x: 100, y: 200 }
223
+
224
+ // Mock the asset creation to fail
225
+ vi.spyOn(editor, 'getAssetForExternalContent').mockRejectedValue(new Error('Failed to fetch'))
226
+
227
+ const result = await createBookmarkFromUrl(editor, { url, center })
228
+
229
+ assert(!result.ok, 'Failed to create bookmark')
230
+ expect(result.error).toBe('Failed to fetch')
231
+
232
+ // Verify no shape was created
233
+ const shapes = editor.getCurrentPageShapes()
234
+ expect(shapes).toHaveLength(0)
235
+ })
236
+
237
+ it('creates bookmark shape even when asset creation returns null', async () => {
238
+ const url = 'https://example.com'
239
+ const center = { x: 100, y: 200 }
240
+
241
+ // Mock the asset creation to return null
242
+ vi.spyOn(editor, 'getAssetForExternalContent').mockResolvedValue(null as any)
243
+
244
+ const result = await createBookmarkFromUrl(editor, { url, center })
245
+
246
+ assert(result.ok, 'Failed to create bookmark')
247
+ const shape = result.value
248
+ expect(shape.type).toBe('bookmark')
249
+ expect(shape.props.url).toBe(url)
250
+ expect(shape.props.assetId).toBe(null)
251
+
252
+ // Verify the shape was created
253
+ const createdShape = editor.getShape(result.value.id)
254
+ expect(createdShape).toBeDefined()
255
+ })
256
+ })