TEXTBOX กล่องกว้างคงที่สไตล์ Smallpdf: คลิกเดียวได้กล่อง 30% + ลากขอบซ้าย/ขวาปรับความกว้าง (ฟอนต์คงขนาด ข้อความ wrap ใหม่)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 2026-09-11 17:41:22 +07:00
parent 9e6158ed45
commit ca74fb1843
10 changed files with 248 additions and 41 deletions

View file

@ -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;
}

View file

@ -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,