Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
import type {
|
|
|
|
|
|
AnnotationDTO,
|
2026-09-17 17:02:31 +07:00
|
|
|
|
AnnotationData,
|
|
|
|
|
|
AnnotationType,
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
CommentData,
|
|
|
|
|
|
DrawPen,
|
|
|
|
|
|
HighlightData,
|
|
|
|
|
|
HighlightStyle,
|
|
|
|
|
|
ShapeData,
|
|
|
|
|
|
StampData,
|
|
|
|
|
|
TextboxData,
|
|
|
|
|
|
} from "@/db/schema";
|
2026-09-17 17:02:31 +07:00
|
|
|
|
import { annotationBBox } from "@/utils/geometry";
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
|
2026-09-11 13:28:35 +07:00
|
|
|
|
// การแสดงผล "ชนิด + ข้อความย่อ" ของ annotation — ใช้ร่วมกันระหว่าง sidebar,
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
// หน้าสรุป (F15/D2) และ export CSV (F16/D3) เพื่อไม่ให้ logic แยกกันคนละที่
|
|
|
|
|
|
|
|
|
|
|
|
/** ชื่อรูปแบบการมาร์กข้อความ (HIGHLIGHT.style) */
|
|
|
|
|
|
export const HIGHLIGHT_STYLE_LABELS: Record<HighlightStyle, string> = {
|
จอสัมผัส (iOS/Android): แก้ครบตาม UX audit C1+M1–M6 + code review + งาน export PDF ที่ค้างใน working tree:
(1) C1 — เมนู dropdown 5 ตัว + popup bookmark เดิมเป็น absolute ใน scroll container ของแถบ tool ที่สลับ overflow ตอนเปิด ทำให้บนมือถือ scroll position รีเซ็ต (ปุ่มเด้งกลับซ้าย) เมนูเปิดนอก viewport และทั้ง app เอียงเลื่อนแนวนอนได้ — ย้ายทั้งหมดไป createPortal(document.body) เป็น position:fixed ตาม rect จริงของปุ่มตอนคลิก (fixedMenuPos + clamp ให้อยู่ในจอ; bookmarkPos จาก ref ปุ่ม 🔖 ใช้ร่วมทุกทางเข้า รวมคีย์ลัด B) + ถอดการสลับ overflow (คง overflow-x-auto เสมอ) + ปิดเมนูเมื่อเลื่อนแถบต่อ หรือ "ความกว้าง" จอเปลี่ยน (code review Major1: resize ที่เปลี่ยนแค่ความสูง เช่น URL bar ยุบ/คีย์บอร์ดของมือถือ ไม่ปิด — เมนู fixed ยึด sticky header อยู่กับที่อยู่แล้ว กันเมนู/popup ที่เปิดค้างปรับค่าโดนปิดกลางคัน) + outside-click ข้ามโหนดใน [data-tool-menu] (ref ของ wrapper มองไม่เห็นโหนดใน portal ไม่ข้ามแล้วคลิกในเมนู=ปิดเมนูเอง) + rule ปุ่ม 36px ใน globals.css เปลี่ยนไป target [data-tool-menu] ตรง ๆ (เดิมเจาะผ่าน .z-50 ที่ไม่มีแล้ว)
(2) M4 — tap/คลิกที่ใช้ "ปิดเมนู" (mousedown ปิดก่อน click ยิงถึง page) กลายเป็นวาง comment/textbox/stamp โดยไม่ตั้งใจ — menuClosedAtRef (markMenuClosed ทุก path ปิด: outside-click/Escape/switchTool เฉพาะเมื่อมีเมนูเปิดจริง/เลื่อนแถบ/resize) แล้ว handlePageClick เฉย 300ms แรก คลิกที่สองค่อยวาง (มาตรฐาน dropdown ใช้ทั้ง desktop+touch — pattern เดียวกับ touchHideTimerRef ของปุ่มไฮไลต์)
(3) M1+M2 ของ TEXTBOX — ทางเข้า editor เคยผูก double-click อย่างเดียว (บน iOS double-tap = zoom หน้า แก้ข้อความไม่ได้เลยตลอดชีวิตบนมือถือ) — เพิ่มปุ่ม "แก้ไขข้อความ" ในแผง selection (onStartEdit → startTextboxEdit) ใช้ได้ทั้ง touch/desktop และ default fontSize 1.2% (≈7px เล็กกว่า preset ต่ำสุด 10px ของตัวเอง) → 2.5% (≈15px อ่านออกทันที) กระทบเฉพาะกล่องใหม่
(4) M3+clamp — hit area บน pointer:coarse โดยของที่มองเห็นคงขนาดเดิม: มุมกรอบใช้ ::after (10→hit 28px), แถบขอบ (8→16px), ปุ่มแผง 20→36px, swatch 16→32px, icon sidebar 16/14→32px (hooks sel-handle/sel-strip/sel-panel/sel-panel-btn/color-swatches + aside[data-sidebar]) และ clamp แนวนอนของแผง selection (left: max(0, min(box.x, size.width-244))) กันรายการชิดขวาทำแผง w-60 ล้นขอบจอแคบจนปุ่มลบหลุดจอ (เจอตอน verify จอ 390px)
(5) M5 — undo/redo max-lg:order-first ไปหัวแถวเลื่อน: เห็นเสมอโดยไม่ต้องเลื่อนแถบ (undo คือทางแก้พลาดหลักของจอสัมผัส) จอ ≥ lg ไม่มี order ลำดับ/หน้าตาเดิมทุกประการ
(6) M6 อื่น ๆ — -webkit-tap-highlight-color: transparent + overscroll-behavior-y: contain (กัน pull-to-refresh ของ Android ล้าง undo history ฝั่ง client), button/label touch-action: manipulation (ตัด double-tap zoom), safe-area inset แบบ defensive (จะมีค่าเมื่อใส่ viewport-fit=cover ในอนาคต), no-callout กัน long-press magnifier เฉพาะ layer TEXTBOX/STAMP (textLayer ยัง select/copy ได้), ปุ่ม composer/popover/pin 24→36px (coarse-36), ปุ่มหน้าสรุป 30→44px (summary-page), ซ่อนชื่อไฟล์บนจอเล็ก, ปุ่ม X ปิด sidebar บน max-lg (เดิมปิดได้ทางเดียวคือจิ้ม backdrop เพราะ overlay z-50 กลบปุ่ม toggle ใน header), tab sidebar เป็นไทย "ที่คั่นหน้า/หมายเหตุ" ให้สอดคล้อง UI ไทยล้วน, chip แจ้ง "หน้าที่เปิดดูเป็นภาพสแกน" เมื่อ tool highlight และทุกหน้าที่ render มี 0 text spans (onTextLayerRendered รายงานต่อหน้า + pageSpanCount)
(7) งาน export PDF ที่อยู่ใน working tree ก่อนหน้า + แก้ตาม code review — COMMENT กลายเป็น sticky note จริง (/Subtype /Text), bookmark → PDF outline (/Dest คำนวณตาม /Rotate เหมือน annotation), โน้ต N1 → sticky note แนบตำแหน่ง, นิยาม yOffset เป็น "ขอบบนของสิ่งที่ดูอยู่" + goToBookmark ใช้ scrollToBelowHeader, และเพิ่ม guard roots.length===0 ใน buildOutline กัน bookmark cycle ไร้ root (DB ถูกแก้ตรง ๆ) ทำ kids[0].ref พังเป็น 500 ทั้ง export
(8) sync .claude/rules/pdf-viewer.md — portal เมนู+ห้ามสลับ overflow ของแถบ, guard 300ms, hit-area hooks ระดับ coarse, TEXTBOX default 2.5% + ปุ่มแก้ไขข้อความ
verify: tsc ผ่าน · vitest 100/100 · Playwright touch emulation จริง (iPhone 390×844 / iPad 820×1180 / desktop 1280): เมนูอยู่ใน viewport ทุกสถานะหลังเลื่อนแถบ + scrollLeft คงที่ + ไม่มี horizontal overflow, แตะในเมนูไม่ปิดเอง, เลื่อนแถบ/กว้างจอเปลี่ยน=ปิด/สูงเปลี่ยน=ค้าง, guard 300ms ทั้ง stamp/textbox/comment, ปุ่มแก้ไขข้อความเปิด editor พร้อมข้อความเดิม, fontSize ใหม่ 14.9px, hit มุม 28px/ปุ่มลบ 36px/swatch 32px, undo อยู่ในจอ + undo/redo ทำงาน, X ปิด sidebar, chip สแกนโชว์/ไฟล์ text ไม่โชว์, desktop เหมือนเดิม, แผง clamp (x=130..370 ปุ่มลบจบ 363) — annotation ทดสอบลบเฉพาะ id ที่สร้างเองทั้งหมด DB กลับสะอาด
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 18:04:40 +07:00
|
|
|
|
highlight: "ไฮไลต์ข้อความ",
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
underline: "เส้นใต้",
|
|
|
|
|
|
strikethrough: "ขีดฆ่า",
|
|
|
|
|
|
squiggle: "ขยุกขยิก",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** ชื่อรูปทรงตามชนิด (F5) */
|
|
|
|
|
|
export const SHAPE_LABELS: Record<ShapeData["shape"], string> = {
|
|
|
|
|
|
rect: "สี่เหลี่ยม",
|
|
|
|
|
|
ellipse: "วงรี",
|
|
|
|
|
|
line: "เส้นตรง",
|
|
|
|
|
|
arrow: "ลูกศร",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ชนิดปากกาของ tool DRAW — สี/ความหนา (% ของความกว้างหน้า)/ความทึบ ที่ render บนจอ
|
|
|
|
|
|
// กับ export PDF ใช้ร่วมกัน (เก็บใน DB เฉพาะ `pen` — ค่าอื่นอ่านจากตรงนี้ตอนสร้างเส้น)
|
|
|
|
|
|
export const DRAW_PENS: Record<
|
|
|
|
|
|
DrawPen,
|
|
|
|
|
|
{ label: string; color: string; strokeWidthPct: number; opacity: number }
|
|
|
|
|
|
> = {
|
|
|
|
|
|
pencil: { label: "ปากกา", color: "#ef4444", strokeWidthPct: 0.4, opacity: 1 },
|
|
|
|
|
|
highlighter: { label: "ไฮไลต์", color: "#fde047", strokeWidthPct: 1.2, opacity: 0.45 },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* ความทึบสุดท้ายของเส้นวาด — เส้นที่สร้างตอนมีตัวปรับความทึบเก็บ `opacity` ชัดเสมอ
|
2026-09-11 13:28:35 +07:00
|
|
|
|
* ส่วนแถวเดิม (ไม่มี field นี้) ตกลไปตามค่า preset ของ `pen` (ไม่มี pen = pencil)
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
* render บนจอกับ export PDF ต้องใช้ค่าจากที่นี่ที่เดียวกัน
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function effectiveDrawOpacity(d: { pen?: DrawPen; opacity?: number }): number {
|
|
|
|
|
|
return d.opacity ?? DRAW_PENS[d.pen ?? "pencil"].opacity;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
genesis+worker: design system Genesis ทั้งชุด + patch pdf.worker แก้ fake-space ไทย + ปิด review design/responsive/quality รอบ 2026-09-24 — (1) Genesis: ฟอนต์ General Sans self-host + DM Sans + Sarabun, AppNav 56px backdrop-blur แทน SessionBar, DotGrid หน้า login, palette/รัศมี/เงา/focus ตาม docs/DESIGN.md ครบทุกหน้า (2) postinstall scripts/patch-pdf-worker.mjs ถอด if (scaledDim) รอบ prevTransform ให้อัปเดตทุก glyph — กัน addFakeSpaces แทรก space กลางคำไทยใน PDF ที่ justify ด้วย letter-spacing + test กัน regression (tests/scripts/ + fixture thai_letterspacing.pdf, invariant #6) (3) ฟอร์มอัปโหลด + ช่องกลุ่ม typeId ทางเลือก — normDocumentGroup ที่ utils/external-file.ts เป็น single source ร่วมกับ N8 (4) token fixes: focus ring ปุ่ม pin + แถว bookmark ตามหน้า, ปุ่ม action ใน popup/composer/dialog เป็น rounded-md ทั้งคู่, selection preview เป็น tint ของ primary แทน blue-500 ดิบ, default สี shape/highlight รวมที่ utils/annotation-display, loading animate-pulse + จดข้อยกเว้น (เงา pin, font weights ของ viewer) ลง DESIGN.md (5) responsive: Export PDF max-lg:order-first ให้เห็นบน iPad, แถวบน header แถวเดียวเลื่อนได้บนจอแคบ, re-fit ความกว้างตอน toggle sidebar เฉพาะเมื่อยังไม่ซูมเอง, pin popover flip ขึ้นบนเมื่อใกล้ท้ายหน้า, toast hint ตอนสลับ tool กลางเอกสาร, ป้าย "ชนิด · ผู้ทำ" ของรายการผู้อื่นสำหรับจอสัมผัส (6) คุณภาพ: @layer ครอบ @media กันกฎ touch รั่ยไป desktop (Tailwind v3 ยก @layer ออกจาก media ทิ้ง wrapper — ยืนยันด้วย build จริง + วัดบนจอ), กฎ M5 ครอบ contenteditable กัน Safari zoom ตอนแก้ไข TEXTBOX บน iPad, COMMENT ใช้ box มาตราฐานเดียวตาม pin px จริง (render/scroll target/move-clamp/bookmark), ตัดหัวการ์ดซ้ำกับ h2 หน้ารายการ — พร้อมไฟล์ untracked ที่ postinstall/โค้ดอ้างถึงครบชุด (app/fonts/, AppNav, DotGrid, tests/) กัน fresh clone fail ที่ npm install (คำขอผู้ใช้ 2026-09-24)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 11:46:02 +07:00
|
|
|
|
// ค่า default สีตอนสร้างรายการใหม่ของ tool shape/highlight — ค่าเดียวกับ preset ใน
|
|
|
|
|
|
// INK_COLORS ของ ToolControls.tsx (แดง = ปากกา/รูปทรง, เหลือง = ไฮไลต์) ประกาศเป็น
|
|
|
|
|
|
// named constant ตาม pattern TEXTBOX_DEFAULT_COLOR (design audit 2026-09-24 — เดิม
|
|
|
|
|
|
// PDFViewer ถือ HIGHLIGHT_COLORS 5 สีเอง ทั้งที่ใช้จริงแค่ตัวแรก ซ้ำกับ preset)
|
|
|
|
|
|
export const SHAPE_DEFAULT_COLOR = "#ef4444";
|
|
|
|
|
|
export const HIGHLIGHT_DEFAULT_COLOR = "#fde047";
|
|
|
|
|
|
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
// ค่า default ตอนสร้าง TEXTBOX (F17/B2) — fontSize เป็น % ของความกว้างหน้า
|
|
|
|
|
|
export const TEXTBOX_DEFAULT_COLOR = "#111827";
|
จอสัมผัส (iOS/Android): แก้ครบตาม UX audit C1+M1–M6 + code review + งาน export PDF ที่ค้างใน working tree:
(1) C1 — เมนู dropdown 5 ตัว + popup bookmark เดิมเป็น absolute ใน scroll container ของแถบ tool ที่สลับ overflow ตอนเปิด ทำให้บนมือถือ scroll position รีเซ็ต (ปุ่มเด้งกลับซ้าย) เมนูเปิดนอก viewport และทั้ง app เอียงเลื่อนแนวนอนได้ — ย้ายทั้งหมดไป createPortal(document.body) เป็น position:fixed ตาม rect จริงของปุ่มตอนคลิก (fixedMenuPos + clamp ให้อยู่ในจอ; bookmarkPos จาก ref ปุ่ม 🔖 ใช้ร่วมทุกทางเข้า รวมคีย์ลัด B) + ถอดการสลับ overflow (คง overflow-x-auto เสมอ) + ปิดเมนูเมื่อเลื่อนแถบต่อ หรือ "ความกว้าง" จอเปลี่ยน (code review Major1: resize ที่เปลี่ยนแค่ความสูง เช่น URL bar ยุบ/คีย์บอร์ดของมือถือ ไม่ปิด — เมนู fixed ยึด sticky header อยู่กับที่อยู่แล้ว กันเมนู/popup ที่เปิดค้างปรับค่าโดนปิดกลางคัน) + outside-click ข้ามโหนดใน [data-tool-menu] (ref ของ wrapper มองไม่เห็นโหนดใน portal ไม่ข้ามแล้วคลิกในเมนู=ปิดเมนูเอง) + rule ปุ่ม 36px ใน globals.css เปลี่ยนไป target [data-tool-menu] ตรง ๆ (เดิมเจาะผ่าน .z-50 ที่ไม่มีแล้ว)
(2) M4 — tap/คลิกที่ใช้ "ปิดเมนู" (mousedown ปิดก่อน click ยิงถึง page) กลายเป็นวาง comment/textbox/stamp โดยไม่ตั้งใจ — menuClosedAtRef (markMenuClosed ทุก path ปิด: outside-click/Escape/switchTool เฉพาะเมื่อมีเมนูเปิดจริง/เลื่อนแถบ/resize) แล้ว handlePageClick เฉย 300ms แรก คลิกที่สองค่อยวาง (มาตรฐาน dropdown ใช้ทั้ง desktop+touch — pattern เดียวกับ touchHideTimerRef ของปุ่มไฮไลต์)
(3) M1+M2 ของ TEXTBOX — ทางเข้า editor เคยผูก double-click อย่างเดียว (บน iOS double-tap = zoom หน้า แก้ข้อความไม่ได้เลยตลอดชีวิตบนมือถือ) — เพิ่มปุ่ม "แก้ไขข้อความ" ในแผง selection (onStartEdit → startTextboxEdit) ใช้ได้ทั้ง touch/desktop และ default fontSize 1.2% (≈7px เล็กกว่า preset ต่ำสุด 10px ของตัวเอง) → 2.5% (≈15px อ่านออกทันที) กระทบเฉพาะกล่องใหม่
(4) M3+clamp — hit area บน pointer:coarse โดยของที่มองเห็นคงขนาดเดิม: มุมกรอบใช้ ::after (10→hit 28px), แถบขอบ (8→16px), ปุ่มแผง 20→36px, swatch 16→32px, icon sidebar 16/14→32px (hooks sel-handle/sel-strip/sel-panel/sel-panel-btn/color-swatches + aside[data-sidebar]) และ clamp แนวนอนของแผง selection (left: max(0, min(box.x, size.width-244))) กันรายการชิดขวาทำแผง w-60 ล้นขอบจอแคบจนปุ่มลบหลุดจอ (เจอตอน verify จอ 390px)
(5) M5 — undo/redo max-lg:order-first ไปหัวแถวเลื่อน: เห็นเสมอโดยไม่ต้องเลื่อนแถบ (undo คือทางแก้พลาดหลักของจอสัมผัส) จอ ≥ lg ไม่มี order ลำดับ/หน้าตาเดิมทุกประการ
(6) M6 อื่น ๆ — -webkit-tap-highlight-color: transparent + overscroll-behavior-y: contain (กัน pull-to-refresh ของ Android ล้าง undo history ฝั่ง client), button/label touch-action: manipulation (ตัด double-tap zoom), safe-area inset แบบ defensive (จะมีค่าเมื่อใส่ viewport-fit=cover ในอนาคต), no-callout กัน long-press magnifier เฉพาะ layer TEXTBOX/STAMP (textLayer ยัง select/copy ได้), ปุ่ม composer/popover/pin 24→36px (coarse-36), ปุ่มหน้าสรุป 30→44px (summary-page), ซ่อนชื่อไฟล์บนจอเล็ก, ปุ่ม X ปิด sidebar บน max-lg (เดิมปิดได้ทางเดียวคือจิ้ม backdrop เพราะ overlay z-50 กลบปุ่ม toggle ใน header), tab sidebar เป็นไทย "ที่คั่นหน้า/หมายเหตุ" ให้สอดคล้อง UI ไทยล้วน, chip แจ้ง "หน้าที่เปิดดูเป็นภาพสแกน" เมื่อ tool highlight และทุกหน้าที่ render มี 0 text spans (onTextLayerRendered รายงานต่อหน้า + pageSpanCount)
(7) งาน export PDF ที่อยู่ใน working tree ก่อนหน้า + แก้ตาม code review — COMMENT กลายเป็น sticky note จริง (/Subtype /Text), bookmark → PDF outline (/Dest คำนวณตาม /Rotate เหมือน annotation), โน้ต N1 → sticky note แนบตำแหน่ง, นิยาม yOffset เป็น "ขอบบนของสิ่งที่ดูอยู่" + goToBookmark ใช้ scrollToBelowHeader, และเพิ่ม guard roots.length===0 ใน buildOutline กัน bookmark cycle ไร้ root (DB ถูกแก้ตรง ๆ) ทำ kids[0].ref พังเป็น 500 ทั้ง export
(8) sync .claude/rules/pdf-viewer.md — portal เมนู+ห้ามสลับ overflow ของแถบ, guard 300ms, hit-area hooks ระดับ coarse, TEXTBOX default 2.5% + ปุ่มแก้ไขข้อความ
verify: tsc ผ่าน · vitest 100/100 · Playwright touch emulation จริง (iPhone 390×844 / iPad 820×1180 / desktop 1280): เมนูอยู่ใน viewport ทุกสถานะหลังเลื่อนแถบ + scrollLeft คงที่ + ไม่มี horizontal overflow, แตะในเมนูไม่ปิดเอง, เลื่อนแถบ/กว้างจอเปลี่ยน=ปิด/สูงเปลี่ยน=ค้าง, guard 300ms ทั้ง stamp/textbox/comment, ปุ่มแก้ไขข้อความเปิด editor พร้อมข้อความเดิม, fontSize ใหม่ 14.9px, hit มุม 28px/ปุ่มลบ 36px/swatch 32px, undo อยู่ในจอ + undo/redo ทำงาน, X ปิด sidebar, chip สแกนโชว์/ไฟล์ text ไม่โชว์, desktop เหมือนเดิม, แผง clamp (x=130..370 ปุ่มลบจบ 363) — annotation ทดสอบลบเฉพาะ id ที่สร้างเองทั้งหมด DB กลับสะอาด
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 18:04:40 +07:00
|
|
|
|
// M2: 2.5% ≈ 15px ที่หน้า A4 (595pt, scale 1) — อ่านออกทันทีที่พิมพ์เสร็จ (เดิม 1.2% ≈ 7px
|
|
|
|
|
|
// เล็กกว่า preset ต่ำสุด 10px ของ NumberCombo ด้วยซ้ำ) / กระทบเฉพาะกล่องที่สร้างใหม่เท่านั้น
|
|
|
|
|
|
export const TEXTBOX_DEFAULT_FONT_SIZE_PCT = 2.5;
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
|
|
|
|
|
|
// STAMP preset (F18/B5, D7 — คงที่ในโค้ด) + ค่าการแสดงผลที่ render กับ export ใช้ร่วมกัน
|
|
|
|
|
|
export const STAMP_PRESETS: readonly string[] = [
|
|
|
|
|
|
"ตรวจสอบแล้ว",
|
|
|
|
|
|
"สำคัญ",
|
|
|
|
|
|
];
|
|
|
|
|
|
/** ความกว้างกรอบตรายางเริ่มต้น (% ของความกว้างหน้า) */
|
|
|
|
|
|
export const STAMP_DEFAULT_SCALE_PCT = 18;
|
|
|
|
|
|
/** สีตรายาง (ขอบ + ตัวอักษร) — ใช้ทั้ง render บนจอและ export */
|
|
|
|
|
|
export const STAMP_COLOR = "#b91c1c";
|
2026-09-11 13:28:35 +07:00
|
|
|
|
/** ขนาดตัวอักษรในตรายาง = สัดส่วนของความกว้างกรอบ (ค่าสูงสุด — ย่อลงเมื่อข้อความยาวเกินกรอบ) */
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
export const STAMP_FONT_RATIO = 0.22;
|
2026-09-11 13:28:35 +07:00
|
|
|
|
/** ความกว้างข้อความสูงสุดที่ยอมให้ = สัดส่วนของกรอบ (เว้นระยะจากขอบใน) */
|
|
|
|
|
|
export const STAMP_TEXT_WIDTH_RATIO = 0.9;
|
|
|
|
|
|
|
|
|
|
|
|
/** ขนาดตัวอักษรตรายางที่ label อยู่บรรทัดเดียว — เริ่มที่ STAMP_FONT_RATIO แล้วย่อตาม
|
|
|
|
|
|
* สัดส่วนที่ข้อความล้น (measure ผูกกับเชิงเส้นกับขนาดฟอนต์ วัดครั้งเดียวพอ) — measure เป็น
|
|
|
|
|
|
* ความกว้างจริงของฝั่งนั้น ๆ: จอ = canvas measureText, export = font.widthOfTextAtSize
|
|
|
|
|
|
* (หากไม่ย่อ label ยาวอย่าง "ตรวจสอบแล้ว" จะ wrap 2 บรรทัดและล้นความสูงกรอบถูกตัดหาย) */
|
|
|
|
|
|
export function stampFitFontSize(
|
|
|
|
|
|
label: string,
|
|
|
|
|
|
boxWidth: number,
|
|
|
|
|
|
measure: (text: string, fontSize: number) => number,
|
|
|
|
|
|
): number {
|
|
|
|
|
|
const base = boxWidth * STAMP_FONT_RATIO;
|
|
|
|
|
|
const textW = measure(label, base);
|
|
|
|
|
|
const maxW = boxWidth * STAMP_TEXT_WIDTH_RATIO;
|
|
|
|
|
|
return textW <= maxW ? base : (base * maxW) / textW;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// คลาสอักขระขยะที่ปนมากับข้อความสกัดจาก textLayer ของ PDF ไทยบางไฟล์ (สร้างแบบ
|
|
|
|
|
|
// glyph ต่อ glyph + cmap ของฟอนต์ผิด) = C0 control ยกเว้น \t \n \r
|
|
|
|
|
|
const CTRL_JUNK = "\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f";
|
|
|
|
|
|
|
|
|
|
|
|
/** ล้างข้อความสกัดจาก textLayer ก่อนเก็บลง DB / ทำชื่อ bookmark / ใส่คลิปบอร์ด:
|
|
|
|
|
|
* กลุ่มอักขระที่มี control ขยะคั่นและมีช่องว่างล้อมสองข้าง (" " ctrl " ") = ขยะ
|
|
|
|
|
|
* ทั้งกลุ่ม (ตัดหมด — ช่องว่างนั้นเป็น artifact ที่ pdfjs เติมรอบ glyph เพี้ยน)
|
|
|
|
|
|
* ส่วนกลุ่มที่ช่องว่างติดข้างเดียว = เก็บช่องว่างไว้ (อาจเป็นช่องว่างจริงระหว่างคำ)
|
|
|
|
|
|
* และช่องว่างติดกัน ≥2 ตัว = artifact จาก letterspacing/gap ของ PDF เพี้ยน ตัดทั้งชุด
|
|
|
|
|
|
* (ข้อความจริงไม่มีช่องว่างคู่ — ช่องว่างจริงระหว่างวลีเป็นตัวเดียวเสมอ) */
|
|
|
|
|
|
export function sanitizePdfText(s: string): string {
|
|
|
|
|
|
return s
|
|
|
|
|
|
.replace(new RegExp(`[ ${CTRL_JUNK}]*[${CTRL_JUNK}][ ${CTRL_JUNK}]*`, "g"), (m) =>
|
|
|
|
|
|
m.startsWith(" ") && m.endsWith(" ") && m.length > 1
|
|
|
|
|
|
? ""
|
|
|
|
|
|
: m.replace(new RegExp(`[${CTRL_JUNK}]`, "g"), ""),
|
|
|
|
|
|
)
|
|
|
|
|
|
.replace(/[ \t]{2,}/g, "")
|
|
|
|
|
|
.replace(/ ?\n ?/g, "\n")
|
|
|
|
|
|
.trim();
|
|
|
|
|
|
}
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
|
2026-09-24 17:34:23 +07:00
|
|
|
|
/** ชื่อย่อต่อชนิดสำหรับบรรทัดสรุปของแต่ละหน้า (หน้าสรุป F15) + แผงตัวกรอง
|
|
|
|
|
|
* explorer — ชื่อเต็มอยู่ที่ annotationTypeLabel (เดิม define ซ้ำ 2 ที่: หน้าสรุป
|
|
|
|
|
|
* กับ FilterControls ของหน้ากลุ่ม รวมมาที่นี่ตอนแยกแผงเป็นกลาง 2026-09-24) */
|
|
|
|
|
|
export const TYPE_SHORT: Record<AnnotationType, string> = {
|
|
|
|
|
|
HIGHLIGHT: "ไฮไลต์",
|
|
|
|
|
|
DRAW: "เส้นวาด",
|
|
|
|
|
|
COMMENT: "คอมเมนต์",
|
|
|
|
|
|
SHAPE: "รูปทรง",
|
|
|
|
|
|
REDACTION: "ปิดพราง",
|
|
|
|
|
|
TEXTBOX: "กล่องข้อความ",
|
|
|
|
|
|
STAMP: "ตรายาง",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** ลำดับชนิดในบรรทัดสรุป/แผงตัวกรอง — ตามตำแหน่ง key ของ TYPE_SHORT (เรียงให้ตรง
|
|
|
|
|
|
* ลำดับ enum) ให้ deterministic ไม่ขึ้นกับลำดับสร้างรายการ */
|
|
|
|
|
|
export const TYPE_ORDER = Object.keys(TYPE_SHORT) as AnnotationType[];
|
|
|
|
|
|
|
Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:29:40 +07:00
|
|
|
|
/** AnnotationDTO เป็น flat type (type/data แยกกัน) → แยกตาม type ก่อนแคสต์ data */
|
|
|
|
|
|
export function annotationTypeLabel(a: AnnotationDTO): string {
|
|
|
|
|
|
switch (a.type) {
|
|
|
|
|
|
case "HIGHLIGHT":
|
|
|
|
|
|
return HIGHLIGHT_STYLE_LABELS[(a.data as HighlightData).style ?? "highlight"];
|
|
|
|
|
|
case "DRAW":
|
|
|
|
|
|
return "เส้นวาด";
|
|
|
|
|
|
case "COMMENT":
|
|
|
|
|
|
return "คอมเมนต์";
|
|
|
|
|
|
case "SHAPE":
|
|
|
|
|
|
return `รูปทรง (${SHAPE_LABELS[(a.data as ShapeData).shape]})`;
|
|
|
|
|
|
case "REDACTION":
|
|
|
|
|
|
return "ปิดพราง";
|
|
|
|
|
|
case "TEXTBOX":
|
|
|
|
|
|
return "กล่องข้อความ";
|
|
|
|
|
|
case "STAMP":
|
|
|
|
|
|
return "ตรายาง";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** ข้อความตัวอย่างของแต่ละ annotation ตามประเภท */
|
|
|
|
|
|
export function annotationPreviewText(a: AnnotationDTO): string {
|
|
|
|
|
|
switch (a.type) {
|
|
|
|
|
|
case "HIGHLIGHT":
|
|
|
|
|
|
return (a.data as HighlightData).text || "(ไม่มีข้อความ)";
|
|
|
|
|
|
case "DRAW":
|
|
|
|
|
|
return "เส้นวาดอิสระ";
|
|
|
|
|
|
case "COMMENT":
|
|
|
|
|
|
return (a.data as CommentData).message;
|
|
|
|
|
|
case "SHAPE":
|
|
|
|
|
|
return `รูปทรง: ${SHAPE_LABELS[(a.data as ShapeData).shape]}`;
|
|
|
|
|
|
case "REDACTION":
|
|
|
|
|
|
return "ปิดพราง";
|
|
|
|
|
|
case "TEXTBOX":
|
|
|
|
|
|
return (a.data as TextboxData).text;
|
|
|
|
|
|
case "STAMP":
|
|
|
|
|
|
return (a.data as StampData).label;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** โน้ตคำอธิบายของ annotation เช่น เหตุผลที่วาดรายการนี้ — ว่าง = "" (COMMENT ไม่มี note) */
|
|
|
|
|
|
export function annotationNote(a: AnnotationDTO): string {
|
|
|
|
|
|
return "note" in a.data && a.data.note ? a.data.note : "";
|
|
|
|
|
|
}
|
2026-09-17 17:02:31 +07:00
|
|
|
|
|
|
|
|
|
|
// ─── สรุปสิ่งที่เปลี่ยนของ version history (F19 — dialog ประวัติใน sidebar) ────
|
|
|
|
|
|
|
|
|
|
|
|
/** ชื่อไทยของสี preset ที่เลือกได้ใน UI (INK_COLORS ของ ToolControls) + default
|
|
|
|
|
|
* ต่อชนิดที่ไม่อยู่ใน palette (STAMP_COLOR) — สีอื่นจากปุ่มสีรุ้งไม่มีชื่อ
|
|
|
|
|
|
* แสดง hex ตรง ๆ */
|
|
|
|
|
|
const COLOR_NAMES: Record<string, string> = {
|
|
|
|
|
|
"#ef4444": "แดง",
|
|
|
|
|
|
"#f97316": "ส้ม",
|
|
|
|
|
|
"#fde047": "เหลือง",
|
|
|
|
|
|
"#22c55e": "เขียว",
|
|
|
|
|
|
"#14b8a6": "เขียวอมฟ้า",
|
|
|
|
|
|
"#3b82f6": "น้ำเงิน",
|
|
|
|
|
|
"#8b5cf6": "ม่วง",
|
|
|
|
|
|
"#ec4899": "ชมพู",
|
|
|
|
|
|
"#b45309": "น้ำตาล",
|
|
|
|
|
|
"#111827": "ดำ",
|
|
|
|
|
|
"#6b7280": "เทา",
|
|
|
|
|
|
"#b91c1c": "แดงเข้ม",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-22 11:48:53 +07:00
|
|
|
|
/** export ให้ ToolControls ใช้ทำ aria-label/title ของปุ่มสีเป็นชื่อไทยด้วย (UX audit 2026-09-22) */
|
|
|
|
|
|
export function colorName(c: string): string {
|
2026-09-17 17:02:31 +07:00
|
|
|
|
return COLOR_NAMES[c.toLowerCase()] ?? c;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const ALIGN_LABELS: Record<NonNullable<TextboxData["align"]>, string> = {
|
|
|
|
|
|
left: "ชิดซ้าย",
|
|
|
|
|
|
center: "กึ่งกลาง",
|
|
|
|
|
|
right: "ชิดขวา",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** ตัดข้อความให้สั้นพอบรรทัดสรุปใน dialog ประวัติ */
|
|
|
|
|
|
function shortChangeText(s: string): string {
|
|
|
|
|
|
const t = s.replace(/\s+/g, " ").trim();
|
|
|
|
|
|
return t.length > 24 ? `${t.slice(0, 24)}…` : t;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** ความทึบ 0–1 → % ตามหน่วยของตัวปรับความทึบใน UI */
|
|
|
|
|
|
function opacityLabel(o: number): string {
|
|
|
|
|
|
return `${Math.round(o * 100)}%`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** ตัวเลขทศนิยม → ข้อความไม่มีศูนย์ท้าย (0.4 → "0.4", 2.5 → "2.5") */
|
|
|
|
|
|
function numLabel(n: number): string {
|
|
|
|
|
|
return String(Number(n.toFixed(2)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** fontSize (% ของความกว้างหน้า) → ตัวเลข px ตามตัวปรับของ UI (ฐานกว้าง A4 ≈ 595) */
|
|
|
|
|
|
function fontSizePxLabel(fontSizePct: number): string {
|
|
|
|
|
|
return `${Math.round((fontSizePct / 100) * 595)}px`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** bbox เปลี่ยนเกินเท่าไรจึงถือว่าย้าย/ปรับขนาด (% ของหน้า — ต่ำกว่านี้เป็น
|
|
|
|
|
|
* noise ทศนิยมของพิกัดที่ผ่าน px⇄% มาแล้ว) */
|
|
|
|
|
|
const GEOM_EPS_PCT = 0.5;
|
|
|
|
|
|
|
|
|
|
|
|
/** โน้ตของ annotation data เดี่ยว (ไม่ผ่าน DTO) — COMMENT/ไม่มี = "" (อ่านแบบ
|
|
|
|
|
|
* เดียวกับ annotationNote: JSONB ตัด undefined ทิ้ง แถวที่ไม่เคยมีโน้ตจึง
|
|
|
|
|
|
* ไม่มี key เลย ใช้ in-check ก่อนแตะ field) */
|
|
|
|
|
|
function changeNote(d: AnnotationData): string {
|
|
|
|
|
|
return "note" in d && d.note ? d.note : "";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** "ย้ายตำแหน่ง"/"ปรับขนาด" จากการเทียบ bbox ของ prev/next (annotationBBox บน
|
|
|
|
|
|
* size 100×100 = ผลลัพธ์หน่วย % โดยตรง) */
|
|
|
|
|
|
function geometryChangeLabels(
|
|
|
|
|
|
type: AnnotationType,
|
|
|
|
|
|
prev: AnnotationData,
|
|
|
|
|
|
next: AnnotationData,
|
|
|
|
|
|
): string[] {
|
|
|
|
|
|
const a = annotationBBox({ type, data: prev }, { width: 100, height: 100 });
|
|
|
|
|
|
const b = annotationBBox({ type, data: next }, { width: 100, height: 100 });
|
|
|
|
|
|
const out: string[] = [];
|
|
|
|
|
|
if (Math.abs(a.x - b.x) > GEOM_EPS_PCT || Math.abs(a.y - b.y) > GEOM_EPS_PCT) {
|
|
|
|
|
|
out.push("ย้ายตำแหน่ง");
|
|
|
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
|
|
|
Math.abs(a.width - b.width) > GEOM_EPS_PCT ||
|
|
|
|
|
|
Math.abs(a.height - b.height) > GEOM_EPS_PCT
|
|
|
|
|
|
) {
|
|
|
|
|
|
out.push("ปรับขนาด");
|
|
|
|
|
|
}
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* สรุปสิ่งที่เปลี่ยนจาก snapshot เดิม (prev — ข้อมูล "ก่อนแก้" ที่ตาราง
|
|
|
|
|
|
* annotation_versions เก็บทุกรอบ) เป็น state ถัดไป (next — snapshot ของรอบแก้
|
|
|
|
|
|
* ที่ใหม่กว่า หรือค่าปัจจุบันของรายการสำหรับรอบล่าสุด) — ใช้ใน dialog
|
|
|
|
|
|
* ประวัติการเปลี่ยนแปลง (F19) หน่วยที่แสดงตรงกับตัวปรับของ UI เสมอ
|
|
|
|
|
|
* (ความหนา ×10, ความทึบเป็น %) — คืน [] เมื่อไม่มีอะไรที่สรุปได้เปลี่ยน
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function describeAnnotationChange(
|
|
|
|
|
|
type: AnnotationType,
|
|
|
|
|
|
prev: AnnotationData,
|
|
|
|
|
|
next: AnnotationData,
|
|
|
|
|
|
): string[] {
|
|
|
|
|
|
const changes: string[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
// โน้ตคำอธิบาย (N1) — มีทุกชนิดยกเว้น COMMENT (message ทำหน้าที่นี้อยู่แล้ว)
|
|
|
|
|
|
const prevNote = changeNote(prev);
|
|
|
|
|
|
const nextNote = changeNote(next);
|
|
|
|
|
|
if (prevNote !== nextNote) {
|
|
|
|
|
|
if (!prevNote) changes.push(`เพิ่มโน้ต: ${shortChangeText(nextNote)}`);
|
|
|
|
|
|
else if (!nextNote) changes.push("ลบโน้ต");
|
|
|
|
|
|
else changes.push(`แก้โน้ต: ${shortChangeText(prevNote)} → ${shortChangeText(nextNote)}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ("rects" in prev && "rects" in next) {
|
|
|
|
|
|
// HIGHLIGHT — แก้ได้แค่สี/รูปแบบ/ความทึบ/โน้ต (ไม่มี handle ย้าย/ขยาย)
|
|
|
|
|
|
if (prev.color !== next.color) {
|
|
|
|
|
|
changes.push(`สี: ${colorName(prev.color)} → ${colorName(next.color)}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
const ps = prev.style ?? "highlight";
|
|
|
|
|
|
const ns = next.style ?? "highlight";
|
|
|
|
|
|
if (ps !== ns) {
|
|
|
|
|
|
changes.push(`รูปแบบ: ${HIGHLIGHT_STYLE_LABELS[ps]} → ${HIGHLIGHT_STYLE_LABELS[ns]}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
const po = prev.opacity ?? 1;
|
|
|
|
|
|
const no = next.opacity ?? 1;
|
|
|
|
|
|
if (po !== no) changes.push(`ความทึบ: ${opacityLabel(po)} → ${opacityLabel(no)}`);
|
|
|
|
|
|
} else if ("paths" in prev && "paths" in next) {
|
|
|
|
|
|
// DRAW — สี/ความหนา/ปากกา/ความทึบ + ย้าย/ขยาย หรือ "แก้เส้น" (eraser ตัดจุด
|
|
|
|
|
|
// โดย bbox เดิม)
|
|
|
|
|
|
if (prev.strokeColor !== next.strokeColor) {
|
|
|
|
|
|
changes.push(`สี: ${colorName(prev.strokeColor)} → ${colorName(next.strokeColor)}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (prev.strokeWidth !== next.strokeWidth) {
|
|
|
|
|
|
changes.push(
|
|
|
|
|
|
`ความหนา: ${numLabel(prev.strokeWidth * 10)} → ${numLabel(next.strokeWidth * 10)}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
const pp = prev.pen ?? "pencil";
|
|
|
|
|
|
const np = next.pen ?? "pencil";
|
|
|
|
|
|
if (pp !== np) changes.push(`ปากกา: ${DRAW_PENS[pp].label} → ${DRAW_PENS[np].label}`);
|
|
|
|
|
|
const eo = effectiveDrawOpacity(prev);
|
|
|
|
|
|
const en = effectiveDrawOpacity(next);
|
|
|
|
|
|
if (eo !== en) changes.push(`ความทึบ: ${opacityLabel(eo)} → ${opacityLabel(en)}`);
|
|
|
|
|
|
const geom = geometryChangeLabels(type, prev, next);
|
|
|
|
|
|
if (geom.length > 0) changes.push(...geom);
|
|
|
|
|
|
else if (JSON.stringify(prev.paths) !== JSON.stringify(next.paths)) changes.push("แก้เส้น");
|
|
|
|
|
|
} else if ("message" in prev && "message" in next) {
|
|
|
|
|
|
// COMMENT — แก้ข้อความ (F13) / ย้าย pin
|
|
|
|
|
|
if (prev.message !== next.message) {
|
|
|
|
|
|
changes.push(
|
|
|
|
|
|
`แก้ข้อความ: "${shortChangeText(prev.message)}" → "${shortChangeText(next.message)}"`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
changes.push(...geometryChangeLabels(type, prev, next));
|
|
|
|
|
|
} else if ("shape" in prev && "shape" in next) {
|
|
|
|
|
|
// SHAPE — สี/ความหนา + ย้าย/ขยาย
|
|
|
|
|
|
if (prev.strokeColor !== next.strokeColor) {
|
|
|
|
|
|
changes.push(`สี: ${colorName(prev.strokeColor)} → ${colorName(next.strokeColor)}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (prev.strokeWidth !== next.strokeWidth) {
|
|
|
|
|
|
changes.push(
|
|
|
|
|
|
`ความหนา: ${numLabel(prev.strokeWidth * 10)} → ${numLabel(next.strokeWidth * 10)}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
changes.push(...geometryChangeLabels(type, prev, next));
|
|
|
|
|
|
} else if ("rect" in prev && "rect" in next) {
|
|
|
|
|
|
// REDACTION — ไม่มีสี/ความหนาให้แก้ มีแต่ตำแหน่ง/ขนาด/โน้ต
|
|
|
|
|
|
changes.push(...geometryChangeLabels(type, prev, next));
|
|
|
|
|
|
} else if ("fontSize" in prev && "fontSize" in next) {
|
|
|
|
|
|
// TEXTBOX — ข้อความ/สี/ขนาดฟอนต์/ตัวหนา/ความทึบ/จัดแนว + ย้าย/ปรับความกว้าง
|
|
|
|
|
|
const textChanged = prev.text !== next.text;
|
|
|
|
|
|
if (textChanged) {
|
|
|
|
|
|
changes.push(
|
|
|
|
|
|
`แก้ข้อความ: "${shortChangeText(prev.text)}" → "${shortChangeText(next.text)}"`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (prev.color !== next.color) {
|
|
|
|
|
|
changes.push(`สี: ${colorName(prev.color)} → ${colorName(next.color)}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (prev.fontSize !== next.fontSize) {
|
|
|
|
|
|
changes.push(
|
|
|
|
|
|
`ขนาดตัวอักษร: ${fontSizePxLabel(prev.fontSize)} → ${fontSizePxLabel(next.fontSize)}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
if ((prev.bold ?? false) !== (next.bold ?? false)) {
|
|
|
|
|
|
changes.push(`ตัวหนา: ${prev.bold ? "หนา" : "ปกติ"} → ${next.bold ? "หนา" : "ปกติ"}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
const po = prev.opacity ?? 1;
|
|
|
|
|
|
const no = next.opacity ?? 1;
|
|
|
|
|
|
if (po !== no) changes.push(`ความทึบ: ${opacityLabel(po)} → ${opacityLabel(no)}`);
|
|
|
|
|
|
const pa = prev.align ?? "left";
|
|
|
|
|
|
const na = next.align ?? "left";
|
|
|
|
|
|
if (pa !== na) changes.push(`จัดแนว: ${ALIGN_LABELS[pa]} → ${ALIGN_LABELS[na]}`);
|
|
|
|
|
|
// ตัวหนาบางส่วน (segments) — แจ้งเฉพาะเมื่อข้อความรวมไม่เปลี่ยน
|
|
|
|
|
|
// (text เปลี่ยนแล้วกล่าวถึงการแก้ข้อความพอ)
|
|
|
|
|
|
if (
|
|
|
|
|
|
!textChanged &&
|
|
|
|
|
|
JSON.stringify(prev.segments ?? null) !== JSON.stringify(next.segments ?? null)
|
|
|
|
|
|
) {
|
|
|
|
|
|
changes.push("แก้ตัวหนาบางส่วน");
|
|
|
|
|
|
}
|
|
|
|
|
|
// ตำแหน่ง/ขนาด — ข้าม "ปรับขนาด" เมื่อข้อความเปลี่ยน (กล่อง shrink-to-fit
|
|
|
|
|
|
// ปรับความกว้าง/สูงตามข้อความเป็นผลพลอง ไม่ใช่การกระทำของผู้ใช้)
|
|
|
|
|
|
for (const g of geometryChangeLabels(type, prev, next)) {
|
|
|
|
|
|
if (!textChanged || g === "ย้ายตำแหน่ง") changes.push(g);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if ("scale" in prev && "scale" in next) {
|
|
|
|
|
|
// STAMP — ข้อความ/สี (fallback STAMP_COLOR)/ขนาด + ย้าย/ขยาย
|
|
|
|
|
|
if (prev.label !== next.label) {
|
|
|
|
|
|
changes.push(
|
|
|
|
|
|
`ข้อความตรายาง: "${shortChangeText(prev.label)}" → "${shortChangeText(next.label)}"`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
const pc = prev.color ?? STAMP_COLOR;
|
|
|
|
|
|
const nc = next.color ?? STAMP_COLOR;
|
|
|
|
|
|
if (pc !== nc) changes.push(`สี: ${colorName(pc)} → ${colorName(nc)}`);
|
|
|
|
|
|
if (prev.scale !== next.scale) {
|
|
|
|
|
|
changes.push(`ขนาด: ${numLabel(prev.scale)}% → ${numLabel(next.scale)}%`);
|
|
|
|
|
|
}
|
|
|
|
|
|
changes.push(...geometryChangeLabels(type, prev, next));
|
|
|
|
|
|
}
|
|
|
|
|
|
return changes;
|
|
|
|
|
|
}
|