feat: 引入 yuxiang-kit 课程组件源码与运行时
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
77742d5b02
commit
3995656175
41 files changed
+2752
No files matched your search
@@ -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,289 @@
|
|||||||
|
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 panelRect = panelRef.current?.getBoundingClientRect();
|
||||||
|
const panelSize = {
|
||||||
|
width: panelRect ? panelRect.width / context.scale : panelMaxWidth,
|
||||||
|
height: panelRect ? panelRect.height / 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(
|
||||||
|
styles.panel,
|
||||||
|
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,157 @@
|
|||||||
|
@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,
|
||||||
|
.panel,
|
||||||
|
.missingPanel {
|
||||||
|
animation: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,625 @@
|
|||||||
|
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 panelRect = panelRef.current?.getBoundingClientRect();
|
||||||
|
const panelSize = {
|
||||||
|
width: panelRect ? panelRect.width / context.scale : panelMaxWidth,
|
||||||
|
height: panelRect ? panelRect.height / 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(
|
||||||
|
styles.panel,
|
||||||
|
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,48 @@
|
|||||||
|
@reference "tailwindcss";
|
||||||
|
|
||||||
|
.button {
|
||||||
|
@apply relative m-0 box-border inline-flex cursor-pointer select-none appearance-none items-center justify-center overflow-hidden rounded-md border border-transparent bg-transparent p-0 align-middle font-medium leading-none transition-colors;
|
||||||
|
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset;
|
||||||
|
@apply disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
@apply bg-blue-600 text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary::before {
|
||||||
|
@apply pointer-events-none absolute inset-0 rounded-[inherit] bg-black/0 transition-colors;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary:hover::before {
|
||||||
|
@apply bg-black/10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
@apply relative z-10 inline-flex items-center justify-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
@apply bg-gray-200 text-gray-900 hover:bg-gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline {
|
||||||
|
@apply border-gray-300 bg-transparent hover:bg-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost {
|
||||||
|
@apply bg-transparent hover:bg-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sm {
|
||||||
|
@apply h-8 px-3 text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md {
|
||||||
|
@apply h-10 px-4 text-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lg {
|
||||||
|
@apply h-12 px-6 text-lg;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { forwardRef } from "react";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import styles from "./Button.module.css";
|
||||||
|
import type { ButtonProps } from "./Button.types";
|
||||||
|
|
||||||
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
(
|
||||||
|
{ children, className, variant = "primary", size = "md", ...props },
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
className={cn(styles.button, styles[variant], styles[size], className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className={styles.content}>{children}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Button.displayName = "Button";
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { ButtonHTMLAttributes } from "react";
|
||||||
|
|
||||||
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: "primary" | "secondary" | "outline" | "ghost";
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { Button } from "./Button";
|
||||||
|
export type { ButtonProps } from "./Button.types";
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
@reference "tailwindcss";
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
@apply pointer-events-auto inset-0 z-50 overscroll-contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed {
|
||||||
|
@apply fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.absolute {
|
||||||
|
@apply absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background {
|
||||||
|
@apply absolute inset-0 cursor-default appearance-none border-0 p-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background:disabled {
|
||||||
|
@apply cursor-default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dim {
|
||||||
|
@apply bg-black/60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light {
|
||||||
|
@apply bg-white/60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transparent {
|
||||||
|
@apply bg-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
@apply pointer-events-none absolute inset-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contentInner {
|
||||||
|
@apply pointer-events-auto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { CSSProperties, MouseEvent } from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Portal } from "@/components/ui/portal";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import styles from "./Overlay.module.css";
|
||||||
|
import type { OverlayProps } from "./Overlay.types";
|
||||||
|
|
||||||
|
export function Overlay({
|
||||||
|
open,
|
||||||
|
children,
|
||||||
|
variant = "dim",
|
||||||
|
backgroundImage,
|
||||||
|
backgroundClassName,
|
||||||
|
closeOnBackgroundClick = true,
|
||||||
|
closeOnEscape = true,
|
||||||
|
lockScroll = true,
|
||||||
|
onClose,
|
||||||
|
container,
|
||||||
|
disabledPortal = false,
|
||||||
|
className,
|
||||||
|
contentClassName,
|
||||||
|
fixed,
|
||||||
|
...props
|
||||||
|
}: OverlayProps) {
|
||||||
|
const isFixed = fixed ?? container == null;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !closeOnEscape) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
onClose?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [closeOnEscape, onClose, open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !lockScroll || typeof document === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = previousOverflow;
|
||||||
|
};
|
||||||
|
}, [lockScroll, open]);
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backgroundImageStyle: CSSProperties | undefined = backgroundImage
|
||||||
|
? { backgroundImage }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const handleBackgroundClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
event.currentTarget.blur();
|
||||||
|
onClose?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Portal container={container} disabled={disabledPortal}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
styles.overlay,
|
||||||
|
isFixed ? styles.fixed : styles.absolute,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="关闭弹层背景"
|
||||||
|
className={cn(
|
||||||
|
styles.background,
|
||||||
|
styles[variant],
|
||||||
|
backgroundClassName,
|
||||||
|
)}
|
||||||
|
disabled={!closeOnBackgroundClick}
|
||||||
|
style={backgroundImageStyle}
|
||||||
|
onClick={handleBackgroundClick}
|
||||||
|
/>
|
||||||
|
<div className={cn(styles.content, contentClassName)}>
|
||||||
|
<div className={styles.contentInner}>{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { HTMLAttributes, ReactNode } from "react";
|
||||||
|
import type { PortalContainer } from "@/components/ui/portal";
|
||||||
|
|
||||||
|
export interface OverlayProps
|
||||||
|
extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
||||||
|
open: boolean;
|
||||||
|
children?: ReactNode;
|
||||||
|
variant?: "dim" | "light" | "transparent";
|
||||||
|
backgroundImage?: string;
|
||||||
|
backgroundClassName?: string;
|
||||||
|
closeOnBackgroundClick?: boolean;
|
||||||
|
closeOnEscape?: boolean;
|
||||||
|
lockScroll?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
container?: PortalContainer;
|
||||||
|
disabledPortal?: boolean;
|
||||||
|
contentClassName?: string;
|
||||||
|
fixed?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { Overlay } from "./Overlay";
|
||||||
|
export type { OverlayProps } from "./Overlay.types";
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import type { PortalContainer, PortalProps } from "./Portal.types";
|
||||||
|
|
||||||
|
function resolveContainer(container?: PortalContainer) {
|
||||||
|
if (!container) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof container === "function") {
|
||||||
|
return container();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("current" in container) {
|
||||||
|
return container.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Portal({ children, container, disabled = false }: PortalProps) {
|
||||||
|
if (disabled || typeof document === "undefined") {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(children, resolveContainer(container) ?? document.body);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { ReactNode, RefObject } from "react";
|
||||||
|
|
||||||
|
export type PortalContainer =
|
||||||
|
| Element
|
||||||
|
| null
|
||||||
|
| RefObject<Element | null>
|
||||||
|
| (() => Element | null);
|
||||||
|
|
||||||
|
export interface PortalProps {
|
||||||
|
children: ReactNode;
|
||||||
|
container?: PortalContainer;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { Portal } from "./Portal";
|
||||||
|
export type { PortalContainer, PortalProps } from "./Portal.types";
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
@reference "tailwindcss";
|
||||||
|
|
||||||
|
.container {
|
||||||
|
@apply pointer-events-none absolute top-1/2 left-1/2 z-60 flex w-auto max-w-[calc(100%-80px)] -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customToast {
|
||||||
|
@apply pointer-events-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
@apply pointer-events-auto inline-flex max-w-[520px] items-center justify-center rounded-lg bg-black/75 px-6 py-3 text-center text-white shadow-[0_8px_24px_rgba(0,0,0,0.18)] backdrop-blur-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
@apply min-w-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
@apply hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
@apply text-sm font-medium leading-6 text-current;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
@apply hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info,
|
||||||
|
.success,
|
||||||
|
.warning,
|
||||||
|
.error {
|
||||||
|
@apply text-white;
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { PortalContainer } from "@/components/ui/portal";
|
||||||
|
|
||||||
|
export type ToastType = "info" | "success" | "warning" | "error";
|
||||||
|
|
||||||
|
export interface ToastOptions {
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
type?: ToastType;
|
||||||
|
duration?: number;
|
||||||
|
className?: string;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToastItem
|
||||||
|
extends Required<Omit<ToastOptions, "className" | "data">> {
|
||||||
|
id: string;
|
||||||
|
className?: string;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToastProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToastContextValue {
|
||||||
|
toasts: ToastItem[];
|
||||||
|
show: (options: ToastOptions | string) => string;
|
||||||
|
success: (
|
||||||
|
message: string,
|
||||||
|
options?: Omit<ToastOptions, "message" | "type">,
|
||||||
|
) => string;
|
||||||
|
error: (
|
||||||
|
message: string,
|
||||||
|
options?: Omit<ToastOptions, "message" | "type">,
|
||||||
|
) => string;
|
||||||
|
warning: (
|
||||||
|
message: string,
|
||||||
|
options?: Omit<ToastOptions, "message" | "type">,
|
||||||
|
) => string;
|
||||||
|
info: (
|
||||||
|
message: string,
|
||||||
|
options?: Omit<ToastOptions, "message" | "type">,
|
||||||
|
) => string;
|
||||||
|
remove: (id: string) => void;
|
||||||
|
clear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToastRenderApi {
|
||||||
|
remove: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToastViewportClassNames {
|
||||||
|
container?: string;
|
||||||
|
item?: string;
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
close?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToastViewportProps {
|
||||||
|
container?: PortalContainer;
|
||||||
|
className?: string;
|
||||||
|
classNames?: ToastViewportClassNames;
|
||||||
|
renderToast?: (toast: ToastItem, api: ToastRenderApi) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToastItemProps {
|
||||||
|
toast: ToastItem;
|
||||||
|
classNames?: ToastViewportClassNames;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import styles from "./Toast.module.css";
|
||||||
|
import type { ToastItemProps } from "./Toast.types";
|
||||||
|
|
||||||
|
export function ToastItem({ toast, classNames, onRemove }: ToastItemProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
styles.toast,
|
||||||
|
styles[toast.type],
|
||||||
|
classNames?.item,
|
||||||
|
toast.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={cn(styles.title, classNames?.title)}>{toast.title}</div>
|
||||||
|
<div className={cn(styles.message, classNames?.message)}>
|
||||||
|
{toast.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(styles.close, classNames?.close)}
|
||||||
|
onClick={() => onRemove(toast.id)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import type {
|
||||||
|
ToastContextValue,
|
||||||
|
ToastItem,
|
||||||
|
ToastOptions,
|
||||||
|
ToastProviderProps,
|
||||||
|
ToastType,
|
||||||
|
} from "./Toast.types";
|
||||||
|
|
||||||
|
const DEFAULT_DURATION = 2400;
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||||
|
|
||||||
|
function createToastId() {
|
||||||
|
return `toast-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultTitle(type: ToastType) {
|
||||||
|
const titleMap: Record<ToastType, string> = {
|
||||||
|
info: "提示",
|
||||||
|
success: "成功",
|
||||||
|
warning: "注意",
|
||||||
|
error: "错误",
|
||||||
|
};
|
||||||
|
|
||||||
|
return titleMap[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: ToastProviderProps) {
|
||||||
|
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||||
|
const timersRef = useRef<Map<string, number>>(new Map());
|
||||||
|
|
||||||
|
const remove = useCallback((id: string) => {
|
||||||
|
const timer = timersRef.current.get(id);
|
||||||
|
if (timer) {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
timersRef.current.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
setToasts((current) => current.filter((toast) => toast.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
timersRef.current.forEach((timer) => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
});
|
||||||
|
timersRef.current.clear();
|
||||||
|
setToasts([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
timersRef.current.forEach((timer) => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
});
|
||||||
|
timersRef.current.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const show = useCallback(
|
||||||
|
(options: ToastOptions | string) => {
|
||||||
|
const normalizedOptions: ToastOptions =
|
||||||
|
typeof options === "string" ? { message: options } : options;
|
||||||
|
const type = normalizedOptions.type ?? "info";
|
||||||
|
const id = createToastId();
|
||||||
|
const toast: ToastItem = {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
title: normalizedOptions.title ?? getDefaultTitle(type),
|
||||||
|
message: normalizedOptions.message,
|
||||||
|
duration: normalizedOptions.duration ?? DEFAULT_DURATION,
|
||||||
|
className: normalizedOptions.className,
|
||||||
|
data: normalizedOptions.data,
|
||||||
|
};
|
||||||
|
|
||||||
|
setToasts((current) => [...current, toast]);
|
||||||
|
|
||||||
|
if (toast.duration > 0) {
|
||||||
|
const timer = window.setTimeout(() => remove(id), toast.duration);
|
||||||
|
timersRef.current.set(id, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
[remove],
|
||||||
|
);
|
||||||
|
|
||||||
|
const contextValue = useMemo<ToastContextValue>(
|
||||||
|
() => ({
|
||||||
|
toasts,
|
||||||
|
show,
|
||||||
|
success: (message, options) =>
|
||||||
|
show({ ...options, message, type: "success" }),
|
||||||
|
error: (message, options) => show({ ...options, message, type: "error" }),
|
||||||
|
warning: (message, options) =>
|
||||||
|
show({ ...options, message, type: "warning" }),
|
||||||
|
info: (message, options) => show({ ...options, message, type: "info" }),
|
||||||
|
remove,
|
||||||
|
clear,
|
||||||
|
}),
|
||||||
|
[clear, remove, show, toasts],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const context = useContext(ToastContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useToast must be used within ToastProvider");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Portal } from "@/components/ui/portal";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import styles from "./Toast.module.css";
|
||||||
|
import type { ToastViewportProps } from "./Toast.types";
|
||||||
|
import { ToastItem } from "./ToastItem";
|
||||||
|
import { useToast } from "./ToastProvider";
|
||||||
|
|
||||||
|
export function ToastViewport({
|
||||||
|
container,
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
renderToast,
|
||||||
|
}: ToastViewportProps) {
|
||||||
|
const { toasts, remove } = useToast();
|
||||||
|
|
||||||
|
if (toasts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Portal container={container}>
|
||||||
|
<div className={cn(styles.container, classNames?.container, className)}>
|
||||||
|
{toasts.map((toast) =>
|
||||||
|
renderToast ? (
|
||||||
|
<div key={toast.id} className={styles.customToast}>
|
||||||
|
{renderToast(toast, { remove })}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ToastItem
|
||||||
|
key={toast.id}
|
||||||
|
toast={toast}
|
||||||
|
classNames={classNames}
|
||||||
|
onRemove={remove}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export type {
|
||||||
|
ToastContextValue,
|
||||||
|
ToastItem,
|
||||||
|
ToastOptions,
|
||||||
|
ToastProviderProps,
|
||||||
|
ToastRenderApi,
|
||||||
|
ToastType,
|
||||||
|
ToastViewportClassNames,
|
||||||
|
ToastViewportProps,
|
||||||
|
} from "./Toast.types";
|
||||||
|
export { ToastProvider, useToast } from "./ToastProvider";
|
||||||
|
export { ToastViewport } from "./ToastViewport";
|
||||||
@@ -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,7 @@
|
|||||||
|
export { CanvasContext, useCourseCanvas } from "./CanvasContext";
|
||||||
|
export { CourseCanvas } from "./CourseCanvas";
|
||||||
|
export type {
|
||||||
|
CourseCanvasContextValue,
|
||||||
|
CourseCanvasProps,
|
||||||
|
CourseCanvasViewport,
|
||||||
|
} from "./CourseCanvas.types";
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface CanvasScaleOptions {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
maxScale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateScale({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
maxScale,
|
||||||
|
}: CanvasScaleOptions): number {
|
||||||
|
if (typeof window === "undefined") return maxScale;
|
||||||
|
return Math.min(
|
||||||
|
window.innerWidth / width,
|
||||||
|
window.innerHeight / 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.visualViewport?.addEventListener("resize", updateScale);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", updateScale);
|
||||||
|
window.visualViewport?.removeEventListener("resize", updateScale);
|
||||||
|
};
|
||||||
|
}, [height, maxScale, width]);
|
||||||
|
|
||||||
|
return scale;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并 className,并处理 Tailwind 冲突。
|
||||||
|
*/
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
Reference in new issue
Block a user