เลือก 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:
parent
4934b9d0b3
commit
6ddc35d6d5
5 changed files with 67 additions and 12 deletions
|
|
@ -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**
|
||||
|
|
|
|||
|
|
@ -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<An
|
|||
}
|
||||
const type = raw.type as AnnotationType;
|
||||
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
|
||||
.insert(annotations)
|
||||
.values({ fileId, pageIndex, userId, userName, type, data })
|
||||
.returning();
|
||||
const values: typeof annotations.$inferInsert = {
|
||||
fileId,
|
||||
pageIndex,
|
||||
userId,
|
||||
userName,
|
||||
type,
|
||||
data,
|
||||
};
|
||||
if (id) values.id = id;
|
||||
const [row] = await db.insert(annotations).values(values).returning();
|
||||
const dto = toAnnotationDTO(row);
|
||||
publishAnnotateEvent(fileId, { table: "annotations", action: "create", row: dto });
|
||||
revalidateFilePages(fileId);
|
||||
|
|
|
|||
|
|
@ -366,6 +366,17 @@ const TOOLS: { key: Tool; label: string; Icon: LucideIcon }[] = [
|
|||
{ 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 บน
|
||||
* แถบ 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}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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) ────
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<AnnotationDTO[] | null>(null);
|
||||
|
|
@ -488,9 +506,12 @@ export default function AnnotationSidebar({
|
|||
return (
|
||||
<li
|
||||
key={a.id}
|
||||
className={`group rounded px-2 py-1.5 hover:bg-slate-100 ${
|
||||
a.resolved ? "opacity-60" : ""
|
||||
}`}
|
||||
data-ann-id={a.id}
|
||||
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">
|
||||
<button
|
||||
|
|
@ -677,7 +698,11 @@ export default function AnnotationSidebar({
|
|||
<li key={a.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-start gap-2 rounded px-1 py-1 text-left hover:bg-slate-100"
|
||||
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)}
|
||||
>
|
||||
<TypeIcon type={a.type} data={a.data} />
|
||||
|
|
@ -717,7 +742,7 @@ export default function AnnotationSidebar({
|
|||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<div ref={listRef} className="flex-1 overflow-y-auto p-2">
|
||||
{tab === "bookmarks" ? (
|
||||
bookmarks.length === 0 ? (
|
||||
<p className="p-4 text-center text-xs text-slate-400">
|
||||
|
|
|
|||
|
|
@ -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=<id>` — payload เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ ไม่ต้องแปลง; `EventSource` ต่อใหม่อัตโนมัติเมื่อ connection ขาด (ห้ามทำ retry เอง)
|
||||
- pub/sub เป็น **in-memory ของ Next.js process เดียว** (dev / `next start` ธรรมดา) — หากอนาคตรันหลาย instance ต้องเพิ่มตัวกลาง (Redis pub/sub หรือ Postgres `LISTEN/NOTIFY`) ก่อน
|
||||
- **Optimistic update ด้วย `useOptimistic` (React 18):** ตอนผู้ใช้ปล่อยเมาส์ → `startTransition(() => { addOptimistic(row ชั่วคราว); return createAnnotation(...) })` → React ถอด optimistic row ออกอัตโนมัติเมื่อ action เสร็จ และ **rollback ให้เองหาก action ล้มเหลว** (ไม่ต้องจัดการ id `temp-*` เอง) → หาก SSE event มาถึงก่อน action เสร็จ จะเห็น duplicate ชั่วขณะแล้วหายเองเมื่อ optimistic row ถูกถอด (self-healing) — hook ยัง dedupe ด้วย id กับ state ปัจจุบันอยู่
|
||||
- **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 ชั่วคราวถูกถอด)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue