jsbeeb 1.23.0 → 1.24.0
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/README.md +2 -2
- package/package.json +14 -10
- package/src/6502.js +39 -42
- package/src/6502.opcodes.js +7 -13
- package/src/6847.js +4 -5
- package/src/6847_fontdata.js +0 -2
- package/src/acia.js +2 -3
- package/src/app/app.js +0 -1
- package/src/app/args.js +0 -2
- package/src/app/electron.js +4 -6
- package/src/app-bench.js +1 -3
- package/src/archive.js +243 -0
- package/src/basic-loader.js +0 -7
- package/src/basic-tokenise.js +19 -6
- package/src/bbc-palette.js +0 -2
- package/src/bbcdiscs.js +0 -2
- package/src/bem-snapshot.js +1 -3
- package/src/binary.js +175 -0
- package/src/cmos.js +0 -2
- package/src/ddnoise.js +0 -1
- package/src/debounce.js +7 -0
- package/src/disc-surface.js +0 -2
- package/src/disc.js +10 -9
- package/src/fake6502.js +7 -23
- package/src/fdc.js +9 -51
- package/src/filestore.js +10 -17
- package/src/hex.js +38 -0
- package/src/index.js +11 -0
- package/src/intel-fdc.js +18 -20
- package/src/{utils_atom.js → keymap-atom.js} +2 -18
- package/src/{utils.js → keymap.js} +47 -610
- package/src/loader.js +63 -0
- package/src/machine-session.js +37 -177
- package/src/machine-spec.js +59 -0
- package/src/main.js +66 -180
- package/src/media-resolver.js +68 -0
- package/src/mmc.js +3 -2
- package/src/models.js +17 -3
- package/src/music5000.js +0 -2
- package/src/ppia.js +3 -4
- package/src/printer.js +7 -14
- package/src/relaynoise.js +0 -1
- package/src/rewind.js +0 -2
- package/src/sample-player.js +2 -3
- package/src/serial.js +0 -2
- package/src/snapshot-helpers.js +0 -2
- package/src/snapshot.js +0 -2
- package/src/soundchip.js +1 -1
- package/src/state-utils.js +0 -2
- package/src/sth.js +2 -4
- package/src/tapes.js +10 -4
- package/src/teletext.js +2 -3
- package/src/teletext_adaptor.js +2 -4
- package/src/teletext_data.js +0 -2
- package/src/test-machine.js +307 -0
- package/src/touchscreen.js +2 -4
- package/src/tube.js +5 -6
- package/src/typist.js +103 -0
- package/src/uef-snapshot.js +0 -2
- package/src/url-params.js +4 -5
- package/src/vdu-capture.js +113 -0
- package/src/via.js +3 -4
- package/src/video-filters/pal-composite.js +0 -2
- package/src/video-filters/passthrough-filter.js +0 -2
- package/src/video-filters/pixel-grid.js +0 -2
- package/src/video-filters/shader-program.js +0 -2
- package/src/video-filters/xbr-filter.js +0 -2
- package/src/video.js +12 -11
- package/src/wd-fdc.js +20 -20
- package/src/web/analogue-inputs.js +6 -8
- package/src/web/analytics.js +13 -0
- package/src/web/audio-handler.js +2 -2
- package/src/{audio-utils.js → web/audio-utils.js} +0 -2
- package/src/web/autoboot.js +10 -13
- package/src/{canvas.js → web/canvas.js} +4 -5
- package/src/web/config.js +41 -74
- package/src/web/console-surface.js +25 -0
- package/src/web/debug.js +6 -5
- package/src/web/disc-visualiser.js +0 -2
- package/src/web/display.js +21 -4
- package/src/{dom-utils.js → web/dom-utils.js} +1 -11
- package/src/web/drives.js +1 -1
- package/src/web/emulation-loop.js +54 -33
- package/src/web/front-panel.js +13 -12
- package/src/{gamepads.js → web/gamepads.js} +2 -6
- package/src/web/google-drive-picker.js +14 -9
- package/src/{google-drive.js → web/google-drive.js} +9 -10
- package/src/web/hfe-picker.js +5 -4
- package/src/web/keyboard-setup.js +18 -38
- package/src/{keyboard.js → web/keyboard.js} +18 -119
- package/src/web/layout.js +4 -0
- package/src/web/local-disc.js +44 -0
- package/src/web/machine.js +27 -20
- package/src/web/media-loader.js +38 -147
- package/src/{microphone-input.js → web/microphone-input.js} +1 -1
- package/src/web/modals.js +6 -9
- package/src/web/page-actions.js +88 -0
- package/src/web/quick-settings.js +16 -15
- package/src/web/reporting.js +0 -7
- package/src/{rewind-thumbnail.js → web/rewind-thumbnail.js} +0 -2
- package/src/web/rewind-ui.js +29 -29
- package/src/web/run-controls.js +39 -0
- package/src/web/settings.js +64 -135
- package/src/web/snapshot-ui.js +10 -16
- package/src/{speech-output.js → web/speech-output.js} +0 -2
- package/src/web/sth-picker.js +9 -7
- package/src/web/url-state.js +20 -6
- package/src/econet/L3FS.dat +0 -0
- package/src/econet/scsi.dat +0 -0
- package/tests/test-machine.js +0 -609
package/src/web/media-loader.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import * as utils from "../utils.js";
|
|
2
1
|
import * as disc from "../fdc.js";
|
|
2
|
+
import { localDisc } from "./local-disc.js";
|
|
3
3
|
import { DiscLayout } from "../disc.js";
|
|
4
4
|
import { loadTapeFromData } from "../tapes.js";
|
|
5
5
|
import { toast } from "./toast.js";
|
|
6
|
-
import { errorText, reportIgnoredFiles, reportLoadFailure
|
|
6
|
+
import { errorText, reportIgnoredFiles, reportLoadFailure } from "./reporting.js";
|
|
7
|
+
import { MediaResolver, openIfZip, splitImage } from "../media-resolver.js";
|
|
8
|
+
import { stringToUint8Array } from "../binary.js";
|
|
9
|
+
import { noteEvent } from "./analytics.js";
|
|
7
10
|
|
|
8
11
|
/** The images offered on the Discs dialog's built-in list. */
|
|
9
12
|
export const BuiltInImages = [
|
|
@@ -24,13 +27,6 @@ export const BuiltInImages = [
|
|
|
24
27
|
},
|
|
25
28
|
];
|
|
26
29
|
|
|
27
|
-
export function splitImage(image) {
|
|
28
|
-
const match = image.match(/(([^:]+):\/?\/?|[!^|])?(.*)/);
|
|
29
|
-
const schema = match[2] || match[1] || "";
|
|
30
|
-
image = match[3];
|
|
31
|
-
return { image: image, schema: schema };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
30
|
function readFileAsBinaryString(file) {
|
|
35
31
|
return new Promise((resolve, reject) => {
|
|
36
32
|
const reader = new FileReader();
|
|
@@ -63,11 +59,12 @@ export class MediaLoader extends EventTarget {
|
|
|
63
59
|
this.drives = drives;
|
|
64
60
|
this.urlState = urlState;
|
|
65
61
|
this.modals = modals;
|
|
66
|
-
this.
|
|
62
|
+
this.resolver = new MediaResolver();
|
|
63
|
+
this.driveSource = null;
|
|
67
64
|
|
|
68
65
|
document.getElementById("disc_load").addEventListener("change", async (evt) => {
|
|
69
66
|
if (evt.target.files.length === 0) return;
|
|
70
|
-
|
|
67
|
+
noteEvent("local", "click"); // NB no filename here
|
|
71
68
|
const file = evt.target.files[0];
|
|
72
69
|
try {
|
|
73
70
|
await this.loadHTMLFile(file);
|
|
@@ -79,7 +76,7 @@ export class MediaLoader extends EventTarget {
|
|
|
79
76
|
|
|
80
77
|
document.getElementById("fs_load").addEventListener("change", async (evt) => {
|
|
81
78
|
if (evt.target.files.length === 0) return;
|
|
82
|
-
|
|
79
|
+
noteEvent("local", "click"); // NB no filename here
|
|
83
80
|
const file = evt.target.files[0];
|
|
84
81
|
try {
|
|
85
82
|
await this.loadSCSIFile(file);
|
|
@@ -92,17 +89,15 @@ export class MediaLoader extends EventTarget {
|
|
|
92
89
|
document.getElementById("tape_load").addEventListener("change", async (evt) => {
|
|
93
90
|
if (evt.target.files.length === 0) return;
|
|
94
91
|
const file = evt.target.files[0];
|
|
95
|
-
|
|
92
|
+
noteEvent("local", "clickTape"); // NB no filename here
|
|
96
93
|
|
|
97
94
|
try {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
this.setProcessorTape(await loadTapeFromData(tapeName, tapeData, model));
|
|
95
|
+
const { name, data, ignored } = await openIfZip(
|
|
96
|
+
file.name,
|
|
97
|
+
stringToUint8Array(await readFileAsBinaryString(file)),
|
|
98
|
+
);
|
|
99
|
+
reportIgnoredFiles(name, ignored);
|
|
100
|
+
this.setProcessorTape(await loadTapeFromData(name, data, model));
|
|
106
101
|
urlState.set({ tape: undefined });
|
|
107
102
|
modals.hide("tapes");
|
|
108
103
|
} catch (error) {
|
|
@@ -119,7 +114,7 @@ export class MediaLoader extends EventTarget {
|
|
|
119
114
|
event.dataTransfer.dropEffect = "copy";
|
|
120
115
|
});
|
|
121
116
|
pastetext.addEventListener("drop", async (event) => {
|
|
122
|
-
|
|
117
|
+
noteEvent("local", "drop");
|
|
123
118
|
const file = event.dataTransfer.files[0];
|
|
124
119
|
if (!file) return;
|
|
125
120
|
try {
|
|
@@ -148,7 +143,7 @@ export class MediaLoader extends EventTarget {
|
|
|
148
143
|
elem.querySelector(".name").textContent = image.name;
|
|
149
144
|
elem.querySelector(".description").textContent = image.desc;
|
|
150
145
|
elem.addEventListener("click", async () => {
|
|
151
|
-
|
|
146
|
+
noteEvent("images", "click", image.file);
|
|
152
147
|
this.setDisc1Image(image.file);
|
|
153
148
|
modals.hide("discs");
|
|
154
149
|
try {
|
|
@@ -166,16 +161,12 @@ export class MediaLoader extends EventTarget {
|
|
|
166
161
|
|
|
167
162
|
/** Register the fetcher behind an image schema; each picker calls this as it is constructed. */
|
|
168
163
|
addSource(schema, fetcher) {
|
|
169
|
-
this.
|
|
164
|
+
if (schema === "drive") this.driveSource = fetcher;
|
|
165
|
+
else this.resolver.addSource(schema, fetcher);
|
|
170
166
|
}
|
|
171
167
|
|
|
172
|
-
/** Route tape to the correct interface (ACIA for BBC, PPIA for Atom) */
|
|
173
168
|
setProcessorTape(tape) {
|
|
174
|
-
|
|
175
|
-
this.processor.atomppia.setTape(tape);
|
|
176
|
-
} else {
|
|
177
|
-
this.processor.acia.setTape(tape);
|
|
178
|
-
}
|
|
169
|
+
this.processor.tapeInterface.setTape(tape);
|
|
179
170
|
}
|
|
180
171
|
|
|
181
172
|
setDisc1Image(name) {
|
|
@@ -194,14 +185,8 @@ export class MediaLoader extends EventTarget {
|
|
|
194
185
|
}
|
|
195
186
|
|
|
196
187
|
async loadHTMLFile(file) {
|
|
197
|
-
const imageData =
|
|
198
|
-
const loadedDisc = disc.discFor(
|
|
199
|
-
this.processor.fdc,
|
|
200
|
-
file.name,
|
|
201
|
-
imageData,
|
|
202
|
-
undefined,
|
|
203
|
-
this.drives.layoutForDrive(0),
|
|
204
|
-
);
|
|
188
|
+
const imageData = stringToUint8Array(await readFileAsBinaryString(file));
|
|
189
|
+
const loadedDisc = disc.discFor(file.name, imageData, undefined, this.drives.layoutForDrive(0));
|
|
205
190
|
// Local file: retain the image bytes for embedding in save-to-file snapshots.
|
|
206
191
|
loadedDisc.setOriginalImage(imageData);
|
|
207
192
|
this.drives.putDiscIn(0, loadedDisc);
|
|
@@ -213,7 +198,7 @@ export class MediaLoader extends EventTarget {
|
|
|
213
198
|
const { processor } = this;
|
|
214
199
|
if (!processor.filestore) return;
|
|
215
200
|
const binaryData = await readFileAsBinaryString(file);
|
|
216
|
-
processor.filestore.scsi =
|
|
201
|
+
processor.filestore.scsi = stringToUint8Array(binaryData);
|
|
217
202
|
|
|
218
203
|
processor.filestore.PC = 0x400;
|
|
219
204
|
processor.filestore.SP = 0xff;
|
|
@@ -229,128 +214,34 @@ export class MediaLoader extends EventTarget {
|
|
|
229
214
|
|
|
230
215
|
async loadDiscImage(discImage, layout = DiscLayout.auto) {
|
|
231
216
|
if (!discImage) return null;
|
|
232
|
-
const
|
|
233
|
-
discImage = split.image;
|
|
234
|
-
const schema = split.schema;
|
|
217
|
+
const { schema, image } = splitImage(discImage);
|
|
235
218
|
if (schema[0] === "!" || schema === "local") {
|
|
236
|
-
return
|
|
219
|
+
return localDisc(image, layout, (error) =>
|
|
237
220
|
toast(
|
|
238
|
-
`Browser storage would not take changes to ${
|
|
221
|
+
`Browser storage would not take changes to ${image} (${errorText(error)}). Use Discs, Download to keep a copy.`,
|
|
239
222
|
{ title: "Disc", quietKey: "quietLocalDiscSaveFailed" },
|
|
240
223
|
),
|
|
241
224
|
);
|
|
242
225
|
}
|
|
243
|
-
|
|
226
|
+
if (schema === "gd") {
|
|
227
|
+
const [, id, name = "(unknown)"] = image.match(/([^/]+)\/?(.*)/) ?? [null, image];
|
|
228
|
+
return this.driveSource({ name, id }, layout);
|
|
229
|
+
}
|
|
230
|
+
// TODO(#822) come up with a decent UX for passing an 'onChange' parameter to each of these.
|
|
244
231
|
// Consider:
|
|
245
232
|
// * hashing contents and making a local disc image named by original disc hash, save by that, and offer
|
|
246
233
|
// to load the modified disc on load.
|
|
247
234
|
// * popping up a message that notes the disc has changed, and offers a way to make a local image
|
|
248
235
|
// * Dialog box (ugh) saying "is this ok?"
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const { name, data, ignored } = await this.sources.sth(discImage);
|
|
253
|
-
reportIgnoredFiles(name, ignored);
|
|
254
|
-
return disc.discFor(this.processor.fdc, name, data, undefined, layout);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
case "hfe":
|
|
258
|
-
return disc.discFor(
|
|
259
|
-
this.processor.fdc,
|
|
260
|
-
discImage,
|
|
261
|
-
await this.sources.hfe(discImage),
|
|
262
|
-
undefined,
|
|
263
|
-
layout,
|
|
264
|
-
);
|
|
265
|
-
|
|
266
|
-
case "gd": {
|
|
267
|
-
const splat = discImage.match(/([^/]+)\/?(.*)/);
|
|
268
|
-
let name = "(unknown)";
|
|
269
|
-
if (splat) {
|
|
270
|
-
discImage = splat[1];
|
|
271
|
-
name = splat[2];
|
|
272
|
-
}
|
|
273
|
-
return this.sources.drive({ name, id: discImage }, layout);
|
|
274
|
-
}
|
|
275
|
-
case "b64data":
|
|
276
|
-
return disc.discFor(this.processor.fdc, "disk.ssd", atob(discImage), undefined, layout);
|
|
277
|
-
|
|
278
|
-
case "data": {
|
|
279
|
-
const arr = Array.prototype.map.call(atob(discImage), (x) => x.charCodeAt(0));
|
|
280
|
-
const { name, data } = await unzipAndReport(arr);
|
|
281
|
-
return disc.discFor(this.processor.fdc, name, data, undefined, layout);
|
|
282
|
-
}
|
|
283
|
-
case "http":
|
|
284
|
-
case "https":
|
|
285
|
-
case "file": {
|
|
286
|
-
const asUrl = `${schema}://${discImage}`;
|
|
287
|
-
// url may end in query params etc, which can upset the DSD/SSD etc detection on the extension.
|
|
288
|
-
discImage = new URL(asUrl).pathname;
|
|
289
|
-
let discData = await utils.loadData(asUrl);
|
|
290
|
-
if (/\.zip/i.test(discImage)) {
|
|
291
|
-
const unzipped = await unzipAndReport(discData);
|
|
292
|
-
discData = unzipped.data;
|
|
293
|
-
discImage = unzipped.name;
|
|
294
|
-
}
|
|
295
|
-
return disc.discFor(this.processor.fdc, discImage, discData, undefined, layout);
|
|
296
|
-
}
|
|
297
|
-
default:
|
|
298
|
-
return disc.discFor(
|
|
299
|
-
this.processor.fdc,
|
|
300
|
-
discImage,
|
|
301
|
-
await disc.load("discs/" + discImage),
|
|
302
|
-
undefined,
|
|
303
|
-
layout,
|
|
304
|
-
);
|
|
305
|
-
}
|
|
236
|
+
const { name, data, ignored } = await this.resolver.resolve("disc", discImage);
|
|
237
|
+
reportIgnoredFiles(name, ignored);
|
|
238
|
+
return disc.discFor(name, data, undefined, layout);
|
|
306
239
|
}
|
|
307
240
|
|
|
308
241
|
async loadTapeImage(tapeImage) {
|
|
309
242
|
if (!tapeImage) return null;
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
switch (schema) {
|
|
315
|
-
case "|":
|
|
316
|
-
case "sth": {
|
|
317
|
-
const { name, data, ignored } = await this.sources.tapeSth(tapeImage);
|
|
318
|
-
reportIgnoredFiles(name, ignored);
|
|
319
|
-
return await loadTapeFromData(name, data, this.model);
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
case "data": {
|
|
323
|
-
const arr = Array.prototype.map.call(atob(tapeImage), (x) => x.charCodeAt(0));
|
|
324
|
-
const { name, data } = await unzipAndReport(arr);
|
|
325
|
-
return await loadTapeFromData(name, data, this.model);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
case "http":
|
|
329
|
-
case "https":
|
|
330
|
-
case "file": {
|
|
331
|
-
const asUrl = `${schema}://${tapeImage}`;
|
|
332
|
-
// url may end in query params etc, which can upset file handling
|
|
333
|
-
tapeImage = new URL(asUrl).pathname;
|
|
334
|
-
let tapeData = await utils.loadData(asUrl);
|
|
335
|
-
if (/\.zip/i.test(tapeImage)) {
|
|
336
|
-
const unzipped = await unzipAndReport(tapeData);
|
|
337
|
-
tapeData = unzipped.data;
|
|
338
|
-
tapeImage = unzipped.name;
|
|
339
|
-
}
|
|
340
|
-
return await loadTapeFromData(tapeImage, tapeData, this.model);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
default: {
|
|
344
|
-
const tapePath = "tapes/" + tapeImage;
|
|
345
|
-
let tapeData = await utils.loadData(tapePath);
|
|
346
|
-
let tapeName = tapeImage;
|
|
347
|
-
if (/\.zip/i.test(tapeName)) {
|
|
348
|
-
const unzipped = await unzipAndReport(tapeData);
|
|
349
|
-
tapeData = unzipped.data;
|
|
350
|
-
tapeName = unzipped.name;
|
|
351
|
-
}
|
|
352
|
-
return await loadTapeFromData(tapeName, tapeData, this.model);
|
|
353
|
-
}
|
|
354
|
-
}
|
|
243
|
+
const { name, data, ignored } = await this.resolver.resolve("tape", tapeImage);
|
|
244
|
+
reportIgnoredFiles(name, ignored);
|
|
245
|
+
return loadTapeFromData(name, data, this.model);
|
|
355
246
|
}
|
|
356
247
|
}
|
package/src/web/modals.js
CHANGED
|
@@ -12,17 +12,16 @@ export class Modals {
|
|
|
12
12
|
this.errorModal = new bootstrap.Modal(this.errorDialog);
|
|
13
13
|
this.loadingDialog = document.getElementById("loading-dialog");
|
|
14
14
|
this.loadingModal = new bootstrap.Modal(this.loadingDialog);
|
|
15
|
-
this.googleDriveAuth = document.getElementById("google-drive-auth");
|
|
16
15
|
this.aysEl = document.getElementById("are-you-sure");
|
|
17
16
|
this.aysModal = new bootstrap.Modal(this.aysEl);
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
document.addEventListener("show.bs.modal", () => {
|
|
21
|
-
if (!
|
|
22
|
-
if (loop.isRunning()) loop.stop(false);
|
|
18
|
+
const holds = new WeakMap();
|
|
19
|
+
document.addEventListener("show.bs.modal", (event) => {
|
|
20
|
+
if (!holds.has(event.target)) holds.set(event.target, loop.pause(`the ${event.target.id} dialog`));
|
|
23
21
|
});
|
|
24
|
-
document.addEventListener("hidden.bs.modal", () => {
|
|
25
|
-
|
|
22
|
+
document.addEventListener("hidden.bs.modal", (event) => {
|
|
23
|
+
holds.get(event.target)?.();
|
|
24
|
+
holds.delete(event.target);
|
|
26
25
|
});
|
|
27
26
|
}
|
|
28
27
|
|
|
@@ -48,12 +47,10 @@ export class Modals {
|
|
|
48
47
|
|
|
49
48
|
popupLoading(msg) {
|
|
50
49
|
this.loadingDialog.querySelector(".loading").textContent = msg;
|
|
51
|
-
this.googleDriveAuth.style.display = "none";
|
|
52
50
|
this.loadingModal.show();
|
|
53
51
|
}
|
|
54
52
|
|
|
55
53
|
loadingFinished(message) {
|
|
56
|
-
this.googleDriveAuth.style.display = "none";
|
|
57
54
|
this.loadingModal.hide();
|
|
58
55
|
if (message) toast(message);
|
|
59
56
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { downloadDriveData } from "./dom-utils.js";
|
|
2
|
+
|
|
3
|
+
const UnloadWarning =
|
|
4
|
+
"It seems like you're still using the emulator. If you're in Chrome, it's impossible for jsbeeb to prevent some shortcuts (like ctrl-W) from performing their default behaviour (e.g. closing the window).\n" +
|
|
5
|
+
"As a workaround, create an 'Application Shortcut' from the Tools menu. When jsbeeb runs as an application, it *can* prevent ctrl-W from closing the window.";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The page around the emulator: the reset menu items and the filestore
|
|
9
|
+
* download, what focus and unload do to the machine, the drop guard, the
|
|
10
|
+
* dialogs a URL can ask for, and the version stamp.
|
|
11
|
+
*/
|
|
12
|
+
export class PageActions {
|
|
13
|
+
constructor({ loop, processor, keyboard, audioHandler, rewindUI, modals, parsedQuery, version }) {
|
|
14
|
+
this.processor = processor;
|
|
15
|
+
this.rewindUI = rewindUI;
|
|
16
|
+
this.pageListeners = new AbortController();
|
|
17
|
+
const { signal } = this.pageListeners;
|
|
18
|
+
|
|
19
|
+
for (const el of document.querySelectorAll(".initially-hidden")) el.classList.remove("initially-hidden");
|
|
20
|
+
document.getElementById("paste-form").addEventListener("submit", (event) => event.preventDefault());
|
|
21
|
+
|
|
22
|
+
window.addEventListener(
|
|
23
|
+
"blur",
|
|
24
|
+
() => {
|
|
25
|
+
keyboard.clearKeys();
|
|
26
|
+
loop.setEmulationLead(audioHandler.setWindowFocused(false));
|
|
27
|
+
},
|
|
28
|
+
{ signal },
|
|
29
|
+
);
|
|
30
|
+
window.addEventListener("focus", () => loop.setEmulationLead(audioHandler.setWindowFocused(true)), { signal });
|
|
31
|
+
|
|
32
|
+
// To lower the chance of data loss, only the drop zone in the menu bar accepts drops.
|
|
33
|
+
document.addEventListener(
|
|
34
|
+
"dragover",
|
|
35
|
+
(event) => {
|
|
36
|
+
event.preventDefault();
|
|
37
|
+
event.dataTransfer.dropEffect = "none";
|
|
38
|
+
},
|
|
39
|
+
{ signal },
|
|
40
|
+
);
|
|
41
|
+
document.addEventListener("drop", (event) => event.preventDefault(), { signal });
|
|
42
|
+
|
|
43
|
+
window.addEventListener(
|
|
44
|
+
"beforeunload",
|
|
45
|
+
(event) => {
|
|
46
|
+
if (!loop.isRunning() || !processor.sysvia.hasAnyKeyDown()) return;
|
|
47
|
+
event.preventDefault();
|
|
48
|
+
event.returnValue = UnloadWarning;
|
|
49
|
+
},
|
|
50
|
+
{ signal },
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
document.getElementById("hard-reset").addEventListener("click", (event) => {
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
this.hardReset();
|
|
56
|
+
});
|
|
57
|
+
document.getElementById("soft-reset").addEventListener("click", (event) => {
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
this.softReset();
|
|
60
|
+
});
|
|
61
|
+
document.getElementById("download-filestore-link").addEventListener("click", () => this.downloadFilestore());
|
|
62
|
+
|
|
63
|
+
if (Object.hasOwn(parsedQuery, "about")) modals.show("info");
|
|
64
|
+
if (Object.hasOwn(parsedQuery, "pp-tos")) modals.show("pp-tos");
|
|
65
|
+
|
|
66
|
+
const versionElement = document.getElementById("jsbeeb-version");
|
|
67
|
+
if (versionElement) versionElement.textContent = `Version ${version}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Stops listening to the window and document; the elements go with the page. */
|
|
71
|
+
dispose() {
|
|
72
|
+
this.pageListeners.abort();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
hardReset() {
|
|
76
|
+
this.rewindUI.reset();
|
|
77
|
+
this.processor.reset(true);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
softReset() {
|
|
81
|
+
this.processor.reset(false);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
downloadFilestore() {
|
|
85
|
+
if (!this.processor.filestore) return;
|
|
86
|
+
downloadDriveData(this.processor.filestore.scsi, "scsi", ".dat");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -1,44 +1,45 @@
|
|
|
1
1
|
import { AudioOutputs } from "../audio-output.js";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* The sound output, speaker amount and display mode controls on the top bar
|
|
5
|
-
*
|
|
6
|
-
* bar can be kept in step with the same settings elsewhere.
|
|
4
|
+
* The sound output, speaker amount and display mode controls on the top bar:
|
|
5
|
+
* a view of those three settings, and a way to set them.
|
|
7
6
|
*/
|
|
8
7
|
export class QuickSettings {
|
|
9
|
-
constructor(
|
|
8
|
+
constructor(settings) {
|
|
10
9
|
this.output = document.getElementById("audio-output");
|
|
11
10
|
this.amount = document.getElementById("speaker-amount");
|
|
12
11
|
this.display = document.getElementById("display-mode");
|
|
13
12
|
if (!this.output || !this.amount || !this.display) return;
|
|
14
13
|
|
|
15
|
-
this.showAudioOutput(audioOutput);
|
|
16
|
-
this.showSpeakerAmount(speakerAmount);
|
|
17
|
-
this.showDisplayMode(displayMode);
|
|
14
|
+
this.showAudioOutput(settings.audioOutput);
|
|
15
|
+
this.showSpeakerAmount(settings.speakerAmount);
|
|
16
|
+
this.showDisplayMode(settings.displayMode);
|
|
17
|
+
settings.on("audioOutput", (audioOutput) => this.showAudioOutput(audioOutput));
|
|
18
|
+
settings.on("speakerAmount", (speakerAmount) => this.showSpeakerAmount(speakerAmount));
|
|
19
|
+
settings.on("displayMode", (displayMode) => this.showDisplayMode(displayMode));
|
|
18
20
|
|
|
19
21
|
this.output.addEventListener("click", (e) => {
|
|
20
|
-
const
|
|
21
|
-
if (
|
|
22
|
+
const audioOutput = e.target.closest("[data-output]")?.dataset.output;
|
|
23
|
+
if (audioOutput) settings.set({ audioOutput });
|
|
22
24
|
});
|
|
23
|
-
this.amount.addEventListener("input", () =>
|
|
25
|
+
this.amount.addEventListener("input", () => settings.set({ speakerAmount: parseFloat(this.amount.value) }));
|
|
24
26
|
this.display.addEventListener("click", (e) => {
|
|
25
|
-
const
|
|
26
|
-
if (
|
|
27
|
+
const displayMode = e.target.closest("[data-mode]")?.dataset.mode;
|
|
28
|
+
if (displayMode) settings.set({ displayMode });
|
|
27
29
|
});
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
showAudioOutput(audioOutput) {
|
|
31
|
-
if (!this.output) return;
|
|
32
33
|
select(this.output.querySelectorAll("[data-output]"), (b) => b.dataset.output === audioOutput);
|
|
33
34
|
this.amount.disabled = audioOutput !== AudioOutputs.speaker;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
showSpeakerAmount(speakerAmount) {
|
|
37
|
-
|
|
38
|
+
this.amount.value = speakerAmount;
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
showDisplayMode(mode) {
|
|
41
|
-
|
|
42
|
+
select(this.display.querySelectorAll("[data-mode]"), (b) => b.dataset.mode === mode);
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
45
|
|
package/src/web/reporting.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import * as utils from "../utils.js";
|
|
2
1
|
import { toast } from "./toast.js";
|
|
3
2
|
|
|
4
3
|
export const errorText = (error) => error?.message ?? `${error}`;
|
|
@@ -15,12 +14,6 @@ export function reportIgnoredFiles(name, ignored) {
|
|
|
15
14
|
});
|
|
16
15
|
}
|
|
17
16
|
|
|
18
|
-
export async function unzipAndReport(data) {
|
|
19
|
-
const unzipped = await utils.unzipDiscImage(data);
|
|
20
|
-
reportIgnoredFiles(unzipped.name, unzipped.ignored);
|
|
21
|
-
return unzipped;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
17
|
/** Handles a component's "notice" event by toasting it. */
|
|
25
18
|
export function showNotice(event) {
|
|
26
19
|
const { message, title, quietKey } = event.detail;
|
package/src/web/rewind-ui.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
import { renderThumbnails, executeUntilFrame } from "./rewind-thumbnail.js";
|
|
2
|
+
import { RewindBuffer } from "../rewind.js";
|
|
3
|
+
import { RewindCaptureInterval } from "./emulation-loop.js";
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
const RewindBufferSnapshots = 30;
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Rewind scrubber UI — a filmstrip overlay showing thumbnails of recent
|
|
@@ -9,18 +11,16 @@ import { renderThumbnails, executeUntilFrame } from "../rewind-thumbnail.js";
|
|
|
9
11
|
export class RewindUI {
|
|
10
12
|
/**
|
|
11
13
|
* @param {object} options
|
|
12
|
-
* @param {object} options.rewindBuffer - RewindBuffer instance
|
|
13
14
|
* @param {object} options.processor - Cpu6502 instance
|
|
14
15
|
* @param {object} options.video - Video instance
|
|
15
|
-
* @param {
|
|
16
|
-
* @param {object} options.loop - the emulation loop, for pausing and resuming
|
|
16
|
+
* @param {object} options.loop - the emulation loop: says when to capture, and is held while the panel is open
|
|
17
17
|
*/
|
|
18
|
-
constructor({
|
|
19
|
-
this.rewindBuffer =
|
|
18
|
+
constructor({ processor, video, loop }) {
|
|
19
|
+
this.rewindBuffer = new RewindBuffer(RewindBufferSnapshots);
|
|
20
20
|
this.processor = processor;
|
|
21
21
|
this.video = video;
|
|
22
|
-
this.captureInterval = captureInterval;
|
|
23
22
|
this.loop = loop;
|
|
23
|
+
loop.addEventListener("rewind-capture", () => this.capture());
|
|
24
24
|
|
|
25
25
|
this.panel = document.getElementById("rewind-panel");
|
|
26
26
|
this.filmstrip = document.getElementById("rewind-filmstrip");
|
|
@@ -28,7 +28,7 @@ export class RewindUI {
|
|
|
28
28
|
this.openBtn = document.getElementById("rewind-open");
|
|
29
29
|
|
|
30
30
|
this.isOpen = false;
|
|
31
|
-
this.
|
|
31
|
+
this.resumeLoop = null;
|
|
32
32
|
this.selectedIndex = -1;
|
|
33
33
|
this.snapshots = [];
|
|
34
34
|
this.savedState = null;
|
|
@@ -41,6 +41,11 @@ export class RewindUI {
|
|
|
41
41
|
});
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
capture() {
|
|
45
|
+
this.rewindBuffer.push(this.processor.snapshotState());
|
|
46
|
+
this.updateButtonState();
|
|
47
|
+
}
|
|
48
|
+
|
|
44
49
|
/** Forget everything captured, as on a hard reset. */
|
|
45
50
|
reset() {
|
|
46
51
|
this.close();
|
|
@@ -55,37 +60,32 @@ export class RewindUI {
|
|
|
55
60
|
this.snapshots = this.rewindBuffer.getAll();
|
|
56
61
|
if (this.snapshots.length === 0) return;
|
|
57
62
|
|
|
58
|
-
this.
|
|
59
|
-
if (this.wasRunning) this.loop.stop(false);
|
|
60
|
-
|
|
63
|
+
this.resumeLoop = this.loop.pause("the rewind panel");
|
|
61
64
|
this.isOpen = true;
|
|
62
|
-
this.savedState = this.processor.snapshotState();
|
|
63
|
-
|
|
64
65
|
try {
|
|
66
|
+
this.savedState = this.processor.snapshotState();
|
|
65
67
|
const thumbnails = renderThumbnails(
|
|
66
68
|
this.processor,
|
|
67
69
|
this.snapshots,
|
|
68
70
|
this.video,
|
|
69
|
-
|
|
71
|
+
RewindCaptureInterval,
|
|
70
72
|
this.savedState,
|
|
71
73
|
);
|
|
72
74
|
this._populateFilmstrip(thumbnails);
|
|
75
|
+
this.panel.hidden = false;
|
|
76
|
+
// Use capture phase so keys don't leak to the emulator's keyboard handler
|
|
77
|
+
document.addEventListener("keydown", this._onKeyDown, true);
|
|
78
|
+
|
|
79
|
+
// Select the newest snapshot ("now") and jump to it
|
|
80
|
+
this.selectedIndex = this.snapshots.length - 1;
|
|
81
|
+
this._restoreAndPaint(this.selectedIndex);
|
|
82
|
+
this._updateSelectionHighlight();
|
|
83
|
+
this.filmstrip.scrollLeft = this.filmstrip.scrollWidth;
|
|
73
84
|
} catch (e) {
|
|
74
|
-
this.processor.restoreState(this.savedState);
|
|
85
|
+
if (this.savedState) this.processor.restoreState(this.savedState);
|
|
75
86
|
this._closePanel();
|
|
76
|
-
if (this.wasRunning) this.loop.go();
|
|
77
87
|
throw e;
|
|
78
88
|
}
|
|
79
|
-
|
|
80
|
-
this.panel.hidden = false;
|
|
81
|
-
// Use capture phase so keys don't leak to the emulator's keyboard handler
|
|
82
|
-
document.addEventListener("keydown", this._onKeyDown, true);
|
|
83
|
-
|
|
84
|
-
// Select the newest snapshot ("now") and jump to it
|
|
85
|
-
this.selectedIndex = this.snapshots.length - 1;
|
|
86
|
-
this._restoreAndPaint(this.selectedIndex);
|
|
87
|
-
this._updateSelectionHighlight();
|
|
88
|
-
this.filmstrip.scrollLeft = this.filmstrip.scrollWidth;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/**
|
|
@@ -100,7 +100,6 @@ export class RewindUI {
|
|
|
100
100
|
this.processor.restoreState(this.snapshots[this.selectedIndex]);
|
|
101
101
|
}
|
|
102
102
|
this._closePanel();
|
|
103
|
-
if (this.wasRunning) this.loop.go();
|
|
104
103
|
}
|
|
105
104
|
|
|
106
105
|
/**
|
|
@@ -110,7 +109,6 @@ export class RewindUI {
|
|
|
110
109
|
if (!this.isOpen) return;
|
|
111
110
|
this._renderState(this.savedState);
|
|
112
111
|
this._closePanel();
|
|
113
|
-
if (this.wasRunning) this.loop.go();
|
|
114
112
|
}
|
|
115
113
|
|
|
116
114
|
/** Alias for cancel — closing the panel without explicit commit cancels. */
|
|
@@ -146,6 +144,8 @@ export class RewindUI {
|
|
|
146
144
|
this.snapshots = [];
|
|
147
145
|
this.savedState = null;
|
|
148
146
|
document.removeEventListener("keydown", this._onKeyDown, true);
|
|
147
|
+
this.resumeLoop?.();
|
|
148
|
+
this.resumeLoop = null;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
/**
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The play and pause buttons on the top bar, kept in step with the loop's
|
|
3
|
+
* running state. pause() and resume() are also the desktop app's menu actions.
|
|
4
|
+
*/
|
|
5
|
+
export class RunControls {
|
|
6
|
+
/**
|
|
7
|
+
* @param {object} options
|
|
8
|
+
* @param {object} options.loop - the emulation loop
|
|
9
|
+
* @param {object} options.dbgr - the debugger, hidden on resume
|
|
10
|
+
* @param {object} options.keyboard - the machine keyboard, told the running state
|
|
11
|
+
*/
|
|
12
|
+
constructor({ loop, dbgr, keyboard }) {
|
|
13
|
+
this.loop = loop;
|
|
14
|
+
this.dbgr = dbgr;
|
|
15
|
+
this.keyboard = keyboard;
|
|
16
|
+
this.playButton = document.getElementById("debug-play");
|
|
17
|
+
this.pauseButton = document.getElementById("debug-pause");
|
|
18
|
+
|
|
19
|
+
loop.addEventListener("running", () => {
|
|
20
|
+
const running = loop.isRunning();
|
|
21
|
+
keyboard.setRunning(running);
|
|
22
|
+
this.playButton.disabled = running;
|
|
23
|
+
this.pauseButton.disabled = !running;
|
|
24
|
+
});
|
|
25
|
+
this.pauseButton.addEventListener("click", () => this.pause());
|
|
26
|
+
this.playButton.addEventListener("click", () => this.resume());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Stop the loop into the debugger. */
|
|
30
|
+
pause() {
|
|
31
|
+
this.loop.stop(true);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Hide the debugger; the keyboard's resume event restarts the loop. */
|
|
35
|
+
resume() {
|
|
36
|
+
this.dbgr.hide();
|
|
37
|
+
this.keyboard.resumeEmulation();
|
|
38
|
+
}
|
|
39
|
+
}
|