UNPKG

@tldraw/tlschema

Version:

tldraw infinite canvas SDK (schema).

327 lines (312 loc) • 8.14 kB
import { safeParseUrl } from '@tldraw/utils' import { T } from '@tldraw/validate' import { createShapePropsMigrationIds, createShapePropsMigrationSequence } from '../records/TLShape' import { RecordProps } from '../recordsWithProps' import { TLBaseShape } from './TLBaseShape' // Only allow multiplayer embeds. If we add additional routes later for example '/help' this won't match const TLDRAW_APP_RE = /(^\/r\/[^/]+\/?$)/ const EMBED_DEFINITIONS = [ { hostnames: ['beta.tldraw.com', 'tldraw.com', 'localhost:3000'], fromEmbedUrl: (url: string) => { const urlObj = safeParseUrl(url) if (urlObj && urlObj.pathname.match(TLDRAW_APP_RE)) { return url } return }, }, { hostnames: ['figma.com'], fromEmbedUrl: (url: string) => { const urlObj = safeParseUrl(url) if (urlObj && urlObj.pathname.match(/^\/embed\/?$/)) { const outUrl = urlObj.searchParams.get('url') if (outUrl) { return outUrl } } return }, }, { hostnames: ['google.*'], fromEmbedUrl: (url: string) => { const urlObj = safeParseUrl(url) if (!urlObj) return const matches = urlObj.pathname.match(/^\/maps\/embed\/v1\/view\/?$/) if (matches && urlObj.searchParams.has('center') && urlObj.searchParams.get('zoom')) { const zoom = urlObj.searchParams.get('zoom') const [lat, lon] = urlObj.searchParams.get('center')!.split(',') return `https://www.google.com/maps/@${lat},${lon},${zoom}z` } return }, }, { hostnames: ['val.town'], fromEmbedUrl: (url: string) => { const urlObj = safeParseUrl(url) // e.g. extract "steveruizok/mathFact" from https://www.val.town/v/steveruizok/mathFact const matches = urlObj && urlObj.pathname.match(/\/embed\/(.+)\/?/) if (matches) { return `https://www.val.town/v/${matches[1]}` } return }, }, { hostnames: ['codesandbox.io'], fromEmbedUrl: (url: string) => { const urlObj = safeParseUrl(url) const matches = urlObj && urlObj.pathname.match(/\/embed\/([^/]+)\/?/) if (matches) { return `https://codesandbox.io/s/${matches[1]}` } return }, }, { hostnames: ['codepen.io'], fromEmbedUrl: (url: string) => { const CODEPEN_EMBED_REGEXP = /https:\/\/codepen.io\/([^/]+)\/embed\/([^/]+)/ const matches = url.match(CODEPEN_EMBED_REGEXP) if (matches) { const [_, user, id] = matches return `https://codepen.io/${user}/pen/${id}` } return }, }, { hostnames: ['scratch.mit.edu'], fromEmbedUrl: (url: string) => { const SCRATCH_EMBED_REGEXP = /https:\/\/scratch.mit.edu\/projects\/embed\/([^/]+)/ const matches = url.match(SCRATCH_EMBED_REGEXP) if (matches) { const [_, id] = matches return `https://scratch.mit.edu/projects/${id}` } return }, }, { hostnames: ['*.youtube.com', 'youtube.com', 'youtu.be'], fromEmbedUrl: (url: string) => { const urlObj = safeParseUrl(url) if (!urlObj) return const hostname = urlObj.hostname.replace(/^www./, '') if (hostname === 'youtube.com') { const matches = urlObj.pathname.match(/^\/embed\/([^/]+)\/?/) if (matches) { return `https://www.youtube.com/watch?v=${matches[1]}` } } return }, }, { hostnames: ['calendar.google.*'], fromEmbedUrl: (url: string) => { const urlObj = safeParseUrl(url) const srcQs = urlObj?.searchParams.get('src') if (urlObj?.pathname.match(/\/calendar\/embed/) && srcQs) { urlObj.pathname = '/calendar/u/0' const keys = Array.from(urlObj.searchParams.keys()) for (const key of keys) { urlObj.searchParams.delete(key) } urlObj.searchParams.set('cid', srcQs) return urlObj.href } return }, }, { hostnames: ['docs.google.*'], fromEmbedUrl: (url: string) => { const urlObj = safeParseUrl(url) if (urlObj?.pathname.match(/^\/presentation/) && urlObj?.pathname.match(/\/embed\/?$/)) { urlObj.pathname = urlObj.pathname.replace(/\/embed$/, '/pub') const keys = Array.from(urlObj.searchParams.keys()) for (const key of keys) { urlObj.searchParams.delete(key) } return urlObj.href } return }, }, { hostnames: ['gist.github.com'], fromEmbedUrl: (url: string) => { const urlObj = safeParseUrl(url) if (urlObj && urlObj.pathname.match(/\/([^/]+)\/([^/]+)/)) { if (!url.split('/').pop()) return return url } return }, }, { hostnames: ['replit.com'], fromEmbedUrl: (url: string) => { const urlObj = safeParseUrl(url) if ( urlObj && urlObj.pathname.match(/\/@([^/]+)\/([^/]+)/) && urlObj.searchParams.has('embed') ) { urlObj.searchParams.delete('embed') return urlObj.href } return }, }, { hostnames: ['felt.com'], fromEmbedUrl: (url: string) => { const urlObj = safeParseUrl(url) if (urlObj && urlObj.pathname.match(/^\/embed\/map\//)) { urlObj.pathname = urlObj.pathname.replace(/^\/embed/, '') return urlObj.href } return }, }, { hostnames: ['open.spotify.com'], fromEmbedUrl: (url: string) => { const urlObj = safeParseUrl(url) if (urlObj && urlObj.pathname.match(/^\/embed\/(artist|album)\//)) { return urlObj.origin + urlObj.pathname.replace(/^\/embed/, '') } return }, }, { hostnames: ['vimeo.com', 'player.vimeo.com'], fromEmbedUrl: (url: string) => { const urlObj = safeParseUrl(url) if (urlObj && urlObj.hostname === 'player.vimeo.com') { const matches = urlObj.pathname.match(/^\/video\/([^/]+)\/?$/) if (matches) { return 'https://vimeo.com/' + matches[1] } } return }, }, { hostnames: ['excalidraw.com'], fromEmbedUrl: (url: string) => { const urlObj = safeParseUrl(url) if (urlObj && urlObj.hash.match(/#room=/)) { return url } return }, }, { hostnames: ['observablehq.com'], fromEmbedUrl: (url: string) => { const urlObj = safeParseUrl(url) if (urlObj && urlObj.pathname.match(/^\/embed\/@([^/]+)\/([^/]+)\/?$/)) { return `${urlObj.origin}${urlObj.pathname.replace('/embed', '')}#cell-*` } if (urlObj && urlObj.pathname.match(/^\/embed\/([^/]+)\/?$/)) { return `${urlObj.origin}${urlObj.pathname.replace('/embed', '/d')}#cell-*` } return }, }, { hostnames: ['desmos.com'], fromEmbedUrl: (url: string) => { const urlObj = safeParseUrl(url) if ( urlObj && urlObj.hostname === 'www.desmos.com' && urlObj.pathname.match(/^\/calculator\/([^/]+)\/?$/) && urlObj.search === '?embed' && urlObj.hash === '' ) { return url.replace('?embed', '') } return }, }, ] /** @public */ export interface TLEmbedShapeProps { w: number h: number url: string } /** @public */ export type TLEmbedShape = TLBaseShape<'embed', TLEmbedShapeProps> /** @public */ export const embedShapeProps: RecordProps<TLEmbedShape> = { w: T.nonZeroNumber, h: T.nonZeroNumber, url: T.string, } const Versions = createShapePropsMigrationIds('embed', { GenOriginalUrlInEmbed: 1, RemoveDoesResize: 2, RemoveTmpOldUrl: 3, RemovePermissionOverrides: 4, }) export { Versions as embedShapeVersions } /** @public */ export const embedShapeMigrations = createShapePropsMigrationSequence({ sequence: [ { id: Versions.GenOriginalUrlInEmbed, // add tmpOldUrl property up: (props) => { try { const url = props.url const host = new URL(url).host.replace('www.', '') let originalUrl for (const localEmbedDef of EMBED_DEFINITIONS) { if (localEmbedDef.hostnames.includes(host)) { try { originalUrl = localEmbedDef.fromEmbedUrl(url) } catch (err) { console.warn(err) } } } props.tmpOldUrl = props.url props.url = originalUrl ?? '' } catch { props.url = '' props.tmpOldUrl = props.url } }, down: 'retired', }, { id: Versions.RemoveDoesResize, up: (props) => { delete props.doesResize }, down: 'retired', }, { id: Versions.RemoveTmpOldUrl, up: (props) => { delete props.tmpOldUrl }, down: 'retired', }, { id: Versions.RemovePermissionOverrides, up: (props) => { delete props.overridePermissions }, down: 'retired', }, ], })