eCourt-note/components/pdf/InkLayer.tsx

603 lines
26 KiB
TypeScript
Raw Permalink Normal View History

"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";
stamp-editor: ตรายางพิมพ์เอง+จัดบรรทัดแบบถอยหลัง+มุมเอียง, editor กล่องข้อความ contentEditable + แก้ตาม code review 2026-09-29 ครบ 5 ข้อ — (1) STAMP คำพิมพ์เอง (D7): dropdown ของ tool stamp เป็น 2 หน้า — preset + แถว "กำหนดเอง…" (stampCustomMode reset ทุกครั้งที่เปิดเมนู) เปิด popup แก้ข้อความ/สี/ขนาด พร้อม preview ตรายาง live ตามสัดส่วนจริง (ความกว้างหน้าแรก × scale%) ใช้สูตร/ฟอนต์/measure ตัวเดียวกับ render จริง, คำพิมพ์ live ไป stampPreset เดียวกับ preset, Enter/ปุ่ม "ใช้ตรายางนี้" = ปิดเมนูแบบเลือกค่าจบ, ปุ่ม tool แสดง "กำหนดเอง" แทนคำเต็ม + แก้คำตรายางที่วางแล้วผ่านปุ่ม "แก้ไขข้อความ" ในแผง selection (StampEditor textarea ใน TextboxStampLayer ใช้ editingId/commitEdit ร่วมกับ TEXTBOX, จำกัด STAMP_MAX_LABEL_CHARS=50 ที่ UI — validator ยังยอม MAX_TEXT_CHARS ตามเจตนา) (2) จัดบรรทัดแบบถอยหลัง (คำขอผู้ใช้รอบสอง): สูตรย้ายรวมที่ utils/geometry.ts — stampLayout: คำสั้น 1 บรรทัดฟอนต์เต็ม, คำยาวพอดี ๆ ย่อฟอนต์ให้อยู่บรรทัดเดียวก่อน (stampFitFontSize เชิงเส้น) จนถึง floor 10% ของความกว้างกรอบ ("ตรวจสอบแล้ว" ย่อ ~25% = บรรทัดเดียว), ยาวเกิน floor กลับไปฟอนต์ใหญ่แล้ว wrapStampLabel ≤3 บรรทัด (×0.92/รอบ เกินบรรทัดยอมแลกกับฟอนต์อ่านออก), stampBoxHeight ให้ 1 บรรทัด = 0.4w เป๊ะเหมือนเดิม — ทุกมิติเป็นสัดส่วนของ w จำนวนบรรทัดจึงไม่ขึ้นกับ scale — constants + ฟังก์ชัน re-export จาก utils/annotation-display.ts ให้ import เดิมทุกจุด + measureStampText (canvas bold system-ui คืน 0 ตอน SSR) เป็น measure ของ render/preview/hit-test/eraser ทั้งฝั่ง client (annotationBBox รับ optional measure — ให้ความสูงกรอบตรง render จริง แก้ขอบล่างกรอบเกินตรายาง) ส่วนฝั่ง server (validator/export sticky note/describeAnnotationChange) ใช้ stampLayoutEstimate ความกว้างต่ออักษร 0.58 (3) มุมเอียง StampData.rotation? (−180..180 หมุนรอบกลางกรอบ): ช่อง "มุมเอียง" ในแผง selection (patch stampRotation, 0° = ถอด field) + จุดจับหมุนวงกลมเหนือขอบบน (mode rotate — atan2 delta รอบกลางกรอบ + snap ทวีคูณ 15° ≤4°), กรอบ selection+handles หมุนด้วย CSS transform ที่ origin เป็นพิกัดใน element ผ่าน rotOn() (ใส่พิกัด page แล้ว div แต่ละตัวหมุนคนละจุดจนกรอบไม่ตรงตัวตรายาง — bug 2026-09-28), annotationBBox คืน AABB ของกรอบหมุน (stampRect/rotatedRectAABB/rotatePoint — anchor handle scale เป็นมุมหลังหมุนบนจอ และ k คำนวณใน frame ของกรอบ), clampStampIntoPage เลื่อน position กลับเข้าหน้าตอนตั้งมุม (validator ตรวจ AABB ไม่ติดลบ ไม่ใช่ position ตรง ๆ — position มุมก่อนหมุนติดลบได้เมื่อกรอบจริงยังในหน้า), StampEditor แสดงตรง 0° พิมพ์ง่าย ปิดแล้วกลับไปเอียงค่าเดิม, export ใช้ pdf-lib rotate: degrees(−θ) + เลื่อน anchor ไปมุมล่างซ้ายหลังหมุน (pdf-lib หมุนรอบ anchor ไม่ใช่ center) + per-line baseline หมุนรอบกลางกรอบตรง CSS rotate ทุกองศา (4) TextboxEditor เป็น contentEditable WYSIWYG แทน textarea (ตัวหนาเฉพาะส่วน Ctrl/Cmd+B + Enter = insertLineBreak ไม่แตก div, paste เป็น plain text): utils/textbox-editor.ts ใหม่ — segmentsToHtml (escape & < > ก่อนใส่ dangerouslySetInnerHTML + <b> + <br>) และ serializeEditor (DOM → text รวม + segments แยกตัวหนา: ขึ้นบรรทัดจากทั้ง <br> และ <div>/<p> ที่ browser สร้าง — push \n ก่อนเข้า block ไม่ใช่หลังจบ block ไม่งั้น "abc<div>def</div>" ของ Chrome กลายเป็น "abcdef", dedupe <br>+block ติดกัน, trimEnd ตัดจาก segments ให้ตรง text กัน <br> ปิดท้ายของ browser ทิ้ง \n ค้างใน DB) — แยกเป็น pure DOM util ให้ unit test กับ jsdom ได้ (tests/utils/textbox-editor.test.ts ใหม่ 16 เคส ตาม pattern useCustomSelection-dom) (5) คิว update ต่อ id ใน PDFViewer (updateChainRef + pendingDataBaseRef): update ทุกตัวของ id เดียวกันเข้าคิวเดียวกัน base คำนวณตอนถึงตาคิวจาก data ล่าสุดที่คิวก่อนหน้าบันทึกไว้ — จอสัมผัส blur-commit ข้อความแล้วแตะสี/ขนาดต่อทันที คอมมิต full object จาก base เก่าจะทับค่าที่เพิ่งเปลี่ยน (code review 2026-09-28 รอบสอง) — ตัวควบคุมเล็กของแผง/editor commit เป็น AnnotationPatch ผสานผ่าน applyAnnotationPatch (color/strokeWidth/highlightStyle/highlightOpacity/stampScale/stampRotation/textbox/textboxText/label) (6) describeAnnotationChange: STAMP แสดง "มุมเอียง" และข้าม geometry เมื่อ label/มุมเปลี่ยน (geometry ต่างเป็นผลพลองของการจัดวาง แบบเดียวกับ TEXTBOX ที่ข้ามปรับขนาดเมื่อข้อความเปลี่ยน) (7) แก้ตาม code review 2026-09-29 ครบ: (a) patch stampScale/label ของตรายางเอียงเรียก clampStampIntoPage เหมือน stampRotation — เดิมขยาย/แก้ข้อความที่ชิดขอบแล้ว AABB ยื่นติดลบ update โดน validator ปฏิเสธเด้งกลับทั้งที่บนจอกรอบยังอยู่ในหน้า (b) validator STAMP ผ่อน tolerance ให้ AABB ติดลบได้ ≤2% ของหน้า กลืนความคลาด measure จริงของ browser กับ stampLayoutEstimate ฝั่ง server (ฟอนต์ต่อเครื่องทำให้จำนวนบรรทัด/AABB คลาด — ค่าที่บนจอพอดีเป๊ะต้องไม่ถูกปฏิเสธ) (c) applyUpdate: applyAnnotation เข้า try เดียวกับ action ให้ resolve() ใน finally ครอบทั้ง callback — เดิม dispatcher โยน sync แล้ว promise ไม่ settle ตลอดกาลจนคิวของ id นั้นตายถาวร + enqueueUpdate เปลี่ยน then(cleanup, cleanup) กัน unhandled rejection (d) catch ของ applyUpdate ล้าง pendingDataBaseRef — เดิม action กลางคิวล้มแล้ว update ถัดไปผสาน base ที่ไม่เคยติด DB จน commit การเปลี่ยนที่ล้มไปด้วย และ undo กู้สถานะที่ไม่มีอยู่จริง (e) ถอด as StampData cast 2 จุดใน AnnotationSelectionLayer — ย้าย stampData ที่ narrow ด้วย "label" in ขึ้นใช้แทนให้ type guard ทำงาน (f) export sticky note ของ STAMP ส่ง measure เดียวกับตอนวาดกรอบ (thaiFont.widthOfTextAtSize) ให้มุมเกาะตรงกรอบจริงไม่ใช่ estimate (8) InkLayer eraser ของ stamp ส่ง measureStampText ให้ bbox ตรง render จริง (9) docs sync ครบ: PLAN-FEAT.md D7/F18 + CLAUDE.md + rules/coordinates.md (patch เปลี่ยนขนาดกรอบเอียงต้อง clamp + tolerance validator 2%) + rules/pdf-viewer.md — ผ่าน code review (ไม่มี bug ระดับต้องแก้) + tsc + vitest 193/193 (คำขอผู้ใช้ 2026-09-28/29) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 09:56:00 +07:00
import { measureStampText } from "@/utils/annotation-display";
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;
stamp-editor: ตรายางพิมพ์เอง+จัดบรรทัดแบบถอยหลัง+มุมเอียง, editor กล่องข้อความ contentEditable + แก้ตาม code review 2026-09-29 ครบ 5 ข้อ — (1) STAMP คำพิมพ์เอง (D7): dropdown ของ tool stamp เป็น 2 หน้า — preset + แถว "กำหนดเอง…" (stampCustomMode reset ทุกครั้งที่เปิดเมนู) เปิด popup แก้ข้อความ/สี/ขนาด พร้อม preview ตรายาง live ตามสัดส่วนจริง (ความกว้างหน้าแรก × scale%) ใช้สูตร/ฟอนต์/measure ตัวเดียวกับ render จริง, คำพิมพ์ live ไป stampPreset เดียวกับ preset, Enter/ปุ่ม "ใช้ตรายางนี้" = ปิดเมนูแบบเลือกค่าจบ, ปุ่ม tool แสดง "กำหนดเอง" แทนคำเต็ม + แก้คำตรายางที่วางแล้วผ่านปุ่ม "แก้ไขข้อความ" ในแผง selection (StampEditor textarea ใน TextboxStampLayer ใช้ editingId/commitEdit ร่วมกับ TEXTBOX, จำกัด STAMP_MAX_LABEL_CHARS=50 ที่ UI — validator ยังยอม MAX_TEXT_CHARS ตามเจตนา) (2) จัดบรรทัดแบบถอยหลัง (คำขอผู้ใช้รอบสอง): สูตรย้ายรวมที่ utils/geometry.ts — stampLayout: คำสั้น 1 บรรทัดฟอนต์เต็ม, คำยาวพอดี ๆ ย่อฟอนต์ให้อยู่บรรทัดเดียวก่อน (stampFitFontSize เชิงเส้น) จนถึง floor 10% ของความกว้างกรอบ ("ตรวจสอบแล้ว" ย่อ ~25% = บรรทัดเดียว), ยาวเกิน floor กลับไปฟอนต์ใหญ่แล้ว wrapStampLabel ≤3 บรรทัด (×0.92/รอบ เกินบรรทัดยอมแลกกับฟอนต์อ่านออก), stampBoxHeight ให้ 1 บรรทัด = 0.4w เป๊ะเหมือนเดิม — ทุกมิติเป็นสัดส่วนของ w จำนวนบรรทัดจึงไม่ขึ้นกับ scale — constants + ฟังก์ชัน re-export จาก utils/annotation-display.ts ให้ import เดิมทุกจุด + measureStampText (canvas bold system-ui คืน 0 ตอน SSR) เป็น measure ของ render/preview/hit-test/eraser ทั้งฝั่ง client (annotationBBox รับ optional measure — ให้ความสูงกรอบตรง render จริง แก้ขอบล่างกรอบเกินตรายาง) ส่วนฝั่ง server (validator/export sticky note/describeAnnotationChange) ใช้ stampLayoutEstimate ความกว้างต่ออักษร 0.58 (3) มุมเอียง StampData.rotation? (−180..180 หมุนรอบกลางกรอบ): ช่อง "มุมเอียง" ในแผง selection (patch stampRotation, 0° = ถอด field) + จุดจับหมุนวงกลมเหนือขอบบน (mode rotate — atan2 delta รอบกลางกรอบ + snap ทวีคูณ 15° ≤4°), กรอบ selection+handles หมุนด้วย CSS transform ที่ origin เป็นพิกัดใน element ผ่าน rotOn() (ใส่พิกัด page แล้ว div แต่ละตัวหมุนคนละจุดจนกรอบไม่ตรงตัวตรายาง — bug 2026-09-28), annotationBBox คืน AABB ของกรอบหมุน (stampRect/rotatedRectAABB/rotatePoint — anchor handle scale เป็นมุมหลังหมุนบนจอ และ k คำนวณใน frame ของกรอบ), clampStampIntoPage เลื่อน position กลับเข้าหน้าตอนตั้งมุม (validator ตรวจ AABB ไม่ติดลบ ไม่ใช่ position ตรง ๆ — position มุมก่อนหมุนติดลบได้เมื่อกรอบจริงยังในหน้า), StampEditor แสดงตรง 0° พิมพ์ง่าย ปิดแล้วกลับไปเอียงค่าเดิม, export ใช้ pdf-lib rotate: degrees(−θ) + เลื่อน anchor ไปมุมล่างซ้ายหลังหมุน (pdf-lib หมุนรอบ anchor ไม่ใช่ center) + per-line baseline หมุนรอบกลางกรอบตรง CSS rotate ทุกองศา (4) TextboxEditor เป็น contentEditable WYSIWYG แทน textarea (ตัวหนาเฉพาะส่วน Ctrl/Cmd+B + Enter = insertLineBreak ไม่แตก div, paste เป็น plain text): utils/textbox-editor.ts ใหม่ — segmentsToHtml (escape & < > ก่อนใส่ dangerouslySetInnerHTML + <b> + <br>) และ serializeEditor (DOM → text รวม + segments แยกตัวหนา: ขึ้นบรรทัดจากทั้ง <br> และ <div>/<p> ที่ browser สร้าง — push \n ก่อนเข้า block ไม่ใช่หลังจบ block ไม่งั้น "abc<div>def</div>" ของ Chrome กลายเป็น "abcdef", dedupe <br>+block ติดกัน, trimEnd ตัดจาก segments ให้ตรง text กัน <br> ปิดท้ายของ browser ทิ้ง \n ค้างใน DB) — แยกเป็น pure DOM util ให้ unit test กับ jsdom ได้ (tests/utils/textbox-editor.test.ts ใหม่ 16 เคส ตาม pattern useCustomSelection-dom) (5) คิว update ต่อ id ใน PDFViewer (updateChainRef + pendingDataBaseRef): update ทุกตัวของ id เดียวกันเข้าคิวเดียวกัน base คำนวณตอนถึงตาคิวจาก data ล่าสุดที่คิวก่อนหน้าบันทึกไว้ — จอสัมผัส blur-commit ข้อความแล้วแตะสี/ขนาดต่อทันที คอมมิต full object จาก base เก่าจะทับค่าที่เพิ่งเปลี่ยน (code review 2026-09-28 รอบสอง) — ตัวควบคุมเล็กของแผง/editor commit เป็น AnnotationPatch ผสานผ่าน applyAnnotationPatch (color/strokeWidth/highlightStyle/highlightOpacity/stampScale/stampRotation/textbox/textboxText/label) (6) describeAnnotationChange: STAMP แสดง "มุมเอียง" และข้าม geometry เมื่อ label/มุมเปลี่ยน (geometry ต่างเป็นผลพลองของการจัดวาง แบบเดียวกับ TEXTBOX ที่ข้ามปรับขนาดเมื่อข้อความเปลี่ยน) (7) แก้ตาม code review 2026-09-29 ครบ: (a) patch stampScale/label ของตรายางเอียงเรียก clampStampIntoPage เหมือน stampRotation — เดิมขยาย/แก้ข้อความที่ชิดขอบแล้ว AABB ยื่นติดลบ update โดน validator ปฏิเสธเด้งกลับทั้งที่บนจอกรอบยังอยู่ในหน้า (b) validator STAMP ผ่อน tolerance ให้ AABB ติดลบได้ ≤2% ของหน้า กลืนความคลาด measure จริงของ browser กับ stampLayoutEstimate ฝั่ง server (ฟอนต์ต่อเครื่องทำให้จำนวนบรรทัด/AABB คลาด — ค่าที่บนจอพอดีเป๊ะต้องไม่ถูกปฏิเสธ) (c) applyUpdate: applyAnnotation เข้า try เดียวกับ action ให้ resolve() ใน finally ครอบทั้ง callback — เดิม dispatcher โยน sync แล้ว promise ไม่ settle ตลอดกาลจนคิวของ id นั้นตายถาวร + enqueueUpdate เปลี่ยน then(cleanup, cleanup) กัน unhandled rejection (d) catch ของ applyUpdate ล้าง pendingDataBaseRef — เดิม action กลางคิวล้มแล้ว update ถัดไปผสาน base ที่ไม่เคยติด DB จน commit การเปลี่ยนที่ล้มไปด้วย และ undo กู้สถานะที่ไม่มีอยู่จริง (e) ถอด as StampData cast 2 จุดใน AnnotationSelectionLayer — ย้าย stampData ที่ narrow ด้วย "label" in ขึ้นใช้แทนให้ type guard ทำงาน (f) export sticky note ของ STAMP ส่ง measure เดียวกับตอนวาดกรอบ (thaiFont.widthOfTextAtSize) ให้มุมเกาะตรงกรอบจริงไม่ใช่ estimate (8) InkLayer eraser ของ stamp ส่ง measureStampText ให้ bbox ตรง render จริง (9) docs sync ครบ: PLAN-FEAT.md D7/F18 + CLAUDE.md + rules/coordinates.md (patch เปลี่ยนขนาดกรอบเอียงต้อง clamp + tolerance validator 2%) + rules/pdf-viewer.md — ผ่าน code review (ไม่มี bug ระดับต้องแก้) + tsc + vitest 193/193 (คำขอผู้ใช้ 2026-09-28/29) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 09:56:00 +07:00
const b = annotationBBox({ type: "STAMP", data: s.data }, measured, measureStampText);
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>
);
}