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.
Files changed (110) hide show
  1. package/README.md +2 -2
  2. package/package.json +14 -10
  3. package/src/6502.js +39 -42
  4. package/src/6502.opcodes.js +7 -13
  5. package/src/6847.js +4 -5
  6. package/src/6847_fontdata.js +0 -2
  7. package/src/acia.js +2 -3
  8. package/src/app/app.js +0 -1
  9. package/src/app/args.js +0 -2
  10. package/src/app/electron.js +4 -6
  11. package/src/app-bench.js +1 -3
  12. package/src/archive.js +243 -0
  13. package/src/basic-loader.js +0 -7
  14. package/src/basic-tokenise.js +19 -6
  15. package/src/bbc-palette.js +0 -2
  16. package/src/bbcdiscs.js +0 -2
  17. package/src/bem-snapshot.js +1 -3
  18. package/src/binary.js +175 -0
  19. package/src/cmos.js +0 -2
  20. package/src/ddnoise.js +0 -1
  21. package/src/debounce.js +7 -0
  22. package/src/disc-surface.js +0 -2
  23. package/src/disc.js +10 -9
  24. package/src/fake6502.js +7 -23
  25. package/src/fdc.js +9 -51
  26. package/src/filestore.js +10 -17
  27. package/src/hex.js +38 -0
  28. package/src/index.js +11 -0
  29. package/src/intel-fdc.js +18 -20
  30. package/src/{utils_atom.js → keymap-atom.js} +2 -18
  31. package/src/{utils.js → keymap.js} +47 -610
  32. package/src/loader.js +63 -0
  33. package/src/machine-session.js +37 -177
  34. package/src/machine-spec.js +59 -0
  35. package/src/main.js +66 -180
  36. package/src/media-resolver.js +68 -0
  37. package/src/mmc.js +3 -2
  38. package/src/models.js +17 -3
  39. package/src/music5000.js +0 -2
  40. package/src/ppia.js +3 -4
  41. package/src/printer.js +7 -14
  42. package/src/relaynoise.js +0 -1
  43. package/src/rewind.js +0 -2
  44. package/src/sample-player.js +2 -3
  45. package/src/serial.js +0 -2
  46. package/src/snapshot-helpers.js +0 -2
  47. package/src/snapshot.js +0 -2
  48. package/src/soundchip.js +1 -1
  49. package/src/state-utils.js +0 -2
  50. package/src/sth.js +2 -4
  51. package/src/tapes.js +10 -4
  52. package/src/teletext.js +2 -3
  53. package/src/teletext_adaptor.js +2 -4
  54. package/src/teletext_data.js +0 -2
  55. package/src/test-machine.js +307 -0
  56. package/src/touchscreen.js +2 -4
  57. package/src/tube.js +5 -6
  58. package/src/typist.js +103 -0
  59. package/src/uef-snapshot.js +0 -2
  60. package/src/url-params.js +4 -5
  61. package/src/vdu-capture.js +113 -0
  62. package/src/via.js +3 -4
  63. package/src/video-filters/pal-composite.js +0 -2
  64. package/src/video-filters/passthrough-filter.js +0 -2
  65. package/src/video-filters/pixel-grid.js +0 -2
  66. package/src/video-filters/shader-program.js +0 -2
  67. package/src/video-filters/xbr-filter.js +0 -2
  68. package/src/video.js +12 -11
  69. package/src/wd-fdc.js +20 -20
  70. package/src/web/analogue-inputs.js +6 -8
  71. package/src/web/analytics.js +13 -0
  72. package/src/web/audio-handler.js +2 -2
  73. package/src/{audio-utils.js → web/audio-utils.js} +0 -2
  74. package/src/web/autoboot.js +10 -13
  75. package/src/{canvas.js → web/canvas.js} +4 -5
  76. package/src/web/config.js +41 -74
  77. package/src/web/console-surface.js +25 -0
  78. package/src/web/debug.js +6 -5
  79. package/src/web/disc-visualiser.js +0 -2
  80. package/src/web/display.js +21 -4
  81. package/src/{dom-utils.js → web/dom-utils.js} +1 -11
  82. package/src/web/drives.js +1 -1
  83. package/src/web/emulation-loop.js +54 -33
  84. package/src/web/front-panel.js +13 -12
  85. package/src/{gamepads.js → web/gamepads.js} +2 -6
  86. package/src/web/google-drive-picker.js +14 -9
  87. package/src/{google-drive.js → web/google-drive.js} +9 -10
  88. package/src/web/hfe-picker.js +5 -4
  89. package/src/web/keyboard-setup.js +18 -38
  90. package/src/{keyboard.js → web/keyboard.js} +18 -119
  91. package/src/web/layout.js +4 -0
  92. package/src/web/local-disc.js +44 -0
  93. package/src/web/machine.js +27 -20
  94. package/src/web/media-loader.js +38 -147
  95. package/src/{microphone-input.js → web/microphone-input.js} +1 -1
  96. package/src/web/modals.js +6 -9
  97. package/src/web/page-actions.js +88 -0
  98. package/src/web/quick-settings.js +16 -15
  99. package/src/web/reporting.js +0 -7
  100. package/src/{rewind-thumbnail.js → web/rewind-thumbnail.js} +0 -2
  101. package/src/web/rewind-ui.js +29 -29
  102. package/src/web/run-controls.js +39 -0
  103. package/src/web/settings.js +64 -135
  104. package/src/web/snapshot-ui.js +10 -16
  105. package/src/{speech-output.js → web/speech-output.js} +0 -2
  106. package/src/web/sth-picker.js +9 -7
  107. package/src/web/url-state.js +20 -6
  108. package/src/econet/L3FS.dat +0 -0
  109. package/src/econet/scsi.dat +0 -0
  110. package/tests/test-machine.js +0 -609
