import { type PointerEvent as ReactPointerEvent, useMemo, useRef, useState } from "react"
import { DocumentGate, type ToolRenderProps, defineStateCodec, defineTool, field, useDocument, useDocumentPresence, useWorldQuery } from "@vvd/sdk"
// src/codec.ts
/** One freehand stroke. Points are flattened [x0, y0, x1, y1, …] — DOCUMENT space. */
export type Stroke = {
points: number[]
color: string
width: number
}
export const codec = defineStateCodec({
// A per-KEY map: two people drawing different strokes at once BOTH land.
strokes: field.map<Stroke>(),
// A reference to a world document — always the id, never a copy.
linkedDocumentId: field.value<string | null>(null),
})
// src/tool.tsx
const newId = () => crypto.randomUUID()
function pathFrom(points: number[]): string {
const out: string[] = []
for (let i = 0; i < points.length; i += 2) out.push(`${points[i]},${points[i + 1]}`)
return out.join(" ")
}
// ── The morphing tool palette ───────────────────────────────────────────────
// Four pens, a colour, a width. Picking a colour or opening the size control
// doesn't swap to a new panel — the same bar changes shape.
type PenId = "pencil" | "pen" | "brush" | "eraser"
const PENS: { id: PenId; label: string; width: number }[] = [
{ id: "pencil", label: "Pencil", width: 2 },
{ id: "pen", label: "Pen", width: 4 },
{ id: "brush", label: "Brush", width: 10 },
{ id: "eraser", label: "Eraser", width: 26 },
]
const COLORS = ["#151515", "#c33d33", "#c98a2c", "#3f8f5f", "#3a6fe0", "#8a4fd6"]
const MORPH = "width 320ms cubic-bezier(0.34, 1.46, 0.5, 1)"
const CHIP = { display: "inline-flex", alignItems: "center", gap: 6, padding: "5px 12px", borderRadius: 999, border: "1px solid rgba(0,0,0,0.18)", background: "#fff", color: "#151515", font: "inherit", fontSize: 12, cursor: "pointer" } as const
type Mode = "tools" | "color" | "size"
function BackButton({ onClick }: { onClick: () => void }) {
return (
<button type="button" onClick={onClick} title="Back" style={{ width: 22, height: 22, borderRadius: "50%", border: "none", background: "rgba(0,0,0,0.06)", cursor: "pointer", fontSize: 11, flexShrink: 0 }}>
‹
</button>
)
}
function Toolbar({ tool, color, width, onTool, onColor, onWidth }: {
tool: PenId
color: string
width: number
onTool: (id: PenId) => void
onColor: (c: string) => void
onWidth: (w: number) => void
}) {
const [mode, setMode] = useState<Mode>("tools")
const barWidth = mode === "tools" ? 268 : mode === "color" ? 232 : 208
return (
<div style={{ display: "flex", alignItems: "center", gap: 6, height: 40, padding: "0 10px", borderRadius: 999, border: "1px solid rgba(0,0,0,0.14)", background: "#fff", overflow: "hidden", width: barWidth, transition: MORPH, boxShadow: "0 6px 18px rgba(0,0,0,0.08)" }}>
{mode === "tools" && (
<>
{PENS.map((p) => (
<button key={p.id} type="button" onClick={() => onTool(p.id)} title={p.label} aria-label={p.label}
style={{ width: 26, height: 26, borderRadius: "50%", border: "none", cursor: "pointer", background: tool === p.id ? "#151515" : "transparent", display: "grid", placeItems: "center", flexShrink: 0 }}>
{p.id === "eraser" ? (
<span style={{ width: 10, height: 10, border: `1.5px solid ${tool === p.id ? "#fff" : "#151515"}`, borderRadius: 2 }} />
) : (
<span style={{ width: Math.max(4, Math.min(p.width, 12)), height: Math.max(4, Math.min(p.width, 12)), borderRadius: "50%", background: tool === p.id ? "#fff" : "#151515" }} />
)}
</button>
))}
<span style={{ flex: 1 }} />
<button type="button" onClick={() => setMode("color")} title="Colour" aria-label="Colour"
style={{ width: 22, height: 22, borderRadius: "50%", border: "2px solid #fff", outline: "1px solid rgba(0,0,0,0.2)", background: color, cursor: "pointer", flexShrink: 0 }} />
<button type="button" onClick={() => setMode("size")} title="Size" aria-label="Size"
style={{ width: 26, height: 26, borderRadius: "50%", border: "none", background: "transparent", cursor: "pointer", display: "grid", placeItems: "center", flexShrink: 0 }}>
<span style={{ width: Math.min(width, 16), height: Math.min(width, 16), borderRadius: "50%", background: "#151515" }} />
</button>
</>
)}
{mode === "color" && (
<>
<BackButton onClick={() => setMode("tools")} />
{COLORS.map((c) => (
<button key={c} type="button" onClick={() => { onColor(c); setMode("tools") }} aria-label={c}
style={{ width: 22, height: 22, borderRadius: "50%", border: c === color ? "2px solid #151515" : "2px solid transparent", background: c, cursor: "pointer", flexShrink: 0 }} />
))}
</>
)}
{mode === "size" && (
<>
<BackButton onClick={() => setMode("tools")} />
<input type="range" min={1} max={30} value={width} onChange={(e) => onWidth(Number(e.target.value))} style={{ flex: 1 }} />
<span style={{ fontSize: 11, opacity: 0.6, width: 20, textAlign: "right", flexShrink: 0 }}>{Math.round(width)}</span>
</>
)}
</div>
)
}
/** Who else is sketching right now — host-stamped name and colour. */
function PresenceStrip({ handle }: { handle: Parameters<typeof useDocumentPresence>[0] }) {
const others = useDocumentPresence(handle).filter((p) => !p.isSelf)
if (others.length === 0) return null
return (
<span style={{ display: "flex", alignItems: "center" }} title={others.map((p) => p.name).join(", ")}>
{others.slice(0, 5).map((p) => (
<span key={p.userId} style={{ width: 22, height: 22, borderRadius: "50%", marginLeft: -6, display: "inline-flex", alignItems: "center", justifyContent: "center", background: p.color, color: "#fff", fontSize: 10, fontWeight: 700, border: "2px solid #faf6ec" }}>
{p.name ? p.name.slice(0, 1).toUpperCase() : ""}
</span>
))}
</span>
)
}
/** Search the world for a card to link, or unlink the one already chosen. */
function LinkChip({ linkedId, onLink, onUnlink }: {
linkedId: string | null
onLink: (id: string) => void
onUnlink: () => void
}) {
const cards = useWorldQuery("documents", { type: "card" })
const [open, setOpen] = useState(false)
const linked = linkedId ? cards.find((c) => c.id === linkedId) : null
if (linked) {
return (
<button type="button" onClick={onUnlink} style={CHIP} title="Click to unlink">
Linked · {linked.name}
</button>
)
}
return (
<span style={{ position: "relative" }}>
<button type="button" onClick={() => setOpen((o) => !o)} style={{ ...CHIP, opacity: 0.6, borderStyle: "dashed" }}>
Link a card
</button>
{open && (
<span style={{ position: "absolute", bottom: "100%", left: 0, marginBottom: 6, display: "grid", gap: 2, minWidth: 200, padding: 4, borderRadius: 10, background: "#fff", border: "1px solid rgba(0,0,0,0.14)", boxShadow: "0 12px 28px rgba(0,0,0,0.14)" }}>
{cards.map((c) => (
<button key={c.id} type="button" onClick={() => { onLink(c.id); setOpen(false) }}
style={{ textAlign: "left", padding: "6px 9px", borderRadius: 7, border: "none", background: "transparent", cursor: "pointer", font: "inherit", fontSize: 13 }}>
{c.name}
</button>
))}
{cards.length === 0 && <span style={{ padding: "8px 9px", fontSize: 12, opacity: 0.55 }}>No cards in this world yet</span>}
</span>
)}
</span>
)
}
function Canvas({ data, actions, handle }: {
data: { strokes: Readonly<Record<string, Stroke>>; linkedDocumentId: string | null }
actions: {
map(name: "strokes"): { set(key: string, value: Stroke): void }
set(name: "linkedDocumentId", value: string | null): void
}
handle: Parameters<typeof useDocumentPresence>[0]
}) {
const [tool, setTool] = useState<PenId>("pen")
const [color, setColor] = useState("#151515")
const [width, setWidth] = useState(4)
const live = useRef<{ id: string; points: number[] } | null>(null)
const styleFor = (t: PenId) =>
t === "eraser" ? { color: "#faf6ec", width: 26 } : { color, width: t === "pencil" ? Math.min(width, 3) : width }
const onPointerDown = (e: ReactPointerEvent<SVGSVGElement>) => {
const box = e.currentTarget.getBoundingClientRect()
const point = [Math.round(e.clientX - box.left), Math.round(e.clientY - box.top)]
const id = newId()
const style = styleFor(tool)
live.current = { id, points: point }
actions.map("strokes").set(id, { points: point, color: style.color, width: style.width })
}
const onPointerMove = (e: ReactPointerEvent<SVGSVGElement>) => {
if (!live.current) return
const box = e.currentTarget.getBoundingClientRect()
live.current.points.push(Math.round(e.clientX - box.left), Math.round(e.clientY - box.top))
const style = styleFor(tool)
actions.map("strokes").set(live.current.id, { points: [...live.current.points], color: style.color, width: style.width })
}
const onPointerUp = () => { live.current = null }
return (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<svg onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerLeave={onPointerUp}
style={{ width: "100%", height: "100%", touchAction: "none", background: "#faf6ec", cursor: "crosshair" }}>
{Object.entries(data.strokes).map(([id, s]) => (
<polyline key={id} points={pathFrom(s.points)} fill="none" stroke={s.color} strokeWidth={s.width} strokeLinecap="round" strokeLinejoin="round" />
))}
</svg>
<div style={{ position: "absolute", top: 12, left: 12 }}>
<Toolbar tool={tool} color={color} width={width} onTool={setTool} onColor={setColor} onWidth={setWidth} />
</div>
<div style={{ position: "absolute", top: 12, right: 12 }}>
<PresenceStrip handle={handle} />
</div>
<div style={{ position: "absolute", bottom: 12, left: 12 }}>
<LinkChip
linkedId={data.linkedDocumentId}
onLink={(id) => actions.set("linkedDocumentId", id)}
onUnlink={() => actions.set("linkedDocumentId", null)}
/>
</div>
</div>
)
}
export function SketchView({ document }: ToolRenderProps) {
const coords = useMemo(() => ({ worldId: document.worldId, documentId: document.id }), [document.worldId, document.id])
const { data, status, actions, retry, handle } = useDocument(coords, codec)
return (
<DocumentGate status={status} onRetry={retry}>
{data && actions && <Canvas data={data} actions={actions} handle={handle} />}
</DocumentGate>
)
}
export default defineTool({
id: "sketch",
name: "sketch",
documentTypes: ["sketch"],
capabilities: { readsWorld: true },
needs: ["world"],
render: SketchView,
})