UNPKG

tldraw

Version:

A tiny little drawing editor.

472 lines (471 loc) • 15.7 kB
import { FileHelpers, compact, isDefined, preventDefault, stopEventPropagation, uniq, useEditor, useValue } from "@tldraw/editor"; import lz from "lz-string"; import { useCallback, useEffect } from "react"; import { TLDRAW_CUSTOM_PNG_MIME_TYPE, getCanonicalClipboardReadType } from "../../utils/clipboard.mjs"; import { useUiEvents } from "../context/events.mjs"; import { pasteExcalidrawContent } from "./clipboard/pasteExcalidrawContent.mjs"; import { pasteFiles } from "./clipboard/pasteFiles.mjs"; import { pasteTldrawContent } from "./clipboard/pasteTldrawContent.mjs"; import { pasteUrl } from "./clipboard/pasteUrl.mjs"; const expectedPasteFileMimeTypes = [ 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 uniq(urls); }; const isSvgText = (text) => { return /^<svg/.test(text); }; const INPUTS = ["input", "select", "textarea"]; function areShortcutsDisabled(editor) { const { activeElement } = document; return editor.menus.hasAnyOpenMenus() || activeElement && (activeElement.getAttribute("contenteditable") || INPUTS.indexOf(activeElement.tagName.toLowerCase()) > -1); } const handleText = (editor, data, point, sources) => { const validUrlList = getValidHttpURLList(data); if (validUrlList) { for (const url of validUrlList) { pasteUrl(editor, url, point); } } else if (isValidHttpURL(data)) { 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) => { const things = []; for (const item of clipboardItems) { for (const type of expectedPasteFileMimeTypes) { if (item.types.includes(type)) { const blobPromise = item.getType(type).then((blob) => FileHelpers.rewriteMimeType(blob, 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 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 FileHelpers.blobToText(blob); })() }); } if (item.types.includes("text/plain")) { things.push({ type: "text", source: (async () => { const blob = await item.getType("text/plain"); return await FileHelpers.blobToText(blob); })() }); } } 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 = compact(await Promise.all(files.map((t) => t.source))); return await 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 { const jsonComment = lz.decompressFromBase64(tldrawHtmlComment); if (jsonComment === null) { r({ type: "error", data: jsonComment, reason: `found tldraw data comment but could not parse base64` }); return; } else { const 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}` }); } 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") { pasteTldrawContent(editor, result.data, point); return; } } for (const result of results) { if (result.type === "excalidraw") { pasteExcalidrawContent(editor, 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()) { handleText(editor, stripHtml(result.data), point, 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") { 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 content = await editor.resolveAssetsInContent( editor.getContentFromCurrentPage(editor.getSelectedShapeIds()) ); if (!content) { if (navigator && navigator.clipboard) { navigator.clipboard.writeText(""); } return; } const stringifiedClipboard = lz.compressToBase64( JSON.stringify({ type: "application/tldraw", kind: "content", data: content }) ); if (typeof navigator === "undefined") { return; } else { const textItems = content.shapes.map((shape) => { const util = editor.getShapeUtil(shape); return util.getText(shape); }).filter(isDefined); if (navigator.clipboard?.write) { const htmlBlob = new Blob([`<div data-tldraw>${stringifiedClipboard}</div>`], { type: "text/html" }); let textContent = textItems.join(" "); if (textContent === "") { textContent = " "; } navigator.clipboard.write([ new ClipboardItem({ "text/html": htmlBlob, // What is this second blob used for? "text/plain": new Blob([textContent], { type: "text/plain" }) }) ]); } else if (navigator.clipboard.writeText) { navigator.clipboard.writeText(`<div data-tldraw>${stringifiedClipboard}</div>`); } } }; function useMenuClipboardEvents() { const editor = useEditor(); const trackEvent = useUiEvents(); const copy = useCallback( async function onCopy(source) { if (editor.getSelectedShapeIds().length === 0) return; await handleNativeOrMenuCopy(editor); trackEvent("copy", { source }); }, [editor, trackEvent] ); const cut = useCallback( async function onCut(source) { if (editor.getSelectedShapeIds().length === 0) return; await handleNativeOrMenuCopy(editor); editor.deleteShapes(editor.getSelectedShapeIds()); trackEvent("cut", { source }); }, [editor, trackEvent] ); const paste = useCallback( async function onPaste(data, source, point) { if (editor.getEditingShapeId() !== null) return; if (Array.isArray(data) && data[0] instanceof ClipboardItem) { handlePasteFromClipboardApi(editor, 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 = useEditor(); const trackEvent = useUiEvents(); const appIsFocused = useValue("editor.isFocused", () => editor.getInstanceState().isFocused, [ editor ]); useEffect(() => { if (!appIsFocused) return; const copy = async (e) => { if (editor.getSelectedShapeIds().length === 0 || editor.getEditingShapeId() !== null || areShortcutsDisabled(editor)) { return; } preventDefault(e); await handleNativeOrMenuCopy(editor); trackEvent("copy", { source: "kbd" }); }; async function cut(e) { if (editor.getSelectedShapeIds().length === 0 || editor.getEditingShapeId() !== null || areShortcutsDisabled(editor)) { return; } 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) { stopEventPropagation(e); return; } if (editor.getEditingShapeId() !== null || areShortcutsDisabled(editor)) return; let point = void 0; let pasteAtCursor = false; if (editor.inputs.shiftKey) pasteAtCursor = true; if (editor.user.getIsPasteAtCursorMode()) pasteAtCursor = !pasteAtCursor; if (pasteAtCursor) point = editor.inputs.currentPagePoint; const pasteFromEvent = () => { if (e.clipboardData) { handlePasteFromEventClipboardData(editor, e.clipboardData, point); } }; if (navigator.clipboard?.read) { navigator.clipboard.read().then( (clipboardItems) => { if (Array.isArray(clipboardItems) && clipboardItems[0] instanceof ClipboardItem) { handlePasteFromClipboardApi(editor, clipboardItems, point); } }, () => { pasteFromEvent(); } ); } else { pasteFromEvent(); } preventDefault(e); trackEvent("paste", { source: "kbd" }); }; document.addEventListener("copy", copy); document.addEventListener("cut", cut); document.addEventListener("paste", paste); document.addEventListener("pointerup", pointerUpHandler); return () => { document.removeEventListener("copy", copy); document.removeEventListener("cut", cut); document.removeEventListener("paste", paste); document.removeEventListener("pointerup", pointerUpHandler); }; }, [editor, trackEvent, appIsFocused]); } export { isValidHttpURL, useMenuClipboardEvents, useNativeClipboardEvents }; //# sourceMappingURL=useClipboardEvents.mjs.map