tldraw
Version:
A tiny little drawing editor.
361 lines (360 loc) • 11.3 kB
JavaScript
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
import {
assert,
atom,
tlenvReactive,
uniqueId,
useMaybeEditor,
useValue
} from "@tldraw/editor";
import { Tooltip as _Tooltip } from "radix-ui";
import React, {
createContext,
forwardRef,
useContext,
useEffect,
useRef,
useState
} from "react";
import { useTldrawUiOrientation } from "./layout.mjs";
const DEFAULT_TOOLTIP_DELAY_MS = 700;
class TooltipManager {
static instance = null;
state = atom("tooltip state", { name: "idle" });
static getInstance() {
if (!TooltipManager.instance) {
TooltipManager.instance = new TooltipManager();
}
return TooltipManager.instance;
}
hideAllTooltips() {
this.handleEvent({ type: "hide_all" });
}
handleEvent(event) {
const currentState = this.state.get();
switch (event.type) {
case "pointer_down": {
if (currentState.name === "waiting_to_hide") {
clearTimeout(currentState.timeoutId);
}
this.state.set({ name: "pointer_down" });
break;
}
case "pointer_up": {
if (currentState.name === "pointer_down") {
this.state.set({ name: "idle" });
}
break;
}
case "show": {
if (currentState.name === "pointer_down") {
return;
}
if (currentState.name === "waiting_to_hide") {
clearTimeout(currentState.timeoutId);
}
this.state.set({ name: "showing", tooltip: event.tooltip });
break;
}
case "hide": {
const { tooltipId, editor, instant } = event;
if (currentState.name === "showing" && currentState.tooltip.id === tooltipId) {
if (editor && !instant) {
const timeoutId = editor.timers.setTimeout(() => {
const state = this.state.get();
if (state.name === "waiting_to_hide" && state.tooltip.id === tooltipId) {
this.state.set({ name: "idle" });
}
}, 300);
this.state.set({
name: "waiting_to_hide",
tooltip: currentState.tooltip,
timeoutId
});
} else {
this.state.set({ name: "idle" });
}
} else if (currentState.name === "waiting_to_hide" && currentState.tooltip.id === tooltipId) {
if (instant) {
clearTimeout(currentState.timeoutId);
this.state.set({ name: "idle" });
}
}
break;
}
case "hide_all": {
if (currentState.name === "waiting_to_hide") {
clearTimeout(currentState.timeoutId);
}
if (currentState.name === "pointer_down") {
return;
}
this.state.set({ name: "idle" });
break;
}
}
}
getCurrentTooltipData() {
const currentState = this.state.get();
let tooltip = null;
if (currentState.name === "showing") {
tooltip = currentState.tooltip;
} else if (currentState.name === "waiting_to_hide") {
tooltip = currentState.tooltip;
}
if (!tooltip) return null;
if (tlenvReactive.get().isCoarsePointer && !tooltip.showOnMobile) return null;
return tooltip;
}
}
const tooltipManager = TooltipManager.getInstance();
function hideAllTooltips() {
tooltipManager.hideAllTooltips();
}
const TooltipSingletonContext = createContext(false);
function TldrawUiTooltipProvider({ children }) {
return /* @__PURE__ */ jsx(_Tooltip.Provider, { skipDelayDuration: 700, children: /* @__PURE__ */ jsxs(TooltipSingletonContext.Provider, { value: true, children: [
children,
/* @__PURE__ */ jsx(TooltipSingleton, {})
] }) });
}
function TooltipSingleton() {
const [isOpen, setIsOpen] = useState(false);
const triggerRef = useRef(null);
const isFirstShowRef = useRef(true);
const editor = useMaybeEditor();
const currentTooltip = useValue(
"current tooltip",
() => tooltipManager.getCurrentTooltipData(),
[]
);
const cameraState = useValue("camera state", () => editor?.getCameraState(), [editor]);
useEffect(() => {
if (cameraState === "moving" && isOpen && currentTooltip) {
tooltipManager.handleEvent({
type: "hide",
tooltipId: currentTooltip.id,
editor,
instant: true
});
}
}, [cameraState, isOpen, currentTooltip, editor]);
useEffect(() => {
function handleKeyDown(event) {
if (event.key === "Escape" && currentTooltip && isOpen) {
hideAllTooltips();
event.stopPropagation();
}
}
document.addEventListener("keydown", handleKeyDown, { capture: true });
return () => {
document.removeEventListener("keydown", handleKeyDown, { capture: true });
};
}, [currentTooltip, isOpen]);
useEffect(() => {
function handlePointerDown() {
tooltipManager.handleEvent({ type: "pointer_down" });
}
function handlePointerUp() {
tooltipManager.handleEvent({ type: "pointer_up" });
}
document.addEventListener("pointerdown", handlePointerDown, { capture: true });
document.addEventListener("pointerup", handlePointerUp, { capture: true });
document.addEventListener("pointercancel", handlePointerUp, { capture: true });
return () => {
document.removeEventListener("pointerdown", handlePointerDown, { capture: true });
document.removeEventListener("pointerup", handlePointerUp, { capture: true });
document.removeEventListener("pointercancel", handlePointerUp, { capture: true });
tooltipManager.handleEvent({ type: "pointer_up" });
};
}, []);
useEffect(() => {
let timer = null;
if (currentTooltip && triggerRef.current) {
const activeRect = currentTooltip.targetElement.getBoundingClientRect();
const trigger = triggerRef.current;
trigger.style.position = "fixed";
trigger.style.left = "0px";
trigger.style.top = "0px";
const cbOffset = trigger.getBoundingClientRect();
trigger.style.left = `${activeRect.left - cbOffset.left}px`;
trigger.style.top = `${activeRect.top - cbOffset.top}px`;
trigger.style.width = `${activeRect.width}px`;
trigger.style.height = `${activeRect.height}px`;
trigger.style.pointerEvents = "none";
trigger.style.zIndex = "9999";
if (isFirstShowRef.current) {
timer = setTimeout(() => {
setIsOpen(true);
isFirstShowRef.current = false;
}, currentTooltip.delayDuration);
} else {
setIsOpen(true);
}
} else {
setIsOpen(false);
isFirstShowRef.current = true;
}
return () => {
if (timer !== null) {
clearTimeout(timer);
}
};
}, [currentTooltip]);
if (!currentTooltip) {
return null;
}
return /* @__PURE__ */ jsxs(_Tooltip.Root, { open: isOpen, delayDuration: 0, children: [
/* @__PURE__ */ jsx(_Tooltip.Trigger, { asChild: true, children: /* @__PURE__ */ jsx("div", { ref: triggerRef }) }),
/* @__PURE__ */ jsxs(
_Tooltip.Content,
{
className: "tlui-tooltip",
side: currentTooltip.side,
sideOffset: currentTooltip.sideOffset,
avoidCollisions: true,
collisionPadding: 8,
dir: "ltr",
children: [
currentTooltip.content,
/* @__PURE__ */ jsx(_Tooltip.Arrow, { className: "tlui-tooltip__arrow" })
]
}
)
] });
}
const TldrawUiTooltip = forwardRef(
({
children,
content,
side,
sideOffset = 5,
disabled = false,
showOnMobile = false,
delayDuration
}, ref) => {
const editor = useMaybeEditor();
const tooltipId = useRef(uniqueId());
const hasProvider = useContext(TooltipSingletonContext);
const enhancedA11yMode = useValue(
"enhancedA11yMode",
() => editor?.user.getEnhancedA11yMode(),
[editor]
);
const orientationCtx = useTldrawUiOrientation();
const sideToUse = side ?? orientationCtx.tooltipSide;
useEffect(() => {
const currentTooltipId = tooltipId.current;
return () => {
if (hasProvider) {
tooltipManager.handleEvent({
type: "hide",
tooltipId: currentTooltipId,
editor,
instant: true
});
}
};
}, [editor, hasProvider]);
if (disabled || !content) {
return /* @__PURE__ */ jsx(Fragment, { children });
}
let delayDurationToUse;
if (enhancedA11yMode) {
delayDurationToUse = 0;
} else {
delayDurationToUse = delayDuration ?? (editor?.options.tooltipDelayMs || DEFAULT_TOOLTIP_DELAY_MS);
}
if (!hasProvider || enhancedA11yMode) {
return /* @__PURE__ */ jsxs(
_Tooltip.Root,
{
delayDuration: delayDurationToUse,
disableHoverableContent: !enhancedA11yMode,
children: [
/* @__PURE__ */ jsx(_Tooltip.Trigger, { asChild: true, ref, children }),
/* @__PURE__ */ jsxs(
_Tooltip.Content,
{
className: "tlui-tooltip",
side: sideToUse,
sideOffset,
avoidCollisions: true,
collisionPadding: 8,
dir: "ltr",
children: [
content,
/* @__PURE__ */ jsx(_Tooltip.Arrow, { className: "tlui-tooltip__arrow" })
]
}
)
]
}
);
}
const child = React.Children.only(children);
assert(React.isValidElement(child), "TldrawUiTooltip children must be a single element");
const childElement = child;
const handleMouseEnter = (event) => {
childElement.props.onMouseEnter?.(event);
tooltipManager.handleEvent({
type: "show",
tooltip: {
id: tooltipId.current,
content,
targetElement: event.currentTarget,
side: sideToUse,
sideOffset,
showOnMobile,
delayDuration: delayDurationToUse
}
});
};
const handleMouseLeave = (event) => {
childElement.props.onMouseLeave?.(event);
tooltipManager.handleEvent({
type: "hide",
tooltipId: tooltipId.current,
editor,
instant: false
});
};
const handleFocus = (event) => {
childElement.props.onFocus?.(event);
tooltipManager.handleEvent({
type: "show",
tooltip: {
id: tooltipId.current,
content,
targetElement: event.currentTarget,
side: sideToUse,
sideOffset,
showOnMobile,
delayDuration: delayDurationToUse
}
});
};
const handleBlur = (event) => {
childElement.props.onBlur?.(event);
tooltipManager.handleEvent({
type: "hide",
tooltipId: tooltipId.current,
editor,
instant: false
});
};
const childrenWithHandlers = React.cloneElement(childElement, {
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
onFocus: handleFocus,
onBlur: handleBlur
});
return childrenWithHandlers;
}
);
export {
TldrawUiTooltip,
TldrawUiTooltipProvider,
hideAllTooltips
};
//# sourceMappingURL=TldrawUiTooltip.mjs.map