@cyber-dash-tech/revela 0.6.1 → 0.7.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.
@@ -0,0 +1,851 @@
1
+ import { randomBytes } from "crypto"
2
+ import { readFileSync, statSync } from "fs"
3
+ import type { EditableDeck } from "./resolve-deck"
4
+ import { buildEditPrompt, type EditCommentPayload } from "./prompt"
5
+
6
+ const TOKEN_BYTES = 24
7
+ const SESSION_TTL_MS = 2 * 60 * 60 * 1000
8
+ const IDLE_STOP_MS = 30 * 60 * 1000
9
+
10
+ interface EditSession {
11
+ token: string
12
+ client: any
13
+ sessionID: string
14
+ deck: string
15
+ file: string
16
+ absoluteFile: string
17
+ createdAt: number
18
+ lastActiveAt: number
19
+ }
20
+
21
+ export interface EditServerHandle {
22
+ baseUrl: string
23
+ createSession(input: { client: any; sessionID: string; deck: EditableDeck }): string
24
+ }
25
+
26
+ let server: ReturnType<typeof Bun.serve> | undefined
27
+ let baseUrl = ""
28
+ let idleTimer: Timer | undefined
29
+ const sessions = new Map<string, EditSession>()
30
+
31
+ export function startEditServer(): EditServerHandle {
32
+ if (!server) {
33
+ server = Bun.serve({
34
+ hostname: "127.0.0.1",
35
+ port: 0,
36
+ fetch: handleRequest,
37
+ })
38
+ baseUrl = `http://127.0.0.1:${server.port}`
39
+ scheduleIdleStop()
40
+ }
41
+
42
+ return {
43
+ baseUrl,
44
+ createSession(input) {
45
+ cleanupExpiredSessions()
46
+ const token = randomBytes(TOKEN_BYTES).toString("base64url")
47
+ sessions.set(token, {
48
+ token,
49
+ client: input.client,
50
+ sessionID: input.sessionID,
51
+ deck: input.deck.slug,
52
+ file: input.deck.file,
53
+ absoluteFile: input.deck.absoluteFile,
54
+ createdAt: Date.now(),
55
+ lastActiveAt: Date.now(),
56
+ })
57
+ return token
58
+ },
59
+ }
60
+ }
61
+
62
+ async function handleRequest(req: Request): Promise<Response> {
63
+ cleanupExpiredSessions()
64
+ const url = new URL(req.url)
65
+
66
+ if (url.pathname === "/health") return textResponse("ok")
67
+
68
+ if (url.pathname === "/edit" && req.method === "GET") {
69
+ const session = validateSession(url.searchParams.get("token"))
70
+ if (!session.ok) return session.response
71
+ return htmlResponse(renderEditorShell(session.value.token))
72
+ }
73
+
74
+ if (url.pathname === "/deck" && req.method === "GET") {
75
+ const session = validateSession(url.searchParams.get("token"))
76
+ if (!session.ok) return session.response
77
+ return htmlResponse(readFileSync(session.value.absoluteFile, "utf-8"))
78
+ }
79
+
80
+ if (url.pathname === "/api/comment" && req.method === "POST") {
81
+ const session = validateSession(url.searchParams.get("token"))
82
+ if (!session.ok) return session.response
83
+ return handleComment(req, session.value)
84
+ }
85
+
86
+ if (url.pathname === "/api/deck-version" && req.method === "GET") {
87
+ const session = validateSession(url.searchParams.get("token"))
88
+ if (!session.ok) return session.response
89
+ return handleDeckVersion(session.value)
90
+ }
91
+
92
+ return textResponse("Not found", 404)
93
+ }
94
+
95
+ function handleDeckVersion(session: EditSession): Response {
96
+ try {
97
+ const stat = statSync(session.absoluteFile)
98
+ session.lastActiveAt = Date.now()
99
+ scheduleIdleStop()
100
+ return jsonResponse({ ok: true, mtimeMs: stat.mtimeMs, size: stat.size })
101
+ } catch (error) {
102
+ const message = error instanceof Error ? error.message : String(error)
103
+ return jsonResponse({ ok: false, error: message }, 404)
104
+ }
105
+ }
106
+
107
+ async function handleComment(req: Request, session: EditSession): Promise<Response> {
108
+ let body: Partial<EditCommentPayload>
109
+ try {
110
+ body = await req.json()
111
+ } catch {
112
+ return jsonResponse({ ok: false, error: "Invalid JSON body" }, 400)
113
+ }
114
+
115
+ const comments = Array.isArray(body.comments)
116
+ ? body.comments
117
+ .map((draft: any) => ({
118
+ comment: typeof draft?.comment === "string" ? draft.comment.trim() : "",
119
+ elements: Array.isArray(draft?.elements) ? draft.elements : [],
120
+ }))
121
+ .filter((draft) => draft.comment && draft.elements.length > 0)
122
+ : []
123
+ const comment = typeof body.comment === "string" ? body.comment.trim() : ""
124
+ const elements = Array.isArray(body.elements) ? body.elements : []
125
+ if (!comment && comments.length === 0) return jsonResponse({ ok: false, error: "Comment is required" }, 400)
126
+
127
+ const prompt = buildEditPrompt({
128
+ ...body,
129
+ deck: session.deck,
130
+ file: session.file,
131
+ comment,
132
+ elements,
133
+ comments,
134
+ })
135
+
136
+ await session.client.session.prompt({
137
+ path: { id: session.sessionID },
138
+ body: {
139
+ parts: [{ type: "text", text: prompt }],
140
+ },
141
+ })
142
+
143
+ session.lastActiveAt = Date.now()
144
+ scheduleIdleStop()
145
+ return jsonResponse({ ok: true })
146
+ }
147
+
148
+ function validateSession(token: string | null): { ok: true; value: EditSession } | { ok: false; response: Response } {
149
+ if (!token) return { ok: false, response: textResponse("Missing token", 401) }
150
+ const session = sessions.get(token)
151
+ if (!session) return { ok: false, response: textResponse("Invalid or expired token", 401) }
152
+ if (Date.now() - session.createdAt > SESSION_TTL_MS) {
153
+ sessions.delete(token)
154
+ return { ok: false, response: textResponse("Expired token", 401) }
155
+ }
156
+ session.lastActiveAt = Date.now()
157
+ return { ok: true, value: session }
158
+ }
159
+
160
+ function cleanupExpiredSessions(): void {
161
+ const now = Date.now()
162
+ for (const [token, session] of sessions) {
163
+ if (now - session.createdAt > SESSION_TTL_MS) sessions.delete(token)
164
+ }
165
+ }
166
+
167
+ function scheduleIdleStop(): void {
168
+ if (idleTimer) clearTimeout(idleTimer)
169
+ idleTimer = setTimeout(() => {
170
+ const now = Date.now()
171
+ const active = [...sessions.values()].some((session) => now - session.lastActiveAt < IDLE_STOP_MS)
172
+ if (active) {
173
+ scheduleIdleStop()
174
+ return
175
+ }
176
+ sessions.clear()
177
+ server?.stop()
178
+ server = undefined
179
+ baseUrl = ""
180
+ idleTimer = undefined
181
+ }, IDLE_STOP_MS)
182
+ }
183
+
184
+ function htmlResponse(body: string, status = 200): Response {
185
+ return new Response(body, {
186
+ status,
187
+ headers: {
188
+ "content-type": "text/html; charset=utf-8",
189
+ "cache-control": "no-store, max-age=0",
190
+ },
191
+ })
192
+ }
193
+
194
+ function textResponse(body: string, status = 200): Response {
195
+ return new Response(body, {
196
+ status,
197
+ headers: { "content-type": "text/plain; charset=utf-8" },
198
+ })
199
+ }
200
+
201
+ function jsonResponse(body: unknown, status = 200): Response {
202
+ return new Response(JSON.stringify(body), {
203
+ status,
204
+ headers: { "content-type": "application/json; charset=utf-8" },
205
+ })
206
+ }
207
+
208
+ function renderEditorShell(token: string): string {
209
+ const encodedToken = JSON.stringify(token)
210
+ return `<!doctype html>
211
+ <html lang="en">
212
+ <head>
213
+ <meta charset="utf-8" />
214
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
215
+ <title>Revela Edit</title>
216
+ <style>
217
+ :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
218
+ * { box-sizing: border-box; }
219
+ body { margin: 0; background: #0d1117; color: #f0f6fc; height: 100vh; overflow: hidden; }
220
+ .app { display: grid; grid-template-columns: minmax(0, 1fr) 380px; height: 100vh; }
221
+ .preview { position: relative; min-width: 0; background: #05070a; border-right: 1px solid #30363d; }
222
+ iframe { display: block; width: 100%; height: 100%; border: 0; background: white; }
223
+ .hitbox { position: absolute; inset: 0; z-index: 2; cursor: crosshair; background: transparent; }
224
+ aside { display: flex; flex-direction: column; gap: 16px; padding: 18px; background: #111827; }
225
+ h1 { margin: 0; font-size: 18px; line-height: 1.2; }
226
+ .hint { margin: 0; color: #9ca3af; font-size: 13px; line-height: 1.5; }
227
+ .panel { display: flex; flex-direction: column; gap: 10px; }
228
+ .label { color: #9ca3af; font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
229
+ .comment-editor { width: 100%; min-height: 160px; max-height: 42vh; overflow: auto; padding: 12px; border: 1px solid #374151; border-radius: 12px; background: #020617; color: #f8fafc; font: inherit; line-height: 1.5; outline: none; white-space: pre-wrap; }
230
+ .comment-editor:focus { border-color: #38bdf8; box-shadow: 0 0 0 2px rgba(56,189,248,.18); }
231
+ .comment-editor:empty::before { content: attr(data-placeholder); color: #64748b; pointer-events: none; }
232
+ .ref-chip { display: inline-flex; align-items: center; margin: 0 2px; padding: 1px 6px; border-radius: 999px; background: rgba(56,189,248,.18); color: #bae6fd; border: 1px solid rgba(56,189,248,.45); font-weight: 700; white-space: nowrap; }
233
+ .comment-thread { display: flex; flex-direction: column; gap: 10px; max-height: 30vh; overflow: auto; }
234
+ .comment-bubble { border: 1px solid #374151; border-radius: 14px; padding: 10px 12px; background: #0f172a; color: #e5e7eb; font-size: 13px; line-height: 1.45; }
235
+ .comment-bubble.sending { border-color: rgba(56,189,248,.5); background: rgba(14,116,144,.14); }
236
+ .comment-bubble.done { border-color: rgba(34,197,94,.55); background: rgba(22,101,52,.18); }
237
+ .comment-bubble.failed { border-color: rgba(248,113,113,.65); background: rgba(127,29,29,.2); }
238
+ .comment-bubble-text { white-space: pre-wrap; overflow-wrap: anywhere; }
239
+ .comment-bubble-state { margin-top: 8px; color: #93c5fd; font-size: 12px; font-weight: 700; }
240
+ .comment-bubble.done .comment-bubble-state { color: #86efac; }
241
+ .comment-bubble.failed .comment-bubble-state { color: #fca5a5; }
242
+ button { width: 100%; padding: 12px 14px; border: 0; border-radius: 12px; background: #38bdf8; color: #04111d; font-weight: 700; cursor: pointer; }
243
+ button:disabled { cursor: not-allowed; opacity: .5; }
244
+ .status { min-height: 20px; color: #93c5fd; font-size: 13px; }
245
+ @media (max-width: 900px) { .app { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; } aside { max-height: 48vh; } }
246
+ </style>
247
+ </head>
248
+ <body>
249
+ <main class="app">
250
+ <section class="preview"><iframe id="deck" src="/deck?token=${encodeURIComponent(token)}"></iframe><div id="hitbox" class="hitbox" aria-label="Deck element selection layer"></div></section>
251
+ <aside>
252
+ <div>
253
+ <h1>Revela Visual Edit</h1>
254
+ <p class="hint">Write one comment. Use Ctrl/Cmd + click on deck elements to insert precise references into the comment.</p>
255
+ </div>
256
+ <div class="panel">
257
+ <div class="label">Comment</div>
258
+ <div id="comment" class="comment-editor" contenteditable="true" role="textbox" aria-multiline="true" data-placeholder="Example: Align @Metric 1 with @Metric 2, and remove @Text block 3."></div>
259
+ </div>
260
+ <div id="commentThread" class="comment-thread" aria-live="polite"></div>
261
+ <button id="send" disabled>Send comments</button>
262
+ <div id="status" class="status"></div>
263
+ </aside>
264
+ </main>
265
+ <script>
266
+ (() => {
267
+ const token = ${encodedToken};
268
+ const state = {
269
+ references: [],
270
+ pendingComments: [],
271
+ hoverEl: null,
272
+ hoverOutline: null,
273
+ referenceOutlines: [],
274
+ nextReferenceId: 1,
275
+ nextCommentId: 1,
276
+ initializedDoc: null,
277
+ deckVersion: null,
278
+ pendingRefreshMessage: false,
279
+ bound: false,
280
+ commentRange: null,
281
+ };
282
+ const els = {
283
+ frame: null,
284
+ hitbox: null,
285
+ comment: null,
286
+ commentThread: null,
287
+ send: null,
288
+ status: null,
289
+ };
290
+
291
+ window.addEventListener('error', (event) => reportError(event.error || event.message));
292
+ window.addEventListener('unhandledrejection', (event) => reportError(event.reason));
293
+
294
+ if (document.readyState === 'loading') {
295
+ document.addEventListener('DOMContentLoaded', boot, { once: true });
296
+ } else {
297
+ boot();
298
+ }
299
+
300
+ function boot() {
301
+ try {
302
+ els.frame = document.getElementById('deck');
303
+ els.hitbox = document.getElementById('hitbox');
304
+ els.comment = document.getElementById('comment');
305
+ els.commentThread = document.getElementById('commentThread');
306
+ els.send = document.getElementById('send');
307
+ els.status = document.getElementById('status');
308
+
309
+ if (!els.frame || !els.hitbox || !els.comment || !els.commentThread || !els.send || !els.status) {
310
+ throw new Error('Editor boot failed: required DOM nodes are missing.');
311
+ }
312
+
313
+ bindEvents();
314
+ setStatus('Editor ready. Ctrl/Cmd + click deck elements to reference them.');
315
+ initFrame();
316
+ startDeckVersionPolling();
317
+ } catch (error) {
318
+ reportError(error);
319
+ }
320
+ }
321
+
322
+ function bindEvents() {
323
+ if (state.bound) return;
324
+ state.bound = true;
325
+ els.frame.addEventListener('load', initFrame);
326
+ document.addEventListener('keydown', (event) => {
327
+ if (event.key === 'Escape') clearHover();
328
+ });
329
+ els.comment.addEventListener('input', () => {
330
+ saveCommentRange();
331
+ syncReferencesFromComment(false);
332
+ updateSendState();
333
+ });
334
+ els.comment.addEventListener('keyup', saveCommentRange);
335
+ els.comment.addEventListener('mouseup', saveCommentRange);
336
+ document.addEventListener('selectionchange', saveCommentRange);
337
+ els.hitbox.addEventListener('pointermove', onHover);
338
+ els.hitbox.addEventListener('pointerdown', onPointerDown);
339
+ els.hitbox.addEventListener('click', onClick);
340
+ els.hitbox.addEventListener('contextmenu', (event) => {
341
+ if (event.ctrlKey || event.metaKey) event.preventDefault();
342
+ });
343
+ els.hitbox.addEventListener('wheel', (event) => {
344
+ const win = els.frame.contentWindow;
345
+ if (!win) return;
346
+ event.preventDefault();
347
+ win.scrollBy({ top: event.deltaY, left: event.deltaX, behavior: 'auto' });
348
+ renderHoverOutline(state.hoverEl);
349
+ renderReferenceOutlines();
350
+ }, { passive: false });
351
+ els.send.addEventListener('click', sendComment);
352
+ }
353
+
354
+ function initFrame() {
355
+ try {
356
+ const doc = els.frame.contentDocument;
357
+ if (!doc) {
358
+ setStatus('Unable to access deck iframe.');
359
+ return;
360
+ }
361
+ if (doc === state.initializedDoc) return;
362
+ if (doc.location.href === 'about:blank') return;
363
+ if (doc.readyState === 'loading') return;
364
+ state.initializedDoc = doc;
365
+ clearReferences(false);
366
+ state.hoverEl = null;
367
+ state.hoverOutline = createOutline(doc, '#38bdf8', 'rgba(56,189,248,.12)');
368
+ state.referenceOutlines = [];
369
+ doc.addEventListener('scroll', () => {
370
+ renderHoverOutline(state.hoverEl);
371
+ renderReferenceOutlines();
372
+ }, true);
373
+ const slides = getSlides(doc);
374
+ updateSendState();
375
+ if (state.pendingRefreshMessage) {
376
+ state.pendingRefreshMessage = false;
377
+ markPendingCommentsDone();
378
+ setStatus('Deck updated. Preview refreshed. Element references were cleared.');
379
+ } else {
380
+ setStatus(slides.length > 0 ? 'Editor ready. Found ' + slides.length + ' slides. Ctrl/Cmd + click to reference elements.' : 'Editor ready, but no .slide elements were found. Ctrl/Cmd + click to reference elements.');
381
+ }
382
+ } catch (error) {
383
+ reportError(error);
384
+ }
385
+ }
386
+
387
+ function startDeckVersionPolling() {
388
+ pollDeckVersion();
389
+ window.setInterval(pollDeckVersion, 2000);
390
+ }
391
+
392
+ async function pollDeckVersion() {
393
+ try {
394
+ const res = await fetch('/api/deck-version?token=' + encodeURIComponent(token), { cache: 'no-store' });
395
+ const body = await res.json().catch(() => ({}));
396
+ if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to check deck version');
397
+ const nextVersion = String(body.mtimeMs) + ':' + String(body.size);
398
+ if (!state.deckVersion) {
399
+ state.deckVersion = nextVersion;
400
+ return;
401
+ }
402
+ if (state.deckVersion === nextVersion) return;
403
+ state.deckVersion = nextVersion;
404
+ refreshDeckPreview(body.mtimeMs);
405
+ } catch (error) {
406
+ reportError(error);
407
+ }
408
+ }
409
+
410
+ function refreshDeckPreview(version) {
411
+ state.pendingRefreshMessage = true;
412
+ state.initializedDoc = null;
413
+ clearReferences(true);
414
+ state.hoverEl = null;
415
+ if (state.hoverOutline) state.hoverOutline.style.display = 'none';
416
+ state.referenceOutlines.forEach((outline) => outline.style.display = 'none');
417
+ state.referenceOutlines = [];
418
+ updateSendState();
419
+ els.frame.src = '/deck?token=' + encodeURIComponent(token) + '&v=' + encodeURIComponent(String(version));
420
+ setStatus('Deck changed. Refreshing preview...');
421
+ }
422
+
423
+ function onHover(event) {
424
+ try {
425
+ initFrame();
426
+ const target = selectable(targetFromPointer(event));
427
+ if (!target || isReferenced(target)) {
428
+ renderHoverOutline(null);
429
+ return;
430
+ }
431
+ state.hoverEl = target;
432
+ renderHoverOutline(target);
433
+ } catch (error) {
434
+ reportError(error);
435
+ }
436
+ }
437
+
438
+ function onClick(event) {
439
+ try {
440
+ initFrame();
441
+ const target = selectable(targetFromPointer(event));
442
+ if (event.ctrlKey || event.metaKey) {
443
+ event.preventDefault();
444
+ event.stopPropagation();
445
+ return;
446
+ } else if (target) {
447
+ setStatus('Use Ctrl/Cmd + click to reference this element in your comment.');
448
+ }
449
+ } catch (error) {
450
+ reportError(error);
451
+ }
452
+ }
453
+
454
+ function onPointerDown(event) {
455
+ if (!event.ctrlKey && !event.metaKey) return;
456
+ try {
457
+ initFrame();
458
+ event.preventDefault();
459
+ event.stopPropagation();
460
+ toggleReference(selectable(targetFromPointer(event)));
461
+ } catch (error) {
462
+ reportError(error);
463
+ }
464
+ }
465
+
466
+ async function sendComment() {
467
+ syncReferencesFromComment(false);
468
+ const text = getCommentText().trim();
469
+ if (!text) return;
470
+ const elements = state.references.map((reference) => reference.payload);
471
+ const commentId = addPendingComment(text, elements, 'sending');
472
+ clearReferences(false);
473
+ els.comment.textContent = '';
474
+ renderReferenceOutlines();
475
+ els.send.disabled = true;
476
+ setStatus('Sending...');
477
+ try {
478
+ const res = await fetch('/api/comment?token=' + encodeURIComponent(token), {
479
+ method: 'POST',
480
+ headers: { 'content-type': 'application/json' },
481
+ body: JSON.stringify({ comment: text, elements }),
482
+ });
483
+ const body = await res.json().catch(() => ({}));
484
+ if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to send comment');
485
+ updatePendingCommentStatus(commentId, 'sent');
486
+ setStatus('Comment sent. Waiting for deck update...');
487
+ updateSendState();
488
+ } catch (error) {
489
+ updatePendingCommentStatus(commentId, 'failed');
490
+ reportError(error);
491
+ updateSendState();
492
+ }
493
+ }
494
+
495
+ function selectable(node) {
496
+ if (!node || node.nodeType !== 1) return null;
497
+ if (node === state.hoverOutline || state.referenceOutlines.includes(node)) return null;
498
+ return findSlide(node) ? node : null;
499
+ }
500
+
501
+ function toggleReference(target) {
502
+ if (!target) {
503
+ setStatus('No selectable deck element found under pointer.');
504
+ return;
505
+ }
506
+ const existing = findReferenceIndex(target);
507
+ if (existing >= 0) {
508
+ const label = state.references[existing].label;
509
+ removeReferenceAt(existing, true);
510
+ setStatus('Removed @' + label + '.');
511
+ return;
512
+ }
513
+ const payload = collectPayload(target);
514
+ const id = 'ref-' + state.nextReferenceId++;
515
+ const label = nextReferenceLabel(payload);
516
+ const reference = { id, target, label, payload };
517
+ state.references.push(reference);
518
+ insertReferenceChip(reference);
519
+ renderReferenceOutlines();
520
+ updateSendState();
521
+ setStatus('Inserted @' + label + '. ' + state.references.length + ' reference' + (state.references.length === 1 ? '' : 's') + ' will be sent.');
522
+ }
523
+
524
+ function isReferenced(target) {
525
+ return findReferenceIndex(target) >= 0;
526
+ }
527
+
528
+ function findReferenceIndex(target) {
529
+ return state.references.findIndex((reference) => reference.target === target);
530
+ }
531
+
532
+ function removeReferenceAt(index, removeToken) {
533
+ const reference = state.references[index];
534
+ if (!reference) return;
535
+ state.references.splice(index, 1);
536
+ if (removeToken) removeReferenceChip(reference.id);
537
+ renderReferenceOutlines();
538
+ updateSendState();
539
+ }
540
+
541
+ function syncReferencesFromComment(showStatus) {
542
+ const activeIds = new Set(Array.from(els.comment.querySelectorAll('.ref-chip[data-ref-id]')).map((chip) => chip.getAttribute('data-ref-id')));
543
+ const before = state.references.length;
544
+ state.references = state.references.filter((reference) => activeIds.has(reference.id));
545
+ if (state.references.length !== before) {
546
+ renderReferenceOutlines();
547
+ if (showStatus) setStatus('References synced with comment text.');
548
+ }
549
+ }
550
+
551
+ function addPendingComment(text, elements, status) {
552
+ const id = 'comment-' + state.nextCommentId++;
553
+ state.pendingComments.push({
554
+ id,
555
+ text,
556
+ elements,
557
+ status,
558
+ });
559
+ renderCommentThread();
560
+ return id;
561
+ }
562
+
563
+ function updatePendingCommentStatus(id, status) {
564
+ const comment = state.pendingComments.find((item) => item.id === id);
565
+ if (!comment) return;
566
+ comment.status = status;
567
+ renderCommentThread();
568
+ }
569
+
570
+ function markPendingCommentsDone() {
571
+ let changed = false;
572
+ state.pendingComments.forEach((comment) => {
573
+ if (comment.status === 'sent' || comment.status === 'sending') {
574
+ comment.status = 'done';
575
+ changed = true;
576
+ }
577
+ });
578
+ if (changed) renderCommentThread();
579
+ }
580
+
581
+ function renderCommentThread() {
582
+ els.commentThread.textContent = '';
583
+ state.pendingComments.forEach((comment) => {
584
+ const bubble = document.createElement('div');
585
+ bubble.className = 'comment-bubble ' + comment.status;
586
+
587
+ const text = document.createElement('div');
588
+ text.className = 'comment-bubble-text';
589
+ text.textContent = comment.text;
590
+
591
+ const status = document.createElement('div');
592
+ status.className = 'comment-bubble-state';
593
+ status.textContent = commentStatusLabel(comment.status);
594
+
595
+ bubble.appendChild(text);
596
+ bubble.appendChild(status);
597
+ els.commentThread.appendChild(bubble);
598
+ });
599
+ }
600
+
601
+ function commentStatusLabel(status) {
602
+ if (status === 'done') return '✅ Applied';
603
+ if (status === 'failed') return 'Failed to send';
604
+ if (status === 'sending') return 'Sending to OpenCode...';
605
+ return '⏳ Sent to OpenCode';
606
+ }
607
+
608
+ function targetFromPointer(event) {
609
+ const doc = els.frame.contentDocument;
610
+ if (!doc || doc.location.href === 'about:blank') return null;
611
+ const frameRect = els.frame.getBoundingClientRect();
612
+ const x = event.clientX - frameRect.left;
613
+ const y = event.clientY - frameRect.top;
614
+ if (x < 0 || y < 0 || x > frameRect.width || y > frameRect.height) return null;
615
+ return doc.elementFromPoint(x, y);
616
+ }
617
+
618
+ function createOutline(doc, border, fill) {
619
+ const outline = doc.createElement('div');
620
+ outline.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;border:2px solid ' + border + ';background:' + fill + ';border-radius:6px;display:none;';
621
+ doc.body.appendChild(outline);
622
+ return outline;
623
+ }
624
+
625
+ function renderBox(outline, target) {
626
+ if (!outline || !target || !target.getBoundingClientRect) {
627
+ if (outline) outline.style.display = 'none';
628
+ return;
629
+ }
630
+ const rect = target.getBoundingClientRect();
631
+ outline.style.display = 'block';
632
+ outline.style.left = rect.left + 'px';
633
+ outline.style.top = rect.top + 'px';
634
+ outline.style.width = rect.width + 'px';
635
+ outline.style.height = rect.height + 'px';
636
+ }
637
+
638
+ function renderHoverOutline(target) {
639
+ renderBox(state.hoverOutline, target);
640
+ }
641
+
642
+ function renderReferenceOutlines() {
643
+ const doc = els.frame.contentDocument;
644
+ if (!doc || doc.location.href === 'about:blank') return;
645
+ while (state.referenceOutlines.length < state.references.length) state.referenceOutlines.push(createOutline(doc, '#f59e0b', 'rgba(245,158,11,.16)'));
646
+ state.referenceOutlines.forEach((outline, index) => renderBox(outline, state.references[index]?.target));
647
+ }
648
+
649
+ function clearHover() {
650
+ state.hoverEl = null;
651
+ setStatus('Hover cleared. Existing references are kept.');
652
+ if (state.hoverOutline) state.hoverOutline.style.display = 'none';
653
+ }
654
+
655
+ function updateSendState() {
656
+ els.send.disabled = !getCommentText().trim();
657
+ }
658
+
659
+ function nextReferenceLabel(payload) {
660
+ return humanElementName(payload) + ' ' + (state.references.length + 1);
661
+ }
662
+
663
+ function insertReferenceChip(reference) {
664
+ const chip = document.createElement('span');
665
+ chip.className = 'ref-chip';
666
+ chip.contentEditable = 'false';
667
+ chip.dataset.refId = reference.id;
668
+ chip.textContent = '@' + reference.label;
669
+ const trailingSpace = document.createTextNode(' ');
670
+ const range = getCommentInsertRange();
671
+ if (range) {
672
+ range.insertNode(trailingSpace);
673
+ range.insertNode(chip);
674
+ range.setStartAfter(trailingSpace);
675
+ range.collapse(true);
676
+ applyCommentRange(range);
677
+ } else {
678
+ if (els.comment.textContent && !/\\s$/.test(els.comment.textContent)) els.comment.appendChild(document.createTextNode(' '));
679
+ els.comment.appendChild(chip);
680
+ els.comment.appendChild(trailingSpace);
681
+ placeCaretAfter(trailingSpace);
682
+ }
683
+ els.comment.focus();
684
+ }
685
+
686
+ function removeReferenceChip(id) {
687
+ const chip = els.comment.querySelector('.ref-chip[data-ref-id="' + cssEscape(id) + '"]');
688
+ if (!chip) return;
689
+ const next = chip.nextSibling;
690
+ chip.remove();
691
+ if (next && next.nodeType === Node.TEXT_NODE && next.textContent === ' ') next.remove();
692
+ }
693
+
694
+ function clearReferences(removeChips) {
695
+ state.references = [];
696
+ if (removeChips) els.comment.querySelectorAll('.ref-chip').forEach((chip) => chip.remove());
697
+ }
698
+
699
+ function getCommentText() {
700
+ return (els.comment.innerText || els.comment.textContent || '').replace(/\\u00a0/g, ' ');
701
+ }
702
+
703
+ function placeCaretAfter(node) {
704
+ const range = document.createRange();
705
+ range.setStartAfter(node);
706
+ range.collapse(true);
707
+ applyCommentRange(range);
708
+ }
709
+
710
+ function saveCommentRange() {
711
+ const selection = window.getSelection();
712
+ if (!selection || selection.rangeCount === 0) return;
713
+ if (!els.comment || !els.comment.contains(selection.anchorNode)) return;
714
+ state.commentRange = selection.getRangeAt(0).cloneRange();
715
+ }
716
+
717
+ function getCommentInsertRange() {
718
+ const selection = window.getSelection();
719
+ if (selection && selection.rangeCount > 0 && els.comment.contains(selection.anchorNode)) {
720
+ const range = selection.getRangeAt(0).cloneRange();
721
+ range.deleteContents();
722
+ return range;
723
+ }
724
+ if (state.commentRange && els.comment.contains(state.commentRange.commonAncestorContainer)) {
725
+ const range = state.commentRange.cloneRange();
726
+ range.deleteContents();
727
+ return range;
728
+ }
729
+ return null;
730
+ }
731
+
732
+ function applyCommentRange(range) {
733
+ const selection = window.getSelection();
734
+ if (!selection) return;
735
+ selection.removeAllRanges();
736
+ selection.addRange(range);
737
+ state.commentRange = range.cloneRange();
738
+ }
739
+
740
+ function humanElementName(payload) {
741
+ const tag = payload.tagName || 'element';
742
+ const classes = payload.classList || [];
743
+ if (/^h[1-6]$/.test(tag)) return 'Heading';
744
+ if (tag === 'p') return 'Text block';
745
+ if (classes.some((name) => /card/i.test(name))) return 'Card';
746
+ if (classes.some((name) => /stat|metric|value/i.test(name))) return 'Metric';
747
+ if (tag === 'img' || tag === 'svg') return 'Visual';
748
+ return 'Element';
749
+ }
750
+
751
+ function collectPayload(el) {
752
+ const doc = els.frame.contentDocument;
753
+ const slides = getSlides(doc);
754
+ const slide = findSlide(el);
755
+ const rect = el.getBoundingClientRect();
756
+ const slideIndex = slide ? slides.indexOf(slide) + 1 : undefined;
757
+ const win = els.frame.contentWindow;
758
+ return {
759
+ slideIndex,
760
+ slideTitle: slide ? ((slide.querySelector('h1,h2,h3,[data-title]') || {}).textContent || '').trim().slice(0, 160) : undefined,
761
+ selector: buildSelector(el, slide),
762
+ domPath: buildDomPath(el, slide),
763
+ tagName: el.tagName.toLowerCase(),
764
+ id: el.id || undefined,
765
+ classList: Array.from(el.classList || []),
766
+ text: (el.innerText || el.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 600),
767
+ outerHTMLExcerpt: (el.outerHTML || '').replace(/\\s+/g, ' ').slice(0, 1200),
768
+ nearbyText: slide ? (slide.innerText || slide.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 1200) : undefined,
769
+ boundingBox: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
770
+ viewport: { width: win ? win.innerWidth : undefined, height: win ? win.innerHeight : undefined },
771
+ };
772
+ }
773
+
774
+ function buildSelector(el, slide) {
775
+ if (el.id) return '#' + cssEscape(el.id);
776
+ const parts = [];
777
+ let node = el;
778
+ while (node && node.nodeType === 1 && node !== slide) {
779
+ let part = node.tagName.toLowerCase();
780
+ const stable = Array.from(node.attributes || []).find((attr) => attr.name.startsWith('data-'));
781
+ if (stable) {
782
+ part += '[' + stable.name + '="' + stable.value.replace(/"/g, '\\"') + '"]';
783
+ parts.unshift(part);
784
+ break;
785
+ }
786
+ const classes = Array.from(node.classList || []).slice(0, 2).map(cssEscape);
787
+ if (classes.length) part += '.' + classes.join('.');
788
+ const siblings = Array.from(node.parentElement ? node.parentElement.children : []).filter((child) => child.tagName === node.tagName);
789
+ if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
790
+ parts.unshift(part);
791
+ node = node.parentElement;
792
+ }
793
+ const slidePart = slide ? slideSelector(slide) : '.slide';
794
+ return [slidePart].concat(parts).join(' > ');
795
+ }
796
+
797
+ function findSlide(node) {
798
+ return node.closest('.slide, [slide-qa], .slide-canvas, .page');
799
+ }
800
+
801
+ function getSlides(doc) {
802
+ const slides = Array.from(doc.querySelectorAll('.slide'));
803
+ if (slides.length) return slides;
804
+ const qaSlides = Array.from(doc.querySelectorAll('[slide-qa]'));
805
+ if (qaSlides.length) return qaSlides;
806
+ const canvases = Array.from(doc.querySelectorAll('.slide-canvas'));
807
+ if (canvases.length) return canvases;
808
+ return Array.from(doc.querySelectorAll('.page'));
809
+ }
810
+
811
+ function slideSelector(slide) {
812
+ if (slide.id) return '#' + cssEscape(slide.id);
813
+ const doc = els.frame.contentDocument;
814
+ const slides = getSlides(doc);
815
+ const index = slides.indexOf(slide) + 1;
816
+ if (slide.classList && slide.classList.contains('slide')) return '.slide:nth-of-type(' + index + ')';
817
+ if (slide.hasAttribute && slide.hasAttribute('slide-qa')) return '[slide-qa]:nth-of-type(' + index + ')';
818
+ if (slide.classList && slide.classList.contains('slide-canvas')) return '.slide-canvas:nth-of-type(' + index + ')';
819
+ return '.page:nth-of-type(' + index + ')';
820
+ }
821
+
822
+ function buildDomPath(el, stop) {
823
+ const parts = [];
824
+ let node = el;
825
+ while (node && node.nodeType === 1 && node !== stop) {
826
+ const siblings = Array.from(node.parentElement ? node.parentElement.children : []);
827
+ parts.unshift(node.tagName.toLowerCase() + '[' + (siblings.indexOf(node) + 1) + ']');
828
+ node = node.parentElement;
829
+ }
830
+ return parts.join(' > ');
831
+ }
832
+
833
+ function cssEscape(value) {
834
+ if (window.CSS && CSS.escape) return CSS.escape(value);
835
+ return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&');
836
+ }
837
+
838
+ function setStatus(message) {
839
+ if (els.status) els.status.textContent = message;
840
+ }
841
+
842
+ function reportError(error) {
843
+ const message = error && error.message ? error.message : String(error);
844
+ setStatus('Editor error: ' + message);
845
+ console.error('[Revela edit]', error);
846
+ }
847
+ })();
848
+ </script>
849
+ </body>
850
+ </html>`
851
+ }