TEXTBOX กล่องกว้างคงที่สไตล์ Smallpdf: คลิกเดียวได้กล่อง 30% + ลากขอบซ้าย/ขวาปรับความกว้าง (ฟอนต์คงขนาด ข้อความ wrap ใหม่)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
9e6158ed45
commit
ca74fb1843
10 changed files with 248 additions and 41 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)
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -573,6 +578,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 +881,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
|
||||||
}
|
}
|
||||||
|
|
@ -2112,6 +2120,15 @@ export default function PDFViewer({
|
||||||
pageEl: HTMLElement,
|
pageEl: HTMLElement,
|
||||||
) {
|
) {
|
||||||
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 +2167,6 @@ export default function PDFViewer({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// คลิกพื้นที่ว่างของหน้า (tool select/draw) → เลิกเลือก annotation
|
|
||||||
if (selectedId !== null) {
|
|
||||||
setSelectedId(null);
|
|
||||||
setPreviewData(null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleComposerSave() {
|
async function handleComposerSave() {
|
||||||
|
|
@ -2253,6 +2265,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 +2314,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 ก่อนเอกสารโหลด)
|
||||||
|
|
@ -2801,7 +2817,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="จัดแนว">
|
||||||
|
|
|
||||||
|
|
@ -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