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
108
utils/tiff-client.ts
Normal file
108
utils/tiff-client.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue