เลือก 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)
|
- 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**
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 ชั่วคราวถูกถอด)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue