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
|
|
@ -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