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.
@@ -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 value (coalesce).
8
- * - Other slides' pending edits keep their place in insertion order.
9
- * - `saveFn(path, slideIndex, note, etag)` is supplied by the caller.
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, { pendingBySlide: Map<number, {note:string, etag:string|null}>, isDraining: boolean }>} */
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
- let entry = queue.get(path);
22
- if (!entry) {
23
- entry = { pendingBySlide: new Map(), isDraining: false };
24
- queue.set(path, entry);
25
- }
26
- entry.pendingBySlide.set(slideIndex, { note, etag });
27
- if (!entry.isDraining) drain(path);
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.pendingBySlide.size > 0) {
36
- const it = entry.pendingBySlide.entries().next();
57
+ while (entry.pending.size > 0) {
58
+ const it = entry.pending.entries().next();
37
59
  if (it.done) break;
38
- const [slideIndex, payload] = it.value;
39
- entry.pendingBySlide.delete(slideIndex);
60
+ const [key, payload] = it.value;
61
+ entry.pending.delete(key);
62
+ let result;
40
63
  try {
41
- await saveFn(path, slideIndex, payload.note, payload.etag);
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.pendingBySlide.size === 0) queue.delete(path);
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.pendingBySlide.clear();
58
- // If a drain is isDraining, it will exit cleanly when the map is empty.
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
- // On our OWN successful save, advance editingEtag to the post-save
540
- // etag so the next autosave in this focus session is not stale.
541
- // External-edit broadcasts deliberately do NOT update editingEtag.
542
- if (msg.ok && editing && msg.etag) editingEtag = msg.etag;
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.
@@ -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: absolute;
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 { display: none !important; }
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; }