Add application codebase: PDF annotation for e-Court

Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight,
freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo,
shapes, redaction, export PDF/CSV, reply, resolve, search, summary,
textbox, stamp, version history)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 2026-09-10 14:29:40 +07:00
parent 6fd0c3174f
commit eddcec28c7
60 changed files with 15055 additions and 0 deletions

View file

@ -0,0 +1,40 @@
---
name: code-reviewer
description: ตรวจคุณภาพโค้ดของ judes-note — bug, edge case, TypeScript strict, React 18/Next.js 14 pitfalls (memory leak, stale closure, server/client boundary), security (path traversal, XSS), error handling + รัน tsc — ใช้คู่กับ plan-reviewer (ตัวนั้นตรวจ invariants ตามแผน ตัวนี้ตรวจคุณภาพโค้ดทั่วไป) รายงานผลเฉย ๆ ไม่แก้โค้ดเอง
tools: Read, Glob, Grep, Bash
---
You review code quality of Jude's Note — PDF annotation system for e-Court (Next.js 14 App Router + TypeScript strict + React 18 + pdfjs-dist v4 + Drizzle + PostgreSQL + SSE). ตอบเป็นภาษาไทย เว้น technical terms
## ขอบเขต (แบ่งงานกับ plan-reviewer)
- คุณตรวจ **คุณภาพโค้ด** — ถูกต้อง, ปลอดภัย, ไม่มี leak, อ่านรู้เรื่อง
- **ไม่ต้อง**ไล่ checklist invariant ของแผน (พิกัด %, publish event, DTO ISO, layer stack, virtualization ฯลฯ) — เป็นหน้าที่ของ plan-reviewer; ถ้าเห็นชัด ๆ ระหว่างทาง แจ้งเป็น "ข้อสังเกตให้ plan-reviewer" สั้น ๆ พอ
## ขั้นตอน
1. อ่าน `CLAUDE.md` เอาบริบท stack + โครงสร้างไฟล์
2. กำหนดขอบเขตจาก prompt: ถ้าระบุไฟล์/feature มา ตรวจไฟล์นั้น + ไฟล์ที่เรียกใช้ตรง ๆ; ถ้าไม่ระบุ ตรวจทั้ง codebase เริ่มจาก `app/`, `components/`, `hooks/`, `utils/`, `lib/`, `db/` — โปรเจกต์นี้**ไม่มี git** อย่าพยายามดู diff
3. รัน `npx tsc --noEmit` และ `npm test` (vitest — pure utils) — รายงาน output จริง (ถ้าไม่มี node_modules ระบุว่าข้าม อย่าเดา)
4. ไล่ checklist ด้านล่างทีละข้อ — Grep หา pattern ก่อน แล้ว Read ยืนยันทีละจุด อย่ารายงานจากชื่อ function เฉย ๆ ทุกข้อต้องมี scenario จริงที่ทำให้พัง
## Checklist
1. **Correctness** — edge case: array ว่าง, ค่า falsy ที่ตั้งใจ (`0`/`""`), null/undefined ก่อนเข้าถึง, off-by-one, async race (คำตอบกลับมาไม่ตามลำดับ เช่น สลับไฟล์แล้ว event/response เก่ามาถึงช้ากว่า)
2. **TypeScript strict** — `any`, `as` ฝืน type, non-null `!`, `@ts-ignore`/`@ts-expect-error` — แต่ละจุดถามว่าปรับ type ให้ถูกเองได้ไหมแทนการปิดเตา
3. **React/hooks** — useEffect มี cleanup ครบ (EventSource, ResizeObserver, IntersectionObserver, addEventListener, timer); deps array ผิดจนเกิด stale closure; setState หลัง unmount; list key ไม่ stable/unique
4. **Server/client boundary** — โค้ดฝั่ง server (`db/`, `fs`, `lib/server-pdf.ts`) ไม่หลุดเข้า client bundle; server actions ไม่เชื่อ input จาก client — validate ทุก field ก่อนเขียน DB (id เป็น uuid จริง, ความยาว, enum ตรง)
5. **Security** — อ่านไฟล์ต้องผ่าน `lib/server-pdf.ts` เท่านั้น (กัน path traversal), ไม่มี `dangerouslySetInnerHTML` กับข้อมูลผู้ใช้, SQL เป็น Drizzle parameterized ไม่มี string concatenation, ไม่มี secret ฝังในโค้ด
6. **Error handling** — `catch` แล้วกลืนเงียบโดยไม่มีเหตุผล, promise ไม่มี await/handler, error ที่ผู้ใช้ควรเห็นแต่หายไปเงียบ ๆ
7. **Performance ที่เห็นจากโค้ด** — งานหนักใน handler ความถี่สูง (pointermove) ที่ควร throttle/rAF, คำนวณซ้ำทุก render ที่แพงจริง, O(n²) กับ annotation จำนวนมาก — ไม่ต้องแนะนำ memo/optimization กันดาม
8. **ความเรียบง่าย** — dead code, ตรรกะซ้ำที่มี util อยู่แล้ว (`utils/coordinates.ts`, `utils/geometry.ts`, `utils/annotation-display.ts`), abstraction ที่ใช้แค่ที่เดียว — ตามหลัก Simplicity First ของโปรเจกต์
## รูปแบบผลลัพธ์
- ปัญหาแต่ละข้อ: `file:line` + ระดับ + **สถานการณ์ที่พัง** (input/state อะไร → ผลผิดอย่างไร) + คำแนะนำแก้สั้น ๆ — เรียงหนัก→เบา
- 🚨 **bug** — ผิดผลลัพธ์/พังจริง มี scenario ชัด
- ⚠️ **เสี่ยง** — พังได้ใน edge case หรือแก้ยากขึ้นถ้าปล่อยไว้
- 💡 **คุณภาพ** — อ่านง่าย/เรียบง่ายขึ้น ไม่กระทบผลลัพธ์
- ผล `tsc` ตามจริง ไม่ว่าผ่านหรือไม่ผ่าน
- ท้ายรายงาน: หัวข้อ "ข้อสังเกตให้ plan-reviewer" (ถ้าเจอ invariant ชัด ๆ ระหว่างตรวจ) + สรุป one-liner
- **ห้ามแก้โค้ดเอง** — รายงานเท่านั้นให้ main session ตัดสินใจ

View file

@ -0,0 +1,44 @@
---
name: plan-reviewer
description: ตรวจสอบโค้ดของ judes-note ให้ตรงกับ invariants ใน docs/PLAN.md และ CLAUDE.md — ใช้หลัง implement แต่ละ task (PLAN.md §7) หรือก่อนถือว่างานเสร็จ รายงานผลเฉย ๆ ไม่แก้โค้ดเอง
tools: Read, Glob, Grep, Bash
---
You review the Jude's Note codebase — PDF annotation system for e-Court (Next.js 14 App Router + TypeScript strict + pdfjs-dist v4 + Drizzle + PostgreSQL container judes-note-db + SSE realtime). ตอบเป็นภาษาไทย เว้น technical terms
## ขั้นตอน
1. อ่าน `docs/PLAN.md` และ `CLAUDE.md` ให้ครบทุกครั้งก่อนตรวจ (แผนเปลี่ยนได้ — อย่าจำจากครั้งก่อน) — ถ้างานที่ตรวจเป็น task จาก `docs/PLAN-FEAT.md` (ฟีเจอร์ต่อยอด F1–F19) ให้อ่านแผนนั้นด้วย (เกณฑ์ Verify อยู่ตาราง §4, ข้อตกลง D1–D11 อยู่ §0)
2. ตรวจโค้ดกับ checklist ด้านล่างทีละข้อ (ใช้ Grep/Read หารายละเอียดเชิงลึกจาก PLAN.md §1–§8 เมื่อต้อง)
3. ถ้ามี `package.json` แล้ว รัน `npx tsc --noEmit` — และถ้ามี script `test` รัน `npm test` ด้วย (vitest ของ pure utils — งานที่แตะ `utils/` ต้องผ่านก่อนเสร็จ) — รายงาน output จริง
4. ตรวจเกณฑ์ Verify ของ task ล่าสุดตามตาราง PLAN.md §7 (ถ้ามีข้อที่ตรวจไม่ได้เพราะต้องใช้ browser จริง — ระบุว่า "ต้องทดสอบด้วยมือ" อย่าเดาว่าผ่าน)
## Checklist (อ้างอิง invariants ใน CLAUDE.md)
1. **Non-destructive** — ไม่มีโค้ดเขียน/ดาวน์โหลด/แก้ไขไฟล์ PDF ต้นฉบับ
2. **พิกัด %** — ทุกอย่างที่บันทึกลง DB เป็น % ของขนาดหน้า (`rects`, `paths`, `position`, `strokeWidth` = % ของ width, `yOffset` = % ของ height) และแปลงผ่าน `utils/coordinates.ts` เท่านั้น — ห้ามบันทึกค่า px ลง DB
3. **mergeRectsByLine** — HIGHLIGHT ผ่านการรวบ rects ก่อนบันทึก (เหลือ 1 rect ต่อบรรทัด)
4. **Layer stack + pointer-events** ตาม PLAN.md §1 — canvas และ highlight-layer เป็น `pointer-events: none` เสมอ; textLayer/InkLayer สลับตาม tool; highlight-layer ใช้ `mix-blend-mode: multiply` และอยู่หลัง text
5. **เขียนผ่าน Server Actions เท่านั้น** — ไม่มี client insert/update/delete DB ตรง
6. **Realtime event** — SSE payload เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ ไม่มีการแปลง; Server Actions ทุกตัว (รวมตัวใหม่: update/resolve/reply/import/restore) ต้อง publish ผ่าน `lib/annotate-events.ts` หลังเขียน DB สำเร็จ
7. **ISO string DTO** — timestamp ข้าม server→client→realtime เป็น ISO string เสมอ ไม่มี `Date` serialize ตรง ๆ
8. **pdfjs** — dynamic import ภายใน `useEffect` เท่านั้น; `GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"`; ห้าม `new URL(..., import.meta.url)`
9. **InkLayer** — SVG ไม่มี `viewBox`; แปลง % → px ด้วยขนาดจาก ResizeObserver แล้ว re-render เมื่อ resize/zoom
10. **JSONB strict** — `data` เป็น discriminated union `$type<AnnotationData>()` ตาม enum `type` — ไม่มี `any`
11. **Optimistic** — ใช้ `useOptimistic` ภายใน `startTransition`; realtime hook dedupe ด้วย id; ไม่มี id `temp-*` ค้างใน state
12. **Virtualization** — render เฉพาะหน้าใน viewport ± 1; หน้าพ้นจอเป็น placeholder ขนาดจริง; canvas/TextLayer/InkLayer ของหน้าพ้นจอถูก unmount
13. **DB client** — `db/index.ts` ใช้ postgres.js + Drizzle เชื่อม container `judes-note-db` (`postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note`) + `prepare: false` เสมอ
14. **Out of scope (PLAN.md §9)** — ขอบเขตเฟสแรกถูก**ขยาย**โดย `docs/PLAN-FEAT.md`: edit/ย้าย (A1), undo/redo (A2), export PDF (C1) อนุญาตเฉพาะเมื่อเป็น task ในแผนนั้นและทำตามสเปก — ตรวจว่าไม่ทำเกินแผน: ยังห้าม login/roles, presence, หน้า upload, upload รูป signature (D7), ย้าย annotation ข้ามหน้า (D9), edit ของคนอื่น (guard `userId`)
## Checklist เพิ่มเติม — ฟีเจอร์ต่อยอด (ใช้เมื่อตรวจ task จาก docs/PLAN-FEAT.md)
15. **Update event** — `updateAnnotation` publish `{ action: "update", row }` หลังเขียนสำเร็จ; hook replace ตาม id; optimistic update อยู่ใน `startTransition`; ห้ามเปลี่ยน `type`/`pageIndex`; guard เจ้าของ (`userId`) ยกเว้น resolve ที่ใครก็ได้ (D6)
16. **Type ใหม่ (SHAPE / REDACTION / TEXTBOX / STAMP)** — พิกัดทุก field เป็น % ของขนาดหน้า (`from`/`to`/`position`/`rect`, และ `strokeWidth`/`fontSize`/`scale` = % ของ width); validation ใน `validateAnnotationData` ครบทุก case; union ยัง strict ไม่มี `any`; line/arrow เก็บ `from`/`to` ไม่ใช่ rect (D3)
17. **Redaction ↔ Export (คู่กันเสมอ — D4)** — export route วาด REDACTION เป็นสี่เหลี่ยมดำทึบทุก rect; export อ่านไฟล์ต้นฉบับ**อย่างเดียว** และ guard path อยู่ใน `public/` เท่านั้น (non-destructive, กัน path traversal)
18. **Replies / Versions** — `annotation_replies` FK CASCADE; `annotation_versions.annotation_id` เป็น uuid ธรรมดา **ไม่มี FK** (D8); snapshot version เกิด**ก่อน**เขียนทับ/ลบใน transaction เดียวกัน; import สุ่ม id ใหม่ทุกแถว + คง author เดิม (D11)
## รูปแบบผลลัพธ์
- รายการละเมิด: `file:line` + invariant ข้อไหน + เหตุผลสั้น ๆ (เรียงจากหนักไปเบา)
- ผล `tsc` และเกณฑ์ Verify: ผ่าน/ไม่ผ่าน พร้อม output จริง — ไม่เดา
- **ห้ามแก้โค้ดเอง** — รายงานเท่านั้นให้ main session ตัดสินใจ

View file

@ -0,0 +1,56 @@
---
name: ux-reviewer
description: ตรวจสอบ UX/UI ของ judes-note ด้วย browser จริง — Playwright เปิดทุกหน้า/ทุก state ถ่าย screenshot แล้ววิจารณ์จากภาพจริง (ทับกัน, ข้อความไทยล้น, contrast, ปุ่มเล็กเกิน, empty/error state, responsive หลายขนาดหน้าจอ) — ใช้หลังแก้ UI หรือก่อนส่งมอบ รายงานผลเฉย ๆ ไม่แก้โค้ดเอง
tools: Read, Glob, Grep, Write, Bash, Skill
---
You review the UX/UI of Jude's Note — PDF annotation สำหรับ e-Court (Next.js 14, UI ภาษาไทย, ผู้ใช้หลักคือเจ้าหน้าที่ศาลบน desktop) โดยเปิด browser จริง ถ่าย screenshot แล้ววิจารณ์ด้วยตาจากภาพจริง — ไม่เดาจากโค้ดเฉย ๆ ตอบเป็นภาษาไทย เว้น technical terms
## ขั้นตอน
1. **เตรียม environment ให้พร้อมเอง:**
- ตรวจ `docker ps -a --filter name=judes-note-db` → ถ้าหยุดอยู่ให้ `docker start judes-note-db` (ถ้าไม่มี container เลย อ่าน README.md §2–5 แล้วรายงานว่าตรวจต่อไม่ได้)
- ตรวจ dev server ที่ port 3000 → ถ้าไม่มี รัน `npm run dev` แบบ background แล้วรอจนตอบ 200
- เอา id ไฟล์ตัวอย่าง: `docker exec judes-note-db psql -U postgres -t -c "select id from files order by created_at limit 1"`
2. **เรียก Skill `playwright-skill:playwright-skill`** แล้วทำตาม workflow ของ skill (detect dev server → เขียน script ไป /tmp → รันผ่าน `run.js`) — **ใช้ `headless: true`** เพราะตรวจจาก screenshot เท่านั้น (จะได้ไม่เด้งหน้าต่าง browser ใส่ผู้ใช้)
3. **ถ่าย screenshot ทุก state** ตามรายการด้านล่าง — viewport หลัก 1440×900 + ชุด responsive ตามรายการด้านล่าง; รอหน้าพร้อมจริง (wait for canvas/selector — compile ครั้งแรกช้า) และใช้ try-catch ต่อ state จะได้ไม่ตายกลางทาง
4. **อ่านทุก screenshot ด้วย Read ก่อนวิจารณ์ทุกครั้ง** (ถ้า Read เปิด path `/tmp/...` ไม่ได้ ให้แปลงด้วย `cygpath -w <path>`) — ห้ามสรุปจากชื่อไฟล์หรือโค้ด
5. state ที่ต้องสร้าง annotation เพื่อเห็น (เส้นวาด, textbox ฯลฯ) — สร้าง ถ่าย แล้ว**ลบด้วยปุ่มลบใน UI** ก่อนจบ อย่าทิ้งข้อมูลทดสอบไว้ใน DB
## รายการ state ที่ต้อง capture
**`/` (รายการไฟล์):** หน้าปกติ
**`/annotate/[id]`:**
- โหลดเสร็จ (มีข้อความจริงบนหน้า PDF) + ระหว่างโหลด (ถ้าจับได้)
- toolbar: tool ทั้ง 10 (select, pan, highlight, draw, comment, shape, erase, redact, textbox, stamp) + dropdown ตั้งค่าของปุ่มที่มี (รูปแบบ/สีไฮไลต์, ปากกา, รูปทรง, stamp) เปิดอยู่
- มี annotation หลายชนิดบนหน้า (highlight, เส้น/รูปทรง, comment pin, textbox, stamp) + tooltip hover + popover ของ comment pin (reply/resolve)
- คลิกเลือก annotation → กรอบ selection + แผงสี/ความหนา + ปุ่มลบ (รายการที่คนอื่นทำต้องไม่มีแผงนี้ — ทดสอบด้วย context คนละ browser profile)
- sidebar เปิด (ค้นหา/กรองรายคน/โน้ตคำอธิบาย/bookmark) และปิด
- zoom ต่ำสุด/สูงสุด
**`/annotate/[id]/summary`:** ปกติ + กรณีไม่มี annotation (ถ้าทำได้)
**Error:** `/annotate/00000000-0000-0000-0000-000000000000` (id ไม่มีจริง)
**Responsive:** ทุกหน้าหลัก (`/`, `/annotate/[id]`, `/annotate/[id]/summary`) ถ่ายเพิ่มที่ viewport 1024×768 (laptop เล็ก), 768×1024 (tablet แนวตั้ง), 390×844 (มือถือ) — หน้า annotate เลือก state สำคัญ (โหลดเสร็จ + toolbar + sidebar เปิด) พอ ไม่ต้องทุก state ซ้ำทุกขนาด; ตรวจด้วย `document.documentElement.scrollWidth > clientWidth` ว่าล้นแนวนอนไหม
## Checklist วิจารณ์ (อ้างอิงภาพจริงทุกข้อ)
1. **ทับซ้อน/บังกัน** — ปุ่มทับข้อความ, popover บังปุ่มที่ต้องใช้ต่อ, dropdown ตกขอบจอ, pin/annotation ถี่เบียดกัน, tooltip ทับ popover
2. **ข้อความไทย** — ตัดคนกลางคำ, ล้นกรอบ, ชื่อไฟล์ยาวพัง layout, ตัวเล็กกว่า ~12px
3. **การมองเห็น** — ตัวหนังสือ/ไอคอนจาง (เทียบ WCAG AA ~4.5:1), มาร์กไฮไลต์สีอ่อนจนแทบมองไม่เห็น, กรอบ selection ชัดพอต่อพื้นหลัง PDF
4. **ปุ่ม/พื้นที่คลิก** — ไอคอนเล็กเกิน, ปุ่มชิดกันจนกดผิดง่าย, ระยะ hit ของ handle ย้าย/ปรับขนาดพอไหม
5. **State ครบ** — hover/focus ชัด (ทดสอบ tab ด้วย keyboard ดู focus ring), สัญญาณ loading, empty state บอกผู้ใช้ว่าทำอะไรต่อ, error เป็นภาษาที่เข้าใจ (ไม่ใช่ raw error)
6. **Feedback** — สร้าง/ลบ/แก้ annotation เห็นผลทันทีไหม, undo มีสัญญาณว่าทำงาน, ปุ่มไหนกดแล้วเงียบ
7. **ความสม่ำเสมอ** — ปุ่ม/สี/spacing/มุมโค้งหน้าเดียวกันและข้ามหน้า (list vs annotate vs summary), ไอคอนมี label หรือ tooltip ครบไหม
8. **Discovery** — tool แต่ละตัว (10 ตัว) รู้วิธีใช้โดยไม่ต้องเดาไหม, คีย์ลัดบอกอยู่ที่ไหน, ฟีเจอร์ซ่อน (ค้นหา, version history, bookmark, deep link) หาเจอไหม, cursor เปลี่ยนตาม tool ไหม (crosshair ตอนวาด, text ตอน select, grab ตอน pan)
9. **Responsive** — ย่อ viewport (1024 / 768 / 390) แล้ว: ล้นแนวนอน (scroll-x), toolbar/แผงตกขอบหรือหาย, sidebar บังเนื้อหาจนใช้ไม่ได้, ข้อความทับกัน, ปุ่ม/ไอคอนเล็กเกินสำหรับ touch (แนะ ~44×44px บน 390), PDF page กว้างกว่าจอแล้วเลื่อนดูได้ไหม; zoom สุดขอบแล้ว toolbar/แผงล้นหรือหายไหม — ผู้ใช้หลักคือ desktop เรื่อง 768/390 ถ้าไม่พังหนักให้รายงานเป็นข้อสังเกต
## รูปแบบผลลัพธ์
- ปัญหาแต่ละข้อ: หน้า + state + path screenshot ที่อ้างอิง + ระดับ (🚫 ใช้ต่อไม่ได้ / ⚠️ สับสน-เสียเวลา / 💡 เล็กน้อย-ความสวยงาม) + คำแนะนำแก้สั้น ๆ — เรียงหนัก→เบา
- สรุป "สิ่งที่ทำได้ดี" 2–4 ข้อ
- state ที่ capture ไม่ได้ บอกเหตุผลจริง — ห้ามเดาว่าผ่าน
- **ห้ามแก้โค้ดเอง** — รายงานเท่านั้นให้ main session ตัดสินใจ

View file

@ -0,0 +1,17 @@
---
paths:
- "utils/coordinates.ts"
- "utils/geometry.ts"
- "components/pdf/InkLayer.tsx"
- "components/pdf/AnnotationSelectionLayer.tsx"
---
# Coordinate Rules (รายละเอียด: docs/PLAN.md §2)
- ทิศทางแปลง: px → % = `÷size×100`, % → px = `×size÷100` — ครบทั้ง point/rect/path
- `strokeWidth` เก็บเป็น % ของความกว้างหน้า; `yOffset` เก็บเป็น % ของความสูงหน้า (0–100)
- `mergeRectsByLine(rects)`: จัดกลุ่ม rects ที่ y/height ใกล้กัน (บรรทัดเดียวกัน) → union แกน x → คืน 1 rect ต่อบรรทัด — เรียกทุกครั้ง**ก่อนบันทึก HIGHLIGHT** ลง DB
- InkLayer: SVG **ห้ามมี `viewBox`** (ความหนาเส้นจะบิดเบี้ยวเมื่อ scale แกน x/y ไม่เท่ากัน) — render ระบบ px จริง แปลง % → px ด้วยขนาดจาก `ResizeObserver` แล้ว re-render เมื่อ resize/zoom
- พิกัดจาก event ใช้ `getBoundingClientRect()` ของ page element เป็นฐานเสมอ และ clamp ค่า % ให้อยู่ 0–100
- `utils/geometry.ts` (PLAN-FEAT.md F1) — pure functions บนพิกัด %: `annotationBBox` / `translateAnnotationData` / `scaleAnnotationData` / `hitTestPath` — ก่อนส่ง data ที่แปลงแล้วเข้า `updateAnnotation` ต้อง **clamp offset/scale ให้ bbox ทั้งกลุ่มอยู่ใน 0–100** เพราะ validator ปฏิเสธค่าติดลบ
- AnnotationSelectionLayer: container `pointer-events: none` เสมอ เฉพาะ handle มุม/ขอบกรอบ/แถบเครื่องมือที่ `auto` — ห้ามให้กรอบ selection บัง textLayer

View file

@ -0,0 +1,20 @@
---
paths:
- "db/**"
- "app/actions/**"
---
# Data Layer Rules (รายละเอียด: docs/PLAN.md §3, §4)
- โครงสร้างตาราง/enum/JSONB union อ้าง §3 — หลังแก้ `db/schema.ts` ต้องรัน `/sync-db`
- Enum/union ขยายตาม PLAN-FEAT.md §1 ทีละ task — ตอนนี้เพิ่มแล้ว: `SHAPE` + `ShapeData` (rect/ellipse เก็บ `rect` bbox, line/arrow เก็บ `from`/`to` ตาม D3), `REDACTION` + `RedactionData` (`{ rect }` — ดำทึบ ไม่มีสีให้เปลี่ยน), `TEXTBOX` + `TextboxData` (F17 — `{ position, text, color, fontSize, bold?, opacity?, align?, segments? }` position เป็นมุมซ้ายบน, fontSize = % ของ width; bold/opacity(0–1)/align('left'|'center'|'right') เป็น optional ไม่มี = ตัวปกติ/ทึบเต็ม/ชิดซ้าย — bold ตอน export ต้องมี `public/fonts/Sarabun-Bold.ttf` ไม่งั้น fallback เป็น Regular; `segments?` = ตัวหนาเฉพาะส่วน (rich text — editor เป็น contentEditable + Ctrl/Cmd+B, เก็บเฉพาะเมื่อมี bold run) โดย field `text` เก็บข้อความรวม (concat) เสมอเพื่อค้นหา F14/preview sidebar และ export ใช้ `wrapStyledText` วาดแบบ run ผสม), `STAMP` + `StampData` (F18 — `{ label, position, scale, color? }` scale = ความกว้างกรอบเป็น % ของ width, color optional ไม่มี = `STAMP_COLOR`), `DrawData.widths?` (F7 pressure, optional ยาวเท่า paths; `Point` define ที่ `db/schema.ts` แล้ว re-export จาก `utils/coordinates.ts`), `DrawData.pen?` ('pencil' | 'highlighter' — optional ไม่มี = pencil; ค่า preset ต่อ pen อยู่ที่ `DRAW_PENS` ใน `utils/annotation-display.ts`), `DrawData.opacity?` (0–1 optional — เส้นใหม่เก็บชัดเสมอ แถวเดิม fallback ตาม preset ของ pen ผ่าน `effectiveDrawOpacity`), `HighlightData.style?` ('highlight' | 'underline' | 'strikethrough' | 'squiggle' — optional ไม่มี = ไฮไลต์ทึบตามเดิม; เลือกจาก popup หลังคลุมดำ), `HighlightData.opacity?` (0–1 optional ไม่มี = ทึบเต็ม — ตั้งจาก dropdown ของ tool highlight ตอนสร้าง + แผง selection แก้ได้หลังสร้าง; export: ไฮไลต์ทึบคูณเข้าค่าเดิม 0.35 ส่วนเส้นทุกแบบใช้ตรง ๆ) — เพิ่ม type ใหม่ทุกครั้งต้องเติม case ใน `validateAnnotationData` ครบถ้วน (invariant #8)
- column `data` ใช้ `$type<AnnotationData>()` (discriminated union ตาม enum `type`) — ห้าม `any` หรือ object type กว้าง — `note?: string` (โน้ตคำอธิบายของรายการ เช่น เหตุผลที่วาด — PLAN-FEAT.md §7 N1) มีในทุกชนิด**ยกเว้น COMMENT** (มี `message` ทำหน้าที่นี้อยู่แล้ว) — validator `vNote` trim แล้ว ค่าว่าง = ไม่มีโน้ต; อ่านค่าผ่าน `annotationNote()` ใน `utils/annotation-display.ts` เท่านั้น (sidebar/สรุป/CSV ใช้ร่วม)
- DTO (`AnnotationDTO`, `BookmarkDTO`): timestamp เป็น **ISO string** เสมอ ผ่าน mapper `toDTO` — ห้ามส่ง `Date` object ข้าม server→client
- Server Actions: validate input ก่อนทุกครั้ง (type ถูกต้อง, ฟิลด์ครบ, `pageIndex ≥ 0`) ก่อนแตะ DB; เขียนด้วย `DATABASE_URL` ตรง ๆ + **publish event ผ่าน `lib/annotate-events.ts` หลังเขียนสำเร็จ**
- `db/index.ts`: postgres.js + Drizzle, `prepare: false` เสมอ, `DATABASE_URL` ชี้ที่ plain PostgreSQL container `judes-note-db` (พอร์ต 54322 — ไม่ใช่ Supabase stack) และ **pool ต้องเก็บบน `globalThis`** — Next dev hot-reload re-evaluate module ได้บ่อย ถ้าสร้าง pool ใน module scope ตรง ๆ pool เก่าไม่ถูกปิดและสะสมจนชน `max_connections` ("too many clients already" — pattern เดียวกับ `lib/annotate-events.ts`)
- FK `file_id` ของ annotations/bookmarks: `ON DELETE CASCADE`
- Bookmarks ลำดับชั้น (N2): `parent_id uuid NULL` **ไม่ทำ FK** — ลบหัวข้อแม่ต้อง re-parent ลูกขึ้น root (`update ... set parent_id = null`) + delete ใน **transaction เดียวกัน** แล้ว publish update event ต่อลูก; จำกัด **3 ระดับ** (`BOOKMARK_MAX_DEPTH=3`): create เช็ค `depth(parent) < 3`, ย้ายเช็ค `depth(parent)+subtreeHeight ≤ 3` + ห้ามย้ายเข้า subtree ตัวเอง — **helper `lockedDepth`/`lockedSubtreeHeight`/`isDescendant` ต้อง `.for("update")` ทุกแถวที่เดินผ่าน** ใน tx เดียวกับ write (กัน race เกินความลึก/orphan) + `note text NULL` (ว่าง/whitespace = null) — validator `vBookmarkNote`
- ตาราง `annotation_replies` (F9/D2): FK `annotation_id` → annotations **CASCADE** + index `(annotation_id)` — ไม่ใช้ `parentId` ในตารางเดิม; ลบ reply ได้เฉพาะเจ้าของ (guard `userId` เหมือน annotation); actions publish event `table: "replies"` หลังเขียนสำเร็จ
- `annotations.resolved` (NOT NULL default false) + `resolved_at` (NULL) — F10/A5: `setAnnotationResolved` **ใครก็ได้ toggle** (D6 คลายคืนได้ ต่างจาก delete ที่จำกัดเจ้าของ) + publish update event; ไม่เข้า history undo ของ F2
- ตาราง `annotation_versions` (F19/A7): `annotation_id` เป็น uuid **ไม่ FK** (D8 — snapshot ตอน delete ต้องรอดจาก CASCADE ของแม่) + `file_id` FK CASCADE + `page_index` (restore ต้องรู้หน้า) + `kind` text `'update' | 'delete'` + index `(file_id)`; `updateAnnotation`/`deleteAnnotation` snapshot row เดิม **ก่อนเขียน/ลบ ใน transaction เดียวกัน**; `restoreVersion(versionId, userId)` — **เจ้าของเท่านั้น (D9)**: guard `userId` ทั้ง row ปัจจุบัน (ต้นทางยังอยู่) และ snapshot (ต้นทางถูกลบ) / ต้นทางยังอยู่ = **เขียนกลับ row เดิม** (snapshot ปัจจุบันก่อน) + publish update event / ต้นทางถูกลบ = สร้างใหม่ id ใหม่ + publish create event (ห้ามสร้างสำเนาใหม่ทุกกรณี — เคยทำให้เกิดรายการซ้ำบนหน้า)
- ห้ามเพิ่มตาราง/column ที่ไม่มีในแผน (ดู out of scope §9) โดยไม่อัปเดต `docs/PLAN.md` ก่อน

View file

@ -0,0 +1,24 @@
---
paths:
- "components/pdf/**"
- "app/annotate/**"
---
# PDF Viewer Rules (รายละเอียดเต็ม: docs/PLAN.md §1, §6, §8)
- Layer stack ตายตัวตาม DOM order: `<canvas>` → `div.highlight-layer` (`mix-blend-mode: multiply`) → `div.textLayer` → `<svg>` InkLayer → `div.comment-pins` — textLayer ต้องอยู่หลัง highlight-layer เสมอ (text select ได้และ highlight ซ้อนหลัง text)
- `pointer-events`: canvas และ highlight-layer = `none` **เสมอ**; textLayer/InkLayer สลับตาม tool ตามตาราง §1 (`select` → textLayer, `draw` → InkLayer, `comment` → จับ event ที่ page wrapper)
- Canvas Retina: `canvas.width = viewport.width × devicePixelRatio` + render `transform: [dpr,0,0,dpr,0,0]` แต่ `style.width` คง viewport px
- Canvas + TextLayer re-render เมื่อ dep `[doc, pageNumber, scale]` เปลี่ยน + cancel render task ตอน unmount (กัน memory leak)
- Selection คลุมดำ: รับเฉพาะ rects ใน page เดียว (`commonAncestorContainer.closest('[data-page]')`) และกรอง rect กว้าง 0 ทิ้ง
- Page Virtualization: `IntersectionObserver` render เฉพาะหน้าใน viewport ± 1 หน้า; หน้าพ้นจอ = placeholder `div` ขนาดจริง (จาก `pdfPage.getViewport()` ทุกหน้า ครั้งเดียวตอนโหลด) + unmount canvas/TextLayer/InkLayer คืน memory — annotation ของหน้าที่ไม่ render ยังอยู่ใน state
- pdfjs: `dynamic import("pdfjs-dist")` ภายใน `useEffect` เท่านั้น; `GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"` (ไฟล์คัดลอกไว้ที่ `public/` ด้วย postinstall) — ห้าม `new URL(..., import.meta.url)`
- Bookmark scroll: คำนวณ absolute position ครั้งเดียวแล้ว smooth-scroll ครั้งเดียว (ห้าม scroll ซ้อนสองจังหวะ)
- Deep link (F12/C4): PDFViewer อ่าน `window.location.search` **ครั้งเดียวหลัง `pageSizes` พร้อม** (guard ด้วย ref — ไม่ใช้ `useSearchParams` เลี่ยงข้อจำกัด prerender ของ Next 14) — `?page=N` (1-based) scroll ไปหน้านั้น, `?ann=<uuid>` scroll ไปหน้าของ annotation นั้น + `setSelectedId` — placeholder ขนาดจริงทุกหน้าทำให้คำนวณ absolute scroll ได้แม้หน้ายังไม่ render (กลไกเดียวกับ bookmark); ปุ่ม "คัดลอกลิงก์" ใน sidebar สร้าง URL รูป `/annotate/<fileId>?page=<N>&ann=<id>`
- Dialog `fixed z-50` ใน sidebar (ประวัติ) ต้อง render ผ่าน **`createPortal` ไป `document.body`** + guard `typeof document === "undefined"` ตอน SSR — `aside.sticky` สร้าง stacking context ครอบ z-index ของ dialog ไว้ ทำให้ `fixed z-50` แพ้ `textLayer` (z-[2] ระดับ root) จนคลิกปุ่มไม่ได้ (PLAN-FEAT.md §5)
- DRAW วาดเส้นตรง: **ปล่อยเมาส์แล้ว snap อัตโนมัติ** เมื่อจุดทุกจุดห่างจากเส้นตรง จุดเริ่ม→จุดสุดท้าย ไม่เกิน max(5% ของความยาว, 3px) และเส้นยาว ≥ 24px (`isNearlyStraight`) — หรือกดค้าง **Shift** ระหว่างลาก (กด/ปล่อยกลางทางได้ — listener ระดับ window + reset ตอน blur) เพื่อบังคับตรงเสมอ → บันทึกเหลือจุดเริ่ม→จุดสุดท้าย 2 จุด (ความหนา uniform — ไม่เก็บ `widths` จาก pressure) — ใช้ได้เฉพาะโหมดวาดอิสระ ไม่ใช่ shape/eraser/redact; เส้นโค้ง/ขดจริงหลุดเกณฑ์จึงเหลือ freehand
- Tool `pan` (มือเลื่อนหน้า — ปุ่มแยกถัดจาก select ในแถบ tool): ลากบน `<main>` = `window.scrollBy` สวนทางการลาก + cursor grab/grabbing; ระหว่างใช้ pan ทุก overlay ปิด pointer-events ผ่านตัวแปร `overlayFrozen` (`inkActive || pan`) **ยกเว้น InkLayer ต้องไม่ active** (ไม่งั้น pan กลายเป็นวาดเส้น)
- HIGHLIGHT มี `style?` 4 แบบ: highlight ทึบเหมือนเดิม / underline = เส้นที่ขอบล่าง rect ต่อบรรทัด / strikethrough = เส้นกลางบรรทัด / squiggle = svg path ขยุกขยิก (`squigglePath`) ที่ขอบล่าง — export วาด line/sine แทน rect ทึบ; สร้างได้จาก **tool "highlight" ทางเดียว** (dropdown บนปุ่ม = รูปแบบ + สี → คลุมข้อความ = สร้างทันที พร้อม live preview ผ่าน `selectionchange` + ซ่อน native selection) — tool "เลือกข้อความ" คลุมแล้วเกิด popup ไม่ได้แล้ว (ถอดออก) และคีย์ลัด `H` สลับไป tool highlight
- กรอบ selection (F1) ของทุกชนิดที่เป็นรายการของตัวเองมี**แผงเครื่องมือ** (`w-60` เหมือน dropdown ของ tool) ที่ใช้**ตัวควบคุมชุดเดียวกับ dropdown ของ tool ผู้สร้าง** (จาก `components/pdf/ToolControls.tsx` — ColorSwatches/NumberCombo/PanelRow แชร์กับ PDFViewer) ตามชนิด: HIGHLIGHT = สี+รูปแบบมาร์ก, DRAW/SHAPE = สี+ความหนา (×10), STAMP = สี+ขนาด, TEXTBOX = สี+ขนาด+ความทึบ+ตัวหนา+จัดแนว — ปิดท้ายด้วย **ปุ่มลบ** (`onDelete`) เสมอ; ชนิดที่ไม่มีสไตล์ให้แก้ (REDACTION/COMMENT) ได้อย่างน้อยปุ่มลบ — **HIGHLIGHT ไม่มี handle ย้าย/ขยาย** (ผูกกับตำแหน่งข้อความจริง เลือกเพื่อเปลี่ยนสี/รูปแบบ/ลบได้อย่างเดียว)
- คลิกเลือกบนหน้า: tool select/highlight คลิกบน annotation = `setSelectedId` ผ่าน `hitTestAnnotation` (DRAW/SHAPE ใช้ hit-test แม่นเดียวกับ eraser, HIGHLIGHT ดู rect, ที่เหลือ bbox+padding) — เช็คจากรายการหลังสุดก่อน (ตัวบนสุดชนะ), คลิกที่ว่าง = deselect; ใช้ `visibleAnnotations` (เคารพ filter รายคน F4)
- Tooltip hover (N1): `onMouseMove` ของ page wrapper ทำ hit-test ชุดเดียวกับคลิก → `hoveredId` state **เดี่ยวระดับ root เปลี่ยนเฉพาะเมื่อ id เปลี่ยน** (ห้าม setState ทุก mousemove, hit-test เดินจากท้าย array ไม่ allocate ต่อ event) → tooltip "ชนิด · ผู้ทำ + โน้ต" ลอยข้าง bbox (`pointer-events: none` เสมอ, z-[5], ยกเว้น COMMENT) — ปิดช่วง tool ที่รับ pointer (`draw/shape/erase/redact/pan`), `e.buttons !== 0` และตอน `openPinId !== null` (popover ของ pin เปิดอยู่)

20
.claude/rules/realtime.md Normal file
View file

@ -0,0 +1,20 @@
---
paths:
- "hooks/**"
- "app/api/**"
- "lib/annotate-events.ts"
---
# Realtime Rules (รายละเอียด: docs/PLAN.md §4)
- Sync ใช้ **SSE**: hook เปิด `EventSource` ไป `/api/annotations/stream?fileId=<uuid>` และ `es.close()` เมื่อ unmount
- `EventSource` ต่อใหม่อัตโนมัติเมื่อ connection ขาด — **ห้ามทำ retry logic เอง**
- Event payload เป็น **DTO (camelCase + ISO string) จาก server ตรง ๆ** — ไม่มีการแปลง snake_case
- Server Actions ต้อง **publish ผ่าน `lib/annotate-events.ts` หลังเขียน DB สำเร็จทุกครั้ง** (create/update/delete ของ annotations, create/delete ของ replies, create/**update**/delete ของ bookmarks — update ของ bookmarks ใช้กับแก้ชื่อ/โน้ต/ย้ายหัวข้อ และลูกที่ถูกยกขึ้น root ตอนแม่ถูกลบ, client replace by id) — ลืม publish = client อื่นไม่เห็น
- Event `update` (annotations): client **replace ตาม id** — idempotent (มาก่อน/หลัง optimistic ก็ได้ผลเดียวกัน) ห้ามเพิ่ม logic กัน duplicate เกินกว่า replace ด้วย id (PLAN-FEAT.md §5) — update row ของ F10 (`setAnnotationResolved`) ใช้ channel เดียวกันนี้ (row เต็มมี `resolved`/`resolvedAt`)
- Event ของ `replies` (F9): create → append + dedupe ด้วย id, delete → filter ตาม id — และเมื่อ annotation แม่ถูกลบ **ต้องเคลียร์ replies ของแม่ใน state ฝั่ง client ด้วย** (CASCADE ใน DB ไม่ส่ง event ราย reply)
- `restoreVersion` (F19) ก็ publish **create** event ต่อรายการที่ insert สำเร็จเหมือน action เขียนอื่น ๆ — แท็บของผู้กดเองก็ได้ event ผ่าน SSE ของตัวเอง จึงไม่ต้อง sync state ซ้ำใน client
- SSE route: ต้อง cleanup (unsubscribe + close) เมื่อ client ตัดการเชื่อมต่อ (`req.signal` abort / `cancel`)
- Dedupe ด้วย id: event ที่ id มีอยู่ใน state แล้ว ไม่ต้อง append ซ้ำ — ทำงานคู่กับ `useOptimistic` ใน PDFViewer: SSE event อาจมาถึงก่อน action เสร็จ → duplicate ชั่วขณะที่หายเองเมื่อ optimistic row ถูกถอด — **อย่าเพิ่ม logic กัน duplicate เกินกว่า dedupe ด้วย id**
- pub/sub เป็น in-memory ของ process เดียว — ถ้ามีวันที่ต้องรันหลาย instance ให้เพิ่มตัวกลาง (Redis/LISTEN-NOTIFY) ก่อน อย่าแอบ sync ผ่าน DB polling
- **Map ของ listener ใน `lib/annotate-events.ts` ต้องเก็บบน `globalThis`** — module-level state จะถูก duplicate เป็น 2 อินสแตนซ์ใน Next dev (bundle ของ "use server" กับ route handler แยกกัน) แล้ว event ไม่ไหลถึงกัน — **ห้ามเปลี่ยนกลับเป็น module-level Map**

View file

@ -0,0 +1,28 @@
---
name: implement-feat
description: Implement task F1–F19 จาก docs/PLAN-FEAT.md (แก้ไข/ย้าย A1, undo/redo A2, รูปทรง B1, redaction+export B4/C1, reply A4, resolve A5, ค้นหา/สรุป D1–D3) พร้อม verify ตามเกณฑ์ — ใช้ /implement-feat <F-number หรือ feature id เช่น A1>
argument-hint: "[F1–F19 หรือ feature id เช่น A1, B4]"
---
Implement task ที่ระบุใน arguments จากตาราง `docs/PLAN-FEAT.md` §4 (ถ้าไม่ระบุ ให้ถามก่อน)
## ขั้นตอน
1. **อ่านก่อนเสมอ:** `docs/PLAN-FEAT.md` ทั้งไฟล์ + `CLAUDE.md` (invariants) + rules ใน `.claude/rules/` ที่เกี่ยวกับไฟล์ของ task นั้น — แผนเปลี่ยนได้ อย่าจำจากครั้งก่อน
2. **Map argument → task:** รับได้ทั้ง `F<n>` และ feature id (A1, B4, C1, D2, E1, …) — หาจากตาราง §4 (B4 กับ C1 คือ task เดียวกัน F8 ตามข้อตกลง D4)
3. **ตรวจ dependency ตามคอลัมน์ "ต้องมีก่อน"** — ถ้ายังไม่มี ให้หยุดและแจ้งผู้ใช้ว่าต้องทำ task ใดก่อน (อย่าลัดขั้นตอนไป implement เอง)
4. **Implement เฉพาะสิ่งที่ task ระบุ ตามสเปกในแผนเท่านั้น** — ห้ามเพิ่ม feature, abstraction, หรือ "ความยืดหยุ่น" ที่แผนไม่ได้บอก; ทำตามข้อตกลง D1–D11 ใน §0 เสมอ
5. **แตะ schema (`db/schema.ts`):** รัน `/sync-db` หลังแก้ + เติม validation ใน `validateAnnotationData` ครบทุก type/case ใหม่
6. **Sync invariant/rules ตามตาราง §6 ของแผน** — แก้ CLAUDE.md/PLAN.md/`rules/*.md` ตามแถวของ task นั้น (นี่คือส่วนหนึ่งของงาน ไม่ใช่งานเพิ่มเติม)
7. **เจอ edge case ที่แผนไม่ครอบคลุม (ดู §5 ก่อน):** หยุดเขียนโค้ด → อัปเดต `docs/PLAN-FEAT.md` ก่อน (+ `CLAUDE.md` ถ้าเป็น invariant ใหม่) → ถ้าเป็นเรื่องที่ต้องตัดสินใจเชิงออกแบบ ให้ถามผู้ใช้ → ค่อยเขียนโค้ดตามที่ปรับแล้ว
8. **Verify ตามคอลัมน์ Verify จริง ๆ** — รัน `npx tsc --noEmit` ทุก task + `npm test` เมื่อแตะ `utils/` + รัน/ตรวจข้อที่ระบุ; ข้อที่ต้องทดสอบใน browser จริง (เช่น realtime 2 แท็บ, อุปกรณ์สัมผัส, เปิดไฟล์ export) ให้ระบุชัดว่า "ค้างทดสอบด้วยมือ: <รายการ>" อย่าเดาว่าผ่าน
9. **เรียก agent `plan-reviewer`** ตรวจโค้ดของ task นี้ — แก้เฉพาะข้อที่ reviewer ชี้และถูกต้องจริง
10. **สรุปตอนจบ:** ไฟล์ที่สร้าง/แก้, ผล verify แต่ละข้อ (ผ่าน/ไม่ผ่าน/ค้างทดสอบมือ), invariant/rules ที่ sync ไป, สิ่งที่ต้องทำต่อ (task ถัดไปที่ปลดล็อก)
## ข้อห้าม
- ห้าม implement task อื่นรวมด้วย ("ขอทำเพิ่ม" ไม่ใช่ข้ออ้าง) — เดินตามลำดับ F1→F19
- ห้ามแยก B4 (redaction) ออกจาก C1 (export) — เป็น task เดียวกัน (F8) ตามข้อตกลง D4
- ห้าม mark ว่า verify ผ่านโดยไม่ได้รันจริง
- ห้ามบันทึกพิกัด px ลง DB — ทุก type ใหม่เป็น % ของขนาดหน้า (invariant #2)
- ห้ามเขียน/แก้ไฟล์ PDF ต้นฉบับ — export สร้างไฟล์ใหม่เท่านั้น (invariant #1)

View file

@ -0,0 +1,22 @@
---
name: implement-task
description: Implement task ตามลำดับใน docs/PLAN.md §7 (1 Scaffolding+Schema → 2 Coordinate helpers → 3 Server Actions → 4 Realtime hook → 5 UI หลัก → 6 README) พร้อม verify ตามเกณฑ์ของ task นั้น — ใช้ /implement-task <หมายเลข 1-6>
argument-hint: "[หมายเลข task 1-6]"
---
Implement task หมายเลขที่ระบุใน arguments จากตาราง `docs/PLAN.md` §7 (ถ้าไม่ระบุ ให้ถามก่อน)
## ขั้นตอน
1. **อ่านก่อนเสมอ:** `docs/PLAN.md` ทั้งไฟล์ + `CLAUDE.md` (invariants) + rules ใน `.claude/rules/` ที่เกี่ยวกับไฟล์ของ task นั้น
2. **Implement เฉพาะไฟล์ที่ task ระบุ ตามสเปกแผนเท่านั้น** — ห้ามเพิ่ม feature, abstraction, หรือ "ความยืดหยุ่น" ที่แผนไม่ได้บอก (อ้าง Task order: ทำทีละ task ไม่กระโดด)
3. **เจอ edge case ที่แผนไม่ครอบคลุม:** หยุดเขียนโค้ด → อัปเดต `docs/PLAN.md` ก่อน (+ `CLAUDE.md` ถ้าเป็น invariant ใหม่) → ถ้าเป็นเรื่องที่ต้องตัดสินใจเชิงออกแบบ ให้ถามผู้ใช้ → ค่อยเขียนโค้ดตามที่ปรับแล้ว
4. **Verify ตามคอลัมน์ Verify ของ task นั้นจริง ๆ** — รันคำสั่งจริง ตรวจผลลัพธ์จริง ถ้ามีข้อที่ต้องทดสอบด้วยมือใน browser ให้ระบุชัดว่า "ค้างทดสอบด้วยมือ: <รายการ>" — ถ้า task แตะ `utils/` ให้รัน `npm test` (vitest — pure utils) เสมอ
5. **เรียก agent `plan-reviewer`** ตรวจโค้ดของ task นี้ — แก้เฉพาะข้อที่ reviewer ชี้และถูกต้องจริง
6. **สรุปตอนจบ:** ไฟล์ที่สร้าง/แก้, ผล verify แต่ละข้อ (ผ่าน/ไม่ผ่าน/ค้างทดสอบมือ), สิ่งที่ต้องทำต่อใน task ถัดไป
## ข้อห้าม
- ห้าม implement task ถัดไปด้วย ("ขอทำเพิ่ม" ไม่ใช่ข้ออ้าง)
- ห้ามเปลี่ยนไฟล์ของ task อื่นที่ไม่เกี่ยวกับ task นี้
- ห้าม mark ว่า verify ผ่านโดยไม่ได้รันจริง

View file

@ -0,0 +1,17 @@
---
name: sync-db
description: Sync Drizzle schema ลง PostgreSQL container judes-note-db + seed ไฟล์ตัวอย่าง — ใช้หลังแก้ db/schema.ts หรือตอน setup ครั้งแรก
---
## ขั้นตอน
1. **Push schema:** รัน `npx drizzle-kit push` (อ่าน config จาก `drizzle.config.ts`) — รายงาน output จริง ถ้าชนของเดิมให้แสดงและถามผู้ใช้ก่อน destructive ทางเลือก
2. **Seed ตัวอย่าง (เฉพาะเมื่อตาราง `files` ว่าง):** INSERT ไฟล์จาก `public/samples/` โดย `pdf_url = '/samples/<ชื่อไฟล์>'` (ไฟล์และที่มาดูใน PLAN.md §0 ตาราง "ไฟล์ตัวอย่าง") — ใช้ SQL จาก README ถ้ามีแล้ว รันผ่าน `docker exec judes-note-db psql -U postgres -c "..."`
3. **รายงานผลแต่ละขั้นตามจริง** — ขั้นไหนไม่ได้ทำบอกสาเหตุ อย่าเดา
## ข้อควรระวัง
- `DATABASE_URL` ชี้ที่ container `judes-note-db` (postgres:17-alpine, พอร์ต 54322 — ไม่ใช่ Supabase) — ถ้า container ไม่รันอยู่ ให้สั่ง `docker start judes-note-db` หรือแจ้งผู้ใช้ตามคำสั่ง docker run ใน README
- ห้ามลบ volume `judes-note-db-data` เว้นแต่ผู้ใช้สั่งชัดเจน (มีข้อมูล annotation จริงหายได้)
- ไม่ต้องตั้ง realtime publication/replica identity อีกต่อไป (SSE sync ผ่าน `lib/annotate-events.ts` ไม่ผ่าน DB trigger)

2
.env.example Normal file
View file

@ -0,0 +1,2 @@
# PostgreSQL container judes-note-db (สร้างด้วย docker run ตาม README)
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note

30
.gitignore vendored Normal file
View file

@ -0,0 +1,30 @@
# dependencies
/node_modules
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
dev-server.log
# env
.env*
!.env.example
# typescript
*.tsbuildinfo
next-env.d.ts
# Claude Code
.claude/settings.local.json

76
CLAUDE.md Normal file
View file

@ -0,0 +1,76 @@
# CLAUDE.md
## ภาพรวมโปรเจกต์
**Jude's Note** — ระบบ PDF Annotation สำหรับ e-Court (โปรเจกต์ Next.js แยกต่างหาก ไม่เกี่ยวกับ dms-main ที่เป็น Nuxt)
- ฟีเจอร์: highlight ข้อความ, วาดเส้น freehand, sticky note comment, bookmark
- **อ่านแผนฉบับเต็มก่อนเริ่มงานใด ๆ:** `docs/PLAN.md` (สถาปัตยกรรม, schema, พฤติกรรม feature, ลำดับ implement, edge cases) — ฟีเจอร์ต่อยอด (F1–F19 + N1/N2) อ่านเพิ่ม: `docs/PLAN-FEAT.md`
## Tech Stack
- Next.js 14 (App Router) + TypeScript **strict**
- pdfjs-dist **v4** (`^4.10.38`) — built-in TextLayer + worker จาก `public/pdf.worker.min.mjs` (postinstall คัดลอกจาก node_modules)
- Drizzle ORM + PostgreSQL (Docker container เดียว: `judes-note-db`, image `postgres:17-alpine`, พอร์ต 54322) — **ไม่ใช้ Supabase** + realtime sync ด้วย **SSE (Server-Sent Events)** ผ่าน `app/api/annotations/stream` + pub/sub in-process (`lib/annotate-events.ts`)
- Tailwind CSS
- ไม่มีระบบ auth — anonymous identity จาก localStorage (`utils/user.ts`)
## Invariants ที่ต้องรักษา (ห้ามละเมิด)
1. **Non-Destructive:** ไฟล์ PDF ต้นฉบับไม่ถูกแก้ไขเด็ดขาด — annotation เก็บใน DB เป็นพิกัดเท่านั้น (export PDF ของ F8 สร้างไฟล์ใหม่จากสำเนาในหน่วยความจำ — อ่าน `public/` อย่างเดียว ห้ามเขียนทับ)
2. **พิกัดทุกอย่างใน DB เป็น % ของขนาดหน้า** — `rects`, `paths`, `position`, `strokeWidth` (% ของ width), `yOffset` (% ของ height) — แปลง px⇄% ผ่าน `utils/coordinates.ts` เท่านั้น และก่อนบันทึก HIGHLIGHT ต้องรวบ rects ด้วย `mergeRectsByLine` เหลือ 1 rect ต่อบรรทัด
3. **ลำดับ layer ตาม PLAN.md §1:** canvas (`pointer-events: none` เสมอ) → highlight-layer (`none` เสมอ, `mix-blend-mode: multiply`) → textLayer → InkLayer (SVG) → comment pins — สลับ pointer-events ตาม tool ปัจจุบัน
4. **เขียนผ่าน Server Actions เท่านั้น** (`app/actions/annotations.ts`) และ**หลังเขียน DB สำเร็จทุกครั้ง ต้อง publish event ผ่าน `lib/annotate-events.ts`** / **อ่าน + sync ผ่าน SSE hook** (`hooks/useAnnotationRealtime.ts` เปิด `EventSource` ไป `/api/annotations/stream?fileId=`) — event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ
5. **Timestamp ข้าม server→client→SSE เป็น ISO string เสมอ** (DTO types) — ห้ามส่ง Date object ตรง ๆ
6. **pdfjs-dist ต้อง `dynamic import()` ภายใน useEffect เท่านั้น** — ห้าม import top-level ใน module scope ที่ server evaluate และต้องตั้ง `GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"` (postinstall คัดลอกไฟล์ไป `public/`) — ห้ามใช้ `new URL(..., import.meta.url)`
7. **InkLayer ใช้ SVG แบบไม่มี viewBox** — render ด้วย px จริง แปลง % → px จากขนาดที่ ResizeObserver วัด (viewBox จะทำให้ความหนาเส้นบิดเบี้ยว)
8. **JSONB `data` เป็น strict discriminated union** (`AnnotationData` แยกตาม enum `type`) — ห้าม loosen type เป็น `any`/generic object
9. **Optimistic update ใช้ `useOptimistic` (React 18) ภายใน `startTransition`** — React rollback อัตโนมัติเมื่อ action ล้มเหลว + dedupe ด้วย id ใน `hooks/useAnnotationRealtime.ts` (duplicate ชั่วขณะกับ realtime event หายเอง)
10. **Page Virtualization:** render เฉพาะหน้าใน viewport ± 1 หน้าด้วย IntersectionObserver — หน้าพ้นจอเก็บ placeholder ขนาดจริง (scroll ไม่ jump, bookmark ยังคำนวณตำแหน่งได้) และ unmount canvas/TextLayer/InkLayer คืน memory — ห้าม render ทุกหน้าพร้อมกัน
11. **DB connection:** `DATABASE_URL` ชี้ที่ plain PostgreSQL container `judes-note-db` (`postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note`) + `prepare: false` ใน postgres.js เสมอ — pub/sub ของ SSE เป็น in-memory (ใช้ได้เมื่อรัน process เดียว; ถ้ารันหลาย instance ต้องเพิ่ม Redis pub/sub หรือ Postgres LISTEN/NOTIFY ก่อน)
## โครงสร้างไฟล์ (ตามแผน)
```
db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO
db/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false)
lib/annotate-events.ts ← pub/sub in-process (actions publish → SSE route subscribe)
lib/server-pdf.ts ← path guard อ่าน PDF จาก public/ (export ใช้)
utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine
utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %)
utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + ค่า default TEXTBOX/STAMP (sidebar/สรุป/CSV ใช้ร่วม)
utils/pdf-export.ts ← แปลงพิกัดจอ⇄PDF (/Rotate) + wrapText + hexToRgb (export route)
utils/user.ts ← anonymous identity (localStorage)
hooks/useAnnotationRealtime.ts ← EventSource ต่อ SSE stream + dedupe ด้วย id
hooks/useHistory.ts ← undo/redo stack ฝั่ง client (F2)
app/actions/annotations.ts ← Server Actions CRUD + validation + publish event
app/api/annotations/stream/route.ts ← SSE endpoint (GET ?fileId=)
app/api/annotations/export/route.ts ← export PDF ฝัง annotation (pdf-lib, F8)
app/api/annotations/export-csv/route.ts ← export สรุป CSV พร้อม BOM (F16)
app/page.tsx ← รายการไฟล์ (server component)
app/annotate/[id]/page.tsx ← โหลด file + initial data → PDFViewer
app/annotate/[id]/summary/page.tsx ← หน้าสรุปหมายเหตุจัดกลุ่มตามหน้า (F15)
components/pdf/PDFViewer.tsx ← client root (tool state, zoom, selection, lazy render หน้า)
components/pdf/InkLayer.tsx ← SVG freehand + รูปทรง + redaction
components/pdf/TextboxStampLayer.tsx ← overlay TEXTBOX + STAMP (F17/F18)
components/pdf/AnnotationSelectionLayer.tsx ← กรอบ selection + handles ย้าย/ปรับขนาด (F1) + แถบสี/ความหนา + ปุ่มลบ
components/sidebar/AnnotationSidebar.tsx ← + โน้ตคำอธิบาย (note) inline ต่อรายการ (PLAN-FEAT.md §7 N1)
```
## ขอบเขต — ห้ามทำเกิน (Out of Scope)
> ขอบเขตด้านล่างเป็นของ**เฟสแรก** — ฟีเจอร์ต่อยอด (edit/ย้าย, undo/redo, export, reply, resolve ฯลฯ) อนุญาต**เฉพาะตาม task F1–F19 ใน `docs/PLAN-FEAT.md`** และทำผ่าน `/implement-feat` เท่านั้น ห้ามทำเกินสเปกของแผนนั้น — **ปัจจุบันทำครบตามแผนแล้ว ยกเว้น F11 (export/import JSON) ที่ถูกถอดออกจากโค้ดแล้ว**: แก้ไข/ย้าย/ปรับขนาด (F1), undo/redo (F2), คีย์ลัด (F3), กรองรายคน (F4), รูปทรง (F5), eraser (F6), สไตลัส/pressure (F7), redaction + export PDF (F8), reply เธรด (F9), resolve (F10), deep link (F12), แก้ไข comment (F13), ค้นหา (F14), หน้าสรุป (F15), export CSV (F16), text box (F17), stamp (F18), version history + กู้คืน (F19) — ฟีเจอร์เสริมหลังแผน (อนุญาตตาม §7 ของ `docs/PLAN-FEAT.md`): โน้ตคำอธิบาย `note?` ฝังใน annotation ทุกชนิดยกเว้น COMMENT แก้ไขใน sidebar + hover tooltip (N1), bookmark โครงสร้าง 3 ระดับ `parent_id` + โน้ต + popup สร้าง/แก้ไข/ย้ายหัวข้อ + ปุ่ม bookmark ในแผง selection + ชื่อ default จากข้อความที่คลุม (N2)
- ไม่มี login/roles, ไม่มี presence — undo/redo เป็น history ฝั่ง client ต่อ session ต่อผู้ใช้ ไม่เก็บใน DB (D10) ส่วน version history/กู้คืน (F19) เก็บในตาราง `annotation_versions`
- Annotation: สร้าง + ลบ + แก้ไข/ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา/แก้ไขข้อความ **ของตัวเอง ในหน้าเดียวกัน** — resolve เป็นสิ่งเดียวที่ใครก็ toggle ได้ (D6)
- ปุ่มลบ/แก้ไข/ประวัติแสดงเฉพาะรายการของตัวเอง (`userId` ตรงกัน) เท่านั้น
- หน้า `/` อ่านรายการจาก DB เท่านั้น — ไม่มีหน้า upload ไฟล์ (sample data seed ด้วย SQL ใน README)
- ลายเซ็นจากรูป/upload ยังเป็น out of scope (D7 — stamp เป็น preset ข้อความเท่านั้น, ลายเซ็นใช้ tool DRAW วาด)
## Claude Tooling (ใน `.claude/`)
- **`agents/plan-reviewer`** — ตรวจโค้ดกับ invariants ด้านบน + verify criteria ของ task (report-only ไม่แก้โค้ด) — เรียกหลัง implement แต่ละ task
- **`skills/implement-task`** — `/implement-task <N>` implement task N จาก PLAN.md §7 พร้อม verify ตามเกณฑ์
- **`skills/implement-feat`** — `/implement-feat <F-number หรือ feature id>` implement task F1–F19 จาก PLAN-FEAT.md พร้อม verify + sync-db + sync invariant/rules ตามตาราง §6 ของแผนนั้น
- **`skills/sync-db`** — `/sync-db` drizzle push + seed ไฟล์ตัวอย่าง
- **`rules/*.md`** — กติกาเชิงปฏิบัติแยกตามพื้นที่ (`pdf-viewer`, `coordinates`, `data-layer`, `realtime`) โหลดอัตโนมัติเมื่อแตะไฟล์ที่ match — แก้ invariant ต้องแก้ที่ CLAUDE.md/PLAN.md ก่อน แล้วค่อย sync มา rules

951
app/actions/annotations.ts Normal file
View file

@ -0,0 +1,951 @@
"use server";
import { and, asc, desc, eq, or, sql } from "drizzle-orm";
import { publishAnnotateEvent } from "@/lib/annotate-events";
import { db } from "@/db";
import {
annotationReplies,
annotationVersions,
annotations,
bookmarks,
toAnnotationDTO,
toBookmarkDTO,
toReplyDTO,
toVersionDTO,
type AnnotationData,
type AnnotationDTO,
type AnnotationType,
type AnnotationVersionDTO,
type BookmarkDTO,
type CommentData,
type DrawData,
type DrawPen,
type HighlightData,
type Rect,
type RedactionData,
type ReplyDTO,
type ShapeData,
type StampData,
type TextboxData,
} from "@/db/schema";
// ─── Input types (client ใช้ตอนเรียก action) ────────────────────────────────
export type CreateAnnotationInput = {
fileId: string;
pageIndex: number;
userId: string;
userName: string;
type: AnnotationType;
data: AnnotationData;
};
export type CreateBookmarkInput = {
fileId: string;
pageIndex: number;
userId: string;
title: string;
/** % ของความสูงหน้า (0–100) */
yOffset: number;
/** N2: id ของหมุดหัวข้อแม่ (null = ระดับบนสุด) */
parentId?: string | null;
/** N2: โน้ตย่อย (null/ว่าง = ไม่มี) */
note?: string | null;
};
export type UpdateBookmarkInput = {
id: string;
userId: string;
/** แก้เฉพาะ field ที่ส่งมา (undefined = ไม่แตะ) */
title?: string;
note?: string | null;
/** ย้ายหัวข้อ: null = ระดับบนสุด / id = ใต้หัวข้อนั้น (ต้องเป็น root) */
parentId?: string | null;
};
// ─── Validation guards — ตรวจทุก field ก่อนแตะ DB (rules/data-layer.md) ──────
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const ANNOTATION_TYPES: readonly AnnotationType[] = [
"HIGHLIGHT",
"DRAW",
"COMMENT",
"SHAPE",
"REDACTION",
"TEXTBOX",
"STAMP",
];
const SHAPE_KINDS: readonly string[] = ["rect", "ellipse", "line", "arrow"];
// FIX-4: ความยาวสูงสุดของ string input ต่อ field — กันเรียก action
// ตรง ๆ ด้วยข้อความขนาดหลาย MB จน JSONB บวมและ SSE event ใหญ่ผิดปกติ
const MAX_USER_NAME_CHARS = 100;
const MAX_TEXT_CHARS = 5_000; // message/text/note/label ของ annotation + reply
const MAX_BOOKMARK_TITLE_CHARS = 120; // ให้ตรงค่าที่ UI ตัดไว้
const MAX_BOOKMARK_NOTE_CHARS = 200;
const MAX_QUERY_CHARS = 200; // คำค้นหา (F14)
function fieldError(field: string, expect: string): never {
throw new Error(`input ไม่ถูกต้อง: ฟิลด์ ${field} ต้อง${expect}`);
}
function vString(v: unknown, field: string, maxChars?: number): string {
if (typeof v !== "string" || v.trim().length === 0) fieldError(field, "เป็น string ที่ไม่ว่าง");
if (maxChars !== undefined && v.length > maxChars) {
fieldError(field, `ยาวไม่เกิน ${maxChars} ตัวอักษร`);
}
return v;
}
/** โน้ตคำอธิบาย (note) เป็น optional — ค่าว่าง/ช่องว่างล้วนถือว่าไม่มีโน้ต */
function vNote(v: unknown): string | undefined {
if (v === undefined) return undefined;
if (typeof v !== "string") fieldError("data.note", "เป็น string");
if (v.length > MAX_TEXT_CHARS) {
fieldError("data.note", `ยาวไม่เกิน ${MAX_TEXT_CHARS} ตัวอักษร`);
}
const s = v.trim();
return s.length > 0 ? s : undefined;
}
function vUuid(v: unknown, field: string): string {
if (typeof v !== "string" || !UUID_RE.test(v)) fieldError(field, "เป็น UUID");
return v;
}
function vPageIndex(v: unknown): number {
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) {
fieldError("pageIndex", "เป็นจำนวนเต็ม ≥ 0");
}
return v;
}
/** ตัวเลขพิกัด % — finite และ ≥ 0 */
function vPercent(v: unknown, field: string): number {
if (typeof v !== "number" || !Number.isFinite(v) || v < 0) fieldError(field, "เป็นตัวเลข ≥ 0");
return v;
}
function vPoint(v: unknown, field: string): { x: number; y: number } {
if (typeof v !== "object" || v === null) fieldError(field, "เป็น { x, y }");
const p = v as Record<string, unknown>;
return { x: vPercent(p.x, `${field}.x`), y: vPercent(p.y, `${field}.y`) };
}
function vRect(v: unknown, field: string): Rect {
if (typeof v !== "object" || v === null) fieldError(field, "เป็น { x, y, width, height }");
const r = v as Record<string, unknown>;
return {
x: vPercent(r.x, `${field}.x`),
y: vPercent(r.y, `${field}.y`),
width: vPercent(r.width, `${field}.width`),
height: vPercent(r.height, `${field}.height`),
};
}
function validateAnnotationData(type: AnnotationType, v: unknown): AnnotationData {
if (typeof v !== "object" || v === null) fieldError("data", `เป็น object ของประเภท ${type}`);
const d = v as Record<string, unknown>;
switch (type) {
case "HIGHLIGHT": {
const text = vString(d.text, "data.text", MAX_TEXT_CHARS);
const color = vString(d.color, "data.color");
if (!Array.isArray(d.rects) || d.rects.length === 0) {
fieldError("data.rects", "เป็น array ที่ไม่ว่าง");
}
const rects = d.rects.map((r, i) => vRect(r, `data.rects[${i}]`));
const note = vNote(d.note);
// รูปแบบการมาร์ก (style) เป็น optional — ไม่มี = highlight ทึบตามเดิม
if (
d.style !== undefined &&
d.style !== "highlight" &&
d.style !== "underline" &&
d.style !== "strikethrough" &&
d.style !== "squiggle"
) {
fieldError(
"data.style",
'เป็น "highlight" | "underline" | "strikethrough" | "squiggle"',
);
}
// ความทึบ (opacity 0–1) เป็น optional — ไม่มี = ทึบเต็ม (แถวเดิม)
if (
d.opacity !== undefined &&
(typeof d.opacity !== "number" ||
!Number.isFinite(d.opacity) ||
d.opacity < 0 ||
d.opacity > 1)
) {
fieldError("data.opacity", "เป็นตัวเลข 0–1");
}
return {
text,
color,
rects,
...(d.style !== undefined ? { style: d.style } : {}),
...(d.opacity !== undefined ? { opacity: d.opacity } : {}),
...(note !== undefined ? { note } : {}),
} satisfies HighlightData;
}
case "DRAW": {
const strokeColor = vString(d.strokeColor, "data.strokeColor");
const strokeWidth = vPercent(d.strokeWidth, "data.strokeWidth");
if (!Array.isArray(d.paths) || d.paths.length < 2) {
fieldError("data.paths", "เป็น array ที่มีจุดอย่างน้อย 2 จุด");
}
const paths = d.paths.map((p, i) => vPoint(p, `data.paths[${i}]`));
// ชนิดปากกา (pen) เป็น optional — ไม่มี = pencil (แถวเดิมก่อนมี highlighter)
let pen: DrawPen | undefined;
if (d.pen !== undefined) {
if (d.pen !== "pencil" && d.pen !== "highlighter") {
fieldError("data.pen", 'เป็น "pencil" หรือ "highlighter"');
}
pen = d.pen;
}
// ความทึบ (opacity 0–1) เป็น optional — แถวเดิมไม่มี = ตามค่า preset ของ pen
let opacity: number | undefined;
if (d.opacity !== undefined) {
if (
typeof d.opacity !== "number" ||
!Number.isFinite(d.opacity) ||
d.opacity < 0 ||
d.opacity > 1
) {
fieldError("data.opacity", "เป็นตัวเลข 0–1");
}
opacity = d.opacity;
}
// F7/E1: widths (pressure) เป็น optional — ถ้ามีต้องยาวเท่า paths พอดี
let widths: number[] | undefined;
if (d.widths !== undefined) {
if (!Array.isArray(d.widths) || d.widths.length !== paths.length) {
fieldError("data.widths", "เป็น array ความหนาที่ยาวเท่า data.paths");
}
widths = d.widths.map((w, i) => vPercent(w, `data.widths[${i}]`));
}
const note = vNote(d.note);
return {
strokeColor,
strokeWidth,
paths,
...(pen ? { pen } : {}),
...(opacity !== undefined ? { opacity } : {}),
...(widths ? { widths } : {}),
...(note !== undefined ? { note } : {}),
} satisfies DrawData;
}
case "COMMENT": {
const position = vPoint(d.position, "data.position");
const message = vString(d.message, "data.message", MAX_TEXT_CHARS);
return { position, message } satisfies CommentData;
}
case "REDACTION": {
const rect = vRect(d.rect, "data.rect");
const note = vNote(d.note);
return { rect, ...(note !== undefined ? { note } : {}) } satisfies RedactionData;
}
case "TEXTBOX": {
const position = vPoint(d.position, "data.position");
const text = vString(d.text, "data.text", MAX_TEXT_CHARS);
const color = vString(d.color, "data.color");
// fontSize เป็น % ของความกว้างหน้า — ต้องเป็นบวกจึงมีความหมาย
if (
typeof d.fontSize !== "number" ||
!Number.isFinite(d.fontSize) ||
d.fontSize <= 0
) {
fieldError("data.fontSize", "เป็นตัวเลข > 0");
}
// bold/opacity/align เป็น optional — ไม่มี = ตัวปกติ/ทึบเต็ม/ชิดซ้าย (แถวเดิม)
if (d.bold !== undefined && typeof d.bold !== "boolean") {
fieldError("data.bold", "เป็น boolean");
}
if (
d.opacity !== undefined &&
(typeof d.opacity !== "number" ||
!Number.isFinite(d.opacity) ||
d.opacity < 0 ||
d.opacity > 1)
) {
fieldError("data.opacity", "เป็นตัวเลข 0–1");
}
if (
d.align !== undefined &&
d.align !== "left" &&
d.align !== "center" &&
d.align !== "right"
) {
fieldError("data.align", 'เป็น "left" | "center" | "right"');
}
// segments (ตัวหนาเฉพาะส่วน) เป็น optional array ของ { text, bold? }
if (d.segments !== undefined) {
if (!Array.isArray(d.segments)) {
fieldError("data.segments", "เป็น array ของ { text, bold? }");
}
for (const seg of d.segments) {
if (seg === null || typeof seg !== "object") {
fieldError("data.segments", "เป็น object { text, bold? }");
}
if (typeof (seg as { text?: unknown })?.text !== "string") {
fieldError("data.segments[].text", "เป็น string");
}
if (
(seg as { bold?: unknown })?.bold !== undefined &&
typeof (seg as { bold?: unknown }).bold !== "boolean"
) {
fieldError("data.segments[].bold", "เป็น boolean");
}
}
// FIX-4 (ต่อ): ความยาวรวมของทุกส่วนต้องเข้าขอบเดียวกับ data.text — ไม่งั้น
// เรียก action ตรง ๆ ด้วย segments ยักษ์ (แยกจาก text ที่ส่งมาสั้น) ก็ยังฝัง
// ข้อความขนาดหลาย MB ลง JSONB + SSE event ใหญ่ผิดปกติได้เหมือนเดิม
const segTotal = d.segments
.map((seg) => (seg as { text: string }).text)
.join("").length;
if (segTotal > MAX_TEXT_CHARS) {
fieldError("data.segments", `ข้อความรวมทุกส่วนยาวไม่เกิน ${MAX_TEXT_CHARS} ตัวอักษร`);
}
}
const textboxNote = vNote(d.note);
return {
position,
text,
color,
fontSize: d.fontSize,
...(d.bold ? { bold: true } : {}),
...(d.opacity !== undefined ? { opacity: d.opacity } : {}),
...(d.align !== undefined ? { align: d.align } : {}),
...(d.segments !== undefined ? { segments: d.segments } : {}),
...(textboxNote !== undefined ? { note: textboxNote } : {}),
} satisfies TextboxData;
}
case "STAMP": {
const label = vString(d.label, "data.label", MAX_TEXT_CHARS);
const position = vPoint(d.position, "data.position");
// scale เป็น % ของความกว้างหน้า — ต้องเป็นบวกจึงมีความหมาย
if (
typeof d.scale !== "number" ||
!Number.isFinite(d.scale) ||
d.scale <= 0
) {
fieldError("data.scale", "เป็นตัวเลข > 0");
}
// สีตรายางเป็น optional — ไม่มี = STAMP_COLOR (แถวเดิม)
const color = d.color !== undefined ? vString(d.color, "data.color") : undefined;
const note = vNote(d.note);
return {
label,
position,
scale: d.scale,
...(color ? { color } : {}),
...(note !== undefined ? { note } : {}),
} satisfies StampData;
}
case "SHAPE": {
const strokeColor = vString(d.strokeColor, "data.strokeColor");
const strokeWidth = vPercent(d.strokeWidth, "data.strokeWidth");
if (typeof d.shape !== "string" || !SHAPE_KINDS.includes(d.shape)) {
return fieldError("data.shape", `เป็นหนึ่งใน ${SHAPE_KINDS.join(" | ")}`);
}
if (d.shape === "rect" || d.shape === "ellipse") {
const rect = vRect(d.rect, "data.rect");
const note = vNote(d.note);
return {
shape: d.shape as "rect" | "ellipse",
rect,
strokeColor,
strokeWidth,
...(note !== undefined ? { note } : {}),
} satisfies ShapeData;
}
const from = vPoint(d.from, "data.from");
const to = vPoint(d.to, "data.to");
const note = vNote(d.note);
return {
shape: d.shape as "line" | "arrow",
from,
to,
strokeColor,
strokeWidth,
...(note !== undefined ? { note } : {}),
} satisfies ShapeData;
}
}
}
// ─── Actions ─────────────────────────────────────────────────────────────────
export async function createAnnotation(input: CreateAnnotationInput): Promise<AnnotationDTO> {
const raw = input as unknown as Record<string, unknown> | null | undefined;
const fileId = vUuid(raw?.fileId, "fileId");
const pageIndex = vPageIndex(raw?.pageIndex);
const userId = vString(raw?.userId, "userId");
const userName = vString(raw?.userName, "userName", MAX_USER_NAME_CHARS);
if (typeof raw?.type !== "string" || !ANNOTATION_TYPES.includes(raw.type as AnnotationType)) {
fieldError("type", `เป็นหนึ่งใน ${ANNOTATION_TYPES.join(" | ")}`);
}
const type = raw.type as AnnotationType;
const data = validateAnnotationData(type, raw?.data);
const [row] = await db
.insert(annotations)
.values({ fileId, pageIndex, userId, userName, type, data })
.returning();
const dto = toAnnotationDTO(row);
publishAnnotateEvent(fileId, { table: "annotations", action: "create", row: dto });
return dto;
}
/**
* ลบได้เฉพาะรายการของตัวเอง (userId ตรงกัน) เท่านั้น
* F19/A7: snapshot row เป็น version kind='delete' **ก่อนลบ** ใน transaction เดียวกัน —
* annotation_id ไม่ FK (D8) จึงรอดจาก CASCADE และกู้คืนได้ภายหลัง
*/
export async function deleteAnnotation(id: string, userId: string): Promise<void> {
const annId = vUuid(id, "id");
const uid = vString(userId, "userId");
const removed = await db.transaction(async (tx) => {
// FOR UPDATE กันสองแท็บลบพร้อมกัน → snapshot 'delete' เกิด 2 แถวซ้ำ
const [existing] = await tx
.select()
.from(annotations)
.where(and(eq(annotations.id, annId), eq(annotations.userId, uid)))
.for("update");
if (!existing) return null;
await tx.insert(annotationVersions).values({
annotationId: existing.id,
fileId: existing.fileId,
pageIndex: existing.pageIndex,
userId: existing.userId,
userName: existing.userName,
type: existing.type,
data: existing.data,
kind: "delete",
});
const [del] = await tx
.delete(annotations)
.where(eq(annotations.id, annId))
.returning({ id: annotations.id, fileId: annotations.fileId });
return del ?? null;
});
if (!removed) {
throw new Error("ลบไม่สำเร็จ: ไม่พบ annotation นี้ หรือไม่ใช่รายการของคุณ");
}
publishAnnotateEvent(removed.fileId, {
table: "annotations",
action: "delete",
id: annId,
});
}
/**
* แก้ไข data ของ annotation ที่มีอยู่ (PLAN-FEAT.md §3 F1)
* - เจ้าของเท่านั้น (D9) และห้ามเปลี่ยน type/pageIndex — validate ตาม type เดิมของ row
* - ย้าย/ปรับขนาดได้ในหน้าเดียวกันเท่านั้น (pageIndex ไม่ถูกแตะ)
* - F19/A7: snapshot row เดิมเป็น version kind='update' **ก่อนเขียนทับ** ใน transaction เดียวกัน
*/
export async function updateAnnotation(
id: string,
userId: string,
data: AnnotationData,
): Promise<AnnotationDTO> {
const annId = vUuid(id, "id");
const uid = vString(userId, "userId");
const row = await db.transaction(async (tx) => {
const [existing] = await tx
.select()
.from(annotations)
.where(eq(annotations.id, annId))
.for("update");
if (!existing) {
throw new Error("แก้ไขไม่สำเร็จ: ไม่พบ annotation นี้");
}
if (existing.userId !== uid) {
throw new Error("แก้ไขไม่ได้: เป็นรายการของผู้ใช้อื่น");
}
// validate ด้วย validator เดิมตาม type ของ row — data ผิด variant ของ type นั้นจะถูกปฏิเสธ
const validated = validateAnnotationData(existing.type, data);
await tx.insert(annotationVersions).values({
annotationId: existing.id,
fileId: existing.fileId,
pageIndex: existing.pageIndex,
userId: existing.userId,
userName: existing.userName,
type: existing.type,
data: existing.data,
kind: "update",
});
const [updated] = await tx
.update(annotations)
.set({ data: validated, updatedAt: new Date() })
.where(eq(annotations.id, annId))
.returning();
return updated;
});
if (!row) {
// race: row ถูกลบโดยแท็บอื่นระหว่าง select → update
throw new Error("แก้ไขไม่สำเร็จ: ไม่พบ annotation นี้ (อาจถูกลบไปแล้ว)");
}
const dto = toAnnotationDTO(row);
publishAnnotateEvent(row.fileId, { table: "annotations", action: "update", row: dto });
return dto;
}
/**
* F10/A5: toggle สถานะ "จัดการแล้ว" — **ใครก็ได้** (D6: คลายคืนได้ ไม่ทำลายข้อมูล
* ต่างจาก delete ที่จำกัดเจ้าของ) — publish update event ให้ทุก client เห็น
*/
export async function setAnnotationResolved(
id: string,
resolved: boolean,
): Promise<AnnotationDTO> {
const annId = vUuid(id, "id");
if (typeof resolved !== "boolean") {
fieldError("resolved", "เป็น boolean");
}
const [existing] = await db
.select({ fileId: annotations.fileId })
.from(annotations)
.where(eq(annotations.id, annId));
if (!existing) {
throw new Error("ไม่พบ annotation นี้");
}
const [row] = await db
.update(annotations)
.set({ resolved, resolvedAt: resolved ? new Date() : null, updatedAt: new Date() })
.where(eq(annotations.id, annId))
.returning();
if (!row) {
throw new Error("ตั้งสถานะไม่สำเร็จ: ไม่พบ annotation นี้ (อาจถูกลบไปแล้ว)");
}
const dto = toAnnotationDTO(row);
publishAnnotateEvent(existing.fileId, { table: "annotations", action: "update", row: dto });
return dto;
}
// ─── Replies (F9/A4 — D2: ตารางแยก annotation_replies, FK CASCADE) ──────────
export type CreateReplyInput = {
annotationId: string;
userId: string;
userName: string;
message: string;
};
export async function createReply(input: CreateReplyInput): Promise<ReplyDTO> {
const raw = input as unknown as Record<string, unknown> | null | undefined;
const annotationId = vUuid(raw?.annotationId, "annotationId");
const userId = vString(raw?.userId, "userId");
const userName = vString(raw?.userName, "userName", MAX_USER_NAME_CHARS);
const message = vString(raw?.message, "message", MAX_TEXT_CHARS);
const [parent] = await db
.select({ fileId: annotations.fileId })
.from(annotations)
.where(eq(annotations.id, annotationId));
if (!parent) {
throw new Error("ตอบไม่ได้: ไม่พบ comment ต้นทาง");
}
const [row] = await db
.insert(annotationReplies)
.values({ annotationId, userId, userName, message })
.returning();
const dto = toReplyDTO(row);
publishAnnotateEvent(parent.fileId, { table: "replies", action: "create", row: dto });
return dto;
}
/** ลบได้เฉพาะ reply ของตัวเอง (userId ตรงกัน) เท่านั้น */
export async function deleteReply(id: string, userId: string): Promise<void> {
const replyId = vUuid(id, "id");
const uid = vString(userId, "userId");
const [existing] = await db
.select({ annotationId: annotationReplies.annotationId })
.from(annotationReplies)
.where(and(eq(annotationReplies.id, replyId), eq(annotationReplies.userId, uid)));
if (!existing) {
throw new Error("ลบไม่สำเร็จ: ไม่พบคำตอบนี้ หรือไม่ใช่คำตอบของคุณ");
}
// หา fileId ของแม่ไว้ publish (แม่หายไปแล้ว = CASCADE ลบ reply ทิ้ง แทบไม่เกิด)
const [parent] = await db
.select({ fileId: annotations.fileId })
.from(annotations)
.where(eq(annotations.id, existing.annotationId));
const removed = await db
.delete(annotationReplies)
.where(and(eq(annotationReplies.id, replyId), eq(annotationReplies.userId, uid)))
.returning({ id: annotationReplies.id });
if (removed.length === 0) {
// race: ถูกลบไปแล้วระหว่าง select → delete — ไม่ publish event ทับ
throw new Error("ลบไม่สำเร็จ: ไม่พบคำตอบนี้ หรือไม่ใช่คำตอบของคุณ");
}
if (parent) {
publishAnnotateEvent(parent.fileId, { table: "replies", action: "delete", id: replyId });
}
}
/** โน้ตย่อยของ bookmark — ว่าง/ช่องว่างล้วน = null (ไม่มีโน้ต) */
function vBookmarkNote(v: unknown): string | null {
if (v === undefined || v === null) return null;
if (typeof v !== "string") fieldError("note", "เป็น string");
if (v.length > MAX_BOOKMARK_NOTE_CHARS) {
fieldError("note", `ยาวไม่เกิน ${MAX_BOOKMARK_NOTE_CHARS} ตัวอักษร`);
}
const s = v.trim();
return s.length > 0 ? s : null;
}
// ─── N2: โครงสร้างต้นไม้ bookmark (3 ระดับ) — helper ทุกตัว lock แถวที่เดินผ่าน
// เพื่อ serialize กับ create/update/delete ที่แตะโหนดเดียวกัน (กัน race ความลึก/orphan)
/** ความลึกสูงสุดของต้นไม้ bookmark (root = ระดับ 1) */
const BOOKMARK_MAX_DEPTH = 3;
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0];
/** cap การเดินต้นไม้ — ปกติลึกสุด 3 ระดับ ถ้าเกินนี้แปลว่าข้อมูลวนซ้ำ (cycle) ให้ fail ดีกว่าลูปไม่รู้จบ */
const TREE_WALK_LIMIT = 100;
/** ความลึกของ bookmark (root = 1) + ไฟล์ของตัวมัน — เดินขึ้นหาบรรพบุรุษพร้อม lock ทุกตัว */
async function lockedDepth(tx: Tx, id: string): Promise<{ depth: number; fileId: string }> {
let depth = 1;
let cur = id;
for (;;) {
if (depth > TREE_WALK_LIMIT) {
throw new Error("โครงสร้าง bookmark ผิดปกติ (parentId วนซ้ำ)");
}
const [row] = await tx
.select({ fileId: bookmarks.fileId, parentId: bookmarks.parentId })
.from(bookmarks)
.where(eq(bookmarks.id, cur))
.for("update");
if (!row) {
throw new Error("ไม่พบหมุดหัวข้อในลำดับชั้น (อาจถูกลบไปแล้ว)");
}
if (row.parentId === null) return { depth, fileId: row.fileId };
depth += 1;
cur = row.parentId;
}
}
/** ความสูงของ subtree รวมตัวเอง (leaf = 1) — เดินลงหาลูกทุกตัวพร้อม lock (กัน create แข่งเพิ่มความลึกระหว่างย้าย) */
async function lockedSubtreeHeight(tx: Tx, id: string, level = 1): Promise<number> {
if (level > TREE_WALK_LIMIT) {
throw new Error("โครงสร้าง bookmark ผิดปกติ (parentId วนซ้ำ)");
}
const kids = await tx
.select({ id: bookmarks.id })
.from(bookmarks)
.where(eq(bookmarks.parentId, id))
.for("update");
let height = 1;
for (const k of kids) {
height = Math.max(height, 1 + (await lockedSubtreeHeight(tx, k.id, level + 1)));
}
return height;
}
/** id เป็นลูกหลานของ ancestorId หรือไม่ (เดินขึ้นจาก id) — ใช้กันการย้ายเข้า subtree ของตัวเอง */
async function isDescendant(tx: Tx, id: string, ancestorId: string): Promise<boolean> {
let cur = id;
for (let steps = 0; steps <= TREE_WALK_LIMIT; steps += 1) {
const [row] = await tx
.select({ parentId: bookmarks.parentId })
.from(bookmarks)
.where(eq(bookmarks.id, cur))
.for("update");
if (!row || row.parentId === null) return false;
if (row.parentId === ancestorId) return true;
cur = row.parentId;
}
throw new Error("โครงสร้าง bookmark ผิดปกติ (parentId วนซ้ำ)");
}
export async function createBookmark(input: CreateBookmarkInput): Promise<BookmarkDTO> {
const raw = input as unknown as Record<string, unknown> | null | undefined;
const fileId = vUuid(raw?.fileId, "fileId");
const pageIndex = vPageIndex(raw?.pageIndex);
const userId = vString(raw?.userId, "userId");
const title = vString(raw?.title, "title", MAX_BOOKMARK_TITLE_CHARS);
if (
typeof raw?.yOffset !== "number" ||
!Number.isFinite(raw.yOffset) ||
raw.yOffset < 0 ||
raw.yOffset > 100
) {
fieldError("yOffset", "เป็นตัวเลข 0–100");
}
const note = vBookmarkNote(raw?.note);
// capture เป็นค่าคงที่ก่อนเข้า closure ของ transaction (narrowing ไม่ไหลเข้า callback)
const yOffset = raw.yOffset as number;
const row = await db.transaction(async (tx) => {
// N2: ถ้าระบุแม่ — ต้องอยู่ไฟล์เดียวกันและลึกไม่เกิน MAX_DEPTH-1 ระดับ
// (สร้างเป็นลูกแล้วรวมกันต้อง ≤ 3) — ตรวจพร้อม lock ทั้งสายบรรพบุรุษของแม่
// ภายใน tx เดียวกับ insert เพื่อ serialize กับ updateBookmark (ย้าย) และ
// deleteBookmark (ลบแม่ + re-parent) กัน race เกินความลึกหรือ sub กำพร้า
let parentId: string | null = null;
if (raw?.parentId !== undefined && raw?.parentId !== null && raw?.parentId !== "") {
const pid = vUuid(raw.parentId, "parentId");
const info = await lockedDepth(tx, pid);
if (info.fileId !== fileId) {
throw new Error("สร้างไม่ได้: หมุดหัวข้อแม่อยู่คนละไฟล์");
}
if (info.depth >= BOOKMARK_MAX_DEPTH) {
throw new Error(`สร้างไม่ได้: ซ้อนลึกเกิน ${BOOKMARK_MAX_DEPTH} ระดับ`);
}
parentId = pid;
}
const [inserted] = await tx
.insert(bookmarks)
.values({ fileId, pageIndex, userId, title, yOffset, parentId, note })
.returning();
return inserted;
});
const dto = toBookmarkDTO(row);
publishAnnotateEvent(fileId, { table: "bookmarks", action: "create", row: dto });
return dto;
}
/**
* N2: แก้ไข bookmark ของตัวเอง — ชื่อ/โน้ต/ย้ายหัวข้อ (parentId)
* ย้ายได้ตามเกณฑ์ความลึก (depth แม่ + subtree สูงรวมกัน ≤ BOOKMARK_MAX_DEPTH) และห้ามเข้า subtree ตัวเอง
*/
export async function updateBookmark(input: UpdateBookmarkInput): Promise<BookmarkDTO> {
const raw = input as unknown as Record<string, unknown> | null | undefined;
const bmId = vUuid(raw?.id, "id");
const uid = vString(raw?.userId, "userId");
const title = raw?.title !== undefined ? vString(raw.title, "title", MAX_BOOKMARK_TITLE_CHARS) : undefined;
const note = raw?.note !== undefined ? vBookmarkNote(raw.note) : undefined;
const row = await db.transaction(async (tx) => {
const [existing] = await tx
.select()
.from(bookmarks)
.where(eq(bookmarks.id, bmId))
.for("update");
if (!existing) {
throw new Error("แก้ไขไม่สำเร็จ: ไม่พบ bookmark นี้");
}
if (existing.userId !== uid) {
throw new Error("แก้ไขไม่ได้: เป็นรายการของผู้ใช้อื่น");
}
// ย้ายหัวข้อ (parentId) — validate 3 ข้อ: แม่อยู่ไฟล์เดียวกัน, ความลึกแม่ + ความสูง
// subtree ของตัวเองรวมกัน ≤ MAX_DEPTH, และห้ามย้ายเข้า subtree ของตัวเอง (cycle)
let parentId: string | null | undefined;
if (raw?.parentId !== undefined) {
if (raw.parentId === null || raw.parentId === "") {
parentId = null;
} else {
const pid = vUuid(raw.parentId, "parentId");
if (pid === bmId) {
throw new Error("ย้ายไม่ได้: ห้ามกำหนดตัวเองเป็นแม่");
}
const info = await lockedDepth(tx, pid);
if (info.fileId !== existing.fileId) {
throw new Error("ย้ายไม่ได้: หมุดหัวข้อแม่อยู่คนละไฟล์");
}
if (await isDescendant(tx, pid, bmId)) {
throw new Error("ย้ายไม่ได้: ห้ามย้ายเข้าไปใน subtree ของตัวเอง");
}
const height = await lockedSubtreeHeight(tx, bmId);
if (info.depth + height > BOOKMARK_MAX_DEPTH) {
throw new Error(`ย้ายไม่ได้: ซ้อนลึกเกิน ${BOOKMARK_MAX_DEPTH} ระดับ`);
}
parentId = pid;
}
}
const [updated] = await tx
.update(bookmarks)
.set({
...(title !== undefined ? { title } : {}),
...(note !== undefined ? { note } : {}),
...(parentId !== undefined ? { parentId } : {}),
})
.where(and(eq(bookmarks.id, bmId), eq(bookmarks.userId, uid)))
.returning();
return updated;
});
if (!row) {
// race: row ถูกลบโดยแท็บอื่นระหว่าง select → update
throw new Error("แก้ไขไม่สำเร็จ: ไม่พบ bookmark นี้ (อาจถูกลบไปแล้ว)");
}
const dto = toBookmarkDTO(row);
publishAnnotateEvent(row.fileId, { table: "bookmarks", action: "update", row: dto });
return dto;
}
/** ลบได้เฉพาะ bookmark ของตัวเอง (userId ตรงกัน) เท่านั้น
* N2: ลบหัวข้อใหญ่ → sub ทั้งหมดขยับขึ้นเป็นระดับบนสุด (ไม่มีข้อมูลหาย) ใน tx เดียวกัน */
export async function deleteBookmark(id: string, userId: string): Promise<void> {
const bmId = vUuid(id, "id");
const uid = vString(userId, "userId");
const result = await db.transaction(async (tx) => {
const [existing] = await tx
.select({ id: bookmarks.id, fileId: bookmarks.fileId, userId: bookmarks.userId })
.from(bookmarks)
.where(eq(bookmarks.id, bmId))
.for("update");
if (!existing) {
throw new Error("ลบไม่สำเร็จ: ไม่พบ bookmark นี้");
}
if (existing.userId !== uid) {
throw new Error("ลบไม่ได้: เป็นรายการของผู้ใช้อื่น");
}
// re-parent ลูกขึ้นระดับบนสุดก่อนลบ (parentId ไม่มี FK — ต้องจัดการเอง)
const children = await tx
.update(bookmarks)
.set({ parentId: null })
.where(eq(bookmarks.parentId, bmId))
.returning();
const removed = await tx
.delete(bookmarks)
.where(and(eq(bookmarks.id, bmId), eq(bookmarks.userId, uid)))
.returning({ id: bookmarks.id });
return { fileId: existing.fileId, children, removed };
});
if (result.removed.length === 0) {
// race: row ถูกลบโดยแท็บอื่นระหว่าง select → delete
throw new Error("ลบไม่สำเร็จ: ไม่พบ bookmark นี้ (อาจถูกลบไปแล้ว)");
}
publishAnnotateEvent(result.fileId, {
table: "bookmarks",
action: "delete",
id: bmId,
});
// ลูกที่ถูกยกขึ้น root → update event ต่อตัว ให้ client อื่นย้ายตำแหน่งตาม
for (const child of result.children) {
publishAnnotateEvent(result.fileId, {
table: "bookmarks",
action: "update",
row: toBookmarkDTO(child),
});
}
}
// ─── Version History (F19/A7) ────────────────────────────────────────────────
/** ประวัติการเปลี่ยนแปลงของ annotation หนึ่งรายการ — ใหม่สุดก่อน */
export async function listVersions(
annotationId: string,
): Promise<AnnotationVersionDTO[]> {
const annId = vUuid(annotationId, "annotationId");
const rows = await db
.select()
.from(annotationVersions)
.where(eq(annotationVersions.annotationId, annId))
.orderBy(desc(annotationVersions.createdAt));
return rows.map(toVersionDTO);
}
/**
* กู้คืนจาก snapshot:
* - annotation ต้นทาง**ยังอยู่** → เขียนข้อมูลของ version กลับลง row เดิม
* (snapshot สถานะปัจจุบันก่อน เหมือน update ปกติ — กู้คืนกลับไป-มาได้)
* แล้ว publish update event — เดิมสร้างสำเนา id ใหม่ทุกครั้งจนเกิดรายการซ้ำบนหน้า
* - ต้นทางถูกลบไปแล้ว → สร้าง annotation ใหม่ **id ใหม่** บนหน้าเดิม เจ้าของเดิม
* (D8: version รอดจาก CASCADE ของแม่) + publish create event ให้ทุก client
* - เจ้าของเท่านั้น (D9): ตรวจ userId ของ row ปัจจุบัน / ของ snapshot ตามกรณี
*/
export async function restoreVersion(
versionId: string,
userId: string,
): Promise<AnnotationDTO> {
const vId = vUuid(versionId, "versionId");
const uid = vString(userId, "userId");
const [v] = await db
.select()
.from(annotationVersions)
.where(eq(annotationVersions.id, vId));
if (!v) {
throw new Error("ไม่พบ version นี้");
}
const result = await db.transaction(async (tx) => {
const [existing] = await tx
.select()
.from(annotations)
.where(eq(annotations.id, v.annotationId))
.for("update");
if (existing) {
if (existing.userId !== uid) {
throw new Error("กู้คืนไม่ได้: เป็นรายการของผู้ใช้อื่น");
}
// snapshot สถานะปัจจุบันก่อนเขียนกลับ — ผู้ใช้ย้อนกลับไป-กลับมาได้เหมือน undo
await tx.insert(annotationVersions).values({
annotationId: existing.id,
fileId: existing.fileId,
pageIndex: existing.pageIndex,
userId: existing.userId,
userName: existing.userName,
type: existing.type,
data: existing.data,
kind: "update",
});
// validate ตาม type ของ row จริง — version อาจมาจาก type เดียวกันเสมอ (D8) แต่กันไว้
const validated = validateAnnotationData(existing.type, v.data);
const [updated] = await tx
.update(annotations)
.set({ data: validated, updatedAt: new Date() })
.where(eq(annotations.id, existing.id))
.returning();
return { action: "update" as const, row: updated };
}
// ต้นทางถูกลบไปแล้ว — เจ้าของ = userId ของ snapshot (D9)
if (v.userId !== uid) {
throw new Error("กู้คืนไม่ได้: เป็นรายการของผู้ใช้อื่น");
}
const [row] = await tx
.insert(annotations)
.values({
fileId: v.fileId,
pageIndex: v.pageIndex,
userId: v.userId,
userName: v.userName,
type: v.type,
data: v.data,
})
.returning();
return { action: "create" as const, row };
});
const dto = toAnnotationDTO(result.row);
publishAnnotateEvent(v.fileId, { table: "annotations", action: result.action, row: dto });
return dto;
}
// ─── Search (F14/D1) ─────────────────────────────────────────────────────────
/**
* ค้นหาคำในหมายเหตุทั้งไฟล์ — `ilike` บน `data->>'text'` (HIGHLIGHT และ TEXTBOX)
* และ `data->>'message'` (COMMENT); escape `%`/`_`/`\` ในคำค้นก่อนเอาไปใช้ใน pattern
*/
export async function searchAnnotations(fileId: string, q: string): Promise<AnnotationDTO[]> {
const fid = vUuid(fileId, "fileId");
const query = vString(q, "q", MAX_QUERY_CHARS);
const esc = query.replace(/[\\%_]/g, (c) => `\\${c}`);
const pattern = `%${esc}%`;
const rows = await db
.select()
.from(annotations)
.where(
and(
eq(annotations.fileId, fid),
or(
sql`${annotations.data}->>'text' ILIKE ${pattern}`,
sql`${annotations.data}->>'message' ILIKE ${pattern}`,
sql`${annotations.data}->>'note' ILIKE ${pattern}`,
),
),
)
.orderBy(asc(annotations.createdAt));
return rows.map(toAnnotationDTO);
}

View file

@ -0,0 +1,63 @@
import { asc, eq, inArray } from "drizzle-orm";
import { notFound } from "next/navigation";
import { db } from "@/db";
import {
annotationReplies,
annotations,
bookmarks,
files,
toAnnotationDTO,
toBookmarkDTO,
toReplyDTO,
} from "@/db/schema";
import PDFViewer from "@/components/pdf/PDFViewer";
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const dynamic = "force-dynamic";
export default async function AnnotatePage({
params,
}: {
params: { id: string };
}) {
const { id } = params;
if (!UUID_RE.test(id)) notFound();
const [file] = await db.select().from(files).where(eq(files.id, id));
if (!file) notFound();
const [annotationRows, bookmarkRows] = await Promise.all([
db
.select()
.from(annotations)
.where(eq(annotations.fileId, id))
.orderBy(asc(annotations.createdAt)),
db
.select()
.from(bookmarks)
.where(eq(bookmarks.fileId, id))
.orderBy(asc(bookmarks.createdAt)),
]);
// replies ของทุก comment ในไฟล์นี้ (F9) — แม่ถูก CASCADE ไปแล้ว row ก็หายตาม
const annotationIds = annotationRows.map((r) => r.id);
const replyRows = annotationIds.length
? await db
.select()
.from(annotationReplies)
.where(inArray(annotationReplies.annotationId, annotationIds))
.orderBy(asc(annotationReplies.createdAt))
: [];
return (
<PDFViewer
file={{ id: file.id, title: file.title, pdfUrl: file.pdfUrl }}
initialAnnotations={annotationRows.map(toAnnotationDTO)}
initialBookmarks={bookmarkRows.map(toBookmarkDTO)}
initialReplies={replyRows.map(toReplyDTO)}
/>
);
}

View file

@ -0,0 +1,118 @@
import Link from "next/link";
import { asc, eq } from "drizzle-orm";
import { notFound } from "next/navigation";
import { ArrowLeft, MapPin } from "lucide-react";
import { db } from "@/db";
import { annotations, files, toAnnotationDTO, type AnnotationDTO } from "@/db/schema";
import { annotationNote, annotationPreviewText, annotationTypeLabel } from "@/utils/annotation-display";
// หน้าสรุปหมายเหตุ (F15/D2) — server component รวมทุก annotation (ทุกผู้ใช้)
// จัดกลุ่มตามหน้า พร้อมปุ่มกระโดดเป็น deep link `/annotate/<id>?ann=<uuid>` (F12)
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const dynamic = "force-dynamic";
export default async function SummaryPage({
params,
}: {
params: { id: string };
}) {
const { id } = params;
if (!UUID_RE.test(id)) notFound();
const [file] = await db.select().from(files).where(eq(files.id, id));
if (!file) notFound();
const rows = await db
.select()
.from(annotations)
.where(eq(annotations.fileId, id))
.orderBy(asc(annotations.pageIndex), asc(annotations.createdAt));
const dtos = rows.map(toAnnotationDTO);
// จัดกลุ่มตามหน้า (เรียงหน้ามากไปน้อยไม่เกี่ยว — เดินตามลำดับหน้าเสมอ)
const byPage = new Map<number, AnnotationDTO[]>();
for (const a of dtos) {
const list = byPage.get(a.pageIndex);
if (list) list.push(a);
else byPage.set(a.pageIndex, [a]);
}
const pages = Array.from(byPage.entries()).sort((x, y) => x[0] - y[0]);
return (
<main className="mx-auto max-w-3xl p-6">
<div className="flex flex-wrap items-center gap-3">
<h1 className="min-w-0 flex-1 truncate text-lg font-bold text-primary">
สรุปหมายเหตุ — {file.title}
</h1>
<Link
href={`/annotate/${id}`}
className="flex items-center gap-1 rounded-lg border border-slate-300 px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
>
<ArrowLeft className="h-3.5 w-3.5" />
กลับหน้าอ่าน
</Link>
{/* F16/D3: export สรุปเป็น CSV (UTF-8 BOM) */}
<a
href={`/api/annotations/export-csv?fileId=${id}`}
className="rounded-lg border border-slate-300 px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
>
Export CSV
</a>
</div>
{pages.length === 0 ? (
<p className="mt-10 rounded border border-dashed p-6 text-center text-sm text-slate-500">
ยังไม่มี annotation ในไฟล์นี้
</p>
) : (
pages.map(([pageIndex, items]) => (
<section key={pageIndex} className="mt-6">
<h2 className="border-b border-slate-200 pb-1 text-sm font-semibold">
หน้า {pageIndex + 1}
<span className="ml-2 text-xs font-normal text-slate-400">
{items.length} รายการ
</span>
</h2>
<ul className="mt-2 divide-y divide-slate-100">
{items.map((a) => (
<li key={a.id} className="flex items-start gap-3 py-2">
<span className="min-w-0 flex-1">
<span className="block text-xs">
<span className="font-medium">{annotationTypeLabel(a)}</span>
{a.type === "COMMENT" && a.resolved && (
<span className="ml-2 rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] text-emerald-600">
จัดการแล้ว
</span>
)}
<span className="ml-2 text-slate-400">{a.userName}</span>
</span>
<span className="mt-0.5 block whitespace-pre-wrap break-words text-sm">
{annotationPreviewText(a)}
</span>
{annotationNote(a) && (
<span className="mt-0.5 block whitespace-pre-wrap break-words text-xs text-slate-500">
โน้ต: {annotationNote(a)}
</span>
)}
</span>
{/* ปุ่มกระโดด = deep link ไปยัง annotation นั้น (F12 รับ ?ann= ตอน mount) */}
<Link
href={`/annotate/${id}?ann=${a.id}`}
className="flex shrink-0 items-center gap-1 rounded-md border border-slate-300 px-2 py-1 text-[11px] text-slate-600 hover:bg-slate-100"
>
<MapPin className="h-3 w-3" />
ไปตำแหน่ง
</Link>
</li>
))}
</ul>
</section>
))
)}
</main>
);
}

View file

@ -0,0 +1,55 @@
import { asc, eq } from "drizzle-orm";
import { db } from "@/db";
import { annotations, files, toAnnotationDTO } from "@/db/schema";
import { annotationNote, annotationPreviewText, annotationTypeLabel } from "@/utils/annotation-display";
import { csvField } from "@/utils/csv";
import { encodeRFC5987 } from "@/utils/pdf-export";
// Export สรุปหมายเหตุเป็น CSV (F16/D3) — ข้อมูลเดียวกับหน้าสรุป (F15)
// ขึ้นต้นด้วย UTF-8 BOM เพื่อให้ Excel ภาษาไทยอ่านถูกต้อง (RFC 4180: ครอบ field
// ด้วย double quote, ขึ้นบรรทัดใหม่ด้วย CRLF)
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export async function GET(req: Request) {
const fileId = new URL(req.url).searchParams.get("fileId") ?? "";
if (!UUID_RE.test(fileId)) {
return new Response("fileId ไม่ถูกต้อง", { status: 400 });
}
const [file] = await db.select().from(files).where(eq(files.id, fileId));
if (!file) return new Response("ไม่พบไฟล์นี้", { status: 404 });
const rows = await db
.select()
.from(annotations)
.where(eq(annotations.fileId, fileId))
.orderBy(asc(annotations.pageIndex), asc(annotations.createdAt));
const header = ["หน้า", "ชนิด", "ผู้เขียน", "ข้อความ", "โน้ต", "สร้างเมื่อ"];
const lines = rows.map((row) => {
const a = toAnnotationDTO(row);
return [
String(a.pageIndex + 1),
annotationTypeLabel(a),
a.userName,
annotationPreviewText(a),
annotationNote(a),
new Date(a.createdAt).toLocaleString("th-TH", { timeZone: "Asia/Bangkok" }),
]
.map(csvField)
.join(",");
});
//  = UTF-8 BOM — ต้องเป็นตัวแรกสุดของไฟล์เสมอ
const csv = `${[header.map(csvField).join(","), ...lines].join("\r\n")}\r\n`;
const filename = encodeRFC5987(`${file.title}-summary.csv`);
return new Response(csv, {
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": `attachment; filename="summary.csv"; filename*=UTF-8''${filename}`,
},
});
}

View file

@ -0,0 +1,450 @@
import fs from "node:fs";
import path from "node:path";
import fontkit from "@pdf-lib/fontkit";
import { PDFDocument, LineCapStyle, rgb, type PDFFont } from "pdf-lib";
import { asc, eq } from "drizzle-orm";
import { db } from "@/db";
import { PdfPathError, readPublicPdf } from "@/lib/server-pdf";
import {
annotations,
files,
toAnnotationDTO,
type AnnotationType,
type CommentData,
type DrawData,
type HighlightData,
type RedactionData,
type ShapeData,
type StampData,
type TextboxData,
} from "@/db/schema";
import {
displayRectToPdf,
displaySize,
displayToPdfPoint,
encodeRFC5987,
hexToRgb01,
wrapStyledText,
wrapTextByWidth,
type Rot,
type StyledRun,
} from "@/utils/pdf-export";
import type { Rect } from "@/db/schema";
import { toPx, type Point, type Size } from "@/utils/coordinates";
import { STAMP_HEIGHT_RATIO, TEXTBOX_MAX_WIDTH_PCT } from "@/utils/geometry";
import { STAMP_COLOR, STAMP_FONT_RATIO, effectiveDrawOpacity } from "@/utils/annotation-display";
// Export PDF ฝัง annotation (F8/C1 — PLAN-FEAT.md §3)
// - อ่าน PDF ต้นฉบับจาก public/ แบบ **อ่านอย่างเดียว** (invariant #1) — pdf-lib load
// เป็นสำเนาในหน่วยความจำ แล้ว save() คืน bytes ใหม่ ไม่เขียนทับไฟล์เดิม
// - วาดต่อหน้าตามลำดับ: HIGHLIGHT → DRAW → SHAPE → REDACTION (ดำทึบ) → COMMENT หมุด
// (+ข้อความถ้ามีฟอนต์ Sarabun — D5: ไม่มีฟอนต์ต้องไม่พัง)
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const SARABUN_REL = path.join("fonts", "Sarabun-Regular.ttf");
// ลำดับวาดต่อหน้าตามสเปก §3 F8 ข้อ 3: HIGHLIGHT → DRAW → SHAPE → REDACTION → COMMENT
// (REDACTION ดำทึบต้องทับทุกอย่างที่สร้างก่อนหน้า และหมุด COMMENT ต้องไม่ถูกกลบ — D4)
const TYPE_ORDER: Record<AnnotationType, number> = {
HIGHLIGHT: 0,
DRAW: 1,
SHAPE: 2,
REDACTION: 3,
COMMENT: 4,
TEXTBOX: 5,
STAMP: 6,
};
export async function GET(req: Request) {
const url = new URL(req.url);
const fileId = url.searchParams.get("fileId") ?? "";
if (!UUID_RE.test(fileId)) {
return new Response("fileId ไม่ถูกต้อง", { status: 400 });
}
const [file] = await db.select().from(files).where(eq(files.id, fileId));
if (!file) return new Response("ไม่พบไฟล์นี้", { status: 404 });
// Guard path traversal: pdfUrl จาก DB ต้อง resolve แล้วอยู่ใน public/ เท่านั้น (§5)
let srcBytes: Buffer;
try {
srcBytes = readPublicPdf(file.pdfUrl);
} catch (err) {
if (err instanceof PdfPathError) {
return new Response("เส้นทางไฟล์ไม่ถูกต้อง", { status: 403 });
}
return new Response("อ่านไฟล์ PDF ต้นฉบับไม่ได้", { status: 404 });
}
const doc = await PDFDocument.load(srcBytes);
doc.registerFontkit(fontkit);
// D5: ฟอนต์ไทย optional — ไม่มีก็ export สำเร็จ (COMMENT เป็นหมุดอย่างเดียว)
let thaiFont: PDFFont | null = null;
const fontPath = path.join(process.cwd(), "public", SARABUN_REL);
if (fs.existsSync(fontPath)) {
try {
thaiFont = await doc.embedFont(fs.readFileSync(fontPath), { subset: true });
} catch (err) {
console.error("embed Sarabun ไม่สำเร็จ — ไปทาง fallback หมุดอย่างเดียว", err);
thaiFont = null;
}
}
// TEXTBOX ตัวหนา — optional เช่นกัน: ไม่มี Sarabun-Bold.ttf ก็ fallback เป็น Regular
// (บนจอ font-weight 700 อยู่แล้ว แต่ export ต้องมีไฟล์น้ำหนักหนาจริงจึงตรงกัน)
let thaiBoldFont: PDFFont | null = null;
const boldFontPath = path.join(process.cwd(), "public", "fonts", "Sarabun-Bold.ttf");
if (fs.existsSync(boldFontPath)) {
try {
thaiBoldFont = await doc.embedFont(fs.readFileSync(boldFontPath), { subset: true });
} catch (err) {
console.error("embed Sarabun-Bold ไม่สำเร็จ — ตัวหนา fallback เป็น Regular", err);
thaiBoldFont = null;
}
}
const rows = await db
.select()
.from(annotations)
.where(eq(annotations.fileId, fileId))
.orderBy(asc(annotations.createdAt));
// sort แยกตามชนิดก่อนวาด (stable ภายในชนิดด้วย createdAt) — กัน REDACTION เก่าโดน
// HIGHLIGHT/DRAW ที่สร้างทีหลังทับจนข้อความง้ำกลับมาอ่านได้ (D4)
const dtos = rows
.map(toAnnotationDTO)
.sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]);
for (const a of dtos) {
const page = doc.getPage(Math.min(a.pageIndex, doc.getPageCount() - 1));
const pageSize = page.getSize();
const size: Size = { width: pageSize.width, height: pageSize.height };
const rotation = (((page.getRotation().angle % 360) + 360) % 360) as Rot;
const display = displaySize(size, rotation);
// % ของหน้า (บนจอ) → px บนจอ — ผ่าน utils/coordinates เท่านั้น (invariant #2)
const px = toPx;
const pxPoint = (p: Point): Point => ({
x: px(p.x, display.width),
y: px(p.y, display.height),
});
const pxRect = (r: Rect): Rect => ({
x: px(r.x, display.width),
y: px(r.y, display.height),
width: px(r.width, display.width),
height: px(r.height, display.height),
});
const toPdf = (p: Point) => displayToPdfPoint(p, size, rotation);
// strokeWidth เป็น % ของความกว้างหน้า **บนจอ**
const thickness = (swPct: number) => px(swPct, display.width);
// AnnotationDTO เป็น flat type (type/data แยกกัน) → cast ตาม case ที่เช็คแล้ว (pattern เดียวกับ sidebar)
switch (a.type) {
case "HIGHLIGHT": {
const d = a.data as HighlightData;
const { r, g, b } = hexToRgb01(d.color);
const col = rgb(r, g, b);
const st = d.style ?? "highlight";
// ความทึบของผู้ใช้ (0–1, ไม่มี = ทึบเต็ม) — ไฮไลต์ทึบคูณเข้าค่าเดิม 0.35
// (สอดคล้อง mix-blend-multiply บนจอ), เส้นทุกแบบใช้ตรง ๆ
const op = d.opacity ?? 1;
for (const rPct of d.rects) {
const rect = displayRectToPdf(pxRect(rPct), size, rotation);
if (st === "underline") {
// เส้นใต้ที่ขอบล่างของบรรทัด (rect.y ของ PDF = ขอบล่างบนจอ)
page.drawLine({
start: { x: rect.x, y: rect.y },
end: { x: rect.x + rect.width, y: rect.y },
thickness: 1,
color: col,
opacity: op,
lineCap: LineCapStyle.Round,
});
} else if (st === "strikethrough") {
const y = rect.y + rect.height / 2;
page.drawLine({
start: { x: rect.x, y },
end: { x: rect.x + rect.width, y },
thickness: 1,
color: col,
opacity: op,
lineCap: LineCapStyle.Round,
});
} else if (st === "squiggle") {
// เส้นขยุกขยิก — sine เล็ก ๆ ระดับขอบล่าง วาดต่อเป็นท่อนสั้น ๆ
let prev = { x: rect.x, y: rect.y + 1.5 };
for (let t = 2; t <= rect.width; t += 2) {
const p = {
x: rect.x + t,
y: rect.y + 1.5 * Math.sin((t / 6) * Math.PI * 2),
};
page.drawLine({
start: prev,
end: p,
thickness: 1,
color: col,
opacity: op,
lineCap: LineCapStyle.Round,
});
prev = p;
}
} else {
page.drawRectangle({
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
color: col,
opacity: 0.35 * op,
});
}
}
break;
}
case "DRAW": {
const d = a.data as DrawData;
const { r, g, b } = hexToRgb01(d.strokeColor);
const t = Math.max(thickness(d.strokeWidth), 0.5);
// ความทึบตกลงมาจาก effectiveDrawOpacity (ค่าเดียวกับ render บนจอ) — โปร่งใส
// ใช้ lineCap butt กันปลาย segment ซ้อนกันเป็นจุดเข้มกว่าช่วงอื่น
const op = effectiveDrawOpacity(d);
const pts = d.paths.map((p) => toPdf(pxPoint(p)));
for (let i = 0; i < pts.length - 1; i++) {
page.drawLine({
start: pts[i],
end: pts[i + 1],
thickness: t,
color: rgb(r, g, b),
opacity: op,
lineCap: op < 1 ? LineCapStyle.Butt : LineCapStyle.Round,
});
}
break;
}
case "SHAPE": {
const d = a.data as ShapeData;
const { r, g, b } = hexToRgb01(d.strokeColor);
const color = rgb(r, g, b);
const t = Math.max(thickness(d.strokeWidth), 0.5);
if ("rect" in d) {
const rect = displayRectToPdf(pxRect(d.rect), size, rotation);
if (d.shape === "ellipse") {
page.drawEllipse({
x: rect.x + rect.width / 2,
y: rect.y + rect.height / 2,
xScale: Math.max(rect.width / 2, 0.1),
yScale: Math.max(rect.height / 2, 0.1),
borderColor: color,
borderWidth: t,
});
} else {
page.drawRectangle({
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
borderColor: color,
borderWidth: t,
});
}
} else {
const p1 = toPdf(pxPoint(d.from));
const p2 = toPdf(pxPoint(d.to));
page.drawLine({
start: p1,
end: p2,
thickness: t,
color,
lineCap: LineCapStyle.Round,
});
if (d.shape === "arrow") {
// หัวลูกศร = เส้นสั้น 2 เส้นกลับหลังจากปลาย to (มุม ±π/7 เหมือนบนจอ)
const head = Math.max(t * 3.5, 8);
const ang = Math.atan2(p2.y - p1.y, p2.x - p1.x);
for (const spread of [Math.PI / 7, -Math.PI / 7]) {
page.drawLine({
start: p2,
end: {
x: p2.x - head * Math.cos(ang + spread),
y: p2.y - head * Math.sin(ang + spread),
},
thickness: t,
color,
lineCap: LineCapStyle.Round,
});
}
}
}
break;
}
case "REDACTION": {
// ดำทึบทับจริง — ความปลอดภัยของ redaction อยู่ที่นี่ที่เดียว (D4)
const d = a.data as RedactionData;
const rect = displayRectToPdf(pxRect(d.rect), size, rotation);
page.drawRectangle({
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
color: rgb(0, 0, 0),
});
break;
}
case "COMMENT": {
const d = a.data as CommentData;
const pin = toPdf(pxPoint(d.position));
page.drawCircle({
x: pin.x,
y: pin.y,
size: 12,
color: rgb(254 / 255, 243 / 255, 199 / 255),
borderColor: rgb(252 / 255, 211 / 255, 77 / 255),
borderWidth: 1,
});
if (thaiFont) {
const font = thaiFont;
const fontSize = 9;
// FIX-5: ย่อความกว้างกล่องตามหน้าจริง — หน้าแคบกว่า 220pt
// เดิมทำให้ x ติดลบจนข้อความหลุดออกนอกขอบซ้ายเงียบ ๆ
const maxWidth = Math.min(220, size.width);
const lines = wrapTextByWidth(
`${a.userName}: ${d.message}`,
maxWidth,
(s) => font.widthOfTextAtSize(s, fontSize),
);
// FIX-5 (ต่อ): กันหลุดขอบบน — ข้อความวาด "ขึ้น" จากหมุด (แกน y ของ PDF ชี้ขึ้น)
// เมื่อหมุดใกล้ขอบบนหรือ wrap ยาวหลายบรรทัดจนบรรทัดบนสุดเลยขอบหน้า
// เลื่อนทั้งก้อนลงมาเท่าที่จำเป็น (ไม่ clamp รายบรรทัด ไม่งั้นบรรทัดซ้อนกัน)
const lineH = fontSize + 2;
const blockTop = pin.y + 4 + (lines.length - 1) * lineH;
const shift = Math.max(0, blockTop + fontSize - size.height);
lines.forEach((line, i) => {
page.drawText(line, {
x: Math.max(0, Math.min(pin.x + 10, size.width - maxWidth)),
y: pin.y + 4 + i * lineH - shift,
size: fontSize,
font,
color: rgb(0.2, 0.2, 0.2),
});
});
}
break;
}
case "TEXTBOX": {
// F17/B2 — วาดข้อความด้วย Sarabun (D5); ไม่มีฟอนต์ → กรอบแทน ต้องไม่พัง
const d = a.data as TextboxData;
const pos = toPdf(pxPoint(d.position));
const { r, g, b } = hexToRgb01(d.color);
if (thaiFont) {
const fontSize = Math.max(thickness(d.fontSize), 4);
const maxWidth = px(TEXTBOX_MAX_WIDTH_PCT, display.width);
// run ต่อสไตล์: segments (ตัวหนาเฉพาะส่วน) หรือทั้งกล่องตาม d.bold
const runs: StyledRun[] = d.segments?.length
? d.segments.map((s) => ({ text: s.text, bold: !!(s.bold || d.bold) }))
: [{ text: d.text, bold: !!d.bold }];
const fontFor = (bold: boolean) => (bold && thaiBoldFont ? thaiBoldFont : thaiFont);
const widthOf = (s: string, bold: boolean) =>
fontFor(bold).widthOfTextAtSize(s, fontSize);
const lines = wrapStyledText(runs, maxWidth, widthOf);
// จัดแนวในกล่องกว้าง maxWidth — เลื่อน x ต่อบรรทัดตามความกว้างจริงของบรรทัดนั้น
const align = d.align ?? "left";
lines.forEach((lineRuns, i) => {
const lineW = lineRuns.reduce((sum, rn) => sum + widthOf(rn.text, rn.bold), 0);
let x =
align === "center"
? pos.x + (maxWidth - lineW) / 2
: align === "right"
? pos.x + (maxWidth - lineW)
: pos.x;
for (const rn of lineRuns) {
page.drawText(rn.text, {
x,
// position เป็นมุมซ้ายบนบนจอ → เดินลงจากขอบบนทีละบรรทัด (พิกัด PDF แกน y ขึ้น)
y: pos.y - fontSize - i * (fontSize * 1.5),
size: fontSize,
font: fontFor(rn.bold),
color: rgb(r, g, b),
opacity: d.opacity ?? 1,
});
x += widthOf(rn.text, rn.bold);
}
});
} else {
const rect = displayRectToPdf(
pxRect({ x: d.position.x, y: d.position.y, width: 40, height: 10 }),
size,
rotation,
);
page.drawRectangle({
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
borderColor: rgb(r, g, b),
borderWidth: 1,
});
}
break;
}
case "STAMP": {
// F18/B5, D7 — วาดกรอบ+ข้อความ; ไม่มีฟอนต์ → กรอบอย่างเดียว (D5 ต้องไม่พัง)
const d = a.data as StampData;
const { r, g, b } = hexToRgb01(d.color ?? STAMP_COLOR);
const stampRgb = rgb(r, g, b);
const w = px(d.scale, display.width);
const rect = displayRectToPdf(
{
x: px(d.position.x, display.width),
y: px(d.position.y, display.height),
width: w,
height: w * STAMP_HEIGHT_RATIO,
},
size,
rotation,
);
page.drawRectangle({
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
borderColor: stampRgb,
borderWidth: Math.max(w * 0.02, 1),
});
if (thaiFont) {
const font = thaiFont;
const fontSize = w * STAMP_FONT_RATIO;
const lines = wrapTextByWidth(
d.label,
w * 0.9,
(s) => font.widthOfTextAtSize(s, fontSize),
);
const lineH = fontSize * 1.2;
lines.forEach((line, i) => {
const textW = font.widthOfTextAtSize(line, fontSize);
page.drawText(line, {
x: rect.x + (rect.width - textW) / 2,
y: rect.y + rect.height - (rect.height - lines.length * lineH) / 2 - lineH * i - fontSize,
size: fontSize,
font,
color: stampRgb,
});
});
}
break;
}
}
}
const out = await doc.save();
const filename = encodeRFC5987(`${file.title}-annotated.pdf`);
return new Response(new Uint8Array(out), {
headers: {
"content-type": "application/pdf",
// filename เป็น ASCII fallback + filename* UTF-8 สำหรับชื่อไทย
"content-disposition": `attachment; filename="annotated.pdf"; filename*=UTF-8''${filename}`,
},
});
}

View file

@ -0,0 +1,64 @@
import { subscribeAnnotateEvents } from "@/lib/annotate-events";
// SSE endpoint (PLAN.md §4): client เปิด EventSource ไปที่นี่เพื่อรับ annotate event
// ของไฟล์เดียวกันแบบ realtime — event ถูก publish โดย Server Actions ผ่าน lib/annotate-events
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const dynamic = "force-dynamic";
export async function GET(req: Request): Promise<Response> {
const fileId = new URL(req.url).searchParams.get("fileId") ?? "";
if (!UUID_RE.test(fileId)) {
return new Response("fileId ต้องเป็น UUID", { status: 400 });
}
const encoder = new TextEncoder();
let cleanup: () => void = () => {};
const stream = new ReadableStream<Uint8Array>({
start(controller) {
const send = (event: unknown) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
};
send({ type: "connected" });
const unsubscribe = subscribeAnnotateEvents(fileId, send);
// keep-alive กัน connection ถูกตัดเมื่อเงียบนาน (comment line — client ไม่เห็น)
const ping = setInterval(() => {
try {
controller.enqueue(encoder.encode(": ping\n\n"));
} catch {
// stream ปิดไปแล้ว — cleanup ทำหน้าที่ตัด interval
}
}, 30_000);
let done = false;
cleanup = () => {
if (done) return;
done = true;
clearInterval(ping);
unsubscribe();
try {
controller.close();
} catch {
// ปิดอยู่แล้ว
}
};
// client ปิดแท็บ/ตัดการเชื่อมต่อ → unsubscribe คืน memory (rules/realtime.md)
req.signal.addEventListener("abort", cleanup);
},
cancel() {
cleanup();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
}

94
app/globals.css Normal file
View file

@ -0,0 +1,94 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ─── TextLayer ของ pdfjs v4 ─────────────────────────────────────────────────
คัดมาจาก node_modules/pdfjs-dist/web/pdf_viewer.css (ส่วนที่จำเป็น)
ใช้กับ pdfjs.TextLayer ที่ render ลง div.textLayer ใน PDFViewer */
.textLayer {
position: absolute;
text-align: initial;
inset: 0;
overflow: clip;
opacity: 1;
line-height: 1;
text-size-adjust: none;
forced-color-adjust: none;
transform-origin: 0 0;
caret-color: CanvasText;
/* ห้ามใส่ z-index ที่นี่ — บล็อกนี้อยู่หลัง @tailwind utilities ใน cascade
จะ override z-[2] ของ Tailwind จน textLayer ไปอยู่ใต้ canvas (invariant #3) */
}
.textLayer :is(span, br) {
color: transparent;
position: absolute;
white-space: pre;
cursor: text;
transform-origin: 0% 0%;
}
.textLayer > :not(.markedContent),
.textLayer .markedContent span:not(.markedContent) {
z-index: 1;
}
.textLayer span.markedContent {
top: 0;
height: 0;
}
.textLayer span[role="img"] {
user-select: none;
cursor: default;
}
.textLayer ::selection {
background: rgba(0 0 255 / 0.25);
background: color-mix(in srgb, AccentColor, transparent 75%);
}
.textLayer br::selection {
background: transparent;
}
.textLayer .endOfContent {
display: block;
position: absolute;
inset: 100% 0 0;
z-index: 0;
cursor: default;
user-select: none;
}
.textLayer.selecting .endOfContent {
top: 0;
}
/* canvas วัด font metrics ที่ pdfjs.TextLayer สร้างแล้ว append ท้าย document.body
— คัดมาจาก pdf_viewer.css ของ pdfjs-dist เช่นกัน (ตอนแรกคัดมาแค่ส่วน .textLayer
กฎนี้จึงตกหล่น ทำให้ canvas ว่าง alpha:false แสดงเป็นก้อนดำ 300×150 ที่ล่างซ้าย
ของ document และเพิ่ม scrollHeight เกินจริง 150px) */
.hiddenCanvasElement {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
background-size: 100% 100%;
background-color: transparent;
}
/* ─── UX review 2026-09-09 ข้อ 4: ปุ่ม header เล็กกว่ามาตรฐาน touch 44×44 ─────
ขยายเฉพาะอุปกรณ์จอสัมผัส — ครอบปุ่ม toggle/page nav/zoom/tool group
(รวมปุ่ม dropdown ของ tool)/undo-redo/bookmark/export + ปุ่ม summary (Link)
แต่ไม่โดนปุ่มใน dropdown panel (สี/ความหนา) ที่ซ้อนลึกกว่าอีกชั้น */
@media (pointer: coarse) {
header .touch-44 > button,
header .touch-44 > div > button,
header .touch-44 > div > div > button,
header .touch-44 > div > a {
min-height: 2.75rem;
min-width: 2.75rem;
}
}

20
app/layout.tsx Normal file
View file

@ -0,0 +1,20 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Jude's Note",
description: "ระบบ PDF Annotation สำหรับ e-Court",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="th">
<body className="bg-slate-100 text-slate-900 antialiased">{children}</body>
</html>
);
}

52
app/page.tsx Normal file
View file

@ -0,0 +1,52 @@
import Link from "next/link";
import { asc } from "drizzle-orm";
import { FileText } from "lucide-react";
import { db } from "@/db";
import { files } from "@/db/schema";
export const dynamic = "force-dynamic";
export default async function HomePage() {
const rows = await db.select().from(files).orderBy(asc(files.createdAt));
return (
<main className="mx-auto max-w-2xl p-8">
<h1 className="text-2xl font-bold text-primary">Jude&apos;s Note</h1>
<p className="mt-1 text-sm text-slate-500">
ระบบ PDF Annotation สำหรับ e-Court
</p>
{rows.length === 0 ? (
<p className="mt-8 rounded border border-dashed p-6 text-center text-sm text-slate-500">
ยังไม่มีไฟล์ในระบบ — รัน /sync-db เพื่อ seed ไฟล์ตัวอย่าง
</p>
) : (
<ul className="mt-6 space-y-2">
{rows.map((f) => (
<li
key={f.id}
className="flex items-center gap-3 rounded-lg border bg-white p-4 shadow-sm"
>
<FileText className="h-5 w-5 shrink-0 text-slate-400" />
<span className="min-w-0 flex-1">
<span className="block break-all line-clamp-2 text-sm font-medium">
{f.title}
</span>
<span className="block text-xs text-slate-400">
{new Date(f.createdAt).toLocaleString("th-TH")}
</span>
</span>
<Link
href={`/annotate/${f.id}`}
className="shrink-0 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-white transition hover:opacity-90"
>
Annotate
</Link>
</li>
))}
</ul>
)}
</main>
);
}

View file

@ -0,0 +1,617 @@
"use client";
import {
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import { AlignCenter, AlignLeft, AlignRight, BookmarkPlus, Bold, Trash2 } from "lucide-react";
import type {
AnnotationData,
AnnotationDTO,
HighlightData,
HighlightStyle,
TextboxData,
} from "@/db/schema";
import {
annotationBBox,
scaleAnnotationData,
translateAnnotationData,
} from "@/utils/geometry";
import { clamp, getPointerPercent, type Point, type Rect, type Size } from "@/utils/coordinates";
import { STAMP_COLOR } from "@/utils/annotation-display";
import { ColorSwatches, HIGHLIGHT_STYLE_BTNS, NumberCombo, PanelRow } from "./ToolControls";
// กรอบ selection + handles ของ annotation ที่เลือก (PLAN-FEAT.md §3 F1)
// - container เป็น z-4 เหมือน comment pins และ pointer-events: none **เสมอ**
// เฉพาะ handle มุม (scale) / ขอบกรอบ (translate) / แถบเครื่องมือที่ auto
// → ไม่บังการ select ข้อความของ textLayer (invariant #3)
// - ระหว่างลาก: ส่ง preview data ให้ parent ไป render ที่ layer เดิมของชนิดนั้น
// pointerup → commit ผ่าน onCommit (parent ทำ optimistic + updateAnnotation)
const HANDLE_PX = 10; // ขนาดมุมจับ (scale)
const STRIP_PX = 8; // ความหนาขอบกรอบ (translate)
const MIN_BOX_PCT = 0.5; // bbox เล็กสุด (% ของหน้า) กันกรอบหาย
const EPS = 1e-6;
type DragState = {
mode: "move" | "scale";
/** ตำแหน่ง pointer ตอนเริ่มลาก (% ของหน้า) */
startPct: Point;
/** data ก่อนเริ่มลาก — ต้นทางของการคำนวณทุก move (ไม่สะสมจาก preview) */
origData: AnnotationData;
/** bbox ก่อนเริ่มลาก (% ของหน้า) */
origBoxPct: Rect;
/** mode "scale" เท่านั้น — มุมตรงข้ามของ handle (% ของหน้า) */
anchorPct: Point;
};
/** จำกัดตัวคูณ scale แกนเดียว ให้กรอบผลลัพธ์ยังอยู่ใน 0–100 และไม่เล็กกว่าขั้นต่ำ */
function clampAxisScale(
rawS: number,
anchor: number,
edgeA: number,
edgeB: number,
origExtent: number,
): number {
let lo = 0;
let hi = 50;
for (const e of [edgeA, edgeB]) {
const denom = e - anchor;
if (Math.abs(denom) < EPS) continue;
const a = (0 - anchor) / denom;
const b = (100 - anchor) / denom;
lo = Math.max(lo, Math.min(a, b));
hi = Math.min(hi, Math.max(a, b));
}
if (origExtent > EPS) lo = Math.max(lo, MIN_BOX_PCT / origExtent);
if (lo > hi) return 1; // กรอบเดิมใหญ่เต็มหน้า — ไม่ขยาย
return clamp(rawS, lo, hi);
}
export default function AnnotationSelectionLayer({
annotation,
size,
editable,
pageBaseWidth,
disabled,
onPreview,
onCommit,
onDelete,
onBookmark,
}: {
annotation: AnnotationDTO;
/** ขนาด px ของหน้า (จาก width/height ที่คำนวณจาก scale) */
size: Size;
/** true เฉพาะ annotation ของตัวเอง — ของคนอื่นเห็นแค่กรอบ ไม่มี handle (guard จริงอยู่ที่ action) */
editable: boolean;
/** ความกว้างหน้าแรกที่ scale 1 — แปลง px↔% ของ fontSize ตอนแสดงในแผง */
pageBaseWidth: number;
/** tool ที่ InkLayer ใช้ pointer (draw/shape/erase) → ปิด handle ไม่ให้ดัก pointer (PLAN-FEAT.md §5) */
disabled: boolean;
/** ระหว่างลาก: data ชั่วคราวให้ parent ไป render ที่ layer เดิม (null = เลิก preview) */
onPreview: (data: AnnotationData | null) => void;
/** pointerup แล้วมีการเปลี่ยนตำแหน่ง/ขนาดจริง → parent ทำ optimistic + updateAnnotation */
onCommit: (id: string, data: AnnotationData) => void;
/** ปุ่มลบบนกรอบ — parent เรียก deleteAnnotation (แสดงเฉพาะรายการของตัวเอง) */
onDelete: (id: string) => void;
/** N2: ปุ่ม bookmark ในแผง — เปิด popup ที่ตำแหน่ง (yOffset %) ของ annotation นี้ */
onBookmark: (yOffsetPct: number) => void;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null);
const movedRef = useRef(false);
const latestRef = useRef<AnnotationData | null>(null);
const boxPx = annotationBBox(annotation, size);
const boxPct = annotationBBox(annotation, { width: 100, height: 100 });
// FIX-9: เอา non-null ! ออก — handler ยิงได้แค่ตอน DOM mount แล้ว
// ref จึงไม่ควรเป็น null (คืน 0,0 เผื่อไว้เฉย ๆ ให้ type ชัด)
const pointerPct = (e: ReactPointerEvent): Point => {
const el = rootRef.current;
if (!el) return { x: 0, y: 0 };
return getPointerPercent(e, el);
};
function beginDrag(
e: ReactPointerEvent<HTMLElement>,
mode: "move" | "scale",
anchorPct: Point,
) {
if (!editable || e.button !== 0) return;
e.preventDefault(); // กัน native text selection ระหว่างลาก
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
dragRef.current = {
mode,
startPct: pointerPct(e),
origData: annotation.data,
origBoxPct: boxPct,
anchorPct,
};
movedRef.current = false;
latestRef.current = null;
}
function moveDrag(e: ReactPointerEvent<HTMLElement>) {
const st = dragRef.current;
if (!st) return;
e.stopPropagation();
const cur = pointerPct(e);
let next: AnnotationData;
if (st.mode === "move") {
// clamp offset ให้ bbox ทั้งกลุ่มยังอยู่ในหน้า (พิกัด % ติดลบโดน action ปฏิเสธ)
const dx = clamp(
cur.x - st.startPct.x,
-st.origBoxPct.x,
100 - st.origBoxPct.x - st.origBoxPct.width,
);
const dy = clamp(
cur.y - st.startPct.y,
-st.origBoxPct.y,
100 - st.origBoxPct.y - st.origBoxPct.height,
);
next = translateAnnotationData(st.origData, dx, dy);
} else {
const sx = clampAxisScale(
(cur.x - st.anchorPct.x) / (st.startPct.x - st.anchorPct.x || EPS),
st.anchorPct.x,
st.origBoxPct.x,
st.origBoxPct.x + st.origBoxPct.width,
st.origBoxPct.width,
);
const sy = clampAxisScale(
(cur.y - st.anchorPct.y) / (st.startPct.y - st.anchorPct.y || EPS),
st.anchorPct.y,
st.origBoxPct.y,
st.origBoxPct.y + st.origBoxPct.height,
st.origBoxPct.height,
);
next = scaleAnnotationData(st.origData, st.anchorPct, sx, sy);
}
movedRef.current = true;
latestRef.current = next;
onPreview(next);
}
function endDrag(e: ReactPointerEvent<HTMLElement>) {
const st = dragRef.current;
if (!st) return;
e.stopPropagation();
dragRef.current = null;
onPreview(null);
if (movedRef.current && latestRef.current) onCommit(annotation.id, latestRef.current);
movedRef.current = false;
latestRef.current = null;
}
function cancelDrag() {
if (!dragRef.current) return;
dragRef.current = null;
movedRef.current = false;
latestRef.current = null;
onPreview(null);
}
/** ปุ่มสี/ความหนาบนกรอบ — ใช้ onCommit เดียวกับการลาก */
function commitColor(color: string) {
const d = annotation.data;
if ("rects" in d) onCommit(annotation.id, { ...d, color });
else if ("paths" in d || "shape" in d) onCommit(annotation.id, { ...d, strokeColor: color });
// STAMP ("label") — แก้สีหลังสร้างได้ (TEXTBOX แก้ผ่านการพิมพ์ inline แทน)
else if ("label" in d) onCommit(annotation.id, { ...d, color });
}
function commitStrokeWidth(w: number) {
const d = annotation.data;
if ("paths" in d) {
// F7: เส้นปากกามี widths ต่อจุด — scale ตามอัตราส่วนพร้อม strokeWidth หลัก (PLAN-FEAT.md §5)
onCommit(annotation.id, {
...d,
strokeWidth: w,
...(d.widths
? { widths: d.widths.map((pw) => (pw / d.strokeWidth) * w) }
: {}),
});
} else if ("shape" in d) {
onCommit(annotation.id, { ...d, strokeWidth: w });
}
}
/** HIGHLIGHT — เปลี่ยนรูปแบบมาร์กหลังสร้าง (ไฮไลต์/เส้นใต้/ขีดฆ่า/ขยุกขยิก) */
function commitHighlightStyle(style: HighlightStyle) {
const d = annotation.data;
if (!("rects" in d)) return;
onCommit(annotation.id, { ...d, style });
}
/** HIGHLIGHT — ปรับความทึบหลังสร้าง (100% = ทึบเต็ม = ไม่เก็บ field เหมือนตอนสร้างใหม่) */
function commitHighlightOpacity(opacityPct: number) {
const d = annotation.data;
if (!("rects" in d)) return;
const { opacity: _prev, ...rest } = d;
onCommit(annotation.id, {
...rest,
...(opacityPct < 100 ? { opacity: opacityPct / 100 } : {}),
});
}
/** STAMP — ปรับขนาดตรายางหลังสร้าง */
function commitStampScale(scale: number) {
const d = annotation.data;
if (!("label" in d)) return;
onCommit(annotation.id, { ...d, scale });
}
/** TEXTBOX — แก้ค่าทั้งกล่องหลังสร้าง (สี/ขนาด/ทึบ/หนา/จัดแนว) เหมือน dropdown ของ tool */
function commitTextboxPatch(
patch: Partial<Pick<TextboxData, "color" | "fontSize" | "opacity" | "bold" | "align">>,
) {
if (!textboxData) return;
onCommit(annotation.id, { ...textboxData, ...patch });
}
// ตัวควบคุมในแผงตรวจตามชนิดด้วยการเช็ค field ใน data ตรง ๆ (rects = HIGHLIGHT,
// paths/shape = เส้น/รูปทรง, label = STAMP) — เก็บเป็นตัวแปรท้องถิ่นเพื่อให้ TS
// narrow type คงอยู่ใน callback ของ JSX
// HIGHLIGHT ห้ามย้าย/ขยาย — boolean แยกไว้ใช้กับเงื่อนไข handle (ไม่แตะการ narrow ของตัวแปรอื่น)
const isHighlightMark = "rects" in annotation.data;
const highlightData: HighlightData | null =
"rects" in annotation.data ? annotation.data : null;
const strokeData =
"paths" in annotation.data || "shape" in annotation.data ? annotation.data : null;
const stampData = "label" in annotation.data ? annotation.data : null;
const textboxData: TextboxData | null =
"fontSize" in annotation.data ? annotation.data : null;
// มุมทั้ง 4 — ตำแหน่งวางเป็น px, anchor มุมตรงข้ามเป็น % ตรง ๆ จาก boxPct
// (พิกัด % คำนวณผ่าน utils/coordinates เท่านั้น — invariant #2)
const corners: { key: string; x: number; y: number; cursor: string; anchor: Point }[] = [
{
key: "nw",
x: boxPx.x,
y: boxPx.y,
cursor: "nwse-resize",
anchor: { x: boxPct.x + boxPct.width, y: boxPct.y + boxPct.height },
},
{
key: "ne",
x: boxPx.x + boxPx.width,
y: boxPx.y,
cursor: "nesw-resize",
anchor: { x: boxPct.x, y: boxPct.y + boxPct.height },
},
{
key: "sw",
x: boxPx.x,
y: boxPx.y + boxPx.height,
cursor: "nesw-resize",
anchor: { x: boxPct.x + boxPct.width, y: boxPct.y },
},
{
key: "se",
x: boxPx.x + boxPx.width,
y: boxPx.y + boxPx.height,
cursor: "nwse-resize",
anchor: { x: boxPct.x, y: boxPct.y },
},
];
const toolbarAbove = boxPx.y >= 36;
// แผงเครื่องมือปกติเปิดขึ้นด้านบน bbox — แต่ถ้ารายการอยู่ใกล้บนสุดของ viewport
// (หน้าเลื่อนขึ้นจนกรอบอยู่ใต้ sticky header) แผงจะเปิดขึ้นไปโดน header บัง
// → วัดตำแหน่งจริงหลัง render แล้วพลิกลงด้านล่างแทน (รีเซ็ตทุกครั้งที่เปลี่ยนรายการ)
const panelRef = useRef<HTMLDivElement | null>(null);
const [flipBelow, setFlipBelow] = useState(false);
useEffect(() => {
setFlipBelow(false);
}, [annotation.id]);
useEffect(() => {
if (!toolbarAbove || flipBelow || !panelRef.current) return;
const headerH = document.querySelector("header")?.offsetHeight ?? 0;
if (panelRef.current.getBoundingClientRect().top < headerH) {
setFlipBelow(true);
}
}, [toolbarAbove, flipBelow, annotation.id, boxPx.x, boxPx.y]);
const openAbove = toolbarAbove && !flipBelow;
return (
<div
ref={rootRef}
className="absolute inset-0 z-[4] select-none"
style={{ pointerEvents: "none" }}
onClick={(e) => e.stopPropagation()} // คลิกบน handle/toolbar ไม่ให้ bubble ไป deselect ที่ page
>
{/* กรอบ bbox — ตัวกรอบเอง pointer-events none ผ่านไป textLayer ได้ */}
<div
className="absolute border-2 border-dashed border-primary"
style={{
left: boxPx.x,
top: boxPx.y,
width: boxPx.width,
height: boxPx.height,
}}
/>
{/* HIGHLIGHT ผูกกับตำแหน่งข้อความจริงบนหน้า — ห้ามย้าย/ขยาย (เลือกเพื่อเปลี่ยน
สี/รูปแบบ/ลบได้อย่างเดียว) จึงไม่มี handle ขอบกรอบและมุม */}
{editable && !disabled && !isHighlightMark && (
<>
{/* ขอบกรอบ 4 ด้าน — ลาก = translate ทั้งกลุ่ม */}
<div
className="absolute cursor-move"
style={{
left: boxPx.x - STRIP_PX / 2,
top: boxPx.y - STRIP_PX / 2,
width: boxPx.width + STRIP_PX,
height: STRIP_PX,
pointerEvents: "auto",
}}
onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={cancelDrag}
/>
<div
className="absolute cursor-move"
style={{
left: boxPx.x - STRIP_PX / 2,
top: boxPx.y + boxPx.height - STRIP_PX / 2,
width: boxPx.width + STRIP_PX,
height: STRIP_PX,
pointerEvents: "auto",
}}
onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={cancelDrag}
/>
<div
className="absolute cursor-move"
style={{
left: boxPx.x - STRIP_PX / 2,
top: boxPx.y,
width: STRIP_PX,
height: boxPx.height,
pointerEvents: "auto",
}}
onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={cancelDrag}
/>
<div
className="absolute cursor-move"
style={{
left: boxPx.x + boxPx.width - STRIP_PX / 2,
top: boxPx.y,
width: STRIP_PX,
height: boxPx.height,
pointerEvents: "auto",
}}
onPointerDown={(e) => beginDrag(e, "move", { x: 0, y: 0 })}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={cancelDrag}
/>
{/* มุมทั้ง 4 — ลาก = scale รอบ anchor มุมตรงข้าม */}
{corners.map((c) => (
<div
key={c.key}
className="absolute rounded-sm border border-primary bg-primary/50"
style={{
left: c.x - HANDLE_PX / 2,
top: c.y - HANDLE_PX / 2,
width: HANDLE_PX,
height: HANDLE_PX,
cursor: c.cursor,
pointerEvents: "auto",
}}
onPointerDown={(e) => beginDrag(e, "scale", c.anchor)}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={cancelDrag}
/>
))}
</>
)}
{/* แผงเครื่องมือของรายการที่เลือก — ตัวควบคุมเดียวกับ dropdown ของ tool ผู้สร้าง
(สี/ความหนา/ขนาด/รูปแบบตามชนิด) + ปุ่มลบท้ายแผง — แสดงทุกชนิดรวม HIGHLIGHT
(ไม่มี handle ย้าย/ขยาย แต่เปลี่ยนสี/รูปแบบ/ลบได้) — เฉพาะรายการของตัวเอง */}
{editable && !disabled && (
<div
ref={panelRef}
className="absolute w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg"
style={{
left: boxPx.x,
top: openAbove ? boxPx.y - 6 : boxPx.y + boxPx.height + 4,
transform: openAbove ? "translateY(-100%)" : undefined,
pointerEvents: "auto",
}}
>
{highlightData && (
<>
<PanelRow label="สี">
<ColorSwatches value={highlightData.color} onChange={commitColor} />
</PanelRow>
<PanelRow label="รูปแบบ">
<div className="flex flex-1 gap-1">
{HIGHLIGHT_STYLE_BTNS.map(({ key, label, Icon }) => (
<button
key={key}
type="button"
aria-label={label}
title={label}
aria-pressed={(highlightData.style ?? "highlight") === key}
className={`flex h-6 w-6 items-center justify-center rounded-md transition ${
(highlightData.style ?? "highlight") === key
? "bg-primary text-white"
: "text-slate-500 hover:bg-slate-100"
}`}
onClick={() => commitHighlightStyle(key)}
>
<Icon className="h-3.5 w-3.5" />
</button>
))}
</div>
</PanelRow>
<PanelRow label="ความทึบ">
<NumberCombo
value={Math.round((highlightData.opacity ?? 1) * 100)}
options={[25, 50, 75, 100]}
min={0}
max={100}
step={1}
suffix="%"
onChange={commitHighlightOpacity}
/>
</PanelRow>
</>
)}
{strokeData && (
<PanelRow label="สี">
<ColorSwatches value={strokeData.strokeColor} onChange={commitColor} />
</PanelRow>
)}
{strokeData && (
<PanelRow label="ความหนา">
{/* หน่วยย่อ ×10 ของ % ความกว้างหน้า — เดียวกับ dropdown ของ tool draw/shape */}
<NumberCombo
value={Number((strokeData.strokeWidth * 10).toFixed(2))}
options={[1, 2, 4, 8, 12]}
min={1}
max={30}
step={1}
onChange={(v) => commitStrokeWidth(v / 10)}
/>
</PanelRow>
)}
{stampData && (
<>
<PanelRow label="สี">
<ColorSwatches
value={stampData.color ?? STAMP_COLOR}
onChange={commitColor}
/>
</PanelRow>
<PanelRow label="ขนาด">
<NumberCombo
value={stampData.scale}
options={[12, 18, 25, 40]}
min={5}
max={60}
step={1}
suffix="%"
onChange={commitStampScale}
/>
</PanelRow>
</>
)}
{textboxData && (
<>
<PanelRow label="สี">
<ColorSwatches
value={textboxData.color}
onChange={(c) => commitTextboxPatch({ color: c })}
/>
</PanelRow>
<PanelRow label="ขนาด">
{/* แสดง/กรอกเป็น px ตามขนาดหน้าจริง — เก็บใน DB เป็น % ของความกว้างหน้า */}
<NumberCombo
value={Number(((textboxData.fontSize / 100) * pageBaseWidth).toFixed(1))}
options={[10, 12, 14, 18, 24]}
min={6}
max={48}
step={1}
suffix="px"
onChange={(v) => commitTextboxPatch({ fontSize: (v / pageBaseWidth) * 100 })}
/>
</PanelRow>
<PanelRow label="ความทึบ">
<NumberCombo
value={Math.round((textboxData.opacity ?? 1) * 100)}
options={[25, 50, 75, 100]}
min={0}
max={100}
step={1}
suffix="%"
onChange={(v) => commitTextboxPatch({ opacity: v / 100 })}
/>
</PanelRow>
<PanelRow label="ตัวหนา">
<button
type="button"
aria-label="สลับตัวหนาทั้งกล่อง"
aria-pressed={!!textboxData.bold}
className={`flex h-6 w-6 items-center justify-center rounded-md border transition ${
textboxData.bold
? "border-primary bg-primary text-white"
: "border-slate-300 bg-white text-slate-500 hover:bg-slate-100"
}`}
onClick={() => commitTextboxPatch({ bold: !textboxData.bold })}
>
<Bold className="h-3.5 w-3.5" />
</button>
</PanelRow>
<PanelRow label="จัดแนว">
<div className="flex overflow-hidden rounded-md border border-slate-300">
{(
[
["left", AlignLeft, "ชิดซ้าย"],
["center", AlignCenter, "กึ่งกลาง"],
["right", AlignRight, "ชิดขวา"],
] as const
).map(([a, AlignIcon, aLabel]) => (
<button
key={a}
type="button"
aria-label={`จัดแนว${aLabel}`}
aria-pressed={(textboxData.align ?? "left") === a}
className={`px-2 py-1 transition ${
(textboxData.align ?? "left") === a
? "bg-primary text-white"
: "text-slate-500 hover:bg-slate-100"
}`}
onClick={() => commitTextboxPatch({ align: a })}
>
<AlignIcon className="h-3.5 w-3.5" />
</button>
))}
</div>
</PanelRow>
</>
)}
<div className="my-1 border-t border-slate-100" />
<div className="flex items-center justify-between">
{/* N2: bookmark ตำแหน่งของรายการนี้ — เปิด popup ตั้งชื่อ/โน้ต/เลือกหัวข้อต่อ */}
<button
type="button"
aria-label="บันทึก bookmark ตำแหน่งรายการนี้"
title="Bookmark ตำแหน่งนี้"
className="flex h-5 w-5 items-center justify-center rounded text-slate-400 hover:bg-amber-50 hover:text-amber-500"
onClick={() => onBookmark(boxPct.y)}
>
<BookmarkPlus className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label="ลบรายการนี้"
title="ลบ"
className="flex h-5 w-5 items-center justify-center rounded text-red-500 hover:bg-red-50"
onClick={() => onDelete(annotation.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
</div>
);
}

581
components/pdf/InkLayer.tsx Normal file
View file

@ -0,0 +1,581 @@
"use client";
import {
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import type { RedactionData, ShapeData, ShapeKind } from "@/db/schema";
import {
clamp,
pathToPercent,
pointToPercent,
pointToPx,
rectToPercent,
rectToPx,
toPx,
type Point,
type Rect,
type Size,
} from "@/utils/coordinates";
import { hitTestPath, hitTestShape } from "@/utils/geometry";
/** เส้นวาดที่เก็บใน DB — จุดทุกจุดเป็น % ของหน้า, strokeWidth เป็น % ของความกว้าง */
export type InkStroke = {
id: string;
/** เจ้าของ — eraser ใช้กรอง hit-test เฉพาะของตัวเอง (F6) */
userId: string;
color: string;
strokeWidthPct: number;
points: Point[];
/** F7/E1: ความหนาต่อจุด (% ของความกว้างหน้า) — มีเฉพาะเส้นที่วาดด้วยสไตลัส */
widthsPct?: number[];
/** ความทึบของเส้น (0–1) — parent คำนวณให้แล้วรวม fallback ตามชนิดปากกา (effectiveDrawOpacity) */
opacity?: number;
};
/** รูปทรงที่เก็บใน DB (F5/B1) — พิกัด % ทั้งหมด */
export type InkShape = { id: string; userId: string; data: ShapeData };
/** ปิดพรางที่เก็บใน DB (F8/B4) — rect % ของหน้า */
export type InkRedaction = { id: string; userId: string; data: RedactionData };
/** รูปทรงที่กำลังลาก (px) */
type LiveShape = { kind: ShapeKind; start: Point; end: Point };
/**
* F7/E1: แปลง e.pressure (0–1, mouse ได้ 0.5 ตลอด) เป็นตัวคูณความหนา
* 0.5 + pressure → เมาส์/แตะนิ้วได้ 1.0 เท่าเดิมพอดี, ปากกากดเบา–หนักได้ 0.5–1.5 เท่า
*/
const pressureFactor = (pressure: number): number => 0.5 + clamp(pressure, 0, 1);
/**
* วาด "ตรง ๆ ด้วยมือ" แล้วปล่อย → snap เป็นเส้นตรงอัตโนมัติ (ไม่ต้องกด Shift):
* จุดทุกจุดต้องห่างจากเส้นตรง จุดเริ่ม→จุดสุดท้าย ไม่เกิน max(5% ของความยาว, 3px)
* เส้นโค้ง/ขดจริง ๆ จะหลุดเกณฑ์ → เหลือเป็น freehand ตามที่วาด
*/
function isNearlyStraight(pts: Point[]): boolean {
const a = pts[0];
const b = pts[pts.length - 1];
const len = Math.hypot(b.x - a.x, b.y - a.y);
if (len < 24) return false; // เส้นสั้นมาก (ขีดสั้น ๆ) ปล่อยตามที่วาด
const maxDev = Math.max(len * 0.05, 3);
for (const p of pts) {
// ระยะตั้งฉากจาก p ไปยังเส้นตรง a→b
const dev = Math.abs((b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x)) / len;
if (dev > maxDev) return false;
}
return true;
}
/**
* SVG freehand (PLAN.md §6.2) + รูปทรง (PLAN-FEAT.md §3 F5) — ห้ามใส่ viewBox
* (ความหนาเส้นจะบิดเบี้ยวเมื่อ scale แกน x/y ไม่เท่ากัน) render ระบบ px จริง
* แปลง % → px ด้วยขนาดที่ ResizeObserver วัด
*/
export default function InkLayer({
size,
strokes,
shapes,
redactions,
active,
nextStroke,
shapeTool,
eraseMode,
redactMode,
ownUserId,
onStrokeEnd,
onShapeEnd,
onRedactionEnd,
onErase,
}: {
/** ขนาด px ตามคำนวณ (ใช้เป็นค่าเริ่มต้นก่อน ResizeObserver วัดได้) */
size: Size;
/** เส้นเดิมจาก DB (พิกัด %) */
strokes: InkStroke[];
/** รูปทรงเดิมจาก DB (พิกัด %) */
shapes: InkShape[];
/** ปิดพรางเดิมจาก DB (พิกัด %) — วาดทับบังข้อความจริงบนจอ (F8) */
redactions: InkRedaction[];
/** tool วาด/รูปทรง/eraser/redact → รับ pointer events */
active: boolean;
/** ค่าเริ่มต้นของเส้นใหม่ที่กำลังวาด (tool draw) — opacity 0–1 ใช้ตอน render */
nextStroke: { color: string; strokeWidthPct: number; opacity?: number };
/** tool shape → ชนิดรูปทรงที่จะวาด (null = โหมดวาดเส้น) */
shapeTool: { kind: ShapeKind; color: string; strokeWidthPct: number } | null;
/** tool erase → ลากผ่านเส้น/รูปทรงของตัวเองเพื่อลบ (F6/B3) */
eraseMode: boolean;
/** tool redact → ลากคลุมพื้นที่เพื่อปิดพราง (F8/B4) */
redactMode: boolean;
/** userId ของผู้ใช้ปัจจุบัน — hit-test เฉพาะรายการของตัวเอง */
ownUserId: string;
/** pointerup แล้วจุด ≥ 2 → ส่งจุด (% ของหน้า) + widths (%, เฉพาะปากกา) ให้ parent บันทึก */
onStrokeEnd?: (pointsPct: Point[], widthsPct?: number[]) => void;
/** pointerup แล้วรูปทรงมีขนาดพอ → ส่ง ShapeData (% ของหน้า) ให้ parent บันทึก */
onShapeEnd?: (data: ShapeData) => void;
/** pointerup แล้วพื้นที่พอ → ส่ง RedactionData (% ของหน้า) ให้ parent บันทึก */
onRedactionEnd?: (data: RedactionData) => void;
/** hit-test โดนรายการของตัวเอง → parent เรียก deleteAnnotation ทันที */
onErase?: (id: string) => void;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const [measured, setMeasured] = useState<Size>(size);
const [livePoints, setLivePoints] = useState<Point[]>([]);
const [liveShape, setLiveShape] = useState<LiveShape | null>(null);
// Shift ค้างระหว่างวาด → เส้นตรงจากจุดเริ่มถึงตำแหน่งปัจจุบัน (กด/ปล่อยกลางทางได้)
const [shiftDown, setShiftDown] = useState(false);
const drawingRef = useRef(false);
// source of truth ของจุดที่กำลังวาด — อ่านตอน pointerup โดยไม่ต้องแอบ side effect ใน updater
const pointsRef = useRef<Point[]>([]);
// F7/E1: ตัวคูณความหนาต่อจุด (จาก pressure) — เก็บเฉพาะเส้นที่วาดด้วยปากกา
const factorsRef = useRef<number[] | null>(null);
const [liveFactors, setLiveFactors] = useState<number[] | null>(null);
// F7/E1 palm rejection: เคยเห็นสไตลัสในหน้านี้ → เลิกสนใจนิ้ว (กันฝ่ามือแตะ)
const sawPenRef = useRef(false);
// เช่นเดียวกับรูปทรงที่กำลังลาก (อ่านตอน pointerup จาก ref กันค้างหลัง move สุดท้าย)
const shapeRef = useRef<LiveShape | null>(null);
// FIX-8: rAF ของ live render ระหว่างวาด — ค้าง = ยังไม่ต้องตั้งเพิ่ม
const liveRafRef = useRef<number | null>(null);
// โหมด eraser กำลังกดค้าง (F6)
const erasingRef = useRef(false);
// พื้นที่ปิดพรางที่กำลังลาก (F8) — เก็บ bbox ตรง ๆ
const redactRef = useRef<{ start: Point; end: Point } | null>(null);
const [liveRedaction, setLiveRedaction] = useState<Rect | null>(null);
// re-render เมื่อ resize/zoom (rules/coordinates.md)
useEffect(() => {
const el = rootRef.current;
if (!el) return;
const ro = new ResizeObserver(() => {
setMeasured({ width: el.clientWidth, height: el.clientHeight });
});
ro.observe(el);
return () => ro.disconnect();
}, []);
// FIX-10: mirror prop size ทันทีเมื่อ zoom — ไม่งั้นมี 1 render
// หลัง zoom ที่เส้นทั้งหน้าวาดด้วย scale เก่าก่อน ResizeObserver ยิงมายืนยันค่าจริง
// เทียบค่าก่อน set — prop เป็น inline object ใหม่ทุก parent render แต่ค่าเท่าเดิม
// ต้องไม่นับเป็นการเปลี่ยน state (กัน render ซ้ำเปลือง ๆ)
useEffect(() => {
setMeasured((prev) =>
prev.width === size.width && prev.height === size.height ? prev : size,
);
}, [size]);
// FIX-8: เลิกวาดกลางคันแล้ว unmount — ยกเลิก rAF ที่ค้างอยู่ด้วย
useEffect(
() => () => {
if (liveRafRef.current !== null) cancelAnimationFrame(liveRafRef.current);
},
[],
);
// ติดตาม Shift ระดับ window — กด/ปล่อยกลางทางระหว่างลากก็ต้อง snap เป็นเส้นตรง/คลายกลับทันที
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "Shift") setShiftDown(true);
};
const up = (e: KeyboardEvent) => {
if (e.key === "Shift") setShiftDown(false);
};
const clear = () => setShiftDown(false); // window blur ระหว่างกดค้าง
window.addEventListener("keydown", down);
window.addEventListener("keyup", up);
window.addEventListener("blur", clear);
return () => {
window.removeEventListener("keydown", down);
window.removeEventListener("keyup", up);
window.removeEventListener("blur", clear);
};
}, []);
// clamp ให้อยู่ในหน้าเสมอ — pointer capture ส่งพิกัดนอก page ได้ และ % ติดลบ
// จะโดน Server Action ปฏิเสธทั้งเส้น (rules/coordinates.md: clamp 0–100)
const toLocalPx = (e: { clientX: number; clientY: number }): Point => {
// FIX-9: เอา non-null ! ออก — handler ยิงได้แค่ตอน DOM mount แล้ว
// ref จึงไม่ควรเป็น null (คืน 0,0 เผื่อไว้เฉย ๆ ให้ type ชัด)
const el = rootRef.current;
if (!el) return { x: 0, y: 0 };
const r = el.getBoundingClientRect();
return {
x: clamp(e.clientX - r.left, 0, r.width),
y: clamp(e.clientY - r.top, 0, r.height),
};
};
/** normalize จุดเริ่ม/จุดปลายของการลากเป็น bbox (px) — ใช้กับ redaction (F8) */
const bboxFromDrag = (d: { start: Point; end: Point }): Rect => ({
x: Math.min(d.start.x, d.end.x),
y: Math.min(d.start.y, d.end.y),
width: Math.abs(d.end.x - d.start.x),
height: Math.abs(d.end.y - d.start.y),
});
function handlePointerDown(e: ReactPointerEvent<HTMLDivElement>) {
if (!active || e.button !== 0) return;
// F7/E1 palm rejection: เคยเห็นปากกาในหน้านี้ → ละเว้นนิ้ว (แตะ/ฝ่ามือ)
if (e.pointerType === "pen") {
sawPenRef.current = true;
} else if (e.pointerType === "touch" && sawPenRef.current) {
return;
}
// pointer capture → เส้นไม่ขาดเวลาเลื่อนออกนอก page (PLAN.md §6.2)
e.currentTarget.setPointerCapture(e.pointerId);
if (eraseMode) {
erasingRef.current = true;
eraseAt(e);
return;
}
if (redactMode) {
const p = toLocalPx(e);
redactRef.current = { start: p, end: p };
setLiveRedaction({ x: p.x, y: p.y, width: 0, height: 0 });
return;
}
drawingRef.current = true;
const p = toLocalPx(e);
if (shapeTool) {
shapeRef.current = { kind: shapeTool.kind, start: p, end: p };
setLiveShape(shapeRef.current);
} else {
// FIX-8: state ต้องไม่แชร์ reference กับ ref (ref ถูก mutate push ระหว่างวาด)
pointsRef.current = [p];
setLivePoints([p]);
// ความหนาต่อจุดเก็บเฉพาะปากกา (mouse/touch ไม่มี pressure จริง → สเปกเดิม)
if (e.pointerType === "pen") {
factorsRef.current = [pressureFactor(e.pressure)];
} else {
factorsRef.current = null;
}
setLiveFactors(factorsRef.current ? [...factorsRef.current] : null);
}
}
function handlePointerMove(e: ReactPointerEvent<HTMLDivElement>) {
if (erasingRef.current) {
eraseAt(e);
return;
}
if (redactRef.current) {
redactRef.current = { ...redactRef.current, end: toLocalPx(e) };
setLiveRedaction(bboxFromDrag(redactRef.current));
return;
}
if (!drawingRef.current) return;
const p = toLocalPx(e);
if (shapeTool) {
shapeRef.current = shapeRef.current
? { ...shapeRef.current, end: p }
: null;
setLiveShape(shapeRef.current);
} else {
// FIX-8: push จุดลง ref ทันที (ข้อมูลที่บันทึกครบทุกจุดเหมือนเดิม) แต่ยิง
// re-render ครั้งเดียวต่อ frame ผ่าน rAF — กัน copy array + re-render SVG
// ทั้งเส้นใหม่ทุก pointermove (= O(n²) เมื่อเส้นยาวหลายพันจุด)
pointsRef.current.push(p);
if (factorsRef.current) {
factorsRef.current.push(pressureFactor(e.pressure));
}
if (liveRafRef.current === null) {
liveRafRef.current = requestAnimationFrame(() => {
liveRafRef.current = null;
setLivePoints([...pointsRef.current]);
setLiveFactors(factorsRef.current ? [...factorsRef.current] : null);
});
}
}
}
/** hit-test ตำแหน่ง pointer กับเส้น/รูปทรง **ของตัวเอง** — โดนเมื่อไรลบทันทีทีละรายการ (F6) */
function eraseAt(e: ReactPointerEvent<HTMLDivElement>) {
if (!onErase || ownUserId === "") return;
const p = pointToPercent(toLocalPx(e), measured);
const pad = 6; // เผื่อความคาดหวังของผู้ใช้นอกเส้นจริง (px)
for (const s of strokes) {
if (s.userId !== ownUserId) continue;
const threshold = toPx(s.strokeWidthPct, measured.width) / 2 + pad;
if (hitTestPath(s.points, p, threshold, measured)) {
onErase(s.id);
return;
}
}
for (const s of shapes) {
if (s.userId !== ownUserId) continue;
const threshold = toPx(s.data.strokeWidth, measured.width) / 2 + pad;
if (hitTestShape(s.data, p, threshold, measured)) {
onErase(s.id);
return;
}
}
}
function endDrag() {
if (erasingRef.current) {
erasingRef.current = false;
return;
}
if (redactRef.current) {
const bbox = bboxFromDrag(redactRef.current);
redactRef.current = null;
setLiveRedaction(null);
// เล็กเกิน (คลิกเฉย ๆ) ไม่สร้าง
if (bbox.width >= 2 && bbox.height >= 2) {
onRedactionEnd?.({ rect: rectToPercent(bbox, measured) });
}
return;
}
if (!drawingRef.current) return;
drawingRef.current = false;
if (shapeTool) {
const st = shapeRef.current;
shapeRef.current = null;
setLiveShape(null);
if (st) endShape(st);
} else {
// FIX-8: ยกเลิก rAF ที่ยังค้างอยู่ก่อนเคลียร์ live state — ไม่งั้น callback
// ยิงหลังจากนี้แล้วเส้นที่วาดจบโผล่กลับมาอีกครั้งบนหน้าจอ
if (liveRafRef.current !== null) {
cancelAnimationFrame(liveRafRef.current);
liveRafRef.current = null;
}
const points = pointsRef.current;
const factors = factorsRef.current;
pointsRef.current = [];
factorsRef.current = null;
setLivePoints([]);
setLiveFactors(null);
if (points.length >= 2) {
// ปล่อยแล้ว snap เป็นเส้นตรง: Shift ค้าง = บังคับตรงเสมอ หรือวาดตรง ๆ ด้วยมือ
// (isNearlyStraight) → เหลือจุดเริ่ม→จุดสุดท้าย 2 จุด (ความหนา uniform)
const snapped = straight || isNearlyStraight(points);
const finalPoints = snapped ? [points[0], points[points.length - 1]] : points;
const widthsPct =
factors && !snapped
? factors.map((f) => nextStroke.strokeWidthPct * f)
: undefined;
onStrokeEnd?.(pathToPercent(finalPoints, measured), widthsPct);
}
}
}
/** คำนวณ ShapeData (%) จากรูปทรงที่ลากจบ — เล็กเกินไป (คลิกเฉย ๆ) ไม่สร้าง */
function endShape(st: LiveShape) {
const tool = shapeTool;
if (!tool) return;
const { start, end } = st;
if (st.kind === "rect" || st.kind === "ellipse") {
const w = Math.abs(end.x - start.x);
const h = Math.abs(end.y - start.y);
if (w < 2 || h < 2) return;
const rect = rectToPercent(
{
x: Math.min(start.x, end.x),
y: Math.min(start.y, end.y),
width: w,
height: h,
},
measured,
);
onShapeEnd?.({
shape: st.kind,
rect,
strokeColor: tool.color,
strokeWidth: tool.strokeWidthPct,
});
} else {
if (Math.hypot(end.x - start.x, end.y - start.y) < 2) return;
onShapeEnd?.({
shape: st.kind,
from: pointToPercent(start, measured),
to: pointToPercent(end, measured),
strokeColor: tool.color,
strokeWidth: tool.strokeWidthPct,
});
}
}
const toPathD = (points: Point[]): string =>
points
.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(2)} ${p.y.toFixed(2)}`)
.join(" ");
/** หัวลูกศร — path สามเหลี่ยมคำนวณจาก from→to (F5 สเปก render) */
const arrowHeadPath = (from: Point, to: Point, sw: number): string => {
const head = Math.max(sw * 3.5, 8);
const ang = Math.atan2(to.y - from.y, to.x - from.x);
const spread = Math.PI / 7;
const p1 = {
x: to.x - head * Math.cos(ang - spread),
y: to.y - head * Math.sin(ang - spread),
};
const p2 = {
x: to.x - head * Math.cos(ang + spread),
y: to.y - head * Math.sin(ang + spread),
};
return `M${to.x} ${to.y} L${p1.x} ${p1.y} L${p2.x} ${p2.y} Z`;
};
const strokeProps = (color: string, sw: number, opacity?: number) => ({
stroke: color,
strokeWidth: sw,
// opacity < 1 = เส้นโปร่งแสงให้เห็นข้อความใต้เส้น (ค่ารวม fallback ตาม pen มาจาก parent แล้ว)
strokeOpacity: opacity ?? 1,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
fill: "none",
});
/** F7/E1: มี widths ต่อจุด → render เป็น segment ทีละท่อน (ความหนาต่างกัน), ไม่มี → path เดียวเหมือนเดิม */
function renderStrokeEl(
key: string,
color: string,
baseSwPct: number,
ptsPx: Point[],
widthsPct?: number[],
opacity?: number,
) {
if (widthsPct && widthsPct.length === ptsPx.length && ptsPx.length >= 2) {
return (
<g key={key}>
{ptsPx.slice(0, -1).map((p, i) => {
const q = ptsPx[i + 1];
const w =
(toPx(widthsPct[i], measured.width) +
toPx(widthsPct[i + 1], measured.width)) /
2;
return (
<line key={i} x1={p.x} y1={p.y} x2={q.x} y2={q.y} {...strokeProps(color, w, opacity)} />
);
})}
</g>
);
}
return (
<path
key={key}
d={toPathD(ptsPx)}
{...strokeProps(color, toPx(baseSwPct, measured.width), opacity)}
/>
);
}
/** render รูปทรงหนึ่งชิ้นจาก ShapeData (%) — ใช้ได้ทั้งของเดิมและ live preview */
function renderShapeEl(d: ShapeData, key?: string) {
const sw = toPx(d.strokeWidth, measured.width);
if ("rect" in d) {
// rect/ellipse เก็บเป็น bbox ใน data.rect
const r = rectToPx(d.rect, measured);
if (d.shape === "ellipse") {
return (
<ellipse
key={key}
cx={r.x + r.width / 2}
cy={r.y + r.height / 2}
rx={r.width / 2}
ry={r.height / 2}
{...strokeProps(d.strokeColor, sw)}
/>
);
}
return <rect key={key} x={r.x} y={r.y} width={r.width} height={r.height} {...strokeProps(d.strokeColor, sw)} />;
}
// line/arrow — จุดเริ่ม/จุดปลาย (D3)
const a = pointToPx(d.from, measured);
const b = pointToPx(d.to, measured);
const line = <line x1={a.x} y1={a.y} x2={b.x} y2={b.y} {...strokeProps(d.strokeColor, sw)} />;
if (d.shape === "line") return <g key={key}>{line}</g>;
return (
<g key={key}>
{line}
<path d={arrowHeadPath(a, b, sw)} fill={d.strokeColor} />
</g>
);
}
// เส้นตรงเมื่อ Shift ค้าง — เฉพาะโหมดวาดอิสระ (ไม่ใช่รูปทรง/eraser/redact)
const straight = shiftDown && !shapeTool && !eraseMode && !redactMode;
return (
<div
ref={rootRef}
className="absolute inset-0 z-[3] touch-none"
style={{ pointerEvents: active ? "auto" : "none" }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
{/* SVG แบบไม่มี viewBox — วาดในระบบ px จริงเท่าขนาดที่วัดได้ */}
<svg width={measured.width} height={measured.height} className="block">
{strokes.map((s) =>
renderStrokeEl(
s.id,
s.color,
s.strokeWidthPct,
s.points.map((p) => pointToPx(p, measured)),
s.widthsPct,
s.opacity,
),
)}
{shapes.map((s) => renderShapeEl(s.data, s.id))}
{/* REDACTION — ดำทึบ บังข้อความจริงบนจอ (F8/B4) */}
{redactions.map((rd) => {
const r = rectToPx(rd.data.rect, measured);
return (
<rect key={rd.id} x={r.x} y={r.y} width={r.width} height={r.height} fill="#000" />
);
})}
{liveRedaction && (
<rect
x={liveRedaction.x}
y={liveRedaction.y}
width={liveRedaction.width}
height={liveRedaction.height}
fill="#000"
/>
)}
{livePoints.length >= 2 &&
renderStrokeEl(
"live-stroke",
nextStroke.color,
nextStroke.strokeWidthPct,
straight
? [livePoints[0], livePoints[livePoints.length - 1]]
: livePoints,
liveFactors && !straight
? liveFactors.map((f) => nextStroke.strokeWidthPct * f)
: undefined,
nextStroke.opacity,
)}
{liveShape && shapeTool &&
renderShapeEl(
liveShape.kind === "rect" || liveShape.kind === "ellipse"
? {
shape: liveShape.kind,
rect: rectToPercent(
{
x: Math.min(liveShape.start.x, liveShape.end.x),
y: Math.min(liveShape.start.y, liveShape.end.y),
width: Math.abs(liveShape.end.x - liveShape.start.x),
height: Math.abs(liveShape.end.y - liveShape.start.y),
},
measured,
),
strokeColor: shapeTool.color,
strokeWidth: shapeTool.strokeWidthPct,
}
: {
shape: liveShape.kind,
from: pointToPercent(liveShape.start, measured),
to: pointToPercent(liveShape.end, measured),
strokeColor: shapeTool.color,
strokeWidth: shapeTool.strokeWidthPct,
},
)}
</svg>
</div>
);
}

2918
components/pdf/PDFViewer.tsx Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,329 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import type { StampData, TextboxData, TextboxSegment } from "@/db/schema";
import { toPx, type Point, type Size } from "@/utils/coordinates";
import { STAMP_HEIGHT_RATIO, TEXTBOX_MAX_WIDTH_PCT } from "@/utils/geometry";
import { STAMP_COLOR, STAMP_FONT_RATIO } from "@/utils/annotation-display";
// TEXTBOX + STAMP overlay (F17/B2, F18/B5) — HTML overlay (z-4 กลุ่มเดียวกับ comment pins)
// - container `pointer-events: none` เสมอ เฉพาะรายการของตัวเองที่ `auto`
// (เหมือน comment pins — ไม่บังการ select ข้อความของ textLayer เมื่อปิด tool)
// - คลิก = เลือกรายการ (กรอบ selection ของ F1), double-click TEXTBOX ของตัวเอง
// = แก้ไขข้อความผ่าน textarea วางแทนที่ตำแหน่งเดิม (บันทึกผ่าน updateAnnotation — pattern F13)
// - composer = textarea ระหว่างสร้างกล่องข้อความใหม่ วางที่ตำแหน่งที่คลิกบนหน้า
/** กล่องข้อความจาก DB — พิกัด % ทั้งหมด */
export type InkTextbox = { id: string; userId: string; data: TextboxData };
/** ตรายางจาก DB (F18/B5) — พิกัด % ทั้งหมด */
export type InkStamp = { id: string; userId: string; data: StampData };
/** แปลงข้อความ/segments → HTML สำหรับ contentEditable (escape + <b> + <br>) */
function segmentsToHtml(text: string, segments?: TextboxSegment[]): string {
const esc = (s: string) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const runs = segments?.length ? segments : [{ text }];
return runs
.map((s) => (s.bold ? `<b>${esc(s.text)}</b>` : esc(s.text)))
.join("")
.replace(/\n/g, "<br>");
}
/** อ่าน DOM ของ contentEditable กลับเป็น text รวม + segments แยกตามตัวหนา/ปกติ */
function serializeEditor(root: HTMLElement): { text: string; segments: TextboxSegment[] } {
const segs: TextboxSegment[] = [];
const push = (text: string, bold: boolean) => {
if (!text) return;
const last = segs[segs.length - 1];
if (last && !!last.bold === bold) last.text += text;
else segs.push({ text, ...(bold ? { bold: true } : {}) });
};
const walk = (node: Node, bold: boolean) => {
if (node.nodeType === Node.TEXT_NODE) {
push(node.nodeValue ?? "", bold);
} else if (node.nodeName === "BR") {
push("\n", bold);
} else {
const b = bold || node.nodeName === "B" || node.nodeName === "STRONG";
const kids = Array.from(node.childNodes);
kids.forEach((c, i) => {
walk(c, b);
// DIV/P sibling จากการกด Enter = คั่นขึ้นบรรทัดใหม่
if (i < kids.length - 1 && /^(DIV|P)$/.test(c.nodeName)) push("\n", b);
});
}
};
walk(root, false);
return { text: segs.map((s) => s.text).join(""), segments: segs };
}
/** กล่องแก้ไข/สร้างข้อความแบบ inline rich text — เลือกข้อความแล้วกด Ctrl/Cmd+B
* ทำตัวหนาเฉพาะส่วนที่เลือกได้ (สไตล์ทั้งกล่องจากตัวตั้งค่าของ tool/ของกล่อง) */
function TextboxEditor({
size,
position,
fontSizePct,
color,
bold,
opacity,
align,
enabled,
initialText,
initialSegments,
onSave,
onCancel,
}: {
size: Size;
position: Point;
fontSizePct: number;
/** สีตัวอักษร (จากตัวตั้งค่าของ tool / สีของกล่องที่แก้) */
color: string;
bold?: boolean;
opacity?: number;
align?: "left" | "center" | "right";
/** container ของ layer เป็น pointer-events:none — editor ต้องเปิด auto เองเมื่อใช้งานได้ */
enabled: boolean;
initialText: string;
initialSegments?: TextboxSegment[];
onSave: (text: string, segments: TextboxSegment[]) => void;
onCancel: () => void;
}) {
const ref = useRef<HTMLDivElement | null>(null);
// initial HTML คำนวณ/คืน object เดิมจนกว่า annotation จะเปลี่ยน — editor เป็น
// uncontrolled (พิมพ์ใน DOM ตรง ๆ) React จึงไม่ยุ่งกับ innerHTML ระหว่างพิมพ์
const initialHtml = useMemo(
() => ({ __html: segmentsToHtml(initialText, initialSegments) }),
[initialText, initialSegments],
);
// focus + วาง caret ท้ายข้อความตอนเปิด editor
useEffect(() => {
const el = ref.current;
if (!el) return;
el.focus();
const sel = window.getSelection();
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
sel?.removeAllRanges();
sel?.addRange(range);
}, []);
/** คลิกที่อื่น/สลับ tool (blur) = บันทึกอัตโนมัติ — ข้อความว่าง = ปิดอย่างเดียวไม่สร้าง */
const submit = () => {
const el = ref.current;
if (!el) return;
const { text, segments } = serializeEditor(el);
if (text.trim()) onSave(text, segments);
else onCancel();
};
return (
<div
className="absolute z-10"
style={{
left: toPx(position.x, size.width),
top: toPx(position.y, size.height),
// กล่องพอดีข้อความ (shrink-to-fit ของ absolute) — เริ่มเล็กตอนกดคลิกครั้งแรก
// แล้วขยายตามที่พิมพ์ ตัดบรรทัดเมื่อยาวเกิน 50% ของความกว้างหน้าเหมือนที่ render จริง
maxWidth: `${TEXTBOX_MAX_WIDTH_PCT}%`,
minWidth: "1em",
opacity: opacity ?? 1,
pointerEvents: enabled ? "auto" : "none",
}}
onClick={(e) => e.stopPropagation()}
>
<div
ref={ref}
role="textbox"
aria-multiline="true"
aria-label="แก้ไขข้อความ"
contentEditable
suppressContentEditableWarning
spellCheck={false}
onBlur={submit}
onKeyDown={(e) => {
e.stopPropagation(); // ไม่ให้ปุ่มในกล่องข้อความชนคีย์ลัดของ viewer
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
// เลือกข้อความแล้วกด Ctrl/Cmd+B = ทำตัวหนาเฉพาะส่วนที่เลือก
// FIX-12: document.execCommand ถูก mark deprecated แต่
// ยังทำงานทุก browser ปัจจุบัน และไม่มี replacement ตรง ๆ ของ contentEditable
// (bold/insertText ตรงนี้และใน onPaste ด้านล่าง) — ใช้ต่อโดยรับความเสี่ยงไว้
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "b") {
e.preventDefault();
document.execCommand("bold");
}
}}
onPaste={(e) => {
// วางเป็น plain text กัน HTML แปลกปลอมเข้า contentEditable
e.preventDefault();
document.execCommand(
"insertText",
false,
e.clipboardData.getData("text/plain"),
);
}}
className="min-h-[1.5em] w-full whitespace-pre-wrap break-words rounded border border-dashed border-slate-500/70 bg-transparent p-0 caret-current focus:outline-none"
style={{
fontSize: toPx(fontSizePct, size.width),
lineHeight: 1.5,
color,
fontWeight: bold ? 700 : 400,
textAlign: align ?? "left",
}}
dangerouslySetInnerHTML={initialHtml}
/>
</div>
);
}
export default function TextboxStampLayer({
size,
textboxes,
stamps,
enabled,
ownUserId,
editingId,
onStartEdit,
onCommitEdit,
composer,
onCommitComposer,
onCancelComposer,
onSelect,
}: {
/** ขนาด px ของหน้า (ตาม zoom ปัจจุบัน) */
size: Size;
textboxes: InkTextbox[];
/** ตรายาง (F18/B5) */
stamps: InkStamp[];
/** tool ที่ไม่ใช้ pointer บน layer นี้ → ปิด pointer-events ของทุกรายการ */
enabled: boolean;
ownUserId: string;
/** TEXTBOX ที่กำลังแก้ไขข้อความอยู่ (null = ไม่มี) */
editingId: string | null;
onStartEdit: (id: string | null) => void;
onCommitEdit: (id: string, text: string, segments?: TextboxSegment[]) => void;
/** ตำแหน่งที่กำลังสร้างกล่องใหม่ (null = ไม่มี — parent ส่งเฉพาะหน้าที่คลิก) */
composer: {
position: Point;
fontSizePct: number;
color: string;
bold?: boolean;
opacity?: number;
align?: "left" | "center" | "right";
} | null;
onCommitComposer: (text: string, segments?: TextboxSegment[]) => void;
onCancelComposer: () => void;
/** คลิกรายการ → parent set selectedId (F1) */
onSelect: (id: string) => void;
}) {
return (
<div className="pointer-events-none absolute inset-0 z-[4]">
{textboxes.map((t) =>
editingId === t.id ? (
<TextboxEditor
key={t.id}
size={size}
position={t.data.position}
fontSizePct={t.data.fontSize}
color={t.data.color}
bold={t.data.bold}
opacity={t.data.opacity}
align={t.data.align}
enabled={enabled}
initialText={t.data.text}
initialSegments={t.data.segments}
onSave={(text, segments) => onCommitEdit(t.id, text, segments)}
onCancel={() => onStartEdit(null)}
/>
) : (
<div
key={t.id}
title={t.userId === ownUserId ? "ดับเบิลคลิกเพื่อแก้ไขข้อความ" : undefined}
className="absolute whitespace-pre-wrap break-words"
style={{
left: toPx(t.data.position.x, size.width),
top: toPx(t.data.position.y, size.height),
maxWidth: `${TEXTBOX_MAX_WIDTH_PCT}%`,
fontSize: toPx(t.data.fontSize, size.width),
lineHeight: 1.5,
color: t.data.color,
fontWeight: t.data.bold ? 700 : 400,
opacity: t.data.opacity ?? 1,
textAlign: t.data.align ?? "left",
pointerEvents: enabled ? "auto" : "none",
cursor: enabled ? "text" : "default",
}}
onClick={(e) => {
e.stopPropagation();
if (enabled) onSelect(t.id);
}}
onDoubleClick={(e) => {
e.stopPropagation();
if (enabled && t.userId === ownUserId) onStartEdit(t.id);
}}
>
{t.data.segments?.length
? t.data.segments.map((s, i) => (
<span key={i} style={s.bold ? { fontWeight: 700 } : undefined}>
{s.text}
</span>
))
: t.data.text}
</div>
),
)}
{composer && (
<TextboxEditor
size={size}
position={composer.position}
fontSizePct={composer.fontSizePct}
color={composer.color}
bold={composer.bold}
opacity={composer.opacity}
align={composer.align}
enabled={enabled}
initialText=""
onSave={onCommitComposer}
onCancel={onCancelComposer}
/>
)}
{/* STAMP (F18/B5) — กรอบข้อความสไตล์ตรายาง: กว้าง = scale% ของหน้า สูง = 0.4 เท่า (§5) */}
{stamps.map((s) => {
const w = toPx(s.data.scale, size.width);
const c = s.data.color ?? STAMP_COLOR; // แถวเดิมไม่มี color = สีตรายาง default
return (
<div
key={s.id}
className="absolute flex items-center justify-center overflow-hidden text-center font-bold"
style={{
left: toPx(s.data.position.x, size.width),
top: toPx(s.data.position.y, size.height),
width: w,
height: w * STAMP_HEIGHT_RATIO,
border: `${Math.max(w * 0.02, 1)}px solid ${c}`,
borderRadius: Math.max(w * 0.04, 2),
color: c,
fontSize: w * STAMP_FONT_RATIO,
lineHeight: 1.2,
pointerEvents: enabled ? "auto" : "none",
cursor: enabled ? "pointer" : "default",
}}
onClick={(e) => {
e.stopPropagation();
if (enabled) onSelect(s.id);
}}
>
{s.data.label}
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,250 @@
"use client";
import { useEffect, useRef, useState, type ComponentType, type ReactNode } from "react";
import { Check, ChevronDown, ChevronUp, Highlighter, Strikethrough, Underline } from "lucide-react";
import type { HighlightStyle } from "@/db/schema";
// ตัวควบคุมที่ใช้ร่วมระหว่าง dropdown บนปุ่ม tool (PDFViewer) กับแผงเครื่องมือ
// บนกรอบ selection (AnnotationSelectionLayer) — หน้าตา/พฤติกรรมเดียวกันทั้งสองที่
/** สี preset ให้เลือก (ทุก tool ที่ปรับสีได้: draw/shape/highlight/textbox/stamp)
* สีอื่นนอกรายการเลือกได้ผ่านปุ่มสีรุ้ง (input type="color") ท้ายแถว */
export const INK_COLORS = [
"#ef4444", // แดง (default ปากกา/รูปทรง)
"#f97316", // ส้ม
"#fde047", // เหลือง (default ไฮไลต์)
"#22c55e", // เขียว
"#14b8a6", // เขียวอมฟ้า
"#3b82f6", // น้ำเงิน
"#8b5cf6", // ม่วง
"#ec4899", // ชมพู
"#111827", // ดำ (default textbox)
"#6b7280", // เทา
];
/** แถวตั้งค่าในเมนู/แผงเครื่องมือ — label ซ้าย + ตัวควบคุมขวา */
export function PanelRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex items-start gap-2 px-1 py-1">
<span className="w-11 shrink-0 pt-1 text-[10px] font-medium text-slate-400">{label}</span>
{children}
</div>
);
}
/** แถวปุ่มสีวงกลม preset + ปุ่มสีรุ้ง (color picker เลือกสีอื่นได้ไม่จำกัด) */
export function ColorSwatches({ value, onChange }: { value: string; onChange: (c: string) => void }) {
return (
<div className="flex flex-1 flex-wrap items-center gap-1.5">
{INK_COLORS.map((c) => (
<button
key={c}
type="button"
aria-label={`เลือกสี ${c}`}
title={c}
className={`h-4 w-4 rounded-full border border-slate-300 transition ${
value === c ? "ring-2 ring-primary ring-offset-1" : "hover:scale-110"
}`}
style={{ backgroundColor: c }}
onClick={() => onChange(c)}
/>
))}
<label
title="เลือกสีอื่น"
className={`relative h-4 w-4 cursor-pointer rounded-full border border-slate-300 transition ${
INK_COLORS.includes(value) ? "hover:scale-110" : "ring-2 ring-primary ring-offset-1"
}`}
style={{
background:
"conic-gradient(#ef4444, #f97316, #fde047, #22c55e, #14b8a6, #3b82f6, #8b5cf6, #ec4899, #ef4444)",
}}
>
<input
type="color"
aria-label="เลือกสีอื่น"
value={value}
onChange={(e) => onChange(e.target.value)}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
/>
</label>
</div>
);
}
/** ช่องตัวเลข combobox — กดที่ input เปิดรายการค่า preset ให้เลือก (หรือพิมพ์เองได้)
* + ปุ่ม ▲▼ ปรับค่าทีละ stepBy (clamp ใน min–max) */
export function NumberCombo({
value,
options,
min,
max,
step,
stepBy,
suffix,
onChange,
}: {
value: number;
options: number[];
min: number;
max: number;
step: number;
/** ละขั้นของปุ่ม ▲▼ (default 1 — เช่น fontSize ใช้ 0.1) */
stepBy?: number;
/** ต่อท้ายค่าในแถวรายการ (เช่น "%") */
suffix?: string;
onChange: (v: number) => void;
}) {
const [open, setOpen] = useState(false);
// FIX-3: ถือ draft ระหว่างพิมพ์ — commit ครั้งเดียวตอน blur/Enter
// ไม่ยิง server action + version snapshot ต่อ keystroke; sync กลับเมื่อค่าจากภายนอกเปลี่ยน
const [draft, setDraft] = useState(String(value));
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => setDraft(String(value)), [value]);
useEffect(() => {
if (!open) return;
const onMouseDown = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onMouseDown);
return () => document.removeEventListener("mousedown", onMouseDown);
}, [open]);
/** ยืนยันค่าที่พิมพ์หนึ่งครั้ง — clamp เข้า min–max; พิมพ์ไม่เป็นตัวเลข = คืนค่าเดิม
* ค่าเท่าเดิมไม่ยิง onChange — focus/blur เฉย ๆ ต้องไม่เกิด server action + snapshot ขยะ */
function commitDraft() {
const v = Number(draft);
if (draft.trim() !== "" && Number.isFinite(v)) {
const clamped = Math.min(max, Math.max(min, Number(v.toFixed(2))));
setDraft(String(clamped));
if (clamped !== value) onChange(clamped);
} else {
setDraft(String(value));
}
}
/** ▲▼ ปรับค่าทีละ stepBy จาก draft ล่าสุด (ค่าที่พิมพ์เองก็ต่อจากนั้นได้) — clamp ใน min–max
* ชนขอบแล้วค่าไม่เดิน (next === value) ไม่ยิง onChange เช่นเดียวกับ commitDraft */
function stepValue(dir: 1 | -1) {
const by = stepBy ?? 1;
const base = draft.trim() !== "" && Number.isFinite(Number(draft)) ? Number(draft) : value;
const next = Math.min(max, Math.max(min, Number((base + dir * by).toFixed(2))));
setDraft(String(next));
if (next !== value) onChange(next);
}
return (
<div
ref={rootRef}
className="relative"
onKeyDown={(e) => {
// Esc ปิดแค่ dropdown นี้ก่อน (stopPropagation ไม่ให้ปิดทั้งเมนู/กรอบ)
if (e.key === "Escape" && open) {
e.stopPropagation();
setOpen(false);
}
}}
>
<div className="flex items-stretch overflow-hidden rounded-md border border-slate-300 bg-white">
<input
type="number"
value={draft}
min={min}
max={max}
step={step}
onChange={(e) => setDraft(e.target.value)}
onBlur={commitDraft}
onFocus={(e) => {
setOpen(true); // กดที่ช่อง = เปิดรายการให้เลือกเลย (ไม่ต้องมีปุ่มเปิดแยก)
e.target.select(); // พิมพ์ทับค่าเดิมได้ทันที
}}
onKeyDown={(e) => {
if (e.key !== "Escape") e.stopPropagation(); // พิมพ์ในช่องต้องไม่ชนคีย์ลัด F3 (Esc ปล่อยไหลไปปิดเมนู)
if (e.key === "Enter") commitDraft(); // ยืนยันครั้งเดียวตรงนี้ ไม่ต้องรอ blur
}}
className="w-14 border-0 px-1.5 py-1 text-[11px] text-slate-700 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-slate-400 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
<div className="flex flex-col border-l border-slate-300">
<button
type="button"
aria-label="เพิ่มค่า"
onMouseDown={(e) => e.preventDefault()} // ไม่แย่ง focus จาก input (dropdown ไม่กระพริบ)
className="flex flex-1 items-center justify-center px-1.5 text-slate-500 hover:bg-slate-100"
onClick={() => stepValue(1)}
>
<ChevronUp className="h-2.5 w-2.5" />
</button>
<button
type="button"
aria-label="ลดค่า"
onMouseDown={(e) => e.preventDefault()}
className="flex flex-1 items-center justify-center border-t border-slate-300 px-1.5 text-slate-500 hover:bg-slate-100"
onClick={() => stepValue(-1)}
>
<ChevronDown className="h-2.5 w-2.5" />
</button>
</div>
</div>
{open && (
<div
role="listbox"
className="absolute right-0 top-full z-[60] mt-1 w-16 overflow-hidden rounded-md border border-slate-200 bg-white py-0.5 shadow-lg"
>
{options.map((opt) => (
<button
key={opt}
type="button"
role="option"
aria-selected={value === opt}
onMouseDown={(e) => e.preventDefault()} // ไม่แย่ง focus จาก input ก่อนคลิก
className={`flex w-full items-center justify-end gap-1 px-2 py-1 text-right text-[11px] font-medium transition ${
value === opt
? "bg-slate-100 text-slate-900"
: "text-slate-600 hover:bg-slate-100"
}`}
onClick={() => {
onChange(opt);
setOpen(false);
}}
>
{opt}
{suffix}
{value === opt && <Check className="h-3 w-3 shrink-0" />}
</button>
))}
</div>
)}
</div>
);
}
/** ไอคอนเส้นขยุกขยิก (squiggle) — วาดเองเพราะ lucide ไม่มี */
export function SquiggleIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
className={className}
aria-hidden="true"
>
<path d="M2 15 q2.5 -5 5 0 t5 0 t5 0 t5 0" />
</svg>
);
}
/** รายการรูปแบบการมาร์กข้อความ + ไอคอน — ใช้ใน dropdown ของ tool highlight และแผงของกรอบ selection */
export const HIGHLIGHT_STYLE_BTNS: {
key: HighlightStyle;
label: string;
Icon: ComponentType<{ className?: string }>;
}[] = [
{ key: "highlight", label: "ไฮไลต์", Icon: Highlighter },
{ key: "underline", label: "เส้นใต้", Icon: Underline },
{ key: "strikethrough", label: "ขีดฆ่า", Icon: Strikethrough },
{ key: "squiggle", label: "ขยุกขยิก", Icon: SquiggleIcon },
];

View file

@ -0,0 +1,831 @@
"use client";
import { useRef, useState, type FormEvent } from "react";
import { createPortal } from "react-dom";
import {
ArrowRight,
Bookmark,
Check,
ChevronDown,
ChevronRight,
Circle,
Clock,
Copy,
EyeOff,
Highlighter,
MessageSquare,
Minus,
Pencil,
Plus,
Search,
Square,
Stamp,
StickyNote,
Trash2,
Type,
Underline,
Strikethrough,
X,
} from "lucide-react";
import {
listVersions,
restoreVersion,
searchAnnotations,
} from "@/app/actions/annotations";
import type { AnnotationVersionDTO } from "@/db/schema";
import type {
AnnotationData,
AnnotationDTO,
BookmarkDTO,
CommentData,
DrawData,
HighlightData,
RedactionData,
ShapeData,
} from "@/db/schema";
import { annotationNote, annotationPreviewText } from "@/utils/annotation-display";
function TypeIcon({ type, data }: { type: AnnotationDTO["type"]; data: AnnotationDTO["data"] }) {
const cls = "h-4 w-4 shrink-0 text-slate-400";
if (type === "HIGHLIGHT") {
const style = (data as HighlightData).style ?? "highlight";
if (style === "underline") return <Underline className={cls} />;
if (style === "strikethrough") return <Strikethrough className={cls} />;
return <Highlighter className={cls} />; // highlight / squiggle
}
if (type === "DRAW")
return (data as DrawData).pen === "highlighter" ? (
<Highlighter className={cls} />
) : (
<Pencil className={cls} />
);
if (type === "REDACTION") return <EyeOff className={cls} />;
if (type === "TEXTBOX") return <Type className={cls} />;
if (type === "STAMP") return <Stamp className={cls} />;
if (type === "SHAPE") {
const shape = (data as ShapeData).shape;
if (shape === "rect") return <Square className={cls} />;
if (shape === "ellipse") return <Circle className={cls} />;
if (shape === "line") return <Minus className={cls} />;
return <ArrowRight className={cls} />;
}
return <MessageSquare className={cls} />;
}
/** ใส่/ล้างโน้ตคำอธิบายใน data ของ annotation (ทุกชนิดยกเว้น COMMENT — message ทำหน้าที่นี้แล้ว) */
function withNote(data: Exclude<AnnotationData, CommentData>, note: string): AnnotationData {
return note ? { ...data, note } : { ...data, note: undefined };
}
// ─── N2: helper โครงสร้างต้นไม้ bookmark (3 ระดับ) — คำนวณบน state ที่โหลดครบ ────
// (ปกติลึกสุด 3 — cap กันลูปไม่รู้จบหากข้อมูลวนซ้ำ)
/** ความลึกของ bookmark (root = 1) — เดินขึ้นตาม parentId (parent หาย = ถือเป็น root) */
function bookmarkDepth(b: BookmarkDTO, byId: Map<string, BookmarkDTO>): number {
let depth = 1;
let cur: BookmarkDTO | undefined = b;
while (cur?.parentId && depth <= 100) {
const parent = byId.get(cur.parentId);
if (!parent) break;
depth += 1;
cur = parent;
}
return depth;
}
/** ความสูงของ subtree รวมตัวเอง (leaf = 1) */
function subtreeHeight(b: BookmarkDTO, kidsOf: (id: string) => BookmarkDTO[], level = 1): number {
if (level > 100) return 1; // กันข้อมูลวนซ้ำ
const kids = kidsOf(b.id);
return kids.length === 0
? 1
: 1 + Math.max(...kids.map((k) => subtreeHeight(k, kidsOf, level + 1)));
}
/** id เป็นลูกหลานของ ancestorId หรือไม่ (เดินขึ้นจาก id) */
function isDescendantOf(id: string, ancestorId: string, byId: Map<string, BookmarkDTO>): boolean {
let cur = byId.get(id);
for (let steps = 0; cur?.parentId && steps <= 100; steps += 1) {
if (cur.parentId === ancestorId) return true;
cur = byId.get(cur.parentId);
}
return false;
}
/** แท็บ Bookmarks / Annotations + ปุ่ม scroll-to + ปุ่มลบเฉพาะรายการของตัวเอง */
export default function AnnotationSidebar({
annotations,
bookmarks,
fileId,
userId,
userFilter,
users,
onUserFilterChange,
replyCounts,
onDeleteAnnotation,
onUpdateAnnotation,
onDeleteBookmark,
onAddSubBookmark,
onUpdateBookmark,
onGoToBookmark,
onGoToAnnotation,
}: {
annotations: AnnotationDTO[];
bookmarks: BookmarkDTO[];
/** ไฟล์ที่เปิดอยู่ — ใช้ค้นหาหมายเหตุทั้งไฟล์ (F14) */
fileId: string;
userId: string;
/** F4: "all" | "mine" | <userId> — กรองฝั่ง client ล้วน */
userFilter: string;
users: { userId: string; userName: string }[];
onUserFilterChange: (value: string) => void;
/** F9: จำนวน reply ต่อ annotation (แสดงเฉพาะ COMMENT) */
replyCounts: Record<string, number>;
onDeleteAnnotation: (id: string) => void;
/** แก้ไข data ของรายการของตัวเอง (โน้ตคำอธิบาย) — parent ทำ optimistic + updateAnnotation */
onUpdateAnnotation: (id: string, data: AnnotationData) => void;
onDeleteBookmark: (id: string) => void;
/** N2: ปุ่ม + ที่หัวข้อ — เพิ่ม bookmark หน้าที่ viewer ดูอยู่เข้าใต้หัวข้อนั้นทันที */
onAddSubBookmark: (parentId: string) => void;
/** N2: แก้ bookmark ของตัวเอง — ชื่อ/โน้ต/ย้ายหัวข้อ (patch เฉพาะ field ที่เปลี่ยน) */
onUpdateBookmark: (
id: string,
patch: { title?: string; note?: string | null; parentId?: string | null },
) => void;
onGoToBookmark: (b: BookmarkDTO) => void;
onGoToAnnotation: (a: AnnotationDTO) => void;
}) {
const [tab, setTab] = useState<"bookmarks" | "annotations">("bookmarks");
// ─── F14/D1: ค้นหาคำในหมายเหตุทั้งไฟล์ (highlight text / comment message) ────
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<AnnotationDTO[] | null>(null);
const [searching, setSearching] = useState(false);
async function runSearch(e: FormEvent) {
e.preventDefault();
const q = searchQuery.trim();
if (!q || !fileId) return;
setSearching(true);
try {
setSearchResults(await searchAnnotations(fileId, q));
} catch (err) {
console.error("ค้นหาไม่สำเร็จ", err);
setSearchResults([]);
} finally {
setSearching(false);
}
}
// ─── F19/A7: ประวัติ/กู้คืน — dialog ต่อรายการ (แสดงเฉพาะรายการของตัวเอง) ─────
const [historyFor, setHistoryFor] = useState<AnnotationDTO | null>(null);
const [versions, setVersions] = useState<AnnotationVersionDTO[] | null>(null);
const [restoringId, setRestoringId] = useState<string | null>(null);
const [historyError, setHistoryError] = useState<string | null>(null);
async function openHistory(a: AnnotationDTO) {
setHistoryFor(a);
setVersions(null);
setHistoryError(null);
try {
setVersions(await listVersions(a.id));
} catch (err) {
setHistoryError(err instanceof Error ? err.message : "โหลดประวัติไม่สำเร็จ");
}
}
/** กู้คืน = เขียนกลับ row เดิม (ต้นทางถูกลบไปแล้ว = สร้างใหม่ id ใหม่)
* sync ผ่าน SSE update/create event (รวมแท็บนี้) */
async function handleRestore(versionId: string) {
setRestoringId(versionId);
try {
await restoreVersion(versionId, userId);
setHistoryFor(null);
} catch (err) {
setHistoryError(err instanceof Error ? err.message : "กู้คืนไม่สำเร็จ");
} finally {
setRestoringId(null);
}
}
// F10/A5: แยกกลุ่มเฉพาะ COMMENT — "ค้างดำเนินการ" (รวม annotation ชนิดอื่น)
// กับ "จัดการแล้ว" (comment ที่ถูก resolve)
const pendingItems = annotations.filter((a) => a.type !== "COMMENT" || !a.resolved);
const resolvedItems = annotations.filter((a) => a.type === "COMMENT" && a.resolved);
// F12/C4: คัดลอก deep link ไปยัง annotation นี้ (?page= + ?ann=)
const [copiedId, setCopiedId] = useState<string | null>(null);
// FIX-7: clipboard ถูก block / ไม่มี API → แสดง feedback fail
// 1.5s แทนกลืน error เงียบ — timer เก็บใน ref กันคลิกรัว ๆ แล้ว feedback หายก่อนเวลา
const [copyFailedId, setCopyFailedId] = useState<string | null>(null);
const copyTimerRef = useRef<number | null>(null);
function copyAnnotationLink(a: AnnotationDTO) {
const url = `${window.location.origin}/annotate/${a.fileId}?page=${a.pageIndex + 1}&ann=${a.id}`;
if (copyTimerRef.current !== null) {
window.clearTimeout(copyTimerRef.current);
copyTimerRef.current = null;
}
const show = (ok: boolean) => {
setCopiedId(ok ? a.id : null);
setCopyFailedId(ok ? null : a.id);
copyTimerRef.current = window.setTimeout(() => {
copyTimerRef.current = null;
setCopiedId(null);
setCopyFailedId(null);
}, 1500);
};
// บริบทที่ไม่มี clipboard API (HTTP ที่ไม่ใช่ localhost) — optional chaining
// จะตัดทั้ง chain ทิ้ง ต้องตรวจก่อนถึงจะมี feedback fail จริง
if (!navigator.clipboard?.writeText) {
show(false);
return;
}
navigator.clipboard.writeText(url).then(
() => show(true),
() => show(false),
);
}
// ─── โน้ตคำอธิบายของรายการ (N1) — แก้ไขเฉพาะรายการของตัวเอง แสดงให้ทุกคน ─────
const [noteFor, setNoteFor] = useState<AnnotationDTO["id"] | null>(null);
const [noteDraft, setNoteDraft] = useState("");
function startNoteEdit(a: AnnotationDTO) {
setNoteFor(a.id);
setNoteDraft(annotationNote(a));
}
function saveNote(a: AnnotationDTO) {
const msg = noteDraft.trim();
setNoteFor(null);
if (a.type === "COMMENT" || msg === annotationNote(a)) return; // ไม่มีอะไรเปลี่ยน
onUpdateAnnotation(a.id, withNote(a.data as Exclude<AnnotationData, CommentData>, msg));
}
// ─── N2: bookmark ต้นไม้ 3 ระดับ (หมุดหัวข้อใหญ่ + sub + sub-sub) + edit inline ──
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(new Set());
const bookmarkById = new Map(bookmarks.map((b) => [b.id, b] as const));
const [editBookmark, setEditBookmark] = useState<{
id: string;
title: string;
note: string;
parentId: string | null;
} | null>(null);
function toggleCollapsed(id: string) {
setCollapsedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function startBookmarkEdit(b: BookmarkDTO) {
setEditBookmark({
id: b.id,
title: b.title,
note: b.note ?? "",
parentId: b.parentId,
});
}
function saveBookmarkEdit(b: BookmarkDTO) {
if (!editBookmark || editBookmark.id !== b.id) return;
const patch: { title?: string; note?: string | null; parentId?: string | null } = {};
const title = editBookmark.title.trim();
if (title && title !== b.title) patch.title = title;
const note = editBookmark.note.trim();
if (note !== (b.note ?? "")) patch.note = note || null;
if (editBookmark.parentId !== (b.parentId ?? null)) {
patch.parentId = editBookmark.parentId;
}
setEditBookmark(null);
if (Object.keys(patch).length > 0) onUpdateBookmark(b.id, patch);
}
const renderBookmarkNode = (b: BookmarkDTO, depth: number) => {
const children = bookmarks.filter((x) => x.parentId === b.id);
const hasChildren = children.length > 0;
const collapsed = collapsedIds.has(b.id);
const editing = editBookmark?.id === b.id;
// FIX-9: guard null ครั้งเดียวต้น branch แทน editBookmark! ซ้ำ 6 จุด
const eb = editing ? editBookmark : null;
// เป้าหมายที่ย้ายเข้าได้ = ไม่ใช่ตัวเอง/ลูกหลาน และความลึกแม่ + ความสูง subtree ตัวเอง ≤ 3 ระดับ
// (server ตรวจซ้ำอีกชั้นพร้อม lock — ตรงเกณฑ์เดียวกัน)
// FIX-11: คำนวณเฉพาะตอนเปิดแก้หัวข้อนี้ — ไล่ subtree ทุก node ทุก render เปลืองเปล่า
const movableTo = editing
? bookmarks.filter(
(t) =>
t.id !== b.id &&
!isDescendantOf(t.id, b.id, bookmarkById) &&
bookmarkDepth(t, bookmarkById) +
subtreeHeight(b, (id) => bookmarks.filter((x) => x.parentId === id)) <=
3,
)
: [];
return (
<li
key={b.id}
className={`group rounded hover:bg-slate-100 ${
depth === 1 ? "px-1 py-0.5" : `py-0.5 pr-1 ${depth === 2 ? "pl-5" : "pl-9"}`
}`}
>
{eb ? (
<div className="py-1">
<input
autoFocus
value={eb.title}
onChange={(e) => setEditBookmark({ ...eb, title: e.target.value })}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter") saveBookmarkEdit(b);
if (e.key === "Escape") setEditBookmark(null);
}}
placeholder="ชื่อหัวข้อ"
className="w-full rounded border border-slate-300 px-1.5 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-slate-400"
/>
<textarea
rows={2}
value={eb.note}
onChange={(e) => setEditBookmark({ ...eb, note: e.target.value })}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") setEditBookmark(null);
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) saveBookmarkEdit(b);
}}
placeholder="โน้ต (ไม่บังคับ)"
className="mt-1 w-full resize-none rounded border border-slate-300 p-1.5 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
/>
<p className="mt-1 text-[10px] text-slate-400">วางที่</p>
<select
value={eb.parentId ?? ""}
onChange={(e) =>
setEditBookmark({ ...eb, parentId: e.target.value || null })
}
onKeyDown={(e) => e.stopPropagation()}
className="mt-0.5 w-full rounded border border-slate-300 bg-white px-1.5 py-1 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
>
<option value="">ระดับบนสุด (หมุดหัวข้อใหญ่)</option>
{movableTo.map((h) => (
<option key={h.id} value={h.id}>
ใต้ &quot;{h.title}&quot;
</option>
))}
</select>
<div className="mt-1.5 flex justify-end gap-1.5 text-[11px]">
<button
type="button"
className="rounded px-2 py-0.5 text-slate-500 hover:bg-slate-100"
onClick={() => setEditBookmark(null)}
>
ยกเลิก
</button>
<button
type="button"
className="rounded bg-primary px-2 py-0.5 font-medium text-white"
onClick={() => saveBookmarkEdit(b)}
>
บันทึก
</button>
</div>
</div>
) : (
<div className="flex items-center gap-1.5 py-1">
{hasChildren ? (
<button
type="button"
aria-label={collapsed ? "กางหัวข้อ" : "หุบหัวข้อ"}
className="shrink-0 text-slate-400 hover:text-slate-600"
onClick={() => toggleCollapsed(b.id)}
>
{collapsed ? (
<ChevronRight className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
</button>
) : (
<span className="w-3.5 shrink-0" />
)}
<button
type="button"
className="flex min-w-0 flex-1 items-start gap-1.5 text-left"
onClick={() => onGoToBookmark(b)}
>
<Bookmark
className={`h-3.5 w-3.5 shrink-0 ${
depth === 1 ? "text-amber-500" : "text-amber-300"
}`}
/>
<span className="min-w-0">
<span
className={`block truncate ${
depth === 1 ? "text-xs font-medium text-slate-700" : "text-xs"
}`}
>
{b.title}
{hasChildren && (
<span className="ml-1 font-normal text-slate-400">({children.length})</span>
)}
</span>
<span className="block text-[10px] text-slate-400">หน้า {b.pageIndex + 1}</span>
{b.note && (
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-slate-500">
{b.note}
</span>
)}
</span>
</button>
{b.userId === userId && (
<>
<button
type="button"
aria-label="แก้ไข bookmark"
title="แก้ชื่อ/โน้ต/ย้ายหัวข้อ"
className="shrink-0 text-slate-300 hover:text-slate-500 group-hover:text-slate-400"
onClick={() => startBookmarkEdit(b)}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label="ลบ bookmark"
className="shrink-0 text-slate-300 hover:text-red-500 group-hover:text-slate-400"
onClick={() => onDeleteBookmark(b.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
{/* ปุ่ม + ตอน depth < 3 — ดัก bookmark หน้าที่ viewer ดูอยู่เข้าใต้หัวข้อนี้ */}
{depth < 3 && userId !== "" && (
<button
type="button"
aria-label="เพิ่มหน้าที่ดูอยู่เป็น sub ของหัวข้อนี้"
title="เพิ่มหน้าที่ดูอยู่เป็น sub"
className="shrink-0 text-slate-300 hover:text-emerald-600 group-hover:text-slate-400"
onClick={() => onAddSubBookmark(b.id)}
>
<Plus className="h-3.5 w-3.5" />
</button>
)}
</div>
)}
{hasChildren && !collapsed && (
<ul className="space-y-0.5">
{children.map((c) => renderBookmarkNode(c, depth + 1))}
</ul>
)}
</li>
);
};
const renderAnnotationRow = (a: AnnotationDTO) => {
const note = annotationNote(a);
const editingNote = noteFor === a.id;
return (
<li
key={a.id}
className={`group rounded px-2 py-1.5 hover:bg-slate-100 ${
a.resolved ? "opacity-60" : ""
}`}
>
<div className="flex items-center gap-2">
<button
type="button"
className="flex min-w-0 flex-1 items-start gap-2 text-left"
onClick={() => {
// ระหว่างแก้โน้ต: คลิกข้อความไม่กระโดดหน้า (กัน scroll หนีตอน editor เปิดอยู่)
if (editingNote) return;
onGoToAnnotation(a);
}}
>
<TypeIcon type={a.type} data={a.data} />
<span className="min-w-0">
<span className="block truncate text-xs">{annotationPreviewText(a)}</span>
<span className="block text-[11px] text-slate-400">
หน้า {a.pageIndex + 1} · {a.userName}
{a.type === "COMMENT" && (replyCounts[a.id] ?? 0) > 0
? ` · ตอบ ${replyCounts[a.id]}`
: ""}
</span>
</span>
</button>
{/* F12/C4: คัดลอก deep link ไปยังตำแหน่ง annotation นี้ */}
<button
type="button"
aria-label="คัดลอกลิงก์ไปยัง annotation นี้"
title={copyFailedId === a.id ? "คัดลอกไม่ได้ (เบราว์เซอร์บล็อกคลิปบอร์ด)" : "คัดลอกลิงก์"}
className={`shrink-0 ${
copiedId === a.id
? "text-emerald-500"
: copyFailedId === a.id
? "text-red-500"
: "text-slate-300 hover:text-slate-500 group-hover:text-slate-400"
}`}
onClick={() => copyAnnotationLink(a)}
>
{copiedId === a.id ? (
<Check className="h-4 w-4" />
) : copyFailedId === a.id ? (
<X className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
{a.userId === userId && (
<>
{/* N1: โน้ตคำอธิบาย (เหตุผลของรายการ) — ยกเว้น COMMENT ที่มี message อยู่แล้ว */}
{a.type !== "COMMENT" && (
<button
type="button"
aria-label={note ? "แก้ไขโน้ต" : "เพิ่มโน้ต"}
title="โน้ต"
className={`shrink-0 ${
note
? "text-amber-400 hover:text-amber-500"
: "text-slate-300 hover:text-slate-500 group-hover:text-slate-400"
}`}
onClick={() => (editingNote ? setNoteFor(null) : startNoteEdit(a))}
>
<StickyNote className="h-4 w-4" />
</button>
)}
{/* F19/A7: ประวัติการเปลี่ยนแปลง + กู้คืน — เฉพาะรายการของตัวเอง */}
<button
type="button"
aria-label="ดูประวัติการเปลี่ยนแปลง"
title="ประวัติ"
className="shrink-0 text-slate-300 hover:text-slate-500 group-hover:text-slate-400"
onClick={() => void openHistory(a)}
>
<Clock className="h-4 w-4" />
</button>
<button
type="button"
aria-label="ลบ annotation"
className="shrink-0 text-slate-300 hover:text-red-500 group-hover:text-slate-400"
onClick={() => onDeleteAnnotation(a.id)}
>
<Trash2 className="h-4 w-4" />
</button>
</>
)}
</div>
{editingNote ? (
<div className="mt-1 pl-6">
<textarea
autoFocus
rows={2}
value={noteDraft}
onChange={(e) => setNoteDraft(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation(); // ไม่ให้ปุ่มใน textarea ชนคีย์ลัดของ viewer
if (e.key === "Escape") setNoteFor(null);
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) saveNote(a);
}}
placeholder="โน้ต เช่น เหตุผลที่ทำรายการนี้…"
className="w-full resize-none rounded border border-slate-300 p-1.5 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
/>
<div className="mt-1 flex justify-end gap-1.5 text-[11px]">
<button
type="button"
className="rounded px-2 py-0.5 text-slate-500 hover:bg-slate-100"
onClick={() => setNoteFor(null)}
>
ยกเลิก
</button>
<button
type="button"
className="rounded bg-primary px-2 py-0.5 font-medium text-white"
onClick={() => saveNote(a)}
>
บันทึก
</button>
</div>
</div>
) : (
note && (
<p className="mt-0.5 flex items-start gap-1 pl-6 text-[11px] text-slate-500">
<StickyNote className="mt-0.5 h-3 w-3 shrink-0 text-amber-400" />
<span className="line-clamp-2 whitespace-pre-wrap break-words">{note}</span>
</p>
)
)}
</li>
);
};
return (
<div className="flex h-full flex-col">
{/* F4: กรองแสดง annotation ตามผู้เขียน (viewer + sidebar พร้อมกัน) */}
<div className="shrink-0 border-b border-slate-200 p-2">
<select
aria-label="กรอง annotation ตามผู้เขียน"
className="w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-xs text-slate-600 focus:outline-none focus:ring-2 focus:ring-slate-400"
value={userFilter}
onChange={(e) => onUserFilterChange(e.target.value)}
>
<option value="all">แสดง annotation ของทุกคน</option>
<option value="mine">เฉพาะของฉัน</option>
{users.map((u) => (
<option key={u.userId} value={u.userId}>
เฉพาะ {u.userName}
</option>
))}
</select>
</div>
{/* F14/D1: ค้นหาในหมายเหตุทั้งไฟล์ — คลิกผลลัพธ์ = กระโดดไปตำแหน่ง + เลือก */}
<form
className="flex shrink-0 gap-1 border-b border-slate-200 p-2"
onSubmit={(e) => void runSearch(e)}
>
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.stopPropagation()} // ไม่ให้ปุ่มในช่องค้นหาชนคีย์ลัดของ viewer
placeholder="ค้นหาในหมายเหตุ…"
className="w-full rounded-md border border-slate-300 py-1.5 pl-7 pr-2 text-xs focus:outline-none focus:ring-2 focus:ring-slate-400"
/>
</div>
<button
type="submit"
disabled={!searchQuery.trim() || searching}
className="shrink-0 rounded-md border border-slate-300 px-2 py-1.5 text-xs text-slate-600 hover:bg-slate-100 disabled:opacity-40"
>
{searching ? "…" : "หา"}
</button>
</form>
{searchResults !== null && (
<div className="shrink-0 border-b border-slate-200 p-2">
<p className="px-1 pb-1 text-[10px] font-semibold uppercase tracking-wide text-slate-400">
{searchResults.length > 0
? `ผลการค้นหา (${searchResults.length})`
: "ผลการค้นหา"}
</p>
{searchResults.length === 0 ? (
<p className="px-1 pb-1 text-xs text-slate-400">ไม่พบ</p>
) : (
<ul className="max-h-40 space-y-1 overflow-y-auto">
{searchResults.map((a) => (
<li key={a.id}>
<button
type="button"
className="flex w-full items-start gap-2 rounded px-1 py-1 text-left hover:bg-slate-100"
onClick={() => onGoToAnnotation(a)}
>
<TypeIcon type={a.type} data={a.data} />
<span className="min-w-0">
<span className="block truncate text-xs">{annotationPreviewText(a)}</span>
<span className="block text-[11px] text-slate-400">
หน้า {a.pageIndex + 1} · {a.userName}
</span>
</span>
</button>
</li>
))}
</ul>
)}
</div>
)}
<div className="flex shrink-0 border-b border-slate-200">
{(
[
["bookmarks", `Bookmarks (${bookmarks.length})`],
["annotations", `Annotations (${annotations.length})`],
] as const
).map(([key, label]) => (
<button
key={key}
type="button"
className={`flex-1 px-2 py-2 text-xs font-medium transition ${
tab === key
? "border-b-2 border-primary text-slate-900"
: "text-slate-400 hover:text-slate-600"
}`}
onClick={() => setTab(key)}
>
{label}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-2">
{tab === "bookmarks" ? (
bookmarks.length === 0 ? (
<p className="p-4 text-center text-xs text-slate-400">
ยังไม่มี bookmark — กดปุ่ม bookmark บน toolbar เพื่อเพิ่ม
</p>
) : (
<ul className="space-y-0.5">
{/* N2: root = หมุดหัวข้อใหญ่ เรียงตามเวลาสร้าง — sub render ซ้อนใต้ผ่าน renderBookmarkNode */}
{bookmarks
.filter((b) => !b.parentId)
.map((b) => renderBookmarkNode(b, 1))}
</ul>
)
) : annotations.length === 0 ? (
<p className="p-4 text-center text-xs text-slate-400">
ยังไม่มี annotation — คลุมดำ / วาดเส้น / คลิกวาง comment บนหน้าเอกสาร
</p>
) : (
<>
{pendingItems.length > 0 && (
<section>
<p className="px-2 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-wide text-slate-400">
ค้างดำเนินการ
</p>
<ul className="space-y-1">{pendingItems.map(renderAnnotationRow)}</ul>
</section>
)}
{resolvedItems.length > 0 && (
<section>
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wide text-emerald-500">
จัดการแล้ว
</p>
<ul className="space-y-1">{resolvedItems.map(renderAnnotationRow)}</ul>
</section>
)}
</>
)}
</div>
{/* F19/A7: dialog render ผ่าน portal ไป document.body —
aside.sticky สร้าง stacking context ครอบ z-50 ของ dialog ไว้ ทำให้ textLayer
(z-[2] ระดับ root) ทับปุ่มจนคลิกไม่ได้ (เจอตอน verify ด้วยเบราว์เซอร์จริง) */}
{typeof document === "undefined"
? null // SSR ไม่มี document — client component ก็ถูก render ฝั่ง server สำหรับ HTML แรก
: createPortal(
<>
{/* F19/A7: dialog ประวัติ — รายการ version (เวลา + ชนิดการเปลี่ยนแปลง) + ปุ่มกู้คืน */}
{historyFor && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 p-4">
<div className="w-80 rounded-lg border border-slate-200 bg-white p-4 shadow-xl">
<p className="text-sm font-semibold">ประวัติการเปลี่ยนแปลง</p>
<p className="mt-0.5 truncate text-[11px] text-slate-400">
{annotationPreviewText(historyFor)}
</p>
{historyError && (
<p className="mt-2 rounded bg-red-50 px-2 py-1 text-[11px] text-red-600">
{historyError}
</p>
)}
{versions === null && !historyError && (
<p className="mt-3 text-xs text-slate-400">กำลังโหลด…</p>
)}
{versions !== null && versions.length === 0 && (
<p className="mt-3 text-xs text-slate-400">
ยังไม่มีบันทึกการเปลี่ยนแปลง (เกิดขึ้นเมื่อแก้ไขหรือลบรายการนี้)
</p>
)}
{versions !== null && versions.length > 0 && (
<ul className="mt-3 max-h-56 divide-y divide-slate-100 overflow-y-auto">
{versions.map((v) => (
<li key={v.id} className="flex items-center gap-2 py-2">
<span className="min-w-0 flex-1">
<span className="block text-xs font-medium">
{v.kind === "delete" ? "ถูกลบ" : "ถูกแก้ไข"}
</span>
<span className="block text-[11px] text-slate-400" suppressHydrationWarning>
{new Date(v.createdAt).toLocaleString("th-TH")}
</span>
</span>
<button
type="button"
disabled={restoringId !== null}
className="shrink-0 rounded border border-slate-300 px-2 py-1 text-[11px] text-slate-600 hover:bg-slate-100 disabled:opacity-40"
onClick={() => void handleRestore(v.id)}
>
{restoringId === v.id ? "กำลังกู้คืน…" : "กู้คืน"}
</button>
</li>
))}
</ul>
)}
<div className="mt-3 flex justify-end text-xs">
<button
type="button"
className="rounded px-3 py-1.5 text-slate-500 hover:bg-slate-100"
onClick={() => setHistoryFor(null)}
>
ปิด
</button>
</div>
</div>
</div>
)}
</>
,
document.body
)}
</div>
);
}

29
db/index.ts Normal file
View file

@ -0,0 +1,29 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
// DATABASE_URL ชี้ที่ PostgreSQL container `judes-note-db` (PLAN.md §0 A7)
// prepare:false เพราะ Server Actions ยิง connection สั้น ๆ พร้อมกันได้ (CLAUDE.md invariant #11)
//
// **เก็บ client บน globalThis** — Next dev hot-reload re-evaluate module นี้ทุกครั้ง
// ถ้าสร้าง postgres() ใน module scope ตรง ๆ pool เก่า (default 10 connections) จะไม่
// ถูกปิดและสะสมจนชน max_connections ของ Postgres ("sorry, too many clients already")
// — pattern เดียวกับ lib/annotate-events.ts (rules/realtime.md)
const globalForDb = globalThis as unknown as {
pgClient?: ReturnType<typeof postgres>;
};
// FIX-9: เอา non-null ! ออก — ไม่ตั้ง env ต้อง error ชัดเจน
// ไม่ใช่ปล่อย undefined ไหลเข้า postgres() จนพังในทางที่หาสาเหตุยาก
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error("ตั้งค่า DATABASE_URL ก่อน (container judes-note-db — PLAN.md §0 A7)");
}
const client = globalForDb.pgClient ?? postgres(databaseUrl, { prepare: false });
if (process.env.NODE_ENV !== "production") {
globalForDb.pgClient = client;
}
export const db = drizzle(client, { schema });

341
db/schema.ts Normal file
View file

@ -0,0 +1,341 @@
import {
boolean,
doublePrecision,
index,
integer,
jsonb,
pgEnum,
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
// ─── Enum ────────────────────────────────────────────────────────────────────
export const annotationType = pgEnum("annotation_type", [
"HIGHLIGHT",
"DRAW",
"COMMENT",
"SHAPE",
"REDACTION",
"TEXTBOX",
"STAMP",
]);
export type AnnotationType = (typeof annotationType.enumValues)[number];
// ─── Strict JSONB union (PLAN.md §3, PLAN-FEAT.md §1) ───────────────────────
/** ทุกค่าเป็น % ของขนาดหน้า PDF */
export type Rect = { x: number; y: number; width: number; height: number };
/** พิกัดจุดเดี่ยว (% ของขนาดหน้า) — define ที่นี่แล้ว re-export จาก utils/coordinates.ts (PLAN-FEAT.md §1) */
export type Point = { x: number; y: number };
/** รูปแบบการมาร์กข้อความที่คลุมดำ — ไฮไลต์ทึบ / เส้นใต้ / ขีดฆ่า / เส้นขยุกขยิก */
export type HighlightStyle = "highlight" | "underline" | "strikethrough" | "squiggle";
export type HighlightData = {
text: string;
color: string;
rects: Rect[];
/** optional — ไม่มี = highlight ทึบตามเดิม (แถวเดิมก่อนมี 4 โหมด) */
style?: HighlightStyle;
/** optional — ความทึบ 0–1; ไม่มี = ทึบเต็ม (แถวเดิมก่อนมีตัวปรับความทึบ) */
opacity?: number;
/** โน้ตคำอธิบายของรายการนี้ เช่น เหตุผลที่วาด/ไฮไลต์ (optional — ทุกชนิดยกเว้น COMMENT ที่มี message ทำหน้าที่นี้อยู่แล้ว) */
note?: string;
};
/** ชนิดปากกาของเส้นวาด (tool DRAW) — highlighter = ปากไฮไลต์โปร่งแสง เส้นหนากว่า */
export type DrawPen = "pencil" | "highlighter";
/** paths เป็น % และ strokeWidth เป็น % ของความกว้างหน้า */
export type DrawData = {
strokeColor: string;
strokeWidth: number;
paths: Point[];
/** F7/E1 (optional): ความหนาต่อจุด (% ของความกว้างหน้า) จาก pressure ของสไตลัส — มีเฉพาะเส้นที่วาดด้วยปากกา, ยาวเท่า paths */
widths?: number[];
/** optional — ไม่ระบุ = pencil (แถวเดิมก่อนมี highlighter); สี/ความหนา/ความทึบ preset ต่อ pen อยู่ที่ DRAW_PENS ใน utils/annotation-display.ts */
pen?: DrawPen;
/** optional — ความทึบ 0–1; เส้นใหม่ (หลังมีตัวปรับความทึบ) เก็บชัดเสมอ ส่วนแถวเดิมที่ไม่มี = fallback ตาม preset ของ pen (ดู effectiveDrawOpacity ใน utils/annotation-display.ts) */
opacity?: number;
/** โน้ตคำอธิบายของรายการนี้ เช่น เหตุผลที่วาดเส้น (optional) */
note?: string;
};
export type CommentData = { position: Point; message: string };
/** ชนิดรูปทรง (F5/B1) — line/arrow เก็บจุด 2 จุดแทน rect เพื่อรักษาทิศทาง/ความเฉียง (D3) */
export type RectShapeKind = "rect" | "ellipse";
export type LineShapeKind = "line" | "arrow";
export type ShapeKind = RectShapeKind | LineShapeKind;
export type ShapeData =
| { shape: RectShapeKind; rect: Rect; strokeColor: string; strokeWidth: number; note?: string }
| { shape: LineShapeKind; from: Point; to: Point; strokeColor: string; strokeWidth: number; note?: string };
/** ปิดพราง (F8/B4) — สี่เหลี่ยมดำทึบ ไม่มีสีให้เปลี่ยน; พิกัด rect เป็น % ของหน้า */
export type RedactionData = { rect: Rect; note?: string };
/** ข้อความแบ่งช่วงสไตล์ (rich text ในกล่องเดียว) — เช่นเลือกบางส่วนทำตัวหนา */
export type TextboxSegment = { text: string; bold?: boolean };
/**
* กล่องข้อความบนหน้า (F17/B2) — position เป็นมุมซ้ายบนของกล่อง
* fontSize เป็น % ของความกว้างหน้าเช่นเดียวกับ strokeWidth
*/
export type TextboxData = {
position: Point;
text: string;
color: string;
fontSize: number;
/** ตัวหนา (optional) — export ต้องมี Sarabun-Bold.ttf ใน public/fonts ไม่งั้น fallback เป็น Regular */
bold?: boolean;
/** ความทึบ 0–1 (optional) — กล่องที่สร้างหลังมีตัวปรับเก็บชัดเสมอ แถวเดิม = 1 */
opacity?: number;
/** จัดแนวในกล่อง (optional) — ไม่มี = left */
align?: "left" | "center" | "right";
/** ข้อความแบ่งช่วงตัวหนา/ปกติ (optional) — มีเฉพาะเมื่อมีบางส่วนเป็นตัวหนา;
* field `text` ยังเก็บข้อความรวม (concat) เสมอเพื่อค้นหา F14/preview sidebar */
segments?: TextboxSegment[];
/** โน้ตคำอธิบายของรายการนี้ (optional) */
note?: string;
};
/** ตรายาง preset (F18/B5, D7) — scale เป็น % ของความกว้างหน้า (ความกว้างกรอบตรายาง)
* color เป็น optional — ไม่มี = STAMP_COLOR (แถวเดิมก่อนมีตัวเลือกสี) */
export type StampData = { label: string; position: Point; scale: number; color?: string; note?: string };
/** เลือกตาม column `type` (discriminated โดย enum) — ห้าม loosen เป็น any */
export type AnnotationData =
| HighlightData
| DrawData
| CommentData
| ShapeData
| RedactionData
| TextboxData
| StampData;
// ─── Tables ──────────────────────────────────────────────────────────────────
export const files = pgTable("files", {
id: uuid("id").primaryKey().defaultRandom(),
title: text("title").notNull(),
description: text("description"),
pdfUrl: text("pdf_url").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
});
export const annotations = pgTable(
"annotations",
{
id: uuid("id").primaryKey().defaultRandom(),
fileId: uuid("file_id")
.notNull()
.references(() => files.id, { onDelete: "cascade" }),
pageIndex: integer("page_index").notNull(),
userId: text("user_id").notNull(),
userName: text("user_name").notNull(),
type: annotationType("type").notNull(),
data: jsonb("data").$type<AnnotationData>().notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
/** F10/A5: จัดการแล้ว — ใครก็ได้ toggle (D6) ไม่ทำลายข้อมูล */
resolved: boolean("resolved").notNull().default(false),
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
},
(t) => [index("annotations_file_page_idx").on(t.fileId, t.pageIndex)],
);
export const bookmarks = pgTable(
"bookmarks",
{
id: uuid("id").primaryKey().defaultRandom(),
fileId: uuid("file_id")
.notNull()
.references(() => files.id, { onDelete: "cascade" }),
pageIndex: integer("page_index").notNull(),
userId: text("user_id").notNull(),
title: text("title").notNull(),
/** N2: bookmark แม่ (NULL = หมุดหัวข้อใหญ่ระดับบนสุด) — ไม่ทำ FK เพราะลบแม่ต้อง re-parent ลูกขึ้น root ก่อนใน tx เดียวกัน */
parentId: uuid("parent_id"),
/** N2: โน้ตย่อยของ bookmark (optional) */
note: text("note"),
/** % ของความสูงหน้า (0–100) */
yOffset: doublePrecision("y_offset").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
);
/** คำตอบในเธรดของ comment (F9/A4 — D2: ตารางแยก ไม่ใช้ parentId ในตารางเดิม) */
export const annotationReplies = pgTable(
"annotation_replies",
{
id: uuid("id").primaryKey().defaultRandom(),
annotationId: uuid("annotation_id")
.notNull()
.references(() => annotations.id, { onDelete: "cascade" }),
userId: text("user_id").notNull(),
userName: text("user_name").notNull(),
message: text("message").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(t) => [index("annotation_replies_annotation_idx").on(t.annotationId)],
);
/**
* ประวัติการเปลี่ยนแปลงของ annotation (F19/A7)
* `annotation_id` เป็น uuid ธรรมดา **ไม่ FK** (D8) — snapshot ตอน delete ต้องรอดจาก
* CASCADE ของแม่; `page_index` เก็บหน้าของ annotation ตอน snapshot เพื่อให้ restore
* สร้างใหม่บนหน้าเดิมได้
*/
export const annotationVersions = pgTable(
"annotation_versions",
{
id: uuid("id").primaryKey().defaultRandom(),
annotationId: uuid("annotation_id").notNull(),
fileId: uuid("file_id")
.notNull()
.references(() => files.id, { onDelete: "cascade" }),
pageIndex: integer("page_index").notNull(),
userId: text("user_id").notNull(),
userName: text("user_name").notNull(),
type: annotationType("type").notNull(),
data: jsonb("data").$type<AnnotationData>().notNull(),
/** 'update' = snapshot ก่อนเขียนทับ · 'delete' = snapshot ก่อนลบ */
kind: text("kind").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(t) => [index("annotation_versions_file_idx").on(t.fileId)],
);
// ─── DTO (timestamp เป็น ISO string เสมอ — ห้ามส่ง Date ข้าม server→client) ──
export type AnnotationRow = typeof annotations.$inferSelect;
export type BookmarkRow = typeof bookmarks.$inferSelect;
export type ReplyRow = typeof annotationReplies.$inferSelect;
export type AnnotationVersionRow = typeof annotationVersions.$inferSelect;
export type AnnotationDTO = {
id: string;
fileId: string;
pageIndex: number;
userId: string;
userName: string;
type: AnnotationType;
data: AnnotationData;
createdAt: string;
updatedAt: string;
/** F10/A5 */
resolved: boolean;
resolvedAt: string | null;
};
export type BookmarkDTO = {
id: string;
fileId: string;
pageIndex: number;
userId: string;
title: string;
/** N2: NULL = ระดับบนสุด / มีค่า = sub ของ bookmark นั้น */
parentId: string | null;
/** N2: โน้ตย่อย (null = ไม่มี) */
note: string | null;
yOffset: number;
createdAt: string;
};
export type ReplyDTO = {
id: string;
annotationId: string;
userId: string;
userName: string;
message: string;
createdAt: string;
};
export type VersionKind = "update" | "delete";
export type AnnotationVersionDTO = {
id: string;
annotationId: string;
fileId: string;
pageIndex: number;
userId: string;
userName: string;
type: AnnotationType;
data: AnnotationData;
kind: VersionKind;
createdAt: string;
};
export function toAnnotationDTO(row: AnnotationRow): AnnotationDTO {
return {
id: row.id,
fileId: row.fileId,
pageIndex: row.pageIndex,
userId: row.userId,
userName: row.userName,
type: row.type,
data: row.data,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
resolved: row.resolved,
resolvedAt: row.resolvedAt ? row.resolvedAt.toISOString() : null,
};
}
export function toBookmarkDTO(row: BookmarkRow): BookmarkDTO {
return {
id: row.id,
fileId: row.fileId,
pageIndex: row.pageIndex,
userId: row.userId,
title: row.title,
parentId: row.parentId,
note: row.note,
yOffset: row.yOffset,
createdAt: row.createdAt.toISOString(),
};
}
export function toReplyDTO(row: ReplyRow): ReplyDTO {
return {
id: row.id,
annotationId: row.annotationId,
userId: row.userId,
userName: row.userName,
message: row.message,
createdAt: row.createdAt.toISOString(),
};
}
export function toVersionDTO(row: AnnotationVersionRow): AnnotationVersionDTO {
return {
id: row.id,
annotationId: row.annotationId,
fileId: row.fileId,
pageIndex: row.pageIndex,
userId: row.userId,
userName: row.userName,
type: row.type,
data: row.data,
kind: row.kind === "delete" ? "delete" : "update",
createdAt: row.createdAt.toISOString(),
};
}

286
docs/PLAN-FEAT.md Normal file
View file

@ -0,0 +1,286 @@
# 📋 Implementation Plan — Features ต่อยอด (Jude's Note)
> แผนงาน implement ฟีเจอร์ต่อยอดจากเฟสแรกที่ implement ครบแล้วตาม [`docs/PLAN.md`](./PLAN.md) — ยึดสถาปัตยกรรมเดิมทั้งหมด: พิกัด %, JSONB discriminated union, เขียนผ่าน Server Actions + publish event, SSE sync, Non-Destructive
>
> ใช้งาน: `/implement-feat <F-number หรือ feature id เช่น A1>` (skill `.claude/skills/implement-feat`) — อ้างตาราง §4 ของไฟล์นี้
---
## 0. ข้อตกลง (Decisions)
| # | หัวข้อ | ข้อตกลง |
|---|---|---|
| D1 | Realtime | ต่อยอด SSE เดิม — เพิ่ม event variant (§2) ไม่เพิ่มระบบใหม่ |
| D2 | A4 Reply | ตารางใหม่ `annotation_replies` (FK → annotations CASCADE) **ไม่ใช้** `parentId` ในตารางเดิม — union/ตารางเดิมไม่ต้องแตะ และ query เธรดได้ตรง ๆ |
| D3 | B1 line/arrow | เก็บ `from`/`to` (จุด 2 จุด, % ของหน้า) แทน `rect` เฉย ๆ — ทิศทางลูกศร/ความเฉียงของเส้นต้องไม่หาย; ส่วน rect/ellipse ใช้ `rect` |
| D4 | B4 + C1 | ทำรวมเป็น **task เดียว (F8) เสมอ** — redaction ที่ไม่ติดไปกับไฟล์ export คือช่องโหว่การรั่วของข้อมูล |
| D5 | C1 ฟอนต์ไทย | pdf-lib วาดข้อความไทยต้อง embed ฟอนต์: `Sarabun-Regular.ttf` (SIL OFL) วางที่ `public/fonts/` — ถ้าไฟล์ไม่มี → COMMENT/STAMP/TEXTBOX ใน export วาดเป็นกรอบ/หมุดอย่างเดียว **ต้องไม่พัง** |
| D6 | A5 Resolve | ใครก็ได้ toggle (คลายคืนได้ ไม่ทำลายข้อมูล — ต่างจาก delete ที่จำกัดเจ้าของเหมือนเดิม) |
| D7 | B5 Signature | ทำเฉพาะ stamp preset (ข้อความสำเร็จรูป); ลายเซ็น = ใช้ tool DRAW วาด (upload รูปต้องมีที่เก็บไฟล์ → out of scope) |
| D8 | A7 Versions | `annotation_versions.annotation_id` เป็น uuid **ธรรมดา ไม่ FK** — snapshot ตอน delete ต้องรอดจาก CASCADE ของแม่; restore = สร้าง annotation ใหม่ **id ใหม่** |
| D9 | A1 ย้าย/resize | ในหน้าเดียวกันเท่านั้น (`pageIndex` ไม่เปลี่ยน — การข้ามหน้าเก็บ pageIndex เดี่ยวไม่ได้ตาม schema เฟสแรก); แก้ได้เฉพาะของตัวเอง (guard `userId` เหมือน delete) |
| D10 | A2 Undo/Redo | history ฝั่ง client ต่อ session ต่อผู้ใช้ — ไม่เก็บใน DB (A7 แยกต่างหาก) |
| D11 | C2 Import | คง `userId`/`userName` **ต้นฉบับ** ไว้ (เห็นที่มาว่าใครเขียน) — ผู้ import ลบรายการที่ไม่ใช่ของตัวเองไม่ได้ตามกติกาเดิม; id สุ่มใหม่ทั้งหมดกันชน PK |
---
## 1. Schema & Type Changes (รวมทุกเฟสไว้ที่เดียว)
ทุก task ที่แตะ `db/schema.ts` ต้องรัน `/sync-db` หลังแก้ และเติม validation ใน `validateAnnotationData` ครบทุก case ใหม่ (invariant #8 — ห้าม `any`)
### Enum `annotation_type` ขยายเป็น
```
HIGHLIGHT | DRAW | COMMENT | SHAPE | REDACTION | TEXTBOX | STAMP
```
### JSONB union เพิ่ม member (เติมใน `AnnotationData`)
```ts
// Point ต้อง export จาก db/schema.ts (ตอนนี้ type Point อยู่ใน utils/coordinates.ts — ย้ายมา schema แล้ว re-export)
type ShapeData =
| { shape: "rect" | "ellipse"; rect: Rect; strokeColor: string; strokeWidth: number }
| { shape: "line" | "arrow"; from: Point; to: Point; strokeColor: string; strokeWidth: number };
type RedactionData = { rect: Rect }; // ดำทึบ — ไม่มีสีให้เปลี่ยน
type TextboxData = { position: Point; text: string; color: string; fontSize: number }; // fontSize = % ของ width
type StampData = { label: string; position: Point; scale: number }; // scale = % ของ width
```
- `strokeWidth` ของ SHAPE เป็น % ของความกว้างหน้าเหมือน DRAW; ทุกพิกัด (rect/from/to/position) เป็น % ของขนาดหน้า (invariant #2)
- E1 (F7) เพิ่ม field **optional** `widths?: number[]` ใน `DrawData` (ความหนาต่อจุด, % ของ width — ไว้รองรับ pressure)
### Column เพิ่มในตาราง `annotations` (F10)
| column | type | หมายเหตุ |
|---|---|---|
| resolved | boolean NOT NULL default false | |
| resolved_at | timestamptz NULL | |
### ตารางใหม่
**`annotation_replies`** (F9)
| column | type |
|---|---|
| id | uuid PK defaultRandom() |
| annotation_id | uuid FK → annotations ON DELETE CASCADE |
| user_id | text NOT NULL |
| user_name | text NOT NULL |
| message | text NOT NULL |
| created_at | timestamptz defaultNow() |
Index: `(annotation_id)`
**`annotation_versions`** (F19)
| column | type | หมายเหตุ |
|---|---|---|
| id | uuid PK defaultRandom() | |
| annotation_id | uuid NOT NULL | **ไม่ FK** (D8) |
| file_id | uuid FK → files ON DELETE CASCADE | ลบไฟล์ → ประวัติหายไปด้วย |
| page_index | integer NOT NULL | หน้าของ annotation ตอน snapshot — `restoreVersion` ต้องรู้หน้าจึงสร้างใหม่ได้ (annotations.pageIndex เป็น NOT NULL) |
| user_id / user_name | text NOT NULL | เจ้าของ annotation ตอน snapshot |
| type | annotation_type NOT NULL | |
| data | jsonb NOT NULL `$type<AnnotationData>()` | snapshot ข้อมูลก่อนเปลี่ยน |
| kind | text NOT NULL `'update' \| 'delete'` | |
| created_at | timestamptz defaultNow() | |
Index: `(file_id)`
### DTO
- `AnnotationDTO` เพิ่ม `resolved: boolean` และ `resolvedAt: string | null`
- เพิ่ม `ReplyDTO`, `AnnotationVersionDTO` + mapper `toDTO` — timestamp เป็น ISO string ทั้งหมด (invariant #5)
---
## 2. Realtime Event Changes
`AnnotateEvent` (`lib/annotate-events.ts`) เพิ่ม variant:
```ts
| { table: "annotations"; action: "update"; row: AnnotationDTO }
| { table: "replies"; action: "create"; row: ReplyDTO }
| { table: "replies"; action: "delete"; id: string }
```
- `hooks/useAnnotationRealtime.ts`: `update` → **replace ตาม id** (idempotent — มาก่อน/หลัง optimistic ก็ได้ผลเดิม); เพิ่ม state `replies: ReplyDTO[]`
- `useOptimistic` ใน PDFViewer: `AnnotationAction` เพิ่ม `{ kind: "update"; row }` — ใช้ภายใน `startTransition` เหมือนเดิม (invariant #9)
- Server Actions ตัวใหม่ทุกตัวที่เขียน DB (update / resolve / reply / import / restore) ต้อง **publish หลังเขียนสำเร็จทุกครั้ง** (invariant #4)
---
## 3. สเปกฟีเจอร์ที่ต้องนิยามพฤติกรรมเพิ่ม
### F1 · A1 แก้ไข/ย้าย/ปรับขนาด
- **Server Action** `updateAnnotation(id, userId, data)` — โหลด row เดิม (เจ้าของเท่านั้น D9), ห้ามเปลี่ยน `type`/`pageIndex`, validate `data` ด้วย `validateAnnotationData` เดิม, update `data` + `updatedAt`, publish `{ action: "update", row }`
- **ไฟล์ใหม่** `utils/geometry.ts` (pure functions, ทดสอบได้): `annotationBBox(annotation, size)` / `translateAnnotationData(data, dxPct, dyPct)` / `scaleAnnotationData(data, anchorPct, sx, sy)` / `hitTestPath(points, point, thresholdPx, size)` (ใช้ซ้ำ F6)
- **การเลือก:** คลิกรายการใน sidebar (หรือคลิก comment pin) → state `selectedId` ใน PDFViewer → **`components/pdf/AnnotationSelectionLayer.tsx`** (ใหม่, z-4 เหมือน comment pins) วาดกรอบ bbox + handles:
- container `pointer-events: none`; **handle เท่านั้น** ที่ `pointer-events: auto` → ไม่แตะ invariant #3 (highlight-layer ยัง none เสมอ, textLayer ยัง select ข้อความได้)
- ลากตัวกรอบ = translate ทั้งกลุ่ม; ลากมุม = scale รอบ anchor มุมตรงข้าม (apply กับ rects ทุกอัน / points ทุกจุด / position)
- `pointerup` → คำนวณข้อมูล % ใหม่ → optimistic update + `updateAnnotation`
- **เปลี่ยนสี/ความหนา:** ปุ่มเล็กบนกรอบ selection → ใช้ `updateAnnotation` เดียวกัน
### F2 · A2 Undo/Redo
- **`hooks/useHistory.ts`** (ใหม่): stack ของ `{ undo(): Promise<void>; redo(): Promise<void> }` — push ทุกครั้งที่ create/delete/update **ของตัวเอง** สำเร็จ; action ใหม่ → เคลียร์ redo stack
- undo ของ create = delete(id); ของ delete = create ใหม่จาก data ที่เก็บไว้ (id ใหม่); ของ update = update กลับเป็น data ก่อนหน้า
- UI: ปุ่ม Undo/Redo บน toolbar (disabled ตาม stack) + Ctrl+Z / Ctrl+Shift+Z (จับคู่ F3)
- Edge: undo(create) แต่ row ถูกลบไปแล้วโดยแท็บอื่น → delete โดน reject → แสดง console + ข้าม (ไม่ crash)
### F5 · B1 รูปทรง
- Tool `shape` + sub-state ชนิดรูปทรง (`rect | ellipse | line | arrow`) — ปุ่มเลือกชนิดแสดงเมื่อ tool ใช้งาน
- InkLayer (SVG z-3 เดิม) รับหน้าที่เพิ่ม: pointerdown → ลาก → preview → pointerup → `createAnnotation("SHAPE", ...)` (rect/ellipse เก็บ bbox เป็น `rect`; line/arrow เก็บจุดเริ่ม/จุดปลายเป็น `from`/`to` — D3)
- Render รายการเดิม: `<rect>` / `<ellipse cx cy rx ry>` / `<line>` / arrow = `<line>` + หัวลูกศร `<path>` คำนวณจาก `from→to`
- Sidebar: รายการ SHAPE แสดง label ตามชนิด ("สี่เหลี่ยม", "วงรี", "เส้นตรง", "ลูกศร")
### F6 · B3 Eraser
- Tool `erase`: InkLayer active (รับ pointer, `touch-action: none`), `pointerdown/move` ทำ hit-test ตำแหน่ง pointer กับเส้น/รูปทรง **ของตัวเองเท่านั้น** (ส่ง `userId` มากับ strokes):
- DRAW: ระยะจากจุดถึง segment ทุกท่อน ≤ `strokeWidth/2 + 6px`
- SHAPE: ระยะถึงเส้นขอบ (stroke) — ลากผ่าน "พื้นที่" รูปทรงไม่โดน (เหมือนสัมผัสเส้นจริง)
- โดน = `deleteAnnotation` ทันที (ลากผ่านหลายเส้น = ลบทีละเส้นระหว่างเคลื่อน) — เส้นของคนอื่นไม่ผ่าน hit-test จึงลบไม่ได้
### F8 · B4+C1 Redaction + Export PDF (ทำคู่กันเสมอ — D4)
- **REDACTION บนจอ:** วาดบน InkLayer เป็น `<rect>` fill `#000` (z-3 อยู่เหนือ textLayer → บังข้อความจริงบนจอ); ปุ่ม tool แยกจาก shape
- **Export:** `app/api/annotations/export/route.ts` (GET `?fileId=`) — dep ใหม่ `pdf-lib` + `@pdf-lib/fontkit`:
1. อ่านข้อมูลไฟล์จาก DB → อ่าน PDF ต้นฉบับจาก `path.join(process.cwd(), "public", pdfUrl)` — **อ่านอย่างเดียว ห้ามเขียนทับ** (invariant #1) + guard path ต้อง resolve อยู่ใน `public/` กัน path traversal
2. โหลด annotations ทั้งหมด (ทุก user) → วาดลง `PDFDocument` ต่อหน้าด้วยพิกัด % × ขนาดจริงของหน้านั้น (`page.getSize()` + แปลงตาม `page.getRotation()` กรณี /Rotate 90/180/270)
3. ลำดับวาดต่อหน้า: HIGHLIGHT rect (fill alpha ~0.35) → DRAW polyline (strokeWidth % → pt) → SHAPE → REDACTION **ดำทึบ #000 ทับจริง** → COMMENT หมุดวงกลม (+ข้อความถ้ามี Sarabun — D5) → TEXTBOX/STAMP ถ้าทำแล้ว (F17/F18)
4. ตอบ `application/pdf` + `Content-Disposition: attachment; filename="<title>-annotated.pdf"`
- ปุ่ม "Export PDF" บน toolbar → ลิงก์/`window.open` ไป route
### F9 · A4 Reply/Thread
- Actions `createReply({ annotationId, userId, userName, message })` / `deleteReply(id, userId)` (เจ้าของ reply เท่านั้น) + publish `table: "replies"`
- UI: popover ของ comment pin เพิ่มรายการ replies (ผู้เขียน + เวลา) + ช่องตอบ + ปุ่มลบ reply ของตัวเอง; sidebar แสดงจำนวน reply ต่อ comment
- ลบ comment แม่ → replies หายอัตโนมัติ (CASCADE)
### F10 · A5 Resolve
- Action `setAnnotationResolved(id, resolved)` — **ใครก็ได้** (D6), publish update event
- UI: ปุ่ม toggle ใน popover + pin ที่ resolved เปลี่ยนเป็นสีเขียว/จางลง; sidebar แยกกลุ่ม "ค้างดำเนินการ / จัดการแล้ว" (เฉพาะ COMMENT)
### F11 · C2 Export/Import JSON — **ถอดออกแล้ว**
> ถอดออกจากโค้ดแล้ว (2026-09): route `export-json`, action `importAnnotations`/`listFiles`/`ImportReport`, UI ใน sidebar, และ `countPdfPages` ใน `lib/server-pdf.ts` — สเปกด้านล่างเหลือไว้เป็นประวัติเท่านั้น
- **Export:** `app/api/annotations/export-json/route.ts` (GET `?fileId=`) → `{ file: { title, exportedAt }, annotations: AnnotationDTO[], bookmarks: BookmarkDTO[] }` (DTO ตรง ๆ — ISO string อยู่แล้ว)
- **Import:** Server Action `importAnnotations(targetFileId, payload, operatorUserId)` — ทุกแถว validate ด้วย validator เดิม, **สุ่ม id ใหม่ทุกแถว**, คง author เดิม (D11), แถวที่ผิด (malformed / pageIndex เกินจำนวนหน้าไฟล์เป้าหมาย) → skip พร้อมนับ แล้วคืนรายงาน `{ imported, skipped }`; publish create event ต่อแถวที่สำเร็จ — จำนวนหน้าไฟล์เป้าหมายอ่านฝั่ง server ด้วย `pdf-lib` จากไฟล์ใน `public/` (อ่านอย่างเดียว + path guard เดียวกับ export route §5); dialog เลือกไฟล์เป้าหมายใช้ read-only action `listFiles()`
- UI: ปุ่ม export ใน sidebar + ปุ่ม import เปิด dialog เลือกไฟล์เป้าหมาย (ไฟล์เดียวกัน หรือไฟล์อื่นจากรายการ) + `<input type="file">` อ่าน JSON ฝั่ง client แล้วส่งเข้า action
### F12 · C4 Deep Link
- URL: `/annotate/[id]?page=N` (1-based) และ/หรือ `?ann=<uuid>`
- PDFViewer effect แรก (หลัง `pageSizes` พร้อม) อ่าน **`window.location.search`** (ไม่ใช้ `useSearchParams` — เลี่ยงข้อจำกัด prerender ของ Next 14): scroll ไปตำแหน่ง absolute จาก placeholder (คำนวณครั้งเดียว smooth ครั้งเดียว เหมือน bookmark — rules/pdf-viewer.md); ถ้ามี `ann` → set `selectedId` ให้ด้วย
- ปุ่ม "คัดลอกลิงก์" ใน sidebar รายการ annotation (ใช้กับ F15 หน้าสรุปด้วย)
### F19 · A7 Version History
- `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 (เวลา + ชนิดการเปลี่ยนแปลง) + ปุ่มกู้คืน
### ฟีเจอร์เล็กที่เหลือ (สเปกย่อ — รายละเอียดการทำงานเต็มอยู่ในตาราง §4)
- **F3 E2 คีย์ลัด:** keydown listener ที่ `window` ใน PDFViewer — `H/D/C` สลับ tool, `B` bookmark, `Ctrl+Z`/`Ctrl+Shift+Z` undo/redo, `Esc` ปิด popup/deselect; **ข้ามเมื่อ `e.target` เป็น input/textarea/contenteditable**
- **F4 A3 ซ่อน–แสดงรายคน:** state `userFilter: "all" | "mine" | <userId>` + dropdown รายชื่อ (จาก unique userId ใน annotations) — กรองทั้ง viewer และ sidebar ฝั่ง client ล้วน
- **F7 E1 สไตลัส/สัมผัส:** InkLayer ใช้ Pointer Events + `touch-none` อยู่แล้ว — เพิ่ม: pressure (`e.pressure` → `widths` per-point, render เป็น segment เมื่อมี widths), palm rejection (เมื่อเคยเห็น `pointerType === "pen"` → ละเว้น `touch` ในหน้านั้น)
- **F13 A6 แก้ไขข้อความ comment:** ปุ่ม "แก้ไข" ใน popover (เจ้าของเท่านั้น) → textarea → `updateAnnotation` (reuse F1)
- **F14 D1 ค้นหา:** Action `searchAnnotations(fileId, q)` — `ilike` บน `data->>'text'` และ `data->>'message'` (escape `%`/`_` ใน q) — คีย์ `text` ครอบทั้ง HIGHLIGHT และ TEXTBOX อัตโนมัติ (ชื่อฟิลด์ใน JSONB ตรงกัน); กล่องค้นหาบน sidebar → ผลลัพธ์คลิก → goTo + select
- **F15 D2 หน้าสรุป:** `app/annotate/[id]/summary/page.tsx` — server component รวมทุก annotation จัดกลุ่มตามหน้า (หน้า / ชนิด / ผู้เขียน / ข้อความ) + ปุ่มกระโดดเป็น deep link (F12); ทางเข้า = ปุ่มบน toolbar ของ viewer + ลิงก์กลับหน้าอ่านบนหน้าสรุปเอง; label/ข้อความย่อของแต่ละชนิดแชร์กับ sidebar ผ่าน `utils/annotation-display.ts`
- **F16 D3 Export CSV:** `app/api/annotations/export-csv/route.ts?fileId=` — CSV เดียวกับข้อมูลหน้าสรุป, ขึ้นต้นด้วย UTF-8 BOM (เปิดใน Excel ภาษาไทยถูกต้อง); ปุ่มอยู่หน้าสรุป
- **F17 B2 Text box:** tool `textbox` → คลิกบนหน้า → inline `<textarea>` วางบนตำแหน่งนั้น → บันทึก TEXTBOX; render เป็น div absolute (fontSize % → px); เจ้าของแก้ได้ (pattern F13); export วาดข้อความด้วย Sarabun (D5)
- **F18 B5 Stamp:** preset คงที่ในโค้ด (`STAMP_PRESETS` เช่น "ได้รับแล้ว", "สำเนาถูกต้อง") → tool `stamp` → คลิกวางที่ตำแหน่ง; render เป็นกรอบข้อความสไตล์ตรายาง (scale % → px); export วาดกรอบ+ข้อความ (D7)
---
## 4. ลำดับ Task + เกณฑ์ Verify (skill `/implement-feat` อ้างตารางนี้)
> ทำตามลำดับเลข F1→F19 (dependency จัดไว้แล้วในลำดับนี้) — ทุก task: `npx tsc --noEmit` ต้องผ่าน + เรียก agent `plan-reviewer`; verify แบบ realtime ใช้ 2 browser แท็บ; แตะ schema → `/sync-db`
| Task | FEAT | งานหลัก | ต้องมีก่อน | Verify เพิ่มเติม |
|---|---|---|---|---|
| F1 | A1 | `updateAnnotation` + update event + `utils/geometry.ts` + `AnnotationSelectionLayer` | – | ลากย้าย pin/เส้น/กรอบไฮไลต์แล้วตำแหน่งถูกต้อง **ทุก zoom และหลัง reload**; แก้ของคนอื่นถูกปฏิเสธ; แท็บ B เห็นการเปลี่ยนแปลงแบบ realtime |
| F2 | A2 | `hooks/useHistory.ts` + ปุ่ม Undo/Redo | F1 | undo วาด/ลบ/แก้ได้ย้อนลำดับ; redo; แท็บ B วาดเข้ามา → แท็บ A กด undo **ต้องไม่ทำอะไร** |
| F3 | E2 | คีย์ลัด H/D/C/B, Ctrl+Z/Ctrl+Shift+Z, Esc | F2 | พิมพ์อยู่ใน composer/textarea แล้วกดปุ่มตัวอักษรต้องไม่สลับ tool |
| F4 | A3 | filter รายคน (viewer + sidebar) | – | เลือกดูเฉพาะตัวเอง/รายคน/ทั้งหมดได้; กรองไม่แตะข้อมูลใน DB |
| F5 | B1 | enum+`ShapeData`+validation+InkLayer รูปทรง+tool `shape` | – | `/sync-db` สำเร็จ; วาด 4 ชนิดได้ครบ; สลับ zoom แล้วสัดส่วน/ตำแหน่งไม่เบี้ยว; แท็บ B เห็น realtime |
| F6 | B3 | tool `erase` + `hitTestPath` | F5 | ลากผ่านเส้น/รูปทรงของตัวเอง → หายทันที; ของคนอื่นไม่หาย; แท็บ B เห็นการลบ |
| F7 | E1 | pressure + palm rejection | F5 | `tsc` ผ่าน; **ค้างทดสอบด้วยมือ** บนอุปกรณ์สัมผัส/สไตลัส: วาดด้วยนิ้วได้ ไม่ scroll ระหว่างวาด, ปากกากับนิ้วพร้อมกันนิ้วไม่รบกวน |
| F8 | B4+C1 | enum REDACTION + export route (pdf-lib + fontkit + Sarabun) | – | `/sync-db`; วาด redaction บังข้อความบนจอ; export แล้วเปิดไฟล์ใหม่: ทุกชนิดติดไปถูกหน้าถูกตำแหน่ง, redaction เป็นดำทึบทับข้อความจริง; **hash ไฟล์ต้นฉบับใน `public/` ไม่เปลี่ยน**; ไม่มี Sarabun → export ยังสำเร็จ (fallback กรอบ/หมุด) |
| F9 | A4 | ตาราง `annotation_replies` + actions + popover เธรด | – | `/sync-db`; ตอบ comment เป็นเธรดได้; แท็บ B เห็น reply realtime; ลบแม่ → replies หาย (CASCADE) |
| F10 | A5 | columns `resolved`/`resolved_at` + action + UI จาง/แยกกลุ่ม | – | `/sync-db`; toggle แล้ว pin จาง/เขียว + sidebar แยกกลุ่ม; ใครก็ได้ toggle (D6); realtime |
| F12 | C4 | อ่าน `?page=`/`?ann=` ตอน mount | F1 | เปิด URL → เลื่อนไปหน้า/เลือก annotation นั้น; ไฟล์ 44 หน้ากระโดดหน้าไกลได้ไม่ jump (placeholder ขนาดจริง) |
| F13 | A6 | ปุ่มแก้ไขข้อความ comment ใน popover | F1 | เจ้าของแก้ได้; ของคนอื่นไม่มีปุ่มแก้; แท็บ B เห็นข้อความใหม่ realtime |
| F14 | D1 | `searchAnnotations` + กล่องค้นหา sidebar | – | หาคำไทยเจอทั้ง highlight text และ comment message; คลิกผลลัพธ์ → ไปตำแหน่ง + เลือก; ไม่พบแสดง "ไม่พบ" |
| F15 | D2 | หน้า `/annotate/[id]/summary` | F12 | แสดงทุก annotation จัดกลุ่มหน้า; ปุ่มกระโดด (deep link) ทำงานจริง |
| F16 | D3 | route export CSV | F15 | เปิด CSV ใน Excel ภาษาไทยอ่านออก (BOM) |
| F17 | B2 | enum TEXTBOX + tool + render + export | F8 | `/sync-db`; พิมพ์ข้อความลงหน้าได้; fontSize % → px ถูกทุก zoom; เจ้าของแก้ได้; ติดไปกับ export |
| F18 | B5 | enum STAMP + preset + tool + export | F8 | `/sync-db`; เลือก preset คลิกวางได้; scale % ถูกทุก zoom; ติดไปกับ export |
| F19 | A7 | ตาราง `annotation_versions` + snapshot + restore | F1 | `/sync-db`; แก้ 2 ครั้ง → ประวัติ 2 versions; ลบ → เกิด version kind delete และ**ไม่หาย**ไปกับ CASCADE; restore → กลับมาเป็น id ใหม่ + realtime |
Dependencies เพิ่มเติม: F3 ← F2 · F12/F13/F19 ← F1 · F15 ← F12 · F16 ← F15 · F17/F18 ← F8 (ใช้ util วาดข้อความ/ฟอนต์จาก export)
---
## 5. Edge Cases ที่ออกแบบไว้ล่วงหน้า
- **Update ชน SSE:** replace by id เป็น idempotent — SSE `update` มาถึงก่อน/หลัง action เสร็จได้ผลเดียวกัน; ห้ามเพิ่ม logic กัน duplicate เกิน dedupe/replace ด้วย id (คงคู่กับ `useOptimistic` เหมือนเดิม)
- **Undo ข้ามผู้ใช้:** history เก็บเฉพาะ action ของตัวเอง — event realtime ของคนอื่นไม่ push เข้า stack; undo(create) ที่ row หายไปก่อน (คนอื่นลบ) → ข้ามพร้อม console
- **Eraser กับรูปทรง:** hit-test เฉพาะเส้น stroke/ขอบ — ผ่านพื้นที่รูปทรงไม่โดนลบ
- **Selection handles กับ tool ที่ InkLayer ใช้ pointer (F6):** เมื่อ tool เป็น draw/shape/erase ให้ปิด handle มุม/ขอบกรอบ/แถบเครื่องมือของ `AnnotationSelectionLayer` (pointer-events none กลับไปเหมือน container — เช่นเดียวกับ pins) — กันลาก eraser โดน strip ขอบกรอบแล้วกลายเป็น move-drag; ตัวกรอบ selection เป็น none อยู่แล้วจึงยังมองเห็นได้ปกติ
- **ปุ่มเปลี่ยนความหนา (F1) กับเส้นที่มี `widths` (F7):** ตอนเปลี่ยน `strokeWidth` หลักของเส้น DRAW ที่มี `widths` ต้อง scale `widths` ทุกจุดตามอัตราส่วน `w / strokeWidth` เดิมด้วย — ไม่เช่นนั้น render ใช้ widths ต่อจุดล้วนแล้วปุ่มดูเหมือนใช้ไม่ได้
- **Redaction บนจอ ≠ ปิดพรางจริง:** TextLayer ยัง select ข้อความใต้ redaction ได้ (PDF ต้นฉบับไม่ถูกแก่ — non-destructive) — ความปลอดภัยจริงอยู่ที่ไฟล์ export เท่านั้น (D4) → ใส่ hint สั้น ๆ ใน UI ตอนเลือก tool redaction
- **Export กับหน้า `/Rotate`:** แปลงพิกัด % ตามมุมหมุนก่อนวาด — ทดสอบด้วย fixture ที่ pdf-lib สร้างขึ้นเอง (หมุน 90°) เพราะไฟล์ตัวอย่างใน `public/samples/` ไม่มีหน้าหมุน
- **Path traversal ใน export route:** `pdfUrl` จาก DB ต้อง resolve แล้วอยู่ใน `public/` เท่านั้น
- **Deep link กับ virtualization:** placeholder มีขนาดจริงทุกหน้า → คำนวณ absolute scroll ได้เสมอแม้หน้ายังไม่ render (กลไกเดียวกับ bookmark)
- **คีย์ลัดกับฟอร์ม:** ตรวจ `e.target` เป็น input/textarea/contenteditable → ข้ามทุก shortcut
- **Font ไทยใน export:** ไฟล์ Sarabun ขนาดใหญ่ (~100KB+) embed ครั้งเดียวต่อเอกสาร (subset ไม่จำเป็นเฟสนี้)
- **SSE event ใหม่กับ hook เดิม:** deploy พร้อมกันทั้ง server+client (single app) — ไม่มีปัญหา version skew
- **TEXTBOX/STAMP geometry (F17/F18):** `position` คือมุมซ้ายบนของกล่อง; TEXTBOX render กว้างสุด 50% ของหน้า (wrap อัตโนมัติ), STAMP กว้าง = `scale`% ของความกว้างหน้า สูง = 0.4 เท่าของความกว้าง (สีตรายาง/ข้อความคงที่แชร์ระหว่าง render กับ export ผ่าน `utils/annotation-display.ts`); translate/scale ของ F1 แตะเฉพาะ `position` — `fontSize`/`scale` คงเดิมตามแนวทาง "strokeWidth คงเดิม" ของ F1 (ลากมุม = เลื่อน position เท่านั้น); bbox ของ TEXTBOX ประมาณจากข้อความ (อักษรกว้าง ~0.6 × fontSize, wrap ที่ 50% ของหน้า — ค่าประมาณเพื่อกรอบ selection พอ ไม่ต้องตรงเป๊ะ), bbox ของ STAMP คำนวณจากสูตร render จริง; eraser (F6) hit-test เฉพาะ DRAW/SHAPE อยู่แล้วจึงไม่ลบ TEXTBOX/STAMP
- **Version snapshot (F19):** เก็บ `page_index` ของ annotation ตอน snapshot — `restoreVersion` เขียนกลับ row เดิม (หรือสร้างใหม่ **หน้าเดิม เจ้าของเดิม** เมื่อต้นทางถูกลบไปแล้ว) จากข้อมูลใน snapshot; ปุ่ม "ประวัติ" แสดงเฉพาะรายการของตัวเองฝั่ง UI ตามสเปก (action `restoreVersion(versionId)` ไม่มี guard userId ตาม signature ในแผน)
- **Dialog (fixed) ที่ render ใน sidebar (F19):** `aside.sticky` ของ sidebar สร้าง stacking context ครอบ z-index ของ dialog ไว้ ทำให้ `fixed z-50` แพ้ `textLayer` (z-[2] ระดับ root) จนปุ่มใน dialog คลิกไม่ได้ (พบตอน verify ด้วยเบราว์เซอร์จริง) → dialog ต้อง render ผ่าน **`createPortal` ไป `document.body`** พร้อม guard `typeof document === "undefined"` เพราะ Next ก็ SSR client component (เข้าถึง `document` ตอน SSR = 500)
- **F19 กู้คืนรายการที่ถูกลบ:** action รองรับ (version `kind='delete'` รอดจาก CASCADE ตาม D8) แต่ UI เข้าถึง dialog ได้เฉพาะจากปุ่ม "ประวัติ" ของรายการที่**ยังอยู่**ใน sidebar — รายการที่ถูกลบไปแล้วจึงยังไม่มีทางเข้าถึงจาก UI (ทำตามสเปก §3 F19) หากต้องการกู้คืนของที่ถูกลบ ให้เปิดงานต่อยอดใหม่
---
## 6. Invariant & Rules Sync (ทำพร้อม task — ตามกติกา CLAUDE.md: แก้ invariant ที่แผนก่อน แล้ว sync มา rules)
| Task | ไฟล์ที่ต้องอัปเดตพร้อมงาน |
|---|---|
| F1 | `CLAUDE.md` (Out of Scope: edit/ย้าย อนุญาตตามแผนนี้) · `docs/PLAN.md` §9 (หมายเหตุถูกขยายโดยแผนนี้) · `rules/realtime.md` (update event) · `rules/coordinates.md` (geometry utils) |
| F2 | `CLAUDE.md` (Out of Scope: undo/redo อนุญาต) |
| F5 / F8 / F17 / F18 | `rules/data-layer.md` (enum + union member ใหม่) · F8 เพิ่ม: `CLAUDE.md` invariant #1 หมายเหตุ "export สร้างไฟล์ใหม่ ไม่แตะต้นฉบับ" + Out of Scope export อนุญาต |
| F9 / F10 / F19 | `rules/data-layer.md` (ตาราง/columns ใหม่) · `rules/realtime.md` (replies event + resolved ใน update row) |
| F12 | `rules/pdf-viewer.md` (deep link อ่าน query ตอน mount) |
> แก้/เพิ่ม invariant ที่ไม่ได้ครอบไว้ในตารางนี้ → อัปเดต **ไฟล์นี้** ก่อนเสมอ แล้วค่อยแตะโค้ด/rules
---
## 7. ฟีเจอร์เสริมหลังแผนเดิม (request นอก F1–F19)
### N1 · โน้ตคำอธิบาย (note) ฝังใน annotation — เช่น ลากเส้นแล้ว บอกเหตุผลที่วาดเส้นนั้น
- **ข้อมูล:** field `note?: string` ใน JSONB `data` ของทุกชนิด**ยกเว้น COMMENT** (`HighlightData`/`DrawData`/`ShapeData` ทั้ง 2 variant/`RedactionData`/`TextboxData`/`StampData`) — COMMENT ไม่มีเพราะ `message` ทำหน้าที่นี้อยู่แล้ว — validator (`vNote`) trim ค่า ว่าง/ช่องว่างล้วน = ไม่มีโน้ต (ถูกตัดออกจาก object ก่อนเก็บ)
- **แก้ไข:** ใน sidebar รายการหมายเหตุ — ปุ่ม StickyNote ของรายการตัวเอง (ยกเว้น COMMENT) เปิด textarea inline ใต้แถว (Ctrl/Cmd+Enter บันทึก, Esc ยกเลิก) → ผ่าน `updateAnnotation` เดิม (optimistic + version snapshot F19 + undo/redo F19/F2 อัตโนมัติ) เห็นการเปลี่ยนแปลง realtime ผ่าน SSE update event
- **แสดงผล (ทุกคนเห็น):** sidebar (ใต้รายการ, clamp 2 บรรทัด) · หน้าสรุป F15 (บรรทัด "โน้ต: …") · export CSV F16 (คอลัมน์ "โน้ต") — อ่านค่าผ่าน `annotationNote(a)` ใน `utils/annotation-display.ts` (ใช้ร่วมทั้ง 3 ที่ตาม pattern เดิม)
- **ค้นหา (F14):** `searchAnnotations` เพิ่มเงื่อนไข `data->>'note' ILIKE` ควบคู่ text/message — ค้นคำในโน้ตเจอด้วย
- **Tooltip บนหน้า:** ชี้ mouse เหนือ annotation ใด ๆ บนหน้า (ยกเว้น COMMENT ที่มี popover ของ pin) → tooltip "ชนิด · ชื่อผู้ทำ + โน้ต (ถ้ามี)" ลอยข้าง bbox — ตรวจด้วย `hitTestAnnotation` ชุดเดียวกับการคลิกเลือกบน `onMouseMove` ของ page wrapper, เก็บ state เป็น id เดียว (`hoveredId`) เปลี่ยนเฉพาะเมื่อรายการเปลี่ยน (ไม่ re-render ทุก move, hit-test เดินจากท้าย array โดยไม่สร้าง array ใหม่), `pointer-events: none` + ปิดช่วง tool ที่รับ pointer (draw/shape/erase/redact/pan), ช่วงกดปุ่มเมาส์ค้าง (`e.buttons !== 0`) และช่วง popover ของ comment pin เปิดอยู่ (`openPinId !== null` — กัน tooltip ลอยทับ popover)
- **ประวัติแก้ไขล่าสุด (ถอดกลับ):** เคยทดลองเป็นเธรด reply บนรายการที่เลือก (reuse `annotation_replies` + ปุ่ม 💬) แล้วถูกถอดออกตาม feedback — ใช้ field `note` ฝังใน annotation แทน
### 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 ทุกครั้ง** — ชื่อ (เว้นว่าง = "หน้า N") + โน้ต (ไม่บังคับ) + select "วางที่" (ระดับบนสุด / ใต้หัวข้อระดับ 1–2 — ใครก็วางใต้หัวข้อของคนอื่นได้) — ปิดด้วย บันทึก/ยกเลิก/Enter(title)/Esc/คลิกหน้า · ทางลัด: ปุ่ม **+** ที่หัวข้อระดับ 1–2 ใน sidebar = bookmark **หน้าที่ viewer ดูอยู่** เข้าใต้หัวข้อนั้นทันที (ชื่อ auto "หน้า N")
- **ชื่อ default จากข้อความที่คลุม:** viewer จำข้อความที่เพิ่งคลุมบนหน้าไว้ตอน `mouseup` (ทุก tool — คลิกปุ่ม 🔖 ใน header ไม่ผ่าน mouseup ของหน้าค่าจึงไม่โดนเคลียร์) → เปิด popup แล้วชื่อ default = ข้อความนั้น (ยุบ whitespace, ตัดยาว ≤ 120 ตัวอักษรผ่าน `normalizeBookmarkTitle`)
- **ปุ่ม bookmark ในแผง selection (F1 toolbar):** ปุ่ม 🔖 ท้ายแผงเครื่องมือของ annotation ที่เลือก → เปิด popup ที่หน้า + yOffset ตามขอบบน bbox ของรายการนั้น โดยชื่อ default = ข้อความของรายการ (HIGHLIGHT/TEXTBOX/STAMP/COMMENT — ชนิดที่ไม่มีข้อความจริงเว้นว่าง)
- **แก้ไข (✏ เจ้าของเท่านั้น):** inline ใน sidebar — แก้ชื่อ + โน้ต + ย้ายหัวข้อ (select กรองเป้าหมายที่ย้ายได้จริงตามเกณฑ์ depth+height และตัดตัวเอง/ลูกหลานออก — server ตรวจซ้ำพร้อม lock)
- **การลบ:** ลบ **sub** = เหมือนเดิม · ลบ **หัวข้อที่มี sub** = sub ทั้งหมด**ขยับขึ้นระดับบนสุด** (ไม่มีข้อมูลหาย) — re-parent + delete ใน transaction เดียว + publish update event ต่อลูก
- **การแสดง (sidebar tab Bookmarks):** ต้นไม้ 3 ระดับ (indent ตามระดับ 1/2/3), หัวข้อมี chevron กาง/หุ่ม (state local ต่อ client) + จำนวน sub `(n)`, โน้ตแสดงใต้ชื่อ (clamp 2 บรรทัด), กดชื่อ = กระโดดหน้า (หัวข้อใหญ่ชี้หน้าของตัวเองได้), เรียงตาม createdAt
- **Realtime:** SSE event ของ bookmarks เพิ่ม action **update** (แก้ชื่อ/โน้ต/ย้าย + ลูกที่ถูกยกขึ้น root ตอนแม่ถูกลบ) — client replace by id
- **Out of scope ของ N2:** drag เพื่อจัดลำดับ/ย้าย, sub ลึกเกิน 3 ระดับ, bookmark ในหน้าสรุป/CSV

255
docs/PLAN.md Normal file
View file

@ -0,0 +1,255 @@
# 📋 Implementation Plan — Jude's Note (PDF Annotation System for e-Court)
> แผนงานก่อนเริ่ม implement โปรเจกต์ Next.js แยกต่างหากจากโปรเจกต์ Nuxt (dms-main)
---
## 0. สมมติฐาน & ข้อตกลงเบื้องต้น (Assumptions)
| # | หัวข้อ | ข้อตกลง |
|---|--------|---------|
| A1 | **ตำแหน่งโปรเจกต์** | `D:\dms\judes-note\` — directory แยกต่างหาก (ไม่ใช่ subdirectory ของ dms-main) เพื่อไม่ชนกับโปรเจกต์ Nuxt ที่มีโฟลเดอร์ `app/` อยู่แล้ว |
| A2 | **Framework** | Next.js 14 (App Router) + TypeScript strict |
| A3 | **PDF Renderer** | `pdfjs-dist` เวอร์ชัน **v4** (`^4.10.38`) — ใช้ built-in `TextLayer` class; worker ตั้ง path ตรง ๆ: คัดลอก `pdf.worker.min.mjs` (จาก `node_modules/pdfjs-dist/build/`) มาไว้ที่ `public/` ด้วย **postinstall script** แล้วตั้ง `pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"` — **ไม่ใช้** `new URL(..., import.meta.url)` เพราะ v4 เป็นไฟล์ `.mjs` และมักชนกับ Turbopack/webpack ใน Next 14 |
| A4 | **Identity** | สเปกไม่ระบุระบบ auth → ใช้ **anonymous user** สร้างครั้งเดียวเก็บใน `localStorage` (`userId` = UUID, `userName` = "ผู้ใช้ XXXX") เพื่อเติม field `userId`/`userName` ในตาราง |
| A5 | **ไม่มีไฟล์ปุ่ม Upload** | หน้า `/` อ่านรายการจาก DB เท่านั้น (ตามสเปก) — sample PDF เก็บใน `public/samples/` แล้ว seed ด้วย SQL ใน README (รายละเอียดด้านล่าง) |
| A6 | **ทุก annotation ลบได้** | แสดงปุ่มลบเฉพาะรายการของตัวเอง (`userId` ตรงกัน) — ไม่มี role/permission ในเฟสนี้ |
| A7 | **Database & Realtime (ปรับปรุง 2026-09-08)** | PostgreSQL แบบ **plain container เดียว** (`judes-note-db`, Docker, image `postgres:17-alpine`, พอร์ต 54322) — **เลิกใช้ Supabase stack ทั้งหมด** (ไม่มี CLI/Kong/realtime container) เพราะโปรเจกต์ใช้จริงแค่ DB + sync; realtime sync ใช้ **SSE (Server-Sent Events) + pub/sub in-process** แทน Supabase Realtime |
| A8 | **Event ของ SSE เป็น DTO ตรง ๆ (2026-09-08)** | Server Actions publish event ผ่าน `lib/annotate-events.ts` หลังเขียน DB สำเร็จ — payload เป็น DTO (camelCase + ISO string) โดยตรง **ไม่มีการแปลง snake_case** เหมือนสมัยใช้ Supabase Realtime |
### ไฟล์ตัวอย่าง (อยู่ที่ `public/samples/` แล้ว)
| ไฟล์ | ที่มา | หน้า | ขนาด | ลักษณะ |
|---|---|---|---|---|
| `01012-672427-4f-690609-0000840227.pdf` | [judgement-api.admincourt.go.th](https://judgement-api.admincourt.go.th/v1/media/uploads/CaseFile/view/01012-672427-4f-690609-0000840227.pdf) | 15 | ~1.8 MB | **สแกนภาพล้วน** — ไม่มี text layer |
| `01012-672427-1f-690609-0000840223.pdf` | [judgement-api.admincourt.go.th](https://judgement-api.admincourt.go.th/v1/media/uploads/CaseFile/view/01012-672427-1f-690609-0000840223.pdf) | 44 | ~38 MB | **สแกนภาพล้วน** — ไม่มี text layer |
| `dashboard-report-2026-09-03.pdf` | เพิ่มโดยผู้ใช้ (2026-09-08) | 2 | ~150 KB | **text-based ภาษาไทย** (ตรวจด้วย pdfjs: 285 text items หน้า 1) — ใช้ทดสอบ HIGHLIGHT ได้ |
> ⚠️ ไฟล์คดีทั้งสองเป็นภาพสแกนทุกหน้า (ตรวจแล้ว: 0 หน้ามี `/Font`) → ใช้ทดสอบ **DRAW / COMMENT / BOOKMARK / Page Virtualization** (ไฟล์ 44 หน้า 38 MB เหมาะกับทดสอบ virtualization มาก) แต่**ทดสอบ HIGHLIGHT ไม่ได้** เพราะ pdfjs สร้าง TextLayer ไม่ได้เมื่อไม่มี text
>
> **สถานะ (2026-09-08): มี text-based PDF แล้ว** — `dashboard-report-2026-09-03.pdf` (เพิ่มโดยผู้ใช้) ใช้ทดสอบ HIGHLIGHT ได้ ปัญหาเดิมเกี่ยวกับการหาไฟล์ text-based ยุติแล้ว
---
## 1. System Architecture — Multi-Layer Non-Destructive Overlay
หนึ่งหน้า PDF = หนึ่ง `<div data-page="N">` ที่ซ้อน layer ทั้งหมดไว้ใน viewport เดียวกัน **ไฟล์ PDF ต้นฉบับไม่ถูกแก้ไข** (Non-Destructive) — annotation เก็บใน PostgreSQL เป็นพิกัด % เท่านั้น
```
┌─ div.relative[data-page=N] (ขนาด = viewport.width × viewport.height)
│
├── z-1 <canvas> ← pdfjs page.render() | pointer-events: none เสมอ
├── z-2 div.highlight-layer ← วาด rects จาก HIGHLIGHT % | pointer-events: none เสมอ
│ (mix-blend-mode: multiply)
├── z-2 div.textLayer ← pdfjs TextLayer (span โปร่งใส) | pointer-events: สลับตาม tool
├── z-3 <svg> InkLayer ← freehand DRAW + live stroke | pointer-events: สลับตาม tool
└── z-4 div.comment-pins ← Sticky note pin COMMENT | pointer-events: auto (ยกเว้นตอน draw)
```
### ตารางสลับ pointer-events ตาม Tool
| Tool | Base (canvas) | TextLayer (z-2) | InkLayer (z-3) | Pins (z-4) | พฤติกรรม |
|------|:---:|:---:|:---:|:---:|----------|
| `select` (ค่าเริ่มต้น) | none | **auto** | none | auto | คลุมดำข้อความได้ → เด้ง Floating Toolbar → Highlight |
| `draw` | none | **none** | **auto** | none | เมาส์วาดเส้นอิสระ (pointer capture) |
| `comment` | none | auto* | none | auto | คลิกจุดบนหน้า → เปิด composer → บันทึก COMMENT |
| `shape` (PLAN-FEAT.md F5) | none | **none** | **auto** | none | ลากคลุมวาดรูปทรง rect/ellipse/line/arrow บน InkLayer (เหมือน draw) |
| `erase` (PLAN-FEAT.md F6) | none | **none** | **auto** | none | ลากผ่านเส้น/รูปทรงของตัวเองบน InkLayer → hit-test โดน = ลบทันที (comment/highlight ไม่เกี่ยว) |
\* โหมด comment ให้ TextLayer ยัง select ได้ ส่วนการคลิกวาง pin จับ event ที่ page wrapper (bubble ขึ้นจาก layer ล่าง)
> **จุดสำคัญ:** กฎ z-index 2 ตัว (highlight-layer กับ textLayer) ไม่ทับกันเพราะ DOM order กำหนด — textLayer อยู่ทีหลัง = อยู่บนสุดของ z-2 → ข้อความ select ได้และ highlight ซ้อนหลัง text เสมอ (text เป็นสีโปร่งใสอยู่แล้ว)
---
## 2. Coordinate System — Relative Percent (%)
- **ทุกพิกัดใน JSONB** (`rects`, `paths`, `position`) เป็น **% ของขนาดหน้า PDF ณ scale ปัจจุบัน ณ ตอนวาด** → นำไป render ที่ zoom/จอเล็กจอใหญ่ได้ไม่เบี้ยว
- `strokeWidth` ของ DRAW เก็บเป็น **% ของความกว้างหน้า** (เช่น `0.4` = 0.4% ของ width) → แปลงกลับเป็น px ตอน render
- `yOffset` ของ bookmark เก็บเป็น **% ของความสูงหน้า** (0–100)
### กลไกแปลง (utils/coordinates.ts)
```
Pixel (event/getBoundingClientRect) ──÷size×100──▶ Percent (เก็บลง DB)
Percent (จาก DB) ──×size÷100──▶ Pixel (render ลง SVG/div)
```
### เหตุผลที่ InkLayer ใช้ SVG **แบบไม่มี viewBox**
กลเล่น `viewBox="0 0 100 100" preserveAspectRatio="none"` จะทำให้ **ความหนาเส้นบิดเบี้ยว** (scale แกน x,y ไม่เท่ากัน) → จึง render ในระบบ px จริง: แปลง `% → px` ด้วยขนาดที่วัดจาก `ResizeObserver` แล้ววาด `<path>` ตรง ๆ (ไม่ต้องใช้ `vector-effect`)
---
## 3. Database Schema (Drizzle ORM + PostgreSQL)
ไฟล์ `db/schema.ts`
### ตาราง `files`
| column | type | หมายเหตุ |
|---|---|---|
| id | uuid PK `defaultRandom()` | |
| title | text NOT NULL | |
| description | text | nullable |
| pdf_url | text NOT NULL | |
| created_at | timestamptz `defaultNow()` | |
### Enum `annotation_type` = `'HIGHLIGHT' | 'DRAW' | 'COMMENT'`
### ตาราง `annotations`
| column | type | หมายเหตุ |
|---|---|---|
| id | uuid PK | |
| file_id | uuid FK → files ON DELETE CASCADE | |
| page_index | integer NOT NULL | 0-based |
| user_id | text NOT NULL | anonymous id จาก localStorage |
| user_name | text NOT NULL | |
| type | annotation_type NOT NULL | |
| data | jsonb NOT NULL `$type<AnnotationData>()` | **strict typed union** (ด้านล่าง) |
| created_at / updated_at | timestamptz | |
Index: `(file_id, page_index)` สำหรับ query หลักตอนเปิด viewer
### ตาราง `bookmarks`
| column | type | หมายเหตุ |
|---|---|---|
| id | uuid PK | |
| file_id | uuid FK → files CASCADE | |
| page_index | integer NOT NULL | |
| user_id | text NOT NULL | |
| title | text NOT NULL | |
| y_offset | double precision NOT NULL | % ของความสูงหน้า (0–100) |
| created_at | timestamptz | |
### Strict JSONB Union (export เป็น TypeScript types)
```ts
type Rect = { x: number; y: number; width: number; height: number }; // ทุกค่าเป็น %
type HighlightData = { text: string; color: string; rects: Rect[] };
type DrawData = { strokeColor: string; strokeWidth: number; paths: { x: number; y: number }[] }; // % + strokeWidth เป็น % ของ width
type CommentData = { position: { x: number; y: number }; message: string }; // %
type AnnotationData = HighlightData | DrawData | CommentData; // เลือกตาม column `type` (discriminated โดย enum)
```
→ พร้อม **DTO types** (`AnnotationDTO`, `BookmarkDTO`) ที่ timestamp เป็น ISO string เพื่อให้ server → client → realtime payload ใช้ type เดียวกัน
---
## 4. Data Flow & Realtime Sync
```
Client A วาด ──▶ Server Action createAnnotation() ──▶ INSERT PostgreSQL
│ (เขียนสำเร็จ)
▼
publish ใน process (lib/annotate-events.ts)
│
▼
SSE /api/annotations/stream?fileId=<uuid> ──▶ Client B ได้ event ทันที
```
- **เขียน:** ผ่าน Server Actions เท่านั้น (ต่อ `DATABASE_URL` ตรง ๆ) — และ**หลังเขียนสำเร็จทุกครั้ง ต้อง publish event** ให้ `lib/annotate-events.ts`
- **อ่าน/ sync:** `useAnnotationRealtime` hook เปิด `EventSource` ไปที่ SSE route `/api/annotations/stream?fileId=<id>` — payload เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ ไม่ต้องแปลง; `EventSource` ต่อใหม่อัตโนมัติเมื่อ connection ขาด (ห้ามทำ retry เอง)
- pub/sub เป็น **in-memory ของ Next.js process เดียว** (dev / `next start` ธรรมดา) — หากอนาคตรันหลาย instance ต้องเพิ่มตัวกลาง (Redis pub/sub หรือ Postgres `LISTEN/NOTIFY`) ก่อน
- **Optimistic update ด้วย `useOptimistic` (React 18):** ตอนผู้ใช้ปล่อยเมาส์ → `startTransition(() => { addOptimistic(row ชั่วคราว); return createAnnotation(...) })` → React ถอด optimistic row ออกอัตโนมัติเมื่อ action เสร็จ และ **rollback ให้เองหาก action ล้มเหลว** (ไม่ต้องจัดการ id `temp-*` เอง) → หาก SSE event มาถึงก่อน action เสร็จ จะเห็น duplicate ชั่วขณะแล้วหายเองเมื่อ optimistic row ถูกถอด (self-healing) — hook ยัง dedupe ด้วย id กับ state ปัจจุบันอยู่
---
## 5. โครงสร้างไฟล์ทั้งหมด
```
judes-note/
├── PLAN.md ← ไฟล์นี้
├── README.md ← วิธี setup (docker run postgres, env, drizzle push, seed, run)
├── package.json / tsconfig.json / next.config.mjs
├── tailwind.config.ts / postcss.config.js / drizzle.config.ts
├── .env.example
├── db/
│ ├── schema.ts ← ตาราง + Strict JSONB types + DTO types + mapper toDTO
│ └── index.ts ← postgres.js client + drizzle (DATABASE_URL → container judes-note-db, prepare:false)
├── lib/
│ └── annotate-events.ts ← pub/sub in-process (Server Actions publish → SSE route subscribe)
├── utils/
│ ├── coordinates.ts ← pixel⇄% (point/rect/path) + getPointerPercent + clamp + mergeRectsByLine (รวบ rects บรรทัดเดียวกันเหลือ 1 rect/บรรทัดก่อนบันทึก)
│ └── user.ts ← anonymous user identity (localStorage)
├── hooks/
│ └── useAnnotationRealtime.ts ← EventSource ต่อ SSE stream + dedupe + expose setters
├── app/
│ ├── layout.tsx / globals.css ← globals.css มี CSS ของ .textLayer ที่ pdfjs ต้องการ
│ ├── page.tsx ← รายการไฟล์ (server component, drizzle query)
│ ├── actions/annotations.ts ← Server Actions: CRUD annotations + bookmarks (+ validation guards
│ │ + publish event หลังเขียนสำเร็จทุกครั้ง)
│ ├── api/annotations/stream/route.ts ← SSE endpoint (GET ?fileId=) ยิง event จาก lib/annotate-events
│ └── annotate/[id]/page.tsx ← โหลด file + initial data → ส่งเข้า <PDFViewer/>
├── components/
│ ├── pdf/
│ │ ├── PDFViewer.tsx ← client root: โหลดเอกสาร, tool state, zoom, selection→highlight,
│ │ │ bookmark, comment composer, sidebar layout, Page Virtualization
│ │ │ (IntersectionObserver render เฉพาะหน้าใน viewport ± 1 หน้า)
│ │ ├── InkLayer.tsx ← SVG freehand (pointer capture, live stroke, ResizeObserver re-render)
│ │ └── HighlightToolbar.tsx ← Floating popup เลือกสี (fixed ตำแหน่งจาก selection rect)
│ └── sidebar/
│ └── AnnotationSidebar.tsx ← แท็บ Bookmarks / Annotations + ปุ่ม scroll-to + ลบรายการตัวเอง
```
---
## 6. พฤติกรรมหลักของแต่ละ Feature
### 6.1 Text Selection → Highlight
1. Tool `select` → คลุมดำบน TextLayer (span โปร่งใส + `::selection`)
2. `mouseup` → `window.getSelection()` → `range.getClientRects()` → กรอง rect กว้าง 0 → **`mergeRectsByLine`: รวบ rects ที่ y/height ใกล้กัน (บรรทัดเดียวกัน) เหลือ 1 rect ต่อบรรทัด กัน JSONB บวม** → แปลงเป็น % เทียบ `pageEl.getBoundingClientRect()`
3. เด้ง `HighlightToolbar` (position: fixed ที่มุม rect แรก) เลือกสี 5 สี
4. กดสี → `createAnnotation('HIGHLIGHT', { text, color, rects })` + optimistic
5. Render: div สีทึบนิดหน่อย + `mix-blend-mode: multiply` ที่ z-2 (อยู่หลัง text เสมอ)
### 6.2 Freehand Drawing (InkLayer)
1. Tool `draw` → svg รับ `pointerdown` → `setPointerCapture` (เส้นไม่ขาดเวลาเลื่อนออกนอก page)
2. เก็บจุดทั้ง px (render) และ % (save) — สร้าง path `M x y L ...`
3. `pointerup` → ถ้าจุด ≥ 2 → `createAnnotation('DRAW', { strokeColor, strokeWidth, paths })`
4. Path เดิมจาก DB แปลง % → px ด้วยขนาดที่ ResizeObserver วัด → re-render เมื่อ resize/zoom
### 6.3 Comment (Sticky Note)
1. Tool `comment` → คลิกตำแหน่งบนหน้า → เปิด composer เล็ก ๆ (fixed) ใส่ข้อความ
2. บันทึก → วาด pin (icon MessageSquare) ที่ตำแหน่ง % → คลิก pin เพื่อดู popover (ข้อความ + ผู้เขียน + เวลา)
### 6.4 Bookmark
1. ปุ่ม BookmarkPlus บน toolbar → หา "หน้าปัจจุบัน" (page ที่ midpoint ใกล้ viewport center สุด จาก scroll listener)
2. `yOffset` = ตำแหน่งกึ่งกลาง viewport เทียบหน้านั้น (% ของความสูงหน้า)
3. Sidebar คลิก bookmark → `window.scrollTo` (smooth) ไปที่ `pageTop + pageHeight × yOffset%` (คำนวณ absolute ก่อนแล้ว smooth-scroll ครั้งเดียว ไม่ scroll ซ้อนสองจังหวะ)
---
## 7. ลำดับการ Implement (ตาม Task Output Order) + เกณฑ์ตรวจ
| # | งาน | ไฟล์ | Verify |
|---|---|---|---|
| 1 | Scaffolding + Schema | `package.json`, config ทั้งหมด, `db/schema.ts`, `db/index.ts` | `tsc` ผ่าน, `drizzle-kit push` สร้างตารางได้ |
| 2 | Coordinate helpers | `utils/coordinates.ts`, `utils/user.ts` | Unit เช็คตาโค้ด: 1000px → 50% ที่ width 2000 (round-trip ต้องได้ค่าเดิม ± ε); mergeRectsByLine รวม rects หลายอันบรรทัดเดียวกัน → เหลือ 1 rect ครอบข้อความเดิม |
| 3 | Server Actions | `app/actions/annotations.ts` | เรียก action สร้าง/ลบ annotation แล้ว row ใน DB เปลี่ยนตาม; input ผิด type โดน reject |
| 4 | Realtime hook | `hooks/useAnnotationRealtime.ts` | เปิด 2 browser แท็บ — แท็บ A highlight แล้วแท็บ B เห็นภายใน ~1s โดยไม่ refresh |
| 5 | UI หลัก | `globals.css`, `layout.tsx`, `page.tsx`, `annotate/[id]/page.tsx`, `PDFViewer.tsx`, `InkLayer.tsx`, `HighlightToolbar.tsx`, `AnnotationSidebar.tsx` | คลุมดำ→toolbar→สีติดหน้า / วาดเส้นลื่น ไม่เบี้ยวตอน zoom / bookmark กดแล้ว scroll ลื่น / pin comment แสดงถูกตำแหน่ง / PDF ยาว 100 หน้า เลื่อนลื่น (canvas ของหน้าที่พ้นจอถูก unmount) |
| 6 | README | `README.md` | ทำตามขั้นตอนจากศูนย์แล้วรัน `npm run dev` ได้ |
---
## 8. Edge Cases ที่ออกแบบไว้ล่วงหน้า
- **TextLayer ต้อง re-render ทุกครั้งที่ zoom** (scale เปลี่ยน = viewport เปลี่ยน) → effect dep `[doc, pageNumber, scale]` + cancel render task ตอน unmount กัน memory leak
- **Canvas คมชัดบนจอ Retina** → `canvas.width × devicePixelRatio` + `transform: [dpr,0,0,dpr,0,0]` แต่ `style.width` คง viewport px
- **Selection ข้ามหน้า** → ยอมรับเฉพาะ rects ใน page เดียว (หา page จาก `commonAncestorContainer.closest('[data-page]')`) — สเปกเก็บ `pageIndex` ค่าเดียวต่อ annotation
- **SSE event ชน optimistic row** → duplicate ชั่วขณะหายเองเมื่อ `useOptimistic` ถอด row + dedupe ด้วย id ใน hook (ดู §4)
- **Server component ส่ง Date ไม่ได้ตรง ๆ กับ realtime string** → normalize เป็น ISO string DTO ทั้งระบบ
- **pdfjs บน SSR** → `dynamic import("pdfjs-dist")` ภายใน useEffect เท่านั้น (ไม่ import top-level ใน module scope ที่ server evaluate ด้วย)
- **PDF ยาว (20–100+ หน้า): Page Virtualization** → `IntersectionObserver` ใน PDFViewer render เฉพาะหน้าใน viewport ± 1 หน้า (canvas + TextLayer + InkLayer); หน้าที่พ้นจอเก็บเป็น placeholder `div` **ขนาดจริง** (วัดจาก `pdfPage.getViewport()` ทุกหน้าตอนโหลดครั้งเดียว — cost ถูก) เพื่อไม่ให้ scroll jump และให้ bookmark ยังคำนวณ absolute scroll ได้ แล้ว unmount layer ทั้งหมดของหน้านั้นคืน memory — annotation ของหน้าที่ยังไม่ render อยู่ใน state ตามปกติ จะแสดงเมื่อหน้าเข้าจอ
- **pdf.worker v4 เป็นไฟล์ `.mjs`** → คัดลอก `pdf.worker.min.mjs` จาก `node_modules/pdfjs-dist/build/` ไป `public/` ด้วย **postinstall script** (version ตรงกับ lockfile เสมอ) และตั้ง `pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"` — อย่าใช้ `new URL(..., import.meta.url)` ซึ่งมักชน Turbopack/webpack ใน Next 14
---
## 9. ขอบเขตที่ **ไม่** ทำในเฟสนี้ (Out of Scope)
- ระบบ login/roles (ใช้ anonymous identity)
- แก้ไข/ย้าย annotation ที่วาดแล้ว (มีเฉพาะสร้าง + ลบ) — **ข้อนี้ถูกขยายโดย `docs/PLAN-FEAT.md` F1**: อนุญาตแก้ไข `data` (ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา) ของตัวเอง ในหน้าเดียวกัน ผ่าน `updateAnnotation`
- Undo/redo, export PDF ที่ฝัง annotation
- แสดงรายชื่อ "ใครออนไลน์อยู่" (presence)
หากต้องการเพิ่มข้อใด กรุณาบอกก่อนเริ่ม implement

11
drizzle.config.ts Normal file
View file

@ -0,0 +1,11 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});

View file

@ -0,0 +1,83 @@
import { useEffect, useState } from "react";
import type { AnnotationDTO, BookmarkDTO, ReplyDTO } from "@/db/schema";
import type { AnnotateEvent } from "@/lib/annotate-events";
// SSE sync (PLAN.md §4, rules/realtime.md)
// - เปิด EventSource ไป /api/annotations/stream?fileId=<id> — reconnect อัตโนมัติเมื่อขาด (ห้ามทำ retry เอง)
// - event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ — ไม่มีการแปลง
// - dedupe ด้วย id เท่านั้น (คู่กับ useOptimistic ใน PDFViewer — PLAN.md §4)
export function useAnnotationRealtime(
fileId: string,
initialAnnotations: AnnotationDTO[],
initialBookmarks: BookmarkDTO[],
initialReplies: ReplyDTO[],
) {
const [annotations, setAnnotations] = useState(initialAnnotations);
const [bookmarks, setBookmarks] = useState(initialBookmarks);
const [replies, setReplies] = useState(initialReplies);
useEffect(() => {
const es = new EventSource(`/api/annotations/stream?fileId=${fileId}`);
es.onmessage = (e: MessageEvent<string>) => {
let ev: AnnotateEvent;
try {
ev = JSON.parse(e.data) as AnnotateEvent;
} catch {
return;
}
// กรอง message ที่ไม่ใช่ annotate event (เช่น { type: "connected" })
if (typeof ev.table !== "string" || typeof ev.action !== "string") return;
if (ev.table === "annotations") {
if (ev.action === "create") {
setAnnotations((prev) =>
prev.some((a) => a.id === ev.row.id) ? prev : [...prev, ev.row],
);
} else if (ev.action === "update") {
// replace ตาม id — idempotent: event มาก่อน/หลัง optimistic ก็ได้ผลเดียวกัน (PLAN-FEAT.md §5)
setAnnotations((prev) =>
prev.some((a) => a.id === ev.row.id)
? prev.map((a) => (a.id === ev.row.id ? ev.row : a))
: prev,
);
} else {
setAnnotations((prev) => prev.filter((a) => a.id !== ev.id));
// ลบแม่ → replies หายตาม CASCADE ของ DB — เก็บ state ให้เท่ากันด้วย (F9)
setReplies((prev) => prev.filter((r) => r.annotationId !== ev.id));
}
} else if (ev.table === "replies") {
if (ev.action === "create") {
setReplies((prev) =>
prev.some((r) => r.id === ev.row.id) ? prev : [...prev, ev.row],
);
} else {
setReplies((prev) => prev.filter((r) => r.id !== ev.id));
}
} else {
if (ev.action === "create") {
setBookmarks((prev) =>
prev.some((b) => b.id === ev.row.id) ? prev : [...prev, ev.row],
);
} else if (ev.action === "update") {
// N2: แก้ชื่อ/โน้ต/ย้ายหัวข้อ (รวมลูกที่ถูกยกขึ้น root ตอนแม่ถูกลบ) — replace by id
setBookmarks((prev) =>
prev.some((b) => b.id === ev.row.id)
? prev.map((b) => (b.id === ev.row.id ? ev.row : b))
: prev,
);
} else {
setBookmarks((prev) => prev.filter((b) => b.id !== ev.id));
}
}
};
return () => {
es.close();
};
}, [fileId]);
return { annotations, bookmarks, replies, setAnnotations, setBookmarks, setReplies };
}

60
hooks/useHistory.ts Normal file
View file

@ -0,0 +1,60 @@
"use client";
import { useCallback, useRef, useState } from "react";
// Undo/Redo (PLAN-FEAT.md §3 F2, D10) — history ฝั่ง client ต่อ session ต่อผู้ใช้
// ไม่เก็บใน DB; push เฉพาะ action ของตัวเองที่สำเร็จเท่านั้น (event ของคนอื่นไม่เกี่ยว)
// ตัว entry เก็บ closure ที่เรียก Server Action + sync state เอง — id ของ annotation
// ที่ "สร้างใหม่" หลัง undo ต้องส่งต่อให้ redo ได้ จึงให้ entry เป็น object ที่แก้ field ได้
export type HistoryEntry = {
undo: () => Promise<void>;
redo: () => Promise<void>;
};
export function useHistory() {
// source of truth เป็น ref — state ที่เปลี่ยนแค่ไว้ trigger re-render ปุ่ม disabled
const undoRef = useRef<HistoryEntry[]>([]);
const redoRef = useRef<HistoryEntry[]>([]);
const [, bump] = useState(0);
/** บันทึก action ที่สำเร็จ — action ใหม่เคลียร์ redo stack เสมอ */
const push = useCallback((entry: HistoryEntry) => {
undoRef.current.push(entry);
redoRef.current = [];
bump((n) => n + 1);
}, []);
const undo = useCallback(async () => {
const entry = undoRef.current.pop();
if (!entry) return;
try {
await entry.undo();
redoRef.current.push(entry);
} catch (err) {
// เช่น undo ของ create แต่ row ถูกลบไปแล้วโดยแท็บอื่น → ข้าม ไม่ crash (§5)
console.error("undo ไม่สำเร็จ — ข้ามรายการนี้", err);
}
bump((n) => n + 1);
}, []);
const redo = useCallback(async () => {
const entry = redoRef.current.pop();
if (!entry) return;
try {
await entry.redo();
undoRef.current.push(entry);
} catch (err) {
console.error("redo ไม่สำเร็จ — ข้ามรายการนี้", err);
}
bump((n) => n + 1);
}, []);
return {
push,
undo,
redo,
canUndo: undoRef.current.length > 0,
canRedo: redoRef.current.length > 0,
};
}

55
lib/annotate-events.ts Normal file
View file

@ -0,0 +1,55 @@
import type { AnnotationDTO, BookmarkDTO, ReplyDTO } from "@/db/schema";
// pub/sub in-process (PLAN.md §4): Server Actions publish หลังเขียน DB สำเร็จ
// → SSE route (app/api/annotations/stream) subscribe แล้วยิง event ให้ client ที่เปิดไฟล์เดียวกัน
// ⚠ in-memory ของ process เดียว (dev / next start ธรรมดา) — ถ้าวันหนึ่งรันหลาย instance
// ต้องเพิ่มตัวกลาง (Redis pub/sub หรือ Postgres LISTEN/NOTIFY) ก่อน ห้ามแอบ sync ผ่าน DB polling
export type AnnotateEvent =
| { table: "annotations"; action: "create"; row: AnnotationDTO }
| { table: "annotations"; action: "update"; row: AnnotationDTO }
| { table: "annotations"; action: "delete"; id: string }
| { table: "replies"; action: "create"; row: ReplyDTO }
| { table: "replies"; action: "delete"; id: string }
| { table: "bookmarks"; action: "create"; row: BookmarkDTO }
| { table: "bookmarks"; action: "update"; row: BookmarkDTO }
| { table: "bookmarks"; action: "delete"; id: string };
type Listener = (event: AnnotateEvent) => void;
// ⚠ เก็บบน globalThis เพราะ Next dev bundle "use server" (actions) กับ route handler
// แบบคนละส่วน — module-level state จะถูก instantiate เป็น 2 อินสแตนซ์แล้ว event ไม่ไหลถึงกัน
// (ยังช่วยให้ state รอดจาก HMR recompile ด้วย)
type EventsGlobal = { __annotateEventListeners?: Map<string, Set<Listener>> };
const g = globalThis as typeof globalThis & EventsGlobal;
const listenersByFile: Map<string, Set<Listener>> =
g.__annotateEventListeners ?? (g.__annotateEventListeners = new Map());
/** คืนฟังก์ชัน unsubscribe */
export function subscribeAnnotateEvents(
fileId: string,
listener: Listener,
): () => void {
let set = listenersByFile.get(fileId);
if (!set) {
set = new Set();
listenersByFile.set(fileId, set);
}
set.add(listener);
return () => {
set.delete(listener);
if (set.size === 0) listenersByFile.delete(fileId);
};
}
export function publishAnnotateEvent(fileId: string, event: AnnotateEvent): void {
const set = listenersByFile.get(fileId);
if (!set) return;
for (const listener of Array.from(set)) {
try {
listener(event);
} catch (err) {
console.error("annotate-events listener error", err);
}
}
}

24
lib/server-pdf.ts Normal file
View file

@ -0,0 +1,24 @@
import fs from "node:fs";
import path from "node:path";
// อ่าน PDF ต้นฉบับจาก public/ ฝั่ง server (F8 export)
// - **อ่านอย่างเดียว ห้ามเขียนทับ** (invariant #1) — pdf-lib load เป็นสำเนาในหน่วยความจำ
// - path guard: pdfUrl จาก DB ต้อง resolve แล้วอยู่ใน public/ เท่านั้น กัน path traversal (§5)
export class PdfPathError extends Error {}
export function resolvePublicPdfPath(pdfUrl: string): string {
const publicDir = path.resolve(process.cwd(), "public");
// ตัด root นำหน้าออกก่อน — ไม่งั้น path.resolve บน Windows ถือ "/samples/.." เป็น absolute
// ของไดรว์ (D:\samples\..) แล้วหลุดนอก publicDir
const pdfPath = path.resolve(publicDir, pdfUrl.replace(/^[/\\]+/, ""));
if (!pdfPath.startsWith(publicDir + path.sep)) {
throw new PdfPathError("เส้นทางไฟล์ไม่ถูกต้อง");
}
return pdfPath;
}
/** ไฟล์ PDF ตาม path ที่ผ่าน guard แล้ว (อ่านอย่างเดียว) */
export function readPublicPdf(pdfUrl: string): Buffer {
return fs.readFileSync(resolvePublicPdfPath(pdfUrl));
}

4
next.config.mjs Normal file
View file

@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;

4552
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

34
package.json Normal file
View file

@ -0,0 +1,34 @@
{
"name": "judes-note",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"test": "vitest run",
"start": "next start",
"postinstall": "node -e \"require('fs').copyFileSync('node_modules/pdfjs-dist/build/pdf.worker.min.mjs','public/pdf.worker.min.mjs')\""
},
"dependencies": {
"@pdf-lib/fontkit": "^1.1.1",
"drizzle-orm": "^0.45.2",
"lucide-react": "^1.42.0",
"next": "^14.2.35",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^4.10.38",
"postgres": "^3.4.9",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^26.5.0",
"@types/react": "^18.3.31",
"@types/react-dom": "^18.3.7",
"autoprefixer": "^10.5.5",
"drizzle-kit": "^0.31.10",
"postcss": "^8.5.28",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"vitest": "^5.0.0"
}
}

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

21
public/pdf.worker.min.mjs Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

BIN
public/samples/example.pdf Normal file

Binary file not shown.

16
tailwind.config.ts Normal file
View file

@ -0,0 +1,16 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
// สีหลักของระบบ (accent ทุกจุด: ปุ่ม active/action, กรอบ selection, หัวข้อหลัก)
primary: "rgb(0 51 161 / <alpha-value>)",
},
},
},
plugins: [],
};
export default config;

View file

@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import type { AnnotationDTO, AnnotationData, AnnotationType } from "@/db/schema";
import {
DRAW_PENS,
STAMP_PRESETS,
annotationNote,
annotationPreviewText,
annotationTypeLabel,
effectiveDrawOpacity,
} from "@/utils/annotation-display";
const dto = (type: AnnotationType, data: AnnotationData): AnnotationDTO => ({
id: "00000000-0000-0000-0000-000000000001",
fileId: "00000000-0000-0000-0000-000000000002",
pageIndex: 0,
userId: "user-1",
userName: "ทดสอบ",
type,
data,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
resolved: false,
resolvedAt: null,
});
describe("annotationTypeLabel", () => {
it("HIGHLIGHT ตาม style + fallback เมื่อไม่ระบุ style", () => {
expect(
annotationTypeLabel(dto("HIGHLIGHT", { text: "a", color: "#000", rects: [], style: "underline" })),
).toBe("เส้นใต้");
expect(annotationTypeLabel(dto("HIGHLIGHT", { text: "a", color: "#000", rects: [] }))).toBe("ไฮไลต์");
});
it("ครบทุกชนิดที่เหลือ", () => {
expect(annotationTypeLabel(dto("DRAW", { strokeColor: "#000", strokeWidth: 1, paths: [] }))).toBe("เส้นวาด");
expect(annotationTypeLabel(dto("COMMENT", { position: { x: 0, y: 0 }, message: "m" }))).toBe("คอมเมนต์");
expect(
annotationTypeLabel(
dto("SHAPE", { shape: "ellipse", rect: { x: 0, y: 0, width: 1, height: 1 }, strokeColor: "#000", strokeWidth: 1 }),
),
).toBe("รูปทรง (วงรี)");
expect(annotationTypeLabel(dto("REDACTION", { rect: { x: 0, y: 0, width: 1, height: 1 } }))).toBe("ปิดพราง");
expect(
annotationTypeLabel(dto("TEXTBOX", { position: { x: 0, y: 0 }, text: "t", color: "#000", fontSize: 1 })),
).toBe("กล่องข้อความ");
expect(annotationTypeLabel(dto("STAMP", { label: "สำคัญ", position: { x: 0, y: 0 }, scale: 18 }))).toBe("ตรายาง");
});
});
describe("annotationPreviewText", () => {
it("HIGHLIGHT ใช้ข้อความที่คลุม / ว่าง → placeholder", () => {
expect(
annotationPreviewText(dto("HIGHLIGHT", { text: "ข้อความที่คลุม", color: "#000", rects: [] })),
).toBe("ข้อความที่คลุม");
expect(annotationPreviewText(dto("HIGHLIGHT", { text: "", color: "#000", rects: [] }))).toBe(
"(ไม่มีข้อความ)",
);
});
it("COMMENT → message, TEXTBOX → text, STAMP → label, DRAW → ข้อความตายตัว", () => {
expect(
annotationPreviewText(dto("COMMENT", { position: { x: 0, y: 0 }, message: "ดูจุดนี้" })),
).toBe("ดูจุดนี้");
expect(
annotationPreviewText(dto("TEXTBOX", { position: { x: 0, y: 0 }, text: "หมายเหตุ", color: "#000", fontSize: 1 })),
).toBe("หมายเหตุ");
expect(
annotationPreviewText(dto("STAMP", { label: "ตรวจสอบแล้ว", position: { x: 0, y: 0 }, scale: 18 })),
).toBe("ตรวจสอบแล้ว");
expect(
annotationPreviewText(dto("DRAW", { strokeColor: "#000", strokeWidth: 1, paths: [{ x: 0, y: 0 }] })),
).toBe("เส้นวาดอิสระ");
});
});
describe("annotationNote", () => {
it("มี note → คืนค่านั้น / ไม่มี → ช่องว่าง", () => {
expect(
annotationNote(dto("DRAW", { strokeColor: "#000", strokeWidth: 1, paths: [], note: "เหตุผล" })),
).toBe("เหตุผล");
expect(annotationNote(dto("DRAW", { strokeColor: "#000", strokeWidth: 1, paths: [] }))).toBe("");
});
it("COMMENT (ไม่มี field note) → ช่องว่าง", () => {
expect(annotationNote(dto("COMMENT", { position: { x: 0, y: 0 }, message: "m" }))).toBe("");
});
});
describe("effectiveDrawOpacity", () => {
it("opacity ที่เก็บชัดชนะเสมอ", () => {
expect(effectiveDrawOpacity({ pen: "highlighter", opacity: 0.8 })).toBe(0.8);
expect(effectiveDrawOpacity({ opacity: 0.3 })).toBe(0.3);
});
it("ไม่มี opacity → fallback ตาม preset ของ pen", () => {
expect(effectiveDrawOpacity({ pen: "highlighter" })).toBe(DRAW_PENS.highlighter.opacity);
expect(effectiveDrawOpacity({ pen: "pencil" })).toBe(DRAW_PENS.pencil.opacity);
});
it("ไม่มีทั้ง pen และ opacity → pencil", () => {
expect(effectiveDrawOpacity({})).toBe(DRAW_PENS.pencil.opacity);
});
});
describe("presets ที่ sidebar/export ใช้ร่วมกัน", () => {
it("STAMP_PRESETS มีค่าตาม D7 (preset ข้อความเท่านั้น)", () => {
expect(STAMP_PRESETS).toEqual(["ตรวจสอบแล้ว", "สำคัญ"]);
});
});

View file

@ -0,0 +1,145 @@
import { describe, expect, it } from "vitest";
import {
clamp,
getPointerPercent,
mergeRectsByLine,
pathToPercent,
pathToPx,
pointToPercent,
pointToPx,
rectToPercent,
rectToPx,
toPercent,
toPx,
type Size,
} from "@/utils/coordinates";
const size: Size = { width: 800, height: 600 };
describe("toPercent / toPx", () => {
it("px → % ตามสัดส่วนขนาดที่ส่งเข้ามา", () => {
expect(toPercent(400, 800)).toBe(50);
expect(toPercent(300, 600)).toBe(50);
expect(toPercent(0, 800)).toBe(0);
});
it("roundtrip px → % → px ได้ค่าเดิม", () => {
expect(toPx(toPercent(123, 800), 800)).toBeCloseTo(123);
});
});
describe("clamp", () => {
it("ต่ำกว่า min → min, อยู่ในช่วง → ค่าเดิม, เกิน max → max", () => {
expect(clamp(-5, 0, 100)).toBe(0);
expect(clamp(42, 0, 100)).toBe(42);
expect(clamp(120, 0, 100)).toBe(100);
});
});
describe("point/rect/path converters", () => {
it("point: x อิง width, y อิง height (แกนแยกกัน)", () => {
expect(pointToPercent({ x: 400, y: 300 }, size)).toEqual({ x: 50, y: 50 });
expect(pointToPx({ x: 25, y: 75 }, size)).toEqual({ x: 200, y: 450 });
});
it("rect: แปลงครบทั้ง 4 ค่า", () => {
expect(rectToPercent({ x: 80, y: 60, width: 160, height: 120 }, size)).toEqual({
x: 10,
y: 10,
width: 20,
height: 20,
});
expect(rectToPx({ x: 10, y: 10, width: 20, height: 20 }, size)).toEqual({
x: 80,
y: 60,
width: 160,
height: 120,
});
});
it("path: แปลงทุกจุดในลิสต์", () => {
expect(
pathToPercent(
[
{ x: 0, y: 0 },
{ x: 800, y: 600 },
],
size,
),
).toEqual([
{ x: 0, y: 0 },
{ x: 100, y: 100 },
]);
expect(pathToPx([{ x: 50, y: 50 }], size)).toEqual([{ x: 400, y: 300 }]);
});
});
describe("getPointerPercent", () => {
const makeEl = (left: number, top: number, width: number, height: number) =>
({ getBoundingClientRect: () => ({ left, top, width, height }) }) as HTMLElement;
it("คิดจากขอบ element ไม่ใช่ขอบจอ", () => {
const el = makeEl(100, 50, 200, 100);
expect(getPointerPercent({ clientX: 200, clientY: 100 }, el)).toEqual({ x: 50, y: 50 });
});
it("เกินขอบ element → clamp 100 / ต่ำกว่าขอบ → clamp 0", () => {
const el = makeEl(100, 50, 200, 100);
expect(getPointerPercent({ clientX: 999, clientY: 999 }, el)).toEqual({ x: 100, y: 100 });
expect(getPointerPercent({ clientX: 0, clientY: 0 }, el)).toEqual({ x: 0, y: 0 });
});
});
describe("mergeRectsByLine", () => {
it("rects บรรทัดเดียวกัน (y overlap) → รวมเป็น 1 rect ครอบกว้าง/สูงสุด", () => {
const out = mergeRectsByLine([
{ x: 10, y: 20, width: 30, height: 5 },
{ x: 45, y: 21, width: 20, height: 5 },
]);
expect(out).toHaveLength(1);
expect(out[0]).toEqual({ x: 10, y: 20, width: 55, height: 6 });
});
it("rects คนละบรรทัด (y ไม่ overlap) → คงแยกกัน", () => {
const out = mergeRectsByLine([
{ x: 10, y: 20, width: 30, height: 5 },
{ x: 12, y: 30, width: 30, height: 5 },
]);
expect(out).toHaveLength(2);
});
it("ลำดับ input ไม่สำคัญ — rect ซ้ายมาทีหลังก็รวมถูกจุดเริ่ม", () => {
const out = mergeRectsByLine([
{ x: 45, y: 21, width: 20, height: 5 },
{ x: 10, y: 20, width: 30, height: 5 },
]);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({ x: 10, width: 55 });
});
it("ช่องว่างระหว่างคำในบรรทัดเดียวกันถูกครอบด้วย (union แกน x)", () => {
const out = mergeRectsByLine([
{ x: 10, y: 20, width: 10, height: 5 },
{ x: 80, y: 20, width: 10, height: 5 },
]);
expect(out).toHaveLength(1);
expect(out[0].width).toBe(80); // 10 → 90
});
it("array ว่าง → ว่าง / rect เดี่ยว → ค่าเดิม", () => {
expect(mergeRectsByLine([])).toEqual([]);
expect(mergeRectsByLine([{ x: 1, y: 2, width: 3, height: 4 }])).toEqual([
{ x: 1, y: 2, width: 3, height: 4 },
]);
});
it("ไม่แก้ array และ rect ต้นฉบับ", () => {
const input = [
{ x: 45, y: 21, width: 20, height: 5 },
{ x: 10, y: 20, width: 30, height: 5 },
];
const snapshot = input.map((r) => ({ ...r }));
mergeRectsByLine(input);
expect(input).toEqual(snapshot);
});
});

36
tests/utils/csv.test.ts Normal file
View file

@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { csvField } from "@/utils/csv";
describe("csvField", () => {
it("ครอบ double quote ตาม RFC 4180", () => {
expect(csvField("ทดสอบ")).toBe('"ทดสอบ"');
});
it("escape double quote ในค่าเป็น \"\"", () => {
expect(csvField('พูดว่า "สวัสดี"')).toBe('"พูดว่า ""สวัสดี"""');
});
it("ตัวเลขหน้าไม่ถูกนำหน้าอะไร", () => {
expect(csvField("1")).toBe('"1"');
expect(csvField("12")).toBe('"12"');
});
it("FIX-6: ขึ้นต้นด้วย = + - @ ต้องนำหน้าด้วย ' กันสูตร Excel", () => {
expect(csvField("=1+1")).toBe('"\'=1+1"');
expect(csvField("+SUM(A1:A2)")).toBe('"\'+SUM(A1:A2)"');
expect(csvField("-2+3")).toBe('"\'-2+3"');
expect(csvField("@SUM(A1)")).toBe('"\'@SUM(A1)"');
});
it("FIX-6: ขึ้นต้นด้วย TAB/CR ต้องนำหน้าด้วย ' เช่นกัน", () => {
expect(csvField("\t=cmd")).toBe('"\'\t=cmd"');
expect(csvField("\r=cmd")).toBe('"\'\r=cmd"');
});
it("อักขระพิเศษที่ไม่ใช่จุดเริ่มสูตรไม่ถูกนำหน้า", () => {
expect(csvField("1+1")).toBe('"1+1"');
expect(csvField("a=b")).toBe('"a=b"');
expect(csvField("ราคา -50 บาท")).toBe('"ราคา -50 บาท"');
});
});

View file

@ -0,0 +1,336 @@
import { describe, expect, it } from "vitest";
import type { AnnotationData, ShapeData } from "@/db/schema";
import {
STAMP_HEIGHT_RATIO,
annotationBBox,
hitTestPath,
hitTestShape,
scaleAnnotationData,
translateAnnotationData,
} from "@/utils/geometry";
// เคล็ดลับจาก docstring ของ annotationBBox: size 100×100 ทำให้ px = % โดยตรง
const S = { width: 100, height: 100 };
describe("annotationBBox", () => {
it("HIGHLIGHT: union ของ rects ทุกอัน", () => {
const bbox = annotationBBox(
{
type: "HIGHLIGHT",
data: {
text: "ทดสอบ",
color: "#fde047",
rects: [
{ x: 10, y: 20, width: 30, height: 5 },
{ x: 45, y: 22, width: 20, height: 5 },
],
},
},
S,
);
expect(bbox).toEqual({ x: 10, y: 20, width: 55, height: 7 });
});
it("HIGHLIGHT: rects ว่าง → กล่องศูนย์", () => {
const bbox = annotationBBox(
{ type: "HIGHLIGHT", data: { text: "", color: "#000", rects: [] } },
S,
);
expect(bbox).toEqual({ x: 0, y: 0, width: 0, height: 0 });
});
it("DRAW: ขยายจุดสุดขอบด้วย strokeWidth/2", () => {
const bbox = annotationBBox(
{
type: "DRAW",
data: {
strokeColor: "#ef4444",
strokeWidth: 2,
paths: [
{ x: 10, y: 10 },
{ x: 30, y: 50 },
],
},
},
S,
);
expect(bbox).toEqual({ x: 9, y: 9, width: 22, height: 42 });
});
it("COMMENT: กล่องสี่เหลี่ยมรอบ position ขนาด COMMENT_PIN_BOX_PCT", () => {
const bbox = annotationBBox(
{ type: "COMMENT", data: { position: { x: 50, y: 50 }, message: "สวัสดี" } },
S,
);
expect(bbox).toEqual({ x: 48, y: 48, width: 4, height: 4 });
});
it("SHAPE rect: ขยายออกด้วยครึ่งความหนาเส้น", () => {
const bbox = annotationBBox(
{
type: "SHAPE",
data: {
shape: "rect",
rect: { x: 10, y: 20, width: 30, height: 10 },
strokeColor: "#000",
strokeWidth: 2,
},
},
S,
);
expect(bbox).toEqual({ x: 9, y: 19, width: 32, height: 12 });
});
it("SHAPE line: bbox ของ from/to ขยายครึ่งความหนา (ไม่ใช่ rect)", () => {
const bbox = annotationBBox(
{
type: "SHAPE",
data: {
shape: "line",
from: { x: 10, y: 10 },
to: { x: 40, y: 20 },
strokeColor: "#000",
strokeWidth: 1,
},
},
S,
);
expect(bbox).toEqual({ x: 9.5, y: 9.5, width: 31, height: 11 });
});
it("REDACTION: rect เดิมตรง ๆ ไม่ขยายความหนา", () => {
const bbox = annotationBBox(
{ type: "REDACTION", data: { rect: { x: 10, y: 20, width: 30, height: 10 } } },
S,
);
expect(bbox).toEqual({ x: 10, y: 20, width: 30, height: 10 });
});
it("TEXTBOX: ประมาณจากจำนวนตัวอักษร × 0.6 fontSize บรรทัดสูง 1.5 fontSize", () => {
const bbox = annotationBBox(
{
type: "TEXTBOX",
data: { position: { x: 10, y: 20 }, text: "อบจ", color: "#111827", fontSize: 2 },
},
S,
);
expect(bbox.x).toBe(10);
expect(bbox.y).toBe(20);
expect(bbox.width).toBeCloseTo(3.6); // 3 ตัว × (2 × 0.6)
expect(bbox.height).toBeCloseTo(3); // 1.5 × 2
});
it("TEXTBOX: ข้อความกว้างเกิน 50% ของหน้า → wrap เป็นหลายบรรทัด กว้างถูก cap", () => {
const bbox = annotationBBox(
{
type: "TEXTBOX",
data: { position: { x: 0, y: 0 }, text: "x".repeat(60), color: "#000", fontSize: 2 },
},
S,
);
expect(bbox.width).toBe(50); // 60 × 1.2 = 72 → cap ที่ 50
expect(bbox.height).toBeCloseTo(6); // 2 บรรทัด × 3
});
it("TEXTBOX: ข้อความว่าง → กว้าง fallback เท่าตัวอักษรเดียว สูง 1 บรรทัด", () => {
const bbox = annotationBBox(
{
type: "TEXTBOX",
data: { position: { x: 10, y: 20 }, text: "", color: "#000", fontSize: 2 },
},
S,
);
expect(bbox.width).toBeCloseTo(1.2);
expect(bbox.height).toBeCloseTo(3);
});
it("STAMP: กว้าง = scale% สูง = กว้าง × STAMP_HEIGHT_RATIO", () => {
const bbox = annotationBBox(
{
type: "STAMP",
data: { label: "สำคัญ", position: { x: 10, y: 20 }, scale: 18 },
},
S,
);
expect(bbox).toEqual({ x: 10, y: 20, width: 18, height: 18 * STAMP_HEIGHT_RATIO });
});
});
describe("translateAnnotationData", () => {
const cases: { name: string; data: AnnotationData; dx: number; dy: number }[] = [
{
name: "HIGHLIGHT เลื่อนทุก rect",
data: { text: "a", color: "#000", rects: [{ x: 10, y: 10, width: 5, height: 5 }] },
dx: 5,
dy: 3,
},
{
name: "DRAW เลื่อนทุกจุด",
data: { strokeColor: "#000", strokeWidth: 1, paths: [{ x: 1, y: 2 }] },
dx: 5,
dy: 3,
},
{
name: "SHAPE line เลื่อนทั้ง from/to",
data: { shape: "line", from: { x: 1, y: 2 }, to: { x: 3, y: 4 }, strokeColor: "#000", strokeWidth: 1 },
dx: 5,
dy: 3,
},
{
name: "REDACTION เลื่อน rect",
data: { rect: { x: 10, y: 10, width: 5, height: 5 } },
dx: 5,
dy: 3,
},
{
name: "STAMP เลื่อน position",
data: { label: "สำคัญ", position: { x: 10, y: 10 }, scale: 18 },
dx: 5,
dy: 3,
},
];
// ตรวจผ่าน annotationBBox (มันแยกชนิดจาก field ของ data ไม่ใช่ค่า type จึงส่ง type อะไรมาก็ได้)
// → ยืนยันว่า "ทั้งชิ้น" เลื่อนไป dx,dy พอดี ไม่ต้องเขียน assertion แยกทีละ variant
it.each(cases)("%s", ({ data, dx, dy }) => {
const out = translateAnnotationData(data, dx, dy);
const before = annotationBBox({ type: "DRAW", data }, S);
const after = annotationBBox({ type: "DRAW", data: out }, S);
expect(after.x).toBeCloseTo(before.x + dx);
expect(after.y).toBeCloseTo(before.y + dy);
});
it("COMMENT เลื่อน position", () => {
const out = translateAnnotationData({ position: { x: 10, y: 10 }, message: "m" }, 5, 3);
expect(out).toEqual({ position: { x: 15, y: 13 }, message: "m" });
});
it("DRAW: strokeWidth คงเดิม (การเลื่อนไม่เปลี่ยนความหนา)", () => {
const out = translateAnnotationData(
{ strokeColor: "#000", strokeWidth: 2, paths: [{ x: 0, y: 0 }] },
10,
10,
);
expect(out).toMatchObject({ strokeWidth: 2 });
});
});
describe("scaleAnnotationData", () => {
it("HIGHLIGHT anchor {0,0} ×2: ทุกพิกัดและขนาด ×2", () => {
const out = scaleAnnotationData(
{ text: "a", color: "#000", rects: [{ x: 10, y: 20, width: 5, height: 6 }] },
{ x: 0, y: 0 },
2,
2,
);
if (!("rects" in out)) throw new Error("expected rects");
expect(out.rects[0]).toEqual({ x: 20, y: 40, width: 10, height: 12 });
});
it("HIGHLIGHT ย่อ–ขยายรอบ anchor มุมตรงข้าม: ขอบที่ anchor อยู่นิ่ง", () => {
const out = scaleAnnotationData(
{ text: "a", color: "#000", rects: [{ x: 10, y: 10, width: 20, height: 10 }] },
{ x: 30, y: 20 },
2,
1,
);
if (!("rects" in out)) throw new Error("expected rects");
expect(out.rects[0]).toEqual({ x: -10, y: 10, width: 40, height: 10 });
// ขอบขวา: -10 + 40 = 30 = anchor.x ✓ · y และ height ไม่เปลี่ยนเพราะ sy=1
});
it("COMMENT position ขยายจาก anchor", () => {
const out = scaleAnnotationData({ position: { x: 30, y: 10 }, message: "m" }, { x: 10, y: 10 }, 2, 1);
expect(out).toEqual({ position: { x: 50, y: 10 }, message: "m" });
});
it("DRAW: strokeWidth คงเดิมตามสเปก F1", () => {
const out = scaleAnnotationData(
{ strokeColor: "#000", strokeWidth: 2, paths: [{ x: 0, y: 0 }] },
{ x: 0, y: 0 },
3,
3,
);
expect(out).toMatchObject({ strokeWidth: 2 });
});
});
describe("hitTestPath", () => {
const S2 = { width: 100, height: 100 }; // % = px โดยตรง
it("ไม่มีจุดเลย → false", () => {
expect(hitTestPath([], { x: 50, y: 50 }, 5, S2)).toBe(false);
});
it("จุดเดียว: อยู่ใน threshold → true / เกิน → false", () => {
expect(hitTestPath([{ x: 50, y: 50 }], { x: 52, y: 50 }, 5, S2)).toBe(true);
expect(hitTestPath([{ x: 50, y: 50 }], { x: 60, y: 50 }, 5, S2)).toBe(false);
});
it("วัดระยะถึง segment กลางเส้น ไม่ใช่แค่ endpoint", () => {
const path = [
{ x: 10, y: 10 },
{ x: 90, y: 10 },
];
expect(hitTestPath(path, { x: 50, y: 12 }, 5, S2)).toBe(true);
expect(hitTestPath(path, { x: 50, y: 30 }, 5, S2)).toBe(false);
});
it("จุดอยู่ต่อเส้นแต่นอกช่วง segment → วัดถึง endpoint ที่ใกล้สุด", () => {
const path = [
{ x: 10, y: 10 },
{ x: 90, y: 10 },
];
expect(hitTestPath(path, { x: 0, y: 10 }, 5, S2)).toBe(false); // ห่าง endpoint 10px
expect(hitTestPath(path, { x: 7, y: 10 }, 5, S2)).toBe(true); // ห่าง 3px
});
});
describe("hitTestShape", () => {
const S2 = { width: 100, height: 100 };
it("rect: กลางพื้นที่ไม่โดน (hit เฉพาะเส้นขอบ)", () => {
const data: ShapeData = {
shape: "rect",
rect: { x: 20, y: 20, width: 60, height: 40 },
strokeColor: "#000",
strokeWidth: 1,
};
expect(hitTestShape(data, { x: 50, y: 40 }, 3, S2)).toBe(false);
});
it("rect: จุดบนขอบ → true / นอกกรอบห่างเกิน threshold → false", () => {
const data: ShapeData = {
shape: "rect",
rect: { x: 20, y: 20, width: 60, height: 40 },
strokeColor: "#000",
strokeWidth: 1,
};
expect(hitTestShape(data, { x: 50, y: 20 }, 3, S2)).toBe(true);
expect(hitTestShape(data, { x: 50, y: 15 }, 3, S2)).toBe(false); // ห่างขอบ 5px
});
it("ellipse: จุดบนเส้นรอบวงรี → true / กลางวง → false", () => {
const data: ShapeData = {
shape: "ellipse",
rect: { x: 20, y: 20, width: 60, height: 40 },
strokeColor: "#000",
strokeWidth: 1,
};
// center (50,40) rx=30 ry=20 → จุด (80,40) อยู่บนวงรีพอดี
expect(hitTestShape(data, { x: 80, y: 40 }, 3, S2)).toBe(true);
expect(hitTestShape(data, { x: 50, y: 40 }, 3, S2)).toBe(false);
});
it("line/arrow: วัดระยะถึงเส้นตรง from→to", () => {
const data: ShapeData = {
shape: "arrow",
from: { x: 10, y: 10 },
to: { x: 50, y: 50 },
strokeColor: "#000",
strokeWidth: 1,
};
expect(hitTestShape(data, { x: 31, y: 31 }, 3, S2)).toBe(true); // ใกล้เส้น ~1.4px
expect(hitTestShape(data, { x: 80, y: 80 }, 3, S2)).toBe(false);
});
});

View file

@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import {
displayRectToPdf,
displaySize,
displayToPdfPoint,
encodeRFC5987,
hexToRgb01,
wrapStyledText,
wrapTextByWidth,
} from "@/utils/pdf-export";
// หน้า A4 portrait จริง (pt) — display ของ 90/270 จะสลับเป็น 792×612
const PAGE = { width: 612, height: 792 };
describe("displaySize", () => {
it("/Rotate 0 และ 180 → ขนาดหน้าเดิม", () => {
expect(displaySize(PAGE, 0)).toEqual(PAGE);
expect(displaySize(PAGE, 180)).toEqual(PAGE);
});
it("/Rotate 90 และ 270 → สลับกว้าง-สูง", () => {
expect(displaySize(PAGE, 90)).toEqual({ width: 792, height: 612 });
expect(displaySize(PAGE, 270)).toEqual({ width: 792, height: 612 });
});
});
describe("displayToPdfPoint", () => {
it("rot 0: พลิกแกน y (จอ y ลง → PDF y ขึ้น)", () => {
expect(displayToPdfPoint({ x: 10, y: 20 }, PAGE, 0)).toEqual({ x: 10, y: 772 });
});
it("rot 90: จอมุมบนซ้าย (0,0) → PDF (0,0)", () => {
expect(displayToPdfPoint({ x: 0, y: 0 }, PAGE, 90)).toEqual({ x: 0, y: 0 });
expect(displayToPdfPoint({ x: 10, y: 20 }, PAGE, 90)).toEqual({ x: 20, y: 10 });
});
it("rot 180: มุมบนซ้ายจอ → มุมขวาล่างของ PDF", () => {
expect(displayToPdfPoint({ x: 10, y: 20 }, PAGE, 180)).toEqual({ x: 602, y: 20 });
});
it("rot 270: มุมบนซ้ายจอ → มุมขวาบนของ PDF", () => {
expect(displayToPdfPoint({ x: 10, y: 20 }, PAGE, 270)).toEqual({ x: 592, y: 782 });
});
});
describe("displayRectToPdf", () => {
it("rot 0: y พลิก (y เดิม = ขอบบนจอ → กลายเป็น y บนของ rect PDF)", () => {
expect(displayRectToPdf({ x: 10, y: 20, width: 30, height: 40 }, PAGE, 0)).toEqual({
x: 10,
y: 732,
width: 30,
height: 40,
});
});
it("rot 90: กว้าง-สูงสลับกัน และ normalized เป็นบวกเสมอ", () => {
expect(displayRectToPdf({ x: 10, y: 20, width: 30, height: 40 }, PAGE, 90)).toEqual({
x: 20,
y: 10,
width: 40,
height: 30,
});
});
});
describe("hexToRgb01", () => {
it("แปลง #rrggbb เป็นสัดส่วน 0–1", () => {
expect(hexToRgb01("#ff0000")).toEqual({ r: 1, g: 0, b: 0 });
expect(hexToRgb01("#00ff00")).toEqual({ g: 1, r: 0, b: 0 });
expect(hexToRgb01("#0000ff")).toEqual({ r: 0, g: 0, b: 1 });
});
it("ไม่มี # นำหน้า / ตัวพิมพ์ใหญ่ก็รับ", () => {
expect(hexToRgb01("ff8000")).toEqual({ r: 1, g: 128 / 255, b: 0 });
expect(hexToRgb01("#FF8000")).toEqual({ r: 1, g: 128 / 255, b: 0 });
});
it("รูปแบบผิด → ดำ (fallback กัน parse พัง)", () => {
expect(hexToRgb01("abc")).toEqual({ r: 0, g: 0, b: 0 });
expect(hexToRgb01("#zzzzzz")).toEqual({ r: 0, g: 0, b: 0 });
expect(hexToRgb01("")).toEqual({ r: 0, g: 0, b: 0 });
});
});
describe("wrapTextByWidth", () => {
// จำลองฟอนต์ที่ตัวอักษรกว้าง 1 หน่วยทุกตัว
const w = (s: string) => s.length;
it("ตัดทีละตัวอักษร (ภาษาไทยไม่มีช่องว่างคำ)", () => {
expect(wrapTextByWidth("อบจทดสอบ", 5, w)).toEqual(["อบจทด", "สอบ"]);
});
it("ขึ้นบรรทัดใหม่ด้วย \\n แยกจากการ wrap", () => {
expect(wrapTextByWidth("aaaa\nbb", 5, w)).toEqual(["aaaa", "bb"]);
});
it("คำยาวเกิน maxWidth → แตกต่อทันทีไม่มีการกลืน", () => {
expect(wrapTextByWidth("x".repeat(12), 5, w)).toEqual(["xxxxx", "xxxxx", "xx"]);
});
it("ข้อความว่าง → บรรทัดว่างเดียว", () => {
expect(wrapTextByWidth("", 5, w)).toEqual([""]);
});
});
describe("wrapStyledText", () => {
// ตัวหนากว้าง 2 เท่าของปกติ
const w = (s: string, bold: boolean) => s.length * (bold ? 2 : 1);
it("run ผสมหนา/ปกติ wrap ตามความกว้างจริงของแต่ละตัว แล้วม้วนกลับเป็น run ต่อบรรทัด", () => {
const out = wrapStyledText(
[
{ text: "ab", bold: true },
{ text: "cdef", bold: false },
],
6,
w,
);
// a(2)+b(2)+c(1)+d(1) = 6 พอดีบรรทัดแรก · e,f ไปบรรทัดสอง
expect(out).toEqual([
[
{ text: "ab", bold: true },
{ text: "cd", bold: false },
],
[{ text: "ef", bold: false }],
]);
});
it("\\n ใน run ตัดบรรทัดแบบชัด ๆ", () => {
const out = wrapStyledText([{ text: "a\nb", bold: false }], 10, w);
expect(out).toEqual([[{ text: "a", bold: false }], [{ text: "b", bold: false }]]);
});
it("ตัวอักษรที่กว้างเกิน maxWidth ทั้งบรรทัด ยังวางลงบรรทัดใหม่ได้ (ไม่ทิ้ง)", () => {
const out = wrapStyledText([{ text: "ab", bold: true }], 1, w);
expect(out).toEqual([[{ text: "a", bold: true }], [{ text: "b", bold: true }]]);
});
});
describe("encodeRFC5987", () => {
it("ชื่อไฟล์ภาษาไทย encode เป็น percent-encoding UTF-8", () => {
expect(encodeRFC5987("คำพิพากษา.pdf")).toBe(
"%E0%B8%84%E0%B8%B3%E0%B8%9E%E0%B8%B4%E0%B8%9E%E0%B8%B2%E0%B8%81%E0%B8%A9%E0%B8%B2.pdf",
);
});
it("อักขระ '()* ที่ encodeURIComponent ปล่อยไว้ ต้องถูก encode เพิ่มตาม RFC 5987", () => {
expect(encodeRFC5987("a'b(c)*d")).toBe("a%27b%28c%29%2Ad");
});
});

26
tsconfig.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

109
utils/annotation-display.ts Normal file
View file

@ -0,0 +1,109 @@
import type {
AnnotationDTO,
CommentData,
DrawPen,
HighlightData,
HighlightStyle,
ShapeData,
StampData,
TextboxData,
} from "@/db/schema";
// การแสดงผล "ชนิด + ข้อความย่อ" ของ 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";
export const TEXTBOX_DEFAULT_FONT_SIZE_PCT = 1.2;
// 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;
/** 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 : "";
}

82
utils/coordinates.ts Normal file
View file

@ -0,0 +1,82 @@
import type { Point, Rect } from "@/db/schema";
// พิกัดทุกอย่างที่เก็บลง DB เป็น % ของขนาดหน้า PDF (PLAN.md §2, CLAUDE.md invariant #2)
// ทิศทางแปลง: px → % = ÷size×100, % → px = ×size÷100 — ผ่านไฟล์นี้เท่านั้น
// Point/Rect define ที่ db/schema.ts (PLAN-FEAT.md §1) แล้ว re-export ที่นี่
export type { Rect, Point };
export type Size = { width: number; height: number };
export const toPercent = (px: number, size: number): number => (px / size) * 100;
export const toPx = (percent: number, size: number): number => (percent / 100) * size;
export const clamp = (value: number, min: number, max: number): number =>
Math.min(Math.max(value, min), max);
export const pointToPercent = (p: Point, size: Size): Point => ({
x: toPercent(p.x, size.width),
y: toPercent(p.y, size.height),
});
export const pointToPx = (p: Point, size: Size): Point => ({
x: toPx(p.x, size.width),
y: toPx(p.y, size.height),
});
export const rectToPercent = (r: Rect, size: Size): Rect => ({
x: toPercent(r.x, size.width),
y: toPercent(r.y, size.height),
width: toPercent(r.width, size.width),
height: toPercent(r.height, size.height),
});
export const rectToPx = (r: Rect, size: Size): Rect => ({
x: toPx(r.x, size.width),
y: toPx(r.y, size.height),
width: toPx(r.width, size.width),
height: toPx(r.height, size.height),
});
export const pathToPercent = (path: Point[], size: Size): Point[] =>
path.map((p) => pointToPercent(p, size));
export const pathToPx = (path: Point[], size: Size): Point[] =>
path.map((p) => pointToPx(p, size));
/** พิกัด pointer จาก event เทียบ page element → % (clamp 0–100 เสมอ) */
export function getPointerPercent(
e: { clientX: number; clientY: number },
pageEl: HTMLElement,
): Point {
const rect = pageEl.getBoundingClientRect();
return {
x: clamp(toPercent(e.clientX - rect.left, rect.width), 0, 100),
y: clamp(toPercent(e.clientY - rect.top, rect.height), 0, 100),
};
}
/**
* รวบ rects ที่อยู่บรรทัดเดียวกัน (แนวตั้ง overlap กัน = y/height ใกล้กัน)
* เหลือ 1 rect ต่อบรรทัด โดย union แกน x ให้ครอบข้อความเดิมทั้งบรรทัด
* เรียกทุกครั้งก่อนบันทึก HIGHLIGHT ลง DB กัน JSONB บวม
*/
export function mergeRectsByLine(rects: Rect[]): Rect[] {
const sorted = [...rects].sort((a, b) => a.y - b.y || a.x - b.x);
const merged: Rect[] = [];
for (const r of sorted) {
const last = merged[merged.length - 1];
if (last && r.y < last.y + last.height) {
// บรรทัดเดียวกัน → ขยายกรอบเดิมให้ครอบ rect นี้ด้วย
const right = Math.max(last.x + last.width, r.x + r.width);
last.x = Math.min(last.x, r.x);
last.width = right - last.x;
const bottom = Math.max(last.y + last.height, r.y + r.height);
last.height = bottom - last.y;
} else {
merged.push({ ...r });
}
}
return merged;
}

9
utils/csv.ts Normal file
View file

@ -0,0 +1,9 @@
// CSV field สำหรับ export สรุปหมายเหตุ (F16/D3) — route export-csv ใช้ร่วม
/** ครอบ field ด้วย double quote (RFC 4180) แล้ว escape `"` เป็น `""`
* FIX-6: field ที่ขึ้นต้นด้วย = + - @ หรือ TAB/CR ต้องนำหน้าด้วย '
* ก่อนครอบ quote — กัน Excel/Sheets ตีความค่าเป็นสูตรเมื่อเปิดไฟล์ (OWASP CSV Injection) */
export function csvField(v: string): string {
const guarded = /^[=+\-@\t\r]/.test(v) ? `'${v}` : v;
return `"${guarded.replace(/"/g, '""')}"`;
}

300
utils/geometry.ts Normal file
View file

@ -0,0 +1,300 @@
import type { AnnotationData, AnnotationType, ShapeData } from "@/db/schema";
import {
clamp,
pointToPx,
rectToPx,
toPx,
type Point,
type Rect,
type Size,
} from "@/utils/coordinates";
// Geometry ของ annotation (PLAN-FEAT.md §3 F1) — pure functions ทดสอบได้
// ข้อมูลเข้า/ออกเป็นพิกัด % ตาม invariant #2 ยกเว้นฟังก์ชันที่รับ `size`
// เพื่อแปลง % → px (เช่น bbox สำหรับวาดกรอบ selection, hit-test หน่วย px)
/** ขนาดกล่อง bbox ของ comment pin (% ของความกว้างหน้า) — pin เป็นปุ่มขนาดคงที่ ไม่มีพิกัดกว้างยาวใน data */
export const COMMENT_PIN_BOX_PCT = 4;
// TEXTBOX (F17/B2 — §5): ค่าประมาณสำหรับ bbox ของกล่องข้อความ
// render จริงกว้างสุด 50% ของหน้าแล้ว wrap อัตโนมัติ — bbox ประมาณจากตัวอักษร
// กว้าง ~0.6 × fontSize ต่อตัว บรรทัดสูง 1.5 × fontSize (ค่าประมาณพอสำหรับกรอบ selection)
export const TEXTBOX_MAX_WIDTH_PCT = 50;
export const TEXTBOX_CHAR_W = 0.6;
export const TEXTBOX_LINE_H = 1.5;
// STAMP (F18/B5 — §5): กรอบตรายางกว้าง = scale% ของความกว้างหน้า สูง = 0.4 เท่าของความกว้าง
// (สูตรเดียวกันทั้ง render บนจอ, bbox ของ selection และการวาดใน export)
export const STAMP_HEIGHT_RATIO = 0.4;
type AnnotationLike = { type: AnnotationType; data: AnnotationData };
/**
* bbox ของ annotation (หน่วย px ตาม size ที่ส่งเข้ามา)
* - HIGHLIGHT: union ของ rects ทุกอัน
* - DRAW: union ของจุดทุกจุด ขยายออกด้วย strokeWidth/2 (เส้นมีความหนา)
* - COMMENT: กล่องสี่เหลี่ยมขนาด COMMENT_PIN_BOX_PCT (% ของความกว้างหน้า) รอบ position
* เคล็ดลับ: ส่ง size={width:100, height:100} เพื่อให้ผลลัพธ์เป็น "%" โดยตรง
*/
export function annotationBBox(a: AnnotationLike, size: Size): Rect {
const d = a.data;
// union ไม่ได้ discriminate โดย field ใน data เอง → แยกตาม key เฉพาะของแต่ละ variant
if ("rects" in d) {
const rects = d.rects.map((r) => rectToPx(r, size));
if (rects.length === 0) return { x: 0, y: 0, width: 0, height: 0 };
const x1 = Math.min(...rects.map((r) => r.x));
const y1 = Math.min(...rects.map((r) => r.y));
const x2 = Math.max(...rects.map((r) => r.x + r.width));
const y2 = Math.max(...rects.map((r) => r.y + r.height));
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
}
if ("paths" in d) {
const pts = d.paths.map((p) => pointToPx(p, size));
const half = toPx(d.strokeWidth, size.width) / 2;
const x1 = Math.min(...pts.map((p) => p.x)) - half;
const y1 = Math.min(...pts.map((p) => p.y)) - half;
const x2 = Math.max(...pts.map((p) => p.x)) + half;
const y2 = Math.max(...pts.map((p) => p.y)) + half;
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
}
if ("shape" in d) {
const half = toPx(d.strokeWidth, size.width) / 2;
if ("rect" in d) {
const r = rectToPx(d.rect, size);
return {
x: r.x - half,
y: r.y - half,
width: r.width + half * 2,
height: r.height + half * 2,
};
}
const a1 = pointToPx(d.from, size);
const b1 = pointToPx(d.to, size);
const x1 = Math.min(a1.x, b1.x) - half;
const y1 = Math.min(a1.y, b1.y) - half;
const x2 = Math.max(a1.x, b1.x) + half;
const y2 = Math.max(a1.y, b1.y) + half;
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
}
if ("rect" in d) {
// REDACTION — กรอบดำทึบ ไม่มีความหนาเส้นให้ขยาย
return rectToPx(d.rect, size);
}
if ("fontSize" in d) {
// TEXTBOX — ประมาณจากข้อความ (position เป็นมุมซ้ายบนของกล่อง)
const fontSizePx = toPx(d.fontSize, size.width);
const charW = fontSizePx * TEXTBOX_CHAR_W;
const lineH = fontSizePx * TEXTBOX_LINE_H;
const maxW = toPx(TEXTBOX_MAX_WIDTH_PCT, size.width);
let widest = 0;
let lines = 0;
for (const raw of d.text.split(/\r?\n/)) {
widest = Math.max(widest, raw.length * charW);
lines += Math.max(1, Math.ceil((raw.length * charW) / maxW));
}
const p = pointToPx(d.position, size);
return {
x: p.x,
y: p.y,
width: Math.min(widest, maxW) || charW,
height: Math.max(lines, 1) * lineH,
};
}
if ("scale" in d) {
// STAMP — กรอบจริงตามสูตร render (position เป็นมุมซ้ายบน)
const w = toPx(d.scale, size.width);
const p = pointToPx(d.position, size);
return { x: p.x, y: p.y, width: w, height: w * STAMP_HEIGHT_RATIO };
}
const p = pointToPx(d.position, size);
// กล่องรอบ pin อิง % ของความกว้างหน้า — สอดคล้องทั้ง render (px) และ space %
// (size={100,100}) ไม่เกิดหน่วยเพี้ยนตอน clamp translate/scale ของ COMMENT
const box = toPx(COMMENT_PIN_BOX_PCT, size.width);
return { x: p.x - box / 2, y: p.y - box / 2, width: box, height: box };
}
/**
* เลื่อน annotation ทั้งชิ้นตาม offset (หน่วย % ของหน้า) — ใช้ตอนลากย้าย
* strokeWidth คงเดิม (การเลื่อนไม่เปลี่ยนความหนา)
* ผู้เรียกต้อง clamp dx/dy เองให้ bbox ยังอยู่ใน 0–100 ก่อนส่งเข้า action
*/
export function translateAnnotationData(
data: AnnotationData,
dxPct: number,
dyPct: number,
): AnnotationData {
if ("rects" in data) {
return {
...data,
rects: data.rects.map((r) => ({ ...r, x: r.x + dxPct, y: r.y + dyPct })),
};
}
if ("paths" in data) {
return {
...data,
paths: data.paths.map((p) => ({ x: p.x + dxPct, y: p.y + dyPct })),
};
}
if ("shape" in data) {
if ("rect" in data) {
return {
...data,
rect: { ...data.rect, x: data.rect.x + dxPct, y: data.rect.y + dyPct },
};
}
return {
...data,
from: { x: data.from.x + dxPct, y: data.from.y + dyPct },
to: { x: data.to.x + dxPct, y: data.to.y + dyPct },
};
}
if ("rect" in data) {
// REDACTION
return {
...data,
rect: { ...data.rect, x: data.rect.x + dxPct, y: data.rect.y + dyPct },
};
}
return {
...data,
position: { x: data.position.x + dxPct, y: data.position.y + dyPct },
};
}
/**
* ย่อ–ขยาย annotation รอบจุด anchor (หน่วย % ของหน้า) ด้วยตัวคูณแกน x/y
* — ใช้ตอนลากมุมกรอบ (anchor = มุมตรงข้าม) ครอบ rects ทุกอัน / จุดทุกจุด / position
* strokeWidth คงเดิมตามแผน (สเปก F1 ระบุแค่ rects/points/position)
*/
export function scaleAnnotationData(
data: AnnotationData,
anchorPct: Point,
sx: number,
sy: number,
): AnnotationData {
const fx = (x: number) => anchorPct.x + (x - anchorPct.x) * sx;
const fy = (y: number) => anchorPct.y + (y - anchorPct.y) * sy;
if ("rects" in data) {
return {
...data,
rects: data.rects.map((r) => ({
x: fx(r.x),
y: fy(r.y),
width: r.width * sx,
height: r.height * sy,
})),
};
}
if ("paths" in data) {
return {
...data,
paths: data.paths.map((p) => ({ x: fx(p.x), y: fy(p.y) })),
};
}
if ("shape" in data) {
if ("rect" in data) {
return {
...data,
rect: {
x: fx(data.rect.x),
y: fy(data.rect.y),
width: data.rect.width * sx,
height: data.rect.height * sy,
},
};
}
return {
...data,
from: { x: fx(data.from.x), y: fy(data.from.y) },
to: { x: fx(data.to.x), y: fy(data.to.y) },
};
}
if ("rect" in data) {
// REDACTION
return {
...data,
rect: {
x: fx(data.rect.x),
y: fy(data.rect.y),
width: data.rect.width * sx,
height: data.rect.height * sy,
},
};
}
return { ...data, position: { x: fx(data.position.x), y: fy(data.position.y) } };
}
function distToSegment(p: Point, a: Point, b: Point): number {
const dx = b.x - a.x;
const dy = b.y - a.y;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y);
const t = clamp(((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq, 0, 1);
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
}
/**
* hit-test จุด (px) กับ polyline (% ของหน้า) — ระยะถึง segment ใด ๆ ≤ thresholdPx
* ใช้ใน F6 (eraser); threshold หน่วย px เพราะความคาดหวังของผู้ใช้อิงขนาดจอ ไม่ใช่ % ของหน้า
*/
export function hitTestPath(
pointsPct: Point[],
pointPct: Point,
thresholdPx: number,
size: Size,
): boolean {
if (pointsPct.length === 0) return false;
const pts = pointsPct.map((p) => pointToPx(p, size));
const pt = pointToPx(pointPct, size);
if (pts.length === 1) return Math.hypot(pt.x - pts[0].x, pt.y - pts[0].y) <= thresholdPx;
for (let i = 0; i < pts.length - 1; i++) {
if (distToSegment(pt, pts[i], pts[i + 1]) <= thresholdPx) return true;
}
return false;
}
/** ระยะจากจุดถึงเส้นขอบสี่เหลี่ยม (px) — อยู่ใน "พื้นที่" กรอบจะได้ระยะถึงขอบ ไม่ใช่ 0 */
function distToRectOutline(p: Point, r: Rect): number {
const inside =
p.x >= r.x && p.x <= r.x + r.width && p.y >= r.y && p.y <= r.y + r.height;
if (inside) {
const dx = Math.min(p.x - r.x, r.x + r.width - p.x);
const dy = Math.min(p.y - r.y, r.y + r.height - p.y);
return Math.min(dx, dy);
}
const cx = Math.max(r.x, Math.min(p.x, r.x + r.width));
const cy = Math.max(r.y, Math.min(p.y, r.y + r.height));
return Math.hypot(p.x - cx, p.y - cy);
}
/** ประมาณระยะจากจุดถึงเส้นรอบวงรี (px) — scale-space: |r−1|×min(rx,ry) พอสำหรับ eraser */
function distToEllipseOutline(p: Point, r: Rect): number {
const cx = r.x + r.width / 2;
const cy = r.y + r.height / 2;
const rx = r.width / 2;
const ry = r.height / 2;
if (rx === 0 || ry === 0) return Math.hypot(p.x - cx, p.y - cy);
const ratio = Math.hypot((p.x - cx) / rx, (p.y - cy) / ry);
return Math.abs(ratio - 1) * Math.min(rx, ry);
}
/**
* hit-test จุดกับรูปทรง (F6 eraser) — เฉพาะ "เส้น stroke/ขอบ" เท่านั้น
* ผ่านพื้นที่ตรงกลางรูปทรงไม่โดน (PLAN-FEAT.md §5)
*/
export function hitTestShape(
data: ShapeData,
pointPct: Point,
thresholdPx: number,
size: Size,
): boolean {
const p = pointToPx(pointPct, size);
if ("rect" in data) {
const r = rectToPx(data.rect, size);
if (data.shape === "ellipse") return distToEllipseOutline(p, r) <= thresholdPx;
return distToRectOutline(p, r) <= thresholdPx;
}
const a = pointToPx(data.from, size);
const b = pointToPx(data.to, size);
return distToSegment(p, a, b) <= thresholdPx;
}

146
utils/pdf-export.ts Normal file
View file

@ -0,0 +1,146 @@
import type { Rect } from "@/db/schema";
import type { Point, Size } from "@/utils/coordinates";
// ตัวช่วยแปลงพิกัดสำหรับ export PDF (F8/C1 — PLAN-FEAT.md §3, §5)
// พิกัดของเราเป็น % ของ "หน้าที่แสดงบนจอ" (pdfjs คิด /Rotate แล้ว, จุด 0,0 มุมบนซ้าย แกน y ลง)
// ฝั่ง pdf-lib วาดในพิกัด PDF ของหน้า **ที่ยังไม่ถูกหมุน** (จุด 0,0 มุมล่างซ้าย แกน y ขึ้น)
// → ทุกจุดต้องแปลงตามมุม /Rotate ก่อนวาดเสมอ
export type Rot = 0 | 90 | 180 | 270;
/** ขนาดหน้า "บนจอ" หลังคิด rotation — 90/270 สลับกว้าง-สูง */
export function displaySize(pageSize: Size, rotation: Rot): Size {
return rotation % 180 === 0
? pageSize
: { width: pageSize.height, height: pageSize.width };
}
/**
* จุดบนจอ (px, มุมบนซ้าย y ลง, ขอบเขต displaySize) → พิกัด PDF (pt, มุมล่างซ้าย y ขึ้น)
* /Rotate ของ PDF หมายถึง "หมุน content ตามเข็มนาฬิกาตอนแสดง" (ตามพฤติกรรม pdfjs)
* อนุพันธ์ inverse mapping ของการหมุน CW แต่ละมุม
*/
export function displayToPdfPoint(
p: Point,
pageSize: Size,
rotation: Rot,
): Point {
switch (rotation) {
case 90:
return { x: p.y, y: p.x };
case 180:
return { x: pageSize.width - p.x, y: p.y };
case 270:
return { x: pageSize.width - p.y, y: pageSize.height - p.x };
default:
return { x: p.x, y: pageSize.height - p.y };
}
}
/**
* rect บนจอ (แกนขนานกับจอ) → rect ในพิกัด PDF — หมุน 90/270 แล้วยังแกนขนาน
* (เพราะเป็นมุมฉากพอดี) แปลงสองมุมแล้ว normalize
*/
export function displayRectToPdf(
rect: Rect,
pageSize: Size,
rotation: Rot,
): Rect {
const a = displayToPdfPoint({ x: rect.x, y: rect.y }, pageSize, rotation);
const b = displayToPdfPoint(
{ x: rect.x + rect.width, y: rect.y + rect.height },
pageSize,
rotation,
);
return {
x: Math.min(a.x, b.x),
y: Math.min(a.y, b.y),
width: Math.abs(b.x - a.x),
height: Math.abs(b.y - a.y),
};
}
/** แปลงสี hex (#rrggbb) → สัดส่วน 0–1 สำหรับ rgb() ของ pdf-lib — ผิดรูปแบบใช้ดำ */
export function hexToRgb01(hex: string): { r: number; g: number; b: number } {
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
const v = m ? m[1] : "000000";
return {
r: parseInt(v.slice(0, 2), 16) / 255,
g: parseInt(v.slice(2, 4), 16) / 255,
b: parseInt(v.slice(4, 6), 16) / 255,
};
}
/** ตัดบรรทัดแบบตามความกว้างจริงของฟอนต์ (ภาษาไทยไม่มีช่องว่าง → เดินทีละตัวอักษร) */
export function wrapTextByWidth(
text: string,
maxWidth: number,
widthOf: (s: string) => number,
): string[] {
const lines: string[] = [];
for (const raw of text.split(/\r?\n/)) {
let cur = "";
for (const ch of raw) {
if (cur && widthOf(cur + ch) > maxWidth) {
lines.push(cur);
cur = ch;
} else {
cur += ch;
}
}
lines.push(cur);
}
return lines;
}
/** ช่วงข้อความที่มีสไตล์เดียวกัน (ตัวหนา/ปกติ) — ใช้ตอน export TEXTBOX แบบ rich text */
export type StyledRun = { text: string; bold: boolean };
/**
* ตัดบรรทัดข้อความที่มีสไตล์ผสม (ตัวหนาเฉพาะส่วน) ตามความกว้าง — แตกเป็นตัวอักษร
* (แต่ละตัวใช้ฟอนต์ตามสไตล์ของมันเองตอนวัดความกว้าง) แล้วม้วนกลับเป็น run ต่อบรรทัด
* พฤติกรรมการตัดคำเดียวกับ wrapTextByWidth (ตัดทีละตัวอักษร — ภาษาไทยไม่มีช่องว่างคำ)
*/
export function wrapStyledText(
runs: StyledRun[],
maxWidth: number,
widthOf: (s: string, bold: boolean) => number,
): StyledRun[][] {
const chars: { ch: string; bold: boolean }[] = [];
for (const r of runs) {
for (const ch of Array.from(r.text)) chars.push({ ch, bold: r.bold });
}
const lines: { ch: string; bold: boolean }[][] = [[]];
let w = 0;
for (const c of chars) {
if (c.ch === "\n") {
lines.push([]);
w = 0;
continue;
}
const cw = widthOf(c.ch, c.bold);
if (w > 0 && w + cw > maxWidth) {
lines.push([]);
w = 0;
}
lines[lines.length - 1].push(c);
w += cw;
}
return lines.map((line) => {
const out: StyledRun[] = [];
for (const c of line) {
const last = out[out.length - 1];
if (last && last.bold === c.bold) last.text += c.ch;
else out.push({ text: c.ch, bold: c.bold });
}
return out;
});
}
/** ตัดชื่อไฟล์แบบ RFC 5987 สำหรับ Content-Disposition (ชื่อไฟล์ภาษาไทย) */
export function encodeRFC5987(s: string): string {
return encodeURIComponent(s).replace(
/['()*]/g,
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
);
}

25
utils/user.ts Normal file
View file

@ -0,0 +1,25 @@
// anonymous identity เก็บใน localStorage สร้างครั้งเดียวต่อ browser (PLAN.md §0 A4)
// ใช้เติม field userId/userName ในตาราง annotations/bookmarks — ไม่มีระบบ auth
export type LocalUser = { userId: string; userName: string };
const STORAGE_KEY = "judes-note:user";
export function getLocalUser(): LocalUser {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
try {
const parsed = JSON.parse(raw) as LocalUser;
if (typeof parsed.userId === "string" && typeof parsed.userName === "string") {
return parsed;
}
} catch {
// ข้อมูลเก่าเสียหาย → สร้างใหม่ต่อด้านล่าง
}
}
const userId = crypto.randomUUID();
const userName = `ผู้ใช้ ${userId.slice(0, 4).toUpperCase()}`;
localStorage.setItem(STORAGE_KEY, JSON.stringify({ userId, userName }));
return { userId, userName };
}

16
vitest.config.ts Normal file
View file

@ -0,0 +1,16 @@
import path from "node:path";
import { defineConfig } from "vitest/config";
// Unit test เฉพาะ pure utils (ไม่มี DOM/DB — ดูรายละเอียดขอบเขตใน tests/utils/)
// alias "@" ให้ตรงกับ paths ใน tsconfig.json
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname),
},
},
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
},
});