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
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue