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 @@
import { describe, expect, it } from "vitest";
import {
SAMPLE_FILE_EXTENSIONS,
fileKindFromUrl,
imageContentTypeFromUrl,
imageFormatFromUrl,
} from "@/utils/file-kind";
describe("fileKindFromUrl", () => {
it("pdf → pdf, รูปทุกนามสกุล → image (case-insensitive)", () => {
expect(fileKindFromUrl("/samples/a.pdf")).toBe("pdf");
expect(fileKindFromUrl("/samples/photo.JPG")).toBe("image");
expect(fileKindFromUrl("/uploads/scan.Jpeg")).toBe("image");
expect(fileKindFromUrl("/uploads/scan.png")).toBe("image");
expect(fileKindFromUrl("/uploads/scan.TIF")).toBe("image");
expect(fileKindFromUrl("/uploads/scan.tiff")).toBe("image");
});
it("ไม่รู้จัก extension = pdf ตามพฤติกรรมก่อน N3", () => {
expect(fileKindFromUrl("/samples/file.docx")).toBe("pdf");
expect(fileKindFromUrl("/samples/noext")).toBe("pdf");
expect(fileKindFromUrl("/samples/.jpg")).toBe("pdf"); // dotfile ไม่ใช่ extension
});
});
describe("imageFormatFromUrl", () => {
it("จับคู่นามสกุล → format (รวม query string / จุดซ้ำในชื่อ)", () => {
expect(imageFormatFromUrl("/a/b/x.jpg?v=2")).toBe("jpeg");
expect(imageFormatFromUrl("/a/b/x.jpeg")).toBe("jpeg");
expect(imageFormatFromUrl("/a/b/scan.v2.png")).toBe("png");
expect(imageFormatFromUrl("/a/b/x.tif")).toBe("tiff");
expect(imageFormatFromUrl("/a/b/x.tiff")).toBe("tiff");
});
it("ไม่ใช่รูป → null (รวมชื่อที่คล้ายนามสกุลแต่เป็นโฟลเดอร์)", () => {
expect(imageFormatFromUrl("/a/b/x.pdf")).toBeNull();
expect(imageFormatFromUrl("/jpg.png/jpg")).toBeNull();
expect(imageFormatFromUrl("")).toBeNull();
});
});
describe("imageContentTypeFromUrl", () => {
it("map format → MIME ที่ถูกต้อง", () => {
expect(imageContentTypeFromUrl("/a/x.jpg")).toBe("image/jpeg");
expect(imageContentTypeFromUrl("/a/x.PNG")).toBe("image/png");
expect(imageContentTypeFromUrl("/a/x.tiff")).toBe("image/tiff");
expect(imageContentTypeFromUrl("/a/x.pdf")).toBe("application/octet-stream");
});
});
describe("SAMPLE_FILE_EXTENSIONS", () => {
it("ครบทุกนามสกุลที่ requirement 5.3.4 ระบุ", () => {
for (const ext of [".pdf", ".jpg", ".jpeg", ".png", ".tif", ".tiff"]) {
expect(SAMPLE_FILE_EXTENSIONS).toContain(ext);
}
});
});

View file

