เลือก annotation บนหน้า PDF → sidebar ตามไปเน้นรายการนั้น + สร้างเสร็จเลือกให้ทันที + กัน duplicate ชั่วขณะตอนสร้าง: (1) ส่ง selectedId ให้ AnnotationSidebar — ไฮไลต์แถว (ทั้งลิสต์หลักและผลค้นหา) + scrollIntoView block:nearest + สลับมาแท็บ Annotations ให้เองถ้ากำลังอยู่ Bookmarks (rAF รอแถว render หลังสลับแท็บค่อยเลื่อน) (2) AUTO_SELECT_TYPES — สร้าง DRAW/SHAPE/TEXTBOX/STAMP/HIGHLIGHT/REDACTION เสร็จ = เลือกตัวใหม่ทันทีด้วย saved.id (กรอบ selection + sidebar ตามไปเลย; COMMENT ยกเว้นเพราะ composer/pin ทำหน้าที่นั้นอยู่แล้ว) — แก้ปัญหา "วาดเสร็จคลิกที่เส้น tool ยังเป็นวาด sidebar นิ่ง" (3) กันอาการ row โผล่แล้วหาย (0→2→1): เดิม optimistic row กับ row จาก server คนละ id ทำให้ SSE event มาก่อน action เสร็จ dedupe ไม่ได้ — ตอนนี้ client ส่ง id ของ optimistic row ให้ createAnnotation ใช้ซ้ำ (validate UUID, ไม่ส่ง = สุ่มเองตามเดิมสำหรับ redo path) ทุกชั้น dedupe ด้วย id เดียวกันตั้งแต่แรก — sync PLAN.md §4 + rules/realtime.md ให้ตรงพฤติกรรมใหม่

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 2026-09-14 11:35:05 +07:00
parent 4934b9d0b3
commit 6ddc35d6d5
5 changed files with 67 additions and 12 deletions

View file

@ -15,6 +15,6 @@ paths:
- Event ของ `replies` (F9): create → append + dedupe ด้วย id, delete → filter ตาม id — และเมื่อ annotation แม่ถูกลบ **ต้องเคลียร์ replies ของแม่ใน state ฝั่ง client ด้วย** (CASCADE ใน DB ไม่ส่ง event ราย reply) - 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 - `restoreVersion` (F19) ก็ publish **create** event ต่อรายการที่ insert สำเร็จเหมือน action เขียนอื่น ๆ — แท็บของผู้กดเองก็ได้ event ผ่าน SSE ของตัวเอง จึงไม่ต้อง sync state ซ้ำใน client
- SSE route: ต้อง cleanup (unsubscribe + close) เมื่อ client ตัดการเชื่อมต่อ (`req.signal` abort / `cancel`) - 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 - 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** - **Map ของ listener ใน `lib/annotate-events.ts` ต้องเก็บบน `globalThis`** — module-level state จะถูก duplicate เป็น 2 อินสแตนซ์ใน Next dev (bundle ของ "use server" กับ route handler แยกกัน) แล้ว event ไม่ไหลถึงกัน — **ห้ามเปลี่ยนกลับเป็น module-level Map**

View file

@ -40,6 +40,9 @@ export type CreateAnnotationInput = {
userName: string; userName: string;
type: AnnotationType; type: AnnotationType;
data: AnnotationData; data: AnnotationData;
/** id ที่ client สร้าง (crypto.randomUUID) ของ optimistic row — ให้ server ใช้ id เดียวกัน
* จะได้ไม่เห็นรายการ duplicate ชั่วขณะ (row ชั่วคราว + row จริงคนละ id) — ไม่ส่ง = สร้างใหม่ */
id?: string;
}; };
export type CreateBookmarkInput = { export type CreateBookmarkInput = {
@ -407,11 +410,19 @@ export async function createAnnotation(input: CreateAnnotationInput): Promise<An
} }
const type = raw.type as AnnotationType; const type = raw.type as AnnotationType;
const data = validateAnnotationData(type, raw?.data); const data = validateAnnotationData(type, raw?.data);
// id จาก client (ถ้ามา) = id ของ optimistic row — SSE event/dedupe ทุกฝั่งเทียบ id เดียวกัน
const id = typeof raw?.id === "string" ? vUuid(raw.id, "id") : undefined;
const [row] = await db const values: typeof annotations.$inferInsert = {
.insert(annotations) fileId,
.values({ fileId, pageIndex, userId, userName, type, data }) pageIndex,
.returning(); userId,
userName,
type,
data,
};
if (id) values.id = id;
const [row] = await db.insert(annotations).values(values).returning();
const dto = toAnnotationDTO(row); const dto = toAnnotationDTO(row);
publishAnnotateEvent(fileId, { table: "annotations", action: "create", row: dto }); publishAnnotateEvent(fileId, { table: "annotations", action: "create", row: dto });
revalidateFilePages(fileId); revalidateFilePages(fileId);

