ส่วนที่ 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>
19 KiB
แผน 5.3.4 — รองรับไฟล์รูปภาพ (jpg/jpeg/png/tif) + อัปโหลดไฟล์ (N3 + N4)
สถานะ: implement เสร็จสมบูรณ์ (2026-09-15) — ทำครบทั้ง 6 task ตามลำดับ,
tscผ่าน, vitest 120/120, plan-reviewer + code-reviewer ผ่าน (findings ทั้งหมดแก้แล้ว: blob revoke race, TIFF orientation 7 บนจอ, EXIF 6/8 export anchor สลับที่ทำหน้าเปล่า — re-verify ด้วยการ render ไฟล์ export กลับด้วย pdfjs แล้วภาพอยู่ในหน้าจริง, magic bytes ตอนอัปโหลด, unlink ไฟล์กำพร้าเมื่อ DB fail, EXIF parser ทนไฟล์เสี้ยว, cap ขนาด TIFF ฝั่ง server 4096px) อ้างอิง: requirement 5.3.4 (gap analysisdocs/requirement/gap-analysis.mdกลุ่ม C) + คำขอเพิ่มอัปโหลดแบบง่าย ตาม governance ของโปรเจกต์: implement ต้องเขียนสเปกลงdocs/PLAN-FEAT.md§7 (N3/N4) + ขยาย whitelist ในCLAUDE.mdก่อน — เป็น task ที่ 1 ของแผนนี้
Context
Requirement 5.3.4 กำหนดรูปแบบเอกสาร: PDF + รูปภาพ (jpg, jpeg, png, tif) เป็นอย่างน้อย — ปัจจุบันรองรับ PDF เท่านั้น และไฟล์เข้าระบบได้ทางเดียวคือ seed script เป้าหมาย: อัปโหลด/เปิดไฟล์รูปภาพ → annotate ครบทุกชนิด → realtime → export PDF/CSV ได้ เหมือน PDF ทุกประการ
การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว)
- สถาปัตยกรรม "ไม่แปลงก่อนใช้": รูปคือ "หน้าเอกสาร" — viewer แสดง
<img>แทน canvas ของ pdfjs, layers ซ้อนบนรูปตรง ๆ, annotation เก็บเป็นพิกัด % ใน DB เหมือนเดิม — PDF เกิดจริงเฉพาะไฟล์ export (สร้างใหม่ใน memory ตาม invariant #1) - TIFF: รองรับเลยด้วย UTIF.js (
utif2— decode รันไทม์ฝั่ง client แสดงผล + ฝั่ง server transcode PNG ตอน export), TIFF หลายเฟรม = หลายหน้า - toolbar คงทุก tool กับทุกเอกสาร — ไม่ซ่อน HIGHLIGHT บนรูป (no-op แบบเดียวกับ PDF สแกน มี hint "ภาพสแกน" โผล่เอง)
- อัปโหลดแบบง่าย: ฟอร์มเดียวบนหน้า
/รับทั้ง PDF และรูป — ไม่มีหน้าจัดการ/ลบ/drag-drop/progress - EXIF-rotated JPEG: จัดการตอน export ใน scope นี้ (parse APP1 orientation 5–8 → swap ขนาดหน้า + drawImage rotate — ไม่งั้น export ได้สัดส่วนบิดต่างจากจอ)
- รับ
.tiff(สะกดยาว) ด้วย, หน้ารูป normalize กว้าง 595 (scale contract — zoom %/fit-width/fontSize% ทำงานเหมือนหน้า A4 ทุกประการ)
หลักตั้งต้น (จากการสำรวจโค้ด + spot-check)
- ทุก layer เหนือ canvas เป็น generic ต่อ "หน้า W×H + พิกัด %" — สิ่งที่ PDF-specific มีแค่ 3 จุด: (1) load effect
PDFViewer.tsx:1320-1366(2) canvas+textLayer ในPdfPage541-1075 (3)PDFDocument.loadที่app/api/annotations/export/route.ts:229— แทรก branch เพียง 3 จุด ที่เหลือ reuse - spot-check แล้ว:
PDFViewer.tsx:3462={mounted && pdfjs && doc ? (,seed-samples.mjs:18= filter.endsWith(".pdf"), มี vitest tests ที่tests/utils/*.test.ts(เพิ่ม test ตาม pattern นี้ได้) lib/server-pdf.tspath guard เป็น path-containment ล้วน (ไม่เช็ค extension) — ใช้กับรูปได้เลย- pdf-lib ^1.17.1 มี
embedJpg/embedPngแล้ว, ไม่มี TIFF embed → ต้อง transcode - deps ใหม่:
utif2+@types/utif,pngjs+@types/pngjs(pure JS ทั้งคู่)
Design
D1. ชนิดไฟล์ — derive จาก extension (ไม่เพิ่ม DB column)
ไฟล์ใหม่ utils/file-kind.ts (pure, ใช้ได้ทั้ง server/client):
export type FileKind = "pdf" | "image";
export type ImageFormat = "jpeg" | "png" | "tiff";
export function fileKindFromUrl(url: string): FileKind; // .jpg/.jpeg/.png/.tif/.tiff → "image" (case-insensitive)
export function imageFormatFromUrl(url: string): ImageFormat | null;
export const SAMPLE_FILE_EXTENSIONS: readonly string[]; // [".pdf",".jpg",".jpeg",".png",".tif",".tiff"]
export function imageContentTypeFromUrl(url: string): string; // image/jpeg | image/png | image/tiff
เหตุผล: extension เก็บอยู่ใน pdf_url อยู่แล้ว (single source of truth, seed idempotent อิง URL) — ไม่ต้อง migrate schema / drizzle push / backfill → docs/PLAN.md §3 ไม่ต้องแตะ ผู้ใช้ 4 จุด: PDFViewer (derive ใน client จาก file.pdfUrl — app/annotate/[id]/page.tsx ไม่ต้องแก้), app/page.tsx (badge), export route, seed script (.mjs duplicate list + comment ให้ sync)
D2. Viewer — PageContent union + branch 3 จุดใน components/pdf/PDFViewer.tsx
type PageContent =
| { kind: "pdf"; pdfjs: typeof import("pdfjs-dist"); doc: import("pdfjs-dist").PDFDocumentProxy }
| { kind: "image"; src: string | null }; // null = เฟรม decode ไม่ได้ (placeholder เทา)
type ImagePage = { src: string | null; width: number; height: number }; // normalize กว้าง 595
- load effect (1320-1366): state ใหม่
imagePages: ImagePage[] | nullคู่ขนานdoc/pdfjs— branch บนสุดของ effect เดิม:- jpeg/png:
new Image()+img.src = file.pdfUrl+await img.decode()→ pageSizes จากnaturalWidth/Height(browser คิด EXIF orientation แล้ว) → normalize 595 - tiff:
fetch(file.pdfUrl)→await import("utif2")(dynamic import เฉพาะ tif ตาม pattern invariant #6) →UTIF.decodeวนทุก IFD →decodeImage+toRGBA8→ canvas →toBlob("image/png")→URL.createObjectURLต่อเฟรม + revoke ทุก blob URL ใน cleanup (กัน leak ตอนสลับไฟล์); TIFF orientation tag 274 หมุนตอนวาง canvas; cap ด้านยาว decoded frame 4096px (resample ใน canvas — annotation เป็น % ไม่กระทบ) - pdf: โค้ดเดิมไม่แตะ
- jpeg/png:
- mount condition (3462): สร้าง
PageContentจาก state ใด state หนึ่งแทนpdfjs && doc - PdfPage (541-1075): prop
pdfjs+docรวมเป็นcontent— z-1:kind==="pdf" ? <canvas> : <img className="absolute inset-0 z-[1] pointer-events-none" draggable={false}>(pointer-events:none เหมือน canvas ตาม invariant #3); pdfjs render effect (644-701) guardif (content.kind !== "pdf") return; textLayer div render เฉพาะ pdf (conditional render ไม่ย้ายโหนด — DOM order คงเดิมตาม invariant #3); effect เล็กonTextLayerRendered(pageNumber-1, 0)ให้ hint "ภาพสแกน" +useCustomSelectionbail ทำงานเอง
ไม่ต้องแตะเลย: virtualization, zoom/fit-width, basePageWidth, deep link ?page/?ann, bookmark scroll, InkLayer/TextboxStampLayer/AnnotationSelectionLayer/pins/tooltip/hit-test, sidebar, Server Actions, SSE, summary, CSV
D3. /api/pdf — รูปโหลด static pdfUrl ตรง ๆ + defensive branch
รูปไม่ต้องผ่าน /api/pdf (ไม่มี ToUnicode ให้ซ่อม, ได้ static cache ฟรี + Content-Type ถูก) — แต่เพิ่ม guard ใน app/api/pdf/route.ts หลัง readPublicPdf: ถ้า fileKindFromUrl === "image" คืน bytes ดิบ + content-type ถูก (กัน stray call หลุดเข้า fixPdfActualText ที่จะ throw)
D4. Export — branch ก่อน PDFDocument.load แล้ว reuse pipeline 100%
ที่ app/api/annotations/export/route.ts:229:
const doc = fileKindFromUrl(file.pdfUrl) === "image"
? await imageDocFromBytes(srcBytes, file.pdfUrl)
: await PDFDocument.load(srcBytes);
ไฟล์ใหม่ utils/image-to-pdf.ts (server-only): PDFDocument.create() → jpeg embedJpg / อื่น ๆ embedPng → addPage([595, 595·h/w]) + drawImage เต็มหน้า → คืน doc แล้ววาด annotation + bookmark outline ด้วย pipeline เดิมทั้งหมด (rotation=0 → displaySize/displayToPdfPoint/pxPoint ใช้ได้ทันที, doc.getPage(pageIndex) map ตรง 1 เฟรม = 1 หน้า)
- JPEG EXIF orientation 5–8: parse APP1 เอง ~30 บรรทัด → swap กว้าง-สูง +
drawImageด้วยrotateradians - TIFF:
tiffFramesToPng(bytes)= utif2 decode ทุก IFD → toRGBA8 → pngjsPNG.sync.write→ embedPng (เฟรม decode พัง = หน้าขาว + console.error เพื่อให้ pageIndex map ตรง)
D5. อัปโหลดแบบง่าย (N4)
- UI: หน้า
app/page.tsx— ฟอร์มเล็ก:<input type="file" accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff">+ ปุ่ม "อัปโหลด" (client component แยกไฟล์components/UploadFileForm.tsx,useTransition+ ปุ่ม disabled ระหว่างอัป) - Server Action
uploadFileในapp/actions/files.ts(ไฟล์ใหม่): รับFormData— validate extension (whitelist เดียวกับSAMPLE_FILE_EXTENSIONS) + ขนาด ≤ 100MB + ไม่ว่าง → เขียนpublic/uploads/<uuid>.<ext>(ชื่อ random กันชน/กัน traversal — ใช้ชื่อจาก randomUUID ไม่ใช่ชื่อผู้ใช้) → insertfiles (title = ชื่อไฟล์เดิมที่ sanitize, pdf_url = /uploads/<uuid>.<ext>)→revalidatePath("/") next.config.mjs:experimental: { serverActions: { bodySizeLimit: "100mb" } }(Next 14 default 1MB)- seed script จัดการเฉพาะ
/samples/%→ uploads ไม่โดน seed ลบ - Out of scope: ลบ/แก้ชื่อไฟล์ใน UI, drag-drop, progress bar, multi-file, auth ผูกเจ้าของไฟล์
D6. Seed + หน้ารายการ
scripts/seed-samples.mjs:18: filter เป็นSAMPLE_FILE_EXTENSIONS.some(...)+ แก้ comment บรรทัด 2 ให้ตรงapp/page.tsx: ไอคอนFileImage(lucide, fallbackImage) + badgePDF/JPG/PNG/TIFFต่อ row
Docs/rules sync (plan-first — ทำเป็น task แรก ก่อนแตะโค้ด)
| ไฟล์ | สิ่งที่แก้ |
|---|---|
docs/PLAN-FEAT.md §7 |
หัวข้อใหม่ N3 รองรับไฟล์รูปภาพ + N4 อัปโหลดไฟล์แบบง่าย (format ตาม N1/N2: ข้อมูล/การโหลด/การแสดง/Export/Realtime/Out of scope ต่อฟีเจอร์) |
CLAUDE.md |
(1) whitelist ขอบเขตเพิ่ม N3/N4 (2) ลบข้อ "ไม่มีหน้า upload" ออกจาก Out of Scope (3) invariant #1 "ไฟล์ PDF ต้นฉบับ" → "ไฟล์ต้นฉบับ (PDF/รูปภาพ)" (4) โครงสร้างไฟล์เพิ่ม utils/file-kind.ts, utils/image-to-pdf.ts, app/actions/files.ts, components/UploadFileForm.tsx |
docs/PLAN.md |
§3 ไม่แตะ (ไม่มี schema change); §0 A5 "ไม่มีหน้า upload" → อัปเดตเป็นมี upload แบบง่าย (N4) |
.claude/rules/pdf-viewer.md |
แก้ bullet "โหลด PDF ผ่าน /api/pdf เสมอ" → เพิ่มข้อยกเว้นรูปโหลด static ตรง, <img> z-1 pointer-events:none, onTextLayerRendered(pi, 0), normalize 595, utif2 dynamic import + revoke blob URL |
.claude/rules/data-layer.md |
files.pdf_url เก็บ path รูปได้ — derive ชนิดผ่าน utils/file-kind.ts เท่านั้น (ไม่มี column kind), TIFF หลายเฟรม = pageIndex ต่อเฟรม, Server Action uploadFile เขียนไฟล์ใหม่ที่ public/uploads/ เท่านั้น (ต้องไม่แตะไฟล์เดิม) |
README.md |
ส่วน seed: extension ที่รับ + วิธีอัปโหลดผ่านหน้า / |
ลำดับ task + verify (ทุก task: npx tsc --noEmit ผ่าน + agent plan-reviewer)
| # | งาน | ไฟล์หลัก | Verify เพิ่มเติม |
|---|---|---|---|
| 1 | Docs sync N3/N4 + utils/file-kind.ts + unit test |
docs/PLAN-FEAT.md, CLAUDE.md, docs/PLAN.md §0, utils/file-kind.ts, tests/utils/file-kind.test.ts |
vitest ผ่านทุก case (uppercase/ไม่มี extension/.tiff ยาว); N3/N4 spec ครบตาม format §7 |
| 2 | Upload (Server Action + ฟอร์ม + next.config) | app/actions/files.ts, components/UploadFileForm.tsx, app/page.tsx, next.config.mjs, .claude/rules/data-layer.md |
Playwright: อัปโหลด PDF + jpg → row ใหม่โผล่ + เปิด PDF ได้เหมือนเดิม; ไฟล์ >100MB ถูกปฏิเสธพร้อมข้อความ; ชื่อไฟล์พิเศษ (ภาษาไทย/../) ไม่หลุดออกนอก public/uploads/ |
| 3 | Viewer jpg/png (PageContent) + /api/pdf defensive branch |
components/pdf/PDFViewer.tsx, app/api/pdf/route.ts, .claude/rules/pdf-viewer.md |
อัปโหลด jpg/png แล้วเปิด: fit-width เต็มจอ, annotate ครบ DRAW/SHAPE/TEXTBOX/STAMP/REDACTION/COMMENT + bookmark, HIGHLIGHT ยังอยู่ + hint "ภาพสแกน", 2 แท็บ realtime, zoom 0.5-3 annotation ไม่เบี้ยว; regression: PDF มี text highlight ได้เหมือนเดิม + PDF หลายสิบหน้า virtualization ปกติ; จอ coarse (Playwright touch) วาดบนรูปได้ไม่กลายเป็น scroll |
| 4 | TIFF ฝั่ง client (utif2) | components/pdf/PDFViewer.tsx, utils/tiff-client.ts, package.json (utif2, @types/utif) |
อัปโหลด tif หลายเฟรม = หลายหน้า, เลื่อนลื่น, ?page=N + bookmark กระโดดถูกเฟรม, สลับไฟล์หลายรอบ memory ไม่โต (blob revoke), เฟรมพัง = placeholder เทาแต่ไฟล์ไม่พังทั้งเล่ม |
| 5 | Export PDF จากรูป + EXIF | app/api/annotations/export/route.ts, utils/image-to-pdf.ts |
annotate ครบทุกชนิดบน jpg → export → เปิดด้วย pdf.js viewer: หน้า 595×สัดส่วนรูป, annotation ตรงตำแหน่ง, REDACTION ทึบ, COMMENT sticky เปิด popup, bookmark outline กระโดดถูกหน้า; JPEG เอียง 90° (fixture สร้างเอง) export ตรงกับจอ; hash ต้นฉบับใน public/ ไม่เปลี่ยน (certutil -hashfile ก่อน/หลัง); export PDF/CSV เดิมได้เหมือนเดิม (regression) |
| 6 | TIFF ฝั่ง server ตอน export + ปิดงาน | utils/image-to-pdf.ts (tiffFramesToPng), package.json (pngjs, @types/pngjs) |
export tif 3 เฟรม → PDF 3 หน้า + annotation บนเฟรม 2/3 ตกหน้าถูก + outline; hash ต้นฉบับไม่เปลี่ยน; plan-reviewer ผ่านทุก invariant ที่ N3/N4 แตะ |
Edge cases (ออกแบบไว้ล่วงหน้า)
- EXIF JPEG: viewer ปลอดภัยอัตโนมัติ (
<img>ใช้ orientation เป็น default,naturalWidth/Height= หลังหมุน) — จุดรั่วคือ export (pdf-lib ฝัง JPEG ดิบ) → จัดการใน D4; PNG ไม่มีปัญหา - TIFF orientation tag 274: UTIF ไม่หมุนให้ — หมุนเอง client (ตอนวาง canvas) + server (transpose RGBA ก่อน PNG encode) ให้สองฝั่งตรงกัน
- Retina:
<img>browser render ที่ dpr เต็ม — เทียบเท่า canvas×dpr ของ pdfjs ไม่ต้องทำอะไร - รูปใหญ่ (สแกน 600dpi ~5000px): normalize 595 ทำให้ zoom clamp 0.5-3 ใช้ได้ + cap 4096px ตอน decode กัน memory
- TIFF compression ที่ utif2 ไม่รอง (CCITT G3/G4): เฟรมนั้น placeholder เทาแต่เอาขนาดจาก IFD header → page count/scroll/bookmark เสถียร (จดไว้ใน out of scope ของ N3)
- stray
/api/pdf?fileId=<รูป>: defensive branch คืน bytes ดิบ — ไม่หลุดเข้าfixPdfActualText - อัปโหลดชื่อซ้ำ/ชื่อแปลก: uuid เป็นชื่อไฟล์บนดิสก์ + sanitize title — ไม่ชนกัน ไม่ traversal
Verification (ภาพรวม end-to-end)
npm run dev+npm run seed-samples— รายการเดิมไม่หาย (dev DB ผู้ใช้ใช้ร่วม — ห้าม reset DB)- Playwright: อัปโหลด
sample.jpg→ เปิด → ทดสอบเต็มวงจร (วาดเส้น/รูปทรง/TEXTBOX/STAMP/COMMENT/bookmark) → แท็บที่สองเห็น realtime → export PDF → เปิดด้วย pdf.js viewer ตรวจตำแหน่ง +certutil -hashfileต้นฉบับก่อน/หลังเทียบกัน - เช่นเดียวกับ
.tifหลายเฟรม +?page=2deep link - Regression PDF เดิม: highlight ข้อความ + export ได้เหมือนก่อนแก้