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/CHANGELOG.md +60 -0
- package/package.json +1 -1
- package/src/static/app.js +708 -96
- 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) {
|