mdv-live 0.5.20 → 0.5.22

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,68 @@ 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.22] - 2026-06-02
9
+
10
+ ### Fixed — 大量のファイル/フォルダがあるディレクトリで開くとタブが固まる
11
+
12
+ ファイル数の多いディレクトリ(例: Downloads、サブフォルダが多いルート)で
13
+ mdv を開く、あるいは開いている間に一括 FS 操作(git checkout / npm install /
14
+ 一括保存)が走ると、ブラウザタブが固まることがあった。原因はすべてブラウザ側の
15
+ 左ファイルツリーの再描画(サーバー側は無罪)。2 系統あった:
16
+
17
+ - **イベント storm**: FS イベントごとに `tree_update` を連発し、クライアントが
18
+ そのたびにツリーを全再構築していた。
19
+ - **一括描画**: 1 ディレクトリの大量エントリ、および初回ロードの先読み
20
+ (lookahead)で大量の DOM ノードを一度に生成していた。
21
+
22
+ 修正:
23
+
24
+ - **storm 抑制**: クライアントの `tree_update` を 1 回の refresh に集約
25
+ (in-flight + dirty で直列化、古いレスポンスの上書き競合も解消)。watcher の
26
+ broadcast を 150ms デバウンス。`POST /api/file` はファイル新規作成時のみ
27
+ `tree_update` を送る(既存ファイル保存ではツリーを更新しない)。
28
+ - **ディレクトリ上限 + 遅延ページング**: 1 ディレクトリあたり 500 件で打ち切り、
29
+ 「もっと読み込む」で残りを `GET /api/tree/page` から取得。
30
+ - **先読み廃止**: `/api/tree` は最上位のみを返し、各フォルダは展開時に遅延
31
+ ロード。どの形のフォルダでも初回オープンの DOM ノード数を最大 1 段(~500 行)
32
+ に固定(サブフォルダ多数ルートで実測 60,206 → 166 ノード)。
33
+ - **差分描画**: ツリーを innerHTML で全破棄せず、パス + 種別キーで差分 reconcile。
34
+ 外部変更時もスクロール位置・展開状態・選択を維持。ロード済み(折りたたみ含む)
35
+ および load-more 済みのディレクトリは、表示中の範囲を再取得して反映。
36
+ - **deep-link reveal**: cap を超える位置のエントリへ URL/リンクで遷移した際も、
37
+ 親をページングして当該ノードを reveal + ハイライト。
38
+
39
+ 302 テスト全 PASS。Codex (GPT-5.5) 異種モデルクロスレビュー済み。
40
+
41
+ ## [0.5.21] - 2026-05-22
42
+
43
+ ### Fixed — Marp スライドが横長ペインで上下に見切れる
44
+
45
+ ウィンドウ表示(split モード)で、スライドペインがスライドのアスペクト比
46
+ (16:9)より横長になると、スライド(特に全面画像)の上下が見切れていた。
47
+
48
+ - 原因: スライドペイン内の `.marpit` に確定した高さがなく、active SVG の
49
+ `max-height: 100%` が無効化されていた。SVG が「幅 100%」だけで決まるため、
50
+ ペインが 16:9 より横長だと縦にあふれて上下がクリップされていた
51
+ (フルスクリーン表示は `.marpit` が `height: 100vh` を持つため影響なし)。
52
+ - 修正: `.marpit` に `height: 100%` を与え、active SVG を
53
+ `width/height: auto` + `max-width/height: 100%` の真の "contain" に変更。
54
+ ペインが横長・縦長どちらでもスライド全体が必ず収まる。
55
+
56
+ ### Added — トラックパッドのピンチズーム / パン
57
+
58
+ Marp スライド表示で、トラックパッドのピンチ操作(macOS では ctrl+wheel、
59
+ マウスの ctrl+スクロールも同様)でスライドを拡大・縮小できるように。
60
+
61
+ - 二本指スクロールでパン(ペインのネイティブ overflow スクロール)。ピンチと
62
+ パンは分離(ctrl 付き wheel のみズーム)。
63
+ - カーソル位置を中心にズーム。ズーム範囲は fit(等倍)〜6倍。
64
+ - ダブルクリック / `0` キーで全体表示(fit)に復帰。`=` / `-` キーでも増減。
65
+ - スライド送り・フルスクリーン切替・ペインのリサイズで自動的に fit に追従。
66
+ - ズーム計算(contain fit / クランプ / wheel→zoom)は DOM 非依存の
67
+ `src/static/lib/marpZoom.js` に分離し、単体テスト(`tests/test-marp-zoom.js`、
68
+ 12 件)を追加。
69
+
8
70
  ## [0.5.20] - 2026-05-18
9
71
 
10
72
  ### 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.22",
4
4
  "description": "Markdown Viewer - File tree + Live preview + Marp support + Hot reload",
5
5
  "main": "src/server.js",
6
6
  "bin": {
package/src/api/file.js CHANGED
@@ -24,6 +24,15 @@ async function resolveAndValidate(relativePath, rootDir) {
24
24
  return { valid: true, fullPath: path.join(rootDir, relativePath) };
25
25
  }
26
26
 
27
+ /**
28
+ * Check whether a file or directory exists at the given path.
29
+ * @param {string} fullPath - Absolute path
30
+ * @returns {Promise<boolean>} True if it exists
31
+ */
32
+ async function pathExists(fullPath) {
33
+ return fs.access(fullPath).then(() => true).catch(() => false);
34
+ }
35
+
27
36
  /**
28
37
  * Broadcast tree_update to all WebSocket clients
29
38
  * @param {Express} app - Express app instance
@@ -184,8 +193,14 @@ export function setupFileRoutes(app) {
184
193
  }
185
194
 
186
195
  try {
196
+ // Only a *new* file changes the tree structure; editing existing content
197
+ // does not. Broadcasting tree_update on every autosave makes all clients
198
+ // re-fetch and re-render the whole tree needlessly (a tree storm during
199
+ // normal editing). Content updates already reach watchers via the
200
+ // targeted file_update channel, so an existing-file save stays silent.
201
+ const isNewFile = !(await pathExists(fullPath));
187
202
  await fs.writeFile(fullPath, content, 'utf-8');
188
- broadcastTreeUpdate(app);
203
+ if (isNewFile) broadcastTreeUpdate(app);
189
204
  res.json({ success: true });
190
205
  } catch (err) {
191
206
  res.status(500).json({ error: err.message });
package/src/api/tree.js CHANGED
@@ -9,6 +9,34 @@ import { getRelativePath, validatePathReal } from '../utils/path.js';
9
9
 
10
10
  const IGNORED_PATTERNS = new Set(['node_modules', '__pycache__', '.git']);
11
11
  const MAX_INITIAL_DEPTH = 1;
12
+ // Cap how many children of a single directory are materialized at once. A
13
+ // directory with tens of thousands of entries would otherwise render tens of
14
+ // thousands of DOM nodes in one shot and freeze the browser tab. The remainder
15
+ // is fetched on demand via /api/tree/page ("load more").
16
+ const MAX_CHILDREN_PER_DIR = 500;
17
+
18
+ /**
19
+ * Build a "load more" sentinel node for a truncated directory listing.
20
+ * @param {string} dirRelativePath - Directory whose children were truncated ('' = root)
21
+ * @param {number} offset - Number of children already returned
22
+ * @param {number} total - Total visible children in the directory
23
+ * @returns {{type: string, path: string, offset: number, total: number, remaining: number}}
24
+ */
25
+ function moreNode(dirRelativePath, offset, total) {
26
+ return { type: 'more', path: dirRelativePath, offset, total, remaining: total - offset };
27
+ }
28
+
29
+ /**
30
+ * Read a directory's visible entries, sorted (directories first, then files).
31
+ * @param {string} dirPath - Directory path
32
+ * @returns {Promise<fs.Dirent[]>} Filtered + sorted entries
33
+ */
34
+ async function readVisibleEntries(dirPath) {
35
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
36
+ const visible = entries.filter((entry) => !shouldIgnore(entry.name));
37
+ visible.sort(sortEntries);
38
+ return visible;
39
+ }
12
40
 
13
41
  /**
14
42
  * Check if an entry should be ignored
@@ -43,12 +71,10 @@ export async function buildFileTree(dirPath, rootDir, depth = 0) {
43
71
  const items = [];
44
72
 
45
73
  try {
46
- const entries = await fs.readdir(dirPath, { withFileTypes: true });
47
- entries.sort(sortEntries);
48
-
49
- for (const entry of entries) {
50
- if (shouldIgnore(entry.name)) continue;
74
+ const visible = await readVisibleEntries(dirPath);
75
+ const shown = visible.slice(0, MAX_CHILDREN_PER_DIR);
51
76
 
77
+ for (const entry of shown) {
52
78
  const fullPath = path.join(dirPath, entry.name);
53
79
  const relativePath = getRelativePath(fullPath, rootDir);
54
80
 
@@ -70,6 +96,10 @@ export async function buildFileTree(dirPath, rootDir, depth = 0) {
70
96
  });
71
97
  }
72
98
  }
99
+
100
+ if (visible.length > MAX_CHILDREN_PER_DIR) {
101
+ items.push(moreNode(getRelativePath(dirPath, rootDir), MAX_CHILDREN_PER_DIR, visible.length));
102
+ }
73
103
  } catch (err) {
74
104
  console.error(`Error reading directory ${dirPath}:`, err);
75
105
  }
@@ -77,15 +107,59 @@ export async function buildFileTree(dirPath, rootDir, depth = 0) {
77
107
  return items;
78
108
  }
79
109
 
110
+ /**
111
+ * Read one page of a directory's direct children (no lookahead). Backs
112
+ * /api/tree/page so the remainder of a large directory can be revealed on
113
+ * demand instead of all at once.
114
+ * @param {string} dirPath - Directory path
115
+ * @param {string} rootDir - Root directory for relative paths
116
+ * @param {number} offset - Index of the first child to return
117
+ * @param {number} limit - Maximum children to return
118
+ * @returns {Promise<Array>} Page items (subdirectories unloaded), plus a
119
+ * trailing "more" sentinel when further children remain.
120
+ */
121
+ export async function readDirPage(dirPath, rootDir, offset, limit) {
122
+ const items = [];
123
+
124
+ try {
125
+ const visible = await readVisibleEntries(dirPath);
126
+ const slice = visible.slice(offset, offset + limit);
127
+
128
+ for (const entry of slice) {
129
+ const fullPath = path.join(dirPath, entry.name);
130
+ const relativePath = getRelativePath(fullPath, rootDir);
131
+
132
+ if (entry.isDirectory()) {
133
+ items.push({ type: 'directory', name: entry.name, path: relativePath, children: [], loaded: false });
134
+ } else {
135
+ items.push({ type: 'file', name: entry.name, path: relativePath, icon: getFileType(entry.name).icon });
136
+ }
137
+ }
138
+
139
+ const nextOffset = offset + slice.length;
140
+ if (visible.length > nextOffset) {
141
+ items.push(moreNode(getRelativePath(dirPath, rootDir), nextOffset, visible.length));
142
+ }
143
+ } catch (err) {
144
+ console.error(`Error reading directory page ${dirPath}:`, err);
145
+ }
146
+
147
+ return items;
148
+ }
149
+
80
150
  /**
81
151
  * Setup file tree routes
82
152
  * @param {Express} app - Express app instance
83
153
  */
84
154
  export function setupTreeRoutes(app) {
85
- // Get full tree
155
+ // Get the top level of the tree. Direct children only (subdirectories come
156
+ // back unloaded), same as expand. Without this depth argument the initial
157
+ // load eagerly materialized one level of grandchildren into hidden DOM — a
158
+ // root with many large subdirectories could mount tens of thousands of nodes
159
+ // up front and freeze the tab. Each folder now loads its children on expand.
86
160
  app.get('/api/tree', async (req, res) => {
87
161
  try {
88
- const tree = await buildFileTree(app.locals.rootDir, app.locals.rootDir);
162
+ const tree = await buildFileTree(app.locals.rootDir, app.locals.rootDir, MAX_INITIAL_DEPTH);
89
163
  res.json(tree);
90
164
  } catch (err) {
91
165
  res.status(500).json({ error: err.message });
@@ -106,12 +180,39 @@ export function setupTreeRoutes(app) {
106
180
  }
107
181
 
108
182
  const fullPath = path.join(app.locals.rootDir, relativePath);
109
- const children = await buildFileTree(fullPath, app.locals.rootDir, 0);
183
+ // Direct children only: start at the depth cap so subdirectories come
184
+ // back unloaded (loaded:false, children:[]). Lazy-loading one level per
185
+ // expand avoids reading a whole grandchild level on every expand of a
186
+ // wide directory (which made expanding/restoring large trees expensive).
187
+ const children = await buildFileTree(fullPath, app.locals.rootDir, MAX_INITIAL_DEPTH);
110
188
  res.json(children);
111
189
  } catch (err) {
112
190
  res.status(500).json({ error: err.message });
113
191
  }
114
192
  });
193
+
194
+ // Load one more page of a large directory's children (lazy pagination).
195
+ app.get('/api/tree/page', async (req, res) => {
196
+ try {
197
+ const relativePath = typeof req.query.path === 'string' ? req.query.path : '';
198
+ const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
199
+ const requested = parseInt(req.query.limit, 10) || MAX_CHILDREN_PER_DIR;
200
+ const limit = Math.min(MAX_CHILDREN_PER_DIR, Math.max(1, requested));
201
+
202
+ // '' = root (always inside rootDir); any other path must validate.
203
+ if (relativePath && !await validatePathReal(relativePath, app.locals.rootDir)) {
204
+ return res.status(403).json({ error: 'Access denied' });
205
+ }
206
+
207
+ const dirPath = relativePath
208
+ ? path.join(app.locals.rootDir, relativePath)
209
+ : app.locals.rootDir;
210
+ const items = await readDirPage(dirPath, app.locals.rootDir, offset, limit);
211
+ res.json(items);
212
+ } catch (err) {
213
+ res.status(500).json({ error: err.message });
214
+ }
215
+ });
115
216
  }
116
217
 
117
218
  export default setupTreeRoutes;
package/src/static/app.js CHANGED
@@ -455,8 +455,10 @@
455
455
  if (data.type === 'file_update' && state.activeTabIndex >= 0) {
456
456
  this.handleFileUpdate(data);
457
457
  } else if (data.type === 'tree_update') {
458
- // tree_update を受信したらAPIから最新ツリーを取得
459
- await FileTreeManager.refresh();
458
+ // Coalesce bursts: bulk FS ops (git checkout, npm install)
459
+ // emit hundreds of tree_update frames. Schedule a single
460
+ // refresh instead of refreshing once per frame.
461
+ FileTreeManager.scheduleRefresh();
460
462
  }
461
463
  };
