feat: 引入 yuxiang-kit 课程组件源码与运行时

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 4.8 committed 2026-08-18 13:39:36 +08:00
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;
}
+23
View File
@@ -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";
+6
View File
@@ -0,0 +1,6 @@
import type { ButtonHTMLAttributes } from "react";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "outline" | "ghost";
size?: "sm" | "md" | "lg";
}
+2
View File
@@ -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;
}
+98
View File
@@ -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;
}
+2
View File
@@ -0,0 +1,2 @@
export { Overlay } from "./Overlay";
export type { OverlayProps } from "./Overlay.types";
+26
View File
@@ -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);
}
+13
View File
@@ -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;
}
+2
View File
@@ -0,0 +1,2 @@
export { Portal } from "./Portal";
export type { PortalContainer, PortalProps } from "./Portal.types";
+36
View File
@@ -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;
}
+72
View File
@@ -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;
}
+30
View File
@@ -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>
);
}
+127
View File
@@ -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;
}
+40
View File
@@ -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>
);
}
+12
View File
@@ -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;
}
+134
View File
@@ -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;
}
+7
View File
@@ -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;
}
+9
View File
@@ -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));
}