fb-slides 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/README.md +47 -10
- package/lib/assets.mjs +227 -0
- package/lib/config.mjs +11 -2
- package/lib/dev.mjs +5 -4
- package/lib/edit.mjs +15 -2
- package/package.json +1 -1
- package/runtime/assets.js +556 -0
- package/runtime/edit.js +65 -15
- package/runtime/snippets.js +198 -69
- package/runtime/syntax.js +64 -0
- package/runtime/theme.base.css +470 -10
- package/templates/starter/assets/screen_2026_08_05_00_24_16.png +0 -0
package/runtime/edit.js
CHANGED
|
@@ -14,11 +14,18 @@
|
|
|
14
14
|
// the URL hash puts the deck back on this slide, and the full pipeline —
|
|
15
15
|
// mermaid, fragments, highlight steps — runs over the saved source.
|
|
16
16
|
//
|
|
17
|
+
// The slide box is written in markdown, so it is coloured like markdown: the
|
|
18
|
+
// textarea sits over a highlighted copy of itself (syntax.js).
|
|
19
|
+
//
|
|
17
20
|
// The `+` over the slide box — or `/` on an empty line — opens the block
|
|
18
21
|
// picker, which writes the markup nobody should have to remember (snippets.js).
|
|
22
|
+
// Files dragged onto the deck while the drawer is open go into assets/ and come
|
|
23
|
+
// back as paths to copy or write into the slide (assets.js).
|
|
19
24
|
// ---------------------------------------------------------------------------
|
|
20
25
|
|
|
26
|
+
import { createAssets, DROP_ICON } from './assets.js';
|
|
21
27
|
import { createSnippets, PLUS_ICON } from './snippets.js';
|
|
28
|
+
import { createSyntax } from './syntax.js';
|
|
22
29
|
|
|
23
30
|
// Where the slide came from, stamped on every section by deck.js: the .md file
|
|
24
31
|
// and the slide's index within it, counted the way the server counts.
|
|
@@ -49,10 +56,6 @@ const joinNotes = ({ content, label, notes }) =>
|
|
|
49
56
|
// one without a CORS preflight, which the dev server never answers.
|
|
50
57
|
const API_HEADERS = { 'x-fb-slides': '1' };
|
|
51
58
|
|
|
52
|
-
// The last stop on the tools' QWER row — pen, arrow, spotlight, pointer, and
|
|
53
|
-
// then this. Reveal answers nothing on T.
|
|
54
|
-
const KEY_CODE = 84;
|
|
55
|
-
|
|
56
59
|
const EDIT_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
|
57
60
|
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
58
61
|
<path d="M11 5H6a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2v-5" />
|
|
@@ -88,13 +91,15 @@ const RevealEdit = () => ({
|
|
|
88
91
|
fetch('/api/editable', { headers: API_HEADERS, cache: 'no-cache' })
|
|
89
92
|
.then((response) => response.json())
|
|
90
93
|
.then((answer) => {
|
|
91
|
-
|
|
94
|
+
// `assets` is the folder a drop lands in — a string, or null for a
|
|
95
|
+
// server that mounts the editing but no place to put a file.
|
|
96
|
+
if (answer?.editable === true) build(deck, answer.assets ?? null);
|
|
92
97
|
})
|
|
93
98
|
.catch(() => {});
|
|
94
99
|
},
|
|
95
100
|
});
|
|
96
101
|
|
|
97
|
-
const build = (deck) => {
|
|
102
|
+
const build = (deck, assetsDir) => {
|
|
98
103
|
// ---- chrome -----------------------------------------------------------
|
|
99
104
|
// The toolbar is annotate's; this plugin registers after the others in
|
|
100
105
|
// deck.js and hangs its button last. A deck running without them still
|
|
@@ -112,9 +117,9 @@ const build = (deck) => {
|
|
|
112
117
|
button.className = 'deck-tool';
|
|
113
118
|
button.type = 'button';
|
|
114
119
|
button.setAttribute('aria-pressed', 'false');
|
|
115
|
-
button.title = 'Edit — this slide’s markdown and notes (
|
|
120
|
+
button.title = 'Edit — this slide’s markdown and notes (Shift+E)';
|
|
116
121
|
button.setAttribute('aria-label', 'Edit the slide');
|
|
117
|
-
button.innerHTML = `${EDIT_ICON}<span class="deck-key" aria-hidden="true"
|
|
122
|
+
button.innerHTML = `${EDIT_ICON}<span class="deck-key" aria-hidden="true">⇧E</span>`;
|
|
118
123
|
toolbar.append(button);
|
|
119
124
|
|
|
120
125
|
const panel = document.createElement('aside');
|
|
@@ -129,7 +134,11 @@ const build = (deck) => {
|
|
|
129
134
|
</header>
|
|
130
135
|
<div class="deck-edit-row">
|
|
131
136
|
<label class="deck-edit-label" for="deck-edit-source">slide</label>
|
|
132
|
-
<
|
|
137
|
+
<span class="deck-edit-row-acts">
|
|
138
|
+
<button type="button" class="deck-edit-drop" hidden
|
|
139
|
+
aria-label="Add a file to the assets folder">${DROP_ICON}</button>
|
|
140
|
+
<button type="button" class="deck-edit-snip" title="Insert a block (/)" aria-label="Insert a block">${PLUS_ICON}</button>
|
|
141
|
+
</span>
|
|
133
142
|
</div>
|
|
134
143
|
<textarea id="deck-edit-source" spellcheck="false"></textarea>
|
|
135
144
|
<label class="deck-edit-label" for="deck-edit-notes">speaker notes</label>
|
|
@@ -177,12 +186,20 @@ const build = (deck) => {
|
|
|
177
186
|
|
|
178
187
|
const markdownPlugin = deck.getPlugin('markdown');
|
|
179
188
|
const highlightPlugin = deck.getPlugin('highlight');
|
|
189
|
+
|
|
190
|
+
// The same hljs that colours the slides' code blocks, colouring the markdown
|
|
191
|
+
// the slide is written in. It wraps the textarea, so it is built before
|
|
192
|
+
// anything else measures or positions itself against the drawer.
|
|
193
|
+
const syntax = createSyntax({ area: sourceArea, hljs: highlightPlugin?.hljs });
|
|
194
|
+
|
|
180
195
|
const marked = markdownPlugin?.marked;
|
|
181
196
|
const render = marked ? (marked.parse ?? marked).bind(marked) : null;
|
|
182
197
|
|
|
183
198
|
let frame = 0;
|
|
184
199
|
const paint = () => {
|
|
185
200
|
frame = 0;
|
|
201
|
+
// The colours track the text even on a slide that has no live preview.
|
|
202
|
+
syntax.refresh();
|
|
186
203
|
if (!editing?.preview) return;
|
|
187
204
|
editing.slide.innerHTML = render(sourceArea.value);
|
|
188
205
|
if (highlightPlugin?.hljs) {
|
|
@@ -206,6 +223,20 @@ const build = (deck) => {
|
|
|
206
223
|
const snippets = createSnippets({ area: sourceArea, paint: schedulePaint });
|
|
207
224
|
panel.append(snippets.element);
|
|
208
225
|
|
|
226
|
+
// ---- the assets drop ----------------------------------------------------
|
|
227
|
+
// Over the drawer like the picker, but the drop itself is the whole page:
|
|
228
|
+
// dragging a file onto the slide you are writing is the gesture, and the
|
|
229
|
+
// drawer being open is what says the file belongs to this deck.
|
|
230
|
+
|
|
231
|
+
const assets = createAssets({ paste: snippets.paste, active: () => Boolean(editing) });
|
|
232
|
+
panel.append(assets.element);
|
|
233
|
+
|
|
234
|
+
const dropButton = panel.querySelector('.deck-edit-drop');
|
|
235
|
+
if (assetsDir) {
|
|
236
|
+
dropButton.hidden = false;
|
|
237
|
+
dropButton.title = `Add a file to ${assetsDir}/ — or just drop it on the deck`;
|
|
238
|
+
}
|
|
239
|
+
|
|
209
240
|
// ---- open and close ---------------------------------------------------
|
|
210
241
|
// The deck is narrowed by CSS while the drawer is out (see theme.base.css);
|
|
211
242
|
// reveal only rescales when told to, so layout() is driven for as long as
|
|
@@ -256,6 +287,8 @@ const build = (deck) => {
|
|
|
256
287
|
cancelAnimationFrame(frame);
|
|
257
288
|
frame = 0;
|
|
258
289
|
snippets.close({ restore: false });
|
|
290
|
+
assets.close();
|
|
291
|
+
assets.hideSheet();
|
|
259
292
|
confirmBox.hidden = true;
|
|
260
293
|
editing.slide.innerHTML = editing.html;
|
|
261
294
|
// The original fragments are back; reveal counts them again.
|
|
@@ -309,6 +342,9 @@ const build = (deck) => {
|
|
|
309
342
|
fileLabel.textContent = src.file;
|
|
310
343
|
sourceArea.value = content;
|
|
311
344
|
notesArea.value = notes;
|
|
345
|
+
// Setting `value` fires no input event, and the slide is already rendered:
|
|
346
|
+
// only the colours need catching up.
|
|
347
|
+
syntax.refresh();
|
|
312
348
|
snippets.reset();
|
|
313
349
|
status.textContent = '';
|
|
314
350
|
saveButton.disabled = false;
|
|
@@ -398,6 +434,7 @@ const build = (deck) => {
|
|
|
398
434
|
setWide(!document.body.classList.contains('edit-wide')),
|
|
399
435
|
);
|
|
400
436
|
panel.querySelector('.deck-edit-snip').addEventListener('click', () => snippets.open());
|
|
437
|
+
dropButton.addEventListener('click', () => (assets.isOpen() ? assets.close() : assets.open()));
|
|
401
438
|
saveButton.addEventListener('click', save);
|
|
402
439
|
sourceArea.addEventListener('input', schedulePaint);
|
|
403
440
|
|
|
@@ -441,19 +478,32 @@ const build = (deck) => {
|
|
|
441
478
|
}
|
|
442
479
|
});
|
|
443
480
|
|
|
444
|
-
//
|
|
445
|
-
//
|
|
481
|
+
// Shift+E opens the drawer — E alone belongs to the spotlight. Reveal drops
|
|
482
|
+
// any shifted key it doesn't whitelist before bindings run, so this listens
|
|
483
|
+
// on the document itself and only borrows reveal's help overlay for the
|
|
484
|
+
// listing. Reveal ignores the keyboard while a textarea has focus, so
|
|
446
485
|
// closing from inside the drawer is Escape's job, taken in capture phase
|
|
447
486
|
// before reveal reads it as the overview toggle.
|
|
448
|
-
deck.
|
|
487
|
+
deck.registerKeyboardShortcut('SHIFT+E', 'Edit the slide');
|
|
488
|
+
document.addEventListener('keydown', (event) => {
|
|
489
|
+
if (event.code !== 'KeyE' || !event.shiftKey) return;
|
|
490
|
+
if (event.ctrlKey || event.altKey || event.metaKey) return;
|
|
491
|
+
const active = document.activeElement;
|
|
492
|
+
if (active && (active.isContentEditable || /input|textarea/i.test(active.tagName))) return;
|
|
493
|
+
event.preventDefault();
|
|
494
|
+
toggle();
|
|
495
|
+
});
|
|
449
496
|
document.addEventListener(
|
|
450
497
|
'keydown',
|
|
451
498
|
(event) => {
|
|
452
499
|
if (event.key !== 'Escape' || !editing) return;
|
|
453
|
-
// One layer at a time, innermost first: the picker,
|
|
454
|
-
//
|
|
500
|
+
// One layer at a time, innermost first: the picker, the assets panel
|
|
501
|
+
// (which has a layer of its own), then a pending delete question, then
|
|
502
|
+
// the drawer itself.
|
|
455
503
|
if (snippets.isOpen()) snippets.close();
|
|
456
|
-
else if (
|
|
504
|
+
else if (assets.escape()) {
|
|
505
|
+
/* the panel took it */
|
|
506
|
+
} else if (confirmBox.hidden) close();
|
|
457
507
|
else confirmBox.hidden = true;
|
|
458
508
|
event.stopPropagation();
|
|
459
509
|
event.preventDefault();
|
package/runtime/snippets.js
CHANGED
|
@@ -17,115 +17,205 @@
|
|
|
17
17
|
const CFG = window.__FB_SLIDES__ ?? {};
|
|
18
18
|
|
|
19
19
|
// `top: true` — the block only works as the first thing in a slide, so that is
|
|
20
|
-
// where it goes, wherever the cursor happens to be.
|
|
20
|
+
// where it goes, wherever the cursor happens to be. `keys` are the words the
|
|
21
|
+
// filter also answers to — what someone types looking for the block ("img",
|
|
22
|
+
// "mermaid") rather than what its row says. `group` is the header the block
|
|
23
|
+
// sits under in the list.
|
|
21
24
|
const BUILT_IN = [
|
|
22
25
|
{
|
|
26
|
+
group: 'Slide',
|
|
23
27
|
label: 'Demo, full slide',
|
|
24
|
-
hint: 'a folder in demo
|
|
28
|
+
hint: 'a folder in demo/ — below the marker, the GitHub fallback',
|
|
29
|
+
keys: 'iframe app live run fallback',
|
|
25
30
|
top: true,
|
|
26
|
-
body:
|
|
31
|
+
body:
|
|
32
|
+
'<!-- demo: ${counter} -->\n\n' +
|
|
33
|
+
'## Live demo\n\n' +
|
|
34
|
+
'<iframe src="../demo/counter/" style="width:100%;height:60vh"></iframe>',
|
|
27
35
|
},
|
|
28
36
|
{
|
|
37
|
+
group: 'Slide',
|
|
29
38
|
label: 'External page, full slide',
|
|
30
|
-
hint: 'any URL
|
|
39
|
+
hint: 'any URL — add | src: demo/x for the source button',
|
|
40
|
+
keys: 'url website iframe embed src fallback',
|
|
41
|
+
top: true,
|
|
42
|
+
body:
|
|
43
|
+
'<!-- demo: ${https://example.com/} -->\n\n' +
|
|
44
|
+
'## Live demo\n\n' +
|
|
45
|
+
'<iframe src="https://example.com/" style="width:100%;height:60vh"></iframe>',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
group: 'Slide',
|
|
49
|
+
label: 'Section divider',
|
|
50
|
+
hint: 'an h1 on its own — the gradient chapter break',
|
|
51
|
+
keys: 'title chapter heading h1 break',
|
|
52
|
+
top: true,
|
|
53
|
+
body: '# ${Section}',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
group: 'Slide',
|
|
57
|
+
label: 'Speaker page',
|
|
58
|
+
hint: 'photos down the left edge, who you are on the right',
|
|
59
|
+
keys: 'author bio about photo intro',
|
|
31
60
|
top: true,
|
|
32
|
-
body:
|
|
61
|
+
body:
|
|
62
|
+
'<!-- .slide: class="author-slide" -->\n\n' +
|
|
63
|
+
'<div class="author-photo">\n' +
|
|
64
|
+
' <img src="assets/author/${portrait.jpg}" alt="" />\n' +
|
|
65
|
+
' <img src="assets/author/on-stage.jpg" alt="" />\n' +
|
|
66
|
+
'</div>\n\n' +
|
|
67
|
+
'<div class="author-bio">\n' +
|
|
68
|
+
' <h1>Your name</h1>\n' +
|
|
69
|
+
' <ul>\n' +
|
|
70
|
+
' <li>What you do</li>\n' +
|
|
71
|
+
' <li>What you are known for</li>\n' +
|
|
72
|
+
' </ul>\n' +
|
|
73
|
+
' <p class="author-meta">The stack · you · work · with</p>\n' +
|
|
74
|
+
' <p class="author-meta">❤️ Life outside work — <strong>yoursite.dev</strong></p>\n' +
|
|
75
|
+
'</div>',
|
|
33
76
|
},
|
|
34
77
|
{
|
|
78
|
+
group: 'Slide',
|
|
79
|
+
label: 'Disabled',
|
|
80
|
+
hint: 'keep the slide, leave it out of the talk',
|
|
81
|
+
keys: 'skip hide off draft',
|
|
82
|
+
top: true,
|
|
83
|
+
body: '<!-- disabled -->',
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
group: 'Slide',
|
|
87
|
+
label: 'Slide class',
|
|
88
|
+
hint: 'hand this slide a class of your own',
|
|
89
|
+
keys: 'css attribute custom style',
|
|
90
|
+
top: true,
|
|
91
|
+
body: '<!-- .slide: class="${name}" -->',
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
group: 'Layout',
|
|
95
|
+
label: 'Two columns',
|
|
96
|
+
hint: 'code on one side, words on the other',
|
|
97
|
+
keys: 'cols split side half layout',
|
|
98
|
+
body:
|
|
99
|
+
'<div class="cols">\n<div class="col">\n\n${left}\n\n</div>\n' +
|
|
100
|
+
'<div class="col">\n\n${right}\n\n</div>\n</div>',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
group: 'Layout',
|
|
35
104
|
label: 'Framed embed',
|
|
36
105
|
hint: 'an iframe that shares the slide with the text',
|
|
106
|
+
keys: 'iframe url page window',
|
|
37
107
|
body: '<div class="frame">\n <iframe src="${https://example.com/}"></iframe>\n</div>',
|
|
38
108
|
},
|
|
39
109
|
{
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
110
|
+
group: 'Layout',
|
|
111
|
+
label: 'Callout',
|
|
112
|
+
hint: 'a bordered block — the point the slide comes back to',
|
|
113
|
+
keys: 'box border note highlight important',
|
|
114
|
+
body: '<div class="box">\n\n${text}\n\n</div>',
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
group: 'Layout',
|
|
118
|
+
label: 'Caption',
|
|
119
|
+
hint: 'a quiet line under a figure',
|
|
120
|
+
keys: 'figure label small muted aside',
|
|
121
|
+
body: '<p class="caption">${text}</p>',
|
|
45
122
|
},
|
|
46
123
|
{
|
|
124
|
+
group: 'Media',
|
|
47
125
|
label: 'Image',
|
|
48
126
|
hint: 'centred, no frame around it',
|
|
127
|
+
keys: 'img picture screenshot png photo',
|
|
49
128
|
body: '',
|
|
50
129
|
},
|
|
51
130
|
{
|
|
52
|
-
|
|
53
|
-
|
|
131
|
+
group: 'Media',
|
|
132
|
+
label: 'Video',
|
|
133
|
+
hint: 'a clip from assets/, with the attributes that make it behave',
|
|
134
|
+
keys: 'clip mp4 movie recording',
|
|
54
135
|
body:
|
|
55
|
-
'<
|
|
56
|
-
'
|
|
136
|
+
'<video src="assets/${clip.mp4}" controls muted playsinline preload="metadata"\n' +
|
|
137
|
+
' style="width: 78%; aspect-ratio: 16 / 9;"></video>',
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
group: 'Media',
|
|
141
|
+
label: 'Video, autoplay',
|
|
142
|
+
hint: 'plays when the slide shows, loops, no controls — a living screenshot',
|
|
143
|
+
keys: 'clip mp4 loop background silent animation',
|
|
144
|
+
body:
|
|
145
|
+
'<video src="assets/${clip.mp4}" data-autoplay muted loop playsinline\n' +
|
|
146
|
+
' style="width: 78%; aspect-ratio: 16 / 9;"></video>',
|
|
57
147
|
},
|
|
58
148
|
{
|
|
149
|
+
group: 'Code',
|
|
59
150
|
label: 'Code',
|
|
60
151
|
hint: 'a fenced block, highlighted',
|
|
152
|
+
keys: 'fence snippet js ts source',
|
|
61
153
|
body: '```${ts}\n${code}\n```',
|
|
62
154
|
},
|
|
63
155
|
{
|
|
156
|
+
group: 'Code',
|
|
64
157
|
label: 'Code, stepped',
|
|
65
158
|
hint: 'the block reveals itself a range of lines at a time',
|
|
159
|
+
keys: 'lines ranges walkthrough highlight steps',
|
|
66
160
|
body: '```${ts} [1-3|5-7|9]\n${code}\n```',
|
|
67
161
|
},
|
|
68
162
|
{
|
|
163
|
+
group: 'Code',
|
|
69
164
|
label: 'Diagram',
|
|
70
165
|
hint: 'a mermaid graph, drawn at load',
|
|
166
|
+
keys: 'mermaid flowchart graph chart boxes',
|
|
71
167
|
body: '```mermaid\nflowchart LR\n A[${Client}] --> B[Server]\n```',
|
|
72
168
|
},
|
|
73
169
|
{
|
|
170
|
+
group: 'Code',
|
|
171
|
+
label: 'Diagram, sequence',
|
|
172
|
+
hint: 'who talks to whom, in order',
|
|
173
|
+
keys: 'mermaid sequence actors messages arrows',
|
|
174
|
+
body:
|
|
175
|
+
'```mermaid\nsequenceDiagram\n' +
|
|
176
|
+
' participant A as ${Client}\n' +
|
|
177
|
+
' participant B as Server\n' +
|
|
178
|
+
' A->>B: request\n' +
|
|
179
|
+
' B-->>A: response\n```',
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
group: 'Text',
|
|
74
183
|
label: 'Fragment',
|
|
75
|
-
hint: 'appears on the next press
|
|
184
|
+
hint: 'a paragraph that appears on the next press',
|
|
185
|
+
keys: 'appear step reveal click',
|
|
76
186
|
body: '<p class="fragment">${text}</p>',
|
|
77
187
|
},
|
|
78
188
|
{
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
189
|
+
group: 'Text',
|
|
190
|
+
label: 'Fragment, any block',
|
|
191
|
+
hint: 'the comment tags whatever sits above it — an image, a list, a box',
|
|
192
|
+
keys: 'appear step reveal element attach',
|
|
193
|
+
body: '${content}\n<!-- .element: class="fragment" -->',
|
|
82
194
|
},
|
|
83
195
|
{
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
196
|
+
group: 'Text',
|
|
197
|
+
label: 'Table',
|
|
198
|
+
hint: 'styled by the theme — first column muted, rows striped',
|
|
199
|
+
keys: 'grid rows columns data',
|
|
200
|
+
body: '| ${Column} | Column |\n| --- | --- |\n| value | value |\n| value | value |',
|
|
87
201
|
},
|
|
88
202
|
{
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
'<div class="author-photo">\n' +
|
|
95
|
-
' <img src="assets/author/${portrait.jpg}" alt="" />\n' +
|
|
96
|
-
' <img src="assets/author/on-stage.jpg" alt="" />\n' +
|
|
97
|
-
'</div>\n\n' +
|
|
98
|
-
'<div class="author-bio">\n' +
|
|
99
|
-
' <h1>Your name</h1>\n' +
|
|
100
|
-
' <ul>\n' +
|
|
101
|
-
' <li>What you do</li>\n' +
|
|
102
|
-
' <li>What you are known for</li>\n' +
|
|
103
|
-
' </ul>\n' +
|
|
104
|
-
' <p class="author-meta">The stack · you · work · with</p>\n' +
|
|
105
|
-
' <p class="author-meta">❤️ Life outside work — <strong>yoursite.dev</strong></p>\n' +
|
|
106
|
-
'</div>',
|
|
107
|
-
},
|
|
108
|
-
{
|
|
109
|
-
label: 'Disabled',
|
|
110
|
-
hint: 'keep the slide, leave it out of the talk',
|
|
111
|
-
top: true,
|
|
112
|
-
body: '<!-- disabled -->',
|
|
113
|
-
},
|
|
114
|
-
{
|
|
115
|
-
label: 'Slide class',
|
|
116
|
-
hint: 'hand this slide a class of your own',
|
|
117
|
-
top: true,
|
|
118
|
-
body: '<!-- .slide: class="${name}" -->',
|
|
203
|
+
group: 'Text',
|
|
204
|
+
label: 'Quote',
|
|
205
|
+
hint: 'a blockquote on the accent bar',
|
|
206
|
+
keys: 'blockquote citation pull saying',
|
|
207
|
+
body: '> ${The line worth quoting.}',
|
|
119
208
|
},
|
|
120
209
|
];
|
|
121
210
|
|
|
122
|
-
// The project's own blocks land after the built-in ones
|
|
123
|
-
//
|
|
211
|
+
// The project's own blocks land after the built-in ones, under their own
|
|
212
|
+
// header unless the config names one. A snippet without a body would be an
|
|
213
|
+
// entry that inserts nothing, so it never reaches the list.
|
|
124
214
|
const catalog = () => [
|
|
125
215
|
...BUILT_IN,
|
|
126
|
-
...(Array.isArray(CFG.snippets) ? CFG.snippets : [])
|
|
127
|
-
(snippet) => snippet && typeof snippet.body === 'string'
|
|
128
|
-
|
|
216
|
+
...(Array.isArray(CFG.snippets) ? CFG.snippets : [])
|
|
217
|
+
.filter((snippet) => snippet && typeof snippet.body === 'string')
|
|
218
|
+
.map((snippet) => ({ group: 'Project', ...snippet })),
|
|
129
219
|
];
|
|
130
220
|
|
|
131
221
|
const PLACEHOLDER = /\$\{([^}]*)\}/g;
|
|
@@ -173,21 +263,31 @@ export const createSnippets = ({ area, paint }) => {
|
|
|
173
263
|
element.innerHTML = `
|
|
174
264
|
<input class="deck-snip-filter" type="text" spellcheck="false" autocomplete="off"
|
|
175
265
|
placeholder="Insert a block…" aria-label="Find a block" />
|
|
266
|
+
<p class="deck-snip-empty" hidden>No block answers to that.</p>
|
|
176
267
|
<ul class="deck-snip-list" role="listbox"></ul>
|
|
268
|
+
<pre class="deck-snip-preview" aria-hidden="true"></pre>
|
|
177
269
|
`;
|
|
178
270
|
|
|
179
271
|
const filter = element.querySelector('.deck-snip-filter');
|
|
180
272
|
const list = element.querySelector('.deck-snip-list');
|
|
273
|
+
const empty = element.querySelector('.deck-snip-empty');
|
|
274
|
+
const preview = element.querySelector('.deck-snip-preview');
|
|
181
275
|
|
|
276
|
+
// Every block under its group's header. A header is furniture, not an
|
|
277
|
+
// option: it carries no data-index, and the keyboard walks straight past it.
|
|
182
278
|
const blocks = catalog();
|
|
279
|
+
let group = null;
|
|
183
280
|
list.innerHTML = blocks
|
|
184
|
-
.map(
|
|
185
|
-
|
|
186
|
-
`<li
|
|
187
|
-
|
|
188
|
-
|
|
281
|
+
.map((snippet, index) => {
|
|
282
|
+
const head =
|
|
283
|
+
snippet.group !== group ? `<li class="deck-snip-head">${(group = snippet.group)}</li>` : '';
|
|
284
|
+
return (
|
|
285
|
+
`${head}<li role="option" data-index="${index}"><span>${snippet.label}</span>` +
|
|
286
|
+
`<small>${snippet.hint ?? ''}</small></li>`
|
|
287
|
+
);
|
|
288
|
+
})
|
|
189
289
|
.join('');
|
|
190
|
-
const items = [...list.
|
|
290
|
+
const items = [...list.querySelectorAll('li[data-index]')];
|
|
191
291
|
|
|
192
292
|
// Where the block goes, decided when the picker opens: the cursor at that
|
|
193
293
|
// moment, or the `/` that summoned it. `take` says whether what that range
|
|
@@ -210,15 +310,21 @@ export const createSnippets = ({ area, paint }) => {
|
|
|
210
310
|
|
|
211
311
|
const current = () => list.querySelector('.is-current');
|
|
212
312
|
|
|
313
|
+
// The pane under the list shows the markup the highlighted row would write,
|
|
314
|
+
// placeholders unwrapped — what lands in the slide, before it lands.
|
|
213
315
|
const highlight = (item) => {
|
|
214
316
|
current()?.classList.remove('is-current');
|
|
215
317
|
item?.classList.add('is-current');
|
|
216
318
|
item?.scrollIntoView({ block: 'nearest' });
|
|
319
|
+
preview.textContent = item ? fill(blocks[Number(item.dataset.index)].body, '').text : '';
|
|
217
320
|
};
|
|
218
321
|
|
|
219
322
|
const apply = () => {
|
|
220
323
|
const shown = items.filter((item) => !item.hidden);
|
|
221
|
-
|
|
324
|
+
empty.hidden = Boolean(shown.length);
|
|
325
|
+
list.hidden = !shown.length;
|
|
326
|
+
preview.hidden = !shown.length;
|
|
327
|
+
if (!shown.length) return highlight(null);
|
|
222
328
|
highlight(shown.includes(current()) ? current() : shown[0]);
|
|
223
329
|
};
|
|
224
330
|
|
|
@@ -226,7 +332,17 @@ export const createSnippets = ({ area, paint }) => {
|
|
|
226
332
|
const query = filter.value.trim().toLowerCase();
|
|
227
333
|
for (const item of items) {
|
|
228
334
|
const snippet = blocks[Number(item.dataset.index)];
|
|
229
|
-
|
|
335
|
+
const words = `${snippet.label} ${snippet.hint ?? ''} ${snippet.keys ?? ''} ${snippet.group ?? ''}`;
|
|
336
|
+
item.hidden = Boolean(query) && !words.toLowerCase().includes(query);
|
|
337
|
+
}
|
|
338
|
+
// A header with nothing left under it goes with its blocks: walk the list,
|
|
339
|
+
// and show each one only if an option before the next header survived.
|
|
340
|
+
for (const head of list.querySelectorAll('.deck-snip-head')) {
|
|
341
|
+
let shown = false;
|
|
342
|
+
for (let li = head.nextElementSibling; li && li.dataset.index; li = li.nextElementSibling) {
|
|
343
|
+
if (!li.hidden) shown = true;
|
|
344
|
+
}
|
|
345
|
+
head.hidden = !shown;
|
|
230
346
|
}
|
|
231
347
|
apply();
|
|
232
348
|
};
|
|
@@ -254,15 +370,19 @@ export const createSnippets = ({ area, paint }) => {
|
|
|
254
370
|
filter.focus();
|
|
255
371
|
};
|
|
256
372
|
|
|
373
|
+
// `literal: true` — the body is already the text, placeholders and all: what
|
|
374
|
+
// the assets panel writes is a path, not a template to type over.
|
|
257
375
|
const insert = (snippet) => {
|
|
258
|
-
const { from, to, take } = target ?? { from:
|
|
376
|
+
const { from, to, take } = target ?? { from: caret[0], to: caret[1], take: false };
|
|
259
377
|
// A block that only works at the top of a slide goes there whatever the
|
|
260
378
|
// cursor was doing, and never swallows a selection on the way.
|
|
261
379
|
const at = snippet.top ? { from: 0, to: 0, take: false } : { from, to, take };
|
|
262
380
|
|
|
263
381
|
const before = area.value.slice(0, at.from);
|
|
264
382
|
const after = area.value.slice(at.to);
|
|
265
|
-
const { text, range } =
|
|
383
|
+
const { text, range } = snippet.literal
|
|
384
|
+
? { text: snippet.body, range: null }
|
|
385
|
+
: fill(snippet.body, at.take ? area.value.slice(at.from, at.to) : '');
|
|
266
386
|
|
|
267
387
|
const lead = gap(before, true);
|
|
268
388
|
const trail = gap(after, false);
|
|
@@ -296,11 +416,11 @@ export const createSnippets = ({ area, paint }) => {
|
|
|
296
416
|
});
|
|
297
417
|
|
|
298
418
|
list.addEventListener('click', (event) => {
|
|
299
|
-
const item = event.target.closest('li');
|
|
419
|
+
const item = event.target.closest('li[data-index]');
|
|
300
420
|
if (item) insert(blocks[Number(item.dataset.index)]);
|
|
301
421
|
});
|
|
302
422
|
list.addEventListener('mousemove', (event) => {
|
|
303
|
-
const item = event.target.closest('li');
|
|
423
|
+
const item = event.target.closest('li[data-index]');
|
|
304
424
|
if (item && item !== current()) highlight(item);
|
|
305
425
|
});
|
|
306
426
|
|
|
@@ -313,7 +433,16 @@ export const createSnippets = ({ area, paint }) => {
|
|
|
313
433
|
open({ from: at, to: at + 1, take: false });
|
|
314
434
|
});
|
|
315
435
|
|
|
316
|
-
return {
|
|
436
|
+
return {
|
|
437
|
+
element,
|
|
438
|
+
open,
|
|
439
|
+
close,
|
|
440
|
+
isOpen: () => !element.hidden,
|
|
441
|
+
reset,
|
|
442
|
+
// A block from somewhere other than the list — the assets panel — landing
|
|
443
|
+
// at the same cursor, with the same blank lines around it.
|
|
444
|
+
paste: (body) => insert({ body, literal: true }),
|
|
445
|
+
};
|
|
317
446
|
};
|
|
318
447
|
|
|
319
448
|
export default createSnippets;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Markdown colour under the slide's textarea.
|
|
3
|
+
//
|
|
4
|
+
// A textarea paints its own text and nothing else — no spans, no colours. So
|
|
5
|
+
// the box becomes two layers: a <pre> holding the same text run through
|
|
6
|
+
// highlight.js, and the real textarea on top of it with transparent ink and a
|
|
7
|
+
// visible caret. They share the font, the padding and the wrapping mode, so
|
|
8
|
+
// every letter lands exactly on its coloured twin, and what you drag, select,
|
|
9
|
+
// undo and type into is still a plain textarea — nothing here touches editing.
|
|
10
|
+
//
|
|
11
|
+
// highlight.js is not a new dependency: it arrives inside reveal's highlight
|
|
12
|
+
// plugin, the one already colouring the code blocks on the slides, and its
|
|
13
|
+
// markdown grammar is in that bundle. The colours themselves are this theme's
|
|
14
|
+
// tokens (see .deck-edit-ink in theme.base.css), not the monokai the slides
|
|
15
|
+
// wear — the drawer follows whatever palette the deck is dressed in.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
export const createSyntax = ({ area, hljs, language = 'markdown' }) => {
|
|
19
|
+
// No highlight plugin, or a bundle without this grammar: the textarea stays
|
|
20
|
+
// exactly as it was, and the drawer never knows.
|
|
21
|
+
if (!hljs?.getLanguage?.(language)) return { refresh: () => {} };
|
|
22
|
+
|
|
23
|
+
const box = document.createElement('div');
|
|
24
|
+
box.className = 'deck-edit-code';
|
|
25
|
+
const layer = document.createElement('pre');
|
|
26
|
+
layer.className = 'deck-edit-ink';
|
|
27
|
+
layer.setAttribute('aria-hidden', 'true');
|
|
28
|
+
const ink = document.createElement('code');
|
|
29
|
+
layer.append(ink);
|
|
30
|
+
|
|
31
|
+
// The textarea keeps its id, its listeners and its place in the flex column
|
|
32
|
+
// — the wrapper takes that place, and the sizing rules with it.
|
|
33
|
+
area.replaceWith(box);
|
|
34
|
+
box.append(layer, area);
|
|
35
|
+
|
|
36
|
+
// The textarea's scrollbar eats width from its text but not from the layer's,
|
|
37
|
+
// and the two would then wrap at different columns. Measuring is the only
|
|
38
|
+
// portable answer: it comes out 0 wherever the bar is drawn as an overlay.
|
|
39
|
+
const gutter = () => {
|
|
40
|
+
box.style.setProperty('--code-gutter', `${area.offsetWidth - area.clientWidth}px`);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const sync = () => {
|
|
44
|
+
layer.scrollTop = area.scrollTop;
|
|
45
|
+
layer.scrollLeft = area.scrollLeft;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const refresh = () => {
|
|
49
|
+
// The trailing newline is the one the textarea keeps a line box for while a
|
|
50
|
+
// paragraph is being ended; without it the layer comes up a line short and
|
|
51
|
+
// the caret sits over nothing.
|
|
52
|
+
ink.innerHTML = hljs.highlight(`${area.value}\n`, { language, ignoreIllegals: true }).value;
|
|
53
|
+
gutter();
|
|
54
|
+
sync();
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Typing is the caller's `paint` (it re-renders the slide too); these are the
|
|
58
|
+
// ways the two layers can drift apart without a keystroke.
|
|
59
|
+
area.addEventListener('scroll', sync);
|
|
60
|
+
new ResizeObserver(gutter).observe(area);
|
|
61
|
+
|
|
62
|
+
refresh();
|
|
63
|
+
return { refresh };
|
|
64
|
+
};
|