From 87cf3b834ac2c0f2324dbf37cdc3f1bb6d935ed1 Mon Sep 17 00:00:00 2001 From: Shine1i Date: Sun, 15 Feb 2026 20:11:28 +0100 Subject: [PATCH] refactor: extract tour utils into separate modules for cleaner structure --- .../tour/{ => components}/guided-tour.tsx | 130 ++++-------------- studio/frontend/src/features/tour/index.ts | 4 +- studio/frontend/src/features/tour/lib/dom.ts | 18 +++ .../frontend/src/features/tour/lib/layout.ts | 66 +++++++++ studio/frontend/src/features/tour/types.ts | 13 ++ 5 files changed, 129 insertions(+), 102 deletions(-) rename studio/frontend/src/features/tour/{ => components}/guided-tour.tsx (82%) create mode 100644 studio/frontend/src/features/tour/lib/dom.ts create mode 100644 studio/frontend/src/features/tour/lib/layout.ts create mode 100644 studio/frontend/src/features/tour/types.ts diff --git a/studio/frontend/src/features/tour/guided-tour.tsx b/studio/frontend/src/features/tour/components/guided-tour.tsx similarity index 82% rename from studio/frontend/src/features/tour/guided-tour.tsx rename to studio/frontend/src/features/tour/components/guided-tour.tsx index d442da24c6..ee6fd7995b 100644 --- a/studio/frontend/src/features/tour/guided-tour.tsx +++ b/studio/frontend/src/features/tour/components/guided-tour.tsx @@ -10,7 +10,6 @@ import { import { Dialog as DialogPrimitive } from "radix-ui"; import { AnimatePresence, motion } from "motion/react"; import { - type ReactNode, useEffect, useId, useLayoutEffect, @@ -18,91 +17,11 @@ import { useRef, useState, } from "react"; +import { cssEscape, toRect } from "../lib/dom"; +import { computeCardPos, padded, pickPlacement } from "../lib/layout"; +import type { Placement, Rect, TourStep } from "../types"; -export type TourStep = { - id: string; - target: string; // data-tour="" - title: string; - body: ReactNode; -}; - -type Rect = { x: number; y: number; w: number; h: number }; -type Placement = "right" | "left" | "top" | "bottom"; - -function clamp(n: number, min: number, max: number): number { - return Math.min(max, Math.max(min, n)); -} - -function cssEscape(value: string): string { - if (typeof CSS !== "undefined" && typeof CSS.escape === "function") { - return CSS.escape(value); - } - return value.replace(/"/g, '\\"'); -} - -function toRect(domRect: DOMRect): Rect { - return { x: domRect.left, y: domRect.top, w: domRect.width, h: domRect.height }; -} - -function padded(r: Rect, pad: number, vw: number, vh: number): Rect { - const x = clamp(r.x - pad, 8, vw - 8); - const y = clamp(r.y - pad, 8, vh - 8); - const w = clamp(r.w + pad * 2, 24, vw - x - 8); - const h = clamp(r.h + pad * 2, 24, vh - y - 8); - return { x, y, w, h }; -} - -function pickPlacement( - target: Rect, - card: { w: number; h: number }, - vw: number, - vh: number, - gap: number, -): Placement { - const canRight = target.x + target.w + gap + card.w <= vw - 12; - const canLeft = target.x - gap - card.w >= 12; - const canBottom = target.y + target.h + gap + card.h <= vh - 12; - const canTop = target.y - gap - card.h >= 12; - - if (canRight) return "right"; - if (canLeft) return "left"; - if (canBottom) return "bottom"; - if (canTop) return "top"; - return "bottom"; -} - -function computeCardPos( - placement: Placement, - target: Rect, - card: { w: number; h: number }, - vw: number, - vh: number, - gap: number, -): { left: number; top: number } { - let left = 12; - let top = 12; - - if (placement === "right") { - left = target.x + target.w + gap; - top = target.y + target.h / 2 - card.h / 2; - } - if (placement === "left") { - left = target.x - gap - card.w; - top = target.y + target.h / 2 - card.h / 2; - } - if (placement === "bottom") { - left = target.x + target.w / 2 - card.w / 2; - top = target.y + target.h + gap; - } - if (placement === "top") { - left = target.x + target.w / 2 - card.w / 2; - top = target.y - gap - card.h; - } - - left = clamp(left, 12, vw - card.w - 12); - top = clamp(top, 12, vh - card.h - 12); - return { left, top }; -} +// (types + layout/dom helpers live in ../types and ../lib) function SpotlightOverlay({ rect, @@ -190,7 +109,7 @@ export function GuidedTour({ const spotlightRect = useMemo(() => { if (!targetRect || !vw || !vh) return null; - const pad = step?.target === "navbar" ? 6 : 14; + const pad = step?.target === "navbar" ? 4 : 14; return padded(targetRect, pad, vw, vh); }, [step?.target, targetRect, vw, vh]); @@ -215,38 +134,49 @@ export function GuidedTour({ if (!open || !step) return; const sel = `[data-tour="${cssEscape(step.target)}"]`; - const el = document.querySelector(sel) as HTMLElement | null; - if (!el) { + const found = document.querySelector(sel); + if (!(found instanceof HTMLElement)) { setTargetRect(null); return; } + const el = found; - el.scrollIntoView({ block: "center", inline: "center", behavior: "smooth" }); + el.scrollIntoView({ + block: "center", + inline: "center", + behavior: "smooth", + }); let raf = 0; let t = 0; - const read = () => { + + function rectChanged(a: Rect | null, b: Rect): boolean { + if (!a) return true; + return ( + Math.abs(a.x - b.x) > 0.5 || + Math.abs(a.y - b.y) > 0.5 || + Math.abs(a.w - b.w) > 0.5 || + Math.abs(a.h - b.h) > 0.5 + ); + } + + function read() { const r = el.getBoundingClientRect(); const next = toRect(r); const prev = lastRectRef.current; - if ( - !prev || - Math.abs(prev.x - next.x) > 0.5 || - Math.abs(prev.y - next.y) > 0.5 || - Math.abs(prev.w - next.w) > 0.5 || - Math.abs(prev.h - next.h) > 0.5 - ) { + if (rectChanged(prev, next)) { lastRectRef.current = next; setTargetRect(next); } - }; - const schedule = () => { + } + + function schedule() { if (rafRef.current != null) return; rafRef.current = window.requestAnimationFrame(() => { rafRef.current = null; read(); }); - }; + } raf = window.requestAnimationFrame(read); t = window.setTimeout(schedule, 240); diff --git a/studio/frontend/src/features/tour/index.ts b/studio/frontend/src/features/tour/index.ts index 6dce052ca3..720ccaf5eb 100644 --- a/studio/frontend/src/features/tour/index.ts +++ b/studio/frontend/src/features/tour/index.ts @@ -1,3 +1,3 @@ -export { GuidedTour } from "./guided-tour"; -export type { TourStep } from "./guided-tour"; +export { GuidedTour } from "./components/guided-tour"; +export type { TourStep } from "./types"; export { studioTourSteps } from "./steps/studio"; diff --git a/studio/frontend/src/features/tour/lib/dom.ts b/studio/frontend/src/features/tour/lib/dom.ts new file mode 100644 index 0000000000..f39b674c32 --- /dev/null +++ b/studio/frontend/src/features/tour/lib/dom.ts @@ -0,0 +1,18 @@ +import type { Rect } from "../types"; + +export function cssEscape(value: string): string { + if (typeof CSS !== "undefined" && typeof CSS.escape === "function") { + return CSS.escape(value); + } + return value.replace(/"/g, '\\"'); +} + +export function toRect(domRect: DOMRect): Rect { + return { + x: domRect.left, + y: domRect.top, + w: domRect.width, + h: domRect.height, + }; +} + diff --git a/studio/frontend/src/features/tour/lib/layout.ts b/studio/frontend/src/features/tour/lib/layout.ts new file mode 100644 index 0000000000..b7f017583f --- /dev/null +++ b/studio/frontend/src/features/tour/lib/layout.ts @@ -0,0 +1,66 @@ +import type { Placement, Rect } from "../types"; + +export function clamp(n: number, min: number, max: number): number { + return Math.min(max, Math.max(min, n)); +} + +export function padded(r: Rect, pad: number, vw: number, vh: number): Rect { + const x = clamp(r.x - pad, 8, vw - 8); + const y = clamp(r.y - pad, 8, vh - 8); + const w = clamp(r.w + pad * 2, 24, vw - x - 8); + const h = clamp(r.h + pad * 2, 24, vh - y - 8); + return { x, y, w, h }; +} + +export function pickPlacement( + target: Rect, + card: { w: number; h: number }, + vw: number, + vh: number, + gap: number, +): Placement { + const canRight = target.x + target.w + gap + card.w <= vw - 12; + const canLeft = target.x - gap - card.w >= 12; + const canBottom = target.y + target.h + gap + card.h <= vh - 12; + const canTop = target.y - gap - card.h >= 12; + + if (canRight) return "right"; + if (canLeft) return "left"; + if (canBottom) return "bottom"; + if (canTop) return "top"; + return "bottom"; +} + +export function computeCardPos( + placement: Placement, + target: Rect, + card: { w: number; h: number }, + vw: number, + vh: number, + gap: number, +): { left: number; top: number } { + let left = 12; + let top = 12; + + if (placement === "right") { + left = target.x + target.w + gap; + top = target.y + target.h / 2 - card.h / 2; + } + if (placement === "left") { + left = target.x - gap - card.w; + top = target.y + target.h / 2 - card.h / 2; + } + if (placement === "bottom") { + left = target.x + target.w / 2 - card.w / 2; + top = target.y + target.h + gap; + } + if (placement === "top") { + left = target.x + target.w / 2 - card.w / 2; + top = target.y - gap - card.h; + } + + left = clamp(left, 12, vw - card.w - 12); + top = clamp(top, 12, vh - card.h - 12); + return { left, top }; +} + diff --git a/studio/frontend/src/features/tour/types.ts b/studio/frontend/src/features/tour/types.ts new file mode 100644 index 0000000000..1ce311269b --- /dev/null +++ b/studio/frontend/src/features/tour/types.ts @@ -0,0 +1,13 @@ +import type { ReactNode } from "react"; + +export type TourStep = { + id: string; + target: string; // data-tour="" + title: string; + body: ReactNode; +}; + +export type Rect = { x: number; y: number; w: number; h: number }; + +export type Placement = "right" | "left" | "top" | "bottom"; +