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/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
- if (answer?.editable === true) build(deck);
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 (T)';
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">T</span>`;
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
- <button type="button" class="deck-edit-snip" title="Insert a block (/)" aria-label="Insert a block">${PLUS_ICON}</button>
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
- // `addKeyBinding` with a descriptor binds the key and lists it in reveal's
445
- // help overlay. Reveal ignores the keyboard while a textarea has focus, so
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.addKeyBinding({ keyCode: KEY_CODE, key: 'T', description: 'Edit the slide' }, toggle);
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, then a pending
454
- // delete question, then the drawer itself.
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 (confirmBox.hidden) close();
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();
@@ -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/, running on the slide',
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: '<!-- demo: ${counter} -->',
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, taking the whole slide',
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: '<!-- demo: ${https://example.com/} -->',
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
- label: 'Video',
41
- hint: 'a clip from assets/, with the attributes that make it behave',
42
- body:
43
- '<video src="assets/${clip.mp4}" controls muted playsinline preload="metadata"\n' +
44
- ' style="width: 78%; aspect-ratio: 16 / 9;"></video>',
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: '![](assets/${shot.png})',
50
129
  },
51
130
  {
52
- label: 'Two columns',
53
- hint: 'code on one side, words on the other',
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
- '<div class="cols">\n<div class="col">\n\n${left}\n\n</div>\n' +
56
- '<div class="col">\n\n${right}\n\n</div>\n</div>',
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, on its own',
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
- label: 'Callout',
80
- hint: 'a bordered block — the point the slide comes back to',
81
- body: '<div class="box">\n\n${text}\n\n</div>',
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
- label: 'Caption',
85
- hint: 'a quiet line under a figure',
86
- body: '<p class="caption">${text}</p>',
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
- label: 'Speaker page',
90
- hint: 'photos down the left edge, who you are on the right',
91
- top: true,
92
- body:
93
- '<!-- .slide: class="author-slide" -->\n\n' +
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. A snippet without a
123
- // body would be an entry that inserts nothing, so it never reaches the list.
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 : []).filter(
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
- (snippet, index) =>
186
- `<li role="option" data-index="${index}"><span>${snippet.label}</span>` +
187
- `<small>${snippet.hint ?? ''}</small></li>`,
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.children];
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
- if (!shown.length) return;
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
- item.hidden = Boolean(query) && !`${snippet.label} ${snippet.hint ?? ''}`.toLowerCase().includes(query);
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: area.value.length, to: area.value.length };
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 } = fill(snippet.body, at.take ? area.value.slice(at.from, at.to) : '');
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 { element, open, close, isOpen: () => !element.hidden, reset };
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
+ };