@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.
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@walkrstudio/studio",
3
+ "version": "0.2.1",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "files": [
8
+ "dist",
9
+ "src",
10
+ "index.html",
11
+ "vite.config.ts",
12
+ "tsconfig.json"
13
+ ],
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "dependencies": {
18
+ "@vitejs/plugin-react": "^4.3.4",
19
+ "react": "^18.3.1",
20
+ "react-dom": "^18.3.1",
21
+ "vite": "^6.1.0",
22
+ "@walkrstudio/core": "^0.2.0",
23
+ "@walkrstudio/engine": "^0.2.1"
24
+ },
25
+ "devDependencies": {
26
+ "@types/react": "^18.3.1",
27
+ "@types/react-dom": "^18.3.1",
28
+ "typescript": "^5.8.2"
29
+ },
30
+ "scripts": {
31
+ "dev": "vite",
32
+ "build": "tsc -b && vite build",
33
+ "type-check": "tsc --noEmit"
34
+ }
35
+ }
package/src/App.tsx ADDED
@@ -0,0 +1,499 @@
1
+ import type { Step } from "@walkrstudio/core";
2
+ import { WalkrEngine } from "@walkrstudio/engine";
3
+ import type React from "react";
4
+ import { useCallback, useEffect, useRef, useState } from "react";
5
+ import { PlaybackControls } from "./components/PlaybackControls";
6
+ import { StepPanel } from "./components/StepPanel";
7
+ import { Timeline } from "./components/Timeline";
8
+ import type { PlaybackStatus, WalkthroughDef } from "./types";
9
+
10
+ const DEMO_WALKTHROUGH: WalkthroughDef = {
11
+ url: "https://example.com",
12
+ title: "Demo Walkthrough",
13
+ steps: [
14
+ { id: "step_1", type: "moveTo", duration: 600, options: { x: 200, y: 150, easing: "ease" } },
15
+ {
16
+ id: "step_2",
17
+ type: "click",
18
+ duration: 200,
19
+ options: { x: 200, y: 150, button: "left", double: false },
20
+ },
21
+ {
22
+ id: "step_3",
23
+ type: "type",
24
+ duration: 1200,
25
+ options: { text: "hello@example.com", delay: 80 },
26
+ },
27
+ { id: "step_4", type: "wait", duration: 500, options: { ms: 500 } },
28
+ {
29
+ id: "step_5",
30
+ type: "moveTo",
31
+ duration: 400,
32
+ options: { x: 400, y: 300, easing: "ease-out" },
33
+ },
34
+ {
35
+ id: "step_6",
36
+ type: "zoom",
37
+ duration: 600,
38
+ options: { level: 1.5, follow: true, easing: "ease" },
39
+ },
40
+ {
41
+ id: "step_7",
42
+ type: "click",
43
+ duration: 200,
44
+ options: { x: 400, y: 300, button: "left", double: false },
45
+ },
46
+ {
47
+ id: "step_8",
48
+ type: "zoom",
49
+ duration: 400,
50
+ options: { level: 1, follow: false, easing: "ease" },
51
+ },
52
+ ],
53
+ };
54
+
55
+ export function App() {
56
+ const [walkthrough, setWalkthrough] = useState<WalkthroughDef | null>(null);
57
+ const [selectedStepIndex, setSelectedStepIndex] = useState<number | null>(null);
58
+ const [playbackStatus, setPlaybackStatus] = useState<PlaybackStatus>("idle");
59
+ const [currentStepIndex, setCurrentStepIndex] = useState(0);
60
+ const [playheadTime, setPlayheadTime] = useState(0);
61
+ const [loop, setLoop] = useState(false);
62
+ const [exportMenuOpen, setExportMenuOpen] = useState(false);
63
+ const exportBtnRef = useRef<HTMLDivElement>(null);
64
+
65
+ const containerRef = useRef<HTMLDivElement>(null);
66
+ const engineRef = useRef<WalkrEngine | null>(null);
67
+
68
+ // Load walkthrough.json on mount, then listen for live updates via Vite HMR
69
+ useEffect(() => {
70
+ fetch("/walkthrough.json")
71
+ .then((res) => {
72
+ if (!res.ok) throw new Error("not found");
73
+ return res.json() as Promise<WalkthroughDef>;
74
+ })
75
+ .then((data) => {
76
+ if (data?.url && Array.isArray(data.steps)) {
77
+ setWalkthrough(data);
78
+ } else {
79
+ setWalkthrough(DEMO_WALKTHROUGH);
80
+ }
81
+ })
82
+ .catch(() => setWalkthrough(DEMO_WALKTHROUGH));
83
+
84
+ if (import.meta.hot) {
85
+ import.meta.hot.on("walkthrough:update", (data: WalkthroughDef) => {
86
+ if (data?.url && Array.isArray(data.steps)) {
87
+ setWalkthrough(data);
88
+ }
89
+ });
90
+ }
91
+ }, []);
92
+
93
+ // Initialize engine when container is ready
94
+ useEffect(() => {
95
+ if (!containerRef.current) return;
96
+
97
+ const engine = new WalkrEngine({
98
+ container: containerRef.current,
99
+ cursor: walkthrough?.cursor,
100
+ });
101
+
102
+ engine.on("complete", () => {
103
+ setPlaybackStatus("idle");
104
+ setPlayheadTime(0);
105
+ setCurrentStepIndex(0);
106
+ });
107
+
108
+ engineRef.current = engine;
109
+
110
+ return () => {
111
+ engine.unmount();
112
+ engineRef.current = null;
113
+ };
114
+ }, [walkthrough?.cursor]);
115
+
116
+ const steps = walkthrough?.steps ?? [];
117
+ const totalDuration = steps.reduce((sum, s) => sum + s.duration, 0);
118
+
119
+ // Animate playhead forward while playing
120
+ const playheadRef = useRef(playheadTime);
121
+ playheadRef.current = playheadTime;
122
+ const stepIndexRef = useRef(currentStepIndex);
123
+ stepIndexRef.current = currentStepIndex;
124
+
125
+ useEffect(() => {
126
+ if (playbackStatus !== "playing") return;
127
+
128
+ let frameId = 0;
129
+ let previousTime = performance.now();
130
+
131
+ const tick = (now: number) => {
132
+ const delta = now - previousTime;
133
+ previousTime = now;
134
+
135
+ let next = playheadRef.current + delta;
136
+ if (next >= totalDuration) {
137
+ if (loop) {
138
+ next = next % totalDuration;
139
+ } else {
140
+ next = totalDuration;
141
+ }
142
+ }
143
+
144
+ setPlayheadTime(next);
145
+
146
+ // Derive step index from playhead position
147
+ let newIndex = steps.length - 1;
148
+ let accumulated = 0;
149
+ for (let i = 0; i < steps.length; i++) {
150
+ accumulated += steps[i].duration;
151
+ if (next < accumulated || (next === 0 && accumulated === 0)) {
152
+ newIndex = i;
153
+ break;
154
+ }
155
+ }
156
+
157
+ if (newIndex !== stepIndexRef.current) {
158
+ setCurrentStepIndex(newIndex);
159
+ }
160
+
161
+ frameId = requestAnimationFrame(tick);
162
+ };
163
+
164
+ frameId = requestAnimationFrame(tick);
165
+ return () => cancelAnimationFrame(frameId);
166
+ }, [playbackStatus, totalDuration, loop, steps]);
167
+
168
+ // Close export menu when clicking outside
169
+ useEffect(() => {
170
+ if (!exportMenuOpen) return;
171
+ const handleClickOutside = (e: MouseEvent) => {
172
+ if (exportBtnRef.current && !exportBtnRef.current.contains(e.target as Node)) {
173
+ setExportMenuOpen(false);
174
+ }
175
+ };
176
+ document.addEventListener("mousedown", handleClickOutside);
177
+ return () => document.removeEventListener("mousedown", handleClickOutside);
178
+ }, [exportMenuOpen]);
179
+
180
+ const handleExportJSON = useCallback(() => {
181
+ if (!walkthrough) return;
182
+ const json = JSON.stringify(walkthrough, null, 2);
183
+ const blob = new Blob([json], { type: "application/json" });
184
+ const url = URL.createObjectURL(blob);
185
+ const a = document.createElement("a");
186
+ a.href = url;
187
+ a.download = `${(walkthrough.title ?? "walkthrough").replace(/\s+/g, "-").toLowerCase()}.json`;
188
+ a.click();
189
+ URL.revokeObjectURL(url);
190
+ setExportMenuOpen(false);
191
+ }, [walkthrough]);
192
+
193
+ const handleCopyJSON = useCallback(() => {
194
+ if (!walkthrough) return;
195
+ const json = JSON.stringify(walkthrough, null, 2);
196
+ navigator.clipboard.writeText(json);
197
+ setExportMenuOpen(false);
198
+ }, [walkthrough]);
199
+
200
+ const handleLoadScript = useCallback(() => {
201
+ const input = document.createElement("input");
202
+ input.type = "file";
203
+ input.accept = ".json,.ts";
204
+ input.onchange = async () => {
205
+ const file = input.files?.[0];
206
+ if (!file) return;
207
+ const text = await file.text();
208
+ try {
209
+ const parsed = JSON.parse(text) as WalkthroughDef;
210
+ setWalkthrough(parsed);
211
+ setSelectedStepIndex(null);
212
+ setCurrentStepIndex(0);
213
+ setPlayheadTime(0);
214
+ setPlaybackStatus("idle");
215
+ } catch {
216
+ // ignore invalid JSON
217
+ }
218
+ };
219
+ input.click();
220
+ }, []);
221
+
222
+ const handleStepUpdate = useCallback((stepIndex: number, updates: Partial<Step>) => {
223
+ setWalkthrough((prev) => {
224
+ if (!prev) return prev;
225
+ const newSteps = prev.steps.map((step, i) => {
226
+ if (i !== stepIndex) return step;
227
+ return {
228
+ ...step,
229
+ ...updates,
230
+ options: updates.options ? { ...step.options, ...updates.options } : step.options,
231
+ };
232
+ });
233
+ return { ...prev, steps: newSteps };
234
+ });
235
+ }, []);
236
+
237
+ const handleReorderStep = useCallback((fromIndex: number, toIndex: number) => {
238
+ setWalkthrough((prev) => {
239
+ if (!prev) return prev;
240
+ const newSteps = [...prev.steps];
241
+ const [moved] = newSteps.splice(fromIndex, 1);
242
+ newSteps.splice(toIndex, 0, moved);
243
+ return { ...prev, steps: newSteps };
244
+ });
245
+ }, []);
246
+
247
+ const handleSeek = useCallback(
248
+ (timeMs: number) => {
249
+ const clamped = Math.max(0, Math.min(timeMs, totalDuration));
250
+ setPlayheadTime(clamped);
251
+
252
+ let accumulated = 0;
253
+ for (let i = 0; i < steps.length; i++) {
254
+ accumulated += steps[i].duration;
255
+ if (clamped <= accumulated) {
256
+ setCurrentStepIndex(i);
257
+ return;
258
+ }
259
+ }
260
+ setCurrentStepIndex(Math.max(0, steps.length - 1));
261
+ },
262
+ [steps, totalDuration],
263
+ );
264
+
265
+ const handlePlay = useCallback(() => {
266
+ const engine = engineRef.current;
267
+ if (!engine || !walkthrough) return;
268
+
269
+ if (playbackStatus === "paused") {
270
+ engine.resume();
271
+ setPlaybackStatus("playing");
272
+ } else {
273
+ setPlaybackStatus("playing");
274
+ setCurrentStepIndex(0);
275
+ setPlayheadTime(0);
276
+ engine.play(walkthrough).catch(() => {
277
+ setPlaybackStatus("idle");
278
+ });
279
+ }
280
+ }, [walkthrough, playbackStatus]);
281
+
282
+ const handlePause = useCallback(() => {
283
+ engineRef.current?.pause();
284
+ setPlaybackStatus("paused");
285
+ }, []);
286
+
287
+ const handleReset = useCallback(() => {
288
+ // Unmount and remount to reset
289
+ const engine = engineRef.current;
290
+ if (engine && containerRef.current) {
291
+ engine.unmount();
292
+ engine.mount(containerRef.current);
293
+ }
294
+ setPlaybackStatus("idle");
295
+ setCurrentStepIndex(0);
296
+ setPlayheadTime(0);
297
+ }, []);
298
+
299
+ const handleStepBack = useCallback(() => {
300
+ const newIndex = Math.max(0, currentStepIndex - 1);
301
+ setCurrentStepIndex(newIndex);
302
+ let time = 0;
303
+ for (let i = 0; i < newIndex; i++) {
304
+ time += steps[i].duration;
305
+ }
306
+ setPlayheadTime(time);
307
+ }, [currentStepIndex, steps]);
308
+
309
+ const handleStepForward = useCallback(() => {
310
+ const newIndex = Math.min(steps.length - 1, currentStepIndex + 1);
311
+ setCurrentStepIndex(newIndex);
312
+ let time = 0;
313
+ for (let i = 0; i < newIndex; i++) {
314
+ time += steps[i].duration;
315
+ }
316
+ setPlayheadTime(time);
317
+ }, [currentStepIndex, steps]);
318
+
319
+ const handleUpdateDuration = useCallback(
320
+ (stepIndex: number, newDuration: number) => {
321
+ handleStepUpdate(stepIndex, { duration: newDuration });
322
+ },
323
+ [handleStepUpdate],
324
+ );
325
+
326
+ const selectedStep = selectedStepIndex !== null ? (steps[selectedStepIndex] ?? null) : null;
327
+
328
+ return (
329
+ <div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
330
+ {/* Header */}
331
+ <header
332
+ style={{
333
+ height: 48,
334
+ background: "#0f0f0f",
335
+ borderBottom: "1px solid #2a2a2a",
336
+ padding: "0 16px",
337
+ display: "flex",
338
+ flexDirection: "row",
339
+ alignItems: "center",
340
+ flexShrink: 0,
341
+ }}
342
+ >
343
+ <span style={{ fontSize: 14, fontWeight: 600, color: "#fff", letterSpacing: "0.05em" }}>
344
+ Walkr Studio
345
+ </span>
346
+
347
+ {walkthrough && (
348
+ <span style={{ fontSize: 12, color: "#888", marginLeft: 12 }}>
349
+ {walkthrough.title ?? "Untitled"} — {walkthrough.steps.length} steps
350
+ </span>
351
+ )}
352
+
353
+ <div style={{ flex: 1 }} />
354
+
355
+ <button type="button" onClick={handleLoadScript} style={headerBtnStyle}>
356
+ Load Script
357
+ </button>
358
+ <div ref={exportBtnRef} style={{ position: "relative", marginLeft: 8 }}>
359
+ <button type="button" onClick={() => setExportMenuOpen((o) => !o)} style={headerBtnStyle}>
360
+ Export ▾
361
+ </button>
362
+ {exportMenuOpen && (
363
+ <div
364
+ style={{
365
+ position: "absolute",
366
+ top: "calc(100% + 4px)",
367
+ right: 0,
368
+ background: "#222",
369
+ border: "1px solid #333",
370
+ borderRadius: 6,
371
+ padding: "4px 0",
372
+ minWidth: 160,
373
+ zIndex: 100,
374
+ }}
375
+ >
376
+ <button type="button" onClick={handleExportJSON} style={dropdownItemStyle}>
377
+ Download JSON
378
+ </button>
379
+ <button type="button" onClick={handleCopyJSON} style={dropdownItemStyle}>
380
+ Copy JSON
381
+ </button>
382
+ </div>
383
+ )}
384
+ </div>
385
+ </header>
386
+
387
+ {/* Main content area */}
388
+ <div style={{ flex: 1, display: "flex", minHeight: 0 }}>
389
+ {/* Engine preview container */}
390
+ <div
391
+ style={{
392
+ flex: 1,
393
+ background: "#1a1a1a",
394
+ display: "flex",
395
+ flexDirection: "column",
396
+ minHeight: 0,
397
+ }}
398
+ >
399
+ <div
400
+ style={{
401
+ height: 32,
402
+ display: "flex",
403
+ alignItems: "center",
404
+ padding: "0 12px",
405
+ gap: 8,
406
+ fontSize: 12,
407
+ color: "#888",
408
+ borderBottom: "1px solid #333",
409
+ flexShrink: 0,
410
+ }}
411
+ >
412
+ <span
413
+ style={{
414
+ flex: 1,
415
+ overflow: "hidden",
416
+ textOverflow: "ellipsis",
417
+ whiteSpace: "nowrap",
418
+ }}
419
+ >
420
+ {walkthrough?.originalUrl ?? walkthrough?.url ?? "No URL"}
421
+ </span>
422
+ {walkthrough?.url && (
423
+ <a
424
+ href={walkthrough.originalUrl ?? walkthrough.url}
425
+ target="_blank"
426
+ rel="noopener noreferrer"
427
+ style={{ color: "#888", textDecoration: "none", fontSize: 14 }}
428
+ title="Open in browser"
429
+ >
430
+ &#8599;
431
+ </a>
432
+ )}
433
+ </div>
434
+ <div
435
+ ref={containerRef}
436
+ style={{
437
+ flex: 1,
438
+ position: "relative",
439
+ overflow: "hidden",
440
+ background: "#000",
441
+ }}
442
+ />
443
+ </div>
444
+
445
+ {/* Step panel */}
446
+ <StepPanel step={selectedStep} stepIndex={selectedStepIndex} onUpdate={handleStepUpdate} />
447
+ </div>
448
+
449
+ {/* Playback controls */}
450
+ <PlaybackControls
451
+ status={playbackStatus}
452
+ currentStepIndex={currentStepIndex}
453
+ totalSteps={steps.length}
454
+ onPlay={handlePlay}
455
+ onPause={handlePause}
456
+ onStepBack={handleStepBack}
457
+ onStepForward={handleStepForward}
458
+ onReset={handleReset}
459
+ onLoop={setLoop}
460
+ loop={loop}
461
+ />
462
+
463
+ {/* Timeline */}
464
+ <Timeline
465
+ steps={steps}
466
+ selectedStepIndex={selectedStepIndex}
467
+ currentStepIndex={currentStepIndex}
468
+ playheadTime={playheadTime}
469
+ totalDuration={totalDuration}
470
+ onSelectStep={setSelectedStepIndex}
471
+ onSeek={handleSeek}
472
+ onUpdateDuration={handleUpdateDuration}
473
+ onReorderStep={handleReorderStep}
474
+ />
475
+ </div>
476
+ );
477
+ }
478
+
479
+ const headerBtnStyle: React.CSSProperties = {
480
+ background: "#222",
481
+ border: "1px solid #333",
482
+ color: "#ccc",
483
+ borderRadius: 6,
484
+ padding: "6px 12px",
485
+ fontSize: 13,
486
+ cursor: "pointer",
487
+ };
488
+
489
+ const dropdownItemStyle: React.CSSProperties = {
490
+ display: "block",
491
+ width: "100%",
492
+ background: "none",
493
+ border: "none",
494
+ color: "#ccc",
495
+ padding: "8px 14px",
496
+ fontSize: 13,
497
+ textAlign: "left",
498
+ cursor: "pointer",
499
+ };
@@ -0,0 +1,104 @@
1
+ import type React from "react";
2
+ import type { PlaybackStatus } from "../types";
3
+
4
+ interface PlaybackControlsProps {
5
+ status: PlaybackStatus;
6
+ currentStepIndex: number;
7
+ totalSteps: number;
8
+ onPlay: () => void;
9
+ onPause: () => void;
10
+ onStepBack: () => void;
11
+ onStepForward: () => void;
12
+ onReset: () => void;
13
+ onLoop: (loop: boolean) => void;
14
+ loop: boolean;
15
+ }
16
+
17
+ const btnStyle: React.CSSProperties = {
18
+ background: "transparent",
19
+ color: "#a0a0a0",
20
+ border: "none",
21
+ cursor: "pointer",
22
+ fontSize: 16,
23
+ padding: "4px 8px",
24
+ borderRadius: 4,
25
+ display: "flex",
26
+ alignItems: "center",
27
+ justifyContent: "center",
28
+ };
29
+
30
+ const playBtnStyle: React.CSSProperties = {
31
+ background: "#3b82f6",
32
+ color: "white",
33
+ border: "none",
34
+ cursor: "pointer",
35
+ borderRadius: 6,
36
+ width: 36,
37
+ height: 28,
38
+ display: "flex",
39
+ alignItems: "center",
40
+ justifyContent: "center",
41
+ fontSize: 14,
42
+ };
43
+
44
+ export function PlaybackControls({
45
+ status,
46
+ currentStepIndex,
47
+ totalSteps,
48
+ onPlay,
49
+ onPause,
50
+ onStepBack,
51
+ onStepForward,
52
+ onReset,
53
+ onLoop,
54
+ loop,
55
+ }: PlaybackControlsProps) {
56
+ return (
57
+ <div
58
+ style={{
59
+ height: 44,
60
+ background: "#1c1c1c",
61
+ borderTop: "1px solid #2a2a2a",
62
+ borderBottom: "1px solid #2a2a2a",
63
+ display: "flex",
64
+ flexDirection: "row",
65
+ alignItems: "center",
66
+ gap: 8,
67
+ padding: "0 16px",
68
+ flexShrink: 0,
69
+ }}
70
+ >
71
+ <button type="button" style={btnStyle} onClick={onReset} title="Reset">
72
+
73
+ </button>
74
+ <button type="button" style={btnStyle} onClick={onStepBack} title="Step back">
75
+
76
+ </button>
77
+ <button
78
+ type="button"
79
+ style={playBtnStyle}
80
+ onClick={status === "playing" ? onPause : onPlay}
81
+ title={status === "playing" ? "Pause" : "Play"}
82
+ >
83
+ {status === "playing" ? "⏸" : "▶"}
84
+ </button>
85
+ <button type="button" style={btnStyle} onClick={onStepForward} title="Step forward">
86
+
87
+ </button>
88
+ <button
89
+ type="button"
90
+ style={{
91
+ ...btnStyle,
92
+ color: loop ? "#3b82f6" : "#a0a0a0",
93
+ }}
94
+ onClick={() => onLoop(!loop)}
95
+ title="Loop"
96
+ >
97
+ 🔁
98
+ </button>
99
+ <span style={{ fontSize: 12, color: "#888", marginLeft: "auto" }}>
100
+ Step {currentStepIndex + 1} / {totalSteps}
101
+ </span>
102
+ </div>
103
+ );
104
+ }