@nysds/playground 0.1.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 (68) hide show
  1. package/LICENSE +25 -0
  2. package/README.md +364 -0
  3. package/bin/cli.mjs +223 -0
  4. package/bin/cli.test.mjs +36 -0
  5. package/decks/customizing-components.json +97 -0
  6. package/dist/assets/index-LO1tomgR.css +6 -0
  7. package/dist/assets/index-bBtGCGL_.js +5190 -0
  8. package/dist/assets/internal/typescript.js +193739 -0
  9. package/dist/assets/nys-icon.library-Bi_7DKlD-YSs5zqZy-DXISxj9N.js +609 -0
  10. package/dist/assets/nys-icon.library-CwuPZJAc-TryaOS7Z.js +600 -0
  11. package/dist/assets/playground-typescript-worker-BcTrPYfY.js +87 -0
  12. package/dist/assets/playground-typescript-worker.js +87 -0
  13. package/dist/favicon.svg +10 -0
  14. package/dist/index.html +769 -0
  15. package/dist/nysds-logo.svg +21 -0
  16. package/dist/nysds-symbol.svg +7 -0
  17. package/index.html +768 -0
  18. package/package.json +59 -0
  19. package/presets/00-welcome.json +8 -0
  20. package/presets/01-button.json +7 -0
  21. package/presets/02-alert.json +7 -0
  22. package/presets/03-badge-and-avatar.json +7 -0
  23. package/presets/04-text-input.json +7 -0
  24. package/presets/05-select-radio-checkbox.json +7 -0
  25. package/presets/06-form-validation.json +7 -0
  26. package/presets/07-card.json +7 -0
  27. package/presets/08-accordion.json +7 -0
  28. package/presets/09-tabs.json +7 -0
  29. package/presets/10-modal.json +7 -0
  30. package/presets/11-stepper.json +7 -0
  31. package/presets/12-table-and-pagination.json +7 -0
  32. package/presets/13-tooltip-and-dropdown.json +7 -0
  33. package/presets/14-navigation.json +7 -0
  34. package/presets/15-page-structure.json +7 -0
  35. package/presets/16-themes.json +7 -0
  36. package/presets/17-utility-classes.json +7 -0
  37. package/presets/README.md +118 -0
  38. package/public/favicon.svg +10 -0
  39. package/public/nysds-logo.svg +21 -0
  40. package/public/nysds-symbol.svg +7 -0
  41. package/src/app.css +1087 -0
  42. package/src/debounce.ts +86 -0
  43. package/src/deck-model.ts +299 -0
  44. package/src/deck-store.ts +144 -0
  45. package/src/decks.test.ts +288 -0
  46. package/src/editor-panes.ts +190 -0
  47. package/src/editors.ts +92 -0
  48. package/src/home.ts +225 -0
  49. package/src/icon-names.ts +117 -0
  50. package/src/icons.test.ts +58 -0
  51. package/src/keys.ts +162 -0
  52. package/src/main.ts +1456 -0
  53. package/src/playground.config.ts +74 -0
  54. package/src/playground.ts +261 -0
  55. package/src/present.ts +398 -0
  56. package/src/preset-schema.ts +228 -0
  57. package/src/route.test.ts +56 -0
  58. package/src/routing.ts +60 -0
  59. package/src/settings.ts +211 -0
  60. package/src/starters.ts +86 -0
  61. package/src/state.test.ts +544 -0
  62. package/src/state.ts +237 -0
  63. package/src/theme.ts +82 -0
  64. package/src/version-catalog.ts +42 -0
  65. package/src/versions.ts +88 -0
  66. package/src/wrapper.ts +84 -0
  67. package/tsconfig.json +25 -0
  68. package/vite.config.ts +62 -0
