diff --git a/docs/PLAN-FEAT.md b/docs/PLAN-FEAT.md
index 0877b8a..0ddc436 100644
--- a/docs/PLAN-FEAT.md
+++ b/docs/PLAN-FEAT.md
@@ -186,7 +186,7 @@ Index: `(file_id)`
- `updateAnnotation` → snapshot row เดิมเป็น version `kind='update'` **ก่อนเขียนทับ** (transaction เดียวกัน); `deleteAnnotation` → snapshot `kind='delete'` ก่อนลบ (D8 ทำให้ version รอดจาก CASCADE)
- Action `listVersions(annotationId)` + `restoreVersion(versionId)` — **ถ้า annotation ต้นทางยังอยู่: เขียนข้อมูลของ version กลับ row เดิม** (snapshot สถานะปัจจุบันก่อน เหมือน update ปกติ → ย้อนกลับไป-มาได้) + publish update / **ถ้าต้นทางถูกลบแล้ว (kind='delete' รอดจาก CASCADE ตาม D8): สร้าง annotation ใหม่ id ใหม่** + publish create (เดิมสร้างสำเนาใหม่ทุกกรณีจนเกิดรายการซ้ำบนหน้า)
-- UI: ปุ่ม "ประวัติ" ใน sidebar (เฉพาะรายการของตัวเอง) → dialog รายการ versions (เวลา + ชนิดการเปลี่ยนแปลง) + ปุ่มกู้คืน
+- UI: ปุ่ม "ประวัติ" ใน sidebar (เฉพาะรายการของตัวเอง) → dialog รายการ versions (เวลา + ชนิดการเปลี่ยนแปลง + ผู้แก้) + ปุ่มกู้คืน — แต่ละแถว kind='update' แสดง**สรุปสิ่งที่เปลี่ยน**ผ่าน `describeAnnotationChange` (`utils/annotation-display.ts` — เพิ่มตามคำขอผู้ใช้ 2026-09-17): snapshot "ก่อนแก้" ของรอบนั้นเทียบกับ state ถัดไป (= snapshot ของรอบแก้ที่ใหม่กว่าถัดไป หรือค่าปัจจุบันของรายการสำหรับรอบล่าสุด) — สรุปเป็นบรรทัดสั้นต่อ field ที่เปลี่ยน (สีเป็นชื่อไทยจาก palette/ความหนา ×10/ความทึบ % ตามหน่วย UI, แก้ข้อความ/โน้ต, ย้ายตำแหน่ง/ปรับขนาดจาก bbox ทน noise 0.5%, DRAW ที่จุดเปลี่ยนแต่ bbox เดิม = "แก้เส้น" ของ eraser) ส่วนแถว kind='delete' เป็น snapshot สุดท้ายก่อนลบ ไม่ diff
### ฟีเจอร์เล็กที่เหลือ (สเปกย่อ — รายละเอียดการทำงานเต็มอยู่ในตาราง §4)
@@ -280,7 +280,7 @@ Dependencies เพิ่มเติม: F3 ← F2 · F12/F13/F19 ← F1 · F15
### N2 · Bookmark แบบหมุดหัวข้อใหญ่ + sub-bookmark + โน้ต (โครงสร้าง 3 ระดับ)
- **ข้อมูล:** ตาราง `bookmarks` เพิ่ม `parent_id uuid NULL` (NULL = หมุดหัวข้อใหญ่ระดับบนสุด / มีค่า = sub ของ bookmark นั้น — **ไม่ทำ FK** เพราะลบแม่ต้อง re-parent ลูกเอง) + `note text NULL` (โน้ตย่อยของ bookmark) — จำกัด **3 ระดับ** (`BOOKMARK_MAX_DEPTH = 3`): create ต้องมี `depth(parent) < 3`, ย้ายต้องผ่าน `depth(parent) + subtreeHeight(ตัวเอง) ≤ 3` + ห้ามย้ายเข้า subtree ของตัวเอง (cycle) — **ทุก action ที่แตะความสัมพันธ์แม่–ลูกตรวจภายใน transaction พร้อม `.for("update")` ทุกแถวที่เดินผ่าน** (บรรพบุรุษ/ลูกหลาน — helper `lockedDepth`/`lockedSubtreeHeight`/`isDescendant`) เพื่อ serialize กัน race เกินความลึก/sub กำพร้า
-- **การสร้าง:** ปุ่ม 🔖 บน toolbar (และคีย์ลัด `B`) เปิด **popup ทุกครั้ง** — ชื่อ (pre-fill default ไว้ในช่อง + focus เลือกข้อความทั้งหมด = พิมพ์ทับ/Enter ยอมรับได้ทันที; ล้างแล้วเว้นว่าง = "หน้า N" ตอนบันทึก) + โน้ต (ไม่บังคับ) + บรรทัดบอก**จุดที่จะชี้** (หน้า · ส่วนบน/ราวกลาง/ส่วนล่างของหน้า — เก็บจากตำแหน่งที่ viewer กำลังดูตอนเปิด popup กันเข้าใจผิดว่าชี้ที่หัวข้อที่ตั้งชื่อ) + select "วางที่" (ระดับบนสุด / ใต้หัวข้อระดับ 1–2 — ใครก็วางใต้หัวข้อของคนอื่นได้) — ปิดด้วย บันทึก/ยกเลิก/Enter(title)/Esc/คลิกหน้า · ทางลัด: ปุ่ม **+** ที่หัวข้อระดับ 1–2 ใน sidebar = bookmark **หน้าที่ viewer ดูอยู่** เข้าใต้หัวข้อนั้นทันที (ชื่อ auto "หน้า N")
+- **การสร้าง:** ปุ่ม 🔖 บน toolbar (และคีย์ลัด `B`) เปิด **popup ทุกครั้ง** — ชื่อ (pre-fill default ไว้ในช่อง + focus เลือกข้อความทั้งหมด = พิมพ์ทับ/Enter ยอมรับได้ทันที; ล้างแล้วเว้นว่าง = "หน้า N" ตอนบันทึก) + โน้ต (ไม่บังคับ — ช่องชื่อ/โน้ตดักความยาวด้วย `maxLength` ตาม limit ของ server จาก `utils/bookmark-limits.ts`: ชื่อ 120 / โน้ต 200 ตัวอักษร ไม่รอ error ตอนบันทึก) + บรรทัดบอก**จุดที่จะชี้** (หน้า · ส่วนบน/ราวกลาง/ส่วนล่างของหน้า — เก็บจากตำแหน่งที่ viewer กำลังดูตอนเปิด popup กันเข้าใจผิดว่าชี้ที่หัวข้อที่ตั้งชื่อ) + select "วางที่" (ระดับบนสุด / ใต้หัวข้อระดับ 1–2 — ใครก็วางใต้หัวข้อของคนอื่นได้) — ปิดด้วย บันทึก/ยกเลิก/Enter(title)/Esc/คลิกหน้า · ทางลัด: ปุ่ม **+** ที่หัวข้อระดับ 1–2 ใน sidebar = bookmark **หน้าที่ viewer ดูอยู่** เข้าใต้หัวข้อนั้นทันที (ชื่อ auto "หน้า N")
- **ชื่อ default จากข้อความที่คลุม:** viewer จำข้อความที่เพิ่งคลุมบนหน้าไว้ตอน `mouseup` (ทุก tool — คลิกปุ่ม 🔖 ใน header ไม่ผ่าน mouseup ของหน้าค่าจึงไม่โดนเคลียร์) → เปิด popup แล้วชื่อ default = ข้อความนั้น (ยุบ whitespace, ตัดยาว ≤ 120 ตัวอักษรผ่าน `normalizeBookmarkTitle`) ไม่มีข้อความที่คลุม = "หน้า N"
- **ปุ่ม bookmark ในแผง selection (F1 toolbar):** ปุ่ม 🔖 ท้ายแผงเครื่องมือของ annotation ที่เลือก → เปิด popup ที่หน้า + yOffset ตามขอบบน bbox ของรายการนั้น โดยชื่อ default = ข้อความของรายการ (HIGHLIGHT/TEXTBOX/STAMP/COMMENT — ชนิดที่ไม่มีข้อความจริงใช้ "หน้า N")
- **แก้ไข (✏ เจ้าของเท่านั้น):** inline ใน sidebar — แก้ชื่อ + โน้ต + ย้ายหัวข้อ (select กรองเป้าหมายที่ย้ายได้จริงตามเกณฑ์ depth+height และตัดตัวเอง/ลูกหลานออก — server ตรวจซ้ำพร้อม lock)
diff --git a/tests/utils/annotation-display.test.ts b/tests/utils/annotation-display.test.ts
index 3e5118f..8f9f079 100644
--- a/tests/utils/annotation-display.test.ts
+++ b/tests/utils/annotation-display.test.ts
@@ -1,5 +1,14 @@
import { describe, expect, it } from "vitest";
-import type { AnnotationDTO, AnnotationData, AnnotationType } from "@/db/schema";
+import type {
+ AnnotationDTO,
+ AnnotationData,
+ AnnotationType,
+ CommentData,
+ DrawData,
+ HighlightData,
+ StampData,
+ TextboxData,
+} from "@/db/schema";
import {
DRAW_PENS,
STAMP_FONT_RATIO,
@@ -8,6 +17,7 @@ import {
annotationNote,
annotationPreviewText,
annotationTypeLabel,
+ describeAnnotationChange,
effectiveDrawOpacity,
sanitizePdfText,
stampFitFontSize,
@@ -138,6 +148,94 @@ describe("stampFitFontSize — ย่อฟอนต์ตรายางให
});
+describe("describeAnnotationChange — สรุปสิ่งที่เปลี่ยนของ version history (F19)", () => {
+ it("HIGHLIGHT เปลี่ยนสี/ความทึบ → ชื่อสีไทยจาก palette + % ตามหน่วย UI", () => {
+ const prev: HighlightData = { text: "ข้อ 3", color: "#ef4444", rects: [], opacity: 0.5 };
+ const next: HighlightData = { text: "ข้อ 3", color: "#3b82f6", rects: [], opacity: 1 };
+ expect(describeAnnotationChange("HIGHLIGHT", prev, next)).toEqual([
+ "สี: แดง → น้ำเงิน",
+ "ความทึบ: 50% → 100%",
+ ]);
+ });
+
+ it("สีนอก palette (ปุ่มสีรุ้ง) → แสดง hex ตรง ๆ", () => {
+ const prev: HighlightData = { text: "a", color: "#123456", rects: [] };
+ const next: HighlightData = { text: "a", color: "#abcdef", rects: [] };
+ expect(describeAnnotationChange("HIGHLIGHT", prev, next)).toEqual(["สี: #123456 → #abcdef"]);
+ });
+
+ it("COMMENT แก้ข้อความ + ย้าย pin", () => {
+ const prev: CommentData = { position: { x: 10, y: 20 }, message: "ดูจุดนี้" };
+ const next: CommentData = { position: { x: 40, y: 20 }, message: "ดูจุดนี้ (แก้)" };
+ expect(describeAnnotationChange("COMMENT", prev, next)).toEqual([
+ 'แก้ข้อความ: "ดูจุดนี้" → "ดูจุดนี้ (แก้)"',
+ "ย้ายตำแหน่ง",
+ ]);
+ });
+
+ it("DRAW ย้ายทั้งเส้น → ย้ายตำแหน่ง / eraser ตัดจุดโดย bbox เดิม → แก้เส้น", () => {
+ const prev: DrawData = {
+ strokeColor: "#ef4444",
+ strokeWidth: 0.4,
+ paths: [
+ { x: 10, y: 10 },
+ { x: 20, y: 10 },
+ { x: 30, y: 10 },
+ { x: 40, y: 10 },
+ ],
+ };
+ const moved: DrawData = { ...prev, paths: prev.paths.map((p) => ({ ...p, y: p.y + 5 })) };
+ expect(describeAnnotationChange("DRAW", prev, moved)).toEqual(["ย้ายตำแหน่ง"]);
+
+ const erased: DrawData = {
+ ...prev,
+ paths: [prev.paths[0], prev.paths[1], prev.paths[3]], // ตัดจุดกลางทิ้ง bbox คงเดิม
+ };
+ expect(describeAnnotationChange("DRAW", prev, erased)).toEqual(["แก้เส้น"]);
+ });
+
+ it("DRAW/SHAPE เปลี่ยนความหนา → แสดง ×10 ตามหน่วยของตัวปรับใน UI", () => {
+ const prev: DrawData = { strokeColor: "#ef4444", strokeWidth: 0.4, paths: [{ x: 1, y: 1 }] };
+ const next: DrawData = { ...prev, strokeWidth: 0.8 };
+ expect(describeAnnotationChange("DRAW", prev, next)).toEqual(["ความหนา: 4 → 8"]);
+ });
+
+ it("โน้ต N1: เพิ่ม / ลบ / แก้", () => {
+ const base: DrawData = { strokeColor: "#ef4444", strokeWidth: 0.4, paths: [] };
+ expect(describeAnnotationChange("DRAW", base, { ...base, note: "เหตุผล" })).toEqual([
+ "เพิ่มโน้ต: เหตุผล",
+ ]);
+ expect(describeAnnotationChange("DRAW", { ...base, note: "เหตุผล" }, base)).toEqual(["ลบโน้ต"]);
+ expect(
+ describeAnnotationChange("DRAW", { ...base, note: "เหตุผล" }, { ...base, note: "เหตุผลใหม่" }),
+ ).toEqual(["แก้โน้ต: เหตุผล → เหตุผลใหม่"]);
+ });
+
+ it("ไม่มีอะไรเปลี่ยน → ว่าง", () => {
+ const same: CommentData = { position: { x: 5, y: 5 }, message: "m" };
+ expect(describeAnnotationChange("COMMENT", same, { ...same })).toEqual([]);
+ });
+
+ it("TEXTBOX แก้ข้อความแล้วกล่อง wrap ใหม่ → ไม่แจ้ง ปรับขนาด ที่เป็นผลพลอง", () => {
+ const prev: TextboxData = {
+ position: { x: 10, y: 10 },
+ text: "สั้น",
+ color: "#111827",
+ fontSize: 2.5,
+ };
+ const next: TextboxData = { ...prev, text: "ข้อความที่ยาวขึ้นมาก" };
+ const changes = describeAnnotationChange("TEXTBOX", prev, next);
+ expect(changes).toContain('แก้ข้อความ: "สั้น" → "ข้อความที่ยาวขึ้นมาก"');
+ expect(changes).not.toContain("ปรับขนาด");
+ });
+
+ it("STAMP เปลี่ยนสีไม่ระบุ (undefined) ต้องเทียบกับ default STAMP_COLOR ก่อน", () => {
+ const prev: StampData = { label: "สำคัญ", position: { x: 10, y: 10 }, scale: 18 };
+ const next: StampData = { ...prev, color: "#b91c1c" }; // = default ไม่ถือว่าเปลี่ยน
+ expect(describeAnnotationChange("STAMP", prev, next)).toEqual([]);
+ });
+});
+
describe("sanitizePdfText — ล้างข้อความเพี้ยนจาก textLayer ของ PDF ไทย (glyph ต่อ glyph)", () => {
// control char ขยะที่พบจริงใน textLayer ของคำสั่งศาลปกครอง เช่น 04/05/0e/0f/03/12/1b
const ctrl = (n: number) => String.fromCharCode(n);
diff --git a/tests/utils/date-format.test.ts b/tests/utils/date-format.test.ts
new file mode 100644
index 0000000..08d3836
--- /dev/null
+++ b/tests/utils/date-format.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vitest";
+import { formatThaiDate } from "@/utils/date-format";
+
+// สร้าง Date จาก local components แล้ววนผ่าน ISO กลับ — ผล test ไม่ขึ้นกับ
+// timezone ของเครื่องที่รัน (เว้นช่วง DST transition ทับพอดี ซึ่งไทยไม่มี)
+describe("formatThaiDate — วันที่ไทยแบบเต็ม พ.ศ. ตามธรรมเนียมเอกสารราชการ", () => {
+ it("ไม่มีเวลา → 18 กันยายน พ.ศ. 2569", () => {
+ const d = new Date(2026, 8, 18, 16, 33, 0);
+ expect(formatThaiDate(d.toISOString())).toBe("18 กันยายน พ.ศ. 2569");
+ });
+
+ it("มีเวลา → 18 กันยายน พ.ศ. 2569 16:33:00 น.", () => {
+ const d = new Date(2026, 8, 18, 16, 33, 0);
+ expect(formatThaiDate(d.toISOString(), true)).toBe("18 กันยายน พ.ศ. 2569 16:33:00 น.");
+ });
+
+ it("วัน/ชั่วโมง/นาที/วินาทีหลักเดียวไม่มีศูนย์นำหน้าที่วัน + ศูนย์นำหน้าที่เวลา", () => {
+ const d = new Date(2026, 10, 5, 9, 5, 3);
+ expect(formatThaiDate(d.toISOString(), true)).toBe("5 พฤศจิกายน พ.ศ. 2569 09:05:03 น.");
+ });
+
+ it("29 กุมภาพันธ์ปีอธิกสุรทิน (2028 → พ.ศ. 2571)", () => {
+ const d = new Date(2028, 1, 29, 23, 59, 59);
+ expect(formatThaiDate(d.toISOString(), true)).toBe("29 กุมภาพันธ์ พ.ศ. 2571 23:59:59 น.");
+ });
+});
diff --git a/utils/annotation-display.ts b/utils/annotation-display.ts
index 5272c2e..6cf4f5f 100644
--- a/utils/annotation-display.ts
+++ b/utils/annotation-display.ts
@@ -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
= {
+ "#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, 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;
+}
diff --git a/utils/bookmark-limits.ts b/utils/bookmark-limits.ts
new file mode 100644
index 0000000..2e613c1
--- /dev/null
+++ b/utils/bookmark-limits.ts
@@ -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;
diff --git a/utils/date-format.ts b/utils/date-format.ts
new file mode 100644
index 0000000..a5303f9
--- /dev/null
+++ b/utils/date-format.ts
@@ -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())} น.`;
+}
From 025ca2ce48b694314f76ed06cbcedc55b05e999e Mon Sep 17 00:00:00 2001
From: Your Name
Date: Thu, 17 Sep 2026 17:15:24 +0700
Subject: [PATCH 06/52] =?UTF-8?q?CD:=20uat.yml=20=E0=B9=81=E0=B8=81?=
=?UTF-8?q?=E0=B9=89=20Run=20workflow=20=E0=B8=88=E0=B8=B2=E0=B8=81=20UI?=
=?UTF-8?q?=20=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B9=84=E0=B8=94=E0=B9=89?=
=?UTF-8?q?=E0=B8=9A=E0=B8=99=20Forgejo=2011=20=E2=80=94=20=E0=B8=AD?=
=?UTF-8?q?=E0=B8=B2=E0=B8=81=E0=B8=B2=E0=B8=A3:=20=E0=B8=81=E0=B8=94=20Ru?=
=?UTF-8?q?n=20workflow=20=E0=B9=81=E0=B8=A5=E0=B9=89=E0=B8=A7=E0=B9=84?=
=?UTF-8?q?=E0=B8=A1=E0=B9=88=E0=B8=A1=E0=B8=B5=E0=B8=8A=E0=B9=88=E0=B8=AD?=
=?UTF-8?q?=E0=B8=87=E0=B8=81=E0=B8=A3=E0=B8=AD=E0=B8=81=20version=20?=
=?UTF-8?q?=E0=B9=81=E0=B8=95=E0=B9=88=20backend=20=E0=B8=82=E0=B8=A7?=
=?UTF-8?q?=E0=B8=B2=E0=B8=87=E0=B8=94=E0=B9=89=E0=B8=A7=E0=B8=A2=20"Requi?=
=?UTF-8?q?re=20value=20for=20input"=20(Forgejo=2011=20base=20Gitea=201.22?=
=?UTF-8?q?=20=3D=20UI=20=E0=B8=A2=E0=B8=B1=E0=B8=87=E0=B9=84=E0=B8=A1?=
=?UTF-8?q?=E0=B9=88=20render=20workflow=5Fdispatch=20input=20fields,=20Gi?=
=?UTF-8?q?tea=20=E0=B9=80=E0=B8=9E=E0=B8=B4=E0=B9=88=E0=B8=A1=E0=B9=83?=
=?UTF-8?q?=E0=B8=99=201.23-1.24)=20=E2=80=94=20=E0=B9=81=E0=B8=81?=
=?UTF-8?q?=E0=B9=89=202=20=E0=B8=8A=E0=B8=B1=E0=B9=89=E0=B8=99:=20(1)=20i?=
=?UTF-8?q?nput=20=E0=B9=80=E0=B8=95=E0=B8=B4=E0=B8=A1=20type:=20string=20?=
=?UTF-8?q?=E0=B8=95=E0=B8=B2=E0=B8=A1=20GitHub=20spec=20=E0=B9=80?=
=?UTF-8?q?=E0=B8=9C=E0=B8=B7=E0=B9=88=E0=B8=AD=20renderer=20=E0=B8=95?=
=?UTF-8?q?=E0=B9=89=E0=B8=AD=E0=B8=87=E0=B9=80=E0=B8=88=E0=B8=AD=20type?=
=?UTF-8?q?=20=E0=B8=96=E0=B8=B6=E0=B8=87=E0=B8=A7=E0=B8=B2=E0=B8=94?=
=?UTF-8?q?=E0=B8=8A=E0=B9=88=E0=B8=AD=E0=B8=87=20(2)=20required:=20false?=
=?UTF-8?q?=20+=20step=20Resolve=20=E0=B8=A3=E0=B8=AD=E0=B8=87=E0=B8=A3?=
=?UTF-8?q?=E0=B8=B1=E0=B8=9A=E0=B8=84=E0=B9=88=E0=B8=B2=E0=B8=A7=E0=B9=88?=
=?UTF-8?q?=E0=B8=B2=E0=B8=87:=20dispatch=20=E0=B9=82=E0=B8=94=E0=B8=A2?=
=?UTF-8?q?=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B9=83=E0=B8=AA=E0=B9=88=20versio?=
=?UTF-8?q?n=20=3D=20deploy=20:latest=20(image=20=E0=B8=A5=E0=B9=88?=
=?UTF-8?q?=E0=B8=B2=E0=B8=AA=E0=B8=B8=E0=B8=94=E0=B8=97=E0=B8=B5=E0=B9=88?=
=?UTF-8?q?=20build.yml=20push=20=E0=B8=95=E0=B8=AD=E0=B8=99=20tag=20v*=20?=
=?UTF-8?q?=E2=80=94=20=E0=B8=84=E0=B8=B7=E0=B8=AD=E0=B9=80=E0=B8=A7?=
=?UTF-8?q?=E0=B8=AD=E0=B8=A3=E0=B9=8C=E0=B8=8A=E0=B8=B1=E0=B8=99=E0=B8=97?=
=?UTF-8?q?=E0=B8=B5=E0=B9=88=E0=B9=80=E0=B8=9E=E0=B8=B4=E0=B9=88=E0=B8=87?=
=?UTF-8?q?=20deploy=20Test=20=E0=B9=84=E0=B8=9B)=20=E0=B9=83=E0=B8=AA?=
=?UTF-8?q?=E0=B9=88=E0=B8=84=E0=B9=88=E0=B8=B2=E0=B8=A1=E0=B8=B2=20(1.2.3?=
=?UTF-8?q?/v1.2.3/deploy1.2.3)=20=E0=B8=81=E0=B9=87=20map=20=E0=B9=80?=
=?UTF-8?q?=E0=B8=9B=E0=B9=87=E0=B8=99=20v1.2.3=20=E0=B9=80=E0=B8=AB?=
=?UTF-8?q?=E0=B8=A1=E0=B8=B7=E0=B8=AD=E0=B8=99=E0=B9=80=E0=B8=94=E0=B8=B4?=
=?UTF-8?q?=E0=B8=A1=20+=20=E0=B9=80=E0=B8=82=E0=B8=B5=E0=B8=A2=E0=B8=99?=
=?UTF-8?q?=20if/else=20=E0=B9=81=E0=B8=A2=E0=B8=81=20event=20=E0=B9=81?=
=?UTF-8?q?=E0=B8=97=E0=B8=99=20&&/||=20=E0=B9=80=E0=B8=9E=E0=B8=A3?=
=?UTF-8?q?=E0=B8=B2=E0=B8=B0=20input=20=E0=B8=A7=E0=B9=88=E0=B8=B2?=
=?UTF-8?q?=E0=B8=87=E0=B8=88=E0=B8=B0=20short-circuit=20=E0=B8=95?=
=?UTF-8?q?=E0=B8=81=E0=B9=84=E0=B8=9B=E0=B9=83=E0=B8=8A=E0=B9=89=20ref=5F?=
=?UTF-8?q?name=20(=E0=B8=8A=E0=B8=B7=E0=B9=88=E0=B8=AD=20branch)=20?=
=?UTF-8?q?=E0=B9=80=E0=B8=81=E0=B8=B4=E0=B8=94=E0=B9=80=E0=B8=9B=E0=B9=87?=
=?UTF-8?q?=E0=B8=99=20image=20vdev=20=E2=80=94=20verify:=20js-yaml=20?=
=?UTF-8?q?=E0=B8=9C=E0=B9=88=E0=B8=B2=E0=B8=99=20+=20=E0=B8=97=E0=B8=94?=
=?UTF-8?q?=E0=B8=AA=E0=B8=AD=E0=B8=9A=20resolve=20=E0=B8=84=E0=B8=A3?=
=?UTF-8?q?=E0=B8=9A=20(deploy1.2.3/v1.2.3/1.2.3=20=E2=86=92=20v1.2.3,=20?=
=?UTF-8?q?=E0=B8=A7=E0=B9=88=E0=B8=B2=E0=B8=87=20=E2=86=92=20latest)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Code
---
.forgejo/workflows/uat.yml | 22 +++++++++++++++-------
1 file changed, 15 insertions(+), 7 deletions(-)
diff --git a/.forgejo/workflows/uat.yml b/.forgejo/workflows/uat.yml
index e38514c..a648843 100644
--- a/.forgejo/workflows/uat.yml
+++ b/.forgejo/workflows/uat.yml
@@ -7,8 +7,9 @@ on:
workflow_dispatch:
inputs:
version:
- description: "เวอร์ชันที่จะ deploy เช่น 1.2.3 / v1.2.3 / deploy1.2.3 — จะ map เป็น image v1.2.3 (ต้อง push tag v1.2.3 ให้ build.yml build ไว้ก่อน)"
- required: true
+ description: "เวอร์ชันที่จะ deploy เช่น 1.2.3 / v1.2.3 / deploy1.2.3 — จะ map เป็น image v1.2.3 | เว้นว่าง = latest (image ล่าสุดที่ build.yml push ไว้)"
+ required: false
+ type: string
env:
# Container Registry
@@ -102,13 +103,20 @@ jobs:
outputs:
image_tag: ${{ steps.image-tag.outputs.tag }}
steps:
- - name: Resolve image tag (deploy1.2.3 / v1.2.3 → v1.2.3)
+ - name: Resolve image tag (deploy1.2.3 / v1.2.3 → v1.2.3, ว่าง → latest)
id: image-tag
run: |
- RAW="${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version || github.ref_name }}"
- TAG="${RAW#deploy}"
- TAG="${TAG#v}"
- TAG="v$TAG"
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ RAW="${{ github.event.inputs.version }}"
+ else
+ RAW="${{ github.ref_name }}"
+ fi
+ TAG="latest"
+ if [ -n "$RAW" ]; then
+ TAG="${RAW#deploy}"
+ TAG="${TAG#v}"
+ TAG="v$TAG"
+ fi
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "Reuse image จาก build.yml: ${{ env.REGISTRY_URL }}/${{ env.WEB_IMAGE_NAME }}:$TAG"
From c56c39f2c54b0afa90a2b57fc2f7a1c33ece64e6 Mon Sep 17 00:00:00 2001
From: Your Name
Date: Thu, 17 Sep 2026 17:16:30 +0700
Subject: [PATCH 07/52] =?UTF-8?q?CD:=20=E0=B8=A2=E0=B9=88=E0=B8=AD=20descr?=
=?UTF-8?q?iption=20=E0=B8=82=E0=B8=AD=E0=B8=87=20input=20version=20?=
=?UTF-8?q?=E0=B9=83=E0=B8=99=20uat.yml=20=E0=B9=83=E0=B8=AB=E0=B9=89?=
=?UTF-8?q?=E0=B8=AA=E0=B8=B1=E0=B9=89=E0=B8=99=E0=B8=A5=E0=B8=87=20?=
=?UTF-8?q?=E2=80=94=20"=E0=B9=80=E0=B8=A7=E0=B8=AD=E0=B8=A3=E0=B9=8C?=
=?UTF-8?q?=E0=B8=8A=E0=B8=B1=E0=B8=99=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B8=88?=
=?UTF-8?q?=E0=B8=B0=20deploy=20=E0=B9=80=E0=B8=8A=E0=B9=88=E0=B8=99=201.2?=
=?UTF-8?q?.3=20(=E0=B8=A7=E0=B9=88=E0=B8=B2=E0=B8=87=20=3D=20latest)"?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Code
---
.forgejo/workflows/uat.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.forgejo/workflows/uat.yml b/.forgejo/workflows/uat.yml
index a648843..d999b9e 100644
--- a/.forgejo/workflows/uat.yml
+++ b/.forgejo/workflows/uat.yml
@@ -7,7 +7,7 @@ on:
workflow_dispatch:
inputs:
version:
- description: "เวอร์ชันที่จะ deploy เช่น 1.2.3 / v1.2.3 / deploy1.2.3 — จะ map เป็น image v1.2.3 | เว้นว่าง = latest (image ล่าสุดที่ build.yml push ไว้)"
+ description: "เวอร์ชันที่จะ deploy เช่น 1.2.3 (ว่าง = latest)"
required: false
type: string
From b1bc65e65dfcc612d727bbc4b4f37fe259caf8b2 Mon Sep 17 00:00:00 2001
From: Your Name
Date: Thu, 17 Sep 2026 17:42:23 +0700
Subject: [PATCH 08/52] =?UTF-8?q?sidebar:=20=E0=B9=81=E0=B8=97=E0=B9=87?=
=?UTF-8?q?=E0=B8=9A=E0=B8=AB=E0=B8=A5=E0=B8=B1=E0=B8=81=E0=B8=81=E0=B8=A5?=
=?UTF-8?q?=E0=B8=B1=E0=B8=9A=E0=B9=84=E0=B8=9B=E0=B9=83=E0=B8=8A=E0=B9=89?=
=?UTF-8?q?=20label=20=E0=B8=A0=E0=B8=B2=E0=B8=A9=E0=B8=B2=E0=B8=AD?=
=?UTF-8?q?=E0=B8=B1=E0=B8=87=E0=B8=81=E0=B8=A4=E0=B8=A9=E0=B9=80=E0=B8=AB?=
=?UTF-8?q?=E0=B8=A1=E0=B8=B7=E0=B8=AD=E0=B8=99=E0=B9=80=E0=B8=94=E0=B8=B4?=
=?UTF-8?q?=E0=B8=A1=20=E2=80=94=20Bookmark=20(N)=20/=20Annotation=20(N)?=
=?UTF-8?q?=20=E0=B9=81=E0=B8=97=E0=B8=99=20=E0=B8=97=E0=B8=B5=E0=B9=88?=
=?UTF-8?q?=E0=B8=84=E0=B8=B1=E0=B9=88=E0=B8=99=E0=B8=AB=E0=B8=99=E0=B9=89?=
=?UTF-8?q?=E0=B8=B2/=E0=B8=AB=E0=B8=A1=E0=B8=B2=E0=B8=A2=E0=B9=80?=
=?UTF-8?q?=E0=B8=AB=E0=B8=95=E0=B8=B8=20(=E0=B8=84=E0=B8=87=E0=B8=95?=
=?UTF-8?q?=E0=B8=B1=E0=B8=A7=E0=B9=80=E0=B8=A5=E0=B8=82=E0=B8=99=E0=B8=B1?=
=?UTF-8?q?=E0=B8=9A=E0=B9=81=E0=B8=A5=E0=B8=B0=20key=20=E0=B8=82=E0=B8=AD?=
=?UTF-8?q?=E0=B8=87=20tab=20=E0=B9=80=E0=B8=94=E0=B8=B4=E0=B8=A1=20?=
=?UTF-8?q?=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B9=81=E0=B8=95=E0=B9=88=E0=B8=95?=
=?UTF-8?q?=E0=B8=A3=E0=B8=A3=E0=B8=81=E0=B8=B0=E0=B8=AD=E0=B8=B7=E0=B9=88?=
=?UTF-8?q?=E0=B8=99)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Code
---
components/sidebar/AnnotationSidebar.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/components/sidebar/AnnotationSidebar.tsx b/components/sidebar/AnnotationSidebar.tsx
index 5cd713f..3c80a31 100644
--- a/components/sidebar/AnnotationSidebar.tsx
+++ b/components/sidebar/AnnotationSidebar.tsx
@@ -1063,8 +1063,8 @@ export default function AnnotationSidebar({
{/* M5: เงาขวาบอกว่าเลื่อนต่อได้ — หายเมื่อถึงท้ายแถบ (จอ ≥ lg ไม่มีการ scroll) */}
From f7cd4b8312deaff9a1078bfb0698cb500937fbf5 Mon Sep 17 00:00:00 2001
From: Your Name
Date: Tue, 22 Sep 2026 09:57:55 +0700
Subject: [PATCH 12/52] =?UTF-8?q?viewer:=20=E0=B9=80=E0=B8=9B=E0=B8=A5?=
=?UTF-8?q?=E0=B8=B5=E0=B9=88=E0=B8=A2=E0=B8=99=E0=B8=95=E0=B8=B1=E0=B8=A7?=
=?UTF-8?q?=E0=B9=80=E0=B8=A5=E0=B8=B7=E0=B9=88=E0=B8=AD=E0=B8=99=E0=B8=82?=
=?UTF-8?q?=E0=B8=AD=E0=B8=87=E0=B8=AB=E0=B8=99=E0=B9=89=E0=B8=B2=20annota?=
=?UTF-8?q?te=20=E0=B8=88=E0=B8=B2=E0=B8=81=20window=20=E0=B8=A1=E0=B8=B2?=
=?UTF-8?q?=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=20=20=E0=B8=82?=
=?UTF-8?q?=E0=B8=AD=E0=B8=87=E0=B9=80=E0=B8=AD=E0=B8=81=E0=B8=AA=E0=B8=B2?=
=?UTF-8?q?=E0=B8=A3=20=E2=80=94=20zoom=20=E0=B9=80=E0=B8=81=E0=B8=B4?=
=?UTF-8?q?=E0=B8=99=E0=B8=88=E0=B8=AD=E0=B9=81=E0=B8=A5=E0=B9=89=E0=B8=A7?=
=?UTF-8?q?=E0=B9=80=E0=B8=A5=E0=B8=B7=E0=B9=88=E0=B8=AD=E0=B8=99=E0=B9=84?=
=?UTF-8?q?=E0=B8=94=E0=B9=89=E0=B8=97=E0=B8=B1=E0=B9=89=E0=B8=87=E0=B8=AA?=
=?UTF-8?q?=E0=B8=AD=E0=B8=87=E0=B9=81=E0=B8=81=E0=B8=99=20sidebar/header?=
=?UTF-8?q?=20=E0=B8=AD=E0=B8=A2=E0=B8=B9=E0=B9=88=E0=B8=81=E0=B8=B1?=
=?UTF-8?q?=E0=B8=9A=E0=B8=97=E0=B8=B5=E0=B9=88=20(=E0=B8=84=E0=B8=B3?=
=?UTF-8?q?=E0=B8=82=E0=B8=AD=E0=B8=9C=E0=B8=B9=E0=B9=89=E0=B9=83=E0=B8=8A?=
=?UTF-8?q?=E0=B9=89=202026-09-22:=20=E0=B9=80=E0=B8=94=E0=B8=B4=E0=B8=A1?=
=?UTF-8?q?=E0=B9=80=E0=B8=A5=E0=B8=B7=E0=B9=88=E0=B8=AD=E0=B8=99/?=
=?UTF-8?q?=E0=B8=8B=E0=B8=B9=E0=B8=A1=E0=B8=97=E0=B8=B5=E0=B9=80=E0=B8=94?=
=?UTF-8?q?=E0=B8=B5=E0=B8=A2=E0=B8=A7=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99?=
=?UTF-8?q?=E0=B8=97=E0=B8=B1=E0=B9=89=E0=B8=87=E0=B8=AB=E0=B8=99=E0=B9=89?=
=?UTF-8?q?=E0=B8=B2=E0=B9=80=E0=B8=A7=E0=B9=87=E0=B8=9A=E0=B8=A3=E0=B8=A7?=
=?UTF-8?q?=E0=B8=A1=20sidebar=20=E0=B9=84=E0=B8=9B=E0=B8=94=E0=B9=89?=
=?UTF-8?q?=E0=B8=A7=E0=B8=A2)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- PDFViewer root เป็น app shell flex h-dvh overflow-hidden (dvh กันปัญหา URL bar ของจอสัมผัส) + content column เป็น flex-col + ได้ class pdf-scroll-main min-h-0 flex-1 overflow-auto overscroll-contain = ตัวเลื่อนเดียวของหน้า; aside เปลี่ยน h-screen เป็น h-full (ถอด sticky top-0 ที่เป็น no-op แล้ว เหลือ top-0 สำหรับ max-lg:fixed)
- class ใหม่ .pdf-scroll-main ใน globals.css: touch-action pan-x pan-y บนพื้นที่เอกสาร — กัน browser pinch/double-tap zoom ทั้งหน้าเว็บ (อาการ sidebar โต้ตามตอนซูมบน tablet) วางก่อน .pdf-custom-select เพื่อให้โหมดมาร์กข้อความ (touch-action:none) ยังชนะตอนเปิดใช้; ปุ่มซูม +- เป็นทางเลือก single-pointer ตาม WCAG 2.5.1
- retarget scroll ทุกจุดจาก window มาที่ mainRef.current: scrollToBelowHeader รับ main + helper ใหม่ contentTopInScroll(main, clientTop) แทนสูตร r.top + window.scrollY (ลบ viewerHeaderHeight ที่ไม่จำเป็น — header อยู่นอกกรอบ scroll), ตรวจหน้าปัจจุบันย้าย listener มาที่ main, IO ของ virtualization ตั้ง root เป็น main, deep link ?page/?ann, goToPage/goToBookmark/goToAnnotation/currentViewPosition, tool pan ใช้ main.scrollBy (ลากได้ทั้งแนวนอนด้วย)
- useCustomSelection auto-scroll: เลื่อน scroll container ของ แทน window + เพิ่มแกนนอน (ลากชิดขอบซ้าย/ขวาเลื่อนต่อ — แก้ zoom แล้วเข้าไม่ถึงครึ่งขวาของหน้าในโหมดมาร์กข้อความบน tablet) และขอบวัดจาก rect ของ main (ใต้ header) แทนขอบจอที่โดน header บัง + hint เปลี่ยนเป็น "ลากชิดขอบจอเพื่อเลื่อนหน้า"
- sync กติกา .claude/rules/pdf-viewer.md: เพิ่ม bullet scroll architecture (ห้ามกลับไปเลื่อนที่ window) + แก้คำอธิบาย pan/auto-scroll ที่อ้าง window เดิม
verify: npx tsc --noEmit ผ่าน, vitest 139/139 ผ่าน — การวาด/สไตลัส/pressure และพิกัด % ไม่ถูกแตะ
Co-Authored-By: Claude Code
---
.claude/rules/pdf-viewer.md | 5 +-
app/globals.css | 10 ++++
components/pdf/PDFViewer.tsx | 97 +++++++++++++++++++++---------------
hooks/useCustomSelection.ts | 48 ++++++++++--------
4 files changed, 96 insertions(+), 64 deletions(-)
diff --git a/.claude/rules/pdf-viewer.md b/.claude/rules/pdf-viewer.md
index 7c33ca7..0daa23f 100644
--- a/.claude/rules/pdf-viewer.md
+++ b/.claude/rules/pdf-viewer.md
@@ -6,6 +6,7 @@ paths:
# PDF Viewer Rules (รายละเอียดเต็ม: docs/PLAN.md §1, §6, §8)
+- **Scroll architecture (2026-09-22):** root ของ PDFViewer เป็น app shell `flex h-dvh overflow-hidden` — **ตัวเลื่อนเดียวของหน้าคือ `` ของเอกสาร** (`pdf-scroll-main min-h-0 flex-1 overflow-auto overscroll-contain` ทั้งแกนตั้ง+แนวนอน ซูมจนหน้าเกินจอแล้วเลื่อนดูได้, sidebar/header อยู่นอกกรอบ scroll จึงนิ่ง) — **ห้ามกลับไปเลื่อนที่ window/document** (`window.scrollY` เป็น 0 เสมอ): ทุก scroll API (bookmark/deep link/goToPage/goToAnnotation/pan/auto-scroll) ต้องเลื่อน `mainRef.current` และแปลงพิกัดด้วย `contentTopInScroll(main, clientTop)` (แทน `r.top + window.scrollY`); `.pdf-scroll-main` ตั้ง `touch-action: pan-x pan-y` กัน browser pinch-zoom ทั้งหน้าเว็บ (ต้องอยู่ก่อน `.pdf-custom-select` ใน globals.css เพื่อให้ touch-action:none ของโหมดมาร์กข้อความชนะ) — IO ของ virtualization ตั้ง `root: mainRef.current`
- Layer stack ตายตัวตาม DOM order: `