tldraw
Version:
A tiny little drawing editor.
564 lines (563 loc) • 20.2 kB
JavaScript
;
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
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 __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var useClipboardEvents_exports = {};
__export(useClipboardEvents_exports, {
isValidHttpURL: () => isValidHttpURL,
useMenuClipboardEvents: () => useMenuClipboardEvents,
useNativeClipboardEvents: () => useNativeClipboardEvents
});
module.exports = __toCommonJS(useClipboardEvents_exports);
var import_editor = require("@tldraw/editor");
var import_lz_string = __toESM(require("lz-string"), 1);
var import_react = require("react");
var import_clipboard = require("../../utils/clipboard");
var import_events = require("../context/events");
var import_pasteFiles = require("./clipboard/pasteFiles");
var import_pasteUrl = require("./clipboard/pasteUrl");
const expectedPasteFileMimeTypes = [
import_clipboard.TLDRAW_CUSTOM_PNG_MIME_TYPE,
"image/png",
"image/jpeg",
"image/webp",
"image/svg+xml"
];
function stripHtml(html) {
const doc = document.implementation.createHTMLDocument("");
doc.documentElement.innerHTML = html.trim();
return doc.body.textContent || doc.body.innerText || "";
}
const isValidHttpURL = (url) => {
try {
const u = new URL(url);
return u.protocol === "http:" || u.protocol === "https:";
} catch {
return false;
}
};
const getValidHttpURLList = (url) => {
const urls = url.split(/[\n\s]/);
for (const url2 of urls) {
try {
const u = new URL(url2);
if (!(u.protocol === "http:" || u.protocol === "https:")) {
return;
}
} catch {
return;
}
}
return (0, import_editor.uniq)(urls);
};
const isSvgText = (text) => {
return /^<svg/.test(text);
};
function areShortcutsDisabled(editor) {
return editor.menus.hasAnyOpenMenus() || (0, import_editor.activeElementShouldCaptureKeys)(false);
}
const handleText = (editor, data, point, sources) => {
const validUrlList = getValidHttpURLList(data);
if (validUrlList) {
for (const url of validUrlList) {
(0, import_pasteUrl.pasteUrl)(editor, url, point);
}
} else if (isValidHttpURL(data)) {
(0, import_pasteUrl.pasteUrl)(editor, data, point);
} else if (isSvgText(data)) {
editor.markHistoryStoppingPoint("paste");
editor.putExternalContent({
type: "svg-text",
text: data,
point,
sources
});
} else {
editor.markHistoryStoppingPoint("paste");
editor.putExternalContent({
type: "text",
text: data,
point,
sources
});
}
};
const handlePasteFromEventClipboardData = async (editor, clipboardData, point) => {
if (editor.getEditingShapeId() !== null) return;
if (!clipboardData) {
throw Error("No clipboard data");
}
const things = [];
for (const item of Object.values(clipboardData.items)) {
switch (item.kind) {
case "file": {
things.push({
type: "file",
source: new Promise((r) => r(item.getAsFile()))
});
break;
}
case "string": {
if (item.type === "text/html") {
things.push({
type: "html",
source: new Promise((r) => item.getAsString(r))
});
} else if (item.type === "text/plain") {
things.push({
type: "text",
source: new Promise((r) => item.getAsString(r))
});
} else {
things.push({ type: item.type, source: new Promise((r) => item.getAsString(r)) });
}
break;
}
}
}
handleClipboardThings(editor, things, point);
};
const handlePasteFromClipboardApi = async ({
editor,
clipboardItems,
point,
fallbackFiles
}) => {
const things = [];
for (const item of clipboardItems) {
for (const type of expectedPasteFileMimeTypes) {
if (item.types.includes(type)) {
const blobPromise = item.getType(type).then((blob) => import_editor.FileHelpers.rewriteMimeType(blob, (0, import_clipboard.getCanonicalClipboardReadType)(type)));
things.push({
type: "blob",
source: blobPromise
});
break;
}
}
if (item.types.includes("text/html")) {
things.push({
type: "html",
source: (async () => {
const blob = await item.getType("text/html");
return await import_editor.FileHelpers.blobToText(blob);
})()
});
}
if (item.types.includes("text/uri-list")) {
things.push({
type: "url",
source: (async () => {
const blob = await item.getType("text/uri-list");
return await import_editor.FileHelpers.blobToText(blob);
})()
});
}
if (item.types.includes("text/plain")) {
things.push({
type: "text",
source: (async () => {
const blob = await item.getType("text/plain");
return await import_editor.FileHelpers.blobToText(blob);
})()
});
}
}
if (fallbackFiles?.length && things.length === 1 && things[0].type === "text") {
things.pop();
things.push(
...fallbackFiles.map((f) => ({ type: "file", source: Promise.resolve(f) }))
);
} else if (fallbackFiles?.length && things.length === 0) {
things.push(
...fallbackFiles.map((f) => ({ type: "file", source: Promise.resolve(f) }))
);
}
return await handleClipboardThings(editor, things, point);
};
async function handleClipboardThings(editor, things, point) {
const files = things.filter(
(t) => (t.type === "file" || t.type === "blob") && t.source !== null
);
if (files.length) {
if (files.length > editor.options.maxFilesAtOnce) {
throw Error("Too many files");
}
const fileBlobs = (0, import_editor.compact)(await Promise.all(files.map((t) => t.source)));
return await (0, import_pasteFiles.pasteFiles)(editor, fileBlobs, point);
}
const results = await Promise.all(
things.filter((t) => t.type !== "file").map(
(t) => new Promise((r) => {
const thing = t;
if (thing.type === "file") {
r({ type: "error", data: null, reason: "unexpected file" });
return;
}
thing.source.then((text) => {
const tldrawHtmlComment = text.match(/<div data-tldraw[^>]*>(.*)<\/div>/)?.[1];
if (tldrawHtmlComment) {
try {
let json;
try {
json = JSON.parse(tldrawHtmlComment);
} catch {
const jsonComment = import_lz_string.default.decompressFromBase64(tldrawHtmlComment);
if (jsonComment === null) {
r({
type: "error",
data: null,
reason: `found tldraw data comment but could not parse`
});
return;
}
json = JSON.parse(jsonComment);
}
if (json.type !== "application/tldraw") {
r({
type: "error",
data: json,
reason: `found tldraw data comment but JSON was of a different type: ${json.type}`
});
return;
}
if (json.version === 3) {
try {
const otherData = JSON.parse(
import_lz_string.default.decompressFromBase64(json.data.otherCompressed) || "{}"
);
const reconstructedData = {
assets: json.data.assets || [],
...otherData
};
r({ type: "tldraw", data: reconstructedData });
return;
} catch (error) {
r({
type: "error",
data: json,
reason: `failed to decompress version 2 clipboard data: ${error}`
});
return;
}
}
if (json.version === 2) {
r({ type: "tldraw", data: json.data });
} else {
if (typeof json.data === "string") {
r({
type: "error",
data: json,
reason: "found tldraw json but data was a string instead of a TLClipboardModel object"
});
return;
}
r({ type: "tldraw", data: json.data });
return;
}
} catch {
r({
type: "error",
data: tldrawHtmlComment,
reason: "found tldraw json but data was a string instead of a TLClipboardModel object"
});
return;
}
} else {
if (thing.type === "html") {
r({ type: "text", data: text, subtype: "html" });
return;
}
if (thing.type === "url") {
r({ type: "text", data: text, subtype: "url" });
return;
}
try {
const json = JSON.parse(text);
if (json.type === "excalidraw/clipboard") {
r({ type: "excalidraw", data: json });
return;
} else {
r({ type: "text", data: text, subtype: "json" });
return;
}
} catch {
r({ type: "text", data: text, subtype: "text" });
return;
}
}
r({ type: "error", data: text, reason: "unhandled case" });
});
})
)
);
for (const result of results) {
if (result.type === "tldraw") {
editor.markHistoryStoppingPoint("paste");
editor.putExternalContent({ type: "tldraw", content: result.data, point });
return;
}
}
for (const result of results) {
if (result.type === "excalidraw") {
editor.markHistoryStoppingPoint("paste");
editor.putExternalContent({ type: "excalidraw", content: result.data, point });
return;
}
}
for (const result of results) {
if (result.type === "text" && result.subtype === "html") {
const rootNode = new DOMParser().parseFromString(result.data, "text/html");
const bodyNode = rootNode.querySelector("body");
const isHtmlSingleLink = bodyNode && Array.from(bodyNode.children).filter((el) => el.nodeType === 1).length === 1 && bodyNode.firstElementChild && bodyNode.firstElementChild.tagName === "A" && bodyNode.firstElementChild.hasAttribute("href") && bodyNode.firstElementChild.getAttribute("href") !== "";
if (isHtmlSingleLink) {
const href = bodyNode.firstElementChild.getAttribute("href");
handleText(editor, href, point, results);
return;
}
if (!results.some((r) => r.type === "text" && r.subtype !== "html") && result.data.trim()) {
const html = stripHtml(result.data) ?? "";
if (html) {
handleText(editor, stripHtml(result.data), point, results);
return;
}
}
if (results.some((r) => r.type === "text" && r.subtype !== "html")) {
const html = stripHtml(result.data) ?? "";
if (html) {
editor.markHistoryStoppingPoint("paste");
editor.putExternalContent({
type: "text",
text: html,
html: result.data,
point,
sources: results
});
return;
}
}
}
if (result.type === "text" && result.subtype === "text" && result.data.startsWith("<iframe ")) {
const rootNode = new DOMParser().parseFromString(result.data, "text/html");
const bodyNode = rootNode.querySelector("body");
const isSingleIframe = bodyNode && Array.from(bodyNode.children).filter((el) => el.nodeType === 1).length === 1 && bodyNode.firstElementChild && bodyNode.firstElementChild.tagName === "IFRAME" && bodyNode.firstElementChild.hasAttribute("src") && bodyNode.firstElementChild.getAttribute("src") !== "";
if (isSingleIframe) {
const src = bodyNode.firstElementChild.getAttribute("src");
handleText(editor, src, point, results);
return;
}
}
}
for (const result of results) {
if (result.type === "text" && result.subtype === "url") {
(0, import_pasteUrl.pasteUrl)(editor, result.data, point, results);
return;
}
}
for (const result of results) {
if (result.type === "text" && result.subtype === "text" && result.data.trim()) {
handleText(editor, result.data, point, results);
return;
}
}
}
const handleNativeOrMenuCopy = async (editor) => {
const navigator2 = editor.getContainer().ownerDocument?.defaultView?.navigator ?? globalThis.navigator;
const content = await editor.resolveAssetsInContent(
editor.getContentFromCurrentPage(editor.getSelectedShapeIds())
);
if (!content) {
if (navigator2 && navigator2.clipboard) {
navigator2.clipboard.writeText("");
}
return;
}
const { assets, ...otherData } = content;
const clipboardData = {
type: "application/tldraw",
kind: "content",
version: 3,
data: {
assets: assets || [],
// Plain JSON, no compression
otherCompressed: import_lz_string.default.compressToBase64(JSON.stringify(otherData))
// Only compress non-asset data
}
};
const stringifiedClipboard = JSON.stringify(clipboardData);
if (typeof navigator2 === "undefined") {
return;
} else {
const textItems = content.shapes.map((shape) => {
const util = editor.getShapeUtil(shape);
return util.getText(shape);
}).filter(import_editor.isDefined);
if (navigator2.clipboard?.write) {
const htmlBlob = new Blob([`<div data-tldraw>${stringifiedClipboard}</div>`], {
type: "text/html"
});
let textContent = textItems.join(" ");
if (textContent === "") {
textContent = " ";
}
navigator2.clipboard.write([
new ClipboardItem({
"text/html": htmlBlob,
// What is this second blob used for?
"text/plain": new Blob([textContent], { type: "text/plain" })
})
]);
} else if (navigator2.clipboard.writeText) {
navigator2.clipboard.writeText(`<div data-tldraw>${stringifiedClipboard}</div>`);
}
}
};
function useMenuClipboardEvents() {
const editor = (0, import_editor.useMaybeEditor)();
const trackEvent = (0, import_events.useUiEvents)();
const copy = (0, import_react.useCallback)(
async function onCopy(source) {
(0, import_editor.assert)(editor, "editor is required for copy");
if (editor.getSelectedShapeIds().length === 0) return;
await handleNativeOrMenuCopy(editor);
trackEvent("copy", { source });
},
[editor, trackEvent]
);
const cut = (0, import_react.useCallback)(
async function onCut(source) {
if (!editor) return;
if (editor.getSelectedShapeIds().length === 0) return;
await handleNativeOrMenuCopy(editor);
editor.deleteShapes(editor.getSelectedShapeIds());
trackEvent("cut", { source });
},
[editor, trackEvent]
);
const paste = (0, import_react.useCallback)(
async function onPaste(data, source, point) {
if (!editor) return;
if (editor.getEditingShapeId() !== null) return;
if (Array.isArray(data) && data[0] instanceof ClipboardItem) {
handlePasteFromClipboardApi({ editor, clipboardItems: data, point });
trackEvent("paste", { source: "menu" });
} else {
navigator.clipboard.read().then((clipboardItems) => {
paste(clipboardItems, source, point);
});
}
},
[editor, trackEvent]
);
return {
copy,
cut,
paste
};
}
function useNativeClipboardEvents() {
const editor = (0, import_editor.useEditor)();
const ownerDocument = editor.getContainer().ownerDocument;
const trackEvent = (0, import_events.useUiEvents)();
const appIsFocused = (0, import_editor.useValue)("editor.isFocused", () => editor.getInstanceState().isFocused, [
editor
]);
(0, import_react.useEffect)(() => {
if (!appIsFocused) return;
const copy = async (e) => {
if (editor.getSelectedShapeIds().length === 0 || editor.getEditingShapeId() !== null || areShortcutsDisabled(editor)) {
return;
}
(0, import_editor.preventDefault)(e);
await handleNativeOrMenuCopy(editor);
trackEvent("copy", { source: "kbd" });
};
async function cut(e) {
if (editor.getSelectedShapeIds().length === 0 || editor.getEditingShapeId() !== null || areShortcutsDisabled(editor)) {
return;
}
(0, import_editor.preventDefault)(e);
await handleNativeOrMenuCopy(editor);
editor.deleteShapes(editor.getSelectedShapeIds());
trackEvent("cut", { source: "kbd" });
}
let disablingMiddleClickPaste = false;
const pointerUpHandler = (e) => {
if (e.button === 1) {
disablingMiddleClickPaste = true;
editor.timers.requestAnimationFrame(() => {
disablingMiddleClickPaste = false;
});
}
};
const paste = (e) => {
if (disablingMiddleClickPaste) {
editor.markEventAsHandled(e);
return;
}
if (editor.getEditingShapeId() !== null || areShortcutsDisabled(editor)) return;
let point = void 0;
let pasteAtCursor = false;
if (editor.inputs.getShiftKey()) pasteAtCursor = true;
if (editor.user.getIsPasteAtCursorMode()) pasteAtCursor = !pasteAtCursor;
if (pasteAtCursor) point = editor.inputs.getCurrentPagePoint();
const pasteFromEvent = () => {
if (e.clipboardData) {
handlePasteFromEventClipboardData(editor, e.clipboardData, point);
}
};
if (navigator.clipboard?.read) {
const fallbackFiles = Array.from(e.clipboardData?.files || []);
navigator.clipboard.read().then(
(clipboardItems) => {
if (Array.isArray(clipboardItems) && clipboardItems[0] instanceof ClipboardItem) {
handlePasteFromClipboardApi({ editor, clipboardItems, point, fallbackFiles });
}
},
() => {
pasteFromEvent();
}
);
} else {
pasteFromEvent();
}
(0, import_editor.preventDefault)(e);
trackEvent("paste", { source: "kbd" });
};
ownerDocument?.addEventListener("copy", copy);
ownerDocument?.addEventListener("cut", cut);
ownerDocument?.addEventListener("paste", paste);
ownerDocument?.addEventListener("pointerup", pointerUpHandler);
return () => {
ownerDocument?.removeEventListener("copy", copy);
ownerDocument?.removeEventListener("cut", cut);
ownerDocument?.removeEventListener("paste", paste);
ownerDocument?.removeEventListener("pointerup", pointerUpHandler);
};
}, [editor, trackEvent, appIsFocused, ownerDocument]);
}
//# sourceMappingURL=useClipboardEvents.js.map