diff --git a/.claude/rules/coordinates.md b/.claude/rules/coordinates.md index b0159b3..cddb3d4 100644 --- a/.claude/rules/coordinates.md +++ b/.claude/rules/coordinates.md @@ -11,7 +11,6 @@ paths: - ทิศทางแปลง: px → % = `÷size×100`, % → px = `×size÷100` — ครบทั้ง point/rect/path - `strokeWidth` เก็บเป็น % ของความกว้างหน้า; `yOffset` เก็บเป็น % ของความสูงหน้า (0–100) - `mergeRectsByLine(rects)`: จัดกลุ่ม rects ที่ y/height ใกล้กัน (บรรทัดเดียวกัน) → union แกน x → คืน 1 rect ต่อบรรทัด — เรียกทุกครั้ง**ก่อนบันทึก HIGHLIGHT** ลง DB -- `rectsInSweptPath(rects, start, end)` (กรอง selection rects ตามเส้นทางลาก): บรรทัดแรก = จุดเริ่ม→ท้ายบรรทัด, บรรทัดสุดท้าย = ต้นบรรทัด→จุดจบ, **บรรทัดกลางเก็บทั้งบรรทัดเฉพาะช่วงคอลัมน์เดียวกับข้อความที่เก็บได้จริง** — ช่วงคอลัมน์ยึดจากขอบซ้าย/ขวาจริงของ rect ที่เก็บได้จากบรรทัดแรก/สุดท้าย (ไม่ใช่พิกัดจุดลากตรง ๆ — ลากชัน/ลาก margin ซ้ายก็ยังเก็บบรรทัดกลางเต็มตาม native) + ขยายริมละ 2×lineTol ให้ทน justify gap ระหว่าง rect (pad อิงความสูงบรรทัด ตาม zoom) — span ประกอบที่ห่างเกิน pad (✓ checkbox คอลัมน์ขวาสุดของฟอร์มราชการ ที่ pdf.js วางไว้ระหว่างบรรทัดข้อความพอดีจน DOM ของ native selection ครอบติดมาด้วย) ถูกตัดทิ้ง (bug report 2026-10-05 + code review ข้อ 1) - InkLayer: SVG **ห้ามมี `viewBox`** (ความหนาเส้นจะบิดเบี้ยวเมื่อ scale แกน x/y ไม่เท่ากัน) — render ระบบ px จริง แปลง % → px ด้วยขนาดจาก `ResizeObserver` แล้ว re-render เมื่อ resize/zoom - พิกัดจาก event ใช้ `getBoundingClientRect()` ของ page element เป็นฐานเสมอ และ clamp ค่า % ให้อยู่ 0–100 - `utils/geometry.ts` (PLAN-FEAT.md F1) — pure functions บนพิกัด %: `annotationBBox` / `translateAnnotationData` / `scaleAnnotationData` / `hitTestPath` — ก่อนส่ง data ที่แปลงแล้วเข้า `updateAnnotation` ต้อง **clamp offset/scale ให้ bbox ทั้งกลุ่มอยู่ใน 0–100** เพราะ validator ปฏิเสธค่าติดลบ — **ยกเว้น STAMP เอียง (มี `rotation`): `position` เป็นมุมกรอบ*ก่อนหมุน* ซึ่งไม่ถูกครอบโดยกรอบจริงบนจอ — เกณฑ์ความถูกต้อง (ทั้ง clamp ฝั่ง client และ validator) ใช้ **AABB ของกรอบหมุน** (`stampRect`/`rotatedRectAABB`) ตัวเดียวกันทั้งสองฝั่ง ไม่ใช่ position ตรง ๆ — ใช้คนละเกณฑ์แล้วลากถึงขอบหน้าแล้ว commit โดนปฏิเสธ เด้งกลับ (code review 2026-09-28) · ตอนตั้งมุมใหม่ (patch/ลากหมุน) และตอน patch ที่เปลี่ยนขนาดกรอบของตรายางเอียง (`stampScale`/`label` เมื่อ rotation ≠ 0) ต้องเรียก `clampStampIntoPage` เลื่อน position กลับเข้าหน้าทันที (code review 2026-09-29) · validator ฝั่ง server ยึด `stampLayoutEstimate` ขณะ client clamp ยึด measure จริงของ browser — สองตัววัดนี้คลาดกันตามฟอนต์ของเครื่อง validator จึงผ่อน tolerance ให้ AABB ติดลบได้ไม่เกิน 2% ของหน้า ไม่ใช่ปฏิเสธทันที (code review 2026-09-29)** diff --git a/.claude/rules/data-layer.md b/.claude/rules/data-layer.md index a6fb346..0f258aa 100644 --- a/.claude/rules/data-layer.md +++ b/.claude/rules/data-layer.md @@ -20,6 +20,6 @@ paths: - ตาราง `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 ก่อนเขียน — ชื่อไฟล์ต้องผ่าน `decodeUploadFilename` ของ `utils/upload-name.ts` ก่อนใช้ (ทั้ง extension และ title): undici ถอด `Content-Disposition` เป็น latin1 ทำชื่อ UTF-8 เพี้ยน (ชื่อไทยกลายเป็น "คำ…" — 2026-10-05) — บันทึกเป็นไฟล์**ใหม่** `public/uploads/.` เท่านั้น (ชื่อจาก randomUUID — ห้ามใช้ชื่อผู้ใช้ตกแต่ง path), insert row (รวม `type_id` จากช่องกลุ่มทางเลือกของฟอร์ม — 2026-09-23) แล้ว `revalidatePath("/")` — ไม่ publish SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE); seed-samples จัดการเฉพาะ `/samples/%` ไม่แตะ `/uploads/` +- Server Action `uploadFile` (`app/actions/files.ts`, N4): validate extension (whitelist `SAMPLE_FILE_EXTENSIONS`) + ≤ 100MB ก่อนเขียน — บันทึกเป็นไฟล์**ใหม่** `public/uploads/.` เท่านั้น (ชื่อจาก randomUUID — ห้ามใช้ชื่อผู้ใช้ตกแต่ง path), insert row (รวม `type_id` จากช่องกลุ่มทางเลือกของฟอร์ม — 2026-09-23) แล้ว `revalidatePath("/")` — ไม่ publish SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE); seed-samples จัดการเฉพาะ `/samples/%` ไม่แตะ `/uploads/` - `files.type_id` (N9 — รหัสกลุ่มเอกสาร จากลิงก์ e-Court `?typeId=` หรือช่อง "กลุ่ม typeId" ของฟอร์มอัปโหลด N4): **เขียนสองที่** — (1) `registerExternalFile` ของ N8 (insert ครั้งแรก + update เมื่อลิงก์รอบใหม่ส่ง `typeId` มา — ลิงก์ไม่ส่ง = คงเดิม ห้ามเคลียร์) (2) `uploadFile` ของ N4 ช่องทางเลือกในฟอร์มอัปโหลด — insert ตอนสร้าง row ใหม่เท่านั้น ไม่มีหน้าทางแก้กลุ่มของไฟล์เดิม; normalize ผ่าน `normDocumentGroup` ของ `utils/external-file.ts` ที่เดียว (trim → ว่าง = NULL, cap 200 — responsive audit 2026-09-24 รวมจากเดิมที่เป็นสองสำเนา mirroring กัน) — N9 อ่านอย่างเดียว (หน้า `/` จัด section + `/groups/[typeId]`) ไม่มี Server Action/SSE ใหม่; กลุ่ม = ค่า distinct ของ `type_id` ณ ตอน query — ไม่มีตารางกลุ่ม - API ภายนอก (N10 — `app/api/external/documents` เส้นเดียว **POST + JSON body** `{fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}}` — ส่งทั้งคู่ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข): **read-only ทั้งหมด** — ไม่เขียน DB ไม่ publish SSE event ไม่ `revalidatePath` (snapshot ณ ตอนเรียก ต่าง context กับ Server Action ใน invariant #4); auth ผ่าน `requireExternalApiKey` ของ `lib/external-api-auth.ts` เป็นอย่างแรกก่อนแตะ DB เสมอ (header `x-api-key` เทียบ env `EXTERNAL_API_KEY` แบบ timing-safe) — **env ว่าง/ไม่ตั้ง = fail closed 401 ทุก request** ไม่มี mode เปิดฟรี; ผู้เรียกคือ backend e-Court (server-to-server) จึงไม่ใส่ CORS headers; validation ของ body (`parseQueryBody`) + การ query/mapper (`loadDocumentBundles` — bulk `inArray` ทุกไฟล์ในชุด query เดียว ไม่ยิงต่อไฟล์) รวมอยู่ `lib/external-api.ts` จุดเดียว ห้ามแยกสำเนา; route ครอบ try/catch ทุก DB call — query ล้ม = log ฝั่ง server + **500 JSON ตาม contract** ของ endpoint (ไม่ปล่อยเป็น 500 ของ framework); `lib/external-api-auth.ts` ห้าม import module ที่ลาก `@/db` มา (unit test ต้องรันได้ไม่ต้องตั้ง DATABASE_URL) diff --git a/.claude/rules/pdf-viewer.md b/.claude/rules/pdf-viewer.md index 9c51374..f7b33c4 100644 --- a/.claude/rules/pdf-viewer.md +++ b/.claude/rules/pdf-viewer.md @@ -13,7 +13,7 @@ paths: - Canvas + TextLayer re-render เมื่อ dep `[doc, pageNumber, scale]` เปลี่ยน + cancel render task ตอน unmount (กัน memory leak) - Selection คลุมดำ: รับเฉพาะ rects ใน page เดียว (`commonAncestorContainer.closest('[data-page]')`) และกรอง rect กว้าง 0 ทิ้ง - **PDF ไทย cmap ของฟอนต์เพี้น (คำสั่งศาล) → โหลดผ่าน `/api/pdf?fileId=` เสมอ** (ไม่ใช่ `file.pdfUrl` ตรง ๆ): `lib/pdf-actual-text.ts` เรียน glyph-code → ข้อความจาก `/ActualText` ใน marked content ของ content stream แล้วเขียน ToUnicode CMap ใหม่**บนสำเนาในหน่วยความจำ** (ไฟล์ใน public/ ไม่ถูกแตะ — invariant #1) ทำให้ textLayer มีข้อความถูกตั้งแต่ต้น (select/copy/ค้นหา/ชื่อ bookmark ถูกทั้งหมด) — PDF ปกติไม่มี ActualText = ได้ bytes เดิมคืน ไม่มี cost; fail ก็ตัวเอง fetch ไฟล์ดิบตามเดิม (fallback ใน PDFViewer) — **fetch/`img.src` ทุกตัว (รวม fallback และรูป N3 ที่โหลด static `pdfUrl` ตรง ๆ) ต้องผ่าน `withBasePath()`** (invariant #12) -- **ข้อความจาก selection ห้ามใช้ `selection.toString()` ตรง ๆ** — แม้ซ่อม ToUnicode แล้ว textLayer ไทยบางไฟล์ยังเรียงใน DOM ไม่ตรงลำดับบนจอ มี ctrl/space ขยะหลงเหลือในส่วนที่ ActualText ไม่ครอบ และ DOM range ครอบ span เกินบริเวณที่ลาก: ใช้ `selectionTextWithin(keptRects, range)` (เดินทีละตัวอักษร เช็ค rect ตกใน rects ที่กรองแล้ว เรียงตามตำแหน่งบนจอ — แพง ใช้เฉพาะ mouseup ที่ `readTextSelection(pageIndex, { withText: true })` กับ event `copy` (กรองด้วยเส้นทางลากล่าสุดจาก `lastSweepRef`) เท่านั้น ไม่ใช้ใน live preview) — **hit-test ของแต่ละตัวอักษรต้องผ่านประตู 2 ชั้น: จุดกึ่งกลางแนวตั้งของตัวอักษรอยู่ในแถบของ rect (เกณฑ์หลักแยกบรรทัด) แล้วเลือก rect ที่ซ้อนทับแนวตั้งมากที่สุด ไม่ใช่ตัวแรกที่เจอ; แยกบรรทัดระหว่างกลุ่มด้วย "rect ไม่ซ้อนทับแนวตั้งกัน = คนละบรรทัด" (แทรก `\n`) ไม่ใช่ heuristic เทียบ midY กับความสูง** — บรรทัดไทยคุมชิด (ห่าง ~16px แต่ rect สูง ~16px เพราะครอบสระบน-ล่าง) ตัวอักษรบรรทัดถัดไป (โดยเฉพาะตัวที่สระบนดันกรอบขึ้น) เหลื่อมกับ rect บรรทัดก่อนหน้า**จริง 1-3px** การเช็คแค่การเหลื่อมไม่พอ: DOM range ของ pdf.js มักครอบ span บรรทัดถัดไปด้วย (ลากจบท้ายบรรทัด/เลยขอบขวา แล้ว caret ปลายทางหลุดเข้า span ถัดไป — `range.toString()` ได้ข้อความหลายบรรทัดทั้งที่ลากบรรทัดเดียว) ตัวอักษรเหล่านั้นเคยถูกดูดเข้ากลุ่มเดียวกันแล้วเรียงรวมด้วย x จนสองบรรทัดสอดแทรกตัวต่อตัวเป็น mojibake ในชื่อ bookmark/copy (bug report 2026-10-05: "แบบบันทึกผลการทดลอง…" กลายเป็น "แบบบันทึกผ(สำลกหารัรบท…") — จุดกึ่งกลางของตัวอักษรบรรทัดถัดไปอยู่นอกแถบเสมอ (เหลื่อมแค่ขอบ) จึงตัดทิ้งให้เหลือเฉพาะข้อความที่เห็นบนจอจริง ปิดท้ายด้วย `sanitizePdfText` (`utils/annotation-display.ts` — ตัด ctrl ขยะ + กลุ่ม space คู่ขึ้นไป มี unit tests) ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด +- **ข้อความจาก selection ห้ามใช้ `selection.toString()` ตรง ๆ** — แม้ซ่อม ToUnicode แล้ว textLayer ไทยบางไฟล์ยังเรียงใน DOM ไม่ตรงลำดับบนจอ มี ctrl/space ขยะหลงเหลือในส่วนที่ ActualText ไม่ครอบ และ DOM range ครอบ span เกินบริเวณที่ลาก: ใช้ `selectionTextWithin(keptRects, range)` (เดินทีละตัวอักษร เช็ค rect ตกใน rects ที่กรองแล้ว เรียงตามตำแหน่งบนจอ — แพง ใช้เฉพาะ mouseup ที่ `readTextSelection(pageIndex, { withText: true })` กับ event `copy` (กรองด้วยเส้นทางลากล่าสุดจาก `lastSweepRef`) เท่านั้น ไม่ใช้ใน live preview) ปิดท้ายด้วย `sanitizePdfText` (`utils/annotation-display.ts` — ตัด ctrl ขยะ + กลุ่ม space คู่ขึ้นไป มี unit tests) ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด - คีย์ลัด H/D/C/B ต้อง `e.preventDefault()` เสมอ — B เปิด popup bookmark ที่ input `autoFocus` ทันทีใน keydown เดียวกัน ถ้าไม่กัน browser พิมพ์ "b" ตามลงในช่องชื่อ — **จับคู่ด้วย `e.code` (ตำแหน่งกายภาพของปุ่ม — map `TOOL_SHORTCUTS` ใน toolbar-tools.ts + `KeyB`/`KeyZ` ตรง ๆ) ไม่ใช่ `e.key` (2026-09-29, PLAN-TOOLBAR #1): ผู้ใช้ศาลส่วนใหญ่สลับ keyboard เป็น layout ไทย ซึ่งปุ่มเดียวกันให้ e.key เป็น "ภ"/"ว"/"แ"/"น" — เช็ค e.key ทำให้คีย์ลัดทั้งชุด (รวม Ctrl+Z undo/redo) ใช้ไม่ได้เลยบน layout ไทย** - `DEL`/`Backspace` ลบ annotation ที่เลือกอยู่ (`selectedId`) — เฉพาะรายการของตัวเอง (เงื่อนไขเดียวกับปุ่มลบในแผง selection), ข้ามเมื่อ focus อยู่ใน form (`inForm` เดียวกับคีย์ลัดอื่น — พิมพ์ใน composer/textbox ต้องไม่โดนลบ) - Page Virtualization: `IntersectionObserver` render เฉพาะหน้าใน viewport ± 1 หน้า; หน้าพ้นจอ = placeholder `div` ขนาดจริง (จาก `pdfPage.getViewport()` ทุกหน้า ครั้งเดียวตอนโหลด) + unmount canvas/TextLayer/InkLayer คืน memory — annotation ของหน้าที่ไม่ render ยังอยู่ใน state diff --git a/CLAUDE.md b/CLAUDE.md index d5b2f9f..fa7662b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,6 @@ utils/user.ts ← parse/build ค่า cookie oacCase → OacUser {user utils/external-file.ts ← N8 validation ลิงก์ไฟล์รีโมต (allowlist "admincourt.go.th" + env EXTERNAL_FILE_HOSTS, suffix match, https เท่านั้น ห้าม user:pass@) + titleFromExternalUrl/externalIdFor/isHttpUrl + normDocumentGroup (N9 normalize รหัสกลุ่ม — ใช้ร่วม N8/N4, pure) utils/base-path.ts ← withBasePath() เติม basePath /docedit/note ให้ URL ฝั่ง browser ที่ Next ไม่เติมให้เอง (ค่าจาก next.config.mjs ผ่าน env — invariant #12) utils/file-kind.ts ← derive ชนิดไฟล์ pdf/image จาก extension ของ path (N3 — ใช้ร่วม viewer/export/หน้ารายการ/seed) -utils/upload-name.ts ← N4 ถอดรหัสชื่อไฟล์จาก FormData ของ Server Action กลับเป็น UTF-8 ก่อนใช้ (undici ถอด header Content-Disposition เป็น latin1 ทำชื่อไทยเพี้ยน "คำ…" — encode กลับ byte แล้วถอด UTF-8; guard: ไม่มีตัว U+0080–U+00FF = ไม่แตะ, ถอดแล้วได้ U+FFFD = เก็บของเดิม, 2026-10-05) 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 diff --git a/app/actions/files.ts b/app/actions/files.ts index 72d63b9..40b013d 100644 --- a/app/actions/files.ts +++ b/app/actions/files.ts @@ -13,7 +13,6 @@ import { db } from "@/db"; import { files } from "@/db/schema"; import { normDocumentGroup } from "@/utils/external-file"; import { SAMPLE_FILE_EXTENSIONS } from "@/utils/file-kind"; -import { decodeUploadFilename } from "@/utils/upload-name"; const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB (ตาม bodySizeLimit ใน next.config.mjs) const UPLOAD_DIR = path.join(process.cwd(), "public", "uploads"); @@ -68,10 +67,7 @@ export async function uploadFile(formData: FormData): Promise if (file.size === 0) { return { ok: false, error: "ไฟล์ว่าง (0 ไบต์) — เลือกไฟล์ที่สมบูรณ์" }; } - // ชื่อไฟล์ที่ browser ส่งมาโดน undici ถอดรหัสเป็น latin1 (ชื่อไทยเพี้ยน) — - // ถอดกลับก่อนใช้ทั้งตรวจ extension และตั้ง title - const fileName = decodeUploadFilename(file.name); - const ext = allowedExt(fileName); + const ext = allowedExt(file.name); if (!ext) { return { ok: false, @@ -97,7 +93,7 @@ export async function uploadFile(formData: FormData): Promise const diskPath = path.join(UPLOAD_DIR, diskName); await writeFile(diskPath, bytes); - const title = sanitizeTitle(fileName); + const title = sanitizeTitle(file.name); const pdfUrl = `/uploads/${diskName}`; const typeId = normDocumentGroup(formData.get("typeId")); try { diff --git a/components/pdf/PDFViewer.tsx b/components/pdf/PDFViewer.tsx index b6d9c30..f1dd062 100644 --- a/components/pdf/PDFViewer.tsx +++ b/components/pdf/PDFViewer.tsx @@ -311,29 +311,13 @@ function selectionTextWithin(kept: DOMRect[], range: Range): string { const r = probe.getClientRects()[0]; let hit = -1; if (r !== undefined) { - // สองเงื่อนไขในการนับตัวอักษรเข้ากลุ่ม rect ของบรรทัด: (1) จุดกึ่งกลาง - // แนวตั้งของตัวอักษรต้องอยู่ในแถบของ rect (2) เลือก rect ที่ซ้อนทับแนวตั้ง - // มากที่สุด ไม่ใช่ตัวแรกที่เจอ — บรรทัดไทยคุมชิด (ห่าง ~16px แต่ rect สูง - // ~16px เพราะครอบสระบน-ล่าง) ตัวอักษรของบรรทัดถัดไป (โดยเฉพาะตัวที่มีสระ - // บนดันกรอบขึ้น) เหลื่อมกับ rect ของบรรทัดก่อนหน้าจริง ๆ 1-3px การเช็คแค่ - // การเหลื่อมจึงไม่พอ: DOM range ของ pdf.js มักครอบ span บรรทัดถัดไปด้วย - // (ลากจบท้ายบรรทัดแล้ว caret ปลายทางหลุดเข้า span ถัดไป) เดินถึงตัวอักษร - // ของบรรทัดนั้นแล้วดูดเข้ากลุ่มเดียวกัน โดนเรียงรวมด้วย x จนสองบรรทัดสอด - // แทรกตัวต่อตัวเป็น mojibake — จุดกึ่งกลางของตัวอักษรบรรทัดถัดไปยังอยู่นอก - // แถบเสมอ (เหลื่อมแค่ขอบ) เป็นเกณฑ์แยกบรรทัดที่เด็ดกว่า (bug report 2026-10-05) - const cy = r.top + r.height / 2; - let best = 0; - for (let ki = 0; ki < kept.length; ki++) { - const k = kept[ki]; - if (r.left >= k.right + eps || r.right <= k.left - eps) continue; - if (cy < k.top - eps || cy > k.bottom + eps) continue; - const score = - Math.min(r.bottom, k.bottom + eps) - Math.max(r.top, k.top - eps); - if (score > best) { - best = score; - hit = ki; - } - } + hit = kept.findIndex( + (k) => + r.left < k.right + eps && + r.right > k.left - eps && + r.top < k.bottom + eps && + r.bottom > k.top - eps, + ); } // combining mark / ตัวไม่มีความกว้าง = ผูกกับตัวก่อนหน้า ตาม rect+x ของตัวนั้น const sticky = prevRect >= 0 && (isThaiCombiningMark(ch) || !r || r.width === 0); @@ -359,13 +343,9 @@ function selectionTextWithin(kept: DOMRect[], range: Range): string { const rectOrder = Array.from(new Set(entries.map((en) => en.rect))).sort((a, b) => { const ra = kept[a]; const rb = kept[b]; - // ใช้เกณฑ์เดียวกับ sameLine ด้านล่าง: rect ไม่ซ้อนทับแนวตั้งกัน = คนละบรรทัด - // เรียงบน→ล่าง, ซ้อนกัน = บรรทัดเดียวกันเรียงซ้าย→ขวา — เดิมเทียบ |midY| กับ - // ความสูงซึ่งตัดสินผิดกับบรรทัดไทยคุมชิด (ห่าง 16px สูง 16px = "บรรทัดเดียวกัน") - // จนย่อหน้าที่บรรทัดแรกเยื้องเข้า (left มากกว่าบรรทัดถัดไป) โดนเรียงตาม left - // กลางเป็นข้อความสลับลำดับบรรทัด (code review 2026-10-05) - const vOv = Math.min(ra.bottom, rb.bottom) - Math.max(ra.top, rb.top); - if (vOv <= eps) return midY(ra) - midY(rb); + if (Math.abs(midY(ra) - midY(rb)) > Math.max(ra.height, rb.height)) { + return midY(ra) - midY(rb); + } return ra.left - rb.left; }); let out = ""; @@ -374,11 +354,7 @@ function selectionTextWithin(kept: DOMRect[], range: Range): string { if (prev >= 0) { const a = kept[prev]; const b = kept[ri]; - // คนละบรรทัด = rect ไม่ซ้อนทับแนวตั้งกัน (แค่ชิด/แตะกัน) — เดิมใช้เทียบ midY - // กับความสูง ซึ่งตัดสินผิดกับบรรทัดไทยคุมชิด (ห่าง 16px, สูง 16px: 16 ≤ 16 - // = "บรรทัดเดียวกัน") จนไม่มี \n คั่น (และช่วยให้โดนจัดกลุ่มรวมกันในตอน hit) - const vOverlap = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top); - const sameLine = vOverlap > eps; + const sameLine = Math.abs(midY(a) - midY(b)) <= Math.max(a.height, b.height); if (!sameLine) out += "\n"; // span คนละบรรทัด = ขึ้นบรรทัดใหม่เหมือน toString() } out += entries diff --git a/tests/utils/coordinates.test.ts b/tests/utils/coordinates.test.ts index 2fe6ac7..b2cbdc2 100644 --- a/tests/utils/coordinates.test.ts +++ b/tests/utils/coordinates.test.ts @@ -189,48 +189,6 @@ describe("rectsInSweptPath", () => { const kept = rectsInSweptPath([...L1, ...L3], { x: 100, y: 106 }, { x: 102, y: 107 }); expect(kept).toEqual([...L1]); }); - - it("✓ checkbox คอลัมน์ขวาที่ลอยระหว่างบรรทัดข้อความ (บรรทัดกลาง) ไม่ติดมา (2026-10-05)", () => { - // ฟอร์มราชการ: span ✓ วางระหว่างบรรทัดข้อความพอดี DOM ของ native selection ครอบมัน - // ติดมาด้วย — แต่ rect ลอยคนละคอลัมน์กับช่วง x ที่ลาก ต้องถูกตัดทิ้ง - const A = R(50, 100, 100, 12); // บรรทัดบน cy=106 - const C = R(50, 140, 100, 12); // บรรทัดล่าง cy=146 - const check = R(900, 110, 12, 12); // cy=116 = บรรทัดกลางของ corridor - const kept = rectsInSweptPath( - [A, check, C], - { x: 60, y: 106 }, - { x: 140, y: 146 }, - ); - expect(kept).toEqual([A, C]); // ไม่มี check - // ฝั่งตรงข้าม: rect กลางที่ซ้อนช่วง x ที่ลาก (คอลัมน์เดียวกัน) ยังเก็บครบ - const mid = R(80, 120, 120, 12); - expect(rectsInSweptPath([A, mid, C], { x: 60, y: 106 }, { x: 140, y: 146 })).toEqual([ - A, - mid, - C, - ]); - }); - - it("ลากชัน ๆ ผ่านบรรทัดกลางที่ justify จนแตกหลาย rect: ยังเก็บครบทั้งบรรทัด (code review 2026-10-05)", () => { - // บรรทัดแรกเยื้องสั้น [100-300], บรรทัดกลางยาวแตก 2 ก้อน [50-300]+[310-750] - // (ช่องว่าง 10px จาก justify), บรรทัดล่าง [50-750] — ลากจาก (120,106) ลง (150,146) - // เกือบตั้ง: ต้องได้บรรทัดกลางทั้งสองก้อนตามพฤติกรรม native (ไม่ใช่เหลือก้อนซ้าย) - const T = R(100, 100, 200, 12); // บรรทัดแรก - const M1 = R(50, 120, 250, 12); // บรรทัดกลางก้อนซ้าย - const M2 = R(310, 120, 440, 12); // บรรทัดกลางก้อนขวา (justify gap 10px) - const B = R(50, 140, 700, 12); // บรรทัดล่าง - const kept = rectsInSweptPath([T, M1, M2, B], { x: 120, y: 106 }, { x: 150, y: 146 }); - expect(kept).toEqual([T, M1, M2, B]); - }); - - it("ลากใน margin ซ้ายของบล็อกข้อความ: บรรทัดกลางยังเก็บเต็มบรรทัด (code review 2026-10-05)", () => { - // จุดลาก x=20 อยู่หลังขอบซ้ายข้อความ (50) — บรรทัดกลางต้องไม่หายทั้งบรรทัด - const A = R(50, 100, 100, 12); - const C = R(50, 140, 100, 12); - const mid = R(50, 120, 120, 12); - const kept = rectsInSweptPath([A, mid, C], { x: 20, y: 106 }, { x: 22, y: 146 }); - expect(kept).toContain(mid); - }); }); describe("rectsInSweptPath — snap จุดเข้าบรรทัดใกล้สุดก่อนตัดสิน (A3 2026-09-28)", () => { diff --git a/tests/utils/upload-name.test.ts b/tests/utils/upload-name.test.ts deleted file mode 100644 index 2f0b9ab..0000000 --- a/tests/utils/upload-name.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Buffer } from "node:buffer"; -import { describe, expect, it } from "vitest"; - -import { decodeUploadFilename } from "@/utils/upload-name"; - -/** จำลองสิ่งที่ undici ทำกับชื่อไฟล์: ถอดรหัส UTF-8 bytes เป็น latin1 (mojibake) */ -function mojibake(name: string): string { - return Buffer.from(name, "utf8").toString("latin1"); -} - -describe("decodeUploadFilename", () => { - it("ชื่อไทยที่ undici ถอดเป็น latin1 → คืนชื่อเดิม", () => { - expect(decodeUploadFilename(mojibake("คำร้องทดสอบอัปโหลด.pdf"))).toBe( - "คำร้องทดสอบอัปโหลด.pdf", - ); - }); - - it("ชื่อไทยผสมตัวเลข/วงเล็บ/ช่องว่าง", () => { - expect(decodeUploadFilename(mojibake("คดีดำที่ 1234 (สำเนา).pdf"))).toBe( - "คดีดำที่ 1234 (สำเนา).pdf", - ); - }); - - it("ชื่ออังกฤษล้วนไม่เปลี่ยน (รอบ latin1⇄utf8 เป็น identity)", () => { - expect(decodeUploadFilename("scan-2026_final.PDF")).toBe("scan-2026_final.PDF"); - }); - - it("ชื่อที่ส่งมาถูกต้องอยู่แล้ว (ไทย ≥ U+0E00 ไม่มีตัวช่วง latin1) ไม่ถูกแตะ", () => { - expect(decodeUploadFilename("คำร้อง.pdf")).toBe("คำร้อง.pdf"); - }); - - it("ชื่อ latin1 จริง (byte เดี่ยวประกอบเป็น UTF-8 ไม่ได้) เก็บของเดิม", () => { - // "café" ที่ sender เข้ารหัส latin1 จริง: é = byte 0xE9 เดี่ยว → ถอด UTF-8 ได้ U+FFFD - expect(decodeUploadFilename("café.pdf")).toBe("café.pdf"); - }); -}); diff --git a/utils/coordinates.ts b/utils/coordinates.ts index 0c8679d..db5f278 100644 --- a/utils/coordinates.ts +++ b/utils/coordinates.ts @@ -65,11 +65,9 @@ export type ClientPt = { x: number; y: number }; * anchor/focus ของ DOM selection อาจตกที่ตำแหน่งไม่มีเรขาคณิต (zero-width node) * จึงใช้จุด pointerdown + ตำแหน่ง pointer ล่าสุด (client coords) เป็น ground truth แทน * กฎ: บรรทัดแรกเก็บจากจุดเริ่ม→ท้ายบรรทัด, บรรทัดสุดท้ายเก็บต้นบรรทัด→จุดจบ, - * บรรทัดกลางเก็บทั้งบรรทัดเฉพาะช่วงคอลัมน์เดียวกับข้อความที่เก็บได้จริง (ตัด span - * ประกอบที่ลอยคนละคอลัมน์ เช่น ✓ checkbox ขอบขวาของฟอร์ม — bug report 2026-10-05), - * บรรทัดนอกช่วงแนวตั้งของเส้นทางลากทิ้ง (กันมาร์กเกิน/เด้งไปบรรทัดอื่น) — คลิกไม่ขยับ - * (ดับเบิลคลิกเลือกคำ/ทริปเปิลคลิกเลือกบรรทัด) ตัดเฉพาะแกนตั้งเป็นหลัก แกน x ตัด - * เฉพาะบรรทัดกลางที่ลอยคนละคอลัมน์กับที่ลาก + * บรรทัดกลางเก็บทั้งบรรทัด, บรรทัดนอกช่วงแนวตั้งของเส้นทางลากทิ้ง (กันมาร์กเกิน/ + * เด้งไปบรรทัดอื่น) — คลิกไม่ขยับ (ดับเบิลคลิกเลือกคำ/ทริปเปิลคลิกเลือกบรรทัด) + * ตัดเฉพาะแกนตั้ง ไม่ตัดแกน x เพราะไม่มีแนวการลากให้อิง * ใช้ทั้ง desktop (native selection ผ่าน selDrag refs) และจอสัมผัส * (useCustomSelection ลากด้วยนิ้ว) * @@ -110,46 +108,17 @@ export function rectsInSweptPath( bottomY = sy; } - const crosses = (r: DOMRect, from: number, to: number) => - r.x < to && r.x + r.width > from; - const cyOf = (r: DOMRect) => r.y + r.height / 2; - - // pass 1: บรรทัดแรก/สุดท้ายตัดตามจุดปลายของการลาก + จดขอบซ้าย/ขวาจริงของสิ่งที่เก็บได้ - // (บรรทัดกลางเก็บไว้ตัดสินใน pass 2) - const firstPass: (boolean | null)[] = rects.map((r) => { - const cy = cyOf(r); + return rects.filter((r) => { + const cy = r.y + r.height / 2; if (cy < topY - lineTol || cy > bottomY + lineTol) return false; // บรรทัดนอกเส้นทางลาก if (!moved) return true; const inTop = Math.abs(cy - topY) <= lineTol; const inBottom = Math.abs(cy - bottomY) <= lineTol; - if (inTop && inBottom) return crosses(r, top.x, bottom.x); // ลากในบรรทัดเดียว - if (inTop) return crosses(r, top.x, Infinity); // บรรทัดแรก: จุดเริ่ม → ท้ายบรรทัด - if (inBottom) return crosses(r, -Infinity, bottom.x); // บรรทัดสุดท้าย: ต้นบรรทัด → จุดจบ - return null; // บรรทัดกลาง - }); - // pass 2: บรรทัดกลาง = ครอบทั้งบรรทัด ยกเว้น rect ที่ลอยคนละคอลัมน์ — ช่วงคอลัมน์ - // ยึดจาก**ขอบจริงของ rect ที่เก็บได้จากบรรทัดแรก/สุดท้าย** (ไม่ใช่พิกัดจุดลากตรง ๆ: - // ลากชัน ๆ หรือลากใน margin ซ้ายก็ยังเก็บบรรทัดกลางเต็มบรรทัดตามพฤติกรรม selection - // แบบ native) + ขยายริมทั้งสองข้างด้วย 2×lineTol ให้ทนช่องว่างระหว่าง rect ของข้อความ - // ที่ justify จนแตกหลายก้อน (pad อิงความสูงบรรทัดจึงตาม zoom อัตโนมัติ) — span ประกอบ - // ที่ห่างจากก้อนข้อความเกิน pad (✓ checkbox คอลัมน์ขวาสุดของฟอร์มราชการ ที่ pdf.js - // วางไว้ระหว่างบรรทัดข้อความพอดีจน DOM ของ native selection ครอบติดมาด้วย) ยังถูกตัด - // (bug report 2026-10-05 + code review ข้อ 1) - let rightEdge = -Infinity; - let leftEdge = Infinity; - rects.forEach((r, i) => { - if (firstPass[i] !== true) return; - const cy = cyOf(r); - if (Math.abs(cy - topY) <= lineTol) rightEdge = Math.max(rightEdge, r.x + r.width); - if (Math.abs(cy - bottomY) <= lineTol) leftEdge = Math.min(leftEdge, r.x); - }); - if (rightEdge === -Infinity) rightEdge = Math.max(top.x, bottom.x); - if (leftEdge === Infinity) leftEdge = Math.min(top.x, bottom.x); - const pad = lineTol * 2; - return rects.filter((r, i) => { - const v = firstPass[i]; - if (v === null) return crosses(r, leftEdge - pad, rightEdge + pad); // บรรทัดกลาง - return v; + 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; // บรรทัดกลาง: ครอบทั้งบรรทัด }); } diff --git a/utils/upload-name.ts b/utils/upload-name.ts deleted file mode 100644 index f503a5d..0000000 --- a/utils/upload-name.ts +++ /dev/null @@ -1,16 +0,0 @@ -// N4 — ถอดรหัสชื่อไฟล์ที่ FormData ของ Server Action ส่งมา -// browser ใส่ชื่อไฟล์ (UTF-8) ลง header Content-Disposition ของ multipart body ตรง ๆ -// แต่ undici ของ Node (ตัว parse request.formData() ที่ Next.js ใช้) ถอดรหัส header -// เป็น latin1 ทำให้ชื่อที่ไม่ใช่ ASCII เพี้ยน ("คำร้อง.pdf" → "คำร้อง.pdf") -// — เข้ารหัสกลับเป็น byte แล้วถอดด้วย UTF-8 จะได้ชื่อเดิมคืน -// guard 2 ชั้นกันแตะชื่อที่ถูกต้องอยู่แล้ว: ชื่อที่ undici ถอดมามีแต่ตัวอักษร -// ช่วง U+0000–U+00FF เท่านั้น ดังนั้นไม่มีตัวไหนใน U+0080–U+00FF เลย = ASCII ล้วนหรือ -// ชื่อที่ส่งมาถูกต้อง (ไทยอยู่ U+0E00+) = ไม่แต้อยู่เดิม / ถอดแล้วได้ U+FFFD -// (ไบต์ไม่ประกอบเป็น UTF-8 ที่สมบูรณ์ — ชื่อเป็น latin1 จริง) = เก็บของเดิมไว้ -const LATIN1_MOJIBAKE_RE = /[\u0080-ÿ]/; - -export function decodeUploadFilename(name: string): string { - if (!LATIN1_MOJIBAKE_RE.test(name)) return name; - const fixed = Buffer.from(name, "latin1").toString("utf8"); - return fixed.includes("�") ? name : fixed; -}