forked from zhongyidian/robot-shanghai
301 lines
6.9 KiB
TypeScript
301 lines
6.9 KiB
TypeScript
import {
|
|
type MouseEvent,
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { Portal } from "@/components/ui/portal";
|
|
import { cn } from "@/lib/cn";
|
|
import { DefaultGuideDialogPanel } from "./DefaultGuideDialogPanel";
|
|
import styles from "./GuideDialog.module.css";
|
|
import type { GuideDialogLayout, GuideDialogProps } from "./GuideDialog.types";
|
|
|
|
const DEFAULT_PANEL_MAX_WIDTH = 560;
|
|
const VIEWPORT_MARGIN = 32;
|
|
const BOTTOM_OFFSET = 72;
|
|
const TOP_OFFSET = 72;
|
|
|
|
interface CoordinateContext {
|
|
portalTarget: HTMLElement;
|
|
scale: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
function resolveContainer(container?: GuideDialogProps["container"]) {
|
|
if (!container) {
|
|
return null;
|
|
}
|
|
|
|
if (typeof container === "function") {
|
|
return container();
|
|
}
|
|
|
|
return container;
|
|
}
|
|
|
|
function getCoordinateContext({
|
|
container,
|
|
viewport,
|
|
}: Pick<GuideDialogProps, "container" | "viewport">): CoordinateContext {
|
|
const portalTarget = resolveContainer(container) ?? document.body;
|
|
|
|
if (viewport) {
|
|
return {
|
|
portalTarget,
|
|
scale: viewport.scale ?? 1,
|
|
width: viewport.width,
|
|
height: viewport.height,
|
|
};
|
|
}
|
|
|
|
const rect = portalTarget.getBoundingClientRect();
|
|
|
|
return {
|
|
portalTarget,
|
|
scale: 1,
|
|
width: rect.width || window.innerWidth,
|
|
height: rect.height || window.innerHeight,
|
|
};
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number) {
|
|
return Math.min(Math.max(value, min), max);
|
|
}
|
|
|
|
function getPanelPosition(
|
|
placement: "top" | "center" | "bottom",
|
|
panel: { width: number; height: number },
|
|
context: CoordinateContext,
|
|
) {
|
|
const left = context.width / 2 - panel.width / 2;
|
|
const topMap = {
|
|
top: TOP_OFFSET,
|
|
center: context.height / 2 - panel.height / 2,
|
|
bottom: context.height - panel.height - BOTTOM_OFFSET,
|
|
};
|
|
const maxLeft = Math.max(
|
|
context.width - panel.width - VIEWPORT_MARGIN,
|
|
VIEWPORT_MARGIN,
|
|
);
|
|
const maxTop = Math.max(
|
|
context.height - panel.height - VIEWPORT_MARGIN,
|
|
VIEWPORT_MARGIN,
|
|
);
|
|
|
|
return {
|
|
left: clamp(left, VIEWPORT_MARGIN, maxLeft),
|
|
top: clamp(topMap[placement], VIEWPORT_MARGIN, maxTop),
|
|
};
|
|
}
|
|
|
|
export function GuideDialog({
|
|
state,
|
|
actions,
|
|
children,
|
|
container,
|
|
viewport,
|
|
theme,
|
|
closeOnOverlayClick = false,
|
|
closeOnEsc = true,
|
|
onStepChange,
|
|
}: GuideDialogProps) {
|
|
const { open, steps, current } = state;
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
const [layout, setLayout] = useState<GuideDialogLayout | null>(null);
|
|
|
|
const step = steps[current];
|
|
const stepKey = step ? `${current}:${step.id}` : "";
|
|
const zIndex = theme?.zIndex ?? 1000;
|
|
const panelMaxWidth = theme?.panelMaxWidth ?? DEFAULT_PANEL_MAX_WIDTH;
|
|
const isLayoutReady = !!layout && layout.stepKey === stepKey;
|
|
const activeLayout = isLayoutReady ? layout : null;
|
|
|
|
const closeAsFinish = useCallback(() => {
|
|
actions.finish();
|
|
}, [actions]);
|
|
|
|
const closeAsSkip = useCallback(() => {
|
|
actions.skip();
|
|
}, [actions]);
|
|
|
|
const measure = useCallback(() => {
|
|
if (!open || !step || typeof document === "undefined") {
|
|
setLayout(null);
|
|
return;
|
|
}
|
|
|
|
const context = getCoordinateContext({ container, viewport });
|
|
const panelElement = panelRef.current;
|
|
const panelRect = panelElement?.getBoundingClientRect();
|
|
const panelSize = {
|
|
width: panelElement
|
|
? Math.max(
|
|
panelRect?.width ?? 0,
|
|
panelElement.scrollWidth * context.scale,
|
|
) / context.scale
|
|
: panelMaxWidth,
|
|
height: panelElement
|
|
? Math.max(
|
|
panelRect?.height ?? 0,
|
|
panelElement.scrollHeight * context.scale,
|
|
) / context.scale
|
|
: 220,
|
|
};
|
|
const position = getPanelPosition(
|
|
step.placement ?? "bottom",
|
|
panelSize,
|
|
context,
|
|
);
|
|
|
|
setLayout({
|
|
stepKey,
|
|
portalTarget: context.portalTarget,
|
|
width: context.width,
|
|
height: context.height,
|
|
panelStyle: {
|
|
left: `${position.left}px`,
|
|
top: `${position.top}px`,
|
|
maxWidth: `${panelMaxWidth}px`,
|
|
},
|
|
});
|
|
}, [container, open, panelMaxWidth, step, stepKey, viewport]);
|
|
|
|
useLayoutEffect(() => {
|
|
measure();
|
|
}, [measure]);
|
|
|
|
useEffect(() => {
|
|
if (!open || !step) return;
|
|
onStepChange?.(current, step);
|
|
}, [current, open, onStepChange, step]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
|
|
const handleResize = () => measure();
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (closeOnEsc && event.key === "Escape") {
|
|
closeAsSkip();
|
|
}
|
|
};
|
|
const resizeObserver =
|
|
typeof ResizeObserver === "undefined"
|
|
? null
|
|
: new ResizeObserver(() => measure());
|
|
|
|
window.addEventListener("resize", handleResize);
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
if (panelRef.current) resizeObserver?.observe(panelRef.current);
|
|
|
|
return () => {
|
|
window.removeEventListener("resize", handleResize);
|
|
window.removeEventListener("keydown", handleKeyDown);
|
|
resizeObserver?.disconnect();
|
|
};
|
|
}, [closeAsSkip, closeOnEsc, measure, open]);
|
|
|
|
if (!open || !step || typeof document === "undefined") return null;
|
|
|
|
const isFirst = current === 0;
|
|
const isLast = current === steps.length - 1;
|
|
const options = step.options ?? [];
|
|
const hasOptions = options.length > 0;
|
|
const primaryAction = isLast ? closeAsFinish : actions.next;
|
|
const dialog = {
|
|
step,
|
|
character: step.character,
|
|
options,
|
|
progress: {
|
|
index: current,
|
|
current: current + 1,
|
|
total: steps.length,
|
|
},
|
|
status: {
|
|
isFirst,
|
|
isLast,
|
|
hasOptions,
|
|
},
|
|
actions: {
|
|
prev: actions.prev,
|
|
next: actions.next,
|
|
primary: primaryAction,
|
|
skip: closeAsSkip,
|
|
finish: closeAsFinish,
|
|
selectOption: actions.selectOption,
|
|
},
|
|
labels: {
|
|
prev: step.prevLabel ?? "上一步",
|
|
next: step.nextLabel ?? "继续",
|
|
skip: step.skipLabel ?? "跳过",
|
|
done: step.doneLabel ?? "完成",
|
|
primary: isLast ? (step.doneLabel ?? "完成") : (step.nextLabel ?? "继续"),
|
|
},
|
|
};
|
|
const panelContent = children ? (
|
|
children(dialog)
|
|
) : (
|
|
<DefaultGuideDialogPanel dialog={dialog} />
|
|
);
|
|
const panelClassName = children ? styles.panelContainer : styles.panel;
|
|
|
|
if (!activeLayout) {
|
|
return (
|
|
<Portal
|
|
container={getCoordinateContext({ container, viewport }).portalTarget}
|
|
>
|
|
<div
|
|
ref={panelRef}
|
|
aria-hidden="true"
|
|
className={cn(
|
|
panelClassName,
|
|
styles.measurePanel,
|
|
theme?.panelClassName,
|
|
)}
|
|
style={{
|
|
maxWidth: `${panelMaxWidth}px`,
|
|
}}
|
|
>
|
|
{panelContent}
|
|
</div>
|
|
</Portal>
|
|
);
|
|
}
|
|
|
|
const overlayClick = (event: MouseEvent<HTMLDivElement>) => {
|
|
event.stopPropagation();
|
|
if (closeOnOverlayClick) {
|
|
closeAsSkip();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Portal container={activeLayout.portalTarget}>
|
|
<div
|
|
aria-hidden="true"
|
|
className={cn(styles.mask, theme?.overlayClassName)}
|
|
onClick={overlayClick}
|
|
style={{
|
|
zIndex,
|
|
background: theme?.overlayColor ?? "rgb(15 23 42 / 0.58)",
|
|
}}
|
|
/>
|
|
<div
|
|
key={`dialog-${stepKey}`}
|
|
ref={panelRef}
|
|
aria-modal="true"
|
|
className={cn(panelClassName, theme?.panelClassName)}
|
|
role="dialog"
|
|
style={{
|
|
zIndex: zIndex + 1,
|
|
...activeLayout.panelStyle,
|
|
}}
|
|
>
|
|
{panelContent}
|
|
</div>
|
|
</Portal>
|
|
);
|
|
}
|