mdv-live 0.5.19 → 0.5.21

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/CHANGELOG.md CHANGED
@@ -5,6 +5,72 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.21] - 2026-05-22
9
+
10
+ ### Fixed — Marp スライドが横長ペインで上下に見切れる
11
+
12
+ ウィンドウ表示(split モード)で、スライドペインがスライドのアスペクト比
13
+ (16:9)より横長になると、スライド(特に全面画像)の上下が見切れていた。
14
+
15
+ - 原因: スライドペイン内の `.marpit` に確定した高さがなく、active SVG の
16
+ `max-height: 100%` が無効化されていた。SVG が「幅 100%」だけで決まるため、
17
+ ペインが 16:9 より横長だと縦にあふれて上下がクリップされていた
18
+ (フルスクリーン表示は `.marpit` が `height: 100vh` を持つため影響なし)。
19
+ - 修正: `.marpit` に `height: 100%` を与え、active SVG を
20
+ `width/height: auto` + `max-width/height: 100%` の真の "contain" に変更。
21
+ ペインが横長・縦長どちらでもスライド全体が必ず収まる。
22
+
23
+ ### Added — トラックパッドのピンチズーム / パン
24
+
25
+ Marp スライド表示で、トラックパッドのピンチ操作(macOS では ctrl+wheel、
26
+ マウスの ctrl+スクロールも同様)でスライドを拡大・縮小できるように。
27
+
28
+ - 二本指スクロールでパン(ペインのネイティブ overflow スクロール)。ピンチと
29
+ パンは分離(ctrl 付き wheel のみズーム)。
30
+ - カーソル位置を中心にズーム。ズーム範囲は fit(等倍)〜6倍。
31
+ - ダブルクリック / `0` キーで全体表示(fit)に復帰。`=` / `-` キーでも増減。
32
+ - スライド送り・フルスクリーン切替・ペインのリサイズで自動的に fit に追従。
33
+ - ズーム計算(contain fit / クランプ / wheel→zoom)は DOM 非依存の
34
+ `src/static/lib/marpZoom.js` に分離し、単体テスト(`tests/test-marp-zoom.js`、
35
+ 12 件)を追加。
36
+
37
+ ## [0.5.20] - 2026-05-18
38
+
39
+ ### Fixed — Presenter View のノート編集が毎回 STALE エラー
40
+
41
+ Presenter View で speaker note を編集すると、毎回一瞬
42
+ `保存失敗: STALE — file changed externally; please reload`(赤)が出る
43
+ バグを修正。同じ deck を mdv のタブ/ウィンドウで 2 つ以上開いていると
44
+ 必ず発生していた。
45
+
46
+ - 原因: Presenter View はノート保存を自前で行わず、`BroadcastChannel` の
47
+ `edit-note` メッセージで **メインウィンドウに保存を委譲** する。この
48
+ メッセージは同一ブラウザの **全メインウィンドウ** に届くため、deck を
49
+ 複数ウィンドウで開いていると **全員が同じ `If-Match` で PUT** し、サーバの
50
+ 楽観ロックで 1 つだけ成功、残りが 412 STALE になっていた。負けた側が
51
+ `note-saved {ok:false, STALE}` を Presenter にブロードキャストし、赤い
52
+ エラー表示になっていた(保存自体は 1 つ成功するため「一瞬だけ」見える)
53
+ - 各メインウィンドウに一意な `windowId` を付与。`slides` メッセージに
54
+ `sourceWindowId` を載せ、Presenter は最初に deck を返したウィンドウを
55
+ saver として固定、`edit-note` に `targetWindowId` を載せて **その 1
56
+ ウィンドウだけが保存** するようルーティング
57
+ - saver ウィンドウが閉じた/凍結した場合は保存タイムアウト(6 秒)で
58
+ 検知し、`find-saver` 問い合わせで deck を持つ別ウィンドウ(非アクティブ
59
+ な背景タブ含む)を探して再ピン・再送するフェイルオーバーを実装
60
+ - 各 `edit-note` に一意な `requestId` を付与し `note-saved` で echo。
61
+ 古い保存の ack が新しい保存のフェイルオーバー監視を誤って解除する問題を
62
+ 防止
63
+ - `saveNote` が「対象 Marp タブなし」のとき `note-saved` をブロードキャスト
64
+ せず return していたため Presenter が `保存中…` のまま固まり得た問題も
65
+ 併せて修正(`code:'NO_DECK'` を返してフェイルオーバーを誘発)
66
+
67
+ ### Verified
68
+
69
+ - 既存 278 + 新規 3(`tests/test-presenter-channel.js`: `newWindowId` の
70
+ 一意性 / フォールバック / モジュール API)
71
+ - Playwright dogfood: メインウィンドウ 2 枚 + Presenter で speaker note を
72
+ 編集 → STALE が出ないこと、メインウィンドウ 1 枚で回帰がないことを実機確認
73
+
8
74
  ## [0.5.19] - 2026-05-16
9
75
 
10
76
  ### Fixed — Marp `![bg]` background images
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mdv-live",
3
- "version": "0.5.19",
3
+ "version": "0.5.21",
4
4
  "description": "Markdown Viewer - File tree + Live preview + Marp support + Hot reload",
5
5
  "main": "src/server.js",
6
6
  "bin": {
package/src/static/app.js CHANGED
@@ -1206,12 +1206,146 @@
1206
1206
  }
1207
1207
  };
1208
1208
 
