mdv-live 0.5.15 → 0.5.16

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) {