จอสัมผัส (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
|
|
@ -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>
|
||||
);
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue