tldraw
Version:
A tiny little drawing editor.
260 lines (259 loc) • 9.16 kB
JavaScript
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