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
tests/utils/file-kind.test.ts
Normal file
57
tests/utils/file-kind.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
113
tests/utils/image-to-pdf.test.ts
Normal file
113
tests/utils/image-to-pdf.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue