fb-slides 0.5.0 → 0.6.2

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.
@@ -0,0 +1,313 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The block picker — the `+` in the edit drawer, and `/` on an empty line.
3
+ //
4
+ // A snippet is the markup you would otherwise have to remember: the attributes
5
+ // a <video> needs to behave, the `min-width: 0` two columns fall over without,
6
+ // the comment that turns a slide into a live demo. Nothing here renders — the
7
+ // classes it reaches for are the slide vocabulary in theme.base.css.
8
+ //
9
+ // `${…}` in a body marks something to type over: the braces are dropped on the
10
+ // way in and the first one is left selected, so a snippet lands ready to fill.
11
+ // Text selected in the textarea takes the place of that first one — select a
12
+ // paragraph, pick Fragment, and the paragraph is what gets wrapped.
13
+ //
14
+ // `snippets:` in slides.config.js adds a project's own blocks to the list.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ const CFG = window.__FB_SLIDES__ ?? {};
18
+
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.
21
+ const BUILT_IN = [
22
+ {
23
+ label: 'Demo, full slide',
24
+ hint: 'a folder in demo/, running on the slide',
25
+ top: true,
26
+ body: '<!-- demo: ${counter} -->',
27
+ },
28
+ {
29
+ label: 'External page, full slide',
30
+ hint: 'any URL, taking the whole slide',
31
+ top: true,
32
+ body: '<!-- demo: ${https://example.com/} -->',
33
+ },
34
+ {
35
+ label: 'Framed embed',
36
+ hint: 'an iframe that shares the slide with the text',
37
+ body: '<div class="frame">\n <iframe src="${https://example.com/}"></iframe>\n</div>',
38
+ },
39
+ {
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>',
45
+ },
46
+ {
47
+ label: 'Image',
48
+ hint: 'centred, no frame around it',
49
+ body: '![](assets/${shot.png})',
50
+ },
51
+ {
52
+ label: 'Two columns',
53
+ hint: 'code on one side, words on the other',
54
+ 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>',
57
+ },
58
+ {
59
+ label: 'Code',
60
+ hint: 'a fenced block, highlighted',
61
+ body: '```${ts}\n${code}\n```',
62
+ },
63
+ {
64
+ label: 'Code, stepped',
65
+ hint: 'the block reveals itself a range of lines at a time',
66
+ body: '```${ts} [1-3|5-7|9]\n${code}\n```',
67
+ },
68
+ {
69
+ label: 'Diagram',
70
+ hint: 'a mermaid graph, drawn at load',
71
+ body: '```mermaid\nflowchart LR\n A[${Client}] --> B[Server]\n```',
72
+ },
73
+ {
74
+ label: 'Fragment',
75
+ hint: 'appears on the next press, on its own',
76
+ body: '<p class="fragment">${text}</p>',
77
+ },
78
+ {
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>',
82
+ },
83
+ {
84
+ label: 'Caption',
85
+ hint: 'a quiet line under a figure',
86
+ body: '<p class="caption">${text}</p>',
87
+ },
88
+ {
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: 'Slide class',
110
+ hint: 'hand this slide a class of your own',
111
+ top: true,
112
+ body: '<!-- .slide: class="${name}" -->',
113
+ },
114
+ ];
115
+
116
+ // The project's own blocks land after the built-in ones. A snippet without a
117
+ // body would be an entry that inserts nothing, so it never reaches the list.
118
+ const catalog = () => [
119
+ ...BUILT_IN,
120
+ ...(Array.isArray(CFG.snippets) ? CFG.snippets : []).filter(
121
+ (snippet) => snippet && typeof snippet.body === 'string',
122
+ ),
123
+ ];
124
+
125
+ const PLACEHOLDER = /\$\{([^}]*)\}/g;
126
+
127
+ // Unwrap every `${…}` to the word inside it, and hand back where the first one
128
+ // ended up — that is the range the cursor lands on. `taken` is the text the
129
+ // editor had selected, which stands in for that first placeholder.
130
+ const fill = (body, taken) => {
131
+ let text = '';
132
+ let range = null;
133
+ let read = 0;
134
+ for (const match of body.matchAll(PLACEHOLDER)) {
135
+ const word = range === null && taken ? taken : match[1];
136
+ text += body.slice(read, match.index) + word;
137
+ range ??= [text.length - word.length, text.length];
138
+ read = match.index + match[0].length;
139
+ }
140
+ return { text: text + body.slice(read), range };
141
+ };
142
+
143
+ // A block wants a blank line around it, and exactly one: pasting into the
144
+ // middle of a paragraph should not glue the markup onto a sentence, and
145
+ // pasting into a gap should not open a second one.
146
+ const gap = (text, atEnd) => {
147
+ if (!text) return '';
148
+ const touching = atEnd ? /\n\n$/.test(text) : /^\n\n/.test(text);
149
+ if (touching) return '';
150
+ return (atEnd ? /\n$/.test(text) : /^\n/.test(text)) ? '\n' : '\n\n';
151
+ };
152
+
153
+ export const PLUS_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"
154
+ stroke-linecap="round" aria-hidden="true">
155
+ <path d="M12 6v12" /><path d="M6 12h12" />
156
+ </svg>`;
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // The picker itself. `area` is the slide's textarea; `paint` re-renders the
160
+ // slide the way typing into it does.
161
+ // ---------------------------------------------------------------------------
162
+
163
+ export const createSnippets = ({ area, paint }) => {
164
+ const element = document.createElement('div');
165
+ element.className = 'deck-snip';
166
+ element.hidden = true;
167
+ element.innerHTML = `
168
+ <input class="deck-snip-filter" type="text" spellcheck="false" autocomplete="off"
169
+ placeholder="Insert a block…" aria-label="Find a block" />
170
+ <ul class="deck-snip-list" role="listbox"></ul>
171
+ `;
172
+
173
+ const filter = element.querySelector('.deck-snip-filter');
174
+ const list = element.querySelector('.deck-snip-list');
175
+
176
+ const blocks = catalog();
177
+ list.innerHTML = blocks
178
+ .map(
179
+ (snippet, index) =>
180
+ `<li role="option" data-index="${index}"><span>${snippet.label}</span>` +
181
+ `<small>${snippet.hint ?? ''}</small></li>`,
182
+ )
183
+ .join('');
184
+ const items = [...list.children];
185
+
186
+ // Where the block goes, decided when the picker opens: the cursor at that
187
+ // moment, or the `/` that summoned it. `take` says whether what that range
188
+ // covers is the author's own text, to be threaded through the snippet.
189
+ let target = null;
190
+
191
+ // The cursor as it was when the textarea last had it. A textarea that has
192
+ // never been clicked reports 0, which would drop every block at the top of
193
+ // the slide instead of at the end of what is written.
194
+ let caret = [0, 0];
195
+ const remember = () => {
196
+ caret = [area.selectionStart, area.selectionEnd];
197
+ };
198
+ for (const event of ['keyup', 'click', 'select', 'blur']) area.addEventListener(event, remember);
199
+
200
+ // A new slide in the drawer: the old cursor belonged to the old text.
201
+ const reset = () => {
202
+ caret = [area.value.length, area.value.length];
203
+ };
204
+
205
+ const current = () => list.querySelector('.is-current');
206
+
207
+ const highlight = (item) => {
208
+ current()?.classList.remove('is-current');
209
+ item?.classList.add('is-current');
210
+ item?.scrollIntoView({ block: 'nearest' });
211
+ };
212
+
213
+ const apply = () => {
214
+ const shown = items.filter((item) => !item.hidden);
215
+ if (!shown.length) return;
216
+ highlight(shown.includes(current()) ? current() : shown[0]);
217
+ };
218
+
219
+ const search = () => {
220
+ const query = filter.value.trim().toLowerCase();
221
+ for (const item of items) {
222
+ const snippet = blocks[Number(item.dataset.index)];
223
+ item.hidden = Boolean(query) && !`${snippet.label} ${snippet.hint ?? ''}`.toLowerCase().includes(query);
224
+ }
225
+ apply();
226
+ };
227
+
228
+ const close = ({ restore = true } = {}) => {
229
+ if (element.hidden) return;
230
+ element.hidden = true;
231
+ target = null;
232
+ if (!restore) return;
233
+ area.focus();
234
+ area.setSelectionRange(caret[0], caret[1]);
235
+ };
236
+
237
+ const open = (where) => {
238
+ target = where ?? {
239
+ // Live if the cursor is still in the box, remembered if it moved on.
240
+ ...(document.activeElement === area
241
+ ? { from: area.selectionStart, to: area.selectionEnd }
242
+ : { from: caret[0], to: caret[1] }),
243
+ take: true,
244
+ };
245
+ filter.value = '';
246
+ search();
247
+ element.hidden = false;
248
+ filter.focus();
249
+ };
250
+
251
+ const insert = (snippet) => {
252
+ const { from, to, take } = target ?? { from: area.value.length, to: area.value.length };
253
+ // A block that only works at the top of a slide goes there whatever the
254
+ // cursor was doing, and never swallows a selection on the way.
255
+ const at = snippet.top ? { from: 0, to: 0, take: false } : { from, to, take };
256
+
257
+ const before = area.value.slice(0, at.from);
258
+ const after = area.value.slice(at.to);
259
+ const { text, range } = fill(snippet.body, at.take ? area.value.slice(at.from, at.to) : '');
260
+
261
+ const lead = gap(before, true);
262
+ const trail = gap(after, false);
263
+ area.value = before + lead + text + trail + after;
264
+
265
+ const start = before.length + lead.length;
266
+ close({ restore: false });
267
+ area.focus();
268
+ if (range) area.setSelectionRange(start + range[0], start + range[1]);
269
+ else area.setSelectionRange(start + text.length, start + text.length);
270
+ remember();
271
+ paint();
272
+ };
273
+
274
+ // ---- wiring -------------------------------------------------------------
275
+
276
+ filter.addEventListener('input', search);
277
+
278
+ filter.addEventListener('keydown', (event) => {
279
+ const shown = items.filter((item) => !item.hidden);
280
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
281
+ event.preventDefault();
282
+ const step = event.key === 'ArrowDown' ? 1 : -1;
283
+ const index = shown.indexOf(current());
284
+ highlight(shown[(index + step + shown.length) % shown.length]);
285
+ } else if (event.key === 'Enter') {
286
+ event.preventDefault();
287
+ const item = current();
288
+ if (item) insert(blocks[Number(item.dataset.index)]);
289
+ }
290
+ });
291
+
292
+ list.addEventListener('click', (event) => {
293
+ const item = event.target.closest('li');
294
+ if (item) insert(blocks[Number(item.dataset.index)]);
295
+ });
296
+ list.addEventListener('mousemove', (event) => {
297
+ const item = event.target.closest('li');
298
+ if (item && item !== current()) highlight(item);
299
+ });
300
+
301
+ // `/` on an empty line opens the same list without leaving the keyboard, and
302
+ // the block takes the place of the slash.
303
+ area.addEventListener('input', (event) => {
304
+ if (event.inputType !== 'insertText' || event.data !== '/') return;
305
+ const at = area.selectionStart - 1;
306
+ if (at > 0 && area.value[at - 1] !== '\n') return;
307
+ open({ from: at, to: at + 1, take: false });
308
+ });
309
+
310
+ return { element, open, close, isOpen: () => !element.hidden, reset };
311
+ };
312
+
313
+ export default createSnippets;
@@ -142,7 +142,15 @@ const RevealSpotlight = () => ({
142
142
  const setSpot = (on) => {
143
143
  armed = on;
144
144
  button.setAttribute('aria-pressed', String(on));
145
- toolbar.classList.toggle('is-armed', on);
145
+ // Recomputed from every module's button, not toggled with this one's
146
+ // boolean: the arming handshake runs the loser's stand-down after the
147
+ // winner set the class, and the last writer would close the bar with a
148
+ // tool still armed. The edit chip's pressed state is the drawer, so it
149
+ // does not count.
150
+ toolbar.classList.toggle(
151
+ 'is-armed',
152
+ !!toolbar.querySelector('.deck-tool[aria-pressed="true"]:not(#tool-edit)'),
153
+ );
146
154
  // Arming must start from a whole veil: a hole still mid-fade from last
147
155
  // time would leak the pointer through (see clear above).
148
156
  if (on) veil.style.clipPath = '';