UNPKG

tldraw

Version:

A tiny little drawing editor.

352 lines (351 loc) • 14.2 kB
"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 DefaultRichTextToolbar_exports = {}; __export(DefaultRichTextToolbar_exports, { DefaultRichTextToolbar: () => DefaultRichTextToolbar }); module.exports = __toCommonJS(DefaultRichTextToolbar_exports); var import_jsx_runtime = require("react/jsx-runtime"); var import_core = require("@tiptap/core"); var import_editor = require("@tldraw/editor"); var import_react = require("react"); var import_useTranslation = require("../../hooks/useTranslation/useTranslation"); var import_TldrawUiContextualToolbar = require("../primitives/TldrawUiContextualToolbar"); var import_DefaultRichTextToolbarContent = require("./DefaultRichTextToolbarContent"); var import_LinkEditor = require("./LinkEditor"); const MOVE_TIMEOUT = 150; const HIDE_VISIBILITY_TIMEOUT = 16; const SHOW_VISIBILITY_TIMEOUT = 16; const TOOLBAR_GAP = 8; const SCREEN_MARGIN = 16; const MIN_DISTANCE_TO_REPOSITION_SQUARED = 16 ** 2; const HIDE_TOOLBAR_WHEN_CAMERA_IS_MOVING = true; const CHANGE_ONLY_WHEN_Y_CHANGES = true; const LEFT_ALIGN_TOOLBAR = false; const DefaultRichTextToolbar = (0, import_editor.track)(function DefaultRichTextToolbar2({ children }) { const editor = (0, import_editor.useEditor)(); const textEditor = (0, import_editor.useValue)("textEditor", () => editor.getRichTextEditor(), [editor]); if (editor.getInstanceState().isCoarsePointer || !textEditor) return null; return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ContextualToolbarInner, { textEditor, children }); }); function ContextualToolbarInner({ textEditor, children }) { const editor = (0, import_editor.useEditor)(); const msg = (0, import_useTranslation.useTranslation)(); const rToolbar = (0, import_react.useRef)(null); const { isVisible, isInteractive, hide, show, position, move } = useToolbarVisibilityStateMachine(); const { isEditingLink, onEditLinkStart, onEditLinkComplete } = useEditingLinkBehavior(textEditor); const forcePositionUpdateAtom = (0, import_editor.useAtom)("force toolbar position update", 0); (0, import_react.useEffect)( function forceUpdateWhenSelectionUpdates() { function handleSelectionUpdate() { forcePositionUpdateAtom.update((t) => t + 1); } import_editor.tltime.requestAnimationFrame("first forced update", handleSelectionUpdate); textEditor.on("selectionUpdate", handleSelectionUpdate); return () => { textEditor.off("selectionUpdate", handleSelectionUpdate); }; }, [textEditor, forcePositionUpdateAtom] ); (0, import_editor.useReactor)( "shape change", function forceUpdateOnNextFrameWhenShapeChanges() { editor.getEditingShape(); forcePositionUpdateAtom.update((t) => t + 1); }, [editor] ); const rCouldShowToolbar = (0, import_react.useRef)(false); const [hasValidToolbarPosition, setHasValidToolbarPosition] = (0, import_react.useState)(false); (0, import_editor.useQuickReactor)( "toolbar position", function updateToolbarPositionAndDisplay() { const toolbarElm = rToolbar.current; if (!toolbarElm) return; editor.getCamera(); forcePositionUpdateAtom.get(); const position2 = getToolbarScreenPosition(editor, toolbarElm); if (!position2) { if (rCouldShowToolbar.current) { rCouldShowToolbar.current = false; setHasValidToolbarPosition(false); } return; } const cameraState2 = editor.getCameraState(); if (cameraState2 === "moving") { const elm = rToolbar.current; elm.style.setProperty("transform", `translate(${position2.x}px, ${position2.y}px)`); } else { move(position2.x, position2.y); } if (!rCouldShowToolbar.current) { rCouldShowToolbar.current = true; setHasValidToolbarPosition(true); } }, [editor, textEditor, forcePositionUpdateAtom] ); const cameraState = (0, import_editor.useValue)("camera state", () => editor.getCameraState(), [editor]); const isMousingDown = useIsMousingDownOnTextEditor(textEditor); (0, import_react.useEffect)(() => { if (cameraState === "moving" && HIDE_TOOLBAR_WHEN_CAMERA_IS_MOVING) { hide(true); return; } if (isMousingDown || !hasValidToolbarPosition) { hide(); return; } show(); }, [hasValidToolbarPosition, cameraState, isMousingDown, show, hide]); (0, import_react.useLayoutEffect)(() => { const elm = rToolbar.current; if (!elm) return; elm.dataset.visible = `${isVisible}`; }, [isVisible, position]); (0, import_react.useLayoutEffect)(() => { const elm = rToolbar.current; if (!elm) return; elm.style.setProperty("transform", `translate(${position.x}px, ${position.y}px)`); }, [position]); (0, import_react.useLayoutEffect)(() => { const elm = rToolbar.current; if (!elm) return; elm.dataset.interactive = `${isInteractive}`; }, [isInteractive]); return /* @__PURE__ */ (0, import_jsx_runtime.jsx)( import_TldrawUiContextualToolbar.TldrawUiContextualToolbar, { ref: rToolbar, className: "tlui-rich-text__toolbar", "data-interactive": false, "data-visible": false, label: msg("tool.rich-text-toolbar-title"), children: children ? children : isEditingLink ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)( import_LinkEditor.LinkEditor, { textEditor, value: textEditor.isActive("link") ? textEditor.getAttributes("link").href : "", onComplete: onEditLinkComplete } ) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_DefaultRichTextToolbarContent.DefaultRichTextToolbarContent, { textEditor, onEditLinkStart }) } ); } function rectToBox(rect) { return new import_editor.Box(rect.x, rect.y, rect.width, rect.height); } function getToolbarScreenPosition(editor, toolbarElm) { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return; const rangeBoxes = []; for (let i = 0; i < selection.rangeCount; i++) { const range = selection.getRangeAt(i); rangeBoxes.push(rectToBox(range.getBoundingClientRect())); } const selectionBounds = import_editor.Box.Common(rangeBoxes); const vsb = editor.getViewportScreenBounds(); selectionBounds.x -= vsb.x; selectionBounds.y -= vsb.y; if (selectionBounds.midY < SCREEN_MARGIN || selectionBounds.midY > vsb.h - SCREEN_MARGIN || selectionBounds.midX < SCREEN_MARGIN || selectionBounds.midX > vsb.w - SCREEN_MARGIN) { return; } const toolbarBounds = rectToBox(toolbarElm.getBoundingClientRect()); if (!toolbarBounds.width || !toolbarBounds.height) return; const { scrollLeft, scrollTop } = editor.getContainer(); let x = LEFT_ALIGN_TOOLBAR ? selectionBounds.x : selectionBounds.midX - toolbarBounds.w / 2; let y = selectionBounds.y - toolbarBounds.h - TOOLBAR_GAP; x = (0, import_editor.clamp)(x, SCREEN_MARGIN, vsb.w - toolbarBounds.w - SCREEN_MARGIN); y = (0, import_editor.clamp)(y, SCREEN_MARGIN, vsb.h - toolbarBounds.h - SCREEN_MARGIN); x += scrollLeft; y += scrollTop; x = Math.round(x); y = Math.round(y); return { x, y }; } function useEditingLinkBehavior(textEditor) { const [isEditingLink, setIsEditingLink] = (0, import_react.useState)(false); (0, import_react.useEffect)(() => { if (!textEditor) { setIsEditingLink(false); return; } const handleClick = () => { const isLinkActive = textEditor.isActive("link"); setIsEditingLink(isLinkActive); }; textEditor.view.dom.addEventListener("click", handleClick); return () => { textEditor.view.dom.removeEventListener("click", handleClick); }; }, [textEditor, isEditingLink]); (0, import_react.useEffect)(() => { if (!textEditor) { return; } if (textEditor.isActive("link")) { try { const { from, to } = (0, import_core.getMarkRange)( textEditor.state.doc.resolve(textEditor.state.selection.from), textEditor.schema.marks.link ); if (textEditor.state.selection.empty) { textEditor.commands.setTextSelection({ from, to }); } } catch { } } }, [textEditor, isEditingLink]); const onEditLinkStart = (0, import_react.useCallback)(() => { setIsEditingLink(true); }, []); const onEditLinkCancel = (0, import_react.useCallback)(() => { setIsEditingLink(false); }, []); const onEditLinkComplete = (0, import_react.useCallback)(() => { setIsEditingLink(false); if (!textEditor) return; const from = textEditor.state.selection.from; textEditor.commands.setTextSelection({ from, to: from }); }, [textEditor]); return { isEditingLink, onEditLinkStart, onEditLinkComplete, onEditLinkCancel }; } function sufficientlyDistant(curr, next) { if (CHANGE_ONLY_WHEN_Y_CHANGES) { return import_editor.Vec.Sub(next, curr).y ** 2 >= MIN_DISTANCE_TO_REPOSITION_SQUARED; } return import_editor.Vec.Len2(import_editor.Vec.Sub(next, curr)) >= MIN_DISTANCE_TO_REPOSITION_SQUARED; } function useToolbarVisibilityStateMachine() { const editor = (0, import_editor.useEditor)(); const rState = (0, import_react.useRef)({ name: "hidden" }); const [isInteractive, setIsInteractive] = (0, import_react.useState)(false); const [isVisible, setIsVisible] = (0, import_react.useState)(false); const [position, setPosition] = (0, import_react.useState)({ x: -1e3, y: -1e3 }); const rCurrPosition = (0, import_react.useRef)(new import_editor.Vec(-1e3, -1e3)); const rNextPosition = (0, import_react.useRef)(new import_editor.Vec(-1e3, -1e3)); const rStableVisibilityTimeout = (0, import_react.useRef)(-1); const rStablePositionTimeout = (0, import_react.useRef)(-1); const move = (0, import_react.useCallback)( (x, y) => { rNextPosition.current.x = x; rNextPosition.current.y = y; if (rState.current.name === "hidden" || rState.current.name === "showing") return; clearTimeout(rStablePositionTimeout.current); rStablePositionTimeout.current = editor.timers.setTimeout(() => { if (rState.current.name === "shown" && sufficientlyDistant(rNextPosition.current, rCurrPosition.current)) { const { x: x2, y: y2 } = rNextPosition.current; rCurrPosition.current = new import_editor.Vec(x2, y2); setPosition({ x: x2, y: y2 }); } }, MOVE_TIMEOUT); }, [editor] ); const hide = (0, import_react.useCallback)( (immediate = false) => { switch (rState.current.name) { case "showing": { clearTimeout(rStableVisibilityTimeout.current); rState.current = { name: "hidden" }; break; } case "shown": { rState.current = { name: "hiding" }; setIsInteractive(false); if (immediate) { rState.current = { name: "hidden" }; setIsVisible(false); } else { rStableVisibilityTimeout.current = editor.timers.setTimeout(() => { rState.current = { name: "hidden" }; setIsVisible(false); }, HIDE_VISIBILITY_TIMEOUT); } break; } default: { } } }, [editor] ); const show = (0, import_react.useCallback)(() => { switch (rState.current.name) { case "hidden": { rState.current = { name: "showing" }; rStableVisibilityTimeout.current = editor.timers.setTimeout(() => { const { x, y } = rNextPosition.current; rCurrPosition.current = new import_editor.Vec(x, y); setPosition({ x, y }); rState.current = { name: "shown" }; setIsVisible(true); setIsInteractive(true); }, SHOW_VISIBILITY_TIMEOUT); break; } case "hiding": { clearTimeout(rStableVisibilityTimeout.current); rState.current = { name: "shown" }; setIsInteractive(true); move(rNextPosition.current.x, rNextPosition.current.y); break; } default: { } } }, [editor, move]); return { isVisible, isInteractive, show, hide, move, position }; } function useIsMousingDownOnTextEditor(textEditor) { const [isMousingDown, setIsMousingDown] = (0, import_react.useState)(false); (0, import_react.useEffect)(() => { if (!textEditor) return; const handlePointingStateChange = (0, import_editor.debounce)(({ isPointing }) => { setIsMousingDown(isPointing); }, 16); const handlePointingDown = () => handlePointingStateChange({ isPointing: true }); const handlePointingUp = () => handlePointingStateChange({ isPointing: false }); const touchDownEvents = ["touchstart", "pointerdown", "mousedown"]; const touchUpEvents = ["touchend", "pointerup", "mouseup"]; touchDownEvents.forEach((eventName) => { textEditor.view.dom.addEventListener(eventName, handlePointingDown); }); touchUpEvents.forEach((eventName) => { document.body.addEventListener(eventName, handlePointingUp); }); return () => { touchDownEvents.forEach((eventName) => { textEditor.view.dom.removeEventListener(eventName, handlePointingDown); }); touchUpEvents.forEach((eventName) => { document.body.removeEventListener(eventName, handlePointingUp); }); }; }, [textEditor]); return isMousingDown; } //# sourceMappingURL=DefaultRichTextToolbar.js.map