From b123ecc9b22c9ca7f05e4517661341ec1d71c21b Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 5 Oct 2026 11:31:33 +0700 Subject: [PATCH] fix(pdf-selection): match text to dragged lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ชื่อ bookmark/ข้อความ copy จากการคลุมข้อความเพี้ยน 3 รูปแบบบน เอกสารไทย (คำขอผู้ใช้ 2026-10-05 รายงานบนฟอร์มราชการ "แบบบันทึกผลการทดลองปฏิบัติหน้าที่ราชการ" ที่บรรทัดคุมชิด ~16px) (1) สองบรรทัดสอดแทรกตัวต่อตัว ("แบบบันทึกผ(สำลกหารัรบท…") — บรรทัดไทยคุมชิด rect สูง ~16px เท่าระยะห่างบรรทัด ตัวอักษร ของบรรทัดถัดไป (สระบนดันกรอบขึ้น) เหลื่อมกับ rect บรรทัดก่อน หน้าจริง 1-3px: hit-test เดิม (findIndex ตัวแรกที่เจอ + eps) จับข้ามบรรทัดจนโดนเรียงรวมกันด้วย x — แก้ selectionTextWithin เป็นประตู 2 ชั้น: จุดกึ่งกลางแนวตั้งของตัวอักษรต้องอยู่ในแถบ ของ rect (เกณฑ์หลักแยกบรรทัด) แล้วเลือก rect ที่ซ้อนทับมากสุด (2) ลากบรรทัดเดียวแต่ได้หลายบรรทัด — DOM range ของ pdf.js มักครอบ span บรรทัดถัดไปด้วย (ลากจบท้ายบรรทัด/เลยขอบขวา แล้ว caret ปลายทางหลุดเข้า span ถัดไป: range.toString() ได้ 3 บรรทัดทั้งที่ ลากบรรทัดเดียว) — ประตูจุดกึ่งกลางใน (1) ตัดตัวอักษรนอกแถบ ทิ้งเอง ได้เฉพาะข้อความที่เห็นบนจอจริง (ดีกว่า native) (3) ✓ checkbox คอลัมน์ขวาติดมากับบล็อกที่เลือก — pdf.js วาง span ✓ ไว้ระหว่างบรรทัดข้อความพอดีจน native selection ครอบมันตาม DOM (raw toString มี ✓ เหมือนกัน) — rectsInSweptPath: บรรทัดกลาง เดิมเก็บทั้งบรรทัดแบบไม่ดูแกน x เปลี่ยนเป็นเก็บเฉพาะช่วงคอลัมน์ เดียวกับข้อความที่เก็บได้จริง: ยึดขอบซ้าย/ขวาจริงของ rect ที่เก็บ ได้จากบรรทัดแรก/สุดท้าย + ขยายริมละ 2×lineTol (ทน justify gap ระหว่าง rect, ตาม zoom อัตโนมัติ) — span ที่ห่างเกิน (✓ gap 30px+) ถูกตัดทั้งจากข้อความและกรอบมาร์กเหลือง (rect ชุดเดียวกัน) (4) แยกบรรทัด/เรียงลำดับกลุ่ม rect ใช้เกณฑ์เดียวกัน — เดิม sameLine (แทรก \n) ใช้ |midY| ≤ max(height) ซึ่งตัดสินผิดกับบรรทัดคุมชิด (16 ≤ 16 = บรรทัดเดียวกัน) และ comparator เรียง rectOrder ก็ใช้ heuristic เดิมจนย่อหน้าที่บรรทัดแรกเยื้องเข้า (left มากกว่า บรรทัดถัดไป) โดนเรียงตาม left กลายเป็นข้อความสลับลำดับบรรทัด — ทั้งคู่เปลี่ยนมาใช้ "rect ไม่ซ้อนทับแนวตั้งกัน = คนละบรรทัด" (5) แก้ตาม code review 2026-10-05 ข้อ 1: หน้าต่างคอลัมน์ของบรรทัด กลางห้ามยึดพิกัดจุดลากตรง ๆ (ลากชัน/ลาก margin ซ้ายแล้ว rect ครึ่งขวาของบรรทัดกลางโดนตัด) — ยึดขอบจริงตาม (3) แทน; doc comment ของ rectsInSweptPath ที่ยังบอก "ไม่ตัดแกน x" sync ให้ตรงพฤติกรรม (6) tests: coordinates +3 เคส (✓ คนละคอลัมน์ถูกตัด + rect คอลัมน์ เดียวกันยังเก็บ / ลากชันผ่าน justify gap ยังเก็บเต็มบรรทัด / ลาก margin ซ้ายบรรทัดกลางไม่หาย) — รวม 215/215 ผ่าน + tsc (7) docs sync: rules/pdf-viewer.md + rules/coordinates.md ยืนยันใน browser จริง (Playwright) บนไฟล์ฟอร์มราชการของผู้ใช้: heading บรรทัดเดียว / 2 บรรทัด / ลากเลยขอบขวา / บล็อก ๑+๒ ปกติ และลากชัน — ชื่อ bookmark ตรงเป๊ะ ไม่มีข้อความปน/สลับ/✓ ติดมา Co-Authored-By: Claude Code --- .claude/rules/coordinates.md | 1 + .claude/rules/pdf-viewer.md | 2 +- components/pdf/PDFViewer.tsx | 46 ++++++++++++++++++++++------- tests/utils/coordinates.test.ts | 42 +++++++++++++++++++++++++++ utils/coordinates.ts | 51 ++++++++++++++++++++++++++------- 5 files changed, 120 insertions(+), 22 deletions(-) diff --git a/.claude/rules/coordinates.md b/.claude/rules/coordinates.md index cddb3d4..b0159b3 100644 --- a/.claude/rules/coordinates.md +++ b/.claude/rules/coordinates.md @@ -11,6 +11,7 @@ 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/pdf-viewer.md b/.claude/rules/pdf-viewer.md index f7b33c4..9c51374 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) ปิดท้ายด้วย `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) — **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/คลิปบอร์ด - คีย์ลัด 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/components/pdf/PDFViewer.tsx b/components/pdf/PDFViewer.tsx index f1dd062..b6d9c30 100644 --- a/components/pdf/PDFViewer.tsx +++ b/components/pdf/PDFViewer.tsx @@ -311,13 +311,29 @@ function selectionTextWithin(kept: DOMRect[], range: Range): string { const r = probe.getClientRects()[0]; let hit = -1; if (r !== undefined) { - hit = kept.findIndex( - (k) => - r.left < k.right + eps && - r.right > k.left - eps && - r.top < k.bottom + eps && - r.bottom > k.top - eps, - ); + // สองเงื่อนไขในการนับตัวอักษรเข้ากลุ่ม 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; + } + } } // combining mark / ตัวไม่มีความกว้าง = ผูกกับตัวก่อนหน้า ตาม rect+x ของตัวนั้น const sticky = prevRect >= 0 && (isThaiCombiningMark(ch) || !r || r.width === 0); @@ -343,9 +359,13 @@ 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]; - if (Math.abs(midY(ra) - midY(rb)) > Math.max(ra.height, rb.height)) { - return midY(ra) - midY(rb); - } + // ใช้เกณฑ์เดียวกับ 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); return ra.left - rb.left; }); let out = ""; @@ -354,7 +374,11 @@ function selectionTextWithin(kept: DOMRect[], range: Range): string { if (prev >= 0) { const a = kept[prev]; const b = kept[ri]; - const sameLine = Math.abs(midY(a) - midY(b)) <= Math.max(a.height, b.height); + // คนละบรรทัด = 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; if (!sameLine) out += "\n"; // span คนละบรรทัด = ขึ้นบรรทัดใหม่เหมือน toString() } out += entries diff --git a/tests/utils/coordinates.test.ts b/tests/utils/coordinates.test.ts index b2cbdc2..2fe6ac7 100644 --- a/tests/utils/coordinates.test.ts +++ b/tests/utils/coordinates.test.ts @@ -189,6 +189,48 @@ 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/utils/coordinates.ts b/utils/coordinates.ts index db5f278..0c8679d 100644 --- a/utils/coordinates.ts +++ b/utils/coordinates.ts @@ -65,9 +65,11 @@ export type ClientPt = { x: number; y: number }; * anchor/focus ของ DOM selection อาจตกที่ตำแหน่งไม่มีเรขาคณิต (zero-width node) * จึงใช้จุด pointerdown + ตำแหน่ง pointer ล่าสุด (client coords) เป็น ground truth แทน * กฎ: บรรทัดแรกเก็บจากจุดเริ่ม→ท้ายบรรทัด, บรรทัดสุดท้ายเก็บต้นบรรทัด→จุดจบ, - * บรรทัดกลางเก็บทั้งบรรทัด, บรรทัดนอกช่วงแนวตั้งของเส้นทางลากทิ้ง (กันมาร์กเกิน/ - * เด้งไปบรรทัดอื่น) — คลิกไม่ขยับ (ดับเบิลคลิกเลือกคำ/ทริปเปิลคลิกเลือกบรรทัด) - * ตัดเฉพาะแกนตั้ง ไม่ตัดแกน x เพราะไม่มีแนวการลากให้อิง + * บรรทัดกลางเก็บทั้งบรรทัดเฉพาะช่วงคอลัมน์เดียวกับข้อความที่เก็บได้จริง (ตัด span + * ประกอบที่ลอยคนละคอลัมน์ เช่น ✓ checkbox ขอบขวาของฟอร์ม — bug report 2026-10-05), + * บรรทัดนอกช่วงแนวตั้งของเส้นทางลากทิ้ง (กันมาร์กเกิน/เด้งไปบรรทัดอื่น) — คลิกไม่ขยับ + * (ดับเบิลคลิกเลือกคำ/ทริปเปิลคลิกเลือกบรรทัด) ตัดเฉพาะแกนตั้งเป็นหลัก แกน x ตัด + * เฉพาะบรรทัดกลางที่ลอยคนละคอลัมน์กับที่ลาก * ใช้ทั้ง desktop (native selection ผ่าน selDrag refs) และจอสัมผัส * (useCustomSelection ลากด้วยนิ้ว) * @@ -108,17 +110,46 @@ export function rectsInSweptPath( bottomY = sy; } - return rects.filter((r) => { - const cy = r.y + r.height / 2; + 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); 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; - 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; // บรรทัดกลาง: ครอบทั้งบรรทัด + 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; }); }