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

531 lines
31 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { AnnotationData, AnnotationType, ShapeData, StampData } from "@/db/schema";
import {
clamp,
pointToPx,
rectToPx,
toPx,
type Point,
type Rect,
type Size,
} from "@/utils/coordinates";
// Geometry ของ annotation (PLAN-FEAT.md §3 F1) — pure functions ทดสอบได้
// ข้อมูลเข้า/ออกเป็นพิกัด % ตาม invariant #2 ยกเว้นฟังก์ชันที่รับ `size`
// เพื่อแปลง % → px (เช่น bbox สำหรับวาดกรอบ selection, hit-test หน่วย px)
/** ขนาดกล่อง bbox ของ comment pin (% ของความกว้างหน้า) — pin เป็นปุ่มขนาดคงที่ ไม่มีพิกัดกว้างยาวใน data
* เป็นค่าประมาณ % สำหรับ hit-test ที่ไม่รู้ขนาด render เท่านั้น — ทุกจุดที่รู้ขนาดหน้าจริง
* (กรอบ selection / scroll target / move-clamp) ใช้ commentPinBoxPx/commentPinBoxPct ตาม px จริงข้างล่าง */
export const COMMENT_PIN_BOX_PCT = 4;
/** COMMENT: ขนาด px จริงของปุ่ม pin — desktop 26px (icon 16 + p-1 รอบด้าน + border 1×2)
* และจอสัมผัส 36px (.coarse-36 ใน globals.css บังคับ min-width/height 2.25rem ให้ปุ่ม
* pin ใน PDFViewer) — pin เป็น px คงที่ไม่ scale ตาม zoom ต่างจากชนิดอื่นที่เป็น % ทั้งหมด
* (bug report 2026-09-23: กรอบไม่ตรงกล่อง + responsive audit 2026-09-24: scroll/clamp
* คิดจาก % เดิมจน pin จมใต้ header ตอนซูมออก) — แก้ปุ่ม pin หรือ .coarse-36 ต้องแก้ค่านี้ตาม */
export const COMMENT_PIN_SIZE_PX = 26;
export const COMMENT_PIN_COARSE_SIZE_PX = 36;
/** bbox px ของ COMMENT ตามขนาด render จริง — จัตุรัส px คงที่ กึ่งกลางแนวนอนที่จุด position
* และขอบล่างชนจุด (สูตรเดียวกับ -translate-x-1/2 -translate-y-full ของปุ่ม pin — ทั้งลูก
* ลอยเหนือจุดคลิก) */
export function commentPinBoxPx(d: { position: Point }, size: Size, pinPx: number): Rect {
const p = pointToPx(d.position, size);
return { x: p.x - pinPx / 2, y: p.y - pinPx, width: pinPx, height: pinPx };
}
/** bbox ของ COMMENT หน่วย % คิดจากขนาด render จริง (size = px ของหน้าที่กำลัง render) —
* ใช้กับ scroll target ของ goToAnnotation/deep link + move-clamp/bookmark ของกรอบ selection
* ให้เป็นมาตราฐานเดียวกับที่ render (responsive audit 2026-09-24 — เดิมสองมาตราฐาน:
* render ใช้ px แต่ scroll/clamp ยังใช้ COMMENT_PIN_BOX_PCT ของ annotationBBox) */
export function commentPinBoxPct(d: { position: Point }, size: Size, pinPx: number): Rect {
const wPct = (pinPx / size.width) * 100;
const hPct = (pinPx / size.height) * 100;
return { x: d.position.x - wPct / 2, y: d.position.y - hPct, width: wPct, height: hPct };
}
// TEXTBOX (F17/B2 — §5): ค่าประมาณสำหรับ bbox ของกล่องข้อความ
// render จริงกว้างสุด 50% ของหน้าแล้ว wrap อัตโนมัติ — bbox ประมาณจากตัวอักษร
// กว้าง ~0.6 × fontSize ต่อตัว บรรทัดสูง 1.5 × fontSize (ค่าประมาณพอสำหรับกรอบ selection)
export const TEXTBOX_MAX_WIDTH_PCT = 50;
export const TEXTBOX_DEFAULT_WIDTH_PCT = 30; // กล่องที่สร้างใหม่ (คลิกเดียว) กว้างเท่านี้ก่อนลากขยาย
export const TEXTBOX_CHAR_W = 0.6;
export const TEXTBOX_LINE_H = 1.5;
/** ความกว้างสูงสุดของกล่องข้อความ (% ของความกว้างหน้า) จากขอบซ้าย (position.x = มุมซ้ายบน
* เสมอ — align จัดข้อความ*ใน*กล่องอย่างเดียวแบบ Smallpdf กล่องไม่ขยับ) — บีบให้กล่อง
* ไม่ล้นขอบขวาของหน้ากระดาษ ใช้ร่วมกันทั้ง render จอ, bbox กรอบ selection และ export PDF
* ติดขอบขวาเป๊ะ (x=100) เหลือกว้างขั้นต่ำ 1% กันกล่องพังเป็นศูนย์ */
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% ของความกว้างหน้า — สูตรจัดวางข้อความทั้งชุด
// (บรรทัด/ฟอนต์/ความสูงกรอบ) อยู่ที่นี่ใช้ร่วมทั้ง 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 };
/**
* bbox ของ annotation (หน่วย px ตาม size ที่ส่งเข้ามา)
* - HIGHLIGHT: union ของ rects ทุกอัน
* - DRAW: union ของจุดทุกจุด ขยายออกด้วย strokeWidth/2 (เส้นมีความหนา)
* - COMMENT: กล่องสี่เหลี่ยมขนาด COMMENT_PIN_BOX_PCT (% ของความกว้างหน้า) รอบ position
* เคล็ดลับ: ส่ง size={width:100, height:100} เพื่อให้ผลลัพธ์เป็น "%" โดยตรง
*/
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) {
const rects = d.rects.map((r) => rectToPx(r, size));
if (rects.length === 0) return { x: 0, y: 0, width: 0, height: 0 };
const x1 = Math.min(...rects.map((r) => r.x));
const y1 = Math.min(...rects.map((r) => r.y));
const x2 = Math.max(...rects.map((r) => r.x + r.width));
const y2 = Math.max(...rects.map((r) => r.y + r.height));
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
}
if ("paths" in d) {
const pts = d.paths.map((p) => pointToPx(p, size));
const half = toPx(d.strokeWidth, size.width) / 2;
const x1 = Math.min(...pts.map((p) => p.x)) - half;
const y1 = Math.min(...pts.map((p) => p.y)) - half;
const x2 = Math.max(...pts.map((p) => p.x)) + half;
const y2 = Math.max(...pts.map((p) => p.y)) + half;
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
}
if ("shape" in d) {
const half = toPx(d.strokeWidth, size.width) / 2;
if ("rect" in d) {
const r = rectToPx(d.rect, size);
return {
x: r.x - half,
y: r.y - half,
width: r.width + half * 2,
height: r.height + half * 2,
};
}
const a1 = pointToPx(d.from, size);
const b1 = pointToPx(d.to, size);
const x1 = Math.min(a1.x, b1.x) - half;
const y1 = Math.min(a1.y, b1.y) - half;
const x2 = Math.max(a1.x, b1.x) + half;
const y2 = Math.max(a1.y, b1.y) + half;
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
}
if ("rect" in d) {
// REDACTION — กรอบดำทึบ ไม่มีความหนาเส้นให้ขยาย
return rectToPx(d.rect, size);
}
if ("fontSize" in d) {
// TEXTBOX — position เป็นมุมซ้ายบนของกล่องเสมอ (align จัดข้อความในกล่องอย่างเดียว);
// กล่องมี width คงที่ (ถ้ามี — สไตล์ Smallpdf) ไม่มี = shrink-to-fit ประมาณจากตัวอักษร
const fontSizePx = toPx(d.fontSize, size.width);
const charW = fontSizePx * TEXTBOX_CHAR_W;
const lineH = fontSizePx * TEXTBOX_LINE_H;
// ความกว้างสูงสุดถึงขอบขวาหน้า — กล่อง width คงที่ก็ถูกบีบด้วยค่านี้เหมือน render
const capPct = textboxMaxWidthPct(d.position.x);
const wrapPx = toPx(Math.min(d.width ?? capPct, capPct), size.width);
const wrapW = Math.max(wrapPx, charW); // กันหารศูนย์เมื่อกล่องติดขอบขวาเป๊ะ
let widest = 0;
let lines = 0;
for (const raw of d.text.split(/\r?\n/)) {
widest = Math.max(widest, raw.length * charW);
lines += Math.max(1, Math.ceil((raw.length * charW) / wrapW));
}
// กล่อง width คงที่กว้างตามที่กำหนด (บีบด้วย cap) — แถวเดิม shrink-to-fit ตามบรรทัดยาวสุด
const width = (d.width !== undefined ? wrapPx : Math.min(widest, wrapPx)) || charW;
const p = pointToPx(d.position, size);
return { x: p.x, y: p.y, width, height: Math.max(lines, 1) * lineH };
}
if ("scale" in d) {
// 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 %
// (size={100,100}) ไม่เกิดหน่วยเพี้ยนตอน clamp translate/scale ของ COMMENT
const box = toPx(COMMENT_PIN_BOX_PCT, size.width);
return { x: p.x - box / 2, y: p.y - box / 2, width: box, height: box };
}
/**
* เลื่อน annotation ทั้งชิ้นตาม offset (หน่วย % ของหน้า) — ใช้ตอนลากย้าย
* strokeWidth คงเดิม (การเลื่อนไม่เปลี่ยนความหนา)
* ผู้เรียกต้อง clamp dx/dy เองให้ bbox ยังอยู่ใน 0–100 ก่อนส่งเข้า action
*/
export function translateAnnotationData(
data: AnnotationData,
dxPct: number,
dyPct: number,
): AnnotationData {
if ("rects" in data) {
return {
...data,
rects: data.rects.map((r) => ({ ...r, x: r.x + dxPct, y: r.y + dyPct })),
};
}
if ("paths" in data) {
return {
...data,
paths: data.paths.map((p) => ({ x: p.x + dxPct, y: p.y + dyPct })),
};
}
if ("shape" in data) {
if ("rect" in data) {
return {
...data,
rect: { ...data.rect, x: data.rect.x + dxPct, y: data.rect.y + dyPct },
};
}
return {
...data,
from: { x: data.from.x + dxPct, y: data.from.y + dyPct },
to: { x: data.to.x + dxPct, y: data.to.y + dyPct },
};
}
if ("rect" in data) {
// REDACTION
return {
...data,
rect: { ...data.rect, x: data.rect.x + dxPct, y: data.rect.y + dyPct },
};
}
return {
...data,
position: { x: data.position.x + dxPct, y: data.position.y + dyPct },
};
}
/**
* ย่อ–ขยาย annotation รอบจุด anchor (หน่วย % ของหน้า) ด้วยตัวคูณแกน x/y
* — ใช้ตอนลากมุมกรอบ (anchor = มุมตรงข้าม) ครอบ rects ทุกอัน / จุดทุกจุด / position
* strokeWidth คงเดิมตามแผน (สเปก F1 ระบุแค่ rects/points/position)
*/
export function scaleAnnotationData(
data: AnnotationData,
anchorPct: Point,
sx: number,
sy: number,
): AnnotationData {
const fx = (x: number) => anchorPct.x + (x - anchorPct.x) * sx;
const fy = (y: number) => anchorPct.y + (y - anchorPct.y) * sy;
if ("rects" in data) {
return {
...data,
rects: data.rects.map((r) => ({
x: fx(r.x),
y: fy(r.y),
width: r.width * sx,
height: r.height * sy,
})),
};
}
if ("paths" in data) {
return {
...data,
paths: data.paths.map((p) => ({ x: fx(p.x), y: fy(p.y) })),
};
}
if ("shape" in data) {
if ("rect" in data) {
return {
...data,
rect: {
x: fx(data.rect.x),
y: fy(data.rect.y),
width: data.rect.width * sx,
height: data.rect.height * sy,
},
};
}
return {
...data,
from: { x: fx(data.from.x), y: fy(data.from.y) },
to: { x: fx(data.to.x), y: fy(data.to.y) },
};
}
if ("rect" in data) {
// REDACTION
return {
...data,
rect: {
x: fx(data.rect.x),
y: fy(data.rect.y),
width: data.rect.width * sx,
height: data.rect.height * sy,
},
};
}
if ("scale" in data) {
// STAMP — ขนาดทั้งกว้าง-สูงเก็บใน scale % ตัวเดียว (สัดส่วนตายตัว
// STAMP_HEIGHT_RATIO) จึงขยายด้วยตัวคูณเดียว: caller ส่ง sx = sy = k
return {
...data,
position: { x: fx(data.position.x), y: fy(data.position.y) },
scale: data.scale * sx,
};
}
return { ...data, position: { x: fx(data.position.x), y: fy(data.position.y) } };
}
function distToSegment(p: Point, a: Point, b: Point): number {
const dx = b.x - a.x;
const dy = b.y - a.y;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y);
const t = clamp(((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq, 0, 1);
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
}
/**
* hit-test จุด (px) กับ polyline (% ของหน้า) — ระยะถึง segment ใด ๆ ≤ thresholdPx
* ใช้ใน F6 (eraser); threshold หน่วย px เพราะความคาดหวังของผู้ใช้อิงขนาดจอ ไม่ใช่ % ของหน้า
*/
export function hitTestPath(
pointsPct: Point[],
pointPct: Point,
thresholdPx: number,
size: Size,
): boolean {
if (pointsPct.length === 0) return false;
const pts = pointsPct.map((p) => pointToPx(p, size));
const pt = pointToPx(pointPct, size);
if (pts.length === 1) return Math.hypot(pt.x - pts[0].x, pt.y - pts[0].y) <= thresholdPx;
for (let i = 0; i < pts.length - 1; i++) {
if (distToSegment(pt, pts[i], pts[i + 1]) <= thresholdPx) return true;
}
return false;
}
/** ระยะจากจุดถึงเส้นขอบสี่เหลี่ยม (px) — อยู่ใน "พื้นที่" กรอบจะได้ระยะถึงขอบ ไม่ใช่ 0 */
function distToRectOutline(p: Point, r: Rect): number {
const inside =
p.x >= r.x && p.x <= r.x + r.width && p.y >= r.y && p.y <= r.y + r.height;
if (inside) {
const dx = Math.min(p.x - r.x, r.x + r.width - p.x);
const dy = Math.min(p.y - r.y, r.y + r.height - p.y);
return Math.min(dx, dy);
}
const cx = Math.max(r.x, Math.min(p.x, r.x + r.width));
const cy = Math.max(r.y, Math.min(p.y, r.y + r.height));
return Math.hypot(p.x - cx, p.y - cy);
}
/** ประมาณระยะจากจุดถึงเส้นรอบวงรี (px) — scale-space: |r−1|×min(rx,ry) พอสำหรับ eraser */
function distToEllipseOutline(p: Point, r: Rect): number {
const cx = r.x + r.width / 2;
const cy = r.y + r.height / 2;
const rx = r.width / 2;
const ry = r.height / 2;
if (rx === 0 || ry === 0) return Math.hypot(p.x - cx, p.y - cy);
const ratio = Math.hypot((p.x - cx) / rx, (p.y - cy) / ry);
return Math.abs(ratio - 1) * Math.min(rx, ry);
}
/**
* hit-test จุดกับรูปทรง (F6 eraser) — เฉพาะ "เส้น stroke/ขอบ" เท่านั้น
* ผ่านพื้นที่ตรงกลางรูปทรงไม่โดน (PLAN-FEAT.md §5)
*/
export function hitTestShape(
data: ShapeData,
pointPct: Point,
thresholdPx: number,
size: Size,
): boolean {
const p = pointToPx(pointPct, size);
if ("rect" in data) {
const r = rectToPx(data.rect, size);
if (data.shape === "ellipse") return distToEllipseOutline(p, r) <= thresholdPx;
return distToRectOutline(p, r) <= thresholdPx;
}
const a = pointToPx(data.from, size);
const b = pointToPx(data.to, size);
return distToSegment(p, a, b) <= thresholdPx;
}