@marver-design/marver 0.3.1 → 0.4.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/CHANGELOG.md +89 -0
- package/README.md +2 -0
- package/dist/auth-B36fMCM3.mjs +245 -0
- package/dist/{build-Ckmyci3O.mjs → build-p3xmXU3b.mjs} +52 -12
- package/dist/cli.mjs +15 -6
- package/dist/collab-CXy8gqoz.mjs +297 -0
- package/dist/comments-Ba8mU600.mjs +90 -0
- package/dist/comments-odHzYdO3.mjs +179 -0
- package/dist/{dev-C2oTKuXe.mjs → dev-DZi1yRhn.mjs} +17 -1
- package/dist/{init-DolP_Ld4.mjs → init-Ck8z-HiD.mjs} +1 -0
- package/dist/{plugin-Crp4CAma.mjs → plugin-CtQqO5ZZ.mjs} +114 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/serve-D_KBK7Oy.mjs +431 -0
- package/dist/sync-CkBk-tUk.mjs +248 -0
- package/package.json +2 -1
- package/src/client/frame-host/bridge.js +231 -0
- package/src/client/shell/App.tsx +73 -7
- package/src/client/shell/Comments.tsx +463 -0
- package/src/client/shell/canvas/FrameNode.tsx +32 -2
- package/src/client/shell/comments-store.ts +180 -0
- package/src/client/shell/hash.ts +14 -3
- package/src/client/shell/icons.tsx +6 -0
- package/src/client/shell/store.ts +4 -1
- package/src/client/shell/styles.css +159 -0
- package/src/shared/events.ts +103 -0
- package/templates/AGENTS-embedded.md +13 -1
- package/templates/AGENTS-studio.md +13 -1
- package/templates/instructions/boards.md +6 -3
- package/templates/instructions/configure.md +28 -0
- package/templates/instructions/iterate.md +38 -0
- package/templates/instructions/publish.md +129 -0
- package/dist/serve-OtA9Nlow.mjs +0 -207
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comment surfaces on the canvas (SPEC-M3 §6). Canvas-first: pins live ON the frame
|
|
3
|
+
* at their anchored elements; an inactive frame collapses its open threads into a
|
|
4
|
+
* top-right stack; the thread card opens beside its pin. Pins keep a screen-space
|
|
5
|
+
* size via --sh-inv (the vbadge pattern) so zoom never shrinks them away.
|
|
6
|
+
*/
|
|
7
|
+
import { useEffect, useRef, useState } from 'react'
|
|
8
|
+
import { avatarFallback, useComments } from './comments-store.ts'
|
|
9
|
+
import { useStore, type Node } from './store.ts'
|
|
10
|
+
import { canvasCtl } from './canvas/Canvas.tsx'
|
|
11
|
+
import { bootHash, buildHash, parseHash, writeHash } from './hash.ts'
|
|
12
|
+
import { ArrowUpIcon, CheckIcon, CheckSquareOffsetIcon, LinkIcon, XIcon } from './icons.tsx'
|
|
13
|
+
import { Tip } from './Tip.tsx'
|
|
14
|
+
import { ROUTE } from '../const.ts'
|
|
15
|
+
import type { Thread } from '../../shared/events.ts'
|
|
16
|
+
|
|
17
|
+
const rel = (ts: number) => {
|
|
18
|
+
const m = Math.round((Date.now() - ts) / 60_000)
|
|
19
|
+
if (m < 1) return 'now'
|
|
20
|
+
if (m < 60) return `${m}m`
|
|
21
|
+
if (m < 24 * 60) return `${Math.round(m / 60)}h`
|
|
22
|
+
return `${Math.round(m / 1440)}d`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function Avatar({ author, size = 22 }: { author?: { email?: string; name?: string; avatar?: string }; size?: number }) {
|
|
26
|
+
if (author?.avatar) return <img className="cm-avatar" src={author.avatar} width={size} height={size} alt="" />
|
|
27
|
+
const { initials, hue } = avatarFallback(author)
|
|
28
|
+
return (
|
|
29
|
+
<span className="cm-avatar" style={{ width: size, height: size, fontSize: size * 0.42, background: `hsl(${hue} 55% 45%)` }}>
|
|
30
|
+
{initials}
|
|
31
|
+
</span>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Face = { email?: string; name?: string; avatar?: string }
|
|
36
|
+
|
|
37
|
+
/** The signal a marker (pin or stack) carries: WHO took part and HOW MANY comments
|
|
38
|
+
* in total - root + every reply, across all the given threads. Unique participants
|
|
39
|
+
* in first-seen order; a single reply-less comment counts as 1. */
|
|
40
|
+
function markerDigest(threads: Thread[]): { faces: Face[]; count: number } {
|
|
41
|
+
const seen = new Map<string, Face>()
|
|
42
|
+
let count = 0
|
|
43
|
+
// namespace the identity key so an email can never collide with someone's name,
|
|
44
|
+
// and normalize email case; authorless comments collapse to one 'anon' face
|
|
45
|
+
const add = (a?: Face) => { if (a) { const k = a.email ? `e:${a.email.toLowerCase()}` : a.name ? `n:${a.name}` : 'anon'; if (!seen.has(k)) seen.set(k, a) } }
|
|
46
|
+
for (const t of threads) {
|
|
47
|
+
count += 1 + t.replies.length
|
|
48
|
+
add(t.author)
|
|
49
|
+
for (const r of t.replies) add(r.author)
|
|
50
|
+
}
|
|
51
|
+
return { faces: [...seen.values()], count }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The face of a marker: up to `max` overlapping avatars + the total count when >1.
|
|
55
|
+
* One shape for pins (one thread, anchored) and stacks (all threads on a frame). */
|
|
56
|
+
function MarkerFace({ threads, max = 3 }: { threads: Thread[]; max?: number }) {
|
|
57
|
+
const { faces, count } = markerDigest(threads)
|
|
58
|
+
return (
|
|
59
|
+
<>
|
|
60
|
+
{faces.slice(0, max).map((a, i) => <Avatar key={i} author={a} size={24} />)}
|
|
61
|
+
{count > 1 && <b>{count}</b>}
|
|
62
|
+
</>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Lives inside FrameNode's body, over the iframe. Owns anchor resolution for its frame. */
|
|
67
|
+
export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: string; iframe: React.RefObject<HTMLIFrameElement | null> }) {
|
|
68
|
+
const show = useComments((s) => s.show)
|
|
69
|
+
const active = useComments((s) => s.active)
|
|
70
|
+
const draft = useComments((s) => s.draft)
|
|
71
|
+
// select the stable array, filter in render - a selector returning a fresh
|
|
72
|
+
// .filter() array re-renders forever (Object.is never matches)
|
|
73
|
+
const allThreads = useComments((s) => s.threads)
|
|
74
|
+
const threads = allThreads.filter((t) => t.frame === frameId && (!t.nodeKey || t.nodeKey === node.key))
|
|
75
|
+
const selected = useStore((s) => s.selection.includes(node.key))
|
|
76
|
+
const { setActive } = useComments.getState()
|
|
77
|
+
const [rects, setRects] = useState<Record<string, { x: number; y: number; w: number; h: number } | null>>({})
|
|
78
|
+
|
|
79
|
+
const open = threads.filter((t) => !t.resolved)
|
|
80
|
+
const anchored = open.filter((t) => (t.anchor as any)?.el)
|
|
81
|
+
|
|
82
|
+
// resolve anchors against the live frame whenever threads or the document change
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
const win = iframe.current?.contentWindow
|
|
85
|
+
if (!win || node.status !== 'ready' || !anchored.length) return
|
|
86
|
+
const ask = () => win.postMessage({ type: 'sh:resolve-anchors', anchors: anchored.map((t) => ({ key: t.id, anchor: t.anchor })) }, location.origin)
|
|
87
|
+
const onMsg = (e: MessageEvent) => {
|
|
88
|
+
if (e.source !== win || e.data?.type !== 'sh:anchor-rects') return
|
|
89
|
+
const next: typeof rects = {}
|
|
90
|
+
for (const r of e.data.rects ?? []) next[r.key] = r.orphan ? null : r.rect
|
|
91
|
+
setRects(next)
|
|
92
|
+
}
|
|
93
|
+
window.addEventListener('message', onMsg)
|
|
94
|
+
ask()
|
|
95
|
+
const iv = setInterval(ask, 4000) // re-renders, scroll, hot reloads - cheap to re-ask
|
|
96
|
+
return () => { clearInterval(iv); window.removeEventListener('message', onMsg) }
|
|
97
|
+
}, [node.status, anchored.map((t) => t.id).join(','), iframe])
|
|
98
|
+
|
|
99
|
+
const drafting = draft?.nodeKey === node.key
|
|
100
|
+
if (!show || (!open.length && !drafting)) return null
|
|
101
|
+
|
|
102
|
+
// inactive frame: the stack - count + avatars, top-right (SPEC-M3 §6)
|
|
103
|
+
const engaged = selected || open.some((t) => t.id === active) || drafting
|
|
104
|
+
if (!engaged && open.length) {
|
|
105
|
+
return (
|
|
106
|
+
<button className="cm-stack sh-no-pan" onClick={(e) => { e.stopPropagation(); useStore.getState().select(node.key); if (open[0]) setActive(open[0].id) }}>
|
|
107
|
+
<MarkerFace threads={open} />
|
|
108
|
+
</button>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const pinPos = (t: Thread) => {
|
|
113
|
+
const a = t.anchor as any
|
|
114
|
+
const r = rects[t.id]
|
|
115
|
+
if (a?.el && r) return { x: r.x + (a.pos?.fx ?? 0.5) * r.w, y: r.y + (a.pos?.fy ?? 0.5) * r.h, orphan: false }
|
|
116
|
+
if (a?.el && r === null) return { x: node.w - 16, y: 16, orphan: true } // orphan parks top-right
|
|
117
|
+
const p = a?.pos // frame-level: stored fraction of the frame
|
|
118
|
+
return { x: (p?.fx ?? 0.94) * node.w, y: (p?.fy ?? 0.06) * node.h, orphan: false }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<>
|
|
123
|
+
{open.map((t) => {
|
|
124
|
+
const { x, y, orphan } = pinPos(t)
|
|
125
|
+
return (
|
|
126
|
+
<div key={t.id} className={`cm-pin sh-no-pan${t.id === active ? ' on' : ''}${orphan ? ' orphan' : ''}`}
|
|
127
|
+
style={{ left: x, top: y }}
|
|
128
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
129
|
+
onClick={(e) => { e.stopPropagation(); setActive(t.id === active ? null : t.id) }}
|
|
130
|
+
title={orphan ? 'the anchored element is gone - comment parked' : undefined}>
|
|
131
|
+
<MarkerFace threads={[t]} />
|
|
132
|
+
</div>
|
|
133
|
+
)
|
|
134
|
+
})}
|
|
135
|
+
{active && open.some((t) => t.id === active) && (
|
|
136
|
+
<ThreadCard key={active} thread={open.find((t) => t.id === active)!} at={pinPos(open.find((t) => t.id === active)!)} node={node} />
|
|
137
|
+
)}
|
|
138
|
+
{draft?.nodeKey === node.key && <DraftComposer at={{ x: ((draft.anchor as any)?.rect?.x ?? 0) + ((draft.anchor as any)?.pos?.fx ?? 0.5) * ((draft.anchor as any)?.rect?.w ?? 0), y: ((draft.anchor as any)?.rect?.y ?? 0) + ((draft.anchor as any)?.pos?.fy ?? 0.5) * ((draft.anchor as any)?.rect?.h ?? 0) }} node={node} />}
|
|
139
|
+
</>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function ThreadCard({ thread, at, node }: { thread: Thread; at: { x: number; y: number }; node: Node }) {
|
|
144
|
+
const { resolve, setActive } = useComments.getState()
|
|
145
|
+
const me = useComments((s) => s.me)
|
|
146
|
+
const local = useComments((s) => s.local)
|
|
147
|
+
const canComment = local || !!me // dev is always "me"; published needs a session
|
|
148
|
+
const [text, setText] = useState('')
|
|
149
|
+
const [copied, setCopied] = useState(false)
|
|
150
|
+
const copyTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
|
151
|
+
useEffect(() => () => clearTimeout(copyTimer.current), [])
|
|
152
|
+
const flip = at.x > node.w * 0.55
|
|
153
|
+
// clear only after the server took it - a failed send must not eat the words,
|
|
154
|
+
// and text typed WHILE the request was in flight must survive the clear
|
|
155
|
+
const submit = async () => {
|
|
156
|
+
const sent = text
|
|
157
|
+
if (sent.trim() && await useComments.getState().replyOk(thread.id, sent)) setText((cur) => (cur === sent ? '' : cur))
|
|
158
|
+
}
|
|
159
|
+
return (
|
|
160
|
+
<div className={`cm-card sh-no-pan${flip ? ' flip' : ''}`} style={{ left: at.x, top: Math.min(at.y + 14, node.h - 40) }}
|
|
161
|
+
onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()}>
|
|
162
|
+
{/* thread-level actions pin to the card corner, out of the header's flow -
|
|
163
|
+
the name row never has to share its line with them */}
|
|
164
|
+
<div className="cm-actions">
|
|
165
|
+
<Tip side="bottom" label={<b>{copied ? 'Copied' : 'Copy link'}</b>}>
|
|
166
|
+
<button className={`cm-icon cm-copy${copied ? ' ok' : ''}`} onClick={() => {
|
|
167
|
+
const url = `${location.origin}${location.pathname}${buildHash({ board: useStore.getState().board, c: thread.id })}`
|
|
168
|
+
// the check means "it's on your clipboard" - only show it when that's true
|
|
169
|
+
navigator.clipboard.writeText(url).then(() => {
|
|
170
|
+
setCopied(true)
|
|
171
|
+
clearTimeout(copyTimer.current)
|
|
172
|
+
copyTimer.current = setTimeout(() => setCopied(false), 1600)
|
|
173
|
+
}, () => useStore.getState().toast('copy blocked - try again'))
|
|
174
|
+
}}>
|
|
175
|
+
<span className="a"><LinkIcon size={15} /></span>
|
|
176
|
+
<span className="b"><CheckIcon size={16} /></span>
|
|
177
|
+
</button>
|
|
178
|
+
</Tip>
|
|
179
|
+
<Tip side="bottom" label={<b>Resolve</b>}>
|
|
180
|
+
<button className="cm-icon" onClick={() => { void resolve(thread.id); setActive(null) }}><CheckSquareOffsetIcon size={16} /></button>
|
|
181
|
+
</Tip>
|
|
182
|
+
<Tip side="bottom" label={<b>Close</b>}>
|
|
183
|
+
<button className="cm-icon" onClick={() => setActive(null)}><XIcon size={15} /></button>
|
|
184
|
+
</Tip>
|
|
185
|
+
</div>
|
|
186
|
+
<header>
|
|
187
|
+
<Avatar author={thread.author} size={24} />
|
|
188
|
+
<b>{thread.author?.name ?? 'Someone'}</b>
|
|
189
|
+
<span className="dim">{rel(thread.ts)}</span>
|
|
190
|
+
</header>
|
|
191
|
+
<p className="cm-body">{thread.body}</p>
|
|
192
|
+
{/* replies repeat the root's exact message shape (Figma's pattern) - only the icons differ */}
|
|
193
|
+
{thread.replies.map((r) => (
|
|
194
|
+
<div key={r.id} className="cm-msg">
|
|
195
|
+
<header>
|
|
196
|
+
<Avatar author={r.author} size={24} />
|
|
197
|
+
<b>{r.author?.name ?? 'Someone'}</b>
|
|
198
|
+
<span className="dim">{rel(r.ts)}</span>
|
|
199
|
+
</header>
|
|
200
|
+
<p className="cm-body">{r.body}</p>
|
|
201
|
+
</div>
|
|
202
|
+
))}
|
|
203
|
+
{canComment ? (
|
|
204
|
+
<div className="cm-compose">
|
|
205
|
+
<Avatar author={me ?? undefined} size={24} />
|
|
206
|
+
<div className="cm-inputwrap">
|
|
207
|
+
<input value={text} placeholder="Reply…" onChange={(e) => setText(e.target.value)}
|
|
208
|
+
onKeyDown={(e) => { e.stopPropagation(); if (e.key === 'Enter') void submit(); if (e.key === 'Escape') setActive(null) }} />
|
|
209
|
+
<button className="cm-send" disabled={!text.trim()} aria-label="Send" onClick={() => void submit()}>
|
|
210
|
+
<ArrowUpIcon size={15} />
|
|
211
|
+
</button>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
) : (
|
|
215
|
+
<button className="cm-signin-cta" onClick={() => useComments.setState({ needsIdentity: true })}>
|
|
216
|
+
Sign in to comment
|
|
217
|
+
</button>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function DraftComposer({ at, node }: { at: { x: number; y: number }; node: Node }) {
|
|
224
|
+
const { create, setDraft } = useComments.getState()
|
|
225
|
+
const [text, setText] = useState('')
|
|
226
|
+
const ref = useRef<HTMLInputElement>(null)
|
|
227
|
+
useEffect(() => { ref.current?.focus() }, [])
|
|
228
|
+
const flip = at.x > node.w * 0.55
|
|
229
|
+
return (
|
|
230
|
+
<div className={`cm-card cm-draft sh-no-pan${flip ? ' flip' : ''}`} style={{ left: at.x, top: Math.min(at.y + 14, node.h - 40) }}
|
|
231
|
+
onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
|
|
232
|
+
<div className="cm-compose">
|
|
233
|
+
<div className="cm-inputwrap">
|
|
234
|
+
<input ref={ref} value={text} placeholder="Comment on this element…" onChange={(e) => setText(e.target.value)}
|
|
235
|
+
onKeyDown={(e) => {
|
|
236
|
+
e.stopPropagation()
|
|
237
|
+
if (e.key === 'Enter') void create(text)
|
|
238
|
+
if (e.key === 'Escape') setDraft(null)
|
|
239
|
+
}} />
|
|
240
|
+
<button className="cm-send" disabled={!text.trim()} aria-label="Comment" onClick={() => void create(text)}>
|
|
241
|
+
<ArrowUpIcon size={15} />
|
|
242
|
+
</button>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Avatar picker: dashed circle → file input → client-side 128px downscale →
|
|
250
|
+
* data-URI preview. Optional everywhere it appears; never gates a CTA. */
|
|
251
|
+
function AvatarPick({ value, onPick }: { value: string; onPick: (dataUri: string) => void }) {
|
|
252
|
+
const fileRef = useRef<HTMLInputElement>(null)
|
|
253
|
+
const pick = (file: File | undefined) => {
|
|
254
|
+
if (!file) return
|
|
255
|
+
const img = new Image()
|
|
256
|
+
img.onload = () => {
|
|
257
|
+
const s = Math.min(img.width, img.height)
|
|
258
|
+
const c = document.createElement('canvas')
|
|
259
|
+
c.width = c.height = 128
|
|
260
|
+
c.getContext('2d')!.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, 128, 128)
|
|
261
|
+
onPick(c.toDataURL('image/jpeg', 0.85))
|
|
262
|
+
URL.revokeObjectURL(img.src)
|
|
263
|
+
}
|
|
264
|
+
img.src = URL.createObjectURL(file)
|
|
265
|
+
}
|
|
266
|
+
return (
|
|
267
|
+
<>
|
|
268
|
+
<Tip side="top" label={<b>{value ? 'Change your photo' : 'Select your profile picture'}</b>}>
|
|
269
|
+
<button className={`cm-pfp${value ? ' set' : ''}`} aria-label="Select your profile picture"
|
|
270
|
+
style={value ? { backgroundImage: `url(${value})` } : undefined}
|
|
271
|
+
onClick={() => fileRef.current?.click()}>{value ? '' : '+'}</button>
|
|
272
|
+
</Tip>
|
|
273
|
+
<input ref={fileRef} type="file" accept="image/*" hidden onChange={(e) => pick(e.target.files?.[0])} />
|
|
274
|
+
</>
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Signed-out published viewer tried to comment (or arrived on an invite link).
|
|
279
|
+
* Invite link: a focused claim - the token in hand, email shown as a chip.
|
|
280
|
+
* Fallback: sign in (returning device), with a quiet switch to pasting a token.
|
|
281
|
+
* Primary CTAs stay disabled until their mandatory fields are filled; hovering
|
|
282
|
+
* the disabled button says what's missing (avatar never gates). */
|
|
283
|
+
export function IdentityDialog() {
|
|
284
|
+
const needs = useComments((s) => s.needsIdentity)
|
|
285
|
+
const invite = useComments((s) => s.inviteToken)
|
|
286
|
+
const { signIn, claim, dismissIdentity } = useComments.getState()
|
|
287
|
+
const [mode, setMode] = useState<'signin' | 'claim'>('signin')
|
|
288
|
+
const [email, setEmail] = useState(''); const [password, setPassword] = useState('')
|
|
289
|
+
const [token, setToken] = useState(''); const [name, setName] = useState('')
|
|
290
|
+
const [avatar, setAvatar] = useState('')
|
|
291
|
+
const [invitedAs, setInvitedAs] = useState('')
|
|
292
|
+
const [err, setErr] = useState<string | null>(null)
|
|
293
|
+
// an invite link knows WHO it's for - show it (the token is the proof of holding)
|
|
294
|
+
useEffect(() => {
|
|
295
|
+
if (!needs || !invite) return setInvitedAs('')
|
|
296
|
+
fetch(`${ROUTE}/api/invite-info?token=${encodeURIComponent(invite)}`)
|
|
297
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
298
|
+
.then((d) => d?.email && setInvitedAs(d.email))
|
|
299
|
+
.catch(() => { /* chip just stays hidden */ })
|
|
300
|
+
}, [needs, invite])
|
|
301
|
+
if (!needs) return null
|
|
302
|
+
const claiming = !!invite || mode === 'claim'
|
|
303
|
+
const ready = claiming
|
|
304
|
+
? !!(password.trim() && name.trim() && (invite || token.trim()))
|
|
305
|
+
: !!(email.trim() && password.trim())
|
|
306
|
+
const missing = claiming
|
|
307
|
+
? (invite ? 'Password and display name still needed' : 'Token, password, and display name still needed')
|
|
308
|
+
: 'Fill in email and password'
|
|
309
|
+
const go = async () => {
|
|
310
|
+
if (!ready) return
|
|
311
|
+
setErr(null)
|
|
312
|
+
const e = claiming ? await claim(invite ?? token, password, name, avatar || undefined) : await signIn(email, password)
|
|
313
|
+
if (e) return setErr(e)
|
|
314
|
+
// a consumed invite link leaves the URL - the hash becomes the plain board view
|
|
315
|
+
if (invite) writeHash({ board: useStore.getState().board })
|
|
316
|
+
}
|
|
317
|
+
const onEnter = (e: React.KeyboardEvent) => e.key === 'Enter' && void go()
|
|
318
|
+
return (
|
|
319
|
+
<div className="cm-modal-wrap" onClick={dismissIdentity}>
|
|
320
|
+
<div className="cm-modal" onClick={(e) => e.stopPropagation()}>
|
|
321
|
+
<h2>{invite ? 'You’re invited to comment' : claiming ? 'Sign up to comment' : 'Sign in to comment'}</h2>
|
|
322
|
+
<p className="dim">
|
|
323
|
+
{invite
|
|
324
|
+
? <>Pick how you’ll appear - comments carry your name.</>
|
|
325
|
+
: claiming
|
|
326
|
+
? <>Create your account with the invite token your admin sent you.</>
|
|
327
|
+
: <>You’re in read-only. Sign in to your account to comment on this canvas.</>}
|
|
328
|
+
</p>
|
|
329
|
+
<div className="cm-fields">
|
|
330
|
+
{claiming ? (
|
|
331
|
+
<>
|
|
332
|
+
{invite
|
|
333
|
+
? invitedAs && <div className="cm-chip"><b>{invitedAs}</b><span>INVITED</span></div>
|
|
334
|
+
: <input placeholder="Invite token" value={token} onChange={(e) => setToken(e.target.value)} />}
|
|
335
|
+
<input placeholder="Choose a password" type="password" autoComplete="new-password"
|
|
336
|
+
value={password} onChange={(e) => setPassword(e.target.value)} />
|
|
337
|
+
<hr className="cm-div" />
|
|
338
|
+
<div className="cm-idrow">
|
|
339
|
+
<AvatarPick value={avatar} onPick={setAvatar} />
|
|
340
|
+
<input placeholder="Set a display name" style={{ flex: 1 }}
|
|
341
|
+
value={name} onChange={(e) => setName(e.target.value)} onKeyDown={onEnter} />
|
|
342
|
+
</div>
|
|
343
|
+
</>
|
|
344
|
+
) : (
|
|
345
|
+
<>
|
|
346
|
+
<input placeholder="Email" type="email" autoComplete="email"
|
|
347
|
+
value={email} onChange={(e) => setEmail(e.target.value)} />
|
|
348
|
+
<input placeholder="Password" type="password" autoComplete="current-password"
|
|
349
|
+
value={password} onChange={(e) => setPassword(e.target.value)} onKeyDown={onEnter} />
|
|
350
|
+
</>
|
|
351
|
+
)}
|
|
352
|
+
</div>
|
|
353
|
+
{err && <span className="cm-err">{err}</span>}
|
|
354
|
+
<div className="cm-row">
|
|
355
|
+
{ready ? (
|
|
356
|
+
<button className="cm-primary" onClick={() => void go()}>
|
|
357
|
+
{claiming ? (invite ? 'Join the canvas' : 'Create account') : 'Sign in'}
|
|
358
|
+
</button>
|
|
359
|
+
) : (
|
|
360
|
+
<Tip side="top" label={<b>{missing}</b>}>
|
|
361
|
+
<button className="cm-primary" disabled>
|
|
362
|
+
{claiming ? (invite ? 'Join the canvas' : 'Create account') : 'Sign in'}
|
|
363
|
+
</button>
|
|
364
|
+
</Tip>
|
|
365
|
+
)}
|
|
366
|
+
<button onClick={dismissIdentity}>Not now</button>
|
|
367
|
+
</div>
|
|
368
|
+
{!invite && (
|
|
369
|
+
<button className="cm-switch" onClick={() => { setErr(null); setMode(mode === 'signin' ? 'claim' : 'signin') }}>
|
|
370
|
+
{mode === 'signin' ? 'Have an invite token instead?' : 'Already have an account? Sign in'}
|
|
371
|
+
</button>
|
|
372
|
+
)}
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Open a thread by id: activate, select its node, fit the camera. True on success. */
|
|
379
|
+
function revealThread(c: string): boolean {
|
|
380
|
+
const t = useComments.getState().threads.find((t) => t.id === c)
|
|
381
|
+
if (!t) return false
|
|
382
|
+
useComments.getState().setActive(c)
|
|
383
|
+
const node = t.nodeKey && useStore.getState().nodes.find((n) => n.key === t.nodeKey)
|
|
384
|
+
if (node) {
|
|
385
|
+
useStore.getState().select(node.key)
|
|
386
|
+
setTimeout(() => canvasCtl.fitNode(node.key), 80)
|
|
387
|
+
}
|
|
388
|
+
return true
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Board-level comment wiring: load + liveness + mode broadcast, one instance in App. */
|
|
392
|
+
let bootThreadConsumed = false
|
|
393
|
+
let pendingThread: string | null = null // cross-board deep link awaiting its board's comments
|
|
394
|
+
export function CommentsController() {
|
|
395
|
+
const board = useStore((s) => s.board)
|
|
396
|
+
const commentMode = useComments((s) => s.commentMode)
|
|
397
|
+
const active = useComments((s) => s.active)
|
|
398
|
+
|
|
399
|
+
// modal-style dismiss: any press outside the card (and outside the surfaces that
|
|
400
|
+
// legitimately manage it - pins, stacks, the identity dialog) closes the thread
|
|
401
|
+
useEffect(() => {
|
|
402
|
+
if (!active) return
|
|
403
|
+
const onDown = (e: PointerEvent) => {
|
|
404
|
+
const t = e.target instanceof Element ? e.target : null
|
|
405
|
+
if (t?.closest('.cm-card, .cm-pin, .cm-stack, .cm-modal')) return
|
|
406
|
+
useComments.getState().setActive(null)
|
|
407
|
+
}
|
|
408
|
+
document.addEventListener('pointerdown', onDown, true)
|
|
409
|
+
return () => document.removeEventListener('pointerdown', onDown, true)
|
|
410
|
+
}, [active])
|
|
411
|
+
|
|
412
|
+
useEffect(() => {
|
|
413
|
+
const { load, live } = useComments.getState()
|
|
414
|
+
void load(board).then(() => {
|
|
415
|
+
// invite link (#/i/<token>): open the claim dialog with the token in hand -
|
|
416
|
+
// unless this browser is already someone (their invite is not for this session)
|
|
417
|
+
const s = useComments.getState()
|
|
418
|
+
if (bootHash.invite && !s.local && !s.me && !s.inviteToken)
|
|
419
|
+
useComments.setState({ inviteToken: bootHash.invite, needsIdentity: true })
|
|
420
|
+
// a cross-board deep link parks its thread id here until the target board's
|
|
421
|
+
// comments have actually loaded - a timer can't know how long that takes
|
|
422
|
+
if (pendingThread && revealThread(pendingThread)) { pendingThread = null; return }
|
|
423
|
+
// deep link ?c=<thread> (SPEC-M3 §6): open the thread, select its node, fit it.
|
|
424
|
+
// Consumed only on a successful find: the controller's first effect fires with
|
|
425
|
+
// the pre-boot board (child effects run before the parent's boot effect), and
|
|
426
|
+
// burning the flag on that empty pass would eat the link.
|
|
427
|
+
if (bootThreadConsumed || !bootHash.c) return
|
|
428
|
+
if (revealThread(bootHash.c)) bootThreadConsumed = true
|
|
429
|
+
})
|
|
430
|
+
return live(board)
|
|
431
|
+
}, [board])
|
|
432
|
+
|
|
433
|
+
// a comment link pasted into an ALREADY-OPEN canvas is a hash-only navigation -
|
|
434
|
+
// no reload, no boot path. Same board: the threads are in memory, reveal now.
|
|
435
|
+
// Different board: park the id; the load effect above consumes it once the
|
|
436
|
+
// board switch has fetched that board's comments.
|
|
437
|
+
useEffect(() => {
|
|
438
|
+
const onHash = (e: HashChangeEvent) => {
|
|
439
|
+
// read the URL the EVENT carries, not location.hash: the shell's own
|
|
440
|
+
// hashchange handler resets selection, whose projection synchronously
|
|
441
|
+
// rewrites the hash - listener order decides which URL survives, and
|
|
442
|
+
// e.newURL is immune to that race
|
|
443
|
+
const h = parseHash(e.newURL ? new URL(e.newURL).hash : location.hash)
|
|
444
|
+
if (h.invite) {
|
|
445
|
+
const s = useComments.getState()
|
|
446
|
+
if (!s.local && !s.me) useComments.setState({ inviteToken: h.invite, needsIdentity: true })
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
if (!h.c) return
|
|
450
|
+
if (!revealThread(h.c)) pendingThread = h.c
|
|
451
|
+
}
|
|
452
|
+
window.addEventListener('hashchange', onHash)
|
|
453
|
+
return () => window.removeEventListener('hashchange', onHash)
|
|
454
|
+
}, [])
|
|
455
|
+
|
|
456
|
+
// broadcast pick mode to every frame (laser rides along inside the bridge)
|
|
457
|
+
useEffect(() => {
|
|
458
|
+
for (const f of document.querySelectorAll('iframe'))
|
|
459
|
+
(f as HTMLIFrameElement).contentWindow?.postMessage({ type: 'sh:pick', on: commentMode }, location.origin)
|
|
460
|
+
}, [commentMode])
|
|
461
|
+
|
|
462
|
+
return <IdentityDialog />
|
|
463
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { memo, useEffect, useRef } from 'react'
|
|
2
2
|
import { cap, frameUrl, useStore, CONFIG, type Node } from '../store.ts'
|
|
3
3
|
import { CopyIcon, IntentGlyph, ReloadIcon, XIcon } from '../icons.tsx'
|
|
4
|
+
import { CommentLayer } from '../Comments.tsx'
|
|
5
|
+
import { useComments } from '../comments-store.ts'
|
|
4
6
|
|
|
5
7
|
export const HEADER = 28
|
|
6
8
|
const SNAP = 12
|
|
@@ -36,6 +38,25 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
36
38
|
}
|
|
37
39
|
}, [node.theme])
|
|
38
40
|
|
|
41
|
+
// laser mode (SPEC-M3 §7) rides the same rail; re-sent when a frame becomes ready
|
|
42
|
+
// so late loaders join an already-lasered board
|
|
43
|
+
const laser = useStore((s) => s.laser)
|
|
44
|
+
const commentMode = useComments((s) => s.commentMode)
|
|
45
|
+
// a node hosting the OPEN thread card (or a draft composer) rises above its
|
|
46
|
+
// neighbors - each node is a stacking context, so an overflowing card would
|
|
47
|
+
// otherwise paint under the next frame
|
|
48
|
+
const hostsCard = useComments((s) =>
|
|
49
|
+
(!!s.active && s.threads.some((t) => t.id === s.active && t.nodeKey === node.key)) || s.draft?.nodeKey === node.key)
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (node.status === 'ready' || !laser)
|
|
52
|
+
iframeRef.current?.contentWindow?.postMessage({ type: 'sh:laser', on: laser }, location.origin)
|
|
53
|
+
}, [laser, node.status])
|
|
54
|
+
// comment mode = pick mode in the frame (late loaders join like laser does)
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (node.status === 'ready' || !commentMode)
|
|
57
|
+
iframeRef.current?.contentWindow?.postMessage({ type: 'sh:pick', on: commentMode }, location.origin)
|
|
58
|
+
}, [commentMode, node.status])
|
|
59
|
+
|
|
39
60
|
// a frame whose FILE actually changed (e.g. tsx -> html swap, same id) must renavigate
|
|
40
61
|
useEffect(() => {
|
|
41
62
|
if (!frame) return
|
|
@@ -157,7 +178,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
157
178
|
<div
|
|
158
179
|
className={`sh-node${selected ? ' sel' : ''}${interact ? ' interact' : ''}`}
|
|
159
180
|
data-theme={node.theme}
|
|
160
|
-
style={{ transform: `translate(${node.x}px, ${node.y}px)`, width: node.w, height: node.h + HEADER }}
|
|
181
|
+
style={{ transform: `translate(${node.x}px, ${node.y}px)`, width: node.w, height: node.h + HEADER, zIndex: hostsCard ? 30 : undefined }}
|
|
161
182
|
data-node={node.key}
|
|
162
183
|
>
|
|
163
184
|
{frame.variantGroup && (
|
|
@@ -197,7 +218,10 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
197
218
|
title={frame.id}
|
|
198
219
|
style={{ width: node.w, height: node.h, display: node.missing || node.status === 'error' ? 'none' : 'block' }}
|
|
199
220
|
/>
|
|
200
|
-
{
|
|
221
|
+
{/* the overlay eats mouse events for drag-by-body; laser and comment mode both
|
|
222
|
+
need the mouse INSIDE the frame for hover highlights, so it steps aside
|
|
223
|
+
(drag still works via the header) */}
|
|
224
|
+
{!interact && !commentMode && !laser && (
|
|
201
225
|
<div
|
|
202
226
|
className="sh-overlay sh-no-pan"
|
|
203
227
|
onPointerDown={(e) => drag(e, 'move')}
|
|
@@ -206,6 +230,12 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
206
230
|
)}
|
|
207
231
|
</div>
|
|
208
232
|
|
|
233
|
+
{/* comments live OUTSIDE the clipped body: a card or pin near the frame edge
|
|
234
|
+
hangs over it (the vbadge precedent) instead of being cut off */}
|
|
235
|
+
<div className="cm-layer" style={{ top: HEADER, height: node.h }}>
|
|
236
|
+
<CommentLayer node={node} frameId={frame.id} iframe={iframeRef} />
|
|
237
|
+
</div>
|
|
238
|
+
|
|
209
239
|
{selected && (
|
|
210
240
|
<>
|
|
211
241
|
<div className="sh-handle e sh-no-pan" onPointerDown={(e) => drag(e, 'e')} />
|