clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

main
diagramzip / renderers / edge / src / adapters / excalidraw.ts
excalidraw.ts 5.1K
1import { parseHTML } from 'linkedom'2import { RenderError } from '../runtime/errors'3import type { RendererAdapter } from '../runtime/types'4import { edgeFailure, edgeResult } from './types'56const VERSION = '@excalidraw/excalidraw@0.18.1'7const MAX_SOURCE_LENGTH = 524_2888const MAX_NODES = 10_000910let libraryPromise: Promise<typeof import('@excalidraw/excalidraw')> | undefined1112function installDomShim(): void {13 const root = globalThis as Record<string, any>14 const window = root.window?.document?.createElement15 ? root.window as Record<string, any>16 : (parseHTML('<!doctype html><html><head></head><body></body></html>') as any).window as Record<string, any>17 root.window = window18 root.document = window.document19 root.location = { origin: 'https://excalidraw.render.diagram.zip', href: 'https://excalidraw.render.diagram.zip/' }20 root.window.location = root.location21 root.navigator ??= { userAgent: 'diagramzip-edge' }22 root.devicePixelRatio ??= 123 for (const name of ['Element', 'Node', 'HTMLElement', 'SVGElement', 'HTMLCanvasElement', 'DOMParser', 'XMLSerializer', 'Event', 'CustomEvent']) {24 if (window[name]) root[name] = window[name]25 }26 const canvasContext = {27 filter: 'none',28 measureText: (text: unknown) => ({ width: String(text).length * 10 }),29 save() {}, restore() {}, scale() {}, translate() {}, rotate() {}, beginPath() {}, closePath() {},30 stroke() {}, fill() {}, clearRect() {}, setTransform() {}, drawImage() {}, fillText() {},31 }32 const originalCreateElement = window.document.createElement.bind(window.document)33 window.document.createElement = (tag: string, options?: unknown) => {34 if (tag.toLowerCase() === 'canvas') {35 return { width: 0, height: 0, style: {}, setAttribute() {}, getAttribute() { return null }, getContext() { return canvasContext }, toDataURL() { return 'data:image/png;base64,' }, addEventListener() {}, removeEventListener() {} }36 }37 return originalCreateElement(tag, options as any)38 }39 if (root.HTMLCanvasElement?.prototype) root.HTMLCanvasElement.prototype.getContext = () => canvasContext40 root.FontFace ??= class { family: string; source: string; unicodeRange = 'U+0-10FFFF'; style = 'normal'; weight = '400'; stretch = 'normal'; display = 'block'; status = 'loaded'; constructor(family: string, source: string, descriptors: Record<string, string> = {}) { this.family = family; this.source = source; Object.assign(this, descriptors) } async load() { return this } }41 root.document.fonts ??= { add() {}, delete() {}, check() { return true }, ready: Promise.resolve() }42 root.EXCALIDRAW_ASSET_PATH ??= 'https://excalidraw.render.diagram.zip/'43}4445function parsedScene(source: string): Record<string, unknown> {46 if (typeof source !== 'string' || new TextEncoder().encode(source).byteLength > MAX_SOURCE_LENGTH) throw new RenderError(413, 'source_too_large', 'Excalidraw source must be a JSON object no larger than 512 KiB.')47 let value: unknown48 try { value = JSON.parse(source) } catch { throw new RenderError(422, 'invalid_source', 'Excalidraw source must be valid JSON.') }49 if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new RenderError(422, 'invalid_source', 'Excalidraw source must be a JSON object.')50 const scene = value as Record<string, unknown>51 const elements = scene.elements52 if (!Array.isArray(elements) || elements.length > MAX_NODES) throw new RenderError(422, 'invalid_source', `Excalidraw elements must be an array with no more than ${MAX_NODES} items.`)53 rejectExternalResources(scene)54 return scene55}5657function rejectExternalResources(value: unknown): void {58 const pending = [value]59 while (pending.length > 0) {60 const current = pending.pop()61 if (typeof current !== 'object' || current === null) continue62 for (const [key, nested] of Object.entries(current)) {63 if (['url', 'src', 'href', 'link', 'dataURL'].includes(key) && typeof nested === 'string' && /^(?:https?:|blob:|file:|javascript:)/i.test(nested)) {64 throw new RenderError(422, 'unsafe_include', 'External Excalidraw resources and links are disabled; embed image data in the scene.')65 }66 if (typeof nested === 'object' && nested !== null) pending.push(nested)67 }68 }69}7071async function exportScene(scene: Record<string, unknown>): Promise<string> {72 installDomShim()73 libraryPromise ??= import('@excalidraw/excalidraw')74 const { exportToSvg } = await libraryPromise75 const svg = await exportToSvg({76 elements: scene.elements as Parameters<typeof exportToSvg>[0]['elements'],77 appState: scene.appState as Parameters<typeof exportToSvg>[0]['appState'],78 files: (scene.files ?? null) as Parameters<typeof exportToSvg>[0]['files'],79 exportPadding: 10,80 })81 return svg.outerHTML.replace(/<style\b[^>]*class="style-fonts"[^>]*>[\s\S]*?<\/style>/gi, '')82}8384export const excalidrawAdapter: RendererAdapter = {85 id: 'excalidraw',86 runtime: 'edge-js',87 version: VERSION,88 async render(request, signal) {89 if (signal.aborted) throw signal.reason90 try {91 const body = await exportScene(parsedScene(request.source))92 return edgeResult('excalidraw', VERSION, body)93 } catch (error) {94 return edgeFailure('excalidraw', error)95 }96 },97}