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
107
app/actions/files.ts
Normal file
107
app/actions/files.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"use server";
|
||||
|
||||
// N4 — อัปโหลดไฟล์เอกสารแบบง่าย (PDF/jpg/jpeg/png/tif/tiff) ผ่านหน้ารายการ
|
||||
// เขียนไฟล์**ใหม่**ที่ public/uploads/<uuid>.<ext> เท่านั้น (ชื่อจาก randomUUID — กันชนกัน/กัน path traversal)
|
||||
// ไม่มี SSE event: รายการไฟล์ไม่ได้ sync ผ่าน SSE — revalidatePath("/") พอ (สเปก PLAN-FEAT.md §7 N4)
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { files } from "@/db/schema";
|
||||
import { SAMPLE_FILE_EXTENSIONS } from "@/utils/file-kind";
|
||||
|
||||
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB (ตาม bodySizeLimit ใน next.config.mjs)
|
||||
const UPLOAD_DIR = path.join(process.cwd(), "public", "uploads");
|
||||
|
||||
export type UploadFileResult =
|
||||
| { ok: true; fileId: string; title: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/** นามสกุล (ตัวเล็ก มีจุด) จากชื่อไฟล์ที่ browser ส่ง — ต้องอยู่ใน whitelist */
|
||||
function allowedExt(name: string): string | null {
|
||||
const dot = name.lastIndexOf(".");
|
||||
if (dot <= 0) return null;
|
||||
const ext = name.slice(dot).toLowerCase();
|
||||
return SAMPLE_FILE_EXTENSIONS.includes(ext) ? ext : null;
|
||||
}
|
||||
|
||||
/** title สำหรับแสดงในรายการ: ชื่อไฟล์เดิม ตัด path ที่ฝังมา จำกัดความยาว */
|
||||
function sanitizeTitle(name: string): string {
|
||||
const base = name.split(/[\\/]/).pop() ?? name;
|
||||
return base.trim().slice(0, 200) || "ไฟล์ไม่มีชื่อ";
|
||||
}
|
||||
|
||||
/** ตรวจ magic bytes ตรงกับ extension — กันไฟล์เปลี่ยนชื่อนามสกุล/ไฟล์เสี้ยวจากการโอน
|
||||
* เข้าระบบแล้วไปพังทีหลังทั้ง viewer (ค้างโหลด) และ export (500) */
|
||||
function matchesSignature(bytes: Buffer, ext: string): boolean {
|
||||
switch (ext) {
|
||||
case ".pdf":
|
||||
return bytes.subarray(0, 5).toString("latin1") === "%PDF-";
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
|
||||
case ".png":
|
||||
return (
|
||||
bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47
|
||||
);
|
||||
case ".tif":
|
||||
case ".tiff":
|
||||
return (
|
||||
(bytes[0] === 0x49 && bytes[1] === 0x49 && bytes[2] === 0x2a && bytes[3] === 0x00) || // II*\0
|
||||
(bytes[0] === 0x4d && bytes[1] === 0x4d && bytes[2] === 0x00 && bytes[3] === 0x2a) // MM\0*
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadFile(formData: FormData): Promise<UploadFileResult> {
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return { ok: false, error: "เลือกไฟล์ก่อนอัปโหลด" };
|
||||
}
|
||||
if (file.size === 0) {
|
||||
return { ok: false, error: "ไฟล์ว่าง (0 ไบต์) — เลือกไฟล์ที่สมบูรณ์" };
|
||||
}
|
||||
const ext = allowedExt(file.name);
|
||||
if (!ext) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `รองรับเฉพาะไฟล์ ${SAMPLE_FILE_EXTENSIONS.join(" / ")}`,
|
||||
};
|
||||
}
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
return { ok: false, error: "ไฟล์ใหญ่เกิน 100MB" };
|
||||
}
|
||||
|
||||
// uuid เป็นทั้งชื่อไฟล์บนดิสก์และ id ของ row — ไม่ใช้ชื่อผู้ใช้ตกแต่ง path เด็ดขาด
|
||||
const id = randomUUID();
|
||||
const diskName = `${id}${ext}`;
|
||||
const bytes = Buffer.from(await file.arrayBuffer());
|
||||
if (!matchesSignature(bytes, ext)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "เนื้อไฟล์ไม่ตรงกับนามสกุล (ไฟล์อาจเสียหรือถูกเปลี่ยนชื่อ) — ตรวจไฟล์แล้วลองใหม่",
|
||||
};
|
||||
}
|
||||
|
||||
await mkdir(UPLOAD_DIR, { recursive: true });
|
||||
const diskPath = path.join(UPLOAD_DIR, diskName);
|
||||
await writeFile(diskPath, bytes);
|
||||
|
||||
const title = sanitizeTitle(file.name);
|
||||
const pdfUrl = `/uploads/${diskName}`;
|
||||
try {
|
||||
await db.insert(files).values({ id, title, pdfUrl });
|
||||
} catch (err) {
|
||||
// DB fail หลังเขียนไฟล์ — เก็บกวาดไฟล์ก่อนโยนต่อ กันไฟล์กำพร้าไม่มี row ชี้ (ไม่มีกลไกเก็บอื่น)
|
||||
await unlink(diskPath).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
|
||||
revalidatePath("/");
|
||||
return { ok: true, fileId: id, title };
|
||||
}
|
||||
|
|
@ -47,6 +47,8 @@ import {
|
|||
} from "@/utils/pdf-export";
|
||||
import type { AnnotationDTO, Rect } from "@/db/schema";
|
||||
import { toPx, type Point, type Size } from "@/utils/coordinates";
|
||||
import { fileKindFromUrl } from "@/utils/file-kind";
|
||||
import { imageDocFromBytes } from "@/utils/image-to-pdf";
|
||||
import {
|
||||
STAMP_HEIGHT_RATIO,
|
||||
annotationBBox,
|
||||
|
|
@ -226,7 +228,20 @@ export async function GET(req: Request) {
|
|||
return new Response("อ่านไฟล์ PDF ต้นฉบับไม่ได้", { status: 404 });
|
||||
}
|
||||
|
||||
const doc = await PDFDocument.load(srcBytes);
|
||||
// N3: ไฟล์รูปภาพ — สร้าง PDF ใหม่ที่ฝังรูปเต็มหน้า (embedJpg/embedPng + EXIF rotate)
|
||||
// แล้ววาด annotation ต่อด้วย pipeline เดิมทั้งหมด (rotation=0, page 595×สัดส่วนรูป
|
||||
// ตรง scale contract ฝั่ง viewer → พิกัด % ตรงกันทั้งจอและ export)
|
||||
// ไฟล์เสีย (ผ่าน magic bytes ตอนอัปโหลดแต่เนื้อข้างในพัง) → ข้อความชัด ๆ แทน 500 เปล่า
|
||||
let doc: Awaited<ReturnType<typeof PDFDocument.load>>;
|
||||
try {
|
||||
doc =
|
||||
fileKindFromUrl(file.pdfUrl) === "image"
|
||||
? await imageDocFromBytes(srcBytes, file.pdfUrl)
|
||||
: await PDFDocument.load(srcBytes);
|
||||
} catch (err) {
|
||||
console.error("สร้างเอกสารสำหรับ export ไม่สำเร็จ", err);
|
||||
return new Response("ไฟล์ต้นฉบับเสียหาย สร้างเอกสาร export ไม่ได้", { status: 500 });
|
||||
}
|
||||
doc.registerFontkit(fontkit);
|
||||
|
||||
// D5: ฟอนต์ไทย optional — ไม่มีก็ export สำเร็จ (COMMENT เป็นหมุดอย่างเดียว)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { db } from "@/db";
|
|||
import { files } from "@/db/schema";
|
||||
import { fixPdfActualText } from "@/lib/pdf-actual-text";
|
||||
import { PdfPathError, readPublicPdf } from "@/lib/server-pdf";
|
||||
import { fileKindFromUrl, imageContentTypeFromUrl } from "@/utils/file-kind";
|
||||
|
||||
// ให้ viewer โหลด PDF ผ่านทางนี้แทนไฟล์ดิบใน public/ — ซ่อม ToUnicode จาก ActualText
|
||||
// ของ PDF ไทยที่ cmap ของฟอนต์เพี้ยน (สกัดข้อความได้ control char ขยะแทนสระ/วรรณยุกต์)
|
||||
|
|
@ -32,6 +33,18 @@ export async function GET(req: Request) {
|
|||
throw err;
|
||||
}
|
||||
|
||||
// N3: ไฟล์รูปภาพไม่มี ToUnicode ให้ซ่อม — คืน bytes ดิบ + content-type ถูกต้อง
|
||||
// (viewer โหลดรูปจาก static pdfUrl ตรง ๆ อยู่แล้ว — branch นี้กัน stray call
|
||||
// หลุดเข้า fixPdfActualText ที่จะ throw กับ bytes ที่ไม่ใช่ PDF)
|
||||
if (fileKindFromUrl(file.pdfUrl) === "image") {
|
||||
return new Response(new Uint8Array(srcBytes), {
|
||||
headers: {
|
||||
"Content-Type": imageContentTypeFromUrl(file.pdfUrl),
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const fixed = await fixPdfActualText(srcBytes);
|
||||
return new Response(new Uint8Array(fixed), {
|
||||
headers: {
|
||||
|
|
|
|||
28
app/page.tsx
28
app/page.tsx
|
|
@ -1,13 +1,29 @@
|
|||
import Link from "next/link";
|
||||
import { asc } from "drizzle-orm";
|
||||
import { FileText } from "lucide-react";
|
||||
import { FileImage, FileText } from "lucide-react";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { files } from "@/db/schema";
|
||||
import UserPicker from "@/components/UserPicker";
|
||||
import UploadFileForm from "@/components/UploadFileForm";
|
||||
import { imageFormatFromUrl } from "@/utils/file-kind";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** badge ชนิดไฟล์ต่อรายการ (N3) */
|
||||
function kindBadge(pdfUrl: string): string {
|
||||
switch (imageFormatFromUrl(pdfUrl)) {
|
||||
case "jpeg":
|
||||
return "JPG";
|
||||
case "png":
|
||||
return "PNG";
|
||||
case "tiff":
|
||||
return "TIFF";
|
||||
default:
|
||||
return "PDF";
|
||||
}
|
||||
}
|
||||
|
||||
export default async function HomePage() {
|
||||
const rows = await db.select().from(files).orderBy(asc(files.createdAt));
|
||||
|
||||
|
|
@ -35,6 +51,7 @@ export default async function HomePage() {
|
|||
<span className="text-xs text-slate-400">{rows.length} ไฟล์</span>
|
||||
)}
|
||||
</div>
|
||||
<UploadFileForm />
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div className="mt-3 rounded-xl border border-dashed border-slate-300 bg-white/60 p-10 text-center">
|
||||
|
|
@ -54,13 +71,20 @@ export default async function HomePage() {
|
|||
className="group flex items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:border-primary/30 hover:shadow-md"
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<FileText className="h-5 w-5 text-primary" />
|
||||
{imageFormatFromUrl(f.pdfUrl) ? (
|
||||
<FileImage className="h-5 w-5 text-primary" />
|
||||
) : (
|
||||
<FileText className="h-5 w-5 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block break-all line-clamp-2 text-sm font-medium text-slate-900">
|
||||
{f.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-slate-400">
|
||||
<span className="mr-1.5 rounded bg-slate-100 px-1 py-0.5 font-medium text-slate-500">
|
||||
{kindBadge(f.pdfUrl)}
|
||||
</span>
|
||||
{new Date(f.createdAt).toLocaleString("th-TH")}
|
||||
</span>
|
||||
</span>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue