fix(pdf-selection): match text to dragged lines
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 4s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 52s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 4s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 52s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
ชื่อ 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 <noreply@anthropic.com>
This commit is contained in:
parent
c08aaab1ed
commit
b123ecc9b2
5 changed files with 120 additions and 22 deletions
|
|
@ -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)**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue