eCourt-note/utils/annotation-display.ts
Your Name df502088b7 groups+summary: redesign หน้า /groups/[typeId] + หน้าสรุปเป็น explorer 2 คอลัมน์ (ตัวกรองซ้าย + list เดียว) — หน้าสรุปใช้มิติแรก "หน้า" ตามแบบหน้ากลุ่มที่ใช้ "ไฟล์" — (1) หน้า /groups/[typeId]: ถอด navbar (เหลือ back link — AppNav เหลือใช้ที่หน้า / อย่างเดียว) + ถอดหมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้", query ทั้งหมดฝั่ง server ส่ง GroupFileSummary[]+GroupListItem[] ให้ GroupExplorer กรองใน memory — ตัวกรอง tree menu 2 ระดับสไตล์ Quasar docs 4 กลุ่ม (ไฟล์/ชนิดรายการ/ชนิดของ Annotation/ผู้บันทึก — ชนิดรายการเป็น checkbox คู่ ไม่เลือก = แสดงทั้งหมด, ติ๊กชนิด → เลือก Annotation อัตโนมัติ, เลิกเลือก Annotation → ล้างชนิดค้าง) + GroupList แถว passive จุดคลิกเดียวคือปุ่ม "View" (2) แยกแผงตัวกรองเป็น component กลาง components/explorer/FacetFilterPanel.tsx — มิติแรกส่งเข้าทาง prop firstGroup (groups = ไฟล์ / สรุป = หน้า พร้อม optionsScroll ให้ scroll ภายในกลุ่ม max-h-64 กันหลายสิบหน้าดันกลุ่มอื่นออกจอ) + TYPE_SHORT/TYPE_ORDER รวมจาก 2 ที่ที่เคย define ซ้ำมาอยู่ utils/annotation-display.ts จุดเดียว (3) หน้าสรุป /annotate/[id]/summary: server ส่ง ExplorerItem[] (หน้า → bookmark ก่อน annotation → ในหน้าเดียวกัน bookmark เรียง yOffset ตาม N5/annotation ตาม createdAt) ให้ SummaryExplorer + SummaryList การ์ดรายหน้าหัวการ์ด sticky (เลขหน้า + บรรทัดสรุปหน้าคำนวณจากรายการที่เห็นจริงหลังกรอง + ปุ่มเปิดหน้านี้) + แถว passive ปุ่ม View deep link ?ann=/?page= เหมือนหน้ากลุ่ม — ถอดรายการตามหน้าจุดไข่ปลา + การ์ด folio + CollapsibleBlock ออก (anchor #page-N ของ section คงไว้ให้ลิงก์เก่ายังใช้ได้) (4) ตอนพิมพ์แสดงฉบับเต็มเสมอ — ตัวกรองที่ตั้งค้างต้องไม่ตัดรายการออกเงียบ ๆ บนกระดาษ (ตอนกรองอยู่ render ชุดบนจอ print:hidden + ชุดเต็ม hidden print:block, prop anchorIds กัน duplicate id) + กฎ print ใหม่ใน globals.css (section>ul>li break-inside:avoid-page + .page-head position:static) (5) ผ่าน code review 2026-09-24 (tsc/build/vitest 144 tests): คืน N5 yOffset ให้ bookmark ต่อหน้าที่ sort createdAt ทับไปโดยไม่ตั้งใจ + จดรับความเสี่ยง localeCompare "th" รันทั้ง SSR/hydration ไว้ที่ comment พร้อมทางแก้ + docs sync ครบ (CLAUDE.md โครงสร้างไฟล์ + PLAN-FEAT ถอดหมายเหตุ + PLAN-DOCSET อ้าง FacetFilterPanel) (คำขอผู้ใช้ 2026-09-24)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 17:34:23 +07:00

396 lines
22 KiB
TypeScript
Raw Permalink 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();
}
/** ชื่อย่อต่อชนิดสำหรับบรรทัดสรุปของแต่ละหน้า (หน้าสรุป F15) + แผงตัวกรอง
* explorer — ชื่อเต็มอยู่ที่ annotationTypeLabel (เดิม define ซ้ำ 2 ที่: หน้าสรุป
* กับ FilterControls ของหน้ากลุ่ม รวมมาที่นี่ตอนแยกแผงเป็นกลาง 2026-09-24) */
export const TYPE_SHORT: Record<AnnotationType, string> = {
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<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;
}