จอสัมผัส (iOS/Android): ไฮไลต์ด้วย custom selection ลาก→ปล่อย=สร้างทันที แทน native selection + ปุ่มยืนยันลอย (code review ครบ):
Some checks failed
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Failing after 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Has been skipped
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Has been skipped
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s
Some checks failed
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Failing after 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Has been skipped
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Has been skipped
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s
(1) hooks/useCustomSelection.ts (ใหม่): tool highlight บนอุปกรณ์ pointer หลัก coarse ลากเลือกข้อความเองผ่าน Pointer Events ชุดเดียวคุม mouse+touch — pointerdown เก็บ anchor caret (caretRangeFromPoint → caretPositionFromPoint fallback) ยังไม่ทำอะไร → เลย threshold 8px จึง setPointerCapture + เริ่ม select จริง (จับตอนเริ่ม select ไม่ใช่ pointerdown — capture ตั้งแต่กดจะ retarget click ไป container จน tap เลือก annotation/pin ใช้ไม่ได้) → pointerup = commit ทันทีผ่าน commitHighlight เดิม (ห้ามแก้ signature — ไปทาง readTextSelection(pi, { range }) ที่เพิ่ม opts.range ให้อ่าน Range จากภายนอกแทน native selection) — ชดเชย UX ที่หายจาก native: word snap ขอบคำทั้ง anchor/focus (Intl.Segmenter แยกคำไทย dictionary → fallback regex \S+, กลางช่องว่างไม่ขยาย), finger offset อ่าน caret ที่ y−12 เพราะนิ้วบังข้อความ (เฉพาะ pointerType touch), auto-scroll rAF loop เมื่อนิ้วใกล้ขอบบน/ล่าง viewport 60px, live preview rAF throttle (getClientRects → rectsInSweptPath → % + mergeRectsByLine รูปร่างเดียวกับ liveMark ส่งเข้า PdfPage ทดแทนตอนมีค่า customSelPreview ?? liveMark)
(2) กัน "ลากแล้วบางทีไม่ commit" ตาม bug report 3 จุด + code review: การหา caret เป็น 4 ชั้นเรียงตามต้นทุน (resolveTextCaretDeep ใช้เฉพาะ pointerdown กับทั้งจุด y−12 และจุดแตะจริง): caret API + descend หา text node (childNodes[startOffset] + findFirstTextNode DFS) → retry 8 ทิศ ±2/3px → elementsFromPoint เจาะ overlay ไร้ text (SVG ของ InkLayer/มาร์ก/กรอบ) จับ span ใต้จุด → span ของหน้านั้นที่ใกล้สุดแนวตั้ง ≤40px (เกินถือว่าไม่มีข้อความจริง; cache span list ต่อ 1 gesture) — ระหว่างลากทุก rAF frame ใช้เฉพาะ 2 ชั้นถูก หาไม่เจอ (ผ่านพื้นที่ว่าง/ช่องว่างระหว่างหน้า) ให้คง focus เดิมจาก frame ก่อน (lastFocus) ไม่ re-scan / lastRef เคลียร์เฉพาะ pointerdown, finish และเมื่อหน้า anchor โดน virtualization unmount กลางลาก (anchorPageEl.isConnected — กัน commit ของเก่าเงียบ ๆ) — frame ที่ rectsInSweptPath กรองว่าง (ลากเร็ว sample ห่าง/นิ้วสั่นตอนปล่อย) ล้างแค่ preview / ลากล้ำเข้าหน้าถัดไป (range คร่อม 2 หน้า) ห้าม bail — clampToAnchorPage clamp ฝั่ง focus ไปท้ายหน้า anchor (ลากลง) หรือต้นหน้า anchor (ลากขึ้น) boundary cache ต่อ gesture (clampBounds ไม่เดิน TreeWalker ทุก frame) / จุดเส้นทางกรอง rectsInSweptPath ใช้จุด offset เดียวกับ caret (anchorPathPt = จุด probe ที่ resolve anchor + pathEnd = จุด focus หัก 12px) — ใช้พิกัดนิ้ว raw ตรง ๆ จะคลาด 12px จนบรรทัดเป้าหมายโดน lineTol ตัดทิ้ง (ลากขึ้น) หรือมาร์กเกินบรรทัด (ลากลง) เมื่อฟอนต์เล็ก
(3) ถอด path เดิมทั้งหมด: touchConfirm state, touchSelRef/touchSelTimerRef/touchHideTimerRef, debounce 400ms ใน effect selectionchange, handleTouchConfirmHighlight, ปุ่ม "ไฮไลต์ข้อความ" ลอยใต้ selection — desktop (pointer หลัก fine) ใช้ native mouse selection + mouseup commit ตามเดิมไม่เปลี่ยน (enabled = tool highlight + matchMedia coarse) / ระหว่าง enabled ให้ <main> ได้ class .pdf-custom-select (user-select/-webkit-user-select/-webkit-touch-callout: none + touch-action: none กัน browser ยึด gesture เป็น scroll → pointercancel) ถอดทันทีเมื่อเปลี่ยน tool (tool อื่น native selection + long-press copy ของ OS ตามปกติ) + restore user-select:text และ touch-action:auto ให้ input/textarea/contenteditable ใต้ main (reply ของ comment pin ยังเลือก/เลื่อนข้อความในช่องได้)
(4) ย้าย ClientPt + rectsInSweptPath จาก PDFViewer ไป utils/coordinates.ts (hook import ได้โดยไม่ circular) + unit tests rectsInSweptPath 4 เคส (ลากบรรทัดเดียว/ลากขึ้นหลายบรรทัด/บรรทัดนอกเส้นทาง/ไม่ขยับ) / gap-analysis.md แถว 5.3.18 อัปเดตเป็นกลไกใหม่ (รวมงานฉบับแก้ไขข้อความข้อ 5.3.18–5.3.25 ของเจ้าของเอกสารที่ค้างใน working tree) / sync rules/pdf-viewer.md ครบทั้งกลไก 4 ชั้น + path points + กรณีหน้า unmount
verify: tsc ผ่าน + vitest 104/104 + Playwright touch emulation 3 ชุด (รอยต่อ span คำติดกัน gap=0/ขอบซ้าย-ขวาสุดบรรทัด/ช่องว่าง line-height, ลากช้า-เร็ว flick/ล้ำหน้าถัดไปทั้งลงและขึ้น/ขยับต่ำกว่า threshold ไม่สร้าง/tap annotation ได้, จุดว่างใกล้ 20px → commit vs ไกล 46px → ไม่สร้าง) — console.warn ชุด TEMP-DEBUG คงไว้ชั่วคราวตาม protocol รอยืนยันบนมือถือจริง (Safari iOS + Chrome Android) ก่อนถอด
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
df0392ea45
commit
0c4af09e85
7 changed files with 843 additions and 190 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,7 +106,9 @@ import {
|
|||
mergeRectsByLine,
|
||||
rectToPercent,
|
||||
rectToPx,
|
||||
rectsInSweptPath,
|
||||
toPx,
|
||||
type ClientPt,
|
||||
type Point,
|
||||
type Rect,
|
||||
type Size,
|
||||
|
|
@ -235,41 +241,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 {
|
||||
|
|
@ -1514,7 +1485,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 +1873,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 {
|
||||
|
|
@ -2082,13 +2035,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 +2084,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 +2114,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 +2135,6 @@ export default function PDFViewer({
|
|||
if (!pageEl) {
|
||||
setLiveMark(null);
|
||||
setSelectPreview(null);
|
||||
scheduleTouchHide();
|
||||
return;
|
||||
}
|
||||
const pi = Number(pageEl.dataset.page);
|
||||
|
|
@ -2224,34 +2157,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 +2169,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 +2215,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 +2231,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 +2272,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 (ใครทำ + โน้ต)
|
||||
|
|
@ -3543,7 +3472,7 @@ export default function PDFViewer({
|
|||
shapeStyle={shapeStyle}
|
||||
textboxStyle={textboxStyle}
|
||||
pageBaseWidth={basePageWidth}
|
||||
liveMark={liveMark}
|
||||
liveMark={customSelPreview ?? liveMark}
|
||||
liveMarkStyle={{
|
||||
color: highlightColor,
|
||||
style: highlightStyle,
|
||||
|
|
@ -3648,35 +3577,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>
|
||||
);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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% ไม่ปรากฏในฉบับแก้ไข
|
||||
|
||||
## สรุปผล
|
||||
|
||||
|
|
@ -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 — ฟังก์ชันที่มีอยู่แล้ว ต่อยอดเล็กน้อยได้เลย
|
||||
งานขนาดเล็ก–กลาง ไม่กระทบสถาปัตยกรรม:
|
||||
|
|
@ -85,9 +86,7 @@
|
|||
- **ชุดของเอกสาร (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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
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 };
|
||||
}
|
||||
|
|
@ -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,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 ให้ครอบข้อความเดิมทั้งบรรทัด
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue