experimental-a2 0.8.0 → 0.8.1
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/AGENTS.md +11 -0
- package/CHANGELOG.md +8 -0
- package/README.md +29 -0
- package/docs/guides/07-examples.mdx +56 -0
- package/docs/index.mdx +13 -0
- package/examples/README.md +15 -0
- package/examples/playground/AGENTS.md +11 -0
- package/examples/playground/DEPLOY.md +106 -0
- package/examples/playground/README.md +19 -0
- package/examples/playground/activity-feed.test.ts +10 -0
- package/examples/playground/app/agent/[agentId]/agent-client.tsx +376 -0
- package/examples/playground/app/agent/[agentId]/page.tsx +29 -0
- package/examples/playground/app/agent/events/route.ts +4 -0
- package/examples/playground/app/agent/model.ts +3 -0
- package/examples/playground/app/agent/new-agent-session.tsx +98 -0
- package/examples/playground/app/agent/page.tsx +25 -0
- package/examples/playground/app/agent/scheduler/route.ts +5 -0
- package/examples/playground/app/agent/server.ts +153 -0
- package/examples/playground/app/agent/session.ts +13 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +682 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-replay.test.ts +68 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-replay.ts +19 -0
- package/examples/playground/app/canvas/[canvasId]/page.tsx +22 -0
- package/examples/playground/app/canvas/[canvasId]/session.ts +19 -0
- package/examples/playground/app/canvas/events/route.ts +13 -0
- package/examples/playground/app/canvas/model.ts +94 -0
- package/examples/playground/app/canvas/open-canvas.tsx +40 -0
- package/examples/playground/app/canvas/page.tsx +20 -0
- package/examples/playground/app/canvas/server.ts +9 -0
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +118 -0
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +316 -0
- package/examples/playground/app/chat/[chatId]/chat-client.tsx +922 -0
- package/examples/playground/app/chat/[chatId]/chat-view.test.ts +152 -0
- package/examples/playground/app/chat/[chatId]/chat-view.ts +101 -0
- package/examples/playground/app/chat/[chatId]/composer.test.ts +44 -0
- package/examples/playground/app/chat/[chatId]/composer.ts +30 -0
- package/examples/playground/app/chat/[chatId]/page.tsx +30 -0
- package/examples/playground/app/chat/[chatId]/session.ts +7 -0
- package/examples/playground/app/chat/events/route.ts +7 -0
- package/examples/playground/app/chat/model.test.ts +155 -0
- package/examples/playground/app/chat/model.ts +310 -0
- package/examples/playground/app/chat/new-conversation.tsx +16 -0
- package/examples/playground/app/chat/page.tsx +25 -0
- package/examples/playground/app/chat/scheduler/route.ts +5 -0
- package/examples/playground/app/chat/server.ts +184 -0
- package/examples/playground/app/components/activity-feed.tsx +54 -0
- package/examples/playground/app/components/connection-pill.tsx +29 -0
- package/examples/playground/app/counter/counter-client.tsx +72 -0
- package/examples/playground/app/counter/events/route.ts +4 -0
- package/examples/playground/app/counter/model.test.ts +36 -0
- package/examples/playground/app/counter/model.ts +31 -0
- package/examples/playground/app/counter/page.tsx +24 -0
- package/examples/playground/app/counter/server.ts +9 -0
- package/examples/playground/app/counter/session.ts +13 -0
- package/examples/playground/app/documents/[documentId]/code-editor.tsx +80 -0
- package/examples/playground/app/documents/[documentId]/document-client.tsx +525 -0
- package/examples/playground/app/documents/[documentId]/page.tsx +23 -0
- package/examples/playground/app/documents/[documentId]/session.ts +7 -0
- package/examples/playground/app/documents/events/route.ts +4 -0
- package/examples/playground/app/documents/model.ts +55 -0
- package/examples/playground/app/documents/open-document.tsx +40 -0
- package/examples/playground/app/documents/page.tsx +22 -0
- package/examples/playground/app/documents/server.ts +9 -0
- package/examples/playground/app/globals.css +2078 -0
- package/examples/playground/app/layout.tsx +44 -0
- package/examples/playground/app/orders/[orderId]/order-client.tsx +140 -0
- package/examples/playground/app/orders/[orderId]/page.tsx +29 -0
- package/examples/playground/app/orders/[orderId]/session.ts +11 -0
- package/examples/playground/app/orders/create/route.ts +30 -0
- package/examples/playground/app/orders/events/route.ts +4 -0
- package/examples/playground/app/orders/model.ts +79 -0
- package/examples/playground/app/orders/new-order-form.tsx +98 -0
- package/examples/playground/app/orders/page.tsx +22 -0
- package/examples/playground/app/orders/scheduler/route.ts +5 -0
- package/examples/playground/app/orders/server.ts +50 -0
- package/examples/playground/app/page.tsx +102 -0
- package/examples/playground/app/recovery/[recoveryId]/page.tsx +31 -0
- package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +144 -0
- package/examples/playground/app/recovery/[recoveryId]/session.ts +7 -0
- package/examples/playground/app/recovery/events/route.ts +3 -0
- package/examples/playground/app/recovery/model.ts +55 -0
- package/examples/playground/app/recovery/new-recovery-session.tsx +20 -0
- package/examples/playground/app/recovery/page.tsx +22 -0
- package/examples/playground/app/recovery/scheduler/route.ts +7 -0
- package/examples/playground/app/recovery/server.ts +53 -0
- package/examples/playground/app/recovery/start/route.ts +41 -0
- package/examples/playground/css.d.ts +4 -0
- package/examples/playground/lib/store.ts +15 -0
- package/examples/playground/next-env.d.ts +5 -0
- package/examples/playground/next.config.ts +10 -0
- package/examples/playground/package.json +46 -0
- package/examples/playground/tsconfig.json +37 -0
- package/examples/playground/vercel.json +40 -0
- package/package.json +5 -2
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import type {
|
|
3
|
+
CSSProperties,
|
|
4
|
+
PointerEvent as ReactPointerEvent,
|
|
5
|
+
MouseEvent as ReactMouseEvent,
|
|
6
|
+
ReactNode,
|
|
7
|
+
} from 'react'
|
|
8
|
+
import {
|
|
9
|
+
useCallback,
|
|
10
|
+
useEffect,
|
|
11
|
+
useLayoutEffect,
|
|
12
|
+
useMemo,
|
|
13
|
+
useRef,
|
|
14
|
+
useState,
|
|
15
|
+
useSyncExternalStore,
|
|
16
|
+
} from 'react'
|
|
17
|
+
import type { CanvasShape, CanvasState, PaletteColor } from '../model'
|
|
18
|
+
import { ConnectionPill } from '@/app/components/connection-pill'
|
|
19
|
+
import { confirmedCanvasEventsSince, replayCanvas } from './canvas-replay'
|
|
20
|
+
import { SessionProvider, useSession } from './session'
|
|
21
|
+
|
|
22
|
+
const VIEW_W = 960
|
|
23
|
+
const VIEW_H = 540
|
|
24
|
+
/** How long a cursor stays painted after its last move — render-time
|
|
25
|
+
* logic against Date.now(); presence is not folded state. */
|
|
26
|
+
const CURSOR_TTL_MS = 4_000
|
|
27
|
+
|
|
28
|
+
const IDENTITY_KEY = 'a2-playground-canvas-identity'
|
|
29
|
+
const ADJECTIVES = ['Bright', 'Calm', 'Curious', 'Kind', 'Swift'] as const
|
|
30
|
+
const ANIMALS = ['Fox', 'Heron', 'Otter', 'Panda', 'Wren'] as const
|
|
31
|
+
const COLORS = ['teal', 'amber', 'blue', 'violet', 'rose'] as const
|
|
32
|
+
const COLOR_HEX: Record<PaletteColor, string> = {
|
|
33
|
+
teal: '#16836a',
|
|
34
|
+
amber: '#bd7917',
|
|
35
|
+
blue: '#3874c8',
|
|
36
|
+
violet: '#7956b6',
|
|
37
|
+
rose: '#bd5269',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The tab's identity: an opaque participant id, plus the name and
|
|
41
|
+
* color it publishes as `profile` presence. */
|
|
42
|
+
type CanvasIdentity = { id: string; name: string; color: PaletteColor }
|
|
43
|
+
|
|
44
|
+
const isColor = (value: string | undefined): value is PaletteColor =>
|
|
45
|
+
value !== undefined && (COLORS as readonly string[]).includes(value)
|
|
46
|
+
|
|
47
|
+
function isIdentity(value: unknown): value is CanvasIdentity {
|
|
48
|
+
if (!value || typeof value !== 'object') return false
|
|
49
|
+
const identity = value as Record<string, unknown>
|
|
50
|
+
return (
|
|
51
|
+
typeof identity['id'] === 'string' &&
|
|
52
|
+
typeof identity['name'] === 'string' &&
|
|
53
|
+
typeof identity['color'] === 'string' &&
|
|
54
|
+
isColor(identity['color'])
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function loadIdentity(): CanvasIdentity {
|
|
59
|
+
// The id is the stable per-tab identity; name and color re-roll on
|
|
60
|
+
// every load — refreshing hands you a new guise.
|
|
61
|
+
let id: string | null = null
|
|
62
|
+
try {
|
|
63
|
+
const stored = sessionStorage.getItem(IDENTITY_KEY)
|
|
64
|
+
if (stored) {
|
|
65
|
+
const parsed: unknown = JSON.parse(stored)
|
|
66
|
+
if (isIdentity(parsed)) id = parsed.id
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// Storage may be unavailable; a tab-local identity still works.
|
|
70
|
+
}
|
|
71
|
+
const random = new Uint32Array(3)
|
|
72
|
+
crypto.getRandomValues(random)
|
|
73
|
+
const identity: CanvasIdentity = {
|
|
74
|
+
id: id ?? crypto.randomUUID(),
|
|
75
|
+
name: `${ADJECTIVES[random[0]! % ADJECTIVES.length]!} ${ANIMALS[random[1]! % ANIMALS.length]!}`,
|
|
76
|
+
color: COLORS[random[2]! % COLORS.length]!,
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
sessionStorage.setItem(IDENTITY_KEY, JSON.stringify(identity))
|
|
80
|
+
} catch {
|
|
81
|
+
// The in-memory identity is enough for this visit.
|
|
82
|
+
}
|
|
83
|
+
return identity
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let tabIdentity: CanvasIdentity | null = null
|
|
87
|
+
const subscribeToIdentity = (): (() => void) => () => {}
|
|
88
|
+
const serverIdentitySnapshot = (): null => null
|
|
89
|
+
const clientIdentitySnapshot = (): CanvasIdentity | null => {
|
|
90
|
+
if (typeof window === 'undefined') return null
|
|
91
|
+
tabIdentity ??= loadIdentity()
|
|
92
|
+
return tabIdentity
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const useCanvasIdentity = (): CanvasIdentity | null =>
|
|
96
|
+
useSyncExternalStore(
|
|
97
|
+
subscribeToIdentity,
|
|
98
|
+
clientIdentitySnapshot,
|
|
99
|
+
serverIdentitySnapshot,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
/** A participant whose `profile` hasn't arrived (TTL'd out, or the
|
|
103
|
+
* snapshot raced the patch) still paints — neutrally. */
|
|
104
|
+
const GUEST_LABEL = { name: 'guest', color: 'teal' } as const
|
|
105
|
+
|
|
106
|
+
const labelOf = (fields: {
|
|
107
|
+
profile?: { value: { name: string; color: PaletteColor } }
|
|
108
|
+
}): { name: string; color: PaletteColor } =>
|
|
109
|
+
fields.profile?.value ?? GUEST_LABEL
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Remote motion chases the newest sample instead of replaying the
|
|
113
|
+
* past: exponential convergence with a ~45ms time constant, so a
|
|
114
|
+
* flick reads as a flick and bursty arrivals never build a playback
|
|
115
|
+
* backlog. Writes the transform imperatively, no React re-render per
|
|
116
|
+
* frame; the loop parks when converged. Local input never goes
|
|
117
|
+
* through here: your own cursor and drag render straight from
|
|
118
|
+
* pointer state.
|
|
119
|
+
*/
|
|
120
|
+
const CHASE_TAU_MS = 45
|
|
121
|
+
|
|
122
|
+
function useSmoothPoint(
|
|
123
|
+
x: number,
|
|
124
|
+
y: number,
|
|
125
|
+
): (el: SVGGElement | null) => void {
|
|
126
|
+
const elRef = useRef<SVGGElement | null>(null)
|
|
127
|
+
const s = useRef({ x, y, tx: x, ty: y, raf: 0, lastT: 0 }).current
|
|
128
|
+
|
|
129
|
+
const paint = useCallback(() => {
|
|
130
|
+
elRef.current?.setAttribute('transform', `translate(${s.x} ${s.y})`)
|
|
131
|
+
}, [s])
|
|
132
|
+
|
|
133
|
+
useLayoutEffect(() => {
|
|
134
|
+
s.tx = x
|
|
135
|
+
s.ty = y
|
|
136
|
+
if (s.raf !== 0) return
|
|
137
|
+
s.lastT = performance.now()
|
|
138
|
+
const step = (now: number): void => {
|
|
139
|
+
const k = 1 - Math.exp(-(now - s.lastT) / CHASE_TAU_MS)
|
|
140
|
+
s.lastT = now
|
|
141
|
+
s.x += (s.tx - s.x) * k
|
|
142
|
+
s.y += (s.ty - s.y) * k
|
|
143
|
+
if (Math.abs(s.tx - s.x) < 0.5 && Math.abs(s.ty - s.y) < 0.5) {
|
|
144
|
+
s.x = s.tx
|
|
145
|
+
s.y = s.ty
|
|
146
|
+
s.raf = 0
|
|
147
|
+
} else {
|
|
148
|
+
s.raf = requestAnimationFrame(step)
|
|
149
|
+
}
|
|
150
|
+
paint()
|
|
151
|
+
}
|
|
152
|
+
s.raf = requestAnimationFrame(step)
|
|
153
|
+
}, [s, paint, x, y])
|
|
154
|
+
|
|
155
|
+
useLayoutEffect(
|
|
156
|
+
() => () => {
|
|
157
|
+
if (s.raf !== 0) cancelAnimationFrame(s.raf)
|
|
158
|
+
s.raf = 0
|
|
159
|
+
},
|
|
160
|
+
[s],
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
// The first paint lands with the mount — no flash at the SVG origin.
|
|
164
|
+
return useCallback(
|
|
165
|
+
(el: SVGGElement | null) => {
|
|
166
|
+
elRef.current = el
|
|
167
|
+
if (el) paint()
|
|
168
|
+
},
|
|
169
|
+
[paint],
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function Smoothed({
|
|
174
|
+
x,
|
|
175
|
+
y,
|
|
176
|
+
className,
|
|
177
|
+
children,
|
|
178
|
+
}: {
|
|
179
|
+
x: number
|
|
180
|
+
y: number
|
|
181
|
+
className?: string
|
|
182
|
+
children: ReactNode
|
|
183
|
+
}): ReactNode {
|
|
184
|
+
const ref = useSmoothPoint(x, y)
|
|
185
|
+
return (
|
|
186
|
+
<g ref={ref} className={className}>
|
|
187
|
+
{children}
|
|
188
|
+
</g>
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function CanvasSession({
|
|
193
|
+
canvasId,
|
|
194
|
+
initialState,
|
|
195
|
+
initialIndex,
|
|
196
|
+
}: {
|
|
197
|
+
canvasId: string
|
|
198
|
+
initialState: CanvasState
|
|
199
|
+
initialIndex: number
|
|
200
|
+
}): ReactNode {
|
|
201
|
+
// The server snapshot is null so hydration matches exactly. The
|
|
202
|
+
// browser snapshot then mints one stable tab identity before presence
|
|
203
|
+
// becomes interactive.
|
|
204
|
+
const identity = useCanvasIdentity()
|
|
205
|
+
return (
|
|
206
|
+
<SessionProvider
|
|
207
|
+
sessionId={canvasId}
|
|
208
|
+
initialState={initialState}
|
|
209
|
+
initialIndex={initialIndex}
|
|
210
|
+
{...(identity ? { participant: identity.id } : {})}
|
|
211
|
+
>
|
|
212
|
+
<CanvasClient
|
|
213
|
+
key={`${canvasId}:${initialIndex}:${identity?.id ?? 'joining'}`}
|
|
214
|
+
canvasId={canvasId}
|
|
215
|
+
identity={identity}
|
|
216
|
+
replayBaseState={initialState}
|
|
217
|
+
replayBaseIndex={initialIndex}
|
|
218
|
+
/>
|
|
219
|
+
</SessionProvider>
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
type Drag = {
|
|
224
|
+
shapeId: string
|
|
225
|
+
/** Pointer offset inside the shape, so it doesn't jump to the cursor. */
|
|
226
|
+
dx: number
|
|
227
|
+
dy: number
|
|
228
|
+
/** Where the pointer went down — a few stray pixels is still a click. */
|
|
229
|
+
sx: number
|
|
230
|
+
sy: number
|
|
231
|
+
x: number
|
|
232
|
+
y: number
|
|
233
|
+
moved: boolean
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const clamp = (value: number, max: number): number =>
|
|
237
|
+
Math.min(Math.max(value, 0), max)
|
|
238
|
+
|
|
239
|
+
function CanvasClient({
|
|
240
|
+
canvasId,
|
|
241
|
+
identity,
|
|
242
|
+
replayBaseState,
|
|
243
|
+
replayBaseIndex,
|
|
244
|
+
}: {
|
|
245
|
+
canvasId: string
|
|
246
|
+
identity: CanvasIdentity | null
|
|
247
|
+
replayBaseState: CanvasState
|
|
248
|
+
replayBaseIndex: number
|
|
249
|
+
}): ReactNode {
|
|
250
|
+
const { state, events, index, connection, push, presence, setPresence } =
|
|
251
|
+
useSession()
|
|
252
|
+
const svgRef = useRef<SVGSVGElement>(null)
|
|
253
|
+
const [profile, setProfile] = useState(() =>
|
|
254
|
+
identity ? { name: identity.name, color: identity.color } : null,
|
|
255
|
+
)
|
|
256
|
+
const [drag, setDrag] = useState<Drag | null>(null)
|
|
257
|
+
/** null = live; a number pins the scene to a received-event prefix. */
|
|
258
|
+
const [scrub, setScrub] = useState<number | null>(null)
|
|
259
|
+
const [error, setError] = useState<string | null>(null)
|
|
260
|
+
const [copied, setCopied] = useState(false)
|
|
261
|
+
/** Render-time clock for cursor expiry — never enters the fold. */
|
|
262
|
+
const [now, setNow] = useState(() => Date.now())
|
|
263
|
+
|
|
264
|
+
const live = scrub === null
|
|
265
|
+
const replayEvents = useMemo(
|
|
266
|
+
() => confirmedCanvasEventsSince(events, replayBaseIndex, index),
|
|
267
|
+
[events, replayBaseIndex, index],
|
|
268
|
+
)
|
|
269
|
+
const replayThrough =
|
|
270
|
+
scrub === null ? replayEvents.length : Math.min(scrub, replayEvents.length)
|
|
271
|
+
// Start at the server-rendered fold, then replay only the tail this
|
|
272
|
+
// client observed. Loading the page never requires the historical log.
|
|
273
|
+
const scene = useMemo(
|
|
274
|
+
() =>
|
|
275
|
+
live ? state : replayCanvas(replayBaseState, replayEvents, replayThrough),
|
|
276
|
+
[live, state, replayEvents, replayThrough, replayBaseState],
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
// Identity metadata is presence: published on join and on every
|
|
280
|
+
// rename. Last-writer-wins per field, so a new name repaints in
|
|
281
|
+
// every open tab — nothing durable to migrate.
|
|
282
|
+
useEffect(() => {
|
|
283
|
+
if (!identity || !profile) return
|
|
284
|
+
const name = profile.name.trim()
|
|
285
|
+
if (!name) return
|
|
286
|
+
setPresence({ profile: { name, color: profile.color } })
|
|
287
|
+
try {
|
|
288
|
+
sessionStorage.setItem(
|
|
289
|
+
IDENTITY_KEY,
|
|
290
|
+
JSON.stringify({ id: identity.id, name, color: profile.color }),
|
|
291
|
+
)
|
|
292
|
+
} catch {
|
|
293
|
+
// The in-memory identity is enough for this visit.
|
|
294
|
+
}
|
|
295
|
+
}, [identity, profile, setPresence])
|
|
296
|
+
|
|
297
|
+
// A blurred window is nobody at the canvas: clear the ephemeral
|
|
298
|
+
// fields; the next pointer move repaints them.
|
|
299
|
+
useEffect(() => {
|
|
300
|
+
if (!identity) return
|
|
301
|
+
const clear = (): void => setPresence({ cursor: null, dragging: null })
|
|
302
|
+
const onVisibility = (): void => {
|
|
303
|
+
if (document.visibilityState === 'hidden') clear()
|
|
304
|
+
}
|
|
305
|
+
window.addEventListener('blur', clear)
|
|
306
|
+
document.addEventListener('visibilitychange', onVisibility)
|
|
307
|
+
return () => {
|
|
308
|
+
window.removeEventListener('blur', clear)
|
|
309
|
+
document.removeEventListener('visibilitychange', onVisibility)
|
|
310
|
+
}
|
|
311
|
+
}, [identity, setPresence])
|
|
312
|
+
|
|
313
|
+
const others = Object.entries(presence).filter(([id]) => id !== identity?.id)
|
|
314
|
+
|
|
315
|
+
// Tick once a second while any cursor could expire.
|
|
316
|
+
const hasCursors = others.some(([, fields]) => fields.cursor)
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
if (!hasCursors) return
|
|
319
|
+
const timer = setInterval(() => setNow(Date.now()), 1_000)
|
|
320
|
+
return () => clearInterval(timer)
|
|
321
|
+
}, [hasCursors])
|
|
322
|
+
|
|
323
|
+
const cursors = live
|
|
324
|
+
? others.flatMap(([id, fields]) => {
|
|
325
|
+
const cursor = fields.cursor
|
|
326
|
+
if (!cursor || now - cursor.at.getTime() > CURSOR_TTL_MS) return []
|
|
327
|
+
return [{ id, ...cursor.value, ...labelOf(fields) }]
|
|
328
|
+
})
|
|
329
|
+
: [] // durable events replay; presence has no historical state
|
|
330
|
+
|
|
331
|
+
// A ghost is dead once the log has moved past it: show `dragging`
|
|
332
|
+
// only while its `seen` frontier reaches the shape's durable
|
|
333
|
+
// `movedAt` (/guides/presence#reconciling-the-planes-seen).
|
|
334
|
+
const ghosts = new Map<
|
|
335
|
+
string,
|
|
336
|
+
{ x: number; y: number; color: PaletteColor }
|
|
337
|
+
>()
|
|
338
|
+
if (live) {
|
|
339
|
+
for (const [, fields] of others) {
|
|
340
|
+
const dragging = fields.dragging
|
|
341
|
+
if (!dragging) continue
|
|
342
|
+
const shape = state.shapes[dragging.value.shapeId]
|
|
343
|
+
if (!shape || dragging.seen < shape.movedAt) continue
|
|
344
|
+
ghosts.set(dragging.value.shapeId, {
|
|
345
|
+
x: dragging.value.x,
|
|
346
|
+
y: dragging.value.y,
|
|
347
|
+
color: labelOf(fields).color,
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const toPoint = (e: { clientX: number; clientY: number }) => {
|
|
353
|
+
const rect = svgRef.current!.getBoundingClientRect()
|
|
354
|
+
return {
|
|
355
|
+
x: Math.round(((e.clientX - rect.left) / rect.width) * VIEW_W),
|
|
356
|
+
y: Math.round(((e.clientY - rect.top) / rect.height) * VIEW_H),
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const pushShape = (event: Parameters<typeof push>[0]): void => {
|
|
361
|
+
setError(null)
|
|
362
|
+
push(event).catch((err: unknown) =>
|
|
363
|
+
setError(err instanceof Error ? err.message : String(err)),
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const createShape = (e: ReactMouseEvent<SVGSVGElement>): void => {
|
|
368
|
+
// detail > 1 is the second click of a double-click; target check
|
|
369
|
+
// skips clicks that started on a shape.
|
|
370
|
+
if (!live || e.target !== e.currentTarget || e.detail !== 1) return
|
|
371
|
+
const p = toPoint(e)
|
|
372
|
+
const random = new Uint32Array(4)
|
|
373
|
+
crypto.getRandomValues(random)
|
|
374
|
+
const w = 70 + (random[2]! % 71)
|
|
375
|
+
const h = 50 + (random[3]! % 61)
|
|
376
|
+
pushShape({
|
|
377
|
+
type: 'shape.created',
|
|
378
|
+
payload: {
|
|
379
|
+
id: crypto.randomUUID().slice(0, 8),
|
|
380
|
+
kind: random[0]! % 2 ? 'rect' : 'ellipse',
|
|
381
|
+
x: clamp(p.x - Math.round(w / 2), VIEW_W - w),
|
|
382
|
+
y: clamp(p.y - Math.round(h / 2), VIEW_H - h),
|
|
383
|
+
w,
|
|
384
|
+
h,
|
|
385
|
+
color: COLORS[random[1]! % COLORS.length]!,
|
|
386
|
+
},
|
|
387
|
+
})
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const startDrag = (
|
|
391
|
+
e: ReactPointerEvent<SVGGraphicsElement>,
|
|
392
|
+
shape: CanvasShape,
|
|
393
|
+
): void => {
|
|
394
|
+
if (!live || !identity) return
|
|
395
|
+
e.currentTarget.setPointerCapture(e.pointerId)
|
|
396
|
+
const p = toPoint(e)
|
|
397
|
+
setDrag({
|
|
398
|
+
shapeId: shape.id,
|
|
399
|
+
dx: p.x - shape.x,
|
|
400
|
+
dy: p.y - shape.y,
|
|
401
|
+
sx: p.x,
|
|
402
|
+
sy: p.y,
|
|
403
|
+
x: shape.x,
|
|
404
|
+
y: shape.y,
|
|
405
|
+
moved: false,
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const onPointerMove = (e: ReactPointerEvent<SVGSVGElement>): void => {
|
|
410
|
+
if (!live || !identity) return
|
|
411
|
+
const p = toPoint(e)
|
|
412
|
+
if (drag) {
|
|
413
|
+
const shape = state.shapes[drag.shapeId]
|
|
414
|
+
const x = clamp(p.x - drag.dx, VIEW_W - (shape?.w ?? 0))
|
|
415
|
+
const y = clamp(p.y - drag.dy, VIEW_H - (shape?.h ?? 0))
|
|
416
|
+
const moved =
|
|
417
|
+
drag.moved || Math.abs(p.x - drag.sx) + Math.abs(p.y - drag.sy) > 3
|
|
418
|
+
setDrag({ ...drag, x, y, moved })
|
|
419
|
+
// One patch, both fields: the ghost everyone else renders, and
|
|
420
|
+
// the cursor riding along. Coalesced on the wire by the client.
|
|
421
|
+
setPresence({
|
|
422
|
+
cursor: p,
|
|
423
|
+
dragging: { shapeId: drag.shapeId, x, y },
|
|
424
|
+
})
|
|
425
|
+
} else {
|
|
426
|
+
setPresence({ cursor: p })
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const endDrag = (): void => {
|
|
431
|
+
if (!drag || !identity) return
|
|
432
|
+
setDrag(null)
|
|
433
|
+
// Facts decide; presence repaints. The seen rule kills the ghost
|
|
434
|
+
// the moment shape.moved arrives, so an explicit dragging-null
|
|
435
|
+
// would only race the event across planes — and losing that race
|
|
436
|
+
// rubber-bands the shape to its pre-drag position. Clear presence
|
|
437
|
+
// only when no event is coming: a sub-threshold click, or a
|
|
438
|
+
// rejected push.
|
|
439
|
+
if (!drag.moved) {
|
|
440
|
+
setPresence({ dragging: null })
|
|
441
|
+
return
|
|
442
|
+
}
|
|
443
|
+
setError(null)
|
|
444
|
+
push({
|
|
445
|
+
type: 'shape.moved',
|
|
446
|
+
payload: { id: drag.shapeId, x: drag.x, y: drag.y },
|
|
447
|
+
}).catch((err: unknown) => {
|
|
448
|
+
setPresence({ dragging: null })
|
|
449
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
450
|
+
})
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const onPointerLeave = (): void => {
|
|
454
|
+
if (!identity || drag) return
|
|
455
|
+
setPresence({ cursor: null })
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const deleteShape = (shapeId: string): void => {
|
|
459
|
+
if (!live) return
|
|
460
|
+
pushShape({
|
|
461
|
+
type: 'shape.deleted',
|
|
462
|
+
payload: { id: shapeId },
|
|
463
|
+
})
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const copyLink = async (): Promise<void> => {
|
|
467
|
+
try {
|
|
468
|
+
await navigator.clipboard.writeText(window.location.href)
|
|
469
|
+
setCopied(true)
|
|
470
|
+
setTimeout(() => setCopied(false), 1500)
|
|
471
|
+
} catch (err) {
|
|
472
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const shapes = Object.values(scene.shapes)
|
|
477
|
+
|
|
478
|
+
return (
|
|
479
|
+
<article>
|
|
480
|
+
<header className="session-header document-header">
|
|
481
|
+
<div>
|
|
482
|
+
<p className="eyebrow">Multiplayer canvas</p>
|
|
483
|
+
<h1>
|
|
484
|
+
<code>{canvasId}</code>
|
|
485
|
+
</h1>
|
|
486
|
+
</div>
|
|
487
|
+
<ConnectionPill connection={connection} />
|
|
488
|
+
<span className="badge">
|
|
489
|
+
{shapes.length} {shapes.length === 1 ? 'shape' : 'shapes'}
|
|
490
|
+
</span>
|
|
491
|
+
<button
|
|
492
|
+
className="secondary compact"
|
|
493
|
+
type="button"
|
|
494
|
+
onClick={() => void copyLink()}
|
|
495
|
+
>
|
|
496
|
+
{copied ? 'Copied!' : 'Copy link'}
|
|
497
|
+
</button>
|
|
498
|
+
</header>
|
|
499
|
+
<p className="hint">
|
|
500
|
+
Click to create a shape, drag to move it, double-click to delete. Open
|
|
501
|
+
the same URL in a second tab: shapes are durable events; cursors and
|
|
502
|
+
drag ghosts are presence, never stored. Rename yourself below and every
|
|
503
|
+
tab repaints — your profile is presence too. Deployed, pushes and
|
|
504
|
+
presence ride one WebSocket; in dev the same route serves SSE.
|
|
505
|
+
</p>
|
|
506
|
+
|
|
507
|
+
<div className="canvas-toolbar">
|
|
508
|
+
<label className="identity-field">
|
|
509
|
+
You are
|
|
510
|
+
<input
|
|
511
|
+
value={profile?.name ?? ''}
|
|
512
|
+
onChange={(e) =>
|
|
513
|
+
setProfile((current) =>
|
|
514
|
+
current
|
|
515
|
+
? { ...current, name: e.target.value.slice(0, 40) }
|
|
516
|
+
: current,
|
|
517
|
+
)
|
|
518
|
+
}
|
|
519
|
+
placeholder="Joining…"
|
|
520
|
+
disabled={!identity}
|
|
521
|
+
/>
|
|
522
|
+
</label>
|
|
523
|
+
<div className="collaborators" aria-label="Cursors on this canvas">
|
|
524
|
+
{profile ? (
|
|
525
|
+
<span className="collaborator">
|
|
526
|
+
<span
|
|
527
|
+
className="collaborator-dot"
|
|
528
|
+
style={
|
|
529
|
+
{
|
|
530
|
+
'--collaborator-color': COLOR_HEX[profile.color],
|
|
531
|
+
} as CSSProperties
|
|
532
|
+
}
|
|
533
|
+
/>
|
|
534
|
+
{profile.name.trim() || 'guest'} (you)
|
|
535
|
+
</span>
|
|
536
|
+
) : null}
|
|
537
|
+
{cursors.length === 0 ? (
|
|
538
|
+
<span className="hint">No other cursors right now</span>
|
|
539
|
+
) : (
|
|
540
|
+
cursors.map((cursor) => (
|
|
541
|
+
<span className="collaborator" key={cursor.id}>
|
|
542
|
+
<span
|
|
543
|
+
className="collaborator-dot"
|
|
544
|
+
style={
|
|
545
|
+
{
|
|
546
|
+
'--collaborator-color': COLOR_HEX[cursor.color],
|
|
547
|
+
} as CSSProperties
|
|
548
|
+
}
|
|
549
|
+
/>
|
|
550
|
+
{cursor.name}
|
|
551
|
+
</span>
|
|
552
|
+
))
|
|
553
|
+
)}
|
|
554
|
+
</div>
|
|
555
|
+
</div>
|
|
556
|
+
|
|
557
|
+
<div className={`canvas-stage${live ? '' : ' historical'}`}>
|
|
558
|
+
<svg
|
|
559
|
+
ref={svgRef}
|
|
560
|
+
className="canvas-svg"
|
|
561
|
+
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
|
|
562
|
+
role="application"
|
|
563
|
+
aria-label="Shared canvas"
|
|
564
|
+
onClick={createShape}
|
|
565
|
+
onPointerMove={onPointerMove}
|
|
566
|
+
onPointerUp={endDrag}
|
|
567
|
+
onPointerLeave={onPointerLeave}
|
|
568
|
+
>
|
|
569
|
+
{shapes.map((shape) => {
|
|
570
|
+
const mine = drag?.shapeId === shape.id ? drag : null
|
|
571
|
+
const ghost = mine ? null : ghosts.get(shape.id)
|
|
572
|
+
const hex = COLOR_HEX[shape.color]
|
|
573
|
+
const common = {
|
|
574
|
+
className: `canvas-shape${ghost ? ' ghost' : ''}${mine ? ' dragging' : ''}`,
|
|
575
|
+
fill: hex,
|
|
576
|
+
stroke: ghost ? COLOR_HEX[ghost.color] : hex,
|
|
577
|
+
onPointerDown: (e: ReactPointerEvent<SVGGraphicsElement>) =>
|
|
578
|
+
startDrag(e, shape),
|
|
579
|
+
onDoubleClick: () => deleteShape(shape.id),
|
|
580
|
+
}
|
|
581
|
+
const geometry =
|
|
582
|
+
shape.kind === 'rect' ? (
|
|
583
|
+
<rect width={shape.w} height={shape.h} rx={6} {...common} />
|
|
584
|
+
) : (
|
|
585
|
+
<ellipse
|
|
586
|
+
cx={shape.w / 2}
|
|
587
|
+
cy={shape.h / 2}
|
|
588
|
+
rx={shape.w / 2}
|
|
589
|
+
ry={shape.h / 2}
|
|
590
|
+
{...common}
|
|
591
|
+
/>
|
|
592
|
+
)
|
|
593
|
+
// Your own drag renders raw from pointer state; scrubbed
|
|
594
|
+
// scenes stay static. Everything else lives in one chaser
|
|
595
|
+
// per shape, stable across its whole life: the target is
|
|
596
|
+
// the ghost while a live remote drag holds the shape, the
|
|
597
|
+
// fold position otherwise — so a drop is just a target
|
|
598
|
+
// change, converging in lockstep with the cursor chaser.
|
|
599
|
+
if (mine || !live) {
|
|
600
|
+
return (
|
|
601
|
+
<g
|
|
602
|
+
key={shape.id}
|
|
603
|
+
transform={`translate(${mine ? mine.x : shape.x} ${mine ? mine.y : shape.y})`}
|
|
604
|
+
>
|
|
605
|
+
{geometry}
|
|
606
|
+
</g>
|
|
607
|
+
)
|
|
608
|
+
}
|
|
609
|
+
return (
|
|
610
|
+
<Smoothed
|
|
611
|
+
key={shape.id}
|
|
612
|
+
x={ghost?.x ?? shape.x}
|
|
613
|
+
y={ghost?.y ?? shape.y}
|
|
614
|
+
>
|
|
615
|
+
{geometry}
|
|
616
|
+
</Smoothed>
|
|
617
|
+
)
|
|
618
|
+
})}
|
|
619
|
+
{cursors.map((cursor) => (
|
|
620
|
+
<Smoothed
|
|
621
|
+
key={cursor.id}
|
|
622
|
+
x={cursor.x}
|
|
623
|
+
y={cursor.y}
|
|
624
|
+
className="canvas-cursor"
|
|
625
|
+
>
|
|
626
|
+
<path
|
|
627
|
+
d="M 0 0 L 0 16 L 4.6 12.3 L 7.2 18 L 9.5 17 L 6.9 11.3 L 12.6 11.3 Z"
|
|
628
|
+
fill={COLOR_HEX[cursor.color]}
|
|
629
|
+
/>
|
|
630
|
+
<text x={13} y={21} fill={COLOR_HEX[cursor.color]}>
|
|
631
|
+
{cursor.name}
|
|
632
|
+
</text>
|
|
633
|
+
</Smoothed>
|
|
634
|
+
))}
|
|
635
|
+
</svg>
|
|
636
|
+
<p className={`canvas-status${live ? '' : ' replaying'}`}>
|
|
637
|
+
<span className="canvas-status-text">
|
|
638
|
+
{live
|
|
639
|
+
? `Live — ${replayEvents.length} confirmed ${replayEvents.length === 1 ? 'event' : 'events'} received since the snapshot`
|
|
640
|
+
: `Replaying from the server snapshot through ${replayThrough} of ${replayEvents.length} confirmed events.`}
|
|
641
|
+
</span>
|
|
642
|
+
{/* Lives in the fixed-slot status line, hidden-not-removed,
|
|
643
|
+
so neither the slider row nor this line ever reflows. */}
|
|
644
|
+
<button
|
|
645
|
+
className="secondary compact canvas-back-to-live"
|
|
646
|
+
type="button"
|
|
647
|
+
disabled={live}
|
|
648
|
+
onClick={() => setScrub(null)}
|
|
649
|
+
>
|
|
650
|
+
Back to live
|
|
651
|
+
</button>
|
|
652
|
+
</p>
|
|
653
|
+
</div>
|
|
654
|
+
|
|
655
|
+
<div className="canvas-scrubber">
|
|
656
|
+
<input
|
|
657
|
+
type="range"
|
|
658
|
+
min={0}
|
|
659
|
+
max={replayEvents.length}
|
|
660
|
+
value={replayThrough}
|
|
661
|
+
disabled={replayEvents.length === 0}
|
|
662
|
+
onChange={(e) => {
|
|
663
|
+
const value = Number(e.target.value)
|
|
664
|
+
setScrub(value >= replayEvents.length ? null : value)
|
|
665
|
+
}}
|
|
666
|
+
aria-label="Replay events received since the server snapshot"
|
|
667
|
+
/>
|
|
668
|
+
<span className="hint">
|
|
669
|
+
{live
|
|
670
|
+
? `live — ${replayEvents.length} confirmed ${replayEvents.length === 1 ? 'event' : 'events'}`
|
|
671
|
+
: `confirmed event ${replayThrough} of ${replayEvents.length}`}
|
|
672
|
+
</span>
|
|
673
|
+
</div>
|
|
674
|
+
<p className="frontier">
|
|
675
|
+
This page starts from one server-rendered state snapshot, then folds
|
|
676
|
+
each confirmed event the client receives. The slider replays only that
|
|
677
|
+
observed tail.
|
|
678
|
+
</p>
|
|
679
|
+
{error ? <p className="error">{error}</p> : null}
|
|
680
|
+
</article>
|
|
681
|
+
)
|
|
682
|
+
}
|