UX audit ทั้งแอป + typography audit 2026-09-16: toast แจ้ง error การบันทึก (H1 — เดิมรายการหายเงียบ + ReplyBox เก็บ draft ตอน fail), คำเตือน redaction แสดงทุกขนาดจอ (H2), toolbar แสดงชื่อ tool active + aria-pressed บนจอสัมผัส (H3/M9), ปุ่ม popover จิ้มง่าย 36px + focus ring เติมครบ (H4) + M1-M9 (ปิด sidebar ตอน navigate, bookmark หัวแถวจอเล็ก, hint โหมดมาร์กบนจอสัมผัส, การ์ดหน้ารายการคลิกได้ทั้งใบ, เช็ค 100MB ฝั่ง client, feedback ✓/pending ตอนบันทึก bookmark, dropdown วางที่ sort ตามหน้า+เลขหน้า, slate-500 แทน slate-400) + webfont Sarabun ผ่าน next/font self-host 400/600 (Option B — แสดงผลตรงกันทุกอุปกรณ์ รวม TEXTBOX ที่ผูก stack นี้) + NumberCombo preset เป็น chips แทน dropdown ล้นเมนู (L2) + leading-relaxed/13px ข้อความไทยที่ wrap + tabular-nums + prefers-reduced-motion (L7) + L1/L4/L5 + แถว bookmark แสดงชื่อผู้บันทึกเหมือนผลการค้นหา

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-09-16 14:28:53 +07:00
parent a020e96bda
commit 21b9958600
10 changed files with 351 additions and 158 deletions

View file

@ -84,17 +84,17 @@ export default async function SummaryPage({
<span className="block text-xs">
<span className="font-medium">{annotationTypeLabel(a)}</span>
{a.type === "COMMENT" && a.resolved && (
<span className="ml-2 rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] text-emerald-600">
<span className="ml-2 rounded bg-emerald-50 px-1.5 py-0.5 text-[11px] text-emerald-600">
จัดการแล้ว
</span>
)}
<span className="ml-2 text-slate-400">{a.userName}</span>
</span>
<span className="mt-0.5 block whitespace-pre-wrap break-words text-sm">
<span className="mt-0.5 block whitespace-pre-wrap break-words text-sm leading-relaxed">
{annotationPreviewText(a)}
</span>
{annotationNote(a) && (
<span className="mt-0.5 block whitespace-pre-wrap break-words text-xs text-slate-500">
<span className="mt-0.5 block whitespace-pre-wrap break-words text-xs leading-relaxed text-slate-500">
โน้ต: {annotationNote(a)}
</span>
)}

View file

@ -108,10 +108,11 @@
/* NumberCombo (ความหนา/ความทึบ/ขนาด): ▲▼ ซ้อนแนวตั้ง 2 ปุ่ม — ถ้าโดน min 36px
คอลัมน์จะสูง 72px และ input (items-stretch) ถูกดึงบวมตามทั้งแถว — ปุ่ม ▲▼
จึงหลุดจาก rule ด้านบน (combo-step) แล้วตั้งความสูงแถวเองที่ 36px แทน:
แตะ input = เปิด preset list (แถวละ 36px จาก rule ด้านบน) คือ touch path
หลัก ส่วน ▲▼ (ครึ่งแถว) เป็น fine-tune สำรอง — ขนาดใกล้ desktop ไม่บวม */
[data-tool-menu] .number-combo > div {
จึงหลุดจาก rule ด้านบน (combo-step) แล้วตั้งความสูงแถว input เองที่ 36px
แทน (:first-child — แถว chips preset ของ L2 โดน rule ปุ่ม 36px ของเมนู
ครอบอยู่แล้ว ห้ามบังคับสูงซ้ำ): แตะ chips preset = touch path หลัก
ส่วน ▲▼ (ครึ่งแถว) เป็น fine-tune สำรอง — ขนาดใกล้ desktop ไม่บวม */
[data-tool-menu] .number-combo > div:first-child {
height: 2.25rem;
}
[data-tool-menu] .combo-step {
@ -197,14 +198,16 @@
}
}
/* UX review 2026-09-14 H2: แอปไม่เคยตั้ง font-family เลย ข้อความไทยเลยตกไปที่
default ของ OS แต่ละเครื่อง — วางฟอนต์ไทยนำหน้า stack (ไม่โหลด webfont
ตามที่เลือกไว้: Leelawadee UI = Windows, Noto Sans Thai = Android/Linux,
Thonburi = macOS) */
/* UX review 2026-09-14 H2 → typography audit 2026-09-16 (T1 + Option B): webfont
Sarabun (next/font จาก layout.tsx — variable มี adjusted fallback ของ next/font
ในตัวแล้ว) นำหน้า stack เพื่อแสดงผลเหมือนกันทุกอุปกรณ์; ที่เหลือเป็น fallback
ตอนฟอนต์ยังไม่ถึง (display:swap)/โหลดไม่ได้ — เรียงแบบ Latin-first ตาม T1:
Latin/ตัวเลขได้ UI font ของ OS (Segoe UI/SF) ส่วนไทยได้ Leelawadee UI
(Windows) / Thonburi (iPad) / Noto Sans Thai (Android/Linux) */
body {
font-family: "Leelawadee UI", "Noto Sans Thai", "Thonburi",
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
font-family: var(--font-sarabun, "Sarabun"), -apple-system, "Segoe UI",
"Helvetica Neue", Arial, "Leelawadee UI", "Thonburi", "Noto Sans Thai",
sans-serif;
}
/* M6: touch hygiene — ตัดแถบเทากระพริบของ iOS ทุกครั้งที่แตะปุ่ม และกัน
@ -249,3 +252,18 @@ header {
.comment-composer {
padding-bottom: env(safe-area-inset-bottom);
}
/* UX audit 2026-09-16 L7: เคารพ prefers-reduced-motion — ตัด transition ของ
sidebar, animation กระพริบของปุ่ม export และ smooth scroll ทุกจุด
(scrollToBelowHeader ฝั่ง JS เช็ค media query เดียวกันตอนเลือก behavior) */
@media (prefers-reduced-motion: reduce) {
aside[data-sidebar] {
transition: none;
}
.animate-pulse {
animation: none;
}
* {
scroll-behavior: auto !important;
}
}

View file

@ -1,7 +1,21 @@
import type { Metadata } from "next";
import { Sarabun } from "next/font/google";
import "./globals.css";
/** typography audit 2026-09-16 (Option B): Sarabun — ฟอนต์ตระกูลมาตรฐานเอกสาร
* ราชการไทย (TH Sarabun) เจ้าหน้าที่คุ้นเคยที่สุด / next/font self-host ตอน build:
* ไฟล์ฟอนต์ serve จาก origin เดียวกับแอป ไม่พึ่ง Google ตอนรัน จึงใช้ใน intranet
* ได้ (แต่เครื่อง build ต้องเห็นอินเทอร์เน็ต) — โหลดเฉพาะ 2 weights ตามแผน
* ~100-160KB แลกกับการแสดงผลเหมือนกันทุกอุปกรณ์ รวมกล่อง TEXTBOX ที่ผูก
* stack นี้ (T1-C: wrap ข้อความตรงกันข้ามอุปกรณ์จริง) */
const sarabun = Sarabun({
subsets: ["thai", "latin"],
weight: ["400", "600"],
display: "swap",
variable: "--font-sarabun",
});
export const metadata: Metadata = {
title: "Judge's Note",
description: "ระบบ PDF Annotation สำหรับ e-Court",
@ -13,7 +27,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<html lang="th">
<html lang="th" className={sarabun.variable}>
<body className="bg-slate-100 text-slate-900 antialiased">{children}</body>
</html>
);

View file

@ -1,6 +1,6 @@
import Link from "next/link";
import { asc } from "drizzle-orm";
import { FileImage, FileText } from "lucide-react";
import { ChevronRight, FileImage, FileText } from "lucide-react";
import { db } from "@/db";
import { files } from "@/db/schema";
@ -48,7 +48,7 @@ export default async function HomePage() {
<div className="mt-8 flex items-baseline justify-between">
<h2 className="text-sm font-semibold text-slate-700">เอกสาร</h2>
{rows.length > 0 && (
<span className="text-xs text-slate-400">{rows.length} ไฟล์</span>
<span className="text-xs tabular-nums text-slate-500">{rows.length} ไฟล์</span>
)}
</div>
<UploadFileForm />
@ -59,7 +59,7 @@ export default async function HomePage() {
<p className="mt-3 text-sm font-medium text-slate-500">
ยังไม่มีไฟล์ในระบบ
</p>
<p className="mt-1 text-xs text-slate-400">
<p className="mt-1 text-xs text-slate-500">
รัน /sync-db เพื่อ seed ไฟล์ตัวอย่าง
</p>
</div>
@ -68,7 +68,7 @@ export default async function HomePage() {
{rows.map((f) => (
<li
key={f.id}
className="group flex items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:border-primary/30 hover:shadow-md"
className="group relative flex items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:border-primary/30 hover:shadow-md"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
{imageFormatFromUrl(f.pdfUrl) ? (
@ -81,19 +81,21 @@ export default async function HomePage() {
<span className="block break-all line-clamp-2 text-sm font-medium text-slate-900">
{f.title}
</span>
<span className="mt-0.5 block text-xs text-slate-400">
<span className="mt-0.5 block text-xs text-slate-500">
<span className="mr-1.5 rounded bg-slate-100 px-1 py-0.5 font-medium text-slate-500">
{kindBadge(f.pdfUrl)}
</span>
{new Date(f.createdAt).toLocaleString("th-TH")}
</span>
</span>
<ChevronRight aria-hidden className="h-4 w-4 shrink-0 text-slate-400" />
{/* M4 (UX audit 2026-09-16): ลิงก์กางเต็มการ์ด — จอสัมผัสจิ้มตรงไหนก็เข้าได้
(เดิมปุ่ม "Annotate" ~30px อย่างเดียว และเป็นภาษาอังกฤษใน UI ไทย) */}
<Link
href={`/annotate/${f.id}`}
className="shrink-0 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-white transition hover:opacity-90"
>
Annotate
</Link>
className="absolute inset-0 rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
aria-label={`เปิด ${f.title}`}
/>
</li>
))}
</ul>

View file

@ -8,6 +8,11 @@ import { uploadFile } from "@/app/actions/files";
const ACCEPT = ".pdf,.jpg,.jpeg,.png,.tif,.tiff";
/** ขนาดสูงสุดตาม validator ของ uploadFile (app/actions/files.ts) — เช็คฝั่ง client
* ก่อนอัปโหลด (M5, UX audit 2026-09-16): ไฟล์สแกนคดีที่เกินไม่ต้องรอส่งจนจบ
* บนเน็ตช้าก่อนเจอ error ของ server */
const MAX_BYTES = 100 * 1024 * 1024;
export default function UploadFileForm() {
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
@ -16,6 +21,12 @@ export default function UploadFileForm() {
e.preventDefault();
const form = e.currentTarget;
setError(null);
const input = form.elements.namedItem("file") as HTMLInputElement | null;
const file = input?.files?.[0];
if (file && file.size > MAX_BYTES) {
setError("ไฟล์ใหญ่เกิน 100MB — เลือกไฟล์เล็กกว่า");
return;
}
startTransition(async () => {
try {
const result = await uploadFile(new FormData(form));
@ -42,12 +53,12 @@ export default function UploadFileForm() {
accept={ACCEPT}
required
disabled={pending}
className="block w-full max-w-xs text-sm text-slate-600 file:mr-3 file:cursor-pointer file:rounded-md file:border-0 file:bg-primary/10 file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary"
className="block w-full max-w-xs text-sm text-slate-600 file:mr-3 file:cursor-pointer file:rounded-md file:border-0 file:bg-primary/10 file:px-4 file:py-2 file:text-sm file:font-medium file:text-primary"
/>
<button
type="submit"
disabled={pending}
className="ml-auto shrink-0 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-white transition hover:opacity-90 disabled:opacity-50"
className="ml-auto shrink-0 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:opacity-90 disabled:opacity-50"
>
{pending ? "กำลังอัปโหลด…" : "อัปโหลด"}
</button>

View file

@ -25,6 +25,11 @@ import { clamp, getPointerPercent, type Point, type Rect, type Size } from "@/ut
import { STAMP_COLOR } from "@/utils/annotation-display";
import { ColorSwatches, HIGHLIGHT_STYLE_BTNS, NumberCombo, PanelRow } from "./ToolControls";
/** UX audit 2026-09-16: focus ring คีย์บอร์ด — ค่าเดียวกับ FOCUS_RING_CLS ของ
* PDFViewer/AnnotationSidebar (ประกาศแยกที่นี่กัน circular import) */
const FOCUS_RING_CLS =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1";
// กรอบ selection + handles ของ annotation ที่เลือก (PLAN-FEAT.md §3 F1)
// - container เป็น z-4 เหมือน comment pins และ pointer-events: none **เสมอ**
// เฉพาะ handle มุม (scale) / ขอบกรอบ (translate) / แถบเครื่องมือที่ auto
@ -519,7 +524,7 @@ export default function AnnotationSelectionLayer({
aria-label={label}
title={label}
aria-pressed={(highlightData.style ?? "highlight") === key}
className={`flex h-6 w-6 items-center justify-center rounded-md transition ${
className={`flex h-6 w-6 items-center justify-center rounded-md transition ${FOCUS_RING_CLS} ${
(highlightData.style ?? "highlight") === key
? "bg-primary text-white"
: "text-slate-500 hover:bg-slate-100"
@ -589,7 +594,7 @@ export default function AnnotationSelectionLayer({
<PanelRow label="ข้อความ">
<button
type="button"
className="flex flex-1 items-center justify-center gap-1 rounded-md border border-slate-300 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-slate-100"
className={`flex flex-1 items-center justify-center gap-1 rounded-md border border-slate-300 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => onStartEdit?.(annotation.id)}
>
<Pencil className="h-3.5 w-3.5" />
@ -630,7 +635,7 @@ export default function AnnotationSelectionLayer({
type="button"
aria-label="สลับตัวหนาทั้งกล่อง"
aria-pressed={!!textboxData.bold}
className={`flex h-6 w-6 items-center justify-center rounded-md border transition ${
className={`flex h-6 w-6 items-center justify-center rounded-md border transition ${FOCUS_RING_CLS} ${
textboxData.bold
? "border-primary bg-primary text-white"
: "border-slate-300 bg-white text-slate-500 hover:bg-slate-100"
@ -657,7 +662,7 @@ export default function AnnotationSelectionLayer({
type="button"
aria-label={`จัดแนว${aLabel}`}
aria-pressed={(textboxData.align ?? "left") === a}
className={`px-2 py-1 transition ${
className={`px-2 py-1 transition ${FOCUS_RING_CLS} ${
(textboxData.align ?? "left") === a
? "bg-primary text-white"
: "text-slate-500 hover:bg-slate-100"
@ -678,7 +683,7 @@ export default function AnnotationSelectionLayer({
type="button"
aria-label="บันทึก bookmark ตำแหน่งรายการนี้"
title="Bookmark ตำแหน่งนี้"
className="flex h-5 w-5 sel-panel-btn items-center justify-center rounded text-slate-400 hover:bg-amber-50 hover:text-amber-500"
className={`flex h-5 w-5 sel-panel-btn items-center justify-center rounded text-slate-400 hover:bg-amber-50 hover:text-amber-500 ${FOCUS_RING_CLS}`}
onClick={() => onBookmark(boxPct.y)}
>
<BookmarkPlus className="h-3.5 w-3.5" />
@ -687,7 +692,7 @@ export default function AnnotationSelectionLayer({
type="button"
aria-label="ลบรายการนี้"
title="ลบ"
className="flex h-5 w-5 sel-panel-btn items-center justify-center rounded text-red-500 hover:bg-red-50"
className={`flex h-5 w-5 sel-panel-btn items-center justify-center rounded text-red-500 hover:bg-red-50 ${FOCUS_RING_CLS}`}
onClick={() => onDelete(annotation.id)}
>
<Trash2 className="h-3.5 w-3.5" />

View file

@ -126,7 +126,7 @@ import {
import InkLayer, { type InkRedaction, type InkShape, type InkStroke } from "./InkLayer";
import TextboxStampLayer, { type InkStamp, type InkTextbox } from "./TextboxStampLayer";
import AnnotationSelectionLayer from "./AnnotationSelectionLayer";
import AnnotationSidebar from "../sidebar/AnnotationSidebar";
import AnnotationSidebar, { byPageOrder } from "../sidebar/AnnotationSidebar";
import Select from "../Select";
import {
ColorSwatches,
@ -228,7 +228,10 @@ const SCROLL_BELOW_HEADER_GAP = 40;
function scrollToBelowHeader(absoluteTop: number): void {
window.scrollTo({
top: Math.max(0, absoluteTop - viewerHeaderHeight() - SCROLL_BELOW_HEADER_GAP),
behavior: "smooth",
// L7 (UX audit 2026-09-16): เคารพ prefers-reduced-motion — คู่กับ rule ใน globals.css
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches
? "auto"
: "smooth",
});
}
@ -467,15 +470,16 @@ function ReplyBox({
annotationId: string;
replies: ReplyDTO[];
ownUserId: string;
onCreateReply: (annotationId: string, message: string) => void;
onCreateReply: (annotationId: string, message: string) => Promise<boolean>;
onDeleteReply: (id: string) => void;
}) {
const [draft, setDraft] = useState("");
function submit() {
async function submit() {
const msg = draft.trim();
if (!msg) return;
setDraft("");
onCreateReply(annotationId, msg);
// H1 (UX audit 2026-09-16): เคลียร์ draft เฉพาะตอนสำเร็จ — fail แล้วข้อความ
// ที่พิมพ์เองต้องอยู่ต่อให้กดส่งใหม่ได้ (เดิมเคลียร์ก่อนเรียก action = หายทั้งข้อ)
if (await onCreateReply(annotationId, msg)) setDraft("");
}
return (
<div className="mt-2 border-t border-slate-100 pt-2" onClick={(e) => e.stopPropagation()}>
@ -501,7 +505,7 @@ function ReplyBox({
<button
type="button"
aria-label="ลบคำตอบ"
className="shrink-0 text-slate-300 hover:text-red-500"
className={`coarse-36 shrink-0 rounded text-slate-300 hover:text-red-500 ${FOCUS_RING_CLS}`}
onClick={(e) => {
e.stopPropagation();
onDeleteReply(r.id);
@ -520,7 +524,7 @@ function ReplyBox({
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation(); // ไม่ให้ปุ่มใน input ชนคีย์ลัด/Enter ของข้างนอก
if (e.key === "Enter") submit();
if (e.key === "Enter") void submit();
}}
placeholder="ตอบ comment…"
className="min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
@ -528,8 +532,8 @@ function ReplyBox({
<button
type="button"
disabled={!draft.trim()}
onClick={submit}
className="shrink-0 rounded bg-primary px-2 py-1 text-[11px] font-medium text-white disabled:opacity-40"
onClick={() => void submit()}
className={`coarse-36 shrink-0 rounded bg-primary px-2 py-1 text-[11px] font-medium text-white disabled:opacity-40 ${FOCUS_RING_CLS}`}
>
ตอบ
</button>
@ -638,7 +642,7 @@ function PdfPage({
onSetResolved: (id: string, resolved: boolean) => void;
/** N2: ปุ่ม bookmark ในแผง selection → เปิด popup ที่ตำแหน่งของ annotation นั้น */
onAnnotationBookmark: (a: AnnotationDTO, yOffsetPct: number) => void;
onCreateReply: (annotationId: string, message: string) => void;
onCreateReply: (annotationId: string, message: string) => Promise<boolean>;
onDeleteReply: (id: string) => void;
onStrokeEnd: (pointsPct: Point[], widthsPct?: number[]) => void;
onShapeEnd: (data: ShapeData) => void;
@ -963,6 +967,9 @@ function PdfPage({
const x = toPx(a.data.position.x, width);
const y = toPx(a.data.position.y, height);
const open = openPinId === a.id;
// L3 (UX audit 2026-09-16): pin ชิดขวาหน้า — popover เปิดขวาของ pin จะยื่น
// เกินกระดาษ 200px+ พลิกไปซ้ายของ pin แทน (pattern เดียวกับ tooltip N1)
const flipLeft = x + 12 + 224 > width;
return (
<div key={a.id} className="absolute" style={{ left: x, top: y }}>
<button
@ -983,8 +990,13 @@ function PdfPage({
</button>
{open && (
<div
className="absolute left-3 top-0 z-10 w-56 rounded-lg border border-slate-200 bg-white p-2 text-xs shadow-xl"
style={{ pointerEvents: overlayFrozen ? "none" : "auto" }}
className="absolute top-0 z-10 w-56 rounded-lg border border-slate-200 bg-white p-2 text-xs shadow-xl"
style={{
...(flipLeft
? { left: width - 232 - x } // ปลายขวา popover อยู่ห่างขอบขวาหน้า 8px
: { left: 12 }),
pointerEvents: overlayFrozen ? "none" : "auto",
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between gap-2">
@ -995,7 +1007,7 @@ function PdfPage({
type="button"
aria-label="แก้ไขข้อความ"
title="แก้ไขข้อความ"
className="shrink-0 text-slate-400 hover:text-slate-600"
className={`coarse-36 shrink-0 rounded text-slate-400 hover:text-slate-600 ${FOCUS_RING_CLS}`}
onClick={(e) => {
e.stopPropagation();
onStartCommentEdit(a.id);
@ -1031,7 +1043,7 @@ function PdfPage({
<div className="mt-1 flex justify-end gap-1.5 text-[11px]">
<button
type="button"
className="coarse-36 rounded px-2 py-0.5 text-slate-500 hover:bg-slate-100"
className={`coarse-36 rounded px-2 py-0.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => onStartCommentEdit(null)}
>
ยกเลิก
@ -1039,7 +1051,7 @@ function PdfPage({
<button
type="button"
disabled={!commentEditDraft.trim()}
className="coarse-36 rounded bg-primary px-2 py-0.5 font-medium text-white disabled:opacity-40"
className={`coarse-36 rounded bg-primary px-2 py-0.5 font-medium text-white disabled:opacity-40 ${FOCUS_RING_CLS}`}
onClick={() => {
const msg = commentEditDraft.trim();
if (!msg) return;
@ -1055,7 +1067,11 @@ function PdfPage({
</div>
</div>
) : (
<p className="mt-1 whitespace-pre-wrap break-words">{a.data.message}</p>
// T2/T3 (typography audit): ข้อความหลักไทยที่อ่านต่อเนื่อง = 13px +
// leading-relaxed (สระบน+วรรณยุกต์ซ้อนชนกันที่ leading เดิม)
<p className="mt-1 whitespace-pre-wrap break-words text-[13px] leading-relaxed">
{a.data.message}
</p>
)}
<p className="mt-1 text-[11px] text-slate-400" suppressHydrationWarning>
{new Date(a.createdAt).toLocaleString("th-TH")}
@ -1063,7 +1079,7 @@ function PdfPage({
{/* F10/A5: toggle จัดการแล้ว — ใครก็ได้ (D6) */}
<button
type="button"
className={`mt-1.5 w-full rounded px-2 py-1 text-[11px] font-medium ${
className={`coarse-36 mt-1.5 w-full rounded px-2 py-1 text-[11px] font-medium ${FOCUS_RING_CLS} ${
a.resolved
? "bg-emerald-100 text-emerald-700 hover:bg-emerald-200"
: "border border-slate-300 text-slate-500 hover:bg-slate-100"
@ -1137,6 +1153,11 @@ export default function PDFViewer({
// F8: Export PDF ดาวน์โหลดในแท็บเดิม (fetch → blob → <a download>) ไม่เปิดแท็บใหม่
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
/** H1 (UX audit 2026-09-16): error ล่าสุดของการบันทึก/แก้ไข/ลบ annotation — เดิม catch
* ทุกตัวมีแต่ console.error ผู้ใช้เห็นรายการหายเงียบ ๆ (React rollback optimistic)
* โดยไม่รู้เลยว่าบันทึกไม่สำเร็จ */
const [actionError, setActionError] = useState<string | null>(null);
const actionErrorTimerRef = useRef<number | null>(null);
const [pageSizes, setPageSizes] = useState<Size[]>([]);
const [tool, setTool] = useState<Tool>("select");
const [shapeKind, setShapeKind] = useState<ShapeKind>("rect"); // F5: ชนิดรูปทรงของ tool shape
@ -1198,6 +1219,12 @@ export default function PDFViewer({
const [bookmarkError, setBookmarkError] = useState<string | null>(null);
/** กัน double-submit ระหว่าง action ค้าง (กดบันทึกรัว ๆ) */
const bookmarkPendingRef = useRef(false);
/** M6 (UX audit 2026-09-16): feedback สั้น ๆ บนปุ่ม 🔖 หลังบันทึกสำเร็จ — sidebar ที่ปิด
* อยู่ (ค่า default ของ iPad) ทำให้ไม่มีอะไรบอกว่าเซฟแล้ว ผู้ใช้กดซ้ำจนได้ bookmark ซ้ำ */
const [bookmarkSaved, setBookmarkSaved] = useState(false);
const bookmarkSavedTimerRef = useRef<number | null>(null);
/** L6: สถานะกำลังบันทึกของปุ่มใน popup bookmark — กันกดซ้ำเพราะคิดว่ากดไม่เข้า */
const [bookmarkSaving, setBookmarkSaving] = useState(false);
const [previewData, setPreviewData] = useState<AnnotationData | null>(null);
// F13/A6: แก้ไขข้อความ comment ของตัวเองใน popover — บันทึกผ่าน updateAnnotation (reuse F1)
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
@ -1769,6 +1796,16 @@ export default function PDFViewer({
// โผล่กลับมาเมื่อ create เสร็จช้ากว่า)
const pendingCreatesRef = useRef(new Map<string, { cancelled: boolean }>());
/** H1: toast แจ้ง error ของ action 6 วินาที (pattern เดียวกับ exportError) */
function showActionError(message: string) {
setActionError(message);
if (actionErrorTimerRef.current !== null) window.clearTimeout(actionErrorTimerRef.current);
actionErrorTimerRef.current = window.setTimeout(() => {
actionErrorTimerRef.current = null;
setActionError(null);
}, 6000);
}
async function handleCreateAnnotation(
pageIndex: number,
type: AnnotationType,
@ -1851,6 +1888,7 @@ export default function PDFViewer({
});
} catch (err) {
console.error("บันทึก annotation ไม่สำเร็จ", err); // React rollback optimistic row ให้เอง
showActionError("บันทึกรายการไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
} finally {
pendingCreatesRef.current.delete(temp.id);
}
@ -1905,6 +1943,7 @@ export default function PDFViewer({
}
} catch (err) {
console.error("ลบ annotation ไม่สำเร็จ", err);
showActionError("ลบรายการไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
}
});
}
@ -1936,6 +1975,7 @@ export default function PDFViewer({
});
} catch (err) {
console.error("แก้ไข annotation ไม่สำเร็จ", err); // React rollback optimistic row ให้เอง
showActionError("แก้ไขรายการไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
}
});
}
@ -1962,14 +2002,18 @@ export default function PDFViewer({
setAnnotations((prev) => prev.map((a) => (a.id === saved.id ? saved : a)));
} catch (err) {
console.error("ตั้งสถานะไม่สำเร็จ", err); // React rollback optimistic ให้เอง
showActionError("ตั้งสถานะไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
}
});
}
// ─── Replies (F9/A4) ───────────────────────────────────────────────────────
async function handleCreateReply(annotationId: string, message: string) {
if (!user) return;
async function handleCreateReply(
annotationId: string,
message: string,
): Promise<boolean> {
if (!user) return false;
try {
const saved = await createReply({
annotationId,
@ -1980,8 +2024,11 @@ export default function PDFViewer({
setReplies((prev) =>
prev.some((r) => r.id === saved.id) ? prev : [...prev, saved],
);
return true;
} catch (err) {
console.error("ตอบ comment ไม่สำเร็จ", err);
showActionError("ตอบ comment ไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
return false;
}
}
@ -1992,6 +2039,7 @@ export default function PDFViewer({
setReplies((prev) => prev.filter((r) => r.id !== id));
} catch (err) {
console.error("ลบคำตอบไม่สำเร็จ", err);
showActionError("ลบคำตอบไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง");
}
}
@ -2103,10 +2151,23 @@ export default function PDFViewer({
};
}
/** M6: ปุ่ม 🔖 เปลี่ยนเป็น ✓ เขียว 1.5 วิ (pattern เดียวกับ copiedId ของ sidebar) */
function flashBookmarkSaved() {
setBookmarkSaved(true);
if (bookmarkSavedTimerRef.current !== null) {
window.clearTimeout(bookmarkSavedTimerRef.current);
}
bookmarkSavedTimerRef.current = window.setTimeout(() => {
bookmarkSavedTimerRef.current = null;
setBookmarkSaved(false);
}, 1500);
}
async function handleSaveBookmark() {
const c = bookmarkComposer;
if (!c || !user || bookmarkPendingRef.current) return;
bookmarkPendingRef.current = true;
setBookmarkSaving(true);
try {
const saved = await createBookmark({
fileId: file.id,
@ -2125,11 +2186,13 @@ export default function PDFViewer({
setBookmarks((prev) =>
prev.some((b) => b.id === saved.id) ? prev : [...prev, saved],
);
flashBookmarkSaved();
} catch (err) {
console.error("บันทึก bookmark ไม่สำเร็จ", err);
setBookmarkError(err instanceof Error ? err.message : "บันทึกไม่สำเร็จ ลองอีกครั้ง");
} finally {
bookmarkPendingRef.current = false;
setBookmarkSaving(false);
}
}
@ -2437,6 +2500,12 @@ export default function PDFViewer({
window.matchMedia("(pointer: coarse)").matches,
);
// M3 (UX audit 2026-09-16): hint วิธีเลื่อนหน้าของ tool highlight บนจอสัมผัส (ช่วง
// tool นี้ลากที่ไหนก็ไม่เลื่อน — gesture เป็นของ selection) แสดง 3 ครั้งแรกต่อ
// session พอ ไม่รบกวนผู้ใช้ที่คุ้นแล้ว
const coarseHlHintCountRef = useRef(0);
const [coarseHlHintVisible, setCoarseHlHintVisible] = useState(false);
// จอสัมผัส + tool highlight — ลากเลือกข้อความเองผ่าน Pointer Events (ไม่พึ่ง
// native selection ของ OS ที่ยึด gesture ปล่อยนิ้วไปแสดงเมนู/handles): ลาก → ปล่อย
// = ไฮไลต์ทันที ผ่าน commitHighlight ชุดเดียวกับ desktop / preview ของ hook
@ -2580,6 +2649,9 @@ export default function PDFViewer({
// ─── Scroll-to จาก sidebar (PLAN.md §6.4 — คำนวณ absolute ครั้งเดียว) ──────
function goToBookmark(b: BookmarkDTO) {
// M1 (UX audit 2026-09-16): จอ < lg sidebar เป็น overlay — กระโดดแล้วปิดให้
// เห็นเป้าหมายทันที ไม่ต้องปิดเองก่อน (deep link ผ่าน goToAnnotation ก็เช่นกัน)
if (!window.matchMedia("(min-width: 1024px)").matches) setSidebarOpen(false);
const el = pageWrapRefs.current[b.pageIndex];
if (!el) return;
const r = el.getBoundingClientRect();
@ -2591,6 +2663,8 @@ export default function PDFViewer({
}
function goToAnnotation(a: AnnotationDTO) {
// M1: จอ < lg sidebar เป็น overlay — กระโดดแล้วปิดให้ เห็นเป้าหมายทันที
if (!window.matchMedia("(min-width: 1024px)").matches) setSidebarOpen(false);
// คลิกใน sidebar = กระโดดไปหน้า + เลือกรายการนั้น (F1) — เลื่อนให้ขอบบน bbox
// ของรายการอยู่ใต้ sticky header (เดิมเลื่อนไปขอบบนหน้าตรง ๆ เลยถูก header บัง
// กรณีรายการอยู่ส่วนบนสุดของหน้า)
@ -2690,6 +2764,13 @@ export default function PDFViewer({
setTextboxComposer(null);
setEditingTextboxId(null);
window.getSelection()?.removeAllRanges();
// M3: เข้า tool highlight บนจอสัมผัส (ปุ่มหรือคีย์ H) — โชว์ hint ตามโควตาครั้งแรก
if (t === "highlight" && coarseTouch && coarseHlHintCountRef.current < 3) {
coarseHlHintCountRef.current += 1;
setCoarseHlHintVisible(true);
} else {
setCoarseHlHintVisible(false);
}
}
/** ตัวเลื่อนหน้า — scroll ไปต้นหน้าที่ระบุ (กลไกเดียวกับ bookmark/deep link:
@ -2822,7 +2903,8 @@ export default function PDFViewer({
>
<ChevronLeft className="h-4 w-4" />
</button>
<span className="w-16 text-center text-xs tabular-nums">
{/* L4: เลข 3 หลักล้น w-16 — ปล่อยกว้างตามเนื้อหา มีขั้นต่ำให้ตัวเลขไม่ขยับ */}
<span className="w-auto min-w-[4.5rem] px-1 text-center text-xs tabular-nums">
หน้า {pageSizes.length ? currentPage + 1 : "–"}/{pageSizes.length}
</span>
<button
@ -2904,6 +2986,7 @@ export default function PDFViewer({
type="button"
title={label}
aria-label={label}
aria-pressed={tool === "highlight"}
aria-haspopup="menu"
aria-expanded={highlightMenuOpen}
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
@ -2943,7 +3026,7 @@ export default function PDFViewer({
<button
key={sKey}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] font-medium transition ${
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs font-medium transition ${FOCUS_RING_CLS} ${
highlightStyle === sKey
? "bg-slate-100 text-slate-900"
: "text-slate-600 hover:bg-slate-100"
@ -2986,6 +3069,7 @@ export default function PDFViewer({
type="button"
title={label}
aria-label={label}
aria-pressed={tool === "draw"}
aria-haspopup="menu"
aria-expanded={drawMenuOpen}
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
@ -3023,7 +3107,7 @@ export default function PDFViewer({
<button
key={p}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] font-medium transition ${
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs font-medium transition ${FOCUS_RING_CLS} ${
drawPen === p
? "bg-slate-100 text-slate-900"
: "text-slate-600 hover:bg-slate-100"
@ -3068,7 +3152,8 @@ export default function PDFViewer({
onChange={setDrawOpacityPct}
/>
</PanelRow>
<p className="px-1 pt-1.5 text-[10px] leading-tight text-slate-400">
{/* M8: hint ไทยต้อง ≥11px + leading ไม่บีบ (สระซ้อนอ่านไม่ออกที่ 10px) */}
<p className="px-1 pt-1.5 text-[11px] leading-snug text-slate-500">
เมื่อวาดเส้นเกือบตรงแล้วปล่อยเมาส์ ระบบจะ Snap ให้เป็นเส้นตรงอัตโนมัติ — และถ้ากด Shift ค้างจะบังคับให้เส้นตรง
</p>
</div>,
@ -3086,6 +3171,7 @@ export default function PDFViewer({
type="button"
title={label}
aria-label={label}
aria-pressed={tool === "shape"}
aria-haspopup="menu"
aria-expanded={shapeMenuOpen}
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
@ -3121,7 +3207,7 @@ export default function PDFViewer({
<button
key={kind}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] font-medium transition ${
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs font-medium transition ${FOCUS_RING_CLS} ${
shapeKind === kind
? "bg-slate-100 text-slate-900"
: "text-slate-600 hover:bg-slate-100"
@ -3162,6 +3248,7 @@ export default function PDFViewer({
type="button"
title={label}
aria-label={label}
aria-pressed={tool === "stamp"}
aria-haspopup="menu"
aria-expanded={stampMenuOpen}
className={`flex items-center gap-0.5 px-2 py-2 transition ${corner} ${activeCls}`}
@ -3197,9 +3284,11 @@ export default function PDFViewer({
<button
key={preset}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] font-medium transition ${
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs font-medium transition ${FOCUS_RING_CLS} ${
// L1: รายการที่เลือก = state "active" ใช้ slate เหมือนเมนูอื่น
// (แดงสื่อ "อันตราย/ถาวร" เกินเหตุ)
stampPreset === preset
? "bg-red-50 text-red-700"
? "bg-slate-100 text-slate-900"
: "text-slate-600 hover:bg-slate-100"
}`}
onClick={() => setStampPreset(preset)}
@ -3239,6 +3328,7 @@ export default function PDFViewer({
type="button"
title={label}
aria-label={label}
aria-pressed={tool === "textbox"}
aria-haspopup="menu"
aria-expanded={textboxMenuOpen}
className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
@ -3302,7 +3392,7 @@ export default function PDFViewer({
type="button"
aria-label="สลับตัวหนา"
aria-pressed={textboxBold}
className={`flex items-center justify-center rounded-md border px-3 py-1 transition ${
className={`flex items-center justify-center rounded-md border px-3 py-1 transition ${FOCUS_RING_CLS} ${
textboxBold
? "border-primary bg-primary text-white"
: "border-slate-300 bg-white text-slate-500 hover:bg-slate-100"
@ -3329,7 +3419,7 @@ export default function PDFViewer({
type="button"
aria-label={`จัดแนว${alignLabel}`}
aria-pressed={textboxAlign === a}
className={`px-2 py-1 transition ${
className={`px-2 py-1 transition ${FOCUS_RING_CLS} ${
textboxAlign === a
? "bg-primary text-white"
: "text-slate-500 hover:bg-slate-100"
@ -3354,6 +3444,7 @@ export default function PDFViewer({
type="button"
title={label}
aria-label={label}
aria-pressed={tool === key}
className={`px-2 py-2 transition ${corner} ${activeCls}`}
onClick={() => switchTool(key)}
>
@ -3363,13 +3454,15 @@ export default function PDFViewer({
})}
</div>
{/* F8: hint ความปลอดภัยของ redaction (PLAN-FEAT.md §5 — บนจอบังเท่านั้น) */}
{tool === "redact" && (
<p className="hidden max-w-xs text-[11px] leading-tight text-amber-600 lg:block">
เป็นการปกปิดข้อมูลบนหน้าจอชั่วคราวเท่านั้น (ข้อความดั้งเดิมยังสามารถคัดลอกได้)
กรุณากด 'Export PDF' เพื่อปกปิดข้อมูลอย่างถาวรก่อนนำไปเผยแพร่
</p>
)}
{/* H3 (UX audit 2026-09-16): จอสัมผัสไม่มี hover อ่าน title ไม่ได้ — แสดงชื่อ tool
ที่กำลังใช้เป็นข้อความถัดกลุ่มปุ่ม (จอ ≥ lg มี tooltip จาก title อยู่แล้ว)
คำเตือน redaction ย้ายไปเป็นแถบเตือนใต้ header แสดงทุกขนาดจอแทน (H2) */}
<span
aria-hidden
className="hidden max-lg:block shrink-0 text-xs font-medium text-slate-600"
>
{TOOLS.find((t) => t.key === tool)?.label}
</span>
{/* F2: Undo/Redo — ติดกลุ่มปุ่ม tool ในแถวเดียวกัน, disabled ตาม stack
M5: จอ < lg สั่ง order-first ให้อยู่หัวแถวเลื่อน — มองเห็นเสมอโดยไม่ต้อง
@ -3399,8 +3492,10 @@ export default function PDFViewer({
</div>
{/* N2: ปุ่ม 🔖 เปิด popup บันทึก bookmark — ชื่อ/โน้ต/วางระดับบนสุดหรือใต้หัวข้อ
(อยู่แถบ tool ชิดขวา — popup เปิดลงด้านล่างทับหน้าเอกสาร) */}
<div ref={bookmarkWrapRef} className="relative ml-auto max-lg:shrink-0">
(อยู่แถบ tool ชิดขวา — popup เปิดลงด้านล่างทับหน้าเอกสาร)
M2: จอ < lg ให้อยู่หัวแถวเคียง undo (bookmark เป็นส่วนของ flow หลัก
highlight → comment → bookmark แต่เดิมถูกดันไปท้ายสุดของแถบเลื่อน) */}
<div ref={bookmarkWrapRef} className="relative ml-auto max-lg:order-first max-lg:ml-0 max-lg:shrink-0">
<button
ref={bookmarkBtnRef}
type="button"
@ -3410,7 +3505,12 @@ export default function PDFViewer({
onClick={openBookmarkComposer}
className={`rounded-lg border border-slate-300 px-2 py-2 text-slate-500 hover:bg-slate-100 disabled:opacity-40 ${FOCUS_RING_CLS}`}
>
<BookmarkPlus className="h-4 w-4" />
{/* M6: ✓ เขียว 1.5 วิหลังบันทึกสำเร็จ — sidebar ปิดอยู่ก็รู้ว่าเซฟแล้ว */}
{bookmarkSaved ? (
<Check className="h-4 w-4 text-emerald-500" />
) : (
<BookmarkPlus className="h-4 w-4" />
)}
</button>
{bookmarkComposer &&
bookmarkPos &&
@ -3434,7 +3534,7 @@ export default function PDFViewer({
</p>
{/* บอกจุดที่ bookmark จะชี้ (เก็บจากตำแหน่งที่กำลังดูอยู่ตอนเปิด popup)
กันความเข้าใจผิดว่าชี้ที่หัวข้อที่ตั้งชื่อไว้ */}
<p className="mt-0.5 text-[11px] text-slate-400">
<p className="mt-0.5 text-[11px] text-slate-500">
จะชี้ที่: หน้า {bookmarkComposer.pageIndex + 1} ·{" "}
{bookmarkComposer.yOffset <= 25
? "ส่วนบน"
@ -3490,9 +3590,12 @@ export default function PDFViewer({
{optimisticBookmarks
// เป้าหมายของ bookmark ใหม่ = ระดับ 1–2 (สร้างแล้วรวมกัน ≤ 3 ระดับ)
.filter((b) => bookmarkDepthOf(b) < 3)
// M7: เรียงตามลำดับหน้าเหมือน sidebar (N5) + บอกเลขหน้าใน option
// ช่วยหาหัวข้อแม่บน select เนทีฟของ iPad ที่ไม่มีอย่างอื่นให้สแกน
.sort(byPageOrder)
.map((h) => (
<option key={h.id} value={h.id}>
ใต้ &quot;{h.title}&quot;
ใต้ &quot;{h.title}&quot; (หน้า {h.pageIndex + 1})
</option>
))}
</Select>
@ -3504,17 +3607,18 @@ export default function PDFViewer({
<div className="mt-2.5 flex justify-end gap-1.5 text-[11px]">
<button
type="button"
className="rounded px-2.5 py-1 text-slate-500 hover:bg-slate-100"
className={`rounded px-2.5 py-1 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => setBookmarkComposer(null)}
>
ยกเลิก
</button>
<button
type="button"
className="rounded bg-primary px-2.5 py-1 font-medium text-white hover:bg-primary/85"
disabled={bookmarkSaving}
className={`rounded bg-primary px-2.5 py-1 font-medium text-white hover:bg-primary/85 disabled:opacity-60 ${FOCUS_RING_CLS}`}
onClick={() => void handleSaveBookmark()}
>
บันทึก
{bookmarkSaving ? "กำลังบันทึก…" : "บันทึก"}
</button>
</div>
</div>,
@ -3585,6 +3689,23 @@ export default function PDFViewer({
หน้าที่เปิดดูเป็นภาพสแกน ไม่มีข้อความให้คลุมไฮไลต์ — ใช้การวาดเส้นหรือกล่องข้อความแทนได้
</p>
)}
{/* H2 (UX audit 2026-09-16): คำเตือน redaction ย้ายมาเป็นแถบใต้ header แสดงทุก
ขนาดจอ — เดิมเป็น hidden lg:block ผู้ใช้ iPad portrait (อุปกรณ์หลัก) ไม่เห็น
เลย ทั้งเป็นความเสี่ยงข้อมูลส่วนบุคคลรั่วถ้าเผยแพร่ไฟล์ต้นฉบับโดยไม่ export */}
{tool === "redact" && (
<p className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-relaxed text-amber-700">
การปิดพรางบนหน้าจอเป็นการชั่วคราว — ข้อความเดิมยังคัดลอกได้ ต้องกด
&quot;Export PDF&quot; เพื่อปิดพรางถาวรก่อนนำไฟล์ไปเผยแพร่
</p>
)}
{/* M3: จอสัมผัส + tool highlight — อธิบายวิธีเลื่อนหน้า กันอาการ "เลื่อนไม่ได้
= แอปค้าง" (แสดง 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>
)}
{pageSizes.length === 0 ? (
loadError ? (
<div className="flex h-[70vh] items-center justify-center px-6">
@ -3690,9 +3811,9 @@ export default function PDFViewer({
void handleSetResolved(id, resolved);
}}
onAnnotationBookmark={handleAnnotationBookmark}
onCreateReply={(annotationId, message) => {
void handleCreateReply(annotationId, message);
}}
onCreateReply={(annotationId, message) =>
handleCreateReply(annotationId, message)
}
onDeleteReply={(id) => {
void handleDeleteReply(id);
}}
@ -3746,7 +3867,7 @@ export default function PDFViewer({
{hovered.userName}
</p>
{note && (
<p className="mt-0.5 whitespace-pre-wrap break-words text-slate-500">
<p className="mt-0.5 whitespace-pre-wrap break-words leading-relaxed text-slate-500">
{note}
</p>
)}
@ -3775,7 +3896,7 @@ export default function PDFViewer({
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="พิมพ์ข้อความ comment"
className="w-full resize-none rounded border border-slate-300 p-2 text-xs focus:outline-none focus:ring-2 focus:ring-slate-400"
className="w-full resize-none rounded border border-slate-300 p-2 text-xs leading-relaxed focus:outline-none focus:ring-2 focus:ring-slate-400"
onKeyDown={(e) => {
if (e.key === "Escape") setComposer(null);
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
@ -3786,7 +3907,7 @@ export default function PDFViewer({
<div className="mt-2 flex justify-end gap-2 text-xs">
<button
type="button"
className="coarse-36 rounded px-2 py-1 text-slate-500 hover:bg-slate-100"
className={`coarse-36 rounded px-2 py-1 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => setComposer(null)}
>
ยกเลิก
@ -3794,7 +3915,7 @@ export default function PDFViewer({
<button
type="button"
disabled={!draft.trim()}
className="coarse-36 rounded bg-primary px-2 py-1 font-medium text-white disabled:opacity-40"
className={`coarse-36 rounded bg-primary px-2 py-1 font-medium text-white disabled:opacity-40 ${FOCUS_RING_CLS}`}
onClick={() => void handleComposerSave()}
>
บันทึก
@ -3803,12 +3924,28 @@ export default function PDFViewer({
</div>
)}
{/* F8: export พัง — toast แจ้งในหน้า (portal ตามกติกา fixed UI ของ rules/pdf-viewer.md) */}
{exportError &&
{/* F8: export พัง + H1: action พัง — toast แจ้งในหน้า (portal ตามกติกา fixed UI
ของ rules/pdf-viewer.md) วางซ้อนเป็นคอลัมน์เดียว ไม่ทับกันเมื่อเกิดพร้อมกัน */}
{(exportError || actionError) &&
typeof document !== "undefined" &&
createPortal(
<div className="fixed bottom-4 right-4 z-[60] max-w-sm rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 shadow-xl">
{exportError}
<div className="fixed bottom-4 right-4 z-[60] flex max-w-sm flex-col gap-2">
{actionError && (
<div
role="alert"
className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 shadow-xl"
>
{actionError}
</div>
)}
{exportError && (
<div
role="alert"
className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 shadow-xl"
>
{exportError}
</div>
)}
</div>,
document.body,
)}

View file

@ -23,6 +23,13 @@ export type InkStamp = { id: string; userId: string; data: StampData };
// ฟอนต์ตรายางบนจอ — ตั้งชัดทั้งตอนวัด (canvas) และตอน render เพื่อให้ความกว้าง
// ที่ stampFitFontSize คำนวณตรงกับที่แสดงจริง (ตัวหนา 700 เหมือน class font-bold)
const STAMP_FONT_STACK = "system-ui, sans-serif";
/** typography audit 2026-09-16 (T4.4 + Option B): ฟอนต์กล่องข้อความบนจอ — pin ชัดทั้ง
* editor และ render ไม่สืบทอดจาก body: กล่องกว้างเป็น % คงที่ ฟอนต์เปลี่ยนตามเครื่อง
* จะทำให้ตัดบรรทัดไม่เท่ากัน — ผูกเข้า webfont Sarabun (var จาก next/font) ให้ wrap
* ตรงกันทุกอุปกรณ์จริง (T1-C) พร้อม system ไทยเดิมเป็น fallback ตอนโหลดไม่ได้ */
const TEXTBOX_FONT_STACK =
"var(--font-sarabun, 'Sarabun'), 'Leelawadee UI', 'Thonburi', sans-serif";
let stampMeasureCtx: CanvasRenderingContext2D | null = null;
/** วัดความกว้างข้อความตรายางบนจอ — ไม่มี document (SSR) คืน 0 ให้ใช้ขนาด base */
@ -189,6 +196,7 @@ function TextboxEditor({
}}
className="min-h-[1.5em] w-full whitespace-pre-wrap break-words rounded border border-dashed border-slate-500/70 bg-transparent p-0 caret-current focus:outline-none"
style={{
fontFamily: TEXTBOX_FONT_STACK,
fontSize: toPx(fontSizePct, size.width),
lineHeight: 1.5,
color,
@ -275,6 +283,7 @@ export default function TextboxStampLayer({
// (แบบ Smallpdf กล่องไม่ขยับ) · maxWidth บีบตามพื้นที่ถึงขอบขวาเหมือนเดิม
...(t.data.width !== undefined ? { width: `${t.data.width}%` } : null),
maxWidth: `${textboxMaxWidthPct(t.data.position.x)}%`,
fontFamily: TEXTBOX_FONT_STACK,
fontSize: toPx(t.data.fontSize, size.width),
lineHeight: 1.5,
color: t.data.color,

View file

@ -1,13 +1,19 @@
"use client";
import { useEffect, useRef, useState, type ComponentType, type ReactNode } from "react";
import { Check, ChevronDown, ChevronUp, Highlighter, Strikethrough, Underline } from "lucide-react";
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
import { ChevronDown, ChevronUp, Highlighter, Strikethrough, Underline } from "lucide-react";
import type { HighlightStyle } from "@/db/schema";
// ตัวควบคุมที่ใช้ร่วมระหว่าง dropdown บนปุ่ม tool (PDFViewer) กับแผงเครื่องมือ
// บนกรอบ selection (AnnotationSelectionLayer) — หน้าตา/พฤติกรรมเดียวกันทั้งสองที่
/** UX audit 2026-09-16: focus ring คีย์บอร์ด — ค่าเดียวกับ FOCUS_RING_CLS ของ
* PDFViewer/AnnotationSidebar (ประกาศแยกที่นี่เพราะ PDFViewer import ไฟล์นี้
* อยู่แล้ว ห้ามวนกลับ) */
const FOCUS_RING_CLS =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1";
/** สี preset ให้เลือก (ทุก tool ที่ปรับสีได้: draw/shape/highlight/textbox/stamp)
* สีอื่นนอกรายการเลือกได้ผ่านปุ่มสีรุ้ง (input type="color") ท้ายแถว */
export const INK_COLORS = [
@ -43,7 +49,7 @@ export function ColorSwatches({ value, onChange }: { value: string; onChange: (c
type="button"
aria-label={`เลือกสี ${c}`}
title={c}
className={`h-4 w-4 rounded-full border border-slate-300 transition ${
className={`h-4 w-4 rounded-full border border-slate-300 transition ${FOCUS_RING_CLS} ${
value === c ? "ring-2 ring-primary ring-offset-1" : "hover:scale-110"
}`}
style={{ backgroundColor: c }}
@ -52,7 +58,7 @@ export function ColorSwatches({ value, onChange }: { value: string; onChange: (c
))}
<label
title="เลือกสีอื่น"
className={`relative h-4 w-4 cursor-pointer rounded-full border border-slate-300 transition ${
className={`relative h-4 w-4 cursor-pointer rounded-full border border-slate-300 transition has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary has-[:focus-visible]:ring-offset-1 ${
INK_COLORS.includes(value) ? "hover:scale-110" : "ring-2 ring-primary ring-offset-1"
}`}
style={{
@ -96,11 +102,9 @@ export function NumberCombo({
suffix?: string;
onChange: (v: number) => void;
}) {
const [open, setOpen] = useState(false);
// FIX-3: ถือ draft ระหว่างพิมพ์ — commit ครั้งเดียวตอน blur/Enter
// ไม่ยิง server action + version snapshot ต่อ keystroke; sync กลับเมื่อค่าจากภายนอกเปลี่ยน
const [draft, setDraft] = useState(String(value));
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => setDraft(String(value)), [value]);
@ -120,15 +124,6 @@ export function NumberCombo({
// eslint-disable-next-line react-hooks/exhaustive-deps -- draft/value/onChange ใช้ค่าล่าสุดตอน timer ยิง
}, [draft]);
useEffect(() => {
if (!open) return;
const onMouseDown = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onMouseDown);
return () => document.removeEventListener("mousedown", onMouseDown);
}, [open]);
/** ยืนยันค่าที่พิมพ์หนึ่งครั้ง — clamp เข้า min–max; พิมพ์ไม่เป็นตัวเลข = คืนค่าเดิม
* ค่าเท่าเดิมไม่ยิง onChange — focus/blur เฉย ๆ ต้องไม่เกิด server action + snapshot ขยะ */
function commitDraft() {
@ -153,17 +148,7 @@ export function NumberCombo({
}
return (
<div
ref={rootRef}
className="number-combo relative"
onKeyDown={(e) => {
// Esc ปิดแค่ dropdown นี้ก่อน (stopPropagation ไม่ให้ปิดทั้งเมนู/กรอบ)
if (e.key === "Escape" && open) {
e.stopPropagation();
setOpen(false);
}
}}
>
<div className="number-combo">
<div className="flex items-stretch overflow-hidden rounded-md border border-slate-300 bg-white">
<input
type="number"
@ -173,10 +158,7 @@ export function NumberCombo({
step={step}
onChange={(e) => setDraft(e.target.value)}
onBlur={commitDraft}
onFocus={(e) => {
setOpen(true); // กดที่ช่อง = เปิดรายการให้เลือกเลย (ไม่ต้องมีปุ่มเปิดแยก)
e.target.select(); // พิมพ์ทับค่าเดิมได้ทันที
}}
onFocus={(e) => e.target.select()} // พิมพ์ทับค่าเดิมได้ทันที
onKeyDown={(e) => {
if (e.key !== "Escape") e.stopPropagation(); // พิมพ์ในช่องต้องไม่ชนคีย์ลัด F3 (Esc ปล่อยไหลไปปิดเมนู)
if (e.key === "Enter") commitDraft(); // ยืนยันครั้งเดียวตรงนี้ ไม่ต้องรอ blur
@ -190,7 +172,7 @@ export function NumberCombo({
type="button"
aria-label="เพิ่มค่า"
onMouseDown={(e) => e.preventDefault()} // ไม่แย่ง focus จาก input (dropdown ไม่กระพริบ)
className="combo-step flex flex-1 items-center justify-center px-1.5 text-slate-500 hover:bg-slate-100"
className={`combo-step flex flex-1 items-center justify-center rounded-sm px-1.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => stepValue(1)}
>
<ChevronUp className="h-3 w-3" />
@ -199,42 +181,41 @@ export function NumberCombo({
type="button"
aria-label="ลดค่า"
onMouseDown={(e) => e.preventDefault()}
className="combo-step flex flex-1 items-center justify-center border-t border-slate-300 px-1.5 text-slate-500 hover:bg-slate-100"
className={`combo-step flex flex-1 items-center justify-center rounded-sm border-t border-slate-300 px-1.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => stepValue(-1)}
>
<ChevronDown className="h-3 w-3" />
</button>
</div>
</div>
{open && (
<div
role="listbox"
className="absolute right-0 top-full z-[60] mt-1 w-16 overflow-hidden rounded-md border border-slate-200 bg-white py-0.5 shadow-lg"
>
{options.map((opt) => (
{/* L2 (UX audit 2026-09-16): preset เดิมเป็น dropdown เปิดลงจากช่อง — แถวท้าย
ของเมนู tool (ความทึบ/ความหนา) ล้นเขต scroll ของเมนู ต้องเลื่อนเมนูจึงเห็น
ครบ เปลี่ยนเป็น chips แถวเดียวใต้ช่อง: เห็นค่าที่เลือกได้ทันที ไม่มี popup
ซ้อนในเมนูอีก (จอสัมผัสได้ min 36px จาก rule [data-tool-menu] โดยอัตโนมัติ) */}
<div className="mt-1 flex flex-wrap gap-1">
{options.map((opt) => {
const active = value === opt;
return (
<button
key={opt}
type="button"
role="option"
aria-selected={value === opt}
onMouseDown={(e) => e.preventDefault()} // ไม่แย่ง focus จาก input ก่อนคลิก
className={`flex w-full items-center justify-end gap-1 px-2 py-1 text-right text-[11px] font-medium transition ${
value === opt
? "bg-slate-100 text-slate-900"
: "text-slate-600 hover:bg-slate-100"
aria-pressed={active}
className={`rounded border px-1.5 py-0.5 text-[11px] transition ${FOCUS_RING_CLS} ${
active
? "border-primary bg-primary/10 font-medium text-primary"
: "border-slate-300 text-slate-500 hover:bg-slate-100"
}`}
onClick={() => {
setDraft(String(opt));
onChange(opt);
setOpen(false);
}}
>
{opt}
{suffix}
{value === opt && <Check className="h-3 w-3 shrink-0" />}
</button>
))}
</div>
)}
);
})}
</div>
</div>
);
}

View file

@ -126,8 +126,9 @@ function isDescendantOf(id: string, ancestorId: string, byId: Map<string, Bookma
}
/** N5: เรียงตามลำดับหน้า — หน้า → ตำแหน่งบนหน้า (บน→ล่าง) → เวลาสร้าง (tiebreak ให้ deterministic)
* sort ตอน render ฝั่ง client เท่านั้น — realtime แทรก row ต่อท้าย state เชื่อลำดับจาก server อย่างเดียวไม่ได้ */
function byPageOrder(a: BookmarkDTO, b: BookmarkDTO): number {
* sort ตอน render ฝั่ง client เท่านั้น — realtime แทรก row ต่อท้าย state เชื่อลำดับจาก server อย่างเดียวไม่ได้
* (export ให้ PDFViewer ใช้กับ dropdown "วางที่" ของ popup bookmark ด้วย — M7 UX audit 2026-09-16) */
export function byPageOrder(a: BookmarkDTO, b: BookmarkDTO): number {
return (
a.pageIndex - b.pageIndex ||
a.yOffset - b.yOffset ||
@ -747,14 +748,16 @@ export default function AnnotationSidebar({
// (server ตรวจซ้ำอีกชั้นพร้อม lock — ตรงเกณฑ์เดียวกัน)
// FIX-11: คำนวณเฉพาะตอนเปิดแก้หัวข้อนี้ — ไล่ subtree ทุก node ทุก render เปลืองเปล่า
const movableTo = editing
? bookmarks.filter(
(t) =>
t.id !== b.id &&
!isDescendantOf(t.id, b.id, bookmarkById) &&
bookmarkDepth(t, bookmarkById) +
subtreeHeight(b, (id) => bookmarks.filter((x) => x.parentId === id)) <=
3,
)
? bookmarks
.filter(
(t) =>
t.id !== b.id &&
!isDescendantOf(t.id, b.id, bookmarkById) &&
bookmarkDepth(t, bookmarkById) +
subtreeHeight(b, (id) => bookmarks.filter((x) => x.parentId === id)) <=
3,
)
.sort(byPageOrder) // M7: เรียงตามลำดับหน้าเหมือนต้นไม้ — หาหัวข้อแม่ใน select ง่ายขึ้น
: [];
// พื้นหลัง hover อยู่ที่ div แถวรายการ ไม่ใช่ li — li ครอบ <ul> ลูกทั้งก้อน
// ทำให้ :hover ของแม่ทำงานด้วยเมื่อชี้รายการลูก (ไฮไลต์ทั้ง subtree เป็นก้อนเดียว)
@ -802,7 +805,7 @@ export default function AnnotationSidebar({
<option value="">ระดับบนสุด (หมุดหัวข้อใหญ่)</option>
{movableTo.map((h) => (
<option key={h.id} value={h.id}>
ใต้ &quot;{h.title}&quot;
ใต้ &quot;{h.title}&quot; (หน้า {h.pageIndex + 1})
</option>
))}
</Select>
@ -855,12 +858,16 @@ export default function AnnotationSidebar({
<span className="min-w-0">
<span
className={`block truncate text-[13px] leading-snug ${
depth === 1 ? "font-medium text-slate-700" : ""
depth === 1 ? "font-semibold text-slate-700" : ""
}`}
>
{b.title}
</span>
<span className="block text-xs text-slate-500">หน้า {b.pageIndex + 1}</span>
{/* แสดงผู้บันทึกด้วย — เหมือนแถวผลการค้นหา (N6) และแถวหมายเหตุ
(แถวเดิมมีแค่ "หน้า N" ไม่รู้ว่าใครเป็นคนคั่นไว้) */}
<span className="block text-xs text-slate-500">
หน้า {b.pageIndex + 1} · {b.userName || "ไม่ทราบชื่อ"}
</span>
{b.note && (
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-slate-500">
{b.note}
@ -870,7 +877,7 @@ export default function AnnotationSidebar({
</button>
{/* จำนวนหมุดย่อย — badge ขวาสุด (UX audit: แยกจากชื่อ ไม่ปนตอนอ่านไว) */}
{hasChildren && (
<span className="shrink-0 self-center rounded-full bg-slate-100 px-1.5 text-[10px] leading-4 text-slate-500">
<span className="shrink-0 self-center rounded-full bg-slate-100 px-1.5 text-[10px] tabular-nums leading-4 text-slate-500">
{children.length}
</span>
)}
@ -1046,7 +1053,7 @@ export default function AnnotationSidebar({
type="button"
role="tab"
aria-selected={tab === key}
className={`flex-1 px-2 py-2 text-xs font-medium transition ${FOCUS_RING_CLS} ${
className={`flex-1 px-2 py-2 text-xs font-medium tabular-nums transition ${FOCUS_RING_CLS} ${
tab === key
? "border-b-2 border-primary text-slate-900"
: "text-slate-500 hover:text-slate-600"
@ -1252,7 +1259,7 @@ export default function AnnotationSidebar({
bmResults !== null ? (
<div>
<div className="flex items-center justify-between px-1 pb-1">
<p className="text-[11px] font-semibold text-slate-500">
<p className="text-[11px] font-semibold tabular-nums text-slate-500">
ผลการค้นหา ({bmResults.length})
</p>
<button
@ -1358,6 +1365,11 @@ export default function AnnotationSidebar({
<span className="block truncate text-[13px] leading-snug font-medium text-slate-700">
{b.title}
</span>
{/* ผู้บันทึกเหมือนแถวผลการค้นหา — ไม่ใส่ "หน้า N"
เพราะหัวกลุ่มของมุมมองนี้บอกหน้าอยู่แล้ว */}
<span className="block text-xs text-slate-500">
{b.userName || "ไม่ทราบชื่อ"}
</span>
{b.note && (
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-slate-500">
{b.note}
@ -1365,7 +1377,7 @@ export default function AnnotationSidebar({
)}
</span>
{childCount > 0 && (
<span className="ml-auto shrink-0 self-center rounded-full bg-slate-100 px-1.5 text-[10px] leading-4 text-slate-500">
<span className="ml-auto shrink-0 self-center rounded-full bg-slate-100 px-1.5 text-[10px] tabular-nums leading-4 text-slate-500">
{childCount}
</span>
)}
@ -1392,7 +1404,7 @@ export default function AnnotationSidebar({
) : visibleSearchResults !== null ? (
<div>
<div className="flex items-center justify-between px-1 pb-1">
<p className="text-[11px] font-semibold text-slate-500">
<p className="text-[11px] font-semibold tabular-nums text-slate-500">
ผลการค้นหา ({visibleSearchResults.length})
</p>
<button
@ -1482,12 +1494,16 @@ export default function AnnotationSidebar({
<>
{/* F19/A7: dialog ประวัติ — รายการ version (เวลา + ชนิดการเปลี่ยนแปลง) + ปุ่มกู้คืน */}
{historyFor && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 p-4">
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 p-4"
onClick={() => setHistoryFor(null)}
>
<div
role="dialog"
aria-modal="true"
aria-label="ประวัติการเปลี่ยนแปลง"
className="w-80 rounded-lg border border-slate-200 bg-white p-4 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<p className="text-sm font-semibold">ประวัติการเปลี่ยนแปลง</p>
<p className="mt-0.5 truncate text-[11px] text-slate-500">
@ -1574,14 +1590,14 @@ export default function AnnotationSidebar({
<div className="mt-1.5 flex justify-end gap-1.5">
<button
type="button"
className={`rounded px-2 py-1 text-[11px] text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
className={`rounded px-2 py-1 text-xs text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={closeRowMenu}
>
ยกเลิก
</button>
<button
type="button"
className={`rounded bg-red-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-red-700 ${FOCUS_RING_CLS}`}
className={`rounded bg-red-600 px-2 py-1 text-xs font-medium text-white hover:bg-red-700 ${FOCUS_RING_CLS}`}
onClick={() => {
// อ่าน kind/id ก่อน close — setMenuFor(null) แล้วเงื่อนไขเดิมหาย
if (menuFor.kind === "annotation") onDeleteAnnotation(menuFor.id);