From ca74fb1843942f031b19ccedc9dcb2faf1adfd45 Mon Sep 17 00:00:00 2001 From: "DESKTOP-1R2VSQH\\Lenovo ThinkPad E490" Date: Fri, 11 Sep 2026 17:41:22 +0700 Subject: [PATCH 01/76] =?UTF-8?q?TEXTBOX=20=E0=B8=81=E0=B8=A5=E0=B9=88?= =?UTF-8?q?=E0=B8=AD=E0=B8=87=E0=B8=81=E0=B8=A7=E0=B9=89=E0=B8=B2=E0=B8=87?= =?UTF-8?q?=E0=B8=84=E0=B8=87=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B8=AA=E0=B9=84?= =?UTF-8?q?=E0=B8=95=E0=B8=A5=E0=B9=8C=20Smallpdf:=20=E0=B8=84=E0=B8=A5?= =?UTF-8?q?=E0=B8=B4=E0=B8=81=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7?= =?UTF-8?q?=E0=B9=84=E0=B8=94=E0=B9=89=E0=B8=81=E0=B8=A5=E0=B9=88=E0=B8=AD?= =?UTF-8?q?=E0=B8=87=2030%=20+=20=E0=B8=A5=E0=B8=B2=E0=B8=81=E0=B8=82?= =?UTF-8?q?=E0=B8=AD=E0=B8=9A=E0=B8=8B=E0=B9=89=E0=B8=B2=E0=B8=A2/?= =?UTF-8?q?=E0=B8=82=E0=B8=A7=E0=B8=B2=E0=B8=9B=E0=B8=A3=E0=B8=B1=E0=B8=9A?= =?UTF-8?q?=E0=B8=84=E0=B8=A7=E0=B8=B2=E0=B8=A1=E0=B8=81=E0=B8=A7=E0=B9=89?= =?UTF-8?q?=E0=B8=B2=E0=B8=87=20(=E0=B8=9F=E0=B8=AD=E0=B8=99=E0=B8=95?= =?UTF-8?q?=E0=B9=8C=E0=B8=84=E0=B8=87=E0=B8=82=E0=B8=99=E0=B8=B2=E0=B8=94?= =?UTF-8?q?=20=E0=B8=82=E0=B9=89=E0=B8=AD=E0=B8=84=E0=B8=A7=E0=B8=B2?= =?UTF-8?q?=E0=B8=A1=20wrap=20=E0=B9=83=E0=B8=AB=E0=B8=A1=E0=B9=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- .claude/rules/data-layer.md | 2 +- app/actions/annotations.ts | 8 ++ app/api/annotations/export/route.ts | 30 +++++-- components/pdf/AnnotationSelectionLayer.tsx | 63 +++++++++++--- components/pdf/PDFViewer.tsx | 35 ++++++-- components/pdf/TextboxStampLayer.tsx | 22 +++-- db/schema.ts | 5 +- docs/PLAN-FEAT.md | 1 + tests/utils/geometry.test.ts | 95 +++++++++++++++++++++ utils/geometry.ts | 28 ++++-- 10 files changed, 248 insertions(+), 41 deletions(-) diff --git a/.claude/rules/data-layer.md b/.claude/rules/data-layer.md index 40e6e04..aefeff4 100644 --- a/.claude/rules/data-layer.md +++ b/.claude/rules/data-layer.md @@ -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()` (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) diff --git a/app/actions/annotations.ts b/app/actions/annotations.ts index 267405b..78da8ea 100644 --- a/app/actions/annotations.ts +++ b/app/actions/annotations.ts @@ -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; } diff --git a/app/api/annotations/export/route.ts b/app/api/annotations/export/route.ts index 47464d8..df98efc 100644 --- a/app/api/annotations/export/route.ts +++ b/app/api/annotations/export/route.ts @@ -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, diff --git a/components/pdf/AnnotationSelectionLayer.tsx b/components/pdf/AnnotationSelectionLayer.tsx index 455a1b6..f9af5ba 100644 --- a/components/pdf/AnnotationSelectionLayer.tsx +++ b/components/pdf/AnnotationSelectionLayer.tsx @@ -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, - 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} />
beginDrag(e, "move", { x: 0, y: 0 })} + onPointerDown={(e) => + beginDrag(e, isFixedTextbox ? "wleft" : "move", { x: 0, y: 0 }) + } onPointerMove={moveDrag} onPointerUp={endDrag} onPointerCancel={cancelDrag} />
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) => (
commitTextboxPatch({ bold: !textboxData.bold })} > - + {/* B ทึบ — เหมือน dropdown ของ tool (ตัวควบคุมชุดเดียวกัน) */} + + B + diff --git a/components/pdf/PDFViewer.tsx b/components/pdf/PDFViewer.tsx index 263a7e4..a05a395 100644 --- a/components/pdf/PDFViewer.tsx +++ b/components/pdf/PDFViewer.tsx @@ -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)} > - + {/* B ทึบ — แทน icon Bold แบบเส้นขอบ (lucide ไม่มีรูปทึบ) */} + + B + diff --git a/components/pdf/TextboxStampLayer.tsx b/components/pdf/TextboxStampLayer.tsx index c118433..4634326 100644 --- a/components/pdf/TextboxStampLayer.tsx +++ b/components/pdf/TextboxStampLayer.tsx @@ -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} diff --git a/db/schema.ts b/db/schema.ts index 3e9fed7..89245b0 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -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; }; diff --git a/docs/PLAN-FEAT.md b/docs/PLAN-FEAT.md index 6455c07..cfbe34d 100644 --- a/docs/PLAN-FEAT.md +++ b/docs/PLAN-FEAT.md @@ -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) diff --git a/tests/utils/geometry.test.ts b/tests/utils/geometry.test.ts index 74416fd..bb2b6f9 100644 --- a/tests/utils/geometry.test.ts +++ b/tests/utils/geometry.test.ts @@ -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( { diff --git a/utils/geometry.ts b/utils/geometry.ts index 56db47a..23129c0 100644 --- a/utils/geometry.ts +++ b/utils/geometry.ts @@ -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 เป็นมุมซ้ายบน) From 9e5083dae1a258d80c3ca3c353b8e4dd95250380 Mon Sep 17 00:00:00 2001 From: "DESKTOP-1R2VSQH\\Lenovo ThinkPad E490" Date: Fri, 11 Sep 2026 17:41:58 +0700 Subject: [PATCH 02/76] =?UTF-8?q?=E0=B8=84=E0=B8=A5=E0=B8=B8=E0=B8=A1?= =?UTF-8?q?=E0=B8=82=E0=B9=89=E0=B8=AD=E0=B8=84=E0=B8=A7=E0=B8=B2=E0=B8=A1?= =?UTF-8?q?=E0=B9=84=E0=B8=94=E0=B9=89=E0=B8=97=E0=B8=B8=E0=B8=81=20tool?= =?UTF-8?q?=20=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B9=84=E0=B8=A1=E0=B9=88?= =?UTF-8?q?=E0=B9=84=E0=B8=94=E0=B9=89=E0=B8=A7=E0=B8=B2=E0=B8=94:=20comme?= =?UTF-8?q?nt/textbox/stamp=20=E0=B8=A5=E0=B8=B2=E0=B8=81=E0=B8=84?= =?UTF-8?q?=E0=B8=A5=E0=B8=B8=E0=B8=A1=E0=B9=84=E0=B8=94=E0=B9=89=20select?= =?UTF-8?q?ion=20=E0=B8=AA=E0=B8=B5=E0=B8=9F=E0=B9=89=E0=B8=B2=E0=B9=80?= =?UTF-8?q?=E0=B8=AB=E0=B8=A1=E0=B8=B7=E0=B8=AD=E0=B8=99=20tool=20?= =?UTF-8?q?=E0=B9=80=E0=B8=A5=E0=B8=B7=E0=B8=AD=E0=B8=81=E0=B8=82=E0=B9=89?= =?UTF-8?q?=E0=B8=AD=E0=B8=84=E0=B8=A7=E0=B8=B2=E0=B8=A1=20(=E0=B8=9B?= =?UTF-8?q?=E0=B8=A5=E0=B9=88=E0=B8=AD=E0=B8=A2=E0=B9=80=E0=B8=A1=E0=B8=B2?= =?UTF-8?q?=E0=B8=AA=E0=B9=8C=E0=B8=88=E0=B8=B2=E0=B8=81=E0=B8=81=E0=B8=B2?= =?UTF-8?q?=E0=B8=A3=E0=B8=A5=E0=B8=B2=E0=B8=81=E0=B9=84=E0=B8=A1=E0=B9=88?= =?UTF-8?q?=E0=B8=A7=E0=B8=B2=E0=B8=87=E0=B8=A3=E0=B8=B2=E0=B8=A2=E0=B8=81?= =?UTF-8?q?=E0=B8=B2=E0=B8=A3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- .claude/rules/pdf-viewer.md | 3 +++ components/pdf/PDFViewer.tsx | 36 +++++++++++++++++++++++++++--------- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/.claude/rules/pdf-viewer.md b/.claude/rules/pdf-viewer.md index 3467c15..6cc7d4b 100644 --- a/.claude/rules/pdf-viewer.md +++ b/.claude/rules/pdf-viewer.md @@ -23,6 +23,9 @@ paths: - DRAW วาดเส้นตรง: **ปล่อยเมาส์แล้ว snap อัตโนมัติ** เมื่อจุดทุกจุดห่างจากเส้นตรง จุดเริ่ม→จุดสุดท้าย ไม่เกิน max(5% ของความยาว, 3px) และเส้นยาว ≥ 24px (`isNearlyStraight`) — หรือกดค้าง **Shift** ระหว่างลาก (กด/ปล่อยกลางทางได้ — listener ระดับ window + reset ตอน blur) เพื่อบังคับตรงเสมอ → บันทึกเหลือจุดเริ่ม→จุดสุดท้าย 2 จุด (ความหนา uniform — ไม่เก็บ `widths` จาก pressure) — ใช้ได้เฉพาะโหมดวาดอิสระ ไม่ใช่ shape/eraser/redact; เส้นโค้ง/ขดจริงหลุดเกณฑ์จึงเหลือ freehand - Tool `pan` (มือเลื่อนหน้า — ปุ่มแยกถัดจาก select ในแถบ tool): ลากบน `
` = `window.scrollBy` สวนทางการลาก + cursor grab/grabbing; ระหว่างใช้ pan ทุก overlay ปิด pointer-events ผ่านตัวแปร `overlayFrozen` (`inkActive || pan`) **ยกเว้น InkLayer ต้องไม่ active** (ไม่งั้น pan กลายเป็นวาดเส้น) - HIGHLIGHT มี `style?` 4 แบบ: highlight ทึบเหมือนเดิม / underline = เส้นที่ขอบล่าง rect ต่อบรรทัด / strikethrough = เส้นกลางบรรทัด / squiggle = svg path ขยุกขยิก (`squigglePath`) ที่ขอบล่าง — export วาด line/sine แทน rect ทึบ; สร้างได้จาก **tool "highlight" ทางเดียว** (dropdown บนปุ่ม = รูปแบบ + สี → คลุมข้อความ = สร้างทันที พร้อม live preview ผ่าน `selectionchange` + ซ่อน native selection) — tool "เลือกข้อความ" คลุมแล้วเกิด popup ไม่ได้แล้ว (ถอดออก) และคีย์ลัด `H` สลับไป tool highlight +- **คลุมข้อความได้ 5 tool** (`TEXT_SELECTION_TOOLS` set ใน PDFViewer: select/highlight/comment/textbox/stamp — ที่เหลือ textLayer ปิด pointer-events คลุมไม่ได้): ทั้งหมด inject ` )} {/* UX review 2026-09-09: backdrop กันคลิกทะลุใต้ sidebar overlay (เฉพาะ < xl) @@ -3067,14 +3085,14 @@ export default function PDFViewer({ onMouseUp={() => handleMouseUpOnPage(i)} onClick={(e) => handlePageClick(e, i, e.currentTarget)} onMouseDown={(e) => { - // จุดเริ่มเส้นทางลากของ tool highlight/select (กรอง selection rects) - if (tool !== "highlight" && tool !== "select") return; + // จุดเริ่มเส้นทางลากของ tool คลุมข้อความ (กรอง selection rects) + if (!TEXT_SELECTION_TOOLS.has(tool)) return; selDragStartRef.current = { x: e.clientX, y: e.clientY }; selDragEndRef.current = { x: e.clientX, y: e.clientY }; }} onMouseMove={(e) => { // ตำแหน่งเมาส์ล่าสุดระหว่างลากค้างปุ่ม (buttons=1) - if ((tool === "highlight" || tool === "select") && e.buttons === 1) { + if (TEXT_SELECTION_TOOLS.has(tool) && e.buttons === 1) { selDragEndRef.current = { x: e.clientX, y: e.clientY }; } handlePageHoverMove(e, i); From b588343d502072d1cf7b5d4812267e4581a4f1c5 Mon Sep 17 00:00:00 2001 From: "DESKTOP-1R2VSQH\\Lenovo ThinkPad E490" Date: Fri, 11 Sep 2026 17:45:31 +0700 Subject: [PATCH 03/76] =?UTF-8?q?bookmark:=20pre-fill=20=E0=B8=8A=E0=B8=B7?= =?UTF-8?q?=E0=B9=88=E0=B8=AD=20default=20"=E0=B8=AB=E0=B8=99=E0=B9=89?= =?UTF-8?q?=E0=B8=B2=20N"=20+=20focus=20=E0=B9=80=E0=B8=A5=E0=B8=B7?= =?UTF-8?q?=E0=B8=AD=E0=B8=81=E0=B8=82=E0=B9=89=E0=B8=AD=E0=B8=84=E0=B8=A7?= =?UTF-8?q?=E0=B8=B2=E0=B8=A1=E0=B8=97=E0=B8=B1=E0=B9=89=E0=B8=87=E0=B8=AB?= =?UTF-8?q?=E0=B8=A1=E0=B8=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit เดิมเปิด popup แล้วช่องชื่อเว้นว่าง (fallback "หน้า N" แค่ตอนบันทึก) — ตอนนี้ pre-fill ทันทีทั้งจากปุ่ม 🔖/คีย์ลัด B (ไม่มีข้อความที่คลุม) และปุ่ม bookmark ในแผง selection (ชนิดที่ไม่มีข้อความ) พร้อม select ข้อความ default ไว้ ให้พิมพ์ทับ/Enter ยอมรับได้ทันที + sync spec N2 ใน PLAN-FEAT.md Co-Authored-By: Claude Code --- components/pdf/PDFViewer.tsx | 14 +++++++++++--- docs/PLAN-FEAT.md | 6 +++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/components/pdf/PDFViewer.tsx b/components/pdf/PDFViewer.tsx index eceeaf0..51b019d 100644 --- a/components/pdf/PDFViewer.tsx +++ b/components/pdf/PDFViewer.tsx @@ -1765,22 +1765,29 @@ export default function PDFViewer({ const pos = currentViewPosition(); if (!pos) return; setBookmarkError(null); - setBookmarkComposer({ ...pos, title: lastSelectionTextRef.current, note: "", parentId: null }); + // ชื่อ default = ข้อความที่เพิ่งคลุม ไม่มี = "หน้า N" — pre-fill ในช่องเลย (กด Enter ยอมรับได้ทันที) + setBookmarkComposer({ + ...pos, + title: lastSelectionTextRef.current || `หน้า ${pos.pageIndex + 1}`, + note: "", + parentId: null, + }); } /** ปุ่ม bookmark ในแผง selection (N2) — เปิด popup ที่ตำแหน่ง annotation นั้น - * ชื่อ default มาจากข้อความของรายการ (ชนิดที่มีข้อความจริง) ไม่งั้นเว้นว่าง = "หน้า N" */ + * ชื่อ default มาจากข้อความของรายการ (ชนิดที่มีข้อความจริง) ไม่งั้น = "หน้า N" */ function handleAnnotationBookmark(a: AnnotationDTO, yOffsetPct: number) { const meaningful = a.type === "HIGHLIGHT" || a.type === "TEXTBOX" || a.type === "STAMP" || a.type === "COMMENT"; + const text = meaningful ? normalizeBookmarkTitle(annotationPreviewText(a)) : ""; setBookmarkError(null); setBookmarkComposer({ pageIndex: a.pageIndex, yOffset: clamp(yOffsetPct, 0, 100), - title: meaningful ? normalizeBookmarkTitle(annotationPreviewText(a)) : "", + title: text || `หน้า ${a.pageIndex + 1}`, note: "", parentId: null, }); @@ -2942,6 +2949,7 @@ export default function PDFViewer({

e.target.select()} // เลือกข้อความ default ทั้งหมด — พิมพ์ทับ/Enter ยอมรับได้ทันที value={bookmarkComposer.title} onChange={(e) => setBookmarkComposer({ ...bookmarkComposer, title: e.target.value }) diff --git a/docs/PLAN-FEAT.md b/docs/PLAN-FEAT.md index cfbe34d..2fd2909 100644 --- a/docs/PLAN-FEAT.md +++ b/docs/PLAN-FEAT.md @@ -277,9 +277,9 @@ Dependencies เพิ่มเติม: F3 ← F2 · F12/F13/F19 ← F1 · F15 ### N2 · Bookmark แบบหมุดหัวข้อใหญ่ + sub-bookmark + โน้ต (โครงสร้าง 3 ระดับ) - **ข้อมูล:** ตาราง `bookmarks` เพิ่ม `parent_id uuid NULL` (NULL = หมุดหัวข้อใหญ่ระดับบนสุด / มีค่า = sub ของ bookmark นั้น — **ไม่ทำ FK** เพราะลบแม่ต้อง re-parent ลูกเอง) + `note text NULL` (โน้ตย่อยของ bookmark) — จำกัด **3 ระดับ** (`BOOKMARK_MAX_DEPTH = 3`): create ต้องมี `depth(parent) < 3`, ย้ายต้องผ่าน `depth(parent) + subtreeHeight(ตัวเอง) ≤ 3` + ห้ามย้ายเข้า subtree ของตัวเอง (cycle) — **ทุก action ที่แตะความสัมพันธ์แม่–ลูกตรวจภายใน transaction พร้อม `.for("update")` ทุกแถวที่เดินผ่าน** (บรรพบุรุษ/ลูกหลาน — helper `lockedDepth`/`lockedSubtreeHeight`/`isDescendant`) เพื่อ serialize กัน race เกินความลึก/sub กำพร้า -- **การสร้าง:** ปุ่ม 🔖 บน toolbar (และคีย์ลัด `B`) เปิด **popup ทุกครั้ง** — ชื่อ (เว้นว่าง = "หน้า N") + โน้ต (ไม่บังคับ) + select "วางที่" (ระดับบนสุด / ใต้หัวข้อระดับ 1–2 — ใครก็วางใต้หัวข้อของคนอื่นได้) — ปิดด้วย บันทึก/ยกเลิก/Enter(title)/Esc/คลิกหน้า · ทางลัด: ปุ่ม **+** ที่หัวข้อระดับ 1–2 ใน sidebar = bookmark **หน้าที่ viewer ดูอยู่** เข้าใต้หัวข้อนั้นทันที (ชื่อ auto "หน้า N") -- **ชื่อ default จากข้อความที่คลุม:** viewer จำข้อความที่เพิ่งคลุมบนหน้าไว้ตอน `mouseup` (ทุก tool — คลิกปุ่ม 🔖 ใน header ไม่ผ่าน mouseup ของหน้าค่าจึงไม่โดนเคลียร์) → เปิด popup แล้วชื่อ default = ข้อความนั้น (ยุบ whitespace, ตัดยาว ≤ 120 ตัวอักษรผ่าน `normalizeBookmarkTitle`) -- **ปุ่ม bookmark ในแผง selection (F1 toolbar):** ปุ่ม 🔖 ท้ายแผงเครื่องมือของ annotation ที่เลือก → เปิด popup ที่หน้า + yOffset ตามขอบบน bbox ของรายการนั้น โดยชื่อ default = ข้อความของรายการ (HIGHLIGHT/TEXTBOX/STAMP/COMMENT — ชนิดที่ไม่มีข้อความจริงเว้นว่าง) +- **การสร้าง:** ปุ่ม 🔖 บน toolbar (และคีย์ลัด `B`) เปิด **popup ทุกครั้ง** — ชื่อ (pre-fill default ไว้ในช่อง + focus เลือกข้อความทั้งหมด = พิมพ์ทับ/Enter ยอมรับได้ทันที; ล้างแล้วเว้นว่าง = "หน้า N" ตอนบันทึก) + โน้ต (ไม่บังคับ) + select "วางที่" (ระดับบนสุด / ใต้หัวข้อระดับ 1–2 — ใครก็วางใต้หัวข้อของคนอื่นได้) — ปิดด้วย บันทึก/ยกเลิก/Enter(title)/Esc/คลิกหน้า · ทางลัด: ปุ่ม **+** ที่หัวข้อระดับ 1–2 ใน sidebar = bookmark **หน้าที่ viewer ดูอยู่** เข้าใต้หัวข้อนั้นทันที (ชื่อ auto "หน้า N") +- **ชื่อ default จากข้อความที่คลุม:** viewer จำข้อความที่เพิ่งคลุมบนหน้าไว้ตอน `mouseup` (ทุก tool — คลิกปุ่ม 🔖 ใน header ไม่ผ่าน mouseup ของหน้าค่าจึงไม่โดนเคลียร์) → เปิด popup แล้วชื่อ default = ข้อความนั้น (ยุบ whitespace, ตัดยาว ≤ 120 ตัวอักษรผ่าน `normalizeBookmarkTitle`) ไม่มีข้อความที่คลุม = "หน้า N" +- **ปุ่ม bookmark ในแผง selection (F1 toolbar):** ปุ่ม 🔖 ท้ายแผงเครื่องมือของ annotation ที่เลือก → เปิด popup ที่หน้า + yOffset ตามขอบบน bbox ของรายการนั้น โดยชื่อ default = ข้อความของรายการ (HIGHLIGHT/TEXTBOX/STAMP/COMMENT — ชนิดที่ไม่มีข้อความจริงใช้ "หน้า N") - **แก้ไข (✏ เจ้าของเท่านั้น):** inline ใน sidebar — แก้ชื่อ + โน้ต + ย้ายหัวข้อ (select กรองเป้าหมายที่ย้ายได้จริงตามเกณฑ์ depth+height และตัดตัวเอง/ลูกหลานออก — server ตรวจซ้ำพร้อม lock) - **การลบ:** ลบ **sub** = เหมือนเดิม · ลบ **หัวข้อที่มี sub** = sub ทั้งหมด**ขยับขึ้นระดับบนสุด** (ไม่มีข้อมูลหาย) — re-parent + delete ใน transaction เดียว + publish update event ต่อลูก - **การแสดง (sidebar tab Bookmarks):** ต้นไม้ 3 ระดับ (indent ตามระดับ 1/2/3), หัวข้อมี chevron กาง/หุ่ม (state local ต่อ client) + จำนวน sub `(n)`, โน้ตแสดงใต้ชื่อ (clamp 2 บรรทัด), กดชื่อ = กระโดดหน้า (หัวข้อใหญ่ชี้หน้าของตัวเองได้), เรียงตาม createdAt From 1c2f4c834a448ecccc77ccb7dd2b867a36a138ac Mon Sep 17 00:00:00 2001 From: "DESKTOP-1R2VSQH\\Lenovo ThinkPad E490" Date: Fri, 11 Sep 2026 18:05:38 +0700 Subject: [PATCH 04/76] =?UTF-8?q?sidebar=20=E0=B8=88=E0=B8=AD=E0=B8=81?= =?UTF-8?q?=E0=B8=A5=E0=B8=B2=E0=B8=87=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99?= =?UTF-8?q?=E0=B8=84=E0=B8=AD=E0=B8=A5=E0=B8=B1=E0=B8=A1=E0=B8=99=E0=B9=8C?= =?UTF-8?q?=20+=20bookmark=20row=20hover=20=E0=B9=81=E0=B8=A2=E0=B8=81?= =?UTF-8?q?=E0=B9=81=E0=B8=96=E0=B8=A7=20+=20PDF=20fit=20=E0=B8=88?= =?UTF-8?q?=E0=B8=AD=E0=B9=80=E0=B8=A5=E0=B9=87=E0=B8=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - overlay sidebar ลด breakpoint จาก xl (1280) เป็น lg (1024): จอ 1024-1280 เปิดแผงข้างแล้ว content ขยับไปข้าง ๆ ไม่ลอยทับบัง PDF - overlay บนจอ < lg กว้างเป็นสัดส่วนของจอ (75vw, cap 288px) กันบังเกือบเต็มมือถือ - ลบการปิด sidebar อัตโนมัติตอน mount บนจอเล็ก (ต้องกดปิดเองเท่านั้น) - PDF fit ความกว้างอัตโนมัติครั้งเดียวตอนโหลดเมื่อพื้นที่แคบกว่าหน้ากระดาษ - รายการ bookmark: ย้ายพื้นหลัง hover จาก li (ครอบ
    ลูกทั้งก้อน ชี้ลูกแล้ว :hover ของแม่ทำงานด้วย ไฮไลต์ทั้ง subtree) ลง div แถวรายการ ให้ไฮไลต์เฉพาะแถว Co-Authored-By: Claude Code --- components/pdf/PDFViewer.tsx | 36 ++++++++++++++++-------- components/sidebar/AnnotationSidebar.tsx | 14 ++++----- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/components/pdf/PDFViewer.tsx b/components/pdf/PDFViewer.tsx index 51b019d..9ac4d47 100644 --- a/components/pdf/PDFViewer.tsx +++ b/components/pdf/PDFViewer.tsx @@ -1234,11 +1234,6 @@ export default function PDFViewer({ setUser(getLocalUser()); }, []); - // UX review 2026-09-09: จอแคบกว่า xl (< 1280px) sidebar เป็น overlay — ปิดอัตโนมัติตอน mount - useEffect(() => { - if (!window.matchMedia("(min-width: 1280px)").matches) setSidebarOpen(false); - }, []); - // โหลดเอกสาร: dynamic import ภายใน useEffect เท่านั้น (invariant #6) useEffect(() => { let cancelled = false; @@ -1374,6 +1369,21 @@ export default function PDFViewer({ } }, [pageSizes, annotations]); + // จอเล็ก: หน้ากระดาษ (scale 1) กว้างกว่าพื้นที่แสดงผล → ย่อให้พอดีความกว้าง + // ครั้งเดียวตอนโหลดเท่านั้น (ผู้ใช้ซูมเองหลังจากนี้แล้วไม่ถูกแทนที่) — + // อิงความกว้างหน้าแรกเหมือน basePageWidth และตัด padding px-4 ซ้าย-ขวาของ main ออก + const mainRef = useRef(null); + const fitWidthDoneRef = useRef(false); + useEffect(() => { + if (pageSizes.length === 0 || fitWidthDoneRef.current) return; + fitWidthDoneRef.current = true; + const avail = (mainRef.current?.clientWidth ?? 0) - 32; + const pageW = pageSizes[0].width; + if (avail > 0 && pageW > avail) { + setScale(clamp(Math.floor((avail / pageW) * 100) / 100, 0.5, 1)); + } + }, [pageSizes]); + useEffect(() => { setDraft(""); }, [composer]); @@ -2352,22 +2362,23 @@ export default function PDFViewer({ {TEXT_SELECTION_TOOLS.has(tool) && ( )} - {/* UX review 2026-09-09: backdrop กันคลิกทะลุใต้ sidebar overlay (เฉพาะ < xl) + {/* UX review 2026-09-09: backdrop กันคลิกทะลุใต้ sidebar overlay (เฉพาะ < lg) z-[45] อยู่เหนือ header sticky z-40 แต่ใต้ aside z-50 / composer z-50 */} {sidebarOpen && (
    setSidebarOpen(false)} /> )} - {/* xl+ = sticky แยกคอลัมน์เหมือนเดิม / < xl = overlay เลื่อนเข้า-ออก - (ปิดบน desktop ใช้ xl:hidden ออกจาก flow → main เต็มจอ) */} + {/* lg+ = sticky แยกคอลัมน์ (เปิดแล้ว content ขยับไปข้าง ๆ ไม่บังกัน) + / < lg = overlay เลื่อนเข้า-ออก กว้างเป็นสัดส่วนของจอกว้างกว่า 288px + (ปิดบน desktop ใช้ lg:hidden ออกจาก flow → main เต็มจอ) */} diff --git a/components/sidebar/AnnotationSidebar.tsx b/components/sidebar/AnnotationSidebar.tsx index 7738bae..551bc04 100644 --- a/components/sidebar/AnnotationSidebar.tsx +++ b/components/sidebar/AnnotationSidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useState, type FormEvent } from "react"; +import { useEffect, useRef, useState, type FormEvent } from "react"; import { createPortal } from "react-dom"; import { ArrowRight, @@ -132,6 +132,7 @@ export default function AnnotationSidebar({ onUpdateBookmark, onGoToBookmark, onGoToAnnotation, + selectedId, }: { annotations: AnnotationDTO[]; bookmarks: BookmarkDTO[]; @@ -157,9 +158,26 @@ export default function AnnotationSidebar({ ) => void; onGoToBookmark: (b: BookmarkDTO) => void; onGoToAnnotation: (a: AnnotationDTO) => void; + /** id ของ annotation ที่ถูกเลือกอยู่บนหน้า PDF (null = ไม่มี) — แถวในแผงนี้ไฮไลต์ตาม */ + selectedId: string | null; }) { const [tab, setTab] = useState<"bookmarks" | "annotations">("bookmarks"); + // คลิกเลือก annotation บนหน้าเอกสาร = สลับมาแท็บ annotations (ถ้ายังอยู่ bookmarks) + // แล้วเลื่อนแถวนั้นเข้ามุมมอง — แถว render หลัง setTab จึงค่อยเลื่อนใน rAF ถัดไป + // (dep รวม tab ตาม lint — ผลข้างเคียง: เปิดแท็บนี้ตอนยังมีรายการถูกเลือก = เลื่อนไปแถวนั้นเช่นกัน) + const listRef = useRef(null); + useEffect(() => { + if (!selectedId) return; + if (tab !== "annotations") setTab("annotations"); + const raf = requestAnimationFrame(() => { + listRef.current + ?.querySelector(`[data-ann-id="${CSS.escape(selectedId)}"]`) + ?.scrollIntoView({ block: "nearest" }); + }); + return () => cancelAnimationFrame(raf); + }, [selectedId, tab]); + // ─── F14/D1: ค้นหาคำในหมายเหตุทั้งไฟล์ (highlight text / comment message) ──── const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState(null); @@ -488,9 +506,12 @@ export default function AnnotationSidebar({ return (
  • -
    +
    {tab === "bookmarks" ? ( bookmarks.length === 0 ? (

    diff --git a/docs/PLAN.md b/docs/PLAN.md index 696d2c6..5517e5e 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -150,7 +150,7 @@ Client A วาด ──▶ Server Action createAnnotation() ──▶ INSERT P - **เขียน:** ผ่าน Server Actions เท่านั้น (ต่อ `DATABASE_URL` ตรง ๆ) — และ**หลังเขียนสำเร็จทุกครั้ง ต้อง publish event** ให้ `lib/annotate-events.ts` - **อ่าน/ sync:** `useAnnotationRealtime` hook เปิด `EventSource` ไปที่ SSE route `/api/annotations/stream?fileId=` — payload เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ ไม่ต้องแปลง; `EventSource` ต่อใหม่อัตโนมัติเมื่อ connection ขาด (ห้ามทำ retry เอง) - pub/sub เป็น **in-memory ของ Next.js process เดียว** (dev / `next start` ธรรมดา) — หากอนาคตรันหลาย instance ต้องเพิ่มตัวกลาง (Redis pub/sub หรือ Postgres `LISTEN/NOTIFY`) ก่อน -- **Optimistic update ด้วย `useOptimistic` (React 18):** ตอนผู้ใช้ปล่อยเมาส์ → `startTransition(() => { addOptimistic(row ชั่วคราว); return createAnnotation(...) })` → React ถอด optimistic row ออกอัตโนมัติเมื่อ action เสร็จ และ **rollback ให้เองหาก action ล้มเหลว** (ไม่ต้องจัดการ id `temp-*` เอง) → หาก SSE event มาถึงก่อน action เสร็จ จะเห็น duplicate ชั่วขณะแล้วหายเองเมื่อ optimistic row ถูกถอด (self-healing) — hook ยัง dedupe ด้วย id กับ state ปัจจุบันอยู่ +- **Optimistic update ด้วย `useOptimistic` (React 18):** ตอนผู้ใช้ปล่อยเมาส์ → `startTransition(() => { addOptimistic(row ชั่วคราว); return createAnnotation(...) })` → React ถอด optimistic row ออกอัตโนมัติเมื่อ action เสร็จ และ **rollback ให้เองหาก action ล้มเหลว** (ไม่ต้องจัดการ id `temp-*` เอง) — client ส่ง id ของ optimistic row ให้ `createAnnotation` ใช้ซ้ำ (action validate เป็น UUID) เพื่อให้ SSE event ที่มาถึงก่อน action เสร็จ dedupe ด้วย id เดียวกันได้ทันที **ไม่เห็น duplicate ชั่วขณะ** (เดิม row จริงใช้ id ใหม่ทำให้แสดง 2 แถวแวบหนึ่งก่อน row ชั่วคราวถูกถอด) --- From b3b884c736b435bd9e4ca140861e73ed302a9af9 Mon Sep 17 00:00:00 2001 From: "DESKTOP-1R2VSQH\\Lenovo ThinkPad E490" Date: Mon, 14 Sep 2026 13:07:08 +0700 Subject: [PATCH 08/76] =?UTF-8?q?=E0=B8=88=E0=B8=AD=E0=B8=AA=E0=B8=B1?= =?UTF-8?q?=E0=B8=A1=E0=B8=9C=E0=B8=B1=E0=B8=AA=20(iOS/Android):=20?= =?UTF-8?q?=E0=B9=84=E0=B8=AE=E0=B9=84=E0=B8=A5=E0=B8=95=E0=B9=8C=20commit?= =?UTF-8?q?=20=E0=B8=9C=E0=B9=88=E0=B8=B2=E0=B8=99=E0=B8=9B=E0=B8=B8?= =?UTF-8?q?=E0=B9=88=E0=B8=A1=20"=E0=B9=84=E0=B8=AE=E0=B9=84=E0=B8=A5?= =?UTF-8?q?=E0=B8=95=E0=B9=8C"=20=E0=B8=A5=E0=B8=AD=E0=B8=A2=E0=B9=83?= =?UTF-8?q?=E0=B8=95=E0=B9=89=20selection=20+=20=E0=B8=A5=E0=B8=B2?= =?UTF-8?q?=E0=B8=81=20handle=20=E0=B8=81=E0=B8=A3=E0=B8=AD=E0=B8=9A=20sel?= =?UTF-8?q?ection=20=E0=B9=84=E0=B8=94=E0=B9=89=E0=B8=88=E0=B8=A3=E0=B8=B4?= =?UTF-8?q?=E0=B8=87:=20(1)=20=E0=B8=9A=E0=B8=99=20touch=20=E0=B8=81?= =?UTF-8?q?=E0=B8=B2=E0=B8=A3=E0=B8=A5=E0=B8=B2=E0=B8=81=E0=B8=84=E0=B8=A5?= =?UTF-8?q?=E0=B8=B8=E0=B8=A1=E0=B8=82=E0=B9=89=E0=B8=AD=E0=B8=84=E0=B8=A7?= =?UTF-8?q?=E0=B8=B2=E0=B8=A1=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B8=A2=E0=B8=B4?= =?UTF-8?q?=E0=B8=87=20synthetic=20mouseup=20(=E0=B8=A2=E0=B8=B4=E0=B8=87?= =?UTF-8?q?=E0=B9=80=E0=B8=89=E0=B8=9E=E0=B8=B2=E0=B8=B0=20tap)=20?= =?UTF-8?q?=E0=B8=97=E0=B8=B3=E0=B9=83=E0=B8=AB=E0=B9=89=20HIGHLIGHT=20?= =?UTF-8?q?=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B8=96=E0=B8=B9=E0=B8=81=E0=B8=AA?= =?UTF-8?q?=E0=B8=A3=E0=B9=89=E0=B8=B2=E0=B8=87=E0=B9=80=E0=B8=A5=E0=B8=A2?= =?UTF-8?q?=20=E2=80=94=20=E0=B9=80=E0=B8=9E=E0=B8=B4=E0=B9=88=E0=B8=A1?= =?UTF-8?q?=E0=B8=9B=E0=B8=B8=E0=B9=88=E0=B8=A1=20"=E0=B9=84=E0=B8=AE?= =?UTF-8?q?=E0=B9=84=E0=B8=A5=E0=B8=95=E0=B9=8C"=20=E0=B8=A5=E0=B8=AD?= =?UTF-8?q?=E0=B8=A2=E0=B9=83=E0=B8=95=E0=B9=89=E0=B8=9A=E0=B8=A3=E0=B8=A3?= =?UTF-8?q?=E0=B8=97=E0=B8=B1=E0=B8=94=E0=B8=AA=E0=B8=B8=E0=B8=94=E0=B8=97?= =?UTF-8?q?=E0=B9=89=E0=B8=B2=E0=B8=A2=E0=B8=82=E0=B8=AD=E0=B8=87=20select?= =?UTF-8?q?ion=20(=E0=B9=81=E0=B8=9E=E0=B8=97=E0=B9=80=E0=B8=97=E0=B8=B4?= =?UTF-8?q?=E0=B8=A3=E0=B9=8C=E0=B8=99=20Acrobat=20mobile;=20=E0=B8=94?= =?UTF-8?q?=E0=B9=89=E0=B8=B2=E0=B8=99=E0=B8=9A=E0=B8=99=E0=B8=96=E0=B8=B9?= =?UTF-8?q?=E0=B8=81=E0=B9=80=E0=B8=A1=E0=B8=99=E0=B8=B9/handles=20?= =?UTF-8?q?=E0=B8=82=E0=B8=AD=E0=B8=87=20OS=20=E0=B8=A2=E0=B8=B6=E0=B8=94?= =?UTF-8?q?=20=E0=B9=83=E0=B8=81=E0=B8=A5=E0=B9=89=E0=B8=82=E0=B8=AD?= =?UTF-8?q?=E0=B8=9A=E0=B8=A5=E0=B9=88=E0=B8=B2=E0=B8=87=E0=B8=AB=E0=B8=99?= =?UTF-8?q?=E0=B9=89=E0=B8=B2=E0=B9=80=E0=B8=81=E0=B8=B4=E0=B8=99=E0=B8=9E?= =?UTF-8?q?=E0=B8=A5=E0=B8=B4=E0=B8=81=E0=B8=82=E0=B8=B6=E0=B9=89=E0=B8=99?= =?UTF-8?q?=E0=B9=80=E0=B8=AB=E0=B8=99=E0=B8=B7=E0=B8=AD=20rect=20?= =?UTF-8?q?=E0=B9=81=E0=B8=A3=E0=B8=81)=20=E0=B9=81=E0=B8=AA=E0=B8=94?= =?UTF-8?q?=E0=B8=87=E0=B9=80=E0=B8=89=E0=B8=9E=E0=B8=B2=E0=B8=B0=20pointe?= =?UTF-8?q?r:coarse=20+=20tool=20highlight=20=E0=B9=80=E0=B8=A1=E0=B8=B7?= =?UTF-8?q?=E0=B9=88=E0=B8=AD=20selection=20=E0=B8=AB=E0=B8=A2=E0=B8=B8?= =?UTF-8?q?=E0=B8=94=E0=B8=99=E0=B8=B4=E0=B9=88=E0=B8=87=20~400ms=20(debou?= =?UTF-8?q?nce=20=E0=B9=83=E0=B8=99=20effect=20selectionchange=20=E0=B8=82?= =?UTF-8?q?=E0=B9=89=E0=B8=B2=E0=B8=A1=E0=B8=8A=E0=B9=88=E0=B8=A7=E0=B8=87?= =?UTF-8?q?=E0=B8=A5=E0=B8=B2=E0=B8=81=20handles)=20(2)=20=E0=B9=81?= =?UTF-8?q?=E0=B8=95=E0=B8=B0=E0=B8=9B=E0=B8=B8=E0=B9=88=E0=B8=A1=20commit?= =?UTF-8?q?=20=E0=B8=88=E0=B8=B2=E0=B8=81=20snapshot=20=E0=B9=83=E0=B8=99?= =?UTF-8?q?=20touchSelRef=20=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B9=83=E0=B8=8A?= =?UTF-8?q?=E0=B9=88=20selection=20=E0=B8=AA=E0=B8=94=20=E0=B9=80=E0=B8=9E?= =?UTF-8?q?=E0=B8=A3=E0=B8=B2=E0=B8=B0=20iOS=20=E0=B9=80=E0=B8=84=E0=B8=A5?= =?UTF-8?q?=E0=B8=B5=E0=B8=A2=E0=B8=A3=E0=B9=8C=20selection=20=E0=B8=97?= =?UTF-8?q?=E0=B8=B1=E0=B8=99=E0=B8=97=E0=B8=B5=E0=B8=97=E0=B8=B5=E0=B9=88?= =?UTF-8?q?=E0=B9=81=E0=B8=95=E0=B8=B0=E0=B8=9B=E0=B8=B8=E0=B9=88=E0=B8=A1?= =?UTF-8?q?=E0=B8=81=E0=B9=88=E0=B8=AD=E0=B8=99=20tap=20=E0=B8=88=E0=B8=B0?= =?UTF-8?q?=E0=B8=96=E0=B8=B6=E0=B8=87=20=E2=80=94=20=E0=B8=81=E0=B8=B2?= =?UTF-8?q?=E0=B8=A3=E0=B8=8B=E0=B9=88=E0=B8=AD=E0=B8=99=E0=B8=9B=E0=B8=B8?= =?UTF-8?q?=E0=B9=88=E0=B8=A1+=E0=B8=A5=E0=B9=89=E0=B8=B2=E0=B8=87=20snaps?= =?UTF-8?q?hot=20=E0=B8=88=E0=B8=B6=E0=B8=87=E0=B8=AB=E0=B8=99=E0=B9=88?= =?UTF-8?q?=E0=B8=A7=E0=B8=87=20250ms=20(scheduleTouchHide)=20=E0=B9=83?= =?UTF-8?q?=E0=B8=AB=E0=B9=89=20tap=20=E0=B8=99=E0=B8=B1=E0=B9=89=E0=B8=99?= =?UTF-8?q?=20commit=20=E0=B9=84=E0=B8=94=E0=B9=89=E0=B8=81=E0=B9=88?= =?UTF-8?q?=E0=B8=AD=E0=B8=99=20=E0=B9=81=E0=B8=A5=E0=B8=B0=E0=B8=9C?= =?UTF-8?q?=E0=B8=B9=E0=B8=81=20commit=20=E0=B8=97=E0=B8=B5=E0=B9=88=20poi?= =?UTF-8?q?nterdown=20(=E0=B9=80=E0=B8=A3=E0=B9=87=E0=B8=A7=E0=B8=81?= =?UTF-8?q?=E0=B8=A7=E0=B9=88=E0=B8=B2=20click);=20desktop=20=E0=B8=A2?= =?UTF-8?q?=E0=B8=B1=E0=B8=87=E0=B9=83=E0=B8=8A=E0=B9=89=20mouseup=20?= =?UTF-8?q?=E0=B8=95=E0=B8=B2=E0=B8=A1=E0=B9=80=E0=B8=94=E0=B8=B4=E0=B8=A1?= =?UTF-8?q?=20=E2=80=94=20=E0=B8=AA=E0=B8=AD=E0=B8=87=20path=20=E0=B8=9C?= =?UTF-8?q?=E0=B9=88=E0=B8=B2=E0=B8=99=20commitHighlight=20=E0=B8=8A?= =?UTF-8?q?=E0=B8=B8=E0=B8=94=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7?= =?UTF-8?q?=E0=B8=81=E0=B8=B1=E0=B8=99=20+=20=E0=B8=95=E0=B8=B1=E0=B9=89?= =?UTF-8?q?=E0=B8=87=20lastSelectionTextRef=20(=E0=B8=8A=E0=B8=B7=E0=B9=88?= =?UTF-8?q?=E0=B8=AD=20bookmark=20default)=20=E0=B9=83=E0=B8=99=20debounce?= =?UTF-8?q?=20=E0=B9=80=E0=B8=AB=E0=B8=A1=E0=B8=B7=E0=B8=AD=E0=B8=99=20mou?= =?UTF-8?q?seup=20(3)=20=E0=B9=80=E0=B8=95=E0=B8=B4=E0=B8=A1=20touch-none?= =?UTF-8?q?=20=E0=B8=97=E0=B8=B5=E0=B9=88=20handle=20=E0=B8=97=E0=B8=B1?= =?UTF-8?q?=E0=B9=89=E0=B8=87=208=20=E0=B8=82=E0=B8=AD=E0=B8=87=20Annotati?= =?UTF-8?q?onSelectionLayer=20(=E0=B8=82=E0=B8=AD=E0=B8=9A=204=20+=20?= =?UTF-8?q?=E0=B8=A1=E0=B8=B8=E0=B8=A1=204=20=E2=80=94=20InkLayer=20?= =?UTF-8?q?=E0=B8=A1=E0=B8=B5=E0=B8=AD=E0=B8=A2=E0=B8=B9=E0=B9=88=E0=B9=81?= =?UTF-8?q?=E0=B8=A5=E0=B9=89=E0=B8=A7)=20=E0=B9=84=E0=B8=A1=E0=B9=88?= =?UTF-8?q?=E0=B8=87=E0=B8=B1=E0=B9=89=E0=B8=99=20browser=20=E0=B8=A2?= =?UTF-8?q?=E0=B8=B6=E0=B8=94=20gesture=20=E0=B9=80=E0=B8=9B=E0=B9=87?= =?UTF-8?q?=E0=B8=99=20scroll=20=E0=B8=AA=E0=B9=88=E0=B8=87=20pointercance?= =?UTF-8?q?l=20=E0=B8=81=E0=B8=A5=E0=B8=B2=E0=B8=A2=E0=B9=80=E0=B8=9B?= =?UTF-8?q?=E0=B9=87=E0=B8=99=E0=B9=80=E0=B8=A5=E0=B8=B7=E0=B9=88=E0=B8=AD?= =?UTF-8?q?=E0=B8=99=E0=B8=AB=E0=B8=99=E0=B9=89=E0=B8=B2=E0=B9=81=E0=B8=97?= =?UTF-8?q?=E0=B8=99=E0=B8=81=E0=B8=B2=E0=B8=A3=E0=B8=A5=E0=B8=B2=E0=B8=81?= =?UTF-8?q?=E0=B8=A2=E0=B9=89=E0=B8=B2=E0=B8=A2/=E0=B8=9B=E0=B8=A3?= =?UTF-8?q?=E0=B8=B1=E0=B8=9A=E0=B8=82=E0=B8=99=E0=B8=B2=E0=B8=94=20(4)=20?= =?UTF-8?q?sync=20rules/pdf-viewer.md=20=E2=80=94=20verify=20=E0=B8=94?= =?UTF-8?q?=E0=B9=89=E0=B8=A7=E0=B8=A2=20Playwright=20touch=20emulation:?= =?UTF-8?q?=20=E0=B8=9B=E0=B8=B8=E0=B9=88=E0=B8=A1=E0=B9=82=E0=B8=9C?= =?UTF-8?q?=E0=B8=A5=E0=B9=88=E0=B8=AB=E0=B8=A5=E0=B8=B1=E0=B8=87=20deboun?= =?UTF-8?q?ce,=20=E0=B9=81=E0=B8=95=E0=B8=B0=E0=B9=81=E0=B8=A5=E0=B9=89?= =?UTF-8?q?=E0=B8=A7=E0=B8=AA=E0=B8=A3=E0=B9=89=E0=B8=B2=E0=B8=87=20HIGHLI?= =?UTF-8?q?GHT=20=E0=B8=88=E0=B8=A3=E0=B8=B4=E0=B8=87=20(server=20action?= =?UTF-8?q?=20+=20DOM=20+=20sidebar),=20=E0=B8=A7=E0=B8=B2=E0=B8=94?= =?UTF-8?q?=E0=B9=80=E0=B8=AA=E0=B9=89=E0=B8=99=E0=B8=94=E0=B9=89=E0=B8=A7?= =?UTF-8?q?=E0=B8=A2=E0=B8=99=E0=B8=B4=E0=B9=89=E0=B8=A7=E0=B9=84=E0=B8=94?= =?UTF-8?q?=E0=B9=89,=20handles=209=20=E0=B8=88=E0=B8=B8=E0=B8=94=20touch-?= =?UTF-8?q?action:none=20=E0=B8=84=E0=B8=A3=E0=B8=9A,=20tsc=20=E0=B8=9C?= =?UTF-8?q?=E0=B9=88=E0=B8=B2=E0=B8=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- .claude/rules/pdf-viewer.md | 3 +- components/pdf/AnnotationSelectionLayer.tsx | 13 +- components/pdf/PDFViewer.tsx | 158 ++++++++++++++++++-- 3 files changed, 158 insertions(+), 16 deletions(-) diff --git a/.claude/rules/pdf-viewer.md b/.claude/rules/pdf-viewer.md index 6cc7d4b..22a138c 100644 --- a/.claude/rules/pdf-viewer.md +++ b/.claude/rules/pdf-viewer.md @@ -23,8 +23,9 @@ paths: - DRAW วาดเส้นตรง: **ปล่อยเมาส์แล้ว snap อัตโนมัติ** เมื่อจุดทุกจุดห่างจากเส้นตรง จุดเริ่ม→จุดสุดท้าย ไม่เกิน max(5% ของความยาว, 3px) และเส้นยาว ≥ 24px (`isNearlyStraight`) — หรือกดค้าง **Shift** ระหว่างลาก (กด/ปล่อยกลางทางได้ — listener ระดับ window + reset ตอน blur) เพื่อบังคับตรงเสมอ → บันทึกเหลือจุดเริ่ม→จุดสุดท้าย 2 จุด (ความหนา uniform — ไม่เก็บ `widths` จาก pressure) — ใช้ได้เฉพาะโหมดวาดอิสระ ไม่ใช่ shape/eraser/redact; เส้นโค้ง/ขดจริงหลุดเกณฑ์จึงเหลือ freehand - Tool `pan` (มือเลื่อนหน้า — ปุ่มแยกถัดจาก select ในแถบ tool): ลากบน `

    ` = `window.scrollBy` สวนทางการลาก + cursor grab/grabbing; ระหว่างใช้ pan ทุก overlay ปิด pointer-events ผ่านตัวแปร `overlayFrozen` (`inkActive || pan`) **ยกเว้น InkLayer ต้องไม่ active** (ไม่งั้น pan กลายเป็นวาดเส้น) - HIGHLIGHT มี `style?` 4 แบบ: highlight ทึบเหมือนเดิม / underline = เส้นที่ขอบล่าง rect ต่อบรรทัด / strikethrough = เส้นกลางบรรทัด / squiggle = svg path ขยุกขยิก (`squigglePath`) ที่ขอบล่าง — export วาด line/sine แทน rect ทึบ; สร้างได้จาก **tool "highlight" ทางเดียว** (dropdown บนปุ่ม = รูปแบบ + สี → คลุมข้อความ = สร้างทันที พร้อม live preview ผ่าน `selectionchange` + ซ่อน native selection) — tool "เลือกข้อความ" คลุมแล้วเกิด popup ไม่ได้แล้ว (ถอดออก) และคีย์ลัด `H` สลับไป tool highlight +- **จอสัมผัส (iOS/Android) ไม่มี `mouseup` หลังคลุมข้อความด้วยนิ้ว** (synthetic mouse events เกิดเฉพาะ tap — การ commit ตอนปล่อยเมาส์ใช้ไม่ได้): อุปกรณ์ที่ `matchMedia("(pointer: coarse)")` จริง + tool highlight → effect `selectionchange` debounce ~400ms หลัง selection หยุดนิ่ง แล้ว snapshot (rects + ข้อความผ่าน `readTextSelection(pi, { withText: true })`) เก็บใน `touchSelRef` + แสดงปุ่ม "ไฮไลต์" ลอย**ใต้**บรรทัดสุดท้ายของ selection (ด้านบนถูกเมนู/handles ของ OS ยึด — ใกล้ขอบล่างหน้าเกินพลิกขึ้นเหนือ rect แรก) — commit ต้องใช้ **snapshot ใน ref เท่านั้น ไม่ใช่ selection สด** เพราะ iOS เคลียร์ selection ทันทีที่แตะปุ่ม ก่อน click ยิง; ทั้ง desktop (mouseup) และ touch (ปุ่มยืนยัน) ผ่าน `commitHighlight` ชุดเดียวกัน; เคลียร์ปุ่ม+timer ทุกครั้งที่ selection หาย/ออกนอกหน้า/เปลี่ยน tool - **คลุมข้อความได้ 5 tool** (`TEXT_SELECTION_TOOLS` set ใน PDFViewer: select/highlight/comment/textbox/stamp — ที่เหลือ textLayer ปิด pointer-events คลุมไม่ได้): ทั้งหมด inject `