จอสัมผัส (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

(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:
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 2026-09-15 12:13:05 +07:00
parent df0392ea45
commit 0c4af09e85
7 changed files with 843 additions and 190 deletions

View file

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