@@ -1,159 +1,88 @@
1
- import * as utils from "../utils.js";
2
- import { Config } from "./config.js";
3
1
  import { DefaultModel, findModel } from "../models.js";
4
2
  import { DefaultAudioOutput, isAudioOutput } from "../audio-output.js";
5
- import { SpeechOutput } from "../speech-output.js";
6
3
  import { guessModelFromHostname } from "../url-params.js";
4
+ import { fittedRoms } from "./config.js";
7
5
  import { toast } from "./toast.js";
8
6
 
9
- // A slider fires for every pixel of a drag, and each URL update is a history entry.
10
- const UrlSettleMs = 300;
7
+ // Kept in browser storage for next time, as well as in the URL.
8
+ const StoredSettings = ["keyLayout", "displayMode", "audioOutput", "speakerAmount"];
9
+
10
+ /** The URL spellings of a model plus a fitting, from before fittings had settings of their own. */
11
+ export function mapLegacyModels(parsedQuery) {
12
+ if (!parsedQuery.model) return;
13
+ switch (parsedQuery.model.toLowerCase()) {
14
+ case "masterturbo":
15
+ parsedQuery.model = "Master";
16
+ parsedQuery.coProcessor = true;
17
+ break;
18
+ case "bmusic5000":
19
+ parsedQuery.model = "B-DFS1.2";
20
+ parsedQuery.hasMusic5000 = true;
21
+ break;
22
+ case "bteletext":
23
+ parsedQuery.model = "B-DFS1.2";
24
+ parsedQuery.hasTeletextAdaptor = true;
25
+ break;
26
+ }
27
+ }
11
28
 
12
29
  /**
13
- * The user's settings: resolved from the URL, browser storage and the
14
- * defaults, kept in step across the Settings dialog, the top bar, storage and
15
- * the URL, and applied to the machine. Built before anything it applies to;
16
- * wire() hands it the targets once they exist, which is before any setting
17
- * can change hands.
30
+ * The user's settings, resolved from the URL, browser storage and the
31
+ * defaults. set() adopts a change, persists it and dispatches an event named
32
+ * for each changed setting carrying its new value, then one "change" event
33
+ * carrying them all; whatever a setting reaches subscribes with on().
18
34
  */
19
- export class Settings {
20
- constructor({ urlState, makeConfig = (...handlers) => new Config(...handlers) }) {
35
+ export class Settings extends EventTarget {
36
+ constructor({ urlState }) {
37
+ super();
21
38
  this.urlState = urlState;
22
- this.targets = null;
23
- const parsedQuery = urlState.params;
24
-
25
- // Speech output: initialised from URL param; can be toggled at runtime via the Settings panel.
26
- // Must be created before Config so the onClose callback and the initial checkbox state can reference it.
27
- this.speechOutput = new SpeechOutput();
28
- this.setSpeechOutput(!!parsedQuery.speechOutput);
29
-
30
- this.keyLayout = window.localStorage.keyLayout || "physical";
31
- if (parsedQuery.keyLayout) {
32
- this.keyLayout = (parsedQuery.keyLayout + "").toLowerCase();
33
- }
34
-
35
- this.config = makeConfig(
36
- (changed) => {
37
- if (changed.audioOutput) this.applyAudioOutput(changed.audioOutput);
38
- if (changed.speakerAmount !== undefined) this.applySpeakerAmount(changed.speakerAmount);
39
- if (changed.displayMode) this.applyDisplayMode(changed.displayMode);
40
- },
41
- (changed) => this.onDialogClosed(changed),
42
- async () => {
43
- const restart = await this.targets.modals.confirm(
44
- "Your change is saved, but only takes effect when the emulator restarts. Restart now?",
45
- "Restart now",
46
- "Later",
47
- );
48
- if (restart) window.location.reload();
49
- },
50
- );
39
+ const params = urlState.params;
40
+ mapLegacyModels(params);
51
41
 
52
- // Perform mapping of legacy models to the new format
53
- this.config.mapLegacyModels(parsedQuery);
54
-
55
- const requestedModelName = parsedQuery.model || guessModelFromHostname(window.location.hostname);
56
- const requestedModel = findModel(requestedModelName);
57
- if (!requestedModel)
42
+ const requestedModelName = params.model || guessModelFromHostname(window.location.hostname);
43
+ this.model = findModel(requestedModelName);
44
+ if (!this.model) {
58
45
  toast(`There is no model called "${requestedModelName}". Using ${DefaultModel.name} instead.`, {
59
46
  title: "Model",
60
47
  });
61
- this.config.setModel((requestedModel ?? DefaultModel).name);
62
- this.config.setKeyLayout(this.keyLayout);
63
- this.config.setTubeCpuMultiplier(parsedQuery.tubeCpuMultiplier || 1);
64
- this.config.setMicrophoneChannel(parsedQuery.microphoneChannel);
65
- this.config.setCheckboxes({
66
- coProcessor: !!parsedQuery.coProcessor,
67
- hasEconet: !!parsedQuery.hasEconet,
68
- hasMusic5000: !!parsedQuery.hasMusic5000,
69
- hasTeletextAdaptor: !!parsedQuery.hasTeletextAdaptor,
70
- mouseJoystickEnabled: !!parsedQuery.mouseJoystickEnabled,
71
- speechOutput: this.speechOutput.enabled,
72
- });
73
-
74
- this.displayMode = parsedQuery.displayMode || window.localStorage.displayMode || "rgb";
75
- this.config.setDisplayMode(this.displayMode);
48
+ this.model = DefaultModel;
49
+ }
50
+ this.keyLayout =
51
+ (params.keyLayout && `${params.keyLayout}`.toLowerCase()) || window.localStorage.keyLayout || "physical";
52
+ this.tubeCpuMultiplier = params.tubeCpuMultiplier || 1;
53
+ this.microphoneChannel = params.microphoneChannel;
54
+ this.coProcessor = !!params.coProcessor;
55
+ this.hasEconet = !!params.hasEconet;
56
+ this.hasMusic5000 = !!params.hasMusic5000;
57
+ this.hasTeletextAdaptor = !!params.hasTeletextAdaptor;
58
+ this.mouseJoystickEnabled = !!params.mouseJoystickEnabled;
59
+ this.speechOutput = !!params.speechOutput;
60
+ this.displayMode = params.displayMode || window.localStorage.displayMode || "rgb";
76
61
  this.audioOutput =
77
- [parsedQuery.audioOutput, window.localStorage.audioOutput].find(isAudioOutput) ?? DefaultAudioOutput;
62
+ [params.audioOutput, window.localStorage.audioOutput].find(isAudioOutput) ?? DefaultAudioOutput;
78
63
  this.speakerAmount =
79
- [parsedQuery.speakerAmount, parseFloat(window.localStorage.speakerAmount)].find(Number.isFinite) ?? 1;
80
- this.config.setAudioOutput(this.audioOutput);
81
- this.config.setSpeakerAmount(this.speakerAmount);
82
-
83
- this.updateUrlOnceSettled = utils.debounce(() => urlState.updateUrl(), UrlSettleMs);
84
- }
85
-
86
- get model() {
87
- return this.config.model;
88
- }
89
-
90
- /** What applying a setting reaches: everything here exists before a setting can change. */
91
- wire(targets) {
92
- this.targets = targets;
93
- }
94
-
95
- setSpeechOutput(enabled) {
96
- this.speechOutput.enabled = enabled;
97
- if (enabled && typeof speechSynthesis === "undefined")
98
- toast("This browser has no speech synthesis, so speech output has nothing to speak with.", {
99
- title: "Speech",
100
- });
64
+ [params.speakerAmount, parseFloat(window.localStorage.speakerAmount)].find(Number.isFinite) ?? 1;
101
65
  }
102
66
 
103
- applyAudioOutput(output) {
104
- this.audioOutput = output;
105
- this.targets.audioHandler.setAudioOutput(output);
106
- this.config.setAudioOutput(output);
107
- this.targets.quickSettings?.showAudioOutput(output);
108
- window.localStorage.audioOutput = output;
109
- this.urlState.set({ audioOutput: output });
67
+ get extraRoms() {
68
+ return fittedRoms(this);
110
69
  }
111
70
 
112
- applySpeakerAmount(amount) {
113
- this.speakerAmount = amount;
114
- this.targets.audioHandler.setSpeakerAmount(amount);
115
- this.config.setSpeakerAmount(amount);
116
- this.targets.quickSettings?.showSpeakerAmount(amount);
117
- window.localStorage.speakerAmount = amount;
118
- this.urlState.params.speakerAmount = amount;
119
- this.updateUrlOnceSettled();
71
+ /** Calls `listener` with the new value whenever the setting `name` is set. */
72
+ on(name, listener) {
73
+ this.addEventListener(name, () => listener(this[name]));
120
74
  }
121
75
 
122
- applyDisplayMode(mode) {
123
- this.displayMode = mode;
124
- this.targets.display.setMode(mode);
125
- // The monitor picture may have changed shape.
126
- this.targets.layout.resize();
127
- this.config.setDisplayMode(mode);
128
- this.targets.quickSettings?.showDisplayMode(mode);
129
- window.localStorage.displayMode = mode;
130
- this.urlState.set({ displayMode: mode });
131
- }
132
-
133
- onDialogClosed(changed) {
134
- const { urlState } = this;
135
- const { machine, keys, inputs } = this.targets;
136
- const parsedQuery = urlState.params;
137
- urlState.set(changed);
138
- if (changed.keyLayout) {
139
- window.localStorage.keyLayout = changed.keyLayout;
140
- machine.emulationConfig.keyLayout = changed.keyLayout;
141
- keys.setKeyLayout(changed.keyLayout);
142
- }
143
- if (changed.mouseJoystickEnabled !== undefined || Object.hasOwn(changed, "microphoneChannel")) {
144
- inputs.updateAdcSources(parsedQuery.mouseJoystickEnabled, parsedQuery.microphoneChannel);
145
-
146
- if (changed.microphoneChannel !== undefined) {
147
- inputs.setupMicrophone();
148
- }
149
- }
150
- if (changed.speechOutput !== undefined) this.setSpeechOutput(!!changed.speechOutput);
151
- if (changed.tubeCpuMultiplier !== undefined) {
152
- machine.emulationConfig.tubeCpuMultiplier = changed.tubeCpuMultiplier;
153
- this.config.setTubeCpuMultiplier(changed.tubeCpuMultiplier);
154
- if (machine.processor.hasTube) {
155
- machine.processor.tube.cpuMultiplier = changed.tubeCpuMultiplier;
156
- }
76
+ /** Adopts `changes` (an undefined value clears a setting), persists them and tells the subscribers. */
77
+ set(changes) {
78
+ for (const [name, value] of Object.entries(changes)) {
79
+ this[name] = name === "model" ? (findModel(value) ?? this.model) : value;
80
+ if (!StoredSettings.includes(name)) continue;
81
+ if (value === undefined) window.localStorage.removeItem(name);
82
+ else window.localStorage[name] = value;
157
83
  }
84
+ this.urlState.set(changes, { settle: "speakerAmount" in changes });
85
+ for (const name of Object.keys(changes)) this.dispatchEvent(new Event(name));
86
+ this.dispatchEvent(new CustomEvent("change", { detail: changes }));
158
87
  }
159
88
  }
@@ -1,6 +1,6 @@
1
1
  import * as disc from "../fdc.js";
2
2
  import { DiscLayout } from "../disc.js";
3
- import { downloadBlob } from "../dom-utils.js";
3
+ import { downloadBlob } from "./dom-utils.js";
4
4
  import {
5
5
  createSnapshot,
6
6
  restoreSnapshot,
@@ -113,9 +113,7 @@ export class SnapshotUI {
113
113
  }
114
114
 
115
115
  async saveState() {
116
- const wasRunning = this.loop.isRunning();
117
- if (wasRunning) this.loop.stop(false);
118
- let failure = null;
116
+ const resume = this.loop.pause("saving state");
119
117
  try {
120
118
  const manifest = snapshotMedia(this.processor.fdc.drives, this.urlState.params, this.defaultBootDisc);
121
119
  const snapshot = createSnapshot(this.processor, this.model, manifest);
@@ -124,18 +122,14 @@ export class SnapshotUI {
124
122
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
125
123
  downloadBlob(blob, `jsbeeb-${this.model.name}-${timestamp}.json.gz`);
126
124
  } catch (e) {
127
- failure = e;
125
+ this.modals.showError("saving state", e);
126
+ } finally {
127
+ resume();
128
128
  }
129
- // Resume before reporting failure: the error dialog pauses a running
130
- // loop itself, and resumes it on close.
131
- if (wasRunning) this.loop.go();
132
- if (failure) this.modals.showError("saving state", failure);
133
129
  }
134
130
 
135
131
  async loadStateFromFile(file, preReadBuffer) {
136
- const wasRunning = this.loop.isRunning();
137
- if (wasRunning) this.loop.stop(false);
138
- let failure = null;
132
+ const resume = this.loop.pause("loading state");
139
133
  try {
140
134
  const arrayBuffer = preReadBuffer || (await file.arrayBuffer());
141
135
  const snapshot = await readSnapshot(arrayBuffer);
@@ -152,10 +146,10 @@ export class SnapshotUI {
152
146
  // Force a repaint so the display updates even while paused
153
147
  this.video.paint();
154
148
  } catch (e) {
155
- failure = e;
149
+ this.modals.showError("loading state", e);
150
+ } finally {
151
+ resume();
156
152
  }
157
- if (wasRunning) this.loop.go();
158
- if (failure) this.modals.showError("loading state", failure);
159
153
  }
160
154
 
161
155
  /** Picks up the state a cross-model reload stashed, once the matching machine is up. */
@@ -199,7 +193,7 @@ export class SnapshotUI {
199
193
  ? savedMedia[imageDataKey]
200
194
  : new Uint8Array(Object.values(savedMedia[imageDataKey]));
201
195
  const discName = savedMedia[discKey + "Name"] || "snapshot.ssd";
202
- loadedDisc = disc.discFor(this.processor.fdc, discName, imageData, undefined, layout);
196
+ loadedDisc = disc.discFor(discName, imageData, undefined, layout);
203
197
  // Retain the image bytes so subsequent saves can re-embed them.
204
198
  loadedDisc.setOriginalImage(imageData);
205
199
  }
@@ -1,5 +1,3 @@
1
- "use strict";
2
-
3
1
  /**
4
2
  * RS-423 handler that routes transmitted bytes to the Web Speech API.
5
3
  *
@@ -1,8 +1,8 @@
1
- import * as utils from "../utils.js";
2
1
  import * as bootstrap from "bootstrap";
3
2
  import { StairwayToHell } from "../sth.js";
4
3
  import { errorText } from "./reporting.js";
5
4
  import { clearArchiveList, filterArchiveList, showArchiveMessage } from "./archive-list.js";
5
+ import { noteEvent } from "./analytics.js";
6
6
 
7
7
  /**
8
8
  * The Stairway to Hell archive picker: one modal browsing either the disc or
@@ -69,8 +69,9 @@ export class SthPicker {
69
69
  }
70
70
 
71
71
  async pickDisc(item) {
72
- utils.noteEvent("sth", "click", item);
73
- this.media.setDisc1Image("sth:" + item);
72
+ noteEvent("sth", "click", item);
73
+ const image = "sth:" + item;
74
+ this.media.setDisc1Image(image);
74
75
  const needsAutoboot = this.urlState.params.autoboot !== undefined;
75
76
  if (needsAutoboot) {
76
77
  this.processor.reset(true);
@@ -78,7 +79,7 @@ export class SthPicker {
78
79
 
79
80
  this.modals.popupLoading("Loading " + item);
80
81
  try {
81
- const loaded = await this.media.loadDiscImage(this.urlState.params.disc1, this.drives.layoutForDrive(0));
82
+ const loaded = await this.media.loadDiscImage(image, this.drives.layoutForDrive(0));
82
83
  this.drives.putDiscIn(0, loaded);
83
84
  this.modals.loadingFinished();
84
85
 
@@ -92,12 +93,13 @@ export class SthPicker {
92
93
  }
93
94
 
94
95
  async pickTape(item) {
95
- utils.noteEvent("sth", "clickTape", item);
96
- this.media.setTapeImage("sth:" + item);
96
+ noteEvent("sth", "clickTape", item);
97
+ const image = "sth:" + item;
98
+ this.media.setTapeImage(image);
97
99
 
98
100
  this.modals.popupLoading("Loading " + item);
99
101
  try {
100
- const tape = await this.media.loadTapeImage(this.urlState.params.tape);
102
+ const tape = await this.media.loadTapeImage(image);
101
103
  this.media.setProcessorTape(tape);
102
104
  this.modals.loadingFinished();
103
105
  } catch (err) {
@@ -1,4 +1,8 @@
1
1
  import { buildUrlFromParams, ParamTypes, parseQueryString } from "../url-params.js";
2
+ import { debounce } from "../debounce.js";
3
+
4
+ // A slider fires for every pixel of a drag, and each URL update is a history entry.
5
+ const UrlSettleMs = 300;
2
6
 
3
7
  /** How each parameter the page understands is parsed and written back. */
4
8
  export const UrlParamTypes = {
@@ -9,8 +13,6 @@ export const UrlParamTypes = {
9
13
  embed: ParamTypes.BOOL,
10
14
  fasttape: ParamTypes.BOOL,
11
15
  noseek: ParamTypes.BOOL,
12
- debug: ParamTypes.BOOL,
13
- verbose: ParamTypes.BOOL,
14
16
  autoboot: ParamTypes.BOOL,
15
17
  autochain: ParamTypes.BOOL,
16
18
  autorun: ParamTypes.BOOL,
@@ -28,9 +30,9 @@ export const UrlParamTypes = {
28
30
  audioDebug: ParamTypes.BOOL,
29
31
 
30
32
  // Numeric parameters
31
- speed: ParamTypes.INT,
32
33
  stationId: ParamTypes.INT,
33
34
  frameSkip: ParamTypes.INT,
35
+ videoCyclesBatch: ParamTypes.INT,
34
36
  audiofilterfreq: ParamTypes.FLOAT,
35
37
  audiofilterq: ParamTypes.FLOAT,
36
38
  speakerAmount: ParamTypes.FLOAT,
@@ -52,6 +54,12 @@ export const UrlParamTypes = {
52
54
  audioOutput: ParamTypes.STRING,
53
55
  drive0Tracks: ParamTypes.STRING,
54
56
  drive1Tracks: ParamTypes.STRING,
57
+ loadBasic: ParamTypes.STRING,
58
+ embedBasic: ParamTypes.STRING,
59
+ patch: ParamTypes.STRING,
60
+ sbLeft: ParamTypes.STRING,
61
+ sbRight: ParamTypes.STRING,
62
+ sbBottom: ParamTypes.STRING,
55
63
  };
56
64
 
57
65
  /**
@@ -67,6 +75,7 @@ export class UrlState {
67
75
  // Parameters may be given after the hash as well as in the query.
68
76
  const queryString = location.search.substring(1) + "&" + location.hash.substring(1);
69
77
  this.params = parseQueryString(queryString, paramTypes);
78
+ this.updateUrlOnceSettled = debounce(() => this.updateUrl(), UrlSettleMs);
70
79
  }
71
80
 
72
81
  /** The page's URL with the parameters as they are now. */
@@ -79,13 +88,18 @@ export class UrlState {
79
88
  return buildUrlFromParams(this.baseUrl, { ...this.params, ...overrides }, this.paramTypes);
80
89
  }
81
90
 
82
- /** Apply `changes` to the parameters (undefined deletes a key) and push the new URL. */
83
- set(changes) {
91
+ /**
92
+ * Apply `changes` to the parameters (undefined deletes a key) and push the
93
+ * new URL; `settle: true` lets a burst of changes finish before pushing one
94
+ * history entry for the lot.
95
+ */
96
+ set(changes, { settle = false } = {}) {
84
97
  for (const [key, value] of Object.entries(changes)) {
85
98
  if (value === undefined) delete this.params[key];
86
99
  else this.params[key] = value;
87
100
  }
88
- this.updateUrl();
101
+ if (settle) this.updateUrlOnceSettled();
102
+ else this.updateUrl();
89
103
  }
90
104
 
91
105
  updateUrl() {
Binary file
Binary file