From f7b5a8488ddfd78d8af95d949eff652356df4901 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 21 Sep 2026 12:03:16 +0700 Subject: [PATCH] =?UTF-8?q?toolbar:=20=E0=B9=83=E0=B8=AA=E0=B9=88=E0=B8=84?= =?UTF-8?q?=E0=B8=B3=E0=B8=AD=E0=B8=98=E0=B8=B4=E0=B8=9A=E0=B8=B2=E0=B8=A2?= =?UTF-8?q?=E0=B9=84=E0=B8=97=E0=B8=A2=E0=B8=AB=E0=B8=A5=E0=B8=B1=E0=B8=87?= =?UTF-8?q?=20icon=20=E0=B8=97=E0=B8=B8=E0=B8=81=E0=B8=9B=E0=B8=B8?= =?UTF-8?q?=E0=B9=88=E0=B8=A1=E0=B8=82=E0=B8=AD=E0=B8=87=20header=20?= =?UTF-8?q?=E0=B8=AB=E0=B8=99=E0=B9=89=E0=B8=B2=20annotate=20(=E0=B8=84?= =?UTF-8?q?=E0=B8=B3=E0=B8=82=E0=B8=AD=E0=B8=9C=E0=B8=B9=E0=B9=89=E0=B9=83?= =?UTF-8?q?=E0=B8=8A=E0=B9=89=202026-09-21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ปุ่ม tool ทั้ง 10 แสดงชื่อสั้นหลัง icon — เพิ่ม field "text" ใน TOOLS แยกจาก "label" (label เต็มพร้อมคีย์ลัด H/D/C ยังใช้ทำ title/aria-label ตามเดิม): เลือกข้อความ/เลื่อนหน้า/ มาร์กข้อความ/วาดเส้น/วางคอมเมนต์/รูปทรง/ยางลบ/ปิดพราง/กล่องข้อความ/ตรายาง — เหตุผล: ผู้ใช้ศาลไม่คุ้น icon ล้วน (NN Group: icon เข้าใจสากลมีน้อยมาก) โดยเฉพาะจอสัมผัส ที่ hover อ่าน title ไม่ได้ จึงถอด span ชื่อ tool-active เฉพาะ max-lg (H3 UX audit 2026-09-16) ออกด้วย เพราะ label มองเห็นทุกปุ่มแล้ว (เหลือ comment จดประวัติ) - ปุ่ม bookmark → "Bookmark" (ตามแท็บ sidebar Bookmark (N) ที่เลือกไว้ commit b1bc65e), ปุ่ม export → "Export PDF" (ตรง string เดิม), ลิงก์หน้าสรุป → "หน้าสรุป" แบบ hidden sm:inline ตาม pattern ชื่อไฟล์ (จอ < 640 เหลือ icon เดี่ยวไม่ให้แถว title ล้น) + เติม aria-label ที่ Link โดยตรง - แก้ตาม UX review (ui-ux-designer): typo หน้าสรุป "ปิดพาง"→"ปิดพราง" (TYPE_SHORT), label ยางลบ → "ยางลบ (ลบด้วยการลาก)" ให้ accessible name มีคำที่แสดงบนปุ่ม (WCAG 2.5.3 Label in Name — เดิม aria คนละชื่อกับข้อความที่เห็น), "วาง comment" → "วางคอมเมนต์" ให้เป็นศัพท์เดียวกับ sidebar/หน้าสรุป/CSV - A2 (UX review): effect ใหม่ — tool เปลี่ยนแล้วปุ่มของ tool นั้นหลุดจอ (จอ < lg ที่แถบ เป็น overflow-x-auto) ให้ smooth-scroll กลับกึ่งกลางแถบ กันเคสเลื่อนแถบไปแตะ tool ท้ายแถว (เช่น ตรายาง) แล้วเลื่อนกลับหา undo จนลืมว่า tool ไหน active แล้วแตะหน้า เอกสาร = สร้างรายการโดยไม่ตั้งใจ — guard ข้ามตอนเมนูเปิด (scroll จะ trigger listener ปิดเมนู C1) + selfScrollUntilRef ให้ listener ของ C1 เมิน scroll event ของแอนิเมชัน ตัวเอง ~600ms กันเมนูที่ผู้ใช้เพิ่งเปิดกลางแอนิเมชันโดนปิดทันที (code review) + matchMedia "not all and (min-width: 1024px)" เป็น complement แท้ของ min-width: 1024px ให้ตรง max-lg ของ Tailwind (max-width: 1023px เดิมพลาดจอเศษส่วน 1023.5px จาก display scaling) + เคารพ prefers-reduced-motion verify: tsc ผ่าน + vitest 139/139 + DOM จริง 4 viewport (1440/1024/820/390: label ครบ บรรทัดเดียวไม่ clip, undo เห็นหัวแถวมือถือ) + A2 จริงที่ 390 (เลื่อนท้ายสุด 836 → กด D → เลื่อนกลับ 337 ปุ่มเข้าจอ, จอ 1280 ไม่มีผลข้างเคียง) — ผลข้างเคียงที่ยอมรับ: header จอ 1024–1180 สูง 178px (~23%) จากเดิม ~110px (ปุ่ม label กว้างขึ้น กรอบ tool wrap 2 แถว) ตามการตัดสินใจของผู้ใช้ ถ้าใช้จริงแล้วหนักไป ย้อนด้วย lg:hidden xl:inline ที่ span ได้ Co-Authored-By: Claude Code --- app/annotate/[id]/summary/page.tsx | 2 +- components/pdf/PDFViewer.tsx | 119 +++++++++++++++++++++-------- 2 files changed, 87 insertions(+), 34 deletions(-) diff --git a/app/annotate/[id]/summary/page.tsx b/app/annotate/[id]/summary/page.tsx index 424596c..ff61f2a 100644 --- a/app/annotate/[id]/summary/page.tsx +++ b/app/annotate/[id]/summary/page.tsx @@ -51,7 +51,7 @@ const TYPE_SHORT: Record = { DRAW: "เส้นวาด", COMMENT: "คอมเมนต์", SHAPE: "รูปทรง", - REDACTION: "ปิดพาง", + REDACTION: "ปิดพราง", TEXTBOX: "กล่องข้อความ", STAMP: "ตรายาง", }; diff --git a/components/pdf/PDFViewer.tsx b/components/pdf/PDFViewer.tsx index 6b5c939..d110630 100644 --- a/components/pdf/PDFViewer.tsx +++ b/components/pdf/PDFViewer.tsx @@ -356,17 +356,21 @@ function selectionTextWithin(kept: DOMRect[], range: Range): string { return sanitizePdfText(out).slice(0, 5000) || sanitizePdfText(raw.slice(0, 5000)); } -const TOOLS: { key: Tool; label: string; Icon: LucideIcon }[] = [ - { key: "select", label: "เลือกข้อความ", Icon: MousePointer2 }, - { key: "pan", label: "เลื่อนหน้า (pan)", Icon: Hand }, - { key: "highlight", label: "มาร์กข้อความ (H)", Icon: Highlighter }, - { key: "draw", label: "วาดเส้น (D)", Icon: Pencil }, - { key: "comment", label: "วาง comment (C)", Icon: MessageSquarePlus }, - { key: "shape", label: "รูปทรง", Icon: Shapes }, - { key: "erase", label: "ลบด้วยการลาก (eraser)", Icon: Eraser }, - { key: "redact", label: "ปิดพราง (redaction)", Icon: EyeOff }, - { key: "textbox", label: "กล่องข้อความ", Icon: Type }, - { key: "stamp", label: "ตรายาง (stamp)", Icon: Stamp }, +/** label = ชื่อเต็มสำหรับ title/aria-label (พร้อมคีย์ลัด/คำอังกฤษประกอบ) — + * text = คำสั้น ๆ แสดงเป็นข้อความหลังไอคอนบนปุ่มทุก tool (คำขอผู้ใช้ 2026-09-21: + * ผู้ใช้ศาลไม่คุ้นไอคอนล้วน โดยเฉพาะจอสัมผัสที่ hover อ่าน title ไม่ได้ — แทน span + * ชื่อ tool-active ของ H3 ที่ถอดออก) */ +const TOOLS: { key: Tool; label: string; text: string; Icon: LucideIcon }[] = [ + { key: "select", label: "เลือกข้อความ", text: "เลือกข้อความ", Icon: MousePointer2 }, + { key: "pan", label: "เลื่อนหน้า (pan)", text: "เลื่อนหน้า", Icon: Hand }, + { key: "highlight", label: "มาร์กข้อความ (H)", text: "มาร์กข้อความ", Icon: Highlighter }, + { key: "draw", label: "วาดเส้น (D)", text: "วาดเส้น", Icon: Pencil }, + { key: "comment", label: "วางคอมเมนต์ (C)", text: "วางคอมเมนต์", Icon: MessageSquarePlus }, + { key: "shape", label: "รูปทรง", text: "รูปทรง", Icon: Shapes }, + { key: "erase", label: "ยางลบ (ลบด้วยการลาก)", text: "ยางลบ", Icon: Eraser }, + { key: "redact", label: "ปิดพราง (redaction)", text: "ปิดพราง", Icon: EyeOff }, + { key: "textbox", label: "กล่องข้อความ", text: "กล่องข้อความ", Icon: Type }, + { key: "stamp", label: "ตรายาง (stamp)", text: "ตรายาง", Icon: Stamp }, ]; /** UX review 2026-09-14 M4: focus ring ตอน keyboard focus — ทั้งโปรเจกต์ไม่เคยมี focus-visible @@ -1361,6 +1365,12 @@ export default function PDFViewer({ textboxMenuOpen || !!bookmarkComposer; + /** A2 (code review 2026-09-21): เวลาที่แอนิเมชัน smooth scroll เลื่อนปุ่ม tool active + * (effect ด้านล่าง) จะหมด — listener ปิดเมนูของ C1 เช็ค ref นี้ก่อน ไม่งั้น scroll + * event ที่เหลือจากแอนิเมชันจะปิดเมนูที่ผู้ใช้เพิ่งเปิดระหว่างนั้นทันที (และ + * markMenuClosed กลืน tap ถัดไป 300ms ด้วย) */ + const selfScrollUntilRef = useRef(0); + // C1: ปิดเมนูทั้งหมดเมื่อเลื่อนแถบ tool ต่อ หรือ resize/หมุนจอ — ตำแหน่ง fixed จะเพี้ยน // จากปุ่มทันทีที่แถบเลื่อน (header sticky → เลื่อนหน้าเอกสารไม่กระทบ ปุ่มไม่ขยับเทียบ viewport) // Major 1 (code review): resize ของมือถือจาก URL bar/คีย์บอร์ดเปลี่ยนแค่ความสูงและเมนูยัง @@ -1379,14 +1389,52 @@ export default function PDFViewer({ const onResize = () => { if (window.innerWidth !== menuOpenWRef.current) close(); }; - toolbarRowRef.current?.addEventListener("scroll", close, { passive: true }); + const onScroll = () => { + // A2: ข้าม scroll event ที่มาจากแอนิเมชันเลื่อนปุ่ม active ของเราเอง — + // กันเมนูที่ผู้ใช้เพิ่งเปิดกลางแอนิเมชันโดนปิดทันที + if (performance.now() < selfScrollUntilRef.current) return; + close(); + }; + toolbarRowRef.current?.addEventListener("scroll", onScroll, { passive: true }); window.addEventListener("resize", onResize); return () => { - toolbarRowRef.current?.removeEventListener("scroll", close); + toolbarRowRef.current?.removeEventListener("scroll", onScroll); window.removeEventListener("resize", onResize); }; }, [anyMenuOpen]); + // UX review 2026-09-21 A2: เมื่อ tool เปลี่ยนแล้วปุ่มของ tool นั้นหลุดออกนอกจอ (แถบเลื่อน + // ของจอ < lg) ให้เลื่อนกลับเข้ามากึ่งกลางแถบ — กันกรณีเลื่อนแถบไปแตะ tool ท้ายแถว + // (เช่น ตรายาง) แล้วเลื่อนกลับหา undo จนปุ่ม active อยู่นอกจอ ผู้ใช้ลืมว่า tool ไหน + // กำลังทำงานแล้วแตะหน้าเอกสาร = สร้างรายการโดยไม่ตั้งใจ — ข้ามเมื่อมีเมนูเปิดอยู่ + // เพราะ smooth scroll ของแถบจะ trigger listener ปิดเมนูด้านบนทันที (ปุ่ม dropdown + // ที่เพิ่งแตะเห็นอยู่แล้วจึงไม่ต้องเลื่อน), ทำเฉพาะจอแคบ < lg (desktop แถบไม่เลื่อน) + useEffect(() => { + if (anyMenuOpen) return; + // เงื่อนไขเป็นส่วนเติมเต็มของ min-width: 1024px (จุดเดียวกับ effect ปิด sidebar ด้านบน) + // ให้ตรงกับ max-lg ของ Tailwind (= not all and (min-width: 1024px)) — max-width: 1023px + // ธรรมดาจะพลาดจอกว้างเศษส่วน เช่น 1023.5px จาก display scaling 125%/150% + if (!window.matchMedia("not all and (min-width: 1024px)").matches) return; + const row = toolbarRowRef.current; + const entry = TOOLS.find((t) => t.key === tool); + if (!row || !entry) return; + const btn = row.querySelector(`button[title="${entry.label}"]`); + if (!btn) return; + const b = btn.getBoundingClientRect(); + const r = row.getBoundingClientRect(); + if (b.left >= r.left - 1 && b.right <= r.right + 1) return; // เห็นอยู่แล้ว + // บอก listener ของ C1 ให้เมิน scroll event ช่วง ~600ms นี้ (แอนิเมชันยาว ~450ms) + selfScrollUntilRef.current = performance.now() + 600; + row.scrollTo({ + // กึ่งกลางแถบ — ปุ่มกว้างกว่าแถว (จอเล็กมาก) ก็ชิดซ้ายขอบแถบพอ + left: row.scrollLeft + b.left - r.left - Math.max(0, (r.width - b.width) / 2), + // L7: เคารพ prefers-reduced-motion (pattern เดียวกับ scrollToBelowHeader) + behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth", + }); + }, [tool, anyMenuOpen]); + // M6: จำนวน span ของ textLayer ต่อหน้า (0 = หน้าสแกนไม่มีข้อความ) — ใช้แสดง hint ตอน // tool highlight ว่าไฟล์นี้คลุมข้อความไม่ได้ / เขียนค่าเดิมคืน object เดิมให้ callback // identity คงที่ (useCallback []) กัน effect render ของทุกหน้ารันซ้ำเพราะ prop เปลี่ยน @@ -2948,13 +2996,16 @@ export default function PDFViewer({
- {/* F15/D2: หน้าสรุปหมายเหตุทั้งไฟล์ */} + {/* F15/D2: หน้าสรุปหมายเหตุทั้งไฟล์ — label แสดงตั้งแต่ sm ขึ้นไปเท่านั้น + (จอแคบกว่านั้นแถว title ใส่ไม่พอ ให้เหลือ icon เดี่ยวเหมือนชื่อไฟล์ที่ hidden sm:block) */} + หน้าสรุป
@@ -2977,7 +3028,7 @@ export default function PDFViewer({ UX review 2026-09-09: บน desktop ปุ่ม wrap ภายในกรอบได้ / M5 2026-09-14: จอแคบเป็นแถวเดียว ไหลตามแถบทั้งแถวที่เลื่อนแนวนอน */}
- {TOOLS.map(({ key, label, Icon }, i) => { + {TOOLS.map(({ key, label, text, Icon }, i) => { const corner = i === 0 ? "rounded-l-lg" : i === TOOLS.length - 1 ? "rounded-r-lg" : ""; const activeCls = `${ tool === key ? "bg-primary text-white" : "text-slate-500 hover:bg-slate-100" @@ -2996,7 +3047,7 @@ export default function PDFViewer({ aria-pressed={tool === "highlight"} aria-haspopup="menu" aria-expanded={highlightMenuOpen} - className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`} + className={`flex items-center gap-1 whitespace-nowrap px-2 py-2 transition ${activeCls}`} onClick={(e) => { openToolMenu(e.currentTarget); // เรียก switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้าที่ค้างเปิด @@ -3008,6 +3059,7 @@ export default function PDFViewer({ }} > + {text} {highlightMenuOpen && @@ -3079,7 +3131,7 @@ export default function PDFViewer({ aria-pressed={tool === "draw"} aria-haspopup="menu" aria-expanded={drawMenuOpen} - className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`} + className={`flex items-center gap-1 whitespace-nowrap px-2 py-2 transition ${activeCls}`} onClick={(e) => { openToolMenu(e.currentTarget); // switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน) @@ -3089,6 +3141,7 @@ export default function PDFViewer({ }} > + {text} {drawMenuOpen && @@ -3181,7 +3234,7 @@ export default function PDFViewer({ aria-pressed={tool === "shape"} aria-haspopup="menu" aria-expanded={shapeMenuOpen} - className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`} + className={`flex items-center gap-1 whitespace-nowrap px-2 py-2 transition ${activeCls}`} onClick={(e) => { openToolMenu(e.currentTarget); // switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน) @@ -3191,6 +3244,7 @@ export default function PDFViewer({ }} > + {text} {shapeMenuOpen && @@ -3258,7 +3312,7 @@ export default function PDFViewer({ aria-pressed={tool === "stamp"} aria-haspopup="menu" aria-expanded={stampMenuOpen} - className={`flex items-center gap-0.5 px-2 py-2 transition ${corner} ${activeCls}`} + className={`flex items-center gap-1 whitespace-nowrap px-2 py-2 transition ${corner} ${activeCls}`} onClick={(e) => { openToolMenu(e.currentTarget); // switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน) @@ -3268,6 +3322,7 @@ export default function PDFViewer({ }} > + {text} {stampMenuOpen && @@ -3338,7 +3393,7 @@ export default function PDFViewer({ aria-pressed={tool === "textbox"} aria-haspopup="menu" aria-expanded={textboxMenuOpen} - className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`} + className={`flex items-center gap-1 whitespace-nowrap px-2 py-2 transition ${activeCls}`} onClick={(e) => { openToolMenu(e.currentTarget); // switchTool เสมอแม้เป็น tool เดิม — เคลียร์ popup บนหน้า (จำ intent ก่อน) @@ -3348,6 +3403,7 @@ export default function PDFViewer({ }} > + {text} {textboxMenuOpen && @@ -3452,24 +3508,19 @@ export default function PDFViewer({ title={label} aria-label={label} aria-pressed={tool === key} - className={`px-2 py-2 transition ${corner} ${activeCls}`} + className={`flex items-center gap-1 whitespace-nowrap px-2 py-2 transition ${corner} ${activeCls}`} onClick={() => switchTool(key)} > + {text} ); })}
- {/* H3 (UX audit 2026-09-16): จอสัมผัสไม่มี hover อ่าน title ไม่ได้ — แสดงชื่อ tool - ที่กำลังใช้เป็นข้อความถัดกลุ่มปุ่ม (จอ ≥ lg มี tooltip จาก title อยู่แล้ว) - คำเตือน redaction ย้ายไปเป็นแถบเตือนใต้ header แสดงทุกขนาดจอแทน (H2) */} - - {TOOLS.find((t) => t.key === tool)?.label} - + {/* (เดิมมี span แสดงชื่อ tool ที่ active เฉพาะจอสัมผัส — H3 UX audit 2026-09-16 + ถอดออก 2026-09-21 เพราะปุ่มทุก tool แสดงชื่อไทยหลังไอคอนแล้ว ส่วนคำเตือน + redaction อยู่ที่แถบเตือนใต้ header แสดงทุกขนาดจอตามเดิม (H2)) */} {/* F2: Undo/Redo — ติดกลุ่มปุ่ม tool ในแถวเดียวกัน, disabled ตาม stack M5: จอ < lg สั่ง order-first ให้อยู่หัวแถวเลื่อน — มองเห็นเสมอโดยไม่ต้อง @@ -3510,7 +3561,7 @@ export default function PDFViewer({ aria-label={`เพิ่ม bookmark หน้า ${currentPage + 1} (B)`} disabled={!user} 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}`} + className={`flex items-center gap-1 whitespace-nowrap rounded-lg border border-slate-300 px-2 py-2 text-slate-500 hover:bg-slate-100 disabled:opacity-40 ${FOCUS_RING_CLS}`} > {/* M6: ✓ เขียว 1.5 วิหลังบันทึกสำเร็จ — sidebar ปิดอยู่ก็รู้ว่าเซฟแล้ว */} {bookmarkSaved ? ( @@ -3518,6 +3569,7 @@ export default function PDFViewer({ ) : ( )} + Bookmark {bookmarkComposer && bookmarkPos && @@ -3641,10 +3693,11 @@ export default function PDFViewer({ title="Export PDF พร้อม annotation" aria-label="Export PDF พร้อม annotation" disabled={exporting} - className={`max-lg:shrink-0 rounded-lg border border-slate-300 px-2 py-2 text-slate-500 hover:bg-slate-100 disabled:opacity-50 ${FOCUS_RING_CLS}`} + className={`flex items-center gap-1 whitespace-nowrap max-lg:shrink-0 rounded-lg border border-slate-300 px-2 py-2 text-slate-500 hover:bg-slate-100 disabled:opacity-50 ${FOCUS_RING_CLS}`} onClick={() => void handleExportPdf()} > + Export PDF {/* M5: เงาขวาบอกว่าเลื่อนต่อได้ — หายเมื่อถึงท้ายแถบ (จอ ≥ lg ไม่มีการ scroll) */}