Compare commits
No commits in common. "9e5083dae1a258d80c3ca3c353b8e4dd95250380" and "9e6158ed45ef0f539af7693140816c73c4ea5946" have entirely different histories.
9e5083dae1
...
9e6158ed45
11 changed files with 50 additions and 278 deletions
|
|
@ -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?, 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)
|
||||
- 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)
|
||||
- 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
|
||||
- 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,9 +23,6 @@ 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): ลากบน `<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
|
||||
- **คลุมข้อความได้ 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 ย้าย/ขยาย** (ผูกกับตำแหน่งข้อความจริง เลือกเพื่อเปลี่ยนสี/รูปแบบ/ลบได้อย่างเดียว)
|
||||
- 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)
|
||||
- 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,13 +309,6 @@ function validateAnnotationData(type: AnnotationType, v: unknown): AnnotationDat
|
|||
}
|
||||
}
|
||||
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 {
|
||||
position,
|
||||
text,
|
||||
|
|
@ -325,7 +318,6 @@ function validateAnnotationData(type: AnnotationType, v: unknown): AnnotationDat
|
|||
...(d.opacity !== undefined ? { opacity: d.opacity } : {}),
|
||||
...(d.align !== undefined ? { align: d.align } : {}),
|
||||
...(d.segments !== undefined ? { segments: d.segments } : {}),
|
||||
...(d.width !== undefined ? { width: d.width } : {}),
|
||||
...(textboxNote !== undefined ? { note: textboxNote } : {}),
|
||||
} satisfies TextboxData;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import {
|
|||
} from "@/utils/pdf-export";
|
||||
import type { Rect } from "@/db/schema";
|
||||
import { toPx, type Point, type Size } from "@/utils/coordinates";
|
||||
import { STAMP_HEIGHT_RATIO, textboxMaxWidthPct } from "@/utils/geometry";
|
||||
import { STAMP_HEIGHT_RATIO, TEXTBOX_MAX_WIDTH_PCT } from "@/utils/geometry";
|
||||
import { STAMP_COLOR, effectiveDrawOpacity, stampFitFontSize } from "@/utils/annotation-display";
|
||||
|
||||
// Export PDF ฝัง annotation (F8/C1 — PLAN-FEAT.md §3)
|
||||
|
|
@ -340,14 +340,7 @@ export async function GET(req: Request) {
|
|||
const { r, g, b } = hexToRgb01(d.color);
|
||||
if (thaiFont) {
|
||||
const fontSize = Math.max(thickness(d.fontSize), 4);
|
||||
// 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,
|
||||
);
|
||||
const maxWidth = px(TEXTBOX_MAX_WIDTH_PCT, display.width);
|
||||
// run ต่อสไตล์: segments (ตัวหนาเฉพาะส่วน) หรือทั้งกล่องตาม d.bold
|
||||
const runs: StyledRun[] = d.segments?.length
|
||||
? d.segments.map((s) => ({ text: s.text, bold: !!(s.bold || d.bold) }))
|
||||
|
|
@ -356,25 +349,16 @@ export async function GET(req: Request) {
|
|||
const widthOf = (s: string, bold: boolean) =>
|
||||
fontFor(bold).widthOfTextAtSize(s, fontSize);
|
||||
const lines = wrapStyledText(runs, maxWidth, widthOf);
|
||||
// กล่องห้อยจากมุมซ้ายบน (pos) เหมือน render จอ — align จัดบรรทัด*ใน*กล่องอย่างเดียว
|
||||
// (แบบ Smallpdf กล่องไม่ขยับ): กล่อง width คงที่กว้างเต็ม maxWidth ตามที่กำหนด
|
||||
// ส่วนแถวเดิม shrink-to-fit กว้างเท่าบรรทัดยาวสุด (ไม่เกิน maxWidth)
|
||||
// จัดแนวในกล่องกว้าง maxWidth — เลื่อน x ต่อบรรทัดตามความกว้างจริงของบรรทัดนั้น
|
||||
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) => {
|
||||
const lineW = lineWOf(lineRuns);
|
||||
const lineW = lineRuns.reduce((sum, rn) => sum + widthOf(rn.text, rn.bold), 0);
|
||||
let x =
|
||||
align === "center"
|
||||
? boxLeft + (boxW - lineW) / 2
|
||||
? pos.x + (maxWidth - lineW) / 2
|
||||
: align === "right"
|
||||
? boxLeft + (boxW - lineW)
|
||||
: boxLeft;
|
||||
? pos.x + (maxWidth - lineW)
|
||||
: pos.x;
|
||||
for (const rn of lineRuns) {
|
||||
page.drawText(rn.text, {
|
||||
x,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import { AlignCenter, AlignLeft, AlignRight, BookmarkPlus, Trash2 } from "lucide-react";
|
||||
import { AlignCenter, AlignLeft, AlignRight, BookmarkPlus, Bold, Trash2 } from "lucide-react";
|
||||
|
||||
import type {
|
||||
AnnotationData,
|
||||
|
|
@ -16,7 +16,6 @@ import type {
|
|||
TextboxData,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
TEXTBOX_MAX_WIDTH_PCT,
|
||||
annotationBBox,
|
||||
scaleAnnotationData,
|
||||
translateAnnotationData,
|
||||
|
|
@ -38,8 +37,7 @@ const MIN_BOX_PCT = 0.5; // bbox เล็กสุด (% ของหน้า)
|
|||
const EPS = 1e-6;
|
||||
|
||||
type DragState = {
|
||||
// wleft/wright = TEXTBOX กว้างคงที่: ลากขอบซ้าย/ขวาปรับความกว้าง (ฟอนต์คงขนาด)
|
||||
mode: "move" | "scale" | "wleft" | "wright";
|
||||
mode: "move" | "scale";
|
||||
/** ตำแหน่ง pointer ตอนเริ่มลาก (% ของหน้า) */
|
||||
startPct: Point;
|
||||
/** data ก่อนเริ่มลาก — ต้นทางของการคำนวณทุก move (ไม่สะสมจาก preview) */
|
||||
|
|
@ -120,7 +118,7 @@ export default function AnnotationSelectionLayer({
|
|||
|
||||
function beginDrag(
|
||||
e: ReactPointerEvent<HTMLElement>,
|
||||
mode: "move" | "scale" | "wleft" | "wright",
|
||||
mode: "move" | "scale",
|
||||
anchorPct: Point,
|
||||
) {
|
||||
if (!editable || e.button !== 0) return;
|
||||
|
|
@ -157,36 +155,6 @@ export default function AnnotationSelectionLayer({
|
|||
100 - st.origBoxPct.y - st.origBoxPct.height,
|
||||
);
|
||||
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) {
|
||||
// STAMP — สัดส่วนกว้าง:สูงตายตัว (สูง = STAMP_HEIGHT_RATIO × กว้าง) แยกสเกล
|
||||
// สองแกนไม่ได้ จึงใช้ตัวคูณ uniform เดียว: เฉลี่ยอัตราส่วนของ pointer สองแกน
|
||||
|
|
@ -320,9 +288,6 @@ export default function AnnotationSelectionLayer({
|
|||
const stampData = "label" in annotation.data ? annotation.data : null;
|
||||
const textboxData: TextboxData | null =
|
||||
"fontSize" in annotation.data ? annotation.data : null;
|
||||
// TEXTBOX กว้างคงที่ (มี width) — ขอบซ้าย/ขวาเป็น handle ปรับความกว้างแทนการย้าย
|
||||
// และซ่อนมุม (scale ไม่มีความหมายกับกล่องกว้างคงที่) — ย้ายกล่องใช้ขอบบน/ล่าง
|
||||
const isFixedTextbox = textboxData?.width !== undefined;
|
||||
|
||||
// มุมทั้ง 4 — ตำแหน่งวางเป็น px, anchor มุมตรงข้ามเป็น % ตรง ๆ จาก boxPct
|
||||
// (พิกัด % คำนวณผ่าน utils/coordinates เท่านั้น — invariant #2)
|
||||
|
|
@ -428,7 +393,7 @@ export default function AnnotationSelectionLayer({
|
|||
onPointerCancel={cancelDrag}
|
||||
/>
|
||||
<div
|
||||
className={`absolute ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
|
||||
className="absolute cursor-move"
|
||||
style={{
|
||||
left: boxPx.x - STRIP_PX / 2,
|
||||
top: boxPx.y,
|
||||
|
|
@ -436,15 +401,13 @@ export default function AnnotationSelectionLayer({
|
|||
height: boxPx.height,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
onPointerDown={(e) =>
|
||||
beginDrag(e, isFixedTextbox ? "wleft" : "move", { x: 0, y: 0 })
|
||||
}
|
||||
onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })}
|
||||
onPointerMove={moveDrag}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={cancelDrag}
|
||||
/>
|
||||
<div
|
||||
className={`absolute ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
|
||||
className="absolute cursor-move"
|
||||
style={{
|
||||
left: boxPx.x + boxPx.width - STRIP_PX / 2,
|
||||
top: boxPx.y,
|
||||
|
|
@ -452,17 +415,14 @@ export default function AnnotationSelectionLayer({
|
|||
height: boxPx.height,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
onPointerDown={(e) =>
|
||||
beginDrag(e, isFixedTextbox ? "wright" : "move", { x: 0, y: 0 })
|
||||
}
|
||||
onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })}
|
||||
onPointerMove={moveDrag}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={cancelDrag}
|
||||
/>
|
||||
|
||||
{/* มุมทั้ง 4 — ลาก = scale รอบ anchor มุมตรงข้าม (TEXTBOX กว้างคงที่ไม่มีมุม —
|
||||
ปรับความกว้างที่ขอบซ้าย/ขวาแทน ส่วนสูงตามจำนวนบรรทัดอัตโนมัติ) */}
|
||||
{!isFixedTextbox && corners.map((c) => (
|
||||
{/* มุมทั้ง 4 — ลาก = scale รอบ anchor มุมตรงข้าม */}
|
||||
{corners.map((c) => (
|
||||
<div
|
||||
key={c.key}
|
||||
className="absolute rounded-sm border border-primary bg-primary/50"
|
||||
|
|
@ -618,10 +578,7 @@ export default function AnnotationSelectionLayer({
|
|||
}`}
|
||||
onClick={() => commitTextboxPatch({ bold: !textboxData.bold })}
|
||||
>
|
||||
{/* B ทึบ — เหมือน dropdown ของ tool (ตัวควบคุมชุดเดียวกัน) */}
|
||||
<span aria-hidden className="text-[13px] font-black leading-none">
|
||||
B
|
||||
</span>
|
||||
<Bold className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PanelRow>
|
||||
<PanelRow label="จัดแนว">
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
AlignLeft,
|
||||
AlignRight,
|
||||
ArrowRight,
|
||||
Bold,
|
||||
BookmarkPlus,
|
||||
Brush,
|
||||
Check,
|
||||
|
|
@ -105,13 +106,7 @@ import {
|
|||
type Size,
|
||||
} from "@/utils/coordinates";
|
||||
import { getLocalUser, type LocalUser } from "@/utils/user";
|
||||
import {
|
||||
TEXTBOX_DEFAULT_WIDTH_PCT,
|
||||
annotationBBox,
|
||||
hitTestPath,
|
||||
hitTestShape,
|
||||
textboxMaxWidthPct,
|
||||
} from "@/utils/geometry";
|
||||
import { annotationBBox, hitTestPath, hitTestShape } from "@/utils/geometry";
|
||||
import InkLayer, { type InkRedaction, type InkShape, type InkStroke } from "./InkLayer";
|
||||
import TextboxStampLayer, { type InkStamp, type InkTextbox } from "./TextboxStampLayer";
|
||||
import AnnotationSelectionLayer from "./AnnotationSelectionLayer";
|
||||
|
|
@ -136,17 +131,6 @@ type Tool =
|
|||
| "textbox"
|
||||
| "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 = {
|
||||
pageIndex: number;
|
||||
position: Point; // % ของหน้า
|
||||
|
|
@ -589,8 +573,6 @@ function PdfPage({
|
|||
bold: boolean;
|
||||
opacity: number;
|
||||
align: "left" | "center" | "right";
|
||||
/** ความกว้างกล่อง default (สไตล์ Smallpdf — คลิกเดียวได้กล่อง ลากขอบขยายทีหลัง) */
|
||||
widthPct: number;
|
||||
};
|
||||
/** live preview มาร์กระหว่างลากคลุมด้วย tool highlight (null = ไม่มี) */
|
||||
liveMark: { pageIndex: number; rects: Rect[] } | null;
|
||||
|
|
@ -892,7 +874,6 @@ function PdfPage({
|
|||
bold: textboxStyle.bold,
|
||||
opacity: textboxStyle.opacity,
|
||||
align: textboxStyle.align,
|
||||
widthPct: textboxStyle.widthPct,
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
|
@ -1951,10 +1932,9 @@ export default function PDFViewer({
|
|||
|
||||
// tool highlight — ฟัง selectionchange ระหว่างลากเพื่อวาด preview มาร์กสด ๆ
|
||||
// (แสดงเส้น/สีตามรูปแบบที่ตั้งไว้ทันที ไม่ต้องรอปล่อยเมาส์)
|
||||
// tool ที่เหลือของ TEXT_SELECTION_TOOLS (select/comment/textbox/stamp) — ใช้กลไก
|
||||
// เดียวกันแต่วาดเป็น selection สีฟ้าสะอาด ๆ (ไม่สร้างไฮไลต์ ไม่วางรายการ)
|
||||
// tool เลือกข้อความ — ใช้กลไกเดียวกันแต่วาดเป็น selection สีฟ้าสะอาด ๆ (ไม่สร้างไฮไลต์)
|
||||
useEffect(() => {
|
||||
if (!TEXT_SELECTION_TOOLS.has(tool)) {
|
||||
if (tool !== "highlight" && tool !== "select") {
|
||||
setLiveMark(null);
|
||||
setSelectPreview(null);
|
||||
return;
|
||||
|
|
@ -2015,7 +1995,7 @@ export default function PDFViewer({
|
|||
// (เทียบข้อความดิบ + ความกว้างหน้า — ต่างจากตอน mouseup = selection/zoom เปลี่ยน
|
||||
// ไปแล้ว กลับไปใช้ rect ของ range เองทั้งหมด) เรียงตามตำแหน่งบนจอแล้วล้างขยะ
|
||||
useEffect(() => {
|
||||
if (!TEXT_SELECTION_TOOLS.has(tool)) return;
|
||||
if (tool !== "highlight" && tool !== "select") return;
|
||||
const onCopy = (e: ClipboardEvent) => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return;
|
||||
|
|
@ -2131,21 +2111,7 @@ export default function PDFViewer({
|
|||
pageIndex: number,
|
||||
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)
|
||||
// คลิกลงหน้า = เลิกเลือก 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) {
|
||||
const position = getPointerPercent(e, pageEl);
|
||||
setComposer({ pageIndex, position, x: e.clientX, y: e.clientY });
|
||||
|
|
@ -2184,6 +2150,11 @@ export default function PDFViewer({
|
|||
}
|
||||
}
|
||||
}
|
||||
// คลิกพื้นที่ว่างของหน้า (tool select/draw) → เลิกเลือก annotation
|
||||
if (selectedId !== null) {
|
||||
setSelectedId(null);
|
||||
setPreviewData(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleComposerSave() {
|
||||
|
|
@ -2282,9 +2253,6 @@ export default function PDFViewer({
|
|||
...(textboxBold ? { bold: true } : {}),
|
||||
opacity: textboxOpacityPct / 100,
|
||||
align: textboxAlign,
|
||||
// กล่องกว้างคงที่ (สไตล์ Smallpdf) — บีบด้วยพื้นที่จริงถึงขอบขวาตอนสร้าง
|
||||
// ให้ตรงกับที่ editor แสดง (maxWidth ของ CSS clamp ให้อยู่แล้วระหว่างพิมพ์)
|
||||
width: Math.min(TEXTBOX_DEFAULT_WIDTH_PCT, textboxMaxWidthPct(c.position.x)),
|
||||
...(hasBold ? { segments } : {}),
|
||||
});
|
||||
}
|
||||
|
|
@ -2331,7 +2299,6 @@ export default function PDFViewer({
|
|||
bold: textboxBold,
|
||||
opacity: textboxOpacityPct / 100,
|
||||
align: textboxAlign,
|
||||
widthPct: TEXTBOX_DEFAULT_WIDTH_PCT,
|
||||
};
|
||||
// ความกว้างหน้าแรกที่ scale 1 (≈ pt ของ PDF) — ใช้แปลงหน่วย fontSize ระหว่าง % (ที่เก็บใน DB)
|
||||
// กับ px ที่แสดงใน UI (fallback 595 = ความกว้าง A4 ก่อนเอกสารโหลด)
|
||||
|
|
@ -2339,10 +2306,9 @@ export default function PDFViewer({
|
|||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* tool คลุมข้อความได้ (TEXT_SELECTION_TOOLS) — ซ่อนสี selection ของเบราว์เซอร์
|
||||
ให้เห็นแต่ preview มาร์ก/selection สีฟ้าของเรา (กรองแนวเส้นทางลากแล้ว
|
||||
สะอาดกว่า native) */}
|
||||
{TEXT_SELECTION_TOOLS.has(tool) && (
|
||||
{/* tool highlight/select — ซ่อนสี selection ของเบราว์เซอร์ ให้เห็นแต่ preview
|
||||
มาร์ก/selection สีฟ้าของเรา (กรองแนวเส้นทางลากแล้ว สะอาดกว่า native) */}
|
||||
{(tool === "highlight" || tool === "select") && (
|
||||
<style>{`.textLayer span::selection, .textLayer br::selection { background: transparent; }`}</style>
|
||||
)}
|
||||
{/* UX review 2026-09-09: backdrop กันคลิกทะลุใต้ sidebar overlay (เฉพาะ < xl)
|
||||
|
|
@ -2835,10 +2801,7 @@ export default function PDFViewer({
|
|||
}`}
|
||||
onClick={() => setTextboxBold((o) => !o)}
|
||||
>
|
||||
{/* B ทึบ — แทน icon Bold แบบเส้นขอบ (lucide ไม่มีรูปทึบ) */}
|
||||
<span aria-hidden className="text-[13px] font-black leading-none">
|
||||
B
|
||||
</span>
|
||||
<Bold className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PanelRow>
|
||||
<PanelRow label="จัดแนว">
|
||||
|
|
@ -3085,14 +3048,14 @@ export default function PDFViewer({
|
|||
onMouseUp={() => handleMouseUpOnPage(i)}
|
||||
onClick={(e) => handlePageClick(e, i, e.currentTarget)}
|
||||
onMouseDown={(e) => {
|
||||
// จุดเริ่มเส้นทางลากของ tool คลุมข้อความ (กรอง selection rects)
|
||||
if (!TEXT_SELECTION_TOOLS.has(tool)) return;
|
||||
// จุดเริ่มเส้นทางลากของ tool highlight/select (กรอง selection rects)
|
||||
if (tool !== "highlight" && tool !== "select") return;
|
||||
selDragStartRef.current = { x: e.clientX, y: e.clientY };
|
||||
selDragEndRef.current = { x: e.clientX, y: e.clientY };
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
// ตำแหน่งเมาส์ล่าสุดระหว่างลากค้างปุ่ม (buttons=1)
|
||||
if (TEXT_SELECTION_TOOLS.has(tool) && e.buttons === 1) {
|
||||
if ((tool === "highlight" || tool === "select") && e.buttons === 1) {
|
||||
selDragEndRef.current = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
handlePageHoverMove(e, i);
|
||||
|
|
|
|||
|
|
@ -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, textboxMaxWidthPct } from "@/utils/geometry";
|
||||
import { STAMP_HEIGHT_RATIO, TEXTBOX_MAX_WIDTH_PCT } 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,7 +83,6 @@ function TextboxEditor({
|
|||
bold,
|
||||
opacity,
|
||||
align,
|
||||
widthPct,
|
||||
enabled,
|
||||
initialText,
|
||||
initialSegments,
|
||||
|
|
@ -98,8 +97,6 @@ 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;
|
||||
|
|
@ -143,11 +140,9 @@ function TextboxEditor({
|
|||
style={{
|
||||
left: toPx(position.x, size.width),
|
||||
top: toPx(position.y, size.height),
|
||||
// มี widthPct = กล่องกว้างคงที่ (สไตล์ Smallpdf — ข้อความ wrap ในกล่อง)
|
||||
// ไม่มี = shrink-to-fit หุ้มข้อความขยายตามที่พิมพ์ (แถวเดิม)
|
||||
// maxWidth บีบตามพื้นที่จริงถึงขอบขวา (ไม่ล้นกระดาษ)
|
||||
...(widthPct !== undefined ? { width: `${widthPct}%` } : null),
|
||||
maxWidth: `${textboxMaxWidthPct(position.x)}%`,
|
||||
// กล่องพอดีข้อความ (shrink-to-fit ของ absolute) — เริ่มเล็กตอนกดคลิกครั้งแรก
|
||||
// แล้วขยายตามที่พิมพ์ ตัดบรรทัดเมื่อยาวเกิน 50% ของความกว้างหน้าเหมือนที่ render จริง
|
||||
maxWidth: `${TEXTBOX_MAX_WIDTH_PCT}%`,
|
||||
minWidth: "1em",
|
||||
opacity: opacity ?? 1,
|
||||
pointerEvents: enabled ? "auto" : "none",
|
||||
|
|
@ -235,8 +230,6 @@ export default function TextboxStampLayer({
|
|||
bold?: boolean;
|
||||
opacity?: number;
|
||||
align?: "left" | "center" | "right";
|
||||
/** ความกว้างกล่อง default % (กล่องใหม่เป็นกล่องกว้างคงที่เสมอ) */
|
||||
widthPct?: number;
|
||||
} | null;
|
||||
onCommitComposer: (text: string, segments?: TextboxSegment[]) => void;
|
||||
onCancelComposer: () => void;
|
||||
|
|
@ -256,7 +249,6 @@ 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}
|
||||
|
|
@ -271,10 +263,7 @@ export default function TextboxStampLayer({
|
|||
style={{
|
||||
left: toPx(t.data.position.x, size.width),
|
||||
top: toPx(t.data.position.y, size.height),
|
||||
// กล่อง width คงที่ (ถ้ามี) กว้างตามที่กำหนด — align จัดข้อความ*ใน*กล่องอย่างเดียว
|
||||
// (แบบ Smallpdf กล่องไม่ขยับ) · maxWidth บีบตามพื้นที่ถึงขอบขวาเหมือนเดิม
|
||||
...(t.data.width !== undefined ? { width: `${t.data.width}%` } : null),
|
||||
maxWidth: `${textboxMaxWidthPct(t.data.position.x)}%`,
|
||||
maxWidth: `${TEXTBOX_MAX_WIDTH_PCT}%`,
|
||||
fontSize: toPx(t.data.fontSize, size.width),
|
||||
lineHeight: 1.5,
|
||||
color: t.data.color,
|
||||
|
|
@ -313,7 +302,6 @@ export default function TextboxStampLayer({
|
|||
bold={composer.bold}
|
||||
opacity={composer.opacity}
|
||||
align={composer.align}
|
||||
widthPct={composer.widthPct}
|
||||
enabled={enabled}
|
||||
initialText=""
|
||||
onSave={onCommitComposer}
|
||||
|
|
|
|||
|
|
@ -96,14 +96,11 @@ export type TextboxData = {
|
|||
bold?: boolean;
|
||||
/** ความทึบ 0–1 (optional) — กล่องที่สร้างหลังมีตัวปรับเก็บชัดเสมอ แถวเดิม = 1 */
|
||||
opacity?: number;
|
||||
/** จัดแนวข้อความในกล่อง (optional) — ไม่มี = left · กล่องไม่ขยับ ขยับแค่ข้อความข้างใน (แบบ Smallpdf) */
|
||||
/** จัดแนวในกล่อง (optional) — ไม่มี = left */
|
||||
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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
hitTestPath,
|
||||
hitTestShape,
|
||||
scaleAnnotationData,
|
||||
textboxMaxWidthPct,
|
||||
translateAnnotationData,
|
||||
} from "@/utils/geometry";
|
||||
|
||||
|
|
@ -133,100 +132,6 @@ 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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -20,18 +20,9 @@ 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;
|
||||
|
|
@ -90,25 +81,24 @@ export function annotationBBox(a: AnnotationLike, size: Size): Rect {
|
|||
return rectToPx(d.rect, size);
|
||||
}
|
||||
if ("fontSize" in d) {
|
||||
// TEXTBOX — position เป็นมุมซ้ายบนของกล่องเสมอ (align จัดข้อความในกล่องอย่างเดียว);
|
||||
// กล่องมี width คงที่ (ถ้ามี — สไตล์ Smallpdf) ไม่มี = shrink-to-fit ประมาณจากตัวอักษร
|
||||
// TEXTBOX — ประมาณจากข้อความ (position เป็นมุมซ้ายบนของกล่อง)
|
||||
const fontSizePx = toPx(d.fontSize, size.width);
|
||||
const charW = fontSizePx * TEXTBOX_CHAR_W;
|
||||
const lineH = fontSizePx * TEXTBOX_LINE_H;
|
||||
// ความกว้างสูงสุดถึงขอบขวาหน้า — กล่อง width คงที่ก็ถูกบีบด้วยค่านี้เหมือน render
|
||||
const capPct = textboxMaxWidthPct(d.position.x);
|
||||
const wrapPx = toPx(Math.min(d.width ?? capPct, capPct), size.width);
|
||||
const wrapW = Math.max(wrapPx, charW); // กันหารศูนย์เมื่อกล่องติดขอบขวาเป๊ะ
|
||||
const maxW = toPx(TEXTBOX_MAX_WIDTH_PCT, size.width);
|
||||
let widest = 0;
|
||||
let lines = 0;
|
||||
for (const raw of d.text.split(/\r?\n/)) {
|
||||
widest = Math.max(widest, raw.length * charW);
|
||||
lines += Math.max(1, Math.ceil((raw.length * charW) / wrapW));
|
||||
lines += Math.max(1, Math.ceil((raw.length * charW) / maxW));
|
||||
}
|
||||
// กล่อง width คงที่กว้างตามที่กำหนด (บีบด้วย cap) — แถวเดิม shrink-to-fit ตามบรรทัดยาวสุด
|
||||
const width = (d.width !== undefined ? wrapPx : Math.min(widest, wrapPx)) || charW;
|
||||
const p = pointToPx(d.position, size);
|
||||
return { x: p.x, y: p.y, width, height: Math.max(lines, 1) * lineH };
|
||||
return {
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
width: Math.min(widest, maxW) || charW,
|
||||
height: Math.max(lines, 1) * lineH,
|
||||
};
|
||||
}
|
||||
if ("scale" in d) {
|
||||
// STAMP — กรอบจริงตามสูตร render (position เป็นมุมซ้ายบน)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue