จอสัมผัส (iOS/Android): ไฮไลต์ commit ผ่านปุ่ม "ไฮไลต์" ลอยใต้ selection + ลาก handle กรอบ selection ได้จริง: (1) บน touch การลากคลุมข้อความไม่ยิง synthetic mouseup (ยิงเฉพาะ tap) ทำให้ HIGHLIGHT ไม่ถูกสร้างเลย — เพิ่มปุ่ม "ไฮไลต์" ลอยใต้บรรทัดสุดท้ายของ selection (แพทเทิร์น Acrobat mobile; ด้านบนถูกเมนู/handles ของ OS ยึด ใกล้ขอบล่างหน้าเกินพลิกขึ้นเหนือ rect แรก) แสดงเฉพาะ pointer:coarse + tool highlight เมื่อ selection หยุดนิ่ง ~400ms (debounce ใน effect selectionchange ข้ามช่วงลาก handles) (2) แตะปุ่ม commit จาก snapshot ใน touchSelRef ไม่ใช่ selection สด เพราะ iOS เคลียร์ selection ทันทีที่แตะปุ่มก่อน tap จะถึง — การซ่อนปุ่ม+ล้าง snapshot จึงหน่วง 250ms (scheduleTouchHide) ให้ tap นั้น commit ได้ก่อน และผูก commit ที่ pointerdown (เร็วกว่า click); desktop ยังใช้ mouseup ตามเดิม — สอง path ผ่าน commitHighlight ชุดเดียวกัน + ตั้ง lastSelectionTextRef (ชื่อ bookmark default) ใน debounce เหมือน mouseup (3) เติม touch-none ที่ handle ทั้ง 8 ของ AnnotationSelectionLayer (ขอบ 4 + มุม 4 — InkLayer มีอยู่แล้ว) ไม่งั้น browser ยึด gesture เป็น scroll ส่ง pointercancel กลายเป็นเลื่อนหน้าแทนการลากย้าย/ปรับขนาด (4) sync rules/pdf-viewer.md — verify ด้วย Playwright touch emulation: ปุ่มโผล่หลัง debounce, แตะแล้วสร้าง HIGHLIGHT จริง (server action + DOM + sidebar), วาดเส้นด้วยนิ้วได้, handles 9 จุด touch-action:none ครบ, tsc ผ่าน
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 42s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 2026-09-14 13:07:08 +07:00
parent 6ddc35d6d5
commit b3b884c736
3 changed files with 158 additions and 16 deletions

View file