1209
+ // ============================================================
1210
+ // Marp Slide Zoom (trackpad pinch-to-zoom + pan)
1211
+ // ============================================================
1212
+ //
1213
+ // At fit (zoom === 1) the slide is sized entirely by the CSS "contain"
1214
+ // rules so the whole slide — image and all — is always visible. Zooming
1215
+ // past 1 switches the active SVG to explicit pixel dimensions
1216
+ // (fitSize * zoom); the pane's native overflow then lets a two-finger
1217
+ // scroll pan around the enlarged slide. macOS trackpad pinch arrives as a
1218
+ // `wheel` event with `ctrlKey` set, so ctrl+scroll on a mouse zooms too.
1219
+ const MarpZoom = {
1220
+ area: null,
1221
+ zoom: 1,
1222
+ onWheel: null,
1223
+ onDblClick: null,
1224
+ ro: null,
1225
+
1226
+ // Pure zoom math lives in lib/marpZoom.js (globalThis.MDVMarpZoom) so
1227
+ // it can be unit-tested without a DOM. If that script failed to load,
1228
+ // the CSS-only fit still works; we just skip wiring the gestures.
1229
+ lib() { return (typeof globalThis !== 'undefined') ? globalThis.MDVMarpZoom : null; },
1230
+
1231
+ init(area) {
1232
+ this.detach();
1233
+ if (!this.lib()) return;
1234
+ this.area = area;
1235
+ this.zoom = 1;
1236
+ this.onWheel = (e) => {
1237
+ // Plain two-finger scroll is left to the pane so it pans the
1238
+ // zoomed slide natively. Only a pinch (ctrlKey) zooms.
1239
+ if (!e.ctrlKey) return;
1240
+ e.preventDefault();
1241
+ this.zoomTo(this.lib().zoomForWheel(this.zoom, e.deltaY), e.clientX, e.clientY);
1242
+ };
1243
+ // Double-click anywhere on the slide snaps back to fit.
1244
+ this.onDblClick = () => this.reset();
1245
+ area.addEventListener('wheel', this.onWheel, { passive: false });
1246
+ area.addEventListener('dblclick', this.onDblClick);
1247
+ // Re-apply the pixel size when the pane is resized (window resize,
1248
+ // dragging the notes splitter) so a zoomed slide tracks the new
1249
+ // fit instead of freezing at a stale size.
1250
+ if (typeof ResizeObserver !== 'undefined') {
1251
+ this.ro = new ResizeObserver(() => {
1252
+ if (!this.lib().isFit(this.zoom)) this.zoomTo(this.zoom);
1253
+ });
1254
+ this.ro.observe(area);
1255
+ }
1256
+ },
1257
+
1258
+ detach() {
1259
+ if (this.area && this.onWheel) {
1260
+ this.area.removeEventListener('wheel', this.onWheel);
1261
+ this.area.removeEventListener('dblclick', this.onDblClick);
1262
+ }
1263
+ if (this.ro) { this.ro.disconnect(); this.ro = null; }
1264
+ this.area = null;
1265
+ this.onWheel = null;
1266
+ this.onDblClick = null;
1267
+ this.zoom = 1;
1268
+ },
1269
+
1270
+ activeSvg() {
1271
+ return this.area
1272
+ ? this.area.querySelector('.marpit > svg[data-marpit-svg].active')
1273
+ : null;
1274
+ },
1275
+
1276
+ // Slide dimensions at fit (zoom 1), resolved the same way the CSS
1277
+ // "contain" rule does — so the 1.0 → 1.01 transition doesn't jump.
1278
+ fitSize(svg) {
1279
+ const cs = getComputedStyle(this.area);
1280
+ const padX = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight);
1281
+ const padY = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
1282
+ const aw = this.area.clientWidth - padX;
1283
+ const ah = this.area.clientHeight - padY;
1284
+ const vb = svg.viewBox && svg.viewBox.baseVal;
1285
+ const ratio = (vb && vb.width) ? vb.height / vb.width : 9 / 16;
1286
+ return this.lib().containFit(aw, ah, ratio);
1287
+ },
1288
+
1289
+ // Apply `z` around a focal point (defaults to the pane centre). The
1290
+ // before/after rects already fold in centring and scroll offsets, so
1291
+ // the point under the cursor stays put as the slide grows.
1292
+ zoomTo(z, focalX, focalY) {
1293
+ const svg = this.activeSvg();
1294
+ if (!svg) return;
1295
+ z = this.lib().clampZoom(z);
1296
+ if (this.lib().isFit(z)) { this.reset(); return; }
1297
+
1298
+ if (focalX == null) {
1299
+ const r = this.area.getBoundingClientRect();
1300
+ focalX = r.left + r.width / 2;
1301
+ focalY = r.top + r.height / 2;
1302
+ }
1303
+ const before = svg.getBoundingClientRect();
1304
+ const relX = before.width ? (focalX - before.left) / before.width : 0.5;
1305
+ const relY = before.height ? (focalY - before.top) / before.height : 0.5;
1306
+
1307
+ this.zoom = z;
1308
+ const fit = this.fitSize(svg);
1309
+ svg.style.width = (fit.w * z) + 'px';
1310
+ svg.style.height = (fit.h * z) + 'px';
1311
+ this.area.classList.add('marp-zoomed');
1312
+
1313
+ const after = svg.getBoundingClientRect();
1314
+ this.area.scrollLeft += (after.left + relX * after.width) - focalX;
1315
+ this.area.scrollTop += (after.top + relY * after.height) - focalY;
1316
+ },
1317
+
1318
+ // Step zoom for keyboard (+/-): dir > 0 zooms in, else out.
1319
+ nudge(dir) {
1320
+ if (!this.lib()) return;
1321
+ this.zoomTo(this.lib().zoomForStep(this.zoom, dir));
1322
+ },
1323
+
1324
+ // Back to fit: clear the pixel sizing on every slide (the active one
1325
+ // may have changed since we zoomed) and hand sizing back to CSS.
1326
+ reset() {
1327
+ this.zoom = 1;
1328
+ if (!this.area) return;
1329
+ this.area.querySelectorAll('.marpit > svg[data-marpit-svg]').forEach(s => {
1330
+ s.style.width = '';
1331
+ s.style.height = '';
1332
+ });
1333
+ this.area.classList.remove('marp-zoomed');
1334
+ this.area.scrollLeft = 0;
1335
+ this.area.scrollTop = 0;
1336
+ }
1337
+ };
1338
+
1209
1339
  // ============================================================
1210
1340
  // Presenter View (separate window with speaker notes)
1211
1341
  // ============================================================
1212
1342
 
1213
1343
  const PresenterView = {
1214
1344
  channel: null,
1345
+ // Unique id for this main window. The presenter echoes it back as
1346
+ // `edit-note.targetWindowId` so that exactly one main window saves,
1347
+ // even when the same deck is open in several windows.
1348
+ windowId: null,
1215
1349
  presenterWindow: null,
1216
1350
  saveQueue: null, // MDVSaveQueue instance (created in init)
1217
1351
  // Map<path, etag> — own-save chain rebase. We track presenter and
@@ -1246,7 +1380,7 @@
1246
1380
  // can autosave in environments (older browsers / sandboxed
1247
1381
  // webviews) where the Presenter window cannot be opened.
1248
1382
  this.saveQueue = window.MDVSaveQueue.createSaveQueue({
1249
- saveFn: (path, slideIndex, note, etag, origin) => {
1383
+ saveFn: (path, slideIndex, note, etag, origin, requestId) => {
1250
1384
  let useEtag = etag;
1251
1385
  const tab = state.tabs.find((t) => t.path === path);
1252
1386
  // Pick the "own etag" map that matches this save's
@@ -1259,7 +1393,7 @@
1259
1393
  : this.lastSavedEtag;
1260
1394
  const own = ownMap.get(path);
1261
1395
  if (tab && own && tab.etag === own) useEtag = own;
1262
- return this.saveNote(path, slideIndex, note, useEtag, origin);
1396
+ return this.saveNote(path, slideIndex, note, useEtag, origin, requestId);
1263
1397
  }
1264
1398
  });
1265
1399
 
@@ -1279,6 +1413,7 @@
1279
1413
  // their channel.postMessage calls (channel === null).
1280
1414
  if (typeof BroadcastChannel !== 'undefined'
1281
1415
  && window.MDVPresenterChannel) {
1416
+ this.windowId = window.MDVPresenterChannel.newWindowId();
1282
1417
  this.channel = window.MDVPresenterChannel.create();
1283
1418
  if (this.channel) {
1284
1419
  this.channel.addEventListener('message', (e) => {
@@ -1287,11 +1422,38 @@
1287
1422
  this.broadcastSlides();
1288
1423
  } else if (msg.type === 'goto') {
1289
1424
  this.gotoSlide(msg.index);
1425
+ } else if (msg.type === 'find-saver') {
1426
+ // Failover discovery: the presenter lost its
1427
+ // saver and asks who can save `path`. Answer if
1428
+ // this window holds that deck in ANY tab —
1429
+ // saveNote() resolves by path, so an inactive
1430
+ // background tab counts (broadcastSlides only
1431
+ // reports the active tab and would miss it).
1432
+ if (msg.path
1433
+ && state.tabs.some((t) => t.path === msg.path && t.isMarp)) {
1434
+ this.channel.postMessage({
1435
+ type: 'saver-here',
1436
+ path: msg.path,
1437
+ windowId: this.windowId
1438
+ });
1439
+ }
1290
1440
  } else if (msg.type === 'edit-note') {
1291
1441
  if (!msg.path) return;
1442
+ // Route: only the main window the presenter
1443
+ // picked as its saver performs the save. Without
1444
+ // this, every main window showing the same deck
1445
+ // fires its own PUT and all but one collide on
1446
+ // the optimistic lock → spurious "STALE" in the
1447
+ // presenter. A missing targetWindowId (older
1448
+ // presenter build) falls back to handling it so
1449
+ // saves still work, just without dedup.
1450
+ if (msg.targetWindowId
1451
+ && msg.targetWindowId !== this.windowId) {
1452
+ return;
1453
+ }
1292
1454
  this.saveQueue.enqueue(
1293
1455
  msg.path, msg.slideIndex, msg.note,
1294
- msg.etag || null, 'presenter'
1456
+ msg.etag || null, 'presenter', msg.requestId
1295
1457
  );
1296
1458
  }
1297
1459
  });
@@ -1317,11 +1479,16 @@
1317
1479
  // next autosave would skip the STALE conflict it should otherwise
1318
1480
  // hit and silently overwrite the inline edit).
1319
1481
  //
1482
+ // `requestId` is the opaque token from the presenter's edit-note;
1483
+ // echoing it back in note-saved lets the presenter match this
1484
+ // result to the exact save it sent (older saves' acks then can't
1485
+ // cancel a newer save's failover timer).
1486
+ //
1320
1487
  // Returns { ok, etag?, normalizedNote?, reason?, code? } so saveQueue
1321
1488
  // can forward the result to enqueue() awaiters (the main-window inline
1322
1489
  // notes panel reads this). The presenter window still gets results via
1323
1490
  // the existing channel.postMessage('note-saved') broadcast.
1324
- async saveNote(path, slideIndex, note, editTimeEtag, origin) {
1491
+ async saveNote(path, slideIndex, note, editTimeEtag, origin, requestId) {
1325
1492
  const broadcast = (payload) => {
1326
1493
  // No-op when BroadcastChannel was unavailable at init —
1327
1494
  // inline autosaves still work because callers also read
@@ -1329,15 +1496,28 @@
1329
1496
  if (!this.channel) return;
1330
1497
  this.channel.postMessage({
1331
1498
  type: 'note-saved',
1499
+ path,
1332
1500
  slideIndex,
1333
1501
  origin: origin || 'unknown',
1502
+ sourceWindowId: this.windowId,
1503
+ requestId,
1334
1504
  ...payload
1335
1505
  });
1336
1506
  };
1337
1507
 
1338
1508
  const tab = state.tabs.find((t) => t.path === path);
1339
1509
  if (!tab || !tab.isMarp) {
1340
- return { ok: false, reason: 'No active Marp tab' };
1510
+ // This window no longer holds the deck (tab closed / switched
1511
+ // away). Broadcast a NO_DECK failure so a presenter that
1512
+ // routed here can fail over to another window instead of
1513
+ // hanging on "保存中…" or surfacing a dead-end error.
1514
+ const result = {
1515
+ ok: false,
1516
+ code: 'NO_DECK',
1517
+ reason: 'No active Marp tab'
1518
+ };
1519
+ broadcast(result);
1520
+ return result;
1341
1521
  }
1342
1522
  const ifMatch = editTimeEtag || tab.etag;
1343
1523
  if (!ifMatch) {
@@ -1447,7 +1627,8 @@
1447
1627
  this.channel.postMessage({
1448
1628
  type: 'slides',
1449
1629
  empty: true,
1450
- reason: 'main-switched-away'
1630
+ reason: 'main-switched-away',
1631
+ sourceWindowId: this.windowId
1451
1632
  });
1452
1633
  return;
1453
1634
  }
@@ -1459,7 +1640,8 @@
1459
1640
  notes: tab.notes || [],
1460
1641
  notesMultiplicity: tab.notesMultiplicity || [],
1461
1642
  etag: tab.etag || null,
1462
- current: marpCurrentSlide
1643
+ current: marpCurrentSlide,
1644
+ sourceWindowId: this.windowId
1463
1645
  });
1464
1646
  },
1465
1647
 
@@ -1471,6 +1653,8 @@
1471
1653
  gotoSlide(index) {
1472
1654
  const slides = elements.content.querySelectorAll('.marpit > svg[data-marpit-svg]');
1473
1655
  if (!slides.length || index < 0 || index >= slides.length) return;
1656
+ // Each slide opens at fit; clear any zoom carried from the last one.
1657
+ MarpZoom.reset();
1474
1658
  slides.forEach((s, i) => s.classList.toggle('active', i === index));
1475
1659
  const panels = elements.content.querySelectorAll(
1476
1660
  '#marpNotesArea > .speaker-notes-panel'
@@ -1567,6 +1751,12 @@
1567
1751
  MarpSplitHandle.attach(splitEl, handleEl);
1568
1752
  }
1569
1753
 
1754
+ // Enable trackpad pinch-to-zoom / pan on the slide pane.
1755
+ const slideArea = document.getElementById('marpSlideArea');
1756
+ if (slideArea) {
1757
+ MarpZoom.init(slideArea);
1758
+ }
1759
+
1570
1760
  // Add navigation controls. The nav is appended to .content (NOT
1571
1761
  // marpit) so its `position: fixed` doesn't get clipped by the
1572
1762
  // grid container's overflow:hidden rule.
@@ -1640,6 +1830,8 @@
1640
1830
  );
1641
1831
 
1642
1832
  const showSlide = (index) => {
1833
+ // Each slide opens at fit; clear any zoom from the last one.
1834
+ MarpZoom.reset();
1643
1835
  slides.forEach((slide, i) => {
1644
1836
  slide.classList.toggle('active', i === index);
1645
1837
  });
@@ -1679,6 +1871,11 @@
1679
1871
  const expandIcon = '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" /></svg>';
1680
1872
  const shrinkIcon = '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 9V4m0 5H4m5 0L4 4m11 5h5m-5 0V4m0 5l5-5M9 15v5m0-5H4m5 0l-5 5m11-5h5m-5 0v5m0-5l5 5" /></svg>';
1681
1873
  const toggleFullscreen = () => {
1874
+ // Snap back to fit across the transition: the fullscreen and
1875
+ // windowed panes have different sizes, and the fullscreen CSS
1876
+ // owns the fit there, so a leftover pixel zoom would mis-size
1877
+ // the slide. The user can re-pinch on either side.
1878
+ MarpZoom.reset();
1682
1879
  document.body.classList.toggle('marp-fullscreen');
1683
1880
  const isFullscreen = document.body.classList.contains('marp-fullscreen');
1684
1881
  if (fullscreenBtn) {
@@ -1774,6 +1971,17 @@
1774
1971
  // shortcut and must not also open the presenter view.
1775
1972
  e.preventDefault();
1776
1973
  PresenterView.open();
1974
+ } else if ((e.key === '+' || e.key === '=') && !e.metaKey && !e.ctrlKey) {
1975
+ // Keyboard zoom (centre-anchored) mirrors the pinch gesture.
1976
+ // Skip Cmd/Ctrl which the browser owns for page zoom.
1977
+ e.preventDefault();
1978
+ MarpZoom.nudge(1);
1979
+ } else if ((e.key === '-' || e.key === '_') && !e.metaKey && !e.ctrlKey) {
1980
+ e.preventDefault();
1981
+ MarpZoom.nudge(-1);
1982
+ } else if (e.key === '0' && !e.metaKey && !e.ctrlKey) {
1983
+ e.preventDefault();
1984
+ MarpZoom.reset();
1777
1985
  } else if (e.key === 'Escape') {
1778
1986
  e.preventDefault();
1779
1987
  if (document.body.classList.contains('marp-fullscreen')) {
@@ -1791,6 +1999,7 @@
1791
1999
  // 800ms save timer doesn't fire after the editor element is gone.
1792
2000
  InlineNotesPanel.detach();
1793
2001
  MarpSplitHandle.detach();
2002
+ MarpZoom.detach();
1794
2003
  elements.content.classList.remove('marp-viewer');
1795
2004
  document.body.classList.remove('marp-fullscreen');
1796
2005
  if (marpKeyHandler) {
@@ -181,6 +181,7 @@
181
181
  <script src="/static/lib/apiClient.js"></script>
182
182
  <script src="/static/lib/saveQueue.js"></script>
183
183
  <script src="/static/lib/tabRegistry.js"></script>
184
+ <script src="/static/lib/marpZoom.js"></script>
184
185
  <script src="/static/app.js"></script>
185
186
  </body>
186
187
  </html>
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Pure math for the Marp slide zoom (src/static/app.js → MarpZoom).
3
+ *
4
+ * Imported from a `<script>` tag (no module loader), so the functions are
5
+ * exposed on `globalThis.MDVMarpZoom`. Kept DOM-free so the contain/clamp
6
+ * logic — the part that decides whether the whole slide stays visible — can
7
+ * be unit-tested without a browser (see tests/test-marp-zoom.js).
8
+ */
9
+ (function () {
10
+ 'use strict';
11
+
12
+ const ZOOM_MIN = 1;
13
+ const ZOOM_MAX = 6;
14
+
15
+ // Per-wheel-delta zoom sensitivity. Pinch deltas are small and frequent;
16
+ // the exponential keeps each step proportional so the gesture feels even
17
+ // across the whole range instead of accelerating near the top. Tuned for a
18
+ // snappy pinch (a 120-delta notch ≈ +62%; was 0.0015 ≈ +20%, 0.0025 ≈ +35%).
19
+ const WHEEL_FACTOR = 0.004;
20
+
21
+ // Keyboard +/- step ratio (zoom in / zoom out).
22
+ const STEP_IN = 1.25;
23
+ const STEP_OUT = 0.8;
24
+
25
+ /**
26
+ * "Contain" fit: the largest w×h with aspect `ratio` (= height/width) that
27
+ * fits inside areaW×areaH. Mirrors the CSS `max-width/height:100%` +
28
+ * `width/height:auto` resolution so the JS-driven zoom (≥1) starts exactly
29
+ * where the CSS fit (=1) leaves off — no jump at the 1.0 boundary.
30
+ *
31
+ * @param {number} areaW available content width (px)
32
+ * @param {number} areaH available content height (px)
33
+ * @param {number} ratio slide height / slide width (e.g. 720/1280)
34
+ * @returns {{w:number,h:number}} fitted slide size, never below 1px
35
+ */
36
+ function containFit(areaW, areaH, ratio) {
37
+ if (!(areaW > 0) || !(areaH > 0) || !(ratio > 0)) {
38
+ return { w: 1, h: 1 };
39
+ }
40
+ let w = areaW;
41
+ let h = areaW * ratio;
42
+ if (h > areaH) {
43
+ h = areaH;
44
+ w = areaH / ratio;
45
+ }
46
+ return { w: Math.max(1, w), h: Math.max(1, h) };
47
+ }
48
+
49
+ /** Clamp a zoom level to [ZOOM_MIN, ZOOM_MAX]. */
50
+ function clampZoom(z) {
51
+ if (!Number.isFinite(z)) return ZOOM_MIN;
52
+ return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z));
53
+ }
54
+
55
+ /**
56
+ * Next zoom level for a wheel/pinch delta. Negative deltaY (pinch open /
57
+ * scroll up) zooms in. Result is already clamped.
58
+ */
59
+ function zoomForWheel(current, deltaY) {
60
+ return clampZoom(current * Math.exp(-deltaY * WHEEL_FACTOR));
61
+ }
62
+
63
+ /** Next zoom level for a keyboard step. dir > 0 zooms in, else out. */
64
+ function zoomForStep(current, dir) {
65
+ return clampZoom(current * (dir > 0 ? STEP_IN : STEP_OUT));
66
+ }
67
+
68
+ /** True when a zoom level is effectively the fit (no pixel sizing needed). */
69
+ function isFit(z) {
70
+ return z <= ZOOM_MIN + 0.001;
71
+ }
72
+
73
+ if (typeof globalThis !== 'undefined') {
74
+ globalThis.MDVMarpZoom = {
75
+ ZOOM_MIN,
76
+ ZOOM_MAX,
77
+ containFit,
78
+ clampZoom,
79
+ zoomForWheel,
80
+ zoomForStep,
81
+ isFit,
82
+ };
83
+ }
84
+ })();
@@ -8,15 +8,33 @@
8
8
  * Message types (discriminated by `type`):
9
9
  *
10
10
  * main → presenter
11
- * { type: 'slides', path, html, css, etag, notes, notesMultiplicity, current }
12
- * { type: 'slides', empty: true, reason } ← clear / no-deck
11
+ * { type: 'slides', path, html, css, etag, notes, notesMultiplicity,
12
+ * current, sourceWindowId }
13
+ * { type: 'slides', empty: true, reason, sourceWindowId } ← clear / no-deck
13
14
  * { type: 'index', index }
14
- * { type: 'note-saved', slideIndex, ok, etag?, normalizedNote?, code?, reason? }
15
+ * { type: 'note-saved', path, slideIndex, ok, etag?, normalizedNote?,
16
+ * code?, reason?, origin, sourceWindowId, requestId }
17
+ * { type: 'saver-here', path, windowId } ← failover reply
15
18
  *
16
19
  * presenter → main
17
20
  * { type: 'request-slides' }
18
21
  * { type: 'goto', index }
19
- * { type: 'edit-note', path, etag, slideIndex, note }
22
+ * { type: 'edit-note', path, etag, slideIndex, note, requestId,
23
+ * targetWindowId }
24
+ * { type: 'find-saver', path } ← failover query
25
+ *
26
+ * Window routing: every main window has a unique `windowId`. Each `slides`
27
+ * message carries the broadcasting window's id as `sourceWindowId`; the
28
+ * presenter pins the first one that can serve the deck and echoes it back as
29
+ * `edit-note.targetWindowId`. Only that one main window performs the save.
30
+ * Without this, N main windows showing the same deck each fire their own PUT
31
+ * and all but one collide on the optimistic lock → spurious "STALE" errors.
32
+ *
33
+ * Failover: if the pinned saver stops answering, the presenter broadcasts
34
+ * `find-saver`; any main window holding the deck (active OR background tab)
35
+ * replies `saver-here` and the presenter re-pins it. Each `edit-note` carries
36
+ * a unique `requestId` echoed back in `note-saved` so the presenter matches a
37
+ * result to the exact save that produced it.
20
38
  */
21
39
  (function () {
22
40
  'use strict';
@@ -27,7 +45,17 @@
27
45
  return new BroadcastChannel(CHANNEL_NAME);
28
46
  }
29
47
 
48
+ // Per-window identifier used to route presenter saves to a single main
49
+ // window. crypto.randomUUID is available on localhost (a secure context);
50
+ // the fallback keeps things working in any odd environment.
51
+ function newWindowId() {
52
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
53
+ return crypto.randomUUID();
54
+ }
55
+ return 'w-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
56
+ }
57
+
30
58
  if (typeof globalThis !== 'undefined') {
31
- globalThis.MDVPresenterChannel = { CHANNEL_NAME, create };
59
+ globalThis.MDVPresenterChannel = { CHANNEL_NAME, create, newWindowId };
32
60
  }
33
61
  })();
@@ -11,10 +11,12 @@
11
11
  * - Crucially, coalescing is scoped per origin: an inline save and a
12
12
  * presenter save for the same slide do NOT replace each other. Both run
13
13
  * serially so neither editor silently loses a draft.
14
- * - `saveFn(path, slideIndex, note, etag, origin)` is supplied by the
15
- * caller; `origin` is an optional tag (e.g. 'presenter' / 'inline') the
14
+ * - `saveFn(path, slideIndex, note, etag, origin, requestId)` is supplied by
15
+ * the caller; `origin` is an optional tag (e.g. 'presenter' / 'inline') the
16
16
  * queue uses for keying and also forwards verbatim so saveFn can route
17
- * notifications back to the right editor.
17
+ * notifications back to the right editor. `requestId` is an optional
18
+ * opaque token forwarded verbatim so a caller can correlate the save's
19
+ * result with the request that triggered it.
18
20
  * - enqueue() returns a Promise that resolves with the saveFn's result (or a
19
21
  * COALESCED sentinel). Existing callers that ignore the return value or
20
22
  * skip the origin argument keep working unchanged.
@@ -32,7 +34,7 @@
32
34
  /** @type {Map<string, { pending: Map<string, {slideIndex:number, note:string, etag:string|null, origin:string|undefined, resolve:Function}>, isDraining: boolean }>} */
33
35
  const queue = new Map();
34
36
 
35
- function enqueue(path, slideIndex, note, etag, origin) {
37
+ function enqueue(path, slideIndex, note, etag, origin, requestId) {
36
38
  return new Promise((resolve) => {
37
39
  let entry = queue.get(path);
38
40
  if (!entry) {
@@ -44,7 +46,7 @@
44
46
  if (existing) {
45
47
  existing.resolve({ ok: false, reason: 'COALESCED' });
46
48
  }
47
- entry.pending.set(key, { slideIndex, note, etag, origin, resolve });
49
+ entry.pending.set(key, { slideIndex, note, etag, origin, requestId, resolve });
48
50
  if (!entry.isDraining) drain(path);
49
51
  });
50
52
  }
@@ -62,7 +64,8 @@
62
64
  let result;
63
65
  try {
64
66
  result = await saveFn(
65
- path, payload.slideIndex, payload.note, payload.etag, payload.origin
67
+ path, payload.slideIndex, payload.note, payload.etag,
68
+ payload.origin, payload.requestId
66
69
  );
67
70
  } catch (err) {
68
71
  console.error('saveQueue saveFn error', err);
@@ -287,6 +287,33 @@
287
287
  let deckPath = '';
288
288
  let saveTimer = null;
289
289
 
290
+ // Window-routing + failover state: the presenter delegates saving to
291
+ // ONE main window (saverWindowId — the first that can serve this deck).
292
+ // saveAckTimer detects when that window stops answering. inflightSave /
293
+ // pendingSave hold a FROZEN copy of the edit ({path, slideIndex, note,
294
+ // etag, requestId}) so a failover resends the original edit, not
295
+ // whatever the editor shows after a later blur / slide navigation.
296
+ let saverWindowId = null;
297
+ let saveAckTimer = null;
298
+ let findSaverTimer = null; // bounds the wait for a find-saver reply
299
+ let inflightSave = null; // routed, awaiting ack | null
300
+ let pendingSave = null; // deferred until a saver is (re)pinned | null
301
+ const SAVE_ACK_TIMEOUT_MS = 6000;
302
+ const FIND_SAVER_TIMEOUT_MS = 3000;
303
+
304
+ // Each edit-note carries a globally-unique requestId so its note-saved
305
+ // ack matches the exact save that produced it — an older save's ack
306
+ // must never clear a newer save's failover timer. The presenterId
307
+ // prefix keeps ids distinct across multiple presenter windows.
308
+ const presenterId = (window.MDVPresenterChannel && window.MDVPresenterChannel.newWindowId)
309
+ ? window.MDVPresenterChannel.newWindowId()
310
+ : ('p-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8));
311
+ let saveRequestSeq = 0;
312
+ function newRequestId() {
313
+ saveRequestSeq += 1;
314
+ return presenterId + ':' + saveRequestSeq;
315
+ }
316
+
290
317
  const notesBanner = document.getElementById('notesBanner');
291
318
 
292
319
  function applyCss(css) {
@@ -384,21 +411,16 @@
384
411
 
385
412
  function loadSlides(payload) {
386
413
  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('');
414
+ // Once a deck is shown, the presenter never blanks itself on an
415
+ // `empty`: with multiple main windows `request-slides` is global and
416
+ // any window on a non-Marp tab including the pinned saver, which
417
+ // can still save the deck from a background tab — broadcasts `empty`.
418
+ // Blanking mid-presentation on that noise is wrong; keep the current
419
+ // slides and let save routing / failover handle reachability. Only
420
+ // an `empty` arriving before any deck has loaded shows the hint.
421
+ if (deckPath !== '') return;
393
422
  fillStage(stageCurrent, '<div class="empty-state">メイン画面で Marp ファイルを開いてください。</div>');
394
423
  fillStage(stageNext, '');
395
- notes = [];
396
- notesMultiplicity = [];
397
- deckEtag = null;
398
- deckPath = '';
399
- slideCount = 0;
400
- counter.textContent = '– / –';
401
- setNotesText('');
402
424
  applyReadOnlyState();
403
425
  return;
404
426
  }
@@ -416,7 +438,20 @@
416
438
  notes = Array.isArray(payload.notes) ? payload.notes : [];
417
439
  notesMultiplicity = Array.isArray(payload.notesMultiplicity) ? payload.notesMultiplicity : [];
418
440
  deckEtag = payload.etag || null;
441
+ // Detect a deck switch BEFORE updating deckPath: the presenter window
442
+ // can be reused by another main window for another deck, and the old
443
+ // saver may not hold the new deck (→ "No active Marp tab", no retry).
444
+ const deckChanged = !!payload.path && payload.path !== deckPath;
419
445
  deckPath = payload.path || deckPath;
446
+ // Pin the window serving this deck as our saver, so edit-note saves
447
+ // route to exactly one window (no duplicate PUTs). A non-empty
448
+ // `slides` proves the sender holds the deck. Re-pin on a deck switch
449
+ // (the presenter window can be reused by another main window).
450
+ // Failover (saver gone) is handled separately via find-saver /
451
+ // saver-here, which also discovers inactive background deck tabs.
452
+ if (payload.sourceWindowId && (saverWindowId === null || deckChanged)) {
453
+ saverWindowId = payload.sourceWindowId;
454
+ }
420
455
  slideCount = stageCurrent.querySelectorAll('.marpit > svg[data-marpit-svg]').length;
421
456
  render(typeof payload.current === 'number' ? payload.current : currentIndex);
422
457
  }
@@ -451,24 +486,97 @@
451
486
  }
452
487
  }
453
488
 
489
+ // Broadcast a find-saver query for `path` and bound the wait: a deck
490
+ // open in ANY window's tab (active OR background) answers `saver-here`;
491
+ // if none does within the timeout the deck has no reachable window, so
492
+ // surface an honest error instead of spinning on 保存中… forever.
493
+ function requestSaver(path) {
494
+ channel.postMessage({ type: 'find-saver', path });
495
+ if (findSaverTimer !== null) clearTimeout(findSaverTimer);
496
+ findSaverTimer = setTimeout(() => {
497
+ findSaverTimer = null;
498
+ if (pendingSave !== null) {
499
+ pendingSave = null;
500
+ setSaveStatus('保存失敗: 保存先ウィンドウが見つかりません', 'err');
501
+ }
502
+ }, FIND_SAVER_TIMEOUT_MS);
503
+ }
504
+
505
+ // Re-pin a live saver and resend `payload` (the exact edit that could
506
+ // not be delivered). Used when the saver window stops answering or no
507
+ // longer holds the deck.
508
+ function failOver(payload) {
509
+ if (saveAckTimer !== null) {
510
+ clearTimeout(saveAckTimer);
511
+ saveAckTimer = null;
512
+ }
513
+ saverWindowId = null;
514
+ inflightSave = null;
515
+ if (!payload) return;
516
+ pendingSave = payload;
517
+ requestSaver(payload.path);
518
+ }
519
+
520
+ // The saver window never acknowledged our edit-note (it was closed or
521
+ // frozen). Fail over, resending the exact edit that timed out.
522
+ function onSaveAckTimeout() {
523
+ saveAckTimer = null;
524
+ failOver(inflightSave);
525
+ }
526
+
454
527
  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.
528
+ // Freeze the slide / deck / etag captured when the user started
529
+ // editing — not the live values, which can change under us if the
530
+ // main window navigates, switches tabs, or receives a watcher update
531
+ // during the debounce. The frozen payload also lets a failover resend
532
+ // the exact edit even after a later blur / slide navigation.
460
533
  const idx = editingSlideIndex >= 0 ? editingSlideIndex : currentIndex;
461
- const path = editingPath || deckPath;
462
- const etag = editingEtag || deckEtag;
463
534
  const value = getNotesText();
464
535
  notes[idx] = value.trim();
536
+ postEditNote({
537
+ path: editingPath || deckPath,
538
+ slideIndex: idx,
539
+ note: value,
540
+ etag: editingEtag || deckEtag
541
+ });
542
+ }
543
+
544
+ // Route one frozen edit payload to the pinned saver, or defer it until
545
+ // a saver is (re)pinned.
546
+ function postEditNote(payload) {
465
547
  setSaveStatus('保存中…');
548
+
549
+ // No live saver pinned yet (first edit before slides arrived, or the
550
+ // previous saver window went away). Routing an edit-note without a
551
+ // targetWindowId would let EVERY main window save it and collide on
552
+ // the optimistic lock — the exact bug this routing fixes. Instead ask
553
+ // who can serve this deck; the saver-here handler re-pins and resends.
554
+ if (saverWindowId === null) {
555
+ pendingSave = payload;
556
+ requestSaver(payload.path);
557
+ return;
558
+ }
559
+
560
+ // Fresh requestId per actual send: a failover resend must NOT reuse
561
+ // the timed-out original's id, or that original's late ack would be
562
+ // mistaken for the resend's ack.
563
+ inflightSave = {
564
+ path: payload.path,
565
+ slideIndex: payload.slideIndex,
566
+ note: payload.note,
567
+ etag: payload.etag,
568
+ requestId: newRequestId()
569
+ };
570
+ if (saveAckTimer !== null) clearTimeout(saveAckTimer);
571
+ saveAckTimer = setTimeout(onSaveAckTimeout, SAVE_ACK_TIMEOUT_MS);
466
572
  channel.postMessage({
467
573
  type: 'edit-note',
468
- path,
469
- etag,
470
- slideIndex: idx,
471
- note: value
574
+ path: inflightSave.path,
575
+ etag: inflightSave.etag,
576
+ slideIndex: inflightSave.slideIndex,
577
+ note: inflightSave.note,
578
+ requestId: inflightSave.requestId,
579
+ targetWindowId: saverWindowId
472
580
  });
473
581
  }
474
582
 
@@ -533,7 +641,56 @@
533
641
  // the new etag came from our own successful save.
534
642
  } else if (msg.type === 'index') {
535
643
  render(msg.index);
644
+ } else if (msg.type === 'saver-here') {
645
+ // A window answered our find-saver query. Pin it and resend the
646
+ // deferred edit — but only if it serves the exact deck that edit
647
+ // targets (find-saver is path-scoped, yet guard defensively). The
648
+ // first answer wins; later answers find pendingSave already null.
649
+ if (pendingSave !== null
650
+ && msg.windowId
651
+ && msg.path === pendingSave.path) {
652
+ if (findSaverTimer !== null) {
653
+ clearTimeout(findSaverTimer);
654
+ findSaverTimer = null;
655
+ }
656
+ saverWindowId = msg.windowId;
657
+ const queued = pendingSave;
658
+ pendingSave = null;
659
+ postEditNote(queued);
660
+ }
536
661
  } else if (msg.type === 'note-saved') {
662
+ // Is this the answer to OUR routed save? Match on the unique
663
+ // requestId so an older save's ack can't clear a newer save's
664
+ // failover timer, and a foreign save (e.g. an inline edit) for the
665
+ // same slide is ignored — inline saves carry no requestId.
666
+ const isOwnAck = inflightSave !== null
667
+ && msg.requestId != null
668
+ && msg.requestId === inflightSave.requestId;
669
+ // A note-saved that carries a requestId but isn't our current
670
+ // inflight save is a superseded / timed-out save's late ack — drop
671
+ // it so it can't overwrite status / backup / editingEtag for the
672
+ // current edit. requestId-less acks (inline edits) still fall
673
+ // through to the observer logic below.
674
+ if (!isOwnAck && msg.requestId != null) return;
675
+ if (isOwnAck) {
676
+ // The pinned saver no longer holds this deck (tab closed / switched
677
+ // away). It IS alive, but cannot save — fail over to a window that
678
+ // can, resending the same edit, instead of surfacing an error.
679
+ if (msg.code === 'NO_DECK') {
680
+ failOver(inflightSave);
681
+ return;
682
+ }
683
+ // A real answer (success OR genuine STALE) — stop the failover
684
+ // timer and let the status handling below run.
685
+ if (saveAckTimer !== null) {
686
+ clearTimeout(saveAckTimer);
687
+ saveAckTimer = null;
688
+ }
689
+ inflightSave = null;
690
+ }
691
+ // Ignore results for a different deck — a second presenter window,
692
+ // or the main window having switched decks mid-flight.
693
+ if (msg.path && deckPath && msg.path !== deckPath) return;
537
694
  const targetIdx = editingSlideIndex >= 0 ? editingSlideIndex : currentIndex;
538
695
  if (msg.slideIndex !== targetIdx) return;
539
696
  // Only OUR own successful saves should advance editingEtag. The
@@ -1080,24 +1080,38 @@ body.marp-fullscreen .marpit > svg[data-marpit-svg] {
1080
1080
  .marp-slide-area {
1081
1081
  overflow: auto;
1082
1082
  display: flex;
1083
- align-items: center;
1084
- justify-content: center;
1083
+ /* `safe` keeps the slide reachable when zoomed larger than the pane:
1084
+ plain `center` would push the overflowing top/left out of the scroll
1085
+ range so you could never scroll back to them. */
1086
+ align-items: safe center;
1087
+ justify-content: safe center;
1085
1088
  padding: 20px;
1086
1089
  min-height: 0;
1087
1090
  }
1088
1091
 
1092
+ /* The pane is the trackpad-zoom focus target. Pinch (ctrl+wheel) zooms the
1093
+ slide; two-finger scroll then pans via the pane's native overflow. */
1094
+ .marp-slide-area.marp-zoomed { cursor: grab; }
1095
+ .marp-slide-area.marp-zoomed.marp-panning { cursor: grabbing; }
1096
+
1089
1097
  .marp-slide-area .marpit {
1098
+ /* Definite height (the pane's height is definite) so the active SVG's
1099
+ `max-height: 100%` actually clamps — without it the SVG was sized by
1100
+ width alone and overflowed vertically on wide/short panes. */
1090
1101
  width: 100%;
1091
- max-height: 100%;
1102
+ height: 100%;
1092
1103
  display: flex;
1093
- align-items: center;
1094
- justify-content: center;
1104
+ align-items: safe center;
1105
+ justify-content: safe center;
1095
1106
  }
1096
1107
 
1097
1108
  .marp-slide-area .marpit > svg[data-marpit-svg] {
1098
1109
  display: none;
1110
+ /* width:auto + height:auto + the two max-* caps give "contain": the SVG
1111
+ (intrinsic 16:9 viewBox) scales down to fit BOTH pane dimensions. */
1099
1112
  max-width: 100%;
1100
1113
  max-height: 100%;
1114
+ width: auto;
1101
1115
  height: auto;
1102
1116
  box-shadow: 0 4px 20px rgba(0,0,0,0.15);
1103
1117
  border-radius: 4px;
@@ -1107,6 +1121,14 @@ body.marp-fullscreen .marpit > svg[data-marpit-svg] {
1107
1121
  display: block;
1108
1122
  }
1109
1123
 
1124
+ /* When zoomed past fit, MarpZoom sets explicit px width/height on the active
1125
+ SVG. The caps must be lifted or they'd clamp it back to the pane size. */
1126
+ .marp-slide-area.marp-zoomed .marpit { width: auto; height: auto; }
1127
+ .marp-slide-area.marp-zoomed .marpit > svg[data-marpit-svg].active {
1128
+ max-width: none;
1129
+ max-height: none;
1130
+ }
1131
+
1110
1132
  .marp-split-handle {
1111
1133
  cursor: row-resize;
1112
1134
  background: var(--border);