462
464
 
@@ -565,7 +567,38 @@
565
567
  elements.fileTree.innerHTML = '<div style="padding: 16px; color: var(--text-muted);">読み込みに失敗しました。<br><button onclick="location.reload()" style="margin-top: 8px; cursor: pointer;">再読み込み</button></div>';
566
568
  },
567
569
 
570
+ // --- tree refresh coalescing --------------------------------------
571
+ // A burst of tree_update frames (a bulk FS operation emits hundreds)
572
+ // must collapse into a single tree refresh. Refreshing once per frame
573
+ // tears down and rebuilds the whole tree repeatedly and freezes the
574
+ // tab. We also never run two refreshes concurrently: the old code did
575
+ // `await refresh()` per ws message, so overlapping fetches could let a
576
+ // stale response overwrite a newer tree. `_refreshInFlight` serializes
577
+ // them; `_refreshDirty` runs exactly one more pass for events that
578
+ // arrived mid-flight.
579
+ _refreshTimer: null,
580
+ _refreshInFlight: false,
581
+ _refreshDirty: false,
582
+
583
+ scheduleRefresh() {
584
+ if (this._refreshInFlight) {
585
+ // A refresh is running; remember to re-run once after it ends.
586
+ this._refreshDirty = true;
587
+ return;
588
+ }
589
+ if (this._refreshTimer) return; // burst already scheduled
590
+ this._refreshTimer = setTimeout(() => {
591
+ this._refreshTimer = null;
592
+ this.refresh();
593
+ }, 50);
594
+ },
595
+
568
596
  async refresh() {
597
+ if (this._refreshInFlight) {
598
+ this._refreshDirty = true;
599
+ return;
600
+ }
601
+ this._refreshInFlight = true;
569
602
  try {
570
603
  const response = await MDVApi.fetchTree();
571
604
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -573,52 +606,248 @@
573
606
  await this.update(tree);
574
607
  } catch (e) {
575
608
  console.error('Failed to refresh tree:', e);
609
+ } finally {
610
+ this._refreshInFlight = false;
611
+ if (this._refreshDirty) {
612
+ this._refreshDirty = false;
613
+ this.scheduleRefresh();
614
+ }
576
615
  }
577
616
  },
578
617
 
