ippon-slides 0.1.1 → 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 変数を上書きできます。
package/assets/deck.js CHANGED
@@ -5,11 +5,28 @@
5
5
  // Home / End 最初 / 最後
6
6
  // f フルスクリーン
7
7
  // o / Esc 一覧
8
+ // p 発表者ビュー(別ウィンドウ)
8
9
  (() => {
9
10
  const slides = Array.from(document.querySelectorAll('#deck .slide'));
10
11
  if (!slides.length)
11
12
  return;
12
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;
24
+ }
25
+ // 別ウィンドウで開いたときは発表者ビューとして動く(本番のナビは動かさない)
26
+ if (window.name === 'ippon-presenter') {
27
+ presenterMode();
28
+ return;
29
+ }
13
30
  let idx = 0;
14
31
  const progress = $('progress');
15
32
  const counter = $('counter');
@@ -18,12 +35,6 @@
18
35
  // ---------- タイマー(表紙から進んだら開始、表紙に戻ったらリセット) ----------
19
36
  let startedAt = null;
20
37
  let ticking;
21
- function fmt(ms) {
22
- const total = Math.floor(ms / 1000);
23
- const m = Math.floor(total / 60);
24
- const s = total % 60;
25
- return m + ':' + (s < 10 ? '0' : '') + s;
26
- }
27
38
  function syncTimer() {
28
39
  if (idx === 0) {
29
40
  startedAt = null;
@@ -45,6 +56,7 @@
45
56
  counter.textContent = idx + 1 + ' / ' + slides.length;
46
57
  progress.style.width = ((idx + 1) / slides.length) * 100 + '%';
47
58
  syncTimer();
59
+ broadcast();
48
60
  }
49
61
  function go(n) {
50
62
  n = clamp(n);
@@ -59,15 +71,32 @@
59
71
  if (n)
60
72
  go(n - 1);
61
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
+ });
62
96
  const overview = $('overview');
63
97
  let ovItems = null;
64
98
  let ovOpen = false;
65
99
  const THUMB = 240;
66
- const div = (className) => {
67
- const el = document.createElement('div');
68
- el.className = className;
69
- return el;
70
- };
71
100
  function buildOverview() {
72
101
  return slides.map((s, i) => {
73
102
  const item = div('ov-item');
@@ -155,6 +184,9 @@
155
184
  case 'o':
156
185
  openOverview();
157
186
  break;
187
+ case 'p':
188
+ openPresenter();
189
+ break;
158
190
  case 'Escape':
159
191
  if (!document.fullscreenElement)
160
192
  openOverview();
@@ -194,4 +226,131 @@
194
226
  if (start)
195
227
  idx = clamp(start - 1);
196
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
+ }
197
356
  })();
package/assets/style.css CHANGED
@@ -424,7 +424,8 @@ body.overview #deck,
424
424
  body.overview #progress,
425
425
  body.overview #counter,
426
426
  body.overview #timer,
427
- body.overview #hint {
427
+ body.overview #hint,
428
+ body.overview #presenter {
428
429
  display: none;
429
430
  }
430
431
  .ov-item {
@@ -466,6 +467,128 @@ body.overview #hint {
466
467
  font-weight: 600;
467
468
  }
468
469
 
470
+ /* ---------- presenter(p / 別ウィンドウ) ---------- */
471
+
472
+ /* 発表者ノートは本番・一覧・PDF には出さない(発表者ビューが innerHTML を別途読む) */
473
+ .notes {
474
+ display: none;
475
+ }
476
+
477
+ #presenter {
478
+ position: fixed;
479
+ inset: 0;
480
+ z-index: 30;
481
+ display: none;
482
+ box-sizing: border-box;
483
+ padding: 24px;
484
+ gap: 24px;
485
+ background: #1c1c1f;
486
+ color: #fff;
487
+ font-family: system-ui, -apple-system, 'Hiragino Sans', sans-serif;
488
+ }
489
+ body.presenter #presenter {
490
+ display: grid;
491
+ grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
492
+ }
493
+ body.presenter #deck,
494
+ body.presenter #progress,
495
+ body.presenter #counter,
496
+ body.presenter #timer,
497
+ body.presenter #hint,
498
+ body.presenter #overview {
499
+ display: none;
500
+ }
501
+
502
+ .pv-left {
503
+ display: flex;
504
+ flex-direction: column;
505
+ gap: 18px;
506
+ min-width: 0;
507
+ }
508
+ .pv-slot:last-child {
509
+ opacity: 0.85;
510
+ }
511
+ .pv-slot:last-child .pv-frame {
512
+ width: 62%;
513
+ }
514
+ .pv-label {
515
+ font-size: 0.85rem;
516
+ color: #9a9aa0;
517
+ margin-bottom: 6px;
518
+ font-variant-numeric: tabular-nums;
519
+ }
520
+ .pv-frame {
521
+ position: relative;
522
+ overflow: hidden;
523
+ border-radius: 10px;
524
+ border: 1px solid rgba(255, 255, 255, 0.14);
525
+ background: var(--slide-bg);
526
+ box-shadow: 0 2px 14px rgba(0, 0, 0, 0.4);
527
+ }
528
+ .pv-scale {
529
+ position: relative;
530
+ transform-origin: 0 0;
531
+ }
532
+ #presenter .slide {
533
+ display: flex;
534
+ animation: none;
535
+ overflow: hidden;
536
+ }
537
+
538
+ .pv-right {
539
+ display: flex;
540
+ flex-direction: column;
541
+ gap: 18px;
542
+ min-width: 0;
543
+ }
544
+ .pv-clock {
545
+ display: flex;
546
+ align-items: baseline;
547
+ gap: 16px;
548
+ }
549
+ .pv-elapsed {
550
+ font-size: 2.8rem;
551
+ font-weight: 700;
552
+ font-variant-numeric: tabular-nums;
553
+ line-height: 1;
554
+ }
555
+ .pv-wall {
556
+ font-size: 1rem;
557
+ color: #9a9aa0;
558
+ font-variant-numeric: tabular-nums;
559
+ }
560
+ .pv-controls {
561
+ display: flex;
562
+ gap: 10px;
563
+ }
564
+ .pv-controls button {
565
+ flex: 1;
566
+ padding: 10px 0;
567
+ font-size: 1.1rem;
568
+ color: #fff;
569
+ background: #2e2e33;
570
+ border: 1px solid rgba(255, 255, 255, 0.16);
571
+ border-radius: 8px;
572
+ cursor: pointer;
573
+ }
574
+ .pv-controls button:hover {
575
+ background: var(--slide-accent);
576
+ border-color: var(--slide-accent);
577
+ }
578
+ .pv-notes {
579
+ flex: 1;
580
+ overflow-y: auto;
581
+ padding: 16px 18px;
582
+ background: #242428;
583
+ border-radius: 10px;
584
+ border: 1px solid rgba(255, 255, 255, 0.1);
585
+ font-size: 1.05rem;
586
+ line-height: 1.7;
587
+ }
588
+ .pv-notes .pv-empty {
589
+ color: #76767c;
590
+ }
591
+
469
592
  /* ---------- PDF(ブラウザの印刷 → PDF に保存) ---------- */
470
593
 
471
594
  @media print {
@@ -511,7 +634,8 @@ body.overview #hint {
511
634
  #counter,
512
635
  #timer,
513
636
  #hint,
514
- #overview {
637
+ #overview,
638
+ #presenter {
515
639
  display: none !important;
516
640
  }
517
641
  }
package/dist/cli.js CHANGED
@@ -186,6 +186,11 @@ const app = new Hono()
186
186
 
187
187
  -# 行頭に "!! " を付けるとその行が強調される
188
188
 
189
+ ::note::
190
+
191
+ p キーで発表者ビューが開きます。
192
+ この ::note:: 以降は本番に出ず、手元のメモとして見えます。
193
+
189
194
  ---
190
195
 
191
196
  \`\`\`console
package/dist/markdown.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // --- スライドの区切り
4
4
  // ::center:: 1 行目に置くとスライドの指示(center / title / wide / bg=xxx / size=1.5)
5
5
  // ::right:: ここから右カラム
6
+ // ::note:: 発表者ノート(この行以降は本番に出ず発表者ビューだけに出る)
6
7
  // -# 注釈 小さい注釈
7
8
  // ```hcl コード(hcl / console / sh / json / js / ts)
8
9
  // !! 行 コードの行を強調(行頭の "!! " は消える)
@@ -165,6 +166,13 @@ export function slide(src) {
165
166
  const classes = ['slide'];
166
167
  let style = '';
167
168
  let body = src.replace(/^\s+/, '');
169
+ // 発表者ノート(::note:: 以降。本番スライドには出さない)
170
+ let notes = '';
171
+ const noteParts = body.split(/^::note::[ \t]*$/m);
172
+ if (noteParts.length > 1) {
173
+ body = noteParts[0];
174
+ notes = blocks(noteParts.slice(1).join('\n').trim());
175
+ }
168
176
  const d = body.match(/^::([^:]+)::[ \t]*(\n|$)/);
169
177
  const tokens = d ? d[1].trim().split(/\s+/) : [];
170
178
  if (d && tokens.every((t) => DIRECTIVES.test(t))) {
@@ -198,7 +206,7 @@ export function slide(src) {
198
206
  else {
199
207
  html = blocks(body);
200
208
  }
201
- return `<section class="${classes.join(' ')}"${style ? ` style="${style}"` : ''}>${html}</section>`;
209
+ return `<section class="${classes.join(' ')}"${style ? ` style="${style}"` : ''}>${html}${notes ? `<aside class="notes">${notes}</aside>` : ''}</section>`;
202
210
  }
203
211
  export function frontmatter(md) {
204
212
  const m = md.replace(/^\uFEFF/, '').match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
package/dist/render.js CHANGED
@@ -39,10 +39,11 @@ ${meta.description ? `<meta name="description" content="${esc(meta.description)}
39
39
  ${body}
40
40
  </div>
41
41
  <div id="overview"></div>
42
+ <div id="presenter"></div>
42
43
  <div id="progress"></div>
43
44
  <div id="timer"></div>
44
45
  <div id="counter"></div>
45
- <div id="hint">← → 移動 ・ f フルスクリーン ・ o 一覧</div>
46
+ <div id="hint">← → 移動 ・ f フルスクリーン ・ o 一覧 ・ p 発表者</div>
46
47
  <script>${script}</script>
47
48
  </body>
48
49
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ippon-slides",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "1 枚 1 メッセージのスライドを Markdown から作る CLI。HTML 1 ファイルと PDF を出力",
5
5
  "keywords": [
6
6
  "slides",