Compare commits

..

No commits in common. "0aef3ff028d94a023d7faf02f03e76b29553245a" and "f7b5a8488ddfd78d8af95d949eff652356df4901" have entirely different histories.

9 changed files with 116 additions and 322 deletions

File diff suppressed because one or more lines are too long

View file

@ -14,7 +14,7 @@ import {
type PDFPage, type PDFPage,
type PDFRef, type PDFRef,
} from "pdf-lib"; } from "pdf-lib";
import { and, asc, eq, inArray } from "drizzle-orm"; import { asc, eq, inArray } from "drizzle-orm";
import { db } from "@/db"; import { db } from "@/db";
import { PdfPathError, readPublicPdf } from "@/lib/server-pdf"; import { PdfPathError, readPublicPdf } from "@/lib/server-pdf";
@ -274,21 +274,9 @@ export async function GET(req: Request) {
.from(annotations) .from(annotations)
.where(eq(annotations.fileId, fileId)) .where(eq(annotations.fileId, fileId))
.orderBy(asc(annotations.createdAt)); .orderBy(asc(annotations.createdAt));
// F4: export ตาม filter รายคนที่กำลังดู (?user=<userId> จาก sidebar — ไม่ส่ง = ทั้งหมด)
// REDACTION ติดไปเสมอแม้กรองคนอื่น (D4 — กล่องดำหายไป = เนื้อหาที่ปิดกั้นกลับมาอ่านได้)
const userParam = url.searchParams.get("user");
const filteredRows = userParam
? rows.filter((r) => r.userId === userParam || r.type === "REDACTION")
: rows;
// ชื่อผู้ใช้เติมท้ายชื่อไฟล์ (mirror ในตาราง — ไม่รับ text จาก client) — กรองแล้ว
// ผู้นั้นยังไม่มีรายการเลย = ใช้ userId ตรง ๆ
const filterName = userParam
? (rows.find((r) => r.userId === userParam && r.userName)?.userName ??
userParam)
: null;
// sort แยกตามชนิดก่อนวาด (stable ภายในชนิดด้วย createdAt) — กัน REDACTION เก่าโดน // sort แยกตามชนิดก่อนวาด (stable ภายในชนิดด้วย createdAt) — กัน REDACTION เก่าโดน
// HIGHLIGHT/DRAW ที่สร้างทีหลังทับจนข้อความง้ำกลับมาอ่านได้ (D4) // HIGHLIGHT/DRAW ที่สร้างทีหลังทับจนข้อความง้ำกลับมาอ่านได้ (D4)
const dtos = filteredRows const dtos = rows
.map(toAnnotationDTO) .map(toAnnotationDTO)
.sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]); .sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]);
@ -646,16 +634,10 @@ export async function GET(req: Request) {
// bookmarks → PDF outline (แผง Bookmarks ของ reader กดกระโดดไปหน้า + yOffset) // bookmarks → PDF outline (แผง Bookmarks ของ reader กดกระโดดไปหน้า + yOffset)
// เรียงตามลำดับหน้าเหมือน sidebar (byPageOrder ของ N5: หน้า → ตำแหน่งบนหน้า → เวลาสร้าง) // เรียงตามลำดับหน้าเหมือน sidebar (byPageOrder ของ N5: หน้า → ตำแหน่งบนหน้า → เวลาสร้าง)
// กรองตามรายคนเหมือน annotation (คำขอผู้ใช้ 2026-09-22) — หัวข้อแม่โดนกรองแต่ลูกยังอยู่
// ลูกขึ้น root (buildOutline จัดการ parent หายเคสนี้อยู่แล้ว)
const bookmarkRows = await db const bookmarkRows = await db
.select() .select()
.from(bookmarks) .from(bookmarks)
.where( .where(eq(bookmarks.fileId, fileId))
userParam
? and(eq(bookmarks.fileId, fileId), eq(bookmarks.userId, userParam))
: eq(bookmarks.fileId, fileId),
)
.orderBy( .orderBy(
asc(bookmarks.pageIndex), asc(bookmarks.pageIndex),
asc(bookmarks.yOffset), asc(bookmarks.yOffset),
@ -664,9 +646,7 @@ export async function GET(req: Request) {
buildOutline(doc, bookmarkRows); buildOutline(doc, bookmarkRows);
const out = await doc.save(); const out = await doc.save();
const filename = encodeRFC5987( const filename = encodeRFC5987(`${file.title}-annotated.pdf`);
`${file.title}-annotated${filterName ? `-${filterName}` : ""}.pdf`,
);
return new Response(new Uint8Array(out), { return new Response(new Uint8Array(out), {
headers: { headers: {
"content-type": "application/pdf", "content-type": "application/pdf",

View file

@ -166,28 +166,6 @@
justify-content: center; justify-content: center;
} }
/* ─── ยกเว้น chevron หุบ/กางของต้นไม้ bookmark (bug report 2026-09-22) ──────
คอลัมน์นี้เป็น "ตาราง" ของต้นไม้ ต้องกว้างเท่าไอคอน 14px เท่ากับ spacer
(w-3.5) ของแถวลูกเสมอ — ถ้าปล่อย min-width 32px ดันไอคอน bookmark ของ
หัวข้อแม่ไปชนแนวพอดีกับของหัวข้อลูก (indent 16px/ระดับโดนกิน) ลำดับ
ชั้นมองไม่เห็น และ root มีลูก (chevron) กับไม่มีลูก (spacer) ก็เพี้ยน
ไม่ตรงแนวกันเอง — จึงคงขนาดจริง 14px แล้วขยาย hit area ด้วย ::after
แบบเดียวกับ sel-handle (จิ้มสบาย 32px เท่าเดิม โดยไม่จอง layout) */
aside[data-sidebar] button.bm-toggle {
min-height: 0;
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
}
aside[data-sidebar] button.bm-toggle::after {
content: "";
position: absolute;
/* ขยาย hit รอบไอคอน 14px เป็น 32px (±9px) โดยไม่กว้างขึ้นจริง */
inset: -0.5625rem;
}
/* M6: ตัด double-tap zoom ของ browser เมื่อแตะปุ่มรัว ๆ (ยัง pinch ได้ตามปกติ — /* M6: ตัด double-tap zoom ของ browser เมื่อแตะปุ่มรัว ๆ (ยัง pinch ได้ตามปกติ —
handle ของกรอบ selection เป็น div + touch-none อยู่แล้ว ไม่โดน rule นี้) */ handle ของกรอบ selection เป็น div + touch-none อยู่แล้ว ไม่โดน rule นี้) */
button, button,
@ -269,16 +247,6 @@ 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)

View file

@ -210,6 +210,11 @@ 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;
@ -225,14 +230,13 @@ function fixedMenuPos(rect: DOMRect, menuW: number): { left: number; top: number
}; };
} }
/** เลื่อน scroll container ของเอกสาร (<main> — ตัวเลื่อนเดียวของ viewer) ให้ตำแหน่ง /** เลื่อนหน้าต่างให้ตำแหน่ง absolute (px จากบนสุดของ document) ไปอยู่ใต้ sticky header
* contentTop (px จากบนสุดของเนื้อหาใน container) ไปอยู่แถวบนสุดของพื้นที่มองเห็น — * (เว้นระยะห่างจาก toolbar 40px ให้รายการไม่ติดแถบเครื่องมือเกินไป) */
* header อยู่นอกกรอบ scroll อยู่แล้ว จึงเว้นแค่ช่องว่าง 40px ให้รายการไม่ติดขอบบนเกินไป */
const SCROLL_BELOW_HEADER_GAP = 40; const SCROLL_BELOW_HEADER_GAP = 40;
function scrollToBelowHeader(main: HTMLElement | null, contentTop: number): void { function scrollToBelowHeader(absoluteTop: number): void {
main?.scrollTo({ window.scrollTo({
top: Math.max(0, contentTop - SCROLL_BELOW_HEADER_GAP), top: Math.max(0, absoluteTop - viewerHeaderHeight() - 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"
@ -240,13 +244,6 @@ function scrollToBelowHeader(main: HTMLElement | null, contentTop: 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";
@ -364,28 +361,18 @@ function selectionTextWithin(kept: DOMRect[], range: Range): string {
* ผู้ใช้ศาลไม่คุ้นไอคอนล้วน โดยเฉพาะจอสัมผัสที่ hover อ่าน title ไม่ได้ — แทน span * ผู้ใช้ศาลไม่คุ้นไอคอนล้วน โดยเฉพาะจอสัมผัสที่ hover อ่าน title ไม่ได้ — แทน span
* ชื่อ tool-active ของ H3 ที่ถอดออก) */ * ชื่อ tool-active ของ H3 ที่ถอดออก) */
const TOOLS: { key: Tool; label: string; text: string; Icon: LucideIcon }[] = [ const TOOLS: { key: Tool; label: string; text: string; Icon: LucideIcon }[] = [
{ key: "select", label: "เลือก", text: "เลือก", Icon: MousePointer2 }, { key: "select", label: "เลือกข้อความ", text: "เลือกข้อความ", Icon: MousePointer2 },
{ key: "pan", label: "เลื่อนหน้า (pan)", text: "เลื่อนหน้า", Icon: Hand }, { key: "pan", label: "เลื่อนหน้า (pan)", text: "เลื่อนหน้า", Icon: Hand },
{ key: "highlight", label: "มาร์กข้อความ (H)", text: "มาร์กข้อความ", Icon: Highlighter }, { key: "highlight", label: "มาร์กข้อความ (H)", text: "มาร์กข้อความ", Icon: Highlighter },
{ key: "draw", label: "วาดเส้น (D)", text: "วาดเส้น", Icon: Pencil }, { key: "draw", label: "วาดเส้น (D)", text: "วาดเส้น", Icon: Pencil },
{ key: "comment", label: "วางคอมเมนต์ (C)", text: "วางคอมเมนต์", Icon: MessageSquarePlus }, { key: "comment", label: "วางคอมเมนต์ (C)", text: "วางคอมเมนต์", Icon: MessageSquarePlus },
{ key: "shape", label: "รูปทรง", text: "รูปทรง", Icon: Shapes }, { key: "shape", label: "รูปทรง", text: "รูปทรง", Icon: Shapes },
{ key: "erase", label: "ยางลบ (เส้น/รูปทรง/ตรายางของตัวเอง)", text: "ยางลบ", Icon: Eraser }, { key: "erase", label: "ยางลบ (ลบด้วยการลาก)", text: "ยางลบ", Icon: Eraser },
{ key: "redact", label: "ปิดพราง (redaction)", text: "ปิดพราง", Icon: EyeOff }, { key: "redact", label: "ปิดพราง (redaction)", text: "ปิดพราง", Icon: EyeOff },
{ key: "textbox", label: "กล่องข้อความ", text: "กล่องข้อความ", Icon: Type }, { key: "textbox", label: "กล่องข้อความ", text: "กล่องข้อความ", Icon: Type },
{ key: "stamp", label: "ตรายาง (stamp)", text: "ตรายาง", Icon: Stamp }, { key: "stamp", label: "ตรายาง (stamp)", text: "ตรายาง", Icon: Stamp },
]; ];
/** แบนเนอร์แนะนำวิธีใช้ tool ที่วางของด้วยคลิกบนหน้า (UX audit 2026-09-22): เดิมเลือก tool
* แล้วจอนิ่ง ไม่มีอะไรบอกขั้นต่อไป (hint เดิมอยู่แค่ empty state ของ sidebar ที่หายทันที
* ที่มีรายการ) — แสดงตลอดตอน tool active ให้ทำหน้าที่ mode indicator ไปพร้อมกัน
* (เงื่อนไข tool เดี่ยว ๆ จึงไม่มีวันซ้อนกับแบนเนอร์ M3/M6/H2 ข้างบน) */
const PLACE_TOOL_HINTS: Partial<Record<Tool, string>> = {
comment: "โหมดวางคอมเมนต์: คลิก (แตะ) ตำแหน่งบนหน้าเพื่อวางกล่องคอมเมนต์",
textbox: "โหมดกล่องข้อความ: คลิก (แตะ) ตำแหน่งบนหน้าเพื่อวางกล่อง แล้วพิมพ์ข้อความ",
stamp: "โหมดตรายาง: คลิก (แตะ) ตำแหน่งบนหน้าเพื่อประทับตรายาง",
};
/** UX review 2026-09-14 M4: focus ring ตอน keyboard focus — ทั้งโปรเจกต์ไม่เคยมี focus-visible /** UX review 2026-09-14 M4: focus ring ตอน keyboard focus — ทั้งโปรเจกต์ไม่เคยมี focus-visible
* ปุ่ม toolbar จึงกลายเป็นกด Tab แล้วไม่รู้ focus อยู่ปุ่มไหน * ปุ่ม toolbar จึงกลายเป็นกด Tab แล้วไม่รู้ focus อยู่ปุ่มไหน
* ring เต็มสี + ring-offset คั่นขาว 1px ให้เห็นชัดแม้บนปุ่ม active ที่พื้น primary เอง * ring เต็มสี + ring-offset คั่นขาว 1px ให้เห็นชัดแม้บนปุ่ม active ที่พื้น primary เอง
@ -393,16 +380,6 @@ const PLACE_TOOL_HINTS: Partial<Record<Tool, string>> = {
const FOCUS_RING_CLS = const FOCUS_RING_CLS =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 focus-visible:z-10"; "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 focus-visible:z-10";
/** สเกลที่ทำให้หน้ากระดาษพอดีความกว้างของ main (ตัด padding px-4 ซ้าย-ขวา = 32px — สูตร
* เดิมของ effect fit-width ตอนโหลด ย้ายมาเป็น helper ใช้ร่วมกับปุ่ม % ที่ header ด้วย
* (UX audit 2026-09-22: ผู้ใช้ซูมดูรายละเอียดแล้วไม่มีทางกลับมาดูทั้งหน้า) — คิดจากความ
* กว้าง main ปัจจุบันทุกครั้งเพราะ sidebar/จออาจเปลี่ยนไปแล้ว; ยังไม่รู้ขนาดหน้า = null */
function fitWidthScale(mainW: number | undefined, pageW: number | undefined): number | null {
const avail = (mainW ?? 0) - 32;
if (!avail || !pageW) return null;
return pageW > avail ? clamp(Math.floor((avail / pageW) * 100) / 100, 0.5, 1) : 1;
}
/** ชนิดที่สร้างเสร็จแล้วเลือกรายการใหม่ให้อัตโนมัติ (กรอบ selection + sidebar ไฮไลต์) /** ชนิดที่สร้างเสร็จแล้วเลือกรายการใหม่ให้อัตโนมัติ (กรอบ selection + sidebar ไฮไลต์)
* ทุกชนิดยกเว้น COMMENT — เปิด composer พิมพ์ก่อนบันทึก และคลิก pin = เลือกอยู่แล้ว */ * ทุกชนิดยกเว้น COMMENT — เปิด composer พิมพ์ก่อนบันทึก และคลิก pin = เลือกอยู่แล้ว */
const AUTO_SELECT_TYPES = new Set<AnnotationType>([ const AUTO_SELECT_TYPES = new Set<AnnotationType>([
@ -526,7 +503,7 @@ function ReplyBox({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-[11px] font-medium text-slate-600"> <p className="text-[11px] font-medium text-slate-600">
{r.userName} {r.userName}
<span className="ml-1 font-normal text-slate-500" suppressHydrationWarning> <span className="ml-1 font-normal text-slate-400" suppressHydrationWarning>
{formatThaiDate(r.createdAt, true)} {formatThaiDate(r.createdAt, true)}
</span> </span>
</p> </p>
@ -648,7 +625,7 @@ function PdfPage({
/** live preview มาร์กระหว่างลากคลุมด้วย tool highlight (null = ไม่มี) */ /** live preview มาร์กระหว่างลากคลุมด้วย tool highlight (null = ไม่มี) */
liveMark: { pageIndex: number; rects: Rect[] } | null; liveMark: { pageIndex: number; rects: Rect[] } | null;
liveMarkStyle: { color: string; style: HighlightStyle; opacity?: number }; liveMarkStyle: { color: string; style: HighlightStyle; opacity?: number };
/** selection สีฟ้าสะอาด ๆ ของ tool เลือก — กรองแนวเส้นทางลากเหมือน liveMark /** selection สีฟ้าสะอาด ๆ ของ tool เลือกข้อความ — กรองแนวเส้นทางลากเหมือน liveMark
* แต่ไม่สร้างไฮไลต์ (ซ่อนสี selection ของเบราว์เซอร์ วาดเองแทน) */ * แต่ไม่สร้างไฮไลต์ (ซ่อนสี selection ของเบราว์เซอร์ วาดเองแทน) */
selectPreview: { pageIndex: number; rects: Rect[] } | null; selectPreview: { pageIndex: number; rects: Rect[] } | null;
/** ความกว้างหน้าแรกที่ scale 1 — แปลง px↔% ของ fontSize ในแผง selection */ /** ความกว้างหน้าแรกที่ scale 1 — แปลง px↔% ของ fontSize ในแผง selection */
@ -912,7 +889,7 @@ function PdfPage({
liveMarkStyle.opacity, liveMarkStyle.opacity,
), ),
)} )}
{/* selection สีฟ้าของ tool เลือก — กรองแนวเส้นทางลากแล้ว วาดเองแทน {/* selection สีฟ้าของ tool เลือกข้อความ — กรองแนวเส้นทางลากแล้ว วาดเองแทน
สี selection ของเบราว์เซอร์ (ไม่สร้างไฮไลต์) */} สี selection ของเบราว์เซอร์ (ไม่สร้างไฮไลต์) */}
{selectPreview && {selectPreview &&
selectPreview.pageIndex === pageNumber - 1 && selectPreview.pageIndex === pageNumber - 1 &&
@ -1104,7 +1081,7 @@ function PdfPage({
{a.data.message} {a.data.message}
</p> </p>
)} )}
<p className="mt-1 text-[11px] text-slate-500" suppressHydrationWarning> <p className="mt-1 text-[11px] text-slate-400" suppressHydrationWarning>
{formatThaiDate(a.createdAt, true)} {formatThaiDate(a.createdAt, true)}
</p> </p>
{/* F10/A5: toggle จัดการแล้ว — ใครก็ได้ (D6) */} {/* F10/A5: toggle จัดการแล้ว — ใครก็ได้ (D6) */}
@ -1231,7 +1208,7 @@ export default function PDFViewer({
const [currentPage, setCurrentPage] = useState(0); const [currentPage, setCurrentPage] = useState(0);
// tool highlight — live preview มาร์กตามรูปแบบ/สีที่ตั้งไว้ ระหว่างลากคลุมข้อความ // tool highlight — live preview มาร์กตามรูปแบบ/สีที่ตั้งไว้ ระหว่างลากคลุมข้อความ
const [liveMark, setLiveMark] = useState<{ pageIndex: number; rects: Rect[] } | null>(null); const [liveMark, setLiveMark] = useState<{ pageIndex: number; rects: Rect[] } | null>(null);
// tool เลือก — selection สีฟ้าสะอาด ๆ (กรองแนวเส้นทางลากแล้ว ไม่สร้างไฮไลต์) // tool เลือกข้อความ — selection สีฟ้าสะอาด ๆ (กรองแนวเส้นทางลากแล้ว ไม่สร้างไฮไลต์)
const [selectPreview, setSelectPreview] = useState<{ pageIndex: number; rects: Rect[] } | null>(null); const [selectPreview, setSelectPreview] = useState<{ pageIndex: number; rects: Rect[] } | null>(null);
const [composer, setComposer] = useState<Composer | null>(null); const [composer, setComposer] = useState<Composer | null>(null);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
@ -1322,15 +1299,11 @@ export default function PDFViewer({
return Array.from(seen, ([userId, userName]) => ({ userId, userName })); return Array.from(seen, ([userId, userName]) => ({ userId, userName }));
}, [optimisticAnnotations, user]); }, [optimisticAnnotations, user]);
// F4: กรองฝั่ง client ล้วน — ไม่แตะข้อมูลใน DB (A3) — REDACTION คงไว้เสมอแม้ // F4: กรองฝั่ง client ล้วน — ไม่แตะข้อมูลใน DB (A3)
// กรองดูของคนอื่น (D4 สูตรเดียวกับ export: กล่องดำหายจากจอ = เนื้อหาที่ปิดกั้น
// กลับมาอ่านได้ และจอจะไม่ตรงกับไฟล์ export ที่คงกล่องไว้)
const visibleAnnotations = useMemo(() => { const visibleAnnotations = useMemo(() => {
if (userFilter === "all") return optimisticAnnotations; if (userFilter === "all") return optimisticAnnotations;
const target = userFilter === "mine" ? user?.userId : userFilter; const target = userFilter === "mine" ? user?.userId : userFilter;
return optimisticAnnotations.filter( return optimisticAnnotations.filter((a) => a.userId === target);
(a) => a.userId === target || a.type === "REDACTION",
);
}, [optimisticAnnotations, userFilter, user]); }, [optimisticAnnotations, userFilter, user]);
// FIX-8: bbox (หน่วย %) ของรายการที่ hover ตรวจได้ — เดิน paths // FIX-8: bbox (หน่วย %) ของรายการที่ hover ตรวจได้ — เดิน paths
@ -1472,20 +1445,6 @@ export default function PDFViewer({
); );
}, []); }, []);
// PDF สแกนล้วน — ซ่อน tool มาร์กข้อความเหมือนไฟล์ภาพ (คำขอผู้ใช้ 2026-09-22):
// "ทุกหน้าที่ render แล้วไม่มี span เลย" โดย entry ของ pageSpanCount สะสมต่อหน้า
// และไม่ถูกลบตอน virtualization unmount (รีเซ็ตเฉพาะตอนสลับไฟล์) จึงเป็น latch
// ธรรมชาติ — เคยเจอหน้าที่มีข้อความแม้หน้าเดียว ไฟล์นี้ถือว่ามีข้อความ ปุ่มกลับ
// มาและไม่หายอีก (ไฟล์ผสมที่สแกนนำหน้า: ซ่อนช่วงแรก โผล่ครั้งเดียวเมื่อถึงหน้า
// ข้อความ — ดีกว่าปุ่มโผล่-หายตามหน้าที่กำลังเปิดดู)
const scannedOnly =
Object.keys(pageSpanCount).length > 0 && Object.values(pageSpanCount).every((n) => n === 0);
// ระหว่างอยู่ tool มาร์กข้อความแล้วผลว่าเป็นไฟล์สแกน (เช่น กด H ตอนหน้าแรกยัง
// ไม่ render) — กลับเครื่องมือเลือกให้ ไม่ค้างเป็น tool ที่ปุ่มหายไปแล้ว
useEffect(() => {
if (scannedOnly) setTool((prev) => (prev === "highlight" ? "select" : prev));
}, [scannedOnly]);
// F8: Export PDF — ดาวน์โหลดเงียบ ๆ ในแท็บเดิม (เดิม window.open _blank จะแวบแท็บใหม่ // F8: Export PDF — ดาวน์โหลดเงียบ ๆ ในแท็บเดิม (เดิม window.open _blank จะแวบแท็บใหม่
// และ error พา viewer ไปโผล่แท็บนั้น): fetch เป็น blob แล้วกด <a download> โดยรับชื่อ // และ error พา viewer ไปโผล่แท็บนั้น): fetch เป็น blob แล้วกด <a download> โดยรับชื่อ
// ไฟล์จาก Content-Disposition ของ route (รองรับภาษาไทยผ่าน filename*) — พัง = toast ในหน้า // ไฟล์จาก Content-Disposition ของ route (รองรับภาษาไทยผ่าน filename*) — พัง = toast ในหน้า
@ -1494,19 +1453,7 @@ export default function PDFViewer({
setExporting(true); setExporting(true);
setExportError(null); setExportError(null);
try { try {
// F4: export ตาม filter รายคนที่กำลังดู ("mine" resolve เป็น userId จริงก่อนส่ง — const res = await fetch(withBasePath(`/api/annotations/export?fileId=${file.id}`));
// route กรองฝั่ง server + เติมชื่อผู้ใช้ท้ายชื่อไฟล์, REDACTION ติดไปเสมอ)
const filterUser =
userFilter === "all"
? null
: userFilter === "mine"
? (user?.userId ?? null)
: userFilter;
const res = await fetch(
withBasePath(
`/api/annotations/export?fileId=${file.id}${filterUser ? `&user=${encodeURIComponent(filterUser)}` : ""}`,
),
);
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => ""); const text = await res.text().catch(() => "");
throw new Error(text || `HTTP ${res.status}`); throw new Error(text || `HTTP ${res.status}`);
@ -1553,10 +1500,6 @@ export default function PDFViewer({
if (fileKindFromUrl(file.pdfUrl) === "image") { if (fileKindFromUrl(file.pdfUrl) === "image") {
setDoc(null); setDoc(null);
setPdfjs(null); setPdfjs(null);
// สลับจากไฟล์ PDF ที่ tool มาร์กข้อความ active อยู่มาเป็นไฟล์ภาพ (dynamic param
// เปลี่ยน = Next reuse component เดิม ไม่ reset state): tool นี้ถูกซ่อนบนไฟล์
// ภาพ ต้องกลับไปเครื่องมือเลือก ไม่งั้นค้างเป็น tool ที่กดไม่ได้
setTool((prev) => (prev === "highlight" ? "select" : prev));
const objectUrls: string[] = []; const objectUrls: string[] = [];
// URL จริงที่ browser ใช้โหลดไฟล์ (fetch/img.src ตรง — ต้องเติม basePath เอง) // URL จริงที่ browser ใช้โหลดไฟล์ (fetch/img.src ตรง — ต้องเติม basePath เอง)
const fileUrl = withBasePath(file.pdfUrl); const fileUrl = withBasePath(file.pdfUrl);
@ -1642,8 +1585,6 @@ 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]));
@ -1668,8 +1609,7 @@ export default function PDFViewer({
} }
setRenderPages(next); setRenderPages(next);
}, },
// root = scroll container ของเอกสาร ไม่ใช่ viewport (window ไม่ scroll แล้ว) { rootMargin: "100% 0px" },
{ 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);
@ -1681,18 +1621,14 @@ export default function PDFViewer({
useEffect(() => { useEffect(() => {
if (pageSizes.length === 0) return; if (pageSizes.length === 0) return;
const onScroll = () => { const onScroll = () => {
const main = mainRef.current; const center = window.scrollY + window.innerHeight / 2;
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 = contentTopInScroll(main, r.top + r.height / 2); const mid = r.top + window.scrollY + 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;
@ -1702,8 +1638,8 @@ export default function PDFViewer({
setCurrentPage(best); setCurrentPage(best);
}; };
onScroll(); onScroll();
mainRef.current?.addEventListener("scroll", onScroll, { passive: true }); window.addEventListener("scroll", onScroll, { passive: true });
return () => mainRef.current?.removeEventListener("scroll", onScroll); return () => window.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>` — อ่าน
@ -1729,9 +1665,8 @@ 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];
const main = mainRef.current; if (el) {
if (el && main) { scrollToBelowHeader(el.getBoundingClientRect().top + window.scrollY);
scrollToBelowHeader(main, contentTopInScroll(main, el.getBoundingClientRect().top));
} }
} }
}, [pageSizes, annotations]); }, [pageSizes, annotations]);
@ -1739,12 +1674,16 @@ 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;
fitWidthDoneRef.current = true; fitWidthDoneRef.current = true;
const s = fitWidthScale(mainRef.current?.clientWidth, pageSizes[0].width); const avail = (mainRef.current?.clientWidth ?? 0) - 32;
if (s !== null && s !== 1) setScale(s); const pageW = pageSizes[0].width;
if (avail > 0 && pageW > avail) {
setScale(clamp(Math.floor((avail / pageW) * 100) / 100, 0.5, 1));
}
}, [pageSizes]); }, [pageSizes]);
useEffect(() => { useEffect(() => {
@ -2253,14 +2192,14 @@ export default function PDFViewer({
/** ตำแหน่งที่กำลังดู: หน้าปัจจุบัน + จุดกึ่งกลางจอเป็น % ของความสูงหน้า */ /** ตำแหน่งที่กำลังดู: หน้าปัจจุบัน + จุดกึ่งกลางจอเป็น % ของความสูงหน้า */
function currentViewPosition() { function currentViewPosition() {
const el = pageWrapRefs.current[currentPage]; const el = pageWrapRefs.current[currentPage];
const main = mainRef.current; if (!el) return null;
if (!el || !main) return null;
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect();
// นิยาม yOffset แบบเดียวกับพิกัด annotation: "จุดจริงบนหน้า" — ใช้ขอบบนของ // นิยาม yOffset แบบเดียวกับพิกัด annotation: "จุดจริงบนหน้า" — ใช้ขอบบนของ
// แถบมองเห็นของ scroll container (+gap เท่ากับตอนกดผ่าน scrollToBelowHeader) // แถบมองเห็นใต้ header (+gap เท่ากับตอนกดผ่าน scrollToBelowHeader) ตอนกด
// ตอนกด bookmark จุดนี้จะกลับมาอยู่ตำแหน่งเดิมพอดี ไม่ใช่กึ่งกลางจอแบบเดิม // bookmark จุดนี้จะกลับมาอยู่ตำแหน่งเดิมพอดี ไม่ใช่กึ่งกลางจอแบบเดิม
const viewTop = main.scrollTop + SCROLL_BELOW_HEADER_GAP; const viewTop =
const pageTop = contentTopInScroll(main, r.top); window.scrollY + viewerHeaderHeight() + SCROLL_BELOW_HEADER_GAP;
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),
@ -2692,7 +2631,7 @@ export default function PDFViewer({
pageEl: HTMLElement, pageEl: HTMLElement,
) { ) {
// ปล่อยเมาส์จากการลากคลุมข้อความ (comment/textbox/stamp ก็คลุมได้) ไม่ถือเป็นคลิก — // ปล่อยเมาส์จากการลากคลุมข้อความ (comment/textbox/stamp ก็คลุมได้) ไม่ถือเป็นคลิก —
// selection ยังค้างตอน click ยิง ให้ผ่านไปเฉย ๆ เหมือน tool เลือก ไม่เปิด // selection ยังค้างตอน click ยิง ให้ผ่านไปเฉย ๆ เหมือน tool เลือกข้อความ ไม่เปิด
// composer/วางตรายาง (คลิกล้วน ๆ selection ถูกยุบตั้งแต่ mousedown จึงไม่โดน guard นี้) // composer/วางตรายาง (คลิกล้วน ๆ selection ถูกยุบตั้งแต่ mousedown จึงไม่โดน guard นี้)
const selection = window.getSelection(); const selection = window.getSelection();
if (selection && !selection.isCollapsed) return; if (selection && !selection.isCollapsed) return;
@ -2769,14 +2708,13 @@ 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];
const main = mainRef.current; if (!el) return;
if (!el || !main) return;
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect();
const pageTop = contentTopInScroll(main, r.top); const pageTop = r.top + window.scrollY;
// ดันจุด yOffset กลับมาชิดขอบบนของพื้นที่มองเห็น — pattern เดียวกับ goToAnnotation // ดันจุด yOffset กลับมาชิดใต้ sticky header — pattern เดียวกับ goToAnnotation
// ที่ดันขอบบน bbox (yOffset เก็บจาก "ขอบบนของสิ่งที่ดูอยู่" ตอนสร้าง กดแล้วจึงได้ // ที่ดันขอบบน bbox (yOffset เก็บจาก "ขอบบนของสิ่งที่ดูอยู่" ตอนสร้าง กดแล้วจึงได้
// มุมมองเดิมพอดี ไม่ต้องชดเชยครึ่งจอ) // มุมมองเดิมพอดี ไม่ต้องชดเชยครึ่งจอ)
scrollToBelowHeader(main, pageTop + (r.height * b.yOffset) / 100); scrollToBelowHeader(pageTop + (r.height * b.yOffset) / 100);
} }
function goToAnnotation(a: AnnotationDTO) { function goToAnnotation(a: AnnotationDTO) {
@ -2789,11 +2727,10 @@ 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];
const main = mainRef.current; if (!el) return;
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(main, contentTopInScroll(main, r.top) + (r.height * boxPct.y) / 100); scrollToBelowHeader(r.top + window.scrollY + (r.height * boxPct.y) / 100);
} }
/** คลิก comment pin = เปิด popover + เลือกรายการ (F1) */ /** คลิก comment pin = เปิด popover + เลือกรายการ (F1) */
@ -2862,9 +2799,6 @@ export default function PDFViewer({
} }
function switchTool(t: Tool) { function switchTool(t: Tool) {
// ไฟล์ภาพ/ไฟล์สแกนล้วน: มาร์กข้อความใช้ไม่ได้ (ไม่มีข้อความให้คลุม) — ปุ่มถูกซ่อน
// ไปแล้ว เหลือทางเข้า tool นี้คือคีย์ลัด H จึงกันตรงนี้ให้จบ (สลับ tool อื่นไม่กระทบ)
if (t === "highlight" && hideHighlightTool) return;
setTool(t); setTool(t);
// M4: ปิดเมนูเพราะเปลี่ยน tool — คลิกที่ตามมาต้องไม่กลายเป็นการวางรายการ (เฉพาะเมื่อ // M4: ปิดเมนูเพราะเปลี่ยน tool — คลิกที่ตามมาต้องไม่กลายเป็นการวางรายการ (เฉพาะเมื่อ
// มีเมนูเปิดจริง: คลิกปุ่ม tool เดี่ยวไม่มีเมนูให้ปิด ไม่ต้อง mark กันกลืนคลิกแรก) // มีเมนูเปิดจริง: คลิกปุ่ม tool เดี่ยวไม่มีเมนูให้ปิด ไม่ต้อง mark กันกลืนคลิกแรก)
@ -2898,21 +2832,13 @@ 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];
const main = mainRef.current; if (!el) return;
if (!el || !main) return;
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect();
scrollToBelowHeader(main, contentTopInScroll(main, r.top)); scrollToBelowHeader(r.top + window.scrollY);
} }
// ─── Render ──────────────────────────────────────────────────────────────── // ─── Render ────────────────────────────────────────────────────────────────
// N3 ไฟล์ภาพ + PDF สแกนล้วน (คำขอผู้ใช้ 2026-09-22): ไม่มีข้อความให้คลุม — ซ่อน tool
// มาร์กข้อความ (ไฮไลต์/เส้นใต้/ขีดฆ่า/ขยุกขยิก) ทั้งปุ่มและเมนูรูปแบบ — กันเข้าทาง
// คีย์ลัด H ที่ switchTool และ reset tool ที่ค้างอยู่ (effect ของแต่ละกรณี)
const isImageFile = fileKindFromUrl(file.pdfUrl) === "image";
const hideHighlightTool = isImageFile || scannedOnly;
const visibleTools = hideHighlightTool ? TOOLS.filter((t) => t.key !== "highlight") : TOOLS;
// ค่าปากกาปัจจุบันรวมเป็นก้อนเดียว — ใช้ทั้ง live preview และตอนบันทึกเส้นใหม่ // ค่าปากกาปัจจุบันรวมเป็นก้อนเดียว — ใช้ทั้ง live preview และตอนบันทึกเส้นใหม่
const drawStyle = { const drawStyle = {
color: drawColor, color: drawColor,
@ -2934,10 +2860,7 @@ export default function PDFViewer({
const basePageWidth = pageSizes[0]?.width ?? 595; const basePageWidth = pageSizes[0]?.width ?? 595;
return ( return (
// 2026-09-22: app shell สูงจอเดียว (dvh กันปัญหา URL bar ของจอสัมผัส) — <div className="flex min-h-screen">
// ตัวเลื่อนเดียวของหน้าคือ <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) */}
@ -2953,13 +2876,12 @@ export default function PDFViewer({
onClick={() => setSidebarOpen(false)} onClick={() => setSidebarOpen(false)}
/> />
)} )}
{/* lg+ = แยกคอลัมน์เต็มความสูง shell (เปิดแล้ว content ขยับไปข้าง ๆ ไม่บังกัน) {/* lg+ = sticky แยกคอลัมน์ (เปิดแล้ว content ขยับไปข้าง ๆ ไม่บังกัน)
/ < lg = overlay เลื่อนเข้า-ออก กว้างเป็นสัดส่วนของจอกว้างกว่า 288px / < lg = overlay เลื่อนเข้า-ออก กว้างเป็นสัดส่วนของจอกว้างกว่า 288px
(ปิดบน desktop ใช้ lg:hidden ออกจาก flow → main เต็มจอ; top-0 จำเป็น (ปิดบน desktop ใช้ lg:hidden ออกจาก flow → main เต็มจอ) */}
ตอน max-lg:fixed ยึดขอบบนจอ) */}
<aside <aside
data-sidebar="" data-sidebar=""
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 ${ 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 ${
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"
@ -3002,7 +2924,7 @@ export default function PDFViewer({
</div> </div>
</aside> </aside>
<div className="flex h-full min-w-0 flex-1 flex-col"> <div className="min-w-0 flex-1">
{/* แยกเป็นสองแถว: บน = ชื่อไฟล์ + ตัวเลื่อนหน้า/ซูม (กลาง) + หน้าสรุป, {/* แยกเป็นสองแถว: บน = ชื่อไฟล์ + ตัวเลื่อนหน้า/ซูม (กลาง) + หน้าสรุป,
ล่าง = แถบ 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">
@ -3059,20 +2981,9 @@ export default function PDFViewer({
> >
<ZoomOut className="h-4 w-4" /> <ZoomOut className="h-4 w-4" />
</button> </button>
{/* UX audit 2026-09-22: ตัวเลข % เป็นปุ่มกลับไป fit-width — PDF viewer ที่ <span className="w-12 text-center text-xs tabular-nums">
ผู้ใช้เคยใช้ทุกตัวมี affordance รีเซ็ตซูม คิดจากความกว้าง main ปัจจุบัน */}
<button
type="button"
aria-label="ซูมพอดีความกว้างหน้า"
title="ซูมพอดีความกว้างหน้า"
className={`w-12 py-2 text-center text-xs tabular-nums text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => {
const s = fitWidthScale(mainRef.current?.clientWidth, pageSizes[0]?.width);
if (s !== null) setScale(s);
}}
>
{Math.round(scale * 100)}% {Math.round(scale * 100)}%
</button> </span>
<button <button
type="button" type="button"
aria-label="ซูมเข้า" aria-label="ซูมเข้า"
@ -3117,8 +3028,8 @@ export default function PDFViewer({
UX review 2026-09-09: บน desktop ปุ่ม wrap ภายในกรอบได้ / M5 2026-09-14: UX review 2026-09-09: บน desktop ปุ่ม wrap ภายในกรอบได้ / M5 2026-09-14:
จอแคบเป็นแถวเดียว ไหลตามแถบทั้งแถวที่เลื่อนแนวนอน */} จอแคบเป็นแถวเดียว ไหลตามแถบทั้งแถวที่เลื่อนแนวนอน */}
<div className="flex max-lg:shrink-0 flex-wrap gap-0.5 rounded-lg border border-slate-300 max-lg:flex-nowrap"> <div className="flex max-lg:shrink-0 flex-wrap gap-0.5 rounded-lg border border-slate-300 max-lg:flex-nowrap">
{visibleTools.map(({ key, label, text, Icon }, i) => { {TOOLS.map(({ key, label, text, Icon }, i) => {
const corner = i === 0 ? "rounded-l-lg" : i === visibleTools.length - 1 ? "rounded-r-lg" : ""; const corner = i === 0 ? "rounded-l-lg" : i === TOOLS.length - 1 ? "rounded-r-lg" : "";
const activeCls = `${ const activeCls = `${
tool === key ? "bg-primary text-white" : "text-slate-500 hover:bg-slate-100" tool === key ? "bg-primary text-white" : "text-slate-500 hover:bg-slate-100"
} ${FOCUS_RING_CLS}`; } ${FOCUS_RING_CLS}`;
@ -3179,12 +3090,7 @@ export default function PDFViewer({
? "bg-slate-100 text-slate-900" ? "bg-slate-100 text-slate-900"
: "text-slate-600 hover:bg-slate-100" : "text-slate-600 hover:bg-slate-100"
}`} }`}
onClick={() => { onClick={() => setHighlightStyle(sKey)}
setHighlightStyle(sKey);
// แถวเดี่ยวเลือกจบ = ปิดเมนูทันที แต่ห้ามผ่าน markMenuClosed
// (guard กลืน tap 300ms — แตะหน้าครั้งถัดไปต้องใช้ได้ทันที)
setHighlightMenuOpen(false);
}}
> >
<SIcon className="h-3.5 w-3.5 shrink-0" /> <SIcon className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1">{sLabel}</span> <span className="flex-1">{sLabel}</span>
@ -3268,13 +3174,10 @@ export default function PDFViewer({
}`} }`}
onClick={() => { onClick={() => {
setDrawPen(p); setDrawPen(p);
// เลือก pen = รีเซ็ตค่ากลับเป็น preset ของ pen นั้น // เลือก pen = รีเซ็ตค่ากลับเป็น preset ของ pen นั้น (เมนูเปิดค้างให้ปรับต่อ)
setDrawColor(DRAW_PENS[p].color); setDrawColor(DRAW_PENS[p].color);
setDrawWidthPct(DRAW_PENS[p].strokeWidthPct); setDrawWidthPct(DRAW_PENS[p].strokeWidthPct);
setDrawOpacityPct(DRAW_PENS[p].opacity * 100); setDrawOpacityPct(DRAW_PENS[p].opacity * 100);
// แถวเดี่ยวเลือกจบ = ปิดเมนูทันที แต่ห้ามผ่าน markMenuClosed
// (guard กลืน tap 300ms — แถวปรับค่าสี/ความหนา/ความทึบยังเปิดค้างให้ปรับต่อ)
setDrawMenuOpen(false);
}} }}
> >
<PenIcon className="h-3.5 w-3.5 shrink-0" /> <PenIcon className="h-3.5 w-3.5 shrink-0" />
@ -3370,12 +3273,7 @@ export default function PDFViewer({
? "bg-slate-100 text-slate-900" ? "bg-slate-100 text-slate-900"
: "text-slate-600 hover:bg-slate-100" : "text-slate-600 hover:bg-slate-100"
}`} }`}
onClick={() => { onClick={() => setShapeKind(kind)}
setShapeKind(kind);
// แถวเดี่ยวเลือกจบ = ปิดเมนูทันที แต่ห้ามผ่าน markMenuClosed
// (guard กลืน tap 300ms — แถวปรับค่าสี/ความหนายังเปิดค้างให้ปรับต่อ)
setShapeMenuOpen(false);
}}
> >
<KindIcon className="h-3.5 w-3.5 shrink-0" /> <KindIcon className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1">{kindLabel}</span> <span className="flex-1">{kindLabel}</span>
@ -3455,12 +3353,7 @@ export default function PDFViewer({
? "bg-slate-100 text-slate-900" ? "bg-slate-100 text-slate-900"
: "text-slate-600 hover:bg-slate-100" : "text-slate-600 hover:bg-slate-100"
}`} }`}
onClick={() => { onClick={() => setStampPreset(preset)}
setStampPreset(preset);
// แถวเดี่ยวเลือกจบ = ปิดเมนูทันที แต่ห้ามผ่าน markMenuClosed
// (guard กลืน tap 300ms — แถวปรับค่าสี/ขนาดยังเปิดค้างให้ปรับต่อ)
setStampMenuOpen(false);
}}
> >
<span className="flex-1">{preset}</span> <span className="flex-1">{preset}</span>
{stampPreset === preset && <Check className="h-3.5 w-3.5 shrink-0" />} {stampPreset === preset && <Check className="h-3.5 w-3.5 shrink-0" />}
@ -3742,7 +3635,7 @@ export default function PDFViewer({
placeholder="โน้ต (ไม่บังคับ)" placeholder="โน้ต (ไม่บังคับ)"
className="mt-1.5 w-full resize-none rounded-md border border-slate-300 px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-slate-400" className="mt-1.5 w-full resize-none rounded-md border border-slate-300 px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-slate-400"
/> />
<p className="mt-1.5 text-[11px] text-slate-500">วางที่</p> <p className="mt-1.5 text-[11px] text-slate-400">วางที่</p>
<Select <Select
value={bookmarkComposer.parentId ?? ""} value={bookmarkComposer.parentId ?? ""}
onChange={(e) => onChange={(e) =>
@ -3816,7 +3709,7 @@ export default function PDFViewer({
<main <main
ref={mainRef} ref={mainRef}
className="pdf-scroll-main min-h-0 flex-1 space-y-4 overflow-auto overscroll-contain px-4 py-4" className="space-y-4 px-4 py-4"
style={{ style={{
cursor: cursor:
tool === "pan" tool === "pan"
@ -3825,9 +3718,7 @@ export default function PDFViewer({
: "grab" : "grab"
: tool === "erase" : tool === "erase"
? ERASER_CURSOR ? ERASER_CURSOR
: tool === "comment" || tool === "textbox" || tool === "stamp" : undefined,
? "crosshair" // tool วางของ — บอกว่าคลิกวางได้ (desktop อย่างเดียว จอสัมผัสไม่มี cursor)
: undefined,
}} }}
onPointerDown={(e) => { onPointerDown={(e) => {
if (tool !== "pan" || e.button !== 0) return; if (tool !== "pan" || e.button !== 0) return;
@ -3838,7 +3729,7 @@ export default function PDFViewer({
onPointerMove={(e) => { onPointerMove={(e) => {
const p = panRef.current; const p = panRef.current;
if (!p) return; if (!p) return;
mainRef.current?.scrollBy(p.x - e.clientX, p.y - e.clientY); window.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={() => {
@ -3850,10 +3741,12 @@ export default function PDFViewer({
setGrabbing(false); setGrabbing(false);
}} }}
> >
{/* M6: hint ไฟล์สแกน — เดิมโชว์ตอน tool highlight บนหน้าที่ไม่มีข้อความ ตอนนี้ {/* M6: hint ไฟล์สแกน — ทุกหน้าที่ render แล้วไม่มี span เลย (virtualization เห็นเฉพาะ
ไฟล์สแกนล้วน (scannedOnly) ซ่อนปุ่ม+reset tool ไปแล้ว แถบจึงเหลือเป็น หน้าใกล้ viewport — ไฟล์ผสมหน้าสแกน/หน้า text จึงพูดแบบระดับ "หน้าที่เปิดดู" เท่านั้น)
defensive ช่วง transition สั้น ๆ เท่านั้น (คดีศาลมี PDF สแกนเยอะ) */} โชว์เฉพาะตอน tool highlight เพื่อไม่รบกวน tool อื่น — คดีศาลมี PDF สแกนเยอะ */}
{tool === "highlight" && scannedOnly && ( {tool === "highlight" &&
Object.keys(pageSpanCount).length > 0 &&
Object.values(pageSpanCount).every((n) => n === 0) && (
<p className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700"> <p className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
หน้าที่เปิดดูเป็นภาพสแกน ไม่มีข้อความให้คลุมไฮไลต์ — ใช้การวาดเส้นหรือกล่องข้อความแทนได้ หน้าที่เปิดดูเป็นภาพสแกน ไม่มีข้อความให้คลุมไฮไลต์ — ใช้การวาดเส้นหรือกล่องข้อความแทนได้
</p> </p>
@ -3871,24 +3764,10 @@ 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>
)} )}
{PLACE_TOOL_HINTS[tool] && (
<p className="mb-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs leading-relaxed text-slate-600">
{PLACE_TOOL_HINTS[tool]}
</p>
)}
{/* UX audit 2026-09-22: ยางลบลบได้เฉพาะเส้น/รูปทรง/ตรายางของตัวเอง (สเปก F6 เดิม
ไม่ขยายตรรกะ) — เดิมลากผ่านไฮไลต์/รายการของผู้อื่นแล้วเงียบเปล่า ผู้ใช้สรุป
ว่าแอปไม่ทำงาน จึงบอกขอบเขตตรง ๆ ตอนใช้ tool */}
{tool === "erase" && (
<p className="mb-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs leading-relaxed text-slate-600">
ยางลบลบได้เฉพาะเส้น รูปทรง และตรายางของตัวเองเท่านั้น — ไฮไลต์และรายการอื่น ๆ
(รวมของผู้อื่นทุกชนิด) ให้ใช้เครื่องมือ &quot;เลือก&quot; คลิกรายการแล้วกดปุ่มลบ
</p>
)}
{pageSizes.length === 0 ? ( {pageSizes.length === 0 ? (
loadError ? ( loadError ? (
<div className="flex h-[70vh] items-center justify-center px-6"> <div className="flex h-[70vh] items-center justify-center px-6">
@ -3897,7 +3776,7 @@ export default function PDFViewer({
</p> </p>
</div> </div>
) : ( ) : (
<div className="flex h-[70vh] items-center justify-center text-sm text-slate-500"> <div className="flex h-[70vh] items-center justify-center text-sm text-slate-400">
กำลังโหลดเอกสาร… กำลังโหลดเอกสาร…
</div> </div>
) )

View file

@ -5,8 +5,6 @@ import { Check, ChevronDown, ChevronUp, Highlighter, Strikethrough, Underline }
import type { HighlightStyle } from "@/db/schema"; import type { HighlightStyle } from "@/db/schema";
import { colorName } from "@/utils/annotation-display";
// ตัวควบคุมที่ใช้ร่วมระหว่าง dropdown บนปุ่ม tool (PDFViewer) กับแผงเครื่องมือ // ตัวควบคุมที่ใช้ร่วมระหว่าง dropdown บนปุ่ม tool (PDFViewer) กับแผงเครื่องมือ
// บนกรอบ selection (AnnotationSelectionLayer) — หน้าตา/พฤติกรรมเดียวกันทั้งสองที่ // บนกรอบ selection (AnnotationSelectionLayer) — หน้าตา/พฤติกรรมเดียวกันทั้งสองที่
@ -55,8 +53,8 @@ export function ColorSwatches({ value, onChange }: { value: string; onChange: (c
<button <button
key={c} key={c}
type="button" type="button"
aria-label={`เลือกสี ${colorName(c)}`} aria-label={`เลือกสี ${c}`}
title={colorName(c)} title={c}
className={`aspect-square max-w-8 w-full justify-self-center rounded-full border border-slate-300 transition ${FOCUS_RING_CLS} ${ className={`aspect-square max-w-8 w-full justify-self-center rounded-full border border-slate-300 transition ${FOCUS_RING_CLS} ${
value === c ? "ring-2 ring-primary ring-offset-1" : "hover:scale-110" value === c ? "ring-2 ring-primary ring-offset-1" : "hover:scale-110"
}`} }`}

View file

@ -374,14 +374,13 @@ export default function AnnotationSidebar({
} }
/** ผลการค้นหา F14 หลังกรองตามเจ้าของหมายเหตุ (F4) ฝั่ง client — searchAnnotations /** ผลการค้นหา F14 หลังกรองตามเจ้าของหมายเหตุ (F4) ฝั่ง client — searchAnnotations
* ไม่รับ userId และห้ามแต้ server action: สูตรเดียวกันกับ visibleAnnotations ของ * ไม่รับ userId และห้ามแต้ server action: สูตรเดียวกับ visibleAnnotations ของ
* PDFViewer (รวม REDACTION ของทุกคนไว้เสมอ — D4) — เปลี่ยนตัวกรองหลังค้นแล้ว * PDFViewer — เปลี่ยนตัวกรองหลังค้นแล้ว ผลกรองทันทีไม่ต้องค้นใหม่ */
* ผลกรองทันทีไม่ต้องค้นใหม่ */
const visibleSearchResults = useMemo(() => { const visibleSearchResults = useMemo(() => {
if (searchResults === null) return null; if (searchResults === null) return null;
if (userFilter === "all") return searchResults; if (userFilter === "all") return searchResults;
const target = userFilter === "mine" ? userId : userFilter; const target = userFilter === "mine" ? userId : userFilter;
return searchResults.filter((a) => a.userId === target || a.type === "REDACTION"); return searchResults.filter((a) => a.userId === target);
}, [searchResults, userFilter, userId]); }, [searchResults, userFilter, userId]);
// ─── F19/A7: ประวัติ/กู้คืน — dialog ต่อรายการ (แสดงเฉพาะรายการของตัวเอง) ───── // ─── F19/A7: ประวัติ/กู้คืน — dialog ต่อรายการ (แสดงเฉพาะรายการของตัวเอง) ─────
@ -436,25 +435,9 @@ export default function AnnotationSidebar({
const pendingItems = annotations.filter((a) => a.type !== "COMMENT" || !a.resolved); const pendingItems = annotations.filter((a) => a.type !== "COMMENT" || !a.resolved);
const resolvedItems = annotations.filter((a) => a.type === "COMMENT" && a.resolved); const resolvedItems = annotations.filter((a) => a.type === "COMMENT" && a.resolved);
// F4: bookmarks ที่แสดง กรองตามเจ้าของหมายเหตุเหมือน annotation (คำขอผู้ใช้
// 2026-09-22 ให้ตรงกับ export ที่กรอง PDF outline ตามผู้บันทึก) — หัวข้อแม่โดนกรอง
// แต่ลูกยังอยู่ ลูกขึ้น root (พฤติกรรมเดียวกับ buildOutline ของ export) — ใช้เฉพาะจุด
// แสดงผล (ต้นไม้/กลุ่มหน้า/ตัวเลขแท็บ): popup ย้ายหัวข้อ (movableTo) กับ bookmarkById
// ยังเห็นต้นไม้เต็ม ย้ายไปใต้หัวข้อของคนอื่นได้ตามเดิม
const keptBookmarks =
userFilter === "all"
? bookmarks
: bookmarks.filter(
(b) => b.userId === (userFilter === "mine" ? userId : userFilter),
);
const keptIds = new Set(keptBookmarks.map((b) => b.id));
const visibleBookmarks = keptBookmarks.map((b) =>
b.parentId && !keptIds.has(b.parentId) ? { ...b, parentId: null } : b,
);
// มุมมอง "ตามหน้า": จัดกลุ่มตามหน้า เรียงน้อย→มาก — bookmark เอาเฉพาะ root // มุมมอง "ตามหน้า": จัดกลุ่มตามหน้า เรียงน้อย→มาก — bookmark เอาเฉพาะ root
// (sub ดูในต้นไม้ "ตามหัวข้อ") / annotation เก็บลำดับเดิมตามเวลาสร้าง // (sub ดูในต้นไม้ "ตามหัวข้อ") / annotation เก็บลำดับเดิมตามเวลาสร้าง
const bmRootPages = groupByPage(visibleBookmarks.filter((b) => !b.parentId).sort(byPageOrder)); const bmRootPages = groupByPage(bookmarks.filter((b) => !b.parentId).sort(byPageOrder));
const annPages = groupByPage(annotations); const annPages = groupByPage(annotations);
// F12/C4: คัดลอก deep link ไปยัง annotation นี้ (?page= + ?ann=) // F12/C4: คัดลอก deep link ไปยัง annotation นี้ (?page= + ?ann=)
@ -732,7 +715,7 @@ export default function AnnotationSidebar({
} }
const renderBookmarkNode = (b: BookmarkDTO, depth: number) => { const renderBookmarkNode = (b: BookmarkDTO, depth: number) => {
const children = visibleBookmarks.filter((x) => x.parentId === b.id).sort(byPageOrder); const children = bookmarks.filter((x) => x.parentId === b.id).sort(byPageOrder);
const hasChildren = children.length > 0; const hasChildren = children.length > 0;
const collapsed = collapsedIds.has(b.id); const collapsed = collapsedIds.has(b.id);
const editing = editBookmark?.id === b.id; const editing = editBookmark?.id === b.id;
@ -825,14 +808,10 @@ export default function AnnotationSidebar({
) : ( ) : (
<div className={`group flex items-center gap-1.5 rounded py-1 hover:bg-slate-100 ${pad}`}> <div className={`group flex items-center gap-1.5 rounded py-1 hover:bg-slate-100 ${pad}`}>
{hasChildren ? ( {hasChildren ? (
// bm-toggle: คอลัมน์นี้เป็น "ตาราง" ของต้นไม้ ต้องกว้าง 14px เท่า spacer
// ของแถวลูกเสมอ — บนจอสัมผัส rule coarse ที่ขยายปุ่มเป็น 32px จะดันไอคอน
// ของแม่ไปชนแนวของลูก (indent 16px หาย) จึงยกเว้นผ่าน class + hit area
// ขยายด้วย ::after แทน (ดู globals.css)
<button <button
type="button" type="button"
aria-label={collapsed ? `กางหัวข้อ ${b.title}` : `หุบหัวข้อ ${b.title}`} aria-label={collapsed ? `กางหัวข้อ ${b.title}` : `หุบหัวข้อ ${b.title}`}
className={`shrink-0 bm-toggle text-slate-500 hover:text-slate-600 ${FOCUS_RING_CLS}`} className={`shrink-0 text-slate-500 hover:text-slate-600 ${FOCUS_RING_CLS}`}
onClick={() => toggleCollapsed(b.id)} onClick={() => toggleCollapsed(b.id)}
> >
{collapsed ? ( {collapsed ? (
@ -1044,7 +1023,7 @@ export default function AnnotationSidebar({
<div role="tablist" aria-label="รายการในแผงข้าง" className="flex shrink-0 border-b border-slate-200"> <div role="tablist" aria-label="รายการในแผงข้าง" className="flex shrink-0 border-b border-slate-200">
{( {(
[ [
["bookmarks", `Bookmark (${visibleBookmarks.length})`], ["bookmarks", `Bookmark (${bookmarks.length})`],
["annotations", `Annotation (${annotations.length})`], ["annotations", `Annotation (${annotations.length})`],
] as const ] as const
).map(([key, label]) => ( ).map(([key, label]) => (
@ -1335,10 +1314,6 @@ export default function AnnotationSidebar({
<p className="p-4 text-center text-xs text-slate-500"> <p className="p-4 text-center text-xs text-slate-500">
ยังไม่มีที่คั่นหน้า — กดปุ่มคั่นหน้าบนแถบเครื่องมือเพื่อเพิ่ม ยังไม่มีที่คั่นหน้า — กดปุ่มคั่นหน้าบนแถบเครื่องมือเพื่อเพิ่ม
</p> </p>
) : visibleBookmarks.length === 0 ? (
<p className="p-4 text-center text-xs text-slate-500">
ไม่มีที่คั่นหน้าของเจ้าของหมายเหตุที่เลือก
</p>
) : ( ) : (
<> <>
{/* สลับมุมมองแท็บนี้: ต้นไม้หัวข้อ ↔ root แยกตามหน้า */} {/* สลับมุมมองแท็บนี้: ต้นไม้หัวข้อ ↔ root แยกตามหน้า */}
@ -1366,7 +1341,7 @@ export default function AnnotationSidebar({
{!collapsed && ( {!collapsed && (
<ul className="space-y-0.5"> <ul className="space-y-0.5">
{roots.map((b) => { {roots.map((b) => {
const childCount = visibleBookmarks.filter( const childCount = bookmarks.filter(
(x) => x.parentId === b.id, (x) => x.parentId === b.id,
).length; ).length;
return ( return (
@ -1409,7 +1384,7 @@ export default function AnnotationSidebar({
) : ( ) : (
<ul className="space-y-0.5"> <ul className="space-y-0.5">
{/* N2: root = หมุดหัวข้อใหญ่ เรียงตามลำดับหน้า (N5) — sub render ซ้อนใต้ผ่าน renderBookmarkNode */} {/* N2: root = หมุดหัวข้อใหญ่ เรียงตามลำดับหน้า (N5) — sub render ซ้อนใต้ผ่าน renderBookmarkNode */}
{visibleBookmarks {bookmarks
.filter((b) => !b.parentId) .filter((b) => !b.parentId)
.sort(byPageOrder) .sort(byPageOrder)
.map((b) => renderBookmarkNode(b, 1))} .map((b) => renderBookmarkNode(b, 1))}

View file

@ -150,7 +150,7 @@ Index: `(file_id)`
- **REDACTION บนจอ:** วาดบน InkLayer เป็น `<rect>` fill `#000` (z-3 อยู่เหนือ textLayer → บังข้อความจริงบนจอ); ปุ่ม tool แยกจาก shape - **REDACTION บนจอ:** วาดบน InkLayer เป็น `<rect>` fill `#000` (z-3 อยู่เหนือ textLayer → บังข้อความจริงบนจอ); ปุ่ม tool แยกจาก shape
- **Export:** `app/api/annotations/export/route.ts` (GET `?fileId=`) — dep ใหม่ `pdf-lib` + `@pdf-lib/fontkit`: - **Export:** `app/api/annotations/export/route.ts` (GET `?fileId=`) — dep ใหม่ `pdf-lib` + `@pdf-lib/fontkit`:
1. อ่านข้อมูลไฟล์จาก DB → อ่าน PDF ต้นฉบับจาก `path.join(process.cwd(), "public", pdfUrl)` — **อ่านอย่างเดียว ห้ามเขียนทับ** (invariant #1) + guard path ต้อง resolve อยู่ใน `public/` กัน path traversal 1. อ่านข้อมูลไฟล์จาก DB → อ่าน PDF ต้นฉบับจาก `path.join(process.cwd(), "public", pdfUrl)` — **อ่านอย่างเดียว ห้ามเขียนทับ** (invariant #1) + guard path ต้อง resolve อยู่ใน `public/` กัน path traversal
2. โหลด annotations ทั้งหมด (ทุก user) → วาดลง `PDFDocument` ต่อหน้าด้วยพิกัด % × ขนาดจริงของหน้านั้น (`page.getSize()` + แปลงตาม `page.getRotation()` กรณี /Rotate 90/180/270) — เพิ่มพารามิเตอร์ `&user=<userId>` (2026-09-22): export ตาม filter รายคน F4 ที่กำลังดู ("mine" resolve เป็น userId ฝั่ง client ก่อนส่ง) — กรองเฉพาะ annotation ของ userId นั้น **ยกเว้น REDACTION ติดไปเสมอ** (D4) และเติมชื่อผู้ใช้ (จาก `user_name` ที่ mirror ในตาราง) ท้ายชื่อไฟล์ `…-annotated-<ชื่อ>.pdf`; bookmarks outline กรองตามผู้บันทึกด้วย (คำขอผู้ใช้ 2026-09-22) — หัวข้อแม่โดนกรองแต่ลูกยังอยู่ ลูกขึ้น root 2. โหลด annotations ทั้งหมด (ทุก user) → วาดลง `PDFDocument` ต่อหน้าด้วยพิกัด % × ขนาดจริงของหน้านั้น (`page.getSize()` + แปลงตาม `page.getRotation()` กรณี /Rotate 90/180/270)
3. ลำดับวาดต่อหน้า: HIGHLIGHT rect (fill alpha ~0.35) → DRAW polyline (strokeWidth % → pt) → SHAPE → REDACTION **ดำทึบ #000 ทับจริง** → TEXTBOX/STAMP ถ้าทำแล้ว (F17/F18) 3. ลำดับวาดต่อหน้า: HIGHLIGHT rect (fill alpha ~0.35) → DRAW polyline (strokeWidth % → pt) → SHAPE → REDACTION **ดำทึบ #000 ทับจริง** → TEXTBOX/STAMP ถ้าทำแล้ว (F17/F18)
4. COMMENT = **sticky note จริงของ PDF** (annotation dict `/Subtype /Text` — คลิกหมุดที่ reader แล้วเปิด popup อ่าน): `/Contents` = message + replies ต่อท้ายทีละบรรทัด (`— ชื่อ: ข้อความ`, F9), `/T` = ผู้เขียน, `/C` สีเหลืองเดียวกับหมุดบนจอ, `/M` เวลาแก้ไขล่าสุด, `/F 4` (Print); ไม่วาดข้อความ flatten ทับหน้ากระดาษ (annotation object อยู่เหนือ content เสมอ ไม่โดนกลบ) — และ `note` ของ annotation ชนิดอื่น (N1 ทุกชนิดยกเว้น COMMENT) ได้ sticky note เล็กแนบมุมขวาบนของ bbox ของรายการนั้น (Chrome/Edge hover แสดง tooltip จาก `/Contents`, Acrobat คลิกเปิด popup) 4. COMMENT = **sticky note จริงของ PDF** (annotation dict `/Subtype /Text` — คลิกหมุดที่ reader แล้วเปิด popup อ่าน): `/Contents` = message + replies ต่อท้ายทีละบรรทัด (`— ชื่อ: ข้อความ`, F9), `/T` = ผู้เขียน, `/C` สีเหลืองเดียวกับหมุดบนจอ, `/M` เวลาแก้ไขล่าสุด, `/F 4` (Print); ไม่วาดข้อความ flatten ทับหน้ากระดาษ (annotation object อยู่เหนือ content เสมอ ไม่โดนกลบ) — และ `note` ของ annotation ชนิดอื่น (N1 ทุกชนิดยกเว้น COMMENT) ได้ sticky note เล็กแนบมุมขวาบนของ bbox ของรายการนั้น (Chrome/Edge hover แสดง tooltip จาก `/Contents`, Acrobat คลิกเปิด popup)
5. Bookmarks (N2) → **PDF outline** (แผง Bookmarks ของ reader กดกระโดดได้): ต่อ `/Outlines` dict เอง (pdf-lib ไม่มี API ระดับสูง) — โครงสร้างตาม `parent_id` เรียงตาม `createdAt` เหมือน sidebar, `/Title` เป็น PDFHexString (ภาษาไทยได้), `/Dest [pageRef /XYZ x y null]` จาก pageIndex + yOffset (แปลงผ่าน `displayToPdfPoint`) ใช้ตำแหน่ง yOffset **เป๊ะ ๆ** — yOffset ของ bookmark มีนิยามเป็น "จุดจริงบนหน้า" เหมือนพิกัด annotation (เก็บจากขอบบนของแถบมองเห็นใต้ header ตอนสร้าง และกดในเว็บแล้วจุดถูกดันกลับมาชิดใต้ header ผ่าน `scrollToBelowHeader` แบบเดียวกับ `goToAnnotation`) ซึ่งตรงกับพฤติกรรม `/XYZ` ของ PDF ที่วางจุดยึดติดขอบบน viewport โดยไม่ต้องชดเชย, เชื่อม First/Last/Prev/Next ครบ และ Count เป็นบวก = แผงเปิดทั้งหมดตอนเปิดไฟล์ 5. Bookmarks (N2) → **PDF outline** (แผง Bookmarks ของ reader กดกระโดดได้): ต่อ `/Outlines` dict เอง (pdf-lib ไม่มี API ระดับสูง) — โครงสร้างตาม `parent_id` เรียงตาม `createdAt` เหมือน sidebar, `/Title` เป็น PDFHexString (ภาษาไทยได้), `/Dest [pageRef /XYZ x y null]` จาก pageIndex + yOffset (แปลงผ่าน `displayToPdfPoint`) ใช้ตำแหน่ง yOffset **เป๊ะ ๆ** — yOffset ของ bookmark มีนิยามเป็น "จุดจริงบนหน้า" เหมือนพิกัด annotation (เก็บจากขอบบนของแถบมองเห็นใต้ header ตอนสร้าง และกดในเว็บแล้วจุดถูกดันกลับมาชิดใต้ header ผ่าน `scrollToBelowHeader` แบบเดียวกับ `goToAnnotation`) ซึ่งตรงกับพฤติกรรม `/XYZ` ของ PDF ที่วางจุดยึดติดขอบบน viewport โดยไม่ต้องชดเชย, เชื่อม First/Last/Prev/Next ครบ และ Count เป็นบวก = แผงเปิดทั้งหมดตอนเปิดไฟล์

View file

@ -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;
/** นิ้วอยู่ใกล้ขอบของพื้นที่เอกสาร (ทั้ง 4 ด้าน) แค่นี้ = auto-scroll ต่อ (rAF loop) */ /** นิ้วอยู่ใกล้ขอบบน/ล่างของ viewport แค่นี้ = 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,39 +503,35 @@ export function useCustomSelection({
}); });
}; };
/** auto-scroll ระหว่างลากค้างใกล้ขอบจอ — rAF loop เลื่อน scroll container ของ /** auto-scroll ระหว่างลากค้างใกล้ขอบจอ — rAF loop เลื่อน window แล้วคำนวณ preview
* เอกสาร (el = <main> ตัวเลื่อนเดียวของ viewer, ทั้งแกนตั้งและแกนนอนกรณี * ใหม่ (จุดนิ้วเดิมชี้ข้อความใหม่หลัง scroll) จบเองเมื่อเลิกลาก */
* ซูมจนหน้ากว้างเกินจอ) แล้วคำนวณ 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 br = el.getBoundingClientRect(); const vh = window.innerHeight;
const speed = (into: number) => let dy = 0;
Math.max( if (d.curPt.y < AUTOSCROLL_MARGIN_PX) {
dy = -Math.max(
1, 1,
Math.ceil( Math.ceil(
(into / AUTOSCROLL_MARGIN_PX) * AUTOSCROLL_MAX_SPEED_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,
), ),
); );
let dx = 0;
let dy = 0;
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 (d.curPt.y < br.top + AUTOSCROLL_MARGIN_PX) { if (dy !== 0) {
dy = -speed(br.top + AUTOSCROLL_MARGIN_PX - d.curPt.y); window.scrollBy(0, dy);
} 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);

View file

@ -172,8 +172,7 @@ const COLOR_NAMES: Record<string, string> = {
"#b91c1c": "แดงเข้ม", "#b91c1c": "แดงเข้ม",
}; };
/** export ให้ ToolControls ใช้ทำ aria-label/title ของปุ่มสีเป็นชื่อไทยด้วย (UX audit 2026-09-22) */ function colorName(c: string): string {
export function colorName(c: string): string {
return COLOR_NAMES[c.toLowerCase()] ?? c; return COLOR_NAMES[c.toLowerCase()] ?? c;
} }