diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md new file mode 100644 index 0000000..5f2255b --- /dev/null +++ b/.claude/agents/code-reviewer.md @@ -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 ตัดสินใจ diff --git a/.claude/agents/plan-reviewer.md b/.claude/agents/plan-reviewer.md new file mode 100644 index 0000000..bd85c32 --- /dev/null +++ b/.claude/agents/plan-reviewer.md @@ -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()` ตาม 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 ตัดสินใจ diff --git a/.claude/agents/ux-reviewer.md b/.claude/agents/ux-reviewer.md new file mode 100644 index 0000000..a70c541 --- /dev/null +++ b/.claude/agents/ux-reviewer.md @@ -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 `) — ห้ามสรุปจากชื่อไฟล์หรือโค้ด +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 ตัดสินใจ diff --git a/.claude/rules/coordinates.md b/.claude/rules/coordinates.md new file mode 100644 index 0000000..9b81497 --- /dev/null +++ b/.claude/rules/coordinates.md @@ -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 diff --git a/.claude/rules/data-layer.md b/.claude/rules/data-layer.md new file mode 100644 index 0000000..4bf1662 --- /dev/null +++ b/.claude/rules/data-layer.md @@ -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()` (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` ก่อน diff --git a/.claude/rules/pdf-viewer.md b/.claude/rules/pdf-viewer.md new file mode 100644 index 0000000..4c44cfb --- /dev/null +++ b/.claude/rules/pdf-viewer.md @@ -0,0 +1,24 @@ +--- +paths: + - "components/pdf/**" + - "app/annotate/**" +--- + +# PDF Viewer Rules (รายละเอียดเต็ม: docs/PLAN.md §1, §6, §8) + +- Layer stack ตายตัวตาม DOM order: `` → `div.highlight-layer` (`mix-blend-mode: multiply`) → `div.textLayer` → `` 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=` scroll ไปหน้าของ annotation นั้น + `setSelectedId` — placeholder ขนาดจริงทุกหน้าทำให้คำนวณ absolute scroll ได้แม้หน้ายังไม่ render (กลไกเดียวกับ bookmark); ปุ่ม "คัดลอกลิงก์" ใน sidebar สร้าง URL รูป `/annotate/?page=&ann=` +- 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): ลากบน `
` = `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 เปิดอยู่) diff --git a/.claude/rules/realtime.md b/.claude/rules/realtime.md new file mode 100644 index 0000000..6c9c008 --- /dev/null +++ b/.claude/rules/realtime.md @@ -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=` และ `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** diff --git a/.claude/skills/implement-feat/SKILL.md b/.claude/skills/implement-feat/SKILL.md new file mode 100644 index 0000000..c2ddb48 --- /dev/null +++ b/.claude/skills/implement-feat/SKILL.md @@ -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 +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` และ 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) diff --git a/.claude/skills/implement-task/SKILL.md b/.claude/skills/implement-task/SKILL.md new file mode 100644 index 0000000..317c8eb --- /dev/null +++ b/.claude/skills/implement-task/SKILL.md @@ -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 ผ่านโดยไม่ได้รันจริง diff --git a/.claude/skills/sync-db/SKILL.md b/.claude/skills/sync-db/SKILL.md new file mode 100644 index 0000000..ff4d28d --- /dev/null +++ b/.claude/skills/sync-db/SKILL.md @@ -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) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c02e954 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# PostgreSQL container judes-note-db (สร้างด้วย docker run ตาม README) +DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa9db7e --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8a43225 --- /dev/null +++ b/CLAUDE.md @@ -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 ` implement task N จาก PLAN.md §7 พร้อม verify ตามเกณฑ์ +- **`skills/implement-feat`** — `/implement-feat ` 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 diff --git a/app/actions/annotations.ts b/app/actions/annotations.ts new file mode 100644 index 0000000..525bf54 --- /dev/null +++ b/app/actions/annotations.ts @@ -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; + 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; + 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; + 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 { + const raw = input as unknown as Record | 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 { + 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 { + 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 { + 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 { + const raw = input as unknown as Record | 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 { + 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[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 { + 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 { + 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 { + const raw = input as unknown as Record | 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 { + const raw = input as unknown as Record | 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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/app/annotate/[id]/page.tsx b/app/annotate/[id]/page.tsx new file mode 100644 index 0000000..dc95e9d --- /dev/null +++ b/app/annotate/[id]/page.tsx @@ -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 ( + + ); +} diff --git a/app/annotate/[id]/summary/page.tsx b/app/annotate/[id]/summary/page.tsx new file mode 100644 index 0000000..60d7b2b --- /dev/null +++ b/app/annotate/[id]/summary/page.tsx @@ -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/?ann=` (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(); + 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 ( +
+
+

+ สรุปหมายเหตุ — {file.title} +

+ + + กลับหน้าอ่าน + + {/* F16/D3: export สรุปเป็น CSV (UTF-8 BOM) */} + + Export CSV + +
+ + {pages.length === 0 ? ( +

+ ยังไม่มี annotation ในไฟล์นี้ +

+ ) : ( + pages.map(([pageIndex, items]) => ( +
+

+ หน้า {pageIndex + 1} + + {items.length} รายการ + +

+
    + {items.map((a) => ( +
  • + + + {annotationTypeLabel(a)} + {a.type === "COMMENT" && a.resolved && ( + + จัดการแล้ว + + )} + {a.userName} + + + {annotationPreviewText(a)} + + {annotationNote(a) && ( + + โน้ต: {annotationNote(a)} + + )} + + {/* ปุ่มกระโดด = deep link ไปยัง annotation นั้น (F12 รับ ?ann= ตอน mount) */} + + + ไปตำแหน่ง + +
  • + ))} +
+
+ )) + )} + + ); +} diff --git a/app/api/annotations/export-csv/route.ts b/app/api/annotations/export-csv/route.ts new file mode 100644 index 0000000..6df5104 --- /dev/null +++ b/app/api/annotations/export-csv/route.ts @@ -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}`, + }, + }); +} diff --git a/app/api/annotations/export/route.ts b/app/api/annotations/export/route.ts new file mode 100644 index 0000000..aae1eb2 --- /dev/null +++ b/app/api/annotations/export/route.ts @@ -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 = { + 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}`, + }, + }); +} diff --git a/app/api/annotations/stream/route.ts b/app/api/annotations/stream/route.ts new file mode 100644 index 0000000..a46837e --- /dev/null +++ b/app/api/annotations/stream/route.ts @@ -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 { + 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({ + 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", + }, + }); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..d0b394e --- /dev/null +++ b/app/globals.css @@ -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; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..69f9f5c --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..011ca7b --- /dev/null +++ b/app/page.tsx @@ -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 ( +
+

Jude's Note

+

+ ระบบ PDF Annotation สำหรับ e-Court +

+ + {rows.length === 0 ? ( +

+ ยังไม่มีไฟล์ในระบบ — รัน /sync-db เพื่อ seed ไฟล์ตัวอย่าง +

+ ) : ( +
    + {rows.map((f) => ( +
  • + + + + {f.title} + + + {new Date(f.createdAt).toLocaleString("th-TH")} + + + + Annotate + +
  • + ))} +
+ )} +
+ ); +} diff --git a/components/pdf/AnnotationSelectionLayer.tsx b/components/pdf/AnnotationSelectionLayer.tsx new file mode 100644 index 0000000..ad40b59 --- /dev/null +++ b/components/pdf/AnnotationSelectionLayer.tsx @@ -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(null); + const dragRef = useRef(null); + const movedRef = useRef(false); + const latestRef = useRef(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, + 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) { + 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) { + 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>, + ) { + 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(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 ( +
e.stopPropagation()} // คลิกบน handle/toolbar ไม่ให้ bubble ไป deselect ที่ page + > + {/* กรอบ bbox — ตัวกรอบเอง pointer-events none ผ่านไป textLayer ได้ */} +
+ + {/* HIGHLIGHT ผูกกับตำแหน่งข้อความจริงบนหน้า — ห้ามย้าย/ขยาย (เลือกเพื่อเปลี่ยน + สี/รูปแบบ/ลบได้อย่างเดียว) จึงไม่มี handle ขอบกรอบและมุม */} + {editable && !disabled && !isHighlightMark && ( + <> + {/* ขอบกรอบ 4 ด้าน — ลาก = translate ทั้งกลุ่ม */} +
beginDrag(e, "move", { x: 0, y: 0 })} + onPointerMove={moveDrag} + onPointerUp={endDrag} + onPointerCancel={cancelDrag} + /> +
beginDrag(e, "move", { x: 0, y: 0 })} + onPointerMove={moveDrag} + onPointerUp={endDrag} + onPointerCancel={cancelDrag} + /> +
beginDrag(e, "move", { x: 0, y: 0 })} + onPointerMove={moveDrag} + onPointerUp={endDrag} + onPointerCancel={cancelDrag} + /> +
beginDrag(e, "move", { x: 0, y: 0 })} + onPointerMove={moveDrag} + onPointerUp={endDrag} + onPointerCancel={cancelDrag} + /> + + {/* มุมทั้ง 4 — ลาก = scale รอบ anchor มุมตรงข้าม */} + {corners.map((c) => ( +
beginDrag(e, "scale", c.anchor)} + onPointerMove={moveDrag} + onPointerUp={endDrag} + onPointerCancel={cancelDrag} + /> + ))} + + + )} + + {/* แผงเครื่องมือของรายการที่เลือก — ตัวควบคุมเดียวกับ dropdown ของ tool ผู้สร้าง + (สี/ความหนา/ขนาด/รูปแบบตามชนิด) + ปุ่มลบท้ายแผง — แสดงทุกชนิดรวม HIGHLIGHT + (ไม่มี handle ย้าย/ขยาย แต่เปลี่ยนสี/รูปแบบ/ลบได้) — เฉพาะรายการของตัวเอง */} + {editable && !disabled && ( +
+ {highlightData && ( + <> + + + + +
+ {HIGHLIGHT_STYLE_BTNS.map(({ key, label, Icon }) => ( + + ))} +
+
+ + + + + )} + {strokeData && ( + + + + )} + {strokeData && ( + + {/* หน่วยย่อ ×10 ของ % ความกว้างหน้า — เดียวกับ dropdown ของ tool draw/shape */} + commitStrokeWidth(v / 10)} + /> + + )} + {stampData && ( + <> + + + + + + + + )} + {textboxData && ( + <> + + commitTextboxPatch({ color: c })} + /> + + + {/* แสดง/กรอกเป็น px ตามขนาดหน้าจริง — เก็บใน DB เป็น % ของความกว้างหน้า */} + commitTextboxPatch({ fontSize: (v / pageBaseWidth) * 100 })} + /> + + + commitTextboxPatch({ opacity: v / 100 })} + /> + + + + + +
+ {( + [ + ["left", AlignLeft, "ชิดซ้าย"], + ["center", AlignCenter, "กึ่งกลาง"], + ["right", AlignRight, "ชิดขวา"], + ] as const + ).map(([a, AlignIcon, aLabel]) => ( + + ))} +
+
+ + )} +
+
+ {/* N2: bookmark ตำแหน่งของรายการนี้ — เปิด popup ตั้งชื่อ/โน้ต/เลือกหัวข้อต่อ */} + + +
+
+ )} +
+ ); +} diff --git a/components/pdf/InkLayer.tsx b/components/pdf/InkLayer.tsx new file mode 100644 index 0000000..37cbfbe --- /dev/null +++ b/components/pdf/InkLayer.tsx @@ -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(null); + const [measured, setMeasured] = useState(size); + const [livePoints, setLivePoints] = useState([]); + const [liveShape, setLiveShape] = useState(null); + // Shift ค้างระหว่างวาด → เส้นตรงจากจุดเริ่มถึงตำแหน่งปัจจุบัน (กด/ปล่อยกลางทางได้) + const [shiftDown, setShiftDown] = useState(false); + const drawingRef = useRef(false); + // source of truth ของจุดที่กำลังวาด — อ่านตอน pointerup โดยไม่ต้องแอบ side effect ใน updater + const pointsRef = useRef([]); + // F7/E1: ตัวคูณความหนาต่อจุด (จาก pressure) — เก็บเฉพาะเส้นที่วาดด้วยปากกา + const factorsRef = useRef(null); + const [liveFactors, setLiveFactors] = useState(null); + // F7/E1 palm rejection: เคยเห็นสไตลัสในหน้านี้ → เลิกสนใจนิ้ว (กันฝ่ามือแตะ) + const sawPenRef = useRef(false); + // เช่นเดียวกับรูปทรงที่กำลังลาก (อ่านตอน pointerup จาก ref กันค้างหลัง move สุดท้าย) + const shapeRef = useRef(null); + // FIX-8: rAF ของ live render ระหว่างวาด — ค้าง = ยังไม่ต้องตั้งเพิ่ม + const liveRafRef = useRef(null); + // โหมด eraser กำลังกดค้าง (F6) + const erasingRef = useRef(false); + // พื้นที่ปิดพรางที่กำลังลาก (F8) — เก็บ bbox ตรง ๆ + const redactRef = useRef<{ start: Point; end: Point } | null>(null); + const [liveRedaction, setLiveRedaction] = useState(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) { + 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) { + 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) { + 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 ( + + {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 ( + + ); + })} + + ); + } + return ( + + ); + } + + /** 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 ( + + ); + } + return ; + } + // line/arrow — จุดเริ่ม/จุดปลาย (D3) + const a = pointToPx(d.from, measured); + const b = pointToPx(d.to, measured); + const line = ; + if (d.shape === "line") return {line}; + return ( + + {line} + + + ); + } + + // เส้นตรงเมื่อ Shift ค้าง — เฉพาะโหมดวาดอิสระ (ไม่ใช่รูปทรง/eraser/redact) + const straight = shiftDown && !shapeTool && !eraseMode && !redactMode; + + return ( +
+ {/* SVG แบบไม่มี viewBox — วาดในระบบ px จริงเท่าขนาดที่วัดได้ */} + + {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 ( + + ); + })} + {liveRedaction && ( + + )} + {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, + }, + )} + +
+ ); +} diff --git a/components/pdf/PDFViewer.tsx b/components/pdf/PDFViewer.tsx new file mode 100644 index 0000000..8bca8fc --- /dev/null +++ b/components/pdf/PDFViewer.tsx @@ -0,0 +1,2918 @@ +"use client"; + +import Link from "next/link"; +import { + useEffect, + useMemo, + useOptimistic, + useRef, + useState, + startTransition, + type MouseEvent as ReactMouseEvent, + type ReactNode, +} from "react"; +import { + AlignCenter, + AlignLeft, + AlignRight, + ArrowRight, + Bold, + BookmarkPlus, + Check, + ChevronDown, + ChevronLeft, + ChevronRight, + Circle, + ClipboardList, + Download, + Eraser, + EyeOff, + Hand, + Highlighter, + MessageSquare, + MessageSquarePlus, + Minus, + MousePointer2, + PanelLeft, + Pencil, + Redo2, + Shapes, + Square, + Trash2, + Type, + Stamp, + Undo2, + ZoomIn, + ZoomOut, + type LucideIcon, +} from "lucide-react"; + +import { + createAnnotation, + createBookmark, + createReply, + deleteAnnotation, + deleteBookmark, + deleteReply, + setAnnotationResolved, + updateAnnotation, + updateBookmark, +} from "@/app/actions/annotations"; +import { + useAnnotationRealtime, +} from "@/hooks/useAnnotationRealtime"; +import { useHistory } from "@/hooks/useHistory"; +import type { + AnnotationData, + AnnotationDTO, + AnnotationType, + BookmarkDTO, + CommentData, + DrawData, + DrawPen, + HighlightData, + HighlightStyle, + TextboxSegment, + RedactionData, + ReplyDTO, + ShapeData, + ShapeKind, + StampData, + TextboxData, +} from "@/db/schema"; +import { + annotationNote, + annotationPreviewText, + annotationTypeLabel, + DRAW_PENS, + effectiveDrawOpacity, + STAMP_COLOR, + STAMP_DEFAULT_SCALE_PCT, + STAMP_PRESETS, + TEXTBOX_DEFAULT_COLOR, + TEXTBOX_DEFAULT_FONT_SIZE_PCT, +} from "@/utils/annotation-display"; +import { + clamp, + getPointerPercent, + mergeRectsByLine, + rectToPercent, + rectToPx, + toPx, + type Point, + type Rect, + type Size, +} from "@/utils/coordinates"; +import { getLocalUser, type LocalUser } from "@/utils/user"; +import { annotationBBox, hitTestPath, hitTestShape } from "@/utils/geometry"; +import InkLayer, { type InkRedaction, type InkShape, type InkStroke } from "./InkLayer"; +import TextboxStampLayer, { type InkStamp, type InkTextbox } from "./TextboxStampLayer"; +import AnnotationSelectionLayer from "./AnnotationSelectionLayer"; +import AnnotationSidebar from "../sidebar/AnnotationSidebar"; +import { + ColorSwatches, + HIGHLIGHT_STYLE_BTNS, + NumberCombo, + PanelRow, +} from "./ToolControls"; + +type Tool = + | "select" + | "pan" + | "highlight" + | "draw" + | "comment" + | "shape" + | "erase" + | "redact" + | "textbox" + | "stamp"; + +type Composer = { + pageIndex: number; + position: Point; // % ของหน้า + x: number; // ตำแหน่งจอสำหรับวางกล่อง (fixed) + y: number; +}; + +/** F17/B2: กล่องข้อความที่กำลังสร้างใหม่ — textarea วางบนหน้า ณ ตำแหน่งที่คลิก */ +type TextboxComposer = { + pageIndex: number; + position: Point; // % ของหน้า +}; + +type AnnotationAction = + | { kind: "add"; row: AnnotationDTO } + | { kind: "update"; row: AnnotationDTO } + | { kind: "remove"; id: string }; + +/** กลุ่ม state/handler ของ TEXTBOX ที่ส่งรวมทีเดียวลง PdfPage (F17/B2) + * ตัว editor เป็น contentEditable uncontrolled — ข้อความ/สไตล์ (segments ตัวหนาเฉพาะส่วน) + * ส่งออกจาก editor ตอน commit เท่านั้น ไม่มี draft กลางทาง */ +type TextboxUI = { + composer: TextboxComposer | null; + commitComposer: (text: string, segments?: TextboxSegment[]) => void; + cancelComposer: () => void; + editingId: string | null; + startEdit: (id: string | null) => void; + commitEdit: (id: string, text: string, segments?: TextboxSegment[]) => void; +}; + +/** N2: เหลือแค่ remove (create รอผลจาก action ตรง ๆ) */ +type BookmarkAction = { kind: "remove"; id: string }; + +const HIGHLIGHT_COLORS = ["#fde047", "#86efac", "#93c5fd", "#f9a8d4", "#fdba74"]; + +/** ชื่อ bookmark ที่มาจากข้อความ (คลุม/ข้อความรายการ) — ยุบ whitespace และตัดยาวไม่เกิน 120 ตัวอักษร */ +function normalizeBookmarkTitle(s: string): string { + const t = s.replace(/\s+/g, " ").trim(); + return t.length > 120 ? `${t.slice(0, 120)}…` : t; +} + +/** ความสูง sticky header ของ viewer — ใช้เว้นระยะเวลาเลื่อนหน้าไม่ให้เป้าหมายถูก header บัง */ +function viewerHeaderHeight(): number { + return document.querySelector("header")?.offsetHeight ?? 0; +} + +/** เลื่อนหน้าต่างให้ตำแหน่ง absolute (px จากบนสุดของ document) ไปอยู่ใต้ sticky header + * (เว้นระยะห่างจาก toolbar 40px ให้รายการไม่ติดแถบเครื่องมือเกินไป) */ +const SCROLL_BELOW_HEADER_GAP = 40; + +function scrollToBelowHeader(absoluteTop: number): void { + window.scrollTo({ + top: Math.max(0, absoluteTop - viewerHeaderHeight() - SCROLL_BELOW_HEADER_GAP), + behavior: "smooth", + }); +} + +/** path เส้นขยุกขยิกยาว w px — ใช้ใน highlight-layer สำหรับ style "squiggle" */ +function squigglePath(w: number): string { + let d = "M0 2.5"; + let up = true; + for (let x = 0; x + 4 <= Math.max(w, 4); x += 4) { + d += up ? " q2 -2.5 4 0" : " q2 2.5 4 0"; + up = !up; + } + return d; +} + +const TOOLS: { key: Tool; label: string; Icon: LucideIcon }[] = [ + { key: "select", label: "เลือกข้อความ", Icon: MousePointer2 }, + { key: "pan", label: "เลื่อนหน้า (pan)", Icon: Hand }, + { key: "highlight", label: "มาร์กข้อความ (highlight)", Icon: Highlighter }, + { key: "draw", label: "วาดเส้น", Icon: Pencil }, + { key: "comment", label: "วาง comment", Icon: MessageSquarePlus }, + { key: "shape", label: "รูปทรง", Icon: Shapes }, + { key: "erase", label: "ลบด้วยการลาก (eraser)", Icon: Eraser }, + { key: "redact", label: "ปิดพราง (redaction)", Icon: EyeOff }, + { key: "textbox", label: "กล่องข้อความ", Icon: Type }, + { key: "stamp", label: "ตรายาง (stamp)", Icon: Stamp }, +]; + +/** cursor รูปยางลบของ tool erase — SVG inline สไตล์ lucide (path เดียวกับ icon บน + * แถบ tool) hotspot ที่ปลายยางมุมล่างซ้าย = จุดที่ใช้ลบจริง */ +const ERASER_CURSOR = `url("data:image/svg+xml,${encodeURIComponent( + '', +)}") 2 17, auto`; + +/** ไอคอนของแต่ละชนิดปากกาของ tool draw — label/สี/ความหนา/ความทึบอยู่ใน DRAW_PENS */ +const DRAW_PEN_ICONS: Record = { + pencil: Pencil, + highlighter: Highlighter, +}; + +/** ชนิดรูปทรงย่อยของ tool shape (F5/B1) — ปุ่มแสดงเมื่อ tool ใช้งาน */ +const SHAPE_KIND_BTNS: { key: ShapeKind; label: string; Icon: LucideIcon }[] = [ + { key: "rect", label: "สี่เหลี่ยม", Icon: Square }, + { key: "ellipse", label: "วงรี", Icon: Circle }, + { key: "line", label: "เส้นตรง", Icon: Minus }, + { key: "arrow", label: "ลูกศร", Icon: ArrowRight }, +]; + +// AnnotationDTO เป็น flat type (type/data แยกกัน) → ใช้ type predicate แคบ scope +function isHighlight(a: AnnotationDTO): a is AnnotationDTO & { type: "HIGHLIGHT"; data: HighlightData } { + return a.type === "HIGHLIGHT"; +} +function isDraw(a: AnnotationDTO): a is AnnotationDTO & { type: "DRAW"; data: DrawData } { + return a.type === "DRAW"; +} +function isComment(a: AnnotationDTO): a is AnnotationDTO & { type: "COMMENT"; data: CommentData } { + return a.type === "COMMENT"; +} +function isShape(a: AnnotationDTO): a is AnnotationDTO & { type: "SHAPE"; data: ShapeData } { + return a.type === "SHAPE"; +} +function isRedaction(a: AnnotationDTO): a is AnnotationDTO & { type: "REDACTION"; data: RedactionData } { + return a.type === "REDACTION"; +} +function isTextbox(a: AnnotationDTO): a is AnnotationDTO & { type: "TEXTBOX"; data: TextboxData } { + return a.type === "TEXTBOX"; +} +function isStamp(a: AnnotationDTO): a is AnnotationDTO & { type: "STAMP"; data: StampData } { + return a.type === "STAMP"; +} + +/** ตรวจว่าจุดคลิก (% ของหน้า) ชน annotation นี้หรือไม่ — hit-test แม่นตามชนิด + * (DRAW/SHAPE ใช้ตัวตรวจเดียวกับ eraser, HIGHLIGHT ดู rect ต่อบรรทัด, + * ที่เหลือดูจาก bbox + padding เผื่อรายการเล็ก) */ +function hitTestAnnotation(a: AnnotationDTO, p: Point, size: Size): boolean { + if (a.type === "HIGHLIGHT") { + return (a.data as HighlightData).rects.some( + (r) => + p.x >= r.x && + p.x <= r.x + r.width && + p.y >= r.y && + p.y <= r.y + r.height, + ); + } + if (a.type === "DRAW") { + const d = a.data as DrawData; + const threshold = toPx(d.strokeWidth, size.width) / 2 + 6; + return hitTestPath(d.paths, p, threshold, size); + } + if (a.type === "SHAPE") { + const d = a.data as ShapeData; + const threshold = toPx(d.strokeWidth, size.width) / 2 + 6; + return hitTestShape(d, p, threshold, size); + } + // ที่เหลือ (REDACTION/TEXTBOX/STAMP) — bbox ในหน่วย % เทียบกับ p (%) หน่วยเดียวกัน + // (เดิมเทียบ bbox หน่วย px ตาม size จริงกับ p ที่เป็น % หน่วยไม่ตรงกัน จนคลิกเลือก + // และ tooltip จับได้เฉพาะกรอบที่ชิดมุมซ้ายบนของหน้า) — padding 4px เดิมแปลงเป็น % ตามแกน + const box = annotationBBox(a, { width: 100, height: 100 }); + const padX = (4 / size.width) * 100; + const padY = (4 / size.height) * 100; + return ( + p.x >= box.x - padX && + p.x <= box.x + box.width + padX && + p.y >= box.y - padY && + p.y <= box.y + box.height + padY + ); +} + +/** กล่องเธรดคำตอบใน popover ของ comment pin (F9/A4) — state draft ของตัวเอง */ +function ReplyBox({ + annotationId, + replies, + ownUserId, + onCreateReply, + onDeleteReply, +}: { + annotationId: string; + replies: ReplyDTO[]; + ownUserId: string; + onCreateReply: (annotationId: string, message: string) => void; + onDeleteReply: (id: string) => void; +}) { + const [draft, setDraft] = useState(""); + function submit() { + const msg = draft.trim(); + if (!msg) return; + setDraft(""); + onCreateReply(annotationId, msg); + } + return ( +
e.stopPropagation()}> + {replies.length > 0 && ( +
+ {replies.map((r) => ( +
+
+

+ {r.userName} + + {new Date(r.createdAt).toLocaleString("th-TH", { + hour: "2-digit", + minute: "2-digit", + day: "2-digit", + month: "short", + })} + +

+

{r.message}

+
+ {r.userId === ownUserId && ( + + )} +
+ ))} +
+ )} +
+ setDraft(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation(); // ไม่ให้ปุ่มใน input ชนคีย์ลัด/Enter ของข้างนอก + if (e.key === "Enter") submit(); + }} + placeholder="ตอบ comment…" + className="min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400" + /> + +
+
+ ); +} + +// ─── หน้าเดียว: canvas + highlight-layer + textLayer + InkLayer + pins ────── + +function PdfPage({ + pdfjs, + doc, + pageNumber, + width, + height, + tool, + shapeKind, + drawStyle, + shapeStyle, + textboxStyle, + /** ความกว้างหน้าแรกที่ scale 1 — แปลง px↔% ของ fontSize ในแผง selection */ + pageBaseWidth, + liveMark, + liveMarkStyle, + annotations, + replies, + openPinId, + ownUserId, + selectedId, + editingCommentId, + commentEditDraft, + onCommentEditDraft, + onStartCommentEdit, + textbox, + onSelectAnnotation, + onPinToggle, + onAnnotationPreview, + onAnnotationUpdate, + onSetResolved, + onAnnotationBookmark, + onCreateReply, + onDeleteReply, + onStrokeEnd, + onShapeEnd, + onRedactionEnd, + onErase, +}: { + pdfjs: typeof import("pdfjs-dist"); + doc: import("pdfjs-dist").PDFDocumentProxy; + /** 1-based */ + pageNumber: number; + width: number; + height: number; + tool: Tool; + shapeKind: ShapeKind; + /** ค่าปากกาปัจจุบันของ tool draw — ใช้ทั้ง live preview และตอนบันทึกเส้นใหม่ */ + drawStyle: { color: string; strokeWidthPct: number; opacity: number }; + /** ค่าสี/ความหนาของ tool shape — ใช้ตอนสร้างรูปทรงใหม่ */ + shapeStyle: { color: string; strokeWidthPct: number }; + /** ค่าสี/ขนาดฟอนต์/หนา-ทึบ-จัดแนวของ tool textbox — ใช้ทั้ง composer และตอนบันทึก */ + textboxStyle: { + color: string; + fontSizePct: number; + bold: boolean; + opacity: number; + align: "left" | "center" | "right"; + }; + /** live preview มาร์กระหว่างลากคลุมด้วย tool highlight (null = ไม่มี) */ + liveMark: { pageIndex: number; rects: Rect[] } | null; + liveMarkStyle: { color: string; style: HighlightStyle; opacity?: number }; + /** ความกว้างหน้าแรกที่ scale 1 — แปลง px↔% ของ fontSize ในแผง selection */ + pageBaseWidth: number; + annotations: AnnotationDTO[]; + replies: ReplyDTO[]; + openPinId: string | null; + ownUserId: string; + selectedId: string | null; + /** F13/A6: comment ที่กำลังแก้ไขข้อความอยู่ (null = ไม่มี) */ + editingCommentId: string | null; + commentEditDraft: string; + onCommentEditDraft: (text: string) => void; + onStartCommentEdit: (id: string | null) => void; + /** F17/B2: state/handler ของกล่องข้อความ (composer จะ render เฉพาะหน้าที่ตรง pageIndex) */ + textbox: TextboxUI; + /** คลิกรายการ overlay (เช่น TEXTBOX) → เลือกรายการนั้น (F1) */ + onSelectAnnotation: (id: string) => void; + onPinToggle: (id: string | null) => void; + onAnnotationPreview: (data: AnnotationData | null) => void; + onAnnotationUpdate: (id: string, data: AnnotationData) => void; + onSetResolved: (id: string, resolved: boolean) => void; + /** N2: ปุ่ม bookmark ในแผง selection → เปิด popup ที่ตำแหน่งของ annotation นั้น */ + onAnnotationBookmark: (a: AnnotationDTO, yOffsetPct: number) => void; + onCreateReply: (annotationId: string, message: string) => void; + onDeleteReply: (id: string) => void; + onStrokeEnd: (pointsPct: Point[], widthsPct?: number[]) => void; + onShapeEnd: (data: ShapeData) => void; + onRedactionEnd: (data: RedactionData) => void; + onErase: (id: string) => void; +}) { + const canvasRef = useRef(null); + const textLayerRef = useRef(null); + + useEffect(() => { + let cancelled = false; + let renderTask: import("pdfjs-dist").RenderTask | null = null; + + (async () => { + const page = await doc.getPage(pageNumber); + if (cancelled) return; + + const scale = width / page.getViewport({ scale: 1 }).width; + const viewport = page.getViewport({ scale }); + + // Canvas Retina: backing store ×dpr + transform แต่ style คง css px + const canvas = canvasRef.current; + if (!canvas) return; + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.floor(viewport.width * dpr); + canvas.height = Math.floor(viewport.height * dpr); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + renderTask = page.render({ + canvasContext: ctx, + viewport, + transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined, + }); + await renderTask.promise.catch(() => undefined); // cancel ระหว่าง unmount ≠ error + if (cancelled) return; + + // TextLayer — span ใช้ calc(var(--scale-factor)*…) → ต้องตั้งบน container + const container = textLayerRef.current; + if (!container) return; + const textContent = await page.getTextContent(); + if (cancelled) return; + container.replaceChildren(); + container.style.setProperty("--scale-factor", String(scale)); + const textLayer = new pdfjs.TextLayer({ + textContentSource: textContent, + container, + viewport, + }); + await textLayer.render(); + })().catch((err) => { + if (!cancelled) { + console.error(`render หน้า ${pageNumber} ไม่สำเร็จ`, err); + } + }); + + return () => { + cancelled = true; + renderTask?.cancel(); // กัน memory leak (rules/pdf-viewer.md) + }; + }, [pdfjs, doc, pageNumber, width, height]); + + const highlights = annotations.filter(isHighlight); + const inkStrokes: InkStroke[] = annotations + .filter(isDraw) + .map((a) => ({ + id: a.id, + userId: a.userId, + color: a.data.strokeColor, + strokeWidthPct: a.data.strokeWidth, + points: a.data.paths, + widthsPct: a.data.widths, + opacity: effectiveDrawOpacity(a.data), + })); + const inkShapes: InkShape[] = annotations + .filter(isShape) + .map((a) => ({ id: a.id, userId: a.userId, data: a.data })); + const inkRedactions: InkRedaction[] = annotations + .filter(isRedaction) + .map((a) => ({ id: a.id, userId: a.userId, data: a.data })); + const comments = annotations.filter(isComment); + const inkTextboxes: InkTextbox[] = annotations + .filter(isTextbox) + .map((a) => ({ id: a.id, userId: a.userId, data: a.data })); + const inkStamps: InkStamp[] = annotations + .filter(isStamp) + .map((a) => ({ id: a.id, userId: a.userId, data: a.data })); + + // tool ที่ InkLayer รับ pointer (วาดเส้น/รูปทรง/eraser/redact) → textLayer กับ pins ต้องปล่อยทาง + const inkActive = + tool === "draw" || tool === "shape" || tool === "erase" || tool === "redact"; + // tool pan — ปิด pointer ของทุก overlay (textLayer/pins/textbox/selection) ให้ลากเลื่อนได้ทั้งหน้า + // แต่ InkLayer ต้องไม่ active ด้วย pan (ไม่งั้นการลากกลายเป็นวาดเส้น) + const overlayFrozen = inkActive || tool === "pan"; + + /** วาดมาร์ก 1 rect ตามรูปแบบ (ทึบ/เส้นใต้/ขีดฆ่า/ขยุกขยิก) — ใช้ทั้งของเดิมและ live preview + * opacity 0–1 (optional — ไม่มี = ทึบเต็ม) ใช้กับทุกรูปแบบ */ + function renderMark( + key: string, + px: Rect, + color: string, + hStyle: HighlightStyle, + opacity?: number, + ) { + const op = opacity ?? 1; + if (hStyle === "underline") { + return ( +
+ ); + } + if (hStyle === "strikethrough") { + return ( +
+ ); + } + if (hStyle === "squiggle") { + const w = Math.max(px.width, 4); + return ( + + + + ); + } + return ( +
+ ); + } + + return ( + <> + {/* z-1: canvas — pointer-events: none เสมอ */} + + + {/* z-2: highlight-layer — pointer-events: none เสมอ, อยู่หน้า textLayer ใน DOM order + แสดงผลตาม style: ทึบ / เส้นใต้ (ขอบล่าง) / ขีดฆ่า (กลางบรรทัด) / ขยุกขยิก (svg) */} +
+ {highlights.map((a) => + a.data.rects.map((r, i) => + renderMark( + `${a.id}-${i}`, + rectToPx(r, { width, height }), + a.data.color, + a.data.style ?? "highlight", + a.data.opacity, + ), + ), + )} + {/* live preview ของ tool highlight ระหว่างลากคลุมข้อความ */} + {liveMark && + liveMark.pageIndex === pageNumber - 1 && + liveMark.rects.map((r, i) => + renderMark( + `live-${i}`, + rectToPx(r, { width, height }), + liveMarkStyle.color, + liveMarkStyle.style, + liveMarkStyle.opacity, + ), + )} +
+ + {/* z-2: textLayer (pdfjs) — อยู่หลัง highlight-layer ใน DOM → อยู่บนสุดของ z-2 */} +
+ + {/* z-3: InkLayer (SVG freehand + รูปทรง F5) */} + + + {/* z-4: TEXTBOX overlay (F17/B2) — ก่อน comment pins ใน DOM */} + + + {/* z-4: comment pins — คอนเทนเนอร์โปร่งใส ปุ่ม pin รับ event เอง */} +
+ {comments.map((a) => { + const x = toPx(a.data.position.x, width); + const y = toPx(a.data.position.y, height); + const open = openPinId === a.id; + return ( +
+ + {open && ( +
e.stopPropagation()} + > +
+

{a.userName}

+ {/* F13/A6: ปุ่มแก้ไขข้อความ — เจ้าของเท่านั้น */} + {a.userId === ownUserId && editingCommentId !== a.id && ( + + )} +
+ {editingCommentId === a.id ? ( +
+