@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.
- package/package.json +18 -0
- package/src/comments/api.js +196 -0
- package/src/comments/api.test.js +194 -0
- package/src/comments/auth.js +79 -0
- package/src/comments/auth.test.js +60 -0
- package/src/comments/commentMode.js +63 -0
- package/src/comments/commentMode.test.js +87 -0
- package/src/comments/config.js +43 -0
- package/src/comments/config.test.js +76 -0
- package/src/comments/graphql.js +65 -0
- package/src/comments/graphql.test.js +95 -0
- package/src/comments/index.js +40 -0
- package/src/comments/metadata.js +52 -0
- package/src/comments/metadata.test.js +110 -0
- package/src/comments/queries.js +182 -0
- package/src/comments/ui/CommentOverlay.js +52 -0
- package/src/comments/ui/authModal.js +349 -0
- package/src/comments/ui/commentWindow.js +872 -0
- package/src/comments/ui/commentsDrawer.js +389 -0
- package/src/comments/ui/composer.js +248 -0
- package/src/comments/ui/mount.js +364 -0
- package/src/devtools.js +365 -0
- package/src/devtools.test.js +81 -0
- package/src/dotPath.js +53 -0
- package/src/dotPath.test.js +114 -0
- package/src/hashSubscribe.js +19 -0
- package/src/hashSubscribe.test.js +62 -0
- package/src/hideMode.js +421 -0
- package/src/hideMode.test.js +224 -0
- package/src/index.js +38 -0
- package/src/interceptHideParams.js +35 -0
- package/src/interceptHideParams.test.js +90 -0
- package/src/loader.js +212 -0
- package/src/loader.test.js +232 -0
- package/src/localStorage.js +134 -0
- package/src/localStorage.test.js +148 -0
- package/src/sceneDebug.js +108 -0
- package/src/sceneDebug.test.js +128 -0
- package/src/session.js +76 -0
- package/src/session.test.js +91 -0
- package/src/viewfinder.js +47 -0
- 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
|
+
}
|