@dfosco/storyboard-core 1.17.2 → 1.18.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 +1 -1
- package/src/comments/commentCache.js +54 -0
- package/src/comments/index.js +1 -1
- package/src/comments/ui/authModal.js +1 -1
- package/src/comments/ui/comments.css +31 -1
- package/src/comments/ui/composer.js +7 -17
- package/src/comments/ui/mount.js +141 -42
- package/src/comments/ui/mount.test.js +8 -0
- package/src/devtools.js +113 -9
- package/src/featureFlags.js +112 -0
- package/src/index.js +3 -0
package/package.json
CHANGED
|
@@ -53,3 +53,57 @@ export function clearCachedComments(route) {
|
|
|
53
53
|
// ignore
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
|
+
|
|
57
|
+
// --- Pending (failed) comments ---
|
|
58
|
+
|
|
59
|
+
const PENDING_PREFIX = 'sb-pending-comments:'
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Save a pending comment that failed to submit.
|
|
63
|
+
* @param {string} route
|
|
64
|
+
* @param {{ id: string, x: number, y: number, text: string, author: object }} comment
|
|
65
|
+
*/
|
|
66
|
+
export function savePendingComment(route, comment) {
|
|
67
|
+
try {
|
|
68
|
+
const pending = getPendingComments(route)
|
|
69
|
+
// Replace if same id already exists, else append
|
|
70
|
+
const idx = pending.findIndex(c => c.id === comment.id)
|
|
71
|
+
if (idx >= 0) pending[idx] = comment
|
|
72
|
+
else pending.push(comment)
|
|
73
|
+
localStorage.setItem(PENDING_PREFIX + route, JSON.stringify(pending))
|
|
74
|
+
} catch {
|
|
75
|
+
// ignore
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get all pending (failed) comments for a route.
|
|
81
|
+
* @param {string} route
|
|
82
|
+
* @returns {Array<{ id: string, x: number, y: number, text: string, author: object }>}
|
|
83
|
+
*/
|
|
84
|
+
export function getPendingComments(route) {
|
|
85
|
+
try {
|
|
86
|
+
const raw = localStorage.getItem(PENDING_PREFIX + route)
|
|
87
|
+
return raw ? JSON.parse(raw) : []
|
|
88
|
+
} catch {
|
|
89
|
+
return []
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Remove a pending comment (after successful retry or dismissal).
|
|
95
|
+
* @param {string} route
|
|
96
|
+
* @param {string} pendingId
|
|
97
|
+
*/
|
|
98
|
+
export function removePendingComment(route, pendingId) {
|
|
99
|
+
try {
|
|
100
|
+
const pending = getPendingComments(route).filter(c => c.id !== pendingId)
|
|
101
|
+
if (pending.length > 0) {
|
|
102
|
+
localStorage.setItem(PENDING_PREFIX + route, JSON.stringify(pending))
|
|
103
|
+
} else {
|
|
104
|
+
localStorage.removeItem(PENDING_PREFIX + route)
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// ignore
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/comments/index.js
CHANGED
|
@@ -33,7 +33,7 @@ export {
|
|
|
33
33
|
} from './api.js'
|
|
34
34
|
|
|
35
35
|
// Cache
|
|
36
|
-
export { getCachedComments, setCachedComments, clearCachedComments } from './commentCache.js'
|
|
36
|
+
export { getCachedComments, setCachedComments, clearCachedComments, savePendingComment, getPendingComments, removePendingComment } from './commentCache.js'
|
|
37
37
|
|
|
38
38
|
// GraphQL client (for advanced use)
|
|
39
39
|
export { graphql } from './graphql.js'
|
|
@@ -34,7 +34,7 @@ export function openAuthModal() {
|
|
|
34
34
|
</div>
|
|
35
35
|
<div class="pa4">
|
|
36
36
|
<p class="ma0 mb3 lh-copy sb-fg-muted sb-f-sm">
|
|
37
|
-
Create a <a class="sb-fg-accent no-underline" href="https://github.com/settings/tokens/new
|
|
37
|
+
Create a <a class="sb-fg-accent no-underline" href="https://github.com/settings/personal-access-tokens/new" target="_blank" rel="noopener">GitHub Fine-Grained Personal Access Token</a> with access to <b>github/storyboard</b> repository and <b>Discussions</b> read/write scope. Then enter the token below to sign in and enable commenting features.
|
|
38
38
|
</p>
|
|
39
39
|
<label class="db mb1 fw5 sb-fg sb-f-sm" for="sb-auth-token-input">Personal Access Token</label>
|
|
40
40
|
<input class="sb-input w-100 ph3 pv2 br2 f6 code db" id="sb-auth-token-input" type="password"
|
|
@@ -139,7 +139,18 @@
|
|
|
139
139
|
.sb-comment-window * { scrollbar-width: none; }
|
|
140
140
|
.sb-comment-window *::-webkit-scrollbar { display: none; }
|
|
141
141
|
.sb-composer { width: 280px; z-index: 100001; }
|
|
142
|
-
.sb-comment-overlay {
|
|
142
|
+
.sb-comment-overlay {
|
|
143
|
+
position: absolute;
|
|
144
|
+
top: 0; left: 0; right: 0; bottom: 0;
|
|
145
|
+
z-index: 99998;
|
|
146
|
+
pointer-events: none;
|
|
147
|
+
}
|
|
148
|
+
.sb-comment-mode {
|
|
149
|
+
position: relative;
|
|
150
|
+
}
|
|
151
|
+
.sb-comment-mode .sb-comment-overlay {
|
|
152
|
+
pointer-events: auto;
|
|
153
|
+
}
|
|
143
154
|
.sb-auth-backdrop { z-index: 100000; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); }
|
|
144
155
|
.sb-comments-drawer-backdrop { z-index: 99997; background: rgba(0,0,0,0.4); }
|
|
145
156
|
.sb-comments-drawer { z-index: 99998; width: 420px; max-width: 90vw; }
|
|
@@ -187,6 +198,25 @@
|
|
|
187
198
|
border-color: var(--sb-fg-muted);
|
|
188
199
|
opacity: 0.5;
|
|
189
200
|
}
|
|
201
|
+
.sb-comment-pin-pending {
|
|
202
|
+
border-color: var(--sb-fg-muted) !important;
|
|
203
|
+
opacity: 0.6;
|
|
204
|
+
animation: sb-pin-pulse 1.2s ease-in-out infinite;
|
|
205
|
+
}
|
|
206
|
+
.sb-comment-pin-failed {
|
|
207
|
+
border-color: var(--sb-fg-danger) !important;
|
|
208
|
+
cursor: pointer;
|
|
209
|
+
animation: sb-pin-shake 0.4s ease-in-out;
|
|
210
|
+
}
|
|
211
|
+
@keyframes sb-pin-pulse {
|
|
212
|
+
0%, 100% { opacity: 0.6; }
|
|
213
|
+
50% { opacity: 1; }
|
|
214
|
+
}
|
|
215
|
+
@keyframes sb-pin-shake {
|
|
216
|
+
0%, 100% { transform: translateX(0); }
|
|
217
|
+
25% { transform: translateX(-3px); }
|
|
218
|
+
75% { transform: translateX(3px); }
|
|
219
|
+
}
|
|
190
220
|
|
|
191
221
|
/* Error alert */
|
|
192
222
|
.sb-error-alert {
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
* Styled with Tachyons + sb-* custom classes for light/dark mode support.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { createComment } from '../api.js'
|
|
9
8
|
import { getCachedUser } from '../auth.js'
|
|
10
9
|
|
|
11
10
|
/**
|
|
@@ -16,7 +15,7 @@ import { getCachedUser } from '../auth.js'
|
|
|
16
15
|
* @param {string} route - Current route path
|
|
17
16
|
* @param {object} [callbacks] - Optional callbacks
|
|
18
17
|
* @param {() => void} [callbacks.onCancel] - Called when composer is dismissed
|
|
19
|
-
* @param {(
|
|
18
|
+
* @param {(text: string) => void} [callbacks.onSubmitOptimistic] - Called with text for optimistic submission
|
|
20
19
|
* @returns {{ el: HTMLElement, destroy: () => void }}
|
|
21
20
|
*/
|
|
22
21
|
export function showComposer(container, xPct, yPct, route, callbacks = {}) {
|
|
@@ -47,8 +46,8 @@ export function showComposer(container, xPct, yPct, route, callbacks = {}) {
|
|
|
47
46
|
</template>
|
|
48
47
|
<div class="flex items-center justify-end pa3">
|
|
49
48
|
<button class="sb-btn-cancel ph3 pv2 br2 f7 fw5 pointer mr1" @click="cancel()">Cancel</button>
|
|
50
|
-
<button class="sb-btn-success ph3 pv2 br2 f7 fw5 pointer bn"
|
|
51
|
-
@click="submit()"
|
|
49
|
+
<button class="sb-btn-success ph3 pv2 br2 f7 fw5 pointer bn"
|
|
50
|
+
@click="submit()">Comment</button>
|
|
52
51
|
</div>
|
|
53
52
|
</div>
|
|
54
53
|
`
|
|
@@ -81,22 +80,13 @@ export function showComposer(container, xPct, yPct, route, callbacks = {}) {
|
|
|
81
80
|
submitting: false,
|
|
82
81
|
error: null,
|
|
83
82
|
|
|
84
|
-
|
|
83
|
+
submit() {
|
|
85
84
|
const val = this.text.trim()
|
|
86
85
|
if (!val) return
|
|
87
86
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
try {
|
|
92
|
-
const comment = await createComment(route, xPct, yPct, val)
|
|
93
|
-
destroy()
|
|
94
|
-
callbacks.onSubmit?.(comment)
|
|
95
|
-
} catch (err) {
|
|
96
|
-
this.error = err.message
|
|
97
|
-
this.submitting = false
|
|
98
|
-
console.error('[storyboard] Failed to post comment:', err)
|
|
99
|
-
}
|
|
87
|
+
// Close composer immediately and hand off to optimistic handler
|
|
88
|
+
destroy()
|
|
89
|
+
callbacks.onSubmitOptimistic?.(val)
|
|
100
90
|
},
|
|
101
91
|
|
|
102
92
|
cancel() {
|
package/src/comments/ui/mount.js
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
|
|
8
8
|
import Alpine from 'alpinejs'
|
|
9
9
|
import { isCommentsEnabled } from '../config.js'
|
|
10
|
-
import { isAuthenticated } from '../auth.js'
|
|
10
|
+
import { isAuthenticated, getCachedUser } from '../auth.js'
|
|
11
11
|
import { toggleCommentMode, setCommentMode, isCommentModeActive, subscribeToCommentMode } from '../commentMode.js'
|
|
12
|
-
import { fetchRouteCommentsSummary, fetchCommentDetail, moveComment } from '../api.js'
|
|
13
|
-
import { getCachedComments, setCachedComments, clearCachedComments } from '../commentCache.js'
|
|
12
|
+
import { fetchRouteCommentsSummary, fetchCommentDetail, moveComment, createComment } from '../api.js'
|
|
13
|
+
import { getCachedComments, setCachedComments, clearCachedComments, savePendingComment, getPendingComments, removePendingComment } from '../commentCache.js'
|
|
14
14
|
import { showComposer } from './composer.js'
|
|
15
15
|
import { openAuthModal } from './authModal.js'
|
|
16
16
|
import { showCommentWindow, closeCommentWindow } from './commentWindow.js'
|
|
@@ -27,17 +27,19 @@ function esc(str) {
|
|
|
27
27
|
return d.innerHTML
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
function getContentContainer() {
|
|
31
|
-
return document.body
|
|
32
|
-
}
|
|
33
|
-
|
|
34
30
|
function ensureOverlay() {
|
|
35
31
|
if (overlay) return overlay
|
|
36
|
-
const container = getContentContainer()
|
|
37
32
|
|
|
38
33
|
overlay = document.createElement('div')
|
|
39
|
-
overlay.className = 'sb-comment-overlay
|
|
40
|
-
|
|
34
|
+
overlay.className = 'sb-comment-overlay'
|
|
35
|
+
document.body.appendChild(overlay)
|
|
36
|
+
|
|
37
|
+
// Click handler for placing comments lives on the overlay itself
|
|
38
|
+
overlay.addEventListener('click', (e) => {
|
|
39
|
+
if (!isCommentModeActive()) return
|
|
40
|
+
if (e.target.closest('.sb-composer') || e.target.closest('.sb-comment-pin') || e.target.closest('.sb-comment-window')) return
|
|
41
|
+
handleOverlayClick(e)
|
|
42
|
+
})
|
|
41
43
|
|
|
42
44
|
return overlay
|
|
43
45
|
}
|
|
@@ -74,6 +76,99 @@ function reloadComments() {
|
|
|
74
76
|
loadAndRenderComments()
|
|
75
77
|
}
|
|
76
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Render an optimistic pin immediately after the user submits a comment.
|
|
81
|
+
* Returns callbacks to mark it as succeeded or failed.
|
|
82
|
+
*/
|
|
83
|
+
function renderOptimisticPin(ov, xPct, yPct, text, user) {
|
|
84
|
+
const pendingId = `pending-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
85
|
+
const pin = document.createElement('div')
|
|
86
|
+
pin.className = 'sb-comment-pin sb-comment-pin-pending absolute br-100 sb-bg pointer sb-shadow pe-auto overflow-hidden'
|
|
87
|
+
pin.style.left = `${xPct}%`
|
|
88
|
+
pin.style.top = `${yPct}%`
|
|
89
|
+
pin.title = `${user?.login ?? 'you'}: ${text.slice(0, 80)}`
|
|
90
|
+
|
|
91
|
+
pin.innerHTML = user?.avatarUrl
|
|
92
|
+
? `<img class="br-100 db sb-pin-img" src="${esc(user.avatarUrl)}" alt="${esc(user.login)}" draggable="false" />`
|
|
93
|
+
: ''
|
|
94
|
+
|
|
95
|
+
ov.appendChild(pin)
|
|
96
|
+
renderedPins.push(pin)
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
pendingId,
|
|
100
|
+
succeed: () => {
|
|
101
|
+
pin.classList.remove('sb-comment-pin-pending')
|
|
102
|
+
},
|
|
103
|
+
fail: () => {
|
|
104
|
+
pin.classList.remove('sb-comment-pin-pending')
|
|
105
|
+
pin.classList.add('sb-comment-pin-failed')
|
|
106
|
+
pin.title = `⚠ Failed to post — click to retry: ${text.slice(0, 60)}`
|
|
107
|
+
|
|
108
|
+
// Save to localStorage for persistence
|
|
109
|
+
const route = getCurrentRoute()
|
|
110
|
+
savePendingComment(route, { id: pendingId, x: xPct, y: yPct, text, author: user })
|
|
111
|
+
|
|
112
|
+
// Click to retry
|
|
113
|
+
pin.addEventListener('click', async (e) => {
|
|
114
|
+
e.stopPropagation()
|
|
115
|
+
pin.classList.remove('sb-comment-pin-failed')
|
|
116
|
+
pin.classList.add('sb-comment-pin-pending')
|
|
117
|
+
pin.title = 'Retrying…'
|
|
118
|
+
try {
|
|
119
|
+
await createComment(route, xPct, yPct, text)
|
|
120
|
+
removePendingComment(route, pendingId)
|
|
121
|
+
pin.classList.remove('sb-comment-pin-pending')
|
|
122
|
+
reloadComments()
|
|
123
|
+
} catch {
|
|
124
|
+
pin.classList.remove('sb-comment-pin-pending')
|
|
125
|
+
pin.classList.add('sb-comment-pin-failed')
|
|
126
|
+
pin.title = `⚠ Failed to post — click to retry: ${text.slice(0, 60)}`
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Render pins for pending (failed) comments from localStorage.
|
|
135
|
+
*/
|
|
136
|
+
function renderPendingPins(ov) {
|
|
137
|
+
const route = getCurrentRoute()
|
|
138
|
+
const pending = getPendingComments(route)
|
|
139
|
+
for (const p of pending) {
|
|
140
|
+
const pin = document.createElement('div')
|
|
141
|
+
pin.className = 'sb-comment-pin sb-comment-pin-failed absolute br-100 sb-bg pointer sb-shadow pe-auto overflow-hidden'
|
|
142
|
+
pin.style.left = `${p.x}%`
|
|
143
|
+
pin.style.top = `${p.y}%`
|
|
144
|
+
pin.title = `⚠ Failed to post — click to retry: ${p.text?.slice(0, 60) ?? ''}`
|
|
145
|
+
|
|
146
|
+
pin.innerHTML = p.author?.avatarUrl
|
|
147
|
+
? `<img class="br-100 db sb-pin-img" src="${esc(p.author.avatarUrl)}" alt="${esc(p.author.login)}" draggable="false" />`
|
|
148
|
+
: ''
|
|
149
|
+
|
|
150
|
+
pin.addEventListener('click', async (e) => {
|
|
151
|
+
e.stopPropagation()
|
|
152
|
+
pin.classList.remove('sb-comment-pin-failed')
|
|
153
|
+
pin.classList.add('sb-comment-pin-pending')
|
|
154
|
+
pin.title = 'Retrying…'
|
|
155
|
+
try {
|
|
156
|
+
await createComment(route, p.x, p.y, p.text)
|
|
157
|
+
removePendingComment(route, p.id)
|
|
158
|
+
pin.remove()
|
|
159
|
+
reloadComments()
|
|
160
|
+
} catch {
|
|
161
|
+
pin.classList.remove('sb-comment-pin-pending')
|
|
162
|
+
pin.classList.add('sb-comment-pin-failed')
|
|
163
|
+
pin.title = `⚠ Failed to post — click to retry: ${p.text?.slice(0, 60) ?? ''}`
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
ov.appendChild(pin)
|
|
168
|
+
renderedPins.push(pin)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
77
172
|
function renderPin(ov, comment, index) {
|
|
78
173
|
const hue = Math.round((index * 137.5) % 360)
|
|
79
174
|
const pin = document.createElement('div')
|
|
@@ -97,21 +192,19 @@ function renderPin(ov, comment, index) {
|
|
|
97
192
|
pin.addEventListener('mousedown', (e) => {
|
|
98
193
|
if (e.button !== 0) return
|
|
99
194
|
dragged = false
|
|
100
|
-
const container = getContentContainer()
|
|
101
|
-
const containerRect = container.getBoundingClientRect()
|
|
102
195
|
const startX = e.clientX
|
|
103
196
|
const startY = e.clientY
|
|
104
|
-
const
|
|
105
|
-
const
|
|
197
|
+
const startLeftPct = parseFloat(pin.style.left)
|
|
198
|
+
const startTopPct = parseFloat(pin.style.top)
|
|
106
199
|
|
|
107
200
|
const onMove = (ev) => {
|
|
108
201
|
const dx = ev.clientX - startX
|
|
109
202
|
const dy = ev.clientY - startY
|
|
110
203
|
if (!dragged && Math.abs(dx) < 4 && Math.abs(dy) < 4) return
|
|
111
204
|
dragged = true
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
const yPct = Math.round((
|
|
205
|
+
const xPct = Math.round((startLeftPct + (dx / window.innerWidth) * 100) * 10) / 10
|
|
206
|
+
const docHeight = document.documentElement.scrollHeight
|
|
207
|
+
const yPct = Math.round((startTopPct + (dy / docHeight) * 100) * 10) / 10
|
|
115
208
|
pin.style.left = `${xPct}%`
|
|
116
209
|
pin.style.top = `${yPct}%`
|
|
117
210
|
}
|
|
@@ -121,11 +214,11 @@ function renderPin(ov, comment, index) {
|
|
|
121
214
|
document.removeEventListener('mouseup', onUp)
|
|
122
215
|
if (!dragged) return
|
|
123
216
|
|
|
124
|
-
const cr = container.getBoundingClientRect()
|
|
125
217
|
const dx = ev.clientX - startX
|
|
126
218
|
const dy = ev.clientY - startY
|
|
127
|
-
const xPct = Math.round((
|
|
128
|
-
const
|
|
219
|
+
const xPct = Math.round((startLeftPct + (dx / window.innerWidth) * 100) * 10) / 10
|
|
220
|
+
const docHeight = document.documentElement.scrollHeight
|
|
221
|
+
const yPct = Math.round((startTopPct + (dy / docHeight) * 100) * 10) / 10
|
|
129
222
|
comment.meta = { ...comment.meta, x: xPct, y: yPct }
|
|
130
223
|
|
|
131
224
|
try {
|
|
@@ -183,6 +276,7 @@ function renderCachedPins() {
|
|
|
183
276
|
renderPin(ov, comment, i)
|
|
184
277
|
}
|
|
185
278
|
})
|
|
279
|
+
renderPendingPins(ov)
|
|
186
280
|
}
|
|
187
281
|
|
|
188
282
|
async function loadAndRenderComments() {
|
|
@@ -206,13 +300,17 @@ async function loadAndRenderComments() {
|
|
|
206
300
|
setCachedComments(route, discussion)
|
|
207
301
|
}
|
|
208
302
|
clearPins()
|
|
209
|
-
if (!discussion?.comments?.length)
|
|
303
|
+
if (!discussion?.comments?.length) {
|
|
304
|
+
renderPendingPins(ov)
|
|
305
|
+
return
|
|
306
|
+
}
|
|
210
307
|
|
|
211
308
|
discussion.comments.forEach((comment, i) => {
|
|
212
309
|
if (comment.meta?.x != null && comment.meta?.y != null) {
|
|
213
310
|
renderPin(ov, comment, i)
|
|
214
311
|
}
|
|
215
312
|
})
|
|
313
|
+
renderPendingPins(ov)
|
|
216
314
|
|
|
217
315
|
autoOpenCommentFromUrl(ov, discussion)
|
|
218
316
|
} catch (err) {
|
|
@@ -228,9 +326,9 @@ async function autoOpenCommentFromUrl(ov, discussion) {
|
|
|
228
326
|
if (!comment) return
|
|
229
327
|
|
|
230
328
|
if (comment.meta?.y != null) {
|
|
231
|
-
const
|
|
232
|
-
const yPx = (comment.meta.y / 100) *
|
|
233
|
-
const viewTop =
|
|
329
|
+
const docHeight = document.documentElement.scrollHeight
|
|
330
|
+
const yPx = (comment.meta.y / 100) * docHeight
|
|
331
|
+
const viewTop = window.scrollY
|
|
234
332
|
const viewBottom = viewTop + window.innerHeight
|
|
235
333
|
if (yPx < viewTop || yPx > viewBottom) {
|
|
236
334
|
const scrollTarget = Math.max(0, yPx - window.innerHeight / 3)
|
|
@@ -272,17 +370,29 @@ function handleOverlayClick(e) {
|
|
|
272
370
|
activeComposer = null
|
|
273
371
|
}
|
|
274
372
|
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
const
|
|
278
|
-
const yPct = Math.round(((e.clientY
|
|
373
|
+
// x as percentage of viewport width, y as percentage of full document height
|
|
374
|
+
const xPct = Math.round((e.clientX / window.innerWidth) * 1000) / 10
|
|
375
|
+
const docHeight = document.documentElement.scrollHeight
|
|
376
|
+
const yPct = Math.round(((e.clientY + window.scrollY) / docHeight) * 1000) / 10
|
|
279
377
|
|
|
280
378
|
const ov = ensureOverlay()
|
|
281
|
-
|
|
379
|
+
const route = getCurrentRoute()
|
|
380
|
+
activeComposer = showComposer(ov, xPct, yPct, route, {
|
|
282
381
|
onCancel: () => { activeComposer = null },
|
|
283
|
-
|
|
382
|
+
onSubmitOptimistic: (text) => {
|
|
284
383
|
activeComposer = null
|
|
285
|
-
|
|
384
|
+
const user = getCachedUser()
|
|
385
|
+
const opt = renderOptimisticPin(ov, xPct, yPct, text, user)
|
|
386
|
+
// Fire API call in background
|
|
387
|
+
createComment(route, xPct, yPct, text)
|
|
388
|
+
.then(() => {
|
|
389
|
+
opt.succeed()
|
|
390
|
+
reloadComments()
|
|
391
|
+
})
|
|
392
|
+
.catch((err) => {
|
|
393
|
+
console.error('[storyboard] Failed to post comment:', err)
|
|
394
|
+
opt.fail()
|
|
395
|
+
})
|
|
286
396
|
},
|
|
287
397
|
})
|
|
288
398
|
}
|
|
@@ -327,17 +437,6 @@ export function mountComments() {
|
|
|
327
437
|
|
|
328
438
|
subscribeToCommentMode(setBodyCommentMode)
|
|
329
439
|
|
|
330
|
-
// Click handler for placing comments — uses document so devtools/modals can be excluded
|
|
331
|
-
document.addEventListener('click', (e) => {
|
|
332
|
-
if (!isCommentModeActive()) return
|
|
333
|
-
// Let devtools, modals, drawers, and existing comment UI handle their own clicks
|
|
334
|
-
if (e.target.closest('.sb-devtools-wrapper') || e.target.closest('.sb-auth-backdrop') ||
|
|
335
|
-
e.target.closest('.sb-comments-drawer') || e.target.closest('.sb-comments-drawer-backdrop') ||
|
|
336
|
-
e.target.closest('.sb-composer') || e.target.closest('.sb-comment-pin') ||
|
|
337
|
-
e.target.closest('.sb-comment-window')) return
|
|
338
|
-
handleOverlayClick(e)
|
|
339
|
-
})
|
|
340
|
-
|
|
341
440
|
window.addEventListener('keydown', (e) => {
|
|
342
441
|
const tag = e.target.tagName
|
|
343
442
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || e.target.isContentEditable) {
|
|
@@ -21,12 +21,16 @@ vi.mock('../api.js', () => ({
|
|
|
21
21
|
fetchRouteCommentsSummary: vi.fn(),
|
|
22
22
|
fetchCommentDetail: vi.fn(),
|
|
23
23
|
moveComment: vi.fn(),
|
|
24
|
+
createComment: vi.fn(),
|
|
24
25
|
}))
|
|
25
26
|
|
|
26
27
|
vi.mock('../commentCache.js', () => ({
|
|
27
28
|
getCachedComments: vi.fn(() => null),
|
|
28
29
|
setCachedComments: vi.fn(),
|
|
29
30
|
clearCachedComments: vi.fn(),
|
|
31
|
+
savePendingComment: vi.fn(),
|
|
32
|
+
getPendingComments: vi.fn(() => []),
|
|
33
|
+
removePendingComment: vi.fn(),
|
|
30
34
|
}))
|
|
31
35
|
|
|
32
36
|
vi.mock('./composer.js', () => ({
|
|
@@ -74,11 +78,15 @@ describe('mount.js', () => {
|
|
|
74
78
|
fetchRouteCommentsSummary: vi.fn(),
|
|
75
79
|
fetchCommentDetail: vi.fn(),
|
|
76
80
|
moveComment: vi.fn(),
|
|
81
|
+
createComment: vi.fn(),
|
|
77
82
|
}))
|
|
78
83
|
vi.doMock('../commentCache.js', () => ({
|
|
79
84
|
getCachedComments: vi.fn(() => null),
|
|
80
85
|
setCachedComments: vi.fn(),
|
|
81
86
|
clearCachedComments: vi.fn(),
|
|
87
|
+
savePendingComment: vi.fn(),
|
|
88
|
+
getPendingComments: vi.fn(() => []),
|
|
89
|
+
removePendingComment: vi.fn(),
|
|
82
90
|
}))
|
|
83
91
|
vi.doMock('./composer.js', () => ({ showComposer: vi.fn() }))
|
|
84
92
|
vi.doMock('./authModal.js', () => ({ openAuthModal: vi.fn() }))
|
package/src/devtools.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { loadScene } from './loader.js'
|
|
17
17
|
import { isCommentsEnabled } from './comments/config.js'
|
|
18
18
|
import { isHideMode, activateHideMode, deactivateHideMode } from './hideMode.js'
|
|
19
|
+
import { getAllFlags, toggleFlag, getFlagKeys } from './featureFlags.js'
|
|
19
20
|
|
|
20
21
|
const STYLES = `
|
|
21
22
|
.sb-devtools-wrapper {
|
|
@@ -67,7 +68,7 @@ const STYLES = `
|
|
|
67
68
|
border: none;
|
|
68
69
|
color: #c9d1d9;
|
|
69
70
|
font-size: 14px;
|
|
70
|
-
font-family:
|
|
71
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
71
72
|
cursor: pointer;
|
|
72
73
|
text-align: left;
|
|
73
74
|
}
|
|
@@ -101,6 +102,7 @@ const STYLES = `
|
|
|
101
102
|
width: 100%;
|
|
102
103
|
max-width: 640px;
|
|
103
104
|
max-height: 60vh;
|
|
105
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
104
106
|
background-color: #0d1117;
|
|
105
107
|
border: 1px solid #30363d;
|
|
106
108
|
border-radius: 12px;
|
|
@@ -151,6 +153,19 @@ const STYLES = `
|
|
|
151
153
|
word-break: break-word;
|
|
152
154
|
}
|
|
153
155
|
.sb-devtools-error { color: #f85149; }
|
|
156
|
+
.sb-devtools-separator {
|
|
157
|
+
height: 1px;
|
|
158
|
+
background-color: #21262d;
|
|
159
|
+
margin: 4px 0;
|
|
160
|
+
}
|
|
161
|
+
.sb-devtools-group-header {
|
|
162
|
+
padding: 6px 16px 2px;
|
|
163
|
+
font-size: 12px;
|
|
164
|
+
font-weight: 600;
|
|
165
|
+
color: #8b949e;
|
|
166
|
+
text-transform: uppercase;
|
|
167
|
+
letter-spacing: 0.5px;
|
|
168
|
+
}
|
|
154
169
|
`
|
|
155
170
|
|
|
156
171
|
// SVG icons (inline to avoid external deps)
|
|
@@ -161,6 +176,8 @@ const VIEWFINDER_ICON = '<svg viewBox="0 0 16 16"><path d="M8.5 1.75a.75.75 0 0
|
|
|
161
176
|
const X_ICON = '<svg viewBox="0 0 16 16"><path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"/></svg>'
|
|
162
177
|
const EYE_ICON = '<svg viewBox="0 0 16 16"><path d="M8 2c1.981 0 3.671.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.62 1.62 0 0 1 0 1.798c-.45.678-1.367 1.932-2.637 3.023C11.67 13.008 9.981 14 8 14s-3.671-.992-4.933-2.078C1.797 10.831.88 9.577.43 8.899a1.62 1.62 0 0 1 0-1.798c.45-.678 1.367-1.932 2.637-3.023C4.33 2.992 6.019 2 8 2ZM1.679 7.932a.12.12 0 0 0 0 .136c.411.622 1.241 1.75 2.366 2.717C5.176 11.758 6.527 12.5 8 12.5s2.823-.742 3.955-1.715c1.124-.967 1.954-2.096 2.366-2.717a.12.12 0 0 0 0-.136c-.412-.621-1.242-1.75-2.366-2.717C10.824 4.242 9.473 3.5 8 3.5s-2.824.742-3.955 1.715c-1.124.967-1.954 2.096-2.366 2.717ZM8 10a2 2 0 1 1-.001-3.999A2 2 0 0 1 8 10Z"/></svg>'
|
|
163
178
|
const EYE_CLOSED_ICON = '<svg viewBox="0 0 16 16"><path d="M.143 2.31a.75.75 0 0 1 1.047-.167l14.5 10.5a.75.75 0 1 1-.88 1.214l-2.248-1.628C11.346 13.19 9.792 14 8 14c-1.981 0-3.671-.992-4.933-2.078C1.797 10.831.88 9.577.43 8.899a1.62 1.62 0 0 1 0-1.798c.35-.527 1.06-1.476 2.019-2.398L.31 3.357A.75.75 0 0 1 .143 2.31Zm3.386 3.378a14.21 14.21 0 0 0-1.85 2.244.12.12 0 0 0 0 .136c.411.622 1.241 1.75 2.366 2.717C5.176 11.758 6.527 12.5 8 12.5c1.195 0 2.31-.488 3.29-1.191L9.063 9.695A2 2 0 0 1 6.058 7.39L3.529 5.688ZM8 3.5c-.516 0-1.017.09-1.499.251a.75.75 0 1 1-.473-1.423A6.23 6.23 0 0 1 8 2c1.981 0 3.671.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.62 1.62 0 0 1 0 1.798c-.11.166-.248.365-.41.587a.75.75 0 1 1-1.21-.887c.14-.191.26-.367.36-.524a.12.12 0 0 0 0-.136c-.412-.621-1.242-1.75-2.366-2.717C10.824 4.242 9.473 3.5 8 3.5Z"/></svg>'
|
|
179
|
+
const CHECK_ICON = '<svg viewBox="0 0 16 16"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/></svg>'
|
|
180
|
+
const ZAP_ICON = '<svg viewBox="0 0 16 16"><path d="M9.504.43a1.516 1.516 0 0 1 2.437 1.713L10.415 5.5h2.123c1.57 0 2.346 1.909 1.22 3.004l-7.34 7.142a1.249 1.249 0 0 1-.871.354h-.302a1.25 1.25 0 0 1-1.157-1.723L5.633 10.5H3.462c-1.57 0-2.346-1.909-1.22-3.004Z"/></svg>'
|
|
164
181
|
|
|
165
182
|
function getSceneName() {
|
|
166
183
|
return new URLSearchParams(window.location.search).get('scene') || 'default'
|
|
@@ -227,10 +244,11 @@ export function mountDevTools(options = {}) {
|
|
|
227
244
|
hint.className = 'sb-devtools-hint'
|
|
228
245
|
hint.innerHTML = 'Press <code>⌘ + .</code> to hide'
|
|
229
246
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
menu
|
|
233
|
-
|
|
247
|
+
// Feature flags entry (opens a dedicated panel)
|
|
248
|
+
const featureFlagsBtn = document.createElement('button')
|
|
249
|
+
featureFlagsBtn.className = 'sb-devtools-menu-item'
|
|
250
|
+
featureFlagsBtn.innerHTML = `${ZAP_ICON} Feature Flags`
|
|
251
|
+
featureFlagsBtn.addEventListener('click', openFlagsPanel)
|
|
234
252
|
|
|
235
253
|
// Comments menu items (injected dynamically if comments are enabled)
|
|
236
254
|
function refreshCommentMenuItems() {
|
|
@@ -258,19 +276,104 @@ export function mountDevTools(options = {}) {
|
|
|
258
276
|
})
|
|
259
277
|
}
|
|
260
278
|
|
|
279
|
+
function renderMainMenu() {
|
|
280
|
+
while (menu.firstChild) menu.removeChild(menu.firstChild)
|
|
281
|
+
menu.appendChild(viewfinderBtn)
|
|
282
|
+
menu.appendChild(showInfoBtn)
|
|
283
|
+
menu.appendChild(resetBtn)
|
|
284
|
+
menu.appendChild(hideModeBtn)
|
|
285
|
+
if (getFlagKeys().length > 0) {
|
|
286
|
+
const sep = document.createElement('div')
|
|
287
|
+
sep.className = 'sb-devtools-separator'
|
|
288
|
+
menu.appendChild(sep)
|
|
289
|
+
menu.appendChild(featureFlagsBtn)
|
|
290
|
+
}
|
|
291
|
+
refreshCommentMenuItems()
|
|
292
|
+
menu.appendChild(hint)
|
|
293
|
+
}
|
|
294
|
+
|
|
261
295
|
// Refresh dynamic items when menu opens
|
|
262
296
|
trigger.addEventListener('click', () => {
|
|
263
|
-
|
|
297
|
+
renderMainMenu()
|
|
264
298
|
updateHideModeBtn()
|
|
265
299
|
})
|
|
266
300
|
|
|
267
|
-
menu.
|
|
301
|
+
// Build initial (closed) menu content so tests and static DOM inspection work.
|
|
302
|
+
renderMainMenu()
|
|
268
303
|
wrapper.appendChild(menu)
|
|
269
304
|
wrapper.appendChild(trigger)
|
|
270
305
|
container.appendChild(wrapper)
|
|
271
306
|
|
|
272
|
-
//
|
|
307
|
+
// Overlays (created lazily)
|
|
273
308
|
let overlay = null
|
|
309
|
+
let flagsOverlay = null
|
|
310
|
+
|
|
311
|
+
function closeFlagsPanel() {
|
|
312
|
+
if (flagsOverlay) {
|
|
313
|
+
flagsOverlay.remove()
|
|
314
|
+
flagsOverlay = null
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function openFlagsPanel() {
|
|
319
|
+
menuOpen = false
|
|
320
|
+
menu.classList.remove('open')
|
|
321
|
+
closeFlagsPanel()
|
|
322
|
+
|
|
323
|
+
flagsOverlay = document.createElement('div')
|
|
324
|
+
flagsOverlay.className = 'sb-devtools-overlay'
|
|
325
|
+
|
|
326
|
+
const backdrop = document.createElement('div')
|
|
327
|
+
backdrop.className = 'sb-devtools-backdrop'
|
|
328
|
+
backdrop.addEventListener('click', closeFlagsPanel)
|
|
329
|
+
|
|
330
|
+
const panel = document.createElement('div')
|
|
331
|
+
panel.className = 'sb-devtools-panel'
|
|
332
|
+
|
|
333
|
+
const header = document.createElement('div')
|
|
334
|
+
header.className = 'sb-devtools-panel-header'
|
|
335
|
+
header.innerHTML = '<span class="sb-devtools-panel-title">Feature Flags</span>'
|
|
336
|
+
|
|
337
|
+
const closeBtn = document.createElement('button')
|
|
338
|
+
closeBtn.className = 'sb-devtools-panel-close'
|
|
339
|
+
closeBtn.setAttribute('aria-label', 'Close feature flags panel')
|
|
340
|
+
closeBtn.innerHTML = X_ICON
|
|
341
|
+
closeBtn.addEventListener('click', closeFlagsPanel)
|
|
342
|
+
header.appendChild(closeBtn)
|
|
343
|
+
|
|
344
|
+
const body = document.createElement('div')
|
|
345
|
+
body.className = 'sb-devtools-panel-body'
|
|
346
|
+
|
|
347
|
+
function renderFlagItems() {
|
|
348
|
+
body.innerHTML = ''
|
|
349
|
+
const keys = getFlagKeys()
|
|
350
|
+
if (keys.length === 0) {
|
|
351
|
+
body.innerHTML = '<span class="sb-devtools-hint">No feature flags are configured.</span>'
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
const flags = getAllFlags()
|
|
355
|
+
for (const key of keys) {
|
|
356
|
+
const btn = document.createElement('button')
|
|
357
|
+
btn.className = 'sb-devtools-menu-item'
|
|
358
|
+
const icon = flags[key].current
|
|
359
|
+
? `<span style="width:16px;height:16px;display:flex;align-items:center;justify-content:center;">${CHECK_ICON}</span>`
|
|
360
|
+
: '<span style="width:16px;height:16px;"></span>'
|
|
361
|
+
btn.innerHTML = `${icon} ${key}`
|
|
362
|
+
btn.addEventListener('click', () => {
|
|
363
|
+
toggleFlag(key)
|
|
364
|
+
renderFlagItems()
|
|
365
|
+
})
|
|
366
|
+
body.appendChild(btn)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
renderFlagItems()
|
|
370
|
+
|
|
371
|
+
panel.appendChild(header)
|
|
372
|
+
panel.appendChild(body)
|
|
373
|
+
flagsOverlay.appendChild(backdrop)
|
|
374
|
+
flagsOverlay.appendChild(panel)
|
|
375
|
+
container.appendChild(flagsOverlay)
|
|
376
|
+
}
|
|
274
377
|
|
|
275
378
|
function openPanel() {
|
|
276
379
|
menuOpen = false
|
|
@@ -367,7 +470,7 @@ export function mountDevTools(options = {}) {
|
|
|
367
470
|
menu.classList.remove('open')
|
|
368
471
|
})
|
|
369
472
|
|
|
370
|
-
// Close menu when clicking outside
|
|
473
|
+
// Close menu when clicking outside — reset to main view
|
|
371
474
|
document.addEventListener('click', (e) => {
|
|
372
475
|
if (menuOpen && !wrapper.contains(e.target)) {
|
|
373
476
|
menuOpen = false
|
|
@@ -385,6 +488,7 @@ export function mountDevTools(options = {}) {
|
|
|
385
488
|
menuOpen = false
|
|
386
489
|
menu.classList.remove('open')
|
|
387
490
|
closePanel()
|
|
491
|
+
closeFlagsPanel()
|
|
388
492
|
}
|
|
389
493
|
}
|
|
390
494
|
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feature flag system for Storyboard.
|
|
3
|
+
*
|
|
4
|
+
* Flags are defined in storyboard.config.json under "featureFlags" and
|
|
5
|
+
* initialized at app startup via the Vite data plugin.
|
|
6
|
+
*
|
|
7
|
+
* Read priority: URL hash → localStorage → config defaults
|
|
8
|
+
* Write target: URL hash (shareable)
|
|
9
|
+
*
|
|
10
|
+
* All flag keys in hash/localStorage are prefixed with "flag." to avoid
|
|
11
|
+
* collisions with scene overrides.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { getParam, setParam, removeParam, getAllParams } from './session.js'
|
|
15
|
+
import { getLocal, setLocal, removeLocal, getAllLocal } from './localStorage.js'
|
|
16
|
+
|
|
17
|
+
const FLAG_PREFIX = 'flag.'
|
|
18
|
+
|
|
19
|
+
/** Module-level storage for config defaults */
|
|
20
|
+
let _defaults = {}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Initialize the feature flag system with config defaults.
|
|
24
|
+
* Seeds localStorage with defaults (doesn't overwrite existing values).
|
|
25
|
+
* @param {Record<string, boolean>} defaults - Flag key → default value
|
|
26
|
+
*/
|
|
27
|
+
export function initFeatureFlags(defaults = {}) {
|
|
28
|
+
_defaults = { ...defaults }
|
|
29
|
+
// Seed localStorage with defaults (don't overwrite existing)
|
|
30
|
+
for (const [key, value] of Object.entries(_defaults)) {
|
|
31
|
+
if (getLocal(FLAG_PREFIX + key) === null) {
|
|
32
|
+
setLocal(FLAG_PREFIX + key, String(value))
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Read a flag value. Priority: hash → localStorage → config default.
|
|
39
|
+
* @param {string} key - Flag key (without prefix)
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
export function getFlag(key) {
|
|
43
|
+
// 1. URL hash (highest priority)
|
|
44
|
+
const hashVal = getParam(FLAG_PREFIX + key)
|
|
45
|
+
if (hashVal !== null) return hashVal === 'true'
|
|
46
|
+
|
|
47
|
+
// 2. localStorage
|
|
48
|
+
const localVal = getLocal(FLAG_PREFIX + key)
|
|
49
|
+
if (localVal !== null) return localVal === 'true'
|
|
50
|
+
|
|
51
|
+
// 3. Config default
|
|
52
|
+
return _defaults[key] ?? false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Set a flag value. Writes to URL hash for shareability.
|
|
57
|
+
* @param {string} key - Flag key (without prefix)
|
|
58
|
+
* @param {boolean} value
|
|
59
|
+
*/
|
|
60
|
+
export function setFlag(key, value) {
|
|
61
|
+
setParam(FLAG_PREFIX + key, String(value))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Toggle a flag. Reads current value, writes opposite to hash.
|
|
66
|
+
* @param {string} key - Flag key (without prefix)
|
|
67
|
+
*/
|
|
68
|
+
export function toggleFlag(key) {
|
|
69
|
+
setFlag(key, !getFlag(key))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get all flags with their default and current (resolved) values.
|
|
74
|
+
* @returns {Record<string, { default: boolean, current: boolean }>}
|
|
75
|
+
*/
|
|
76
|
+
export function getAllFlags() {
|
|
77
|
+
const result = {}
|
|
78
|
+
for (const key of Object.keys(_defaults)) {
|
|
79
|
+
result[key] = {
|
|
80
|
+
default: _defaults[key] ?? false,
|
|
81
|
+
current: getFlag(key),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return result
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Reset all flags — removes hash and localStorage overrides.
|
|
89
|
+
* Flags revert to config defaults.
|
|
90
|
+
*/
|
|
91
|
+
export function resetFlags() {
|
|
92
|
+
const allParams = getAllParams()
|
|
93
|
+
for (const paramKey of Object.keys(allParams)) {
|
|
94
|
+
if (paramKey.startsWith(FLAG_PREFIX)) {
|
|
95
|
+
removeParam(paramKey)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const allLocal = getAllLocal()
|
|
99
|
+
for (const localKey of Object.keys(allLocal)) {
|
|
100
|
+
if (localKey.startsWith(FLAG_PREFIX)) {
|
|
101
|
+
removeLocal(localKey)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get all registered flag keys.
|
|
108
|
+
* @returns {string[]}
|
|
109
|
+
*/
|
|
110
|
+
export function getFlagKeys() {
|
|
111
|
+
return Object.keys(_defaults)
|
|
112
|
+
}
|
package/src/index.js
CHANGED
|
@@ -37,5 +37,8 @@ export { mountSceneDebug } from './sceneDebug.js'
|
|
|
37
37
|
// Viewfinder utilities
|
|
38
38
|
export { hash, resolveSceneRoute, getSceneMeta } from './viewfinder.js'
|
|
39
39
|
|
|
40
|
+
// Feature flags
|
|
41
|
+
export { initFeatureFlags, getFlag, setFlag, toggleFlag, getAllFlags, resetFlags, getFlagKeys } from './featureFlags.js'
|
|
42
|
+
|
|
40
43
|
// Comments system
|
|
41
44
|
export { initCommentsConfig, getCommentsConfig, isCommentsEnabled } from './comments/config.js'
|