bookmark: ดักความยาวชื่อ/โน้ตที่ช่องกรอก + F19 สรุปสิ่งที่เปลี่ยนใน dialog ประวัติ + formatThaiDate — (1) bookmark limits: utils/bookmark-limits.ts ใหม่เป็น source of truth เดียว (ชื่อ 120/โน้ต 200 ตัวอักษร) server action import ร่วมแทน const ท้องถิ่น + ใส่ maxLength ที่ popup บันทึก bookmark (PDFViewer) และฟอร์มแก้ไข ✏ ใน sidebar ดักทั้งพิมพ์/paste ตั้งแต่ในช่อง ไม่รอ error ตอนกดบันทึก + แก้ off-by-one ของ normalizeBookmarkTitle (เดิม slice 120 แล้วต่อ … = 121 ตัว ทำให้คลุมข้อความยาวแล้วกด B บันทึกเลยโดน limit ของ server ตีกลับทั้งที่ไม่ได้พิมพ์เอง — ตอนนี้ slice 119 + … = 120 พอดีตามสเปก §7 N2 ที่เขียนไว้ ≤ 120 อยู่แล้ว) + PLAN-FEAT.md §7 N2 บันทึกเพิ่ม — (2) งานคู่ขนาดจากอีก session (แชร์ไฟล์ PDFViewer/sidebar เลยรวม commit): dialog ประวัติ F19 แสดงสรุปสิ่งที่เปลี่ยนต่อรอบผ่าน describeAnnotationChange ใหม่ใน utils/annotation-display.ts (เทียบ snapshot ก่อนแก้ v.data กับ state ถัดไป — snapshot รอบใหม่กว่า หรือค่าปัจจุบันจาก realtime lookup สด historyCurrent — สรุปเป็นหน่วยเดียวกับตัวปรับ UI: ความหนา ×10 ความทึบ % fontSize px ชื่อสี preset ย้าย/ปรับขนาดจาก bbox ผลต่างเกิน GEOM_EPS_PCT 0.5% ข้อความ/โน้ต/label) + dialog ขยาย w-96 max-h-[60vh] แถวเปลี่ยน items-start + utils/date-format.ts ใหม่ formatThaiDate วันที่ไทยเต็ม "18 กันยายน พ.ศ. 2569" (+เวลา) แทน toLocaleString("th-TH") ทุกจุดแสดงวันที่ใน UI (หน้ารายการไฟล์ sidebar ประวัติ/รายการ timestamps ใน PDFViewer) กัน server/client locale ไม่ตรงกัน + tests ใหม่/ขยาย (date-format + describeAnnotationChange) — CLAUDE.md sync โครงสร้าง utils — verify: tsc strict ผ่าน + vitest 139/139 ผ่าน
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 43s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 3s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 43s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 3s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
8ba911a119
commit
e0c78d68fe
11 changed files with 469 additions and 38 deletions
|
|
@ -41,7 +41,8 @@ lib/server-pdf.ts ← path guard อ่าน PDF จาก public/ (export
|
|||
lib/session.ts ← getSessionUser — identity จาก cookie oacCase (mock login e-Court)
|
||||
utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine
|
||||
utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %)
|
||||
utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + ค่า default TEXTBOX/STAMP (sidebar/สรุป/CSV ใช้ร่วม)
|
||||
utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/CSV ใช้ร่วม)
|
||||
utils/date-format.ts ← formatThaiDate วันที่ไทยแบบเต็ม "18 กันยายน พ.ศ. 2569" (+เวลา "… 16:33:00 น.") — ทุกจุดที่แสดงวันที่ใน UI ใช้ร่วม (เว้น export CSV ที่ lock Asia/Bangkok)
|
||||
utils/pdf-export.ts ← แปลงพิกัดจอ⇄PDF (/Rotate) + wrapText + hexToRgb (export route)
|
||||
utils/user.ts ← parse/build ค่า cookie oacCase → OacUser {userId: u_userid, userName: u_userlogin} (pure module — middleware ใช้ร่วมได้)
|
||||
utils/base-path.ts ← withBasePath() เติม basePath /docedit/note ให้ URL ฝั่ง browser ที่ Next ไม่เติมให้เอง (ค่าจาก next.config.mjs ผ่าน env — invariant #12)
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ import {
|
|||
type StampData,
|
||||
type TextboxData,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
MAX_BOOKMARK_NOTE_CHARS,
|
||||
MAX_BOOKMARK_TITLE_CHARS,
|
||||
} from "@/utils/bookmark-limits";
|
||||
|
||||
// ─── Input types (client ใช้ตอนเรียก action) ────────────────────────────────
|
||||
|
||||
|
|
@ -88,8 +92,6 @@ const SHAPE_KINDS: readonly string[] = ["rect", "ellipse", "line", "arrow"];
|
|||
// ตรง ๆ ด้วยข้อความขนาดหลาย MB จน JSONB บวมและ SSE event ใหญ่ผิดปกติ
|
||||
const MAX_USER_NAME_CHARS = 100;
|
||||
const MAX_TEXT_CHARS = 5_000; // message/text/note/label ของ annotation + reply
|
||||
const MAX_BOOKMARK_TITLE_CHARS = 120; // ให้ตรงค่าที่ UI ตัดไว้
|
||||
const MAX_BOOKMARK_NOTE_CHARS = 200;
|
||||
const MAX_QUERY_CHARS = 200; // คำค้นหา (F14)
|
||||
|
||||
function fieldError(field: string, expect: string): never {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { files } from "@/db/schema";
|
|||
import SessionBar from "@/components/SessionBar";
|
||||
import UploadFileForm from "@/components/UploadFileForm";
|
||||
import { imageFormatFromUrl } from "@/utils/file-kind";
|
||||
import { formatThaiDate } from "@/utils/date-format";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
@ -95,7 +96,7 @@ export default async function HomePage() {
|
|||
<span className="mr-1.5 rounded bg-slate-100 px-1 py-0.5 font-medium text-slate-500">
|
||||
{kindBadge(f.pdfUrl)}
|
||||
</span>
|
||||
{new Date(f.createdAt).toLocaleString("th-TH")}
|
||||
{formatThaiDate(f.createdAt, true)}
|
||||
</span>
|
||||
</span>
|
||||
{/* ปุ่มเข้า annotate แทนลิงก์กางเต็มการ์ด (M4) ตาม feedback — เป้าจิ้ม 36px (H4) */}
|
||||
|
|
|
|||
|
|
@ -114,8 +114,13 @@ import {
|
|||
type Size,
|
||||
} from "@/utils/coordinates";
|
||||
import type { OacUser } from "@/utils/user";
|
||||
import {
|
||||
MAX_BOOKMARK_NOTE_CHARS,
|
||||
MAX_BOOKMARK_TITLE_CHARS,
|
||||
} from "@/utils/bookmark-limits";
|
||||
import { fileKindFromUrl, imageFormatFromUrl } from "@/utils/file-kind";
|
||||
import { withBasePath } from "@/utils/base-path";
|
||||
import { formatThaiDate } from "@/utils/date-format";
|
||||
import { decodeTiffPages } from "@/utils/tiff-client";
|
||||
import {
|
||||
TEXTBOX_DEFAULT_WIDTH_PCT,
|
||||
|
|
@ -196,10 +201,13 @@ const HIGHLIGHT_COLORS = ["#fde047", "#86efac", "#93c5fd", "#f9a8d4", "#fdba74"]
|
|||
|
||||
/** ชื่อ bookmark ที่มาจากข้อความ (คลุม/ข้อความรายการ) — ล้างขยะของ textLayer ไทย
|
||||
* (sanitizePdfText ครอบด้วยข้อความจาก DB เก่าที่ยังไม่ผ่านการล้าง) ยุบ whitespace
|
||||
* และตัดยาวไม่เกิน 120 ตัวอักษร */
|
||||
* และตัดยาวไม่เกิน MAX_BOOKMARK_TITLE_CHARS ตัวอักษร (รวม … ที่ต่อท้าย —
|
||||
* เดิม slice 120 แล้วต่อ … กลายเป็น 121 ตัว โดน limit ของ server ตีกลับตอนบันทึก) */
|
||||
function normalizeBookmarkTitle(s: string): string {
|
||||
const t = sanitizePdfText(s).replace(/\s+/g, " ").trim();
|
||||
return t.length > 120 ? `${t.slice(0, 120)}…` : t;
|
||||
return t.length > MAX_BOOKMARK_TITLE_CHARS
|
||||
? `${t.slice(0, MAX_BOOKMARK_TITLE_CHARS - 1)}…`
|
||||
: t;
|
||||
}
|
||||
|
||||
/** ความสูง sticky header ของ viewer — ใช้เว้นระยะเวลาเลื่อนหน้าไม่ให้เป้าหมายถูก header บัง */
|
||||
|
|
@ -492,12 +500,7 @@ function ReplyBox({
|
|||
<p className="text-[11px] font-medium text-slate-600">
|
||||
{r.userName}
|
||||
<span className="ml-1 font-normal text-slate-400" suppressHydrationWarning>
|
||||
{new Date(r.createdAt).toLocaleString("th-TH", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
})}
|
||||
{formatThaiDate(r.createdAt, true)}
|
||||
</span>
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap break-words text-[11px]">{r.message}</p>
|
||||
|
|
@ -1075,7 +1078,7 @@ function PdfPage({
|
|||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-[11px] text-slate-400" suppressHydrationWarning>
|
||||
{new Date(a.createdAt).toLocaleString("th-TH")}
|
||||
{formatThaiDate(a.createdAt, true)}
|
||||
</p>
|
||||
{/* F10/A5: toggle จัดการแล้ว — ใครก็ได้ (D6) */}
|
||||
<button
|
||||
|
|
@ -3550,6 +3553,7 @@ export default function PDFViewer({
|
|||
<input
|
||||
autoFocus
|
||||
onFocus={(e) => e.target.select()} // เลือกข้อความ default ทั้งหมด — พิมพ์ทับ/Enter ยอมรับได้ทันที
|
||||
maxLength={MAX_BOOKMARK_TITLE_CHARS}
|
||||
value={bookmarkComposer.title}
|
||||
onChange={(e) =>
|
||||
setBookmarkComposer({ ...bookmarkComposer, title: e.target.value })
|
||||
|
|
@ -3564,6 +3568,7 @@ export default function PDFViewer({
|
|||
/>
|
||||
<textarea
|
||||
rows={2}
|
||||
maxLength={MAX_BOOKMARK_NOTE_CHARS}
|
||||
value={bookmarkComposer.note}
|
||||
onChange={(e) =>
|
||||
setBookmarkComposer({ ...bookmarkComposer, note: e.target.value })
|
||||
|
|
|
|||
|
|
@ -51,8 +51,17 @@ import type {
|
|||
RedactionData,
|
||||
ShapeData,
|
||||
} from "@/db/schema";
|
||||
import { annotationNote, annotationPreviewText } from "@/utils/annotation-display";
|
||||
import {
|
||||
annotationNote,
|
||||
annotationPreviewText,
|
||||
describeAnnotationChange,
|
||||
} from "@/utils/annotation-display";
|
||||
import { withBasePath } from "@/utils/base-path";
|
||||
import {
|
||||
MAX_BOOKMARK_NOTE_CHARS,
|
||||
MAX_BOOKMARK_TITLE_CHARS,
|
||||
} from "@/utils/bookmark-limits";
|
||||
import { formatThaiDate } from "@/utils/date-format";
|
||||
|
||||
/** UX audit 2026-09-16: focus ring ตอน keyboard focus — ค่าเดียวกับ FOCUS_RING_CLS
|
||||
* ของ PDFViewer.tsx (ห้าม import ข้ามไฟล์: PDFViewer → sidebar → PDFViewer เป็น
|
||||
|
|
@ -455,6 +464,12 @@ export default function AnnotationSidebar({
|
|||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [historyFor]);
|
||||
|
||||
// state ปัจจุบันของรายการที่เปิดประวัติอยู่ — lookup สดจาก realtime ก่อน
|
||||
// (SSE update หลังเปิด dialog ก็ยังเทียบกับค่าล่าสุดได้) โดย fallback เป็น
|
||||
// snapshot ตอนเปิด (รายการจากผลค้นหา F14 ไม่อยู่ใน prop annotations)
|
||||
const historyCurrent =
|
||||
historyFor && (annotations.find((a) => a.id === historyFor.id) ?? historyFor);
|
||||
|
||||
// F10/A5: แยกกลุ่มเฉพาะ COMMENT — "ค้างดำเนินการ" (รวม annotation ชนิดอื่น)
|
||||
// กับ "จัดการแล้ว" (comment ที่ถูก resolve)
|
||||
const pendingItems = annotations.filter((a) => a.type !== "COMMENT" || !a.resolved);
|
||||
|
|
@ -773,6 +788,7 @@ export default function AnnotationSidebar({
|
|||
<div className={`rounded py-1 ${pad}`}>
|
||||
<input
|
||||
autoFocus
|
||||
maxLength={MAX_BOOKMARK_TITLE_CHARS}
|
||||
value={eb.title}
|
||||
onChange={(e) => setEditBookmark({ ...eb, title: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
|
|
@ -785,6 +801,7 @@ export default function AnnotationSidebar({
|
|||
/>
|
||||
<textarea
|
||||
rows={2}
|
||||
maxLength={MAX_BOOKMARK_NOTE_CHARS}
|
||||
value={eb.note}
|
||||
onChange={(e) => setEditBookmark({ ...eb, note: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
|
|
@ -1516,7 +1533,7 @@ export default function AnnotationSidebar({
|
|||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="ประวัติการเปลี่ยนแปลง"
|
||||
className="w-80 rounded-lg border border-slate-200 bg-white p-4 shadow-xl"
|
||||
className="w-96 max-w-full rounded-lg border border-slate-200 bg-white p-4 shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="text-sm font-semibold">ประวัติการเปลี่ยนแปลง</p>
|
||||
|
|
@ -1537,27 +1554,46 @@ export default function AnnotationSidebar({
|
|||
</p>
|
||||
)}
|
||||
{versions !== null && versions.length > 0 && (
|
||||
<ul className="mt-3 max-h-56 divide-y divide-slate-100 overflow-y-auto">
|
||||
{versions.map((v) => (
|
||||
<li key={v.id} className="flex items-center gap-2 py-2">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-xs font-medium">
|
||||
{v.kind === "delete" ? "ถูกลบ" : "ถูกแก้ไข"}
|
||||
<ul className="mt-3 max-h-[60vh] divide-y divide-slate-100 overflow-y-auto">
|
||||
{versions.map((v, i) => {
|
||||
// สิ่งที่เปลี่ยนของรอบนี้ = snapshot ก่อนแก้ (v.data) เทียบ state
|
||||
// ถัดไป: snapshot ของรอบแก้ที่ใหม่กว่าถัดไป (i-1) หรือค่าปัจจุบัน
|
||||
// ของรายการ (version ใหม่สุด) — แถว "ถูกลบ" เป็น snapshot สุดท้าย
|
||||
// ก่อนลบ ไม่มี state ต่อจากนั้น จึงไม่ diff
|
||||
const changes =
|
||||
v.kind === "update" && historyCurrent
|
||||
? describeAnnotationChange(
|
||||
v.type,
|
||||
v.data,
|
||||
i === 0 ? historyCurrent.data : versions[i - 1].data,
|
||||
)
|
||||
: [];
|
||||
return (
|
||||
<li key={v.id} className="flex items-start gap-2 py-2">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-xs font-medium">
|
||||
{v.kind === "delete" ? "ถูกลบ" : "ถูกแก้ไข"}
|
||||
</span>
|
||||
<span className="block text-[11px] text-slate-500" suppressHydrationWarning>
|
||||
{formatThaiDate(v.createdAt, true)} · โดย {v.userName}
|
||||
</span>
|
||||
{changes.map((c) => (
|
||||
<span key={c} className="mt-0.5 block text-[11px] text-slate-600">
|
||||
{c}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="block text-[11px] text-slate-500" suppressHydrationWarning>
|
||||
{new Date(v.createdAt).toLocaleString("th-TH")}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={restoringId !== null}
|
||||
className={`shrink-0 rounded border border-slate-300 px-2 py-1 text-[11px] text-slate-600 hover:bg-slate-100 disabled:opacity-40 ${FOCUS_RING_CLS}`}
|
||||
onClick={() => void handleRestore(v.id)}
|
||||
>
|
||||
{restoringId === v.id ? "กำลังกู้คืน…" : "กู้คืน"}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
disabled={restoringId !== null}
|
||||
className={`mt-0.5 shrink-0 rounded border border-slate-300 px-2 py-1 text-[11px] text-slate-600 hover:bg-slate-100 disabled:opacity-40 ${FOCUS_RING_CLS}`}
|
||||
onClick={() => void handleRestore(v.id)}
|
||||
>
|
||||
{restoringId === v.id ? "กำลังกู้คืน…" : "กู้คืน"}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<div className="mt-3 flex justify-end text-xs">
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ Index: `(file_id)`
|
|||
|
||||
- `updateAnnotation` → snapshot row เดิมเป็น version `kind='update'` **ก่อนเขียนทับ** (transaction เดียวกัน); `deleteAnnotation` → snapshot `kind='delete'` ก่อนลบ (D8 ทำให้ version รอดจาก CASCADE)
|
||||
- Action `listVersions(annotationId)` + `restoreVersion(versionId)` — **ถ้า annotation ต้นทางยังอยู่: เขียนข้อมูลของ version กลับ row เดิม** (snapshot สถานะปัจจุบันก่อน เหมือน update ปกติ → ย้อนกลับไป-มาได้) + publish update / **ถ้าต้นทางถูกลบแล้ว (kind='delete' รอดจาก CASCADE ตาม D8): สร้าง annotation ใหม่ id ใหม่** + publish create (เดิมสร้างสำเนาใหม่ทุกกรณีจนเกิดรายการซ้ำบนหน้า)
|
||||
- UI: ปุ่ม "ประวัติ" ใน sidebar (เฉพาะรายการของตัวเอง) → dialog รายการ versions (เวลา + ชนิดการเปลี่ยนแปลง) + ปุ่มกู้คืน
|
||||
- UI: ปุ่ม "ประวัติ" ใน sidebar (เฉพาะรายการของตัวเอง) → dialog รายการ versions (เวลา + ชนิดการเปลี่ยนแปลง + ผู้แก้) + ปุ่มกู้คืน — แต่ละแถว kind='update' แสดง**สรุปสิ่งที่เปลี่ยน**ผ่าน `describeAnnotationChange` (`utils/annotation-display.ts` — เพิ่มตามคำขอผู้ใช้ 2026-09-17): snapshot "ก่อนแก้" ของรอบนั้นเทียบกับ state ถัดไป (= snapshot ของรอบแก้ที่ใหม่กว่าถัดไป หรือค่าปัจจุบันของรายการสำหรับรอบล่าสุด) — สรุปเป็นบรรทัดสั้นต่อ field ที่เปลี่ยน (สีเป็นชื่อไทยจาก palette/ความหนา ×10/ความทึบ % ตามหน่วย UI, แก้ข้อความ/โน้ต, ย้ายตำแหน่ง/ปรับขนาดจาก bbox ทน noise 0.5%, DRAW ที่จุดเปลี่ยนแต่ bbox เดิม = "แก้เส้น" ของ eraser) ส่วนแถว kind='delete' เป็น snapshot สุดท้ายก่อนลบ ไม่ diff
|
||||
|
||||
### ฟีเจอร์เล็กที่เหลือ (สเปกย่อ — รายละเอียดการทำงานเต็มอยู่ในตาราง §4)
|
||||
|
||||
|
|
@ -280,7 +280,7 @@ 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 ทุกครั้ง** — ชื่อ (pre-fill default ไว้ในช่อง + focus เลือกข้อความทั้งหมด = พิมพ์ทับ/Enter ยอมรับได้ทันที; ล้างแล้วเว้นว่าง = "หน้า N" ตอนบันทึก) + โน้ต (ไม่บังคับ) + บรรทัดบอก**จุดที่จะชี้** (หน้า · ส่วนบน/ราวกลาง/ส่วนล่างของหน้า — เก็บจากตำแหน่งที่ viewer กำลังดูตอนเปิด popup กันเข้าใจผิดว่าชี้ที่หัวข้อที่ตั้งชื่อ) + select "วางที่" (ระดับบนสุด / ใต้หัวข้อระดับ 1–2 — ใครก็วางใต้หัวข้อของคนอื่นได้) — ปิดด้วย บันทึก/ยกเลิก/Enter(title)/Esc/คลิกหน้า · ทางลัด: ปุ่ม **+** ที่หัวข้อระดับ 1–2 ใน sidebar = bookmark **หน้าที่ viewer ดูอยู่** เข้าใต้หัวข้อนั้นทันที (ชื่อ auto "หน้า N")
|
||||
- **การสร้าง:** ปุ่ม 🔖 บน toolbar (และคีย์ลัด `B`) เปิด **popup ทุกครั้ง** — ชื่อ (pre-fill default ไว้ในช่อง + focus เลือกข้อความทั้งหมด = พิมพ์ทับ/Enter ยอมรับได้ทันที; ล้างแล้วเว้นว่าง = "หน้า N" ตอนบันทึก) + โน้ต (ไม่บังคับ — ช่องชื่อ/โน้ตดักความยาวด้วย `maxLength` ตาม limit ของ server จาก `utils/bookmark-limits.ts`: ชื่อ 120 / โน้ต 200 ตัวอักษร ไม่รอ error ตอนบันทึก) + บรรทัดบอก**จุดที่จะชี้** (หน้า · ส่วนบน/ราวกลาง/ส่วนล่างของหน้า — เก็บจากตำแหน่งที่ viewer กำลังดูตอนเปิด popup กันเข้าใจผิดว่าชี้ที่หัวข้อที่ตั้งชื่อ) + 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)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { AnnotationDTO, AnnotationData, AnnotationType } from "@/db/schema";
|
||||
import type {
|
||||
AnnotationDTO,
|
||||
AnnotationData,
|
||||
AnnotationType,
|
||||
CommentData,
|
||||
DrawData,
|
||||
HighlightData,
|
||||
StampData,
|
||||
TextboxData,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
DRAW_PENS,
|
||||
STAMP_FONT_RATIO,
|
||||
|
|
@ -8,6 +17,7 @@ import {
|
|||
annotationNote,
|
||||
annotationPreviewText,
|
||||
annotationTypeLabel,
|
||||
describeAnnotationChange,
|
||||
effectiveDrawOpacity,
|
||||
sanitizePdfText,
|
||||
stampFitFontSize,
|
||||
|
|
@ -138,6 +148,94 @@ describe("stampFitFontSize — ย่อฟอนต์ตรายางให
|
|||
});
|
||||
|
||||
|
||||
describe("describeAnnotationChange — สรุปสิ่งที่เปลี่ยนของ version history (F19)", () => {
|
||||
it("HIGHLIGHT เปลี่ยนสี/ความทึบ → ชื่อสีไทยจาก palette + % ตามหน่วย UI", () => {
|
||||
const prev: HighlightData = { text: "ข้อ 3", color: "#ef4444", rects: [], opacity: 0.5 };
|
||||
const next: HighlightData = { text: "ข้อ 3", color: "#3b82f6", rects: [], opacity: 1 };
|
||||
expect(describeAnnotationChange("HIGHLIGHT", prev, next)).toEqual([
|
||||
"สี: แดง → น้ำเงิน",
|
||||
"ความทึบ: 50% → 100%",
|
||||
]);
|
||||
});
|
||||
|
||||
it("สีนอก palette (ปุ่มสีรุ้ง) → แสดง hex ตรง ๆ", () => {
|
||||
const prev: HighlightData = { text: "a", color: "#123456", rects: [] };
|
||||
const next: HighlightData = { text: "a", color: "#abcdef", rects: [] };
|
||||
expect(describeAnnotationChange("HIGHLIGHT", prev, next)).toEqual(["สี: #123456 → #abcdef"]);
|
||||
});
|
||||
|
||||
it("COMMENT แก้ข้อความ + ย้าย pin", () => {
|
||||
const prev: CommentData = { position: { x: 10, y: 20 }, message: "ดูจุดนี้" };
|
||||
const next: CommentData = { position: { x: 40, y: 20 }, message: "ดูจุดนี้ (แก้)" };
|
||||
expect(describeAnnotationChange("COMMENT", prev, next)).toEqual([
|
||||
'แก้ข้อความ: "ดูจุดนี้" → "ดูจุดนี้ (แก้)"',
|
||||
"ย้ายตำแหน่ง",
|
||||
]);
|
||||
});
|
||||
|
||||
it("DRAW ย้ายทั้งเส้น → ย้ายตำแหน่ง / eraser ตัดจุดโดย bbox เดิม → แก้เส้น", () => {
|
||||
const prev: DrawData = {
|
||||
strokeColor: "#ef4444",
|
||||
strokeWidth: 0.4,
|
||||
paths: [
|
||||
{ x: 10, y: 10 },
|
||||
{ x: 20, y: 10 },
|
||||
{ x: 30, y: 10 },
|
||||
{ x: 40, y: 10 },
|
||||
],
|
||||
};
|
||||
const moved: DrawData = { ...prev, paths: prev.paths.map((p) => ({ ...p, y: p.y + 5 })) };
|
||||
expect(describeAnnotationChange("DRAW", prev, moved)).toEqual(["ย้ายตำแหน่ง"]);
|
||||
|
||||
const erased: DrawData = {
|
||||
...prev,
|
||||
paths: [prev.paths[0], prev.paths[1], prev.paths[3]], // ตัดจุดกลางทิ้ง bbox คงเดิม
|
||||
};
|
||||
expect(describeAnnotationChange("DRAW", prev, erased)).toEqual(["แก้เส้น"]);
|
||||
});
|
||||
|
||||
it("DRAW/SHAPE เปลี่ยนความหนา → แสดง ×10 ตามหน่วยของตัวปรับใน UI", () => {
|
||||
const prev: DrawData = { strokeColor: "#ef4444", strokeWidth: 0.4, paths: [{ x: 1, y: 1 }] };
|
||||
const next: DrawData = { ...prev, strokeWidth: 0.8 };
|
||||
expect(describeAnnotationChange("DRAW", prev, next)).toEqual(["ความหนา: 4 → 8"]);
|
||||
});
|
||||
|
||||
it("โน้ต N1: เพิ่ม / ลบ / แก้", () => {
|
||||
const base: DrawData = { strokeColor: "#ef4444", strokeWidth: 0.4, paths: [] };
|
||||
expect(describeAnnotationChange("DRAW", base, { ...base, note: "เหตุผล" })).toEqual([
|
||||
"เพิ่มโน้ต: เหตุผล",
|
||||
]);
|
||||
expect(describeAnnotationChange("DRAW", { ...base, note: "เหตุผล" }, base)).toEqual(["ลบโน้ต"]);
|
||||
expect(
|
||||
describeAnnotationChange("DRAW", { ...base, note: "เหตุผล" }, { ...base, note: "เหตุผลใหม่" }),
|
||||
).toEqual(["แก้โน้ต: เหตุผล → เหตุผลใหม่"]);
|
||||
});
|
||||
|
||||
it("ไม่มีอะไรเปลี่ยน → ว่าง", () => {
|
||||
const same: CommentData = { position: { x: 5, y: 5 }, message: "m" };
|
||||
expect(describeAnnotationChange("COMMENT", same, { ...same })).toEqual([]);
|
||||
});
|
||||
|
||||
it("TEXTBOX แก้ข้อความแล้วกล่อง wrap ใหม่ → ไม่แจ้ง ปรับขนาด ที่เป็นผลพลอง", () => {
|
||||
const prev: TextboxData = {
|
||||
position: { x: 10, y: 10 },
|
||||
text: "สั้น",
|
||||
color: "#111827",
|
||||
fontSize: 2.5,
|
||||
};
|
||||
const next: TextboxData = { ...prev, text: "ข้อความที่ยาวขึ้นมาก" };
|
||||
const changes = describeAnnotationChange("TEXTBOX", prev, next);
|
||||
expect(changes).toContain('แก้ข้อความ: "สั้น" → "ข้อความที่ยาวขึ้นมาก"');
|
||||
expect(changes).not.toContain("ปรับขนาด");
|
||||
});
|
||||
|
||||
it("STAMP เปลี่ยนสีไม่ระบุ (undefined) ต้องเทียบกับ default STAMP_COLOR ก่อน", () => {
|
||||
const prev: StampData = { label: "สำคัญ", position: { x: 10, y: 10 }, scale: 18 };
|
||||
const next: StampData = { ...prev, color: "#b91c1c" }; // = default ไม่ถือว่าเปลี่ยน
|
||||
expect(describeAnnotationChange("STAMP", prev, next)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizePdfText — ล้างข้อความเพี้ยนจาก textLayer ของ PDF ไทย (glyph ต่อ glyph)", () => {
|
||||
// control char ขยะที่พบจริงใน textLayer ของคำสั่งศาลปกครอง เช่น 04/05/0e/0f/03/12/1b
|
||||
const ctrl = (n: number) => String.fromCharCode(n);
|
||||
|
|
|
|||
26
tests/utils/date-format.test.ts
Normal file
26
tests/utils/date-format.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { formatThaiDate } from "@/utils/date-format";
|
||||
|
||||
// สร้าง Date จาก local components แล้ววนผ่าน ISO กลับ — ผล test ไม่ขึ้นกับ
|
||||
// timezone ของเครื่องที่รัน (เว้นช่วง DST transition ทับพอดี ซึ่งไทยไม่มี)
|
||||
describe("formatThaiDate — วันที่ไทยแบบเต็ม พ.ศ. ตามธรรมเนียมเอกสารราชการ", () => {
|
||||
it("ไม่มีเวลา → 18 กันยายน พ.ศ. 2569", () => {
|
||||
const d = new Date(2026, 8, 18, 16, 33, 0);
|
||||
expect(formatThaiDate(d.toISOString())).toBe("18 กันยายน พ.ศ. 2569");
|
||||
});
|
||||
|
||||
it("มีเวลา → 18 กันยายน พ.ศ. 2569 16:33:00 น.", () => {
|
||||
const d = new Date(2026, 8, 18, 16, 33, 0);
|
||||
expect(formatThaiDate(d.toISOString(), true)).toBe("18 กันยายน พ.ศ. 2569 16:33:00 น.");
|
||||
});
|
||||
|
||||
it("วัน/ชั่วโมง/นาที/วินาทีหลักเดียวไม่มีศูนย์นำหน้าที่วัน + ศูนย์นำหน้าที่เวลา", () => {
|
||||
const d = new Date(2026, 10, 5, 9, 5, 3);
|
||||
expect(formatThaiDate(d.toISOString(), true)).toBe("5 พฤศจิกายน พ.ศ. 2569 09:05:03 น.");
|
||||
});
|
||||
|
||||
it("29 กุมภาพันธ์ปีอธิกสุรทิน (2028 → พ.ศ. 2571)", () => {
|
||||
const d = new Date(2028, 1, 29, 23, 59, 59);
|
||||
expect(formatThaiDate(d.toISOString(), true)).toBe("29 กุมภาพันธ์ พ.ศ. 2571 23:59:59 น.");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import type {
|
||||
AnnotationDTO,
|
||||
AnnotationData,
|
||||
AnnotationType,
|
||||
CommentData,
|
||||
DrawPen,
|
||||
HighlightData,
|
||||
|
|
@ -8,6 +10,7 @@ import type {
|
|||
StampData,
|
||||
TextboxData,
|
||||
} from "@/db/schema";
|
||||
import { annotationBBox } from "@/utils/geometry";
|
||||
|
||||
// การแสดงผล "ชนิด + ข้อความย่อ" ของ annotation — ใช้ร่วมกันระหว่าง sidebar,
|
||||
// หน้าสรุป (F15/D2) และ export CSV (F16/D3) เพื่อไม่ให้ logic แยกกันคนละที่
|
||||
|
|
@ -148,3 +151,221 @@ export function annotationPreviewText(a: AnnotationDTO): string {
|
|||
export function annotationNote(a: AnnotationDTO): string {
|
||||
return "note" in a.data && a.data.note ? a.data.note : "";
|
||||
}
|
||||
|
||||
// ─── สรุปสิ่งที่เปลี่ยนของ 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": "แดงเข้ม",
|
||||
};
|
||||
|
||||
function colorName(c: string): string {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
7
utils/bookmark-limits.ts
Normal file
7
utils/bookmark-limits.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/** ความยาวสูงสุดของชื่อ/โน้ต bookmark — source of truth เดียวใช้ร่วมทั้งฝั่ง
|
||||
* server action (app/actions/annotations.ts — validate ก่อนแตะ DB) และฝั่ง UI
|
||||
* (maxLength ของ popup บันทึก bookmark ใน PDFViewer + ฟอร์มแก้ไขใน
|
||||
* AnnotationSidebar) เพื่อให้ UI ดักความยาวไว้ก่อนถึง limit ของ server */
|
||||
|
||||
export const MAX_BOOKMARK_TITLE_CHARS = 120;
|
||||
export const MAX_BOOKMARK_NOTE_CHARS = 200;
|
||||
34
utils/date-format.ts
Normal file
34
utils/date-format.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/** รูปแบบวันที่-เวลาภาษาไทยสำหรับ UI — เจ้าหน้าที่ศาลอ่านวันที่แบบเต็ม พ.ศ.
|
||||
* (ธรรมเนียมเอกสารราชการ) เขียนเองแทน toLocaleDateString("th-TH") เพราะ Intl
|
||||
* ไม่แทรกคำว่า "พ.ศ." หน้าปี และ format ต้องคงที่ทุก browser/ICU */
|
||||
|
||||
const THAI_MONTHS = [
|
||||
"มกราคม",
|
||||
"กุมภาพันธ์",
|
||||
"มีนาคม",
|
||||
"เมษายน",
|
||||
"พฤษภาคม",
|
||||
"มิถุนายน",
|
||||
"กรกฎาคม",
|
||||
"สิงหาคม",
|
||||
"กันยายน",
|
||||
"ตุลาคม",
|
||||
"พฤศจิกายน",
|
||||
"ธันวาคม",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* วันที่ → ไทยแบบเต็ม: "18 กันยายน พ.ศ. 2569" · withTime = ต่อท้ายเวลา "… 16:33:00 น."
|
||||
* รับทั้ง ISO string (DTO ที่ส่งข้าม server→client — invariant #5) และ Date object
|
||||
* (server component ที่อ่าน row จาก DB ตรง ๆ เช่นหน้ารายการไฟล์)
|
||||
* แปลงตามเวลาท้องถิ่นของเครื่องที่ render (ค่าใน DB เป็น timestamptz) —
|
||||
* วันที่เดียวกันจึงอาจต่างกันตามเครื่องแต่ละคน ให้คง suppressHydrationWarning
|
||||
* ไว้ตามจุดที่ render ผ่าน server เดิม
|
||||
*/
|
||||
export function formatThaiDate(input: string | Date, withTime = false): string {
|
||||
const d = input instanceof Date ? input : new Date(input);
|
||||
const date = `${d.getDate()} ${THAI_MONTHS[d.getMonth()]} พ.ศ. ${d.getFullYear() + 543}`;
|
||||
if (!withTime) return date;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${date} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())} น.`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue