forked from zhongyidian/robot-shanghai
Initial commit
This commit is contained in:
commit
6b1e803320
158 files changed
+16461
No files matched your search
@@ -0,0 +1,300 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user