eCourt-note/utils/annotation-display.ts
Your Name c5c6b65bd2 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

379 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type {
AnnotationDTO,
AnnotationData,
AnnotationType,
CommentData,
DrawPen,
HighlightData,
HighlightStyle,
ShapeData,
StampData,
TextboxData,
} from "@/db/schema";
import { annotationBBox } from "@/utils/geometry";
// การแสดงผล "ชนิด + ข้อความย่อ" ของ annotation — ใช้ร่วมกันระหว่าง sidebar,
// หน้าสรุป (F15/D2) และ export CSV (F16/D3) เพื่อไม่ให้ logic แยกกันคนละที่
/** ชื่อรูปแบบการมาร์กข้อความ (HIGHLIGHT.style) */
export const HIGHLIGHT_STYLE_LABELS: Record<HighlightStyle, string> = {
highlight: "ไฮไลต์ข้อความ",
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` ชัดเสมอ
* ส่วนแถวเดิม (ไม่มี field นี้) ตกลไปตามค่า preset ของ `pen` (ไม่มี pen = pencil)
* render บนจอกับ export PDF ต้องใช้ค่าจากที่นี่ที่เดียวกัน
*/
export function effectiveDrawOpacity(d: { pen?: DrawPen; opacity?: number }): number {
return d.opacity ?? DRAW_PENS[d.pen ?? "pencil"].opacity;
}
// ค่า 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";
// ค่า default ตอนสร้าง TEXTBOX (F17/B2) — fontSize เป็น % ของความกว้างหน้า
export const TEXTBOX_DEFAULT_COLOR = "#111827";
// M2: 2.5% ≈ 15px ที่หน้า A4 (595pt, scale 1) — อ่านออกทันทีที่พิมพ์เสร็จ (เดิม 1.2% ≈ 7px
// เล็กกว่า preset ต่ำสุด 10px ของ NumberCombo ด้วยซ้ำ) / กระทบเฉพาะกล่องที่สร้างใหม่เท่านั้น
export const TEXTBOX_DEFAULT_FONT_SIZE_PCT = 2.5;
// 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";
/** ขนาดตัวอักษรในตรายาง = สัดส่วนของความกว้างกรอบ (ค่าสูงสุด — ย่อลงเมื่อข้อความยาวเกินกรอบ) */
export const STAMP_FONT_RATIO = 0.22;
/** ความกว้างข้อความสูงสุดที่ยอมให้ = สัดส่วนของกรอบ (เว้นระยะจากขอบใน) */
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();
}
/** 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 : "";
}
// ─── สรุปสิ่งที่เปลี่ยนของ 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": "แดงเข้ม",
};
/** export ให้ ToolControls ใช้ทำ aria-label/title ของปุ่มสีเป็นชื่อไทยด้วย (UX audit 2026-09-22) */
export 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;
}