UNPKG

tldraw

Version:

A tiny little drawing editor.

639 lines (638 loc) • 22.3 kB
import { AssetRecordType, DEFAULT_SUPPORTED_IMAGE_TYPES, DEFAULT_SUPPORT_VIDEO_TYPES, MediaHelpers, Vec, assert, createShapeId, fetch, getHashForBuffer, getHashForString, maybeSnapToGrid, toRichText } from "@tldraw/editor"; import { createBookmarkFromUrl } from "./shapes/bookmark/bookmarks.mjs"; import { getCroppedImageDataForReplacedImage } from "./shapes/shared/crop.mjs"; import { FONT_FAMILIES, FONT_SIZES, TEXT_PROPS } from "./shapes/shared/default-shape-constants.mjs"; import { containBoxSize } from "./utils/assets/assets.mjs"; import { putExcalidrawContent } from "./utils/excalidraw/putExcalidrawContent.mjs"; import { renderRichTextFromHTML } from "./utils/text/richText.mjs"; import { cleanupText, isRightToLeftLanguage } from "./utils/text/text.mjs"; const DEFAULT_MAX_IMAGE_DIMENSION = 5e3; const DEFAULT_MAX_ASSET_SIZE = 10 * 1024 * 1024; function registerDefaultExternalContentHandlers(editor, options) { editor.registerExternalAssetHandler("file", async (externalAsset) => { return defaultHandleExternalFileAsset(editor, externalAsset, options); }); editor.registerExternalAssetHandler("url", async (externalAsset) => { return defaultHandleExternalUrlAsset(editor, externalAsset, options); }); editor.registerExternalContentHandler("svg-text", async (externalContent) => { return defaultHandleExternalSvgTextContent(editor, externalContent); }); editor.registerExternalContentHandler("embed", (externalContent) => { return defaultHandleExternalEmbedContent(editor, externalContent); }); editor.registerExternalContentHandler("files", async (externalContent) => { return defaultHandleExternalFileContent(editor, externalContent, options); }); editor.registerExternalContentHandler("file-replace", async (externalContent) => { return defaultHandleExternalFileReplaceContent(editor, externalContent, options); }); editor.registerExternalContentHandler("text", async (externalContent) => { return defaultHandleExternalTextContent(editor, externalContent); }); editor.registerExternalContentHandler("url", async (externalContent) => { return defaultHandleExternalUrlContent(editor, externalContent, options); }); editor.registerExternalContentHandler("tldraw", async (externalContent) => { return defaultHandleExternalTldrawContent(editor, externalContent); }); editor.registerExternalContentHandler("excalidraw", async (externalContent) => { return defaultHandleExternalExcalidrawContent(editor, externalContent); }); } async function defaultHandleExternalFileAsset(editor, { file, assetId }, options) { const isSuccess = notifyIfFileNotAllowed(file, options); if (!isSuccess) assert(false, "File checks failed"); const sanitizedFile = await maybeSanitizeSvgFile(file); if (!sanitizedFile) assert(false, "SVG file contained no safe content"); const assetInfo = await getAssetInfo(sanitizedFile, options, assetId); const result = await editor.uploadAsset(assetInfo, sanitizedFile); assetInfo.props.src = result.src; if (result.meta) assetInfo.meta = { ...assetInfo.meta, ...result.meta }; return AssetRecordType.create(assetInfo); } async function defaultHandleExternalFileReplaceContent(editor, { file, shapeId, isImage }, options) { const isSuccess = notifyIfFileNotAllowed(file, options); if (!isSuccess) assert(false, "File checks failed"); const sanitizedFile = await maybeSanitizeSvgFile(file); if (!sanitizedFile) return; const shape = editor.getShape(shapeId); if (!shape) assert(false, "Shape not found"); const hash = getHashForBuffer(await sanitizedFile.arrayBuffer()); const assetId = AssetRecordType.createId(hash); editor.createTemporaryAssetPreview(assetId, sanitizedFile); const assetInfoPartial = await getMediaAssetInfoPartial( sanitizedFile, assetId, isImage, !isImage /* isVideo */ ); editor.createAssets([assetInfoPartial]); if (shape.type === "image") { const imageShape = shape; const currentCrop = imageShape.props.crop; let newWidth = assetInfoPartial.props.w; let newHeight = assetInfoPartial.props.h; let newX = imageShape.x; let newY = imageShape.y; let finalCrop = currentCrop; if (currentCrop) { const result = getCroppedImageDataForReplacedImage( imageShape, assetInfoPartial.props.w, assetInfoPartial.props.h ); finalCrop = result.crop; newWidth = result.w; newHeight = result.h; newX = result.x; newY = result.y; } editor.updateShapes([ { id: imageShape.id, type: imageShape.type, props: { assetId, crop: finalCrop, w: newWidth, h: newHeight }, x: newX, y: newY } ]); } else if (shape.type === "video") { editor.updateShapes([ { id: shape.id, type: shape.type, props: { assetId, w: assetInfoPartial.props.w, h: assetInfoPartial.props.h } } ]); } const asset = await editor.getAssetForExternalContent({ type: "file", file: sanitizedFile, assetId }); editor.updateAssets([{ ...asset, id: assetId }]); return asset; } async function defaultHandleExternalUrlAsset(editor, { url }, { toasts, msg }) { let meta; try { const resp = await fetch(url, { method: "GET", mode: "no-cors" }); const html = await resp.text(); const doc = new DOMParser().parseFromString(html, "text/html"); meta = { image: doc.head.querySelector('meta[property="og:image"]')?.getAttribute("content") ?? "", favicon: doc.head.querySelector('link[rel="apple-touch-icon"]')?.getAttribute("href") ?? doc.head.querySelector('link[rel="icon"]')?.getAttribute("href") ?? "", title: doc.head.querySelector('meta[property="og:title"]')?.getAttribute("content") ?? url, description: doc.head.querySelector('meta[property="og:description"]')?.getAttribute("content") ?? "" }; if (!meta.image.startsWith("http")) { meta.image = new URL(meta.image, url).href; } if (!meta.favicon.startsWith("http")) { meta.favicon = new URL(meta.favicon, url).href; } } catch (error) { console.error(error); toasts.addToast({ title: msg("assets.url.failed"), severity: "error" }); meta = { image: "", favicon: "", title: "", description: "" }; } return { id: AssetRecordType.createId(getHashForString(url)), typeName: "asset", type: "bookmark", props: { src: url, description: meta.description, image: meta.image, favicon: meta.favicon, title: meta.title }, meta: {} }; } async function defaultHandleExternalSvgTextContent(editor, { point, text }) { const { sanitizeSvg } = await import("./utils/svg/sanitizeSvg.mjs"); text = sanitizeSvg(text); if (!text) return; const position = point ?? (editor.inputs.getShiftKey() ? editor.inputs.getCurrentPagePoint() : editor.getViewportPageBounds().center); const svg = new DOMParser().parseFromString(text, "image/svg+xml").querySelector("svg"); if (!svg) { throw new Error("No <svg/> element present"); } let width = parseFloat(svg.getAttribute("width") || "0"); let height = parseFloat(svg.getAttribute("height") || "0"); if (!(width && height)) { document.body.appendChild(svg); const box = svg.getBoundingClientRect(); document.body.removeChild(svg); width = box.width; height = box.height; } const asset = await editor.getAssetForExternalContent({ type: "file", file: new File([text], "asset.svg", { type: "image/svg+xml" }) }); if (!asset) throw Error("Could not create an asset"); createShapesForAssets(editor, [asset], position); } function defaultHandleExternalEmbedContent(editor, { point, url, embed }) { const position = point ?? (editor.inputs.getShiftKey() ? editor.inputs.getCurrentPagePoint() : editor.getViewportPageBounds().center); const { width, height } = embed; const id = createShapeId(); const newPoint = maybeSnapToGrid( new Vec(position.x - (width || 450) / 2, position.y - (height || 450) / 2), editor ); const shapePartial = { id, type: "embed", x: newPoint.x, y: newPoint.y, props: { w: width, h: height, url } }; if (editor.canCreateShape(shapePartial)) { editor.createShape(shapePartial).select(id); } } async function defaultHandleExternalFileContent(editor, { point, files }, options) { const { acceptedImageMimeTypes = DEFAULT_SUPPORTED_IMAGE_TYPES, toasts, msg } = options; if (files.length > editor.options.maxFilesAtOnce) { toasts.addToast({ title: msg("assets.files.amount-too-many"), severity: "error" }); return; } const position = point ?? (editor.inputs.getShiftKey() ? editor.inputs.getCurrentPagePoint() : editor.getViewportPageBounds().center); const pagePoint = new Vec(position.x, position.y); const assetPartials = []; const assetsToUpdate = []; for (const file of files) { const isSuccess = notifyIfFileNotAllowed(file, options); if (!isSuccess) continue; const sanitizedFile = await maybeSanitizeSvgFile(file); if (!sanitizedFile) { toasts.addToast({ title: msg("assets.files.upload-failed"), severity: "error" }); continue; } const assetInfo = await getAssetInfo(sanitizedFile, options); if (acceptedImageMimeTypes.includes(sanitizedFile.type)) { editor.createTemporaryAssetPreview(assetInfo.id, sanitizedFile); } assetPartials.push(assetInfo); assetsToUpdate.push({ asset: assetInfo, file: sanitizedFile }); } Promise.allSettled( assetsToUpdate.map(async (assetAndFile) => { try { const newAsset = await editor.getAssetForExternalContent({ type: "file", file: assetAndFile.file }); if (!newAsset) { throw Error("Could not create an asset"); } editor.updateAssets([{ ...newAsset, id: assetAndFile.asset.id }]); } catch (error) { toasts.addToast({ title: msg("assets.files.upload-failed"), severity: "error" }); console.error(error); editor.deleteAssets([assetAndFile.asset.id]); return; } }) ); createShapesForAssets(editor, assetPartials, pagePoint); } async function defaultHandleExternalTextContent(editor, { point, text, html }) { const p = point ?? (editor.inputs.getShiftKey() ? editor.inputs.getCurrentPagePoint() : editor.getViewportPageBounds().center); const defaultProps = editor.getShapeUtil("text").getDefaultProps(); const cleanedUpPlaintext = cleanupText(text); const richTextToPaste = html ? renderRichTextFromHTML(editor, html) : toRichText(cleanedUpPlaintext); let w; let h; let autoSize; let align = "middle"; const htmlToMeasure = html ?? cleanedUpPlaintext.replace(/\n/g, "<br>"); const isMultiLine = html ? richTextToPaste.content.length > 1 : cleanedUpPlaintext.split("\n").length > 1; const isRtl = isRightToLeftLanguage(cleanedUpPlaintext); if (isMultiLine) { align = isMultiLine ? isRtl ? "end" : "start" : "middle"; } const rawSize = editor.textMeasure.measureHtml(htmlToMeasure, { ...TEXT_PROPS, fontFamily: FONT_FAMILIES[defaultProps.font], fontSize: FONT_SIZES[defaultProps.size], maxWidth: null }); const minWidth = Math.min( isMultiLine ? editor.getViewportPageBounds().width * 0.9 : 920, Math.max(200, editor.getViewportPageBounds().width * 0.9) ); if (rawSize.w > minWidth) { const shrunkSize = editor.textMeasure.measureHtml(htmlToMeasure, { ...TEXT_PROPS, fontFamily: FONT_FAMILIES[defaultProps.font], fontSize: FONT_SIZES[defaultProps.size], maxWidth: minWidth }); w = shrunkSize.w; h = shrunkSize.h; autoSize = false; align = isRtl ? "end" : "start"; } else { w = Math.max(rawSize.w, 10); h = Math.max(rawSize.h, 10); autoSize = true; } if (p.y - h / 2 < editor.getViewportPageBounds().minY + 40) { p.y = editor.getViewportPageBounds().minY + 40 + h / 2; } const newPoint = maybeSnapToGrid(new Vec(p.x - w / 2, p.y - h / 2), editor); const shapeId = createShapeId(); editor.createShapes([ { id: shapeId, type: "text", x: newPoint.x, y: newPoint.y, props: { richText: richTextToPaste, // if the text has more than one line, align it to the left textAlign: align, autoSize, w } } ]); } async function defaultHandleExternalUrlContent(editor, { point, url }, { toasts, msg }) { const embedUtil = editor.getShapeUtil("embed"); const embedInfo = embedUtil?.getEmbedDefinition(url); if (embedInfo && embedInfo.definition.embedOnPaste !== false) { return editor.putExternalContent({ type: "embed", url: embedInfo.url, point, embed: embedInfo.definition }); } const position = point ?? (editor.inputs.getShiftKey() ? editor.inputs.getCurrentPagePoint() : editor.getViewportPageBounds().center); const result = await createBookmarkFromUrl(editor, { url, center: position }); if (!result.ok) { toasts.addToast({ title: msg("assets.url.failed"), severity: "error" }); return; } } async function defaultHandleExternalTldrawContent(editor, { point, content }) { editor.run(() => { const selectionBoundsBefore = editor.getSelectionPageBounds(); editor.markHistoryStoppingPoint("paste"); for (const shape of content.shapes) { if (content.rootShapeIds.includes(shape.id)) { shape.isLocked = false; } } editor.putContentOntoCurrentPage(content, { point, select: true }); const selectedBoundsAfter = editor.getSelectionPageBounds(); if (selectionBoundsBefore && selectedBoundsAfter && selectionBoundsBefore?.collides(selectedBoundsAfter)) { editor.updateInstanceState({ isChangingStyle: true }); editor.timers.setTimeout(() => { editor.updateInstanceState({ isChangingStyle: false }); }, 150); } }); } async function defaultHandleExternalExcalidrawContent(editor, { point, content }) { editor.run(() => { putExcalidrawContent(editor, content, point); }); } async function getMediaAssetInfoPartial(file, assetId, isImageType, isVideoType, maxImageDimension) { let fileType = file.type; if (file.type === "video/quicktime") { fileType = "video/mp4"; } const size = isImageType ? await MediaHelpers.getImageSize(file) : await MediaHelpers.getVideoSize(file); const isAnimated = (await MediaHelpers.isAnimated(file)) || isVideoType; const pixelRatio = "pixelRatio" in size && size.pixelRatio !== 1 ? size.pixelRatio : void 0; const assetInfo = { id: assetId, type: isImageType ? "image" : "video", typeName: "asset", props: { name: file.name, src: "", w: size.w, h: size.h, fileSize: file.size, mimeType: fileType, isAnimated, ...(isImageType && pixelRatio ? { pixelRatio } : void 0) }, meta: {} }; if (maxImageDimension && isFinite(maxImageDimension)) { const size2 = { w: assetInfo.props.w, h: assetInfo.props.h }; const resizedSize = containBoxSize(size2, { w: maxImageDimension, h: maxImageDimension }); if (size2 !== resizedSize && MediaHelpers.isStaticImageType(file.type)) { assetInfo.props.w = resizedSize.w; assetInfo.props.h = resizedSize.h; } } return assetInfo; } async function createShapesForAssets(editor, assets, position) { if (!assets.length) return []; const currentPoint = Vec.From(position); const partials = []; for (let i = 0; i < assets.length; i++) { const asset = assets[i]; switch (asset.type) { case "image": { partials.push({ id: createShapeId(), type: "image", x: currentPoint.x, y: currentPoint.y, opacity: 1, props: { assetId: asset.id, w: asset.props.w, h: asset.props.h } }); currentPoint.x += asset.props.w; break; } case "video": { partials.push({ id: createShapeId(), type: "video", x: currentPoint.x, y: currentPoint.y, opacity: 1, props: { assetId: asset.id, w: asset.props.w, h: asset.props.h } }); currentPoint.x += asset.props.w; } } } editor.run(() => { const assetsToCreate = assets.filter((asset) => !editor.getAsset(asset.id)); editor.store.atomic(() => { if (editor.canCreateShapes(partials)) { if (assetsToCreate.length) { editor.createAssets(assetsToCreate); } editor.createShapes(partials).select(...partials.map((p) => p.id)); centerSelectionAroundPoint(editor, position); } }); }); return partials.map((p) => p.id); } function centerSelectionAroundPoint(editor, position) { const viewportPageBounds = editor.getViewportPageBounds(); let selectionPageBounds = editor.getSelectionPageBounds(); if (selectionPageBounds) { const offset = selectionPageBounds.center.sub(position); editor.updateShapes( editor.getSelectedShapes().map((shape) => { const localRotation = editor.getShapeParentTransform(shape).decompose().rotation; const localDelta = Vec.Rot(offset, -localRotation); return { id: shape.id, type: shape.type, x: shape.x - localDelta.x, y: shape.y - localDelta.y }; }) ); } selectionPageBounds = editor.getSelectionPageBounds(); if (selectionPageBounds && editor.getInstanceState().isGridMode) { const gridSize = editor.getDocumentSettings().gridSize; const topLeft = new Vec(selectionPageBounds.minX, selectionPageBounds.minY); const gridSnappedPoint = topLeft.clone().snapToGrid(gridSize); const delta = Vec.Sub(topLeft, gridSnappedPoint); editor.updateShapes( editor.getSelectedShapes().map((shape) => { const newPoint = { x: shape.x - delta.x, y: shape.y - delta.y }; return { id: shape.id, type: shape.type, x: newPoint.x, y: newPoint.y }; }) ); } selectionPageBounds = editor.getSelectionPageBounds(); if (selectionPageBounds && !viewportPageBounds.contains(selectionPageBounds)) { editor.zoomToSelection({ animation: { duration: editor.options.animationMediumMs } }); } } function createEmptyBookmarkShape(editor, url, position) { const partial = { id: createShapeId(), type: "bookmark", x: position.x - 150, y: position.y - 160, opacity: 1, props: { assetId: null, url } }; editor.run(() => { editor.createShape(partial); if (!editor.getShape(partial.id)) return; editor.select(partial.id); centerSelectionAroundPoint(editor, position); }); return editor.getShape(partial.id); } async function maybeSanitizeSvgFile(file) { if (file.type !== "image/svg+xml") return file; try { const text = await file.text(); const { sanitizeSvg } = await import("./utils/svg/sanitizeSvg.mjs"); const sanitized = sanitizeSvg(text); if (!sanitized) return null; return new File([sanitized], file.name, { type: file.type, lastModified: file.lastModified }); } catch { return null; } } function notifyIfFileNotAllowed(file, options) { const { acceptedImageMimeTypes = DEFAULT_SUPPORTED_IMAGE_TYPES, acceptedVideoMimeTypes = DEFAULT_SUPPORT_VIDEO_TYPES, maxAssetSize = DEFAULT_MAX_ASSET_SIZE, toasts, msg } = options; const isImageType = acceptedImageMimeTypes.includes(file.type); const isVideoType = acceptedVideoMimeTypes.includes(file.type); if (!isImageType && !isVideoType) { toasts.addToast({ title: msg("assets.files.type-not-allowed"), severity: "error" }); return false; } if (file.size > maxAssetSize) { const formatBytes = (bytes) => { if (bytes === 0) return "0 bytes"; const units = ["bytes", "KB", "MB", "GB", "TB", "PB"]; const base = 1024; const unitIndex = Math.floor(Math.log(bytes) / Math.log(base)); const value = bytes / Math.pow(base, unitIndex); const formatted = value % 1 === 0 ? value.toString() : value.toFixed(1); return `${formatted} ${units[unitIndex]}`; }; toasts.addToast({ title: msg("assets.files.size-too-big"), description: msg("assets.files.maximum-size").replace("{size}", formatBytes(maxAssetSize)), severity: "error" }); return false; } if (!file.type) { toasts.addToast({ title: msg("assets.files.upload-failed"), severity: "error" }); console.error("No mime type"); return false; } return true; } async function getAssetInfo(file, options, assetId) { const { acceptedImageMimeTypes = DEFAULT_SUPPORTED_IMAGE_TYPES, acceptedVideoMimeTypes = DEFAULT_SUPPORT_VIDEO_TYPES, maxImageDimension = DEFAULT_MAX_IMAGE_DIMENSION } = options; const isImageType = acceptedImageMimeTypes.includes(file.type); const isVideoType = acceptedVideoMimeTypes.includes(file.type); const hash = getHashForBuffer(await file.arrayBuffer()); assetId ??= AssetRecordType.createId(hash); const assetInfo = await getMediaAssetInfoPartial( file, assetId, isImageType, isVideoType, maxImageDimension ); return assetInfo; } export { DEFAULT_MAX_ASSET_SIZE, DEFAULT_MAX_IMAGE_DIMENSION, centerSelectionAroundPoint, createEmptyBookmarkShape, createShapesForAssets, defaultHandleExternalEmbedContent, defaultHandleExternalExcalidrawContent, defaultHandleExternalFileAsset, defaultHandleExternalFileContent, defaultHandleExternalFileReplaceContent, defaultHandleExternalSvgTextContent, defaultHandleExternalTextContent, defaultHandleExternalTldrawContent, defaultHandleExternalUrlAsset, defaultHandleExternalUrlContent, getAssetInfo, getMediaAssetInfoPartial, notifyIfFileNotAllowed, registerDefaultExternalContentHandlers }; //# sourceMappingURL=defaultExternalContentHandlers.mjs.map