@peaske7/readit 0.1.7 → 0.2.0

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 (118) hide show
  1. package/README.md +0 -3
  2. package/biome.json +1 -1
  3. package/bun.lock +43 -185
  4. package/docs/perf-baseline.md +75 -0
  5. package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
  6. package/e2e/perf/add-comment.spec.ts +118 -0
  7. package/e2e/perf/fixtures/generate.ts +331 -0
  8. package/e2e/perf/initial-load.spec.ts +49 -0
  9. package/e2e/perf/perf.setup.ts +23 -0
  10. package/e2e/perf/perf.teardown.ts +9 -0
  11. package/e2e/perf/scroll.spec.ts +39 -0
  12. package/e2e/perf/tab-switch.spec.ts +69 -0
  13. package/e2e/perf/text-selection.spec.ts +119 -0
  14. package/e2e/perf/utils/metrics.ts +286 -0
  15. package/e2e/perf/utils/perf-cli.ts +86 -0
  16. package/package.json +9 -18
  17. package/playwright.config.ts +12 -0
  18. package/src/App.tsx +133 -178
  19. package/src/{cli/index.ts → cli.ts} +211 -107
  20. package/src/components/ActionsMenu.tsx +6 -27
  21. package/src/components/DocumentViewer/DocumentViewer.tsx +78 -105
  22. package/src/components/DocumentViewer/MermaidDiagram.tsx +6 -7
  23. package/src/components/Header.tsx +9 -20
  24. package/src/components/InlineEditor.tsx +5 -5
  25. package/src/components/MarginNote.tsx +71 -93
  26. package/src/components/MarginNotes.tsx +7 -34
  27. package/src/components/RawModal.tsx +9 -8
  28. package/src/components/ReanchorConfirm.tsx +2 -2
  29. package/src/components/SettingsModal.tsx +11 -89
  30. package/src/components/TabBar.tsx +4 -4
  31. package/src/components/TableOfContents.tsx +5 -5
  32. package/src/components/comments/CommentInput.tsx +7 -35
  33. package/src/components/comments/CommentListItem.tsx +9 -11
  34. package/src/components/comments/CommentManager.tsx +53 -37
  35. package/src/components/comments/CommentNav.tsx +14 -14
  36. package/src/components/ui/ActionLink.tsx +14 -18
  37. package/src/components/ui/Button.tsx +42 -43
  38. package/src/components/ui/Dialog.tsx +73 -113
  39. package/src/components/ui/DropdownMenu.tsx +113 -69
  40. package/src/components/ui/Text.tsx +30 -37
  41. package/src/contexts/CommentContext.tsx +75 -106
  42. package/src/contexts/LocaleContext.tsx +45 -4
  43. package/src/contexts/PositionsContext.tsx +16 -0
  44. package/src/contexts/SettingsContext.tsx +133 -0
  45. package/src/hooks/useClickOutside.ts +0 -4
  46. package/src/hooks/useCommentNavigation.ts +6 -29
  47. package/src/hooks/useComments.ts +6 -18
  48. package/src/hooks/useDocument.ts +35 -34
  49. package/src/hooks/useHeadings.test.ts +8 -50
  50. package/src/hooks/useHeadings.ts +5 -88
  51. package/src/hooks/useScrollSpy.ts +10 -14
  52. package/src/hooks/useTextSelection.ts +1 -38
  53. package/src/lib/__fixtures__/bench-data.ts +1 -41
  54. package/src/lib/anchor.bench.ts +57 -67
  55. package/src/lib/anchor.test.ts +5 -1
  56. package/src/lib/anchor.ts +13 -93
  57. package/src/lib/comment-storage.test.ts +4 -4
  58. package/src/lib/comment-storage.ts +2 -46
  59. package/src/lib/export.ts +7 -13
  60. package/src/lib/highlight/core.test.ts +1 -1
  61. package/src/lib/highlight/dom.ts +5 -68
  62. package/src/lib/highlight/highlighter.ts +102 -262
  63. package/src/lib/highlight/resolver.ts +112 -0
  64. package/src/lib/highlight/types.ts +0 -35
  65. package/src/lib/highlight/worker.ts +45 -0
  66. package/src/lib/i18n/en.ts +1 -50
  67. package/src/lib/i18n/ja.ts +1 -50
  68. package/src/lib/i18n/types.ts +1 -49
  69. package/src/lib/margin-layout.ts +5 -27
  70. package/src/lib/positions.ts +150 -0
  71. package/src/lib/utils.ts +2 -19
  72. package/src/schema.ts +81 -0
  73. package/src/{server/index.ts → server.ts} +111 -81
  74. package/src/{store/index.ts → store.ts} +14 -46
  75. package/vite.config.ts +8 -0
  76. package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
  77. package/src/components/DocumentViewer/InlineCode.tsx +0 -60
  78. package/src/components/DocumentViewer/index.ts +0 -1
  79. package/src/components/FloatingTOC.tsx +0 -61
  80. package/src/components/ShortcutCapture.tsx +0 -48
  81. package/src/components/ShortcutList.tsx +0 -198
  82. package/src/components/comments/CommentMinimap.tsx +0 -62
  83. package/src/components/ui/ActionBar.tsx +0 -16
  84. package/src/components/ui/SeparatorDot.tsx +0 -9
  85. package/src/contexts/LayoutContext.tsx +0 -88
  86. package/src/hooks/useClipboard.ts +0 -82
  87. package/src/hooks/useEditorScheme.ts +0 -51
  88. package/src/hooks/useFontPreference.ts +0 -59
  89. package/src/hooks/useKeybindings.ts +0 -108
  90. package/src/hooks/useKeyboardShortcuts.ts +0 -63
  91. package/src/hooks/useLayoutMode.ts +0 -44
  92. package/src/hooks/useLocalePreference.ts +0 -42
  93. package/src/hooks/useReanchorMode.ts +0 -33
  94. package/src/hooks/useScrollMetrics.ts +0 -56
  95. package/src/hooks/useThemePreference.ts +0 -66
  96. package/src/lib/comment-storage.bench.ts +0 -63
  97. package/src/lib/context.bench.ts +0 -41
  98. package/src/lib/context.test.ts +0 -224
  99. package/src/lib/context.ts +0 -193
  100. package/src/lib/editor-links.ts +0 -59
  101. package/src/lib/export.bench.ts +0 -35
  102. package/src/lib/highlight/colors.ts +0 -37
  103. package/src/lib/highlight/core.ts +0 -54
  104. package/src/lib/highlight/index.ts +0 -23
  105. package/src/lib/highlight/script-builder.ts +0 -485
  106. package/src/lib/html-processor.test.tsx +0 -170
  107. package/src/lib/html-processor.tsx +0 -95
  108. package/src/lib/i18n/completeness.test.ts +0 -51
  109. package/src/lib/i18n/translations.test.ts +0 -39
  110. package/src/lib/layout-constants.ts +0 -12
  111. package/src/lib/margin-layout.bench.ts +0 -28
  112. package/src/lib/scroll.test.ts +0 -118
  113. package/src/lib/scroll.ts +0 -47
  114. package/src/lib/shortcut-registry.test.ts +0 -173
  115. package/src/lib/shortcut-registry.ts +0 -209
  116. package/src/lib/utils.test.ts +0 -110
  117. package/src/store/index.test.ts +0 -242
  118. package/src/types/index.ts +0 -127
@@ -1,37 +0,0 @@
1
- /**
2
- * Color palette for comment highlights.
3
- * Colors are assigned by document position (top-to-bottom).
4
- */
5
-
6
- export const COMMENT_COLORS = [
7
- {
8
- name: "amber",
9
- bg: "rgba(245, 222, 160, 0.5)",
10
- bgFocused: "rgba(228, 195, 110, 0.65)",
11
- border: "#c9a84a",
12
- text: "#8b6914",
13
- },
14
- {
15
- name: "blue",
16
- bg: "rgba(168, 196, 228, 0.5)",
17
- bgFocused: "rgba(130, 168, 210, 0.65)",
18
- border: "#5b7fa8",
19
- text: "#3d5f8a",
20
- },
21
- {
22
- name: "green",
23
- bg: "rgba(170, 210, 170, 0.5)",
24
- bgFocused: "rgba(130, 185, 135, 0.65)",
25
- border: "#5a9a62",
26
- text: "#3d6e45",
27
- },
28
- {
29
- name: "rose",
30
- bg: "rgba(225, 180, 185, 0.5)",
31
- bgFocused: "rgba(205, 145, 155, 0.65)",
32
- border: "#b86b78",
33
- text: "#8a4a55",
34
- },
35
- ] as const;
36
-
37
- export type CommentColor = (typeof COMMENT_COLORS)[number];
@@ -1,54 +0,0 @@
1
- import type { TextPosition } from "./types";
2
-
3
- /**
4
- * Find text position in content, handling duplicate occurrences.
5
- * Returns the occurrence closest to hintOffset when multiple exist.
6
- */
7
- export function findTextPosition(
8
- textContent: string,
9
- selectedText: string,
10
- hintOffset?: number,
11
- ): TextPosition | undefined {
12
- if (!selectedText || !textContent) {
13
- return undefined;
14
- }
15
-
16
- const occurrences: number[] = [];
17
- let idx = 0;
18
-
19
- for (;;) {
20
- idx = textContent.indexOf(selectedText, idx);
21
- if (idx === -1) break;
22
- occurrences.push(idx);
23
- idx += 1;
24
- }
25
-
26
- if (occurrences.length === 0) {
27
- return undefined;
28
- }
29
-
30
- if (occurrences.length === 1) {
31
- return {
32
- start: occurrences[0],
33
- end: occurrences[0] + selectedText.length,
34
- };
35
- }
36
-
37
- // Multiple occurrences: find closest to hint offset
38
- const target = hintOffset ?? 0;
39
- let closest = occurrences[0];
40
- let minDist = Math.abs(closest - target);
41
-
42
- for (const occ of occurrences) {
43
- const dist = Math.abs(occ - target);
44
- if (dist < minDist) {
45
- minDist = dist;
46
- closest = occ;
47
- }
48
- }
49
-
50
- return {
51
- start: closest,
52
- end: closest + selectedText.length,
53
- };
54
- }
@@ -1,23 +0,0 @@
1
- // Highlighter (unified adapter)
2
-
3
- export type { CommentColor } from "./colors";
4
- // Colors
5
- export { COMMENT_COLORS } from "./colors";
6
- export type {
7
- Highlighter,
8
- HighlighterOptions,
9
- HoverHandler,
10
- PositionChangeHandler,
11
- SelectionHandler,
12
- } from "./highlighter";
13
- export { createHighlighter } from "./highlighter";
14
-
15
- // Script builder (needed by IframeContainer)
16
- export { buildIframeScript } from "./script-builder";
17
-
18
- // Types (public API)
19
- export type {
20
- HighlightComment,
21
- HighlightPositions,
22
- HighlightStyle,
23
- } from "./types";
@@ -1,485 +0,0 @@
1
- /**
2
- * Builds the JavaScript script to be injected into the iframe.
3
- *
4
- * This script contains the core highlighting functions that run inside
5
- * the sandboxed iframe, communicating with the parent via postMessage.
6
- *
7
- * IMPORTANT: DUPLICATED FUNCTIONS
8
- * ================================
9
- * The following functions are duplicated from TypeScript sources.
10
- * They must be kept in sync manually. When modifying any of these
11
- * functions, update BOTH locations.
12
- *
13
- * Duplicated from core.ts:
14
- * - findTextPosition()
15
- *
16
- * Duplicated from dom.ts:
17
- * - getTextOffset()
18
- * - getDOMTextContent()
19
- * - collectTextNodes()
20
- * - applyHighlightToRange()
21
- * - clearHighlights()
22
- * - collectHighlightPositions() (viewport variant)
23
- *
24
- * Why duplication exists:
25
- * The iframe runs in a sandboxed environment and receives content
26
- * via srcdoc. It cannot import TypeScript modules. The functions
27
- * must be embedded as plain JavaScript strings.
28
- *
29
- * Keeping them in sync:
30
- * The TypeScript sources (core.ts, dom.ts) are the source of truth.
31
- * Tests in core.test.ts verify the behavior. If you change the
32
- * TypeScript implementation, manually update the corresponding
33
- * function here to match.
34
- */
35
-
36
- /**
37
- * Build the complete iframe script with parent origin for secure postMessage.
38
- */
39
- export function buildIframeScript(parentOrigin: string): string {
40
- return `
41
- <script>
42
- (function() {
43
- const parentOrigin = ${JSON.stringify(parentOrigin)};
44
- const root = document.body;
45
-
46
- // --- Core Functions (from core.ts) ---
47
-
48
- function findTextPosition(textContent, selectedText, hintOffset) {
49
- if (!selectedText || !textContent) {
50
- return null;
51
- }
52
-
53
- const occurrences = [];
54
- let idx = 0;
55
-
56
- for (;;) {
57
- idx = textContent.indexOf(selectedText, idx);
58
- if (idx === -1) break;
59
- occurrences.push(idx);
60
- idx += 1;
61
- }
62
-
63
- if (occurrences.length === 0) {
64
- return null;
65
- }
66
-
67
- if (occurrences.length === 1) {
68
- return {
69
- start: occurrences[0],
70
- end: occurrences[0] + selectedText.length,
71
- };
72
- }
73
-
74
- // Multiple occurrences: find closest to hint offset
75
- const target = hintOffset ?? 0;
76
- let closest = occurrences[0];
77
- let minDist = Math.abs(closest - target);
78
-
79
- for (const occ of occurrences) {
80
- const dist = Math.abs(occ - target);
81
- if (dist < minDist) {
82
- minDist = dist;
83
- closest = occ;
84
- }
85
- }
86
-
87
- return {
88
- start: closest,
89
- end: closest + selectedText.length,
90
- };
91
- }
92
-
93
- // --- DOM Functions (from dom.ts) ---
94
-
95
- const BLOCK_ELEMENTS = new Set([
96
- 'P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
97
- 'PRE', 'BLOCKQUOTE', 'LI', 'TR', 'BR'
98
- ]);
99
-
100
- function findBlockParent(node) {
101
- let parent = node.parentElement;
102
- while (parent && !BLOCK_ELEMENTS.has(parent.tagName)) {
103
- parent = parent.parentElement;
104
- }
105
- return parent;
106
- }
107
-
108
- function getTextOffset(root, targetNode, targetOffset) {
109
- let offset = 0;
110
- let lastBlockParent = null;
111
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
112
-
113
- let node = walker.nextNode();
114
- while (node) {
115
- const blockParent = findBlockParent(node);
116
-
117
- // Add newline when transitioning between different block parents
118
- if (lastBlockParent && blockParent && lastBlockParent !== blockParent) {
119
- if (!lastBlockParent.contains(blockParent) && !blockParent.contains(lastBlockParent)) {
120
- offset += 1; // Account for the newline
121
- }
122
- }
123
-
124
- if (node === targetNode) {
125
- return offset + targetOffset;
126
- }
127
- offset += (node.textContent?.length ?? 0);
128
- lastBlockParent = blockParent;
129
- node = walker.nextNode();
130
- }
131
-
132
- return offset;
133
- }
134
-
135
- function getDOMTextContent(root) {
136
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
137
- let text = '';
138
- let lastBlockParent = null;
139
- let node = walker.nextNode();
140
-
141
- while (node) {
142
- const blockParent = findBlockParent(node);
143
-
144
- // Insert newline when transitioning between different block parents
145
- if (lastBlockParent && blockParent && lastBlockParent !== blockParent) {
146
- if (!lastBlockParent.contains(blockParent) && !blockParent.contains(lastBlockParent)) {
147
- text += '\\n';
148
- }
149
- }
150
-
151
- text += node.textContent ?? '';
152
- lastBlockParent = blockParent;
153
- node = walker.nextNode();
154
- }
155
-
156
- return text;
157
- }
158
-
159
- function collectTextNodes(root) {
160
- const textNodes = [];
161
- let currentOffset = 0;
162
- let lastBlockParent = null;
163
-
164
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
165
- let node = walker.nextNode();
166
-
167
- while (node) {
168
- const blockParent = findBlockParent(node);
169
-
170
- // Account for newline when transitioning between different block parents
171
- // (same logic as getDOMTextContent)
172
- if (lastBlockParent && blockParent && lastBlockParent !== blockParent) {
173
- if (!lastBlockParent.contains(blockParent) && !blockParent.contains(lastBlockParent)) {
174
- currentOffset += 1; // Account for the newline
175
- }
176
- }
177
-
178
- const length = node.textContent?.length ?? 0;
179
- textNodes.push({
180
- node: node,
181
- start: currentOffset,
182
- end: currentOffset + length,
183
- });
184
- currentOffset += length;
185
- lastBlockParent = blockParent;
186
- node = walker.nextNode();
187
- }
188
-
189
- return textNodes;
190
- }
191
-
192
- function applyHighlightToRange(root, startOffset, endOffset, style) {
193
- const textNodes = collectTextNodes(root);
194
-
195
- const overlappingNodes = textNodes.filter(
196
- n => n.end > startOffset && n.start < endOffset
197
- );
198
-
199
- if (overlappingNodes.length === 0) {
200
- return;
201
- }
202
-
203
- for (const { node: textNode, start } of overlappingNodes) {
204
- const nodeStart = Math.max(0, startOffset - start);
205
- const nodeEnd = Math.min(textNode.length, endOffset - start);
206
-
207
- if (nodeStart >= nodeEnd) {
208
- continue;
209
- }
210
-
211
- const range = document.createRange();
212
- range.setStart(textNode, nodeStart);
213
- range.setEnd(textNode, nodeEnd);
214
-
215
- const mark = document.createElement('mark');
216
- mark.setAttribute(style.attribute, style.attributeValue);
217
-
218
- try {
219
- range.surroundContents(mark);
220
- } catch (e) {
221
- // Range crosses element boundaries (e.g., syntax-highlighted code blocks)
222
- // Use extractContents + insertNode as fallback
223
- try {
224
- const fragment = range.extractContents();
225
- mark.appendChild(fragment);
226
- range.insertNode(mark);
227
- } catch (e2) {
228
- // If even extractContents fails, skip this node
229
- }
230
- }
231
- }
232
- }
233
-
234
- function clearHighlights(root) {
235
- const marks = root.querySelectorAll('mark[data-comment-id], mark[data-pending]');
236
-
237
- for (const mark of marks) {
238
- const parent = mark.parentNode;
239
- if (parent) {
240
- while (mark.firstChild) {
241
- parent.insertBefore(mark.firstChild, mark);
242
- }
243
- parent.removeChild(mark);
244
- }
245
- }
246
- }
247
-
248
- function collectHighlightPositions(root) {
249
- const positions = {};
250
- const documentPositions = {};
251
- const scrollY = window.scrollY || 0;
252
-
253
- const marks = root.querySelectorAll('mark[data-comment-id]');
254
- for (const mark of marks) {
255
- const commentId = mark.getAttribute('data-comment-id');
256
- if (!commentId || positions[commentId] !== undefined) continue;
257
-
258
- const rect = mark.getBoundingClientRect();
259
- positions[commentId] = rect.top;
260
- documentPositions[commentId] = rect.top + scrollY;
261
- }
262
-
263
- let pendingTop = null;
264
- const pendingMark = root.querySelector('mark[data-pending]');
265
- if (pendingMark) {
266
- const pendingRect = pendingMark.getBoundingClientRect();
267
- pendingTop = pendingRect.top;
268
- }
269
-
270
- return { positions, documentPositions, pendingTop };
271
- }
272
-
273
- // --- Selection Handler ---
274
-
275
- document.addEventListener('mouseup', function() {
276
- const selection = window.getSelection();
277
- if (!selection || selection.isCollapsed) return;
278
-
279
- // Normalize whitespace: collapse any sequence of whitespace containing newlines
280
- // Browser's selection.toString() includes CSS margins as extra newlines/spaces
281
- const text = selection.toString().trim().replace(/\\r?\\n\\s*/g, '\\n');
282
- if (text.length === 0) return;
283
-
284
- const range = selection.getRangeAt(0);
285
- const startOffset = getTextOffset(root, range.startContainer, range.startOffset);
286
- const endOffset = getTextOffset(root, range.endContainer, range.endOffset);
287
-
288
- const rangeRect = range.getBoundingClientRect();
289
- const selectionTop = rangeRect.top + document.documentElement.scrollTop;
290
-
291
- parent.postMessage({
292
- type: 'textSelection',
293
- text: text,
294
- startOffset: startOffset,
295
- endOffset: endOffset,
296
- selectionTop: selectionTop
297
- }, parentOrigin);
298
- });
299
-
300
- // --- Message Handler ---
301
-
302
- window.addEventListener('message', function(event) {
303
- // Handle scroll to heading request from parent
304
- if (event.data.type === 'scrollToHeading') {
305
- const id = event.data.id;
306
- const element = document.getElementById(id);
307
- if (element) {
308
- element.scrollIntoView({ behavior: 'smooth', block: 'start' });
309
- }
310
- return;
311
- }
312
-
313
- // Handle scroll to highlight request from parent
314
- if (event.data.type === 'scrollToHighlight') {
315
- const mark = document.querySelector('mark[data-comment-id="' + event.data.commentId + '"]');
316
- if (mark) {
317
- mark.scrollIntoView({ behavior: 'smooth', block: 'center' });
318
- }
319
- return;
320
- }
321
-
322
- if (event.data.type === 'applyHighlights') {
323
- clearHighlights(root);
324
-
325
- const comments = event.data.comments || [];
326
- const pending = event.data.pendingSelection;
327
-
328
- const textContent = getDOMTextContent(root);
329
-
330
- // Resolve anchors and apply highlights
331
- const resolved = comments
332
- .map(function(c) {
333
- const anchor = findTextPosition(textContent, c.selectedText, c.startOffset);
334
- if (anchor) {
335
- return { id: c.id, startOffset: anchor.start, endOffset: anchor.end };
336
- }
337
- return { id: c.id, startOffset: c.startOffset, endOffset: c.endOffset };
338
- })
339
- .sort(function(a, b) { return a.startOffset - b.startOffset; });
340
-
341
- for (const comment of resolved) {
342
- applyHighlightToRange(root, comment.startOffset, comment.endOffset, {
343
- attribute: 'data-comment-id',
344
- attributeValue: comment.id
345
- });
346
- }
347
-
348
- if (pending) {
349
- applyHighlightToRange(root, pending.startOffset, pending.endOffset, {
350
- attribute: 'data-pending',
351
- attributeValue: 'true'
352
- });
353
- }
354
-
355
- setTimeout(function() {
356
- reportPositions();
357
- reportContentHeight();
358
- }, 50);
359
- }
360
- });
361
-
362
- // --- Position Reporting ---
363
-
364
- function reportPositions() {
365
- const result = collectHighlightPositions(root);
366
- parent.postMessage({
367
- type: 'highlightPositions',
368
- positions: result.positions,
369
- documentPositions: result.documentPositions,
370
- pendingTop: result.pendingTop
371
- }, parentOrigin);
372
- }
373
-
374
- // --- Content Height Reporting ---
375
-
376
- function reportContentHeight() {
377
- parent.postMessage({
378
- type: 'contentHeight',
379
- height: document.body.scrollHeight
380
- }, parentOrigin);
381
- }
382
-
383
- window.addEventListener('scroll', reportPositions, { passive: true });
384
- document.addEventListener('scroll', reportPositions, { passive: true });
385
- window.addEventListener('resize', function() {
386
- reportPositions();
387
- reportContentHeight();
388
- });
389
- window.addEventListener('load', reportContentHeight);
390
-
391
- // --- Hover Handlers ---
392
-
393
- document.addEventListener('mouseover', function(e) {
394
- const mark = e.target.closest('mark[data-comment-id]');
395
- if (mark) {
396
- parent.postMessage({
397
- type: 'highlightHover',
398
- commentId: mark.getAttribute('data-comment-id')
399
- }, parentOrigin);
400
- }
401
- });
402
-
403
- document.addEventListener('mouseout', function(e) {
404
- const mark = e.target.closest('mark[data-comment-id]');
405
- if (mark) {
406
- const related = e.relatedTarget?.closest?.('mark[data-comment-id]');
407
- if (!related || related.getAttribute('data-comment-id') !== mark.getAttribute('data-comment-id')) {
408
- parent.postMessage({ type: 'highlightHover', commentId: null }, parentOrigin);
409
- }
410
- }
411
- });
412
-
413
- document.addEventListener('click', function(e) {
414
- const mark = e.target.closest('mark[data-comment-id]');
415
- if (mark) {
416
- parent.postMessage({
417
- type: 'highlightClick',
418
- commentId: mark.getAttribute('data-comment-id')
419
- }, parentOrigin);
420
- }
421
- });
422
-
423
- // --- Ready Signal ---
424
-
425
- parent.postMessage({ type: 'iframeReady' }, parentOrigin);
426
-
427
- // --- Ensure Heading IDs for TOC navigation ---
428
-
429
- function ensureHeadingIds() {
430
- const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
431
- const seenIds = {};
432
-
433
- for (const heading of headings) {
434
- if (!heading.id) {
435
- let id = (heading.textContent || '')
436
- .toLowerCase()
437
- .trim()
438
- .replace(/[^a-z0-9 -]/g, '')
439
- .replace(/ +/g, '-')
440
- .replace(/-+/g, '-');
441
-
442
- // Handle duplicates
443
- const baseId = id;
444
- const count = seenIds[baseId] || 0;
445
- if (count > 0) {
446
- id = baseId + '-' + count;
447
- }
448
- seenIds[baseId] = count + 1;
449
-
450
- heading.id = id;
451
- }
452
- }
453
- }
454
- ensureHeadingIds();
455
-
456
- // Height reporting delays to catch layout shifts
457
- const HEIGHT_REPORT_DELAY_SHORT = 100;
458
- const HEIGHT_REPORT_DELAY_LONG = 500;
459
-
460
- // Report initial height reliably - use multiple strategies
461
- function scheduleHeightReport() {
462
- // Immediate report
463
- reportContentHeight();
464
- // Delayed report to catch layout shifts
465
- setTimeout(reportContentHeight, HEIGHT_REPORT_DELAY_SHORT);
466
- setTimeout(reportContentHeight, HEIGHT_REPORT_DELAY_LONG);
467
- }
468
-
469
- if (document.readyState === 'complete') {
470
- scheduleHeightReport();
471
- } else {
472
- window.addEventListener('load', scheduleHeightReport);
473
- }
474
-
475
- // Watch for content size changes with ResizeObserver
476
- if (typeof ResizeObserver !== 'undefined') {
477
- const resizeObserver = new ResizeObserver(function() {
478
- reportContentHeight();
479
- });
480
- resizeObserver.observe(document.body);
481
- }
482
- })();
483
- </script>
484
- `;
485
- }