mdv-live 0.5.16 → 0.5.18

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/src/static/app.js CHANGED
@@ -25,8 +25,8 @@
25
25
  const SLIDE_ROW_MIN_PX = 80;
26
26
 
27
27
  const HLJS_THEMES = {
28
- light: 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css',
29
- dark: 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css'
28
+ light: '/static/vendor/highlight/github.min.css',
29
+ dark: '/static/vendor/highlight/github-dark.min.css'
30
30
  };
31
31
 
32
32
  const MERMAID_THEMES = {
@@ -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
- const response = await MDVApi.fetchFile(path);
1919
- const data = await response.json();
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
- state.activeTabIndex = index;
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[index].path);
2056
+ WebSocketManager.watchFile(state.tabs[newIndex].path);
1987
2057
  FileTreeManager.updateHighlight();
1988
- updateUrlPath(state.tabs[index].path);
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
- message: '変更を保存せずにタブを閉じますか?',
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 || !state.isEditMode) return;
2497
+ if (state.activeTabIndex < 0) return;
2270
2498
 
2271
- const tab = state.tabs[state.activeTabIndex];
2272
- const textarea = document.getElementById('editorTextarea');
2273
- if (!textarea) return;
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
- const newContent = textarea.value;
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
- try {
2278
- elements.editorStatus.textContent = 'Saving...';
2279
- elements.editorStatus.className = 'editor-status';
2544
+ const response = await MDVApi.saveFile(path, content, signal);
2545
+ const result = await response.json();
2280
2546
 
2281
- const response = await MDVApi.saveFile(tab.path, newContent);
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
- const result = await response.json();
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
- if (result.error) {
2286
- elements.editorStatus.textContent = 'Error: ' + result.error;
2287
- elements.editorStatus.className = 'editor-status modified';
2288
- return;
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', () => this.toggle());
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
- await EditorManager.toggle();
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
  },
@@ -12,7 +12,7 @@
12
12
  <link rel="apple-touch-icon" sizes="128x128" href="/static/images/icon-128.png">
13
13
 
14
14
  <!-- Stylesheets -->
15
- <link id="hljs-theme" rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
15
+ <link id="hljs-theme" rel="stylesheet" href="/static/vendor/highlight/github-dark.min.css">
16
16
  <link rel="stylesheet" href="/static/styles.css">
17
17
 
18
18
  <!-- Theme initialization (FOUC prevention) -->
@@ -24,13 +24,13 @@
24
24
  })();
25
25
  </script>
26
26
 
27
- <!-- External libraries -->
28
- <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
29
- <script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
30
- <script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
27
+ <!-- External libraries (offline, served from src/static/vendor/) -->
28
+ <script src="/static/vendor/highlight.min.js"></script>
29
+ <script src="/static/vendor/mermaid.min.js"></script>
30
+ <script src="/static/vendor/html2pdf.bundle.min.js"></script>
31
31
 
32
32
  <!-- Tailwind CSS (for Marp slides) -->
33
- <script src="https://cdn.tailwindcss.com"></script>
33
+ <script src="/static/vendor/tailwind.min.js"></script>
34
34
  <script>
35
35
  tailwind.config = {
36
36
  corePlugins: { preflight: false, container: false },
@@ -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'); }
@@ -0,0 +1,13 @@
1
+ # vendor/
2
+
3
+ This directory holds offline copies of third-party browser libraries that
4
+ index.html used to load from CDN. Regenerate it with:
5
+
6
+ node scripts/sync-vendor.js
7
+
8
+ Sources and licenses (full text in vendor/licenses/):
9
+ - highlight.min.js / highlight/*.css — @highlightjs/cdn-assets (BSD-3-Clause)
10
+ - mermaid.min.js — mermaid (MIT)
11
+ - html2pdf.bundle.min.js — html2pdf.js (MIT); see also
12
+ html2pdf.bundle.min.js.LICENSE.txt for embedded notices
13
+ - tailwind.min.js — Tailwind CSS Play CDN 3.4.17 (MIT)
@@ -0,0 +1,10 @@
1
+ pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
2
+ Theme: GitHub Dark
3
+ Description: Dark theme as seen on github.com
4
+ Author: github.com
5
+ Maintainer: @Hirse
6
+ Updated: 2021-05-15
7
+
8
+ Outdated base version: https://github.com/primer/github-syntax-dark
9
+ Current colors taken from GitHub's CSS
10
+ */.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#79c0ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-code,.hljs-comment,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}
@@ -0,0 +1,10 @@
1
+ pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
2
+ Theme: GitHub
3
+ Description: Light theme as seen on github.com
4
+ Author: github.com
5
+ Maintainer: @Hirse
6
+ Updated: 2021-05-15
7
+
8
+ Outdated base version: https://github.com/primer/github-syntax-light
9
+ Current colors taken from GitHub's CSS
10
+ */.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0}