Template
feat: 完善课程模板通用能力
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
1e02c7835e
commit
a641d0e2ea
70 files changed
+2868
-205
No files matched your search
@@ -0,0 +1,14 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { CourseCanvasContextValue } from "./CourseCanvas.types";
|
||||
|
||||
export const CanvasContext = createContext<CourseCanvasContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export function useCourseCanvas(): CourseCanvasContextValue {
|
||||
const context = useContext(CanvasContext);
|
||||
if (!context) {
|
||||
throw new Error("useCourseCanvas must be used within CourseCanvas");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CanvasContext } from "./CanvasContext";
|
||||
import type {
|
||||
CourseCanvasContextValue,
|
||||
CourseCanvasProps,
|
||||
CourseCanvasViewport,
|
||||
} from "./CourseCanvas.types";
|
||||
import { useCanvasScale } from "./useCanvasScale";
|
||||
|
||||
const DEFAULT_WIDTH = 1920;
|
||||
const DEFAULT_HEIGHT = 1080;
|
||||
|
||||
export function CourseCanvas({
|
||||
children,
|
||||
width = DEFAULT_WIDTH,
|
||||
height = DEFAULT_HEIGHT,
|
||||
maxScale = 1,
|
||||
className,
|
||||
canvasClassName,
|
||||
portalClassName,
|
||||
}: CourseCanvasProps) {
|
||||
const scale = useCanvasScale({ width, height, maxScale });
|
||||
const frameRef = useRef<HTMLDivElement>(null);
|
||||
const portalContainer = useRef<HTMLDivElement>(null);
|
||||
const [viewport, setViewport] = useState<CourseCanvasViewport>({
|
||||
width,
|
||||
height,
|
||||
scale,
|
||||
originLeft: 0,
|
||||
originTop: 0,
|
||||
});
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const frame = frameRef.current;
|
||||
if (!frame) return;
|
||||
const rect = frame.getBoundingClientRect();
|
||||
const committedScale = rect.width > 0 ? rect.width / width : scale;
|
||||
const next = {
|
||||
width,
|
||||
height,
|
||||
scale: committedScale,
|
||||
originLeft: rect.left,
|
||||
originTop: rect.top,
|
||||
};
|
||||
setViewport((current) =>
|
||||
current.width === next.width &&
|
||||
current.height === next.height &&
|
||||
current.scale === next.scale &&
|
||||
current.originLeft === next.originLeft &&
|
||||
current.originTop === next.originTop
|
||||
? current
|
||||
: next,
|
||||
);
|
||||
}, [height, scale, width]);
|
||||
|
||||
useEffect(() => {
|
||||
const frame = frameRef.current;
|
||||
let animationFrame = requestAnimationFrame(measure);
|
||||
const scheduleMeasure = () => {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
animationFrame = requestAnimationFrame(measure);
|
||||
};
|
||||
const observer =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? null
|
||||
: new ResizeObserver(scheduleMeasure);
|
||||
|
||||
if (frame) observer?.observe(frame);
|
||||
observer?.observe(document.documentElement);
|
||||
window.addEventListener("resize", scheduleMeasure);
|
||||
window.visualViewport?.addEventListener("resize", scheduleMeasure);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
observer?.disconnect();
|
||||
window.removeEventListener("resize", scheduleMeasure);
|
||||
window.visualViewport?.removeEventListener("resize", scheduleMeasure);
|
||||
};
|
||||
}, [measure]);
|
||||
|
||||
const value = useMemo<CourseCanvasContextValue>(
|
||||
() => ({ width, height, scale, portalContainer, viewport }),
|
||||
[height, scale, viewport, width],
|
||||
);
|
||||
|
||||
return (
|
||||
<CanvasContext.Provider value={value}>
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={frameRef}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: width * scale,
|
||||
height: height * scale,
|
||||
flex: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={canvasClassName}
|
||||
style={{
|
||||
position: "relative",
|
||||
width,
|
||||
height,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "left top",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<div
|
||||
ref={portalContainer}
|
||||
data-course-portal-root=""
|
||||
className={portalClassName}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CanvasContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
|
||||
export interface CourseCanvasViewport {
|
||||
width: number;
|
||||
height: number;
|
||||
scale: number;
|
||||
originLeft: number;
|
||||
originTop: number;
|
||||
}
|
||||
|
||||
export interface CourseCanvasContextValue {
|
||||
width: number;
|
||||
height: number;
|
||||
scale: number;
|
||||
portalContainer: RefObject<HTMLDivElement | null>;
|
||||
viewport: CourseCanvasViewport;
|
||||
}
|
||||
|
||||
export interface CourseCanvasProps {
|
||||
children: ReactNode;
|
||||
width?: number;
|
||||
height?: number;
|
||||
maxScale?: number;
|
||||
className?: string;
|
||||
canvasClassName?: string;
|
||||
portalClassName?: string;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { RefObject } from "react";
|
||||
|
||||
let portalRoot: RefObject<HTMLDivElement | null> | null = null;
|
||||
|
||||
export function registerAppPortalRoot(
|
||||
container: RefObject<HTMLDivElement | null>,
|
||||
): () => void {
|
||||
portalRoot = container;
|
||||
return () => {
|
||||
if (portalRoot === container) portalRoot = null;
|
||||
};
|
||||
}
|
||||
|
||||
export function getAppPortalRoot(): HTMLDivElement | null {
|
||||
return portalRoot?.current ?? null;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { CanvasContext, useCourseCanvas } from "./CanvasContext";
|
||||
export { CourseCanvas } from "./CourseCanvas";
|
||||
export type {
|
||||
CourseCanvasContextValue,
|
||||
CourseCanvasProps,
|
||||
CourseCanvasViewport,
|
||||
} from "./CourseCanvas.types";
|
||||
export { getAppPortalRoot, registerAppPortalRoot } from "./canvasPortal";
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface CanvasScaleOptions {
|
||||
width: number;
|
||||
height: number;
|
||||
maxScale: number;
|
||||
}
|
||||
|
||||
function getViewportSize() {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
width: window.visualViewport?.width ?? window.innerWidth,
|
||||
height: window.visualViewport?.height ?? window.innerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function calculateScale({
|
||||
width,
|
||||
height,
|
||||
maxScale,
|
||||
}: CanvasScaleOptions): number {
|
||||
if (typeof window === "undefined") return maxScale;
|
||||
const viewport = getViewportSize();
|
||||
if (!viewport) return maxScale;
|
||||
return Math.min(viewport.width / width, viewport.height / height, maxScale);
|
||||
}
|
||||
|
||||
export function useCanvasScale({
|
||||
width,
|
||||
height,
|
||||
maxScale,
|
||||
}: CanvasScaleOptions): number {
|
||||
const [scale, setScale] = useState(() =>
|
||||
calculateScale({ width, height, maxScale }),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const updateScale = () =>
|
||||
setScale(calculateScale({ width, height, maxScale }));
|
||||
updateScale();
|
||||
window.addEventListener("resize", updateScale);
|
||||
window.addEventListener("orientationchange", updateScale);
|
||||
window.visualViewport?.addEventListener("resize", updateScale);
|
||||
window.visualViewport?.addEventListener("scroll", updateScale);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateScale);
|
||||
window.removeEventListener("orientationchange", updateScale);
|
||||
window.visualViewport?.removeEventListener("resize", updateScale);
|
||||
window.visualViewport?.removeEventListener("scroll", updateScale);
|
||||
};
|
||||
}, [height, maxScale, width]);
|
||||
|
||||
return scale;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
@reference "tailwindcss";
|
||||
|
||||
.draggable {
|
||||
@apply touch-none select-none;
|
||||
}
|
||||
|
||||
.dragging {
|
||||
@apply z-10 cursor-grabbing;
|
||||
}
|
||||
|
||||
.droppable {
|
||||
@apply transition-colors;
|
||||
}
|
||||
|
||||
.over {
|
||||
@apply bg-blue-50;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
@apply pointer-events-none opacity-50;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type {
|
||||
CollisionDetection,
|
||||
DndContextProps,
|
||||
DragCancelEvent,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
DragStartEvent,
|
||||
UniqueIdentifier,
|
||||
} from "@dnd-kit/core";
|
||||
import type { CSSProperties, HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export type DragId = UniqueIdentifier;
|
||||
|
||||
export interface DragOverlayRenderState {
|
||||
activeId: DragId;
|
||||
activeData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type DragDropClassName<State> = string | ((state: State) => string | undefined);
|
||||
|
||||
export interface DragDropProviderProps {
|
||||
children: ReactNode;
|
||||
scale?: number;
|
||||
renderOverlay?: (state: DragOverlayRenderState) => ReactNode;
|
||||
collisionDetection?: CollisionDetection;
|
||||
onDragStart?: (event: DragStartEvent) => void;
|
||||
onDragOver?: (event: DragOverEvent) => void;
|
||||
onDragEnd?: (event: DragEndEvent) => void;
|
||||
onDragCancel?: (event: DragCancelEvent) => void;
|
||||
options?: Omit<
|
||||
DndContextProps,
|
||||
| "children"
|
||||
| "collisionDetection"
|
||||
| "sensors"
|
||||
| "onDragStart"
|
||||
| "onDragOver"
|
||||
| "onDragEnd"
|
||||
| "onDragCancel"
|
||||
>;
|
||||
}
|
||||
|
||||
export interface DraggableState {
|
||||
isDragging: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface DraggableProps
|
||||
extends Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
"children" | "className" | "id"
|
||||
> {
|
||||
id: DragId;
|
||||
children: ReactNode | ((state: DraggableState) => ReactNode);
|
||||
disabled?: boolean;
|
||||
data?: Record<string, unknown>;
|
||||
className?: DragDropClassName<DraggableState>;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export interface DroppableState {
|
||||
isOver: boolean;
|
||||
disabled: boolean;
|
||||
activeId: DragId | null;
|
||||
canDrop: boolean;
|
||||
}
|
||||
|
||||
export interface DroppableProps
|
||||
extends Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
"children" | "className" | "id"
|
||||
> {
|
||||
id: DragId;
|
||||
children: ReactNode | ((state: DroppableState) => ReactNode);
|
||||
disabled?: boolean;
|
||||
data?: Record<string, unknown>;
|
||||
activeClassName?: string;
|
||||
canDrop?: (activeId: DragId) => boolean;
|
||||
className?: DragDropClassName<DroppableState>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Transform } from "@dnd-kit/utilities";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
interface DragDropContextValue {
|
||||
scale: number;
|
||||
inverseScale: number;
|
||||
hasOverlay: boolean;
|
||||
getScaledTransform: (transform: Transform | null) => Transform | null;
|
||||
}
|
||||
|
||||
const DragDropContext = createContext<DragDropContextValue>({
|
||||
scale: 1,
|
||||
inverseScale: 1,
|
||||
hasOverlay: false,
|
||||
getScaledTransform: (transform) => transform,
|
||||
});
|
||||
|
||||
export const DragDropContextProvider = DragDropContext.Provider;
|
||||
|
||||
export function useDragDropContext() {
|
||||
return useContext(DragDropContext);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type {
|
||||
DragCancelEvent,
|
||||
DragEndEvent,
|
||||
DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import type { Transform } from "@dnd-kit/utilities";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Portal } from "@/components/ui/portal";
|
||||
import { pointerWithinWithFallback } from "./collision";
|
||||
import type {
|
||||
DragDropProviderProps,
|
||||
DragOverlayRenderState,
|
||||
} from "./DragDrop.types";
|
||||
import { DragDropContextProvider } from "./DragDropContext";
|
||||
|
||||
export function DragDropProvider({
|
||||
children,
|
||||
scale = 1,
|
||||
renderOverlay,
|
||||
collisionDetection = pointerWithinWithFallback,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragEnd,
|
||||
onDragCancel,
|
||||
options,
|
||||
}: DragDropProviderProps) {
|
||||
const safeScale = scale || 1;
|
||||
const inverseScale = 1 / safeScale;
|
||||
const hasOverlay = renderOverlay != null;
|
||||
const [overlayState, setOverlayState] =
|
||||
useState<DragOverlayRenderState | null>(null);
|
||||
const activationDistance = Math.max(2, 6 / safeScale);
|
||||
const touchTolerance = Math.max(4, 8 / safeScale);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: activationDistance,
|
||||
},
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 120,
|
||||
tolerance: touchTolerance,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
scale: safeScale,
|
||||
inverseScale,
|
||||
hasOverlay,
|
||||
getScaledTransform: (transform: Transform | null) =>
|
||||
transform
|
||||
? {
|
||||
...transform,
|
||||
x: transform.x * inverseScale,
|
||||
y: transform.y * inverseScale,
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
[hasOverlay, inverseScale, safeScale],
|
||||
);
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
if (renderOverlay) {
|
||||
setOverlayState({
|
||||
activeId: event.active.id,
|
||||
activeData: event.active.data.current,
|
||||
});
|
||||
}
|
||||
|
||||
onDragStart?.(event);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
setOverlayState(null);
|
||||
onDragEnd?.(event);
|
||||
};
|
||||
|
||||
const handleDragCancel = (event: DragCancelEvent) => {
|
||||
setOverlayState(null);
|
||||
onDragCancel?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<DragDropContextProvider value={contextValue}>
|
||||
<DndContext
|
||||
collisionDetection={collisionDetection}
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
{...options}
|
||||
>
|
||||
{children}
|
||||
{renderOverlay && typeof document !== "undefined" && (
|
||||
<Portal container={document.body}>
|
||||
<DragOverlay adjustScale={false} dropAnimation={null}>
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${safeScale})`,
|
||||
transformOrigin: "top left",
|
||||
}}
|
||||
>
|
||||
{overlayState ? renderOverlay(overlayState) : null}
|
||||
</div>
|
||||
</DragOverlay>
|
||||
</Portal>
|
||||
)}
|
||||
</DndContext>
|
||||
</DragDropContextProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { cn } from "@/lib/cn";
|
||||
import styles from "./DragDrop.module.css";
|
||||
import type { DraggableProps } from "./DragDrop.types";
|
||||
import { useDragDropContext } from "./DragDropContext";
|
||||
|
||||
export function Draggable({
|
||||
id,
|
||||
children,
|
||||
disabled = false,
|
||||
data,
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: DraggableProps) {
|
||||
const { getScaledTransform, hasOverlay } = useDragDropContext();
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||
useDraggable({
|
||||
id,
|
||||
data,
|
||||
disabled,
|
||||
});
|
||||
const scaledTransform = getScaledTransform(transform);
|
||||
const state = {
|
||||
isDragging,
|
||||
disabled,
|
||||
};
|
||||
const resolvedClassName =
|
||||
typeof className === "function" ? className(state) : className;
|
||||
const resolvedChildren =
|
||||
typeof children === "function" ? children(state) : children;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
styles.draggable,
|
||||
isDragging && styles.dragging,
|
||||
disabled && styles.disabled,
|
||||
resolvedClassName,
|
||||
)}
|
||||
style={{
|
||||
...style,
|
||||
transform: hasOverlay
|
||||
? style?.transform
|
||||
: CSS.Translate.toString(scaledTransform),
|
||||
}}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
{...props}
|
||||
>
|
||||
{resolvedChildren}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useDndContext, useDroppable } from "@dnd-kit/core";
|
||||
import { cn } from "@/lib/cn";
|
||||
import styles from "./DragDrop.module.css";
|
||||
import type { DroppableProps, DroppableState } from "./DragDrop.types";
|
||||
|
||||
export function Droppable({
|
||||
id,
|
||||
children,
|
||||
disabled = false,
|
||||
data,
|
||||
className,
|
||||
activeClassName,
|
||||
canDrop,
|
||||
...props
|
||||
}: DroppableProps) {
|
||||
const { active } = useDndContext();
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id,
|
||||
data,
|
||||
disabled,
|
||||
});
|
||||
const activeId = active?.id ?? null;
|
||||
const state: DroppableState = {
|
||||
isOver,
|
||||
disabled,
|
||||
activeId,
|
||||
canDrop: activeId != null ? (canDrop?.(activeId) ?? true) : false,
|
||||
};
|
||||
const resolvedClassName =
|
||||
typeof className === "function" ? className(state) : className;
|
||||
const resolvedChildren =
|
||||
typeof children === "function" ? children(state) : children;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
styles.droppable,
|
||||
isOver && className == null && !activeClassName && styles.over,
|
||||
isOver && activeClassName,
|
||||
disabled && styles.disabled,
|
||||
resolvedClassName,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{resolvedChildren}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
type CollisionDetection,
|
||||
closestCenter,
|
||||
pointerWithin,
|
||||
} from "@dnd-kit/core";
|
||||
|
||||
export const pointerWithinWithFallback: CollisionDetection = (args) => {
|
||||
const pointerCollisions = pointerWithin(args);
|
||||
|
||||
if (args.pointerCoordinates) {
|
||||
return pointerCollisions;
|
||||
}
|
||||
|
||||
return pointerCollisions.length > 0 ? pointerCollisions : closestCenter(args);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
export { pointerWithinWithFallback } from "./collision";
|
||||
export type {
|
||||
DragDropProviderProps,
|
||||
DraggableProps,
|
||||
DraggableState,
|
||||
DragId,
|
||||
DragOverlayRenderState,
|
||||
DroppableProps,
|
||||
DroppableState,
|
||||
} from "./DragDrop.types";
|
||||
export { DragDropProvider } from "./DragDropProvider";
|
||||
export { Draggable } from "./Draggable";
|
||||
export { Droppable } from "./Droppable";
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import styles from "./GuideDialog.module.css";
|
||||
import type { GuideDialogRenderState } from "./GuideDialog.types";
|
||||
|
||||
export interface DefaultGuideDialogPanelProps {
|
||||
dialog: GuideDialogRenderState;
|
||||
}
|
||||
|
||||
export function DefaultGuideDialogPanel({
|
||||
dialog,
|
||||
}: DefaultGuideDialogPanelProps) {
|
||||
const { character, options, progress, status, step, actions, labels } =
|
||||
dialog;
|
||||
|
||||
return (
|
||||
<>
|
||||
{character && (
|
||||
<div className={styles.character}>
|
||||
<div className={styles.avatar}>{character.avatar ?? "🙂"}</div>
|
||||
<div>
|
||||
{character.name && (
|
||||
<div className={styles.name}>{character.name}</div>
|
||||
)}
|
||||
{character.mood && (
|
||||
<div className="text-xs text-gray-400">{character.mood}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.title && <h2 className={styles.title}>{step.title}</h2>}
|
||||
{step.description && (
|
||||
<div className={styles.description}>{step.description}</div>
|
||||
)}
|
||||
|
||||
{options.length > 0 && (
|
||||
<div className={styles.options}>
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.id}
|
||||
variant="outline"
|
||||
onClick={() => actions.selectOption(option.id)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.footer}>
|
||||
<span className={styles.counter}>
|
||||
{progress.current} / {progress.total}
|
||||
</span>
|
||||
<div className={styles.actions}>
|
||||
<Button variant="ghost" size="sm" onClick={actions.skip}>
|
||||
{labels.skip}
|
||||
</Button>
|
||||
{!status.isFirst && (
|
||||
<Button variant="secondary" size="sm" onClick={actions.prev}>
|
||||
{labels.prev}
|
||||
</Button>
|
||||
)}
|
||||
{!status.hasOptions && (
|
||||
<Button size="sm" onClick={actions.primary}>
|
||||
{labels.primary}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
@reference "tailwindcss";
|
||||
|
||||
.mask {
|
||||
@apply pointer-events-auto absolute inset-0 block h-full w-full border-0 p-0;
|
||||
animation: dialog-fade-in 220ms ease-out both;
|
||||
}
|
||||
|
||||
.panelContainer {
|
||||
@apply pointer-events-auto absolute;
|
||||
animation: dialog-panel-in 320ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
transition:
|
||||
left 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
top 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
transform 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
opacity 180ms ease;
|
||||
will-change: left, top, transform, opacity;
|
||||
}
|
||||
|
||||
.panel {
|
||||
@apply pointer-events-auto absolute w-[520px] rounded-3xl bg-white p-6 text-gray-900 shadow-2xl;
|
||||
animation: dialog-panel-in 320ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
transition:
|
||||
left 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
top 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
transform 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
opacity 180ms ease;
|
||||
will-change: left, top, transform, opacity;
|
||||
}
|
||||
|
||||
.measurePanel {
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
visibility: hidden;
|
||||
animation: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.character {
|
||||
@apply mb-4 flex items-center gap-3;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
@apply flex h-14 w-14 items-center justify-center rounded-full bg-blue-100 text-3xl;
|
||||
}
|
||||
|
||||
.name {
|
||||
@apply text-sm font-semibold text-blue-600;
|
||||
}
|
||||
|
||||
.title {
|
||||
@apply text-2xl font-bold;
|
||||
}
|
||||
|
||||
.description {
|
||||
@apply mt-3 text-base leading-7 text-gray-600;
|
||||
}
|
||||
|
||||
.options {
|
||||
@apply mt-5 grid gap-2;
|
||||
}
|
||||
|
||||
.footer {
|
||||
@apply mt-6 flex items-center justify-between gap-3;
|
||||
}
|
||||
|
||||
.counter {
|
||||
@apply text-xs text-gray-400;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
@keyframes dialog-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dialog-panel-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 12px, 0) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mask,
|
||||
.panel,
|
||||
.panelContainer {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
export type GuideDialogPlacement = "top" | "center" | "bottom";
|
||||
|
||||
export interface GuideDialogCharacter {
|
||||
name?: ReactNode;
|
||||
avatar?: ReactNode;
|
||||
mood?: string;
|
||||
}
|
||||
|
||||
export interface GuideDialogOption {
|
||||
id: string;
|
||||
label: ReactNode;
|
||||
nextId?: string;
|
||||
finish?: boolean;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface GuideDialogStep {
|
||||
id: string;
|
||||
character?: GuideDialogCharacter;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
placement?: GuideDialogPlacement;
|
||||
nextLabel?: string;
|
||||
prevLabel?: string;
|
||||
skipLabel?: string;
|
||||
doneLabel?: string;
|
||||
options?: GuideDialogOption[];
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface GuideDialogTheme {
|
||||
overlayClassName?: string;
|
||||
panelClassName?: string;
|
||||
zIndex?: number;
|
||||
panelMaxWidth?: number;
|
||||
overlayColor?: string;
|
||||
}
|
||||
|
||||
export interface GuideDialogState {
|
||||
open: boolean;
|
||||
steps: GuideDialogStep[];
|
||||
current: number;
|
||||
}
|
||||
|
||||
export interface GuideDialogActions {
|
||||
next: () => void;
|
||||
prev: () => void;
|
||||
skip: () => void;
|
||||
finish: () => void;
|
||||
selectOption: (optionId: string) => void;
|
||||
}
|
||||
|
||||
export interface GuideDialogRenderState {
|
||||
step: GuideDialogStep;
|
||||
character?: GuideDialogCharacter;
|
||||
options: GuideDialogOption[];
|
||||
progress: {
|
||||
index: number;
|
||||
current: number;
|
||||
total: number;
|
||||
};
|
||||
status: {
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
hasOptions: boolean;
|
||||
};
|
||||
actions: {
|
||||
prev: () => void;
|
||||
next: () => void;
|
||||
primary: () => void;
|
||||
skip: () => void;
|
||||
finish: () => void;
|
||||
selectOption: (optionId: string) => void;
|
||||
};
|
||||
labels: {
|
||||
prev: string;
|
||||
next: string;
|
||||
skip: string;
|
||||
done: string;
|
||||
primary: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GuideDialogProps {
|
||||
state: GuideDialogState;
|
||||
actions: GuideDialogActions;
|
||||
children?: (dialog: GuideDialogRenderState) => ReactNode;
|
||||
container?: HTMLElement | null | (() => HTMLElement | null);
|
||||
theme?: GuideDialogTheme;
|
||||
viewport?: {
|
||||
width: number;
|
||||
height: number;
|
||||
scale?: number;
|
||||
};
|
||||
closeOnOverlayClick?: boolean;
|
||||
closeOnEsc?: boolean;
|
||||
onStepChange?: (index: number, step: GuideDialogStep) => void;
|
||||
}
|
||||
|
||||
export interface GuideDialogLayout {
|
||||
stepKey: string;
|
||||
portalTarget: HTMLElement;
|
||||
width: number;
|
||||
height: number;
|
||||
panelStyle: CSSProperties;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type { DefaultGuideDialogPanelProps } from "./DefaultGuideDialogPanel";
|
||||
export { DefaultGuideDialogPanel } from "./DefaultGuideDialogPanel";
|
||||
export { GuideDialog } from "./GuideDialog";
|
||||
export type {
|
||||
GuideDialogActions,
|
||||
GuideDialogCharacter,
|
||||
GuideDialogLayout,
|
||||
GuideDialogOption,
|
||||
GuideDialogPlacement,
|
||||
GuideDialogProps,
|
||||
GuideDialogRenderState,
|
||||
GuideDialogState,
|
||||
GuideDialogStep,
|
||||
GuideDialogTheme,
|
||||
} from "./GuideDialog.types";
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import styles from "./GuideSpotlight.module.css";
|
||||
import type { GuideSpotlightRenderState } from "./GuideSpotlight.types";
|
||||
|
||||
export interface DefaultGuideSpotlightPanelProps {
|
||||
spotlight: GuideSpotlightRenderState;
|
||||
}
|
||||
|
||||
export function DefaultGuideSpotlightPanel({
|
||||
spotlight,
|
||||
}: DefaultGuideSpotlightPanelProps) {
|
||||
const { step, progress, status, actions, labels } = spotlight;
|
||||
|
||||
return (
|
||||
<>
|
||||
{step.title && <h2 className={styles.title}>{step.title}</h2>}
|
||||
{step.description && (
|
||||
<div className={styles.description}>{step.description}</div>
|
||||
)}
|
||||
|
||||
<div className={styles.footer}>
|
||||
<span className={styles.counter}>
|
||||
{progress.current} / {progress.total}
|
||||
</span>
|
||||
<div className={styles.actions}>
|
||||
<Button variant="ghost" size="sm" onClick={actions.skip}>
|
||||
{labels.skip}
|
||||
</Button>
|
||||
{!status.isFirst && (
|
||||
<Button variant="secondary" size="sm" onClick={actions.prev}>
|
||||
{labels.prev}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={actions.primary}>
|
||||
{labels.primary}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
@reference "tailwindcss";
|
||||
|
||||
.mask {
|
||||
@apply pointer-events-auto absolute inset-0 block h-full w-full border-0 p-0;
|
||||
animation: spotlight-fade-in 220ms ease-out both;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
@apply absolute border-2 border-white shadow-[0_0_0_1px_rgba(255,255,255,0.35),0_0_28px_rgba(255,255,255,0.5),0_0_56px_rgba(59,130,246,0.22)];
|
||||
pointer-events: none;
|
||||
animation: spotlight-highlight-in 320ms cubic-bezier(0.16, 1, 0.3, 1) both,
|
||||
spotlight-pulse 2400ms ease-in-out 360ms infinite;
|
||||
transition:
|
||||
top 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
left 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
width 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
height 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
border-radius 260ms ease;
|
||||
will-change: top, left, width, height, transform, opacity;
|
||||
}
|
||||
|
||||
.blocker {
|
||||
@apply pointer-events-auto absolute bg-transparent;
|
||||
}
|
||||
|
||||
.panelContainer {
|
||||
@apply pointer-events-auto absolute;
|
||||
animation: spotlight-panel-in 320ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
transition:
|
||||
left 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
top 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
transform 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
opacity 180ms ease;
|
||||
will-change: left, top, transform, opacity;
|
||||
}
|
||||
|
||||
.panel {
|
||||
@apply pointer-events-auto absolute w-80 rounded-xl bg-white p-5 text-gray-900 shadow-2xl;
|
||||
animation: spotlight-panel-in 320ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
transition:
|
||||
left 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
top 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
transform 260ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
opacity 180ms ease;
|
||||
will-change: left, top, transform, opacity;
|
||||
}
|
||||
|
||||
.measurePanel {
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
visibility: hidden;
|
||||
animation: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.missingPanel {
|
||||
@apply pointer-events-auto absolute left-1/2 top-1/2 w-96 max-w-[calc(100%-32px)] -translate-x-1/2 -translate-y-1/2 rounded-xl bg-white p-5 text-gray-900 shadow-2xl;
|
||||
animation: spotlight-missing-panel-in 320ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.missingBadge {
|
||||
@apply mb-3 inline-flex rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-700;
|
||||
}
|
||||
|
||||
.missingSelector {
|
||||
@apply mt-3 block rounded-lg bg-gray-100 px-3 py-2 text-sm text-gray-700;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.title {
|
||||
@apply text-lg font-semibold;
|
||||
}
|
||||
|
||||
.description {
|
||||
@apply mt-2 text-sm leading-6 text-gray-600;
|
||||
}
|
||||
|
||||
.footer {
|
||||
@apply mt-5 flex items-center justify-between gap-3;
|
||||
}
|
||||
|
||||
.counter {
|
||||
@apply text-xs text-gray-400;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
@keyframes spotlight-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spotlight-highlight-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spotlight-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(255 255 255 / 35%),
|
||||
0 0 28px rgb(255 255 255 / 50%),
|
||||
0 0 56px rgb(59 130 246 / 22%);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(255 255 255 / 48%),
|
||||
0 0 36px rgb(255 255 255 / 68%),
|
||||
0 0 72px rgb(59 130 246 / 32%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spotlight-panel-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 10px, 0) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spotlight-missing-panel-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate3d(-50%, calc(-50% + 10px), 0) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate3d(-50%, -50%, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mask,
|
||||
.highlight,
|
||||
.panelContainer,
|
||||
.panel,
|
||||
.missingPanel {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
import {
|
||||
type MouseEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Portal } from "@/components/ui/portal";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { DefaultGuideSpotlightPanel } from "./DefaultGuideSpotlightPanel";
|
||||
import styles from "./GuideSpotlight.module.css";
|
||||
import type {
|
||||
GuideSpotlightProps,
|
||||
Rect,
|
||||
SpotlightLayout,
|
||||
SpotlightStep,
|
||||
} from "./GuideSpotlight.types";
|
||||
|
||||
const DEFAULT_OFFSET = 16;
|
||||
const DEFAULT_PADDING = 8;
|
||||
const DEFAULT_RADIUS = 12;
|
||||
const VIEWPORT_MARGIN = 16;
|
||||
|
||||
interface CoordinateContext {
|
||||
portalTarget: HTMLElement;
|
||||
originLeft: number;
|
||||
originTop: number;
|
||||
scale: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function resolveContainer(container?: GuideSpotlightProps["container"]) {
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof container === "function") {
|
||||
return container();
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
function getCoordinateContext({
|
||||
container,
|
||||
viewport,
|
||||
}: Pick<GuideSpotlightProps, "container" | "viewport">): CoordinateContext {
|
||||
const portalTarget = resolveContainer(container) ?? document.body;
|
||||
|
||||
if (viewport) {
|
||||
return {
|
||||
portalTarget,
|
||||
originLeft: viewport.originLeft ?? 0,
|
||||
originTop: viewport.originTop ?? 0,
|
||||
scale: viewport.scale ?? 1,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
};
|
||||
}
|
||||
|
||||
const rect = portalTarget.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
portalTarget,
|
||||
originLeft: rect.left,
|
||||
originTop: rect.top,
|
||||
scale: 1,
|
||||
width: rect.width || window.innerWidth,
|
||||
height: rect.height || window.innerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function toCanvasRect(rect: DOMRect, context: CoordinateContext): Rect {
|
||||
const top = (rect.top - context.originTop) / context.scale;
|
||||
const left = (rect.left - context.originLeft) / context.scale;
|
||||
const width = rect.width / context.scale;
|
||||
const height = rect.height / context.scale;
|
||||
|
||||
return {
|
||||
top,
|
||||
left,
|
||||
width,
|
||||
height,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
};
|
||||
}
|
||||
|
||||
function getTargetSelector(step: SpotlightStep) {
|
||||
if (typeof step.target === "string") {
|
||||
return step.target;
|
||||
}
|
||||
|
||||
if (step.target.dataGuide) {
|
||||
return `[data-guide="${step.target.dataGuide}"]`;
|
||||
}
|
||||
|
||||
return step.target.selector ?? "";
|
||||
}
|
||||
|
||||
function getTargetElement(step: SpotlightStep) {
|
||||
const selector = getTargetSelector(step);
|
||||
if (!selector) return null;
|
||||
return document.querySelector<HTMLElement>(selector);
|
||||
}
|
||||
|
||||
function getHighlightRect(
|
||||
element: HTMLElement,
|
||||
padding: number,
|
||||
context: CoordinateContext,
|
||||
): Rect {
|
||||
const rect = toCanvasRect(element.getBoundingClientRect(), context);
|
||||
const top = Math.floor(Math.max(rect.top - padding, 0));
|
||||
const left = Math.floor(Math.max(rect.left - padding, 0));
|
||||
const right = Math.ceil(Math.min(rect.right + padding, context.width));
|
||||
const bottom = Math.ceil(Math.min(rect.bottom + padding, context.height));
|
||||
|
||||
return {
|
||||
top,
|
||||
left,
|
||||
width: right - left,
|
||||
height: bottom - top,
|
||||
right,
|
||||
bottom,
|
||||
};
|
||||
}
|
||||
|
||||
function getOverflowScore(
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
context: CoordinateContext,
|
||||
) {
|
||||
const overflowLeft = Math.max(VIEWPORT_MARGIN - left, 0);
|
||||
const overflowTop = Math.max(VIEWPORT_MARGIN - top, 0);
|
||||
const overflowRight = Math.max(
|
||||
left + width + VIEWPORT_MARGIN - context.width,
|
||||
0,
|
||||
);
|
||||
const overflowBottom = Math.max(
|
||||
top + height + VIEWPORT_MARGIN - context.height,
|
||||
0,
|
||||
);
|
||||
|
||||
return overflowLeft + overflowTop + overflowRight + overflowBottom;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function getRoundedRectPath(rect: Rect, radius: number) {
|
||||
const right = rect.left + rect.width;
|
||||
const bottom = rect.top + rect.height;
|
||||
const safeRadius = Math.min(radius, rect.width / 2, rect.height / 2);
|
||||
|
||||
if (safeRadius <= 0) {
|
||||
return `M ${rect.left} ${rect.top} H ${right} V ${bottom} H ${rect.left} Z`;
|
||||
}
|
||||
|
||||
return [
|
||||
`M ${rect.left + safeRadius} ${rect.top}`,
|
||||
`H ${right - safeRadius}`,
|
||||
`Q ${right} ${rect.top} ${right} ${rect.top + safeRadius}`,
|
||||
`V ${bottom - safeRadius}`,
|
||||
`Q ${right} ${bottom} ${right - safeRadius} ${bottom}`,
|
||||
`H ${rect.left + safeRadius}`,
|
||||
`Q ${rect.left} ${bottom} ${rect.left} ${bottom - safeRadius}`,
|
||||
`V ${rect.top + safeRadius}`,
|
||||
`Q ${rect.left} ${rect.top} ${rect.left + safeRadius} ${rect.top}`,
|
||||
"Z",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function getPanelPosition(
|
||||
target: Rect,
|
||||
panel: { width: number; height: number },
|
||||
placement: SpotlightStep["placement"],
|
||||
offset: number,
|
||||
context: CoordinateContext,
|
||||
) {
|
||||
const positions = {
|
||||
top: {
|
||||
left: target.left + target.width / 2 - panel.width / 2,
|
||||
top: target.top - panel.height - offset,
|
||||
},
|
||||
bottom: {
|
||||
left: target.left + target.width / 2 - panel.width / 2,
|
||||
top: target.bottom + offset,
|
||||
},
|
||||
left: {
|
||||
left: target.left - panel.width - offset,
|
||||
top: target.top + target.height / 2 - panel.height / 2,
|
||||
},
|
||||
right: {
|
||||
left: target.right + offset,
|
||||
top: target.top + target.height / 2 - panel.height / 2,
|
||||
},
|
||||
};
|
||||
|
||||
const preferred = placement && placement !== "auto" ? placement : "bottom";
|
||||
const ordered = [
|
||||
preferred,
|
||||
...(["bottom", "top", "right", "left"] as const).filter(
|
||||
(item) => item !== preferred,
|
||||
),
|
||||
];
|
||||
|
||||
const best = ordered
|
||||
.map((item) => ({
|
||||
placement: item,
|
||||
...positions[item],
|
||||
score: getOverflowScore(
|
||||
positions[item].left,
|
||||
positions[item].top,
|
||||
panel.width,
|
||||
panel.height,
|
||||
context,
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.score - b.score)[0];
|
||||
|
||||
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(best.left, VIEWPORT_MARGIN, maxLeft),
|
||||
top: clamp(best.top, VIEWPORT_MARGIN, maxTop),
|
||||
};
|
||||
}
|
||||
|
||||
export function GuideSpotlight({
|
||||
state,
|
||||
actions,
|
||||
children,
|
||||
container,
|
||||
viewport,
|
||||
theme,
|
||||
closeOnOverlayClick = false,
|
||||
closeOnEsc = true,
|
||||
skipIfTargetMissing = true,
|
||||
onStepChange,
|
||||
}: GuideSpotlightProps) {
|
||||
const { open, steps, current } = state;
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [layout, setLayout] = useState<SpotlightLayout | null>(null);
|
||||
|
||||
const step = steps[current];
|
||||
const stepKey = step ? `${current}:${step.id}` : "";
|
||||
const offset = theme?.offset ?? DEFAULT_OFFSET;
|
||||
const radius = step?.radius ?? theme?.radius ?? DEFAULT_RADIUS;
|
||||
const padding = step?.padding ?? DEFAULT_PADDING;
|
||||
const zIndex = theme?.zIndex ?? 1000;
|
||||
const panelMaxWidth = theme?.panelMaxWidth ?? 320;
|
||||
const isLayoutReady = !!layout && layout.stepKey === stepKey;
|
||||
const highlightRect = isLayoutReady ? layout.highlightRect : null;
|
||||
const targetMissingSelector = isLayoutReady
|
||||
? layout.targetMissingSelector
|
||||
: 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 selector = getTargetSelector(step);
|
||||
const context = getCoordinateContext({ container, viewport });
|
||||
const target = getTargetElement(step);
|
||||
if (!target) {
|
||||
console.warn(`[GuideSpotlight] target not found: ${selector}`);
|
||||
|
||||
if (skipIfTargetMissing) {
|
||||
if (current >= steps.length - 1) {
|
||||
closeAsFinish();
|
||||
} else {
|
||||
actions.next();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setLayout({
|
||||
stepKey,
|
||||
portalTarget: context.portalTarget,
|
||||
width: context.width,
|
||||
height: context.height,
|
||||
highlightRect: null,
|
||||
panelStyle: {
|
||||
maxWidth: `${panelMaxWidth}px`,
|
||||
},
|
||||
targetMissingSelector: selector,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nextHighlightRect = getHighlightRect(target, padding, context);
|
||||
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
|
||||
: 180,
|
||||
};
|
||||
const position = getPanelPosition(
|
||||
nextHighlightRect,
|
||||
panelSize,
|
||||
step.placement ?? "auto",
|
||||
offset,
|
||||
context,
|
||||
);
|
||||
|
||||
setLayout({
|
||||
stepKey,
|
||||
portalTarget: context.portalTarget,
|
||||
width: context.width,
|
||||
height: context.height,
|
||||
highlightRect: nextHighlightRect,
|
||||
panelStyle: {
|
||||
left: `${position.left}px`,
|
||||
top: `${position.top}px`,
|
||||
maxWidth: `${panelMaxWidth}px`,
|
||||
},
|
||||
targetMissingSelector: null,
|
||||
});
|
||||
}, [
|
||||
actions,
|
||||
closeAsFinish,
|
||||
container,
|
||||
current,
|
||||
offset,
|
||||
open,
|
||||
padding,
|
||||
panelMaxWidth,
|
||||
skipIfTargetMissing,
|
||||
step,
|
||||
stepKey,
|
||||
steps.length,
|
||||
viewport,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
measure();
|
||||
}, [measure]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !step) return;
|
||||
onStepChange?.(current, step);
|
||||
}, [current, open, onStepChange, step]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const scrollKeys = new Set([
|
||||
"ArrowDown",
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"ArrowUp",
|
||||
"End",
|
||||
"Home",
|
||||
"PageDown",
|
||||
"PageUp",
|
||||
" ",
|
||||
]);
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
const previousOverscrollBehavior = document.body.style.overscrollBehavior;
|
||||
const preventScroll = (event: Event) => event.preventDefault();
|
||||
const preventKeyboardScroll = (event: KeyboardEvent) => {
|
||||
if (scrollKeys.has(event.key)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
document.body.style.overflow = "hidden";
|
||||
document.body.style.overscrollBehavior = "none";
|
||||
window.addEventListener("wheel", preventScroll, {
|
||||
capture: true,
|
||||
passive: false,
|
||||
});
|
||||
window.addEventListener("touchmove", preventScroll, {
|
||||
capture: true,
|
||||
passive: false,
|
||||
});
|
||||
window.addEventListener("keydown", preventKeyboardScroll, true);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.body.style.overscrollBehavior = previousOverscrollBehavior;
|
||||
window.removeEventListener("wheel", preventScroll, true);
|
||||
window.removeEventListener("touchmove", preventScroll, true);
|
||||
window.removeEventListener("keydown", preventKeyboardScroll, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleResize = () => measure();
|
||||
const handleScroll = () => measure();
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (closeOnEsc && event.key === "Escape") {
|
||||
closeAsSkip();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
const target = step ? getTargetElement(step) : null;
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? null
|
||||
: new ResizeObserver(() => measure());
|
||||
if (target) resizeObserver?.observe(target);
|
||||
if (panelRef.current) resizeObserver?.observe(panelRef.current);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
window.removeEventListener("scroll", handleScroll, true);
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
resizeObserver?.disconnect();
|
||||
};
|
||||
}, [closeAsSkip, closeOnEsc, measure, open, step]);
|
||||
|
||||
if (!open || !step || typeof document === "undefined") return null;
|
||||
|
||||
const maskStyle = {
|
||||
zIndex,
|
||||
};
|
||||
const isFirst = current === 0;
|
||||
const isLast = current === steps.length - 1;
|
||||
|
||||
const overlayClick = (event: MouseEvent<SVGPathElement>) => {
|
||||
event.stopPropagation();
|
||||
if (closeOnOverlayClick) {
|
||||
closeAsSkip();
|
||||
}
|
||||
};
|
||||
|
||||
const primaryAction = isLast ? closeAsFinish : actions.next;
|
||||
const spotlight = {
|
||||
step,
|
||||
progress: {
|
||||
index: current,
|
||||
current: current + 1,
|
||||
total: steps.length,
|
||||
},
|
||||
status: {
|
||||
isFirst,
|
||||
isLast,
|
||||
},
|
||||
actions: {
|
||||
prev: actions.prev,
|
||||
next: actions.next,
|
||||
primary: primaryAction,
|
||||
skip: closeAsSkip,
|
||||
finish: closeAsFinish,
|
||||
},
|
||||
labels: {
|
||||
prev: step.prevLabel ?? "上一步",
|
||||
next: step.nextLabel ?? "下一步",
|
||||
skip: step.skipLabel ?? "跳过",
|
||||
done: step.doneLabel ?? "完成",
|
||||
primary: isLast
|
||||
? (step.doneLabel ?? "完成")
|
||||
: (step.nextLabel ?? "下一步"),
|
||||
},
|
||||
highlight: {
|
||||
rect: highlightRect,
|
||||
targetMissingSelector,
|
||||
},
|
||||
};
|
||||
const panelContent = children ? (
|
||||
children(spotlight)
|
||||
) : (
|
||||
<DefaultGuideSpotlightPanel spotlight={spotlight} />
|
||||
);
|
||||
|
||||
const panelClassName = children ? styles.panelContainer : styles.panel;
|
||||
|
||||
if (!highlightRect && !targetMissingSelector) {
|
||||
return (
|
||||
<Portal
|
||||
container={getCoordinateContext({ container, viewport }).portalTarget}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
panelClassName,
|
||||
styles.measurePanel,
|
||||
theme?.panelClassName,
|
||||
step.panelClassName,
|
||||
)}
|
||||
style={{
|
||||
maxWidth: `${panelMaxWidth}px`,
|
||||
}}
|
||||
>
|
||||
{panelContent}
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
const activeLayout = layout;
|
||||
if (!activeLayout) return null;
|
||||
|
||||
if (targetMissingSelector) {
|
||||
return (
|
||||
<Portal container={activeLayout.portalTarget}>
|
||||
<div
|
||||
aria-modal="true"
|
||||
className={styles.missingPanel}
|
||||
role="dialog"
|
||||
style={{ zIndex: zIndex + 3 }}
|
||||
>
|
||||
<div className={styles.missingBadge}>DEV</div>
|
||||
<h2 className={styles.title}>引导目标未找到</h2>
|
||||
<p className={styles.description}>
|
||||
请检查当前步骤的 data-guide 或 selector 是否和页面元素一致。
|
||||
</p>
|
||||
<code className={styles.missingSelector}>
|
||||
{targetMissingSelector}
|
||||
</code>
|
||||
<div className={styles.footer}>
|
||||
<span className={styles.counter}>
|
||||
{current + 1} / {steps.length}
|
||||
</span>
|
||||
<div className={styles.actions}>
|
||||
<Button variant="ghost" size="sm" onClick={closeAsSkip}>
|
||||
跳过
|
||||
</Button>
|
||||
<Button size="sm" onClick={primaryAction}>
|
||||
{isLast ? "完成" : "下一步"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
if (!highlightRect) return null;
|
||||
|
||||
const activeRect = highlightRect;
|
||||
|
||||
return (
|
||||
<Portal container={activeLayout.portalTarget}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={cn(styles.mask, theme?.overlayClassName)}
|
||||
style={maskStyle}
|
||||
viewBox={`0 0 ${activeLayout.width} ${activeLayout.height}`}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: click area must follow the SVG cutout path so the target hole stays interactive. */}
|
||||
<path
|
||||
d={`M 0 0 H ${activeLayout.width} V ${activeLayout.height} H 0 Z ${getRoundedRectPath(activeRect, radius)}`}
|
||||
fill={theme?.overlayColor ?? "rgb(0 0 0 / 0.6)"}
|
||||
fillRule="evenodd"
|
||||
onClick={overlayClick}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{!step.allowTargetClick && (
|
||||
<div
|
||||
className={styles.blocker}
|
||||
style={{
|
||||
zIndex: zIndex + 1,
|
||||
top: `${activeRect.top}px`,
|
||||
left: `${activeRect.left}px`,
|
||||
width: `${activeRect.width}px`,
|
||||
height: `${activeRect.height}px`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
key={`highlight-${stepKey}`}
|
||||
className={cn(styles.highlight, theme?.highlightClassName)}
|
||||
style={{
|
||||
zIndex: zIndex + 2,
|
||||
top: `${activeRect.top}px`,
|
||||
left: `${activeRect.left}px`,
|
||||
width: `${activeRect.width}px`,
|
||||
height: `${activeRect.height}px`,
|
||||
borderRadius: `${radius}px`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
key={`panel-${stepKey}`}
|
||||
ref={panelRef}
|
||||
aria-modal="true"
|
||||
className={cn(
|
||||
panelClassName,
|
||||
theme?.panelClassName,
|
||||
step.panelClassName,
|
||||
)}
|
||||
role="dialog"
|
||||
style={{
|
||||
zIndex: zIndex + 3,
|
||||
...activeLayout.panelStyle,
|
||||
}}
|
||||
>
|
||||
{panelContent}
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
export type SpotlightPlacement = "top" | "bottom" | "left" | "right" | "auto";
|
||||
|
||||
export interface SpotlightTarget {
|
||||
selector?: string;
|
||||
dataGuide?: string;
|
||||
}
|
||||
|
||||
export interface SpotlightStep {
|
||||
id: string;
|
||||
target: string | SpotlightTarget;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
placement?: SpotlightPlacement;
|
||||
allowTargetClick?: boolean;
|
||||
padding?: number;
|
||||
radius?: number;
|
||||
panelClassName?: string;
|
||||
nextLabel?: string;
|
||||
prevLabel?: string;
|
||||
doneLabel?: string;
|
||||
skipLabel?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface Rect {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export interface SpotlightLayout {
|
||||
stepKey: string;
|
||||
portalTarget: HTMLElement;
|
||||
width: number;
|
||||
height: number;
|
||||
highlightRect: Rect | null;
|
||||
panelStyle: CSSProperties;
|
||||
targetMissingSelector: string | null;
|
||||
}
|
||||
|
||||
export interface GuideSpotlightTheme {
|
||||
overlayClassName?: string;
|
||||
panelClassName?: string;
|
||||
highlightClassName?: string;
|
||||
zIndex?: number;
|
||||
panelMaxWidth?: number;
|
||||
offset?: number;
|
||||
radius?: number;
|
||||
overlayColor?: string;
|
||||
}
|
||||
|
||||
export interface GuideSpotlightState {
|
||||
open: boolean;
|
||||
steps: SpotlightStep[];
|
||||
current: number;
|
||||
}
|
||||
|
||||
export interface GuideSpotlightActions {
|
||||
next: () => void;
|
||||
prev: () => void;
|
||||
skip: () => void;
|
||||
finish: () => void;
|
||||
}
|
||||
|
||||
export interface GuideSpotlightRenderState {
|
||||
step: SpotlightStep;
|
||||
progress: {
|
||||
index: number;
|
||||
current: number;
|
||||
total: number;
|
||||
};
|
||||
status: {
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
};
|
||||
actions: {
|
||||
prev: () => void;
|
||||
next: () => void;
|
||||
primary: () => void;
|
||||
skip: () => void;
|
||||
finish: () => void;
|
||||
};
|
||||
labels: {
|
||||
prev: string;
|
||||
next: string;
|
||||
skip: string;
|
||||
done: string;
|
||||
primary: string;
|
||||
};
|
||||
highlight: {
|
||||
rect: Rect | null;
|
||||
targetMissingSelector: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GuideSpotlightProps {
|
||||
state: GuideSpotlightState;
|
||||
actions: GuideSpotlightActions;
|
||||
children?: (spotlight: GuideSpotlightRenderState) => ReactNode;
|
||||
container?: HTMLElement | null | (() => HTMLElement | null);
|
||||
viewport?: {
|
||||
width: number;
|
||||
height: number;
|
||||
scale?: number;
|
||||
originLeft?: number;
|
||||
originTop?: number;
|
||||
};
|
||||
theme?: GuideSpotlightTheme;
|
||||
closeOnOverlayClick?: boolean;
|
||||
closeOnEsc?: boolean;
|
||||
skipIfTargetMissing?: boolean;
|
||||
onStepChange?: (index: number, step: SpotlightStep) => void;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type { DefaultGuideSpotlightPanelProps } from "./DefaultGuideSpotlightPanel";
|
||||
export { DefaultGuideSpotlightPanel } from "./DefaultGuideSpotlightPanel";
|
||||
export { GuideSpotlight } from "./GuideSpotlight";
|
||||
export type {
|
||||
GuideSpotlightActions,
|
||||
GuideSpotlightProps,
|
||||
GuideSpotlightRenderState,
|
||||
GuideSpotlightState,
|
||||
GuideSpotlightTheme,
|
||||
Rect,
|
||||
SpotlightLayout,
|
||||
SpotlightPlacement,
|
||||
SpotlightStep,
|
||||
SpotlightTarget,
|
||||
} from "./GuideSpotlight.types";
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { OrientationGuardProps } from "./OrientationGuard.types";
|
||||
import { useOrientation } from "./useOrientation";
|
||||
|
||||
function DefaultLandscapePrompt() {
|
||||
return (
|
||||
<div className="fixed inset-0 z-2000 flex items-center justify-center bg-gray-950/80 px-8 text-white">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="h-full w-full text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<rect x="2" y="5" width="20" height="14" rx="2" />
|
||||
<circle cx="12" cy="12" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold">请旋转屏幕</h1>
|
||||
<p className="mt-3 text-base text-gray-400">
|
||||
为获得最佳体验,请横屏浏览
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrientationGuard({ renderPrompt }: OrientationGuardProps) {
|
||||
const isPortrait = useOrientation();
|
||||
|
||||
if (!isPortrait) return null;
|
||||
if (renderPrompt) return <>{renderPrompt()}</>;
|
||||
|
||||
return <DefaultLandscapePrompt />;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface OrientationGuardProps {
|
||||
/** 竖屏时渲染的自定义提示;不传则用内置的横屏提示 */
|
||||
renderPrompt?: () => ReactNode;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { OrientationGuard } from "./OrientationGuard";
|
||||
export type { OrientationGuardProps } from "./OrientationGuard.types";
|
||||
export { useOrientation } from "./useOrientation";
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
function getIsPortrait() {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.innerHeight > window.innerWidth;
|
||||
}
|
||||
|
||||
export function useOrientation() {
|
||||
const [isPortrait, setIsPortrait] = useState(getIsPortrait);
|
||||
|
||||
useEffect(() => {
|
||||
const updateOrientation = () => setIsPortrait(getIsPortrait());
|
||||
window.addEventListener("resize", updateOrientation);
|
||||
window.addEventListener("orientationchange", updateOrientation);
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateOrientation);
|
||||
window.removeEventListener("orientationchange", updateOrientation);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isPortrait;
|
||||
}
|
||||
Reference in new issue
Block a user