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>
This commit is contained in:
Your Name 2026-09-29 09:56:00 +07:00
parent 45e60330b8
commit 651e354cad
17 changed files with 1699 additions and 332 deletions

View file

@ -13,5 +13,5 @@ paths:
- `mergeRectsByLine(rects)`: จัดกลุ่ม rects ที่ y/height ใกล้กัน (บรรทัดเดียวกัน) → union แกน x → คืน 1 rect ต่อบรรทัด — เรียกทุกครั้ง**ก่อนบันทึก HIGHLIGHT** ลง DB
- InkLayer: SVG **ห้ามมี `viewBox`** (ความหนาเส้นจะบิดเบี้ยวเมื่อ scale แกน x/y ไม่เท่ากัน) — render ระบบ px จริง แปลง % → px ด้วยขนาดจาก `ResizeObserver` แล้ว re-render เมื่อ resize/zoom
- พิกัดจาก event ใช้ `getBoundingClientRect()` ของ page element เป็นฐานเสมอ และ clamp ค่า % ให้อยู่ 0–100
- `utils/geometry.ts` (PLAN-FEAT.md F1) — pure functions บนพิกัด %: `annotationBBox` / `translateAnnotationData` / `scaleAnnotationData` / `hitTestPath` — ก่อนส่ง data ที่แปลงแล้วเข้า `updateAnnotation` ต้อง **clamp offset/scale ให้ bbox ทั้งกลุ่มอยู่ใน 0–100** เพราะ validator ปฏิเสธค่าติดลบ
- `utils/geometry.ts` (PLAN-FEAT.md F1) — pure functions บนพิกัด %: `annotationBBox` / `translateAnnotationData` / `scaleAnnotationData` / `hitTestPath` — ก่อนส่ง data ที่แปลงแล้วเข้า `updateAnnotation` ต้อง **clamp offset/scale ให้ bbox ทั้งกลุ่มอยู่ใน 0–100** เพราะ validator ปฏิเสธค่าติดลบ — **ยกเว้น STAMP เอียง (มี `rotation`): `position` เป็นมุมกรอบ*ก่อนหมุน* ซึ่งไม่ถูกครอบโดยกรอบจริงบนจอ — เกณฑ์ความถูกต้อง (ทั้ง clamp ฝั่ง client และ validator) ใช้ **AABB ของกรอบหมุน** (`stampRect`/`rotatedRectAABB`) ตัวเดียวกันทั้งสองฝั่ง ไม่ใช่ position ตรง ๆ — ใช้คนละเกณฑ์แล้วลากถึงขอบหน้าแล้ว commit โดนปฏิเสธ เด้งกลับ (code review 2026-09-28) · ตอนตั้งมุมใหม่ (patch/ลากหมุน) และตอน patch ที่เปลี่ยนขนาดกรอบของตรายางเอียง (`stampScale`/`label` เมื่อ rotation ≠ 0) ต้องเรียก `clampStampIntoPage` เลื่อน position กลับเข้าหน้าทันที (code review 2026-09-29) · validator ฝั่ง server ยึด `stampLayoutEstimate` ขณะ client clamp ยึด measure จริงของ browser — สองตัววัดนี้คลาดกันตามฟอนต์ของเครื่อง validator จึงผ่อน tolerance ให้ AABB ติดลบได้ไม่เกิน 2% ของหน้า ไม่ใช่ปฏิเสธทันที (code review 2026-09-29)**
- AnnotationSelectionLayer: container `pointer-events: none` เสมอ เฉพาะ handle มุม/ขอบกรอบ/แถบเครื่องมือที่ `auto` — ห้ามให้กรอบ selection บัง textLayer

File diff suppressed because one or more lines are too long

View file

@ -95,7 +95,7 @@ components/DotGrid.tsx ← กริดจุดอันตรกิริย
- Annotation: สร้าง + ลบ + แก้ไข/ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา/แก้ไขข้อความ **ของตัวเอง ในหน้าเดียวกัน** — resolve เป็นสิ่งเดียวที่ใครก็ toggle ได้ (D6)
- ปุ่มลบ/แก้ไข/ประวัติแสดงเฉพาะรายการของตัวเอง (`userId` ตรงกัน) เท่านั้น
- หน้า `/` อ่านรายการจาก DB + ฟอร์มอัปโหลดไฟล์เดียวแบบง่าย (N4 — PDF/jpg/jpeg/png/tif/tiff ≤ 100MB เขียนไฟล์ใหม่ที่ `public/uploads/` เท่านั้น; ไม่มีหน้าจัดการ/ลบไฟล์ใน UI; sample data sync ด้วย `npm run seed-samples`)
- ลายเซ็นจากรูป/upload ยังเป็น out of scope (D7 — stamp เป็น preset ข้อความเท่านั้น, ลายเซ็นใช้ tool DRAW วาด)
- ลายเซ็นจากรูป/upload ยังเป็น out of scope (D7 — stamp เลือก preset **หรือพิมพ์คำเอง** ได้แล้ว 2026-09-28: แถว "กำหนดเอง…" ใน dropdown ของ tool เปิดหน้า popup แก้ข้อความ/สี/ขนาด พร้อม preview ตรายางตามสัดส่วนหน้าจริง + แก้คำตรายางที่วางแล้วผ่านปุ่ม "แก้ไขข้อความ" ในแผง selection — StampEditor WYSIWYG (textarea) · **จัดบรรทัดแบบถอยหลัง** (`stampLayout`/`wrapStampLabel`/`stampBoxHeight` ที่ `utils/geometry.ts` — คำสั้น 1 บรรทัดฟอนต์เต็ม 0.4w เหมือนเดิมเป๊ะ, คำยาวพอดี ๆ ย่อฟอนต์ให้อยู่บรรทัดเดียวก่อนลงได้จนถึง floor 10% ของความกว้างกรอบ ("ตรวจสอบแล้ว" ย่อ ~25% พอ = บรรทัดเดียว — คำขอผู้ใช้ 2026-09-28 รอบสอง), ยาวเกิน floor ค่อย wrap ≤3 บรรทัด กรอบสูงตามจำนวนบรรทัด; ทุกมิติเป็นสัดส่วนของความกว้างกรอบ จำนวนบรรทัดจึงไม่ขึ้นกับ scale ขยาย width ไม่ช่วยเรื่องบรรทัดตก; ใช้ร่วม render/preview/bbox/export ผ่าน measure ของแต่ละฝั่ง); · **มุมเอียง `StampData.rotation?` (องศา −180..180 หมุนรอบกลางกรอบ, 2026-09-28)** — ช่อง "มุมเอียง" ในแผง selection (patch `stampRotation`, 0° = ถอด field) + **จุดจับหมุนวงกลมเหนือขอบบนกรอบ ลากวนรอบกลางกรอบได้ (mode "rotate" — atan2 delta + snap ทวีคูณ 15°)**, render/กรอบ selection+handles หมุนด้วย CSS transform origin กลางกรอบ (**origin เป็นพิกัด*ใน element* ผ่าน `rotOn()` ไม่ใช่พิกัด page**), `annotationBBox` คืน AABB ของกรอบหมุน (`stampRect`/`rotatedRectAABB`/`rotatePoint` ที่ utils/geometry.ts — anchor ของ handle scale เป็นมุมกรอบหลังหมุนบนจอ และ k คำนวณใน frame ของกรอบ), StampEditor แสดงตรง (พิมพ์ง่าย), export ใช้ pdf-lib `rotate: degrees(−θ)` + anchor มุมล่างซ้ายหลังหมุน (pdf-lib หมุนรอบ anchor ไม่ใช่ center); **เกณฑ์ "ไม่ติดลบ" ของ STAMP ทั้ง clamp client และ validator ใช้ AABB ตัวเดียวกัน (position = มุมก่อนหมุน ติดลบได้เมื่อกรอบจริงยังในหน้า — `vPointFinite`) และตอนตั้งมุมใหม่ต้อง `clampStampIntoPage` เลื่อนกลับเข้าหน้าทันที** (code review 2026-09-28); ลายเซ็นใช้ tool DRAW วาด)
## Claude Tooling (ใน `.claude/`)

View file

@ -34,6 +34,7 @@ import {
MAX_BOOKMARK_NOTE_CHARS,
MAX_BOOKMARK_TITLE_CHARS,
} from "@/utils/bookmark-limits";
import { rotatedRectAABB, stampRect } from "@/utils/geometry";
// ─── Input types (client ใช้ตอนเรียก action) ────────────────────────────────
@ -141,6 +142,20 @@ function vPoint(v: unknown, field: string): { x: number; y: number } {
return { x: vPercent(p.x, `${field}.x`), y: vPercent(p.y, `${field}.y`) };
}
/** vPoint แบบไม่บังคับ ≥ 0 — ใช้กับ STAMP ที่หมุน: position เป็นมุมกรอบ*ก่อนหมุน*
* ซึ่งติดลบ/เกิน 100 ได้โดยกรอบจริง (AABB ของกรอบหมุน) ยังอยู่ในหน้าครบ — เกณฑ์
* ความถูกต้องของพิกัดอยู่ที่ AABB ซึ่งผู้เรียกตรวจเอง (code review 2026-09-28) */
function vPointFinite(v: unknown, field: string): { x: number; y: number } {
if (typeof v !== "object" || v === null) fieldError(field, "เป็น { x, y }");
const p = v as Record<string, unknown>;
for (const axis of ["x", "y"] as const) {
if (typeof p[axis] !== "number" || !Number.isFinite(p[axis])) {
fieldError(`${field}.${axis}`, "เป็นตัวเลข");
}
}
return { x: p.x as number, y: p.y as number };
}
function vRect(v: unknown, field: string): Rect {
if (typeof v !== "object" || v === null) fieldError(field, "เป็น { x, y, width, height }");
const r = v as Record<string, unknown>;
@ -338,7 +353,9 @@ function validateAnnotationData(type: AnnotationType, v: unknown): AnnotationDat
}
case "STAMP": {
const label = vString(d.label, "data.label", MAX_TEXT_CHARS);
const position = vPoint(d.position, "data.position");
// position = มุมกรอบก่อนหมุน — ติดลบได้เมื่อตรายางเอียง (ดู vPointFinite)
// เกณฑ์ "ไม่ติดลบ" ตรวจที่ AABB ของตรายางด้านล่างแทน position ตรง ๆ
const position = vPointFinite(d.position, "data.position");
// scale เป็น % ของความกว้างหน้า — ต้องเป็นบวกจึงมีความหมาย
if (
typeof d.scale !== "number" ||
@ -350,12 +367,41 @@ function validateAnnotationData(type: AnnotationType, v: unknown): AnnotationDat
// สีตรายางเป็น optional — ไม่มี = STAMP_COLOR (แถวเดิม)
const color = d.color !== undefined ? vString(d.color, "data.color") : undefined;
const note = vNote(d.note);
// มุมเอียง (องศา −180..180 หมุนรอบกลางกรอบ) optional — ไม่มี = ไม่เอียง
if (
d.rotation !== undefined &&
(typeof d.rotation !== "number" ||
!Number.isFinite(d.rotation) ||
d.rotation < -180 ||
d.rotation > 180)
) {
fieldError("data.rotation", "เป็นตัวเลข −180 ถึง 180");
}
// เกณฑ์พิกัดไม่ติดลบของ STAMP = AABB ของกรอบ (หมุนรอบกลางกรอบแล้ว): ตรายาง
// เอียงชิดขอบ มุมก่อนหมุนหลุดนอกหน้าได้โดยตัวตรายางจริงยังอยู่ในหน้าครบ —
// ตรวจ position ตรง ๆ แล้ว client clamp (ยึด AABB) ขัดกับ validator จนลาก
// ถึงขอบแล้วเด้งกลับ (code review 2026-09-28); ไม่มี rotation → AABB = rect
// เดิม เกณฑ์เดิมเป๊ะ — คำนวณด้วย estimate (pure ฝั่ง server ไม่มี DOM)
const stampAabb = rotatedRectAABB(
stampRect(
{ label, position, scale: d.scale, rotation: d.rotation ?? 0 },
{ width: 100, height: 100 },
),
);
// tolerance 2% ของหน้า: clamp ฝั่ง client ยึด measure จริงของ browser
// (measureStampText) ขณะที่นี่ยึด stampLayoutEstimate — ฟอนต์ต่อเครื่องทำให้
// จำนวนบรรทัด/AABB คลาดกันนิดหน่อย ค่าที่บนจอพอดีเป๊ะต้องไม่ถูกปฏิเสธเด้งกลับ
// (code review 2026-09-29) — ยอมให้ AABB ยื่นเกินขอบนิดหน่อยแลกกับไม่เด้ง
if (stampAabb.x < -2 || stampAabb.y < -2) {
fieldError("data.position", "เป็นพิกัดที่ตรายางยังอยู่ในหน้า (ไม่ติดลบ)");
}
return {
label,
position,
scale: d.scale,
...(color ? { color } : {}),
...(note !== undefined ? { note } : {}),
...(d.rotation !== undefined ? { rotation: d.rotation } : {}),
} satisfies StampData;
}
case "SHAPE": {

View file

@ -8,6 +8,7 @@ import {
PDFName,
PDFString,
LineCapStyle,
degrees,
rgb,
type PDFFont,
type PDFDict,
@ -51,15 +52,16 @@ import { toPx, type Point, type Size } from "@/utils/coordinates";
import { fileKindFromUrl } from "@/utils/file-kind";
import { imageDocFromBytes } from "@/utils/image-to-pdf";
import {
STAMP_HEIGHT_RATIO,
annotationBBox,
rotatePoint,
stampBoxHeight,
stampLayout,
textboxMaxWidthPct,
} from "@/utils/geometry";
import {
STAMP_COLOR,
annotationNote,
effectiveDrawOpacity,
stampFitFontSize,
} from "@/utils/annotation-display";
// Export PDF ฝัง annotation + bookmarks (F8/C1 — PLAN-FEAT.md §3)
@ -596,42 +598,65 @@ export async function GET(req: Request) {
const { r, g, b } = hexToRgb01(d.color ?? STAMP_COLOR);
const stampRgb = rgb(r, g, b);
const w = px(d.scale, display.width);
// จัดวางข้อความแบบเดียวกับบนจอ (stampLayout): คำสั้น 1 บรรทัด, คำยาวพอดี ๆ
// ย่อฟอนต์ให้บรรทัดเดียวก่อน (จนถึง floor 10%), ยาวเกินค่อย wrap หลายบรรทัด
// กรอบสูงขึ้นตาม — measure ของฝั่งนี้คือ pdf-lib font (2026-09-28)
const lay = thaiFont
? stampLayout(d.label, w, (s, size) => thaiFont.widthOfTextAtSize(s, size))
: null;
const rect = displayRectToPdf(
{
x: px(d.position.x, display.width),
y: px(d.position.y, display.height),
width: w,
height: w * STAMP_HEIGHT_RATIO,
height: lay ? stampBoxHeight(lay, w) : w * 0.4,
},
size,
rotation,
);
// มุมเอียงของตรายาง: จอหมุนรอบ**กลางกรอบ** ส่วน pdf-lib หมุนรอบ anchor มุม
// ล่างซ้ายของ object — จึงต้อง (1) ใช้มุมลบ (แกน y ของ PDF ชี้ขึ้น ส่วน CSS
// rotate วัดบนจอที่ y ชี้ลง) และ (2) เลื่อน anchor ไปตำแหน่งมุมล่างซ้าย*หลัง
// หมุน* ให้ pdf-lib หมุนต่อรอบมัน ผลลัพธ์กลับมาตรงตำแหน่งเดียวกับบนจอ
const rot = d.rotation ?? 0;
const pdfRot = -rot;
const ctr = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
const anchor = rot
? rotatePoint({ x: rect.x, y: rect.y }, ctr, pdfRot)
: { x: rect.x, y: rect.y };
const rotateOpt = rot ? { rotate: degrees(pdfRot) } : undefined;
page.drawRectangle({
x: rect.x,
y: rect.y,
x: anchor.x,
y: anchor.y,
width: rect.width,
height: rect.height,
borderColor: stampRgb,
borderWidth: Math.max(w * 0.02, 1),
...rotateOpt,
});
if (thaiFont) {
if (lay && thaiFont) {
const font = thaiFont;
// ย่อฟอนต์ให้ label อยู่บรรทัดเดียวพอดีกรอบ — เหมือนที่ render บนจอ
// (ไม่ย่อ label ยาวอย่าง "ตรวจสอบแล้ว" จะ wrap 2 บรรทัดแล้วล้นกรอบ)
const fontSize = stampFitFontSize(
d.label,
w,
(s, size) => font.widthOfTextAtSize(s, size),
);
const textW = font.widthOfTextAtSize(d.label, fontSize);
const lineH = fontSize * 1.2; // กึ่งกลางแนวตั้งตาม lineHeight บนจอ
page.drawText(d.label, {
x: rect.x + (rect.width - textW) / 2,
y: rect.y + (rect.height - lineH) / 2 + lineH - fontSize,
size: fontSize,
// วาดทีละบรรทัดจากกึ่งกลางแนวตั้งของกรอบ (pdf y ชี้ขึ้นจากล่าง) — จุดวาด
// คำนวณในพิกัดกรอบ*ก่อนหมุน* แล้วหมุนรอบกลางกรอบเหมือนจอก่อนส่ง drawText
const lineStep = lay.fontSize * lay.lineHeight;
const totalH = lay.lines.length * lineStep;
let baseline = rect.y + rect.height / 2 + totalH / 2 - lay.fontSize;
for (const ln of lay.lines) {
const textW = font.widthOfTextAtSize(ln, lay.fontSize);
const tx = rect.x + (rect.width - textW) / 2;
const p = rot
? rotatePoint({ x: tx, y: baseline }, ctr, pdfRot)
: { x: tx, y: baseline };
page.drawText(ln, {
x: p.x,
y: p.y,
size: lay.fontSize,
font,
color: stampRgb,
...rotateOpt,
});
baseline -= lineStep;
}
}
break;
}
@ -642,8 +667,15 @@ export async function GET(req: Request) {
if (a.type !== "COMMENT") {
const note = annotationNote(a);
if (note) {
// annotationBBox คืน pt ของ "หน้าบนจอ" อยู่แล้ว — แปลงตรงเข้าพิกัด PDF
const bbox = annotationBBox(a, display);
// annotationBBox คืน pt ของ "หน้าบนจอ" อยู่แล้ว — แปลงตรงเข้าพิกัด PDF — ส่ง
// measure ของ pdf-lib font เดียวกับตอนวาดกรอบ STAMP ด้วย ให้มุมเกาะ sticky
// note ตรงกรอบที่วาดจริง (ไม่ส่ง = stampLayoutEstimate คลาดจากกรอบจริงเล็กน้อย
// — code review 2026-09-29)
const bbox = annotationBBox(
a,
display,
thaiFont ? (s, size) => thaiFont.widthOfTextAtSize(s, size) : undefined,
);
attachNoteSticky(
doc,
page,

View file

@ -6,7 +6,15 @@ import {
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import { AlignCenter, AlignLeft, AlignRight, BookmarkPlus, Pencil, Trash2 } from "lucide-react";
import {
AlignCenter,
AlignLeft,
AlignRight,
BookmarkPlus,
Pencil,
RotateCw,
Trash2,
} from "lucide-react";
import type {
AnnotationData,
@ -15,15 +23,19 @@ import type {
HighlightData,
HighlightStyle,
TextboxData,
TextboxSegment,
} from "@/db/schema";
import {
COMMENT_PIN_COARSE_SIZE_PX,
COMMENT_PIN_SIZE_PX,
TEXTBOX_MAX_WIDTH_PCT,
annotationBBox,
clampStampIntoPage,
commentPinBoxPct,
commentPinBoxPx,
rotatePoint,
scaleAnnotationData,
stampRect,
translateAnnotationData,
} from "@/utils/geometry";
import {
@ -33,9 +45,40 @@ import {
type Rect,
type Size,
} from "@/utils/coordinates";
import { annotationTypeLabel, STAMP_COLOR } from "@/utils/annotation-display";
import {
annotationTypeLabel,
measureStampText,
STAMP_COLOR,
} from "@/utils/annotation-display";
import { ColorSwatches, HIGHLIGHT_STYLE_BTNS, NumberCombo, PanelRow } from "./ToolControls";
/** Patch ของตัวควบคุมเล็กในแผง selection / editor ข้อความ — ส่งเฉพาะ field ที่แตะ
* ให้ viewer ผสานเข้ากับ data ล่าสุด**ตอนถึงคิว update** ไม่ใช่ snapshot ของ render
* นี้ — คอมมิตแบบ full object จาก base เก่าจะทับค่าที่เพิ่งเปลี่ยน (จอสัมผัส:
* blur-commit ข้อความใน editor แล้วแตะสี/ขนาดในแผงต่อทันทีก่อน React render
* optimistic row — code review 2026-09-28 รอบสอง) */
export type AnnotationPatch =
/** สี — ชื่อ field ตามชนิด: HIGHLIGHT/STAMP = `color`, DRAW/SHAPE = `strokeColor` */
| { kind: "color"; color: string }
/** ความหนา — DRAW สเกล `widths` ต่อจุดตามอัตราส่วนตอนผสาน (เหมือนเดิม) */
| { kind: "strokeWidth"; strokeWidth: number }
| { kind: "highlightStyle"; style: HighlightStyle }
/** ความทึบของ HIGHLIGHT — 100 = ถอด field `opacity` ทิ้งเหมือนตอนสร้างใหม่ */
| { kind: "highlightOpacity"; opacityPct: number }
| { kind: "stampScale"; scale: number }
/** STAMP — มุมเอียง (องศา −180..180 หมุนรอบกลางกรอบ; 0 = ถอด field เหมือนแถวเดิม) */
| { kind: "stampRotation"; rotation: number }
| {
kind: "textbox";
fields: Partial<
Pick<TextboxData, "color" | "fontSize" | "opacity" | "bold" | "align">
>;
}
/** StampEditor — แก้คำตรายาง */
| { kind: "label"; label: string }
/** TextboxEditor — แก้ข้อความกล่องข้อความ (segments null = เคลียร์ตัวหนาทั้งก้อน) */
| { kind: "textboxText"; text: string; segments: TextboxSegment[] | null };
/** UX audit 2026-09-16: focus ring คีย์บอร์ด — ค่าเดียวกับ FOCUS_RING_CLS ของ
* PDFViewer/AnnotationSidebar (ประกาศแยกที่นี่กัน circular import) */
const FOCUS_RING_CLS =
@ -50,6 +93,8 @@ const FOCUS_RING_CLS =
const HANDLE_PX = 10; // ขนาดมุมจับ (scale)
const STRIP_PX = 8; // ความหนาขอบกรอบ (translate)
const ROT_HANDLE_PX = 18; // จุดจับหมุนตรายาง (STAMP) — วงกลมเหนือกึ่งกลางขอบบน
const ROT_HANDLE_GAP_PX = 20; // ระยะแนวฉากจากขอบบนกรอบถึงจุดจับหมุน
const MIN_BOX_PCT = 0.5; // bbox เล็กสุด (% ของหน้า) กันกรอบหาย
const EPS = 1e-6;
@ -58,15 +103,19 @@ const EPS = 1e-6;
type DragState = {
// wleft/wright = TEXTBOX กว้างคงที่: ลากขอบซ้าย/ขวาปรับความกว้าง (ฟอนต์คงขนาด)
mode: "move" | "scale" | "wleft" | "wright";
mode: "move" | "scale" | "wleft" | "wright" | "rotate";
/** ตำแหน่ง pointer ตอนเริ่มลาก (% ของหน้า) */
startPct: Point;
/** data ก่อนเริ่มลาก — ต้นทางของการคำนวณทุก move (ไม่สะสมจาก preview) */
origData: AnnotationData;
/** bbox ก่อนเริ่มลาก (% ของหน้า) */
/** bbox ก่อนเริ่มลาก (% ของหน้า) — STAMP เอียง = AABB ของกรอบหมุนแล้ว (ครอบกรอบจริง) */
origBoxPct: Rect;
/** mode "scale" เท่านั้น — มุมตรงข้ามของ handle (% ของหน้า) */
/** mode "scale" เท่านั้น — มุมตรงข้ามของ handle (% ของหน้า ตำแหน่งจริงบนจอ) */
anchorPct: Point;
/** STAMP เอียงเท่านั้น — มุมกรอบตอนเริ่มลาก (องศา): คำนวณ k ของ scale ต้อง
* แปลง pointer เข้า frame ของกรอบ (ย้อนหมุนรอบ center) ก่อน ไม่งั้นลากมุมของ
* กรอบเอียงแล้วสัดส่วนแกน x/y ผิดจากสายตา */
rot?: number;
};
/** จำกัดตัวคูณ scale แกนเดียว ให้กรอบผลลัพธ์ยังอยู่ใน 0–100 และไม่เล็กกว่าขั้นต่ำ */
@ -103,6 +152,7 @@ export default function AnnotationSelectionLayer({
onDelete,
onBookmark,
onStartEdit,
onPatch,
}: {
annotation: AnnotationDTO;
/** ขนาด px ของหน้า (จาก width/height ที่คำนวณจาก scale) */
@ -124,6 +174,10 @@ export default function AnnotationSelectionLayer({
/** M1: ปุ่ม "แก้ไขข้อความ" ของ TEXTBOX — เข้าโหมดแก้ไขโดยไม่ต้อง double-click
* (จอสัมผัสไม่มี dblclick ที่เชื่อถือได้: iOS double-tap = zoom หน้า) */
onStartEdit?: (id: string) => void;
/** ตัวควบคุมเล็กของแผง (สี/ความหนา/ขนาด/รูปแบบ/ทึบ/patch กล่องข้อความ) commit เป็น
* patch — viewer ผสานกับ data ล่าสุดตอนถึงคิว update กัน stale base ทับค่าที่เพิ่ง
* เปลี่ยน (ดู AnnotationPatch) */
onPatch: (id: string, patch: AnnotationPatch) => void;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null);
@ -143,12 +197,44 @@ export default function AnnotationSelectionLayer({
// ต้องเห็น pin ตามที่ render ไม่ใช่ COMMENT_PIN_BOX_PCT ประมาณ (audit 2026-09-24)
const isCommentPin = annotation.type === "COMMENT";
const pinPx = coarseTouch ? COMMENT_PIN_COARSE_SIZE_PX : COMMENT_PIN_SIZE_PX;
// bbox ของ STAMP ส่ง measureStampText ให้ความสูงตรงกับที่ StampItem render จริง
// (ประมาณแล้วขอบล่างกรอบเกินตรายาง — bug 2026-09-28)
const boxPx = isCommentPin
? commentPinBoxPx(annotation.data as CommentData, size, pinPx)
: annotationBBox(annotation, size);
: annotationBBox(annotation, size, measureStampText);
const boxPct = isCommentPin
? commentPinBoxPct(annotation.data as CommentData, size, pinPx)
: annotationBBox(annotation, { width: 100, height: 100 });
: annotationBBox(annotation, { width: 100, height: 100 }, measureStampText);
// STAMP เอียง (rotation ≠ 0) — กรอบ+handles วาดที่ตำแหน่งกรอบ*ก่อนหมุน* แล้วหมุน
// ทั้งชุดด้วย CSS transform รอบกลางกรอบ (ตรงกับที่ StampItem render) ส่วน boxPx/
// boxPct ด้านบนเป็น AABB ของกรอบหมุนแล้ว ใช้กับ clamp ย้าย/แผงเครื่องมือ/ป้ายชื่อ
// stampData narrow ด้วย "label" in ตรงนี้แล้วส่งเข้า stampRect ตรง ๆ — ไม่ as-cast
// (cast พังเงียบถ้าเงื่อนไขแวดข้างเปลี่ยน — code review 2026-09-29)
const stampData = "label" in annotation.data ? annotation.data : null;
const stampRot = stampData?.rotation ?? 0;
const isRotStamp = stampRot !== 0;
const framePx =
stampData && isRotStamp ? stampRect(stampData, size, measureStampText) : boxPx;
const framePct =
stampData && isRotStamp
? stampRect(stampData, { width: 100, height: 100 }, measureStampText)
: boxPct;
// transform หมุนทั้งชุดกรอบ — origin เป็น**กลางกรอบในพิกัดของ element นั้น ๆ** (CSS
// transform-origin วัดจากมุมซ้ายบนของ element เอง ไม่ใช่ของ page — จุดกลางกรอบ
// ในพิกัด page ลบด้วย left/top ของ element จึงได้ origin ที่ทุก div หมุนไปตรงจุด
// เดียวกัน ตามตัวตรายาง — bug "กรอบไม่ตรงตัวเมื่อเอียง" 2026-09-28)
const rotC = {
x: framePx.x + framePx.width / 2,
y: framePx.y + framePx.height / 2,
};
const rotOn = (elLeft: number, elTop: number) =>
isRotStamp
? {
transform: `rotate(${stampRot}deg)`,
transformOrigin: `${rotC.x - elLeft}px ${rotC.y - elTop}px`,
}
: undefined;
// FIX-9: เอา non-null ! ออก — handler ยิงได้แค่ตอน DOM mount แล้ว
// ref จึงไม่ควรเป็น null (คืน 0,0 เผื่อไว้เฉย ๆ ให้ type ชัด)
@ -160,7 +246,7 @@ export default function AnnotationSelectionLayer({
function beginDrag(
e: ReactPointerEvent<HTMLElement>,
mode: "move" | "scale" | "wleft" | "wright",
mode: "move" | "scale" | "wleft" | "wright" | "rotate",
anchorPct: Point,
) {
if (!editable || e.button !== 0) return;
@ -173,6 +259,7 @@ export default function AnnotationSelectionLayer({
origData: annotation.data,
origBoxPct: boxPct,
anchorPct,
...(stampRot !== 0 ? { rot: stampRot } : null),
};
movedRef.current = false;
latestRef.current = null;
@ -184,7 +271,30 @@ export default function AnnotationSelectionLayer({
e.stopPropagation();
const cur = pointerPct(e);
let next: AnnotationData;
if (st.mode === "move") {
if (st.mode === "rotate") {
// STAMP — ลากหมุน: มุมใหม่ = มุมเดิม + มุมที่ pointer กวาดรอบ**กลางกรอบ**
// (atan2 ในพิกัดจอ y ลง = ทิศเดียวกับ CSS rotate) — snap ดูดเข้าทวีคูณ 15°
// เมื่อใกล้ ≤4° ให้ตั้ง 0/±45/±90 เร็ว แล้ว wrap เข้า (−180, 180] เก็บ 1 ทศนิยม
// (0° ถอด field เหมือน patch ของแผง — anchorPct = กลางกรอบจาก AABB)
const ctr = st.anchorPct;
const a0 = Math.atan2(st.startPct.y - ctr.y, st.startPct.x - ctr.x);
const a1 = Math.atan2(cur.y - ctr.y, cur.x - ctr.x);
let deg = (st.rot ?? 0) + ((a1 - a0) * 180) / Math.PI;
const snap = Math.round(deg / 15) * 15;
if (Math.abs(deg - snap) <= 4) deg = snap;
deg = Number(((((deg + 180) % 360) + 360) % 360 - 180).toFixed(1));
if ("label" in st.origData) {
const { rotation: _prev, ...rest } = st.origData;
// หมุนจนกรอบยื่นเกินขอบหน้า = เลื่อนกลับเข้าหน้าทันทีระหว่างลาก (เดียวกับ
// patch ของแผง — validator ตรวจ AABB ไม่ติดลบ, code review 2026-09-28)
next =
deg === 0
? rest
: { ...rest, rotation: deg, position: clampStampIntoPage({ ...rest, rotation: deg }) };
} else {
next = st.origData;
}
} else if (st.mode === "move") {
// clamp offset ให้ bbox ทั้งกลุ่มยังอยู่ในหน้า (พิกัด % ติดลบโดน action ปฏิเสธ)
const dx = clamp(
cur.x - st.startPct.x,
@ -228,12 +338,29 @@ export default function AnnotationSelectionLayer({
next = { ...d, width: w, position: { ...d.position, x: left } };
}
} else if ("scale" in st.origData) {
// STAMP — สัดส่วนกว้าง:สูงตายตัว (สูง = STAMP_HEIGHT_RATIO × กว้าง) แยกสเกล
// สองแกนไม่ได้ จึงใช้ตัวคูณ uniform เดียว: เฉลี่ยอัตราส่วนของ pointer สองแกน
// STAMP — ความกว้างมีมาจาก scale เพียงตัวเดียว (สูงตามจำนวนบรรทัดของ label,
// stampLayout) จึงขยายด้วยตัวคูณ uniform เดียว: เฉลี่ยอัตราส่วนของ pointer สองแกน
// แล้ว clamp รายแกนซ้อนกัน (x แล้ว y) ให้กรอบผลลัพธ์ยังอยู่ในหน้าทั้งสองแกนพร้อมกัน
// · STAMP เอียง: อัตราส่วนต้องวัดในแกนของ*กรอบ* — ย้อนหมุน pointer รอบกลางกรอบ
// ก่อน (แกน x/y ของสูตรคือแกนกรอบ ไม่ใช่แกนจอ) ส่วน clamp กับ scaleAnnotationData
// ใช้พิกัดจอ: มุมกรอบหมุนทั้งชุดโดย scale-uniform รอบ anchor จริง ๆ (AABB ของ
// กรอบหมุน scale ตาม k รอบ anchor เช่นกัน) clamp ด้วยขอบ AABB จึงยังถูกต้อง
const rot = st.rot ?? 0;
let curL = cur;
let startL = st.startPct;
let anchorL = st.anchorPct;
if (rot) {
const ctr = {
x: st.origBoxPct.x + st.origBoxPct.width / 2,
y: st.origBoxPct.y + st.origBoxPct.height / 2,
};
curL = rotatePoint(cur, ctr, -rot);
startL = rotatePoint(st.startPct, ctr, -rot);
anchorL = rotatePoint(st.anchorPct, ctr, -rot);
}
const rawK =
((cur.x - st.anchorPct.x) / (st.startPct.x - st.anchorPct.x || EPS) +
(cur.y - st.anchorPct.y) / (st.startPct.y - st.anchorPct.y || EPS)) /
((curL.x - anchorL.x) / (startL.x - anchorL.x || EPS) +
(curL.y - anchorL.y) / (startL.y - anchorL.y || EPS)) /
2;
const k = clampAxisScale(
clampAxisScale(
@ -290,54 +417,34 @@ export default function AnnotationSelectionLayer({
onPreview(null);
}
/** ปุ่มสี/ความหนาบนกรอบ — ใช้ onCommit เดียวกับการลาก */
/** ปุ่มสี/ความหนาบนกรอบ — ส่ง patch ให้ viewer ผสานกับ data ล่าสุด (ชื่อ field ตาม
* ชนิด: HIGHLIGHT/STAMP = color, DRAW/SHAPE = strokeColor — ตัดสินตอนผสาน) */
function commitColor(color: string) {
const d = annotation.data;
if ("rects" in d) onCommit(annotation.id, { ...d, color });
else if ("paths" in d || "shape" in d) onCommit(annotation.id, { ...d, strokeColor: color });
// STAMP ("label") — แก้สีหลังสร้างได้ (TEXTBOX แก้ผ่านการพิมพ์ inline แทน)
else if ("label" in d) onCommit(annotation.id, { ...d, color });
onPatch(annotation.id, { kind: "color", color });
}
function commitStrokeWidth(w: number) {
const d = annotation.data;
if ("paths" in d) {
// F7: เส้นปากกามี widths ต่อจุด — scale ตามอัตราส่วนพร้อม strokeWidth หลัก (PLAN-FEAT.md §5)
onCommit(annotation.id, {
...d,
strokeWidth: w,
...(d.widths
? { widths: d.widths.map((pw) => (pw / d.strokeWidth) * w) }
: {}),
});
} else if ("shape" in d) {
onCommit(annotation.id, { ...d, strokeWidth: w });
}
onPatch(annotation.id, { kind: "strokeWidth", strokeWidth: w });
}
/** HIGHLIGHT — เปลี่ยนรูปแบบมาร์กหลังสร้าง (ไฮไลต์/เส้นใต้/ขีดฆ่า/ขยุกขยิก) */
function commitHighlightStyle(style: HighlightStyle) {
const d = annotation.data;
if (!("rects" in d)) return;
onCommit(annotation.id, { ...d, style });
onPatch(annotation.id, { kind: "highlightStyle", style });
}
/** HIGHLIGHT — ปรับความทึบหลังสร้าง (100% = ทึบเต็ม = ไม่เก็บ field เหมือนตอนสร้างใหม่) */
/** HIGHLIGHT — ปรับความทึบหลังสร้าง (100% = ทึบเต็ม = ถอด field opacity ตอนผสาน) */
function commitHighlightOpacity(opacityPct: number) {
const d = annotation.data;
if (!("rects" in d)) return;
const { opacity: _prev, ...rest } = d;
onCommit(annotation.id, {
...rest,
...(opacityPct < 100 ? { opacity: opacityPct / 100 } : {}),
});
onPatch(annotation.id, { kind: "highlightOpacity", opacityPct });
}
/** STAMP — ปรับขนาดตรายางหลังสร้าง */
function commitStampScale(scale: number) {
const d = annotation.data;
if (!("label" in d)) return;
onCommit(annotation.id, { ...d, scale });
onPatch(annotation.id, { kind: "stampScale", scale });
}
/** STAMP — ปรับมุมเอียงหลังสร้าง (องศา −180..180 หมุนรอบกลางกรอบ) */
function commitStampRotation(rotation: number) {
onPatch(annotation.id, { kind: "stampRotation", rotation });
}
/** TEXTBOX — แก้ค่าทั้งกล่องหลังสร้าง (สี/ขนาด/ทึบ/หนา/จัดแนว) เหมือน dropdown ของ tool */
@ -345,7 +452,7 @@ export default function AnnotationSelectionLayer({
patch: Partial<Pick<TextboxData, "color" | "fontSize" | "opacity" | "bold" | "align">>,
) {
if (!textboxData) return;
onCommit(annotation.id, { ...textboxData, ...patch });
onPatch(annotation.id, { kind: "textbox", fields: patch });
}
// ตัวควบคุมในแผงตรวจตามชนิดด้วยการเช็ค field ใน data ตรง ๆ (rects = HIGHLIGHT,
@ -356,8 +463,10 @@ export default function AnnotationSelectionLayer({
const highlightData: HighlightData | null =
"rects" in annotation.data ? annotation.data : null;
const strokeData =
"paths" in annotation.data || "shape" in annotation.data ? annotation.data : null;
const stampData = "label" in annotation.data ? annotation.data : null;
"paths" in annotation.data || "shape" in annotation.data
? annotation.data
: null;
// (stampData ย้ายไปขึ้นบน — ใช้ร่วมกับ stampRot/framePx ของตรายางเอียง)
const textboxData: TextboxData | null =
"fontSize" in annotation.data ? annotation.data : null;
// TEXTBOX กว้างคงที่ (มี width) — ขอบซ้าย/ขวาเป็น handle ปรับความกว้างแทนการย้าย
@ -368,36 +477,48 @@ export default function AnnotationSelectionLayer({
// กรณีหลังหุบเป็น w-fit ไม่คั่นเส้น และปุ่มติดกันแทน justify-between
const hasStyleControls = !!(highlightData || strokeData || stampData || textboxData);
// มุมทั้ง 4 — ตำแหน่งวางเป็น px, anchor มุมตรงข้ามเป็น % ตรง ๆ จาก boxPct
// (พิกัด % คำนวณผ่าน utils/coordinates เท่านั้น — invariant #2)
// มุมทั้ง 4 — ตำแหน่งวางเป็น px ของกรอบ*ก่อนหมุน* (STAMP เอียง: div ทั้งชุดถูกหมุน
// ทีหลังผ่าน rotStyle), anchor มุมตรงข้ามเป็น % **ตำแหน่งจริงบนจอ** — STAMP เอียง
// ต้องหมุนมุมกลับออกมาก่อน (พิกัด % คำนวณผ่าน utils/coordinates เท่านั้น — invariant #2)
const frameCtrPct = isRotStamp
? {
x: framePct.x + framePct.width / 2,
y: framePct.y + framePct.height / 2,
}
: null;
const anchorOnScreen = (p: Point): Point =>
frameCtrPct ? rotatePoint(p, frameCtrPct, stampRot) : p;
const corners: { key: string; x: number; y: number; cursor: string; anchor: Point }[] = [
{
key: "nw",
x: boxPx.x,
y: boxPx.y,
x: framePx.x,
y: framePx.y,
cursor: "nwse-resize",
anchor: { x: boxPct.x + boxPct.width, y: boxPct.y + boxPct.height },
anchor: anchorOnScreen({
x: framePct.x + framePct.width,
y: framePct.y + framePct.height,
}),
},
{
key: "ne",
x: boxPx.x + boxPx.width,
y: boxPx.y,
x: framePx.x + framePx.width,
y: framePx.y,
cursor: "nesw-resize",
anchor: { x: boxPct.x, y: boxPct.y + boxPct.height },
anchor: anchorOnScreen({ x: framePct.x, y: framePct.y + framePct.height }),
},
{
key: "sw",
x: boxPx.x,
y: boxPx.y + boxPx.height,
x: framePx.x,
y: framePx.y + framePx.height,
cursor: "nesw-resize",
anchor: { x: boxPct.x + boxPct.width, y: boxPct.y },
anchor: anchorOnScreen({ x: framePct.x + framePct.width, y: framePct.y }),
},
{
key: "se",
x: boxPx.x + boxPx.width,
y: boxPx.y + boxPx.height,
x: framePx.x + framePx.width,
y: framePx.y + framePx.height,
cursor: "nwse-resize",
anchor: { x: boxPct.x, y: boxPct.y },
anchor: anchorOnScreen({ x: framePct.x, y: framePct.y }),
},
];
@ -427,14 +548,16 @@ export default function AnnotationSelectionLayer({
style={{ pointerEvents: "none" }}
onClick={(e) => e.stopPropagation()} // คลิกบน handle/toolbar ไม่ให้ bubble ไป deselect ที่ page
>
{/* กรอบ bbox — ตัวกรอบเอง pointer-events none ผ่านไป textLayer ได้ */}
{/* กรอบ bbox — ตัวกรอบเอง pointer-events none ผ่านไป textLayer ได้ · STAMP เอียง
วาดที่กรอบก่อนหมุนแล้วหมุนทั้งชุด (กรอบ+handles) รอบกลางกรอบเหมือน StampItem */}
<div
className="absolute border-2 border-dashed border-primary"
style={{
left: boxPx.x,
top: boxPx.y,
width: boxPx.width,
height: boxPx.height,
left: framePx.x,
top: framePx.y,
width: framePx.width,
height: framePx.height,
...rotOn(framePx.x, framePx.y),
}}
/>
@ -447,11 +570,12 @@ export default function AnnotationSelectionLayer({
<div
className="absolute cursor-move touch-none sel-strip"
style={{
left: boxPx.x - STRIP_PX / 2,
top: boxPx.y - STRIP_PX / 2,
width: boxPx.width + STRIP_PX,
left: framePx.x - STRIP_PX / 2,
top: framePx.y - STRIP_PX / 2,
width: framePx.width + STRIP_PX,
height: STRIP_PX,
pointerEvents: "auto",
...rotOn(framePx.x - STRIP_PX / 2, framePx.y - STRIP_PX / 2),
}}
onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })}
onPointerMove={moveDrag}
@ -461,11 +585,12 @@ export default function AnnotationSelectionLayer({
<div
className="absolute cursor-move touch-none sel-strip"
style={{
left: boxPx.x - STRIP_PX / 2,
top: boxPx.y + boxPx.height - STRIP_PX / 2,
width: boxPx.width + STRIP_PX,
left: framePx.x - STRIP_PX / 2,
top: framePx.y + framePx.height - STRIP_PX / 2,
width: framePx.width + STRIP_PX,
height: STRIP_PX,
pointerEvents: "auto",
...rotOn(framePx.x - STRIP_PX / 2, framePx.y + framePx.height - STRIP_PX / 2),
}}
onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })}
onPointerMove={moveDrag}
@ -475,11 +600,12 @@ export default function AnnotationSelectionLayer({
<div
className={`absolute touch-none sel-strip ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
style={{
left: boxPx.x - STRIP_PX / 2,
top: boxPx.y,
left: framePx.x - STRIP_PX / 2,
top: framePx.y,
width: STRIP_PX,
height: boxPx.height,
height: framePx.height,
pointerEvents: "auto",
...rotOn(framePx.x - STRIP_PX / 2, framePx.y),
}}
onPointerDown={(e) =>
beginDrag(e, isFixedTextbox ? "wleft" : "move", { x: 0, y: 0 })
@ -491,11 +617,12 @@ export default function AnnotationSelectionLayer({
<div
className={`absolute touch-none sel-strip ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
style={{
left: boxPx.x + boxPx.width - STRIP_PX / 2,
top: boxPx.y,
left: framePx.x + framePx.width - STRIP_PX / 2,
top: framePx.y,
width: STRIP_PX,
height: boxPx.height,
height: framePx.height,
pointerEvents: "auto",
...rotOn(framePx.x + framePx.width - STRIP_PX / 2, framePx.y),
}}
onPointerDown={(e) =>
beginDrag(e, isFixedTextbox ? "wright" : "move", { x: 0, y: 0 })
@ -520,6 +647,7 @@ export default function AnnotationSelectionLayer({
height: HANDLE_PX,
cursor: c.cursor,
pointerEvents: "auto",
...rotOn(c.x - HANDLE_PX / 2, c.y - HANDLE_PX / 2),
}}
onPointerDown={(e) => beginDrag(e, "scale", c.anchor)}
onPointerMove={moveDrag}
@ -528,6 +656,39 @@ export default function AnnotationSelectionLayer({
/>
))}
{/* STAMP — จุดจับหมุนเหนือกึ่งกลางขอบบนของกรอบ (หมุนตามกรอบผ่าน rotOn):
ลากวนรอบกลางกรอบ = ปรับมุมเอียงแทนการพิมพ์ในแผงอย่างเดียว (คำขอผู้ใช้
2026-09-28) — anchor ของ mode "rotate" = กลางกรอบ (% จาก AABB) */}
{stampData && (
<div
className="absolute flex touch-none sel-handle cursor-grab items-center justify-center rounded-full border border-primary bg-white text-primary shadow-lg"
style={{
left: rotC.x - ROT_HANDLE_PX / 2,
top: framePx.y - ROT_HANDLE_GAP_PX - ROT_HANDLE_PX / 2,
width: ROT_HANDLE_PX,
height: ROT_HANDLE_PX,
pointerEvents: "auto",
...rotOn(
rotC.x - ROT_HANDLE_PX / 2,
framePx.y - ROT_HANDLE_GAP_PX - ROT_HANDLE_PX / 2,
),
}}
title="ลากเพื่อหมุนตรายาง"
aria-label="ลากเพื่อหมุนตรายาง"
onPointerDown={(e) =>
beginDrag(e, "rotate", {
x: boxPct.x + boxPct.width / 2,
y: boxPct.y + boxPct.height / 2,
})
}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={cancelDrag}
>
<RotateCw className="h-3 w-3" />
</div>
)}
</>
)}
@ -623,6 +784,20 @@ export default function AnnotationSelectionLayer({
)}
{stampData && (
<>
{/* ทางเข้า StampEditor (แก้คำตรายางหลังวาง — เหมือนปุ่มเดียวกันของ TEXTBOX):
แผงโผล่เฉพาะของตัวเองอยู่แล้ว (editable) จึงไม่ต้องเช็ค userId ซ้ำ /
root มี stopPropagation คลิกไม่หลุดไป deselect · sel-panel-btn ขยาย
hit 36px บนจอสัมผัสเหมือนปุ่มอื่นของแผง (UX audit 2026-09-28) */}
<PanelRow label="ข้อความ">
<button
type="button"
className={`sel-panel-btn flex flex-1 items-center justify-center gap-1 rounded-md border border-line px-2 py-1 text-[11px] font-medium text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
onClick={() => onStartEdit?.(annotation.id)}
>
<Pencil className="h-3.5 w-3.5" />
แก้ไขข้อความ
</button>
</PanelRow>
<PanelRow>
<ColorSwatches
value={stampData.color ?? STAMP_COLOR}
@ -640,16 +815,28 @@ export default function AnnotationSelectionLayer({
onChange={commitStampScale}
/>
</PanelRow>
<PanelRow label="มุมเอียง">
<NumberCombo
value={stampData.rotation ?? 0}
options={[-45, -15, 0, 15, 45]}
min={-180}
max={180}
step={1}
suffix="°"
onChange={commitStampRotation}
/>
</PanelRow>
</>
)}
{textboxData && (
<>
{/* M1: ทางเข้า editor นอกจาก double-click — แผงโผล่เฉพาะของตัวเองอยู่แล้ว
(editable) จึงไม่ต้องเช็ค userId ซ้ำ / root มี stopPropagation คลิกไม่หลุดไป deselect */}
(editable) จึงไม่ต้องเช็ค userId ซ้ำ / root มี stopPropagation คลิกไม่หลุดไป deselect
· sel-panel-btn ขยาย hit 36px บนจอสัมผัสเหมือนปุ่มอื่นของแผง (UX audit 2026-09-28) */}
<PanelRow label="ข้อความ">
<button
type="button"
className={`flex flex-1 items-center justify-center gap-1 rounded-md border border-line px-2 py-1 text-[11px] font-medium text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
className={`sel-panel-btn flex flex-1 items-center justify-center gap-1 rounded-md border border-line px-2 py-1 text-[11px] font-medium text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
onClick={() => onStartEdit?.(annotation.id)}
>
<Pencil className="h-3.5 w-3.5" />

View file

@ -20,6 +20,7 @@ import {
type Rect,
type Size,
} from "@/utils/coordinates";
import { measureStampText } from "@/utils/annotation-display";
import { annotationBBox, hitTestPath, hitTestShape } from "@/utils/geometry";
import type { InkStamp } from "./TextboxStampLayer";
@ -303,7 +304,7 @@ export default function InkLayer({
// (bbox = สูตรเดียวกับ render และกรอบ selection — annotationBBox)
for (const s of stamps) {
if (s.userId !== ownUserId) continue;
const b = annotationBBox({ type: "STAMP", data: s.data }, measured);
const b = annotationBBox({ type: "STAMP", data: s.data }, measured, measureStampText);
if (
lp.x >= b.x - pad &&
lp.x <= b.x + b.width + pad &&

View file

@ -96,8 +96,13 @@ import {
HIGHLIGHT_DEFAULT_COLOR,
sanitizePdfText,
SHAPE_DEFAULT_COLOR,
measureStampText,
stampBoxHeight,
stampLayout,
STAMP_COLOR,
STAMP_DEFAULT_SCALE_PCT,
STAMP_FONT_STACK,
STAMP_MAX_LABEL_CHARS,
STAMP_PRESETS,
TEXTBOX_DEFAULT_COLOR,
TEXTBOX_DEFAULT_FONT_SIZE_PCT,
@ -130,6 +135,7 @@ import {
COMMENT_PIN_SIZE_PX,
TEXTBOX_DEFAULT_WIDTH_PCT,
annotationBBox,
clampStampIntoPage,
commentPinBoxPct,
hitTestPath,
hitTestShape,
@ -137,7 +143,7 @@ import {
} from "@/utils/geometry";
import InkLayer, { type InkRedaction, type InkShape, type InkStroke } from "./InkLayer";
import TextboxStampLayer, { type InkStamp, type InkTextbox } from "./TextboxStampLayer";
import AnnotationSelectionLayer from "./AnnotationSelectionLayer";
import AnnotationSelectionLayer, { type AnnotationPatch } from "./AnnotationSelectionLayer";
import AnnotationSidebar, { byPageOrder } from "../sidebar/AnnotationSidebar";
import Select from "../Select";
import {
@ -190,7 +196,8 @@ type AnnotationAction =
/** กลุ่ม state/handler ของ TEXTBOX ที่ส่งรวมทีเดียวลง PdfPage (F17/B2)
* ตัว editor เป็น contentEditable uncontrolled — ข้อความ/สไตล์ (segments ตัวหนาเฉพาะส่วน)
* ส่งออกจาก editor ตอน commit เท่านั้น ไม่มี draft กลางทาง */
* ส่งออกจาก editor ตอน commit เท่านั้น ไม่มี draft กลางทาง
* (editingId/commitEdit ใช้ร่วมกับ STAMP ด้วย — StampEditor แก้คำตรายางผ่าน id เดียวกัน) */
type TextboxUI = {
composer: TextboxComposer | null;
commitComposer: (text: string, segments?: TextboxSegment[]) => void;
@ -505,7 +512,7 @@ function hitTestAnnotation(a: AnnotationDTO, p: Point, size: Size): boolean {
// ที่เหลือ (REDACTION/TEXTBOX/STAMP) — bbox ในหน่วย % เทียบกับ p (%) หน่วยเดียวกัน
// (เดิมเทียบ bbox หน่วย px ตาม size จริงกับ p ที่เป็น % หน่วยไม่ตรงกัน จนคลิกเลือก
// และ tooltip จับได้เฉพาะกรอบที่ชิดมุมซ้ายบนของหน้า) — padding 4px เดิมแปลงเป็น % ตามแกน
const box = annotationBBox(a, { width: 100, height: 100 });
const box = annotationBBox(a, { width: 100, height: 100 }, measureStampText);
const padX = (4 / size.width) * 100;
const padY = (4 / size.height) * 100;
return (
@ -635,6 +642,7 @@ function PdfPage({
onPinToggle,
onAnnotationPreview,
onAnnotationUpdate,
onAnnotationPatch,
onSetResolved,
onAnnotationBookmark,
onCreateReply,
@ -691,6 +699,9 @@ function PdfPage({
onPinToggle: (id: string | null) => void;
onAnnotationPreview: (data: AnnotationData | null) => void;
onAnnotationUpdate: (id: string, data: AnnotationData) => void;
/** ตัวควบคุมเล็กของแผง commit เป็น patch — viewer ผสานกับ data ล่าสุดตอนถึงคิว
* update กัน stale base ทับค่าที่เพิ่งเปลี่ยน (AnnotationPatch, code review 2026-09-28) */
onAnnotationPatch: (id: string, patch: AnnotationPatch) => void;
onSetResolved: (id: string, resolved: boolean) => void;
/** N2: ปุ่ม bookmark ในแผง selection → เปิด popup ที่ตำแหน่งของ annotation นั้น */
onAnnotationBookmark: (a: AnnotationDTO, yOffsetPct: number) => void;
@ -1180,6 +1191,7 @@ function PdfPage({
onDelete={onErase}
onBookmark={(y) => onAnnotationBookmark(selected, y)}
onStartEdit={textbox.startEdit}
onPatch={onAnnotationPatch}
/>
);
})()}
@ -1189,6 +1201,88 @@ function PdfPage({
// ─── Client root ─────────────────────────────────────────────────────────────
/** ผสาน patch ของตัวควบคุมเล็ก (AnnotationPatch ของ AnnotationSelectionLayer) เข้ากับ
* data ตามชนิด (เช็ค field เหมือนตัวควบคุมของแผง: rects = HIGHLIGHT, paths/shape =
* เส้น/รูปทรง, label = STAMP, fontSize = TEXTBOX — AnnotationData ไม่มี field type)
* — เรียกตอนถึงตาของคิว update จึงได้ base ล่าสุดเสมอ (ไม่ใช่ snapshot ตอนกดปุ่ม)
* ชนิดไม่ตรงกับ patch = เจตนาเดิมของตัวควบคุมนั้น → คืน base เปล่า ๆ */
function applyAnnotationPatch(base: AnnotationData, patch: AnnotationPatch): AnnotationData {
switch (patch.kind) {
case "color":
// HIGHLIGHT ("rects") กับ STAMP ("label") = field `color`, เส้น/รูปทรง = `strokeColor`
if ("rects" in base || "label" in base) return { ...base, color: patch.color };
if ("paths" in base || "shape" in base) return { ...base, strokeColor: patch.color };
return base;
case "strokeWidth":
if ("paths" in base) {
// F7: เส้นปากกามี widths ต่อจุด — scale ตามอัตราส่วนพร้อม strokeWidth หลัก (PLAN-FEAT.md §5)
return {
...base,
strokeWidth: patch.strokeWidth,
...(base.widths
? {
widths: base.widths.map(
(pw) => (pw / base.strokeWidth) * patch.strokeWidth,
),
}
: {}),
};
}
if ("shape" in base) return { ...base, strokeWidth: patch.strokeWidth };
return base;
case "highlightStyle":
if (!("rects" in base)) return base;
return { ...base, style: patch.style };
case "highlightOpacity": {
if (!("rects" in base)) return base;
// 100% = ทึบเต็ม = ไม่เก็บ field เหมือนตอนสร้างใหม่
const { opacity: _prev, ...rest } = base;
return { ...rest, ...(patch.opacityPct < 100 ? { opacity: patch.opacityPct / 100 } : {}) };
}
case "stampScale": {
if (!("label" in base)) return base;
const scaled = { ...base, scale: patch.scale };
// ตรายางเอียง: ขยายแล้ว AABB ของกรอบหมุนยื่นเกินหน้าได้ (position = มุมก่อนหมุน) —
// เลื่อนกลับเข้าหน้าทันทีเหมือน stampRotation ไม่งั้น validator ปฏิเสธแล้วเด้ง
// ทั้งที่บนจอกรอบยังอยู่ในหน้า (code review 2026-09-29)
return scaled.rotation
? { ...scaled, position: clampStampIntoPage(scaled) }
: scaled;
}
case "stampRotation": {
if (!("label" in base)) return base;
// 0° = ถอด field ทิ้งเหมือนแถวเดิม (schema/validator ถือว่าไม่มี = ไม่เอียง)
const { rotation: _prev, ...rest } = base;
if (patch.rotation === 0) return rest;
// หมุนที่ตำแหน่งชิดขอบแล้ว AABB ของกรอบอาจยื่นเกินหน้า — เลื่อนกลับเข้าทันที
// (validator ตรวจ AABB ไม่ติดลบ ไม่งั้น update โดนปฏิเสธแล้วเด้ง — review 2026-09-28)
return {
...rest,
rotation: patch.rotation,
position: clampStampIntoPage({ ...rest, rotation: patch.rotation }),
};
}
case "textbox":
if (!("fontSize" in base)) return base;
return { ...base, ...patch.fields };
case "label": {
if (!("label" in base)) return base;
const relabeled = { ...base, label: patch.label };
// ข้อความใหม่เปลี่ยนจำนวนบรรทัด/ความสูงกรอบ — เอียงแล้ว AABB ยื่นเกินหน้าได้
// เช่นเดียวกับ stampScale จึง clamp กลับเข้าหน้าทันที (code review 2026-09-29)
return relabeled.rotation
? { ...relabeled, position: clampStampIntoPage(relabeled) }
: relabeled;
}
case "textboxText": {
if (!("fontSize" in base)) return base;
// segments null = เคลียร์ตัวหนาเฉพาะส่วนทั้งก้อน (ไม่มีส่วนตัวหนาเหลืออยู่แล้ว)
const { segments: _prev, ...rest } = base;
return { ...rest, text: patch.text, ...(patch.segments ? { segments: patch.segments } : {}) };
}
}
}
export default function PDFViewer({
file,
initialAnnotations,
@ -1241,6 +1335,11 @@ export default function PDFViewer({
const [stampScalePct, setStampScalePct] = useState(STAMP_DEFAULT_SCALE_PCT);
const [stampPreset, setStampPreset] = useState<string>(STAMP_PRESETS[0]); // F18: preset ตรายาง
const [stampMenuOpen, setStampMenuOpen] = useState(false); // F18: dropdown เลือก preset ตรายาง
// หน้า popup "กำหนดเอง" ในเมนูตรายาง (ข้อความ/สี/ขนาด + preview) — reset กลับหน้า preset ทุกครั้งที่เปิดเมนูใหม่
const [stampCustomMode, setStampCustomMode] = useState(false);
// ปุ่มแถว "กำหนดเอง…" ของหน้ารายการ — ย้อนกลับจากหน้ากำหนดเองแล้วคืน focus มาที่นี่
// (input ถูก unmount พร้อม focus ที่อยู่บนมัน ไม่คืนแล้ว focus ตกไป body — UX audit 2026-09-28)
const stampCustomRowRef = useRef<HTMLButtonElement | null>(null);
const [shapeMenuOpen, setShapeMenuOpen] = useState(false); // F5: dropdown เลือกชนิดรูปทรง
const [drawMenuOpen, setDrawMenuOpen] = useState(false); // dropdown เลือกชนิดปากกา (draw)
const [textboxMenuOpen, setTextboxMenuOpen] = useState(false); // dropdown ตั้งค่ากล่องข้อความ
@ -1368,7 +1467,7 @@ export default function PDFViewer({
const m = new Map<AnnotationDTO["id"], Rect>();
for (const a of visibleAnnotations) {
if (a.type === "COMMENT") continue; // pin มี popover ของตัวเอง ไม่เข้า hit-test
m.set(a.id, annotationBBox(a, { width: 100, height: 100 }));
m.set(a.id, annotationBBox(a, { width: 100, height: 100 }, measureStampText));
}
return m;
}, [visibleAnnotations]);
@ -2131,19 +2230,31 @@ export default function PDFViewer({
});
}
// F1: ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา — optimistic แบบเดียวกับ create (invariant #9)
async function handleUpdateAnnotation(id: string, data: AnnotationData) {
if (!user) return;
// ─── คิว update ต่อรายการ (code review 2026-09-28 รอบสอง) ────────────────────
// จอสัมผัส: blur-commit ข้อความใน editor แล้วแตะสี/ขนาดในแผง selection ต่อทันที —
// ทั้งสอง event วิ่งก่อน React render optimistic row คอมมิตตัวหลังจึงอ่าน base จาก
// snapshot เก่าจนทับค่าที่เพิ่งเปลี่ยน (และ server action สองตัวแข่งกันไม่เรียงลำดับ)
// — update ทุกตัวของ id เดียวกันจึงเข้าคิวเดียวกัน โดย base คำนวณ**ตอนถึงคิว**จาก
// data ล่าสุดที่คิวก่อนหน้าบันทึกไว้ (pendingDataBaseRef) ไม่ใช่ state ณ ตอน event
const updateChainRef = useRef(new Map<string, Promise<void>>());
const pendingDataBaseRef = useRef(new Map<string, AnnotationData>());
/** แกน update เดิม (optimistic + action + history) — เรียกเมื่อถึงตาของคิวเท่านั้น
* คืน promise ที่ resolve เมื่อ server action จบจริง (resolve ใน finally ข้างใน scope —
* `await startTransition(async …)` ของ React 18 คืน control ตั้งแต่ callback สลับไปรอ
* await ครั้งแรกเท่านั้น ไม่รอ action จบ ใช้ตัวนั้นหน่วงคิวแล้ว action เรียงไม่ได้) */
function applyUpdate(id: string, data: AnnotationData, before: AnnotationData): Promise<void> {
return new Promise((resolve) => {
if (!user) return resolve();
const current = annotations.find((a) => a.id === id);
if (!current || current.userId !== user.userId) return; // ของคนอื่นแก้ไม่ได้ (D9)
const before = current.data; // ไว้ย้อนกลับตอน undo (F2)
if (!current || current.userId !== user.userId) return resolve(); // ของคนอื่นแก้ไม่ได้ (D9)
setPreviewData(null);
await startTransition(async () => {
startTransition(async () => {
try {
applyAnnotation({
kind: "update",
row: { ...current, data, updatedAt: new Date().toISOString() },
});
try {
const saved = await updateAnnotation(id, user.userId, data);
setAnnotations((prev) => prev.map((a) => (a.id === saved.id ? saved : a)));
pushHistory({
@ -2159,8 +2270,53 @@ export default function PDFViewer({
} catch (err) {
console.error("แก้ไข annotation ไม่สำเร็จ", err); // React rollback optimistic row ให้เอง
showActionError("แก้ไขรายการไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
// ทิ้ง base ที่คิวเก็บไว้ — เป็น data ที่ action นี้ตั้งใจเขียนแต่ไม่เคยติด DB
// ถ้าคงไว้ update ถัดไปจะผสานจน commit การเปลี่ยนที่ล้มไปด้วย และ undo
// history กู้สถานะที่ไม่มีอยู่จริง (code review 2026-09-29) — รอบหน้าเริ่ม
// จาก state จริงหลัง rollback แทน
pendingDataBaseRef.current.delete(id);
} finally {
resolve();
}
});
});
}
/** เข้าคิว update ของ id นี้ — make คำนวณ data จาก base ล่าสุดตอนถึงตาของคิว */
function enqueueUpdate(id: string, make: (base: AnnotationData) => AnnotationData) {
const prev = updateChainRef.current.get(id) ?? Promise.resolve();
const run = prev
.catch(() => {}) // action ล้มจัดการใน applyUpdate แล้ว — กัน reject ตกทอดตัดคิวที่เหลือ
.then(() => {
const fromState = annotations.find((a) => a.id === id)?.data;
const base = pendingDataBaseRef.current.get(id) ?? fromState;
if (!base) return;
const data = make(base);
pendingDataBaseRef.current.set(id, data);
return applyUpdate(id, data, base);
});
updateChainRef.current.set(id, run);
const cleanup = () => {
// คิวว่างแล้ว (ไม่มีตัวใหม่แทรกระหว่างรอ) — ทิ้ง base ค้างให้รอบหน้าเริ่มจาก state จริง
if (updateChainRef.current.get(id) === run) {
updateChainRef.current.delete(id);
pendingDataBaseRef.current.delete(id);
}
};
// cleanup ทั้งสองทาง — ถ้า run reject (คำนวณ base/applyUpdate โยน) ก็ต้องไม่ค้างคิวของ
// id นี้ตลอดกาลจนการแก้ไขรายการนี้เงียบไปหมด (code review 2026-09-29)
void run.then(cleanup, cleanup);
}
// F1: ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา — optimistic แบบเดียวกับ create (invariant #9)
// (data เต็มจาก drag/preview — ผ่านคิวเดียวกันให้ server action เรียงลำดับเสมอ)
function handleUpdateAnnotation(id: string, data: AnnotationData) {
enqueueUpdate(id, () => data);
}
/** ตัวควบคุมเล็กของแผง selection / editor ข้อความ — patch ผสานกับ base ล่าสุดตอนถึงคิว */
function handlePatchAnnotation(id: string, patch: AnnotationPatch) {
enqueueUpdate(id, (base) => applyAnnotationPatch(base, patch));
}
// ─── Resolve (F10/A5 — ใครก็ได้ toggle ตาม D6; ไม่เข้า history ของ F2
@ -2790,11 +2946,12 @@ export default function PDFViewer({
setTextboxComposer({ pageIndex, position });
return;
}
// F18/B5: คลิกบนหน้า = วางตรายาง preset ที่เลือกไว้ทันที (สี/ขนาดจากเมนูตั้งค่าของ tool)
// F18/B5: คลิกบนหน้า = วางตรายางคำที่เลือกไว้ทันที (preset หรือคำที่พิมพ์เองจากเมนู —
// สี/ขนาดจากเมนูตั้งค่าของ tool เช่นกัน; ช่องคำว่าง/เว้นวรรคล้วน = preset แรก กัน label ว่าง)
if (tool === "stamp" && user) {
const position = getPointerPercent(e, pageEl);
void handleCreateAnnotation(pageIndex, "STAMP", {
label: stampPreset,
label: stampPreset.trim() || STAMP_PRESETS[0],
position,
scale: stampScalePct,
color: stampColor,
@ -2876,7 +3033,7 @@ export default function PDFViewer({
{ width: r.width, height: r.height },
coarseTouch ? COMMENT_PIN_COARSE_SIZE_PX : COMMENT_PIN_SIZE_PX,
)
: annotationBBox(a, { width: 100, height: 100 });
: annotationBBox(a, { width: 100, height: 100 }, measureStampText);
scrollToBelowHeader(main, contentTopInScroll(main, r.top) + (r.height * boxPct.y) / 100);
}
@ -2905,23 +3062,25 @@ export default function PDFViewer({
// ─── Text box (F17/B2) ─────────────────────────────────────────────────────
/** เข้าสู่โหมดแก้ไขข้อความ TEXTBOX ของตัวเอง (double-click — null = ยกเลิก) */
/** เข้าสู่โหมดแก้ไขข้อความ inline (TEXTBOX double-click / STAMP ปุ่ม "แก้ไขข้อความ" — null = ยกเลิก) */
function startTextboxEdit(id: string | null) {
setEditingTextboxId(id);
}
/** บันทึกข้อความที่แก้ — reuse updateAnnotation ของ F1 (เจ้าของเท่านั้น การกันอยู่ที่ handler)
* segments (ตัวหนาเฉพาะส่วน) เก็บเฉพาะเมื่อยังมีส่วนที่เป็นตัวหนา ไม่งั้นเคลียร์ทิ้ง */
/** บันทึกข้อความที่แก้ (TEXTBOX inline / STAMP ผ่าน StampEditor) — ส่งเป็น patch ให้คิว
* update ผสานกับ data ล่าสุด (สี/ขนาดที่เพิ่งเปลี่ยนในแผงก่อนหน้าไม่ถูกทิ้ง); segments
* (ตัวหนาเฉพาะส่วน) เก็บเฉพาะ TEXTBOX เมื่อยังมีส่วนที่เป็นตัวหนา ไม่งั้นเคลียร์ทิ้ง */
function commitTextboxEdit(id: string, text: string, segments?: TextboxSegment[]) {
const current = annotations.find((a) => a.id === id);
if (!current || current.type !== "TEXTBOX") return;
setEditingTextboxId(null);
const current = annotations.find((a) => a.id === id);
if (!current) return;
const hasBold = !!segments?.some((s) => s.bold);
void handleUpdateAnnotation(id, {
...current.data,
text,
segments: hasBold ? segments : undefined,
});
handlePatchAnnotation(
id,
current.type === "STAMP"
? { kind: "label", label: text }
: { kind: "textboxText", text, segments: hasBold ? segments ?? [] : null },
);
}
/** บันทึกกล่องข้อความใหม่จาก composer ณ ตำแหน่งที่คลิก (ค่าทั้งหมดจากเมนูตั้งค่าของ tool) */
@ -3504,9 +3663,19 @@ export default function PDFViewer({
);
}
// F18/B5: ปุ่มตรายาง = dropdown เลือก preset
// F18/B5: ปุ่มตรายาง = dropdown เลือก preset — เลือกคำกำหนดเองแล้วปุ่มแสดง
// "กำหนดเอง" แทนคำเต็ม (คำจริงเห็นในเมนู/preview ตอนเลือกอยู่แล้ว)
if (key === "stamp") {
const btnText = stampPreset;
const btnText = STAMP_PRESETS.includes(stampPreset)
? stampPreset
: "กำหนดเอง";
/** จบหน้ากำหนดเองแบบเลือกค่าจบ (ปุ่ม "ใช้ตรายางนี้"/Enter): ช่องว่างล้วน =
* กลับ preset แรก ให้สถานะทุกจุด (ปุ่ม tool/แถวเมนู/ตอนวาง) สอดคล้องกัน —
* ปิดเมนูด้วย setState ตรง ไม่ผ่าน markMenuClosed ให้แตะหน้าวางได้ทันที */
const finishCustom = () => {
if (!stampPreset.trim()) setStampPreset(STAMP_PRESETS[0]);
setStampMenuOpen(false);
};
return (
<div key={key} ref={stampMenuRef} className="relative">
<button
@ -3522,7 +3691,10 @@ export default function PDFViewer({
// switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน)
const reopen = tool !== "stamp" || !stampMenuOpen;
switchTool("stamp");
if (reopen) setStampMenuOpen(true);
if (reopen) {
setStampMenuOpen(true);
setStampCustomMode(false); // เปิดเมนูใหม่ = กลับหน้ารายการ preset
}
}}
>
<Icon className="h-4 w-4" />
@ -3546,6 +3718,122 @@ export default function PDFViewer({
overflowY: "auto",
}}
>
{stampCustomMode ? (
<>
{/* ─── หน้ากำหนดเอง: ข้อความ + สี + ขนาด + preview ตรายางตามสัดส่วนจริง ─── */}
<div className="flex items-center gap-1 px-1 pb-1">
{/* ย้อนกลับหน้ารายการ — คืน focus ไปปุ่ม "กำหนดเอง…" หลัง render
หน้า preset (rAF รอหลัง commit) เพราะ input ที่กำลัง focus อยู่
ถูก unmount ไปพร้อมกัน ไม่คืนแล้ว focus ตกไป body (UX audit 2026-09-28) */}
<button
type="button"
title="กลับรายการตรายาง"
aria-label="กลับรายการตรายาง"
className={`rounded-md p-1 text-ink-soft transition hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
onClick={() => {
setStampCustomMode(false);
requestAnimationFrame(() =>
stampCustomRowRef.current?.focus(),
);
}}
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
<span className="text-xs font-semibold text-ink">ตรายางกำหนดเอง</span>
</div>
{/* ข้อความ — พิมพ์ live ไปที่ค่าที่เลือก (stampPreset) และ preview ทันที */}
<input
type="text"
aria-label="คำตรายางกำหนดเอง"
placeholder="พิมพ์คำสำหรับตรายาง…"
value={stampPreset}
maxLength={STAMP_MAX_LABEL_CHARS}
spellCheck={false}
autoFocus
onChange={(e) => setStampPreset(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation(); // พิมพ์ในช่องต้องไม่ชนคีย์ลัดของ viewer
if (e.key === "Enter") {
e.preventDefault();
finishCustom();
}
if (e.key === "Escape") {
e.preventDefault();
// ย้อนกลับหน้ารายการ (คำที่พิมพ์คงไว้) + คืน focus ไปปุ่มแถว
setStampCustomMode(false);
requestAnimationFrame(() =>
stampCustomRowRef.current?.focus(),
);
}
}}
// focus: แบบไม่มีเงื่อนไข (ไม่ใช่ focus-visible:) — autoFocus หลัง
// แตะปุ่ม "กำหนดเอง…" ด้วยเมาส์/นิ้ว ไม่ match :focus-visible ใน
// Chromium จนช่องโฟกัสอยู่แต่มองไม่เห็น — รูปแบบเดียวกับ popup
// bookmark + rounded-md 6px ตามสเปก Inputs ของ Genesis (UX audit)
className="mb-2 w-full rounded-md border border-line bg-white px-2 py-1.5 text-xs text-ink placeholder:text-ink-faint focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
/>
{/* preview — กรอบ/ฟอนต์/การจัดวาง (ย่อฟอนต์บรรทัดเดียวก่อน คำยาว
ค่อย wrap หลายบรรทัด) สูตรเดียวกับตอน render บนหน้าเป๊ะ (measure
ตัวเดียวกัน) ขนาดตามสัดส่วนจริงบนจอ: ความกว้างหน้าแรก × scale% —
ใหญ่เกินเมนู (w-60) ย่อทั้งชุดเชิงเส้น */}
<div className="mb-2 flex justify-center overflow-hidden rounded border border-line bg-canvas px-2 py-3">
{(() => {
const pageW = pageSizes[0]
? Math.ceil(pageSizes[0].width * scale)
: 595;
const wReal = (pageW * stampScalePct) / 100;
const k = Math.min(1, 192 / wReal);
const w = wReal * k;
const label = stampPreset.trim() || "ตัวอย่าง";
const lay = stampLayout(label, w, measureStampText);
return (
<div
className="flex flex-col items-center justify-center overflow-hidden text-center font-bold"
style={{
width: w,
height: stampBoxHeight(lay, w),
border: `${Math.max(w * 0.02, 1)}px solid ${stampColor}`,
borderRadius: Math.max(w * 0.04, 2),
color: stampColor,
fontFamily: STAMP_FONT_STACK,
fontSize: lay.fontSize,
lineHeight: lay.lineHeight,
}}
>
{lay.lines.map((ln, i) => (
<div key={i}>{ln || " "}</div>
))}
</div>
);
})()}
</div>
<PanelRow>
<ColorSwatches value={stampColor} onChange={setStampColor} />
</PanelRow>
<PanelRow label="ขนาด">
{/* ความกว้างกรอบตรายางเป็น % ของความกว้างหน้า */}
<NumberCombo
value={stampScalePct}
options={[12, 18, 25, 40]}
min={5}
max={60}
step={1}
suffix="%"
onChange={setStampScalePct}
/>
</PanelRow>
{/* จบเหมือนเลือก preset: ปิดเมนูด้วย setState ตรง (ไม่ผ่าน
markMenuClosed) ให้แตะหน้าวางได้ทันที ไม่โดน guard กลืน tap 300ms */}
<button
type="button"
className={`mb-1 w-full rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-white transition hover:bg-primary-hover ${FOCUS_RING_CLS}`}
onClick={finishCustom}
>
ใช้ตรายางนี้
</button>
</>
) : (
<>
{STAMP_PRESETS.map((preset) => (
<button
key={preset}
@ -3568,6 +3856,26 @@ export default function PDFViewer({
{stampPreset === preset && <Check className="h-3.5 w-3.5 shrink-0" />}
</button>
))}
{/* เข้าหน้ากำหนดเอง (ข้อความ/สี/ขนาด + preview) — สลับหน้าใน popup เดียวกัน
ไม่ปิดเมนู (tap อยู่บนเมนูเอง) · active เมื่อค่าปัจจุบันเป็นคำกำหนดเอง
· ref ไว้คืน focus ตอนย้อนกลับจากหน้ากำหนดเอง (UX audit 2026-09-28) */}
<button
ref={stampCustomRowRef}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs font-medium transition ${FOCUS_RING_CLS} ${
stampPreset.trim() !== "" &&
!STAMP_PRESETS.includes(stampPreset)
? "bg-primary/10 text-primary"
: "text-ink-soft hover:bg-ink/[0.05]"
}`}
onClick={() => setStampCustomMode(true)}
>
<Pencil className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1">กำหนดเอง…</span>
{stampPreset.trim() !== "" && !STAMP_PRESETS.includes(stampPreset) && (
<Check className="h-3.5 w-3.5 shrink-0" />
)}
</button>
<div className="my-1.5 border-t border-line" />
<PanelRow>
<ColorSwatches value={stampColor} onChange={setStampColor} />
@ -3584,6 +3892,8 @@ export default function PDFViewer({
onChange={setStampScalePct}
/>
</PanelRow>
</>
)}
</div>,
document.body
)}
@ -4093,6 +4403,7 @@ export default function PDFViewer({
onAnnotationUpdate={(id, data) => {
void handleUpdateAnnotation(id, data);
}}
onAnnotationPatch={handlePatchAnnotation}
onSetResolved={(id, resolved) => {
void handleSetResolved(id, resolved);
}}
@ -4134,7 +4445,7 @@ export default function PDFViewer({
// รายการที่ชี้ถูกเลือกอยู่ (แผง selection F1 เปิดแถวนั้นแล้ว) = ไม่โชว์
// tooltip ซ้อนอีกกล่อง — เจอตอนลากคลุมจบ auto-select โผล่ทันทีที่ตำแหน่งเคอร์เซอร์
if (!hovered || hovered.pageIndex !== i || hovered.id === selectedId) return null;
const box = annotationBBox(hovered, { width: w, height: h });
const box = annotationBBox(hovered, { width: w, height: h }, measureStampText);
const note = annotationNote(hovered);
// เหลือพื้นที่ขวาของ bbox ไม่พอ (~264px) → วางทางซ้ายแทน
const flipLeft = box.x + box.width + 264 > w;

View file

@ -1,17 +1,26 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { StampData, TextboxData, TextboxSegment } from "@/db/schema";
import { toPx, type Point, type Size } from "@/utils/coordinates";
import { STAMP_HEIGHT_RATIO, textboxMaxWidthPct } from "@/utils/geometry";
import { STAMP_COLOR, stampFitFontSize } from "@/utils/annotation-display";
import { textboxMaxWidthPct } from "@/utils/geometry";
import { segmentsToHtml, serializeEditor } from "@/utils/textbox-editor";
import {
measureStampText,
STAMP_COLOR,
stampBoxHeight,
STAMP_FONT_STACK,
stampLayout,
STAMP_MAX_LABEL_CHARS,
} from "@/utils/annotation-display";
// TEXTBOX + STAMP overlay (F17/B2, F18/B5) — HTML overlay (z-4 กลุ่มเดียวกับ comment pins)
// - container `pointer-events: none` เสมอ เฉพาะรายการของตัวเองที่ `auto`
// (เหมือน comment pins — ไม่บังการ select ข้อความของ textLayer เมื่อปิด tool)
// - คลิก = เลือกรายการ (กรอบ selection ของ F1), double-click TEXTBOX ของตัวเอง
// = แก้ไขข้อความผ่าน textarea วางแทนที่ตำแหน่งเดิม (บันทึกผ่าน updateAnnotation — pattern F13)
// ส่วน STAMP แก้ข้อความผ่านปุ่ม "แก้ไขข้อความ" ในแผง selection (editingId ใช้ร่วมกับ TEXTBOX)
// - composer = textarea ระหว่างสร้างกล่องข้อความใหม่ วางที่ตำแหน่งที่คลิกบนหน้า
/** กล่องข้อความจาก DB — พิกัด % ทั้งหมด */
@ -20,9 +29,8 @@ export type InkTextbox = { id: string; userId: string; data: TextboxData };
/** ตรายางจาก DB (F18/B5) — พิกัด % ทั้งหมด */
export type InkStamp = { id: string; userId: string; data: StampData };
// ฟอนต์ตรายางบนจอ — ตั้งชัดทั้งตอนวัด (canvas) และตอน render เพื่อให้ความกว้าง
// ที่ stampFitFontSize คำนวณตรงกับที่แสดงจริง (ตัวหนา 700 เหมือน class font-bold)
const STAMP_FONT_STACK = "system-ui, sans-serif";
// ฟอนต์ตรายางบนจอ + canvas measure ย้ายไป utils/annotation-display.ts แล้ว
// (2026-09-28) ให้ preview ในเมนูของ tool ใช้ตัววัดเดียวกันกับตอน render จริง
/** typography audit 2026-09-16 (T4.4 + Option B): ฟอนต์กล่องข้อความบนจอ — pin ชัดทั้ง
* editor และ render ไม่สืบทอดจาก body: กล่องกว้างเป็น % คงที่ ฟอนต์เปลี่ยนตามเครื่อง
@ -30,55 +38,6 @@ const STAMP_FONT_STACK = "system-ui, sans-serif";
* ตรงกันทุกอุปกรณ์จริง (T1-C) พร้อม system ไทยเดิมเป็น fallback ตอนโหลดไม่ได้ */
const TEXTBOX_FONT_STACK =
"var(--font-sarabun, 'Sarabun'), 'Leelawadee UI', 'Thonburi', sans-serif";
let stampMeasureCtx: CanvasRenderingContext2D | null = null;
/** วัดความกว้างข้อความตรายางบนจอ — ไม่มี document (SSR) คืน 0 ให้ใช้ขนาด base */
function measureStampText(text: string, fontSize: number): number {
if (typeof document === "undefined") return 0;
stampMeasureCtx ??= document.createElement("canvas").getContext("2d");
if (!stampMeasureCtx) return 0;
stampMeasureCtx.font = `bold ${fontSize}px ${STAMP_FONT_STACK}`;
return stampMeasureCtx.measureText(text).width;
}
/** แปลงข้อความ/segments → HTML สำหรับ contentEditable (escape + <b> + <br>) */
function segmentsToHtml(text: string, segments?: TextboxSegment[]): string {
const esc = (s: string) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const runs = segments?.length ? segments : [{ text }];
return runs
.map((s) => (s.bold ? `<b>${esc(s.text)}</b>` : esc(s.text)))
.join("")
.replace(/\n/g, "<br>");
}
/** อ่าน DOM ของ contentEditable กลับเป็น text รวม + segments แยกตามตัวหนา/ปกติ */
function serializeEditor(root: HTMLElement): { text: string; segments: TextboxSegment[] } {
const segs: TextboxSegment[] = [];
const push = (text: string, bold: boolean) => {
if (!text) return;
const last = segs[segs.length - 1];
if (last && !!last.bold === bold) last.text += text;
else segs.push({ text, ...(bold ? { bold: true } : {}) });
};
const walk = (node: Node, bold: boolean) => {
if (node.nodeType === Node.TEXT_NODE) {
push(node.nodeValue ?? "", bold);
} else if (node.nodeName === "BR") {
push("\n", bold);
} else {
const b = bold || node.nodeName === "B" || node.nodeName === "STRONG";
const kids = Array.from(node.childNodes);
kids.forEach((c, i) => {
walk(c, b);
// DIV/P sibling จากการกด Enter = คั่นขึ้นบรรทัดใหม่
if (i < kids.length - 1 && /^(DIV|P)$/.test(c.nodeName)) push("\n", b);
});
}
};
walk(root, false);
return { text: segs.map((s) => s.text).join(""), segments: segs };
}
/** กล่องแก้ไข/สร้างข้อความแบบ inline rich text — เลือกข้อความแล้วกด Ctrl/Cmd+B
* ทำตัวหนาเฉพาะส่วนที่เลือกได้ (สไตล์ทั้งกล่องจากตัวตั้งค่าของ tool/ของกล่อง) */
@ -115,12 +74,23 @@ function TextboxEditor({
onCancel: () => void;
}) {
const ref = useRef<HTMLDivElement | null>(null);
// initial HTML คำนวณ/คืน object เดิมจนกว่า annotation จะเปลี่ยน — editor เป็น
// uncontrolled (พิมพ์ใน DOM ตรง ๆ) React จึงไม่ยุ่งกับ innerHTML ระหว่างพิมพ์
const initialHtml = useMemo(
() => ({ __html: segmentsToHtml(initialText, initialSegments) }),
[initialText, initialSegments],
);
// จบ editor ได้ครั้งเดียว — blur (บันทึกอัตโนมัติ), Escape, unmount แต่ละทางเรียก
// ต่างกันและคำสั่งที่สอง (blur ตามหลัง Escape/Enter ตอน element ถูกถอด, Enter ซ้ำ
// จาก double-tap บนจอสัมผัสก่อน React ถอด editor) ต้องเงียบ — ไม่งั้น onSave ยิง
// ซ้ำจน composer สร้างกล่องซ้ำ / update เดียวกันกลายเป็น 2 version ในประวัติ F19
const doneRef = useRef(false);
const finish = (fn: () => void) => {
if (doneRef.current) return;
doneRef.current = true;
fn();
};
// initial HTML freeze ตอน mount (useState initializer) — editor เป็น uncontrolled
// (พิมพ์ใน DOM ตรง ๆ) ค่าเริ่มต้นต้องไม่เปลี่ยนตาม props กลางทาง: realtime update
// แก้กล่องเดียวกัน (อีกแท็บ/อีกคน) แล้ว useMemo คืน __html ใหม่จะ reset innerHTML
// เผลอข้อความที่ผู้ใช้กำลังพิมพ์
const [initialHtml] = useState(() => ({
__html: segmentsToHtml(initialText, initialSegments),
}));
// focus + วาง caret ท้ายข้อความตอนเปิด editor
useEffect(() => {
@ -139,9 +109,11 @@ function TextboxEditor({
const submit = () => {
const el = ref.current;
if (!el) return;
finish(() => {
const { text, segments } = serializeEditor(el);
if (text.trim()) onSave(text, segments);
else onCancel();
});
};
return (
@ -172,17 +144,32 @@ function TextboxEditor({
onBlur={submit}
onKeyDown={(e) => {
e.stopPropagation(); // ไม่ให้ปุ่มในกล่องข้อความชนคีย์ลัดของ viewer
// Enter ระหว่าง IME compose (คีย์บอร์ดเสมือนของ iPad/Android, IME ภาษาอื่น)
// = ยืนยันคำที่กำลังพิมพ์ ไม่ใช่คำสั่งของ editor
if (e.nativeEvent.isComposing) return;
if (e.key === "Escape") {
e.preventDefault();
onCancel();
finish(onCancel);
return;
}
// เลือกข้อความแล้วกด Ctrl/Cmd+B = ทำตัวหนาเฉพาะส่วนที่เลือก
// Enter = ขึ้นบรรทัดใหม่เป็น <br> ล้วนเสมอ — ปล่อย browser สร้าง <div> ต่อ
// บรรทัดเอง (Chrome) แล้ว serializeEditor ต้องเดา newline จากรอยต่อ block
// จน "abc<div>def</div>" กลายเป็น "abcdef" (รายงาน review ข้อ 2, 2026-09-28)
if (e.key === "Enter") {
e.preventDefault();
document.execCommand("insertLineBreak");
return;
}
// เลือกข้อความแล้วกด Ctrl/Cmd+B = ทำตัวหนาเฉพาะส่วนที่เลือก — ปิดเมื่อกล่อง
// หนาทั้งกล่อง: execCommand("bold") จะ toggle ออกเป็น span font-weight:normal
// ที่ serialize ไม่เก็บ และตอน render segment ก็ inherit 700 ของกล่องอยู่ดี
// (อยากปรับเป็นปกติ = ปิดตัวหนาทั้งกล่องที่แผง selection ก่อน)
// FIX-12: document.execCommand ถูก mark deprecated แต่
// ยังทำงานทุก browser ปัจจุบัน และไม่มี replacement ตรง ๆ ของ contentEditable
// (bold/insertText ตรงนี้และใน onPaste ด้านล่าง) — ใช้ต่อโดยรับความเสี่ยงไว้
// (bold/insertLineBreak/insertText ตรงนี้และใน onPaste ด้านล่าง) — ใช้ต่อโดยรับความเสี่ยงไว้
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "b") {
e.preventDefault();
document.execCommand("bold");
if (!bold) document.execCommand("bold");
}
}}
onPaste={(e) => {
@ -194,7 +181,9 @@ function TextboxEditor({
e.clipboardData.getData("text/plain"),
);
}}
className="min-h-[1.5em] w-full whitespace-pre-wrap break-words rounded border border-dashed border-ink-faint/70 bg-transparent p-0 caret-current focus:outline-none"
// ขอบประใช้ outline (ไม่กิน layout): border 1px ใน border-box ทำให้พื้นที่ข้อความ
// แคบกว่าตอน render จริง 2px จนตัดบรรทัดต่างจากที่จะบันทึก (review ข้อ 3)
className="min-h-[1.5em] w-full whitespace-pre-wrap break-words rounded outline-1 outline-dashed outline-ink-faint/70 bg-transparent p-0 caret-current"
style={{
fontFamily: TEXTBOX_FONT_STACK,
fontSize: toPx(fontSizePct, size.width),
@ -209,6 +198,174 @@ function TextboxEditor({
);
}
/** แก้ข้อความตรายางในที่ (ปุ่ม "แก้ไขข้อความ" ในแผง selection) — กรอบ/สี/ฟอนต์/การ
* จัดวาง (ย่อฟอนต์/wrap หลายบรรทัด) เหมือนตอน render จริงทุกอย่าง (WYSIWYG)
* ยกเว้น**มุมเอียง**: editor แสดงตรง (0°) เสมอ — textarea เอียงพิมพ์ลำบากและ
* กรอบครอบตำแหน่งเดิมอยู่แล้ว ปิด editor แล้วตรายางกลับไปเอียงตามค่าเดิมทันที ·
* พิมพ์ยาวขึ้น = ฟอนต์ย่อจนถึง floor แล้วค่อยขึ้นบรรทัดใหม่ กรอบสูงขึ้นตาม
* stampLayout ทันที — blur/Enter = บันทึก (ข้อความว่าง = ยกเลิก), Escape = ยกเลิก ·
* Enter ไม่ใช้ขึ้นบรรทัด (ตรายางจัดบรรทัดอัตโนมัติล้วน ให้ตรงกับ preview/ตอนวางเสมอ) */
function StampEditor({
size,
data,
enabled,
onSave,
onCancel,
}: {
size: Size;
data: StampData;
enabled: boolean;
onSave: (label: string) => void;
onCancel: () => void;
}) {
const [label, setLabel] = useState(data.label);
// จบ editor ได้ครั้งเดียว — Enter ซ้ำจาก double-tap บนจอสัมผัส (ก่อน React ถอด
// editor) หรือ blur ตามหลัง Enter ต้องเงียบ ไม่งั้น update เดียวกันกลายเป็น
// 2 version ในประวัติ F19 (เหตุผลเดียวกับ doneRef ของ TextboxEditor)
const doneRef = useRef(false);
const finish = (fn: () => void) => {
if (doneRef.current) return;
doneRef.current = true;
fn();
};
const w = toPx(data.scale, size.width);
const c = data.color ?? STAMP_COLOR;
const lay = useMemo(() => stampLayout(label, w, measureStampText), [label, w]);
const commit = () =>
finish(() => {
const t = label.trim();
if (t) onSave(t);
else onCancel();
});
return (
<div
className="absolute flex items-center justify-center overflow-hidden"
style={{
left: toPx(data.position.x, size.width),
top: toPx(data.position.y, size.height),
width: w,
height: stampBoxHeight(lay, w),
border: `${Math.max(w * 0.02, 1)}px solid ${c}`,
borderRadius: Math.max(w * 0.04, 2),
pointerEvents: enabled ? "auto" : "none",
}}
onClick={(e) => e.stopPropagation()}
>
<textarea
autoFocus
aria-label="แก้ไขข้อความตรายาง"
value={label}
maxLength={STAMP_MAX_LABEL_CHARS}
rows={lay.lines.length}
spellCheck={false}
onChange={(e) => setLabel(e.target.value.replace(/\n/g, ""))}
onBlur={commit}
onKeyDown={(e) => {
e.stopPropagation(); // ไม่ให้ปุ่มในช่องข้อความชนคีย์ลัดของ viewer
// Enter ระหว่าง IME compose = ยืนยันคำ ไม่ใช่บันทึก (เหตุผลเดียวกับ TextboxEditor)
if (e.nativeEvent.isComposing) return;
if (e.key === "Escape") {
e.preventDefault();
finish(onCancel);
return;
}
if (e.key === "Enter") {
e.preventDefault();
commit();
}
}}
// ไม่ใส่ padding แนวนอน: พื้นที่พิมพ์ = w − border (≈0.96w) กว้างกว่า maxW
// ของ stampLayout (0.9w) เสมอ ข้อความที่ย่อจนพอดี 0.9w เป๊ะ จึงไม่ wrap ใน
// editor ทั้งที่ผลหลังบันทึกเป็นบรรทัดเดียว (WYSIWYG — code review 2026-09-28)
className="w-full resize-none bg-transparent text-center font-bold focus:outline-none"
style={{
color: c,
fontFamily: STAMP_FONT_STACK,
fontSize: lay.fontSize,
lineHeight: lay.lineHeight,
}}
/>
</div>
);
}
/** ตรายางหนึ่งดวง — stampLayout (canvas measure ทีละบรรทัดหลายรอบตามจำนวนบรรทัด
* ที่ลอง wrap) ถูก useMemo ไว้ dep primitive [label, w] จึงคำนวณใหม่เฉพาะเมื่อ
* label/ความกว้างจริงเปลี่ยน ไม่ตาม re-render เพราะเหตุอื่น (annotation ตัวอื่น
* เพิ่ม/ลบ, selectedId, SSE event) — memo ระดับ component ไม่ช่วยเพราะ props
* (s/size object) เป็น reference ใหม่ทุก render ของ parent (code review
* 2026-09-28) · ฟอนต์ที่วัดเป็น system-ui (STAMP_FONT_STACK) มีพร้อมตั้งแต่
* browser เปิด จึงไม่ต้องรอ document.fonts.ready แล้ววัดซ้ำ (review ข้อ 7) */
function StampItem({
s,
size,
enabled,
onSelect,
}: {
s: InkStamp;
size: Size;
/** tool ที่ไม่ใช้ pointer บน layer นี้ → ปิด pointer-events/โฟกัสของทุกรายการ */
enabled: boolean;
onSelect: (id: string) => void;
}) {
const w = toPx(s.data.scale, size.width);
const c = s.data.color ?? STAMP_COLOR; // แถวเดิมไม่มี color = สีตรายาง default
// จัดวางข้อความแบบเดียวกับ preview/export: คำสั้น 1 บรรทัดฟอนต์เต็ม, คำยาวพอดี ๆ
// ย่อฟอนต์ให้อยู่บรรทัดเดียวก่อน (จนถึง floor 10%), คำยาวจริงเกิน floor ค่อย wrap
// ≤3 บรรทัด กรอบสูงขึ้นตามจำนวนบรรทัด (2026-09-28 รอบสอง — ตาม stampLayout)
const lay = useMemo(
() => stampLayout(s.data.label, w, measureStampText),
[s.data.label, w],
);
return (
<div
role="button"
tabIndex={enabled ? 0 : -1}
aria-label={`ตรายาง: ${s.data.label}`}
className="absolute flex flex-col items-center justify-center overflow-hidden text-center font-bold"
style={{
left: toPx(s.data.position.x, size.width),
top: toPx(s.data.position.y, size.height),
width: w,
height: stampBoxHeight(lay, w),
// มุมเอียง (−180..180) หมุนรอบ**กลางกรอบ** — left/top ยังเป็นมุมซ้ายบนของ
// กรอบ*ก่อนหมุน*เสมอ (bbox = AABB ของกรอบหมุน คำนวณใน geometry)
...(s.data.rotation
? { transform: `rotate(${s.data.rotation}deg)`, transformOrigin: "center" }
: null),
border: `${Math.max(w * 0.02, 1)}px solid ${c}`,
borderRadius: Math.max(w * 0.04, 2),
color: c,
fontFamily: STAMP_FONT_STACK,
fontSize: lay.fontSize,
lineHeight: lay.lineHeight,
pointerEvents: enabled ? "auto" : "none",
cursor: enabled ? "pointer" : "default",
}}
onClick={(e) => {
e.stopPropagation();
if (enabled) onSelect(s.id);
}}
onKeyDown={(e) => {
if (!enabled) return;
// กลืนเฉพาะคีย์ที่เราจัดการเอง — คีย์อื่น (DEL/Escape/H/D/C/B ของ viewer
// ที่ฟังที่ window) ต้อง bubble ต่อ เหตุผลเดียวกับ div กล่องข้อความด้านบน
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
onSelect(s.id);
}
}}
>
{lay.lines.map((ln, i) => (
// บรรทัดว่างจาก wrap ใช้ nbsp กัน div ยุบ — ตัวอักษรใน quotes เป็น U+00A0 จริง ๆ
// (มองเหมือน space ปกติ — ห้ามลบหรือแทนด้วย space)
<div key={i}>{ln || " "}</div>
))}
</div>
);
}
export default function TextboxStampLayer({
size,
textboxes,
@ -275,6 +432,10 @@ export default function TextboxStampLayer({
<div
key={t.id}
title={t.userId === ownUserId ? "ดับเบิลคลิกเพื่อแก้ไขข้อความ" : undefined}
// a11y: กล่องคลิกเลือกได้ = ปุ่ม — ใช้คีย์บอร์ด (Tab/Enter/Space) เลือกได้ด้วย
role="button"
tabIndex={enabled ? 0 : -1}
aria-label={`กล่องข้อความ${t.data.text ? `: ${t.data.text.slice(0, 40)}` : ""}`}
className="absolute whitespace-pre-wrap break-words"
style={{
left: toPx(t.data.position.x, size.width),
@ -293,11 +454,22 @@ export default function TextboxStampLayer({
pointerEvents: enabled ? "auto" : "none",
cursor: enabled ? "text" : "default",
}}
onClick={(e) => {
e.stopPropagation();
if (enabled) onSelect(t.id);
}}
onKeyDown={(e) => {
if (!enabled) return;
// กลืนเฉพาะคีย์ที่เราจัดการเอง (Enter/Space เลือกรายการ) — คีย์อื่น
// (DEL/Backspace ลบ, Escape deselect, H/D/C/B ของ viewer ที่ฟังที่
// window) ต้อง bubble ต่อ: focus ค้างบน item หลังคลิกเลือก (tabIndex)
// แล้วกลืนทุกคีย์ = ปุ่มลบ/ยกเลิกตายหมด (code review 2026-09-28)
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
onSelect(t.id);
}
}}
onDoubleClick={(e) => {
e.stopPropagation();
if (enabled && t.userId === ownUserId) onStartEdit(t.id);
@ -329,39 +501,24 @@ export default function TextboxStampLayer({
onCancel={onCancelComposer}
/>
)}
{/* STAMP (F18/B5) — กรอบข้อความสไตล์ตรายาง: กว้าง = scale% ของหน้า สูง = 0.4 เท่า (§5)
ฟอนต์ย่อให้ label อยู่บรรทัดเดียวเสมอ (ยาวอย่าง "ตรวจสอบแล้ว" ไม่ wrap/ถูกตัด) */}
{stamps.map((s) => {
const w = toPx(s.data.scale, size.width);
const c = s.data.color ?? STAMP_COLOR; // แถวเดิมไม่มี color = สีตรายาง default
return (
<div
{/* STAMP (F18/B5) — กรอบข้อความสไตล์ตรายาง กว้าง = scale% ของหน้า สูง 0.4 เท่า
(§5): คำสั้นอยู่บรรทัดเดียว คำยาว wrap ≤3 บรรทัด (2026-09-28) — ตัว render
อยู่ใน StampItem (memo), โหมดแก้ข้อความ (ปุ่ม "แก้ไขข้อความ" ในแผง selection)
= StampEditor แทนกรอบเดิม */}
{stamps.map((s) =>
editingId === s.id ? (
<StampEditor
key={s.id}
className="absolute flex items-center justify-center overflow-hidden text-center font-bold"
style={{
left: toPx(s.data.position.x, size.width),
top: toPx(s.data.position.y, size.height),
width: w,
height: w * STAMP_HEIGHT_RATIO,
border: `${Math.max(w * 0.02, 1)}px solid ${c}`,
borderRadius: Math.max(w * 0.04, 2),
color: c,
fontFamily: STAMP_FONT_STACK,
fontSize: stampFitFontSize(s.data.label, w, measureStampText),
lineHeight: 1.2,
whiteSpace: "nowrap",
pointerEvents: enabled ? "auto" : "none",
cursor: enabled ? "pointer" : "default",
}}
onClick={(e) => {
e.stopPropagation();
if (enabled) onSelect(s.id);
}}
>
{s.data.label}
</div>
);
})}
size={size}
data={s.data}
enabled={enabled}
onSave={(label) => onCommitEdit(s.id, label)}
onCancel={() => onStartEdit(null)}
/>
) : (
<StampItem key={s.id} s={s} size={size} enabled={enabled} onSelect={onSelect} />
),
)}
</div>
);
}

View file

@ -111,7 +111,16 @@ export type TextboxData = {
/** ตรายาง preset (F18/B5, D7) — scale เป็น % ของความกว้างหน้า (ความกว้างกรอบตรายาง)
* color เป็น optional — ไม่มี = STAMP_COLOR (แถวเดิมก่อนมีตัวเลือกสี) */
export type StampData = { label: string; position: Point; scale: number; color?: string; note?: string };
/** rotation = มุมเอียงของตรายางเป็นองศา (−180..180, หมุนรอบ**กลางกรอบ** — ลบ =
* ทวนเข็มนาฬิกา) ไม่มี field = ไม่เอียง (0°) — แถวเดิมไม่มี ใช้ร่วมกันได้ */
export type StampData = {
label: string;
position: Point;
scale: number;
color?: string;
note?: string;
rotation?: number;
};
/** เลือกตาม column `type` (discriminated โดย enum) — ห้าม loosen เป็น any */
export type AnnotationData =

File diff suppressed because one or more lines are too long

View file

@ -271,3 +271,29 @@ describe("sanitizePdfText — ล้างข้อความเพี้ย
);
});
});
describe("describeAnnotationChange — STAMP มุมเอียง (2026-09-28)", () => {
const base = { label: "สำคัญ", position: { x: 30, y: 20 }, scale: 18 };
it("มุมเอียงเปลี่ยนอย่างเดียว → บรรทัด 'มุมเอียง' และไม่ติด geometry ผลพลองของ AABB ที่หมุน", () => {
const changes = describeAnnotationChange("STAMP", base, { ...base, rotation: -15 });
expect(changes).toContain("มุมเอียง: 0° → -15°");
expect(changes.some((c) => c.includes("ย้ายตำแหน่ง") || c.includes("ปรับขนาด"))).toBe(false);
});
it("มุมเอียงเปลี่ยนทั้งต้นทางปลายทาง", () => {
expect(describeAnnotationChange("STAMP", { ...base, rotation: 45 }, { ...base, rotation: 0 })).toEqual([
"มุมเอียง: 45° → 0°",
]);
});
it("ข้อความเปลี่ยน → ไม่ติด geometry ผลพลองของการจัดบรรทัด (แบบ TEXTBOX)", () => {
const changes = describeAnnotationChange(
"STAMP",
{ ...base, rotation: 30 },
{ label: "สำเนาถูกต้อง ใช้เอกสารฉบับนี้แทนต้นฉบับได้", position: { x: 30, y: 20 }, scale: 18, rotation: 30 },
);
expect(changes.length).toBe(1);
expect(changes[0]).toContain("ข้อความตรายาง");
});
});

View file

@ -1,13 +1,24 @@
import { describe, expect, it } from "vitest";
import type { AnnotationData, ShapeData } from "@/db/schema";
import {
STAMP_FONT_RATIO,
STAMP_HEIGHT_RATIO,
STAMP_MAX_LINES,
STAMP_TEXT_WIDTH_RATIO,
annotationBBox,
clampStampIntoPage,
hitTestPath,
hitTestShape,
rotatePoint,
rotatedRectAABB,
scaleAnnotationData,
stampBoxHeight,
stampFitFontSize,
stampLayout,
stampRect,
textboxMaxWidthPct,
translateAnnotationData,
wrapStampLabel,
} from "@/utils/geometry";
// เคล็ดลับจาก docstring ของ annotationBBox: size 100×100 ทำให้ px = % โดยตรง
@ -251,6 +262,69 @@ describe("annotationBBox", () => {
});
});
describe("stampLayout / wrapStampLabel / stampBoxHeight — ตรายางหลายบรรทัด (2026-09-28)", () => {
// measure จำลองเชิงเส้น: อักษรกว้าง 0.6 เท่าของฟอนต์ — เพียงพอต่อการตรวจสูตร
const measure = (t: string, fs: number) => t.length * fs * 0.6;
const w = 100;
it("คำสั้น = บรรทัดเดียวฟอนต์เต็ม กรอบสูง 0.4w เหมือนก่อนมีการ wrap", () => {
const lay = stampLayout("สำคัญ", w, measure);
expect(lay.lines).toEqual(["สำคัญ"]);
expect(lay.fontSize).toBe(w * STAMP_FONT_RATIO);
expect(stampBoxHeight(lay, w)).toBeCloseTo(w * STAMP_HEIGHT_RATIO);
});
it("คำยาว = wrap หลายบรรทัด ฟอนต์ใหญ่กว่าการย่อบรรทัดเดียว กรอบสูงขึ้น", () => {
const label = "สำเนาถูกต้อง ใช้เอกสารฉบับนี้แทนต้นฉบับได้";
const lay = stampLayout(label, w, measure);
expect(lay.lines.length).toBeGreaterThan(1);
expect(lay.lines.length).toBeLessThanOrEqual(STAMP_MAX_LINES);
expect(lay.fontSize).toBeGreaterThan(stampFitFontSize(label, w, measure));
expect(stampBoxHeight(lay, w)).toBeGreaterThan(w * STAMP_HEIGHT_RATIO);
// ทุกบรรทัดกว้างไม่เกินขอบใน
for (const ln of lay.lines) {
expect(measure(ln, lay.fontSize)).toBeLessThanOrEqual(
w * STAMP_TEXT_WIDTH_RATIO + 1e-9,
);
}
});
it("คำยาวพอย่อได้ (30 ตัว) = ฟอนต์หยุดที่ค่าที่ให้บรรทัดเข้าเกณฑ์ STAMP_MAX_LINES", () => {
const lay = stampLayout("ก".repeat(30), w, measure);
expect(lay.lines.length).toBeLessThanOrEqual(STAMP_MAX_LINES);
expect(lay.lines.join("")).toBe("ก".repeat(30));
});
it("คำยาวเกินไป (50 ตัว) = หยุดย่อที่ฟอนต์ระดับ floor แลกกับบรรทัดเกิน (อ่านออกกว่าฟอนต์จิ๋ว)", () => {
const lay = stampLayout("ก".repeat(50), w, measure);
// STAMP_FONT_FLOOR_RATIO = 0.1 — ฟอนต์ไม่ตกต่ำกว่านี้แม้บรรทัดจะเกินเกณฑ์
expect(lay.fontSize).toBeGreaterThanOrEqual(w * 0.1 - 1e-9);
expect(lay.lines.join("")).toBe("ก".repeat(50));
});
it("wrapStampLabel: แยกตามช่องว่าง ส่วนคำเดี่ยวยาวเกินตัดที่ตัวอักษร", () => {
// อักษรกว้างเท่าฟอนต์เลย (measure ตรง) บรรทัดกว้าง 24 → "ab cd" แยกสองบรรทัด
expect(wrapStampLabel("ab cd", 10, 24, (t, fs) => t.length * fs)).toEqual([
"ab",
"cd",
]);
// "abcdef" (6×10=60 > 24) ไม่มีช่องว่าง — ตัดที่อักษร บรรทัดละ 2 ตัว = 3 บรรทัด ครบทุกตัว
const cut = wrapStampLabel("abcdef", 10, 24, (t, fs) => t.length * fs);
expect(cut).toEqual(["ab", "cd", "ef"]);
});
it("annotationBBox STAMP คำยาวสูงเกิน 0.4w (ประมาณด้วยความกว้างต่ออักษร)", () => {
const bbox = annotationBBox(
{
type: "STAMP",
data: { label: "ก".repeat(30), position: { x: 5, y: 5 }, scale: 20 },
},
S,
);
expect(bbox.height).toBeGreaterThan(20 * STAMP_HEIGHT_RATIO);
});
});
describe("translateAnnotationData", () => {
const cases: { name: string; data: AnnotationData; dx: number; dy: number }[] = [
{
@ -452,3 +526,137 @@ describe("hitTestShape", () => {
expect(hitTestShape(data, { x: 80, y: 80 }, 3, S2)).toBe(false);
});
});
describe("annotationBBox STAMP — measure param (ขอบล่างกรอบเกินตรายาง 2026-09-28)", () => {
const S = { width: 100, height: 100 }; // % = px โดยตรง
// 10 อักษร กว้าง w=18: base=3.96, maxW=16.2 — ตามฟอนต์ของแต่ละเครื่องอาจ 1 หรือ 2 บรรทัด
const data = { label: "ตรวจสอบแล้ว", position: { x: 10, y: 20 }, scale: 18 };
const minH = 18 * STAMP_HEIGHT_RATIO; // 0.4w — สูงขั้นต่ำของคำสั้น 1 บรรทัด
it("ส่ง measure ที่ข้อความแคบพอ 1 บรรทัด → สูง = 0.4w เหมือนที่ StampItem render จริง", () => {
const narrow = (text: string, fs: number) => text.length * fs * 0.3;
const box = annotationBBox({ type: "STAMP", data }, S, narrow);
expect(box.x).toBeCloseTo(10);
expect(box.width).toBeCloseTo(18);
expect(box.height).toBeCloseTo(minH);
});
it("คำพอดี ๆ ที่ต้องย่อฟอนต์นิดเดียว = บรรทัดเดียว (ไม่ตก 2 บรรทัด) — กติกา 2026-09-28 รอบสอง", () => {
// ฟอนต์ไทยจริงเฉลี่ย ~0.45/อักษร: "ตรวจสอบแล้ว" ย่อเหลือ ~91% ของ base ก็พอบรรทัดเดียว
const real = (text: string, fs: number) => text.length * fs * 0.45;
const lay = stampLayout(data.label, 18, real);
expect(lay.lines).toEqual([data.label]);
expect(lay.fontSize).toBeLessThan(18 * STAMP_FONT_RATIO); // ย่อจริง
expect(lay.fontSize).toBeGreaterThanOrEqual(18 * 0.1); // ไม่ต่ำกว่า floor
const box = annotationBBox({ type: "STAMP", data }, S, real);
expect(box.height).toBeCloseTo(minH);
});
it("คำยาวเกิน floor (ย่อไม่ช่วย) = wrap หลายบรรทัด สูงเกิน 0.4w — ใช้ measure ที่ส่งเข้ามาจริง", () => {
const wide = (text: string, fs: number) => text.length * fs * 1.2;
const box = annotationBBox({ type: "STAMP", data }, S, wide);
expect(box.height).toBeGreaterThan(minH + 1);
// ไม่ส่ง measure (estimate ฝั่ง server) ก็เข้าเส้นทาง wrap เหมือนกันกับคำยาวระดับนี้
const longData = { ...data, label: "ก".repeat(30) };
const est = annotationBBox({ type: "STAMP", data: longData }, S);
expect(est.height).toBeGreaterThan(minH + 1);
});
});
describe("ตรายางเอียง — rotatePoint / stampRect / rotatedRectAABB (2026-09-28)", () => {
it("rotatePoint: 0° = จุดเดิม, 90° ตามเข็มรอบ origin แกน x ไปแกน y", () => {
const p = { x: 10, y: 0 };
expect(rotatePoint(p, { x: 0, y: 0 }, 0)).toEqual(p); // early return ตรง ๆ
const q = rotatePoint(p, { x: 0, y: 0 }, 90);
expect(q.x).toBeCloseTo(0); // cos(90°) มี float ตกค้าง ตรวจแบบใกล้เคียง
expect(q.y).toBeCloseTo(10);
// ย้อนหมุนกลับตำแหน่งเดิม
const back = rotatePoint(rotatePoint(p, { x: 0, y: 0 }, 37), { x: 0, y: 0 }, -37);
expect(back.x).toBeCloseTo(p.x);
expect(back.y).toBeCloseTo(p.y);
});
it("stampRect: คืนกรอบก่อนหมุน + rotation ของ data (ไม่มี field = 0)", () => {
const data = { label: "สำคัญ", position: { x: 10, y: 20 }, scale: 18 };
const r = stampRect(data, S);
expect(r).toMatchObject({ x: 10, y: 20, width: 18, rotation: 0 });
const tilted = stampRect({ ...data, rotation: -30 }, S);
expect(tilted).toMatchObject({ x: 10, y: 20, width: 18, rotation: -30 });
// กรอบก่อนหมุนขนาดเท่ากันไม่ว่าจะเอียงเท่าไหร่ (หมุนทีหลังรอบ center)
expect(tilted.height).toBeCloseTo(r.height);
});
it("rotatedRectAABB: 90° สลับกว้าง-สูงรอบ center เดิม, 45° = แนวทะเลียบใหญ่สุด", () => {
const r = { x: 10, y: 20, width: 100, height: 50, rotation: 90 };
const a = rotatedRectAABB(r);
expect(a.width).toBeCloseTo(50);
expect(a.height).toBeCloseTo(100);
expect(a.x + a.width / 2).toBeCloseTo(60); // center คงเดิม
expect(a.y + a.height / 2).toBeCloseTo(45);
const b = rotatedRectAABB({ ...r, rotation: 45 });
expect(b.width).toBeCloseTo((100 + 50) / Math.SQRT2);
expect(b.height).toBeCloseTo((100 + 50) / Math.SQRT2);
});
it("annotationBBox STAMP เอียง = AABB ของกรอบหมุน (ไม่ใช่กรอบก่อนหมุน)", () => {
const data = { label: "สำคัญ", position: { x: 10, y: 20 }, scale: 20, rotation: 90 };
const flat = annotationBBox({ type: "STAMP", data: { ...data, rotation: undefined } }, S);
const tilted = annotationBBox({ type: "STAMP", data }, S);
expect(tilted.width).toBeCloseTo(flat.height);
expect(tilted.height).toBeCloseTo(flat.width);
expect(tilted.x + tilted.width / 2).toBeCloseTo(flat.x + flat.width / 2);
});
it("translate/scale ของ STAMP เอียงยังเก็บ rotation ไปด้วย (spread ...data)", () => {
const data: AnnotationData = {
label: "สำคัญ",
position: { x: 10, y: 20 },
scale: 18,
rotation: -15,
};
expect(translateAnnotationData(data, 5, 5)).toMatchObject({ rotation: -15 });
expect(scaleAnnotationData(data, { x: 0, y: 0 }, 2, 2)).toMatchObject({
rotation: -15,
scale: 36,
});
});
});
describe("clampStampIntoPage — ตรายางเอียงชิดขอบหมุนแล้วเลื่อนกลับเข้าหน้า (2026-09-28)", () => {
const mk = (position: { x: number; y: number }, rotation: number) => ({
label: "สำคัญ",
position,
scale: 20,
rotation,
});
it("กลางหน้า (หมุนแล้วยังอยู่ใน 0–100) = position เดิมเป๊ะ", () => {
const d = mk({ x: 40, y: 40 }, 45);
expect(clampStampIntoPage(d)).toEqual(d.position);
});
it("ชิดขอบบน + หมุน 45° (AABB ล้นบน) → เลื่อนลงเข้าหน้า อีกแกนไม่ขยับ", () => {
// w=20 h=8 (คำสั้น estimate) rot 45 → AABB 19.8×19.8 รอบ center (11, 5) → y เริ่ม −4.9
const out = clampStampIntoPage(mk({ x: 1, y: 1 }, 45));
expect(out.y).toBeGreaterThan(1);
expect(out.x).toBe(1); // AABB x ≈ 1.1 ไม่ล้น → แกน x ไม่ขยับ
const aabb = rotatedRectAABB(
stampRect({ label: "สำคัญ", position: out, scale: 20, rotation: 45 }, S),
);
expect(aabb.y).toBeGreaterThanOrEqual(-1e-9);
});
it("ชิดขอบขวา + หมุน 90° → เลื่อนซ้ายกลับเข้าหน้า (แกน x ลด)", () => {
// w=20 h=8 rot 90 → AABB 8×20 รอบ center (100, 44) → x+w = 104 เกินขอบขวา 4
const d = mk({ x: 90, y: 40 }, 90);
const out = clampStampIntoPage(d);
expect(out.x).toBeLessThan(d.position.x);
const aabb = rotatedRectAABB(stampRect({ ...d, position: out }, S));
expect(aabb.x + aabb.width).toBeLessThanOrEqual(100 + 1e-9);
});
it("ไม่หมุน (0°) = พฤติกรรม clamp rect เดิม: ในหน้าอยู่แล้วไม่ขยับ", () => {
const d = mk({ x: 10, y: 10 }, 0);
expect(clampStampIntoPage(d)).toEqual(d.position);
});
});

View file

@ -0,0 +1,115 @@
// @vitest-environment jsdom
// เทสต์ serialize/segmentsToHtml ของ editor กล่องข้อความ (utils/textbox-editor.ts)
// ที่ต้องมี DOM จริง — แยกจากชุด pure utils (environment node ตาม vitest.config.ts)
// ด้วย pragma ต่อไฟล์ เหมือน tests/hooks/useCustomSelection-dom.test.ts
// พฤติกรรม editor จริง (keydown/execCommand/gesture) ยืนยันด้วย Playwright แทน
import { describe, expect, it } from "vitest";
import { segmentsToHtml, serializeEditor } from "@/utils/textbox-editor";
/** div root พร้อม innerHTML ตามที่ contentEditable ค้างไว้ตอน blur */
function mount(html: string): HTMLElement {
const el = document.createElement("div");
el.innerHTML = html;
return el;
}
describe("serializeEditor — ขึ้นบรรทัดใหม่", () => {
it("โครงสร้าง Enter ของ Chrome text→div ต่อท้าย → ได้ newline คั่น (เดิมหายเป็น abcdef)", () => {
const r = serializeEditor(mount("abc<div>def</div>"));
expect(r.text).toBe("abc\ndef");
});
it("หลายบรรทัดเป็น div เรียงกันทั้งก้อน → คั่นครบทุกบรรทัด", () => {
const r = serializeEditor(mount("<div>a</div><div>b</div><div>c</div>"));
expect(r.text).toBe("a\nb\nc");
});
it("<br> (insertLineBreak ของ Enter ที่เราดักเอง) → newline เหมือนกัน", () => {
const r = serializeEditor(mount("abc<br>def"));
expect(r.text).toBe("abc\ndef");
});
it("<br> ตามด้วย <div> ติดกัน → newline เดียว ไม่ซ้ำสอง", () => {
const r = serializeEditor(mount("a<br><div>b</div>"));
expect(r.text).toBe("a\nb");
});
it("<p> ก็เป็น block คั่นบรรทัดเหมือน <div>", () => {
const r = serializeEditor(mount("abc<p>def</p>"));
expect(r.text).toBe("abc\ndef");
});
});
describe("serializeEditor — ตัวหนาเป็น segments", () => {
it("<b> กลางข้อความ → 3 segments ปกติ/หนา/ปกติ + text ต่อกันไม่มีช่องว่าง", () => {
const r = serializeEditor(mount("a<b>bold</b>c"));
expect(r.text).toBe("aboldc");
expect(r.segments).toEqual([
{ text: "a" },
{ text: "bold", bold: true },
{ text: "c" },
]);
});
it("segment ติดกันสไตล์เดียวกัน (รวมข้าม <b> สองอัน) → merge เป็น segment เดียว", () => {
const r = serializeEditor(mount("<b>a</b><b>b</b>"));
expect(r.segments).toEqual([{ text: "ab", bold: true }]);
});
it("newline ที่รอยต่อ block → segment ปกติของตัวเอง (newline ไม่มี glyph จึงไม่กระทบการแสดงผล)", () => {
const r = serializeEditor(mount("<b>a</b><div><b>b</b></div>"));
expect(r.text).toBe("a\nb");
expect(r.segments).toEqual([
{ text: "a", bold: true },
{ text: "\n" },
{ text: "b", bold: true },
]);
});
});
describe("serializeEditor — whitespace ท้าย", () => {
it("<br> ปิดท้ายที่ browser เติมให้ contentEditable → ไม่เหลือ newline ค้างใน DB", () => {
const r = serializeEditor(mount("hello<br>"));
expect(r.text).toBe("hello");
expect(r.segments).toEqual([{ text: "hello" }]);
});
it("newline ท้ายหลายตัว + space ตัดหมด และตัดจาก segments ให้ text กับ segments ตรงกัน", () => {
const r = serializeEditor(mount("a<b>b</b><br><br> "));
expect(r.text).toBe("ab");
expect(r.segments).toEqual([{ text: "a" }, { text: "b", bold: true }]);
});
it("ก้อนว่างทั้งหมด (แค่ <br>) → text ว่าง + segments ว่าง", () => {
const r = serializeEditor(mount("<br>"));
expect(r.text).toBe("");
expect(r.segments).toEqual([]);
});
it("space หน้าไม่ถูกตัด (เว้นวรรคตั้งใจยังเก็บ)", () => {
const r = serializeEditor(mount(" a"));
expect(r.text).toBe(" a");
});
});
describe("segmentsToHtml", () => {
it("escape HTML ของข้อความ + แปลง \\n เป็น <br>", () => {
expect(segmentsToHtml("a<b>&x\ny")).toBe("a&lt;b&gt;&amp;x<br>y");
});
it("segments bold → <b> ตามส่วน", () => {
expect(
segmentsToHtml("ab", [{ text: "a" }, { text: "b", bold: true }]),
).toBe("a<b>b</b>");
});
it("round-trip: segmentsToHtml → DOM → serializeEditor ได้ text/segments เดิม", () => {
const text = "หนา\nปกติ\nธรรมดา";
const segments = [
{ text: "หนา", bold: true },
{ text: "\nปกติ\nธรรมดา" },
];
const html = segmentsToHtml(text, segments);
expect(serializeEditor(mount(html))).toEqual({ text, segments });
});
});

View file

@ -68,28 +68,40 @@ export const STAMP_PRESETS: readonly string[] = [
"ตรวจสอบแล้ว",
"สำคัญ",
];
/** ความยาวคำตรายางสูงสุดที่ช่องกรอกของ UI ยอมรับ (preset + คำที่พิมพ์เอง) —
* validator ของ action ยอมถึง MAX_TEXT_CHARS แต่ตรายางเป็นข้อความบรรทัดเดียวที่
* stampFitFontSize ย่อให้พอดีกรอบเสมอ ยาวเกินระดับนี้ฟอนต์เล็กจนอ่านไม่ออก */
export const STAMP_MAX_LABEL_CHARS = 50;
/** ฟอนต์ตรายางบนจอ — ตั้งชัดทั้งตอนวัด (canvas measure) และตอน render เพื่อให้ความกว้าง
* ที่ stampFitFontSize คำนวณตรงกับที่แสดงจริง (ตัวหนา 700 เหมือน class font-bold) —
* ใช้ร่วมทั้ง render (TextboxStampLayer) และ preview ในเมนูของ tool (PDFViewer) */
export const STAMP_FONT_STACK = "system-ui, sans-serif";
// สูตรจัดวางข้อความตรายาง (constants + stampFitFontSize + wrap/layout หลายบรรทัด)
// ย้ายไป utils/geometry.ts แล้ว (ใช้ร่วมกับ annotationBBox) — re-export ให้ import
// เดิมที่อ้างจากไฟล์นี้ไม่ต้องแก้
export {
STAMP_FONT_RATIO,
STAMP_TEXT_WIDTH_RATIO,
stampFitFontSize,
stampLayout,
stampBoxHeight,
wrapStampLabel,
} from "@/utils/geometry";
/** ความกว้างกรอบตรายางเริ่มต้น (% ของความกว้างหน้า) */
export const STAMP_DEFAULT_SCALE_PCT = 18;
/** สีตรายาง (ขอบ + ตัวอักษร) — ใช้ทั้ง render บนจอและ export */
export const STAMP_COLOR = "#b91c1c";
/** ขนาดตัวอักษรในตรายาง = สัดส่วนของความกว้างกรอบ (ค่าสูงสุด — ย่อลงเมื่อข้อความยาวเกินกรอบ) */
export const STAMP_FONT_RATIO = 0.22;
/** ความกว้างข้อความสูงสุดที่ยอมให้ = สัดส่วนของกรอบ (เว้นระยะจากขอบใน) */
export const STAMP_TEXT_WIDTH_RATIO = 0.9;
/** ขนาดตัวอักษรตรายางที่ label อยู่บรรทัดเดียว — เริ่มที่ STAMP_FONT_RATIO แล้วย่อตาม
* สัดส่วนที่ข้อความล้น (measure ผูกกับเชิงเส้นกับขนาดฟอนต์ วัดครั้งเดียวพอ) — measure เป็น
* ความกว้างจริงของฝั่งนั้น ๆ: จอ = canvas measureText, export = font.widthOfTextAtSize
* (หากไม่ย่อ label ยาวอย่าง "ตรวจสอบแล้ว" จะ wrap 2 บรรทัดและล้นความสูงกรอบถูกตัดหาย) */
export function stampFitFontSize(
label: string,
boxWidth: number,
measure: (text: string, fontSize: number) => number,
): number {
const base = boxWidth * STAMP_FONT_RATIO;
const textW = measure(label, base);
const maxW = boxWidth * STAMP_TEXT_WIDTH_RATIO;
return textW <= maxW ? base : (base * maxW) / textW;
let stampMeasureCtx: CanvasRenderingContext2D | null = null;
/** วัดความกว้างข้อความตรายางด้วย canvas บนจอ (measure ของ stampFitFontSize เมื่อ render/
* preview บนหน้าเว็บ) — ไม่มี document (SSR) คืน 0 ให้ใช้ขนาด base ตรง ๆ */
export function measureStampText(text: string, fontSize: number): number {
if (typeof document === "undefined") return 0;
stampMeasureCtx ??= document.createElement("canvas").getContext("2d");
if (!stampMeasureCtx) return 0;
stampMeasureCtx.font = `bold ${fontSize}px ${STAMP_FONT_STACK}`;
return stampMeasureCtx.measureText(text).width;
}
// คลาสอักขระขยะที่ปนมากับข้อความสกัดจาก textLayer ของ PDF ไทยบางไฟล์ (สร้างแบบ
@ -378,8 +390,9 @@ export function describeAnnotationChange(
if (!textChanged || g === "ย้ายตำแหน่ง") changes.push(g);
}
} else if ("scale" in prev && "scale" in next) {
// STAMP — ข้อความ/สี (fallback STAMP_COLOR)/ขนาด + ย้าย/ขยาย
if (prev.label !== next.label) {
// STAMP — ข้อความ/สี (fallback STAMP_COLOR)/ขนาด/มุมเอียง + ย้าย/ขยาย
const labelChanged = prev.label !== next.label;
if (labelChanged) {
changes.push(
`ข้อความตรายาง: "${shortChangeText(prev.label)}" → "${shortChangeText(next.label)}"`,
);
@ -390,7 +403,17 @@ export function describeAnnotationChange(
if (prev.scale !== next.scale) {
changes.push(`ขนาด: ${numLabel(prev.scale)}% → ${numLabel(next.scale)}%`);
}
const pr = prev.rotation ?? 0;
const nr = next.rotation ?? 0;
if (pr !== nr) changes.push(`มุมเอียง: ${pr}° → ${nr}°`);
// ตำแหน่ง/ขนาด — เมื่อข้อความหรือมุมเอียงเปลี่ยน geometry ที่ต่างเป็นผลพลองของ
// การจัดวางทั้งหมด (label ยาว → กรอบสูงตามจำนวนบรรทัด, หมุน → AABB ขยายสมมาตร
// รอบ center ทั้งตำแหน่ง+ขนาด ขณะที่กรอบจริงอยู่ที่เดิม) จึงไม่แสดง geometry เลย
// — ส่วนขยาย/ย้ายจริงเป็น action แยกที่เทียบโดยไม่มี label/มุมเปลี่ยน (แบบ
// TEXTBOX ที่ข้าม "ปรับขนาด" เมื่อข้อความเปลี่ยน — code review 2026-09-28)
if (!labelChanged && pr === nr) {
changes.push(...geometryChangeLabels(type, prev, next));
}
}
return changes;
}

View file

@ -1,4 +1,4 @@
import type { AnnotationData, AnnotationType, ShapeData } from "@/db/schema";
import type { AnnotationData, AnnotationType, ShapeData, StampData } from "@/db/schema";
import {
clamp,
pointToPx,
@ -60,9 +60,182 @@ export function textboxMaxWidthPct(leftX: number): number {
return Math.min(TEXTBOX_MAX_WIDTH_PCT, Math.max(100 - clamp(leftX, 0, 100), 1));
}
// STAMP (F18/B5 — §5): กรอบตรายางกว้าง = scale% ของความกว้างหน้า สูง = 0.4 เท่าของความกว้าง
// (สูตรเดียวกันทั้ง render บนจอ, bbox ของ selection และการวาดใน export)
// STAMP (F18/B5 — §5): กรอบตรายางกว้าง = scale% ของความกว้างหน้า — สูตรจัดวางข้อความทั้งชุด
// (บรรทัด/ฟอนต์/ความสูงกรอบ) อยู่ที่นี่ใช้ร่วมทั้ง render บนจอ, preview ในเมนู, bbox ของ
// selection และการวาดใน export — utils/annotation-display.ts re-export ตัวที่เดิมอยู่ที่นั่น
/** ความสูงกรอบตรายางขั้นต่ำ (คำสั้น 1 บรรทัดคงสัดส่วนหน้าตาเดิม) — สัดส่วนของความกว้างกรอบ */
export const STAMP_HEIGHT_RATIO = 0.4;
/** ขนาดตัวอักษรในตรายาง = สัดส่วนของความกว้างกรอบ (ค่าสูงสุด — ย่อลงเมื่อข้อความยาว) */
export const STAMP_FONT_RATIO = 0.22;
/** ความกว้างข้อความสูงสุดที่ยอมให้ต่อบรรทัด = สัดส่วนของกรอบ (เว้นระยะจากขอบใน) */
export const STAMP_TEXT_WIDTH_RATIO = 0.9;
/** จำนวนบรรทัดสูงสุดของข้อความตรายาง (2026-09-28) — เกินกว่านี้ย่อฟอนต์ต่อจนเข้าเกณฑ์ */
export const STAMP_MAX_LINES = 3;
/** เพดานล่างของฟอนต์ (สัดส่วนของความกว้างกรอบ) — ต่ำกว่านี้อ่านไม่ออก ยอมให้บรรทัดเกินแทน */
export const STAMP_FONT_FLOOR_RATIO = 0.1;
/** ความกว้างเฉลี่ยต่ออักษร (สัดส่วนของฟอนต์) — measure ประมาณเมื่อไม่มี measure จริง (bbox) */
const STAMP_CHAR_W = 0.58;
/** ขนาดตัวอักษรตรายางที่ label อยู่บรรทัดเดียว — เริ่มที่ STAMP_FONT_RATIO แล้วย่อตาม
* สัดส่วนที่ข้อความล้น (measure ผูกกับเชิงเส้นกับขนาดฟอนต์ วัดครั้งเดียวพอ) — measure เป็น
* ความกว้างจริงของฝั่งนั้น ๆ: จอ = canvas measureText, export = font.widthOfTextAtSize */
export function stampFitFontSize(
label: string,
boxWidth: number,
measure: (text: string, fontSize: number) => number,
): number {
const base = boxWidth * STAMP_FONT_RATIO;
const textW = measure(label, base);
const maxW = boxWidth * STAMP_TEXT_WIDTH_RATIO;
return textW <= maxW ? base : (base * maxW) / textW;
}
/** ผลการจัดวางข้อความตรายางในกรอบ — บรรทัดที่ตัดแล้ว + ขนาดฟอนต์ + lineHeight */
export type StampLayout = { lines: string[]; fontSize: number; lineHeight: number };
/** ตัด label เป็นบรรทัด ๆ กว้างไม่เกิน maxWidth — เว้นคำที่ช่องว่าง ส่วนคำเดี่ยวยาวเกิน
* (ไทยไม่มีช่องว่างคั่น) ตัดที่ตัวอักษรตรง ๆ */
export function wrapStampLabel(
label: string,
fontSize: number,
maxWidth: number,
measure: (text: string, fontSize: number) => number,
): string[] {
const lines: string[] = [];
let cur = "";
for (const word of label.split(/\s+/)) {
if (!word) continue;
// คำเดี่ยวยาวเกินบรรทัด — ตัดที่อักษร
if (measure(word, fontSize) > maxWidth) {
for (const ch of word) {
if (cur && measure(cur + ch, fontSize) > maxWidth) {
lines.push(cur);
cur = "";
}
cur += ch;
}
continue;
}
const candidate = cur ? `${cur} ${word}` : word;
if (measure(candidate, fontSize) <= maxWidth) cur = candidate;
else {
lines.push(cur);
cur = word;
}
}
if (cur) lines.push(cur);
return lines.length ? lines : [""];
}
/** จัดวางข้อความตรายางในกรอบกว้าง boxWidth (2026-09-28): คำสั้น = บรรทัดเดียวฟอนต์เต็ม
* (พฤติกรรมเดิม) — คำยาวพอดี ๆ = **ย่อฟอนต์ให้อยู่บรรทัดเดียวก่อน** ลงได้จนถึง
* STAMP_FONT_FLOOR_RATIO (คำอย่าง "ตรวจสอบแล้ว" ย่อ ~25% ก็พอ จะได้บรรทัดเดียว
* กลับคืน ไม่ตก 2 บรรทัด — คำขอผู้ใช้ 2026-09-28 รอบสอง) — คำยาวจนย่อถึง floor แล้ว
* ยังไม่พอ = กลับไปที่ฟอนต์ใหญ่แล้ว wrap หลายบรรทัด (ไม่เกิน STAMP_MAX_LINES) ให้
* ฟอนต์ใหญ่กว่าการย่อบรรทัดเดียวจนแทบอ่านไม่ออก — ไล่ขนาดฟอนต์จากใหญ่ไปเล็ก
* (×0.92 ต่อรอบ) จนจำนวนบรรทัดเข้าเกณฑ์ หรือถึงเพดานล่าง (ยอมให้เกินบรรทัด
* แลกกับฟอนต์ที่ยังอ่านออก) */
export function stampLayout(
label: string,
boxWidth: number,
measure: (text: string, fontSize: number) => number,
): StampLayout {
const base = boxWidth * STAMP_FONT_RATIO;
const maxW = boxWidth * STAMP_TEXT_WIDTH_RATIO;
const floor = boxWidth * STAMP_FONT_FLOOR_RATIO;
const single = stampFitFontSize(label, boxWidth, measure);
// ย่อฟอนต์เป็นบรรทัดเดียวได้โดยไม่ต่ำกว่า floor → บรรทัดเดียว (เจตนาเดิมของ
// F18 "ยาวอย่าง 'ตรวจสอบแล้ว' ไม่ wrap" — การ wrap ที่ฟอนต์เต็มทันทีทำคำพอดี ๆ
// ตกบรรทัดโดยไม่จำเป็น) — single ≤ base เสมอ (stampFitFontSize คืน base เมื่อ
// พอดี หรือ base×maxW/textW < base เมื่อล้น) จึงใช้ตรง ๆ ได้
if (single >= floor) return { lines: [label], fontSize: single, lineHeight: 1.2 };
// ย่อจนต่ำกว่า floor = อ่านไม่ออก — ฟอนต์กลับไปใหญ่แล้ว wrap หลายบรรทัดแทน
let fontSize = base;
let lines = wrapStampLabel(label, fontSize, maxW, measure);
while (lines.length > STAMP_MAX_LINES && fontSize * 0.92 >= floor) {
fontSize *= 0.92;
lines = wrapStampLabel(label, fontSize, maxW, measure);
}
return { lines, fontSize, lineHeight: 1.2 };
}
/** ความสูงกรอบตรายางจาก layout — ข้อความหลายบรรทัดสูงขึ้นตามจำนวนบรรทัด (พื้นที่ว่าง
* บน+ล่างรวม 13.6% ของความกว้าง = 0.4w เดิม − ข้อความ 1 บรรทัดที่ฟอนต์เต็ม ทำให้คำ
* สั้น 1 บรรทัดสูง 0.4w เป๊ะเหมือนก่อนมีการ wrap) แต่ไม่ต่ำกว่า STAMP_HEIGHT_RATIO */
export function stampBoxHeight(layout: StampLayout, boxWidth: number): number {
const textH = layout.lines.length * layout.fontSize * layout.lineHeight;
return Math.max(boxWidth * STAMP_HEIGHT_RATIO, textH + boxWidth * 0.136);
}
/** จัดวางข้อความตรายางแบบไม่ต้องมี measure จริง (ใช้ความกว้างเฉลี่ยต่ออักษร) — สำหรับ
* annotationBBox/hit-test ที่เป็น pure function รันได้ทั้งฝั่ง server — ผลใกล้เคียง
* ของจริงพอสำหรับการชี้เลือก (จอจริงวัดด้วย measureStampText ของ annotation-display) */
export function stampLayoutEstimate(label: string, boxWidth: number): StampLayout {
return stampLayout(label, boxWidth, (text, fs) => text.length * fs * STAMP_CHAR_W);
}
/** กรอบตรายาง (ก่อนหมุน) + มุมเอียง หน่วย px ตาม size — single source ให้ทั้ง
* annotationBBox (ครอบเป็น AABB ของกรอบหมุนแล้ว) และกรอบ selection ที่วาดกรอบ
* หมุนจริง (ต้องการกรอบเดิม + มุมแยกกันจึงใช้ CSS transform หมุนรอบกลาง) */
export function stampRect(
d: Pick<StampData, "label" | "position" | "scale" | "rotation">,
size: Size,
measure?: (text: string, fontSize: number) => number,
): Rect & { rotation: number } {
const w = toPx(d.scale, size.width);
const p = pointToPx(d.position, size);
const lay = measure ? stampLayout(d.label, w, measure) : stampLayoutEstimate(d.label, w);
return {
x: p.x,
y: p.y,
width: w,
height: stampBoxHeight(lay, w),
rotation: d.rotation ?? 0,
};
}
/** หมุนจุดรอบ center ด้วยองศา (บวก = ตามเข็มนาฬิกา ตาม CSS rotate) — ใช้แปลงพิกัด
* มุมกรอบตรายางเอียง (anchor ของ handle scale) และแปลง pointer เข้า frame ของกรอบ
* (ส่ง deg เป็นลบ = ย้อนหมุน) — หน่วยใดก็ได้ตรงกันทั้งคู่ (% หรือ px) */
export function rotatePoint(p: Point, center: Point, deg: number): Point {
if (!deg) return p;
const rad = (deg * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const dx = p.x - center.x;
const dy = p.y - center.y;
return { x: center.x + dx * cos - dy * sin, y: center.y + dx * sin + dy * cos };
}
/** เลื่อน position ของตรายาง (%, มุมกรอบ*ก่อนหมุน*) แค่น้อยที่สุดให้ AABB ของกรอบ
* หมุนแล้วกลับเข้า 0–100 ทุกด้าน — ใช้ตอนตั้งมุมเอียงใหม่ (patch/ลากหมุน): หมุนที่
* ตำแหน่งชิดขอบแล้วกรอบอาจยื่นเกินขอบหน้า ต้องดึงกลับทันที ไม่ใช่รอให้เด้งตอน
* ลากครั้งถัดไป (code review 2026-09-28) — rotation = 0 กลางหน้า = ไม่ขยับ */
export function clampStampIntoPage(
d: Pick<StampData, "label" | "position" | "scale" | "rotation">,
): Point {
const aabb = rotatedRectAABB(stampRect(d, { width: 100, height: 100 }));
const dx =
aabb.x < 0 ? -aabb.x : aabb.x + aabb.width > 100 ? 100 - (aabb.x + aabb.width) : 0;
const dy =
aabb.y < 0 ? -aabb.y : aabb.y + aabb.height > 100 ? 100 - (aabb.y + aabb.height) : 0;
return { x: d.position.x + dx, y: d.position.y + dy };
}
/** AABB (กรอบตรงแกน) ของ rect หลังหมุน `rotation` องศารอบ**กลางกรอบ** — สำหรับ
* hit-test/tooltip/clamp ของตรายางเอียง: กรอบจริงเอียง AABB จึงกว้าง/สูงกว่ากรอบ
* เดิม (ที่ 45° ใหญ่สุด = แนวทะเลียบ) — จุดที่อยู่ใน AABB แต่นอกกรอบเอียง = โดน
* เผื่อไว้ ยอมรับได้เพราะ handle ลากก็ใช้พื้นที่รอบกรอบอยู่แล้ว */
export function rotatedRectAABB(r: Rect & { rotation: number }): Rect {
const rad = (r.rotation * Math.PI) / 180;
const cos = Math.abs(Math.cos(rad));
const sin = Math.abs(Math.sin(rad));
const w = r.width * cos + r.height * sin;
const h = r.width * sin + r.height * cos;
const cx = r.x + r.width / 2;
const cy = r.y + r.height / 2;
return { x: cx - w / 2, y: cy - h / 2, width: w, height: h };
}
type AnnotationLike = { type: AnnotationType; data: AnnotationData };
@ -73,7 +246,15 @@ type AnnotationLike = { type: AnnotationType; data: AnnotationData };
* - COMMENT: กล่องสี่เหลี่ยมขนาด COMMENT_PIN_BOX_PCT (% ของความกว้างหน้า) รอบ position
* เคล็ดลับ: ส่ง size={width:100, height:100} เพื่อให้ผลลัพธ์เป็น "%" โดยตรง
*/
export function annotationBBox(a: AnnotationLike, size: Size): Rect {
export function annotationBBox(
a: AnnotationLike,
size: Size,
/** measure ความกว้างข้อความตรายาง — ไม่ส่ง = ประมาณด้วย STAMP_CHAR_W (stampLayoutEstimate,
* รันได้ฝั่ง server) · ฝั่ง client (กรอบ selection, hit-test, tooltip, eraser) ต้องส่ง
* measureStampText ให้สูงกรอบตรงกับที่ StampItem render จริง — ประมาณแล้วจำนวนบรรทัด
* คลาดตามฟอนต์ของแต่ละเครื่อง จนขอบล่างกรอบเกิน/สั้นกว่าตรายาง (bug 2026-09-28) */
measure?: (text: string, fontSize: number) => number,
): Rect {
const d = a.data;
// union ไม่ได้ discriminate โดย field ใน data เอง → แยกตาม key เฉพาะของแต่ละ variant
if ("rects" in d) {
@ -139,10 +320,13 @@ export function annotationBBox(a: AnnotationLike, size: Size): Rect {
return { x: p.x, y: p.y, width, height: Math.max(lines, 1) * lineH };
}
if ("scale" in d) {
// STAMP — กรอบจริงตามสูตร render (position เป็นมุมซ้ายบน)
const w = toPx(d.scale, size.width);
const p = pointToPx(d.position, size);
return { x: p.x, y: p.y, width: w, height: w * STAMP_HEIGHT_RATIO };
// STAMP — กรอบจริงตามสูตร render (position เป็นมุมซ้ายบน) หมุนรอบกลางกรอบตาม
// rotation (ไม่มี = 0°) — คืน AABB ของกรอบหมุนแล้ว (hit-test/tooltip/clamp เป็น
// แกนตรง ส่วนกรอบ selection ใช้ stampRect วาดกรอบหมุนจริง)
const r = stampRect(d, size, measure);
return r.rotation
? rotatedRectAABB(r)
: { x: r.x, y: r.y, width: r.width, height: r.height };
}
const p = pointToPx(d.position, size);
// กล่องรอบ pin อิง % ของความกว้างหน้า — สอดคล้องทั้ง render (px) และ space %

68
utils/textbox-editor.ts Normal file
View file

@ -0,0 +1,68 @@
import type { TextboxSegment } from "@/db/schema";
// แปลงข้อความ/segments ของกล่องข้อความ (TEXTBOX F17) ไป–กลับระหว่าง model ใน DB
// กับ DOM ของ contentEditable ใน TextboxEditor (components/pdf/TextboxStampLayer.tsx)
// — แยกเป็น pure DOM util (ไม่แตะ React) เพื่อ unit test กับ jsdom ได้ตาม pattern
// tests/hooks/useCustomSelection-dom.test.ts (2026-09-28)
/** แปลงข้อความ/segments → HTML สำหรับ contentEditable (escape + <b> + <br>) */
export function segmentsToHtml(text: string, segments?: TextboxSegment[]): string {
const esc = (s: string) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const runs = segments?.length ? segments : [{ text }];
return runs
.map((s) => (s.bold ? `<b>${esc(s.text)}</b>` : esc(s.text)))
.join("")
.replace(/\n/g, "<br>");
}
/** อ่าน DOM ของ contentEditable กลับเป็น text รวม + segments แยกตามตัวหนา/ปกติ
* - ขึ้นบรรทัดใหม่มาจาก 2 แหล่ง: <br> (Enter ถูกดักเป็น insertLineBreak — <br> ล้วน)
* และ <div>/<p> ที่ browser สร้างเอง (paste/โหมดแก้ไขของ browser อื่น) — เจอ
* block ใหม่ให้ push "\n" *ก่อน* เข้า block จากตำแหน่งข้อความที่ไปก่อนหน้า:
* push หลังจบ block แบบเดิมจะตกขอบทั้งรอยต่อ text→div แรก ("abc<div>def</div>"
* ของ Chrome กลายเป็น "abcdef") และ block ตัวสุดท้ายของก้อน
* - whitespace ท้ายทั้งก้อนถูกตัดออกทั้ง text และ segments ให้ตรงกัน (browser เติม
* <br> ปิดท้ายให้ contentEditable จนได้ "\n" ค้างใน DB) */
export function serializeEditor(root: HTMLElement): {
text: string;
segments: TextboxSegment[];
} {
const segs: TextboxSegment[] = [];
const push = (text: string, bold: boolean) => {
if (!text) return;
const last = segs[segs.length - 1];
if (last && !!last.bold === bold) last.text += text;
else segs.push({ text, ...(bold ? { bold: true } : {}) });
};
const walk = (node: Node, bold: boolean) => {
if (node.nodeType === Node.TEXT_NODE) {
push(node.nodeValue ?? "", bold);
} else if (node.nodeName === "BR") {
push("\n", bold);
} else {
const b = bold || node.nodeName === "B" || node.nodeName === "STRONG";
if (
/^(DIV|P)$/.test(node.nodeName) &&
segs.length > 0 &&
!segs[segs.length - 1].text.endsWith("\n")
) {
push("\n", b);
}
node.childNodes.forEach((c) => walk(c, b));
}
};
walk(root, false);
const joined = segs.map((s) => s.text).join("");
let cut = joined.length - joined.trimEnd().length;
for (let i = segs.length - 1; i >= 0 && cut > 0; i--) {
if (segs[i].text.length <= cut) {
cut -= segs[i].text.length;
segs.splice(i, 1);
} else {
segs[i].text = segs[i].text.slice(0, segs[i].text.length - cut);
cut = 0;
}
}
return { text: joined.trimEnd(), segments: segs };
}