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:
parent
6fd0c3174f
commit
eddcec28c7
60 changed files with 15055 additions and 0 deletions
40
.claude/agents/code-reviewer.md
Normal file
40
.claude/agents/code-reviewer.md
Normal 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 ตัดสินใจ
|
||||
44
.claude/agents/plan-reviewer.md
Normal file
44
.claude/agents/plan-reviewer.md
Normal 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 ตัดสินใจ
|
||||
56
.claude/agents/ux-reviewer.md
Normal file
56
.claude/agents/ux-reviewer.md
Normal 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 ตัดสินใจ
|
||||
17
.claude/rules/coordinates.md
Normal file
17
.claude/rules/coordinates.md
Normal 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
|
||||
20
.claude/rules/data-layer.md
Normal file
20
.claude/rules/data-layer.md
Normal 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` ก่อน
|
||||
24
.claude/rules/pdf-viewer.md
Normal file
24
.claude/rules/pdf-viewer.md
Normal 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
20
.claude/rules/realtime.md
Normal 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**
|
||||
28
.claude/skills/implement-feat/SKILL.md
Normal file
28
.claude/skills/implement-feat/SKILL.md
Normal 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)
|
||||
22
.claude/skills/implement-task/SKILL.md
Normal file
22
.claude/skills/implement-task/SKILL.md
Normal 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 ผ่านโดยไม่ได้รันจริง
|
||||
17
.claude/skills/sync-db/SKILL.md
Normal file
17
.claude/skills/sync-db/SKILL.md
Normal 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
2
.env.example
Normal 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
30
.gitignore
vendored
Normal 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
76
CLAUDE.md
Normal 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
951
app/actions/annotations.ts
Normal 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);
|
||||
}
|
||||
63
app/annotate/[id]/page.tsx
Normal file
63
app/annotate/[id]/page.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
118
app/annotate/[id]/summary/page.tsx
Normal file
118
app/annotate/[id]/summary/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
app/api/annotations/export-csv/route.ts
Normal file
55
app/api/annotations/export-csv/route.ts
Normal 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}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
450
app/api/annotations/export/route.ts
Normal file
450
app/api/annotations/export/route.ts
Normal 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}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
64
app/api/annotations/stream/route.ts
Normal file
64
app/api/annotations/stream/route.ts
Normal 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
94
app/globals.css
Normal 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
20
app/layout.tsx
Normal 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
52
app/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
617
components/pdf/AnnotationSelectionLayer.tsx
Normal file
617
components/pdf/AnnotationSelectionLayer.tsx
Normal 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
581
components/pdf/InkLayer.tsx
Normal 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
2918
components/pdf/PDFViewer.tsx
Normal file
File diff suppressed because it is too large
Load diff
329
components/pdf/TextboxStampLayer.tsx
Normal file
329
components/pdf/TextboxStampLayer.tsx
Normal 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
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>
|
||||
);
|
||||
}
|
||||
250
components/pdf/ToolControls.tsx
Normal file
250
components/pdf/ToolControls.tsx
Normal 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 },
|
||||
];
|
||||
831
components/sidebar/AnnotationSidebar.tsx
Normal file
831
components/sidebar/AnnotationSidebar.tsx
Normal 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}>
|
||||
ใต้ "{h.title}"
|
||||
</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
29
db/index.ts
Normal 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
341
db/schema.ts
Normal 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
286
docs/PLAN-FEAT.md
Normal 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
255
docs/PLAN.md
Normal 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
11
drizzle.config.ts
Normal 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!,
|
||||
},
|
||||
});
|
||||
|
||||
83
hooks/useAnnotationRealtime.ts
Normal file
83
hooks/useAnnotationRealtime.ts
Normal 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
60
hooks/useHistory.ts
Normal 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
55
lib/annotate-events.ts
Normal 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
24
lib/server-pdf.ts
Normal 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
4
next.config.mjs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
4552
package-lock.json
generated
Normal file
4552
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
34
package.json
Normal file
34
package.json
Normal 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
6
postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
public/fonts/Sarabun-Regular.ttf
Normal file
BIN
public/fonts/Sarabun-Regular.ttf
Normal file
Binary file not shown.
21
public/pdf.worker.min.mjs
Normal file
21
public/pdf.worker.min.mjs
Normal file
File diff suppressed because one or more lines are too long
BIN
public/samples/01012-672427-1f-690609-0000840223.pdf
Normal file
BIN
public/samples/01012-672427-1f-690609-0000840223.pdf
Normal file
Binary file not shown.
BIN
public/samples/01012-672427-4f-690609-0000840227.pdf
Normal file
BIN
public/samples/01012-672427-4f-690609-0000840227.pdf
Normal file
Binary file not shown.
BIN
public/samples/example.pdf
Normal file
BIN
public/samples/example.pdf
Normal file
Binary file not shown.
16
tailwind.config.ts
Normal file
16
tailwind.config.ts
Normal 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;
|
||||
109
tests/utils/annotation-display.test.ts
Normal file
109
tests/utils/annotation-display.test.ts
Normal 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(["ตรวจสอบแล้ว", "สำคัญ"]);
|
||||
});
|
||||
});
|
||||
145
tests/utils/coordinates.test.ts
Normal file
145
tests/utils/coordinates.test.ts
Normal 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
36
tests/utils/csv.test.ts
Normal 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 บาท"');
|
||||
});
|
||||
});
|
||||
336
tests/utils/geometry.test.ts
Normal file
336
tests/utils/geometry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
150
tests/utils/pdf-export.test.ts
Normal file
150
tests/utils/pdf-export.test.ts
Normal 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
26
tsconfig.json
Normal 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
109
utils/annotation-display.ts
Normal 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
82
utils/coordinates.ts
Normal 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
9
utils/csv.ts
Normal 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
300
utils/geometry.ts
Normal 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
146
utils/pdf-export.ts
Normal 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
25
utils/user.ts
Normal 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
16
vitest.config.ts
Normal 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"],
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue