Files
drawit/src/components/ColoringRunner.tsx
T
2026-06-21 08:44:23 -05:00

416 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { loadProgressDrawing, saveProgressDrawing, saveFinalDrawing, fileToDataURL, loadDrawingAsLayer } from "./drawingPersistence";
interface Meta { level: string; levelName: string; sublevel: number; title: string; emoji: string; intro: string; baseSvg: string; phaseLabel?: string; hintSvg?: string }
const W = 880;
const H = 660;
const COLORS = ["#2b2440", "#4f7cff", "#34c6c6", "#58c777", "#ffc843", "#ff8a3d", "#ff7eb6", "#a06bff", "#8a5a2b", "#ffffff"];
const SIZES = [8, 18, 36];
type Tool = "pen" | "pencil" | "marker" | "eraser" | "fill";
const TOOLS: { id: Tool; label: string; icon: string }[] = [
{ id: "pen", label: "Pen", icon: "🖊️" },
{ id: "pencil", label: "Pencil", icon: "✏️" },
{ id: "marker", label: "Marker", icon: "🖍️" },
{ id: "eraser", label: "Eraser", icon: "🧽" },
{ id: "fill", label: "Fill", icon: "🪣" },
];
interface Layer { id: number; name: string; visible: boolean; canvas: HTMLCanvasElement }
function hexToRgb(hex: string): [number, number, number] {
const m = hex.replace("#", "");
const n = m.length === 3 ? m.split("").map((c) => c + c).join("") : m;
return [parseInt(n.slice(0, 2), 16), parseInt(n.slice(2, 4), 16), parseInt(n.slice(4, 6), 16)];
}
function newLayer(id: number, name: string): Layer {
const c = document.createElement("canvas");
c.width = W; c.height = H;
return { id, name, visible: true, canvas: c };
}
export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned, userSketch = "", carryImage = "" }: {
meta: Meta; loggedIn: boolean; username: string; alreadyEarned: boolean; userSketch?: string; carryImage?: string;
}) {
const router = useRouter();
const phaseLabel = meta.phaseLabel || "Color it!";
// Which line art to color: the stock template or the child's own sketch.
// If they have no sketch of their own, skip the choice and use the stock art.
const [sketch, setSketch] = useState<"stock" | "yours" | null>(userSketch ? null : "stock");
const [overlayUrl, setOverlayUrl] = useState<string>("");
const [done, setDone] = useState(false);
const [finalImage, setFinalImage] = useState<string | null>(null);
const [uploadMsg, setUploadMsg] = useState("");
const uploadRef = useRef<HTMLInputElement | null>(null);
const dispRef = useRef<HTMLCanvasElement | null>(null);
const boundaryRef = useRef<ImageData | null>(null);
const templImgRef = useRef<HTMLImageElement | null>(null);
const layersRef = useRef<Layer[]>([]);
const layerSeq = useRef(2);
const committedRef = useRef<ImageData | null>(null); // active layer before current stroke
const undoRef = useRef<{ layerId: number; data: ImageData }[]>([]);
const drawingRef = useRef<{ x: number; y: number }[] | null>(null);
const pressureRef = useRef(0.7);
const saveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const [, force] = useState(0);
const [activeId, setActiveId] = useState(0);
const [tool, setTool] = useState<Tool>("pen");
const [color, setColor] = useState(COLORS[1]);
const [size, setSize] = useState(SIZES[1]);
const [opacity, setOpacity] = useState(1);
const [custom, setCustom] = useState("#7ec86a");
const layers = layersRef.current;
const active = layers.find((l) => l.id === activeId) || layers[0];
const composite = useCallback(() => {
const disp = dispRef.current; if (!disp) return;
const ctx = disp.getContext("2d")!;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, W, H);
for (const l of layersRef.current) if (l.visible) ctx.drawImage(l.canvas, 0, 0);
}, []);
// Create the initial layers on the client (canvas needs the DOM, not the server).
useEffect(() => {
if (layersRef.current.length === 0) layersRef.current = [newLayer(0, "Color"), newLayer(1, "Top")];
composite();
force((n) => n + 1);
}, [composite]);
// Repaint when returning to the studio from the "done" screen
useEffect(() => { if (!done) composite(); }, [done, composite]);
// Build the line-art overlay (+ fill boundary + final export template) from the chosen source.
// "stock" = the curriculum's template SVG; "yours" = the child's own sketch (white knocked out to transparent so coloring shows under the lines).
useEffect(() => {
if (!sketch) return;
let alive = true;
(async () => {
let url: string;
if (sketch === "yours" && userSketch) {
const layer = await loadDrawingAsLayer(userSketch); // dark lines on transparent
const c = document.createElement("canvas"); c.width = W; c.height = H;
c.getContext("2d")!.drawImage(layer, 0, 0, W, H);
url = c.toDataURL("image/png");
} else {
url = "data:image/svg+xml;utf8," + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300" width="${W}" height="${H}">${meta.baseSvg}</svg>`);
}
if (!alive) return;
const img = new Image();
img.onload = () => {
if (!alive) return;
templImgRef.current = img;
const b = document.createElement("canvas"); b.width = W; b.height = H;
const bctx = b.getContext("2d")!; bctx.drawImage(img, 0, 0, W, H);
boundaryRef.current = bctx.getImageData(0, 0, W, H);
setOverlayUrl(url);
};
img.src = url;
})();
return () => { alive = false; };
}, [sketch, meta.baseSvg, userSketch]);
// Restore saved coloring into the bottom layer; if none, seed with the carry image
// (e.g. the Light phase starts from the colored drawing).
useEffect(() => {
let alive = true;
const seed = (data: string) => {
const im = new Image();
im.onload = () => { if (alive) { layersRef.current[0]?.canvas.getContext("2d")!.drawImage(im, 0, 0, W, H); composite(); } };
im.src = data;
};
if (!loggedIn) { if (carryImage) seed(carryImage); else composite(); return; }
loadProgressDrawing(meta.level, meta.sublevel).then((data) => {
if (!alive) return;
if (data) seed(data);
else if (carryImage) seed(carryImage);
else composite();
});
return () => { alive = false; };
}, [loggedIn, meta.level, meta.sublevel, composite, carryImage]);
function flatten(withTemplate: boolean): string {
const c = document.createElement("canvas"); c.width = W; c.height = H;
const ctx = c.getContext("2d")!;
ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, W, H);
for (const l of layersRef.current) if (l.visible) ctx.drawImage(l.canvas, 0, 0);
if (withTemplate && templImgRef.current) ctx.drawImage(templImgRef.current, 0, 0, W, H);
return c.toDataURL("image/png");
}
function hasContent() { return undoRef.current.length > 0; }
function flushSave() { if (!loggedIn || !hasContent()) return; saveProgressDrawing(meta.level, meta.sublevel, flatten(false)); }
function scheduleSave() { if (!loggedIn) return; clearTimeout(saveTimer.current); saveTimer.current = setTimeout(flushSave, 1200); }
function pushUndo() {
const ctx = active.canvas.getContext("2d")!;
undoRef.current.push({ layerId: active.id, data: ctx.getImageData(0, 0, W, H) });
if (undoRef.current.length > 8) undoRef.current.shift();
}
function undo() {
const u = undoRef.current.pop(); if (!u) return;
const l = layersRef.current.find((x) => x.id === u.layerId); if (!l) return;
l.canvas.getContext("2d")!.putImageData(u.data, 0, 0);
composite(); force((n) => n + 1); scheduleSave();
}
function clearActive() { pushUndo(); active.canvas.getContext("2d")!.clearRect(0, 0, W, H); composite(); force((n) => n + 1); scheduleSave(); }
function pt(e: React.PointerEvent) {
const r = dispRef.current!.getBoundingClientRect();
return { x: ((e.clientX - r.left) / r.width) * W, y: ((e.clientY - r.top) / r.height) * H };
}
function strokeStyleFor(pressure: number) {
const ctx = active.canvas.getContext("2d")!;
if (tool === "eraser") { ctx.globalCompositeOperation = "destination-out"; ctx.globalAlpha = 1; ctx.strokeStyle = "#000"; ctx.lineWidth = size * 1.4; return; }
ctx.globalCompositeOperation = "source-over";
ctx.strokeStyle = color;
if (tool === "pen") { ctx.globalAlpha = opacity; ctx.lineWidth = size; }
else if (tool === "pencil") { ctx.globalAlpha = Math.min(1, opacity * 0.5 * (0.6 + pressure * 0.8)); ctx.lineWidth = size * 0.8; }
else { ctx.globalAlpha = Math.min(1, opacity * 0.55 * (0.6 + pressure * 0.6)); ctx.lineWidth = size * 1.7; } // marker
}
function renderStroke() {
const ctx = active.canvas.getContext("2d")!;
if (committedRef.current) ctx.putImageData(committedRef.current, 0, 0);
const pts = drawingRef.current!;
if (!pts.length) return;
ctx.save(); // isolate this stroke's tool settings so they can't leak to the next stroke
strokeStyleFor(pressureRef.current);
ctx.lineCap = "round"; ctx.lineJoin = "round";
ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y);
if (pts.length === 1) ctx.lineTo(pts[0].x + 0.1, pts[0].y + 0.1);
else for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
ctx.restore();
composite();
}
function floodFill(x: number, y: number) {
const bd = boundaryRef.current; if (!bd) return;
pushUndo();
const ctx = active.canvas.getContext("2d")!;
const img = ctx.getImageData(0, 0, W, H); const d = img.data;
const [fr, fg, fb] = hexToRgb(color);
const isLine = (p: number) => bd.data[p * 4 + 3] > 60 && bd.data[p * 4] + bd.data[p * 4 + 1] + bd.data[p * 4 + 2] < 420;
const sx = Math.round(x), sy = Math.round(y);
if (sx < 0 || sy < 0 || sx >= W || sy >= H) return;
const start = sy * W + sx;
if (isLine(start)) return;
const visited = new Uint8Array(W * H);
const stack = [start];
while (stack.length) {
const p = stack.pop()!;
if (visited[p]) continue; visited[p] = 1;
if (isLine(p)) continue;
const o = p * 4; d[o] = fr; d[o + 1] = fg; d[o + 2] = fb; d[o + 3] = 255;
const px = p % W, py = (p / W) | 0;
if (px > 0) stack.push(p - 1);
if (px < W - 1) stack.push(p + 1);
if (py > 0) stack.push(p - W);
if (py < H - 1) stack.push(p + W);
}
ctx.putImageData(img, 0, 0);
composite(); force((n) => n + 1); scheduleSave();
}
function onDown(e: React.PointerEvent) {
(e.target as Element).setPointerCapture?.(e.pointerId);
const p = pt(e);
if (tool === "fill") { floodFill(p.x, p.y); return; }
pushUndo();
committedRef.current = active.canvas.getContext("2d")!.getImageData(0, 0, W, H);
drawingRef.current = [p];
pressureRef.current = e.pressure && e.pressure > 0 ? e.pressure : 0.7;
renderStroke();
}
function onMove(e: React.PointerEvent) {
if (!drawingRef.current) return;
drawingRef.current.push(pt(e));
pressureRef.current = e.pressure && e.pressure > 0 ? e.pressure : 0.7;
renderStroke();
}
function onUp() { if (!drawingRef.current) return; drawingRef.current = null; committedRef.current = null; force((n) => n + 1); scheduleSave(); }
// Layers panel actions
function addLayer() { if (layers.length >= 4) return; const id = layerSeq.current++; layersRef.current.push(newLayer(id, `Layer ${layers.length + 1}`)); setActiveId(id); force((n) => n + 1); }
function deleteLayer(id: number) {
if (layersRef.current.length <= 1) return;
layersRef.current = layersRef.current.filter((l) => l.id !== id);
undoRef.current = undoRef.current.filter((u) => u.layerId !== id);
if (activeId === id) setActiveId(layersRef.current[layersRef.current.length - 1].id);
composite(); force((n) => n + 1); scheduleSave();
}
function toggleVisible(id: number) { const l = layersRef.current.find((x) => x.id === id); if (l) { l.visible = !l.visible; composite(); force((n) => n + 1); scheduleSave(); } }
async function finish() {
// Record completion of the Color it! lesson so the Learn page can mark it Done.
if (loggedIn) {
try { await fetch("/api/progress", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ level: meta.level, sublevel: meta.sublevel, step: 1 }) }); } catch { /* */ }
}
if (hasContent()) { const data = flatten(true); setFinalImage(data); if (loggedIn) await saveFinalDrawing(meta.level, meta.sublevel, "canvas", data); }
setDone(true);
router.refresh(); // refresh cached Learn page with the new completion
}
async function onUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]; if (!file) return;
if (!loggedIn) { setUploadMsg("Log in to save to your gallery."); return; }
setUploadMsg("Uploading…");
try { const data = await fileToDataURL(file); const ok = await saveFinalDrawing(meta.level, meta.sublevel, "upload", data); setUploadMsg(ok ? "Saved to your gallery! 🖼️" : "Couldn't save that file."); }
catch { setUploadMsg("Couldn't read that image."); }
finally { if (uploadRef.current) uploadRef.current.value = ""; }
}
// Ask which line art to color before opening the studio (only when the child has a sketch of their own).
if (sketch === null) {
return (
<div>
<div className="muted" style={{ fontWeight: 700 }}>{meta.levelName} · {phaseLabel}</div>
<h1 style={{ margin: "2px 0" }}>{meta.emoji} {meta.title}</h1>
<p className="muted">Which drawing would you like to color in?</p>
<div style={{ display: "grid", gap: 16, gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))", marginTop: 12 }}>
<button onClick={() => setSketch("yours")} className="card" style={{ cursor: "pointer", textAlign: "center", border: "2px solid var(--line)" }}>
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid var(--line)", overflow: "hidden", aspectRatio: "4 / 3" }}>
<img src={userSketch} alt="Your sketch" style={{ width: "100%", height: "100%", objectFit: "contain" }} />
</div>
<strong style={{ display: "block", marginTop: 10 }}>✏️ Use my sketch</strong>
<span className="muted" style={{ fontSize: "0.85rem" }}>Color the drawing you made</span>
</button>
<button onClick={() => setSketch("stock")} className="card" style={{ cursor: "pointer", textAlign: "center", border: "2px solid var(--line)" }}>
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid var(--line)", overflow: "hidden", aspectRatio: "4 / 3" }}>
<svg viewBox="0 0 400 300" style={{ width: "100%", height: "100%" }} dangerouslySetInnerHTML={{ __html: meta.baseSvg }} />
</div>
<strong style={{ display: "block", marginTop: 10 }}>🖼️ Use the stock picture</strong>
<span className="muted" style={{ fontSize: "0.85rem" }}>Color our ready-made outline</span>
</button>
</div>
</div>
);
}
if (done) {
return (
<div className="form-narrow center">
<div className="card">
<h1>🎨 Beautiful!</h1>
<p className="muted">You colored your {meta.title.replace(" · Color it!", "")}.</p>
{finalImage && (
<div style={{ marginTop: 12 }}>
<img src={finalImage} alt="Your colored drawing" style={{ width: "100%", borderRadius: 12, border: "1px solid var(--line)" }} />
<a className="btn secondary" href={finalImage} download={`drawit-${meta.level}-${meta.sublevel}.png`} style={{ marginTop: 10 }}>⬇ Download my drawing</a>
</div>
)}
<div style={{ marginTop: 16 }}>
<input ref={uploadRef} type="file" accept="image/*" onChange={onUpload} style={{ display: "none" }} />
<button className="btn secondary" onClick={() => uploadRef.current?.click()}>📷 Upload a paper drawing</button>
{uploadMsg && <p className="muted" style={{ marginTop: 8 }}>{uploadMsg}</p>}
</div>
<div className="row" style={{ justifyContent: "center", marginTop: 18 }}>
{loggedIn && <Link className="btn ghost" href="/profile">🖼️ My drawings</Link>}
<button className="btn ghost" onClick={() => { setDone(false); setFinalImage(null); setUploadMsg(""); }}>↺ Keep coloring</button>
<Link className="btn ghost" href="/learn">Back to lessons</Link>
</div>
</div>
</div>
);
}
const swatch = (c: string, key: string) => (
<button key={key} onClick={() => setColor(c)} aria-label={`Color ${c}`}
style={{ width: 30, height: 30, borderRadius: "50%", background: c, border: color === c ? "3px solid var(--ink)" : "3px solid #fff", boxShadow: "0 0 0 1px var(--line)", cursor: "pointer" }} />
);
return (
<div>
<div className="row" style={{ justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<div className="muted" style={{ fontWeight: 700 }}>{meta.levelName} · {phaseLabel}</div>
<h1 style={{ margin: "2px 0" }}>{meta.emoji} {meta.title}</h1>
</div>
<Link className="btn ghost" href={`/report?level=${meta.level}&sublevel=${meta.sublevel}`} style={{ minHeight: 40 }}>Report a problem</Link>
</div>
<p className="muted">{meta.intro}</p>
<div style={{ display: "grid", gap: 16, gridTemplateColumns: "1fr", alignItems: "start" }}>
<div>
<div style={{ position: "relative", width: "100%", maxWidth: 660, margin: "0 auto", aspectRatio: "4 / 3", background: "#fff", border: "2px solid var(--line)", borderRadius: 16, overflow: "hidden" }} className="no-touch-scroll">
<canvas ref={dispRef} width={W} height={H} onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp}
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", touchAction: "none", cursor: tool === "fill" ? "pointer" : "crosshair" }} />
{overlayUrl && <img src={overlayUrl} alt="" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "fill", pointerEvents: "none" }} />}
{meta.hintSvg && <svg viewBox="0 0 400 300" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", pointerEvents: "none" }} dangerouslySetInnerHTML={{ __html: meta.hintSvg }} />}
</div>
{/* Tools */}
<div className="row" style={{ justifyContent: "center", marginTop: 12, gap: 8, flexWrap: "wrap" }}>
{TOOLS.map((t) => (
<button key={t.id} onClick={() => setTool(t.id)} title={t.label}
style={{ minHeight: 40, padding: "6px 12px", borderRadius: 10, border: "2px solid var(--line)", cursor: "pointer", fontWeight: 700, fontSize: "0.85rem", background: tool === t.id ? "var(--primary)" : "#fff", color: tool === t.id ? "#fff" : "var(--ink)" }}>
{t.icon} {t.label}
</button>
))}
</div>
{/* Sizes + opacity */}
<div className="row" style={{ justifyContent: "center", marginTop: 10, gap: 10, alignItems: "center", flexWrap: "wrap" }}>
{SIZES.map((s) => (
<button key={s} onClick={() => setSize(s)} aria-label={`Size ${s}`}
style={{ width: 36, height: 36, borderRadius: "50%", background: size === s ? "var(--primary)" : "#fff", border: "2px solid var(--line)", cursor: "pointer", display: "grid", placeItems: "center" }}>
<span style={{ width: s / 3, height: s / 3, borderRadius: "50%", background: size === s ? "#fff" : "var(--ink)" }} />
</button>
))}
<span style={{ width: 1, height: 28, background: "var(--line)" }} />
<label className="muted" style={{ fontSize: "0.82rem", fontWeight: 700 }}>Opacity
<input type="range" min={0.1} max={1} step={0.1} value={opacity} onChange={(e) => setOpacity(Number(e.target.value))} style={{ verticalAlign: "middle", marginLeft: 6, width: 90 }} />
</label>
<button className="btn secondary" style={miniBtn} onClick={undo}>↶ Undo</button>
<button className="btn secondary" style={miniBtn} onClick={clearActive}>🗑 Clear layer</button>
</div>
{/* Colors + custom */}
<div className="row" style={{ justifyContent: "center", marginTop: 10, gap: 8, alignItems: "center", flexWrap: "wrap" }}>
{COLORS.map((c, i) => swatch(c, "c" + i))}
<span style={{ width: 1, height: 28, background: "var(--line)" }} />
<label title="Create a color" style={{ display: "inline-flex", alignItems: "center", gap: 6, cursor: "pointer", fontSize: "0.82rem", fontWeight: 700 }} className="muted">
➕ New color
<input type="color" value={custom} onChange={(e) => { setCustom(e.target.value); setColor(e.target.value); }} style={{ width: 30, height: 30, border: "none", background: "none", cursor: "pointer" }} />
</label>
</div>
</div>
{/* Layers + finish */}
<div className="card">
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
<strong>Layers</strong>
<button className="btn secondary" style={miniBtn} onClick={addLayer} disabled={layers.length >= 4}>+ Add</button>
</div>
<div className="stack" style={{ marginTop: 8 }}>
{[...layers].reverse().map((l) => (
<div key={l.id} className="row" style={{ justifyContent: "space-between", alignItems: "center", border: l.id === activeId ? "2px solid var(--primary)" : "2px solid var(--line)", borderRadius: 10, padding: "6px 10px" }}>
<button onClick={() => setActiveId(l.id)} style={{ background: "none", border: "none", cursor: "pointer", fontWeight: l.id === activeId ? 800 : 600, color: "var(--ink)" }}>
{l.id === activeId ? "✏️ " : ""}{l.name}
</button>
<span className="row" style={{ gap: 4 }}>
<button onClick={() => toggleVisible(l.id)} title="Show/hide" style={iconBtn}>{l.visible ? "👁️" : "🙈"}</button>
<button onClick={() => deleteLayer(l.id)} title="Delete layer" disabled={layers.length <= 1} style={iconBtn}>🗑️</button>
</span>
</div>
))}
</div>
<p className="muted" style={{ fontSize: "0.8rem", marginTop: 8 }}>
Color on the bottom layer, shade on a layer above. Lower the opacity and use the pencil or marker to layer and shade.
{loggedIn && " Saved automatically."}
</p>
<button className="btn big" onClick={finish} style={{ width: "100%", marginTop: 8 }}>✓ I&apos;m done — save it</button>
{!loggedIn && <p className="muted center" style={{ marginTop: 10, fontSize: "0.9rem" }}><Link href="/login">Log in</Link> to keep your drawing.</p>}
</div>
</div>
</div>
);
}
const miniBtn: React.CSSProperties = { minHeight: 34, padding: "6px 12px", fontSize: "0.85rem", boxShadow: "none" };
const iconBtn: React.CSSProperties = { background: "none", border: "none", cursor: "pointer", fontSize: "1rem", padding: 2 };