@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.
- package/LICENSE +25 -0
- package/README.md +364 -0
- package/bin/cli.mjs +223 -0
- package/bin/cli.test.mjs +36 -0
- package/decks/customizing-components.json +97 -0
- package/dist/assets/index-LO1tomgR.css +6 -0
- package/dist/assets/index-bBtGCGL_.js +5190 -0
- package/dist/assets/internal/typescript.js +193739 -0
- package/dist/assets/nys-icon.library-Bi_7DKlD-YSs5zqZy-DXISxj9N.js +609 -0
- package/dist/assets/nys-icon.library-CwuPZJAc-TryaOS7Z.js +600 -0
- package/dist/assets/playground-typescript-worker-BcTrPYfY.js +87 -0
- package/dist/assets/playground-typescript-worker.js +87 -0
- package/dist/favicon.svg +10 -0
- package/dist/index.html +769 -0
- package/dist/nysds-logo.svg +21 -0
- package/dist/nysds-symbol.svg +7 -0
- package/index.html +768 -0
- package/package.json +59 -0
- package/presets/00-welcome.json +8 -0
- package/presets/01-button.json +7 -0
- package/presets/02-alert.json +7 -0
- package/presets/03-badge-and-avatar.json +7 -0
- package/presets/04-text-input.json +7 -0
- package/presets/05-select-radio-checkbox.json +7 -0
- package/presets/06-form-validation.json +7 -0
- package/presets/07-card.json +7 -0
- package/presets/08-accordion.json +7 -0
- package/presets/09-tabs.json +7 -0
- package/presets/10-modal.json +7 -0
- package/presets/11-stepper.json +7 -0
- package/presets/12-table-and-pagination.json +7 -0
- package/presets/13-tooltip-and-dropdown.json +7 -0
- package/presets/14-navigation.json +7 -0
- package/presets/15-page-structure.json +7 -0
- package/presets/16-themes.json +7 -0
- package/presets/17-utility-classes.json +7 -0
- package/presets/README.md +118 -0
- package/public/favicon.svg +10 -0
- package/public/nysds-logo.svg +21 -0
- package/public/nysds-symbol.svg +7 -0
- package/src/app.css +1087 -0
- package/src/debounce.ts +86 -0
- package/src/deck-model.ts +299 -0
- package/src/deck-store.ts +144 -0
- package/src/decks.test.ts +288 -0
- package/src/editor-panes.ts +190 -0
- package/src/editors.ts +92 -0
- package/src/home.ts +225 -0
- package/src/icon-names.ts +117 -0
- package/src/icons.test.ts +58 -0
- package/src/keys.ts +162 -0
- package/src/main.ts +1456 -0
- package/src/playground.config.ts +74 -0
- package/src/playground.ts +261 -0
- package/src/present.ts +398 -0
- package/src/preset-schema.ts +228 -0
- package/src/route.test.ts +56 -0
- package/src/routing.ts +60 -0
- package/src/settings.ts +211 -0
- package/src/starters.ts +86 -0
- package/src/state.test.ts +544 -0
- package/src/state.ts +237 -0
- package/src/theme.ts +82 -0
- package/src/version-catalog.ts +42 -0
- package/src/versions.ts +88 -0
- package/src/wrapper.ts +84 -0
- package/tsconfig.json +25 -0
- package/vite.config.ts +62 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks for the deck model and the route decision.
|
|
3
|
+
*
|
|
4
|
+
* These cover the parts of the store that do not need IndexedDB: id
|
|
5
|
+
* generation, collision handling, import normalization, the export shape, and
|
|
6
|
+
* seed diffing.
|
|
7
|
+
*/
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import test from 'node:test';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
copyOfDeck,
|
|
13
|
+
makeDeck,
|
|
14
|
+
makeSlide,
|
|
15
|
+
missingStarters,
|
|
16
|
+
nextSlideId,
|
|
17
|
+
normalizeImport,
|
|
18
|
+
relativeTime,
|
|
19
|
+
slideIndex,
|
|
20
|
+
slugify,
|
|
21
|
+
staleStarters,
|
|
22
|
+
toDeckFile,
|
|
23
|
+
uniqueId,
|
|
24
|
+
} from './deck-model.ts';
|
|
25
|
+
import type {StoredDeck} from './deck-model.ts';
|
|
26
|
+
|
|
27
|
+
const NOW = new Date('2026-09-16T12:00:00.000Z');
|
|
28
|
+
|
|
29
|
+
function deck(id: string, extra: Partial<StoredDeck> = {}): StoredDeck {
|
|
30
|
+
return {
|
|
31
|
+
id,
|
|
32
|
+
title: id,
|
|
33
|
+
description: '',
|
|
34
|
+
baseCss: '',
|
|
35
|
+
slides: [makeSlide({id: 'slide-1', title: 'Slide 1', html: 'x'})],
|
|
36
|
+
createdAt: NOW.toISOString(),
|
|
37
|
+
updatedAt: NOW.toISOString(),
|
|
38
|
+
...extra,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test('slugify makes a url-safe id', () => {
|
|
43
|
+
assert.equal(slugify('Three Levels of Strictness!'), 'three-levels-of-strictness');
|
|
44
|
+
assert.equal(slugify(' '), 'deck');
|
|
45
|
+
assert.equal(slugify('a'.repeat(120)).length, 60);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('uniqueId appends a counter until the id is free', () => {
|
|
49
|
+
assert.equal(uniqueId('deck', []), 'deck');
|
|
50
|
+
assert.equal(uniqueId('deck', ['deck']), 'deck-2');
|
|
51
|
+
assert.equal(uniqueId('deck', ['deck', 'deck-2', 'deck-3']), 'deck-4');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('nextSlideId falls back to a numbered id and avoids collisions', () => {
|
|
55
|
+
const slides = [makeSlide({id: 'intro', title: 'Intro'})];
|
|
56
|
+
assert.equal(nextSlideId('Second', slides), 'second');
|
|
57
|
+
assert.equal(nextSlideId('Intro', slides), 'intro-2');
|
|
58
|
+
assert.equal(nextSlideId(' ', slides), 'slide-2');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('makeDeck starts with one usable slide', () => {
|
|
62
|
+
const made = makeDeck('My Deck', ['my-deck'], NOW);
|
|
63
|
+
assert.equal(made.id, 'my-deck-2');
|
|
64
|
+
assert.equal(made.title, 'My Deck');
|
|
65
|
+
assert.equal(made.slides.length, 1);
|
|
66
|
+
assert.equal(made.slides[0]?.html, '<nys-button label="Excelsior"></nys-button>\n');
|
|
67
|
+
assert.equal(made.createdAt, made.updatedAt);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('copyOfDeck takes a free id and drops the starter link', () => {
|
|
71
|
+
const copy = copyOfDeck(deck('library', {starter: 'library'}), ['library', 'library-copy'], NOW);
|
|
72
|
+
assert.equal(copy.id, 'library-copy-2');
|
|
73
|
+
assert.equal(copy.title, 'Copy of library');
|
|
74
|
+
assert.equal(copy.starter, undefined);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('slideIndex finds a slide or reports -1', () => {
|
|
78
|
+
const d = deck('a');
|
|
79
|
+
assert.equal(slideIndex(d, 'slide-1'), 0);
|
|
80
|
+
assert.equal(slideIndex(d, 'missing'), -1);
|
|
81
|
+
assert.equal(slideIndex(d, null), -1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('toDeckFile writes the deck file shape without timestamps', () => {
|
|
85
|
+
const file = toDeckFile(deck('a', {title: 'A', description: 'd', baseCss: 'p{}'}));
|
|
86
|
+
assert.deepEqual(Object.keys(file), ['title', 'description', 'boilerplate', 'presets']);
|
|
87
|
+
assert.deepEqual(file.boilerplate, {baseCss: 'p{}'});
|
|
88
|
+
assert.equal(file.presets.length, 1);
|
|
89
|
+
assert.equal('createdAt' in file, false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('normalizeImport accepts a deck file and tolerates extra keys', () => {
|
|
93
|
+
const result = normalizeImport(
|
|
94
|
+
{
|
|
95
|
+
$comment: 'ignore me',
|
|
96
|
+
title: 'Styling Levels',
|
|
97
|
+
boilerplate: {head: '<link>', baseCss: 'body{}'},
|
|
98
|
+
presets: [{id: '01-a', title: 'A', html: '<p>a</p>'}],
|
|
99
|
+
},
|
|
100
|
+
[],
|
|
101
|
+
'styling-levels',
|
|
102
|
+
NOW,
|
|
103
|
+
);
|
|
104
|
+
assert.equal(result.ok, true);
|
|
105
|
+
if (!result.ok) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
assert.equal(result.deck.id, 'styling-levels');
|
|
109
|
+
assert.equal(result.deck.baseCss, 'body{}');
|
|
110
|
+
assert.equal(result.deck.slides.length, 1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('normalizeImport appends a suffix when the file name is taken', () => {
|
|
114
|
+
const raw = {title: 'Three levels of strictness', presets: [{id: 'a', title: 'A', html: 'x'}]};
|
|
115
|
+
const first = normalizeImport(raw, ['styling-levels'], 'styling-levels', NOW);
|
|
116
|
+
assert.equal(first.ok && first.deck.id, 'styling-levels-2');
|
|
117
|
+
const second = normalizeImport(raw, ['styling-levels', 'styling-levels-2'], 'styling-levels', NOW);
|
|
118
|
+
assert.equal(second.ok && second.deck.id, 'styling-levels-3');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('normalizeImport falls back to the title when there is no file name', () => {
|
|
122
|
+
const raw = {title: 'Styling Levels', presets: [{id: 'a', title: 'A', html: 'x'}]};
|
|
123
|
+
const result = normalizeImport(raw, [], '', NOW);
|
|
124
|
+
assert.equal(result.ok, true);
|
|
125
|
+
assert.equal(result.ok ? result.deck.id : '', 'styling-levels');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('normalizeImport accepts a single preset as a one-slide deck', () => {
|
|
129
|
+
const result = normalizeImport(
|
|
130
|
+
{title: 'Button', html: '<nys-button></nys-button>', css: '', js: ''},
|
|
131
|
+
[],
|
|
132
|
+
'button',
|
|
133
|
+
NOW,
|
|
134
|
+
);
|
|
135
|
+
assert.equal(result.ok, true);
|
|
136
|
+
if (!result.ok) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
assert.equal(result.deck.slides.length, 1);
|
|
140
|
+
assert.equal(result.deck.title, 'Button');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('normalizeImport rejects what is not a deck', () => {
|
|
144
|
+
assert.equal(normalizeImport([1, 2], [], 'x', NOW).ok, false);
|
|
145
|
+
assert.equal(normalizeImport('nope', [], 'x', NOW).ok, false);
|
|
146
|
+
assert.equal(normalizeImport({presets: []}, [], 'x', NOW).ok, false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('missingStarters adds only the starters the store has not seen', () => {
|
|
150
|
+
const starters = [
|
|
151
|
+
deck('library', {starter: 'library'}),
|
|
152
|
+
deck('styling-levels', {starter: 'styling-levels'}),
|
|
153
|
+
];
|
|
154
|
+
assert.equal(missingStarters(starters, []).length, 2);
|
|
155
|
+
assert.deepEqual(
|
|
156
|
+
missingStarters(starters, [deck('library', {starter: 'library'})]).map((d) => d.id),
|
|
157
|
+
['styling-levels'],
|
|
158
|
+
);
|
|
159
|
+
assert.equal(missingStarters(starters, starters).length, 0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('missingStarters keeps a restored starter off an id already in use', () => {
|
|
163
|
+
const starters = [deck('library', {starter: 'library'})];
|
|
164
|
+
const stored = [deck('library')];
|
|
165
|
+
assert.deepEqual(missingStarters(starters, stored).map((d) => d.id), ['library-2']);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('relativeTime reads as a person would say it', () => {
|
|
169
|
+
const now = new Date('2026-09-16T12:00:00.000Z');
|
|
170
|
+
assert.equal(relativeTime('2026-09-16T09:00:00.000Z', now), 'today');
|
|
171
|
+
assert.equal(relativeTime('2026-09-15T09:00:00.000Z', now), 'yesterday');
|
|
172
|
+
assert.equal(relativeTime('2026-09-10T12:00:00.000Z', now), '6 days ago');
|
|
173
|
+
assert.equal(relativeTime('2026-07-16T12:00:00.000Z', now), '2 months ago');
|
|
174
|
+
assert.equal(relativeTime('not a date', now), 'unknown');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
/* Confirm dialog handler lifetime ---------------------------------------- */
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The shape of `confirmAction` in `src/main.ts`, minus the DOM.
|
|
181
|
+
*
|
|
182
|
+
* Deleting one deck used to delete its neighbour: dismissing the modal with
|
|
183
|
+
* its own close button skipped the Cancel handler, so the next question added
|
|
184
|
+
* a second listener and one confirmation answered both.
|
|
185
|
+
*/
|
|
186
|
+
function makeConfirm(): {
|
|
187
|
+
ask(onConfirm: () => void): void;
|
|
188
|
+
confirm(): void;
|
|
189
|
+
dismiss(): void;
|
|
190
|
+
} {
|
|
191
|
+
const listeners = new Set<() => void>();
|
|
192
|
+
let controller: {abort(): void} | undefined;
|
|
193
|
+
return {
|
|
194
|
+
ask(onConfirm) {
|
|
195
|
+
controller?.abort();
|
|
196
|
+
const handler = (): void => {
|
|
197
|
+
listeners.delete(handler);
|
|
198
|
+
controller = undefined;
|
|
199
|
+
onConfirm();
|
|
200
|
+
};
|
|
201
|
+
listeners.add(handler);
|
|
202
|
+
controller = {
|
|
203
|
+
abort() {
|
|
204
|
+
listeners.delete(handler);
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
},
|
|
208
|
+
confirm() {
|
|
209
|
+
for (const handler of [...listeners]) {
|
|
210
|
+
handler();
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
dismiss() {
|
|
214
|
+
controller?.abort();
|
|
215
|
+
controller = undefined;
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
test('confirming answers only the question on screen', () => {
|
|
221
|
+
const confirm = makeConfirm();
|
|
222
|
+
const answered: string[] = [];
|
|
223
|
+
// The first question is dismissed without Cancel, the way the modal's own
|
|
224
|
+
// close button does it.
|
|
225
|
+
confirm.ask(() => answered.push('first'));
|
|
226
|
+
confirm.ask(() => answered.push('second'));
|
|
227
|
+
confirm.confirm();
|
|
228
|
+
assert.deepEqual(answered, ['second']);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('confirming twice does not repeat the previous answer', () => {
|
|
232
|
+
const confirm = makeConfirm();
|
|
233
|
+
const answered: string[] = [];
|
|
234
|
+
confirm.ask(() => answered.push('a'));
|
|
235
|
+
confirm.confirm();
|
|
236
|
+
confirm.ask(() => answered.push('b'));
|
|
237
|
+
confirm.confirm();
|
|
238
|
+
assert.deepEqual(answered, ['a', 'b']);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('dismissing leaves nothing to answer', () => {
|
|
242
|
+
const confirm = makeConfirm();
|
|
243
|
+
const answered: string[] = [];
|
|
244
|
+
confirm.ask(() => answered.push('a'));
|
|
245
|
+
confirm.dismiss();
|
|
246
|
+
confirm.confirm();
|
|
247
|
+
assert.deepEqual(answered, []);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
/* Untitled decks ---------------------------------------------------------- */
|
|
251
|
+
|
|
252
|
+
test('a new deck is called Untitled and takes the next free id', () => {
|
|
253
|
+
const first = makeDeck('Untitled', [], NOW);
|
|
254
|
+
assert.equal(first.id, 'untitled');
|
|
255
|
+
assert.equal(first.title, 'Untitled');
|
|
256
|
+
|
|
257
|
+
const second = makeDeck('Untitled', ['untitled'], NOW);
|
|
258
|
+
assert.equal(second.id, 'untitled-2');
|
|
259
|
+
|
|
260
|
+
const third = makeDeck('Untitled', ['untitled', 'untitled-2'], NOW);
|
|
261
|
+
assert.equal(third.id, 'untitled-3');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('renaming a deck does not move its id', () => {
|
|
265
|
+
// The id is generated once, at creation; a later title is only a label.
|
|
266
|
+
const deck = makeDeck('Untitled', [], NOW);
|
|
267
|
+
const renamed = {...deck, title: 'Three levels of strictness'};
|
|
268
|
+
assert.equal(renamed.id, 'untitled');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('staleStarters finds untouched copies seeded under a retired key', () => {
|
|
272
|
+
const retired = [{starter: 'old', replacedBy: 'new', title: 'Old title'}];
|
|
273
|
+
const starters = [deck('new', {starter: 'new', title: 'New title'})];
|
|
274
|
+
const untouched = deck('old', {starter: 'old', title: 'Old title'});
|
|
275
|
+
const renamed = deck('old', {starter: 'old', title: 'My deck'});
|
|
276
|
+
const edited = deck('old', {
|
|
277
|
+
starter: 'old',
|
|
278
|
+
title: 'Old title',
|
|
279
|
+
slides: [makeSlide({id: 'slide-1', title: 'Slide 1', html: 'changed'})],
|
|
280
|
+
});
|
|
281
|
+
const unrelated = deck('mine');
|
|
282
|
+
|
|
283
|
+
assert.deepEqual(
|
|
284
|
+
staleStarters([untouched, renamed, edited, unrelated], retired, starters),
|
|
285
|
+
[untouched],
|
|
286
|
+
);
|
|
287
|
+
assert.deepEqual(staleStarters([untouched], retired, []), []);
|
|
288
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drives the editor pane: the tabs layout, the side-by-side columns, and which
|
|
3
|
+
* columns are collapsed.
|
|
4
|
+
*/
|
|
5
|
+
import type {EditorLayout, PaneId} from './editors';
|
|
6
|
+
import {
|
|
7
|
+
PANE_FILES,
|
|
8
|
+
PANE_IDS,
|
|
9
|
+
initialLayout,
|
|
10
|
+
otherLayout,
|
|
11
|
+
readCollapsedPanes,
|
|
12
|
+
writeCollapsedPanes,
|
|
13
|
+
writeLayout,
|
|
14
|
+
} from './editors';
|
|
15
|
+
import {DEFAULTS} from './settings';
|
|
16
|
+
|
|
17
|
+
/** The elements that make up one column. */
|
|
18
|
+
interface ColumnElements {
|
|
19
|
+
column: HTMLElement;
|
|
20
|
+
label: HTMLElement;
|
|
21
|
+
collapse: HTMLElement;
|
|
22
|
+
editor: HTMLElement;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Shows one layout at a time and remembers what the presenter collapsed. */
|
|
26
|
+
export class EditorPanes {
|
|
27
|
+
private readonly app: HTMLElement;
|
|
28
|
+
private readonly tabsLayout: HTMLElement;
|
|
29
|
+
private readonly columnsLayout: HTMLElement;
|
|
30
|
+
private readonly tabsEditor: HTMLElement;
|
|
31
|
+
private readonly columns: Record<PaneId, ColumnElements>;
|
|
32
|
+
private readonly onLayoutChange: (layout: EditorLayout) => void;
|
|
33
|
+
|
|
34
|
+
private currentLayout: EditorLayout;
|
|
35
|
+
private collapsed: Set<PaneId>;
|
|
36
|
+
|
|
37
|
+
constructor(onLayoutChange: (layout: EditorLayout) => void = () => {}) {
|
|
38
|
+
this.onLayoutChange = onLayoutChange;
|
|
39
|
+
this.app = required('#app');
|
|
40
|
+
this.tabsLayout = required('#tabs-layout');
|
|
41
|
+
this.columnsLayout = required('#columns-layout');
|
|
42
|
+
this.tabsEditor = required('#file-editor');
|
|
43
|
+
this.columns = Object.fromEntries(
|
|
44
|
+
PANE_IDS.map((pane) => [
|
|
45
|
+
pane,
|
|
46
|
+
{
|
|
47
|
+
column: required(`#column-${pane}`),
|
|
48
|
+
label: required(`#column-${pane}-label`),
|
|
49
|
+
collapse: required(`#column-${pane}-collapse`),
|
|
50
|
+
editor: required(`#column-${pane}-editor`),
|
|
51
|
+
},
|
|
52
|
+
]),
|
|
53
|
+
) as Record<PaneId, ColumnElements>;
|
|
54
|
+
|
|
55
|
+
this.currentLayout = initialLayout();
|
|
56
|
+
this.collapsed = readCollapsedPanes();
|
|
57
|
+
this.bind();
|
|
58
|
+
this.render();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The layout on screen. */
|
|
62
|
+
get layout(): EditorLayout {
|
|
63
|
+
return this.currentLayout;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Reports whether a column is collapsed. */
|
|
67
|
+
isCollapsed(pane: PaneId): boolean {
|
|
68
|
+
return this.collapsed.has(pane);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Switches between the tabs layout and the columns layout. */
|
|
72
|
+
setLayout(layout: EditorLayout, persist = true): void {
|
|
73
|
+
if (layout === this.currentLayout) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
this.currentLayout = layout;
|
|
77
|
+
if (persist) {
|
|
78
|
+
writeLayout(layout);
|
|
79
|
+
}
|
|
80
|
+
this.render();
|
|
81
|
+
this.onLayoutChange(layout);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Flips to the other layout. */
|
|
85
|
+
toggleLayout(): void {
|
|
86
|
+
this.setLayout(otherLayout(this.currentLayout));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Collapses a column, or brings it back. */
|
|
90
|
+
togglePane(pane: PaneId): void {
|
|
91
|
+
if (this.collapsed.has(pane)) {
|
|
92
|
+
this.collapsed.delete(pane);
|
|
93
|
+
} else {
|
|
94
|
+
this.collapsed.add(pane);
|
|
95
|
+
}
|
|
96
|
+
writeCollapsedPanes(this.collapsed);
|
|
97
|
+
this.render();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Applies a slide's `editors` field.
|
|
102
|
+
*
|
|
103
|
+
* `null` means the slide said nothing about the columns, so whatever is on
|
|
104
|
+
* screen stays.
|
|
105
|
+
*/
|
|
106
|
+
applyCollapsed(collapsed: Set<PaneId> | null): void {
|
|
107
|
+
if (collapsed === null) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
this.collapsed = new Set(collapsed);
|
|
111
|
+
writeCollapsedPanes(this.collapsed);
|
|
112
|
+
this.render();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Returns to the tabs layout with every column expanded. */
|
|
116
|
+
reset(): void {
|
|
117
|
+
this.collapsed = new Set();
|
|
118
|
+
writeCollapsedPanes(this.collapsed);
|
|
119
|
+
if (this.currentLayout === DEFAULTS.layout) {
|
|
120
|
+
this.render();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
this.setLayout(DEFAULTS.layout);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private bind(): void {
|
|
127
|
+
for (const pane of PANE_IDS) {
|
|
128
|
+
const {label, collapse} = this.columns[pane];
|
|
129
|
+
label.addEventListener('click', () => this.togglePane(pane));
|
|
130
|
+
collapse.addEventListener('nys-click', () => this.togglePane(pane));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private render(): void {
|
|
135
|
+
const columns = this.currentLayout === 'columns';
|
|
136
|
+
this.app.classList.toggle('app--columns', columns);
|
|
137
|
+
this.tabsLayout.hidden = columns;
|
|
138
|
+
this.columnsLayout.hidden = !columns;
|
|
139
|
+
|
|
140
|
+
for (const pane of PANE_IDS) {
|
|
141
|
+
const {column, label, collapse} = this.columns[pane];
|
|
142
|
+
const isCollapsed = this.collapsed.has(pane);
|
|
143
|
+
const {label: name} = PANE_FILES[pane];
|
|
144
|
+
column.classList.toggle('column--collapsed', isCollapsed);
|
|
145
|
+
label.setAttribute('aria-expanded', String(!isCollapsed));
|
|
146
|
+
label.title = isCollapsed ? `Expand ${name}` : `Collapse ${name}`;
|
|
147
|
+
collapse.setAttribute('label', `Collapse ${name}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
this.refreshVisibleEditors();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Makes the editors that just became visible re-read their file, and stops
|
|
155
|
+
* the ones that are off screen from writing anything back.
|
|
156
|
+
*
|
|
157
|
+
* Both layouts stay mounted, so a hidden editor still holds the document it
|
|
158
|
+
* had when it was last seen. `project.editFile` does not fire
|
|
159
|
+
* `filesChanged`, so it never learns about edits made in the other layout,
|
|
160
|
+
* and on its next render it would write that stale text back over them.
|
|
161
|
+
* Marking it read-only keeps its CodeMirror from emitting changes at all.
|
|
162
|
+
*/
|
|
163
|
+
private refreshVisibleEditors(): void {
|
|
164
|
+
const columns = this.currentLayout === 'columns';
|
|
165
|
+
const visible = new Set<HTMLElement>(
|
|
166
|
+
columns
|
|
167
|
+
? PANE_IDS.filter((pane) => !this.collapsed.has(pane)).map(
|
|
168
|
+
(pane) => this.columns[pane].editor,
|
|
169
|
+
)
|
|
170
|
+
: [this.tabsEditor],
|
|
171
|
+
);
|
|
172
|
+
const all = [this.tabsEditor, ...PANE_IDS.map((pane) => this.columns[pane].editor)];
|
|
173
|
+
for (const editor of all) {
|
|
174
|
+
(editor as HTMLElement & {readonly?: boolean}).readonly = !visible.has(editor);
|
|
175
|
+
}
|
|
176
|
+
requestAnimationFrame(() => {
|
|
177
|
+
for (const editor of visible) {
|
|
178
|
+
(editor as HTMLElement & {requestUpdate?: () => void}).requestUpdate?.();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function required<T extends HTMLElement = HTMLElement>(selector: string): T {
|
|
185
|
+
const element = document.querySelector<T>(selector);
|
|
186
|
+
if (!element) {
|
|
187
|
+
throw new Error(`The page is missing the element "${selector}".`);
|
|
188
|
+
}
|
|
189
|
+
return element;
|
|
190
|
+
}
|
package/src/editors.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two editor layouts and the state of the side-by-side columns.
|
|
3
|
+
*
|
|
4
|
+
* `tabs` shows one file at a time behind a tab bar. `columns` shows all three
|
|
5
|
+
* files next to each other, and any column can be collapsed to a narrow strip.
|
|
6
|
+
*/
|
|
7
|
+
// The explicit extension lets `node --test` load these modules directly, the
|
|
8
|
+
// same way `src/state.test.ts` imports them.
|
|
9
|
+
import type {PaneId} from './preset-schema.ts';
|
|
10
|
+
import {PANE_IDS} from './preset-schema.ts';
|
|
11
|
+
import {
|
|
12
|
+
DEFAULTS,
|
|
13
|
+
STORAGE_KEYS,
|
|
14
|
+
readCollapsedPanes,
|
|
15
|
+
readKey,
|
|
16
|
+
writeCollapsedPanes,
|
|
17
|
+
writeKey,
|
|
18
|
+
} from './settings.ts';
|
|
19
|
+
|
|
20
|
+
export {readCollapsedPanes, writeCollapsedPanes};
|
|
21
|
+
|
|
22
|
+
export type {PaneId};
|
|
23
|
+
export {PANE_IDS};
|
|
24
|
+
|
|
25
|
+
/** How the editors are arranged. */
|
|
26
|
+
export type EditorLayout = 'tabs' | 'columns';
|
|
27
|
+
|
|
28
|
+
/** The query parameter that picks the layout, as in `?editors=columns`. */
|
|
29
|
+
export const EDITORS_PARAM = 'editors';
|
|
30
|
+
|
|
31
|
+
/** The project file and tab label behind each column. */
|
|
32
|
+
export const PANE_FILES: Record<PaneId, {file: string; label: string}> = {
|
|
33
|
+
html: {file: 'snippet.html', label: 'HTML'},
|
|
34
|
+
css: {file: 'styles.css', label: 'CSS'},
|
|
35
|
+
js: {file: 'script.js', label: 'JS'},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Reads `?editors=` from a query string. Returns `null` when it isn't set. */
|
|
39
|
+
export function readLayoutParam(search: string): EditorLayout | null {
|
|
40
|
+
const value = new URLSearchParams(search).get(EDITORS_PARAM);
|
|
41
|
+
return value === 'columns' || value === 'tabs' ? value : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Returns the other layout. */
|
|
45
|
+
export function otherLayout(layout: EditorLayout): EditorLayout {
|
|
46
|
+
return layout === 'columns' ? 'tabs' : 'columns';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Returns the URL with `?editors=` set to the given layout. */
|
|
50
|
+
export function layoutUrl(layout: EditorLayout, href: string): string {
|
|
51
|
+
const url = new URL(href);
|
|
52
|
+
url.searchParams.set(EDITORS_PARAM, layout);
|
|
53
|
+
return url.toString();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Turns a preset's `editors` field into the set of columns to collapse.
|
|
58
|
+
*
|
|
59
|
+
* Every column the preset does not list is collapsed, so a slide that names
|
|
60
|
+
* `["html", "css"]` puts JavaScript out of the way. A preset that leaves the
|
|
61
|
+
* field out returns `null`, which means "keep whatever is on screen".
|
|
62
|
+
*/
|
|
63
|
+
export function collapsedForPreset(editors: PaneId[] | null): Set<PaneId> | null {
|
|
64
|
+
if (editors === null) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return new Set(PANE_IDS.filter((pane) => !editors.includes(pane)));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Picks the layout for this visit: the URL wins, then the remembered choice,
|
|
72
|
+
* then the side-by-side columns.
|
|
73
|
+
*
|
|
74
|
+
* A layout chosen before is kept, so changing the default does not move
|
|
75
|
+
* anyone's editors on them.
|
|
76
|
+
*/
|
|
77
|
+
export function initialLayout(search: string = window.location.search): EditorLayout {
|
|
78
|
+
const fromUrl = readLayoutParam(search);
|
|
79
|
+
if (fromUrl) {
|
|
80
|
+
return fromUrl;
|
|
81
|
+
}
|
|
82
|
+
const stored = readKey(STORAGE_KEYS.layout);
|
|
83
|
+
if (stored === 'columns' || stored === 'tabs') {
|
|
84
|
+
return stored;
|
|
85
|
+
}
|
|
86
|
+
return DEFAULTS.layout;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Remembers the layout for the next visit. */
|
|
90
|
+
export function writeLayout(layout: EditorLayout): void {
|
|
91
|
+
writeKey(STORAGE_KEYS.layout, layout);
|
|
92
|
+
}
|