package/src/main.ts ADDED
@@ -0,0 +1,1456 @@
1
+ /**
2
+ * Boots the playground shell: the toolbar, the split pane, and the editors.
3
+ */
4
+ import '@nysds/components';
5
+ import '@nysds/styles/full';
6
+ import './app.css';
7
+
8
+ import 'playground-elements/playground-code-editor.js';
9
+ import type {PlaygroundProject} from 'playground-elements/playground-project.js';
10
+
11
+ import type {Slide, StoredDeck} from './deck-model';
12
+ import {
13
+ BLANK_SLIDE_HTML,
14
+ getSlide,
15
+ makeSlide,
16
+ nextSlideId,
17
+ slideIndex,
18
+ toDeckFile,
19
+ } from './deck-model';
20
+ import * as store from './deck-store';
21
+ import {HomeView} from './home';
22
+ import type {Route} from './routing';
23
+ import {needsReboot, routeFor, slideIdFromHash} from './routing';
24
+ import {PLAYGROUND_CONFIG, componentsUrl, stylesUrl} from './playground.config';
25
+ import {PlaygroundHost} from './playground';
26
+ import {Presentation} from './present';
27
+ import type {PlaygroundState} from './state';
28
+ import {
29
+ debounce,
30
+ deckUrl,
31
+ encodeState,
32
+ isPresentMode,
33
+ readLocation,
34
+ writeCodeHash,
35
+ writePresetHash,
36
+ } from './state';
37
+ import {EditorPanes} from './editor-panes';
38
+ import type {EditorLayout} from './editors';
39
+ import {collapsedForPreset, layoutUrl} from './editors';
40
+ import {isBuildShortcut} from './keys';
41
+ import type {FontSize, UpdateMode} from './settings';
42
+ import {
43
+ DEFAULTS,
44
+ STORAGE_KEYS,
45
+ applyFontSize,
46
+ clearAllSettings,
47
+ initialFontSize,
48
+ initialUpdateMode,
49
+ readPrereleases,
50
+ readRatio,
51
+ settingUrl,
52
+ writeKey,
53
+ writePrereleases,
54
+ writeRatio,
55
+ } from './settings';
56
+ import type {EditorTheme} from './theme';
57
+ import {applyEditorTheme, initialTheme, writeTheme} from './theme';
58
+ import {isPrerelease, loadVersions, resolveVersion} from './versions';
59
+
60
+ /** How long to wait after a keystroke before writing the URL. */
61
+ const HASH_DEBOUNCE_MS = 300;
62
+
63
+ /** How long a toast stays on screen, in milliseconds. */
64
+ const TOAST_DURATION_MS = 2600;
65
+
66
+ /** The name every new deck starts with, until someone types over it. */
67
+ export const UNTITLED_DECK = 'Untitled';
68
+
69
+ /** How long to wait after a keystroke before writing to the store. */
70
+ const SAVE_DEBOUNCE_MS = 500;
71
+
72
+ /** The deck the scratch pad pretends to be, so the slide bar still works. */
73
+ function scratchDeck(state?: PlaygroundState): StoredDeck {
74
+ const stamp = new Date(0).toISOString();
75
+ return {
76
+ id: '',
77
+ title: 'Scratch pad',
78
+ description: '',
79
+ baseCss: '',
80
+ slides: [
81
+ makeSlide({
82
+ id: 'scratch',
83
+ title: 'Scratch pad',
84
+ html: state?.html ?? BLANK_SLIDE_HTML,
85
+ css: state?.css ?? '',
86
+ js: state?.js ?? '',
87
+ version: state?.version ?? 'latest',
88
+ }),
89
+ ],
90
+ createdAt: stamp,
91
+ updatedAt: stamp,
92
+ };
93
+ }
94
+
95
+ /** The three editable files of one slide. */
96
+ interface SlideEdits {
97
+ html: string;
98
+ css: string;
99
+ js: string;
100
+ }
101
+
102
+ /** Ties the toolbar, the editors, and the URL together. */
103
+ class PlaygroundApp {
104
+ private readonly host: PlaygroundHost;
105
+ private readonly presetSelect: HTMLElement;
106
+ private readonly versionSelect: HTMLElement;
107
+ private readonly prereleaseToggle: HTMLElement & {checked?: boolean};
108
+ private readonly columnsToggle: HTMLElement & {checked?: boolean};
109
+ private readonly themeToggle: HTMLElement & {checked?: boolean};
110
+ private readonly fontSizeSelect: HTMLElement;
111
+ private readonly updateModeSelect: HTMLElement;
112
+ private readonly settingsModal: HTMLElement & {open?: boolean};
113
+ private readonly buildButton: HTMLElement;
114
+ private readonly panes: EditorPanes;
115
+ private readonly toast: HTMLElement;
116
+ private readonly savedIndicator: HTMLElement;
117
+ private deck: StoredDeck;
118
+ /** True when the deck came from the store rather than the scratch pad. */
119
+ private readonly hasDeck: boolean;
120
+ private present: boolean;
121
+ private savedTimer: number | undefined;
122
+
123
+ /** The route the page booted with, used to spot a history move. */
124
+ private readonly bootRoute: Route = routeFor(window.location.search, window.location.hash);
125
+
126
+ /** The scratch pad's starting code, so unsaved edits can be spotted. */
127
+ private readonly openedWith: {html: string; css: string; js: string};
128
+
129
+ /**
130
+ * This session's edits, keyed by slide id.
131
+ *
132
+ * Stepping back to a slide restores what the presenter typed. Nothing is
133
+ * written to storage, so a reload starts from the deck again.
134
+ */
135
+ private readonly edits = new Map<string, SlideEdits>();
136
+
137
+ private version: string;
138
+ private activePresetId: string | null;
139
+ private modified: boolean;
140
+ private showPrereleases = readPrereleases();
141
+ private fontSize: FontSize = initialFontSize();
142
+ private updateMode: UpdateMode = initialUpdateMode();
143
+ private theme: EditorTheme = initialTheme();
144
+ private toastTimer: number | undefined;
145
+ private leaveListeners: AbortController | undefined;
146
+ private presentation: Presentation | undefined;
147
+
148
+ private readonly syncHash = debounce(() => this.writeHash(), HASH_DEBOUNCE_MS);
149
+
150
+ private readonly saveSoon = debounce(() => void this.persist(), SAVE_DEBOUNCE_MS);
151
+
152
+ constructor(
153
+ project: PlaygroundProject,
154
+ deck: StoredDeck,
155
+ initial: PlaygroundState,
156
+ presetId: string | null,
157
+ present: boolean,
158
+ panes: EditorPanes,
159
+ ) {
160
+ this.panes = panes;
161
+ this.deck = deck;
162
+ this.hasDeck = deck.id !== '';
163
+ this.present = present;
164
+ this.version = initial.version;
165
+ this.activePresetId = presetId;
166
+ this.modified = presetId === null;
167
+ this.presetSelect = required('#slide-picker');
168
+ this.versionSelect = required('#version-select');
169
+ this.prereleaseToggle = required('#prerelease-toggle');
170
+ this.columnsToggle = required('#columns-toggle');
171
+ this.themeToggle = required('#theme-toggle');
172
+ this.fontSizeSelect = required('#font-size-select');
173
+ this.updateModeSelect = required('#update-mode-select');
174
+ this.settingsModal = required('#settings-modal');
175
+ this.buildButton = required('#build-button');
176
+ this.savedIndicator = required('#saved-indicator');
177
+ this.toast = required('#toast');
178
+ this.openedWith = {html: initial.html, css: initial.css, js: initial.js};
179
+ this.host = new PlaygroundHost(project, initial, deck.baseCss);
180
+ this.host.setUpdateMode(this.updateMode);
181
+ this.host.onEdit(() => this.handleEdit());
182
+ this.host.onPendingChange((pending) => this.renderBuildButton(pending));
183
+ }
184
+
185
+ /** Renders the toolbar, binds every control, and sets the page title. */
186
+ async start(): Promise<void> {
187
+ document.querySelector('#app')?.classList.toggle('app--deck', this.hasDeck);
188
+ this.renderDeckChrome();
189
+ this.renderPresetOptions();
190
+ this.syncSettingsControls();
191
+ this.bindToolbar();
192
+ this.bindSettings();
193
+ this.renderBuildButton(false);
194
+ this.updateTitle();
195
+ this.applyPresetPanes(this.activePresetId);
196
+ if (this.activePresetId) {
197
+ // Name the slide in the URL so a refresh stays put.
198
+ writePresetHash(this.activePresetId);
199
+ }
200
+ // A deck that was just created arrives with its name waiting to be typed.
201
+ if (this.hasDeck && new URLSearchParams(window.location.search).has('new')) {
202
+ const url = new URL(window.location.href);
203
+ url.searchParams.delete('new');
204
+ window.history.replaceState(null, '', url.toString());
205
+ this.openTitleEditor();
206
+ }
207
+ await this.renderVersionOptions();
208
+ }
209
+
210
+ /** The deck being presented. */
211
+ getDeck(): StoredDeck {
212
+ return this.deck;
213
+ }
214
+
215
+ /** Returns the id of the slide on screen, or `null` for a shared link. */
216
+ getActivePresetId(): string | null {
217
+ return this.activePresetId;
218
+ }
219
+
220
+ /** Loads a slide, restoring any edits made to it earlier in the session. */
221
+ loadPreset(preset: Slide): void {
222
+ this.activePresetId = preset.id;
223
+ const remembered = this.edits.get(preset.id);
224
+ this.modified = remembered !== undefined;
225
+ const version = preset.version === 'latest' ? this.version : preset.version;
226
+ this.version = version;
227
+ this.host.load(
228
+ {
229
+ version,
230
+ html: remembered?.html ?? preset.html,
231
+ css: remembered?.css ?? preset.css,
232
+ js: remembered?.js ?? preset.js,
233
+ },
234
+ this.deck.baseCss,
235
+ );
236
+ this.setSelectValue(this.versionSelect, version);
237
+ this.renderPresetOptions();
238
+ this.applyPresetPanes(preset.id);
239
+ this.syncHash.cancel();
240
+ writePresetHash(preset.id);
241
+ this.updateTitle();
242
+ this.presentation?.refresh();
243
+ }
244
+
245
+ /** Expands the columns a slide asks for and collapses the rest. */
246
+ private applyPresetPanes(presetId: string | null): void {
247
+ const preset = getSlide(this.deck, presetId);
248
+ if (preset) {
249
+ this.panes.applyCollapsed(collapsedForPreset(preset.editors));
250
+ }
251
+ }
252
+
253
+ /** Keeps the settings controls in step with the state they describe. */
254
+ syncColumnsToggle(): void {
255
+ this.columnsToggle.checked = this.panes.layout === 'columns';
256
+ }
257
+
258
+ private syncSettingsControls(): void {
259
+ this.syncColumnsToggle();
260
+ this.themeToggle.checked = this.theme === 'dark';
261
+ this.prereleaseToggle.checked = this.showPrereleases;
262
+ this.setSelectValue(this.fontSizeSelect, this.fontSize);
263
+ this.setSelectValue(this.updateModeSelect, this.updateMode);
264
+ }
265
+
266
+ private bindSettings(): void {
267
+ const openSettings = (): void => {
268
+ this.syncSettingsControls();
269
+ this.settingsModal.open = true;
270
+ };
271
+ bindClick('#settings-button', openSettings);
272
+ // The toolbar is hidden while presenting, so the slide bar carries its own
273
+ // way into the settings and back to editing.
274
+ bindClick('#present-settings-button', openSettings);
275
+ bindClick('#exit-present-button', () => this.presentation?.exit());
276
+ bindClick('#settings-done-button', () => {
277
+ this.settingsModal.open = false;
278
+ });
279
+ bindClick('#build-button', () => this.host.buildNow());
280
+
281
+ this.themeToggle.addEventListener('nys-change', () => {
282
+ this.setTheme(this.themeToggle.checked === true ? 'dark' : 'light');
283
+ });
284
+
285
+ this.fontSizeSelect.addEventListener('nys-change', (event) => {
286
+ const value = detailValue(event);
287
+ if (value === 'small' || value === 'medium' || value === 'large') {
288
+ this.setFontSize(value);
289
+ }
290
+ });
291
+
292
+ this.updateModeSelect.addEventListener('nys-change', (event) => {
293
+ const value = detailValue(event);
294
+ if (value === 'typing' || value === 'pause' || value === 'manual') {
295
+ this.setUpdateMode(value);
296
+ }
297
+ });
298
+
299
+ bindClick('#reset-settings-button', () => this.resetSettings());
300
+
301
+ bindClick('#deck-settings-button', () => this.openDeckSettings());
302
+ required('#deck-title').addEventListener('click', () => this.openTitleEditor());
303
+ bindClick('#deck-modal-done', () => {
304
+ required<HTMLElement & {open?: boolean}>('#deck-modal').open = false;
305
+ void this.applyDeckSettings();
306
+ });
307
+
308
+ bindClick('#add-slide-button', () => void this.addSlide());
309
+ bindClick('#slide-settings-button', () => this.openSlideSettings());
310
+ bindClick('#move-slide-back-button', () => void this.moveSlide(-1));
311
+ bindClick('#move-slide-forward-button', () => void this.moveSlide(1));
312
+ bindClick('#duplicate-slide-button', () => void this.duplicateSlide());
313
+ bindClick('#delete-slide-button', () => this.deleteSlide());
314
+ bindClick('#slide-modal-done', () => {
315
+ required<HTMLElement & {open?: boolean}>('#slide-modal').open = false;
316
+ void this.applySlideSettings();
317
+ });
318
+
319
+ bindClick('#share-deck-link', () => {
320
+ required<HTMLElement & {open?: boolean}>('#share-modal').open = false;
321
+ void this.copyDeckLink();
322
+ });
323
+ bindClick('#share-code-link', () => {
324
+ required<HTMLElement & {open?: boolean}>('#share-modal').open = false;
325
+ void this.copyStandaloneLink();
326
+ });
327
+ bindClick('#share-modal-done', () => {
328
+ required<HTMLElement & {open?: boolean}>('#share-modal').open = false;
329
+ });
330
+
331
+ bindClick('#save-as-deck-button', () => void this.saveAsDeck());
332
+ const goHome = (): void => {
333
+ void this.flushSave().then(() => this.leaveFor('./'));
334
+ };
335
+ // The mark is a real link, so it needs its own handler to run the scratch
336
+ // pad's leave check before the browser follows it.
337
+ required('#toolbar-home').addEventListener('click', (event) => {
338
+ event.preventDefault();
339
+ goHome();
340
+ });
341
+
342
+ // A reload or a closed tab must not lose the last keystrokes. A deck
343
+ // autosaves, so only the scratch pad has anything to warn about.
344
+ window.addEventListener('beforeunload', (event) => {
345
+ void this.flushSave();
346
+ if (this.hasUnsavedScratch()) {
347
+ event.preventDefault();
348
+ event.returnValue = '';
349
+ }
350
+ });
351
+
352
+ // The router runs once at start-up, so going back or forward between
353
+ // views has to be turned into a reload.
354
+ window.addEventListener('popstate', () => this.handleHistoryMove());
355
+ window.addEventListener('hashchange', () => this.handleHistoryMove());
356
+ }
357
+
358
+ private setTheme(theme: EditorTheme): void {
359
+ this.theme = theme;
360
+ applyEditorTheme(theme);
361
+ writeTheme(theme);
362
+ replaceQuery('theme', theme);
363
+ }
364
+
365
+ private setFontSize(size: FontSize): void {
366
+ this.fontSize = size;
367
+ applyFontSize(size);
368
+ writeKey(STORAGE_KEYS.fontSize, size);
369
+ replaceQuery('font', size);
370
+ }
371
+
372
+ private setUpdateMode(mode: UpdateMode): void {
373
+ this.updateMode = mode;
374
+ this.host.setUpdateMode(mode);
375
+ writeKey(STORAGE_KEYS.updateMode, mode);
376
+ replaceQuery('update', mode);
377
+ this.renderBuildButton(this.host.hasPendingChanges);
378
+ }
379
+
380
+ /** Clears every remembered setting and reapplies the defaults in place. */
381
+ private resetSettings(): void {
382
+ clearAllSettings();
383
+ this.setTheme(DEFAULTS.theme);
384
+ this.setFontSize(DEFAULTS.fontSize);
385
+ this.setUpdateMode(DEFAULTS.updateMode);
386
+ this.showPrereleases = DEFAULTS.prereleases;
387
+ void this.renderVersionOptions();
388
+ this.panes.reset();
389
+ this.syncSettingsControls();
390
+ this.showToast('success', 'Settings reset', 'The playground is back to its defaults.');
391
+ }
392
+
393
+ /** Shows the manual update button, and marks it when a build is waiting. */
394
+ private renderBuildButton(pending: boolean): void {
395
+ this.buildButton.hidden = this.updateMode !== 'manual';
396
+ this.buildButton.classList.toggle('preview__build--pending', pending);
397
+ this.buildButton.setAttribute(
398
+ 'label',
399
+ pending ? 'Update preview (changes pending)' : 'Update preview',
400
+ );
401
+ }
402
+
403
+ /** Rebuilds the preview now, whatever the update mode is. */
404
+ buildNow(): void {
405
+ this.host.buildNow();
406
+ }
407
+
408
+ /** Discards this session's edits to the current slide. */
409
+ resetSlide(): void {
410
+ const preset = getSlide(this.deck, this.activePresetId) ?? this.deck.slides[0];
411
+ if (!preset) {
412
+ return;
413
+ }
414
+ this.edits.delete(preset.id);
415
+ this.loadPreset(preset);
416
+ }
417
+
418
+ /** Attaches the slide bar controller. */
419
+ attachPresentation(presentation: Presentation): void {
420
+ this.presentation = presentation;
421
+ }
422
+
423
+ /**
424
+ * Records whether the playground is presenting.
425
+ *
426
+ * Edits made while presenting are a demo, not a change to the deck, so
427
+ * leaving presentation mode drops them and puts the saved slide back. Going
428
+ * the other way commits anything still waiting to be written.
429
+ */
430
+ setPresent(present: boolean): void {
431
+ const wasPresenting = this.present;
432
+ if (present && !wasPresenting) {
433
+ void this.flushSave();
434
+ }
435
+ this.present = present;
436
+ if (!present && wasPresenting && this.hasDeck) {
437
+ this.edits.clear();
438
+ const slide = getSlide(this.deck, this.activePresetId);
439
+ if (slide) {
440
+ this.loadPreset(slide);
441
+ }
442
+ }
443
+ this.renderPresetOptions();
444
+ this.updateTitle();
445
+ this.syncHash.cancel();
446
+ this.writeHash();
447
+ }
448
+
449
+ /* Deck editing ------------------------------------------------------- */
450
+
451
+ /**
452
+ * Writes the current slide back to the store.
453
+ *
454
+ * Presentation mode never reaches this: edits made while presenting stay in
455
+ * the session so a demo cannot damage the deck.
456
+ */
457
+ private async persist(): Promise<void> {
458
+ if (!this.hasDeck || this.present || !this.activePresetId) {
459
+ return;
460
+ }
461
+ const index = slideIndex(this.deck, this.activePresetId);
462
+ if (index === -1) {
463
+ return;
464
+ }
465
+ const state = this.host.getState();
466
+ const slides = [...this.deck.slides];
467
+ slides[index] = {...slides[index]!, html: state.html, css: state.css, js: state.js};
468
+ this.deck = await store.saveDeck({...this.deck, slides});
469
+ this.showSaved();
470
+ }
471
+
472
+ /** Writes anything pending right away. */
473
+ async flushSave(): Promise<void> {
474
+ this.saveSoon.cancel();
475
+ await this.persist();
476
+ }
477
+
478
+ /**
479
+ * Replaces the deck in the store and redraws everything that shows it.
480
+ *
481
+ * The scratch pad has no deck to update. The home page builds an editor
482
+ * instance to reuse the settings modal, and that instance shares the deck
483
+ * modal's Done button, so without this guard creating a deck also wrote the
484
+ * scratch pad to the store under an empty id.
485
+ */
486
+ private async updateDeck(deck: StoredDeck): Promise<void> {
487
+ if (!this.hasDeck) {
488
+ return;
489
+ }
490
+ this.deck = await store.saveDeck(deck);
491
+ this.renderDeckChrome();
492
+ this.renderPresetOptions();
493
+ this.updateTitle();
494
+ this.presentation?.refresh();
495
+ this.showSaved();
496
+ }
497
+
498
+ private showSaved(): void {
499
+ this.savedIndicator.textContent = 'Saved';
500
+ this.savedIndicator.classList.add('toolbar__saved--on');
501
+ if (this.savedTimer !== undefined) {
502
+ window.clearTimeout(this.savedTimer);
503
+ }
504
+ this.savedTimer = window.setTimeout(() => {
505
+ this.savedIndicator.classList.remove('toolbar__saved--on');
506
+ }, 1400);
507
+ }
508
+
509
+ /** Adds a slide after the current one and opens its settings. */
510
+ private async addSlide(): Promise<void> {
511
+ await this.flushSave();
512
+ const title = `Slide ${this.deck.slides.length + 1}`;
513
+ const slide = makeSlide({
514
+ id: nextSlideId(title, this.deck.slides),
515
+ title,
516
+ html: BLANK_SLIDE_HTML,
517
+ });
518
+ const at = slideIndex(this.deck, this.activePresetId);
519
+ const slides = [...this.deck.slides];
520
+ slides.splice(at === -1 ? slides.length : at + 1, 0, slide);
521
+ await this.updateDeck({...this.deck, slides});
522
+ this.loadPreset(slide);
523
+ this.openSlideSettings();
524
+ }
525
+
526
+ /** Moves the current slide one place earlier or later. */
527
+ private async moveSlide(delta: number): Promise<void> {
528
+ await this.flushSave();
529
+ const from = slideIndex(this.deck, this.activePresetId);
530
+ const to = from + delta;
531
+ if (from === -1 || to < 0 || to >= this.deck.slides.length) {
532
+ return;
533
+ }
534
+ const slides = [...this.deck.slides];
535
+ const [slide] = slides.splice(from, 1);
536
+ slides.splice(to, 0, slide!);
537
+ await this.updateDeck({...this.deck, slides});
538
+ }
539
+
540
+ /** Copies the current slide in place. */
541
+ private async duplicateSlide(): Promise<void> {
542
+ await this.flushSave();
543
+ const current = getSlide(this.deck, this.activePresetId);
544
+ if (!current) {
545
+ return;
546
+ }
547
+ const title = `${current.title} copy`;
548
+ const copy = makeSlide({...current, id: nextSlideId(title, this.deck.slides), title});
549
+ const slides = [...this.deck.slides];
550
+ slides.splice(slideIndex(this.deck, current.id) + 1, 0, copy);
551
+ await this.updateDeck({...this.deck, slides});
552
+ this.loadPreset(copy);
553
+ }
554
+
555
+ /** Removes the current slide. A deck always keeps at least one. */
556
+ private deleteSlide(): void {
557
+ const current = getSlide(this.deck, this.activePresetId);
558
+ if (!current) {
559
+ return;
560
+ }
561
+ if (this.deck.slides.length === 1) {
562
+ this.showToast('warning', 'Keep one slide', 'A deck needs at least one slide.');
563
+ return;
564
+ }
565
+ confirmAction(`Delete the slide "${current.title}"? This cannot be undone.`, async () => {
566
+ this.saveSoon.cancel();
567
+ const index = slideIndex(this.deck, current.id);
568
+ const slides = this.deck.slides.filter((slide) => slide.id !== current.id);
569
+ // Move to the survivor first so nothing redraws against a slide that is
570
+ // no longer in the deck.
571
+ const next = slides[Math.min(index, slides.length - 1)];
572
+ this.activePresetId = next?.id ?? null;
573
+ await this.updateDeck({...this.deck, slides});
574
+ if (next) {
575
+ this.loadPreset(next);
576
+ }
577
+ this.presentation?.refresh();
578
+ });
579
+ }
580
+
581
+ /** Opens the deck settings modal, which also renames the deck. */
582
+ openDeckSettings(): void {
583
+ const modal = required<HTMLElement & {open?: boolean}>('#deck-modal');
584
+ setFieldValue('#deck-title-input', this.deck.title);
585
+ setFieldValue('#deck-description-input', this.deck.description);
586
+ setFieldValue('#deck-base-css-input', this.deck.baseCss);
587
+ modal.open = true;
588
+ }
589
+
590
+ private async applyDeckSettings(): Promise<void> {
591
+ if (!this.hasDeck) {
592
+ return;
593
+ }
594
+ const title = fieldValue('#deck-title-input').trim() || this.deck.title;
595
+ await this.updateDeck({
596
+ ...this.deck,
597
+ title,
598
+ description: fieldValue('#deck-description-input'),
599
+ baseCss: fieldValue('#deck-base-css-input'),
600
+ });
601
+ // The base CSS lives in the hidden head, so the preview has to be rebuilt.
602
+ this.host.load(this.currentState(), this.deck.baseCss);
603
+ }
604
+
605
+ /** Opens the inspector for the current slide. */
606
+ private openSlideSettings(): void {
607
+ const slide = getSlide(this.deck, this.activePresetId);
608
+ if (!slide) {
609
+ return;
610
+ }
611
+ setFieldValue('#slide-title-input', slide.title);
612
+ setFieldValue('#slide-group-input', slide.group);
613
+ setFieldValue('#slide-description-input', slide.description);
614
+ setFieldValue('#slide-notes-input', slide.notes);
615
+ void this.renderSlideVersionOptions(slide.version);
616
+ for (const pane of ['html', 'css', 'js'] as const) {
617
+ const box = required<HTMLElement & {checked?: boolean}>(`#slide-editor-${pane}`);
618
+ box.checked = slide.editors?.includes(pane) ?? false;
619
+ }
620
+ required<HTMLElement & {open?: boolean}>('#slide-modal').open = true;
621
+ }
622
+
623
+ /**
624
+ * Fills the slide inspector's version menu from the same catalog the toolbar
625
+ * uses, honouring the prerelease setting.
626
+ *
627
+ * A version the catalog no longer lists is added anyway, so opening the
628
+ * inspector cannot quietly change what a slide pinned.
629
+ */
630
+ private async renderSlideVersionOptions(current: string): Promise<void> {
631
+ const select = required('#slide-version-select');
632
+ const catalog = await loadVersions();
633
+ const list = this.showPrereleases ? catalog.all : catalog.stable;
634
+ const versions = ['latest', ...list];
635
+ if (current && !versions.includes(current)) {
636
+ versions.splice(1, 0, current);
637
+ }
638
+ select.innerHTML = versions
639
+ .map((version) =>
640
+ option(
641
+ version,
642
+ version === 'latest'
643
+ ? 'latest (follow the newest release)'
644
+ : isPrerelease(version)
645
+ ? `${version} (prerelease)`
646
+ : version,
647
+ version === current,
648
+ ),
649
+ )
650
+ .join('');
651
+ this.setSelectValue(select, current || 'latest');
652
+ }
653
+
654
+ private async applySlideSettings(): Promise<void> {
655
+ const index = slideIndex(this.deck, this.activePresetId);
656
+ const slide = this.deck.slides[index];
657
+ if (!slide) {
658
+ return;
659
+ }
660
+ const chosen = (['html', 'css', 'js'] as const).filter(
661
+ (pane) =>
662
+ required<HTMLElement & {checked?: boolean}>(`#slide-editor-${pane}`).checked === true,
663
+ );
664
+ const slides = [...this.deck.slides];
665
+ slides[index] = {
666
+ ...slide,
667
+ title: fieldValue('#slide-title-input').trim() || slide.title,
668
+ group: fieldValue('#slide-group-input'),
669
+ description: fieldValue('#slide-description-input'),
670
+ notes: fieldValue('#slide-notes-input'),
671
+ version: fieldValue('#slide-version-select').trim() || 'latest',
672
+ editors: chosen.length > 0 ? [...chosen] : null,
673
+ };
674
+ await this.updateDeck({...this.deck, slides});
675
+ }
676
+
677
+ /** Downloads the deck as a JSON file. */
678
+ private async exportDeck(): Promise<void> {
679
+ await this.flushSave();
680
+ download(`${this.deck.id || 'deck'}.json`, `${JSON.stringify(toDeckFile(this.deck), null, 2)}\n`);
681
+ this.showToast('success', 'Deck exported', 'Import the file to open it in another browser.');
682
+ }
683
+
684
+ /**
685
+ * Turns the scratch pad into a deck of its own.
686
+ *
687
+ * `destination` is where to go afterwards. Without one, the new deck opens.
688
+ */
689
+ private async saveAsDeck(destination?: string): Promise<void> {
690
+ const state = this.currentState();
691
+ const deck = await store.createDeck(UNTITLED_DECK);
692
+ const slides = [
693
+ makeSlide({
694
+ ...deck.slides[0]!,
695
+ html: state.html,
696
+ css: state.css,
697
+ js: state.js,
698
+ version: state.version,
699
+ }),
700
+ ];
701
+ const saved = await store.saveDeck({...deck, slides});
702
+ window.location.assign(
703
+ destination ?? `${deckUrl(saved.id, slides[0]!.id, '')}`.replace('#', '&new=1#'),
704
+ );
705
+ }
706
+
707
+ /* Leaving ------------------------------------------------------------- */
708
+
709
+ /** Whether the scratch pad holds code that is not saved anywhere. */
710
+ private hasUnsavedScratch(): boolean {
711
+ if (this.hasDeck) {
712
+ return false;
713
+ }
714
+ const state = this.host.getState();
715
+ return (
716
+ state.html !== this.openedWith.html ||
717
+ state.css !== this.openedWith.css ||
718
+ state.js !== this.openedWith.js
719
+ );
720
+ }
721
+
722
+ /** Goes to a URL, asking first when the scratch pad has unsaved edits. */
723
+ private leaveFor(destination: string): void {
724
+ if (!this.hasUnsavedScratch()) {
725
+ window.location.assign(destination);
726
+ return;
727
+ }
728
+ this.askBeforeLeaving(destination);
729
+ }
730
+
731
+ /**
732
+ * Handles a history move.
733
+ *
734
+ * Only a change of view needs a reload. A move within one deck is a slide
735
+ * change, which loads in place; `loadPreset` rewrites the hash with
736
+ * `replaceState`, so that cannot loop.
737
+ */
738
+ private handleHistoryMove(): void {
739
+ const next = routeFor(window.location.search, window.location.hash);
740
+ if (!needsReboot(this.bootRoute, next)) {
741
+ const slideId = slideIdFromHash(window.location.hash);
742
+ const slide = slideId ? getSlide(this.deck, slideId) : undefined;
743
+ if (slide && slide.id !== this.activePresetId) {
744
+ this.loadPreset(slide);
745
+ }
746
+ return;
747
+ }
748
+ const destination = window.location.href;
749
+ if (!this.hasUnsavedScratch()) {
750
+ window.location.reload();
751
+ return;
752
+ }
753
+ // Put the scratch pad back in the address bar so the person is still on
754
+ // the page they are being asked about.
755
+ window.history.pushState(null, '', this.scratchUrl());
756
+ this.askBeforeLeaving(destination);
757
+ }
758
+
759
+ /** The URL that describes the scratch pad as it stands. */
760
+ private scratchUrl(): string {
761
+ const url = new URL(window.location.href);
762
+ url.searchParams.delete('deck');
763
+ url.hash = `#code=${encodeState(this.currentState())}`;
764
+ return url.toString();
765
+ }
766
+
767
+ private askBeforeLeaving(destination: string): void {
768
+ const modal = required<HTMLElement & {open?: boolean}>('#leave-modal');
769
+ this.leaveListeners?.abort();
770
+ this.leaveListeners = new AbortController();
771
+ const {signal} = this.leaveListeners;
772
+ const close = (): void => {
773
+ modal.open = false;
774
+ this.leaveListeners?.abort();
775
+ this.leaveListeners = undefined;
776
+ };
777
+ required('#leave-stay').addEventListener('nys-click', close, {signal});
778
+ required('#leave-discard').addEventListener(
779
+ 'nys-click',
780
+ () => {
781
+ close();
782
+ window.location.assign(destination);
783
+ },
784
+ {signal},
785
+ );
786
+ required('#leave-save').addEventListener(
787
+ 'nys-click',
788
+ () => {
789
+ close();
790
+ void this.saveAsDeck(destination);
791
+ },
792
+ {signal},
793
+ );
794
+ modal.open = true;
795
+ }
796
+
797
+ /* Share --------------------------------------------------------------- */
798
+
799
+ private openShare(): void {
800
+ this.syncHash.cancel();
801
+ if (!this.hasDeck) {
802
+ void this.copyStandaloneLink();
803
+ return;
804
+ }
805
+ required<HTMLElement & {open?: boolean}>('#share-modal').open = true;
806
+ }
807
+
808
+ private async copyDeckLink(): Promise<void> {
809
+ await this.flushSave();
810
+ const url = deckUrl(this.deck.id, this.activePresetId ?? '', '');
811
+ await this.copy(url, 'Deck link copied', 'It opens this slide in this browser.');
812
+ }
813
+
814
+ private async copyStandaloneLink(): Promise<void> {
815
+ const state = this.currentState();
816
+ const url = new URL(window.location.href);
817
+ url.searchParams.delete('deck');
818
+ url.searchParams.delete('present');
819
+ url.hash = `#code=${encodeState(state)}`;
820
+ await this.copy(url.toString(), 'Standalone link copied', 'It carries the code, so it opens anywhere.');
821
+ }
822
+
823
+ private async copy(text: string, heading: string, detail: string): Promise<void> {
824
+ try {
825
+ await navigator.clipboard.writeText(text);
826
+ this.showToast('success', heading, detail);
827
+ } catch {
828
+ this.showToast(
829
+ 'warning',
830
+ 'Copy the link yourself',
831
+ 'The browser blocked clipboard access. Copy the URL from the address bar.',
832
+ );
833
+ }
834
+ }
835
+
836
+ private handleEdit(): void {
837
+ this.modified = true;
838
+ const state = this.host.getState();
839
+ if (this.activePresetId) {
840
+ this.edits.set(this.activePresetId, {html: state.html, css: state.css, js: state.js});
841
+ }
842
+ if (!this.present) {
843
+ // Outside presentation mode, an edit takes the session off the preset,
844
+ // so the link has to carry the code rather than a preset id.
845
+ this.renderPresetOptions();
846
+ this.updateTitle();
847
+ this.saveSoon();
848
+ }
849
+ this.syncHash();
850
+ this.presentation?.refresh();
851
+ }
852
+
853
+ private writeHash(): void {
854
+ // The scratch pad has nowhere to save, so its URL has to carry the code.
855
+ if (!this.hasDeck) {
856
+ writeCodeHash(this.currentState());
857
+ return;
858
+ }
859
+ // A deck keeps the readable `#preset=` link: edits are saved to the deck,
860
+ // so the slide id still describes what is on screen. The same holds while
861
+ // presenting.
862
+ if (this.activePresetId) {
863
+ writePresetHash(this.activePresetId);
864
+ return;
865
+ }
866
+ writeCodeHash(this.currentState());
867
+ }
868
+
869
+ private currentState(): PlaygroundState {
870
+ return {...this.host.getState(), version: this.version};
871
+ }
872
+
873
+ private updateTitle(): void {
874
+ const preset = this.customCode() ? undefined : getSlide(this.deck, this.activePresetId);
875
+ document.title = preset
876
+ ? `${preset.title} · ${this.deck.title}`
877
+ : PLAYGROUND_CONFIG.title;
878
+ }
879
+
880
+ /** Whether the toolbar should present the session as edited code. */
881
+ private customCode(): boolean {
882
+ return this.modified && !this.present && !this.hasDeck;
883
+ }
884
+
885
+ /* Toolbar ------------------------------------------------------------ */
886
+
887
+ private renderDeckChrome(): void {
888
+ required('#deck-title-text').textContent = this.deck.title;
889
+ required('#deck-title').setAttribute(
890
+ 'aria-label',
891
+ `Rename deck: ${this.deck.title}`,
892
+ );
893
+ required('#deck-title-static').textContent = this.deck.title;
894
+ }
895
+
896
+ /* Renaming in place ---------------------------------------------------- */
897
+
898
+ /**
899
+ * Swaps the title for a text field, the way a document title behaves.
900
+ *
901
+ * Enter or blur saves, Escape puts the old title back. An empty name is
902
+ * ignored rather than accepted, so a deck always has something to call it.
903
+ */
904
+ openTitleEditor(): void {
905
+ if (!this.hasDeck) {
906
+ return;
907
+ }
908
+ const button = required('#deck-title');
909
+ const field = required<HTMLInputElement>('#deck-title-field');
910
+ if (!field.hidden) {
911
+ return;
912
+ }
913
+ const original = this.deck.title;
914
+ field.value = original;
915
+ field.hidden = false;
916
+ button.hidden = true;
917
+ field.focus();
918
+ field.select();
919
+
920
+ const controller = new AbortController();
921
+ const {signal} = controller;
922
+ const close = (): void => {
923
+ controller.abort();
924
+ field.hidden = true;
925
+ button.hidden = false;
926
+ };
927
+ const commit = (): void => {
928
+ const next = field.value.trim();
929
+ close();
930
+ if (next && next !== original) {
931
+ void this.updateDeck({...this.deck, title: next});
932
+ }
933
+ };
934
+ field.addEventListener('keydown', (event) => {
935
+ if (event.key === 'Enter') {
936
+ event.preventDefault();
937
+ commit();
938
+ } else if (event.key === 'Escape') {
939
+ event.preventDefault();
940
+ close();
941
+ button.focus();
942
+ }
943
+ }, {signal});
944
+ field.addEventListener('blur', commit, {signal});
945
+ }
946
+
947
+ private renderPresetOptions(): void {
948
+ const parts: string[] = [];
949
+ if (this.customCode()) {
950
+ parts.push(option('__custom__', 'Custom code', true));
951
+ }
952
+ // Group consecutive slides that share a `group` label.
953
+ let openGroup: string | null = null;
954
+ for (const preset of this.deck.slides) {
955
+ const group = preset.group;
956
+ if (group !== openGroup) {
957
+ if (openGroup) {
958
+ parts.push('</optgroup>');
959
+ }
960
+ if (group) {
961
+ parts.push(`<optgroup label="${escapeAttribute(group)}">`);
962
+ }
963
+ openGroup = group || null;
964
+ }
965
+ const selected = !this.customCode() && preset.id === this.activePresetId;
966
+ parts.push(option(preset.id, preset.title, selected));
967
+ }
968
+ if (openGroup) {
969
+ parts.push('</optgroup>');
970
+ }
971
+ this.presetSelect.innerHTML = parts.join('');
972
+ this.setSelectValue(
973
+ this.presetSelect,
974
+ this.customCode() ? '__custom__' : (this.activePresetId ?? ''),
975
+ );
976
+ }
977
+
978
+ private async renderVersionOptions(): Promise<void> {
979
+ const catalog = await loadVersions();
980
+ const list = this.showPrereleases ? catalog.all : catalog.stable;
981
+ // Keep a pinned version visible even when it is not in the catalog.
982
+ const versions = list.includes(this.version) ? list : [this.version, ...list];
983
+ this.versionSelect.innerHTML = versions
984
+ .map((version) =>
985
+ option(
986
+ version,
987
+ isPrerelease(version) ? `${version} (prerelease)` : version,
988
+ version === this.version,
989
+ ),
990
+ )
991
+ .join('');
992
+ this.setSelectValue(this.versionSelect, this.version);
993
+ if (catalog.usedFallback) {
994
+ console.warn('Showing the built-in version list because the CDN did not answer.');
995
+ }
996
+ }
997
+
998
+ private setSelectValue(select: HTMLElement, value: string): void {
999
+ (select as HTMLElement & {value?: string}).value = value;
1000
+ }
1001
+
1002
+ private bindToolbar(): void {
1003
+ this.presetSelect.addEventListener('change', (event) => {
1004
+ const value = detailValue(event);
1005
+ if (!value || value === '__custom__') {
1006
+ return;
1007
+ }
1008
+ const preset = getSlide(this.deck, value);
1009
+ if (preset) {
1010
+ this.loadPreset(preset);
1011
+ }
1012
+ });
1013
+
1014
+ this.versionSelect.addEventListener('nys-change', (event) => {
1015
+ const value = detailValue(event);
1016
+ if (!value || value === this.version) {
1017
+ return;
1018
+ }
1019
+ this.version = value;
1020
+ this.host.setVersion(value);
1021
+ this.syncHash.cancel();
1022
+ this.writeHash();
1023
+ });
1024
+
1025
+ this.columnsToggle.addEventListener('nys-change', () => {
1026
+ const layout: EditorLayout = this.columnsToggle.checked === true ? 'columns' : 'tabs';
1027
+ this.panes.setLayout(layout);
1028
+ });
1029
+
1030
+ this.prereleaseToggle.addEventListener('nys-change', () => {
1031
+ this.showPrereleases = this.prereleaseToggle.checked === true;
1032
+ writePrereleases(this.showPrereleases);
1033
+ void this.renderVersionOptions();
1034
+ });
1035
+
1036
+ bindClick('#share-button', () => this.openShare());
1037
+ bindClick('#export-button', () => void this.exportDeck());
1038
+ bindClick('#present-button', () => {
1039
+ this.syncHash.flush();
1040
+ this.presentation?.enter();
1041
+ });
1042
+ }
1043
+
1044
+ private showToast(type: string, heading: string, text: string): void {
1045
+ const alert = document.createElement('nys-alert');
1046
+ alert.setAttribute('type', type);
1047
+ alert.setAttribute('heading', heading);
1048
+ alert.setAttribute('text', text);
1049
+ alert.setAttribute('dismissible', '');
1050
+ this.toast.replaceChildren(alert);
1051
+ this.toast.hidden = false;
1052
+ if (this.toastTimer !== undefined) {
1053
+ window.clearTimeout(this.toastTimer);
1054
+ }
1055
+ this.toastTimer = window.setTimeout(() => {
1056
+ this.toast.hidden = true;
1057
+ this.toast.replaceChildren();
1058
+ }, TOAST_DURATION_MS);
1059
+ }
1060
+ }
1061
+
1062
+ /* Helpers ------------------------------------------------------------- */
1063
+
1064
+ function required<T extends HTMLElement = HTMLElement>(selector: string): T {
1065
+ const element = document.querySelector<T>(selector);
1066
+ if (!element) {
1067
+ throw new Error(`The page is missing the element "${selector}".`);
1068
+ }
1069
+ return element;
1070
+ }
1071
+
1072
+ function escapeAttribute(value: string): string {
1073
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
1074
+ }
1075
+
1076
+ function option(value: string, label: string, selected: boolean): string {
1077
+ const escaped = escapeAttribute(label);
1078
+ return `<option value="${escapeAttribute(value)}" label="${escaped}"${
1079
+ selected ? ' selected' : ''
1080
+ }>${escaped}</option>`;
1081
+ }
1082
+
1083
+ function detailValue(event: Event): string | undefined {
1084
+ const detail = (event as CustomEvent<{value?: string}>).detail;
1085
+ if (detail && typeof detail.value === 'string') {
1086
+ return detail.value;
1087
+ }
1088
+ return (event.target as HTMLElement & {value?: string}).value;
1089
+ }
1090
+
1091
+ function bindClick(selector: string, handler: () => void): void {
1092
+ required(selector).addEventListener('nys-click', handler);
1093
+ }
1094
+
1095
+ /** Reads the value of a design system text field. */
1096
+ function fieldValue(selector: string): string {
1097
+ return required<HTMLElement & {value?: string}>(selector).value ?? '';
1098
+ }
1099
+
1100
+ /** Writes the value of a design system text field. */
1101
+ function setFieldValue(selector: string, value: string): void {
1102
+ required<HTMLElement & {value?: string}>(selector).value = value;
1103
+ }
1104
+
1105
+ /**
1106
+ * Asks before doing something destructive.
1107
+ *
1108
+ * It uses the design system modal rather than `window.confirm`, which blocks
1109
+ * the page and the automated checks along with it.
1110
+ */
1111
+ /** Drops the listeners from whichever question the modal asked last. */
1112
+ let confirmListeners: AbortController | undefined;
1113
+
1114
+ function confirmAction(message: string, onConfirm: () => void): void {
1115
+ const modal = required<HTMLElement & {open?: boolean}>('#confirm-modal');
1116
+ required('#confirm-message').textContent = message;
1117
+ // Dismissing the modal with its own close button or Escape skips the Cancel
1118
+ // handler, so listeners from an earlier question would otherwise pile up and
1119
+ // one confirmation would answer all of them.
1120
+ confirmListeners?.abort();
1121
+ confirmListeners = new AbortController();
1122
+ const {signal} = confirmListeners;
1123
+ const close = (): void => {
1124
+ modal.open = false;
1125
+ confirmListeners?.abort();
1126
+ confirmListeners = undefined;
1127
+ };
1128
+ required('#confirm-ok').addEventListener(
1129
+ 'nys-click',
1130
+ () => {
1131
+ close();
1132
+ onConfirm();
1133
+ },
1134
+ {signal},
1135
+ );
1136
+ required('#confirm-cancel').addEventListener('nys-click', close, {signal});
1137
+ modal.open = true;
1138
+ }
1139
+
1140
+ /** Hands the browser a file to save. */
1141
+ function download(filename: string, text: string): void {
1142
+ const blob = new Blob([text], {type: 'application/json'});
1143
+ const url = URL.createObjectURL(blob);
1144
+ const link = document.createElement('a');
1145
+ link.href = url;
1146
+ link.download = filename;
1147
+ link.click();
1148
+ URL.revokeObjectURL(url);
1149
+ }
1150
+
1151
+ /** Records a setting in the URL so a copied link opens the same way. */
1152
+ function replaceQuery(name: string, value: string): void {
1153
+ window.history.replaceState(null, '', settingUrl(name, value, window.location.href));
1154
+ }
1155
+
1156
+ /**
1157
+ * Makes the drawer divider draggable and remembers its height.
1158
+ *
1159
+ * The drawer sits over the bottom of the stage, so a larger pointer Y means a
1160
+ * shorter drawer.
1161
+ */
1162
+ function setupSplitter(): void {
1163
+ const stage = required('#split');
1164
+ const divider = required('#divider');
1165
+ const MIN = 15;
1166
+ const MAX = 85;
1167
+
1168
+ const apply = (ratio: number): void => {
1169
+ stage.style.setProperty('--pg-drawer-size', `${ratio.toFixed(2)}%`);
1170
+ };
1171
+
1172
+ apply(readRatio(STORAGE_KEYS.drawerSize, DEFAULTS.drawerSize, MIN, MAX));
1173
+
1174
+ let dragging = false;
1175
+ const move = (clientY: number): void => {
1176
+ const rect = stage.getBoundingClientRect();
1177
+ if (rect.height === 0) {
1178
+ return;
1179
+ }
1180
+ const ratio = Math.min(Math.max(((rect.bottom - clientY) / rect.height) * 100, MIN), MAX);
1181
+ apply(ratio);
1182
+ writeRatio(STORAGE_KEYS.drawerSize, ratio);
1183
+ };
1184
+
1185
+ divider.addEventListener('pointerdown', (event) => {
1186
+ dragging = true;
1187
+ divider.setPointerCapture(event.pointerId);
1188
+ event.preventDefault();
1189
+ });
1190
+ divider.addEventListener('pointermove', (event) => {
1191
+ if (dragging) {
1192
+ move(event.clientY);
1193
+ }
1194
+ });
1195
+ const stop = (event: PointerEvent): void => {
1196
+ if (!dragging) {
1197
+ return;
1198
+ }
1199
+ dragging = false;
1200
+ try {
1201
+ divider.releasePointerCapture(event.pointerId);
1202
+ } catch {
1203
+ // The capture may already be gone.
1204
+ }
1205
+ };
1206
+ divider.addEventListener('pointerup', stop);
1207
+ divider.addEventListener('pointercancel', stop);
1208
+ divider.addEventListener('keydown', (event) => {
1209
+ const step = event.key === 'ArrowDown' ? -2 : event.key === 'ArrowUp' ? 2 : 0;
1210
+ if (step === 0) {
1211
+ return;
1212
+ }
1213
+ event.preventDefault();
1214
+ const rect = stage.getBoundingClientRect();
1215
+ move(divider.getBoundingClientRect().top - (step / 100) * rect.height);
1216
+ });
1217
+ }
1218
+
1219
+ /** Works out which slide the URL asks for inside a deck. */
1220
+ async function resolveInitialState(deck: StoredDeck): Promise<{
1221
+ state: PlaygroundState;
1222
+ presetId: string | null;
1223
+ }> {
1224
+ const location = readLocation();
1225
+ if (location.kind === 'code') {
1226
+ return {
1227
+ state: {...location.state, version: await resolveVersion(location.state.version)},
1228
+ presetId: deck.id === '' ? deck.slides[0]!.id : null,
1229
+ };
1230
+ }
1231
+ const slide =
1232
+ (location.kind === 'preset' ? getSlide(deck, location.id) : undefined) ?? deck.slides[0]!;
1233
+ return {
1234
+ state: {
1235
+ version: await resolveVersion(slide.version),
1236
+ html: slide.html,
1237
+ css: slide.css,
1238
+ js: slide.js,
1239
+ },
1240
+ presetId: slide.id,
1241
+ };
1242
+ }
1243
+
1244
+ /** Shows the "that deck is not here" message. */
1245
+ function showMissingDeck(): void {
1246
+ document.querySelector('#app')?.classList.add('app--missing');
1247
+ required('#deck-missing').hidden = false;
1248
+ required('#deck-missing-home').addEventListener('nys-click', () => {
1249
+ window.location.href = './';
1250
+ });
1251
+ }
1252
+
1253
+ /** Builds and shows the home view. */
1254
+ async function startHome(openSettings: () => void): Promise<void> {
1255
+ const refresh = async (): Promise<void> => {
1256
+ home.render(await store.listDecks());
1257
+ };
1258
+ const readFiles = async (files: FileList | File[]): Promise<void> => {
1259
+ let added = 0;
1260
+ let failure = '';
1261
+ for (const file of Array.from(files)) {
1262
+ const result = await store.importDeck(
1263
+ await file.text(),
1264
+ file.name.replace(/\.json$/i, ''),
1265
+ );
1266
+ if (result.ok) {
1267
+ added += 1;
1268
+ } else {
1269
+ failure = result.message;
1270
+ }
1271
+ }
1272
+ await refresh();
1273
+ if (added > 0) {
1274
+ toast('success', added === 1 ? 'Deck imported' : `${added} decks imported`, '');
1275
+ } else {
1276
+ toast('warning', 'Nothing imported', failure || 'That file is not a deck.');
1277
+ }
1278
+ };
1279
+
1280
+ const home = new HomeView({
1281
+ open: (id) => {
1282
+ window.location.href = `./?deck=${encodeURIComponent(id)}`;
1283
+ },
1284
+ present: (id) => {
1285
+ window.location.href = `./?deck=${encodeURIComponent(id)}&present=1`;
1286
+ },
1287
+ duplicate: async (id) => {
1288
+ await store.duplicateDeck(id);
1289
+ await refresh();
1290
+ },
1291
+ exportDeck: async (id) => {
1292
+ const json = await store.exportDeck(id);
1293
+ if (json) {
1294
+ download(`${id}.json`, json);
1295
+ }
1296
+ },
1297
+ remove: (deck) => {
1298
+ confirmAction(`Delete the deck "${deck.title}"? This cannot be undone.`, async () => {
1299
+ await store.deleteDeck(deck.id);
1300
+ await refresh();
1301
+ });
1302
+ },
1303
+ create: () => {
1304
+ // No prompt: the deck appears at once and the editor opens with its name
1305
+ // selected, so typing replaces "Untitled" straight away.
1306
+ void store.createDeck(UNTITLED_DECK).then((deck) => {
1307
+ window.location.assign(`./?deck=${encodeURIComponent(deck.id)}&new=1`);
1308
+ });
1309
+ },
1310
+ importFiles: readFiles,
1311
+ restoreStarters: async () => {
1312
+ const added = await store.seedStarters();
1313
+ await refresh();
1314
+ toast(
1315
+ 'success',
1316
+ added === 0 ? 'Nothing to restore' : `${added} starter deck${added === 1 ? '' : 's'} added`,
1317
+ added === 0 ? 'Every starter deck is already here.' : '',
1318
+ );
1319
+ },
1320
+ scratch: () => {
1321
+ // Changing only the hash does not reload, and the router runs once at
1322
+ // start-up, so ask for the reload explicitly.
1323
+ window.location.hash = '#preset=scratch';
1324
+ window.location.reload();
1325
+ },
1326
+ openSettings,
1327
+ });
1328
+
1329
+ home.show(await store.listDecks());
1330
+ }
1331
+
1332
+ /** Shows a toast outside the editor. */
1333
+ function toast(type: string, heading: string, text: string): void {
1334
+ const host = required('#toast');
1335
+ const alert = document.createElement('nys-alert');
1336
+ alert.setAttribute('type', type);
1337
+ alert.setAttribute('heading', heading);
1338
+ if (text) {
1339
+ alert.setAttribute('text', text);
1340
+ }
1341
+ alert.setAttribute('dismissible', '');
1342
+ host.replaceChildren(alert);
1343
+ host.hidden = false;
1344
+ window.setTimeout(() => {
1345
+ host.hidden = true;
1346
+ host.replaceChildren();
1347
+ }, 2600);
1348
+ }
1349
+
1350
+ /** Binds the always-on shortcut that rebuilds the preview immediately. */
1351
+ function bindBuildShortcut(build: () => void): void {
1352
+ window.addEventListener(
1353
+ 'keydown',
1354
+ (event) => {
1355
+ if (!isBuildShortcut(event)) {
1356
+ return;
1357
+ }
1358
+ // Cmd+S would otherwise open the browser's save dialog.
1359
+ event.preventDefault();
1360
+ build();
1361
+ },
1362
+ true,
1363
+ );
1364
+ }
1365
+
1366
+ async function main(): Promise<void> {
1367
+ const present = isPresentMode();
1368
+ applyEditorTheme(initialTheme());
1369
+ applyFontSize(initialFontSize());
1370
+
1371
+ // The bundled decks and presets only seed an empty store.
1372
+ await store.seedStarters();
1373
+
1374
+ const route = routeFor(window.location.search, window.location.hash);
1375
+ if (route.kind === 'home') {
1376
+ await startHome(() => {
1377
+ required<HTMLElement & {open?: boolean}>('#settings-modal').open = true;
1378
+ });
1379
+ // The settings modal is shared with the editor, so bind its controls too.
1380
+ const project = required<PlaygroundProject>('#project');
1381
+ const panes = new EditorPanes();
1382
+ const app = new PlaygroundApp(
1383
+ project,
1384
+ scratchDeck(),
1385
+ {version: await resolveVersion('latest'), html: '', css: '', js: ''},
1386
+ null,
1387
+ false,
1388
+ panes,
1389
+ );
1390
+ await app.start();
1391
+ document.querySelector('#app')?.classList.add('app--home');
1392
+ return;
1393
+ }
1394
+
1395
+ setupSplitter();
1396
+
1397
+ let deck: StoredDeck;
1398
+ if (route.kind === 'deck') {
1399
+ const found = await store.getDeck(route.id);
1400
+ if (!found) {
1401
+ showMissingDeck();
1402
+ return;
1403
+ }
1404
+ deck = found;
1405
+ } else {
1406
+ deck = scratchDeck();
1407
+ }
1408
+
1409
+ const project = required<PlaygroundProject>('#project');
1410
+ const {state, presetId} = await resolveInitialState(deck);
1411
+ if (route.kind === 'scratch') {
1412
+ // The scratch pad's single slide carries whatever the link asked for.
1413
+ deck = scratchDeck(state);
1414
+ }
1415
+ let app: PlaygroundApp | undefined;
1416
+ const panes = new EditorPanes((layout) => {
1417
+ // Keep `?editors=` accurate so a copied link opens the same way.
1418
+ window.history.replaceState(null, '', layoutUrl(layout, window.location.href));
1419
+ app?.syncColumnsToggle();
1420
+ });
1421
+ app = new PlaygroundApp(project, deck, state, presetId, present, panes);
1422
+ await app.start();
1423
+ bindBuildShortcut(() => app?.buildNow());
1424
+
1425
+ // The slide bar is always on screen, so presentation mode is a state the
1426
+ // same controller switches into rather than a separate page.
1427
+ const presentation = new Presentation(app, panes);
1428
+ app.attachPresentation(presentation);
1429
+ presentation.start(present);
1430
+
1431
+ // Warm the CDN cache check so the first preview paint is not the first
1432
+ // request for these URLs.
1433
+ void fetch(stylesUrl(state.version), {mode: 'no-cors'}).catch(() => undefined);
1434
+ void fetch(componentsUrl(state.version), {mode: 'no-cors'}).catch(() => undefined);
1435
+ }
1436
+
1437
+ /**
1438
+ * Shows the shell and removes the splash.
1439
+ *
1440
+ * `index.html` hides the shell until this runs, so the page never paints
1441
+ * unstyled text before the components are defined. It runs even when boot
1442
+ * fails, so an error surfaces instead of a blank page.
1443
+ */
1444
+ function revealApp(): void {
1445
+ document.querySelector('#app')?.classList.add('app--ready');
1446
+ const splash = document.querySelector<HTMLElement>('#splash');
1447
+ if (splash) {
1448
+ splash.hidden = true;
1449
+ }
1450
+ }
1451
+
1452
+ void main()
1453
+ .catch((error: unknown) => {
1454
+ console.error(error);
1455
+ })
1456
+ .finally(revealApp);