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.
- package/README.md +74 -0
- package/lib/config.mjs +4 -0
- package/lib/render.mjs +1 -0
- package/package.json +1 -1
- package/runtime/annotate.js +11 -1
- package/runtime/deck.js +52 -0
- package/runtime/edit.js +76 -14
- package/runtime/pointer.js +9 -1
- package/runtime/snippets.js +313 -0
- package/runtime/spotlight.js +9 -1
- package/runtime/theme.base.css +361 -31
- package/templates/starter/assets/author/photo-1.svg +11 -0
- package/templates/starter/assets/author/photo-2.svg +11 -0
- package/templates/starter/decks/01-intro.md +22 -0
- package/templates/starter/decks/02-demos.md +38 -1
|
@@ -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: '',
|
|
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;
|
package/runtime/spotlight.js
CHANGED
|
@@ -142,7 +142,15 @@ const RevealSpotlight = () => ({
|
|
|
142
142
|
const setSpot = (on) => {
|
|
143
143
|
armed = on;
|
|
144
144
|
button.setAttribute('aria-pressed', String(on));
|
|
145
|
-
|
|
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 = '';
|