mdv-live 0.5.15 → 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/src/static/app.js CHANGED
@@ -13,9 +13,17 @@
13
13
  THEME: 'mdv-theme',
14
14
  SIDEBAR_WIDTH: 'mdv-sidebar-width',
15
15
  PDF_STYLE_PATH: 'mdv-pdf-style-path',
16
- PDF_OPTIONS_PATH: 'mdv-pdf-options-path'
16
+ PDF_OPTIONS_PATH: 'mdv-pdf-options-path',
17
+ NOTES_ROW_PX: 'mdv-notes-row-px',
18
+ NOTES_STALE_BACKUP: 'mdv-notes-stale-backup'
17
19
  };
18
20
 
21
+ const NOTES_AUTOSAVE_DEBOUNCE_MS = 800;
22
+ const NOTES_ROW_DEFAULT_PX = 240;
23
+ const NOTES_ROW_MIN_PX = 0;
24
+ const SPLIT_HANDLE_PX = 8;
25
+ const SLIDE_ROW_MIN_PX = 80;
26
+
19
27
  const HLJS_THEMES = {
20
28
  light: 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css',
21
29
  dark: 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css'
@@ -500,6 +508,29 @@
500
508
  if (data.etag) tab.etag = data.etag;
501
509
  if (data.lineEnding) tab.lineEnding = data.lineEnding;
502
510
  if (typeof data.hasBom !== 'undefined') tab.hasBom = !!data.hasBom;
511
+
512
+ // If the user is mid-edit in the inline notes panel for THIS
513
+ // deck, suppress the re-render so their cursor isn't yanked
514
+ // out of contenteditable mid-keystroke. tab.{notes,etag,…}
515
+ // have already been refreshed above so the next render after
516
+ // blur will show fresh data; the presenter window still gets
517
+ // the broadcast and updates immediately because that path
518
+ // has its own `if (!editing)` guard.
519
+ //
520
+ // We mark a deferred render on the tab so that when the user
521
+ // blurs the editor (handleFocusOut → render hook), the slide
522
+ // SVGs catch up to whatever external edit landed during the
523
+ // edit session. Without this, an external write to the same
524
+ // file leaves the slide pane stale until the next full
525
+ // navigation.
526
+ if (InlineNotesPanel.editing
527
+ && InlineNotesPanel.editingPath === tab.path) {
528
+ tab.pendingRender = true;
529
+ PresenterView.broadcastSlides();
530
+ return;
531
+ }
532
+
533
+ tab.pendingRender = false;
503
534
  ContentRenderer.renderMarp(data.content, tab.css);
504
535
  PresenterView.broadcastSlides();
505
536
  } else {
@@ -690,6 +721,491 @@
690
721
  let marpCurrentSlide = 0;
691
722
  let marpKeyHandler = null;
692
723
 
724
+ // ============================================================
725
+ // Inline Speaker Notes Panel (under each Marp slide in the main view)
726
+ // ============================================================
727
+
728
+ // contenteditable inserts <div>/<br> nodes for line breaks; textContent
729
+ // flattens those without separators. Walk the DOM and emit \n at block
730
+ // boundaries so two-line edits arrive as `line1\nline2`. Mirrors the
731
+ // implementation in presenter.html.
732
+ function readEditableText(el) {
733
+ let out = '';
734
+ function walk(node) {
735
+ if (node.nodeType === Node.TEXT_NODE) {
736
+ out += node.textContent;
737
+ return;
738
+ }
739
+ if (node.nodeType !== Node.ELEMENT_NODE) return;
740
+ const tag = node.tagName;
741
+ if (tag === 'BR') { out += '\n'; return; }
742
+ const isBlock = tag === 'DIV' || tag === 'P' || tag === 'LI';
743
+ if (isBlock && out && !out.endsWith('\n')) out += '\n';
744
+ for (const child of node.childNodes) walk(child);
745
+ if (isBlock && !out.endsWith('\n')) out += '\n';
746
+ }
747
+ for (const child of el.childNodes) walk(child);
748
+ return out.replace(/\n+$/, '');
749
+ }
750
+
751
+ const InlineNotesPanel = {
752
+ attached: false,
753
+ editing: false,
754
+ editingSlideIndex: -1,
755
+ editingPath: '',
756
+ editingEtag: null,
757
+ saveTimer: null,
758
+ // Auto-clear save status text after a delay (one timer per slide).
759
+ statusClearTimers: new Map(),
760
+
761
+ // Build a panel for one slide. Caller appends it to the notes area.
762
+ // The editor's text is set via textContent (NOT innerHTML) so a note
763
+ // containing HTML-like characters can never inject markup. Status
764
+ // (保存中… / 保存済み / 失敗) floats in the panel's top-right via CSS
765
+ // — no header chrome eats vertical space.
766
+ buildPanel(slideIndex, noteText, multiplicity, hasEtag) {
767
+ const canEdit = hasEtag && multiplicity <= 1;
768
+ const panel = document.createElement('aside');
769
+ panel.className = 'speaker-notes-panel';
770
+ panel.dataset.slideIndex = String(slideIndex);
771
+ panel.innerHTML = `
772
+ <span class="speaker-notes-status" data-role="status" aria-live="polite"></span>
773
+ <div class="speaker-notes-banner" data-role="banner" hidden></div>
774
+ <div class="speaker-notes-editor"
775
+ data-role="editor"
776
+ data-placeholder="(ノートなし)"
777
+ spellcheck="false"
778
+ role="textbox"
779
+ aria-label="Speaker notes for slide ${slideIndex + 1}"></div>
780
+ `;
781
+ const editor = panel.querySelector('[data-role="editor"]');
782
+ editor.textContent = noteText || '';
783
+ editor.contentEditable = canEdit ? 'true' : 'false';
784
+
785
+ if (!canEdit) {
786
+ const banner = panel.querySelector('[data-role="banner"]');
787
+ let msg = '';
788
+ if (!hasEtag) {
789
+ msg = 'このファイルは現在解析できないため自動保存は無効です。';
790
+ } else if (multiplicity > 1) {
791
+ msg = 'このスライドは複数のコメントを含むため自動保存を無効化しています(markdown editor で直接編集してください)。';
792
+ }
793
+ banner.textContent = msg;
794
+ banner.hidden = false;
795
+ }
796
+ return panel;
797
+ },
798
+
799
+ // Attach event delegation to the content area. Idempotent: calling
800
+ // attach() twice is a no-op until detach() runs.
801
+ attach() {
802
+ if (this.attached) return;
803
+ elements.content.addEventListener('focusin', this.handleFocusIn);
804
+ elements.content.addEventListener('focusout', this.handleFocusOut);
805
+ elements.content.addEventListener('input', this.handleInput);
806
+ elements.content.addEventListener('keydown', this.handleKeydown);
807
+ this.attached = true;
808
+ },
809
+
810
+ detach() {
811
+ // Always run flush even if attached=false: a previous detach call
812
+ // already removed the listeners but the editor could still be in
813
+ // the DOM with a pending timer (race during fast tab switching).
814
+ this.flush();
815
+ if (!this.attached) return;
816
+ elements.content.removeEventListener('focusin', this.handleFocusIn);
817
+ elements.content.removeEventListener('focusout', this.handleFocusOut);
818
+ elements.content.removeEventListener('input', this.handleInput);
819
+ elements.content.removeEventListener('keydown', this.handleKeydown);
820
+ this.attached = false;
821
+ this.statusClearTimers.forEach((t) => clearTimeout(t));
822
+ this.statusClearTimers.clear();
823
+ this.editing = false;
824
+ this.editingSlideIndex = -1;
825
+ this.editingPath = '';
826
+ this.editingEtag = null;
827
+ },
828
+
829
+ flush() {
830
+ if (this.saveTimer) {
831
+ clearTimeout(this.saveTimer);
832
+ this.saveTimer = null;
833
+ this.sendSave();
834
+ }
835
+ },
836
+
837
+ scheduleSave(editor) {
838
+ this.setStatus(editor, '編集中…', '');
839
+ if (this.saveTimer) clearTimeout(this.saveTimer);
840
+ this.saveTimer = setTimeout(() => {
841
+ this.saveTimer = null;
842
+ this.sendSave();
843
+ }, NOTES_AUTOSAVE_DEBOUNCE_MS);
844
+ },
845
+
846
+ async sendSave() {
847
+ const idx = this.editingSlideIndex;
848
+ const path = this.editingPath;
849
+ const etag = this.editingEtag;
850
+ if (idx < 0 || !path || !etag) return;
851
+ const editor = this.findEditor(idx);
852
+ if (!editor) return;
853
+ const value = readEditableText(editor);
854
+ this.setStatus(editor, '保存中…', '');
855
+
856
+ // Use the same saveQueue as the presenter window so concurrent
857
+ // edits to the same deck are serialized. The Promise resolves
858
+ // with the saveFn result (saveNote returns {ok, etag, reason}).
859
+ const saveQueue = PresenterView.saveQueue;
860
+ if (!saveQueue) {
861
+ this.setStatus(editor, '保存失敗: queue 未初期化', 'err');
862
+ return;
863
+ }
864
+ // Tag the request as 'inline' so the Presenter window's
865
+ // note-saved handler ignores it (otherwise an inline save
866
+ // would overwrite the Presenter's pinned editingEtag and
867
+ // skip the STALE conflict its next autosave should hit).
868
+ const result = await saveQueue.enqueue(path, idx, value, etag, 'inline');
869
+
870
+ // The user may have switched tabs while the save was in flight.
871
+ // Only touch DOM/UI for the deck we actually saved against —
872
+ // findEditor() runs against elements.content which always shows
873
+ // the active tab, so we must verify the active tab still matches
874
+ // `path` before treating the editor as ours. Otherwise a status
875
+ // string for deck A would land on deck B's panel and a STALE
876
+ // backup could be filled with text from the wrong deck.
877
+ const activeTab = state.tabs[state.activeTabIndex];
878
+ const activeTabMatches = !!(activeTab && activeTab.path === path);
879
+ const liveEditor = activeTabMatches ? this.findEditor(idx) : null;
880
+ if (!result || result.reason === 'COALESCED') {
881
+ // A newer enqueue superseded us. The newer one will update
882
+ // status when it resolves; don't overwrite "保存中…" here.
883
+ return;
884
+ }
885
+ if (result.ok) {
886
+ if (this.editing
887
+ && this.editingPath === path
888
+ && this.editingSlideIndex === idx
889
+ && result.etag) {
890
+ this.editingEtag = result.etag;
891
+ }
892
+ if (liveEditor) {
893
+ // Only show "保存済み" when the live editor still
894
+ // matches what we just saved AND no newer autosave is
895
+ // pending. Otherwise the user has already typed more
896
+ // and the success message would be a lie about which
897
+ // text is actually durable; leave the status as-is so
898
+ // the upcoming save's "編集中…/保存中…/保存済み" can
899
+ // describe the truth.
900
+ const liveText = readEditableText(liveEditor);
901
+ if (liveText === value && !this.saveTimer) {
902
+ this.setStatus(liveEditor, '保存済み', 'ok', 1800);
903
+ }
904
+ }
905
+ } else {
906
+ const isStale = result.code === 'STALE'
907
+ || (typeof result.reason === 'string' && result.reason.indexOf('STALE') === 0);
908
+ if (isStale) {
909
+ // Back up the in-progress text so the user can recover
910
+ // after reloading. Mirror presenter.html behavior. Use
911
+ // the captured `value` (text we tried to save) — never
912
+ // read from the live DOM here, because by the time we
913
+ // resolve the user may have switched tabs and the
914
+ // editor in the DOM belongs to a different deck.
915
+ try {
916
+ if (value) {
917
+ const key = STORAGE_KEYS.NOTES_STALE_BACKUP + ':' + path + '#' + idx;
918
+ localStorage.setItem(key, value);
919
+ }
920
+ } catch (e) { /* ignore */ }
921
+ }
922
+ const reason = result.reason || 'Save failed';
923
+ if (liveEditor) {
924
+ // STALE messages stay until the next edit; transient
925
+ // errors auto-clear after 5s.
926
+ this.setStatus(liveEditor, '保存失敗: ' + reason, 'err', isStale ? 0 : 5000);
927
+ }
928
+ }
929
+ },
930
+
931
+ findEditor(slideIndex) {
932
+ return elements.content.querySelector(
933
+ `.speaker-notes-panel[data-slide-index="${slideIndex}"] [data-role="editor"]`
934
+ );
935
+ },
936
+
937
+ setStatus(editor, text, kind, autoClearMs) {
938
+ const panel = editor.closest('.speaker-notes-panel');
939
+ if (!panel) return;
940
+ const status = panel.querySelector('[data-role="status"]');
941
+ if (!status) return;
942
+ status.textContent = text || '';
943
+ status.classList.remove('ok', 'err');
944
+ if (kind) status.classList.add(kind);
945
+
946
+ const idx = Number(panel.dataset.slideIndex);
947
+ const prev = this.statusClearTimers.get(idx);
948
+ if (prev) clearTimeout(prev);
949
+ this.statusClearTimers.delete(idx);
950
+ if (autoClearMs && autoClearMs > 0) {
951
+ const t = setTimeout(() => {
952
+ if (status.textContent === text) {
953
+ status.textContent = '';
954
+ status.classList.remove('ok', 'err');
955
+ }
956
+ this.statusClearTimers.delete(idx);
957
+ }, autoClearMs);
958
+ this.statusClearTimers.set(idx, t);
959
+ }
960
+ },
961
+
962
+ // ----- Event handlers (arrow funcs to keep `this` bound) -----------
963
+
964
+ handleFocusIn: (event) => {
965
+ const editor = event.target.closest('[data-role="editor"]');
966
+ if (!editor) return;
967
+ const panel = editor.closest('.speaker-notes-panel');
968
+ if (!panel) return;
969
+ if (editor.contentEditable !== 'true') return;
970
+ const tab = state.tabs[state.activeTabIndex];
971
+ if (!tab || !tab.isMarp) return;
972
+ InlineNotesPanel.editing = true;
973
+ InlineNotesPanel.editingSlideIndex = Number(panel.dataset.slideIndex);
974
+ InlineNotesPanel.editingPath = tab.path;
975
+ // Pin the etag at edit start, NOT the live tab.etag — a watcher
976
+ // refresh during the debounce would otherwise smuggle a write
977
+ // past the optimistic lock with the post-refresh etag.
978
+ InlineNotesPanel.editingEtag = tab.etag || null;
979
+ },
980
+
981
+ handleFocusOut: (event) => {
982
+ const editor = event.target.closest('[data-role="editor"]');
983
+ if (!editor) return;
984
+ const justEditedPath = InlineNotesPanel.editingPath;
985
+ InlineNotesPanel.editing = false;
986
+ InlineNotesPanel.flush();
987
+ InlineNotesPanel.editingSlideIndex = -1;
988
+ InlineNotesPanel.editingPath = '';
989
+ InlineNotesPanel.editingEtag = null;
990
+
991
+ // If a watcher update arrived for this deck while the user was
992
+ // editing, we suppressed the re-render to avoid yanking their
993
+ // cursor. Now that focus is gone, catch the slide pane up.
994
+ // Defer to a microtask so the active blur completes first
995
+ // (re-rendering inside focusout can re-target focus weirdly in
996
+ // some browsers).
997
+ const tab = state.tabs[state.activeTabIndex];
998
+ if (tab && tab.isMarp
999
+ && tab.path === justEditedPath
1000
+ && tab.pendingRender) {
1001
+ tab.pendingRender = false;
1002
+ queueMicrotask(() => {
1003
+ // Re-check before firing: the user could have switched
1004
+ // tabs in the same tick.
1005
+ const t = state.tabs[state.activeTabIndex];
1006
+ if (t && t.path === justEditedPath && t.isMarp) {
1007
+ ContentRenderer.renderMarp(t.content, t.css);
1008
+ PresenterView.broadcastSlides();
1009
+ }
1010
+ });
1011
+ }
1012
+ },
1013
+
1014
+ handleInput: (event) => {
1015
+ const editor = event.target.closest('[data-role="editor"]');
1016
+ if (!editor) return;
1017
+ const panel = editor.closest('.speaker-notes-panel');
1018
+ if (!panel) return;
1019
+ // Pin at first input as a safety net (focusin should already
1020
+ // have set these but defenders add belts to suspenders).
1021
+ if (InlineNotesPanel.editingSlideIndex < 0) {
1022
+ const tab = state.tabs[state.activeTabIndex];
1023
+ if (!tab || !tab.isMarp) return;
1024
+ InlineNotesPanel.editing = true;
1025
+ InlineNotesPanel.editingSlideIndex = Number(panel.dataset.slideIndex);
1026
+ InlineNotesPanel.editingPath = tab.path;
1027
+ InlineNotesPanel.editingEtag = tab.etag || null;
1028
+ }
1029
+ // Mirror local cache so a subsequent slide-switch + re-render
1030
+ // doesn't immediately overwrite the just-typed value.
1031
+ const tab = state.tabs[state.activeTabIndex];
1032
+ if (tab && Array.isArray(tab.notes)) {
1033
+ tab.notes[InlineNotesPanel.editingSlideIndex] = readEditableText(editor);
1034
+ }
1035
+ InlineNotesPanel.scheduleSave(editor);
1036
+ },
1037
+
1038
+ handleKeydown: (event) => {
1039
+ // Stop Marp's slide-navigation shortcuts (←/→/Space/F/N/P) from
1040
+ // firing while the user is typing in the notes editor.
1041
+ const editor = event.target.closest('[data-role="editor"]');
1042
+ if (!editor) return;
1043
+ event.stopPropagation();
1044
+ }
1045
+ };
1046
+
1047
+ // ============================================================
1048
+ // Marp Split-Pane Drag Handle (PowerPoint-style)
1049
+ // ============================================================
1050
+
1051
+ // Drives the horizontal divider between the slide pane and the speaker
1052
+ // notes pane. The notes-row height is a CSS custom property so that
1053
+ // CSS Grid (.marp-split) automatically reflows the slide row to occupy
1054
+ // the remaining space. Persisted in localStorage so the chosen ratio
1055
+ // survives reloads / tab switches.
1056
+ const MarpSplitHandle = {
1057
+ dragging: false,
1058
+ startY: 0,
1059
+ startNotesPx: 0,
1060
+ splitEl: null,
1061
+ handleEl: null,
1062
+ // Bound listener references — module-level so we can detach the
1063
+ // exact same function instance even if attach() is called twice.
1064
+ onMouseMove: null,
1065
+ onMouseUp: null,
1066
+
1067
+ // Read the persisted notes row height. Returns NOTES_ROW_DEFAULT_PX
1068
+ // only when the value is missing or non-finite. A literal stored
1069
+ // `0` (user dragged the pane fully closed) is a valid value and is
1070
+ // preserved as 0.
1071
+ getSavedNotesPx() {
1072
+ const raw = localStorage.getItem(STORAGE_KEYS.NOTES_ROW_PX);
1073
+ if (raw === null) return NOTES_ROW_DEFAULT_PX;
1074
+ const n = parseFloat(raw);
1075
+ if (!Number.isFinite(n) || n < 0) return NOTES_ROW_DEFAULT_PX;
1076
+ return n;
1077
+ },
1078
+
1079
+ setNotesPx(px) {
1080
+ if (!this.splitEl) return;
1081
+ this.splitEl.style.setProperty('--marp-notes-row', `${px}px`);
1082
+ },
1083
+
1084
+ clampNotesPx(notesPx, totalHeight) {
1085
+ const max = Math.max(0, totalHeight - SPLIT_HANDLE_PX - SLIDE_ROW_MIN_PX);
1086
+ if (notesPx < NOTES_ROW_MIN_PX) return NOTES_ROW_MIN_PX;
1087
+ if (notesPx > max) return max;
1088
+ return notesPx;
1089
+ },
1090
+
1091
+ // Always clear any body-level drag chrome we may have set so we
1092
+ // can't leak a row-resize cursor / userSelect:none into the rest
1093
+ // of the app even if the mouseup handler never gets a chance to
1094
+ // run (re-render mid-drag, tab switch, etc.).
1095
+ clearDragChrome() {
1096
+ this.dragging = false;
1097
+ if (this.handleEl) this.handleEl.classList.remove('dragging');
1098
+ document.body.style.cursor = '';
1099
+ document.body.style.userSelect = '';
1100
+ },
1101
+
1102
+ attach(splitEl, handleEl) {
1103
+ this.detach();
1104
+ this.splitEl = splitEl;
1105
+ this.handleEl = handleEl;
1106
+ // Clamp the restored value against the current split height —
1107
+ // a value persisted on a tall window should not collapse the
1108
+ // slide pane to nothing when the deck is reopened on a smaller
1109
+ // viewport. We can't measure before the element is in the DOM,
1110
+ // so do it lazily on the next animation frame.
1111
+ const requested = this.getSavedNotesPx();
1112
+ requestAnimationFrame(() => {
1113
+ if (!this.splitEl) return;
1114
+ const totalHeight = this.splitEl.getBoundingClientRect().height;
1115
+ const clamped = totalHeight > 0
1116
+ ? this.clampNotesPx(requested, totalHeight)
1117
+ : requested;
1118
+ this.setNotesPx(clamped);
1119
+ });
1120
+
1121
+ this.onMouseMove = (e) => {
1122
+ if (!this.dragging) return;
1123
+ const dy = e.clientY - this.startY;
1124
+ const totalHeight = this.splitEl.getBoundingClientRect().height;
1125
+ const next = this.clampNotesPx(this.startNotesPx - dy, totalHeight);
1126
+ this.setNotesPx(next);
1127
+ };
1128
+ this.onMouseUp = () => {
1129
+ if (!this.dragging) return;
1130
+ this.clearDragChrome();
1131
+ // Persist the resolved px (read from CSS var, not the drag
1132
+ // delta) so a clamp at the edge is what we save, not the
1133
+ // unbounded value. Use an explicit Number.isFinite check
1134
+ // — `||` would coerce a legitimate 0 (pane fully closed)
1135
+ // back to the default and stop the user's choice from
1136
+ // surviving a reload.
1137
+ const computed = getComputedStyle(this.splitEl)
1138
+ .getPropertyValue('--marp-notes-row');
1139
+ const parsed = parseFloat(computed);
1140
+ const px = Number.isFinite(parsed) && parsed >= 0
1141
+ ? parsed
1142
+ : NOTES_ROW_DEFAULT_PX;
1143
+ localStorage.setItem(STORAGE_KEYS.NOTES_ROW_PX, String(px));
1144
+ };
1145
+
1146
+ handleEl.addEventListener('mousedown', this.onMouseDown);
1147
+ handleEl.addEventListener('dblclick', this.onDoubleClick);
1148
+ document.addEventListener('mousemove', this.onMouseMove);
1149
+ document.addEventListener('mouseup', this.onMouseUp);
1150
+ },
1151
+
1152
+ detach() {
1153
+ // If the user is mid-drag when we tear down (re-render mid-
1154
+ // gesture, tab switch, etc.), the document mouseup handler
1155
+ // we registered would never fire — clean up the body chrome
1156
+ // ourselves so the cursor and userSelect don't get stuck.
1157
+ if (this.dragging) this.clearDragChrome();
1158
+ if (this.handleEl) {
1159
+ this.handleEl.removeEventListener('mousedown', this.onMouseDown);
1160
+ this.handleEl.removeEventListener('dblclick', this.onDoubleClick);
1161
+ }
1162
+ if (this.onMouseMove) document.removeEventListener('mousemove', this.onMouseMove);
1163
+ if (this.onMouseUp) document.removeEventListener('mouseup', this.onMouseUp);
1164
+ this.dragging = false;
1165
+ this.splitEl = null;
1166
+ this.handleEl = null;
1167
+ this.onMouseMove = null;
1168
+ this.onMouseUp = null;
1169
+ },
1170
+
1171
+ onMouseDown: (e) => {
1172
+ const self = MarpSplitHandle;
1173
+ if (!self.splitEl || !self.handleEl) return;
1174
+ self.dragging = true;
1175
+ self.startY = e.clientY;
1176
+ // Use an explicit finite-number check so a stored 0 (the user
1177
+ // has previously collapsed the pane) isn't coerced to DEFAULT
1178
+ // by `||`. Otherwise the next drag jumps from 240px instead
1179
+ // of resizing from the collapsed state.
1180
+ const computed = getComputedStyle(self.splitEl)
1181
+ .getPropertyValue('--marp-notes-row');
1182
+ const parsed = parseFloat(computed);
1183
+ self.startNotesPx = Number.isFinite(parsed) && parsed >= 0
1184
+ ? parsed
1185
+ : NOTES_ROW_DEFAULT_PX;
1186
+ self.handleEl.classList.add('dragging');
1187
+ document.body.style.cursor = 'row-resize';
1188
+ document.body.style.userSelect = 'none';
1189
+ e.preventDefault();
1190
+ },
1191
+
1192
+ onDoubleClick: () => {
1193
+ const self = MarpSplitHandle;
1194
+ // Clamp the default against the current split height so the
1195
+ // reset can't violate SLIDE_ROW_MIN_PX in a short viewport.
1196
+ // Drag / restore paths already clamp; doubleclick used to
1197
+ // skip it and could shrink the slide pane to zero.
1198
+ const totalHeight = self.splitEl
1199
+ ? self.splitEl.getBoundingClientRect().height
1200
+ : 0;
1201
+ const target = totalHeight > 0
1202
+ ? self.clampNotesPx(NOTES_ROW_DEFAULT_PX, totalHeight)
1203
+ : NOTES_ROW_DEFAULT_PX;
1204
+ self.setNotesPx(target);
1205
+ localStorage.setItem(STORAGE_KEYS.NOTES_ROW_PX, String(target));
1206
+ }
1207
+ };
1208
+
693
1209
  // ============================================================
694
1210
  // Presenter View (separate window with speaker notes)
695
1211
  // ============================================================
@@ -698,36 +1214,52 @@
698
1214
  channel: null,
699
1215
  presenterWindow: null,
700
1216
  saveQueue: null, // MDVSaveQueue instance (created in init)
701
- lastSavedEtag: new Map(), // Map<path, etag> — own-save chain rebase
1217
+ // Map<path, etag> — own-save chain rebase. We track presenter and
1218
+ // inline saves separately so that a successful save from one editor
1219
+ // doesn't let the other editor rebase past a stale pinned etag and
1220
+ // silently overwrite the other editor's in-flight changes.
1221
+ lastSavedEtag: new Map(),
1222
+ lastSavedInlineEtag: new Map(),
702
1223
 
703
1224
  init() {
704
- if (typeof BroadcastChannel === 'undefined') return;
705
- if (!window.MDVPresenterChannel || !window.MDVSaveQueue) return;
706
- this.channel = window.MDVPresenterChannel.create();
707
- if (!this.channel) return;
1225
+ if (!window.MDVSaveQueue) return;
708
1226
 
709
1227
  // saveQueue rebases queued edits onto the etag of our last own
710
1228
  // save when there has been no external watcher update. If an
711
1229
  // external edit arrives, fallback to the originally-pinned etag
712
1230
  // so optimistic locking can detect the conflict via 412.
1231
+ //
1232
+ // IMPORTANT: the rebase only applies to Presenter-originated
1233
+ // saves. Inline-panel saves (origin === 'inline') always use
1234
+ // the etag pinned at edit start so a concurrent presenter edit
1235
+ // gets the STALE conflict it deserves. Without this guard a
1236
+ // successful inline save would mark its post-save etag as
1237
+ // "own" for the shared queue, and the presenter's next save
1238
+ // would silently overwrite the inline edit.
1239
+ //
1240
+ // The 5th arg (`origin`) is forwarded to saveNote so the
1241
+ // note-saved broadcast can be filtered correctly on the
1242
+ // presenter side.
1243
+ //
1244
+ // The queue is created unconditionally — independent of
1245
+ // BroadcastChannel availability — so the inline notes panel
1246
+ // can autosave in environments (older browsers / sandboxed
1247
+ // webviews) where the Presenter window cannot be opened.
713
1248
  this.saveQueue = window.MDVSaveQueue.createSaveQueue({
714
- saveFn: (path, slideIndex, note, etag) => {
1249
+ saveFn: (path, slideIndex, note, etag, origin) => {
1250
+ let useEtag = etag;
715
1251
  const tab = state.tabs.find((t) => t.path === path);
716
- const own = this.lastSavedEtag.get(path);
717
- const useEtag = (tab && own && tab.etag === own) ? own : etag;
718
- return this.saveNote(path, slideIndex, note, useEtag);
719
- }
720
- });
721
-
722
- this.channel.addEventListener('message', (e) => {
723
- const msg = e.data || {};
724
- if (msg.type === 'request-slides') {
725
- this.broadcastSlides();
726
- } else if (msg.type === 'goto') {
727
- this.gotoSlide(msg.index);
728
- } else if (msg.type === 'edit-note') {
729
- if (!msg.path) return;
730
- this.saveQueue.enqueue(msg.path, msg.slideIndex, msg.note, msg.etag || null);
1252
+ // Pick the "own etag" map that matches this save's
1253
+ // origin, so a presenter save can't rebase past an
1254
+ // inline edit (and vice versa). The same-origin check
1255
+ // — `tab.etag === own` — is what tells us no other
1256
+ // editor wrote in between, making the rebase safe.
1257
+ const ownMap = origin === 'inline'
1258
+ ? this.lastSavedInlineEtag
1259
+ : this.lastSavedEtag;
1260
+ const own = ownMap.get(path);
1261
+ if (tab && own && tab.etag === own) useEtag = own;
1262
+ return this.saveNote(path, slideIndex, note, useEtag, origin);
731
1263
  }
732
1264
  });
733
1265
 
@@ -737,9 +1269,35 @@
737
1269
  window.MDVTabRegistry.onTabClosed((path) => {
738
1270
  if (this.saveQueue) this.saveQueue.dropPath(path);
739
1271
  this.lastSavedEtag.delete(path);
1272
+ this.lastSavedInlineEtag.delete(path);
740
1273
  });
741
1274
  }
742
1275
 
1276
+ // BroadcastChannel powers the cross-window presenter view.
1277
+ // Where it's missing we keep the inline path working with
1278
+ // saveQueue alone — broadcastSlides / saveNote then no-op
1279
+ // their channel.postMessage calls (channel === null).
1280
+ if (typeof BroadcastChannel !== 'undefined'
1281
+ && window.MDVPresenterChannel) {
1282
+ this.channel = window.MDVPresenterChannel.create();
1283
+ if (this.channel) {
1284
+ this.channel.addEventListener('message', (e) => {
1285
+ const msg = e.data || {};
1286
+ if (msg.type === 'request-slides') {
1287
+ this.broadcastSlides();
1288
+ } else if (msg.type === 'goto') {
1289
+ this.gotoSlide(msg.index);
1290
+ } else if (msg.type === 'edit-note') {
1291
+ if (!msg.path) return;
1292
+ this.saveQueue.enqueue(
1293
+ msg.path, msg.slideIndex, msg.note,
1294
+ msg.etag || null, 'presenter'
1295
+ );
1296
+ }
1297
+ });
1298
+ }
1299
+ }
1300
+
743
1301
  window.addEventListener('beforeunload', () => {
744
1302
  if (this.presenterWindow && !this.presenterWindow.closed) {
745
1303
  this.presenterWindow.close();
@@ -749,22 +1307,47 @@
749
1307
 
750
1308
  // Persist a speaker note edit via the Marpit-token-based API. The
751
1309
  // server resolves the path, validates ETag, and rewrites surgically.
752
- // `editTimeEtag` is the etag captured by the presenter at edit start;
753
- // we send that as If-Match (NOT the live tab.etag) so a watcher
754
- // refresh during the debounce can't smuggle a write past the lock.
755
- async saveNote(path, slideIndex, note, editTimeEtag) {
1310
+ // `editTimeEtag` is the etag captured at edit start; we send that as
1311
+ // If-Match (NOT the live tab.etag) so a watcher refresh during the
1312
+ // debounce can't smuggle a write past the lock.
1313
+ //
1314
+ // `origin` is forwarded into the note-saved broadcast so the
1315
+ // Presenter window can refuse to advance its editingEtag onto a
1316
+ // save that came from the inline panel (otherwise the Presenter's
1317
+ // next autosave would skip the STALE conflict it should otherwise
1318
+ // hit and silently overwrite the inline edit).
1319
+ //
1320
+ // Returns { ok, etag?, normalizedNote?, reason?, code? } so saveQueue
1321
+ // can forward the result to enqueue() awaiters (the main-window inline
1322
+ // notes panel reads this). The presenter window still gets results via
1323
+ // the existing channel.postMessage('note-saved') broadcast.
1324
+ async saveNote(path, slideIndex, note, editTimeEtag, origin) {
1325
+ const broadcast = (payload) => {
1326
+ // No-op when BroadcastChannel was unavailable at init —
1327
+ // inline autosaves still work because callers also read
1328
+ // the saveFn return value via saveQueue.enqueue().then().
1329
+ if (!this.channel) return;
1330
+ this.channel.postMessage({
1331
+ type: 'note-saved',
1332
+ slideIndex,
1333
+ origin: origin || 'unknown',
1334
+ ...payload
1335
+ });
1336
+ };
1337
+
756
1338
  const tab = state.tabs.find((t) => t.path === path);
757
- if (!tab || !tab.isMarp) return;
1339
+ if (!tab || !tab.isMarp) {
1340
+ return { ok: false, reason: 'No active Marp tab' };
1341
+ }
758
1342
  const ifMatch = editTimeEtag || tab.etag;
759
1343
  if (!ifMatch) {
760
1344
  // GET degrade or no etag yet — refuse without writing.
761
- this.channel.postMessage({
762
- type: 'note-saved',
763
- slideIndex,
1345
+ const result = {
764
1346
  ok: false,
765
1347
  reason: 'Deck not parseable (degraded mode)'
766
- });
767
- return;
1348
+ };
1349
+ broadcast(result);
1350
+ return result;
768
1351
  }
769
1352
 
770
1353
  let res, data;
@@ -772,10 +1355,9 @@
772
1355
  ({ res, data } = await window.MDVApi.saveMarpNote(path, slideIndex, note, ifMatch));
773
1356
  } catch (err) {
774
1357
  console.error('saveNote network error', err);
775
- this.channel.postMessage({
776
- type: 'note-saved', slideIndex, ok: false, reason: 'Network error'
777
- });
778
- return;
1358
+ const result = { ok: false, reason: 'Network error' };
1359
+ broadcast(result);
1360
+ return result;
779
1361
  }
780
1362
 
781
1363
  if (res.status === 412 && data.code === 'STALE') {
@@ -786,13 +1368,13 @@
786
1368
  // file_update event will refresh tab.{content,notes,etag}
787
1369
  // together once chokidar sees the change. Until then, all
788
1370
  // PUTs from this tab keep returning 412.
789
- this.channel.postMessage({
790
- type: 'note-saved',
791
- slideIndex,
1371
+ const result = {
792
1372
  ok: false,
1373
+ code: 'STALE',
793
1374
  reason: 'STALE — file changed externally; please reload'
794
- });
795
- return;
1375
+ };
1376
+ broadcast(result);
1377
+ return result;
796
1378
  }
797
1379
 
798
1380
  if (res.ok && data.ok) {
@@ -801,29 +1383,38 @@
801
1383
  // immediately see the saved content. Otherwise raw/notes
802
1384
  // would lag until the watcher's file_update event arrives.
803
1385
  tab.etag = data.etag;
804
- this.lastSavedEtag.set(path, data.etag);
1386
+ // Track post-save etag separately per origin. The shared
1387
+ // queue uses this map to rebase queued same-origin
1388
+ // autosaves onto our own post-save etag (so a user typing
1389
+ // continuously gets through), but recording into the OTHER
1390
+ // origin's map would let it skip STALE and overwrite an
1391
+ // in-flight edit from the concurrent editor.
1392
+ if (origin === 'inline') {
1393
+ this.lastSavedInlineEtag.set(path, data.etag);
1394
+ } else {
1395
+ this.lastSavedEtag.set(path, data.etag);
1396
+ }
805
1397
  if (typeof data.source === 'string') tab.raw = data.source;
806
1398
  if (Array.isArray(data.notes)) tab.notes = data.notes;
807
1399
  if (Array.isArray(data.notesMultiplicity)) {
808
1400
  tab.notesMultiplicity = data.notesMultiplicity;
809
1401
  }
810
- this.channel.postMessage({
811
- type: 'note-saved',
812
- slideIndex,
1402
+ const result = {
813
1403
  ok: true,
814
1404
  etag: data.etag,
815
1405
  normalizedNote: data.normalizedNote
816
- });
1406
+ };
1407
+ broadcast(result);
817
1408
  // Re-broadcast so the presenter window picks up the new
818
1409
  // notes/etag without waiting for the watcher event.
819
1410
  this.broadcastSlides();
820
- return;
1411
+ return result;
821
1412
  }
822
1413
 
823
1414
  const reason = data && (data.error || data.code) || 'Save failed';
824
- this.channel.postMessage({
825
- type: 'note-saved', slideIndex, ok: false, reason
826
- });
1415
+ const result = { ok: false, reason };
1416
+ broadcast(result);
1417
+ return result;
827
1418
  },
828
1419
 
829
1420
  open() {
@@ -881,6 +1472,10 @@
881
1472
  const slides = elements.content.querySelectorAll('.marpit > svg[data-marpit-svg]');
882
1473
  if (!slides.length || index < 0 || index >= slides.length) return;
883
1474
  slides.forEach((s, i) => s.classList.toggle('active', i === index));
1475
+ const panels = elements.content.querySelectorAll(
1476
+ '#marpNotesArea > .speaker-notes-panel'
1477
+ );
1478
+ panels.forEach((p, i) => p.classList.toggle('active', i === index));
884
1479
  marpCurrentSlide = index;
885
1480
  const counter = elements.content.querySelector('.slide-counter');
886
1481
  if (counter) counter.textContent = `${index + 1} / ${slides.length}`;
@@ -924,56 +1519,57 @@
924
1519
  // Add new Marp style with navigation overrides
925
1520
  const style = document.createElement('style');
926
1521
  style.id = 'marp-style';
927
- // Add slide navigation CSS
928
- const navOverrides = `
929
- /* Marp slide navigation */
930
- .marpit {
931
- position: relative;
932
- display: flex;
933
- flex-direction: column;
934
- align-items: center;
935
- padding: 20px;
936
- padding-bottom: 80px;
937
- }
938
- .marpit > svg[data-marpit-svg] {
939
- display: none;
940
- max-width: 100%;
941
- height: auto;
942
- box-shadow: 0 4px 20px rgba(0,0,0,0.15);
943
- border-radius: 4px;
944
- }
945
- .marpit > svg[data-marpit-svg].active {
946
- display: block;
947
- }
948
- @media print {
949
- .marpit {
950
- padding: 0 !important;
951
- background: transparent !important;
952
- }
953
- .marpit > svg[data-marpit-svg] {
954
- display: block !important;
955
- width: 100% !important;
956
- height: auto !important;
957
- max-width: none !important;
958
- box-shadow: none !important;
959
- border-radius: 0 !important;
960
- page-break-after: always;
961
- page-break-inside: avoid;
962
- }
963
- .marpit > svg[data-marpit-svg]:last-child {
964
- page-break-after: avoid;
965
- }
966
- .marp-nav { display: none !important; }
967
- }
968
- `;
969
- style.textContent = css + navOverrides;
1522
+ // marp-core's per-deck CSS is injected unmodified; the split
1523
+ // layout / responsive sizing rules live in styles.css so they
1524
+ // load once and don't need to be repeated per render.
1525
+ style.textContent = css;
970
1526
  document.head.appendChild(style);
971
1527
  }
972
1528
 
973
- elements.content.innerHTML = htmlContent;
1529
+ // PowerPoint-style split: top = slide stage, bottom = notes
1530
+ // editor, with a draggable horizontal handle between them. The
1531
+ // marp-core HTML (`<div class="marpit">…</div>`) lives inside
1532
+ // .marp-slide-area; speaker-notes panels stack inside
1533
+ // .marp-notes-area and the active one is shown via JS.
1534
+ elements.content.innerHTML = `
1535
+ <div class="marp-split" id="marpSplit">
1536
+ <div class="marp-slide-area" id="marpSlideArea">${htmlContent}</div>
1537
+ <div class="marp-split-handle" id="marpSplitHandle" title="ドラッグでスライド/ノートの比率を変更(ダブルクリックでリセット)"></div>
1538
+ <div class="marp-notes-area" id="marpNotesArea"></div>
1539
+ </div>
1540
+ `;
974
1541
 
975
- // Add navigation controls to marpit container
976
1542
  const marpit = elements.content.querySelector('.marpit');
1543
+ const notesArea = document.getElementById('marpNotesArea');
1544
+ if (marpit && notesArea) {
1545
+ const tab = state.tabs[state.activeTabIndex];
1546
+ const notes = (tab && Array.isArray(tab.notes)) ? tab.notes : [];
1547
+ const multiplicity = (tab && Array.isArray(tab.notesMultiplicity))
1548
+ ? tab.notesMultiplicity : [];
1549
+ const hasEtag = !!(tab && tab.etag);
1550
+ const svgs = marpit.querySelectorAll('svg[data-marpit-svg]');
1551
+ svgs.forEach((_svg, i) => {
1552
+ const panel = InlineNotesPanel.buildPanel(
1553
+ i,
1554
+ notes[i] || '',
1555
+ multiplicity[i] || 0,
1556
+ hasEtag
1557
+ );
1558
+ notesArea.appendChild(panel);
1559
+ });
1560
+ InlineNotesPanel.attach();
1561
+ }
1562
+
1563
+ // Wire up the split-pane drag handle.
1564
+ const splitEl = document.getElementById('marpSplit');
1565
+ const handleEl = document.getElementById('marpSplitHandle');
1566
+ if (splitEl && handleEl) {
1567
+ MarpSplitHandle.attach(splitEl, handleEl);
1568
+ }
1569
+
1570
+ // Add navigation controls. The nav is appended to .content (NOT
1571
+ // marpit) so its `position: fixed` doesn't get clipped by the
1572
+ // grid container's overflow:hidden rule.
977
1573
  if (marpit) {
978
1574
  const nav = document.createElement('div');
979
1575
  nav.className = 'marp-nav';
@@ -1005,7 +1601,10 @@
1005
1601
  </svg>
1006
1602
  </button>
1007
1603
  `;
1008
- marpit.appendChild(nav);
1604
+ // Append to .content directly (NOT marpit) so it sits
1605
+ // outside .marp-split — fixed positioning + overflow:hidden
1606
+ // on the grid container would otherwise interact poorly.
1607
+ elements.content.appendChild(nav);
1009
1608
  }
1010
1609
 
1011
1610
  // Initialize slide navigation
@@ -1034,10 +1633,19 @@
1034
1633
  marpCurrentSlide = 0;
1035
1634
  }
1036
1635
 
1636
+ // Cache the panels alongside the slides so flipping the active
1637
+ // class is one DOM read instead of a fresh query per click.
1638
+ const panels = elements.content.querySelectorAll(
1639
+ '#marpNotesArea > .speaker-notes-panel'
1640
+ );
1641
+
1037
1642
  const showSlide = (index) => {
1038
1643
  slides.forEach((slide, i) => {
1039
1644
  slide.classList.toggle('active', i === index);
1040
1645
  });
1646
+ panels.forEach((panel, i) => {
1647
+ panel.classList.toggle('active', i === index);
1648
+ });
1041
1649
  marpCurrentSlide = index;
1042
1650
  if (counter) {
1043
1651
  counter.textContent = `${index + 1} / ${slides.length}`;
@@ -1179,6 +1787,10 @@
1179
1787
  },
1180
1788
 
1181
1789
  cleanupMarp() {
1790
+ // Flush + detach BEFORE the DOM is wiped so a pending
1791
+ // 800ms save timer doesn't fire after the editor element is gone.
1792
+ InlineNotesPanel.detach();
1793
+ MarpSplitHandle.detach();
1182
1794
  elements.content.classList.remove('marp-viewer');
1183
1795
  document.body.classList.remove('marp-fullscreen');
1184
1796
  if (marpKeyHandler) {
@@ -1299,14 +1911,57 @@
1299
1911
  async open(path) {
1300
1912
  const existingIndex = state.tabs.findIndex(t => t.path === path);
1301
1913
  if (existingIndex >= 0) {
1302
- this.switch(existingIndex);
1914
+ await this.switch(existingIndex);
1303
1915
  return;
1304
1916
  }
1305
1917
 
1306
- const response = await MDVApi.fetchFile(path);
1307
- 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
+ }
1308
1962
 
1309
1963
  if (data.error) {
1964
+ unlockOnFailure();
1310
1965
  alert('Error: ' + data.error);
1311
1966
  return;
1312
1967
  }
@@ -1345,9 +2000,31 @@
1345
2000
  updateUrlPath(path);
1346
2001
  },
1347
2002
 
1348
- 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
+
1349
2011
  if (state.activeTabIndex >= 0 && state.activeTabIndex < state.tabs.length) {
1350
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
+ }
1351
2028
  const textarea = document.getElementById('editorTextarea');
1352
2029
  if (textarea) {
1353
2030
  state.tabs[state.activeTabIndex].raw = textarea.value;
@@ -1368,19 +2045,30 @@
1368
2045
  EditorManager.updateButton();
1369
2046
  }
1370
2047
 
1371
- 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;
1372
2054
  this.render();
1373
2055
  this.renderActive();
1374
- WebSocketManager.watchFile(state.tabs[index].path);
2056
+ WebSocketManager.watchFile(state.tabs[newIndex].path);
1375
2057
  FileTreeManager.updateHighlight();
1376
- updateUrlPath(state.tabs[index].path);
2058
+ updateUrlPath(state.tabs[newIndex].path);
1377
2059
  },
1378
2060
 
1379
2061
  close(index) {
1380
2062
  // Warn about unsaved changes
1381
2063
  if (state.isEditMode && state.hasUnsavedChanges && index === state.activeTabIndex) {
1382
2064
  DialogManager.show('未保存の変更', {
1383
- 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(自動保存処理中の場合、その時点までの内容がファイルに残る可能性があります)',
1384
2072
  isConfirm: true,
1385
2073
  danger: true,
1386
2074
  confirmText: '閉じる',
@@ -1388,11 +2076,28 @@
1388
2076
  state.hasUnsavedChanges = false;
1389
2077
  state.isEditMode = false;
1390
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();
1391
2083
  TabManager.close(index);
1392
2084
  }
1393
2085
  });
1394
2086
  return;
1395
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
+
1396
2101
  const closingPath = state.tabs[index] && state.tabs[index].path;
1397
2102
  state.tabs.splice(index, 1);
1398
2103
  if (closingPath && window.MDVTabRegistry) {
@@ -1480,7 +2185,28 @@
1480
2185
  // Editor Manager
1481
2186
  // ============================================================
1482
2187
 
2188
+ const EDITOR_AUTOSAVE_DEBOUNCE_MS = 1500;
2189
+
1483
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
+
1484
2210
  async toggle() {
1485
2211
  if (state.activeTabIndex < 0) return;
1486
2212
  const tab = state.tabs[state.activeTabIndex];
@@ -1495,6 +2221,96 @@
1495
2221
  state.isEditMode ? this.show() : await this.hide();
1496
2222
  },
1497
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
+
1498
2314
  updateButton() {
1499
2315
  elements.editToggle.classList.toggle('active', state.isEditMode);
1500
2316
  elements.editLabel.textContent = state.isEditMode ? 'View' : 'Edit';
@@ -1526,6 +2342,7 @@
1526
2342
  state.hasUnsavedChanges = true;
1527
2343
  elements.editorStatus.textContent = 'Modified';
1528
2344
  elements.editorStatus.className = 'editor-status modified';
2345
+ EditorManager.scheduleAutosave();
1529
2346
  });
1530
2347
 
1531
2348
  setTimeout(() => {
@@ -1573,6 +2390,29 @@
1573
2390
  if (state.activeTabIndex < 0) return;
1574
2391
  const tab = state.tabs[state.activeTabIndex];
1575
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
+
1576
2416
  const textarea = document.getElementById('editorTextarea');
1577
2417
  let topLineNumber = -1;
1578
2418
  let scrollPercentage = 0;
@@ -1654,46 +2494,179 @@
1654
2494
  },
1655
2495
 
1656
2496
  async save() {
1657
- if (state.activeTabIndex < 0 || !state.isEditMode) return;
2497
+ if (state.activeTabIndex < 0) return;
1658
2498
 
1659
- const tab = state.tabs[state.activeTabIndex];
1660
- const textarea = document.getElementById('editorTextarea');
1661
- 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
+ }
1662
2505
 
1663
- 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';
1664
2543
 
1665
- try {
1666
- elements.editorStatus.textContent = 'Saving...';
1667
- elements.editorStatus.className = 'editor-status';
2544
+ const response = await MDVApi.saveFile(path, content, signal);
2545
+ const result = await response.json();
1668
2546
 
1669
- 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
+ }
1670
2560
 
1671
- 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
+ }
1672
2591
 
1673
- if (result.error) {
1674
- elements.editorStatus.textContent = 'Error: ' + result.error;
1675
- elements.editorStatus.className = 'editor-status modified';
1676
- 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;
1677
2641
  }
2642
+ })();
1678
2643
 
1679
- tab.raw = newContent;
1680
- state.hasUnsavedChanges = false;
1681
- elements.editorStatus.textContent = 'Saved!';
1682
- elements.editorStatus.className = 'editor-status saved';
1683
-
1684
- setTimeout(() => {
1685
- elements.editorStatus.textContent = 'Ready';
1686
- elements.editorStatus.className = 'editor-status';
1687
- }, 2000);
1688
-
1689
- } catch (e) {
1690
- elements.editorStatus.textContent = 'Error: ' + e.message;
1691
- elements.editorStatus.className = 'editor-status modified';
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
+ }
2662
+ }
1692
2663
  }
1693
2664
  },
1694
2665
 
1695
2666
  init() {
1696
- elements.editToggle.addEventListener('click', () => this.toggle());
2667
+ elements.editToggle.addEventListener('click', () => {
2668
+ this.toggle().catch(() => { /* status already shown */ });
2669
+ });
1697
2670
  }
1698
2671
  };
1699
2672
 
@@ -1715,9 +2688,15 @@
1715
2688
 
1716
2689
  const tab = state.tabs[state.activeTabIndex];
1717
2690
 
1718
- // editモード中は閉じてからPDF生成
2691
+ // editモード中は閉じてからPDF生成。autosave が失敗していた
2692
+ // ら toggle() が throw する → 印刷を中止して edit モード維持
2693
+ // (古い on-disk 内容で勝手に PDF 化しないように)。
1719
2694
  if (state.isEditMode) {
1720
- await EditorManager.toggle();
2695
+ try {
2696
+ await EditorManager.toggle();
2697
+ } catch (_e) {
2698
+ return;
2699
+ }
1721
2700
  }
1722
2701
 
1723
2702
  if (tab.isMarp || this.isMarpPresentation()) {
@@ -2256,7 +3235,7 @@
2256
3235
  shortcuts: {
2257
3236
  'b': { handler: () => SidebarManager.toggle() },
2258
3237
  'w': { handler: () => TabManager.close(state.activeTabIndex), requiresTab: true },
2259
- 'e': { handler: () => EditorManager.toggle(), requiresTab: true },
3238
+ 'e': { handler: () => EditorManager.toggle().catch(() => { /* status already shown */ }), requiresTab: true },
2260
3239
  's': { handler: () => EditorManager.save(), requiresEditMode: true },
2261
3240
  'p': { handler: () => PrintManager.print(), requiresTab: true }
2262
3241
  },