popup ซ้อนกัน/ไม่ปิดตอนกดปุ่ม tool + แท็บที่คั่นหน้ากลับไม่ได้ + งานค้างใน working tree: ไฟล์รูปภาพ annotate ได้ (N3) + อัปโหลดไฟล์ (N4):
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 2s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 40s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s

ส่วนที่ 1 — แก้ popup ตาม bug report ("กดแก้ไขแล้วไปกดที่ tool popup ของแก้ไขไม่ปิด/แสดงซ้อนกัน"):
(1) ปุ่ม tool แบบ dropdown ทั้ง 5 (highlight/draw/shape/stamp/textbox) เรียก switchTool เสมอแม้เป็น tool เดิม — เดิมกดปุ่ม tool ที่ active อยู่จะ toggle เมนูอย่างเดียวไม่เรียก switchTool (ซึ่งเป็นที่เดียวที่เคลียร์ popup บนหน้า: editor/แผง selection/popover) ผลคือ popup แก้ไขค้างเปิด + dropdown เปิดซ้อนขึ้นมาอีกชั้น — ตอนนี้จำ intent เปิด/พับเมนูไว้ก่อนเรียก (switchTool ปิดเมนูทุกตัว) แล้วเปิดคืนเฉพาะที่ตั้งใจ; toggle พับเมนูและกลไกกลืนคลิก M4 เท่าเดิม (mousedown บนปุ่ม mark อยู่แล้ว)
(2) switchTool ปิด popup bookmark ด้วย + openBookmarkComposer ปิดเมนูทั้ง 5 ก่อนเปิด popup (เดิมคีย์ลัด B ระหว่างเมนูเปิด = สอง popup ซ้อนกัน)
(3) คลิกหน้าว่าง = ปิด popover ของ comment pin + เลือกรายการอื่น/goToAnnotation ปิด popover เดิมทิ้ง (เดิมปิด popover ได้แค่กด pin เดิม/Escape/สลับ tool)
(4) COMMENT ที่เปิด popover อยู่ไม่ render กรอบ selection/แผงเครื่องมือ — bbox ของ COMMENT ครอบพื้นที่ pin พอดี แผงจะทับ popover และแถบ handle บังการคลิก (sidebar ยังตาม selectedId ไฮไลต์เหมือนเดิม)
(5) แก้แท็บ sidebar ตาม bug report ("เลือก Annotation แล้วจะกลับไป tabs bookmarks ไม่ได้"): effect เดิม dep รวม tab และบังคับ setTab ไปแท็บ annotations ทุกครั้งที่รันตราบ selectedId ไม่ null ทำให้กดแท็บที่คั่นหน้าแล้วถูกดึงกลับทันที — ใช้ ref จำ id ที่ประกาศแล้ว บังคับสลับเฉพาะตอน selectedId เพิ่งเปลี่ยน เคลียร์เป็น null ตอน deselect ให้เลือกรายการเดิมซ้ำภายหลังยังสลับแท็บให้ ส่วน scroll แถวตอนเปิดแท็บหมายเหตุคงเดิม
- sync rules/pdf-viewer.md: กติกาใหม่ popup บนหน้าเปิดได้ทีละชุดห้ามค้างเปิดซ้อนกัน (ปุ่ม dropdown เรียก switchTool เสมอ + วงจรปิด popover/แผง) และยกเว้น markMenuClosed ของคีย์ B ในกติกา M4
- verify: tsc ผ่าน, vitest 120/120, Playwright 18 เคสผ่าน (repro ตรงตามรายงาน: สร้าง textbox → แก้ไขข้อความ → กดปุ่ม tool เดิม → editor+แผงปิด+เมนูใช้ได้; popover pin ไม่ซ้อนแผง/ปิดด้วยคลิกหน้าว่าง; popup bookmark ปิดตอนสลับ tool; แท็บที่คั่นหน้ากลับได้)

