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:
parent
a020e96bda
commit
21b9958600
10 changed files with 351 additions and 158 deletions
|
|
@ -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}>
|
||||
ใต้ "{h.title}"
|
||||
ใต้ "{h.title}" (หน้า {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">
|
||||
การปิดพรางบนหน้าจอเป็นการชั่วคราว — ข้อความเดิมยังคัดลอกได้ ต้องกด
|
||||
"Export PDF" เพื่อปิดพรางถาวรก่อนนำไฟล์ไปเผยแพร่
|
||||
</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,
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue