@dfosco/storyboard-core 1.1.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 (42) hide show
  1. package/package.json +18 -0
  2. package/src/comments/api.js +196 -0
  3. package/src/comments/api.test.js +194 -0
  4. package/src/comments/auth.js +79 -0
  5. package/src/comments/auth.test.js +60 -0
  6. package/src/comments/commentMode.js +63 -0
  7. package/src/comments/commentMode.test.js +87 -0
  8. package/src/comments/config.js +43 -0
  9. package/src/comments/config.test.js +76 -0
  10. package/src/comments/graphql.js +65 -0
  11. package/src/comments/graphql.test.js +95 -0
  12. package/src/comments/index.js +40 -0
  13. package/src/comments/metadata.js +52 -0
  14. package/src/comments/metadata.test.js +110 -0
  15. package/src/comments/queries.js +182 -0
  16. package/src/comments/ui/CommentOverlay.js +52 -0
  17. package/src/comments/ui/authModal.js +349 -0
  18. package/src/comments/ui/commentWindow.js +872 -0
  19. package/src/comments/ui/commentsDrawer.js +389 -0
  20. package/src/comments/ui/composer.js +248 -0
  21. package/src/comments/ui/mount.js +364 -0
  22. package/src/devtools.js +365 -0
  23. package/src/devtools.test.js +81 -0
  24. package/src/dotPath.js +53 -0
  25. package/src/dotPath.test.js +114 -0
  26. package/src/hashSubscribe.js +19 -0
  27. package/src/hashSubscribe.test.js +62 -0
  28. package/src/hideMode.js +421 -0
  29. package/src/hideMode.test.js +224 -0
  30. package/src/index.js +38 -0
  31. package/src/interceptHideParams.js +35 -0
  32. package/src/interceptHideParams.test.js +90 -0
  33. package/src/loader.js +212 -0
  34. package/src/loader.test.js +232 -0
  35. package/src/localStorage.js +134 -0
  36. package/src/localStorage.test.js +148 -0
  37. package/src/sceneDebug.js +108 -0
  38. package/src/sceneDebug.test.js +128 -0
  39. package/src/session.js +76 -0
  40. package/src/session.test.js +91 -0
  41. package/src/viewfinder.js +47 -0
  42. package/src/viewfinder.test.js +87 -0
