refactor(toolbar): extract toolbar tool logic to module

ย้าย logic ของแถบ tool ออกจาก PDFViewer.tsx ไป pure module ใหม่
components/pdf/toolbar-tools.ts (behavior-preserving) ตามแผน TDD
docs/PLAN-TOOLBAR.md ขั้น 1+2 รวม findings #1-#6:

- ขั้น 1: ย้าย type Tool/TOOLS/visibleToolsFor/canSwitchTo/
  toolButtonFace/menuIntentAfterClick ออกจาก PDFViewer.tsx + rewire +
  ตัด icon imports ว่าง 9 ตัว + spec test
  tests/components/toolbar-tools.test.ts
- ขั้น 2 (findings #2/#5): TOOL_MENU_KEYS + type MenuTool +
  TOOL_MENU_TABLE (Record บังคับครบทุก key — เพิ่มเมนูลืมแถว =
  compile fail) + closeToolMenus()/closePagePopups() ใช้ร่วมครบทุก
  กลุ่มจุดเดิม รวม 2 จุดที่เดิมไม่เคลียร์เลย (scannedOnly + สลับไฟล์
  ภาพ) — popup bookmark/composer/popover ของ pin/แผง selection/editor
  ข้อความไม่ค้างเปิดซ้อนกันครบทุกทางเข้า
- findings #1: คีย์ลัดจับคู่ด้วย e.code แทน e.key (TOOL_SHORTCUTS
  KeyH/KeyD/KeyC + ขยายครอบ Ctrl+Z undo/redo) — layout ไทยให้ e.key
  เป็นอักษรไทยจนคีย์ลัด H/D/C/B และ undo ใช้ไม่ได้
- findings #3/#4/#6: ปรับให้ปุ่มใช้ตัวแปร active เดียวกันทั้งชุด
  (aria-pressed)

ผ่าน tsc + vitest 207/207 + e2e Playwright ตามแผน (เมนูค้าง/สลับไฟล์
ภาพ/popup bookmark ลำดับปิด-เปิด/คีย์ลัด synthetic layout ไทย)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-09-29 14:47:12 +07:00
parent 8571ef9aaf
commit 0681e056fd
5 changed files with 561 additions and 148 deletions

155
docs/PLAN-TOOLBAR.md Normal file
View file

@ -0,0 +1,155 @@
# PLAN-TOOLBAR — Test + แก้ไข logic แถบ tool ของหน้า annotate
> สร้าง 2026-09-29 จาก TDD session — ขั้นตอนไหนจะทำเมื่อไรให้รอคำสั่ง
> (อ้างอิงบรรทัดจาก HEAD 8571ef9)
>
> **ความคืบหน้า:** ขั้น 1 + ขั้น 2 (รวม findings #2/#5) + findings #1/#3/#4/#6
> เสร็จแล้ว 2026-09-29 (ยังไม่ commit) — เหลือเฉพาะขั้น 4 (รออาการ bug เฉพาะ)
> และรายการ "จดไว้" #7–#10, #13–#14
## สถานะปัจจุบัน
- **ขั้น 1 ทำเสร็จ 2026-09-29:** spec test ฉบับแรกถูก revert ไปพร้อมโค้ดรอบก่อน
(git มีแต่แผนนี้) จึงเขียนใหม่ตามเกณฑ์เดิม (literal จาก rules + 8571ef9
ไม่คัดลอกสูตร — กัน tautological) → ยืนยันแดงที่ import ก่อน implement →
สร้าง `components/pdf/toolbar-tools.ts` + rewire PDFViewer + ตัด icon imports
ว่าง 9 ตัว — vitest **204/204** + `tsc --noEmit` สะอาด + code-reviewer ผ่าน
(ไม่มี findings ต้องแก้/ควรแก้ — จุดจดไว้อยู่ท้ายตารางขั้น 3 #11–#12)
- **ขั้น 2 + findings #2/#5 ทำเสร็จ 2026-09-29:** ทางเลือก (ก) — `TOOL_MENU_KEYS`
+ `type MenuTool` ใน toolbar-tools.ts, `TOOL_MENU_TABLE` (Record บังคับครบทุก
key = เพิ่มเมนูลืมแถวก็ compile fail) + `closeToolMenus()`/`closePagePopups()`
ใน PDFViewer ใช้ร่วมครบ 6 กลุ่มจุดเดิม (switchTool/outside-click/เลื่อนแถบ·
resize/auto-select/openBookmarkComposer/reset tool ตรง 2 จุดที่เดิมไม่เคลียร์
เลย — scannedOnly + สลับไฟล์ภาพ) — finding #2 แก้: `openBookmarkComposer`
เรียก `closePagePopups()` ก่อนเปิด popup bookmark — test เพิ่ม TOOL_MENU_KEYS
cross-check + truth table เคสที่ 4 (**205/205**) + e2e Playwright 8/8
(กดแก้ไข comment → กด B = popover/editor ปิดก่อน popup bookmark โผล่ + focus
ย้ายเข้าช่องชื่อ; เมนู highlight ค้าง → สลับไฟล์ภาพ = ปุ่มหาย เมนูไม่ค้าง) +
sync rules/pdf-viewer.md 2 จุด
- **findings #1 แก้เสร็จ 2026-09-29:** คีย์ลัดจับคู่ด้วย `e.code` แทน `e.key` —
`TOOL_SHORTCUTS` (KeyH/KeyD/KeyC → tool) ใน toolbar-tools.ts, handler ใช้
`e.code` ครบ H/D/C/B **+ Ctrl+Z undo/redo** (ขยายครอบตามเหตุผลเดียวกัน —
layout ไทยให้ e.key "ฎ" ทำให้ undo ตายเหมือนกัน), delete/backspace ใช้
canonical `e.key` ต่อ (เหมือนทุก layout) — unit 207/207 (เพิ่ม 2 เคส
TOOL_SHORTCUTS + cross-check คู่ label `(H)/(D)/(C)` กัน drift) + e2e
synthetic Thai keydown (e.key อักษรไทย + e.code ตำแหน่งปุ่ม) **6/6** +
rules sync (bullet คีย์ลัด)
- **แก้ตาม code review ขั้น 2 (รอบเดียวกัน):** ไม่มี bug — แก้ 3 จุด: (a) ⚠️
`handleAnnotationBookmark` (ปุ่ม bookmark ในแผง selection N2 — ทางเปิด popup
bookmark ทางเดียวที่ยังไม่ผ่านชุดเคลียร์) เพิ่ม `closeToolMenus()` +
`closePagePopups()` ต้น function ให้จบ finding #2 ครบทุกทาง (b) ตัด unused
import `TOOLS` ที่ขั้น 1 ตกค้าง (c) ก้อน Esc ของคีย์ลัด (7 setter) แทนด้วย
`closePagePopups()` — reviewer เทียบ 1:1 เป๊ะ behavior-preserving — จุด
enumerate setter ด้วยมือฝั่ง popup หมดสิ้น — tsc + 205/205 + e2e rerun เคส
A (B/Esc path) **5/5**
- ขั้น 3 ขึ้นไป: codebase ยังไม่ถูกแตะ รอคำสั่ง
- reviewer 2 ตัว (code-reviewer + plan-reviewer) ตรวจพื้นที่ toolbar แล้ว —
สรุป findings ในขั้น 3
## ขั้น 1 — ทำ spec แดงให้เขียว: สร้าง `components/pdf/toolbar-tools.ts`
pure module (ไม่มี React/state) export 5 อย่างตาม spec:
| API | เนื้อที่ย้ายจาก PDFViewer.tsx (behavior-preserving 100%) |
|---|---|
| `type Tool` + `ToolEntry` | 156-166 |
| `TOOLS` + comment เต็ม | 379-398 |
| `visibleToolsFor(hide)` | 3161 |
| `canSwitchTo(t, hide)` | guard 3114 |
| `toolButtonFace(active, {label,text}, value?)` | pattern เดียวกัน 3391-3397 / 3487-3489 / 3597-3600 / 3686-3691 |
| `menuIntentAfterClick(cur, key, open)` | `reopen` 3414 / 3504 / 3615 / 3713 / 3941 |
Rewire ใน PDFViewer: import แทน definition เดิม + แทน inline ternary 4 ปุ่ม
dropdown ด้วย `toolButtonFace` + แทน `reopen` 5 จุด + **ตัด icon imports ที่ว่าง
9 ตัว** (Eraser/EyeOff/Hand/Highlighter/MessageSquarePlus/MousePointer2/Shapes/
Type/Stamp — เหลือ Pencil, LucideIcon ที่ยังใช้อื่น)
**Verify:** test 11 เคสเขียว + ทั้งชุด 204/204 + `tsc --noEmit` สะอาด
(เคย implement ผ่านเต็มมาแล้วรอบหนึ่งก่อนคืนตามคำสั่ง — ขั้นตอนนี้ทำซ้ำได้เลย)
## ขั้น 2 — switchTool cascade: รวมชุด "ปิดเมนู/popup" ที่เขียนซ้ำ 6 แห่ง
**ปัญหา:** ชุด `set*MenuOpen(false)` ×5 enumerate ด้วยมือซ้ำกัน 6 จุด —
3118-3126 (switchTool), 1968-1971 (outside-click), 1538-1544 (เลื่อนแถบ/resize),
2442-2446 (openBookmarkComposer), 2146-2151 (auto-select), + setTool ตรง 2 จุด
(1617-1619 scannedOnly, 1691 สลับไฟล์ภาพ) ที่**ไม่เคลียร์อะไรเลย** → เพิ่มเมนู
ใหม่/แก้พฤติกรรมแล้วลืมแห่งเดียว = drift เงียบ (เคยเกิดจริง: effect เลื่อนปุ่ม
active ใช้ title match เสียเงียบตั้งแต่ 2026-09-23 — commit 8571ef9)
**ทางเลือก (เลือกตอน implement):**
- **(ก) เบา — แนะนำ:** helper เดียวใน toolbar-tools.ts เช่น `TOOL_MENU_KEYS`
+ `resetMenus()`/`resetPagePopups()` ใช้ร่วมทั้ง 6 จุด; test ยืนยัน helper ครบ
ทุกเมนูที่มี dropdown (cross-check กับ TOOLS — กันลืมตัวใหม่)
- **(ข) หนัก:** ย้ายก้อน state menu/popup (~10 ตัว) ไป `useReducer` เดียว —
test ครบทั้ง transition แต่ diff ใหญ่ในไฟล์ ~4,000 บรรทัด เสี่ยงเกินผล
**แก้พวงในขั้นเดียวกัน (ต้องแตะจุดเดียวกัน):**
- `openBookmarkComposer` (2438-2458) ปิดเมนูแต่**ไม่เคลียร์ popup บนหน้า**
(`editingCommentId`/`openPinId`/`selectedId`/`composer`/`textboxComposer`/
`editingTextboxId`) → popup bookmark ซ้อน popover/editor ที่ค้างเปิด +
autoFocus แย่ง focus — ผิดหลัก "popup เปิดได้ทีละชุด" (rules ข้อ 30)
- setTool ตรง 2 จุด (1617-1619, 1691) ให้ผ่านชุดเคลียร์เดียวกัน — edge:
เมนู highlight ค้างเปิด → ไฟล์จริงเป็นสแกนล้วน → ปุ่มหายแต่เมนู portal
ยังลอยไร้ปุ่มยึด
- จุด `anyMenuOpen` อ่านจาก closure ของ render (3118) — ปัจจุบันเวิร์กเพราะ
outside-click flush ก่อนเสมอ แต่ไม่ enforced ที่จุดเดียว; ใส่ comment
กำกับ ordering นี้ตอนรวม helper
**Verify:** test helper ครบทุกเมนู + เดินมือ/Playwright: กด "แก้ไข" comment →
กด B = popover ปิดก่อน popup bookmark โผล่; เปิดเมนู highlight → เปิดไฟล์ภาพ
(สลับ dynamic param) = เมนูไม่ค้าง
## ขั้น 3 — findings จาก reviewer แปลงเป็นรายการแก้ (จัดลำดับ)
| # | ประเด็น | ที่ | แนวทาง | ลำดับ |
|---|---|---|---|---|
| 1 | ~~**คีย์ลัด H/D/C/B ใช้ไม่ได้บน layout ภาษาไทย**~~ **แก้เสร็จ 2026-09-29** — `TOOL_SHORTCUTS` (map `e.code`→tool) ใน toolbar-tools.ts + handler ใช้ `e.code` ทั้ง H/D/C/B และ **Ctrl+Z undo/redo ด้วย** (ขยายครอบตามเหตุผลเดียวกัน — e.key บน layout ไทยเป็น "ฎ" ทำให้ undo ตายเหมือนกัน) + test cross-check คู่ label `(H)/(D)/(C)` + e2e synthetic Thai keydown 6/6 | เดิม 2056-2070 | — | **เสร็จ** |
| 2 | popup bookmark ซ้อน editor (รายละเอียดขั้น 2) | 2438-2458 | รวมกับขั้น 2 | สูง (ทำพร้อมขั้น 2) |
| 3 | ~~โควตา coarse hint 3 ครั้ง นับเกิน~~ **แก้เสร็จ 2026-09-29** — นับเฉพาะ `enteringHighlight` (เพิ่งสลับเข้าจาก tool อื่น — กดปุ่ม dropdown เปิด/พับเมนูตอน active อยู่แล้ว และกด H ซ้ำ ไม่กินโควตา) | เดิม 3136-3138 | — | **เสร็จ** |
| 4 | ~~กด H บนไฟล์ภาพ/สแกนล้วน = no-op เงียบสนิท~~ **แก้เสร็จ 2026-09-29 (ทางเบา)** — เก็บ tool เดิมไว้ แต่ผ่านชุดเคลียร์เมนู+popup+selection เหมือนสลับ tool จริง (toast บอกเหตุผลเป็นทางเลือกที่**ยังไม่ทำ** — จิตวิทยา minimal ของผู้ใช้) | เดิม 3114 | — | **เสร็จ** |
| 5 | โครงซ้ำ 6 แห่ง (ขั้น 2) | ดูขั้น 2 | ขั้น 2 | สูง (พร้อมขั้น 2) |
| 6 | ~~`aria-pressed={tool === "textbox"}` ไม่ใช้ตัวแปร `active`~~ **แก้เสร็จ 2026-09-29** — เปลี่ยนเป็น `active` พร้อมชุด #3/#4 | เดิม 3934 | — | **เสร็จ** |
| 7 | `aria-pressed` คู่ `aria-haspopup="menu"` ชนกันตาม WAI-ARIA (toggle vs menu button) ทั้ง 5 ปุ่ม | 3405-3407 ฯลฯ | พิจารณาคง `aria-expanded` เดี่ยว — **เป็น behavior change ต้องขออนุมัติแยก** | ต่ำ/เลือกได้ |
| 8 | `liveMark`/`selectPreview` เคลียร์ผ่าน side effect ของ `removeAllRanges()` → `selectionchange` → effect ไม่ใช่ state ตรง — เวิร์กแต่ coupling ซ่อนอยู่ | 2681-2735, 3134 | ไม่แก้ — บันทึกความเสี่ยงไว้ใน comment ใกล้ switchTool | จดไว้ |
| 9 | ทางทฤษฎี: ความกว้าง `main` เปลี่ยนโดย window คงเดิม ไม่ปิดเมนู (ทางเข้าสลับ sidebar ผ่าน outside-click ก่อนเสมอจึงไม่เจอจริง) | 1546-1548 | ไม่แก้ — จดไว้ | จดไว้ |
| 10 | เคสจิ๋ว: แอนิเมชันเลื่อนปุ่ม active (guard 600ms) กลืน scroll ของผู้ใช้ → เมนูเพี้ยนจากปุ่ม | 1552, 1586 | ต้อง track จบจริง (`scrollend`) — แพงเกินปัญหา | จดไว้ |
| 11 | ช่องว่าง exhaustiveness ของ BTNS arrays: ถ้าขยาย `HighlightStyle`/`ShapeKind` ใน schema โดยไม่เพิ่มแถว `HIGHLIGHT_STYLE_BTNS`/`SHAPE_KIND_BTNS` (array ธรรมดา ไม่มี type บังคับครอบ union) ปุ่ม active จะแสดง `text` + title `label` เฉย ๆ (เดิม: title `"label — text"`) — ทางแก้ยาว: BTNS เป็น `Record<HighlightStyle, …>` หรือ `satisfies` เต็ม union | ToolControls.tsx:280, PDFViewer (SHAPE_KIND_BTNS) | ไม่แก้ตอนนี้ (ขัด behavior-preserving ของขั้น 1) — แก้เมื่อขั้นไหนแตะ BTNS | จดไว้ (code review ขั้น 1) |
| 12 | ~~test `menuIntentAfterClick` ยังไม่ครอบ truth table เคสที่ 4~~ **แก้แล้ว 2026-09-29 ระหว่างขั้น 2** | tests/components/toolbar-tools.test.ts | — | เสร็จ |
| 13 | deps ของ outside-click effect เป็น spread array `[...TOOL_MENU_KEYS.map(...)]` — runtime ถูกต้อง (ความยาว/ลำดับคงที่ ค่า primitive) แต่ react-hooks/exhaustive-deps lint มองไม่เห็นค่าใน spread — โปรเจกต์ไม่มี lint script จึงไร้ผลตอนนี้ | PDFViewer outside-click effect deps | จดไว้กันงงถ้าอนาคตเปิด eslint | จดไว้ (code review ขั้น 2) |
| 14 | `closeToolMenus`/`TOOL_MENU_TABLE` นิยามหลังจุดใช้เมื่อไล่อ่านจากบน (เรียกครั้งแรกที่ effect resize ~1538 นิยามที่ ~1962) — ไม่ใช่ bug (hoisted + effect รัน post-render + setter เสถียร) แค่อ่านยาก | PDFViewer | ย้ายขึ้กลุ่ม state เมนูเมื่อแตะจุดนั้นครั้งถัดไป | จดไว้ (code review ขั้น 2) |
| 15 | Ctrl+Alt+Z / Cmd+Option+Z ยิง undo เพิ่มจาก HEAD ในบาง layout (Windows จำลอง AltGr เป็น Ctrl+Alt — HEAD เช็ค e.key ที่ AltGr+Z แผ่อักขระพิเศษจึงไม่ตรง แต่ e.code ยิงเสมอ) — แทบไม่เกิดกับ layout ไทย/อังกฤษ ไม่ทำลายอะไร | PDFViewer ctrl branch | ถ้าต้องกัน: `!e.altKey` ในเงื่อนไข KeyZ บรรทัดเดียว | จดไว้ (code review #1/#3/#4/#6) |
| 16 | `TOOL_SHORTCUTS[e.code]` type เป็น `Tool` ไม่ใช่ `Tool \| undefined` (ไม่มี `noUncheckedIndexedAccess`) — ปลอดภัยเพราะมี guard runtime แต่ถอด guard ทิ้ง tsc ไม่ช่วยจับ | toolbar-tools.ts | เปลี่ยนเป็น `Partial<Record<string, Tool>>` ถ้าอยากให้ compiler บังคับ | จดไว้ (code review #1/#3/#4/#6) |
| 17 | `e.code` บนคีย์บอร์ดจอ (software keyboard) ของ iPad เคยมีรายงานว่าเป็นค่าว่างใน WebKit บางเวอร์ชัน — e2e ยืนยันด้วย synthetic event บน desktop เท่านั้น (hardware keyboard ปลอดภัยแน่นอน) | PDFViewer keydown | ถ้าได้รายงาน "คีย์ลัดตายบนคีย์บอร์ดจอ iPad" เช็คจุดนี้อันดับแรก + ทดสอบบนเครื่องจริง | จดไว้ (code review #1/#3/#4/#6) |
**ช่องว่าง test เพิ่ม (ตาม plan-reviewer):** ค่า face ของ stamp (`"กำหนดเอง"` เมื่อ
`stampPreset` ไม่ตรง preset), การ reset ค่ากลับ preset ตอนเลือก pen (3541-3546),
ยืนยัน textbox ไม่มีค่าย่อย — เพิ่มใน suite เดียวกันตอนขั้น 1/2; DOM-level
(toggle เมนู / outside-click + tap 300ms / Escape สองชั้น / `data-tool` ครบ 10 ปุ่ม)
ต้อง jsdom/e2e — แยกเป็นรอบหลัง
## ขั้น 4 — อาการเฉพาะจากผู้ใช้
**รอระบุ** — ผู้ใช้เลือกไว้ว่ามีอาการ bug เฉพาะที่เจอเอง แต่ยังไม่ได้บอกรายละเอียด
เมื่อได้อาการแล้ว: เพิ่มเป็นหัวข้อ "เคส F" พร้อม test reproduce ก่อนแก้ตามลูป TDD
## Carve-outs ที่ห้าม "แก้ให้ตรงกติกา" (จาก plan-reviewer — ทำลายได้ถ้ารวมเช่นเดียวกัน)
1. แถวเดี่ยวเลือกจบ 4 เมนู (3451-3455, 3541-3549, 3650-3654, 3869-3873) +
`finishCustom` (3695-3698) **ปิดเมนูไม่ผ่าน `markMenuClosed` โดยเจตนา** —
tap ถัดไปต้องวางรายการได้ทันที (กลืน tap 300ms จะฆ่า flow วางตรายาง)
2. คีย์ B ปิดเมนูไม่ mark — คีย์บอร์ดไม่มี click หลงเหลือ
3. Escape สองชั้น (1973-1979 มี stopPropagation) — ครั้งแรกปิดเมนูอย่างเดียว
4. ลำดับ guard ของ `handlePageClick` (2916-2988): isCollapsed → 300ms → ปิด
bookmark/pin → deselect → branch ตาม tool — ห้ามสลับ
5. `menuOpenWRef` ตั้ง 2 ทาง (openToolMenu + positionBookmarkPopup) — resize
เฉพาะความกว้างครอบ popup bookmark ด้วย
6. `selfScrollUntilRef` ต้องไม่ trigger การปิดเมนู (1529, 1586)
7. `stampCustomMode` reset เฉพาะตอนเปิดเมนูใหม่ผ่านปุ่ม (3717)
8. `scannedOnly` เป็น latch ของ `pageSpanCount` — ไม่ลบตอน virtualization unmount
## ลำดับการทำเมื่ออนุมัติ
1. ขั้น 1 (test เขียว) → 2. ขั้น 2 + findings #2/#5 → 3. findings #1 (คีย์ลัด
ไทย — แจ้ง behavior change ก่อน) → #3 → #4 → #6 → 4. ขั้น 4 เมื่อได้อาการ
ทุกขั้นจบด้วย: `tsc --noEmit` สะอาด + vitest ทั้งชุดผ่าน + sync
`.claude/rules/pdf-viewer.md` ถ้าพฤติกรรมเปลี่ยน + code review