Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight, freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo, shapes, redaction, export PDF/CSV, reply, resolve, search, summary, textbox, stamp, version history) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
6fd0c3174f
commit
eddcec28c7
60 changed files with 15055 additions and 0 deletions
581
components/pdf/InkLayer.tsx
Normal file
581
components/pdf/InkLayer.tsx
Normal file
|
|
@ -0,0 +1,581 @@
|
|||
"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<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 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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue