Compare commits
2 commits
9e6158ed45
...
9e5083dae1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e5083dae1 | ||
|
|
ca74fb1843 |
11 changed files with 278 additions and 50 deletions
|
|
@ -7,7 +7,7 @@ paths:
|
||||||
# Data Layer Rules (รายละเอียด: docs/PLAN.md §3, §4)
|
# Data Layer Rules (รายละเอียด: docs/PLAN.md §3, §4)
|
||||||
|
|
||||||
- โครงสร้างตาราง/enum/JSONB union อ้าง §3 — หลังแก้ `db/schema.ts` ต้องรัน `/sync-db`
|
- โครงสร้างตาราง/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<AnnotationData>()` (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 ใช้ร่วม)
|
- column `data` ใช้ `$type<AnnotationData>()` (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
|
- 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)
|
- 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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
- DRAW วาดเส้นตรง: **ปล่อยเมาส์แล้ว snap อัตโนมัติ** เมื่อจุดทุกจุดห่างจากเส้นตรง จุดเริ่ม→จุดสุดท้าย ไม่เกิน max(5% ของความยาว, 3px) และเส้นยาว ≥ 24px (`isNearlyStraight`) — หรือกดค้าง **Shift** ระหว่างลาก (กด/ปล่อยกลางทางได้ — listener ระดับ window + reset ตอน blur) เพื่อบังคับตรงเสมอ → บันทึกเหลือจุดเริ่ม→จุดสุดท้าย 2 จุด (ความหนา uniform — ไม่เก็บ `widths` จาก pressure) — ใช้ได้เฉพาะโหมดวาดอิสระ ไม่ใช่ shape/eraser/redact; เส้นโค้ง/ขดจริงหลุดเกณฑ์จึงเหลือ freehand
|
||||||
- Tool `pan` (มือเลื่อนหน้า — ปุ่มแยกถัดจาก select ในแถบ tool): ลากบน `<main>` = `window.scrollBy` สวนทางการลาก + cursor grab/grabbing; ระหว่างใช้ pan ทุก overlay ปิด pointer-events ผ่านตัวแปร `overlayFrozen` (`inkActive || pan`) **ยกเว้น InkLayer ต้องไม่ active** (ไม่งั้น pan กลายเป็นวาดเส้น)
|
- Tool `pan` (มือเลื่อนหน้า — ปุ่มแยกถัดจาก select ในแถบ tool): ลากบน `<main>` = `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
|
- 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 `<style>` ซ่อน native selection + ฟัง `selectionchange` วาดของเราเอง (highlight = preview มาร์ก, ที่เหลือ = selection สีฟ้า) + tracking เส้นทางลาก (`selDragStart/EndRef` ที่ mousedown/move) + Ctrl+C กรองข้อความตามเส้นทาง — comment/textbox/stamp ปล่อยเมาส์จากการลากคลุม**ไม่วางรายการ** (guard `!selection.isCollapsed` ต้น `handlePageClick`: click ที่ยิงต่อจาก drag คลุมต้องเฉยไป เหมือน tool เลือกข้อความ คลิกล้วน ๆ ต่างหากที่เปิด composer/วางตรายาง)
|
||||||
- กรอบ selection (F1) ของทุกชนิดที่เป็นรายการของตัวเองมี**แผงเครื่องมือ** (`w-60` เหมือน dropdown ของ tool) ที่ใช้**ตัวควบคุมชุดเดียวกับ dropdown ของ tool ผู้สร้าง** (จาก `components/pdf/ToolControls.tsx` — ColorSwatches/NumberCombo/PanelRow แชร์กับ PDFViewer) ตามชนิด: HIGHLIGHT = สี+รูปแบบมาร์ก, DRAW/SHAPE = สี+ความหนา (×10), STAMP = สี+ขนาด, TEXTBOX = สี+ขนาด+ความทึบ+ตัวหนา+จัดแนว — ปิดท้ายด้วย **ปุ่มลบ** (`onDelete`) เสมอ; ชนิดที่ไม่มีสไตล์ให้แก้ (REDACTION/COMMENT) ได้อย่างน้อยปุ่มลบ — **HIGHLIGHT ไม่มี handle ย้าย/ขยาย** (ผูกกับตำแหน่งข้อความจริง เลือกเพื่อเปลี่ยนสี/รูปแบบ/ลบได้อย่างเดียว)
|
- กรอบ selection (F1) ของทุกชนิดที่เป็นรายการของตัวเองมี**แผงเครื่องมือ** (`w-60` เหมือน dropdown ของ tool) ที่ใช้**ตัวควบคุมชุดเดียวกับ dropdown ของ tool ผู้สร้าง** (จาก `components/pdf/ToolControls.tsx` — ColorSwatches/NumberCombo/PanelRow แชร์กับ PDFViewer) ตามชนิด: HIGHLIGHT = สี+รูปแบบมาร์ก, DRAW/SHAPE = สี+ความหนา (×10), STAMP = สี+ขนาด, TEXTBOX = สี+ขนาด+ความทึบ+ตัวหนา+จัดแนว — ปิดท้ายด้วย **ปุ่มลบ** (`onDelete`) เสมอ; ชนิดที่ไม่มีสไตล์ให้แก้ (REDACTION/COMMENT) ได้อย่างน้อยปุ่มลบ — **HIGHLIGHT ไม่มี handle ย้าย/ขยาย** (ผูกกับตำแหน่งข้อความจริง เลือกเพื่อเปลี่ยนสี/รูปแบบ/ลบได้อย่างเดียว)
|
||||||
|
- TEXTBOX (F17) `position` เป็น**มุมซ้ายบนของกล่องเสมอ** — `align` จัดข้อความ*ใน*กล่องอย่างเดียว (`textAlign`, แบบ Smallpdf: กดจัดแนวแล้วกล่องไม่ขยับ ขยับแค่ข้อความข้างใน — เคยทดลองให้ align เป็น "จุดยึด" ที่ขยับทั้งกล่องแล้วถอดออกเพราะผู้ใช้รู้สึกว่ากล่องเด้ง); ความกว้างสูงสุดจาก `textboxMaxWidthPct(x)` (`utils/geometry.ts`) บีบตามพื้นที่จริงถึงขอบขวา (cap 50% ของหน้า, ขั้นต่ำ 1%) **กันข้อความล้นขอบกระดาษ** — ใช้สูตรเดียวกันทั้ง render (`TextboxStampLayer`), bbox กรอบ selection/hit-test (`annotationBBox`) และ export PDF
|
||||||
|
- TEXTBOX กล่องกว้างคงที่ (`width?` สไตล์ Smallpdf/Acrobat): กล่องที่สร้างใหม่คลิกเดียวได้ `width = min(TEXTBOX_DEFAULT_WIDTH_PCT=30, textboxMaxWidthPct)` — **ลากขอบซ้าย/ขวาของกรอบ selection = ปรับความกว้าง** (mode `wleft`/`wright` ใน AnnotationSelectionLayer: ขอบซ้ายขยับ = เลื่อนกล่อง+เปลี่ยนกว้าง (`position.x` = ขอบซ้ายตรง ๆ), ขอบขวาขยับ = เปลี่ยนกว้างอย่างเดียว, clamp กว้าง 2–50% และอยู่ในหน้า) ฟอนต์คงขนาด ข้อความ wrap ใหม่ ส่วนสูงตามจำนวนบรรทัดอัตโนมัติ — กล่องแบบนี้**ซ่อนมุมกรอบ** (scale ไม่มีความหมาย) ย้ายกล่องใช้ขอบบน/ล่าง; กล่องเก่าที่ไม่มี `width` = shrink-to-fit หุ้มข้อความตามพฤติกรรมเดิมทั้งหมด
|
||||||
- คลิกเลือกบนหน้า: tool select/highlight คลิกบน annotation = `setSelectedId` ผ่าน `hitTestAnnotation` (DRAW/SHAPE ใช้ hit-test แม่นเดียวกับ eraser, HIGHLIGHT ดู rect, ที่เหลือ bbox+padding) — เช็คจากรายการหลังสุดก่อน (ตัวบนสุดชนะ), คลิกที่ว่าง = deselect; ใช้ `visibleAnnotations` (เคารพ filter รายคน F4)
|
- คลิกเลือกบนหน้า: tool select/highlight คลิกบน annotation = `setSelectedId` ผ่าน `hitTestAnnotation` (DRAW/SHAPE ใช้ hit-test แม่นเดียวกับ eraser, HIGHLIGHT ดู rect, ที่เหลือ bbox+padding) — เช็คจากรายการหลังสุดก่อน (ตัวบนสุดชนะ), คลิกที่ว่าง = deselect; ใช้ `visibleAnnotations` (เคารพ filter รายคน F4)
|
||||||
- Tooltip hover (N1): `onMouseMove` ของ page wrapper ทำ hit-test ชุดเดียวกับคลิก → `hoveredId` state **เดี่ยวระดับ root เปลี่ยนเฉพาะเมื่อ id เปลี่ยน** (ห้าม setState ทุก mousemove, hit-test เดินจากท้าย array ไม่ allocate ต่อ event) → tooltip "ชนิด · ผู้ทำ + โน้ต" ลอยข้าง bbox (`pointer-events: none` เสมอ, z-[5], ยกเว้น COMMENT) — ปิดช่วง tool ที่รับ pointer (`draw/shape/erase/redact/pan`), `e.buttons !== 0` และตอน `openPinId !== null` (popover ของ pin เปิดอยู่)
|
- Tooltip hover (N1): `onMouseMove` ของ page wrapper ทำ hit-test ชุดเดียวกับคลิก → `hoveredId` state **เดี่ยวระดับ root เปลี่ยนเฉพาะเมื่อ id เปลี่ยน** (ห้าม setState ทุก mousemove, hit-test เดินจากท้าย array ไม่ allocate ต่อ event) → tooltip "ชนิด · ผู้ทำ + โน้ต" ลอยข้าง bbox (`pointer-events: none` เสมอ, z-[5], ยกเว้น COMMENT) — ปิดช่วง tool ที่รับ pointer (`draw/shape/erase/redact/pan`), `e.buttons !== 0` และตอน `openPinId !== null` (popover ของ pin เปิดอยู่)
|
||||||
|
|
|
||||||
|
|
@ -309,6 +309,13 @@ function validateAnnotationData(type: AnnotationType, v: unknown): AnnotationDat
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const textboxNote = vNote(d.note);
|
const textboxNote = vNote(d.note);
|
||||||
|
// width (กล่องกว้างคงที่) เป็น optional — ไม่มี = shrink-to-fit ตามเดิม (แถวเดิม)
|
||||||
|
if (
|
||||||
|
d.width !== undefined &&
|
||||||
|
(typeof d.width !== "number" || !Number.isFinite(d.width) || d.width <= 0)
|
||||||
|
) {
|
||||||
|
fieldError("data.width", "เป็นตัวเลข > 0");
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
position,
|
position,
|
||||||
text,
|
text,
|
||||||
|
|
@ -318,6 +325,7 @@ function validateAnnotationData(type: AnnotationType, v: unknown): AnnotationDat
|
||||||
...(d.opacity !== undefined ? { opacity: d.opacity } : {}),
|
...(d.opacity !== undefined ? { opacity: d.opacity } : {}),
|
||||||
...(d.align !== undefined ? { align: d.align } : {}),
|
...(d.align !== undefined ? { align: d.align } : {}),
|
||||||
...(d.segments !== undefined ? { segments: d.segments } : {}),
|
...(d.segments !== undefined ? { segments: d.segments } : {}),
|
||||||
|
...(d.width !== undefined ? { width: d.width } : {}),
|
||||||
...(textboxNote !== undefined ? { note: textboxNote } : {}),
|
...(textboxNote !== undefined ? { note: textboxNote } : {}),
|
||||||
} satisfies TextboxData;
|
} satisfies TextboxData;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ import {
|
||||||
} from "@/utils/pdf-export";
|
} from "@/utils/pdf-export";
|
||||||
import type { Rect } from "@/db/schema";
|
import type { Rect } from "@/db/schema";
|
||||||
import { toPx, type Point, type Size } from "@/utils/coordinates";
|
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, effectiveDrawOpacity, stampFitFontSize } from "@/utils/annotation-display";
|
import { STAMP_COLOR, effectiveDrawOpacity, stampFitFontSize } from "@/utils/annotation-display";
|
||||||
|
|
||||||
// Export PDF ฝัง annotation (F8/C1 — PLAN-FEAT.md §3)
|
// Export PDF ฝัง annotation (F8/C1 — PLAN-FEAT.md §3)
|
||||||
|
|
@ -340,7 +340,14 @@ export async function GET(req: Request) {
|
||||||
const { r, g, b } = hexToRgb01(d.color);
|
const { r, g, b } = hexToRgb01(d.color);
|
||||||
if (thaiFont) {
|
if (thaiFont) {
|
||||||
const fontSize = Math.max(thickness(d.fontSize), 4);
|
const fontSize = Math.max(thickness(d.fontSize), 4);
|
||||||
const maxWidth = px(TEXTBOX_MAX_WIDTH_PCT, display.width);
|
// clamp ขอบขวาแบบเดียวกับบนจอ (textboxMaxWidthPct) — ไม่งั้นกล่อง
|
||||||
|
// ที่ position.x สูง (ครึ่งขวาของหน้า) ข้อความทุกบรรทัดทะลุขอบหน้ากระดาษ
|
||||||
|
// กล่อง width คงที่ (ถ้ามี) wrap ที่ความกว้างนั้น (บีบด้วย cap เหมือน render จอ)
|
||||||
|
const capPct = textboxMaxWidthPct(d.position.x);
|
||||||
|
const maxWidth = Math.max(
|
||||||
|
px(Math.min(d.width ?? capPct, capPct), display.width),
|
||||||
|
10,
|
||||||
|
);
|
||||||
// run ต่อสไตล์: segments (ตัวหนาเฉพาะส่วน) หรือทั้งกล่องตาม d.bold
|
// run ต่อสไตล์: segments (ตัวหนาเฉพาะส่วน) หรือทั้งกล่องตาม d.bold
|
||||||
const runs: StyledRun[] = d.segments?.length
|
const runs: StyledRun[] = d.segments?.length
|
||||||
? d.segments.map((s) => ({ text: s.text, bold: !!(s.bold || d.bold) }))
|
? d.segments.map((s) => ({ text: s.text, bold: !!(s.bold || d.bold) }))
|
||||||
|
|
@ -349,16 +356,25 @@ export async function GET(req: Request) {
|
||||||
const widthOf = (s: string, bold: boolean) =>
|
const widthOf = (s: string, bold: boolean) =>
|
||||||
fontFor(bold).widthOfTextAtSize(s, fontSize);
|
fontFor(bold).widthOfTextAtSize(s, fontSize);
|
||||||
const lines = wrapStyledText(runs, maxWidth, widthOf);
|
const lines = wrapStyledText(runs, maxWidth, widthOf);
|
||||||
// จัดแนวในกล่องกว้าง maxWidth — เลื่อน x ต่อบรรทัดตามความกว้างจริงของบรรทัดนั้น
|
// กล่องห้อยจากมุมซ้ายบน (pos) เหมือน render จอ — align จัดบรรทัด*ใน*กล่องอย่างเดียว
|
||||||
|
// (แบบ Smallpdf กล่องไม่ขยับ): กล่อง width คงที่กว้างเต็ม maxWidth ตามที่กำหนด
|
||||||
|
// ส่วนแถวเดิม shrink-to-fit กว้างเท่าบรรทัดยาวสุด (ไม่เกิน maxWidth)
|
||||||
const align = d.align ?? "left";
|
const align = d.align ?? "left";
|
||||||
|
const lineWOf = (lineRuns: StyledRun[]) =>
|
||||||
|
lineRuns.reduce((sum, rn) => sum + widthOf(rn.text, rn.bold), 0);
|
||||||
|
const boxW =
|
||||||
|
d.width !== undefined
|
||||||
|
? maxWidth
|
||||||
|
: Math.max(Math.min(...lines.map(lineWOf), maxWidth), 1);
|
||||||
|
const boxLeft = pos.x;
|
||||||
lines.forEach((lineRuns, i) => {
|
lines.forEach((lineRuns, i) => {
|
||||||
const lineW = lineRuns.reduce((sum, rn) => sum + widthOf(rn.text, rn.bold), 0);
|
const lineW = lineWOf(lineRuns);
|
||||||
let x =
|
let x =
|
||||||
align === "center"
|
align === "center"
|
||||||
? pos.x + (maxWidth - lineW) / 2
|
? boxLeft + (boxW - lineW) / 2
|
||||||
: align === "right"
|
: align === "right"
|
||||||
? pos.x + (maxWidth - lineW)
|
? boxLeft + (boxW - lineW)
|
||||||
: pos.x;
|
: boxLeft;
|
||||||
for (const rn of lineRuns) {
|
for (const rn of lineRuns) {
|
||||||
page.drawText(rn.text, {
|
page.drawText(rn.text, {
|
||||||
x,
|
x,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
useState,
|
useState,
|
||||||
type PointerEvent as ReactPointerEvent,
|
type PointerEvent as ReactPointerEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { AlignCenter, AlignLeft, AlignRight, BookmarkPlus, Bold, Trash2 } from "lucide-react";
|
import { AlignCenter, AlignLeft, AlignRight, BookmarkPlus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AnnotationData,
|
AnnotationData,
|
||||||
|
|
@ -16,6 +16,7 @@ import type {
|
||||||
TextboxData,
|
TextboxData,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import {
|
import {
|
||||||
|
TEXTBOX_MAX_WIDTH_PCT,
|
||||||
annotationBBox,
|
annotationBBox,
|
||||||
scaleAnnotationData,
|
scaleAnnotationData,
|
||||||
translateAnnotationData,
|
translateAnnotationData,
|
||||||
|
|
@ -37,7 +38,8 @@ const MIN_BOX_PCT = 0.5; // bbox เล็กสุด (% ของหน้า)
|
||||||
const EPS = 1e-6;
|
const EPS = 1e-6;
|
||||||
|
|
||||||
type DragState = {
|
type DragState = {
|
||||||
mode: "move" | "scale";
|
// wleft/wright = TEXTBOX กว้างคงที่: ลากขอบซ้าย/ขวาปรับความกว้าง (ฟอนต์คงขนาด)
|
||||||
|
mode: "move" | "scale" | "wleft" | "wright";
|
||||||
/** ตำแหน่ง pointer ตอนเริ่มลาก (% ของหน้า) */
|
/** ตำแหน่ง pointer ตอนเริ่มลาก (% ของหน้า) */
|
||||||
startPct: Point;
|
startPct: Point;
|
||||||
/** data ก่อนเริ่มลาก — ต้นทางของการคำนวณทุก move (ไม่สะสมจาก preview) */
|
/** data ก่อนเริ่มลาก — ต้นทางของการคำนวณทุก move (ไม่สะสมจาก preview) */
|
||||||
|
|
@ -118,7 +120,7 @@ export default function AnnotationSelectionLayer({
|
||||||
|
|
||||||
function beginDrag(
|
function beginDrag(
|
||||||
e: ReactPointerEvent<HTMLElement>,
|
e: ReactPointerEvent<HTMLElement>,
|
||||||
mode: "move" | "scale",
|
mode: "move" | "scale" | "wleft" | "wright",
|
||||||
anchorPct: Point,
|
anchorPct: Point,
|
||||||
) {
|
) {
|
||||||
if (!editable || e.button !== 0) return;
|
if (!editable || e.button !== 0) return;
|
||||||
|
|
@ -155,6 +157,36 @@ export default function AnnotationSelectionLayer({
|
||||||
100 - st.origBoxPct.y - st.origBoxPct.height,
|
100 - st.origBoxPct.y - st.origBoxPct.height,
|
||||||
);
|
);
|
||||||
next = translateAnnotationData(st.origData, dx, dy);
|
next = translateAnnotationData(st.origData, dx, dy);
|
||||||
|
} else if (st.mode === "wleft" || st.mode === "wright") {
|
||||||
|
// TEXTBOX กว้างคงที่ — ขอบที่จับขยับ อีกขอบตายตัว (position = มุมซ้ายบนเสมอ:
|
||||||
|
// ลากขอบซ้าย = เลื่อนกล่อง+เปลี่ยนกว้าง, ลากขอบขวา = เปลี่ยนกว้างอย่างเดียว)
|
||||||
|
// ฟอนต์คงขนาด ข้อความ wrap ใหม่ ส่วนสูงตามจำนวนบรรทัดอัตโนมัติ
|
||||||
|
const d = st.origData;
|
||||||
|
if (!("fontSize" in d)) {
|
||||||
|
next = d;
|
||||||
|
} else {
|
||||||
|
const MIN_W = 2; // % ของหน้า — กล่องแคบสุดที่ยังใช้ได้จริง
|
||||||
|
const right = st.origBoxPct.x + st.origBoxPct.width;
|
||||||
|
let left = st.origBoxPct.x;
|
||||||
|
let w = st.origBoxPct.width;
|
||||||
|
if (st.mode === "wright") {
|
||||||
|
const r = clamp(
|
||||||
|
cur.x,
|
||||||
|
st.origBoxPct.x + MIN_W,
|
||||||
|
Math.min(100, st.origBoxPct.x + TEXTBOX_MAX_WIDTH_PCT),
|
||||||
|
);
|
||||||
|
w = r - st.origBoxPct.x;
|
||||||
|
} else {
|
||||||
|
const l = clamp(
|
||||||
|
cur.x,
|
||||||
|
Math.max(0, right - TEXTBOX_MAX_WIDTH_PCT),
|
||||||
|
right - MIN_W,
|
||||||
|
);
|
||||||
|
left = l;
|
||||||
|
w = right - l;
|
||||||
|
}
|
||||||
|
next = { ...d, width: w, position: { ...d.position, x: left } };
|
||||||
|
}
|
||||||
} else if ("scale" in st.origData) {
|
} else if ("scale" in st.origData) {
|
||||||
// STAMP — สัดส่วนกว้าง:สูงตายตัว (สูง = STAMP_HEIGHT_RATIO × กว้าง) แยกสเกล
|
// STAMP — สัดส่วนกว้าง:สูงตายตัว (สูง = STAMP_HEIGHT_RATIO × กว้าง) แยกสเกล
|
||||||
// สองแกนไม่ได้ จึงใช้ตัวคูณ uniform เดียว: เฉลี่ยอัตราส่วนของ pointer สองแกน
|
// สองแกนไม่ได้ จึงใช้ตัวคูณ uniform เดียว: เฉลี่ยอัตราส่วนของ pointer สองแกน
|
||||||
|
|
@ -288,6 +320,9 @@ export default function AnnotationSelectionLayer({
|
||||||
const stampData = "label" in annotation.data ? annotation.data : null;
|
const stampData = "label" in annotation.data ? annotation.data : null;
|
||||||
const textboxData: TextboxData | null =
|
const textboxData: TextboxData | null =
|
||||||
"fontSize" in annotation.data ? annotation.data : null;
|
"fontSize" in annotation.data ? annotation.data : null;
|
||||||
|
// TEXTBOX กว้างคงที่ (มี width) — ขอบซ้าย/ขวาเป็น handle ปรับความกว้างแทนการย้าย
|
||||||
|
// และซ่อนมุม (scale ไม่มีความหมายกับกล่องกว้างคงที่) — ย้ายกล่องใช้ขอบบน/ล่าง
|
||||||
|
const isFixedTextbox = textboxData?.width !== undefined;
|
||||||
|
|
||||||
// มุมทั้ง 4 — ตำแหน่งวางเป็น px, anchor มุมตรงข้ามเป็น % ตรง ๆ จาก boxPct
|
// มุมทั้ง 4 — ตำแหน่งวางเป็น px, anchor มุมตรงข้ามเป็น % ตรง ๆ จาก boxPct
|
||||||
// (พิกัด % คำนวณผ่าน utils/coordinates เท่านั้น — invariant #2)
|
// (พิกัด % คำนวณผ่าน utils/coordinates เท่านั้น — invariant #2)
|
||||||
|
|
@ -393,7 +428,7 @@ export default function AnnotationSelectionLayer({
|
||||||
onPointerCancel={cancelDrag}
|
onPointerCancel={cancelDrag}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="absolute cursor-move"
|
className={`absolute ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
|
||||||
style={{
|
style={{
|
||||||
left: boxPx.x - STRIP_PX / 2,
|
left: boxPx.x - STRIP_PX / 2,
|
||||||
top: boxPx.y,
|
top: boxPx.y,
|
||||||
|
|
@ -401,13 +436,15 @@ export default function AnnotationSelectionLayer({
|
||||||
height: boxPx.height,
|
height: boxPx.height,
|
||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
}}
|
}}
|
||||||
onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })}
|
onPointerDown={(e) =>
|
||||||
|
beginDrag(e, isFixedTextbox ? "wleft" : "move", { x: 0, y: 0 })
|
||||||
|
}
|
||||||
onPointerMove={moveDrag}
|
onPointerMove={moveDrag}
|
||||||
onPointerUp={endDrag}
|
onPointerUp={endDrag}
|
||||||
onPointerCancel={cancelDrag}
|
onPointerCancel={cancelDrag}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="absolute cursor-move"
|
className={`absolute ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
|
||||||
style={{
|
style={{
|
||||||
left: boxPx.x + boxPx.width - STRIP_PX / 2,
|
left: boxPx.x + boxPx.width - STRIP_PX / 2,
|
||||||
top: boxPx.y,
|
top: boxPx.y,
|
||||||
|
|
@ -415,14 +452,17 @@ export default function AnnotationSelectionLayer({
|
||||||
height: boxPx.height,
|
height: boxPx.height,
|
||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
}}
|
}}
|
||||||
onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })}
|
onPointerDown={(e) =>
|
||||||
|
beginDrag(e, isFixedTextbox ? "wright" : "move", { x: 0, y: 0 })
|
||||||
|
}
|
||||||
onPointerMove={moveDrag}
|
onPointerMove={moveDrag}
|
||||||
onPointerUp={endDrag}
|
onPointerUp={endDrag}
|
||||||
onPointerCancel={cancelDrag}
|
onPointerCancel={cancelDrag}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* มุมทั้ง 4 — ลาก = scale รอบ anchor มุมตรงข้าม */}
|
{/* มุมทั้ง 4 — ลาก = scale รอบ anchor มุมตรงข้าม (TEXTBOX กว้างคงที่ไม่มีมุม —
|
||||||
{corners.map((c) => (
|
ปรับความกว้างที่ขอบซ้าย/ขวาแทน ส่วนสูงตามจำนวนบรรทัดอัตโนมัติ) */}
|
||||||
|
{!isFixedTextbox && corners.map((c) => (
|
||||||
<div
|
<div
|
||||||
key={c.key}
|
key={c.key}
|
||||||
className="absolute rounded-sm border border-primary bg-primary/50"
|
className="absolute rounded-sm border border-primary bg-primary/50"
|
||||||
|
|
@ -578,7 +618,10 @@ export default function AnnotationSelectionLayer({
|
||||||
}`}
|
}`}
|
||||||
onClick={() => commitTextboxPatch({ bold: !textboxData.bold })}
|
onClick={() => commitTextboxPatch({ bold: !textboxData.bold })}
|
||||||
>
|
>
|
||||||
<Bold className="h-3.5 w-3.5" />
|
{/* B ทึบ — เหมือน dropdown ของ tool (ตัวควบคุมชุดเดียวกัน) */}
|
||||||
|
<span aria-hidden className="text-[13px] font-black leading-none">
|
||||||
|
B
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</PanelRow>
|
</PanelRow>
|
||||||
<PanelRow label="จัดแนว">
|
<PanelRow label="จัดแนว">
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import {
|
||||||
AlignLeft,
|
AlignLeft,
|
||||||
AlignRight,
|
AlignRight,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Bold,
|
|
||||||
BookmarkPlus,
|
BookmarkPlus,
|
||||||
Brush,
|
Brush,
|
||||||
Check,
|
Check,
|
||||||
|
|
@ -106,7 +105,13 @@ import {
|
||||||
type Size,
|
type Size,
|
||||||
} from "@/utils/coordinates";
|
} from "@/utils/coordinates";
|
||||||
import { getLocalUser, type LocalUser } from "@/utils/user";
|
import { getLocalUser, type LocalUser } from "@/utils/user";
|
||||||
import { annotationBBox, hitTestPath, hitTestShape } from "@/utils/geometry";
|
import {
|
||||||
|
TEXTBOX_DEFAULT_WIDTH_PCT,
|
||||||
|
annotationBBox,
|
||||||
|
hitTestPath,
|
||||||
|
hitTestShape,
|
||||||
|
textboxMaxWidthPct,
|
||||||
|
} from "@/utils/geometry";
|
||||||
import InkLayer, { type InkRedaction, type InkShape, type InkStroke } from "./InkLayer";
|
import InkLayer, { type InkRedaction, type InkShape, type InkStroke } from "./InkLayer";
|
||||||
import TextboxStampLayer, { type InkStamp, type InkTextbox } from "./TextboxStampLayer";
|
import TextboxStampLayer, { type InkStamp, type InkTextbox } from "./TextboxStampLayer";
|
||||||
import AnnotationSelectionLayer from "./AnnotationSelectionLayer";
|
import AnnotationSelectionLayer from "./AnnotationSelectionLayer";
|
||||||
|
|
@ -131,6 +136,17 @@ type Tool =
|
||||||
| "textbox"
|
| "textbox"
|
||||||
| "stamp";
|
| "stamp";
|
||||||
|
|
||||||
|
/** tool ที่ลากคลุมข้อความใน textLayer ได้ (ตัวที่ textLayer ยังรับ pointer อยู่) —
|
||||||
|
* ทั้งหมดซ่อน native selection แล้ววาดของเราเองแทน: highlight = preview มาร์กสด,
|
||||||
|
* select/comment/textbox/stamp = selection สีฟ้าสะอาด (กรองแนวเส้นทางลาก) */
|
||||||
|
const TEXT_SELECTION_TOOLS = new Set<Tool>([
|
||||||
|
"select",
|
||||||
|
"highlight",
|
||||||
|
"comment",
|
||||||
|
"textbox",
|
||||||
|
"stamp",
|
||||||
|
]);
|
||||||
|
|
||||||
type Composer = {
|
type Composer = {
|
||||||
pageIndex: number;
|
pageIndex: number;
|
||||||
position: Point; // % ของหน้า
|
position: Point; // % ของหน้า
|
||||||
|
|
@ -573,6 +589,8 @@ function PdfPage({
|
||||||
bold: boolean;
|
bold: boolean;
|
||||||
opacity: number;
|
opacity: number;
|
||||||
align: "left" | "center" | "right";
|
align: "left" | "center" | "right";
|
||||||
|
/** ความกว้างกล่อง default (สไตล์ Smallpdf — คลิกเดียวได้กล่อง ลากขอบขยายทีหลัง) */
|
||||||
|
widthPct: number;
|
||||||
};
|
};
|
||||||
/** live preview มาร์กระหว่างลากคลุมด้วย tool highlight (null = ไม่มี) */
|
/** live preview มาร์กระหว่างลากคลุมด้วย tool highlight (null = ไม่มี) */
|
||||||
liveMark: { pageIndex: number; rects: Rect[] } | null;
|
liveMark: { pageIndex: number; rects: Rect[] } | null;
|
||||||
|
|
@ -874,6 +892,7 @@ function PdfPage({
|
||||||
bold: textboxStyle.bold,
|
bold: textboxStyle.bold,
|
||||||
opacity: textboxStyle.opacity,
|
opacity: textboxStyle.opacity,
|
||||||
align: textboxStyle.align,
|
align: textboxStyle.align,
|
||||||
|
widthPct: textboxStyle.widthPct,
|
||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
|
|
@ -1932,9 +1951,10 @@ export default function PDFViewer({
|
||||||
|
|
||||||
// tool highlight — ฟัง selectionchange ระหว่างลากเพื่อวาด preview มาร์กสด ๆ
|
// tool highlight — ฟัง selectionchange ระหว่างลากเพื่อวาด preview มาร์กสด ๆ
|
||||||
// (แสดงเส้น/สีตามรูปแบบที่ตั้งไว้ทันที ไม่ต้องรอปล่อยเมาส์)
|
// (แสดงเส้น/สีตามรูปแบบที่ตั้งไว้ทันที ไม่ต้องรอปล่อยเมาส์)
|
||||||
// tool เลือกข้อความ — ใช้กลไกเดียวกันแต่วาดเป็น selection สีฟ้าสะอาด ๆ (ไม่สร้างไฮไลต์)
|
// tool ที่เหลือของ TEXT_SELECTION_TOOLS (select/comment/textbox/stamp) — ใช้กลไก
|
||||||
|
// เดียวกันแต่วาดเป็น selection สีฟ้าสะอาด ๆ (ไม่สร้างไฮไลต์ ไม่วางรายการ)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tool !== "highlight" && tool !== "select") {
|
if (!TEXT_SELECTION_TOOLS.has(tool)) {
|
||||||
setLiveMark(null);
|
setLiveMark(null);
|
||||||
setSelectPreview(null);
|
setSelectPreview(null);
|
||||||
return;
|
return;
|
||||||
|
|
@ -1995,7 +2015,7 @@ export default function PDFViewer({
|
||||||
// (เทียบข้อความดิบ + ความกว้างหน้า — ต่างจากตอน mouseup = selection/zoom เปลี่ยน
|
// (เทียบข้อความดิบ + ความกว้างหน้า — ต่างจากตอน mouseup = selection/zoom เปลี่ยน
|
||||||
// ไปแล้ว กลับไปใช้ rect ของ range เองทั้งหมด) เรียงตามตำแหน่งบนจอแล้วล้างขยะ
|
// ไปแล้ว กลับไปใช้ rect ของ range เองทั้งหมด) เรียงตามตำแหน่งบนจอแล้วล้างขยะ
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tool !== "highlight" && tool !== "select") return;
|
if (!TEXT_SELECTION_TOOLS.has(tool)) return;
|
||||||
const onCopy = (e: ClipboardEvent) => {
|
const onCopy = (e: ClipboardEvent) => {
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return;
|
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return;
|
||||||
|
|
@ -2111,7 +2131,21 @@ export default function PDFViewer({
|
||||||
pageIndex: number,
|
pageIndex: number,
|
||||||
pageEl: HTMLElement,
|
pageEl: HTMLElement,
|
||||||
) {
|
) {
|
||||||
|
// ปล่อยเมาส์จากการลากคลุมข้อความ (comment/textbox/stamp ก็คลุมได้) ไม่ถือเป็นคลิก —
|
||||||
|
// selection ยังค้างตอน click ยิง ให้ผ่านไปเฉย ๆ เหมือน tool เลือกข้อความ ไม่เปิด
|
||||||
|
// composer/วางตรายาง (คลิกล้วน ๆ selection ถูกยุบตั้งแต่ mousedown จึงไม่โดน guard นี้)
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection && !selection.isCollapsed) return;
|
||||||
if (bookmarkComposer) setBookmarkComposer(null); // คลิกหน้า = ปิด popup bookmark (N2)
|
if (bookmarkComposer) setBookmarkComposer(null); // คลิกหน้า = ปิด popup bookmark (N2)
|
||||||
|
// คลิกลงหน้า = เลิกเลือก annotation ก่อนทุก tool — แผงเครื่องมือของกรอบ selection
|
||||||
|
// ต้องปิดเมื่อคลิกไปสร้างรายการใหม่ (comment/textbox/stamp เดิม return ก่อนถึง
|
||||||
|
// logic เลิกเลือกท้ายฟังก์ชัน แผงจึงค้างเปิด) — คลิกบนตัว annotation เองถูก
|
||||||
|
// stopPropagation ที่ layer ของมันแล้ว ส่วน tool select/highlight ตั้ง selectedId
|
||||||
|
// ตาม hit-test ทีหลังจุดนี้เหมือนเดิม พฤติกรรมจึงเท่าเดิม
|
||||||
|
if (selectedId !== null) {
|
||||||
|
setSelectedId(null);
|
||||||
|
setPreviewData(null);
|
||||||
|
}
|
||||||
if (tool === "comment" && user) {
|
if (tool === "comment" && user) {
|
||||||
const position = getPointerPercent(e, pageEl);
|
const position = getPointerPercent(e, pageEl);
|
||||||
setComposer({ pageIndex, position, x: e.clientX, y: e.clientY });
|
setComposer({ pageIndex, position, x: e.clientX, y: e.clientY });
|
||||||
|
|
@ -2150,11 +2184,6 @@ export default function PDFViewer({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// คลิกพื้นที่ว่างของหน้า (tool select/draw) → เลิกเลือก annotation
|
|
||||||
if (selectedId !== null) {
|
|
||||||
setSelectedId(null);
|
|
||||||
setPreviewData(null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleComposerSave() {
|
async function handleComposerSave() {
|
||||||
|
|
@ -2253,6 +2282,9 @@ export default function PDFViewer({
|
||||||
...(textboxBold ? { bold: true } : {}),
|
...(textboxBold ? { bold: true } : {}),
|
||||||
opacity: textboxOpacityPct / 100,
|
opacity: textboxOpacityPct / 100,
|
||||||
align: textboxAlign,
|
align: textboxAlign,
|
||||||
|
// กล่องกว้างคงที่ (สไตล์ Smallpdf) — บีบด้วยพื้นที่จริงถึงขอบขวาตอนสร้าง
|
||||||
|
// ให้ตรงกับที่ editor แสดง (maxWidth ของ CSS clamp ให้อยู่แล้วระหว่างพิมพ์)
|
||||||
|
width: Math.min(TEXTBOX_DEFAULT_WIDTH_PCT, textboxMaxWidthPct(c.position.x)),
|
||||||
...(hasBold ? { segments } : {}),
|
...(hasBold ? { segments } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -2299,6 +2331,7 @@ export default function PDFViewer({
|
||||||
bold: textboxBold,
|
bold: textboxBold,
|
||||||
opacity: textboxOpacityPct / 100,
|
opacity: textboxOpacityPct / 100,
|
||||||
align: textboxAlign,
|
align: textboxAlign,
|
||||||
|
widthPct: TEXTBOX_DEFAULT_WIDTH_PCT,
|
||||||
};
|
};
|
||||||
// ความกว้างหน้าแรกที่ scale 1 (≈ pt ของ PDF) — ใช้แปลงหน่วย fontSize ระหว่าง % (ที่เก็บใน DB)
|
// ความกว้างหน้าแรกที่ scale 1 (≈ pt ของ PDF) — ใช้แปลงหน่วย fontSize ระหว่าง % (ที่เก็บใน DB)
|
||||||
// กับ px ที่แสดงใน UI (fallback 595 = ความกว้าง A4 ก่อนเอกสารโหลด)
|
// กับ px ที่แสดงใน UI (fallback 595 = ความกว้าง A4 ก่อนเอกสารโหลด)
|
||||||
|
|
@ -2306,9 +2339,10 @@ export default function PDFViewer({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
{/* tool highlight/select — ซ่อนสี selection ของเบราว์เซอร์ ให้เห็นแต่ preview
|
{/* tool คลุมข้อความได้ (TEXT_SELECTION_TOOLS) — ซ่อนสี selection ของเบราว์เซอร์
|
||||||
มาร์ก/selection สีฟ้าของเรา (กรองแนวเส้นทางลากแล้ว สะอาดกว่า native) */}
|
ให้เห็นแต่ preview มาร์ก/selection สีฟ้าของเรา (กรองแนวเส้นทางลากแล้ว
|
||||||
{(tool === "highlight" || tool === "select") && (
|
สะอาดกว่า native) */}
|
||||||
|
{TEXT_SELECTION_TOOLS.has(tool) && (
|
||||||
<style>{`.textLayer span::selection, .textLayer br::selection { background: transparent; }`}</style>
|
<style>{`.textLayer span::selection, .textLayer br::selection { background: transparent; }`}</style>
|
||||||
)}
|
)}
|
||||||
{/* UX review 2026-09-09: backdrop กันคลิกทะลุใต้ sidebar overlay (เฉพาะ < xl)
|
{/* UX review 2026-09-09: backdrop กันคลิกทะลุใต้ sidebar overlay (เฉพาะ < xl)
|
||||||
|
|
@ -2801,7 +2835,10 @@ export default function PDFViewer({
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setTextboxBold((o) => !o)}
|
onClick={() => setTextboxBold((o) => !o)}
|
||||||
>
|
>
|
||||||
<Bold className="h-3.5 w-3.5" />
|
{/* B ทึบ — แทน icon Bold แบบเส้นขอบ (lucide ไม่มีรูปทึบ) */}
|
||||||
|
<span aria-hidden className="text-[13px] font-black leading-none">
|
||||||
|
B
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</PanelRow>
|
</PanelRow>
|
||||||
<PanelRow label="จัดแนว">
|
<PanelRow label="จัดแนว">
|
||||||
|
|
@ -3048,14 +3085,14 @@ export default function PDFViewer({
|
||||||
onMouseUp={() => handleMouseUpOnPage(i)}
|
onMouseUp={() => handleMouseUpOnPage(i)}
|
||||||
onClick={(e) => handlePageClick(e, i, e.currentTarget)}
|
onClick={(e) => handlePageClick(e, i, e.currentTarget)}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
// จุดเริ่มเส้นทางลากของ tool highlight/select (กรอง selection rects)
|
// จุดเริ่มเส้นทางลากของ tool คลุมข้อความ (กรอง selection rects)
|
||||||
if (tool !== "highlight" && tool !== "select") return;
|
if (!TEXT_SELECTION_TOOLS.has(tool)) return;
|
||||||
selDragStartRef.current = { x: e.clientX, y: e.clientY };
|
selDragStartRef.current = { x: e.clientX, y: e.clientY };
|
||||||
selDragEndRef.current = { x: e.clientX, y: e.clientY };
|
selDragEndRef.current = { x: e.clientX, y: e.clientY };
|
||||||
}}
|
}}
|
||||||
onMouseMove={(e) => {
|
onMouseMove={(e) => {
|
||||||
// ตำแหน่งเมาส์ล่าสุดระหว่างลากค้างปุ่ม (buttons=1)
|
// ตำแหน่งเมาส์ล่าสุดระหว่างลากค้างปุ่ม (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 };
|
selDragEndRef.current = { x: e.clientX, y: e.clientY };
|
||||||
}
|
}
|
||||||
handlePageHoverMove(e, i);
|
handlePageHoverMove(e, i);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef } from "react";
|
||||||
|
|
||||||
import type { StampData, TextboxData, TextboxSegment } from "@/db/schema";
|
import type { StampData, TextboxData, TextboxSegment } from "@/db/schema";
|
||||||
import { toPx, type Point, type Size } from "@/utils/coordinates";
|
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";
|
import { STAMP_COLOR, stampFitFontSize } from "@/utils/annotation-display";
|
||||||
|
|
||||||
// TEXTBOX + STAMP overlay (F17/B2, F18/B5) — HTML overlay (z-4 กลุ่มเดียวกับ comment pins)
|
// TEXTBOX + STAMP overlay (F17/B2, F18/B5) — HTML overlay (z-4 กลุ่มเดียวกับ comment pins)
|
||||||
|
|
@ -83,6 +83,7 @@ function TextboxEditor({
|
||||||
bold,
|
bold,
|
||||||
opacity,
|
opacity,
|
||||||
align,
|
align,
|
||||||
|
widthPct,
|
||||||
enabled,
|
enabled,
|
||||||
initialText,
|
initialText,
|
||||||
initialSegments,
|
initialSegments,
|
||||||
|
|
@ -97,6 +98,8 @@ function TextboxEditor({
|
||||||
bold?: boolean;
|
bold?: boolean;
|
||||||
opacity?: number;
|
opacity?: number;
|
||||||
align?: "left" | "center" | "right";
|
align?: "left" | "center" | "right";
|
||||||
|
/** ความกว้างกล่องคงที่ % (กล่องใหม่/กล่องที่มี width) — ไม่มี = shrink-to-fit (แถวเดิม) */
|
||||||
|
widthPct?: number;
|
||||||
/** container ของ layer เป็น pointer-events:none — editor ต้องเปิด auto เองเมื่อใช้งานได้ */
|
/** container ของ layer เป็น pointer-events:none — editor ต้องเปิด auto เองเมื่อใช้งานได้ */
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
initialText: string;
|
initialText: string;
|
||||||
|
|
@ -140,9 +143,11 @@ function TextboxEditor({
|
||||||
style={{
|
style={{
|
||||||
left: toPx(position.x, size.width),
|
left: toPx(position.x, size.width),
|
||||||
top: toPx(position.y, size.height),
|
top: toPx(position.y, size.height),
|
||||||
// กล่องพอดีข้อความ (shrink-to-fit ของ absolute) — เริ่มเล็กตอนกดคลิกครั้งแรก
|
// มี widthPct = กล่องกว้างคงที่ (สไตล์ Smallpdf — ข้อความ wrap ในกล่อง)
|
||||||
// แล้วขยายตามที่พิมพ์ ตัดบรรทัดเมื่อยาวเกิน 50% ของความกว้างหน้าเหมือนที่ render จริง
|
// ไม่มี = shrink-to-fit หุ้มข้อความขยายตามที่พิมพ์ (แถวเดิม)
|
||||||
maxWidth: `${TEXTBOX_MAX_WIDTH_PCT}%`,
|
// maxWidth บีบตามพื้นที่จริงถึงขอบขวา (ไม่ล้นกระดาษ)
|
||||||
|
...(widthPct !== undefined ? { width: `${widthPct}%` } : null),
|
||||||
|
maxWidth: `${textboxMaxWidthPct(position.x)}%`,
|
||||||
minWidth: "1em",
|
minWidth: "1em",
|
||||||
opacity: opacity ?? 1,
|
opacity: opacity ?? 1,
|
||||||
pointerEvents: enabled ? "auto" : "none",
|
pointerEvents: enabled ? "auto" : "none",
|
||||||
|
|
@ -230,6 +235,8 @@ export default function TextboxStampLayer({
|
||||||
bold?: boolean;
|
bold?: boolean;
|
||||||
opacity?: number;
|
opacity?: number;
|
||||||
align?: "left" | "center" | "right";
|
align?: "left" | "center" | "right";
|
||||||
|
/** ความกว้างกล่อง default % (กล่องใหม่เป็นกล่องกว้างคงที่เสมอ) */
|
||||||
|
widthPct?: number;
|
||||||
} | null;
|
} | null;
|
||||||
onCommitComposer: (text: string, segments?: TextboxSegment[]) => void;
|
onCommitComposer: (text: string, segments?: TextboxSegment[]) => void;
|
||||||
onCancelComposer: () => void;
|
onCancelComposer: () => void;
|
||||||
|
|
@ -249,6 +256,7 @@ export default function TextboxStampLayer({
|
||||||
bold={t.data.bold}
|
bold={t.data.bold}
|
||||||
opacity={t.data.opacity}
|
opacity={t.data.opacity}
|
||||||
align={t.data.align}
|
align={t.data.align}
|
||||||
|
widthPct={t.data.width}
|
||||||
enabled={enabled}
|
enabled={enabled}
|
||||||
initialText={t.data.text}
|
initialText={t.data.text}
|
||||||
initialSegments={t.data.segments}
|
initialSegments={t.data.segments}
|
||||||
|
|
@ -263,7 +271,10 @@ export default function TextboxStampLayer({
|
||||||
style={{
|
style={{
|
||||||
left: toPx(t.data.position.x, size.width),
|
left: toPx(t.data.position.x, size.width),
|
||||||
top: toPx(t.data.position.y, size.height),
|
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),
|
fontSize: toPx(t.data.fontSize, size.width),
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
color: t.data.color,
|
color: t.data.color,
|
||||||
|
|
@ -302,6 +313,7 @@ export default function TextboxStampLayer({
|
||||||
bold={composer.bold}
|
bold={composer.bold}
|
||||||
opacity={composer.opacity}
|
opacity={composer.opacity}
|
||||||
align={composer.align}
|
align={composer.align}
|
||||||
|
widthPct={composer.widthPct}
|
||||||
enabled={enabled}
|
enabled={enabled}
|
||||||
initialText=""
|
initialText=""
|
||||||
onSave={onCommitComposer}
|
onSave={onCommitComposer}
|
||||||
|
|
|
||||||
|
|
@ -96,11 +96,14 @@ export type TextboxData = {
|
||||||
bold?: boolean;
|
bold?: boolean;
|
||||||
/** ความทึบ 0–1 (optional) — กล่องที่สร้างหลังมีตัวปรับเก็บชัดเสมอ แถวเดิม = 1 */
|
/** ความทึบ 0–1 (optional) — กล่องที่สร้างหลังมีตัวปรับเก็บชัดเสมอ แถวเดิม = 1 */
|
||||||
opacity?: number;
|
opacity?: number;
|
||||||
/** จัดแนวในกล่อง (optional) — ไม่มี = left */
|
/** จัดแนวข้อความในกล่อง (optional) — ไม่มี = left · กล่องไม่ขยับ ขยับแค่ข้อความข้างใน (แบบ Smallpdf) */
|
||||||
align?: "left" | "center" | "right";
|
align?: "left" | "center" | "right";
|
||||||
/** ข้อความแบ่งช่วงตัวหนา/ปกติ (optional) — มีเฉพาะเมื่อมีบางส่วนเป็นตัวหนา;
|
/** ข้อความแบ่งช่วงตัวหนา/ปกติ (optional) — มีเฉพาะเมื่อมีบางส่วนเป็นตัวหนา;
|
||||||
* field `text` ยังเก็บข้อความรวม (concat) เสมอเพื่อค้นหา F14/preview sidebar */
|
* field `text` ยังเก็บข้อความรวม (concat) เสมอเพื่อค้นหา F14/preview sidebar */
|
||||||
segments?: TextboxSegment[];
|
segments?: TextboxSegment[];
|
||||||
|
/** ความกว้างกล่องคงที่ % ของความกว้างหน้า (optional — สไตล์ Smallpdf/Acrobat: ลากขอบ
|
||||||
|
* ปรับความกว้าง ข้อความ wrap ตาม) — ไม่มี = กล่อง shrink-to-fit หุ้มข้อความ (แถวเดิม) */
|
||||||
|
width?: number;
|
||||||
/** โน้ตคำอธิบายของรายการนี้ (optional) */
|
/** โน้ตคำอธิบายของรายการนี้ (optional) */
|
||||||
note?: string;
|
note?: string;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ type StampData = { label: string; position: Point; scale: number };
|
||||||
|
|
||||||
- `strokeWidth` ของ SHAPE เป็น % ของความกว้างหน้าเหมือน DRAW; ทุกพิกัด (rect/from/to/position) เป็น % ของขนาดหน้า (invariant #2)
|
- `strokeWidth` ของ SHAPE เป็น % ของความกว้างหน้าเหมือน DRAW; ทุกพิกัด (rect/from/to/position) เป็น % ของขนาดหน้า (invariant #2)
|
||||||
- E1 (F7) เพิ่ม field **optional** `widths?: number[]` ใน `DrawData` (ความหนาต่อจุด, % ของ width — ไว้รองรับ pressure)
|
- 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)
|
### Column เพิ่มในตาราง `annotations` (F10)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
hitTestPath,
|
hitTestPath,
|
||||||
hitTestShape,
|
hitTestShape,
|
||||||
scaleAnnotationData,
|
scaleAnnotationData,
|
||||||
|
textboxMaxWidthPct,
|
||||||
translateAnnotationData,
|
translateAnnotationData,
|
||||||
} from "@/utils/geometry";
|
} from "@/utils/geometry";
|
||||||
|
|
||||||
|
|
@ -132,6 +133,100 @@ describe("annotationBBox", () => {
|
||||||
expect(bbox.height).toBeCloseTo(6); // 2 บรรทัด × 3
|
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 บรรทัด", () => {
|
it("TEXTBOX: ข้อความว่าง → กว้าง fallback เท่าตัวอักษรเดียว สูง 1 บรรทัด", () => {
|
||||||
const bbox = annotationBBox(
|
const bbox = annotationBBox(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,18 @@ export const COMMENT_PIN_BOX_PCT = 4;
|
||||||
// render จริงกว้างสุด 50% ของหน้าแล้ว wrap อัตโนมัติ — bbox ประมาณจากตัวอักษร
|
// render จริงกว้างสุด 50% ของหน้าแล้ว wrap อัตโนมัติ — bbox ประมาณจากตัวอักษร
|
||||||
// กว้าง ~0.6 × fontSize ต่อตัว บรรทัดสูง 1.5 × fontSize (ค่าประมาณพอสำหรับกรอบ selection)
|
// กว้าง ~0.6 × fontSize ต่อตัว บรรทัดสูง 1.5 × fontSize (ค่าประมาณพอสำหรับกรอบ selection)
|
||||||
export const TEXTBOX_MAX_WIDTH_PCT = 50;
|
export const TEXTBOX_MAX_WIDTH_PCT = 50;
|
||||||
|
export const TEXTBOX_DEFAULT_WIDTH_PCT = 30; // กล่องที่สร้างใหม่ (คลิกเดียว) กว้างเท่านี้ก่อนลากขยาย
|
||||||
export const TEXTBOX_CHAR_W = 0.6;
|
export const TEXTBOX_CHAR_W = 0.6;
|
||||||
export const TEXTBOX_LINE_H = 1.5;
|
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 เท่าของความกว้าง
|
// STAMP (F18/B5 — §5): กรอบตรายางกว้าง = scale% ของความกว้างหน้า สูง = 0.4 เท่าของความกว้าง
|
||||||
// (สูตรเดียวกันทั้ง render บนจอ, bbox ของ selection และการวาดใน export)
|
// (สูตรเดียวกันทั้ง render บนจอ, bbox ของ selection และการวาดใน export)
|
||||||
export const STAMP_HEIGHT_RATIO = 0.4;
|
export const STAMP_HEIGHT_RATIO = 0.4;
|
||||||
|
|
@ -81,24 +90,25 @@ export function annotationBBox(a: AnnotationLike, size: Size): Rect {
|
||||||
return rectToPx(d.rect, size);
|
return rectToPx(d.rect, size);
|
||||||
}
|
}
|
||||||
if ("fontSize" in d) {
|
if ("fontSize" in d) {
|
||||||
// TEXTBOX — ประมาณจากข้อความ (position เป็นมุมซ้ายบนของกล่อง)
|
// TEXTBOX — position เป็นมุมซ้ายบนของกล่องเสมอ (align จัดข้อความในกล่องอย่างเดียว);
|
||||||
|
// กล่องมี width คงที่ (ถ้ามี — สไตล์ Smallpdf) ไม่มี = shrink-to-fit ประมาณจากตัวอักษร
|
||||||
const fontSizePx = toPx(d.fontSize, size.width);
|
const fontSizePx = toPx(d.fontSize, size.width);
|
||||||
const charW = fontSizePx * TEXTBOX_CHAR_W;
|
const charW = fontSizePx * TEXTBOX_CHAR_W;
|
||||||
const lineH = fontSizePx * TEXTBOX_LINE_H;
|
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 widest = 0;
|
||||||
let lines = 0;
|
let lines = 0;
|
||||||
for (const raw of d.text.split(/\r?\n/)) {
|
for (const raw of d.text.split(/\r?\n/)) {
|
||||||
widest = Math.max(widest, raw.length * charW);
|
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);
|
const p = pointToPx(d.position, size);
|
||||||
return {
|
return { x: p.x, y: p.y, width, height: Math.max(lines, 1) * lineH };
|
||||||
x: p.x,
|
|
||||||
y: p.y,
|
|
||||||
width: Math.min(widest, maxW) || charW,
|
|
||||||
height: Math.max(lines, 1) * lineH,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if ("scale" in d) {
|
if ("scale" in d) {
|
||||||
// STAMP — กรอบจริงตามสูตร render (position เป็นมุมซ้ายบน)
|
// STAMP — กรอบจริงตามสูตร render (position เป็นมุมซ้ายบน)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue