import { type PointerEvent as ReactPointerEvent, useMemo, useRef, useState } from "react"
import { DocumentGate, type ToolRenderProps, defineStateCodec, defineTool, field, useDocument } 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>(),
})
// 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(" ")
}
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)"
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>
)
}
function Canvas({
data,
actions,
}: {
data: { strokes: Readonly<Record<string, Stroke>> }
actions: { map(name: "strokes"): { set(key: string, value: Stroke): void } }
}) {
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>
)
}
export function SketchView({ document }: ToolRenderProps) {
const coords = useMemo(() => ({ worldId: document.worldId, documentId: document.id }), [document.worldId, document.id])
const { data, status, actions, retry } = useDocument(coords, codec)
return (
<DocumentGate status={status} onRetry={retry}>
{data && actions && <Canvas data={data} actions={actions} />}
</DocumentGate>
)
}
export default defineTool({
id: "sketch",
name: "sketch",
documentTypes: ["sketch"],
needs: [],
render: SketchView,
})