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:
Your Name 2026-09-22 09:57:55 +07:00
parent f7b5a8488d
commit f7cd4b8312
4 changed files with 96 additions and 64 deletions

File diff suppressed because one or more lines are too long

View file

@ -247,6 +247,16 @@ html {
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) —
ปิด native selection/callout ของ OS และยึด gesture ลากเป็นของเราทั้งหมด
(touch-action:none กัน browser ยึดไปเป็น scroll → pointercancel)

View file

@ -210,11 +210,6 @@ function normalizeBookmarkTitle(s: string): string {
: t;
}
/** ความสูง sticky header ของ viewer — ใช้เว้นระยะเวลาเลื่อนหน้าไม่ให้เป้าหมายถูก header บัง */
function viewerHeaderHeight(): number {
return document.querySelector("header")?.offsetHeight ?? 0;
}
/** ความกว้างจริงของเมนู dropdown ของ tool (w-60) กับ popup bookmark (w-72) —
* ใช้ clamp ตำแหน่ง fixed ให้อยู่ในจอ (C1) */
const TOOL_MENU_W = 240;
@ -230,13 +225,14 @@ function fixedMenuPos(rect: DOMRect, menuW: number): { left: number; top: number
};
}
/** เลื่อนหน้าต่างให้ตำแหน่ง absolute (px จากบนสุดของ document) ไปอยู่ใต้ sticky header
* (เว้นระยะห่างจาก toolbar 40px ให้รายการไม่ติดแถบเครื่องมือเกินไป) */
/** เลื่อน scroll container ของเอกสาร (<main> — ตัวเลื่อนเดียวของ viewer) ให้ตำแหน่ง
* contentTop (px จากบนสุดของเนื้อหาใน container) ไปอยู่แถวบนสุดของพื้นที่มองเห็น —
* header อยู่นอกกรอบ scroll อยู่แล้ว จึงเว้นแค่ช่องว่าง 40px ให้รายการไม่ติดขอบบนเกินไป */
const SCROLL_BELOW_HEADER_GAP = 40;
function scrollToBelowHeader(absoluteTop: number): void {
window.scrollTo({
top: Math.max(0, absoluteTop - viewerHeaderHeight() - SCROLL_BELOW_HEADER_GAP),
function scrollToBelowHeader(main: HTMLElement | null, contentTop: number): void {
main?.scrollTo({
top: Math.max(0, contentTop - SCROLL_BELOW_HEADER_GAP),
// L7 (UX audit 2026-09-16): เคารพ prefers-reduced-motion — คู่กับ rule ใน globals.css
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches
? "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" */
function squigglePath(w: number): string {
let d = "M0 2.5";
@ -1585,6 +1588,8 @@ export default function PDFViewer({
}, [file.id, file.pdfUrl]);
const pageWrapRefs = useRef<(HTMLDivElement | null)[]>([]);
// scroll container ของเอกสาร (<main>) — ตัวเลื่อนเดียวของ viewer (sidebar/header นิ่ง)
const mainRef = useRef<HTMLElement>(null);
// Page Virtualization: render เฉพาะหน้าใน viewport ± 1 หน้า (invariant #10)
const [renderPages, setRenderPages] = useState<Set<number>>(new Set([0]));
@ -1609,7 +1614,8 @@ export default function PDFViewer({
}
setRenderPages(next);
},
{ rootMargin: "100% 0px" },
// root = scroll container ของเอกสาร ไม่ใช่ viewport (window ไม่ scroll แล้ว)
{ root: mainRef.current, rootMargin: "100% 0px" },
);
for (const el of pageWrapRefs.current) {
if (el) io.observe(el);
@ -1621,14 +1627,18 @@ export default function PDFViewer({
useEffect(() => {
if (pageSizes.length === 0) return;
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 bestDist = Infinity;
for (let i = 0; i < pageWrapRefs.current.length; i++) {
const el = pageWrapRefs.current[i];
if (!el) continue;
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);
if (d < bestDist) {
bestDist = d;
@ -1638,8 +1648,8 @@ export default function PDFViewer({
setCurrentPage(best);
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
mainRef.current?.addEventListener("scroll", onScroll, { passive: true });
return () => mainRef.current?.removeEventListener("scroll", onScroll);
}, [pageSizes]);
// 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) {
targetPage = Math.min(pageParam - 1, pageSizes.length - 1);
const el = pageWrapRefs.current[targetPage];
if (el) {
scrollToBelowHeader(el.getBoundingClientRect().top + window.scrollY);
const main = mainRef.current;
if (el && main) {
scrollToBelowHeader(main, contentTopInScroll(main, el.getBoundingClientRect().top));
}
}
}, [pageSizes, annotations]);
@ -1674,7 +1685,6 @@ export default function PDFViewer({
// จอเล็ก: หน้ากระดาษ (scale 1) กว้างกว่าพื้นที่แสดงผล → ย่อให้พอดีความกว้าง
// ครั้งเดียวตอนโหลดเท่านั้น (ผู้ใช้ซูมเองหลังจากนี้แล้วไม่ถูกแทนที่) —
// อิงความกว้างหน้าแรกเหมือน basePageWidth และตัด padding px-4 ซ้าย-ขวาของ main ออก
const mainRef = useRef<HTMLElement>(null);
const fitWidthDoneRef = useRef(false);
useEffect(() => {
if (pageSizes.length === 0 || fitWidthDoneRef.current) return;
@ -2192,14 +2202,14 @@ export default function PDFViewer({
/** ตำแหน่งที่กำลังดู: หน้าปัจจุบัน + จุดกึ่งกลางจอเป็น % ของความสูงหน้า */
function currentViewPosition() {
const el = pageWrapRefs.current[currentPage];
if (!el) return null;
const main = mainRef.current;
if (!el || !main) return null;
const r = el.getBoundingClientRect();
// นิยาม yOffset แบบเดียวกับพิกัด annotation: "จุดจริงบนหน้า" — ใช้ขอบบนของ
// แถบมองเห็นใต้ header (+gap เท่ากับตอนกดผ่าน scrollToBelowHeader) ตอนกด
// bookmark จุดนี้จะกลับมาอยู่ตำแหน่งเดิมพอดี ไม่ใช่กึ่งกลางจอแบบเดิม
const viewTop =
window.scrollY + viewerHeaderHeight() + SCROLL_BELOW_HEADER_GAP;
const pageTop = r.top + window.scrollY;
// แถบมองเห็นของ scroll container (+gap เท่ากับตอนกดผ่าน scrollToBelowHeader)
// ตอนกด bookmark จุดนี้จะกลับมาอยู่ตำแหน่งเดิมพอดี ไม่ใช่กึ่งกลางจอแบบเดิม
const viewTop = main.scrollTop + SCROLL_BELOW_HEADER_GAP;
const pageTop = contentTopInScroll(main, r.top);
return {
pageIndex: currentPage,
yOffset: clamp(((viewTop - pageTop) / r.height) * 100, 0, 100),
@ -2708,13 +2718,14 @@ export default function PDFViewer({
// เห็นเป้าหมายทันที ไม่ต้องปิดเองก่อน (deep link ผ่าน goToAnnotation ก็เช่นกัน)
if (!window.matchMedia("(min-width: 1024px)").matches) setSidebarOpen(false);
const el = pageWrapRefs.current[b.pageIndex];
if (!el) return;
const main = mainRef.current;
if (!el || !main) return;
const r = el.getBoundingClientRect();
const pageTop = r.top + window.scrollY;
// ดันจุด yOffset กลับมาชิดใต้ sticky header — pattern เดียวกับ goToAnnotation
const pageTop = contentTopInScroll(main, r.top);
// ดันจุด yOffset กลับมาชิดขอบบนของพื้นที่มองเห็น — pattern เดียวกับ goToAnnotation
// ที่ดันขอบบน bbox (yOffset เก็บจาก "ขอบบนของสิ่งที่ดูอยู่" ตอนสร้าง กดแล้วจึงได้
// มุมมองเดิมพอดี ไม่ต้องชดเชยครึ่งจอ)
scrollToBelowHeader(pageTop + (r.height * b.yOffset) / 100);
scrollToBelowHeader(main, pageTop + (r.height * b.yOffset) / 100);
}
function goToAnnotation(a: AnnotationDTO) {
@ -2727,10 +2738,11 @@ export default function PDFViewer({
setPreviewData(null);
setOpenPinId(null); // กระโดดไปเลือกรายการอื่น = ปิด popover ของ pin เดิมทิ้ง
const el = pageWrapRefs.current[a.pageIndex];
if (!el) return;
const main = mainRef.current;
if (!el || !main) return;
const r = el.getBoundingClientRect();
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) */
@ -2832,9 +2844,10 @@ export default function PDFViewer({
* placeholder ทุกหน้ามีขนาดจริงจึงคำนวณ absolute scroll ได้เสมอ) */
function goToPage(pageIndex: number) {
const el = pageWrapRefs.current[pageIndex];
if (!el) return;
const main = mainRef.current;
if (!el || !main) return;
const r = el.getBoundingClientRect();
scrollToBelowHeader(r.top + window.scrollY);
scrollToBelowHeader(main, contentTopInScroll(main, r.top));
}
// ─── Render ────────────────────────────────────────────────────────────────
@ -2860,7 +2873,10 @@ export default function PDFViewer({
const basePageWidth = pageSizes[0]?.width ?? 595;
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 ของเบราว์เซอร์
ให้เห็นแต่ preview มาร์ก/selection สีฟ้าของเรา (กรองแนวเส้นทางลากแล้ว
สะอาดกว่า native) */}
@ -2876,12 +2892,13 @@ export default function PDFViewer({
onClick={() => setSidebarOpen(false)}
/>
)}
{/* lg+ = sticky แยกคอลัมน์ (เปิดแล้ว content ขยับไปข้าง ๆ ไม่บังกัน)
{/* lg+ = แยกคอลัมน์เต็มความสูง shell (เปิดแล้ว content ขยับไปข้าง ๆ ไม่บังกัน)
/ < lg = overlay เลื่อนเข้า-ออก กว้างเป็นสัดส่วนของจอกว้างกว่า 288px
(ปิดบน desktop ใช้ lg:hidden ออกจาก flow → main เต็มจอ) */}
(ปิดบน desktop ใช้ lg:hidden ออกจาก flow → main เต็มจอ; top-0 จำเป็น
ตอน max-lg:fixed ยึดขอบบนจอ) */}
<aside
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
? "max-lg:translate-x-0"
: "max-lg:pointer-events-none max-lg:-translate-x-full lg:hidden"
@ -2924,7 +2941,7 @@ export default function PDFViewer({
</div>
</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 (ชิดขวา) */}
<header className="sticky top-0 z-40 border-b border-slate-200 bg-white">
@ -3709,7 +3726,7 @@ export default function PDFViewer({
<main
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={{
cursor:
tool === "pan"
@ -3729,7 +3746,7 @@ export default function PDFViewer({
onPointerMove={(e) => {
const p = panRef.current;
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 };
}}
onPointerUp={() => {
@ -3764,7 +3781,7 @@ export default function PDFViewer({
= แอปค้าง" (แสดง 3 ครั้งแรกต่อ session) */}
{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>
)}

View file

@ -25,7 +25,7 @@ const MOVE_THRESHOLD_PX = 8;
/** อ่าน caret จากจุด y ตรงนิ้วเลื่อนขึ้น 12px — นิ้วบังข้อความที่กำลังเลือก
* (เฉพาะ pointerType touch; เมาส์/สไตลัสชี้แม่นอยู่แล้ว) */
const FINGER_OFFSET_PX = 12;
/** นิ้วอยู่ใกล้ขอบบน/ล่างของ viewport แค่นี้ = auto-scroll ต่อ (rAF loop) */
/** นิ้วอยู่ใกล้ขอบของพื้นที่เอกสาร (ทั้ง 4 ด้าน) แค่นี้ = auto-scroll ต่อ (rAF loop) */
const AUTOSCROLL_MARGIN_PX = 60;
const AUTOSCROLL_MAX_SPEED_PX = 12;
@ -503,35 +503,39 @@ export function useCustomSelection({
});
};
/** auto-scroll ระหว่างลากค้างใกล้ขอบจอ — rAF loop เลื่อน window แล้วคำนวณ preview
* ใหม่ (จุดนิ้วเดิมชี้ข้อความใหม่หลัง scroll) จบเองเมื่อเลิกลาก */
/** auto-scroll ระหว่างลากค้างใกล้ขอบจอ — rAF loop เลื่อน scroll container ของ
* เอกสาร (el = <main> ตัวเลื่อนเดียวของ viewer, ทั้งแกนตั้งและแกนนอนกรณี
* ซูมจนหน้ากว้างเกินจอ) แล้วคำนวณ preview ใหม่ (จุดนิ้วเดิมชี้ข้อความใหม่หลัง
* scroll) จบเองเมื่อเลิกลาก — ขอบวัดจากกรอบของ el เอง (ใต้ header) ไม่ใช่
* ขอบจอ เพราะส่วนบนถูก header บังอยู่ */
const startAutoScroll = () => {
if (scrollRafRef.current !== null) return;
const step = () => {
scrollRafRef.current = null;
const d = dragRef.current;
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;
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 (d.curPt.x < br.left + AUTOSCROLL_MARGIN_PX) {
dx = -speed(br.left + AUTOSCROLL_MARGIN_PX - d.curPt.x);
} else if (d.curPt.x > br.right - AUTOSCROLL_MARGIN_PX) {
dx = speed(d.curPt.x - (br.right - AUTOSCROLL_MARGIN_PX));
}
if (dy !== 0) {
window.scrollBy(0, dy);
if (d.curPt.y < br.top + AUTOSCROLL_MARGIN_PX) {
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();
}
scrollRafRef.current = requestAnimationFrame(step);