@@ -0,0 +1,872 @@
1
+ /**
2
+ * Comment window β€” vanilla JS popup that shows a comment thread with replies and reactions.
3
+ *
4
+ * Opens when clicking a comment pin. Shows comment body, author, replies,
5
+ * reply input, reactions, and supports drag-to-move.
6
+ */
7
+
8
+ import { replyToComment, addReaction, removeReaction, moveComment, resolveComment, fetchRouteDiscussion } from '../api.js'
9
+ import { getCachedUser } from '../auth.js'
10
+
11
+ const STYLE_ID = 'sb-comment-window-style'
12
+
13
+ const REACTION_EMOJI = {
14
+ THUMBS_UP: 'πŸ‘',
15
+ THUMBS_DOWN: 'πŸ‘Ž',
16
+ LAUGH: 'πŸ˜„',
17
+ HOORAY: 'πŸŽ‰',
18
+ CONFUSED: 'πŸ˜•',
19
+ HEART: '❀️',
20
+ ROCKET: 'πŸš€',
21
+ EYES: 'πŸ‘€',
22
+ }
23
+
24
+ function injectStyles() {
25
+ if (document.getElementById(STYLE_ID)) return
26
+ const style = document.createElement('style')
27
+ style.id = STYLE_ID
28
+ style.textContent = `
29
+ .sb-comment-window {
30
+ position: absolute;
31
+ z-index: 100001;
32
+ width: 360px;
33
+ max-height: 480px;
34
+ display: flex;
35
+ flex-direction: column;
36
+ background: #161b22;
37
+ border: 1px solid #30363d;
38
+ border-radius: 10px;
39
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
40
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
41
+ overflow: hidden;
42
+ }
43
+
44
+ .sb-comment-window-header {
45
+ display: flex;
46
+ align-items: center;
47
+ justify-content: space-between;
48
+ padding: 10px 12px;
49
+ border-bottom: 1px solid #21262d;
50
+ cursor: grab;
51
+ user-select: none;
52
+ }
53
+ .sb-comment-window-header:active {
54
+ cursor: grabbing;
55
+ }
56
+
57
+ .sb-comment-window-header-left {
58
+ display: flex;
59
+ align-items: center;
60
+ gap: 8px;
61
+ }
62
+
63
+ .sb-comment-window-avatar {
64
+ width: 24px;
65
+ height: 24px;
66
+ border-radius: 50%;
67
+ border: 1px solid #30363d;
68
+ flex-shrink: 0;
69
+ }
70
+
71
+ .sb-comment-window-author {
72
+ font-size: 12px;
73
+ font-weight: 600;
74
+ color: #f0f6fc;
75
+ }
76
+
77
+ .sb-comment-window-time {
78
+ font-size: 11px;
79
+ color: #484f58;
80
+ margin-left: 4px;
81
+ }
82
+
83
+ .sb-comment-window-close {
84
+ display: flex;
85
+ align-items: center;
86
+ justify-content: center;
87
+ width: 24px;
88
+ height: 24px;
89
+ background: none;
90
+ border: none;
91
+ border-radius: 6px;
92
+ color: #8b949e;
93
+ cursor: pointer;
94
+ font-size: 16px;
95
+ line-height: 1;
96
+ flex-shrink: 0;
97
+ }
98
+ .sb-comment-window-close:hover {
99
+ background: #21262d;
100
+ color: #c9d1d9;
101
+ }
102
+
103
+ .sb-comment-window-header-actions {
104
+ display: flex;
105
+ align-items: center;
106
+ gap: 4px;
107
+ flex-shrink: 0;
108
+ }
109
+
110
+ .sb-comment-window-action-btn {
111
+ display: flex;
112
+ align-items: center;
113
+ justify-content: center;
114
+ padding: 8px;
115
+ background: none;
116
+ border: none;
117
+ border-radius: 6px;
118
+ color: #8b949e;
119
+ cursor: pointer;
120
+ font-size: 11px;
121
+ font-weight: 500;
122
+ font-family: inherit;
123
+ line-height: 1;
124
+ flex-shrink: 0;
125
+ white-space: nowrap;
126
+ }
127
+ .sb-comment-window-action-btn:hover {
128
+ background: #21262d;
129
+ color: #c9d1d9;
130
+ }
131
+ .sb-comment-window-action-btn[data-resolved="true"] {
132
+ color: #3fb950;
133
+ }
134
+ .sb-comment-window-action-btn[data-copied="true"] {
135
+ color: #3fb950;
136
+ }
137
+
138
+ .sb-comment-window-body {
139
+ flex: 1;
140
+ overflow-y: auto;
141
+ padding: 12px;
142
+ }
143
+
144
+ .sb-comment-window-text {
145
+ font-size: 13px;
146
+ line-height: 1.5;
147
+ color: #c9d1d9;
148
+ margin: 0 0 8px;
149
+ word-break: break-word;
150
+ }
151
+
152
+ .sb-comment-window-reactions {
153
+ display: flex;
154
+ align-items: center;
155
+ gap: 4px;
156
+ flex-wrap: wrap;
157
+ margin-bottom: 10px;
158
+ }
159
+
160
+ .sb-reaction-pill {
161
+ display: inline-flex;
162
+ align-items: center;
163
+ gap: 6px;
164
+ padding: 2px 8px;
165
+ border-radius: 999px;
166
+ border: 1px solid #30363d;
167
+ background: none;
168
+ color: #8b949e;
169
+ cursor: pointer;
170
+ // font-size: 12px;
171
+ font-family: inherit;
172
+ transition: border-color 100ms, background 100ms;
173
+ }
174
+ .sb-reaction-pill span {
175
+ // font-size: 12px;
176
+ }
177
+ .sb-reaction-pill:hover {
178
+ border-color: #8b949e;
179
+ }
180
+ .sb-reaction-pill[data-active="true"] {
181
+ border-color: rgba(88, 166, 255, 0.4);
182
+ background: rgba(88, 166, 255, 0.1);
183
+ color: #58a6ff;
184
+ }
185
+
186
+ .sb-reaction-add-btn {
187
+ display: inline-flex;
188
+ align-items: center;
189
+ padding: 2px 6px;
190
+ gap: 4px;
191
+ border-radius: 999px;
192
+ border: 1px solid transparent;
193
+ background: none;
194
+ color: #8b949e;
195
+ font-size: 12px;
196
+ cursor: pointer;
197
+ font-family: inherit;
198
+ position: relative;
199
+ border-color: #30363d;
200
+ background: #21262d;
201
+ }
202
+ .sb-reaction-add-btn:hover {
203
+ border: 1px solid rgba(88, 166, 255, 0.4);
204
+ background: rgba(88, 166, 255, 0.1);
205
+ }
206
+
207
+ .sb-reaction-picker {
208
+ position: absolute;
209
+ bottom: 100%;
210
+ left: 0;
211
+ margin-bottom: 4px;
212
+ z-index: 10;
213
+ display: flex;
214
+ gap: 2px;
215
+ padding: 4px;
216
+ background: #161b22;
217
+ border: 1px solid #30363d;
218
+ border-radius: 10px;
219
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
220
+ }
221
+
222
+ .sb-reaction-picker-btn {
223
+ display: flex;
224
+ align-items: center;
225
+ justify-content: center;
226
+ width: 28px;
227
+ height: 28px;
228
+ border-radius: 6px;
229
+ border: none;
230
+ background: none;
231
+ font-size: 14px;
232
+ cursor: pointer;
233
+ transition: background 100ms;
234
+ }
235
+ .sb-reaction-picker-btn:hover {
236
+ background: #21262d;
237
+ }
238
+ .sb-reaction-picker-btn[data-active="true"] {
239
+ background: rgba(88, 166, 255, 0.15);
240
+ box-shadow: inset 0 0 0 1px rgba(88, 166, 255, 0.4);
241
+ }
242
+
243
+ .sb-comment-window-replies {
244
+ border-top: 1px solid #21262d;
245
+ padding-top: 10px;
246
+ margin-top: 4px;
247
+ }
248
+
249
+ .sb-comment-window-replies-label {
250
+ font-size: 11px;
251
+ font-weight: 600;
252
+ color: #8b949e;
253
+ text-transform: uppercase;
254
+ letter-spacing: 0.5px;
255
+ margin-bottom: 8px;
256
+ }
257
+
258
+ .sb-reply-item {
259
+ display: flex;
260
+ gap: 8px;
261
+ margin-bottom: 10px;
262
+ }
263
+
264
+ .sb-reply-avatar {
265
+ width: 20px;
266
+ height: 20px;
267
+ border-radius: 50%;
268
+ border: 1px solid #30363d;
269
+ flex-shrink: 0;
270
+ }
271
+
272
+ .sb-reply-content {
273
+ flex: 1;
274
+ min-width: 0;
275
+ }
276
+
277
+ .sb-reply-meta {
278
+ display: flex;
279
+ align-items: center;
280
+ gap: 4px;
281
+ margin-bottom: 2px;
282
+ }
283
+
284
+ .sb-reply-author {
285
+ font-size: 12px;
286
+ font-weight: 600;
287
+ color: #f0f6fc;
288
+ }
289
+
290
+ .sb-reply-time {
291
+ font-size: 11px;
292
+ color: #484f58;
293
+ }
294
+
295
+ .sb-reply-text {
296
+ font-size: 13px;
297
+ line-height: 1.4;
298
+ color: #c9d1d9;
299
+ margin: 0;
300
+ word-break: break-word;
301
+ }
302
+
303
+ .sb-reply-reactions {
304
+ display: flex;
305
+ align-items: center;
306
+ gap: 4px;
307
+ flex-wrap: wrap;
308
+ margin-top: 4px;
309
+ }
310
+
311
+ .sb-comment-window-reply-form {
312
+ border-top: 1px solid #21262d;
313
+ padding: 10px 12px;
314
+ display: flex;
315
+ flex-direction: column;
316
+ gap: 6px;
317
+ }
318
+
319
+ .sb-reply-textarea {
320
+ width: 100%;
321
+ min-height: 40px;
322
+ max-height: 100px;
323
+ padding: 6px 8px;
324
+ background: #0d1117;
325
+ border: 1px solid #30363d;
326
+ border-radius: 6px;
327
+ color: #c9d1d9;
328
+ font-size: 12px;
329
+ font-family: inherit;
330
+ line-height: 1.4;
331
+ resize: vertical;
332
+ outline: none;
333
+ box-sizing: border-box;
334
+ }
335
+ .sb-reply-textarea:focus {
336
+ border-color: #58a6ff;
337
+ box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.15);
338
+ }
339
+ .sb-reply-textarea::placeholder {
340
+ color: #484f58;
341
+ }
342
+
343
+ .sb-reply-form-actions {
344
+ display: flex;
345
+ justify-content: flex-end;
346
+ }
347
+
348
+ .sb-reply-submit-btn {
349
+ padding: 4px 10px;
350
+ border-radius: 6px;
351
+ font-size: 12px;
352
+ font-weight: 500;
353
+ font-family: inherit;
354
+ cursor: pointer;
355
+ border: none;
356
+ background: #238636;
357
+ color: #fff;
358
+ }
359
+ .sb-reply-submit-btn:hover {
360
+ background: #2ea043;
361
+ }
362
+ .sb-reply-submit-btn:disabled {
363
+ opacity: 0.5;
364
+ cursor: not-allowed;
365
+ }
366
+ `
367
+ document.head.appendChild(style)
368
+ }
369
+
370
+ function timeAgo(dateStr) {
371
+ const d = new Date(dateStr)
372
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
373
+ }
374
+
375
+ /**
376
+ * Build a reaction bar for a comment or reply.
377
+ * @param {object} item - The comment/reply object with reactionGroups
378
+ * @returns {HTMLElement}
379
+ */
380
+ function buildReactionBar(item) {
381
+ const bar = document.createElement('div')
382
+ bar.className = item.replies ? 'sb-comment-window-reactions' : 'sb-reply-reactions'
383
+
384
+ function render() {
385
+ bar.innerHTML = ''
386
+ const groups = item.reactionGroups ?? []
387
+
388
+ for (const group of groups) {
389
+ if (group.users?.totalCount === 0 && !group.viewerHasReacted) continue
390
+ const count = group.users?.totalCount ?? 0
391
+ if (count === 0) continue
392
+
393
+ const pill = document.createElement('button')
394
+ pill.className = 'sb-reaction-pill'
395
+ pill.dataset.active = String(!!group.viewerHasReacted)
396
+ pill.innerHTML = `<span>${REACTION_EMOJI[group.content] ?? group.content}</span><span>${count}</span>`
397
+ pill.addEventListener('click', (e) => {
398
+ e.stopPropagation()
399
+ toggleReaction(item, group.content, group, render)
400
+ })
401
+ bar.appendChild(pill)
402
+ }
403
+
404
+ // Add reaction button
405
+ const addBtn = document.createElement('button')
406
+ addBtn.className = 'sb-reaction-add-btn'
407
+ addBtn.textContent = 'πŸ˜€ +'
408
+ addBtn.addEventListener('click', (e) => {
409
+ e.stopPropagation()
410
+ showPicker(addBtn, item, render)
411
+ })
412
+ bar.appendChild(addBtn)
413
+ }
414
+
415
+ render()
416
+ return bar
417
+ }
418
+
419
+ function showPicker(anchorBtn, item, rerenderBar) {
420
+ // Remove any existing picker
421
+ const existing = anchorBtn.querySelector('.sb-reaction-picker')
422
+ if (existing) { existing.remove(); return }
423
+
424
+ const picker = document.createElement('div')
425
+ picker.className = 'sb-reaction-picker'
426
+
427
+ for (const [content, emoji] of Object.entries(REACTION_EMOJI)) {
428
+ const groups = item.reactionGroups ?? []
429
+ const reacted = groups.some(r => r.content === content && r.viewerHasReacted)
430
+
431
+ const btn = document.createElement('button')
432
+ btn.className = 'sb-reaction-picker-btn'
433
+ btn.dataset.active = String(reacted)
434
+ btn.textContent = emoji
435
+ btn.addEventListener('click', (e) => {
436
+ e.stopPropagation()
437
+ const group = groups.find(r => r.content === content)
438
+ toggleReaction(item, content, group, rerenderBar)
439
+ picker.remove()
440
+ })
441
+ picker.appendChild(btn)
442
+ }
443
+
444
+ anchorBtn.appendChild(picker)
445
+
446
+ // Close picker on next click outside
447
+ function onClickOutside(e) {
448
+ if (!picker.contains(e.target) && e.target !== anchorBtn) {
449
+ picker.remove()
450
+ document.removeEventListener('click', onClickOutside, true)
451
+ }
452
+ }
453
+ setTimeout(() => document.addEventListener('click', onClickOutside, true), 0)
454
+ }
455
+
456
+ async function toggleReaction(item, content, existingGroup, rerenderBar) {
457
+ const wasReacted = existingGroup?.viewerHasReacted ?? false
458
+
459
+ // Optimistic update
460
+ if (!item.reactionGroups) item.reactionGroups = []
461
+
462
+ if (wasReacted && existingGroup) {
463
+ existingGroup.users = { totalCount: Math.max(0, (existingGroup.users?.totalCount ?? 1) - 1) }
464
+ existingGroup.viewerHasReacted = false
465
+ if (existingGroup.users.totalCount === 0) {
466
+ item.reactionGroups = item.reactionGroups.filter(r => r.content !== content)
467
+ }
468
+ } else if (existingGroup) {
469
+ existingGroup.users = { totalCount: (existingGroup.users?.totalCount ?? 0) + 1 }
470
+ existingGroup.viewerHasReacted = true
471
+ } else {
472
+ item.reactionGroups.push({
473
+ content,
474
+ users: { totalCount: 1 },
475
+ viewerHasReacted: true,
476
+ })
477
+ }
478
+
479
+ rerenderBar()
480
+
481
+ try {
482
+ if (wasReacted) {
483
+ await removeReaction(item.id, content)
484
+ } else {
485
+ await addReaction(item.id, content)
486
+ }
487
+ } catch (err) {
488
+ console.error('[storyboard] Reaction toggle failed:', err)
489
+ }
490
+ }
491
+
492
+ // Track the currently open window so only one is open at a time
493
+ let activeWindow = null
494
+
495
+ /**
496
+ * Show a comment window for a given comment.
497
+ * @param {HTMLElement} container - The positioned container element (overlay)
498
+ * @param {object} comment - The parsed comment object (with meta, text, replies, reactionGroups, author, etc.)
499
+ * @param {object} discussion - The discussion object (id needed for replies)
500
+ * @param {object} [callbacks] - Optional callbacks
501
+ * @param {() => void} [callbacks.onClose] - Called when window is closed
502
+ * @param {() => void} [callbacks.onMove] - Called after comment is moved (for re-rendering pins)
503
+ * @returns {{ el: HTMLElement, destroy: () => void }}
504
+ */
505
+ export function showCommentWindow(container, comment, discussion, callbacks = {}) {
506
+ injectStyles()
507
+
508
+ // Close any existing window
509
+ if (activeWindow) {
510
+ activeWindow.destroy()
511
+ activeWindow = null
512
+ }
513
+
514
+ const user = getCachedUser()
515
+ const win = document.createElement('div')
516
+ win.className = 'sb-comment-window'
517
+ win.style.left = `${comment.meta?.x ?? 0}%`
518
+ win.style.top = `${comment.meta?.y ?? 0}%`
519
+ win.style.transform = 'translate(12px, -50%)'
520
+
521
+ // --- Header (draggable) ---
522
+ const header = document.createElement('div')
523
+ header.className = 'sb-comment-window-header'
524
+
525
+ const headerLeft = document.createElement('div')
526
+ headerLeft.className = 'sb-comment-window-header-left'
527
+
528
+ if (comment.author?.avatarUrl) {
529
+ const avatar = document.createElement('img')
530
+ avatar.className = 'sb-comment-window-avatar'
531
+ avatar.src = comment.author.avatarUrl
532
+ avatar.alt = comment.author.login ?? ''
533
+ headerLeft.appendChild(avatar)
534
+ }
535
+
536
+ const authorSpan = document.createElement('span')
537
+ authorSpan.className = 'sb-comment-window-author'
538
+ authorSpan.textContent = comment.author?.login ?? 'unknown'
539
+ headerLeft.appendChild(authorSpan)
540
+
541
+ if (comment.createdAt) {
542
+ const timeSpan = document.createElement('span')
543
+ timeSpan.className = 'sb-comment-window-time'
544
+ timeSpan.textContent = timeAgo(comment.createdAt)
545
+ headerLeft.appendChild(timeSpan)
546
+ }
547
+
548
+ header.appendChild(headerLeft)
549
+
550
+ const headerActions = document.createElement('div')
551
+ headerActions.className = 'sb-comment-window-header-actions'
552
+
553
+ // Resolve button
554
+ const resolveBtn = document.createElement('button')
555
+ resolveBtn.className = 'sb-comment-window-action-btn'
556
+ resolveBtn.setAttribute('aria-label', comment.meta?.resolved ? 'Resolved' : 'Resolve')
557
+ resolveBtn.title = comment.meta?.resolved ? 'Resolved' : 'Resolve'
558
+ resolveBtn.textContent = comment.meta?.resolved ? 'Resolved' : 'Resolve'
559
+ if (comment.meta?.resolved) resolveBtn.dataset.resolved = 'true'
560
+ resolveBtn.addEventListener('click', async (e) => {
561
+ e.stopPropagation()
562
+ if (comment.meta?.resolved) return
563
+ resolveBtn.dataset.resolved = 'true'
564
+ resolveBtn.textContent = 'Resolved'
565
+ resolveBtn.title = 'Resolved'
566
+ try {
567
+ await resolveComment(comment.id, comment._rawBody ?? comment.body ?? '')
568
+ comment.meta = { ...comment.meta, resolved: true }
569
+ callbacks.onMove?.()
570
+ } catch (err) {
571
+ console.error('[storyboard] Failed to resolve comment:', err)
572
+ resolveBtn.dataset.resolved = 'false'
573
+ resolveBtn.textContent = 'Resolve'
574
+ resolveBtn.title = 'Resolve'
575
+ }
576
+ })
577
+ headerActions.appendChild(resolveBtn)
578
+
579
+ // Share button
580
+ const shareBtn = document.createElement('button')
581
+ shareBtn.className = 'sb-comment-window-action-btn'
582
+ shareBtn.setAttribute('aria-label', 'Copy link')
583
+ shareBtn.title = 'Copy link'
584
+ shareBtn.textContent = 'Copy link'
585
+ shareBtn.addEventListener('click', (e) => {
586
+ e.stopPropagation()
587
+ const url = new URL(window.location.href)
588
+ url.searchParams.set('comment', comment.id)
589
+ navigator.clipboard.writeText(url.toString()).then(() => {
590
+ shareBtn.dataset.copied = 'true'
591
+ shareBtn.textContent = 'Copied!'
592
+ shareBtn.title = 'Copied!'
593
+ setTimeout(() => {
594
+ shareBtn.dataset.copied = 'false'
595
+ shareBtn.textContent = 'Copy link'
596
+ shareBtn.title = 'Copy link'
597
+ }, 2000)
598
+ }).catch(() => {
599
+ // Fallback: select text in a temp input
600
+ const input = document.createElement('input')
601
+ input.value = url.toString()
602
+ document.body.appendChild(input)
603
+ input.select()
604
+ document.execCommand('copy')
605
+ input.remove()
606
+ })
607
+ })
608
+ headerActions.appendChild(shareBtn)
609
+
610
+ // Close button
611
+ const closeBtn = document.createElement('button')
612
+ closeBtn.className = 'sb-comment-window-close'
613
+ closeBtn.innerHTML = 'Γ—'
614
+ closeBtn.setAttribute('aria-label', 'Close')
615
+ closeBtn.addEventListener('click', (e) => {
616
+ e.stopPropagation()
617
+ destroy()
618
+ })
619
+ headerActions.appendChild(closeBtn)
620
+
621
+ header.appendChild(headerActions)
622
+ win.appendChild(header)
623
+
624
+ // --- Body ---
625
+ const body = document.createElement('div')
626
+ body.className = 'sb-comment-window-body'
627
+
628
+ const textP = document.createElement('p')
629
+ textP.className = 'sb-comment-window-text'
630
+ textP.textContent = comment.text ?? ''
631
+ body.appendChild(textP)
632
+
633
+ // Reactions for the main comment
634
+ body.appendChild(buildReactionBar(comment))
635
+
636
+ // --- Replies ---
637
+ const replies = comment.replies ?? []
638
+ if (replies.length > 0) {
639
+ const repliesSection = document.createElement('div')
640
+ repliesSection.className = 'sb-comment-window-replies'
641
+
642
+ const repliesLabel = document.createElement('div')
643
+ repliesLabel.className = 'sb-comment-window-replies-label'
644
+ repliesLabel.textContent = `${replies.length} ${replies.length === 1 ? 'Reply' : 'Replies'}`
645
+ repliesSection.appendChild(repliesLabel)
646
+
647
+ for (const reply of replies) {
648
+ const replyEl = document.createElement('div')
649
+ replyEl.className = 'sb-reply-item'
650
+
651
+ if (reply.author?.avatarUrl) {
652
+ const avatar = document.createElement('img')
653
+ avatar.className = 'sb-reply-avatar'
654
+ avatar.src = reply.author.avatarUrl
655
+ avatar.alt = reply.author.login ?? ''
656
+ replyEl.appendChild(avatar)
657
+ }
658
+
659
+ const content = document.createElement('div')
660
+ content.className = 'sb-reply-content'
661
+
662
+ const meta = document.createElement('div')
663
+ meta.className = 'sb-reply-meta'
664
+
665
+ const authorEl = document.createElement('span')
666
+ authorEl.className = 'sb-reply-author'
667
+ authorEl.textContent = reply.author?.login ?? 'unknown'
668
+ meta.appendChild(authorEl)
669
+
670
+ if (reply.createdAt) {
671
+ const timeEl = document.createElement('span')
672
+ timeEl.className = 'sb-reply-time'
673
+ timeEl.textContent = timeAgo(reply.createdAt)
674
+ meta.appendChild(timeEl)
675
+ }
676
+
677
+ content.appendChild(meta)
678
+
679
+ const replyText = document.createElement('p')
680
+ replyText.className = 'sb-reply-text'
681
+ replyText.textContent = reply.text ?? reply.body ?? ''
682
+ content.appendChild(replyText)
683
+
684
+ // Reply reactions
685
+ content.appendChild(buildReactionBar(reply))
686
+
687
+ replyEl.appendChild(content)
688
+ repliesSection.appendChild(replyEl)
689
+ }
690
+
691
+ body.appendChild(repliesSection)
692
+ }
693
+
694
+ win.appendChild(body)
695
+
696
+ // --- Reply form ---
697
+ if (user && discussion) {
698
+ const form = document.createElement('div')
699
+ form.className = 'sb-comment-window-reply-form'
700
+
701
+ const textarea = document.createElement('textarea')
702
+ textarea.className = 'sb-reply-textarea'
703
+ textarea.placeholder = 'Reply…'
704
+ form.appendChild(textarea)
705
+
706
+ const actions = document.createElement('div')
707
+ actions.className = 'sb-reply-form-actions'
708
+
709
+ const submitBtn = document.createElement('button')
710
+ submitBtn.className = 'sb-reply-submit-btn'
711
+ submitBtn.textContent = 'Reply'
712
+ submitBtn.disabled = true
713
+
714
+ textarea.addEventListener('input', () => {
715
+ submitBtn.disabled = !textarea.value.trim()
716
+ })
717
+
718
+ async function submitReply() {
719
+ const text = textarea.value.trim()
720
+ if (!text) return
721
+
722
+ submitBtn.disabled = true
723
+ submitBtn.textContent = 'Posting…'
724
+
725
+ try {
726
+ await replyToComment(discussion.id, comment.id, text)
727
+ textarea.value = ''
728
+ submitBtn.textContent = 'Reply'
729
+ // Refresh the window with new data
730
+ callbacks.onMove?.()
731
+ } catch (err) {
732
+ console.error('[storyboard] Failed to post reply:', err)
733
+ submitBtn.textContent = 'Reply'
734
+ submitBtn.disabled = false
735
+ }
736
+ }
737
+
738
+ submitBtn.addEventListener('click', submitReply)
739
+
740
+ textarea.addEventListener('keydown', (e) => {
741
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
742
+ e.preventDefault()
743
+ submitReply()
744
+ }
745
+ if (e.key === 'Escape') {
746
+ e.preventDefault()
747
+ e.stopPropagation()
748
+ }
749
+ })
750
+
751
+ actions.appendChild(submitBtn)
752
+ form.appendChild(actions)
753
+ win.appendChild(form)
754
+ }
755
+
756
+ // --- Drag to move ---
757
+ let isDragging = false
758
+ let dragStartX = 0
759
+ let dragStartY = 0
760
+ let winStartLeft = 0
761
+ let winStartTop = 0
762
+
763
+ function onMouseDown(e) {
764
+ // Only drag from header, not action buttons
765
+ if (e.target.closest('.sb-comment-window-header-actions')) return
766
+ isDragging = true
767
+ dragStartX = e.clientX
768
+ dragStartY = e.clientY
769
+
770
+ const containerRect = container.getBoundingClientRect()
771
+ // Parse current position from percentage
772
+ winStartLeft = (parseFloat(win.style.left) / 100) * containerRect.width
773
+ winStartTop = (parseFloat(win.style.top) / 100) * containerRect.height
774
+
775
+ document.addEventListener('mousemove', onMouseMove)
776
+ document.addEventListener('mouseup', onMouseUp)
777
+ e.preventDefault()
778
+ }
779
+
780
+ function onMouseMove(e) {
781
+ if (!isDragging) return
782
+ const dx = e.clientX - dragStartX
783
+ const dy = e.clientY - dragStartY
784
+
785
+ const containerRect = container.getBoundingClientRect()
786
+ const newLeft = winStartLeft + dx
787
+ const newTop = winStartTop + dy
788
+
789
+ const xPct = Math.round((newLeft / containerRect.width) * 1000) / 10
790
+ const yPct = Math.round((newTop / containerRect.height) * 1000) / 10
791
+
792
+ win.style.left = `${xPct}%`
793
+ win.style.top = `${yPct}%`
794
+ }
795
+
796
+ async function onMouseUp(e) {
797
+ if (!isDragging) return
798
+ isDragging = false
799
+ document.removeEventListener('mousemove', onMouseMove)
800
+ document.removeEventListener('mouseup', onMouseUp)
801
+
802
+ // Calculate final position percentage
803
+ const containerRect = container.getBoundingClientRect()
804
+ const dx = e.clientX - dragStartX
805
+ const dy = e.clientY - dragStartY
806
+ const newLeft = winStartLeft + dx
807
+ const newTop = winStartTop + dy
808
+ const xPct = Math.round((newLeft / containerRect.width) * 1000) / 10
809
+ const yPct = Math.round((newTop / containerRect.height) * 1000) / 10
810
+
811
+ // Only update if actually moved
812
+ if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
813
+ // Update the pin position
814
+ comment.meta = { ...comment.meta, x: xPct, y: yPct }
815
+
816
+ // Move the corresponding pin element without re-rendering everything
817
+ const pins = container.querySelectorAll('.sb-comment-pin')
818
+ for (const pin of pins) {
819
+ if (pin._commentId === comment.id) {
820
+ pin.style.left = `${xPct}%`
821
+ pin.style.top = `${yPct}%`
822
+ break
823
+ }
824
+ }
825
+
826
+ try {
827
+ await moveComment(comment.id, comment._rawBody ?? '', xPct, yPct)
828
+ // Update raw body with new metadata for future moves
829
+ comment._rawBody = null // force re-fetch on next move
830
+ } catch (err) {
831
+ console.error('[storyboard] Failed to move comment:', err)
832
+ }
833
+ }
834
+ }
835
+
836
+ header.addEventListener('mousedown', onMouseDown)
837
+
838
+ // Stop clicks from propagating to overlay
839
+ win.addEventListener('click', (e) => e.stopPropagation())
840
+
841
+ // Set URL param for deep linking
842
+ const url = new URL(window.location.href)
843
+ url.searchParams.set('comment', comment.id)
844
+ window.history.replaceState(null, '', url.toString())
845
+
846
+ container.appendChild(win)
847
+
848
+ function destroy() {
849
+ document.removeEventListener('mousemove', onMouseMove)
850
+ document.removeEventListener('mouseup', onMouseUp)
851
+ win.remove()
852
+ if (activeWindow?.el === win) activeWindow = null
853
+ // Clear URL param
854
+ const currentUrl = new URL(window.location.href)
855
+ currentUrl.searchParams.delete('comment')
856
+ window.history.replaceState(null, '', currentUrl.toString())
857
+ callbacks.onClose?.()
858
+ }
859
+
860
+ activeWindow = { el: win, destroy }
861
+ return { el: win, destroy }
862
+ }
863
+
864
+ /**
865
+ * Close the currently open comment window, if any.
866
+ */
867
+ export function closeCommentWindow() {
868
+ if (activeWindow) {
869
+ activeWindow.destroy()
870
+ activeWindow = null
871
+ }
872
+ }