diff --git a/src/components/Learn/tours/registry.ts b/src/components/Learn/tours/registry.ts index 86f34af8b..c306b4864 100644 --- a/src/components/Learn/tours/registry.ts +++ b/src/components/Learn/tours/registry.ts @@ -2,7 +2,7 @@ import type { StepType } from "@reactour/tour"; import { publicAsset } from "@/utils/publicAsset"; -type TourStep = StepType & { +export type TourStep = StepType & { interaction?: "undock-window" | "redock-window" | "select-task"; targetWindowId?: string; fallbackContent?: string; diff --git a/src/routes/v2/pages/Editor/components/EditorTourBridge.tsx b/src/routes/v2/pages/Editor/components/EditorTourBridge.tsx index ecc49762e..2d05f5953 100644 --- a/src/routes/v2/pages/Editor/components/EditorTourBridge.tsx +++ b/src/routes/v2/pages/Editor/components/EditorTourBridge.tsx @@ -1,3 +1,211 @@ +import { useTour } from "@reactour/tour"; +import { reaction } from "mobx"; +import { useEffect, useRef } from "react"; + +import type { TourStep } from "@/components/Learn/tours/registry"; +import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext"; +import type { WindowStoreImpl } from "@/routes/v2/shared/windows/windowStore"; + +function followWindowPosition( + windows: WindowStoreImpl, + targetWindowId: string | undefined, +): () => void { + if (!targetWindowId) return () => undefined; + + let rafId: number | null = null; + const dispose = reaction( + () => { + const w = windows.getWindowById(targetWindowId); + return w ? `${w.position.x},${w.position.y}` : ""; + }, + () => { + if (rafId !== null) return; + rafId = requestAnimationFrame(() => { + rafId = null; + window.dispatchEvent(new Event("resize")); + }); + }, + ); + + return () => { + dispose(); + if (rafId !== null) cancelAnimationFrame(rafId); + }; +} + +function trackDockStateTransition( + windows: WindowStoreImpl, + matchInitial: (w: { dockState: string }) => boolean, + matchTransition: (w: { dockState: string }) => boolean, + targetWindowId?: string, +): { didTransition: () => boolean; dispose: () => void } { + const baseline = new Set(); + for (const w of windows.getAllWindows()) { + if (targetWindowId ? w.id === targetWindowId : matchInitial(w)) { + baseline.add(w.id); + } + } + let fired = false; + + const stateReaction = reaction( + () => + windows + .getAllWindows() + .map((w) => `${w.id}:${w.dockState}`) + .join("|"), + () => { + for (const w of windows.getAllWindows()) { + if (targetWindowId) { + if (w.id === targetWindowId && matchTransition(w)) { + fired = true; + } + continue; + } + if (matchInitial(w)) { + baseline.add(w.id); + } else if (baseline.has(w.id) && matchTransition(w)) { + fired = true; + } + } + }, + ); + + return { + didTransition: () => fired, + dispose: stateReaction, + }; +} + export function EditorTourBridge() { + const { steps, currentStep, setCurrentStep, setSteps, isOpen } = useTour(); + const { windows } = useSharedStores(); + + const prevStepRef = useRef(null); + const directionRef = useRef<1 | -1>(1); + + const step = steps[currentStep] as TourStep | undefined; + const interaction = step?.interaction; + const targetWindowId = step?.targetWindowId; + + useEffect(() => { + if (!isOpen) return undefined; + + const prev = prevStepRef.current; + if (prev !== null && currentStep !== prev) { + directionRef.current = currentStep < prev ? -1 : 1; + } + prevStepRef.current = currentStep; + const direction = directionRef.current; + + // Run outside the interaction branch so informational/fallback steps that + // target a floating window still track its position. + const stopFollow = followWindowPosition(windows, targetWindowId); + + if (!interaction) return stopFollow; + + const advance = () => { + setCurrentStep((s: number) => + Math.min(s + 1, Math.max(0, steps.length - 1)), + ); + }; + + const skip = () => { + setCurrentStep((s: number) => { + if (direction === -1) return Math.max(s - 1, 0); + return Math.min(s + 1, Math.max(0, steps.length - 1)); + }); + }; + + const skipWithFallback = (currentStepData: TourStep) => { + if (currentStepData.fallbackContent) { + const replaced: TourStep = { + ...currentStepData, + content: currentStepData.fallbackContent, + interaction: undefined, + stepInteraction: false, + }; + const next = steps.map((s, i) => (i === currentStep ? replaced : s)); + setSteps?.(next); + } else { + skip(); + } + }; + + if (interaction === "undock-window" || interaction === "redock-window") { + const isDocked = (w: { dockState: string }) => w.dockState !== "none"; + const isUndocked = (w: { dockState: string }) => w.dockState === "none"; + const matchInitial = + interaction === "undock-window" ? isDocked : isUndocked; + const matchTransition = + interaction === "undock-window" ? isUndocked : isDocked; + + if (targetWindowId) { + const target = windows.getWindowById(targetWindowId); + if (!target || matchTransition(target)) { + skipWithFallback(step); + return stopFollow; + } + } else { + const hasSourceWindow = windows + .getAllWindows() + .some((w) => w.state !== "hidden" && matchInitial(w)); + if (!hasSourceWindow) { + if (step) skipWithFallback(step); + else skip(); + return stopFollow; + } + } + + const tracker = trackDockStateTransition( + windows, + matchInitial, + matchTransition, + targetWindowId, + ); + + let pendingCheck: ReturnType | null = null; + const handleMouseUp = () => { + if (pendingCheck !== null) clearTimeout(pendingCheck); + pendingCheck = setTimeout(() => { + pendingCheck = null; + if (tracker.didTransition()) advance(); + }, 0); + }; + document.addEventListener("mouseup", handleMouseUp); + + return () => { + stopFollow(); + tracker.dispose(); + if (pendingCheck !== null) clearTimeout(pendingCheck); + document.removeEventListener("mouseup", handleMouseUp); + }; + } + + if (interaction === "select-task") { + const handleClick = (event: MouseEvent) => { + const target = event.target as Element | null; + if (target?.closest(".react-flow__node")) { + advance(); + } + }; + document.addEventListener("click", handleClick); + return () => { + stopFollow(); + document.removeEventListener("click", handleClick); + }; + } + + return stopFollow; + }, [ + isOpen, + interaction, + targetWindowId, + setCurrentStep, + setSteps, + step, + steps, + windows, + ]); + return null; }