"use client"; import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, } from "react"; import type { RedactionData, ShapeData, ShapeKind } from "@/db/schema"; import { clamp, pathToPercent, pointToPercent, pointToPx, rectToPercent, rectToPx, toPx, type Point, type Rect, type Size, } from "@/utils/coordinates"; import { hitTestPath, hitTestShape } from "@/utils/geometry"; /** เส้นวาดที่เก็บใน DB — จุดทุกจุดเป็น % ของหน้า, strokeWidth เป็น % ของความกว้าง */ export type InkStroke = { id: string; /** เจ้าของ — eraser ใช้กรอง hit-test เฉพาะของตัวเอง (F6) */ userId: string; color: string; strokeWidthPct: number; points: Point[]; /** F7/E1: ความหนาต่อจุด (% ของความกว้างหน้า) — มีเฉพาะเส้นที่วาดด้วยสไตลัส */ widthsPct?: number[]; /** ความทึบของเส้น (0–1) — parent คำนวณให้แล้วรวม fallback ตามชนิดปากกา (effectiveDrawOpacity) */ opacity?: number; }; /** รูปทรงที่เก็บใน DB (F5/B1) — พิกัด % ทั้งหมด */ export type InkShape = { id: string; userId: string; data: ShapeData }; /** ปิดพรางที่เก็บใน DB (F8/B4) — rect % ของหน้า */ export type InkRedaction = { id: string; userId: string; data: RedactionData }; /** รูปทรงที่กำลังลาก (px) */ type LiveShape = { kind: ShapeKind; start: Point; end: Point }; /** * F7/E1: แปลง e.pressure (0–1, mouse ได้ 0.5 ตลอด) เป็นตัวคูณความหนา * 0.5 + pressure → เมาส์/แตะนิ้วได้ 1.0 เท่าเดิมพอดี, ปากกากดเบา–หนักได้ 0.5–1.5 เท่า */ const pressureFactor = (pressure: number): number => 0.5 + clamp(pressure, 0, 1); /** * วาด "ตรง ๆ ด้วยมือ" แล้วปล่อย → snap เป็นเส้นตรงอัตโนมัติ (ไม่ต้องกด Shift): * จุดทุกจุดต้องห่างจากเส้นตรง จุดเริ่ม→จุดสุดท้าย ไม่เกิน max(5% ของความยาว, 3px) * เส้นโค้ง/ขดจริง ๆ จะหลุดเกณฑ์ → เหลือเป็น freehand ตามที่วาด */ function isNearlyStraight(pts: Point[]): boolean { const a = pts[0]; const b = pts[pts.length - 1]; const len = Math.hypot(b.x - a.x, b.y - a.y); if (len < 24) return false; // เส้นสั้นมาก (ขีดสั้น ๆ) ปล่อยตามที่วาด const maxDev = Math.max(len * 0.05, 3); for (const p of pts) { // ระยะตั้งฉากจาก p ไปยังเส้นตรง a→b const dev = Math.abs((b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x)) / len; if (dev > maxDev) return false; } return true; } /** * SVG freehand (PLAN.md §6.2) + รูปทรง (PLAN-FEAT.md §3 F5) — ห้ามใส่ viewBox * (ความหนาเส้นจะบิดเบี้ยวเมื่อ scale แกน x/y ไม่เท่ากัน) render ระบบ px จริง * แปลง % → px ด้วยขนาดที่ ResizeObserver วัด */ export default function InkLayer({ size, strokes, shapes, redactions, active, nextStroke, shapeTool, eraseMode, redactMode, ownUserId, onStrokeEnd, onShapeEnd, onRedactionEnd, onErase, }: { /** ขนาด px ตามคำนวณ (ใช้เป็นค่าเริ่มต้นก่อน ResizeObserver วัดได้) */ size: Size; /** เส้นเดิมจาก DB (พิกัด %) */ strokes: InkStroke[]; /** รูปทรงเดิมจาก DB (พิกัด %) */ shapes: InkShape[]; /** ปิดพรางเดิมจาก DB (พิกัด %) — วาดทับบังข้อความจริงบนจอ (F8) */ redactions: InkRedaction[]; /** tool วาด/รูปทรง/eraser/redact → รับ pointer events */ active: boolean; /** ค่าเริ่มต้นของเส้นใหม่ที่กำลังวาด (tool draw) — opacity 0–1 ใช้ตอน render */ nextStroke: { color: string; strokeWidthPct: number; opacity?: number }; /** tool shape → ชนิดรูปทรงที่จะวาด (null = โหมดวาดเส้น) */ shapeTool: { kind: ShapeKind; color: string; strokeWidthPct: number } | null; /** tool erase → ลากผ่านเส้น/รูปทรงของตัวเองเพื่อลบ (F6/B3) */ eraseMode: boolean; /** tool redact → ลากคลุมพื้นที่เพื่อปิดพราง (F8/B4) */ redactMode: boolean; /** userId ของผู้ใช้ปัจจุบัน — hit-test เฉพาะรายการของตัวเอง */ ownUserId: string; /** pointerup แล้วจุด ≥ 2 → ส่งจุด (% ของหน้า) + widths (%, เฉพาะปากกา) ให้ parent บันทึก */ onStrokeEnd?: (pointsPct: Point[], widthsPct?: number[]) => void; /** pointerup แล้วรูปทรงมีขนาดพอ → ส่ง ShapeData (% ของหน้า) ให้ parent บันทึก */ onShapeEnd?: (data: ShapeData) => void; /** pointerup แล้วพื้นที่พอ → ส่ง RedactionData (% ของหน้า) ให้ parent บันทึก */ onRedactionEnd?: (data: RedactionData) => void; /** hit-test โดนรายการของตัวเอง → parent เรียก deleteAnnotation ทันที */ onErase?: (id: string) => void; }) { const rootRef = useRef(null); const [measured, setMeasured] = useState(size); const [livePoints, setLivePoints] = useState([]); const [liveShape, setLiveShape] = useState(null); // Shift ค้างระหว่างวาด → เส้นตรงจากจุดเริ่มถึงตำแหน่งปัจจุบัน (กด/ปล่อยกลางทางได้) const [shiftDown, setShiftDown] = useState(false); const drawingRef = useRef(false); // source of truth ของจุดที่กำลังวาด — อ่านตอน pointerup โดยไม่ต้องแอบ side effect ใน updater const pointsRef = useRef([]); // F7/E1: ตัวคูณความหนาต่อจุด (จาก pressure) — เก็บเฉพาะเส้นที่วาดด้วยปากกา const factorsRef = useRef(null); const [liveFactors, setLiveFactors] = useState(null); // F7/E1 palm rejection: เคยเห็นสไตลัสในหน้านี้ → เลิกสนใจนิ้ว (กันฝ่ามือแตะ) const sawPenRef = useRef(false); // เช่นเดียวกับรูปทรงที่กำลังลาก (อ่านตอน pointerup จาก ref กันค้างหลัง move สุดท้าย) const shapeRef = useRef(null); // FIX-8: rAF ของ live render ระหว่างวาด — ค้าง = ยังไม่ต้องตั้งเพิ่ม const liveRafRef = useRef(null); // โหมด eraser กำลังกดค้าง (F6) const erasingRef = useRef(false); // พื้นที่ปิดพรางที่กำลังลาก (F8) — เก็บ bbox ตรง ๆ const redactRef = useRef<{ start: Point; end: Point } | null>(null); const [liveRedaction, setLiveRedaction] = useState(null); // re-render เมื่อ resize/zoom (rules/coordinates.md) useEffect(() => { const el = rootRef.current; if (!el) return; const ro = new ResizeObserver(() => { setMeasured({ width: el.clientWidth, height: el.clientHeight }); }); ro.observe(el); return () => ro.disconnect(); }, []); // FIX-10: mirror prop size ทันทีเมื่อ zoom — ไม่งั้นมี 1 render // หลัง zoom ที่เส้นทั้งหน้าวาดด้วย scale เก่าก่อน ResizeObserver ยิงมายืนยันค่าจริง // เทียบค่าก่อน set — prop เป็น inline object ใหม่ทุก parent render แต่ค่าเท่าเดิม // ต้องไม่นับเป็นการเปลี่ยน state (กัน render ซ้ำเปลือง ๆ) useEffect(() => { setMeasured((prev) => prev.width === size.width && prev.height === size.height ? prev : size, ); }, [size]); // FIX-8: เลิกวาดกลางคันแล้ว unmount — ยกเลิก rAF ที่ค้างอยู่ด้วย useEffect( () => () => { if (liveRafRef.current !== null) cancelAnimationFrame(liveRafRef.current); }, [], ); // ติดตาม Shift ระดับ window — กด/ปล่อยกลางทางระหว่างลากก็ต้อง snap เป็นเส้นตรง/คลายกลับทันที useEffect(() => { const down = (e: KeyboardEvent) => { if (e.key === "Shift") setShiftDown(true); }; const up = (e: KeyboardEvent) => { if (e.key === "Shift") setShiftDown(false); }; const clear = () => setShiftDown(false); // window blur ระหว่างกดค้าง window.addEventListener("keydown", down); window.addEventListener("keyup", up); window.addEventListener("blur", clear); return () => { window.removeEventListener("keydown", down); window.removeEventListener("keyup", up); window.removeEventListener("blur", clear); }; }, []); // clamp ให้อยู่ในหน้าเสมอ — pointer capture ส่งพิกัดนอก page ได้ และ % ติดลบ // จะโดน Server Action ปฏิเสธทั้งเส้น (rules/coordinates.md: clamp 0–100) const toLocalPx = (e: { clientX: number; clientY: number }): Point => { // FIX-9: เอา non-null ! ออก — handler ยิงได้แค่ตอน DOM mount แล้ว // ref จึงไม่ควรเป็น null (คืน 0,0 เผื่อไว้เฉย ๆ ให้ type ชัด) const el = rootRef.current; if (!el) return { x: 0, y: 0 }; const r = el.getBoundingClientRect(); return { x: clamp(e.clientX - r.left, 0, r.width), y: clamp(e.clientY - r.top, 0, r.height), }; }; /** normalize จุดเริ่ม/จุดปลายของการลากเป็น bbox (px) — ใช้กับ redaction (F8) */ const bboxFromDrag = (d: { start: Point; end: Point }): Rect => ({ x: Math.min(d.start.x, d.end.x), y: Math.min(d.start.y, d.end.y), width: Math.abs(d.end.x - d.start.x), height: Math.abs(d.end.y - d.start.y), }); function handlePointerDown(e: ReactPointerEvent) { if (!active || e.button !== 0) return; // F7/E1 palm rejection: เคยเห็นปากกาในหน้านี้ → ละเว้นนิ้ว (แตะ/ฝ่ามือ) if (e.pointerType === "pen") { sawPenRef.current = true; } else if (e.pointerType === "touch" && sawPenRef.current) { return; } // pointer capture → เส้นไม่ขาดเวลาเลื่อนออกนอก page (PLAN.md §6.2) e.currentTarget.setPointerCapture(e.pointerId); if (eraseMode) { erasingRef.current = true; eraseAt(e); return; } if (redactMode) { const p = toLocalPx(e); redactRef.current = { start: p, end: p }; setLiveRedaction({ x: p.x, y: p.y, width: 0, height: 0 }); return; } drawingRef.current = true; const p = toLocalPx(e); if (shapeTool) { shapeRef.current = { kind: shapeTool.kind, start: p, end: p }; setLiveShape(shapeRef.current); } else { // FIX-8: state ต้องไม่แชร์ reference กับ ref (ref ถูก mutate push ระหว่างวาด) pointsRef.current = [p]; setLivePoints([p]); // ความหนาต่อจุดเก็บเฉพาะปากกา (mouse/touch ไม่มี pressure จริง → สเปกเดิม) if (e.pointerType === "pen") { factorsRef.current = [pressureFactor(e.pressure)]; } else { factorsRef.current = null; } setLiveFactors(factorsRef.current ? [...factorsRef.current] : null); } } function handlePointerMove(e: ReactPointerEvent) { if (erasingRef.current) { eraseAt(e); return; } if (redactRef.current) { redactRef.current = { ...redactRef.current, end: toLocalPx(e) }; setLiveRedaction(bboxFromDrag(redactRef.current)); return; } if (!drawingRef.current) return; const p = toLocalPx(e); if (shapeTool) { shapeRef.current = shapeRef.current ? { ...shapeRef.current, end: p } : null; setLiveShape(shapeRef.current); } else { // FIX-8: push จุดลง ref ทันที (ข้อมูลที่บันทึกครบทุกจุดเหมือนเดิม) แต่ยิง // re-render ครั้งเดียวต่อ frame ผ่าน rAF — กัน copy array + re-render SVG // ทั้งเส้นใหม่ทุก pointermove (= O(n²) เมื่อเส้นยาวหลายพันจุด) pointsRef.current.push(p); if (factorsRef.current) { factorsRef.current.push(pressureFactor(e.pressure)); } if (liveRafRef.current === null) { liveRafRef.current = requestAnimationFrame(() => { liveRafRef.current = null; setLivePoints([...pointsRef.current]); setLiveFactors(factorsRef.current ? [...factorsRef.current] : null); }); } } } /** hit-test ตำแหน่ง pointer กับเส้น/รูปทรง **ของตัวเอง** — โดนเมื่อไรลบทันทีทีละรายการ (F6) */ function eraseAt(e: ReactPointerEvent) { if (!onErase || ownUserId === "") return; const p = pointToPercent(toLocalPx(e), measured); const pad = 6; // เผื่อความคาดหวังของผู้ใช้นอกเส้นจริง (px) for (const s of strokes) { if (s.userId !== ownUserId) continue; const threshold = toPx(s.strokeWidthPct, measured.width) / 2 + pad; if (hitTestPath(s.points, p, threshold, measured)) { onErase(s.id); return; } } for (const s of shapes) { if (s.userId !== ownUserId) continue; const threshold = toPx(s.data.strokeWidth, measured.width) / 2 + pad; if (hitTestShape(s.data, p, threshold, measured)) { onErase(s.id); return; } } } function endDrag() { if (erasingRef.current) { erasingRef.current = false; return; } if (redactRef.current) { const bbox = bboxFromDrag(redactRef.current); redactRef.current = null; setLiveRedaction(null); // เล็กเกิน (คลิกเฉย ๆ) ไม่สร้าง if (bbox.width >= 2 && bbox.height >= 2) { onRedactionEnd?.({ rect: rectToPercent(bbox, measured) }); } return; } if (!drawingRef.current) return; drawingRef.current = false; if (shapeTool) { const st = shapeRef.current; shapeRef.current = null; setLiveShape(null); if (st) endShape(st); } else { // FIX-8: ยกเลิก rAF ที่ยังค้างอยู่ก่อนเคลียร์ live state — ไม่งั้น callback // ยิงหลังจากนี้แล้วเส้นที่วาดจบโผล่กลับมาอีกครั้งบนหน้าจอ if (liveRafRef.current !== null) { cancelAnimationFrame(liveRafRef.current); liveRafRef.current = null; } const points = pointsRef.current; const factors = factorsRef.current; pointsRef.current = []; factorsRef.current = null; setLivePoints([]); setLiveFactors(null); if (points.length >= 2) { // ปล่อยแล้ว snap เป็นเส้นตรง: Shift ค้าง = บังคับตรงเสมอ หรือวาดตรง ๆ ด้วยมือ // (isNearlyStraight) → เหลือจุดเริ่ม→จุดสุดท้าย 2 จุด (ความหนา uniform) const snapped = straight || isNearlyStraight(points); const finalPoints = snapped ? [points[0], points[points.length - 1]] : points; const widthsPct = factors && !snapped ? factors.map((f) => nextStroke.strokeWidthPct * f) : undefined; onStrokeEnd?.(pathToPercent(finalPoints, measured), widthsPct); } } } /** คำนวณ ShapeData (%) จากรูปทรงที่ลากจบ — เล็กเกินไป (คลิกเฉย ๆ) ไม่สร้าง */ function endShape(st: LiveShape) { const tool = shapeTool; if (!tool) return; const { start, end } = st; if (st.kind === "rect" || st.kind === "ellipse") { const w = Math.abs(end.x - start.x); const h = Math.abs(end.y - start.y); if (w < 2 || h < 2) return; const rect = rectToPercent( { x: Math.min(start.x, end.x), y: Math.min(start.y, end.y), width: w, height: h, }, measured, ); onShapeEnd?.({ shape: st.kind, rect, strokeColor: tool.color, strokeWidth: tool.strokeWidthPct, }); } else { if (Math.hypot(end.x - start.x, end.y - start.y) < 2) return; onShapeEnd?.({ shape: st.kind, from: pointToPercent(start, measured), to: pointToPercent(end, measured), strokeColor: tool.color, strokeWidth: tool.strokeWidthPct, }); } } const toPathD = (points: Point[]): string => points .map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(2)} ${p.y.toFixed(2)}`) .join(" "); /** หัวลูกศร — path สามเหลี่ยมคำนวณจาก from→to (F5 สเปก render) */ const arrowHeadPath = (from: Point, to: Point, sw: number): string => { const head = Math.max(sw * 3.5, 8); const ang = Math.atan2(to.y - from.y, to.x - from.x); const spread = Math.PI / 7; const p1 = { x: to.x - head * Math.cos(ang - spread), y: to.y - head * Math.sin(ang - spread), }; const p2 = { x: to.x - head * Math.cos(ang + spread), y: to.y - head * Math.sin(ang + spread), }; return `M${to.x} ${to.y} L${p1.x} ${p1.y} L${p2.x} ${p2.y} Z`; }; const strokeProps = (color: string, sw: number, opacity?: number) => ({ stroke: color, strokeWidth: sw, // opacity < 1 = เส้นโปร่งแสงให้เห็นข้อความใต้เส้น (ค่ารวม fallback ตาม pen มาจาก parent แล้ว) strokeOpacity: opacity ?? 1, strokeLinecap: "round" as const, strokeLinejoin: "round" as const, fill: "none", }); /** F7/E1: มี widths ต่อจุด → render เป็น segment ทีละท่อน (ความหนาต่างกัน), ไม่มี → path เดียวเหมือนเดิม */ function renderStrokeEl( key: string, color: string, baseSwPct: number, ptsPx: Point[], widthsPct?: number[], opacity?: number, ) { if (widthsPct && widthsPct.length === ptsPx.length && ptsPx.length >= 2) { return ( {ptsPx.slice(0, -1).map((p, i) => { const q = ptsPx[i + 1]; const w = (toPx(widthsPct[i], measured.width) + toPx(widthsPct[i + 1], measured.width)) / 2; return ( ); })} ); } return ( ); } /** render รูปทรงหนึ่งชิ้นจาก ShapeData (%) — ใช้ได้ทั้งของเดิมและ live preview */ function renderShapeEl(d: ShapeData, key?: string) { const sw = toPx(d.strokeWidth, measured.width); if ("rect" in d) { // rect/ellipse เก็บเป็น bbox ใน data.rect const r = rectToPx(d.rect, measured); if (d.shape === "ellipse") { return ( ); } return ; } // line/arrow — จุดเริ่ม/จุดปลาย (D3) const a = pointToPx(d.from, measured); const b = pointToPx(d.to, measured); const line = ; if (d.shape === "line") return {line}; return ( {line} ); } // เส้นตรงเมื่อ Shift ค้าง — เฉพาะโหมดวาดอิสระ (ไม่ใช่รูปทรง/eraser/redact) const straight = shiftDown && !shapeTool && !eraseMode && !redactMode; return (
{/* SVG แบบไม่มี viewBox — วาดในระบบ px จริงเท่าขนาดที่วัดได้ */} {strokes.map((s) => renderStrokeEl( s.id, s.color, s.strokeWidthPct, s.points.map((p) => pointToPx(p, measured)), s.widthsPct, s.opacity, ), )} {shapes.map((s) => renderShapeEl(s.data, s.id))} {/* REDACTION — ดำทึบ บังข้อความจริงบนจอ (F8/B4) */} {redactions.map((rd) => { const r = rectToPx(rd.data.rect, measured); return ( ); })} {liveRedaction && ( )} {livePoints.length >= 2 && renderStrokeEl( "live-stroke", nextStroke.color, nextStroke.strokeWidthPct, straight ? [livePoints[0], livePoints[livePoints.length - 1]] : livePoints, liveFactors && !straight ? liveFactors.map((f) => nextStroke.strokeWidthPct * f) : undefined, nextStroke.opacity, )} {liveShape && shapeTool && renderShapeEl( liveShape.kind === "rect" || liveShape.kind === "ellipse" ? { shape: liveShape.kind, rect: rectToPercent( { x: Math.min(liveShape.start.x, liveShape.end.x), y: Math.min(liveShape.start.y, liveShape.end.y), width: Math.abs(liveShape.end.x - liveShape.start.x), height: Math.abs(liveShape.end.y - liveShape.start.y), }, measured, ), strokeColor: shapeTool.color, strokeWidth: shapeTool.strokeWidthPct, } : { shape: liveShape.kind, from: pointToPercent(liveShape.start, measured), to: pointToPercent(liveShape.end, measured), strokeColor: shapeTool.color, strokeWidth: shapeTool.strokeWidthPct, }, )}
); }