diff --git a/.claude/rules/realtime.md b/.claude/rules/realtime.md index fcdb359..991b6ef 100644 --- a/.claude/rules/realtime.md +++ b/.claude/rules/realtime.md @@ -15,6 +15,6 @@ paths: - 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** +- Dedupe ด้วย id: event ที่ id มีอยู่ใน state แล้ว ไม่ต้อง append ซ้ำ — ทำงานคู่กับ `useOptimistic` ใน PDFViewer: SSE event อาจมาถึงก่อน action เสร็จ — `createAnnotation` รับ `id` ที่ client สร้างของ optimistic row มาใช้ซ้ำ (validate เป็น UUID) เพื่อให้ dedupe ด้วย id เดียวกันตั้งแต่แรก **ไม่เกิด duplicate ชั่วขณะ** (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/app/actions/annotations.ts b/app/actions/annotations.ts index 78da8ea..bea55db 100644 --- a/app/actions/annotations.ts +++ b/app/actions/annotations.ts @@ -40,6 +40,9 @@ export type CreateAnnotationInput = { userName: string; type: AnnotationType; data: AnnotationData; + /** id ที่ client สร้าง (crypto.randomUUID) ของ optimistic row — ให้ server ใช้ id เดียวกัน + * จะได้ไม่เห็นรายการ duplicate ชั่วขณะ (row ชั่วคราว + row จริงคนละ id) — ไม่ส่ง = สร้างใหม่ */ + id?: string; }; export type CreateBookmarkInput = { @@ -407,11 +410,19 @@ export async function createAnnotation(input: CreateAnnotationInput): Promise([ + "DRAW", + "SHAPE", + "TEXTBOX", + "STAMP", + "HIGHLIGHT", + "REDACTION", +]); + /** cursor รูปยางลบของ tool erase — SVG inline สไตล์ lucide (path เดียวกับ icon บน * แถบ tool) hotspot ที่ปลายยางมุมล่างซ้าย = จุดที่ใช้ลบจริง */ const ERASER_CURSOR = `url("data:image/svg+xml,${encodeURIComponent( @@ -1550,6 +1561,7 @@ export default function PDFViewer({ applyAnnotation({ kind: "add", row: temp }); try { const saved = await createAnnotation({ + id: temp.id, // ใช้ id เดียวกับ optimistic row — กัน duplicate ชั่วขณะ (row โผล่แล้วหาย) fileId: file.id, pageIndex, userId: user.userId, @@ -1566,6 +1578,12 @@ export default function PDFViewer({ setAnnotations((prev) => prev.some((a) => a.id === saved.id) ? prev : [...prev, saved], ); + // สร้าง "วัตถุ" เสร็จ = เลือกตัวใหม่ทันที (ใช้ id จาก server — id ชั่วคราวของ + // optimistic row ถูกถอดตอน transition จบ เลือก id นั้นไฮไลต์จะหายเอง) + if (AUTO_SELECT_TYPES.has(type)) { + setSelectedId(saved.id); + setPreviewData(null); + } // F2: undo ของ create = ลบ id ปัจจุบัน; redo = สร้างใหม่ (id ใหม่) แล้วส่งต่อให้ undo รอบถัดไป let currentId = saved.id; pushHistory({ @@ -2400,6 +2418,7 @@ export default function PDFViewer({ onUpdateBookmark={(id, patch) => void handleUpdateBookmark(id, patch)} onGoToBookmark={goToBookmark} onGoToAnnotation={goToAnnotation} + selectedId={selectedId} /> diff --git a/components/sidebar/AnnotationSidebar.tsx b/components/sidebar/AnnotationSidebar.tsx index 7738bae..551bc04 100644 --- a/components/sidebar/AnnotationSidebar.tsx +++ b/components/sidebar/AnnotationSidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useState, type FormEvent } from "react"; +import { useEffect, useRef, useState, type FormEvent } from "react"; import { createPortal } from "react-dom"; import { ArrowRight, @@ -132,6 +132,7 @@ export default function AnnotationSidebar({ onUpdateBookmark, onGoToBookmark, onGoToAnnotation, + selectedId, }: { annotations: AnnotationDTO[]; bookmarks: BookmarkDTO[]; @@ -157,9 +158,26 @@ export default function AnnotationSidebar({ ) => void; onGoToBookmark: (b: BookmarkDTO) => void; onGoToAnnotation: (a: AnnotationDTO) => void; + /** id ของ annotation ที่ถูกเลือกอยู่บนหน้า PDF (null = ไม่มี) — แถวในแผงนี้ไฮไลต์ตาม */ + selectedId: string | null; }) { const [tab, setTab] = useState<"bookmarks" | "annotations">("bookmarks"); + // คลิกเลือก annotation บนหน้าเอกสาร = สลับมาแท็บ annotations (ถ้ายังอยู่ bookmarks) + // แล้วเลื่อนแถวนั้นเข้ามุมมอง — แถว render หลัง setTab จึงค่อยเลื่อนใน rAF ถัดไป + // (dep รวม tab ตาม lint — ผลข้างเคียง: เปิดแท็บนี้ตอนยังมีรายการถูกเลือก = เลื่อนไปแถวนั้นเช่นกัน) + const listRef = useRef(null); + useEffect(() => { + if (!selectedId) return; + if (tab !== "annotations") setTab("annotations"); + const raf = requestAnimationFrame(() => { + listRef.current + ?.querySelector(`[data-ann-id="${CSS.escape(selectedId)}"]`) + ?.scrollIntoView({ block: "nearest" }); + }); + return () => cancelAnimationFrame(raf); + }, [selectedId, tab]); + // ─── F14/D1: ค้นหาคำในหมายเหตุทั้งไฟล์ (highlight text / comment message) ──── const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState(null); @@ -488,9 +506,12 @@ export default function AnnotationSidebar({ return (
  • -
    +
    {tab === "bookmarks" ? ( bookmarks.length === 0 ? (

    diff --git a/docs/PLAN.md b/docs/PLAN.md index 696d2c6..5517e5e 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -150,7 +150,7 @@ Client A วาด ──▶ Server Action createAnnotation() ──▶ INSERT P - **เขียน:** ผ่าน Server Actions เท่านั้น (ต่อ `DATABASE_URL` ตรง ๆ) — และ**หลังเขียนสำเร็จทุกครั้ง ต้อง publish event** ให้ `lib/annotate-events.ts` - **อ่าน/ sync:** `useAnnotationRealtime` hook เปิด `EventSource` ไปที่ SSE route `/api/annotations/stream?fileId=` — payload เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ ไม่ต้องแปลง; `EventSource` ต่อใหม่อัตโนมัติเมื่อ connection ขาด (ห้ามทำ retry เอง) - pub/sub เป็น **in-memory ของ Next.js process เดียว** (dev / `next start` ธรรมดา) — หากอนาคตรันหลาย instance ต้องเพิ่มตัวกลาง (Redis pub/sub หรือ Postgres `LISTEN/NOTIFY`) ก่อน -- **Optimistic update ด้วย `useOptimistic` (React 18):** ตอนผู้ใช้ปล่อยเมาส์ → `startTransition(() => { addOptimistic(row ชั่วคราว); return createAnnotation(...) })` → React ถอด optimistic row ออกอัตโนมัติเมื่อ action เสร็จ และ **rollback ให้เองหาก action ล้มเหลว** (ไม่ต้องจัดการ id `temp-*` เอง) → หาก SSE event มาถึงก่อน action เสร็จ จะเห็น duplicate ชั่วขณะแล้วหายเองเมื่อ optimistic row ถูกถอด (self-healing) — hook ยัง dedupe ด้วย id กับ state ปัจจุบันอยู่ +- **Optimistic update ด้วย `useOptimistic` (React 18):** ตอนผู้ใช้ปล่อยเมาส์ → `startTransition(() => { addOptimistic(row ชั่วคราว); return createAnnotation(...) })` → React ถอด optimistic row ออกอัตโนมัติเมื่อ action เสร็จ และ **rollback ให้เองหาก action ล้มเหลว** (ไม่ต้องจัดการ id `temp-*` เอง) — client ส่ง id ของ optimistic row ให้ `createAnnotation` ใช้ซ้ำ (action validate เป็น UUID) เพื่อให้ SSE event ที่มาถึงก่อน action เสร็จ dedupe ด้วย id เดียวกันได้ทันที **ไม่เห็น duplicate ชั่วขณะ** (เดิม row จริงใช้ id ใหม่ทำให้แสดง 2 แถวแวบหนึ่งก่อน row ชั่วคราวถูกถอด) ---