UNPKG

tldraw

Version:

A tiny little drawing editor.

260 lines (259 loc) • 9.16 kB
import { jsx } from "react/jsx-runtime"; import { assert, Box, clamp, react, useAtom, useEditor, usePassThroughMouseOverEvents, usePassThroughWheelEvents, useValue, Vec } from "@tldraw/editor"; import classNames from "classnames"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { flushSync } from "react-dom"; import { TldrawUiToolbar } from "./TldrawUiToolbar.mjs"; const MOVE_TIMEOUT = 150; const HIDE_VISIBILITY_TIMEOUT = 16; const SHOW_VISIBILITY_TIMEOUT = 16; const MIN_DISTANCE_TO_REPOSITION_SQUARED = 16 ** 2; const TOOLBAR_GAP = 8; const SCREEN_MARGIN = 16; const HIDE_TOOLBAR_WHEN_CAMERA_IS_MOVING = false; const LEFT_ALIGN_TOOLBAR = false; const TldrawUiContextualToolbar = ({ children, className, isMousingDown, getSelectionBounds, changeOnlyWhenYChanges = false, label }) => { const editor = useEditor(); const toolbarRef = useRef(null); usePassThroughWheelEvents(toolbarRef); usePassThroughMouseOverEvents(toolbarRef); const { isVisible, isInteractive, hide, show, position, move } = useToolbarVisibilityStateMachine(changeOnlyWhenYChanges); const rCouldShowToolbar = useRef(false); const [hasValidToolbarPosition, setHasValidToolbarPosition] = useState(false); const contentSizeUpdateCounter = useAtom("content size update counter", 0); useEffect(() => { assert(toolbarRef.current); const observer = new ResizeObserver(() => { contentSizeUpdateCounter.update((n) => n + 1); }); observer.observe(toolbarRef.current); return () => observer.disconnect(); }, [contentSizeUpdateCounter]); useEffect(() => { let lastContentSizeUpdateCounter = contentSizeUpdateCounter.get(); return react("toolbar position", function updateToolbarPositionAndDisplay() { const toolbarElm = toolbarRef.current; if (!toolbarElm) return; const nextContentSizeUpdateCounter = contentSizeUpdateCounter.get(); editor.getCamera(); contentSizeUpdateCounter.get(); const position2 = getToolbarScreenPosition(editor, toolbarElm, getSelectionBounds); if (!position2) { if (rCouldShowToolbar.current) { rCouldShowToolbar.current = false; setHasValidToolbarPosition(false); } } else { const cameraState2 = editor.getCameraState(); if (cameraState2 === "moving") { const elm = toolbarRef.current; if (elm) { elm.style.setProperty("transform", `translate(${position2.x}px, ${position2.y}px)`); } } else { const moveImmediately = lastContentSizeUpdateCounter !== nextContentSizeUpdateCounter; move(position2.x, position2.y, moveImmediately); } if (!rCouldShowToolbar.current) { rCouldShowToolbar.current = true; setHasValidToolbarPosition(true); } } lastContentSizeUpdateCounter = nextContentSizeUpdateCounter; }); }, [editor, getSelectionBounds, contentSizeUpdateCounter, move]); const cameraState = useValue("camera state", () => editor.getCameraState(), [editor]); useEffect(() => { if (cameraState === "moving" && HIDE_TOOLBAR_WHEN_CAMERA_IS_MOVING) { hide(true); return; } if (isMousingDown || !hasValidToolbarPosition) { hide(); return; } show(); }, [hasValidToolbarPosition, cameraState, isMousingDown, show, hide]); useLayoutEffect(() => { const elm = toolbarRef.current; if (!elm) return; elm.dataset.visible = `${isVisible}`; }, [isVisible, position]); useLayoutEffect(() => { const elm = toolbarRef.current; if (!elm) return; elm.style.setProperty("transform", `translate(${position.x}px, ${position.y}px)`); }, [position]); useLayoutEffect(() => { const elm = toolbarRef.current; if (!elm) return; elm.dataset.interactive = `${isInteractive}`; }, [isInteractive]); return /* @__PURE__ */ jsx( "div", { ref: toolbarRef, "data-interactive": false, "data-visible": false, "data-testid": "contextual-toolbar", className: classNames("tlui-contextual-toolbar", className), onPointerDown: editor.markEventAsHandled, children: /* @__PURE__ */ jsx( TldrawUiToolbar, { orientation: "horizontal", className: "tlui-menu", label, tooltipSide: "top", children } ) } ); }; function rectToBox(rect) { return new Box(rect.x, rect.y, rect.width, rect.height); } function getToolbarScreenPosition(editor, toolbarElm, getSelectionBounds) { const selectionBounds = getSelectionBounds()?.clone(); if (!selectionBounds) return; 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 = clamp(x, SCREEN_MARGIN, vsb.w - toolbarBounds.w - SCREEN_MARGIN); y = 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 sufficientlyDistant(curr, next, changeOnlyWhenYChanges) { if (changeOnlyWhenYChanges) { return Vec.Sub(next, curr).y ** 2 >= MIN_DISTANCE_TO_REPOSITION_SQUARED; } return Vec.Len2(Vec.Sub(next, curr)) >= MIN_DISTANCE_TO_REPOSITION_SQUARED; } function useToolbarVisibilityStateMachine(changeOnlyWhenYChanges) { const editor = useEditor(); const rState = useRef({ name: "hidden" }); const [isInteractive, setIsInteractive] = useState(false); const [isVisible, setIsVisible] = useState(false); const [position, setPosition] = useState({ x: -1e3, y: -1e3 }); const rCurrPosition = useRef(new Vec(-1e3, -1e3)); const rNextPosition = useRef(new Vec(-1e3, -1e3)); const rStableVisibilityTimeout = useRef(-1); const rStablePositionTimeout = useRef(-1); const move = useCallback( (x, y, immediate = false) => { rNextPosition.current.x = x; rNextPosition.current.y = y; if (rState.current.name === "hidden" || rState.current.name === "showing") return; clearTimeout(rStablePositionTimeout.current); const flushMove = () => { if (rState.current.name === "shown" && sufficientlyDistant(rNextPosition.current, rCurrPosition.current, changeOnlyWhenYChanges)) { const { x: x2, y: y2 } = rNextPosition.current; rCurrPosition.current = new Vec(x2, y2); if (immediate) { flushSync(() => setPosition({ x: x2, y: y2 })); } else { setPosition({ x: x2, y: y2 }); } } }; if (immediate) { flushMove(); } else { rStablePositionTimeout.current = editor.timers.setTimeout(flushMove, MOVE_TIMEOUT); } }, [editor, changeOnlyWhenYChanges] ); const hide = 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 = useCallback(() => { switch (rState.current.name) { case "hidden": { rState.current = { name: "showing" }; rStableVisibilityTimeout.current = editor.timers.setTimeout(() => { const { x, y } = rNextPosition.current; rCurrPosition.current = new 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 }; } export { TldrawUiContextualToolbar, getToolbarScreenPosition, rectToBox, useToolbarVisibilityStateMachine }; //# sourceMappingURL=TldrawUiContextualToolbar.mjs.map