@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/dist/App.d.ts.map +1 -1
- package/dist/debugui/components/BottomBar.d.ts.map +1 -1
- package/dist/debugui/components/LoadTapeDialog.d.ts +2 -0
- package/dist/debugui/components/LoadTapeDialog.d.ts.map +1 -0
- package/dist/debugui/state.d.ts +8 -0
- package/dist/debugui/state.d.ts.map +1 -1
- package/dist/debugui/styles.d.ts +1 -1
- package/dist/debugui/styles.d.ts.map +1 -1
- package/dist/mod.js +349 -41
- package/dist/mod.js.map +10 -9
- package/package.json +3 -3
- package/src/App.ts +3 -1
- package/src/debugui/components/BottomBar.tsx +10 -0
- package/src/debugui/components/LoadTapeDialog.tsx +112 -0
- package/src/debugui/state.ts +90 -0
- package/src/debugui/styles.ts +83 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bloopjs/web",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
37
|
-
"@bloopjs/engine": "0.0.
|
|
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
|
|
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
|
+
}
|
package/src/debugui/state.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/debugui/styles.ts
CHANGED
|
@@ -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
|
`;
|