diff --git a/src/components/course-interactions/drag-drop/DragDrop.module.css b/src/components/course-interactions/drag-drop/DragDrop.module.css new file mode 100644 index 0000000..5d14d2e --- /dev/null +++ b/src/components/course-interactions/drag-drop/DragDrop.module.css @@ -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; +} diff --git a/src/components/course-interactions/drag-drop/DragDrop.types.ts b/src/components/course-interactions/drag-drop/DragDrop.types.ts new file mode 100644 index 0000000..05c6400 --- /dev/null +++ b/src/components/course-interactions/drag-drop/DragDrop.types.ts @@ -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; +} + +type DragDropClassName = 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, + "children" | "className" | "id" + > { + id: DragId; + children: ReactNode | ((state: DraggableState) => ReactNode); + disabled?: boolean; + data?: Record; + className?: DragDropClassName; + style?: CSSProperties; +} + +export interface DroppableState { + isOver: boolean; + disabled: boolean; + activeId: DragId | null; + canDrop: boolean; +} + +export interface DroppableProps + extends Omit< + HTMLAttributes, + "children" | "className" | "id" + > { + id: DragId; + children: ReactNode | ((state: DroppableState) => ReactNode); + disabled?: boolean; + data?: Record; + activeClassName?: string; + canDrop?: (activeId: DragId) => boolean; + className?: DragDropClassName; +} diff --git a/src/components/course-interactions/drag-drop/DragDropContext.tsx b/src/components/course-interactions/drag-drop/DragDropContext.tsx new file mode 100644 index 0000000..f92e27a --- /dev/null +++ b/src/components/course-interactions/drag-drop/DragDropContext.tsx @@ -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({ + scale: 1, + inverseScale: 1, + hasOverlay: false, + getScaledTransform: (transform) => transform, +}); + +export const DragDropContextProvider = DragDropContext.Provider; + +export function useDragDropContext() { + return useContext(DragDropContext); +} diff --git a/src/components/course-interactions/drag-drop/DragDropProvider.tsx b/src/components/course-interactions/drag-drop/DragDropProvider.tsx new file mode 100644 index 0000000..fe33960 --- /dev/null +++ b/src/components/course-interactions/drag-drop/DragDropProvider.tsx @@ -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(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 ( + + + {children} + {renderOverlay && typeof document !== "undefined" && ( + + +
+ {overlayState ? renderOverlay(overlayState) : null} +
+
+
+ )} +
+
+ ); +} diff --git a/src/components/course-interactions/drag-drop/Draggable.tsx b/src/components/course-interactions/drag-drop/Draggable.tsx new file mode 100644 index 0000000..06afbcd --- /dev/null +++ b/src/components/course-interactions/drag-drop/Draggable.tsx @@ -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 ( +
+ {resolvedChildren} +
+ ); +} diff --git a/src/components/course-interactions/drag-drop/Droppable.tsx b/src/components/course-interactions/drag-drop/Droppable.tsx new file mode 100644 index 0000000..f1628ac --- /dev/null +++ b/src/components/course-interactions/drag-drop/Droppable.tsx @@ -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 ( +
+ {resolvedChildren} +
+ ); +} diff --git a/src/components/course-interactions/drag-drop/collision.ts b/src/components/course-interactions/drag-drop/collision.ts new file mode 100644 index 0000000..ad282fc --- /dev/null +++ b/src/components/course-interactions/drag-drop/collision.ts @@ -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); +}; diff --git a/src/components/course-interactions/drag-drop/index.ts b/src/components/course-interactions/drag-drop/index.ts new file mode 100644 index 0000000..fdf563c --- /dev/null +++ b/src/components/course-interactions/drag-drop/index.ts @@ -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"; diff --git a/src/components/course-interactions/guide-dialog/DefaultGuideDialogPanel.tsx b/src/components/course-interactions/guide-dialog/DefaultGuideDialogPanel.tsx new file mode 100644 index 0000000..51f6d03 --- /dev/null +++ b/src/components/course-interactions/guide-dialog/DefaultGuideDialogPanel.tsx @@ -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 && ( +
+
{character.avatar ?? "🙂"}
+
+ {character.name && ( +
{character.name}
+ )} + {character.mood && ( +
{character.mood}
+ )} +
+
+ )} + + {step.title &&

{step.title}

} + {step.description && ( +
{step.description}
+ )} + + {options.length > 0 && ( +
+ {options.map((option) => ( + + ))} +
+ )} + +
+ + {progress.current} / {progress.total} + +
+ + {!status.isFirst && ( + + )} + {!status.hasOptions && ( + + )} +
+
+ + ); +} diff --git a/src/components/course-interactions/guide-dialog/GuideDialog.module.css b/src/components/course-interactions/guide-dialog/GuideDialog.module.css new file mode 100644 index 0000000..324928c --- /dev/null +++ b/src/components/course-interactions/guide-dialog/GuideDialog.module.css @@ -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; + } +} diff --git a/src/components/course-interactions/guide-dialog/GuideDialog.tsx b/src/components/course-interactions/guide-dialog/GuideDialog.tsx new file mode 100644 index 0000000..6c94961 --- /dev/null +++ b/src/components/course-interactions/guide-dialog/GuideDialog.tsx @@ -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): 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(null); + const [layout, setLayout] = useState(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) + ) : ( + + ); + const panelClassName = children ? styles.panelContainer : styles.panel; + + if (!activeLayout) { + return ( + + + + ); + } + + const overlayClick = (event: MouseEvent) => { + event.stopPropagation(); + if (closeOnOverlayClick) { + closeAsSkip(); + } + }; + + return ( + +