@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,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
+ }