eCourt-note/utils/annotation-display.ts
Your Name 357f3e972f toolbar: แก้ quick wins ตาม usability audit 2026-09-22 — ปิดเมนูหลังเลือกแถวเดี่ยว + แบนเนอร์ hint ยางลบ/tool วางของ + ปุ่ม % ซูมรีเซ็ต fit-width + ชื่อ tool "เลือกข้อความ" เป็น "เลือก"
- เมนู tool 4 ตัว (รูปแบบมาร์ก/ชนิดปากกา/ชนิดรูปทรง/preset ตรายาง): เลือกแถวเดี่ยวแล้ว
  ปิดเมนูทันทีด้วย setState ตรง ๆ โดยไม่ผ่าน markMenuClosed (guard กลืน tap 300ms
  จะทำให้แตะหน้าครั้งถัดไปเงียบ — เดิมวางตรายาง 1 ชิ้นต้องแตะ 4 ครั้ง เหลือ 3 ครั้ง
  ที่ตอบสนองทุกครั้ง) ส่วนแถวปรับค่า สี/ความหนา/ความทึบ/ขนาด ยังคงเมนูเปิด
  ให้ปรับต่อหลายค่าติดกันตามเจตนาเดิม
- แบนเนอร์ใต้ header (reuse pattern M3/M6/H2): tool คอมเมนต์/กล่องข้อความ/ตรายาง
  แสดง "โหมด…: คลิก (แตะ) ตำแหน่งบนหน้าเพื่อวาง" ตลอดตอน active (ทำหน้าที่ mode
  indicator แก้อาการเลือก tool แล้วจอนิ่ง — เดิม hint อยู่แค่ empty state ของ sidebar
  ที่หายทันทีที่มีรายการ) + ยางลบบอกขอบเขตว่าลบได้เฉพาะเส้น/รูปทรง/ตรายางของตัวเอง
  (ไม่แตะตรรกะ eraseAt ตามสเปก F6 เดิม) + label ปุ่มยางลบเปลี่ยนตาม
- cursor เป็น crosshair ตอนใช้ tool วางของ 3 ตัวบน desktop (จอสัมผัสไม่มี cursor)
- ตัวเลข % ซูมกลายเป็นปุ่มรีเซ็ตกลับ fit-width — extract สูตรจาก effect ตอนโหลด
  เป็น fitWidthScale() ใช้ร่วมกัน คิดจากความกว้าง main ปัจจุบัน (sidebar/จออาจเปลี่ยน)
- ชื่อ tool select "เลือกข้อความ" → "เลือก" (ไม่ชน "มาร์กข้อความ" — title/aria-label/
  ตัวเลื่อนปุ่ม active อ้างจาก TOOLS อัตโนมัติ) + sync comment ที่อ้างชื่อเก่า 4 จุด
- contrast: text-slate-400 → slate-500 ที่ข้อความข้อมูล 4 จุด (เวลา reply, เวลา comment
  popup, "วางที่" ของ popup bookmark, "กำลังโหลดเอกสาร…")
- ปุ่มสี ColorSwatches ใช้ชื่อสีไทยใน aria-label/title ผ่าน colorName() ที่ export
  เพิ่มจาก utils/annotation-display.ts (แทน hex "#ef4444" ที่ screen reader อ่านไม่รู้เรื่อง)

ยังคงเดิมตามคำสั่งผู้ใช้: ปุ่ม Bookmark/Export PDF คงป้ายอังกฤษ, แท็บ sidebar,
InkLayer/AnnotationSelectionLayer

verify: tsc ผ่าน + vitest 139/139 + Playwright 24 checks ผ่าน (flow ตรายาง 4 แตะ→3 แตะ,
แถวปรับค่าเมนูยังเปิด, แบนเนอร์/cursor ครบทุก tool, ซูม 100%→150%→รีเซ็ตกลับ 100%,
aria-label "เลือกสี แดง")

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 11:48:53 +07:00

372 lines
20 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 ตอนสร้าง 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;
}