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/CHANGELOG.md +127 -0
- package/README.md +154 -23
- package/bin/mdv.js +141 -81
- package/package.json +1 -1
- package/src/api/marpNote/guards.js +79 -0
- package/src/api/marpNote/handleGet.js +65 -0
- package/src/api/marpNote/handlePut.js +162 -0
- package/src/api/marpNote/readDeck.js +42 -0
- package/src/api/marpNote.js +40 -0
- package/src/api/pdf.js +65 -8
- package/src/concurrency/pathLock.js +39 -0
- package/src/rendering/index.js +9 -1
- package/src/rendering/markdown.js +24 -21
- package/src/rendering/marp.js +11 -32
- package/src/rendering/marpNoteWriter.js +156 -0
- package/src/rendering/marpitAdapter.js +139 -0
- package/src/server.js +29 -4
- package/src/static/app.js +369 -22
- package/src/static/index.html +24 -0
- package/src/static/lib/apiClient.js +73 -0
- package/src/static/lib/presenterChannel.js +33 -0
- package/src/static/lib/saveQueue.js +71 -0
- package/src/static/lib/tabRegistry.js +32 -0
- package/src/static/presenter.html +687 -0
- package/src/static/styles.css +34 -0
- package/src/styles/index.js +90 -0
- package/src/styles/report.example.css +201 -0
- package/src/styles/report.pdf-options.example.json +10 -0
- package/src/utils/atomicWrite.js +159 -0
- package/src/utils/errors.js +50 -0
- package/src/utils/etag.js +11 -0
- package/src/utils/lineMath.js +86 -0
- package/src/rendering/slides.js +0 -152
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2391
|
+
const infoResponse = await MDVApi.fetchInfo();
|
|
2045
2392
|
const info = await infoResponse.json();
|
|
2046
2393
|
state.rootPath = info.rootPath;
|
|
2047
2394
|
} catch (e) {
|