@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,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mount the comments system — keyboard shortcut, cursor overlay, click-to-comment.
|
|
3
|
+
*
|
|
4
|
+
* Call mountComments() once at app startup (after initCommentsConfig).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { isCommentsEnabled } from '../config.js'
|
|
8
|
+
import { isAuthenticated } from '../auth.js'
|
|
9
|
+
import { toggleCommentMode, setCommentMode, isCommentModeActive, subscribeToCommentMode } from '../commentMode.js'
|
|
10
|
+
import { fetchRouteDiscussion } from '../api.js'
|
|
11
|
+
import { showComposer } from './composer.js'
|
|
12
|
+
import { openAuthModal } from './authModal.js'
|
|
13
|
+
import { showCommentWindow, closeCommentWindow } from './commentWindow.js'
|
|
14
|
+
|
|
15
|
+
const CURSOR_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" stroke="%23fff" stroke-width="1.5" d="M19.503 9.97c1.204.489 1.112 2.224-.137 2.583l-6.305 1.813l-2.88 5.895c-.571 1.168-2.296.957-2.569-.314L4.677 6.257A1.369 1.369 0 0 1 6.53 4.7z" clip-rule="evenodd"/></svg>`
|
|
16
|
+
|
|
17
|
+
const STYLE_ID = 'sb-comment-mode-style'
|
|
18
|
+
|
|
19
|
+
function injectStyles() {
|
|
20
|
+
if (document.getElementById(STYLE_ID)) return
|
|
21
|
+
const style = document.createElement('style')
|
|
22
|
+
style.id = STYLE_ID
|
|
23
|
+
style.textContent = `
|
|
24
|
+
.sb-comment-mode {
|
|
25
|
+
cursor: url("data:image/svg+xml,${CURSOR_SVG}") 4 2, crosshair;
|
|
26
|
+
}
|
|
27
|
+
.sb-comment-overlay {
|
|
28
|
+
position: absolute;
|
|
29
|
+
inset: 0;
|
|
30
|
+
z-index: 99998;
|
|
31
|
+
pointer-events: none;
|
|
32
|
+
}
|
|
33
|
+
.sb-comment-overlay.active {
|
|
34
|
+
pointer-events: auto;
|
|
35
|
+
}
|
|
36
|
+
.sb-comment-mode-banner {
|
|
37
|
+
position: fixed;
|
|
38
|
+
bottom: 12px;
|
|
39
|
+
left: 50%;
|
|
40
|
+
transform: translateX(-50%);
|
|
41
|
+
z-index: 99999;
|
|
42
|
+
background: rgba(0, 0, 0, 0.85);
|
|
43
|
+
color: #fff;
|
|
44
|
+
padding: 6px 16px;
|
|
45
|
+
border-radius: 8px;
|
|
46
|
+
font: 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
47
|
+
display: flex;
|
|
48
|
+
align-items: center;
|
|
49
|
+
gap: 8px;
|
|
50
|
+
pointer-events: none;
|
|
51
|
+
backdrop-filter: blur(8px);
|
|
52
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
53
|
+
}
|
|
54
|
+
.sb-comment-mode-banner kbd {
|
|
55
|
+
display: inline-block;
|
|
56
|
+
padding: 1px 5px;
|
|
57
|
+
font-size: 11px;
|
|
58
|
+
font-family: inherit;
|
|
59
|
+
border: 1px solid rgba(255,255,255,0.3);
|
|
60
|
+
border-radius: 4px;
|
|
61
|
+
background: rgba(255,255,255,0.1);
|
|
62
|
+
}
|
|
63
|
+
.sb-comment-pin {
|
|
64
|
+
position: absolute;
|
|
65
|
+
z-index: 100000;
|
|
66
|
+
width: 32px;
|
|
67
|
+
height: 32px;
|
|
68
|
+
margin-left: -16px;
|
|
69
|
+
margin-top: -16px;
|
|
70
|
+
border-radius: 50%;
|
|
71
|
+
background: #161b22;
|
|
72
|
+
border: 3px solid hsl(var(--pin-hue, 140), 50%, 38%);
|
|
73
|
+
cursor: pointer;
|
|
74
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
|
75
|
+
pointer-events: auto;
|
|
76
|
+
transition: transform 100ms ease;
|
|
77
|
+
overflow: hidden;
|
|
78
|
+
}
|
|
79
|
+
.sb-comment-pin img {
|
|
80
|
+
width: 100%;
|
|
81
|
+
height: 100%;
|
|
82
|
+
border-radius: 50%;
|
|
83
|
+
object-fit: cover;
|
|
84
|
+
display: block;
|
|
85
|
+
}
|
|
86
|
+
.sb-comment-pin:hover {
|
|
87
|
+
transform: scale(1.15);
|
|
88
|
+
}
|
|
89
|
+
.sb-comment-pin[data-resolved="true"] {
|
|
90
|
+
border-color: #8b949e;
|
|
91
|
+
opacity: 0.5;
|
|
92
|
+
}
|
|
93
|
+
`
|
|
94
|
+
document.head.appendChild(style)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let banner = null
|
|
98
|
+
let overlay = null
|
|
99
|
+
let activeComposer = null
|
|
100
|
+
let renderedPins = []
|
|
101
|
+
let cachedDiscussion = null
|
|
102
|
+
|
|
103
|
+
function getContentContainer() {
|
|
104
|
+
// Per plan: coordinates relative to <main> or nearest positioned parent
|
|
105
|
+
return document.querySelector('main') || document.body
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function ensureOverlay() {
|
|
109
|
+
if (overlay) return overlay
|
|
110
|
+
const container = getContentContainer()
|
|
111
|
+
// Ensure container is positioned so absolute children work
|
|
112
|
+
const pos = getComputedStyle(container).position
|
|
113
|
+
if (pos === 'static') container.style.position = 'relative'
|
|
114
|
+
|
|
115
|
+
overlay = document.createElement('div')
|
|
116
|
+
overlay.className = 'sb-comment-overlay'
|
|
117
|
+
container.appendChild(overlay)
|
|
118
|
+
return overlay
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function showBanner() {
|
|
122
|
+
if (banner) return
|
|
123
|
+
banner = document.createElement('div')
|
|
124
|
+
banner.className = 'sb-comment-mode-banner'
|
|
125
|
+
banner.innerHTML = 'Comment mode — click to place a comment. Press <kbd>C</kbd> or <kbd>Esc</kbd> to exit.'
|
|
126
|
+
document.body.appendChild(banner)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function hideBanner() {
|
|
130
|
+
if (!banner) return
|
|
131
|
+
banner.remove()
|
|
132
|
+
banner = null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function getCurrentRoute() {
|
|
136
|
+
return window.location.pathname
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function clearPins() {
|
|
140
|
+
for (const pin of renderedPins) pin.remove()
|
|
141
|
+
renderedPins = []
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function renderPin(ov, comment, index) {
|
|
145
|
+
const pin = document.createElement('div')
|
|
146
|
+
pin.className = 'sb-comment-pin'
|
|
147
|
+
pin.style.left = `${comment.meta?.x ?? 0}%`
|
|
148
|
+
pin.style.top = `${comment.meta?.y ?? 0}%`
|
|
149
|
+
|
|
150
|
+
// Rotate hue by index (golden angle ≈ 137.5° gives good distribution)
|
|
151
|
+
const hue = (index * 137.5) % 360
|
|
152
|
+
pin.style.setProperty('--pin-hue', String(Math.round(hue)))
|
|
153
|
+
|
|
154
|
+
// Show author avatar instead of number
|
|
155
|
+
if (comment.author?.avatarUrl) {
|
|
156
|
+
const img = document.createElement('img')
|
|
157
|
+
img.src = comment.author.avatarUrl
|
|
158
|
+
img.alt = comment.author.login ?? ''
|
|
159
|
+
pin.appendChild(img)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (comment.meta?.resolved) pin.setAttribute('data-resolved', 'true')
|
|
163
|
+
pin.title = `${comment.author?.login ?? 'unknown'}: ${comment.text?.slice(0, 80) ?? ''}`
|
|
164
|
+
|
|
165
|
+
// Store comment ID on pin for drag-move updates
|
|
166
|
+
pin._commentId = comment.id
|
|
167
|
+
|
|
168
|
+
// Store raw body for move operations
|
|
169
|
+
comment._rawBody = comment.body
|
|
170
|
+
|
|
171
|
+
// Click pin to open comment window
|
|
172
|
+
pin.addEventListener('click', (e) => {
|
|
173
|
+
e.stopPropagation()
|
|
174
|
+
// Dismiss any open composer
|
|
175
|
+
if (activeComposer) {
|
|
176
|
+
activeComposer.destroy()
|
|
177
|
+
activeComposer = null
|
|
178
|
+
}
|
|
179
|
+
showCommentWindow(ov, comment, cachedDiscussion, {
|
|
180
|
+
onClose: () => {},
|
|
181
|
+
onMove: () => loadAndRenderComments(),
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
ov.appendChild(pin)
|
|
186
|
+
renderedPins.push(pin)
|
|
187
|
+
return pin
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function renderCachedPins() {
|
|
191
|
+
if (!cachedDiscussion?.comments?.length) return
|
|
192
|
+
const ov = ensureOverlay()
|
|
193
|
+
clearPins()
|
|
194
|
+
cachedDiscussion.comments.forEach((comment, i) => {
|
|
195
|
+
if (comment.meta?.x != null && comment.meta?.y != null) {
|
|
196
|
+
renderPin(ov, comment, i)
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function loadAndRenderComments() {
|
|
202
|
+
if (!isAuthenticated()) return
|
|
203
|
+
const ov = ensureOverlay()
|
|
204
|
+
|
|
205
|
+
// Show cached pins immediately if available
|
|
206
|
+
renderCachedPins()
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const discussion = await fetchRouteDiscussion(getCurrentRoute())
|
|
210
|
+
cachedDiscussion = discussion
|
|
211
|
+
clearPins()
|
|
212
|
+
if (!discussion?.comments?.length) return
|
|
213
|
+
|
|
214
|
+
discussion.comments.forEach((comment, i) => {
|
|
215
|
+
if (comment.meta?.x != null && comment.meta?.y != null) {
|
|
216
|
+
renderPin(ov, comment, i)
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
// Auto-open comment from URL param
|
|
221
|
+
autoOpenCommentFromUrl(ov, discussion)
|
|
222
|
+
} catch (err) {
|
|
223
|
+
console.warn('[storyboard] Could not load comments:', err.message)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function autoOpenCommentFromUrl(ov, discussion) {
|
|
228
|
+
const commentId = new URLSearchParams(window.location.search).get('comment')
|
|
229
|
+
if (!commentId || !discussion?.comments?.length) return
|
|
230
|
+
|
|
231
|
+
const comment = discussion.comments.find(c => c.id === commentId)
|
|
232
|
+
if (!comment) return
|
|
233
|
+
|
|
234
|
+
// Scroll to comment Y position if not in viewport
|
|
235
|
+
if (comment.meta?.y != null) {
|
|
236
|
+
const container = getContentContainer()
|
|
237
|
+
const yPx = (comment.meta.y / 100) * container.scrollHeight
|
|
238
|
+
const viewTop = container.scrollTop || window.scrollY
|
|
239
|
+
const viewBottom = viewTop + window.innerHeight
|
|
240
|
+
if (yPx < viewTop || yPx > viewBottom) {
|
|
241
|
+
const scrollTarget = Math.max(0, yPx - window.innerHeight / 3)
|
|
242
|
+
window.scrollTo({ top: scrollTarget, behavior: 'smooth' })
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
comment._rawBody = comment.body
|
|
247
|
+
showCommentWindow(ov, comment, discussion, {
|
|
248
|
+
onClose: () => {},
|
|
249
|
+
onMove: () => loadAndRenderComments(),
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function handleOverlayClick(e) {
|
|
254
|
+
if (!isCommentModeActive()) return
|
|
255
|
+
// Don't place if clicking on an existing composer, pin, or comment window
|
|
256
|
+
if (e.target.closest('.sb-composer') || e.target.closest('.sb-comment-pin') || e.target.closest('.sb-comment-window')) return
|
|
257
|
+
|
|
258
|
+
// Close any open comment window
|
|
259
|
+
closeCommentWindow()
|
|
260
|
+
|
|
261
|
+
// Dismiss any open composer
|
|
262
|
+
if (activeComposer) {
|
|
263
|
+
activeComposer.destroy()
|
|
264
|
+
activeComposer = null
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const container = getContentContainer()
|
|
268
|
+
const rect = container.getBoundingClientRect()
|
|
269
|
+
const xPct = Math.round(((e.clientX - rect.left) / rect.width) * 1000) / 10
|
|
270
|
+
const yPct = Math.round(((e.clientY - rect.top + container.scrollTop) / container.scrollHeight) * 1000) / 10
|
|
271
|
+
|
|
272
|
+
const ov = ensureOverlay()
|
|
273
|
+
activeComposer = showComposer(ov, xPct, yPct, getCurrentRoute(), {
|
|
274
|
+
onCancel: () => { activeComposer = null },
|
|
275
|
+
onSubmit: () => {
|
|
276
|
+
activeComposer = null
|
|
277
|
+
// Re-fetch and render all pins to get correct numbering
|
|
278
|
+
loadAndRenderComments()
|
|
279
|
+
},
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function setBodyCommentMode(active) {
|
|
284
|
+
if (active) {
|
|
285
|
+
document.body.classList.add('sb-comment-mode')
|
|
286
|
+
showBanner()
|
|
287
|
+
const ov = ensureOverlay()
|
|
288
|
+
ov.classList.add('active')
|
|
289
|
+
// Show cached pins instantly, then refresh in background
|
|
290
|
+
renderCachedPins()
|
|
291
|
+
loadAndRenderComments()
|
|
292
|
+
} else {
|
|
293
|
+
document.body.classList.remove('sb-comment-mode')
|
|
294
|
+
hideBanner()
|
|
295
|
+
if (activeComposer) {
|
|
296
|
+
activeComposer.destroy()
|
|
297
|
+
activeComposer = null
|
|
298
|
+
}
|
|
299
|
+
closeCommentWindow()
|
|
300
|
+
clearPins()
|
|
301
|
+
if (overlay) overlay.classList.remove('active')
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let _mounted = false
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Mount the comments system — registers keyboard shortcuts, cursor overlay, and click handler.
|
|
309
|
+
* Safe to call multiple times (idempotent).
|
|
310
|
+
*/
|
|
311
|
+
export function mountComments() {
|
|
312
|
+
if (_mounted) return
|
|
313
|
+
_mounted = true
|
|
314
|
+
|
|
315
|
+
injectStyles()
|
|
316
|
+
|
|
317
|
+
// React to comment mode changes
|
|
318
|
+
subscribeToCommentMode(setBodyCommentMode)
|
|
319
|
+
|
|
320
|
+
// Click handler for placing comments
|
|
321
|
+
document.addEventListener('click', (e) => {
|
|
322
|
+
if (!isCommentModeActive()) return
|
|
323
|
+
// Ignore clicks on devtools, modals, etc.
|
|
324
|
+
if (e.target.closest('.sb-devtools-wrapper') || e.target.closest('.sb-auth-backdrop') || e.target.closest('.sb-comments-drawer') || e.target.closest('.sb-comments-drawer-backdrop')) return
|
|
325
|
+
handleOverlayClick(e)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
// C key toggles comment mode, Escape exits
|
|
329
|
+
window.addEventListener('keydown', (e) => {
|
|
330
|
+
// Don't trigger when typing in inputs
|
|
331
|
+
const tag = e.target.tagName
|
|
332
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || e.target.isContentEditable) {
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (e.key === 'c' && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
337
|
+
if (!isCommentsEnabled()) return
|
|
338
|
+
e.preventDefault()
|
|
339
|
+
|
|
340
|
+
// If not authenticated, open auth modal instead of toggling
|
|
341
|
+
if (!isCommentModeActive() && !isAuthenticated()) {
|
|
342
|
+
openAuthModal()
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
toggleCommentMode()
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (e.key === 'Escape') {
|
|
350
|
+
if (isCommentModeActive()) {
|
|
351
|
+
e.preventDefault()
|
|
352
|
+
setCommentMode(false)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
// Auto-open comment from URL param on page load
|
|
358
|
+
if (isCommentsEnabled() && isAuthenticated()) {
|
|
359
|
+
const commentId = new URLSearchParams(window.location.search).get('comment')
|
|
360
|
+
if (commentId) {
|
|
361
|
+
setCommentMode(true)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|