@walkrstudio/studio 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,376 @@
1
+ import type { Step } from "@walkrstudio/core";
2
+ import type React from "react";
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+
5
+ interface TimelineProps {
6
+ steps: Step[];
7
+ selectedStepIndex: number | null;
8
+ currentStepIndex: number;
9
+ playheadTime: number;
10
+ totalDuration: number;
11
+ onSelectStep: (index: number) => void;
12
+ onSeek: (timeMs: number) => void;
13
+ onUpdateDuration: (stepIndex: number, newDuration: number) => void;
14
+ onReorderStep: (fromIndex: number, toIndex: number) => void;
15
+ }
16
+
17
+ const STEP_COLORS: Record<string, string> = {
18
+ moveTo: "#1d3a5c",
19
+ click: "#3b1d5c",
20
+ type: "#1d5c2a",
21
+ scroll: "#5c3b1d",
22
+ wait: "#333",
23
+ zoom: "#5c1d3b",
24
+ pan: "#1d5c5c",
25
+ highlight: "#5c5c1d",
26
+ clearCache: "#5c2a1d",
27
+ };
28
+
29
+ const STEP_BORDER_COLORS: Record<string, string> = {
30
+ moveTo: "#2a4f75",
31
+ click: "#4f2a75",
32
+ type: "#2a753f",
33
+ scroll: "#754f2a",
34
+ wait: "#444",
35
+ zoom: "#752a4f",
36
+ pan: "#2a7575",
37
+ highlight: "#75752a",
38
+ clearCache: "#753f2a",
39
+ };
40
+
41
+ const SCALE = 0.3; // 1ms = 0.3px
42
+ const MIN_BLOCK_WIDTH = 40;
43
+
44
+ function stepWidth(duration: number): number {
45
+ return Math.max(duration * SCALE, MIN_BLOCK_WIDTH);
46
+ }
47
+
48
+ export function Timeline({
49
+ steps,
50
+ selectedStepIndex,
51
+ currentStepIndex,
52
+ playheadTime,
53
+ totalDuration,
54
+ onSelectStep,
55
+ onSeek,
56
+ onUpdateDuration,
57
+ onReorderStep,
58
+ }: TimelineProps) {
59
+ const trackRef = useRef<HTMLDivElement>(null);
60
+ const scrollRef = useRef<HTMLDivElement>(null);
61
+ const [resizing, setResizing] = useState<{
62
+ index: number;
63
+ startX: number;
64
+ startDuration: number;
65
+ } | null>(null);
66
+ const [resizePreview, setResizePreview] = useState<number | null>(null);
67
+ const [dragIndex, setDragIndex] = useState<number | null>(null);
68
+ const [dropTarget, setDropTarget] = useState<number | null>(null);
69
+ const [_draggingPlayhead, setDraggingPlayhead] = useState(false);
70
+
71
+ const totalWidth = steps.reduce((sum, s) => sum + stepWidth(s.duration), 0);
72
+
73
+ const handlePlayheadMouseDown = useCallback(
74
+ (e: React.MouseEvent) => {
75
+ e.stopPropagation();
76
+ setDraggingPlayhead(true);
77
+
78
+ const onMove = (ev: MouseEvent) => {
79
+ if (!trackRef.current) return;
80
+ const rect = trackRef.current.getBoundingClientRect();
81
+ const x = Math.max(0, Math.min(ev.clientX - rect.left, rect.width));
82
+ const ratio = x / totalWidth;
83
+ onSeek(Math.round(ratio * totalDuration));
84
+ };
85
+
86
+ const onUp = () => {
87
+ setDraggingPlayhead(false);
88
+ window.removeEventListener("mousemove", onMove);
89
+ window.removeEventListener("mouseup", onUp);
90
+ };
91
+
92
+ window.addEventListener("mousemove", onMove);
93
+ window.addEventListener("mouseup", onUp);
94
+ },
95
+ [totalDuration, totalWidth, onSeek],
96
+ );
97
+
98
+ const handleResizeStart = useCallback(
99
+ (e: React.MouseEvent, index: number, duration: number) => {
100
+ e.stopPropagation();
101
+ e.preventDefault();
102
+ const startX = e.clientX;
103
+ setResizing({ index, startX, startDuration: duration });
104
+
105
+ const onMove = (ev: MouseEvent) => {
106
+ const dx = ev.clientX - startX;
107
+ const newDuration = Math.max(50, Math.round(duration + dx / SCALE));
108
+ setResizePreview(newDuration);
109
+ };
110
+
111
+ const onUp = (ev: MouseEvent) => {
112
+ const dx = ev.clientX - startX;
113
+ const newDuration = Math.max(50, Math.round(duration + dx / SCALE));
114
+ onUpdateDuration(index, newDuration);
115
+ setResizing(null);
116
+ setResizePreview(null);
117
+ window.removeEventListener("mousemove", onMove);
118
+ window.removeEventListener("mouseup", onUp);
119
+ };
120
+
121
+ window.addEventListener("mousemove", onMove);
122
+ window.addEventListener("mouseup", onUp);
123
+ },
124
+ [onUpdateDuration],
125
+ );
126
+
127
+ const handleDragStart = useCallback((e: React.DragEvent, index: number) => {
128
+ e.dataTransfer.effectAllowed = "move";
129
+ e.dataTransfer.setData("text/plain", String(index));
130
+ setDragIndex(index);
131
+ }, []);
132
+
133
+ const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
134
+ e.preventDefault();
135
+ e.dataTransfer.dropEffect = "move";
136
+ setDropTarget(index);
137
+ }, []);
138
+
139
+ const handleDrop = useCallback(
140
+ (e: React.DragEvent, toIndex: number) => {
141
+ e.preventDefault();
142
+ const fromIndex = parseInt(e.dataTransfer.getData("text/plain"), 10);
143
+ if (!Number.isNaN(fromIndex) && fromIndex !== toIndex) {
144
+ onReorderStep(fromIndex, toIndex);
145
+ }
146
+ setDragIndex(null);
147
+ setDropTarget(null);
148
+ },
149
+ [onReorderStep],
150
+ );
151
+
152
+ const handleDragEnd = useCallback(() => {
153
+ setDragIndex(null);
154
+ setDropTarget(null);
155
+ }, []);
156
+
157
+ const handleTrackClick = useCallback(
158
+ (e: React.MouseEvent) => {
159
+ if (!trackRef.current) return;
160
+ const rect = trackRef.current.getBoundingClientRect();
161
+ const x = e.clientX - rect.left;
162
+ const ratio = x / totalWidth;
163
+ onSeek(Math.round(ratio * totalDuration));
164
+ },
165
+ [totalDuration, totalWidth, onSeek],
166
+ );
167
+
168
+ // Compute playhead X by walking through steps, accounting for
169
+ // non-linear time-to-pixel mapping (MIN_BLOCK_WIDTH inflates 0ms steps)
170
+ let playheadX = 0;
171
+ if (totalDuration > 0) {
172
+ let timeRemaining = playheadTime;
173
+ for (let i = 0; i < steps.length; i++) {
174
+ const dur = steps[i].duration;
175
+ const w = stepWidth(dur);
176
+ if (timeRemaining <= dur) {
177
+ // Playhead is within this step
178
+ playheadX += dur > 0 ? (timeRemaining / dur) * w : 0;
179
+ break;
180
+ }
181
+ playheadX += w + 4; // 4 = gap between blocks
182
+ timeRemaining -= dur;
183
+ }
184
+ }
185
+
186
+ // Auto-scroll to keep playhead visible
187
+ useEffect(() => {
188
+ const container = scrollRef.current;
189
+ if (!container) return;
190
+ const playheadLeft = 8 + playheadX;
191
+ const { scrollLeft, clientWidth } = container;
192
+ const margin = 60;
193
+ if (playheadLeft > scrollLeft + clientWidth - margin) {
194
+ container.scrollLeft = playheadLeft - clientWidth + margin;
195
+ } else if (playheadLeft < scrollLeft + margin) {
196
+ container.scrollLeft = playheadLeft - margin;
197
+ }
198
+ }, [playheadX]);
199
+
200
+ // Time markers
201
+ let cumulativeMs = 0;
202
+ const _stepOffsets = steps.map((s) => {
203
+ const offset = cumulativeMs;
204
+ cumulativeMs += s.duration;
205
+ return offset;
206
+ });
207
+
208
+ return (
209
+ <div
210
+ style={{
211
+ height: 120,
212
+ background: "#141414",
213
+ borderTop: "1px solid #2a2a2a",
214
+ display: "flex",
215
+ flexShrink: 0,
216
+ }}
217
+ >
218
+ {/* Left column: time markers */}
219
+ <div
220
+ style={{
221
+ width: 60,
222
+ borderRight: "1px solid #2a2a2a",
223
+ display: "flex",
224
+ flexDirection: "column",
225
+ justifyContent: "center",
226
+ alignItems: "center",
227
+ fontSize: 10,
228
+ color: "#555",
229
+ flexShrink: 0,
230
+ }}
231
+ >
232
+ <div>{formatTime(playheadTime)}</div>
233
+ <div style={{ marginTop: 4, color: "#444" }}>{formatTime(totalDuration)}</div>
234
+ </div>
235
+
236
+ {/* Main track area */}
237
+ <div
238
+ ref={scrollRef}
239
+ style={{
240
+ flex: 1,
241
+ overflowX: "auto",
242
+ overflowY: "hidden",
243
+ position: "relative",
244
+ }}
245
+ >
246
+ <div
247
+ ref={trackRef}
248
+ style={{
249
+ display: "flex",
250
+ alignItems: "center",
251
+ height: "100%",
252
+ padding: "24px 8px",
253
+ gap: 4,
254
+ minWidth: totalWidth + 16,
255
+ position: "relative",
256
+ cursor: "pointer",
257
+ }}
258
+ onClick={handleTrackClick}
259
+ >
260
+ {/* Step blocks */}
261
+ {steps.map((step, i) => {
262
+ const w =
263
+ resizing?.index === i && resizePreview !== null
264
+ ? stepWidth(resizePreview)
265
+ : stepWidth(step.duration);
266
+ const isSelected = selectedStepIndex === i;
267
+ const isCurrent = currentStepIndex === i;
268
+ const isDragTarget = dropTarget === i;
269
+
270
+ return (
271
+ <div
272
+ key={step.id}
273
+ draggable
274
+ onDragStart={(e) => handleDragStart(e, i)}
275
+ onDragOver={(e) => handleDragOver(e, i)}
276
+ onDrop={(e) => handleDrop(e, i)}
277
+ onDragEnd={handleDragEnd}
278
+ onClick={(e) => {
279
+ e.stopPropagation();
280
+ onSelectStep(i);
281
+ }}
282
+ style={{
283
+ width: w,
284
+ height: 72,
285
+ background: STEP_COLORS[step.type] ?? "#333",
286
+ border: `1px solid ${STEP_BORDER_COLORS[step.type] ?? "#444"}`,
287
+ borderRadius: 6,
288
+ display: "flex",
289
+ flexDirection: "column",
290
+ justifyContent: "center",
291
+ alignItems: "center",
292
+ cursor: "grab",
293
+ position: "relative",
294
+ flexShrink: 0,
295
+ userSelect: "none",
296
+ boxShadow: isSelected
297
+ ? "0 0 0 2px #3b82f6"
298
+ : isCurrent
299
+ ? "0 0 0 2px #10b981"
300
+ : "none",
301
+ opacity: dragIndex === i ? 0.5 : 1,
302
+ borderLeft: isDragTarget ? "3px solid #3b82f6" : undefined,
303
+ }}
304
+ >
305
+ <span
306
+ style={{
307
+ fontSize: 11,
308
+ fontWeight: 600,
309
+ textTransform: "uppercase",
310
+ color: "#e8e8e8",
311
+ }}
312
+ >
313
+ {step.type}
314
+ </span>
315
+ <span style={{ fontSize: 10, color: "rgba(255,255,255,0.5)", marginTop: 2 }}>
316
+ {resizing?.index === i && resizePreview !== null
317
+ ? `${resizePreview}ms`
318
+ : `${step.duration}ms`}
319
+ </span>
320
+
321
+ {/* Resize handle */}
322
+ <div
323
+ onMouseDown={(e) => handleResizeStart(e, i, step.duration)}
324
+ style={{
325
+ position: "absolute",
326
+ right: 0,
327
+ top: 0,
328
+ bottom: 0,
329
+ width: 4,
330
+ cursor: "col-resize",
331
+ borderRadius: "0 6px 6px 0",
332
+ }}
333
+ />
334
+ </div>
335
+ );
336
+ })}
337
+
338
+ {/* Playhead */}
339
+ <div
340
+ onMouseDown={handlePlayheadMouseDown}
341
+ style={{
342
+ position: "absolute",
343
+ left: 8 + playheadX,
344
+ top: 0,
345
+ bottom: 0,
346
+ width: 2,
347
+ background: "#ef4444",
348
+ cursor: "ew-resize",
349
+ zIndex: 10,
350
+ pointerEvents: "auto",
351
+ }}
352
+ >
353
+ <div
354
+ style={{
355
+ position: "absolute",
356
+ top: 0,
357
+ left: -4,
358
+ width: 10,
359
+ height: 10,
360
+ background: "#ef4444",
361
+ borderRadius: "50%",
362
+ }}
363
+ />
364
+ </div>
365
+ </div>
366
+ </div>
367
+ </div>
368
+ );
369
+ }
370
+
371
+ function formatTime(ms: number): string {
372
+ const s = ms / 1000;
373
+ const mins = Math.floor(s / 60);
374
+ const secs = (s % 60).toFixed(1);
375
+ return mins > 0 ? `${mins}:${secs.padStart(4, "0")}` : `${secs}s`;
376
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { App } from "./App";
package/src/main.tsx ADDED
@@ -0,0 +1,10 @@
1
+ import React from "react";
2
+ import ReactDOM from "react-dom/client";
3
+ import { App } from "./App";
4
+
5
+ // biome-ignore lint/style/noNonNullAssertion: root element is guaranteed in index.html
6
+ ReactDOM.createRoot(document.getElementById("root")!).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>,
10
+ );
package/src/types.ts ADDED
@@ -0,0 +1,11 @@
1
+ import type { CursorConfig, Step } from "@walkrstudio/core";
2
+
3
+ export interface WalkthroughDef {
4
+ url: string;
5
+ originalUrl?: string;
6
+ title?: string;
7
+ steps: Step[];
8
+ cursor?: CursorConfig;
9
+ }
10
+
11
+ export type PlaybackStatus = "idle" | "playing" | "paused" | "done";
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "jsx": "react-jsx",
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "moduleResolution": "bundler",
8
+ "rootDir": "src",
9
+ "noEmit": true
10
+ },
11
+ "include": ["src"]
12
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,161 @@
1
+ import { watch as fsWatch, readFileSync } from "node:fs";
2
+ import type { IncomingMessage, ServerResponse } from "node:http";
3
+ import { resolve } from "node:path";
4
+ import react from "@vitejs/plugin-react";
5
+ import type { Plugin } from "vite";
6
+ import { defineConfig } from "vite";
7
+
8
+ function walkthroughHmr(): Plugin {
9
+ const jsonPath = resolve(__dirname, "public", "walkthrough.json");
10
+ return {
11
+ name: "walkr-walkthrough-hmr",
12
+ configureServer(server) {
13
+ let last = "";
14
+ const watcher = fsWatch(jsonPath, () => {
15
+ try {
16
+ const content = readFileSync(jsonPath, "utf-8");
17
+ if (content === last) return;
18
+ last = content;
19
+ server.ws.send({
20
+ type: "custom",
21
+ event: "walkthrough:update",
22
+ data: JSON.parse(content),
23
+ });
24
+ } catch {
25
+ // file may be mid-write
26
+ }
27
+ });
28
+ server.httpServer?.on("close", () => watcher.close());
29
+ },
30
+ };
31
+ }
32
+
33
+ let proxyTarget = process.env.WALKR_PROXY_TARGET ?? "";
34
+ if (!proxyTarget) {
35
+ try {
36
+ proxyTarget = readFileSync(resolve(__dirname, ".walkr-proxy-target"), "utf-8").trim();
37
+ } catch {
38
+ // No proxy target file — proxy disabled
39
+ }
40
+ }
41
+ const PREFIX = "/__target__";
42
+ const targetPort = proxyTarget ? new URL(proxyTarget).port || "80" : "";
43
+
44
+ /**
45
+ * Rewrite absolute paths in proxied text responses so that sub-resource
46
+ * requests (scripts, styles, fetches) route back through the proxy.
47
+ *
48
+ * Content-type-aware to avoid corrupting JS regex literals like `/"/g`
49
+ * which the old blanket regex would turn into `"/__target__/g`.
50
+ */
51
+ function rewriteAbsolutePaths(content: string, contentType: string): string {
52
+ // HTML: targeted attribute rewriting + conservative general patterns
53
+ if (contentType.includes("html")) {
54
+ let html = content
55
+ // HTML attributes: src="/x", href="/x", action="/x", etc.
56
+ .replace(
57
+ /((?:src|href|action|poster|formaction|icon|manifest|ping|background)\s*=\s*)(["'])\/(?!\/|__target__)/gi,
58
+ `$1$2${PREFIX}/`,
59
+ )
60
+ // Inline style url()
61
+ .replace(/\burl\(\s*["']?\/(?!\/|__target__)/g, (m) => m.replace("/", `${PREFIX}/`))
62
+ // Quoted deep paths (contain /): "/assets/chunk.js" → "/__target__/assets/chunk.js"
63
+ .replace(/(["'`])\/(?!\/|__target__)([a-zA-Z@._][\w@._-]*\/)/g, `$1${PREFIX}/$2`)
64
+ // Quoted root files with extension: "/favicon.ico" → "/__target__/favicon.ico"
65
+ .replace(/(["'`])\/(?!\/|__target__)([a-zA-Z@._][\w@._-]+\.\w{2,})/g, `$1${PREFIX}/$2`);
66
+
67
+ // Inject <base> so client-side routers (Vue Router, React Router, etc.)
68
+ // resolve routes relative to the proxy prefix instead of "/".
69
+ // Existing <base> tags are already rewritten by the href regex above.
70
+ if (!/<base\b/i.test(html)) {
71
+ html = html.replace(/(<head[^>]*>)/i, `$1<base href="${PREFIX}/">`);
72
+ }
73
+
74
+ return html;
75
+ }
76
+
77
+ // CSS: url() and @import
78
+ if (contentType.includes("css")) {
79
+ return content
80
+ .replace(/\burl\(\s*["']?\/(?!\/|__target__)/g, (m) => m.replace("/", `${PREFIX}/`))
81
+ .replace(/(@import\s+["'])\/(?!\/|__target__)/g, `$1${PREFIX}/`);
82
+ }
83
+
84
+ // JS/JSON/other text: conservative patterns only.
85
+ // Only match paths with depth ("/segment/...") or file extensions ("/file.ext").
86
+ // This avoids corrupting regex flags like .replace(/"/g, ...) where "/g" would match.
87
+ return content
88
+ .replace(/(["'`])\/(?!\/|__target__)([a-zA-Z@._][\w@._-]*\/)/g, `$1${PREFIX}/$2`)
89
+ .replace(/(["'`])\/(?!\/|__target__)([a-zA-Z@._][\w@._-]+\.\w{2,})/g, `$1${PREFIX}/$2`);
90
+ }
91
+
92
+ function isTextResponse(contentType: string): boolean {
93
+ return (
94
+ contentType.includes("text/") ||
95
+ contentType.includes("javascript") ||
96
+ contentType.includes("json") ||
97
+ contentType.includes("css")
98
+ );
99
+ }
100
+
101
+ export default defineConfig({
102
+ plugins: [react(), walkthroughHmr()],
103
+ server: {
104
+ port: 3000,
105
+ proxy: proxyTarget
106
+ ? {
107
+ [PREFIX]: {
108
+ target: proxyTarget,
109
+ changeOrigin: true,
110
+ rewrite: (path) => path.replace(new RegExp(`^${PREFIX}`), ""),
111
+ // We handle the response ourselves so we can rewrite paths
112
+ selfHandleResponse: true,
113
+ configure: (proxy) => {
114
+ // Ask target not to compress so we can rewrite text
115
+ proxy.on("proxyReq", (proxyReq) => {
116
+ proxyReq.setHeader("accept-encoding", "identity");
117
+ });
118
+
119
+ proxy.on("proxyRes", (proxyRes, _req: IncomingMessage, res: ServerResponse) => {
120
+ const contentType = proxyRes.headers["content-type"] ?? "";
121
+
122
+ if (isTextResponse(contentType)) {
123
+ // Buffer text response, rewrite paths, then send
124
+ const chunks: Buffer[] = [];
125
+ proxyRes.on("data", (chunk: Buffer) => chunks.push(chunk));
126
+ proxyRes.on("end", () => {
127
+ const raw = Buffer.concat(chunks).toString("utf-8");
128
+ let rewritten = rewriteAbsolutePaths(raw, contentType);
129
+
130
+ // Redirect the target's HMR WebSocket to its own Vite
131
+ // server instead of the Studio's (avoids token mismatch).
132
+ if (_req.url?.includes("/@vite/client")) {
133
+ rewritten = rewritten.replace(
134
+ /hmrPort\s*=\s*null/,
135
+ `hmrPort = ${targetPort}`,
136
+ );
137
+ }
138
+ const headers = { ...proxyRes.headers };
139
+ delete headers["content-length"];
140
+ delete headers["content-encoding"];
141
+ // Prevent browser from caching rewritten responses
142
+ headers["cache-control"] = "no-store";
143
+ delete headers.etag;
144
+ res.writeHead(proxyRes.statusCode ?? 200, headers);
145
+ res.end(rewritten);
146
+ });
147
+ } else {
148
+ // Binary responses (images, fonts) pass through unchanged
149
+ const headers = { ...proxyRes.headers };
150
+ headers["cache-control"] = "no-store";
151
+ delete headers.etag;
152
+ res.writeHead(proxyRes.statusCode ?? 200, headers);
153
+ proxyRes.pipe(res);
154
+ }
155
+ });
156
+ },
157
+ },
158
+ }
159
+ : {},
160
+ },
161
+ });