@ -398,9 +398,10 @@ export default function AnnotationSelectionLayer({
สี/รูปแบบ/ลบได้อย่างเดียว) จึงไม่มี handle ขอบกรอบและมุม */}
{editable && !disabled && !isHighlightMark && (
<>
{/* ขอบกรอบ 4 ด้าน — ลาก = translate ทั้งกลุ่ม */}
{/* ขอบกรอบ 4 ด้าน — ลาก = translate ทั้งกลุ่ม (touch-none: จอสัมผัสลาก
ได้จริง ไม่ถูก browser ยึดเป็น scroll แล้วส่ง pointercancel) */}
<div
className="absolute cursor-move"
className="absolute cursor-move touch-none"
style={{
left: boxPx.x - STRIP_PX / 2,
top: boxPx.y - STRIP_PX / 2,
@ -414,7 +415,7 @@ export default function AnnotationSelectionLayer({
onPointerCancel={cancelDrag}
/>
<div
className="absolute cursor-move"
className="absolute cursor-move touch-none"
style={{
left: boxPx.x - STRIP_PX / 2,
top: boxPx.y + boxPx.height - STRIP_PX / 2,
@ -428,7 +429,7 @@ export default function AnnotationSelectionLayer({
onPointerCancel={cancelDrag}
/>
<div
className={`absolute ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
className={`absolute touch-none ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
style={{
left: boxPx.x - STRIP_PX / 2,
top: boxPx.y,
@ -444,7 +445,7 @@ export default function AnnotationSelectionLayer({
onPointerCancel={cancelDrag}
/>
<div
className={`absolute ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
className={`absolute touch-none ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
style={{
left: boxPx.x + boxPx.width - STRIP_PX / 2,
top: boxPx.y,
@ -465,7 +466,7 @@ export default function AnnotationSelectionLayer({
{!isFixedTextbox && corners.map((c) => (
<div
key={c.key}
className="absolute rounded-sm border border-primary bg-primary/50"
className="absolute touch-none rounded-sm border border-primary bg-primary/50"
style={{
left: c.x - HANDLE_PX / 2,
top: c.y - HANDLE_PX / 2,

View file

@ -1776,6 +1776,23 @@ 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 {
@ -1982,6 +1999,7 @@ export default function PDFViewer({
// — text ใช้แค่ค้นหา/preview/ชื่อ bookmark
text: opts?.withText ? selectionTextWithin(sweptRects, range) : "",
first: sweptRects[0],
last: sweptRects[sweptRects.length - 1],
};
}
@ -1993,13 +2011,44 @@ export default function PDFViewer({
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;
@ -2008,6 +2057,7 @@ export default function PDFViewer({
if (!pageEl) {
setLiveMark(null);
setSelectPreview(null);
scheduleTouchHide();
return;
}
const pi = Number(pageEl.dataset.page);
@ -2030,6 +2080,34 @@ 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
@ -2042,6 +2120,14 @@ 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]);
@ -2087,6 +2173,38 @@ export default function PDFViewer({
return () => document.removeEventListener("copy", onCopy);
}, [tool]);
/** สร้าง HIGHLIGHT จาก selection ที่อ่านได้ — ใช้ร่วมกันทั้ง desktop (mouseup บนหน้า)
* และจอสัมผัส (ปุ่ม "ไฮไลต์" ลอยใต้ selection) */
function commitHighlight(
pageIndex: number,
sel: { rects: HighlightData["rects"]; text: string },
) {
setLiveMark(null);
window.getSelection()?.removeAllRanges();
void handleCreateAnnotation(pageIndex, "HIGHLIGHT", {
text: sel.text,
color: highlightColor,
rects: sel.rects,
...(highlightStyle !== "highlight" ? { style: highlightStyle } : {}),
...(highlightOpacityPct < 100 ? { opacity: highlightOpacityPct / 100 } : {}),
});
}
/** จอสัมผัส: แตะปุ่ม "ไฮไลต์" ลอยใต้ 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);
}
function handleMouseUpOnPage(pageIndex: number) {
// N2: จำข้อความที่เพิ่งคลุมบนหน้า (ทุก tool) — ใช้เป็นชื่อ default ของ popup bookmark
// (คลิกปุ่ม 🔖 ใน header ไม่ผ่าน mouseup ของหน้า ค่าจึงไม่โดนเคลียร์ก่อนเปิด popup)
@ -2097,15 +2215,7 @@ export default function PDFViewer({
// tool select คลุมข้อความเฉย ๆ — ไม่มี popup
if (tool !== "highlight") return;
if (!sel) return;
setLiveMark(null);
window.getSelection()?.removeAllRanges();
void handleCreateAnnotation(pageIndex, "HIGHLIGHT", {
text: sel.text,
color: highlightColor,
rects: sel.rects,
...(highlightStyle !== "highlight" ? { style: highlightStyle } : {}),
...(highlightOpacityPct < 100 ? { opacity: highlightOpacityPct / 100 } : {}),
});
commitHighlight(pageIndex, sel);
}
// ─── Comment (PLAN.md §6.3) ────────────────────────────────────────────────
@ -3255,6 +3365,36 @@ export default function PDFViewer({
</div>
);
})()}
{/* จอสัมผัส: ปุ่มยืนยันสร้าง 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>
);
})