View file

@ -366,6 +366,17 @@ const TOOLS: { key: Tool; label: string; Icon: LucideIcon }[] = [
{ key: "stamp", label: "ตรายาง (stamp)", Icon: Stamp }, { key: "stamp", label: "ตรายาง (stamp)", Icon: Stamp },
]; ];
/** ชนิดที่สร้างเสร็จแล้วเลือกรายการใหม่ให้อัตโนมัติ (กรอบ selection + sidebar ไฮไลต์)
* ทุกชนิดยกเว้น COMMENT — เปิด composer พิมพ์ก่อนบันทึก และคลิก pin = เลือกอยู่แล้ว */
const AUTO_SELECT_TYPES = new Set<AnnotationType>([
"DRAW",
"SHAPE",
"TEXTBOX",
"STAMP",
"HIGHLIGHT",
"REDACTION",
]);
/** cursor รูปยางลบของ tool erase — SVG inline สไตล์ lucide (path เดียวกับ icon บน /** cursor รูปยางลบของ tool erase — SVG inline สไตล์ lucide (path เดียวกับ icon บน
* แถบ tool) hotspot ที่ปลายยางมุมล่างซ้าย = จุดที่ใช้ลบจริง */ * แถบ tool) hotspot ที่ปลายยางมุมล่างซ้าย = จุดที่ใช้ลบจริง */
const ERASER_CURSOR = `url("data:image/svg+xml,${encodeURIComponent( const ERASER_CURSOR = `url("data:image/svg+xml,${encodeURIComponent(
@ -1550,6 +1561,7 @@ export default function PDFViewer({
applyAnnotation({ kind: "add", row: temp }); applyAnnotation({ kind: "add", row: temp });
try { try {
const saved = await createAnnotation({ const saved = await createAnnotation({
id: temp.id, // ใช้ id เดียวกับ optimistic row — กัน duplicate ชั่วขณะ (row โผล่แล้วหาย)
fileId: file.id, fileId: file.id,
pageIndex, pageIndex,
userId: user.userId, userId: user.userId,
@ -1566,6 +1578,12 @@ export default function PDFViewer({
setAnnotations((prev) => setAnnotations((prev) =>
prev.some((a) => a.id === saved.id) ? prev : [...prev, saved], 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 รอบถัดไป // F2: undo ของ create = ลบ id ปัจจุบัน; redo = สร้างใหม่ (id ใหม่) แล้วส่งต่อให้ undo รอบถัดไป
let currentId = saved.id; let currentId = saved.id;
pushHistory({ pushHistory({
@ -2400,6 +2418,7 @@ export default function PDFViewer({
onUpdateBookmark={(id, patch) => void handleUpdateBookmark(id, patch)} onUpdateBookmark={(id, patch) => void handleUpdateBookmark(id, patch)}
onGoToBookmark={goToBookmark} onGoToBookmark={goToBookmark}
onGoToAnnotation={goToAnnotation} onGoToAnnotation={goToAnnotation}
selectedId={selectedId}
/> />
</aside> </aside>

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useRef, useState, type FormEvent } from "react"; import { useEffect, useRef, useState, type FormEvent } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { import {
ArrowRight, ArrowRight,
@ -132,6 +132,7 @@ export default function AnnotationSidebar({
onUpdateBookmark, onUpdateBookmark,
onGoToBookmark, onGoToBookmark,
onGoToAnnotation, onGoToAnnotation,
selectedId,
}: { }: {
annotations: AnnotationDTO[]; annotations: AnnotationDTO[];
bookmarks: BookmarkDTO[]; bookmarks: BookmarkDTO[];
@ -157,9 +158,26 @@ export default function AnnotationSidebar({
) => void; ) => void;
onGoToBookmark: (b: BookmarkDTO) => void; onGoToBookmark: (b: BookmarkDTO) => void;
onGoToAnnotation: (a: AnnotationDTO) => void; onGoToAnnotation: (a: AnnotationDTO) => void;
/** id ของ annotation ที่ถูกเลือกอยู่บนหน้า PDF (null = ไม่มี) — แถวในแผงนี้ไฮไลต์ตาม */
selectedId: string | null;
}) { }) {
const [tab, setTab] = useState<"bookmarks" | "annotations">("bookmarks"); const [tab, setTab] = useState<"bookmarks" | "annotations">("bookmarks");
// คลิกเลือก annotation บนหน้าเอกสาร = สลับมาแท็บ annotations (ถ้ายังอยู่ bookmarks)
// แล้วเลื่อนแถวนั้นเข้ามุมมอง — แถว render หลัง setTab จึงค่อยเลื่อนใน rAF ถัดไป
// (dep รวม tab ตาม lint — ผลข้างเคียง: เปิดแท็บนี้ตอนยังมีรายการถูกเลือก = เลื่อนไปแถวนั้นเช่นกัน)
const listRef = useRef<HTMLDivElement>(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) ──── // ─── F14/D1: ค้นหาคำในหมายเหตุทั้งไฟล์ (highlight text / comment message) ────
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<AnnotationDTO[] | null>(null); const [searchResults, setSearchResults] = useState<AnnotationDTO[] | null>(null);
@ -488,9 +506,12 @@ export default function AnnotationSidebar({
return ( return (
<li <li
key={a.id} key={a.id}
className={`group rounded px-2 py-1.5 hover:bg-slate-100 ${ data-ann-id={a.id}
a.resolved ? "opacity-60" : "" className={`group rounded px-2 py-1.5 ${
}`} a.id === selectedId
? "bg-primary/10 ring-1 ring-inset ring-primary/30"
: "hover:bg-slate-100"
} ${a.resolved ? "opacity-60" : ""}`}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
@ -677,7 +698,11 @@ export default function AnnotationSidebar({
<li key={a.id}> <li key={a.id}>
<button <button
type="button" type="button"
className="flex w-full items-start gap-2 rounded px-1 py-1 text-left hover:bg-slate-100" className={`flex w-full items-start gap-2 rounded px-1 py-1 text-left ${
a.id === selectedId
? "bg-primary/10 ring-1 ring-inset ring-primary/30"
: "hover:bg-slate-100"
}`}
onClick={() => onGoToAnnotation(a)} onClick={() => onGoToAnnotation(a)}
> >
<TypeIcon type={a.type} data={a.data} /> <TypeIcon type={a.type} data={a.data} />
@ -717,7 +742,7 @@ export default function AnnotationSidebar({
))} ))}
</div> </div>
<div className="flex-1 overflow-y-auto p-2"> <div ref={listRef} className="flex-1 overflow-y-auto p-2">
{tab === "bookmarks" ? ( {tab === "bookmarks" ? (
bookmarks.length === 0 ? ( bookmarks.length === 0 ? (
<p className="p-4 text-center text-xs text-slate-400"> <p className="p-4 text-center text-xs text-slate-400">

View file

@ -150,7 +150,7 @@ Client A วาด ──▶ Server Action createAnnotation() ──▶ INSERT P
- **เขียน:** ผ่าน Server Actions เท่านั้น (ต่อ `DATABASE_URL` ตรง ๆ) — และ**หลังเขียนสำเร็จทุกครั้ง ต้อง publish event** ให้ `lib/annotate-events.ts` - **เขียน:** ผ่าน Server Actions เท่านั้น (ต่อ `DATABASE_URL` ตรง ๆ) — และ**หลังเขียนสำเร็จทุกครั้ง ต้อง publish event** ให้ `lib/annotate-events.ts`
- **อ่าน/ sync:** `useAnnotationRealtime` hook เปิด `EventSource` ไปที่ SSE route `/api/annotations/stream?fileId=<id>` — payload เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ ไม่ต้องแปลง; `EventSource` ต่อใหม่อัตโนมัติเมื่อ connection ขาด (ห้ามทำ retry เอง) - **อ่าน/ sync:** `useAnnotationRealtime` hook เปิด `EventSource` ไปที่ SSE route `/api/annotations/stream?fileId=<id>` — payload เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ ไม่ต้องแปลง; `EventSource` ต่อใหม่อัตโนมัติเมื่อ connection ขาด (ห้ามทำ retry เอง)
- pub/sub เป็น **in-memory ของ Next.js process เดียว** (dev / `next start` ธรรมดา) — หากอนาคตรันหลาย instance ต้องเพิ่มตัวกลาง (Redis pub/sub หรือ Postgres `LISTEN/NOTIFY`) ก่อน - 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 ชั่วคราวถูกถอด)
--- ---