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

@ -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" />