ส่วนที่ 2 — งานที่ค้างใน working tree ก่อนหน้า (ตามแผน docs/PLAN-IMAGE.md):
- N3 รองรับไฟล์รูปภาพ jpg/jpeg/png/tif เป็นเอกสาร annotate ได้ครบทุกชนิด (viewer render img แทน canvas ไม่มี textLayer, scale contract เดียวกับ export) + TIFF หลายเฟรม = หลายหน้า (decode ผ่าน utif2 dynamic import → PNG blob URL ต่อเฟรม ฝั่ง client) + export PDF ฝังรูป (utils/image-to-pdf.ts: embedJpg/embedPng + EXIF rotate) + utils/file-kind.ts derive ชนิดไฟล์ + tests ของทั้งสอง utils
- N4 อัปโหลดไฟล์ PDF/รูป ≤100MB ผ่านหน้ารายการ (components/UploadFileForm.tsx + app/actions/files.ts: validate + เขียนไฟล์ใหม่ที่ public/uploads/ เท่านั้น — invariant #1)
- เอกสาร: docs/PLAN-IMAGE.md เพิ่มใหม่ + sync PLAN.md/PLAN-FEAT.md/CLAUDE.md/README/gap-analysis/data-layer rule

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 2026-09-15 14:12:51 +07:00
parent 0c4af09e85
commit 9cc38835f0
25 changed files with 1269 additions and 77 deletions

View file

@ -0,0 +1,57 @@
"use client";
// N4 — ฟอร์มอัปโหลดไฟล์เอกสารแบบง่ายบนหน้ารายการ (PDF/รูป รับพร้อมกัน)
// เสร็จแล้วรายการโผล่เองจาก revalidatePath("/") ของ action
import { useState, useTransition } from "react";
import { uploadFile } from "@/app/actions/files";
const ACCEPT = ".pdf,.jpg,.jpeg,.png,.tif,.tiff";
export default function UploadFileForm() {
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
setError(null);
startTransition(async () => {
try {
const result = await uploadFile(new FormData(form));
if (result.ok) {
form.reset();
} else {
setError(result.error);
}
} catch {
// action fail ที่ระดับ transport (เช่น body ใหญ่เกิน bodySizeLimit ของ next.config)
setError("อัปโหลดไม่สำเร็จ — ลองใหม่อีกครั้ง");
}
});
};
return (
<form
onSubmit={onSubmit}
className="mt-3 flex flex-wrap items-center gap-2 rounded-xl border border-dashed border-slate-300 bg-white/60 p-4"
>
<input
type="file"
name="file"
accept={ACCEPT}
required
disabled={pending}
className="block w-full max-w-xs text-sm text-slate-600 file:mr-3 file:cursor-pointer file:rounded-md file:border-0 file:bg-primary/10 file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary"
/>
<button
type="submit"
disabled={pending}
className="shrink-0 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-white transition hover:opacity-90 disabled:opacity-50"
>
{pending ? "กำลังอัปโหลด…" : "อัปโหลด"}
</button>
{error && <p className="w-full text-xs text-red-600">{error}</p>}
</form>
);
}

View file

