mdv-live 0.5.18 → 0.5.20
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 +71 -0
- package/package.json +1 -1
- package/src/rendering/index.js +29 -1
- package/src/static/app.js +59 -7
- package/src/static/lib/presenterChannel.js +33 -5
- package/src/static/lib/saveQueue.js +9 -6
- package/src/static/presenter.html +181 -24
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,77 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.5.20] - 2026-05-18
|
|
9
|
+
|
|
10
|
+
### Fixed — Presenter View のノート編集が毎回 STALE エラー
|
|
11
|
+
|
|
12
|
+
Presenter View で speaker note を編集すると、毎回一瞬
|
|
13
|
+
`保存失敗: STALE — file changed externally; please reload`(赤)が出る
|
|
14
|
+
バグを修正。同じ deck を mdv のタブ/ウィンドウで 2 つ以上開いていると
|
|
15
|
+
必ず発生していた。
|
|
16
|
+
|
|
17
|
+
- 原因: Presenter View はノート保存を自前で行わず、`BroadcastChannel` の
|
|
18
|
+
`edit-note` メッセージで **メインウィンドウに保存を委譲** する。この
|
|
19
|
+
メッセージは同一ブラウザの **全メインウィンドウ** に届くため、deck を
|
|
20
|
+
複数ウィンドウで開いていると **全員が同じ `If-Match` で PUT** し、サーバの
|
|
21
|
+
楽観ロックで 1 つだけ成功、残りが 412 STALE になっていた。負けた側が
|
|
22
|
+
`note-saved {ok:false, STALE}` を Presenter にブロードキャストし、赤い
|
|
23
|
+
エラー表示になっていた(保存自体は 1 つ成功するため「一瞬だけ」見える)
|
|
24
|
+
- 各メインウィンドウに一意な `windowId` を付与。`slides` メッセージに
|
|
25
|
+
`sourceWindowId` を載せ、Presenter は最初に deck を返したウィンドウを
|
|
26
|
+
saver として固定、`edit-note` に `targetWindowId` を載せて **その 1
|
|
27
|
+
ウィンドウだけが保存** するようルーティング
|
|
28
|
+
- saver ウィンドウが閉じた/凍結した場合は保存タイムアウト(6 秒)で
|
|
29
|
+
検知し、`find-saver` 問い合わせで deck を持つ別ウィンドウ(非アクティブ
|
|
30
|
+
な背景タブ含む)を探して再ピン・再送するフェイルオーバーを実装
|
|
31
|
+
- 各 `edit-note` に一意な `requestId` を付与し `note-saved` で echo。
|
|
32
|
+
古い保存の ack が新しい保存のフェイルオーバー監視を誤って解除する問題を
|
|
33
|
+
防止
|
|
34
|
+
- `saveNote` が「対象 Marp タブなし」のとき `note-saved` をブロードキャスト
|
|
35
|
+
せず return していたため Presenter が `保存中…` のまま固まり得た問題も
|
|
36
|
+
併せて修正(`code:'NO_DECK'` を返してフェイルオーバーを誘発)
|
|
37
|
+
|
|
38
|
+
### Verified
|
|
39
|
+
|
|
40
|
+
- 既存 278 + 新規 3(`tests/test-presenter-channel.js`: `newWindowId` の
|
|
41
|
+
一意性 / フォールバック / モジュール API)
|
|
42
|
+
- Playwright dogfood: メインウィンドウ 2 枚 + Presenter で speaker note を
|
|
43
|
+
編集 → STALE が出ないこと、メインウィンドウ 1 枚で回帰がないことを実機確認
|
|
44
|
+
|
|
45
|
+
## [0.5.19] - 2026-05-16
|
|
46
|
+
|
|
47
|
+
### Fixed — Marp `![bg]` background images
|
|
48
|
+
|
|
49
|
+
Marp の `` 背景画像構文を含むスライドが mdv で真っ白になっていた
|
|
50
|
+
バグを修正。NotebookLM 製の図解 13 枚を `![bg]` で全面配置したプレゼンが mdv
|
|
51
|
+
で全スライド空白になり発覚(157_イディアコーポレーション案件)。
|
|
52
|
+
|
|
53
|
+
- 原因: marp-core は `![bg]` を `<img>` ではなく
|
|
54
|
+
`<figure style="background-image:url("パス")">` として出力する。
|
|
55
|
+
`rewriteMediaPaths` は `<img>/<video>/<audio>/<source>` の `src` 属性しか
|
|
56
|
+
`/raw/` に書き換えず、CSS の `background-image:url(...)` を素通ししていた。
|
|
57
|
+
結果、相対パスが SPA の現在 URL 基準で誤解決され 404 → CSS 背景画像のため
|
|
58
|
+
コンソールエラーも出ず真っ白になっていた
|
|
59
|
+
- `rewriteMediaPaths` に `background-image:url(...)` 書き換えルールを追加。
|
|
60
|
+
marp-core が HTML エンコードするクォート(`"`)に対応
|
|
61
|
+
- quoted URL は **対応する閉じクォートまで** を URL として読む。最初の `)` で
|
|
62
|
+
切らないため、`cover (1).png` のような括弧入りファイル名
|
|
63
|
+
(Marp 出力 `url("cover%20(1).png")`)も壊れない
|
|
64
|
+
- 絶対 URL・data URI・空 URL は書き換えない
|
|
65
|
+
- 既知の制約: 1 つの `background-image:` 宣言に複数 `url()` がある場合は先頭
|
|
66
|
+
のみ書き換わる。`![bg]` は 1 枚ごとに別 `<figure>` になるため影響なし。
|
|
67
|
+
カンマ区切りの `backgroundImage:` ディレクティブのみのエッジケース
|
|
68
|
+
|
|
69
|
+
### Verified
|
|
70
|
+
|
|
71
|
+
- 278 テスト 全 PASS(既存 272 + 新規 6: `![bg]` 相対/サブディレクトリ/括弧/
|
|
72
|
+
エンコード空白/空/絶対 URL)
|
|
73
|
+
- Playwright dogfood(`docs/dogfood_20260516/`): 全面 bg / bg fit / split bg /
|
|
74
|
+
インライン画像 / 絶対 URL bg / サブディレクトリ解決の 6 ケースを実機目視、
|
|
75
|
+
コンソールエラー 0
|
|
76
|
+
- Codex review 3 round(codex-loop)で収束。round 1 [P2] 括弧切れ修正、
|
|
77
|
+
round 2 [P3] 空 URL ガード、round 3 [P3] 複数 url 制約を文書化
|
|
78
|
+
|
|
8
79
|
## [0.5.18] - 2026-05-12
|
|
9
80
|
|
|
10
81
|
### Fixed — Offline operation
|
package/package.json
CHANGED
package/src/rendering/index.js
CHANGED
|
@@ -54,7 +54,7 @@ function renderText(content) {
|
|
|
54
54
|
*/
|
|
55
55
|
function rewriteMediaPaths(html, relativeDir) {
|
|
56
56
|
// Match src="..." that are not absolute URLs or data URIs
|
|
57
|
-
|
|
57
|
+
let out = html.replace(
|
|
58
58
|
/(<(?:img|video|audio|source)\s[^>]*?\bsrc=")([^"]+)(")/gi,
|
|
59
59
|
(match, before, src, after) => {
|
|
60
60
|
if (/^(https?:\/\/|data:|\/raw\/|\/)/.test(src)) return match;
|
|
@@ -62,6 +62,34 @@ function rewriteMediaPaths(html, relativeDir) {
|
|
|
62
62
|
return `${before}/raw/${resolved}${after}`;
|
|
63
63
|
}
|
|
64
64
|
);
|
|
65
|
+
|
|
66
|
+
// Marp `` renders as <figure style="background-image:url(...)">,
|
|
67
|
+
// never as an <img>, so the rule above never sees it. marp-core HTML-encodes
|
|
68
|
+
// the surrounding quotes ("). For the quoted form the URL runs to the
|
|
69
|
+
// matching closing quote — not the first `)` — so filenames containing
|
|
70
|
+
// parentheses (e.g. "cover (1).png", which Marp emits as
|
|
71
|
+
// url("cover%20(1).png")) survive intact.
|
|
72
|
+
//
|
|
73
|
+
// Known limitation: only the first url() of a `background-image:` declaration
|
|
74
|
+
// is rewritten. Every `![bg]` produces its own <figure> with a single-url
|
|
75
|
+
// declaration, so this never affects `![bg]`; it only leaves later urls of a
|
|
76
|
+
// comma-separated `backgroundImage:` directive (a rare hand-authored case)
|
|
77
|
+
// SPA-relative. Bounding the declaration is unreliable because the encoded
|
|
78
|
+
// quote `"` itself contains the `;` that would delimit it.
|
|
79
|
+
out = out.replace(
|
|
80
|
+
/background-image:\s*url\(\s*(?:("|"|')([\s\S]*?)\1|([^)\s'"]+))\s*\)/gi,
|
|
81
|
+
(match, quote, quotedSrc, bareSrc) => {
|
|
82
|
+
const src = quote ? quotedSrc : bareSrc;
|
|
83
|
+
// Empty quoted URL (url("")) — leave it alone, as the old
|
|
84
|
+
// pattern did; rewriting it to /raw/ would invent a bogus request.
|
|
85
|
+
if (!src || /^(https?:\/\/|data:|\/raw\/|\/)/.test(src)) return match;
|
|
86
|
+
const resolved = relativeDir ? `${relativeDir}/${src}` : src;
|
|
87
|
+
const q = quote || '';
|
|
88
|
+
return `background-image:url(${q}/raw/${resolved}${q})`;
|
|
89
|
+
}
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
return out;
|
|
65
93
|
}
|
|
66
94
|
|
|
67
95
|
/**
|
package/src/static/app.js
CHANGED
|
@@ -1212,6 +1212,10 @@
|
|
|
1212
1212
|
|
|
1213
1213
|
const PresenterView = {
|
|
1214
1214
|
channel: null,
|
|
1215
|
+
// Unique id for this main window. The presenter echoes it back as
|
|
1216
|
+
// `edit-note.targetWindowId` so that exactly one main window saves,
|
|
1217
|
+
// even when the same deck is open in several windows.
|
|
1218
|
+
windowId: null,
|
|
1215
1219
|
presenterWindow: null,
|
|
1216
1220
|
saveQueue: null, // MDVSaveQueue instance (created in init)
|
|
1217
1221
|
// Map<path, etag> — own-save chain rebase. We track presenter and
|
|
@@ -1246,7 +1250,7 @@
|
|
|
1246
1250
|
// can autosave in environments (older browsers / sandboxed
|
|
1247
1251
|
// webviews) where the Presenter window cannot be opened.
|
|
1248
1252
|
this.saveQueue = window.MDVSaveQueue.createSaveQueue({
|
|
1249
|
-
saveFn: (path, slideIndex, note, etag, origin) => {
|
|
1253
|
+
saveFn: (path, slideIndex, note, etag, origin, requestId) => {
|
|
1250
1254
|
let useEtag = etag;
|
|
1251
1255
|
const tab = state.tabs.find((t) => t.path === path);
|
|
1252
1256
|
// Pick the "own etag" map that matches this save's
|
|
@@ -1259,7 +1263,7 @@
|
|
|
1259
1263
|
: this.lastSavedEtag;
|
|
1260
1264
|
const own = ownMap.get(path);
|
|
1261
1265
|
if (tab && own && tab.etag === own) useEtag = own;
|
|
1262
|
-
return this.saveNote(path, slideIndex, note, useEtag, origin);
|
|
1266
|
+
return this.saveNote(path, slideIndex, note, useEtag, origin, requestId);
|
|
1263
1267
|
}
|
|
1264
1268
|
});
|
|
1265
1269
|
|
|
@@ -1279,6 +1283,7 @@
|
|
|
1279
1283
|
// their channel.postMessage calls (channel === null).
|
|
1280
1284
|
if (typeof BroadcastChannel !== 'undefined'
|
|
1281
1285
|
&& window.MDVPresenterChannel) {
|
|
1286
|
+
this.windowId = window.MDVPresenterChannel.newWindowId();
|
|
1282
1287
|
this.channel = window.MDVPresenterChannel.create();
|
|
1283
1288
|
if (this.channel) {
|
|
1284
1289
|
this.channel.addEventListener('message', (e) => {
|
|
@@ -1287,11 +1292,38 @@
|
|
|
1287
1292
|
this.broadcastSlides();
|
|
1288
1293
|
} else if (msg.type === 'goto') {
|
|
1289
1294
|
this.gotoSlide(msg.index);
|
|
1295
|
+
} else if (msg.type === 'find-saver') {
|
|
1296
|
+
// Failover discovery: the presenter lost its
|
|
1297
|
+
// saver and asks who can save `path`. Answer if
|
|
1298
|
+
// this window holds that deck in ANY tab —
|
|
1299
|
+
// saveNote() resolves by path, so an inactive
|
|
1300
|
+
// background tab counts (broadcastSlides only
|
|
1301
|
+
// reports the active tab and would miss it).
|
|
1302
|
+
if (msg.path
|
|
1303
|
+
&& state.tabs.some((t) => t.path === msg.path && t.isMarp)) {
|
|
1304
|
+
this.channel.postMessage({
|
|
1305
|
+
type: 'saver-here',
|
|
1306
|
+
path: msg.path,
|
|
1307
|
+
windowId: this.windowId
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1290
1310
|
} else if (msg.type === 'edit-note') {
|
|
1291
1311
|
if (!msg.path) return;
|
|
1312
|
+
// Route: only the main window the presenter
|
|
1313
|
+
// picked as its saver performs the save. Without
|
|
1314
|
+
// this, every main window showing the same deck
|
|
1315
|
+
// fires its own PUT and all but one collide on
|
|
1316
|
+
// the optimistic lock → spurious "STALE" in the
|
|
1317
|
+
// presenter. A missing targetWindowId (older
|
|
1318
|
+
// presenter build) falls back to handling it so
|
|
1319
|
+
// saves still work, just without dedup.
|
|
1320
|
+
if (msg.targetWindowId
|
|
1321
|
+
&& msg.targetWindowId !== this.windowId) {
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1292
1324
|
this.saveQueue.enqueue(
|
|
1293
1325
|
msg.path, msg.slideIndex, msg.note,
|
|
1294
|
-
msg.etag || null, 'presenter'
|
|
1326
|
+
msg.etag || null, 'presenter', msg.requestId
|
|
1295
1327
|
);
|
|
1296
1328
|
}
|
|
1297
1329
|
});
|
|
@@ -1317,11 +1349,16 @@
|
|
|
1317
1349
|
// next autosave would skip the STALE conflict it should otherwise
|
|
1318
1350
|
// hit and silently overwrite the inline edit).
|
|
1319
1351
|
//
|
|
1352
|
+
// `requestId` is the opaque token from the presenter's edit-note;
|
|
1353
|
+
// echoing it back in note-saved lets the presenter match this
|
|
1354
|
+
// result to the exact save it sent (older saves' acks then can't
|
|
1355
|
+
// cancel a newer save's failover timer).
|
|
1356
|
+
//
|
|
1320
1357
|
// Returns { ok, etag?, normalizedNote?, reason?, code? } so saveQueue
|
|
1321
1358
|
// can forward the result to enqueue() awaiters (the main-window inline
|
|
1322
1359
|
// notes panel reads this). The presenter window still gets results via
|
|
1323
1360
|
// the existing channel.postMessage('note-saved') broadcast.
|
|
1324
|
-
async saveNote(path, slideIndex, note, editTimeEtag, origin) {
|
|
1361
|
+
async saveNote(path, slideIndex, note, editTimeEtag, origin, requestId) {
|
|
1325
1362
|
const broadcast = (payload) => {
|
|
1326
1363
|
// No-op when BroadcastChannel was unavailable at init —
|
|
1327
1364
|
// inline autosaves still work because callers also read
|
|
@@ -1329,15 +1366,28 @@
|
|
|
1329
1366
|
if (!this.channel) return;
|
|
1330
1367
|
this.channel.postMessage({
|
|
1331
1368
|
type: 'note-saved',
|
|
1369
|
+
path,
|
|
1332
1370
|
slideIndex,
|
|
1333
1371
|
origin: origin || 'unknown',
|
|
1372
|
+
sourceWindowId: this.windowId,
|
|
1373
|
+
requestId,
|
|
1334
1374
|
...payload
|
|
1335
1375
|
});
|
|
1336
1376
|
};
|
|
1337
1377
|
|
|
1338
1378
|
const tab = state.tabs.find((t) => t.path === path);
|
|
1339
1379
|
if (!tab || !tab.isMarp) {
|
|
1340
|
-
|
|
1380
|
+
// This window no longer holds the deck (tab closed / switched
|
|
1381
|
+
// away). Broadcast a NO_DECK failure so a presenter that
|
|
1382
|
+
// routed here can fail over to another window instead of
|
|
1383
|
+
// hanging on "保存中…" or surfacing a dead-end error.
|
|
1384
|
+
const result = {
|
|
1385
|
+
ok: false,
|
|
1386
|
+
code: 'NO_DECK',
|
|
1387
|
+
reason: 'No active Marp tab'
|
|
1388
|
+
};
|
|
1389
|
+
broadcast(result);
|
|
1390
|
+
return result;
|
|
1341
1391
|
}
|
|
1342
1392
|
const ifMatch = editTimeEtag || tab.etag;
|
|
1343
1393
|
if (!ifMatch) {
|
|
@@ -1447,7 +1497,8 @@
|
|
|
1447
1497
|
this.channel.postMessage({
|
|
1448
1498
|
type: 'slides',
|
|
1449
1499
|
empty: true,
|
|
1450
|
-
reason: 'main-switched-away'
|
|
1500
|
+
reason: 'main-switched-away',
|
|
1501
|
+
sourceWindowId: this.windowId
|
|
1451
1502
|
});
|
|
1452
1503
|
return;
|
|
1453
1504
|
}
|
|
@@ -1459,7 +1510,8 @@
|
|
|
1459
1510
|
notes: tab.notes || [],
|
|
1460
1511
|
notesMultiplicity: tab.notesMultiplicity || [],
|
|
1461
1512
|
etag: tab.etag || null,
|
|
1462
|
-
current: marpCurrentSlide
|
|
1513
|
+
current: marpCurrentSlide,
|
|
1514
|
+
sourceWindowId: this.windowId
|
|
1463
1515
|
});
|
|
1464
1516
|
},
|
|
1465
1517
|
|
|
@@ -8,15 +8,33 @@
|
|
|
8
8
|
* Message types (discriminated by `type`):
|
|
9
9
|
*
|
|
10
10
|
* main → presenter
|
|
11
|
-
* { type: 'slides', path, html, css, etag, notes, notesMultiplicity,
|
|
12
|
-
*
|
|
11
|
+
* { type: 'slides', path, html, css, etag, notes, notesMultiplicity,
|
|
12
|
+
* current, sourceWindowId }
|
|
13
|
+
* { type: 'slides', empty: true, reason, sourceWindowId } ← clear / no-deck
|
|
13
14
|
* { type: 'index', index }
|
|
14
|
-
* { type: 'note-saved', slideIndex, ok, etag?, normalizedNote?,
|
|
15
|
+
* { type: 'note-saved', path, slideIndex, ok, etag?, normalizedNote?,
|
|
16
|
+
* code?, reason?, origin, sourceWindowId, requestId }
|
|
17
|
+
* { type: 'saver-here', path, windowId } ← failover reply
|
|
15
18
|
*
|
|
16
19
|
* presenter → main
|
|
17
20
|
* { type: 'request-slides' }
|
|
18
21
|
* { type: 'goto', index }
|
|
19
|
-
* { type: 'edit-note', path, etag, slideIndex, note
|
|
22
|
+
* { type: 'edit-note', path, etag, slideIndex, note, requestId,
|
|
23
|
+
* targetWindowId }
|
|
24
|
+
* { type: 'find-saver', path } ← failover query
|
|
25
|
+
*
|
|
26
|
+
* Window routing: every main window has a unique `windowId`. Each `slides`
|
|
27
|
+
* message carries the broadcasting window's id as `sourceWindowId`; the
|
|
28
|
+
* presenter pins the first one that can serve the deck and echoes it back as
|
|
29
|
+
* `edit-note.targetWindowId`. Only that one main window performs the save.
|
|
30
|
+
* Without this, N main windows showing the same deck each fire their own PUT
|
|
31
|
+
* and all but one collide on the optimistic lock → spurious "STALE" errors.
|
|
32
|
+
*
|
|
33
|
+
* Failover: if the pinned saver stops answering, the presenter broadcasts
|
|
34
|
+
* `find-saver`; any main window holding the deck (active OR background tab)
|
|
35
|
+
* replies `saver-here` and the presenter re-pins it. Each `edit-note` carries
|
|
36
|
+
* a unique `requestId` echoed back in `note-saved` so the presenter matches a
|
|
37
|
+
* result to the exact save that produced it.
|
|
20
38
|
*/
|
|
21
39
|
(function () {
|
|
22
40
|
'use strict';
|
|
@@ -27,7 +45,17 @@
|
|
|
27
45
|
return new BroadcastChannel(CHANNEL_NAME);
|
|
28
46
|
}
|
|
29
47
|
|
|
48
|
+
// Per-window identifier used to route presenter saves to a single main
|
|
49
|
+
// window. crypto.randomUUID is available on localhost (a secure context);
|
|
50
|
+
// the fallback keeps things working in any odd environment.
|
|
51
|
+
function newWindowId() {
|
|
52
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
53
|
+
return crypto.randomUUID();
|
|
54
|
+
}
|
|
55
|
+
return 'w-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
|
|
56
|
+
}
|
|
57
|
+
|
|
30
58
|
if (typeof globalThis !== 'undefined') {
|
|
31
|
-
globalThis.MDVPresenterChannel = { CHANNEL_NAME, create };
|
|
59
|
+
globalThis.MDVPresenterChannel = { CHANNEL_NAME, create, newWindowId };
|
|
32
60
|
}
|
|
33
61
|
})();
|
|
@@ -11,10 +11,12 @@
|
|
|
11
11
|
* - Crucially, coalescing is scoped per origin: an inline save and a
|
|
12
12
|
* presenter save for the same slide do NOT replace each other. Both run
|
|
13
13
|
* serially so neither editor silently loses a draft.
|
|
14
|
-
* - `saveFn(path, slideIndex, note, etag, origin)` is supplied by
|
|
15
|
-
* caller; `origin` is an optional tag (e.g. 'presenter' / 'inline') the
|
|
14
|
+
* - `saveFn(path, slideIndex, note, etag, origin, requestId)` is supplied by
|
|
15
|
+
* the caller; `origin` is an optional tag (e.g. 'presenter' / 'inline') the
|
|
16
16
|
* queue uses for keying and also forwards verbatim so saveFn can route
|
|
17
|
-
* notifications back to the right editor.
|
|
17
|
+
* notifications back to the right editor. `requestId` is an optional
|
|
18
|
+
* opaque token forwarded verbatim so a caller can correlate the save's
|
|
19
|
+
* result with the request that triggered it.
|
|
18
20
|
* - enqueue() returns a Promise that resolves with the saveFn's result (or a
|
|
19
21
|
* COALESCED sentinel). Existing callers that ignore the return value or
|
|
20
22
|
* skip the origin argument keep working unchanged.
|
|
@@ -32,7 +34,7 @@
|
|
|
32
34
|
/** @type {Map<string, { pending: Map<string, {slideIndex:number, note:string, etag:string|null, origin:string|undefined, resolve:Function}>, isDraining: boolean }>} */
|
|
33
35
|
const queue = new Map();
|
|
34
36
|
|
|
35
|
-
function enqueue(path, slideIndex, note, etag, origin) {
|
|
37
|
+
function enqueue(path, slideIndex, note, etag, origin, requestId) {
|
|
36
38
|
return new Promise((resolve) => {
|
|
37
39
|
let entry = queue.get(path);
|
|
38
40
|
if (!entry) {
|
|
@@ -44,7 +46,7 @@
|
|
|
44
46
|
if (existing) {
|
|
45
47
|
existing.resolve({ ok: false, reason: 'COALESCED' });
|
|
46
48
|
}
|
|
47
|
-
entry.pending.set(key, { slideIndex, note, etag, origin, resolve });
|
|
49
|
+
entry.pending.set(key, { slideIndex, note, etag, origin, requestId, resolve });
|
|
48
50
|
if (!entry.isDraining) drain(path);
|
|
49
51
|
});
|
|
50
52
|
}
|
|
@@ -62,7 +64,8 @@
|
|
|
62
64
|
let result;
|
|
63
65
|
try {
|
|
64
66
|
result = await saveFn(
|
|
65
|
-
path, payload.slideIndex, payload.note, payload.etag,
|
|
67
|
+
path, payload.slideIndex, payload.note, payload.etag,
|
|
68
|
+
payload.origin, payload.requestId
|
|
66
69
|
);
|
|
67
70
|
} catch (err) {
|
|
68
71
|
console.error('saveQueue saveFn error', err);
|
|
@@ -287,6 +287,33 @@
|
|
|
287
287
|
let deckPath = '';
|
|
288
288
|
let saveTimer = null;
|
|
289
289
|
|
|
290
|
+
// Window-routing + failover state: the presenter delegates saving to
|
|
291
|
+
// ONE main window (saverWindowId — the first that can serve this deck).
|
|
292
|
+
// saveAckTimer detects when that window stops answering. inflightSave /
|
|
293
|
+
// pendingSave hold a FROZEN copy of the edit ({path, slideIndex, note,
|
|
294
|
+
// etag, requestId}) so a failover resends the original edit, not
|
|
295
|
+
// whatever the editor shows after a later blur / slide navigation.
|
|
296
|
+
let saverWindowId = null;
|
|
297
|
+
let saveAckTimer = null;
|
|
298
|
+
let findSaverTimer = null; // bounds the wait for a find-saver reply
|
|
299
|
+
let inflightSave = null; // routed, awaiting ack | null
|
|
300
|
+
let pendingSave = null; // deferred until a saver is (re)pinned | null
|
|
301
|
+
const SAVE_ACK_TIMEOUT_MS = 6000;
|
|
302
|
+
const FIND_SAVER_TIMEOUT_MS = 3000;
|
|
303
|
+
|
|
304
|
+
// Each edit-note carries a globally-unique requestId so its note-saved
|
|
305
|
+
// ack matches the exact save that produced it — an older save's ack
|
|
306
|
+
// must never clear a newer save's failover timer. The presenterId
|
|
307
|
+
// prefix keeps ids distinct across multiple presenter windows.
|
|
308
|
+
const presenterId = (window.MDVPresenterChannel && window.MDVPresenterChannel.newWindowId)
|
|
309
|
+
? window.MDVPresenterChannel.newWindowId()
|
|
310
|
+
: ('p-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8));
|
|
311
|
+
let saveRequestSeq = 0;
|
|
312
|
+
function newRequestId() {
|
|
313
|
+
saveRequestSeq += 1;
|
|
314
|
+
return presenterId + ':' + saveRequestSeq;
|
|
315
|
+
}
|
|
316
|
+
|
|
290
317
|
const notesBanner = document.getElementById('notesBanner');
|
|
291
318
|
|
|
292
319
|
function applyCss(css) {
|
|
@@ -384,21 +411,16 @@
|
|
|
384
411
|
|
|
385
412
|
function loadSlides(payload) {
|
|
386
413
|
if (payload.empty) {
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
414
|
+
// Once a deck is shown, the presenter never blanks itself on an
|
|
415
|
+
// `empty`: with multiple main windows `request-slides` is global and
|
|
416
|
+
// any window on a non-Marp tab — including the pinned saver, which
|
|
417
|
+
// can still save the deck from a background tab — broadcasts `empty`.
|
|
418
|
+
// Blanking mid-presentation on that noise is wrong; keep the current
|
|
419
|
+
// slides and let save routing / failover handle reachability. Only
|
|
420
|
+
// an `empty` arriving before any deck has loaded shows the hint.
|
|
421
|
+
if (deckPath !== '') return;
|
|
393
422
|
fillStage(stageCurrent, '<div class="empty-state">メイン画面で Marp ファイルを開いてください。</div>');
|
|
394
423
|
fillStage(stageNext, '');
|
|
395
|
-
notes = [];
|
|
396
|
-
notesMultiplicity = [];
|
|
397
|
-
deckEtag = null;
|
|
398
|
-
deckPath = '';
|
|
399
|
-
slideCount = 0;
|
|
400
|
-
counter.textContent = '– / –';
|
|
401
|
-
setNotesText('');
|
|
402
424
|
applyReadOnlyState();
|
|
403
425
|
return;
|
|
404
426
|
}
|
|
@@ -416,7 +438,20 @@
|
|
|
416
438
|
notes = Array.isArray(payload.notes) ? payload.notes : [];
|
|
417
439
|
notesMultiplicity = Array.isArray(payload.notesMultiplicity) ? payload.notesMultiplicity : [];
|
|
418
440
|
deckEtag = payload.etag || null;
|
|
441
|
+
// Detect a deck switch BEFORE updating deckPath: the presenter window
|
|
442
|
+
// can be reused by another main window for another deck, and the old
|
|
443
|
+
// saver may not hold the new deck (→ "No active Marp tab", no retry).
|
|
444
|
+
const deckChanged = !!payload.path && payload.path !== deckPath;
|
|
419
445
|
deckPath = payload.path || deckPath;
|
|
446
|
+
// Pin the window serving this deck as our saver, so edit-note saves
|
|
447
|
+
// route to exactly one window (no duplicate PUTs). A non-empty
|
|
448
|
+
// `slides` proves the sender holds the deck. Re-pin on a deck switch
|
|
449
|
+
// (the presenter window can be reused by another main window).
|
|
450
|
+
// Failover (saver gone) is handled separately via find-saver /
|
|
451
|
+
// saver-here, which also discovers inactive background deck tabs.
|
|
452
|
+
if (payload.sourceWindowId && (saverWindowId === null || deckChanged)) {
|
|
453
|
+
saverWindowId = payload.sourceWindowId;
|
|
454
|
+
}
|
|
420
455
|
slideCount = stageCurrent.querySelectorAll('.marpit > svg[data-marpit-svg]').length;
|
|
421
456
|
render(typeof payload.current === 'number' ? payload.current : currentIndex);
|
|
422
457
|
}
|
|
@@ -451,24 +486,97 @@
|
|
|
451
486
|
}
|
|
452
487
|
}
|
|
453
488
|
|
|
489
|
+
// Broadcast a find-saver query for `path` and bound the wait: a deck
|
|
490
|
+
// open in ANY window's tab (active OR background) answers `saver-here`;
|
|
491
|
+
// if none does within the timeout the deck has no reachable window, so
|
|
492
|
+
// surface an honest error instead of spinning on 保存中… forever.
|
|
493
|
+
function requestSaver(path) {
|
|
494
|
+
channel.postMessage({ type: 'find-saver', path });
|
|
495
|
+
if (findSaverTimer !== null) clearTimeout(findSaverTimer);
|
|
496
|
+
findSaverTimer = setTimeout(() => {
|
|
497
|
+
findSaverTimer = null;
|
|
498
|
+
if (pendingSave !== null) {
|
|
499
|
+
pendingSave = null;
|
|
500
|
+
setSaveStatus('保存失敗: 保存先ウィンドウが見つかりません', 'err');
|
|
501
|
+
}
|
|
502
|
+
}, FIND_SAVER_TIMEOUT_MS);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Re-pin a live saver and resend `payload` (the exact edit that could
|
|
506
|
+
// not be delivered). Used when the saver window stops answering or no
|
|
507
|
+
// longer holds the deck.
|
|
508
|
+
function failOver(payload) {
|
|
509
|
+
if (saveAckTimer !== null) {
|
|
510
|
+
clearTimeout(saveAckTimer);
|
|
511
|
+
saveAckTimer = null;
|
|
512
|
+
}
|
|
513
|
+
saverWindowId = null;
|
|
514
|
+
inflightSave = null;
|
|
515
|
+
if (!payload) return;
|
|
516
|
+
pendingSave = payload;
|
|
517
|
+
requestSaver(payload.path);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// The saver window never acknowledged our edit-note (it was closed or
|
|
521
|
+
// frozen). Fail over, resending the exact edit that timed out.
|
|
522
|
+
function onSaveAckTimeout() {
|
|
523
|
+
saveAckTimer = null;
|
|
524
|
+
failOver(inflightSave);
|
|
525
|
+
}
|
|
526
|
+
|
|
454
527
|
function sendNoteSave() {
|
|
455
|
-
//
|
|
456
|
-
// not the live values, which can change under us if the
|
|
457
|
-
// navigates, switches tabs, or receives a watcher update
|
|
458
|
-
// debounce.
|
|
459
|
-
//
|
|
528
|
+
// Freeze the slide / deck / etag captured when the user started
|
|
529
|
+
// editing — not the live values, which can change under us if the
|
|
530
|
+
// main window navigates, switches tabs, or receives a watcher update
|
|
531
|
+
// during the debounce. The frozen payload also lets a failover resend
|
|
532
|
+
// the exact edit even after a later blur / slide navigation.
|
|
460
533
|
const idx = editingSlideIndex >= 0 ? editingSlideIndex : currentIndex;
|
|
461
|
-
const path = editingPath || deckPath;
|
|
462
|
-
const etag = editingEtag || deckEtag;
|
|
463
534
|
const value = getNotesText();
|
|
464
535
|
notes[idx] = value.trim();
|
|
536
|
+
postEditNote({
|
|
537
|
+
path: editingPath || deckPath,
|
|
538
|
+
slideIndex: idx,
|
|
539
|
+
note: value,
|
|
540
|
+
etag: editingEtag || deckEtag
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Route one frozen edit payload to the pinned saver, or defer it until
|
|
545
|
+
// a saver is (re)pinned.
|
|
546
|
+
function postEditNote(payload) {
|
|
465
547
|
setSaveStatus('保存中…');
|
|
548
|
+
|
|
549
|
+
// No live saver pinned yet (first edit before slides arrived, or the
|
|
550
|
+
// previous saver window went away). Routing an edit-note without a
|
|
551
|
+
// targetWindowId would let EVERY main window save it and collide on
|
|
552
|
+
// the optimistic lock — the exact bug this routing fixes. Instead ask
|
|
553
|
+
// who can serve this deck; the saver-here handler re-pins and resends.
|
|
554
|
+
if (saverWindowId === null) {
|
|
555
|
+
pendingSave = payload;
|
|
556
|
+
requestSaver(payload.path);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Fresh requestId per actual send: a failover resend must NOT reuse
|
|
561
|
+
// the timed-out original's id, or that original's late ack would be
|
|
562
|
+
// mistaken for the resend's ack.
|
|
563
|
+
inflightSave = {
|
|
564
|
+
path: payload.path,
|
|
565
|
+
slideIndex: payload.slideIndex,
|
|
566
|
+
note: payload.note,
|
|
567
|
+
etag: payload.etag,
|
|
568
|
+
requestId: newRequestId()
|
|
569
|
+
};
|
|
570
|
+
if (saveAckTimer !== null) clearTimeout(saveAckTimer);
|
|
571
|
+
saveAckTimer = setTimeout(onSaveAckTimeout, SAVE_ACK_TIMEOUT_MS);
|
|
466
572
|
channel.postMessage({
|
|
467
573
|
type: 'edit-note',
|
|
468
|
-
path,
|
|
469
|
-
etag,
|
|
470
|
-
slideIndex:
|
|
471
|
-
note:
|
|
574
|
+
path: inflightSave.path,
|
|
575
|
+
etag: inflightSave.etag,
|
|
576
|
+
slideIndex: inflightSave.slideIndex,
|
|
577
|
+
note: inflightSave.note,
|
|
578
|
+
requestId: inflightSave.requestId,
|
|
579
|
+
targetWindowId: saverWindowId
|
|
472
580
|
});
|
|
473
581
|
}
|
|
474
582
|
|
|
@@ -533,7 +641,56 @@
|
|
|
533
641
|
// the new etag came from our own successful save.
|
|
534
642
|
} else if (msg.type === 'index') {
|
|
535
643
|
render(msg.index);
|
|
644
|
+
} else if (msg.type === 'saver-here') {
|
|
645
|
+
// A window answered our find-saver query. Pin it and resend the
|
|
646
|
+
// deferred edit — but only if it serves the exact deck that edit
|
|
647
|
+
// targets (find-saver is path-scoped, yet guard defensively). The
|
|
648
|
+
// first answer wins; later answers find pendingSave already null.
|
|
649
|
+
if (pendingSave !== null
|
|
650
|
+
&& msg.windowId
|
|
651
|
+
&& msg.path === pendingSave.path) {
|
|
652
|
+
if (findSaverTimer !== null) {
|
|
653
|
+
clearTimeout(findSaverTimer);
|
|
654
|
+
findSaverTimer = null;
|
|
655
|
+
}
|
|
656
|
+
saverWindowId = msg.windowId;
|
|
657
|
+
const queued = pendingSave;
|
|
658
|
+
pendingSave = null;
|
|
659
|
+
postEditNote(queued);
|
|
660
|
+
}
|
|
536
661
|
} else if (msg.type === 'note-saved') {
|
|
662
|
+
// Is this the answer to OUR routed save? Match on the unique
|
|
663
|
+
// requestId so an older save's ack can't clear a newer save's
|
|
664
|
+
// failover timer, and a foreign save (e.g. an inline edit) for the
|
|
665
|
+
// same slide is ignored — inline saves carry no requestId.
|
|
666
|
+
const isOwnAck = inflightSave !== null
|
|
667
|
+
&& msg.requestId != null
|
|
668
|
+
&& msg.requestId === inflightSave.requestId;
|
|
669
|
+
// A note-saved that carries a requestId but isn't our current
|
|
670
|
+
// inflight save is a superseded / timed-out save's late ack — drop
|
|
671
|
+
// it so it can't overwrite status / backup / editingEtag for the
|
|
672
|
+
// current edit. requestId-less acks (inline edits) still fall
|
|
673
|
+
// through to the observer logic below.
|
|
674
|
+
if (!isOwnAck && msg.requestId != null) return;
|
|
675
|
+
if (isOwnAck) {
|
|
676
|
+
// The pinned saver no longer holds this deck (tab closed / switched
|
|
677
|
+
// away). It IS alive, but cannot save — fail over to a window that
|
|
678
|
+
// can, resending the same edit, instead of surfacing an error.
|
|
679
|
+
if (msg.code === 'NO_DECK') {
|
|
680
|
+
failOver(inflightSave);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
// A real answer (success OR genuine STALE) — stop the failover
|
|
684
|
+
// timer and let the status handling below run.
|
|
685
|
+
if (saveAckTimer !== null) {
|
|
686
|
+
clearTimeout(saveAckTimer);
|
|
687
|
+
saveAckTimer = null;
|
|
688
|
+
}
|
|
689
|
+
inflightSave = null;
|
|
690
|
+
}
|
|
691
|
+
// Ignore results for a different deck — a second presenter window,
|
|
692
|
+
// or the main window having switched decks mid-flight.
|
|
693
|
+
if (msg.path && deckPath && msg.path !== deckPath) return;
|
|
537
694
|
const targetIdx = editingSlideIndex >= 0 ? editingSlideIndex : currentIndex;
|
|
538
695
|
if (msg.slideIndex !== targetIdx) return;
|
|
539
696
|
// Only OUR own successful saves should advance editingEtag. The
|