mdv-live 0.5.19 → 0.5.21
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 +66 -0
- package/package.json +1 -1
- package/src/static/app.js +216 -7
- package/src/static/index.html +1 -0
- package/src/static/lib/marpZoom.js +84 -0
- package/src/static/lib/presenterChannel.js +33 -5
- package/src/static/lib/saveQueue.js +9 -6
- package/src/static/presenter.html +181 -24
- package/src/static/styles.css +27 -5
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,72 @@ 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.21] - 2026-05-22
|
|
9
|
+
|
|
10
|
+
### Fixed — Marp スライドが横長ペインで上下に見切れる
|
|
11
|
+
|
|
12
|
+
ウィンドウ表示(split モード)で、スライドペインがスライドのアスペクト比
|
|
13
|
+
(16:9)より横長になると、スライド(特に全面画像)の上下が見切れていた。
|
|
14
|
+
|
|
15
|
+
- 原因: スライドペイン内の `.marpit` に確定した高さがなく、active SVG の
|
|
16
|
+
`max-height: 100%` が無効化されていた。SVG が「幅 100%」だけで決まるため、
|
|
17
|
+
ペインが 16:9 より横長だと縦にあふれて上下がクリップされていた
|
|
18
|
+
(フルスクリーン表示は `.marpit` が `height: 100vh` を持つため影響なし)。
|
|
19
|
+
- 修正: `.marpit` に `height: 100%` を与え、active SVG を
|
|
20
|
+
`width/height: auto` + `max-width/height: 100%` の真の "contain" に変更。
|
|
21
|
+
ペインが横長・縦長どちらでもスライド全体が必ず収まる。
|
|
22
|
+
|
|
23
|
+
### Added — トラックパッドのピンチズーム / パン
|
|
24
|
+
|
|
25
|
+
Marp スライド表示で、トラックパッドのピンチ操作(macOS では ctrl+wheel、
|
|
26
|
+
マウスの ctrl+スクロールも同様)でスライドを拡大・縮小できるように。
|
|
27
|
+
|
|
28
|
+
- 二本指スクロールでパン(ペインのネイティブ overflow スクロール)。ピンチと
|
|
29
|
+
パンは分離(ctrl 付き wheel のみズーム)。
|
|
30
|
+
- カーソル位置を中心にズーム。ズーム範囲は fit(等倍)〜6倍。
|
|
31
|
+
- ダブルクリック / `0` キーで全体表示(fit)に復帰。`=` / `-` キーでも増減。
|
|
32
|
+
- スライド送り・フルスクリーン切替・ペインのリサイズで自動的に fit に追従。
|
|
33
|
+
- ズーム計算(contain fit / クランプ / wheel→zoom)は DOM 非依存の
|
|
34
|
+
`src/static/lib/marpZoom.js` に分離し、単体テスト(`tests/test-marp-zoom.js`、
|
|
35
|
+
12 件)を追加。
|
|
36
|
+
|
|
37
|
+
## [0.5.20] - 2026-05-18
|
|
38
|
+
|
|
39
|
+
### Fixed — Presenter View のノート編集が毎回 STALE エラー
|
|
40
|
+
|
|
41
|
+
Presenter View で speaker note を編集すると、毎回一瞬
|
|
42
|
+
`保存失敗: STALE — file changed externally; please reload`(赤)が出る
|
|
43
|
+
バグを修正。同じ deck を mdv のタブ/ウィンドウで 2 つ以上開いていると
|
|
44
|
+
必ず発生していた。
|
|
45
|
+
|
|
46
|
+
- 原因: Presenter View はノート保存を自前で行わず、`BroadcastChannel` の
|
|
47
|
+
`edit-note` メッセージで **メインウィンドウに保存を委譲** する。この
|
|
48
|
+
メッセージは同一ブラウザの **全メインウィンドウ** に届くため、deck を
|
|
49
|
+
複数ウィンドウで開いていると **全員が同じ `If-Match` で PUT** し、サーバの
|
|
50
|
+
楽観ロックで 1 つだけ成功、残りが 412 STALE になっていた。負けた側が
|
|
51
|
+
`note-saved {ok:false, STALE}` を Presenter にブロードキャストし、赤い
|
|
52
|
+
エラー表示になっていた(保存自体は 1 つ成功するため「一瞬だけ」見える)
|
|
53
|
+
- 各メインウィンドウに一意な `windowId` を付与。`slides` メッセージに
|
|
54
|
+
`sourceWindowId` を載せ、Presenter は最初に deck を返したウィンドウを
|
|
55
|
+
saver として固定、`edit-note` に `targetWindowId` を載せて **その 1
|
|
56
|
+
ウィンドウだけが保存** するようルーティング
|
|
57
|
+
- saver ウィンドウが閉じた/凍結した場合は保存タイムアウト(6 秒)で
|
|
58
|
+
検知し、`find-saver` 問い合わせで deck を持つ別ウィンドウ(非アクティブ
|
|
59
|
+
な背景タブ含む)を探して再ピン・再送するフェイルオーバーを実装
|
|
60
|
+
- 各 `edit-note` に一意な `requestId` を付与し `note-saved` で echo。
|
|
61
|
+
古い保存の ack が新しい保存のフェイルオーバー監視を誤って解除する問題を
|
|
62
|
+
防止
|
|
63
|
+
- `saveNote` が「対象 Marp タブなし」のとき `note-saved` をブロードキャスト
|
|
64
|
+
せず return していたため Presenter が `保存中…` のまま固まり得た問題も
|
|
65
|
+
併せて修正(`code:'NO_DECK'` を返してフェイルオーバーを誘発)
|
|
66
|
+
|
|
67
|
+
### Verified
|
|
68
|
+
|
|
69
|
+
- 既存 278 + 新規 3(`tests/test-presenter-channel.js`: `newWindowId` の
|
|
70
|
+
一意性 / フォールバック / モジュール API)
|
|
71
|
+
- Playwright dogfood: メインウィンドウ 2 枚 + Presenter で speaker note を
|
|
72
|
+
編集 → STALE が出ないこと、メインウィンドウ 1 枚で回帰がないことを実機確認
|
|
73
|
+
|
|
8
74
|
## [0.5.19] - 2026-05-16
|
|
9
75
|
|
|
10
76
|
### Fixed — Marp `![bg]` background images
|
package/package.json
CHANGED
package/src/static/app.js
CHANGED
|
@@ -1206,12 +1206,146 @@
|
|
|
1206
1206
|
}
|
|
1207
1207
|
};
|
|
1208
1208
|
|
|
1209
|
+
// ============================================================
|
|
1210
|
+
// Marp Slide Zoom (trackpad pinch-to-zoom + pan)
|
|
1211
|
+
// ============================================================
|
|
1212
|
+
//
|
|
1213
|
+
// At fit (zoom === 1) the slide is sized entirely by the CSS "contain"
|
|
1214
|
+
// rules so the whole slide — image and all — is always visible. Zooming
|
|
1215
|
+
// past 1 switches the active SVG to explicit pixel dimensions
|
|
1216
|
+
// (fitSize * zoom); the pane's native overflow then lets a two-finger
|
|
1217
|
+
// scroll pan around the enlarged slide. macOS trackpad pinch arrives as a
|
|
1218
|
+
// `wheel` event with `ctrlKey` set, so ctrl+scroll on a mouse zooms too.
|
|
1219
|
+
const MarpZoom = {
|
|
1220
|
+
area: null,
|
|
1221
|
+
zoom: 1,
|
|
1222
|
+
onWheel: null,
|
|
1223
|
+
onDblClick: null,
|
|
1224
|
+
ro: null,
|
|
1225
|
+
|
|
1226
|
+
// Pure zoom math lives in lib/marpZoom.js (globalThis.MDVMarpZoom) so
|
|
1227
|
+
// it can be unit-tested without a DOM. If that script failed to load,
|
|
1228
|
+
// the CSS-only fit still works; we just skip wiring the gestures.
|
|
1229
|
+
lib() { return (typeof globalThis !== 'undefined') ? globalThis.MDVMarpZoom : null; },
|
|
1230
|
+
|
|
1231
|
+
init(area) {
|
|
1232
|
+
this.detach();
|
|
1233
|
+
if (!this.lib()) return;
|
|
1234
|
+
this.area = area;
|
|
1235
|
+
this.zoom = 1;
|
|
1236
|
+
this.onWheel = (e) => {
|
|
1237
|
+
// Plain two-finger scroll is left to the pane so it pans the
|
|
1238
|
+
// zoomed slide natively. Only a pinch (ctrlKey) zooms.
|
|
1239
|
+
if (!e.ctrlKey) return;
|
|
1240
|
+
e.preventDefault();
|
|
1241
|
+
this.zoomTo(this.lib().zoomForWheel(this.zoom, e.deltaY), e.clientX, e.clientY);
|
|
1242
|
+
};
|
|
1243
|
+
// Double-click anywhere on the slide snaps back to fit.
|
|
1244
|
+
this.onDblClick = () => this.reset();
|
|
1245
|
+
area.addEventListener('wheel', this.onWheel, { passive: false });
|
|
1246
|
+
area.addEventListener('dblclick', this.onDblClick);
|
|
1247
|
+
// Re-apply the pixel size when the pane is resized (window resize,
|
|
1248
|
+
// dragging the notes splitter) so a zoomed slide tracks the new
|
|
1249
|
+
// fit instead of freezing at a stale size.
|
|
1250
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
1251
|
+
this.ro = new ResizeObserver(() => {
|
|
1252
|
+
if (!this.lib().isFit(this.zoom)) this.zoomTo(this.zoom);
|
|
1253
|
+
});
|
|
1254
|
+
this.ro.observe(area);
|
|
1255
|
+
}
|
|
1256
|
+
},
|
|
1257
|
+
|
|
1258
|
+
detach() {
|
|
1259
|
+
if (this.area && this.onWheel) {
|
|
1260
|
+
this.area.removeEventListener('wheel', this.onWheel);
|
|
1261
|
+
this.area.removeEventListener('dblclick', this.onDblClick);
|
|
1262
|
+
}
|
|
1263
|
+
if (this.ro) { this.ro.disconnect(); this.ro = null; }
|
|
1264
|
+
this.area = null;
|
|
1265
|
+
this.onWheel = null;
|
|
1266
|
+
this.onDblClick = null;
|
|
1267
|
+
this.zoom = 1;
|
|
1268
|
+
},
|
|
1269
|
+
|
|
1270
|
+
activeSvg() {
|
|
1271
|
+
return this.area
|
|
1272
|
+
? this.area.querySelector('.marpit > svg[data-marpit-svg].active')
|
|
1273
|
+
: null;
|
|
1274
|
+
},
|
|
1275
|
+
|
|
1276
|
+
// Slide dimensions at fit (zoom 1), resolved the same way the CSS
|
|
1277
|
+
// "contain" rule does — so the 1.0 → 1.01 transition doesn't jump.
|
|
1278
|
+
fitSize(svg) {
|
|
1279
|
+
const cs = getComputedStyle(this.area);
|
|
1280
|
+
const padX = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight);
|
|
1281
|
+
const padY = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
|
|
1282
|
+
const aw = this.area.clientWidth - padX;
|
|
1283
|
+
const ah = this.area.clientHeight - padY;
|
|
1284
|
+
const vb = svg.viewBox && svg.viewBox.baseVal;
|
|
1285
|
+
const ratio = (vb && vb.width) ? vb.height / vb.width : 9 / 16;
|
|
1286
|
+
return this.lib().containFit(aw, ah, ratio);
|
|
1287
|
+
},
|
|
1288
|
+
|
|
1289
|
+
// Apply `z` around a focal point (defaults to the pane centre). The
|
|
1290
|
+
// before/after rects already fold in centring and scroll offsets, so
|
|
1291
|
+
// the point under the cursor stays put as the slide grows.
|
|
1292
|
+
zoomTo(z, focalX, focalY) {
|
|
1293
|
+
const svg = this.activeSvg();
|
|
1294
|
+
if (!svg) return;
|
|
1295
|
+
z = this.lib().clampZoom(z);
|
|
1296
|
+
if (this.lib().isFit(z)) { this.reset(); return; }
|
|
1297
|
+
|
|
1298
|
+
if (focalX == null) {
|
|
1299
|
+
const r = this.area.getBoundingClientRect();
|
|
1300
|
+
focalX = r.left + r.width / 2;
|
|
1301
|
+
focalY = r.top + r.height / 2;
|
|
1302
|
+
}
|
|
1303
|
+
const before = svg.getBoundingClientRect();
|
|
1304
|
+
const relX = before.width ? (focalX - before.left) / before.width : 0.5;
|
|
1305
|
+
const relY = before.height ? (focalY - before.top) / before.height : 0.5;
|
|
1306
|
+
|
|
1307
|
+
this.zoom = z;
|
|
1308
|
+
const fit = this.fitSize(svg);
|
|
1309
|
+
svg.style.width = (fit.w * z) + 'px';
|
|
1310
|
+
svg.style.height = (fit.h * z) + 'px';
|
|
1311
|
+
this.area.classList.add('marp-zoomed');
|
|
1312
|
+
|
|
1313
|
+
const after = svg.getBoundingClientRect();
|
|
1314
|
+
this.area.scrollLeft += (after.left + relX * after.width) - focalX;
|
|
1315
|
+
this.area.scrollTop += (after.top + relY * after.height) - focalY;
|
|
1316
|
+
},
|
|
1317
|
+
|
|
1318
|
+
// Step zoom for keyboard (+/-): dir > 0 zooms in, else out.
|
|
1319
|
+
nudge(dir) {
|
|
1320
|
+
if (!this.lib()) return;
|
|
1321
|
+
this.zoomTo(this.lib().zoomForStep(this.zoom, dir));
|
|
1322
|
+
},
|
|
1323
|
+
|
|
1324
|
+
// Back to fit: clear the pixel sizing on every slide (the active one
|
|
1325
|
+
// may have changed since we zoomed) and hand sizing back to CSS.
|
|
1326
|
+
reset() {
|
|
1327
|
+
this.zoom = 1;
|
|
1328
|
+
if (!this.area) return;
|
|
1329
|
+
this.area.querySelectorAll('.marpit > svg[data-marpit-svg]').forEach(s => {
|
|
1330
|
+
s.style.width = '';
|
|
1331
|
+
s.style.height = '';
|
|
1332
|
+
});
|
|
1333
|
+
this.area.classList.remove('marp-zoomed');
|
|
1334
|
+
this.area.scrollLeft = 0;
|
|
1335
|
+
this.area.scrollTop = 0;
|
|
1336
|
+
}
|
|
1337
|
+
};
|
|
1338
|
+
|
|
1209
1339
|
// ============================================================
|
|
1210
1340
|
// Presenter View (separate window with speaker notes)
|
|
1211
1341
|
// ============================================================
|
|
1212
1342
|
|
|
1213
1343
|
const PresenterView = {
|
|
1214
1344
|
channel: null,
|
|
1345
|
+
// Unique id for this main window. The presenter echoes it back as
|
|
1346
|
+
// `edit-note.targetWindowId` so that exactly one main window saves,
|
|
1347
|
+
// even when the same deck is open in several windows.
|
|
1348
|
+
windowId: null,
|
|
1215
1349
|
presenterWindow: null,
|
|
1216
1350
|
saveQueue: null, // MDVSaveQueue instance (created in init)
|
|
1217
1351
|
// Map<path, etag> — own-save chain rebase. We track presenter and
|
|
@@ -1246,7 +1380,7 @@
|
|
|
1246
1380
|
// can autosave in environments (older browsers / sandboxed
|
|
1247
1381
|
// webviews) where the Presenter window cannot be opened.
|
|
1248
1382
|
this.saveQueue = window.MDVSaveQueue.createSaveQueue({
|
|
1249
|
-
saveFn: (path, slideIndex, note, etag, origin) => {
|
|
1383
|
+
saveFn: (path, slideIndex, note, etag, origin, requestId) => {
|
|
1250
1384
|
let useEtag = etag;
|
|
1251
1385
|
const tab = state.tabs.find((t) => t.path === path);
|
|
1252
1386
|
// Pick the "own etag" map that matches this save's
|
|
@@ -1259,7 +1393,7 @@
|
|
|
1259
1393
|
: this.lastSavedEtag;
|
|
1260
1394
|
const own = ownMap.get(path);
|
|
1261
1395
|
if (tab && own && tab.etag === own) useEtag = own;
|
|
1262
|
-
return this.saveNote(path, slideIndex, note, useEtag, origin);
|
|
1396
|
+
return this.saveNote(path, slideIndex, note, useEtag, origin, requestId);
|
|
1263
1397
|
}
|
|
1264
1398
|
});
|
|
1265
1399
|
|
|
@@ -1279,6 +1413,7 @@
|
|
|
1279
1413
|
// their channel.postMessage calls (channel === null).
|
|
1280
1414
|
if (typeof BroadcastChannel !== 'undefined'
|
|
1281
1415
|
&& window.MDVPresenterChannel) {
|
|
1416
|
+
this.windowId = window.MDVPresenterChannel.newWindowId();
|
|
1282
1417
|
this.channel = window.MDVPresenterChannel.create();
|
|
1283
1418
|
if (this.channel) {
|
|
1284
1419
|
this.channel.addEventListener('message', (e) => {
|
|
@@ -1287,11 +1422,38 @@
|
|
|
1287
1422
|
this.broadcastSlides();
|
|
1288
1423
|
} else if (msg.type === 'goto') {
|
|
1289
1424
|
this.gotoSlide(msg.index);
|
|
1425
|
+
} else if (msg.type === 'find-saver') {
|
|
1426
|
+
// Failover discovery: the presenter lost its
|
|
1427
|
+
// saver and asks who can save `path`. Answer if
|
|
1428
|
+
// this window holds that deck in ANY tab —
|
|
1429
|
+
// saveNote() resolves by path, so an inactive
|
|
1430
|
+
// background tab counts (broadcastSlides only
|
|
1431
|
+
// reports the active tab and would miss it).
|
|
1432
|
+
if (msg.path
|
|
1433
|
+
&& state.tabs.some((t) => t.path === msg.path && t.isMarp)) {
|
|
1434
|
+
this.channel.postMessage({
|
|
1435
|
+
type: 'saver-here',
|
|
1436
|
+
path: msg.path,
|
|
1437
|
+
windowId: this.windowId
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1290
1440
|
} else if (msg.type === 'edit-note') {
|
|
1291
1441
|
if (!msg.path) return;
|
|
1442
|
+
// Route: only the main window the presenter
|
|
1443
|
+
// picked as its saver performs the save. Without
|
|
1444
|
+
// this, every main window showing the same deck
|
|
1445
|
+
// fires its own PUT and all but one collide on
|
|
1446
|
+
// the optimistic lock → spurious "STALE" in the
|
|
1447
|
+
// presenter. A missing targetWindowId (older
|
|
1448
|
+
// presenter build) falls back to handling it so
|
|
1449
|
+
// saves still work, just without dedup.
|
|
1450
|
+
if (msg.targetWindowId
|
|
1451
|
+
&& msg.targetWindowId !== this.windowId) {
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1292
1454
|
this.saveQueue.enqueue(
|
|
1293
1455
|
msg.path, msg.slideIndex, msg.note,
|
|
1294
|
-
msg.etag || null, 'presenter'
|
|
1456
|
+
msg.etag || null, 'presenter', msg.requestId
|
|
1295
1457
|
);
|
|
1296
1458
|
}
|
|
1297
1459
|
});
|
|
@@ -1317,11 +1479,16 @@
|
|
|
1317
1479
|
// next autosave would skip the STALE conflict it should otherwise
|
|
1318
1480
|
// hit and silently overwrite the inline edit).
|
|
1319
1481
|
//
|
|
1482
|
+
// `requestId` is the opaque token from the presenter's edit-note;
|
|
1483
|
+
// echoing it back in note-saved lets the presenter match this
|
|
1484
|
+
// result to the exact save it sent (older saves' acks then can't
|
|
1485
|
+
// cancel a newer save's failover timer).
|
|
1486
|
+
//
|
|
1320
1487
|
// Returns { ok, etag?, normalizedNote?, reason?, code? } so saveQueue
|
|
1321
1488
|
// can forward the result to enqueue() awaiters (the main-window inline
|
|
1322
1489
|
// notes panel reads this). The presenter window still gets results via
|
|
1323
1490
|
// the existing channel.postMessage('note-saved') broadcast.
|
|
1324
|
-
async saveNote(path, slideIndex, note, editTimeEtag, origin) {
|
|
1491
|
+
async saveNote(path, slideIndex, note, editTimeEtag, origin, requestId) {
|
|
1325
1492
|
const broadcast = (payload) => {
|
|
1326
1493
|
// No-op when BroadcastChannel was unavailable at init —
|
|
1327
1494
|
// inline autosaves still work because callers also read
|
|
@@ -1329,15 +1496,28 @@
|
|
|
1329
1496
|
if (!this.channel) return;
|
|
1330
1497
|
this.channel.postMessage({
|
|
1331
1498
|
type: 'note-saved',
|
|
1499
|
+
path,
|
|
1332
1500
|
slideIndex,
|
|
1333
1501
|
origin: origin || 'unknown',
|
|
1502
|
+
sourceWindowId: this.windowId,
|
|
1503
|
+
requestId,
|
|
1334
1504
|
...payload
|
|
1335
1505
|
});
|
|
1336
1506
|
};
|
|
1337
1507
|
|
|
1338
1508
|
const tab = state.tabs.find((t) => t.path === path);
|
|
1339
1509
|
if (!tab || !tab.isMarp) {
|
|
1340
|
-
|
|
1510
|
+
// This window no longer holds the deck (tab closed / switched
|
|
1511
|
+
// away). Broadcast a NO_DECK failure so a presenter that
|
|
1512
|
+
// routed here can fail over to another window instead of
|
|
1513
|
+
// hanging on "保存中…" or surfacing a dead-end error.
|
|
1514
|
+
const result = {
|
|
1515
|
+
ok: false,
|
|
1516
|
+
code: 'NO_DECK',
|
|
1517
|
+
reason: 'No active Marp tab'
|
|
1518
|
+
};
|
|
1519
|
+
broadcast(result);
|
|
1520
|
+
return result;
|
|
1341
1521
|
}
|
|
1342
1522
|
const ifMatch = editTimeEtag || tab.etag;
|
|
1343
1523
|
if (!ifMatch) {
|
|
@@ -1447,7 +1627,8 @@
|
|
|
1447
1627
|
this.channel.postMessage({
|
|
1448
1628
|
type: 'slides',
|
|
1449
1629
|
empty: true,
|
|
1450
|
-
reason: 'main-switched-away'
|
|
1630
|
+
reason: 'main-switched-away',
|
|
1631
|
+
sourceWindowId: this.windowId
|
|
1451
1632
|
});
|
|
1452
1633
|
return;
|
|
1453
1634
|
}
|
|
@@ -1459,7 +1640,8 @@
|
|
|
1459
1640
|
notes: tab.notes || [],
|
|
1460
1641
|
notesMultiplicity: tab.notesMultiplicity || [],
|
|
1461
1642
|
etag: tab.etag || null,
|
|
1462
|
-
current: marpCurrentSlide
|
|
1643
|
+
current: marpCurrentSlide,
|
|
1644
|
+
sourceWindowId: this.windowId
|
|
1463
1645
|
});
|
|
1464
1646
|
},
|
|
1465
1647
|
|
|
@@ -1471,6 +1653,8 @@
|
|
|
1471
1653
|
gotoSlide(index) {
|
|
1472
1654
|
const slides = elements.content.querySelectorAll('.marpit > svg[data-marpit-svg]');
|
|
1473
1655
|
if (!slides.length || index < 0 || index >= slides.length) return;
|
|
1656
|
+
// Each slide opens at fit; clear any zoom carried from the last one.
|
|
1657
|
+
MarpZoom.reset();
|
|
1474
1658
|
slides.forEach((s, i) => s.classList.toggle('active', i === index));
|
|
1475
1659
|
const panels = elements.content.querySelectorAll(
|
|
1476
1660
|
'#marpNotesArea > .speaker-notes-panel'
|
|
@@ -1567,6 +1751,12 @@
|
|
|
1567
1751
|
MarpSplitHandle.attach(splitEl, handleEl);
|
|
1568
1752
|
}
|
|
1569
1753
|
|
|
1754
|
+
// Enable trackpad pinch-to-zoom / pan on the slide pane.
|
|
1755
|
+
const slideArea = document.getElementById('marpSlideArea');
|
|
1756
|
+
if (slideArea) {
|
|
1757
|
+
MarpZoom.init(slideArea);
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1570
1760
|
// Add navigation controls. The nav is appended to .content (NOT
|
|
1571
1761
|
// marpit) so its `position: fixed` doesn't get clipped by the
|
|
1572
1762
|
// grid container's overflow:hidden rule.
|
|
@@ -1640,6 +1830,8 @@
|
|
|
1640
1830
|
);
|
|
1641
1831
|
|
|
1642
1832
|
const showSlide = (index) => {
|
|
1833
|
+
// Each slide opens at fit; clear any zoom from the last one.
|
|
1834
|
+
MarpZoom.reset();
|
|
1643
1835
|
slides.forEach((slide, i) => {
|
|
1644
1836
|
slide.classList.toggle('active', i === index);
|
|
1645
1837
|
});
|
|
@@ -1679,6 +1871,11 @@
|
|
|
1679
1871
|
const expandIcon = '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><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" /></svg>';
|
|
1680
1872
|
const shrinkIcon = '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 9V4m0 5H4m5 0L4 4m11 5h5m-5 0V4m0 5l5-5M9 15v5m0-5H4m5 0l-5 5m11-5h5m-5 0v5m0-5l5 5" /></svg>';
|
|
1681
1873
|
const toggleFullscreen = () => {
|
|
1874
|
+
// Snap back to fit across the transition: the fullscreen and
|
|
1875
|
+
// windowed panes have different sizes, and the fullscreen CSS
|
|
1876
|
+
// owns the fit there, so a leftover pixel zoom would mis-size
|
|
1877
|
+
// the slide. The user can re-pinch on either side.
|
|
1878
|
+
MarpZoom.reset();
|
|
1682
1879
|
document.body.classList.toggle('marp-fullscreen');
|
|
1683
1880
|
const isFullscreen = document.body.classList.contains('marp-fullscreen');
|
|
1684
1881
|
if (fullscreenBtn) {
|
|
@@ -1774,6 +1971,17 @@
|
|
|
1774
1971
|
// shortcut and must not also open the presenter view.
|
|
1775
1972
|
e.preventDefault();
|
|
1776
1973
|
PresenterView.open();
|
|
1974
|
+
} else if ((e.key === '+' || e.key === '=') && !e.metaKey && !e.ctrlKey) {
|
|
1975
|
+
// Keyboard zoom (centre-anchored) mirrors the pinch gesture.
|
|
1976
|
+
// Skip Cmd/Ctrl which the browser owns for page zoom.
|
|
1977
|
+
e.preventDefault();
|
|
1978
|
+
MarpZoom.nudge(1);
|
|
1979
|
+
} else if ((e.key === '-' || e.key === '_') && !e.metaKey && !e.ctrlKey) {
|
|
1980
|
+
e.preventDefault();
|
|
1981
|
+
MarpZoom.nudge(-1);
|
|
1982
|
+
} else if (e.key === '0' && !e.metaKey && !e.ctrlKey) {
|
|
1983
|
+
e.preventDefault();
|
|
1984
|
+
MarpZoom.reset();
|
|
1777
1985
|
} else if (e.key === 'Escape') {
|
|
1778
1986
|
e.preventDefault();
|
|
1779
1987
|
if (document.body.classList.contains('marp-fullscreen')) {
|
|
@@ -1791,6 +1999,7 @@
|
|
|
1791
1999
|
// 800ms save timer doesn't fire after the editor element is gone.
|
|
1792
2000
|
InlineNotesPanel.detach();
|
|
1793
2001
|
MarpSplitHandle.detach();
|
|
2002
|
+
MarpZoom.detach();
|
|
1794
2003
|
elements.content.classList.remove('marp-viewer');
|
|
1795
2004
|
document.body.classList.remove('marp-fullscreen');
|
|
1796
2005
|
if (marpKeyHandler) {
|
package/src/static/index.html
CHANGED
|
@@ -181,6 +181,7 @@
|
|
|
181
181
|
<script src="/static/lib/apiClient.js"></script>
|
|
182
182
|
<script src="/static/lib/saveQueue.js"></script>
|
|
183
183
|
<script src="/static/lib/tabRegistry.js"></script>
|
|
184
|
+
<script src="/static/lib/marpZoom.js"></script>
|
|
184
185
|
<script src="/static/app.js"></script>
|
|
185
186
|
</body>
|
|
186
187
|
</html>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure math for the Marp slide zoom (src/static/app.js → MarpZoom).
|
|
3
|
+
*
|
|
4
|
+
* Imported from a `<script>` tag (no module loader), so the functions are
|
|
5
|
+
* exposed on `globalThis.MDVMarpZoom`. Kept DOM-free so the contain/clamp
|
|
6
|
+
* logic — the part that decides whether the whole slide stays visible — can
|
|
7
|
+
* be unit-tested without a browser (see tests/test-marp-zoom.js).
|
|
8
|
+
*/
|
|
9
|
+
(function () {
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const ZOOM_MIN = 1;
|
|
13
|
+
const ZOOM_MAX = 6;
|
|
14
|
+
|
|
15
|
+
// Per-wheel-delta zoom sensitivity. Pinch deltas are small and frequent;
|
|
16
|
+
// the exponential keeps each step proportional so the gesture feels even
|
|
17
|
+
// across the whole range instead of accelerating near the top. Tuned for a
|
|
18
|
+
// snappy pinch (a 120-delta notch ≈ +62%; was 0.0015 ≈ +20%, 0.0025 ≈ +35%).
|
|
19
|
+
const WHEEL_FACTOR = 0.004;
|
|
20
|
+
|
|
21
|
+
// Keyboard +/- step ratio (zoom in / zoom out).
|
|
22
|
+
const STEP_IN = 1.25;
|
|
23
|
+
const STEP_OUT = 0.8;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* "Contain" fit: the largest w×h with aspect `ratio` (= height/width) that
|
|
27
|
+
* fits inside areaW×areaH. Mirrors the CSS `max-width/height:100%` +
|
|
28
|
+
* `width/height:auto` resolution so the JS-driven zoom (≥1) starts exactly
|
|
29
|
+
* where the CSS fit (=1) leaves off — no jump at the 1.0 boundary.
|
|
30
|
+
*
|
|
31
|
+
* @param {number} areaW available content width (px)
|
|
32
|
+
* @param {number} areaH available content height (px)
|
|
33
|
+
* @param {number} ratio slide height / slide width (e.g. 720/1280)
|
|
34
|
+
* @returns {{w:number,h:number}} fitted slide size, never below 1px
|
|
35
|
+
*/
|
|
36
|
+
function containFit(areaW, areaH, ratio) {
|
|
37
|
+
if (!(areaW > 0) || !(areaH > 0) || !(ratio > 0)) {
|
|
38
|
+
return { w: 1, h: 1 };
|
|
39
|
+
}
|
|
40
|
+
let w = areaW;
|
|
41
|
+
let h = areaW * ratio;
|
|
42
|
+
if (h > areaH) {
|
|
43
|
+
h = areaH;
|
|
44
|
+
w = areaH / ratio;
|
|
45
|
+
}
|
|
46
|
+
return { w: Math.max(1, w), h: Math.max(1, h) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Clamp a zoom level to [ZOOM_MIN, ZOOM_MAX]. */
|
|
50
|
+
function clampZoom(z) {
|
|
51
|
+
if (!Number.isFinite(z)) return ZOOM_MIN;
|
|
52
|
+
return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Next zoom level for a wheel/pinch delta. Negative deltaY (pinch open /
|
|
57
|
+
* scroll up) zooms in. Result is already clamped.
|
|
58
|
+
*/
|
|
59
|
+
function zoomForWheel(current, deltaY) {
|
|
60
|
+
return clampZoom(current * Math.exp(-deltaY * WHEEL_FACTOR));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Next zoom level for a keyboard step. dir > 0 zooms in, else out. */
|
|
64
|
+
function zoomForStep(current, dir) {
|
|
65
|
+
return clampZoom(current * (dir > 0 ? STEP_IN : STEP_OUT));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** True when a zoom level is effectively the fit (no pixel sizing needed). */
|
|
69
|
+
function isFit(z) {
|
|
70
|
+
return z <= ZOOM_MIN + 0.001;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (typeof globalThis !== 'undefined') {
|
|
74
|
+
globalThis.MDVMarpZoom = {
|
|
75
|
+
ZOOM_MIN,
|
|
76
|
+
ZOOM_MAX,
|
|
77
|
+
containFit,
|
|
78
|
+
clampZoom,
|
|
79
|
+
zoomForWheel,
|
|
80
|
+
zoomForStep,
|
|
81
|
+
isFit,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
})();
|
|
@@ -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
|
package/src/static/styles.css
CHANGED
|
@@ -1080,24 +1080,38 @@ body.marp-fullscreen .marpit > svg[data-marpit-svg] {
|
|
|
1080
1080
|
.marp-slide-area {
|
|
1081
1081
|
overflow: auto;
|
|
1082
1082
|
display: flex;
|
|
1083
|
-
|
|
1084
|
-
|
|
1083
|
+
/* `safe` keeps the slide reachable when zoomed larger than the pane:
|
|
1084
|
+
plain `center` would push the overflowing top/left out of the scroll
|
|
1085
|
+
range so you could never scroll back to them. */
|
|
1086
|
+
align-items: safe center;
|
|
1087
|
+
justify-content: safe center;
|
|
1085
1088
|
padding: 20px;
|
|
1086
1089
|
min-height: 0;
|
|
1087
1090
|
}
|
|
1088
1091
|
|
|
1092
|
+
/* The pane is the trackpad-zoom focus target. Pinch (ctrl+wheel) zooms the
|
|
1093
|
+
slide; two-finger scroll then pans via the pane's native overflow. */
|
|
1094
|
+
.marp-slide-area.marp-zoomed { cursor: grab; }
|
|
1095
|
+
.marp-slide-area.marp-zoomed.marp-panning { cursor: grabbing; }
|
|
1096
|
+
|
|
1089
1097
|
.marp-slide-area .marpit {
|
|
1098
|
+
/* Definite height (the pane's height is definite) so the active SVG's
|
|
1099
|
+
`max-height: 100%` actually clamps — without it the SVG was sized by
|
|
1100
|
+
width alone and overflowed vertically on wide/short panes. */
|
|
1090
1101
|
width: 100%;
|
|
1091
|
-
|
|
1102
|
+
height: 100%;
|
|
1092
1103
|
display: flex;
|
|
1093
|
-
align-items: center;
|
|
1094
|
-
justify-content: center;
|
|
1104
|
+
align-items: safe center;
|
|
1105
|
+
justify-content: safe center;
|
|
1095
1106
|
}
|
|
1096
1107
|
|
|
1097
1108
|
.marp-slide-area .marpit > svg[data-marpit-svg] {
|
|
1098
1109
|
display: none;
|
|
1110
|
+
/* width:auto + height:auto + the two max-* caps give "contain": the SVG
|
|
1111
|
+
(intrinsic 16:9 viewBox) scales down to fit BOTH pane dimensions. */
|
|
1099
1112
|
max-width: 100%;
|
|
1100
1113
|
max-height: 100%;
|
|
1114
|
+
width: auto;
|
|
1101
1115
|
height: auto;
|
|
1102
1116
|
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
|
|
1103
1117
|
border-radius: 4px;
|
|
@@ -1107,6 +1121,14 @@ body.marp-fullscreen .marpit > svg[data-marpit-svg] {
|
|
|
1107
1121
|
display: block;
|
|
1108
1122
|
}
|
|
1109
1123
|
|
|
1124
|
+
/* When zoomed past fit, MarpZoom sets explicit px width/height on the active
|
|
1125
|
+
SVG. The caps must be lifted or they'd clamp it back to the pane size. */
|
|
1126
|
+
.marp-slide-area.marp-zoomed .marpit { width: auto; height: auto; }
|
|
1127
|
+
.marp-slide-area.marp-zoomed .marpit > svg[data-marpit-svg].active {
|
|
1128
|
+
max-width: none;
|
|
1129
|
+
max-height: none;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1110
1132
|
.marp-split-handle {
|
|
1111
1133
|
cursor: row-resize;
|
|
1112
1134
|
background: var(--border);
|