@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.
@@ -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)!)} node={node} />
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) }} node={node} />}
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
- function ThreadCard({ thread, at, node }: { thread: Thread; at: { x: number; y: number }; node: Node }) {
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 > node.w * 0.55
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, node.h - 40) }}
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
- function DraftComposer({ at, node }: { at: { x: number; y: number }; node: Node }) {
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 > node.w * 0.55
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, node.h - 40) }}
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">