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
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:
parent
0c4af09e85
commit
9cc38835f0
25 changed files with 1269 additions and 77 deletions
57
components/UploadFileForm.tsx
Normal file
57
components/UploadFileForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}"]`)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue