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:
parent
4dab25be57
commit
ccb6b918c5
21 changed files with 1016 additions and 4084 deletions
|
@ -1,25 +1,18 @@
|
|||
// @ts-expect-error
|
||||
import grabity from 'grabity'
|
||||
import { unfurl } from '../lib/unfurl'
|
||||
import { runCorsMiddleware } from './_cors'
|
||||
|
||||
interface RequestBody {
|
||||
url: string
|
||||
}
|
||||
|
||||
interface ResponseBody {
|
||||
title?: string
|
||||
description?: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
export default async function handler(req: any, res: any) {
|
||||
try {
|
||||
await runCorsMiddleware(req, res)
|
||||
const { url } = typeof req.body === 'string' ? JSON.parse(req.body) : (req.body as RequestBody)
|
||||
const it = await grabity.grabIt(url)
|
||||
res.send(it)
|
||||
const results = await unfurl(url)
|
||||
res.send(results)
|
||||
} catch (error: any) {
|
||||
console.error(error)
|
||||
res.status(500).send(error.message)
|
||||
res.status(422).send(error.message)
|
||||
}
|
||||
}
|
||||
|
|
40
apps/dotcom-bookmark-extractor/lib/unfurl.ts
Normal file
40
apps/dotcom-bookmark-extractor/lib/unfurl.ts
Normal 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,
|
||||
}
|
||||
}
|
|
@ -8,16 +8,19 @@
|
|||
"email": "hello@tldraw.com"
|
||||
},
|
||||
"scripts": {
|
||||
"run-local": "vercel dev",
|
||||
"lint": "yarn run -T tsx ../../scripts/lint.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/cors": "^2.8.15",
|
||||
"cors": "^2.8.5",
|
||||
"grabity": "^1.0.5",
|
||||
"tslib": "^2.6.2"
|
||||
"cheerio": "1.0.0-rc.12",
|
||||
"cors": "^2.8.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cheerio": "0.22.33",
|
||||
"@types/cors": "^2.8.15",
|
||||
"lazyrepo": "0.0.0-alpha.27",
|
||||
"typescript": "^5.3.3"
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.3.3",
|
||||
"vercel": "^34.2.4"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
{
|
||||
"include": ["api"],
|
||||
"exclude": ["node_modules", "dist", ".tsbuild*", ".vercel"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import { AssetRecordType, TLAsset, getHashForString, truncateStringWithEllipsis } from 'tldraw'
|
||||
import { AssetRecordType, TLAsset, getHashForString } from 'tldraw'
|
||||
import { BOOKMARK_ENDPOINT } from './config'
|
||||
|
||||
interface ResponseBody {
|
||||
title?: string
|
||||
description?: string
|
||||
image?: string
|
||||
favicon?: string
|
||||
}
|
||||
|
||||
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,
|
||||
description: meta.description ?? '',
|
||||
image: meta.image ?? '',
|
||||
title: meta.title ?? truncateStringWithEllipsis(url, 32),
|
||||
favicon: meta.favicon ?? '',
|
||||
title: meta.title ?? '',
|
||||
},
|
||||
meta: {},
|
||||
}
|
||||
} catch (error) {
|
||||
// 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 {
|
||||
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')
|
||||
meta = {
|
||||
image: doc.head.querySelector('meta[property="og:image"]')?.getAttribute('content') ?? '',
|
||||
title:
|
||||
doc.head.querySelector('meta[property="og:title"]')?.getAttribute('content') ??
|
||||
truncateStringWithEllipsis(url, 32),
|
||||
favicon:
|
||||
doc.head.querySelector('link[rel="apple-touch-icon"]')?.getAttribute('href') ??
|
||||
doc.head.querySelector('link[rel="icon"]')?.getAttribute('href') ??
|
||||
'',
|
||||
title: doc.head.querySelector('meta[property="og:title"]')?.getAttribute('content') ?? '',
|
||||
description:
|
||||
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) {
|
||||
console.error(error)
|
||||
meta = { image: '', title: truncateStringWithEllipsis(url, 32), description: '' }
|
||||
meta = { image: '', favicon: '', title: '', description: '' }
|
||||
}
|
||||
|
||||
// Create the bookmark asset from the meta
|
||||
|
@ -68,6 +77,7 @@ export async function createAssetFromUrl({ url }: { type: 'url'; url: string }):
|
|||
props: {
|
||||
src: url,
|
||||
image: meta.image,
|
||||
favicon: meta.favicon,
|
||||
title: meta.title,
|
||||
description: meta.description,
|
||||
},
|
||||
|
|
|
@ -1,10 +1,6 @@
|
|||
import { AssetRecordType, TLAsset, TLExternalAssetContent, getHashForString } from 'tldraw'
|
||||
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({
|
||||
url,
|
||||
}: TLExternalAssetContent & { type: 'url' }): Promise<TLAsset> {
|
||||
|
@ -20,14 +16,15 @@ export async function onCreateAssetFromUrl({
|
|||
src: url,
|
||||
description: meta.description ?? '',
|
||||
image: meta.image ?? '',
|
||||
title: meta.title ?? truncateStringWithEllipsis(url, 32),
|
||||
favicon: meta.favicon ?? '',
|
||||
title: meta.title ?? '',
|
||||
},
|
||||
meta: {},
|
||||
}
|
||||
} catch (error) {
|
||||
// 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 {
|
||||
const resp = await fetch(url, {
|
||||
|
@ -39,15 +36,17 @@ export async function onCreateAssetFromUrl({
|
|||
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||
meta = {
|
||||
image: doc.head.querySelector('meta[property="og:image"]')?.getAttribute('content') ?? '',
|
||||
title:
|
||||
doc.head.querySelector('meta[property="og:title"]')?.getAttribute('content') ??
|
||||
truncateStringWithEllipsis(url, 32),
|
||||
favicon:
|
||||
doc.head.querySelector('link[rel="apple-touch-icon"]')?.getAttribute('href') ??
|
||||
doc.head.querySelector('link[rel="icon"]')?.getAttribute('href') ??
|
||||
'',
|
||||
title: doc.head.querySelector('meta[property="og:title"]')?.getAttribute('content') ?? '',
|
||||
description:
|
||||
doc.head.querySelector('meta[property="og:description"]')?.getAttribute('content') ?? '',
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
meta = { image: '', title: truncateStringWithEllipsis(url, 32), description: '' }
|
||||
meta = { image: '', favicon: '', title: '', description: '' }
|
||||
}
|
||||
|
||||
// Create the bookmark asset from the meta
|
||||
|
@ -58,6 +57,7 @@ export async function onCreateAssetFromUrl({
|
|||
props: {
|
||||
src: url,
|
||||
image: meta.image,
|
||||
favicon: meta.favicon,
|
||||
title: meta.title,
|
||||
description: meta.description,
|
||||
},
|
||||
|
|
|
@ -97,6 +97,7 @@ export class WebViewMessageHandler {
|
|||
title: json.title,
|
||||
description: json.description,
|
||||
image: json.image,
|
||||
favicon: json.favicon,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
|
@ -13,6 +13,7 @@ type BookmarkResponse = {
|
|||
title?: string
|
||||
description?: string
|
||||
image?: string
|
||||
favicon?: string
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -103,7 +103,7 @@
|
|||
"rimraf": "^4.4.0",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vercel": "^28.16.15"
|
||||
"vercel": "^34.2.4"
|
||||
},
|
||||
"// resolutions.canvas": [
|
||||
"our examples app depenends on pdf.js which pulls in canvas as an optional dependency.",
|
||||
|
|
|
@ -973,6 +973,7 @@ input,
|
|||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
border-bottom: 1px solid var(--color-muted-2);
|
||||
}
|
||||
|
||||
.tl-bookmark__copy_container {
|
||||
|
@ -991,27 +992,63 @@ input,
|
|||
|
||||
.tl-bookmark__heading {
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
font-weight: bold;
|
||||
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 {
|
||||
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 {
|
||||
font-size: 14px;
|
||||
font-size: 12px;
|
||||
pointer-events: all;
|
||||
z-index: var(--layer-bookmark-link);
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
color: var(--color-text);
|
||||
text-overflow: ellipsis;
|
||||
text-decoration: none;
|
||||
color: var(--color-text-1);
|
||||
display: flex;
|
||||
color: var(--color-text-2);
|
||||
align-items: center;
|
||||
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 ---------------- */
|
||||
|
@ -1056,9 +1093,9 @@ input,
|
|||
color: var(--color-selected);
|
||||
}
|
||||
|
||||
.tl-hyperlink-button__icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
.tl-hyperlink__icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: currentColor;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
|
|
@ -91,7 +91,6 @@ import { TLKeyboardEvent } from '@tldraw/editor';
|
|||
import { TLKeyboardEventInfo } from '@tldraw/editor';
|
||||
import { TLLineShape } from '@tldraw/editor';
|
||||
import { TLNoteShape } from '@tldraw/editor';
|
||||
import { TLOnBeforeCreateHandler } from '@tldraw/editor';
|
||||
import { TLOnBeforeUpdateHandler } from '@tldraw/editor';
|
||||
import { TLOnDoubleClickHandler } from '@tldraw/editor';
|
||||
import { TLOnEditEndHandler } from '@tldraw/editor';
|
||||
|
@ -261,7 +260,25 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
|
|||
// (undocumented)
|
||||
static migrations: TLPropsMigrations;
|
||||
// (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)
|
||||
onBeforeUpdate?: TLOnBeforeUpdateHandler<TLBookmarkShape>;
|
||||
// (undocumented)
|
||||
|
|
|
@ -24,7 +24,7 @@ import { TLUiToastsContextType } from './ui/context/toasts'
|
|||
import { useTranslation } from './ui/hooks/useTranslation/useTranslation'
|
||||
import { containBoxSize, downsizeImage } from './utils/assets/assets'
|
||||
import { getEmbedInfo } from './utils/embeds/embeds'
|
||||
import { cleanupText, isRightToLeftLanguage, truncateStringWithEllipsis } from './utils/text/text'
|
||||
import { cleanupText, isRightToLeftLanguage } from './utils/text/text'
|
||||
|
||||
/** @public */
|
||||
export interface TLExternalContentProps {
|
||||
|
@ -110,7 +110,7 @@ export function registerDefaultExternalContentHandlers(
|
|||
|
||||
// urls -> bookmark asset
|
||||
editor.registerExternalAssetHandler('url', async ({ url }) => {
|
||||
let meta: { image: string; title: string; description: string }
|
||||
let meta: { image: string; favicon: string; title: string; description: string }
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
|
@ -122,9 +122,11 @@ export function registerDefaultExternalContentHandlers(
|
|||
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||
meta = {
|
||||
image: doc.head.querySelector('meta[property="og:image"]')?.getAttribute('content') ?? '',
|
||||
title:
|
||||
doc.head.querySelector('meta[property="og:title"]')?.getAttribute('content') ??
|
||||
truncateStringWithEllipsis(url, 32),
|
||||
favicon:
|
||||
doc.head.querySelector('link[rel="apple-touch-icon"]')?.getAttribute('href') ??
|
||||
doc.head.querySelector('link[rel="icon"]')?.getAttribute('href') ??
|
||||
'',
|
||||
title: doc.head.querySelector('meta[property="og:title"]')?.getAttribute('content') ?? url,
|
||||
description:
|
||||
doc.head.querySelector('meta[property="og:description"]')?.getAttribute('content') ?? '',
|
||||
}
|
||||
|
@ -134,7 +136,7 @@ export function registerDefaultExternalContentHandlers(
|
|||
title: msg('assets.url.failed'),
|
||||
severity: 'error',
|
||||
})
|
||||
meta = { image: '', title: truncateStringWithEllipsis(url, 32), description: '' }
|
||||
meta = { image: '', favicon: '', title: '', description: '' }
|
||||
}
|
||||
|
||||
// Create the bookmark asset from the meta
|
||||
|
@ -146,6 +148,7 @@ export function registerDefaultExternalContentHandlers(
|
|||
src: url,
|
||||
description: meta.description,
|
||||
image: meta.image,
|
||||
favicon: meta.favicon,
|
||||
title: meta.title,
|
||||
},
|
||||
meta: {},
|
||||
|
|
|
@ -7,7 +7,6 @@ import {
|
|||
TLAssetId,
|
||||
TLBookmarkAsset,
|
||||
TLBookmarkShape,
|
||||
TLOnBeforeCreateHandler,
|
||||
TLOnBeforeUpdateHandler,
|
||||
bookmarkShapeMigrations,
|
||||
bookmarkShapeProps,
|
||||
|
@ -16,10 +15,15 @@ import {
|
|||
stopEventPropagation,
|
||||
toDomPrecision,
|
||||
} from '@tldraw/editor'
|
||||
import { truncateStringWithEllipsis } from '../../utils/text/text'
|
||||
import { HyperlinkButton } from '../shared/HyperlinkButton'
|
||||
import { LINK_ICON } from '../shared/icons-editor'
|
||||
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 */
|
||||
export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
|
||||
static override type = 'bookmark' as const
|
||||
|
@ -33,8 +37,8 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
|
|||
override getDefaultProps(): TLBookmarkShape['props'] {
|
||||
return {
|
||||
url: '',
|
||||
w: 300,
|
||||
h: 320,
|
||||
w: BOOKMARK_WIDTH,
|
||||
h: BOOKMARK_HEIGHT,
|
||||
assetId: null,
|
||||
}
|
||||
}
|
||||
|
@ -56,8 +60,9 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
|
|||
boxShadow: getRotatedBoxShadow(pageRotation),
|
||||
}}
|
||||
>
|
||||
{(!asset || asset.props.image) && (
|
||||
<div className="tl-bookmark__image_container">
|
||||
{asset?.props.image ? (
|
||||
{asset ? (
|
||||
<img
|
||||
className="tl-bookmark__image"
|
||||
draggable={false}
|
||||
|
@ -68,19 +73,18 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
|
|||
) : (
|
||||
<div className="tl-bookmark__placeholder" />
|
||||
)}
|
||||
{asset?.props.image && (
|
||||
<HyperlinkButton url={shape.props.url} zoomLevel={this.editor.getZoomLevel()} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="tl-bookmark__copy_container">
|
||||
{asset?.props.title && (
|
||||
<h2 className="tl-bookmark__heading">
|
||||
{truncateStringWithEllipsis(asset?.props.title || '', 54)}
|
||||
</h2>
|
||||
)}
|
||||
{asset?.props.description && (
|
||||
<p className="tl-bookmark__description">
|
||||
{truncateStringWithEllipsis(asset?.props.description || '', 128)}
|
||||
</p>
|
||||
)}
|
||||
{asset?.props.title ? (
|
||||
<h2 className="tl-bookmark__heading">{asset.props.title}</h2>
|
||||
) : null}
|
||||
{asset?.props.description && asset?.props.image ? (
|
||||
<p className="tl-bookmark__description">{asset.props.description}</p>
|
||||
) : null}
|
||||
<a
|
||||
className="tl-bookmark__link"
|
||||
href={shape.props.url || ''}
|
||||
|
@ -90,7 +94,23 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
|
|||
onPointerUp={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>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -109,8 +129,8 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
|
|||
)
|
||||
}
|
||||
|
||||
override onBeforeCreate?: TLOnBeforeCreateHandler<TLBookmarkShape> = (shape) => {
|
||||
updateBookmarkAssetOnUrlChange(this.editor, shape)
|
||||
override onBeforeCreate = (next: TLBookmarkShape) => {
|
||||
return getBookmarkSize(this.editor, next)
|
||||
}
|
||||
|
||||
override onBeforeUpdate?: TLOnBeforeUpdateHandler<TLBookmarkShape> = (prev, shape) => {
|
||||
|
@ -121,6 +141,36 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
|
|||
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) => {
|
||||
try {
|
||||
const url = new URL(shape.props.url)
|
||||
const path = url.pathname.replace(/\/*$/, '')
|
||||
return `${url.hostname}${path}`
|
||||
// we want the hostname without any www
|
||||
return url.hostname.replace(/^www\./, '')
|
||||
} catch (e) {
|
||||
return shape.props.url
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@ export function HyperlinkButton({ url, zoomLevel }: { url: string; zoomLevel: nu
|
|||
draggable={false}
|
||||
>
|
||||
<div
|
||||
className="tl-hyperlink-button__icon"
|
||||
className="tl-hyperlink__icon"
|
||||
style={{
|
||||
mask: `url("${LINK_ICON}") center 100% / 100% no-repeat`,
|
||||
WebkitMask: `url("${LINK_ICON}") center 100% / 100% no-repeat`,
|
||||
|
|
2
packages/tldraw/src/lib/shapes/shared/icons-editor.ts
Normal file
2
packages/tldraw/src/lib/shapes/shared/icons-editor.ts
Normal 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"
|
|
@ -16,7 +16,7 @@ export async function pasteUrl(
|
|||
point?: VecLike,
|
||||
sources?: TLExternalContentSource[]
|
||||
) {
|
||||
// Lets see if its an image and we have CORs
|
||||
// Lets see if its an image and we have CORS
|
||||
try {
|
||||
// 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)) {
|
||||
|
|
|
@ -81,12 +81,12 @@ describe('The URL formatter', () => {
|
|||
const e = editor.getShape<TLBookmarkShape>(ids.e)!
|
||||
const f = editor.getShape<TLBookmarkShape>(ids.f)!
|
||||
|
||||
expect(getHumanReadableAddress(a)).toBe('www.github.com')
|
||||
expect(getHumanReadableAddress(b)).toBe('www.github.com')
|
||||
expect(getHumanReadableAddress(c)).toBe('www.github.com/TodePond')
|
||||
expect(getHumanReadableAddress(d)).toBe('www.github.com/TodePond')
|
||||
expect(getHumanReadableAddress(e)).toBe('www.github.com')
|
||||
expect(getHumanReadableAddress(f)).toBe('www.github.com/TodePond/DreamBerd')
|
||||
expect(getHumanReadableAddress(a)).toBe('github.com')
|
||||
expect(getHumanReadableAddress(b)).toBe('github.com')
|
||||
expect(getHumanReadableAddress(c)).toBe('github.com')
|
||||
expect(getHumanReadableAddress(d)).toBe('github.com')
|
||||
expect(getHumanReadableAddress(e)).toBe('github.com')
|
||||
expect(getHumanReadableAddress(f)).toBe('github.com')
|
||||
})
|
||||
|
||||
it("Doesn't resize bookmarks", () => {
|
||||
|
|
|
@ -976,6 +976,7 @@ export type TLBindingUpdate<T extends TLBinding = TLBinding> = Expand<{
|
|||
// @public
|
||||
export type TLBookmarkAsset = TLBaseAsset<'bookmark', {
|
||||
description: string;
|
||||
favicon: string;
|
||||
image: string;
|
||||
src: null | string;
|
||||
title: string;
|
||||
|
|
|
@ -13,6 +13,7 @@ export type TLBookmarkAsset = TLBaseAsset<
|
|||
title: string
|
||||
description: string
|
||||
image: string
|
||||
favicon: string
|
||||
src: string | null
|
||||
}
|
||||
>
|
||||
|
@ -24,12 +25,14 @@ export const bookmarkAssetValidator: T.Validator<TLBookmarkAsset> = createAssetV
|
|||
title: T.string,
|
||||
description: T.string,
|
||||
image: T.string,
|
||||
favicon: T.string,
|
||||
src: T.srcUrl.nullable(),
|
||||
})
|
||||
)
|
||||
|
||||
const Versions = createMigrationIds('com.tldraw.asset.bookmark', {
|
||||
MakeUrlsValid: 1,
|
||||
AddFavicon: 2,
|
||||
} as const)
|
||||
|
||||
export { Versions as bookmarkAssetVersions }
|
||||
|
@ -51,5 +54,16 @@ export const bookmarkAssetMigrations = createRecordMigrationSequence({
|
|||
// noop
|
||||
},
|
||||
},
|
||||
{
|
||||
id: Versions.AddFavicon,
|
||||
up: (asset: any) => {
|
||||
if (!T.srcUrl.isValid(asset.props.favicon)) {
|
||||
asset.props.favicon = ''
|
||||
}
|
||||
},
|
||||
down: (asset: any) => {
|
||||
delete asset.props.favicon
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
|
@ -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', () => {
|
||||
const { up, down } = getTestMigration(instanceVersions.AddDuplicateProps)
|
||||
it('up works as expected', () => {
|
||||
|
|
Loading…
Reference in a new issue