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 +62 -0
- package/package.json +1 -1
- package/src/api/file.js +16 -1
- package/src/api/tree.js +109 -8
- package/src/static/app.js +446 -28
- package/src/static/index.html +1 -0
- package/src/static/lib/apiClient.js +7 -0
- package/src/static/lib/marpZoom.js +84 -0
- package/src/static/styles.css +41 -5
- package/src/watcher.js +14 -4
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
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
|
|
47
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
459
|
-
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
704
|
+
for (const [key, el] of existing) {
|
|
705
|
+
if (!used.has(key)) el.remove();
|
|
706
|
+
}
|
|
707
|
+
},
|
|
602
708
|
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/static/index.html
CHANGED
|
@@ -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
|
+
})();
|
package/src/static/styles.css
CHANGED
|
@@ -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
|
-
|
|
1084
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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) => {
|