579
618
  async update(tree) {
580
- // 展開済みかつ読み込み済みのパスを保存
581
- const expandedPaths = new Set();
582
- document.querySelectorAll('.tree-item').forEach(item => {
583
- const children = item.querySelector('.tree-children');
584
- if (children && !children.classList.contains('collapsed') && item.dataset.loaded === 'true') {
585
- expandedPaths.add(item.dataset.path);
586
- }
587
- });
619
+ // Keyed, in-place reconcile instead of an innerHTML teardown. The
620
+ // old code rebuilt the whole tree on every external change, which
621
+ // (a) flickered, (b) lost scroll position, and (c) collapsed any
622
+ // folder deeper than the lookahead because those nodes were not in
623
+ // the rebuilt DOM. Reconciling by path key preserves scroll,
624
+ // selection, and already-expanded subtrees, and only touches nodes
625
+ // that actually changed.
626
+ const treeEl = elements.fileTree;
627
+ const prevScroll = treeEl.scrollTop;
628
+
629
+ // `tree` is the first capped page of the root. If the user paged the
630
+ // root past it ("load more"), refetch the full shown range so
631
+ // reconcile keeps AND refreshes those extra rows rather than pruning
632
+ // them or going stale.
633
+ const rootCapped = tree.length > 0 && tree[tree.length - 1].type === 'more';
634
+ const rootRows = this.countItemRows(treeEl);
635
+ const rootList = (rootCapped && rootRows > tree.length - 1)
636
+ ? await this.fetchChildrenUpTo('', rootRows)
637
+ : tree;
638
+ this.reconcile(treeEl, rootList);
639
+
640
+ // `/api/tree` only carries the top level, so every directory whose
641
+ // children have been loaded must be refreshed explicitly to pick up
642
+ // changes. Done in parallel and reconciled in place (keeps each
643
+ // folder's expansion state).
644
+ await this.refreshLoaded();
645
+
646
+ treeEl.scrollTop = prevScroll;
647
+ this.updateHighlight();
648
+ },
588
649
 
589
- elements.fileTree.innerHTML = this.renderItems(tree);
650
+ nodeKey(el) {
651
+ if (el.classList && el.classList.contains('tree-item')) {
652
+ // Include kind so a file and a directory at the same path get
653
+ // different keys: if an entry is replaced by one of the other
654
+ // kind, reconcile treats it as remove+add and rebuilds correct
655
+ // markup instead of reusing a node that can't toggle kind.
656
+ const kind = el.querySelector(':scope > .tree-children') ? 'd' : 'f';
657
+ return 'item:' + kind + ':' + el.dataset.path;
658
+ }
659
+ if (el.classList && el.classList.contains('tree-more')) return 'more:' + (el.dataset.dir || '');
660
+ return null;
661
+ },
662
+
663
+ itemKey(item) {
664
+ if (item.type === 'more') return 'more:' + (item.path || '');
665
+ const kind = item.type === 'directory' ? 'd' : 'f';
666
+ return 'item:' + kind + ':' + item.path;
667
+ },
668
+
669
+ // Reconcile `container`'s direct children against `items` by path key:
670
+ // reuse matching nodes (preserving their subtrees), create new ones,
671
+ // remove deleted ones, and fix ordering — without tearing the list down.
672
+ reconcile(container, items) {
673
+ const list = Array.isArray(items) ? items : [];
674
+
675
+ // Callers always pass a list covering at least the rows currently
676
+ // shown (paged directories are refetched in full via
677
+ // fetchChildrenUpTo before reconciling), so the keyed diff below can
678
+ // prune freely without dropping load-more'd rows.
679
+ const existing = new Map();
680
+ for (const el of Array.from(container.children)) {
681
+ const key = this.nodeKey(el);
682
+ if (key) existing.set(key, el);
683
+ }
684
+
685
+ const used = new Set();
686
+ let prev = null;
687
+ for (const item of list) {
688
+ const key = this.itemKey(item);
689
+ let el = existing.get(key);
690
+ if (el && !used.has(key)) {
691
+ this.updateNode(el, item);
692
+ } else {
693
+ el = this.createNode(item);
694
+ }
695
+ used.add(key);
590
696
 
591
- // 展開済みディレクトリを復元(子要素も再取得)
592
- for (const path of expandedPaths) {
593
- const item = document.querySelector(`.tree-item[data-path="${CSS.escape(path)}"]`);
594
- if (item) {
595
- const children = item.querySelector('.tree-children');
596
- const chevron = item.querySelector('.chevron');
697
+ const desiredNext = prev ? prev.nextSibling : container.firstChild;
698
+ if (el !== desiredNext) {
699
+ container.insertBefore(el, desiredNext);
700
+ }
701
+ prev = el;
702
+ }
597
703
 
598
- // 子要素を再取得
599
- if (children && item.dataset.loaded !== 'true') {
600
- await this.expandDirectory(path, children);
601
- }
704
+ for (const [key, el] of existing) {
705
+ if (!used.has(key)) el.remove();
706
+ }
707
+ },
602
708
 
603
- if (children) children.classList.remove('collapsed');
604
- if (chevron) chevron.classList.add('expanded');
709
+ updateNode(el, item) {
710
+ if (item.type === 'directory') {
711
+ // Only refresh the child level when the new data carries it
712
+ // (loaded:true). When it doesn't (loaded:false), leave the
713
+ // existing — possibly deeper-expanded — subtree untouched and
714
+ // never downgrade an already-loaded directory.
715
+ if (item.loaded === true) {
716
+ el.dataset.loaded = 'true';
717
+ const childrenBox = el.querySelector(':scope > .tree-children');
718
+ if (childrenBox) this.reconcile(childrenBox, item.children || []);
719
+ }
720
+ } else if (item.type === 'more') {
721
+ el.dataset.offset = item.offset;
722
+ el.dataset.total = item.total;
723
+ const name = el.querySelector('.name');
724
+ if (name) {
725
+ const remaining = item.remaining != null ? item.remaining : (item.total - item.offset);
726
+ name.textContent = `… 残り ${remaining} 件を表示`;
605
727
  }
606
728
  }
729
+ // files: name/icon are keyed by path, so a rename is add + remove
730
+ },
607
731
 
608
- this.updateHighlight();
732
+ createNode(item) {
733
+ const html = item.type === 'directory' ? this.renderDirectory(item)
734
+ : item.type === 'more' ? this.renderMore(item)
735
+ : this.renderFile(item);
736
+ const tmp = document.createElement('div');
737
+ tmp.innerHTML = html.trim();
738
+ return tmp.firstElementChild;
739
+ },
740
+
741
+ countItemRows(container) {
742
+ let n = 0;
743
+ for (const el of container.children) {
744
+ if (el.classList && el.classList.contains('tree-item')) n++;
745
+ }
746
+ return n;
747
+ },
748
+
749
+ // Fetch a directory's children up to at least `minCount` rows, paging
750
+ // through /api/tree/page. Used to refresh a directory the user has
751
+ // "load more"d past the cap: re-read exactly what is currently shown
752
+ // (plus a trailing "more" row if further entries remain) so reconcile
753
+ // can apply adds/deletes without dropping the loaded rows. For a normal
754
+ // (<= one page) directory this is a single request. `dirPath` is '' for
755
+ // the root.
756
+ async fetchChildrenUpTo(dirPath, minCount) {
757
+ let items = [];
758
+ let offset = 0;
759
+ // Bounded as a safety net; each page advances the offset.
760
+ for (let guard = 0; guard < 1000; guard++) {
761
+ const response = await MDVApi.pageTree(dirPath, offset);
762
+ if (!response.ok) break;
763
+ const page = await response.json();
764
+ let more = null;
765
+ if (page.length && page[page.length - 1].type === 'more') {
766
+ more = page.pop();
767
+ }
768
+ items = items.concat(page);
769
+ if (!more) return items; // directory fully read
770
+ if (items.length >= minCount) { // covered what is shown
771
+ items.push(more); // keep the "load more" row
772
+ return items;
773
+ }
774
+ offset = more.offset;
775
+ }
776
+ return items;
777
+ },
778
+
779
+ // Refresh every directory whose children have been loaded — expanded or
780
+ // collapsed. A collapsed-but-loaded folder still holds cached rows in
781
+ // the DOM; without refetching it here, a file added or removed inside it
782
+ // stays stale until reload (the top-level payload no longer carries a
783
+ // lookahead that refreshed those folders for free). reconcile keeps each
784
+ // folder's expanded descendants intact.
785
+ async refreshLoaded() {
786
+ const loaded = [];
787
+ document.querySelectorAll('.tree-item').forEach(item => {
788
+ const box = item.querySelector(':scope > .tree-children');
789
+ if (item.dataset.loaded === 'true' && box) {
790
+ loaded.push({ path: item.dataset.path, count: this.countItemRows(box) });
791
+ }
792
+ });
793
+
794
+ await Promise.all(loaded.map(async ({ path: dirPath, count }) => {
795
+ try {
796
+ // Re-read exactly what is shown (paging if it was load-more'd)
797
+ // so reconcile refreshes the directory without dropping rows.
798
+ const children = await this.fetchChildrenUpTo(dirPath, count);
799
+ const item = document.querySelector(`.tree-item[data-path="${CSS.escape(dirPath)}"]`);
800
+ const box = item && item.querySelector(':scope > .tree-children');
801
+ if (box) this.reconcile(box, children);
802
+ } catch (e) {
803
+ // best-effort refresh of one loaded directory; ignore
804
+ }
805
+ }));
609
806
  },
610
807
 
611
808
  renderItems(items) {
612
809
  if (!items || items.length === 0) return '';
613
810
 
614
811
  return items.map(item => {
615
- if (item.type === 'directory') {
616
- return this.renderDirectory(item);
617
- }
812
+ if (item.type === 'directory') return this.renderDirectory(item);
813
+ if (item.type === 'more') return this.renderMore(item);
618
814
  return this.renderFile(item);
619
815
  }).join('');
620
816
  },
621
817
 
818
+ // "Load more" row shown when a directory has more children than the
819
+ // per-directory cap. Clicking it fetches the next page and splices the
820
+ // rows in. Not a .tree-item, so it never matches the file-open / drag /
821
+ // context-menu delegation.
822
+ renderMore(item) {
823
+ const remaining = item.remaining != null ? item.remaining : (item.total - item.offset);
824
+ const safeDir = escapeHtml(item.path || '');
825
+ return `
826
+ <div class="tree-more" data-dir="${safeDir}" data-offset="${item.offset}" data-total="${item.total}" onclick="MDV.loadMore(this)">
827
+ <span class="name">… 残り ${remaining} 件を表示</span>
828
+ </div>
829
+ `;
830
+ },
831
+
832
+ async loadMore(el) {
833
+ if (el.classList.contains('loading')) return;
834
+ el.classList.add('loading');
835
+ const dir = el.dataset.dir || '';
836
+ const offset = parseInt(el.dataset.offset, 10) || 0;
837
+ try {
838
+ const response = await MDVApi.pageTree(dir, offset);
839
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
840
+ const items = await response.json();
841
+ // Splice the new rows in before this node, then drop it. The
842
+ // page response carries its own trailing "more" row if needed.
843
+ el.insertAdjacentHTML('beforebegin', this.renderItems(items));
844
+ el.remove();
845
+ } catch (e) {
846
+ console.error('Failed to load more:', e);
847
+ el.classList.remove('loading');
848
+ }
849
+ },
850
+
622
851
  renderDirectory(item) {
623
852
  const loaded = item.loaded !== false;
624
853
  const safePath = escapeHtml(item.path);
@@ -655,6 +884,32 @@
655
884
  }
656
885
  },
657
886
 
887
+ // Page a directory's listing until `targetPath` appears, so URL/link
888
+ // navigation can reveal an entry that sorts past the per-directory cap.
889
+ // The parent must already be loaded (expandToPath processes parents
890
+ // first). Returns the node, or null if it genuinely isn't there.
891
+ async revealInParent(targetPath) {
892
+ const slash = targetPath.lastIndexOf('/');
893
+ const parentPath = slash >= 0 ? targetPath.slice(0, slash) : '';
894
+ const container = parentPath
895
+ ? document.querySelector(`.tree-item[data-path="${CSS.escape(parentPath)}"] > .tree-children`)
896
+ : elements.fileTree;
897
+ if (!container) return null;
898
+
899
+ const sel = `:scope > .tree-item[data-path="${CSS.escape(targetPath)}"]`;
900
+ for (let guard = 0; guard < 1000; guard++) {
901
+ const found = container.querySelector(sel);
902
+ if (found) return found;
903
+ const more = container.querySelector(':scope > .tree-more');
904
+ if (!more) return null; // listing exhausted; target not present
905
+ const offsetBefore = more.dataset.offset;
906
+ await this.loadMore(more);
907
+ const moreAfter = container.querySelector(':scope > .tree-more');
908
+ if (moreAfter && moreAfter.dataset.offset === offsetBefore) return null; // no progress
909
+ }
910
+ return null;
911
+ },
912
+
658
913
  async expandToPath(filePath) {
659
914
  // パスを分割して順番に展開
660
915
  const parts = filePath.split('/');
@@ -663,7 +918,12 @@
663
918
  for (const part of parts) {
664
919
  currentPath = currentPath ? `${currentPath}/${part}` : part;
665
920
 
666
- const item = document.querySelector(`.tree-item[data-path="${CSS.escape(currentPath)}"]`);
921
+ let item = document.querySelector(`.tree-item[data-path="${CSS.escape(currentPath)}"]`);
922
+ if (!item) {
923
+ // The node may sort past its parent's per-directory cap and
924
+ // not be rendered yet. Page the parent in until it appears.
925
+ item = await this.revealInParent(currentPath);
926
+ }
667
927
  if (!item) continue;
668
928
 
669
929
  const children = item.querySelector('.tree-children');
@@ -1206,6 +1466,136 @@
1206
1466
  }
1207
1467
  };
1208
1468
 
1469
+ // ============================================================
1470
+ // Marp Slide Zoom (trackpad pinch-to-zoom + pan)
1471
+ // ============================================================
1472
+ //
1473
+ // At fit (zoom === 1) the slide is sized entirely by the CSS "contain"
1474
+ // rules so the whole slide — image and all — is always visible. Zooming
1475
+ // past 1 switches the active SVG to explicit pixel dimensions
1476
+ // (fitSize * zoom); the pane's native overflow then lets a two-finger
1477
+ // scroll pan around the enlarged slide. macOS trackpad pinch arrives as a
1478
+ // `wheel` event with `ctrlKey` set, so ctrl+scroll on a mouse zooms too.
1479
+ const MarpZoom = {
1480
+ area: null,
1481
+ zoom: 1,
1482
+ onWheel: null,
1483
+ onDblClick: null,
1484
+ ro: null,
1485
+
1486
+ // Pure zoom math lives in lib/marpZoom.js (globalThis.MDVMarpZoom) so
1487
+ // it can be unit-tested without a DOM. If that script failed to load,
1488
+ // the CSS-only fit still works; we just skip wiring the gestures.
1489
+ lib() { return (typeof globalThis !== 'undefined') ? globalThis.MDVMarpZoom : null; },
1490
+
1491
+ init(area) {
1492
+ this.detach();
1493
+ if (!this.lib()) return;
1494
+ this.area = area;
1495
+ this.zoom = 1;
1496
+ this.onWheel = (e) => {
1497
+ // Plain two-finger scroll is left to the pane so it pans the
1498
+ // zoomed slide natively. Only a pinch (ctrlKey) zooms.
1499
+ if (!e.ctrlKey) return;
1500
+ e.preventDefault();
1501
+ this.zoomTo(this.lib().zoomForWheel(this.zoom, e.deltaY), e.clientX, e.clientY);
1502
+ };
1503
+ // Double-click anywhere on the slide snaps back to fit.
1504
+ this.onDblClick = () => this.reset();
1505
+ area.addEventListener('wheel', this.onWheel, { passive: false });
1506
+ area.addEventListener('dblclick', this.onDblClick);
1507
+ // Re-apply the pixel size when the pane is resized (window resize,
1508
+ // dragging the notes splitter) so a zoomed slide tracks the new
1509
+ // fit instead of freezing at a stale size.
1510
+ if (typeof ResizeObserver !== 'undefined') {
1511
+ this.ro = new ResizeObserver(() => {
1512
+ if (!this.lib().isFit(this.zoom)) this.zoomTo(this.zoom);
1513
+ });
1514
+ this.ro.observe(area);
1515
+ }
1516
+ },
1517
+
1518
+ detach() {
1519
+ if (this.area && this.onWheel) {
1520
+ this.area.removeEventListener('wheel', this.onWheel);
1521
+ this.area.removeEventListener('dblclick', this.onDblClick);
1522
+ }
1523
+ if (this.ro) { this.ro.disconnect(); this.ro = null; }
1524
+ this.area = null;
1525
+ this.onWheel = null;
1526
+ this.onDblClick = null;
1527
+ this.zoom = 1;
1528
+ },
1529
+
1530
+ activeSvg() {
1531
+ return this.area
1532
+ ? this.area.querySelector('.marpit > svg[data-marpit-svg].active')
1533
+ : null;
1534
+ },
1535
+
1536
+ // Slide dimensions at fit (zoom 1), resolved the same way the CSS
1537
+ // "contain" rule does — so the 1.0 → 1.01 transition doesn't jump.
1538
+ fitSize(svg) {
1539
+ const cs = getComputedStyle(this.area);
1540
+ const padX = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight);
1541
+ const padY = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
1542
+ const aw = this.area.clientWidth - padX;
1543
+ const ah = this.area.clientHeight - padY;
1544
+ const vb = svg.viewBox && svg.viewBox.baseVal;
1545
+ const ratio = (vb && vb.width) ? vb.height / vb.width : 9 / 16;
1546
+ return this.lib().containFit(aw, ah, ratio);
1547
+ },
1548
+
1549
+ // Apply `z` around a focal point (defaults to the pane centre). The
1550
+ // before/after rects already fold in centring and scroll offsets, so
1551
+ // the point under the cursor stays put as the slide grows.
1552
+ zoomTo(z, focalX, focalY) {
1553
+ const svg = this.activeSvg();
1554
+ if (!svg) return;
1555
+ z = this.lib().clampZoom(z);
1556
+ if (this.lib().isFit(z)) { this.reset(); return; }
1557
+
1558
+ if (focalX == null) {
1559
+ const r = this.area.getBoundingClientRect();
1560
+ focalX = r.left + r.width / 2;
1561
+ focalY = r.top + r.height / 2;
1562
+ }
1563
+ const before = svg.getBoundingClientRect();
1564
+ const relX = before.width ? (focalX - before.left) / before.width : 0.5;
1565
+ const relY = before.height ? (focalY - before.top) / before.height : 0.5;
1566
+
1567
+ this.zoom = z;
1568
+ const fit = this.fitSize(svg);
1569
+ svg.style.width = (fit.w * z) + 'px';
1570
+ svg.style.height = (fit.h * z) + 'px';
1571
+ this.area.classList.add('marp-zoomed');
1572
+
1573
+ const after = svg.getBoundingClientRect();
1574
+ this.area.scrollLeft += (after.left + relX * after.width) - focalX;
1575
+ this.area.scrollTop += (after.top + relY * after.height) - focalY;
1576
+ },
1577
+
1578
+ // Step zoom for keyboard (+/-): dir > 0 zooms in, else out.
1579
+ nudge(dir) {
1580
+ if (!this.lib()) return;
1581
+ this.zoomTo(this.lib().zoomForStep(this.zoom, dir));
1582
+ },
1583
+
1584
+ // Back to fit: clear the pixel sizing on every slide (the active one
1585
+ // may have changed since we zoomed) and hand sizing back to CSS.
1586
+ reset() {
1587
+ this.zoom = 1;
1588
+ if (!this.area) return;
1589
+ this.area.querySelectorAll('.marpit > svg[data-marpit-svg]').forEach(s => {
1590
+ s.style.width = '';
1591
+ s.style.height = '';
1592
+ });
1593
+ this.area.classList.remove('marp-zoomed');
1594
+ this.area.scrollLeft = 0;
1595
+ this.area.scrollTop = 0;
1596
+ }
1597
+ };
1598
+
1209
1599
  // ============================================================
1210
1600
  // Presenter View (separate window with speaker notes)
1211
1601
  // ============================================================
@@ -1523,6 +1913,8 @@
1523
1913
  gotoSlide(index) {
1524
1914
  const slides = elements.content.querySelectorAll('.marpit > svg[data-marpit-svg]');
1525
1915
  if (!slides.length || index < 0 || index >= slides.length) return;
1916
+ // Each slide opens at fit; clear any zoom carried from the last one.
1917
+ MarpZoom.reset();
1526
1918
  slides.forEach((s, i) => s.classList.toggle('active', i === index));
1527
1919
  const panels = elements.content.querySelectorAll(
1528
1920
  '#marpNotesArea > .speaker-notes-panel'
@@ -1619,6 +2011,12 @@
1619
2011
  MarpSplitHandle.attach(splitEl, handleEl);
1620
2012
  }
1621
2013
 
2014
+ // Enable trackpad pinch-to-zoom / pan on the slide pane.
2015
+ const slideArea = document.getElementById('marpSlideArea');
2016
+ if (slideArea) {
2017
+ MarpZoom.init(slideArea);
2018
+ }
2019
+
1622
2020
  // Add navigation controls. The nav is appended to .content (NOT
1623
2021
  // marpit) so its `position: fixed` doesn't get clipped by the
1624
2022
  // grid container's overflow:hidden rule.
@@ -1692,6 +2090,8 @@
1692
2090
  );
1693
2091
 
1694
2092
  const showSlide = (index) => {
2093
+ // Each slide opens at fit; clear any zoom from the last one.
2094
+ MarpZoom.reset();
1695
2095
  slides.forEach((slide, i) => {
1696
2096
  slide.classList.toggle('active', i === index);
1697
2097
  });
@@ -1731,6 +2131,11 @@
1731
2131
  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
2132
  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
2133
  const toggleFullscreen = () => {
2134
+ // Snap back to fit across the transition: the fullscreen and
2135
+ // windowed panes have different sizes, and the fullscreen CSS
2136
+ // owns the fit there, so a leftover pixel zoom would mis-size
2137
+ // the slide. The user can re-pinch on either side.
2138
+ MarpZoom.reset();
1734
2139
  document.body.classList.toggle('marp-fullscreen');
1735
2140
  const isFullscreen = document.body.classList.contains('marp-fullscreen');
1736
2141
  if (fullscreenBtn) {
@@ -1826,6 +2231,17 @@
1826
2231
  // shortcut and must not also open the presenter view.
1827
2232
  e.preventDefault();
1828
2233
  PresenterView.open();
2234
+ } else if ((e.key === '+' || e.key === '=') && !e.metaKey && !e.ctrlKey) {
2235
+ // Keyboard zoom (centre-anchored) mirrors the pinch gesture.
2236
+ // Skip Cmd/Ctrl which the browser owns for page zoom.
2237
+ e.preventDefault();
2238
+ MarpZoom.nudge(1);
2239
+ } else if ((e.key === '-' || e.key === '_') && !e.metaKey && !e.ctrlKey) {
2240
+ e.preventDefault();
2241
+ MarpZoom.nudge(-1);
2242
+ } else if (e.key === '0' && !e.metaKey && !e.ctrlKey) {
2243
+ e.preventDefault();
2244
+ MarpZoom.reset();
1829
2245
  } else if (e.key === 'Escape') {
1830
2246
  e.preventDefault();
1831
2247
  if (document.body.classList.contains('marp-fullscreen')) {
@@ -1843,6 +2259,7 @@
1843
2259
  // 800ms save timer doesn't fire after the editor element is gone.
1844
2260
  InlineNotesPanel.detach();
1845
2261
  MarpSplitHandle.detach();
2262
+ MarpZoom.detach();
1846
2263
  elements.content.classList.remove('marp-viewer');
1847
2264
  document.body.classList.remove('marp-fullscreen');
1848
2265
  if (marpKeyHandler) {
@@ -3360,6 +3777,7 @@
3360
3777
  openFile: (path) => TabManager.open(path),
3361
3778
  switchTab: (index) => TabManager.switch(index),
3362
3779
  closeTab: (index) => TabManager.close(index),
3780
+ loadMore: (element) => FileTreeManager.loadMore(element),
3363
3781
  toggleDirectory: async (element) => {
3364
3782
  const chevron = element.querySelector('.chevron');
3365
3783
  const children = element.nextElementSibling;
@@ -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>
@@ -39,6 +39,12 @@
39
39
  function expandTree(path) {
40
40
  return fetch('/api/tree/expand?path=' + encodeURIComponent(path));
41
41
  }
42
+ /** GET /api/tree/page — next page of a large directory's children */
43
+ function pageTree(path, offset, limit) {
44
+ const params = new URLSearchParams({ path: path || '', offset: String(offset || 0) });
45
+ if (limit) params.set('limit', String(limit));
46
+ return fetch('/api/tree/page?' + params.toString());
47
+ }
42
48
  function fetchFile(path) {
43
49
  return fetch('/api/file?path=' + encodeURIComponent(path));
44
50
  }
@@ -65,6 +71,7 @@
65
71
  saveMarpNote,
66
72
  fetchTree,
67
73
  expandTree,
74
+ pageTree,
68
75
  fetchFile,
69
76
  saveFile,
70
77
  fetchInfo,
@@ -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
+ })();
@@ -166,6 +166,20 @@ body {
166
166
  .tree-item-content .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
167
167
  .tree-children { padding-left: 16px; }
168
168
  .tree-children.collapsed { display: none; }
169
+
170
+ /* "Load more" row for directories with more children than the per-dir cap */
171
+ .tree-more {
172
+ display: flex;
173
+ align-items: center;
174
+ padding: 6px 12px 6px 34px;
175
+ cursor: pointer;
176
+ font-size: 12px;
177
+ font-style: italic;
178
+ color: var(--text-muted);
179
+ user-select: none;
180
+ }
181
+ .tree-more:hover { background: var(--bg-tertiary); color: var(--text-primary); }
182
+ .tree-more.loading { opacity: 0.5; cursor: default; }
169
183
  .chevron { transition: transform 0.15s; }
170
184
  .chevron.expanded { transform: rotate(90deg); }
171
185
  .chevron.loading { animation: spin 0.6s linear infinite; }
@@ -1080,24 +1094,38 @@ body.marp-fullscreen .marpit > svg[data-marpit-svg] {
1080
1094
  .marp-slide-area {
1081
1095
  overflow: auto;
1082
1096
  display: flex;
1083
- align-items: center;
1084
- justify-content: center;
1097
+ /* `safe` keeps the slide reachable when zoomed larger than the pane:
1098
+ plain `center` would push the overflowing top/left out of the scroll
1099
+ range so you could never scroll back to them. */
1100
+ align-items: safe center;
1101
+ justify-content: safe center;
1085
1102
  padding: 20px;
1086
1103
  min-height: 0;
1087
1104
  }
1088
1105
 
1106
+ /* The pane is the trackpad-zoom focus target. Pinch (ctrl+wheel) zooms the
1107
+ slide; two-finger scroll then pans via the pane's native overflow. */
1108
+ .marp-slide-area.marp-zoomed { cursor: grab; }
1109
+ .marp-slide-area.marp-zoomed.marp-panning { cursor: grabbing; }
1110
+
1089
1111
  .marp-slide-area .marpit {
1112
+ /* Definite height (the pane's height is definite) so the active SVG's
1113
+ `max-height: 100%` actually clamps — without it the SVG was sized by
1114
+ width alone and overflowed vertically on wide/short panes. */
1090
1115
  width: 100%;
1091
- max-height: 100%;
1116
+ height: 100%;
1092
1117
  display: flex;
1093
- align-items: center;
1094
- justify-content: center;
1118
+ align-items: safe center;
1119
+ justify-content: safe center;
1095
1120
  }
1096
1121
 
1097
1122
  .marp-slide-area .marpit > svg[data-marpit-svg] {
1098
1123
  display: none;
1124
+ /* width:auto + height:auto + the two max-* caps give "contain": the SVG
1125
+ (intrinsic 16:9 viewBox) scales down to fit BOTH pane dimensions. */
1099
1126
  max-width: 100%;
1100
1127
  max-height: 100%;
1128
+ width: auto;
1101
1129
  height: auto;
1102
1130
  box-shadow: 0 4px 20px rgba(0,0,0,0.15);
1103
1131
  border-radius: 4px;
@@ -1107,6 +1135,14 @@ body.marp-fullscreen .marpit > svg[data-marpit-svg] {
1107
1135
  display: block;
1108
1136
  }
1109
1137
 
1138
+ /* When zoomed past fit, MarpZoom sets explicit px width/height on the active
1139
+ SVG. The caps must be lifted or they'd clamp it back to the pane size. */
1140
+ .marp-slide-area.marp-zoomed .marpit { width: auto; height: auto; }
1141
+ .marp-slide-area.marp-zoomed .marpit > svg[data-marpit-svg].active {
1142
+ max-width: none;
1143
+ max-height: none;
1144
+ }
1145
+
1110
1146
  .marp-split-handle {
1111
1147
  cursor: row-resize;
1112
1148
  background: var(--border);
package/src/watcher.js CHANGED
@@ -56,11 +56,21 @@ export function setupWatcher(rootDir, wss, options = {}) {
56
56
  return path.relative(rootDir, filePath).split(path.sep).join('/');
57
57
  }
58
58
 
59
+ // Coalesce bursts: a bulk FS operation (git checkout, npm install, unzip)
60
+ // fires many add/unlink events. Emit at most one tree_update per debounce
61
+ // window so clients don't re-fetch and re-render the whole tree per event.
62
+ const TREE_UPDATE_DEBOUNCE_MS = 150;
63
+ let treeUpdateTimer = null;
64
+
59
65
  function broadcastTreeUpdate() {
60
- wss.broadcast({
61
- type: 'tree_update',
62
- tree: null
63
- });
66
+ if (treeUpdateTimer) return; // a broadcast is already scheduled for this burst
67
+ treeUpdateTimer = setTimeout(() => {
68
+ treeUpdateTimer = null;
69
+ wss.broadcast({
70
+ type: 'tree_update',
71
+ tree: null
72
+ });
73
+ }, TREE_UPDATE_DEBOUNCE_MS);
64
74
  }
65
75
 
66
76
  watcher.on('change', async (filePath) => {