mdv-live 0.5.14 → 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 +102 -0
- package/README.md +8 -0
- package/bin/mdv.js +27 -40
- package/package.json +3 -3
- package/src/api/pdf.js +10 -138
- package/src/services/pdf.js +206 -0
- 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
|
@@ -1,30 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-deck save queue with per-slide coalescing.
|
|
2
|
+
* Per-deck save queue with per-slide-per-origin coalescing.
|
|
3
3
|
*
|
|
4
4
|
* - Saves to the same deck are processed strictly serially (the server has
|
|
5
5
|
* a per-path mutex, but client-side serialization keeps user-visible
|
|
6
6
|
* ordering intuitive).
|
|
7
|
-
* - New edits for the same slideIndex overwrite any pending
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* - New edits for the same (slideIndex, origin) pair overwrite any pending
|
|
8
|
+
* value (coalesce). The superseded enqueue() Promise resolves with
|
|
9
|
+
* { ok: false, reason: 'COALESCED' } so callers awaiting the older write
|
|
10
|
+
* can drop their stale UI state instead of hanging forever.
|
|
11
|
+
* - Crucially, coalescing is scoped per origin: an inline save and a
|
|
12
|
+
* presenter save for the same slide do NOT replace each other. Both run
|
|
13
|
+
* serially so neither editor silently loses a draft.
|
|
14
|
+
* - `saveFn(path, slideIndex, note, etag, origin)` is supplied by the
|
|
15
|
+
* caller; `origin` is an optional tag (e.g. 'presenter' / 'inline') the
|
|
16
|
+
* queue uses for keying and also forwards verbatim so saveFn can route
|
|
17
|
+
* notifications back to the right editor.
|
|
18
|
+
* - enqueue() returns a Promise that resolves with the saveFn's result (or a
|
|
19
|
+
* COALESCED sentinel). Existing callers that ignore the return value or
|
|
20
|
+
* skip the origin argument keep working unchanged.
|
|
10
21
|
*
|
|
11
22
|
* Loaded as a classic <script>; exposes window.MDVSaveQueue.
|
|
12
23
|
*/
|
|
13
24
|
(function () {
|
|
14
25
|
'use strict';
|
|
15
26
|
|
|
27
|
+
function buildKey(slideIndex, origin) {
|
|
28
|
+
return slideIndex + '|' + (origin || '');
|
|
29
|
+
}
|
|
30
|
+
|
|
16
31
|
function createSaveQueue({ saveFn }) {
|
|
17
|
-
/** @type {Map<string, {
|
|
32
|
+
/** @type {Map<string, { pending: Map<string, {slideIndex:number, note:string, etag:string|null, origin:string|undefined, resolve:Function}>, isDraining: boolean }>} */
|
|
18
33
|
const queue = new Map();
|
|
19
34
|
|
|
20
|
-
function enqueue(path, slideIndex, note, etag) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
35
|
+
function enqueue(path, slideIndex, note, etag, origin) {
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
let entry = queue.get(path);
|
|
38
|
+
if (!entry) {
|
|
39
|
+
entry = { pending: new Map(), isDraining: false };
|
|
40
|
+
queue.set(path, entry);
|
|
41
|
+
}
|
|
42
|
+
const key = buildKey(slideIndex, origin);
|
|
43
|
+
const existing = entry.pending.get(key);
|
|
44
|
+
if (existing) {
|
|
45
|
+
existing.resolve({ ok: false, reason: 'COALESCED' });
|
|
46
|
+
}
|
|
47
|
+
entry.pending.set(key, { slideIndex, note, etag, origin, resolve });
|
|
48
|
+
if (!entry.isDraining) drain(path);
|
|
49
|
+
});
|
|
28
50
|
}
|
|
29
51
|
|
|
30
52
|
async function drain(path) {
|
|
@@ -32,21 +54,25 @@
|
|
|
32
54
|
if (!entry || entry.isDraining) return;
|
|
33
55
|
entry.isDraining = true;
|
|
34
56
|
try {
|
|
35
|
-
while (entry.
|
|
36
|
-
const it = entry.
|
|
57
|
+
while (entry.pending.size > 0) {
|
|
58
|
+
const it = entry.pending.entries().next();
|
|
37
59
|
if (it.done) break;
|
|
38
|
-
const [
|
|
39
|
-
entry.
|
|
60
|
+
const [key, payload] = it.value;
|
|
61
|
+
entry.pending.delete(key);
|
|
62
|
+
let result;
|
|
40
63
|
try {
|
|
41
|
-
await saveFn(
|
|
64
|
+
result = await saveFn(
|
|
65
|
+
path, payload.slideIndex, payload.note, payload.etag, payload.origin
|
|
66
|
+
);
|
|
42
67
|
} catch (err) {
|
|
43
|
-
// Caller logs; never let drain break.
|
|
44
68
|
console.error('saveQueue saveFn error', err);
|
|
69
|
+
result = { ok: false, reason: String(err && err.message || err) };
|
|
45
70
|
}
|
|
71
|
+
payload.resolve(result);
|
|
46
72
|
}
|
|
47
73
|
} finally {
|
|
48
74
|
entry.isDraining = false;
|
|
49
|
-
if (entry.
|
|
75
|
+
if (entry.pending.size === 0) queue.delete(path);
|
|
50
76
|
}
|
|
51
77
|
}
|
|
52
78
|
|
|
@@ -54,8 +80,11 @@
|
|
|
54
80
|
function dropPath(path) {
|
|
55
81
|
const entry = queue.get(path);
|
|
56
82
|
if (!entry) return;
|
|
57
|
-
entry.
|
|
58
|
-
|
|
83
|
+
entry.pending.forEach((payload) => {
|
|
84
|
+
payload.resolve({ ok: false, reason: 'DROPPED' });
|
|
85
|
+
});
|
|
86
|
+
entry.pending.clear();
|
|
87
|
+
// If a drain is in-flight, it will exit cleanly once the map is empty.
|
|
59
88
|
if (!entry.isDraining) queue.delete(path);
|
|
60
89
|
}
|
|
61
90
|
|
|
@@ -536,10 +536,29 @@
|
|
|
536
536
|
} else if (msg.type === 'note-saved') {
|
|
537
537
|
const targetIdx = editingSlideIndex >= 0 ? editingSlideIndex : currentIndex;
|
|
538
538
|
if (msg.slideIndex !== targetIdx) return;
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
|
|
539
|
+
// Only OUR own successful saves should advance editingEtag. The
|
|
540
|
+
// inline notes panel in the main window goes through the same
|
|
541
|
+
// saveNote path and broadcasts the same shape; without this guard
|
|
542
|
+
// we'd rebase the presenter's pinned etag onto an inline save and
|
|
543
|
+
// the presenter's next autosave would silently overwrite the
|
|
544
|
+
// inline edit instead of returning a STALE conflict. Treat
|
|
545
|
+
// legacy/missing origin tags as our own to stay backwards-
|
|
546
|
+
// compatible with broadcasts from older builds.
|
|
547
|
+
const isOwnSave = !msg.origin
|
|
548
|
+
|| msg.origin === 'presenter'
|
|
549
|
+
|| msg.origin === 'unknown';
|
|
550
|
+
|
|
551
|
+
// While the presenter is mid-edit, don't let an inline save's
|
|
552
|
+
// result overwrite our save status / STALE backup. Showing
|
|
553
|
+
// 保存済み for someone else's save would mask the presenter's own
|
|
554
|
+
// pending draft, and an inline STALE backup would be written with
|
|
555
|
+
// the presenter's in-progress text under the wrong key. We also
|
|
556
|
+
// don't want to advance editingEtag for foreign saves (already
|
|
557
|
+
// gated by isOwnSave above). Bail out before the status / backup
|
|
558
|
+
// logic when the result isn't ours.
|
|
559
|
+
if (!isOwnSave && editing) return;
|
|
560
|
+
|
|
561
|
+
if (msg.ok && editing && isOwnSave && msg.etag) editingEtag = msg.etag;
|
|
543
562
|
|
|
544
563
|
// STALE: back up the in-progress text to localStorage so the user can
|
|
545
564
|
// recover after reloading the deck.
|
package/src/static/styles.css
CHANGED
|
@@ -911,9 +911,13 @@ body {
|
|
|
911
911
|
.marp-slide img { max-width: 100%; max-height: 60vh; }
|
|
912
912
|
.marp-slide .mermaid { margin: 16px 0; }
|
|
913
913
|
|
|
914
|
-
/* Navigation Controls
|
|
914
|
+
/* Navigation Controls.
|
|
915
|
+
`position: fixed` keeps it visible while the user scrolls through long
|
|
916
|
+
speaker notes. The slide and notes panel use full width, so the nav
|
|
917
|
+
intentionally floats over them with a translucent backdrop — opacity
|
|
918
|
+
bumps to 1 on hover so it never blocks reading the content underneath. */
|
|
915
919
|
.marp-nav {
|
|
916
|
-
position:
|
|
920
|
+
position: fixed;
|
|
917
921
|
bottom: 24px;
|
|
918
922
|
right: 24px;
|
|
919
923
|
display: flex;
|
|
@@ -926,6 +930,14 @@ body {
|
|
|
926
930
|
border: 1px solid var(--border);
|
|
927
931
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
|
928
932
|
user-select: none;
|
|
933
|
+
opacity: 0.85;
|
|
934
|
+
backdrop-filter: blur(6px);
|
|
935
|
+
-webkit-backdrop-filter: blur(6px);
|
|
936
|
+
transition: opacity 0.15s ease;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
.marp-nav:hover {
|
|
940
|
+
opacity: 1;
|
|
929
941
|
}
|
|
930
942
|
|
|
931
943
|
.marp-nav button {
|
|
@@ -1050,26 +1062,222 @@ body.marp-fullscreen .marpit > svg[data-marpit-svg] {
|
|
|
1050
1062
|
display: none !important;
|
|
1051
1063
|
}
|
|
1052
1064
|
|
|
1065
|
+
/* PowerPoint-style split layout: slide on top, speaker notes on bottom,
|
|
1066
|
+
draggable horizontal handle in between. The notes row's height is a
|
|
1067
|
+
CSS variable (--marp-notes-row, default 240px) that JS updates while
|
|
1068
|
+
dragging and persists in localStorage. Setting it to 0 effectively
|
|
1069
|
+
"closes" the notes pane; dragging the handle to the very top maxes it
|
|
1070
|
+
out. The slide row uses 1fr so it absorbs the remaining height — making
|
|
1071
|
+
notes bigger automatically shrinks the slide. */
|
|
1072
|
+
.marp-split {
|
|
1073
|
+
display: grid;
|
|
1074
|
+
grid-template-rows: 1fr 8px var(--marp-notes-row, 240px);
|
|
1075
|
+
height: 100%;
|
|
1076
|
+
width: 100%;
|
|
1077
|
+
overflow: hidden;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
.marp-slide-area {
|
|
1081
|
+
overflow: auto;
|
|
1082
|
+
display: flex;
|
|
1083
|
+
align-items: center;
|
|
1084
|
+
justify-content: center;
|
|
1085
|
+
padding: 20px;
|
|
1086
|
+
min-height: 0;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
.marp-slide-area .marpit {
|
|
1090
|
+
width: 100%;
|
|
1091
|
+
max-height: 100%;
|
|
1092
|
+
display: flex;
|
|
1093
|
+
align-items: center;
|
|
1094
|
+
justify-content: center;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
.marp-slide-area .marpit > svg[data-marpit-svg] {
|
|
1098
|
+
display: none;
|
|
1099
|
+
max-width: 100%;
|
|
1100
|
+
max-height: 100%;
|
|
1101
|
+
height: auto;
|
|
1102
|
+
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
|
|
1103
|
+
border-radius: 4px;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
.marp-slide-area .marpit > svg[data-marpit-svg].active {
|
|
1107
|
+
display: block;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
.marp-split-handle {
|
|
1111
|
+
cursor: row-resize;
|
|
1112
|
+
background: var(--border);
|
|
1113
|
+
position: relative;
|
|
1114
|
+
user-select: none;
|
|
1115
|
+
transition: background 0.15s ease;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
.marp-split-handle::before {
|
|
1119
|
+
content: '';
|
|
1120
|
+
position: absolute;
|
|
1121
|
+
left: 50%;
|
|
1122
|
+
top: 50%;
|
|
1123
|
+
transform: translate(-50%, -50%);
|
|
1124
|
+
width: 60px;
|
|
1125
|
+
height: 3px;
|
|
1126
|
+
background: var(--text-muted);
|
|
1127
|
+
border-radius: 2px;
|
|
1128
|
+
opacity: 0.45;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
.marp-split-handle:hover {
|
|
1132
|
+
background: var(--accent);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
.marp-split-handle:hover::before {
|
|
1136
|
+
background: white;
|
|
1137
|
+
opacity: 1;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
.marp-split-handle.dragging {
|
|
1141
|
+
background: var(--accent);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
.marp-notes-area {
|
|
1145
|
+
overflow: auto;
|
|
1146
|
+
background: var(--bg-secondary);
|
|
1147
|
+
border-top: 1px solid var(--border);
|
|
1148
|
+
min-height: 0;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
.speaker-notes-panel {
|
|
1152
|
+
display: none;
|
|
1153
|
+
position: relative;
|
|
1154
|
+
margin: 0;
|
|
1155
|
+
padding: 12px 20px 20px;
|
|
1156
|
+
font-size: 14px;
|
|
1157
|
+
color: var(--text-primary);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
.speaker-notes-panel.active {
|
|
1161
|
+
display: block;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
.speaker-notes-status {
|
|
1165
|
+
position: absolute;
|
|
1166
|
+
top: 12px;
|
|
1167
|
+
right: 24px;
|
|
1168
|
+
font-size: 12px;
|
|
1169
|
+
color: var(--text-muted);
|
|
1170
|
+
min-height: 16px;
|
|
1171
|
+
font-weight: 400;
|
|
1172
|
+
pointer-events: none;
|
|
1173
|
+
user-select: none;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
.speaker-notes-status:empty { display: none; }
|
|
1177
|
+
.speaker-notes-status.ok { color: var(--success, #2da44e); }
|
|
1178
|
+
.speaker-notes-status.err { color: var(--danger, #c93636); }
|
|
1179
|
+
|
|
1180
|
+
.speaker-notes-banner {
|
|
1181
|
+
margin: 0 0 8px;
|
|
1182
|
+
padding: 8px 12px;
|
|
1183
|
+
background: rgba(232, 152, 50, 0.12);
|
|
1184
|
+
color: #c8851e;
|
|
1185
|
+
border: 1px solid rgba(232, 152, 50, 0.3);
|
|
1186
|
+
border-radius: 4px;
|
|
1187
|
+
font-size: 13px;
|
|
1188
|
+
line-height: 1.5;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
.speaker-notes-banner[hidden] { display: none; }
|
|
1192
|
+
|
|
1193
|
+
.speaker-notes-editor {
|
|
1194
|
+
min-height: 60px;
|
|
1195
|
+
padding: 10px 12px;
|
|
1196
|
+
background: var(--bg-primary);
|
|
1197
|
+
border: 1px solid var(--border);
|
|
1198
|
+
border-radius: 4px;
|
|
1199
|
+
line-height: 1.7;
|
|
1200
|
+
white-space: pre-wrap;
|
|
1201
|
+
word-break: break-word;
|
|
1202
|
+
outline: none;
|
|
1203
|
+
color: var(--text-primary);
|
|
1204
|
+
caret-color: var(--accent);
|
|
1205
|
+
cursor: text;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
.speaker-notes-editor:focus {
|
|
1209
|
+
border-color: var(--accent);
|
|
1210
|
+
box-shadow: 0 0 0 2px rgba(79, 140, 255, 0.15);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
.speaker-notes-editor[contenteditable="false"] {
|
|
1214
|
+
cursor: default;
|
|
1215
|
+
color: var(--text-muted);
|
|
1216
|
+
background: var(--bg-tertiary);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/* CSS pseudo-element placeholder — never enters textContent so editing
|
|
1220
|
+
can never accidentally save the placeholder string as a real note. */
|
|
1221
|
+
.speaker-notes-editor[data-placeholder]:empty::before {
|
|
1222
|
+
content: attr(data-placeholder);
|
|
1223
|
+
color: var(--text-muted);
|
|
1224
|
+
font-style: italic;
|
|
1225
|
+
pointer-events: none;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
body.marp-fullscreen .marp-split {
|
|
1229
|
+
grid-template-rows: 1fr 0 0;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
body.marp-fullscreen .marp-split-handle,
|
|
1233
|
+
body.marp-fullscreen .marp-notes-area {
|
|
1234
|
+
display: none !important;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1053
1237
|
/* Print styles for Marp */
|
|
1054
1238
|
@media print {
|
|
1055
|
-
.marp-nav
|
|
1239
|
+
.marp-nav,
|
|
1240
|
+
.marp-split-handle,
|
|
1241
|
+
.marp-notes-area,
|
|
1242
|
+
.speaker-notes-panel { display: none !important; }
|
|
1243
|
+
|
|
1244
|
+
.marp-split {
|
|
1245
|
+
display: block !important;
|
|
1246
|
+
height: auto !important;
|
|
1247
|
+
overflow: visible !important;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
.marp-slide-area {
|
|
1251
|
+
display: block !important;
|
|
1252
|
+
overflow: visible !important;
|
|
1253
|
+
padding: 0 !important;
|
|
1254
|
+
}
|
|
1056
1255
|
|
|
1057
1256
|
.marpit {
|
|
1058
1257
|
padding: 0 !important;
|
|
1059
1258
|
background: transparent !important;
|
|
1259
|
+
/* The runtime layout makes .marpit a row-oriented flex container
|
|
1260
|
+
so the active SVG can be centered; for printing, force it back
|
|
1261
|
+
to block flow so each `display: block` SVG honors page-break-
|
|
1262
|
+
after and lands on its own page (otherwise multi-slide PDFs
|
|
1263
|
+
collapse into a single wide row). */
|
|
1264
|
+
display: block !important;
|
|
1060
1265
|
}
|
|
1061
1266
|
|
|
1267
|
+
.marp-slide-area .marpit > svg[data-marpit-svg],
|
|
1062
1268
|
.marpit > svg[data-marpit-svg] {
|
|
1063
1269
|
display: block !important;
|
|
1064
1270
|
width: 100% !important;
|
|
1065
1271
|
height: auto !important;
|
|
1066
1272
|
max-width: none !important;
|
|
1273
|
+
max-height: none !important;
|
|
1067
1274
|
box-shadow: none !important;
|
|
1068
1275
|
border-radius: 0 !important;
|
|
1069
1276
|
page-break-after: always;
|
|
1070
1277
|
page-break-inside: avoid;
|
|
1071
1278
|
}
|
|
1072
1279
|
|
|
1280
|
+
.marp-slide-area .marpit > svg[data-marpit-svg]:last-child,
|
|
1073
1281
|
.marpit > svg[data-marpit-svg]:last-child { page-break-after: avoid; }
|
|
1074
1282
|
|
|
1075
1283
|
.marp-presentation { height: auto !important; }
|