perf(pdf-viewer): limit drag preview re-render to its page
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 45s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 3s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s

แก้ "ลากย้ายเส้น/กล่องข้อความ/ตรายาง/pin ไม่ smooth เป็น step"
(คำขอผู้ใช้ 2026-09-29):

- สาเหตุ: preview ของการลาก (onPreview → setPreviewData) เป็น state
  ระดับ root ของ PDFViewer — ทุก pointermove ระหว่างลาก re-render ทั้ง
  ต้นไม้ (toolbar + sidebar + ทุกหน้าที่ mounted ตาม virtualization)
  ทั้งที่ผู้ใช้ลากอยู่หน้าเดียว และค่าถูกอ่านแค่จุดเดียวคือ substitution
  ของหน้าที่ลาก — เทียบกับการวาดเส้นที่ smooth เพราะ live state อยู่ใน
  InkLayer เอง + rAF throttle (FIX-8) อยู่แล้ว
- แก้: ย้าย preview state ลงเป็น state ท้องถิ่นของ PdfPage (substitution
  ย้ายตามมาอยู่ก่อน derive layers ทั้งหมด) — ระหว่างลาก re-render เฉพาะ
  หน้าที่ลาก; root สั่งเคลียร์ผ่าน counter previewClear (useReducer)
  แทน setter ตรง ครบทุกจุดเดิม 9 จุด (ปิด popup/Escape/auto-select/
  delete/ก่อนคิว update/deselect/goToAnnotation/เลือกรายการ)
- onPreview ของ AnnotationSelectionLayer throttle ด้วย rAF ≤1 ครั้ง/
  เฟรม (pattern FIX-8 ของ InkLayer): ทุก pointermove ยังคำนวณ + เก็บ
  latestRef เสมอ (ค่า commit ตอน pointerup เป็นค่าล่าสุดสุดเสมอ) เฉพาะ
  การ render ที่จัดคิวตามเฟรม + cancel ครบทุกทางออกของ drag
  (pointerup/pointercancel/unmount)
- แก้ตาม code review 2026-09-29: preview เก็บเป็น {id, data} เทียบ id
  เจ้าของตอน substitute แทน selectedId — กันจอสัมผัสเปลี่ยน selection
  กลาง drag (นิ้วหนึ่งลากค้าง อีกนิ้วเลือกอื่น) แล้ว preview ของรายการเก่า
  ไปทดแทนรายการใหม่หนึ่งเฟรม (การเคลียร์จาก root เป็น passive effect
  หลัง paint)
- sync สถาปัตยกรรมใหม่ใน .claude/rules/pdf-viewer.md (หัวข้อกรอบ
  selection F1)

ผ่าน code-reviewer + plan-reviewer (ไม่มี bug ค้าง/ไม่ละเมิด invariant) +
tsc + vitest 207/207 + e2e Playwright: ลาก DRAW FPS≈60.6 (commit
เลื่อนตรงระยะ/undo/redo/reload คงอยู่) + ลาก TEXTBOX FPS≈59-60.9 ไม่มี
เฟรม drop (commit/reload ตรงเป๊ะ)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-09-29 14:49:05 +07:00
parent 0681e056fd
commit e4ecc026b0
3 changed files with 80 additions and 24 deletions

View file

@ -183,6 +183,26 @@ export default function AnnotationSelectionLayer({
const dragRef = useRef<DragState | null>(null);
const movedRef = useRef(false);
const latestRef = useRef<AnnotationData | null>(null);
// rAF ของ preview ระหว่างลาก — ยิง onPreview ≤1 ครั้ง/เฟรม (pattern เดียวกับ FIX-8
// ของ InkLayer): pointermove ยิงถี่กว่าเฟรมได้ ทุก event ยังคำนวณ+เก็บค่าล่าสุดใน
// latestRef เสมอ (ค่าตอน commit ต้องล่าสุดสุด) เฉพาะการ render ที่จัดคิวตามเฟรม
const previewRafRef = useRef<number | null>(null);
/** ยกเลิก rAF preview ที่ค้าง — เรียกก่อนเคลียร์ preview ทุกทางออกของ drag (ไม่งั้น
* callback ยิงตามหลัง ดัน preview เก่ากลับมาตอน dragRef เคลียร์ไปแล้ว) */
function cancelPreviewRaf() {
if (previewRafRef.current === null) return;
cancelAnimationFrame(previewRafRef.current);
previewRafRef.current = null;
}
// เลิกลากกลางคันแล้ว unmount — ยกเลิก rAF ที่ค้างอยู่ด้วย (เหมือน InkLayer)
useEffect(
() => () => {
cancelPreviewRaf();
},
[],
);
// อุปกรณ์จอสัมผัส (pointer หลัก coarse) — คำนวณครั้งเดียวตอน mount เหมือน
// coarseTouch ของ PDFViewer: .coarse-36 ขยายปุ่ม pin เป็น 36px บนพวกนี้
@ -395,7 +415,14 @@ export default function AnnotationSelectionLayer({
}
movedRef.current = true;
latestRef.current = next;
onPreview(next);
// render preview ครั้งเดียวต่อ animation frame — callback อ่าน latestRef ตอนเฟรม
// ยิงจริง ๆ เพื่อให้ได้ค่าใหม่สุดเสมอแม้ event ถี่ (แก้ลากไม่ smooth 2026-09-29)
if (previewRafRef.current === null) {
previewRafRef.current = requestAnimationFrame(() => {
previewRafRef.current = null;
if (dragRef.current && latestRef.current) onPreview(latestRef.current);
});
}
}
function endDrag(e: ReactPointerEvent<HTMLElement>) {
@ -403,6 +430,7 @@ export default function AnnotationSelectionLayer({
if (!st) return;
e.stopPropagation();
dragRef.current = null;
cancelPreviewRaf();
onPreview(null);
if (movedRef.current && latestRef.current) onCommit(annotation.id, latestRef.current);
movedRef.current = false;
@ -412,6 +440,7 @@ export default function AnnotationSelectionLayer({
function cancelDrag() {
if (!dragRef.current) return;
dragRef.current = null;
cancelPreviewRaf();
movedRef.current = false;
latestRef.current = null;
onPreview(null);

View file

@ -6,6 +6,7 @@ import {
useEffect,
useMemo,
useOptimistic,
useReducer,
useRef,
useState,
startTransition,
@ -600,11 +601,14 @@ function PdfPage({
liveMark,
liveMarkStyle,
selectPreview,
annotations,
annotations: annotationsProp,
replies,
openPinId,
ownUserId,
selectedId,
/** root สั่งเคลียร์ preview ของการลาก (bump counter — ปิด popup/เปลี่ยน selection/ก่อน
* คิว update): ทุกครั้งที่ค่าเปลี่ยน หน้าเคลียร์ previewData ท้องถิ่นของตัวเอง */
previewClear,
editingCommentId,
commentEditDraft,
onCommentEditDraft,
@ -612,7 +616,6 @@ function PdfPage({
textbox,
onSelectAnnotation,
onPinToggle,
onAnnotationPreview,
onAnnotationUpdate,
onAnnotationPatch,
onSetResolved,
@ -659,6 +662,8 @@ function PdfPage({
openPinId: string | null;
ownUserId: string;
selectedId: string | null;
/** root สั่งเคลียร์ preview ของการลาก (bump counter — ดูตัวแปร previewClear ของ viewer) */
previewClear: number;
/** F13/A6: comment ที่กำลังแก้ไขข้อความอยู่ (null = ไม่มี) */
editingCommentId: string | null;
commentEditDraft: string;
@ -669,7 +674,6 @@ function PdfPage({
/** คลิกรายการ overlay (เช่น TEXTBOX) → เลือกรายการนั้น (F1) */
onSelectAnnotation: (id: string) => void;
onPinToggle: (id: string | null) => void;
onAnnotationPreview: (data: AnnotationData | null) => void;
onAnnotationUpdate: (id: string, data: AnnotationData) => void;
/** ตัวควบคุมเล็กของแผง commit เป็น patch — viewer ผสานกับ data ล่าสุดตอนถึงคิว
* update กัน stale base ทับค่าที่เพิ่งเปลี่ยน (AnnotationPatch, code review 2026-09-28) */
@ -690,6 +694,22 @@ function PdfPage({
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const textLayerRef = useRef<HTMLDivElement | null>(null);
// ระหว่างลากย้าย/ปรับขนาดรายการที่เลือก: data ชั่วคราวให้ layer เดิมของชนิดนั้น render
// ตาม (null = เลิก preview) — state อยู่ที่หน้านี้ ไม่ใช่ root ของ viewer: ต่อ pointermove
// ตอนลากต้อง re-render เฉพาะหน้าที่ลากเท่านั้น (เดิม setPreviewData อยู่ root แล้วแทนค่า
// กลับลงมาให้ทุกหน้า = จ่าย render ทั้งต้นไม้ต่อเฟรม จนลากเป็น step — แก้ 2026-09-29)
// เก็บพร้อม id เจ้าของไม่อิง selectedId ตอน substitute: การเคลียร์จาก root มาเป็น
// passive effect หลัง paint — อิง selectedId แล้วจอสัมผัสเปลี่ยน selection กลาง drag
// (นิ้วหนึ่งลากค้าง อีกนิ้วเลือกอื่น) จะเอา preview ของรายการเก่าไปทดแทนรายการใหม่
// หนึ่งเฟรม (code review 2026-09-29)
const [preview, setPreview] = useState<{ id: string; data: AnnotationData } | null>(null);
// root สั่งเคลียร์ preview ผ่าน counter (ปิด popup/เปลี่ยน selection/ก่อนคิว update) —
// ครั้งแรกตอน mount preview ยัง null อยู่แล้ว จึงเป็น no-op
useEffect(() => {
setPreview(null);
}, [previewClear]);
// N3: หน้ารูปภาพไม่มี canvas/textLayer — effect ของ pdfjs ทำงานเฉพาะหน้า pdf
const pdfjsLib = content.kind === "pdf" ? content.pdfjs : null;
const pdfDoc = content.kind === "pdf" ? content.doc : null;
@ -761,6 +781,15 @@ function PdfPage({
if (content.kind === "image") onTextLayerRendered?.(pageNumber - 1, 0);
}, [content.kind, pageNumber]);
// ระหว่างลาก: แทน data ของรายการที่เลือกด้วย preview ก่อนแยกไป layer ต่าง ๆ +
// กรอบ selection (เดิมแทนตอน root ส่ง prop — ย้ายลงมาพร้อม state ของหน้า, 2026-09-29)
// เทียบ id กับเจ้าของ preview โดยตรง (ไม่ใช่ selectedId ปัจจุบัน) — ดู comment ของ state
const annotations = preview
? annotationsProp.map((a) =>
a.id === preview.id ? { ...a, data: preview.data } : a,
)
: annotationsProp;
const highlights = annotations.filter(isHighlight);
const inkStrokes: InkStroke[] = annotations
.filter(isDraw)
@ -1158,7 +1187,8 @@ function PdfPage({
editable={ownUserId !== "" && selected.userId === ownUserId}
pageBaseWidth={pageBaseWidth}
disabled={overlayFrozen}
onPreview={onAnnotationPreview}
// ผูก id เจ้าของ preview ตอนรับค่า (ไม่ใช่ตอน substitute) — ดู comment ของ state preview
onPreview={(d) => setPreview(d && selected ? { id: selected.id, data: d } : null)}
onCommit={onAnnotationUpdate}
onDelete={onErase}
onBookmark={(y) => onAnnotationBookmark(selected, y)}
@ -1358,7 +1388,11 @@ export default function PDFViewer({
const bookmarkSavedTimerRef = useRef<number | null>(null);
/** L6: สถานะกำลังบันทึกของปุ่มใน popup bookmark — กันกดซ้ำเพราะคิดว่ากดไม่เข้า */
const [bookmarkSaving, setBookmarkSaving] = useState(false);
const [previewData, setPreviewData] = useState<AnnotationData | null>(null);
// "เคลียร์ preview ของการลาก" จาก root — preview เป็น state ท้องถิ่นของหน้า (PdfPage)
// ตั้งแต่ 2026-09-29 root จึงสั่งเคลียร์ผ่าน counter นี้แทน setter ตรง ๆ: ทุกจุดที่เดิม
// setPreviewData(null) (ปิด popup/เปลี่ยน selection/ก่อนคิว update — กัน preview เก่าค้าง
// ทับรายการที่เลือกใหม่) bump ค่านี้ แล้วทุกหน้า effect เคลียร์ previewData ตัวเอง
const [previewClear, bumpPreviewClear] = useReducer((c: number) => c + 1, 0);
// F13/A6: แก้ไขข้อความ comment ของตัวเองใน popover — บันทึกผ่าน updateAnnotation (reuse F1)
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
const [commentEditDraft, setCommentEditDraft] = useState("");
@ -1941,7 +1975,7 @@ export default function PDFViewer({
setComposer(null);
setOpenPinId(null);
setSelectedId(null);
setPreviewData(null);
bumpPreviewClear();
setEditingCommentId(null);
setTextboxComposer(null);
setEditingTextboxId(null);
@ -2136,7 +2170,7 @@ export default function PDFViewer({
// optimistic row ถูกถอดตอน transition จบ เลือก id นั้นไฮไลต์จะหายเอง)
if (AUTO_SELECT_TYPES.has(type)) {
setSelectedId(saved.id);
setPreviewData(null);
bumpPreviewClear();
// แผง selection กำลังจะโผล่ = ปิดเมนู tool/popup bookmark ที่เปิดค้างก่อน — บน iOS
// การลาก (คลุมข้อความ/วาดเส้น) ไม่ยิง compat mousedown ให้ outside-click ปิดเมนู
// เอง (Android/desktop ปิดไปแล้วตั้งแต่เริ่มกด) ไม่ปิดตรงนี้ = เมนูซ้อนแผงบนจอสัมผัส
@ -2184,12 +2218,12 @@ export default function PDFViewer({
if (pending) {
pending.cancelled = true;
setSelectedId(null);
setPreviewData(null);
bumpPreviewClear();
return;
}
const target = annotations.find((a) => a.id === id); // เก็บข้อมูลก่อนลบไว้ทำ undo
setSelectedId(null);
setPreviewData(null);
bumpPreviewClear();
await startTransition(async () => {
applyAnnotation({ kind: "remove", id });
try {
@ -2245,7 +2279,7 @@ export default function PDFViewer({
if (!user) return resolve();
const current = annotations.find((a) => a.id === id);
if (!current || current.userId !== user.userId) return resolve(); // ของคนอื่นแก้ไม่ได้ (D9)
setPreviewData(null);
bumpPreviewClear();
startTransition(async () => {
try {
applyAnnotation({
@ -2935,7 +2969,7 @@ export default function PDFViewer({
// ตาม hit-test ทีหลังจุดนี้เหมือนเดิม พฤติกรรมจึงเท่าเดิม
if (selectedId !== null) {
setSelectedId(null);
setPreviewData(null);
bumpPreviewClear();
}
if (tool === "comment" && user) {
const position = getPointerPercent(e, pageEl);
@ -3019,7 +3053,7 @@ export default function PDFViewer({
// ของรายการอยู่ใต้ sticky header (เดิมเลื่อนไปขอบบนหน้าตรง ๆ เลยถูก header บัง
// กรณีรายการอยู่ส่วนบนสุดของหน้า)
setSelectedId(a.id);
setPreviewData(null);
bumpPreviewClear();
setOpenPinId(null); // กระโดดไปเลือกรายการอื่น = ปิด popover ของ pin เดิมทิ้ง
const el = pageWrapRefs.current[a.pageIndex];
const main = mainRef.current;
@ -3048,7 +3082,7 @@ export default function PDFViewer({
/** คลิกรายการ overlay (TEXTBOX ฯลฯ) = เลือกรายการนั้น (F1) */
function handleSelectAnnotation(id: string) {
setSelectedId(id);
setPreviewData(null);
bumpPreviewClear();
// เลือกรายการอื่น = ปิด popover ของ pin เดิม (คลิก pin จะไม่ผ่านที่นี่ — มัน toggle เอง)
setOpenPinId(null);
}
@ -4390,18 +4424,12 @@ export default function PDFViewer({
: {}),
}}
selectPreview={selectPreview}
annotations={visibleAnnotations
.filter((a) => a.pageIndex === i)
// ระหว่างลาก: แทน data ด้วย preview ให้ layer เดิม (highlight/ink/pin) render ตาม
.map((a) =>
a.id === selectedId && previewData
? { ...a, data: previewData }
: a,
)}
annotations={visibleAnnotations.filter((a) => a.pageIndex === i)}
replies={visibleReplies}
openPinId={openPinId}
ownUserId={user?.userId ?? ""}
selectedId={selectedId}
previewClear={previewClear}
editingCommentId={editingCommentId}
commentEditDraft={commentEditDraft}
onCommentEditDraft={setCommentEditDraft}
@ -4416,7 +4444,6 @@ export default function PDFViewer({
}}
onSelectAnnotation={handleSelectAnnotation}
onPinToggle={handlePinToggle}
onAnnotationPreview={setPreviewData}
onAnnotationUpdate={(id, data) => {
void handleUpdateAnnotation(id, data);
}}