601 lines
26 KiB
TypeScript
601 lines
26 KiB
TypeScript
"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 { annotationBBox, hitTestPath, hitTestShape } from "@/utils/geometry";
|
||
import type { InkStamp } from "./TextboxStampLayer";
|
||
|
||
/** เส้นวาดที่เก็บใน 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,
|
||
stamps,
|
||
redactions,
|
||
active,
|
||
nextStroke,
|
||
shapeTool,
|
||
eraseMode,
|
||
redactMode,
|
||
ownUserId,
|
||
onStrokeEnd,
|
||
onShapeEnd,
|
||
onRedactionEnd,
|
||
onErase,
|
||
}: {
|
||
/** ขนาด px ตามคำนวณ (ใช้เป็นค่าเริ่มต้นก่อน ResizeObserver วัดได้) */
|
||
size: Size;
|
||
/** เส้นเดิมจาก DB (พิกัด %) */
|
||
strokes: InkStroke[];
|
||
/** รูปทรงเดิมจาก DB (พิกัด %) */
|
||
shapes: InkShape[];
|
||
/** ตรายางเดิมจาก DB (พิกัด %) — render ที่ TextboxStampLayer แต่ eraser hit-test ที่นี่ */
|
||
stamps: InkStamp[];
|
||
/** ปิดพรางเดิมจาก 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<HTMLDivElement>(null);
|
||
const [measured, setMeasured] = useState<Size>(size);
|
||
const [livePoints, setLivePoints] = useState<Point[]>([]);
|
||
const [liveShape, setLiveShape] = useState<LiveShape | null>(null);
|
||
// Shift ค้างระหว่างวาด → เส้นตรงจากจุดเริ่มถึงตำแหน่งปัจจุบัน (กด/ปล่อยกลางทางได้)
|
||
const [shiftDown, setShiftDown] = useState(false);
|
||
const drawingRef = useRef(false);
|
||
// source of truth ของจุดที่กำลังวาด — อ่านตอน pointerup โดยไม่ต้องแอบ side effect ใน updater
|
||
const pointsRef = useRef<Point[]>([]);
|
||
// F7/E1: ตัวคูณความหนาต่อจุด (จาก pressure) — เก็บเฉพาะเส้นที่วาดด้วยปากกา
|
||
const factorsRef = useRef<number[] | null>(null);
|
||
const [liveFactors, setLiveFactors] = useState<number[] | null>(null);
|
||
// F7/E1 palm rejection: เคยเห็นสไตลัสในหน้านี้ → เลิกสนใจนิ้ว (กันฝ่ามือแตะ)
|
||
const sawPenRef = useRef(false);
|
||
// เช่นเดียวกับรูปทรงที่กำลังลาก (อ่านตอน pointerup จาก ref กันค้างหลัง move สุดท้าย)
|
||
const shapeRef = useRef<LiveShape | null>(null);
|
||
// FIX-8: rAF ของ live render ระหว่างวาด — ค้าง = ยังไม่ต้องตั้งเพิ่ม
|
||
const liveRafRef = useRef<number | null>(null);
|
||
// โหมด eraser กำลังกดค้าง (F6)
|
||
const erasingRef = useRef(false);
|
||
// พื้นที่ปิดพรางที่กำลังลาก (F8) — เก็บ bbox ตรง ๆ
|
||
const redactRef = useRef<{ start: Point; end: Point } | null>(null);
|
||
const [liveRedaction, setLiveRedaction] = useState<Rect | null>(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<HTMLDivElement>) {
|
||
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<HTMLDivElement>) {
|
||
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<HTMLDivElement>) {
|
||
if (!onErase || ownUserId === "") return;
|
||
const lp = toLocalPx(e);
|
||
const p = pointToPercent(lp, measured);
|
||
const pad = 6; // เผื่อความคาดหวังของผู้ใช้นอกเส้นจริง (px)
|
||
// ตรายาง render อยู่ layer บน (TextboxStampLayer) → เช็คก่อน ให้ตัวบนสุดชนะ
|
||
// (bbox = สูตรเดียวกับ render และกรอบ selection — annotationBBox)
|
||
for (const s of stamps) {
|
||
if (s.userId !== ownUserId) continue;
|
||
const b = annotationBBox({ type: "STAMP", data: s.data }, measured);
|
||
if (
|
||
lp.x >= b.x - pad &&
|
||
lp.x <= b.x + b.width + pad &&
|
||
lp.y >= b.y - pad &&
|
||
lp.y <= b.y + b.height + pad
|
||
) {
|
||
onErase(s.id);
|
||
return;
|
||
}
|
||
}
|
||
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 (
|
||
<g key={key}>
|
||
{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 (
|
||
<line key={i} x1={p.x} y1={p.y} x2={q.x} y2={q.y} {...strokeProps(color, w, opacity)} />
|
||
);
|
||
})}
|
||
</g>
|
||
);
|
||
}
|
||
return (
|
||
<path
|
||
key={key}
|
||
d={toPathD(ptsPx)}
|
||
{...strokeProps(color, toPx(baseSwPct, measured.width), opacity)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
/** 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 (
|
||
<ellipse
|
||
key={key}
|
||
cx={r.x + r.width / 2}
|
||
cy={r.y + r.height / 2}
|
||
rx={r.width / 2}
|
||
ry={r.height / 2}
|
||
{...strokeProps(d.strokeColor, sw)}
|
||
/>
|
||
);
|
||
}
|
||
return <rect key={key} x={r.x} y={r.y} width={r.width} height={r.height} {...strokeProps(d.strokeColor, sw)} />;
|
||
}
|
||
// line/arrow — จุดเริ่ม/จุดปลาย (D3)
|
||
const a = pointToPx(d.from, measured);
|
||
const b = pointToPx(d.to, measured);
|
||
const line = <line x1={a.x} y1={a.y} x2={b.x} y2={b.y} {...strokeProps(d.strokeColor, sw)} />;
|
||
if (d.shape === "line") return <g key={key}>{line}</g>;
|
||
return (
|
||
<g key={key}>
|
||
{line}
|
||
<path d={arrowHeadPath(a, b, sw)} fill={d.strokeColor} />
|
||
</g>
|
||
);
|
||
}
|
||
|
||
// เส้นตรงเมื่อ Shift ค้าง — เฉพาะโหมดวาดอิสระ (ไม่ใช่รูปทรง/eraser/redact)
|
||
const straight = shiftDown && !shapeTool && !eraseMode && !redactMode;
|
||
|
||
return (
|
||
<div
|
||
ref={rootRef}
|
||
className="absolute inset-0 z-[3] touch-none"
|
||
style={{ pointerEvents: active ? "auto" : "none" }}
|
||
onPointerDown={handlePointerDown}
|
||
onPointerMove={handlePointerMove}
|
||
onPointerUp={endDrag}
|
||
onPointerCancel={endDrag}
|
||
>
|
||
{/* SVG แบบไม่มี viewBox — วาดในระบบ px จริงเท่าขนาดที่วัดได้ */}
|
||
<svg width={measured.width} height={measured.height} className="block">
|
||
{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 (
|
||
<rect key={rd.id} x={r.x} y={r.y} width={r.width} height={r.height} fill="#000" />
|
||
);
|
||
})}
|
||
{liveRedaction && (
|
||
<rect
|
||
x={liveRedaction.x}
|
||
y={liveRedaction.y}
|
||
width={liveRedaction.width}
|
||
height={liveRedaction.height}
|
||
fill="#000"
|
||
/>
|
||
)}
|
||
{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,
|
||
},
|
||
)}
|
||
</svg>
|
||
</div>
|
||
);
|
||
}
|