gutterpress 0.9.0-alpha.2 → 0.10.0-alpha.4

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.
Files changed (138) hide show
  1. package/README.md +18 -4
  2. package/dist/api/index.d.ts +11 -5
  3. package/dist/api/index.js +20 -15
  4. package/dist/assets/preview/scripts/preview-bridge.d.ts +1 -0
  5. package/dist/assets/preview/scripts/preview-interface.d.ts +1 -0
  6. package/dist/{audit-nhn2pjz3.js → audit-k1vnfwvc.js} +10 -7
  7. package/dist/{build-san7fv2z.js → build-hqmwgvdw.js} +14 -8
  8. package/dist/checks/source/index.d.ts +1 -0
  9. package/dist/checks/source/layout-markers.d.ts +3 -0
  10. package/dist/checks/source/local-ref-parser.d.ts +28 -0
  11. package/dist/{cli-e5zhb0xs.js → cli-0r0tq16s.js} +17 -24
  12. package/dist/cli-46ycxe6r.js +18 -0
  13. package/dist/cli-c41yr7he.js +241 -0
  14. package/dist/cli-hp9r2pzt.js +2343 -0
  15. package/dist/{index-yzrh708h.js → cli-k1065rkg.js} +2 -5
  16. package/dist/{cli-k4bd06sd.js → cli-n25qycwz.js} +4986 -5889
  17. package/dist/{cli-najycadg.js → cli-ra0ed2xt.js} +69 -253
  18. package/dist/{cli-506tg37g.js → cli-revgt4pr.js} +2 -1
  19. package/dist/{cli-wchtvxvw.js → cli-v5mp7a6q.js} +14 -1
  20. package/dist/cli.js +20 -18
  21. package/dist/{doctor-hrk0kxxz.js → doctor-dxms7ehm.js} +4 -2
  22. package/dist/engine/compiler/build.d.ts +139 -0
  23. package/dist/engine/compiler/postprocess.d.ts +23 -0
  24. package/dist/engine/compiler/tier2.d.ts +78 -0
  25. package/dist/engine/shared/cdp.d.ts +104 -0
  26. package/dist/engine/shared/content-value.d.ts +64 -0
  27. package/dist/engine/shared/gcpm-extract.d.ts +119 -0
  28. package/dist/engine/shared/margin-box-support.d.ts +12 -0
  29. package/dist/engine/shared/pdf-inspect.d.ts +24 -0
  30. package/dist/engine/shared/synthesis.d.ts +155 -0
  31. package/dist/engine-wa7y9av9.js +41 -0
  32. package/dist/engine-z4p9sr4h.js +40 -0
  33. package/dist/gutterpress-agent-1ctgfz92.js +576 -0
  34. package/dist/gutterpress-viewer-cem7dmr5.js +2349 -0
  35. package/dist/{index-bynn850m.js → index-05y3dnxq.js} +3049 -4015
  36. package/dist/index-9tyq9kks.js +708 -0
  37. package/dist/{cli-yzrh708h.js → index-mdefp0y5.js} +1 -1
  38. package/dist/{index-wchtvxvw.js → index-v5mp7a6q.js} +14 -1
  39. package/dist/index-xxg4zfrg.js +1907 -0
  40. package/dist/index.d.ts +3 -1
  41. package/dist/index.js +24 -17
  42. package/dist/lib/asset-inline.d.ts +36 -0
  43. package/dist/lib/browser-pool.d.ts +18 -0
  44. package/dist/lib/build-error.d.ts +1 -1
  45. package/dist/lib/build-preflight.d.ts +35 -2
  46. package/dist/lib/build-runner.d.ts +45 -17
  47. package/dist/lib/build-staging.d.ts +9 -55
  48. package/dist/lib/cli-args.d.ts +8 -0
  49. package/dist/lib/desktop.d.ts +2 -2
  50. package/dist/lib/embedded-assets.d.ts +1 -1
  51. package/dist/lib/engine.d.ts +33 -0
  52. package/dist/lib/ghostscript.d.ts +46 -1
  53. package/dist/lib/markdown/assemble.d.ts +18 -10
  54. package/dist/lib/markdown/gp-pin-scope.d.ts +1 -0
  55. package/dist/lib/markdown/gutterpress-css.d.ts +125 -0
  56. package/dist/lib/markdown/images.d.ts +26 -0
  57. package/dist/lib/markdown/index.d.ts +4 -2
  58. package/dist/lib/markdown/inline-source.d.ts +10 -0
  59. package/dist/lib/markdown/markers.d.ts +32 -0
  60. package/dist/lib/markdown/renderer.d.ts +8 -3
  61. package/dist/lib/markdown/source-range.d.ts +61 -0
  62. package/dist/lib/missing-asset-placeholder.d.ts +52 -0
  63. package/dist/lib/presets.d.ts +1 -1
  64. package/dist/lib/printsafe.d.ts +2 -3
  65. package/dist/lib/remote-auth/converge-merge.d.ts +48 -0
  66. package/dist/lib/remote-auth/image-clash.d.ts +17 -0
  67. package/dist/lib/remote-auth/recovery/classify.d.ts +55 -67
  68. package/dist/lib/remote-auth/recovery/inspect.d.ts +14 -13
  69. package/dist/lib/remote-auth/recovery/locks.d.ts +20 -0
  70. package/dist/lib/remote-auth/recovery/repair.d.ts +29 -0
  71. package/dist/lib/remote-auth/recovery/types.d.ts +8 -204
  72. package/dist/lib/remote-auth/sync-messages.d.ts +2 -3
  73. package/dist/lib/remote-auth/sync-types.d.ts +35 -63
  74. package/dist/lib/remote-auth/sync.d.ts +11 -18
  75. package/dist/lib/remote-auth/transport.d.ts +14 -7
  76. package/dist/lib/theme-import.d.ts +4 -5
  77. package/dist/{lint-96j9hrj4.js → lint-xjwm5ep8.js} +10 -7
  78. package/dist/{manifest.schema-z61rzw44.json → manifest.schema-zxgxnbg7.json} +21 -0
  79. package/dist/{new-8p38wavc.js → new-kwdwpf0j.js} +12 -8
  80. package/dist/{plugin-ees6nhkc.js → plugin-rg4tnn96.js} +10 -7
  81. package/dist/{preflight-1q6c2edh.js → preflight-3127y25z.js} +10 -7
  82. package/dist/preview/file-watcher.d.ts +13 -17
  83. package/dist/preview/lifecycle.d.ts +1 -1
  84. package/dist/{pagedjs-bridge-vn4hk9fx.js → preview-bridge-fz7vpk8m.js} +8 -0
  85. package/dist/preview-interface-435cczt5.js +1059 -0
  86. package/dist/{preview-y5a2zen1.js → preview-ncgfhqmw.js} +16 -9
  87. package/dist/preview-shell-c5mfa3q0.js +346 -0
  88. package/dist/{project-source-p0gn1wd5.js → project-source-ekcyp63q.js} +1 -1
  89. package/dist/{publish-rm9yb3wh.js → publish-pr0rwh6p.js} +10 -7
  90. package/dist/render.d.ts +3 -4
  91. package/dist/render.js +728 -64
  92. package/dist/{repair-zgq7q2g6.js → repair-8270smfw.js} +45 -79
  93. package/dist/schema/manifest.types.d.ts +38 -0
  94. package/dist/{source-provider-c1rjm2c0.js → source-provider-3tcj6qg2.js} +2 -2
  95. package/dist/source-provider-vanafrt9.js +40 -0
  96. package/dist/{theme-zz2ktzqs.css → theme-h5recz6c.css} +8 -7
  97. package/dist/{theme-570zmh2t.css → theme-j2bagrfx.css} +8 -7
  98. package/dist/{theme-nya4nqh6.css → theme-nn6d53zy.css} +8 -7
  99. package/dist/types.d.ts +7 -0
  100. package/dist/{validate-nr0xa6sa.js → validate-54e17rae.js} +10 -7
  101. package/package.json +6 -6
  102. package/dist/cli-yja077f6.js +0 -92
  103. package/dist/git-http-yrb4ag6z.js +0 -17
  104. package/dist/index-yja077f6.js +0 -92
  105. package/dist/lib/markdown/markdown-it-paged.d.ts +0 -30
  106. package/dist/lib/pagedjs-marker.d.ts +0 -42
  107. package/dist/lib/pagedjs.d.ts +0 -26
  108. package/dist/lib/pagination.d.ts +0 -149
  109. package/dist/lib/remote-auth/conflict-resolution.d.ts +0 -29
  110. package/dist/lib/remote-auth/recovery/abort-interrupted-operation.d.ts +0 -103
  111. package/dist/lib/remote-auth/recovery/backup.d.ts +0 -113
  112. package/dist/lib/remote-auth/recovery/context.d.ts +0 -47
  113. package/dist/lib/remote-auth/recovery/dispatch.d.ts +0 -28
  114. package/dist/lib/remote-auth/recovery/failsafe.d.ts +0 -33
  115. package/dist/lib/remote-auth/recovery/manual-guidance.d.ts +0 -28
  116. package/dist/lib/remote-auth/recovery/outcome-mapping.d.ts +0 -59
  117. package/dist/lib/remote-auth/recovery/policy.d.ts +0 -47
  118. package/dist/lib/remote-auth/recovery/recover-auth.d.ts +0 -42
  119. package/dist/lib/remote-auth/recovery/recover-binary-conflict.d.ts +0 -37
  120. package/dist/lib/remote-auth/recovery/recover-corrupt-index.d.ts +0 -40
  121. package/dist/lib/remote-auth/recovery/recover-detached-head.d.ts +0 -70
  122. package/dist/lib/remote-auth/recovery/recover-interrupted-cherry-pick.d.ts +0 -23
  123. package/dist/lib/remote-auth/recovery/recover-interrupted-merge.d.ts +0 -28
  124. package/dist/lib/remote-auth/recovery/recover-interrupted-rebase.d.ts +0 -39
  125. package/dist/lib/remote-auth/recovery/recover-merge-conflict.d.ts +0 -34
  126. package/dist/lib/remote-auth/recovery/recover-missing-git-dir.d.ts +0 -37
  127. package/dist/lib/remote-auth/recovery/recover-missing-objects.d.ts +0 -56
  128. package/dist/lib/remote-auth/recovery/recover-network.d.ts +0 -34
  129. package/dist/lib/remote-auth/recovery/recover-non-fast-forward.d.ts +0 -27
  130. package/dist/lib/remote-auth/recovery/recover-stale-lock.d.ts +0 -68
  131. package/dist/lib/remote-auth/recovery/recover-unrelated-histories.d.ts +0 -44
  132. package/dist/lib/remote-auth/recovery/recover-wrong-remote.d.ts +0 -35
  133. package/dist/lib/remote-auth/resolution-plan.d.ts +0 -64
  134. package/dist/paged.polyfill-n95pbxfn.js +0 -33288
  135. package/dist/pagedjs-interface-qxvzgwd7.js +0 -557
  136. package/dist/preview-shell-6dqexx1m.js +0 -581
  137. /package/dist/assets/{preview/scripts/pagedjs-bridge.d.ts → engine/gutterpress-agent.d.ts} +0 -0
  138. /package/dist/assets/{preview/scripts/pagedjs-interface.d.ts → engine/gutterpress-viewer.d.ts} +0 -0
