contour-deck 0.3.1 → 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 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
- | ![跨區塊移動:翻到區塊盡頭時拉遠再推進,位置列的圓點直接飛到另一個區塊](https://raw.githubusercontent.com/wayne930242/contour-deck/main/docs/media/move.gif) | ![3D 結構:輪廓傾斜、頁疊成層,仍可點進投影片](https://raw.githubusercontent.com/wayne930242/contour-deck/main/docs/media/structure.gif) |
27
- | 跨區塊移動:拉遠再推進,位置列的圓點直接飛到別的區塊 | 3D 結構:按 3,頁疊成層 |
26
+ | ![跨區塊移動:翻到區塊盡頭時拉遠再推進,位置列的圓點直接飛到另一個區塊](https://raw.githubusercontent.com/wayne930242/contour-deck/main/docs/media/move.gif) | ![縱深桌面:每個區塊一列往後排,上下鍵或滾輪把下一列推到面前,仍可點進投影片](https://raw.githubusercontent.com/wayne930242/contour-deck/main/docs/media/structure.gif) |
27
+ | 跨區塊移動:拉遠再推進,位置列的圓點直接飛到別的區塊 | 3D 縱深桌面:按 3,整份簡報一個畫面,上下換列 |
28
28
  | ![步驟:表格一列一列出現,步驟用完才換頁](https://raw.githubusercontent.com/wayne930242/contour-deck/main/docs/media/build.gif) | ![手機:講稿檢視、輪廓、舞台與往上滑的換頁](https://raw.githubusercontent.com/wayne930242/contour-deck/main/docs/media/phone.gif) |
29
29
  | 步驟:空白鍵讓表格一列一列出現 | 手機:講稿檢視與往上滑換頁 |
30
30
 
31
31
  | | |
32
32
  | --- | --- |
33
- | ![輪廓:七個區塊的縮圖牆](https://raw.githubusercontent.com/wayne930242/contour-deck/main/docs/media/overview.png) | ![3D 結構](https://raw.githubusercontent.com/wayne930242/contour-deck/main/docs/media/overview-3d.png) |
34
- | 輪廓 | 3D 結構 |
33
+ | ![輪廓:七個區塊的縮圖牆](https://raw.githubusercontent.com/wayne930242/contour-deck/main/docs/media/overview.png) | ![3D 縱深桌面](https://raw.githubusercontent.com/wayne930242/contour-deck/main/docs/media/overview-3d.png) |
34
+ | 輪廓(講過的投影片變淡) | 3D 縱深桌面 |
35
35
  | ![舞台旁的講稿面板](https://raw.githubusercontent.com/wayne930242/contour-deck/main/docs/media/notes.png) | ![淺色主題的舞台](https://raw.githubusercontent.com/wayne930242/contour-deck/main/docs/media/light.png) |
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 tilted 3D structure. |
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. |
@@ -116,7 +116,7 @@ deck.prev();
116
116
  deck.open("4.2"); // put a block on the stage
117
117
  deck.go("4.4.2"); // or deck.go("slide-id")
118
118
  deck.overview(); // back to the outline
119
- deck.slideBy(1); // the next slide, as the right arrow key; -1 for the one before
119
+ deck.slideBy(1); // the next slide, as the right arrow key once the page's steps are shown; -1 for the one before
120
120
  deck.pageBy(1); // one stop down inside the slide, as the down arrow key
121
121
  deck.state; // { mode, block, slide, page, step }; ordinals are 1-based
122
122
  deck.on("change", ({ mode, at, step }) => {});
@@ -201,13 +201,13 @@ The phone must be able to open the page's URL, so a deck on `localhost` needs a
201
201
  | --- | --- | --- |
202
202
  | `Space`, `Enter`, `PageDown` | advance one step | open the current slide |
203
203
  | `Shift+Space`, `PageUp`, `Backspace` | back one step | |
204
- | `←` `→` | previous, next slide | move between thumbnails |
205
- | `↑` `↓` | previous, next stop | move between thumbnails |
206
- | `Home`, `End` | first, last slide of the block | |
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; 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 structure |
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);
@@ -302,6 +308,15 @@ export function createDeck(root, options = {}) {
302
308
  slide.el.scrollBy({ top: below + 32, behavior: smooth() });
303
309
  render();
304
310
  }
311
+ /** The arrow keys: reveal or hide the current page's steps first, then move to the next or previous slide. */
312
+ function stepOrSlide(direction) {
313
+ readScroll();
314
+ const steps = pageAt().steps;
315
+ const pending = direction > 0 ? steps.some(item => !item.hasAttribute(ON)) : steps.some(item => item.hasAttribute(ON));
316
+ if (pending)
317
+ return direction > 0 ? next() : prev();
318
+ slideBy(direction);
319
+ }
305
320
  function prev() {
306
321
  if (busy || mode !== "stage")
307
322
  return;
@@ -319,8 +334,17 @@ export function createDeck(root, options = {}) {
319
334
  };
320
335
  const thumbRect = (at) => {
321
336
  const face = overview.faces[at.b][at.s];
322
- face.scrollIntoView({ block: "nearest" });
323
- return face.getBoundingClientRect();
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;
324
348
  };
325
349
  const cancel = (animations) => animations.forEach(animation => animation.cancel());
326
350
  /** Outline to stage: the stage grows out of the slide's thumbnail. */
@@ -365,9 +389,21 @@ export function createDeck(root, options = {}) {
365
389
  stage.style.opacity = "0";
366
390
  cancel(out);
367
391
  place(at, end);
368
- const thumb = thumbRect(pos);
369
- render();
370
- await wait(reduced() ? 80 : 400);
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
+ }
371
407
  if (!alive)
372
408
  return;
373
409
  const into = zoomStage(stage, root, thumb, "in", 460, reduced());
@@ -403,6 +439,8 @@ export function createDeck(root, options = {}) {
403
439
  if (mode === "stage")
404
440
  return close();
405
441
  mode = "overview";
442
+ if (depthOn())
443
+ front(pos.b, true);
406
444
  render();
407
445
  }
408
446
  function toScript() {
@@ -428,7 +466,62 @@ export function createDeck(root, options = {}) {
428
466
  }
429
467
  /* ---- input ---- */
430
468
  const press = (el, on) => el.setAttribute("aria-pressed", String(on));
431
- const toggle3d = () => press(threeDButton, app.toggleAttribute("data-ct-3d"));
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
+ }
432
525
  /** The stage changed size: put the current slide and page back in view. */
433
526
  function align(p = pos.p) {
434
527
  if (busy)
@@ -451,11 +544,20 @@ export function createDeck(root, options = {}) {
451
544
  const exit = () => busy || listeners.exit.forEach(listener => listener());
452
545
  const remote = () => options.remote?.(api);
453
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 });
454
550
  app.addEventListener("click", event => {
455
551
  const target = event.target;
456
552
  const action = target.closest("[data-ct-act]")?.dataset.ctAct;
457
553
  if (action)
458
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);
459
561
  const go = target.closest("[data-ct-go]")?.dataset.ctGo;
460
562
  if (!go)
461
563
  return;
@@ -464,7 +566,15 @@ export function createDeck(root, options = {}) {
464
566
  }, { signal });
465
567
  addEventListener("resize", () => {
466
568
  const p = pos.p;
467
- requestAnimationFrame(() => alive && align(p));
569
+ requestAnimationFrame(() => {
570
+ if (!alive)
571
+ return;
572
+ if (depthOn()) {
573
+ overview.depth(true);
574
+ front(aim, true);
575
+ }
576
+ align(p);
577
+ });
468
578
  }, { signal });
469
579
  // The browser drops the screen wake lock when the page is hidden; the notes view asks again on return.
470
580
  document.addEventListener("visibilitychange", () => {
@@ -476,9 +586,25 @@ export function createDeck(root, options = {}) {
476
586
  function focusThumb(step) {
477
587
  const all = overview.thumbs.flat();
478
588
  const focused = all.indexOf(document.activeElement);
479
- 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() });
480
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 });
481
606
  document.addEventListener("keydown", event => {
607
+ pressed = null;
482
608
  const target = event.target;
483
609
  if (event.metaKey || event.ctrlKey || event.altKey || busy || target.closest?.("input,textarea,select,[contenteditable]"))
484
610
  return;
@@ -489,10 +615,15 @@ export function createDeck(root, options = {}) {
489
615
  if (key === "r" && options.remote && mode !== "notes")
490
616
  run = remote;
491
617
  else if (mode === "overview") {
618
+ const rows = depthOn();
492
619
  if (key === "3")
493
620
  run = toggle3d;
494
621
  else if (key === "Escape")
495
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);
496
627
  else if (key.startsWith("Arrow"))
497
628
  run = () => focusThumb(key === "ArrowRight" || key === "ArrowDown" ? 1 : -1);
498
629
  else if (key === "PageDown" || (activates && !onControl))
@@ -509,8 +640,8 @@ export function createDeck(root, options = {}) {
509
640
  PageDown: next,
510
641
  PageUp: prev,
511
642
  Backspace: prev,
512
- ArrowRight: () => slideBy(1),
513
- ArrowLeft: () => slideBy(-1),
643
+ ArrowRight: () => stepOrSlide(1),
644
+ ArrowLeft: () => stepOrSlide(-1),
514
645
  ArrowDown: () => stepV(1),
515
646
  ArrowUp: () => stepV(-1),
516
647
  Escape: toOverview,
@@ -538,6 +669,16 @@ export function createDeck(root, options = {}) {
538
669
  stepV(step);
539
670
  }, { passive: false, signal });
540
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 });
541
682
  /* ---- start ---- */
542
683
  const params = new URLSearchParams(useUrl ? location.search : "");
543
684
  const startAt = resolve(options.at ?? params.get("at") ?? "");
@@ -560,6 +701,7 @@ export function createDeck(root, options = {}) {
560
701
  alive = false;
561
702
  listeners.destroy.forEach(listener => listener());
562
703
  abort.abort();
704
+ stopMove();
563
705
  cancelAnimationFrame(frame);
564
706
  clearTimeout(idle);
565
707
  setCurrent(null);
@@ -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;
@@ -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
- const plane = el.appendChild(h("div", "ct-plane"));
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
- return {
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
- const { x, y } = offsetIn(face, plane);
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
  }
@@ -24,6 +24,16 @@ const KEYS = {
24
24
  Escape: "overview",
25
25
  o: "overview",
26
26
  };
27
+ /** The arrow keys reveal or hide the current page's steps before they move to another slide, as on the deck. */
28
+ function arrowStep(key, state) {
29
+ if (!state || state.mode !== "stage")
30
+ return undefined;
31
+ if (key === "ArrowRight" && state.step < state.steps)
32
+ return "next";
33
+ if (key === "ArrowLeft" && state.step > 0)
34
+ return "prev";
35
+ return undefined;
36
+ }
27
37
  export function presenterView(root, options = {}) {
28
38
  injectStyle(options.nonce);
29
39
  const labels = { ...defaultRemoteLabels, ...options.labels };
@@ -94,7 +104,7 @@ export function presenterView(root, options = {}) {
94
104
  const onButton = !!event.target.closest?.("button,a");
95
105
  if (onButton && (key === " " || key === "Enter"))
96
106
  return;
97
- const command = key === " " && event.shiftKey ? "prev" : KEYS[key];
107
+ const command = key === " " && event.shiftKey ? "prev" : arrowStep(key, guest.state) ?? KEYS[key];
98
108
  if (!command)
99
109
  return;
100
110
  event.preventDefault();
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
- overflow: hidden;
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
- transform: translateY(-4%) rotateX(52deg) rotateZ(-24deg) scale(.86);
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-layer {
393
- transform: translateZ(calc((var(--n) - 1 - var(--k)) * var(--ct-lift)));
468
+ .ct-app[data-ct-3d] .ct-tile:not([data-ct-front]) {
469
+ cursor: pointer;
394
470
  }
395
- .ct-app[data-ct-3d] .ct-face {
396
- transform: translateZ(calc((var(--n) - 1) * var(--ct-lift)));
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] .ct-thumb:is(:hover, :focus-visible) {
399
- transform: translateZ(22px);
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-app[data-ct-3d] .ct-ring {
402
- transform: translateZ(calc((var(--n) - 1) * var(--ct-lift) + 1px));
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-plane, .ct-layer, .ct-face, .ct-thumb, .ct-ring, .ct-rm, [data-ct-step]) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contour-deck",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Outline-first slide decks on real DOM: blocks, slides, pages.",
5
5
  "keywords": [
6
6
  "slides",
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; transition: transform .7s var(--ct-ease); }
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
- /* 3D structure: the plane tilts and every slide becomes a stack as high as its page count */
88
- .ct-app[data-ct-3d] .ct-ov { overflow: hidden; }
89
- .ct-app[data-ct-3d] .ct-plane { transform: translateY(-4%) rotateX(52deg) rotateZ(-24deg) scale(.86); }
90
- .ct-app[data-ct-3d] .ct-layer { transform: translateZ(calc((var(--n) - 1 - var(--k)) * var(--ct-lift))); }
91
- .ct-app[data-ct-3d] .ct-face { transform: translateZ(calc((var(--n) - 1) * var(--ct-lift))); }
92
- .ct-app[data-ct-3d] .ct-thumb:is(:hover, :focus-visible) { transform: translateZ(22px); }
93
- .ct-app[data-ct-3d] .ct-ring { transform: translateZ(calc((var(--n) - 1) * var(--ct-lift) + 1px)); }
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-plane, .ct-layer, .ct-face, .ct-thumb, .ct-ring, .ct-rm, [data-ct-step]) { transition-duration: .01ms !important; }
184
+ .ct-app :is(.ct-layer, .ct-face, .ct-thumb, .ct-ring, .ct-rm, [data-ct-step]) { transition-duration: .01ms !important; }
151
185
  }