UNPKG

tldraw

Version:

A tiny little drawing editor.

468 lines (467 loc) • 15.7 kB
import { AssetRecordType, MediaHelpers, Vec, assert, createShapeId, fetch, getHashForBuffer, getHashForString } from "@tldraw/editor"; import { FONT_FAMILIES, FONT_SIZES, TEXT_PROPS } from "./shapes/shared/default-shape-constants.mjs"; import { containBoxSize } from "./utils/assets/assets.mjs"; import { cleanupText, isRightToLeftLanguage } from "./utils/text/text.mjs"; function registerDefaultExternalContentHandlers(editor, { maxImageDimension, maxAssetSize, acceptedImageMimeTypes, acceptedVideoMimeTypes }, { toasts, msg }) { editor.registerExternalAssetHandler("file", async ({ file, assetId }) => { 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" }); } assert(isImageType || isVideoType, `File type not allowed: ${file.type}`); if (file.size > maxAssetSize) { toasts.addToast({ title: msg("assets.files.size-too-big"), severity: "error" }); } assert( file.size <= maxAssetSize, `File size too big: ${(file.size / 1024).toFixed()}kb > ${(maxAssetSize / 1024).toFixed()}kb` ); const hash = getHashForBuffer(await file.arrayBuffer()); assetId = assetId ?? AssetRecordType.createId(hash); const assetInfo = await getMediaAssetInfoPartial(file, assetId, isImageType, isVideoType); if (isFinite(maxImageDimension)) { const size = { w: assetInfo.props.w, h: assetInfo.props.h }; const resizedSize = containBoxSize(size, { w: maxImageDimension, h: maxImageDimension }); if (size !== resizedSize && MediaHelpers.isStaticImageType(file.type)) { assetInfo.props.w = resizedSize.w; assetInfo.props.h = resizedSize.h; } } assetInfo.props.src = await editor.uploadAsset(assetInfo, file); return AssetRecordType.create(assetInfo); }); editor.registerExternalAssetHandler("url", async ({ url }) => { 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: {} }; }); editor.registerExternalContentHandler("svg-text", async ({ point, text }) => { const position = point ?? (editor.inputs.shiftKey ? editor.inputs.currentPagePoint : 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); }); editor.registerExternalContentHandler( "embed", ({ point, url, embed }) => { const position = point ?? (editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.getViewportPageBounds().center); const { width, height } = embed; const id = createShapeId(); const shapePartial = { id, type: "embed", x: position.x - (width || 450) / 2, y: position.y - (height || 450) / 2, props: { w: width, h: height, url } }; editor.createShapes([shapePartial]).select(id); } ); editor.registerExternalContentHandler("files", async ({ point, files }) => { if (files.length > editor.options.maxFilesAtOnce) { throw Error("Too many files"); } const position = point ?? (editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.getViewportPageBounds().center); const pagePoint = new Vec(position.x, position.y); const assetsToUpdate = []; for (const file of files) { if (file.size > maxAssetSize) { toasts.addToast({ title: msg("assets.files.size-too-big"), severity: "error" }); console.warn( `File size too big: ${(file.size / 1024).toFixed()}kb > ${(maxAssetSize / 1024).toFixed()}kb` ); continue; } if (!file.type) { toasts.addToast({ title: msg("assets.files.upload-failed"), severity: "error" }); console.error("No mime type"); continue; } if (!acceptedImageMimeTypes.concat(acceptedVideoMimeTypes).includes(file.type)) { toasts.addToast({ title: msg("assets.files.type-not-allowed"), severity: "error" }); console.warn(`${file.name} not loaded - Mime type not allowed ${file.type}.`); continue; } const isImageType = acceptedImageMimeTypes.includes(file.type); const isVideoType = acceptedVideoMimeTypes.includes(file.type); const hash = getHashForBuffer(await file.arrayBuffer()); const assetId = AssetRecordType.createId(hash); const assetInfo = await getMediaAssetInfoPartial(file, assetId, isImageType, isVideoType); let temporaryAssetPreview; if (isImageType) { temporaryAssetPreview = editor.createTemporaryAssetPreview(assetId, file); } assetsToUpdate.push({ asset: assetInfo, file, temporaryAssetPreview }); } const assets = []; await 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"); } const updated = { ...newAsset, id: assetAndFile.asset.id }; assets.push(updated); editor.updateAssets([updated]); } catch (error) { toasts.addToast({ title: msg("assets.files.upload-failed"), severity: "error" }); console.error(error); return; } }) ); createShapesForAssets(editor, assets, pagePoint); }); editor.registerExternalContentHandler("text", async ({ point, text }) => { const p = point ?? (editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.getViewportPageBounds().center); const defaultProps = editor.getShapeUtil("text").getDefaultProps(); const textToPaste = cleanupText(text); const onlySelectedShape = editor.getOnlySelectedShape(); if (onlySelectedShape && "text" in onlySelectedShape.props) { editor.updateShapes([ { id: onlySelectedShape.id, type: onlySelectedShape.type, props: { text: textToPaste } } ]); return; } let w; let h; let autoSize; let align = "middle"; const isMultiLine = textToPaste.split("\n").length > 1; const isRtl = isRightToLeftLanguage(textToPaste); if (isMultiLine) { align = isMultiLine ? isRtl ? "end" : "start" : "middle"; } const rawSize = editor.textMeasure.measureText(textToPaste, { ...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.measureText(textToPaste, { ...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 = rawSize.w; h = rawSize.h; autoSize = true; } if (p.y - h / 2 < editor.getViewportPageBounds().minY + 40) { p.y = editor.getViewportPageBounds().minY + 40 + h / 2; } editor.createShapes([ { id: createShapeId(), type: "text", x: p.x - w / 2, y: p.y - h / 2, props: { text: textToPaste, // if the text has more than one line, align it to the left textAlign: align, autoSize, w } } ]); }); editor.registerExternalContentHandler("url", async ({ point, url }) => { const embedUtil = editor.getShapeUtil("embed"); const embedInfo = embedUtil?.getEmbedDefinition(url); if (embedInfo) { return editor.putExternalContent({ type: "embed", url: embedInfo.url, point, embed: embedInfo.definition }); } const position = point ?? (editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.getViewportPageBounds().center); const assetId = AssetRecordType.createId(getHashForString(url)); const shape = createEmptyBookmarkShape(editor, url, position); let asset = editor.getAsset(assetId); let shouldAlsoCreateAsset = false; if (!asset) { shouldAlsoCreateAsset = true; try { const bookmarkAsset = await editor.getAssetForExternalContent({ type: "url", url }); if (!bookmarkAsset) throw Error("Could not create an asset"); asset = bookmarkAsset; } catch { toasts.addToast({ title: msg("assets.url.failed"), severity: "error" }); return; } } editor.run(() => { if (shouldAlsoCreateAsset) { editor.createAssets([asset]); } editor.updateShapes([ { id: shape.id, type: shape.type, props: { assetId: asset.id } } ]); }); }); } async function getMediaAssetInfoPartial(file, assetId, isImageType, isVideoType) { 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 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: {} }; 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)); 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.createShapes([partial]).select(partial.id); centerSelectionAroundPoint(editor, position); }); return editor.getShape(partial.id); } export { centerSelectionAroundPoint, createEmptyBookmarkShape, createShapesForAssets, getMediaAssetInfoPartial, registerDefaultExternalContentHandlers }; //# sourceMappingURL=defaultExternalContentHandlers.mjs.map