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:
parent
f7cd4b8312
commit
357f3e972f
3 changed files with 90 additions and 25 deletions
|
|
@ -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,7 +3781,9 @@ export default function PDFViewer({
|
|||
: "grab"
|
||||
: tool === "erase"
|
||||
? ERASER_CURSOR
|
||||
: undefined,
|
||||
: tool === "comment" || tool === "textbox" || tool === "stamp"
|
||||
? "crosshair" // tool วางของ — บอกว่าคลิกวางได้ (desktop อย่างเดียว จอสัมผัสไม่มี cursor)
|
||||
: undefined,
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (tool !== "pan" || e.button !== 0) return;
|
||||
|
|
@ -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">
|
||||
ยางลบลบได้เฉพาะเส้น รูปทรง และตรายางของตัวเองเท่านั้น — ไฮไลต์และรายการอื่น ๆ
|
||||
(รวมของผู้อื่นทุกชนิด) ให้ใช้เครื่องมือ "เลือก" คลิกรายการแล้วกดปุ่มลบ
|
||||
</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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}`}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue