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/CHANGELOG.md +102 -0
- package/package.json +1 -1
- package/src/static/app.js +1114 -135
- package/src/static/lib/apiClient.js +3 -2
- package/src/static/lib/saveQueue.js +51 -22
- package/src/static/presenter.html +23 -4
- package/src/static/styles.css +211 -3
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
|
-
|
|
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 (
|
|
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
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
|
|
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)
|
|
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
|
-
|
|
762
|
-
type: 'note-saved',
|
|
763
|
-
slideIndex,
|
|
1345
|
+
const result = {
|
|
764
1346
|
ok: false,
|
|
765
1347
|
reason: 'Deck not parseable (degraded mode)'
|
|
766
|
-
}
|
|
767
|
-
|
|
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
|
-
|
|
776
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
825
|
-
|
|
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
|
-
//
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1307
|
-
|
|
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
|
-
|
|
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[
|
|
2056
|
+
WebSocketManager.watchFile(state.tabs[newIndex].path);
|
|
1375
2057
|
FileTreeManager.updateHighlight();
|
|
1376
|
-
updateUrlPath(state.tabs[
|
|
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
|
-
|
|
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
|
|
2497
|
+
if (state.activeTabIndex < 0) return;
|
|
1658
2498
|
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
if (
|
|
2499
|
+
// Cancel any pending debounce; whether we got here via the
|
|
2500
|
+
// timer, Cmd+S, or flushAutosave, this single save covers it.
|
|
2501
|
+
if (this.saveTimer) {
|
|
2502
|
+
clearTimeout(this.saveTimer);
|
|
2503
|
+
this.saveTimer = null;
|
|
2504
|
+
}
|
|
1662
2505
|
|
|
1663
|
-
|
|
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
|
-
|
|
1666
|
-
|
|
1667
|
-
elements.editorStatus.className = 'editor-status';
|
|
2544
|
+
const response = await MDVApi.saveFile(path, content, signal);
|
|
2545
|
+
const result = await response.json();
|
|
1668
2546
|
|
|
1669
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
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
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
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', () =>
|
|
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
|
-
|
|
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
|
},
|