@ -114,6 +114,8 @@ import {
type Size,
} from "@/utils/coordinates";
import { getLocalUser, type LocalUser } from "@/utils/user";
import { fileKindFromUrl, imageFormatFromUrl } from "@/utils/file-kind";
import { decodeTiffPages } from "@/utils/tiff-client";
import {
TEXTBOX_DEFAULT_WIDTH_PCT,
annotationBBox,
@ -538,9 +540,18 @@ function ReplyBox({
// ─── หน้าเดียว: canvas + highlight-layer + textLayer + InkLayer + pins ──────
/** N3: เนื้อหาหนึ่งหน้า — pdf (pdfjs) หรือรูปภาพ (src = URL รูป; null = เฟรม TIFF decode
* ไม่ได้ → placeholder เทา แต่หน้ายังมีขนาดจาก IFD header ให้ scroll/bookmark เสถียร) */
type PageContent =
| { kind: "pdf"; pdfjs: typeof import("pdfjs-dist"); doc: import("pdfjs-dist").PDFDocumentProxy }
| { kind: "image"; src: string | null };
/** N3: หน้ารูปภาพ normalize กว้าง 595 (เท่าหน้า A4 pt) — scale contract เดียวกับ export
* (utils/image-to-pdf.ts) ให้ zoom %/fit-width clamp 0.5–3/fontSize % ทำงานเหมือนหน้า PDF */
const IMAGE_PAGE_WIDTH = 595;
function PdfPage({
pdfjs,
doc,
content,
pageNumber,
width,
height,
@ -578,8 +589,7 @@ function PdfPage({
onErase,
onTextLayerRendered,
}: {
pdfjs: typeof import("pdfjs-dist");
doc: import("pdfjs-dist").PDFDocumentProxy;
content: PageContent;
/** 1-based */
pageNumber: number;
width: number;
@ -641,12 +651,17 @@ function PdfPage({
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const textLayerRef = useRef<HTMLDivElement | null>(null);
// N3: หน้ารูปภาพไม่มี canvas/textLayer — effect ของ pdfjs ทำงานเฉพาะหน้า pdf
const pdfjsLib = content.kind === "pdf" ? content.pdfjs : null;
const pdfDoc = content.kind === "pdf" ? content.doc : null;
useEffect(() => {
let cancelled = false;
let renderTask: import("pdfjs-dist").RenderTask | null = null;
if (!pdfjsLib || !pdfDoc) return;
(async () => {
const page = await doc.getPage(pageNumber);
const page = await pdfDoc.getPage(pageNumber);
if (cancelled) return;
const scale = width / page.getViewport({ scale: 1 }).width;
@ -677,7 +692,7 @@ function PdfPage({
if (cancelled) return;
container.replaceChildren();
container.style.setProperty("--scale-factor", String(scale));
const textLayer = new pdfjs.TextLayer({
const textLayer = new pdfjsLib.TextLayer({
textContentSource: textContent,
container,
viewport,
@ -698,7 +713,14 @@ function PdfPage({
// onTextLayerRendered ตั้งใจไม่อยู่ใน deps: callback ต้องเป็น useCallback [] เสถียร
// เท่านั้น (handleTextLayerRendered) — ถ้าวันหน้าทำให้ identity เปลี่ยนได้ ต้องย้าย
// ไปเก็บใน ref ก่อน ไม่งั้น effect นี้จะไม่รันซ้ำและค่า span count ของ chip จะเก่า
}, [pdfjs, doc, pageNumber, width, height]);
}, [pdfjsLib, pdfDoc, pageNumber, width, height]);
// N3: หน้ารูปไม่มี textLayer — รายงาน span count = 0 ให้ hint "ภาพสแกน" ของ tool
// highlight ทำงาน (พฤติกรรมเดียวกับ PDF สแกน) — onTextLayerRendered ไม่อยู่ใน deps
// เพราะเป็น useCallback [] เสถียร (เหมือน effect ด้านบน)
useEffect(() => {
if (content.kind === "image") onTextLayerRendered?.(pageNumber - 1, 0);
}, [content.kind, pageNumber]);
const highlights = annotations.filter(isHighlight);
const inkStrokes: InkStroke[] = annotations
@ -812,8 +834,22 @@ function PdfPage({
return (
<>
{/* z-1: canvas — pointer-events: none เสมอ */}
<canvas ref={canvasRef} className="absolute inset-0 z-[1] pointer-events-none" />
{/* z-1: canvas (pdf) / <img> (รูปภาพ N3) — pointer-events: none เสมอ (invariant #3)
รูป: browser render ที่ devicePixelRatio เต็มเอง เทียบเท่า canvas×dpr ของ pdfjs */}
{content.kind === "pdf" ? (
<canvas ref={canvasRef} className="absolute inset-0 z-[1] pointer-events-none" />
) : content.src ? (
<img
src={content.src}
alt=""
draggable={false}
className="pointer-events-none absolute inset-0 z-[1] h-full w-full select-none"
/>
) : (
<div className="pointer-events-none absolute inset-0 z-[1] flex items-center justify-center bg-slate-200 text-xs text-slate-500">
ถอดรหัสหน้านี้ไม่ได้
</div>
)}
{/* z-2: highlight-layer — pointer-events: none เสมอ, อยู่หน้า textLayer ใน DOM order
แสดงผลตาม style: ทึบ / เส้นใต้ (ขอบล่าง) / ขีดฆ่า (กลางบรรทัด) / ขยุกขยิก (svg) */}
@ -856,12 +892,15 @@ function PdfPage({
)}
</div>
{/* z-2: textLayer (pdfjs) — อยู่หลัง highlight-layer ใน DOM → อยู่บนสุดของ z-2 */}
<div
ref={textLayerRef}
className="textLayer absolute inset-0 z-[2]"
style={{ pointerEvents: overlayFrozen ? "none" : "auto" }}
/>
{/* z-2: textLayer (pdfjs — pdf เท่านั้น, N3: หน้ารูปไม่มี) — อยู่หลัง highlight-layer
ใน DOM → อยู่บนสุดของ z-2 (conditional render ไม่ย้ายโหนด — invariant #3) */}
{content.kind === "pdf" && (
<div
ref={textLayerRef}
className="textLayer absolute inset-0 z-[2]"
style={{ pointerEvents: overlayFrozen ? "none" : "auto" }}
/>
)}
{/* z-3: InkLayer (SVG freehand + รูปทรง F5) */}
<InkLayer
@ -1054,7 +1093,9 @@ function PdfPage({
{/* z-4: กรอบ selection + handles ของรายการที่เลือก (F1) — หลัง pins ใน DOM */}
{(() => {
const selected = annotations.find((a) => a.id === selectedId);
if (!selected) return null;
// COMMENT ที่เปิด popover อยู่ไม่วาดกรอบ/แผง — bbox ของ COMMENT ครอบพื้นที่ pin
// พอดี แผงจะทับ popover และแถบ handle บังการคลิก (sidebar ยังตาม selectedId เหมือนเดิม)
if (!selected || (selected.type === "COMMENT" && openPinId === selected.id)) return null;
return (
<AnnotationSelectionLayer
annotation={selected}
@ -1089,6 +1130,13 @@ export default function PDFViewer({
}) {
const [pdfjs, setPdfjs] = useState<typeof import("pdfjs-dist") | null>(null);
const [doc, setDoc] = useState<import("pdfjs-dist").PDFDocumentProxy | null>(null);
// N3: src ของรูปภาพต่อหน้า (คู่ขนานกับ doc ของ pdf — ไฟล์รูปใช้อันนี้แทน pdfjs/doc)
const [imageSrcs, setImageSrcs] = useState<(string | null)[] | null>(null);
// โหลดเอกสารพัง (ไฟล์เสีย/decode ไม่ได้) — แสดงข้อความแทน "กำลังโหลด…" ค้างตลอดไป
const [loadError, setLoadError] = useState<string | null>(null);
// F8: Export PDF ดาวน์โหลดในแท็บเดิม (fetch → blob → <a download>) ไม่เปิดแท็บใหม่
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const [pageSizes, setPageSizes] = useState<Size[]>([]);
const [tool, setTool] = useState<Tool>("select");
const [shapeKind, setShapeKind] = useState<ShapeKind>("rect"); // F5: ชนิดรูปทรงของ tool shape
@ -1317,11 +1365,99 @@ export default function PDFViewer({
);
}, []);
// F8: Export PDF — ดาวน์โหลดเงียบ ๆ ในแท็บเดิม (เดิม window.open _blank จะแวบแท็บใหม่
// และ error พา viewer ไปโผล่แท็บนั้น): fetch เป็น blob แล้วกด <a download> โดยรับชื่อ
// ไฟล์จาก Content-Disposition ของ route (รองรับภาษาไทยผ่าน filename*) — พัง = toast ในหน้า
const handleExportPdf = async () => {
if (exporting) return;
setExporting(true);
setExportError(null);
try {
const res = await fetch(`/api/annotations/export?fileId=${file.id}`);
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `HTTP ${res.status}`);
}
const blob = await res.blob();
const disposition = res.headers.get("Content-Disposition") ?? "";
const utf8 = /filename\*=UTF-8''([^;\s]+)/i.exec(disposition);
const ascii = /filename="([^"]+)"/i.exec(disposition);
const name = utf8 ? decodeURIComponent(utf8[1]) : (ascii?.[1] ?? `${file.title}.pdf`);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (err) {
console.error("export PDF ไม่สำเร็จ", err);
setExportError("Export PDF ไม่สำเร็จ — ลองอีกครั้ง (ไฟล์อาจใหญ่เกินหรือต้นฉบับเสียหาย)");
window.setTimeout(() => setExportError(null), 6000);
} finally {
setExporting(false);
}
};
// โหลดเอกสาร: dynamic import ภายใน useEffect เท่านั้น (invariant #6)
useEffect(() => {
let cancelled = false;
let loadedDoc: import("pdfjs-dist").PDFDocumentProxy | null = null;
// สลับไฟล์ (Next reuse component เดิมเมื่อเปลี่ยน dynamic param): เคลียร์เอกสาร
// ก่อนหน้าก่อนโหลดใหม่ — กัน state เก่า (doc ที่ถูก destroy / blob URL ที่ถูก revoke
// / span count ของหน้าเดิม) ถูก render ชั่วขณะกลางทาง แล้วกลับไป "กำลังโหลด…" ตามปกติ
setLoadError(null);
setPageSizes([]);
setImageSrcs(null);
setPageSpanCount({});
// N3: ไฟล์รูปภาพไม่ผ่าน pdfjs — โหลด static pdfUrl ตรง ๆ (ไม่มี ToUnicode ให้ซ่อม
// ตาม rules/pdf-viewer.md) แล้วผลิต pageSizes จาก naturalWidth/Height (browser คิด
// EXIF orientation ให้แล้ว) normalize กว้าง IMAGE_PAGE_WIDTH ให้ zoom/fit-width
// ทำงานเหมือนหน้า A4 — jpg/png คือ 1 หน้าเสมอ
if (fileKindFromUrl(file.pdfUrl) === "image") {
setDoc(null);
setPdfjs(null);
const objectUrls: string[] = [];
(async () => {
let pages: { src: string | null; width: number; height: number }[];
if (imageFormatFromUrl(file.pdfUrl) === "tiff") {
// N3: TIFF หลายเฟรม = หลายหน้า — decode ครั้งเดียวต่อการเปิดไฟล์
// (utif2 dynamic import ภายใน decodeTiffPages) แล้วเก็บ blob URL ไว้ใช้ต่อ
const buffer = await (await fetch(file.pdfUrl)).arrayBuffer();
pages = await decodeTiffPages(buffer, IMAGE_PAGE_WIDTH);
for (const p of pages) if (p.src) objectUrls.push(p.src);
} else {
const img = new Image();
img.src = file.pdfUrl;
await img.decode();
const factor = IMAGE_PAGE_WIDTH / img.naturalWidth;
pages = [
{ src: file.pdfUrl, width: IMAGE_PAGE_WIDTH, height: img.naturalHeight * factor },
];
}
if (cancelled) {
// cleanup ของ effect รันไปแล้วตอน objectUrls ยังว่าง (สลับไฟล์กลาง decode)
// — ต้อง revoke เอง ไม่งั้น blob ทั้งชุดรั่ว (plan-reviewer)
objectUrls.forEach((u) => URL.revokeObjectURL(u));
return;
}
setImageSrcs(pages.map((p) => p.src));
setPageSizes(pages.map((p) => ({ width: p.width, height: p.height })));
})().catch((err) => {
console.error("โหลดรูปไม่สำเร็จ", err);
if (!cancelled) setLoadError("โหลดรูปไม่สำเร็จ — ไฟล์อาจเสียหรือรูปแบบไม่รองรับ");
objectUrls.forEach((u) => URL.revokeObjectURL(u));
});
return () => {
cancelled = true;
// N3: revoke ทุก blob URL ตอนสลับไฟล์/unmount กัน memory leak (rules/pdf-viewer.md)
objectUrls.forEach((u) => URL.revokeObjectURL(u));
};
}
(async () => {
const pdfjs = await import("pdfjs-dist");
pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs";
@ -1357,6 +1493,7 @@ export default function PDFViewer({
setPageSizes(sizes);
})().catch((err) => {
console.error("โหลดเอกสารไม่สำเร็จ", err);
if (!cancelled) setLoadError("โหลดเอกสารไม่สำเร็จ — ไฟล์อาจเสียหรือไม่ใช่ PDF ที่รองรับ");
});
return () => {
@ -1899,6 +2036,14 @@ export default function PDFViewer({
}, []);
function openBookmarkComposer() {
// ปิดเมนู tool ที่เปิดอยู่ก่อน (เช่นคีย์ลัด B ระหว่างเมนูเปิด) — popup bookmark (z เดียวกัน
// ทั้งคู่ fixed ใต้แถบ tool) เปิดซ้อนบนเมนูได้; ไม่ markMenuClosed เพราะ B เป็นคีย์บอร์ด
// ไม่มี click หลงเหลือให้กลืน
setStampMenuOpen(false);
setShapeMenuOpen(false);
setDrawMenuOpen(false);
setTextboxMenuOpen(false);
setHighlightMenuOpen(false);
const pos = currentViewPosition();
if (!pos) return;
setBookmarkError(null);
@ -2359,6 +2504,9 @@ export default function PDFViewer({
// ต้องไม่กลายเป็นการวาง comment/textbox/stamp — คลิกที่สอง (หลัง 300ms) ค่อยวาง
if (Date.now() - menuClosedAtRef.current < 300) return;
if (bookmarkComposer) setBookmarkComposer(null); // คลิกหน้า = ปิด popup bookmark (N2)
// คลิกหน้าว่าง = ปิด popover ของ comment pin ด้วย — คลิกใน popover/pin ถูก
// stopPropagation ไว้แล้วจึงไม่โดนบรรทัดนี้
if (openPinId) setOpenPinId(null);
// คลิกลงหน้า = เลิกเลือก annotation ก่อนทุก tool — แผงเครื่องมือของกรอบ selection
// ต้องปิดเมื่อคลิกไปสร้างรายการใหม่ (comment/textbox/stamp เดิม return ก่อนถึง
// logic เลิกเลือกท้ายฟังก์ชัน แผงจึงค้างเปิด) — คลิกบนตัว annotation เองถูก
@ -2437,6 +2585,7 @@ export default function PDFViewer({
// กรณีรายการอยู่ส่วนบนสุดของหน้า)
setSelectedId(a.id);
setPreviewData(null);
setOpenPinId(null); // กระโดดไปเลือกรายการอื่น = ปิด popover ของ pin เดิมทิ้ง
const el = pageWrapRefs.current[a.pageIndex];
if (!el) return;
const r = el.getBoundingClientRect();
@ -2454,6 +2603,8 @@ export default function PDFViewer({
function handleSelectAnnotation(id: string) {
setSelectedId(id);
setPreviewData(null);
// เลือกรายการอื่น = ปิด popover ของ pin เดิม (คลิก pin จะไม่ผ่านที่นี่ — มัน toggle เอง)
setOpenPinId(null);
}
/** F13/A6: เข้าสู่โหมดแก้ไขข้อความ comment (null = ยกเลิก) — draft เริ่มจากข้อความปัจจุบัน */
@ -2517,6 +2668,9 @@ export default function PDFViewer({
setDrawMenuOpen(false);
setTextboxMenuOpen(false);
setHighlightMenuOpen(false);
// popup bookmark ก็ปิดตาม — สลับ tool แล้ว popup ค้างเปิดจะซ้อนกับสิ่งที่ทำต่อ
// (anyMenuOpen นับ bookmarkComposer อยู่แล้ว พฤติกรรม M4 เท่าเดิม)
setBookmarkComposer(null);
setComposer(null);
setOpenPinId(null);
setSelectedId(null);
@ -2744,11 +2898,12 @@ export default function PDFViewer({
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
onClick={(e) => {
openToolMenu(e.currentTarget);
if (tool === "highlight") setHighlightMenuOpen((o) => !o);
else {
switchTool("highlight");
setHighlightMenuOpen(true);
}
// เรียก switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้าที่ค้างเปิด
// (editor/แผง selection/popover) ไม่ให้ซ้อนกับเมนู; จำ intent เปิด/พับ
// ไว้ก่อนเพราะ switchTool ปิดเมนูทุกตัวก่อน
const reopen = tool !== "highlight" || !highlightMenuOpen;
switchTool("highlight");
if (reopen) setHighlightMenuOpen(true);
}}
>
<CurrentIcon className="h-4 w-4" />
@ -2825,11 +2980,10 @@ export default function PDFViewer({
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
onClick={(e) => {
openToolMenu(e.currentTarget);
if (tool === "draw") setDrawMenuOpen((o) => !o);
else {
switchTool("draw"); // switchTool ปิดเมนูก่อน แล้วค่อยเปิดทีหลัง
setDrawMenuOpen(true);
}
// switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน)
const reopen = tool !== "draw" || !drawMenuOpen;
switchTool("draw");
if (reopen) setDrawMenuOpen(true);
}}
>
<CurrentPenIcon className="h-4 w-4" />
@ -2926,11 +3080,10 @@ export default function PDFViewer({
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
onClick={(e) => {
openToolMenu(e.currentTarget);
if (tool === "shape") setShapeMenuOpen((o) => !o);
else {
switchTool("shape"); // switchTool ปิดเมนูก่อน แล้วค่อยเปิดทีหลัง
setShapeMenuOpen(true);
}
// switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน)
const reopen = tool !== "shape" || !shapeMenuOpen;
switchTool("shape");
if (reopen) setShapeMenuOpen(true);
}}
>
<Icon className="h-4 w-4" />
@ -3003,11 +3156,10 @@ export default function PDFViewer({
className={`flex items-center gap-0.5 px-2 py-2 transition ${corner} ${activeCls}`}
onClick={(e) => {
openToolMenu(e.currentTarget);
if (tool === "stamp") setStampMenuOpen((o) => !o);
else {
switchTool("stamp");
setStampMenuOpen(true);
}
// switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน)
const reopen = tool !== "stamp" || !stampMenuOpen;
switchTool("stamp");
if (reopen) setStampMenuOpen(true);
}}
>
<Icon className="h-4 w-4" />
@ -3081,11 +3233,10 @@ export default function PDFViewer({
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
onClick={(e) => {
openToolMenu(e.currentTarget);
if (tool === "textbox") setTextboxMenuOpen((o) => !o);
else {
switchTool("textbox"); // switchTool ปิดเมนูก่อน แล้วค่อยเปิดทีหลัง
setTextboxMenuOpen(true);
}
// switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน)
const reopen = tool !== "textbox" || !textboxMenuOpen;
switchTool("textbox");
if (reopen) setTextboxMenuOpen(true);
}}
>
<Icon className="h-4 w-4" />
@ -3363,14 +3514,13 @@ export default function PDFViewer({
{/* F8/C1: export PDF ฝัง annotation (redaction ติดไปเสมอ — D4) */}
<button
type="button"
title="Export PDF พร้อม annotation (เปิดแท็บใหม่)"
aria-label="Export PDF พร้อม annotation (เปิดแท็บใหม่)"
className={`max-lg:shrink-0 rounded-lg border border-slate-300 px-2 py-2 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() =>
window.open(`/api/annotations/export?fileId=${file.id}`, "_blank")
}
title="Export PDF พร้อม annotation"
aria-label="Export PDF พร้อม annotation"
disabled={exporting}
className={`max-lg:shrink-0 rounded-lg border border-slate-300 px-2 py-2 text-slate-500 hover:bg-slate-100 disabled:opacity-50 ${FOCUS_RING_CLS}`}
onClick={() => void handleExportPdf()}
>
<Download className="h-4 w-4" />
<Download className={`h-4 w-4 ${exporting ? "animate-pulse" : ""}`} />
</button>
</div>
{/* M5: เงาขวาบอกว่าเลื่อนต่อได้ — หายเมื่อถึงท้ายแถบ (จอ ≥ lg ไม่มีการ scroll) */}
@ -3425,14 +3575,29 @@ export default function PDFViewer({
</p>
)}
{pageSizes.length === 0 ? (
<div className="flex h-[70vh] items-center justify-center text-sm text-slate-400">
กำลังโหลดเอกสาร…
</div>
loadError ? (
<div className="flex h-[70vh] items-center justify-center px-6">
<p className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{loadError}
</p>
</div>
) : (
<div className="flex h-[70vh] items-center justify-center text-sm text-slate-400">
กำลังโหลดเอกสาร…
</div>
)
) : (
pageSizes.map((size, i) => {
const w = Math.ceil(size.width * scale);
const h = Math.ceil(size.height * scale);
const mounted = renderPages.has(i);
// N3: เนื้อหาหน้า = pdf (pdfjs) หรือรูปภาพ — เลือกจาก state ที่โหลดแล้ว
const content: PageContent | null =
pdfjs && doc
? { kind: "pdf", pdfjs, doc }
: imageSrcs
? { kind: "image", src: imageSrcs[i] ?? null }
: null;
return (
<div
key={i}
@ -3459,10 +3624,9 @@ export default function PDFViewer({
}}
onMouseLeave={() => setHoveredId(null)}
>
{mounted && pdfjs && doc ? (
{mounted && content ? (
<PdfPage
pdfjs={pdfjs}
doc={doc}
content={content}
pageNumber={i + 1}
width={w}
height={h}
@ -3625,6 +3789,16 @@ export default function PDFViewer({
</div>
</div>
)}
{/* F8: export พัง — toast แจ้งในหน้า (portal ตามกติกา fixed UI ของ rules/pdf-viewer.md) */}
{exportError &&
typeof document !== "undefined" &&
createPortal(
<div className="fixed bottom-4 right-4 z-[60] max-w-sm rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 shadow-xl">
{exportError}
</div>,
document.body,
)}
</div>
);
}

View file

@ -167,9 +167,19 @@ export default function AnnotationSidebar({
// แล้วเลื่อนแถวนั้นเข้ามุมมอง — แถว render หลัง setTab จึงค่อยเลื่อนใน rAF ถัดไป
// (dep รวม tab ตาม lint — ผลข้างเคียง: เปิดแท็บนี้ตอนยังมีรายการถูกเลือก = เลื่อนไปแถวนั้นเช่นกัน)
const listRef = useRef<HTMLDivElement>(null);
// id ล่าสุดที่ประกาศไปแล้ว — บังคับสลับแท็บเฉพาะตอน selectedId "เพิ่งเปลี่ยน" เท่านั้น
// ไม่ใช่ทุกครั้งที่ effect รัน เพราะ dep มี tab ด้วย ไม่งั้นผู้ใช้กดกลับมาแท็บ bookmarks
// ไม่ได้เลยตอนมีรายการถูกเลือกอยู่ (ถูกดึงกลับ annotations ทันที); เคลียร์เป็น null
// ตอนไม่มีรายการถูกเลือก เพื่อให้เลือกรายการเดิมซ้ำภายหลังยังสลับแท็บให้เหมือนเดิม
const announcedSelRef = useRef<string | null>(null);
useEffect(() => {
if (!selectedId) return;
if (tab !== "annotations") setTab("annotations");
if (!selectedId) {
announcedSelRef.current = null;
return;
}
const firstAnnounce = announcedSelRef.current !== selectedId;
announcedSelRef.current = selectedId;
if (firstAnnounce && tab !== "annotations") setTab("annotations");
const raf = requestAnimationFrame(() => {
listRef.current
?.querySelector(`[data-ann-id="${CSS.escape(selectedId)}"]`)