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

@ -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="จัดแนว">