Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
387 lines
9.5 KiB
TypeScript
387 lines
9.5 KiB
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',
|
|
},
|
|
],
|
|
})
|