mdv-live 0.5.20 → 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,35 @@ 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
+
8
37
  ## [0.5.20] - 2026-05-18
9
38
 
10
39
  ### Fixed — Presenter View のノート編集が毎回 STALE エラー
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mdv-live",
3
- "version": "0.5.20",
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,6 +1206,136 @@
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
  // ============================================================
@@ -1523,6 +1653,8 @@
1523
1653
  gotoSlide(index) {
1524
1654
  const slides = elements.content.querySelectorAll('.marpit > svg[data-marpit-svg]');
1525
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();
1526
1658
  slides.forEach((s, i) => s.classList.toggle('active', i === index));
1527
1659
  const panels = elements.content.querySelectorAll(
1528
1660
  '#marpNotesArea > .speaker-notes-panel'
@@ -1619,6 +1751,12 @@
1619
1751
  MarpSplitHandle.attach(splitEl, handleEl);
1620
1752
  }
1621
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
+
1622
1760
  // Add navigation controls. The nav is appended to .content (NOT
1623
1761
  // marpit) so its `position: fixed` doesn't get clipped by the
1624
1762
  // grid container's overflow:hidden rule.
@@ -1692,6 +1830,8 @@
1692
1830
  );
1693
1831
 
1694
1832
  const showSlide = (index) => {
1833
+ // Each slide opens at fit; clear any zoom from the last one.
1834
+ MarpZoom.reset();
1695
1835
  slides.forEach((slide, i) => {
1696
1836
  slide.classList.toggle('active', i === index);
1697
1837
  });
@@ -1731,6 +1871,11 @@
1731
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>';
1732
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>';
1733
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();
1734
1879
  document.body.classList.toggle('marp-fullscreen');
1735
1880
  const isFullscreen = document.body.classList.contains('marp-fullscreen');
1736
1881
  if (fullscreenBtn) {
@@ -1826,6 +1971,17 @@
1826
1971
  // shortcut and must not also open the presenter view.
1827
1972
  e.preventDefault();
1828
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();
1829
1985
  } else if (e.key === 'Escape') {
1830
1986
  e.preventDefault();
1831
1987
  if (document.body.classList.contains('marp-fullscreen')) {
@@ -1843,6 +1999,7 @@
1843
1999
  // 800ms save timer doesn't fire after the editor element is gone.
1844
2000
  InlineNotesPanel.detach();
1845
2001
  MarpSplitHandle.detach();
2002
+ MarpZoom.detach();
1846
2003
  elements.content.classList.remove('marp-viewer');
1847
2004
  document.body.classList.remove('marp-fullscreen');
1848
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
+ })();
@@ -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);