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)
|
||||
|
||||
- โครงสร้างตาราง/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 ใช้ร่วม)
|
||||
- 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)
|
||||
|
|
|
|||
|
|
@ -309,6 +309,13 @@ 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,
|
||||
|
|
@ -318,6 +325,7 @@ 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, TEXTBOX_MAX_WIDTH_PCT } from "@/utils/geometry";
|
||||
import { STAMP_HEIGHT_RATIO, textboxMaxWidthPct } from "@/utils/geometry";
|
||||
import { STAMP_COLOR, effectiveDrawOpacity, stampFitFontSize } from "@/utils/annotation-display";
|
||||
|
||||
// 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);
|
||||
if (thaiFont) {
|
||||
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
|
||||
const runs: StyledRun[] = d.segments?.length
|
||||
? 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) =>
|
||||
fontFor(bold).widthOfTextAtSize(s, fontSize);
|
||||
const lines = wrapStyledText(runs, maxWidth, widthOf);
|
||||
// จัดแนวในกล่องกว้าง maxWidth — เลื่อน x ต่อบรรทัดตามความกว้างจริงของบรรทัดนั้น
|
||||
// กล่องห้อยจากมุมซ้ายบน (pos) เหมือน render จอ — align จัดบรรทัด*ใน*กล่องอย่างเดียว
|
||||
// (แบบ Smallpdf กล่องไม่ขยับ): กล่อง width คงที่กว้างเต็ม maxWidth ตามที่กำหนด
|
||||
// ส่วนแถวเดิม shrink-to-fit กว้างเท่าบรรทัดยาวสุด (ไม่เกิน maxWidth)
|
||||
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 = lineRuns.reduce((sum, rn) => sum + widthOf(rn.text, rn.bold), 0);
|
||||
const lineW = lineWOf(lineRuns);
|
||||
let x =
|
||||
align === "center"
|
||||
? pos.x + (maxWidth - lineW) / 2
|
||||
? boxLeft + (boxW - lineW) / 2
|
||||
: align === "right"
|
||||
? pos.x + (maxWidth - lineW)
|
||||
: pos.x;
|
||||
? boxLeft + (boxW - lineW)
|
||||
: boxLeft;
|
||||
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, Bold, Trash2 } from "lucide-react";
|
||||
import { AlignCenter, AlignLeft, AlignRight, BookmarkPlus, Trash2 } from "lucide-react";
|
||||
|
||||
import type {
|
||||
AnnotationData,
|
||||
|
|
@ -16,6 +16,7 @@ import type {
|
|||
TextboxData,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
TEXTBOX_MAX_WIDTH_PCT,
|
||||
annotationBBox,
|
||||
scaleAnnotationData,
|
||||
translateAnnotationData,
|
||||
|
|
@ -37,7 +38,8 @@ const MIN_BOX_PCT = 0.5; // bbox เล็กสุด (% ของหน้า)
|
|||
const EPS = 1e-6;
|
||||
|
||||
type DragState = {
|
||||
mode: "move" | "scale";
|
||||
// wleft/wright = TEXTBOX กว้างคงที่: ลากขอบซ้าย/ขวาปรับความกว้าง (ฟอนต์คงขนาด)
|
||||
mode: "move" | "scale" | "wleft" | "wright";
|
||||
/** ตำแหน่ง pointer ตอนเริ่มลาก (% ของหน้า) */
|
||||
startPct: Point;
|
||||
/** data ก่อนเริ่มลาก — ต้นทางของการคำนวณทุก move (ไม่สะสมจาก preview) */
|
||||
|
|
@ -118,7 +120,7 @@ export default function AnnotationSelectionLayer({
|
|||
|
||||
function beginDrag(
|
||||
e: ReactPointerEvent<HTMLElement>,
|
||||
mode: "move" | "scale",
|
||||
mode: "move" | "scale" | "wleft" | "wright",
|
||||
anchorPct: Point,
|
||||
) {
|
||||
if (!editable || e.button !== 0) return;
|
||||
|
|
@ -155,6 +157,36 @@ 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 สองแกน
|
||||
|
|
@ -288,6 +320,9 @@ 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)
|
||||
|
|
@ -393,7 +428,7 @@ export default function AnnotationSelectionLayer({
|
|||
onPointerCancel={cancelDrag}
|
||||
/>
|
||||
<div
|
||||
className="absolute cursor-move"
|
||||
className={`absolute ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
|
||||
style={{
|
||||
left: boxPx.x - STRIP_PX / 2,
|
||||
top: boxPx.y,
|
||||
|
|
@ -401,13 +436,15 @@ export default function AnnotationSelectionLayer({
|
|||
height: boxPx.height,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })}
|
||||
onPointerDown={(e) =>
|
||||
beginDrag(e, isFixedTextbox ? "wleft" : "move", { x: 0, y: 0 })
|
||||
}
|
||||
onPointerMove={moveDrag}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={cancelDrag}
|
||||
/>
|
||||
<div
|
||||
className="absolute cursor-move"
|
||||
className={`absolute ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
|
||||
style={{
|
||||
left: boxPx.x + boxPx.width - STRIP_PX / 2,
|
||||
top: boxPx.y,
|
||||
|
|
@ -415,14 +452,17 @@ export default function AnnotationSelectionLayer({
|
|||
height: boxPx.height,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })}
|
||||
onPointerDown={(e) =>
|
||||
beginDrag(e, isFixedTextbox ? "wright" : "move", { x: 0, y: 0 })
|
||||
}
|
||||
onPointerMove={moveDrag}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={cancelDrag}
|
||||
/>
|
||||
|
||||
{/* มุมทั้ง 4 — ลาก = scale รอบ anchor มุมตรงข้าม */}
|
||||
{corners.map((c) => (
|
||||
{/* มุมทั้ง 4 — ลาก = scale รอบ anchor มุมตรงข้าม (TEXTBOX กว้างคงที่ไม่มีมุม —
|
||||
ปรับความกว้างที่ขอบซ้าย/ขวาแทน ส่วนสูงตามจำนวนบรรทัดอัตโนมัติ) */}
|
||||
{!isFixedTextbox && corners.map((c) => (
|
||||
<div
|
||||
key={c.key}
|
||||
className="absolute rounded-sm border border-primary bg-primary/50"
|
||||
|
|
@ -578,7 +618,10 @@ export default function AnnotationSelectionLayer({
|
|||
}`}
|
||||
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>
|
||||
</PanelRow>
|
||||
<PanelRow label="จัดแนว">
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import {
|
|||
AlignLeft,
|
||||
AlignRight,
|
||||
ArrowRight,
|
||||
Bold,
|
||||
BookmarkPlus,
|
||||
Brush,
|
||||
Check,
|
||||
|
|
@ -106,7 +105,13 @@ import {
|
|||
type Size,
|
||||
} from "@/utils/coordinates";
|
||||
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 TextboxStampLayer, { type InkStamp, type InkTextbox } from "./TextboxStampLayer";
|
||||
import AnnotationSelectionLayer from "./AnnotationSelectionLayer";
|
||||
|
|
@ -573,6 +578,8 @@ 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;
|
||||
|
|
@ -874,6 +881,7 @@ function PdfPage({
|
|||
bold: textboxStyle.bold,
|
||||
opacity: textboxStyle.opacity,
|
||||
align: textboxStyle.align,
|
||||
widthPct: textboxStyle.widthPct,
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
|
@ -2112,6 +2120,15 @@ export default function PDFViewer({
|
|||
pageEl: HTMLElement,
|
||||
) {
|
||||
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 });
|
||||
|
|
@ -2150,11 +2167,6 @@ export default function PDFViewer({
|
|||
}
|
||||
}
|
||||
}
|
||||
// คลิกพื้นที่ว่างของหน้า (tool select/draw) → เลิกเลือก annotation
|
||||
if (selectedId !== null) {
|
||||
setSelectedId(null);
|
||||
setPreviewData(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleComposerSave() {
|
||||
|
|
@ -2253,6 +2265,9 @@ 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 } : {}),
|
||||
});
|
||||
}
|
||||
|
|
@ -2299,6 +2314,7 @@ 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 ก่อนเอกสารโหลด)
|
||||
|
|
@ -2801,7 +2817,10 @@ export default function PDFViewer({
|
|||
}`}
|
||||
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>
|
||||
</PanelRow>
|
||||
<PanelRow label="จัดแนว">
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef } from "react";
|
|||
|
||||
import type { StampData, TextboxData, TextboxSegment } from "@/db/schema";
|
||||
import { toPx, type Point, type Size } from "@/utils/coordinates";
|
||||
import { STAMP_HEIGHT_RATIO, TEXTBOX_MAX_WIDTH_PCT } from "@/utils/geometry";
|
||||
import { STAMP_HEIGHT_RATIO, textboxMaxWidthPct } from "@/utils/geometry";
|
||||
import { STAMP_COLOR, stampFitFontSize } from "@/utils/annotation-display";
|
||||
|
||||
// TEXTBOX + STAMP overlay (F17/B2, F18/B5) — HTML overlay (z-4 กลุ่มเดียวกับ comment pins)
|
||||
|
|
@ -83,6 +83,7 @@ function TextboxEditor({
|
|||
bold,
|
||||
opacity,
|
||||
align,
|
||||
widthPct,
|
||||
enabled,
|
||||
initialText,
|
||||
initialSegments,
|
||||
|
|
@ -97,6 +98,8 @@ function TextboxEditor({
|
|||
bold?: boolean;
|
||||
opacity?: number;
|
||||
align?: "left" | "center" | "right";
|
||||
/** ความกว้างกล่องคงที่ % (กล่องใหม่/กล่องที่มี width) — ไม่มี = shrink-to-fit (แถวเดิม) */
|
||||
widthPct?: number;
|
||||
/** container ของ layer เป็น pointer-events:none — editor ต้องเปิด auto เองเมื่อใช้งานได้ */
|
||||
enabled: boolean;
|
||||
initialText: string;
|
||||
|
|
@ -140,9 +143,11 @@ function TextboxEditor({
|
|||
style={{
|
||||
left: toPx(position.x, size.width),
|
||||
top: toPx(position.y, size.height),
|
||||
// กล่องพอดีข้อความ (shrink-to-fit ของ absolute) — เริ่มเล็กตอนกดคลิกครั้งแรก
|
||||
// แล้วขยายตามที่พิมพ์ ตัดบรรทัดเมื่อยาวเกิน 50% ของความกว้างหน้าเหมือนที่ render จริง
|
||||
maxWidth: `${TEXTBOX_MAX_WIDTH_PCT}%`,
|
||||
// มี widthPct = กล่องกว้างคงที่ (สไตล์ Smallpdf — ข้อความ wrap ในกล่อง)
|
||||
// ไม่มี = shrink-to-fit หุ้มข้อความขยายตามที่พิมพ์ (แถวเดิม)
|
||||
// maxWidth บีบตามพื้นที่จริงถึงขอบขวา (ไม่ล้นกระดาษ)
|
||||
...(widthPct !== undefined ? { width: `${widthPct}%` } : null),
|
||||
maxWidth: `${textboxMaxWidthPct(position.x)}%`,
|
||||
minWidth: "1em",
|
||||
opacity: opacity ?? 1,
|
||||
pointerEvents: enabled ? "auto" : "none",
|
||||
|
|
@ -230,6 +235,8 @@ export default function TextboxStampLayer({
|
|||
bold?: boolean;
|
||||
opacity?: number;
|
||||
align?: "left" | "center" | "right";
|
||||
/** ความกว้างกล่อง default % (กล่องใหม่เป็นกล่องกว้างคงที่เสมอ) */
|
||||
widthPct?: number;
|
||||
} | null;
|
||||
onCommitComposer: (text: string, segments?: TextboxSegment[]) => void;
|
||||
onCancelComposer: () => void;
|
||||
|
|
@ -249,6 +256,7 @@ export default function TextboxStampLayer({
|
|||
bold={t.data.bold}
|
||||
opacity={t.data.opacity}
|
||||
align={t.data.align}
|
||||
widthPct={t.data.width}
|
||||
enabled={enabled}
|
||||
initialText={t.data.text}
|
||||
initialSegments={t.data.segments}
|
||||
|
|
@ -263,7 +271,10 @@ export default function TextboxStampLayer({
|
|||
style={{
|
||||
left: toPx(t.data.position.x, size.width),
|
||||
top: toPx(t.data.position.y, size.height),
|
||||
maxWidth: `${TEXTBOX_MAX_WIDTH_PCT}%`,
|
||||
// กล่อง width คงที่ (ถ้ามี) กว้างตามที่กำหนด — align จัดข้อความ*ใน*กล่องอย่างเดียว
|
||||
// (แบบ Smallpdf กล่องไม่ขยับ) · maxWidth บีบตามพื้นที่ถึงขอบขวาเหมือนเดิม
|
||||
...(t.data.width !== undefined ? { width: `${t.data.width}%` } : null),
|
||||
maxWidth: `${textboxMaxWidthPct(t.data.position.x)}%`,
|
||||
fontSize: toPx(t.data.fontSize, size.width),
|
||||
lineHeight: 1.5,
|
||||
color: t.data.color,
|
||||
|
|
@ -302,6 +313,7 @@ export default function TextboxStampLayer({
|
|||
bold={composer.bold}
|
||||
opacity={composer.opacity}
|
||||
align={composer.align}
|
||||
widthPct={composer.widthPct}
|
||||
enabled={enabled}
|
||||
initialText=""
|
||||
onSave={onCommitComposer}
|
||||
|
|
|
|||
|
|
@ -96,11 +96,14 @@ export type TextboxData = {
|
|||
bold?: boolean;
|
||||
/** ความทึบ 0–1 (optional) — กล่องที่สร้างหลังมีตัวปรับเก็บชัดเสมอ แถวเดิม = 1 */
|
||||
opacity?: number;
|
||||
/** จัดแนวในกล่อง (optional) — ไม่มี = left */
|
||||
/** จัดแนวข้อความในกล่อง (optional) — ไม่มี = left · กล่องไม่ขยับ ขยับแค่ข้อความข้างใน (แบบ Smallpdf) */
|
||||
align?: "left" | "center" | "right";
|
||||
/** ข้อความแบ่งช่วงตัวหนา/ปกติ (optional) — มีเฉพาะเมื่อมีบางส่วนเป็นตัวหนา;
|
||||
* field `text` ยังเก็บข้อความรวม (concat) เสมอเพื่อค้นหา F14/preview sidebar */
|
||||
segments?: TextboxSegment[];
|
||||
/** ความกว้างกล่องคงที่ % ของความกว้างหน้า (optional — สไตล์ Smallpdf/Acrobat: ลากขอบ
|
||||
* ปรับความกว้าง ข้อความ wrap ตาม) — ไม่มี = กล่อง shrink-to-fit หุ้มข้อความ (แถวเดิม) */
|
||||
width?: number;
|
||||
/** โน้ตคำอธิบายของรายการนี้ (optional) */
|
||||
note?: string;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ type StampData = { label: string; position: Point; scale: number };
|
|||
|
||||
- `strokeWidth` ของ SHAPE เป็น % ของความกว้างหน้าเหมือน DRAW; ทุกพิกัด (rect/from/to/position) เป็น % ของขนาดหน้า (invariant #2)
|
||||
- E1 (F7) เพิ่ม field **optional** `widths?: number[]` ใน `DrawData` (ความหนาต่อจุด, % ของ width — ไว้รองรับ pressure)
|
||||
- F17 (B2) ภายหลังเพิ่ม `bold?/opacity?/align?/segments?/width?` ใน `TextboxData` — `position` เป็น**มุมซ้ายบนของกล่องเสมอ** (`align` จัดข้อความ*ใน*กล่องอย่างเดียวแบบ Smallpdf กล่องไม่ขยับ): กล่องกว้างคงที่เมื่อมี `width` (คลิกเดียวได้กล่อง default 30% ลากขอบซ้าย/ขวาปรับความกว้าง ข้อความ wrap ตาม ฟอนต์คงขนาด) ไม่มี `width` = shrink-to-fit หุ้มข้อความ (แถวเดิม) — ความกว้างสูงสุดคำนวณด้วย `textboxMaxWidthPct` (cap 50% ของหน้าและบีบตามพื้นที่จริงถึงขอบขวา — กันข้อความล้นกระดาษ) ใช้สูตรเดียวกันทั้ง render จอ / bbox กรอบ selection / export PDF
|
||||
|
||||
### Column เพิ่มในตาราง `annotations` (F10)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
hitTestPath,
|
||||
hitTestShape,
|
||||
scaleAnnotationData,
|
||||
textboxMaxWidthPct,
|
||||
translateAnnotationData,
|
||||
} from "@/utils/geometry";
|
||||
|
||||
|
|
@ -132,6 +133,100 @@ describe("annotationBBox", () => {
|
|||
expect(bbox.height).toBeCloseTo(6); // 2 บรรทัด × 3
|
||||
});
|
||||
|
||||
it("TEXTBOX: กล่องใกล้ขอบขวา → bbox ถูก clamp ไม่ยื่นเกินขอบหน้า (เหมือนที่ render บีบ)", () => {
|
||||
const bbox = annotationBBox(
|
||||
{
|
||||
type: "TEXTBOX",
|
||||
data: { position: { x: 95, y: 50 }, text: "x".repeat(60), color: "#000", fontSize: 2 },
|
||||
},
|
||||
S,
|
||||
);
|
||||
expect(bbox.x).toBe(95);
|
||||
expect(bbox.width).toBe(5); // ค่าประมาณกว้าง 50 แต่เหลือพื้นที่แค่ 100 − 95 → clamp ที่ 5
|
||||
});
|
||||
|
||||
it("TEXTBOX: align ไม่ขยับกล่อง — position เป็นมุมซ้ายบนเสมอ (ขยับแค่ข้อความข้างใน)", () => {
|
||||
for (const align of ["left", "center", "right"] as const) {
|
||||
const bbox = annotationBBox(
|
||||
{
|
||||
type: "TEXTBOX",
|
||||
data: {
|
||||
position: { x: 30, y: 20 },
|
||||
text: "x".repeat(60),
|
||||
color: "#000",
|
||||
fontSize: 2,
|
||||
align,
|
||||
},
|
||||
},
|
||||
S,
|
||||
);
|
||||
expect(bbox.x).toBe(30); // ขอบซ้ายกล่อง = position.x ทุก align
|
||||
expect(bbox.width).toBe(50); // cap เดิม min(50, 100−30=70)
|
||||
}
|
||||
});
|
||||
|
||||
it("textboxMaxWidthPct: ความกว้างสูงสุด = พื้นที่จริงถึงขอบขวา (cap 50%)", () => {
|
||||
expect(textboxMaxWidthPct(30)).toBe(50); // กลางหน้า → เต็ม cap
|
||||
expect(textboxMaxWidthPct(95)).toBe(5); // ใกล้ขอบขวา → เหลือพื้นที่จริง
|
||||
expect(textboxMaxWidthPct(100)).toBe(1); // ติดขอบเป๊ะ → ขั้นต่ำ 1%
|
||||
});
|
||||
|
||||
it("TEXTBOX: กล่อง width คงที่ — กว้างตามที่กำหนดแม้ข้อความสั้น (สไตล์ Smallpdf)", () => {
|
||||
const bbox = annotationBBox(
|
||||
{
|
||||
type: "TEXTBOX",
|
||||
data: {
|
||||
position: { x: 20, y: 20 },
|
||||
text: "x".repeat(10), // กว้างแค่ 12 < 30 → กล่องยังกว้าง 30
|
||||
color: "#000",
|
||||
fontSize: 2,
|
||||
width: 30,
|
||||
},
|
||||
},
|
||||
S,
|
||||
);
|
||||
expect(bbox.x).toBe(20);
|
||||
expect(bbox.width).toBe(30);
|
||||
expect(bbox.height).toBeCloseTo(3); // ยัง 1 บรรทัด
|
||||
});
|
||||
|
||||
it("TEXTBOX: width ใหญ่กว่าพื้นที่ถึงขอบขวา → บีบเหลือพื้นที่จริง", () => {
|
||||
const bbox = annotationBBox(
|
||||
{
|
||||
type: "TEXTBOX",
|
||||
data: {
|
||||
position: { x: 80, y: 20 },
|
||||
text: "x".repeat(10),
|
||||
color: "#000",
|
||||
fontSize: 2,
|
||||
width: 40,
|
||||
},
|
||||
},
|
||||
S,
|
||||
);
|
||||
expect(bbox.x).toBe(80);
|
||||
expect(bbox.width).toBe(20); // min(40, 100−80)
|
||||
});
|
||||
|
||||
it("TEXTBOX: width คงที่ + align ใดก็ตาม → กล่องอยู่ที่มุมซ้ายบนเสมอ", () => {
|
||||
const bbox = annotationBBox(
|
||||
{
|
||||
type: "TEXTBOX",
|
||||
data: {
|
||||
position: { x: 50, y: 20 },
|
||||
text: "x".repeat(10),
|
||||
color: "#000",
|
||||
fontSize: 2,
|
||||
align: "right",
|
||||
width: 30,
|
||||
},
|
||||
},
|
||||
S,
|
||||
);
|
||||
expect(bbox.x).toBe(50);
|
||||
expect(bbox.width).toBe(30);
|
||||
});
|
||||
|
||||
it("TEXTBOX: ข้อความว่าง → กว้าง fallback เท่าตัวอักษรเดียว สูง 1 บรรทัด", () => {
|
||||
const bbox = annotationBBox(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -20,9 +20,18 @@ export const COMMENT_PIN_BOX_PCT = 4;
|
|||
// render จริงกว้างสุด 50% ของหน้าแล้ว wrap อัตโนมัติ — bbox ประมาณจากตัวอักษร
|
||||
// กว้าง ~0.6 × fontSize ต่อตัว บรรทัดสูง 1.5 × fontSize (ค่าประมาณพอสำหรับกรอบ selection)
|
||||
export const TEXTBOX_MAX_WIDTH_PCT = 50;
|
||||
export const TEXTBOX_DEFAULT_WIDTH_PCT = 30; // กล่องที่สร้างใหม่ (คลิกเดียว) กว้างเท่านี้ก่อนลากขยาย
|
||||
export const TEXTBOX_CHAR_W = 0.6;
|
||||
export const TEXTBOX_LINE_H = 1.5;
|
||||
|
||||
/** ความกว้างสูงสุดของกล่องข้อความ (% ของความกว้างหน้า) จากขอบซ้าย (position.x = มุมซ้ายบน
|
||||
* เสมอ — align จัดข้อความ*ใน*กล่องอย่างเดียวแบบ Smallpdf กล่องไม่ขยับ) — บีบให้กล่อง
|
||||
* ไม่ล้นขอบขวาของหน้ากระดาษ ใช้ร่วมกันทั้ง render จอ, bbox กรอบ selection และ export PDF
|
||||
* ติดขอบขวาเป๊ะ (x=100) เหลือกว้างขั้นต่ำ 1% กันกล่องพังเป็นศูนย์ */
|
||||
export function textboxMaxWidthPct(leftX: number): number {
|
||||
return Math.min(TEXTBOX_MAX_WIDTH_PCT, Math.max(100 - clamp(leftX, 0, 100), 1));
|
||||
}
|
||||
|
||||
// STAMP (F18/B5 — §5): กรอบตรายางกว้าง = scale% ของความกว้างหน้า สูง = 0.4 เท่าของความกว้าง
|
||||
// (สูตรเดียวกันทั้ง render บนจอ, bbox ของ selection และการวาดใน export)
|
||||
export const STAMP_HEIGHT_RATIO = 0.4;
|
||||
|
|
@ -81,24 +90,25 @@ export function annotationBBox(a: AnnotationLike, size: Size): Rect {
|
|||
return rectToPx(d.rect, size);
|
||||
}
|
||||
if ("fontSize" in d) {
|
||||
// TEXTBOX — ประมาณจากข้อความ (position เป็นมุมซ้ายบนของกล่อง)
|
||||
// TEXTBOX — position เป็นมุมซ้ายบนของกล่องเสมอ (align จัดข้อความในกล่องอย่างเดียว);
|
||||
// กล่องมี width คงที่ (ถ้ามี — สไตล์ Smallpdf) ไม่มี = shrink-to-fit ประมาณจากตัวอักษร
|
||||
const fontSizePx = toPx(d.fontSize, size.width);
|
||||
const charW = fontSizePx * TEXTBOX_CHAR_W;
|
||||
const lineH = fontSizePx * TEXTBOX_LINE_H;
|
||||
const maxW = toPx(TEXTBOX_MAX_WIDTH_PCT, size.width);
|
||||
// ความกว้างสูงสุดถึงขอบขวาหน้า — กล่อง width คงที่ก็ถูกบีบด้วยค่านี้เหมือน render
|
||||
const capPct = textboxMaxWidthPct(d.position.x);
|
||||
const wrapPx = toPx(Math.min(d.width ?? capPct, capPct), size.width);
|
||||
const wrapW = Math.max(wrapPx, charW); // กันหารศูนย์เมื่อกล่องติดขอบขวาเป๊ะ
|
||||
let widest = 0;
|
||||
let lines = 0;
|
||||
for (const raw of d.text.split(/\r?\n/)) {
|
||||
widest = Math.max(widest, raw.length * charW);
|
||||
lines += Math.max(1, Math.ceil((raw.length * charW) / maxW));
|
||||
lines += Math.max(1, Math.ceil((raw.length * charW) / wrapW));
|
||||
}
|
||||
// กล่อง width คงที่กว้างตามที่กำหนด (บีบด้วย cap) — แถวเดิม shrink-to-fit ตามบรรทัดยาวสุด
|
||||
const width = (d.width !== undefined ? wrapPx : Math.min(widest, wrapPx)) || charW;
|
||||
const p = pointToPx(d.position, size);
|
||||
return {
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
width: Math.min(widest, maxW) || charW,
|
||||
height: Math.max(lines, 1) * lineH,
|
||||
};
|
||||
return { x: p.x, y: p.y, width, height: Math.max(lines, 1) * lineH };
|
||||
}
|
||||
if ("scale" in d) {
|
||||
// STAMP — กรอบจริงตามสูตร render (position เป็นมุมซ้ายบน)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue