@bloopjs/web 0.0.90 → 0.0.92

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bloopjs/web",
3
- "version": "0.0.90",
3
+ "version": "0.0.92",
4
4
  "author": "Neil Sarkar",
5
5
  "type": "module",
6
6
  "repository": {
@@ -33,8 +33,8 @@
33
33
  "typescript": "^5"
34
34
  },
35
35
  "dependencies": {
36
- "@bloopjs/bloop": "0.0.90",
37
- "@bloopjs/engine": "0.0.90",
36
+ "@bloopjs/bloop": "0.0.92",
37
+ "@bloopjs/engine": "0.0.92",
38
38
  "@preact/signals": "^1.3.1",
39
39
  "partysocket": "^1.1.6",
40
40
  "preact": "^10.25.4"
package/src/App.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  triggerHmrFlash,
14
14
  wirePlaybarHandlers,
15
15
  wireTapeDragDrop,
16
+ wireTapeLoadHandlers,
16
17
  } from "./debugui/state.ts";
17
18
  import {
18
19
  joinRoom as joinRoomInternal,
@@ -129,9 +130,10 @@ export class App {
129
130
  if (this.#debugUi) return this.#debugUi;
130
131
  this.#debugUi = new DebugUi(opts);
131
132
 
132
- // Wire up playbar handlers and drag-drop
133
+ // Wire up playbar handlers, drag-drop, and tape loading
133
134
  wirePlaybarHandlers(this);
134
135
  wireTapeDragDrop(this.#debugUi.canvas, this);
136
+ wireTapeLoadHandlers(this);
135
137
 
136
138
  return this.#debugUi;
137
139
  }
@@ -1,5 +1,6 @@
1
1
  import { useRef, useCallback } from "preact/hooks";
2
2
  import { debugState } from "../state.ts";
3
+ import { LoadTapeDialog } from "./LoadTapeDialog.tsx";
3
4
 
4
5
  /** Hook that returns handlers for repeat-on-hold behavior with initial debounce */
5
6
  function useRepeatOnHold(action: () => void) {
@@ -114,39 +115,57 @@ export function BottomBar() {
114
115
 
115
116
  const seekDrag = useSeekDrag(handleSeek);
116
117
 
118
+ const handleLoadTapeClick = useCallback(() => {
119
+ debugState.isLoadDialogOpen.value = true;
120
+ }, []);
121
+
122
+ const handleSaveTapeClick = useCallback(() => {
123
+ debugState.onSaveTape.value?.();
124
+ }, []);
125
+
117
126
  return (
118
127
  <div className="bottom-bar">
119
128
  <div className="playbar-controls">
120
- <button className="playbar-btn" {...jumpBackRepeat}>
129
+ <button className="playbar-btn jump-back" {...jumpBackRepeat}>
121
130
  {"<<"}
122
131
  <span className="tooltip tooltip-left">
123
132
  Jump back <kbd>4</kbd>
124
133
  </span>
125
134
  </button>
126
- <button className="playbar-btn" {...stepBackRepeat}>
135
+ <button className="playbar-btn step-back" {...stepBackRepeat}>
127
136
  {"<"}
128
137
  <span className="tooltip">
129
138
  Step back <kbd>5</kbd>
130
139
  </span>
131
140
  </button>
132
- <button className="playbar-btn" onClick={handlePlayPause}>
141
+ <button className="playbar-btn play-pause" onClick={handlePlayPause}>
133
142
  {isPlaying ? "||" : ">"}
134
143
  <span className="tooltip">
135
144
  {isPlaying ? "Pause" : "Play"} <kbd>6</kbd>
136
145
  </span>
137
146
  </button>
138
- <button className="playbar-btn" {...stepForwardRepeat}>
147
+ <button className="playbar-btn step-forward" {...stepForwardRepeat}>
139
148
  {">"}
140
149
  <span className="tooltip">
141
150
  Step forward <kbd>7</kbd>
142
151
  </span>
143
152
  </button>
144
- <button className="playbar-btn" {...jumpForwardRepeat}>
153
+ <button className="playbar-btn jump-forward" {...jumpForwardRepeat}>
145
154
  {">>"}
146
155
  <span className="tooltip">
147
156
  Jump forward <kbd>8</kbd>
148
157
  </span>
149
158
  </button>
159
+ <button className="playbar-btn save-tape-btn" onClick={handleSaveTapeClick}>
160
+ Save
161
+ <span className="tooltip">
162
+ Save tape <kbd>Cmd+S</kbd>
163
+ </span>
164
+ </button>
165
+ <button className="playbar-btn load-tape-btn" onClick={handleLoadTapeClick}>
166
+ Load
167
+ <span className="tooltip">Load tape</span>
168
+ </button>
150
169
  </div>
151
170
  <div className="seek-bar" {...seekDrag}>
152
171
  <div
@@ -160,6 +179,7 @@ export function BottomBar() {
160
179
  />
161
180
  )}
162
181
  </div>
182
+ <LoadTapeDialog />
163
183
  </div>
164
184
  );
165
185
  }
@@ -0,0 +1,112 @@
1
+ import { useRef, useEffect, useCallback, useState } from "preact/hooks";
2
+ import { debugState } from "../state.ts";
3
+
4
+ export function LoadTapeDialog() {
5
+ const dialogRef = useRef<HTMLDialogElement>(null);
6
+ const fileInputRef = useRef<HTMLInputElement>(null);
7
+ const [isDragOver, setIsDragOver] = useState(false);
8
+
9
+ const isOpen = debugState.isLoadDialogOpen.value;
10
+ const lastTapeName = debugState.lastTapeName.value;
11
+
12
+ // Sync dialog open/close with signal
13
+ useEffect(() => {
14
+ const dialog = dialogRef.current;
15
+ if (!dialog) return;
16
+
17
+ if (isOpen && !dialog.open) {
18
+ dialog.showModal();
19
+ } else if (!isOpen && dialog.open) {
20
+ dialog.close();
21
+ }
22
+ }, [isOpen]);
23
+
24
+ // Handle escape key closing dialog
25
+ const handleClose = useCallback(() => {
26
+ debugState.isLoadDialogOpen.value = false;
27
+ }, []);
28
+
29
+ const handleFileSelect = useCallback(async (file: File) => {
30
+ if (!file.name.endsWith(".bloop")) return;
31
+ const bytes = new Uint8Array(await file.arrayBuffer());
32
+ debugState.onLoadTape.value?.(bytes, file.name);
33
+ }, []);
34
+
35
+ const handleDropZoneClick = useCallback(() => {
36
+ fileInputRef.current?.click();
37
+ }, []);
38
+
39
+ const handleFileInputChange = useCallback(
40
+ (e: { currentTarget: HTMLInputElement }) => {
41
+ const input = e.currentTarget;
42
+ const file = input.files?.[0];
43
+ if (file) {
44
+ handleFileSelect(file);
45
+ input.value = ""; // Reset for next selection
46
+ }
47
+ },
48
+ [handleFileSelect],
49
+ );
50
+
51
+ const handleDragOver = useCallback(
52
+ (e: { preventDefault: () => void; dataTransfer: DataTransfer | null }) => {
53
+ e.preventDefault();
54
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
55
+ setIsDragOver(true);
56
+ },
57
+ [],
58
+ );
59
+
60
+ const handleDragLeave = useCallback(() => {
61
+ setIsDragOver(false);
62
+ }, []);
63
+
64
+ const handleDrop = useCallback(
65
+ (e: { preventDefault: () => void; dataTransfer: DataTransfer | null }) => {
66
+ e.preventDefault();
67
+ setIsDragOver(false);
68
+ const file = e.dataTransfer?.files[0];
69
+ if (file) {
70
+ handleFileSelect(file);
71
+ }
72
+ },
73
+ [handleFileSelect],
74
+ );
75
+
76
+ const handleReplayLast = useCallback(() => {
77
+ debugState.onReplayLastTape.value?.();
78
+ }, []);
79
+
80
+ return (
81
+ <dialog ref={dialogRef} className="load-tape-dialog" onClose={handleClose}>
82
+ <div className="load-tape-dialog-content">
83
+ <h3>Load Tape</h3>
84
+ <div
85
+ className={`drop-zone ${isDragOver ? "drag-over" : ""}`}
86
+ onClick={handleDropZoneClick}
87
+ onDragOver={handleDragOver}
88
+ onDragLeave={handleDragLeave}
89
+ onDrop={handleDrop}
90
+ >
91
+ <span className="drop-zone-text">
92
+ Drop .bloop file here
93
+ <br />
94
+ or click to browse
95
+ </span>
96
+ </div>
97
+ <input
98
+ ref={fileInputRef}
99
+ type="file"
100
+ accept=".bloop"
101
+ className="hidden-file-input"
102
+ onChange={handleFileInputChange}
103
+ />
104
+ {lastTapeName && (
105
+ <button className="replay-last-btn" onClick={handleReplayLast}>
106
+ Replay last: {lastTapeName}
107
+ </button>
108
+ )}
109
+ </div>
110
+ </dialog>
111
+ );
112
+ }
@@ -53,6 +53,12 @@ export type DebugState = {
53
53
  onStepForward: Signal<(() => void) | null>;
54
54
  onJumpForward: Signal<(() => void) | null>;
55
55
  onSeek: Signal<((position: number) => void) | null>;
56
+ // Tape loading/saving
57
+ onLoadTape: Signal<((bytes: Uint8Array, fileName: string) => void) | null>;
58
+ onReplayLastTape: Signal<(() => void) | null>;
59
+ onSaveTape: Signal<(() => void) | null>;
60
+ lastTapeName: Signal<string | null>;
61
+ isLoadDialogOpen: Signal<boolean>;
56
62
  };
57
63
 
58
64
  const layoutMode = signal<LayoutMode>("off");
@@ -84,6 +90,13 @@ const onStepForward = signal<(() => void) | null>(null);
84
90
  const onJumpForward = signal<(() => void) | null>(null);
85
91
  const onSeek = signal<((position: number) => void) | null>(null);
86
92
 
93
+ // Tape loading/saving
94
+ const onLoadTape = signal<((bytes: Uint8Array, fileName: string) => void) | null>(null);
95
+ const onReplayLastTape = signal<(() => void) | null>(null);
96
+ const onSaveTape = signal<(() => void) | null>(null);
97
+ const lastTapeName = signal<string | null>(null);
98
+ const isLoadDialogOpen = signal(false);
99
+
87
100
  export const debugState: DebugState = {
88
101
  /** Layout mode: off, letterboxed, or full */
89
102
  layoutMode,
@@ -129,6 +142,13 @@ export const debugState: DebugState = {
129
142
  onStepForward,
130
143
  onJumpForward,
131
144
  onSeek,
145
+
146
+ /** Tape loading/saving */
147
+ onLoadTape,
148
+ onReplayLastTape,
149
+ onSaveTape,
150
+ lastTapeName,
151
+ isLoadDialogOpen,
132
152
  };
133
153
 
134
154
  /** Cycle through layout modes: off -> letterboxed -> full -> off */
@@ -310,3 +330,88 @@ export function wireTapeDragDrop(canvas: HTMLCanvasElement, app: App): void {
310
330
  app.loadTape(bytes);
311
331
  });
312
332
  }
333
+
334
+ // IndexedDB helpers for tape persistence
335
+ const TAPE_DB_NAME = "bloop-debug";
336
+ const TAPE_STORE_NAME = "tapes";
337
+ const TAPE_KEY = "last";
338
+
339
+ function openTapeDB(): Promise<IDBDatabase> {
340
+ return new Promise((resolve, reject) => {
341
+ const request = indexedDB.open(TAPE_DB_NAME, 1);
342
+ request.onerror = () => reject(request.error);
343
+ request.onsuccess = () => resolve(request.result);
344
+ request.onupgradeneeded = () => {
345
+ request.result.createObjectStore(TAPE_STORE_NAME);
346
+ };
347
+ });
348
+ }
349
+
350
+ async function saveTapeToStorage(
351
+ bytes: Uint8Array,
352
+ fileName: string,
353
+ ): Promise<void> {
354
+ const db = await openTapeDB();
355
+ return new Promise((resolve, reject) => {
356
+ const tx = db.transaction(TAPE_STORE_NAME, "readwrite");
357
+ tx.objectStore(TAPE_STORE_NAME).put({ bytes, fileName }, TAPE_KEY);
358
+ tx.oncomplete = () => resolve();
359
+ tx.onerror = () => reject(tx.error);
360
+ });
361
+ }
362
+
363
+ async function loadTapeFromStorage(): Promise<{
364
+ bytes: Uint8Array;
365
+ fileName: string;
366
+ } | null> {
367
+ try {
368
+ const db = await openTapeDB();
369
+ return new Promise((resolve, reject) => {
370
+ const tx = db.transaction(TAPE_STORE_NAME, "readonly");
371
+ const request = tx.objectStore(TAPE_STORE_NAME).get(TAPE_KEY);
372
+ request.onsuccess = () => resolve(request.result ?? null);
373
+ request.onerror = () => reject(request.error);
374
+ });
375
+ } catch {
376
+ return null;
377
+ }
378
+ }
379
+
380
+ /** Check for saved tape and update lastTapeName signal */
381
+ export async function checkForSavedTape(): Promise<void> {
382
+ const saved = await loadTapeFromStorage();
383
+ debugState.lastTapeName.value = saved?.fileName ?? null;
384
+ }
385
+
386
+ /** Wire up tape loading handlers */
387
+ export function wireTapeLoadHandlers(app: App): void {
388
+ debugState.onLoadTape.value = async (bytes: Uint8Array, fileName: string) => {
389
+ app.loadTape(bytes);
390
+ await saveTapeToStorage(bytes, fileName);
391
+ debugState.lastTapeName.value = fileName;
392
+ debugState.isLoadDialogOpen.value = false;
393
+ };
394
+
395
+ debugState.onReplayLastTape.value = async () => {
396
+ const saved = await loadTapeFromStorage();
397
+ if (saved) {
398
+ app.loadTape(saved.bytes);
399
+ debugState.isLoadDialogOpen.value = false;
400
+ }
401
+ };
402
+
403
+ debugState.onSaveTape.value = () => {
404
+ if (!app.sim.hasHistory) return;
405
+ const tape = app.sim.saveTape();
406
+ const blob = new Blob([tape], { type: "application/octet-stream" });
407
+ const url = URL.createObjectURL(blob);
408
+ const a = document.createElement("a");
409
+ a.href = url;
410
+ a.download = `tape-${Date.now()}.bloop`;
411
+ a.click();
412
+ URL.revokeObjectURL(url);
413
+ };
414
+
415
+ // Check for saved tape on init
416
+ checkForSavedTape();
417
+ }