Compare commits

..

No commits in common. "651e354cadf666070add5ce56562cd49878cbf80" and "c2ed9dabc13a326448bdc7808480b3521bda8d69" have entirely different histories.

23 changed files with 407 additions and 2613 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 ปฏิเสธค่าติดลบ — **ยกเว้น 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)**
- `utils/geometry.ts` (PLAN-FEAT.md F1) — pure functions บนพิกัด %: `annotationBBox` / `translateAnnotationData` / `scaleAnnotationData` / `hitTestPath` — ก่อนส่ง data ที่แปลงแล้วเข้า `updateAnnotation` ต้อง **clamp offset/scale ให้ bbox ทั้งกลุ่มอยู่ใน 0–100** เพราะ validator ปฏิเสธค่าติดลบ
- 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 **หรือพิมพ์คำเอง** ได้แล้ว 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 วาด)
- ลายเซ็นจากรูป/upload ยังเป็น out of scope (D7 — stamp เป็น preset ข้อความเท่านั้น, ลายเซ็นใช้ tool DRAW วาด)
## Claude Tooling (ใน `.claude/`)

View file

@ -34,7 +34,6 @@ import {
MAX_BOOKMARK_NOTE_CHARS,
MAX_BOOKMARK_TITLE_CHARS,
} from "@/utils/bookmark-limits";
import { rotatedRectAABB, stampRect } from "@/utils/geometry";
// ─── Input types (client ใช้ตอนเรียก action) ────────────────────────────────
@ -142,20 +141,6 @@ 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>;
@ -353,9 +338,7 @@ function validateAnnotationData(type: AnnotationType, v: unknown): AnnotationDat
}
case "STAMP": {
const label = vString(d.label, "data.label", MAX_TEXT_CHARS);
// position = มุมกรอบก่อนหมุน — ติดลบได้เมื่อตรายางเอียง (ดู vPointFinite)
// เกณฑ์ "ไม่ติดลบ" ตรวจที่ AABB ของตรายางด้านล่างแทน position ตรง ๆ
const position = vPointFinite(d.position, "data.position");
const position = vPoint(d.position, "data.position");
// scale เป็น % ของความกว้างหน้า — ต้องเป็นบวกจึงมีความหมาย
if (
typeof d.scale !== "number" ||
@ -367,41 +350,12 @@ 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,7 +8,6 @@ import {
PDFName,
PDFString,
LineCapStyle,
degrees,
rgb,
type PDFFont,
type PDFDict,
@ -52,16 +51,15 @@ 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)
@ -598,65 +596,42 @@ 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: lay ? stampBoxHeight(lay, w) : w * 0.4,
height: w * STAMP_HEIGHT_RATIO,
},
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: anchor.x,
y: anchor.y,
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
borderColor: stampRgb,
borderWidth: Math.max(w * 0.02, 1),
...rotateOpt,
});
if (lay && thaiFont) {
if (thaiFont) {
const font = thaiFont;
// วาดทีละบรรทัดจากกึ่งกลางแนวตั้งของกรอบ (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;
}
// ย่อฟอนต์ให้ 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,
font,
color: stampRgb,
});
}
break;
}
@ -667,15 +642,8 @@ export async function GET(req: Request) {
if (a.type !== "COMMENT") {
const note = annotationNote(a);
if (note) {
// 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,
);
// annotationBBox คืน pt ของ "หน้าบนจอ" อยู่แล้ว — แปลงตรงเข้าพิกัด PDF
const bbox = annotationBBox(a, display);
attachNoteSticky(
doc,
page,

View file

@ -6,15 +6,7 @@ import {
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import {
AlignCenter,
AlignLeft,
AlignRight,
BookmarkPlus,
Pencil,
RotateCw,
Trash2,
} from "lucide-react";
import { AlignCenter, AlignLeft, AlignRight, BookmarkPlus, Pencil, Trash2 } from "lucide-react";
import type {
AnnotationData,
@ -23,19 +15,15 @@ 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 {
@ -45,40 +33,9 @@ import {
type Rect,
type Size,
} from "@/utils/coordinates";
import {
annotationTypeLabel,
measureStampText,
STAMP_COLOR,
} from "@/utils/annotation-display";
import { annotationTypeLabel, 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 =
@ -93,8 +50,6 @@ 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;
@ -103,19 +58,15 @@ const EPS = 1e-6;
type DragState = {
// wleft/wright = TEXTBOX กว้างคงที่: ลากขอบซ้าย/ขวาปรับความกว้าง (ฟอนต์คงขนาด)
mode: "move" | "scale" | "wleft" | "wright" | "rotate";
mode: "move" | "scale" | "wleft" | "wright";
/** ตำแหน่ง pointer ตอนเริ่มลาก (% ของหน้า) */
startPct: Point;
/** data ก่อนเริ่มลาก — ต้นทางของการคำนวณทุก move (ไม่สะสมจาก preview) */
origData: AnnotationData;
/** bbox ก่อนเริ่มลาก (% ของหน้า) — STAMP เอียง = AABB ของกรอบหมุนแล้ว (ครอบกรอบจริง) */
/** bbox ก่อนเริ่มลาก (% ของหน้า) */
origBoxPct: Rect;
/** mode "scale" เท่านั้น — มุมตรงข้ามของ handle (% ของหน้า ตำแหน่งจริงบนจอ) */
/** mode "scale" เท่านั้น — มุมตรงข้ามของ handle (% ของหน้า) */
anchorPct: Point;
/** STAMP เอียงเท่านั้น — มุมกรอบตอนเริ่มลาก (องศา): คำนวณ k ของ scale ต้อง
* แปลง pointer เข้า frame ของกรอบ (ย้อนหมุนรอบ center) ก่อน ไม่งั้นลากมุมของ
* กรอบเอียงแล้วสัดส่วนแกน x/y ผิดจากสายตา */
rot?: number;
};
/** จำกัดตัวคูณ scale แกนเดียว ให้กรอบผลลัพธ์ยังอยู่ใน 0–100 และไม่เล็กกว่าขั้นต่ำ */
@ -152,7 +103,6 @@ export default function AnnotationSelectionLayer({
onDelete,
onBookmark,
onStartEdit,
onPatch,
}: {
annotation: AnnotationDTO;
/** ขนาด px ของหน้า (จาก width/height ที่คำนวณจาก scale) */
@ -174,10 +124,6 @@ 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);
@ -197,44 +143,12 @@ 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, measureStampText);
: annotationBBox(annotation, size);
const boxPct = isCommentPin
? commentPinBoxPct(annotation.data as CommentData, size, pinPx)
: 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;
: annotationBBox(annotation, { width: 100, height: 100 });
// FIX-9: เอา non-null ! ออก — handler ยิงได้แค่ตอน DOM mount แล้ว
// ref จึงไม่ควรเป็น null (คืน 0,0 เผื่อไว้เฉย ๆ ให้ type ชัด)
@ -246,7 +160,7 @@ export default function AnnotationSelectionLayer({
function beginDrag(
e: ReactPointerEvent<HTMLElement>,
mode: "move" | "scale" | "wleft" | "wright" | "rotate",
mode: "move" | "scale" | "wleft" | "wright",
anchorPct: Point,
) {
if (!editable || e.button !== 0) return;
@ -259,7 +173,6 @@ export default function AnnotationSelectionLayer({
origData: annotation.data,
origBoxPct: boxPct,
anchorPct,
...(stampRot !== 0 ? { rot: stampRot } : null),
};
movedRef.current = false;
latestRef.current = null;
@ -271,30 +184,7 @@ export default function AnnotationSelectionLayer({
e.stopPropagation();
const cur = pointerPct(e);
let next: AnnotationData;
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") {
if (st.mode === "move") {
// clamp offset ให้ bbox ทั้งกลุ่มยังอยู่ในหน้า (พิกัด % ติดลบโดน action ปฏิเสธ)
const dx = clamp(
cur.x - st.startPct.x,
@ -338,29 +228,12 @@ export default function AnnotationSelectionLayer({
next = { ...d, width: w, position: { ...d.position, x: left } };
}
} else if ("scale" in st.origData) {
// STAMP — ความกว้างมีมาจาก scale เพียงตัวเดียว (สูงตามจำนวนบรรทัดของ label,
// stampLayout) จึงขยายด้วยตัวคูณ uniform เดียว: เฉลี่ยอัตราส่วนของ pointer สองแกน
// STAMP — สัดส่วนกว้าง:สูงตายตัว (สูง = STAMP_HEIGHT_RATIO × กว้าง) แยกสเกล
// สองแกนไม่ได้ จึงใช้ตัวคูณ 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 =
((curL.x - anchorL.x) / (startL.x - anchorL.x || EPS) +
(curL.y - anchorL.y) / (startL.y - anchorL.y || EPS)) /
((cur.x - st.anchorPct.x) / (st.startPct.x - st.anchorPct.x || EPS) +
(cur.y - st.anchorPct.y) / (st.startPct.y - st.anchorPct.y || EPS)) /
2;
const k = clampAxisScale(
clampAxisScale(
@ -417,34 +290,54 @@ export default function AnnotationSelectionLayer({
onPreview(null);
}
/** ปุ่มสี/ความหนาบนกรอบ — ส่ง patch ให้ viewer ผสานกับ data ล่าสุด (ชื่อ field ตาม
* ชนิด: HIGHLIGHT/STAMP = color, DRAW/SHAPE = strokeColor — ตัดสินตอนผสาน) */
/** ปุ่มสี/ความหนาบนกรอบ — ใช้ onCommit เดียวกับการลาก */
function commitColor(color: string) {
onPatch(annotation.id, { kind: "color", color });
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 });
}
function commitStrokeWidth(w: number) {
onPatch(annotation.id, { kind: "strokeWidth", strokeWidth: w });
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 });
}
}
/** HIGHLIGHT — เปลี่ยนรูปแบบมาร์กหลังสร้าง (ไฮไลต์/เส้นใต้/ขีดฆ่า/ขยุกขยิก) */
function commitHighlightStyle(style: HighlightStyle) {
onPatch(annotation.id, { kind: "highlightStyle", style });
const d = annotation.data;
if (!("rects" in d)) return;
onCommit(annotation.id, { ...d, style });
}
/** HIGHLIGHT — ปรับความทึบหลังสร้าง (100% = ทึบเต็ม = ถอด field opacity ตอนผสาน) */
/** HIGHLIGHT — ปรับความทึบหลังสร้าง (100% = ทึบเต็ม = ไม่เก็บ field เหมือนตอนสร้างใหม่) */
function commitHighlightOpacity(opacityPct: number) {
onPatch(annotation.id, { kind: "highlightOpacity", opacityPct });
const d = annotation.data;
if (!("rects" in d)) return;
const { opacity: _prev, ...rest } = d;
onCommit(annotation.id, {
...rest,
...(opacityPct < 100 ? { opacity: opacityPct / 100 } : {}),
});
}
/** STAMP — ปรับขนาดตรายางหลังสร้าง */
function commitStampScale(scale: number) {
onPatch(annotation.id, { kind: "stampScale", scale });
}
/** STAMP — ปรับมุมเอียงหลังสร้าง (องศา −180..180 หมุนรอบกลางกรอบ) */
function commitStampRotation(rotation: number) {
onPatch(annotation.id, { kind: "stampRotation", rotation });
const d = annotation.data;
if (!("label" in d)) return;
onCommit(annotation.id, { ...d, scale });
}
/** TEXTBOX — แก้ค่าทั้งกล่องหลังสร้าง (สี/ขนาด/ทึบ/หนา/จัดแนว) เหมือน dropdown ของ tool */
@ -452,7 +345,7 @@ export default function AnnotationSelectionLayer({
patch: Partial<Pick<TextboxData, "color" | "fontSize" | "opacity" | "bold" | "align">>,
) {
if (!textboxData) return;
onPatch(annotation.id, { kind: "textbox", fields: patch });
onCommit(annotation.id, { ...textboxData, ...patch });
}
// ตัวควบคุมในแผงตรวจตามชนิดด้วยการเช็ค field ใน data ตรง ๆ (rects = HIGHLIGHT,
@ -463,10 +356,8 @@ 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;
// (stampData ย้ายไปขึ้นบน — ใช้ร่วมกับ stampRot/framePx ของตรายางเอียง)
"paths" in annotation.data || "shape" in annotation.data ? annotation.data : null;
const stampData = "label" in annotation.data ? annotation.data : null;
const textboxData: TextboxData | null =
"fontSize" in annotation.data ? annotation.data : null;
// TEXTBOX กว้างคงที่ (มี width) — ขอบซ้าย/ขวาเป็น handle ปรับความกว้างแทนการย้าย
@ -477,48 +368,36 @@ export default function AnnotationSelectionLayer({
// กรณีหลังหุบเป็น w-fit ไม่คั่นเส้น และปุ่มติดกันแทน justify-between
const hasStyleControls = !!(highlightData || strokeData || stampData || textboxData);
// มุมทั้ง 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;
// มุมทั้ง 4 — ตำแหน่งวางเป็น px, anchor มุมตรงข้ามเป็น % ตรง ๆ จาก boxPct
// (พิกัด % คำนวณผ่าน utils/coordinates เท่านั้น — invariant #2)
const corners: { key: string; x: number; y: number; cursor: string; anchor: Point }[] = [
{
key: "nw",
x: framePx.x,
y: framePx.y,
x: boxPx.x,
y: boxPx.y,
cursor: "nwse-resize",
anchor: anchorOnScreen({
x: framePct.x + framePct.width,
y: framePct.y + framePct.height,
}),
anchor: { x: boxPct.x + boxPct.width, y: boxPct.y + boxPct.height },
},
{
key: "ne",
x: framePx.x + framePx.width,
y: framePx.y,
x: boxPx.x + boxPx.width,
y: boxPx.y,
cursor: "nesw-resize",
anchor: anchorOnScreen({ x: framePct.x, y: framePct.y + framePct.height }),
anchor: { x: boxPct.x, y: boxPct.y + boxPct.height },
},
{
key: "sw",
x: framePx.x,
y: framePx.y + framePx.height,
x: boxPx.x,
y: boxPx.y + boxPx.height,
cursor: "nesw-resize",
anchor: anchorOnScreen({ x: framePct.x + framePct.width, y: framePct.y }),
anchor: { x: boxPct.x + boxPct.width, y: boxPct.y },
},
{
key: "se",
x: framePx.x + framePx.width,
y: framePx.y + framePx.height,
x: boxPx.x + boxPx.width,
y: boxPx.y + boxPx.height,
cursor: "nwse-resize",
anchor: anchorOnScreen({ x: framePct.x, y: framePct.y }),
anchor: { x: boxPct.x, y: boxPct.y },
},
];
@ -548,16 +427,14 @@ export default function AnnotationSelectionLayer({
style={{ pointerEvents: "none" }}
onClick={(e) => e.stopPropagation()} // คลิกบน handle/toolbar ไม่ให้ bubble ไป deselect ที่ page
>
{/* กรอบ bbox — ตัวกรอบเอง pointer-events none ผ่านไป textLayer ได้ · STAMP เอียง
วาดที่กรอบก่อนหมุนแล้วหมุนทั้งชุด (กรอบ+handles) รอบกลางกรอบเหมือน StampItem */}
{/* กรอบ bbox — ตัวกรอบเอง pointer-events none ผ่านไป textLayer ได้ */}
<div
className="absolute border-2 border-dashed border-primary"
style={{
left: framePx.x,
top: framePx.y,
width: framePx.width,
height: framePx.height,
...rotOn(framePx.x, framePx.y),
left: boxPx.x,
top: boxPx.y,
width: boxPx.width,
height: boxPx.height,
}}
/>
@ -570,12 +447,11 @@ export default function AnnotationSelectionLayer({
<div
className="absolute cursor-move touch-none sel-strip"
style={{
left: framePx.x - STRIP_PX / 2,
top: framePx.y - STRIP_PX / 2,
width: framePx.width + STRIP_PX,
left: boxPx.x - STRIP_PX / 2,
top: boxPx.y - STRIP_PX / 2,
width: boxPx.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}
@ -585,12 +461,11 @@ export default function AnnotationSelectionLayer({
<div
className="absolute cursor-move touch-none sel-strip"
style={{
left: framePx.x - STRIP_PX / 2,
top: framePx.y + framePx.height - STRIP_PX / 2,
width: framePx.width + STRIP_PX,
left: boxPx.x - STRIP_PX / 2,
top: boxPx.y + boxPx.height - STRIP_PX / 2,
width: boxPx.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}
@ -600,12 +475,11 @@ export default function AnnotationSelectionLayer({
<div
className={`absolute touch-none sel-strip ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
style={{
left: framePx.x - STRIP_PX / 2,
top: framePx.y,
left: boxPx.x - STRIP_PX / 2,
top: boxPx.y,
width: STRIP_PX,
height: framePx.height,
height: boxPx.height,
pointerEvents: "auto",
...rotOn(framePx.x - STRIP_PX / 2, framePx.y),
}}
onPointerDown={(e) =>
beginDrag(e, isFixedTextbox ? "wleft" : "move", { x: 0, y: 0 })
@ -617,12 +491,11 @@ export default function AnnotationSelectionLayer({
<div
className={`absolute touch-none sel-strip ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
style={{
left: framePx.x + framePx.width - STRIP_PX / 2,
top: framePx.y,
left: boxPx.x + boxPx.width - STRIP_PX / 2,
top: boxPx.y,
width: STRIP_PX,
height: framePx.height,
height: boxPx.height,
pointerEvents: "auto",
...rotOn(framePx.x + framePx.width - STRIP_PX / 2, framePx.y),
}}
onPointerDown={(e) =>
beginDrag(e, isFixedTextbox ? "wright" : "move", { x: 0, y: 0 })
@ -647,7 +520,6 @@ 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}
@ -656,39 +528,6 @@ 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>
)}
</>
)}
@ -784,20 +623,6 @@ 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}
@ -815,28 +640,16 @@ 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
· sel-panel-btn ขยาย hit 36px บนจอสัมผัสเหมือนปุ่มอื่นของแผง (UX audit 2026-09-28) */}
(editable) จึงไม่ต้องเช็ค userId ซ้ำ / root มี stopPropagation คลิกไม่หลุดไป deselect */}
<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}`}
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}`}
onClick={() => onStartEdit?.(annotation.id)}
>
<Pencil className="h-3.5 w-3.5" />

View file

@ -20,7 +20,6 @@ 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";
@ -304,7 +303,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, measureStampText);
const b = annotationBBox({ type: "STAMP", data: s.data }, measured);
if (
lp.x >= b.x - pad &&
lp.x <= b.x + b.width + pad &&

View file

@ -96,13 +96,8 @@ 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,
@ -135,7 +130,6 @@ import {
COMMENT_PIN_SIZE_PX,
TEXTBOX_DEFAULT_WIDTH_PCT,
annotationBBox,
clampStampIntoPage,
commentPinBoxPct,
hitTestPath,
hitTestShape,
@ -143,7 +137,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, { type AnnotationPatch } from "./AnnotationSelectionLayer";
import AnnotationSelectionLayer from "./AnnotationSelectionLayer";
import AnnotationSidebar, { byPageOrder } from "../sidebar/AnnotationSidebar";
import Select from "../Select";
import {
@ -196,8 +190,7 @@ type AnnotationAction =
/** กลุ่ม state/handler ของ TEXTBOX ที่ส่งรวมทีเดียวลง PdfPage (F17/B2)
* ตัว editor เป็น contentEditable uncontrolled — ข้อความ/สไตล์ (segments ตัวหนาเฉพาะส่วน)
* ส่งออกจาก editor ตอน commit เท่านั้น ไม่มี draft กลางทาง
* (editingId/commitEdit ใช้ร่วมกับ STAMP ด้วย — StampEditor แก้คำตรายางผ่าน id เดียวกัน) */
* ส่งออกจาก editor ตอน commit เท่านั้น ไม่มี draft กลางทาง */
type TextboxUI = {
composer: TextboxComposer | null;
commitComposer: (text: string, segments?: TextboxSegment[]) => void;
@ -512,7 +505,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 }, measureStampText);
const box = annotationBBox(a, { width: 100, height: 100 });
const padX = (4 / size.width) * 100;
const padY = (4 / size.height) * 100;
return (
@ -642,7 +635,6 @@ function PdfPage({
onPinToggle,
onAnnotationPreview,
onAnnotationUpdate,
onAnnotationPatch,
onSetResolved,
onAnnotationBookmark,
onCreateReply,
@ -699,9 +691,6 @@ 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;
@ -1191,7 +1180,6 @@ function PdfPage({
onDelete={onErase}
onBookmark={(y) => onAnnotationBookmark(selected, y)}
onStartEdit={textbox.startEdit}
onPatch={onAnnotationPatch}
/>
);
})()}
@ -1201,88 +1189,6 @@ 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,
@ -1335,11 +1241,6 @@ 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 ตั้งค่ากล่องข้อความ
@ -1467,7 +1368,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 }, measureStampText));
m.set(a.id, annotationBBox(a, { width: 100, height: 100 }));
}
return m;
}, [visibleAnnotations]);
@ -2230,93 +2131,36 @@ export default function PDFViewer({
});
}
// ─── คิว 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 resolve(); // ของคนอื่นแก้ไม่ได้ (D9)
setPreviewData(null);
startTransition(async () => {
try {
applyAnnotation({
kind: "update",
row: { ...current, data, updatedAt: new Date().toISOString() },
});
const saved = await updateAnnotation(id, user.userId, data);
setAnnotations((prev) => prev.map((a) => (a.id === saved.id ? saved : a)));
pushHistory({
undo: async () => {
const up = await updateAnnotation(id, user.userId, before);
setAnnotations((prev) => prev.map((a) => (a.id === up.id ? up : a)));
},
redo: async () => {
const up = await updateAnnotation(id, user.userId, data);
setAnnotations((prev) => prev.map((a) => (a.id === up.id ? up : a)));
},
});
} 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));
async function handleUpdateAnnotation(id: string, data: AnnotationData) {
if (!user) return;
const current = annotations.find((a) => a.id === id);
if (!current || current.userId !== user.userId) return; // ของคนอื่นแก้ไม่ได้ (D9)
const before = current.data; // ไว้ย้อนกลับตอน undo (F2)
setPreviewData(null);
await startTransition(async () => {
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({
undo: async () => {
const up = await updateAnnotation(id, user.userId, before);
setAnnotations((prev) => prev.map((a) => (a.id === up.id ? up : a)));
},
redo: async () => {
const up = await updateAnnotation(id, user.userId, data);
setAnnotations((prev) => prev.map((a) => (a.id === up.id ? up : a)));
},
});
} catch (err) {
console.error("แก้ไข annotation ไม่สำเร็จ", err); // React rollback optimistic row ให้เอง
showActionError("แก้ไขรายการไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
}
});
}
// ─── Resolve (F10/A5 — ใครก็ได้ toggle ตาม D6; ไม่เข้า history ของ F2
@ -2946,12 +2790,11 @@ export default function PDFViewer({
setTextboxComposer({ pageIndex, position });
return;
}
// F18/B5: คลิกบนหน้า = วางตรายางคำที่เลือกไว้ทันที (preset หรือคำที่พิมพ์เองจากเมนู —
// สี/ขนาดจากเมนูตั้งค่าของ tool เช่นกัน; ช่องคำว่าง/เว้นวรรคล้วน = preset แรก กัน label ว่าง)
// F18/B5: คลิกบนหน้า = วางตรายาง preset ที่เลือกไว้ทันที (สี/ขนาดจากเมนูตั้งค่าของ tool)
if (tool === "stamp" && user) {
const position = getPointerPercent(e, pageEl);
void handleCreateAnnotation(pageIndex, "STAMP", {
label: stampPreset.trim() || STAMP_PRESETS[0],
label: stampPreset,
position,
scale: stampScalePct,
color: stampColor,
@ -3033,7 +2876,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 }, measureStampText);
: annotationBBox(a, { width: 100, height: 100 });
scrollToBelowHeader(main, contentTopInScroll(main, r.top) + (r.height * boxPct.y) / 100);
}
@ -3062,25 +2905,23 @@ export default function PDFViewer({
// ─── Text box (F17/B2) ─────────────────────────────────────────────────────
/** เข้าสู่โหมดแก้ไขข้อความ inline (TEXTBOX double-click / STAMP ปุ่ม "แก้ไขข้อความ" — null = ยกเลิก) */
/** เข้าสู่โหมดแก้ไขข้อความ TEXTBOX ของตัวเอง (double-click — null = ยกเลิก) */
function startTextboxEdit(id: string | null) {
setEditingTextboxId(id);
}
/** บันทึกข้อความที่แก้ (TEXTBOX inline / STAMP ผ่าน StampEditor) — ส่งเป็น patch ให้คิว
* update ผสานกับ data ล่าสุด (สี/ขนาดที่เพิ่งเปลี่ยนในแผงก่อนหน้าไม่ถูกทิ้ง); segments
* (ตัวหนาเฉพาะส่วน) เก็บเฉพาะ TEXTBOX เมื่อยังมีส่วนที่เป็นตัวหนา ไม่งั้นเคลียร์ทิ้ง */
/** บันทึกข้อความที่แก้ — reuse updateAnnotation ของ F1 (เจ้าของเท่านั้น การกันอยู่ที่ handler)
* segments (ตัวหนาเฉพาะส่วน) เก็บเฉพาะเมื่อยังมีส่วนที่เป็นตัวหนา ไม่งั้นเคลียร์ทิ้ง */
function commitTextboxEdit(id: string, text: string, segments?: TextboxSegment[]) {
setEditingTextboxId(null);
const current = annotations.find((a) => a.id === id);
if (!current) return;
if (!current || current.type !== "TEXTBOX") return;
setEditingTextboxId(null);
const hasBold = !!segments?.some((s) => s.bold);
handlePatchAnnotation(
id,
current.type === "STAMP"
? { kind: "label", label: text }
: { kind: "textboxText", text, segments: hasBold ? segments ?? [] : null },
);
void handleUpdateAnnotation(id, {
...current.data,
text,
segments: hasBold ? segments : undefined,
});
}
/** บันทึกกล่องข้อความใหม่จาก composer ณ ตำแหน่งที่คลิก (ค่าทั้งหมดจากเมนูตั้งค่าของ tool) */
@ -3663,19 +3504,9 @@ export default function PDFViewer({
);
}
// F18/B5: ปุ่มตรายาง = dropdown เลือก preset — เลือกคำกำหนดเองแล้วปุ่มแสดง
// "กำหนดเอง" แทนคำเต็ม (คำจริงเห็นในเมนู/preview ตอนเลือกอยู่แล้ว)
// F18/B5: ปุ่มตรายาง = dropdown เลือก preset
if (key === "stamp") {
const btnText = STAMP_PRESETS.includes(stampPreset)
? stampPreset
: "กำหนดเอง";
/** จบหน้ากำหนดเองแบบเลือกค่าจบ (ปุ่ม "ใช้ตรายางนี้"/Enter): ช่องว่างล้วน =
* กลับ preset แรก ให้สถานะทุกจุด (ปุ่ม tool/แถวเมนู/ตอนวาง) สอดคล้องกัน —
* ปิดเมนูด้วย setState ตรง ไม่ผ่าน markMenuClosed ให้แตะหน้าวางได้ทันที */
const finishCustom = () => {
if (!stampPreset.trim()) setStampPreset(STAMP_PRESETS[0]);
setStampMenuOpen(false);
};
const btnText = stampPreset;
return (
<div key={key} ref={stampMenuRef} className="relative">
<button
@ -3691,10 +3522,7 @@ export default function PDFViewer({
// switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน)
const reopen = tool !== "stamp" || !stampMenuOpen;
switchTool("stamp");
if (reopen) {
setStampMenuOpen(true);
setStampCustomMode(false); // เปิดเมนูใหม่ = กลับหน้ารายการ preset
}
if (reopen) setStampMenuOpen(true);
}}
>
<Icon className="h-4 w-4" />
@ -3718,182 +3546,44 @@ 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}
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} ${
// L1: รายการที่เลือก = state "active" ใช้ primary tint เหมือน
// เมนูอื่น (แดงสื่อ "อันตราย/ถาวร" เกินเหตุ)
stampPreset === preset
? "bg-primary/10 text-primary"
: "text-ink-soft hover:bg-ink/[0.05]"
}`}
onClick={() => {
setStampPreset(preset);
// แถวเดี่ยวเลือกจบ = ปิดเมนูทันที แต่ห้ามผ่าน markMenuClosed
// (guard กลืน tap 300ms — แถวปรับค่าสี/ขนาดยังเปิดค้างให้ปรับต่อ)
setStampMenuOpen(false);
}}
>
<span className="flex-1">{preset}</span>
{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} />
</PanelRow>
<PanelRow label="ขนาด">
{/* ความกว้างกรอบตรายางเป็น % ของความกว้างหน้า */}
<NumberCombo
value={stampScalePct}
options={[12, 18, 25, 40]}
min={5}
max={60}
step={1}
suffix="%"
onChange={setStampScalePct}
/>
</PanelRow>
</>
)}
{STAMP_PRESETS.map((preset) => (
<button
key={preset}
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} ${
// L1: รายการที่เลือก = state "active" ใช้ primary tint เหมือน
// เมนูอื่น (แดงสื่อ "อันตราย/ถาวร" เกินเหตุ)
stampPreset === preset
? "bg-primary/10 text-primary"
: "text-ink-soft hover:bg-ink/[0.05]"
}`}
onClick={() => {
setStampPreset(preset);
// แถวเดี่ยวเลือกจบ = ปิดเมนูทันที แต่ห้ามผ่าน markMenuClosed
// (guard กลืน tap 300ms — แถวปรับค่าสี/ขนาดยังเปิดค้างให้ปรับต่อ)
setStampMenuOpen(false);
}}
>
<span className="flex-1">{preset}</span>
{stampPreset === preset && <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} />
</PanelRow>
<PanelRow label="ขนาด">
{/* ความกว้างกรอบตรายางเป็น % ของความกว้างหน้า */}
<NumberCombo
value={stampScalePct}
options={[12, 18, 25, 40]}
min={5}
max={60}
step={1}
suffix="%"
onChange={setStampScalePct}
/>
</PanelRow>
</div>,
document.body
)}
@ -4403,7 +4093,6 @@ export default function PDFViewer({
onAnnotationUpdate={(id, data) => {
void handleUpdateAnnotation(id, data);
}}
onAnnotationPatch={handlePatchAnnotation}
onSetResolved={(id, resolved) => {
void handleSetResolved(id, resolved);
}}
@ -4445,7 +4134,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 }, measureStampText);
const box = annotationBBox(hovered, { width: w, height: h });
const note = annotationNote(hovered);
// เหลือพื้นที่ขวาของ bbox ไม่พอ (~264px) → วางทางซ้ายแทน
const flipLeft = box.x + box.width + 264 > w;

View file

@ -1,26 +1,17 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef } from "react";
import type { StampData, TextboxData, TextboxSegment } from "@/db/schema";
import { toPx, type Point, type Size } from "@/utils/coordinates";
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";
import { STAMP_HEIGHT_RATIO, textboxMaxWidthPct } from "@/utils/geometry";
import { STAMP_COLOR, stampFitFontSize } 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 — พิกัด % ทั้งหมด */
@ -29,8 +20,9 @@ export type InkTextbox = { id: string; userId: string; data: TextboxData };
/** ตรายางจาก DB (F18/B5) — พิกัด % ทั้งหมด */
export type InkStamp = { id: string; userId: string; data: StampData };
// ฟอนต์ตรายางบนจอ + canvas measure ย้ายไป utils/annotation-display.ts แล้ว
// (2026-09-28) ให้ preview ในเมนูของ tool ใช้ตัววัดเดียวกันกับตอน render จริง
// ฟอนต์ตรายางบนจอ — ตั้งชัดทั้งตอนวัด (canvas) และตอน render เพื่อให้ความกว้าง
// ที่ stampFitFontSize คำนวณตรงกับที่แสดงจริง (ตัวหนา 700 เหมือน class font-bold)
const STAMP_FONT_STACK = "system-ui, sans-serif";
/** typography audit 2026-09-16 (T4.4 + Option B): ฟอนต์กล่องข้อความบนจอ — pin ชัดทั้ง
* editor และ render ไม่สืบทอดจาก body: กล่องกว้างเป็น % คงที่ ฟอนต์เปลี่ยนตามเครื่อง
@ -38,6 +30,55 @@ export type InkStamp = { id: string; userId: string; data: StampData };
* ตรงกันทุกอุปกรณ์จริง (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/ของกล่อง) */
@ -74,23 +115,12 @@ function TextboxEditor({
onCancel: () => void;
}) {
const ref = useRef<HTMLDivElement | null>(null);
// จบ 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),
}));
// initial HTML คำนวณ/คืน object เดิมจนกว่า annotation จะเปลี่ยน — editor เป็น
// uncontrolled (พิมพ์ใน DOM ตรง ๆ) React จึงไม่ยุ่งกับ innerHTML ระหว่างพิมพ์
const initialHtml = useMemo(
() => ({ __html: segmentsToHtml(initialText, initialSegments) }),
[initialText, initialSegments],
);
// focus + วาง caret ท้ายข้อความตอนเปิด editor
useEffect(() => {
@ -109,11 +139,9 @@ 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();
});
const { text, segments } = serializeEditor(el);
if (text.trim()) onSave(text, segments);
else onCancel();
};
return (
@ -144,32 +172,17 @@ 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();
finish(onCancel);
return;
onCancel();
}
// 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 ก่อน)
// เลือกข้อความแล้วกด Ctrl/Cmd+B = ทำตัวหนาเฉพาะส่วนที่เลือก
// FIX-12: document.execCommand ถูก mark deprecated แต่
// ยังทำงานทุก browser ปัจจุบัน และไม่มี replacement ตรง ๆ ของ contentEditable
// (bold/insertLineBreak/insertText ตรงนี้และใน onPaste ด้านล่าง) — ใช้ต่อโดยรับความเสี่ยงไว้
// (bold/insertText ตรงนี้และใน onPaste ด้านล่าง) — ใช้ต่อโดยรับความเสี่ยงไว้
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "b") {
e.preventDefault();
if (!bold) document.execCommand("bold");
document.execCommand("bold");
}
}}
onPaste={(e) => {
@ -181,9 +194,7 @@ function TextboxEditor({
e.clipboardData.getData("text/plain"),
);
}}
// ขอบประใช้ 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"
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"
style={{
fontFamily: TEXTBOX_FONT_STACK,
fontSize: toPx(fontSizePct, size.width),
@ -198,174 +209,6 @@ 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,
@ -432,10 +275,6 @@ 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),
@ -454,22 +293,11 @@ 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);
@ -501,24 +329,39 @@ export default function TextboxStampLayer({
onCancel={onCancelComposer}
/>
)}
{/* STAMP (F18/B5) — กรอบข้อความสไตล์ตรายาง กว้าง = scale% ของหน้า สูง 0.4 เท่า
(§5): คำสั้นอยู่บรรทัดเดียว คำยาว wrap ≤3 บรรทัด (2026-09-28) — ตัว render
อยู่ใน StampItem (memo), โหมดแก้ข้อความ (ปุ่ม "แก้ไขข้อความ" ในแผง selection)
= StampEditor แทนกรอบเดิม */}
{stamps.map((s) =>
editingId === s.id ? (
<StampEditor
{/* 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
key={s.id}
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} />
),
)}
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>
);
})}
</div>
);
}

View file

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

File diff suppressed because one or more lines are too long

View file

@ -23,24 +23,8 @@ const CONTAINER_CLASS = "pdf-custom-select";
* ไม่ capture pointer ไม่สร้าง selection — click ของ annotation/pin ใช้ได้ตามเดิม) */
const MOVE_THRESHOLD_PX = 8;
/** อ่าน caret จากจุด y ตรงนิ้วเลื่อนขึ้น 12px — นิ้วบังข้อความที่กำลังเลือก
* (เฉพาะ pointerType touch; เมาส์/สไตลัสชี้แม่นอยู่แล้ว) — ค่านี้เป็น "เพดาน" เท่านั้น:
* offset จริงของแต่ละ gesture ถูก cap ด้วยความสูงบรรทัดผ่าน fingerOffsetFor (บรรทัด
* PDF บน tablet zoom-fit สูงแค่ ~14px — หักตายตัว 12px ทำ probe หลุดขอบบนบรรทัด
* ไปโดน heading/บรรทัดอื่น จนลากแนวนอนบรรทัดเดียวแล้วเงียบไม่สร้างมาร์ก — รายงาน
* 2026-09-28) */
* (เฉพาะ pointerType touch; เมาส์/สไตลัสชี้แม่นอยู่แล้ว) */
const FINGER_OFFSET_PX = 12;
/** offset ของ gesture นี้ = min(12, ครึ่งความสูงบรรทัด − 2) วัดจาก span ของ anchor
* ที่ resolve ได้ (span ของ textLayer สูงเท่าบรรทัด) — บรรทัดใหญ่ (≥28px) ได้ 12 เท่า
* เดิม, บรรทัดเตี้ย 14px ได้ 5px จนยังอยู่ในบรรทัด; วัดไม่ได้ = 12 ตามเดิม */
function fingerOffsetFor(caret: Range): number {
const node = caret.startContainer;
const el = node instanceof Element ? node : node.parentElement;
const span = el?.closest(".textLayer span");
const h = span?.getBoundingClientRect().height ?? 0;
if (!(h > 4)) return FINGER_OFFSET_PX;
return Math.min(FINGER_OFFSET_PX, Math.max(2, h / 2 - 2));
}
/** นิ้วอยู่ใกล้ขอบของพื้นที่เอกสาร (ทั้ง 4 ด้าน) แค่นี้ = auto-scroll ต่อ (rAF loop) */
const AUTOSCROLL_MARGIN_PX = 60;
const AUTOSCROLL_MAX_SPEED_PX = 12;
@ -91,23 +75,11 @@ function findFirstTextNode(node: Node): Node | null {
return null;
}
/** ทิศ retry ของ resolveTextCaret — รอยต่อระหว่าง span บางกว่า 2–3px มักพอ */
const RETRY_OFFSETS: ReadonlyArray<readonly [number, number]> = [
[0, -2], [0, 2], [-2, 0], [2, 0],
[0, -3], [0, 3], [-3, 0], [3, 0],
];
/** เหมือน caretFromPoint แต่ guarantee ว่าถ้าหา text node ได้จะคืน Range ที่
* startContainer เป็น TEXT_NODE เสมอ — กันเคสตกรอยต่อ span/margin/padding
* ที่ caretRangeFromPoint คืน element แทน text node (ทำ onPointerDown bail
* เงียบจน drag ไม่เริ่มเลย — bug report จุดที่ 1)
* maxRetries: ระหว่างลากส่งค่าน้อย (2) — fail แล้วมีทางออกอยู่แล้วคือคง
* lastFocus ของเฟรมก่อน ไม่ควรเสีย ~6ms/เฟรม retry ครบ 8 ทิศเหมือนตอนกด (B1) */
function resolveTextCaret(
x: number,
y: number,
maxRetries = RETRY_OFFSETS.length,
): Range | null {
* เงียบจน drag ไม่เริ่มเลย — bug report จุดที่ 1) */
function resolveTextCaret(x: number, y: number): Range | null {
const first = caretFromPoint(x, y);
const fromElement = (r: Range | null): Range | null => {
if (!r) return null;
@ -121,9 +93,6 @@ function resolveTextCaret(
const textNode =
child?.nodeType === Node.TEXT_NODE ? child : findFirstTextNode(child ?? el);
if (!textNode) return null;
// container ใหญ่จน DFS หลุดออกนอก textLayer (เช่นตก page div) — text node
// แรกของหน้าไม่ใช่ข้อความที่จุดนั้น ทิ้งดีกว่าเด้งไปมุมบนซ้าย (A6)
if (!textNode.parentElement?.closest(".textLayer")) return null;
const nr = document.createRange();
nr.setStart(textNode, 0);
nr.collapse(true);
@ -133,8 +102,12 @@ function resolveTextCaret(
const resolved = fromElement(first);
if (resolved) return resolved;
// ยังไม่เจอ — retry ขยับพิกัดรอบจุดเดิม
for (const [dx, dy] of RETRY_OFFSETS.slice(0, maxRetries)) {
// ยังไม่เจอ — retry ขยับพิกัดรอบจุดเดิม (รอยต่อบางกว่า 2-3px มักพอ)
const offsets: Array<[number, number]> = [
[0, -2], [0, 2], [-2, 0], [2, 0],
[0, -3], [0, 3], [-3, 0], [3, 0],
];
for (const [dx, dy] of offsets) {
const r = fromElement(caretFromPoint(x + dx, y + dy));
if (r) return r;
}
@ -145,32 +118,17 @@ function resolveTextCaret(
* ข้อความอยู่บริเวณนั้นจริง ๆ (กลาง margin กว้าง ๆ ของหน้า) ค่อย bail */
const NEAREST_SPAN_MAX_PX = 40;
/** รายการ span ของหน้าพร้อมพิกัดแบบสัดส่วน 0–1 ของหน้า — วัดครั้งเดียวต่อ gesture
* (getBoundingClientRect ทั้งหน้า ~3,000 span แพง ~2.3ms ต่อครั้ง) เก็บเป็น
* fraction แบบเดียวกับ anchorRel: ทนทั้งการเลื่อนหน้าระหว่างลาก และ re-fit
* ความกว้างหน้ากลาง gesture (นิ้วที่สอง toggle sidebar — code review 2.3
* 2026-09-28) โดยบวก rect ปัจจุบันของหน้าคืนตอนใช้ (B2) */
type SpanItem = {
el: Element;
top: number;
bottom: number;
left: number;
right: number;
};
type SpanCache = { pageEl: HTMLElement; items: SpanItem[] } | null;
/** cache รายชื่อ span ของหน้า ต่อ 1 gesture — query ครั้งเดียว (textLayer ไม่
* เปลี่ยนระหว่างลาก) ใช้กับการหา span ใกล้สุดเฉพาะตอน pointerdown */
type SpanCache = { pageEl: HTMLElement; spans: Element[] } | null;
/** caret ใน span ที่ตำแหน่งสัดส่วน x (0–1) ของความกว้าง span — ชั้น 3–4 ของ
* resolver เดิมวางที่ต้น span เสมอ (pdf.js มักครอบทั้งบรรทัดใน span เดียว) จน
* anchor/focus เด้งไปต้นบรรทัดแทนตำแหน่งนิ้ว; ประมาณ offset จากสัดส่วนตัวอักษร
* (ฟอนต์ไม่ monospace คลาดได้เล็กน้อย — snapCaretToWord ปรับลงขอบคำให้)
* len−1 กัน offset ตกท้าย text node ที่ word snap ไม่ขยาย (A5) */
function caretInSpanAt(span: Element, ratio: number): Range | null {
/** caret ที่ต้น text node แรกของ span — offset 0 พอ (กลางคำก็ได้ เพราะ
* snapCaretToWord จะขยายเต็มคำให้อยู่แล้ว) */
function caretAtSpanStart(span: Element): Range | null {
const t = findFirstTextNode(span);
const len = t?.textContent?.length ?? 0;
if (!t || len === 0) return null;
const off = Math.min(len - 1, Math.max(0, Math.round(ratio * len)));
if (!t) return null;
const r = document.createRange();
r.setStart(t, off);
r.setStart(t, 0);
r.collapse(true);
return r;
}
@ -185,8 +143,7 @@ function spanFromPointStack(x: number, y: number): Range | null {
// เฉพาะ span ของ textLayer เท่านั้น (ตัว container .textLayer เองไม่ใช่ —
// จุดไม่ได้อยู่บน span ไหนเลยเป็นงานของการหาใกล้สุดในชั้นถัดไป)
if (el.tagName !== "SPAN" || !el.closest(".textLayer")) continue;
const rc = el.getBoundingClientRect();
const caret = caretInSpanAt(el, rc.width > 0 ? (x - rc.left) / rc.width : 0);
const caret = caretAtSpanStart(el);
if (caret) return caret;
}
return null;
@ -204,43 +161,28 @@ function nearestSpanCaret(
const pageEl =
document.elementFromPoint(x, y)?.closest<HTMLElement>("[data-page]") ?? null;
if (!pageEl) return null;
const pr = pageEl.getBoundingClientRect();
if (!cache.spanCache || cache.spanCache.pageEl !== pageEl) {
const items: SpanItem[] = [];
for (const el of Array.from(pageEl.querySelectorAll(".textLayer span"))) {
const r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) continue;
items.push({
el,
top: (r.top - pr.top) / pr.height,
bottom: (r.bottom - pr.top) / pr.height,
left: (r.left - pr.left) / pr.width,
right: (r.right - pr.left) / pr.width,
});
}
cache.spanCache = { pageEl, items };
cache.spanCache = {
pageEl,
spans: Array.from(pageEl.querySelectorAll(".textLayer span")),
};
}
// จุดเป็น fraction เดียวกับ cache — pr อ่านใหม่ทุกครั้งจึงตามทั้งการเลื่อนหน้า
// และ re-fit ความกว้าง; รัศมีค้นหา (px) แปลงเป็น fraction ของความสูงหน้าปัจจุบัน
const fx = (x - pr.left) / pr.width;
const fy = (y - pr.top) / pr.height;
const maxDy = NEAREST_SPAN_MAX_PX / pr.height;
let best: SpanItem | null = null;
let best: Element | null = null;
let bestDy = Infinity;
let bestDx = Infinity;
for (const it of cache.spanCache.items) {
const dy = fy < it.top ? it.top - fy : fy > it.bottom ? fy - it.bottom : 0;
if (dy > maxDy) continue;
const dx = Math.abs((it.left + it.right) / 2 - fx);
for (const s of cache.spanCache.spans) {
const r = s.getBoundingClientRect();
if (r.width === 0 && r.height === 0) continue;
const dy = y < r.top ? r.top - y : y > r.bottom ? y - r.bottom : 0;
if (dy > NEAREST_SPAN_MAX_PX) continue;
const dx = Math.abs(r.left + r.width / 2 - x);
if (dy < bestDy || (dy === bestDy && dx < bestDx)) {
best = it;
best = s;
bestDy = dy;
bestDx = dx;
}
}
if (!best) return null;
const w = best.right - best.left;
return caretInSpanAt(best.el, w > 0 ? (fx - best.left) / w : 0);
return best ? caretAtSpanStart(best) : null;
}
/** resolveTextCaret ครบทั้ง 4 ชั้น (เรียงตามต้นทุน): caret API + ขยับ ±3px →
@ -274,8 +216,7 @@ function getSegmenter(): Intl.Segmenter | null {
* กลางคำแล้วได้ทั้งคำ เหมือน selection ของ OS) — ใช้ Intl.Segmenter ถ้ามี (แยกคำ
* ไทยได้ด้วย dictionary) fallback เป็น regex \S+ — ถ้า caret อยู่บนช่องว่างล้วน
* หรือไม่ใช่ text node = ไม่ขยาย */
// export เพื่อ unit test (tests/hooks/) — ตัว hook เองยังใช้ภายในอย่างเดียว
export function snapCaretToWord(caret: Range): void {
function snapCaretToWord(caret: Range): void {
const node = caret.startContainer;
if (node.nodeType !== Node.TEXT_NODE) return;
const text = node.textContent ?? "";
@ -303,21 +244,16 @@ export function snapCaretToWord(caret: Range): void {
}
/** Range จาก caret สองจุด (จัดลำดับ start/end ให้ถูกทิศการลาก) — null เมื่อคนละ
* tree จน compare ไม่ได้
* ฝั่งหลัง (b หรือ a ตามทิศ) ต้องใช้ boundary **end** ไม่ใช่ start: anchor/focus
* ผ่าน snapCaretToWord แล้วเป็น range ครอบคำ (ไม่ collapsed) การอ่าน start จะตัด
* คำท้ายทิ้ง (ลาก alpha→gamma ได้แค่ alpha beta) และลากในคำเดียว (a==b) จะได้
* selection ว่าง — bug report A1 2026-09-28 */
// export เพื่อ unit test (tests/hooks/) — ตัว hook เองยังใช้ภายในอย่างเดียว
export function rangeBetween(a: Range, b: Range): Range | null {
* tree จน compare ไม่ได้ */
function rangeBetween(a: Range, b: Range): Range | null {
const r = document.createRange();
try {
if (a.compareBoundaryPoints(Range.START_TO_START, b) <= 0) {
r.setStart(a.startContainer, a.startOffset);
r.setEnd(b.endContainer, b.endOffset);
r.setEnd(b.startContainer, b.startOffset);
} else {
r.setStart(b.startContainer, b.startOffset);
r.setEnd(a.endContainer, a.endOffset);
r.setEnd(a.startContainer, a.startOffset);
}
} catch {
return null;
@ -376,24 +312,16 @@ function clampToAnchorPage(
type DragState = {
pointerId: number;
pointerType: string;
/** ระยะหักขึ้นของจุด probe (เฉพาะ touch — กันนิ้วบังข้อความ) คำนวณครั้งเดียวตอน
* pointerdown จากความสูงบรรทัดของ anchor (fingerOffsetFor) ใช้ทั้ง probe
* ระหว่างลากและเส้นทางกรอง rect; เมาส์/สไตลัส = 0 */
fingerOffset: number;
/** จุด pointerdown จริง (client) — ground truth ของเส้นทางลาก ใช้กรอง rect */
anchorPt: ClientPt;
/** ตำแหน่ง pointer ล่าสุด */
curPt: ClientPt;
/** caret ที่จุดกด (word-snap ตอนเริ่ม select จริง — ครั้งเดียว) */
anchor: Range;
/** จุด probe ที่ resolve anchor ได้ เก็บเป็นสัดส่วน 0–1 ของหน้า anchor (จอสัมผัส =
* จุด y−offset หรือจุดแตะจริงลูกใดลูกหนึ่ง) — แปลงกลับเป็นพิกัดจอใหม่ทุกเฟรม
* เพื่อเป็นต้นเส้นทางของ rectsInSweptPath ให้ตรงแนวกับ caret จริง (ใช้พิกัดนิ้ว
* raw ตรง ๆ จะคลาดเท่า offset จนบรรทัดเป้าหมายโดนกรองทิ้ง — code review 🚨;
* เก็บเป็นสัดส่วนเพราะพิกัด client คงที่จะโกหกทันทีที่ auto-scroll เลื่อนเนื้อหา
* กลางลาก จนบรรทัดต้นทางหายจากทั้ง preview และค่าที่บันทึก — bug report A2
* 2026-09-28) */
anchorRel: { x: number; y: number };
/** จุด probe ที่ resolve anchor ได้ (จอสัมผัส = จุด y−12 หรือจุดแตะจริงลูกใด
* ลูกหนึ่ง) — ใช้เป็นต้นเส้นทางของ rectsInSweptPath ให้ตรงแนวกับ caret จริง
* (ใช้พิกัดนิ้ว raw ตรง ๆ จะคลาด 12px จนบรรทัดเป้าหมายโดนกรองทิ้ง — code review 🚨) */
anchorPathPt: ClientPt;
/** หน้าของ anchor — เก็บตั้งแต่ pointerdown (anchor ไม่ย้ายหน้าระหว่างลาก)
* ใช้ clamp ตอนลากล้ำเข้าหน้าถัดไป (จุดที่ 3 ของ bug report) */
anchorPageEl: HTMLElement;
@ -474,19 +402,17 @@ export function useCustomSelection({
setPreviewRects(null);
return;
}
const y = d.curPt.y - d.fingerOffset;
const y = d.curPt.y - (d.pointerType === "touch" ? FINGER_OFFSET_PX : 0);
// ระหว่างลากใช้เฉพาะชั้นถูกของ resolveTextCaret — ผ่านพื้นที่ว่าง (gap กว้าง /
// ช่องว่างระหว่างหน้า) หาไม่เจอก็คง focus เดิมจาก frame ก่อนไว้แทน re-scan
// ด้วยชั้นแพง (perf note ของ bug report — selection หยุดรอที่ข้อความสุดท้าย
// แล้วขยายต่อเมื่อนิ้วถึงข้อความอีกครั้ง)
let focus = resolveTextCaret(d.curPt.x, y, 2);
let focus = resolveTextCaret(d.curPt.x, y);
if (!focus) {
d.failStreak++;
// fail ต่อเนื่องเกิน 3 frame = ลองชั้นแพงกว่าก่อนยอม fallback ไปตำแหน่งเดิม
// (ส่ง d ตรง ๆ ให้ cache ที่ชั้นแพงสร้างถูกเก็บกลับที่ gesture ใช้ต่อ — เดิม
// ห่อ object ใหม่ทุกครั้ง cache จึงไม่เคยติด A7.5)
if (d.failStreak > 3 && d.failStreak % 5 === 0) {
focus = resolveTextCaretDeep(d.curPt.x, y, d);
focus = resolveTextCaretDeep(d.curPt.x, y, { spanCache: d.spanCache });
}
}
if (focus) {
@ -505,16 +431,7 @@ export function useCustomSelection({
}
snapCaretToWord(focus);
let range = rangeBetween(d.anchor, focus);
if (range && range.collapsed) {
// ผู้ใช้ย่อ selection จนว่างจริง (anchor==focus) — เคลียร์ payload ให้
// commit ตรงกับ preview เสมอ: ปล่อยนิ้วตอนนี้ = ไม่สร้างมาร์ก ต่างจาก
// frame ที่ rectsInSweptPath กรองว่าง "ชั่วคราว" ซึ่งยังคง lastRef ตาม
// เจตนาเดิมของ bug report จุดที่ 2 (A4)
lastRef.current = null;
setPreviewRects(null);
return;
}
let pageEl = range ? pageOfRange(range) : null;
let pageEl = range && !range.collapsed ? pageOfRange(range) : null;
// ลากล้ำเข้าหน้าอื่น (คร่อม 2 หน้า หรือ focus ข้ามไปหน้าถัดไปแล้ว) — clamp
// ฝั่ง focus ไว้ที่ท้าย/ต้นหน้าของ anchor แทนการทิ้ง selection ทั้งเส้น
// (จุดที่ 3 ของ bug report — ข้ามเมื่อ range collapsed: anchor==focus ใน
@ -531,24 +448,18 @@ export function useCustomSelection({
return;
}
const rects = Array.from(range.getClientRects()).filter((r) => r.width > 0);
// เส้นทางกรองต้องอยู่แนวเดียวกับ caret ที่ selection อิง (จอสัมผัส = จุดที่
// หัก fingerOffset แล้ว) — ใช้พิกัดนิ้ว raw ตรง ๆ แล้วคลาดจนบรรทัดเป้าหมายโดน
// เส้นทางกรองต้องอยู่แนวเดียวกับ caret ที่ selection อิง (จุด y−12 สำหรับ
// จอสัมผัส) — ใช้พิกัดนิ้ว raw ตรง ๆ แล้วคลาด 12px จนบรรทัดเป้าหมายโดน
// lineTol ตัดทิ้ง (ลากขึ้น) หรือบรรทัดสุดท้ายกลายเป็น "บรรทัดกลาง" จนมาร์ก
// เกิน (ลากลง) เมื่อฟอนต์เล็ก/zoom ต่ำ (code review 🚨)
const pathEnd: ClientPt =
d.pointerType === "touch" ? { x: d.curPt.x, y } : d.curPt;
const pr = pageEl.getBoundingClientRect();
// ต้นเส้นทางแปลงจากสัดส่วนของหน้าใหม่ทุกเฟรม (A2) — anchor ต้องเลื่อนตาม
// เนื้อหาตอน auto-scroll ไม่ใช่ค้างที่พิกัดจอตอนกดนิ้ว
const anchorNow: ClientPt = {
x: pr.left + d.anchorRel.x * pr.width,
y: pr.top + d.anchorRel.y * pr.height,
};
const kept = rectsInSweptPath(rects, anchorNow, pathEnd);
const kept = rectsInSweptPath(rects, d.anchorPathPt, pathEnd);
if (kept.length === 0) {
setPreviewRects(null);
return;
}
const pr = pageEl.getBoundingClientRect();
const rectsPct = mergeRectsByLine(
kept.map((r) =>
rectToPercent(
@ -564,7 +475,7 @@ export function useCustomSelection({
);
lastRef.current = {
range: range.cloneRange(),
start: anchorNow,
start: d.anchorPathPt,
end: pathEnd,
};
const pageIndex = Number(pageEl.dataset.page);
@ -608,10 +519,7 @@ export function useCustomSelection({
Math.max(
1,
Math.ceil(
// cap ระยะเลยขอบที่ margin — นิ้วอยู่นอกพื้นที่เอกสารไกล ๆ ต้องไม่
// เร็วกว่า AUTOSCROLL_MAX_SPEED_PX (A7.1)
(Math.min(into, AUTOSCROLL_MARGIN_PX) / AUTOSCROLL_MARGIN_PX) *
AUTOSCROLL_MAX_SPEED_PX,
(into / AUTOSCROLL_MARGIN_PX) * AUTOSCROLL_MAX_SPEED_PX,
),
);
let dx = 0;
@ -635,9 +543,9 @@ export function useCustomSelection({
scrollRafRef.current = requestAnimationFrame(step);
};
const seenMovePointerIds = new Set<number>();
const onPointerDown = (e: PointerEvent) => {
// อุปกรณ์ hybrid (จอสัมผัส + เมาส์): คลิกขวา/กลางไม่ใช่การเริ่มเลือกข้อความ (A7.4)
if (e.pointerType === "mouse" && e.button !== 0) return;
if (dragRef.current) {
// นิ้วที่สองระหว่างลาก — ไม่สน รวมถึง touch ปลอมจาก palm rejection (มือ
// ที่พักบนจอระหว่างลากด้วย pen) — ห้ามให้ pointer ตัวใหม่แทนที่ drag เดิม
@ -672,38 +580,16 @@ export function useCustomSelection({
// ธรรมดา ไม่เริ่ม selection (click ของ annotation/pin ทำงานตามเดิม)
return;
}
// offset จริงของ gesture นี้จากความสูงบรรทัดของ anchor ที่เพิ่ง resolve ได้ —
// บรรทัดเตี้ยกว่านิ้ว (span ที่ probe ที่ −12 โดนสูง < 28px) ให้หักน้อยลงแล้ว
// resolve anchor ใหม่ที่จุดที่หักจริง เพื่อไม่ให้ anchor ค้างบรรทัดที่ probe
// เต็มพลาดไปโดน; resolve ใหม่ไม่ได้ = ใช้ anchor เดิมต่อ — ขอบเขต: ถ้า probe
// ที่ −12 โดน span สูง ≥ 28px (เช่น heading ใหญ่เหนือบรรทัดเป้าหมาย) offset
// ยังเป็น 12 จึงไม่ re-resolve — anchor ค้าง heading ตาม heuristic เดิม
// (code review 2.2 2026-09-28: comment เดิมอ้างว่าแก้เคส heading — เกินจริง)
const fingerOffset =
e.pointerType === "touch" ? fingerOffsetFor(caret) : 0;
if (fingerOffset < FINGER_OFFSET_PX) {
const adjustedY = e.clientY - fingerOffset;
const re = resolveTextCaretDeep(e.clientX, adjustedY, cache);
if (re) {
caret = re;
resolvedY = adjustedY;
}
}
const anchorPageEl = pageOfRange(caret);
if (!anchorPageEl) return;
const anchorPr = anchorPageEl.getBoundingClientRect();
// gesture ใหม่ = ล้างผลล่าสุดของ gesture ก่อน (จุดที่ 2 ของ bug report:
// lastRef เคลียร์เฉพาะตรงนี้กับตอน finish เท่านั้น)
lastRef.current = null;
dragRef.current = {
pointerId: e.pointerId,
pointerType: e.pointerType,
fingerOffset,
anchorPt: { x: e.clientX, y: e.clientY },
anchorRel: {
x: (e.clientX - anchorPr.left) / anchorPr.width,
y: (resolvedY - anchorPr.top) / anchorPr.height,
},
anchorPathPt: { x: e.clientX, y: resolvedY },
curPt: { x: e.clientX, y: e.clientY },
anchor: caret,
anchorPageEl,
@ -714,13 +600,14 @@ export function useCustomSelection({
anchorSnapped: false,
active: false,
};
// arm ตั้งแต่ pointerdown ไม่ใช่ตอนเลย threshold — tap ที่ไม่ขยับ (ไม่
// active) ก็ต้องมีตาข่ายรับ pointerup ของตัวเอง ไม่งั้น dragRef ค้างจน
// gesture ถัดไปเงียบหมด (เจอจริงหลังย้าย listener ออกจาก el — e2e 2026-09-28)
armDocUpListeners();
};
const onPointerMove = (e: PointerEvent) => {
// pen/touch ยิง 60–120Hz ถ้า log ทุก event จะท่วม console และหน่วงเครื่อง
// จนเปลี่ยนพฤติกรรมที่กำลังวินิจฉัยอยู่เอง
if (!seenMovePointerIds.has(e.pointerId)) {
seenMovePointerIds.add(e.pointerId);
}
const d = dragRef.current;
if (!d || e.pointerId !== d.pointerId) return;
d.curPt = { x: e.clientX, y: e.clientY };
@ -741,31 +628,11 @@ export function useCustomSelection({
schedulePreview();
};
const onUp = (e: PointerEvent) => finish(e, true);
const onCancel = (e: PointerEvent) => finish(e, false);
/** ตาข่ายรับ pointerup/pointercancel ระดับ document (capture phase) — ผูกตั้งแต่
* pointerdown ที่เริ่ม gesture จนถึง finish (รวม tap ที่ไม่ activate):
* Chromium กับ touch ถอด pointer capture กลาง drag ได้เอง (lostpointercapture
* ยิงทั้งที่ยังไม่ปล่อยนิ้ว — เห็นจริงใน e2e 2026-09-28) เมื่อ capture หาด
* pointerup ยิงที่ element ใต้นิ้วจริง ซึ่งอาจอยู่นอก <main> (header/sidebar
* ตอน auto-scroll ดันนิ้วค้างใกล้ขอบ) — ผูกที่ el อย่างเดียวจะไม่ได้ยิน จน
* dragRef ค้างและ gesture ถัดไปทุกตัวเงียบหมด (code review 2.1 2026-09-28) */
const armDocUpListeners = () => {
document.addEventListener("pointerup", onUp, true);
document.addEventListener("pointercancel", onCancel, true);
};
const disarmDocUpListeners = () => {
document.removeEventListener("pointerup", onUp, true);
document.removeEventListener("pointercancel", onCancel, true);
};
/** เคลียร์ state ทุกทาง — commit เฉพาะตอน pointerup ที่มี range ไม่ collapsed
* (pointercancel จาก gesture ของระบบ = ทิ้ง selection ไม่ commit) */
const finish = (e: PointerEvent, commit: boolean) => {
const d = dragRef.current;
if (!d || e.pointerId !== d.pointerId) return;
disarmDocUpListeners();
stopAutoScroll();
stopPreviewRaf();
if (d.active) {
@ -782,19 +649,17 @@ export function useCustomSelection({
el.addEventListener("pointerdown", onPointerDown);
el.addEventListener("pointermove", onPointerMove);
// pointerup/pointercancel ไม่ผูกที่ el — ใช้ตาข่าย document capture phase ต่อ
// ช่วง active (armDocUpListeners ด้านบน) เพื่อรอดเคส capture หลุดกลาง drag
// ห้ามผูก finish เข้า lostpointercapture: กับ touch ของ Chromium event นี้ยิง
// "กลาง drag" ได้เอง (การ re-hit-test ของ touch pointer ที่ implicit capture
// อยู่ที่ span ตั้งแต่ pointerdown แล้วถูกย้ายมา container ตอน setPointerCapture)
// ผูกแล้ว dragRef ถูกเคลียร์กลางทางจนทุก gesture เงียบหมด — ยืนยันด้วย
// Playwright + CDP touch แล้ว 2026-09-28 (A7.3 ของรายงาน review — ไม่ใช้)
const onUp = (e: PointerEvent) => finish(e, true);
const onCancel = (e: PointerEvent) => finish(e, false);
el.addEventListener("pointerup", onUp);
el.addEventListener("pointercancel", onCancel);
return () => {
el.classList.remove(CONTAINER_CLASS);
el.removeEventListener("pointerdown", onPointerDown);
el.removeEventListener("pointermove", onPointerMove);
disarmDocUpListeners();
el.removeEventListener("pointerup", onUp);
el.removeEventListener("pointercancel", onCancel);
stopAutoScroll();
stopPreviewRaf();
dragRef.current = null;

537
package-lock.json generated
View file

@ -28,7 +28,6 @@
"@types/react-dom": "^18.3.7",
"autoprefixer": "^10.5.5",
"drizzle-kit": "^0.31.10",
"jsdom": "^29.1.1",
"postcss": "^8.5.28",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
@ -48,212 +47,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz",
"integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.1.tgz",
"integrity": "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.1",
"@csstools/css-tokenizer": "^4.0.2"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.4.tgz",
"integrity": "sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.2",
"@csstools/css-calc": "^3.4.1"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.1",
"@csstools/css-tokenizer": "^4.0.2"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.1.tgz",
"integrity": "sha512-ShL8BqPfbKJrJiKFH0xBbN0i7Nrh9HXYRuF+pzyj93R/BL2YAsUxJeqANErzqM+0JGl7vjHp+3OIgd/DL69YIA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.2"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
"integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz",
"integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@drizzle-team/brocli": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
@ -1139,24 +932,6 @@
"node": ">=18"
}
},
"node_modules/@exodus/bytes": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
"integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@ -1980,6 +1755,7 @@
"integrity": "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~8.9.0"
}
@ -2149,16 +1925,6 @@
"node": ">=6.0.0"
}
},
"node_modules/bidi-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -2332,20 +2098,6 @@
"node": ">= 6"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -2366,27 +2118,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@ -3043,19 +2774,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/entities": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
@ -3282,19 +3000,6 @@
"node": ">= 0.4"
}
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@ -3357,13 +3062,6 @@
"node": ">=0.12.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jiti": {
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
@ -3381,48 +3079,6 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.3.5",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.25.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
@ -3716,16 +3372,6 @@
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/lucide-react": {
"version": "1.42.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.42.0.tgz",
@ -3745,13 +3391,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -3944,19 +3583,6 @@
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@ -4210,16 +3836,6 @@
"url": "https://github.com/sponsors/porsager"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -4288,16 +3904,6 @@
"node": ">=8.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@ -4399,19 +4005,6 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@ -4539,13 +4132,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
@ -4676,26 +4262,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tldts": {
"version": "7.4.16",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz",
"integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.16"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.16",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz",
"integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==",
"dev": true,
"license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -4709,32 +4275,6 @@
"node": ">=8.0"
}
},
"node_modules/tough-cookie": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@ -4781,16 +4321,6 @@
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "7.30.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz",
"integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
@ -5032,54 +4562,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@ -5096,23 +4578,6 @@
"engines": {
"node": ">=8"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
}
}
}

View file

@ -30,7 +30,6 @@
"@types/react-dom": "^18.3.7",
"autoprefixer": "^10.5.5",
"drizzle-kit": "^0.31.10",
"jsdom": "^29.1.1",
"postcss": "^8.5.28",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",

View file

@ -1,82 +0,0 @@
// @vitest-environment jsdom
// เทสต์ pure helpers ของ useCustomSelection ที่ต้องมี DOM Range จริง — แยกจาก
// ชุด pure utils (environment node ตาม vitest.config.ts) ด้วย pragma ต่อไฟล์
// ตัว hook เอง (Pointer Events + rAF + gesture จริง) ยืนยันด้วย Playwright แทน
import { describe, expect, it } from "vitest";
import { rangeBetween, snapCaretToWord } from "@/hooks/useCustomSelection";
/** text node เดียวใน body + collapsed caret ที่ offset (เลียน anchor/focus ของ
* useCustomSelection ก่อนผ่าน word snap) */
function setup(text: string, offset: number): { caret: () => Range } {
const node = document.createTextNode(text);
document.body.replaceChildren(node);
return {
caret: () => {
const r = document.createRange();
r.setStart(node, offset);
r.collapse(true);
return r;
},
};
}
describe("snapCaretToWord", () => {
it("จิ้มกลางคำ → ขยายครอบทั้งคำ (อังกฤษ)", () => {
const { caret } = setup("alpha beta gamma", 2);
const c = caret();
snapCaretToWord(c);
expect(c.toString()).toBe("alpha");
});
it("จิ้มกลางคำไทย (ไม่มีช่องว่าง) → Intl.Segmenter แยกคำให้ (node ≥16 มี full-icu)", () => {
const { caret } = setup("แมวกินปลา", 1);
const c = caret();
snapCaretToWord(c);
expect(c.toString()).toBe("แมว");
});
it("จิ้มกลางช่องว่างล้วน → ไม่ขยาย (ยัง collapsed)", () => {
const { caret } = setup("alpha beta", 6);
const c = caret();
snapCaretToWord(c);
expect(c.collapsed).toBe(true);
});
});
describe("rangeBetween — ฝั่งหลังต้องใช้ end boundary (A1: อย่าทิ้งคำท้าย)", () => {
const TEXT = "alpha beta gamma delta";
// offset: alpha 0–5, beta 6–10, gamma 11–16, delta 17–22
/** เลียน flow จริง: สร้าง caret สองจุด → word snap ทั้งคู่ → rangeBetween */
function between(offA: number, offB: number): string {
const node = document.createTextNode(TEXT);
document.body.replaceChildren(node);
const mk = (off: number) => {
const r = document.createRange();
r.setStart(node, off);
r.collapse(true);
return r;
};
const a = mk(offA);
const b = mk(offB);
snapCaretToWord(a);
snapCaretToWord(b);
return rangeBetween(a, b)?.toString() ?? "<null>";
}
it("ลาก alpha→gamma ได้ครบถึงคำท้าย (เดิมหาย gamma)", () => {
expect(between(2, 13)).toBe("alpha beta gamma");
});
it("ลากย้อน gamma→alpha ได้ผลเดียวกัน", () => {
expect(between(13, 2)).toBe("alpha beta gamma");
});
it("ลากในคำเดียว (beta→beta) ได้ทั้งคำ (เดิม selection ว่าง)", () => {
expect(between(7, 9)).toBe("beta");
});
it("ลาก alpha→beta ได้สองคำ", () => {
expect(between(2, 8)).toBe("alpha beta");
});
});

View file

@ -271,29 +271,3 @@ 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

@ -190,67 +190,3 @@ describe("rectsInSweptPath", () => {
expect(kept).toEqual([...L1]);
});
});
describe("rectsInSweptPath — snap จุดเข้าบรรทัดใกล้สุดก่อนตัดสิน (A3 2026-09-28)", () => {
// พิกัดนิ้วดิบที่ห่าง center บรรทัดเพียงเล็กน้อยแต่คนละบรรทัด เคยถูกตีเป็น
// "บรรทัดเดียวกัน" จนกรองได้ 0 rect — ชุดนี้ยืนยันว่า snap แล้วผลถูกต้อง
// (บรรทัดสูง 20 ระยะห่าง 24 → center = 24*i + 10 + dy, lineTol = 12)
const R2 = (x: number, y: number, width: number, height: number) =>
({ x, y, width, height }) as DOMRect;
const L = (i: number, x0: number, x1: number, dy = 0) =>
R2(x0, 24 * i + dy, x1 - x0, 20);
it("ลากลงหลายบรรทัด มีบรรทัดรบกวนนอกเส้นทาง: เก็บเฉพาะช่วงที่ลากผ่าน", () => {
const noise = [L(0, 0, 500), L(7, 0, 500)];
const ml = [L(1, 200, 500), L(2, 0, 500), L(3, 0, 500), L(4, 0, 300)];
expect(
rectsInSweptPath([...noise, ...ml], { x: 200, y: 34 }, { x: 300, y: 106 }),
).toHaveLength(4);
});
it("ลากขึ้นหลายบรรทัด (ทิศกลับ): ผลเหมือนลากลง", () => {
const noise = [L(0, 0, 500), L(7, 0, 500)];
const ml = [L(1, 200, 500), L(2, 0, 500), L(3, 0, 500), L(4, 0, 300)];
expect(
rectsInSweptPath([...noise, ...ml], { x: 300, y: 106 }, { x: 200, y: 34 }),
).toHaveLength(4);
});
it("ลากย้อนในบรรทัดเดียว (ขวา→ซ้าย): เก็บ rect ที่เส้นทางพาด", () => {
const rects = [L(0, 0, 500), L(1, 100, 400), L(7, 0, 500)];
expect(
rectsInSweptPath(rects, { x: 400, y: 34 }, { x: 100, y: 34 }),
).toHaveLength(1);
});
it("2 บรรทัด dy ใหญ่: เก็บทั้งคู่ (ท้ายบรรทัดบน → ต้นบรรทัดล่าง)", () => {
const rects = [L(1, 400, 500), L(2, 0, 100)];
expect(
rectsInSweptPath(rects, { x: 400, y: 34 }, { x: 100, y: 58 }),
).toHaveLength(2);
});
it("2 บรรทัด dy เล็ก (พิกัดนิ้วไม่ตรง center — เดิมได้ 0 rect)", () => {
const rects = [L(1, 400, 500), L(2, 0, 100)];
expect(
rectsInSweptPath(rects, { x: 400, y: 42 }, { x: 100, y: 50 }),
).toHaveLength(2);
});
it("2 บรรทัด dy เล็ก ลากขึ้น (เดิมได้ 0 rect)", () => {
const rects = [L(1, 400, 500), L(2, 0, 100)];
expect(
rectsInSweptPath(rects, { x: 100, y: 50 }, { x: 400, y: 42 }),
).toHaveLength(2);
});
it("หลัง auto-scroll 300px: anchor ที่ชดเชยแล้ว (สัดส่วนของหน้า A2) ต้องได้ครบทุกบรรทัด", () => {
const sc = -300;
const rects = [L(0, 100, 500, sc + 100)];
for (let i = 1; i <= 24; i++) rects.push(L(i, 0, 500, sc + 100));
rects.push(L(25, 0, 300, sc + 100));
expect(
rectsInSweptPath(rects, { x: 100, y: 110 + sc }, { x: 300, y: 400 }),
).toHaveLength(26);
});
});

View file

@ -1,24 +1,13 @@
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 = % โดยตรง
@ -262,69 +251,6 @@ 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 }[] = [
{
@ -526,137 +452,3 @@ 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

@ -1,115 +0,0 @@
// @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,40 +68,28 @@ 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;
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;
/** ขนาดตัวอักษรตรายางที่ 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;
}
// คลาสอักขระขยะที่ปนมากับข้อความสกัดจาก textLayer ของ PDF ไทยบางไฟล์ (สร้างแบบ
@ -390,9 +378,8 @@ export function describeAnnotationChange(
if (!textChanged || g === "ย้ายตำแหน่ง") changes.push(g);
}
} else if ("scale" in prev && "scale" in next) {
// STAMP — ข้อความ/สี (fallback STAMP_COLOR)/ขนาด/มุมเอียง + ย้าย/ขยาย
const labelChanged = prev.label !== next.label;
if (labelChanged) {
// STAMP — ข้อความ/สี (fallback STAMP_COLOR)/ขนาด + ย้าย/ขยาย
if (prev.label !== next.label) {
changes.push(
`ข้อความตรายาง: "${shortChangeText(prev.label)}" → "${shortChangeText(next.label)}"`,
);
@ -403,17 +390,7 @@ 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));
}
changes.push(...geometryChangeLabels(type, prev, next));
}
return changes;
}

View file

@ -69,51 +69,29 @@ export type ClientPt = { x: number; y: number };
* เด้งไปบรรทัดอื่น) — คลิกไม่ขยับ (ดับเบิลคลิกเลือกคำ/ทริปเปิลคลิกเลือกบรรทัด)
* ตัดเฉพาะแกนตั้ง ไม่ตัดแกน x เพราะไม่มีแนวการลากให้อิง
* ใช้ทั้ง desktop (native selection ผ่าน selDrag refs) และจอสัมผัส
* (useCustomSelection ลากด้วยนิ้ว)
*
* ก่อนตัดสิน "บรรทัดเดียวกัน/คนละบรรทัด" จุด start/end ถูก snap เข้า center
* ของบรรทัดที่ใกล้ที่สุดก่อน (snapY) — พิกัดนิ้วดิบที่ห่างจาก center บรรทัดเพียง
* เล็กน้อยแต่คนละบรรทัด (ลากจากท้ายบรรทัดบนไปต้นบรรทัดถัดไประยะสั้น) เคยถูกตี
* เป็น "บรรทัดเดียวกัน" จนกรองได้ 0 rect (bug report A3 2026-09-28) */
* (useCustomSelection ลากด้วยนิ้ว) */
export function rectsInSweptPath(
rects: DOMRect[],
start: ClientPt,
end: ClientPt,
): DOMRect[] {
if (rects.length === 0) return rects;
const hs = rects.map((r) => r.height).sort((a, b) => a - b);
const lineTol = (hs[Math.floor(hs.length / 2)] || 10) * 0.6;
const moved = Math.hypot(end.x - start.x, end.y - start.y) > 4;
const centers = rects.map((r) => r.y + r.height / 2);
const snapY = (y: number) =>
centers.reduce((b, c) => (Math.abs(c - y) < Math.abs(b - y) ? c : b), centers[0]);
const sy = snapY(start.y);
const ey = snapY(end.y);
// เรียงจุดตาม visual: บน→ล่าง (บรรทัดเดียวกัน: ซ้าย→ขวา) ให้กฎตรงทุกทิศการลาก
let top: ClientPt, bottom: ClientPt, topY: number, bottomY: number;
if (Math.abs(ey - sy) < lineTol) {
let top: ClientPt;
let bottom: ClientPt;
if (Math.abs(end.y - start.y) < lineTol) {
[top, bottom] = start.x <= end.x ? [start, end] : [end, start];
topY = bottomY = Math.min(sy, ey);
} else if (sy < ey) {
top = start;
bottom = end;
topY = sy;
bottomY = ey;
} else {
top = end;
bottom = start;
topY = ey;
bottomY = sy;
[top, bottom] = start.y < end.y ? [start, end] : [end, start];
}
return rects.filter((r) => {
const cy = r.y + r.height / 2;
if (cy < topY - lineTol || cy > bottomY + lineTol) return false; // บรรทัดนอกเส้นทางลาก
if (cy < top.y - lineTol || cy > bottom.y + lineTol) return false; // บรรทัดนอกเส้นทางลาก
if (!moved) return true;
const inTop = Math.abs(cy - topY) <= lineTol;
const inBottom = Math.abs(cy - bottomY) <= lineTol;
const inTop = Math.abs(cy - top.y) <= lineTol;
const inBottom = Math.abs(cy - bottom.y) <= lineTol;
const crosses = (from: number, to: number) => r.x < to && r.x + r.width > from;
if (inTop && inBottom) return crosses(top.x, bottom.x); // ลากในบรรทัดเดียว
if (inTop) return crosses(top.x, Infinity); // บรรทัดแรก: จุดเริ่ม → ท้ายบรรทัด

View file

@ -1,4 +1,4 @@
import type { AnnotationData, AnnotationType, ShapeData, StampData } from "@/db/schema";
import type { AnnotationData, AnnotationType, ShapeData } from "@/db/schema";
import {
clamp,
pointToPx,
@ -60,182 +60,9 @@ 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 บรรทัดคงสัดส่วนหน้าตาเดิม) — สัดส่วนของความกว้างกรอบ */
// STAMP (F18/B5 — §5): กรอบตรายางกว้าง = scale% ของความกว้างหน้า สูง = 0.4 เท่าของความกว้าง
// (สูตรเดียวกันทั้ง render บนจอ, bbox ของ selection และการวาดใน export)
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 };
@ -246,15 +73,7 @@ type AnnotationLike = { type: AnnotationType; data: AnnotationData };
* - 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 {
export function annotationBBox(a: AnnotationLike, size: Size): Rect {
const d = a.data;
// union ไม่ได้ discriminate โดย field ใน data เอง → แยกตาม key เฉพาะของแต่ละ variant
if ("rects" in d) {
@ -320,13 +139,10 @@ export function annotationBBox(
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 };
// 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 };
}
const p = pointToPx(d.position, size);
// กล่องรอบ pin อิง % ของความกว้างหน้า — สอดคล้องทั้ง render (px) และ space %

View file

@ -1,68 +0,0 @@
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 };
}