@bloopjs/web 0.0.90 → 0.0.91

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.91",
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.91",
37
+ "@bloopjs/engine": "0.0.91",
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,6 +115,10 @@ export function BottomBar() {
114
115
 
115
116
  const seekDrag = useSeekDrag(handleSeek);
116
117
 
118
+ const handleLoadTapeClick = useCallback(() => {
119
+ debugState.isLoadDialogOpen.value = true;
120
+ }, []);
121
+
117
122
  return (
118
123
  <div className="bottom-bar">
119
124
  <div className="playbar-controls">
@@ -147,6 +152,10 @@ export function BottomBar() {
147
152
  Jump forward <kbd>8</kbd>
148
153
  </span>
149
154
  </button>
155
+ <button className="playbar-btn load-tape-btn" onClick={handleLoadTapeClick}>
156
+ Load
157
+ <span className="tooltip">Load tape</span>
158
+ </button>
150
159
  </div>
151
160
  <div className="seek-bar" {...seekDrag}>
152
161
  <div
@@ -160,6 +169,7 @@ export function BottomBar() {
160
169
  />
161
170
  )}
162
171
  </div>
172
+ <LoadTapeDialog />
163
173
  </div>
164
174
  );
165
175
  }
@@ -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,11 @@ 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
57
+ onLoadTape: Signal<((bytes: Uint8Array, fileName: string) => void) | null>;
58
+ onReplayLastTape: Signal<(() => void) | null>;
59
+ lastTapeName: Signal<string | null>;
60
+ isLoadDialogOpen: Signal<boolean>;
56
61
  };
57
62
 
58
63
  const layoutMode = signal<LayoutMode>("off");
@@ -84,6 +89,12 @@ const onStepForward = signal<(() => void) | null>(null);
84
89
  const onJumpForward = signal<(() => void) | null>(null);
85
90
  const onSeek = signal<((position: number) => void) | null>(null);
86
91
 
92
+ // Tape loading
93
+ const onLoadTape = signal<((bytes: Uint8Array, fileName: string) => void) | null>(null);
94
+ const onReplayLastTape = signal<(() => void) | null>(null);
95
+ const lastTapeName = signal<string | null>(null);
96
+ const isLoadDialogOpen = signal(false);
97
+
87
98
  export const debugState: DebugState = {
88
99
  /** Layout mode: off, letterboxed, or full */
89
100
  layoutMode,
@@ -129,6 +140,12 @@ export const debugState: DebugState = {
129
140
  onStepForward,
130
141
  onJumpForward,
131
142
  onSeek,
143
+
144
+ /** Tape loading */
145
+ onLoadTape,
146
+ onReplayLastTape,
147
+ lastTapeName,
148
+ isLoadDialogOpen,
132
149
  };
133
150
 
134
151
  /** Cycle through layout modes: off -> letterboxed -> full -> off */
@@ -310,3 +327,76 @@ export function wireTapeDragDrop(canvas: HTMLCanvasElement, app: App): void {
310
327
  app.loadTape(bytes);
311
328
  });
312
329
  }
