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 = { highlight: "ไฮไลต์ข้อความ", underline: "เส้นใต้", strikethrough: "ขีดฆ่า", squiggle: "ขยุกขยิก", }; /** ชื่อรูปทรงตามชนิด (F5) */ export const SHAPE_LABELS: Record = { 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[] = [ "ตรวจสอบแล้ว", "สำคัญ", ]; /** ความยาวคำตรายางสูงสุดที่ช่องกรอกของ UI ยอมรับ (preset + คำที่พิมพ์เอง) — * validator ของ action ยอมถึง MAX_TEXT_CHARS แต่ตรายางเป็นข้อความบรรทัดเดียวที่ * stampFitFontSize ย่อให้พอดีกรอบเสมอ ยาวเกินระดับนี้ฟอนต์เล็กจนอ่านไม่ออก */ export const STAMP_MAX_LABEL_CHARS = 50; /** ฟอนต์ตรายางบนจอ — ตั้งชัดทั้งตอนวัด (canvas measure) และตอน render เพื่อให้ความกว้าง * ที่ stampFitFontSize คำนวณตรงกับที่แสดงจริง (ตัวหนา 700 เหมือน class font-bold) — * ใช้ร่วมทั้ง render (TextboxStampLayer) และ preview ในเมนูของ tool (PDFViewer) */ export const STAMP_FONT_STACK = "system-ui, sans-serif"; // สูตรจัดวางข้อความตรายาง (constants + stampFitFontSize + wrap/layout หลายบรรทัด) // ย้ายไป utils/geometry.ts แล้ว (ใช้ร่วมกับ annotationBBox) — re-export ให้ import // เดิมที่อ้างจากไฟล์นี้ไม่ต้องแก้ export { STAMP_FONT_RATIO, STAMP_TEXT_WIDTH_RATIO, stampFitFontSize, stampLayout, stampBoxHeight, wrapStampLabel, } from "@/utils/geometry"; /** ความกว้างกรอบตรายางเริ่มต้น (% ของความกว้างหน้า) */ export const STAMP_DEFAULT_SCALE_PCT = 18; /** สีตรายาง (ขอบ + ตัวอักษร) — ใช้ทั้ง render บนจอและ export */ export const STAMP_COLOR = "#b91c1c"; let stampMeasureCtx: CanvasRenderingContext2D | null = null; /** วัดความกว้างข้อความตรายางด้วย canvas บนจอ (measure ของ stampFitFontSize เมื่อ render/ * preview บนหน้าเว็บ) — ไม่มี document (SSR) คืน 0 ให้ใช้ขนาด base ตรง ๆ */ export function measureStampText(text: string, fontSize: number): number { if (typeof document === "undefined") return 0; stampMeasureCtx ??= document.createElement("canvas").getContext("2d"); if (!stampMeasureCtx) return 0; stampMeasureCtx.font = `bold ${fontSize}px ${STAMP_FONT_STACK}`; return stampMeasureCtx.measureText(text).width; } // คลาสอักขระขยะที่ปนมากับข้อความสกัดจาก 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(); } /** ชื่อย่อต่อชนิดสำหรับบรรทัดสรุปของแต่ละหน้า (หน้าสรุป F15) + แผงตัวกรอง * explorer — ชื่อเต็มอยู่ที่ annotationTypeLabel (เดิม define ซ้ำ 2 ที่: หน้าสรุป * กับ FilterControls ของหน้ากลุ่ม รวมมาที่นี่ตอนแยกแผงเป็นกลาง 2026-09-24) */ export const TYPE_SHORT: Record = { HIGHLIGHT: "ไฮไลต์", DRAW: "เส้นวาด", COMMENT: "คอมเมนต์", SHAPE: "รูปทรง", REDACTION: "ปิดพราง", TEXTBOX: "กล่องข้อความ", STAMP: "ตรายาง", }; /** ลำดับชนิดในบรรทัดสรุป/แผงตัวกรอง — ตามตำแหน่ง key ของ TYPE_SHORT (เรียงให้ตรง * ลำดับ enum) ให้ deterministic ไม่ขึ้นกับลำดับสร้างรายการ */ export const TYPE_ORDER = Object.keys(TYPE_SHORT) as AnnotationType[]; /** 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 = { "#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, 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)/ขนาด/มุมเอียง + ย้าย/ขยาย const labelChanged = prev.label !== next.label; if (labelChanged) { 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)}%`); } const pr = prev.rotation ?? 0; const nr = next.rotation ?? 0; if (pr !== nr) changes.push(`มุมเอียง: ${pr}° → ${nr}°`); // ตำแหน่ง/ขนาด — เมื่อข้อความหรือมุมเอียงเปลี่ยน geometry ที่ต่างเป็นผลพลองของ // การจัดวางทั้งหมด (label ยาว → กรอบสูงตามจำนวนบรรทัด, หมุน → AABB ขยายสมมาตร // รอบ center ทั้งตำแหน่ง+ขนาด ขณะที่กรอบจริงอยู่ที่เดิม) จึงไม่แสดง geometry เลย // — ส่วนขยาย/ย้ายจริงเป็น action แยกที่เทียบโดยไม่มี label/มุมเปลี่ยน (แบบ // TEXTBOX ที่ข้าม "ปรับขนาด" เมื่อข้อความเปลี่ยน — code review 2026-09-28) if (!labelChanged && pr === nr) { changes.push(...geometryChangeLabels(type, prev, next)); } } return changes; }