@dustfeather/deckrun 2.0.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/dist/editor.js ADDED
@@ -0,0 +1,3916 @@
1
+ import { RESET_CSS } from "./generate.js";
2
+ import { DEFAULT_THEME, decorOf, findFont, fontSummaries, googleFontsHref, resolveThemeName, themeSummaries, themeSwitchableCss, } from "./themes.js";
3
+ import { SNIPPETS, SNIPPET_GROUPS, TIPS, WELCOME_DECK, } from "./editor-content.js";
4
+ import { PREVIEW_WIDTH, PREVIEW_HEIGHT } from "./preview.js";
5
+ import { DEFAULT_TEMPLATE, DEFAULT_TRANSITION, resolveTemplateName, resolveTransitionName, templateSummaries, transitionSummaries, } from "./presentation-options.js";
6
+ import { HIGHLIGHT_RUNTIME } from "./highlights.js";
7
+ function bootstrapJson(theme, fonts, template, transition, file, token) {
8
+ const payload = {
9
+ token,
10
+ file,
11
+ theme,
12
+ themes: themeSummaries(),
13
+ fonts,
14
+ faces: fontSummaries(),
15
+ template,
16
+ templates: templateSummaries(),
17
+ transition,
18
+ transitions: transitionSummaries(),
19
+ width: PREVIEW_WIDTH,
20
+ height: PREVIEW_HEIGHT,
21
+ groups: SNIPPET_GROUPS,
22
+ snippets: SNIPPETS,
23
+ tips: TIPS,
24
+ welcome: WELCOME_DECK,
25
+ };
26
+ // Keep the JSON inert inside a <script> block.
27
+ return JSON.stringify(payload).replace(/</g, "\\u003c");
28
+ }
29
+ /** The Markdown editor served when `deckrun` is launched without a file. */
30
+ export function generateEditorHtml(themeInput = DEFAULT_THEME, fontInput = {}, templateInput = DEFAULT_TEMPLATE, transitionInput = DEFAULT_TRANSITION, file = null, token = "") {
31
+ // Normalized here rather than trusted from the caller, the way template,
32
+ // transition and the fonts already are: this is an exported builder and it
33
+ // interpolates the value straight into a `data-theme=` attribute.
34
+ const theme = resolveThemeName(themeInput);
35
+ const template = resolveTemplateName(templateInput);
36
+ const transition = resolveTransitionName(transitionInput);
37
+ const fonts = { head: findFont(fontInput.head), body: findFont(fontInput.body) };
38
+ return `<!DOCTYPE html>
39
+ <html lang="en" data-theme="${theme}" data-decor="${decorOf(theme)}" data-template="${template}" data-transition="${transition}">
40
+ <head>
41
+ <meta charset="UTF-8">
42
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
43
+ <title>deckrun · editor</title>
44
+ <link rel="preconnect" href="https://fonts.googleapis.com">
45
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
46
+ <link href="${googleFontsHref()}" rel="stylesheet">
47
+ <style>
48
+ ${RESET_CSS}
49
+
50
+ ${themeSwitchableCss()}
51
+
52
+ html, body {
53
+ height: 100%;
54
+ overflow: hidden;
55
+ background: var(--crust);
56
+ color: var(--text);
57
+ font-family: var(--font-mono);
58
+ font-size: 13px;
59
+ -webkit-font-smoothing: antialiased;
60
+ -moz-osx-font-smoothing: grayscale;
61
+ }
62
+
63
+ button { font: inherit; color: inherit; background: none; border: none; cursor: pointer; }
64
+ ::selection { background: var(--selection-bg, var(--accent-line, rgba(56, 139, 253, 0.35))); color: var(--selection-text, inherit); }
65
+ ::-moz-selection { background: var(--selection-bg, var(--accent-line, rgba(56, 139, 253, 0.35))); color: var(--selection-text, inherit); }
66
+
67
+ ::-webkit-scrollbar { width: 9px; height: 9px; }
68
+ ::-webkit-scrollbar-track { background: transparent; }
69
+ ::-webkit-scrollbar-thumb { background: var(--surface1); border-radius: 5px; border: 2px solid transparent; background-clip: content-box; }
70
+ ::-webkit-scrollbar-thumb:hover { background: var(--surface2); background-clip: content-box; }
71
+
72
+ #app { display: flex; flex-direction: column; height: 100%; }
73
+
74
+ /* ── Top bar ──────────────────────────────────────────────────────────── */
75
+ /* Ten controls live here and there is no room to spare. The bar wraps rather
76
+ than scrolls: a scrollable top bar would clip the dropdowns that hang out of
77
+ it, since overflow-x also clips vertically. */
78
+ #topbar {
79
+ display: flex;
80
+ flex-wrap: wrap;
81
+ align-items: center;
82
+ gap: 7px 9px;
83
+ min-height: 46px;
84
+ flex: 0 0 auto;
85
+ padding: 7px 12px;
86
+ background: var(--mantle);
87
+ border-bottom: 1px solid var(--surface0);
88
+ }
89
+
90
+ /* The right-hand controls wrap as a block and stay right-aligned, so a narrow
91
+ window never leaves "present" stranded at the far left of a second row. */
92
+ #topbar-right {
93
+ display: flex;
94
+ flex: 1 1 auto;
95
+ flex-wrap: wrap;
96
+ align-items: center;
97
+ justify-content: flex-end;
98
+ gap: 7px 9px;
99
+ min-width: 0;
100
+ }
101
+
102
+ /* Before it wraps, shed what is least load-bearing: the keyboard hints, then
103
+ the two buttons whose action the command palette also carries. */
104
+ #font-label {
105
+ display: inline-block;
106
+ max-width: 136px;
107
+ overflow: hidden;
108
+ text-overflow: ellipsis;
109
+ white-space: nowrap;
110
+ vertical-align: bottom;
111
+ }
112
+
113
+ @media (max-width: 1460px) {
114
+ .btn kbd { display: none; }
115
+ }
116
+
117
+ @media (max-width: 1300px) {
118
+ #font-label { max-width: 76px; }
119
+ #docname { width: 130px; }
120
+ }
121
+
122
+ @media (max-width: 1140px) {
123
+ #btn-new { display: none; }
124
+ }
125
+
126
+ @media (max-width: 1040px) {
127
+ #btn-guide { display: none; }
128
+ }
129
+
130
+ #brand {
131
+ display: flex;
132
+ align-items: center;
133
+ gap: 8px;
134
+ font-weight: 600;
135
+ letter-spacing: 0.01em;
136
+ white-space: nowrap;
137
+ }
138
+
139
+ #brand .caret {
140
+ width: 8px;
141
+ height: 15px;
142
+ background: var(--accent);
143
+ animation: blink 1.1s step-start infinite;
144
+ }
145
+
146
+ @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
147
+
148
+ #docname {
149
+ width: 190px;
150
+ padding: 5px 8px;
151
+ font: inherit;
152
+ color: var(--subtext1);
153
+ background: transparent;
154
+ border: 1px solid transparent;
155
+ border-radius: 6px;
156
+ }
157
+
158
+ #docname:hover { border-color: var(--surface0); }
159
+ #docname:focus { outline: none; border-color: var(--accent); color: var(--text); }
160
+
161
+ /* Both of these sat in the top bar and were the loudest things in it. They
162
+ belong with the other counters, at the bottom. */
163
+ #chip-slides { white-space: nowrap; }
164
+
165
+ #save-state { white-space: nowrap; color: var(--overlay1); }
166
+ #save-state:empty { display: none; }
167
+ #save-state.ok { color: var(--green); }
168
+ #save-state.warn { color: var(--yellow); }
169
+ #save-state.err { color: var(--red); }
170
+
171
+ .spacer { flex: 1 1 auto; }
172
+
173
+ .btn {
174
+ display: inline-flex;
175
+ align-items: center;
176
+ gap: 7px;
177
+ height: 30px;
178
+ padding: 0 11px;
179
+ font-size: 12px;
180
+ color: var(--subtext1);
181
+ border: 1px solid var(--surface0);
182
+ border-radius: 7px;
183
+ white-space: nowrap;
184
+ transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease;
185
+ }
186
+
187
+ .btn:hover { border-color: var(--accent); color: var(--text); background: var(--accent-soft); }
188
+ .btn kbd { font: inherit; font-size: 10px; color: var(--overlay0); }
189
+
190
+ /* ── Font menu ────────────────────────────────────────────────────────── */
191
+ .menu__pop--font { min-width: 452px; padding: 0; }
192
+
193
+ #ff-cols { display: flex; }
194
+ .ff-col { flex: 1 1 0; min-width: 0; padding: 7px; }
195
+ .ff-col + .ff-col { border-left: 1px solid var(--surface0); }
196
+
197
+ .ff-col__title {
198
+ padding: 4px 8px 7px;
199
+ font-size: 9px;
200
+ letter-spacing: 0.16em;
201
+ text-transform: uppercase;
202
+ color: var(--overlay0);
203
+ }
204
+
205
+ .ff-list { max-height: 58vh; overflow-y: auto; }
206
+
207
+ .ff-row {
208
+ display: block;
209
+ width: 100%;
210
+ padding: 5px 8px;
211
+ border-radius: 6px;
212
+ border-left: 2px solid transparent;
213
+ text-align: left;
214
+ }
215
+
216
+ .ff-row:hover { background: var(--surface-soft); border-left-color: var(--accent); }
217
+ .ff-row.is-on { border-left-color: var(--accent); }
218
+ .ff-row.is-on .ff-row__name { color: var(--accent); }
219
+
220
+ /* Each row is set in the face it offers, which is the only honest preview. */
221
+ .ff-row__name {
222
+ display: block;
223
+ font-size: 13.5px;
224
+ line-height: 1.35;
225
+ color: var(--text);
226
+ white-space: nowrap;
227
+ overflow: hidden;
228
+ text-overflow: ellipsis;
229
+ }
230
+
231
+ .ff-row--auto .ff-row__name { font-family: var(--font-mono); font-size: 12px; color: var(--subtext0); }
232
+
233
+ .ff-foot {
234
+ padding: 8px 14px;
235
+ border-top: 1px solid var(--surface0);
236
+ font-size: 10px;
237
+ color: var(--overlay0);
238
+ }
239
+
240
+ /* The swatch on the theme button, so the current palette is visible at rest. */
241
+ .th-dot {
242
+ width: 9px;
243
+ height: 9px;
244
+ border-radius: 3px;
245
+ flex: 0 0 auto;
246
+ background: var(--gradient);
247
+ transform: rotate(45deg);
248
+ }
249
+ .btn--icon { padding: 0 9px; }
250
+
251
+ .btn--primary {
252
+ color: var(--crust);
253
+ font-weight: 600;
254
+ border-color: transparent;
255
+ background: var(--gradient);
256
+ }
257
+
258
+ /* The gradient has to be restated: .btn:hover is a class plus a pseudo-class,
259
+ so its faint tint outranks the plain .btn--primary background and the button
260
+ would drop to near-black text on a barely-there surface. */
261
+ .btn--primary:hover {
262
+ background: var(--gradient);
263
+ filter: brightness(1.12);
264
+ border-color: transparent;
265
+ color: var(--crust);
266
+ }
267
+ .btn--primary kbd { color: var(--crust); opacity: 0.7; }
268
+
269
+ /* ── Panes ────────────────────────────────────────────────────────────── */
270
+ #panes { flex: 1 1 auto; display: flex; min-height: 0; }
271
+
272
+ #pane-edit {
273
+ position: relative;
274
+ display: flex;
275
+ flex-direction: column;
276
+ min-width: 240px;
277
+ background: var(--mantle);
278
+ }
279
+
280
+ #divider { flex: 0 0 7px; position: relative; cursor: col-resize; background: var(--crust); }
281
+ #divider::after {
282
+ content: '';
283
+ position: absolute;
284
+ inset: 0 3px;
285
+ background: var(--surface0);
286
+ transition: background 0.15s ease;
287
+ }
288
+ #divider:hover::after, #divider.is-dragging::after { background: var(--accent); }
289
+
290
+ #pane-prev {
291
+ flex: 1 1 auto;
292
+ display: flex;
293
+ flex-direction: column;
294
+ min-width: 260px;
295
+ background: var(--crust);
296
+ }
297
+
298
+ /* ── Editor surface ───────────────────────────────────────────────────── */
299
+ #edit-wrap { position: relative; flex: 1 1 auto; min-height: 0; }
300
+
301
+ #hl, #src, #measure {
302
+ position: absolute;
303
+ inset: 0;
304
+ margin: 0;
305
+ padding: 18px 20px;
306
+ font-family: var(--font-mono);
307
+ font-size: 13.5px;
308
+ font-weight: 400;
309
+ font-style: normal;
310
+ line-height: 1.75;
311
+ letter-spacing: 0;
312
+ word-spacing: 0;
313
+ white-space: pre-wrap;
314
+ overflow-wrap: break-word;
315
+ word-break: break-word;
316
+ tab-size: 2;
317
+ border: 0;
318
+ box-sizing: border-box;
319
+ background: transparent;
320
+ scrollbar-width: none;
321
+ -ms-overflow-style: none;
322
+ }
323
+
324
+ #hl::-webkit-scrollbar, #src::-webkit-scrollbar {
325
+ display: none;
326
+ }
327
+
328
+ #hl { z-index: 1; overflow: hidden; pointer-events: none; color: var(--subtext0); }
329
+ #measure { z-index: 0; visibility: hidden; overflow: hidden; }
330
+
331
+ #src {
332
+ z-index: 2;
333
+ overflow-y: auto;
334
+ overflow-x: hidden;
335
+ color: transparent;
336
+ caret-color: var(--accent);
337
+ resize: none;
338
+ outline: none;
339
+ }
340
+
341
+ #src::selection { background: var(--selection-bg, var(--accent-line, rgba(56, 139, 253, 0.35))); color: transparent; }
342
+ #src::-moz-selection { background: var(--selection-bg, var(--accent-line, rgba(56, 139, 253, 0.35))); color: transparent; }
343
+
344
+ /* Markdown tokens — color only to guarantee pixel-identical alignment */
345
+ .t-h1 { color: var(--accent); }
346
+ .t-h2 { color: var(--accent-2); }
347
+ .t-h3 { color: var(--accent-3); }
348
+ .t-h4 { color: var(--teal); }
349
+ .t-strong { color: var(--peach); }
350
+ .t-em { color: var(--subtext1); }
351
+ .t-code { color: var(--green); }
352
+ .t-fence { color: var(--overlay1); }
353
+ .t-codeline { color: var(--subtext1); }
354
+ .t-quote { color: var(--subtext0); }
355
+ .t-marker { color: var(--accent); }
356
+ .t-link { color: var(--blue); }
357
+ .t-url { color: var(--overlay0); }
358
+ .t-img { color: var(--sapphire); }
359
+ .t-dir { color: var(--yellow); }
360
+ .t-note { color: var(--overlay0); }
361
+ .t-html { color: var(--pink); }
362
+ .t-table { color: var(--lavender); }
363
+ .t-sep { color: var(--accent); }
364
+
365
+ /* ── Drop target ──────────────────────────────────────────────────────── */
366
+ #pane-edit.is-dropping::after {
367
+ content: 'drop a Markdown or HTML file to open it';
368
+ position: absolute;
369
+ inset: 10px;
370
+ z-index: 9;
371
+ display: flex;
372
+ align-items: center;
373
+ justify-content: center;
374
+ border: 2px dashed var(--accent);
375
+ border-radius: 12px;
376
+ background: var(--accent-soft);
377
+ color: var(--accent);
378
+ letter-spacing: 0.05em;
379
+ pointer-events: none;
380
+ }
381
+
382
+ /* ── Preview ──────────────────────────────────────────────────────────── */
383
+ #prev-head {
384
+ display: flex;
385
+ align-items: center;
386
+ gap: 10px;
387
+ height: 34px;
388
+ flex: 0 0 34px;
389
+ padding: 0 12px;
390
+ border-bottom: 1px solid var(--surface0);
391
+ font-size: 11px;
392
+ color: var(--overlay1);
393
+ }
394
+
395
+ #overflow-badge {
396
+ display: none;
397
+ align-items: center;
398
+ gap: 5px;
399
+ padding: 2px 7px;
400
+ border: 1px solid var(--yellow);
401
+ border-radius: 999px;
402
+ color: var(--yellow);
403
+ background: var(--surface-soft);
404
+ font-size: 9px;
405
+ letter-spacing: 0.1em;
406
+ text-transform: uppercase;
407
+ }
408
+
409
+ #overflow-badge.is-on { display: inline-flex; }
410
+
411
+ /* ── Dropdown menu ────────────────────────────────────────────────────── */
412
+ .menu { position: relative; display: inline-flex; }
413
+ .menu__chev { font-size: 9px; color: var(--overlay0); }
414
+ .menu.is-open > .btn { border-color: var(--accent); color: var(--text); background: var(--accent-soft); }
415
+
416
+ .menu__pop {
417
+ position: absolute;
418
+ top: calc(100% + 6px);
419
+ right: 0;
420
+ z-index: 45;
421
+ min-width: 316px;
422
+ padding: 5px;
423
+ display: none;
424
+ flex-direction: column;
425
+ background: var(--mantle);
426
+ border: 1px solid var(--surface1);
427
+ border-radius: 10px;
428
+ box-shadow: var(--shadow-lg);
429
+ }
430
+
431
+ .menu.is-open .menu__pop { display: flex; }
432
+
433
+ .menu__pop button {
434
+ display: flex;
435
+ align-items: center;
436
+ gap: 10px;
437
+ padding: 8px 10px;
438
+ border-radius: 7px;
439
+ border-left: 2px solid transparent;
440
+ text-align: left;
441
+ }
442
+
443
+ .menu__pop button:hover, .menu__pop button.is-sel {
444
+ background: var(--surface-soft);
445
+ border-left-color: var(--accent);
446
+ }
447
+
448
+ /* A fixed name column keeps the three rows on one grid. */
449
+ .menu__name { flex: 0 0 74px; font-size: 12.5px; color: var(--text); }
450
+ .menu__hint { flex: 1 1 auto; font-size: 11px; color: var(--overlay1); white-space: nowrap; }
451
+ .menu__pop kbd {
452
+ flex: 0 0 auto;
453
+ margin-left: auto;
454
+ font: inherit;
455
+ font-size: 10px;
456
+ color: var(--overlay0);
457
+ white-space: nowrap;
458
+ }
459
+
460
+ .menu__pop--template { min-width: 390px; padding: 7px; }
461
+ .menu-section + .menu-section { margin-top: 6px; padding-top: 7px; border-top: 1px solid var(--surface0); }
462
+ .menu-section__title {
463
+ padding: 2px 10px 5px;
464
+ font-size: 9px;
465
+ letter-spacing: 0.16em;
466
+ text-transform: uppercase;
467
+ color: var(--overlay0);
468
+ }
469
+ .menu__pop--template button.is-on {
470
+ background: var(--accent-soft);
471
+ border-left-color: var(--accent);
472
+ }
473
+ .menu__pop--template button.is-on .menu__name { color: var(--accent); }
474
+
475
+ .seg { display: flex; border: 1px solid var(--surface0); border-radius: 7px; overflow: hidden; }
476
+ .seg button { font-size: 11px; padding: 3px 10px; color: var(--overlay1); }
477
+ .seg button.is-on { background: var(--surface0); color: var(--text); }
478
+
479
+ .step { font-size: 13px; color: var(--overlay1); padding: 0 5px; }
480
+ .step:hover { color: var(--text); }
481
+ .step[disabled] { opacity: 0.3; cursor: default; }
482
+
483
+ #stage {
484
+ position: relative;
485
+ flex: 1 1 auto;
486
+ min-height: 0;
487
+ display: flex;
488
+ align-items: center;
489
+ justify-content: center;
490
+ padding: 16px;
491
+ overflow: hidden;
492
+ }
493
+
494
+ #frame-box { position: relative; }
495
+
496
+ #frame {
497
+ position: absolute;
498
+ top: 0;
499
+ left: 0;
500
+ width: ${PREVIEW_WIDTH}px;
501
+ height: ${PREVIEW_HEIGHT}px;
502
+ border: 0;
503
+ transform-origin: top left;
504
+ background: var(--base);
505
+ }
506
+
507
+ #frame-box.is-single {
508
+ border-radius: 10px;
509
+ overflow: hidden;
510
+ border: 1px solid var(--surface0);
511
+ box-shadow: var(--shadow-lg);
512
+ }
513
+
514
+ #notes {
515
+ flex: 0 0 auto;
516
+ max-height: 24%;
517
+ overflow: auto;
518
+ padding: 9px 12px 11px;
519
+ border-top: 1px solid var(--surface0);
520
+ background: var(--mantle);
521
+ display: none;
522
+ }
523
+
524
+ #notes.is-on { display: block; }
525
+ #notes__label {
526
+ font-size: 10px;
527
+ letter-spacing: 0.14em;
528
+ text-transform: uppercase;
529
+ color: var(--overlay0);
530
+ margin-bottom: 4px;
531
+ }
532
+ #notes__text { font-size: 12.5px; line-height: 1.65; color: var(--subtext0); white-space: pre-wrap; }
533
+
534
+ /* ── Status bar ───────────────────────────────────────────────────────── */
535
+ #statusbar {
536
+ display: flex;
537
+ align-items: center;
538
+ gap: 12px;
539
+ height: 28px;
540
+ flex: 0 0 28px;
541
+ padding: 0 12px;
542
+ background: var(--mantle);
543
+ border-top: 1px solid var(--surface0);
544
+ font-size: 11px;
545
+ color: var(--overlay0);
546
+ }
547
+
548
+ #statusbar > span { flex: 0 0 auto; }
549
+ #tipbar { flex: 1 1 auto !important; display: flex; align-items: center; gap: 8px; min-width: 0; }
550
+ #tipbar .tag {
551
+ flex: 0 0 auto;
552
+ color: var(--accent);
553
+ letter-spacing: 0.14em;
554
+ text-transform: uppercase;
555
+ font-size: 9px;
556
+ }
557
+ #tip-text { color: var(--subtext0); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
558
+ #tipbar button { color: var(--overlay0); padding: 0 3px; }
559
+ #tipbar button:hover { color: var(--text); }
560
+
561
+ #follow-chip {
562
+ flex: 0 0 auto;
563
+ border: 1px solid var(--surface0);
564
+ border-radius: 5px;
565
+ padding: 1px 7px;
566
+ color: var(--accent2);
567
+ cursor: pointer;
568
+ user-select: none;
569
+ }
570
+ #follow-chip:hover { border-color: var(--accent2); }
571
+ #follow-chip b { font-weight: 600; }
572
+ #follow-chip.hidden { display: none; }
573
+
574
+ /* ── Command palette ──────────────────────────────────────────────────── */
575
+ #palette, #guide, #library { position: fixed; inset: 0; z-index: 40; display: none; }
576
+ #palette.is-on, #guide.is-on, #library.is-on { display: block; }
577
+ .backdrop { position: absolute; inset: 0; background: var(--scrim); backdrop-filter: blur(3px); }
578
+
579
+ #pal-box, #lib-box {
580
+ position: relative;
581
+ width: min(690px, 92vw);
582
+ margin: 9vh auto 0;
583
+ background: var(--mantle);
584
+ border: 1px solid var(--surface1);
585
+ border-radius: 14px;
586
+ box-shadow: var(--shadow-lg);
587
+ overflow: hidden;
588
+ }
589
+
590
+ #pal-input, #lib-head {
591
+ width: 100%;
592
+ padding: 15px 18px;
593
+ font: inherit;
594
+ font-size: 14px;
595
+ color: var(--text);
596
+ background: transparent;
597
+ border: 0;
598
+ border-bottom: 1px solid var(--surface0);
599
+ outline: none;
600
+ }
601
+
602
+ #pal-input::placeholder { color: var(--overlay0); }
603
+ #pal-list, #lib-list { max-height: 54vh; overflow-y: auto; padding: 6px; }
604
+
605
+ #lib-head {
606
+ display: flex;
607
+ align-items: center;
608
+ gap: 10px;
609
+ font-size: 13px;
610
+ color: var(--text);
611
+ }
612
+ #lib-head span { flex: 1 1 auto; }
613
+ #lib-head button {
614
+ font-size: 11px;
615
+ color: var(--subtext0);
616
+ border: 1px solid var(--surface1);
617
+ border-radius: 6px;
618
+ padding: 4px 9px;
619
+ }
620
+ #lib-head button:hover { border-color: var(--accent); color: var(--text); background: var(--accent-soft); }
621
+
622
+ .lib-row {
623
+ display: flex;
624
+ align-items: center;
625
+ gap: 12px;
626
+ padding: 9px 12px;
627
+ border-radius: 8px;
628
+ border-left: 2px solid transparent;
629
+ cursor: pointer;
630
+ }
631
+
632
+ .lib-row.is-sel { background: var(--surface-soft); border-left-color: var(--accent); }
633
+ .lib-row.is-current .lib-row__name { color: var(--accent-2); }
634
+ .lib-row__main { flex: 1 1 auto; min-width: 0; }
635
+ .lib-row__name {
636
+ color: var(--text);
637
+ font-size: 12.5px;
638
+ overflow: hidden;
639
+ text-overflow: ellipsis;
640
+ white-space: nowrap;
641
+ }
642
+ .lib-row__meta { color: var(--overlay1); font-size: 11px; margin-top: 2px; }
643
+ .lib-row__acts { flex: 0 0 auto; display: flex; gap: 5px; opacity: 0; transition: opacity 0.12s ease; }
644
+ .lib-row:hover .lib-row__acts, .lib-row.is-sel .lib-row__acts { opacity: 1; }
645
+ .lib-row__acts button {
646
+ font-size: 10px;
647
+ color: var(--overlay1);
648
+ border: 1px solid var(--surface1);
649
+ border-radius: 5px;
650
+ padding: 3px 7px;
651
+ }
652
+ .lib-row__acts button:hover { border-color: var(--accent); color: var(--text); }
653
+ .lib-row__acts button.danger:hover { border-color: var(--red); color: var(--red); }
654
+
655
+ .pal-group {
656
+ padding: 11px 12px 5px;
657
+ font-size: 9px;
658
+ letter-spacing: 0.16em;
659
+ text-transform: uppercase;
660
+ color: var(--overlay0);
661
+ }
662
+
663
+ .pal-row {
664
+ display: flex;
665
+ align-items: center;
666
+ gap: 12px;
667
+ padding: 8px 12px;
668
+ border-radius: 8px;
669
+ border-left: 2px solid transparent;
670
+ cursor: pointer;
671
+ }
672
+
673
+ .pal-row.is-sel { background: var(--surface-soft); border-left-color: var(--accent); }
674
+ .pal-row__main { flex: 1 1 auto; min-width: 0; }
675
+ .pal-row__label { color: var(--text); font-size: 12.5px; }
676
+ .pal-row__hint { color: var(--overlay1); font-size: 11px; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
677
+ .pal-row__syntax {
678
+ flex: 0 0 auto;
679
+ max-width: 210px;
680
+ overflow: hidden;
681
+ text-overflow: ellipsis;
682
+ white-space: nowrap;
683
+ font-size: 11px;
684
+ color: var(--green);
685
+ background: var(--surface-soft);
686
+ border-radius: 5px;
687
+ padding: 3px 7px;
688
+ }
689
+ .pal-row__keys { flex: 0 0 auto; font-size: 10px; color: var(--overlay0); }
690
+ #pal-foot {
691
+ display: flex;
692
+ gap: 14px;
693
+ padding: 8px 14px;
694
+ border-top: 1px solid var(--surface0);
695
+ font-size: 10px;
696
+ color: var(--overlay0);
697
+ }
698
+
699
+ /* ── Guide drawer ─────────────────────────────────────────────────────── */
700
+ #guide-panel {
701
+ position: absolute;
702
+ top: 0;
703
+ right: 0;
704
+ bottom: 0;
705
+ width: min(540px, 94vw);
706
+ display: flex;
707
+ flex-direction: column;
708
+ background: var(--mantle);
709
+ border-left: 1px solid var(--surface1);
710
+ box-shadow: -22px 0 70px var(--scrim);
711
+ }
712
+
713
+ #guide-head {
714
+ display: flex;
715
+ align-items: center;
716
+ gap: 10px;
717
+ padding: 14px 16px;
718
+ border-bottom: 1px solid var(--surface0);
719
+ }
720
+ #guide-head h2 { font-size: 14px; font-weight: 600; color: var(--text); }
721
+ #guide-head p { font-size: 11px; color: var(--overlay1); margin-top: 3px; }
722
+ #guide-body { flex: 1 1 auto; overflow-y: auto; padding: 14px 16px 30px; }
723
+
724
+ .gsec { margin-bottom: 22px; }
725
+ .gsec > h3 {
726
+ font-size: 9px;
727
+ letter-spacing: 0.18em;
728
+ text-transform: uppercase;
729
+ color: var(--accent);
730
+ margin-bottom: 9px;
731
+ }
732
+
733
+ .gcard {
734
+ border: 1px solid var(--surface0);
735
+ border-radius: 10px;
736
+ padding: 11px 12px;
737
+ margin-bottom: 8px;
738
+ background: var(--base);
739
+ transition: border-color 0.15s ease;
740
+ }
741
+ .gcard:hover { border-color: var(--surface2); }
742
+ .gcard__top { display: flex; align-items: baseline; gap: 10px; }
743
+ .gcard__label { flex: 1 1 auto; color: var(--text); font-size: 12.5px; }
744
+ .gcard__keys { font-size: 10px; color: var(--overlay0); }
745
+ .gcard__hint { font-size: 11.5px; color: var(--overlay1); line-height: 1.6; margin-top: 4px; }
746
+ .gcard__syntax {
747
+ margin-top: 8px;
748
+ font-size: 11px;
749
+ color: var(--green);
750
+ background: var(--crust);
751
+ border: 1px solid var(--surface0);
752
+ border-radius: 6px;
753
+ padding: 7px 9px;
754
+ overflow-x: auto;
755
+ white-space: pre;
756
+ }
757
+ .gcard__ins {
758
+ margin-top: 8px;
759
+ font-size: 11px;
760
+ color: var(--subtext0);
761
+ border: 1px solid var(--surface1);
762
+ border-radius: 6px;
763
+ padding: 4px 9px;
764
+ }
765
+ .gcard__ins:hover { border-color: var(--accent); color: var(--text); background: var(--accent-soft); }
766
+
767
+ /* ── Theme picker ─────────────────────────────────────────────────────── */
768
+ #themes { position: fixed; inset: 0; z-index: 44; display: none; }
769
+ #themes.is-on { display: block; }
770
+
771
+ #th-box {
772
+ position: relative;
773
+ width: min(1120px, 95vw);
774
+ margin: 5vh auto 0;
775
+ background: var(--mantle);
776
+ border: 1px solid var(--surface1);
777
+ border-radius: 16px;
778
+ box-shadow: var(--shadow-lg);
779
+ overflow: hidden;
780
+ }
781
+
782
+ #th-head {
783
+ display: flex;
784
+ align-items: center;
785
+ gap: 12px;
786
+ padding: 14px 18px;
787
+ border-bottom: 1px solid var(--surface0);
788
+ font-size: 13px;
789
+ color: var(--text);
790
+ }
791
+
792
+ #th-head .th-head__sub { flex: 1 1 auto; font-size: 11px; color: var(--overlay1); }
793
+ #th-head .th-head__sub kbd {
794
+ font: inherit;
795
+ font-size: 10px;
796
+ color: var(--subtext0);
797
+ border: 1px solid var(--surface1);
798
+ border-radius: 3px;
799
+ padding: 0 3px;
800
+ }
801
+
802
+ #th-head button {
803
+ font-size: 11px;
804
+ color: var(--subtext0);
805
+ border: 1px solid var(--surface1);
806
+ border-radius: 6px;
807
+ padding: 4px 9px;
808
+ }
809
+ #th-head button:hover { border-color: var(--accent); color: var(--text); background: var(--accent-soft); }
810
+
811
+ #th-list { max-height: 76vh; overflow-y: auto; padding: 14px 16px 18px; }
812
+
813
+ .th-group {
814
+ padding: 4px 2px 8px;
815
+ font-size: 9px;
816
+ letter-spacing: 0.16em;
817
+ text-transform: uppercase;
818
+ color: var(--overlay0);
819
+ }
820
+
821
+ .th-grid {
822
+ display: grid;
823
+ grid-template-columns: repeat(auto-fill, minmax(212px, 1fr));
824
+ gap: 12px;
825
+ margin-bottom: 18px;
826
+ }
827
+
828
+ .th-card {
829
+ display: flex;
830
+ flex-direction: column;
831
+ gap: 0;
832
+ padding: 0;
833
+ overflow: hidden;
834
+ border: 1px solid var(--surface1);
835
+ border-radius: 12px;
836
+ background: var(--base);
837
+ cursor: pointer;
838
+ text-align: left;
839
+ transition: border-color 0.14s ease, transform 0.14s ease, box-shadow 0.14s ease;
840
+ }
841
+
842
+ .th-card:hover, .th-card.is-sel {
843
+ border-color: var(--accent);
844
+ transform: translateY(-2px);
845
+ box-shadow: var(--shadow-md);
846
+ }
847
+
848
+ .th-card.is-current { box-shadow: inset 0 0 0 1px var(--accent); }
849
+
850
+ /* The thumbnail is a real slide in miniature: the theme's own background,
851
+ its own accent, its own faces. Nothing about it is a stand-in. */
852
+ .th-thumb {
853
+ position: relative;
854
+ aspect-ratio: 16 / 9;
855
+ padding: 13px 14px;
856
+ overflow: hidden;
857
+ border-bottom: 1px solid var(--surface0);
858
+ }
859
+
860
+ .th-thumb__glow {
861
+ position: absolute;
862
+ width: 150%;
863
+ height: 150%;
864
+ right: -55%;
865
+ top: -60%;
866
+ border-radius: 50%;
867
+ pointer-events: none;
868
+ }
869
+
870
+ .th-thumb__title {
871
+ position: relative;
872
+ font-size: 15px;
873
+ line-height: 1.1;
874
+ margin-bottom: 6px;
875
+ }
876
+
877
+ .th-thumb__rule { position: relative; width: 34px; height: 2px; border-radius: 2px; margin-bottom: 9px; }
878
+ .th-thumb__line { position: relative; height: 3px; border-radius: 2px; margin-bottom: 5px; }
879
+ .th-thumb__code {
880
+ position: relative;
881
+ margin-top: 8px;
882
+ border-radius: 4px;
883
+ padding: 4px 6px;
884
+ font-size: 8px;
885
+ letter-spacing: 0;
886
+ white-space: nowrap;
887
+ overflow: hidden;
888
+ }
889
+
890
+ .th-meta { padding: 9px 12px 11px; }
891
+ .th-meta__top { display: flex; align-items: baseline; gap: 7px; }
892
+ .th-meta__name { flex: 1 1 auto; font-size: 12.5px; color: var(--text); }
893
+ .th-meta__mood {
894
+ flex: 0 0 auto;
895
+ font-size: 9px;
896
+ letter-spacing: 0.12em;
897
+ text-transform: uppercase;
898
+ color: var(--overlay0);
899
+ }
900
+ .th-meta__blurb {
901
+ font-size: 10.5px;
902
+ line-height: 1.55;
903
+ color: var(--overlay1);
904
+ margin-top: 4px;
905
+ }
906
+
907
+ /* ── Toasts ───────────────────────────────────────────────────────────── */
908
+ #toasts {
909
+ position: fixed;
910
+ right: 16px;
911
+ bottom: 42px;
912
+ z-index: 60;
913
+ display: flex;
914
+ flex-direction: column;
915
+ align-items: flex-end;
916
+ gap: 8px;
917
+ pointer-events: none;
918
+ }
919
+
920
+ .toast {
921
+ display: flex;
922
+ align-items: center;
923
+ gap: 10px;
924
+ max-width: 380px;
925
+ padding: 9px 12px;
926
+ font-size: 12px;
927
+ color: var(--subtext1);
928
+ background: var(--base);
929
+ border: 1px solid var(--surface1);
930
+ border-left: 2px solid var(--teal);
931
+ border-radius: 9px;
932
+ box-shadow: var(--shadow-md);
933
+ pointer-events: all;
934
+ animation: rise 0.18s ease;
935
+ }
936
+
937
+ .toast--warn { border-left-color: var(--yellow); }
938
+ .toast--err { border-left-color: var(--red); }
939
+ .toast button {
940
+ font-size: 11px;
941
+ color: var(--subtext0);
942
+ border: 1px solid var(--surface1);
943
+ border-radius: 6px;
944
+ padding: 3px 8px;
945
+ }
946
+ .toast button:hover { border-color: var(--accent); color: var(--text); }
947
+
948
+ @keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
949
+
950
+ /* ── HTML doc source / preview ───────────────────────────────────────────
951
+ These live inside the same #pane-edit / #pane-prev panes the Markdown
952
+ editor uses, so the divider drag-resize keeps working for free — only the
953
+ inner content swaps, via [data-doc-kind]. */
954
+ #src-html {
955
+ display: none;
956
+ position: absolute;
957
+ inset: 0;
958
+ margin: 0;
959
+ padding: 18px 20px;
960
+ font-family: var(--font-mono);
961
+ font-size: 13.5px;
962
+ line-height: 1.75;
963
+ color: var(--text);
964
+ caret-color: var(--accent);
965
+ background: transparent;
966
+ border: 0;
967
+ outline: none;
968
+ resize: none;
969
+ white-space: pre;
970
+ overflow: auto;
971
+ tab-size: 2;
972
+ }
973
+
974
+ #frame-html {
975
+ display: none;
976
+ flex: 1 1 auto;
977
+ min-height: 0;
978
+ width: 100%;
979
+ border: 0;
980
+ background: #fff;
981
+ }
982
+
983
+ [data-doc-kind="html"] #edit-wrap,
984
+ [data-doc-kind="html"] #nudge,
985
+ [data-doc-kind="html"] #prev-head,
986
+ [data-doc-kind="html"] #stage,
987
+ [data-doc-kind="html"] #notes {
988
+ display: none !important;
989
+ }
990
+
991
+ [data-doc-kind="html"] #src-html,
992
+ [data-doc-kind="html"] #frame-html {
993
+ display: block;
994
+ }
995
+
996
+ [data-doc-kind="html"] #btn-guide,
997
+ [data-doc-kind="html"] #btn-palette,
998
+ [data-doc-kind="html"] #template-wrap {
999
+ display: none !important;
1000
+ }
1001
+
1002
+ /* ── Start screen ─────────────────────────────────────────────────────── */
1003
+ #screen-start { position: fixed; inset: 0; z-index: 55; display: none; }
1004
+ #screen-start.is-on { display: block; }
1005
+
1006
+ #start-box {
1007
+ position: relative;
1008
+ width: min(680px, 92vw);
1009
+ margin: 12vh auto 0;
1010
+ padding: 26px 28px 22px;
1011
+ background: var(--mantle);
1012
+ border: 1px solid var(--surface1);
1013
+ border-radius: 14px;
1014
+ box-shadow: var(--shadow-lg);
1015
+ }
1016
+
1017
+ #start-head { margin-bottom: 18px; }
1018
+ #start-head #brand { font-size: 15px; }
1019
+ #start-head p { margin-top: 8px; font-size: 12.5px; color: var(--overlay1); }
1020
+
1021
+ #start-cards { display: flex; flex-direction: column; gap: 10px; }
1022
+
1023
+ .start-card {
1024
+ display: block;
1025
+ width: 100%;
1026
+ text-align: left;
1027
+ padding: 14px 16px;
1028
+ background: var(--base);
1029
+ border: 1px solid var(--surface0);
1030
+ border-radius: 10px;
1031
+ transition: border-color 0.15s ease, background 0.15s ease;
1032
+ }
1033
+
1034
+ .start-card:hover { border-color: var(--accent); background: var(--accent-soft); }
1035
+ .start-card.is-disabled { opacity: 0.45; pointer-events: none; }
1036
+
1037
+ .start-card__title { display: block; font-size: 13px; font-weight: 600; color: var(--text); }
1038
+ .start-card__desc { display: block; margin-top: 4px; font-size: 11.5px; color: var(--overlay1); }
1039
+
1040
+ .start-card__acts { display: flex; gap: 8px; margin-top: 10px; }
1041
+ .start-card__acts button {
1042
+ padding: 5px 10px;
1043
+ font-size: 11.5px;
1044
+ color: var(--subtext1);
1045
+ border: 1px solid var(--surface1);
1046
+ border-radius: 6px;
1047
+ flex: none;
1048
+ }
1049
+ .start-card__acts button:hover { border-color: var(--accent); color: var(--text); }
1050
+
1051
+ .start-options {
1052
+ margin-top: 12px;
1053
+ padding-top: 11px;
1054
+ border-top: 1px solid var(--surface0);
1055
+ }
1056
+ .start-options__row { display: flex; align-items: center; gap: 8px; margin-top: 7px; }
1057
+ .start-options__label {
1058
+ flex: 0 0 72px;
1059
+ font-size: 9px;
1060
+ letter-spacing: 0.12em;
1061
+ text-transform: uppercase;
1062
+ color: var(--overlay0);
1063
+ }
1064
+ .start-options__choices { display: flex; flex-wrap: wrap; gap: 5px; }
1065
+ .start-choice {
1066
+ padding: 4px 8px;
1067
+ font-size: 10.5px;
1068
+ color: var(--subtext0);
1069
+ border: 1px solid var(--surface1);
1070
+ border-radius: 6px;
1071
+ }
1072
+ .start-choice:hover { color: var(--text); border-color: var(--accent); }
1073
+ .start-choice.is-on { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
1074
+
1075
+ .start-card__url { display: flex; gap: 6px; flex: 1 1 auto; min-width: 0; }
1076
+ .start-card__url input {
1077
+ flex: 1 1 auto;
1078
+ min-width: 0;
1079
+ padding: 5px 8px;
1080
+ font-size: 11.5px;
1081
+ font-family: inherit;
1082
+ color: var(--text);
1083
+ background: var(--mantle);
1084
+ border: 1px solid var(--surface1);
1085
+ border-radius: 6px;
1086
+ }
1087
+ .start-card__url input:focus { border-color: var(--accent); outline: none; }
1088
+ .start-card__url button { flex: none; }
1089
+
1090
+ /* ── Narrow screens ───────────────────────────────────────────────────── */
1091
+ @media (max-width: 900px) {
1092
+ #panes { flex-direction: column; }
1093
+ #pane-edit { flex: 1 1 50%; width: auto !important; min-height: 0; }
1094
+ #pane-prev { flex: 1 1 50%; min-height: 0; }
1095
+ #divider { display: none; }
1096
+ }
1097
+ </style>
1098
+ </head>
1099
+ <body>
1100
+ <div id="app">
1101
+ <header id="topbar">
1102
+ <span id="brand">deckrun<span class="caret"></span></span>
1103
+ <input id="docname" value="deck" spellcheck="false" title="Deck name, also the download filename">
1104
+ <button class="btn" id="btn-decks" title="Switch between the decks in this browser">decks <span id="deck-count">1</span> <kbd>Cmd O</kbd></button>
1105
+ <span class="spacer"></span>
1106
+ <span id="topbar-right">
1107
+ <button class="btn" id="btn-guide" title="Everything you can put on a slide">guide <kbd>Cmd /</kbd></button>
1108
+ <button class="btn" id="btn-palette" title="Insert anything">insert <kbd>Cmd K</kbd></button>
1109
+ <button class="btn" id="btn-new" title="Start a new Markdown deck or HTML doc">new</button>
1110
+ <span class="menu">
1111
+ <button class="btn" id="btn-export" aria-haspopup="true" aria-expanded="false">export <span class="menu__chev">&#9662;</span></button>
1112
+ <div class="menu__pop" id="export-menu">
1113
+ <button data-export="md">
1114
+ <span class="menu__name">Markdown</span>
1115
+ <span class="menu__hint">a plain .md file</span>
1116
+ <kbd>Cmd S</kbd>
1117
+ </button>
1118
+ <button data-export="pdf">
1119
+ <span class="menu__name">PDF</span>
1120
+ <span class="menu__hint">16:9 pages, styling intact</span>
1121
+ <kbd>Cmd Shift S</kbd>
1122
+ </button>
1123
+ <button data-export="html">
1124
+ <span class="menu__name">HTML</span>
1125
+ <span class="menu__hint">one self-contained page</span>
1126
+ <kbd></kbd>
1127
+ </button>
1128
+ </div>
1129
+ </span>
1130
+ <span class="menu" id="template-wrap">
1131
+ <button class="btn" id="btn-template" aria-haspopup="true" aria-expanded="false" title="Composition template and slide transition">
1132
+ <span id="template-label">template</span> <span class="menu__chev">&#9662;</span>
1133
+ </button>
1134
+ <div class="menu__pop menu__pop--template" id="template-menu">
1135
+ <div class="menu-section">
1136
+ <div class="menu-section__title">template</div>
1137
+ <div id="template-list"></div>
1138
+ </div>
1139
+ <div class="menu-section">
1140
+ <div class="menu-section__title">transition</div>
1141
+ <div id="transition-list"></div>
1142
+ </div>
1143
+ </div>
1144
+ </span>
1145
+ <button class="btn" id="btn-theme" title="Pick a theme">
1146
+ <span class="th-dot" id="theme-dot"></span>
1147
+ <span id="theme-label">theme</span>
1148
+ <kbd>Cmd Shift L</kbd>
1149
+ </button>
1150
+ <span class="menu">
1151
+ <button class="btn" id="btn-font" aria-haspopup="true" aria-expanded="false" title="Heading and body faces">
1152
+ <span id="font-label">font</span> <span class="menu__chev">&#9662;</span>
1153
+ </button>
1154
+ <div class="menu__pop menu__pop--font" id="font-menu">
1155
+ <div id="ff-cols">
1156
+ <div class="ff-col">
1157
+ <div class="ff-col__title">heading &amp; title</div>
1158
+ <div class="ff-list" id="ff-head"></div>
1159
+ </div>
1160
+ <div class="ff-col">
1161
+ <div class="ff-col__title">body</div>
1162
+ <div class="ff-list" id="ff-body"></div>
1163
+ </div>
1164
+ </div>
1165
+ <div class="ff-foot">Code keeps the theme's monospace face.</div>
1166
+ </div>
1167
+ </span>
1168
+ <button class="btn btn--primary" id="btn-present" title="Open the real deck in a new tab">present <kbd>Cmd Enter</kbd></button>
1169
+ </span>
1170
+ </header>
1171
+
1172
+ <main id="panes">
1173
+ <section id="pane-edit">
1174
+ <div id="edit-wrap">
1175
+ <pre id="hl" aria-hidden="true"></pre>
1176
+ <pre id="measure" aria-hidden="true"></pre>
1177
+ <textarea id="src" spellcheck="false" autocomplete="off" autocapitalize="off" wrap="soft" aria-label="Markdown source"></textarea>
1178
+ </div>
1179
+ <textarea id="src-html" spellcheck="false" autocomplete="off" autocapitalize="off" wrap="off" aria-label="HTML source"></textarea>
1180
+ </section>
1181
+
1182
+ <div id="divider" title="Drag to resize, double-click to reset"></div>
1183
+
1184
+ <section id="pane-prev">
1185
+ <div id="prev-head">
1186
+ <button class="step" id="btn-prev" title="Previous slide">&#8592;</button>
1187
+ <span id="prev-count">slide 0 / 0</span>
1188
+ <button class="step" id="btn-next" title="Next slide">&#8594;</button>
1189
+ <span id="overflow-badge" title="This slide is clipped. Shorten it or split it with three dashes.">overflow</span>
1190
+ <span class="spacer"></span>
1191
+ <span id="prev-scale"></span>
1192
+ <div class="seg">
1193
+ <button id="seg-single" class="is-on">single</button>
1194
+ <button id="seg-grid">grid</button>
1195
+ </div>
1196
+ </div>
1197
+ <div id="stage">
1198
+ <div id="frame-box" class="is-single">
1199
+ <!-- Sandboxed deliberately without allow-same-origin. Slide HTML is
1200
+ written into this frame with innerHTML, and innerHTML still runs
1201
+ <img onerror> and friends; without the sandbox that code holds
1202
+ the editor's own origin and can reach /__file, /__fetch-doc,
1203
+ localStorage and every file under the launch directory. The
1204
+ frame talks to the editor over postMessage only, so nothing it
1205
+ needs is lost. -->
1206
+ <iframe id="frame" src="/__preview?token=${encodeURIComponent(token)}" title="Slide preview"
1207
+ sandbox="allow-scripts allow-popups"></iframe>
1208
+ </div>
1209
+ </div>
1210
+ <div id="notes">
1211
+ <div id="notes__label">speaker notes</div>
1212
+ <div id="notes__text"></div>
1213
+ </div>
1214
+ <!-- Fed an untrusted HTML document verbatim through srcdoc, and a
1215
+ srcdoc frame inherits the parent origin unless it is sandboxed. -->
1216
+ <iframe id="frame-html" title="HTML doc preview"
1217
+ sandbox="allow-scripts allow-popups"></iframe>
1218
+ </section>
1219
+ </main>
1220
+
1221
+ <footer id="statusbar">
1222
+ <span id="pos">Ln 1, Col 1</span>
1223
+ <span id="words">0 words</span>
1224
+ <span id="chip-slides">0 slides</span>
1225
+ <span id="save-state"></span>
1226
+ <span id="follow-chip" class="hidden" title="The editor is mirroring the presented deck. Click to stop following.">following deck &middot; <b id="follow-slide">1</b></span>
1227
+ <span id="tipbar">
1228
+ <span class="tag">tip</span>
1229
+ <span id="tip-text"></span>
1230
+ <button id="tip-prev" title="Previous tip">&#8249;</button>
1231
+ <button id="tip-next" title="Next tip">&#8250;</button>
1232
+ </span>
1233
+ <span>local only, nothing is uploaded</span>
1234
+ </footer>
1235
+ </div>
1236
+
1237
+ <div id="palette">
1238
+ <div class="backdrop" data-close="palette"></div>
1239
+ <div id="pal-box">
1240
+ <input id="pal-input" placeholder="Insert a style, layout, embed, or action" spellcheck="false" autocomplete="off">
1241
+ <div id="pal-list"></div>
1242
+ <div id="pal-foot">
1243
+ <span>&#8593;&#8595; move</span><span>enter inserts</span><span>esc closes</span>
1244
+ </div>
1245
+ </div>
1246
+ </div>
1247
+
1248
+ <div id="guide">
1249
+ <div class="backdrop" data-close="guide"></div>
1250
+ <div id="guide-panel">
1251
+ <div id="guide-head">
1252
+ <div style="flex:1 1 auto">
1253
+ <h2>Everything you can put on a slide</h2>
1254
+ <p>Click insert on any card to drop it at your caret.</p>
1255
+ </div>
1256
+ <button class="nudge-btn nudge-btn--x" data-close="guide" title="Close">&times;</button>
1257
+ </div>
1258
+ <div id="guide-body"></div>
1259
+ </div>
1260
+ </div>
1261
+
1262
+ <div id="library">
1263
+ <div class="backdrop" data-close="library"></div>
1264
+ <div id="lib-box">
1265
+ <div id="lib-head">
1266
+ <span>Decks in this browser</span>
1267
+ <button id="lib-new">new deck</button>
1268
+ <button data-close="library">close</button>
1269
+ </div>
1270
+ <div id="lib-list"></div>
1271
+ <div id="pal-foot">
1272
+ <span>&#8593;&#8595; move</span><span>enter opens</span><span>esc closes</span>
1273
+ </div>
1274
+ </div>
1275
+ </div>
1276
+
1277
+ <div id="themes">
1278
+ <div class="backdrop" data-close="themes"></div>
1279
+ <div id="th-box">
1280
+ <div id="th-head">
1281
+ <span>Themes</span>
1282
+ <span class="th-head__sub">Arrow keys preview a theme live &nbsp;·&nbsp; enter keeps it &nbsp;·&nbsp; esc puts it back</span>
1283
+ <button data-close="themes">close</button>
1284
+ </div>
1285
+ <div id="th-list"></div>
1286
+ </div>
1287
+ </div>
1288
+
1289
+ <div id="screen-start">
1290
+ <div class="backdrop"></div>
1291
+ <div id="start-box">
1292
+ <div id="start-head">
1293
+ <span id="brand">deckrun<span class="caret"></span></span>
1294
+ <p>What are you making?</p>
1295
+ </div>
1296
+ <div id="start-cards">
1297
+ <div class="start-card" id="start-md-card">
1298
+ <span class="start-card__title">Markdown deck</span>
1299
+ <span class="start-card__desc">Slides written in Markdown, presented one at a time.</span>
1300
+ <span class="start-card__acts">
1301
+ <button id="start-md-blank">start blank</button>
1302
+ <button id="start-md-upload">upload .md</button>
1303
+ </span>
1304
+ <div class="start-options">
1305
+ <div class="start-options__row">
1306
+ <span class="start-options__label">template</span>
1307
+ <span class="start-options__choices" id="start-template-list"></span>
1308
+ </div>
1309
+ <div class="start-options__row">
1310
+ <span class="start-options__label">transition</span>
1311
+ <span class="start-options__choices" id="start-transition-list"></span>
1312
+ </div>
1313
+ </div>
1314
+ </div>
1315
+ <div class="start-card" id="start-html-card">
1316
+ <span class="start-card__title">HTML document</span>
1317
+ <span class="start-card__desc">One self-contained page, presented with the laser, pen, and canvas &mdash; no slide boundaries.</span>
1318
+ <span class="start-card__acts">
1319
+ <button id="start-html-upload">upload .html</button>
1320
+ <span class="start-card__url">
1321
+ <input type="url" id="start-html-url" placeholder="https://a-public-page.html" inputmode="url" autocomplete="off" spellcheck="false">
1322
+ <button id="start-html-url-go">load URL</button>
1323
+ </span>
1324
+ </span>
1325
+ </div>
1326
+ <button class="start-card" id="start-lib">
1327
+ <span class="start-card__title">Open from library</span>
1328
+ <span class="start-card__desc" id="start-lib-desc">Decks and docs already in this browser.</span>
1329
+ </button>
1330
+ </div>
1331
+ </div>
1332
+ </div>
1333
+
1334
+ <div id="toasts"></div>
1335
+ <input type="file" id="file-any" accept=".md,.markdown,text/markdown,text/plain,.html,.htm,text/html" hidden>
1336
+
1337
+ <script type="application/json" id="bootstrap">${bootstrapJson(theme, fonts, template, transition, file, token)}</script>
1338
+ <script>
1339
+ ${HIGHLIGHT_RUNTIME}
1340
+ </script>
1341
+ <script>
1342
+ (function () {
1343
+ 'use strict';
1344
+
1345
+ var D = JSON.parse(document.getElementById('bootstrap').textContent);
1346
+ var MAC = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
1347
+ var CMD = MAC ? 'Cmd' : 'Ctrl';
1348
+
1349
+ // The document deckrun was launched with (a file on disk or a fetched
1350
+ // URL). When set, the editor is backed by it instead of the browser
1351
+ // library: content comes from /__file, edits save back through it, and
1352
+ // external changes on disk arrive over /__events.
1353
+ var FILE = D.file || null;
1354
+
1355
+ // Every /__ route requires this. It is minted per server run and delivered
1356
+ // only inside this page, so a POST from another site — which is a CORS
1357
+ // simple request and therefore not preflighted — cannot carry it.
1358
+ var TOKEN = D.token || '';
1359
+
1360
+ /** fetch() with the session token attached. */
1361
+ function api(path, init) {
1362
+ init = init || {};
1363
+ var headers = {};
1364
+ for (var key in (init.headers || {})) headers[key] = init.headers[key];
1365
+ headers['X-Deckrun-Token'] = TOKEN;
1366
+ init.headers = headers;
1367
+ return fetch(path, init);
1368
+ }
1369
+
1370
+ var K = {
1371
+ index: 'deckrun.decks.v1',
1372
+ deck: 'deckrun.deck.',
1373
+ current: 'deckrun.current.v1',
1374
+ theme: 'deckrun.theme.v1',
1375
+ head: 'deckrun.font.head.v1',
1376
+ body: 'deckrun.font.body.v1',
1377
+ template:'deckrun.template.v1',
1378
+ transition:'deckrun.transition.v1',
1379
+ split: 'deckrun.split.v1',
1380
+ mode: 'deckrun.mode.v1',
1381
+ nudge: 'deckrun.nudges.v1',
1382
+ // Superseded by the deck library, read once to migrate.
1383
+ oldDoc: 'presentmd.doc.v1',
1384
+ oldName: 'presentmd.name.v1'
1385
+ };
1386
+
1387
+ // present-md was renamed to deckrun — the whole storage namespace moves
1388
+ // with it, so this copies it over once rather than orphaning every deck
1389
+ // already saved under the old prefix.
1390
+ var OLD_NS = {
1391
+ index: 'presentmd.decks.v1', deck: 'presentmd.deck.', current: 'presentmd.current.v1',
1392
+ theme: 'presentmd.theme.v1', head: 'presentmd.font.head.v1',
1393
+ body: 'presentmd.font.body.v1', split: 'presentmd.split.v1', mode: 'presentmd.mode.v1',
1394
+ nudge: 'presentmd.nudges.v1'
1395
+ };
1396
+ (function migrateNamespace() {
1397
+ if (lsGet(K.index, null) !== null) return;
1398
+ var oldIndexRaw = lsGet(OLD_NS.index, null);
1399
+ if (oldIndexRaw === null) return;
1400
+ lsSet(K.index, oldIndexRaw);
1401
+ lsDel(OLD_NS.index);
1402
+ try {
1403
+ var oldIndex = JSON.parse(oldIndexRaw);
1404
+ if (Object.prototype.toString.call(oldIndex) === '[object Array]') {
1405
+ oldIndex.forEach(function (entry) {
1406
+ if (!entry || !entry.id) return;
1407
+ var content = lsGet(OLD_NS.deck + entry.id, null);
1408
+ if (content !== null) {
1409
+ lsSet(K.deck + entry.id, content);
1410
+ lsDel(OLD_NS.deck + entry.id);
1411
+ }
1412
+ });
1413
+ }
1414
+ } catch (e) {}
1415
+ ['current', 'theme', 'head', 'body', 'split', 'mode', 'nudge'].forEach(function (slot) {
1416
+ var v = lsGet(OLD_NS[slot], null);
1417
+ if (v !== null) { lsSet(K[slot], v); lsDel(OLD_NS[slot]); }
1418
+ });
1419
+ })();
1420
+
1421
+ function lsGet(k, fallback) {
1422
+ try { var v = localStorage.getItem(k); return v === null ? fallback : v; }
1423
+ catch (e) { return fallback; }
1424
+ }
1425
+
1426
+ function lsSet(k, v) {
1427
+ try { localStorage.setItem(k, v); return true; }
1428
+ catch (e) { return false; }
1429
+ }
1430
+
1431
+ function lsDel(k) {
1432
+ try { localStorage.removeItem(k); } catch (e) {}
1433
+ }
1434
+
1435
+ // ── Deck library ───────────────────────────────────────────────────────
1436
+ // The index holds metadata only, so listing decks never reads their text.
1437
+ // Each deck's Markdown lives under its own key.
1438
+
1439
+ function loadIndex() {
1440
+ try {
1441
+ var list = JSON.parse(lsGet(K.index, '[]'));
1442
+ return Object.prototype.toString.call(list) === '[object Array]' ? list : [];
1443
+ } catch (e) { return []; }
1444
+ }
1445
+
1446
+ function saveIndex(list) {
1447
+ return lsSet(K.index, JSON.stringify(list));
1448
+ }
1449
+
1450
+ function newDeckId() {
1451
+ return 'd' + Date.now().toString(36) + Math.floor(Math.random() * 1e6).toString(36);
1452
+ }
1453
+
1454
+ function readDeck(id) { return lsGet(K.deck + id, ''); }
1455
+
1456
+ function findDeck(id) {
1457
+ var list = loadIndex();
1458
+ for (var i = 0; i < list.length; i++) if (list[i].id === id) return list[i];
1459
+ return null;
1460
+ }
1461
+
1462
+ /** Returns the new deck's id, or null if this browser refused to store it. */
1463
+ function createDeck(name, content, kind, template, transition) {
1464
+ var id = newDeckId();
1465
+ if (!lsSet(K.deck + id, content)) return null;
1466
+ var list = loadIndex();
1467
+ list.unshift({
1468
+ id: id,
1469
+ name: name || 'untitled',
1470
+ kind: kind || 'markdown',
1471
+ slides: 0,
1472
+ chars: content.length,
1473
+ template: template || (typeof state !== 'undefined' && state.template ? state.template : D.template),
1474
+ transition: transition || (typeof state !== 'undefined' && state.transition ? state.transition : D.transition),
1475
+ at: Date.now()
1476
+ });
1477
+ if (!saveIndex(list)) { lsDel(K.deck + id); return null; }
1478
+ return id;
1479
+ }
1480
+
1481
+ function uniqueName(base) {
1482
+ var list = loadIndex();
1483
+ var taken = {};
1484
+ list.forEach(function (d) { taken[d.name] = true; });
1485
+ if (!taken[base]) return base;
1486
+ for (var n = 2; n < 500; n++) if (!taken[base + ' ' + n]) return base + ' ' + n;
1487
+ return base;
1488
+ }
1489
+
1490
+ function timeAgo(ts) {
1491
+ var mins = Math.floor((Date.now() - ts) / 60000);
1492
+ if (mins < 1) return 'just now';
1493
+ if (mins < 60) return mins + 'm ago';
1494
+ var hours = Math.floor(mins / 60);
1495
+ if (hours < 24) return hours + 'h ago';
1496
+ var days = Math.floor(hours / 24);
1497
+ return days + 'd ago';
1498
+ }
1499
+
1500
+ function updateDocTitle() {
1501
+ if ($('screen-start') && $('screen-start').classList.contains('is-on')) {
1502
+ document.title = 'deckrun \u00b7 start';
1503
+ return;
1504
+ }
1505
+ if ($('library') && $('library').classList.contains('is-on')) {
1506
+ document.title = 'deckrun \u00b7 library';
1507
+ return;
1508
+ }
1509
+ var name = ($('docname').value || '').trim();
1510
+ document.title = name ? name + ' \u00b7 deckrun' : 'deckrun \u00b7 editor';
1511
+ }
1512
+
1513
+ /** One deck's metadata is refreshed from the editor on every save. */
1514
+ function touchCurrent() {
1515
+ var list = loadIndex();
1516
+ for (var i = 0; i < list.length; i++) {
1517
+ if (list[i].id === state.deckId) {
1518
+ list[i].name = $('docname').value.trim() || 'untitled';
1519
+ list[i].kind = state.kind;
1520
+ list[i].template = state.template;
1521
+ list[i].transition = state.transition;
1522
+ if (state.kind === 'html') {
1523
+ list[i].slides = 0;
1524
+ list[i].chars = srcHtml.value.length;
1525
+ } else {
1526
+ list[i].slides = state.slides.length;
1527
+ list[i].chars = src.value.length;
1528
+ }
1529
+ list[i].at = Date.now();
1530
+ break;
1531
+ }
1532
+ }
1533
+ updateDocTitle();
1534
+ return saveIndex(list);
1535
+ }
1536
+
1537
+ function updateDeckCount() {
1538
+ $('deck-count').textContent = String(loadIndex().length);
1539
+ }
1540
+
1541
+ /** The open document's own text, whichever kind it is. */
1542
+ function curValue() { return state.kind === 'html' ? srcHtml.value : src.value; }
1543
+
1544
+ /** Relabels the export menu, since "Markdown" makes no sense for a doc. */
1545
+ function paintExportMenu() {
1546
+ var mdName = document.querySelector('#export-menu [data-export="md"] .menu__name');
1547
+ var mdHint = document.querySelector('#export-menu [data-export="md"] .menu__hint');
1548
+ var htmlName = document.querySelector('#export-menu [data-export="html"] .menu__name');
1549
+ var htmlHint = document.querySelector('#export-menu [data-export="html"] .menu__hint');
1550
+ if (state.kind === 'html') {
1551
+ mdName.textContent = 'Source';
1552
+ mdHint.textContent = 'the raw .html file';
1553
+ htmlName.textContent = 'Presenter Page';
1554
+ htmlHint.textContent = 'standalone, with the tool belt built in';
1555
+ } else {
1556
+ mdName.textContent = 'Markdown';
1557
+ mdHint.textContent = 'a plain .md file';
1558
+ htmlName.textContent = 'HTML';
1559
+ htmlHint.textContent = 'one self-contained page';
1560
+ }
1561
+ }
1562
+
1563
+ /** Switches which document kind the chrome is dressed for. */
1564
+ function setDocKind(kind) {
1565
+ state.kind = kind === 'html' ? 'html' : 'markdown';
1566
+ document.documentElement.dataset.docKind = state.kind;
1567
+ paintExportMenu();
1568
+ }
1569
+
1570
+ // ── Themes ─────────────────────────────────────────────────────────────
1571
+ var THEMES = D.themes;
1572
+ var THEME_BY_ID = {};
1573
+ THEMES.forEach(function (t) { THEME_BY_ID[t.id] = t; });
1574
+
1575
+ /** A theme id we can actually apply, whatever localStorage happens to hold. */
1576
+ function pickTheme(id) {
1577
+ return THEME_BY_ID[id] ? id : (THEME_BY_ID[D.theme] ? D.theme : THEMES[0].id);
1578
+ }
1579
+
1580
+ var TEMPLATES = D.templates;
1581
+ var TEMPLATE_BY_ID = {};
1582
+ TEMPLATES.forEach(function (item) { TEMPLATE_BY_ID[item.id] = item; });
1583
+
1584
+ function pickTemplate(id) {
1585
+ return TEMPLATE_BY_ID[id] ? id : (TEMPLATE_BY_ID[D.template] ? D.template : 'classic');
1586
+ }
1587
+
1588
+ var TRANSITIONS = D.transitions;
1589
+ var TRANSITION_BY_ID = {};
1590
+ TRANSITIONS.forEach(function (item) { TRANSITION_BY_ID[item.id] = item; });
1591
+
1592
+ function pickTransition(id) {
1593
+ return TRANSITION_BY_ID[id] ? id : (TRANSITION_BY_ID[D.transition] ? D.transition : 'slide');
1594
+ }
1595
+
1596
+ var FACES = D.faces;
1597
+ var FACE_BY_ID = {};
1598
+ FACES.forEach(function (f) { FACE_BY_ID[f.id] = f; });
1599
+
1600
+ /** null means "whatever the theme says", which is not a face id. */
1601
+ function pickFont(id) {
1602
+ return FACE_BY_ID[id] ? id : null;
1603
+ }
1604
+
1605
+ // ── Elements ───────────────────────────────────────────────────────────
1606
+ var $ = function (id) { return document.getElementById(id); };
1607
+ var src = $('src'), hl = $('hl'), measure = $('measure');
1608
+ var frame = $('frame'), frameBox = $('frame-box'), stage = $('stage');
1609
+ var paneEdit = $('pane-edit'), panePrev = $('pane-prev'), divider = $('divider');
1610
+ var elChipSlides = $('chip-slides'), elSave = $('save-state'), elPos = $('pos'), elWords = $('words');
1611
+ var elCount = $('prev-count'), elScale = $('prev-scale');
1612
+ var elNotes = $('notes'), elNotesText = $('notes__text');
1613
+ var palette = $('palette'), palInput = $('pal-input'), palList = $('pal-list');
1614
+ var guide = $('guide'), guideBody = $('guide-body');
1615
+ var srcHtml = $('src-html'), frameHtml = $('frame-html');
1616
+
1617
+ // ── State ──────────────────────────────────────────────────────────────
1618
+ var state = {
1619
+ deckId: null,
1620
+ kind: 'markdown',
1621
+ slides: [],
1622
+ notes: [],
1623
+ index: 0,
1624
+ mode: lsGet(K.mode, 'single') === 'grid' ? 'grid' : 'single',
1625
+ theme: pickTheme(lsGet(K.theme, D.theme)),
1626
+ head: pickFont(lsGet(K.head, D.fonts.head)),
1627
+ body: pickFont(lsGet(K.body, D.fonts.body)),
1628
+ template: pickTemplate(lsGet(K.template, D.template)),
1629
+ transition: pickTransition(lsGet(K.transition, D.transition)),
1630
+ frameReady: false,
1631
+ overflow: {},
1632
+ dismissed: {},
1633
+ activeNudge: null,
1634
+ nudgeShownAt: 0,
1635
+ seq: 0,
1636
+ inflight: null,
1637
+ tip: Math.floor(Math.random() * D.tips.length)
1638
+ };
1639
+
1640
+ try {
1641
+ var saved = JSON.parse(lsGet(K.nudge, '[]'));
1642
+ if (saved && saved.length) saved.forEach(function (id) { state.dismissed[id] = true; });
1643
+ } catch (e) {}
1644
+
1645
+ // ── Following the presented deck ───────────────────────────────────────
1646
+ // Presenting hands the deck tab this editor's session id (ps=... in the
1647
+ // URL), and the editor listens on that same channel: the preview and the
1648
+ // notes panel mirror the deck's current slide, so the editor window is the
1649
+ // presenter's screen and the deck tab is the projector. No second window
1650
+ // is involved.
1651
+ var FOLLOW_KEY = 'deckrun.presenter.session';
1652
+ var followSid = null;
1653
+ try { followSid = sessionStorage.getItem(FOLLOW_KEY); } catch (e) {}
1654
+ if (!followSid) {
1655
+ followSid = (window.crypto && crypto.randomUUID)
1656
+ ? crypto.randomUUID()
1657
+ : 'e' + Date.now().toString(36) + Math.random().toString(36).slice(2);
1658
+ try { sessionStorage.setItem(FOLLOW_KEY, followSid); } catch (e) {}
1659
+ }
1660
+ var followChan = (typeof BroadcastChannel !== 'undefined')
1661
+ ? new BroadcastChannel('deckrun:' + followSid)
1662
+ : null;
1663
+
1664
+ var following = false; // the editor is mirroring the deck right now
1665
+ var expectingDeck = false; // a present just went out: follow the deck that answers
1666
+ var deckNotes = []; // the deck's own notes, authoritative while following
1667
+ var followChipEl = $('follow-chip');
1668
+ var followSlideEl = $('follow-slide');
1669
+
1670
+ /** The editor drives the deck as well as the preview: wherever the
1671
+ presenter lands in the editor, the deck goes there too. The deck echoes
1672
+ its state back over the same channel, which is what keeps both in step. */
1673
+ function announceIndex(i) {
1674
+ if (followChan) followChan.postMessage({ id: followSid, type: 'goto', index: i });
1675
+ }
1676
+
1677
+ function followDeck(i) {
1678
+ var n = state.slides.length;
1679
+ if (!n) return;
1680
+ i = Math.max(0, Math.min(n - 1, i));
1681
+ var changed = i !== state.index;
1682
+ state.index = i;
1683
+ state.notes = deckNotes;
1684
+ followSlideEl.textContent = String(i + 1);
1685
+ if (changed) pushFrame();
1686
+ renderCounts();
1687
+ }
1688
+
1689
+ if (followChan) {
1690
+ followChan.onmessage = function (e) {
1691
+ var m = e.data || {};
1692
+ if (m.id !== followSid) return;
1693
+ if (m.type === 'init') {
1694
+ deckNotes = m.notes || [];
1695
+ if (expectingDeck) {
1696
+ expectingDeck = false;
1697
+ following = true;
1698
+ }
1699
+ followChipEl.classList.toggle('hidden', !following);
1700
+ } else if (m.type === 'state') {
1701
+ if (following) followDeck(m.index);
1702
+ }
1703
+ };
1704
+ setInterval(function () {
1705
+ followChan.postMessage({ id: followSid, type: 'ready' });
1706
+ }, 2000);
1707
+ }
1708
+
1709
+ followChipEl.addEventListener('click', function () {
1710
+ following = false;
1711
+ followChipEl.classList.add('hidden');
1712
+ });
1713
+
1714
+ // ── Session highlights ─────────────────────────────────────────────────
1715
+ // Selecting text in either preview paints a highlight and, optionally,
1716
+ // hangs a comment off it. The store is the browser session's, shared with
1717
+ // the tab present opens, so the marks are already there on the projector.
1718
+ // Nothing is written to disk, and closing the browser clears the lot.
1719
+ function highlightKey() {
1720
+ if (FILE) return 'file:' + FILE.name;
1721
+ return state.deckId ? 'deck:' + state.deckId : 'deck:unsaved';
1722
+ }
1723
+
1724
+ var hlSlides = null, hlDoc = null;
1725
+ if (window.deckrunHighlights) {
1726
+ var onHighlightWarn = function (message) { toast(message, 'warn'); };
1727
+ hlSlides = window.deckrunHighlights.mount({
1728
+ frame: frame,
1729
+ docKey: highlightKey(),
1730
+ scopes: 'slides',
1731
+ onWarn: onHighlightWarn
1732
+ });
1733
+ hlDoc = window.deckrunHighlights.mount({
1734
+ frame: frameHtml,
1735
+ docKey: highlightKey(),
1736
+ scopes: 'doc',
1737
+ onWarn: onHighlightWarn
1738
+ });
1739
+ }
1740
+
1741
+ /** Points both previews at whichever document is now open. */
1742
+ function syncHighlightDoc() {
1743
+ var key = highlightKey();
1744
+ if (hlSlides) hlSlides.setDocKey(key);
1745
+ if (hlDoc) hlDoc.setDocKey(key);
1746
+ }
1747
+
1748
+ // ── Toasts ─────────────────────────────────────────────────────────────
1749
+ function toast(message, kind, actionLabel, action) {
1750
+ var el = document.createElement('div');
1751
+ el.className = 'toast' + (kind ? ' toast--' + kind : '');
1752
+ var text = document.createElement('span');
1753
+ text.style.flex = '1 1 auto';
1754
+ text.textContent = message;
1755
+ el.appendChild(text);
1756
+ if (actionLabel) {
1757
+ var btn = document.createElement('button');
1758
+ btn.textContent = actionLabel;
1759
+ btn.addEventListener('click', function () { action(); el.remove(); });
1760
+ el.appendChild(btn);
1761
+ }
1762
+ $('toasts').appendChild(el);
1763
+ setTimeout(function () {
1764
+ el.style.transition = 'opacity .3s ease';
1765
+ el.style.opacity = '0';
1766
+ setTimeout(function () { el.remove(); }, 320);
1767
+ }, actionLabel ? 8000 : 4200);
1768
+ }
1769
+
1770
+ // ── Markdown highlighting for the editor surface ───────────────────────
1771
+ function esc(s) {
1772
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1773
+ }
1774
+
1775
+ var HOLD = '\\u0000';
1776
+
1777
+ function inlineTokens(line) {
1778
+ var held = [];
1779
+ function hold(html) { held.push(html); return HOLD + (held.length - 1) + HOLD; }
1780
+
1781
+ var out = esc(line);
1782
+
1783
+ // Images first: the title attribute carries the layout directive.
1784
+ out = out.replace(/!\\[([^\\]\\n]*)\\]\\(([^\\s)]*)(\\s+"[^"\\n]*")?\\)/g, function (m, alt, url, title) {
1785
+ var html = '<span class="t-img">![' + alt + ']</span><span class="t-url">(' + url;
1786
+ if (title) html += '</span><span class="t-dir">' + title + '</span><span class="t-url">';
1787
+ return hold(html + ')</span>');
1788
+ });
1789
+
1790
+ // Links.
1791
+ out = out.replace(/\\[([^\\]\\n]*)\\]\\(([^\\s)]*)(\\s+"[^"\\n]*")?\\)/g, function (m, label, url, title) {
1792
+ return hold('<span class="t-link">[' + label + ']</span><span class="t-url">(' + url +
1793
+ (title ? '</span><span class="t-dir">' + title + '</span><span class="t-url">' : '') + ')</span>');
1794
+ });
1795
+
1796
+ // Inline code.
1797
+ out = out.replace(/\`[^\`\\n]+\`/g, function (m) { return hold('<span class="t-code">' + m + '</span>'); });
1798
+
1799
+ // Raw HTML tags.
1800
+ out = out.replace(/&lt;\\/?[a-zA-Z][^&]{0,200}?&gt;/g, function (m) {
1801
+ return hold('<span class="t-html">' + m + '</span>');
1802
+ });
1803
+
1804
+ out = out.replace(/\\*\\*[^*\\n]+\\*\\*/g, function (m) { return '<span class="t-strong">' + m + '</span>'; });
1805
+ out = out.replace(/(^|[^*\\w])(\\*[^*\\n]+\\*)/g, function (m, pre, body) {
1806
+ return pre + '<span class="t-em">' + body + '</span>';
1807
+ });
1808
+ out = out.replace(/(^|[^_\\w])(_[^_\\n]+_)/g, function (m, pre, body) {
1809
+ return pre + '<span class="t-em">' + body + '</span>';
1810
+ });
1811
+
1812
+ return out.replace(new RegExp(HOLD + '(\\\\d+)' + HOLD, 'g'), function (m, i) { return held[+i]; });
1813
+ }
1814
+
1815
+ function highlightMarkdown(text) {
1816
+ var lines = text.split('\\n');
1817
+ var out = [];
1818
+ var inFence = false;
1819
+
1820
+ for (var i = 0; i < lines.length; i++) {
1821
+ var line = lines[i];
1822
+
1823
+ // A pasted data URI is one enormous line. Colour it in a single pass so
1824
+ // typing stays responsive.
1825
+ if (line.length > 4000) {
1826
+ out.push('<span class="t-url">' + esc(line) + '</span>');
1827
+ continue;
1828
+ }
1829
+
1830
+ if (/^\\s*\`\`\`/.test(line)) {
1831
+ out.push('<span class="t-fence">' + esc(line) + '</span>');
1832
+ inFence = !inFence;
1833
+ continue;
1834
+ }
1835
+ if (inFence) { out.push('<span class="t-codeline">' + esc(line) + '</span>'); continue; }
1836
+ if (/^[ \\t]*---[ \\t]*$/.test(line)) {
1837
+ out.push('<span class="t-sep">' + esc(line) + '</span>');
1838
+ continue;
1839
+ }
1840
+ if (/^\\s*<!--/.test(line)) {
1841
+ out.push('<span class="t-note">' + esc(line) + '</span>');
1842
+ continue;
1843
+ }
1844
+
1845
+ var head = line.match(/^(#{1,6})\\s/);
1846
+ if (head) {
1847
+ var lvl = Math.min(head[1].length, 4);
1848
+ out.push('<span class="t-h' + lvl + '">' + inlineTokens(line) + '</span>');
1849
+ continue;
1850
+ }
1851
+ if (/^\\s*>/.test(line)) {
1852
+ out.push('<span class="t-quote">' + inlineTokens(line) + '</span>');
1853
+ continue;
1854
+ }
1855
+ if (/^\\s*\\|.*\\|\\s*$/.test(line)) {
1856
+ out.push('<span class="t-table">' + inlineTokens(line) + '</span>');
1857
+ continue;
1858
+ }
1859
+ var marker = line.match(/^(\\s*)([-*+]|\\d+[.)])(\\s+)/);
1860
+ if (marker) {
1861
+ out.push(marker[1] + '<span class="t-marker">' + esc(marker[2]) + '</span>' + marker[3] +
1862
+ inlineTokens(line.slice(marker[0].length)));
1863
+ continue;
1864
+ }
1865
+ out.push(inlineTokens(line));
1866
+ }
1867
+
1868
+ return out.join('\\n') + '\\n';
1869
+ }
1870
+
1871
+ function paint() { hl.innerHTML = highlightMarkdown(src.value); }
1872
+
1873
+ function syncScroll() { hl.scrollTop = src.scrollTop; hl.scrollLeft = src.scrollLeft; }
1874
+
1875
+ // ── Slide map, mirroring the server-side split ─────────────────────────
1876
+ var SEP = /\\r?\\n[ \\t]*---[ \\t]*\\r?\\n/g;
1877
+
1878
+ function slideMap() {
1879
+ var text = src.value, spans = [], last = 0, m;
1880
+ SEP.lastIndex = 0;
1881
+ while ((m = SEP.exec(text)) !== null) {
1882
+ spans.push([last, m.index]);
1883
+ last = m.index + m[0].length;
1884
+ SEP.lastIndex = last;
1885
+ }
1886
+ spans.push([last, text.length]);
1887
+ return spans.filter(function (s) { return text.slice(s[0], s[1]).trim().length > 0; });
1888
+ }
1889
+
1890
+ function caretSlide() {
1891
+ var pos = src.selectionStart, spans = slideMap();
1892
+ for (var i = 0; i < spans.length; i++) {
1893
+ if (pos <= spans[i][1]) return i;
1894
+ }
1895
+ return Math.max(0, spans.length - 1);
1896
+ }
1897
+
1898
+ function offsetTop(pos) {
1899
+ measure.textContent = src.value.slice(0, pos);
1900
+ var marker = document.createElement('span');
1901
+ marker.textContent = '\\u200b';
1902
+ measure.appendChild(marker);
1903
+ return marker.offsetTop;
1904
+ }
1905
+
1906
+ function scrollCaretIntoView() {
1907
+ var top = offsetTop(src.selectionStart);
1908
+ var view = src.clientHeight;
1909
+ if (top < src.scrollTop + 40 || top > src.scrollTop + view - 80) {
1910
+ src.scrollTop = Math.max(0, top - view / 3);
1911
+ syncScroll();
1912
+ }
1913
+ }
1914
+
1915
+ // ── Preview plumbing ───────────────────────────────────────────────────
1916
+ /** Dropped rather than queued until the frame is up: see the ready handler. */
1917
+ function post(msg) {
1918
+ if (!state.frameReady) return;
1919
+ frame.contentWindow.postMessage(msg, location.origin);
1920
+ }
1921
+
1922
+ function pushFrame() {
1923
+ post({ type: 'render', slides: state.slides, index: state.index, mode: state.mode });
1924
+ }
1925
+
1926
+ function layout() {
1927
+ var padding = 32;
1928
+ var w = Math.max(120, stage.clientWidth - padding);
1929
+ var h = Math.max(90, stage.clientHeight - padding);
1930
+ var scale;
1931
+ if (state.mode === 'single') {
1932
+ scale = Math.min(w / D.width, h / D.height);
1933
+ frame.style.height = D.height + 'px';
1934
+ frameBox.classList.add('is-single');
1935
+ frameBox.style.width = Math.round(D.width * scale) + 'px';
1936
+ frameBox.style.height = Math.round(D.height * scale) + 'px';
1937
+ } else {
1938
+ scale = w / D.width;
1939
+ var virtualH = Math.round(h / scale);
1940
+ frame.style.height = virtualH + 'px';
1941
+ frameBox.classList.remove('is-single');
1942
+ frameBox.style.width = Math.round(w) + 'px';
1943
+ frameBox.style.height = Math.round(h) + 'px';
1944
+ }
1945
+ frame.style.transform = 'scale(' + scale + ')';
1946
+ elScale.textContent = Math.round(scale * 100) + '%';
1947
+ }
1948
+
1949
+ function renderCounts() {
1950
+ var n = state.slides.length;
1951
+ elChipSlides.textContent = n + (n === 1 ? ' slide' : ' slides');
1952
+ elCount.textContent = 'slide ' + (n ? state.index + 1 : 0) + ' / ' + n;
1953
+ $('btn-prev').disabled = state.index <= 0;
1954
+ $('btn-next').disabled = state.index >= n - 1;
1955
+ $('overflow-badge').classList.toggle(
1956
+ 'is-on',
1957
+ state.mode === 'single' && !!state.overflow[state.index]
1958
+ );
1959
+
1960
+ var note = state.notes[state.index];
1961
+ if (note) { elNotesText.textContent = note; elNotes.classList.add('is-on'); }
1962
+ else { elNotes.classList.remove('is-on'); }
1963
+
1964
+ var words = src.value.trim() ? src.value.trim().split(/\\s+/).length : 0;
1965
+ elWords.textContent = words + (words === 1 ? ' word' : ' words');
1966
+ }
1967
+
1968
+ function setIndex(i, moveCaret) {
1969
+ var n = state.slides.length;
1970
+ if (!n) return;
1971
+ i = Math.max(0, Math.min(n - 1, i));
1972
+ if (i === state.index && !moveCaret) return;
1973
+ state.index = i;
1974
+ if (moveCaret) {
1975
+ var spans = slideMap();
1976
+ if (spans[i]) {
1977
+ var target = spans[i][0];
1978
+ while (/\\s/.test(src.value.charAt(target)) && target < spans[i][1]) target++;
1979
+ src.focus();
1980
+ src.setSelectionRange(target, target);
1981
+ scrollCaretIntoView();
1982
+ updateCaretUi();
1983
+ }
1984
+ }
1985
+ post({ type: 'index', index: state.index });
1986
+ announceIndex(state.index);
1987
+ renderCounts();
1988
+ }
1989
+
1990
+ function updateCaretUi() {
1991
+ var before = src.value.slice(0, src.selectionStart);
1992
+ var lines = before.split('\\n');
1993
+ elPos.textContent = 'Ln ' + lines.length + ', Col ' + (lines[lines.length - 1].length + 1);
1994
+ }
1995
+
1996
+ // ── Parse round-trip: the server owns the Markdown, so what you see here
1997
+ // is byte-for-byte what deckrun file.md renders. ───────────────
1998
+ function refresh() {
1999
+ var mine = ++state.seq;
2000
+ if (state.inflight) state.inflight.abort();
2001
+ state.inflight = new AbortController();
2002
+
2003
+ api('/__parse', {
2004
+ method: 'POST',
2005
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
2006
+ body: src.value,
2007
+ signal: state.inflight.signal
2008
+ })
2009
+ .then(function (r) { return r.json(); })
2010
+ .then(function (data) {
2011
+ if (mine !== state.seq) return;
2012
+ state.slides = data.slides || [];
2013
+ state.notes = data.notes || [];
2014
+ if (state.index >= state.slides.length) state.index = Math.max(0, state.slides.length - 1);
2015
+ pushFrame();
2016
+ renderCounts();
2017
+ evalNudges();
2018
+ })
2019
+ .catch(function (err) {
2020
+ if (err && err.name === 'AbortError') return;
2021
+ toast('Preview failed: ' + err.message + '. Is the server still running?', 'err');
2022
+ });
2023
+ }
2024
+
2025
+ // ── Autosave ───────────────────────────────────────────────────────────
2026
+ var saveTimer = null;
2027
+
2028
+ function saveNow() {
2029
+ if (FILE) {
2030
+ if (!FILE.writable) return;
2031
+ api('/__file', {
2032
+ method: 'POST',
2033
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
2034
+ body: curValue()
2035
+ })
2036
+ .then(function (r) {
2037
+ if (!r.ok) throw new Error('HTTP ' + r.status);
2038
+ elSave.className = '';
2039
+ elSave.textContent = '';
2040
+ })
2041
+ .catch(function () {
2042
+ elSave.className = 'err';
2043
+ elSave.textContent = 'not saved: write to ' + FILE.name + ' failed';
2044
+ });
2045
+ return;
2046
+ }
2047
+ var ok = state.deckId && lsSet(K.deck + state.deckId, curValue()) && touchCurrent();
2048
+ if (ok) {
2049
+ elSave.className = '';
2050
+ elSave.textContent = '';
2051
+ } else if (state.deckId) {
2052
+ elSave.className = 'err';
2053
+ elSave.textContent = 'not saved: storage full';
2054
+ toast('This browser is out of room. Download this deck, or delete one you no longer need.', 'err', 'download', download);
2055
+ }
2056
+ }
2057
+
2058
+ function scheduleSave() {
2059
+ if (saveTimer) clearTimeout(saveTimer);
2060
+ // Writing a megabyte of content costs real time. Back off on big docs.
2061
+ saveTimer = setTimeout(saveNow, curValue().length > 1024 * 1024 ? 1500 : 500);
2062
+ }
2063
+
2064
+ // ── Text insertion, undo-safe ──────────────────────────────────────────
2065
+ function typeText(text) {
2066
+ src.focus();
2067
+ var inserted = false;
2068
+ try { inserted = document.execCommand('insertText', false, text); } catch (e) {}
2069
+ if (!inserted) {
2070
+ var s = src.selectionStart, e2 = src.selectionEnd;
2071
+ src.setRangeText(text, s, e2, 'end');
2072
+ }
2073
+ }
2074
+
2075
+ var MARK = '\\u0001';
2076
+
2077
+ function insertSnippet(sn) {
2078
+ if (!sn.insert) return;
2079
+ var s = src.selectionStart, e = src.selectionEnd;
2080
+ var sel = src.value.slice(s, e);
2081
+
2082
+ var text = sn.insert.split('{caret}').join(MARK);
2083
+ if (text.indexOf(MARK) === -1 && !sel) text = text.split('{sel}').join(MARK);
2084
+ text = text.split('{sel}').join(sel);
2085
+
2086
+ var prefix = '', suffix = '';
2087
+ if (sn.block) {
2088
+ var before = src.value.slice(0, s);
2089
+ if (before && !/\\n[ \\t]*$/.test(before)) prefix = '\\n\\n';
2090
+ else if (before && !/\\n[ \\t]*\\n[ \\t]*$/.test(before)) prefix = '\\n';
2091
+ var after = src.value.slice(e);
2092
+ if (after && !/^[ \\t]*\\n/.test(after)) suffix = '\\n';
2093
+ }
2094
+
2095
+ var caretAt = text.indexOf(MARK);
2096
+ text = text.split(MARK).join('');
2097
+ var full = prefix + text + suffix;
2098
+
2099
+ typeText(full);
2100
+ var pos = s + prefix.length + (caretAt === -1 ? text.length : caretAt);
2101
+ src.setSelectionRange(pos, pos);
2102
+ onInput();
2103
+ scrollCaretIntoView();
2104
+ }
2105
+
2106
+ // ── Nudges: rules evaluated against the live document ──────────────────
2107
+ var NUDGE_RULES = [
2108
+ {
2109
+ id: 'overflow',
2110
+ sticky: true,
2111
+ test: function (ctx) {
2112
+ var i = state.index;
2113
+ return state.overflow[i] ? 'Slide ' + (i + 1) + ' overflows and is being clipped. Trim it, or split it with <code>---</code>.' : null;
2114
+ },
2115
+ snippet: 'slide-break'
2116
+ },
2117
+ {
2118
+ id: 'no-split',
2119
+ test: function (ctx) {
2120
+ return ctx.count === 1 && ctx.text.length > 420
2121
+ ? 'This is one long slide. Three dashes on their own line start the next one.'
2122
+ : null;
2123
+ },
2124
+ snippet: 'slide-break'
2125
+ },
2126
+ {
2127
+ id: 'untagged-fence',
2128
+ test: function (ctx) {
2129
+ return ctx.untaggedFence
2130
+ ? 'That code block has no language tag. Write <code>&#96;&#96;&#96;go</code> and it gets highlighted.'
2131
+ : null;
2132
+ },
2133
+ snippet: 'code-go'
2134
+ },
2135
+ {
2136
+ id: 'img-plain',
2137
+ test: function (ctx) {
2138
+ return ctx.plainImage
2139
+ ? 'Your image is inline. Add <code>"right"</code> after the URL and the slide splits: text left, image right.'
2140
+ : null;
2141
+ },
2142
+ snippet: 'img-right'
2143
+ },
2144
+ {
2145
+ id: 'no-notes',
2146
+ test: function (ctx) {
2147
+ return ctx.count >= 3 && !ctx.hasNotes
2148
+ ? 'Speaker notes live in <code>&#60;!-- notes: ... --&#62;</code>. They show under the preview and never reach the deck.'
2149
+ : null;
2150
+ },
2151
+ snippet: 'slide-notes'
2152
+ },
2153
+ {
2154
+ id: 'no-image',
2155
+ test: function (ctx) {
2156
+ return ctx.count >= 4 && !ctx.hasImage
2157
+ ? 'All text so far. An image path resolves against the folder you launched in: <code>![alt](diagram.png "right")</code>.'
2158
+ : null;
2159
+ },
2160
+ snippet: 'img-right'
2161
+ },
2162
+ {
2163
+ id: 'no-table',
2164
+ test: function (ctx) {
2165
+ return ctx.count >= 5 && !ctx.hasTable
2166
+ ? 'Numbers land harder in a table than in bullets. Zebra rows and an accented header come for free.'
2167
+ : null;
2168
+ },
2169
+ snippet: 'table'
2170
+ },
2171
+ {
2172
+ id: 'no-embed',
2173
+ test: function (ctx) {
2174
+ return ctx.count >= 6 && !ctx.hasEmbed
2175
+ ? 'Raw HTML works, so a YouTube or dashboard <code>&#60;iframe&#62;</code> can live on a slide, sized to 16:9.'
2176
+ : null;
2177
+ },
2178
+ snippet: 'embed-youtube'
2179
+ },
2180
+ {
2181
+ id: 'dense',
2182
+ test: function (ctx) {
2183
+ return ctx.denseSlide !== -1
2184
+ ? 'Slide ' + (ctx.denseSlide + 1) + ' is dense. Around seven bullets is the ceiling from the back row.'
2185
+ : null;
2186
+ },
2187
+ snippet: 'slide-break'
2188
+ },
2189
+ {
2190
+ id: 'huge',
2191
+ sticky: true,
2192
+ test: function (ctx) {
2193
+ return ctx.text.length > 3.5 * 1024 * 1024
2194
+ ? 'This deck is over 3.5 MB, near what a browser will autosave. Download a copy.'
2195
+ : null;
2196
+ },
2197
+ action: 'download'
2198
+ }
2199
+ ];
2200
+
2201
+ function docContext() {
2202
+ var text = src.value;
2203
+ var lines = text.split('\\n');
2204
+ var untagged = false, inFence = false;
2205
+ for (var i = 0; i < lines.length; i++) {
2206
+ var m = lines[i].match(/^\\s*\`\`\`(.*)$/);
2207
+ if (!m) continue;
2208
+ if (!inFence && !m[1].trim()) untagged = true;
2209
+ inFence = !inFence;
2210
+ }
2211
+
2212
+ var dense = -1;
2213
+ var spans = slideMap();
2214
+ for (var j = 0; j < spans.length; j++) {
2215
+ var body = text.slice(spans[j][0], spans[j][1]);
2216
+ if (body.indexOf('\`\`\`') !== -1) continue;
2217
+ var bullets = body.split('\\n').filter(function (l) { return /^\\s*([-*+]|\\d+[.)])\\s/.test(l); });
2218
+ if (bullets.length > 9) { dense = j; break; }
2219
+ }
2220
+
2221
+ return {
2222
+ text: text,
2223
+ count: state.slides.length,
2224
+ untaggedFence: untagged,
2225
+ plainImage: /!\\[[^\\]]*\\]\\([^\\s)]+\\)(?!\\s*\\{)/.test(text) && !/!\\[[^\\]]*\\]\\([^\\s)]+\\s+"[^"]*"\\)/.test(text),
2226
+ hasNotes: /<!--\\s*notes?:/i.test(text),
2227
+ hasImage: /!\\[[^\\]]*\\]\\(/.test(text),
2228
+ hasTable: /^\\s*\\|.*\\|\\s*$/m.test(text),
2229
+ hasEmbed: /<(iframe|video)\\b/i.test(text),
2230
+ denseSlide: dense
2231
+ };
2232
+ }
2233
+
2234
+ var bySnippetId = {};
2235
+ D.snippets.forEach(function (s) { bySnippetId[s.id] = s; });
2236
+
2237
+ function evalNudges() {}
2238
+ function hideNudge() {}
2239
+
2240
+ // ── Tips carousel ──────────────────────────────────────────────────────
2241
+ var tipTimer = null;
2242
+
2243
+ function showTip(step) {
2244
+ state.tip = (state.tip + step + D.tips.length) % D.tips.length;
2245
+ $('tip-text').textContent = D.tips[state.tip];
2246
+ }
2247
+
2248
+ function startTips() {
2249
+ showTip(0);
2250
+ if (tipTimer) clearInterval(tipTimer);
2251
+ tipTimer = setInterval(function () { showTip(1); }, 13000);
2252
+ }
2253
+
2254
+ $('tip-prev').addEventListener('click', function () { showTip(-1); startTips(); });
2255
+ $('tip-next').addEventListener('click', function () { showTip(1); startTips(); });
2256
+ $('tipbar').addEventListener('mouseenter', function () { if (tipTimer) clearInterval(tipTimer); });
2257
+ $('tipbar').addEventListener('mouseleave', startTips);
2258
+
2259
+ // ── Command palette ────────────────────────────────────────────────────
2260
+ var palRows = [], palSel = 0;
2261
+
2262
+ function palFilter() {
2263
+ var q = palInput.value.trim().toLowerCase();
2264
+ var terms = q ? q.split(/\\s+/) : [];
2265
+ return D.snippets.filter(function (s) {
2266
+ if (!terms.length) return true;
2267
+ var hay = (s.label + ' ' + s.group + ' ' + s.hint + ' ' + s.syntax).toLowerCase();
2268
+ return terms.every(function (t) { return hay.indexOf(t) !== -1; });
2269
+ });
2270
+ }
2271
+
2272
+ function renderPalette() {
2273
+ var items = palFilter();
2274
+ palRows = items;
2275
+ if (palSel >= items.length) palSel = Math.max(0, items.length - 1);
2276
+ palList.innerHTML = '';
2277
+
2278
+ if (!items.length) {
2279
+ var none = document.createElement('div');
2280
+ none.className = 'pal-group';
2281
+ none.textContent = 'nothing matches';
2282
+ palList.appendChild(none);
2283
+ return;
2284
+ }
2285
+
2286
+ var group = null;
2287
+ items.forEach(function (item, i) {
2288
+ if (item.group !== group) {
2289
+ group = item.group;
2290
+ var head = document.createElement('div');
2291
+ head.className = 'pal-group';
2292
+ head.textContent = group;
2293
+ palList.appendChild(head);
2294
+ }
2295
+ var row = document.createElement('div');
2296
+ row.className = 'pal-row' + (i === palSel ? ' is-sel' : '');
2297
+ row.dataset.i = String(i);
2298
+
2299
+ var main = document.createElement('div');
2300
+ main.className = 'pal-row__main';
2301
+ var label = document.createElement('div');
2302
+ label.className = 'pal-row__label';
2303
+ label.textContent = item.label;
2304
+ var hint = document.createElement('div');
2305
+ hint.className = 'pal-row__hint';
2306
+ hint.textContent = item.hint;
2307
+ main.appendChild(label);
2308
+ main.appendChild(hint);
2309
+ row.appendChild(main);
2310
+
2311
+ if (item.syntax) {
2312
+ var syn = document.createElement('code');
2313
+ syn.className = 'pal-row__syntax';
2314
+ syn.textContent = item.syntax.split('\\\\n').join(' ');
2315
+ row.appendChild(syn);
2316
+ }
2317
+ if (item.keys) {
2318
+ var keys = document.createElement('span');
2319
+ keys.className = 'pal-row__keys';
2320
+ keys.textContent = item.keys.replace('Cmd', CMD);
2321
+ row.appendChild(keys);
2322
+ }
2323
+
2324
+ row.addEventListener('mousemove', function () {
2325
+ if (palSel === i) return;
2326
+ palSel = i;
2327
+ var sel = palList.querySelector('.pal-row.is-sel');
2328
+ if (sel) sel.classList.remove('is-sel');
2329
+ row.classList.add('is-sel');
2330
+ });
2331
+ row.addEventListener('click', function () { palRun(i); });
2332
+ palList.appendChild(row);
2333
+ });
2334
+ }
2335
+
2336
+ function palRun(i) {
2337
+ var item = palRows[i];
2338
+ if (!item) return;
2339
+ closeOverlays();
2340
+ if (item.action) runAction(item.action);
2341
+ else insertSnippet(item);
2342
+ }
2343
+
2344
+ function palMove(step) {
2345
+ if (!palRows.length) return;
2346
+ palSel = (palSel + step + palRows.length) % palRows.length;
2347
+ renderPalette();
2348
+ var sel = palList.querySelector('.pal-row.is-sel');
2349
+ if (sel) sel.scrollIntoView({ block: 'nearest' });
2350
+ }
2351
+
2352
+ function openPalette() {
2353
+ palette.classList.add('is-on');
2354
+ palInput.value = '';
2355
+ palSel = 0;
2356
+ renderPalette();
2357
+ palInput.focus();
2358
+ }
2359
+
2360
+ palInput.addEventListener('input', function () { palSel = 0; renderPalette(); });
2361
+ palInput.addEventListener('keydown', function (e) {
2362
+ if (e.key === 'ArrowDown') { e.preventDefault(); palMove(1); }
2363
+ else if (e.key === 'ArrowUp') { e.preventDefault(); palMove(-1); }
2364
+ else if (e.key === 'Enter') { e.preventDefault(); palRun(palSel); }
2365
+ else if (e.key === 'Escape') { e.preventDefault(); closeOverlays(); }
2366
+ });
2367
+
2368
+ // ── Guide drawer ───────────────────────────────────────────────────────
2369
+ function buildGuide() {
2370
+ guideBody.innerHTML = '';
2371
+ D.groups.forEach(function (group) {
2372
+ var items = D.snippets.filter(function (s) { return s.group === group; });
2373
+ if (!items.length) return;
2374
+
2375
+ var sec = document.createElement('section');
2376
+ sec.className = 'gsec';
2377
+ var head = document.createElement('h3');
2378
+ head.textContent = group;
2379
+ sec.appendChild(head);
2380
+
2381
+ items.forEach(function (item) {
2382
+ var card = document.createElement('div');
2383
+ card.className = 'gcard';
2384
+
2385
+ var top = document.createElement('div');
2386
+ top.className = 'gcard__top';
2387
+ var label = document.createElement('span');
2388
+ label.className = 'gcard__label';
2389
+ label.textContent = item.label;
2390
+ top.appendChild(label);
2391
+ if (item.keys) {
2392
+ var keys = document.createElement('span');
2393
+ keys.className = 'gcard__keys';
2394
+ keys.textContent = item.keys.replace('Cmd', CMD);
2395
+ top.appendChild(keys);
2396
+ }
2397
+ card.appendChild(top);
2398
+
2399
+ var hint = document.createElement('div');
2400
+ hint.className = 'gcard__hint';
2401
+ hint.textContent = item.hint;
2402
+ card.appendChild(hint);
2403
+
2404
+ if (item.syntax) {
2405
+ var syn = document.createElement('pre');
2406
+ syn.className = 'gcard__syntax';
2407
+ syn.textContent = item.syntax.split('\\\\n').join('\\n');
2408
+ card.appendChild(syn);
2409
+ }
2410
+
2411
+ var btn = document.createElement('button');
2412
+ btn.className = 'gcard__ins';
2413
+ btn.textContent = item.action ? 'run' : 'insert at caret';
2414
+ btn.addEventListener('click', function () {
2415
+ closeOverlays();
2416
+ if (item.action) runAction(item.action);
2417
+ else insertSnippet(item);
2418
+ });
2419
+ card.appendChild(btn);
2420
+
2421
+ sec.appendChild(card);
2422
+ });
2423
+
2424
+ guideBody.appendChild(sec);
2425
+ });
2426
+ }
2427
+
2428
+ function closeOverlays(keepFocus) {
2429
+ // A dropdown outranks the picker in the stacking order, so it has to go
2430
+ // down too or it floats over the modal that just opened.
2431
+ closeMenu();
2432
+ palette.classList.remove('is-on');
2433
+ guide.classList.remove('is-on');
2434
+ $('library').classList.remove('is-on');
2435
+ if ($('themes').classList.contains('is-on')) closeThemes(true);
2436
+ updateDocTitle();
2437
+ if (!keepFocus) src.focus();
2438
+ }
2439
+
2440
+ Array.prototype.forEach.call(document.querySelectorAll('[data-close]'), function (el) {
2441
+ el.addEventListener('click', function () { closeOverlays(); });
2442
+ });
2443
+
2444
+ // ── Actions ────────────────────────────────────────────────────────────
2445
+ function slugify(s) {
2446
+ return (s || 'deck').trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-|-$/g, '') || 'deck';
2447
+ }
2448
+
2449
+ function download() {
2450
+ var name = slugify($('docname').value);
2451
+ if (state.kind === 'html') {
2452
+ if (!/\\.html?$/.test(name)) name += '.html';
2453
+ saveBlob(name, new Blob([srcHtml.value], { type: 'text/html;charset=utf-8' }));
2454
+ toast('Downloaded ' + name);
2455
+ return;
2456
+ }
2457
+ if (!/\\.(md|markdown)$/.test(name)) name += '.md';
2458
+ saveBlob(name, new Blob([src.value], { type: 'text/markdown;charset=utf-8' }));
2459
+ toast('Downloaded ' + name);
2460
+ }
2461
+
2462
+ function saveBlob(name, blob) {
2463
+ var a = document.createElement('a');
2464
+ a.href = URL.createObjectURL(blob);
2465
+ a.download = name;
2466
+ document.body.appendChild(a);
2467
+ a.click();
2468
+ setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
2469
+ }
2470
+
2471
+ function isEmpty() {
2472
+ return state.kind === 'html' ? !srcHtml.value.trim() : !state.slides.length;
2473
+ }
2474
+
2475
+ /** Downloads the built deck or doc as one standalone page. */
2476
+ function exportHtml() {
2477
+ if (isEmpty()) { toast('Nothing to export yet.', 'warn'); return; }
2478
+ var builder = state.kind === 'html' ? buildHtmlDoc : buildDeck;
2479
+ builder(false, true)
2480
+ .then(function (data) { return fetch(data.path); })
2481
+ .then(function (r) {
2482
+ if (!r.ok) throw new Error('server said ' + r.status);
2483
+ return r.text();
2484
+ })
2485
+ .then(function (html) {
2486
+ var name = slugify($('docname').value).replace(/\\.(md|markdown|html?)$/, '') + '.html';
2487
+ saveBlob(name, new Blob([html], { type: 'text/html;charset=utf-8' }));
2488
+ toast('Downloaded ' + name + '. Fonts and highlighting load from a CDN, so it needs a connection.');
2489
+ })
2490
+ .catch(function (err) { toast('Could not export HTML: ' + err.message, 'err'); });
2491
+ }
2492
+
2493
+ /**
2494
+ * Exports a real PDF file. The server drives a headless browser, so nobody
2495
+ * has to find the landscape and background-graphics settings in a dialog.
2496
+ * Falls back to the dialog only when the machine has no browser to drive.
2497
+ */
2498
+ var pdfInFlight = false;
2499
+
2500
+ function exportPdf() {
2501
+ if (isEmpty()) { toast('Nothing to export yet.', 'warn'); return; }
2502
+ if (pdfInFlight) { toast('Already building a PDF.'); return; }
2503
+ pdfInFlight = true;
2504
+ elSave.className = 'warn';
2505
+ elSave.textContent = 'building the PDF';
2506
+
2507
+ var pdfUrl = state.kind === 'html' ? '/__pdf-doc' : '/__pdf';
2508
+ var pdfBody = state.kind === 'html'
2509
+ ? { html: srcHtml.value, title: $('docname').value }
2510
+ : {
2511
+ markdown: src.value,
2512
+ theme: state.theme,
2513
+ head: state.head,
2514
+ body: state.body,
2515
+ template: state.template,
2516
+ transition: state.transition,
2517
+ title: $('docname').value
2518
+ };
2519
+
2520
+ api(pdfUrl, {
2521
+ method: 'POST',
2522
+ headers: { 'Content-Type': 'application/json' },
2523
+ body: JSON.stringify(pdfBody)
2524
+ })
2525
+ .then(function (r) {
2526
+ if (r.status === 501) {
2527
+ return r.json().then(function (info) {
2528
+ printFallback(info && info.detail);
2529
+ return null;
2530
+ });
2531
+ }
2532
+ if (!r.ok) {
2533
+ return r.json().then(
2534
+ function (info) { throw new Error((info && info.detail) || 'server said ' + r.status); },
2535
+ function () { throw new Error('server said ' + r.status); }
2536
+ );
2537
+ }
2538
+ return r.blob();
2539
+ })
2540
+ .then(function (blob) {
2541
+ if (!blob) return;
2542
+ var name = slugify($('docname').value).replace(/\\.(md|markdown|html?)$/, '') + '.pdf';
2543
+ saveBlob(name, blob);
2544
+ toast('Downloaded ' + name + (state.kind === 'html' ? '.' : ', one 16:9 page per slide.'));
2545
+ })
2546
+ .catch(function (err) { toast('Could not export the PDF: ' + err.message, 'err'); })
2547
+ .then(function () {
2548
+ pdfInFlight = false;
2549
+ elSave.className = '';
2550
+ elSave.textContent = '';
2551
+ scheduleSave();
2552
+ });
2553
+ }
2554
+
2555
+ /** No local browser to drive: hand the deck or doc to the print dialog instead. */
2556
+ function printFallback(detail) {
2557
+ var tab = window.open('about:blank', '_blank');
2558
+ var builder = state.kind === 'html' ? buildHtmlDoc : buildDeck;
2559
+ builder(true)
2560
+ .then(function (data) {
2561
+ var url = location.origin + data.path;
2562
+ if (!tab) {
2563
+ toast('Allow pop-ups, or press Cmd P, to save a PDF.', 'warn', 'open here', function () { location.href = url; });
2564
+ return;
2565
+ }
2566
+ tab.location.replace(url);
2567
+ toast((detail || 'No local browser to render with.') + ' Using the print dialog instead, which is already set to landscape.', 'warn');
2568
+ })
2569
+ .catch(function (err) {
2570
+ if (tab) tab.close();
2571
+ toast('Could not build the page: ' + err.message, 'err');
2572
+ });
2573
+ }
2574
+
2575
+ function buildDeck(forPrint, standalone) {
2576
+ return api('/__present', {
2577
+ method: 'POST',
2578
+ headers: { 'Content-Type': 'application/json' },
2579
+ body: JSON.stringify({
2580
+ markdown: src.value,
2581
+ theme: state.theme,
2582
+ head: state.head,
2583
+ body: state.body,
2584
+ template: state.template,
2585
+ transition: state.transition,
2586
+ title: $('docname').value,
2587
+ print: !!forPrint,
2588
+ standalone: !!standalone
2589
+ })
2590
+ }).then(function (r) {
2591
+ if (!r.ok) throw new Error('server said ' + r.status);
2592
+ return r.json();
2593
+ });
2594
+ }
2595
+
2596
+ function buildHtmlDoc(forPrint) {
2597
+ return api('/__present-doc', {
2598
+ method: 'POST',
2599
+ headers: { 'Content-Type': 'application/json' },
2600
+ body: JSON.stringify({
2601
+ html: srcHtml.value,
2602
+ theme: state.theme,
2603
+ title: $('docname').value,
2604
+ print: !!forPrint
2605
+ })
2606
+ }).then(function (r) {
2607
+ if (!r.ok) throw new Error('server said ' + r.status);
2608
+ return r.json();
2609
+ });
2610
+ }
2611
+
2612
+ function present() {
2613
+ if (isEmpty()) { toast('Nothing to present yet.', 'warn'); return; }
2614
+ expectingDeck = true;
2615
+ var builder = state.kind === 'html' ? buildHtmlDoc : buildDeck;
2616
+ var tab = window.open('about:blank', '_blank');
2617
+ builder(false)
2618
+ .then(function (data) {
2619
+ var url = location.origin + data.path +
2620
+ (data.path.indexOf('?') >= 0 ? '&' : '?') + 'ps=' + followSid +
2621
+ '&hl=' + encodeURIComponent(highlightKey());
2622
+ if (tab) tab.location.replace(url);
2623
+ else toast('Allow pop-ups to present in a new tab.', 'warn', 'present here', function () { location.href = url; });
2624
+ })
2625
+ .catch(function (err) {
2626
+ if (tab) tab.close();
2627
+ toast('Could not build the page: ' + err.message, 'err');
2628
+ });
2629
+ }
2630
+
2631
+ /**
2632
+ * Applies a theme everywhere at once: the editor chrome, the preview iframe,
2633
+ * and the decor attribute the backdrop patterns key off. A remember of false
2634
+ * means the picker is only previewing, so arrowing past a theme never
2635
+ * overwrites the one the user actually chose.
2636
+ */
2637
+ function setTheme(next, remember) {
2638
+ var id = pickTheme(next);
2639
+ state.theme = id;
2640
+ document.documentElement.dataset.theme = id;
2641
+ document.documentElement.dataset.decor = THEME_BY_ID[id].decor;
2642
+ if (remember !== false) lsSet(K.theme, id);
2643
+ pushLook();
2644
+ }
2645
+
2646
+ function setTemplate(next, remember) {
2647
+ var id = pickTemplate(next);
2648
+ state.template = id;
2649
+ document.documentElement.dataset.template = id;
2650
+ if (remember !== false) {
2651
+ lsSet(K.template, id);
2652
+ scheduleSave();
2653
+ }
2654
+ pushLook();
2655
+ paintTemplateMenu();
2656
+ }
2657
+
2658
+ function setTransition(next, remember) {
2659
+ var id = pickTransition(next);
2660
+ state.transition = id;
2661
+ document.documentElement.dataset.transition = id;
2662
+ if (remember !== false) {
2663
+ lsSet(K.transition, id);
2664
+ scheduleSave();
2665
+ }
2666
+ pushLook();
2667
+ paintTemplateMenu();
2668
+ }
2669
+
2670
+ /**
2671
+ * Heading and body faces are chosen separately, and either can be handed
2672
+ * back to the theme by picking nothing. Passing null is what clears it.
2673
+ */
2674
+ function setFont(slot, id, remember) {
2675
+ if (slot !== 'head' && slot !== 'body') return;
2676
+ var next = pickFont(id);
2677
+ state[slot] = next;
2678
+ if (remember !== false) {
2679
+ // '' rather than a missing key, so a cleared override survives a reload
2680
+ // instead of falling back to whatever the CLI started with.
2681
+ lsSet(slot === 'head' ? K.head : K.body, next || '');
2682
+ }
2683
+ pushLook();
2684
+ paintFontMenu();
2685
+ }
2686
+
2687
+ function syncHtmlFrameTheme() {
2688
+ if (!frameHtml) return;
2689
+ try {
2690
+ var root = frameHtml.contentDocument && frameHtml.contentDocument.documentElement;
2691
+ if (root) {
2692
+ root.dataset.theme = state.theme;
2693
+ root.dataset.decor = decorOf(state.theme);
2694
+ root.dataset.template = state.template;
2695
+ root.dataset.transition = state.transition;
2696
+ if (state.head) root.dataset.head = state.head; else delete root.dataset.head;
2697
+ if (state.body) root.dataset.body = state.body; else delete root.dataset.body;
2698
+ }
2699
+ } catch (e) {}
2700
+ try {
2701
+ if (frameHtml.contentWindow) {
2702
+ frameHtml.contentWindow.postMessage({
2703
+ type: 'theme',
2704
+ theme: state.theme,
2705
+ head: state.head,
2706
+ body: state.body,
2707
+ template: state.template,
2708
+ transition: state.transition
2709
+ }, location.origin);
2710
+ }
2711
+ } catch (e) {}
2712
+ }
2713
+
2714
+ /** One message for all four, since the preview applies them to one root. */
2715
+ function pushLook() {
2716
+ post({
2717
+ type: 'theme',
2718
+ theme: state.theme,
2719
+ head: state.head,
2720
+ body: state.body,
2721
+ template: state.template,
2722
+ transition: state.transition
2723
+ });
2724
+ syncHtmlFrameTheme();
2725
+ paintThemeButton();
2726
+ paintTemplateMenu();
2727
+ }
2728
+
2729
+ function faceLabel(id) {
2730
+ return id && FACE_BY_ID[id] ? FACE_BY_ID[id].name : 'theme';
2731
+ }
2732
+
2733
+ function buildFontMenu() {
2734
+ [['head', 'ff-head'], ['body', 'ff-body']].forEach(function (pair) {
2735
+ var slot = pair[0];
2736
+ var host = $(pair[1]);
2737
+ host.innerHTML = '';
2738
+
2739
+ host.appendChild(fontRow(slot, null, 'theme default', null));
2740
+
2741
+ var lastKind = null;
2742
+ FACES.forEach(function (f) {
2743
+ if (f.kind !== lastKind) {
2744
+ lastKind = f.kind;
2745
+ var head = document.createElement('div');
2746
+ head.className = 'ff-col__title';
2747
+ head.textContent = f.kind;
2748
+ host.appendChild(head);
2749
+ }
2750
+ host.appendChild(fontRow(slot, f.id, f.name, f));
2751
+ });
2752
+ });
2753
+ paintFontMenu();
2754
+ }
2755
+
2756
+ function fontRow(slot, id, label, face) {
2757
+ var b = document.createElement('button');
2758
+ b.className = 'ff-row' + (id ? '' : ' ff-row--auto');
2759
+ b.dataset.slot = slot;
2760
+ if (id) b.dataset.font = id;
2761
+
2762
+ var name = document.createElement('span');
2763
+ name.className = 'ff-row__name';
2764
+ name.textContent = label;
2765
+ if (face) name.style.fontFamily = face.stack;
2766
+ b.appendChild(name);
2767
+
2768
+ return b;
2769
+ }
2770
+
2771
+ function paintFontMenu() {
2772
+ Array.prototype.forEach.call($('font-menu').querySelectorAll('.ff-row'), function (b) {
2773
+ var slot = b.dataset.slot;
2774
+ b.classList.toggle('is-on', (b.dataset.font || null) === state[slot]);
2775
+ });
2776
+ var custom = state.head || state.body;
2777
+ $('font-label').textContent = custom
2778
+ ? faceLabel(state.head) + ' / ' + faceLabel(state.body)
2779
+ : 'font';
2780
+ $('btn-font').title = 'Heading ' + faceLabel(state.head) +
2781
+ ' · body ' + faceLabel(state.body);
2782
+ }
2783
+
2784
+ function paintThemeButton() {
2785
+ var t = THEME_BY_ID[state.theme];
2786
+ $('theme-label').textContent = t ? t.label : 'theme';
2787
+ $('btn-theme').title = t ? t.label + ' — ' + t.blurb : 'Pick a theme';
2788
+ }
2789
+
2790
+ function optionRow(kind, item) {
2791
+ var b = document.createElement('button');
2792
+ b.dataset[kind] = item.id;
2793
+ var name = document.createElement('span');
2794
+ name.className = 'menu__name';
2795
+ name.textContent = item.label;
2796
+ var hint = document.createElement('span');
2797
+ hint.className = 'menu__hint';
2798
+ hint.textContent = item.blurb;
2799
+ b.appendChild(name);
2800
+ b.appendChild(hint);
2801
+ return b;
2802
+ }
2803
+
2804
+ function buildTemplateMenu() {
2805
+ var templateHost = $('template-list');
2806
+ var transitionHost = $('transition-list');
2807
+ templateHost.innerHTML = '';
2808
+ transitionHost.innerHTML = '';
2809
+ TEMPLATES.forEach(function (item) { templateHost.appendChild(optionRow('template', item)); });
2810
+ TRANSITIONS.forEach(function (item) { transitionHost.appendChild(optionRow('transition', item)); });
2811
+ paintTemplateMenu();
2812
+ }
2813
+
2814
+ function paintTemplateMenu() {
2815
+ var menu = $('template-menu');
2816
+ if (!menu) return;
2817
+ Array.prototype.forEach.call(menu.querySelectorAll('[data-template]'), function (b) {
2818
+ b.classList.toggle('is-on', b.dataset.template === state.template);
2819
+ });
2820
+ Array.prototype.forEach.call(menu.querySelectorAll('[data-transition]'), function (b) {
2821
+ b.classList.toggle('is-on', b.dataset.transition === state.transition);
2822
+ });
2823
+ var current = TEMPLATE_BY_ID[state.template];
2824
+ $('template-label').textContent = current ? current.label : 'template';
2825
+ $('btn-template').title = (current ? current.blurb : 'Composition template') +
2826
+ ' · transition ' + state.transition;
2827
+ }
2828
+
2829
+ // ── Theme picker ───────────────────────────────────────────────────────
2830
+ var themeCards = [];
2831
+ var themeSel = 0;
2832
+ var themeCommitted = null;
2833
+
2834
+ /** A slide in miniature, painted with the theme's own palette and faces. */
2835
+ function themeThumb(t) {
2836
+ var c = t.colors;
2837
+ var wrap = document.createElement('div');
2838
+ wrap.className = 'th-thumb';
2839
+ wrap.style.background = c.crust;
2840
+
2841
+ var glow = document.createElement('div');
2842
+ glow.className = 'th-thumb__glow';
2843
+ glow.style.background = 'radial-gradient(circle, ' + c.accent + '2e 0%, transparent 68%)';
2844
+ wrap.appendChild(glow);
2845
+
2846
+ var title = document.createElement('div');
2847
+ title.className = 'th-thumb__title';
2848
+ title.textContent = 'Aa';
2849
+ title.style.fontFamily = t.fonts.display;
2850
+ title.style.fontWeight = '700';
2851
+ title.style.color = c.accent;
2852
+ wrap.appendChild(title);
2853
+
2854
+ var rule = document.createElement('div');
2855
+ rule.className = 'th-thumb__rule';
2856
+ rule.style.background = 'linear-gradient(90deg, ' + c.accent + ', ' + c.accent2 + ')';
2857
+ wrap.appendChild(rule);
2858
+
2859
+ [88, 70, 52].forEach(function (pct, i) {
2860
+ var line = document.createElement('div');
2861
+ line.className = 'th-thumb__line';
2862
+ line.style.width = pct + '%';
2863
+ line.style.background = i === 0 ? c.subtext1 : c.overlay1;
2864
+ line.style.opacity = i === 0 ? '0.85' : '0.5';
2865
+ wrap.appendChild(line);
2866
+ });
2867
+
2868
+ var code = document.createElement('div');
2869
+ code.className = 'th-thumb__code';
2870
+ code.textContent = 'const deck = md';
2871
+ code.style.fontFamily = t.fonts.mono;
2872
+ code.style.background = c.mantle;
2873
+ code.style.color = c.accent3;
2874
+ code.style.border = '1px solid ' + c.surface0;
2875
+ wrap.appendChild(code);
2876
+
2877
+ return wrap;
2878
+ }
2879
+
2880
+ function themeCard(t) {
2881
+ var card = document.createElement('button');
2882
+ card.className = 'th-card' + (t.id === state.theme ? ' is-current' : '');
2883
+ card.dataset.theme = t.id;
2884
+ card.appendChild(themeThumb(t));
2885
+
2886
+ var meta = document.createElement('div');
2887
+ meta.className = 'th-meta';
2888
+
2889
+ var top = document.createElement('div');
2890
+ top.className = 'th-meta__top';
2891
+ var name = document.createElement('span');
2892
+ name.className = 'th-meta__name';
2893
+ name.textContent = t.label;
2894
+ var mood = document.createElement('span');
2895
+ mood.className = 'th-meta__mood';
2896
+ mood.textContent = t.mood;
2897
+ top.appendChild(name);
2898
+ top.appendChild(mood);
2899
+
2900
+ var blurb = document.createElement('div');
2901
+ blurb.className = 'th-meta__blurb';
2902
+ blurb.textContent = t.blurb;
2903
+
2904
+ meta.appendChild(top);
2905
+ meta.appendChild(blurb);
2906
+ card.appendChild(meta);
2907
+ return card;
2908
+ }
2909
+
2910
+ function buildThemePicker() {
2911
+ var host = $('th-list');
2912
+ host.innerHTML = '';
2913
+ themeCards = [];
2914
+
2915
+ [['dark', 'dark'], ['light', 'light']].forEach(function (pair) {
2916
+ var list = THEMES.filter(function (t) { return t.mood === pair[0]; });
2917
+ if (!list.length) return;
2918
+
2919
+ var label = document.createElement('div');
2920
+ label.className = 'th-group';
2921
+ label.textContent = pair[1];
2922
+ host.appendChild(label);
2923
+
2924
+ var grid = document.createElement('div');
2925
+ grid.className = 'th-grid';
2926
+ list.forEach(function (t) {
2927
+ var card = themeCard(t);
2928
+ // Hovering previews too, so the mouse gets the same instant feedback
2929
+ // as the arrow keys.
2930
+ card.addEventListener('mouseenter', function () { selectTheme(themeCards.indexOf(card)); });
2931
+ card.addEventListener('click', function () { selectTheme(themeCards.indexOf(card)); commitTheme(); });
2932
+ grid.appendChild(card);
2933
+ themeCards.push(card);
2934
+ });
2935
+ host.appendChild(grid);
2936
+ });
2937
+ }
2938
+
2939
+ function selectTheme(i) {
2940
+ if (i < 0 || i >= themeCards.length) return;
2941
+ themeSel = i;
2942
+ themeCards.forEach(function (c, n) { c.classList.toggle('is-sel', n === i); });
2943
+ setTheme(themeCards[i].dataset.theme, false);
2944
+ }
2945
+
2946
+ function openThemes() {
2947
+ closeOverlays(true);
2948
+ themeCommitted = state.theme;
2949
+ buildThemePicker();
2950
+ var at = 0;
2951
+ themeCards.forEach(function (c, n) { if (c.dataset.theme === state.theme) at = n; });
2952
+ $('themes').classList.add('is-on');
2953
+ selectTheme(at);
2954
+ themeCards[themeSel].scrollIntoView({ block: 'nearest' });
2955
+ src.blur();
2956
+ }
2957
+
2958
+ function commitTheme() {
2959
+ themeCommitted = state.theme;
2960
+ lsSet(K.theme, state.theme);
2961
+ closeThemes();
2962
+ var t = THEME_BY_ID[state.theme];
2963
+ toast('Theme set to ' + (t ? t.label : state.theme) + '.');
2964
+ }
2965
+
2966
+ /** Leaving without choosing puts back whatever was on when the picker opened. */
2967
+ function closeThemes(restore) {
2968
+ if (restore && themeCommitted && themeCommitted !== state.theme) {
2969
+ setTheme(themeCommitted, true);
2970
+ }
2971
+ $('themes').classList.remove('is-on');
2972
+ themeCards.forEach(function (c) { c.classList.remove('is-sel'); });
2973
+ }
2974
+
2975
+ function themesOpen() { return $('themes').classList.contains('is-on'); }
2976
+
2977
+ function setMode(next) {
2978
+ state.mode = next;
2979
+ lsSet(K.mode, next);
2980
+ $('seg-single').classList.toggle('is-on', next === 'single');
2981
+ $('seg-grid').classList.toggle('is-on', next === 'grid');
2982
+ layout();
2983
+ pushFrame();
2984
+ }
2985
+
2986
+ /** The status-bar counters' HTML-doc analog: chars and caret position. */
2987
+ function renderHtmlCounts() {
2988
+ elChipSlides.textContent = srcHtml.value.length + ' chars';
2989
+ var before = srcHtml.value.slice(0, srcHtml.selectionStart || 0);
2990
+ var lines = before.split('\\n');
2991
+ elPos.textContent = 'Ln ' + lines.length + ', Col ' + (lines[lines.length - 1].length + 1);
2992
+ elWords.textContent = '';
2993
+ }
2994
+
2995
+ /** Load a deck or doc into the editor, saving whatever is open first. */
2996
+ /** File mode owns the session: the browser library stays out of the way. */
2997
+ function fileModeBlocks() {
2998
+ if (!FILE) return false;
2999
+ toast('Editing ' + FILE.name + ' — the browser library is available when deckrun runs without a file.', 'warn');
3000
+ return true;
3001
+ }
3002
+
3003
+ function openDeck(id, announce) {
3004
+ if (fileModeBlocks()) return;
3005
+ var meta = findDeck(id);
3006
+ if (!meta) { toast('That deck is gone.', 'warn'); return; }
3007
+ if (state.deckId && id !== state.deckId) saveNow();
3008
+ state.deckId = id;
3009
+ lsSet(K.current, id);
3010
+ syncHighlightDoc();
3011
+ setDocKind(meta.kind || 'markdown');
3012
+ setTemplate(meta.template || state.template, true);
3013
+ setTransition(meta.transition || state.transition, true);
3014
+ $('docname').value = meta.name;
3015
+ updateDocTitle();
3016
+ updateDeckCount();
3017
+ elSave.className = '';
3018
+ elSave.textContent = '';
3019
+
3020
+ if (state.kind === 'html') {
3021
+ srcHtml.value = readDeck(id);
3022
+ frameHtml.srcdoc = srcHtml.value;
3023
+ renderHtmlCounts();
3024
+ if (announce) toast('Opened ' + meta.name);
3025
+ srcHtml.focus();
3026
+ return;
3027
+ }
3028
+
3029
+ src.value = readDeck(id);
3030
+ src.setSelectionRange(0, 0);
3031
+ src.scrollTop = 0;
3032
+ state.index = 0;
3033
+ state.overflow = {};
3034
+ paint();
3035
+ syncScroll();
3036
+ updateCaretUi();
3037
+ refresh();
3038
+ if (announce) toast('Opened ' + meta.name);
3039
+ src.focus();
3040
+ }
3041
+
3042
+ function noRoom() {
3043
+ toast('This browser has no room for another deck. Delete one, or download this one first.', 'err');
3044
+ }
3045
+
3046
+ function newDeck() {
3047
+ if (fileModeBlocks()) return;
3048
+ saveNow();
3049
+ var id = createDeck(uniqueName('untitled'), '', 'markdown');
3050
+ if (!id) { noRoom(); return; }
3051
+ openDeck(id);
3052
+ toast('New deck. Press ' + CMD + ' K to see what you can add.');
3053
+ }
3054
+
3055
+ function duplicateDeck() {
3056
+ if (fileModeBlocks()) return;
3057
+ saveNow();
3058
+ var meta = findDeck(state.deckId);
3059
+ var id = createDeck(uniqueName((meta ? meta.name : 'deck') + ' copy'), curValue(), state.kind);
3060
+ if (!id) { noRoom(); return; }
3061
+ openDeck(id);
3062
+ toast('Duplicated');
3063
+ }
3064
+
3065
+ function deleteDeck(id) {
3066
+ if (fileModeBlocks()) return;
3067
+ var meta = findDeck(id);
3068
+ if (!meta) return;
3069
+ if (!confirm('Delete "' + meta.name + '"? This cannot be undone, and it is only in this browser.')) return;
3070
+
3071
+ var list = loadIndex().filter(function (d) { return d.id !== id; });
3072
+ saveIndex(list);
3073
+ lsDel(K.deck + id);
3074
+ if (window.deckrunHighlights) window.deckrunHighlights.forget('deck:' + id);
3075
+
3076
+ if (id === state.deckId) {
3077
+ if (list.length) {
3078
+ openDeck(list[0].id);
3079
+ } else {
3080
+ var fresh = createDeck('deck', '', 'markdown');
3081
+ if (fresh) openDeck(fresh);
3082
+ }
3083
+ }
3084
+ updateDeckCount();
3085
+ renderLibrary();
3086
+ toast('Deleted ' + meta.name);
3087
+ }
3088
+
3089
+ // ── Library panel ──────────────────────────────────────────────────────
3090
+ var libRows = [], libSel = 0;
3091
+
3092
+ /** Most recently touched first, the useful order for a switcher. */
3093
+ function libraryList() {
3094
+ return loadIndex().slice().sort(function (a, b) { return (b.at || 0) - (a.at || 0); });
3095
+ }
3096
+
3097
+ function renderLibrary() {
3098
+ var list = libraryList();
3099
+ libRows = list;
3100
+ if (libSel >= list.length) libSel = Math.max(0, list.length - 1);
3101
+ var host = $('lib-list');
3102
+ host.innerHTML = '';
3103
+
3104
+ list.forEach(function (deck, i) {
3105
+ var row = document.createElement('div');
3106
+ row.className = 'lib-row' + (i === libSel ? ' is-sel' : '') +
3107
+ (deck.id === state.deckId ? ' is-current' : '');
3108
+
3109
+ var main = document.createElement('div');
3110
+ main.className = 'lib-row__main';
3111
+ var name = document.createElement('div');
3112
+ name.className = 'lib-row__name';
3113
+ name.textContent = deck.name + (deck.id === state.deckId ? ' (open)' : '');
3114
+ var meta = document.createElement('div');
3115
+ meta.className = 'lib-row__meta';
3116
+ var kind = deck.kind || 'markdown';
3117
+ if (kind === 'html') {
3118
+ var chars = deck.id === state.deckId ? srcHtml.value.length : deck.chars;
3119
+ meta.textContent = 'html' +
3120
+ ' \u00b7 ' + Math.max(1, Math.round((chars || 0) / 1024)) + ' KB' +
3121
+ ' \u00b7 ' + timeAgo(deck.at || Date.now());
3122
+ } else {
3123
+ var slides = deck.id === state.deckId ? state.slides.length : deck.slides;
3124
+ meta.textContent = (slides || 0) + (slides === 1 ? ' slide' : ' slides') +
3125
+ ' \u00b7 ' + Math.max(1, Math.round((deck.chars || 0) / 1024)) + ' KB' +
3126
+ ' \u00b7 ' + timeAgo(deck.at || Date.now());
3127
+ }
3128
+ main.appendChild(name);
3129
+ main.appendChild(meta);
3130
+ row.appendChild(main);
3131
+
3132
+ var acts = document.createElement('div');
3133
+ acts.className = 'lib-row__acts';
3134
+
3135
+ var dupe = document.createElement('button');
3136
+ dupe.textContent = 'duplicate';
3137
+ dupe.addEventListener('click', function (e) {
3138
+ e.stopPropagation();
3139
+ if (deck.id === state.deckId) { duplicateDeck(); renderLibrary(); return; }
3140
+ var id = createDeck(
3141
+ uniqueName(deck.name + ' copy'),
3142
+ readDeck(deck.id),
3143
+ deck.kind || 'markdown',
3144
+ deck.template || state.template,
3145
+ deck.transition || state.transition
3146
+ );
3147
+ if (!id) { noRoom(); return; }
3148
+ updateDeckCount();
3149
+ renderLibrary();
3150
+ toast('Duplicated ' + deck.name);
3151
+ });
3152
+ acts.appendChild(dupe);
3153
+
3154
+ var del = document.createElement('button');
3155
+ del.className = 'danger';
3156
+ del.textContent = 'delete';
3157
+ del.addEventListener('click', function (e) { e.stopPropagation(); deleteDeck(deck.id); });
3158
+ acts.appendChild(del);
3159
+
3160
+ row.appendChild(acts);
3161
+ row.addEventListener('mousemove', function () {
3162
+ if (libSel === i) return;
3163
+ libSel = i;
3164
+ var sel = host.querySelector('.lib-row.is-sel');
3165
+ if (sel) sel.classList.remove('is-sel');
3166
+ row.classList.add('is-sel');
3167
+ });
3168
+ row.addEventListener('click', function () { closeOverlays(); openDeck(deck.id, true); });
3169
+ host.appendChild(row);
3170
+ });
3171
+ }
3172
+
3173
+ function openLibrary() {
3174
+ if (fileModeBlocks()) return;
3175
+ var list = libraryList();
3176
+ libSel = 0;
3177
+ for (var i = 0; i < list.length; i++) if (list[i].id === state.deckId) libSel = i;
3178
+ renderLibrary();
3179
+ $('library').classList.add('is-on');
3180
+ updateDocTitle();
3181
+ }
3182
+
3183
+ $('lib-new').addEventListener('click', function () { closeOverlays(); showStartScreen(); });
3184
+
3185
+ $('btn-decks').addEventListener('click', openLibrary);
3186
+
3187
+ // ── Start screen ─────────────────────────────────────────────────────
3188
+ // Shown on a true first run, and whenever "new" is chosen explicitly from
3189
+ // the library — never on an ordinary launch that has a deck to resume.
3190
+ var startTemplate = state.template;
3191
+ var startTransition = state.transition;
3192
+
3193
+ function paintStartOptions() {
3194
+ Array.prototype.forEach.call($('start-template-list').querySelectorAll('[data-template]'), function (b) {
3195
+ b.classList.toggle('is-on', b.dataset.template === startTemplate);
3196
+ });
3197
+ Array.prototype.forEach.call($('start-transition-list').querySelectorAll('[data-transition]'), function (b) {
3198
+ b.classList.toggle('is-on', b.dataset.transition === startTransition);
3199
+ });
3200
+ }
3201
+
3202
+ function buildStartOptions() {
3203
+ var templateHost = $('start-template-list');
3204
+ var transitionHost = $('start-transition-list');
3205
+ templateHost.innerHTML = '';
3206
+ transitionHost.innerHTML = '';
3207
+ TEMPLATES.forEach(function (item) {
3208
+ var b = document.createElement('button');
3209
+ b.className = 'start-choice';
3210
+ b.dataset.template = item.id;
3211
+ b.textContent = item.label;
3212
+ b.title = item.blurb;
3213
+ b.addEventListener('click', function () { startTemplate = item.id; paintStartOptions(); });
3214
+ templateHost.appendChild(b);
3215
+ });
3216
+ TRANSITIONS.forEach(function (item) {
3217
+ var b = document.createElement('button');
3218
+ b.className = 'start-choice';
3219
+ b.dataset.transition = item.id;
3220
+ b.textContent = item.label;
3221
+ b.title = item.blurb;
3222
+ b.addEventListener('click', function () { startTransition = item.id; paintStartOptions(); });
3223
+ transitionHost.appendChild(b);
3224
+ });
3225
+ paintStartOptions();
3226
+ }
3227
+
3228
+ function showStartScreen() {
3229
+ startTemplate = state.template;
3230
+ startTransition = state.transition;
3231
+ paintStartOptions();
3232
+ $('start-lib').classList.toggle('is-disabled', !loadIndex().length);
3233
+ $('screen-start').classList.add('is-on');
3234
+ updateDocTitle();
3235
+ }
3236
+
3237
+ function hideStartScreenSilently() {
3238
+ $('screen-start').classList.remove('is-on');
3239
+ updateDocTitle();
3240
+ }
3241
+
3242
+ /** Escape/backdrop close. With nothing open yet, that would be a dead end,
3243
+ so the one specified fallback is a blank Markdown deck. */
3244
+ function hideStartScreen(fallbackIfEmpty) {
3245
+ hideStartScreenSilently();
3246
+ if (fallbackIfEmpty && !state.deckId) startNewMarkdown();
3247
+ }
3248
+
3249
+ function startNewMarkdown() {
3250
+ setTemplate(startTemplate, true);
3251
+ setTransition(startTransition, true);
3252
+ var id = createDeck(uniqueName('untitled'), D.welcome, 'markdown', startTemplate, startTransition);
3253
+ hideStartScreenSilently();
3254
+ if (!id) {
3255
+ // Storage refuses even a first write: use the deck unsaved rather than
3256
+ // leaving the editor with nothing open.
3257
+ state.deckId = newDeckId();
3258
+ setDocKind('markdown');
3259
+ src.value = D.welcome;
3260
+ $('docname').value = 'deck';
3261
+ src.setSelectionRange(0, 0);
3262
+ src.scrollTop = 0;
3263
+ paint();
3264
+ syncScroll();
3265
+ updateCaretUi();
3266
+ refresh();
3267
+ updateDeckCount();
3268
+ elSave.className = 'err';
3269
+ elSave.textContent = 'this browser blocks local storage';
3270
+ setTimeout(function () {
3271
+ toast('This browser will not let the editor save anything. Download your deck to keep it.', 'err', 'download', download);
3272
+ }, 700);
3273
+ src.focus();
3274
+ return;
3275
+ }
3276
+ openDeck(id);
3277
+ setTimeout(function () {
3278
+ toast('This deck is yours to overwrite. Press ' + CMD + ' K to see every layout and style.', null, 'open the guide', function () { runAction('guide'); });
3279
+ }, 900);
3280
+ }
3281
+
3282
+ function startOpenLibrary() {
3283
+ if (!loadIndex().length) return;
3284
+ hideStartScreenSilently();
3285
+ openLibrary();
3286
+ }
3287
+
3288
+ /** Fetches a public URL server-side (sidesteps CORS) and opens it as a new Markdown deck or HTML doc. */
3289
+ function loadHtmlUrl() {
3290
+ var input = $('start-html-url');
3291
+ var raw = input.value.trim();
3292
+ if (!raw) { input.focus(); return; }
3293
+ var url;
3294
+ try { url = new URL(raw); } catch (err) { toast('Not a valid URL.', 'err'); return; }
3295
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
3296
+ toast('URL must be http or https.', 'err');
3297
+ return;
3298
+ }
3299
+ var btn = $('start-html-url-go');
3300
+ btn.disabled = true;
3301
+ api('/__fetch-doc', {
3302
+ method: 'POST',
3303
+ headers: { 'Content-Type': 'application/json' },
3304
+ body: JSON.stringify({ url: url.toString() }),
3305
+ })
3306
+ .then(function (res) {
3307
+ return res.json().then(function (body) {
3308
+ if (!res.ok) throw new Error(body.detail || body.error || 'could not load that URL');
3309
+ return body;
3310
+ });
3311
+ })
3312
+ .then(function (body) {
3313
+ saveNow();
3314
+ var name = uniqueName(body.title || url.hostname);
3315
+ var kind = body.kind || (body.markdown ? 'markdown' : 'html');
3316
+ var content = kind === 'markdown' ? (body.markdown || body.content || '') : (body.html || body.content || '');
3317
+ var id = createDeck(
3318
+ name,
3319
+ content,
3320
+ kind,
3321
+ kind === 'markdown' ? startTemplate : state.template,
3322
+ kind === 'markdown' ? startTransition : state.transition
3323
+ );
3324
+ if (!id) { noRoom(); return; }
3325
+ input.value = '';
3326
+ hideStartScreenSilently();
3327
+ openDeck(id);
3328
+ toast('Loaded ' + (kind === 'markdown' ? 'Markdown deck' : 'HTML doc') + ' from ' + url.hostname);
3329
+ })
3330
+ .catch(function (err) { toast('Could not load URL: ' + err.message, 'err'); })
3331
+ .then(function () { btn.disabled = false; });
3332
+ }
3333
+
3334
+ $('start-md-blank').addEventListener('click', startNewMarkdown);
3335
+ $('start-md-upload').addEventListener('click', function () { $('file-any').click(); });
3336
+ $('start-html-upload').addEventListener('click', function () { $('file-any').click(); });
3337
+ $('start-html-url-go').addEventListener('click', loadHtmlUrl);
3338
+ $('start-html-url').addEventListener('keydown', function (e) {
3339
+ if (e.key === 'Enter') { e.preventDefault(); loadHtmlUrl(); }
3340
+ });
3341
+ $('start-lib').addEventListener('click', startOpenLibrary);
3342
+ $('screen-start').querySelector('.backdrop').addEventListener('click', function () { hideStartScreen(true); });
3343
+
3344
+ function runAction(name) {
3345
+ if (name === 'present') present();
3346
+ else if (name === 'download') download();
3347
+ else if (name === 'pdf') exportPdf();
3348
+ else if (name === 'html') exportHtml();
3349
+ else if (name === 'open') $('file-any').click();
3350
+ else if (name === 'grid') setMode(state.mode === 'grid' ? 'single' : 'grid');
3351
+ else if (name === 'theme') openThemes();
3352
+ else if (name === 'guide') { guide.classList.add('is-on'); }
3353
+ else if (name === 'palette') openPalette();
3354
+ else if (name === 'decks') openLibrary();
3355
+ else if (name === 'new') newDeck();
3356
+ else if (name === 'duplicate') duplicateDeck();
3357
+ else if (name === 'delete') deleteDeck(state.deckId);
3358
+ }
3359
+
3360
+ // ── Wiring ─────────────────────────────────────────────────────────────
3361
+ var parseTimer = null;
3362
+
3363
+ function onInput() {
3364
+ // Typing takes the editor back from the deck.
3365
+ if (following) { following = false; followChipEl.classList.add('hidden'); }
3366
+ paint();
3367
+ syncScroll();
3368
+ updateCaretUi();
3369
+ scheduleSave();
3370
+ if (parseTimer) clearTimeout(parseTimer);
3371
+ parseTimer = setTimeout(refresh, 140);
3372
+ }
3373
+
3374
+ src.addEventListener('input', onInput);
3375
+ src.addEventListener('scroll', syncScroll);
3376
+ src.addEventListener('click', updateCaretUi);
3377
+ src.addEventListener('keyup', function (e) {
3378
+ if (e.key.indexOf('Arrow') === 0 || e.key === 'Home' || e.key === 'End' ||
3379
+ e.key === 'PageUp' || e.key === 'PageDown') updateCaretUi();
3380
+ });
3381
+
3382
+ src.addEventListener('keydown', function (e) {
3383
+ if (e.key === 'Tab') {
3384
+ e.preventDefault();
3385
+ typeText(' ');
3386
+ onInput();
3387
+ }
3388
+ });
3389
+
3390
+ // ── HTML doc source ──────────────────────────────────────────────────
3391
+ var htmlPreviewTimer = null;
3392
+
3393
+ function onHtmlInput() {
3394
+ scheduleSave();
3395
+ renderHtmlCounts();
3396
+ if (htmlPreviewTimer) clearTimeout(htmlPreviewTimer);
3397
+ htmlPreviewTimer = setTimeout(function () { frameHtml.srcdoc = srcHtml.value; }, 140);
3398
+ }
3399
+
3400
+ srcHtml.addEventListener('input', onHtmlInput);
3401
+ frameHtml.addEventListener('load', syncHtmlFrameTheme);
3402
+ srcHtml.addEventListener('click', renderHtmlCounts);
3403
+ srcHtml.addEventListener('keyup', function (e) {
3404
+ if (e.key.indexOf('Arrow') === 0 || e.key === 'Home' || e.key === 'End' ||
3405
+ e.key === 'PageUp' || e.key === 'PageDown') renderHtmlCounts();
3406
+ });
3407
+ srcHtml.addEventListener('keydown', function (e) {
3408
+ if (e.key !== 'Tab') return;
3409
+ e.preventDefault();
3410
+ srcHtml.focus();
3411
+ var inserted = false;
3412
+ try { inserted = document.execCommand('insertText', false, ' '); } catch (err) {}
3413
+ if (!inserted) {
3414
+ var s = srcHtml.selectionStart, e2 = srcHtml.selectionEnd;
3415
+ srcHtml.setRangeText(' ', s, e2, 'end');
3416
+ }
3417
+ onHtmlInput();
3418
+ });
3419
+
3420
+ // Drag and drop a .md file onto the editor to open it.
3421
+ ['dragenter', 'dragover'].forEach(function (type) {
3422
+ paneEdit.addEventListener(type, function (e) {
3423
+ if (!e.dataTransfer) return;
3424
+ e.preventDefault();
3425
+ paneEdit.classList.add('is-dropping');
3426
+ });
3427
+ });
3428
+
3429
+ ['dragleave', 'dragend'].forEach(function (type) {
3430
+ paneEdit.addEventListener(type, function (e) {
3431
+ if (e.target === paneEdit || type === 'dragend') paneEdit.classList.remove('is-dropping');
3432
+ });
3433
+ });
3434
+
3435
+ paneEdit.addEventListener('drop', function (e) {
3436
+ e.preventDefault();
3437
+ paneEdit.classList.remove('is-dropping');
3438
+ var files = e.dataTransfer ? e.dataTransfer.files : null;
3439
+ if (!files || !files.length) return;
3440
+ for (var i = 0; i < files.length; i++) {
3441
+ var f = files[i];
3442
+ if (/\\.(md|markdown|txt)$/i.test(f.name)) loadMarkdownFile(f);
3443
+ else if (/\\.html?$/i.test(f.name)) loadHtmlFile(f);
3444
+ else toast('Skipped ' + f.name + '. Drop a Markdown or HTML file to open it.', 'warn');
3445
+ }
3446
+ });
3447
+
3448
+ /** An opened file lands as a new deck, so nothing in the library is lost. */
3449
+ function loadMarkdownFile(file, chosenTemplate, chosenTransition) {
3450
+ if (fileModeBlocks()) return;
3451
+ var fr = new FileReader();
3452
+ fr.onload = function () {
3453
+ saveNow();
3454
+ var markdown = String(fr.result).replace(/\\r\\n/g, '\\n').replace(/\\r/g, '\\n');
3455
+ var name = uniqueName(file.name.replace(/\\.(md|markdown|txt)$/i, '') || 'untitled');
3456
+ var id = createDeck(
3457
+ name,
3458
+ markdown,
3459
+ 'markdown',
3460
+ chosenTemplate || state.template,
3461
+ chosenTransition || state.transition
3462
+ );
3463
+ if (!id) { noRoom(); return; }
3464
+ hideStartScreenSilently();
3465
+ openDeck(id);
3466
+ toast('Loaded ' + file.name + ' as a new deck');
3467
+ };
3468
+ fr.onerror = function () { toast('Could not read ' + file.name, 'err'); };
3469
+ fr.readAsText(file);
3470
+ }
3471
+
3472
+ /** An uploaded HTML doc lands as a new library entry, same as a .md file. */
3473
+ function loadHtmlFile(file) {
3474
+ if (fileModeBlocks()) return;
3475
+ var fr = new FileReader();
3476
+ fr.onload = function () {
3477
+ saveNow();
3478
+ var html = String(fr.result);
3479
+ var name = uniqueName(file.name.replace(/\\.html?$/i, '') || 'untitled');
3480
+ var id = createDeck(name, html, 'html');
3481
+ if (!id) { noRoom(); return; }
3482
+ hideStartScreenSilently();
3483
+ openDeck(id);
3484
+ toast('Loaded ' + file.name + ' as a new HTML doc');
3485
+ };
3486
+ fr.onerror = function () { toast('Could not read ' + file.name, 'err'); };
3487
+ fr.readAsText(file);
3488
+ }
3489
+
3490
+ $('file-any').addEventListener('change', function (e) {
3491
+ var f = e.target.files[0];
3492
+ if (f) {
3493
+ var fromStart = $('screen-start').classList.contains('is-on');
3494
+ if (/\\.(md|markdown|txt)$/i.test(f.name)) {
3495
+ loadMarkdownFile(
3496
+ f,
3497
+ fromStart ? startTemplate : state.template,
3498
+ fromStart ? startTransition : state.transition
3499
+ );
3500
+ }
3501
+ else if (/\\.html?$/i.test(f.name)) loadHtmlFile(f);
3502
+ else toast('Unrecognized file type.', 'warn');
3503
+ }
3504
+ e.target.value = '';
3505
+ });
3506
+
3507
+ $('docname').addEventListener('input', function () {
3508
+ updateDocTitle();
3509
+ scheduleSave();
3510
+ });
3511
+
3512
+ $('btn-guide').addEventListener('click', function () { runAction('guide'); });
3513
+ $('btn-palette').addEventListener('click', openPalette);
3514
+ $('btn-new').addEventListener('click', function () { closeOverlays(); showStartScreen(); });
3515
+ // ── Dropdown menus ─────────────────────────────────────────────────────
3516
+ // Both the export and font popovers behave the same way, so one open menu is
3517
+ // tracked here rather than each growing its own copy of this.
3518
+ var openMenu = null;
3519
+
3520
+ function menuIsOpen() { return !!openMenu; }
3521
+
3522
+ function closeMenu() {
3523
+ if (!openMenu) return;
3524
+ openMenu.classList.remove('is-open');
3525
+ var btn = openMenu.querySelector('.btn');
3526
+ if (btn) btn.setAttribute('aria-expanded', 'false');
3527
+ openMenu = null;
3528
+ }
3529
+
3530
+ function toggleMenu(el) {
3531
+ var wasOpen = openMenu === el;
3532
+ closeMenu();
3533
+ if (wasOpen) return;
3534
+ openMenu = el;
3535
+ el.classList.add('is-open');
3536
+ var btn = el.querySelector('.btn');
3537
+ if (btn) btn.setAttribute('aria-expanded', 'true');
3538
+ var first = el.querySelector('.menu__pop button');
3539
+ if (first) first.focus();
3540
+ }
3541
+
3542
+ /** Wires one dropdown: its trigger, its arrow keys, and its own Escape. */
3543
+ function bindMenu(triggerId, popId, onPick, pickSelector) {
3544
+ var wrap = $(triggerId).parentNode;
3545
+ var pop = $(popId);
3546
+
3547
+ $(triggerId).addEventListener('click', function (e) {
3548
+ e.stopPropagation();
3549
+ toggleMenu(wrap);
3550
+ });
3551
+
3552
+ pop.addEventListener('click', function (e) {
3553
+ var btn = e.target.closest ? e.target.closest(pickSelector) : null;
3554
+ if (!btn) return;
3555
+ onPick(btn);
3556
+ });
3557
+
3558
+ pop.addEventListener('keydown', function (e) {
3559
+ var items = Array.prototype.slice.call(pop.querySelectorAll('button'));
3560
+ var at = items.indexOf(document.activeElement);
3561
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
3562
+ e.preventDefault();
3563
+ var next = (at + (e.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length;
3564
+ items[next].focus();
3565
+ } else if (e.key === 'Escape') {
3566
+ e.preventDefault();
3567
+ closeMenu();
3568
+ src.focus();
3569
+ }
3570
+ });
3571
+
3572
+ return wrap;
3573
+ }
3574
+
3575
+ bindMenu('btn-export', 'export-menu', function (btn) {
3576
+ closeMenu();
3577
+ runExport(btn.dataset.export);
3578
+ }, 'button[data-export]');
3579
+
3580
+ // The font menu stays open across picks: choosing a heading face and then a
3581
+ // body face is one decision, and closing between them would fight the user.
3582
+ bindMenu('btn-font', 'font-menu', function (btn) {
3583
+ setFont(btn.dataset.slot, btn.dataset.font || null, true);
3584
+ }, 'button[data-slot]');
3585
+
3586
+ // Template and transition compose, so the menu remains open while either
3587
+ // choice is changed and the existing Markdown reflows immediately.
3588
+ bindMenu('btn-template', 'template-menu', function (btn) {
3589
+ if (btn.dataset.template) setTemplate(btn.dataset.template, true);
3590
+ if (btn.dataset.transition) setTransition(btn.dataset.transition, true);
3591
+ }, 'button[data-template], button[data-transition]');
3592
+
3593
+ document.addEventListener('click', function (e) {
3594
+ if (openMenu && !openMenu.contains(e.target)) closeMenu();
3595
+ });
3596
+
3597
+ function runExport(kind) {
3598
+ if (kind === 'md') download();
3599
+ else if (kind === 'pdf') exportPdf();
3600
+ else if (kind === 'html') exportHtml();
3601
+ }
3602
+ $('btn-theme').addEventListener('click', function () { runAction('theme'); });
3603
+
3604
+ // The picker owns the arrow keys while it is up, and Enter keeps the preview.
3605
+ document.addEventListener('keydown', function (e) {
3606
+ if (!themesOpen() || e.metaKey || e.ctrlKey || e.altKey) return;
3607
+ var cols = 0;
3608
+ if (themeCards.length) {
3609
+ var grid = themeCards[themeSel].parentNode;
3610
+ cols = Math.max(1, Math.round(grid.clientWidth / themeCards[themeSel].offsetWidth));
3611
+ }
3612
+ var step = 0;
3613
+ if (e.key === 'ArrowRight') step = 1;
3614
+ else if (e.key === 'ArrowLeft') step = -1;
3615
+ else if (e.key === 'ArrowDown') step = cols;
3616
+ else if (e.key === 'ArrowUp') step = -cols;
3617
+ else if (e.key === 'Enter') { e.preventDefault(); commitTheme(); return; }
3618
+ else if (e.key === 'Escape') { e.preventDefault(); closeOverlays(); return; }
3619
+ else return;
3620
+ e.preventDefault();
3621
+ var next = (themeSel + step + themeCards.length) % themeCards.length;
3622
+ selectTheme(next);
3623
+ themeCards[next].scrollIntoView({ block: 'nearest' });
3624
+ }, true);
3625
+ $('btn-present').addEventListener('click', present);
3626
+ $('btn-prev').addEventListener('click', function () { setIndex(state.index - 1, false); });
3627
+ $('btn-next').addEventListener('click', function () { setIndex(state.index + 1, false); });
3628
+ $('seg-single').addEventListener('click', function () { setMode('single'); });
3629
+ $('seg-grid').addEventListener('click', function () { setMode('grid'); });
3630
+
3631
+ $('pane-prev').addEventListener('mousedown', function () {
3632
+ if (document.activeElement && isEditingText()) {
3633
+ document.activeElement.blur();
3634
+ }
3635
+ });
3636
+
3637
+ // ── Global keys ────────────────────────────────────────────────────────
3638
+ function isEditingText() {
3639
+ var el = document.activeElement;
3640
+ if (!el) return false;
3641
+ var tag = el.tagName ? el.tagName.toLowerCase() : '';
3642
+ return tag === 'textarea' || tag === 'input' || el.isContentEditable;
3643
+ }
3644
+
3645
+ document.addEventListener('keydown', function (e) {
3646
+ var mod = e.metaKey || e.ctrlKey;
3647
+
3648
+ if (themesOpen()) return;
3649
+
3650
+ if ($('screen-start').classList.contains('is-on')) {
3651
+ if (e.key === 'Escape') { e.preventDefault(); hideStartScreen(true); }
3652
+ return;
3653
+ }
3654
+
3655
+ if ($('library').classList.contains('is-on') && !mod) {
3656
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
3657
+ e.preventDefault();
3658
+ if (libRows.length) {
3659
+ libSel = (libSel + (e.key === 'ArrowDown' ? 1 : -1) + libRows.length) % libRows.length;
3660
+ renderLibrary();
3661
+ var sel = $('lib-list').querySelector('.lib-row.is-sel');
3662
+ if (sel) sel.scrollIntoView({ block: 'nearest' });
3663
+ }
3664
+ return;
3665
+ }
3666
+ if (e.key === 'Enter') {
3667
+ e.preventDefault();
3668
+ if (libRows[libSel]) { closeOverlays(); openDeck(libRows[libSel].id, true); }
3669
+ return;
3670
+ }
3671
+ }
3672
+
3673
+ if (e.key === 'Escape') {
3674
+ if (menuIsOpen()) { e.preventDefault(); closeMenu(); src.focus(); return; }
3675
+ if (palette.classList.contains('is-on') || guide.classList.contains('is-on') ||
3676
+ $('library').classList.contains('is-on')) {
3677
+ e.preventDefault();
3678
+ closeOverlays();
3679
+ }
3680
+ return;
3681
+ }
3682
+
3683
+ if (mod) {
3684
+ var key = e.key.toLowerCase();
3685
+ var md = state.kind === 'markdown';
3686
+
3687
+ if (key === 'k' && !e.shiftKey && md) { e.preventDefault(); openPalette(); }
3688
+ else if (key === 'o') { e.preventDefault(); openLibrary(); }
3689
+ else if (key === '/' && md) { e.preventDefault(); runAction('guide'); }
3690
+ else if (key === 'p' || (key === 's' && e.shiftKey)) { e.preventDefault(); exportPdf(); }
3691
+ else if (key === 's') { e.preventDefault(); saveNow(); download(); }
3692
+ else if (key === 'enter') { e.preventDefault(); present(); }
3693
+ else if (key === 'd' && md) { e.preventDefault(); insertSnippet(bySnippetId['slide-break']); }
3694
+ else if (key === 'b' && md) { e.preventDefault(); insertSnippet(bySnippetId.bold); }
3695
+ else if (key === 'i' && md) { e.preventDefault(); insertSnippet(bySnippetId.italic); }
3696
+ else if (key === 'e' && md) { e.preventDefault(); insertSnippet(bySnippetId['inline-code']); }
3697
+ else if (key === 'g' && md) { e.preventDefault(); runAction('grid'); }
3698
+ else if (key === 'l' && e.shiftKey) { e.preventDefault(); runAction('theme'); }
3699
+ return;
3700
+ }
3701
+
3702
+ if (palette.classList.contains('is-on') || guide.classList.contains('is-on') ||
3703
+ $('library').classList.contains('is-on') || menuIsOpen()) {
3704
+ return;
3705
+ }
3706
+
3707
+ // Alt + arrows/page keys hops between slides (works anywhere, including inside the editor).
3708
+ if (e.altKey) {
3709
+ if (e.key === 'ArrowDown' || e.key === 'ArrowRight' || e.key === 'PageDown') {
3710
+ e.preventDefault();
3711
+ setIndex(state.index + 1, false);
3712
+ } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft' || e.key === 'PageUp') {
3713
+ e.preventDefault();
3714
+ setIndex(state.index - 1, false);
3715
+ }
3716
+ return;
3717
+ }
3718
+
3719
+ // Direct arrow keys / page keys when not typing into a text field.
3720
+ if (!isEditingText()) {
3721
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === ' ' || e.key === 'PageDown') {
3722
+ e.preventDefault();
3723
+ setIndex(state.index + 1, false);
3724
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') {
3725
+ e.preventDefault();
3726
+ setIndex(state.index - 1, false);
3727
+ } else if (e.key === 'Home') {
3728
+ e.preventDefault();
3729
+ setIndex(0, false);
3730
+ } else if (e.key === 'End') {
3731
+ e.preventDefault();
3732
+ setIndex(state.slides.length - 1, false);
3733
+ }
3734
+ }
3735
+ });
3736
+
3737
+ // ── Split pane ─────────────────────────────────────────────────────────
3738
+ var splitPct = parseFloat(lsGet(K.split, '48'));
3739
+ if (!(splitPct > 15 && splitPct < 85)) splitPct = 48;
3740
+
3741
+ function applySplit() {
3742
+ paneEdit.style.flex = '0 0 ' + splitPct + '%';
3743
+ layout();
3744
+ }
3745
+
3746
+ divider.addEventListener('mousedown', function (e) {
3747
+ e.preventDefault();
3748
+ divider.classList.add('is-dragging');
3749
+ document.body.style.cursor = 'col-resize';
3750
+
3751
+ function move(ev) {
3752
+ var box = $('panes').getBoundingClientRect();
3753
+ var pct = ((ev.clientX - box.left) / box.width) * 100;
3754
+ splitPct = Math.max(18, Math.min(82, pct));
3755
+ applySplit();
3756
+ }
3757
+
3758
+ function up() {
3759
+ divider.classList.remove('is-dragging');
3760
+ document.body.style.cursor = '';
3761
+ lsSet(K.split, String(Math.round(splitPct)));
3762
+ document.removeEventListener('mousemove', move);
3763
+ document.removeEventListener('mouseup', up);
3764
+ }
3765
+
3766
+ document.addEventListener('mousemove', move);
3767
+ document.addEventListener('mouseup', up);
3768
+ });
3769
+
3770
+ divider.addEventListener('dblclick', function () {
3771
+ splitPct = 48;
3772
+ lsSet(K.split, '48');
3773
+ applySplit();
3774
+ });
3775
+
3776
+ window.addEventListener('resize', layout);
3777
+
3778
+ // ── Frame messages ─────────────────────────────────────────────────────
3779
+ window.addEventListener('message', function (e) {
3780
+ // Only the frames this page created may drive it. The source property
3781
+ // cannot be forged, so a page that framed or window.open'd the editor
3782
+ // and posted {type:'action', action:'delete'} at it is refused here.
3783
+ if (e.origin !== location.origin) return;
3784
+ if (e.source !== frame.contentWindow && e.source !== frameHtml.contentWindow) return;
3785
+ var m = e.data || {};
3786
+ if (m.type === 'ready') {
3787
+ // pushLook and pushFrame between them send the whole of the current
3788
+ // state, so a message dropped while the frame was loading is already
3789
+ // superseded by the time it is up.
3790
+ state.frameReady = true;
3791
+ pushLook();
3792
+ pushFrame();
3793
+ } else if (m.type === 'goto') {
3794
+ setMode('single');
3795
+ setIndex(m.index, true);
3796
+ } else if (m.type === 'nav') {
3797
+ setIndex(state.index + m.delta, false);
3798
+ } else if (m.type === 'index-select') {
3799
+ setIndex(m.index, false);
3800
+ } else if (m.type === 'action') {
3801
+ runAction(m.action);
3802
+ } else if (m.type === 'overflow') {
3803
+ var had = !!state.overflow[m.index];
3804
+ state.overflow[m.index] = m.overflow;
3805
+ renderCounts();
3806
+ if (had !== m.overflow) evalNudges();
3807
+ }
3808
+ });
3809
+
3810
+ // ── Boot ───────────────────────────────────────────────────────────────
3811
+ state.bootAt = Date.now();
3812
+
3813
+ // Carry over a deck saved before the library existed.
3814
+ var legacy = lsGet(K.oldDoc, null);
3815
+ if (legacy !== null && !loadIndex().length) {
3816
+ createDeck(lsGet(K.oldName, 'deck'), legacy, 'markdown');
3817
+ lsDel(K.oldDoc);
3818
+ lsDel(K.oldName);
3819
+ }
3820
+
3821
+ document.documentElement.dataset.theme = state.theme;
3822
+ document.documentElement.dataset.decor = THEME_BY_ID[state.theme].decor;
3823
+ document.documentElement.dataset.template = state.template;
3824
+ document.documentElement.dataset.transition = state.transition;
3825
+ buildFontMenu();
3826
+ buildTemplateMenu();
3827
+ buildStartOptions();
3828
+ paintThemeButton();
3829
+ $('seg-single').classList.toggle('is-on', state.mode === 'single');
3830
+ $('seg-grid').classList.toggle('is-on', state.mode === 'grid');
3831
+
3832
+ Array.prototype.forEach.call(document.querySelectorAll('.btn kbd, .menu__pop kbd'), function (el) {
3833
+ el.textContent = el.textContent.replace('Cmd', CMD);
3834
+ });
3835
+
3836
+ buildGuide();
3837
+ startTips();
3838
+ applySplit();
3839
+ paintExportMenu();
3840
+ updateDeckCount();
3841
+
3842
+ // ── File mode ──────────────────────────────────────────────────────────
3843
+ /** Replaces the open document with what the server just read. */
3844
+ function setFileContent(content) {
3845
+ if (state.kind === 'html') {
3846
+ srcHtml.value = content;
3847
+ frameHtml.srcdoc = content;
3848
+ renderHtmlCounts();
3849
+ return;
3850
+ }
3851
+ // Keep the caret and scroll roughly in place across an external reload.
3852
+ var s = src.selectionStart, e = src.selectionEnd, top = src.scrollTop;
3853
+ src.value = content;
3854
+ src.setSelectionRange(Math.min(s, content.length), Math.min(e, content.length));
3855
+ src.scrollTop = top;
3856
+ state.overflow = {};
3857
+ paint();
3858
+ syncScroll();
3859
+ updateCaretUi();
3860
+ refresh();
3861
+ }
3862
+
3863
+ function loadFromDisk(announce) {
3864
+ api('/__file')
3865
+ .then(function (r) {
3866
+ if (!r.ok) throw new Error('HTTP ' + r.status);
3867
+ return r.text();
3868
+ })
3869
+ .then(function (content) {
3870
+ if (content === curValue()) return;
3871
+ setFileContent(content);
3872
+ if (announce) toast('Reloaded ' + FILE.name + ' from disk');
3873
+ })
3874
+ .catch(function (err) {
3875
+ toast('Cannot read ' + FILE.name + ': ' + err.message, 'err');
3876
+ });
3877
+ }
3878
+
3879
+ function bootFromFile() {
3880
+ syncHighlightDoc();
3881
+ setDocKind(FILE.kind);
3882
+ $('docname').value = FILE.name;
3883
+ // The name is the file's; renaming a disk file is the shell's job.
3884
+ $('docname').readOnly = true;
3885
+ $('btn-decks').style.display = 'none';
3886
+ updateDocTitle();
3887
+ loadFromDisk(false);
3888
+ if (FILE.watched && typeof EventSource !== 'undefined') {
3889
+ // EventSource cannot set a header, so the token rides in the query.
3890
+ var es = new EventSource('/__events?token=' + encodeURIComponent(TOKEN));
3891
+ es.onmessage = function (e) {
3892
+ if (e.data === 'reload') loadFromDisk(true);
3893
+ };
3894
+ }
3895
+ (state.kind === 'html' ? srcHtml : src).focus();
3896
+ }
3897
+
3898
+ // A launch with a file opens straight into it; otherwise a deck to resume
3899
+ // opens with zero clicks, exactly as before, and only a true first run —
3900
+ // nothing in the library at all — shows the start screen.
3901
+ if (FILE) {
3902
+ bootFromFile();
3903
+ } else {
3904
+ var index = loadIndex();
3905
+ if (index.length === 0) {
3906
+ showStartScreen();
3907
+ } else {
3908
+ var wanted = lsGet(K.current, null);
3909
+ openDeck(findDeck(wanted) ? wanted : index[0].id);
3910
+ }
3911
+ }
3912
+ })();
3913
+ </script>
3914
+ </body>
3915
+ </html>`;
3916
+ }