330
+
331
+ // IndexedDB helpers for tape persistence
332
+ const TAPE_DB_NAME = "bloop-debug";
333
+ const TAPE_STORE_NAME = "tapes";
334
+ const TAPE_KEY = "last";
335
+
336
+ function openTapeDB(): Promise<IDBDatabase> {
337
+ return new Promise((resolve, reject) => {
338
+ const request = indexedDB.open(TAPE_DB_NAME, 1);
339
+ request.onerror = () => reject(request.error);
340
+ request.onsuccess = () => resolve(request.result);
341
+ request.onupgradeneeded = () => {
342
+ request.result.createObjectStore(TAPE_STORE_NAME);
343
+ };
344
+ });
345
+ }
346
+
347
+ async function saveTapeToStorage(
348
+ bytes: Uint8Array,
349
+ fileName: string,
350
+ ): Promise<void> {
351
+ const db = await openTapeDB();
352
+ return new Promise((resolve, reject) => {
353
+ const tx = db.transaction(TAPE_STORE_NAME, "readwrite");
354
+ tx.objectStore(TAPE_STORE_NAME).put({ bytes, fileName }, TAPE_KEY);
355
+ tx.oncomplete = () => resolve();
356
+ tx.onerror = () => reject(tx.error);
357
+ });
358
+ }
359
+
360
+ async function loadTapeFromStorage(): Promise<{
361
+ bytes: Uint8Array;
362
+ fileName: string;
363
+ } | null> {
364
+ try {
365
+ const db = await openTapeDB();
366
+ return new Promise((resolve, reject) => {
367
+ const tx = db.transaction(TAPE_STORE_NAME, "readonly");
368
+ const request = tx.objectStore(TAPE_STORE_NAME).get(TAPE_KEY);
369
+ request.onsuccess = () => resolve(request.result ?? null);
370
+ request.onerror = () => reject(request.error);
371
+ });
372
+ } catch {
373
+ return null;
374
+ }
375
+ }
376
+
377
+ /** Check for saved tape and update lastTapeName signal */
378
+ export async function checkForSavedTape(): Promise<void> {
379
+ const saved = await loadTapeFromStorage();
380
+ debugState.lastTapeName.value = saved?.fileName ?? null;
381
+ }
382
+
383
+ /** Wire up tape loading handlers */
384
+ export function wireTapeLoadHandlers(app: App): void {
385
+ debugState.onLoadTape.value = async (bytes: Uint8Array, fileName: string) => {
386
+ app.loadTape(bytes);
387
+ await saveTapeToStorage(bytes, fileName);
388
+ debugState.lastTapeName.value = fileName;
389
+ debugState.isLoadDialogOpen.value = false;
390
+ };
391
+
392
+ debugState.onReplayLastTape.value = async () => {
393
+ const saved = await loadTapeFromStorage();
394
+ if (saved) {
395
+ app.loadTape(saved.bytes);
396
+ debugState.isLoadDialogOpen.value = false;
397
+ }
398
+ };
399
+
400
+ // Check for saved tape on init
401
+ checkForSavedTape();
402
+ }
@@ -445,4 +445,87 @@ export const styles = /*css*/ `
445
445
  border-radius: 4px;
446
446
  border: 1px inset lavender;
447
447
  }
448
+
449
+ /* Load Tape Dialog */
450
+ .load-tape-dialog {
451
+ background: #1a1a1a;
452
+ border: 1px solid #333;
453
+ border-radius: 8px;
454
+ padding: 0;
455
+ color: #ccc;
456
+ font-family: monospace;
457
+ max-width: 320px;
458
+ width: 90vw;
459
+ }
460
+
461
+ .load-tape-dialog::backdrop {
462
+ background: rgba(0, 0, 0, 0.7);
463
+ }
464
+
465
+ .load-tape-dialog-content {
466
+ padding: 16px;
467
+ }
468
+
469
+ .load-tape-dialog h3 {
470
+ margin: 0 0 16px 0;
471
+ font-size: 14px;
472
+ font-weight: 600;
473
+ color: #fff;
474
+ }
475
+
476
+ .drop-zone {
477
+ border: 2px dashed #444;
478
+ border-radius: 8px;
479
+ padding: 32px 16px;
480
+ text-align: center;
481
+ cursor: pointer;
482
+ transition: border-color 0.15s, background 0.15s;
483
+ }
484
+
485
+ .drop-zone:hover {
486
+ border-color: #666;
487
+ background: #222;
488
+ }
489
+
490
+ .drop-zone.drag-over {
491
+ border-color: #7b3fa0;
492
+ background: rgba(123, 63, 160, 0.1);
493
+ }
494
+
495
+ .drop-zone-text {
496
+ color: #888;
497
+ font-size: 12px;
498
+ line-height: 1.5;
499
+ }
500
+
501
+ .hidden-file-input {
502
+ display: none;
503
+ }
504
+
505
+ .replay-last-btn {
506
+ width: 100%;
507
+ margin-top: 12px;
508
+ padding: 8px 12px;
509
+ background: #333;
510
+ border: none;
511
+ border-radius: 4px;
512
+ color: #ccc;
513
+ font-family: monospace;
514
+ font-size: 12px;
515
+ cursor: pointer;
516
+ transition: background 0.15s;
517
+ text-align: left;
518
+ overflow: hidden;
519
+ text-overflow: ellipsis;
520
+ white-space: nowrap;
521
+ }
522
+
523
+ .replay-last-btn:hover {
524
+ background: #444;
525
+ color: #fff;
526
+ }
527
+
528
+ .load-tape-btn {
529
+ margin-left: 4px;
530
+ }
448
531
  `;