@marver-design/marver 0.6.0 → 0.7.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 +49 -0
- package/package.json +1 -1
- package/src/client/frame-host/bridge.js +28 -242
- package/src/client/frame-host/inspect.d.ts +6 -0
- package/src/client/frame-host/inspect.js +346 -0
- package/src/client/shell/App.tsx +25 -110
- package/src/client/shell/Comments.tsx +56 -11
- package/src/client/shell/Play.tsx +214 -155
- package/src/client/shell/Toolbar.tsx +189 -0
- package/src/client/shell/canvas/FrameNode.tsx +26 -2
- package/src/client/shell/icons.tsx +1 -0
- package/src/client/shell/styles.css +63 -10
- package/src/client/stage/main.tsx +35 -11
|
@@ -14,6 +14,27 @@ import { Tip } from './Tip.tsx'
|
|
|
14
14
|
import { ROUTE } from '../const.ts'
|
|
15
15
|
import type { Thread } from '../../shared/events.ts'
|
|
16
16
|
|
|
17
|
+
/** Tint the pin / composer / thread card EDGES (border, outline, focus ring) in the anchored
|
|
18
|
+
* element's own laser hue (captured at pick as anchor.el.hue) via --cm-line/--cm-line-ring.
|
|
19
|
+
* Deliberately NOT the button fills (--comment stays green: white text on a saturated hue
|
|
20
|
+
* fails at yellow/green/cyan) and NOT the avatar (keeps its per-user colour). No hue -> the
|
|
21
|
+
* CSS falls back to the green token. */
|
|
22
|
+
export function hueVars(hue?: number): React.CSSProperties {
|
|
23
|
+
// guard NaN / Infinity from a bad persisted anchor - an invalid custom property would
|
|
24
|
+
// suppress the CSS fallback and leave the edge uncoloured
|
|
25
|
+
if (!Number.isFinite(hue)) return {}
|
|
26
|
+
const h = hue as number
|
|
27
|
+
// --cm-solid is the FILLED-button colour (send / sign-in): a hue-aware lightness so the
|
|
28
|
+
// white glyph keeps contrast - yellow/green/cyan read light, so they go darker.
|
|
29
|
+
const l = (h >= 40 && h < 75) ? 34 : (h >= 75 && h < 165) ? 36 : (h >= 165 && h < 200) ? 38 : 45
|
|
30
|
+
return {
|
|
31
|
+
'--cm-line': `hsl(${h} 95% 45%)`,
|
|
32
|
+
'--cm-line-ring': `hsl(${h} 95% 50% / .30)`,
|
|
33
|
+
'--cm-solid': `hsl(${h} 85% ${l}%)`,
|
|
34
|
+
} as React.CSSProperties
|
|
35
|
+
}
|
|
36
|
+
const anchorHue = (a: unknown): number | undefined => (a as any)?.el?.hue
|
|
37
|
+
|
|
17
38
|
const rel = (ts: number) => {
|
|
18
39
|
const m = Math.round((Date.now() - ts) / 60_000)
|
|
19
40
|
if (m < 1) return 'now'
|
|
@@ -53,7 +74,7 @@ function markerDigest(threads: Thread[]): { faces: Face[]; count: number } {
|
|
|
53
74
|
|
|
54
75
|
/** The face of a marker: up to `max` overlapping avatars + the total count when >1.
|
|
55
76
|
* One shape for pins (one thread, anchored) and stacks (all threads on a frame). */
|
|
56
|
-
function MarkerFace({ threads, max = 3 }: { threads: Thread[]; max?: number }) {
|
|
77
|
+
export function MarkerFace({ threads, max = 3 }: { threads: Thread[]; max?: number }) {
|
|
57
78
|
const { faces, count } = markerDigest(threads)
|
|
58
79
|
return (
|
|
59
80
|
<>
|
|
@@ -78,6 +99,9 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
78
99
|
|
|
79
100
|
const open = threads.filter((t) => !t.resolved)
|
|
80
101
|
const anchored = open.filter((t) => (t.anchor as any)?.el)
|
|
102
|
+
// whether the ACTIVE thread is still open on this frame - a dep for the highlight effect so
|
|
103
|
+
// a remote resolve (thread leaves `open` while `active` is unchanged) still clears the lock
|
|
104
|
+
const activeShown = !!active && open.some((t) => t.id === active)
|
|
81
105
|
|
|
82
106
|
// resolve anchors against the live frame whenever threads or the document change
|
|
83
107
|
useEffect(() => {
|
|
@@ -85,7 +109,7 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
85
109
|
if (!win || node.status !== 'ready' || !anchored.length) return
|
|
86
110
|
const ask = () => win.postMessage({ type: 'sh:resolve-anchors', anchors: anchored.map((t) => ({ key: t.id, anchor: t.anchor })) }, location.origin)
|
|
87
111
|
const onMsg = (e: MessageEvent) => {
|
|
88
|
-
if (e.source !== win || e.data?.type !== 'sh:anchor-rects') return
|
|
112
|
+
if (e.source !== win || e.origin !== location.origin || e.data?.type !== 'sh:anchor-rects') return
|
|
89
113
|
const next: typeof rects = {}
|
|
90
114
|
for (const r of e.data.rects ?? []) next[r.key] = r.orphan ? null : r.rect
|
|
91
115
|
setRects(next)
|
|
@@ -96,6 +120,23 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
96
120
|
return () => { clearInterval(iv); window.removeEventListener('message', onMsg) }
|
|
97
121
|
}, [node.status, anchored.map((t) => t.id).join(','), iframe])
|
|
98
122
|
|
|
123
|
+
// #4/#5: drive the persistent element highlight into this frame - the composing draft on
|
|
124
|
+
// this node (lock on pick), or the open thread anchored here (highlight on open). null
|
|
125
|
+
// clears it, so the shell owns the release; on a failed/late resolve the frame re-applies
|
|
126
|
+
// on its next anchor-resolve tick. The self-lock on pick handles the instant case.
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
const win = iframe.current?.contentWindow
|
|
129
|
+
if (!win || node.status !== 'ready') return
|
|
130
|
+
// ONLY light an element while its thread is the active one (or a draft is composing on
|
|
131
|
+
// it) AND pins are shown - closing the thread or hiding comments (⇧C) clears it, so a
|
|
132
|
+
// stray outline never lingers on the frame.
|
|
133
|
+
const draftHere = draft?.nodeKey === node.key ? (draft.anchor as any) : null
|
|
134
|
+
const activeHere = active ? open.find((t) => t.id === active && (t.anchor as any)?.el) : undefined
|
|
135
|
+
const anchor = show ? (draftHere ?? (activeHere ? (activeHere.anchor as any) : null)) : null
|
|
136
|
+
win.postMessage({ type: 'sh:highlight-anchor', frame: frameId, anchor: anchor ?? null }, location.origin)
|
|
137
|
+
// active/draft/show/activeShown are the triggers; `open` is read fresh from the closure
|
|
138
|
+
}, [active, draft, show, activeShown, node.status, node.key, frameId, iframe])
|
|
139
|
+
|
|
99
140
|
const drafting = draft?.nodeKey === node.key
|
|
100
141
|
if (!show || (!open.length && !drafting)) return null
|
|
101
142
|
|
|
@@ -124,7 +165,7 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
124
165
|
const { x, y, orphan } = pinPos(t)
|
|
125
166
|
return (
|
|
126
167
|
<div key={t.id} className={`cm-pin sh-no-pan${t.id === active ? ' on' : ''}${orphan ? ' orphan' : ''}`}
|
|
127
|
-
style={{ left: x, top: y }}
|
|
168
|
+
style={{ left: x, top: y, ...hueVars(anchorHue(t.anchor)) }}
|
|
128
169
|
onPointerDown={(e) => e.stopPropagation()}
|
|
129
170
|
onClick={(e) => { e.stopPropagation(); setActive(t.id === active ? null : t.id) }}
|
|
130
171
|
title={orphan ? 'the anchored element is gone - comment parked' : undefined}>
|
|
@@ -133,14 +174,16 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
133
174
|
)
|
|
134
175
|
})}
|
|
135
176
|
{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)!)}
|
|
177
|
+
<ThreadCard key={active} thread={open.find((t) => t.id === active)!} at={pinPos(open.find((t) => t.id === active)!)} bounds={{ w: node.w, h: node.h }} />
|
|
137
178
|
)}
|
|
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) }}
|
|
179
|
+
{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) }} bounds={{ w: node.w, h: node.h }} hue={anchorHue(draft.anchor)} />}
|
|
139
180
|
</>
|
|
140
181
|
)
|
|
141
182
|
}
|
|
142
183
|
|
|
143
|
-
|
|
184
|
+
/** The open thread card - shared by canvas (bounds = node size, `at` in frame coords) and
|
|
185
|
+
* prototype (bounds = the on-screen stage size, `at` in screen coords). Geometry only. */
|
|
186
|
+
export function ThreadCard({ thread, at, bounds }: { thread: Thread; at: { x: number; y: number }; bounds: { w: number; h: number } }) {
|
|
144
187
|
const { resolve, setActive } = useComments.getState()
|
|
145
188
|
const me = useComments((s) => s.me)
|
|
146
189
|
const local = useComments((s) => s.local)
|
|
@@ -149,7 +192,7 @@ function ThreadCard({ thread, at, node }: { thread: Thread; at: { x: number; y:
|
|
|
149
192
|
const [copied, setCopied] = useState(false)
|
|
150
193
|
const copyTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
|
151
194
|
useEffect(() => () => clearTimeout(copyTimer.current), [])
|
|
152
|
-
const flip = at.x >
|
|
195
|
+
const flip = at.x > bounds.w * 0.55
|
|
153
196
|
// clear only after the server took it - a failed send must not eat the words,
|
|
154
197
|
// and text typed WHILE the request was in flight must survive the clear
|
|
155
198
|
const submit = async () => {
|
|
@@ -157,7 +200,7 @@ function ThreadCard({ thread, at, node }: { thread: Thread; at: { x: number; y:
|
|
|
157
200
|
if (sent.trim() && await useComments.getState().replyOk(thread.id, sent)) setText((cur) => (cur === sent ? '' : cur))
|
|
158
201
|
}
|
|
159
202
|
return (
|
|
160
|
-
<div data-sh-wheel-local className={`cm-card sh-no-pan${flip ? ' flip' : ''}`} style={{ left: at.x, top: Math.min(at.y + 14,
|
|
203
|
+
<div data-sh-wheel-local className={`cm-card sh-no-pan${flip ? ' flip' : ''}`} style={{ left: at.x, top: Math.min(at.y + 14, bounds.h - 40), ...hueVars(anchorHue(thread.anchor)) }}
|
|
161
204
|
onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()}>
|
|
162
205
|
{/* thread-level actions pin to the card corner, out of the header's flow -
|
|
163
206
|
the name row never has to share its line with them */}
|
|
@@ -220,14 +263,16 @@ function ThreadCard({ thread, at, node }: { thread: Thread; at: { x: number; y:
|
|
|
220
263
|
)
|
|
221
264
|
}
|
|
222
265
|
|
|
223
|
-
|
|
266
|
+
/** The draft composer - shared by canvas and prototype, geometry via `bounds`, tinted with
|
|
267
|
+
* the picked element's hue. */
|
|
268
|
+
export function DraftComposer({ at, bounds, hue }: { at: { x: number; y: number }; bounds: { w: number; h: number }; hue?: number }) {
|
|
224
269
|
const { create, setDraft } = useComments.getState()
|
|
225
270
|
const [text, setText] = useState('')
|
|
226
271
|
const ref = useRef<HTMLInputElement>(null)
|
|
227
272
|
useEffect(() => { ref.current?.focus() }, [])
|
|
228
|
-
const flip = at.x >
|
|
273
|
+
const flip = at.x > bounds.w * 0.55
|
|
229
274
|
return (
|
|
230
|
-
<div data-sh-wheel-local className={`cm-card cm-draft sh-no-pan${flip ? ' flip' : ''}`} style={{ left: at.x, top: Math.min(at.y + 14,
|
|
275
|
+
<div data-sh-wheel-local className={`cm-card cm-draft sh-no-pan${flip ? ' flip' : ''}`} style={{ left: at.x, top: Math.min(at.y + 14, bounds.h - 40), ...hueVars(hue) }}
|
|
231
276
|
onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
|
|
232
277
|
<div className="cm-compose">
|
|
233
278
|
<div className="cm-inputwrap">
|