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>
108 lines
5.3 KiB
TypeScript
108 lines
5.3 KiB
TypeScript
// N3 — decode TIFF ฝั่ง client: ทุกเฟรม (IFD) → RGBA → canvas → PNG blob URL ต่อเฟรม
|
||
// (TIFF หลายเฟรม = หลายหน้าเหมือน PDF หลายหน้า)
|
||
// utif2 ถูก dynamic import เฉพาะไฟล์ tif เท่านั้น (pattern invariant #6)
|
||
// ขนาดหน้า normalize กว้าง imagePageWidth (scale contract เดียวกับ jpg/png และ export)
|
||
// caller ต้อง revoke ทุก src ที่ไม่ null ตอน cleanup กัน memory leak (rules/pdf-viewer.md)
|
||
|
||
const MAX_FRAME_DIMENSION = 4096; // cap ด้านยาว decoded frame (สแกน 600dpi ~5000px — annotation เป็น % ไม่กระทบ)
|
||
|
||
type TiffIfd = import("utif2").IFD;
|
||
|
||
/** อ่านค่าตัวเลขแรกของ tag (คืน 0 ถ้าไม่ใช่ array ตัวเลข — เช่น tag ชนิดอื่น) */
|
||
function tagNumber(ifd: TiffIfd, tag: "t256" | "t257" | "t274"): number {
|
||
const value = ifd[tag];
|
||
return Array.isArray(value) ? Number(value[0]) || 0 : 0;
|
||
}
|
||
|
||
export type DecodedTiffPage = {
|
||
/** PNG blob URL ของเฟรม (null = เฟรมนี้ decode ไม่ได้ → placeholder เทา แต่หน้ายังมีขนาดจาก header) */
|
||
src: string | null;
|
||
width: number;
|
||
height: number;
|
||
};
|
||
|
||
/** TIFF orientation tag (274) — ครบทุกค่า 1–8 ตามมาตรฐาน EXIF (หมุน + mirror)
|
||
* โดย transform matrix มาตรฐาน (canvas ปลายทางสลับกว้าง-สูงสำหรับ 5–8)
|
||
* ต้องตรงกับ rotateRgba ของ utils/image-to-pdf.ts ฝั่ง export เสมอ */
|
||
function rgbaToCanvas(rgba: Uint8Array, w: number, h: number, orientation: number): HTMLCanvasElement {
|
||
const swap = orientation >= 5 && orientation <= 8; // หมุน 90° → สลับกว้าง-สูง
|
||
const targetW = swap ? h : w;
|
||
const targetH = swap ? w : h;
|
||
const scale = Math.min(1, MAX_FRAME_DIMENSION / Math.max(targetW, targetH));
|
||
|
||
// วาด RGBA ลง canvas ต้นทางก่อน แล้วค่อย transform (orientation + downscale) ลง canvas จริง
|
||
const src = document.createElement("canvas");
|
||
src.width = w;
|
||
src.height = h;
|
||
src.getContext("2d")!.putImageData(new ImageData(new Uint8ClampedArray(rgba), w, h), 0, 0);
|
||
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = Math.max(1, Math.round(targetW * scale));
|
||
canvas.height = Math.max(1, Math.round(targetH * scale));
|
||
const ctx = canvas.getContext("2d")!;
|
||
ctx.imageSmoothingQuality = "high";
|
||
ctx.scale(scale, scale);
|
||
switch (orientation) {
|
||
case 2:
|
||
ctx.transform(-1, 0, 0, 1, w, 0);
|
||
break;
|
||
case 3:
|
||
ctx.transform(-1, 0, 0, -1, w, h);
|
||
break;
|
||
case 4:
|
||
ctx.transform(1, 0, 0, -1, 0, h);
|
||
break;
|
||
case 5:
|
||
ctx.transform(0, 1, 1, 0, 0, 0);
|
||
break;
|
||
case 6:
|
||
ctx.transform(0, 1, -1, 0, h, 0);
|
||
break;
|
||
case 7:
|
||
// anti-transpose: (x,y) → (h-1-y, w-1-x) — e/f เป็น h,w ตาม rotateRgba ฝั่ง export
|
||
ctx.transform(0, -1, -1, 0, h, w);
|
||
break;
|
||
case 8:
|
||
ctx.transform(0, -1, 1, 0, 0, w);
|
||
break;
|
||
}
|
||
ctx.drawImage(src, 0, 0);
|
||
return canvas;
|
||
}
|
||
|
||
export async function decodeTiffPages(
|
||
buffer: ArrayBuffer,
|
||
imagePageWidth: number,
|
||
): Promise<DecodedTiffPage[]> {
|
||
const UTIF = await import("utif2");
|
||
const ifds = UTIF.decode(buffer);
|
||
// เอาเฉพาะ IFD ที่เป็นเฟรมรูป (มี tag ขนาด t256/t257) — ไม่ใช่ EXIF metadata IFD
|
||
const frames = ifds.filter((ifd) => Array.isArray(ifd.t256) && Array.isArray(ifd.t257));
|
||
if (frames.length === 0) throw new Error("ไม่พบเฟรมรูปในไฟล์ TIFF");
|
||
|
||
const pages: DecodedTiffPage[] = [];
|
||
for (const ifd of frames) {
|
||
const headerW = tagNumber(ifd, "t256");
|
||
const headerH = tagNumber(ifd, "t257");
|
||
try {
|
||
UTIF.decodeImage(buffer, ifd);
|
||
const rgba = UTIF.toRGBA8(ifd);
|
||
const orientation = tagNumber(ifd, "t274") || 1;
|
||
const canvas = rgbaToCanvas(rgba, ifd.width, ifd.height, orientation);
|
||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
|
||
if (!blob) throw new Error("แปลง canvas เป็น PNG ไม่ได้");
|
||
pages.push({
|
||
src: URL.createObjectURL(blob),
|
||
width: imagePageWidth,
|
||
height: (canvas.height / canvas.width) * imagePageWidth,
|
||
});
|
||
} catch (err) {
|
||
// เฟรมเดียวพัง (เช่น compression ที่ utif2 ไม่รอง) ไม่ทำให้ทั้งไฟล์พัง — placeholder เทา
|
||
// แต่ขนาดหน้ายังเอาจาก header ได้ → page count/scroll/bookmark/deep link เสถียร
|
||
console.error("decode เฟรม TIFF ไม่ได้ — แสดง placeholder", err);
|
||
const ratio = headerW > 0 && headerH > 0 ? headerH / headerW : 1.4;
|
||
pages.push({ src: null, width: imagePageWidth, height: imagePageWidth * ratio });
|
||
}
|
||
}
|
||
return pages;
|
||
}
|