ippon-slides 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -91,6 +91,7 @@ const app = new Hono()
91
91
  | `::bg=blue::` | 背景色。`blue` / `orange` / `purple` / `red` / `green` / `black` / `white`、または `#rrggbb` |
92
92
  | `::size=1.5::` | 文字サイズの倍率 |
93
93
  | `::right::` | ここから右カラム。先頭の見出しはカラムの上に出る |
94
+ | `::note::` | この行以降は発表者ノート。本番には出ず発表者ビュー(`p`)だけに出る |
94
95
  | `-# 注釈` | 小さい注釈 |
95
96
  | ` ```hcl ` | コード。`hcl` / `console` / `sh` / `json` / `js` / `ts` をハイライト |
96
97
  | `!! ` | コードの行頭に付けるとその行を強調 |
@@ -107,10 +108,18 @@ const app = new Hono()
107
108
  | `Home` / `End` | 最初 / 最後 |
108
109
  | `f` | フルスクリーン |
109
110
  | `o` / `Esc` | スライド一覧 |
111
+ | `p` | 発表者ビュー(別ウィンドウ) |
110
112
 
111
113
  表紙から進むと右下でタイマーが動き、表紙に戻るとリセットされます。
112
114
  URL の `#12` で 12 枚目から開けます。
113
115
 
116
+ ### 発表者ビュー
117
+
118
+ `p` を押すと別ウィンドウで発表者ビューが開きます。現在のスライド・次のスライド・
119
+ `::note::` で書いたノート・経過タイマー・時計が手元に出て、本番側(プロジェクタに映す画面)は
120
+ 通常表示のままです。どちらのウィンドウで `→` / `←` を押しても両画面が同期します。
121
+ 発表者ビューは `p` / `Esc` で閉じます。
122
+
114
123
  ## 見た目を変える
115
124
 
116
125
  `--css` で CSS 変数を上書きできます。
@@ -131,6 +140,19 @@ import { render, toPdf } from 'ippon-slides'
131
140
  const { html } = render(markdown, { baseDir: '.', outDir: 'dist' })
132
141
  ```
133
142
 
143
+ 型定義(`.d.ts`)も同梱しています。
144
+
145
+ ## 開発
146
+
147
+ TypeScript で書いています。`src/` が Node 側、`src/client/deck.ts` がブラウザで動くナビゲーションです。
148
+
149
+ ```sh
150
+ npm install
151
+ npm run build # dist/ と assets/deck.js を作る
152
+ npm run typecheck
153
+ npm test # ビルドしてからテスト(テストの実行には Node 22.18+ が必要)
154
+ ```
155
+
134
156
  ## License
135
157
 
136
158
  MIT
package/assets/deck.js CHANGED
@@ -1,211 +1,356 @@
1
+ "use strict";
1
2
  // スライドのナビゲーション。ippon が HTML に埋め込む。
2
3
  // → / Space / PageDown / クリック右側 次へ
3
4
  // ← / PageUp / クリック左側 前へ
4
5
  // Home / End 最初 / 最後
5
6
  // f フルスクリーン
6
7
  // o / Esc 一覧
7
- ;(function () {
8
- var slides = Array.prototype.slice.call(document.querySelectorAll('#deck .slide'))
9
- if (!slides.length) return
10
-
11
- var idx = 0
12
- var progress = document.getElementById('progress')
13
- var counter = document.getElementById('counter')
14
- var timerEl = document.getElementById('timer')
15
-
16
- function clamp(n) {
17
- return Math.max(0, Math.min(slides.length - 1, n))
18
- }
19
-
20
- // ---------- タイマー(表紙から進んだら開始、表紙に戻ったらリセット) ----------
21
-
22
- var startedAt = null
23
- var ticking = null
24
- function fmt(ms) {
25
- var s = Math.floor(ms / 1000)
26
- var m = Math.floor(s / 60)
27
- s = s % 60
28
- return m + ':' + (s < 10 ? '0' : '') + s
29
- }
30
- function syncTimer() {
31
- if (idx === 0) {
32
- startedAt = null
33
- clearInterval(ticking)
34
- ticking = null
35
- timerEl.textContent = ''
36
- return
8
+ // p 発表者ビュー(別ウィンドウ)
9
+ (() => {
10
+ const slides = Array.from(document.querySelectorAll('#deck .slide'));
11
+ if (!slides.length)
12
+ return;
13
+ const $ = (id) => document.getElementById(id);
14
+ const div = (className) => {
15
+ const el = document.createElement('div');
16
+ el.className = className;
17
+ return el;
18
+ };
19
+ function fmt(ms) {
20
+ const total = Math.floor(ms / 1000);
21
+ const m = Math.floor(total / 60);
22
+ const s = total % 60;
23
+ return m + ':' + (s < 10 ? '0' : '') + s;
37
24
  }
38
- if (startedAt === null) startedAt = Date.now()
39
- if (!ticking) {
40
- ticking = setInterval(function () {
41
- timerEl.textContent = fmt(Date.now() - startedAt)
42
- }, 500)
25
+ // 別ウィンドウで開いたときは発表者ビューとして動く(本番のナビは動かさない)
26
+ if (window.name === 'ippon-presenter') {
27
+ presenterMode();
28
+ return;
43
29
  }
44
- timerEl.textContent = fmt(Date.now() - startedAt)
45
- }
46
-
47
- // ---------- 描画 ----------
48
-
49
- function render() {
50
- slides.forEach(function (s, i) {
51
- s.classList.toggle('active', i === idx)
52
- })
53
- slides[idx].scrollTop = 0
54
- counter.textContent = idx + 1 + ' / ' + slides.length
55
- progress.style.width = ((idx + 1) / slides.length) * 100 + '%'
56
- syncTimer()
57
- }
58
- function go(n) {
59
- n = clamp(n)
60
- if (n === idx) return
61
- idx = n
62
- render()
63
- history.replaceState(null, '', '#' + (idx + 1))
64
- }
65
- window.addEventListener('hashchange', function () {
66
- var n = parseInt(location.hash.slice(1), 10)
67
- if (n) go(n - 1)
68
- })
69
-
70
- // ---------- 一覧 ----------
71
-
72
- var overview = document.getElementById('overview')
73
- var ovItems = null
74
- var ovOpen = false
75
- var THUMB = 240
76
-
77
- function buildOverview() {
78
- ovItems = slides.map(function (s, i) {
79
- var item = document.createElement('div')
80
- item.className = 'ov-item'
81
- var frame = document.createElement('div')
82
- frame.className = 'ov-frame'
83
- var scale = document.createElement('div')
84
- scale.className = 'ov-scale'
85
- var clone = s.cloneNode(true)
86
- clone.classList.remove('active')
87
- scale.appendChild(clone)
88
- frame.appendChild(scale)
89
- var num = document.createElement('div')
90
- num.className = 'ov-num'
91
- num.textContent = i + 1
92
- item.appendChild(frame)
93
- item.appendChild(num)
94
- item.addEventListener('click', function () {
95
- idx = i
96
- closeOverview()
97
- render()
98
- history.replaceState(null, '', '#' + (idx + 1))
99
- })
100
- overview.appendChild(item)
101
- return { item: item, frame: frame, scale: scale }
102
- })
103
- }
104
- function layoutOverview() {
105
- var w = window.innerWidth
106
- var h = window.innerHeight
107
- var k = THUMB / w
108
- ovItems.forEach(function (t) {
109
- t.frame.style.width = THUMB + 'px'
110
- t.frame.style.height = h * k + 'px'
111
- t.scale.style.width = w + 'px'
112
- t.scale.style.height = h + 'px'
113
- t.scale.style.transform = 'scale(' + k + ')'
114
- })
115
- }
116
- function openOverview() {
117
- if (!ovItems) buildOverview()
118
- layoutOverview()
119
- ovItems.forEach(function (t, i) {
120
- t.item.classList.toggle('current', i === idx)
121
- })
122
- ovOpen = true
123
- document.body.classList.add('overview')
124
- ovItems[idx].item.scrollIntoView({ block: 'center' })
125
- }
126
- function closeOverview() {
127
- ovOpen = false
128
- document.body.classList.remove('overview')
129
- }
130
- window.addEventListener('resize', function () {
131
- if (ovOpen) layoutOverview()
132
- })
133
-
134
- // ---------- 入力 ----------
135
-
136
- document.addEventListener('keydown', function (e) {
137
- if (e.metaKey || e.ctrlKey || e.altKey) return
138
- if (ovOpen) {
139
- if (e.key === 'Escape' || e.key === 'o') closeOverview()
140
- return
30
+ let idx = 0;
31
+ const progress = $('progress');
32
+ const counter = $('counter');
33
+ const timerEl = $('timer');
34
+ const clamp = (n) => Math.max(0, Math.min(slides.length - 1, n));
35
+ // ---------- タイマー(表紙から進んだら開始、表紙に戻ったらリセット) ----------
36
+ let startedAt = null;
37
+ let ticking;
38
+ function syncTimer() {
39
+ if (idx === 0) {
40
+ startedAt = null;
41
+ clearInterval(ticking);
42
+ ticking = undefined;
43
+ timerEl.textContent = '';
44
+ return;
45
+ }
46
+ const t0 = (startedAt ?? (startedAt = Date.now()));
47
+ ticking ?? (ticking = window.setInterval(() => {
48
+ timerEl.textContent = fmt(Date.now() - t0);
49
+ }, 500));
50
+ timerEl.textContent = fmt(Date.now() - t0);
141
51
  }
142
- switch (e.key) {
143
- case 'ArrowRight':
144
- case 'PageDown':
145
- case ' ':
146
- e.preventDefault()
147
- go(idx + 1)
148
- break
149
- case 'ArrowLeft':
150
- case 'PageUp':
151
- e.preventDefault()
152
- go(idx - 1)
153
- break
154
- case 'Home':
155
- go(0)
156
- break
157
- case 'End':
158
- go(slides.length - 1)
159
- break
160
- case 'f':
161
- if (document.fullscreenElement) document.exitFullscreen()
162
- else document.documentElement.requestFullscreen()
163
- break
164
- case 'o':
165
- openOverview()
166
- break
167
- case 'Escape':
168
- if (!document.fullscreenElement) openOverview()
169
- break
52
+ // ---------- 描画 ----------
53
+ function render() {
54
+ slides.forEach((s, i) => s.classList.toggle('active', i === idx));
55
+ slides[idx].scrollTop = 0;
56
+ counter.textContent = idx + 1 + ' / ' + slides.length;
57
+ progress.style.width = ((idx + 1) / slides.length) * 100 + '%';
58
+ syncTimer();
59
+ broadcast();
170
60
  }
171
- })
172
-
173
- // 画面の右 3 割で次へ、左 3 割で前へ(リンクと文字選択は邪魔しない)
174
- var deck = document.getElementById('deck')
175
- deck.addEventListener('click', function (e) {
176
- if (e.target.closest('a, button')) return
177
- if (String(window.getSelection() || '')) return
178
- var x = e.clientX / window.innerWidth
179
- if (x > 0.7) go(idx + 1)
180
- else if (x < 0.3) go(idx - 1)
181
- })
182
-
183
- // 横スワイプ
184
- var tx = null
185
- var ty = null
186
- deck.addEventListener(
187
- 'touchstart',
188
- function (e) {
189
- if (e.touches.length !== 1) return (tx = null)
190
- tx = e.touches[0].clientX
191
- ty = e.touches[0].clientY
192
- },
193
- { passive: true }
194
- )
195
- deck.addEventListener(
196
- 'touchend',
197
- function (e) {
198
- if (tx === null) return
199
- var t = e.changedTouches[0]
200
- var dx = t.clientX - tx
201
- var dy = t.clientY - ty
202
- tx = null
203
- if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5) go(dx < 0 ? idx + 1 : idx - 1)
204
- },
205
- { passive: true }
206
- )
207
-
208
- var start = parseInt(location.hash.slice(1), 10)
209
- if (start) idx = clamp(start - 1)
210
- render()
211
- })()
61
+ function go(n) {
62
+ n = clamp(n);
63
+ if (n === idx)
64
+ return;
65
+ idx = n;
66
+ render();
67
+ history.replaceState(null, '', '#' + (idx + 1));
68
+ }
69
+ window.addEventListener('hashchange', () => {
70
+ const n = parseInt(location.hash.slice(1), 10);
71
+ if (n)
72
+ go(n - 1);
73
+ });
74
+ // ---------- 発表者ビュー(別ウィンドウ)との同期 ----------
75
+ let presenterWin = null;
76
+ function openPresenter() {
77
+ if (presenterWin && !presenterWin.closed) {
78
+ presenterWin.focus();
79
+ return;
80
+ }
81
+ presenterWin = window.open(location.href, 'ippon-presenter', 'width=1024,height=700');
82
+ }
83
+ function broadcast() {
84
+ if (presenterWin && !presenterWin.closed)
85
+ presenterWin.postMessage({ ippon: 'state', idx, total: slides.length, startedAt }, '*');
86
+ }
87
+ window.addEventListener('message', (e) => {
88
+ const m = e.data;
89
+ if (!m || typeof m !== 'object')
90
+ return;
91
+ if (m.ippon === 'hello')
92
+ broadcast();
93
+ else if (m.ippon === 'go' && typeof m.n === 'number')
94
+ go(m.n);
95
+ });
96
+ const overview = $('overview');
97
+ let ovItems = null;
98
+ let ovOpen = false;
99
+ const THUMB = 240;
100
+ function buildOverview() {
101
+ return slides.map((s, i) => {
102
+ const item = div('ov-item');
103
+ const frame = div('ov-frame');
104
+ const scale = div('ov-scale');
105
+ const clone = s.cloneNode(true);
106
+ clone.classList.remove('active');
107
+ scale.appendChild(clone);
108
+ frame.appendChild(scale);
109
+ const num = div('ov-num');
110
+ num.textContent = String(i + 1);
111
+ item.appendChild(frame);
112
+ item.appendChild(num);
113
+ item.addEventListener('click', () => {
114
+ idx = i;
115
+ closeOverview();
116
+ render();
117
+ history.replaceState(null, '', '#' + (idx + 1));
118
+ });
119
+ overview.appendChild(item);
120
+ return { item, frame, scale };
121
+ });
122
+ }
123
+ function layoutOverview(items) {
124
+ const w = window.innerWidth;
125
+ const h = window.innerHeight;
126
+ const k = THUMB / w;
127
+ for (const t of items) {
128
+ t.frame.style.width = THUMB + 'px';
129
+ t.frame.style.height = h * k + 'px';
130
+ t.scale.style.width = w + 'px';
131
+ t.scale.style.height = h + 'px';
132
+ t.scale.style.transform = 'scale(' + k + ')';
133
+ }
134
+ }
135
+ function openOverview() {
136
+ const items = (ovItems ?? (ovItems = buildOverview()));
137
+ layoutOverview(items);
138
+ items.forEach((t, i) => t.item.classList.toggle('current', i === idx));
139
+ ovOpen = true;
140
+ document.body.classList.add('overview');
141
+ items[idx].item.scrollIntoView({ block: 'center' });
142
+ }
143
+ function closeOverview() {
144
+ ovOpen = false;
145
+ document.body.classList.remove('overview');
146
+ }
147
+ window.addEventListener('resize', () => {
148
+ if (ovOpen && ovItems)
149
+ layoutOverview(ovItems);
150
+ });
151
+ // ---------- 入力 ----------
152
+ document.addEventListener('keydown', (e) => {
153
+ if (e.metaKey || e.ctrlKey || e.altKey)
154
+ return;
155
+ if (ovOpen) {
156
+ if (e.key === 'Escape' || e.key === 'o')
157
+ closeOverview();
158
+ return;
159
+ }
160
+ switch (e.key) {
161
+ case 'ArrowRight':
162
+ case 'PageDown':
163
+ case ' ':
164
+ e.preventDefault();
165
+ go(idx + 1);
166
+ break;
167
+ case 'ArrowLeft':
168
+ case 'PageUp':
169
+ e.preventDefault();
170
+ go(idx - 1);
171
+ break;
172
+ case 'Home':
173
+ go(0);
174
+ break;
175
+ case 'End':
176
+ go(slides.length - 1);
177
+ break;
178
+ case 'f':
179
+ if (document.fullscreenElement)
180
+ document.exitFullscreen();
181
+ else
182
+ document.documentElement.requestFullscreen();
183
+ break;
184
+ case 'o':
185
+ openOverview();
186
+ break;
187
+ case 'p':
188
+ openPresenter();
189
+ break;
190
+ case 'Escape':
191
+ if (!document.fullscreenElement)
192
+ openOverview();
193
+ break;
194
+ }
195
+ });
196
+ // 画面の右 3 割で次へ、左 3 割で前へ(リンクと文字選択は邪魔しない)
197
+ const deck = $('deck');
198
+ deck.addEventListener('click', (e) => {
199
+ if (e.target instanceof Element && e.target.closest('a, button'))
200
+ return;
201
+ if (String(window.getSelection() || ''))
202
+ return;
203
+ const x = e.clientX / window.innerWidth;
204
+ if (x > 0.7)
205
+ go(idx + 1);
206
+ else if (x < 0.3)
207
+ go(idx - 1);
208
+ });
209
+ // 横スワイプ
210
+ let touch = null;
211
+ deck.addEventListener('touchstart', (e) => {
212
+ const t = e.touches[0];
213
+ touch = e.touches.length === 1 && t ? { x: t.clientX, y: t.clientY } : null;
214
+ }, { passive: true });
215
+ deck.addEventListener('touchend', (e) => {
216
+ const t = e.changedTouches[0];
217
+ if (!touch || !t)
218
+ return;
219
+ const dx = t.clientX - touch.x;
220
+ const dy = t.clientY - touch.y;
221
+ touch = null;
222
+ if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5)
223
+ go(dx < 0 ? idx + 1 : idx - 1);
224
+ }, { passive: true });
225
+ const start = parseInt(location.hash.slice(1), 10);
226
+ if (start)
227
+ idx = clamp(start - 1);
228
+ render();
229
+ // ---------- 発表者ビュー本体(別ウィンドウで動く) ----------
230
+ function presenterMode() {
231
+ document.body.classList.add('presenter');
232
+ const root = $('presenter');
233
+ const left = div('pv-left');
234
+ const right = div('pv-right');
235
+ root.appendChild(left);
236
+ root.appendChild(right);
237
+ const makeSlot = (labelText) => {
238
+ const slot = div('pv-slot');
239
+ const label = div('pv-label');
240
+ label.textContent = labelText;
241
+ const frame = div('pv-frame');
242
+ const scale = div('pv-scale');
243
+ frame.appendChild(scale);
244
+ slot.appendChild(label);
245
+ slot.appendChild(frame);
246
+ left.appendChild(slot);
247
+ return { label, frame, scale };
248
+ };
249
+ const cur = makeSlot('現在');
250
+ const nxt = makeSlot('次へ');
251
+ const clock = div('pv-clock');
252
+ const elapsedEl = div('pv-elapsed');
253
+ const wallEl = div('pv-wall');
254
+ clock.appendChild(elapsedEl);
255
+ clock.appendChild(wallEl);
256
+ const controls = div('pv-controls');
257
+ const prevBtn = document.createElement('button');
258
+ prevBtn.textContent = '◀';
259
+ const nextBtn = document.createElement('button');
260
+ nextBtn.textContent = '▶';
261
+ controls.appendChild(prevBtn);
262
+ controls.appendChild(nextBtn);
263
+ const notesEl = div('pv-notes');
264
+ right.appendChild(clock);
265
+ right.appendChild(controls);
266
+ right.appendChild(notesEl);
267
+ let curIdx = 0;
268
+ let total = slides.length;
269
+ let startedAt = null;
270
+ function fitPreview(frame, scale) {
271
+ const w = window.innerWidth;
272
+ const h = window.innerHeight;
273
+ const fw = frame.clientWidth;
274
+ const k = fw / w;
275
+ frame.style.height = fw * (h / w) + 'px';
276
+ scale.style.width = w + 'px';
277
+ scale.style.height = h + 'px';
278
+ scale.style.transform = 'scale(' + k + ')';
279
+ }
280
+ function setPreview(frame, scale, i) {
281
+ scale.textContent = '';
282
+ const s = slides[i];
283
+ if (s)
284
+ scale.appendChild(s.cloneNode(true));
285
+ fitPreview(frame, scale);
286
+ }
287
+ function draw() {
288
+ cur.label.textContent = '現在 ' + (curIdx + 1) + ' / ' + total;
289
+ setPreview(cur.frame, cur.scale, curIdx);
290
+ const hasNext = curIdx + 1 < total;
291
+ nxt.label.textContent = hasNext ? '次へ ' + (curIdx + 2) + ' / ' + total : 'おわり';
292
+ setPreview(nxt.frame, nxt.scale, hasNext ? curIdx + 1 : -1);
293
+ const note = slides[curIdx]?.querySelector('.notes');
294
+ notesEl.innerHTML = note ? note.innerHTML : '<p class="pv-empty">(ノートなし)</p>';
295
+ }
296
+ function tick() {
297
+ elapsedEl.textContent = startedAt == null ? '0:00' : fmt(Date.now() - startedAt);
298
+ wallEl.textContent = new Date().toLocaleTimeString();
299
+ }
300
+ const send = (n) => {
301
+ if (window.opener)
302
+ window.opener.postMessage({ ippon: 'go', n }, '*');
303
+ };
304
+ prevBtn.addEventListener('click', () => send(curIdx - 1));
305
+ nextBtn.addEventListener('click', () => send(curIdx + 1));
306
+ window.addEventListener('message', (e) => {
307
+ const m = e.data;
308
+ if (!m || m.ippon !== 'state')
309
+ return;
310
+ if (typeof m.idx === 'number')
311
+ curIdx = m.idx;
312
+ if (typeof m.total === 'number')
313
+ total = m.total;
314
+ startedAt = m.startedAt ?? null;
315
+ draw();
316
+ tick();
317
+ });
318
+ document.addEventListener('keydown', (e) => {
319
+ if (e.metaKey || e.ctrlKey || e.altKey)
320
+ return;
321
+ switch (e.key) {
322
+ case 'ArrowRight':
323
+ case 'PageDown':
324
+ case ' ':
325
+ e.preventDefault();
326
+ send(curIdx + 1);
327
+ break;
328
+ case 'ArrowLeft':
329
+ case 'PageUp':
330
+ e.preventDefault();
331
+ send(curIdx - 1);
332
+ break;
333
+ case 'Home':
334
+ send(0);
335
+ break;
336
+ case 'End':
337
+ send(total - 1);
338
+ break;
339
+ case 'p':
340
+ case 'Escape':
341
+ window.close();
342
+ break;
343
+ }
344
+ });
345
+ window.addEventListener('resize', () => {
346
+ fitPreview(cur.frame, cur.scale);
347
+ fitPreview(nxt.frame, nxt.scale);
348
+ });
349
+ window.setInterval(tick, 500);
350
+ // 本番ウィンドウに現在の状態を要求する
351
+ if (window.opener)
352
+ window.opener.postMessage({ ippon: 'hello' }, '*');
353
+ draw();
354
+ tick();
355
+ }
356
+ })();