/* eslint-disable react-hooks/rules-of-hooks */ import { BaseBoxShapeUtil, HTMLContainer, TLEmbedShape, TLEmbedShapeProps, TLResizeInfo, embedShapeMigrations, embedShapeProps, lerp, resizeBox, toDomPrecision, useColorMode, useIsEditing, useSvgExportContext, useValue, } from '@tldraw/editor' import { DEFAULT_EMBED_DEFINITIONS, EmbedDefinition, TLEmbedDefinition, TLEmbedShapePermissions, embedShapePermissionDefaults, unknownEmbedShapePermissionOverrides, } from '../../defaultEmbedDefinitions' import { TLEmbedResult, getEmbedInfo } from '../../utils/embeds/embeds' import { BookmarkShapeComponent } from '../bookmark/BookmarkShapeUtil' import { ShapeOptionsWithDisplayValues, getDisplayValues } from '../shared/getDisplayValues' import { getRotatedBoxShadow } from '../shared/rotated-box-shadow' /** @public */ export interface EmbedShapeUtilDisplayValues { showShadow: boolean } /** @public */ export interface EmbedShapeOptions extends ShapeOptionsWithDisplayValues< TLEmbedShape, EmbedShapeUtilDisplayValues > { /** The embed definitions to use for this shape util. */ readonly embedDefinitions: readonly TLEmbedDefinition[] } const getSandboxPermissions = (permissions: TLEmbedShapePermissions) => { return Object.entries(permissions) .filter(([_perm, isEnabled]) => isEnabled) .map(([perm]) => perm) .join(' ') } /** @public */ export class EmbedShapeUtil extends BaseBoxShapeUtil { static override type = 'embed' as const static override props = embedShapeProps static override migrations = embedShapeMigrations override options: EmbedShapeOptions = { embedDefinitions: DEFAULT_EMBED_DEFINITIONS, getDefaultDisplayValues(): EmbedShapeUtilDisplayValues { return { showShadow: true, } }, getCustomDisplayValues(): Partial { return {} }, } override canEditWhileLocked(shape: TLEmbedShape) { const result = this.getEmbedDefinition(shape.props.url) if (!result) return true return result.definition.canEditWhileLocked ?? true } private static legacyEmbedDefinitions: readonly EmbedDefinition[] | null = null /** @deprecated - Use `EmbedShapeUtil.configure({ embedDefinitions: [...] })` instead. */ static setEmbedDefinitions(embedDefinitions: readonly EmbedDefinition[]) { EmbedShapeUtil.legacyEmbedDefinitions = embedDefinitions } private getEmbedDefs(): readonly TLEmbedDefinition[] { return EmbedShapeUtil.legacyEmbedDefinitions ?? this.options.embedDefinitions } getEmbedDefinitions(): readonly TLEmbedDefinition[] { return this.getEmbedDefs() } getEmbedDefinition(url: string): TLEmbedResult { return getEmbedInfo(this.getEmbedDefs(), url) } override getText(shape: TLEmbedShape) { return shape.props.url } override getAriaDescriptor(shape: TLEmbedShape) { const embedInfo = this.getEmbedDefinition(shape.props.url) return embedInfo?.definition.title } override hideSelectionBoundsFg(shape: TLEmbedShape) { return !this.canResize(shape) } override canEdit(shape: TLEmbedShape) { return true } override canResize(shape: TLEmbedShape) { return this.getEmbedDefinition(shape.props.url)?.definition?.doesResize ?? true } override canEditInReadonly(shape: TLEmbedShape) { return true } override getDefaultProps(): TLEmbedShape['props'] { return { w: 300, h: 300, url: '', } } override getGeometry(shape: TLEmbedShape) { return super.getGeometry(shape) } override isAspectRatioLocked(shape: TLEmbedShape) { const embedInfo = this.getEmbedDefinition(shape.props.url) return embedInfo?.definition.isAspectRatioLocked ?? false } override onResize(shape: TLEmbedShape, info: TLResizeInfo) { const isAspectRatioLocked = this.isAspectRatioLocked(shape) const embedInfo = this.getEmbedDefinition(shape.props.url) let minWidth = embedInfo?.definition.minWidth ?? 200 let minHeight = embedInfo?.definition.minHeight ?? 200 if (isAspectRatioLocked) { // Enforce aspect ratio // Neither the width or height can be less than 200 const aspectRatio = shape.props.w / shape.props.h if (aspectRatio > 1) { // Landscape minWidth *= aspectRatio } else { // Portrait minHeight /= aspectRatio } } return resizeBox(shape, info, { minWidth, minHeight }) } override component(shape: TLEmbedShape) { const svgExport = useSvgExportContext() const { w, h, url } = shape.props const isEditing = useIsEditing(shape.id) const colorMode = useColorMode() const dv = getDisplayValues(this, shape, colorMode) const embedInfo = this.getEmbedDefinition(url) const isHoveringWhileEditingSameShape = useValue( 'is hovering', () => { const { editingShapeId, hoveredShapeId } = this.editor.getCurrentPageState() if (editingShapeId && hoveredShapeId !== editingShapeId) { const editingShape = this.editor.getShape(editingShapeId) if (editingShape && this.editor.isShapeOfType(editingShape, 'embed')) { return true } } return false }, [] ) const pageRotation = this.editor.getShapePageTransform(shape)!.rotation() if (svgExport) { // for SVG exports, we show a blank embed return (
) } const isInteractive = isEditing || isHoveringWhileEditingSameShape // Prevent nested embedding of tldraw const isIframe = typeof window !== 'undefined' && (window !== window.top || window.self !== window.parent) if (isIframe && embedInfo?.definition.type === 'tldraw') return null const sandbox = getSandboxPermissions({ ...embedShapePermissionDefaults, ...(embedInfo ? (embedInfo.definition.overridePermissions ?? {}) : unknownEmbedShapePermissionOverrides), }) if (embedInfo?.definition.type === 'github_gist') { const idFromGistUrl = embedInfo.url.split('/').pop() if (!idFromGistUrl) throw Error('No gist id!') // Gist embeds use srcDoc, so we must disable allow-same-origin. Otherwise // the embedded script shares the parent's origin and can escape the sandbox. const gistSandbox = getSandboxPermissions({ ...embedShapePermissionDefaults, ...(embedInfo?.definition?.overridePermissions ?? {}), 'allow-same-origin': false, }) return ( ) } const iframeSrc = embedInfo?.embedUrl ?? url return ( {iframeSrc ? (