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

ชื่อ 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:
Your Name 2026-10-05 11:31:33 +07:00
parent c08aaab1ed
commit b123ecc9b2
5 changed files with 120 additions and 22 deletions

View file

@ -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;
});
}