mdv-live 0.5.16 → 0.5.17
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 +42 -0
- package/package.json +1 -1
- package/src/static/app.js +407 -40
- package/src/static/lib/apiClient.js +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,48 @@ 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.17] - 2026-05-10
|
|
9
|
+
|
|
10
|
+
### Added — Edit-mode Autosave
|
|
11
|
+
|
|
12
|
+
Markdown エディタを **入力 → 1500ms debounce で自動保存** に。これまで Cmd+S を
|
|
13
|
+
押し忘れると未保存で View に戻すと内容が消える挙動だった。
|
|
14
|
+
|
|
15
|
+
- `input` で 1500ms debounce → `EditorManager.save()` が `/api/file` に POST
|
|
16
|
+
- toolbar status の遷移: `Modified → Saving... → Saved! → (2s 後) Ready`
|
|
17
|
+
- **Cmd+S** は引き続き使えて、押すと pending な debounce を即 flush
|
|
18
|
+
- View 切替 / タブ切替 / 別ファイル open 時に **flush + await** で未保存破棄事故を防止
|
|
19
|
+
- save 中の連続 input は serialize(chain)。古い save が後着して新しい save を
|
|
20
|
+
上書きしないよう、各 save 自身の Promise を chain tail にして flush は末尾まで drain
|
|
21
|
+
- save 失敗時は `hide()` / `switch()` / `open()` がすべて navigation を中止して
|
|
22
|
+
Edit mode を維持。toolbar に `Error: ...` を表示してリトライ余地を残す
|
|
23
|
+
- discard-on-close ダイアログ: AbortController で in-flight POST も abort。
|
|
24
|
+
ただしサーバーが既に request 受信済みの race window は残るため、ダイアログ
|
|
25
|
+
メッセージで「自動保存処理中の場合、その時点までの内容がファイルに残る可能性が
|
|
26
|
+
あります」と明示
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- `MDVApi.saveFile(path, content, signal?)` に AbortSignal 引数追加(既存
|
|
31
|
+
caller は signal 省略で動作継続)
|
|
32
|
+
- `TabManager.switch()` を `async` 化(path で target を pin → flush await →
|
|
33
|
+
index 再 lookup で navigation race 回避)
|
|
34
|
+
- save 成功時に `MDVApi.fetchFile(path)` を chain 内で await して
|
|
35
|
+
`tab.{content,css,notes,notesMultiplicity,etag,isMarp}` を更新(古い fetch
|
|
36
|
+
が新しい fetch の後に到着して content を上書きする race を排除)
|
|
37
|
+
|
|
38
|
+
### Fixed (codex review round 1〜14 で潰した issues)
|
|
39
|
+
|
|
40
|
+
- 保存中に typing 続いた場合の dirty フラグ誤クリア(live editor とのテキスト一致
|
|
41
|
+
を確認してから "Saved!" 表示)
|
|
42
|
+
- 連続 autosave で古い ETag の POST が新しい save の後に到着して overwrite
|
|
43
|
+
- BroadcastChannel 経由じゃない、HTTP 経路独自の serialize chain
|
|
44
|
+
- 編集中のタブを close したときに edit mode flag が残る regression
|
|
45
|
+
- discard-on-close で saveTimer / inFlight 両方 abort + lastAutosaveError も clear
|
|
46
|
+
- open() で fetch 中の typing をブロック(textarea.readOnly)+ error 時に restore
|
|
47
|
+
- debounce-fired save が silent fail したまま flushAutosave が成功扱いする問題
|
|
48
|
+
(`lastAutosaveError` を保持し、navigation 時に再 throw)
|
|
49
|
+
|
|
8
50
|
## [0.5.16] - 2026-05-09
|
|
9
51
|
|
|
10
52
|
### Added — Inline Speaker Notes (PowerPoint-style)
|
package/package.json
CHANGED
package/src/static/app.js
CHANGED
|
@@ -1911,14 +1911,57 @@
|
|
|
1911
1911
|
async open(path) {
|
|
1912
1912
|
const existingIndex = state.tabs.findIndex(t => t.path === path);
|
|
1913
1913
|
if (existingIndex >= 0) {
|
|
1914
|
-
this.switch(existingIndex);
|
|
1914
|
+
await this.switch(existingIndex);
|
|
1915
1915
|
return;
|
|
1916
1916
|
}
|
|
1917
1917
|
|
|
1918
|
-
|
|
1919
|
-
|
|
1918
|
+
// The not-yet-open path used to skip the outgoing-tab flush
|
|
1919
|
+
// that switch() does. If the user types and then clicks a
|
|
1920
|
+
// brand-new file within the 1.5s debounce, the textarea is
|
|
1921
|
+
// ripped out before the timer fires and the last edits are
|
|
1922
|
+
// lost. Mirror switch()'s outgoing flush + raw capture here,
|
|
1923
|
+
// including the abort-on-flush-failure behavior so a failed
|
|
1924
|
+
// save doesn't quietly kick the user off the tab they were
|
|
1925
|
+
// editing.
|
|
1926
|
+
let outgoingTextarea = null;
|
|
1927
|
+
if (state.activeTabIndex >= 0
|
|
1928
|
+
&& state.activeTabIndex < state.tabs.length
|
|
1929
|
+
&& state.isEditMode) {
|
|
1930
|
+
try {
|
|
1931
|
+
await EditorManager.flushAutosave();
|
|
1932
|
+
} catch (_e) {
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
outgoingTextarea = document.getElementById('editorTextarea');
|
|
1936
|
+
if (outgoingTextarea) {
|
|
1937
|
+
state.tabs[state.activeTabIndex].raw = outgoingTextarea.value;
|
|
1938
|
+
// Lock the editor while the new file loads. Without
|
|
1939
|
+
// this, slow file loads let the user type more text
|
|
1940
|
+
// that schedules a fresh autosave, then open() tears
|
|
1941
|
+
// the textarea out before that timer ever fires and
|
|
1942
|
+
// the last keystrokes are lost.
|
|
1943
|
+
outgoingTextarea.readOnly = true;
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
// Always restore the outgoing textarea's editability if we
|
|
1948
|
+
// bail out below. On the success path the textarea will be
|
|
1949
|
+
// wiped by render() anyway, so the unlock is harmless then.
|
|
1950
|
+
const unlockOnFailure = () => {
|
|
1951
|
+
if (outgoingTextarea) outgoingTextarea.readOnly = false;
|
|
1952
|
+
};
|
|
1953
|
+
|
|
1954
|
+
let response, data;
|
|
1955
|
+
try {
|
|
1956
|
+
response = await MDVApi.fetchFile(path);
|
|
1957
|
+
data = await response.json();
|
|
1958
|
+
} catch (e) {
|
|
1959
|
+
unlockOnFailure();
|
|
1960
|
+
throw e;
|
|
1961
|
+
}
|
|
1920
1962
|
|
|
1921
1963
|
if (data.error) {
|
|
1964
|
+
unlockOnFailure();
|
|
1922
1965
|
alert('Error: ' + data.error);
|
|
1923
1966
|
return;
|
|
1924
1967
|
}
|
|
@@ -1957,9 +2000,31 @@
|
|
|
1957
2000
|
updateUrlPath(path);
|
|
1958
2001
|
},
|
|
1959
2002
|
|
|
1960
|
-
switch(index) {
|
|
2003
|
+
async switch(index) {
|
|
2004
|
+
// Pin the target by PATH (not by index) before any await:
|
|
2005
|
+
// the user could close a tab while we're flushing, which
|
|
2006
|
+
// would shift the indices and turn `index` into either the
|
|
2007
|
+
// wrong tab or an out-of-bounds dereference.
|
|
2008
|
+
const targetPath = state.tabs[index] && state.tabs[index].path;
|
|
2009
|
+
if (!targetPath) return;
|
|
2010
|
+
|
|
1961
2011
|
if (state.activeTabIndex >= 0 && state.activeTabIndex < state.tabs.length) {
|
|
1962
2012
|
if (state.isEditMode) {
|
|
2013
|
+
// Flush a pending autosave for the OUTGOING tab before
|
|
2014
|
+
// we render it out. Otherwise the debounce timer
|
|
2015
|
+
// captures #editorTextarea at fire time, finds it
|
|
2016
|
+
// gone, and the last keystrokes are stuck only in
|
|
2017
|
+
// tab.raw without ever reaching disk.
|
|
2018
|
+
//
|
|
2019
|
+
// If the flush rejects, the user's edits did NOT
|
|
2020
|
+
// reach disk; aborting the switch keeps them in
|
|
2021
|
+
// edit mode so they can retry instead of losing
|
|
2022
|
+
// work behind a tab they walked away from.
|
|
2023
|
+
try {
|
|
2024
|
+
await EditorManager.flushAutosave();
|
|
2025
|
+
} catch (_e) {
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
1963
2028
|
const textarea = document.getElementById('editorTextarea');
|
|
1964
2029
|
if (textarea) {
|
|
1965
2030
|
state.tabs[state.activeTabIndex].raw = textarea.value;
|
|
@@ -1980,19 +2045,30 @@
|
|
|
1980
2045
|
EditorManager.updateButton();
|
|
1981
2046
|
}
|
|
1982
2047
|
|
|
1983
|
-
|
|
2048
|
+
// Re-resolve the target by path post-await — its index may
|
|
2049
|
+
// have shifted (or it may have been closed entirely) during
|
|
2050
|
+
// the flush.
|
|
2051
|
+
const newIndex = state.tabs.findIndex((t) => t.path === targetPath);
|
|
2052
|
+
if (newIndex < 0) return;
|
|
2053
|
+
state.activeTabIndex = newIndex;
|
|
1984
2054
|
this.render();
|
|
1985
2055
|
this.renderActive();
|
|
1986
|
-
WebSocketManager.watchFile(state.tabs[
|
|
2056
|
+
WebSocketManager.watchFile(state.tabs[newIndex].path);
|
|
1987
2057
|
FileTreeManager.updateHighlight();
|
|
1988
|
-
updateUrlPath(state.tabs[
|
|
2058
|
+
updateUrlPath(state.tabs[newIndex].path);
|
|
1989
2059
|
},
|
|
1990
2060
|
|
|
1991
2061
|
close(index) {
|
|
1992
2062
|
// Warn about unsaved changes
|
|
1993
2063
|
if (state.isEditMode && state.hasUnsavedChanges && index === state.activeTabIndex) {
|
|
1994
2064
|
DialogManager.show('未保存の変更', {
|
|
1995
|
-
|
|
2065
|
+
// The autosave runs every 1.5s. If a POST is already
|
|
2066
|
+
// in flight when the user discards, the server may
|
|
2067
|
+
// have received the request before our AbortController
|
|
2068
|
+
// can cancel it — so the discarded text can still
|
|
2069
|
+
// land on disk in that small window. Be honest about
|
|
2070
|
+
// it rather than promising a guarantee we can't keep.
|
|
2071
|
+
message: '変更を保存せずにタブを閉じますか?\n(自動保存処理中の場合、その時点までの内容がファイルに残る可能性があります)',
|
|
1996
2072
|
isConfirm: true,
|
|
1997
2073
|
danger: true,
|
|
1998
2074
|
confirmText: '閉じる',
|
|
@@ -2000,11 +2076,28 @@
|
|
|
2000
2076
|
state.hasUnsavedChanges = false;
|
|
2001
2077
|
state.isEditMode = false;
|
|
2002
2078
|
EditorManager.updateButton();
|
|
2079
|
+
// Drop the pending debounce so a queued autosave
|
|
2080
|
+
// can't fire after the tab is gone and persist
|
|
2081
|
+
// text the user explicitly chose to discard.
|
|
2082
|
+
EditorManager.cancelPendingAutosave();
|
|
2003
2083
|
TabManager.close(index);
|
|
2004
2084
|
}
|
|
2005
2085
|
});
|
|
2006
2086
|
return;
|
|
2007
2087
|
}
|
|
2088
|
+
// The clean-close path skips the confirm dialog entirely (no
|
|
2089
|
+
// unsaved changes thanks to autosave). It still has to exit
|
|
2090
|
+
// edit mode if we're closing the ACTIVE tab — otherwise
|
|
2091
|
+
// state.isEditMode stays true, the next tab renders in edit
|
|
2092
|
+
// mode (HTML files show source instead of preview, the
|
|
2093
|
+
// toolbar / shortcuts misbehave), and a fresh edit session
|
|
2094
|
+
// is needed to recover.
|
|
2095
|
+
if (state.isEditMode && index === state.activeTabIndex) {
|
|
2096
|
+
state.isEditMode = false;
|
|
2097
|
+
EditorManager.updateButton();
|
|
2098
|
+
EditorManager.cancelPendingAutosave();
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2008
2101
|
const closingPath = state.tabs[index] && state.tabs[index].path;
|
|
2009
2102
|
state.tabs.splice(index, 1);
|
|
2010
2103
|
if (closingPath && window.MDVTabRegistry) {
|
|
@@ -2092,7 +2185,28 @@
|
|
|
2092
2185
|
// Editor Manager
|
|
2093
2186
|
// ============================================================
|
|
2094
2187
|
|
|
2188
|
+
const EDITOR_AUTOSAVE_DEBOUNCE_MS = 1500;
|
|
2189
|
+
|
|
2095
2190
|
const EditorManager = {
|
|
2191
|
+
// Debounced-autosave state. saveTimer is the pending input→save
|
|
2192
|
+
// schedule; savedStatusTimer auto-clears the "Saved!" toast so the
|
|
2193
|
+
// toolbar doesn't pin a stale success message. inFlight serializes
|
|
2194
|
+
// overlapping save() calls so a slow earlier POST can't reach the
|
|
2195
|
+
// last-write-wins server endpoint after a faster newer POST and
|
|
2196
|
+
// overwrite the user's newer text. saveAbortController abort()s
|
|
2197
|
+
// every save sharing the chain, so an explicit discard (close-
|
|
2198
|
+
// without-saving) can cancel an in-flight POST instead of letting
|
|
2199
|
+
// it persist text the user just discarded. lastAutosaveError
|
|
2200
|
+
// remembers a failure that was thrown from a debounce-fired save
|
|
2201
|
+
// (whose own caller silently caught it because the toolbar
|
|
2202
|
+
// status had already been updated) so a later flushAutosave for
|
|
2203
|
+
// navigation can refuse to drop the user's buffer.
|
|
2204
|
+
saveTimer: null,
|
|
2205
|
+
savedStatusTimer: null,
|
|
2206
|
+
inFlight: null,
|
|
2207
|
+
saveAbortController: null,
|
|
2208
|
+
lastAutosaveError: null,
|
|
2209
|
+
|
|
2096
2210
|
async toggle() {
|
|
2097
2211
|
if (state.activeTabIndex < 0) return;
|
|
2098
2212
|
const tab = state.tabs[state.activeTabIndex];
|
|
@@ -2107,6 +2221,96 @@
|
|
|
2107
2221
|
state.isEditMode ? this.show() : await this.hide();
|
|
2108
2222
|
},
|
|
2109
2223
|
|
|
2224
|
+
scheduleAutosave() {
|
|
2225
|
+
if (this.saveTimer) clearTimeout(this.saveTimer);
|
|
2226
|
+
this.saveTimer = setTimeout(() => {
|
|
2227
|
+
this.saveTimer = null;
|
|
2228
|
+
// Debounce-fired saves swallow rejections — the toolbar
|
|
2229
|
+
// status already reflects the error, and there is no
|
|
2230
|
+
// caller waiting for the Promise. Without this catch
|
|
2231
|
+
// every failed autosave would surface as an
|
|
2232
|
+
// "Unhandled Promise rejection" in the console.
|
|
2233
|
+
this.save().catch(() => { /* status already shown */ });
|
|
2234
|
+
}, EDITOR_AUTOSAVE_DEBOUNCE_MS);
|
|
2235
|
+
},
|
|
2236
|
+
|
|
2237
|
+
// Cancel a pending debounce AND any in-flight POST so a discard-
|
|
2238
|
+
// on-close is fully honored. The aborted save() resolves silently
|
|
2239
|
+
// (its catch maps AbortError → no-op), so the chain unblocks and
|
|
2240
|
+
// no toolbar status mutation runs.
|
|
2241
|
+
cancelPendingAutosave() {
|
|
2242
|
+
if (this.saveTimer) {
|
|
2243
|
+
clearTimeout(this.saveTimer);
|
|
2244
|
+
this.saveTimer = null;
|
|
2245
|
+
}
|
|
2246
|
+
if (this.saveAbortController) {
|
|
2247
|
+
this.saveAbortController.abort();
|
|
2248
|
+
this.saveAbortController = null;
|
|
2249
|
+
}
|
|
2250
|
+
// Drop any stored failure too — discard means "I don't care
|
|
2251
|
+
// about that buffer anymore." Without this, the next edit
|
|
2252
|
+
// session for an unrelated file would inherit the prior
|
|
2253
|
+
// failure and flushAutosave would throw on its first
|
|
2254
|
+
// navigation, blocking work that has nothing to do with
|
|
2255
|
+
// the discarded tab.
|
|
2256
|
+
this.lastAutosaveError = null;
|
|
2257
|
+
},
|
|
2258
|
+
|
|
2259
|
+
// Flush a pending autosave NOW (instead of waiting for the
|
|
2260
|
+
// debounce timer). Used by Cmd+S, hide(), and tab switching so
|
|
2261
|
+
// leaving edit mode never silently drops the last unsaved
|
|
2262
|
+
// keystrokes — and so a slow in-flight save can't run its
|
|
2263
|
+
// post-success "clear dirty / show Saved!" branch after the user
|
|
2264
|
+
// has already moved on to a different tab (the global
|
|
2265
|
+
// hasUnsavedChanges flag would clobber the new editor's state).
|
|
2266
|
+
//
|
|
2267
|
+
// The loop keeps draining until both the debounce queue and the
|
|
2268
|
+
// in-flight chain are empty: while we await an in-flight POST
|
|
2269
|
+
// the textarea is still editable, so a new keystroke can arm a
|
|
2270
|
+
// fresh saveTimer. We have to re-check after each await or the
|
|
2271
|
+
// tail of typing escapes the flush and the eventual save() call
|
|
2272
|
+
// returns no-op because the textarea has been removed by the
|
|
2273
|
+
// navigation that triggered us.
|
|
2274
|
+
async flushAutosave() {
|
|
2275
|
+
// Surface a previously-silenced autosave failure first.
|
|
2276
|
+
// If the last debounce-fired save threw and nobody else
|
|
2277
|
+
// has seen it (its caller .catch'd silently), we MUST
|
|
2278
|
+
// throw before letting navigation continue — otherwise
|
|
2279
|
+
// hide() would refetch over the unsaved buffer.
|
|
2280
|
+
if (this.lastAutosaveError) {
|
|
2281
|
+
throw this.lastAutosaveError;
|
|
2282
|
+
}
|
|
2283
|
+
let lastError = null;
|
|
2284
|
+
while (this.saveTimer || this.inFlight) {
|
|
2285
|
+
if (this.saveTimer) {
|
|
2286
|
+
clearTimeout(this.saveTimer);
|
|
2287
|
+
this.saveTimer = null;
|
|
2288
|
+
try {
|
|
2289
|
+
await this.save();
|
|
2290
|
+
} catch (e) {
|
|
2291
|
+
// First failure aborts the drain. Re-trying
|
|
2292
|
+
// the same chain would just replay the failure
|
|
2293
|
+
// and risk an infinite loop if the user keeps
|
|
2294
|
+
// typing. The next input will arm a fresh
|
|
2295
|
+
// saveTimer and we can flush again on the next
|
|
2296
|
+
// navigation attempt.
|
|
2297
|
+
lastError = e;
|
|
2298
|
+
break;
|
|
2299
|
+
}
|
|
2300
|
+
} else {
|
|
2301
|
+
try {
|
|
2302
|
+
await this.inFlight;
|
|
2303
|
+
} catch (e) {
|
|
2304
|
+
lastError = e;
|
|
2305
|
+
break;
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
// Propagate so navigation callers (hide / switch / open) can
|
|
2310
|
+
// bail out instead of silently dropping the user's buffer.
|
|
2311
|
+
if (lastError) throw lastError;
|
|
2312
|
+
},
|
|
2313
|
+
|
|
2110
2314
|
updateButton() {
|
|
2111
2315
|
elements.editToggle.classList.toggle('active', state.isEditMode);
|
|
2112
2316
|
elements.editLabel.textContent = state.isEditMode ? 'View' : 'Edit';
|
|
@@ -2138,6 +2342,7 @@
|
|
|
2138
2342
|
state.hasUnsavedChanges = true;
|
|
2139
2343
|
elements.editorStatus.textContent = 'Modified';
|
|
2140
2344
|
elements.editorStatus.className = 'editor-status modified';
|
|
2345
|
+
EditorManager.scheduleAutosave();
|
|
2141
2346
|
});
|
|
2142
2347
|
|
|
2143
2348
|
setTimeout(() => {
|
|
@@ -2185,6 +2390,29 @@
|
|
|
2185
2390
|
if (state.activeTabIndex < 0) return;
|
|
2186
2391
|
const tab = state.tabs[state.activeTabIndex];
|
|
2187
2392
|
|
|
2393
|
+
// Flush any pending autosave BEFORE we read the textarea +
|
|
2394
|
+
// re-fetch the file. Otherwise the post-fetch render would
|
|
2395
|
+
// overwrite tab.raw with the on-disk version while the user's
|
|
2396
|
+
// last keystrokes (still inside the debounce window) are
|
|
2397
|
+
// silently discarded.
|
|
2398
|
+
//
|
|
2399
|
+
// If the flush throws (a write failed somewhere in the chain),
|
|
2400
|
+
// bail out: the on-disk content does NOT match the user's
|
|
2401
|
+
// textarea, so swapping back to View mode would refetch the
|
|
2402
|
+
// older version and lose the in-progress edits. Stay in edit
|
|
2403
|
+
// mode with the existing 'Error: ...' status visible so the
|
|
2404
|
+
// user can retry / fix the underlying issue. Re-throw so
|
|
2405
|
+
// toggle()'s callers (PrintManager.print and friends) can
|
|
2406
|
+
// detect the failure instead of silently exporting from the
|
|
2407
|
+
// pre-edit on-disk content.
|
|
2408
|
+
try {
|
|
2409
|
+
await this.flushAutosave();
|
|
2410
|
+
} catch (e) {
|
|
2411
|
+
state.isEditMode = true;
|
|
2412
|
+
this.updateButton();
|
|
2413
|
+
throw e;
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2188
2416
|
const textarea = document.getElementById('editorTextarea');
|
|
2189
2417
|
let topLineNumber = -1;
|
|
2190
2418
|
let scrollPercentage = 0;
|
|
@@ -2266,46 +2494,179 @@
|
|
|
2266
2494
|
},
|
|
2267
2495
|
|
|
2268
2496
|
async save() {
|
|
2269
|
-
if (state.activeTabIndex < 0
|
|
2497
|
+
if (state.activeTabIndex < 0) return;
|
|
2270
2498
|
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
if (
|
|
2499
|
+
// Cancel any pending debounce; whether we got here via the
|
|
2500
|
+
// timer, Cmd+S, or flushAutosave, this single save covers it.
|
|
2501
|
+
if (this.saveTimer) {
|
|
2502
|
+
clearTimeout(this.saveTimer);
|
|
2503
|
+
this.saveTimer = null;
|
|
2504
|
+
}
|
|
2274
2505
|
|
|
2275
|
-
|
|
2506
|
+
// Pin tab, path, and content NOW. We must not re-read these
|
|
2507
|
+
// after the prior save completes, because by then the active
|
|
2508
|
+
// tab and textarea may have changed under us — and we still
|
|
2509
|
+
// need to persist the snapshot the user actually authored
|
|
2510
|
+
// when this save() was invoked.
|
|
2511
|
+
const initialTab = state.tabs[state.activeTabIndex];
|
|
2512
|
+
const textarea = document.getElementById('editorTextarea');
|
|
2513
|
+
if (!initialTab || !textarea) return;
|
|
2514
|
+
const path = initialTab.path;
|
|
2515
|
+
const content = textarea.value;
|
|
2516
|
+
|
|
2517
|
+
// One AbortController governs the whole chain: cancel-pending
|
|
2518
|
+
// calls .abort() once and every queued / in-flight save sees
|
|
2519
|
+
// the same signal. We only create a fresh one when the chain
|
|
2520
|
+
// is currently empty (or has been previously aborted+cleared).
|
|
2521
|
+
if (!this.saveAbortController) {
|
|
2522
|
+
this.saveAbortController = new AbortController();
|
|
2523
|
+
}
|
|
2524
|
+
const signal = this.saveAbortController.signal;
|
|
2525
|
+
|
|
2526
|
+
// Chain after the previous save's Promise so concurrent saves
|
|
2527
|
+
// reach the last-write-wins endpoint in invocation order.
|
|
2528
|
+
// flushAutosave() awaits this.inFlight to drain the entire
|
|
2529
|
+
// chain (not just the head), so any number of queued saves
|
|
2530
|
+
// are guaranteed to complete before navigation proceeds.
|
|
2531
|
+
const prior = this.inFlight;
|
|
2532
|
+
const self = this;
|
|
2533
|
+
const mine = (async () => {
|
|
2534
|
+
if (prior) {
|
|
2535
|
+
try { await prior; } catch (_e) { /* ignore */ }
|
|
2536
|
+
}
|
|
2537
|
+
// If the chain was aborted while we were waiting in line,
|
|
2538
|
+
// skip the POST entirely.
|
|
2539
|
+
if (signal.aborted) return;
|
|
2540
|
+
try {
|
|
2541
|
+
elements.editorStatus.textContent = 'Saving...';
|
|
2542
|
+
elements.editorStatus.className = 'editor-status';
|
|
2276
2543
|
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
elements.editorStatus.className = 'editor-status';
|
|
2544
|
+
const response = await MDVApi.saveFile(path, content, signal);
|
|
2545
|
+
const result = await response.json();
|
|
2280
2546
|
|
|
2281
|
-
|
|
2547
|
+
if (result.error) {
|
|
2548
|
+
// Only paint status onto the toolbar if the user
|
|
2549
|
+
// is still on the deck we tried to save.
|
|
2550
|
+
const active = state.tabs[state.activeTabIndex];
|
|
2551
|
+
if (active && active.path === path) {
|
|
2552
|
+
elements.editorStatus.textContent = 'Error: ' + result.error;
|
|
2553
|
+
elements.editorStatus.className = 'editor-status modified';
|
|
2554
|
+
}
|
|
2555
|
+
// Throw so flushAutosave / hide() can detect that
|
|
2556
|
+
// the write failed and avoid silently overwriting
|
|
2557
|
+
// the user's edits with the on-disk content.
|
|
2558
|
+
throw new Error(result.error);
|
|
2559
|
+
}
|
|
2282
2560
|
|
|
2283
|
-
|
|
2561
|
+
// Mirror the saved content into the deck's tab even
|
|
2562
|
+
// if the user has navigated away — the on-disk file
|
|
2563
|
+
// and tab.raw should agree on what was persisted.
|
|
2564
|
+
const target = state.tabs.find((t) => t.path === path);
|
|
2565
|
+
if (target) {
|
|
2566
|
+
target.raw = content;
|
|
2567
|
+
// Re-fetch rendered HTML / Marp metadata INLINE,
|
|
2568
|
+
// not fire-and-forget. The save chain is
|
|
2569
|
+
// serialized per-tab; awaiting the refresh here
|
|
2570
|
+
// ensures the older save's refresh can never
|
|
2571
|
+
// arrive after a newer save's refresh and
|
|
2572
|
+
// overwrite the newer rendered state. The
|
|
2573
|
+
// perceived latency cost is the round trip,
|
|
2574
|
+
// which only blocks a *follow-up* autosave (the
|
|
2575
|
+
// user's typing is unblocked the instant we
|
|
2576
|
+
// dispatched POST).
|
|
2577
|
+
try {
|
|
2578
|
+
const refreshRes = await MDVApi.fetchFile(path);
|
|
2579
|
+
const data = await refreshRes.json();
|
|
2580
|
+
const t = state.tabs.find((x) => x.path === path);
|
|
2581
|
+
if (t) {
|
|
2582
|
+
if (typeof data.content === 'string') t.content = data.content;
|
|
2583
|
+
if (typeof data.css !== 'undefined') t.css = data.css;
|
|
2584
|
+
if (Array.isArray(data.notes)) t.notes = data.notes;
|
|
2585
|
+
if (Array.isArray(data.notesMultiplicity)) t.notesMultiplicity = data.notesMultiplicity;
|
|
2586
|
+
if (data.etag) t.etag = data.etag;
|
|
2587
|
+
if (typeof data.isMarp !== 'undefined') t.isMarp = data.isMarp;
|
|
2588
|
+
}
|
|
2589
|
+
} catch (_e) { /* watcher will catch up */ }
|
|
2590
|
+
}
|
|
2284
2591
|
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2592
|
+
// Global hasUnsavedChanges and the toolbar are tied
|
|
2593
|
+
// to the ACTIVE tab. Don't clear them on behalf of a
|
|
2594
|
+
// save whose deck the user has already left, and
|
|
2595
|
+
// don't clear them when the user has typed more text
|
|
2596
|
+
// since this save was scheduled — the next debounce
|
|
2597
|
+
// is already requeued and will settle state itself.
|
|
2598
|
+
const active = state.tabs[state.activeTabIndex];
|
|
2599
|
+
if (active && active.path === path) {
|
|
2600
|
+
const liveTextarea = document.getElementById('editorTextarea');
|
|
2601
|
+
const stillFresh = liveTextarea && liveTextarea.value === content;
|
|
2602
|
+
if (stillFresh) {
|
|
2603
|
+
state.hasUnsavedChanges = false;
|
|
2604
|
+
elements.editorStatus.textContent = 'Saved!';
|
|
2605
|
+
elements.editorStatus.className = 'editor-status saved';
|
|
2606
|
+
if (self.savedStatusTimer) clearTimeout(self.savedStatusTimer);
|
|
2607
|
+
self.savedStatusTimer = setTimeout(() => {
|
|
2608
|
+
if (elements.editorStatus.textContent === 'Saved!') {
|
|
2609
|
+
elements.editorStatus.textContent = 'Ready';
|
|
2610
|
+
elements.editorStatus.className = 'editor-status';
|
|
2611
|
+
}
|
|
2612
|
+
self.savedStatusTimer = null;
|
|
2613
|
+
}, 2000);
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
// Whatever earlier failure we may have remembered is
|
|
2617
|
+
// moot now — the chain went through.
|
|
2618
|
+
self.lastAutosaveError = null;
|
|
2619
|
+
} catch (e) {
|
|
2620
|
+
// Abort is intentional (discard-on-close cancelled
|
|
2621
|
+
// us). Don't treat that as a failure.
|
|
2622
|
+
if (e.name === 'AbortError') return;
|
|
2623
|
+
const active = state.tabs[state.activeTabIndex];
|
|
2624
|
+
if (active && active.path === path) {
|
|
2625
|
+
elements.editorStatus.textContent = 'Error: ' + e.message;
|
|
2626
|
+
elements.editorStatus.className = 'editor-status modified';
|
|
2627
|
+
}
|
|
2628
|
+
// Remember the failure so a later flushAutosave —
|
|
2629
|
+
// even one fired AFTER saveTimer/inFlight have both
|
|
2630
|
+
// settled — can surface it. Without this a debounced
|
|
2631
|
+
// save that fails silently (its caller's `.catch(()
|
|
2632
|
+
// => {})`) would leave hasUnsavedChanges=true with
|
|
2633
|
+
// no observable error, and hide()'s subsequent flush
|
|
2634
|
+
// would return success and refetch the on-disk file
|
|
2635
|
+
// over the user's unsaved buffer.
|
|
2636
|
+
self.lastAutosaveError = e;
|
|
2637
|
+
// Re-throw so a flushAutosave caller (hide / switch /
|
|
2638
|
+
// open / Cmd+S) can react and refuse to discard the
|
|
2639
|
+
// unsaved buffer.
|
|
2640
|
+
throw e;
|
|
2641
|
+
}
|
|
2642
|
+
})();
|
|
2643
|
+
|
|
2644
|
+
// Make `mine` the new chain tail. flushAutosave awaits whatever
|
|
2645
|
+
// is at the tail, so as long as each save replaces the tail
|
|
2646
|
+
// with a Promise that internally awaits its predecessor, the
|
|
2647
|
+
// caller always waits for the entire pending chain.
|
|
2648
|
+
this.inFlight = mine;
|
|
2649
|
+
try {
|
|
2650
|
+
await mine;
|
|
2651
|
+
} finally {
|
|
2652
|
+
// Only the tail clears inFlight. If a newer save has
|
|
2653
|
+
// chained on after us, leave its Promise in place — and
|
|
2654
|
+
// leave the shared AbortController in place too so the
|
|
2655
|
+
// newer save can still be cancelled via the same handle.
|
|
2656
|
+
if (this.inFlight === mine) {
|
|
2657
|
+
this.inFlight = null;
|
|
2658
|
+
if (this.saveAbortController
|
|
2659
|
+
&& this.saveAbortController.signal === signal) {
|
|
2660
|
+
this.saveAbortController = null;
|
|
2661
|
+
}
|
|
2289
2662
|
}
|
|
2290
|
-
|
|
2291
|
-
tab.raw = newContent;
|
|
2292
|
-
state.hasUnsavedChanges = false;
|
|
2293
|
-
elements.editorStatus.textContent = 'Saved!';
|
|
2294
|
-
elements.editorStatus.className = 'editor-status saved';
|
|
2295
|
-
|
|
2296
|
-
setTimeout(() => {
|
|
2297
|
-
elements.editorStatus.textContent = 'Ready';
|
|
2298
|
-
elements.editorStatus.className = 'editor-status';
|
|
2299
|
-
}, 2000);
|
|
2300
|
-
|
|
2301
|
-
} catch (e) {
|
|
2302
|
-
elements.editorStatus.textContent = 'Error: ' + e.message;
|
|
2303
|
-
elements.editorStatus.className = 'editor-status modified';
|
|
2304
2663
|
}
|
|
2305
2664
|
},
|
|
2306
2665
|
|
|
2307
2666
|
init() {
|
|
2308
|
-
elements.editToggle.addEventListener('click', () =>
|
|
2667
|
+
elements.editToggle.addEventListener('click', () => {
|
|
2668
|
+
this.toggle().catch(() => { /* status already shown */ });
|
|
2669
|
+
});
|
|
2309
2670
|
}
|
|
2310
2671
|
};
|
|
2311
2672
|
|
|
@@ -2327,9 +2688,15 @@
|
|
|
2327
2688
|
|
|
2328
2689
|
const tab = state.tabs[state.activeTabIndex];
|
|
2329
2690
|
|
|
2330
|
-
// editモード中は閉じてからPDF
|
|
2691
|
+
// editモード中は閉じてからPDF生成。autosave が失敗していた
|
|
2692
|
+
// ら toggle() が throw する → 印刷を中止して edit モード維持
|
|
2693
|
+
// (古い on-disk 内容で勝手に PDF 化しないように)。
|
|
2331
2694
|
if (state.isEditMode) {
|
|
2332
|
-
|
|
2695
|
+
try {
|
|
2696
|
+
await EditorManager.toggle();
|
|
2697
|
+
} catch (_e) {
|
|
2698
|
+
return;
|
|
2699
|
+
}
|
|
2333
2700
|
}
|
|
2334
2701
|
|
|
2335
2702
|
if (tab.isMarp || this.isMarpPresentation()) {
|
|
@@ -2868,7 +3235,7 @@
|
|
|
2868
3235
|
shortcuts: {
|
|
2869
3236
|
'b': { handler: () => SidebarManager.toggle() },
|
|
2870
3237
|
'w': { handler: () => TabManager.close(state.activeTabIndex), requiresTab: true },
|
|
2871
|
-
'e': { handler: () => EditorManager.toggle(), requiresTab: true },
|
|
3238
|
+
'e': { handler: () => EditorManager.toggle().catch(() => { /* status already shown */ }), requiresTab: true },
|
|
2872
3239
|
's': { handler: () => EditorManager.save(), requiresEditMode: true },
|
|
2873
3240
|
'p': { handler: () => PrintManager.print(), requiresTab: true }
|
|
2874
3241
|
},
|
|
@@ -42,11 +42,12 @@
|
|
|
42
42
|
function fetchFile(path) {
|
|
43
43
|
return fetch('/api/file?path=' + encodeURIComponent(path));
|
|
44
44
|
}
|
|
45
|
-
function saveFile(path, content) {
|
|
45
|
+
function saveFile(path, content, signal) {
|
|
46
46
|
return fetch('/api/file', {
|
|
47
47
|
method: 'POST',
|
|
48
48
|
headers: { 'Content-Type': 'application/json' },
|
|
49
|
-
body: JSON.stringify({ path, content })
|
|
49
|
+
body: JSON.stringify({ path, content }),
|
|
50
|
+
signal
|
|
50
51
|
});
|
|
51
52
|
}
|
|
52
53
|
function fetchInfo() { return fetch('/api/info'); }
|