toolbar: แก้ quick wins ตาม usability audit 2026-09-22 — ปิดเมนูหลังเลือกแถวเดี่ยว + แบนเนอร์ hint ยางลบ/tool วางของ + ปุ่ม % ซูมรีเซ็ต fit-width + ชื่อ tool "เลือกข้อความ" เป็น "เลือก"

- เมนู tool 4 ตัว (รูปแบบมาร์ก/ชนิดปากกา/ชนิดรูปทรง/preset ตรายาง): เลือกแถวเดี่ยวแล้ว
  ปิดเมนูทันทีด้วย setState ตรง ๆ โดยไม่ผ่าน markMenuClosed (guard กลืน tap 300ms
  จะทำให้แตะหน้าครั้งถัดไปเงียบ — เดิมวางตรายาง 1 ชิ้นต้องแตะ 4 ครั้ง เหลือ 3 ครั้ง
  ที่ตอบสนองทุกครั้ง) ส่วนแถวปรับค่า สี/ความหนา/ความทึบ/ขนาด ยังคงเมนูเปิด
  ให้ปรับต่อหลายค่าติดกันตามเจตนาเดิม
- แบนเนอร์ใต้ header (reuse pattern M3/M6/H2): tool คอมเมนต์/กล่องข้อความ/ตรายาง
  แสดง "โหมด…: คลิก (แตะ) ตำแหน่งบนหน้าเพื่อวาง" ตลอดตอน active (ทำหน้าที่ mode
  indicator แก้อาการเลือก tool แล้วจอนิ่ง — เดิม hint อยู่แค่ empty state ของ sidebar
  ที่หายทันทีที่มีรายการ) + ยางลบบอกขอบเขตว่าลบได้เฉพาะเส้น/รูปทรง/ตรายางของตัวเอง
  (ไม่แตะตรรกะ eraseAt ตามสเปก F6 เดิม) + label ปุ่มยางลบเปลี่ยนตาม
- cursor เป็น crosshair ตอนใช้ tool วางของ 3 ตัวบน desktop (จอสัมผัสไม่มี cursor)
- ตัวเลข % ซูมกลายเป็นปุ่มรีเซ็ตกลับ fit-width — extract สูตรจาก effect ตอนโหลด
  เป็น fitWidthScale() ใช้ร่วมกัน คิดจากความกว้าง main ปัจจุบัน (sidebar/จออาจเปลี่ยน)
- ชื่อ tool select "เลือกข้อความ" → "เลือก" (ไม่ชน "มาร์กข้อความ" — title/aria-label/
  ตัวเลื่อนปุ่ม active อ้างจาก TOOLS อัตโนมัติ) + sync comment ที่อ้างชื่อเก่า 4 จุด
- contrast: text-slate-400 → slate-500 ที่ข้อความข้อมูล 4 จุด (เวลา reply, เวลา comment
  popup, "วางที่" ของ popup bookmark, "กำลังโหลดเอกสาร…")
- ปุ่มสี ColorSwatches ใช้ชื่อสีไทยใน aria-label/title ผ่าน colorName() ที่ export
  เพิ่มจาก utils/annotation-display.ts (แทน hex "#ef4444" ที่ screen reader อ่านไม่รู้เรื่อง)

ยังคงเดิมตามคำสั่งผู้ใช้: ปุ่ม Bookmark/Export PDF คงป้ายอังกฤษ, แท็บ sidebar,
InkLayer/AnnotationSelectionLayer

verify: tsc ผ่าน + vitest 139/139 + Playwright 24 checks ผ่าน (flow ตรายาง 4 แตะ→3 แตะ,
แถวปรับค่าเมนูยังเปิด, แบนเนอร์/cursor ครบทุก tool, ซูม 100%→150%→รีเซ็ตกลับ 100%,
aria-label "เลือกสี แดง")

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-09-22 11:48:53 +07:00
parent f7cd4b8312
commit 357f3e972f
3 changed files with 90 additions and 25 deletions

View file

@ -364,18 +364,28 @@ function selectionTextWithin(kept: DOMRect[], range: Range): string {
* ผู้ใช้ศาลไม่คุ้นไอคอนล้วน โดยเฉพาะจอสัมผัสที่ hover อ่าน title ไม่ได้ — แทน span
* ชื่อ tool-active ของ H3 ที่ถอดออก) */
const TOOLS: { key: Tool; label: string; text: string; Icon: LucideIcon }[] = [
{ key: "select", label: "เลือกข้อความ", text: "เลือกข้อความ", Icon: MousePointer2 },
{ 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: "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 },
];
/** แบนเนอร์แนะนำวิธีใช้ tool ที่วางของด้วยคลิกบนหน้า (UX audit 2026-09-22): เดิมเลือก tool
* แล้วจอนิ่ง ไม่มีอะไรบอกขั้นต่อไป (hint เดิมอยู่แค่ empty state ของ sidebar ที่หายทันที
* ที่มีรายการ) — แสดงตลอดตอน tool active ให้ทำหน้าที่ mode indicator ไปพร้อมกัน
* (เงื่อนไข tool เดี่ยว ๆ จึงไม่มีวันซ้อนกับแบนเนอร์ M3/M6/H2 ข้างบน) */
const PLACE_TOOL_HINTS: Partial<Record<Tool, string>> = {
comment: "โหมดวางคอมเมนต์: คลิก (แตะ) ตำแหน่งบนหน้าเพื่อวางกล่องคอมเมนต์",
textbox: "โหมดกล่องข้อความ: คลิก (แตะ) ตำแหน่งบนหน้าเพื่อวางกล่อง แล้วพิมพ์ข้อความ",
stamp: "โหมดตรายาง: คลิก (แตะ) ตำแหน่งบนหน้าเพื่อประทับตรายาง",
};
/** UX review 2026-09-14 M4: focus ring ตอน keyboard focus — ทั้งโปรเจกต์ไม่เคยมี focus-visible
* ปุ่ม toolbar จึงกลายเป็นกด Tab แล้วไม่รู้ focus อยู่ปุ่มไหน
* ring เต็มสี + ring-offset คั่นขาว 1px ให้เห็นชัดแม้บนปุ่ม active ที่พื้น primary เอง
@ -383,6 +393,16 @@ const TOOLS: { key: Tool; label: string; text: string; Icon: LucideIcon }[] = [
const FOCUS_RING_CLS =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 focus-visible:z-10";
/** สเกลที่ทำให้หน้ากระดาษพอดีความกว้างของ main (ตัด padding px-4 ซ้าย-ขวา = 32px — สูตร
* เดิมของ effect fit-width ตอนโหลด ย้ายมาเป็น helper ใช้ร่วมกับปุ่ม % ที่ header ด้วย
* (UX audit 2026-09-22: ผู้ใช้ซูมดูรายละเอียดแล้วไม่มีทางกลับมาดูทั้งหน้า) — คิดจากความ
* กว้าง main ปัจจุบันทุกครั้งเพราะ sidebar/จออาจเปลี่ยนไปแล้ว; ยังไม่รู้ขนาดหน้า = null */
function fitWidthScale(mainW: number | undefined, pageW: number | undefined): number | null {
const avail = (mainW ?? 0) - 32;
if (!avail || !pageW) return null;
return pageW > avail ? clamp(Math.floor((avail / pageW) * 100) / 100, 0.5, 1) : 1;
}
/** ชนิดที่สร้างเสร็จแล้วเลือกรายการใหม่ให้อัตโนมัติ (กรอบ selection + sidebar ไฮไลต์)
* ทุกชนิดยกเว้น COMMENT — เปิด composer พิมพ์ก่อนบันทึก และคลิก pin = เลือกอยู่แล้ว */
const AUTO_SELECT_TYPES = new Set<AnnotationType>([
@ -506,7 +526,7 @@ function ReplyBox({
<div className="min-w-0 flex-1">
<p className="text-[11px] font-medium text-slate-600">
{r.userName}
<span className="ml-1 font-normal text-slate-400" suppressHydrationWarning>
<span className="ml-1 font-normal text-slate-500" suppressHydrationWarning>
{formatThaiDate(r.createdAt, true)}
</span>
</p>
@ -628,7 +648,7 @@ function PdfPage({
/** live preview มาร์กระหว่างลากคลุมด้วย tool highlight (null = ไม่มี) */
liveMark: { pageIndex: number; rects: Rect[] } | null;
liveMarkStyle: { color: string; style: HighlightStyle; opacity?: number };
/** selection สีฟ้าสะอาด ๆ ของ tool เลือกข้อความ — กรองแนวเส้นทางลากเหมือน liveMark
/** selection สีฟ้าสะอาด ๆ ของ tool เลือก — กรองแนวเส้นทางลากเหมือน liveMark
* แต่ไม่สร้างไฮไลต์ (ซ่อนสี selection ของเบราว์เซอร์ วาดเองแทน) */
selectPreview: { pageIndex: number; rects: Rect[] } | null;
/** ความกว้างหน้าแรกที่ scale 1 — แปลง px↔% ของ fontSize ในแผง selection */
@ -892,7 +912,7 @@ function PdfPage({
liveMarkStyle.opacity,
),
)}
{/* selection สีฟ้าของ tool เลือกข้อความ — กรองแนวเส้นทางลากแล้ว วาดเองแทน
{/* selection สีฟ้าของ tool เลือก — กรองแนวเส้นทางลากแล้ว วาดเองแทน
สี selection ของเบราว์เซอร์ (ไม่สร้างไฮไลต์) */}
{selectPreview &&
selectPreview.pageIndex === pageNumber - 1 &&
@ -1084,7 +1104,7 @@ function PdfPage({
{a.data.message}
</p>
)}
<p className="mt-1 text-[11px] text-slate-400" suppressHydrationWarning>
<p className="mt-1 text-[11px] text-slate-500" suppressHydrationWarning>
{formatThaiDate(a.createdAt, true)}
</p>
{/* F10/A5: toggle จัดการแล้ว — ใครก็ได้ (D6) */}
@ -1211,7 +1231,7 @@ export default function PDFViewer({
const [currentPage, setCurrentPage] = useState(0);
// tool highlight — live preview มาร์กตามรูปแบบ/สีที่ตั้งไว้ ระหว่างลากคลุมข้อความ
const [liveMark, setLiveMark] = useState<{ pageIndex: number; rects: Rect[] } | null>(null);
// tool เลือกข้อความ — selection สีฟ้าสะอาด ๆ (กรองแนวเส้นทางลากแล้ว ไม่สร้างไฮไลต์)
// tool เลือก — selection สีฟ้าสะอาด ๆ (กรองแนวเส้นทางลากแล้ว ไม่สร้างไฮไลต์)
const [selectPreview, setSelectPreview] = useState<{ pageIndex: number; rects: Rect[] } | null>(null);
const [composer, setComposer] = useState<Composer | null>(null);
const [draft, setDraft] = useState("");
@ -1689,11 +1709,8 @@ export default function PDFViewer({
useEffect(() => {
if (pageSizes.length === 0 || fitWidthDoneRef.current) return;
fitWidthDoneRef.current = true;
const avail = (mainRef.current?.clientWidth ?? 0) - 32;
const pageW = pageSizes[0].width;
if (avail > 0 && pageW > avail) {
setScale(clamp(Math.floor((avail / pageW) * 100) / 100, 0.5, 1));
}
const s = fitWidthScale(mainRef.current?.clientWidth, pageSizes[0].width);
if (s !== null && s !== 1) setScale(s);
}, [pageSizes]);
useEffect(() => {
@ -2641,7 +2658,7 @@ export default function PDFViewer({
pageEl: HTMLElement,
) {
// ปล่อยเมาส์จากการลากคลุมข้อความ (comment/textbox/stamp ก็คลุมได้) ไม่ถือเป็นคลิก —
// selection ยังค้างตอน click ยิง ให้ผ่านไปเฉย ๆ เหมือน tool เลือกข้อความ ไม่เปิด
// selection ยังค้างตอน click ยิง ให้ผ่านไปเฉย ๆ เหมือน tool เลือก ไม่เปิด
// composer/วางตรายาง (คลิกล้วน ๆ selection ถูกยุบตั้งแต่ mousedown จึงไม่โดน guard นี้)
const selection = window.getSelection();
if (selection && !selection.isCollapsed) return;
@ -2998,9 +3015,20 @@ export default function PDFViewer({
>
<ZoomOut className="h-4 w-4" />
</button>
<span className="w-12 text-center text-xs tabular-nums">
{/* UX audit 2026-09-22: ตัวเลข % เป็นปุ่มกลับไป fit-width — PDF viewer ที่
ผู้ใช้เคยใช้ทุกตัวมี affordance รีเซ็ตซูม คิดจากความกว้าง main ปัจจุบัน */}
<button
type="button"
aria-label="ซูมพอดีความกว้างหน้า"
title="ซูมพอดีความกว้างหน้า"
className={`w-12 py-2 text-center text-xs tabular-nums text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => {
const s = fitWidthScale(mainRef.current?.clientWidth, pageSizes[0]?.width);
if (s !== null) setScale(s);
}}
>
{Math.round(scale * 100)}%
</span>
</button>
<button
type="button"
aria-label="ซูมเข้า"
@ -3107,7 +3135,12 @@ export default function PDFViewer({
? "bg-slate-100 text-slate-900"
: "text-slate-600 hover:bg-slate-100"
}`}
onClick={() => setHighlightStyle(sKey)}
onClick={() => {
setHighlightStyle(sKey);
// แถวเดี่ยวเลือกจบ = ปิดเมนูทันที แต่ห้ามผ่าน markMenuClosed
// (guard กลืน tap 300ms — แตะหน้าครั้งถัดไปต้องใช้ได้ทันที)
setHighlightMenuOpen(false);
}}
>
<SIcon className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1">{sLabel}</span>
@ -3191,10 +3224,13 @@ export default function PDFViewer({
}`}
onClick={() => {
setDrawPen(p);
// เลือก pen = รีเซ็ตค่ากลับเป็น preset ของ pen นั้น (เมนูเปิดค้างให้ปรับต่อ)
// เลือก pen = รีเซ็ตค่ากลับเป็น preset ของ pen นั้น
setDrawColor(DRAW_PENS[p].color);
setDrawWidthPct(DRAW_PENS[p].strokeWidthPct);
setDrawOpacityPct(DRAW_PENS[p].opacity * 100);
// แถวเดี่ยวเลือกจบ = ปิดเมนูทันที แต่ห้ามผ่าน markMenuClosed
// (guard กลืน tap 300ms — แถวปรับค่าสี/ความหนา/ความทึบยังเปิดค้างให้ปรับต่อ)
setDrawMenuOpen(false);
}}
>
<PenIcon className="h-3.5 w-3.5 shrink-0" />
@ -3290,7 +3326,12 @@ export default function PDFViewer({
? "bg-slate-100 text-slate-900"
: "text-slate-600 hover:bg-slate-100"
}`}
onClick={() => setShapeKind(kind)}
onClick={() => {
setShapeKind(kind);
// แถวเดี่ยวเลือกจบ = ปิดเมนูทันที แต่ห้ามผ่าน markMenuClosed
// (guard กลืน tap 300ms — แถวปรับค่าสี/ความหนายังเปิดค้างให้ปรับต่อ)
setShapeMenuOpen(false);
}}
>
<KindIcon className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1">{kindLabel}</span>
@ -3370,7 +3411,12 @@ export default function PDFViewer({
? "bg-slate-100 text-slate-900"
: "text-slate-600 hover:bg-slate-100"
}`}
onClick={() => setStampPreset(preset)}
onClick={() => {
setStampPreset(preset);
// แถวเดี่ยวเลือกจบ = ปิดเมนูทันที แต่ห้ามผ่าน markMenuClosed
// (guard กลืน tap 300ms — แถวปรับค่าสี/ขนาดยังเปิดค้างให้ปรับต่อ)
setStampMenuOpen(false);
}}
>
<span className="flex-1">{preset}</span>
{stampPreset === preset && <Check className="h-3.5 w-3.5 shrink-0" />}
@ -3652,7 +3698,7 @@ export default function PDFViewer({
placeholder="โน้ต (ไม่บังคับ)"
className="mt-1.5 w-full resize-none rounded-md border border-slate-300 px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-slate-400"
/>
<p className="mt-1.5 text-[11px] text-slate-400">วางที่</p>
<p className="mt-1.5 text-[11px] text-slate-500">วางที่</p>
<Select
value={bookmarkComposer.parentId ?? ""}
onChange={(e) =>
@ -3735,6 +3781,8 @@ export default function PDFViewer({
: "grab"
: tool === "erase"
? ERASER_CURSOR
: tool === "comment" || tool === "textbox" || tool === "stamp"
? "crosshair" // tool วางของ — บอกว่าคลิกวางได้ (desktop อย่างเดียว จอสัมผัสไม่มี cursor)
: undefined,
}}
onPointerDown={(e) => {
@ -3785,6 +3833,20 @@ export default function PDFViewer({
หรือสลับเป็นเครื่องมืออื่นเพื่อปัดเลื่อนตามปกติ
</p>
)}
{PLACE_TOOL_HINTS[tool] && (
<p className="mb-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs leading-relaxed text-slate-600">
{PLACE_TOOL_HINTS[tool]}
</p>
)}
{/* UX audit 2026-09-22: ยางลบลบได้เฉพาะเส้น/รูปทรง/ตรายางของตัวเอง (สเปก F6 เดิม
ไม่ขยายตรรกะ) — เดิมลากผ่านไฮไลต์/รายการของผู้อื่นแล้วเงียบเปล่า ผู้ใช้สรุป
ว่าแอปไม่ทำงาน จึงบอกขอบเขตตรง ๆ ตอนใช้ tool */}
{tool === "erase" && (
<p className="mb-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs leading-relaxed text-slate-600">
ยางลบลบได้เฉพาะเส้น รูปทรง และตรายางของตัวเองเท่านั้น — ไฮไลต์และรายการอื่น ๆ
(รวมของผู้อื่นทุกชนิด) ให้ใช้เครื่องมือ &quot;เลือก&quot; คลิกรายการแล้วกดปุ่มลบ
</p>
)}
{pageSizes.length === 0 ? (
loadError ? (
<div className="flex h-[70vh] items-center justify-center px-6">
@ -3793,7 +3855,7 @@ export default function PDFViewer({
</p>
</div>
) : (
<div className="flex h-[70vh] items-center justify-center text-sm text-slate-400">
<div className="flex h-[70vh] items-center justify-center text-sm text-slate-500">
กำลังโหลดเอกสาร…
</div>
)

View file

@ -5,6 +5,8 @@ import { Check, ChevronDown, ChevronUp, Highlighter, Strikethrough, Underline }
import type { HighlightStyle } from "@/db/schema";
import { colorName } from "@/utils/annotation-display";
// ตัวควบคุมที่ใช้ร่วมระหว่าง dropdown บนปุ่ม tool (PDFViewer) กับแผงเครื่องมือ
// บนกรอบ selection (AnnotationSelectionLayer) — หน้าตา/พฤติกรรมเดียวกันทั้งสองที่
@ -53,8 +55,8 @@ export function ColorSwatches({ value, onChange }: { value: string; onChange: (c
<button
key={c}
type="button"
aria-label={`เลือกสี ${c}`}
title={c}
aria-label={`เลือกสี ${colorName(c)}`}
title={colorName(c)}
className={`aspect-square max-w-8 w-full justify-self-center rounded-full border border-slate-300 transition ${FOCUS_RING_CLS} ${
value === c ? "ring-2 ring-primary ring-offset-1" : "hover:scale-110"
}`}

View file

@ -172,7 +172,8 @@ const COLOR_NAMES: Record<string, string> = {
"#b91c1c": "แดงเข้ม",
};
function colorName(c: string): string {
/** export ให้ ToolControls ใช้ทำ aria-label/title ของปุ่มสีเป็นชื่อไทยด้วย (UX audit 2026-09-22) */
export function colorName(c: string): string {
return COLOR_NAMES[c.toLowerCase()] ?? c;
}