Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/components/Learn/tours/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 2 additions & 6 deletions src/providers/TourProvider/TourNavigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,7 @@ describe("TourStepChecklist", () => {
/>
</TourProgressProvider>,
);
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", () => {
Expand All @@ -110,9 +108,7 @@ describe("TourStepChecklist", () => {
/>
</TourProgressProvider>,
);
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");
});
});
Expand Down
12 changes: 12 additions & 0 deletions src/providers/TourProvider/tourActionLabels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
178 changes: 178 additions & 0 deletions src/routes/v2/pages/Editor/components/EditorTourBridge.tsx
Original file line number Diff line number Diff line change
@@ -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<string>();
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<typeof setTimeout> | 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;
}
Loading