Files
robot-shanghai/src/course/interactions/guide-dialog/GuideDialog.tsx
T
2026-09-08 14:19:44 +08:00

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>
);
}