viewer: เปลี่ยนตัวเลื่อนของหน้า annotate จาก window มาเป็น <main> ของเอกสาร — zoom เกินจอแล้วเลื่อนได้ทั้งสองแกน sidebar/header อยู่กับที่ (คำขอผู้ใช้ 2026-09-22: เดิมเลื่อน/ซูมทีเดียวเป็นทั้งหน้าเว็บรวม sidebar ไปด้วย)
- PDFViewer root เป็น app shell flex h-dvh overflow-hidden (dvh กันปัญหา URL bar ของจอสัมผัส) + content column เป็น flex-col + <main> ได้ class pdf-scroll-main min-h-0 flex-1 overflow-auto overscroll-contain = ตัวเลื่อนเดียวของหน้า; aside เปลี่ยน h-screen เป็น h-full (ถอด sticky top-0 ที่เป็น no-op แล้ว เหลือ top-0 สำหรับ max-lg:fixed) - class ใหม่ .pdf-scroll-main ใน globals.css: touch-action pan-x pan-y บนพื้นที่เอกสาร — กัน browser pinch/double-tap zoom ทั้งหน้าเว็บ (อาการ sidebar โต้ตามตอนซูมบน tablet) วางก่อน .pdf-custom-select เพื่อให้โหมดมาร์กข้อความ (touch-action:none) ยังชนะตอนเปิดใช้; ปุ่มซูม +- เป็นทางเลือก single-pointer ตาม WCAG 2.5.1 - retarget scroll ทุกจุดจาก window มาที่ mainRef.current: scrollToBelowHeader รับ main + helper ใหม่ contentTopInScroll(main, clientTop) แทนสูตร r.top + window.scrollY (ลบ viewerHeaderHeight ที่ไม่จำเป็น — header อยู่นอกกรอบ scroll), ตรวจหน้าปัจจุบันย้าย listener มาที่ main, IO ของ virtualization ตั้ง root เป็น main, deep link ?page/?ann, goToPage/goToBookmark/goToAnnotation/currentViewPosition, tool pan ใช้ main.scrollBy (ลากได้ทั้งแนวนอนด้วย) - useCustomSelection auto-scroll: เลื่อน scroll container ของ <main> แทน window + เพิ่มแกนนอน (ลากชิดขอบซ้าย/ขวาเลื่อนต่อ — แก้ zoom แล้วเข้าไม่ถึงครึ่งขวาของหน้าในโหมดมาร์กข้อความบน tablet) และขอบวัดจาก rect ของ main (ใต้ header) แทนขอบจอที่โดน header บัง + hint เปลี่ยนเป็น "ลากชิดขอบจอเพื่อเลื่อนหน้า" - sync กติกา .claude/rules/pdf-viewer.md: เพิ่ม bullet scroll architecture (ห้ามกลับไปเลื่อนที่ window) + แก้คำอธิบาย pan/auto-scroll ที่อ้าง window เดิม verify: npx tsc --noEmit ผ่าน, vitest 139/139 ผ่าน — การวาด/สไตลัส/pressure และพิกัด % ไม่ถูกแตะ Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
f7b5a8488d
commit
f7cd4b8312
4 changed files with 96 additions and 64 deletions
File diff suppressed because one or more lines are too long
|
|
@ -247,6 +247,16 @@ html {
|
||||||
overscroll-behavior-y: contain;
|
overscroll-behavior-y: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 2026-09-22: <main> ของ viewer เป็น scroll container เดียวของหน้า (แนวตั้ง +
|
||||||
|
แนวนอน — ซูมจนหน้ากว้างเกินจอแล้วปัดเลื่อนดูส่วนที่เกินได้) — จำกัด gesture
|
||||||
|
สัมผัสให้เป็นการแพนของกรอบนี้เท่านั้น ไม่ปล่อยให้ browser pinch-zoom/double-tap
|
||||||
|
zoom ทั้งหน้าเว็บ (อาการซูมแล้ว header/sidebar โต้ตามไปด้วย); ปุ่มซูม ± เป็น
|
||||||
|
ทางเลือก single-pointer ตาม WCAG 2.5.1 — ต้องอยู่ก่อน .pdf-custom-select
|
||||||
|
เพื่อให้โหมดมาร์กข้อความ (touch-action:none) ยังชนะตอนเปิดใช้ */
|
||||||
|
.pdf-scroll-main {
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
|
}
|
||||||
|
|
||||||
/* custom selection ของ tool highlight บนจอสัมผัส (hooks/useCustomSelection) —
|
/* custom selection ของ tool highlight บนจอสัมผัส (hooks/useCustomSelection) —
|
||||||
ปิด native selection/callout ของ OS และยึด gesture ลากเป็นของเราทั้งหมด
|
ปิด native selection/callout ของ OS และยึด gesture ลากเป็นของเราทั้งหมด
|
||||||
(touch-action:none กัน browser ยึดไปเป็น scroll → pointercancel)
|
(touch-action:none กัน browser ยึดไปเป็น scroll → pointercancel)
|
||||||
|
|
|
||||||
|
|
@ -210,11 +210,6 @@ function normalizeBookmarkTitle(s: string): string {
|
||||||
: t;
|
: t;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ความสูง sticky header ของ viewer — ใช้เว้นระยะเวลาเลื่อนหน้าไม่ให้เป้าหมายถูก header บัง */
|
|
||||||
function viewerHeaderHeight(): number {
|
|
||||||
return document.querySelector("header")?.offsetHeight ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ความกว้างจริงของเมนู dropdown ของ tool (w-60) กับ popup bookmark (w-72) —
|
/** ความกว้างจริงของเมนู dropdown ของ tool (w-60) กับ popup bookmark (w-72) —
|
||||||
* ใช้ clamp ตำแหน่ง fixed ให้อยู่ในจอ (C1) */
|
* ใช้ clamp ตำแหน่ง fixed ให้อยู่ในจอ (C1) */
|
||||||
const TOOL_MENU_W = 240;
|
const TOOL_MENU_W = 240;
|
||||||
|
|
@ -230,13 +225,14 @@ function fixedMenuPos(rect: DOMRect, menuW: number): { left: number; top: number
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** เลื่อนหน้าต่างให้ตำแหน่ง absolute (px จากบนสุดของ document) ไปอยู่ใต้ sticky header
|
/** เลื่อน scroll container ของเอกสาร (<main> — ตัวเลื่อนเดียวของ viewer) ให้ตำแหน่ง
|
||||||
* (เว้นระยะห่างจาก toolbar 40px ให้รายการไม่ติดแถบเครื่องมือเกินไป) */
|
* contentTop (px จากบนสุดของเนื้อหาใน container) ไปอยู่แถวบนสุดของพื้นที่มองเห็น —
|
||||||
|
* header อยู่นอกกรอบ scroll อยู่แล้ว จึงเว้นแค่ช่องว่าง 40px ให้รายการไม่ติดขอบบนเกินไป */
|
||||||
const SCROLL_BELOW_HEADER_GAP = 40;
|
const SCROLL_BELOW_HEADER_GAP = 40;
|
||||||
|
|
||||||
function scrollToBelowHeader(absoluteTop: number): void {
|
function scrollToBelowHeader(main: HTMLElement | null, contentTop: number): void {
|
||||||
window.scrollTo({
|
main?.scrollTo({
|
||||||
top: Math.max(0, absoluteTop - viewerHeaderHeight() - SCROLL_BELOW_HEADER_GAP),
|
top: Math.max(0, contentTop - SCROLL_BELOW_HEADER_GAP),
|
||||||
// L7 (UX audit 2026-09-16): เคารพ prefers-reduced-motion — คู่กับ rule ใน globals.css
|
// L7 (UX audit 2026-09-16): เคารพ prefers-reduced-motion — คู่กับ rule ใน globals.css
|
||||||
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||||
? "auto"
|
? "auto"
|
||||||
|
|
@ -244,6 +240,13 @@ function scrollToBelowHeader(absoluteTop: number): void {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** แปลง client Y (getBoundingClientRect().top) → พิกัดแนวตั้งในเนื้อหาของ scroll
|
||||||
|
* container ของเอกสาร — แทนสูตร r.top + window.scrollY ยุคที่ window เป็นตัวเลื่อน
|
||||||
|
* (app shell สูงจอควบ lock การ scroll ของ document ไว้ ทุกอย่างเลื่อนใน <main>) */
|
||||||
|
function contentTopInScroll(main: HTMLElement, clientTop: number): number {
|
||||||
|
return clientTop - main.getBoundingClientRect().top + main.scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
/** path เส้นขยุกขยิกยาว w px — ใช้ใน highlight-layer สำหรับ style "squiggle" */
|
/** path เส้นขยุกขยิกยาว w px — ใช้ใน highlight-layer สำหรับ style "squiggle" */
|
||||||
function squigglePath(w: number): string {
|
function squigglePath(w: number): string {
|
||||||
let d = "M0 2.5";
|
let d = "M0 2.5";
|
||||||
|
|
@ -1585,6 +1588,8 @@ export default function PDFViewer({
|
||||||
}, [file.id, file.pdfUrl]);
|
}, [file.id, file.pdfUrl]);
|
||||||
|
|
||||||
const pageWrapRefs = useRef<(HTMLDivElement | null)[]>([]);
|
const pageWrapRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
// scroll container ของเอกสาร (<main>) — ตัวเลื่อนเดียวของ viewer (sidebar/header นิ่ง)
|
||||||
|
const mainRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
// Page Virtualization: render เฉพาะหน้าใน viewport ± 1 หน้า (invariant #10)
|
// Page Virtualization: render เฉพาะหน้าใน viewport ± 1 หน้า (invariant #10)
|
||||||
const [renderPages, setRenderPages] = useState<Set<number>>(new Set([0]));
|
const [renderPages, setRenderPages] = useState<Set<number>>(new Set([0]));
|
||||||
|
|
@ -1609,7 +1614,8 @@ export default function PDFViewer({
|
||||||
}
|
}
|
||||||
setRenderPages(next);
|
setRenderPages(next);
|
||||||
},
|
},
|
||||||
{ rootMargin: "100% 0px" },
|
// root = scroll container ของเอกสาร ไม่ใช่ viewport (window ไม่ scroll แล้ว)
|
||||||
|
{ root: mainRef.current, rootMargin: "100% 0px" },
|
||||||
);
|
);
|
||||||
for (const el of pageWrapRefs.current) {
|
for (const el of pageWrapRefs.current) {
|
||||||
if (el) io.observe(el);
|
if (el) io.observe(el);
|
||||||
|
|
@ -1621,14 +1627,18 @@ export default function PDFViewer({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pageSizes.length === 0) return;
|
if (pageSizes.length === 0) return;
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
const center = window.scrollY + window.innerHeight / 2;
|
const main = mainRef.current;
|
||||||
|
if (!main) return;
|
||||||
|
const mr = main.getBoundingClientRect();
|
||||||
|
// กึ่งกลางจอ (client) แปลงเป็นพิกัดในเนื้อหาของ <main> เพื่อเทียบกับ midpoint หน้า
|
||||||
|
const center = main.scrollTop + (window.innerHeight / 2 - mr.top);
|
||||||
let best = 0;
|
let best = 0;
|
||||||
let bestDist = Infinity;
|
let bestDist = Infinity;
|
||||||
for (let i = 0; i < pageWrapRefs.current.length; i++) {
|
for (let i = 0; i < pageWrapRefs.current.length; i++) {
|
||||||
const el = pageWrapRefs.current[i];
|
const el = pageWrapRefs.current[i];
|
||||||
if (!el) continue;
|
if (!el) continue;
|
||||||
const r = el.getBoundingClientRect();
|
const r = el.getBoundingClientRect();
|
||||||
const mid = r.top + window.scrollY + r.height / 2;
|
const mid = contentTopInScroll(main, r.top + r.height / 2);
|
||||||
const d = Math.abs(mid - center);
|
const d = Math.abs(mid - center);
|
||||||
if (d < bestDist) {
|
if (d < bestDist) {
|
||||||
bestDist = d;
|
bestDist = d;
|
||||||
|
|
@ -1638,8 +1648,8 @@ export default function PDFViewer({
|
||||||
setCurrentPage(best);
|
setCurrentPage(best);
|
||||||
};
|
};
|
||||||
onScroll();
|
onScroll();
|
||||||
window.addEventListener("scroll", onScroll, { passive: true });
|
mainRef.current?.addEventListener("scroll", onScroll, { passive: true });
|
||||||
return () => window.removeEventListener("scroll", onScroll);
|
return () => mainRef.current?.removeEventListener("scroll", onScroll);
|
||||||
}, [pageSizes]);
|
}, [pageSizes]);
|
||||||
|
|
||||||
// F12/C4: deep link `?page=N` (1-based) และ/หรือ `?ann=<uuid>` — อ่าน
|
// F12/C4: deep link `?page=N` (1-based) และ/หรือ `?ann=<uuid>` — อ่าน
|
||||||
|
|
@ -1665,8 +1675,9 @@ export default function PDFViewer({
|
||||||
if (targetPage === null && Number.isInteger(pageParam) && pageParam >= 1) {
|
if (targetPage === null && Number.isInteger(pageParam) && pageParam >= 1) {
|
||||||
targetPage = Math.min(pageParam - 1, pageSizes.length - 1);
|
targetPage = Math.min(pageParam - 1, pageSizes.length - 1);
|
||||||
const el = pageWrapRefs.current[targetPage];
|
const el = pageWrapRefs.current[targetPage];
|
||||||
if (el) {
|
const main = mainRef.current;
|
||||||
scrollToBelowHeader(el.getBoundingClientRect().top + window.scrollY);
|
if (el && main) {
|
||||||
|
scrollToBelowHeader(main, contentTopInScroll(main, el.getBoundingClientRect().top));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [pageSizes, annotations]);
|
}, [pageSizes, annotations]);
|
||||||
|
|
@ -1674,7 +1685,6 @@ export default function PDFViewer({
|
||||||
// จอเล็ก: หน้ากระดาษ (scale 1) กว้างกว่าพื้นที่แสดงผล → ย่อให้พอดีความกว้าง
|
// จอเล็ก: หน้ากระดาษ (scale 1) กว้างกว่าพื้นที่แสดงผล → ย่อให้พอดีความกว้าง
|
||||||
// ครั้งเดียวตอนโหลดเท่านั้น (ผู้ใช้ซูมเองหลังจากนี้แล้วไม่ถูกแทนที่) —
|
// ครั้งเดียวตอนโหลดเท่านั้น (ผู้ใช้ซูมเองหลังจากนี้แล้วไม่ถูกแทนที่) —
|
||||||
// อิงความกว้างหน้าแรกเหมือน basePageWidth และตัด padding px-4 ซ้าย-ขวาของ main ออก
|
// อิงความกว้างหน้าแรกเหมือน basePageWidth และตัด padding px-4 ซ้าย-ขวาของ main ออก
|
||||||
const mainRef = useRef<HTMLElement>(null);
|
|
||||||
const fitWidthDoneRef = useRef(false);
|
const fitWidthDoneRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pageSizes.length === 0 || fitWidthDoneRef.current) return;
|
if (pageSizes.length === 0 || fitWidthDoneRef.current) return;
|
||||||
|
|
@ -2192,14 +2202,14 @@ export default function PDFViewer({
|
||||||
/** ตำแหน่งที่กำลังดู: หน้าปัจจุบัน + จุดกึ่งกลางจอเป็น % ของความสูงหน้า */
|
/** ตำแหน่งที่กำลังดู: หน้าปัจจุบัน + จุดกึ่งกลางจอเป็น % ของความสูงหน้า */
|
||||||
function currentViewPosition() {
|
function currentViewPosition() {
|
||||||
const el = pageWrapRefs.current[currentPage];
|
const el = pageWrapRefs.current[currentPage];
|
||||||
if (!el) return null;
|
const main = mainRef.current;
|
||||||
|
if (!el || !main) return null;
|
||||||
const r = el.getBoundingClientRect();
|
const r = el.getBoundingClientRect();
|
||||||
// นิยาม yOffset แบบเดียวกับพิกัด annotation: "จุดจริงบนหน้า" — ใช้ขอบบนของ
|
// นิยาม yOffset แบบเดียวกับพิกัด annotation: "จุดจริงบนหน้า" — ใช้ขอบบนของ
|
||||||
// แถบมองเห็นใต้ header (+gap เท่ากับตอนกดผ่าน scrollToBelowHeader) ตอนกด
|
// แถบมองเห็นของ scroll container (+gap เท่ากับตอนกดผ่าน scrollToBelowHeader)
|
||||||
// bookmark จุดนี้จะกลับมาอยู่ตำแหน่งเดิมพอดี ไม่ใช่กึ่งกลางจอแบบเดิม
|
// ตอนกด bookmark จุดนี้จะกลับมาอยู่ตำแหน่งเดิมพอดี ไม่ใช่กึ่งกลางจอแบบเดิม
|
||||||
const viewTop =
|
const viewTop = main.scrollTop + SCROLL_BELOW_HEADER_GAP;
|
||||||
window.scrollY + viewerHeaderHeight() + SCROLL_BELOW_HEADER_GAP;
|
const pageTop = contentTopInScroll(main, r.top);
|
||||||
const pageTop = r.top + window.scrollY;
|
|
||||||
return {
|
return {
|
||||||
pageIndex: currentPage,
|
pageIndex: currentPage,
|
||||||
yOffset: clamp(((viewTop - pageTop) / r.height) * 100, 0, 100),
|
yOffset: clamp(((viewTop - pageTop) / r.height) * 100, 0, 100),
|
||||||
|
|
@ -2708,13 +2718,14 @@ export default function PDFViewer({
|
||||||
// เห็นเป้าหมายทันที ไม่ต้องปิดเองก่อน (deep link ผ่าน goToAnnotation ก็เช่นกัน)
|
// เห็นเป้าหมายทันที ไม่ต้องปิดเองก่อน (deep link ผ่าน goToAnnotation ก็เช่นกัน)
|
||||||
if (!window.matchMedia("(min-width: 1024px)").matches) setSidebarOpen(false);
|
if (!window.matchMedia("(min-width: 1024px)").matches) setSidebarOpen(false);
|
||||||
const el = pageWrapRefs.current[b.pageIndex];
|
const el = pageWrapRefs.current[b.pageIndex];
|
||||||
if (!el) return;
|
const main = mainRef.current;
|
||||||
|
if (!el || !main) return;
|
||||||
const r = el.getBoundingClientRect();
|
const r = el.getBoundingClientRect();
|
||||||
const pageTop = r.top + window.scrollY;
|
const pageTop = contentTopInScroll(main, r.top);
|
||||||
// ดันจุด yOffset กลับมาชิดใต้ sticky header — pattern เดียวกับ goToAnnotation
|
// ดันจุด yOffset กลับมาชิดขอบบนของพื้นที่มองเห็น — pattern เดียวกับ goToAnnotation
|
||||||
// ที่ดันขอบบน bbox (yOffset เก็บจาก "ขอบบนของสิ่งที่ดูอยู่" ตอนสร้าง กดแล้วจึงได้
|
// ที่ดันขอบบน bbox (yOffset เก็บจาก "ขอบบนของสิ่งที่ดูอยู่" ตอนสร้าง กดแล้วจึงได้
|
||||||
// มุมมองเดิมพอดี ไม่ต้องชดเชยครึ่งจอ)
|
// มุมมองเดิมพอดี ไม่ต้องชดเชยครึ่งจอ)
|
||||||
scrollToBelowHeader(pageTop + (r.height * b.yOffset) / 100);
|
scrollToBelowHeader(main, pageTop + (r.height * b.yOffset) / 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
function goToAnnotation(a: AnnotationDTO) {
|
function goToAnnotation(a: AnnotationDTO) {
|
||||||
|
|
@ -2727,10 +2738,11 @@ export default function PDFViewer({
|
||||||
setPreviewData(null);
|
setPreviewData(null);
|
||||||
setOpenPinId(null); // กระโดดไปเลือกรายการอื่น = ปิด popover ของ pin เดิมทิ้ง
|
setOpenPinId(null); // กระโดดไปเลือกรายการอื่น = ปิด popover ของ pin เดิมทิ้ง
|
||||||
const el = pageWrapRefs.current[a.pageIndex];
|
const el = pageWrapRefs.current[a.pageIndex];
|
||||||
if (!el) return;
|
const main = mainRef.current;
|
||||||
|
if (!el || !main) return;
|
||||||
const r = el.getBoundingClientRect();
|
const r = el.getBoundingClientRect();
|
||||||
const boxPct = annotationBBox(a, { width: 100, height: 100 });
|
const boxPct = annotationBBox(a, { width: 100, height: 100 });
|
||||||
scrollToBelowHeader(r.top + window.scrollY + (r.height * boxPct.y) / 100);
|
scrollToBelowHeader(main, contentTopInScroll(main, r.top) + (r.height * boxPct.y) / 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** คลิก comment pin = เปิด popover + เลือกรายการ (F1) */
|
/** คลิก comment pin = เปิด popover + เลือกรายการ (F1) */
|
||||||
|
|
@ -2832,9 +2844,10 @@ export default function PDFViewer({
|
||||||
* placeholder ทุกหน้ามีขนาดจริงจึงคำนวณ absolute scroll ได้เสมอ) */
|
* placeholder ทุกหน้ามีขนาดจริงจึงคำนวณ absolute scroll ได้เสมอ) */
|
||||||
function goToPage(pageIndex: number) {
|
function goToPage(pageIndex: number) {
|
||||||
const el = pageWrapRefs.current[pageIndex];
|
const el = pageWrapRefs.current[pageIndex];
|
||||||
if (!el) return;
|
const main = mainRef.current;
|
||||||
|
if (!el || !main) return;
|
||||||
const r = el.getBoundingClientRect();
|
const r = el.getBoundingClientRect();
|
||||||
scrollToBelowHeader(r.top + window.scrollY);
|
scrollToBelowHeader(main, contentTopInScroll(main, r.top));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Render ────────────────────────────────────────────────────────────────
|
// ─── Render ────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -2860,7 +2873,10 @@ export default function PDFViewer({
|
||||||
const basePageWidth = pageSizes[0]?.width ?? 595;
|
const basePageWidth = pageSizes[0]?.width ?? 595;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
// 2026-09-22: app shell สูงจอเดียว (dvh กันปัญหา URL bar ของจอสัมผัส) —
|
||||||
|
// ตัวเลื่อนเดียวของหน้าคือ <main> ของเอกสาร sidebar/header อยู่กับที่
|
||||||
|
// ไม่หนีไปกับ scroll/zoom ตามคำขอผู้ใช้ (เดิมเลื่อนที่ window ทั้งหน้า)
|
||||||
|
<div className="flex h-dvh overflow-hidden">
|
||||||
{/* tool คลุมข้อความได้ (TEXT_SELECTION_TOOLS) — ซ่อนสี selection ของเบราว์เซอร์
|
{/* tool คลุมข้อความได้ (TEXT_SELECTION_TOOLS) — ซ่อนสี selection ของเบราว์เซอร์
|
||||||
ให้เห็นแต่ preview มาร์ก/selection สีฟ้าของเรา (กรองแนวเส้นทางลากแล้ว
|
ให้เห็นแต่ preview มาร์ก/selection สีฟ้าของเรา (กรองแนวเส้นทางลากแล้ว
|
||||||
สะอาดกว่า native) */}
|
สะอาดกว่า native) */}
|
||||||
|
|
@ -2876,12 +2892,13 @@ export default function PDFViewer({
|
||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* lg+ = sticky แยกคอลัมน์ (เปิดแล้ว content ขยับไปข้าง ๆ ไม่บังกัน)
|
{/* lg+ = แยกคอลัมน์เต็มความสูง shell (เปิดแล้ว content ขยับไปข้าง ๆ ไม่บังกัน)
|
||||||
/ < lg = overlay เลื่อนเข้า-ออก กว้างเป็นสัดส่วนของจอกว้างกว่า 288px
|
/ < lg = overlay เลื่อนเข้า-ออก กว้างเป็นสัดส่วนของจอกว้างกว่า 288px
|
||||||
(ปิดบน desktop ใช้ lg:hidden ออกจาก flow → main เต็มจอ) */}
|
(ปิดบน desktop ใช้ lg:hidden ออกจาก flow → main เต็มจอ; top-0 จำเป็น
|
||||||
|
ตอน max-lg:fixed ยึดขอบบนจอ) */}
|
||||||
<aside
|
<aside
|
||||||
data-sidebar=""
|
data-sidebar=""
|
||||||
className={`sticky top-0 flex h-screen w-72 flex-col shrink-0 border-r border-slate-200 bg-white max-lg:fixed max-lg:left-0 max-lg:z-50 max-lg:w-[75vw] max-lg:max-w-72 max-lg:transition-transform max-lg:duration-200 ${
|
className={`top-0 flex h-full w-72 flex-col shrink-0 border-r border-slate-200 bg-white max-lg:fixed max-lg:left-0 max-lg:z-50 max-lg:w-[75vw] max-lg:max-w-72 max-lg:transition-transform max-lg:duration-200 ${
|
||||||
sidebarOpen
|
sidebarOpen
|
||||||
? "max-lg:translate-x-0"
|
? "max-lg:translate-x-0"
|
||||||
: "max-lg:pointer-events-none max-lg:-translate-x-full lg:hidden"
|
: "max-lg:pointer-events-none max-lg:-translate-x-full lg:hidden"
|
||||||
|
|
@ -2924,7 +2941,7 @@ export default function PDFViewer({
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="flex h-full min-w-0 flex-1 flex-col">
|
||||||
{/* แยกเป็นสองแถว: บน = ชื่อไฟล์ + ตัวเลื่อนหน้า/ซูม (กลาง) + หน้าสรุป,
|
{/* แยกเป็นสองแถว: บน = ชื่อไฟล์ + ตัวเลื่อนหน้า/ซูม (กลาง) + หน้าสรุป,
|
||||||
ล่าง = แถบ tool + undo/redo + bookmark/export (ชิดขวา) */}
|
ล่าง = แถบ tool + undo/redo + bookmark/export (ชิดขวา) */}
|
||||||
<header className="sticky top-0 z-40 border-b border-slate-200 bg-white">
|
<header className="sticky top-0 z-40 border-b border-slate-200 bg-white">
|
||||||
|
|
@ -3709,7 +3726,7 @@ export default function PDFViewer({
|
||||||
|
|
||||||
<main
|
<main
|
||||||
ref={mainRef}
|
ref={mainRef}
|
||||||
className="space-y-4 px-4 py-4"
|
className="pdf-scroll-main min-h-0 flex-1 space-y-4 overflow-auto overscroll-contain px-4 py-4"
|
||||||
style={{
|
style={{
|
||||||
cursor:
|
cursor:
|
||||||
tool === "pan"
|
tool === "pan"
|
||||||
|
|
@ -3729,7 +3746,7 @@ export default function PDFViewer({
|
||||||
onPointerMove={(e) => {
|
onPointerMove={(e) => {
|
||||||
const p = panRef.current;
|
const p = panRef.current;
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
window.scrollBy(p.x - e.clientX, p.y - e.clientY);
|
mainRef.current?.scrollBy(p.x - e.clientX, p.y - e.clientY);
|
||||||
panRef.current = { x: e.clientX, y: e.clientY };
|
panRef.current = { x: e.clientX, y: e.clientY };
|
||||||
}}
|
}}
|
||||||
onPointerUp={() => {
|
onPointerUp={() => {
|
||||||
|
|
@ -3764,7 +3781,7 @@ export default function PDFViewer({
|
||||||
= แอปค้าง" (แสดง 3 ครั้งแรกต่อ session) */}
|
= แอปค้าง" (แสดง 3 ครั้งแรกต่อ session) */}
|
||||||
{tool === "highlight" && coarseTouch && coarseHlHintVisible && (
|
{tool === "highlight" && coarseTouch && coarseHlHintVisible && (
|
||||||
<p className="mb-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs leading-relaxed text-slate-600">
|
<p className="mb-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs leading-relaxed text-slate-600">
|
||||||
โหมดมาร์กข้อความ: ลากบนหน้าเพื่อคลุมข้อความ ลากชิดขอบบน/ล่างจอเพื่อเลื่อนหน้า
|
โหมดมาร์กข้อความ: ลากบนหน้าเพื่อคลุมข้อความ ลากชิดขอบจอเพื่อเลื่อนหน้า
|
||||||
หรือสลับเป็นเครื่องมืออื่นเพื่อปัดเลื่อนตามปกติ
|
หรือสลับเป็นเครื่องมืออื่นเพื่อปัดเลื่อนตามปกติ
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ const MOVE_THRESHOLD_PX = 8;
|
||||||
/** อ่าน caret จากจุด y ตรงนิ้วเลื่อนขึ้น 12px — นิ้วบังข้อความที่กำลังเลือก
|
/** อ่าน caret จากจุด y ตรงนิ้วเลื่อนขึ้น 12px — นิ้วบังข้อความที่กำลังเลือก
|
||||||
* (เฉพาะ pointerType touch; เมาส์/สไตลัสชี้แม่นอยู่แล้ว) */
|
* (เฉพาะ pointerType touch; เมาส์/สไตลัสชี้แม่นอยู่แล้ว) */
|
||||||
const FINGER_OFFSET_PX = 12;
|
const FINGER_OFFSET_PX = 12;
|
||||||
/** นิ้วอยู่ใกล้ขอบบน/ล่างของ viewport แค่นี้ = auto-scroll ต่อ (rAF loop) */
|
/** นิ้วอยู่ใกล้ขอบของพื้นที่เอกสาร (ทั้ง 4 ด้าน) แค่นี้ = auto-scroll ต่อ (rAF loop) */
|
||||||
const AUTOSCROLL_MARGIN_PX = 60;
|
const AUTOSCROLL_MARGIN_PX = 60;
|
||||||
const AUTOSCROLL_MAX_SPEED_PX = 12;
|
const AUTOSCROLL_MAX_SPEED_PX = 12;
|
||||||
|
|
||||||
|
|
@ -503,35 +503,39 @@ export function useCustomSelection({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** auto-scroll ระหว่างลากค้างใกล้ขอบจอ — rAF loop เลื่อน window แล้วคำนวณ preview
|
/** auto-scroll ระหว่างลากค้างใกล้ขอบจอ — rAF loop เลื่อน scroll container ของ
|
||||||
* ใหม่ (จุดนิ้วเดิมชี้ข้อความใหม่หลัง scroll) จบเองเมื่อเลิกลาก */
|
* เอกสาร (el = <main> ตัวเลื่อนเดียวของ viewer, ทั้งแกนตั้งและแกนนอนกรณี
|
||||||
|
* ซูมจนหน้ากว้างเกินจอ) แล้วคำนวณ preview ใหม่ (จุดนิ้วเดิมชี้ข้อความใหม่หลัง
|
||||||
|
* scroll) จบเองเมื่อเลิกลาก — ขอบวัดจากกรอบของ el เอง (ใต้ header) ไม่ใช่
|
||||||
|
* ขอบจอ เพราะส่วนบนถูก header บังอยู่ */
|
||||||
const startAutoScroll = () => {
|
const startAutoScroll = () => {
|
||||||
if (scrollRafRef.current !== null) return;
|
if (scrollRafRef.current !== null) return;
|
||||||
const step = () => {
|
const step = () => {
|
||||||
scrollRafRef.current = null;
|
scrollRafRef.current = null;
|
||||||
const d = dragRef.current;
|
const d = dragRef.current;
|
||||||
if (!d?.active) return;
|
if (!d?.active) return;
|
||||||
const vh = window.innerHeight;
|
const br = el.getBoundingClientRect();
|
||||||
|
const speed = (into: number) =>
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil(
|
||||||
|
(into / AUTOSCROLL_MARGIN_PX) * AUTOSCROLL_MAX_SPEED_PX,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let dx = 0;
|
||||||
let dy = 0;
|
let dy = 0;
|
||||||
if (d.curPt.y < AUTOSCROLL_MARGIN_PX) {
|
if (d.curPt.x < br.left + AUTOSCROLL_MARGIN_PX) {
|
||||||
dy = -Math.max(
|
dx = -speed(br.left + AUTOSCROLL_MARGIN_PX - d.curPt.x);
|
||||||
1,
|
} else if (d.curPt.x > br.right - AUTOSCROLL_MARGIN_PX) {
|
||||||
Math.ceil(
|
dx = speed(d.curPt.x - (br.right - AUTOSCROLL_MARGIN_PX));
|
||||||
((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) {
|
if (d.curPt.y < br.top + AUTOSCROLL_MARGIN_PX) {
|
||||||
window.scrollBy(0, dy);
|
dy = -speed(br.top + AUTOSCROLL_MARGIN_PX - d.curPt.y);
|
||||||
|
} else if (d.curPt.y > br.bottom - AUTOSCROLL_MARGIN_PX) {
|
||||||
|
dy = speed(d.curPt.y - (br.bottom - AUTOSCROLL_MARGIN_PX));
|
||||||
|
}
|
||||||
|
if (dx !== 0 || dy !== 0) {
|
||||||
|
el.scrollBy(dx, dy);
|
||||||
schedulePreview();
|
schedulePreview();
|
||||||
}
|
}
|
||||||
scrollRafRef.current = requestAnimationFrame(step);
|
scrollRafRef.current = requestAnimationFrame(step);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue