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,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);