UNPKG

tldraw

Version:

A tiny little drawing editor.

617 lines (616 loc) • 23.3 kB
"use strict"; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); var defaultExternalContentHandlers_exports = {}; __export(defaultExternalContentHandlers_exports, { DEFAULT_MAX_ASSET_SIZE: () => DEFAULT_MAX_ASSET_SIZE, DEFAULT_MAX_IMAGE_DIMENSION: () => DEFAULT_MAX_IMAGE_DIMENSION, centerSelectionAroundPoint: () => centerSelectionAroundPoint, createEmptyBookmarkShape: () => createEmptyBookmarkShape, createShapesForAssets: () => createShapesForAssets, defaultHandleExternalEmbedContent: () => defaultHandleExternalEmbedContent, defaultHandleExternalExcalidrawContent: () => defaultHandleExternalExcalidrawContent, defaultHandleExternalFileAsset: () => defaultHandleExternalFileAsset, defaultHandleExternalFileContent: () => defaultHandleExternalFileContent, defaultHandleExternalFileReplaceContent: () => defaultHandleExternalFileReplaceContent, defaultHandleExternalSvgTextContent: () => defaultHandleExternalSvgTextContent, defaultHandleExternalTextContent: () => defaultHandleExternalTextContent, defaultHandleExternalTldrawContent: () => defaultHandleExternalTldrawContent, defaultHandleExternalUrlAsset: () => defaultHandleExternalUrlAsset, defaultHandleExternalUrlContent: () => defaultHandleExternalUrlContent, getAssetInfo: () => getAssetInfo, getMediaAssetInfoPartial: () => getMediaAssetInfoPartial, notifyIfFileNotAllowed: () => notifyIfFileNotAllowed, registerDefaultExternalContentHandlers: () => registerDefaultExternalContentHandlers }); module.exports = __toCommonJS(defaultExternalContentHandlers_exports); var import_editor = require("@tldraw/editor"); var import_bookmarks = require("./shapes/bookmark/bookmarks"); var import_crop = require("./shapes/shared/crop"); var import_default_shape_constants = require("./shapes/shared/default-shape-constants"); var import_assets = require("./utils/assets/assets"); var import_putExcalidrawContent = require("./utils/excalidraw/putExcalidrawContent"); var import_richText = require("./utils/text/richText"); var import_text = require("./utils/text/text"); 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) (0, import_editor.assert)(false, "File checks failed"); const assetInfo = await getAssetInfo(file, options, assetId); const result = await editor.uploadAsset(assetInfo, file); assetInfo.props.src = result.src; if (result.meta) assetInfo.meta = { ...assetInfo.meta, ...result.meta }; return import_editor.AssetRecordType.create(assetInfo); } async function defaultHandleExternalFileReplaceContent(editor, { file, shapeId, isImage }, options) { const isSuccess = notifyIfFileNotAllowed(file, options); if (!isSuccess) (0, import_editor.assert)(false, "File checks failed"); const shape = editor.getShape(shapeId); if (!shape) (0, import_editor.assert)(false, "Shape not found"); const hash = (0, import_editor.getHashForBuffer)(await file.arrayBuffer()); const assetId = import_editor.AssetRecordType.createId(hash); editor.createTemporaryAssetPreview(assetId, file); const assetInfoPartial = await getMediaAssetInfoPartial( file, 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 = (0, import_crop.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, assetId }); editor.updateAssets([{ ...asset, id: assetId }]); return asset; } async function defaultHandleExternalUrlAsset(editor, { url }, { toasts, msg }) { let meta; try { const resp = await (0, import_editor.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: import_editor.AssetRecordType.createId((0, import_editor.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 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 = (0, import_editor.createShapeId)(); const newPoint = (0, import_editor.maybeSnapToGrid)( new import_editor.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 = import_editor.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 import_editor.Vec(position.x, position.y); const assetPartials = []; const assetsToUpdate = []; for (const file of files) { const isSuccess = notifyIfFileNotAllowed(file, options); if (!isSuccess) continue; const assetInfo = await getAssetInfo(file, options); if (acceptedImageMimeTypes.includes(file.type)) { editor.createTemporaryAssetPreview(assetInfo.id, file); } assetPartials.push(assetInfo); assetsToUpdate.push({ asset: assetInfo, file }); } 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 = (0, import_text.cleanupText)(text); const richTextToPaste = html ? (0, import_richText.renderRichTextFromHTML)(editor, html) : (0, import_editor.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 = (0, import_text.isRightToLeftLanguage)(cleanedUpPlaintext); if (isMultiLine) { align = isMultiLine ? isRtl ? "end" : "start" : "middle"; } const rawSize = editor.textMeasure.measureHtml(htmlToMeasure, { ...import_default_shape_constants.TEXT_PROPS, fontFamily: import_default_shape_constants.FONT_FAMILIES[defaultProps.font], fontSize: import_default_shape_constants.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, { ...import_default_shape_constants.TEXT_PROPS, fontFamily: import_default_shape_constants.FONT_FAMILIES[defaultProps.font], fontSize: import_default_shape_constants.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 = (0, import_editor.maybeSnapToGrid)(new import_editor.Vec(p.x - w / 2, p.y - h / 2), editor); const shapeId = (0, import_editor.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 (0, import_bookmarks.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(() => { (0, import_putExcalidrawContent.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 import_editor.MediaHelpers.getImageSize(file) : await import_editor.MediaHelpers.getVideoSize(file); const isAnimated = await import_editor.MediaHelpers.isAnimated(file) || isVideoType; 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 }, meta: {} }; if (maxImageDimension && isFinite(maxImageDimension)) { const size2 = { w: assetInfo.props.w, h: assetInfo.props.h }; const resizedSize = (0, import_assets.containBoxSize)(size2, { w: maxImageDimension, h: maxImageDimension }); if (size2 !== resizedSize && import_editor.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 = import_editor.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: (0, import_editor.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: (0, import_editor.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 = import_editor.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 import_editor.Vec(selectionPageBounds.minX, selectionPageBounds.minY); const gridSnappedPoint = topLeft.clone().snapToGrid(gridSize); const delta = import_editor.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: (0, import_editor.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); } function notifyIfFileNotAllowed(file, options) { const { acceptedImageMimeTypes = import_editor.DEFAULT_SUPPORTED_IMAGE_TYPES, acceptedVideoMimeTypes = import_editor.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 = import_editor.DEFAULT_SUPPORTED_IMAGE_TYPES, acceptedVideoMimeTypes = import_editor.DEFAULT_SUPPORT_VIDEO_TYPES, maxImageDimension = DEFAULT_MAX_IMAGE_DIMENSION } = options; const isImageType = acceptedImageMimeTypes.includes(file.type); const isVideoType = acceptedVideoMimeTypes.includes(file.type); const hash = (0, import_editor.getHashForBuffer)(await file.arrayBuffer()); assetId ??= import_editor.AssetRecordType.createId(hash); const assetInfo = await getMediaAssetInfoPartial( file, assetId, isImageType, isVideoType, maxImageDimension ); return assetInfo; } //# sourceMappingURL=defaultExternalContentHandlers.js.map