bookmark: fix up double request and rework extractor (#3856)

This code has started to bitrot a bit and this freshens it up a bit.
- there's a double request happening for every bookmark paste at the
moment, yikes! One request originates from the paste logic, and the
other originates from the `onBeforeCreate` in `BookmarkShapeUtil`. They
both see that an asset is missing and race to make the request at the
same time. It _seems_ like we don't need the `onBeforeCreate` anymore.
But, if I'm mistaken on some edge case here lemme know and we can
address this in a different way.
- the extractor is really crusty (the grabity code is from 5 yrs ago and
hasn't been updated) and we don't have control over it. i've worked on
unfurling stuff before with Paper and my other projects and this reworks
things to use Cheerio, which is a more robust library.
- this adds `favicon` to the response request which should usually
default to the apple-touch-icon. this helps with some better bookmark
displays (e.g. like Wikipedia if an image is empty)

In general, this'll start to make this more maintainable and improvable
on our end.

Double request:
<img width="1496" alt="Screenshot 2024-05-31 at 17 54 49"
src="https://github.com/tldraw/tldraw/assets/469604/22033170-caaa-4fd2-854f-f19b61611978">

Before:
<img width="355" alt="Screenshot 2024-05-31 at 17 55 02"
src="https://github.com/tldraw/tldraw/assets/469604/fd272669-ee52-4cc7-bed7-72a8ed8d53a0">

After:
<img width="351" alt="Screenshot 2024-05-31 at 17 55 44"
src="https://github.com/tldraw/tldraw/assets/469604/87d27342-0d49-4cfc-a811-356370562d19">


### Change Type

<!--  Please select a 'Scope' label ️ -->

- [ ] `sdk` — Changes the tldraw SDK
- [x] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [x] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [x] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know


### Test Plan

1. Test pasting links in, and pasting again.

### Release Notes

- Bookmarks: fix up double request and rework extractor code.

---------

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
Mime Čuvalo 2024-06-10 11:50:49 +01:00 committed by GitHub
parent 4dab25be57
commit ccb6b918c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1016 additions and 4084 deletions

View file

@ -1,25 +1,18 @@
// @ts-expect-error import { unfurl } from '../lib/unfurl'
import grabity from 'grabity'
import { runCorsMiddleware } from './_cors' import { runCorsMiddleware } from './_cors'
interface RequestBody { interface RequestBody {
url: string url: string
} }
interface ResponseBody {
title?: string
description?: string
image?: string
}
export default async function handler(req: any, res: any) { export default async function handler(req: any, res: any) {
try { try {
await runCorsMiddleware(req, res) await runCorsMiddleware(req, res)
const { url } = typeof req.body === 'string' ? JSON.parse(req.body) : (req.body as RequestBody) const { url } = typeof req.body === 'string' ? JSON.parse(req.body) : (req.body as RequestBody)
const it = await grabity.grabIt(url) const results = await unfurl(url)
res.send(it) res.send(results)
} catch (error: any) { } catch (error: any) {
console.error(error) console.error(error)
res.status(500).send(error.message) res.status(422).send(error.message)
} }
} }

View file

@ -0,0 +1,40 @@
import cheerio from 'cheerio'
export async function unfurl(url: string) {
const response = await fetch(url)
if (response.status >= 400) {
throw new Error(`Error fetching url: ${response.status}`)
}
const contentType = response.headers.get('content-type')
if (!contentType?.includes('text/html')) {
throw new Error(`Content-type not right: ${contentType}`)
}
const content = await response.text()
const $ = cheerio.load(content)
const og: { [key: string]: string | undefined } = {}
const twitter: { [key: string]: string | undefined } = {}
$('meta[property^=og:]').each((_, el) => (og[$(el).attr('property')!] = $(el).attr('content')))
$('meta[name^=twitter:]').each((_, el) => (twitter[$(el).attr('name')!] = $(el).attr('content')))
const title = og['og:title'] ?? twitter['twitter:title'] ?? $('title').text() ?? undefined
const description =
og['og:description'] ??
twitter['twitter:description'] ??
$('meta[name="description"]').attr('content') ??
undefined
const image = og['og:image:secure_url'] ?? og['og:image'] ?? twitter['twitter:image'] ?? undefined
const favicon =
$('link[rel="apple-touch-icon"]').attr('href') ??
$('link[rel="icon"]').attr('href') ??
undefined
return {
title,
description,
image,
favicon,
}
}

View file

@ -8,16 +8,19 @@
"email": "hello@tldraw.com" "email": "hello@tldraw.com"
}, },
"scripts": { "scripts": {
"run-local": "vercel dev",
"lint": "yarn run -T tsx ../../scripts/lint.ts" "lint": "yarn run -T tsx ../../scripts/lint.ts"
}, },
"dependencies": { "dependencies": {
"@types/cors": "^2.8.15", "cheerio": "1.0.0-rc.12",
"cors": "^2.8.5", "cors": "^2.8.5"
"grabity": "^1.0.5",
"tslib": "^2.6.2"
}, },
"devDependencies": { "devDependencies": {
"@types/cheerio": "0.22.33",
"@types/cors": "^2.8.15",
"lazyrepo": "0.0.0-alpha.27", "lazyrepo": "0.0.0-alpha.27",
"typescript": "^5.3.3" "tslib": "^2.6.2",
"typescript": "^5.3.3",
"vercel": "^34.2.4"
} }
} }

