353 lines
19 KiB
TypeScript
353 lines
19 KiB
TypeScript
"use client";
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import Link from "next/link";
|
||
import { loadProgressDrawing, saveProgressDrawing, saveFinalDrawing, fileToDataURL } from "./drawingPersistence";
|
||
|
||
interface Meta { level: string; levelName: string; sublevel: number; title: string; emoji: string; intro: string; baseSvg: 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 }: {
|
||
meta: Meta; loggedIn: boolean; username: string; alreadyEarned: boolean;
|
||
}) {
|
||
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 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]);
|
||
|
||
// Rasterize the coloring-book template for the fill boundary + final export
|
||
useEffect(() => {
|
||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300" width="${W}" height="${H}">${meta.baseSvg}</svg>`;
|
||
const blob = new Blob([svg], { type: "image/svg+xml" });
|
||
const url = URL.createObjectURL(blob);
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
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);
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
img.src = url;
|
||
}, [meta.baseSvg]);
|
||
|
||
// Restore saved coloring into the bottom layer
|
||
useEffect(() => {
|
||
if (!loggedIn) { composite(); return; }
|
||
let alive = true;
|
||
loadProgressDrawing(meta.level, meta.sublevel).then((data) => {
|
||
if (!data || !alive) { composite(); return; }
|
||
const im = new Image();
|
||
im.onload = () => { layersRef.current[0].canvas.getContext("2d")!.drawImage(im, 0, 0, W, H); composite(); };
|
||
im.src = data;
|
||
});
|
||
return () => { alive = false; };
|
||
}, [loggedIn, meta.level, meta.sublevel, composite]);
|
||
|
||
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.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.globalAlpha = 1; ctx.globalCompositeOperation = "source-over";
|
||
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];
|
||
strokeStyleFor(e.pressure && e.pressure > 0 ? e.pressure : 0.7);
|
||
renderStroke();
|
||
}
|
||
function onMove(e: React.PointerEvent) {
|
||
if (!drawingRef.current) return;
|
||
drawingRef.current.push(pt(e));
|
||
strokeStyleFor(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() {
|
||
if (hasContent()) { const data = flatten(true); setFinalImage(data); if (loggedIn) await saveFinalDrawing(meta.level, meta.sublevel, "canvas", data); }
|
||
setDone(true);
|
||
}
|
||
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 = ""; }
|
||
}
|
||
|
||
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); if (tool === "eraser" || tool === "fill") setTool("pen"); }} 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} · Color it!</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" }} />
|
||
<svg viewBox="0 0 400 300" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", pointerEvents: "none" }} dangerouslySetInnerHTML={{ __html: meta.baseSvg }} />
|
||
</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); if (tool === "eraser" || tool === "fill") setTool("pen"); }} 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'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 };
|