contour-deck 0.3.2 → 0.4.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 +9 -8
- package/dist/core/deck.js +141 -8
- package/dist/core/overview.d.ts +10 -0
- package/dist/core/overview.js +79 -5
- package/dist/style.css +88 -12
- package/docs/CONTRACT.md +1 -1
- package/package.json +1 -1
- package/styles/deck.css +43 -9
package/README.md
CHANGED
|
@@ -23,15 +23,15 @@ The design is in [docs/DESIGN.md](docs/DESIGN.md) (Traditional Chinese); the HTM
|
|
|
23
23
|
|
|
24
24
|
| | |
|
|
25
25
|
| --- | --- |
|
|
26
|
-
|  |  |  |
|
|
27
|
+
| 跨區塊移動:拉遠再推進,位置列的圓點直接飛到別的區塊 | 3D 縱深桌面:按 3,整份簡報一個畫面,上下換列 |
|
|
28
28
|
|  |  |
|
|
29
29
|
| 步驟:空白鍵讓表格一列一列出現 | 手機:講稿檢視與往上滑換頁 |
|
|
30
30
|
|
|
31
31
|
| | |
|
|
32
32
|
| --- | --- |
|
|
33
|
-
|  |  |  |
|
|
34
|
+
| 輪廓(講過的投影片變淡) | 3D 縱深桌面 |
|
|
35
35
|
|  |  |
|
|
36
36
|
| 講稿面板(按 N) | 淺色主題 |
|
|
37
37
|
|
|
@@ -100,7 +100,7 @@ A page that is not Astro gets the same behaviour from `mountAll()` in `contour-d
|
|
|
100
100
|
| Option | Default | Meaning |
|
|
101
101
|
| --- | --- | --- |
|
|
102
102
|
| `paging` | `"snap"` | `"snap"`: one wheel or trackpad gesture moves one stop. `"free"`: native scrolling with proximity snap. |
|
|
103
|
-
| `overview` | `"flat"` | `"3d"` opens the outline as the
|
|
103
|
+
| `overview` | `"flat"` | `"3d"` opens the outline as the depth desk: one row per block, the current one in front, the rest receding behind it. |
|
|
104
104
|
| `linear.skip` | `["appendix"]` | Block kinds the linear advance and the arrow keys never enter. |
|
|
105
105
|
| `view` | from the URL | `"deck"` or `"notes"` (the speaker notes as a scrolling script, for a phone). |
|
|
106
106
|
| `at` | from the URL | Entry position, such as `"4.4.2"` or a slide id. |
|
|
@@ -202,12 +202,12 @@ The phone must be able to open the page's URL, so a deck on `localhost` needs a
|
|
|
202
202
|
| `Space`, `Enter`, `PageDown` | advance one step | open the current slide |
|
|
203
203
|
| `Shift+Space`, `PageUp`, `Backspace` | back one step | |
|
|
204
204
|
| `←` `→` | hide or show the current page's steps first, then the previous or next slide | move between thumbnails |
|
|
205
|
-
| `↑` `↓` | previous, next stop | move between thumbnails |
|
|
206
|
-
| `Home`, `End` | first, last slide of the block | |
|
|
205
|
+
| `↑` `↓` | previous, next stop | move between thumbnails; in 3D, the previous or next row to the front |
|
|
206
|
+
| `Home`, `End` | first, last slide of the block | in 3D, the first or last row to the front |
|
|
207
207
|
| `N` | notes panel | |
|
|
208
208
|
| `R` | remote panel (with the `remote` option) | remote panel |
|
|
209
209
|
| `Esc`, `O` | outline | `Esc` leaves the deck (`exit`) |
|
|
210
|
-
| `3` | | 3D
|
|
210
|
+
| `3` | | 3D: the depth desk |
|
|
211
211
|
|
|
212
212
|
### URL
|
|
213
213
|
|
|
@@ -220,6 +220,7 @@ The core removes both parameters in `destroy()` and leaves the others alone.
|
|
|
220
220
|
|
|
221
221
|
- Set `--ct-bg`, `--ct-fg`, `--ct-accent`, `--ct-accent-fg`, `--ct-border`, `--ct-muted`, `--ct-mono`, and `--ct-measure` on `.ct-app`.
|
|
222
222
|
By default the colours follow the site's `--background`, `--foreground`, `--accent`, `--accent-foreground`, `--border`, and `--muted-foreground`.
|
|
223
|
+
- The depth desk (3D outline) reads `--ct-row-depth` (how far back each row stands, default `260px`) and `--ct-row-rise` (how much higher, default `26px`) on `.ct-app`.
|
|
223
224
|
- A page keeps its content in a centred column of `--ct-measure` through its own side padding, so margins and widths the host sets on the content stay as written.
|
|
224
225
|
The type of a page follows the size of the stage and never goes below `1rem`; sizes the host writes in `em` follow it, sizes in `rem` do not.
|
|
225
226
|
- The open deck keeps its ancestors, so content styles that depend on them still apply.
|
package/dist/core/deck.js
CHANGED
|
@@ -39,6 +39,10 @@ export function createDeck(root, options = {}) {
|
|
|
39
39
|
/** A slide change the deck itself started and whose scroll has not arrived yet. */
|
|
40
40
|
let pending = null;
|
|
41
41
|
let wakeLock = null;
|
|
42
|
+
/** The depth desk: the row the camera is headed for, and the camera move under way. */
|
|
43
|
+
let aim = 0;
|
|
44
|
+
let move = 0;
|
|
45
|
+
let endMove = null;
|
|
42
46
|
/* ---- shell: bar, outline, stage, notes panel, notes view ---- */
|
|
43
47
|
const app = h("div", "ct-app");
|
|
44
48
|
const bar = app.appendChild(h("header", "ct-bar"));
|
|
@@ -146,6 +150,8 @@ export function createDeck(root, options = {}) {
|
|
|
146
150
|
// The title first: its width moves the rail, and the marker is placed by layout offsets.
|
|
147
151
|
titleEl.textContent = mode === "stage" ? blocks[pos.b].title : deckTitle;
|
|
148
152
|
rail.mark(pos);
|
|
153
|
+
if (mode === "stage")
|
|
154
|
+
overview.seen(pos.b, pos.s);
|
|
149
155
|
overview.ring(pos.b, pos.s);
|
|
150
156
|
const height = slide.el.clientHeight || 1;
|
|
151
157
|
const pages = boxes(slide);
|
|
@@ -328,8 +334,17 @@ export function createDeck(root, options = {}) {
|
|
|
328
334
|
};
|
|
329
335
|
const thumbRect = (at) => {
|
|
330
336
|
const face = overview.faces[at.b][at.s];
|
|
331
|
-
|
|
332
|
-
|
|
337
|
+
if (!depthOn()) {
|
|
338
|
+
face.scrollIntoView({ block: "nearest" });
|
|
339
|
+
return face.getBoundingClientRect();
|
|
340
|
+
}
|
|
341
|
+
// On the depth desk the block's row comes to the front at once, and the rectangle is read where its sheets end up.
|
|
342
|
+
front(at.b, true);
|
|
343
|
+
const plane = face.closest(".ct-plane");
|
|
344
|
+
plane.setAttribute("data-ct-still", "");
|
|
345
|
+
const rect = face.getBoundingClientRect();
|
|
346
|
+
plane.removeAttribute("data-ct-still");
|
|
347
|
+
return rect;
|
|
333
348
|
};
|
|
334
349
|
const cancel = (animations) => animations.forEach(animation => animation.cancel());
|
|
335
350
|
/** Outline to stage: the stage grows out of the slide's thumbnail. */
|
|
@@ -374,9 +389,21 @@ export function createDeck(root, options = {}) {
|
|
|
374
389
|
stage.style.opacity = "0";
|
|
375
390
|
cancel(out);
|
|
376
391
|
place(at, end);
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
392
|
+
let thumb;
|
|
393
|
+
if (depthOn()) {
|
|
394
|
+
// On the depth desk the camera moves forward to the new block's row, then the stage grows out of it.
|
|
395
|
+
render();
|
|
396
|
+
await front(pos.b);
|
|
397
|
+
if (!alive)
|
|
398
|
+
return;
|
|
399
|
+
thumb = thumbRect(pos);
|
|
400
|
+
await wait(reduced() ? 80 : 160);
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
thumb = thumbRect(pos);
|
|
404
|
+
render();
|
|
405
|
+
await wait(reduced() ? 80 : 400);
|
|
406
|
+
}
|
|
380
407
|
if (!alive)
|
|
381
408
|
return;
|
|
382
409
|
const into = zoomStage(stage, root, thumb, "in", 460, reduced());
|
|
@@ -412,6 +439,8 @@ export function createDeck(root, options = {}) {
|
|
|
412
439
|
if (mode === "stage")
|
|
413
440
|
return close();
|
|
414
441
|
mode = "overview";
|
|
442
|
+
if (depthOn())
|
|
443
|
+
front(pos.b, true);
|
|
415
444
|
render();
|
|
416
445
|
}
|
|
417
446
|
function toScript() {
|
|
@@ -437,7 +466,62 @@ export function createDeck(root, options = {}) {
|
|
|
437
466
|
}
|
|
438
467
|
/* ---- input ---- */
|
|
439
468
|
const press = (el, on) => el.setAttribute("aria-pressed", String(on));
|
|
440
|
-
const
|
|
469
|
+
const depthOn = () => app.hasAttribute("data-ct-3d");
|
|
470
|
+
function toggle3d() {
|
|
471
|
+
const on = app.toggleAttribute("data-ct-3d");
|
|
472
|
+
press(threeDButton, on);
|
|
473
|
+
stopMove();
|
|
474
|
+
overview.depth(on);
|
|
475
|
+
if (on)
|
|
476
|
+
front(pos.b, true);
|
|
477
|
+
else {
|
|
478
|
+
overview.el.scrollTop = 0;
|
|
479
|
+
overview.faces[pos.b][pos.s].scrollIntoView({ block: "nearest" });
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
function stopMove() {
|
|
483
|
+
cancelAnimationFrame(move);
|
|
484
|
+
move = 0;
|
|
485
|
+
endMove?.();
|
|
486
|
+
endMove = null;
|
|
487
|
+
}
|
|
488
|
+
/** Brings a block's row to the front of the depth desk: the camera glides there, or jumps under reduced motion. */
|
|
489
|
+
function front(b, instant = reduced()) {
|
|
490
|
+
b = clamp(b, 0, blocks.length - 1);
|
|
491
|
+
if (move && aim === b && !instant)
|
|
492
|
+
return Promise.resolve();
|
|
493
|
+
stopMove();
|
|
494
|
+
aim = b;
|
|
495
|
+
const ov = overview.el;
|
|
496
|
+
const from = overview.camera();
|
|
497
|
+
if (instant || from === b) {
|
|
498
|
+
overview.camera(b);
|
|
499
|
+
ov.scrollTop = b * overview.step;
|
|
500
|
+
return Promise.resolve();
|
|
501
|
+
}
|
|
502
|
+
const started = performance.now();
|
|
503
|
+
const ms = clamp(240 + 80 * Math.abs(b - from), 240, 640);
|
|
504
|
+
return new Promise(resolve => {
|
|
505
|
+
endMove = resolve;
|
|
506
|
+
const tick = (now) => {
|
|
507
|
+
const t = Math.min(1, (now - started) / ms);
|
|
508
|
+
overview.camera(from + (b - from) * (1 - (1 - t) ** 3));
|
|
509
|
+
if (t < 1)
|
|
510
|
+
return void (move = requestAnimationFrame(tick));
|
|
511
|
+
move = 0;
|
|
512
|
+
endMove = null;
|
|
513
|
+
ov.scrollTop = b * overview.step;
|
|
514
|
+
resolve();
|
|
515
|
+
};
|
|
516
|
+
move = requestAnimationFrame(tick);
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
/** A row on the depth desk, with the focus on its first thumbnail, or on the current one in the current block. */
|
|
520
|
+
function toRow(b) {
|
|
521
|
+
b = clamp(b, 0, blocks.length - 1);
|
|
522
|
+
overview.thumbs[b][b === pos.b ? pos.s : 0].focus({ preventScroll: true });
|
|
523
|
+
void front(b);
|
|
524
|
+
}
|
|
441
525
|
/** The stage changed size: put the current slide and page back in view. */
|
|
442
526
|
function align(p = pos.p) {
|
|
443
527
|
if (busy)
|
|
@@ -460,11 +544,20 @@ export function createDeck(root, options = {}) {
|
|
|
460
544
|
const exit = () => busy || listeners.exit.forEach(listener => listener());
|
|
461
545
|
const remote = () => options.remote?.(api);
|
|
462
546
|
const actions = { overview: toOverview, "3d": toggle3d, script: toScript, notes: toggleNotes, remote, prev, next, exit };
|
|
547
|
+
/** The row in front when the pointer went down: the focus that follows may already be bringing another row forward. */
|
|
548
|
+
let pressed = null;
|
|
549
|
+
overview.el.addEventListener("pointerdown", () => void (pressed = aim), { capture: true, signal });
|
|
463
550
|
app.addEventListener("click", event => {
|
|
464
551
|
const target = event.target;
|
|
465
552
|
const action = target.closest("[data-ct-act]")?.dataset.ctAct;
|
|
466
553
|
if (action)
|
|
467
554
|
return void actions[action]?.();
|
|
555
|
+
// On the depth desk a click on a row behind brings it to the front; only the row in front opens.
|
|
556
|
+
const row = mode === "overview" && depthOn() ? overview.rowOf(target) : -1;
|
|
557
|
+
const wasFront = pressed ?? aim;
|
|
558
|
+
pressed = null;
|
|
559
|
+
if (row >= 0 && row !== wasFront)
|
|
560
|
+
return void front(row);
|
|
468
561
|
const go = target.closest("[data-ct-go]")?.dataset.ctGo;
|
|
469
562
|
if (!go)
|
|
470
563
|
return;
|
|
@@ -473,7 +566,15 @@ export function createDeck(root, options = {}) {
|
|
|
473
566
|
}, { signal });
|
|
474
567
|
addEventListener("resize", () => {
|
|
475
568
|
const p = pos.p;
|
|
476
|
-
requestAnimationFrame(() =>
|
|
569
|
+
requestAnimationFrame(() => {
|
|
570
|
+
if (!alive)
|
|
571
|
+
return;
|
|
572
|
+
if (depthOn()) {
|
|
573
|
+
overview.depth(true);
|
|
574
|
+
front(aim, true);
|
|
575
|
+
}
|
|
576
|
+
align(p);
|
|
577
|
+
});
|
|
477
578
|
}, { signal });
|
|
478
579
|
// The browser drops the screen wake lock when the page is hidden; the notes view asks again on return.
|
|
479
580
|
document.addEventListener("visibilitychange", () => {
|
|
@@ -485,9 +586,25 @@ export function createDeck(root, options = {}) {
|
|
|
485
586
|
function focusThumb(step) {
|
|
486
587
|
const all = overview.thumbs.flat();
|
|
487
588
|
const focused = all.indexOf(document.activeElement);
|
|
488
|
-
all[focused < 0 ? all.indexOf(overview.thumbs[pos.b][pos.s]) : clamp(focused + step, 0, all.length - 1)].focus();
|
|
589
|
+
all[focused < 0 ? all.indexOf(overview.thumbs[pos.b][pos.s]) : clamp(focused + step, 0, all.length - 1)].focus({ preventScroll: depthOn() });
|
|
489
590
|
}
|
|
591
|
+
// On the depth desk the focus brings its row to the front: Tab, the arrow keys, a click.
|
|
592
|
+
overview.el.addEventListener("focusin", event => {
|
|
593
|
+
const row = depthOn() ? overview.rowOf(event.target) : -1;
|
|
594
|
+
if (row >= 0 && row !== aim)
|
|
595
|
+
void front(row);
|
|
596
|
+
}, { signal });
|
|
597
|
+
// Native scrolling (touch, the scroll bar) moves the camera; one snap stop per row.
|
|
598
|
+
overview.el.addEventListener("scroll", () => {
|
|
599
|
+
if (!depthOn() || move)
|
|
600
|
+
return;
|
|
601
|
+
const at = overview.el.scrollTop / overview.step;
|
|
602
|
+
const row = Math.round(at);
|
|
603
|
+
overview.camera(Math.abs(at - row) < 0.02 ? row : at);
|
|
604
|
+
aim = clamp(row, 0, blocks.length - 1);
|
|
605
|
+
}, { passive: true, signal });
|
|
490
606
|
document.addEventListener("keydown", event => {
|
|
607
|
+
pressed = null;
|
|
491
608
|
const target = event.target;
|
|
492
609
|
if (event.metaKey || event.ctrlKey || event.altKey || busy || target.closest?.("input,textarea,select,[contenteditable]"))
|
|
493
610
|
return;
|
|
@@ -498,10 +615,15 @@ export function createDeck(root, options = {}) {
|
|
|
498
615
|
if (key === "r" && options.remote && mode !== "notes")
|
|
499
616
|
run = remote;
|
|
500
617
|
else if (mode === "overview") {
|
|
618
|
+
const rows = depthOn();
|
|
501
619
|
if (key === "3")
|
|
502
620
|
run = toggle3d;
|
|
503
621
|
else if (key === "Escape")
|
|
504
622
|
run = exit;
|
|
623
|
+
else if (rows && (key === "ArrowUp" || key === "ArrowDown"))
|
|
624
|
+
run = () => toRow(aim + (key === "ArrowDown" ? 1 : -1));
|
|
625
|
+
else if (rows && (key === "Home" || key === "End"))
|
|
626
|
+
run = () => toRow(key === "Home" ? 0 : blocks.length - 1);
|
|
505
627
|
else if (key.startsWith("Arrow"))
|
|
506
628
|
run = () => focusThumb(key === "ArrowRight" || key === "ArrowDown" ? 1 : -1);
|
|
507
629
|
else if (key === "PageDown" || (activates && !onControl))
|
|
@@ -547,6 +669,16 @@ export function createDeck(root, options = {}) {
|
|
|
547
669
|
stepV(step);
|
|
548
670
|
}, { passive: false, signal });
|
|
549
671
|
}
|
|
672
|
+
// The depth desk: one wheel gesture moves one row.
|
|
673
|
+
const rowStep = createWheelGate();
|
|
674
|
+
overview.el.addEventListener("wheel", event => {
|
|
675
|
+
if (!depthOn() || event.ctrlKey || Math.abs(event.deltaX) > Math.abs(event.deltaY))
|
|
676
|
+
return;
|
|
677
|
+
event.preventDefault();
|
|
678
|
+
const step = rowStep(event.deltaY * (event.deltaMode === 1 ? 16 : 1), performance.now(), busy);
|
|
679
|
+
if (step)
|
|
680
|
+
toRow(aim + step);
|
|
681
|
+
}, { passive: false, signal });
|
|
550
682
|
/* ---- start ---- */
|
|
551
683
|
const params = new URLSearchParams(useUrl ? location.search : "");
|
|
552
684
|
const startAt = resolve(options.at ?? params.get("at") ?? "");
|
|
@@ -569,6 +701,7 @@ export function createDeck(root, options = {}) {
|
|
|
569
701
|
alive = false;
|
|
570
702
|
listeners.destroy.forEach(listener => listener());
|
|
571
703
|
abort.abort();
|
|
704
|
+
stopMove();
|
|
572
705
|
cancelAnimationFrame(frame);
|
|
573
706
|
clearTimeout(idle);
|
|
574
707
|
setCurrent(null);
|
package/dist/core/overview.d.ts
CHANGED
|
@@ -7,5 +7,15 @@ export interface Overview {
|
|
|
7
7
|
thumbs: HTMLButtonElement[][];
|
|
8
8
|
/** Moves the "you are here" ring onto a thumbnail. */
|
|
9
9
|
ring(b: number, s: number, instant?: boolean): void;
|
|
10
|
+
/** Records that a slide has been on the stage: the outline shows it faded. */
|
|
11
|
+
seen(b: number, s: number): void;
|
|
12
|
+
/** Switches the depth desk on or off and fits its rows to the outline's size. */
|
|
13
|
+
depth(on: boolean): void;
|
|
14
|
+
/** The depth desk's camera: the row it stands at, fractional while it moves. Without an argument, reads it. */
|
|
15
|
+
camera(at?: number): number;
|
|
16
|
+
/** The block whose row is under `el`, or -1. */
|
|
17
|
+
rowOf(el: Element): number;
|
|
18
|
+
/** Scroll distance of one row on the depth desk. */
|
|
19
|
+
readonly step: number;
|
|
10
20
|
}
|
|
11
21
|
export declare function buildOverview(blocks: BlockNode[], labels: Labels): Overview;
|
package/dist/core/overview.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
/** The outline: one panel per block, one schematic thumbnail per slide. */
|
|
2
|
-
import { h, offsetIn } from "./dom.js";
|
|
1
|
+
/** The outline: one panel per block, one schematic thumbnail per slide; in 3D, one row per block on the depth desk. */
|
|
2
|
+
import { clamp, h, offsetIn } from "./dom.js";
|
|
3
3
|
const FIGURE = "figure,img,picture,svg,canvas,video,iframe";
|
|
4
4
|
function sketchType(el) {
|
|
5
5
|
if (el.dataset.ctSk)
|
|
@@ -36,11 +36,24 @@ function sketch(page) {
|
|
|
36
36
|
}
|
|
37
37
|
export function buildOverview(blocks, labels) {
|
|
38
38
|
const el = h("div", "ct-ov");
|
|
39
|
-
|
|
39
|
+
// In 3D the desk is the camera (sticky, clipped, with the perspective) and the plane the scene inside it;
|
|
40
|
+
// the snap points under them give each row a stop of native scrolling.
|
|
41
|
+
const desk = el.appendChild(h("div", "ct-desk"));
|
|
42
|
+
const plane = desk.appendChild(h("div", "ct-plane"));
|
|
43
|
+
const stops = el.appendChild(h("div", "ct-stops", "", { "aria-hidden": "true" }));
|
|
40
44
|
const faces = [];
|
|
41
45
|
const thumbs = [];
|
|
46
|
+
const tiles = [];
|
|
47
|
+
const visited = blocks.map(block => block.slides.map(() => false));
|
|
48
|
+
let depth = false;
|
|
49
|
+
let cam = 0;
|
|
50
|
+
let front = -1;
|
|
51
|
+
let here = null;
|
|
42
52
|
blocks.forEach((block, b) => {
|
|
43
53
|
const tile = plane.appendChild(h("section", "ct-tile"));
|
|
54
|
+
tile.style.setProperty("--i", String(b));
|
|
55
|
+
tiles.push(tile);
|
|
56
|
+
stops.append(h("i"));
|
|
44
57
|
if (block.kind)
|
|
45
58
|
tile.dataset.kind = block.kind;
|
|
46
59
|
if (block.slides.length > 3)
|
|
@@ -80,13 +93,73 @@ export function buildOverview(blocks, labels) {
|
|
|
80
93
|
});
|
|
81
94
|
});
|
|
82
95
|
const ringEl = plane.appendChild(h("div", "ct-ring", "", { "data-label": labels.here }));
|
|
83
|
-
|
|
96
|
+
let ringAt = [0, 0];
|
|
97
|
+
/** Rows the camera has passed slide out and take no pointer; the row in front shows its stacks in depth. */
|
|
98
|
+
function setFront(row) {
|
|
99
|
+
if (row === front)
|
|
100
|
+
return;
|
|
101
|
+
front = row;
|
|
102
|
+
tiles.forEach((tile, b) => {
|
|
103
|
+
tile.toggleAttribute("data-ct-front", depth && b === row);
|
|
104
|
+
tile.toggleAttribute("data-ct-past", depth && b < row);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/** In 3D a row taller than six tenths of the outline is scaled down; all rows hang from one line that leaves room for the tallest. */
|
|
108
|
+
function fit() {
|
|
109
|
+
if (!depth)
|
|
110
|
+
return;
|
|
111
|
+
const height = el.clientHeight;
|
|
112
|
+
let tallest = 0;
|
|
113
|
+
for (const tile of tiles) {
|
|
114
|
+
const scale = Math.min(1, (height * 0.6) / (tile.offsetHeight || 1));
|
|
115
|
+
tile.style.setProperty("--fit", scale.toFixed(3));
|
|
116
|
+
tallest = Math.max(tallest, tile.offsetHeight * scale);
|
|
117
|
+
}
|
|
118
|
+
plane.style.setProperty("--ct-top", `${Math.round(clamp(height - tallest - 40, height * 0.36, height * 0.6))}px`);
|
|
119
|
+
}
|
|
120
|
+
const api = {
|
|
84
121
|
el,
|
|
85
122
|
faces,
|
|
86
123
|
thumbs,
|
|
124
|
+
get step() {
|
|
125
|
+
return stops.firstElementChild.offsetHeight || 1;
|
|
126
|
+
},
|
|
127
|
+
depth(on) {
|
|
128
|
+
depth = on;
|
|
129
|
+
front = -1;
|
|
130
|
+
if (on)
|
|
131
|
+
fit();
|
|
132
|
+
setFront(on ? clamp(Math.round(cam), 0, tiles.length - 1) : -1);
|
|
133
|
+
api.ring(...ringAt, true);
|
|
134
|
+
},
|
|
135
|
+
camera(at) {
|
|
136
|
+
if (at === undefined)
|
|
137
|
+
return cam;
|
|
138
|
+
cam = at;
|
|
139
|
+
plane.style.setProperty("--ct-cam", String(+at.toFixed(4)));
|
|
140
|
+
setFront(depth ? clamp(Math.round(at), 0, tiles.length - 1) : -1);
|
|
141
|
+
return cam;
|
|
142
|
+
},
|
|
143
|
+
rowOf: target => tiles.findIndex(tile => tile.contains(target)),
|
|
144
|
+
seen(b, s) {
|
|
145
|
+
visited[b][s] = true;
|
|
146
|
+
thumbs[b][s].setAttribute("data-ct-seen", "");
|
|
147
|
+
},
|
|
87
148
|
ring(b, s, instant) {
|
|
149
|
+
ringAt = [b, s];
|
|
150
|
+
// The current thumbnail stands out of the faded ones; a block is faded once all its slides were shown and it is not current.
|
|
151
|
+
here?.removeAttribute("aria-current");
|
|
152
|
+
here = thumbs[b][s];
|
|
153
|
+
here.setAttribute("aria-current", "true");
|
|
154
|
+
tiles.forEach((tile, i) => tile.toggleAttribute("data-ct-seen", i !== b && visited[i].every(Boolean)));
|
|
88
155
|
const face = faces[b][s];
|
|
89
|
-
|
|
156
|
+
// In 3D the ring rides in the row of its thumbnail, so it moves with that row.
|
|
157
|
+
const host = depth ? tiles[b] : plane;
|
|
158
|
+
if (ringEl.parentElement !== host) {
|
|
159
|
+
host.append(ringEl);
|
|
160
|
+
instant = true;
|
|
161
|
+
}
|
|
162
|
+
const { x, y } = offsetIn(face, host);
|
|
90
163
|
const style = ringEl.style;
|
|
91
164
|
if (instant)
|
|
92
165
|
style.transition = "none";
|
|
@@ -101,4 +174,5 @@ export function buildOverview(blocks, labels) {
|
|
|
101
174
|
}
|
|
102
175
|
},
|
|
103
176
|
};
|
|
177
|
+
return api;
|
|
104
178
|
}
|
package/dist/style.css
CHANGED
|
@@ -21,6 +21,8 @@ html[data-ct-open] {
|
|
|
21
21
|
Menlo,
|
|
22
22
|
Consolas,
|
|
23
23
|
monospace;
|
|
24
|
+
--ct-row-depth: 260px;
|
|
25
|
+
--ct-row-rise: 26px;
|
|
24
26
|
}
|
|
25
27
|
.ct-app {
|
|
26
28
|
--ct-panel: color-mix(in srgb, var(--ct-fg) 4%, var(--ct-bg));
|
|
@@ -186,7 +188,6 @@ html[data-ct-open] {
|
|
|
186
188
|
max-width: 86rem;
|
|
187
189
|
margin: 0 auto;
|
|
188
190
|
transform-style: preserve-3d;
|
|
189
|
-
transition: transform .7s var(--ct-ease);
|
|
190
191
|
}
|
|
191
192
|
.ct-tile {
|
|
192
193
|
border: 1px solid var(--ct-border);
|
|
@@ -383,23 +384,98 @@ html[data-ct-open] {
|
|
|
383
384
|
color: var(--ct-accent-fg);
|
|
384
385
|
white-space: nowrap;
|
|
385
386
|
}
|
|
387
|
+
.ct-plane {
|
|
388
|
+
--ct-ink: var(--ct-fg);
|
|
389
|
+
--ct-line: var(--ct-border);
|
|
390
|
+
--ct-dim: var(--ct-muted);
|
|
391
|
+
}
|
|
392
|
+
.ct-plane :is(.ct-tile, .ct-thumb) {
|
|
393
|
+
--ct-fade: min(80%, (var(--ct-fog, 0) + var(--ct-was, 0)) * 100%);
|
|
394
|
+
--ct-fg: color-mix(in srgb, var(--ct-bg) var(--ct-fade), var(--ct-ink));
|
|
395
|
+
--ct-border: color-mix(in srgb, var(--ct-bg) var(--ct-fade), var(--ct-line));
|
|
396
|
+
--ct-muted: color-mix(in srgb, var(--ct-bg) var(--ct-fade), var(--ct-dim));
|
|
397
|
+
}
|
|
398
|
+
.ct-app .ct-plane :is(.ct-tile, .ct-thumb) {
|
|
399
|
+
color: var(--ct-fg);
|
|
400
|
+
}
|
|
401
|
+
.ct-plane .ct-thumb {
|
|
402
|
+
--ct-was: 0;
|
|
403
|
+
}
|
|
404
|
+
.ct-plane :is(.ct-tile[data-ct-seen], .ct-thumb[data-ct-seen]:not([aria-current])) {
|
|
405
|
+
--ct-was: .5;
|
|
406
|
+
}
|
|
407
|
+
.ct-stops {
|
|
408
|
+
display: none;
|
|
409
|
+
}
|
|
386
410
|
.ct-app[data-ct-3d] .ct-ov {
|
|
387
|
-
|
|
411
|
+
container-type: size;
|
|
412
|
+
padding: 0;
|
|
413
|
+
overflow: hidden auto;
|
|
414
|
+
scroll-snap-type: y mandatory;
|
|
415
|
+
overscroll-behavior: contain;
|
|
416
|
+
scrollbar-width: none;
|
|
417
|
+
}
|
|
418
|
+
.ct-app[data-ct-3d] .ct-ov::-webkit-scrollbar {
|
|
419
|
+
display: none;
|
|
420
|
+
}
|
|
421
|
+
.ct-app[data-ct-3d] .ct-desk {
|
|
422
|
+
position: sticky;
|
|
423
|
+
top: 0;
|
|
424
|
+
height: 100cqh;
|
|
425
|
+
overflow: clip;
|
|
426
|
+
perspective: 1000px;
|
|
427
|
+
perspective-origin: 50% 0;
|
|
428
|
+
z-index: 1;
|
|
429
|
+
}
|
|
430
|
+
.ct-app[data-ct-3d] .ct-stops {
|
|
431
|
+
display: block;
|
|
432
|
+
margin-top: -100cqh;
|
|
433
|
+
pointer-events: none;
|
|
434
|
+
}
|
|
435
|
+
.ct-stops i {
|
|
436
|
+
display: block;
|
|
437
|
+
height: 120px;
|
|
438
|
+
scroll-snap-align: start;
|
|
439
|
+
scroll-snap-stop: always;
|
|
440
|
+
}
|
|
441
|
+
.ct-stops i:last-child {
|
|
442
|
+
height: 100cqh;
|
|
388
443
|
}
|
|
389
444
|
.ct-app[data-ct-3d] .ct-plane {
|
|
390
|
-
|
|
445
|
+
position: absolute;
|
|
446
|
+
inset: 0;
|
|
447
|
+
display: block;
|
|
448
|
+
max-width: none;
|
|
449
|
+
margin: 0;
|
|
450
|
+
pointer-events: none;
|
|
451
|
+
}
|
|
452
|
+
.ct-app[data-ct-3d] .ct-tile {
|
|
453
|
+
--ahead: max(var(--i) - var(--ct-cam, 0), 0);
|
|
454
|
+
--past: min(var(--i) - var(--ct-cam, 0), 0);
|
|
455
|
+
--ct-fog: min(var(--ahead) * .14, .56);
|
|
456
|
+
position: absolute;
|
|
457
|
+
left: 50%;
|
|
458
|
+
top: var(--ct-top, 40%);
|
|
459
|
+
width: min(100% - 2.4rem, 60rem);
|
|
460
|
+
transform-origin: 50% 0;
|
|
461
|
+
transform: translate3d(-50%, calc(var(--past) * -45cqh - min(var(--ahead), 4) * var(--ct-row-rise)), calc(var(--past) * -240px - var(--ahead) * var(--ct-row-depth))) scale(var(--fit, 1));
|
|
462
|
+
opacity: calc(1 + var(--past) * 1.6);
|
|
463
|
+
pointer-events: auto;
|
|
464
|
+
}
|
|
465
|
+
.ct-app[data-ct-3d] .ct-tile[data-ct-past] {
|
|
466
|
+
pointer-events: none;
|
|
391
467
|
}
|
|
392
|
-
.ct-app[data-ct-3d] .ct-
|
|
393
|
-
|
|
468
|
+
.ct-app[data-ct-3d] .ct-tile:not([data-ct-front]) {
|
|
469
|
+
cursor: pointer;
|
|
394
470
|
}
|
|
395
|
-
.ct-app[data-ct-3d] .ct-
|
|
396
|
-
transform:
|
|
471
|
+
.ct-app[data-ct-3d] [data-ct-front] .ct-layer {
|
|
472
|
+
transform: translate3d(calc(var(--k) * 3px), calc(var(--k) * 3px), calc((var(--n) - 1 - var(--k)) * var(--ct-lift)));
|
|
397
473
|
}
|
|
398
|
-
.ct-app[data-ct-3d]
|
|
399
|
-
transform: translateZ(
|
|
474
|
+
.ct-app[data-ct-3d] [data-ct-front] :is(.ct-face, .ct-ring) {
|
|
475
|
+
transform: translateZ(calc((var(--n) - 1) * var(--ct-lift)));
|
|
400
476
|
}
|
|
401
|
-
.ct-
|
|
402
|
-
|
|
477
|
+
.ct-plane[data-ct-still] :is(.ct-layer, .ct-face, .ct-ring, .ct-thumb) {
|
|
478
|
+
transition: none;
|
|
403
479
|
}
|
|
404
480
|
.ct-stage {
|
|
405
481
|
position: absolute;
|
|
@@ -621,7 +697,7 @@ html[data-ct-open] {
|
|
|
621
697
|
}
|
|
622
698
|
}
|
|
623
699
|
@media (prefers-reduced-motion: reduce) {
|
|
624
|
-
.ct-app :is(.ct-
|
|
700
|
+
.ct-app :is(.ct-layer, .ct-face, .ct-thumb, .ct-ring, .ct-rm, [data-ct-step]) {
|
|
625
701
|
transition-duration: .01ms !important;
|
|
626
702
|
}
|
|
627
703
|
}
|
package/docs/CONTRACT.md
CHANGED
|
@@ -146,7 +146,7 @@ The core does not read them, and a host that calls `createDeck` itself needs non
|
|
|
146
146
|
| `data-ct-host` | the wrapper of one deck element | The element the host script mounts. The deck may sit at any depth inside it. |
|
|
147
147
|
| `data-ct-doc-class` | the wrapper | Space-separated classes the wrapper carries only while the deck is closed; the script removes them on opening and restores them on closing. |
|
|
148
148
|
| `data-ct-paging` | the wrapper | `snap` or `free`: the `paging` option of the core. |
|
|
149
|
-
| `data-ct-overview` | the wrapper | `flat` or `3d`: the `overview` option of the core. |
|
|
149
|
+
| `data-ct-overview` | the wrapper | `flat` or `3d`: the `overview` option of the core. `3d` opens the outline as the depth desk, one row per block. |
|
|
150
150
|
| `data-ct-legacy` | the wrapper | Accept old reveal.js links: `?view=slides&slide=h.v` is rewritten to `?view=deck&at=…` through `data-legacy` and `data-legacy-v`. |
|
|
151
151
|
| `data-ct-start` | any control in the page | A click opens the deck; with the value `notes`, its notes view. Inside a wrapper it opens that deck, elsewhere the first deck of the page. |
|
|
152
152
|
| `data-ct-remote` | the wrapper | The deck has the remote button and the `R` key (`contour-deck/remote`, loaded on first use). |
|
package/package.json
CHANGED
package/styles/deck.css
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
--ct-muted: var(--muted-foreground, color-mix(in srgb, var(--ct-fg) 62%, var(--ct-bg)));
|
|
9
9
|
--ct-measure: 62rem;
|
|
10
10
|
--ct-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
11
|
+
/* the depth desk (3D outline): how far back each row stands, and how much higher */
|
|
12
|
+
--ct-row-depth: 260px;
|
|
13
|
+
--ct-row-rise: 26px;
|
|
11
14
|
}
|
|
12
15
|
.ct-app {
|
|
13
16
|
--ct-panel: color-mix(in srgb, var(--ct-fg) 4%, var(--ct-bg));
|
|
@@ -51,7 +54,7 @@
|
|
|
51
54
|
|
|
52
55
|
/* ---- outline ---- */
|
|
53
56
|
.ct-ov { position: absolute; inset: 0; overflow: auto; padding: 1.1rem; }
|
|
54
|
-
.ct-plane { position: relative; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr)); gap: 1rem; max-width: 86rem; margin: 0 auto; transform-style: preserve-3d;
|
|
57
|
+
.ct-plane { position: relative; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr)); gap: 1rem; max-width: 86rem; margin: 0 auto; transform-style: preserve-3d; }
|
|
55
58
|
.ct-tile { border: 1px solid var(--ct-border); background: var(--ct-bg); padding: .7rem .8rem .9rem; transform-style: preserve-3d; min-width: 0; }
|
|
56
59
|
.ct-tile[data-kind="appendix"] { background: var(--ct-panel); }
|
|
57
60
|
@media (min-width: 900px) { .ct-tile[data-wide] { grid-column: span 2; } }
|
|
@@ -84,13 +87,44 @@
|
|
|
84
87
|
.ct-ring { position: absolute; left: 0; top: 0; pointer-events: none; outline: 2px solid var(--ct-accent); outline-offset: 3px; z-index: 3; transition: left .38s var(--ct-ease), top .38s var(--ct-ease), width .38s var(--ct-ease), height .38s var(--ct-ease), transform .7s var(--ct-ease); }
|
|
85
88
|
.ct-ring::after { content: attr(data-label); position: absolute; left: -5px; top: calc(100% + 5px); font: 650 .56rem/1 var(--ct-mono); letter-spacing: .08em; padding: .2rem .3rem; background: var(--ct-accent); color: var(--ct-accent-fg); white-space: nowrap; }
|
|
86
89
|
|
|
87
|
-
/*
|
|
88
|
-
|
|
89
|
-
.ct-
|
|
90
|
-
.ct-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
/* Faded: a slide already shown, a block already finished, and on the depth desk the rows far behind.
|
|
91
|
+
Colours mix toward the background instead of using opacity, which would flatten the 3D of a row. */
|
|
92
|
+
.ct-plane { --ct-ink: var(--ct-fg); --ct-line: var(--ct-border); --ct-dim: var(--ct-muted); }
|
|
93
|
+
.ct-plane :is(.ct-tile, .ct-thumb) {
|
|
94
|
+
--ct-fade: min(80%, (var(--ct-fog, 0) + var(--ct-was, 0)) * 100%);
|
|
95
|
+
--ct-fg: color-mix(in srgb, var(--ct-bg) var(--ct-fade), var(--ct-ink));
|
|
96
|
+
--ct-border: color-mix(in srgb, var(--ct-bg) var(--ct-fade), var(--ct-line));
|
|
97
|
+
--ct-muted: color-mix(in srgb, var(--ct-bg) var(--ct-fade), var(--ct-dim));
|
|
98
|
+
}
|
|
99
|
+
.ct-app .ct-plane :is(.ct-tile, .ct-thumb) { color: var(--ct-fg); }
|
|
100
|
+
.ct-plane .ct-thumb { --ct-was: 0; }
|
|
101
|
+
.ct-plane :is(.ct-tile[data-ct-seen], .ct-thumb[data-ct-seen]:not([aria-current])) { --ct-was: .5; }
|
|
102
|
+
|
|
103
|
+
/* 3D, the depth desk: every block is a row facing the viewer. The camera stands at row --ct-cam (the scroll position, one
|
|
104
|
+
snap stop per row); rows ahead recede and rise, rows passed slide out toward the viewer. Only the row in front stacks its pages in depth. */
|
|
105
|
+
.ct-stops { display: none; }
|
|
106
|
+
.ct-app[data-ct-3d] .ct-ov { container-type: size; padding: 0; overflow: hidden auto; scroll-snap-type: y mandatory; overscroll-behavior: contain; scrollbar-width: none; }
|
|
107
|
+
.ct-app[data-ct-3d] .ct-ov::-webkit-scrollbar { display: none; }
|
|
108
|
+
.ct-app[data-ct-3d] .ct-desk { position: sticky; top: 0; height: 100cqh; overflow: clip; perspective: 1000px; perspective-origin: 50% 0; z-index: 1; }
|
|
109
|
+
.ct-app[data-ct-3d] .ct-stops { display: block; margin-top: -100cqh; pointer-events: none; }
|
|
110
|
+
.ct-stops i { display: block; height: 120px; scroll-snap-align: start; scroll-snap-stop: always; }
|
|
111
|
+
.ct-stops i:last-child { height: 100cqh; }
|
|
112
|
+
/* The scene's own surface stands at depth 0, in front of every row behind: only the rows take the pointer. */
|
|
113
|
+
.ct-app[data-ct-3d] .ct-plane { position: absolute; inset: 0; display: block; max-width: none; margin: 0; pointer-events: none; }
|
|
114
|
+
.ct-app[data-ct-3d] .ct-tile {
|
|
115
|
+
--ahead: max(var(--i) - var(--ct-cam, 0), 0);
|
|
116
|
+
--past: min(var(--i) - var(--ct-cam, 0), 0);
|
|
117
|
+
--ct-fog: min(var(--ahead) * .14, .56);
|
|
118
|
+
position: absolute; left: 50%; top: var(--ct-top, 40%); width: min(100% - 2.4rem, 60rem); transform-origin: 50% 0;
|
|
119
|
+
transform: translate3d(-50%, calc(var(--past) * -45cqh - min(var(--ahead), 4) * var(--ct-row-rise)), calc(var(--past) * -240px - var(--ahead) * var(--ct-row-depth))) scale(var(--fit, 1));
|
|
120
|
+
opacity: calc(1 + var(--past) * 1.6);
|
|
121
|
+
pointer-events: auto;
|
|
122
|
+
}
|
|
123
|
+
.ct-app[data-ct-3d] .ct-tile[data-ct-past] { pointer-events: none; }
|
|
124
|
+
.ct-app[data-ct-3d] .ct-tile:not([data-ct-front]) { cursor: pointer; }
|
|
125
|
+
.ct-app[data-ct-3d] [data-ct-front] .ct-layer { transform: translate3d(calc(var(--k) * 3px), calc(var(--k) * 3px), calc((var(--n) - 1 - var(--k)) * var(--ct-lift))); }
|
|
126
|
+
.ct-app[data-ct-3d] [data-ct-front] :is(.ct-face, .ct-ring) { transform: translateZ(calc((var(--n) - 1) * var(--ct-lift))); }
|
|
127
|
+
.ct-plane[data-ct-still] :is(.ct-layer, .ct-face, .ct-ring, .ct-thumb) { transition: none; }
|
|
94
128
|
|
|
95
129
|
/* ---- stage: one block is a left-right track; each slide scrolls by pages ---- */
|
|
96
130
|
.ct-stage { position: absolute; inset: 0; background: var(--ct-bg); border: 1px solid var(--ct-border); transform-origin: 0 0; z-index: 2; overflow: hidden; outline: none; container-type: size; }
|
|
@@ -147,5 +181,5 @@
|
|
|
147
181
|
}
|
|
148
182
|
|
|
149
183
|
@media (prefers-reduced-motion: reduce) {
|
|
150
|
-
.ct-app :is(.ct-
|
|
184
|
+
.ct-app :is(.ct-layer, .ct-face, .ct-thumb, .ct-ring, .ct-rm, [data-ct-step]) { transition-duration: .01ms !important; }
|
|
151
185
|
}
|