jsbeeb 1.16.0 → 1.17.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/README.md +16 -2
- package/package.json +9 -1
- package/src/6502.js +2 -1
- package/src/app/app.js +4 -0
- package/src/bbcdiscs.js +84 -0
- package/src/bem-snapshot.js +3 -4
- package/src/canvas.js +9 -2
- package/src/cmos.js +38 -0
- package/src/disc-drive.js +45 -12
- package/src/disc-hfe.js +19 -2
- package/src/disc.js +154 -22
- package/src/fdc.js +97 -12
- package/src/google-drive.js +6 -5
- package/src/intel-fdc.js +8 -1
- package/src/jsbeeb.css +65 -0
- package/src/keyboard.js +14 -15
- package/src/machine-session.js +2 -1
- package/src/main.js +381 -170
- package/src/models.js +2 -0
- package/src/printer.js +59 -0
- package/src/sth.js +5 -5
- package/src/teletext_adaptor.js +6 -4
- package/src/url-params.js +28 -0
- package/src/utils.js +28 -55
- package/src/via.js +7 -1
- package/src/wd-fdc.js +8 -1
- package/src/web/audio-handler.js +27 -8
- package/src/web/toast.js +83 -0
- package/tests/test-machine.js +1 -1
package/src/models.js
CHANGED
package/src/printer.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Centronics printer attached to the user VIA: acknowledges each character and keeps the most
|
|
5
|
+
* recent output so it can be shown whenever a printer window is opened.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const MaxBufferedChars = 64 * 1024;
|
|
9
|
+
|
|
10
|
+
export class Printer {
|
|
11
|
+
/**
|
|
12
|
+
* @param {object} [handlers]
|
|
13
|
+
* @param {function(string): void} [handlers.onOutput] called with each character as it is printed
|
|
14
|
+
* @param {function(): void} [handlers.onFirstOutput] called once, when the machine first prints
|
|
15
|
+
*/
|
|
16
|
+
constructor({ onOutput = () => {}, onFirstOutput = () => {} } = {}) {
|
|
17
|
+
this._onOutput = onOutput;
|
|
18
|
+
this._onFirstOutput = onFirstOutput;
|
|
19
|
+
this._uservia = null;
|
|
20
|
+
this._olderText = "";
|
|
21
|
+
this._newerText = "";
|
|
22
|
+
this._hasPrinted = false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
outputStrobe(level, output) {
|
|
26
|
+
if (!output || level) return;
|
|
27
|
+
|
|
28
|
+
const uservia = this._uservia;
|
|
29
|
+
// Ack the character by pulsing CA1 low.
|
|
30
|
+
uservia.setca1(false);
|
|
31
|
+
uservia.setca1(true);
|
|
32
|
+
this._append(String.fromCharCode(uservia.ora));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
attach(uservia) {
|
|
36
|
+
this._uservia = uservia;
|
|
37
|
+
// The ack line idles high, so that each ack is a falling edge.
|
|
38
|
+
uservia.setca1(true);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The most recent output, at most MaxBufferedChars of it. */
|
|
42
|
+
get text() {
|
|
43
|
+
return (this._olderText + this._newerText).slice(-MaxBufferedChars);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_append(char) {
|
|
47
|
+
// Two chunks so the bound costs a trim when the text is read, not one per character.
|
|
48
|
+
this._newerText += char;
|
|
49
|
+
if (this._newerText.length >= MaxBufferedChars) {
|
|
50
|
+
this._olderText = this._newerText;
|
|
51
|
+
this._newerText = "";
|
|
52
|
+
}
|
|
53
|
+
if (!this._hasPrinted) {
|
|
54
|
+
this._hasPrinted = true;
|
|
55
|
+
this._onFirstOutput();
|
|
56
|
+
}
|
|
57
|
+
this._onOutput(char);
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/sth.js
CHANGED
|
@@ -50,12 +50,12 @@ export class StairwayToHell {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
async fetch(file) {
|
|
53
|
-
const
|
|
54
|
-
console.log("Loading ZIP from " +
|
|
55
|
-
const response = await fetch(
|
|
56
|
-
if (!response.ok) throw new Error(
|
|
53
|
+
const url = this._baseUrl + encodePath(file);
|
|
54
|
+
console.log("Loading ZIP from " + url);
|
|
55
|
+
const response = await fetch(url);
|
|
56
|
+
if (!response.ok) throw new Error(`Unable to load ${url}, http code ${response.status}`);
|
|
57
57
|
try {
|
|
58
|
-
return
|
|
58
|
+
return await utils.unzipDiscImage(new Uint8Array(await response.arrayBuffer()));
|
|
59
59
|
} catch (error) {
|
|
60
60
|
console.error("Failed to fetch file:", error);
|
|
61
61
|
throw error;
|
package/src/teletext_adaptor.js
CHANGED
|
@@ -35,8 +35,8 @@ Status register:
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
* Emulates the Acorn teletext adaptor. Dispatches a `
|
|
39
|
-
* `
|
|
38
|
+
* Emulates the Acorn teletext adaptor. Dispatches a `notice` CustomEvent, carrying a
|
|
39
|
+
* `message` in its detail, when a channel's stream cannot be loaded.
|
|
40
40
|
*/
|
|
41
41
|
export class TeletextAdaptor extends EventTarget {
|
|
42
42
|
constructor(cpu) {
|
|
@@ -79,8 +79,10 @@ export class TeletextAdaptor extends EventTarget {
|
|
|
79
79
|
if (request !== this.streamRequest) return;
|
|
80
80
|
console.error(`Teletext adaptor: failed to load channel ${channel}`, error);
|
|
81
81
|
this.dispatchEvent(
|
|
82
|
-
new CustomEvent("
|
|
83
|
-
detail: {
|
|
82
|
+
new CustomEvent("notice", {
|
|
83
|
+
detail: {
|
|
84
|
+
message: `Teletext channel ${channel} could not be loaded (${error?.message ?? error}). The adaptor carries on with nothing to show.`,
|
|
85
|
+
},
|
|
84
86
|
}),
|
|
85
87
|
);
|
|
86
88
|
return;
|
package/src/url-params.js
CHANGED
|
@@ -236,6 +236,34 @@ export function processAutobootParams(parsedQuery) {
|
|
|
236
236
|
return { needsAutoboot, autoType };
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
+
/** Where a drive's 40/80 switch is set, `auto` leaving it to whatever disc is loaded. */
|
|
240
|
+
export const DriveTracks = Object.freeze({ auto: "auto", forty: "40", eighty: "80" });
|
|
241
|
+
|
|
242
|
+
const NumDrives = 2;
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Process the per-drive 40/80 track settings
|
|
246
|
+
* @param {Object} parsedQuery - The parsed query parameters
|
|
247
|
+
* @returns {{settings: string[], warnings: string[]}} One DriveTracks per drive, and what was
|
|
248
|
+
* unusable about anything asked for that is not in there
|
|
249
|
+
*/
|
|
250
|
+
export function processDriveTrackParams(parsedQuery) {
|
|
251
|
+
const warnings = [];
|
|
252
|
+
const settings = [];
|
|
253
|
+
for (let driveIndex = 0; driveIndex < NumDrives; ++driveIndex) {
|
|
254
|
+
const name = `drive${driveIndex}Tracks`;
|
|
255
|
+
const asked = parsedQuery[name];
|
|
256
|
+
const setting = isDefined(asked) ? `${asked}`.toLowerCase() : DriveTracks.auto;
|
|
257
|
+
if (Object.values(DriveTracks).includes(setting)) {
|
|
258
|
+
settings.push(setting);
|
|
259
|
+
} else {
|
|
260
|
+
warnings.push(`${name}=${asked}: a drive is set to 40, 80 or auto.`);
|
|
261
|
+
settings.push(DriveTracks.auto);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return { settings, warnings };
|
|
265
|
+
}
|
|
266
|
+
|
|
239
267
|
/**
|
|
240
268
|
* Guess the appropriate model based on the hostname
|
|
241
269
|
* @param {string} hostname - The hostname to check
|
package/src/utils.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
// Minimal ZIP extractor
|
|
3
|
-
//
|
|
4
|
-
|
|
2
|
+
// Minimal ZIP extractor. Supports methods 0 (stored) and 8 (deflate); other
|
|
3
|
+
// methods (bzip2, lzma, etc.) will throw an error with the method number.
|
|
4
|
+
|
|
5
|
+
import { inflate as pakoInflate, inflateRaw as pakoInflateRaw, ungzip as pakoUngzip } from "pako";
|
|
5
6
|
|
|
6
7
|
const ZipLocalHeaderSig = 0x04034b50;
|
|
7
8
|
const ZipCentralDirSig = 0x02014b50;
|
|
@@ -35,45 +36,17 @@ function findEocd(buf) {
|
|
|
35
36
|
throw new Error("Not a ZIP file: EOCD not found");
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const ds = new DecompressionStream(format);
|
|
45
|
-
const writer = ds.writable.getWriter();
|
|
46
|
-
const reader = ds.readable.getReader();
|
|
47
|
-
const chunks = [];
|
|
48
|
-
const readPromise = (async () => {
|
|
49
|
-
for (;;) {
|
|
50
|
-
const { done, value } = await reader.read();
|
|
51
|
-
if (done) break;
|
|
52
|
-
chunks.push(value);
|
|
53
|
-
}
|
|
54
|
-
})();
|
|
55
|
-
const writePromise = (async () => {
|
|
56
|
-
await writer.write(data);
|
|
57
|
-
await writer.close();
|
|
58
|
-
})().catch(() => {
|
|
59
|
-
// Intentionally empty.
|
|
60
|
-
});
|
|
61
|
-
// Intentionally empty: Node's DecompressionStream rejects multiple
|
|
62
|
-
// promises on error (write, close, closed). The read side surfaces
|
|
63
|
-
// the same error with proper context — catching these just prevents
|
|
64
|
-
// unhandled rejections from the write-side promises.
|
|
65
|
-
writer.closed.catch(() => {});
|
|
66
|
-
await readPromise;
|
|
67
|
-
await writePromise;
|
|
68
|
-
if (chunks.length === 1) return chunks[0];
|
|
69
|
-
const totalLen = chunks.reduce((s, c) => s + c.length, 0);
|
|
70
|
-
const out = new Uint8Array(totalLen);
|
|
71
|
-
let offset = 0;
|
|
72
|
-
for (const chunk of chunks) {
|
|
73
|
-
out.set(chunk, offset);
|
|
74
|
-
offset += chunk.length;
|
|
39
|
+
function inflateWith(inflater, data, context) {
|
|
40
|
+
if (!(data instanceof Uint8Array)) data = new Uint8Array(data);
|
|
41
|
+
try {
|
|
42
|
+
return inflater(data);
|
|
43
|
+
} catch (cause) {
|
|
44
|
+
throw new Error(`${context}: ${cause.message || cause}`, { cause });
|
|
75
45
|
}
|
|
76
|
-
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function inflate(data) {
|
|
49
|
+
return inflateWith(pakoInflate, data, "Unable to inflate");
|
|
77
50
|
}
|
|
78
51
|
|
|
79
52
|
// Extract all files from a ZIP archive. Returns {filename: Uint8Array}.
|
|
@@ -92,6 +65,7 @@ export async function unzip(buf) {
|
|
|
92
65
|
const flags = readU16(buf, pos + 8);
|
|
93
66
|
if (flags & 0x0001) throw new Error("Encrypted ZIP entries are not supported");
|
|
94
67
|
const method = readU16(buf, pos + 10);
|
|
68
|
+
const expectedCrc = readU32(buf, pos + 16);
|
|
95
69
|
const compressedSize = readU32(buf, pos + 20);
|
|
96
70
|
const nameLen = readU16(buf, pos + 28);
|
|
97
71
|
const extraLen = readU16(buf, pos + 30);
|
|
@@ -112,25 +86,25 @@ export async function unzip(buf) {
|
|
|
112
86
|
if (method === ZipMethodStored) {
|
|
113
87
|
files[name] = raw.slice();
|
|
114
88
|
} else if (method === ZipMethodDeflate) {
|
|
115
|
-
files[name] =
|
|
89
|
+
files[name] = inflateWith(pakoInflateRaw, raw, `Unable to inflate ZIP entry ${name}`);
|
|
116
90
|
} else {
|
|
117
91
|
throw new Error(`Unsupported ZIP compression method ${method} for ${name}`);
|
|
118
92
|
}
|
|
93
|
+
if (crc32(files[name]) >>> 0 !== expectedCrc) throw new Error(`Corrupt ZIP entry ${name}: CRC32 mismatch`);
|
|
119
94
|
}
|
|
120
95
|
return files;
|
|
121
96
|
}
|
|
122
97
|
|
|
123
98
|
// Standard CRC-32/ISO-HDLC.
|
|
99
|
+
const Crc32Table = new Uint32Array(256).map((_, index) => {
|
|
100
|
+
let crc = index;
|
|
101
|
+
for (let bit = 0; bit < 8; ++bit) crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
|
|
102
|
+
return crc;
|
|
103
|
+
});
|
|
104
|
+
|
|
124
105
|
export function crc32(data) {
|
|
125
106
|
let crc = 0xffffffff;
|
|
126
|
-
for (let i = 0; i < data.length; ++i)
|
|
127
|
-
crc ^= data[i];
|
|
128
|
-
for (let j = 0; j < 8; ++j) {
|
|
129
|
-
const doEor = crc & 1;
|
|
130
|
-
crc = crc >>> 1;
|
|
131
|
-
if (doEor) crc ^= 0xedb88320;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
107
|
+
for (let i = 0; i < data.length; ++i) crc = (crc >>> 8) ^ Crc32Table[(crc ^ data[i]) & 0xff];
|
|
134
108
|
return ~crc;
|
|
135
109
|
}
|
|
136
110
|
|
|
@@ -1112,11 +1086,7 @@ export function readFloat32(data, offset) {
|
|
|
1112
1086
|
}
|
|
1113
1087
|
|
|
1114
1088
|
export async function ungzip(data) {
|
|
1115
|
-
|
|
1116
|
-
return await decompress(data, "gzip");
|
|
1117
|
-
} catch (cause) {
|
|
1118
|
-
throw new Error("Unable to ungzip: " + (cause.message || cause), { cause });
|
|
1119
|
-
}
|
|
1089
|
+
return inflateWith(pakoUngzip, data, "Unable to ungzip");
|
|
1120
1090
|
}
|
|
1121
1091
|
|
|
1122
1092
|
export class DataStream {
|
|
@@ -1216,7 +1186,10 @@ const knownDiscExtensions = {
|
|
|
1216
1186
|
uef: true,
|
|
1217
1187
|
ssd: true,
|
|
1218
1188
|
dsd: true,
|
|
1189
|
+
adf: true,
|
|
1190
|
+
adm: true,
|
|
1219
1191
|
adl: true,
|
|
1192
|
+
hfe: true,
|
|
1220
1193
|
};
|
|
1221
1194
|
|
|
1222
1195
|
const knownRomExtensions = {
|
package/src/via.js
CHANGED
|
@@ -154,6 +154,11 @@ class Via {
|
|
|
154
154
|
}
|
|
155
155
|
this.updateIFR();
|
|
156
156
|
|
|
157
|
+
// The strobe is "data ready": the port settles before CA2 falls.
|
|
158
|
+
// http://archive.6502.org/datasheets/wdc_w65c22s_mar_2004.pdf figures 3-6 and 3-7.
|
|
159
|
+
this.ora = val;
|
|
160
|
+
this.recalculatePortAPins();
|
|
161
|
+
|
|
157
162
|
mode = this.pcr & 0x0e;
|
|
158
163
|
if (mode === 8) {
|
|
159
164
|
// Handshake mode
|
|
@@ -163,7 +168,8 @@ class Via {
|
|
|
163
168
|
this.setca2(false);
|
|
164
169
|
this.setca2(true);
|
|
165
170
|
}
|
|
166
|
-
|
|
171
|
+
break;
|
|
172
|
+
|
|
167
173
|
case ORAnh:
|
|
168
174
|
this.ora = val;
|
|
169
175
|
this.recalculatePortAPins();
|
package/src/wd-fdc.js
CHANGED
|
@@ -1414,8 +1414,15 @@ export class WdFdc {
|
|
|
1414
1414
|
* @param {Number} drive
|
|
1415
1415
|
* @param {Disc} disc
|
|
1416
1416
|
*/
|
|
1417
|
-
|
|
1417
|
+
/**
|
|
1418
|
+
* @param {Number} drive
|
|
1419
|
+
* @param {Disc} disc
|
|
1420
|
+
* @param {Number} [tracksPerStep] where to leave the drive's 40/80 switch, which by default
|
|
1421
|
+
* follows the disc, since no drive can tell what pitch the disc in it was written at
|
|
1422
|
+
*/
|
|
1423
|
+
loadDisc(drive, disc, tracksPerStep = disc?.is40Track ? 2 : 1) {
|
|
1418
1424
|
this._drives[drive].setDisc(disc);
|
|
1425
|
+
this._drives[drive].tracksPerStep = tracksPerStep;
|
|
1419
1426
|
}
|
|
1420
1427
|
|
|
1421
1428
|
get motorOn() {
|
package/src/web/audio-handler.js
CHANGED
|
@@ -4,6 +4,7 @@ import { RelayNoise, FakeRelayNoise } from "../relaynoise.js";
|
|
|
4
4
|
import { Music5000, FakeMusic5000 } from "../music5000.js";
|
|
5
5
|
import { createAudioContext } from "../audio-utils.js";
|
|
6
6
|
import { toggle, fadeIn, fadeOut } from "../dom-utils.js";
|
|
7
|
+
import { toast } from "./toast.js";
|
|
7
8
|
|
|
8
9
|
// Using this approach means when jsbeeb is embedded in other projects, vite doesn't have a fit.
|
|
9
10
|
// See https://github.com/vitejs/vite/discussions/6459
|
|
@@ -15,6 +16,7 @@ export class AudioHandler {
|
|
|
15
16
|
this.cpuSpeed = cpuSpeed;
|
|
16
17
|
this.isAtom = isAtom;
|
|
17
18
|
this.warningNode = warningNode;
|
|
19
|
+
this.noAudio = false;
|
|
18
20
|
toggle(this.warningNode, false);
|
|
19
21
|
this.stats = {};
|
|
20
22
|
if (statsNode) {
|
|
@@ -37,18 +39,19 @@ export class AudioHandler {
|
|
|
37
39
|
this.masterGain.connect(this.audioContext.destination);
|
|
38
40
|
this.ddNoise = noSeek ? new FakeDdNoise() : new DdNoise(this.audioContext, this.masterGain);
|
|
39
41
|
this.relayNoise = new RelayNoise(this.audioContext, this.masterGain);
|
|
40
|
-
this._setup(audioFilterFreq, audioFilterQ).
|
|
42
|
+
this._setup(audioFilterFreq, audioFilterQ).catch((error) => this._audioUnavailable(error));
|
|
41
43
|
} else {
|
|
42
44
|
if (this.audioContext && !this.audioContext.audioWorklet) {
|
|
43
45
|
this.audioContext = null;
|
|
46
|
+
this.noAudio = true;
|
|
44
47
|
console.log("Unable to initialise audio: no audio worklet API");
|
|
45
|
-
toggle(this.warningNode, true);
|
|
46
48
|
const localhost = new URL(window.location);
|
|
47
49
|
localhost.hostname = "localhost";
|
|
48
50
|
this.warningNode.innerHTML = `No audio worklet API was found - there will be no audio.
|
|
49
51
|
If you are running a local jsbeeb, you must either use a host of
|
|
50
52
|
<a href="${localhost}">localhost</a>,
|
|
51
53
|
or serve the content over <em>https</em>.`;
|
|
54
|
+
toggle(this.warningNode, true);
|
|
52
55
|
}
|
|
53
56
|
this.soundChip = new FakeSoundChip();
|
|
54
57
|
this.ddNoise = new FakeDdNoise();
|
|
@@ -56,7 +59,6 @@ export class AudioHandler {
|
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
this.warningNode.addEventListener("mousedown", () => this.tryResume());
|
|
59
|
-
toggle(this.warningNode, false);
|
|
60
62
|
|
|
61
63
|
// Initialise Music 5000 audio context
|
|
62
64
|
this.audioContextM5000 = createAudioContext({ sampleRate: 46875 });
|
|
@@ -65,12 +67,21 @@ export class AudioHandler {
|
|
|
65
67
|
this.audioContextM5000.onstatechange = () => this.checkStatus();
|
|
66
68
|
this.music5000 = new Music5000((buffer) => this._onBufferMusic5000(buffer));
|
|
67
69
|
|
|
68
|
-
this.audioContextM5000.audioWorklet
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
this.audioContextM5000.audioWorklet
|
|
71
|
+
.addModule(music5000WorkletUrl)
|
|
72
|
+
.then(() => {
|
|
73
|
+
this._music5000workletnode = new AudioWorkletNode(this.audioContextM5000, "music5000", {
|
|
74
|
+
outputChannelCount: [2],
|
|
75
|
+
});
|
|
76
|
+
this._music5000workletnode.connect(this.audioContextM5000.destination);
|
|
77
|
+
})
|
|
78
|
+
.catch((error) => {
|
|
79
|
+
console.error("Unable to initialise Music 5000 audio", error);
|
|
80
|
+
toast(
|
|
81
|
+
`The Music 5000 will be silent: its audio could not be started (${error?.message ?? error}). Reloading the page may help.`,
|
|
82
|
+
{ title: "Music 5000", quietKey: "quietMusic5000Audio" },
|
|
83
|
+
);
|
|
71
84
|
});
|
|
72
|
-
this._music5000workletnode.connect(this.audioContextM5000.destination);
|
|
73
|
-
});
|
|
74
85
|
} else {
|
|
75
86
|
this.music5000 = new FakeMusic5000();
|
|
76
87
|
}
|
|
@@ -115,6 +126,13 @@ export class AudioHandler {
|
|
|
115
126
|
};
|
|
116
127
|
}
|
|
117
128
|
|
|
129
|
+
_audioUnavailable(error) {
|
|
130
|
+
console.error("Unable to initialise audio", error);
|
|
131
|
+
this.noAudio = true;
|
|
132
|
+
this.warningNode.textContent = `There will be no sound: the audio system could not be started (${error?.message ?? error}). Reloading the page may help.`;
|
|
133
|
+
fadeIn(this.warningNode);
|
|
134
|
+
}
|
|
135
|
+
|
|
118
136
|
_addStat(stat, info) {
|
|
119
137
|
const timeSeries = new this._TimeSeries();
|
|
120
138
|
this.stats[stat] = timeSeries;
|
|
@@ -145,6 +163,7 @@ export class AudioHandler {
|
|
|
145
163
|
}
|
|
146
164
|
|
|
147
165
|
checkStatus() {
|
|
166
|
+
if (this.noAudio) return;
|
|
148
167
|
if (!this.audioContext && !this.audioContextM5000) return;
|
|
149
168
|
const suspended =
|
|
150
169
|
(this.audioContext && this.audioContext.state === "suspended") ||
|
package/src/web/toast.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import * as bootstrap from "bootstrap";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Passing notices: something happened that is worth knowing and needs nothing doing about it.
|
|
5
|
+
* The error dialog is for the other kind.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
let nextQuietId = 0;
|
|
9
|
+
|
|
10
|
+
function toastContainer() {
|
|
11
|
+
const existing = document.querySelector(".toast-container");
|
|
12
|
+
if (existing) return existing;
|
|
13
|
+
const container = document.createElement("div");
|
|
14
|
+
container.className = "toast-container position-fixed bottom-0 end-0 p-3";
|
|
15
|
+
document.body.appendChild(container);
|
|
16
|
+
return container;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Storage can be unreachable or full, and a notice that nothing needs doing about is not worth
|
|
20
|
+
// failing over: forget the answer and say the thing again.
|
|
21
|
+
function remembered(key) {
|
|
22
|
+
try {
|
|
23
|
+
return !!window.localStorage.getItem(key);
|
|
24
|
+
} catch (e) {
|
|
25
|
+
console.log(`Unable to read ${key}: ${e}`);
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function remember(key, wanted) {
|
|
31
|
+
try {
|
|
32
|
+
if (wanted) window.localStorage.setItem(key, "yes");
|
|
33
|
+
else window.localStorage.removeItem(key);
|
|
34
|
+
} catch (e) {
|
|
35
|
+
console.log(`Unable to remember ${key}: ${e}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} message
|
|
41
|
+
* @param {object} [options]
|
|
42
|
+
* @param {string} [options.title] a heading, for a notice whose message does not say where it came from
|
|
43
|
+
* @param {string} [options.quietKey] offer to stop showing this kind of notice, remembering the answer here
|
|
44
|
+
*/
|
|
45
|
+
export function toast(message, { title = "", quietKey = "" } = {}) {
|
|
46
|
+
if (quietKey && remembered(quietKey)) return;
|
|
47
|
+
|
|
48
|
+
const element = document.createElement("div");
|
|
49
|
+
element.className = "toast text-bg-dark";
|
|
50
|
+
element.setAttribute("role", "status");
|
|
51
|
+
element.setAttribute("aria-live", "polite");
|
|
52
|
+
element.setAttribute("aria-atomic", "true");
|
|
53
|
+
const quietId = `toast-quiet-${nextQuietId++}`;
|
|
54
|
+
element.innerHTML = `
|
|
55
|
+
<div class="toast-header text-bg-dark">
|
|
56
|
+
<strong class="me-auto"></strong>
|
|
57
|
+
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast" aria-label="Close"></button>
|
|
58
|
+
</div>
|
|
59
|
+
<div class="toast-body">
|
|
60
|
+
<div class="message"></div>
|
|
61
|
+
<div class="form-check mt-2">
|
|
62
|
+
<input class="form-check-input" type="checkbox" id="${quietId}" />
|
|
63
|
+
<label class="form-check-label small" for="${quietId}">Stop telling me this</label>
|
|
64
|
+
</div>
|
|
65
|
+
</div>`;
|
|
66
|
+
// Disc names and the like come from the outside world, so they are set as text, never as markup.
|
|
67
|
+
element.querySelector(".message").textContent = message;
|
|
68
|
+
element.querySelector(".toast-header strong").textContent = title;
|
|
69
|
+
element.querySelector(".toast-header").hidden = !title;
|
|
70
|
+
|
|
71
|
+
const quiet = element.querySelector(".form-check");
|
|
72
|
+
quiet.hidden = !quietKey;
|
|
73
|
+
quiet.querySelector("input").addEventListener("change", (event) => remember(quietKey, event.target.checked));
|
|
74
|
+
|
|
75
|
+
toastContainer().appendChild(element);
|
|
76
|
+
// Bootstrap keys its instances by element, so one only removed stays held.
|
|
77
|
+
element.addEventListener("hidden.bs.toast", () => {
|
|
78
|
+
bootstrap.Toast.getInstance(element)?.dispose();
|
|
79
|
+
element.remove();
|
|
80
|
+
});
|
|
81
|
+
bootstrap.Toast.getOrCreateInstance(element).show();
|
|
82
|
+
return element;
|
|
83
|
+
}
|
package/tests/test-machine.js
CHANGED
|
@@ -213,7 +213,7 @@ export class TestMachine {
|
|
|
213
213
|
|
|
214
214
|
async loadDisc(image) {
|
|
215
215
|
const data = await fdc.load(image);
|
|
216
|
-
this.processor.fdc.loadDisc(0, fdc.discFor(this.processor.fdc,
|
|
216
|
+
this.processor.fdc.loadDisc(0, fdc.discFor(this.processor.fdc, image, data));
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
/**
|