View file

@ -1,5 +1,4 @@
{ {
"include": ["api"],
"exclude": ["node_modules", "dist", ".tsbuild*", ".vercel"], "exclude": ["node_modules", "dist", ".tsbuild*", ".vercel"],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,

View file

@ -1,10 +1,11 @@
import { AssetRecordType, TLAsset, getHashForString, truncateStringWithEllipsis } from 'tldraw' import { AssetRecordType, TLAsset, getHashForString } from 'tldraw'
import { BOOKMARK_ENDPOINT } from './config' import { BOOKMARK_ENDPOINT } from './config'
interface ResponseBody { interface ResponseBody {
title?: string title?: string
description?: string description?: string
image?: string image?: string
favicon?: string
} }
export async function createAssetFromUrl({ url }: { type: 'url'; url: string }): Promise<TLAsset> { export async function createAssetFromUrl({ url }: { type: 'url'; url: string }): Promise<TLAsset> {
@ -30,14 +31,15 @@ export async function createAssetFromUrl({ url }: { type: 'url'; url: string }):
src: url, src: url,
description: meta.description ?? '', description: meta.description ?? '',
image: meta.image ?? '', image: meta.image ?? '',
title: meta.title ?? truncateStringWithEllipsis(url, 32), favicon: meta.favicon ?? '',
title: meta.title ?? '',
}, },
meta: {}, meta: {},
} }
} catch (error) { } catch (error) {
// Otherwise, fallback to fetching data from the url // Otherwise, fallback to fetching data from the url
let meta: { image: string; title: string; description: string } let meta: { image: string; favicon: string; title: string; description: string }
try { try {
const resp = await fetch(url, { const resp = await fetch(url, {
@ -49,15 +51,22 @@ export async function createAssetFromUrl({ url }: { type: 'url'; url: string }):
const doc = new DOMParser().parseFromString(html, 'text/html') const doc = new DOMParser().parseFromString(html, 'text/html')
meta = { meta = {
image: doc.head.querySelector('meta[property="og:image"]')?.getAttribute('content') ?? '', image: doc.head.querySelector('meta[property="og:image"]')?.getAttribute('content') ?? '',
title: favicon:
doc.head.querySelector('meta[property="og:title"]')?.getAttribute('content') ?? doc.head.querySelector('link[rel="apple-touch-icon"]')?.getAttribute('href') ??
truncateStringWithEllipsis(url, 32), doc.head.querySelector('link[rel="icon"]')?.getAttribute('href') ??
'',
title: doc.head.querySelector('meta[property="og:title"]')?.getAttribute('content') ?? '',
description: description:
doc.head.querySelector('meta[property="og:description"]')?.getAttribute('content') ?? '', doc.head.querySelector('meta[property="og:description"]')?.getAttribute('content') ?? '',
} }
// Resolve relative URLs
if (meta.image.startsWith('/')) {
const urlObj = new URL(url)
meta.image = `${urlObj.origin}${meta.image}`
}
} catch (error) { } catch (error) {
console.error(error) console.error(error)
meta = { image: '', title: truncateStringWithEllipsis(url, 32), description: '' } meta = { image: '', favicon: '', title: '', description: '' }
} }
// Create the bookmark asset from the meta // Create the bookmark asset from the meta
@ -68,6 +77,7 @@ export async function createAssetFromUrl({ url }: { type: 'url'; url: string }):
props: { props: {
src: url, src: url,
image: meta.image, image: meta.image,
favicon: meta.favicon,
title: meta.title, title: meta.title,
description: meta.description, description: meta.description,
}, },

View file

@ -1,10 +1,6 @@
import { AssetRecordType, TLAsset, TLExternalAssetContent, getHashForString } from 'tldraw' import { AssetRecordType, TLAsset, TLExternalAssetContent, getHashForString } from 'tldraw'
import { rpc } from './rpc' import { rpc } from './rpc'
export const truncateStringWithEllipsis = (str: string, maxLength: number) => {
return str.length <= maxLength ? str : str.substring(0, maxLength - 3) + '...'
}
export async function onCreateAssetFromUrl({ export async function onCreateAssetFromUrl({
url, url,
}: TLExternalAssetContent & { type: 'url' }): Promise<TLAsset> { }: TLExternalAssetContent & { type: 'url' }): Promise<TLAsset> {
@ -20,14 +16,15 @@ export async function onCreateAssetFromUrl({
src: url, src: url,
description: meta.description ?? '', description: meta.description ?? '',
image: meta.image ?? '', image: meta.image ?? '',
title: meta.title ?? truncateStringWithEllipsis(url, 32), favicon: meta.favicon ?? '',
title: meta.title ?? '',
}, },
meta: {}, meta: {},
} }
} catch (error) { } catch (error) {
// Otherwise, fallback to fetching data from the url // Otherwise, fallback to fetching data from the url
let meta: { image: string; title: string; description: string } let meta: { image: string; favicon: string; title: string; description: string }
try { try {
const resp = await fetch(url, { const resp = await fetch(url, {
@ -39,15 +36,17 @@ export async function onCreateAssetFromUrl({
const doc = new DOMParser().parseFromString(html, 'text/html') const doc = new DOMParser().parseFromString(html, 'text/html')
meta = { meta = {
image: doc.head.querySelector('meta[property="og:image"]')?.getAttribute('content') ?? '', image: doc.head.querySelector('meta[property="og:image"]')?.getAttribute('content') ?? '',
title: favicon:
doc.head.querySelector('meta[property="og:title"]')?.getAttribute('content') ?? doc.head.querySelector('link[rel="apple-touch-icon"]')?.getAttribute('href') ??
truncateStringWithEllipsis(url, 32), doc.head.querySelector('link[rel="icon"]')?.getAttribute('href') ??
'',
title: doc.head.querySelector('meta[property="og:title"]')?.getAttribute('content') ?? '',
description: description:
doc.head.querySelector('meta[property="og:description"]')?.getAttribute('content') ?? '', doc.head.querySelector('meta[property="og:description"]')?.getAttribute('content') ?? '',
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error)
meta = { image: '', title: truncateStringWithEllipsis(url, 32), description: '' } meta = { image: '', favicon: '', title: '', description: '' }
} }
// Create the bookmark asset from the meta // Create the bookmark asset from the meta
@ -58,6 +57,7 @@ export async function onCreateAssetFromUrl({
props: { props: {
src: url, src: url,
image: meta.image, image: meta.image,
favicon: meta.favicon,
title: meta.title, title: meta.title,
description: meta.description, description: meta.description,
}, },

View file

@ -97,6 +97,7 @@ export class WebViewMessageHandler {
title: json.title, title: json.title,
description: json.description, description: json.description,
image: json.image, image: json.image,
favicon: json.favicon,
}, },
}) })
}) })

View file

@ -13,6 +13,7 @@ type BookmarkResponse = {
title?: string title?: string
description?: string description?: string
image?: string image?: string
favicon?: string
} }
} }

View file

@ -103,7 +103,7 @@
"rimraf": "^4.4.0", "rimraf": "^4.4.0",
"tsx": "^4.0.0", "tsx": "^4.0.0",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vercel": "^28.16.15" "vercel": "^34.2.4"
}, },
"// resolutions.canvas": [ "// resolutions.canvas": [
"our examples app depenends on pdf.js which pulls in canvas as an optional dependency.", "our examples app depenends on pdf.js which pulls in canvas as an optional dependency.",

View file

@ -973,6 +973,7 @@ input,
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
object-position: center; object-position: center;
border-bottom: 1px solid var(--color-muted-2);
} }
.tl-bookmark__copy_container { .tl-bookmark__copy_container {
@ -991,27 +992,63 @@ input,
.tl-bookmark__heading { .tl-bookmark__heading {
font-size: 16px; font-size: 16px;
line-height: 1.5;
font-weight: bold; font-weight: bold;
padding-bottom: var(--space-2); padding-bottom: var(--space-2);
margin: 8px 0px; overflow: hidden;
max-height: calc((16px * 1.5) * 2);
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
text-overflow: ellipsis;
display: -webkit-box;
} }
.tl-bookmark__description { .tl-bookmark__description {
font-size: 12px; font-size: 12px;
padding-bottom: var(--space-4); line-height: 1.5;
overflow: hidden;
max-height: calc((12px * 1.5) * 3);
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
text-overflow: ellipsis;
display: -webkit-box;
color: var(--color-text-2);
margin: var(--space-2) 0px;
} }
.tl-bookmark__heading + .tl-bookmark__link,
.tl-bookmark__description + .tl-bookmark__link {
margin-top: var(--space-3);
}
.tl-bookmark__link { .tl-bookmark__link {
font-size: 14px; font-size: 12px;
pointer-events: all; pointer-events: all;
z-index: var(--layer-bookmark-link); z-index: var(--layer-bookmark-link);
overflow: hidden; display: flex;
display: block; color: var(--color-text-2);
color: var(--color-text); align-items: center;
text-overflow: ellipsis;
text-decoration: none;
color: var(--color-text-1);
cursor: var(--tl-cursor-pointer); cursor: var(--tl-cursor-pointer);
width: fit-content;
max-width: 100%;
}
.tl-bookmark__link > span {
flex-shrink: 0px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tl-bookmark__link > .tl-hyperlink__icon {
margin-right: 8px;
flex-shrink: 0;
}
.tl-bookmark__link > .tl-bookmark__favicon {
margin-right: 8px;
width: 16px;
height: 16px;
flex-shrink: 0;
} }
/* ---------------- Hyperlink Button ---------------- */ /* ---------------- Hyperlink Button ---------------- */
@ -1056,9 +1093,9 @@ input,
color: var(--color-selected); color: var(--color-selected);
} }
.tl-hyperlink-button__icon { .tl-hyperlink__icon {
width: 18px; width: 16px;
height: 18px; height: 16px;
background-color: currentColor; background-color: currentColor;
pointer-events: none; pointer-events: none;
} }

View file

@ -91,7 +91,6 @@ import { TLKeyboardEvent } from '@tldraw/editor';
import { TLKeyboardEventInfo } from '@tldraw/editor'; import { TLKeyboardEventInfo } from '@tldraw/editor';
import { TLLineShape } from '@tldraw/editor'; import { TLLineShape } from '@tldraw/editor';
import { TLNoteShape } from '@tldraw/editor'; import { TLNoteShape } from '@tldraw/editor';
import { TLOnBeforeCreateHandler } from '@tldraw/editor';
import { TLOnBeforeUpdateHandler } from '@tldraw/editor'; import { TLOnBeforeUpdateHandler } from '@tldraw/editor';
import { TLOnDoubleClickHandler } from '@tldraw/editor'; import { TLOnDoubleClickHandler } from '@tldraw/editor';
import { TLOnEditEndHandler } from '@tldraw/editor'; import { TLOnEditEndHandler } from '@tldraw/editor';
@ -261,7 +260,25 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
// (undocumented) // (undocumented)
static migrations: TLPropsMigrations; static migrations: TLPropsMigrations;
// (undocumented) // (undocumented)
onBeforeCreate?: TLOnBeforeCreateHandler<TLBookmarkShape>; onBeforeCreate: (next: TLBookmarkShape) => {
id: TLShapeId;
index: IndexKey;
isLocked: boolean;
meta: JsonObject;
opacity: number;
parentId: TLParentId;
props: {
assetId: null | TLAssetId;
h: number;
url: string;
w: number;
};
rotation: number;
type: "bookmark";
typeName: "shape";
x: number;
y: number;
};
// (undocumented) // (undocumented)
onBeforeUpdate?: TLOnBeforeUpdateHandler<TLBookmarkShape>; onBeforeUpdate?: TLOnBeforeUpdateHandler<TLBookmarkShape>;
// (undocumented) // (undocumented)

View file

@ -24,7 +24,7 @@ import { TLUiToastsContextType } from './ui/context/toasts'
import { useTranslation } from './ui/hooks/useTranslation/useTranslation' import { useTranslation } from './ui/hooks/useTranslation/useTranslation'
import { containBoxSize, downsizeImage } from './utils/assets/assets' import { containBoxSize, downsizeImage } from './utils/assets/assets'
import { getEmbedInfo } from './utils/embeds/embeds' import { getEmbedInfo } from './utils/embeds/embeds'
import { cleanupText, isRightToLeftLanguage, truncateStringWithEllipsis } from './utils/text/text' import { cleanupText, isRightToLeftLanguage } from './utils/text/text'
/** @public */ /** @public */
export interface TLExternalContentProps { export interface TLExternalContentProps {
@ -110,7 +110,7 @@ export function registerDefaultExternalContentHandlers(
// urls -> bookmark asset // urls -> bookmark asset
editor.registerExternalAssetHandler('url', async ({ url }) => { editor.registerExternalAssetHandler('url', async ({ url }) => {
let meta: { image: string; title: string; description: string } let meta: { image: string; favicon: string; title: string; description: string }
try { try {
const resp = await fetch(url, { const resp = await fetch(url, {
@ -122,9 +122,11 @@ export function registerDefaultExternalContentHandlers(
const doc = new DOMParser().parseFromString(html, 'text/html') const doc = new DOMParser().parseFromString(html, 'text/html')
meta = { meta = {
image: doc.head.querySelector('meta[property="og:image"]')?.getAttribute('content') ?? '', image: doc.head.querySelector('meta[property="og:image"]')?.getAttribute('content') ?? '',
title: favicon:
doc.head.querySelector('meta[property="og:title"]')?.getAttribute('content') ?? doc.head.querySelector('link[rel="apple-touch-icon"]')?.getAttribute('href') ??
truncateStringWithEllipsis(url, 32), doc.head.querySelector('link[rel="icon"]')?.getAttribute('href') ??
'',
title: doc.head.querySelector('meta[property="og:title"]')?.getAttribute('content') ?? url,
description: description:
doc.head.querySelector('meta[property="og:description"]')?.getAttribute('content') ?? '', doc.head.querySelector('meta[property="og:description"]')?.getAttribute('content') ?? '',
} }
@ -134,7 +136,7 @@ export function registerDefaultExternalContentHandlers(
title: msg('assets.url.failed'), title: msg('assets.url.failed'),
severity: 'error', severity: 'error',
}) })
meta = { image: '', title: truncateStringWithEllipsis(url, 32), description: '' } meta = { image: '', favicon: '', title: '', description: '' }
} }
// Create the bookmark asset from the meta // Create the bookmark asset from the meta
@ -146,6 +148,7 @@ export function registerDefaultExternalContentHandlers(
src: url, src: url,
description: meta.description, description: meta.description,
image: meta.image, image: meta.image,
favicon: meta.favicon,
title: meta.title, title: meta.title,
}, },
meta: {}, meta: {},

View file

@ -7,7 +7,6 @@ import {
TLAssetId, TLAssetId,
TLBookmarkAsset, TLBookmarkAsset,
TLBookmarkShape, TLBookmarkShape,
TLOnBeforeCreateHandler,
TLOnBeforeUpdateHandler, TLOnBeforeUpdateHandler,
bookmarkShapeMigrations, bookmarkShapeMigrations,
bookmarkShapeProps, bookmarkShapeProps,
@ -16,10 +15,15 @@ import {
stopEventPropagation, stopEventPropagation,
toDomPrecision, toDomPrecision,
} from '@tldraw/editor' } from '@tldraw/editor'
import { truncateStringWithEllipsis } from '../../utils/text/text'
import { HyperlinkButton } from '../shared/HyperlinkButton' import { HyperlinkButton } from '../shared/HyperlinkButton'
import { LINK_ICON } from '../shared/icons-editor'
import { getRotatedBoxShadow } from '../shared/rotated-box-shadow' import { getRotatedBoxShadow } from '../shared/rotated-box-shadow'
const BOOKMARK_WIDTH = 300
const BOOKMARK_HEIGHT = 320
const BOOKMARK_JUST_URL_HEIGHT = 46
const SHORT_BOOKMARK_HEIGHT = 110
/** @public */ /** @public */
export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> { export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
static override type = 'bookmark' as const static override type = 'bookmark' as const
@ -33,8 +37,8 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
override getDefaultProps(): TLBookmarkShape['props'] { override getDefaultProps(): TLBookmarkShape['props'] {
return { return {
url: '', url: '',
w: 300, w: BOOKMARK_WIDTH,
h: 320, h: BOOKMARK_HEIGHT,
assetId: null, assetId: null,
} }
} }
@ -56,8 +60,9 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
boxShadow: getRotatedBoxShadow(pageRotation), boxShadow: getRotatedBoxShadow(pageRotation),
}} }}
> >
{(!asset || asset.props.image) && (
<div className="tl-bookmark__image_container"> <div className="tl-bookmark__image_container">
{asset?.props.image ? ( {asset ? (
<img <img
className="tl-bookmark__image" className="tl-bookmark__image"
draggable={false} draggable={false}
@ -68,19 +73,18 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
) : ( ) : (
<div className="tl-bookmark__placeholder" /> <div className="tl-bookmark__placeholder" />
)} )}
{asset?.props.image && (
<HyperlinkButton url={shape.props.url} zoomLevel={this.editor.getZoomLevel()} /> <HyperlinkButton url={shape.props.url} zoomLevel={this.editor.getZoomLevel()} />
)}
</div> </div>
)}
<div className="tl-bookmark__copy_container"> <div className="tl-bookmark__copy_container">
{asset?.props.title && ( {asset?.props.title ? (
<h2 className="tl-bookmark__heading"> <h2 className="tl-bookmark__heading">{asset.props.title}</h2>
{truncateStringWithEllipsis(asset?.props.title || '', 54)} ) : null}
</h2> {asset?.props.description && asset?.props.image ? (
)} <p className="tl-bookmark__description">{asset.props.description}</p>
{asset?.props.description && ( ) : null}
<p className="tl-bookmark__description">
{truncateStringWithEllipsis(asset?.props.description || '', 128)}
</p>
)}
<a <a
className="tl-bookmark__link" className="tl-bookmark__link"
href={shape.props.url || ''} href={shape.props.url || ''}
@ -90,7 +94,23 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
onPointerUp={stopEventPropagation} onPointerUp={stopEventPropagation}
onClick={stopEventPropagation} onClick={stopEventPropagation}
> >
{truncateStringWithEllipsis(address, 45)} {asset?.props.favicon ? (
<img
className="tl-bookmark__favicon"
src={asset?.props.favicon}
referrerPolicy="strict-origin-when-cross-origin"
alt={`favicon of ${address}`}
/>
) : (
<div
className="tl-hyperlink__icon"
style={{
mask: `url("${LINK_ICON}") center 100% / 100% no-repeat`,
WebkitMask: `url("${LINK_ICON}") center 100% / 100% no-repeat`,
}}
/>
)}
<span>{address}</span>
</a> </a>
</div> </div>
</div> </div>
@ -109,8 +129,8 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
) )
} }
override onBeforeCreate?: TLOnBeforeCreateHandler<TLBookmarkShape> = (shape) => { override onBeforeCreate = (next: TLBookmarkShape) => {
updateBookmarkAssetOnUrlChange(this.editor, shape) return getBookmarkSize(this.editor, next)
} }
override onBeforeUpdate?: TLOnBeforeUpdateHandler<TLBookmarkShape> = (prev, shape) => { override onBeforeUpdate?: TLOnBeforeUpdateHandler<TLBookmarkShape> = (prev, shape) => {
@ -121,6 +141,36 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
updateBookmarkAssetOnUrlChange(this.editor, shape) updateBookmarkAssetOnUrlChange(this.editor, shape)
} }
} }
if (prev.props.assetId !== shape.props.assetId) {
return getBookmarkSize(this.editor, shape)
}
}
}
function getBookmarkSize(editor: Editor, shape: TLBookmarkShape) {
const asset = (
shape.props.assetId ? editor.getAsset(shape.props.assetId) : null
) as TLBookmarkAsset
let h = BOOKMARK_HEIGHT
if (asset) {
if (!asset.props.image) {
if (!asset.props.title) {
h = BOOKMARK_JUST_URL_HEIGHT
} else {
h = SHORT_BOOKMARK_HEIGHT
}
}
}
return {
...shape,
props: {
...shape.props,
h,
},
} }
} }
@ -128,8 +178,8 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
export const getHumanReadableAddress = (shape: TLBookmarkShape) => { export const getHumanReadableAddress = (shape: TLBookmarkShape) => {
try { try {
const url = new URL(shape.props.url) const url = new URL(shape.props.url)
const path = url.pathname.replace(/\/*$/, '') // we want the hostname without any www
return `${url.hostname}${path}` return url.hostname.replace(/^www\./, '')
} catch (e) { } catch (e) {
return shape.props.url return shape.props.url
} }

View file

@ -19,7 +19,7 @@ export function HyperlinkButton({ url, zoomLevel }: { url: string; zoomLevel: nu
draggable={false} draggable={false}
> >
<div <div
className="tl-hyperlink-button__icon" className="tl-hyperlink__icon"
style={{ style={{
mask: `url("${LINK_ICON}") center 100% / 100% no-repeat`, mask: `url("${LINK_ICON}") center 100% / 100% no-repeat`,
WebkitMask: `url("${LINK_ICON}") center 100% / 100% no-repeat`, WebkitMask: `url("${LINK_ICON}") center 100% / 100% no-repeat`,

View file

@ -0,0 +1,2 @@
export const LINK_ICON =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' fill='none'%3E%3Cpath stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M13 5H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6M19 5h6m0 0v6m0-6L13 17'/%3E%3C/svg%3E"

View file

@ -16,7 +16,7 @@ export async function pasteUrl(
point?: VecLike, point?: VecLike,
sources?: TLExternalContentSource[] sources?: TLExternalContentSource[]
) { ) {
// Lets see if its an image and we have CORs // Lets see if its an image and we have CORS
try { try {
// skip this step if the url doesn't contain an image extension, treat it as a regular bookmark // skip this step if the url doesn't contain an image extension, treat it as a regular bookmark
if (new URL(url).pathname.match(/\.(png|jpe?g|gif|svg|webp)$/i)) { if (new URL(url).pathname.match(/\.(png|jpe?g|gif|svg|webp)$/i)) {

View file

@ -81,12 +81,12 @@ describe('The URL formatter', () => {
const e = editor.getShape<TLBookmarkShape>(ids.e)! const e = editor.getShape<TLBookmarkShape>(ids.e)!
const f = editor.getShape<TLBookmarkShape>(ids.f)! const f = editor.getShape<TLBookmarkShape>(ids.f)!
expect(getHumanReadableAddress(a)).toBe('www.github.com') expect(getHumanReadableAddress(a)).toBe('github.com')
expect(getHumanReadableAddress(b)).toBe('www.github.com') expect(getHumanReadableAddress(b)).toBe('github.com')
expect(getHumanReadableAddress(c)).toBe('www.github.com/TodePond') expect(getHumanReadableAddress(c)).toBe('github.com')
expect(getHumanReadableAddress(d)).toBe('www.github.com/TodePond') expect(getHumanReadableAddress(d)).toBe('github.com')
expect(getHumanReadableAddress(e)).toBe('www.github.com') expect(getHumanReadableAddress(e)).toBe('github.com')
expect(getHumanReadableAddress(f)).toBe('www.github.com/TodePond/DreamBerd') expect(getHumanReadableAddress(f)).toBe('github.com')
}) })
it("Doesn't resize bookmarks", () => { it("Doesn't resize bookmarks", () => {

View file

@ -976,6 +976,7 @@ export type TLBindingUpdate<T extends TLBinding = TLBinding> = Expand<{
// @public // @public
export type TLBookmarkAsset = TLBaseAsset<'bookmark', { export type TLBookmarkAsset = TLBaseAsset<'bookmark', {
description: string; description: string;
favicon: string;
image: string; image: string;
src: null | string; src: null | string;
title: string; title: string;

View file

@ -13,6 +13,7 @@ export type TLBookmarkAsset = TLBaseAsset<
title: string title: string
description: string description: string
image: string image: string
favicon: string
src: string | null src: string | null
} }
> >
@ -24,12 +25,14 @@ export const bookmarkAssetValidator: T.Validator<TLBookmarkAsset> = createAssetV
title: T.string, title: T.string,
description: T.string, description: T.string,
image: T.string, image: T.string,
favicon: T.string,
src: T.srcUrl.nullable(), src: T.srcUrl.nullable(),
}) })
) )
const Versions = createMigrationIds('com.tldraw.asset.bookmark', { const Versions = createMigrationIds('com.tldraw.asset.bookmark', {
MakeUrlsValid: 1, MakeUrlsValid: 1,
AddFavicon: 2,
} as const) } as const)
export { Versions as bookmarkAssetVersions } export { Versions as bookmarkAssetVersions }
@ -51,5 +54,16 @@ export const bookmarkAssetMigrations = createRecordMigrationSequence({
// noop // noop
}, },
}, },
{
id: Versions.AddFavicon,
up: (asset: any) => {
if (!T.srcUrl.isValid(asset.props.favicon)) {
asset.props.favicon = ''
}
},
down: (asset: any) => {
delete asset.props.favicon
},
},
], ],
}) })

View file

@ -1314,6 +1314,32 @@ describe('Make urls valid for all the assets', () => {
} }
}) })
describe('Ensure favicons are on bookmarks', () => {
const { up, down } = getTestMigration(bookmarkAssetVersions.AddFavicon)
it('up works as expected', () => {
expect(
up({
props: {},
})
).toEqual({
props: {
favicon: '',
},
})
})
it('down works as expected', () => {
expect(
down({
props: {
favicon: 'https://tldraw.com/favicon.ico',
},
})
).toEqual({
props: {},
})
})
})
describe('Add duplicate props to instance', () => { describe('Add duplicate props to instance', () => {
const { up, down } = getTestMigration(instanceVersions.AddDuplicateProps) const { up, down } = getTestMigration(instanceVersions.AddDuplicateProps)
it('up works as expected', () => { it('up works as expected', () => {

4711
yarn.lock

File diff suppressed because it is too large Load diff