From 0e49b76be631bc44c62d4654ea33d6e054a73ad4 Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Tue, 2 Jun 2026 17:04:38 -0700 Subject: [PATCH] feat: Guided Tours Framework (Navigating the Editor) --- src/components/Learn/tours/registry.ts | 7 +- .../TourProvider/TourNavigation.test.tsx | 8 +- .../TourProvider/tourActionLabels.ts | 12 ++ .../Editor/components/EditorTourBridge.tsx | 178 ++++++++++++++++++ 4 files changed, 198 insertions(+), 7 deletions(-) diff --git a/src/components/Learn/tours/registry.ts b/src/components/Learn/tours/registry.ts index ac4779674..2e650546e 100644 --- a/src/components/Learn/tours/registry.ts +++ b/src/components/Learn/tours/registry.ts @@ -2,7 +2,12 @@ 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; + targetWindowName?: string; + fallbackContent?: string; +}; export interface TourDefinition { id: string; diff --git a/src/providers/TourProvider/TourNavigation.test.tsx b/src/providers/TourProvider/TourNavigation.test.tsx index 143f8e4cc..b42913371 100644 --- a/src/providers/TourProvider/TourNavigation.test.tsx +++ b/src/providers/TourProvider/TourNavigation.test.tsx @@ -86,9 +86,7 @@ describe("TourStepChecklist", () => { /> , ); - expect( - screen.getByText("Complete the highlighted action to continue"), - ).toBeInTheDocument(); + expect(screen.getByText("Select the highlighted task")).toBeInTheDocument(); }); it("renders nothing for a non-interactive step", () => { @@ -110,9 +108,7 @@ describe("TourStepChecklist", () => { /> , ); - const label = screen.getByText( - "Complete the highlighted action to continue", - ); + const label = screen.getByText("Drag the panel out of its dock"); expect(label.className).toContain("line-through"); }); }); diff --git a/src/providers/TourProvider/tourActionLabels.ts b/src/providers/TourProvider/tourActionLabels.ts index 4c0d15628..7aebeb4c8 100644 --- a/src/providers/TourProvider/tourActionLabels.ts +++ b/src/providers/TourProvider/tourActionLabels.ts @@ -14,6 +14,18 @@ const GENERIC_LABEL = "Complete the highlighted action to continue"; // to highlight the contextual target, e.g. the task or folder name. export function tourActionLabel(step: TourActionLabelInput): string { switch (step.interaction) { + case "select-task": + return step.targetTaskName + ? `Select the **${step.targetTaskName}** task` + : "Select the highlighted task"; + case "undock-window": + return step.targetWindowName + ? `Drag the **${step.targetWindowName}** panel out of its dock` + : "Drag the panel out of its dock"; + case "redock-window": + return step.targetWindowName + ? `Dock the **${step.targetWindowName}** panel back into place` + : "Dock the panel back into place"; default: return GENERIC_LABEL; } diff --git a/src/routes/v2/pages/Editor/components/EditorTourBridge.tsx b/src/routes/v2/pages/Editor/components/EditorTourBridge.tsx index ecc49762e..cb25f9a6b 100644 --- a/src/routes/v2/pages/Editor/components/EditorTourBridge.tsx +++ b/src/routes/v2/pages/Editor/components/EditorTourBridge.tsx @@ -1,3 +1,181 @@ +import { useTour } from "@reactour/tour"; +import { reaction } from "mobx"; +import { useEffect } from "react"; + +import type { TourStep } from "@/components/Learn/tours/registry"; +import { useTourProgress } from "@/providers/TourProvider/TourProgressContext"; +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, isOpen } = useTour(); + const { windows } = useSharedStores(); + const { markStepComplete } = useTourProgress(); + + const step = steps[currentStep] as TourStep | undefined; + const interaction = step?.interaction; + const targetWindowId = step?.targetWindowId; + + useEffect(() => { + if (!isOpen) return undefined; + + // 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; + + // Gated progression: completing the interaction (or finding it already + // satisfied on entry) marks the step done so "Next" enables. Advancing is + // the user's click, handled by the popover. + const advance = () => markStepComplete(currentStep); + const skip = () => markStepComplete(currentStep); + const skipWithFallback = (_step: TourStep) => markStepComplete(currentStep); + + 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, + step, + windows, + markStepComplete, + currentStep, + ]); + return null; }