@@ -0,0 +1,1059 @@
1
+ // Interface adapter: exposes window.previewAPI for the parent toolbar.
2
+ //
3
+ // The Gutterpress engine viewer paginates into .gp-sheet elements (one per
4
+ // page, `dataset.page` = 1-based book page) and exposes
5
+ // window.Gutterpress.pageOf(el) (0-based); it fires 'gp:layout' when its
6
+ // pagination completes.
7
+ //
8
+ // The block-overlay fragment-grouping machinery below (blocksMatchingRange)
9
+ // groups by `{chapter, range}` (data-source-range) — the native viewer never
10
+ // clones an element across pages (an element that visually spans pages is
11
+ // still ONE element), so nativeRectsFor resolves rects straight off the
12
+ // single matching element via getClientRects() + pageOf().
13
+
14
+ (function () {
15
+ 'use strict';
16
+
17
+ var pages = [];
18
+ var currentPage = 1;
19
+ var debugMode = false;
20
+ var currentViewMode = 'two-column';
21
+ var ignoreScrollUntil = 0;
22
+ var lastSourceLine = -1;
23
+ var lastSourceChapter = null;
24
+ // Find-in-book state (see previewAPI.find below).
25
+ var findQuery = '';
26
+ var findTotal = 0;
27
+
28
+ function refreshPages() {
29
+ pages = Array.from(document.querySelectorAll('.gp-sheet')).sort(function (a, b) {
30
+ return (parseInt(a.dataset.page, 10) || 0) - (parseInt(b.dataset.page, 10) || 0);
31
+ });
32
+ return pages;
33
+ }
34
+
35
+ function clampPage(n) {
36
+ if (pages.length === 0) return 1;
37
+ var page = Number(n);
38
+ if (!Number.isFinite(page)) page = 1;
39
+ return Math.max(1, Math.min(Math.round(page), pages.length));
40
+ }
41
+
42
+ // The native viewer lays one CHAPTER per row (`.gp-run`), each row as
43
+ // wide as that chapter needs — rows stack vertically, but a long chapter's
44
+ // pages run off horizontally within its own row (viewer.css: `.gp-sheet`
45
+ // is `left`-positioned within the row, every sheet in a row shares `top`).
46
+ // A top-only scan can't tell two sheets in the same row apart — every one
47
+ // of them has the same `top`, so it always resolves to the LAST sheet of
48
+ // whichever row is vertically in view, ignoring scrollLeft entirely
49
+ // (measured: goToPage(18/30/34) in a 34pp book all landed on page 14 —
50
+ // row 1's last page — regardless of horizontal scroll position). Pick the
51
+ // sheet with the GREATEST visible overlap area with the viewport — a fixed
52
+ // reference-point distance was tried first but broke at the very end of a
53
+ // row: the browser clamps scrollIntoView({inline:'start'}) once there's no
54
+ // more row content to scroll past, so the last page of a short final row
55
+ // can land mid-viewport rather than flush against any fixed point
56
+ // (measured: goToPage(34) on a 34pp book left page 34 at ~40% visible
57
+ // width from the left edge, so a left-edge reference point missed it and
58
+ // matched page 33 instead, which still touched the reference point). Falls
59
+ // back to nearest-by-distance when nothing overlaps at all (e.g. mid-scroll
60
+ // between rows, or every sheet clipped by a shorter viewport than a page).
61
+ function detectVisiblePage() {
62
+ if (pages.length === 0) return 1;
63
+ var vw = window.innerWidth;
64
+ var vh = window.innerHeight;
65
+ var best = 0;
66
+ var bestArea = 0;
67
+ var nearest = 0;
68
+ var nearestScore = Infinity;
69
+ var refX = vw / 3;
70
+ var refY = vh / 3;
71
+ for (var i = 0; i < pages.length; i++) {
72
+ var r = pages[i].getBoundingClientRect();
73
+ var w = Math.min(r.right, vw) - Math.max(r.left, 0);
74
+ var h = Math.min(r.bottom, vh) - Math.max(r.top, 0);
75
+ var area = w > 0 && h > 0 ? w * h : 0;
76
+ if (area > bestArea) { bestArea = area; best = i; }
77
+ var dx = refX < r.left ? r.left - refX : (refX > r.right ? refX - r.right : 0);
78
+ var dy = refY < r.top ? r.top - refY : (refY > r.bottom ? refY - r.bottom : 0);
79
+ var score = dx * dx + dy * dy;
80
+ if (score < nearestScore) { nearestScore = score; nearest = i; }
81
+ }
82
+ return (bestArea > 0 ? best : nearest) + 1;
83
+ }
84
+
85
+ function scrollToCurrentPage() {
86
+ if (pages.length === 0) return;
87
+ var page = clampPage(currentPage);
88
+ currentPage = page;
89
+ ignoreScrollUntil = Date.now() + 300;
90
+ // Native's rows can be wider than the viewport (a long chapter scrolls
91
+ // horizontally within its own row) — align the target sheet's left edge
92
+ // to the viewport's left edge (matching detectVisiblePage's `refX`).
93
+ pages[page - 1].scrollIntoView({
94
+ behavior: 'instant',
95
+ block: 'start',
96
+ inline: 'start'
97
+ });
98
+ recordVisibleSource();
99
+ }
100
+
101
+ function pageStep(mode) {
102
+ return (mode || currentViewMode) === 'single' ? 1 : 2;
103
+ }
104
+
105
+ // ── Source-mapping helpers (ADR 0005) ──────────────────────────────────────
106
+ // Every block element carries data-source-line (markdown-it-source-map). These
107
+ // map rendered DOM <-> markdown source line and rendered DOM <-> page. The
108
+ // native fragmenter MOVES elements into strips, it does not clone them, so
109
+ // these attributes survive intact.
110
+ function lineOf(el) {
111
+ if (!el || !el.getAttribute) return null;
112
+ var n = parseInt(el.getAttribute('data-source-line'), 10);
113
+ return Number.isFinite(n) ? n : null;
114
+ }
115
+
116
+ function pageIndexOf(el) {
117
+ if (!el) return 0;
118
+ if (!window.Gutterpress || typeof window.Gutterpress.pageOf !== 'function') return 0;
119
+ var native = window.Gutterpress.pageOf(el);
120
+ return native >= 0 ? native + 1 : 0;
121
+ }
122
+
123
+ function sourcedBlocks() {
124
+ return Array.from(document.querySelectorAll('[data-source-line]'));
125
+ }
126
+
127
+ // data-source-line resets PER FILE, so a line number is only unambiguous when
128
+ // paired with its chapter (data-chapter-src — the source filename). These two
129
+ // helpers scope line lookups to a chapter so editor<->preview sync maps to the
130
+ // right file in a multi-chapter book.
131
+ function chapterOf(el) {
132
+ var c = el && el.closest ? el.closest('[data-chapter-src]') : null;
133
+ return c ? c.getAttribute('data-chapter-src') : null;
134
+ }
135
+
136
+ function blocksInChapter(chapter) {
137
+ if (!chapter) return sourcedBlocks();
138
+ return sourcedBlocks().filter(function (block) {
139
+ return chapterOf(block) === chapter;
140
+ });
141
+ }
142
+
143
+ function rectsOf(el) {
144
+ var rects = el && el.getClientRects ? Array.from(el.getClientRects()) : [];
145
+ return rects.length ? rects : [el.getBoundingClientRect()];
146
+ }
147
+
148
+ function visibleRectOf(el, pageRect) {
149
+ var vw = window.innerWidth;
150
+ var vh = window.innerHeight;
151
+ var ref = 4;
152
+ var rects = rectsOf(el);
153
+ var best = null;
154
+ var bestIndex = -1;
155
+ var bestDistance = Infinity;
156
+ var bestArea = -1;
157
+ for (var i = 0; i < rects.length; i++) {
158
+ var r = rects[i];
159
+ var width = Math.min(r.right, vw, pageRect ? pageRect.right : vw) -
160
+ Math.max(r.left, 0, pageRect ? pageRect.left : 0);
161
+ var height = Math.min(r.bottom, vh, pageRect ? pageRect.bottom : vh) -
162
+ Math.max(r.top, 0, pageRect ? pageRect.top : 0);
163
+ if (width <= 0 || height <= 0) continue;
164
+ var distance = r.top <= ref && r.bottom > ref ? 0 : Math.abs(r.top - ref);
165
+ var area = width * height;
166
+ if (distance < bestDistance || (distance === bestDistance && area > bestArea)) {
167
+ best = r;
168
+ bestIndex = i;
169
+ bestDistance = distance;
170
+ bestArea = area;
171
+ }
172
+ }
173
+ return best ? { rect: best, index: bestIndex, distance: bestDistance, area: bestArea } : null;
174
+ }
175
+
176
+ function rectOnPage(el, pageRect) {
177
+ if (!pageRect) return null;
178
+ var rects = rectsOf(el);
179
+ for (var i = 0; i < rects.length; i++) {
180
+ var r = rects[i];
181
+ var width = Math.min(r.right, pageRect.right) - Math.max(r.left, pageRect.left);
182
+ var height = Math.min(r.bottom, pageRect.bottom) - Math.max(r.top, pageRect.top);
183
+ if (width > 0 && height > 0) return r;
184
+ }
185
+ return null;
186
+ }
187
+
188
+ // Find the source fragment nearest the viewport's top edge. getClientRects()
189
+ // matters here: one source block can fragment across several printed pages.
190
+ function topVisibleSource() {
191
+ var blocks = sourcedBlocks();
192
+ if (blocks.length === 0) return null;
193
+ if (pages.length === 0) refreshPages();
194
+ var page = pages[detectVisiblePage() - 1];
195
+ var pageRect = page ? page.getBoundingClientRect() : null;
196
+ var best = null, bestRect = null, bestIndex = -1, bestDistance = Infinity, bestArea = -1;
197
+ for (var i = 0; i < blocks.length; i++) {
198
+ var visible = visibleRectOf(blocks[i], pageRect);
199
+ if (!visible) continue;
200
+ if (visible.distance < bestDistance || (visible.distance === bestDistance && visible.area > bestArea)) {
201
+ best = blocks[i];
202
+ bestRect = visible.rect;
203
+ bestIndex = visible.index;
204
+ bestDistance = visible.distance;
205
+ bestArea = visible.area;
206
+ }
207
+ }
208
+ return best ? { el: best, rect: bestRect, index: bestIndex, pageRect: pageRect } : null;
209
+ }
210
+
211
+ function topVisibleSourceEl() {
212
+ var source = topVisibleSource();
213
+ return source ? source.el : null;
214
+ }
215
+
216
+ // {el, line} of the viewport-top source position, with the line interpolated
217
+ // within the straddling block by the viewport top's fractional distance to the
218
+ // next annotated block (source-map only annotates top-level blocks, so the
219
+ // block's start line alone can be tens of lines above the visible content).
220
+ function visibleSourcePosition() {
221
+ var source = topVisibleSource();
222
+ if (!source) return null;
223
+ var el = source.el;
224
+ var line = lineOf(el);
225
+ if (line == null) return { el: el, rect: source.rect, index: source.index, line: null };
226
+ var ref = 4; // same reference line as topVisibleSourceEl
227
+ var top = source.rect.top;
228
+ if (top < ref) {
229
+ // Find the next annotated sibling-in-flow (document order, higher line —
230
+ // explicitly constrained to this chapter because source lines restart per
231
+ // file and a following chapter may begin at any line after front matter).
232
+ var blocks = sourcedBlocks();
233
+ var idx = blocks.indexOf(el);
234
+ var chapter = chapterOf(el);
235
+ for (var i = idx + 1; i < blocks.length; i++) {
236
+ if (chapterOf(blocks[i]) !== chapter) break;
237
+ var nl = lineOf(blocks[i]);
238
+ if (nl == null) continue;
239
+ if (nl <= line) break;
240
+ var nextRect = source.pageRect ? rectOnPage(blocks[i], source.pageRect) : null;
241
+ if (!nextRect) break;
242
+ var nextTop = nextRect.top;
243
+ if (nextTop > top) {
244
+ var f = Math.max(0, Math.min(1, (ref - top) / (nextTop - top)));
245
+ line = line + Math.round(f * (nl - line));
246
+ }
247
+ break;
248
+ }
249
+ }
250
+ return { el: el, rect: source.rect, index: source.index, line: line };
251
+ }
252
+
253
+ function recordVisibleSource() {
254
+ var pos = visibleSourcePosition();
255
+ lastSourceLine = pos && pos.line != null ? pos.line : -1;
256
+ lastSourceChapter = pos ? chapterOf(pos.el) : null;
257
+ }
258
+
259
+ function preserveViewport(change) {
260
+ refreshPages();
261
+ var position = visibleSourcePosition();
262
+ var anchor = position ? {
263
+ el: position.el,
264
+ index: position.index,
265
+ top: position.rect.top,
266
+ left: position.rect.left
267
+ } : null;
268
+ change();
269
+ refreshPages();
270
+ if (!anchor) {
271
+ scrollToCurrentPage();
272
+ return;
273
+ }
274
+ ignoreScrollUntil = Date.now() + 300;
275
+ var rects = rectsOf(anchor.el);
276
+ var currentRect = rects[anchor.index] || anchor.el.getBoundingClientRect();
277
+ var delta = currentRect.top - anchor.top;
278
+ var deltaX = currentRect.left - anchor.left;
279
+ if (delta || deltaX) {
280
+ window.scrollBy({ top: delta, left: deltaX, behavior: 'instant' });
281
+ }
282
+ currentPage = detectVisiblePage();
283
+ recordVisibleSource();
284
+ }
285
+
286
+ // Resolve a {line, chapter?} target to its enclosing annotated block PLUS the
287
+ // following annotated block, so callers can interpolate within long blocks
288
+ // (markdown-it-source-map only annotates top-level *_open tokens, so a target
289
+ // line deep inside a list/table/paragraph would otherwise snap to the block's
290
+ // start line — measured 35-line snap-backs on real chapters).
291
+ function resolveLinePosition(target) {
292
+ var line = Number(target.line);
293
+ var blocks = blocksInChapter(target.chapter);
294
+ var best = null, bestLine = -Infinity, next = null, nextLine = Infinity;
295
+ for (var i = 0; i < blocks.length; i++) {
296
+ var l = lineOf(blocks[i]);
297
+ if (l == null) continue;
298
+ if (l <= line && l > bestLine) { bestLine = l; best = blocks[i]; }
299
+ else if (l > line && l < nextLine) { nextLine = l; next = blocks[i]; }
300
+ }
301
+ if (!best) return blocks[0] ? { el: blocks[0], line: lineOf(blocks[0]), nextEl: null, nextLine: null } : null;
302
+ return { el: best, line: bestLine, nextEl: next, nextLine: next ? nextLine : null };
303
+ }
304
+
305
+ // Resolve a scrollTo/highlight target ({line}|{id}|{selector}|{page} or a bare
306
+ // line number) to a DOM element.
307
+ function resolveTarget(target) {
308
+ if (target == null) return null;
309
+ if (typeof target === 'number') target = { line: target };
310
+ if (target.selector) { try { return document.querySelector(target.selector); } catch (_e) { return null; } }
311
+ if (target.id) return document.getElementById(target.id);
312
+ if (target.page != null) { refreshPages(); return pages[clampPage(target.page) - 1] || null; }
313
+ if (target.line != null) {
314
+ var pos = resolveLinePosition(target);
315
+ return pos ? pos.el : null;
316
+ }
317
+ return null;
318
+ }
319
+
320
+ // ── Context-menu target resolution (protocol v4) ────────────────────────────
321
+ // docs/inline-editing-plan.md §3.1 / ADR 0009. getContextTargetAt() is the
322
+ // single resolution routine shared by the public previewAPI member and both
323
+ // event listeners below (contextmenu + keyboard) — see that doc for the
324
+ // exact kind-precedence contract implemented here.
325
+ var LAYOUT_MARKER_CLASSES = ['chapter', 'spread', 'page', 'section', 'gp-page-break', 'gp-column-break'];
326
+
327
+ function elementAtPoint(x, y) {
328
+ try {
329
+ if (typeof document.elementFromPoint === 'function') return document.elementFromPoint(x, y);
330
+ } catch (_e) { /* unsupported host — degrade to null */ }
331
+ return null;
332
+ }
333
+
334
+ function elementOf(node) {
335
+ if (!node) return null;
336
+ return node.nodeType === 1 ? node : (node.parentElement || null);
337
+ }
338
+
339
+ // Parse a `data-source-range="<start>:<end>"` value into a [start, end)
340
+ // pair — token.map's own 0-based half-open convention, verbatim.
341
+ function sourceRangeOf(el) {
342
+ if (!el || !el.getAttribute) return null;
343
+ var raw = el.getAttribute('data-source-range');
344
+ if (!raw) return null;
345
+ var sep = raw.indexOf(':');
346
+ if (sep < 0) return null;
347
+ var start = parseInt(raw.slice(0, sep), 10);
348
+ var end = parseInt(raw.slice(sep + 1), 10);
349
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
350
+ return [start, end];
351
+ }
352
+
353
+ // Fence gotcha (§2.6): markdown-it's default fence renderer puts token
354
+ // attrs on the inner <code>, never the <pre> wrapper — a click on the code
355
+ // block's padding hits <pre>'s box, which never carries data-source-range.
356
+ // Prefer an annotated <code> child over climbing to a coarser ancestor.
357
+ function fenceCodeChild(el) {
358
+ if (!el || !el.tagName || el.tagName.toLowerCase() !== 'pre' || !el.children) return null;
359
+ for (var i = 0; i < el.children.length; i++) {
360
+ var c = el.children[i];
361
+ if (c.tagName && c.tagName.toLowerCase() === 'code' && c.getAttribute && c.getAttribute('data-source-range')) {
362
+ return c;
363
+ }
364
+ }
365
+ return null;
366
+ }
367
+
368
+ // Innermost [data-source-range] element at/around `el` (self-or-ancestor),
369
+ // with the fence <pre>-hits-<code> special case above taking priority.
370
+ function resolveAnnotatedBlock(el) {
371
+ if (!el) return null;
372
+ var fenceCode = fenceCodeChild(el);
373
+ if (fenceCode) return fenceCode;
374
+ return el.closest ? el.closest('[data-source-range]') : null;
375
+ }
376
+
377
+ function isMarkerBlock(el) {
378
+ if (!el || !el.classList) return false;
379
+ for (var i = 0; i < LAYOUT_MARKER_CLASSES.length; i++) {
380
+ if (el.classList.contains(LAYOUT_MARKER_CLASSES[i])) return true;
381
+ }
382
+ return false;
383
+ }
384
+
385
+ // The native fragmenter MOVES elements into strips rather than cloning them,
386
+ // so there is no split-fragment marker to detect here — a block that visually
387
+ // spans pages is still exactly one element.
388
+ function isSplitFragment() {
389
+ return false;
390
+ }
391
+
392
+ // Plain, JSON-cloneable rect — the payload crosses two postMessage
393
+ // boundaries, so no DOMRect instances (§3.5).
394
+ function plainRect(el) {
395
+ if (!el || !el.getBoundingClientRect) return null;
396
+ var r = el.getBoundingClientRect();
397
+ return { top: r.top, left: r.left, width: r.width, height: r.height };
398
+ }
399
+
400
+ // Non-collapsed selection info, or null. NEVER Range.toString() — a
401
+ // cross-page range's raw text is polluted with inter-page structural
402
+ // whitespace and can include every intervening page's content (§3.5);
403
+ // selection.toString() is the display-only source of truth here.
404
+ function selectionInfo() {
405
+ var sel = typeof window.getSelection === 'function' ? window.getSelection() : null;
406
+ if (!sel || sel.isCollapsed) return null;
407
+ var anchorBlock = resolveAnnotatedBlock(elementOf(sel.anchorNode));
408
+ var focusBlock = resolveAnnotatedBlock(elementOf(sel.focusNode));
409
+ var withinSingleBlock = !!(anchorBlock && focusBlock && anchorBlock === focusBlock);
410
+ return {
411
+ text: sel.toString(),
412
+ withinSingleBlock: withinSingleBlock,
413
+ range: withinSingleBlock ? sourceRangeOf(anchorBlock) : null,
414
+ chapter: withinSingleBlock ? chapterOf(anchorBlock) : null
415
+ };
416
+ }
417
+
418
+ // Shared resolution: builds the full getContextTargetAt() payload for a
419
+ // point element. `selection`/`image`/`link` are populated whenever
420
+ // applicable REGARDLESS of which `kind` wins, so the menu can offer
421
+ // secondary items (§3.1).
422
+ //
423
+ // kind precedence (decided): selection -> image -> link -> marker -> block
424
+ // -> none.
425
+ function buildContextTarget(pointEl) {
426
+ var selection = selectionInfo();
427
+ var imageEl = pointEl && pointEl.closest ? pointEl.closest('img') : null;
428
+ var sourceOf = function (el) {
429
+ if (!el || !el.hasAttribute || !el.hasAttribute('data-gp-source-token')) return null;
430
+ var token = el.getAttribute('data-gp-source-token');
431
+ var rawOccurrence = el.getAttribute('data-gp-source-occurrence');
432
+ if (!token || !rawOccurrence) return null;
433
+ for (var i = 0; i < rawOccurrence.length; i++) {
434
+ if (rawOccurrence[i] < '0' || rawOccurrence[i] > '9') return null;
435
+ }
436
+ var occurrence = Number(rawOccurrence);
437
+ if (!Number.isSafeInteger(occurrence)) return null;
438
+ return {
439
+ token: token,
440
+ occurrence: occurrence
441
+ };
442
+ };
443
+ var image = imageEl ? {
444
+ src: imageEl.getAttribute('src'),
445
+ alt: imageEl.getAttribute('alt'),
446
+ source: sourceOf(imageEl)
447
+ } : null;
448
+ var linkEl = pointEl && pointEl.closest ? pointEl.closest('a') : null;
449
+ var link = linkEl ? {
450
+ href: linkEl.getAttribute('href'),
451
+ text: (linkEl.textContent || '').trim(),
452
+ source: sourceOf(linkEl)
453
+ } : null;
454
+
455
+ var block = resolveAnnotatedBlock(pointEl);
456
+
457
+ var kind;
458
+ if (selection) kind = 'selection';
459
+ else if (image) kind = 'image';
460
+ else if (link) kind = 'link';
461
+ else if (block && isMarkerBlock(block)) kind = 'marker';
462
+ else if (block) kind = 'block';
463
+ else kind = 'none';
464
+
465
+ return {
466
+ kind: kind,
467
+ chapter: chapterOf(pointEl),
468
+ range: block ? sourceRangeOf(block) : null,
469
+ blockTag: block && block.tagName ? block.tagName.toLowerCase() : null,
470
+ split: isSplitFragment(block),
471
+ rect: plainRect(block || pointEl),
472
+ image: image,
473
+ link: link,
474
+ selection: selection
475
+ };
476
+ }
477
+
478
+ // The anchor point for a keyboard-invoked menu: the current selection's
479
+ // focus position if non-collapsed, else the block at the top of the
480
+ // viewport (topVisibleSourceEl(), which already exists for scroll sync).
481
+ // Keyboard events targeted at a focused element in a cross-origin iframe
482
+ // never reach the parent SPA — this resolution MUST live here, not
483
+ // SPA-side.
484
+ function keyboardAnchorPoint() {
485
+ var sel = typeof window.getSelection === 'function' ? window.getSelection() : null;
486
+ var el = sel && !sel.isCollapsed ? elementOf(sel.focusNode) : null;
487
+ if (!el) el = topVisibleSourceEl();
488
+ var rect = el ? plainRect(el) : null;
489
+ if (!rect) return { x: 0, y: 0 };
490
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
491
+ }
492
+
493
+ // ── Block-overlay geometry + masking (protocol v6) ──────────────────────────
494
+ // docs/inline-editing-plan.md §5.3 / ADR 0009. A block split across pages
495
+ // exists as MULTIPLE DOM fragments that duplicate every data attribute
496
+ // (§3.5's split-fragment gotcha applies here too) — `data-source-range` is
497
+ // duplicated onto every fragment identically, so `{chapter, range}` groups
498
+ // them (protocol v5 dropped `data-ref` from the wire contract entirely: the
499
+ // native viewer never mints one at all — it never clones, so there is
500
+ // nothing to give a shared identity to — and a source range already
501
+ // uniquely identifies one block).
502
+
503
+ function rangedBlocks() {
504
+ return Array.from(document.querySelectorAll('[data-source-range]'));
505
+ }
506
+
507
+ function rangedBlocksInChapter(chapter) {
508
+ if (!chapter) return rangedBlocks();
509
+ return rangedBlocks().filter(function (el) { return chapterOf(el) === chapter; });
510
+ }
511
+
512
+ function blocksMatchingRange(chapter, range) {
513
+ if (!range) return [];
514
+ return rangedBlocksInChapter(chapter).filter(function (el) {
515
+ var r = sourceRangeOf(el);
516
+ return r && r[0] === range[0] && r[1] === range[1];
517
+ });
518
+ }
519
+
520
+ // getRectsFor(): the viewer never clones, so a spec resolves to AT MOST ONE
521
+ // element. Its fragment rects come straight from getClientRects() — a block
522
+ // can still visually span pages if the browser's own multicol layout breaks
523
+ // it there. Resolve each rect against the sheet it actually intersects;
524
+ // pageOf(el) can only identify the element's starting fragmentainer.
525
+ function nativeRectsFor(spec) {
526
+ spec = spec || {};
527
+ var el = blocksMatchingRange(spec.chapter, spec.range)[0] || null;
528
+ if (!el) return { rects: [] };
529
+ var geometryEl = el.tagName && el.tagName.toLowerCase() === 'code' &&
530
+ el.parentElement && el.parentElement.tagName.toLowerCase() === 'pre'
531
+ ? el.parentElement
532
+ : el;
533
+ if (pages.length === 0) refreshPages();
534
+ var fallbackPage = pageIndexOf(el);
535
+ var raw = geometryEl.getClientRects ? Array.from(geometryEl.getClientRects()) : [];
536
+ if (!raw.length) {
537
+ var r0 = plainRect(geometryEl);
538
+ raw = r0 ? [r0] : [];
539
+ }
540
+ var rects = raw.map(function (r) {
541
+ var page = fallbackPage;
542
+ var bestArea = 0;
543
+ for (var i = 0; i < pages.length; i++) {
544
+ var sheetRect = pages[i].getBoundingClientRect();
545
+ var width = Math.min(r.right, sheetRect.right) - Math.max(r.left, sheetRect.left);
546
+ var height = Math.min(r.bottom, sheetRect.bottom) - Math.max(r.top, sheetRect.top);
547
+ var area = width > 0 && height > 0 ? width * height : 0;
548
+ if (area > bestArea) {
549
+ bestArea = area;
550
+ page = i + 1;
551
+ }
552
+ }
553
+ return { top: r.top, left: r.left, width: r.width, height: r.height, page: page };
554
+ });
555
+ return { rects: rects };
556
+ }
557
+
558
+ var api = {
559
+ getTotalPages: function () { refreshPages(); return pages.length; },
560
+ getCurrentPage: function () { return currentPage; },
561
+ goToPage: function (n) {
562
+ refreshPages();
563
+ currentPage = clampPage(n);
564
+ scrollToCurrentPage();
565
+ return api.notifyPageChange();
566
+ },
567
+ getPageDimensions: function () {
568
+ refreshPages();
569
+ var page = pages[0] || null;
570
+ if (!page) return null;
571
+ var width = page.offsetWidth;
572
+ if (currentViewMode !== 'single') {
573
+ var runs = Array.from(document.querySelectorAll('.gp-run'));
574
+ for (var i = 0; i < runs.length; i++) {
575
+ var gap = parseFloat(getComputedStyle(runs[i]).getPropertyValue('--gp-sheet-gap')) || 0;
576
+ var runSheets = Array.from(runs[i].querySelectorAll('.gp-sheet'));
577
+ if (runSheets.length) {
578
+ var left = 0;
579
+ var right = page.offsetWidth;
580
+ for (var j = 0; j < runSheets.length; j++) {
581
+ left = Math.min(left, runSheets[j].offsetLeft || 0);
582
+ right = Math.max(right, (runSheets[j].offsetLeft || 0) + (runSheets[j].offsetWidth || 0));
583
+ }
584
+ width = Math.max(width, right - left);
585
+ } else {
586
+ width = Math.max(width, Math.max(0, (runs[i].offsetWidth || 0) - gap));
587
+ }
588
+ }
589
+ }
590
+ return {
591
+ width: width,
592
+ height: page.offsetHeight,
593
+ // The host iframe's clientWidth includes this document's vertical
594
+ // scrollbar. Report the actual layout viewport so fit-width can center
595
+ // within the pixels available to the book.
596
+ viewportWidth: document.documentElement.clientWidth
597
+ };
598
+ },
599
+ firstPage: function () { return api.goToPage(1); },
600
+ prevPage: function (mode) { return api.goToPage(currentPage - pageStep(mode)); },
601
+ nextPage: function (mode) { return api.goToPage(currentPage + pageStep(mode)); },
602
+ lastPage: function () { refreshPages(); return api.goToPage(pages.length); },
603
+ setViewMode: function (mode, silent) {
604
+ preserveViewport(function () {
605
+ currentViewMode = mode || 'two-column';
606
+ document.body.classList.remove('view-single', 'view-spread', 'view-two-column');
607
+ if (mode) document.body.classList.add('view-' + mode);
608
+ // `pageStep()` above makes next/prevPage step by 1 or 2 book pages.
609
+ // `setSpread()` does the real relayout: `column-wrap: wrap` wraps each
610
+ // chapter's columns into a 2-column grid so content, not just sheet
611
+ // chrome, actually moves into pairs — see fragment.ts's
612
+ // `applySpreadMode`. No-ops to single-row on a browser without the
613
+ // capability (Firefox/Safari as of this writing).
614
+ if (window.Gutterpress && typeof window.Gutterpress.setSpread === 'function') {
615
+ window.Gutterpress.setSpread(currentViewMode !== 'single');
616
+ }
617
+ });
618
+ if (!silent) return api.notifyPageChange();
619
+ },
620
+ setZoom: function (z, silent) {
621
+ preserveViewport(function () {
622
+ // Embedded previews have one zoom owner. Clear the standalone
623
+ // viewer's narrow-screen fit before applying the host scale.
624
+ document.body.style.removeProperty('--gutterpress-fit-zoom');
625
+ document.documentElement.style.setProperty('--gutterpress-zoom', z);
626
+ });
627
+ if (!silent) api.notifyPageChange();
628
+ },
629
+ toggleDebugMode: function () {
630
+ debugMode = !debugMode;
631
+ document.body.classList.toggle('debug', debugMode);
632
+ if (window.Gutterpress && window.Gutterpress.decoration) {
633
+ window.Gutterpress.decoration.setDesigner(debugMode);
634
+ }
635
+ return debugMode;
636
+ },
637
+ notifyPageChange: function () {
638
+ var detail = { currentPage: api.getCurrentPage(), totalPages: pages.length };
639
+ window.dispatchEvent(new CustomEvent('pageChanged', { detail: detail }));
640
+ return detail;
641
+ },
642
+ notifyRenderingComplete: function () {
643
+ window.dispatchEvent(new CustomEvent('renderingComplete', {
644
+ detail: { totalPages: pages.length }
645
+ }));
646
+ },
647
+ // Re-read the page list and recompute the current page from the scroll
648
+ // position, then notify. Hosts may call this after replacing paginated DOM
649
+ // so cached page counters and scroll synchronization stay current.
650
+ refresh: function () {
651
+ refreshPages();
652
+ currentPage = detectVisiblePage();
653
+ recordVisibleSource();
654
+ return api.notifyPageChange();
655
+ },
656
+
657
+ // ── ADR 0005 generic primitives ─────────────────────────────────────────
658
+ // Bumped whenever a command/event is added so a hot-updated SPA can
659
+ // feature-detect against an older bundled lib.
660
+ // v6 (WORK PACKAGE B item 2): getRectsFor()/setEditMask() dropped the
661
+ // {ref} form entirely — {chapter, range} is the only target shape now
662
+ // (the native viewer never mints a ref — it never clones).
663
+ getProtocolVersion: function () { return 6; },
664
+
665
+ // Resolve the annotated element/selection at a viewport point (protocol
666
+ // v4). Pure read; see buildContextTarget() above for the full contract.
667
+ getContextTargetAt: function (spec) {
668
+ spec = spec || {};
669
+ return buildContextTarget(elementAtPoint(spec.x, spec.y));
670
+ },
671
+
672
+ // All fragment rects for one logical block (protocol v6, §5.3), targeted
673
+ // by {chapter, range}. Pure read; never mutates the DOM. Plain,
674
+ // JSON-cloneable objects only (§3.5) — no DOMRect instances.
675
+ getRectsFor: function (spec) {
676
+ return nativeRectsFor(spec);
677
+ },
678
+
679
+ // Toggle a masking class on EVERY fragment of a block ({chapter, range}
680
+ // match, protocol v6, §5.1/§5.3), plus a scroll lock on the book document
681
+ // element. Purely cosmetic and fully reversible — nothing here may touch
682
+ // anything layout-affecting; see the class definitions below and ADR
683
+ // 0009. `masked: false` always removes the lock class too, even if this
684
+ // particular range has zero live fragments (e.g. called defensively
685
+ // during teardown after a splice) — it is a document-level toggle, not
686
+ // scoped per-block, and there is at most one overlay open at a time.
687
+ setEditMask: function (spec) {
688
+ spec = spec || {};
689
+ var els = blocksMatchingRange(spec.chapter, spec.range);
690
+ for (var i = 0; i < els.length; i++) {
691
+ if (spec.masked) els[i].classList.add('gutterpress-edit-mask');
692
+ else els[i].classList.remove('gutterpress-edit-mask');
693
+ }
694
+ var root = document.documentElement;
695
+ if (root && root.classList) {
696
+ if (spec.masked) root.classList.add('gutterpress-edit-scroll-lock');
697
+ else root.classList.remove('gutterpress-edit-scroll-lock');
698
+ }
699
+ return { count: els.length };
700
+ },
701
+
702
+ // Publish any debounced reader movement before a host atomically replaces
703
+ // this frame. `silent` lets the shell relay the returned event itself so a
704
+ // queued postMessage from the outgoing frame cannot arrive after the swap.
705
+ flushScroll: function (silent) {
706
+ if (scrollTimer) clearTimeout(scrollTimer);
707
+ scrollTimer = null;
708
+ return publishScrollPosition(true, silent === true);
709
+ },
710
+
711
+ // Heading tree with page + source line — powers chapter jump (UX-013), TOC,
712
+ // minimap, scrollspy. Page math needs same-origin engine access, so it
713
+ // lives here rather than being derived host-side.
714
+ getOutline: function () {
715
+ refreshPages();
716
+ var hs = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6'));
717
+ var out = [];
718
+ for (var i = 0; i < hs.length; i++) {
719
+ var h = hs[i];
720
+ var text = (h.textContent || '').trim();
721
+ if (!text) continue;
722
+ out.push({
723
+ level: parseInt(h.tagName.charAt(1), 10),
724
+ text: text,
725
+ id: h.id || null,
726
+ sourceLine: lineOf(h),
727
+ chapter: chapterOf(h),
728
+ page: pageIndexOf(h),
729
+ index: i
730
+ });
731
+ }
732
+ return out;
733
+ },
734
+
735
+ // Single anchored-jump primitive: target {line}|{id}|{selector}|{page}.
736
+ // Returns the resolved {page, sourceLine}. Suppresses the scroll-driven
737
+ // pageChanged/sourceLineChanged echo so host-driven jumps don't loop back.
738
+ scrollTo: function (target, opts) {
739
+ opts = opts || {};
740
+ if (typeof target === 'number') target = { line: target };
741
+ var el = null;
742
+ var fraction = 0;
743
+ var nextEl = null;
744
+ if (target && target.line != null && target.selector == null && target.id == null && target.page == null) {
745
+ var pos = resolveLinePosition(target);
746
+ if (pos) {
747
+ el = pos.el;
748
+ if (pos.nextEl && pos.nextLine != null && pos.nextLine > pos.line) {
749
+ nextEl = pos.nextEl;
750
+ fraction = (Number(target.line) - pos.line) / (pos.nextLine - pos.line);
751
+ fraction = Math.max(0, Math.min(1, fraction));
752
+ }
753
+ }
754
+ } else {
755
+ el = resolveTarget(target);
756
+ }
757
+ if (!el) return null;
758
+ ignoreScrollUntil = Date.now() + 350;
759
+ var behavior = opts.smooth ? 'smooth' : 'instant';
760
+ el.scrollIntoView({
761
+ behavior: fraction > 0 ? 'instant' : behavior,
762
+ block: opts.block || 'start',
763
+ inline: 'nearest'
764
+ });
765
+ if (fraction > 0 && nextEl) {
766
+ // Interpolate within the block: nudge the scroll by the target line's
767
+ // fractional distance between this block's top and the NEXT annotated
768
+ // block's top. Only when the next block is genuinely lower on screen —
769
+ // a facing-page or next-column neighbour (top <= ours) degrades to the
770
+ // plain block-top scroll, which is the pre-existing behaviour.
771
+ var top = el.getBoundingClientRect().top;
772
+ var nextTop = nextEl.getBoundingClientRect().top;
773
+ if (nextTop > top) {
774
+ window.scrollBy({ top: fraction * (nextTop - top), behavior: behavior });
775
+ }
776
+ }
777
+ var page = pageIndexOf(el);
778
+ if (page) currentPage = page;
779
+ if (!opts.smooth) recordVisibleSource();
780
+ return { page: page || currentPage, sourceLine: lineOf(el) };
781
+ },
782
+
783
+ // Source line + page of the block at the top of the viewport (host scroll
784
+ // position read for preview->editor sync).
785
+ getVisibleSource: function () {
786
+ if (pages.length === 0) refreshPages();
787
+ var pos = visibleSourcePosition();
788
+ return pos ? { sourceLine: pos.line, chapter: chapterOf(pos.el), page: detectVisiblePage() } : null;
789
+ },
790
+
791
+ // Generic, read-only DOM extraction. fields: 'text'|'id'|'sourceLine'|
792
+ // 'page'|'tag'|'rectTop'|{attr:'name'}. No eval, no innerHTML — this is the
793
+ // forward-compat hook that lets the SPA build find/figure-list/etc. with no
794
+ // further lib change.
795
+ queryDom: function (spec) {
796
+ spec = spec || {};
797
+ if (!spec.selector) return [];
798
+ var fields = spec.fields || ['text'];
799
+ var els;
800
+ try { els = Array.from(document.querySelectorAll(spec.selector)); }
801
+ catch (_e) { return []; }
802
+ if (spec.limit && els.length > spec.limit) els = els.slice(0, spec.limit);
803
+ return els.map(function (el) {
804
+ var row = {};
805
+ for (var i = 0; i < fields.length; i++) {
806
+ var f = fields[i];
807
+ if (f === 'text') row.text = (el.textContent || '').trim();
808
+ else if (f === 'id') row.id = el.id || null;
809
+ else if (f === 'sourceLine') row.sourceLine = lineOf(el);
810
+ else if (f === 'page') row.page = pageIndexOf(el);
811
+ else if (f === 'chapter') row.chapter = chapterOf(el);
812
+ else if (f === 'tag') row.tag = el.tagName.toLowerCase();
813
+ else if (f === 'rectTop') row.rectTop = el.getBoundingClientRect().top;
814
+ else if (f && typeof f === 'object' && f.attr) row['attr:' + f.attr] = el.getAttribute(f.attr);
815
+ }
816
+ return row;
817
+ });
818
+ },
819
+
820
+ // Toggle a marker class on matched elements (DOM write inside the frame).
821
+ // spec: {line?|id?|selector?, group?, scroll?, transient?, transientMs?}.
822
+ // Powers find-in-page, editor-cursor echo, annotations.
823
+ highlight: function (spec) {
824
+ spec = spec || {};
825
+ var group = spec.group || 'default';
826
+ var els = [];
827
+ if (spec.selector) { try { els = Array.from(document.querySelectorAll(spec.selector)); } catch (_e) {} }
828
+ else if (spec.id) { var byId = document.getElementById(spec.id); if (byId) els = [byId]; }
829
+ else if (spec.line != null) { var one = resolveTarget({ line: spec.line }); if (one) els = [one]; }
830
+ for (var i = 0; i < els.length; i++) {
831
+ els[i].classList.add('gutterpress-hl');
832
+ els[i].setAttribute('data-gutterpress-hl-group', group);
833
+ }
834
+ if (spec.scroll && els[0]) {
835
+ ignoreScrollUntil = Date.now() + 350;
836
+ els[0].scrollIntoView({ behavior: 'instant', block: 'center', inline: 'nearest' });
837
+ recordVisibleSource();
838
+ }
839
+ if (spec.transient && els.length) {
840
+ setTimeout(function () {
841
+ for (var j = 0; j < els.length; j++) {
842
+ if (els[j].getAttribute('data-gutterpress-hl-group') === group) {
843
+ els[j].classList.remove('gutterpress-hl');
844
+ els[j].removeAttribute('data-gutterpress-hl-group');
845
+ }
846
+ }
847
+ }, spec.transientMs || 1200);
848
+ }
849
+ return { count: els.length };
850
+ },
851
+
852
+ // ── Find in book (the app's Ctrl+F) ─────────────────────────────────
853
+ // window.find() runs inside THIS frame, so it can only ever match book
854
+ // content — never the app's toolbar chrome or the editor. Each call with
855
+ // the same query advances the native selection (wrapping at the ends)
856
+ // and Chromium scrolls the match into view; a NEW query restarts from
857
+ // the top and counts total occurrences once (case-insensitive).
858
+ find: function (query, backwards) {
859
+ query = String(query == null ? '' : query);
860
+ if (!query) return api.clearFind();
861
+ if (query !== findQuery) {
862
+ findQuery = query;
863
+ var text = (document.body.textContent || '').toLowerCase();
864
+ var needle = query.toLowerCase();
865
+ findTotal = 0;
866
+ var idx = 0;
867
+ while (findTotal < 10000) {
868
+ idx = text.indexOf(needle, idx);
869
+ if (idx === -1) break;
870
+ findTotal++;
871
+ idx += needle.length;
872
+ }
873
+ var sel = window.getSelection();
874
+ if (sel) sel.removeAllRanges();
875
+ }
876
+ var found = false;
877
+ try {
878
+ found = window.find(query, false, !!backwards, true, false, false, false);
879
+ } catch (_e) {
880
+ found = false;
881
+ }
882
+ return { found: !!found, total: findTotal };
883
+ },
884
+
885
+ clearFind: function () {
886
+ findQuery = '';
887
+ findTotal = 0;
888
+ var sel = window.getSelection();
889
+ if (sel) sel.removeAllRanges();
890
+ return { found: false, total: 0 };
891
+ },
892
+
893
+ clearHighlights: function (group) {
894
+ var sel = group ? '.gutterpress-hl[data-gutterpress-hl-group="' + group + '"]' : '.gutterpress-hl';
895
+ var els = Array.from(document.querySelectorAll(sel));
896
+ for (var i = 0; i < els.length; i++) {
897
+ els[i].classList.remove('gutterpress-hl');
898
+ els[i].removeAttribute('data-gutterpress-hl-group');
899
+ }
900
+ return { cleared: els.length };
901
+ }
902
+ };
903
+
904
+ window.previewAPI = api;
905
+
906
+ // Default highlight style (preview-only; never part of the PDF build path).
907
+ // Guarded so a minimal/headless DOM (e.g. unit-test harness) can't crash here.
908
+ if (typeof document.createElement === 'function') {
909
+ try {
910
+ var hlStyle = document.createElement('style');
911
+ hlStyle.textContent =
912
+ '.gutterpress-hl{outline:2px solid var(--gutterpress-hl-color,#4ea1ff);outline-offset:2px;' +
913
+ 'background:var(--gutterpress-hl-bg,rgba(78,161,255,.14));' +
914
+ 'transition:outline-color .2s,background .2s;}';
915
+ (document.head || document.documentElement).appendChild(hlStyle);
916
+ } catch (_e) { /* non-fatal: highlight just renders unstyled */ }
917
+ }
918
+
919
+ // Block-overlay mask + scroll-lock style (protocol v5, plan §5.1/§5.3).
920
+ // Preview-only, never part of the PDF build path. Purely cosmetic: dims the
921
+ // masked fragment(s) so stale rendered text doesn't show behind/beside the
922
+ // overlay, and disables the book document's own scroll while an overlay is
923
+ // open (the overlay is positioned in host-SPA coordinates from a rect
924
+ // snapshot; an unlocked scroll would silently drift it over unrelated
925
+ // content — see BlockOverlayController). Exact visual treatment (dim vs.
926
+ // blank) is a placeholder pending design review (plan §7.6 open item).
927
+ if (typeof document.createElement === 'function') {
928
+ try {
929
+ var maskStyle = document.createElement('style');
930
+ maskStyle.textContent =
931
+ '.gutterpress-edit-mask{opacity:.2;filter:saturate(.4);pointer-events:none;' +
932
+ 'transition:opacity .12s,filter .12s;}' +
933
+ 'html.gutterpress-edit-scroll-lock{overflow:hidden !important;}';
934
+ (document.head || document.documentElement).appendChild(maskStyle);
935
+ } catch (_e) { /* non-fatal: mask just renders unstyled */ }
936
+ }
937
+
938
+ // Click-to-source: emit elementActivated when the user clicks a source-mapped
939
+ // block. Never preventDefault (links/selection keep working); the host decides
940
+ // whether to act. (ADR 0005)
941
+ if (typeof document.addEventListener === 'function') {
942
+ document.addEventListener('click', function (e) {
943
+ var el = e.target && e.target.closest ? e.target.closest('[data-source-line]') : null;
944
+ if (!el) return;
945
+ window.dispatchEvent(new CustomEvent('elementActivated', {
946
+ detail: { sourceLine: lineOf(el), chapter: chapterOf(el), id: el.id || null, tag: el.tagName.toLowerCase() }
947
+ }));
948
+ }, true);
949
+
950
+ // Context menu (protocol v4, docs/inline-editing-plan.md §3.1). Both the
951
+ // mouse and keyboard paths dispatch the same contextMenuRequested window
952
+ // event carrying the getContextTargetAt() payload plus the viewport
953
+ // x/y and `via`.
954
+ //
955
+ // preventDefault() ONLY when kind !== 'none' — right-clicks on page
956
+ // furniture (margin boxes, running headers, page numbers) keep native
957
+ // behavior: that text is selectable/copyable, and killing native copy
958
+ // with no replacement menu would be a strict regression. No event is
959
+ // dispatched for 'none' either.
960
+ document.addEventListener('contextmenu', function (e) {
961
+ var detail = api.getContextTargetAt({ x: e.clientX, y: e.clientY });
962
+ if (detail.kind === 'none') return;
963
+ e.preventDefault();
964
+ detail.x = e.clientX;
965
+ detail.y = e.clientY;
966
+ detail.via = 'mouse';
967
+ window.dispatchEvent(new CustomEvent('contextMenuRequested', { detail: detail }));
968
+ }, true);
969
+
970
+ // Shift+F10 / the dedicated ContextMenu key. This listener MUST live
971
+ // inside the book iframe: keyboard events targeted at a focused element
972
+ // in a cross-origin iframe never reach the parent SPA, so an SPA-side
973
+ // listener cannot implement this.
974
+ document.addEventListener('keydown', function (e) {
975
+ var isShiftF10 = e.shiftKey && e.key === 'F10';
976
+ var isMenuKey = e.key === 'ContextMenu';
977
+ if (!isShiftF10 && !isMenuKey) return;
978
+ var anchor = keyboardAnchorPoint();
979
+ var detail = api.getContextTargetAt({ x: anchor.x, y: anchor.y });
980
+ detail.x = anchor.x;
981
+ detail.y = anchor.y;
982
+ detail.via = 'keyboard';
983
+ e.preventDefault();
984
+ window.dispatchEvent(new CustomEvent('contextMenuRequested', { detail: detail }));
985
+ }, true);
986
+ }
987
+
988
+ // Scroll tracking
989
+ var scrollTimer = null;
990
+ var viewportFrame = null;
991
+ function publishScrollPosition(force, silent) {
992
+ scrollTimer = null;
993
+ var remainingGuard = ignoreScrollUntil - Date.now();
994
+ if (!force && remainingGuard > 0) {
995
+ scrollTimer = setTimeout(publishScrollPosition, remainingGuard);
996
+ return null;
997
+ }
998
+ if (pages.length === 0) refreshPages();
999
+ if (pages.length === 0) return null;
1000
+ var page = detectVisiblePage();
1001
+ if (page !== currentPage) {
1002
+ currentPage = page;
1003
+ if (!silent) api.notifyPageChange();
1004
+ }
1005
+ // Emit finer-grained source position for editor sync (ADR 0005).
1006
+ var pos = visibleSourcePosition();
1007
+ var sl = pos ? pos.line : null;
1008
+ var chapter = pos ? chapterOf(pos.el) : null;
1009
+ if (sl != null && (sl !== lastSourceLine || chapter !== lastSourceChapter)) {
1010
+ lastSourceLine = sl;
1011
+ lastSourceChapter = chapter;
1012
+ var detail = { sourceLine: sl, chapter: chapter, page: page };
1013
+ if (!silent) {
1014
+ window.dispatchEvent(new CustomEvent('sourceLineChanged', { detail: detail }));
1015
+ }
1016
+ return detail;
1017
+ }
1018
+ return null;
1019
+ }
1020
+ function scheduleViewportChanged() {
1021
+ if (!viewportFrame) {
1022
+ var schedule = typeof window.requestAnimationFrame === 'function'
1023
+ ? window.requestAnimationFrame.bind(window)
1024
+ : function (fn) { return setTimeout(fn, 0); };
1025
+ viewportFrame = schedule(function () {
1026
+ viewportFrame = null;
1027
+ window.dispatchEvent(new CustomEvent('viewportChanged', { detail: {} }));
1028
+ });
1029
+ }
1030
+ }
1031
+ window.addEventListener('scroll', function () {
1032
+ scheduleViewportChanged();
1033
+ if (scrollTimer) clearTimeout(scrollTimer);
1034
+ scrollTimer = setTimeout(publishScrollPosition, 150);
1035
+ });
1036
+ window.addEventListener('resize', scheduleViewportChanged);
1037
+
1038
+ // Reset to page 1, scroll to top, and announce completion once the native
1039
+ // viewer's pagination finishes.
1040
+ function onRenderingComplete() {
1041
+ // Latch: a host that attaches its 'renderingComplete' listener after the
1042
+ // event already fired (preview-shell.js binds on the iframe's `load`, which
1043
+ // the native viewer's DOMContentLoaded mount can beat) has no other way to
1044
+ // know pagination is done.
1045
+ window.__GUTTERPRESS_RENDERED__ = true;
1046
+ refreshPages();
1047
+ currentPage = 1;
1048
+ ignoreScrollUntil = Date.now() + 300;
1049
+ window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
1050
+ recordVisibleSource();
1051
+ console.log('Gutterpress engine rendered ' + pages.length + ' pages');
1052
+ api.notifyRenderingComplete();
1053
+ setTimeout(api.notifyPageChange, 0);
1054
+ }
1055
+
1056
+ // The Gutterpress engine viewer dispatches this when its pagination
1057
+ // completes (engine/viewer/index.ts's mount()).
1058
+ window.addEventListener('gp:layout', onRenderingComplete);
1059
+ })();