Files
edueasy/components/child/trazo-letra.tsx
T
renato97 a3360ec6e4 feat: fase 2+3 — expansion masiva multi-perfil + bug fixes + hardening
Fase 2 (expansion):
- Francesca: +6 etapas matematicas (5-10), +7 etapas lectura (3-9), +2 ortografia
- Sebastian: +8 etapas matematicas (4-11), +6 lectura, +3 historia, +3 geografia, +1 banderas
- Nuevas paginas: ortografia, lectura, geografia, matematicas
- ~900 ejercicios nuevos, paridad ~500 por perfil

Fase 3 (bug fixes + hardening):
- Fix: SessionLog auto-create en grade route
- Fix: engine cache (no borrar antes de revisar)
- Fix: FSRS cards actualizadas en flujo curriculum
- Fix: responseMs real (no hardcodeado 3000)
- Fix: options dentro de content en sebastian etapa-1
- Fix: trazo-letra consonantes crash
- Security: execFileSync anti-command-injection en TTS
- DB: 18 cascade deletes + indices + unique constraints
- APIs debug: reset, state, seed
- Docker: espeak-ng + prisma + user/group
- E2E: 23 tests nuevos (curriculum-flow.spec.ts)
- Code review: tailwind colors, middleware dev mode, eslint config

Verificado: tsc exit 0, build exit 0, 61 E2E tests pass
2026-07-25 22:07:28 -03:00

181 lines
5.6 KiB
TypeScript

"use client"
import { useState, useRef, useCallback, useEffect } from "react"
import { getStroke } from "perfect-freehand"
interface TrazoLetraProps {
letra: string
color?: string
onComplete?: () => void
promptLevel?: number
}
export default function TrazoLetra({
letra,
color = "#8CB8A0",
onComplete,
promptLevel = 0,
}: TrazoLetraProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const [isDrawing, setIsDrawing] = useState(false)
const points = useRef<number[][]>([])
const [showLetter, setShowLetter] = useState(promptLevel < 2)
useEffect(() => {
if (promptLevel < 2) {
drawLetterGuide(letra)
}
}, [letra, promptLevel])
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
canvas.style.touchAction = "none"
}, [])
const drawLetterGuide = (l: string) => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext("2d")
if (!ctx) return
const w = canvas.width
const h = canvas.height
ctx.clearRect(0, 0, w, h)
ctx.font = `${h * 0.6}px "Fredoka", sans-serif`
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.globalAlpha = 0.15
ctx.fillStyle = "#2D3436"
ctx.fillText(l.toUpperCase(), w / 2, h / 2)
ctx.globalAlpha = 1
}
const startDrawing = useCallback((e: React.PointerEvent) => {
setIsDrawing(true)
points.current = [[e.clientX, e.clientY, e.pressure || 0.5]]
const canvas = canvasRef.current
if (canvas) canvas.setPointerCapture(e.pointerId)
}, [])
const draw = useCallback((e: React.PointerEvent) => {
if (!isDrawing) return
points.current.push([e.clientX, e.clientY, e.pressure || 0.5])
renderStroke()
}, [isDrawing])
const endDrawing = useCallback(() => {
setIsDrawing(false)
if (points.current.length > 5 && onComplete) {
onComplete()
}
points.current = []
}, [onComplete])
const renderStroke = () => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext("2d")
if (!ctx) return
const stroke = getStroke(points.current, {
size: 12,
thinning: 0.5,
smoothing: 0.5,
streamline: 0.5,
})
if (stroke.length < 2) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
if (showLetter) drawLetterGuide(letra)
ctx.beginPath()
ctx.moveTo(stroke[0][0], stroke[0][1])
for (let i = 1; i < stroke.length; i++) {
ctx.lineTo(stroke[i][0], stroke[i][1])
}
ctx.strokeStyle = color
ctx.lineWidth = 3
ctx.lineCap = "round"
ctx.lineJoin = "round"
ctx.fillStyle = color
ctx.fill()
if (promptLevel < 3) {
drawDirectionArrow(ctx, letra)
}
}
const drawDirectionArrow = (ctx: CanvasRenderingContext2D, l: string) => {
const w = ctx.canvas.width
const h = ctx.canvas.height
ctx.save()
ctx.globalAlpha = 0.3
ctx.strokeStyle = "#8CB8A0"
ctx.lineWidth = 2
ctx.fillStyle = "#8CB8A0"
const arrowPaths: Record<string, { sx: number; sy: number; ex: number; ey: number }> = {
a: { sx: w * 0.3, sy: h * 0.7, ex: w * 0.7, ey: h * 0.3 },
e: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.7 },
i: { sx: w * 0.5, sy: h * 0.2, ex: w * 0.5, ey: h * 0.8 },
o: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.6, ey: h * 0.6 },
u: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.5 },
// Consonants: generic top-to-bottom, left-to-right arrow
b: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.7 },
c: { sx: w * 0.6, sy: h * 0.3, ex: w * 0.4, ey: h * 0.7 },
d: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.7 },
f: { sx: w * 0.5, sy: h * 0.2, ex: w * 0.5, ey: h * 0.8 },
g: { sx: w * 0.6, sy: h * 0.3, ex: w * 0.4, ey: h * 0.7 },
h: { sx: w * 0.5, sy: h * 0.2, ex: w * 0.5, ey: h * 0.8 },
j: { sx: w * 0.5, sy: h * 0.2, ex: w * 0.5, ey: h * 0.8 },
k: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.7 },
l: { sx: w * 0.5, sy: h * 0.2, ex: w * 0.5, ey: h * 0.8 },
m: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.5 },
n: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.5 },
p: { sx: w * 0.5, sy: h * 0.2, ex: w * 0.5, ey: h * 0.8 },
q: { sx: w * 0.6, sy: h * 0.3, ex: w * 0.4, ey: h * 0.7 },
r: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.7 },
s: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.7 },
t: { sx: w * 0.5, sy: h * 0.2, ex: w * 0.5, ey: h * 0.8 },
v: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.7 },
w: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.5 },
x: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.7 },
y: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.7 },
z: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.5 },
}
const path = arrowPaths[l.toLowerCase()]
if (path) {
ctx.beginPath()
ctx.moveTo(path.sx, path.sy)
ctx.lineTo(path.ex, path.ey)
ctx.stroke()
const angle = Math.atan2(path.ey - path.sy, path.ex - path.sx)
const sz = 6
ctx.beginPath()
ctx.moveTo(path.ex, path.ey)
ctx.lineTo(path.ex - sz * Math.cos(angle - Math.PI / 6), path.ey - sz * Math.sin(angle - Math.PI / 6))
ctx.lineTo(path.ex - sz * Math.cos(angle + Math.PI / 6), path.ey - sz * Math.sin(angle + Math.PI / 6))
ctx.closePath()
ctx.fill()
}
ctx.restore()
}
return (
<canvas
ref={canvasRef}
width={300}
height={300}
className="w-[250px] h-[250px] touch-none rounded-2xl bg-white shadow-md"
onPointerDown={startDrawing}
onPointerMove={draw}
onPointerUp={endDrawing}
onPointerCancel={endDrawing}
/>
)
}