Compare commits
2 commits
df0392ea45
...
9cc38835f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cc38835f0 | ||
|
|
0c4af09e85 |
29 changed files with 2111 additions and 266 deletions
|
|
@ -18,3 +18,5 @@ paths:
|
|||
- `annotations.resolved` (NOT NULL default false) + `resolved_at` (NULL) — F10/A5: `setAnnotationResolved` **ใครก็ได้ toggle** (D6 คลายคืนได้ ต่างจาก delete ที่จำกัดเจ้าของ) + publish update event; ไม่เข้า history undo ของ F2
|
||||
- ตาราง `annotation_versions` (F19/A7): `annotation_id` เป็น uuid **ไม่ FK** (D8 — snapshot ตอน delete ต้องรอดจาก CASCADE ของแม่) + `file_id` FK CASCADE + `page_index` (restore ต้องรู้หน้า) + `kind` text `'update' | 'delete'` + index `(file_id)`; `updateAnnotation`/`deleteAnnotation` snapshot row เดิม **ก่อนเขียน/ลบ ใน transaction เดียวกัน**; `restoreVersion(versionId, userId)` — **เจ้าของเท่านั้น (D9)**: guard `userId` ทั้ง row ปัจจุบัน (ต้นทางยังอยู่) และ snapshot (ต้นทางถูกลบ) / ต้นทางยังอยู่ = **เขียนกลับ row เดิม** (snapshot ปัจจุบันก่อน) + publish update event / ต้นทางถูกลบ = สร้างใหม่ id ใหม่ + publish create event (ห้ามสร้างสำเนาใหม่ทุกกรณี — เคยทำให้เกิดรายการซ้ำบนหน้า)
|
||||
- ห้ามเพิ่มตาราง/column ที่ไม่มีในแผน (ดู out of scope §9) โดยไม่อัปเดต `docs/PLAN.md` ก่อน
|
||||
- `files.pdf_url` เก็บ path **รูปภาพ** (jpg/jpeg/png/tif/tiff) ใน `public/` ได้เหมือน PDF (N3) — **ไม่มี column kind**: ชนิดไฟล์ derive จาก extension ผ่าน `utils/file-kind.ts` เท่านั้น (จะเพิ่ม column ต้องแก้แผนก่อน); TIFF หลายเฟรม = `pageIndex` ต่อเฟรม (1 เฟรม = 1 หน้า)
|
||||
- Server Action `uploadFile` (`app/actions/files.ts`, N4): validate extension (whitelist `SAMPLE_FILE_EXTENSIONS`) + ≤ 100MB ก่อนเขียน — บันทึกเป็นไฟล์**ใหม่** `public/uploads/<uuid>.<ext>` เท่านั้น (ชื่อจาก randomUUID — ห้ามใช้ชื่อผู้ใช้ตกแต่ง path), insert row แล้ว `revalidatePath("/")` — ไม่ publish SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE); seed-samples จัดการเฉพาะ `/samples/%` ไม่แตะ `/uploads/`
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -27,4 +27,6 @@ dev-server.log
|
|||
next-env.d.ts
|
||||
|
||||
# Claude Code
|
||||
.claude/settings.local.json
|
||||
.claude/settings.local.json
|
||||
# N4: ไฟล์ที่ผู้ใช้อัปโหลดผ่านหน้ารายการ (รันไทม์ — ไม่เข้า repo)
|
||||
public/uploads/
|
||||
|
|
|
|||
13
CLAUDE.md
13
CLAUDE.md
|
|
@ -5,7 +5,7 @@
|
|||
**Judge's Note** — ระบบ PDF Annotation สำหรับ e-Court (โปรเจกต์ Next.js แยกต่างหาก ไม่เกี่ยวกับ dms-main ที่เป็น Nuxt)
|
||||
|
||||
- ฟีเจอร์: highlight ข้อความ, วาดเส้น freehand, sticky note comment, bookmark
|
||||
- **อ่านแผนฉบับเต็มก่อนเริ่มงานใด ๆ:** `docs/PLAN.md` (สถาปัตยกรรม, schema, พฤติกรรม feature, ลำดับ implement, edge cases) — ฟีเจอร์ต่อยอด (F1–F19 + N1/N2) อ่านเพิ่ม: `docs/PLAN-FEAT.md`
|
||||
- **อ่านแผนฉบับเต็มก่อนเริ่มงานใด ๆ:** `docs/PLAN.md` (สถาปัตยกรรม, schema, พฤติกรรม feature, ลำดับ implement, edge cases) — ฟีเจอร์ต่อยอด (F1–F19 + N1–N4) อ่านเพิ่ม: `docs/PLAN-FEAT.md`
|
||||
|
||||
## Tech Stack
|
||||
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
|
||||
## Invariants ที่ต้องรักษา (ห้ามละเมิด)
|
||||
|
||||
1. **Non-Destructive:** ไฟล์ PDF ต้นฉบับไม่ถูกแก้ไขเด็ดขาด — annotation เก็บใน DB เป็นพิกัดเท่านั้น (export PDF ของ F8 สร้างไฟล์ใหม่จากสำเนาในหน่วยความจำ — อ่าน `public/` อย่างเดียว ห้ามเขียนทับ)
|
||||
1. **Non-Destructive:** ไฟล์ต้นฉบับ (PDF/รูปภาพ) ไม่ถูกแก้ไขเด็ดขาด — annotation เก็บใน DB เป็นพิกัดเท่านั้น (export PDF ของ F8/N3 สร้างไฟล์ใหม่จากสำเนาในหน่วยความจำ — อ่าน `public/` อย่างเดียว ห้ามเขียนทับ; อัปโหลด N4 เขียนไฟล์**ใหม่**ที่ `public/uploads/` เท่านั้น ห้ามแตะไฟล์เดิม)
|
||||
2. **พิกัดทุกอย่างใน DB เป็น % ของขนาดหน้า** — `rects`, `paths`, `position`, `strokeWidth` (% ของ width), `yOffset` (% ของ height) — แปลง px⇄% ผ่าน `utils/coordinates.ts` เท่านั้น และก่อนบันทึก HIGHLIGHT ต้องรวบ rects ด้วย `mergeRectsByLine` เหลือ 1 rect ต่อบรรทัด
|
||||
3. **ลำดับ layer ตาม PLAN.md §1:** canvas (`pointer-events: none` เสมอ) → highlight-layer (`none` เสมอ, `mix-blend-mode: multiply`) → textLayer → InkLayer (SVG) → comment pins — สลับ pointer-events ตาม tool ปัจจุบัน
|
||||
4. **เขียนผ่าน Server Actions เท่านั้น** (`app/actions/annotations.ts`) และ**หลังเขียน DB สำเร็จทุกครั้ง ต้อง publish event ผ่าน `lib/annotate-events.ts` + `revalidatePath` หน้า viewer และ summary ของ fileId นั้น** (helper `revalidateFilePages` — สั่ง browser ทิ้ง Router Cache ฝั่ง client, `force-dynamic` ปิดแค่ฝั่ง server ไม่พอ: ไม่งั้นกด back แล้วคลิกเข้าไฟล์ใหม่ยังได้ initial data เก่าจนต้อง hard refresh) / **อ่าน + sync ผ่าน SSE hook** (`hooks/useAnnotationRealtime.ts` เปิด `EventSource` ไป `/api/annotations/stream?fileId=`) — event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ
|
||||
|
|
@ -41,9 +41,13 @@ utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation
|
|||
utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + ค่า default TEXTBOX/STAMP (sidebar/สรุป/CSV ใช้ร่วม)
|
||||
utils/pdf-export.ts ← แปลงพิกัดจอ⇄PDF (/Rotate) + wrapText + hexToRgb (export route)
|
||||
utils/user.ts ← anonymous identity (localStorage)
|
||||
utils/file-kind.ts ← derive ชนิดไฟล์ pdf/image จาก extension ของ path (N3 — ใช้ร่วม viewer/export/หน้ารายการ/seed)
|
||||
utils/tiff-client.ts ← decode TIFF ทุกเฟรม → PNG blob URL ฝั่ง client (N3)
|
||||
utils/image-to-pdf.ts ← สร้าง PDF ใหม่จากไฟล์รูป embedJpg/embedPng + EXIF rotate + TIFF→PNG (N3, export route)
|
||||
hooks/useAnnotationRealtime.ts ← EventSource ต่อ SSE stream + dedupe ด้วย id
|
||||
hooks/useHistory.ts ← undo/redo stack ฝั่ง client (F2)
|
||||
app/actions/annotations.ts ← Server Actions CRUD + validation + publish event
|
||||
app/actions/files.ts ← Server Action uploadFile — validate + เขียนไฟล์ใหม่ที่ public/uploads/ (N4)
|
||||
app/api/annotations/stream/route.ts ← SSE endpoint (GET ?fileId=)
|
||||
app/api/annotations/export/route.ts ← export PDF ฝัง annotation (pdf-lib, F8)
|
||||
app/api/annotations/export-csv/route.ts ← export สรุป CSV พร้อม BOM (F16)
|
||||
|
|
@ -55,16 +59,17 @@ components/pdf/InkLayer.tsx ← SVG freehand + รูปทรง + reda
|
|||
components/pdf/TextboxStampLayer.tsx ← overlay TEXTBOX + STAMP (F17/F18)
|
||||
components/pdf/AnnotationSelectionLayer.tsx ← กรอบ selection + handles ย้าย/ปรับขนาด (F1) + แถบสี/ความหนา + ปุ่มลบ
|
||||
components/sidebar/AnnotationSidebar.tsx ← + โน้ตคำอธิบาย (note) inline ต่อรายการ (PLAN-FEAT.md §7 N1)
|
||||
components/UploadFileForm.tsx ← ฟอร์มอัปโหลดไฟล์บนหน้ารายการ (N4)
|
||||
```
|
||||
|
||||
## ขอบเขต — ห้ามทำเกิน (Out of Scope)
|
||||
|
||||
> ขอบเขตด้านล่างเป็นของ**เฟสแรก** — ฟีเจอร์ต่อยอด (edit/ย้าย, undo/redo, export, reply, resolve ฯลฯ) อนุญาต**เฉพาะตาม task F1–F19 ใน `docs/PLAN-FEAT.md`** และทำผ่าน `/implement-feat` เท่านั้น ห้ามทำเกินสเปกของแผนนั้น — **ปัจจุบันทำครบตามแผนแล้ว ยกเว้น F11 (export/import JSON) ที่ถูกถอดออกจากโค้ดแล้ว**: แก้ไข/ย้าย/ปรับขนาด (F1), undo/redo (F2), คีย์ลัด (F3), กรองรายคน (F4), รูปทรง (F5), eraser (F6), สไตลัส/pressure (F7), redaction + export PDF (F8), reply เธรด (F9), resolve (F10), deep link (F12), แก้ไข comment (F13), ค้นหา (F14), หน้าสรุป (F15), export CSV (F16), text box (F17), stamp (F18), version history + กู้คืน (F19) — ฟีเจอร์เสริมหลังแผน (อนุญาตตาม §7 ของ `docs/PLAN-FEAT.md`): โน้ตคำอธิบาย `note?` ฝังใน annotation ทุกชนิดยกเว้น COMMENT แก้ไขใน sidebar + hover tooltip (N1), bookmark โครงสร้าง 3 ระดับ `parent_id` + โน้ต + popup สร้าง/แก้ไข/ย้ายหัวข้อ + ปุ่ม bookmark ในแผง selection + ชื่อ default จากข้อความที่คลุม (N2)
|
||||
> ขอบเขตด้านล่างเป็นของ**เฟสแรก** — ฟีเจอร์ต่อยอด (edit/ย้าย, undo/redo, export, reply, resolve ฯลฯ) อนุญาต**เฉพาะตาม task F1–F19 ใน `docs/PLAN-FEAT.md`** และทำผ่าน `/implement-feat` เท่านั้น ห้ามทำเกินสเปกของแผนนั้น — **ปัจจุบันทำครบตามแผนแล้ว ยกเว้น F11 (export/import JSON) ที่ถูกถอดออกจากโค้ดแล้ว**: แก้ไข/ย้าย/ปรับขนาด (F1), undo/redo (F2), คีย์ลัด (F3), กรองรายคน (F4), รูปทรง (F5), eraser (F6), สไตลัส/pressure (F7), redaction + export PDF (F8), reply เธรด (F9), resolve (F10), deep link (F12), แก้ไข comment (F13), ค้นหา (F14), หน้าสรุป (F15), export CSV (F16), text box (F17), stamp (F18), version history + กู้คืน (F19) — ฟีเจอร์เสริมหลังแผน (อนุญาตตาม §7 ของ `docs/PLAN-FEAT.md`): โน้ตคำอธิบาย `note?` ฝังใน annotation ทุกชนิดยกเว้น COMMENT แก้ไขใน sidebar + hover tooltip (N1), bookmark โครงสร้าง 3 ระดับ `parent_id` + โน้ต + popup สร้าง/แก้ไข/ย้ายหัวข้อ + ปุ่ม bookmark ในแผง selection + ชื่อ default จากข้อความที่คลุม (N2), รองรับไฟล์รูปภาพ jpg/jpeg/png/tif เป็นเอกสาร annotate ได้ครบทุกชนิด + TIFF หลายเฟรม = หลายหน้า + export PDF ฝังรูป (N3 — แผน `docs/PLAN-IMAGE.md`), อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการแบบง่าย (N4)
|
||||
|
||||
- ไม่มี login/roles, ไม่มี presence — undo/redo เป็น history ฝั่ง client ต่อ session ต่อผู้ใช้ ไม่เก็บใน DB (D10) ส่วน version history/กู้คืน (F19) เก็บในตาราง `annotation_versions`
|
||||
- Annotation: สร้าง + ลบ + แก้ไข/ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา/แก้ไขข้อความ **ของตัวเอง ในหน้าเดียวกัน** — resolve เป็นสิ่งเดียวที่ใครก็ toggle ได้ (D6)
|
||||
- ปุ่มลบ/แก้ไข/ประวัติแสดงเฉพาะรายการของตัวเอง (`userId` ตรงกัน) เท่านั้น
|
||||
- หน้า `/` อ่านรายการจาก DB เท่านั้น — ไม่มีหน้า upload ไฟล์ (sample data seed ด้วย SQL ใน README)
|
||||
- หน้า `/` อ่านรายการจาก DB + ฟอร์มอัปโหลดไฟล์เดียวแบบง่าย (N4 — PDF/jpg/jpeg/png/tif/tiff ≤ 100MB เขียนไฟล์ใหม่ที่ `public/uploads/` เท่านั้น; ไม่มีหน้าจัดการ/ลบไฟล์ใน UI; sample data sync ด้วย `npm run seed-samples`)
|
||||
- ลายเซ็นจากรูป/upload ยังเป็น out of scope (D7 — stamp เป็น preset ข้อความเท่านั้น, ลายเซ็นใช้ tool DRAW วาด)
|
||||
|
||||
## Claude Tooling (ใน `.claude/`)
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ npx drizzle-kit push
|
|||
|
||||
### 5. Seed ไฟล์ตัวอย่าง
|
||||
|
||||
ไฟล์ตัวอย่างอยู่ที่ `public/samples/` แล้ว — sync ลงตาราง `files` (idempotent รันซ้ำได้):
|
||||
ไฟล์ตัวอย่างอยู่ที่ `public/samples/` แล้ว — sync ลงตาราง `files` (idempotent รันซ้ำได้) — รองรับทั้ง `.pdf` และรูปภาพ `.jpg/.jpeg/.png/.tif/.tiff` (N3):
|
||||
|
||||
```bash
|
||||
npm run seed-samples
|
||||
|
|
@ -73,7 +73,7 @@ npm run seed-samples
|
|||
npm run dev
|
||||
```
|
||||
|
||||
เปิด http://localhost:3000 — คลิกไฟล์เพื่อเข้าหน้า annotate
|
||||
เปิด http://localhost:3000 — คลิกไฟล์เพื่อเข้าหน้า annotate หรืออัปโหลดไฟล์ใหม่ (PDF/รูปภาพ ≤ 100MB) ผ่านฟอร์มบนหน้ารายการ (N4 — ไฟล์เก็บที่ `public/uploads/`)
|
||||
|
||||
## การใช้งานบนหน้า annotate
|
||||
|
||||
|
|
@ -116,9 +116,10 @@ utils/user.ts anonymous identity (localStorage)
|
|||
hooks/useAnnotationRealtime.ts EventSource รับ SSE event + dedupe ด้วย id
|
||||
app/actions/annotations.ts Server Actions CRUD + publish event (ทางเขียนทางเดียว)
|
||||
app/api/annotations/stream/route.ts SSE endpoint (GET ?fileId=)
|
||||
app/page.tsx รายการไฟล์
|
||||
app/page.tsx รายการไฟล์ + ฟอร์มอัปโหลด (N4)
|
||||
app/annotate/[id]/page.tsx โหลด file + initial data → PDFViewer
|
||||
components/pdf/ PDFViewer (root + virtualization), InkLayer, HighlightToolbar
|
||||
components/sidebar/ AnnotationSidebar
|
||||
public/samples/ ไฟล์ PDF ตัวอย่าง
|
||||
public/samples/ ไฟล์ตัวอย่าง (PDF + รูปภาพ) — seed ด้วย npm run seed-samples
|
||||
public/uploads/ ไฟล์ที่อัปโหลดผ่านหน้ารายการ (N4)
|
||||
```
|
||||
|
|
|
|||
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: {
|
||||
|
|
|
|||
|
|
@ -206,6 +206,32 @@ html {
|
|||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
/* custom selection ของ tool highlight บนจอสัมผัส (hooks/useCustomSelection) —
|
||||
ปิด native selection/callout ของ OS และยึด gesture ลากเป็นของเราทั้งหมด
|
||||
(touch-action:none กัน browser ยึดไปเป็น scroll → pointercancel)
|
||||
hook ใส่/ถอด class นี้บน <main> ตาม enabled — ปิดแล้ว native selection
|
||||
กลับมาทันที (tool อื่น long-press copy ของ OS ใช้ได้ตามปกติ) */
|
||||
.pdf-custom-select {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* ช่องกรอกข้อมูลในหน้า (reply ของ comment pin, textbox) ที่อยู่ใต้ <main> ตอน
|
||||
tool highlight — ต้องเลือก/คัดลอกข้อความในช่องและเลื่อนข้อความภายในช่อง (reply
|
||||
ยาว) ได้ตามปกติ ไม่โดน touch-action:none ของ class ด้านบนลากไปด้วย (การ scroll
|
||||
ในช่องจบที่ตัวช่องเองซึ่งเป็น scroll container — pointerdown ของ custom
|
||||
selection ก็เริ่มบน .textLayer เท่านั้น ไม่ชนกัน) */
|
||||
.pdf-custom-select input,
|
||||
.pdf-custom-select textarea,
|
||||
.pdf-custom-select [contenteditable="true"] {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
-webkit-touch-callout: default;
|
||||
touch-action: auto;
|
||||
}
|
||||
|
||||
/* M6: safe-area — defensive (ค่าเป็น 0 จนกว่าจะใส่ viewport-fit=cover ใน meta
|
||||
ของ layout — ยังไม่ใส่ตามขอบเขตงานนี้ ใส่ไว้ก่อนเพื่อไม่ต้องแก้ CSS ซ้ำ) */
|
||||
header {
|
||||
|
|
|
|||
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>
|
||||
|
|
|
|||
57
components/UploadFileForm.tsx
Normal file
57
components/UploadFileForm.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"use client";
|
||||
|
||||
// N4 — ฟอร์มอัปโหลดไฟล์เอกสารแบบง่ายบนหน้ารายการ (PDF/รูป รับพร้อมกัน)
|
||||
// เสร็จแล้วรายการโผล่เองจาก revalidatePath("/") ของ action
|
||||
import { useState, useTransition } from "react";
|
||||
|
||||
import { uploadFile } from "@/app/actions/files";
|
||||
|
||||
const ACCEPT = ".pdf,.jpg,.jpeg,.png,.tif,.tiff";
|
||||
|
||||
export default function UploadFileForm() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await uploadFile(new FormData(form));
|
||||
if (result.ok) {
|
||||
form.reset();
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
} catch {
|
||||
// action fail ที่ระดับ transport (เช่น body ใหญ่เกิน bodySizeLimit ของ next.config)
|
||||
setError("อัปโหลดไม่สำเร็จ — ลองใหม่อีกครั้ง");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="mt-3 flex flex-wrap items-center gap-2 rounded-xl border border-dashed border-slate-300 bg-white/60 p-4"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
name="file"
|
||||
accept={ACCEPT}
|
||||
required
|
||||
disabled={pending}
|
||||
className="block w-full max-w-xs text-sm text-slate-600 file:mr-3 file:cursor-pointer file:rounded-md file:border-0 file:bg-primary/10 file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="shrink-0 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-white transition hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "กำลังอัปโหลด…" : "อัปโหลด"}
|
||||
</button>
|
||||
{error && <p className="w-full text-xs text-red-600">{error}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -65,6 +65,10 @@ import {
|
|||
useAnnotationRealtime,
|
||||
} from "@/hooks/useAnnotationRealtime";
|
||||
import { useHistory } from "@/hooks/useHistory";
|
||||
import {
|
||||
useCustomSelection,
|
||||
type CustomSelectionCommit,
|
||||
} from "@/hooks/useCustomSelection";
|
||||
import type {
|
||||
AnnotationData,
|
||||
AnnotationDTO,
|
||||
|
|
@ -102,12 +106,16 @@ import {
|
|||
mergeRectsByLine,
|
||||
rectToPercent,
|
||||
rectToPx,
|
||||
rectsInSweptPath,
|
||||
toPx,
|
||||
type ClientPt,
|
||||
type Point,
|
||||
type Rect,
|
||||
type Size,
|
||||
} from "@/utils/coordinates";
|
||||
import { getLocalUser, type LocalUser } from "@/utils/user";
|
||||
import { fileKindFromUrl, imageFormatFromUrl } from "@/utils/file-kind";
|
||||
import { decodeTiffPages } from "@/utils/tiff-client";
|
||||
import {
|
||||
TEXTBOX_DEFAULT_WIDTH_PCT,
|
||||
annotationBBox,
|
||||
|
|
@ -235,41 +243,6 @@ function squigglePath(w: number): string {
|
|||
return d;
|
||||
}
|
||||
|
||||
/** กรอง clientRects ของ selection ให้เหลือเฉพาะแนวเส้นทางที่ผู้ใช้ลากเมาส์จริง —
|
||||
* span ของ textLayer ในบาง PDF (เอกสารไทย) เรียงใน DOM ไม่ตรงลำดับบรรทัด และ
|
||||
* anchor/focus ของ DOM selection อาจตกที่ตำแหน่งไม่มีเรขาคณิต (zero-width node)
|
||||
* จึงใช้จุด mousedown + ตำแหน่งเมาส์ล่าสุด (client coords) เป็น ground truth แทน
|
||||
* กฎ: บรรทัดแรกเก็บจากจุดเริ่ม→ท้ายบรรทัด, บรรทัดสุดท้ายเก็บต้นบรรทัด→จุดจบ,
|
||||
* บรรทัดกลางเก็บทั้งบรรทัด, บรรทัดนอกช่วงแนวตั้งของเส้นทางลากทิ้ง (กันมาร์กเกิน/
|
||||
* เด้งไปบรรทัดอื่น) — คลิกไม่ขยับ (ดับเบิลคลิกเลือกคำ/ทริปเปิลคลิกเลือกบรรทัด)
|
||||
* ตัดเฉพาะแกนตั้ง ไม่ตัดแกน x เพราะไม่มีแนวการลากให้อิง */
|
||||
type ClientPt = { x: number; y: number };
|
||||
function rectsInSweptPath(rects: DOMRect[], start: ClientPt, end: ClientPt): DOMRect[] {
|
||||
const hs = rects.map((r) => r.height).sort((a, b) => a - b);
|
||||
const lineTol = (hs[Math.floor(hs.length / 2)] || 10) * 0.6;
|
||||
const moved = Math.hypot(end.x - start.x, end.y - start.y) > 4;
|
||||
// เรียงจุดตาม visual: บน→ล่าง (บรรทัดเดียวกัน: ซ้าย→ขวา) ให้กฎตรงทุกทิศการลาก
|
||||
let top: ClientPt;
|
||||
let bottom: ClientPt;
|
||||
if (Math.abs(end.y - start.y) < lineTol) {
|
||||
[top, bottom] = start.x <= end.x ? [start, end] : [end, start];
|
||||
} else {
|
||||
[top, bottom] = start.y < end.y ? [start, end] : [end, start];
|
||||
}
|
||||
return rects.filter((r) => {
|
||||
const cy = r.y + r.height / 2;
|
||||
if (cy < top.y - lineTol || cy > bottom.y + lineTol) return false; // บรรทัดนอกเส้นทางลาก
|
||||
if (!moved) return true;
|
||||
const inTop = Math.abs(cy - top.y) <= lineTol;
|
||||
const inBottom = Math.abs(cy - bottom.y) <= lineTol;
|
||||
const crosses = (from: number, to: number) => r.x < to && r.x + r.width > from;
|
||||
if (inTop && inBottom) return crosses(top.x, bottom.x); // ลากในบรรทัดเดียว
|
||||
if (inTop) return crosses(top.x, Infinity); // บรรทัดแรก: จุดเริ่ม → ท้ายบรรทัด
|
||||
if (inBottom) return crosses(-Infinity, bottom.x); // บรรทัดสุดท้าย: ต้นบรรทัด → จุดจบ
|
||||
return true; // บรรทัดกลาง: ครอบทั้งบรรทัด
|
||||
});
|
||||
}
|
||||
|
||||
/** วรรณยุกต์/สระบน-ล่างไทย (combining mark) — ตามหลังพยัญชนะฐานเสมอ แต่เดี่ยว ๆ
|
||||
* rect อาจกว้าง 0 หรือลอยเหนือบรรทัด จึงต้องให้ตามสถานะของตัวฐานก่อนหน้า */
|
||||
function isThaiCombiningMark(c: string): boolean {
|
||||
|
|
@ -567,9 +540,18 @@ function ReplyBox({
|
|||
|
||||
// ─── หน้าเดียว: canvas + highlight-layer + textLayer + InkLayer + pins ──────
|
||||
|
||||
/** N3: เนื้อหาหนึ่งหน้า — pdf (pdfjs) หรือรูปภาพ (src = URL รูป; null = เฟรม TIFF decode
|
||||
* ไม่ได้ → placeholder เทา แต่หน้ายังมีขนาดจาก IFD header ให้ scroll/bookmark เสถียร) */
|
||||
type PageContent =
|
||||
| { kind: "pdf"; pdfjs: typeof import("pdfjs-dist"); doc: import("pdfjs-dist").PDFDocumentProxy }
|
||||
| { kind: "image"; src: string | null };
|
||||
|
||||
/** N3: หน้ารูปภาพ normalize กว้าง 595 (เท่าหน้า A4 pt) — scale contract เดียวกับ export
|
||||
* (utils/image-to-pdf.ts) ให้ zoom %/fit-width clamp 0.5–3/fontSize % ทำงานเหมือนหน้า PDF */
|
||||
const IMAGE_PAGE_WIDTH = 595;
|
||||
|
||||
function PdfPage({
|
||||
pdfjs,
|
||||
doc,
|
||||
content,
|
||||
pageNumber,
|
||||
width,
|
||||
height,
|
||||
|
|
@ -607,8 +589,7 @@ function PdfPage({
|
|||
onErase,
|
||||
onTextLayerRendered,
|
||||
}: {
|
||||
pdfjs: typeof import("pdfjs-dist");
|
||||
doc: import("pdfjs-dist").PDFDocumentProxy;
|
||||
content: PageContent;
|
||||
/** 1-based */
|
||||
pageNumber: number;
|
||||
width: number;
|
||||
|
|
@ -670,12 +651,17 @@ function PdfPage({
|
|||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const textLayerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// N3: หน้ารูปภาพไม่มี canvas/textLayer — effect ของ pdfjs ทำงานเฉพาะหน้า pdf
|
||||
const pdfjsLib = content.kind === "pdf" ? content.pdfjs : null;
|
||||
const pdfDoc = content.kind === "pdf" ? content.doc : null;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let renderTask: import("pdfjs-dist").RenderTask | null = null;
|
||||
if (!pdfjsLib || !pdfDoc) return;
|
||||
|
||||
(async () => {
|
||||
const page = await doc.getPage(pageNumber);
|
||||
const page = await pdfDoc.getPage(pageNumber);
|
||||
if (cancelled) return;
|
||||
|
||||
const scale = width / page.getViewport({ scale: 1 }).width;
|
||||
|
|
@ -706,7 +692,7 @@ function PdfPage({
|
|||
if (cancelled) return;
|
||||
container.replaceChildren();
|
||||
container.style.setProperty("--scale-factor", String(scale));
|
||||
const textLayer = new pdfjs.TextLayer({
|
||||
const textLayer = new pdfjsLib.TextLayer({
|
||||
textContentSource: textContent,
|
||||
container,
|
||||
viewport,
|
||||
|
|
@ -727,7 +713,14 @@ function PdfPage({
|
|||
// onTextLayerRendered ตั้งใจไม่อยู่ใน deps: callback ต้องเป็น useCallback [] เสถียร
|
||||
// เท่านั้น (handleTextLayerRendered) — ถ้าวันหน้าทำให้ identity เปลี่ยนได้ ต้องย้าย
|
||||
// ไปเก็บใน ref ก่อน ไม่งั้น effect นี้จะไม่รันซ้ำและค่า span count ของ chip จะเก่า
|
||||
}, [pdfjs, doc, pageNumber, width, height]);
|
||||
}, [pdfjsLib, pdfDoc, pageNumber, width, height]);
|
||||
|
||||
// N3: หน้ารูปไม่มี textLayer — รายงาน span count = 0 ให้ hint "ภาพสแกน" ของ tool
|
||||
// highlight ทำงาน (พฤติกรรมเดียวกับ PDF สแกน) — onTextLayerRendered ไม่อยู่ใน deps
|
||||
// เพราะเป็น useCallback [] เสถียร (เหมือน effect ด้านบน)
|
||||
useEffect(() => {
|
||||
if (content.kind === "image") onTextLayerRendered?.(pageNumber - 1, 0);
|
||||
}, [content.kind, pageNumber]);
|
||||
|
||||
const highlights = annotations.filter(isHighlight);
|
||||
const inkStrokes: InkStroke[] = annotations
|
||||
|
|
@ -841,8 +834,22 @@ function PdfPage({
|
|||
|
||||
return (
|
||||
<>
|
||||
{/* z-1: canvas — pointer-events: none เสมอ */}
|
||||
<canvas ref={canvasRef} className="absolute inset-0 z-[1] pointer-events-none" />
|
||||
{/* z-1: canvas (pdf) / <img> (รูปภาพ N3) — pointer-events: none เสมอ (invariant #3)
|
||||
รูป: browser render ที่ devicePixelRatio เต็มเอง เทียบเท่า canvas×dpr ของ pdfjs */}
|
||||
{content.kind === "pdf" ? (
|
||||
<canvas ref={canvasRef} className="absolute inset-0 z-[1] pointer-events-none" />
|
||||
) : content.src ? (
|
||||
<img
|
||||
src={content.src}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="pointer-events-none absolute inset-0 z-[1] h-full w-full select-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="pointer-events-none absolute inset-0 z-[1] flex items-center justify-center bg-slate-200 text-xs text-slate-500">
|
||||
ถอดรหัสหน้านี้ไม่ได้
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* z-2: highlight-layer — pointer-events: none เสมอ, อยู่หน้า textLayer ใน DOM order
|
||||
แสดงผลตาม style: ทึบ / เส้นใต้ (ขอบล่าง) / ขีดฆ่า (กลางบรรทัด) / ขยุกขยิก (svg) */}
|
||||
|
|
@ -885,12 +892,15 @@ function PdfPage({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* z-2: textLayer (pdfjs) — อยู่หลัง highlight-layer ใน DOM → อยู่บนสุดของ z-2 */}
|
||||
<div
|
||||
ref={textLayerRef}
|
||||
className="textLayer absolute inset-0 z-[2]"
|
||||
style={{ pointerEvents: overlayFrozen ? "none" : "auto" }}
|
||||
/>
|
||||
{/* z-2: textLayer (pdfjs — pdf เท่านั้น, N3: หน้ารูปไม่มี) — อยู่หลัง highlight-layer
|
||||
ใน DOM → อยู่บนสุดของ z-2 (conditional render ไม่ย้ายโหนด — invariant #3) */}
|
||||
{content.kind === "pdf" && (
|
||||
<div
|
||||
ref={textLayerRef}
|
||||
className="textLayer absolute inset-0 z-[2]"
|
||||
style={{ pointerEvents: overlayFrozen ? "none" : "auto" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* z-3: InkLayer (SVG freehand + รูปทรง F5) */}
|
||||
<InkLayer
|
||||
|
|
@ -1083,7 +1093,9 @@ function PdfPage({
|
|||
{/* z-4: กรอบ selection + handles ของรายการที่เลือก (F1) — หลัง pins ใน DOM */}
|
||||
{(() => {
|
||||
const selected = annotations.find((a) => a.id === selectedId);
|
||||
if (!selected) return null;
|
||||
// COMMENT ที่เปิด popover อยู่ไม่วาดกรอบ/แผง — bbox ของ COMMENT ครอบพื้นที่ pin
|
||||
// พอดี แผงจะทับ popover และแถบ handle บังการคลิก (sidebar ยังตาม selectedId เหมือนเดิม)
|
||||
if (!selected || (selected.type === "COMMENT" && openPinId === selected.id)) return null;
|
||||
return (
|
||||
<AnnotationSelectionLayer
|
||||
annotation={selected}
|
||||
|
|
@ -1118,6 +1130,13 @@ export default function PDFViewer({
|
|||
}) {
|
||||
const [pdfjs, setPdfjs] = useState<typeof import("pdfjs-dist") | null>(null);
|
||||
const [doc, setDoc] = useState<import("pdfjs-dist").PDFDocumentProxy | null>(null);
|
||||
// N3: src ของรูปภาพต่อหน้า (คู่ขนานกับ doc ของ pdf — ไฟล์รูปใช้อันนี้แทน pdfjs/doc)
|
||||
const [imageSrcs, setImageSrcs] = useState<(string | null)[] | null>(null);
|
||||
// โหลดเอกสารพัง (ไฟล์เสีย/decode ไม่ได้) — แสดงข้อความแทน "กำลังโหลด…" ค้างตลอดไป
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
// F8: Export PDF ดาวน์โหลดในแท็บเดิม (fetch → blob → <a download>) ไม่เปิดแท็บใหม่
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
const [pageSizes, setPageSizes] = useState<Size[]>([]);
|
||||
const [tool, setTool] = useState<Tool>("select");
|
||||
const [shapeKind, setShapeKind] = useState<ShapeKind>("rect"); // F5: ชนิดรูปทรงของ tool shape
|
||||
|
|
@ -1346,11 +1365,99 @@ export default function PDFViewer({
|
|||
);
|
||||
}, []);
|
||||
|
||||
// F8: Export PDF — ดาวน์โหลดเงียบ ๆ ในแท็บเดิม (เดิม window.open _blank จะแวบแท็บใหม่
|
||||
// และ error พา viewer ไปโผล่แท็บนั้น): fetch เป็น blob แล้วกด <a download> โดยรับชื่อ
|
||||
// ไฟล์จาก Content-Disposition ของ route (รองรับภาษาไทยผ่าน filename*) — พัง = toast ในหน้า
|
||||
const handleExportPdf = async () => {
|
||||
if (exporting) return;
|
||||
setExporting(true);
|
||||
setExportError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/annotations/export?fileId=${file.id}`);
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const disposition = res.headers.get("Content-Disposition") ?? "";
|
||||
const utf8 = /filename\*=UTF-8''([^;\s]+)/i.exec(disposition);
|
||||
const ascii = /filename="([^"]+)"/i.exec(disposition);
|
||||
const name = utf8 ? decodeURIComponent(utf8[1]) : (ascii?.[1] ?? `${file.title}.pdf`);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error("export PDF ไม่สำเร็จ", err);
|
||||
setExportError("Export PDF ไม่สำเร็จ — ลองอีกครั้ง (ไฟล์อาจใหญ่เกินหรือต้นฉบับเสียหาย)");
|
||||
window.setTimeout(() => setExportError(null), 6000);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// โหลดเอกสาร: dynamic import ภายใน useEffect เท่านั้น (invariant #6)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let loadedDoc: import("pdfjs-dist").PDFDocumentProxy | null = null;
|
||||
|
||||
// สลับไฟล์ (Next reuse component เดิมเมื่อเปลี่ยน dynamic param): เคลียร์เอกสาร
|
||||
// ก่อนหน้าก่อนโหลดใหม่ — กัน state เก่า (doc ที่ถูก destroy / blob URL ที่ถูก revoke
|
||||
// / span count ของหน้าเดิม) ถูก render ชั่วขณะกลางทาง แล้วกลับไป "กำลังโหลด…" ตามปกติ
|
||||
setLoadError(null);
|
||||
setPageSizes([]);
|
||||
setImageSrcs(null);
|
||||
setPageSpanCount({});
|
||||
|
||||
// N3: ไฟล์รูปภาพไม่ผ่าน pdfjs — โหลด static pdfUrl ตรง ๆ (ไม่มี ToUnicode ให้ซ่อม
|
||||
// ตาม rules/pdf-viewer.md) แล้วผลิต pageSizes จาก naturalWidth/Height (browser คิด
|
||||
// EXIF orientation ให้แล้ว) normalize กว้าง IMAGE_PAGE_WIDTH ให้ zoom/fit-width
|
||||
// ทำงานเหมือนหน้า A4 — jpg/png คือ 1 หน้าเสมอ
|
||||
if (fileKindFromUrl(file.pdfUrl) === "image") {
|
||||
setDoc(null);
|
||||
setPdfjs(null);
|
||||
const objectUrls: string[] = [];
|
||||
(async () => {
|
||||
let pages: { src: string | null; width: number; height: number }[];
|
||||
if (imageFormatFromUrl(file.pdfUrl) === "tiff") {
|
||||
// N3: TIFF หลายเฟรม = หลายหน้า — decode ครั้งเดียวต่อการเปิดไฟล์
|
||||
// (utif2 dynamic import ภายใน decodeTiffPages) แล้วเก็บ blob URL ไว้ใช้ต่อ
|
||||
const buffer = await (await fetch(file.pdfUrl)).arrayBuffer();
|
||||
pages = await decodeTiffPages(buffer, IMAGE_PAGE_WIDTH);
|
||||
for (const p of pages) if (p.src) objectUrls.push(p.src);
|
||||
} else {
|
||||
const img = new Image();
|
||||
img.src = file.pdfUrl;
|
||||
await img.decode();
|
||||
const factor = IMAGE_PAGE_WIDTH / img.naturalWidth;
|
||||
pages = [
|
||||
{ src: file.pdfUrl, width: IMAGE_PAGE_WIDTH, height: img.naturalHeight * factor },
|
||||
];
|
||||
}
|
||||
if (cancelled) {
|
||||
// cleanup ของ effect รันไปแล้วตอน objectUrls ยังว่าง (สลับไฟล์กลาง decode)
|
||||
// — ต้อง revoke เอง ไม่งั้น blob ทั้งชุดรั่ว (plan-reviewer)
|
||||
objectUrls.forEach((u) => URL.revokeObjectURL(u));
|
||||
return;
|
||||
}
|
||||
setImageSrcs(pages.map((p) => p.src));
|
||||
setPageSizes(pages.map((p) => ({ width: p.width, height: p.height })));
|
||||
})().catch((err) => {
|
||||
console.error("โหลดรูปไม่สำเร็จ", err);
|
||||
if (!cancelled) setLoadError("โหลดรูปไม่สำเร็จ — ไฟล์อาจเสียหรือรูปแบบไม่รองรับ");
|
||||
objectUrls.forEach((u) => URL.revokeObjectURL(u));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
// N3: revoke ทุก blob URL ตอนสลับไฟล์/unmount กัน memory leak (rules/pdf-viewer.md)
|
||||
objectUrls.forEach((u) => URL.revokeObjectURL(u));
|
||||
};
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const pdfjs = await import("pdfjs-dist");
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs";
|
||||
|
|
@ -1386,6 +1493,7 @@ export default function PDFViewer({
|
|||
setPageSizes(sizes);
|
||||
})().catch((err) => {
|
||||
console.error("โหลดเอกสารไม่สำเร็จ", err);
|
||||
if (!cancelled) setLoadError("โหลดเอกสารไม่สำเร็จ — ไฟล์อาจเสียหรือไม่ใช่ PDF ที่รองรับ");
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
|
@ -1514,7 +1622,6 @@ export default function PDFViewer({
|
|||
// M4: เวลาที่เมนู/popup ปิดล่าสุด — tap/คลิกที่ใช้ปิดเมนูยังยิง click ต่อถึงหน้ากระดาษ
|
||||
// ในลำดับเดียวกัน (mousedown ปิดเมนูก่อน → click ไปถึง page) กลายเป็นวาง comment/
|
||||
// textbox/stamp โดยไม่ตั้งใจ — handlePageClick จึงเฉย 300ms แรกหลังปิดเมนู
|
||||
// (pattern เดียวกับ touchHideTimerRef ของปุ่มไฮไลต์จอสัมผัส)
|
||||
const menuClosedAtRef = useRef(0);
|
||||
function markMenuClosed() {
|
||||
menuClosedAtRef.current = Date.now();
|
||||
|
|
@ -1903,23 +2010,6 @@ export default function PDFViewer({
|
|||
raw: string;
|
||||
} | null>(null);
|
||||
|
||||
/** จอสัมผัส: selection ล่าสุดที่หยุดนิ่งแล้ว (snapshot) + ตำแหน่งปุ่มยืนยัน —
|
||||
* บน touch ไม่มี mouseup หลังคลุมข้อความด้วยนิ้ว (synthetic mouse events เกิด
|
||||
* เฉพาะ tap) จึง commit ผ่านปุ่ม "ไฮไลต์ข้อความ" ลอยใต้ selection แทน — ปุ่มอ่านจาก
|
||||
* snapshot ใน ref เพราะ iOS เคลียร์ selection ทันทีที่แตะปุ่ม ก่อน click ยิง */
|
||||
const [touchConfirm, setTouchConfirm] = useState<{
|
||||
pageIndex: number;
|
||||
x: number; // จุดยึดกึ่งกลางแนวนอนของแถบ (px อิงหน้า)
|
||||
y: number; // ขอบบนของแถบ (px อิงหน้า)
|
||||
} | null>(null);
|
||||
const touchSelRef = useRef<{
|
||||
pageIndex: number;
|
||||
rects: HighlightData["rects"];
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const touchSelTimerRef = useRef<number | null>(null);
|
||||
const touchHideTimerRef = useRef<number | null>(null);
|
||||
|
||||
/** ความลึกของ bookmark (root = 1) บน state ปัจจุบัน — popup ใช้กรองเป้าหมายระดับ 1–2
|
||||
* (cap 100 กันลูปไม่รู้จบหากข้อมูลวนซ้ำ) */
|
||||
function bookmarkDepthOf(b: BookmarkDTO): number {
|
||||
|
|
@ -1946,6 +2036,14 @@ export default function PDFViewer({
|
|||
}, []);
|
||||
|
||||
function openBookmarkComposer() {
|
||||
// ปิดเมนู tool ที่เปิดอยู่ก่อน (เช่นคีย์ลัด B ระหว่างเมนูเปิด) — popup bookmark (z เดียวกัน
|
||||
// ทั้งคู่ fixed ใต้แถบ tool) เปิดซ้อนบนเมนูได้; ไม่ markMenuClosed เพราะ B เป็นคีย์บอร์ด
|
||||
// ไม่มี click หลงเหลือให้กลืน
|
||||
setStampMenuOpen(false);
|
||||
setShapeMenuOpen(false);
|
||||
setDrawMenuOpen(false);
|
||||
setTextboxMenuOpen(false);
|
||||
setHighlightMenuOpen(false);
|
||||
const pos = currentViewPosition();
|
||||
if (!pos) return;
|
||||
setBookmarkError(null);
|
||||
|
|
@ -2082,13 +2180,23 @@ export default function PDFViewer({
|
|||
/** อ่าน text selection ปัจจุบันบนหน้าที่ระบุ → rects % (รวบบรรทัดแล้ว) + ข้อความ + rect แรก
|
||||
* ใช้ทั้งตอน mouseup สร้างมาร์ก และ live preview ระหว่างลาก — ข้อความ (กรองให้เหลือ
|
||||
* เฉพาะตัวอักษรใน rects ที่เห็นบนจอ) คำนวณเฉพาะเมื่อ withText เพราะแพง ส่วน live
|
||||
* preview ที่เรียกถี่ระหว่างลากใช้แค่ rects */
|
||||
function readTextSelection(pageIndex: number, opts?: { withText?: boolean }) {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
|
||||
return null;
|
||||
* preview ที่เรียกถี่ระหว่างลากใช้แค่ rects
|
||||
* opts.range = ใช้ Range จากภายนอกแทน native selection (custom selection จอสัมผัส
|
||||
* ของ useCustomSelection — ตอนนั้น user-select:none ไม่มี native selection ให้อ่าน) */
|
||||
function readTextSelection(
|
||||
pageIndex: number,
|
||||
opts?: { withText?: boolean; range?: Range },
|
||||
) {
|
||||
let range: Range;
|
||||
if (opts?.range) {
|
||||
range = opts.range;
|
||||
} else {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
|
||||
return null;
|
||||
}
|
||||
range = selection.getRangeAt(0);
|
||||
}
|
||||
const range = selection.getRangeAt(0);
|
||||
// รับเฉพาะ selection ใน page เดียว (PLAN.md §8)
|
||||
const container = range.commonAncestorContainer;
|
||||
const node =
|
||||
|
|
@ -2121,7 +2229,7 @@ export default function PDFViewer({
|
|||
start: { x: startPt.x - pageRect.left, y: startPt.y - pageRect.top },
|
||||
end: { x: endPt.x - pageRect.left, y: endPt.y - pageRect.top },
|
||||
pageWidth: pageRect.width,
|
||||
raw: selection.toString(),
|
||||
raw: range.toString(),
|
||||
}
|
||||
: null; // เลือกด้วยคีย์บอร์ด (ไม่มีเส้นทาง) — copy ใช้ rect ของ range ตรง ๆ
|
||||
}
|
||||
|
|
@ -2151,48 +2259,19 @@ export default function PDFViewer({
|
|||
// (แสดงเส้น/สีตามรูปแบบที่ตั้งไว้ทันที ไม่ต้องรอปล่อยเมาส์)
|
||||
// tool ที่เหลือของ TEXT_SELECTION_TOOLS (select/comment/textbox/stamp) — ใช้กลไก
|
||||
// เดียวกันแต่วาดเป็น selection สีฟ้าสะอาด ๆ (ไม่สร้างไฮไลต์ ไม่วางรายการ)
|
||||
// จอสัมผัส (coarse) + tool highlight ไม่ผ่านทางนี้ — native selection ถูกปิดไว้
|
||||
// ใช้ custom selection ของ useCustomSelection แทน (ด้านล่าง)
|
||||
useEffect(() => {
|
||||
if (!TEXT_SELECTION_TOOLS.has(tool)) {
|
||||
setLiveMark(null);
|
||||
setSelectPreview(null);
|
||||
setTouchConfirm(null);
|
||||
touchSelRef.current = null;
|
||||
return;
|
||||
}
|
||||
// จอสัมผัส: หลังคลุมข้อความด้วยนิ้วไม่มี mouseup ให้ commit (synthetic mouse
|
||||
// events เกิดเฉพาะ tap) → tool highlight แสดงปุ่ม "ไฮไลต์ข้อความ" ลอยใต้ selection
|
||||
// แทนการสร้างตอนปล่อยเมาส์ — เฉพาะอุปกรณ์ที่ pointer หลักเป็น coarse (เมาส์
|
||||
// ยังใช้ mouseup ตามเดิม ไม่โผล่ปุ่มมารบกวน)
|
||||
const coarse = window.matchMedia("(pointer: coarse)").matches;
|
||||
// ซ่อนปุ่ม + ล้าง snapshot แบบหน่วงเวลา: บน iOS การแตะปุ่มเองทำให้ OS เคลียร์
|
||||
// selection ก่อน tap จะถึงปุ่ม (selectionchange collapsed ยิงก่อน pointerdown
|
||||
// ของปุ่ม) — หน่วงไว้ให้ tap นั้น commit จาก snapshot ได้ก่อน ปุ่มจึงไม่หายกลางคัน
|
||||
const scheduleTouchHide = () => {
|
||||
if (touchHideTimerRef.current !== null) {
|
||||
window.clearTimeout(touchHideTimerRef.current);
|
||||
}
|
||||
touchHideTimerRef.current = window.setTimeout(() => {
|
||||
touchHideTimerRef.current = null;
|
||||
touchSelRef.current = null;
|
||||
setTouchConfirm(null);
|
||||
}, 250);
|
||||
};
|
||||
const onSelectionChange = () => {
|
||||
// selection กำลังเปลี่ยนอยู่ — ยกเลิก snapshot ที่ค้าง + การซ่อนปุ่มที่ค้าง
|
||||
// (selection กลับมา = ผู้ใช้เริ่ม/ปรับ selection ใหม่ ปุ่มต้องอยู่ต่อ)
|
||||
if (touchSelTimerRef.current !== null) {
|
||||
window.clearTimeout(touchSelTimerRef.current);
|
||||
touchSelTimerRef.current = null;
|
||||
}
|
||||
if (touchHideTimerRef.current !== null) {
|
||||
window.clearTimeout(touchHideTimerRef.current);
|
||||
touchHideTimerRef.current = null;
|
||||
}
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
|
||||
setLiveMark(null);
|
||||
setSelectPreview(null);
|
||||
scheduleTouchHide();
|
||||
return;
|
||||
}
|
||||
const node = selection.getRangeAt(0).commonAncestorContainer;
|
||||
|
|
@ -2201,7 +2280,6 @@ export default function PDFViewer({
|
|||
if (!pageEl) {
|
||||
setLiveMark(null);
|
||||
setSelectPreview(null);
|
||||
scheduleTouchHide();
|
||||
return;
|
||||
}
|
||||
const pi = Number(pageEl.dataset.page);
|
||||
|
|
@ -2224,34 +2302,6 @@ export default function PDFViewer({
|
|||
);
|
||||
return same ? prev : next;
|
||||
});
|
||||
// จอสัมผัส + tool highlight — selection หยุดนิ่ง ~400ms แล้ว snapshot (rects +
|
||||
// ข้อความ) ให้ปุ่มยืนยันใช้: debounce ข้ามช่วงที่ผู้ใช้ยังลาก handles อยู่
|
||||
if (coarse && tool === "highlight") {
|
||||
touchSelTimerRef.current = window.setTimeout(() => {
|
||||
touchSelTimerRef.current = null;
|
||||
const snap = readTextSelection(pi, { withText: true });
|
||||
if (!snap) {
|
||||
scheduleTouchHide();
|
||||
return;
|
||||
}
|
||||
touchSelRef.current = {
|
||||
pageIndex: pi,
|
||||
rects: snap.rects,
|
||||
text: snap.text,
|
||||
};
|
||||
// ชื่อ bookmark default จาก selection เหมือน mouseup ของ desktop
|
||||
lastSelectionTextRef.current = normalizeBookmarkTitle(snap.text);
|
||||
const pr = pageEl.getBoundingClientRect();
|
||||
// แถบลอยใต้บรรทัดสุดท้ายของ selection — ด้านบนถูกเมนู/handles ของ OS
|
||||
// ยึดไว้ ใกล้ขอบล่างหน้าเกิน → พลิกขึ้นเหนือ rect แรกแทน
|
||||
const below = snap.last.bottom - pr.top + 8;
|
||||
setTouchConfirm({
|
||||
pageIndex: pi,
|
||||
x: snap.first.left + snap.first.width / 2 - pr.left,
|
||||
y: below + 40 <= pr.height ? below : snap.first.top - pr.top - 44,
|
||||
});
|
||||
}, 400);
|
||||
}
|
||||
};
|
||||
document.addEventListener("selectionchange", onSelectionChange);
|
||||
// เคลียร์เส้นทางลากตอนปล่อยเมาส์ — listener นี้ที่ document ยิงหลัง React
|
||||
|
|
@ -2264,14 +2314,6 @@ export default function PDFViewer({
|
|||
return () => {
|
||||
document.removeEventListener("selectionchange", onSelectionChange);
|
||||
document.removeEventListener("mouseup", onMouseUpDoc);
|
||||
if (touchSelTimerRef.current !== null) {
|
||||
window.clearTimeout(touchSelTimerRef.current);
|
||||
touchSelTimerRef.current = null;
|
||||
}
|
||||
if (touchHideTimerRef.current !== null) {
|
||||
window.clearTimeout(touchHideTimerRef.current);
|
||||
touchHideTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [tool]);
|
||||
|
||||
|
|
@ -2318,7 +2360,7 @@ export default function PDFViewer({
|
|||
}, [tool]);
|
||||
|
||||
/** สร้าง HIGHLIGHT จาก selection ที่อ่านได้ — ใช้ร่วมกันทั้ง desktop (mouseup บนหน้า)
|
||||
* และจอสัมผัส (ปุ่ม "ไฮไลต์ข้อความ" ลอยใต้ selection) */
|
||||
* และจอสัมผัส (custom selection ของ useCustomSelection commit ตอนปล่อยนิ้ว) */
|
||||
function commitHighlight(
|
||||
pageIndex: number,
|
||||
sel: { rects: HighlightData["rects"]; text: string },
|
||||
|
|
@ -2334,19 +2376,32 @@ export default function PDFViewer({
|
|||
});
|
||||
}
|
||||
|
||||
/** จอสัมผัส: แตะปุ่ม "ไฮไลต์ข้อความ" ลอยใต้ selection — commit จาก snapshot ใน ref
|
||||
* (ไม่ใช่ selection สด เพราะ iOS เคลียร์ selection ก่อน tap จะถึงปุ่ม —
|
||||
* จึงผูกกับ onPointerDown ที่ยิงก่อน click ด้วย) */
|
||||
function handleTouchConfirmHighlight() {
|
||||
const snap = touchSelRef.current;
|
||||
touchSelRef.current = null;
|
||||
if (touchHideTimerRef.current !== null) {
|
||||
window.clearTimeout(touchHideTimerRef.current);
|
||||
touchHideTimerRef.current = null;
|
||||
}
|
||||
setTouchConfirm(null);
|
||||
if (!snap) return;
|
||||
commitHighlight(snap.pageIndex, snap);
|
||||
/** custom selection (จอสัมผัส + tool highlight) ปล่อยนิ้ว — hook สร้าง Range จาก
|
||||
* caret ตำแหน่งนิ้วให้ อ่านเป็น rects+ข้อความผ่าน readTextSelection ชุดเดียวกับ
|
||||
* desktop (อุดหนุเส้นทางลากให้ rectsInSweptPath กรองแนวที่ลากจริง) แล้ว commit
|
||||
* ทันทีไม่มีปุ่มยืนยัน — สร้างเสร็จเลือกให้ทันทีผ่าน AUTO_SELECT_TYPES ของ
|
||||
* handleCreateAnnotation เหมือน desktop */
|
||||
function handleCustomSelectionCommit({
|
||||
range,
|
||||
start,
|
||||
end,
|
||||
}: CustomSelectionCommit) {
|
||||
const node = range.commonAncestorContainer;
|
||||
const el = node instanceof Element ? node : node.parentElement;
|
||||
const pageEl = el?.closest<HTMLElement>("[data-page]");
|
||||
if (!pageEl) return;
|
||||
const pi = Number(pageEl.dataset.page);
|
||||
// ยืม selDrag refs ชั่วคราวให้ readTextSelection กรองตามเส้นทางนิ้ว (desktop
|
||||
// เขียนคู่นี้จาก mousedown/mousemove ของ page wrapper)
|
||||
selDragStartRef.current = start;
|
||||
selDragEndRef.current = end;
|
||||
const sel = readTextSelection(pi, { withText: true, range });
|
||||
selDragStartRef.current = null;
|
||||
selDragEndRef.current = null;
|
||||
if (!sel) return;
|
||||
// ชื่อ bookmark default จาก selection เหมือน mouseup ของ desktop
|
||||
lastSelectionTextRef.current = normalizeBookmarkTitle(sel.text);
|
||||
commitHighlight(pi, sel);
|
||||
}
|
||||
|
||||
function handleMouseUpOnPage(pageIndex: number) {
|
||||
|
|
@ -2362,6 +2417,25 @@ export default function PDFViewer({
|
|||
commitHighlight(pageIndex, sel);
|
||||
}
|
||||
|
||||
/** อุปกรณ์จอสัมผัส (pointer หลัก coarse) — คำนวณครั้งเดียวตอน mount: เฉพาะพวกนี้
|
||||
* ที่ใช้ custom selection ตอน tool highlight (desktop ใช้ native mouse selection
|
||||
* + commit ตอน mouseup ตามเดิม ไม่เปลี่ยนพฤติกรรม) */
|
||||
const [coarseTouch] = useState(
|
||||
() =>
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(pointer: coarse)").matches,
|
||||
);
|
||||
|
||||
// จอสัมผัส + tool highlight — ลากเลือกข้อความเองผ่าน Pointer Events (ไม่พึ่ง
|
||||
// native selection ของ OS ที่ยึด gesture ปล่อยนิ้วไปแสดงเมนู/handles): ลาก → ปล่อย
|
||||
// = ไฮไลต์ทันที ผ่าน commitHighlight ชุดเดียวกับ desktop / preview ของ hook
|
||||
// (สร้างสดระหว่างลาก) ทดแทน liveMark ของ selectionchange ที่ใช้ไม่ได้ตอนนั้น
|
||||
const { previewRects: customSelPreview } = useCustomSelection({
|
||||
containerRef: mainRef,
|
||||
enabled: tool === "highlight" && coarseTouch,
|
||||
onCommit: handleCustomSelectionCommit,
|
||||
});
|
||||
|
||||
// ─── Comment (PLAN.md §6.3) ────────────────────────────────────────────────
|
||||
|
||||
/** N1: ชี้ mouse เหนือ annotation บนหน้า → เก็บ id ไว้แสดง tooltip (ใครทำ + โน้ต)
|
||||
|
|
@ -2430,6 +2504,9 @@ export default function PDFViewer({
|
|||
// ต้องไม่กลายเป็นการวาง comment/textbox/stamp — คลิกที่สอง (หลัง 300ms) ค่อยวาง
|
||||
if (Date.now() - menuClosedAtRef.current < 300) return;
|
||||
if (bookmarkComposer) setBookmarkComposer(null); // คลิกหน้า = ปิด popup bookmark (N2)
|
||||
// คลิกหน้าว่าง = ปิด popover ของ comment pin ด้วย — คลิกใน popover/pin ถูก
|
||||
// stopPropagation ไว้แล้วจึงไม่โดนบรรทัดนี้
|
||||
if (openPinId) setOpenPinId(null);
|
||||
// คลิกลงหน้า = เลิกเลือก annotation ก่อนทุก tool — แผงเครื่องมือของกรอบ selection
|
||||
// ต้องปิดเมื่อคลิกไปสร้างรายการใหม่ (comment/textbox/stamp เดิม return ก่อนถึง
|
||||
// logic เลิกเลือกท้ายฟังก์ชัน แผงจึงค้างเปิด) — คลิกบนตัว annotation เองถูก
|
||||
|
|
@ -2508,6 +2585,7 @@ export default function PDFViewer({
|
|||
// กรณีรายการอยู่ส่วนบนสุดของหน้า)
|
||||
setSelectedId(a.id);
|
||||
setPreviewData(null);
|
||||
setOpenPinId(null); // กระโดดไปเลือกรายการอื่น = ปิด popover ของ pin เดิมทิ้ง
|
||||
const el = pageWrapRefs.current[a.pageIndex];
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
|
|
@ -2525,6 +2603,8 @@ export default function PDFViewer({
|
|||
function handleSelectAnnotation(id: string) {
|
||||
setSelectedId(id);
|
||||
setPreviewData(null);
|
||||
// เลือกรายการอื่น = ปิด popover ของ pin เดิม (คลิก pin จะไม่ผ่านที่นี่ — มัน toggle เอง)
|
||||
setOpenPinId(null);
|
||||
}
|
||||
|
||||
/** F13/A6: เข้าสู่โหมดแก้ไขข้อความ comment (null = ยกเลิก) — draft เริ่มจากข้อความปัจจุบัน */
|
||||
|
|
@ -2588,6 +2668,9 @@ export default function PDFViewer({
|
|||
setDrawMenuOpen(false);
|
||||
setTextboxMenuOpen(false);
|
||||
setHighlightMenuOpen(false);
|
||||
// popup bookmark ก็ปิดตาม — สลับ tool แล้ว popup ค้างเปิดจะซ้อนกับสิ่งที่ทำต่อ
|
||||
// (anyMenuOpen นับ bookmarkComposer อยู่แล้ว พฤติกรรม M4 เท่าเดิม)
|
||||
setBookmarkComposer(null);
|
||||
setComposer(null);
|
||||
setOpenPinId(null);
|
||||
setSelectedId(null);
|
||||
|
|
@ -2815,11 +2898,12 @@ export default function PDFViewer({
|
|||
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
|
||||
onClick={(e) => {
|
||||
openToolMenu(e.currentTarget);
|
||||
if (tool === "highlight") setHighlightMenuOpen((o) => !o);
|
||||
else {
|
||||
switchTool("highlight");
|
||||
setHighlightMenuOpen(true);
|
||||
}
|
||||
// เรียก switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้าที่ค้างเปิด
|
||||
// (editor/แผง selection/popover) ไม่ให้ซ้อนกับเมนู; จำ intent เปิด/พับ
|
||||
// ไว้ก่อนเพราะ switchTool ปิดเมนูทุกตัวก่อน
|
||||
const reopen = tool !== "highlight" || !highlightMenuOpen;
|
||||
switchTool("highlight");
|
||||
if (reopen) setHighlightMenuOpen(true);
|
||||
}}
|
||||
>
|
||||
<CurrentIcon className="h-4 w-4" />
|
||||
|
|
@ -2896,11 +2980,10 @@ export default function PDFViewer({
|
|||
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
|
||||
onClick={(e) => {
|
||||
openToolMenu(e.currentTarget);
|
||||
if (tool === "draw") setDrawMenuOpen((o) => !o);
|
||||
else {
|
||||
switchTool("draw"); // switchTool ปิดเมนูก่อน แล้วค่อยเปิดทีหลัง
|
||||
setDrawMenuOpen(true);
|
||||
}
|
||||
// switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน)
|
||||
const reopen = tool !== "draw" || !drawMenuOpen;
|
||||
switchTool("draw");
|
||||
if (reopen) setDrawMenuOpen(true);
|
||||
}}
|
||||
>
|
||||
<CurrentPenIcon className="h-4 w-4" />
|
||||
|
|
@ -2997,11 +3080,10 @@ export default function PDFViewer({
|
|||
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
|
||||
onClick={(e) => {
|
||||
openToolMenu(e.currentTarget);
|
||||
if (tool === "shape") setShapeMenuOpen((o) => !o);
|
||||
else {
|
||||
switchTool("shape"); // switchTool ปิดเมนูก่อน แล้วค่อยเปิดทีหลัง
|
||||
setShapeMenuOpen(true);
|
||||
}
|
||||
// switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน)
|
||||
const reopen = tool !== "shape" || !shapeMenuOpen;
|
||||
switchTool("shape");
|
||||
if (reopen) setShapeMenuOpen(true);
|
||||
}}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
|
|
@ -3074,11 +3156,10 @@ export default function PDFViewer({
|
|||
className={`flex items-center gap-0.5 px-2 py-2 transition ${corner} ${activeCls}`}
|
||||
onClick={(e) => {
|
||||
openToolMenu(e.currentTarget);
|
||||
if (tool === "stamp") setStampMenuOpen((o) => !o);
|
||||
else {
|
||||
switchTool("stamp");
|
||||
setStampMenuOpen(true);
|
||||
}
|
||||
// switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน)
|
||||
const reopen = tool !== "stamp" || !stampMenuOpen;
|
||||
switchTool("stamp");
|
||||
if (reopen) setStampMenuOpen(true);
|
||||
}}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
|
|
@ -3152,11 +3233,10 @@ export default function PDFViewer({
|
|||
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
|
||||
onClick={(e) => {
|
||||
openToolMenu(e.currentTarget);
|
||||
if (tool === "textbox") setTextboxMenuOpen((o) => !o);
|
||||
else {
|
||||
switchTool("textbox"); // switchTool ปิดเมนูก่อน แล้วค่อยเปิดทีหลัง
|
||||
setTextboxMenuOpen(true);
|
||||
}
|
||||
// switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน)
|
||||
const reopen = tool !== "textbox" || !textboxMenuOpen;
|
||||
switchTool("textbox");
|
||||
if (reopen) setTextboxMenuOpen(true);
|
||||
}}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
|
|
@ -3434,14 +3514,13 @@ export default function PDFViewer({
|
|||
{/* F8/C1: export PDF ฝัง annotation (redaction ติดไปเสมอ — D4) */}
|
||||
<button
|
||||
type="button"
|
||||
title="Export PDF พร้อม annotation (เปิดแท็บใหม่)"
|
||||
aria-label="Export PDF พร้อม annotation (เปิดแท็บใหม่)"
|
||||
className={`max-lg:shrink-0 rounded-lg border border-slate-300 px-2 py-2 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
||||
onClick={() =>
|
||||
window.open(`/api/annotations/export?fileId=${file.id}`, "_blank")
|
||||
}
|
||||
title="Export PDF พร้อม annotation"
|
||||
aria-label="Export PDF พร้อม annotation"
|
||||
disabled={exporting}
|
||||
className={`max-lg:shrink-0 rounded-lg border border-slate-300 px-2 py-2 text-slate-500 hover:bg-slate-100 disabled:opacity-50 ${FOCUS_RING_CLS}`}
|
||||
onClick={() => void handleExportPdf()}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
<Download className={`h-4 w-4 ${exporting ? "animate-pulse" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
{/* M5: เงาขวาบอกว่าเลื่อนต่อได้ — หายเมื่อถึงท้ายแถบ (จอ ≥ lg ไม่มีการ scroll) */}
|
||||
|
|
@ -3496,14 +3575,29 @@ export default function PDFViewer({
|
|||
</p>
|
||||
)}
|
||||
{pageSizes.length === 0 ? (
|
||||
<div className="flex h-[70vh] items-center justify-center text-sm text-slate-400">
|
||||
กำลังโหลดเอกสาร…
|
||||
</div>
|
||||
loadError ? (
|
||||
<div className="flex h-[70vh] items-center justify-center px-6">
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{loadError}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-[70vh] items-center justify-center text-sm text-slate-400">
|
||||
กำลังโหลดเอกสาร…
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
pageSizes.map((size, i) => {
|
||||
const w = Math.ceil(size.width * scale);
|
||||
const h = Math.ceil(size.height * scale);
|
||||
const mounted = renderPages.has(i);
|
||||
// N3: เนื้อหาหน้า = pdf (pdfjs) หรือรูปภาพ — เลือกจาก state ที่โหลดแล้ว
|
||||
const content: PageContent | null =
|
||||
pdfjs && doc
|
||||
? { kind: "pdf", pdfjs, doc }
|
||||
: imageSrcs
|
||||
? { kind: "image", src: imageSrcs[i] ?? null }
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
|
|
@ -3530,10 +3624,9 @@ export default function PDFViewer({
|
|||
}}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
>
|
||||
{mounted && pdfjs && doc ? (
|
||||
{mounted && content ? (
|
||||
<PdfPage
|
||||
pdfjs={pdfjs}
|
||||
doc={doc}
|
||||
content={content}
|
||||
pageNumber={i + 1}
|
||||
width={w}
|
||||
height={h}
|
||||
|
|
@ -3543,7 +3636,7 @@ export default function PDFViewer({
|
|||
shapeStyle={shapeStyle}
|
||||
textboxStyle={textboxStyle}
|
||||
pageBaseWidth={basePageWidth}
|
||||
liveMark={liveMark}
|
||||
liveMark={customSelPreview ?? liveMark}
|
||||
liveMarkStyle={{
|
||||
color: highlightColor,
|
||||
style: highlightStyle,
|
||||
|
|
@ -3648,35 +3741,6 @@ export default function PDFViewer({
|
|||
);
|
||||
})()}
|
||||
|
||||
{/* จอสัมผัส: ปุ่มยืนยันสร้าง highlight หลังคลุมข้อความเสร็จ (บน
|
||||
touch ไม่มี mouseup) — ลอยใต้ selection แตะแล้ว commit จาก
|
||||
snapshot; commit ที่ pointerdown (เร็วกว่า click) +
|
||||
stopPropagation กัน bubble ไป handler ของหน้า */}
|
||||
{touchConfirm?.pageIndex === i && (
|
||||
<div
|
||||
className="absolute z-[5] select-none"
|
||||
style={{
|
||||
left: touchConfirm.x,
|
||||
top: touchConfirm.y,
|
||||
transform: "translate(-50%, 0)",
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTouchConfirmHighlight();
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex touch-manipulation items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-700 shadow-lg"
|
||||
>
|
||||
<span
|
||||
className="h-3.5 w-3.5 rounded-[2px]"
|
||||
style={{ backgroundColor: highlightColor }}
|
||||
/>
|
||||
ไฮไลต์ข้อความ
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
|
@ -3725,6 +3789,16 @@ export default function PDFViewer({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* F8: export พัง — toast แจ้งในหน้า (portal ตามกติกา fixed UI ของ rules/pdf-viewer.md) */}
|
||||
{exportError &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<div className="fixed bottom-4 right-4 z-[60] max-w-sm rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 shadow-xl">
|
||||
{exportError}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,9 +167,19 @@ export default function AnnotationSidebar({
|
|||
// แล้วเลื่อนแถวนั้นเข้ามุมมอง — แถว render หลัง setTab จึงค่อยเลื่อนใน rAF ถัดไป
|
||||
// (dep รวม tab ตาม lint — ผลข้างเคียง: เปิดแท็บนี้ตอนยังมีรายการถูกเลือก = เลื่อนไปแถวนั้นเช่นกัน)
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
// id ล่าสุดที่ประกาศไปแล้ว — บังคับสลับแท็บเฉพาะตอน selectedId "เพิ่งเปลี่ยน" เท่านั้น
|
||||
// ไม่ใช่ทุกครั้งที่ effect รัน เพราะ dep มี tab ด้วย ไม่งั้นผู้ใช้กดกลับมาแท็บ bookmarks
|
||||
// ไม่ได้เลยตอนมีรายการถูกเลือกอยู่ (ถูกดึงกลับ annotations ทันที); เคลียร์เป็น null
|
||||
// ตอนไม่มีรายการถูกเลือก เพื่อให้เลือกรายการเดิมซ้ำภายหลังยังสลับแท็บให้เหมือนเดิม
|
||||
const announcedSelRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!selectedId) return;
|
||||
if (tab !== "annotations") setTab("annotations");
|
||||
if (!selectedId) {
|
||||
announcedSelRef.current = null;
|
||||
return;
|
||||
}
|
||||
const firstAnnounce = announcedSelRef.current !== selectedId;
|
||||
announcedSelRef.current = selectedId;
|
||||
if (firstAnnounce && tab !== "annotations") setTab("annotations");
|
||||
const raf = requestAnimationFrame(() => {
|
||||
listRef.current
|
||||
?.querySelector(`[data-ann-id="${CSS.escape(selectedId)}"]`)
|
||||
|
|
|
|||
|
|
@ -288,3 +288,23 @@ Dependencies เพิ่มเติม: F3 ← F2 · F12/F13/F19 ← F1 · F15
|
|||
- **การแสดง (sidebar tab Bookmarks):** ต้นไม้ 3 ระดับ (indent ตามระดับ 1/2/3), หัวข้อมี chevron กาง/หุ่ม (state local ต่อ client) + จำนวน sub `(n)`, โน้ตแสดงใต้ชื่อ (clamp 2 บรรทัด), กดชื่อ = กระโดดหน้า (หัวข้อใหญ่ชี้หน้าของตัวเองได้), เรียงตาม createdAt
|
||||
- **Realtime:** SSE event ของ bookmarks เพิ่ม action **update** (แก้ชื่อ/โน้ต/ย้าย + ลูกที่ถูกยกขึ้น root ตอนแม่ถูกลบ) — client replace by id
|
||||
- **Out of scope ของ N2:** drag เพื่อจัดลำดับ/ย้าย, sub ลึกเกิน 3 ระดับ, bookmark ในหน้าสรุป/CSV
|
||||
|
||||
### N3 · รองรับไฟล์รูปภาพ (jpg/jpeg/png/tif) เป็นเอกสารที่ annotate ได้เหมือน PDF (2026-09-15)
|
||||
|
||||
> แผนงานฉบับเต็ม: `docs/PLAN-IMAGE.md` — requirement 5.3.4
|
||||
|
||||
- **ข้อมูล (ไม่แตะ schema):** `files.pdf_url` เก็บ path รูปใน `public/` ได้เหมือน PDF — ชนิดไฟล์ derive จาก extension ผ่าน `utils/file-kind.ts` (`fileKindFromUrl`/`imageFormatFromUrl`/`imageContentTypeFromUrl`) ใช้ร่วม viewer/export/หน้ารายการ/seed — annotation/bookmark ใช้พิกัด % + `pageIndex` 0-based เหมือนเดิม (**TIFF หลายเฟรม = หลายหน้า** เหมือน PDF หลายหน้า) — invariant #1/#2/#10 ครอบคลุมรูปอัตโนมัติ
|
||||
- **การโหลด (viewer):** jpg/png โหลดจาก **static `pdfUrl` ตรง ๆ** (ไม่ผ่าน `/api/pdf` — ไม่มี ToUnicode ให้ซ่อม) ด้วย `new Image()` + `decode()` ขนาดจาก `naturalWidth/Height` (browser คิด EXIF orientation ให้แล้ว); TIFF (`utils/tiff-client.ts`): fetch bytes → `utif2` (**dynamic import เฉพาะ tif** ตาม pattern invariant #6) decode ทุก IFD → `toRGBA8` → canvas (หมุนตาม orientation tag 274, cap ด้านยาว 4096px กัน memory) → PNG blob URL ต่อเฟรม — **revoke ทุก blob URL ตอน unload/สลับไฟล์**; ทุกหน้ารูป normalize กว้าง **595** สัดส่วนเดิม (scale contract กับ export) ให้ zoom %/fit-width clamp 0.5–3/fontSize % ทำงานเหมือนหน้า A4
|
||||
- **การแสดง:** prop `content: PageContent` (union `pdf` | `image`) ใน `PdfPage` — รูป: `<img>` ที่ z-1 `pointer-events:none` เหมือน canvas (invariant #3 — DOM order ไม่ขยับ, textLayer เป็น conditional render เฉพาะ pdf), ไม่มี TextLayer → เรียก `onTextLayerRendered(pageIndex, 0)` ให้ hint "ภาพสแกน" + custom selection จอสัมผัส bail เอง; **toolbar คงทุก tool รวม HIGHLIGHT** (คลุมข้อความบนรูปไม่ได้ = no-op มี hint อธิบาย); เฟรม TIFF ที่ decode พัง = placeholder เทาแต่เอาขนาดจาก IFD header → page count/scroll/bookmark/deep link เสถียร ไฟล์ไม่พังทั้งเล่ม
|
||||
- **Export PDF (ต่อยอด F8):** branch ก่อน `PDFDocument.load` → `imageDocFromBytes` (`utils/image-to-pdf.ts`): `PDFDocument.create` + `embedJpg`/`embedPng` + `addPage([595, 595·h/w])` + `drawImage` เต็มหน้า (rotation=0) แล้ววาด annotation + bookmark outline ด้วย pipeline เดิมทั้งหมด; TIFF decode ทุกเฟรม → หมุน/mirror ครบทุก orientation 1–8 (`rotateRgba` — ตรงกับฝั่ง client) → PNG (`pngjs`) → embedPng (เฟรมพัง = หน้าขาวให้ pageIndex map ตรง); **JPEG EXIF orientation:** exact สำหรับ 3/6/8 (parse APP1 → swap กว้าง-สูง + `drawImage` rotate) — **mirror 2/4/5/7 เป็น known limitation** (pdf-lib วาด mirror ไม่ได้ และพบน้อยมากในไฟล์สแกน — มักเจอจากกล้องหน้ามือถือ: 5/7 ประมาณด้วยการหมุนของ 8/6, 2/4 แสดงตรง ๆ); `/api/pdf` เพิ่ม defensive branch คืน bytes ดิบ + content-type ถูกสำหรับรูป (กัน stray call เข้า `fixPdfActualText`)
|
||||
- **Realtime:** ไม่มี event/schema ใหม่ — SSE ผูก `fileId` เหมือนเดิม
|
||||
- **หน้ารายการ/seed:** หน้า `/` แสดงไอคอน + badge ชนิด (PDF/JPG/PNG/TIFF); `scripts/seed-samples.mjs` sync ทุก extension ใน `SAMPLE_FILE_EXTENSIONS`
|
||||
- **Out of scope ของ N3:** รูปแบบอื่น (gif/webp/bmp/heic), หมุน/ครอปรูปใน viewer, OCR, TIFF CCITT G3/G4 + JPEG-in-TIFF (เฟรมพัง = placeholder เทา), แปลงหรือเขียนไฟล์ต้นฉบับเด็ดขาด
|
||||
|
||||
### N4 · อัปโหลดไฟล์แบบง่าย (2026-09-15)
|
||||
|
||||
- **UI:** ฟอร์มเดียวบนหน้า `/` (`components/UploadFileForm.tsx`) — `<input type="file" accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff">` + ปุ่มอัปโหลด (`useTransition`, disabled ระหว่างส่ง) รับทั้ง PDF และรูป
|
||||
- **Server Action `uploadFile`** (`app/actions/files.ts`): รับ `FormData` — validate extension (whitelist เดียวกับ `SAMPLE_FILE_EXTENSIONS`) + ขนาด ≤ 100MB + ไฟล์ไม่ว่าง → เขียน `public/uploads/<uuid>.<ext>` (**ชื่อไฟล์บนดิสก์จาก randomUUID เท่านั้น** — กันชนกันและกัน path traversal; `title` = ชื่อไฟล์เดิมที่ sanitize) → insert `files (title, pdf_url)` → `revalidatePath("/")` — ไม่มี SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE)
|
||||
- **next.config:** `experimental.serverActions.bodySizeLimit = "100mb"` (default 1MB ไม่พอสำหรับ PDF/สแกนศาล)
|
||||
- **ความสัมพันธ์กับ seed:** seed-samples จัดการเฉพาะ `/samples/%` — ไฟล์อัปโหลดใน `/uploads/` ไม่ถูก seed ลบ
|
||||
- **Out of scope ของ N4:** หน้าจัดการ/ลบ/แก้ชื่อไฟล์ใน UI, drag-drop, progress bar, multi-file พร้อมกัน, auth ผูกเจ้าของไฟล์, สิทธิ์ต่อไฟล์
|
||||
|
|
|
|||
132
docs/PLAN-IMAGE.md
Normal file
132
docs/PLAN-IMAGE.md
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# แผน 5.3.4 — รองรับไฟล์รูปภาพ (jpg/jpeg/png/tif) + อัปโหลดไฟล์ (N3 + N4)
|
||||
|
||||
> สถานะ: **implement เสร็จสมบูรณ์** (2026-09-15) — ทำครบทั้ง 6 task ตามลำดับ, `tsc` ผ่าน, vitest 120/120, plan-reviewer + code-reviewer ผ่าน (findings ทั้งหมดแก้แล้ว: blob revoke race, TIFF orientation 7 บนจอ, **EXIF 6/8 export anchor สลับที่ทำหน้าเปล่า** — re-verify ด้วยการ render ไฟล์ export กลับด้วย pdfjs แล้วภาพอยู่ในหน้าจริง, magic bytes ตอนอัปโหลด, unlink ไฟล์กำพร้าเมื่อ DB fail, EXIF parser ทนไฟล์เสี้ยว, cap ขนาด TIFF ฝั่ง server 4096px)
|
||||
> อ้างอิง: requirement 5.3.4 (gap analysis `docs/requirement/gap-analysis.md` กลุ่ม C) + คำขอเพิ่มอัปโหลดแบบง่าย
|
||||
> ตาม governance ของโปรเจกต์: implement ต้องเขียนสเปกลง `docs/PLAN-FEAT.md` §7 (N3/N4) + ขยาย whitelist ใน `CLAUDE.md` ก่อน — เป็น task ที่ 1 ของแผนนี้
|
||||
|
||||
## Context
|
||||
|
||||
Requirement 5.3.4 กำหนดรูปแบบเอกสาร: PDF + รูปภาพ (jpg, jpeg, png, tif) เป็นอย่างน้อย — ปัจจุบันรองรับ PDF เท่านั้น และไฟล์เข้าระบบได้ทางเดียวคือ seed script เป้าหมาย: อัปโหลด/เปิดไฟล์รูปภาพ → annotate ครบทุกชนิด → realtime → export PDF/CSV ได้ เหมือน PDF ทุกประการ
|
||||
|
||||
## การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว)
|
||||
|
||||
1. **สถาปัตยกรรม "ไม่แปลงก่อนใช้":** รูปคือ "หน้าเอกสาร" — viewer แสดง `<img>` แทน canvas ของ pdfjs, layers ซ้อนบนรูปตรง ๆ, annotation เก็บเป็นพิกัด % ใน DB เหมือนเดิม — PDF เกิดจริงเฉพาะไฟล์ export (สร้างใหม่ใน memory ตาม invariant #1)
|
||||
2. **TIFF: รองรับเลยด้วย UTIF.js** (`utif2` — decode รันไทม์ฝั่ง client แสดงผล + ฝั่ง server transcode PNG ตอน export), TIFF หลายเฟรม = หลายหน้า
|
||||
3. **toolbar คงทุก tool** กับทุกเอกสาร — ไม่ซ่อน HIGHLIGHT บนรูป (no-op แบบเดียวกับ PDF สแกน มี hint "ภาพสแกน" โผล่เอง)
|
||||
4. **อัปโหลดแบบง่าย:** ฟอร์มเดียวบนหน้า `/` รับทั้ง PDF และรูป — ไม่มีหน้าจัดการ/ลบ/drag-drop/progress
|
||||
5. **EXIF-rotated JPEG:** จัดการตอน export ใน scope นี้ (parse APP1 orientation 5–8 → swap ขนาดหน้า + drawImage rotate — ไม่งั้น export ได้สัดส่วนบิดต่างจากจอ)
|
||||
6. **รับ `.tiff` (สะกดยาว) ด้วย**, หน้ารูป normalize กว้าง **595** (scale contract — zoom %/fit-width/fontSize% ทำงานเหมือนหน้า A4 ทุกประการ)
|
||||
|
||||
## หลักตั้งต้น (จากการสำรวจโค้ด + spot-check)
|
||||
|
||||
- ทุก layer เหนือ canvas เป็น generic ต่อ "หน้า W×H + พิกัด %" — สิ่งที่ PDF-specific มีแค่ 3 จุด: (1) load effect `PDFViewer.tsx:1320-1366` (2) canvas+textLayer ใน `PdfPage` 541-1075 (3) `PDFDocument.load` ที่ `app/api/annotations/export/route.ts:229` — แทรก branch เพียง 3 จุด ที่เหลือ reuse
|
||||
- spot-check แล้ว: `PDFViewer.tsx:3462` = `{mounted && pdfjs && doc ? (`, `seed-samples.mjs:18` = filter `.endsWith(".pdf")`, มี vitest tests ที่ `tests/utils/*.test.ts` (เพิ่ม test ตาม pattern นี้ได้)
|
||||
- `lib/server-pdf.ts` path guard เป็น path-containment ล้วน (ไม่เช็ค extension) — ใช้กับรูปได้เลย
|
||||
- pdf-lib ^1.17.1 มี `embedJpg`/`embedPng` แล้ว, ไม่มี TIFF embed → ต้อง transcode
|
||||
- deps ใหม่: `utif2` + `@types/utif`, `pngjs` + `@types/pngjs` (pure JS ทั้งคู่)
|
||||
|
||||
## Design
|
||||
|
||||
### D1. ชนิดไฟล์ — derive จาก extension (ไม่เพิ่ม DB column)
|
||||
|
||||
ไฟล์ใหม่ `utils/file-kind.ts` (pure, ใช้ได้ทั้ง server/client):
|
||||
|
||||
```ts
|
||||
export type FileKind = "pdf" | "image";
|
||||
export type ImageFormat = "jpeg" | "png" | "tiff";
|
||||
export function fileKindFromUrl(url: string): FileKind; // .jpg/.jpeg/.png/.tif/.tiff → "image" (case-insensitive)
|
||||
export function imageFormatFromUrl(url: string): ImageFormat | null;
|
||||
export const SAMPLE_FILE_EXTENSIONS: readonly string[]; // [".pdf",".jpg",".jpeg",".png",".tif",".tiff"]
|
||||
export function imageContentTypeFromUrl(url: string): string; // image/jpeg | image/png | image/tiff
|
||||
```
|
||||
|
||||
เหตุผล: extension เก็บอยู่ใน `pdf_url` อยู่แล้ว (single source of truth, seed idempotent อิง URL) — ไม่ต้อง migrate schema / drizzle push / backfill → `docs/PLAN.md` §3 ไม่ต้องแตะ ผู้ใช้ 4 จุด: PDFViewer (derive ใน client จาก `file.pdfUrl` — `app/annotate/[id]/page.tsx` ไม่ต้องแก้), `app/page.tsx` (badge), export route, seed script (.mjs duplicate list + comment ให้ sync)
|
||||
|
||||
### D2. Viewer — `PageContent` union + branch 3 จุดใน `components/pdf/PDFViewer.tsx`
|
||||
|
||||
```ts
|
||||
type PageContent =
|
||||
| { kind: "pdf"; pdfjs: typeof import("pdfjs-dist"); doc: import("pdfjs-dist").PDFDocumentProxy }
|
||||
| { kind: "image"; src: string | null }; // null = เฟรม decode ไม่ได้ (placeholder เทา)
|
||||
type ImagePage = { src: string | null; width: number; height: number }; // normalize กว้าง 595
|
||||
```
|
||||
|
||||
1. **load effect (1320-1366):** state ใหม่ `imagePages: ImagePage[] | null` คู่ขนาน `doc`/`pdfjs` — branch บนสุดของ effect เดิม:
|
||||
- jpeg/png: `new Image()` + `img.src = file.pdfUrl` + `await img.decode()` → pageSizes จาก `naturalWidth/Height` (browser คิด EXIF orientation แล้ว) → normalize 595
|
||||
- tiff: `fetch(file.pdfUrl)` → **`await import("utif2")`** (dynamic import เฉพาะ tif ตาม pattern invariant #6) → `UTIF.decode` วนทุก IFD → `decodeImage`+`toRGBA8` → canvas → `toBlob("image/png")` → `URL.createObjectURL` ต่อเฟรม + **revoke ทุก blob URL ใน cleanup** (กัน leak ตอนสลับไฟล์); TIFF orientation tag 274 หมุนตอนวาง canvas; cap ด้านยาว decoded frame 4096px (resample ใน canvas — annotation เป็น % ไม่กระทบ)
|
||||
- pdf: โค้ดเดิมไม่แตะ
|
||||
2. **mount condition (3462):** สร้าง `PageContent` จาก state ใด state หนึ่งแทน `pdfjs && doc`
|
||||
3. **PdfPage (541-1075):** prop `pdfjs`+`doc` รวมเป็น `content` — z-1: `kind==="pdf" ? <canvas> : <img className="absolute inset-0 z-[1] pointer-events-none" draggable={false}>` (**pointer-events:none เหมือน canvas ตาม invariant #3**); pdfjs render effect (644-701) guard `if (content.kind !== "pdf") return`; textLayer div render เฉพาะ pdf (conditional render ไม่ย้ายโหนด — DOM order คงเดิมตาม invariant #3); effect เล็ก `onTextLayerRendered(pageNumber-1, 0)` ให้ hint "ภาพสแกน" + `useCustomSelection` bail ทำงานเอง
|
||||
|
||||
ไม่ต้องแตะเลย: virtualization, zoom/fit-width, `basePageWidth`, deep link `?page`/`?ann`, bookmark scroll, InkLayer/TextboxStampLayer/AnnotationSelectionLayer/pins/tooltip/hit-test, sidebar, Server Actions, SSE, summary, CSV
|
||||
|
||||
### D3. `/api/pdf` — รูปโหลด static `pdfUrl` ตรง ๆ + defensive branch
|
||||
|
||||
รูปไม่ต้องผ่าน `/api/pdf` (ไม่มี ToUnicode ให้ซ่อม, ได้ static cache ฟรี + Content-Type ถูก) — แต่เพิ่ม guard ใน `app/api/pdf/route.ts` หลัง `readPublicPdf`: ถ้า `fileKindFromUrl === "image"` คืน bytes ดิบ + content-type ถูก (กัน stray call หลุดเข้า `fixPdfActualText` ที่จะ throw)
|
||||
|
||||
### D4. Export — branch ก่อน `PDFDocument.load` แล้ว reuse pipeline 100%
|
||||
|
||||
ที่ `app/api/annotations/export/route.ts:229`:
|
||||
|
||||
```ts
|
||||
const doc = fileKindFromUrl(file.pdfUrl) === "image"
|
||||
? await imageDocFromBytes(srcBytes, file.pdfUrl)
|
||||
: await PDFDocument.load(srcBytes);
|
||||
```
|
||||
|
||||
ไฟล์ใหม่ `utils/image-to-pdf.ts` (server-only): `PDFDocument.create()` → jpeg `embedJpg` / อื่น ๆ `embedPng` → `addPage([595, 595·h/w])` + `drawImage` เต็มหน้า → คืน doc แล้ววาด annotation + bookmark outline ด้วย pipeline เดิมทั้งหมด (rotation=0 → `displaySize`/`displayToPdfPoint`/`pxPoint` ใช้ได้ทันที, `doc.getPage(pageIndex)` map ตรง 1 เฟรม = 1 หน้า)
|
||||
|
||||
- **JPEG EXIF orientation 5–8:** parse APP1 เอง ~30 บรรทัด → swap กว้าง-สูง + `drawImage` ด้วย `rotate` radians
|
||||
- **TIFF:** `tiffFramesToPng(bytes)` = utif2 decode ทุก IFD → toRGBA8 → pngjs `PNG.sync.write` → embedPng (เฟรม decode พัง = หน้าขาว + console.error เพื่อให้ pageIndex map ตรง)
|
||||
|
||||
### D5. อัปโหลดแบบง่าย (N4)
|
||||
|
||||
- **UI:** หน้า `app/page.tsx` — ฟอร์มเล็ก: `<input type="file" accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff">` + ปุ่ม "อัปโหลด" (client component แยกไฟล์ `components/UploadFileForm.tsx`, `useTransition` + ปุ่ม disabled ระหว่างอัป)
|
||||
- **Server Action `uploadFile`** ใน `app/actions/files.ts` (ไฟล์ใหม่): รับ `FormData` — validate extension (whitelist เดียวกับ `SAMPLE_FILE_EXTENSIONS`) + ขนาด ≤ 100MB + ไม่ว่าง → เขียน `public/uploads/<uuid>.<ext>` (ชื่อ random กันชน/กัน traversal — ใช้ชื่อจาก randomUUID ไม่ใช่ชื่อผู้ใช้) → insert `files (title = ชื่อไฟล์เดิมที่ sanitize, pdf_url = /uploads/<uuid>.<ext>)` → `revalidatePath("/")`
|
||||
- **`next.config.mjs`:** `experimental: { serverActions: { bodySizeLimit: "100mb" } }` (Next 14 default 1MB)
|
||||
- seed script จัดการเฉพาะ `/samples/%` → uploads ไม่โดน seed ลบ
|
||||
- Out of scope: ลบ/แก้ชื่อไฟล์ใน UI, drag-drop, progress bar, multi-file, auth ผูกเจ้าของไฟล์
|
||||
|
||||
### D6. Seed + หน้ารายการ
|
||||
|
||||
- `scripts/seed-samples.mjs:18`: filter เป็น `SAMPLE_FILE_EXTENSIONS.some(...)` + แก้ comment บรรทัด 2 ให้ตรง
|
||||
- `app/page.tsx`: ไอคอน `FileImage` (lucide, fallback `Image`) + badge `PDF/JPG/PNG/TIFF` ต่อ row
|
||||
|
||||
## Docs/rules sync (plan-first — ทำเป็น task แรก ก่อนแตะโค้ด)
|
||||
|
||||
| ไฟล์ | สิ่งที่แก้ |
|
||||
|---|---|
|
||||
| `docs/PLAN-FEAT.md` §7 | หัวข้อใหม่ **N3 รองรับไฟล์รูปภาพ** + **N4 อัปโหลดไฟล์แบบง่าย** (format ตาม N1/N2: ข้อมูล/การโหลด/การแสดง/Export/Realtime/Out of scope ต่อฟีเจอร์) |
|
||||
| `CLAUDE.md` | (1) whitelist ขอบเขตเพิ่ม N3/N4 (2) ลบข้อ "ไม่มีหน้า upload" ออกจาก Out of Scope (3) invariant #1 "ไฟล์ PDF ต้นฉบับ" → "ไฟล์ต้นฉบับ (PDF/รูปภาพ)" (4) โครงสร้างไฟล์เพิ่ม `utils/file-kind.ts`, `utils/image-to-pdf.ts`, `app/actions/files.ts`, `components/UploadFileForm.tsx` |
|
||||
| `docs/PLAN.md` | §3 ไม่แตะ (ไม่มี schema change); §0 A5 "ไม่มีหน้า upload" → อัปเดตเป็นมี upload แบบง่าย (N4) |
|
||||
| `.claude/rules/pdf-viewer.md` | แก้ bullet "โหลด PDF ผ่าน /api/pdf เสมอ" → เพิ่มข้อยกเว้นรูปโหลด static ตรง, `<img>` z-1 pointer-events:none, `onTextLayerRendered(pi, 0)`, normalize 595, utif2 dynamic import + revoke blob URL |
|
||||
| `.claude/rules/data-layer.md` | `files.pdf_url` เก็บ path รูปได้ — derive ชนิดผ่าน `utils/file-kind.ts` เท่านั้น (ไม่มี column kind), TIFF หลายเฟรม = pageIndex ต่อเฟรม, Server Action `uploadFile` เขียนไฟล์ใหม่ที่ `public/uploads/` เท่านั้น (ต้องไม่แตะไฟล์เดิม) |
|
||||
| `README.md` | ส่วน seed: extension ที่รับ + วิธีอัปโหลดผ่านหน้า `/` |
|
||||
|
||||
## ลำดับ task + verify (ทุก task: `npx tsc --noEmit` ผ่าน + agent plan-reviewer)
|
||||
|
||||
| # | งาน | ไฟล์หลัก | Verify เพิ่มเติม |
|
||||
|---|---|---|---|
|
||||
| 1 | Docs sync N3/N4 + `utils/file-kind.ts` + unit test | `docs/PLAN-FEAT.md`, `CLAUDE.md`, `docs/PLAN.md` §0, `utils/file-kind.ts`, `tests/utils/file-kind.test.ts` | vitest ผ่านทุก case (uppercase/ไม่มี extension/.tiff ยาว); N3/N4 spec ครบตาม format §7 |
|
||||
| 2 | Upload (Server Action + ฟอร์ม + next.config) | `app/actions/files.ts`, `components/UploadFileForm.tsx`, `app/page.tsx`, `next.config.mjs`, `.claude/rules/data-layer.md` | Playwright: อัปโหลด PDF + jpg → row ใหม่โผล่ + เปิด PDF ได้เหมือนเดิม; ไฟล์ >100MB ถูกปฏิเสธพร้อมข้อความ; ชื่อไฟล์พิเศษ (ภาษาไทย/`../`) ไม่หลุดออกนอก `public/uploads/` |
|
||||
| 3 | Viewer jpg/png (`PageContent`) + `/api/pdf` defensive branch | `components/pdf/PDFViewer.tsx`, `app/api/pdf/route.ts`, `.claude/rules/pdf-viewer.md` | อัปโหลด jpg/png แล้วเปิด: fit-width เต็มจอ, annotate ครบ DRAW/SHAPE/TEXTBOX/STAMP/REDACTION/COMMENT + bookmark, HIGHLIGHT ยังอยู่ + hint "ภาพสแกน", 2 แท็บ realtime, zoom 0.5-3 annotation ไม่เบี้ยว; **regression: PDF มี text highlight ได้เหมือนเดิม + PDF หลายสิบหน้า virtualization ปกติ**; จอ coarse (Playwright touch) วาดบนรูปได้ไม่กลายเป็น scroll |
|
||||
| 4 | TIFF ฝั่ง client (utif2) | `components/pdf/PDFViewer.tsx`, `utils/tiff-client.ts`, `package.json` (`utif2`, `@types/utif`) | อัปโหลด tif หลายเฟรม = หลายหน้า, เลื่อนลื่น, `?page=N` + bookmark กระโดดถูกเฟรม, สลับไฟล์หลายรอบ memory ไม่โต (blob revoke), เฟรมพัง = placeholder เทาแต่ไฟล์ไม่พังทั้งเล่ม |
|
||||
| 5 | Export PDF จากรูป + EXIF | `app/api/annotations/export/route.ts`, `utils/image-to-pdf.ts` | annotate ครบทุกชนิดบน jpg → export → เปิดด้วย pdf.js viewer: หน้า 595×สัดส่วนรูป, annotation ตรงตำแหน่ง, REDACTION ทึบ, COMMENT sticky เปิด popup, bookmark outline กระโดดถูกหน้า; JPEG เอียง 90° (fixture สร้างเอง) export ตรงกับจอ; **hash ต้นฉบับใน public/ ไม่เปลี่ยน** (`certutil -hashfile` ก่อน/หลัง); export PDF/CSV เดิมได้เหมือนเดิม (regression) |
|
||||
| 6 | TIFF ฝั่ง server ตอน export + ปิดงาน | `utils/image-to-pdf.ts` (`tiffFramesToPng`), `package.json` (`pngjs`, `@types/pngjs`) | export tif 3 เฟรม → PDF 3 หน้า + annotation บนเฟรม 2/3 ตกหน้าถูก + outline; hash ต้นฉบับไม่เปลี่ยน; plan-reviewer ผ่านทุก invariant ที่ N3/N4 แตะ |
|
||||
|
||||
## Edge cases (ออกแบบไว้ล่วงหน้า)
|
||||
|
||||
1. **EXIF JPEG:** viewer ปลอดภัยอัตโนมัติ (`<img>` ใช้ orientation เป็น default, `naturalWidth/Height` = หลังหมุน) — จุดรั่วคือ export (pdf-lib ฝัง JPEG ดิบ) → จัดการใน D4; PNG ไม่มีปัญหา
|
||||
2. **TIFF orientation tag 274:** UTIF ไม่หมุนให้ — หมุนเอง client (ตอนวาง canvas) + server (transpose RGBA ก่อน PNG encode) ให้สองฝั่งตรงกัน
|
||||
3. **Retina:** `<img>` browser render ที่ dpr เต็ม — เทียบเท่า canvas×dpr ของ pdfjs ไม่ต้องทำอะไร
|
||||
4. **รูปใหญ่ (สแกน 600dpi ~5000px):** normalize 595 ทำให้ zoom clamp 0.5-3 ใช้ได้ + cap 4096px ตอน decode กัน memory
|
||||
5. **TIFF compression ที่ utif2 ไม่รอง (CCITT G3/G4):** เฟรมนั้น placeholder เทาแต่เอาขนาดจาก IFD header → page count/scroll/bookmark เสถียร (จดไว้ใน out of scope ของ N3)
|
||||
6. **stray `/api/pdf?fileId=<รูป>`:** defensive branch คืน bytes ดิบ — ไม่หลุดเข้า `fixPdfActualText`
|
||||
7. **อัปโหลดชื่อซ้ำ/ชื่อแปลก:** uuid เป็นชื่อไฟล์บนดิสก์ + sanitize title — ไม่ชนกัน ไม่ traversal
|
||||
|
||||
## Verification (ภาพรวม end-to-end)
|
||||
|
||||
1. `npm run dev` + `npm run seed-samples` — รายการเดิมไม่หาย (dev DB ผู้ใช้ใช้ร่วม — ห้าม reset DB)
|
||||
2. Playwright: อัปโหลด `sample.jpg` → เปิด → ทดสอบเต็มวงจร (วาดเส้น/รูปทรง/TEXTBOX/STAMP/COMMENT/bookmark) → แท็บที่สองเห็น realtime → export PDF → เปิดด้วย pdf.js viewer ตรวจตำแหน่ง + `certutil -hashfile` ต้นฉบับก่อน/หลังเทียบกัน
|
||||
3. เช่นเดียวกับ `.tif` หลายเฟรม + `?page=2` deep link
|
||||
4. Regression PDF เดิม: highlight ข้อความ + export ได้เหมือนก่อนแก้
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
| A2 | **Framework** | Next.js 14 (App Router) + TypeScript strict |
|
||||
| A3 | **PDF Renderer** | `pdfjs-dist` เวอร์ชัน **v4** (`^4.10.38`) — ใช้ built-in `TextLayer` class; worker ตั้ง path ตรง ๆ: คัดลอก `pdf.worker.min.mjs` (จาก `node_modules/pdfjs-dist/build/`) มาไว้ที่ `public/` ด้วย **postinstall script** แล้วตั้ง `pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"` — **ไม่ใช้** `new URL(..., import.meta.url)` เพราะ v4 เป็นไฟล์ `.mjs` และมักชนกับ Turbopack/webpack ใน Next 14 |
|
||||
| A4 | **Identity** | สเปกไม่ระบุระบบ auth → ใช้ **anonymous user** สร้างครั้งเดียวเก็บใน `localStorage` (`userId` = UUID, `userName` = "ผู้ใช้ XXXX") เพื่อเติม field `userId`/`userName` ในตาราง |
|
||||
| A5 | **ไม่มีไฟล์ปุ่ม Upload** | หน้า `/` อ่านรายการจาก DB เท่านั้น (ตามสเปก) — sample PDF เก็บใน `public/samples/` แล้ว seed ด้วย SQL ใน README (รายละเอียดด้านล่าง) |
|
||||
| A5 | **Upload แบบง่าย (ปรับปรุง 2026-09-15 — N4)** | หน้า `/` อ่านรายการจาก DB + ฟอร์มอัปโหลดไฟล์เดียว (PDF/jpg/jpeg/png/tif/tiff, ≤ 100MB) ผ่าน Server Action เขียนไฟล์**ใหม่**ที่ `public/uploads/<uuid>.<ext>` เท่านั้น (ไม่แตะไฟล์เดิม) — sample เก็บใน `public/samples/` แล้ว sync ด้วย `npm run seed-samples` (รายละเอียดด้านล่าง) |
|
||||
| A6 | **ทุก annotation ลบได้** | แสดงปุ่มลบเฉพาะรายการของตัวเอง (`userId` ตรงกัน) — ไม่มี role/permission ในเฟสนี้ |
|
||||
| A7 | **Database & Realtime (ปรับปรุง 2026-09-08)** | PostgreSQL แบบ **plain container เดียว** (`judes-note-db`, Docker, image `postgres:17-alpine`, พอร์ต 54322) — **เลิกใช้ Supabase stack ทั้งหมด** (ไม่มี CLI/Kong/realtime container) เพราะโปรเจกต์ใช้จริงแค่ DB + sync; realtime sync ใช้ **SSE (Server-Sent Events) + pub/sub in-process** แทน Supabase Realtime |
|
||||
| A8 | **Event ของ SSE เป็น DTO ตรง ๆ (2026-09-08)** | Server Actions publish event ผ่าน `lib/annotate-events.ts` หลังเขียน DB สำเร็จ — payload เป็น DTO (camelCase + ISO string) โดยตรง **ไม่มีการแปลง snake_case** เหมือนสมัยใช้ Supabase Realtime |
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
# รายงานเทียบ Requirement กับโปรเจกต์ (Gap Analysis)
|
||||
|
||||
- **วันที่ตรวจสอบ:** 2026-09-14
|
||||
- **อัปเดตล่าสุด:** 2026-09-15 — แก้ไขข้อความข้อ 5.3.18–5.3.25 ตามที่เจ้าของเอกสารยืนยันใหม่
|
||||
- **เอกสาร requirement ต้นทาง:** `docs/requirement/1.png`, `docs/requirement/2.png` (ข้อกำหนดระบบบันทึกความเห็นของกรรมการ — Judges' Notes System)
|
||||
- **โค้ดที่ใช้เทียบ:** branch `forgejo-dev` ณ commit `b3b884c` (implement ครบตาม PLAN.md + PLAN-FEAT.md แล้ว ยกเว้น F11 ที่ถูกถอดออก)
|
||||
- **ช่วงเลขข้อ (ยืนยันโดยเจ้าของเอกสาร):** ภาพที่ 1 = ข้อ **5.3.1–5.3.6** / ภาพที่ 2 = ข้อ **5.3.7–5.3.25**
|
||||
|
||||
> ⚠️ **หมายเหตุการอ่าน requirement:** สำนวนภาษาไทยบางคำอาจคลาดเคลื่อนเล็กน้อยจากต้นฉบับ แต่สาระของทุกข้อชัดเจน — การอ่านครั้งแรกสับสนเพราะเลขข้อเป็นเลขอารบิก (5.3.X) ที่ OCR อ่านเป็นอักษรไทย ช่วงเลขข้อได้รับยืนยันจากเจ้าของเอกสารแล้ว และการตรวจซ้ำด้วยการสแกนตำแหน่งบรรทัด + อ่านเลขข้อรายบรรทัดยืนยันว่า **จำนวนข้อ map ตรง 1:1 ครบทั้ง 19 ข้อ** (10 ข้อครึ่งบน + 9 ข้อครึ่งล่าง รวม 5.3.7–5.3.25) — การจับคู่เลข ↔ เนื้อหาในตารางจึงเป็นที่ยืนยันแล้วทั้งหมด
|
||||
> ⚠️ **หมายเหตุการอ่าน requirement:** สำนวนภาษาไทยบางคำอาจคลาดเคลื่อนเล็กน้อยจากต้นฉบับ แต่สาระของทุกข้อชัดเจน — การอ่านครั้งแรกสับสนเพราะเลขข้อเป็นเลขอารบิก (5.3.X) ที่ OCR อ่านเป็นอักษรไทย ช่วงเลขข้อได้รับยืนยันจากเจ้าของเอกสารแล้ว และการตรวจซ้ำด้วยการสแกนตำแหน่งบรรทัด + อ่านเลขข้อรายบรรทัดยืนยันว่า **จำนวนข้อ map ตรง 1:1 ครบทั้ง 19 ข้อ** (10 ข้อครึ่งบน + 9 ข้อครึ่งล่าง รวม 5.3.7–5.3.25) — การจับคู่เลข ↔ เนื้อหาในตารางจึงเป็นที่ยืนยันแล้วทั้งหมด จนถึง**ฉบับแก้ไข 2026-09-15** ที่เจ้าของเอกสารให้ข้อความข้อ 5.3.18–5.3.25 ฉบับถูกต้อง: HTML5 แยกจากข้อ Tablet/iPad ออกเป็นข้อ 5.3.19 ของตัวเอง (ข้อถัดมาเลื่อนลง 1 ลำดับ), เพิ่มข้อสิทธิการใช้งานตามระบบปกครอง (5.3.23) และเชื่อมโยงกับระบบปกครอง (5.3.25) — ข้อเดิมเรื่อง API ระบบอื่น, มาตรฐานความมั่นคงปลอดภัย และ Availability 99.5% ไม่ปรากฏในฉบับแก้ไข
|
||||
|
||||
## สรุปผล
|
||||
|
||||
|
|
@ -30,7 +31,7 @@
|
|||
| **5.3.1** เรียกดูเอกสารที่เกี่ยวข้องและตรวจสอบจากระบบงานคดีปกครองได้โดยตรง | ⚠️ | ตัว viewer แสดงไฟล์ PDF ที่ระบุใน DB ได้ แต่**ไม่มีการเชื่อมต่อระบบงานคดีปกครอง** (ไม่มี integration/API ดึงเอกสารโดยตรง) และไม่มีหน้า upload — หน้า `/` อ่านรายการจาก DB เท่านั้น (sample data seed ด้วย SQL) — ผูกกับ gap 5.3.14 (API) และ 5.3.16 (integrate) |
|
||||
| **5.3.2** ระบบต้องไม่สามารถแก้ไขไฟล์ต้นฉบับได้ (Non-Destructive) | ✅ | เป็น **invariant หลักของโปรเจกต์** (CLAUDE.md ข้อ 1): annotation เก็บใน DB เป็นพิกัด % เท่านั้น, ไฟล์ PDF ใน `public/` ถูกอ่านอย่างเดียวผ่าน path guard (`lib/server-pdf.ts`), export PDF (F8) สร้างไฟล์ใหม่จากสำเนาในหน่วยความจำ (pdf-lib load แล้ว save คืน bytes ใหม่) — ไม่เขียนทับต้นฉบับเด็ดขาด |
|
||||
| **5.3.3** จัดเก็บข้อมูล Bookmark แยกจากเอกสารโดยเก็บไว้ในฐานข้อมูล และสามารถนำข้อมูลดังกล่าวมาแสดงได้ | ✅ | เป็น**สถาปัตยกรรมแกนกลางของโปรเจกต์** (invariant #1–2): bookmark เก็บใน PostgreSQL ตาราง `bookmarks` เป็นพิกัด/ข้อมูลล้วน (pageIndex, ชื่อ, โครงสร้าง parent_id) แยกจากไฟล์ PDF ใน `public/` — นำกลับมาแสดงได้ทั้งแท็บ Bookmarks ใน sidebar (scroll ไปหน้านั้น), หน้าสรุป F15 และ export |
|
||||
| **5.3.4** รูปแบบเอกสารที่รองรับได้ — ไฟล์เอกสาร PDF และไฟล์รูปภาพ (jpg, jpeg, png, tif) เป็นอย่างน้อย | ❌ | **รองรับ PDF เท่านั้น** — viewer ใช้ pdfjs-dist ล้วน ไม่มีการรับไฟล์รูปภาพ (ไม่เคยอยู่ในแผน) |
|
||||
| **5.3.4** รูปแบบเอกสารที่รองรับได้ — ไฟล์เอกสาร PDF และไฟล์รูปภาพ (jpg, jpeg, png, tif) เป็นอย่างน้อย | ✅ | รองรับครบทุกนามสกุลแล้ว (N3 — แผน `docs/PLAN-IMAGE.md`, 2026-09-15): รูปคือ "หน้าเอกสาร" แสดง `<img>` แทน canvas + annotate ครบทุกชนิด + bookmark + realtime + export PDF ฝังรูป/CSV; TIFF รองรับทั้งหลายเฟรม (decode ด้วย utif2) และ EXIF orientation ตอน export; อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการได้ (N4) |
|
||||
| **5.3.5** บันทึกความเห็น (Bookmark) ทั้งแบบกรอกข้อความ (Text Box) และเขียนด้วยมือ (Freehand Drawing) | ✅ | TEXTBOX (F17) + DRAW freehand ดินสอ/ไฮไลต์เตอร์ + รองรับสไตลัส pressure (F7) |
|
||||
| **5.3.6** เพิ่ม ลบ แก้ไขความเห็นในกล่องข้อความได้ | ✅ | TEXTBOX (F17): สร้างกล่องข้อความ + แก้ไขข้อความในกล่องโดยตรง (editor รองรับตัวหนาบางส่วน — rich text) + ลบ/ย้าย/ปรับขนาด ของตัวเอง (F1, F13) |
|
||||
|
||||
|
|
@ -51,14 +52,14 @@
|
|||
| **5.3.15** ส่งออก (Export) รายการ bookmark เป็น PDF | ⚠️ | ใกล้เคียง: export PDF ฝัง annotation + bookmark เป็น **PDF outline** (F8 — `app/api/annotations/export/route.ts`) + export CSV สรุปรายการ (F16) — แต่ไม่ใช่ "รายงานรายการ bookmark" แบบเอกสาร PDF ตรง ๆ |
|
||||
| **5.3.16** หน้าจอเชื่อมต่อกับระบบอื่น (Integrate) เป็น modal หรือ iframe | ❌ | เป็น standalone Next.js app — ไม่มีโหมด embed |
|
||||
| **5.3.17** รองรับ Web Browser: Google Chrome, Microsoft Edge, Safari | ✅ | Web app มาตรฐาน — ทดสอบ desktop + iOS/Android แล้ว |
|
||||
| **5.3.18** ใช้งานบน Tablet, iPad — Web Application ภาษา HTML5 | ✅ | Touch support เต็มรูปแบบ: ไฮไลต์ผ่านปุ่มลอย, ลาก handles, วาดด้วยนิ้ว (commit `b3b884c`) |
|
||||
| **5.3.19** Responsive แสดงผลได้ทุกความละเอียดหน้าจอ | ✅ | sidebar จอกลาง/จอเล็ก, PDF fit จอเล็ก, ปุ่มขนาด touch-44px (commit `1c2f4c8`) |
|
||||
| **5.3.20** ใช้งานหลายผู้ใช้พร้อมกัน (Multi-user) จากหลายเครื่อง | ✅ | Realtime sync ผ่าน SSE (`hooks/useAnnotationRealtime.ts`) — แก้ไข/ลบ ของใครของมันบนไฟล์เดียวกันได้พร้อมกัน |
|
||||
| **5.3.21** การรับรองตัวผู้ใช้งาน (Authentication) | ❌ | ไม่มีระบบ auth — anonymous identity จาก localStorage โดยเจตนา (out of scope ตามแผน) |
|
||||
| **5.3.22** ใช้งานร่วมกับระบบงานอื่นผ่าน API | ❌ | ไม่มี |
|
||||
| **5.3.23** ติดตั้ง On-Premise บน Server ภายใน หรือ Cloud | ⚠️ | สถาปัตยกรรม Next.js + PostgreSQL deploy ได้ทั้งสองรูปแบบทางเทคนิค แต่ไม่มีข้อกำหนด/packaging พิเศษ (หมายเหตุ: SSE pub/sub เป็น in-memory — รันหลาย instance ต้องเพิ่ม Redis/LISTEN-NOTIFY ก่อน) |
|
||||
| **5.3.24** รองรับมาตรฐานความมั่นคงปลอดภัยของหน่วยงาน | ❌ | ไม่มี auth/audit log/encryption policy — ไม่ตอบโจทย์ในระดับนี้ |
|
||||
| **5.3.25** ระบบมีความพร้อมในการให้บริการ (Availability) อย่างน้อย 99.5% ตลอดเวลาการให้บริการ | ⚠️ | ยังไม่มีการออกแบบเรื่อง SLA/uptime — ไม่มี health check, monitoring, แผน failover/backups — เป็นข้อกำหนดระดับ infrastructure (กลุ่ม D) ที่ต้องออกแบบตอนวางระบบ production จริง |
|
||||
| **5.3.18** ใช้งานบน Tablet, iPad | ✅ | Touch support เต็มรูปแบบ: ไฮไลต์ด้วย custom selection ลาก→ปล่อย=สร้างทันที (`hooks/useCustomSelection.ts` — แทนปุ่มลอยของ commit `b3b884c` ที่ถอดออกแล้ว), ลาก handles, วาดด้วยนิ้ว |
|
||||
| **5.3.19** รองรับการพัฒนาโปรแกรมแบบ Web Application ที่สนับสนุนเทคโนโลยี HTML5 หรือดีกว่า | ✅ | เป็น Web Application (Next.js 14) สร้างบนเทคโนโลยี HTML5 ทั้งหมด: Canvas 2D (render PDF ผ่าน pdfjs), SVG (InkLayer), Server-Sent Events/EventSource (realtime), Web Storage (anonymous identity), Pointer Events + pressure (สไตลัส) — ไม่พึ่ง plugin เดิม เช่น Flash/Java Applet |
|
||||
| **5.3.20** Responsive รองรับแสดงผลได้ทุกความละเอียดหน้าจอ | ✅ | sidebar จอกลาง/จอเล็ก, PDF fit จอเล็ก, ปุ่มขนาด touch-44px (commit `1c2f4c8`) |
|
||||
| **5.3.21** ใช้งานหลายผู้ใช้พร้อมกัน (Multi-user) จากหลายเครื่อง | ✅ | Realtime sync ผ่าน SSE (`hooks/useAnnotationRealtime.ts`) — แก้ไข/ลบ ของใครของมันบนไฟล์เดียวกันได้พร้อมกัน |
|
||||
| **5.3.22** การรับรองตัวผู้ใช้งาน (Authentication) | ❌ | ไม่มีระบบ auth — anonymous identity จาก localStorage โดยเจตนา (out of scope ตามแผน) |
|
||||
| **5.3.23** สิทธิการใช้งานเป็นไปตามระบบปกครอง | ❌ | ไม่มีระบบสิทธิ์ (roles/permissions) — ไม่มี login จึงกำหนดสิทธิ์ตามระบบปกครองไม่ได้ (ต้องทำ 5.3.22 ก่อน) |
|
||||
| **5.3.24** ติดตั้ง On-Premise บน Server ภายใน หรือ Cloud | ⚠️ | สถาปัตยกรรม Next.js + PostgreSQL deploy ได้ทั้งสองรูปแบบทางเทคนิค แต่ไม่มีข้อกำหนด/packaging พิเศษ (หมายเหตุ: SSE pub/sub เป็น in-memory — รันหลาย instance ต้องเพิ่ม Redis/LISTEN-NOTIFY ก่อน) |
|
||||
| **5.3.25** ระบบที่พัฒนาสามารถเชื่อมโยงกับระบบปกครองได้ | ❌ | เป็น standalone Next.js app — ยังไม่มี integration/API เชื่อมกับระบบงานคดีปกครอง (ผูกกับ gap 5.3.1 เรียกเอกสารโดยตรง และ 5.3.14 ชุด API) |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -67,11 +68,11 @@
|
|||
### กลุ่ม A — ของที่ out of scope โดยเจตนาตามแผนเฟสแรก
|
||||
ข้อที่ต้องเปลี่ยนขอบเขตโปรเจกต์ + ออกแบบเพิ่มก่อน implement:
|
||||
|
||||
- Authentication ผู้ใช้งาน + ระบบสิทธิ์ (5.3.21, 5.3.24)
|
||||
- Authentication ผู้ใช้งาน + สิทธิการใช้งานตามระบบปกครอง (5.3.22, 5.3.23)
|
||||
- Private/Group visibility ของ bookmark (5.3.9)
|
||||
- ชุด API สำหรับระบบภายนอก (5.3.14, 5.3.22)
|
||||
- ชุด API สำหรับระบบภายนอก (5.3.14)
|
||||
- โหมด Integrate แบบ modal/iframe (5.3.16)
|
||||
- เรียกดูเอกสารจากระบบงานคดีปกครองโดยตรง (5.3.1)
|
||||
- เชื่อมโยงกับระบบงานคดีปกครอง — ทั้งเรียกเอกสารโดยตรงและเชื่อมต่อระบบ (5.3.1, 5.3.25)
|
||||
|
||||
### กลุ่ม B — ฟังก์ชันที่มีอยู่แล้ว ต่อยอดเล็กน้อยได้เลย
|
||||
งานขนาดเล็ก–กลาง ไม่กระทบสถาปัตยกรรม:
|
||||
|
|
@ -81,18 +82,16 @@
|
|||
- **รายงานรายการ bookmark เป็น PDF** (5.3.15) — ต่อจากหน้าสรุป F15 หรือ export route เดิม
|
||||
|
||||
### กลุ่ม C — ของใหม่ที่ไม่เคยอยู่ในแผน
|
||||
- **รองรับไฟล์รูปภาพ jpg/jpeg/png/tif** (5.3.4) — กระทบ viewer เป็นวงกว้าง (pdfjs-dist อ่าน PDF เท่านั้น ต้องเพิ่ม image viewer path แยก + คิดเรื่องพิกัด % ของหน้ารูปภาพ)
|
||||
- ~~**รองรับไฟล์รูปภาพ jpg/jpeg/png/tif** (5.3.4)~~ — **ทำแล้ว (2026-09-15, N3 + N4 ตามแผน `docs/PLAN-IMAGE.md`)**: รูปคือ "หน้าเอกสาร" (layers เดิมซ้อนบน `<img>` พิกัด % ใช้ของเดิมได้ทันที) + TIFF หลายเฟรม + EXIF orientation + export PDF ฝังรูป + อัปโหลดผ่านหน้ารายการ
|
||||
- **ชุดของเอกสาร (document set) + แสดง bookmark รวมหลายไฟล์** (5.3.10) — ต้องเพิ่ม data model จัดกลุ่มไฟล์ + หน้ารวม bookmark ข้ามไฟล์ (ตอนนี้ไฟล์เป็นลิสต์แบนหน้าเดียว)
|
||||
|
||||
### กลุ่ม D — infrastructure (ระยะยาว)
|
||||
- On-Premise/Cloud packaging (5.3.23) — รวมถึงเตรียม multi-instance สำหรับ SSE
|
||||
- มาตรฐานความปลอดภัยของหน่วยงาน (5.3.24) — ผูกกับ auth ในกลุ่ม A
|
||||
- Availability ≥ 99.5% (5.3.25) — SLA/uptime, monitoring, failover, backup ระดับ production
|
||||
- On-Premise/Cloud packaging (5.3.24) — รวมถึงเตรียม multi-instance สำหรับ SSE
|
||||
|
||||
---
|
||||
|
||||
## 4. ข้อเสนอแนะลำดับความสำคัญ
|
||||
|
||||
1. **ทำกลุ่ม B ก่อน** — คุ้มค่าต่อแรง เห็นผลไว เสริมฟีเจอร์ bookmark ให้ครบ requirement โดยไม่แตะสถาปัตยกรรม
|
||||
2. **ตัดสินใจเรื่องรูปภาพ (กลุ่ม C)** — เป็น requirement ที่ชัดเจนใน 5.3.4 แต่แพงที่สุดในเชิงโครงสร้าง ควรตัดสินใจก่อนว่าจะอยู่ในขอบเขตจริงไหม
|
||||
2. ~~**ตัดสินใจเรื่องรูปภาพ (กลุ่ม C)**~~ — **ตัดสินใจและทำเสร็จแล้ว (2026-09-15)**: 5.3.4 ครบทุกนามสกุลตาม N3 + อัปโหลดไฟล์ (N4)
|
||||
3. **กลุ่ม A ตามด้วย D** — ต้องอัปเดต `docs/PLAN.md` ขยายขอบเขตอย่างเป็นทางการก่อน implement ตามกระบวนการของโปรเจกต์
|
||||
|
|
|
|||
647
hooks/useCustomSelection.ts
Normal file
647
hooks/useCustomSelection.ts
Normal file
|
|
@ -0,0 +1,647 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
mergeRectsByLine,
|
||||
rectToPercent,
|
||||
rectsInSweptPath,
|
||||
type ClientPt,
|
||||
type Rect,
|
||||
} from "@/utils/coordinates";
|
||||
|
||||
// Custom text selection สำหรับจอสัมผัส (tool highlight) — ไม่พึ่ง native selection
|
||||
// ของ OS เพราะ browser ยึด gesture ปล่อยนิ้วไปแสดงเมนู/handles เอง ทำให้ commit-on-release
|
||||
// ทำไม่ได้ — hook นี้ลากด้วย Pointer Events ชุดเดียว (mouse + touch), สร้าง Range เอง
|
||||
// จาก caret ตำแหน่งนิ้ว, วาด live preview ผ่าน rAF แล้ว commit ทันทีตอนปล่อย
|
||||
// (desktop ไม่ใช้ hook นี้ — enabled ผู้เรียกเป็น tool highlight + pointer หลัก coarse)
|
||||
|
||||
/** class ที่ container ได้ตอน enabled — ปิด native selection/callout/scroll ของ OS
|
||||
* ให้ gesture ลากเป็นของเราทั้งหมด (นิยามใน app/globals.css) */
|
||||
const CONTAINER_CLASS = "pdf-custom-select";
|
||||
|
||||
/** เลื่อนเกินระยะนี้จากจุดกดจึงถือว่าเริ่ม select จริง (ต่ำกว่านี้ = tap ธรรมดา
|
||||
* ไม่ capture pointer ไม่สร้าง selection — click ของ annotation/pin ใช้ได้ตามเดิม) */
|
||||
const MOVE_THRESHOLD_PX = 8;
|
||||
/** อ่าน caret จากจุด y ตรงนิ้วเลื่อนขึ้น 12px — นิ้วบังข้อความที่กำลังเลือก
|
||||
* (เฉพาะ pointerType touch; เมาส์/สไตลัสชี้แม่นอยู่แล้ว) */
|
||||
const FINGER_OFFSET_PX = 12;
|
||||
/** นิ้วอยู่ใกล้ขอบบน/ล่างของ viewport แค่นี้ = auto-scroll ต่อ (rAF loop) */
|
||||
const AUTOSCROLL_MARGIN_PX = 60;
|
||||
const AUTOSCROLL_MAX_SPEED_PX = 12;
|
||||
|
||||
/** preview ที่วาดซ้อนบนหน้า — rects เป็น % ของหน้า รวบบรรทัดแล้ว (รูปร่างเดียว
|
||||
* กับ liveMark ของ desktop) ผู้เรียกเอาไป render ใน highlight-layer ได้เลย */
|
||||
export type CustomSelectionPreview = { pageIndex: number; rects: Rect[] } | null;
|
||||
|
||||
/** สิ่งที่ส่งเข้า onCommit ตอนปล่อยนิ้ว — start/end เป็นเส้นทางลากจริง (client
|
||||
* coords) สำหรับกรอง rect ตามแนวลากด้วย rectsInSweptPath เหมือน desktop */
|
||||
export type CustomSelectionCommit = {
|
||||
range: Range;
|
||||
start: ClientPt;
|
||||
end: ClientPt;
|
||||
};
|
||||
|
||||
/** caret (collapsed Range) ที่จุด (x, y) ของ viewport — WebKit ก่อนแล้วค่อย spec API
|
||||
* ของ Firefox ถ้าไม่มีทั้งคู่ = null (bail เงียบ ๆ ไม่เริ่ม selection) */
|
||||
function caretFromPoint(x: number, y: number): Range | null {
|
||||
const doc = document as Document & {
|
||||
caretRangeFromPoint?: (x: number, y: number) => Range | null;
|
||||
caretPositionFromPoint?: (
|
||||
x: number,
|
||||
y: number,
|
||||
) => { offsetNode: Node; offset: number } | null;
|
||||
};
|
||||
if (typeof doc.caretRangeFromPoint === "function") {
|
||||
return doc.caretRangeFromPoint(x, y);
|
||||
}
|
||||
if (typeof doc.caretPositionFromPoint === "function") {
|
||||
const pos = doc.caretPositionFromPoint(x, y);
|
||||
if (!pos) return null;
|
||||
const r = document.createRange();
|
||||
r.setStart(pos.offsetNode, pos.offset);
|
||||
r.collapse(true);
|
||||
return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** หา text node แรกใน subtree (DFS) */
|
||||
function findFirstTextNode(node: Node): Node | null {
|
||||
if (node.nodeType === Node.TEXT_NODE) return node;
|
||||
for (const child of Array.from(node.childNodes)) {
|
||||
const found = findFirstTextNode(child);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** เหมือน caretFromPoint แต่ guarantee ว่าถ้าหา text node ได้จะคืน Range ที่
|
||||
* startContainer เป็น TEXT_NODE เสมอ — กันเคสตกรอยต่อ span/margin/padding
|
||||
* ที่ caretRangeFromPoint คืน element แทน text node (ทำ onPointerDown bail
|
||||
* เงียบจน drag ไม่เริ่มเลย — bug report จุดที่ 1) */
|
||||
function resolveTextCaret(x: number, y: number): Range | null {
|
||||
const first = caretFromPoint(x, y);
|
||||
const fromElement = (r: Range | null): Range | null => {
|
||||
if (!r) return null;
|
||||
if (r.startContainer.nodeType === Node.TEXT_NODE) return r;
|
||||
// startContainer เป็น element (เช่น span คำ, หรือ textLayer เอง) —
|
||||
// หา text node ลูกที่ offset ชี้ถึง แล้วสร้าง Range ใหม่ที่ต้นของมัน
|
||||
const el = r.startContainer;
|
||||
const child =
|
||||
el.childNodes[r.startOffset] ??
|
||||
el.childNodes[el.childNodes.length - 1];
|
||||
const textNode =
|
||||
child?.nodeType === Node.TEXT_NODE ? child : findFirstTextNode(child ?? el);
|
||||
if (!textNode) return null;
|
||||
const nr = document.createRange();
|
||||
nr.setStart(textNode, 0);
|
||||
nr.collapse(true);
|
||||
return nr;
|
||||
};
|
||||
|
||||
const resolved = fromElement(first);
|
||||
if (resolved) return resolved;
|
||||
|
||||
// ยังไม่เจอ — retry ขยับพิกัดรอบจุดเดิม (รอยต่อบางกว่า 2-3px มักพอ)
|
||||
const offsets: Array<[number, number]> = [
|
||||
[0, -2], [0, 2], [-2, 0], [2, 0],
|
||||
[0, -3], [0, 3], [-3, 0], [3, 0],
|
||||
];
|
||||
for (const [dx, dy] of offsets) {
|
||||
const r = fromElement(caretFromPoint(x + dx, y + dy));
|
||||
if (r) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** รัศมีค้นหา span ข้อความใกล้สุดจากจุดแตะ (px แนวตั้ง) — เกินนี้ถือว่าจุดนั้นไม่มี
|
||||
* ข้อความอยู่บริเวณนั้นจริง ๆ (กลาง margin กว้าง ๆ ของหน้า) ค่อย bail */
|
||||
const NEAREST_SPAN_MAX_PX = 40;
|
||||
|
||||
/** cache รายชื่อ span ของหน้า ต่อ 1 gesture — query ครั้งเดียว (textLayer ไม่
|
||||
* เปลี่ยนระหว่างลาก) ใช้กับการหา span ใกล้สุดเฉพาะตอน pointerdown */
|
||||
type SpanCache = { pageEl: HTMLElement; spans: Element[] } | null;
|
||||
|
||||
/** caret ที่ต้น text node แรกของ span — offset 0 พอ (กลางคำก็ได้ เพราะ
|
||||
* snapCaretToWord จะขยายเต็มคำให้อยู่แล้ว) */
|
||||
function caretAtSpanStart(span: Element): Range | null {
|
||||
const t = findFirstTextNode(span);
|
||||
if (!t) return null;
|
||||
const r = document.createRange();
|
||||
r.setStart(t, 0);
|
||||
r.collapse(true);
|
||||
return r;
|
||||
}
|
||||
|
||||
/** ชั้น 3: เจาะผ่าน overlay ที่ซ้อนอยู่เหนือข้อความ (SVG ของ InkLayer / มาร์ก /
|
||||
* กรอบ selection — ล้วนไร้ text node ใน subtree จน caret API คืนอะไรมาใช้ไม่ได้)
|
||||
* ด้วย elementsFromPoint ที่ได้ทุก element ใต้จุด ไม่ใช่แค่ตัวบนสุด แล้วจับ span
|
||||
* ข้อความที่อยู่ใต้จุดพอดีตามเรขาคณิตตัวแรก */
|
||||
function spanFromPointStack(x: number, y: number): Range | null {
|
||||
for (const el of document.elementsFromPoint(x, y)) {
|
||||
if (!(el instanceof Element)) continue;
|
||||
// เฉพาะ span ของ textLayer เท่านั้น (ตัว container .textLayer เองไม่ใช่ —
|
||||
// จุดไม่ได้อยู่บน span ไหนเลยเป็นงานของการหาใกล้สุดในชั้นถัดไป)
|
||||
if (el.tagName !== "SPAN" || !el.closest(".textLayer")) continue;
|
||||
const caret = caretAtSpanStart(el);
|
||||
if (caret) return caret;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** ชั้น 4 (สุดท้าย): span ข้อความที่ใกล้จุดที่สุดในแนวตั้งของหน้าที่จุดนั้นอยู่ —
|
||||
* บรรทัดถัดไป/ก่อนหน้าที่ใกล้เจตนาผู้ใช้ที่สุด (gap ระหว่างบรรทัด/section
|
||||
* กว้างกว่า retry radius ±3px) จำกัดระยะไม่เกิน NEAREST_SPAN_MAX_PX —
|
||||
* cache รายชื่อ span ไว้ใน drag เพื่อไม่ query ซ้ำต่อการเรียกแต่ละจุด */
|
||||
function nearestSpanCaret(
|
||||
x: number,
|
||||
y: number,
|
||||
cache: { spanCache: SpanCache },
|
||||
): Range | null {
|
||||
const pageEl =
|
||||
document.elementFromPoint(x, y)?.closest<HTMLElement>("[data-page]") ?? null;
|
||||
if (!pageEl) return null;
|
||||
if (!cache.spanCache || cache.spanCache.pageEl !== pageEl) {
|
||||
cache.spanCache = {
|
||||
pageEl,
|
||||
spans: Array.from(pageEl.querySelectorAll(".textLayer span")),
|
||||
};
|
||||
}
|
||||
let best: Element | null = null;
|
||||
let bestDy = Infinity;
|
||||
let bestDx = Infinity;
|
||||
for (const s of cache.spanCache.spans) {
|
||||
const r = s.getBoundingClientRect();
|
||||
if (r.width === 0 && r.height === 0) continue;
|
||||
const dy = y < r.top ? r.top - y : y > r.bottom ? y - r.bottom : 0;
|
||||
if (dy > NEAREST_SPAN_MAX_PX) continue;
|
||||
const dx = Math.abs(r.left + r.width / 2 - x);
|
||||
if (dy < bestDy || (dy === bestDy && dx < bestDx)) {
|
||||
best = s;
|
||||
bestDy = dy;
|
||||
bestDx = dx;
|
||||
}
|
||||
}
|
||||
return best ? caretAtSpanStart(best) : null;
|
||||
}
|
||||
|
||||
/** resolveTextCaret ครบทั้ง 4 ชั้น (เรียงตามต้นทุน): caret API + ขยับ ±3px →
|
||||
* เจาะผ่าน overlay ด้วย elementsFromPoint → span ใกล้สุด ≤40px — ชั้นที่เพิ่มมา
|
||||
* แพงกว่า caret API (query DOM หลาย element + วัด rect) จึงใช้เฉพาะตอน
|
||||
* pointerdown เท่านั้น ระหว่างลากใช้ชั้นถูก + คง focus เดิมแทน (perf) */
|
||||
function resolveTextCaretDeep(
|
||||
x: number,
|
||||
y: number,
|
||||
cache: { spanCache: SpanCache },
|
||||
): Range | null {
|
||||
return (
|
||||
resolveTextCaret(x, y) ??
|
||||
spanFromPointStack(x, y) ??
|
||||
nearestSpanCaret(x, y, cache)
|
||||
);
|
||||
}
|
||||
|
||||
let segmenter: Intl.Segmenter | null | undefined;
|
||||
function getSegmenter(): Intl.Segmenter | null {
|
||||
if (segmenter === undefined) {
|
||||
segmenter =
|
||||
typeof Intl !== "undefined" && "Segmenter" in Intl
|
||||
? new Intl.Segmenter(undefined, { granularity: "word" })
|
||||
: null;
|
||||
}
|
||||
return segmenter;
|
||||
}
|
||||
|
||||
/** ขยาย caret ไปขอบคำที่ครอบตำแหน่งนั้น (word snap — ชดเชยความหนาของนิ้วที่จิ้ม
|
||||
* กลางคำแล้วได้ทั้งคำ เหมือน selection ของ OS) — ใช้ Intl.Segmenter ถ้ามี (แยกคำ
|
||||
* ไทยได้ด้วย dictionary) fallback เป็น regex \S+ — ถ้า caret อยู่บนช่องว่างล้วน
|
||||
* หรือไม่ใช่ text node = ไม่ขยาย */
|
||||
function snapCaretToWord(caret: Range): void {
|
||||
const node = caret.startContainer;
|
||||
if (node.nodeType !== Node.TEXT_NODE) return;
|
||||
const text = node.textContent ?? "";
|
||||
const off = caret.startOffset;
|
||||
const seg = getSegmenter();
|
||||
if (seg) {
|
||||
// offset ตรงปลายคำ = นับเป็นคำถัดไป (ถ้าเป็นคำ → snap คำนั้น, ช่องว่าง → ไม่ขยาย)
|
||||
// (ใช้ Array.from แทน for..of — tsconfig ไม่ตั้ง target จึงห้าม iterate ตรง ๆ)
|
||||
const s = Array.from(seg.segment(text)).find(
|
||||
(seg2) => off >= seg2.index && off < seg2.index + seg2.segment.length,
|
||||
);
|
||||
if (s?.isWordLike) {
|
||||
caret.setStart(node, s.index);
|
||||
caret.setEnd(node, s.index + s.segment.length);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let s = off;
|
||||
while (s > 0 && /\S/.test(text[s - 1] ?? "")) s--;
|
||||
let e = off;
|
||||
while (e < text.length && /\S/.test(text[e] ?? "")) e++;
|
||||
if (s === e) return; // กลางช่องว่าง — ไม่ขยาย
|
||||
caret.setStart(node, s);
|
||||
caret.setEnd(node, e);
|
||||
}
|
||||
|
||||
/** Range จาก caret สองจุด (จัดลำดับ start/end ให้ถูกทิศการลาก) — null เมื่อคนละ
|
||||
* tree จน compare ไม่ได้ */
|
||||
function rangeBetween(a: Range, b: Range): Range | null {
|
||||
const r = document.createRange();
|
||||
try {
|
||||
if (a.compareBoundaryPoints(Range.START_TO_START, b) <= 0) {
|
||||
r.setStart(a.startContainer, a.startOffset);
|
||||
r.setEnd(b.startContainer, b.startOffset);
|
||||
} else {
|
||||
r.setStart(b.startContainer, b.startOffset);
|
||||
r.setEnd(a.startContainer, a.startOffset);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/** หน้า PDF ที่ range นี้อยู่ (เฉพาะ selection ใน page เดียว — พ้นหน้า/ข้ามหน้า = null) */
|
||||
function pageOfRange(range: Range): HTMLElement | null {
|
||||
const c = range.commonAncestorContainer;
|
||||
const el = c instanceof Element ? c : c.parentElement;
|
||||
return el?.closest<HTMLElement>("[data-page]") ?? null;
|
||||
}
|
||||
|
||||
/** ลากล้ำเข้าหน้าอื่น (range คร่อม 2 หน้า) — clamp ฝั่ง focus ไปที่ท้ายหน้า (ลาก
|
||||
* ลง) หรือต้นหน้า (ลากขึ้น) ของ anchor เพื่อให้ selection จบที่ขอบหน้า anchor
|
||||
* แทนการหายทั้งเส้น (viewer เลื่อนต่อเนื่อง เกิดบ่อยตอนลากใกล้ขอบล่างหน้า)
|
||||
* คืน range ที่ clamp แล้ว หรือ null เมื่อหน้า anchor ไม่มี text node เลย (สแกน)
|
||||
* boundary ไม่เปลี่ยนระหว่างลากจึง cache ต่อ gesture (เดิน TreeWalker ทุก text
|
||||
* node ของ textLayer แพงเกินจะทำทุก rAF frame — code review 💡4) */
|
||||
function clampToAnchorPage(
|
||||
anchorPageEl: HTMLElement,
|
||||
anchor: Range,
|
||||
focus: Range,
|
||||
cache: { clampBounds: { first: Text; last: Text } | null },
|
||||
): Range | null {
|
||||
let bounds = cache.clampBounds;
|
||||
if (!bounds || !bounds.first.isConnected || !bounds.last.isConnected) {
|
||||
const layer = anchorPageEl.querySelector(".textLayer");
|
||||
if (!layer) return null;
|
||||
const walker = document.createTreeWalker(layer, NodeFilter.SHOW_TEXT);
|
||||
let first: Text | null = null;
|
||||
let last: Text | null = null;
|
||||
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
||||
const t = n as Text;
|
||||
if ((t.textContent ?? "").trim().length === 0) continue;
|
||||
if (!first) first = t;
|
||||
last = t;
|
||||
}
|
||||
if (!first || !last) return null;
|
||||
bounds = { first, last };
|
||||
cache.clampBounds = bounds;
|
||||
}
|
||||
const boundary = document.createRange();
|
||||
// ทิศลาก: focus อยู่หลัง anchor (ลากลง) = จบที่ท้ายหน้า anchor ก่อนข้ามไปหน้าถัดไป
|
||||
// กลับกัน (ลากขึ้นจากหน้าถัดไป) = เริ่มที่ต้นหน้า anchor แทนหน้าที่อยู่เหนือกว่า
|
||||
if (anchor.compareBoundaryPoints(Range.START_TO_START, focus) < 0) {
|
||||
boundary.setStart(bounds.last, bounds.last.length);
|
||||
} else {
|
||||
boundary.setStart(bounds.first, 0);
|
||||
}
|
||||
boundary.collapse(true);
|
||||
return rangeBetween(anchor, boundary);
|
||||
}
|
||||
|
||||
type DragState = {
|
||||
pointerId: number;
|
||||
pointerType: string;
|
||||
/** จุด pointerdown จริง (client) — ground truth ของเส้นทางลาก ใช้กรอง rect */
|
||||
anchorPt: ClientPt;
|
||||
/** ตำแหน่ง pointer ล่าสุด */
|
||||
curPt: ClientPt;
|
||||
/** caret ที่จุดกด (word-snap ตอนเริ่ม select จริง — ครั้งเดียว) */
|
||||
anchor: Range;
|
||||
/** จุด probe ที่ resolve anchor ได้ (จอสัมผัส = จุด y−12 หรือจุดแตะจริงลูกใด
|
||||
* ลูกหนึ่ง) — ใช้เป็นต้นเส้นทางของ rectsInSweptPath ให้ตรงแนวกับ caret จริง
|
||||
* (ใช้พิกัดนิ้ว raw ตรง ๆ จะคลาด 12px จนบรรทัดเป้าหมายโดนกรองทิ้ง — code review 🚨) */
|
||||
anchorPathPt: ClientPt;
|
||||
/** หน้าของ anchor — เก็บตั้งแต่ pointerdown (anchor ไม่ย้ายหน้าระหว่างลาก)
|
||||
* ใช้ clamp ตอนลากล้ำเข้าหน้าถัดไป (จุดที่ 3 ของ bug report) */
|
||||
anchorPageEl: HTMLElement;
|
||||
/** boundary text node แรก/สุดท้ายของ textLayer หน้า anchor — cache ต่อ gesture
|
||||
* ให้ clampToAnchorPage ไม่เดิน TreeWalker ใหม่ทุก frame (code review 💡4) */
|
||||
clampBounds: { first: Text; last: Text } | null;
|
||||
/** focus caret ล่าสุดที่ resolve สำเร็จ — ระหว่างลากผ่านพื้นที่ว่าง (ชั้นถูก
|
||||
* ของ resolveTextCaret หาไม่เจอ) ให้คงตำแหน่งเดิมไว้แทน re-scan ทุก frame */
|
||||
lastFocus: Range | null;
|
||||
anchorSnapped: boolean;
|
||||
/** เลย threshold 8px แล้วหรือยัง (false = tap ที่ยังไม่ขยับ) */
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param containerRef element ที่รับ pointer ของทั้งหน้า (ผ่านการ bubble จาก
|
||||
* textLayer) — ตอน enabled จะได้ class .pdf-custom-select และถอดทันทีเมื่อปิด
|
||||
* @param enabled เปิดเฉพาะช่วงที่ต้องการลากเลือกข้อความเอง (tool highlight + จอสัมผัส)
|
||||
* @param onCommit เรียกตอนปล่อยนิ้วเมื่อ range ไม่ collapsed (identity เปลี่ยนกี่ครั้ง
|
||||
* ก็ได้ — hook เก็บใน ref ไม่ถอด listener ใหม่)
|
||||
*/
|
||||
export function useCustomSelection({
|
||||
containerRef,
|
||||
enabled,
|
||||
onCommit,
|
||||
}: {
|
||||
containerRef: RefObject<HTMLElement | null>;
|
||||
enabled: boolean;
|
||||
onCommit: (payload: CustomSelectionCommit) => void;
|
||||
}): { previewRects: CustomSelectionPreview } {
|
||||
const [previewRects, setPreviewRects] =
|
||||
useState<CustomSelectionPreview>(null);
|
||||
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
/** range + เส้นทางลากล่าสุดที่ preview คำนวณแล้ว — ใช้ commit ตอนปล่อยนิ้ว */
|
||||
const lastRef = useRef<CustomSelectionCommit | null>(null);
|
||||
const previewRafRef = useRef<number | null>(null);
|
||||
const scrollRafRef = useRef<number | null>(null);
|
||||
|
||||
// onCommit เก็บใน ref — effect ผูก listener ครั้งเดียวต่อช่วง enabled ไม่ re-attach
|
||||
const onCommitRef = useRef(onCommit);
|
||||
onCommitRef.current = onCommit;
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el || !enabled) return;
|
||||
el.classList.add(CONTAINER_CLASS);
|
||||
|
||||
const stopPreviewRaf = () => {
|
||||
if (previewRafRef.current !== null) {
|
||||
cancelAnimationFrame(previewRafRef.current);
|
||||
previewRafRef.current = null;
|
||||
}
|
||||
};
|
||||
const stopAutoScroll = () => {
|
||||
if (scrollRafRef.current !== null) {
|
||||
cancelAnimationFrame(scrollRafRef.current);
|
||||
scrollRafRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
/** คำนวณ preview จาก anchor↔focus ปัจจุบัน — เรียกจาก rAF (throttle) และตอน
|
||||
* ปล่อยนิ้ว (synchronous เพื่อเก็บตำแหน่งสุดท้ายก่อน commit) */
|
||||
const updatePreview = () => {
|
||||
const d = dragRef.current;
|
||||
if (!d || !d.active) return;
|
||||
// หน้า anchor ถูก virtualization unmount กลางลาก (auto-scroll ไกลจนพ้น
|
||||
// ช่วง render) — anchor/focus ชี้ detached nodes, rects ว่างหมด: เคลียร์
|
||||
// lastRef ด้วย กัน commit ของเก่าเงียบ ๆ (code review ⚠️ — จุดเคลียร์
|
||||
// lastRef เพิ่มจาก pointerdown/finish ตามเหตุการณ์ "หน้าหาย" เท่านั้น)
|
||||
if (!d.anchorPageEl.isConnected) {
|
||||
lastRef.current = null;
|
||||
setPreviewRects(null);
|
||||
return;
|
||||
}
|
||||
const y = d.curPt.y - (d.pointerType === "touch" ? FINGER_OFFSET_PX : 0);
|
||||
// ระหว่างลากใช้เฉพาะชั้นถูกของ resolveTextCaret — ผ่านพื้นที่ว่าง (gap กว้าง /
|
||||
// ช่องว่างระหว่างหน้า) หาไม่เจอก็คง focus เดิมจาก frame ก่อนไว้แทน re-scan
|
||||
// ด้วยชั้นแพง (perf note ของ bug report — selection หยุดรอที่ข้อความสุดท้าย
|
||||
// แล้วขยายต่อเมื่อนิ้วถึงข้อความอีกครั้ง)
|
||||
const focus = resolveTextCaret(d.curPt.x, y) ?? d.lastFocus;
|
||||
if (!focus) {
|
||||
console.warn("bail: focus caret null"); // TEMP-DEBUG
|
||||
return;
|
||||
}
|
||||
d.lastFocus = focus.cloneRange();
|
||||
if (!d.anchorSnapped) {
|
||||
snapCaretToWord(d.anchor);
|
||||
d.anchorSnapped = true;
|
||||
}
|
||||
snapCaretToWord(focus);
|
||||
let range = rangeBetween(d.anchor, focus);
|
||||
let pageEl = range && !range.collapsed ? pageOfRange(range) : null;
|
||||
// ลากล้ำเข้าหน้าอื่น (คร่อม 2 หน้า หรือ focus ข้ามไปหน้าถัดไปแล้ว) — clamp
|
||||
// ฝั่ง focus ไว้ที่ท้าย/ต้นหน้าของ anchor แทนการทิ้ง selection ทั้งเส้น
|
||||
// (จุดที่ 3 ของ bug report — ข้ามเมื่อ range collapsed: anchor==focus ใน
|
||||
// หน้าเดียว ไม่ใช่ข้ามหน้า ห้ามนำไป clamp กลายเป็น selection ตั้งแต่ต้นหน้า)
|
||||
if (range && !range.collapsed && pageEl !== d.anchorPageEl) {
|
||||
const clamped = clampToAnchorPage(d.anchorPageEl, d.anchor, focus, d);
|
||||
console.warn("clamp: cross-page"); // TEMP-DEBUG
|
||||
range = clamped && !clamped.collapsed ? clamped : null;
|
||||
pageEl = range ? d.anchorPageEl : null;
|
||||
}
|
||||
// หมายเหตุ (จุดที่ 2): branch พวกนี้เคลียร์แค่ preview — ห้ามแตะ lastRef
|
||||
// เพื่อให้ commit ใช้ selection ล่าสุดที่ไม่ว่างจาก frame ก่อนหน้า
|
||||
if (!range || !pageEl) {
|
||||
setPreviewRects(null);
|
||||
return;
|
||||
}
|
||||
const rects = Array.from(range.getClientRects()).filter((r) => r.width > 0);
|
||||
// เส้นทางกรองต้องอยู่แนวเดียวกับ caret ที่ selection อิง (จุด y−12 สำหรับ
|
||||
// จอสัมผัส) — ใช้พิกัดนิ้ว raw ตรง ๆ แล้วคลาด 12px จนบรรทัดเป้าหมายโดน
|
||||
// lineTol ตัดทิ้ง (ลากขึ้น) หรือบรรทัดสุดท้ายกลายเป็น "บรรทัดกลาง" จนมาร์ก
|
||||
// เกิน (ลากลง) เมื่อฟอนต์เล็ก/zoom ต่ำ (code review 🚨)
|
||||
const pathEnd: ClientPt =
|
||||
d.pointerType === "touch" ? { x: d.curPt.x, y } : d.curPt;
|
||||
const kept = rectsInSweptPath(rects, d.anchorPathPt, pathEnd);
|
||||
if (kept.length === 0) {
|
||||
console.warn("bail: swept path empty"); // TEMP-DEBUG
|
||||
setPreviewRects(null);
|
||||
return;
|
||||
}
|
||||
const pr = pageEl.getBoundingClientRect();
|
||||
const rectsPct = mergeRectsByLine(
|
||||
kept.map((r) =>
|
||||
rectToPercent(
|
||||
{
|
||||
x: r.left - pr.left,
|
||||
y: r.top - pr.top,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
},
|
||||
{ width: pr.width, height: pr.height },
|
||||
),
|
||||
),
|
||||
);
|
||||
lastRef.current = {
|
||||
range: range.cloneRange(),
|
||||
start: d.anchorPathPt,
|
||||
end: pathEnd,
|
||||
};
|
||||
const pageIndex = Number(pageEl.dataset.page);
|
||||
setPreviewRects((prev) =>
|
||||
prev !== null &&
|
||||
prev.pageIndex === pageIndex &&
|
||||
prev.rects.length === rectsPct.length &&
|
||||
prev.rects.every(
|
||||
(r, i) =>
|
||||
r.x === rectsPct[i].x &&
|
||||
r.y === rectsPct[i].y &&
|
||||
r.width === rectsPct[i].width &&
|
||||
r.height === rectsPct[i].height,
|
||||
)
|
||||
? prev
|
||||
: { pageIndex, rects: rectsPct },
|
||||
);
|
||||
};
|
||||
|
||||
const schedulePreview = () => {
|
||||
if (previewRafRef.current !== null) return;
|
||||
previewRafRef.current = requestAnimationFrame(() => {
|
||||
previewRafRef.current = null;
|
||||
updatePreview();
|
||||
});
|
||||
};
|
||||
|
||||
/** auto-scroll ระหว่างลากค้างใกล้ขอบจอ — rAF loop เลื่อน window แล้วคำนวณ preview
|
||||
* ใหม่ (จุดนิ้วเดิมชี้ข้อความใหม่หลัง scroll) จบเองเมื่อเลิกลาก */
|
||||
const startAutoScroll = () => {
|
||||
if (scrollRafRef.current !== null) return;
|
||||
const step = () => {
|
||||
scrollRafRef.current = null;
|
||||
const d = dragRef.current;
|
||||
if (!d?.active) return;
|
||||
const vh = window.innerHeight;
|
||||
let dy = 0;
|
||||
if (d.curPt.y < AUTOSCROLL_MARGIN_PX) {
|
||||
dy = -Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
((AUTOSCROLL_MARGIN_PX - d.curPt.y) / AUTOSCROLL_MARGIN_PX) *
|
||||
AUTOSCROLL_MAX_SPEED_PX,
|
||||
),
|
||||
);
|
||||
} else if (d.curPt.y > vh - AUTOSCROLL_MARGIN_PX) {
|
||||
dy = Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
((d.curPt.y - (vh - AUTOSCROLL_MARGIN_PX)) / AUTOSCROLL_MARGIN_PX) *
|
||||
AUTOSCROLL_MAX_SPEED_PX,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (dy !== 0) {
|
||||
window.scrollBy(0, dy);
|
||||
schedulePreview();
|
||||
}
|
||||
scrollRafRef.current = requestAnimationFrame(step);
|
||||
};
|
||||
scrollRafRef.current = requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (dragRef.current) return; // นิ้วที่สองระหว่างลาก — ไม่สน
|
||||
// เริ่ม selection ได้เฉพาะที่เริ่มบน textLayer (แตะ pin/ปุ่ม/กล่องข้อความ = งานของมัน)
|
||||
if (
|
||||
!(e.target instanceof Element) ||
|
||||
!e.target.closest(".textLayer")
|
||||
) {
|
||||
console.warn(
|
||||
"bail: down ไม่ได้เริ่มบน textLayer",
|
||||
(e.target instanceof Element ? e.target.className.toString() : "").slice(0, 40),
|
||||
); // TEMP-DEBUG
|
||||
return;
|
||||
}
|
||||
const y = e.clientY - (e.pointerType === "touch" ? FINGER_OFFSET_PX : 0);
|
||||
// ค้น anchor ครบทั้ง 4 ชั้น ทีละจุด: จุดชดเชยนิ้ว (y-12) ก่อน แล้วจุดที่แตะ
|
||||
// จริง (finger offset เป็น heuristic — บางจังหวะจุดจริงต่างหากที่อยู่บนข้อความ)
|
||||
// ชั้น 3-4 (เจาะ overlay / span ใกล้สุด ≤40px) แพงกว่า caret API จึงรัน
|
||||
// เฉพาะตรงนี้ตอนกด — ระหว่างลากใช้ชั้นถูกอย่างเดียว (cache ต่อ 1 gesture)
|
||||
const cache: { spanCache: SpanCache } = { spanCache: null };
|
||||
const probes =
|
||||
e.pointerType === "touch" ? [y, e.clientY] : [y];
|
||||
let caret: Range | null = null;
|
||||
let resolvedY = y;
|
||||
for (const py of probes) {
|
||||
caret = resolveTextCaretDeep(e.clientX, py, cache);
|
||||
if (caret) {
|
||||
resolvedY = py;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!caret) {
|
||||
// จุดกดไม่เกี่ยวกับข้อความเลย (แม้ค้น text node รอบ ๆ แล้ว) — เป็น tap
|
||||
// ธรรมดา ไม่เริ่ม selection (click ของ annotation/pin ทำงานตามเดิม)
|
||||
console.warn("bail: resolveTextCaret failed after retries", { x: e.clientX, y }); // TEMP-DEBUG
|
||||
return;
|
||||
}
|
||||
const anchorPageEl = pageOfRange(caret);
|
||||
if (!anchorPageEl) return;
|
||||
// gesture ใหม่ = ล้างผลล่าสุดของ gesture ก่อน (จุดที่ 2 ของ bug report:
|
||||
// lastRef เคลียร์เฉพาะตรงนี้กับตอน finish เท่านั้น)
|
||||
lastRef.current = null;
|
||||
dragRef.current = {
|
||||
pointerId: e.pointerId,
|
||||
pointerType: e.pointerType,
|
||||
anchorPt: { x: e.clientX, y: e.clientY },
|
||||
anchorPathPt: { x: e.clientX, y: resolvedY },
|
||||
curPt: { x: e.clientX, y: e.clientY },
|
||||
anchor: caret,
|
||||
anchorPageEl,
|
||||
clampBounds: null,
|
||||
lastFocus: null,
|
||||
anchorSnapped: false,
|
||||
active: false,
|
||||
};
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || e.pointerId !== d.pointerId) return;
|
||||
d.curPt = { x: e.clientX, y: e.clientY };
|
||||
if (!d.active) {
|
||||
if (
|
||||
Math.hypot(e.clientX - d.anchorPt.x, e.clientY - d.anchorPt.y) <
|
||||
MOVE_THRESHOLD_PX
|
||||
) {
|
||||
return;
|
||||
}
|
||||
d.active = true;
|
||||
console.warn("debug: select เริ่ม (activated)"); // TEMP-DEBUG
|
||||
// capture ตอน"เริ่ม select จริง"ไม่ใช่ pointerdown — capture ตั้งแต่กดจะ
|
||||
// retarget click ไปที่ container จน tap เลือก annotation / ปุ่ม pin ใช้ไม่ได้
|
||||
// (tap ที่ไม่ขยับเลย threshold = ไม่ capture → click ไปถึงเป้าหมายตามเดิม)
|
||||
el.setPointerCapture(d.pointerId);
|
||||
startAutoScroll();
|
||||
}
|
||||
schedulePreview();
|
||||
};
|
||||
|
||||
/** เคลียร์ state ทุกทาง — commit เฉพาะตอน pointerup ที่มี range ไม่ collapsed
|
||||
* (pointercancel จาก gesture ของระบบ = ทิ้ง selection ไม่ commit) */
|
||||
const finish = (e: PointerEvent, commit: boolean) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || e.pointerId !== d.pointerId) return;
|
||||
stopAutoScroll();
|
||||
stopPreviewRaf();
|
||||
if (d.active) {
|
||||
updatePreview(); // synchronous — ตำแหน่งสุดท้ายก่อน commit (rAF อาจยังค้าง)
|
||||
}
|
||||
dragRef.current = null;
|
||||
setPreviewRects(null);
|
||||
const last = lastRef.current;
|
||||
lastRef.current = null;
|
||||
if (commit && last && !last.range.collapsed) {
|
||||
onCommitRef.current(last);
|
||||
}
|
||||
};
|
||||
|
||||
el.addEventListener("pointerdown", onPointerDown);
|
||||
el.addEventListener("pointermove", onPointerMove);
|
||||
const onUp = (e: PointerEvent) => finish(e, true);
|
||||
const onCancel = (e: PointerEvent) => finish(e, false);
|
||||
el.addEventListener("pointerup", onUp);
|
||||
el.addEventListener("pointercancel", onCancel);
|
||||
|
||||
return () => {
|
||||
el.classList.remove(CONTAINER_CLASS);
|
||||
el.removeEventListener("pointerdown", onPointerDown);
|
||||
el.removeEventListener("pointermove", onPointerMove);
|
||||
el.removeEventListener("pointerup", onUp);
|
||||
el.removeEventListener("pointercancel", onCancel);
|
||||
stopAutoScroll();
|
||||
stopPreviewRaf();
|
||||
dragRef.current = null;
|
||||
lastRef.current = null;
|
||||
setPreviewRects(null);
|
||||
};
|
||||
// containerRef เป็น ref object ที่ identity คงที่ตลอด life ของ component
|
||||
}, [enabled, containerRef]);
|
||||
|
||||
return { previewRects };
|
||||
}
|
||||
|
|
@ -2,6 +2,12 @@
|
|||
const nextConfig = {
|
||||
// สำหรับ Docker (docker/web.Dockerfile) — build แล้วได้ .next/standalone รันด้วย node server.js
|
||||
output: "standalone",
|
||||
// N4 อัปโหลดไฟล์ผ่าน Server Action — default 1MB ไม่พอสำหรับ PDF/สแกนศาล (ต้องตรงกับ MAX_UPLOAD_BYTES ใน app/actions/files.ts)
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: "100mb",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
33
package-lock.json
generated
33
package-lock.json
generated
|
|
@ -15,12 +15,15 @@
|
|||
"next": "^14.2.35",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^4.10.38",
|
||||
"pngjs": "^7.0.0",
|
||||
"postgres": "^3.4.9",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"utif2": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.5.0",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@types/react": "^18.3.31",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"autoprefixer": "^10.5.5",
|
||||
|
|
@ -1757,6 +1760,16 @@
|
|||
"undici-types": "~8.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pngjs": {
|
||||
"version": "6.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz",
|
||||
"integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
|
|
@ -3636,6 +3649,15 @@
|
|||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
|
||||
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
|
|
@ -4337,6 +4359,15 @@
|
|||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/utif2": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz",
|
||||
"integrity": "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.11"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -17,12 +17,15 @@
|
|||
"next": "^14.2.35",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^4.10.38",
|
||||
"pngjs": "^7.0.0",
|
||||
"postgres": "^3.4.9",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"utif2": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.5.0",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@types/react": "^18.3.31",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"autoprefixer": "^10.5.5",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// Sync ตาราง files กับไฟล์จริงใน public/samples/ (idempotent — รันซ้ำได้ทุกครั้ง)
|
||||
// - INSERT ไฟล์ .pdf ที่ยังไม่มี row (pdf_url = /samples/<ชื่อไฟล์>)
|
||||
// - INSERT ไฟล์ .pdf/.jpg/.jpeg/.png/.tif/.tiff ที่ยังไม่มี row (pdf_url = /samples/<ชื่อไฟล์> — N3)
|
||||
// (สำเนารายการ extension นี้ต้อง sync กับ SAMPLE_FILE_EXTENSIONS ใน utils/file-kind.ts)
|
||||
// - DELETE row ที่ชี้ /samples/* แต่ไฟล์หายจาก disk (CASCADE ลบ annotation/bookmark ตาม)
|
||||
// รันตอน container start ต่อจาก migrate.mjs และรันมือบนเครื่อง dev ได้ผ่าน npm run seed-samples
|
||||
import fs from "node:fs";
|
||||
|
|
@ -8,6 +9,9 @@ import postgres from "postgres";
|
|||
|
||||
const SAMPLES_DIR = path.join(process.cwd(), "public", "samples");
|
||||
|
||||
// ต้องตรงกับ SAMPLE_FILE_EXTENSIONS ใน utils/file-kind.ts (script .mjs import TS ไม่ได้)
|
||||
const SAMPLE_FILE_EXTENSIONS = [".pdf", ".jpg", ".jpeg", ".png", ".tif", ".tiff"];
|
||||
|
||||
const dbUrl = process.env.DATABASE_URL;
|
||||
if (!dbUrl) throw new Error("ต้องตั้ง DATABASE_URL ก่อนรัน seed-samples");
|
||||
|
||||
|
|
@ -15,7 +19,7 @@ const sql = postgres(dbUrl, { prepare: false, max: 1 });
|
|||
try {
|
||||
const onDisk = fs
|
||||
.readdirSync(SAMPLES_DIR)
|
||||
.filter((f) => f.toLowerCase().endsWith(".pdf"))
|
||||
.filter((f) => SAMPLE_FILE_EXTENSIONS.some((ext) => f.toLowerCase().endsWith(ext)))
|
||||
.map((f) => ({ title: f, pdfUrl: `/samples/${f}` }));
|
||||
const diskUrls = new Set(onDisk.map((f) => f.pdfUrl));
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
pointToPx,
|
||||
rectToPercent,
|
||||
rectToPx,
|
||||
rectsInSweptPath,
|
||||
toPercent,
|
||||
toPx,
|
||||
type Size,
|
||||
|
|
@ -152,3 +153,40 @@ describe("mergeRectsByLine", () => {
|
|||
expect(input).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rectsInSweptPath", () => {
|
||||
// DOMRect ใน node env ไม่มี — function อ่านแค่ x/y/width/height จึงใช้ plain object แทน
|
||||
const R = (x: number, y: number, width: number, height: number) =>
|
||||
({ x, y, width, height }) as DOMRect;
|
||||
// บรรทัดสูง 12px ที่ y=100/120/140 (lineTol = 12×0.6 = 7.2)
|
||||
const L1 = [R(50, 100, 100, 12), R(200, 100, 100, 12)];
|
||||
const L2 = [R(20, 120, 280, 12)];
|
||||
const L3 = [R(50, 140, 100, 12), R(200, 140, 100, 12)];
|
||||
|
||||
it("ลากในบรรทัดเดียว เก็บเฉพาะ rect ที่เส้นทางพาดผ่าน (ตัดซ้าย/ขวาที่ไม่ถึง)", () => {
|
||||
const kept = rectsInSweptPath(L1, { x: 60, y: 106 }, { x: 150, y: 106 });
|
||||
expect(kept).toEqual([L1[0]]); // rect 50–150 พาด 60–150 ✓, rect 200–300 ไม่ถึง
|
||||
});
|
||||
|
||||
it("ลากขึ้นหลายบรรทัด: บรรทัดแรก(บน)เก็บจากจุดจบ→ท้ายบรรทัด, บรรทัดล่างเก็บต้นบรรทัด→จุดเริ่ม, บรรทัดกลางทั้งบรรทัด", () => {
|
||||
const kept = rectsInSweptPath(
|
||||
[...L1, ...L2, ...L3],
|
||||
{ x: 100, y: 146 }, // เริ่มกลางบรรทัดล่าง (y=140)
|
||||
{ x: 150, y: 106 }, // จบกลางบรรทัดบน (y=100)
|
||||
);
|
||||
expect(kept).toEqual([L1[1], ...L2, L3[0]]); // บนเก็บ 200–300 (จาก 150→ท้าย), ล่างเก็บ 50–150 (ต้น→100)
|
||||
});
|
||||
|
||||
it("บรรทัดนอกช่วงแนวตั้งของเส้นทางถูกทิ้ง (กันมาร์กเด้งไปบรรทัดอื่น)", () => {
|
||||
const far = [R(50, 60, 100, 12)]; // เหนือบรรทัดบนเกิน lineTol
|
||||
const kept = rectsInSweptPath([...far, ...L2], { x: 100, y: 126 }, { x: 100, y: 146 });
|
||||
expect(kept).toEqual(L2);
|
||||
});
|
||||
|
||||
it("ไม่ขยับ (≤4px — ดับเบิลคลิกเลือกคำ): เก็บทุก rect ของบรรทัดนั้นโดยไม่ตัดแกน x", () => {
|
||||
// จุดคลิกอยู่บนบรรทัด L1 (cy=106) ขยับนิดเดียว — rect ทั้งคู่ของบรรทัดเก็บครบ
|
||||
// แม้ x ไม่พาดผ่านจุดคลิก (ไม่มีแนวลากให้อิง) ส่วนบรรทัดอื่นนอกแนวตั้งถูกทิ้ง
|
||||
const kept = rectsInSweptPath([...L1, ...L3], { x: 100, y: 106 }, { x: 102, y: 107 });
|
||||
expect(kept).toEqual([...L1]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -57,6 +57,49 @@ export function getPointerPercent(
|
|||
};
|
||||
}
|
||||
|
||||
/** จุดบน viewport (client coords) — ใช้ติดตามเส้นทางลากของ pointer */
|
||||
export type ClientPt = { x: number; y: number };
|
||||
|
||||
/** กรอง clientRects ของ selection ให้เหลือเฉพาะแนวเส้นทางที่ผู้ใช้ลากจริง —
|
||||
* span ของ textLayer ในบาง PDF (เอกสารไทย) เรียงใน DOM ไม่ตรงลำดับบรรทัด และ
|
||||
* anchor/focus ของ DOM selection อาจตกที่ตำแหน่งไม่มีเรขาคณิต (zero-width node)
|
||||
* จึงใช้จุด pointerdown + ตำแหน่ง pointer ล่าสุด (client coords) เป็น ground truth แทน
|
||||
* กฎ: บรรทัดแรกเก็บจากจุดเริ่ม→ท้ายบรรทัด, บรรทัดสุดท้ายเก็บต้นบรรทัด→จุดจบ,
|
||||
* บรรทัดกลางเก็บทั้งบรรทัด, บรรทัดนอกช่วงแนวตั้งของเส้นทางลากทิ้ง (กันมาร์กเกิน/
|
||||
* เด้งไปบรรทัดอื่น) — คลิกไม่ขยับ (ดับเบิลคลิกเลือกคำ/ทริปเปิลคลิกเลือกบรรทัด)
|
||||
* ตัดเฉพาะแกนตั้ง ไม่ตัดแกน x เพราะไม่มีแนวการลากให้อิง
|
||||
* ใช้ทั้ง desktop (native selection ผ่าน selDrag refs) และจอสัมผัส
|
||||
* (useCustomSelection ลากด้วยนิ้ว) */
|
||||
export function rectsInSweptPath(
|
||||
rects: DOMRect[],
|
||||
start: ClientPt,
|
||||
end: ClientPt,
|
||||
): DOMRect[] {
|
||||
const hs = rects.map((r) => r.height).sort((a, b) => a - b);
|
||||
const lineTol = (hs[Math.floor(hs.length / 2)] || 10) * 0.6;
|
||||
const moved = Math.hypot(end.x - start.x, end.y - start.y) > 4;
|
||||
// เรียงจุดตาม visual: บน→ล่าง (บรรทัดเดียวกัน: ซ้าย→ขวา) ให้กฎตรงทุกทิศการลาก
|
||||
let top: ClientPt;
|
||||
let bottom: ClientPt;
|
||||
if (Math.abs(end.y - start.y) < lineTol) {
|
||||
[top, bottom] = start.x <= end.x ? [start, end] : [end, start];
|
||||
} else {
|
||||
[top, bottom] = start.y < end.y ? [start, end] : [end, start];
|
||||
}
|
||||
return rects.filter((r) => {
|
||||
const cy = r.y + r.height / 2;
|
||||
if (cy < top.y - lineTol || cy > bottom.y + lineTol) return false; // บรรทัดนอกเส้นทางลาก
|
||||
if (!moved) return true;
|
||||
const inTop = Math.abs(cy - top.y) <= lineTol;
|
||||
const inBottom = Math.abs(cy - bottom.y) <= lineTol;
|
||||
const crosses = (from: number, to: number) => r.x < to && r.x + r.width > from;
|
||||
if (inTop && inBottom) return crosses(top.x, bottom.x); // ลากในบรรทัดเดียว
|
||||
if (inTop) return crosses(top.x, Infinity); // บรรทัดแรก: จุดเริ่ม → ท้ายบรรทัด
|
||||
if (inBottom) return crosses(-Infinity, bottom.x); // บรรทัดสุดท้าย: ต้นบรรทัด → จุดจบ
|
||||
return true; // บรรทัดกลาง: ครอบทั้งบรรทัด
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* รวบ rects ที่อยู่บรรทัดเดียวกัน (แนวตั้ง overlap กัน = y/height ใกล้กัน)
|
||||
* เหลือ 1 rect ต่อบรรทัด โดย union แกน x ให้ครอบข้อความเดิมทั้งบรรทัด
|
||||
|
|
|
|||
54
utils/file-kind.ts
Normal file
54
utils/file-kind.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// ชนิดไฟล์เอกสาร — derive จาก extension ของ path ที่เก็บใน files.pdf_url (N3)
|
||||
// ไม่มี column kind ใน DB: extension คือ single source of truth (seed idempotent อิง URL)
|
||||
// pure module — ใช้ได้ทั้ง server และ client
|
||||
|
||||
export type FileKind = "pdf" | "image";
|
||||
export type ImageFormat = "jpeg" | "png" | "tiff";
|
||||
|
||||
const IMAGE_EXTENSIONS: Record<string, ImageFormat> = {
|
||||
".jpg": "jpeg",
|
||||
".jpeg": "jpeg",
|
||||
".png": "png",
|
||||
".tif": "tiff",
|
||||
".tiff": "tiff",
|
||||
};
|
||||
|
||||
/** extension (ตัวเล็ก พร้อมจุดนำหน้า) ของ path หลังตัด query string — ไม่มี = "" */
|
||||
function extOf(url: string): string {
|
||||
const clean = url.split(/[?#]/, 1)[0];
|
||||
const slash = Math.max(clean.lastIndexOf("/"), clean.lastIndexOf("\\"));
|
||||
const name = slash >= 0 ? clean.slice(slash + 1) : clean;
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot <= 0 ? "" : name.slice(dot).toLowerCase();
|
||||
}
|
||||
|
||||
export function imageFormatFromUrl(url: string): ImageFormat | null {
|
||||
return IMAGE_EXTENSIONS[extOf(url)] ?? null;
|
||||
}
|
||||
|
||||
/** ไม่รู้จัก extension → "pdf" ตามพฤติกรรมเดิม (ก่อน N3 ทุกไฟล์เปิดเป็น PDF) */
|
||||
export function fileKindFromUrl(url: string): FileKind {
|
||||
if (imageFormatFromUrl(url)) return "image";
|
||||
return "pdf";
|
||||
}
|
||||
|
||||
/** whitelist extension ที่ seed-samples sync จาก public/samples/ (seed .mjs มีสำเนา — sync กัน) */
|
||||
export const SAMPLE_FILE_EXTENSIONS: readonly string[] = [
|
||||
".pdf",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".tif",
|
||||
".tiff",
|
||||
];
|
||||
|
||||
const IMAGE_CONTENT_TYPES: Record<ImageFormat, string> = {
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
tiff: "image/tiff",
|
||||
};
|
||||
|
||||
export function imageContentTypeFromUrl(url: string): string {
|
||||
const format = imageFormatFromUrl(url);
|
||||
return format ? IMAGE_CONTENT_TYPES[format] : "application/octet-stream";
|
||||
}
|
||||
252
utils/image-to-pdf.ts
Normal file
252
utils/image-to-pdf.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
// N3 — สร้าง PDF document ใหม่จากไฟล์รูปภาพ (jpeg/png) สำหรับ export route
|
||||
// ต้นฉบับไม่ถูกแตะ: อ่าน bytes จาก public/ อย่างเดียวแล้ว embed ลง PDF ใหม่ในหน่วยความจำ (invariant #1)
|
||||
// หลังสร้างเสร็จ pipeline วาด annotation/bookmark outline ของ route ใช้ได้ทันที
|
||||
// (rotation=0, page.getSize() = [595, h] ตรง scale contract ฝั่ง viewer)
|
||||
import { degrees, PDFDocument } from "pdf-lib";
|
||||
import { PNG } from "pngjs";
|
||||
import UTIF from "utif2";
|
||||
|
||||
import { imageFormatFromUrl } from "./file-kind";
|
||||
|
||||
/** กว้างหน้า normalize — ต้องเท่ากับ IMAGE_PAGE_WIDTH ใน PDFViewer.tsx
|
||||
* (scale contract: zoom %/พิกัด % ของ annotation ตรงกันทั้งจอและไฟล์ export) */
|
||||
export const IMAGE_PAGE_WIDTH = 595;
|
||||
|
||||
/** อ่าน EXIF orientation (APP1) ของ JPEG → 1–8 (ไม่มี EXIF/อ่านไม่ได้ = 1)
|
||||
* pdf-lib embedJpg ฝัง JPEG ดิบไม่สน EXIF แต่ browser (<img>) ใช้ orientation เป็น default
|
||||
* → ค่า 3/6/8 ต้องหมุนตอน drawImage เอง ไม่งั้นหน้า export สัดส่วนบิดต่างจากที่เห็นบนจอ */
|
||||
export function jpegExifOrientation(bytes: Buffer): number {
|
||||
// ทนไฟล์เสี้ยว: อะไรก็ตามที่อ่านเกิน buffer/ผิดรูปแบบ = ไม่มี EXIF (คืน 1) ห้ามโยนออกไป
|
||||
// ทำให้ export route พัง (code-review: RangeError จาก APP1 ถูกตัดกลางคัน)
|
||||
try {
|
||||
return jpegExifOrientationUnsafe(bytes);
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function jpegExifOrientationUnsafe(bytes: Buffer): number {
|
||||
// เดิน marker ของ JPEG: SOI → ... จนเจอ APP1 (FFE1) ที่ขึ้นต้น "Exif\0\0" (หยุดที่ SOS/EOI)
|
||||
if (bytes[0] !== 0xff || bytes[1] !== 0xd8) return 1;
|
||||
let o = 2;
|
||||
while (o + 4 <= bytes.length) {
|
||||
if (bytes[o] !== 0xff) return 1;
|
||||
if (bytes[o + 1] === 0xff) {
|
||||
o++; // fill bytes ก่อน marker (JPEG spec อนุญาต) — ไม่ข้ามแล้วเลข length เพี้ยน
|
||||
continue;
|
||||
}
|
||||
const marker = bytes[o + 1];
|
||||
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
||||
o += 2; // standalone marker ไม่มี length
|
||||
continue;
|
||||
}
|
||||
if (marker === 0xd9 || marker === 0xda) return 1; // จบ header แล้ว ไม่มี EXIF
|
||||
const len = bytes.readUInt16BE(o + 2);
|
||||
if (len < 2) return 1; // length ไม่สมเหตุสมผล — กันวนลูป
|
||||
if (marker === 0xe1 && len >= 16 && bytes.toString("latin1", o + 4, o + 10) === "Exif\0\0") {
|
||||
const tiff = o + 10;
|
||||
const le = bytes.toString("latin1", tiff, tiff + 2) === "II";
|
||||
const u16 = (p: number) => (le ? bytes.readUInt16LE(p) : bytes.readUInt16BE(p));
|
||||
const u32 = (p: number) => (le ? bytes.readUInt32LE(p) : bytes.readUInt32BE(p));
|
||||
if (u16(tiff + 2) !== 42) return 1;
|
||||
const ifd = tiff + u32(tiff + 4);
|
||||
if (ifd + 2 > bytes.length) return 1;
|
||||
const count = u16(ifd);
|
||||
for (let i = 0; i < count && ifd + 2 + (i + 1) * 12 <= bytes.length; i++) {
|
||||
const entry = ifd + 2 + i * 12;
|
||||
if (u16(entry) === 274) {
|
||||
const value = u16(entry + 8);
|
||||
return value >= 1 && value <= 8 ? value : 1;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
o += 2 + len;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** หมุน/mirror RGBA buffer ตาม EXIF/TIFF orientation ครบทุกค่า 1–8 ตามมาตรฐาน
|
||||
* (3=180°, 6=90°CW, 8=270°CW, 2/4=flip แนวนอน/ตั้ง, 5=transpose, 7=anti-transpose)
|
||||
* ต้องให้ผลตรงกับ rgbaToCanvas ของ utils/tiff-client.ts ฝั่ง viewer เสมอ
|
||||
* (export เพื่อ unit test) */
|
||||
export function rotateRgba(
|
||||
rgba: Uint8Array,
|
||||
w: number,
|
||||
h: number,
|
||||
orientation: number,
|
||||
): { data: Uint8Array; width: number; height: number } {
|
||||
if (orientation < 2 || orientation > 8) return { data: rgba, width: w, height: h };
|
||||
const swap = orientation >= 5;
|
||||
const nw = swap ? h : w;
|
||||
const nh = swap ? w : h;
|
||||
const out = new Uint8Array(rgba.length);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
let dx = x;
|
||||
let dy = y;
|
||||
switch (orientation) {
|
||||
case 2:
|
||||
dx = w - 1 - x;
|
||||
break;
|
||||
case 3:
|
||||
dx = w - 1 - x;
|
||||
dy = h - 1 - y;
|
||||
break;
|
||||
case 4:
|
||||
dy = h - 1 - y;
|
||||
break;
|
||||
case 5:
|
||||
dx = y;
|
||||
dy = x;
|
||||
break;
|
||||
case 6:
|
||||
dx = h - 1 - y;
|
||||
dy = x;
|
||||
break;
|
||||
case 7:
|
||||
dx = h - 1 - y;
|
||||
dy = w - 1 - x;
|
||||
break;
|
||||
case 8:
|
||||
dx = y;
|
||||
dy = w - 1 - x;
|
||||
break;
|
||||
}
|
||||
const src = (y * w + x) * 4;
|
||||
const dst = (dy * nw + dx) * 4;
|
||||
out[dst] = rgba[src];
|
||||
out[dst + 1] = rgba[src + 1];
|
||||
out[dst + 2] = rgba[src + 2];
|
||||
out[dst + 3] = rgba[src + 3];
|
||||
}
|
||||
}
|
||||
return { data: out, width: nw, height: nh };
|
||||
}
|
||||
|
||||
/** JPEG export ทำได้แค่ rotate (pdf-lib ไม่มี mirror ตอน drawImage) → exact สำหรับ
|
||||
* 1/3/6/8 ส่วน mirror 2/4/5/7 (พบน้อยมากในไฟล์สแกน — มักเจอจากกล้องหน้ามือถือ)
|
||||
* ประมาณด้วยการหมุนของคู่ตรงข้าม (5→8, 7→6) หรือแสดงตรง ๆ (2/4) —
|
||||
* known limitation จดใน PLAN-FEAT.md §7 N3 */
|
||||
function approxJpegOrientation(orientation: number): number {
|
||||
if (orientation === 5) return 8;
|
||||
if (orientation === 7) return 6;
|
||||
if (orientation === 2 || orientation === 4) return 1;
|
||||
return orientation;
|
||||
}
|
||||
|
||||
function rgbaToPng(data: Uint8Array, width: number, height: number): Buffer {
|
||||
const png = new PNG({ width, height });
|
||||
Buffer.from(data.buffer, data.byteOffset, data.byteLength).copy(png.data);
|
||||
return PNG.sync.write(png);
|
||||
}
|
||||
|
||||
/** cap ด้านยาว decoded frame ให้เท่าฝั่ง client (MAX_FRAME_DIMENSION ของ tiff-client.ts)
|
||||
* — สแกน 600dpi ~5000×7000 จะกิน RGBA ~140MB ต่อเฟรม คูณจำนวนเฟรมตอน export
|
||||
* downscale แบบ nearest ก็พอเพราะเป็นแค่เรือ่ง guard memory ไม่ใช่คุณภาพหลัก
|
||||
* (annotation เป็น % ของหน้า — ย่อภาพแล้วตำแหน่งไม่กระทบ) */
|
||||
const MAX_TIFF_EXPORT_DIMENSION = 4096;
|
||||
|
||||
function downscaleRgba(
|
||||
data: Uint8Array,
|
||||
w: number,
|
||||
h: number,
|
||||
): { data: Uint8Array; width: number; height: number } {
|
||||
const long = Math.max(w, h);
|
||||
if (long <= MAX_TIFF_EXPORT_DIMENSION) return { data, width: w, height: h };
|
||||
const k = MAX_TIFF_EXPORT_DIMENSION / long;
|
||||
const nw = Math.max(1, Math.round(w * k));
|
||||
const nh = Math.max(1, Math.round(h * k));
|
||||
const out = new Uint8Array(nw * nh * 4);
|
||||
for (let y = 0; y < nh; y++) {
|
||||
const sy = Math.min(h - 1, Math.round(y / k));
|
||||
for (let x = 0; x < nw; x++) {
|
||||
const sx = Math.min(w - 1, Math.round(x / k));
|
||||
const src = (sy * w + sx) * 4;
|
||||
const dst = (y * nw + x) * 4;
|
||||
out[dst] = data[src];
|
||||
out[dst + 1] = data[src + 1];
|
||||
out[dst + 2] = data[src + 2];
|
||||
out[dst + 3] = data[src + 3];
|
||||
}
|
||||
}
|
||||
return { data: out, width: nw, height: nh };
|
||||
}
|
||||
|
||||
/** TIFF decode ทุกเฟรม (IFD) → PNG buffer ต่อเฟรม (TIFF หลายเฟรม = หลายหน้า)
|
||||
* เฟรมที่ decode พัง (เช่น compression ที่ utif2 ไม่รอง) = หน้าขาวสัดส่วนตาม header
|
||||
* เพื่อให้จำนวนหน้า/pageIndex map ตรงกับฝั่ง viewer เสมอ */
|
||||
function tiffFramesToPng(bytes: Buffer): { bytes: Buffer; jpeg: boolean }[] {
|
||||
const ifds = UTIF.decode(bytes);
|
||||
// เอาเฉพาะ IFD ที่เป็นเฟรมรูป — เรียงลำดับ/เงื่อนไขเดียวกับ decodeTiffPages ฝั่ง client
|
||||
const frames = ifds.filter(
|
||||
(ifd) => Array.isArray(ifd.t256) && Array.isArray(ifd.t257),
|
||||
);
|
||||
return frames.map((ifd) => {
|
||||
const headerW = Array.isArray(ifd.t256) ? Number(ifd.t256[0]) || 1 : 1;
|
||||
const headerH = Array.isArray(ifd.t257) ? Number(ifd.t257[0]) || 1 : 1;
|
||||
try {
|
||||
UTIF.decodeImage(bytes, ifd);
|
||||
const rgba = UTIF.toRGBA8(ifd);
|
||||
const orientation = Array.isArray(ifd.t274) ? Number(ifd.t274[0]) || 1 : 1;
|
||||
const scaled = downscaleRgba(rgba, ifd.width, ifd.height);
|
||||
const rotated = rotateRgba(scaled.data, scaled.width, scaled.height, orientation);
|
||||
return { bytes: rgbaToPng(rotated.data, rotated.width, rotated.height), jpeg: false };
|
||||
} catch (err) {
|
||||
console.error("decode เฟรม TIFF ตอน export ไม่ได้ — ใส่หน้าขาวแทน", err);
|
||||
// หน้าขาวจอง buffer ที่ขนาด capped พอ (สัดส่วนเดียวกับ header — หน้า PDF ได้ ratio เดิม)
|
||||
const capped = downscaleRgba(new Uint8Array(0), headerW, headerH);
|
||||
const white = new Uint8Array(capped.width * capped.height * 4).fill(255);
|
||||
return { bytes: rgbaToPng(white, capped.width, capped.height), jpeg: false };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** สร้าง PDF ใหม่ที่มีรูปเต็มหน้าทุกเฟรม (TIFF หลายเฟรม = หลายหน้า)
|
||||
* คืน PDFDocument ให้ export route วาด annotation ต่อด้วย pipeline เดิม */
|
||||
export async function imageDocFromBytes(bytes: Buffer, url: string): Promise<PDFDocument> {
|
||||
const format = imageFormatFromUrl(url);
|
||||
if (!format) throw new Error(`ไฟล์ ${url} ไม่ใช่รูปภาพที่รองรับ`);
|
||||
|
||||
const doc = await PDFDocument.create();
|
||||
const frames: { bytes: Buffer; jpeg: boolean }[] =
|
||||
format === "tiff" ? tiffFramesToPng(bytes) : [{ bytes, jpeg: format === "jpeg" }];
|
||||
// guard เดียวกับ decodeTiffPages ฝั่ง client — PDF 0 หน้าจะทำให้ getPage(-1) throw ทีหลัง
|
||||
if (frames.length === 0) throw new Error("ไม่พบเฟรมรูปในไฟล์ TIFF");
|
||||
|
||||
for (const frame of frames) {
|
||||
const image = frame.jpeg ? await doc.embedJpg(frame.bytes) : await doc.embedPng(frame.bytes);
|
||||
// PNG ไม่มี EXIF; TIFF ถูกหมุนครบทุกค่าตั้งแต่ tiffFramesToPng แล้ว — เหลือ JPEG (approx)
|
||||
const orientation = frame.jpeg ? approxJpegOrientation(jpegExifOrientation(frame.bytes)) : 1;
|
||||
const quarter = orientation >= 5 && orientation <= 8; // หมุน 90° → สลับกว้าง-สูงของภาพที่แสดง
|
||||
|
||||
// ขนาดหน้า: กว้าง 595 เสมอ สูงตามสัดส่วน "ภาพหลังหมุน" (ตรงกับที่แสดงบนจอ)
|
||||
const storedW = image.width;
|
||||
const storedH = image.height;
|
||||
const displayedRatio = quarter ? storedW / storedH : storedH / storedW;
|
||||
const pageW = IMAGE_PAGE_WIDTH;
|
||||
const pageH = pageW * displayedRatio;
|
||||
const page = doc.addPage([pageW, pageH]);
|
||||
|
||||
if (orientation === 6) {
|
||||
// 90°CW: anchor มุมล่างซ้าย วาดแล้วหมุน -90° รอบ anchor (pdf-lib หมุนรอบ x,y —
|
||||
// bbox ที่ได้ต้องครอบหน้าพอดี ตรวจด้วยการคูณ matrix จาก content stream จริง)
|
||||
page.drawImage(image, {
|
||||
x: 0, y: pageH, width: pageH, height: pageW, rotate: degrees(-90),
|
||||
});
|
||||
} else if (orientation === 8) {
|
||||
// 270°CW: anchor มุมล่างขวา วาดแล้วหมุน +90°
|
||||
page.drawImage(image, {
|
||||
x: pageW, y: 0, width: pageH, height: pageW, rotate: degrees(90),
|
||||
});
|
||||
} else if (orientation === 3) {
|
||||
// 180°: anchor มุมบนขวา
|
||||
page.drawImage(image, {
|
||||
x: pageW, y: pageH, width: pageW, height: pageH, rotate: degrees(180),
|
||||
});
|
||||
} else {
|
||||
page.drawImage(image, { x: 0, y: 0, width: pageW, height: pageH });
|
||||
}
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
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