fb-slides 0.4.2 → 0.5.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/README.md +25 -0
- package/docs/framework-demos.md +164 -0
- package/lib/dev.mjs +5 -0
- package/lib/edit.mjs +167 -0
- package/lib/server.mjs +7 -3
- package/package.json +2 -1
- package/runtime/deck.js +12 -5
- package/runtime/edit.js +445 -0
- package/runtime/spotlight.js +13 -1
- package/runtime/theme.base.css +234 -3
- package/templates/starter/README.md +3 -0
package/README.md
CHANGED
|
@@ -97,6 +97,10 @@ transform and that fires no resize inside the frame.
|
|
|
97
97
|
**Moving a demo is moving those lines.** Put the block wherever you want it, in any file;
|
|
98
98
|
delete it and the demo slide is gone.
|
|
99
99
|
|
|
100
|
+
A demo can also be a whole application with its own dev server — Angular, or anything on
|
|
101
|
+
Vite — copied into `demo/` as it is. It is embedded by **URL**, not by folder name:
|
|
102
|
+
[docs/framework-demos.md](docs/framework-demos.md).
|
|
103
|
+
|
|
100
104
|
### Stepped code highlighting
|
|
101
105
|
|
|
102
106
|
The line ranges live in the fence's info string — reveal reveals one group per click:
|
|
@@ -123,6 +127,22 @@ A `Note:` block at the end of a slide never shows on screen, only in the speaker
|
|
|
123
127
|
Note: a bad description is the most common reason a tool never gets used.
|
|
124
128
|
```
|
|
125
129
|
|
|
130
|
+
### Editing in the browser
|
|
131
|
+
|
|
132
|
+
On the dev server, `T` (or the last button on the toolbar) opens the slide you
|
|
133
|
+
are looking at in a drawer: its markdown in one box, its speaker notes in
|
|
134
|
+
another. Typing re-renders the real slide in place — same renderer, same theme —
|
|
135
|
+
and **Save** writes the text back into the slide's own lines of the `.md`,
|
|
136
|
+
leaving the rest of the file untouched. `Esc` closes without saving; moving to
|
|
137
|
+
another slide — the header's arrows do it without leaving the editor — keeps
|
|
138
|
+
the drawer open on it, dropping anything unsaved. **Add** inserts a fresh slide
|
|
139
|
+
right after the current one and opens it; **Delete** removes the slide from its
|
|
140
|
+
file, after asking. Clicking into the notes box trades the room with the slide
|
|
141
|
+
box, so both are comfortable to write in.
|
|
142
|
+
|
|
143
|
+
The drawer exists only under `fb-slides dev`: a built deck is static files and
|
|
144
|
+
never shows the button.
|
|
145
|
+
|
|
126
146
|
## Presenting
|
|
127
147
|
|
|
128
148
|
| Key | |
|
|
@@ -132,6 +152,7 @@ Note: a bad description is the most common reason a tool never gets used.
|
|
|
132
152
|
| `W` | the arrow — click the tail, then the tip; same colours as the pen |
|
|
133
153
|
| `E` | the spotlight — drag a rectangle, the rest of the slide dims and blurs |
|
|
134
154
|
| `R` | the pointer — replaces the cursor: a glowing halo, a dart aimed at the centre, or the normal mouse |
|
|
155
|
+
| `T` | edit the slide — markdown and notes, saved back into the `.md` (dev server only) |
|
|
135
156
|
| `S` | speaker view — notes, timer, next slide |
|
|
136
157
|
| `Esc` / `O` | overview — the slides as a grid |
|
|
137
158
|
| `V` | the vertical navigator — every slide by title, down the left edge |
|
|
@@ -181,6 +202,10 @@ export default {
|
|
|
181
202
|
A `servers:` entry that fails — a missing `node_modules`, a busy port — prints a warning
|
|
182
203
|
and nothing more: the slide that embeds it comes up empty, the rest of the deck works.
|
|
183
204
|
|
|
205
|
+
Copying a real Angular or Vite app into `demo/` and wiring it up — including the port flag
|
|
206
|
+
Vite needs so a busy port fails instead of moving:
|
|
207
|
+
[docs/framework-demos.md](docs/framework-demos.md).
|
|
208
|
+
|
|
184
209
|
## Theming
|
|
185
210
|
|
|
186
211
|
`theme.css` in the project is loaded **after** the package's base theme, so it overrides
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Demos that are real apps
|
|
2
|
+
|
|
3
|
+
A demo folder is usually a static page the deck serves itself. It can also be **a whole
|
|
4
|
+
application with its own dev server** — an Angular or a Vite project, copied in as it is,
|
|
5
|
+
keeping its `package.json`, its `.gitignore`, its `node_modules`. Nothing gets renamed and
|
|
6
|
+
nothing gets flattened.
|
|
7
|
+
|
|
8
|
+
That kind of demo needs two things said out loud: an entry in `servers:` so `dev` starts
|
|
9
|
+
it, and its **URL** in the slide.
|
|
10
|
+
|
|
11
|
+
## Copy the app in
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
cp -R ~/projects/checkout demo/checkout
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
That is the whole step. One thing to check afterwards: some scaffolders initialise a git
|
|
18
|
+
repository of their own — `ng new` does, `create-vite` does not — and a nested `.git` makes
|
|
19
|
+
the outer repo treat the folder as something it must not touch.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
rm -rf demo/checkout/.git
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Install it
|
|
26
|
+
|
|
27
|
+
The project's own `npm install` does **not** reach into `demo/`. There are no workspaces,
|
|
28
|
+
on purpose: someone who scaffolds a deck and deletes the framework demo should not have
|
|
29
|
+
downloaded it first. So install it separately, once:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm --prefix demo/checkout install
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Until you do, `dev` says so and carries on without it:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
⚠ checkout: no node_modules — run `npm install` in demo/checkout.
|
|
39
|
+
Its slides will show an empty frame until you do.
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Declare its server
|
|
43
|
+
|
|
44
|
+
In `slides.config.js`. The shape is the same for every framework; only the script and the
|
|
45
|
+
port flags differ.
|
|
46
|
+
|
|
47
|
+
**Angular** — the script is `start`, and `ng serve` refuses to start on a taken port, which
|
|
48
|
+
is the behaviour you want:
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
{
|
|
52
|
+
name: 'checkout',
|
|
53
|
+
cwd: 'demo/checkout',
|
|
54
|
+
command: 'npm',
|
|
55
|
+
args: ['start', '--', '--port', '4200'],
|
|
56
|
+
url: 'http://localhost:4200/',
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**React, Vue, Svelte — anything on Vite** — the script is `dev`, and `--strictPort` is not
|
|
61
|
+
optional:
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
{
|
|
65
|
+
name: 'storefront',
|
|
66
|
+
cwd: 'demo/storefront',
|
|
67
|
+
command: 'npm',
|
|
68
|
+
args: ['run', 'dev', '--', '--port', '5173', '--strictPort'],
|
|
69
|
+
url: 'http://localhost:5173/',
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Without it, Vite does not fail on a busy port — it takes the next one and says so in a line
|
|
74
|
+
you will not be reading:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
Port 5173 is in use, trying another one...
|
|
78
|
+
➜ Local: http://localhost:5174/
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The app is then up and healthy on 5174 while the slide still points at 5173, and you find
|
|
82
|
+
out in front of the room. With `--strictPort` it exits instead, and `dev` tells you which
|
|
83
|
+
slide just died:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
⚠ storefront stopped (exit 1) — its slides will be empty.
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Three details worth knowing about the entry:
|
|
90
|
+
|
|
91
|
+
- `url:` is **only** the log line. It wires nothing up — the slide does that.
|
|
92
|
+
- omit `args` and `command` goes through a shell, so `command: 'npm start -- --port 4200'`
|
|
93
|
+
works too. With `args` there is no shell and no quoting to get wrong.
|
|
94
|
+
- `env: { … }` is merged over the current environment when the app needs variables.
|
|
95
|
+
|
|
96
|
+
## Embed it in a slide
|
|
97
|
+
|
|
98
|
+
**Use the URL, never the folder name.** This is the one mistake worth spelling out:
|
|
99
|
+
|
|
100
|
+
```markdown
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
<!-- demo: http://localhost:5173/ -->
|
|
104
|
+
|
|
105
|
+
## The storefront, live
|
|
106
|
+
|
|
107
|
+
[the fallback for GitHub and Marp goes here]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`<!-- demo: storefront -->` looks like it should work — the folder *is* under `demo/`, and
|
|
111
|
+
the deck *does* serve it. It gives you a blank slide. A Vite `index.html` asks for
|
|
112
|
+
`/src/main.jsx`, an absolute path, so the browser looks for it at the deck's root and gets
|
|
113
|
+
a 404 — and even on the right path it is raw JSX no browser can run. Untransformed sources
|
|
114
|
+
are not a site. The dev server is the thing that makes them one, and the URL is how you
|
|
115
|
+
reach it.
|
|
116
|
+
|
|
117
|
+
The folder form stays right for what it was meant for: a demo that really is a static page.
|
|
118
|
+
|
|
119
|
+
## What `dev` does with them
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
$ npm run dev
|
|
123
|
+
|
|
124
|
+
↑ checkout → http://localhost:4200/
|
|
125
|
+
↑ storefront → http://localhost:5173/
|
|
126
|
+
|
|
127
|
+
My Talk
|
|
128
|
+
→ http://localhost:4000/
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
They start alongside the deck and die with it: `Ctrl-C` takes down the deck server and
|
|
132
|
+
every side process it started, leaving nothing holding a port.
|
|
133
|
+
|
|
134
|
+
One failing never becomes the deck's failure. A busy port, a missing install, a crash at
|
|
135
|
+
startup — each prints its warning, that slide comes up empty, and the deck and the *other*
|
|
136
|
+
demos keep running. You keep presenting.
|
|
137
|
+
|
|
138
|
+
## What `build` does with them
|
|
139
|
+
|
|
140
|
+
A framework demo ships as **sources**, not as a build: its own `dist/` is its own dev
|
|
141
|
+
server's business. These are never copied, at any depth:
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
node_modules .git .angular .next .cache .DS_Store
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
So the weight stays where it belongs — two real demos in one deck:
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
demo/checkout/node_modules 214M
|
|
151
|
+
demo/storefront/node_modules 55M
|
|
152
|
+
dist/ 6.0M
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The catch is the one under **Publishing** in the README: a slide embedding
|
|
156
|
+
`http://localhost:…` shows an empty frame once the deck is online, because that URL exists
|
|
157
|
+
only on the machine running the app. A framework demo is for presenting live. If the deck
|
|
158
|
+
has to stand on its own on the web, point that slide at a deployed URL — the marker takes
|
|
159
|
+
any `http(s)` address.
|
|
160
|
+
|
|
161
|
+
## Removing one
|
|
162
|
+
|
|
163
|
+
Three things, all yours: the folder, the `servers:` entry, the slide. Delete them and
|
|
164
|
+
nothing else in the project remembers the demo existed.
|
package/lib/dev.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { existsSync } from 'node:fs';
|
|
|
11
11
|
import { join, resolve } from 'node:path';
|
|
12
12
|
|
|
13
13
|
import { assertUsable } from './config.mjs';
|
|
14
|
+
import { createEditApi } from './edit.mjs';
|
|
14
15
|
import { createDeckServer, listen } from './server.mjs';
|
|
15
16
|
import { renderIndex } from './render.mjs';
|
|
16
17
|
import { REVEAL_THEME_URL, readRevealTheme } from './theme.mjs';
|
|
@@ -92,6 +93,10 @@ export const dev = async (config, runtimeDir, reload) => {
|
|
|
92
93
|
// the server came up — the mounts, the port, the side processes — still
|
|
93
94
|
// needs a restart.
|
|
94
95
|
index: async () => renderIndex(await current(), runtimeDir),
|
|
96
|
+
// Editing a slide in the browser saves it back into its .md — dev only,
|
|
97
|
+
// which is what makes the edit button appear at all (the runtime probes
|
|
98
|
+
// /api/editable). Build and preview never mount this.
|
|
99
|
+
api: createEditApi(config.decksPath),
|
|
95
100
|
generated: {
|
|
96
101
|
[`/${REVEAL_THEME_URL}`]: async () => {
|
|
97
102
|
const now = await current();
|
package/lib/edit.mjs
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// The edit API — what lets the browser save a slide back into its .md file.
|
|
3
|
+
//
|
|
4
|
+
// Mounted by `dev` only: a built deck is static files, and preview serves
|
|
5
|
+
// exactly what the host will, so neither answers /api/ at all. The runtime
|
|
6
|
+
// probes /api/editable to decide whether to show the edit button, which is how
|
|
7
|
+
// the same runtime works against both servers with nothing configured.
|
|
8
|
+
//
|
|
9
|
+
// GET /api/editable → { editable: true } — the probe
|
|
10
|
+
// GET /api/slide?file&index → { source } — one slide, as it is on disk
|
|
11
|
+
// POST /api/slide → { op?, file, index, expected, source }
|
|
12
|
+
// op 'save' (default) replaces the slide, 'add' inserts a new one right
|
|
13
|
+
// after it, 'delete' removes it — separator and all.
|
|
14
|
+
//
|
|
15
|
+
// The slide boundaries are computed here with the same split deck.js uses, so
|
|
16
|
+
// the index the browser counted is the index this file finds. The splice
|
|
17
|
+
// touches only the slide's own bytes: front matter, blank lines between
|
|
18
|
+
// slides and the rest of the file come through untouched.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
22
|
+
import { resolve, sep } from 'node:path';
|
|
23
|
+
|
|
24
|
+
// Kept in step with deck.js: front matter comes off first, then slides split on
|
|
25
|
+
// `---` lines, are trimmed, and empty segments are dropped.
|
|
26
|
+
const FRONT_MATTER = /^---\r?\n[\s\S]*?\r?\n---\r?\n/;
|
|
27
|
+
const SEPARATOR = /\r?\n---\r?\n/g;
|
|
28
|
+
|
|
29
|
+
// Each slide as deck.js sees it, plus where its trimmed source starts and ends
|
|
30
|
+
// in the file — the range the save replaces.
|
|
31
|
+
const slideRangesOf = (markdown) => {
|
|
32
|
+
const offset = markdown.match(FRONT_MATTER)?.[0].length ?? 0;
|
|
33
|
+
const body = markdown.slice(offset);
|
|
34
|
+
|
|
35
|
+
const cuts = [0];
|
|
36
|
+
for (const match of body.matchAll(SEPARATOR)) {
|
|
37
|
+
cuts.push(match.index, match.index + match[0].length);
|
|
38
|
+
}
|
|
39
|
+
cuts.push(body.length);
|
|
40
|
+
|
|
41
|
+
const ranges = [];
|
|
42
|
+
for (let i = 0; i < cuts.length; i += 2) {
|
|
43
|
+
const raw = body.slice(cuts[i], cuts[i + 1]);
|
|
44
|
+
const source = raw.trim();
|
|
45
|
+
if (!source) continue;
|
|
46
|
+
const start = offset + cuts[i] + (raw.length - raw.trimStart().length);
|
|
47
|
+
ranges.push({ source, start, end: start + source.length });
|
|
48
|
+
}
|
|
49
|
+
return ranges;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// A deck file is a flat name in the decks folder — that is all listDecks ever
|
|
53
|
+
// hands out, so anything shaped otherwise did not come from this deck.
|
|
54
|
+
const FILE_NAME = /^[^/\\]+\.md$/;
|
|
55
|
+
|
|
56
|
+
const json = (res, status, body) =>
|
|
57
|
+
res
|
|
58
|
+
.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
59
|
+
.end(JSON.stringify(body));
|
|
60
|
+
|
|
61
|
+
const readBody = (req, limit = 1024 * 1024) =>
|
|
62
|
+
new Promise((resolvePromise, reject) => {
|
|
63
|
+
const chunks = [];
|
|
64
|
+
let size = 0;
|
|
65
|
+
req.on('data', (chunk) => {
|
|
66
|
+
size += chunk.length;
|
|
67
|
+
if (size > limit) reject(new Error('body too large'));
|
|
68
|
+
else chunks.push(chunk);
|
|
69
|
+
});
|
|
70
|
+
req.on('end', () => resolvePromise(Buffer.concat(chunks).toString('utf8')));
|
|
71
|
+
req.on('error', reject);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// This endpoint writes files, and localhost is not as private as it looks: any
|
|
75
|
+
// web page you have open can POST to it. Cross-origin requests cannot carry a
|
|
76
|
+
// custom header without a CORS preflight — which is never answered — so
|
|
77
|
+
// requiring one shuts that door; the Host check covers DNS rebinding, where
|
|
78
|
+
// the request arrives same-origin under a name that is not ours.
|
|
79
|
+
const LOCAL_HOST = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
|
|
80
|
+
|
|
81
|
+
export const createEditApi = (decksPath) => {
|
|
82
|
+
const deckFile = (name) => {
|
|
83
|
+
if (typeof name !== 'string' || !FILE_NAME.test(name) || name.startsWith('.')) return null;
|
|
84
|
+
const target = resolve(decksPath, name);
|
|
85
|
+
return target.startsWith(decksPath + sep) ? target : null;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return async (req, res, pathname) => {
|
|
89
|
+
if (!LOCAL_HOST.test(req.headers.host ?? '')) return json(res, 403, { error: 'forbidden' });
|
|
90
|
+
|
|
91
|
+
if (pathname === '/api/editable') return json(res, 200, { editable: true });
|
|
92
|
+
|
|
93
|
+
if (pathname !== '/api/slide') return json(res, 404, { error: 'not found' });
|
|
94
|
+
|
|
95
|
+
const query = new URL(req.url, 'http://localhost').searchParams;
|
|
96
|
+
|
|
97
|
+
if (req.method === 'GET') {
|
|
98
|
+
const target = deckFile(query.get('file'));
|
|
99
|
+
const index = Number(query.get('index'));
|
|
100
|
+
if (!target || !Number.isInteger(index) || index < 0) return json(res, 400, { error: 'bad request' });
|
|
101
|
+
|
|
102
|
+
const ranges = slideRangesOf(await readFile(target, 'utf8'));
|
|
103
|
+
const range = ranges[index];
|
|
104
|
+
if (!range) return json(res, 409, { error: `no slide ${index} in ${query.get('file')}` });
|
|
105
|
+
return json(res, 200, { source: range.source });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (req.method === 'POST') {
|
|
109
|
+
if (!req.headers['x-fb-slides']) return json(res, 403, { error: 'forbidden' });
|
|
110
|
+
|
|
111
|
+
let body;
|
|
112
|
+
try {
|
|
113
|
+
body = JSON.parse(await readBody(req));
|
|
114
|
+
} catch {
|
|
115
|
+
return json(res, 400, { error: 'bad request' });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const { op = 'save', file, index, expected, source } = body ?? {};
|
|
119
|
+
const target = deckFile(file);
|
|
120
|
+
if (!target || !Number.isInteger(index) || index < 0) {
|
|
121
|
+
return json(res, 400, { error: 'bad request' });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const markdown = await readFile(target, 'utf8');
|
|
125
|
+
const ranges = slideRangesOf(markdown);
|
|
126
|
+
const range = ranges[index];
|
|
127
|
+
if (!range) return json(res, 409, { error: `no slide ${index} in ${file}` });
|
|
128
|
+
|
|
129
|
+
// The browser edited what it fetched; if the file moved on underneath —
|
|
130
|
+
// another editor, another tab — refuse rather than overwrite that work.
|
|
131
|
+
const stale = typeof expected === 'string' && expected.trim() !== range.source;
|
|
132
|
+
|
|
133
|
+
if (op === 'add') {
|
|
134
|
+
const fresh = typeof source === 'string' && source.trim() ? source.trim() : '## New slide';
|
|
135
|
+
await writeFile(
|
|
136
|
+
target,
|
|
137
|
+
`${markdown.slice(0, range.end)}\n\n---\n\n${fresh}${markdown.slice(range.end)}`,
|
|
138
|
+
);
|
|
139
|
+
return json(res, 200, { ok: true, index: index + 1 });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (op === 'delete') {
|
|
143
|
+
// The file's last slide would leave an empty file behind, and an empty
|
|
144
|
+
// deck file is a decision for the file system, not for this endpoint.
|
|
145
|
+
if (ranges.length === 1) {
|
|
146
|
+
return json(res, 409, { error: 'the only slide in the file — delete the file itself instead' });
|
|
147
|
+
}
|
|
148
|
+
if (stale) return json(res, 409, { error: 'the slide changed on disk — reload and try again' });
|
|
149
|
+
// The slide goes with one separator: the one before it, or for the
|
|
150
|
+
// first slide everything up to where the next slide's content starts.
|
|
151
|
+
const [from, to] =
|
|
152
|
+
index > 0 ? [ranges[index - 1].end, range.end] : [range.start, ranges[1].start];
|
|
153
|
+
await writeFile(target, markdown.slice(0, from) + markdown.slice(to));
|
|
154
|
+
return json(res, 200, { ok: true });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const next = typeof source === 'string' ? source.trim() : '';
|
|
158
|
+
if (op !== 'save' || !next) return json(res, 400, { error: 'bad request' });
|
|
159
|
+
if (stale) return json(res, 409, { error: 'the slide changed on disk — reload and edit again' });
|
|
160
|
+
|
|
161
|
+
await writeFile(target, markdown.slice(0, range.start) + next + markdown.slice(range.end));
|
|
162
|
+
return json(res, 200, { ok: true });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return json(res, 405, { error: 'method not allowed' });
|
|
166
|
+
};
|
|
167
|
+
};
|
package/lib/server.mjs
CHANGED
|
@@ -101,10 +101,10 @@ const sendFile = (req, res, path, size) => {
|
|
|
101
101
|
// mounts: [{ prefix: '/', dir }] — checked in order, first hit wins.
|
|
102
102
|
// generated: { '/path.css': async () => body } — files this package rewrites
|
|
103
103
|
// rather than serves, checked before any mount so node_modules cannot shadow one.
|
|
104
|
-
|
|
104
|
+
// api: async (req, res, pathname) — everything under /api/, methods included;
|
|
105
|
+
// only `dev` passes one, so a built deck answers /api/ with a plain 404.
|
|
106
|
+
export const createDeckServer = ({ mounts, index, decksPath, generated = {}, api }) => {
|
|
105
107
|
const handler = async (req, res) => {
|
|
106
|
-
if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'method not allowed');
|
|
107
|
-
|
|
108
108
|
let pathname;
|
|
109
109
|
try {
|
|
110
110
|
pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
|
|
@@ -112,6 +112,10 @@ export const createDeckServer = ({ mounts, index, decksPath, generated = {} }) =
|
|
|
112
112
|
return send(res, 400, 'bad request');
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
if (api && pathname.startsWith('/api/')) return api(req, res, pathname);
|
|
116
|
+
|
|
117
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'method not allowed');
|
|
118
|
+
|
|
115
119
|
if (pathname === '/' || pathname === '/index.html') {
|
|
116
120
|
return send(res, 200, await index(), { 'Content-Type': MIME['.html'] });
|
|
117
121
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fb-slides",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Markdown-driven reveal.js decks: live demo embeds, annotation, mermaid, and a zero-config dev server",
|
|
6
6
|
"keywords": [
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"files": [
|
|
28
28
|
"bin",
|
|
29
|
+
"docs",
|
|
29
30
|
"lib",
|
|
30
31
|
"runtime",
|
|
31
32
|
"templates",
|
package/runtime/deck.js
CHANGED
|
@@ -2,6 +2,7 @@ import RevealAnnotate from './annotate.js';
|
|
|
2
2
|
import RevealSpotlight from './spotlight.js';
|
|
3
3
|
import RevealPointer from './pointer.js';
|
|
4
4
|
import RevealOutline from './outline.js';
|
|
5
|
+
import RevealEdit from './edit.js';
|
|
5
6
|
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
7
8
|
// The decks are the Markdown files. This file only assembles them into reveal.js
|
|
@@ -134,9 +135,15 @@ for (const file of DECKS) {
|
|
|
134
135
|
// `section:` names the deck; without one the file name has to do.
|
|
135
136
|
const deck = { file, label: front.section ?? file.replace(/^\d+-|\.md$/g, '') };
|
|
136
137
|
|
|
137
|
-
for (const source of splitSlides(stripFrontMatter(markdown))) {
|
|
138
|
+
for (const [index, source] of splitSlides(stripFrontMatter(markdown)).entries()) {
|
|
138
139
|
const marker = source.match(DEMO_MARKER);
|
|
139
|
-
|
|
140
|
+
const section = marker ? demoSection(marker[1], deck) : markdownSection(source, deck);
|
|
141
|
+
// Where the slide came from — the file and its index within it, counted by
|
|
142
|
+
// the same split as above. The edit drawer saves back through these; the
|
|
143
|
+
// markdown plugin forwards data-* attributes when it rewrites the section.
|
|
144
|
+
section.dataset.srcFile = file;
|
|
145
|
+
section.dataset.srcIndex = index;
|
|
146
|
+
slidesEl.append(section);
|
|
140
147
|
}
|
|
141
148
|
}
|
|
142
149
|
|
|
@@ -162,9 +169,9 @@ await Reveal.initialize({
|
|
|
162
169
|
pdfSeparateFragments: false,
|
|
163
170
|
// Anything the project wants to change, from `reveal:` in slides.config.js.
|
|
164
171
|
...(CFG.reveal ?? {}),
|
|
165
|
-
// Spotlight and
|
|
166
|
-
// toolbar the pen builds.
|
|
167
|
-
plugins: [RevealMarkdown, RevealHighlight, RevealNotes, RevealAnnotate(), RevealSpotlight(), RevealPointer(), RevealOutline()],
|
|
172
|
+
// Spotlight, Pointer and Edit after Annotate: they hang their buttons on
|
|
173
|
+
// the toolbar the pen builds, in this order.
|
|
174
|
+
plugins: [RevealMarkdown, RevealHighlight, RevealNotes, RevealAnnotate(), RevealSpotlight(), RevealPointer(), RevealOutline(), RevealEdit()],
|
|
168
175
|
});
|
|
169
176
|
|
|
170
177
|
// ---------------------------------------------------------------------------
|
package/runtime/edit.js
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// RevealEdit — edit the slide you are looking at, save it back into its .md.
|
|
3
|
+
//
|
|
4
|
+
// A reveal.js plugin, dev-server only in effect: at init it probes the edit
|
|
5
|
+
// API that `fb-slides dev` mounts, and only an answer makes the button appear.
|
|
6
|
+
// A built deck is static files, the probe 404s, and this plugin does nothing —
|
|
7
|
+
// which is how the same runtime ships in both without configuration.
|
|
8
|
+
//
|
|
9
|
+
// The drawer opens down the right edge with the slide still on stage, one
|
|
10
|
+
// textarea for the slide and one for the speaker notes. Typing re-renders the
|
|
11
|
+
// real slide in place — the same markdown renderer, the same theme, the same
|
|
12
|
+
// scale — so the preview is the slide. Saving splices the text back into the
|
|
13
|
+
// slide's own range of the .md (the server owns that arithmetic) and reloads:
|
|
14
|
+
// the URL hash puts the deck back on this slide, and the full pipeline —
|
|
15
|
+
// mermaid, fragments, highlight steps — runs over the saved source.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
// Where the slide came from, stamped on every section by deck.js: the .md file
|
|
19
|
+
// and the slide's index within it, counted the way the server counts.
|
|
20
|
+
const SRC = (slide) => {
|
|
21
|
+
const file = slide?.dataset.srcFile;
|
|
22
|
+
return file ? { file, index: Number(slide.dataset.srcIndex) } : null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// The same notes separator reveal's markdown plugin splits on: a line opening
|
|
26
|
+
// with `Note:` (or `Notes:`). Kept when found, so a file spelling it its own
|
|
27
|
+
// way round-trips as written; `Note:` only when notes are added from scratch.
|
|
28
|
+
const NOTES = /^s*notes?:/im;
|
|
29
|
+
|
|
30
|
+
const splitNotes = (source) => {
|
|
31
|
+
const match = source.match(NOTES);
|
|
32
|
+
if (!match) return { content: source, label: null, notes: '' };
|
|
33
|
+
return {
|
|
34
|
+
content: source.slice(0, match.index).trimEnd(),
|
|
35
|
+
label: match[0].trim(),
|
|
36
|
+
notes: source.slice(match.index + match[0].length).trim(),
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const joinNotes = ({ content, label, notes }) =>
|
|
41
|
+
notes ? `${content.trimEnd()}\n\n${label ?? 'Note:'} ${notes}` : content.trimEnd();
|
|
42
|
+
|
|
43
|
+
// Requests wear the header the server demands: a cross-origin page cannot add
|
|
44
|
+
// one without a CORS preflight, which the dev server never answers.
|
|
45
|
+
const API_HEADERS = { 'x-fb-slides': '1' };
|
|
46
|
+
|
|
47
|
+
// The last stop on the tools' QWER row — pen, arrow, spotlight, pointer, and
|
|
48
|
+
// then this. Reveal answers nothing on T.
|
|
49
|
+
const KEY_CODE = 84;
|
|
50
|
+
|
|
51
|
+
const EDIT_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
|
52
|
+
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
53
|
+
<path d="M11 5H6a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2v-5" />
|
|
54
|
+
<path d="M17.3 4.7a2 2 0 0 1 2.8 2.8L12 15.6 8.5 16.4l.8-3.5 8-8.2z" />
|
|
55
|
+
</svg>`;
|
|
56
|
+
|
|
57
|
+
const CLOSE_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
|
|
58
|
+
stroke-linecap="round" aria-hidden="true">
|
|
59
|
+
<path d="M6 6l12 12" /><path d="M18 6L6 18" />
|
|
60
|
+
</svg>`;
|
|
61
|
+
|
|
62
|
+
const PREV_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
|
|
63
|
+
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
64
|
+
<path d="M14.5 6L8.5 12l6 6" />
|
|
65
|
+
</svg>`;
|
|
66
|
+
|
|
67
|
+
const NEXT_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
|
|
68
|
+
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
69
|
+
<path d="M9.5 6l6 6-6 6" />
|
|
70
|
+
</svg>`;
|
|
71
|
+
|
|
72
|
+
// Matches the outline's SLIDE_MS: the deck reflows for as long as it is moving.
|
|
73
|
+
const SLIDE_MS = 260;
|
|
74
|
+
|
|
75
|
+
const RevealEdit = () => ({
|
|
76
|
+
id: 'edit',
|
|
77
|
+
|
|
78
|
+
init(deck) {
|
|
79
|
+
// Nothing is built until the API answers: on a static host the fetch fails
|
|
80
|
+
// or comes back as someone's 404 page, the JSON parse throws, and the deck
|
|
81
|
+
// never knows this plugin existed. Init itself stays synchronous so a slow
|
|
82
|
+
// or absent server cannot hold up the whole deck.
|
|
83
|
+
fetch('/api/editable', { headers: API_HEADERS, cache: 'no-cache' })
|
|
84
|
+
.then((response) => response.json())
|
|
85
|
+
.then((answer) => {
|
|
86
|
+
if (answer?.editable === true) build(deck);
|
|
87
|
+
})
|
|
88
|
+
.catch(() => {});
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const build = (deck) => {
|
|
93
|
+
// ---- chrome -----------------------------------------------------------
|
|
94
|
+
// The toolbar is annotate's; this plugin registers after the others in
|
|
95
|
+
// deck.js and hangs its button last. A deck running without them still
|
|
96
|
+
// gets a toolbar.
|
|
97
|
+
let toolbar = document.querySelector('#deck-tools');
|
|
98
|
+
if (!toolbar) {
|
|
99
|
+
toolbar = document.createElement('div');
|
|
100
|
+
toolbar.id = 'deck-tools';
|
|
101
|
+
toolbar.setAttribute('role', 'toolbar');
|
|
102
|
+
toolbar.setAttribute('aria-label', 'Slide tools');
|
|
103
|
+
document.body.append(toolbar);
|
|
104
|
+
}
|
|
105
|
+
const button = document.createElement('button');
|
|
106
|
+
button.id = 'tool-edit';
|
|
107
|
+
button.className = 'deck-tool';
|
|
108
|
+
button.type = 'button';
|
|
109
|
+
button.setAttribute('aria-pressed', 'false');
|
|
110
|
+
button.title = 'Edit — this slide’s markdown and notes (T)';
|
|
111
|
+
button.setAttribute('aria-label', 'Edit the slide');
|
|
112
|
+
button.innerHTML = `${EDIT_ICON}<span class="deck-key" aria-hidden="true">T</span>`;
|
|
113
|
+
toolbar.append(button);
|
|
114
|
+
|
|
115
|
+
const panel = document.createElement('aside');
|
|
116
|
+
panel.id = 'deck-edit';
|
|
117
|
+
panel.innerHTML = `
|
|
118
|
+
<header>
|
|
119
|
+
<span>edit</span>
|
|
120
|
+
<code class="deck-edit-file"></code>
|
|
121
|
+
<button type="button" class="deck-edit-nav" data-go="-1" title="Previous slide" aria-label="Previous slide">${PREV_ICON}</button>
|
|
122
|
+
<button type="button" class="deck-edit-nav" data-go="1" title="Next slide" aria-label="Next slide">${NEXT_ICON}</button>
|
|
123
|
+
<button type="button" class="deck-edit-close" aria-label="Close">${CLOSE_ICON}</button>
|
|
124
|
+
</header>
|
|
125
|
+
<label class="deck-edit-label" for="deck-edit-source">slide</label>
|
|
126
|
+
<textarea id="deck-edit-source" spellcheck="false"></textarea>
|
|
127
|
+
<label class="deck-edit-label" for="deck-edit-notes">speaker notes</label>
|
|
128
|
+
<textarea id="deck-edit-notes" spellcheck="false"></textarea>
|
|
129
|
+
<footer>
|
|
130
|
+
<button type="button" class="deck-edit-add" title="Add a slide after this one">Add</button>
|
|
131
|
+
<button type="button" class="deck-edit-delete" title="Delete this slide">Delete</button>
|
|
132
|
+
<span class="deck-edit-status" role="status"></span>
|
|
133
|
+
<button type="button" class="deck-edit-save">Save</button>
|
|
134
|
+
</footer>
|
|
135
|
+
<div class="deck-edit-confirm" hidden>
|
|
136
|
+
<p>Delete this slide from <code></code>?<br />There is no undo.</p>
|
|
137
|
+
<div>
|
|
138
|
+
<button type="button" class="deck-edit-cancel">Cancel</button>
|
|
139
|
+
<button type="button" class="deck-edit-confirm-delete">Delete</button>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
`;
|
|
143
|
+
document.body.append(panel);
|
|
144
|
+
panel.inert = true;
|
|
145
|
+
|
|
146
|
+
const fileLabel = panel.querySelector('.deck-edit-file');
|
|
147
|
+
const sourceArea = panel.querySelector('#deck-edit-source');
|
|
148
|
+
const notesArea = panel.querySelector('#deck-edit-notes');
|
|
149
|
+
const status = panel.querySelector('.deck-edit-status');
|
|
150
|
+
const saveButton = panel.querySelector('.deck-edit-save');
|
|
151
|
+
const confirmBox = panel.querySelector('.deck-edit-confirm');
|
|
152
|
+
|
|
153
|
+
// ---- state ------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
let editing = null; // { slide, src, original, label, html, preview } or null
|
|
156
|
+
|
|
157
|
+
const joined = () =>
|
|
158
|
+
joinNotes({ content: sourceArea.value, label: editing.label, notes: notesArea.value.trim() });
|
|
159
|
+
|
|
160
|
+
// ---- the live preview -------------------------------------------------
|
|
161
|
+
// The slide itself is the preview: its innerHTML is re-rendered from the
|
|
162
|
+
// textarea with the same marked the markdown plugin used, and highlight runs
|
|
163
|
+
// over the code blocks again. What deck.js adds on top — mermaid, fragment
|
|
164
|
+
// steps, the dense-code class — waits for the reload a save triggers; a
|
|
165
|
+
// ```mermaid fence shows as its source while it is being written, which is
|
|
166
|
+
// the editable form of it anyway. Closing without saving puts the original
|
|
167
|
+
// rendering back, so nothing of this survives a cancel.
|
|
168
|
+
|
|
169
|
+
const markdownPlugin = deck.getPlugin('markdown');
|
|
170
|
+
const highlightPlugin = deck.getPlugin('highlight');
|
|
171
|
+
const marked = markdownPlugin?.marked;
|
|
172
|
+
const render = marked ? (marked.parse ?? marked).bind(marked) : null;
|
|
173
|
+
|
|
174
|
+
let frame = 0;
|
|
175
|
+
const paint = () => {
|
|
176
|
+
frame = 0;
|
|
177
|
+
if (!editing?.preview) return;
|
|
178
|
+
editing.slide.innerHTML = render(sourceArea.value);
|
|
179
|
+
if (highlightPlugin?.hljs) {
|
|
180
|
+
for (const block of editing.slide.querySelectorAll('pre code')) {
|
|
181
|
+
highlightPlugin.hljs.highlightElement(block);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// The rewrite dropped the slide's fragments, but reveal still counts the
|
|
185
|
+
// old ones — without this, the first "next" steps a ghost.
|
|
186
|
+
deck.syncFragments?.(editing.slide);
|
|
187
|
+
deck.layout();
|
|
188
|
+
};
|
|
189
|
+
const schedulePaint = () => {
|
|
190
|
+
frame ||= requestAnimationFrame(paint);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// ---- open and close ---------------------------------------------------
|
|
194
|
+
// The deck is narrowed by CSS while the drawer is out (see theme.base.css);
|
|
195
|
+
// reveal only rescales when told to, so layout() is driven for as long as
|
|
196
|
+
// the width is moving — the outline does the same on the other edge.
|
|
197
|
+
|
|
198
|
+
const reflow = () => {
|
|
199
|
+
const until = performance.now() + SLIDE_MS;
|
|
200
|
+
const step = () => {
|
|
201
|
+
deck.layout();
|
|
202
|
+
if (performance.now() < until) requestAnimationFrame(step);
|
|
203
|
+
};
|
|
204
|
+
requestAnimationFrame(step);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const setOpen = (open) => {
|
|
208
|
+
document.body.classList.toggle('edit-open', open);
|
|
209
|
+
button.setAttribute('aria-pressed', String(open));
|
|
210
|
+
panel.inert = !open;
|
|
211
|
+
reflow();
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// Whatever the preview did to the slide, the original rendering returns —
|
|
215
|
+
// and unsaved typing goes with it.
|
|
216
|
+
const discard = () => {
|
|
217
|
+
cancelAnimationFrame(frame);
|
|
218
|
+
frame = 0;
|
|
219
|
+
confirmBox.hidden = true;
|
|
220
|
+
editing.slide.innerHTML = editing.html;
|
|
221
|
+
// The original fragments are back; reveal counts them again.
|
|
222
|
+
deck.syncFragments?.(editing.slide);
|
|
223
|
+
editing = null;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const close = () => {
|
|
227
|
+
if (!editing) return;
|
|
228
|
+
discard();
|
|
229
|
+
setOpen(false);
|
|
230
|
+
deck.layout();
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const open = async () => {
|
|
234
|
+
const slide = deck.getCurrentSlide();
|
|
235
|
+
const src = SRC(slide);
|
|
236
|
+
if (!src) return;
|
|
237
|
+
|
|
238
|
+
// The source comes from the server, not from the DOM: what is on disk right
|
|
239
|
+
// now is what an edit starts from, and the server splits the file with the
|
|
240
|
+
// same arithmetic it will save by.
|
|
241
|
+
let source;
|
|
242
|
+
try {
|
|
243
|
+
const response = await fetch(
|
|
244
|
+
`/api/slide?file=${encodeURIComponent(src.file)}&index=${src.index}`,
|
|
245
|
+
{ headers: API_HEADERS, cache: 'no-cache' },
|
|
246
|
+
);
|
|
247
|
+
if (!response.ok) throw new Error((await response.json()).error ?? response.status);
|
|
248
|
+
({ source } = await response.json());
|
|
249
|
+
} catch (error) {
|
|
250
|
+
console.warn(`[edit] cannot load the slide: ${error.message}`);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const { content, label, notes } = splitNotes(source);
|
|
255
|
+
editing = {
|
|
256
|
+
slide,
|
|
257
|
+
src,
|
|
258
|
+
original: source,
|
|
259
|
+
label,
|
|
260
|
+
html: slide.innerHTML,
|
|
261
|
+
// A demo slide's markdown is its marker comment: editable, but rendering
|
|
262
|
+
// it over the live iframe would preview nothing worth looking at.
|
|
263
|
+
preview: Boolean(render) && !slide.classList.contains('demo-slide'),
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
fileLabel.textContent = src.file;
|
|
267
|
+
sourceArea.value = content;
|
|
268
|
+
notesArea.value = notes;
|
|
269
|
+
status.textContent = '';
|
|
270
|
+
saveButton.disabled = false;
|
|
271
|
+
panel.dataset.focus = 'source';
|
|
272
|
+
|
|
273
|
+
// One pointer, one tool: the pen, spotlight and pointer stand down — and
|
|
274
|
+
// stand this drawer down in turn when one of them arms (see below).
|
|
275
|
+
document.dispatchEvent(new CustomEvent('deck:tool-armed', { detail: 'edit' }));
|
|
276
|
+
setOpen(true);
|
|
277
|
+
sourceArea.focus();
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const toggle = () => (editing ? close() : open());
|
|
281
|
+
|
|
282
|
+
// ---- saving, adding, deleting -----------------------------------------
|
|
283
|
+
// Every write ends in a reload: the hash keeps the place, and the saved
|
|
284
|
+
// file goes through the full pipeline instead of the preview's part of it.
|
|
285
|
+
// The flag below survives the reload so the drawer opens itself again and
|
|
286
|
+
// the editing flow is not broken by its own saves.
|
|
287
|
+
|
|
288
|
+
const REOPEN_KEY = 'fb-slides.edit-reopen';
|
|
289
|
+
|
|
290
|
+
const reloadEditing = () => {
|
|
291
|
+
try {
|
|
292
|
+
sessionStorage.setItem(REOPEN_KEY, '1');
|
|
293
|
+
} catch {
|
|
294
|
+
/* walled-off storage only costs the reopen */
|
|
295
|
+
}
|
|
296
|
+
location.reload();
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const post = async (body) => {
|
|
300
|
+
const response = await fetch('/api/slide', {
|
|
301
|
+
method: 'POST',
|
|
302
|
+
headers: { ...API_HEADERS, 'Content-Type': 'application/json' },
|
|
303
|
+
body: JSON.stringify(body),
|
|
304
|
+
});
|
|
305
|
+
if (!response.ok) throw new Error((await response.json()).error ?? response.status);
|
|
306
|
+
return response.json();
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// One write in flight at a time; the message stays if the server refuses.
|
|
310
|
+
const write = async (label, body, landing) => {
|
|
311
|
+
if (!editing || saveButton.disabled) return;
|
|
312
|
+
saveButton.disabled = true;
|
|
313
|
+
status.textContent = `${label}…`;
|
|
314
|
+
try {
|
|
315
|
+
await post(body);
|
|
316
|
+
if (landing !== undefined) location.hash = `#/${landing}`;
|
|
317
|
+
reloadEditing();
|
|
318
|
+
} catch (error) {
|
|
319
|
+
saveButton.disabled = false;
|
|
320
|
+
status.textContent = error.message;
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const save = () => {
|
|
325
|
+
if (!editing) return;
|
|
326
|
+
const next = joined();
|
|
327
|
+
// Nothing changed: closing is the whole save.
|
|
328
|
+
if (next === editing.original) return close();
|
|
329
|
+
write('saving', { ...editing.src, expected: editing.original, source: next });
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// The new slide lands right after this one, and the reload opens it — ready
|
|
333
|
+
// for its content. Whatever was typed here and not saved goes the usual way.
|
|
334
|
+
const addSlide = () => {
|
|
335
|
+
if (!editing) return;
|
|
336
|
+
write('adding', { op: 'add', ...editing.src }, deck.getIndices().h + 1);
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// The slide after the deleted one slides into this spot, so the hash can
|
|
340
|
+
// stay put; reveal clamps it if the deck just got shorter at the end.
|
|
341
|
+
const deleteSlide = () => {
|
|
342
|
+
confirmBox.hidden = true;
|
|
343
|
+
if (!editing) return;
|
|
344
|
+
write('deleting', { op: 'delete', ...editing.src, expected: editing.original });
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
// ---- wiring -----------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
button.addEventListener('click', toggle);
|
|
350
|
+
panel.querySelector('.deck-edit-close').addEventListener('click', close);
|
|
351
|
+
saveButton.addEventListener('click', save);
|
|
352
|
+
sourceArea.addEventListener('input', schedulePaint);
|
|
353
|
+
|
|
354
|
+
// The header's arrows walk the deck whole slides at a time — fragments are a
|
|
355
|
+
// stage thing — and the drawer follows through `slidechanged` like any other
|
|
356
|
+
// navigation.
|
|
357
|
+
for (const nav of panel.querySelectorAll('.deck-edit-nav')) {
|
|
358
|
+
nav.addEventListener('click', () => {
|
|
359
|
+
if (Number(nav.dataset.go) < 0) deck.left({ skipFragments: true });
|
|
360
|
+
else deck.right({ skipFragments: true });
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Whichever box the cursor is in gets the room; the other keeps a readable
|
|
365
|
+
// strip. The swap is CSS's — see [data-focus] in theme.base.css.
|
|
366
|
+
for (const [area, focus] of [[sourceArea, 'source'], [notesArea, 'notes']]) {
|
|
367
|
+
area.addEventListener('focus', () => {
|
|
368
|
+
panel.dataset.focus = focus;
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Deleting is the one thing a reload cannot undo, so it asks first — an
|
|
373
|
+
// overlay over the drawer, not a browser dialog.
|
|
374
|
+
panel.querySelector('.deck-edit-add').addEventListener('click', addSlide);
|
|
375
|
+
panel.querySelector('.deck-edit-delete').addEventListener('click', () => {
|
|
376
|
+
if (!editing) return;
|
|
377
|
+
confirmBox.querySelector('code').textContent = editing.src.file;
|
|
378
|
+
confirmBox.hidden = false;
|
|
379
|
+
});
|
|
380
|
+
panel.querySelector('.deck-edit-cancel').addEventListener('click', () => {
|
|
381
|
+
confirmBox.hidden = true;
|
|
382
|
+
});
|
|
383
|
+
panel.querySelector('.deck-edit-confirm-delete').addEventListener('click', deleteSlide);
|
|
384
|
+
|
|
385
|
+
// Cmd+S / Ctrl+S anywhere in the drawer saves; the browser's own save-page
|
|
386
|
+
// dialog has no business over an editor.
|
|
387
|
+
panel.addEventListener('keydown', (event) => {
|
|
388
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 's') {
|
|
389
|
+
event.preventDefault();
|
|
390
|
+
save();
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// `addKeyBinding` with a descriptor binds the key and lists it in reveal's
|
|
395
|
+
// help overlay. Reveal ignores the keyboard while a textarea has focus, so
|
|
396
|
+
// closing from inside the drawer is Escape's job, taken in capture phase
|
|
397
|
+
// before reveal reads it as the overview toggle.
|
|
398
|
+
deck.addKeyBinding({ keyCode: KEY_CODE, key: 'T', description: 'Edit the slide' }, toggle);
|
|
399
|
+
document.addEventListener(
|
|
400
|
+
'keydown',
|
|
401
|
+
(event) => {
|
|
402
|
+
if (event.key !== 'Escape' || !editing) return;
|
|
403
|
+
// One layer at a time: a pending delete question goes first.
|
|
404
|
+
if (confirmBox.hidden) close();
|
|
405
|
+
else confirmBox.hidden = true;
|
|
406
|
+
event.stopPropagation();
|
|
407
|
+
event.preventDefault();
|
|
408
|
+
},
|
|
409
|
+
true,
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
// Another tool arming is the signal to stand down — same handshake the pen,
|
|
413
|
+
// spotlight and pointer use among themselves, and unsaved typing goes the
|
|
414
|
+
// way a half-drawn line does.
|
|
415
|
+
document.addEventListener('deck:tool-armed', (event) => {
|
|
416
|
+
if (event.detail !== 'edit') close();
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// A write reloaded the page with the drawer out: pick the editing back up
|
|
420
|
+
// on whatever slide the hash landed on.
|
|
421
|
+
try {
|
|
422
|
+
if (sessionStorage.getItem(REOPEN_KEY)) {
|
|
423
|
+
sessionStorage.removeItem(REOPEN_KEY);
|
|
424
|
+
if (deck.isReady?.()) open();
|
|
425
|
+
else deck.on('ready', open);
|
|
426
|
+
}
|
|
427
|
+
} catch {
|
|
428
|
+
/* walled-off storage: the drawer just stays closed */
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// The drawer follows the deck: landing on another slide loads that one,
|
|
432
|
+
// and whatever was typed on the old one goes the way of a cancel. The
|
|
433
|
+
// overview is a different surface altogether, so there it does close.
|
|
434
|
+
deck.on('slidechanged', async () => {
|
|
435
|
+
if (!editing) return;
|
|
436
|
+
discard();
|
|
437
|
+
await open();
|
|
438
|
+
// The new slide would not load — a drawer left standing would lie about
|
|
439
|
+
// what it edits.
|
|
440
|
+
if (!editing) setOpen(false);
|
|
441
|
+
});
|
|
442
|
+
deck.on('overviewshown', close);
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
export default RevealEdit;
|
package/runtime/spotlight.js
CHANGED
|
@@ -102,6 +102,7 @@ const RevealSpotlight = () => ({
|
|
|
102
102
|
let rect = null; // {x, y, w, h} in screen px, or null when the veil is clear
|
|
103
103
|
let press = null; // where the current drag started, or null
|
|
104
104
|
let grab = null; // the pointer's offset into a rectangle being moved, or null
|
|
105
|
+
let ghostTimer = 0; // closes the old hole once the veil has faded out
|
|
105
106
|
|
|
106
107
|
const layout = () => {
|
|
107
108
|
if (!rect) return;
|
|
@@ -120,7 +121,11 @@ const RevealSpotlight = () => ({
|
|
|
120
121
|
};
|
|
121
122
|
|
|
122
123
|
// The clip-path stays as it was, so the hole holds its place while the
|
|
123
|
-
// veil fades out around it.
|
|
124
|
+
// veil fades out around it — but only for as long as the fade runs. The
|
|
125
|
+
// clip cuts hit-testing along with paint, so a ghost hole left on an
|
|
126
|
+
// armed veil would keep handing the pointer to the slide underneath:
|
|
127
|
+
// right where the light last was, a drag would select text instead of
|
|
128
|
+
// drawing the next rectangle.
|
|
124
129
|
const clear = () => {
|
|
125
130
|
rect = null;
|
|
126
131
|
press = null;
|
|
@@ -128,12 +133,19 @@ const RevealSpotlight = () => ({
|
|
|
128
133
|
veil.classList.remove('has-hole');
|
|
129
134
|
ring.classList.remove('has-hole', 'is-live');
|
|
130
135
|
ring.style.cursor = '';
|
|
136
|
+
clearTimeout(ghostTimer);
|
|
137
|
+
ghostTimer = setTimeout(() => {
|
|
138
|
+
if (!rect) veil.style.clipPath = '';
|
|
139
|
+
}, 320); // just past the 0.3s fade
|
|
131
140
|
};
|
|
132
141
|
|
|
133
142
|
const setSpot = (on) => {
|
|
134
143
|
armed = on;
|
|
135
144
|
button.setAttribute('aria-pressed', String(on));
|
|
136
145
|
toolbar.classList.toggle('is-armed', on);
|
|
146
|
+
// Arming must start from a whole veil: a hole still mid-fade from last
|
|
147
|
+
// time would leak the pointer through (see clear above).
|
|
148
|
+
if (on) veil.style.clipPath = '';
|
|
137
149
|
// Only an armed veil takes the pointer; the rest of the time clicks fall
|
|
138
150
|
// through to the slide underneath — same contract as the pen's canvas.
|
|
139
151
|
veil.classList.toggle('is-live', on);
|
package/runtime/theme.base.css
CHANGED
|
@@ -21,8 +21,11 @@
|
|
|
21
21
|
long titles. */
|
|
22
22
|
--outline-w: 300px;
|
|
23
23
|
--outline-step: 15px;
|
|
24
|
-
/*
|
|
25
|
-
|
|
24
|
+
/* The edit drawer down the right edge (edit.js, dev server only). */
|
|
25
|
+
--editor-w: 400px;
|
|
26
|
+
/* The hairline scrollbar the outline list and the edit drawer's boxes share,
|
|
27
|
+
at rest and under the pointer: tokens so a project that reskins either
|
|
28
|
+
panel does not have to restate the WebKit rules. */
|
|
26
29
|
--outline-bar: color-mix(in srgb, var(--text) 16%, transparent);
|
|
27
30
|
--outline-bar-hover: color-mix(in srgb, var(--text) 30%, transparent);
|
|
28
31
|
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
@@ -840,6 +843,233 @@ body.outline-open #deck-outline { transform: none; }
|
|
|
840
843
|
background: color-mix(in srgb, var(--text) 5%, transparent);
|
|
841
844
|
}
|
|
842
845
|
|
|
846
|
+
/* --- the edit drawer (edit.js) -------------------------------------------- */
|
|
847
|
+
|
|
848
|
+
/* The outline's mirror image: it takes room from the deck's right edge, so the
|
|
849
|
+
slide stays whole — and is the live preview — while its markdown is typed.
|
|
850
|
+
Only the dev server ever shows it; a built deck never builds this chrome. */
|
|
851
|
+
body.edit-open > .reveal { width: calc(100% - var(--editor-w)); }
|
|
852
|
+
/* Both panels out at once: the deck keeps to the strip between them. */
|
|
853
|
+
body.outline-open.edit-open > .reveal { width: calc(100% - var(--outline-w) - var(--editor-w)); }
|
|
854
|
+
body.edit-open #deck-tools { left: calc(50% - var(--editor-w) / 2); }
|
|
855
|
+
body.outline-open.edit-open #deck-tools { left: calc(50% + var(--outline-w) / 2 - var(--editor-w) / 2); }
|
|
856
|
+
/* The corner chrome on the right steps clear of the drawer. */
|
|
857
|
+
body.edit-open #deck-badge { right: calc(var(--editor-w) + 18px); }
|
|
858
|
+
body.edit-open #deck-sig { right: calc(var(--editor-w) + 14px); }
|
|
859
|
+
|
|
860
|
+
#deck-edit {
|
|
861
|
+
position: fixed;
|
|
862
|
+
inset: 0 0 0 auto;
|
|
863
|
+
z-index: 46;
|
|
864
|
+
display: flex;
|
|
865
|
+
flex-direction: column;
|
|
866
|
+
width: var(--editor-w);
|
|
867
|
+
background: color-mix(in srgb, var(--bg-2) 92%, transparent);
|
|
868
|
+
border-left: 1px solid var(--line);
|
|
869
|
+
backdrop-filter: blur(10px);
|
|
870
|
+
transform: translateX(100%);
|
|
871
|
+
transition: transform 0.26s ease;
|
|
872
|
+
}
|
|
873
|
+
body.edit-open #deck-edit { transform: none; }
|
|
874
|
+
|
|
875
|
+
#deck-edit > header {
|
|
876
|
+
display: flex;
|
|
877
|
+
align-items: center;
|
|
878
|
+
gap: 8px;
|
|
879
|
+
padding: 14px 10px 12px 16px;
|
|
880
|
+
border-bottom: 1px solid var(--line);
|
|
881
|
+
}
|
|
882
|
+
#deck-edit > header span {
|
|
883
|
+
font: 600 12px/1 var(--sans);
|
|
884
|
+
letter-spacing: 0.1em;
|
|
885
|
+
text-transform: uppercase;
|
|
886
|
+
color: var(--muted);
|
|
887
|
+
}
|
|
888
|
+
.deck-edit-file {
|
|
889
|
+
flex: 1;
|
|
890
|
+
font: 500 12px/1 var(--mono);
|
|
891
|
+
color: var(--accent-2);
|
|
892
|
+
overflow: hidden;
|
|
893
|
+
text-overflow: ellipsis;
|
|
894
|
+
white-space: nowrap;
|
|
895
|
+
}
|
|
896
|
+
.deck-edit-close,
|
|
897
|
+
.deck-edit-nav {
|
|
898
|
+
display: grid;
|
|
899
|
+
place-items: center;
|
|
900
|
+
width: 28px;
|
|
901
|
+
height: 28px;
|
|
902
|
+
padding: 0;
|
|
903
|
+
border: 0;
|
|
904
|
+
border-radius: 999px;
|
|
905
|
+
background: transparent;
|
|
906
|
+
color: var(--muted);
|
|
907
|
+
cursor: pointer;
|
|
908
|
+
}
|
|
909
|
+
.deck-edit-close:hover,
|
|
910
|
+
.deck-edit-nav:hover { background: color-mix(in srgb, var(--text) 10%, transparent); color: var(--text); }
|
|
911
|
+
.deck-edit-close svg,
|
|
912
|
+
.deck-edit-nav svg { display: block; width: 16px; height: 16px; }
|
|
913
|
+
|
|
914
|
+
.deck-edit-label {
|
|
915
|
+
padding: 12px 16px 6px;
|
|
916
|
+
font: 600 10px/1 var(--sans);
|
|
917
|
+
letter-spacing: 0.12em;
|
|
918
|
+
text-transform: uppercase;
|
|
919
|
+
color: var(--muted);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
#deck-edit textarea {
|
|
923
|
+
margin: 0 12px;
|
|
924
|
+
padding: 10px 12px;
|
|
925
|
+
font: 400 13px/1.55 var(--mono);
|
|
926
|
+
color: var(--text);
|
|
927
|
+
background: color-mix(in srgb, var(--card) 70%, transparent);
|
|
928
|
+
border: 1px solid var(--line);
|
|
929
|
+
border-radius: 8px;
|
|
930
|
+
resize: none;
|
|
931
|
+
-moz-tab-size: 2;
|
|
932
|
+
tab-size: 2;
|
|
933
|
+
}
|
|
934
|
+
#deck-edit textarea:focus {
|
|
935
|
+
outline: none;
|
|
936
|
+
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/* The same hairline the outline list wears, on the two boxes that scroll — a
|
|
940
|
+
slide long enough to run past the drawer is the ordinary case, and macOS
|
|
941
|
+
fades its overlay bar away exactly when you want the hint that there is more
|
|
942
|
+
below. Quiet at rest, awake while the drawer is under the pointer, and
|
|
943
|
+
tinted with the accent on the box that holds the cursor, so the bar you can
|
|
944
|
+
drag is the one you are already typing in. */
|
|
945
|
+
#deck-edit textarea::-webkit-scrollbar { width: 5px; }
|
|
946
|
+
#deck-edit textarea::-webkit-scrollbar-track { background: transparent; }
|
|
947
|
+
#deck-edit textarea::-webkit-scrollbar-thumb {
|
|
948
|
+
border-radius: 999px;
|
|
949
|
+
background: var(--outline-bar);
|
|
950
|
+
}
|
|
951
|
+
#deck-edit:hover textarea::-webkit-scrollbar-thumb { background: var(--outline-bar-hover); }
|
|
952
|
+
#deck-edit textarea:focus::-webkit-scrollbar-thumb {
|
|
953
|
+
background: color-mix(in srgb, var(--accent) 45%, transparent);
|
|
954
|
+
}
|
|
955
|
+
#deck-edit textarea::-webkit-scrollbar-thumb:hover,
|
|
956
|
+
#deck-edit textarea:focus::-webkit-scrollbar-thumb:hover {
|
|
957
|
+
background: color-mix(in srgb, var(--accent) 75%, transparent);
|
|
958
|
+
}
|
|
959
|
+
/* Firefox only, for the reason the outline states above: naming
|
|
960
|
+
`scrollbar-width` in Chrome throws away every WebKit rule here. */
|
|
961
|
+
@supports not selector(::-webkit-scrollbar) {
|
|
962
|
+
#deck-edit textarea {
|
|
963
|
+
scrollbar-width: thin;
|
|
964
|
+
scrollbar-color: var(--outline-bar) transparent;
|
|
965
|
+
}
|
|
966
|
+
#deck-edit:hover textarea { scrollbar-color: var(--outline-bar-hover) transparent; }
|
|
967
|
+
#deck-edit textarea:focus {
|
|
968
|
+
scrollbar-color: color-mix(in srgb, var(--accent) 45%, transparent) transparent;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
/* Whichever box holds the cursor gets the room, the other keeps a readable
|
|
972
|
+
strip — edit.js writes [data-focus] as the focus moves, and the swap
|
|
973
|
+
animates through flex. The slide box starts big: notes are the side dish. */
|
|
974
|
+
#deck-edit textarea {
|
|
975
|
+
min-height: 0;
|
|
976
|
+
transition: flex-grow 0.25s ease, flex-basis 0.25s ease;
|
|
977
|
+
}
|
|
978
|
+
#deck-edit-source { flex: 1 1 0; }
|
|
979
|
+
#deck-edit-notes { flex: 0 0 120px; }
|
|
980
|
+
#deck-edit[data-focus='notes'] #deck-edit-source { flex: 0 0 120px; }
|
|
981
|
+
#deck-edit[data-focus='notes'] #deck-edit-notes { flex: 1 1 0; }
|
|
982
|
+
|
|
983
|
+
#deck-edit > footer {
|
|
984
|
+
display: flex;
|
|
985
|
+
align-items: center;
|
|
986
|
+
gap: 10px;
|
|
987
|
+
padding: 12px 16px 14px;
|
|
988
|
+
}
|
|
989
|
+
.deck-edit-status {
|
|
990
|
+
flex: 1;
|
|
991
|
+
font: 400 12px/1.4 var(--sans);
|
|
992
|
+
color: var(--muted);
|
|
993
|
+
overflow: hidden;
|
|
994
|
+
}
|
|
995
|
+
.deck-edit-save {
|
|
996
|
+
padding: 7px 18px;
|
|
997
|
+
border: 0;
|
|
998
|
+
border-radius: 999px;
|
|
999
|
+
font: 600 13px/1 var(--sans);
|
|
1000
|
+
color: #0d1626;
|
|
1001
|
+
background: var(--accent);
|
|
1002
|
+
cursor: pointer;
|
|
1003
|
+
}
|
|
1004
|
+
.deck-edit-save:hover { background: color-mix(in srgb, var(--accent) 85%, #fff); }
|
|
1005
|
+
.deck-edit-save:disabled { opacity: 0.5; cursor: default; }
|
|
1006
|
+
|
|
1007
|
+
/* The file operations, quieter than Save: Add is an ordinary pill, Delete only
|
|
1008
|
+
shows its colour when the pointer commits to it. */
|
|
1009
|
+
.deck-edit-add,
|
|
1010
|
+
.deck-edit-delete {
|
|
1011
|
+
padding: 6px 13px;
|
|
1012
|
+
border: 1px solid var(--line);
|
|
1013
|
+
border-radius: 999px;
|
|
1014
|
+
font: 600 12px/1 var(--sans);
|
|
1015
|
+
color: var(--muted);
|
|
1016
|
+
background: transparent;
|
|
1017
|
+
cursor: pointer;
|
|
1018
|
+
}
|
|
1019
|
+
.deck-edit-add:hover { border-color: color-mix(in srgb, var(--text) 30%, transparent); color: var(--text); }
|
|
1020
|
+
.deck-edit-delete:hover { border-color: #e5484d; color: #ff8589; }
|
|
1021
|
+
|
|
1022
|
+
/* The delete question, over the drawer alone — the deck stays untouched. */
|
|
1023
|
+
.deck-edit-confirm {
|
|
1024
|
+
position: absolute;
|
|
1025
|
+
inset: 0;
|
|
1026
|
+
z-index: 1;
|
|
1027
|
+
display: flex;
|
|
1028
|
+
flex-direction: column;
|
|
1029
|
+
align-items: center;
|
|
1030
|
+
justify-content: center;
|
|
1031
|
+
gap: 16px;
|
|
1032
|
+
padding: 24px;
|
|
1033
|
+
text-align: center;
|
|
1034
|
+
background: color-mix(in srgb, var(--bg) 68%, transparent);
|
|
1035
|
+
backdrop-filter: blur(5px);
|
|
1036
|
+
}
|
|
1037
|
+
.deck-edit-confirm[hidden] { display: none; }
|
|
1038
|
+
.deck-edit-confirm p {
|
|
1039
|
+
margin: 0;
|
|
1040
|
+
font: 400 13.5px/1.6 var(--sans);
|
|
1041
|
+
color: var(--text);
|
|
1042
|
+
}
|
|
1043
|
+
.deck-edit-confirm p code { font: 500 12.5px var(--mono); color: var(--accent-2); }
|
|
1044
|
+
.deck-edit-confirm > div { display: flex; gap: 10px; }
|
|
1045
|
+
.deck-edit-cancel,
|
|
1046
|
+
.deck-edit-confirm-delete {
|
|
1047
|
+
padding: 7px 18px;
|
|
1048
|
+
border: 1px solid var(--line);
|
|
1049
|
+
border-radius: 999px;
|
|
1050
|
+
font: 600 13px/1 var(--sans);
|
|
1051
|
+
color: var(--text);
|
|
1052
|
+
background: transparent;
|
|
1053
|
+
cursor: pointer;
|
|
1054
|
+
}
|
|
1055
|
+
.deck-edit-cancel:hover { background: color-mix(in srgb, var(--text) 10%, transparent); }
|
|
1056
|
+
.deck-edit-confirm-delete {
|
|
1057
|
+
border: 0;
|
|
1058
|
+
color: #fff;
|
|
1059
|
+
background: #e5484d;
|
|
1060
|
+
}
|
|
1061
|
+
.deck-edit-confirm-delete:hover { background: #f2555a; }
|
|
1062
|
+
|
|
1063
|
+
/* The open drawer's button wears the accent — it draws in nothing, like the
|
|
1064
|
+
pointer's; the id outweighs the generic armed-tool rule. */
|
|
1065
|
+
#tool-edit[aria-pressed='true'] {
|
|
1066
|
+
background: var(--accent);
|
|
1067
|
+
color: #0d1626;
|
|
1068
|
+
box-shadow:
|
|
1069
|
+
0 0 0 1px color-mix(in srgb, var(--text) 30%, transparent),
|
|
1070
|
+
0 0 16px color-mix(in srgb, var(--accent) 55%, transparent);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
843
1073
|
/* --- the pointer (pointer.js) -------------------------------------------- */
|
|
844
1074
|
|
|
845
1075
|
/* The real cursor goes away everywhere while the pointer is armed — the
|
|
@@ -1036,7 +1266,8 @@ html.deck-pointer-on * { cursor: none !important; }
|
|
|
1036
1266
|
#deck-pointer,
|
|
1037
1267
|
#deck-tools,
|
|
1038
1268
|
#deck-menu,
|
|
1039
|
-
#deck-outline
|
|
1269
|
+
#deck-outline,
|
|
1270
|
+
#deck-edit { display: none; }
|
|
1040
1271
|
/* The signature stays on the exported PDF — but the pill behind it does not
|
|
1041
1272
|
survive a white page. */
|
|
1042
1273
|
#deck-sig .sig-name { background: none; border-color: transparent; backdrop-filter: none; color: #444; }
|
|
@@ -23,5 +23,8 @@ npm run preview # build, then serve dist/
|
|
|
23
23
|
The Angular demo needs its own install once — `npm --prefix demo/angular-hello install`.
|
|
24
24
|
Without it that one slide comes up empty and everything else works.
|
|
25
25
|
|
|
26
|
+
To swap it for a real app of your own — copied in whole, with its own `package.json` and
|
|
27
|
+
`node_modules` — see [framework demos](https://github.com/fabiobiondi/fb-slides/blob/main/docs/framework-demos.md).
|
|
28
|
+
|
|
26
29
|
The deck engine itself is not in this repo: it comes from the package, so
|
|
27
30
|
`npm update __PKG__` brings new features to this talk and every other one.
|