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

@ -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,
)}