mdv-live 0.5.4 → 0.5.8

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.
@@ -0,0 +1,687 @@
1
+ <!DOCTYPE html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Presenter View - MDV</title>
6
+ <style>
7
+ :root {
8
+ color-scheme: dark;
9
+ --bg: #0e0e10;
10
+ --panel: #1a1a1d;
11
+ --border: #2a2a2e;
12
+ --text: #e8e8ea;
13
+ --muted: #9a9aa2;
14
+ --accent: #4f8cff;
15
+ --col-left: 1.6fr;
16
+ --col-right: 1fr;
17
+ --row-top: 1fr;
18
+ --row-bottom: 1fr;
19
+ --gutter: 6px;
20
+ }
21
+ * { box-sizing: border-box; }
22
+ html, body {
23
+ margin: 0;
24
+ padding: 0;
25
+ height: 100%;
26
+ background: var(--bg);
27
+ color: var(--text);
28
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", "Yu Gothic", sans-serif;
29
+ overflow: hidden;
30
+ }
31
+
32
+ .topbar {
33
+ display: flex;
34
+ align-items: center;
35
+ gap: 16px;
36
+ padding: 8px 16px;
37
+ background: var(--panel);
38
+ border-bottom: 1px solid var(--border);
39
+ height: 48px;
40
+ }
41
+ .topbar .title { font-weight: 600; }
42
+ .topbar .spacer { flex: 1; }
43
+ .topbar .timer {
44
+ font-variant-numeric: tabular-nums;
45
+ font-size: 18px;
46
+ color: var(--accent);
47
+ }
48
+ .topbar .counter { color: var(--muted); }
49
+ .topbar .save-status {
50
+ font-size: 12px;
51
+ color: var(--muted);
52
+ min-width: 60px;
53
+ text-align: right;
54
+ }
55
+ .topbar .save-status.ok { color: #5fc97f; }
56
+ .topbar .save-status.err { color: #ff6b6b; }
57
+ .topbar button {
58
+ background: transparent;
59
+ color: var(--text);
60
+ border: 1px solid var(--border);
61
+ border-radius: 4px;
62
+ padding: 4px 10px;
63
+ cursor: pointer;
64
+ font-size: 13px;
65
+ }
66
+ .topbar button:hover { background: var(--border); }
67
+
68
+ /* Layout: two columns, the right column splits into two rows.
69
+ Custom drag handles let the user resize columns and the right-column rows. */
70
+ .layout {
71
+ display: grid;
72
+ grid-template-columns: var(--col-left) var(--gutter) var(--col-right);
73
+ grid-template-rows: 1fr;
74
+ gap: 0;
75
+ padding: 12px;
76
+ height: calc(100% - 48px);
77
+ }
78
+ .right-col {
79
+ display: grid;
80
+ grid-template-rows: var(--row-top) var(--gutter) var(--row-bottom);
81
+ min-width: 0;
82
+ min-height: 0;
83
+ }
84
+ .panel {
85
+ background: var(--panel);
86
+ border: 1px solid var(--border);
87
+ border-radius: 8px;
88
+ overflow: hidden;
89
+ display: flex;
90
+ flex-direction: column;
91
+ min-height: 0;
92
+ min-width: 0;
93
+ }
94
+ .panel-label {
95
+ padding: 6px 12px;
96
+ font-size: 12px;
97
+ color: var(--muted);
98
+ border-bottom: 1px solid var(--border);
99
+ text-transform: uppercase;
100
+ letter-spacing: 0.05em;
101
+ user-select: none;
102
+ }
103
+ .panel-body {
104
+ flex: 1;
105
+ min-height: 0;
106
+ overflow: hidden;
107
+ position: relative;
108
+ }
109
+
110
+ /* Drag gutters between panels. */
111
+ .gutter {
112
+ background: transparent;
113
+ position: relative;
114
+ }
115
+ .gutter::after {
116
+ content: '';
117
+ position: absolute;
118
+ inset: 0;
119
+ margin: auto;
120
+ background: var(--border);
121
+ border-radius: 2px;
122
+ transition: background 120ms;
123
+ }
124
+ .gutter.col {
125
+ cursor: col-resize;
126
+ grid-column: 2;
127
+ grid-row: 1;
128
+ }
129
+ .gutter.col::after { width: 2px; }
130
+ .gutter.row {
131
+ cursor: row-resize;
132
+ grid-row: 2;
133
+ }
134
+ .gutter.row::after { height: 2px; }
135
+ .gutter:hover::after { background: var(--accent); }
136
+ .gutter.dragging::after { background: var(--accent); }
137
+
138
+ .slide-stage {
139
+ width: 100%;
140
+ height: 100%;
141
+ display: flex;
142
+ align-items: center;
143
+ justify-content: center;
144
+ background: #000;
145
+ }
146
+ .slide-stage .marpit {
147
+ width: 100%;
148
+ height: 100%;
149
+ display: flex;
150
+ align-items: center;
151
+ justify-content: center;
152
+ }
153
+ .slide-stage .marpit > svg[data-marpit-svg] {
154
+ display: none;
155
+ max-width: 100%;
156
+ max-height: 100%;
157
+ width: auto;
158
+ height: auto;
159
+ }
160
+ .slide-stage .marpit > svg[data-marpit-svg].active {
161
+ display: block;
162
+ }
163
+
164
+ .notes-body {
165
+ padding: 16px 20px;
166
+ overflow: auto;
167
+ font-size: 18px;
168
+ line-height: 1.7;
169
+ white-space: pre-wrap;
170
+ height: 100%;
171
+ outline: none;
172
+ color: var(--text);
173
+ caret-color: var(--accent);
174
+ }
175
+ .notes-body:focus {
176
+ background: rgba(79, 140, 255, 0.04);
177
+ }
178
+ /* CSS pseudo-element placeholder — never enters textContent, so editing
179
+ can never accidentally save the placeholder string as a real note. */
180
+ .notes-body[data-placeholder]:empty::before {
181
+ content: attr(data-placeholder);
182
+ color: var(--muted);
183
+ font-style: italic;
184
+ pointer-events: none;
185
+ }
186
+
187
+ .empty-state {
188
+ display: flex;
189
+ align-items: center;
190
+ justify-content: center;
191
+ height: 100%;
192
+ color: var(--muted);
193
+ flex-direction: column;
194
+ gap: 12px;
195
+ padding: 24px;
196
+ text-align: center;
197
+ }
198
+ .empty-state code {
199
+ background: var(--border);
200
+ padding: 2px 6px;
201
+ border-radius: 3px;
202
+ }
203
+ </style>
204
+ </head>
205
+ <body>
206
+ <div class="topbar">
207
+ <span class="title">Presenter View</span>
208
+ <span class="counter" id="counter">– / –</span>
209
+ <span class="save-status" id="saveStatus"></span>
210
+ <span class="spacer"></span>
211
+ <span class="timer" id="timer">00:00</span>
212
+ <button id="resetTimer" title="Reset timer">Reset</button>
213
+ <button id="prevBtn" title="Previous (←)">◀</button>
214
+ <button id="nextBtn" title="Next (→)">▶</button>
215
+ </div>
216
+
217
+ <div class="layout" id="layout">
218
+ <section class="panel slide-current">
219
+ <div class="panel-label">Current</div>
220
+ <div class="panel-body">
221
+ <div class="slide-stage" id="stageCurrent">
222
+ <div class="empty-state">
223
+ メイン画面で Marp プレビューを開いて <code>P</code> を押してください。
224
+ </div>
225
+ </div>
226
+ </div>
227
+ </section>
228
+
229
+ <div class="gutter col" id="gutterCol" title="左右の幅を調整"></div>
230
+
231
+ <div class="right-col">
232
+ <section class="panel slide-next">
233
+ <div class="panel-label">Next</div>
234
+ <div class="panel-body">
235
+ <div class="slide-stage" id="stageNext"></div>
236
+ </div>
237
+ </section>
238
+
239
+ <div class="gutter row" id="gutterRow" title="上下の比率を調整"></div>
240
+
241
+ <section class="panel notes">
242
+ <div class="panel-label" id="notesLabel">Speaker Notes <span style="text-transform:none; opacity:0.7;">(クリックで編集)</span></div>
243
+ <div class="panel-body">
244
+ <div class="notes-banner" id="notesBanner" style="display:none; padding:10px 16px; background:rgba(232, 152, 50, 0.12); color:#e89832; font-size:13px; border-bottom:1px solid var(--border);"></div>
245
+ <div class="notes-body" id="notesBody" contenteditable="true" spellcheck="false" data-placeholder="(ノートなし)"></div>
246
+ </div>
247
+ </section>
248
+ </div>
249
+ </div>
250
+
251
+ <script src="/static/lib/presenterChannel.js"></script>
252
+ <script>
253
+ (function () {
254
+ const channel = (window.MDVPresenterChannel && window.MDVPresenterChannel.create()) || null;
255
+ if (!channel) {
256
+ document.querySelector('.empty-state').textContent =
257
+ 'このブラウザは BroadcastChannel に未対応です。';
258
+ return;
259
+ }
260
+
261
+ const stageCurrent = document.getElementById('stageCurrent');
262
+ const stageNext = document.getElementById('stageNext');
263
+ const notesBody = document.getElementById('notesBody');
264
+ const counter = document.getElementById('counter');
265
+ const prevBtn = document.getElementById('prevBtn');
266
+ const nextBtn = document.getElementById('nextBtn');
267
+ const timerEl = document.getElementById('timer');
268
+ const resetBtn = document.getElementById('resetTimer');
269
+ const saveStatus = document.getElementById('saveStatus');
270
+ const layout = document.getElementById('layout');
271
+
272
+ const STORAGE_KEY_LAYOUT = 'mdv-presenter-layout';
273
+ const STORAGE_KEY_BACKUP = 'mdv-presenter-stale-backup';
274
+
275
+ let slidesHtml = '';
276
+ let cssText = '';
277
+ let notes = [];
278
+ let notesMultiplicity = [];
279
+ let deckEtag = null;
280
+ let slideCount = 0;
281
+ let currentIndex = 0;
282
+ let cssEl = null;
283
+ let editing = false;
284
+ let editingSlideIndex = -1;
285
+ let editingPath = '';
286
+ let editingEtag = null;
287
+ let deckPath = '';
288
+ let saveTimer = null;
289
+
290
+ const notesBanner = document.getElementById('notesBanner');
291
+
292
+ function applyCss(css) {
293
+ if (cssEl) cssEl.remove();
294
+ cssEl = document.createElement('style');
295
+ cssEl.textContent = css || '';
296
+ document.head.appendChild(cssEl);
297
+ }
298
+
299
+ function fillStage(stage, html) {
300
+ stage.innerHTML = html;
301
+ }
302
+
303
+ function showSlideAt(stage, index) {
304
+ const svgs = stage.querySelectorAll('.marpit > svg[data-marpit-svg]');
305
+ svgs.forEach((s, i) => s.classList.toggle('active', i === index));
306
+ }
307
+
308
+ function setNotesText(text) {
309
+ // Empty text is fine: the CSS :empty::before placeholder takes over.
310
+ notesBody.textContent = text || '';
311
+ }
312
+
313
+ // contenteditable inserts <div>/<br> nodes for line breaks, and
314
+ // textContent flattens those without separators. Walk the DOM and emit
315
+ // \n at block boundaries so two-line edits arrive as `line1\nline2`.
316
+ function readEditableText(el) {
317
+ let out = '';
318
+ function walk(node) {
319
+ if (node.nodeType === Node.TEXT_NODE) {
320
+ out += node.textContent;
321
+ return;
322
+ }
323
+ if (node.nodeType !== Node.ELEMENT_NODE) return;
324
+ const tag = node.tagName;
325
+ if (tag === 'BR') { out += '\n'; return; }
326
+ const isBlock = tag === 'DIV' || tag === 'P' || tag === 'LI';
327
+ if (isBlock && out && !out.endsWith('\n')) out += '\n';
328
+ for (const child of node.childNodes) walk(child);
329
+ if (isBlock && !out.endsWith('\n')) out += '\n';
330
+ }
331
+ for (const child of el.childNodes) walk(child);
332
+ return out.replace(/\n+$/, '');
333
+ }
334
+
335
+ function getNotesText() {
336
+ return readEditableText(notesBody);
337
+ }
338
+
339
+ function isReadOnlyForCurrent() {
340
+ if (!deckEtag) return true; // GET degrade or no etag
341
+ const m = notesMultiplicity[currentIndex];
342
+ if (typeof m === 'number' && m > 1) return true;
343
+ return false;
344
+ }
345
+
346
+ function applyReadOnlyState() {
347
+ const ro = isReadOnlyForCurrent();
348
+ notesBody.contentEditable = ro ? 'false' : 'true';
349
+ if (ro) {
350
+ let msg = '';
351
+ if (!deckEtag) msg = 'このファイルは現在解析できないため自動保存は無効です。';
352
+ else if (notesMultiplicity[currentIndex] > 1) {
353
+ msg = 'このスライドは複数のノートを含むため自動保存を無効化しています(markdown editor で直接編集してください)。';
354
+ }
355
+ notesBanner.textContent = msg;
356
+ notesBanner.style.display = 'block';
357
+ } else {
358
+ notesBanner.style.display = 'none';
359
+ }
360
+ }
361
+
362
+ function render(index) {
363
+ if (!slideCount) return;
364
+ currentIndex = Math.max(0, Math.min(slideCount - 1, index));
365
+
366
+ showSlideAt(stageCurrent, currentIndex);
367
+ if (currentIndex + 1 < slideCount) {
368
+ showSlideAt(stageNext, currentIndex + 1);
369
+ } else {
370
+ const svgs = stageNext.querySelectorAll('.marpit > svg[data-marpit-svg]');
371
+ svgs.forEach((s) => s.classList.remove('active'));
372
+ }
373
+
374
+ counter.textContent = `${currentIndex + 1} / ${slideCount}`;
375
+ if (!editing) {
376
+ const note = (notes[currentIndex] || '').trim();
377
+ setNotesText(note);
378
+ }
379
+ applyReadOnlyState();
380
+
381
+ prevBtn.disabled = currentIndex === 0;
382
+ nextBtn.disabled = currentIndex === slideCount - 1;
383
+ }
384
+
385
+ function loadSlides(payload) {
386
+ if (payload.empty) {
387
+ // Main window switched to a non-Marp tab or closed the deck — clear
388
+ // everything so the user can't keep editing notes that would land in
389
+ // the wrong file. saveQueue retains nothing because path mismatches.
390
+ slidesHtml = '';
391
+ cssText = '';
392
+ applyCss('');
393
+ fillStage(stageCurrent, '<div class="empty-state">メイン画面で Marp ファイルを開いてください。</div>');
394
+ fillStage(stageNext, '');
395
+ notes = [];
396
+ notesMultiplicity = [];
397
+ deckEtag = null;
398
+ deckPath = '';
399
+ slideCount = 0;
400
+ counter.textContent = '– / –';
401
+ setNotesText('');
402
+ applyReadOnlyState();
403
+ return;
404
+ }
405
+ const html = payload.html || '';
406
+ const css = payload.css || '';
407
+ const changed = (html !== slidesHtml) || (css !== cssText);
408
+
409
+ if (changed) {
410
+ slidesHtml = html;
411
+ cssText = css;
412
+ applyCss(css);
413
+ fillStage(stageCurrent, html);
414
+ fillStage(stageNext, html);
415
+ }
416
+ notes = Array.isArray(payload.notes) ? payload.notes : [];
417
+ notesMultiplicity = Array.isArray(payload.notesMultiplicity) ? payload.notesMultiplicity : [];
418
+ deckEtag = payload.etag || null;
419
+ deckPath = payload.path || deckPath;
420
+ slideCount = stageCurrent.querySelectorAll('.marpit > svg[data-marpit-svg]').length;
421
+ render(typeof payload.current === 'number' ? payload.current : currentIndex);
422
+ }
423
+
424
+ function gotoIndex(index) {
425
+ // Commit any pending edit before navigating away.
426
+ flushPendingNoteSave();
427
+ render(index);
428
+ channel.postMessage({ type: 'goto', index: currentIndex });
429
+ }
430
+
431
+ function setSaveStatus(text, kind) {
432
+ saveStatus.textContent = text || '';
433
+ saveStatus.classList.remove('ok', 'err');
434
+ if (kind) saveStatus.classList.add(kind);
435
+ }
436
+
437
+ function scheduleNoteSave() {
438
+ setSaveStatus('編集中…');
439
+ if (saveTimer) clearTimeout(saveTimer);
440
+ saveTimer = setTimeout(() => {
441
+ saveTimer = null;
442
+ sendNoteSave();
443
+ }, 800);
444
+ }
445
+
446
+ function flushPendingNoteSave() {
447
+ if (saveTimer) {
448
+ clearTimeout(saveTimer);
449
+ saveTimer = null;
450
+ sendNoteSave();
451
+ }
452
+ }
453
+
454
+ function sendNoteSave() {
455
+ // Use the slide / deck / etag captured when the user started editing —
456
+ // not the live values, which can change under us if the main window
457
+ // navigates, switches tabs, or receives a watcher update during the
458
+ // debounce. Sending the live etag would let a watcher-refreshed etag
459
+ // pass If-Match against the post-edit deck.
460
+ const idx = editingSlideIndex >= 0 ? editingSlideIndex : currentIndex;
461
+ const path = editingPath || deckPath;
462
+ const etag = editingEtag || deckEtag;
463
+ const value = getNotesText();
464
+ notes[idx] = value.trim();
465
+ setSaveStatus('保存中…');
466
+ channel.postMessage({
467
+ type: 'edit-note',
468
+ path,
469
+ etag,
470
+ slideIndex: idx,
471
+ note: value
472
+ });
473
+ }
474
+
475
+ // Notes editing
476
+ notesBody.addEventListener('focus', () => {
477
+ if (isReadOnlyForCurrent()) return;
478
+ editing = true;
479
+ editingSlideIndex = currentIndex;
480
+ editingPath = deckPath;
481
+ editingEtag = deckEtag; // pin the etag at edit start
482
+ });
483
+ notesBody.addEventListener('blur', () => {
484
+ editing = false;
485
+ flushPendingNoteSave();
486
+ editingSlideIndex = -1;
487
+ editingPath = '';
488
+ editingEtag = null;
489
+ // CSS :empty::before takes over once the user leaves an empty field;
490
+ // no manual placeholder restore needed.
491
+ });
492
+ notesBody.addEventListener('input', () => {
493
+ if (editingSlideIndex < 0) editingSlideIndex = currentIndex;
494
+ if (!editingPath) editingPath = deckPath;
495
+ if (!editingEtag) editingEtag = deckEtag;
496
+ notes[editingSlideIndex] = readEditableText(notesBody);
497
+ scheduleNoteSave();
498
+ });
499
+ notesBody.addEventListener('keydown', (e) => {
500
+ // Don't let presenter shortcuts hijack typing.
501
+ e.stopPropagation();
502
+ });
503
+
504
+ prevBtn.addEventListener('click', () => gotoIndex(currentIndex - 1));
505
+ nextBtn.addEventListener('click', () => gotoIndex(currentIndex + 1));
506
+
507
+ document.addEventListener('keydown', (e) => {
508
+ // Skip when typing in the notes panel.
509
+ if (e.target === notesBody) return;
510
+ if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'PageDown') {
511
+ e.preventDefault();
512
+ gotoIndex(currentIndex + 1);
513
+ } else if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
514
+ e.preventDefault();
515
+ gotoIndex(currentIndex - 1);
516
+ } else if (e.key === 'Home') {
517
+ e.preventDefault();
518
+ gotoIndex(0);
519
+ } else if (e.key === 'End') {
520
+ e.preventDefault();
521
+ gotoIndex(slideCount - 1);
522
+ }
523
+ });
524
+
525
+ channel.addEventListener('message', (e) => {
526
+ const msg = e.data || {};
527
+ if (msg.type === 'slides') {
528
+ loadSlides(msg);
529
+ // Do NOT refresh editingEtag here — `slides` can also originate from
530
+ // a watcher-detected external edit, and adopting that etag would let
531
+ // our debounced write overwrite the external change. editingEtag is
532
+ // only refreshed in `note-saved` handler below, where we can prove
533
+ // the new etag came from our own successful save.
534
+ } else if (msg.type === 'index') {
535
+ render(msg.index);
536
+ } else if (msg.type === 'note-saved') {
537
+ const targetIdx = editingSlideIndex >= 0 ? editingSlideIndex : currentIndex;
538
+ if (msg.slideIndex !== targetIdx) return;
539
+ // On our OWN successful save, advance editingEtag to the post-save
540
+ // etag so the next autosave in this focus session is not stale.
541
+ // External-edit broadcasts deliberately do NOT update editingEtag.
542
+ if (msg.ok && editing && msg.etag) editingEtag = msg.etag;
543
+
544
+ // STALE: back up the in-progress text to localStorage so the user can
545
+ // recover after reloading the deck.
546
+ const isStale = !msg.ok && msg.reason && msg.reason.indexOf('STALE') === 0;
547
+ if (isStale) {
548
+ try {
549
+ const draft = readEditableText(notesBody);
550
+ if (draft) {
551
+ const key = STORAGE_KEY_BACKUP + ':' + (editingPath || deckPath) + '#' + targetIdx;
552
+ localStorage.setItem(key, draft);
553
+ }
554
+ } catch (e) { /* ignore */ }
555
+ }
556
+
557
+ const text = msg.ok ? '保存済み' : (msg.reason ? '保存失敗: ' + msg.reason : '保存失敗');
558
+ setSaveStatus(text, msg.ok ? 'ok' : 'err');
559
+ // STALE messages stay until the user dismisses (no auto-clear) so they
560
+ // notice the conflict; success/other failures auto-dismiss.
561
+ if (!isStale) {
562
+ const dismissDelay = msg.ok ? 1800 : 5000;
563
+ setTimeout(() => {
564
+ if (saveStatus.textContent === text) setSaveStatus('');
565
+ }, dismissDelay);
566
+ }
567
+ }
568
+ });
569
+
570
+ // Timer
571
+ let startedAt = Date.now();
572
+ function tick() {
573
+ const elapsed = Math.floor((Date.now() - startedAt) / 1000);
574
+ const m = String(Math.floor(elapsed / 60)).padStart(2, '0');
575
+ const s = String(elapsed % 60).padStart(2, '0');
576
+ timerEl.textContent = `${m}:${s}`;
577
+ }
578
+ setInterval(tick, 500);
579
+ tick();
580
+ resetBtn.addEventListener('click', () => {
581
+ startedAt = Date.now();
582
+ tick();
583
+ });
584
+
585
+ // ----- Resizable panels -----
586
+ function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
587
+
588
+ function persistLayout() {
589
+ const data = {
590
+ colLeft: layout.style.getPropertyValue('--col-left') || '',
591
+ colRight: layout.style.getPropertyValue('--col-right') || '',
592
+ rowTop: layout.style.getPropertyValue('--row-top') || '',
593
+ rowBottom: layout.style.getPropertyValue('--row-bottom') || ''
594
+ };
595
+ try { localStorage.setItem(STORAGE_KEY_LAYOUT, JSON.stringify(data)); } catch (e) {}
596
+ }
597
+ function restoreLayout() {
598
+ try {
599
+ const data = JSON.parse(localStorage.getItem(STORAGE_KEY_LAYOUT) || '{}');
600
+ if (data.colLeft) layout.style.setProperty('--col-left', data.colLeft);
601
+ if (data.colRight) layout.style.setProperty('--col-right', data.colRight);
602
+ if (data.rowTop) layout.style.setProperty('--row-top', data.rowTop);
603
+ if (data.rowBottom) layout.style.setProperty('--row-bottom', data.rowBottom);
604
+ } catch (e) {}
605
+ }
606
+
607
+ function resetLayout() {
608
+ layout.style.removeProperty('--col-left');
609
+ layout.style.removeProperty('--col-right');
610
+ layout.style.removeProperty('--row-top');
611
+ layout.style.removeProperty('--row-bottom');
612
+ try { localStorage.removeItem(STORAGE_KEY_LAYOUT); } catch (e) { /* ignore */ }
613
+ }
614
+
615
+ function setupColumnDrag() {
616
+ const handle = document.getElementById('gutterCol');
617
+ handle.addEventListener('dblclick', resetLayout);
618
+ let dragging = false;
619
+ handle.addEventListener('mousedown', (e) => {
620
+ dragging = true;
621
+ handle.classList.add('dragging');
622
+ document.body.style.userSelect = 'none';
623
+ e.preventDefault();
624
+ });
625
+ document.addEventListener('mousemove', (e) => {
626
+ if (!dragging) return;
627
+ const rect = layout.getBoundingClientRect();
628
+ const padding = 12;
629
+ const totalWidth = rect.width - padding * 2;
630
+ const gutterWidth = 6;
631
+ const usable = totalWidth - gutterWidth;
632
+ const leftPx = clamp(e.clientX - rect.left - padding, 200, usable - 200);
633
+ const rightPx = usable - leftPx;
634
+ layout.style.setProperty('--col-left', leftPx + 'px');
635
+ layout.style.setProperty('--col-right', rightPx + 'px');
636
+ });
637
+ document.addEventListener('mouseup', () => {
638
+ if (!dragging) return;
639
+ dragging = false;
640
+ handle.classList.remove('dragging');
641
+ document.body.style.userSelect = '';
642
+ persistLayout();
643
+ });
644
+ }
645
+
646
+ function setupRowDrag() {
647
+ const handle = document.getElementById('gutterRow');
648
+ const rightCol = handle.parentElement;
649
+ handle.addEventListener('dblclick', resetLayout);
650
+ let dragging = false;
651
+ handle.addEventListener('mousedown', (e) => {
652
+ dragging = true;
653
+ handle.classList.add('dragging');
654
+ document.body.style.userSelect = 'none';
655
+ e.preventDefault();
656
+ });
657
+ document.addEventListener('mousemove', (e) => {
658
+ if (!dragging) return;
659
+ const rect = rightCol.getBoundingClientRect();
660
+ const gutterHeight = 6;
661
+ const usable = rect.height - gutterHeight;
662
+ const topPx = clamp(e.clientY - rect.top, 100, usable - 100);
663
+ const bottomPx = usable - topPx;
664
+ layout.style.setProperty('--row-top', topPx + 'px');
665
+ layout.style.setProperty('--row-bottom', bottomPx + 'px');
666
+ });
667
+ document.addEventListener('mouseup', () => {
668
+ if (!dragging) return;
669
+ dragging = false;
670
+ handle.classList.remove('dragging');
671
+ document.body.style.userSelect = '';
672
+ persistLayout();
673
+ });
674
+ }
675
+
676
+ restoreLayout();
677
+ setupColumnDrag();
678
+ setupRowDrag();
679
+
680
+ window.addEventListener('beforeunload', flushPendingNoteSave);
681
+
682
+ // Ask main window for slides
683
+ channel.postMessage({ type: 'request-slides' });
684
+ })();
685
+ </script>
686
+ </body>
687
+ </html>