tldraw
Version:
A tiny little drawing editor.
617 lines (616 loc) • 23.3 kB
JavaScript
"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