Compare commits
No commits in common. "651e354cadf666070add5ce56562cd49878cbf80" and "c2ed9dabc13a326448bdc7808480b3521bda8d69" have entirely different histories.
651e354cad
...
c2ed9dabc1
23 changed files with 407 additions and 2613 deletions
|
|
@ -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
|
|
@ -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/`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
11
db/schema.ts
11
db/schema.ts
|
|
@ -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
|
|
@ -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
537
package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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("ข้อความตรายาง");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<b>&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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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); // บรรทัดแรก: จุดเริ่ม → ท้ายบรรทัด
|
||||
|
|
|
|||
|
|
@ -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 %
|
||||
|
|
|
|||
|
|
@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue