diff --git a/.claude/rules/data-layer.md b/.claude/rules/data-layer.md index 40e6e04..aefeff4 100644 --- a/.claude/rules/data-layer.md +++ b/.claude/rules/data-layer.md @@ -7,7 +7,7 @@ paths: # Data Layer Rules (รายละเอียด: docs/PLAN.md §3, §4) - โครงสร้างตาราง/enum/JSONB union อ้าง §3 — หลังแก้ `db/schema.ts` ต้องรัน `/sync-db` -- Enum/union ขยายตาม PLAN-FEAT.md §1 ทีละ task — ตอนนี้เพิ่มแล้ว: `SHAPE` + `ShapeData` (rect/ellipse เก็บ `rect` bbox, line/arrow เก็บ `from`/`to` ตาม D3), `REDACTION` + `RedactionData` (`{ rect }` — ดำทึบ ไม่มีสีให้เปลี่ยน), `TEXTBOX` + `TextboxData` (F17 — `{ position, text, color, fontSize, bold?, opacity?, align?, segments? }` position เป็นมุมซ้ายบน, fontSize = % ของ width; bold/opacity(0–1)/align('left'|'center'|'right') เป็น optional ไม่มี = ตัวปกติ/ทึบเต็ม/ชิดซ้าย — bold ตอน export ต้องมี `public/fonts/Sarabun-Bold.ttf` ไม่งั้น fallback เป็น Regular; `segments?` = ตัวหนาเฉพาะส่วน (rich text — editor เป็น contentEditable + Ctrl/Cmd+B, เก็บเฉพาะเมื่อมี bold run) โดย field `text` เก็บข้อความรวม (concat) เสมอเพื่อค้นหา F14/preview sidebar และ export ใช้ `wrapStyledText` วาดแบบ run ผสม), `STAMP` + `StampData` (F18 — `{ label, position, scale, color? }` scale = ความกว้างกรอบเป็น % ของ width, color optional ไม่มี = `STAMP_COLOR`), `DrawData.widths?` (F7 pressure, optional ยาวเท่า paths; `Point` define ที่ `db/schema.ts` แล้ว re-export จาก `utils/coordinates.ts`), `DrawData.pen?` ('pencil' | 'highlighter' — optional ไม่มี = pencil; ค่า preset ต่อ pen อยู่ที่ `DRAW_PENS` ใน `utils/annotation-display.ts`), `DrawData.opacity?` (0–1 optional — เส้นใหม่เก็บชัดเสมอ แถวเดิม fallback ตาม preset ของ pen ผ่าน `effectiveDrawOpacity`), `HighlightData.style?` ('highlight' | 'underline' | 'strikethrough' | 'squiggle' — optional ไม่มี = ไฮไลต์ทึบตามเดิม; เลือกจาก popup หลังคลุมดำ), `HighlightData.opacity?` (0–1 optional ไม่มี = ทึบเต็ม — ตั้งจาก dropdown ของ tool highlight ตอนสร้าง + แผง selection แก้ได้หลังสร้าง; export: ไฮไลต์ทึบคูณเข้าค่าเดิม 0.35 ส่วนเส้นทุกแบบใช้ตรง ๆ) — เพิ่ม type ใหม่ทุกครั้งต้องเติม case ใน `validateAnnotationData` ครบถ้วน (invariant #8) +- Enum/union ขยายตาม PLAN-FEAT.md §1 ทีละ task — ตอนนี้เพิ่มแล้ว: `SHAPE` + `ShapeData` (rect/ellipse เก็บ `rect` bbox, line/arrow เก็บ `from`/`to` ตาม D3), `REDACTION` + `RedactionData` (`{ rect }` — ดำทึบ ไม่มีสีให้เปลี่ยน), `TEXTBOX` + `TextboxData` (F17 — `{ position, text, color, fontSize, bold?, opacity?, align?, segments?, width? }` position เป็น**มุมซ้ายบนของกล่องเสมอ** (align จัดข้อความในกล่องอย่างเดียวแบบ Smallpdf — กล่องไม่ขยับ), fontSize = % ของ width; bold/opacity(0–1)/align('left'|'center'|'right')/width (กล่องกว้างคงที่ % ของ width — สไตล์ Smallpdf: มี = กล่องกว้างคงที่ลากขอบปรับได้, ไม่มี = shrink-to-fit แถวเดิม) เป็น optional ไม่มี = ตัวปกติ/ทึบเต็ม/ชิดซ้าย/หุ้มข้อความ — bold ตอน export ต้องมี `public/fonts/Sarabun-Bold.ttf` ไม่งั้น fallback เป็น Regular; `segments?` = ตัวหนาเฉพาะส่วน (rich text — editor เป็น contentEditable + Ctrl/Cmd+B, เก็บเฉพาะเมื่อมี bold run) โดย field `text` เก็บข้อความรวม (concat) เสมอเพื่อค้นหา F14/preview sidebar และ export ใช้ `wrapStyledText` วาดแบบ run ผสม), `STAMP` + `StampData` (F18 — `{ label, position, scale, color? }` scale = ความกว้างกรอบเป็น % ของ width, color optional ไม่มี = `STAMP_COLOR`), `DrawData.widths?` (F7 pressure, optional ยาวเท่า paths; `Point` define ที่ `db/schema.ts` แล้ว re-export จาก `utils/coordinates.ts`), `DrawData.pen?` ('pencil' | 'highlighter' — optional ไม่มี = pencil; ค่า preset ต่อ pen อยู่ที่ `DRAW_PENS` ใน `utils/annotation-display.ts`), `DrawData.opacity?` (0–1 optional — เส้นใหม่เก็บชัดเสมอ แถวเดิม fallback ตาม preset ของ pen ผ่าน `effectiveDrawOpacity`), `HighlightData.style?` ('highlight' | 'underline' | 'strikethrough' | 'squiggle' — optional ไม่มี = ไฮไลต์ทึบตามเดิม; เลือกจาก popup หลังคลุมดำ), `HighlightData.opacity?` (0–1 optional ไม่มี = ทึบเต็ม — ตั้งจาก dropdown ของ tool highlight ตอนสร้าง + แผง selection แก้ได้หลังสร้าง; export: ไฮไลต์ทึบคูณเข้าค่าเดิม 0.35 ส่วนเส้นทุกแบบใช้ตรง ๆ) — เพิ่ม type ใหม่ทุกครั้งต้องเติม case ใน `validateAnnotationData` ครบถ้วน (invariant #8) - column `data` ใช้ `$type()` (discriminated union ตาม enum `type`) — ห้าม `any` หรือ object type กว้าง — `note?: string` (โน้ตคำอธิบายของรายการ เช่น เหตุผลที่วาด — PLAN-FEAT.md §7 N1) มีในทุกชนิด**ยกเว้น COMMENT** (มี `message` ทำหน้าที่นี้อยู่แล้ว) — validator `vNote` trim แล้ว ค่าว่าง = ไม่มีโน้ต; อ่านค่าผ่าน `annotationNote()` ใน `utils/annotation-display.ts` เท่านั้น (sidebar/สรุป/CSV ใช้ร่วม) - DTO (`AnnotationDTO`, `BookmarkDTO`): timestamp เป็น **ISO string** เสมอ ผ่าน mapper `toDTO` — ห้ามส่ง `Date` object ข้าม server→client - Server Actions: validate input ก่อนทุกครั้ง (type ถูกต้อง, ฟิลด์ครบ, `pageIndex ≥ 0`) ก่อนแตะ DB; เขียนด้วย `DATABASE_URL` ตรง ๆ + **publish event ผ่าน `lib/annotate-events.ts` หลังเขียนสำเร็จ** + **`revalidatePath` หน้า viewer+summary ของ fileId นั้น** (helper `revalidateFilePages` ในไฟล์เดียวกัน — invalidate Router Cache ฝั่ง client เพราะ `force-dynamic` ปิดแค่ฝั่ง server: ไม่งั้นกด back แล้วเข้าไฟล์ใหม่ได้ initial data เก่าจนต้อง hard refresh; action ใหม่ที่เขียน DB ต้องเรียกทุกครั้งเช่นเดียวกับ publish event) diff --git a/.claude/rules/pdf-viewer.md b/.claude/rules/pdf-viewer.md index 3467c15..6cc7d4b 100644 --- a/.claude/rules/pdf-viewer.md +++ b/.claude/rules/pdf-viewer.md @@ -23,6 +23,9 @@ paths: - DRAW วาดเส้นตรง: **ปล่อยเมาส์แล้ว snap อัตโนมัติ** เมื่อจุดทุกจุดห่างจากเส้นตรง จุดเริ่ม→จุดสุดท้าย ไม่เกิน max(5% ของความยาว, 3px) และเส้นยาว ≥ 24px (`isNearlyStraight`) — หรือกดค้าง **Shift** ระหว่างลาก (กด/ปล่อยกลางทางได้ — listener ระดับ window + reset ตอน blur) เพื่อบังคับตรงเสมอ → บันทึกเหลือจุดเริ่ม→จุดสุดท้าย 2 จุด (ความหนา uniform — ไม่เก็บ `widths` จาก pressure) — ใช้ได้เฉพาะโหมดวาดอิสระ ไม่ใช่ shape/eraser/redact; เส้นโค้ง/ขดจริงหลุดเกณฑ์จึงเหลือ freehand - Tool `pan` (มือเลื่อนหน้า — ปุ่มแยกถัดจาก select ในแถบ tool): ลากบน `
` = `window.scrollBy` สวนทางการลาก + cursor grab/grabbing; ระหว่างใช้ pan ทุก overlay ปิด pointer-events ผ่านตัวแปร `overlayFrozen` (`inkActive || pan`) **ยกเว้น InkLayer ต้องไม่ active** (ไม่งั้น pan กลายเป็นวาดเส้น) - HIGHLIGHT มี `style?` 4 แบบ: highlight ทึบเหมือนเดิม / underline = เส้นที่ขอบล่าง rect ต่อบรรทัด / strikethrough = เส้นกลางบรรทัด / squiggle = svg path ขยุกขยิก (`squigglePath`) ที่ขอบล่าง — export วาด line/sine แทน rect ทึบ; สร้างได้จาก **tool "highlight" ทางเดียว** (dropdown บนปุ่ม = รูปแบบ + สี → คลุมข้อความ = สร้างทันที พร้อม live preview ผ่าน `selectionchange` + ซ่อน native selection) — tool "เลือกข้อความ" คลุมแล้วเกิด popup ไม่ได้แล้ว (ถอดออก) และคีย์ลัด `H` สลับไป tool highlight +- **คลุมข้อความได้ 5 tool** (`TEXT_SELECTION_TOOLS` set ใน PDFViewer: select/highlight/comment/textbox/stamp — ที่เหลือ textLayer ปิด pointer-events คลุมไม่ได้): ทั้งหมด inject ` )} {/* UX review 2026-09-09: backdrop กันคลิกทะลุใต้ sidebar overlay (เฉพาะ < xl) @@ -2801,7 +2835,10 @@ export default function PDFViewer({ }`} onClick={() => setTextboxBold((o) => !o)} > - + {/* B ทึบ — แทน icon Bold แบบเส้นขอบ (lucide ไม่มีรูปทึบ) */} + + B + @@ -3048,14 +3085,14 @@ export default function PDFViewer({ onMouseUp={() => handleMouseUpOnPage(i)} onClick={(e) => handlePageClick(e, i, e.currentTarget)} onMouseDown={(e) => { - // จุดเริ่มเส้นทางลากของ tool highlight/select (กรอง selection rects) - if (tool !== "highlight" && tool !== "select") return; + // จุดเริ่มเส้นทางลากของ tool คลุมข้อความ (กรอง selection rects) + if (!TEXT_SELECTION_TOOLS.has(tool)) return; selDragStartRef.current = { x: e.clientX, y: e.clientY }; selDragEndRef.current = { x: e.clientX, y: e.clientY }; }} onMouseMove={(e) => { // ตำแหน่งเมาส์ล่าสุดระหว่างลากค้างปุ่ม (buttons=1) - if ((tool === "highlight" || tool === "select") && e.buttons === 1) { + if (TEXT_SELECTION_TOOLS.has(tool) && e.buttons === 1) { selDragEndRef.current = { x: e.clientX, y: e.clientY }; } handlePageHoverMove(e, i); diff --git a/components/pdf/TextboxStampLayer.tsx b/components/pdf/TextboxStampLayer.tsx index c118433..4634326 100644 --- a/components/pdf/TextboxStampLayer.tsx +++ b/components/pdf/TextboxStampLayer.tsx @@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef } from "react"; import type { StampData, TextboxData, TextboxSegment } from "@/db/schema"; import { toPx, type Point, type Size } from "@/utils/coordinates"; -import { STAMP_HEIGHT_RATIO, TEXTBOX_MAX_WIDTH_PCT } from "@/utils/geometry"; +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) @@ -83,6 +83,7 @@ function TextboxEditor({ bold, opacity, align, + widthPct, enabled, initialText, initialSegments, @@ -97,6 +98,8 @@ function TextboxEditor({ bold?: boolean; opacity?: number; align?: "left" | "center" | "right"; + /** ความกว้างกล่องคงที่ % (กล่องใหม่/กล่องที่มี width) — ไม่มี = shrink-to-fit (แถวเดิม) */ + widthPct?: number; /** container ของ layer เป็น pointer-events:none — editor ต้องเปิด auto เองเมื่อใช้งานได้ */ enabled: boolean; initialText: string; @@ -140,9 +143,11 @@ function TextboxEditor({ style={{ left: toPx(position.x, size.width), top: toPx(position.y, size.height), - // กล่องพอดีข้อความ (shrink-to-fit ของ absolute) — เริ่มเล็กตอนกดคลิกครั้งแรก - // แล้วขยายตามที่พิมพ์ ตัดบรรทัดเมื่อยาวเกิน 50% ของความกว้างหน้าเหมือนที่ render จริง - maxWidth: `${TEXTBOX_MAX_WIDTH_PCT}%`, + // มี widthPct = กล่องกว้างคงที่ (สไตล์ Smallpdf — ข้อความ wrap ในกล่อง) + // ไม่มี = shrink-to-fit หุ้มข้อความขยายตามที่พิมพ์ (แถวเดิม) + // maxWidth บีบตามพื้นที่จริงถึงขอบขวา (ไม่ล้นกระดาษ) + ...(widthPct !== undefined ? { width: `${widthPct}%` } : null), + maxWidth: `${textboxMaxWidthPct(position.x)}%`, minWidth: "1em", opacity: opacity ?? 1, pointerEvents: enabled ? "auto" : "none", @@ -230,6 +235,8 @@ export default function TextboxStampLayer({ bold?: boolean; opacity?: number; align?: "left" | "center" | "right"; + /** ความกว้างกล่อง default % (กล่องใหม่เป็นกล่องกว้างคงที่เสมอ) */ + widthPct?: number; } | null; onCommitComposer: (text: string, segments?: TextboxSegment[]) => void; onCancelComposer: () => void; @@ -249,6 +256,7 @@ export default function TextboxStampLayer({ bold={t.data.bold} opacity={t.data.opacity} align={t.data.align} + widthPct={t.data.width} enabled={enabled} initialText={t.data.text} initialSegments={t.data.segments} @@ -263,7 +271,10 @@ export default function TextboxStampLayer({ style={{ left: toPx(t.data.position.x, size.width), top: toPx(t.data.position.y, size.height), - maxWidth: `${TEXTBOX_MAX_WIDTH_PCT}%`, + // กล่อง width คงที่ (ถ้ามี) กว้างตามที่กำหนด — align จัดข้อความ*ใน*กล่องอย่างเดียว + // (แบบ Smallpdf กล่องไม่ขยับ) · maxWidth บีบตามพื้นที่ถึงขอบขวาเหมือนเดิม + ...(t.data.width !== undefined ? { width: `${t.data.width}%` } : null), + maxWidth: `${textboxMaxWidthPct(t.data.position.x)}%`, fontSize: toPx(t.data.fontSize, size.width), lineHeight: 1.5, color: t.data.color, @@ -302,6 +313,7 @@ export default function TextboxStampLayer({ bold={composer.bold} opacity={composer.opacity} align={composer.align} + widthPct={composer.widthPct} enabled={enabled} initialText="" onSave={onCommitComposer} diff --git a/db/schema.ts b/db/schema.ts index 3e9fed7..89245b0 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -96,11 +96,14 @@ export type TextboxData = { bold?: boolean; /** ความทึบ 0–1 (optional) — กล่องที่สร้างหลังมีตัวปรับเก็บชัดเสมอ แถวเดิม = 1 */ opacity?: number; - /** จัดแนวในกล่อง (optional) — ไม่มี = left */ + /** จัดแนวข้อความในกล่อง (optional) — ไม่มี = left · กล่องไม่ขยับ ขยับแค่ข้อความข้างใน (แบบ Smallpdf) */ align?: "left" | "center" | "right"; /** ข้อความแบ่งช่วงตัวหนา/ปกติ (optional) — มีเฉพาะเมื่อมีบางส่วนเป็นตัวหนา; * field `text` ยังเก็บข้อความรวม (concat) เสมอเพื่อค้นหา F14/preview sidebar */ segments?: TextboxSegment[]; + /** ความกว้างกล่องคงที่ % ของความกว้างหน้า (optional — สไตล์ Smallpdf/Acrobat: ลากขอบ + * ปรับความกว้าง ข้อความ wrap ตาม) — ไม่มี = กล่อง shrink-to-fit หุ้มข้อความ (แถวเดิม) */ + width?: number; /** โน้ตคำอธิบายของรายการนี้ (optional) */ note?: string; }; diff --git a/docs/PLAN-FEAT.md b/docs/PLAN-FEAT.md index 6455c07..cfbe34d 100644 --- a/docs/PLAN-FEAT.md +++ b/docs/PLAN-FEAT.md @@ -48,6 +48,7 @@ type StampData = { label: string; position: Point; scale: number }; - `strokeWidth` ของ SHAPE เป็น % ของความกว้างหน้าเหมือน DRAW; ทุกพิกัด (rect/from/to/position) เป็น % ของขนาดหน้า (invariant #2) - E1 (F7) เพิ่ม field **optional** `widths?: number[]` ใน `DrawData` (ความหนาต่อจุด, % ของ width — ไว้รองรับ pressure) +- F17 (B2) ภายหลังเพิ่ม `bold?/opacity?/align?/segments?/width?` ใน `TextboxData` — `position` เป็น**มุมซ้ายบนของกล่องเสมอ** (`align` จัดข้อความ*ใน*กล่องอย่างเดียวแบบ Smallpdf กล่องไม่ขยับ): กล่องกว้างคงที่เมื่อมี `width` (คลิกเดียวได้กล่อง default 30% ลากขอบซ้าย/ขวาปรับความกว้าง ข้อความ wrap ตาม ฟอนต์คงขนาด) ไม่มี `width` = shrink-to-fit หุ้มข้อความ (แถวเดิม) — ความกว้างสูงสุดคำนวณด้วย `textboxMaxWidthPct` (cap 50% ของหน้าและบีบตามพื้นที่จริงถึงขอบขวา — กันข้อความล้นกระดาษ) ใช้สูตรเดียวกันทั้ง render จอ / bbox กรอบ selection / export PDF ### Column เพิ่มในตาราง `annotations` (F10) diff --git a/tests/utils/geometry.test.ts b/tests/utils/geometry.test.ts index 74416fd..bb2b6f9 100644 --- a/tests/utils/geometry.test.ts +++ b/tests/utils/geometry.test.ts @@ -6,6 +6,7 @@ import { hitTestPath, hitTestShape, scaleAnnotationData, + textboxMaxWidthPct, translateAnnotationData, } from "@/utils/geometry"; @@ -132,6 +133,100 @@ describe("annotationBBox", () => { expect(bbox.height).toBeCloseTo(6); // 2 บรรทัด × 3 }); + it("TEXTBOX: กล่องใกล้ขอบขวา → bbox ถูก clamp ไม่ยื่นเกินขอบหน้า (เหมือนที่ render บีบ)", () => { + const bbox = annotationBBox( + { + type: "TEXTBOX", + data: { position: { x: 95, y: 50 }, text: "x".repeat(60), color: "#000", fontSize: 2 }, + }, + S, + ); + expect(bbox.x).toBe(95); + expect(bbox.width).toBe(5); // ค่าประมาณกว้าง 50 แต่เหลือพื้นที่แค่ 100 − 95 → clamp ที่ 5 + }); + + it("TEXTBOX: align ไม่ขยับกล่อง — position เป็นมุมซ้ายบนเสมอ (ขยับแค่ข้อความข้างใน)", () => { + for (const align of ["left", "center", "right"] as const) { + const bbox = annotationBBox( + { + type: "TEXTBOX", + data: { + position: { x: 30, y: 20 }, + text: "x".repeat(60), + color: "#000", + fontSize: 2, + align, + }, + }, + S, + ); + expect(bbox.x).toBe(30); // ขอบซ้ายกล่อง = position.x ทุก align + expect(bbox.width).toBe(50); // cap เดิม min(50, 100−30=70) + } + }); + + it("textboxMaxWidthPct: ความกว้างสูงสุด = พื้นที่จริงถึงขอบขวา (cap 50%)", () => { + expect(textboxMaxWidthPct(30)).toBe(50); // กลางหน้า → เต็ม cap + expect(textboxMaxWidthPct(95)).toBe(5); // ใกล้ขอบขวา → เหลือพื้นที่จริง + expect(textboxMaxWidthPct(100)).toBe(1); // ติดขอบเป๊ะ → ขั้นต่ำ 1% + }); + + it("TEXTBOX: กล่อง width คงที่ — กว้างตามที่กำหนดแม้ข้อความสั้น (สไตล์ Smallpdf)", () => { + const bbox = annotationBBox( + { + type: "TEXTBOX", + data: { + position: { x: 20, y: 20 }, + text: "x".repeat(10), // กว้างแค่ 12 < 30 → กล่องยังกว้าง 30 + color: "#000", + fontSize: 2, + width: 30, + }, + }, + S, + ); + expect(bbox.x).toBe(20); + expect(bbox.width).toBe(30); + expect(bbox.height).toBeCloseTo(3); // ยัง 1 บรรทัด + }); + + it("TEXTBOX: width ใหญ่กว่าพื้นที่ถึงขอบขวา → บีบเหลือพื้นที่จริง", () => { + const bbox = annotationBBox( + { + type: "TEXTBOX", + data: { + position: { x: 80, y: 20 }, + text: "x".repeat(10), + color: "#000", + fontSize: 2, + width: 40, + }, + }, + S, + ); + expect(bbox.x).toBe(80); + expect(bbox.width).toBe(20); // min(40, 100−80) + }); + + it("TEXTBOX: width คงที่ + align ใดก็ตาม → กล่องอยู่ที่มุมซ้ายบนเสมอ", () => { + const bbox = annotationBBox( + { + type: "TEXTBOX", + data: { + position: { x: 50, y: 20 }, + text: "x".repeat(10), + color: "#000", + fontSize: 2, + align: "right", + width: 30, + }, + }, + S, + ); + expect(bbox.x).toBe(50); + expect(bbox.width).toBe(30); + }); + it("TEXTBOX: ข้อความว่าง → กว้าง fallback เท่าตัวอักษรเดียว สูง 1 บรรทัด", () => { const bbox = annotationBBox( { diff --git a/utils/geometry.ts b/utils/geometry.ts index 56db47a..23129c0 100644 --- a/utils/geometry.ts +++ b/utils/geometry.ts @@ -20,9 +20,18 @@ export const COMMENT_PIN_BOX_PCT = 4; // render จริงกว้างสุด 50% ของหน้าแล้ว wrap อัตโนมัติ — bbox ประมาณจากตัวอักษร // กว้าง ~0.6 × fontSize ต่อตัว บรรทัดสูง 1.5 × fontSize (ค่าประมาณพอสำหรับกรอบ selection) export const TEXTBOX_MAX_WIDTH_PCT = 50; +export const TEXTBOX_DEFAULT_WIDTH_PCT = 30; // กล่องที่สร้างใหม่ (คลิกเดียว) กว้างเท่านี้ก่อนลากขยาย export const TEXTBOX_CHAR_W = 0.6; export const TEXTBOX_LINE_H = 1.5; +/** ความกว้างสูงสุดของกล่องข้อความ (% ของความกว้างหน้า) จากขอบซ้าย (position.x = มุมซ้ายบน + * เสมอ — align จัดข้อความ*ใน*กล่องอย่างเดียวแบบ Smallpdf กล่องไม่ขยับ) — บีบให้กล่อง + * ไม่ล้นขอบขวาของหน้ากระดาษ ใช้ร่วมกันทั้ง render จอ, bbox กรอบ selection และ export PDF + * ติดขอบขวาเป๊ะ (x=100) เหลือกว้างขั้นต่ำ 1% กันกล่องพังเป็นศูนย์ */ +export function textboxMaxWidthPct(leftX: number): number { + return Math.min(TEXTBOX_MAX_WIDTH_PCT, Math.max(100 - clamp(leftX, 0, 100), 1)); +} + // STAMP (F18/B5 — §5): กรอบตรายางกว้าง = scale% ของความกว้างหน้า สูง = 0.4 เท่าของความกว้าง // (สูตรเดียวกันทั้ง render บนจอ, bbox ของ selection และการวาดใน export) export const STAMP_HEIGHT_RATIO = 0.4; @@ -81,24 +90,25 @@ export function annotationBBox(a: AnnotationLike, size: Size): Rect { return rectToPx(d.rect, size); } if ("fontSize" in d) { - // TEXTBOX — ประมาณจากข้อความ (position เป็นมุมซ้ายบนของกล่อง) + // TEXTBOX — position เป็นมุมซ้ายบนของกล่องเสมอ (align จัดข้อความในกล่องอย่างเดียว); + // กล่องมี width คงที่ (ถ้ามี — สไตล์ Smallpdf) ไม่มี = shrink-to-fit ประมาณจากตัวอักษร const fontSizePx = toPx(d.fontSize, size.width); const charW = fontSizePx * TEXTBOX_CHAR_W; const lineH = fontSizePx * TEXTBOX_LINE_H; - const maxW = toPx(TEXTBOX_MAX_WIDTH_PCT, size.width); + // ความกว้างสูงสุดถึงขอบขวาหน้า — กล่อง width คงที่ก็ถูกบีบด้วยค่านี้เหมือน render + const capPct = textboxMaxWidthPct(d.position.x); + const wrapPx = toPx(Math.min(d.width ?? capPct, capPct), size.width); + const wrapW = Math.max(wrapPx, charW); // กันหารศูนย์เมื่อกล่องติดขอบขวาเป๊ะ let widest = 0; let lines = 0; for (const raw of d.text.split(/\r?\n/)) { widest = Math.max(widest, raw.length * charW); - lines += Math.max(1, Math.ceil((raw.length * charW) / maxW)); + lines += Math.max(1, Math.ceil((raw.length * charW) / wrapW)); } + // กล่อง width คงที่กว้างตามที่กำหนด (บีบด้วย cap) — แถวเดิม shrink-to-fit ตามบรรทัดยาวสุด + const width = (d.width !== undefined ? wrapPx : Math.min(widest, wrapPx)) || charW; const p = pointToPx(d.position, size); - return { - x: p.x, - y: p.y, - width: Math.min(widest, maxW) || charW, - height: Math.max(lines, 1) * lineH, - }; + return { x: p.x, y: p.y, width, height: Math.max(lines, 1) * lineH }; } if ("scale" in d) { // STAMP — กรอบจริงตามสูตร render (position เป็นมุมซ้ายบน)