mdv-live 0.5.4 → 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.js CHANGED
@@ -10,11 +10,13 @@ import path from 'path';
10
10
  import { fileURLToPath } from 'url';
11
11
 
12
12
  import { setupFileRoutes } from './api/file.js';
13
+ import { setupMarpNoteRoutes } from './api/marpNote.js';
13
14
  import { setupPdfRoutes } from './api/pdf.js';
14
15
  import { setupTreeRoutes } from './api/tree.js';
15
16
  import { setupUploadRoutes } from './api/upload.js';
16
17
  import { setupWatcher } from './watcher.js';
17
18
  import { setupWebSocket } from './websocket.js';
19
+ import { sweepStaleTemps } from './utils/atomicWrite.js';
18
20
 
19
21
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
22
  const STATIC_DIR = path.join(__dirname, 'static');
@@ -26,11 +28,12 @@ const { version: VERSION } = JSON.parse(
26
28
  * Setup API routes for the Express app
27
29
  * @param {express.Application} app - Express application instance
28
30
  */
29
- function setupApiRoutes(app) {
31
+ function setupApiRoutes(app, options) {
30
32
  setupTreeRoutes(app);
31
33
  setupFileRoutes(app);
32
34
  setupUploadRoutes(app);
33
35
  setupPdfRoutes(app);
36
+ setupMarpNoteRoutes(app, { port: options.port });
34
37
 
35
38
  app.get('/api/info', (req, res) => {
36
39
  res.json({
@@ -61,11 +64,31 @@ export function createMdvServer(options) {
61
64
 
62
65
  app.locals.rootDir = path.resolve(rootDir);
63
66
 
64
- app.use(express.json());
65
- app.use(express.urlencoded({ extended: true }));
67
+ app.use(express.json({ limit: '128kb' }));
68
+ app.use(express.urlencoded({ extended: true, limit: '128kb' }));
66
69
  app.use('/static', express.static(STATIC_DIR));
67
70
 
68
- setupApiRoutes(app);
71
+ setupApiRoutes(app, { port });
72
+
73
+ // Body-parser error handler (size limit, malformed JSON) must come AFTER
74
+ // the routes so route-level errors fall through to the default handler.
75
+ app.use((err, req, res, next) => {
76
+ if (err && err.type === 'entity.too.large') {
77
+ return res.status(413).json({
78
+ ok: false,
79
+ code: 'PAYLOAD_TOO_LARGE',
80
+ error: 'request body exceeds limit'
81
+ });
82
+ }
83
+ if (err && err.type === 'entity.parse.failed') {
84
+ return res.status(400).json({
85
+ ok: false,
86
+ code: 'INVALID_NOTE',
87
+ error: 'malformed JSON'
88
+ });
89
+ }
90
+ next(err);
91
+ });
69
92
 
70
93
  // Catch-all: serve index.html for SPA (path-based routing)
71
94
  // Express matches routes in order, so API/static routes above take priority
@@ -80,6 +103,8 @@ export function createMdvServer(options) {
80
103
  app.locals.wss = wss;
81
104
 
82
105
  function start() {
106
+ // Best-effort sweep of stale temp files left by a previous crashed write.
107
+ sweepStaleTemps(app.locals.rootDir).catch(() => {});
83
108
  return new Promise((resolve) => {
84
109
  server.listen(port, () => {
85
110
  console.log(`MDV server running at http://localhost:${port}`);
package/src/static/app.js CHANGED
@@ -11,7 +11,9 @@
11
11
 
12
12
  const STORAGE_KEYS = {
13
13
  THEME: 'mdv-theme',
14
- SIDEBAR_WIDTH: 'mdv-sidebar-width'
14
+ SIDEBAR_WIDTH: 'mdv-sidebar-width',
15
+ PDF_STYLE_PATH: 'mdv-pdf-style-path',
16
+ PDF_OPTIONS_PATH: 'mdv-pdf-options-path'
15
17
  };
16
18
 
17
19
  const HLJS_THEMES = {
@@ -85,7 +87,9 @@
85
87
  isResizing: false,
86
88
  skipScrollRestore: false,
87
89
  uploadTargetPath: '',
88
- rootPath: ''
90
+ rootPath: '',
91
+ pdfStylePath: localStorage.getItem(STORAGE_KEYS.PDF_STYLE_PATH) || '',
92
+ pdfOptionsPath: localStorage.getItem(STORAGE_KEYS.PDF_OPTIONS_PATH) || ''
89
93
  };
90
94
 
91
95
  // ============================================================
@@ -121,6 +125,12 @@
121
125
  statusText: document.getElementById('statusText'),
122
126
  resizeHandle: document.getElementById('resizeHandle'),
123
127
  editToggle: document.getElementById('editToggle'),
128
+ pdfStyleToggle: document.getElementById('pdfStyleToggle'),
129
+ pdfStylePanel: document.getElementById('pdfStylePanel'),
130
+ pdfStylePath: document.getElementById('pdfStylePath'),
131
+ pdfOptionsPath: document.getElementById('pdfOptionsPath'),
132
+ pdfStyleApply: document.getElementById('pdfStyleApply'),
133
+ pdfStyleClear: document.getElementById('pdfStyleClear'),
124
134
  editLabel: document.getElementById('editLabel'),
125
135
  editorStatus: document.getElementById('editorStatus'),
126
136
  shutdownBtn: document.getElementById('shutdownBtn'),
@@ -163,6 +173,10 @@
163
173
  });
164
174
  }
165
175
 
176
+ function normalizeUserPath(path) {
177
+ return path.trim().replace(/^\/+/, '');
178
+ }
179
+
166
180
  async function apiRequest(url, options = {}) {
167
181
  const response = await fetch(url, options);
168
182
  const data = await response.json();
@@ -235,6 +249,101 @@
235
249
  }
236
250
  };
237
251
 
252
+ // ============================================================
253
+ // PDF Style Preview
254
+ // ============================================================
255
+
256
+ const PdfStyleManager = {
257
+ scopedCssId: 'pdf-style-preview-css',
258
+
259
+ init() {
260
+ elements.pdfStylePath.value = state.pdfStylePath;
261
+ elements.pdfOptionsPath.value = state.pdfOptionsPath;
262
+ elements.pdfStyleToggle.addEventListener('click', () => {
263
+ elements.pdfStylePanel.classList.toggle('hidden');
264
+ });
265
+ elements.pdfStyleApply.addEventListener('click', () => this.applyFromInputs());
266
+ elements.pdfStyleClear.addEventListener('click', () => this.clear());
267
+ elements.pdfStylePath.addEventListener('keydown', (event) => {
268
+ if (event.key === 'Enter') this.applyFromInputs();
269
+ });
270
+ elements.pdfOptionsPath.addEventListener('keydown', (event) => {
271
+ if (event.key === 'Enter') this.applyFromInputs();
272
+ });
273
+ this.loadPreviewCss();
274
+ },
275
+
276
+ getExportOptions() {
277
+ return {
278
+ stylePath: normalizeUserPath(state.pdfStylePath),
279
+ pdfOptionsPath: normalizeUserPath(state.pdfOptionsPath)
280
+ };
281
+ },
282
+
283
+ async applyFromInputs() {
284
+ state.pdfStylePath = normalizeUserPath(elements.pdfStylePath.value);
285
+ state.pdfOptionsPath = normalizeUserPath(elements.pdfOptionsPath.value);
286
+ elements.pdfStylePath.value = state.pdfStylePath;
287
+ elements.pdfOptionsPath.value = state.pdfOptionsPath;
288
+ localStorage.setItem(STORAGE_KEYS.PDF_STYLE_PATH, state.pdfStylePath);
289
+ localStorage.setItem(STORAGE_KEYS.PDF_OPTIONS_PATH, state.pdfOptionsPath);
290
+ await this.loadPreviewCss();
291
+ TabManager.renderActive();
292
+ },
293
+
294
+ clear() {
295
+ state.pdfStylePath = '';
296
+ state.pdfOptionsPath = '';
297
+ elements.pdfStylePath.value = '';
298
+ elements.pdfOptionsPath.value = '';
299
+ localStorage.removeItem(STORAGE_KEYS.PDF_STYLE_PATH);
300
+ localStorage.removeItem(STORAGE_KEYS.PDF_OPTIONS_PATH);
301
+ const oldStyle = document.getElementById(this.scopedCssId);
302
+ if (oldStyle) oldStyle.remove();
303
+ TabManager.renderActive();
304
+ elements.statusText.textContent = 'PDF style cleared';
305
+ setTimeout(() => { elements.statusText.textContent = 'Connected'; }, 1600);
306
+ },
307
+
308
+ async loadPreviewCss() {
309
+ const oldStyle = document.getElementById(this.scopedCssId);
310
+ if (oldStyle) oldStyle.remove();
311
+ if (!state.pdfStylePath) return;
312
+
313
+ try {
314
+ const response = await fetch(`/raw/${state.pdfStylePath}`);
315
+ if (!response.ok) throw new Error('CSS file not found');
316
+ const cssText = await response.text();
317
+ const style = document.createElement('style');
318
+ style.id = this.scopedCssId;
319
+ style.textContent = this.scopeCss(cssText);
320
+ document.head.appendChild(style);
321
+ elements.statusText.textContent = 'PDF style applied';
322
+ setTimeout(() => { elements.statusText.textContent = 'Connected'; }, 1600);
323
+ } catch (error) {
324
+ console.error('PDF style preview error:', error);
325
+ elements.statusText.textContent = 'PDF style failed';
326
+ setTimeout(() => { elements.statusText.textContent = 'Connected'; }, 2500);
327
+ }
328
+ },
329
+
330
+ scopeCss(cssText) {
331
+ const scope = '.markdown-body.pdf-style-preview';
332
+ const withoutComments = cssText.replace(/\/\*[\s\S]*?\*\//g, '');
333
+ return withoutComments.replace(/([^{}]+)\{/g, (match, selectorText) => {
334
+ const selectors = selectorText.trim();
335
+ if (!selectors || selectors.startsWith('@')) return match;
336
+ const scopedSelectors = selectors.split(',').map((selector) => {
337
+ const trimmed = selector.trim();
338
+ if (trimmed === ':root' || trimmed === 'body') return scope;
339
+ if (trimmed.startsWith(scope)) return trimmed;
340
+ return `${scope} ${trimmed}`;
341
+ });
342
+ return `${scopedSelectors.join(', ')} {`;
343
+ });
344
+ }
345
+ };
346
+
238
347
  // ============================================================
239
348
  // Sidebar Management
240
349
  // ============================================================
@@ -371,7 +480,13 @@
371
480
 
372
481
  if (tab.isMarp) {
373
482
  if (data.css) tab.css = data.css;
483
+ if (data.notes) tab.notes = data.notes;
484
+ if (data.notesMultiplicity) tab.notesMultiplicity = data.notesMultiplicity;
485
+ if (data.etag) tab.etag = data.etag;
486
+ if (data.lineEnding) tab.lineEnding = data.lineEnding;
487
+ if (typeof data.hasBom !== 'undefined') tab.hasBom = !!data.hasBom;
374
488
  ContentRenderer.renderMarp(data.content, tab.css);
489
+ PresenterView.broadcastSlides();
375
490
  } else {
376
491
  const currentScroll = saveScrollPosition(elements.content);
377
492
  ContentRenderer.render(data.content, data.fileType || tab.fileType);
@@ -388,7 +503,7 @@
388
503
  async load(retries = 5) {
389
504
  for (let i = 0; i < retries; i++) {
390
505
  try {
391
- const response = await fetch('/api/tree');
506
+ const response = await MDVApi.fetchTree();
392
507
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
393
508
  const tree = await response.json();
394
509
  elements.fileTree.innerHTML = this.renderItems(tree);
@@ -406,7 +521,7 @@
406
521
 
407
522
  async refresh() {
408
523
  try {
409
- const response = await fetch('/api/tree');
524
+ const response = await MDVApi.fetchTree();
410
525
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
411
526
  const tree = await response.json();
412
527
  await this.update(tree);
@@ -480,7 +595,7 @@
480
595
 
481
596
  async expandDirectory(path, childrenContainer) {
482
597
  try {
483
- const response = await fetch(`/api/tree/expand?path=${encodeURIComponent(path)}`);
598
+ const response = await MDVApi.expandTree(path);
484
599
  const children = await response.json();
485
600
  childrenContainer.innerHTML = this.renderItems(children);
486
601
 
@@ -560,11 +675,214 @@
560
675
  let marpCurrentSlide = 0;
561
676
  let marpKeyHandler = null;
562
677
 
678
+ // ============================================================
679
+ // Presenter View (separate window with speaker notes)
680
+ // ============================================================
681
+
682
+ const PresenterView = {
683
+ channel: null,
684
+ presenterWindow: null,
685
+ saveQueue: null, // MDVSaveQueue instance (created in init)
686
+ lastSavedEtag: new Map(), // Map<path, etag> — own-save chain rebase
687
+
688
+ init() {
689
+ if (typeof BroadcastChannel === 'undefined') return;
690
+ if (!window.MDVPresenterChannel || !window.MDVSaveQueue) return;
691
+ this.channel = window.MDVPresenterChannel.create();
692
+ if (!this.channel) return;
693
+
694
+ // saveQueue rebases queued edits onto the etag of our last own
695
+ // save when there has been no external watcher update. If an
696
+ // external edit arrives, fallback to the originally-pinned etag
697
+ // so optimistic locking can detect the conflict via 412.
698
+ this.saveQueue = window.MDVSaveQueue.createSaveQueue({
699
+ saveFn: (path, slideIndex, note, etag) => {
700
+ const tab = state.tabs.find((t) => t.path === path);
701
+ const own = this.lastSavedEtag.get(path);
702
+ const useEtag = (tab && own && tab.etag === own) ? own : etag;
703
+ return this.saveNote(path, slideIndex, note, useEtag);
704
+ }
705
+ });
706
+
707
+ this.channel.addEventListener('message', (e) => {
708
+ const msg = e.data || {};
709
+ if (msg.type === 'request-slides') {
710
+ this.broadcastSlides();
711
+ } else if (msg.type === 'goto') {
712
+ this.gotoSlide(msg.index);
713
+ } else if (msg.type === 'edit-note') {
714
+ if (!msg.path) return;
715
+ this.saveQueue.enqueue(msg.path, msg.slideIndex, msg.note, msg.etag || null);
716
+ }
717
+ });
718
+
719
+ // When a tab closes, drop its queued saves and own-etag entry to
720
+ // prevent a slow leak under long sessions with many decks.
721
+ if (window.MDVTabRegistry) {
722
+ window.MDVTabRegistry.onTabClosed((path) => {
723
+ if (this.saveQueue) this.saveQueue.dropPath(path);
724
+ this.lastSavedEtag.delete(path);
725
+ });
726
+ }
727
+
728
+ window.addEventListener('beforeunload', () => {
729
+ if (this.presenterWindow && !this.presenterWindow.closed) {
730
+ this.presenterWindow.close();
731
+ }
732
+ });
733
+ },
734
+
735
+ // Persist a speaker note edit via the Marpit-token-based API. The
736
+ // server resolves the path, validates ETag, and rewrites surgically.
737
+ // `editTimeEtag` is the etag captured by the presenter at edit start;
738
+ // we send that as If-Match (NOT the live tab.etag) so a watcher
739
+ // refresh during the debounce can't smuggle a write past the lock.
740
+ async saveNote(path, slideIndex, note, editTimeEtag) {
741
+ const tab = state.tabs.find((t) => t.path === path);
742
+ if (!tab || !tab.isMarp) return;
743
+ const ifMatch = editTimeEtag || tab.etag;
744
+ if (!ifMatch) {
745
+ // GET degrade or no etag yet — refuse without writing.
746
+ this.channel.postMessage({
747
+ type: 'note-saved',
748
+ slideIndex,
749
+ ok: false,
750
+ reason: 'Deck not parseable (degraded mode)'
751
+ });
752
+ return;
753
+ }
754
+
755
+ let res, data;
756
+ try {
757
+ ({ res, data } = await window.MDVApi.saveMarpNote(path, slideIndex, note, ifMatch));
758
+ } catch (err) {
759
+ console.error('saveNote network error', err);
760
+ this.channel.postMessage({
761
+ type: 'note-saved', slideIndex, ok: false, reason: 'Network error'
762
+ });
763
+ return;
764
+ }
765
+
766
+ if (res.status === 412 && data.code === 'STALE') {
767
+ // The file changed under us. Do NOT update tab.etag here —
768
+ // tab.content/notes/slideRanges are still the pre-conflict
769
+ // version, so adopting the new etag would let the next edit
770
+ // pass If-Match while the deck index is wrong. The watcher's
771
+ // file_update event will refresh tab.{content,notes,etag}
772
+ // together once chokidar sees the change. Until then, all
773
+ // PUTs from this tab keep returning 412.
774
+ this.channel.postMessage({
775
+ type: 'note-saved',
776
+ slideIndex,
777
+ ok: false,
778
+ reason: 'STALE — file changed externally; please reload'
779
+ });
780
+ return;
781
+ }
782
+
783
+ if (res.ok && data.ok) {
784
+ // Update local tab state from the server's authoritative
785
+ // post-rewrite payload so re-broadcasts and the editor
786
+ // immediately see the saved content. Otherwise raw/notes
787
+ // would lag until the watcher's file_update event arrives.
788
+ tab.etag = data.etag;
789
+ this.lastSavedEtag.set(path, data.etag);
790
+ if (typeof data.source === 'string') tab.raw = data.source;
791
+ if (Array.isArray(data.notes)) tab.notes = data.notes;
792
+ if (Array.isArray(data.notesMultiplicity)) {
793
+ tab.notesMultiplicity = data.notesMultiplicity;
794
+ }
795
+ this.channel.postMessage({
796
+ type: 'note-saved',
797
+ slideIndex,
798
+ ok: true,
799
+ etag: data.etag,
800
+ normalizedNote: data.normalizedNote
801
+ });
802
+ // Re-broadcast so the presenter window picks up the new
803
+ // notes/etag without waiting for the watcher event.
804
+ this.broadcastSlides();
805
+ return;
806
+ }
807
+
808
+ const reason = data && (data.error || data.code) || 'Save failed';
809
+ this.channel.postMessage({
810
+ type: 'note-saved', slideIndex, ok: false, reason
811
+ });
812
+ },
813
+
814
+ open() {
815
+ const tab = state.tabs[state.activeTabIndex];
816
+ if (!tab || !tab.isMarp) return;
817
+
818
+ if (this.presenterWindow && !this.presenterWindow.closed) {
819
+ this.presenterWindow.focus();
820
+ this.broadcastSlides();
821
+ return;
822
+ }
823
+
824
+ this.presenterWindow = window.open(
825
+ '/static/presenter.html',
826
+ 'mdv-presenter',
827
+ 'width=1280,height=720,resizable=yes,scrollbars=yes'
828
+ );
829
+
830
+ // presenter sends `request-slides` on load, but broadcast as a fallback
831
+ setTimeout(() => this.broadcastSlides(), 300);
832
+ },
833
+
834
+ broadcastSlides() {
835
+ if (!this.channel) return;
836
+ const tab = state.tabs[state.activeTabIndex];
837
+ if (!tab || !tab.isMarp) {
838
+ // Active tab is not a Marp deck (or no tab) — clear the
839
+ // presenter so it doesn't keep showing stale slides /
840
+ // accept edits against the wrong file.
841
+ this.channel.postMessage({
842
+ type: 'slides',
843
+ empty: true,
844
+ reason: 'main-switched-away'
845
+ });
846
+ return;
847
+ }
848
+ this.channel.postMessage({
849
+ type: 'slides',
850
+ path: tab.path,
851
+ html: tab.content,
852
+ css: tab.css,
853
+ notes: tab.notes || [],
854
+ notesMultiplicity: tab.notesMultiplicity || [],
855
+ etag: tab.etag || null,
856
+ current: marpCurrentSlide
857
+ });
858
+ },
859
+
860
+ broadcastIndex(index) {
861
+ if (!this.channel) return;
862
+ this.channel.postMessage({ type: 'index', index });
863
+ },
864
+
865
+ gotoSlide(index) {
866
+ const slides = elements.content.querySelectorAll('.marpit > svg[data-marpit-svg]');
867
+ if (!slides.length || index < 0 || index >= slides.length) return;
868
+ slides.forEach((s, i) => s.classList.toggle('active', i === index));
869
+ marpCurrentSlide = index;
870
+ const counter = elements.content.querySelector('.slide-counter');
871
+ if (counter) counter.textContent = `${index + 1} / ${slides.length}`;
872
+ const prevBtn = elements.content.querySelector('.marp-prev');
873
+ const nextBtn = elements.content.querySelector('.marp-next');
874
+ if (prevBtn) prevBtn.disabled = index === 0;
875
+ if (nextBtn) nextBtn.disabled = index === slides.length - 1;
876
+ }
877
+ };
878
+
563
879
  const ContentRenderer = {
564
880
  render(htmlContent, fileType) {
565
881
  const containerClass = fileType === 'code'
566
882
  ? 'markdown-body code-view-container'
567
- : 'markdown-body';
883
+ : fileType === 'markdown'
884
+ ? 'markdown-body pdf-style-preview'
885
+ : 'markdown-body';
568
886
  elements.content.innerHTML = `<div class="${containerClass}">${htmlContent}</div>`;
569
887
 
570
888
  elements.content.querySelectorAll('pre code').forEach(block => {
@@ -661,6 +979,11 @@
661
979
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
662
980
  </svg>
663
981
  </button>
982
+ <button class="marp-presenter-btn" title="Presenter View (P)">
983
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
984
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
985
+ </svg>
986
+ </button>
664
987
  <button class="marp-close-nav" title="Hide (N to show)">
665
988
  <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
666
989
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
@@ -706,6 +1029,7 @@
706
1029
  }
707
1030
  if (prevBtn) prevBtn.disabled = index === 0;
708
1031
  if (nextBtn) nextBtn.disabled = index === slides.length - 1;
1032
+ PresenterView.broadcastIndex(index);
709
1033
  };
710
1034
 
711
1035
  const nextSlide = () => {
@@ -750,6 +1074,10 @@
750
1074
  };
751
1075
  if (fullscreenBtn) fullscreenBtn.addEventListener('click', toggleFullscreen);
752
1076
 
1077
+ // Presenter view button
1078
+ const presenterBtn = elements.content.querySelector('.marp-presenter-btn');
1079
+ if (presenterBtn) presenterBtn.addEventListener('click', () => PresenterView.open());
1080
+
753
1081
  // Make nav draggable and closeable
754
1082
  const nav = elements.content.querySelector('.marp-nav');
755
1083
  if (nav) {
@@ -818,6 +1146,11 @@
818
1146
  } else if (e.key === 'n' || e.key === 'N') {
819
1147
  e.preventDefault();
820
1148
  if (nav) nav.classList.toggle('hidden');
1149
+ } else if ((e.key === 'p' || e.key === 'P') && !e.metaKey && !e.ctrlKey && !e.altKey) {
1150
+ // Skip if modifiers are held — Cmd/Ctrl+P is the print
1151
+ // shortcut and must not also open the presenter view.
1152
+ e.preventDefault();
1153
+ PresenterView.open();
821
1154
  } else if (e.key === 'Escape') {
822
1155
  e.preventDefault();
823
1156
  if (document.body.classList.contains('marp-fullscreen')) {
@@ -955,7 +1288,7 @@
955
1288
  return;
956
1289
  }
957
1290
 
958
- const response = await fetch(`/api/file?path=${encodeURIComponent(path)}`);
1291
+ const response = await MDVApi.fetchFile(path);
959
1292
  const data = await response.json();
960
1293
 
961
1294
  if (data.error) {
@@ -971,6 +1304,11 @@
971
1304
  fileType: data.fileType,
972
1305
  isMarp: data.isMarp || false,
973
1306
  css: data.css || null, // Marp CSS from marp-core
1307
+ notes: data.notes || [], // Marp speaker notes per slide
1308
+ notesMultiplicity: data.notesMultiplicity || [],
1309
+ etag: data.etag || null,
1310
+ lineEnding: data.lineEnding || '\n',
1311
+ hasBom: !!data.hasBom,
974
1312
  imageUrl: data.imageUrl,
975
1313
  pdfUrl: data.pdfUrl,
976
1314
  htmlUrl: data.htmlUrl,
@@ -1040,7 +1378,11 @@
1040
1378
  });
1041
1379
  return;
1042
1380
  }
1381
+ const closingPath = state.tabs[index] && state.tabs[index].path;
1043
1382
  state.tabs.splice(index, 1);
1383
+ if (closingPath && window.MDVTabRegistry) {
1384
+ window.MDVTabRegistry.notifyClosed(closingPath);
1385
+ }
1044
1386
 
1045
1387
  if (state.tabs.length === 0) {
1046
1388
  state.activeTabIndex = -1;
@@ -1094,6 +1436,7 @@
1094
1436
 
1095
1437
  if (tab.isMarp) {
1096
1438
  ContentRenderer.renderMarp(tab.content, tab.css);
1439
+ PresenterView.broadcastSlides();
1097
1440
  return;
1098
1441
  }
1099
1442
 
@@ -1231,10 +1574,17 @@
1231
1574
  elements.editorStatus.style.display = 'none';
1232
1575
 
1233
1576
  try {
1234
- const response = await fetch(`/api/file?path=${encodeURIComponent(tab.path)}`);
1577
+ const response = await MDVApi.fetchFile(tab.path);
1235
1578
  const data = await response.json();
1236
1579
  if (data.content) tab.content = data.content;
1237
1580
  if (data.raw) tab.raw = data.raw;
1581
+ if (data.css) tab.css = data.css;
1582
+ if (data.notes) tab.notes = data.notes;
1583
+ if (data.notesMultiplicity) tab.notesMultiplicity = data.notesMultiplicity;
1584
+ if (data.etag) tab.etag = data.etag;
1585
+ if (data.lineEnding) tab.lineEnding = data.lineEnding;
1586
+ if (typeof data.hasBom !== 'undefined') tab.hasBom = !!data.hasBom;
1587
+ if (typeof data.isMarp !== 'undefined') tab.isMarp = data.isMarp;
1238
1588
  } catch (e) {
1239
1589
  console.error('Failed to fetch updated content:', e);
1240
1590
  }
@@ -1301,11 +1651,7 @@
1301
1651
  elements.editorStatus.textContent = 'Saving...';
1302
1652
  elements.editorStatus.className = 'editor-status';
1303
1653
 
1304
- const response = await fetch('/api/file', {
1305
- method: 'POST',
1306
- headers: { 'Content-Type': 'application/json' },
1307
- body: JSON.stringify({ path: tab.path, content: newContent })
1308
- });
1654
+ const response = await MDVApi.saveFile(tab.path, newContent);
1309
1655
 
1310
1656
  const result = await response.json();
1311
1657
 
@@ -1360,9 +1706,11 @@
1360
1706
  }
1361
1707
 
1362
1708
  if (tab.isMarp || this.isMarpPresentation()) {
1363
- await this.exportMarpPdf(tab.path);
1709
+ await this.exportPdf(tab.path);
1364
1710
  } else if (this.isHtmlPreview()) {
1365
1711
  this.printHtmlPreview(tab.name);
1712
+ } else if (tab.fileType === 'markdown') {
1713
+ await this.exportPdf(tab.path);
1366
1714
  } else {
1367
1715
  this.browserPrint(tab.name);
1368
1716
  }
@@ -1384,18 +1732,15 @@
1384
1732
  }
1385
1733
  },
1386
1734
 
1387
- async exportMarpPdf(filePath) {
1735
+ async exportPdf(filePath) {
1388
1736
  const statusText = elements.statusText;
1389
1737
  const originalStatus = statusText.textContent;
1390
1738
 
1391
1739
  try {
1392
1740
  statusText.textContent = 'Generating PDF...';
1741
+ const exportOptions = PdfStyleManager.getExportOptions();
1393
1742
 
1394
- const response = await fetch('/api/pdf/export', {
1395
- method: 'POST',
1396
- headers: { 'Content-Type': 'application/json' },
1397
- body: JSON.stringify({ filePath })
1398
- });
1743
+ const response = await MDVApi.exportPdf({ filePath, ...exportOptions });
1399
1744
 
1400
1745
  if (!response.ok) {
1401
1746
  const error = await response.json();
@@ -2002,7 +2347,7 @@
2002
2347
  WebSocketManager.watchFile(tab.path);
2003
2348
 
2004
2349
  try {
2005
- const response = await fetch(`/api/file?path=${encodeURIComponent(tab.path)}`);
2350
+ const response = await MDVApi.fetchFile(tab.path);
2006
2351
  const data = await response.json();
2007
2352
  if (data.content && data.content !== tab.content) {
2008
2353
  tab.content = data.content;
@@ -2021,6 +2366,7 @@
2021
2366
  async function init() {
2022
2367
  // Initialize all managers
2023
2368
  ThemeManager.init();
2369
+ PdfStyleManager.init();
2024
2370
  SidebarManager.init();
2025
2371
  ResizeHandler.init();
2026
2372
  EditorManager.init();
@@ -2030,6 +2376,7 @@
2030
2376
  ContextMenuManager.init();
2031
2377
  DragDropManager.init();
2032
2378
  KeyboardManager.init();
2379
+ PresenterView.init();
2033
2380
 
2034
2381
  // Warn before leaving with unsaved changes
2035
2382
  window.addEventListener('beforeunload', (e) => {
@@ -2041,7 +2388,7 @@
2041
2388
  TabManager.render();
2042
2389
 
2043
2390
  try {
2044
- const infoResponse = await fetch('/api/info');
2391
+ const infoResponse = await MDVApi.fetchInfo();
2045
2392
  const info = await infoResponse.json();
2046
2393
  state.rootPath = info.rootPath;
2047
2394
  } catch (e) {