@tldraw/tlschema
Version:
tldraw infinite canvas SDK (schema).
387 lines (372 loc) • 9.73 kB
text/typescript
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'],
canEditWhileLocked: true,
fromEmbedUrl: (url: string) => {
const urlObj = safeParseUrl(url)
if (urlObj && urlObj.pathname.match(TLDRAW_APP_RE)) {
return url
}
return
},
},
{
hostnames: ['figma.com'],
canEditWhileLocked: true,
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.*'],
canEditWhileLocked: true,
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'],
canEditWhileLocked: true,
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'],
canEditWhileLocked: true,
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'],
canEditWhileLocked: true,
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'],
canEditWhileLocked: true,
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'],
canEditWhileLocked: true,
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.*'],
canEditWhileLocked: true,
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.*'],
canEditWhileLocked: true,
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'],
canEditWhileLocked: true,
fromEmbedUrl: (url: string) => {
const urlObj = safeParseUrl(url)
if (urlObj && urlObj.pathname.match(/\/([^/]+)\/([^/]+)/)) {
if (!url.split('/').pop()) return
return url
}
return
},
},
{
hostnames: ['replit.com'],
canEditWhileLocked: true,
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'],
canEditWhileLocked: true,
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'],
canEditWhileLocked: true,
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'],
canEditWhileLocked: true,
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: ['observablehq.com'],
canEditWhileLocked: true,
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'],
canEditWhileLocked: true,
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
},
},
]
/**
* Properties for the embed shape, which displays embedded content from external services.
*
* @public
*/
export interface TLEmbedShapeProps {
/** Width of the embed shape in pixels */
w: number
/** Height of the embed shape in pixels */
h: number
/** URL of the content to embed (supports YouTube, Figma, CodePen, etc.) */
url: string
}
/**
* An embed shape displays external content like YouTube videos, Figma designs, CodePen demos,
* and other embeddable content within the tldraw canvas.
*
* @public
* @example
* ```ts
* const embedShape: TLEmbedShape = {
* id: createShapeId(),
* typeName: 'shape',
* type: 'embed',
* x: 200,
* y: 200,
* rotation: 0,
* index: 'a1',
* parentId: 'page:page1',
* isLocked: false,
* opacity: 1,
* props: {
* w: 560,
* h: 315,
* url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
* },
* meta: {}
* }
* ```
*/
export type TLEmbedShape = TLBaseShape<'embed', TLEmbedShapeProps>
/**
* Validation schema for embed shape properties.
*
* @public
* @example
* ```ts
* // Validate embed shape properties
* const isValidUrl = embedShapeProps.url.isValid('https://youtube.com/watch?v=abc123')
* const isValidSize = embedShapeProps.w.isValid(560)
* ```
*/
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,
})
/**
* Version identifiers for embed shape migrations.
*
* @public
*/
export { Versions as embedShapeVersions }
/**
* Migration sequence for embed shape properties across different schema versions.
* Handles URL transformations and removal of deprecated properties.
*
* @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',
},
],
})