tldraw
Version:
A tiny little drawing editor.
639 lines (638 loc) • 22.3 kB
JavaScript
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