@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { jpegExifOrientation, rotateRgba } from "@/utils/image-to-pdf";
/** สร้าง JPEG หลอง (SOI + marker ที่กำหนด + SOI-trailer) สำหรับทดสอบ parser */
function fakeJpeg(markers: Buffer[]): Buffer {
return Buffer.concat([Buffer.from([0xff, 0xd8]), ...markers]);
}
/** APP1 Exif ที่มีแค่ tag Orientation (274) ตาม byte layout จริง */
function app1Exif(orientation: number): Buffer {
const tiff = Buffer.alloc(26);
tiff.write("II", 0, "ascii");
tiff.writeUInt16LE(42, 2);
tiff.writeUInt32LE(8, 4);
tiff.writeUInt16LE(1, 8);
tiff.writeUInt16LE(274, 10);
tiff.writeUInt16LE(3, 12);
tiff.writeUInt32LE(1, 14);
tiff.writeUInt16LE(orientation, 18);
const payload = Buffer.concat([Buffer.from("Exif\0\0", "latin1"), tiff]);
const app1 = Buffer.alloc(4 + payload.length);
app1[0] = 0xff;
app1[1] = 0xe1;
app1.writeUInt16BE(payload.length + 2, 2);
payload.copy(app1, 4);
return app1;
}
describe("jpegExifOrientation", () => {
it("อ่าน orientation จาก APP1 ได้ (ค่า 1–8)", () => {
for (const v of [1, 3, 6, 8]) {
expect(jpegExifOrientation(fakeJpeg([app1Exif(v)]))).toBe(v);
}
});
it("ไม่มี EXIF / ไม่ใช่ JPEG = 1", () => {
expect(jpegExifOrientation(fakeJpeg([]))).toBe(1);
expect(jpegExifOrientation(Buffer.from([0x89, 0x50]))).toBe(1); // PNG signature
});
it("ข้าม marker อื่นก่อนเจอ APP1 (เช่น APP0 JFIF)", () => {
const app0 = Buffer.from([0xff, 0xe0, 0x00, 0x04, 0x4a, 0x46]); // len=4
expect(jpegExifOrientation(fakeJpeg([app0, app1Exif(6)]))).toBe(6);
});
it("APP1 ที่ไม่ใช่ Exif หรือ value ผิดพิสัย = 1", () => {
const notExif = Buffer.from([0xff, 0xe1, 0x00, 0x0a, 0x58, 0x4d, 0x4c, 0, 0, 1, 2, 3]); // "XML..."
expect(jpegExifOrientation(fakeJpeg([notExif]))).toBe(1);
expect(jpegExifOrientation(fakeJpeg([app1Exif(99)]))).toBe(1);
});
it("ทนไฟล์เสี้ยว — APP1 ถูกตัดกลางคันไม่โยน exception (คืน 1)", () => {
const full = app1Exif(6);
for (const cut of [10, 14, 20, 24]) {
const truncated = full.subarray(0, cut);
expect(jpegExifOrientation(fakeJpeg([truncated]))).toBe(1);
}
});
it("ข้าม fill bytes (0xFF ซ้ำ) ก่อน marker ได้", () => {
const fills = Buffer.from([0xff, 0xff, 0xff]);
expect(jpegExifOrientation(fakeJpeg([fills, app1Exif(6)]))).toBe(6);
});
});
/** ตาราง 3×2 RGBA — pixel ระบุตำแหน่งด้วยค่าสี r=x, g=y เพื่อตรวจ mapping */
const W = 3;
const H = 2;
const rgba = new Uint8Array(W * H * 4);
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const i = (y * W + x) * 4;
rgba[i] = x; // r = ตำแหน่ง x เดิม
rgba[i + 1] = y; // g = ตำแหน่ง y เดิม
rgba[i + 2] = 0;
rgba[i + 3] = 255;
}
}
const pixel = (data: Uint8Array, x: number, y: number, width: number) => {
const i = (y * width + x) * 4;
return [data[i], data[i + 1]];
};
describe("rotateRgba", () => {
it("orientation 1 = ไม่แปลง", () => {
const out = rotateRgba(rgba, W, H, 1);
expect(out.width).toBe(W);
expect(pixel(out.data, 2, 1, W)).toEqual([2, 1]);
});
it("6 = หมุน 90°CW: สลับกว้าง-สูง, มุมซ้ายบนเดิมไปมุมขวาบน", () => {
const out = rotateRgba(rgba, W, H, 6);
expect(out.width).toBe(H);
expect(out.height).toBe(W);
expect(pixel(out.data, H - 1, 0, H)).toEqual([0, 0]); // (0,0) → (h-1, 0)
expect(pixel(out.data, 0, W - 1, H)).toEqual([W - 1, H - 1]); // (w-1,h-1) → (0, w-1)
});
it("5 = transpose และ 7 = anti-transpose (mirror + หมุน)", () => {
const t5 = rotateRgba(rgba, W, H, 5);
expect(t5.width).toBe(H);
expect(pixel(t5.data, 1, 2, H)).toEqual([2, 1]); // (x,y) → (y,x)
const t7 = rotateRgba(rgba, W, H, 7);
expect(t7.width).toBe(H);
expect(pixel(t7.data, H - 1 - 1, W - 1 - 2, H)).toEqual([2, 1]); // (x,y) → (h-1-y, w-1-x)
});
it("2 = flip แนวนอน, 3 = 180°, 4 = flip แนวตั้ง", () => {
expect(pixel(rotateRgba(rgba, W, H, 2).data, W - 1, 0, W)).toEqual([0, 0]);
expect(pixel(rotateRgba(rgba, W, H, 3).data, W - 1, H - 1, W)).toEqual([0, 0]);
expect(pixel(rotateRgba(rgba, W, H, 4).data, 0, H - 1, W)).toEqual([0, 0]);
});
});