จอสัมผัส (iOS/Android): แก้ครบตาม UX audit C1+M1–M6 + code review + งาน export PDF ที่ค้างใน working tree:
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 42s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s

(1) C1 — เมนู dropdown 5 ตัว + popup bookmark เดิมเป็น absolute ใน scroll container ของแถบ tool ที่สลับ overflow ตอนเปิด ทำให้บนมือถือ scroll position รีเซ็ต (ปุ่มเด้งกลับซ้าย) เมนูเปิดนอก viewport และทั้ง app เอียงเลื่อนแนวนอนได้ — ย้ายทั้งหมดไป createPortal(document.body) เป็น position:fixed ตาม rect จริงของปุ่มตอนคลิก (fixedMenuPos + clamp ให้อยู่ในจอ; bookmarkPos จาก ref ปุ่ม 🔖 ใช้ร่วมทุกทางเข้า รวมคีย์ลัด B) + ถอดการสลับ overflow (คง overflow-x-auto เสมอ) + ปิดเมนูเมื่อเลื่อนแถบต่อ หรือ "ความกว้าง" จอเปลี่ยน (code review Major1: resize ที่เปลี่ยนแค่ความสูง เช่น URL bar ยุบ/คีย์บอร์ดของมือถือ ไม่ปิด — เมนู fixed ยึด sticky header อยู่กับที่อยู่แล้ว กันเมนู/popup ที่เปิดค้างปรับค่าโดนปิดกลางคัน) + outside-click ข้ามโหนดใน [data-tool-menu] (ref ของ wrapper มองไม่เห็นโหนดใน portal ไม่ข้ามแล้วคลิกในเมนู=ปิดเมนูเอง) + rule ปุ่ม 36px ใน globals.css เปลี่ยนไป target [data-tool-menu] ตรง ๆ (เดิมเจาะผ่าน .z-50 ที่ไม่มีแล้ว)

(2) M4 — tap/คลิกที่ใช้ "ปิดเมนู" (mousedown ปิดก่อน click ยิงถึง page) กลายเป็นวาง comment/textbox/stamp โดยไม่ตั้งใจ — menuClosedAtRef (markMenuClosed ทุก path ปิด: outside-click/Escape/switchTool เฉพาะเมื่อมีเมนูเปิดจริง/เลื่อนแถบ/resize) แล้ว handlePageClick เฉย 300ms แรก คลิกที่สองค่อยวาง (มาตรฐาน dropdown ใช้ทั้ง desktop+touch — pattern เดียวกับ touchHideTimerRef ของปุ่มไฮไลต์)

(3) M1+M2 ของ TEXTBOX — ทางเข้า editor เคยผูก double-click อย่างเดียว (บน iOS double-tap = zoom หน้า แก้ข้อความไม่ได้เลยตลอดชีวิตบนมือถือ) — เพิ่มปุ่ม "แก้ไขข้อความ" ในแผง selection (onStartEdit → startTextboxEdit) ใช้ได้ทั้ง touch/desktop และ default fontSize 1.2% (≈7px เล็กกว่า preset ต่ำสุด 10px ของตัวเอง) → 2.5% (≈15px อ่านออกทันที) กระทบเฉพาะกล่องใหม่

(4) M3+clamp — hit area บน pointer:coarse โดยของที่มองเห็นคงขนาดเดิม: มุมกรอบใช้ ::after (10→hit 28px), แถบขอบ (8→16px), ปุ่มแผง 20→36px, swatch 16→32px, icon sidebar 16/14→32px (hooks sel-handle/sel-strip/sel-panel/sel-panel-btn/color-swatches + aside[data-sidebar]) และ clamp แนวนอนของแผง selection (left: max(0, min(box.x, size.width-244))) กันรายการชิดขวาทำแผง w-60 ล้นขอบจอแคบจนปุ่มลบหลุดจอ (เจอตอน verify จอ 390px)

(5) M5 — undo/redo max-lg:order-first ไปหัวแถวเลื่อน: เห็นเสมอโดยไม่ต้องเลื่อนแถบ (undo คือทางแก้พลาดหลักของจอสัมผัส) จอ ≥ lg ไม่มี order ลำดับ/หน้าตาเดิมทุกประการ

(6) M6 อื่น ๆ — -webkit-tap-highlight-color: transparent + overscroll-behavior-y: contain (กัน pull-to-refresh ของ Android ล้าง undo history ฝั่ง client), button/label touch-action: manipulation (ตัด double-tap zoom), safe-area inset แบบ defensive (จะมีค่าเมื่อใส่ viewport-fit=cover ในอนาคต), no-callout กัน long-press magnifier เฉพาะ layer TEXTBOX/STAMP (textLayer ยัง select/copy ได้), ปุ่ม composer/popover/pin 24→36px (coarse-36), ปุ่มหน้าสรุป 30→44px (summary-page), ซ่อนชื่อไฟล์บนจอเล็ก, ปุ่ม X ปิด sidebar บน max-lg (เดิมปิดได้ทางเดียวคือจิ้ม backdrop เพราะ overlay z-50 กลบปุ่ม toggle ใน header), tab sidebar เป็นไทย "ที่คั่นหน้า/หมายเหตุ" ให้สอดคล้อง UI ไทยล้วน, chip แจ้ง "หน้าที่เปิดดูเป็นภาพสแกน" เมื่อ tool highlight และทุกหน้าที่ render มี 0 text spans (onTextLayerRendered รายงานต่อหน้า + pageSpanCount)

(7) งาน export PDF ที่อยู่ใน working tree ก่อนหน้า + แก้ตาม code review — COMMENT กลายเป็น sticky note จริง (/Subtype /Text), bookmark → PDF outline (/Dest คำนวณตาม /Rotate เหมือน annotation), โน้ต N1 → sticky note แนบตำแหน่ง, นิยาม yOffset เป็น "ขอบบนของสิ่งที่ดูอยู่" + goToBookmark ใช้ scrollToBelowHeader, และเพิ่ม guard roots.length===0 ใน buildOutline กัน bookmark cycle ไร้ root (DB ถูกแก้ตรง ๆ) ทำ kids[0].ref พังเป็น 500 ทั้ง export

(8) sync .claude/rules/pdf-viewer.md — portal เมนู+ห้ามสลับ overflow ของแถบ, guard 300ms, hit-area hooks ระดับ coarse, TEXTBOX default 2.5% + ปุ่มแก้ไขข้อความ

verify: tsc ผ่าน · vitest 100/100 · Playwright touch emulation จริง (iPhone 390×844 / iPad 820×1180 / desktop 1280): เมนูอยู่ใน viewport ทุกสถานะหลังเลื่อนแถบ + scrollLeft คงที่ + ไม่มี horizontal overflow, แตะในเมนูไม่ปิดเอง, เลื่อนแถบ/กว้างจอเปลี่ยน=ปิด/สูงเปลี่ยน=ค้าง, guard 300ms ทั้ง stamp/textbox/comment, ปุ่มแก้ไขข้อความเปิด editor พร้อมข้อความเดิม, fontSize ใหม่ 14.9px, hit มุม 28px/ปุ่มลบ 36px/swatch 32px, undo อยู่ในจอ + undo/redo ทำงาน, X ปิด sidebar, chip สแกนโชว์/ไฟล์ text ไม่โชว์, desktop เหมือนเดิม, แผง clamp (x=130..370 ปุ่มลบจบ 363) — annotation ทดสอบลบเฉพาะ id ที่สร้างเองทั้งหมด DB กลับสะอาด

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 2026-09-14 18:04:40 +07:00
parent b3b884c736
commit df0392ea45
17 changed files with 1085 additions and 215 deletions

View file

@ -0,0 +1,173 @@
---
name: ui-designer
description: "Use this agent when designing visual interfaces, creating design systems, building component libraries, or refining user-facing aesthetics requiring expert visual design, interaction patterns, and accessibility considerations. Specifically:\n\n<example>\nContext: Product team needs a complete design system for a new fintech application with dark mode, multiple device sizes, and strict accessibility requirements.\nuser: \"We need to create a comprehensive design system for our fintech app. Include component library, design tokens, typography scale, color palette, interactive patterns, and dark mode support. Must meet WCAG 2.1 AA.\"\nassistant: \"I'll create a complete design system starting with understanding your brand guidelines and user base through the context-manager. I'll build a modular component library with documented specs, design tokens in multiple formats (CSS, JSON, Figma), responsive patterns across web and mobile, dark mode variants, and comprehensive accessibility annotations. I'll deliver Figma files, design documentation, and developer handoff specifications.\"\n<commentary>\nUse ui-designer when you need to establish or refine a complete design system, including component libraries, design tokens, and comprehensive visual standards. This agent excels at creating reusable design assets and documentation that scale across teams and platforms.\n</commentary>\n</example>\n\n<example>\nContext: A frontend team is building a new feature but needs UI design and interaction patterns before development.\nuser: \"Design the booking flow for our travel app. Need 3 screen variations, empty/loading/error states, mobile and desktop layouts, and clear interaction patterns for each step.\"\nassistant: \"I'll design a complete booking flow with wireframes, visual mockups, and interaction specifications. First, let me check the context-manager for your existing design system and style guide. Then I'll create all screen states, document interaction patterns, provide motion design specifications, and ensure alignment with your design language and accessibility standards.\"\n<commentary>\nInvoke ui-designer when your frontend developers need detailed visual designs, interaction specifications, and asset files before implementation. The agent provides a clear handoff package that developers can build from.\n</commentary>\n</example>\n\n<example>\nContext: A redesign initiative is underway and you need UI improvements to an existing product.\nuser: \"Our dashboard needs a visual refresh. The functionality is fine but the UX feels dated. Improve the visual hierarchy, modernize colors and typography, add micro-interactions, and ensure it works on mobile.\"\nassistant: \"I'll analyze your current dashboard using the context-manager, identify visual improvement opportunities, redesign layouts for better hierarchy and scannability, update colors and typography to modern standards, add meaningful micro-interactions, and ensure responsive design. I'll provide before/after comparisons, design rationale, and implementation specifications for your developers.\"\n<commentary>\nUse ui-designer for visual refinements, redesigns, and aesthetic improvements to existing interfaces. This agent modernizes dated UIs while respecting existing functionality and providing clear upgrade paths.\n</commentary>\n</example>"
tools: Read, Write, Edit, Bash, Glob, Grep
---
You are a senior UI designer with expertise in visual design, interaction design, and design systems. Your focus spans creating beautiful, functional interfaces that delight users while maintaining consistency, accessibility, and brand alignment across all touchpoints.
## Communication Protocol
### Required Initial Step: Design Context Gathering
Always begin by requesting design context from the context-manager. This step is mandatory to understand the existing design landscape and requirements.
Send this context request:
```json
{
"requesting_agent": "ui-designer",
"request_type": "get_design_context",
"payload": {
"query": "Design context needed: brand guidelines, existing design system, component libraries, visual patterns, accessibility requirements, and target user demographics."
}
}
```
## Execution Flow
Follow this structured approach for all UI design tasks:
### 1. Context Discovery
Begin by querying the context-manager to understand the design landscape. This prevents inconsistent designs and ensures brand alignment.
Context areas to explore:
- Brand guidelines and visual identity
- Existing design system components
- Current design patterns in use
- Accessibility requirements
- Performance constraints
Smart questioning approach:
- Leverage context data before asking users
- Focus on specific design decisions
- Validate brand alignment
- Request only critical missing details
### 2. Design Execution
Transform requirements into polished designs while maintaining communication.
Active design includes:
- Creating visual concepts and variations
- Building component systems
- Defining interaction patterns
- Documenting design decisions
- Preparing developer handoff
Status updates during work:
```json
{
"agent": "ui-designer",
"update_type": "progress",
"current_task": "Component design",
"completed_items": ["Visual exploration", "Component structure", "State variations"],
"next_steps": ["Motion design", "Documentation"]
}
```
### 3. Handoff and Documentation
Complete the delivery cycle with comprehensive documentation and specifications.
Final delivery includes:
- Notify context-manager of all design deliverables
- Document component specifications
- Provide implementation guidelines
- Include accessibility annotations
- Share design tokens and assets
Completion message format:
"UI design completed successfully. Delivered comprehensive design system with 47 components, full responsive layouts, and dark mode support. Includes Figma component library, design tokens, and developer handoff documentation. Accessibility validated at WCAG 2.1 AA level."
Design critique process:
- Self-review checklist
- Peer feedback
- Stakeholder review
- User testing
- Iteration cycles
- Final approval
- Version control
- Change documentation
Performance considerations:
- Asset optimization
- Loading strategies
- Animation performance
- Render efficiency
- Memory usage
- Battery impact
- Network requests
- Bundle size
Motion design:
- Animation principles
- Timing functions
- Duration standards
- Sequencing patterns
- Performance budget
- Accessibility options
- Platform conventions
- Implementation specs
Dark mode design:
- Color adaptation
- Contrast adjustment
- Shadow alternatives
- Image treatment
- System integration
- Toggle mechanics
- Transition handling
- Testing matrix
Cross-platform consistency:
- Web standards
- iOS guidelines
- Android patterns
- Desktop conventions
- Responsive behavior
- Native patterns
- Progressive enhancement
- Graceful degradation
Design documentation:
- Component specs
- Interaction notes
- Animation details
- Accessibility requirements
- Implementation guides
- Design rationale
- Update logs
- Migration paths
Quality assurance:
- Design review
- Consistency check
- Accessibility audit
- Performance validation
- Browser testing
- Device verification
- User feedback
- Iteration planning
Deliverables organized by type:
- Design files with component libraries
- Style guide documentation
- Design token exports
- Asset packages
- Prototype links
- Specification documents
- Handoff annotations
- Implementation notes
Integration with other agents:
- Collaborate with ux-researcher on user insights
- Provide specs to frontend-developer
- Work with accessibility-tester on compliance
- Support product-manager on feature design
- Guide backend-developer on data visualization
- Partner with content-marketer on visual content
- Assist qa-expert with visual testing
- Coordinate with performance-engineer on optimization
Always prioritize user needs, maintain design consistency, and ensure accessibility while creating beautiful, functional interfaces that enhance the user experience.

View file

@ -20,13 +20,15 @@ paths:
- Bookmark scroll: คำนวณ absolute position ครั้งเดียวแล้ว smooth-scroll ครั้งเดียว (ห้าม scroll ซ้อนสองจังหวะ) - Bookmark scroll: คำนวณ absolute position ครั้งเดียวแล้ว smooth-scroll ครั้งเดียว (ห้าม scroll ซ้อนสองจังหวะ)
- Deep link (F12/C4): PDFViewer อ่าน `window.location.search` **ครั้งเดียวหลัง `pageSizes` พร้อม** (guard ด้วย ref — ไม่ใช้ `useSearchParams` เลี่ยงข้อจำกัด prerender ของ Next 14) — `?page=N` (1-based) scroll ไปหน้านั้น, `?ann=<uuid>` scroll ไปหน้าของ annotation นั้น + `setSelectedId` — placeholder ขนาดจริงทุกหน้าทำให้คำนวณ absolute scroll ได้แม้หน้ายังไม่ render (กลไกเดียวกับ bookmark); ปุ่ม "คัดลอกลิงก์" ใน sidebar สร้าง URL รูป `/annotate/<fileId>?page=<N>&ann=<id>` - Deep link (F12/C4): PDFViewer อ่าน `window.location.search` **ครั้งเดียวหลัง `pageSizes` พร้อม** (guard ด้วย ref — ไม่ใช้ `useSearchParams` เลี่ยงข้อจำกัด prerender ของ Next 14) — `?page=N` (1-based) scroll ไปหน้านั้น, `?ann=<uuid>` scroll ไปหน้าของ annotation นั้น + `setSelectedId` — placeholder ขนาดจริงทุกหน้าทำให้คำนวณ absolute scroll ได้แม้หน้ายังไม่ render (กลไกเดียวกับ bookmark); ปุ่ม "คัดลอกลิงก์" ใน sidebar สร้าง URL รูป `/annotate/<fileId>?page=<N>&ann=<id>`
- Dialog `fixed z-50` ใน sidebar (ประวัติ) ต้อง render ผ่าน **`createPortal` ไป `document.body`** + guard `typeof document === "undefined"` ตอน SSR — `aside.sticky` สร้าง stacking context ครอบ z-index ของ dialog ไว้ ทำให้ `fixed z-50` แพ้ `textLayer` (z-[2] ระดับ root) จนคลิกปุ่มไม่ได้ (PLAN-FEAT.md §5) - Dialog `fixed z-50` ใน sidebar (ประวัติ) ต้อง render ผ่าน **`createPortal` ไป `document.body`** + guard `typeof document === "undefined"` ตอน SSR — `aside.sticky` สร้าง stacking context ครอบ z-index ของ dialog ไว้ ทำให้ `fixed z-50` แพ้ `textLayer` (z-[2] ระดับ root) จนคลิกปุ่มไม่ได้ (PLAN-FEAT.md §5)
- เมนู dropdown ของ tool ทั้ง 5 (highlight/draw/shape/stamp/textbox) + popup bookmark ก็ render ผ่าน **`createPortal` → `document.body` เป็น `position: fixed`** เหมือนกัน แต่ยึดพิกัดจริงของปุ่ม (`fixedMenuPos` — `getBoundingClientRect()` ตอนคลิก + clamp ให้อยู่ในจอ; popup bookmark คำนวณจาก ref ของปุ่ม 🔖 เพราะเปิดได้จากคีย์ลัด B/ปุ่มในแผง selection ด้วย) — **ห้าม**กลับไปเป็น `absolute` ใน scroll container ของแถบ tool และ**ห้ามสลับ `overflow` ของแถบตอนเปิดเมนู** (สลับแล้ว scroll position ของแถบรีเซ็ต เมนูเปิดนอกจอบนมือถือ และทั้ง app เอียงเลื่อนแนวนอนได้); เลื่อนแถบต่อหรือ **ความกว้างจอเปลี่ยน** (หมุนจอ/split view — เทียบ `menuOpenWRef` ตอนเปิด) = ปิดเมนูทั้งหมด แต่ resize ที่เปลี่ยนแค่ความสูง (URL bar ยุบ/คีย์บอร์ดของมือถือ) ปิดไม่ได้ เพราะเมนู fixed ยึด sticky header ยังอยู่กับที่ (ตำแหน่งคำนวณตอนเปิดเท่านั้น ไม่ตามปุ่มที่เลื่อนไป); outside-click ต้อง**ข้ามโหนดใน `[data-tool-menu]` ก่อน** — ref ของ wrapper มองไม่เห็นโหนดใน portal ไม่ข้ามแล้วคลิกในเมนู = ปิดเมนูเอง
- DRAW วาดเส้นตรง: **ปล่อยเมาส์แล้ว snap อัตโนมัติ** เมื่อจุดทุกจุดห่างจากเส้นตรง จุดเริ่ม→จุดสุดท้าย ไม่เกิน max(5% ของความยาว, 3px) และเส้นยาว ≥ 24px (`isNearlyStraight`) — หรือกดค้าง **Shift** ระหว่างลาก (กด/ปล่อยกลางทางได้ — listener ระดับ window + reset ตอน blur) เพื่อบังคับตรงเสมอ → บันทึกเหลือจุดเริ่ม→จุดสุดท้าย 2 จุด (ความหนา uniform — ไม่เก็บ `widths` จาก pressure) — ใช้ได้เฉพาะโหมดวาดอิสระ ไม่ใช่ shape/eraser/redact; เส้นโค้ง/ขดจริงหลุดเกณฑ์จึงเหลือ freehand - DRAW วาดเส้นตรง: **ปล่อยเมาส์แล้ว snap อัตโนมัติ** เมื่อจุดทุกจุดห่างจากเส้นตรง จุดเริ่ม→จุดสุดท้าย ไม่เกิน max(5% ของความยาว, 3px) และเส้นยาว ≥ 24px (`isNearlyStraight`) — หรือกดค้าง **Shift** ระหว่างลาก (กด/ปล่อยกลางทางได้ — listener ระดับ window + reset ตอน blur) เพื่อบังคับตรงเสมอ → บันทึกเหลือจุดเริ่ม→จุดสุดท้าย 2 จุด (ความหนา uniform — ไม่เก็บ `widths` จาก pressure) — ใช้ได้เฉพาะโหมดวาดอิสระ ไม่ใช่ shape/eraser/redact; เส้นโค้ง/ขดจริงหลุดเกณฑ์จึงเหลือ freehand
- Tool `pan` (มือเลื่อนหน้า — ปุ่มแยกถัดจาก select ในแถบ tool): ลากบน `<main>` = `window.scrollBy` สวนทางการลาก + cursor grab/grabbing; ระหว่างใช้ pan ทุก overlay ปิด pointer-events ผ่านตัวแปร `overlayFrozen` (`inkActive || pan`) **ยกเว้น InkLayer ต้องไม่ active** (ไม่งั้น pan กลายเป็นวาดเส้น) - Tool `pan` (มือเลื่อนหน้า — ปุ่มแยกถัดจาก select ในแถบ tool): ลากบน `<main>` = `window.scrollBy` สวนทางการลาก + cursor grab/grabbing; ระหว่างใช้ pan ทุก overlay ปิด pointer-events ผ่านตัวแปร `overlayFrozen` (`inkActive || pan`) **ยกเว้น InkLayer ต้องไม่ active** (ไม่งั้น pan กลายเป็นวาดเส้น)
- HIGHLIGHT มี `style?` 4 แบบ: highlight ทึบเหมือนเดิม / underline = เส้นที่ขอบล่าง rect ต่อบรรทัด / strikethrough = เส้นกลางบรรทัด / squiggle = svg path ขยุกขยิก (`squigglePath`) ที่ขอบล่าง — export วาด line/sine แทน rect ทึบ; สร้างได้จาก **tool "highlight" ทางเดียว** (dropdown บนปุ่ม = รูปแบบ + สี → คลุมข้อความ = สร้างทันที พร้อม live preview ผ่าน `selectionchange` + ซ่อน native selection) — tool "เลือกข้อความ" คลุมแล้วเกิด popup ไม่ได้แล้ว (ถอดออก) และคีย์ลัด `H` สลับไป tool highlight - HIGHLIGHT มี `style?` 4 แบบ: highlight ทึบเหมือนเดิม / underline = เส้นที่ขอบล่าง rect ต่อบรรทัด / strikethrough = เส้นกลางบรรทัด / squiggle = svg path ขยุกขยิก (`squigglePath`) ที่ขอบล่าง — export วาด line/sine แทน rect ทึบ; สร้างได้จาก **tool "highlight" ทางเดียว** (dropdown บนปุ่ม = รูปแบบ + สี → คลุมข้อความ = สร้างทันที พร้อม live preview ผ่าน `selectionchange` + ซ่อน native selection) — tool "เลือกข้อความ" คลุมแล้วเกิด popup ไม่ได้แล้ว (ถอดออก) และคีย์ลัด `H` สลับไป tool highlight
- **จอสัมผัส (iOS/Android) ไม่มี `mouseup` หลังคลุมข้อความด้วยนิ้ว** (synthetic mouse events เกิดเฉพาะ tap — การ commit ตอนปล่อยเมาส์ใช้ไม่ได้): อุปกรณ์ที่ `matchMedia("(pointer: coarse)")` จริง + tool highlight → effect `selectionchange` debounce ~400ms หลัง selection หยุดนิ่ง แล้ว snapshot (rects + ข้อความผ่าน `readTextSelection(pi, { withText: true })`) เก็บใน `touchSelRef` + แสดงปุ่ม "ไฮไลต์" ลอย**ใต้**บรรทัดสุดท้ายของ selection (ด้านบนถูกเมนู/handles ของ OS ยึด — ใกล้ขอบล่างหน้าเกินพลิกขึ้นเหนือ rect แรก) — commit ต้องใช้ **snapshot ใน ref เท่านั้น ไม่ใช่ selection สด** เพราะ iOS เคลียร์ selection ทันทีที่แตะปุ่ม ก่อน click ยิง; ทั้ง desktop (mouseup) และ touch (ปุ่มยืนยัน) ผ่าน `commitHighlight` ชุดเดียวกัน; เคลียร์ปุ่ม+timer ทุกครั้งที่ selection หาย/ออกนอกหน้า/เปลี่ยน tool - **จอสัมผัส (iOS/Android) ไม่มี `mouseup` หลังคลุมข้อความด้วยนิ้ว** (synthetic mouse events เกิดเฉพาะ tap — การ commit ตอนปล่อยเมาส์ใช้ไม่ได้): อุปกรณ์ที่ `matchMedia("(pointer: coarse)")` จริง + tool highlight → effect `selectionchange` debounce ~400ms หลัง selection หยุดนิ่ง แล้ว snapshot (rects + ข้อความผ่าน `readTextSelection(pi, { withText: true })`) เก็บใน `touchSelRef` + แสดงปุ่ม "ไฮไลต์ข้อความ" ลอย**ใต้**บรรทัดสุดท้ายของ selection (ด้านบนถูกเมนู/handles ของ OS ยึด — ใกล้ขอบล่างหน้าเกินพลิกขึ้นเหนือ rect แรก) — commit ต้องใช้ **snapshot ใน ref เท่านั้น ไม่ใช่ selection สด** เพราะ iOS เคลียร์ selection ทันทีที่แตะปุ่ม ก่อน click ยิง; ทั้ง desktop (mouseup) และ touch (ปุ่มยืนยัน) ผ่าน `commitHighlight` ชุดเดียวกัน; เคลียร์ปุ่ม+timer ทุกครั้งที่ selection หาย/ออกนอกหน้า/เปลี่ยน tool
- **tap/คลิกที่ใช้ "ปิดเมนู" ต้องไม่สร้างรายการ**: outside-click ปิดเมนูที่ `mousedown` ก่อน `click` ยิงถึง page wrapper ทำให้ tool comment/textbox/stamp วางรายการจาก tap ปิดเมนูโดยไม่ตั้งใจ — `handlePageClick` เช็ค `menuClosedAtRef` (เขียนทุก path ปิดเมนูผ่าน `markMenuClosed`: outside-click/Escape/switchTool/เลื่อนแถบ/resize) และเฉย 300ms แรก คลิกที่สองค่อยวาง (มาตรฐาน dropdown ทั่วไป ใช้ทั้ง desktop และ touch — pattern เดียวกับ `touchHideTimerRef`)
- **คลุมข้อความได้ 5 tool** (`TEXT_SELECTION_TOOLS` set ใน PDFViewer: select/highlight/comment/textbox/stamp — ที่เหลือ textLayer ปิด pointer-events คลุมไม่ได้): ทั้งหมด inject `<style>` ซ่อน native selection + ฟัง `selectionchange` วาดของเราเอง (highlight = preview มาร์ก, ที่เหลือ = selection สีฟ้า) + tracking เส้นทางลาก (`selDragStart/EndRef` ที่ mousedown/move) + Ctrl+C กรองข้อความตามเส้นทาง — comment/textbox/stamp ปล่อยเมาส์จากการลากคลุม**ไม่วางรายการ** (guard `!selection.isCollapsed` ต้น `handlePageClick`: click ที่ยิงต่อจาก drag คลุมต้องเฉยไป เหมือน tool เลือกข้อความ คลิกล้วน ๆ ต่างหากที่เปิด composer/วางตรายาง) - **คลุมข้อความได้ 5 tool** (`TEXT_SELECTION_TOOLS` set ใน PDFViewer: select/highlight/comment/textbox/stamp — ที่เหลือ textLayer ปิด pointer-events คลุมไม่ได้): ทั้งหมด inject `<style>` ซ่อน native selection + ฟัง `selectionchange` วาดของเราเอง (highlight = preview มาร์ก, ที่เหลือ = selection สีฟ้า) + tracking เส้นทางลาก (`selDragStart/EndRef` ที่ mousedown/move) + Ctrl+C กรองข้อความตามเส้นทาง — comment/textbox/stamp ปล่อยเมาส์จากการลากคลุม**ไม่วางรายการ** (guard `!selection.isCollapsed` ต้น `handlePageClick`: click ที่ยิงต่อจาก drag คลุมต้องเฉยไป เหมือน tool เลือกข้อความ คลิกล้วน ๆ ต่างหากที่เปิด composer/วางตรายาง)
- กรอบ selection (F1) ของทุกชนิดที่เป็นรายการของตัวเองมี**แผงเครื่องมือ** (`w-60` เหมือน dropdown ของ tool) ที่ใช้**ตัวควบคุมชุดเดียวกับ dropdown ของ tool ผู้สร้าง** (จาก `components/pdf/ToolControls.tsx` — ColorSwatches/NumberCombo/PanelRow แชร์กับ PDFViewer) ตามชนิด: HIGHLIGHT = สี+รูปแบบมาร์ก, DRAW/SHAPE = สี+ความหนา (×10), STAMP = สี+ขนาด, TEXTBOX = สี+ขนาด+ความทึบ+ตัวหนา+จัดแนว — ปิดท้ายด้วย **ปุ่มลบ** (`onDelete`) เสมอ; ชนิดที่ไม่มีสไตล์ให้แก้ (REDACTION/COMMENT) ได้อย่างน้อยปุ่มลบ — **HIGHLIGHT ไม่มี handle ย้าย/ขยาย** (ผูกกับตำแหน่งข้อความจริง เลือกเพื่อเปลี่ยนสี/รูปแบบ/ลบได้อย่างเดียว) — handle ทุกตัว (ขอบ 4 + มุม 4) ต้องมี `touch-none`: จอสัมผัสไม่มี touch-action:none แล้ว browser ยึด gesture เป็น scroll ส่ง `pointercancel` กลายเป็นเลื่อนหน้าแทนการลาก (InkLayer มี `touch-none` อยู่แล้ว) - กรอบ selection (F1) ของทุกชนิดที่เป็นรายการของตัวเองมี**แผงเครื่องมือ** (`w-60` เหมือน dropdown ของ tool) ที่ใช้**ตัวควบคุมชุดเดียวกับ dropdown ของ tool ผู้สร้าง** (จาก `components/pdf/ToolControls.tsx` — ColorSwatches/NumberCombo/PanelRow แชร์กับ PDFViewer) ตามชนิด: HIGHLIGHT = สี+รูปแบบมาร์ก, DRAW/SHAPE = สี+ความหนา (×10), STAMP = สี+ขนาด, TEXTBOX = สี+ขนาด+ความทึบ+ตัวหนา+จัดแนว — ปิดท้ายด้วย **ปุ่มลบ** (`onDelete`) เสมอ; ชนิดที่ไม่มีสไตล์ให้แก้ (REDACTION/COMMENT) ได้อย่างน้อยปุ่มลบ — **HIGHLIGHT ไม่มี handle ย้าย/ขยาย** (ผูกกับตำแหน่งข้อความจริง เลือกเพื่อเปลี่ยนสี/รูปแบบ/ลบได้อย่างเดียว) — handle ทุกตัว (ขอบ 4 + มุม 4) ต้องมี `touch-none`: จอสัมผัสไม่มี touch-action:none แล้ว browser ยึด gesture เป็น scroll ส่ง `pointercancel` กลายเป็นเลื่อนหน้าแทนการลาก (InkLayer มี `touch-none` อยู่แล้ว) — บน `pointer: coarse` ขยาย hit area โดยไม่ขยายของที่มองเห็น (rule ใน globals.css เท่านั้น desktop ไม่แตะ): มุม/แถบเป็น div ขนาดจาก inline style (10px/8px) แก้ตรง ๆ ไม่ได้ จึงใช้ `::after` (hook `sel-handle` hit 28px, `sel-strip` hit 16px), ปุ่มแผงใช้ `sel-panel-btn` (36px), swatch ในแผง `.sel-panel .color-swatches` (32px), ปุ่ม icon ใน sidebar `aside[data-sidebar] button.shrink-0` (32px), ปุ่มเล็กใน composer/popover/pin ใช้ class `coarse-36` — ปุ่มในเมนูที่ portal ต้องครอบด้วย selector `[data-tool-menu]` คู่กับ `header .touch-44 .z-50` เดิม
- TEXTBOX (F17) `position` เป็น**มุมซ้ายบนของกล่องเสมอ** — `align` จัดข้อความ*ใน*กล่องอย่างเดียว (`textAlign`, แบบ Smallpdf: กดจัดแนวแล้วกล่องไม่ขยับ ขยับแค่ข้อความข้างใน — เคยทดลองให้ align เป็น "จุดยึด" ที่ขยับทั้งกล่องแล้วถอดออกเพราะผู้ใช้รู้สึกว่ากล่องเด้ง); ความกว้างสูงสุดจาก `textboxMaxWidthPct(x)` (`utils/geometry.ts`) บีบตามพื้นที่จริงถึงขอบขวา (cap 50% ของหน้า, ขั้นต่ำ 1%) **กันข้อความล้นขอบกระดาษ** — ใช้สูตรเดียวกันทั้ง render (`TextboxStampLayer`), bbox กรอบ selection/hit-test (`annotationBBox`) และ export PDF - TEXTBOX (F17) `position` เป็น**มุมซ้ายบนของกล่องเสมอ** — `align` จัดข้อความ*ใน*กล่องอย่างเดียว (`textAlign`, แบบ Smallpdf: กดจัดแนวแล้วกล่องไม่ขยับ ขยับแค่ข้อความข้างใน — เคยทดลองให้ align เป็น "จุดยึด" ที่ขยับทั้งกล่องแล้วถอดออกเพราะผู้ใช้รู้สึกว่ากล่องเด้ง); ความกว้างสูงสุดจาก `textboxMaxWidthPct(x)` (`utils/geometry.ts`) บีบตามพื้นที่จริงถึงขอบขวา (cap 50% ของหน้า, ขั้นต่ำ 1%) **กันข้อความล้นขอบกระดาษ** — ใช้สูตรเดียวกันทั้ง render (`TextboxStampLayer`), bbox กรอบ selection/hit-test (`annotationBBox`) และ export PDF
- TEXTBOX กล่องกว้างคงที่ (`width?` สไตล์ Smallpdf/Acrobat): กล่องที่สร้างใหม่คลิกเดียวได้ `width = min(TEXTBOX_DEFAULT_WIDTH_PCT=30, textboxMaxWidthPct)` — **ลากขอบซ้าย/ขวาของกรอบ selection = ปรับความกว้าง** (mode `wleft`/`wright` ใน AnnotationSelectionLayer: ขอบซ้ายขยับ = เลื่อนกล่อง+เปลี่ยนกว้าง (`position.x` = ขอบซ้ายตรง ๆ), ขอบขวาขยับ = เปลี่ยนกว้างอย่างเดียว, clamp กว้าง 2–50% และอยู่ในหน้า) ฟอนต์คงขนาด ข้อความ wrap ใหม่ ส่วนสูงตามจำนวนบรรทัดอัตโนมัติ — กล่องแบบนี้**ซ่อนมุมกรอบ** (scale ไม่มีความหมาย) ย้ายกล่องใช้ขอบบน/ล่าง; กล่องเก่าที่ไม่มี `width` = shrink-to-fit หุ้มข้อความตามพฤติกรรมเดิมทั้งหมด - TEXTBOX กล่องกว้างคงที่ (`width?` สไตล์ Smallpdf/Acrobat): กล่องที่สร้างใหม่คลิกเดียวได้ `width = min(TEXTBOX_DEFAULT_WIDTH_PCT=30, textboxMaxWidthPct)` — **ลากขอบซ้าย/ขวาของกรอบ selection = ปรับความกว้าง** (mode `wleft`/`wright` ใน AnnotationSelectionLayer: ขอบซ้ายขยับ = เลื่อนกล่อง+เปลี่ยนกว้าง (`position.x` = ขอบซ้ายตรง ๆ), ขอบขวาขยับ = เปลี่ยนกว้างอย่างเดียว, clamp กว้าง 2–50% และอยู่ในหน้า) ฟอนต์คงขนาด ข้อความ wrap ใหม่ ส่วนสูงตามจำนวนบรรทัดอัตโนมัติ — กล่องแบบนี้**ซ่อนมุมกรอบ** (scale ไม่มีความหมาย) ย้ายกล่องใช้ขอบบน/ล่าง; กล่องเก่าที่ไม่มี `width` = shrink-to-fit หุ้มข้อความตามพฤติกรรมเดิมทั้งหมด; ฟอนต์ default ตอนสร้าง = `TEXTBOX_DEFAULT_FONT_SIZE_PCT` 2.5% (≈15px — เดิม 1.2% ≈ 7px อ่านไม่ออก และเล็กกว่า preset ต่ำสุด 10px ของตัวเอง) และเข้าโหมดแก้ข้อความได้จากปุ่ม "แก้ไขข้อความ" ในแผง selection (`onStartEdit`) นอกเหนือจาก double-click (จอสัมผัส iOS double-tap = zoom หน้า dblclick จึงใช้ไม่ได้)
- คลิกเลือกบนหน้า: tool select/highlight คลิกบน annotation = `setSelectedId` ผ่าน `hitTestAnnotation` (DRAW/SHAPE ใช้ hit-test แม่นเดียวกับ eraser, HIGHLIGHT ดู rect, ที่เหลือ bbox+padding) — เช็คจากรายการหลังสุดก่อน (ตัวบนสุดชนะ), คลิกที่ว่าง = deselect; ใช้ `visibleAnnotations` (เคารพ filter รายคน F4) - คลิกเลือกบนหน้า: tool select/highlight คลิกบน annotation = `setSelectedId` ผ่าน `hitTestAnnotation` (DRAW/SHAPE ใช้ hit-test แม่นเดียวกับ eraser, HIGHLIGHT ดู rect, ที่เหลือ bbox+padding) — เช็คจากรายการหลังสุดก่อน (ตัวบนสุดชนะ), คลิกที่ว่าง = deselect; ใช้ `visibleAnnotations` (เคารพ filter รายคน F4)
- Tooltip hover (N1): `onMouseMove` ของ page wrapper ทำ hit-test ชุดเดียวกับคลิก → `hoveredId` state **เดี่ยวระดับ root เปลี่ยนเฉพาะเมื่อ id เปลี่ยน** (ห้าม setState ทุก mousemove, hit-test เดินจากท้าย array ไม่ allocate ต่อ event) → tooltip "ชนิด · ผู้ทำ + โน้ต" ลอยข้าง bbox (`pointer-events: none` เสมอ, z-[5], ยกเว้น COMMENT) — ปิดช่วง tool ที่รับ pointer (`draw/shape/erase/redact/pan`), `e.buttons !== 0` และตอน `openPinId !== null` (popover ของ pin เปิดอยู่) - Tooltip hover (N1): `onMouseMove` ของ page wrapper ทำ hit-test ชุดเดียวกับคลิก → `hoveredId` state **เดี่ยวระดับ root เปลี่ยนเฉพาะเมื่อ id เปลี่ยน** (ห้าม setState ทุก mousemove, hit-test เดินจากท้าย array ไม่ allocate ต่อ event) → tooltip "ชนิด · ผู้ทำ + โน้ต" ลอยข้าง bbox (`pointer-events: none` เสมอ, z-[5], ยกเว้น COMMENT) — ปิดช่วง tool ที่รับ pointer (`draw/shape/erase/redact/pan`), `e.buttons !== 0` และตอน `openPinId !== null` (popover ของ pin เปิดอยู่)

View file

@ -43,7 +43,7 @@ export default async function SummaryPage({
const pages = Array.from(byPage.entries()).sort((x, y) => x[0] - y[0]); const pages = Array.from(byPage.entries()).sort((x, y) => x[0] - y[0]);
return ( return (
<main className="mx-auto max-w-3xl p-6"> <main className="summary-page mx-auto max-w-3xl p-6">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<h1 className="min-w-0 flex-1 truncate text-lg font-bold text-primary"> <h1 className="min-w-0 flex-1 truncate text-lg font-bold text-primary">
สรุปหมายเหตุ — {file.title} สรุปหมายเหตุ — {file.title}

View file

@ -2,20 +2,35 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import fontkit from "@pdf-lib/fontkit"; import fontkit from "@pdf-lib/fontkit";
import { PDFDocument, LineCapStyle, rgb, type PDFFont } from "pdf-lib"; import {
import { asc, eq } from "drizzle-orm"; PDFDocument,
PDFHexString,
PDFName,
PDFString,
LineCapStyle,
rgb,
type PDFFont,
type PDFDict,
type PDFPage,
type PDFRef,
} from "pdf-lib";
import { asc, eq, inArray } from "drizzle-orm";
import { db } from "@/db"; import { db } from "@/db";
import { PdfPathError, readPublicPdf } from "@/lib/server-pdf"; import { PdfPathError, readPublicPdf } from "@/lib/server-pdf";
import { import {
annotationReplies,
annotations, annotations,
bookmarks,
files, files,
toAnnotationDTO, toAnnotationDTO,
type AnnotationType, type AnnotationType,
type BookmarkRow,
type CommentData, type CommentData,
type DrawData, type DrawData,
type HighlightData, type HighlightData,
type RedactionData, type RedactionData,
type ReplyRow,
type ShapeData, type ShapeData,
type StampData, type StampData,
type TextboxData, type TextboxData,
@ -27,28 +42,39 @@ import {
encodeRFC5987, encodeRFC5987,
hexToRgb01, hexToRgb01,
wrapStyledText, wrapStyledText,
wrapTextByWidth,
type Rot, type Rot,
type StyledRun, type StyledRun,
} from "@/utils/pdf-export"; } from "@/utils/pdf-export";
import type { Rect } from "@/db/schema"; import type { AnnotationDTO, Rect } from "@/db/schema";
import { toPx, type Point, type Size } from "@/utils/coordinates"; import { toPx, type Point, type Size } from "@/utils/coordinates";
import { STAMP_HEIGHT_RATIO, textboxMaxWidthPct } from "@/utils/geometry"; import {
import { STAMP_COLOR, effectiveDrawOpacity, stampFitFontSize } from "@/utils/annotation-display"; STAMP_HEIGHT_RATIO,
annotationBBox,
textboxMaxWidthPct,
} from "@/utils/geometry";
import {
STAMP_COLOR,
annotationNote,
effectiveDrawOpacity,
stampFitFontSize,
} from "@/utils/annotation-display";
// Export PDF ฝัง annotation (F8/C1 — PLAN-FEAT.md §3) // Export PDF ฝัง annotation + bookmarks (F8/C1 — PLAN-FEAT.md §3)
// - อ่าน PDF ต้นฉบับจาก public/ แบบ **อ่านอย่างเดียว** (invariant #1) — pdf-lib load // - อ่าน PDF ต้นฉบับจาก public/ แบบ **อ่านอย่างเดียว** (invariant #1) — pdf-lib load
// เป็นสำเนาในหน่วยความจำ แล้ว save() คืน bytes ใหม่ ไม่เขียนทับไฟล์เดิม // เป็นสำเนาในหน่วยความจำ แล้ว save() คืน bytes ใหม่ ไม่เขียนทับไฟล์เดิม
// - วาดต่อหน้าตามลำดับ: HIGHLIGHT → DRAW → SHAPE → REDACTION (ดำทึบ) → COMMENT หมุด // - วาดต่อหน้าตามลำดับ: HIGHLIGHT → DRAW → SHAPE → REDACTION (ดำทึบ) → TEXTBOX/STAMP
// (+ข้อความถ้ามีฟอนต์ Sarabun — D5: ไม่มีฟอนต์ต้องไม่พัง) // (+ข้อความถ้ามีฟอนต์ Sarabun — D5: ไม่มีฟอนต์ต้องไม่พัง)
// - COMMENT เป็น sticky note จริงของ PDF (/Subtype /Text — คลิกหมุดที่ reader เปิด
// popup อ่าน message + replies), bookmarks เป็น PDF outline (แผง Bookmarks)
const UUID_RE = const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const SARABUN_REL = path.join("fonts", "Sarabun-Regular.ttf"); const SARABUN_REL = path.join("fonts", "Sarabun-Regular.ttf");
// ลำดับวาดต่อหน้าตามสเปก §3 F8 ข้อ 3: HIGHLIGHT → DRAW → SHAPE → REDACTION → COMMENT // ลำดับวาดต่อหน้าตามสเปก §3 F8 ข้อ 3: HIGHLIGHT → DRAW → SHAPE → REDACTION → TEXTBOX → STAMP
// (REDACTION ดำทึบต้องทับทุกอย่างที่สร้างก่อนหน้า และหมุด COMMENT ต้องไม่ถูกกลบ — D4) // (REDACTION ดำทึบต้องทับทุกอย่างที่สร้างก่อนหน้า — D4) — COMMENT ไม่เข้าลำดับนี้แล้ว:
// เป็น annotation object ที่ reader วาดเหนือ content เสมอ จึงไม่มีปัญหาถูกกลบ
const TYPE_ORDER: Record<AnnotationType, number> = { const TYPE_ORDER: Record<AnnotationType, number> = {
HIGHLIGHT: 0, HIGHLIGHT: 0,
DRAW: 1, DRAW: 1,
@ -59,6 +85,126 @@ const TYPE_ORDER: Record<AnnotationType, number> = {
STAMP: 6, STAMP: 6,
}; };
/** ISO string → PDF date string (D:YYYYMMDDHHmmSSZ) สำหรับ /M ของ annotation */
function toPdfDate(iso: string): string {
const d = new Date(iso);
const p = (n: number) => String(n).padStart(2, "0");
return `D:${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}Z`;
}
/**
* โน้ตคำอธิบาย (N1) ของ annotation → sticky note เล็กแนบมุมขวาบนของ bbox —
* Chrome/Edge แสดง tooltip จาก /Contents ตอน hover, Acrobat คลิกเปิด popup
* (แทน hover tooltip บนเว็บที่ flatten ลงหน้าแล้วอ่านไม่ได้)
*/
function attachNoteSticky(
doc: PDFDocument,
page: PDFPage,
a: AnnotationDTO,
note: string,
anchor: Point, // มุมขวาบนของ bbox ในพิกัด PDF (pt)
): void {
const annot = doc.context.obj({
Type: "Annot",
Subtype: "Text",
// หมุดเล็ก 16pt ห้อยลงจากจุดยึด (แกน y ของ PDF ชี้ขึ้น)
Rect: [anchor.x, anchor.y - 16, anchor.x + 16, anchor.y],
Contents: PDFHexString.fromText(note),
T: PDFHexString.fromText(a.userName),
C: [254 / 255, 243 / 255, 199 / 255],
M: PDFString.of(toPdfDate(a.updatedAt)),
F: 4, // Print — พิมพ์แล้ว icon ติดไปด้วย
});
page.node.addAnnot(doc.context.register(annot));
}
/**
* ฝัง bookmarks เป็น PDF outline (แผง Bookmarks ของ reader กดกระโดดได้) — โครงสร้าง
* ตาม parentId (DB จำกัด 3 ระดับอยู่แล้ว) เรียงตาม createdAt เหมือน sidebar แต่ละรายการ
* /Dest ชี้หน้า + ตำแหน่ง yOffset — pdf-lib ไม่มี API ระดับสูงสำหรับ outline จึงต่อ
* PDFDict เอง (Title เป็น PDFHexString UTF-16BE รองรับภาษาไทย)
*/
function buildOutline(doc: PDFDocument, rows: BookmarkRow[]): void {
if (rows.length === 0) return;
const ctx = doc.context;
const ids = new Set(rows.map((b) => b.id));
const childrenOf = new Map<string, BookmarkRow[]>();
const roots: BookmarkRow[] = [];
for (const b of rows) {
// parent หาย (ความครบถ้วนของข้อมูล) → ผลักขึ้น root เหมือนที่ sidebar ทำ
if (b.parentId && ids.has(b.parentId)) {
const list = childrenOf.get(b.parentId) ?? [];
list.push(b);
childrenOf.set(b.parentId, list);
} else {
roots.push(b);
}
}
// Major 2 (code review): ข้อมูลวนซ้ำไร้ root (A แม่ของ B, B แม่ของ A — action/sidebar กัน
// ตอนเขียนแล้ว แต่กันเคส DB ถูกแก้ตรง ๆ) roots จะว่างและ kids[0] พังทั้ง export (500) —
// ปล่อยเป็นไฟล์ไม่มี outline เงียบ ๆ ดีกว่าไฟล์พังทั้งใบ
if (roots.length === 0) return;
const buildNode = (
b: BookmarkRow,
parentRef: PDFRef,
): { ref: PDFRef; dict: PDFDict; count: number } => {
const ref = ctx.nextRef();
const kids = (childrenOf.get(b.id) ?? []).map((k) => buildNode(k, ref));
const page = doc.getPage(
Math.min(Math.max(b.pageIndex, 0), doc.getPageCount() - 1),
);
const size = page.getSize();
const rotation = (((page.getRotation().angle % 360) + 360) % 360) as Rot;
const display = displaySize(size, rotation);
// yOffset (% ของความสูงหน้าบนจอ) → พิกัด PDF ทางเดียวกับ annotation (invariant #2)
// นิยาม yOffset = "จุดจริงบนหน้า" (ขอบบนของสิ่งที่ดูอยู่ตอนสร้าง) และในเว็บกดแล้ว
// จุดถูกดันชิดใต้ header — dest /XYZ ของ PDF ก็วางจุดยึดติดขอบบน viewport เหมือนกัน
// จึงใช้ตำแหน่งเป๊ะ ๆ ไม่ต้องชดเชย (ก่อนหน้านี้ yOffset เป็น "กึ่งกลางจอตอนสร้าง"
// จึงเคยต้องเลื่อนไถออก 25% ของความสูงหน้า)
const dest = displayToPdfPoint(
{ x: 0, y: toPx(b.yOffset, display.height) },
size,
rotation,
);
const count = kids.reduce((sum, k) => sum + 1 + k.count, 0);
const dict = ctx.obj({
Title: PDFHexString.fromText(b.title),
Parent: parentRef,
// /XYZ = กระโดดไปตำแหน่งนี้โดยคง zoom ของผู้อ่าน (null)
Dest: [page.ref, PDFName.of("XYZ"), dest.x, dest.y, null],
...(kids.length > 0
? { First: kids[0].ref, Last: kids[kids.length - 1].ref, Count: count }
: {}),
});
for (let i = 0; i < kids.length; i++) {
if (i > 0) kids[i].dict.set(PDFName.of("Prev"), kids[i - 1].ref);
if (i < kids.length - 1)
kids[i].dict.set(PDFName.of("Next"), kids[i + 1].ref);
}
ctx.assign(ref, dict);
return { ref, dict, count };
};
const rootRef = ctx.nextRef();
const kids = roots.map((r) => buildNode(r, rootRef));
for (let i = 0; i < kids.length; i++) {
if (i > 0) kids[i].dict.set(PDFName.of("Prev"), kids[i - 1].ref);
if (i < kids.length - 1) kids[i].dict.set(PDFName.of("Next"), kids[i + 1].ref);
}
ctx.assign(
rootRef,
ctx.obj({
Type: "Outlines",
First: kids[0].ref,
Last: kids[kids.length - 1].ref,
// Count เป็นบวก = แผง outline เปิดทั้งหมดตอนเปิดไฟล์
Count: kids.reduce((sum, k) => sum + 1 + k.count, 0),
}),
);
doc.catalog.set(PDFName.of("Outlines"), rootRef);
}
export async function GET(req: Request) { export async function GET(req: Request) {
const url = new URL(req.url); const url = new URL(req.url);
const fileId = url.searchParams.get("fileId") ?? ""; const fileId = url.searchParams.get("fileId") ?? "";
@ -118,6 +264,22 @@ export async function GET(req: Request) {
.map(toAnnotationDTO) .map(toAnnotationDTO)
.sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]); .sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]);
// replies ของ COMMENT สำหรับฝังลง popup ของ sticky note (F9) — query เฉพาะเมื่อมี COMMENT
const repliesByAnnotation = new Map<string, ReplyRow[]>();
const commentIds = dtos.filter((a) => a.type === "COMMENT").map((a) => a.id);
if (commentIds.length > 0) {
const replyRows = await db
.select()
.from(annotationReplies)
.where(inArray(annotationReplies.annotationId, commentIds))
.orderBy(asc(annotationReplies.createdAt));
for (const r of replyRows) {
const list = repliesByAnnotation.get(r.annotationId) ?? [];
list.push(r);
repliesByAnnotation.set(r.annotationId, list);
}
}
for (const a of dtos) { for (const a of dtos) {
const page = doc.getPage(Math.min(a.pageIndex, doc.getPageCount() - 1)); const page = doc.getPage(Math.min(a.pageIndex, doc.getPageCount() - 1));
const pageSize = page.getSize(); const pageSize = page.getSize();
@ -294,43 +456,27 @@ export async function GET(req: Request) {
break; break;
} }
case "COMMENT": { case "COMMENT": {
// sticky note จริงของ PDF (/Subtype /Text) — คลิกหมุดที่ reader แล้วเปิด popup
// อ่าน message + replies (F9); ไม่ต้องมี /AP เพราะ reader วาด icon เอง
const d = a.data as CommentData; const d = a.data as CommentData;
const pin = toPdf(pxPoint(d.position)); const pin = toPdf(pxPoint(d.position));
page.drawCircle({ const parts = [d.message];
x: pin.x, for (const r of repliesByAnnotation.get(a.id) ?? []) {
y: pin.y, parts.push(`— ${r.userName}: ${r.message}`);
size: 12,
color: rgb(254 / 255, 243 / 255, 199 / 255),
borderColor: rgb(252 / 255, 211 / 255, 77 / 255),
borderWidth: 1,
});
if (thaiFont) {
const font = thaiFont;
const fontSize = 9;
// FIX-5: ย่อความกว้างกล่องตามหน้าจริง — หน้าแคบกว่า 220pt
// เดิมทำให้ x ติดลบจนข้อความหลุดออกนอกขอบซ้ายเงียบ ๆ
const maxWidth = Math.min(220, size.width);
const lines = wrapTextByWidth(
`${a.userName}: ${d.message}`,
maxWidth,
(s) => font.widthOfTextAtSize(s, fontSize),
);
// FIX-5 (ต่อ): กันหลุดขอบบน — ข้อความวาด "ขึ้น" จากหมุด (แกน y ของ PDF ชี้ขึ้น)
// เมื่อหมุดใกล้ขอบบนหรือ wrap ยาวหลายบรรทัดจนบรรทัดบนสุดเลยขอบหน้า
// เลื่อนทั้งก้อนลงมาเท่าที่จำเป็น (ไม่ clamp รายบรรทัด ไม่งั้นบรรทัดซ้อนกัน)
const lineH = fontSize + 2;
const blockTop = pin.y + 4 + (lines.length - 1) * lineH;
const shift = Math.max(0, blockTop + fontSize - size.height);
lines.forEach((line, i) => {
page.drawText(line, {
x: Math.max(0, Math.min(pin.x + 10, size.width - maxWidth)),
y: pin.y + 4 + i * lineH - shift,
size: fontSize,
font,
color: rgb(0.2, 0.2, 0.2),
});
});
} }
const annot = doc.context.obj({
Type: "Annot",
Subtype: "Text",
// หมุด icon 20pt ห้อยลงจากจุด position (แกน y ของ PDF ชี้ขึ้น)
Rect: [pin.x, pin.y - 20, pin.x + 20, pin.y],
Contents: PDFHexString.fromText(parts.join("\n")),
T: PDFHexString.fromText(a.userName),
// สีเดียวกับหมุดบนจอ (เหลืองอ่อน) — reader เอาไปทา icon
C: [254 / 255, 243 / 255, 199 / 255],
M: PDFString.of(toPdfDate(a.updatedAt)),
F: 4, // Print — พิมพ์แล้ว icon ติดไปด้วย
});
page.node.addAnnot(doc.context.register(annot));
break; break;
} }
case "TEXTBOX": { case "TEXTBOX": {
@ -451,7 +597,32 @@ export async function GET(req: Request) {
break; break;
} }
} }
// โน้ตคำอธิบาย (N1) → sticky note แนบมุมขวาบนของ bbox — COMMENT ไม่มี note
// (มี message อยู่ใน popup ของตัวเองแล้ว)
if (a.type !== "COMMENT") {
const note = annotationNote(a);
if (note) {
// annotationBBox คืน pt ของ "หน้าบนจอ" อยู่แล้ว — แปลงตรงเข้าพิกัด PDF
const bbox = annotationBBox(a, display);
attachNoteSticky(
doc,
page,
a,
note,
toPdf({ x: bbox.x + bbox.width, y: bbox.y }),
);
} }
}
}
// bookmarks → PDF outline (แผง Bookmarks ของ reader กดกระโดดไปหน้า + yOffset)
const bookmarkRows = await db
.select()
.from(bookmarks)
.where(eq(bookmarks.fileId, fileId))
.orderBy(asc(bookmarks.createdAt));
buildOutline(doc, bookmarkRows);
const out = await doc.save(); const out = await doc.save();
const filename = encodeRFC5987(`${file.title}-annotated.pdf`); const filename = encodeRFC5987(`${file.title}-annotated.pdf`);

View file

@ -81,8 +81,7 @@
/* ─── UX review 2026-09-09 ข้อ 4: ปุ่ม header เล็กกว่ามาตรฐาน touch 44×44 ───── /* ─── UX review 2026-09-09 ข้อ 4: ปุ่ม header เล็กกว่ามาตรฐาน touch 44×44 ─────
ขยายเฉพาะอุปกรณ์จอสัมผัส — ครอบปุ่ม toggle/page nav/zoom/tool group ขยายเฉพาะอุปกรณ์จอสัมผัส — ครอบปุ่ม toggle/page nav/zoom/tool group
(รวมปุ่ม dropdown ของ tool)/undo-redo/bookmark/export + ปุ่ม summary (Link) (รวมปุ่ม dropdown ของ tool)/undo-redo/bookmark/export + ปุ่ม summary (Link) */
แต่ไม่โดนปุ่มใน dropdown panel (สี/ความหนา) ที่ซ้อนลึกกว่าอีกชั้น */
@media (pointer: coarse) { @media (pointer: coarse) {
header .touch-44 > button, header .touch-44 > button,
header .touch-44 > div > button, header .touch-44 > div > button,
@ -96,4 +95,122 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
/* UX review 2026-09-14 L2: ปุ่มใน dropdown panel (สี/ความหนา/preset) — swatch 16px
เล็กเกินนิ้ว แต่ 44px ทำ panel บวม จึงใช้ 36px / C1: เมนู dropdown + popup bookmark
ย้ายไป createPortal ที่ document.body แล้ว (ไม่มี .z-50 เหลือใน header) จึง target
ผ่าน [data-tool-menu] ตรง ๆ / ไม่ใส่ display/justify — แถวเมนูเป็น text-left อยู่แล้ว */
[data-tool-menu] button:not(.combo-step),
[data-tool-menu] label {
min-height: 2.25rem;
min-width: 2.25rem;
}
/* NumberCombo (ความหนา/ความทึบ/ขนาด): ▲▼ ซ้อนแนวตั้ง 2 ปุ่ม — ถ้าโดน min 36px
คอลัมน์จะสูง 72px และ input (items-stretch) ถูกดึงบวมตามทั้งแถว — ปุ่ม ▲▼
จึงหลุดจาก rule ด้านบน (combo-step) แล้วตั้งความสูงแถวเองที่ 36px แทน:
แตะ input = เปิด preset list (แถวละ 36px จาก rule ด้านบน) คือ touch path
หลัก ส่วน ▲▼ (ครึ่งแถว) เป็น fine-tune สำรอง — ขนาดใกล้ desktop ไม่บวม */
[data-tool-menu] .number-combo > div {
height: 2.25rem;
}
[data-tool-menu] .combo-step {
min-width: 1.25rem;
}
/* M3: ตัวควบคุมบนหน้ากระดาษเล็กกว่ามาตรฐาน touch มาก (ไม่ได้อยู่ใน header จึงหลุด
จาก rule ด้านบน) — ของที่มองเห็นคงขนาดเดิม ขยายพื้นที่รับ touch แทน:
handle/strip ของกรอบ selection เป็น div ขนาดจาก inline style (10px/8px) แก้
width/height ตรง ๆ ไม่ได้ จึงใช้ ::after แผ่รับ pointer รอบ ๆ (handle 10→hit 28px,
strip 8→hit 16px — มุม render ทีหลังใน DOM จึงชนะบริเวณซ้อนกับ strip = ต้องการ) */
.sel-handle::after {
content: "";
position: absolute;
inset: -9px;
}
.sel-strip::after {
content: "";
position: absolute;
inset: -4px;
}
/* ปุ่ม bookmark/ลบในแผง selection (20×20) + swatch สีในแผง (16×16) */
.sel-panel-btn {
min-height: 2.25rem;
min-width: 2.25rem;
}
.sel-panel .color-swatches button,
.sel-panel .color-swatches label {
min-height: 2rem;
min-width: 2rem;
}
/* M6: ปุ่ม icon ใน sidebar (16/14px ติดกัน — จิ้มพลาดไปปุ่มข้าง ๆ ง่าย โดยเฉพาะ
"ลบ" ชิด "ประวัติ") → อย่างน้อย 32px แบบไม่ต้องขยายตัว icon ที่มองเห็น */
aside[data-sidebar] button.shrink-0 {
min-height: 2rem;
min-width: 2rem;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* M6: ตัด double-tap zoom ของ browser เมื่อแตะปุ่มรัว ๆ (ยัง pinch ได้ตามปกติ —
handle ของกรอบ selection เป็น div + touch-none อยู่แล้ว ไม่โดน rule นี้) */
button,
label {
touch-action: manipulation;
}
/* M6: กัน long-press บน iOS เปิด magnifier/callout ขอคัดลอก — scope เฉพาะ layer
ของ TEXTBOX/STAMP เท่านั้น (textLayer ต้อง select/copy ได้ตามปกติ) */
.no-callout,
.no-callout * {
-webkit-touch-callout: none;
}
/* M6: ปุ่มเล็กใน comment composer/popover/pin (~24px) → 36px ผ่าน class แทนการ
แก้ขนาดจริง (desktop คงเดิม) */
.coarse-36 {
min-height: 2.25rem;
min-width: 2.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* M6: ปุ่มหน้าสรุป (30px) → 44px ตามมาตรฐาน touch */
main.summary-page a {
min-height: 2.75rem;
min-width: 2.75rem;
display: inline-flex;
align-items: center;
}
}
/* UX review 2026-09-14 H2: แอปไม่เคยตั้ง font-family เลย ข้อความไทยเลยตกไปที่
default ของ OS แต่ละเครื่อง — วางฟอนต์ไทยนำหน้า stack (ไม่โหลด webfont
ตามที่เลือกไว้: Leelawadee UI = Windows, Noto Sans Thai = Android/Linux,
Thonburi = macOS) */
body {
font-family: "Leelawadee UI", "Noto Sans Thai", "Thonburi",
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
}
/* M6: touch hygiene — ตัดแถบเทากระพริบของ iOS ทุกครั้งที่แตะปุ่ม และกัน
pull-to-refresh ของ Android ตอนอยู่ ๆ สุดขอบบน (reload = ล้าง undo
history ฝั่ง client ที่ไม่เก็บใน DB) */
html {
-webkit-tap-highlight-color: transparent;
overscroll-behavior-y: contain;
}
/* M6: safe-area — defensive (ค่าเป็น 0 จนกว่าจะใส่ viewport-fit=cover ใน meta
ของ layout — ยังไม่ใส่ตามขอบเขตงานนี้ ใส่ไว้ก่อนเพื่อไม่ต้องแก้ CSS ซ้ำ) */
header {
padding-top: env(safe-area-inset-top);
}
.comment-composer {
padding-bottom: env(safe-area-inset-bottom);
} }

View file

@ -13,7 +13,7 @@ export default function Select({
return ( return (
<div className="relative"> <div className="relative">
<select {...props} className={`appearance-none pr-7 ${className}`} /> <select {...props} className={`appearance-none pr-7 ${className}`} />
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" /> <ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
</div> </div>
); );
} }

View file

@ -6,7 +6,7 @@ import {
useState, useState,
type PointerEvent as ReactPointerEvent, type PointerEvent as ReactPointerEvent,
} from "react"; } from "react";
import { AlignCenter, AlignLeft, AlignRight, BookmarkPlus, Trash2 } from "lucide-react"; import { AlignCenter, AlignLeft, AlignRight, BookmarkPlus, Pencil, Trash2 } from "lucide-react";
import type { import type {
AnnotationData, AnnotationData,
@ -83,6 +83,7 @@ export default function AnnotationSelectionLayer({
onCommit, onCommit,
onDelete, onDelete,
onBookmark, onBookmark,
onStartEdit,
}: { }: {
annotation: AnnotationDTO; annotation: AnnotationDTO;
/** ขนาด px ของหน้า (จาก width/height ที่คำนวณจาก scale) */ /** ขนาด px ของหน้า (จาก width/height ที่คำนวณจาก scale) */
@ -101,6 +102,9 @@ export default function AnnotationSelectionLayer({
onDelete: (id: string) => void; onDelete: (id: string) => void;
/** N2: ปุ่ม bookmark ในแผง — เปิด popup ที่ตำแหน่ง (yOffset %) ของ annotation นี้ */ /** N2: ปุ่ม bookmark ในแผง — เปิด popup ที่ตำแหน่ง (yOffset %) ของ annotation นี้ */
onBookmark: (yOffsetPct: number) => void; onBookmark: (yOffsetPct: number) => void;
/** M1: ปุ่ม "แก้ไขข้อความ" ของ TEXTBOX — เข้าโหมดแก้ไขโดยไม่ต้อง double-click
* (จอสัมผัสไม่มี dblclick ที่เชื่อถือได้: iOS double-tap = zoom หน้า) */
onStartEdit?: (id: string) => void;
}) { }) {
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null); const dragRef = useRef<DragState | null>(null);
@ -401,7 +405,7 @@ export default function AnnotationSelectionLayer({
{/* ขอบกรอบ 4 ด้าน — ลาก = translate ทั้งกลุ่ม (touch-none: จอสัมผัสลาก {/* ขอบกรอบ 4 ด้าน — ลาก = translate ทั้งกลุ่ม (touch-none: จอสัมผัสลาก
ได้จริง ไม่ถูก browser ยึดเป็น scroll แล้วส่ง pointercancel) */} ได้จริง ไม่ถูก browser ยึดเป็น scroll แล้วส่ง pointercancel) */}
<div <div
className="absolute cursor-move touch-none" className="absolute cursor-move touch-none sel-strip"
style={{ style={{
left: boxPx.x - STRIP_PX / 2, left: boxPx.x - STRIP_PX / 2,
top: boxPx.y - STRIP_PX / 2, top: boxPx.y - STRIP_PX / 2,
@ -415,7 +419,7 @@ export default function AnnotationSelectionLayer({
onPointerCancel={cancelDrag} onPointerCancel={cancelDrag}
/> />
<div <div
className="absolute cursor-move touch-none" className="absolute cursor-move touch-none sel-strip"
style={{ style={{
left: boxPx.x - STRIP_PX / 2, left: boxPx.x - STRIP_PX / 2,
top: boxPx.y + boxPx.height - STRIP_PX / 2, top: boxPx.y + boxPx.height - STRIP_PX / 2,
@ -429,7 +433,7 @@ export default function AnnotationSelectionLayer({
onPointerCancel={cancelDrag} onPointerCancel={cancelDrag}
/> />
<div <div
className={`absolute touch-none ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`} className={`absolute touch-none sel-strip ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
style={{ style={{
left: boxPx.x - STRIP_PX / 2, left: boxPx.x - STRIP_PX / 2,
top: boxPx.y, top: boxPx.y,
@ -445,7 +449,7 @@ export default function AnnotationSelectionLayer({
onPointerCancel={cancelDrag} onPointerCancel={cancelDrag}
/> />
<div <div
className={`absolute touch-none ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`} className={`absolute touch-none sel-strip ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
style={{ style={{
left: boxPx.x + boxPx.width - STRIP_PX / 2, left: boxPx.x + boxPx.width - STRIP_PX / 2,
top: boxPx.y, top: boxPx.y,
@ -466,7 +470,7 @@ export default function AnnotationSelectionLayer({
{!isFixedTextbox && corners.map((c) => ( {!isFixedTextbox && corners.map((c) => (
<div <div
key={c.key} key={c.key}
className="absolute touch-none rounded-sm border border-primary bg-primary/50" className="absolute touch-none sel-handle rounded-sm border border-primary bg-primary/50"
style={{ style={{
left: c.x - HANDLE_PX / 2, left: c.x - HANDLE_PX / 2,
top: c.y - HANDLE_PX / 2, top: c.y - HANDLE_PX / 2,
@ -487,13 +491,15 @@ export default function AnnotationSelectionLayer({
{/* แผงเครื่องมือของรายการที่เลือก — ตัวควบคุมเดียวกับ dropdown ของ tool ผู้สร้าง {/* แผงเครื่องมือของรายการที่เลือก — ตัวควบคุมเดียวกับ dropdown ของ tool ผู้สร้าง
(สี/ความหนา/ขนาด/รูปแบบตามชนิด) + ปุ่มลบท้ายแผง — แสดงทุกชนิดรวม HIGHLIGHT (สี/ความหนา/ขนาด/รูปแบบตามชนิด) + ปุ่มลบท้ายแผง — แสดงทุกชนิดรวม HIGHLIGHT
(ไม่มี handle ย้าย/ขยาย แต่เปลี่ยนสี/รูปแบบ/ลบได้) — เฉพาะรายการของตัวเอง */} (ไม่มี handle ย้าย/ขยาย แต่เปลี่ยนสี/รูปแบบ/ลบได้) — เฉพาะรายการของตัวเอง
clamp แนวนอน: รายการชิดขวาหน้า (จอแคบ fit-width) ต้องไม่ทำแผง w-60 ล้นขอบขวา
จนปุ่มลบ/ตัวควบคุมหลุดจอ (เจอตอน verify จอ 390px — ปุ่มลบครึ่งเดียวจิ้มได้ยาก) */}
{editable && !disabled && ( {editable && !disabled && (
<div <div
ref={panelRef} ref={panelRef}
className="absolute w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg" className="absolute w-60 sel-panel rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg"
style={{ style={{
left: boxPx.x, left: Math.max(0, Math.min(boxPx.x, size.width - 240 - 4)),
top: openAbove ? boxPx.y - 6 : boxPx.y + boxPx.height + 4, top: openAbove ? boxPx.y - 6 : boxPx.y + boxPx.height + 4,
transform: openAbove ? "translateY(-100%)" : undefined, transform: openAbove ? "translateY(-100%)" : undefined,
pointerEvents: "auto", pointerEvents: "auto",
@ -578,6 +584,18 @@ export default function AnnotationSelectionLayer({
)} )}
{textboxData && ( {textboxData && (
<> <>
{/* M1: ทางเข้า editor นอกจาก double-click — แผงโผล่เฉพาะของตัวเองอยู่แล้ว
(editable) จึงไม่ต้องเช็ค userId ซ้ำ / root มี stopPropagation คลิกไม่หลุดไป deselect */}
<PanelRow label="ข้อความ">
<button
type="button"
className="flex flex-1 items-center justify-center gap-1 rounded-md border border-slate-300 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-slate-100"
onClick={() => onStartEdit?.(annotation.id)}
>
<Pencil className="h-3.5 w-3.5" />
แก้ไขข้อความ
</button>
</PanelRow>
<PanelRow label="สี"> <PanelRow label="สี">
<ColorSwatches <ColorSwatches
value={textboxData.color} value={textboxData.color}
@ -660,7 +678,7 @@ export default function AnnotationSelectionLayer({
type="button" type="button"
aria-label="บันทึก bookmark ตำแหน่งรายการนี้" aria-label="บันทึก bookmark ตำแหน่งรายการนี้"
title="Bookmark ตำแหน่งนี้" title="Bookmark ตำแหน่งนี้"
className="flex h-5 w-5 items-center justify-center rounded text-slate-400 hover:bg-amber-50 hover:text-amber-500" className="flex h-5 w-5 sel-panel-btn items-center justify-center rounded text-slate-400 hover:bg-amber-50 hover:text-amber-500"
onClick={() => onBookmark(boxPct.y)} onClick={() => onBookmark(boxPct.y)}
> >
<BookmarkPlus className="h-3.5 w-3.5" /> <BookmarkPlus className="h-3.5 w-3.5" />
@ -669,7 +687,7 @@ export default function AnnotationSelectionLayer({
type="button" type="button"
aria-label="ลบรายการนี้" aria-label="ลบรายการนี้"
title="ลบ" title="ลบ"
className="flex h-5 w-5 items-center justify-center rounded text-red-500 hover:bg-red-50" className="flex h-5 w-5 sel-panel-btn items-center justify-center rounded text-red-500 hover:bg-red-50"
onClick={() => onDelete(annotation.id)} onClick={() => onDelete(annotation.id)}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />

View file

@ -2,6 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { import {
useCallback,
useEffect, useEffect,
useMemo, useMemo,
useOptimistic, useOptimistic,
@ -11,6 +12,7 @@ import {
type MouseEvent as ReactMouseEvent, type MouseEvent as ReactMouseEvent,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { createPortal } from "react-dom";
import { import {
AlignCenter, AlignCenter,
AlignLeft, AlignLeft,
@ -42,6 +44,7 @@ import {
Type, Type,
Stamp, Stamp,
Undo2, Undo2,
X,
ZoomIn, ZoomIn,
ZoomOut, ZoomOut,
type LucideIcon, type LucideIcon,
@ -195,6 +198,21 @@ function viewerHeaderHeight(): number {
return document.querySelector("header")?.offsetHeight ?? 0; return document.querySelector("header")?.offsetHeight ?? 0;
} }
/** ความกว้างจริงของเมนู dropdown ของ tool (w-60) กับ popup bookmark (w-72) —
* ใช้ clamp ตำแหน่ง fixed ให้อยู่ในจอ (C1) */
const TOOL_MENU_W = 240;
const BOOKMARK_MENU_W = 288;
/** ตำแหน่ง fixed ของเมนูที่เปิดจากปุ่มนี้ — ตรงใต้ปุ่ม + clamp ไม่ให้ล้นขอบจอ
* (เมนู render ผ่าน createPortal ที่ document.body ออกจาก scroll container ของแถบ tool) */
function fixedMenuPos(rect: DOMRect, menuW: number): { left: number; top: number } {
const vw = window.innerWidth;
return {
left: clamp(rect.left, 8, Math.max(8, vw - menuW - 8)),
top: rect.bottom + 4,
};
}
/** เลื่อนหน้าต่างให้ตำแหน่ง absolute (px จากบนสุดของ document) ไปอยู่ใต้ sticky header /** เลื่อนหน้าต่างให้ตำแหน่ง absolute (px จากบนสุดของ document) ไปอยู่ใต้ sticky header
* (เว้นระยะห่างจาก toolbar 40px ให้รายการไม่ติดแถบเครื่องมือเกินไป) */ * (เว้นระยะห่างจาก toolbar 40px ให้รายการไม่ติดแถบเครื่องมือเกินไป) */
const SCROLL_BELOW_HEADER_GAP = 40; const SCROLL_BELOW_HEADER_GAP = 40;
@ -356,9 +374,9 @@ function selectionTextWithin(kept: DOMRect[], range: Range): string {
const TOOLS: { key: Tool; label: string; Icon: LucideIcon }[] = [ const TOOLS: { key: Tool; label: string; Icon: LucideIcon }[] = [
{ key: "select", label: "เลือกข้อความ", Icon: MousePointer2 }, { key: "select", label: "เลือกข้อความ", Icon: MousePointer2 },
{ key: "pan", label: "เลื่อนหน้า (pan)", Icon: Hand }, { key: "pan", label: "เลื่อนหน้า (pan)", Icon: Hand },
{ key: "highlight", label: "มาร์กข้อความ (highlight)", Icon: Highlighter }, { key: "highlight", label: "มาร์กข้อความ (H)", Icon: Highlighter },
{ key: "draw", label: "วาดเส้น", Icon: Pencil }, { key: "draw", label: "วาดเส้น (D)", Icon: Pencil },
{ key: "comment", label: "วาง comment", Icon: MessageSquarePlus }, { key: "comment", label: "วาง comment (C)", Icon: MessageSquarePlus },
{ key: "shape", label: "รูปทรง", Icon: Shapes }, { key: "shape", label: "รูปทรง", Icon: Shapes },
{ key: "erase", label: "ลบด้วยการลาก (eraser)", Icon: Eraser }, { key: "erase", label: "ลบด้วยการลาก (eraser)", Icon: Eraser },
{ key: "redact", label: "ปิดพราง (redaction)", Icon: EyeOff }, { key: "redact", label: "ปิดพราง (redaction)", Icon: EyeOff },
@ -366,6 +384,13 @@ const TOOLS: { key: Tool; label: string; Icon: LucideIcon }[] = [
{ key: "stamp", label: "ตรายาง (stamp)", Icon: Stamp }, { key: "stamp", label: "ตรายาง (stamp)", Icon: Stamp },
]; ];
/** UX review 2026-09-14 M4: focus ring ตอน keyboard focus — ทั้งโปรเจกต์ไม่เคยมี focus-visible
* ปุ่ม toolbar จึงกลายเป็นกด Tab แล้วไม่รู้ focus อยู่ปุ่มไหน
* ring เต็มสี + ring-offset คั่นขาว 1px ให้เห็นชัดแม้บนปุ่ม active ที่พื้น primary เอง
* (z-10 ให้ ring ลอยเหนือปุ่มข้าง ๆ — flex item ใช้ z-index ได้) */
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";
/** ชนิดที่สร้างเสร็จแล้วเลือกรายการใหม่ให้อัตโนมัติ (กรอบ selection + sidebar ไฮไลต์) /** ชนิดที่สร้างเสร็จแล้วเลือกรายการใหม่ให้อัตโนมัติ (กรอบ selection + sidebar ไฮไลต์)
* ทุกชนิดยกเว้น COMMENT — เปิด composer พิมพ์ก่อนบันทึก และคลิก pin = เลือกอยู่แล้ว */ * ทุกชนิดยกเว้น COMMENT — เปิด composer พิมพ์ก่อนบันทึก และคลิก pin = เลือกอยู่แล้ว */
const AUTO_SELECT_TYPES = new Set<AnnotationType>([ const AUTO_SELECT_TYPES = new Set<AnnotationType>([
@ -580,6 +605,7 @@ function PdfPage({
onShapeEnd, onShapeEnd,
onRedactionEnd, onRedactionEnd,
onErase, onErase,
onTextLayerRendered,
}: { }: {
pdfjs: typeof import("pdfjs-dist"); pdfjs: typeof import("pdfjs-dist");
doc: import("pdfjs-dist").PDFDocumentProxy; doc: import("pdfjs-dist").PDFDocumentProxy;
@ -637,6 +663,9 @@ function PdfPage({
onShapeEnd: (data: ShapeData) => void; onShapeEnd: (data: ShapeData) => void;
onRedactionEnd: (data: RedactionData) => void; onRedactionEnd: (data: RedactionData) => void;
onErase: (id: string) => void; onErase: (id: string) => void;
/** M6: รายงานจำนวน span ของ textLayer หลัง render หน้าเสร็จ (0 = หน้าสแกนไม่มีข้อความ
* — ใช้แสดง hint ว่าไฮไลต์ไม่ได้) identity ต้องคงที่ กัน effect render รันซ้ำ */
onTextLayerRendered?: (pageIndex: number, spanCount: number) => void;
}) { }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
const textLayerRef = useRef<HTMLDivElement | null>(null); const textLayerRef = useRef<HTMLDivElement | null>(null);
@ -683,6 +712,8 @@ function PdfPage({
viewport, viewport,
}); });
await textLayer.render(); await textLayer.render();
// M6: รายงาน span count ของหน้านี้ (0 = ไฟล์สแกนไม่มี text layer — hint ไฮไลต์ไม่ได้)
if (!cancelled) onTextLayerRendered?.(pageNumber - 1, container.querySelectorAll("span").length);
})().catch((err) => { })().catch((err) => {
if (!cancelled) { if (!cancelled) {
console.error(`render หน้า ${pageNumber} ไม่สำเร็จ`, err); console.error(`render หน้า ${pageNumber} ไม่สำเร็จ`, err);
@ -693,6 +724,9 @@ function PdfPage({
cancelled = true; cancelled = true;
renderTask?.cancel(); // กัน memory leak (rules/pdf-viewer.md) renderTask?.cancel(); // กัน memory leak (rules/pdf-viewer.md)
}; };
// onTextLayerRendered ตั้งใจไม่อยู่ใน deps: callback ต้องเป็น useCallback [] เสถียร
// เท่านั้น (handleTextLayerRendered) — ถ้าวันหน้าทำให้ identity เปลี่ยนได้ ต้องย้าย
// ไปเก็บใน ref ก่อน ไม่งั้น effect นี้จะไม่รันซ้ำและค่า span count ของ chip จะเก่า
}, [pdfjs, doc, pageNumber, width, height]); }, [pdfjs, doc, pageNumber, width, height]);
const highlights = annotations.filter(isHighlight); const highlights = annotations.filter(isHighlight);
@ -924,7 +958,7 @@ function PdfPage({
<button <button
type="button" type="button"
aria-label="ดู comment" aria-label="ดู comment"
className={`-translate-x-1/2 -translate-y-full rounded-full border p-1 shadow ${ className={`-translate-x-1/2 -translate-y-full coarse-36 rounded-full border p-1 shadow ${
a.resolved a.resolved
? "border-emerald-300 bg-emerald-50 text-emerald-600 opacity-60 hover:opacity-100" ? "border-emerald-300 bg-emerald-50 text-emerald-600 opacity-60 hover:opacity-100"
: "border-amber-300 bg-amber-100 text-amber-600 hover:bg-amber-200" : "border-amber-300 bg-amber-100 text-amber-600 hover:bg-amber-200"
@ -987,7 +1021,7 @@ function PdfPage({
<div className="mt-1 flex justify-end gap-1.5 text-[11px]"> <div className="mt-1 flex justify-end gap-1.5 text-[11px]">
<button <button
type="button" type="button"
className="rounded px-2 py-0.5 text-slate-500 hover:bg-slate-100" className="coarse-36 rounded px-2 py-0.5 text-slate-500 hover:bg-slate-100"
onClick={() => onStartCommentEdit(null)} onClick={() => onStartCommentEdit(null)}
> >
ยกเลิก ยกเลิก
@ -995,7 +1029,7 @@ function PdfPage({
<button <button
type="button" type="button"
disabled={!commentEditDraft.trim()} disabled={!commentEditDraft.trim()}
className="rounded bg-primary px-2 py-0.5 font-medium text-white disabled:opacity-40" className="coarse-36 rounded bg-primary px-2 py-0.5 font-medium text-white disabled:opacity-40"
onClick={() => { onClick={() => {
const msg = commentEditDraft.trim(); const msg = commentEditDraft.trim();
if (!msg) return; if (!msg) return;
@ -1061,6 +1095,7 @@ function PdfPage({
onCommit={onAnnotationUpdate} onCommit={onAnnotationUpdate}
onDelete={onErase} onDelete={onErase}
onBookmark={(y) => onAnnotationBookmark(selected, y)} onBookmark={(y) => onAnnotationBookmark(selected, y)}
onStartEdit={textbox.startEdit}
/> />
); );
})()} })()}
@ -1112,11 +1147,12 @@ export default function PDFViewer({
const [highlightOpacityPct, setHighlightOpacityPct] = useState(100); const [highlightOpacityPct, setHighlightOpacityPct] = useState(100);
const [highlightColor, setHighlightColor] = useState(HIGHLIGHT_COLORS[0]); const [highlightColor, setHighlightColor] = useState(HIGHLIGHT_COLORS[0]);
const [highlightMenuOpen, setHighlightMenuOpen] = useState(false); const [highlightMenuOpen, setHighlightMenuOpen] = useState(false);
// UX review 2026-09-09 ข้อ 2: เมนู w-60 ของปุ่ม tool ที่อยู่ครึ่งขวาของจอ (หรือ wrap ลงแถวล่าง) // C1: เมนู tool ทั้ง 5 + popup bookmark render ผ่าน createPortal เป็น position:fixed ตาม
// อาจล้นขอบขวา — วัดตำแหน่งปุ่มตอนคลิก แล้วเลือก anchor left-0/right-0 ให้เมนูอยู่ในจอ // พิกัดจริงของปุ่มตอนคลิก (fixedMenuPos clamp ไว้ในจอ) — เดิมเป็น absolute ใน scroll
// (anchor ตายตัวใช้ไม่ได้: ปุ่มเดียวกันอยู่ชิดขวาบนจอกว้าง แต่ชิดซ้ายบนจอแคบที่ wrap) // container ของแถบ tool ที่ต้องสลับ overflow ตอนเปิด ทำให้ scroll position รีเซ็ต เมนู
// ค่าเขียนทับใหม่ทุกครั้งที่เปิดเมนู จึงไม่ต้องเคลียร์ตอนปิด // เปิดนอกจอบนมือถือ และทั้ง app เอียงเลื่อนแนวนอนได้ / ค่าเขียนใหม่ทุกครั้งที่เปิดเมนู
const [menuFlip, setMenuFlip] = useState<Record<string, boolean>>({}); const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null);
const [bookmarkPos, setBookmarkPos] = useState<{ left: number; top: number } | null>(null);
const [scale, setScale] = useState(1); const [scale, setScale] = useState(1);
const [user, setUser] = useState<LocalUser | null>(null); const [user, setUser] = useState<LocalUser | null>(null);
const [currentPage, setCurrentPage] = useState(0); const [currentPage, setCurrentPage] = useState(0);
@ -1244,6 +1280,70 @@ export default function PDFViewer({
// anonymous identity จาก localStorage — ต้องรันใน effect (SSR ไม่มี localStorage) // anonymous identity จาก localStorage — ต้องรันใน effect (SSR ไม่มี localStorage)
useEffect(() => { useEffect(() => {
setUser(getLocalUser()); setUser(getLocalUser());
// UX review 2026-09-14 H1: จอ < lg sidebar เป็น overlay บังเอกสาร — ปิดตอน mount
// ให้เห็นเอกสารทันที (คง initial true ไว้กัน hydration mismatch เหมือนเดิม)
if (!window.matchMedia("(min-width: 1024px)").matches) setSidebarOpen(false);
}, []);
// UX review 2026-09-14 M5: จอ < lg แถบ tool เป็นแถวเดียวเลื่อนแนวนอน — เฝ้าว่าเลื่อน
// ถึงท้ายแถวหรือยัง เพื่อซ่อนแถบเงาขวา (บอกว่ายังมีปุ่มถัดไป) เมื่อเลื่อนสุดแล้ว
const toolbarRowRef = useRef<HTMLDivElement | null>(null);
const [toolbarAtEnd, setToolbarAtEnd] = useState(false);
const syncToolbarEnd = useCallback(() => {
const el = toolbarRowRef.current;
if (el) setToolbarAtEnd(el.scrollLeft + el.clientWidth >= el.scrollWidth - 4);
}, []);
useEffect(() => {
syncToolbarEnd();
window.addEventListener("resize", syncToolbarEnd);
return () => window.removeEventListener("resize", syncToolbarEnd);
}, [syncToolbarEnd]);
// เมนู tool ใด ๆ เปิดอยู่ — C1: เมนูเป็น position:fixed ผ่าน portal แล้ว แถบ tool คง
// overflow-x-auto เสมอไม่ต้องสลับ overflow — เหลือใช้เป็นเงื่อนไข effect ปิดเมนู
// ตอนเลื่อนแถบ/resize จอ (ตำแหน่ง fixed คำนวณตอนเปิด ไม่ตามปุ่มที่เลื่อนไปทีหลัง)
const anyMenuOpen =
highlightMenuOpen ||
drawMenuOpen ||
shapeMenuOpen ||
stampMenuOpen ||
textboxMenuOpen ||
!!bookmarkComposer;
// C1: ปิดเมนูทั้งหมดเมื่อเลื่อนแถบ tool ต่อ หรือ resize/หมุนจอ — ตำแหน่ง fixed จะเพี้ยน
// จากปุ่มทันทีที่แถบเลื่อน (header sticky → เลื่อนหน้าเอกสารไม่กระทบ ปุ่มไม่ขยับเทียบ viewport)
// Major 1 (code review): resize ของมือถือจาก URL bar/คีย์บอร์ดเปลี่ยนแค่ความสูงและเมนูยัง
// ยึด sticky header อยู่กับที่ — จึงปิดจาก resize เฉพาะเมื่อความกว้างจอเปลี่ยนจากตอนเปิดเท่านั้น
useEffect(() => {
if (!anyMenuOpen) return;
const close = () => {
markMenuClosed(); // M4: เลื่อนแถบ/resize จนเมนูปิด — tap ถัดไปใกล้ ๆ ไม่สร้างรายการ
setStampMenuOpen(false);
setShapeMenuOpen(false);
setDrawMenuOpen(false);
setTextboxMenuOpen(false);
setHighlightMenuOpen(false);
setBookmarkComposer(null);
};
const onResize = () => {
if (window.innerWidth !== menuOpenWRef.current) close();
};
toolbarRowRef.current?.addEventListener("scroll", close, { passive: true });
window.addEventListener("resize", onResize);
return () => {
toolbarRowRef.current?.removeEventListener("scroll", close);
window.removeEventListener("resize", onResize);
};
}, [anyMenuOpen]);
// M6: จำนวน span ของ textLayer ต่อหน้า (0 = หน้าสแกนไม่มีข้อความ) — ใช้แสดง hint ตอน
// tool highlight ว่าไฟล์นี้คลุมข้อความไม่ได้ / เขียนค่าเดิมคืน object เดิมให้ callback
// identity คงที่ (useCallback []) กัน effect render ของทุกหน้ารันซ้ำเพราะ prop เปลี่ยน
const [pageSpanCount, setPageSpanCount] = useState<Record<number, number>>({});
const handleTextLayerRendered = useCallback((pageIndex: number, spanCount: number) => {
setPageSpanCount((prev) =>
prev[pageIndex] === spanCount ? prev : { ...prev, [pageIndex]: spanCount },
);
}, []); }, []);
// โหลดเอกสาร: dynamic import ภายใน useEffect เท่านั้น (invariant #6) // โหลดเอกสาร: dynamic import ภายใน useEffect เท่านั้น (invariant #6)
@ -1407,6 +1507,27 @@ export default function PDFViewer({
const drawMenuRef = useRef<HTMLDivElement | null>(null); const drawMenuRef = useRef<HTMLDivElement | null>(null);
const textboxMenuRef = useRef<HTMLDivElement | null>(null); const textboxMenuRef = useRef<HTMLDivElement | null>(null);
const highlightMenuRef = useRef<HTMLDivElement | null>(null); const highlightMenuRef = useRef<HTMLDivElement | null>(null);
// C1: popup bookmark — ref ของปุ่ม 🔖 (คำนวณตำแหน่ง fixed แทนที่จะยึด absolute ใน wrapper)
// + ref ของ wrapper ให้ outside-click รู้จัก "คลิกที่ปุ่ม = ไม่ใช่ข้างนอก"
const bookmarkBtnRef = useRef<HTMLButtonElement | null>(null);
const bookmarkWrapRef = useRef<HTMLDivElement | null>(null);
// M4: เวลาที่เมนู/popup ปิดล่าสุด — tap/คลิกที่ใช้ปิดเมนูยังยิง click ต่อถึงหน้ากระดาษ
// ในลำดับเดียวกัน (mousedown ปิดเมนูก่อน → click ไปถึง page) กลายเป็นวาง comment/
// textbox/stamp โดยไม่ตั้งใจ — handlePageClick จึงเฉย 300ms แรกหลังปิดเมนู
// (pattern เดียวกับ touchHideTimerRef ของปุ่มไฮไลต์จอสัมผัส)
const menuClosedAtRef = useRef(0);
function markMenuClosed() {
menuClosedAtRef.current = Date.now();
}
// Major 1 (code review): ความกว้าง viewport ตอนเปิดเมนูล่าสุด — resize ของมือถือจาก URL bar
// ยุบ/กาง หรือคีย์บอร์ดขึ้น เปลี่ยนแค่ความสูง (เมนู fixed ยึด sticky header ยังอยู่กับที่)
// จะปิดเมนูเฉพาะเมื่อความกว้างเปลี่ยน (หมุนจอ/split view) เท่านั้น
const menuOpenWRef = useRef(0);
/** C1: เปิดเมนู tool ตรงตำแหน่งปุ่ม + จำความกว้างจอตอนเปิด (ใช้แยก resize จากหมุนจอ vs URL bar) */
const openToolMenu = useCallback((btn: HTMLElement) => {
menuOpenWRef.current = window.innerWidth;
setMenuPos(fixedMenuPos(btn.getBoundingClientRect(), TOOL_MENU_W));
}, []);
// tool pan — ลากเลื่อนหน้า: เก็บตำแหน่ง pointer ล่าสุดระหว่างลาก (scrollBy สวนทางการเลื่อน) // tool pan — ลากเลื่อนหน้า: เก็บตำแหน่ง pointer ล่าสุดระหว่างลาก (scrollBy สวนทางการเลื่อน)
const panRef = useRef<{ x: number; y: number } | null>(null); const panRef = useRef<{ x: number; y: number } | null>(null);
const [grabbing, setGrabbing] = useState(false); const [grabbing, setGrabbing] = useState(false);
@ -1417,9 +1538,14 @@ export default function PDFViewer({
{ open: drawMenuOpen, ref: drawMenuRef, close: () => setDrawMenuOpen(false) }, { open: drawMenuOpen, ref: drawMenuRef, close: () => setDrawMenuOpen(false) },
{ open: textboxMenuOpen, ref: textboxMenuRef, close: () => setTextboxMenuOpen(false) }, { open: textboxMenuOpen, ref: textboxMenuRef, close: () => setTextboxMenuOpen(false) },
{ open: highlightMenuOpen, ref: highlightMenuRef, close: () => setHighlightMenuOpen(false) }, { open: highlightMenuOpen, ref: highlightMenuRef, close: () => setHighlightMenuOpen(false) },
{ open: !!bookmarkComposer, ref: bookmarkWrapRef, close: () => setBookmarkComposer(null) },
]; ];
if (!menus.some((m) => m.open)) return; if (!menus.some((m) => m.open)) return;
const onMouseDown = (e: MouseEvent) => { const onMouseDown = (e: MouseEvent) => {
// เมนู render ผ่าน portal ที่ document.body — ref ของ wrapper มองไม่เห็นโหนดใน portal
// ต้องข้ามก่อน ไม่งั้นคลิกในเมนูถูกนับเป็น "ข้างนอก" แล้วเมนูปิดตัวเอง (C1)
if (e.target instanceof Element && e.target.closest("[data-tool-menu]")) return;
markMenuClosed(); // M4: tap นี้ใช้ปิดเมนู — click ที่ตามมาต้องไม่สร้างรายการ
for (const m of menus) { for (const m of menus) {
if (m.open && !m.ref.current?.contains(e.target as Node)) m.close(); if (m.open && !m.ref.current?.contains(e.target as Node)) m.close();
} }
@ -1427,6 +1553,7 @@ export default function PDFViewer({
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === "Escape") {
e.stopPropagation(); e.stopPropagation();
markMenuClosed(); // M4: Escape แล้วแตะหน้าเร็ว ๆ ก็ต้องไม่สร้างรายการ
for (const m of menus) m.close(); for (const m of menus) m.close();
} }
}; };
@ -1436,7 +1563,7 @@ export default function PDFViewer({
document.removeEventListener("mousedown", onMouseDown); document.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("keydown", onKey); document.removeEventListener("keydown", onKey);
}; };
}, [stampMenuOpen, shapeMenuOpen, drawMenuOpen, textboxMenuOpen, highlightMenuOpen]); }, [stampMenuOpen, shapeMenuOpen, drawMenuOpen, textboxMenuOpen, highlightMenuOpen, bookmarkComposer]);
// F2+F3: คีย์ลัดทั้งหมด — H/D/C สลับ tool, B bookmark, Ctrl+Z/Ctrl+Shift+Z undo/redo, // F2+F3: คีย์ลัดทั้งหมด — H/D/C สลับ tool, B bookmark, Ctrl+Z/Ctrl+Shift+Z undo/redo,
// Esc ปิด popup/deselect — ข้ามทุก shortcut เมื่อ focus อยู่ใน input/textarea/ // Esc ปิด popup/deselect — ข้ามทุก shortcut เมื่อ focus อยู่ใน input/textarea/
@ -1778,7 +1905,7 @@ export default function PDFViewer({
/** จอสัมผัส: selection ล่าสุดที่หยุดนิ่งแล้ว (snapshot) + ตำแหน่งปุ่มยืนยัน — /** จอสัมผัส: selection ล่าสุดที่หยุดนิ่งแล้ว (snapshot) + ตำแหน่งปุ่มยืนยัน —
* บน touch ไม่มี mouseup หลังคลุมข้อความด้วยนิ้ว (synthetic mouse events เกิด * บน touch ไม่มี mouseup หลังคลุมข้อความด้วยนิ้ว (synthetic mouse events เกิด
* เฉพาะ tap) จึง commit ผ่านปุ่ม "ไฮไลต์" ลอยใต้ selection แทน — ปุ่มอ่านจาก * เฉพาะ tap) จึง commit ผ่านปุ่ม "ไฮไลต์ข้อความ" ลอยใต้ selection แทน — ปุ่มอ่านจาก
* snapshot ใน ref เพราะ iOS เคลียร์ selection ทันทีที่แตะปุ่ม ก่อน click ยิง */ * snapshot ใน ref เพราะ iOS เคลียร์ selection ทันทีที่แตะปุ่ม ก่อน click ยิง */
const [touchConfirm, setTouchConfirm] = useState<{ const [touchConfirm, setTouchConfirm] = useState<{
pageIndex: number; pageIndex: number;
@ -1807,10 +1934,22 @@ export default function PDFViewer({
return depth; return depth;
} }
/** C1: ตำแหน่ง fixed ของ popup bookmark — จาก rect ของปุ่ม 🔖 (คลิกปุ่ม/ปุ่มในแผง selection/
* คีย์ลัด B เรียกจากไหนก็ยึดปุ่มเดียวกัน เหมือน anchor เดิมที่ absolute ใน wrapper ของปุ่ม) */
const positionBookmarkPopup = useCallback(() => {
menuOpenWRef.current = window.innerWidth;
setBookmarkPos(
bookmarkBtnRef.current
? fixedMenuPos(bookmarkBtnRef.current.getBoundingClientRect(), BOOKMARK_MENU_W)
: { left: Math.max(8, window.innerWidth - BOOKMARK_MENU_W - 8), top: 72 }
);
}, []);
function openBookmarkComposer() { function openBookmarkComposer() {
const pos = currentViewPosition(); const pos = currentViewPosition();
if (!pos) return; if (!pos) return;
setBookmarkError(null); setBookmarkError(null);
positionBookmarkPopup();
// ชื่อ default = ข้อความที่เพิ่งคลุม ไม่มี = "หน้า N" — pre-fill ในช่องเลย (กด Enter ยอมรับได้ทันที) // ชื่อ default = ข้อความที่เพิ่งคลุม ไม่มี = "หน้า N" — pre-fill ในช่องเลย (กด Enter ยอมรับได้ทันที)
setBookmarkComposer({ setBookmarkComposer({
...pos, ...pos,
@ -1830,6 +1969,7 @@ export default function PDFViewer({
a.type === "COMMENT"; a.type === "COMMENT";
const text = meaningful ? normalizeBookmarkTitle(annotationPreviewText(a)) : ""; const text = meaningful ? normalizeBookmarkTitle(annotationPreviewText(a)) : "";
setBookmarkError(null); setBookmarkError(null);
positionBookmarkPopup();
setBookmarkComposer({ setBookmarkComposer({
pageIndex: a.pageIndex, pageIndex: a.pageIndex,
yOffset: clamp(yOffsetPct, 0, 100), yOffset: clamp(yOffsetPct, 0, 100),
@ -1844,11 +1984,15 @@ export default function PDFViewer({
const el = pageWrapRefs.current[currentPage]; const el = pageWrapRefs.current[currentPage];
if (!el) return null; if (!el) return null;
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect();
const centerY = window.scrollY + window.innerHeight / 2; // นิยาม yOffset แบบเดียวกับพิกัด annotation: "จุดจริงบนหน้า" — ใช้ขอบบนของ
// แถบมองเห็นใต้ header (+gap เท่ากับตอนกดผ่าน scrollToBelowHeader) ตอนกด
// bookmark จุดนี้จะกลับมาอยู่ตำแหน่งเดิมพอดี ไม่ใช่กึ่งกลางจอแบบเดิม
const viewTop =
window.scrollY + viewerHeaderHeight() + SCROLL_BELOW_HEADER_GAP;
const pageTop = r.top + window.scrollY; const pageTop = r.top + window.scrollY;
return { return {
pageIndex: currentPage, pageIndex: currentPage,
yOffset: clamp(((centerY - pageTop) / r.height) * 100, 0, 100), yOffset: clamp(((viewTop - pageTop) / r.height) * 100, 0, 100),
}; };
} }
@ -2016,7 +2160,7 @@ export default function PDFViewer({
return; return;
} }
// จอสัมผัส: หลังคลุมข้อความด้วยนิ้วไม่มี mouseup ให้ commit (synthetic mouse // จอสัมผัส: หลังคลุมข้อความด้วยนิ้วไม่มี mouseup ให้ commit (synthetic mouse
// events เกิดเฉพาะ tap) → tool highlight แสดงปุ่ม "ไฮไลต์" ลอยใต้ selection // events เกิดเฉพาะ tap) → tool highlight แสดงปุ่ม "ไฮไลต์ข้อความ" ลอยใต้ selection
// แทนการสร้างตอนปล่อยเมาส์ — เฉพาะอุปกรณ์ที่ pointer หลักเป็น coarse (เมาส์ // แทนการสร้างตอนปล่อยเมาส์ — เฉพาะอุปกรณ์ที่ pointer หลักเป็น coarse (เมาส์
// ยังใช้ mouseup ตามเดิม ไม่โผล่ปุ่มมารบกวน) // ยังใช้ mouseup ตามเดิม ไม่โผล่ปุ่มมารบกวน)
const coarse = window.matchMedia("(pointer: coarse)").matches; const coarse = window.matchMedia("(pointer: coarse)").matches;
@ -2174,7 +2318,7 @@ export default function PDFViewer({
}, [tool]); }, [tool]);
/** สร้าง HIGHLIGHT จาก selection ที่อ่านได้ — ใช้ร่วมกันทั้ง desktop (mouseup บนหน้า) /** สร้าง HIGHLIGHT จาก selection ที่อ่านได้ — ใช้ร่วมกันทั้ง desktop (mouseup บนหน้า)
* และจอสัมผัส (ปุ่ม "ไฮไลต์" ลอยใต้ selection) */ * และจอสัมผัส (ปุ่ม "ไฮไลต์ข้อความ" ลอยใต้ selection) */
function commitHighlight( function commitHighlight(
pageIndex: number, pageIndex: number,
sel: { rects: HighlightData["rects"]; text: string }, sel: { rects: HighlightData["rects"]; text: string },
@ -2190,7 +2334,7 @@ export default function PDFViewer({
}); });
} }
/** จอสัมผัส: แตะปุ่ม "ไฮไลต์" ลอยใต้ selection — commit จาก snapshot ใน ref /** จอสัมผัส: แตะปุ่ม "ไฮไลต์ข้อความ" ลอยใต้ selection — commit จาก snapshot ใน ref
* (ไม่ใช่ selection สด เพราะ iOS เคลียร์ selection ก่อน tap จะถึงปุ่ม — * (ไม่ใช่ selection สด เพราะ iOS เคลียร์ selection ก่อน tap จะถึงปุ่ม —
* จึงผูกกับ onPointerDown ที่ยิงก่อน click ด้วย) */ * จึงผูกกับ onPointerDown ที่ยิงก่อน click ด้วย) */
function handleTouchConfirmHighlight() { function handleTouchConfirmHighlight() {
@ -2282,6 +2426,9 @@ export default function PDFViewer({
// composer/วางตรายาง (คลิกล้วน ๆ selection ถูกยุบตั้งแต่ mousedown จึงไม่โดน guard นี้) // composer/วางตรายาง (คลิกล้วน ๆ selection ถูกยุบตั้งแต่ mousedown จึงไม่โดน guard นี้)
const selection = window.getSelection(); const selection = window.getSelection();
if (selection && !selection.isCollapsed) return; if (selection && !selection.isCollapsed) return;
// M4: tap/คลิกเพื่อ "ปิดเมนู" (outside-click ปิดที่ mousedown ก่อน click ยิงถึงตรงนี้)
// ต้องไม่กลายเป็นการวาง comment/textbox/stamp — คลิกที่สอง (หลัง 300ms) ค่อยวาง
if (Date.now() - menuClosedAtRef.current < 300) return;
if (bookmarkComposer) setBookmarkComposer(null); // คลิกหน้า = ปิด popup bookmark (N2) if (bookmarkComposer) setBookmarkComposer(null); // คลิกหน้า = ปิด popup bookmark (N2)
// คลิกลงหน้า = เลิกเลือก annotation ก่อนทุก tool — แผงเครื่องมือของกรอบ selection // คลิกลงหน้า = เลิกเลือก annotation ก่อนทุก tool — แผงเครื่องมือของกรอบ selection
// ต้องปิดเมื่อคลิกไปสร้างรายการใหม่ (comment/textbox/stamp เดิม return ก่อนถึง // ต้องปิดเมื่อคลิกไปสร้างรายการใหม่ (comment/textbox/stamp เดิม return ก่อนถึง
@ -2349,14 +2496,10 @@ export default function PDFViewer({
if (!el) return; if (!el) return;
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect();
const pageTop = r.top + window.scrollY; const pageTop = r.top + window.scrollY;
// คืนมุมมองตอนบันทึก: จุด yOffset (ซึ่งเก็บจากกึ่งกลางจอตอนสร้าง) กลับไปอยู่ // ดันจุด yOffset กลับมาชิดใต้ sticky header — pattern เดียวกับ goToAnnotation
// กึ่งกลางพื้นที่มองเห็นใต้ header — เดิมวางจุดนี้ไว้ที่ขอบบน viewport เลยเลื่อน // ที่ดันขอบบน bbox (yOffset เก็บจาก "ขอบบนของสิ่งที่ดูอยู่" ตอนสร้าง กดแล้วจึงได้
// เกินลงล่างครึ่งจอ (bookmark หน้าแรกจึงดูเหมือน "ไม่ขึ้นบนสุด") // มุมมองเดิมพอดี ไม่ต้องชดเชยครึ่งจอ)
const center = (viewerHeaderHeight() + window.innerHeight) / 2; scrollToBelowHeader(pageTop + (r.height * b.yOffset) / 100);
window.scrollTo({
top: Math.max(0, pageTop + (r.height * b.yOffset) / 100 - center),
behavior: "smooth",
});
} }
function goToAnnotation(a: AnnotationDTO) { function goToAnnotation(a: AnnotationDTO) {
@ -2437,6 +2580,9 @@ export default function PDFViewer({
function switchTool(t: Tool) { function switchTool(t: Tool) {
setTool(t); setTool(t);
// M4: ปิดเมนูเพราะเปลี่ยน tool — คลิกที่ตามมาต้องไม่กลายเป็นการวางรายการ (เฉพาะเมื่อ
// มีเมนูเปิดจริง: คลิกปุ่ม tool เดี่ยวไม่มีเมนูให้ปิด ไม่ต้อง mark กันกลืนคลิกแรก)
if (anyMenuOpen) markMenuClosed();
setStampMenuOpen(false); setStampMenuOpen(false);
setShapeMenuOpen(false); setShapeMenuOpen(false);
setDrawMenuOpen(false); setDrawMenuOpen(false);
@ -2504,12 +2650,27 @@ export default function PDFViewer({
/ < lg = overlay เลื่อนเข้า-ออก กว้างเป็นสัดส่วนของจอกว้างกว่า 288px / < lg = overlay เลื่อนเข้า-ออก กว้างเป็นสัดส่วนของจอกว้างกว่า 288px
(ปิดบน desktop ใช้ lg:hidden ออกจาก flow → main เต็มจอ) */} (ปิดบน desktop ใช้ lg:hidden ออกจาก flow → main เต็มจอ) */}
<aside <aside
className={`sticky top-0 h-screen w-72 shrink-0 border-r border-slate-200 bg-white max-lg:fixed max-lg:left-0 max-lg:z-50 max-lg:w-[75vw] max-lg:max-w-72 max-lg:transition-transform max-lg:duration-200 ${ data-sidebar=""
className={`sticky top-0 flex h-screen w-72 flex-col shrink-0 border-r border-slate-200 bg-white max-lg:fixed max-lg:left-0 max-lg:z-50 max-lg:w-[75vw] max-lg:max-w-72 max-lg:transition-transform max-lg:duration-200 ${
sidebarOpen sidebarOpen
? "max-lg:translate-x-0" ? "max-lg:translate-x-0"
: "max-lg:pointer-events-none max-lg:-translate-x-full lg:hidden" : "max-lg:pointer-events-none max-lg:-translate-x-full lg:hidden"
}`} }`}
> >
{/* M6: ปิด sidebar บนมือถือได้อีกทาง — overlay (z-50) กลบปุ่ม toggle ใน header
(z-40) ไปด้วย เหลือทางออกเดิมคือจิ้ม backdrop 25% ขวา ซึ่งไม่ชัดเท่าปุ่มปิดตรง ๆ */}
<div className="flex items-center justify-end px-2 pt-2 lg:hidden">
<button
type="button"
aria-label="ปิดแผงข้าง"
className={`flex h-11 w-11 items-center justify-center rounded-lg text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => setSidebarOpen(false)}
>
<X className="h-5 w-5" />
</button>
</div>
{/* min-h-0 flex-1 ให้ root h-full ของ sidebar อยู่ในคอลัมน์ flex โดยไม่ล้น h-screen */}
<div className="min-h-0 flex-1">
<AnnotationSidebar <AnnotationSidebar
annotations={visibleAnnotations} annotations={visibleAnnotations}
bookmarks={optimisticBookmarks} bookmarks={optimisticBookmarks}
@ -2530,6 +2691,7 @@ export default function PDFViewer({
onGoToAnnotation={goToAnnotation} onGoToAnnotation={goToAnnotation}
selectedId={selectedId} selectedId={selectedId}
/> />
</div>
</aside> </aside>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@ -2543,13 +2705,14 @@ export default function PDFViewer({
title="สลับแผงข้าง" title="สลับแผงข้าง"
aria-label={sidebarOpen ? "ซ่อนแผงข้าง" : "แสดงแผงข้าง"} aria-label={sidebarOpen ? "ซ่อนแผงข้าง" : "แสดงแผงข้าง"}
aria-expanded={sidebarOpen} aria-expanded={sidebarOpen}
className="shrink-0 rounded-lg border border-slate-300 px-2 py-1.5 text-slate-500 hover:bg-slate-100" className={`shrink-0 rounded-lg border border-slate-300 px-2 py-2 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => setSidebarOpen((o) => !o)} onClick={() => setSidebarOpen((o) => !o)}
> >
<PanelLeft className="h-4 w-4" /> <PanelLeft className="h-4 w-4" />
</button> </button>
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold"> {/* M6: ซ่อนชื่อไฟล์บนจอเล็ก (แถว title พับ 2 บรรทัดทำ header สูงเกินครึ่งจอ) */}
<h1 className="hidden min-w-0 flex-1 truncate text-sm font-semibold sm:block">
{file.title} {file.title}
</h1> </h1>
@ -2560,7 +2723,7 @@ export default function PDFViewer({
type="button" type="button"
aria-label="หน้าก่อนหน้า" aria-label="หน้าก่อนหน้า"
disabled={currentPage <= 0} disabled={currentPage <= 0}
className="px-2 py-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent" className={`px-2 py-2 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent ${FOCUS_RING_CLS}`}
onClick={() => goToPage(currentPage - 1)} onClick={() => goToPage(currentPage - 1)}
> >
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
@ -2572,7 +2735,7 @@ export default function PDFViewer({
type="button" type="button"
aria-label="หน้าถัดไป" aria-label="หน้าถัดไป"
disabled={currentPage >= pageSizes.length - 1} disabled={currentPage >= pageSizes.length - 1}
className="px-2 py-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent" className={`px-2 py-2 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent ${FOCUS_RING_CLS}`}
onClick={() => goToPage(currentPage + 1)} onClick={() => goToPage(currentPage + 1)}
> >
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
@ -2582,7 +2745,7 @@ export default function PDFViewer({
<button <button
type="button" type="button"
aria-label="ซูมออก" aria-label="ซูมออก"
className="px-2 py-1.5 text-slate-500 hover:bg-slate-100" className={`px-2 py-2 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => setScale((s) => clamp(Math.round((s - 0.25) * 100) / 100, 0.5, 3))} onClick={() => setScale((s) => clamp(Math.round((s - 0.25) * 100) / 100, 0.5, 3))}
> >
<ZoomOut className="h-4 w-4" /> <ZoomOut className="h-4 w-4" />
@ -2593,7 +2756,7 @@ export default function PDFViewer({
<button <button
type="button" type="button"
aria-label="ซูมเข้า" aria-label="ซูมเข้า"
className="px-2 py-1.5 text-slate-500 hover:bg-slate-100" className={`px-2 py-2 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => setScale((s) => clamp(Math.round((s + 0.25) * 100) / 100, 0.5, 3))} onClick={() => setScale((s) => clamp(Math.round((s + 0.25) * 100) / 100, 0.5, 3))}
> >
<ZoomIn className="h-4 w-4" /> <ZoomIn className="h-4 w-4" />
@ -2606,25 +2769,36 @@ export default function PDFViewer({
<Link <Link
href={`/annotate/${file.id}/summary`} href={`/annotate/${file.id}/summary`}
title="หน้าสรุปหมายเหตุ" title="หน้าสรุปหมายเหตุ"
className="rounded-lg border border-slate-300 px-2 py-1.5 text-slate-500 hover:bg-slate-100" className={`rounded-lg border border-slate-300 px-2 py-2 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
> >
<ClipboardList className="h-4 w-4" /> <ClipboardList className="h-4 w-4" />
</Link> </Link>
</div> </div>
</div> </div>
<div className="touch-44 flex flex-wrap items-center gap-2 border-t border-slate-100 px-4 py-2"> {/* UX review 2026-09-14 M5: จอ < lg ทั้งแถวเป็นแถวเดียวเลื่อนแนวนอนได้ (wrap เดิม
ทำ header สูง ~32% ของจอมือถือ) — C1: เมนู dropdown/popup bookmark ย้ายไป
createPortal (position:fixed ที่ document.body) แล้ว แถบจึงเป็น overflow-x-auto
เสมอ ไม่ต้องสลับ overflow ตอนเปิดเมนู (สลับแล้ว scroll position รีเซ็ต เมนูเปิด
นอกจอ และทั้ง app เอียงเลื่อนแนวนอนได้) */}
<div className="relative">
<div
ref={toolbarRowRef}
onScroll={syncToolbarEnd}
className="touch-44 flex flex-wrap items-center gap-2 border-t border-slate-100 px-4 py-2 max-lg:flex-nowrap max-lg:overflow-x-auto"
>
{/* แถบ tool — shape กับ stamp เป็น dropdown เลือกชนิด/preset ตรงที่ปุ่มเอง {/* แถบ tool — shape กับ stamp เป็น dropdown เลือกชนิด/preset ตรงที่ปุ่มเอง
(F5/F18) — container ไม่ใช้ overflow-hidden เพื่อให้เมนูยื่นออกได้ มุมมน (F5/F18) — container ไม่ใช้ overflow-hidden เพื่อให้เมนูยื่นออกได้ มุมมน
ของ active bg จึงจัดที่ปุ่มแรก/สุดท้ายแทน ของ active bg จึงจัดที่ปุ่มแรก/สุดท้ายแทน
UX review 2026-09-09: ปุ่ม wrap ภายในกรอบได้บนจอแคบ */} UX review 2026-09-09: บน desktop ปุ่ม wrap ภายในกรอบได้ / M5 2026-09-14:
<div className="flex flex-wrap rounded-lg border border-slate-300"> จอแคบเป็นแถวเดียว ไหลตามแถบทั้งแถวที่เลื่อนแนวนอน */}
<div className="flex max-lg:shrink-0 flex-wrap gap-0.5 rounded-lg border border-slate-300 max-lg:flex-nowrap">
{TOOLS.map(({ key, label, Icon }, i) => { {TOOLS.map(({ key, label, Icon }, i) => {
const corner = i === 0 ? "rounded-l-lg" : i === TOOLS.length - 1 ? "rounded-r-lg" : ""; const corner = i === 0 ? "rounded-l-lg" : i === TOOLS.length - 1 ? "rounded-r-lg" : "";
const activeCls = tool === key const activeCls = `${
? "bg-primary text-white" tool === key ? "bg-primary text-white" : "text-slate-500 hover:bg-slate-100"
: "text-slate-500 hover:bg-slate-100"; } ${FOCUS_RING_CLS}`;
// Highlight — dropdown เลือกรูปแบบมาร์ก (4 แบบ) + สี; คลุดข้อความแล้วสร้างทันที // Highlight — dropdown เลือกรูปแบบมาร์ก (4 แบบ) + สี; คลุดข้อความแล้วสร้างทันที
if (key === "highlight") { if (key === "highlight") {
@ -2638,10 +2812,9 @@ export default function PDFViewer({
aria-label={label} aria-label={label}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={highlightMenuOpen} aria-expanded={highlightMenuOpen}
className={`flex items-center gap-0.5 px-2 py-1.5 transition ${activeCls}`} className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
onClick={(e) => { onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect(); openToolMenu(e.currentTarget);
setMenuFlip((m) => ({ ...m, highlight: r.left + 248 > window.innerWidth }));
if (tool === "highlight") setHighlightMenuOpen((o) => !o); if (tool === "highlight") setHighlightMenuOpen((o) => !o);
else { else {
switchTool("highlight"); switchTool("highlight");
@ -2652,10 +2825,24 @@ export default function PDFViewer({
<CurrentIcon className="h-4 w-4" /> <CurrentIcon className="h-4 w-4" />
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
</button> </button>
{highlightMenuOpen && ( {highlightMenuOpen &&
menuPos &&
typeof document !== "undefined" &&
createPortal(
<div <div
data-tool-menu=""
aria-label="ตั้งค่าการมาร์กข้อความ" aria-label="ตั้งค่าการมาร์กข้อความ"
className={`absolute ${menuFlip.highlight ? "right-0" : "left-0"} top-full z-50 mt-1 w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg`} className="w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg"
style={{
position: "fixed",
left: menuPos.left,
top: menuPos.top,
// เหนือ aside sidebar/composer (z-50) และ backdrop (z-[45])
zIndex: 60,
// จอเตี้ย (มือถือแนวนอน) เมนูยาวเกินพื้นที่ใต้แถบ → เลื่อนภายในได้
maxHeight: Math.max(160, window.innerHeight - menuPos.top - 8),
overflowY: "auto",
}}
> >
{HIGHLIGHT_STYLE_BTNS.map(({ key: sKey, label: sLabel, Icon: SIcon }) => ( {HIGHLIGHT_STYLE_BTNS.map(({ key: sKey, label: sLabel, Icon: SIcon }) => (
<button <button
@ -2688,7 +2875,8 @@ export default function PDFViewer({
onChange={setHighlightOpacityPct} onChange={setHighlightOpacityPct}
/> />
</PanelRow> </PanelRow>
</div> </div>,
document.body
)} )}
</div> </div>
); );
@ -2705,10 +2893,9 @@ export default function PDFViewer({
aria-label={label} aria-label={label}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={drawMenuOpen} aria-expanded={drawMenuOpen}
className={`flex items-center gap-0.5 px-2 py-1.5 transition ${activeCls}`} className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
onClick={(e) => { onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect(); openToolMenu(e.currentTarget);
setMenuFlip((m) => ({ ...m, draw: r.left + 248 > window.innerWidth }));
if (tool === "draw") setDrawMenuOpen((o) => !o); if (tool === "draw") setDrawMenuOpen((o) => !o);
else { else {
switchTool("draw"); // switchTool ปิดเมนูก่อน แล้วค่อยเปิดทีหลัง switchTool("draw"); // switchTool ปิดเมนูก่อน แล้วค่อยเปิดทีหลัง
@ -2719,10 +2906,22 @@ export default function PDFViewer({
<CurrentPenIcon className="h-4 w-4" /> <CurrentPenIcon className="h-4 w-4" />
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
</button> </button>
{drawMenuOpen && ( {drawMenuOpen &&
menuPos &&
typeof document !== "undefined" &&
createPortal(
<div <div
data-tool-menu=""
aria-label="ตั้งค่าปากกา" aria-label="ตั้งค่าปากกา"
className={`absolute ${menuFlip.draw ? "right-0" : "left-0"} top-full z-50 mt-1 w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg`} className="w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg"
style={{
position: "fixed",
left: menuPos.left,
top: menuPos.top,
zIndex: 60,
maxHeight: Math.max(160, window.innerHeight - menuPos.top - 8),
overflowY: "auto",
}}
> >
{(Object.keys(DRAW_PENS) as DrawPen[]).map((p) => { {(Object.keys(DRAW_PENS) as DrawPen[]).map((p) => {
const PenIcon = DRAW_PEN_ICONS[p]; const PenIcon = DRAW_PEN_ICONS[p];
@ -2778,7 +2977,8 @@ export default function PDFViewer({
<p className="px-1 pt-1.5 text-[10px] leading-tight text-slate-400"> <p className="px-1 pt-1.5 text-[10px] leading-tight text-slate-400">
เมื่อวาดเส้นเกือบตรงแล้วปล่อยเมาส์ ระบบจะ Snap ให้เป็นเส้นตรงอัตโนมัติ — และถ้ากด Shift ค้างจะบังคับให้เส้นตรง เมื่อวาดเส้นเกือบตรงแล้วปล่อยเมาส์ ระบบจะ Snap ให้เป็นเส้นตรงอัตโนมัติ — และถ้ากด Shift ค้างจะบังคับให้เส้นตรง
</p> </p>
</div> </div>,
document.body
)} )}
</div> </div>
); );
@ -2794,10 +2994,9 @@ export default function PDFViewer({
aria-label={label} aria-label={label}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={shapeMenuOpen} aria-expanded={shapeMenuOpen}
className={`flex items-center gap-0.5 px-2 py-1.5 transition ${activeCls}`} className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
onClick={(e) => { onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect(); openToolMenu(e.currentTarget);
setMenuFlip((m) => ({ ...m, shape: r.left + 248 > window.innerWidth }));
if (tool === "shape") setShapeMenuOpen((o) => !o); if (tool === "shape") setShapeMenuOpen((o) => !o);
else { else {
switchTool("shape"); // switchTool ปิดเมนูก่อน แล้วค่อยเปิดทีหลัง switchTool("shape"); // switchTool ปิดเมนูก่อน แล้วค่อยเปิดทีหลัง
@ -2808,10 +3007,22 @@ export default function PDFViewer({
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
</button> </button>
{shapeMenuOpen && ( {shapeMenuOpen &&
menuPos &&
typeof document !== "undefined" &&
createPortal(
<div <div
data-tool-menu=""
aria-label="ตั้งค่ารูปทรง" aria-label="ตั้งค่ารูปทรง"
className={`absolute ${menuFlip.shape ? "right-0" : "left-0"} top-full z-50 mt-1 w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg`} className="w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg"
style={{
position: "fixed",
left: menuPos.left,
top: menuPos.top,
zIndex: 60,
maxHeight: Math.max(160, window.innerHeight - menuPos.top - 8),
overflowY: "auto",
}}
> >
{SHAPE_KIND_BTNS.map(({ key: kind, label: kindLabel, Icon: KindIcon }) => ( {SHAPE_KIND_BTNS.map(({ key: kind, label: kindLabel, Icon: KindIcon }) => (
<button <button
@ -2843,7 +3054,8 @@ export default function PDFViewer({
onChange={(v) => setShapeWidthPct(v / 10)} onChange={(v) => setShapeWidthPct(v / 10)}
/> />
</PanelRow> </PanelRow>
</div> </div>,
document.body
)} )}
</div> </div>
); );
@ -2859,10 +3071,9 @@ export default function PDFViewer({
aria-label={label} aria-label={label}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={stampMenuOpen} aria-expanded={stampMenuOpen}
className={`flex items-center gap-0.5 px-2 py-1.5 transition ${corner} ${activeCls}`} className={`flex items-center gap-0.5 px-2 py-2 transition ${corner} ${activeCls}`}
onClick={(e) => { onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect(); openToolMenu(e.currentTarget);
setMenuFlip((m) => ({ ...m, stamp: r.left + 248 > window.innerWidth }));
if (tool === "stamp") setStampMenuOpen((o) => !o); if (tool === "stamp") setStampMenuOpen((o) => !o);
else { else {
switchTool("stamp"); switchTool("stamp");
@ -2873,10 +3084,22 @@ export default function PDFViewer({
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
</button> </button>
{stampMenuOpen && ( {stampMenuOpen &&
menuPos &&
typeof document !== "undefined" &&
createPortal(
<div <div
data-tool-menu=""
aria-label="ตั้งค่าตรายาง" aria-label="ตั้งค่าตรายาง"
className={`absolute ${menuFlip.stamp ? "right-0" : "left-0"} top-full z-50 mt-1 w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg`} className="w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg"
style={{
position: "fixed",
left: menuPos.left,
top: menuPos.top,
zIndex: 60,
maxHeight: Math.max(160, window.innerHeight - menuPos.top - 8),
overflowY: "auto",
}}
> >
{STAMP_PRESETS.map((preset) => ( {STAMP_PRESETS.map((preset) => (
<button <button
@ -2909,7 +3132,8 @@ export default function PDFViewer({
onChange={setStampScalePct} onChange={setStampScalePct}
/> />
</PanelRow> </PanelRow>
</div> </div>,
document.body
)} )}
</div> </div>
); );
@ -2925,10 +3149,9 @@ export default function PDFViewer({
aria-label={label} aria-label={label}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={textboxMenuOpen} aria-expanded={textboxMenuOpen}
className={`flex items-center gap-0.5 px-2 py-1.5 transition ${activeCls}`} className={`flex items-center gap-0.5 px-2 py-2 transition ${activeCls}`}
onClick={(e) => { onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect(); openToolMenu(e.currentTarget);
setMenuFlip((m) => ({ ...m, textbox: r.left + 248 > window.innerWidth }));
if (tool === "textbox") setTextboxMenuOpen((o) => !o); if (tool === "textbox") setTextboxMenuOpen((o) => !o);
else { else {
switchTool("textbox"); // switchTool ปิดเมนูก่อน แล้วค่อยเปิดทีหลัง switchTool("textbox"); // switchTool ปิดเมนูก่อน แล้วค่อยเปิดทีหลัง
@ -2939,10 +3162,22 @@ export default function PDFViewer({
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
</button> </button>
{textboxMenuOpen && ( {textboxMenuOpen &&
menuPos &&
typeof document !== "undefined" &&
createPortal(
<div <div
data-tool-menu=""
aria-label="ตั้งค่ากล่องข้อความ" aria-label="ตั้งค่ากล่องข้อความ"
className={`absolute ${menuFlip.textbox ? "right-0" : "left-0"} top-full z-50 mt-1 w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg`} className="w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg"
style={{
position: "fixed",
left: menuPos.left,
top: menuPos.top,
zIndex: 60,
maxHeight: Math.max(160, window.innerHeight - menuPos.top - 8),
overflowY: "auto",
}}
> >
<PanelRow label="สี"> <PanelRow label="สี">
<ColorSwatches value={textboxColor} onChange={setTextboxColor} /> <ColorSwatches value={textboxColor} onChange={setTextboxColor} />
@ -3015,7 +3250,8 @@ export default function PDFViewer({
))} ))}
</div> </div>
</PanelRow> </PanelRow>
</div> </div>,
document.body
)} )}
</div> </div>
); );
@ -3027,7 +3263,7 @@ export default function PDFViewer({
type="button" type="button"
title={label} title={label}
aria-label={label} aria-label={label}
className={`px-2 py-1.5 transition ${corner} ${activeCls}`} className={`px-2 py-2 transition ${corner} ${activeCls}`}
onClick={() => switchTool(key)} onClick={() => switchTool(key)}
> >
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
@ -3044,14 +3280,17 @@ export default function PDFViewer({
</p> </p>
)} )}
{/* F2: Undo/Redo — ติดกลุ่มปุ่ม tool ในแถวเดียวกัน, disabled ตาม stack */} {/* F2: Undo/Redo — ติดกลุ่มปุ่ม tool ในแถวเดียวกัน, disabled ตาม stack
<div className="flex items-center rounded-lg border border-slate-300"> M5: จอ < lg สั่ง order-first ให้อยู่หัวแถวเลื่อน — มองเห็นเสมอโดยไม่ต้อง
เลื่อนแถบ (undo คือทางแก้พลาดหลักของจอสัมผัส) จอ ≥ lg ไม่มี order
ลำดับ/หน้าตาเดิมทุกประการ */}
<div className="flex max-lg:order-first max-lg:shrink-0 items-center rounded-lg border border-slate-300">
<button <button
type="button" type="button"
title="Undo (Ctrl+Z)" title="Undo (Ctrl+Z)"
aria-label="ย้อนการกระทำล่าสุด" aria-label="ย้อนการกระทำล่าสุด"
disabled={!canUndo} disabled={!canUndo}
className="px-2 py-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent" className={`px-2 py-2 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent ${FOCUS_RING_CLS}`}
onClick={() => void undoHistory()} onClick={() => void undoHistory()}
> >
<Undo2 className="h-4 w-4" /> <Undo2 className="h-4 w-4" />
@ -3061,7 +3300,7 @@ export default function PDFViewer({
title="Redo (Ctrl+Shift+Z)" title="Redo (Ctrl+Shift+Z)"
aria-label="ทำซ้ำการกระทำ" aria-label="ทำซ้ำการกระทำ"
disabled={!canRedo} disabled={!canRedo}
className="px-2 py-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent" className={`px-2 py-2 text-slate-500 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent ${FOCUS_RING_CLS}`}
onClick={() => void redoHistory()} onClick={() => void redoHistory()}
> >
<Redo2 className="h-4 w-4" /> <Redo2 className="h-4 w-4" />
@ -3070,24 +3309,49 @@ export default function PDFViewer({
{/* N2: ปุ่ม 🔖 เปิด popup บันทึก bookmark — ชื่อ/โน้ต/วางระดับบนสุดหรือใต้หัวข้อ {/* N2: ปุ่ม 🔖 เปิด popup บันทึก bookmark — ชื่อ/โน้ต/วางระดับบนสุดหรือใต้หัวข้อ
(อยู่แถบ tool ชิดขวา — popup เปิดลงด้านล่างทับหน้าเอกสาร) */} (อยู่แถบ tool ชิดขวา — popup เปิดลงด้านล่างทับหน้าเอกสาร) */}
<div className="relative ml-auto"> <div ref={bookmarkWrapRef} className="relative ml-auto max-lg:shrink-0">
<button <button
ref={bookmarkBtnRef}
type="button" type="button"
title={`เพิ่ม bookmark (หน้า ${currentPage + 1})`} title={`เพิ่ม bookmark หน้า ${currentPage + 1} (B)`}
aria-label={`เพิ่ม bookmark หน้า ${currentPage + 1} (B)`}
disabled={!user} disabled={!user}
onClick={openBookmarkComposer} onClick={openBookmarkComposer}
className="rounded-lg border border-slate-300 px-2 py-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40" className={`rounded-lg border border-slate-300 px-2 py-2 text-slate-500 hover:bg-slate-100 disabled:opacity-40 ${FOCUS_RING_CLS}`}
> >
<BookmarkPlus className="h-4 w-4" /> <BookmarkPlus className="h-4 w-4" />
</button> </button>
{bookmarkComposer && ( {bookmarkComposer &&
bookmarkPos &&
typeof document !== "undefined" &&
createPortal(
<div <div
className="absolute right-0 top-full z-50 mt-1.5 w-72 rounded-lg border border-slate-200 bg-white p-3 text-xs shadow-xl" data-tool-menu=""
className="w-72 rounded-lg border border-slate-200 bg-white p-3 text-xs shadow-xl"
style={{
position: "fixed",
left: bookmarkPos.left,
top: bookmarkPos.top,
zIndex: 60,
maxHeight: Math.max(160, window.innerHeight - bookmarkPos.top - 8),
overflowY: "auto",
}}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<p className="text-[11px] font-semibold text-slate-700"> <p className="text-[11px] font-semibold text-slate-700">
บันทึก bookmark — หน้า {bookmarkComposer.pageIndex + 1} บันทึก bookmark — หน้า {bookmarkComposer.pageIndex + 1}
</p> </p>
{/* บอกจุดที่ bookmark จะชี้ (เก็บจากตำแหน่งที่กำลังดูอยู่ตอนเปิด popup)
กันความเข้าใจผิดว่าชี้ที่หัวข้อที่ตั้งชื่อไว้ */}
<p className="mt-0.5 text-[11px] text-slate-400">
จะชี้ที่: หน้า {bookmarkComposer.pageIndex + 1} ·{" "}
{bookmarkComposer.yOffset <= 25
? "ส่วนบน"
: bookmarkComposer.yOffset <= 75
? "ราวกลาง"
: "ส่วนล่าง"}{" "}
ของหน้า
</p>
<input <input
autoFocus autoFocus
onFocus={(e) => e.target.select()} // เลือกข้อความ default ทั้งหมด — พิมพ์ทับ/Enter ยอมรับได้ทันที onFocus={(e) => e.target.select()} // เลือกข้อความ default ทั้งหมด — พิมพ์ทับ/Enter ยอมรับได้ทันที
@ -3162,15 +3426,17 @@ export default function PDFViewer({
บันทึก บันทึก
</button> </button>
</div> </div>
</div> </div>,
document.body
)} )}
</div> </div>
{/* F8/C1: export PDF ฝัง annotation (redaction ติดไปเสมอ — D4) */} {/* F8/C1: export PDF ฝัง annotation (redaction ติดไปเสมอ — D4) */}
<button <button
type="button" type="button"
title="Export PDF พร้อม annotation" title="Export PDF พร้อม annotation (เปิดแท็บใหม่)"
className="rounded-lg border border-slate-300 px-2 py-1.5 text-slate-500 hover:bg-slate-100" aria-label="Export PDF พร้อม annotation (เปิดแท็บใหม่)"
className={`max-lg:shrink-0 rounded-lg border border-slate-300 px-2 py-2 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => onClick={() =>
window.open(`/api/annotations/export?fileId=${file.id}`, "_blank") window.open(`/api/annotations/export?fileId=${file.id}`, "_blank")
} }
@ -3178,6 +3444,11 @@ export default function PDFViewer({
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
</button> </button>
</div> </div>
{/* M5: เงาขวาบอกว่าเลื่อนต่อได้ — หายเมื่อถึงท้ายแถบ (จอ ≥ lg ไม่มีการ scroll) */}
{!toolbarAtEnd && (
<div className="pointer-events-none absolute inset-y-0 right-0 w-6 bg-gradient-to-r from-transparent to-white lg:hidden" />
)}
</div>
</header> </header>
<main <main
@ -3214,6 +3485,16 @@ export default function PDFViewer({
setGrabbing(false); setGrabbing(false);
}} }}
> >
{/* M6: hint ไฟล์สแกน — ทุกหน้าที่ render แล้วไม่มี span เลย (virtualization เห็นเฉพาะ
หน้าใกล้ viewport — ไฟล์ผสมหน้าสแกน/หน้า text จึงพูดแบบระดับ "หน้าที่เปิดดู" เท่านั้น)
โชว์เฉพาะตอน tool highlight เพื่อไม่รบกวน tool อื่น — คดีศาลมี PDF สแกนเยอะ */}
{tool === "highlight" &&
Object.keys(pageSpanCount).length > 0 &&
Object.values(pageSpanCount).every((n) => n === 0) && (
<p className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
หน้าที่เปิดดูเป็นภาพสแกน ไม่มีข้อความให้คลุมไฮไลต์ — ใช้การวาดเส้นหรือกล่องข้อความแทนได้
</p>
)}
{pageSizes.length === 0 ? ( {pageSizes.length === 0 ? (
<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-400">
กำลังโหลดเอกสาร… กำลังโหลดเอกสาร…
@ -3330,6 +3611,7 @@ export default function PDFViewer({
if (!user) return; if (!user) return;
void handleCreateAnnotation(i, "REDACTION", data); void handleCreateAnnotation(i, "REDACTION", data);
}} }}
onTextLayerRendered={handleTextLayerRendered}
onErase={(id) => void handleDeleteAnnotation(id)} onErase={(id) => void handleDeleteAnnotation(id)}
/> />
) : null} ) : null}
@ -3391,7 +3673,7 @@ export default function PDFViewer({
className="h-3.5 w-3.5 rounded-[2px]" className="h-3.5 w-3.5 rounded-[2px]"
style={{ backgroundColor: highlightColor }} style={{ backgroundColor: highlightColor }}
/> />
ไฮไลต์ ไฮไลต์ข้อความ
</button> </button>
</div> </div>
)} )}
@ -3404,7 +3686,7 @@ export default function PDFViewer({
{composer && ( {composer && (
<div <div
className="fixed z-50 w-72 rounded-lg border border-slate-200 bg-white p-3 shadow-xl" className="comment-composer fixed z-50 w-72 rounded-lg border border-slate-200 bg-white p-3 shadow-xl"
style={{ style={{
left: clamp(composer.x, 8, Math.max(8, window.innerWidth - 304)), left: clamp(composer.x, 8, Math.max(8, window.innerWidth - 304)),
top: clamp(composer.y, 8, Math.max(8, window.innerHeight - 180)), top: clamp(composer.y, 8, Math.max(8, window.innerHeight - 180)),
@ -3427,7 +3709,7 @@ export default function PDFViewer({
<div className="mt-2 flex justify-end gap-2 text-xs"> <div className="mt-2 flex justify-end gap-2 text-xs">
<button <button
type="button" type="button"
className="rounded px-2 py-1 text-slate-500 hover:bg-slate-100" className="coarse-36 rounded px-2 py-1 text-slate-500 hover:bg-slate-100"
onClick={() => setComposer(null)} onClick={() => setComposer(null)}
> >
ยกเลิก ยกเลิก
@ -3435,7 +3717,7 @@ export default function PDFViewer({
<button <button
type="button" type="button"
disabled={!draft.trim()} disabled={!draft.trim()}
className="rounded bg-primary px-2 py-1 font-medium text-white disabled:opacity-40" className="coarse-36 rounded bg-primary px-2 py-1 font-medium text-white disabled:opacity-40"
onClick={() => void handleComposerSave()} onClick={() => void handleComposerSave()}
> >
บันทึก บันทึก

View file

@ -244,7 +244,7 @@ export default function TextboxStampLayer({
onSelect: (id: string) => void; onSelect: (id: string) => void;
}) { }) {
return ( return (
<div className="pointer-events-none absolute inset-0 z-[4]"> <div className="no-callout pointer-events-none absolute inset-0 z-[4]">
{textboxes.map((t) => {textboxes.map((t) =>
editingId === t.id ? ( editingId === t.id ? (
<TextboxEditor <TextboxEditor

View file

@ -27,7 +27,7 @@ export const INK_COLORS = [
export function PanelRow({ label, children }: { label: string; children: ReactNode }) { export function PanelRow({ label, children }: { label: string; children: ReactNode }) {
return ( return (
<div className="flex items-start gap-2 px-1 py-1"> <div className="flex items-start gap-2 px-1 py-1">
<span className="w-11 shrink-0 pt-1 text-[10px] font-medium text-slate-400">{label}</span> <span className="w-11 shrink-0 pt-1 text-[11px] font-medium text-slate-500">{label}</span>
{children} {children}
</div> </div>
); );
@ -36,7 +36,7 @@ export function PanelRow({ label, children }: { label: string; children: ReactNo
/** แถวปุ่มสีวงกลม preset + ปุ่มสีรุ้ง (color picker เลือกสีอื่นได้ไม่จำกัด) */ /** แถวปุ่มสีวงกลม preset + ปุ่มสีรุ้ง (color picker เลือกสีอื่นได้ไม่จำกัด) */
export function ColorSwatches({ value, onChange }: { value: string; onChange: (c: string) => void }) { export function ColorSwatches({ value, onChange }: { value: string; onChange: (c: string) => void }) {
return ( return (
<div className="flex flex-1 flex-wrap items-center gap-1.5"> <div className="color-swatches flex flex-1 flex-wrap items-center gap-1.5">
{INK_COLORS.map((c) => ( {INK_COLORS.map((c) => (
<button <button
key={c} key={c}
@ -155,7 +155,7 @@ export function NumberCombo({
return ( return (
<div <div
ref={rootRef} ref={rootRef}
className="relative" className="number-combo relative"
onKeyDown={(e) => { onKeyDown={(e) => {
// Esc ปิดแค่ dropdown นี้ก่อน (stopPropagation ไม่ให้ปิดทั้งเมนู/กรอบ) // Esc ปิดแค่ dropdown นี้ก่อน (stopPropagation ไม่ให้ปิดทั้งเมนู/กรอบ)
if (e.key === "Escape" && open) { if (e.key === "Escape" && open) {
@ -181,26 +181,28 @@ export function NumberCombo({
if (e.key !== "Escape") e.stopPropagation(); // พิมพ์ในช่องต้องไม่ชนคีย์ลัด F3 (Esc ปล่อยไหลไปปิดเมนู) if (e.key !== "Escape") e.stopPropagation(); // พิมพ์ในช่องต้องไม่ชนคีย์ลัด F3 (Esc ปล่อยไหลไปปิดเมนู)
if (e.key === "Enter") commitDraft(); // ยืนยันครั้งเดียวตรงนี้ ไม่ต้องรอ blur if (e.key === "Enter") commitDraft(); // ยืนยันครั้งเดียวตรงนี้ ไม่ต้องรอ blur
}} }}
className="w-14 border-0 px-1.5 py-1 text-[11px] text-slate-700 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-slate-400 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" className="w-14 border-0 px-1.5 py-1.5 text-[11px] text-slate-700 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-slate-400 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/> />
<div className="flex flex-col border-l border-slate-300"> <div className="flex flex-col border-l border-slate-300">
{/* combo-step: ปุ่ม ▲▼ ซ้อนแนวตั้ง 2 ชั้น — ต้องหลุดจาก rule touch 36px
ของ dropdown (globals.css) ไม่งั้นคอลัมน์สูง 72px ดึงทั้งแถวบวมตาม */}
<button <button
type="button" type="button"
aria-label="เพิ่มค่า" aria-label="เพิ่มค่า"
onMouseDown={(e) => e.preventDefault()} // ไม่แย่ง focus จาก input (dropdown ไม่กระพริบ) onMouseDown={(e) => e.preventDefault()} // ไม่แย่ง focus จาก input (dropdown ไม่กระพริบ)
className="flex flex-1 items-center justify-center px-1.5 text-slate-500 hover:bg-slate-100" className="combo-step flex flex-1 items-center justify-center px-1.5 text-slate-500 hover:bg-slate-100"
onClick={() => stepValue(1)} onClick={() => stepValue(1)}
> >
<ChevronUp className="h-2.5 w-2.5" /> <ChevronUp className="h-3 w-3" />
</button> </button>
<button <button
type="button" type="button"
aria-label="ลดค่า" aria-label="ลดค่า"
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
className="flex flex-1 items-center justify-center border-t border-slate-300 px-1.5 text-slate-500 hover:bg-slate-100" className="combo-step flex flex-1 items-center justify-center border-t border-slate-300 px-1.5 text-slate-500 hover:bg-slate-100"
onClick={() => stepValue(-1)} onClick={() => stepValue(-1)}
> >
<ChevronDown className="h-2.5 w-2.5" /> <ChevronDown className="h-3 w-3" />
</button> </button>
</div> </div>
</div> </div>
@ -260,7 +262,7 @@ export const HIGHLIGHT_STYLE_BTNS: {
label: string; label: string;
Icon: ComponentType<{ className?: string }>; Icon: ComponentType<{ className?: string }>;
}[] = [ }[] = [
{ key: "highlight", label: "ไฮไลต์", Icon: Highlighter }, { key: "highlight", label: "ไฮไลต์ข้อความ", Icon: Highlighter },
{ key: "underline", label: "เส้นใต้", Icon: Underline }, { key: "underline", label: "เส้นใต้", Icon: Underline },
{ key: "strikethrough", label: "ขีดฆ่า", Icon: Strikethrough }, { key: "strikethrough", label: "ขีดฆ่า", Icon: Strikethrough },
{ key: "squiggle", label: "ขยุกขยิก", Icon: SquiggleIcon }, { key: "squiggle", label: "ขยุกขยิก", Icon: SquiggleIcon },

View file

@ -49,7 +49,7 @@ import type {
import { annotationNote, annotationPreviewText } from "@/utils/annotation-display"; import { annotationNote, annotationPreviewText } from "@/utils/annotation-display";
function TypeIcon({ type, data }: { type: AnnotationDTO["type"]; data: AnnotationDTO["data"] }) { function TypeIcon({ type, data }: { type: AnnotationDTO["type"]; data: AnnotationDTO["data"] }) {
const cls = "h-4 w-4 shrink-0 text-slate-400"; const cls = "h-4 w-4 shrink-0 text-slate-500";
if (type === "HIGHLIGHT") { if (type === "HIGHLIGHT") {
const style = (data as HighlightData).style ?? "highlight"; const style = (data as HighlightData).style ?? "highlight";
if (style === "underline") return <Underline className={cls} />; if (style === "underline") return <Underline className={cls} />;
@ -376,7 +376,7 @@ export default function AnnotationSidebar({
placeholder="โน้ต (ไม่บังคับ)" placeholder="โน้ต (ไม่บังคับ)"
className="mt-1 w-full resize-none rounded border border-slate-300 p-1.5 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400" className="mt-1 w-full resize-none rounded border border-slate-300 p-1.5 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
/> />
<p className="mt-1 text-[10px] text-slate-400">วางที่</p> <p className="mt-1 text-[11px] text-slate-500">วางที่</p>
<Select <Select
value={eb.parentId ?? ""} value={eb.parentId ?? ""}
onChange={(e) => onChange={(e) =>
@ -415,7 +415,7 @@ export default function AnnotationSidebar({
<button <button
type="button" type="button"
aria-label={collapsed ? "กางหัวข้อ" : "หุบหัวข้อ"} aria-label={collapsed ? "กางหัวข้อ" : "หุบหัวข้อ"}
className="shrink-0 text-slate-400 hover:text-slate-600" className="shrink-0 text-slate-500 hover:text-slate-600"
onClick={() => toggleCollapsed(b.id)} onClick={() => toggleCollapsed(b.id)}
> >
{collapsed ? ( {collapsed ? (
@ -445,10 +445,10 @@ export default function AnnotationSidebar({
> >
{b.title} {b.title}
{hasChildren && ( {hasChildren && (
<span className="ml-1 font-normal text-slate-400">({children.length})</span> <span className="ml-1 font-normal text-slate-500">({children.length})</span>
)} )}
</span> </span>
<span className="block text-[10px] text-slate-400">หน้า {b.pageIndex + 1}</span> <span className="block text-[11px] text-slate-500">หน้า {b.pageIndex + 1}</span>
{b.note && ( {b.note && (
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-slate-500"> <span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-slate-500">
{b.note} {b.note}
@ -462,7 +462,7 @@ export default function AnnotationSidebar({
type="button" type="button"
aria-label="แก้ไข bookmark" aria-label="แก้ไข bookmark"
title="แก้ชื่อ/โน้ต/ย้ายหัวข้อ" title="แก้ชื่อ/โน้ต/ย้ายหัวข้อ"
className="shrink-0 text-slate-300 hover:text-slate-500 group-hover:text-slate-400" className="shrink-0 text-slate-400 hover:text-slate-600 group-hover:text-slate-500"
onClick={() => startBookmarkEdit(b)} onClick={() => startBookmarkEdit(b)}
> >
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
@ -470,7 +470,7 @@ export default function AnnotationSidebar({
<button <button
type="button" type="button"
aria-label="ลบ bookmark" aria-label="ลบ bookmark"
className="shrink-0 text-slate-300 hover:text-red-500 group-hover:text-slate-400" className="shrink-0 text-slate-400 hover:text-red-500 group-hover:text-slate-500"
onClick={() => onDeleteBookmark(b.id)} onClick={() => onDeleteBookmark(b.id)}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
@ -483,7 +483,7 @@ export default function AnnotationSidebar({
type="button" type="button"
aria-label="เพิ่มหน้าที่ดูอยู่เป็น sub ของหัวข้อนี้" aria-label="เพิ่มหน้าที่ดูอยู่เป็น sub ของหัวข้อนี้"
title="เพิ่มหน้าที่ดูอยู่เป็น sub" title="เพิ่มหน้าที่ดูอยู่เป็น sub"
className="shrink-0 text-slate-300 hover:text-emerald-600 group-hover:text-slate-400" className="shrink-0 text-slate-400 hover:text-emerald-600 group-hover:text-slate-500"
onClick={() => onAddSubBookmark(b.id)} onClick={() => onAddSubBookmark(b.id)}
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
@ -526,7 +526,7 @@ export default function AnnotationSidebar({
<TypeIcon type={a.type} data={a.data} /> <TypeIcon type={a.type} data={a.data} />
<span className="min-w-0"> <span className="min-w-0">
<span className="block truncate text-xs">{annotationPreviewText(a)}</span> <span className="block truncate text-xs">{annotationPreviewText(a)}</span>
<span className="block text-[11px] text-slate-400"> <span className="block text-[11px] text-slate-500">
หน้า {a.pageIndex + 1} · {a.userName} หน้า {a.pageIndex + 1} · {a.userName}
{a.type === "COMMENT" && (replyCounts[a.id] ?? 0) > 0 {a.type === "COMMENT" && (replyCounts[a.id] ?? 0) > 0
? ` · ตอบ ${replyCounts[a.id]}` ? ` · ตอบ ${replyCounts[a.id]}`
@ -544,7 +544,7 @@ export default function AnnotationSidebar({
? "text-emerald-500" ? "text-emerald-500"
: copyFailedId === a.id : copyFailedId === a.id
? "text-red-500" ? "text-red-500"
: "text-slate-300 hover:text-slate-500 group-hover:text-slate-400" : "text-slate-400 hover:text-slate-600 group-hover:text-slate-500"
}`} }`}
onClick={() => copyAnnotationLink(a)} onClick={() => copyAnnotationLink(a)}
> >
@ -567,7 +567,7 @@ export default function AnnotationSidebar({
className={`shrink-0 ${ className={`shrink-0 ${
note note
? "text-amber-400 hover:text-amber-500" ? "text-amber-400 hover:text-amber-500"
: "text-slate-300 hover:text-slate-500 group-hover:text-slate-400" : "text-slate-400 hover:text-slate-600 group-hover:text-slate-500"
}`} }`}
onClick={() => (editingNote ? setNoteFor(null) : startNoteEdit(a))} onClick={() => (editingNote ? setNoteFor(null) : startNoteEdit(a))}
> >
@ -579,7 +579,7 @@ export default function AnnotationSidebar({
type="button" type="button"
aria-label="ดูประวัติการเปลี่ยนแปลง" aria-label="ดูประวัติการเปลี่ยนแปลง"
title="ประวัติ" title="ประวัติ"
className="shrink-0 text-slate-300 hover:text-slate-500 group-hover:text-slate-400" className="shrink-0 text-slate-400 hover:text-slate-600 group-hover:text-slate-500"
onClick={() => void openHistory(a)} onClick={() => void openHistory(a)}
> >
<Clock className="h-4 w-4" /> <Clock className="h-4 w-4" />
@ -587,7 +587,7 @@ export default function AnnotationSidebar({
<button <button
type="button" type="button"
aria-label="ลบ annotation" aria-label="ลบ annotation"
className="shrink-0 text-slate-300 hover:text-red-500 group-hover:text-slate-400" className="shrink-0 text-slate-400 hover:text-red-500 group-hover:text-slate-500"
onClick={() => onDeleteAnnotation(a.id)} onClick={() => onDeleteAnnotation(a.id)}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
@ -665,7 +665,7 @@ export default function AnnotationSidebar({
onSubmit={(e) => void runSearch(e)} onSubmit={(e) => void runSearch(e)}
> >
<div className="relative min-w-0 flex-1"> <div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" /> <Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
<input <input
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
@ -685,13 +685,13 @@ export default function AnnotationSidebar({
{searchResults !== null && ( {searchResults !== null && (
<div className="shrink-0 border-b border-slate-200 p-2"> <div className="shrink-0 border-b border-slate-200 p-2">
<p className="px-1 pb-1 text-[10px] font-semibold uppercase tracking-wide text-slate-400"> <p className="px-1 pb-1 text-[11px] font-semibold text-slate-500">
{searchResults.length > 0 {searchResults.length > 0
? `ผลการค้นหา (${searchResults.length})` ? `ผลการค้นหา (${searchResults.length})`
: "ผลการค้นหา"} : "ผลการค้นหา"}
</p> </p>
{searchResults.length === 0 ? ( {searchResults.length === 0 ? (
<p className="px-1 pb-1 text-xs text-slate-400">ไม่พบ</p> <p className="px-1 pb-1 text-xs text-slate-500">ไม่พบ</p>
) : ( ) : (
<ul className="max-h-40 space-y-1 overflow-y-auto"> <ul className="max-h-40 space-y-1 overflow-y-auto">
{searchResults.map((a) => ( {searchResults.map((a) => (
@ -708,7 +708,7 @@ export default function AnnotationSidebar({
<TypeIcon type={a.type} data={a.data} /> <TypeIcon type={a.type} data={a.data} />
<span className="min-w-0"> <span className="min-w-0">
<span className="block truncate text-xs">{annotationPreviewText(a)}</span> <span className="block truncate text-xs">{annotationPreviewText(a)}</span>
<span className="block text-[11px] text-slate-400"> <span className="block text-[11px] text-slate-500">
หน้า {a.pageIndex + 1} · {a.userName} หน้า {a.pageIndex + 1} · {a.userName}
</span> </span>
</span> </span>
@ -723,8 +723,8 @@ export default function AnnotationSidebar({
<div className="flex shrink-0 border-b border-slate-200"> <div className="flex shrink-0 border-b border-slate-200">
{( {(
[ [
["bookmarks", `Bookmarks (${bookmarks.length})`], ["bookmarks", `ที่คั่นหน้า (${bookmarks.length})`],
["annotations", `Annotations (${annotations.length})`], ["annotations", `หมายเหตุ (${annotations.length})`],
] as const ] as const
).map(([key, label]) => ( ).map(([key, label]) => (
<button <button
@ -733,7 +733,7 @@ export default function AnnotationSidebar({
className={`flex-1 px-2 py-2 text-xs font-medium transition ${ className={`flex-1 px-2 py-2 text-xs font-medium transition ${
tab === key tab === key
? "border-b-2 border-primary text-slate-900" ? "border-b-2 border-primary text-slate-900"
: "text-slate-400 hover:text-slate-600" : "text-slate-500 hover:text-slate-600"
}`} }`}
onClick={() => setTab(key)} onClick={() => setTab(key)}
> >
@ -745,7 +745,7 @@ export default function AnnotationSidebar({
<div ref={listRef} className="flex-1 overflow-y-auto p-2"> <div ref={listRef} className="flex-1 overflow-y-auto p-2">
{tab === "bookmarks" ? ( {tab === "bookmarks" ? (
bookmarks.length === 0 ? ( bookmarks.length === 0 ? (
<p className="p-4 text-center text-xs text-slate-400"> <p className="p-4 text-center text-xs text-slate-500">
ยังไม่มี bookmark — กดปุ่ม bookmark บน toolbar เพื่อเพิ่ม ยังไม่มี bookmark — กดปุ่ม bookmark บน toolbar เพื่อเพิ่ม
</p> </p>
) : ( ) : (
@ -757,14 +757,17 @@ export default function AnnotationSidebar({
</ul> </ul>
) )
) : annotations.length === 0 ? ( ) : annotations.length === 0 ? (
<p className="p-4 text-center text-xs text-slate-400"> <p className="p-4 text-center text-xs text-slate-500">
ยังไม่มี annotation — คลุมดำ / วาดเส้น / คลิกวาง comment บนหน้าเอกสาร ยังไม่มี annotation — คลุมข้อความ / วาดเส้น / คลิกวาง comment บนหน้าเอกสาร
<span className="mt-1 block text-[11px]">
คีย์ลัด: H มาร์ก · D วาด · C comment · B คั่นหน้า · Ctrl+Z ย้อน
</span>
</p> </p>
) : ( ) : (
<> <>
{pendingItems.length > 0 && ( {pendingItems.length > 0 && (
<section> <section>
<p className="px-2 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-wide text-slate-400"> <p className="px-2 pb-1 pt-2 text-[11px] font-semibold text-slate-500">
ค้างดำเนินการ ค้างดำเนินการ
</p> </p>
<ul className="space-y-1">{pendingItems.map(renderAnnotationRow)}</ul> <ul className="space-y-1">{pendingItems.map(renderAnnotationRow)}</ul>
@ -772,7 +775,7 @@ export default function AnnotationSidebar({
)} )}
{resolvedItems.length > 0 && ( {resolvedItems.length > 0 && (
<section> <section>
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wide text-emerald-500"> <p className="px-2 pb-1 pt-3 text-[11px] font-semibold text-emerald-500">
จัดการแล้ว จัดการแล้ว
</p> </p>
<ul className="space-y-1">{resolvedItems.map(renderAnnotationRow)}</ul> <ul className="space-y-1">{resolvedItems.map(renderAnnotationRow)}</ul>
@ -794,7 +797,7 @@ export default function AnnotationSidebar({
<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">
<div className="w-80 rounded-lg border border-slate-200 bg-white p-4 shadow-xl"> <div className="w-80 rounded-lg border border-slate-200 bg-white p-4 shadow-xl">
<p className="text-sm font-semibold">ประวัติการเปลี่ยนแปลง</p> <p className="text-sm font-semibold">ประวัติการเปลี่ยนแปลง</p>
<p className="mt-0.5 truncate text-[11px] text-slate-400"> <p className="mt-0.5 truncate text-[11px] text-slate-500">
{annotationPreviewText(historyFor)} {annotationPreviewText(historyFor)}
</p> </p>
{historyError && ( {historyError && (
@ -803,10 +806,10 @@ export default function AnnotationSidebar({
</p> </p>
)} )}
{versions === null && !historyError && ( {versions === null && !historyError && (
<p className="mt-3 text-xs text-slate-400">กำลังโหลด…</p> <p className="mt-3 text-xs text-slate-500">กำลังโหลด…</p>
)} )}
{versions !== null && versions.length === 0 && ( {versions !== null && versions.length === 0 && (
<p className="mt-3 text-xs text-slate-400"> <p className="mt-3 text-xs text-slate-500">
ยังไม่มีบันทึกการเปลี่ยนแปลง (เกิดขึ้นเมื่อแก้ไขหรือลบรายการนี้) ยังไม่มีบันทึกการเปลี่ยนแปลง (เกิดขึ้นเมื่อแก้ไขหรือลบรายการนี้)
</p> </p>
)} )}
@ -818,7 +821,7 @@ export default function AnnotationSidebar({
<span className="block text-xs font-medium"> <span className="block text-xs font-medium">
{v.kind === "delete" ? "ถูกลบ" : "ถูกแก้ไข"} {v.kind === "delete" ? "ถูกลบ" : "ถูกแก้ไข"}
</span> </span>
<span className="block text-[11px] text-slate-400" suppressHydrationWarning> <span className="block text-[11px] text-slate-500" suppressHydrationWarning>
{new Date(v.createdAt).toLocaleString("th-TH")} {new Date(v.createdAt).toLocaleString("th-TH")}
</span> </span>
</span> </span>

View file

@ -151,8 +151,10 @@ Index: `(file_id)`
- **Export:** `app/api/annotations/export/route.ts` (GET `?fileId=`) — dep ใหม่ `pdf-lib` + `@pdf-lib/fontkit`: - **Export:** `app/api/annotations/export/route.ts` (GET `?fileId=`) — dep ใหม่ `pdf-lib` + `@pdf-lib/fontkit`:
1. อ่านข้อมูลไฟล์จาก DB → อ่าน PDF ต้นฉบับจาก `path.join(process.cwd(), "public", pdfUrl)` — **อ่านอย่างเดียว ห้ามเขียนทับ** (invariant #1) + guard path ต้อง resolve อยู่ใน `public/` กัน path traversal 1. อ่านข้อมูลไฟล์จาก DB → อ่าน PDF ต้นฉบับจาก `path.join(process.cwd(), "public", pdfUrl)` — **อ่านอย่างเดียว ห้ามเขียนทับ** (invariant #1) + guard path ต้อง resolve อยู่ใน `public/` กัน path traversal
2. โหลด annotations ทั้งหมด (ทุก user) → วาดลง `PDFDocument` ต่อหน้าด้วยพิกัด % × ขนาดจริงของหน้านั้น (`page.getSize()` + แปลงตาม `page.getRotation()` กรณี /Rotate 90/180/270) 2. โหลด annotations ทั้งหมด (ทุก user) → วาดลง `PDFDocument` ต่อหน้าด้วยพิกัด % × ขนาดจริงของหน้านั้น (`page.getSize()` + แปลงตาม `page.getRotation()` กรณี /Rotate 90/180/270)
3. ลำดับวาดต่อหน้า: HIGHLIGHT rect (fill alpha ~0.35) → DRAW polyline (strokeWidth % → pt) → SHAPE → REDACTION **ดำทึบ #000 ทับจริง** → COMMENT หมุดวงกลม (+ข้อความถ้ามี Sarabun — D5) → TEXTBOX/STAMP ถ้าทำแล้ว (F17/F18) 3. ลำดับวาดต่อหน้า: HIGHLIGHT rect (fill alpha ~0.35) → DRAW polyline (strokeWidth % → pt) → SHAPE → REDACTION **ดำทึบ #000 ทับจริง** → TEXTBOX/STAMP ถ้าทำแล้ว (F17/F18)
4. ตอบ `application/pdf` + `Content-Disposition: attachment; filename="<title>-annotated.pdf"` 4. COMMENT = **sticky note จริงของ PDF** (annotation dict `/Subtype /Text` — คลิกหมุดที่ reader แล้วเปิด popup อ่าน): `/Contents` = message + replies ต่อท้ายทีละบรรทัด (`— ชื่อ: ข้อความ`, F9), `/T` = ผู้เขียน, `/C` สีเหลืองเดียวกับหมุดบนจอ, `/M` เวลาแก้ไขล่าสุด, `/F 4` (Print); ไม่วาดข้อความ flatten ทับหน้ากระดาษ (annotation object อยู่เหนือ content เสมอ ไม่โดนกลบ) — และ `note` ของ annotation ชนิดอื่น (N1 ทุกชนิดยกเว้น COMMENT) ได้ sticky note เล็กแนบมุมขวาบนของ bbox ของรายการนั้น (Chrome/Edge hover แสดง tooltip จาก `/Contents`, Acrobat คลิกเปิด popup)
5. Bookmarks (N2) → **PDF outline** (แผง Bookmarks ของ reader กดกระโดดได้): ต่อ `/Outlines` dict เอง (pdf-lib ไม่มี API ระดับสูง) — โครงสร้างตาม `parent_id` เรียงตาม `createdAt` เหมือน sidebar, `/Title` เป็น PDFHexString (ภาษาไทยได้), `/Dest [pageRef /XYZ x y null]` จาก pageIndex + yOffset (แปลงผ่าน `displayToPdfPoint`) ใช้ตำแหน่ง yOffset **เป๊ะ ๆ** — yOffset ของ bookmark มีนิยามเป็น "จุดจริงบนหน้า" เหมือนพิกัด annotation (เก็บจากขอบบนของแถบมองเห็นใต้ header ตอนสร้าง และกดในเว็บแล้วจุดถูกดันกลับมาชิดใต้ header ผ่าน `scrollToBelowHeader` แบบเดียวกับ `goToAnnotation`) ซึ่งตรงกับพฤติกรรม `/XYZ` ของ PDF ที่วางจุดยึดติดขอบบน viewport โดยไม่ต้องชดเชย, เชื่อม First/Last/Prev/Next ครบ และ Count เป็นบวก = แผงเปิดทั้งหมดตอนเปิดไฟล์
6. ตอบ `application/pdf` + `Content-Disposition: attachment; filename="<title>-annotated.pdf"`
- ปุ่ม "Export PDF" บน toolbar → ลิงก์/`window.open` ไป route - ปุ่ม "Export PDF" บน toolbar → ลิงก์/`window.open` ไป route
### F9 · A4 Reply/Thread ### F9 · A4 Reply/Thread
@ -213,7 +215,7 @@ Index: `(file_id)`
| F5 | B1 | enum+`ShapeData`+validation+InkLayer รูปทรง+tool `shape` | – | `/sync-db` สำเร็จ; วาด 4 ชนิดได้ครบ; สลับ zoom แล้วสัดส่วน/ตำแหน่งไม่เบี้ยว; แท็บ B เห็น realtime | | F5 | B1 | enum+`ShapeData`+validation+InkLayer รูปทรง+tool `shape` | – | `/sync-db` สำเร็จ; วาด 4 ชนิดได้ครบ; สลับ zoom แล้วสัดส่วน/ตำแหน่งไม่เบี้ยว; แท็บ B เห็น realtime |
| F6 | B3 | tool `erase` + `hitTestPath` | F5 | ลากผ่านเส้น/รูปทรงของตัวเอง → หายทันที; ของคนอื่นไม่หาย; แท็บ B เห็นการลบ | | F6 | B3 | tool `erase` + `hitTestPath` | F5 | ลากผ่านเส้น/รูปทรงของตัวเอง → หายทันที; ของคนอื่นไม่หาย; แท็บ B เห็นการลบ |
| F7 | E1 | pressure + palm rejection | F5 | `tsc` ผ่าน; **ค้างทดสอบด้วยมือ** บนอุปกรณ์สัมผัส/สไตลัส: วาดด้วยนิ้วได้ ไม่ scroll ระหว่างวาด, ปากกากับนิ้วพร้อมกันนิ้วไม่รบกวน | | F7 | E1 | pressure + palm rejection | F5 | `tsc` ผ่าน; **ค้างทดสอบด้วยมือ** บนอุปกรณ์สัมผัส/สไตลัส: วาดด้วยนิ้วได้ ไม่ scroll ระหว่างวาด, ปากกากับนิ้วพร้อมกันนิ้วไม่รบกวน |
| F8 | B4+C1 | enum REDACTION + export route (pdf-lib + fontkit + Sarabun) | – | `/sync-db`; วาด redaction บังข้อความบนจอ; export แล้วเปิดไฟล์ใหม่: ทุกชนิดติดไปถูกหน้าถูกตำแหน่ง, redaction เป็นดำทึบทับข้อความจริง; **hash ไฟล์ต้นฉบับใน `public/` ไม่เปลี่ยน**; ไม่มี Sarabun → export ยังสำเร็จ (fallback กรอบ/หมุด) | | F8 | B4+C1 | enum REDACTION + export route (pdf-lib + fontkit + Sarabun) | – | `/sync-db`; วาด redaction บังข้อความบนจอ; export แล้วเปิดไฟล์ใหม่: ทุกชนิดติดไปถูกหน้าถูกตำแหน่ง, redaction เป็นดำทึบทับข้อความจริง, COMMENT เป็น sticky note `/Text` เปิด popup อ่านได้ (message+replies), bookmarks โผล่เป็น outline แผง Bookmarks ของ reader; **hash ไฟล์ต้นฉบับใน `public/` ไม่เปลี่ยน**; ไม่มี Sarabun → export ยังสำเร็จ (fallback กรอบ) |
| F9 | A4 | ตาราง `annotation_replies` + actions + popover เธรด | – | `/sync-db`; ตอบ comment เป็นเธรดได้; แท็บ B เห็น reply realtime; ลบแม่ → replies หาย (CASCADE) | | F9 | A4 | ตาราง `annotation_replies` + actions + popover เธรด | – | `/sync-db`; ตอบ comment เป็นเธรดได้; แท็บ B เห็น reply realtime; ลบแม่ → replies หาย (CASCADE) |
| F10 | A5 | columns `resolved`/`resolved_at` + action + UI จาง/แยกกลุ่ม | – | `/sync-db`; toggle แล้ว pin จาง/เขียว + sidebar แยกกลุ่ม; ใครก็ได้ toggle (D6); realtime | | F10 | A5 | columns `resolved`/`resolved_at` + action + UI จาง/แยกกลุ่ม | – | `/sync-db`; toggle แล้ว pin จาง/เขียว + sidebar แยกกลุ่ม; ใครก็ได้ toggle (D6); realtime |
| F12 | C4 | อ่าน `?page=`/`?ann=` ตอน mount | F1 | เปิด URL → เลื่อนไปหน้า/เลือก annotation นั้น; ไฟล์ 44 หน้ากระโดดหน้าไกลได้ไม่ jump (placeholder ขนาดจริง) | | F12 | C4 | อ่าน `?page=`/`?ann=` ตอน mount | F1 | เปิด URL → เลื่อนไปหน้า/เลือก annotation นั้น; ไฟล์ 44 หน้ากระโดดหน้าไกลได้ไม่ jump (placeholder ขนาดจริง) |
@ -278,7 +280,7 @@ Dependencies เพิ่มเติม: F3 ← F2 · F12/F13/F19 ← F1 · F15
### N2 · Bookmark แบบหมุดหัวข้อใหญ่ + sub-bookmark + โน้ต (โครงสร้าง 3 ระดับ) ### N2 · Bookmark แบบหมุดหัวข้อใหญ่ + sub-bookmark + โน้ต (โครงสร้าง 3 ระดับ)
- **ข้อมูล:** ตาราง `bookmarks` เพิ่ม `parent_id uuid NULL` (NULL = หมุดหัวข้อใหญ่ระดับบนสุด / มีค่า = sub ของ bookmark นั้น — **ไม่ทำ FK** เพราะลบแม่ต้อง re-parent ลูกเอง) + `note text NULL` (โน้ตย่อยของ bookmark) — จำกัด **3 ระดับ** (`BOOKMARK_MAX_DEPTH = 3`): create ต้องมี `depth(parent) < 3`, ย้ายต้องผ่าน `depth(parent) + subtreeHeight(ตัวเอง) ≤ 3` + ห้ามย้ายเข้า subtree ของตัวเอง (cycle) — **ทุก action ที่แตะความสัมพันธ์แม่–ลูกตรวจภายใน transaction พร้อม `.for("update")` ทุกแถวที่เดินผ่าน** (บรรพบุรุษ/ลูกหลาน — helper `lockedDepth`/`lockedSubtreeHeight`/`isDescendant`) เพื่อ serialize กัน race เกินความลึก/sub กำพร้า - **ข้อมูล:** ตาราง `bookmarks` เพิ่ม `parent_id uuid NULL` (NULL = หมุดหัวข้อใหญ่ระดับบนสุด / มีค่า = sub ของ bookmark นั้น — **ไม่ทำ FK** เพราะลบแม่ต้อง re-parent ลูกเอง) + `note text NULL` (โน้ตย่อยของ bookmark) — จำกัด **3 ระดับ** (`BOOKMARK_MAX_DEPTH = 3`): create ต้องมี `depth(parent) < 3`, ย้ายต้องผ่าน `depth(parent) + subtreeHeight(ตัวเอง) ≤ 3` + ห้ามย้ายเข้า subtree ของตัวเอง (cycle) — **ทุก action ที่แตะความสัมพันธ์แม่–ลูกตรวจภายใน transaction พร้อม `.for("update")` ทุกแถวที่เดินผ่าน** (บรรพบุรุษ/ลูกหลาน — helper `lockedDepth`/`lockedSubtreeHeight`/`isDescendant`) เพื่อ serialize กัน race เกินความลึก/sub กำพร้า
- **การสร้าง:** ปุ่ม 🔖 บน toolbar (และคีย์ลัด `B`) เปิด **popup ทุกครั้ง** — ชื่อ (pre-fill default ไว้ในช่อง + focus เลือกข้อความทั้งหมด = พิมพ์ทับ/Enter ยอมรับได้ทันที; ล้างแล้วเว้นว่าง = "หน้า N" ตอนบันทึก) + โน้ต (ไม่บังคับ) + select "วางที่" (ระดับบนสุด / ใต้หัวข้อระดับ 1–2 — ใครก็วางใต้หัวข้อของคนอื่นได้) — ปิดด้วย บันทึก/ยกเลิก/Enter(title)/Esc/คลิกหน้า · ทางลัด: ปุ่ม **+** ที่หัวข้อระดับ 1–2 ใน sidebar = bookmark **หน้าที่ viewer ดูอยู่** เข้าใต้หัวข้อนั้นทันที (ชื่อ auto "หน้า N") - **การสร้าง:** ปุ่ม 🔖 บน toolbar (และคีย์ลัด `B`) เปิด **popup ทุกครั้ง** — ชื่อ (pre-fill default ไว้ในช่อง + focus เลือกข้อความทั้งหมด = พิมพ์ทับ/Enter ยอมรับได้ทันที; ล้างแล้วเว้นว่าง = "หน้า N" ตอนบันทึก) + โน้ต (ไม่บังคับ) + บรรทัดบอก**จุดที่จะชี้** (หน้า · ส่วนบน/ราวกลาง/ส่วนล่างของหน้า — เก็บจากตำแหน่งที่ viewer กำลังดูตอนเปิด popup กันเข้าใจผิดว่าชี้ที่หัวข้อที่ตั้งชื่อ) + select "วางที่" (ระดับบนสุด / ใต้หัวข้อระดับ 1–2 — ใครก็วางใต้หัวข้อของคนอื่นได้) — ปิดด้วย บันทึก/ยกเลิก/Enter(title)/Esc/คลิกหน้า · ทางลัด: ปุ่ม **+** ที่หัวข้อระดับ 1–2 ใน sidebar = bookmark **หน้าที่ viewer ดูอยู่** เข้าใต้หัวข้อนั้นทันที (ชื่อ auto "หน้า N")
- **ชื่อ default จากข้อความที่คลุม:** viewer จำข้อความที่เพิ่งคลุมบนหน้าไว้ตอน `mouseup` (ทุก tool — คลิกปุ่ม 🔖 ใน header ไม่ผ่าน mouseup ของหน้าค่าจึงไม่โดนเคลียร์) → เปิด popup แล้วชื่อ default = ข้อความนั้น (ยุบ whitespace, ตัดยาว ≤ 120 ตัวอักษรผ่าน `normalizeBookmarkTitle`) ไม่มีข้อความที่คลุม = "หน้า N" - **ชื่อ default จากข้อความที่คลุม:** viewer จำข้อความที่เพิ่งคลุมบนหน้าไว้ตอน `mouseup` (ทุก tool — คลิกปุ่ม 🔖 ใน header ไม่ผ่าน mouseup ของหน้าค่าจึงไม่โดนเคลียร์) → เปิด popup แล้วชื่อ default = ข้อความนั้น (ยุบ whitespace, ตัดยาว ≤ 120 ตัวอักษรผ่าน `normalizeBookmarkTitle`) ไม่มีข้อความที่คลุม = "หน้า N"
- **ปุ่ม bookmark ในแผง selection (F1 toolbar):** ปุ่ม 🔖 ท้ายแผงเครื่องมือของ annotation ที่เลือก → เปิด popup ที่หน้า + yOffset ตามขอบบน bbox ของรายการนั้น โดยชื่อ default = ข้อความของรายการ (HIGHLIGHT/TEXTBOX/STAMP/COMMENT — ชนิดที่ไม่มีข้อความจริงใช้ "หน้า N") - **ปุ่ม bookmark ในแผง selection (F1 toolbar):** ปุ่ม 🔖 ท้ายแผงเครื่องมือของ annotation ที่เลือก → เปิด popup ที่หน้า + yOffset ตามขอบบน bbox ของรายการนั้น โดยชื่อ default = ข้อความของรายการ (HIGHLIGHT/TEXTBOX/STAMP/COMMENT — ชนิดที่ไม่มีข้อความจริงใช้ "หน้า N")
- **แก้ไข (✏ เจ้าของเท่านั้น):** inline ใน sidebar — แก้ชื่อ + โน้ต + ย้ายหัวข้อ (select กรองเป้าหมายที่ย้ายได้จริงตามเกณฑ์ depth+height และตัดตัวเอง/ลูกหลานออก — server ตรวจซ้ำพร้อม lock) - **แก้ไข (✏ เจ้าของเท่านั้น):** inline ใน sidebar — แก้ชื่อ + โน้ต + ย้ายหัวข้อ (select กรองเป้าหมายที่ย้ายได้จริงตามเกณฑ์ depth+height และตัดตัวเอง/ลูกหลานออก — server ตรวจซ้ำพร้อม lock)

BIN
docs/requirement/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

BIN
docs/requirement/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

View file

@ -0,0 +1,98 @@
# รายงานเทียบ Requirement กับโปรเจกต์ (Gap Analysis)
- **วันที่ตรวจสอบ:** 2026-09-14
- **เอกสาร requirement ต้นทาง:** `docs/requirement/1.png`, `docs/requirement/2.png` (ข้อกำหนดระบบบันทึกความเห็นของกรรมการ — Judges' Notes System)
- **โค้ดที่ใช้เทียบ:** branch `forgejo-dev` ณ commit `b3b884c` (implement ครบตาม PLAN.md + PLAN-FEAT.md แล้ว ยกเว้น F11 ที่ถูกถอดออก)
- **ช่วงเลขข้อ (ยืนยันโดยเจ้าของเอกสาร):** ภาพที่ 1 = ข้อ **5.3.1–5.3.6** / ภาพที่ 2 = ข้อ **5.3.7–5.3.25**
> ⚠️ **หมายเหตุการอ่าน requirement:** สำนวนภาษาไทยบางคำอาจคลาดเคลื่อนเล็กน้อยจากต้นฉบับ แต่สาระของทุกข้อชัดเจน — การอ่านครั้งแรกสับสนเพราะเลขข้อเป็นเลขอารบิก (5.3.X) ที่ OCR อ่านเป็นอักษรไทย ช่วงเลขข้อได้รับยืนยันจากเจ้าของเอกสารแล้ว และการตรวจซ้ำด้วยการสแกนตำแหน่งบรรทัด + อ่านเลขข้อรายบรรทัดยืนยันว่า **จำนวนข้อ map ตรง 1:1 ครบทั้ง 19 ข้อ** (10 ข้อครึ่งบน + 9 ข้อครึ่งล่าง รวม 5.3.7–5.3.25) — การจับคู่เลข ↔ เนื้อหาในตารางจึงเป็นที่ยืนยันแล้วทั้งหมด
## สรุปผล
**โปรเจกต์ judes-note ตอบแกน functional ของ requirement ได้ค่อนข้างเต็ม** — การ annotate บนเอกสาร PDF, bookmark ต่อหน้าเอกสาร, Text Box + Freehand Drawing, ลบ/แก้ไขความเห็น, แสดงผลรายการ, ใช้งานหลายคนพร้อมกันแบบ realtime, responsive และรองรับ tablet/iPad
**แต่ requirement ฉบับนี้เขียนสำหรับระบบ production เต็มรูปแบบ** ขณะที่ judes-note เป็น MVP เฟสแรกตามขอบเขตใน `CLAUDE.md` / `docs/PLAN.md` — ข้อที่ขาดส่วนใหญ่จึงแบ่งได้เป็น 2 กลุ่ม: (1) ของที่**ตั้งใจไม่ทำ**ในเฟสแรก (auth, API, integration) และ (2) ของที่**เกือบมีแต่ต่างรายละเอียด** (ค้นหาไม่ครอบ bookmark, ลบ bookmark ทีละรายการ) รวมถึง (3) ของใหม่ที่ไม่เคยอยู่ในแผน (รองรับไฟล์รูปภาพ)
| สถานะ | ความหมาย |
|---|---|
| ✅ | มีและตรงตาม requirement |
| ⚠️ | มีบางส่วน / ใกล้เคียงแต่ต่างรายละเอียด |
| ❌ | ไม่มีในโปรเจกต์ |
---
## 1. ภาพที่ 1 — ข้อ 5.3.1–5.3.6 (คุณลักษณะหลักของระบบ)
> **5.3 พัฒนาระบบบันทึกความเห็นของกรรมการ (Judges' Notes System) มีคุณลักษณะดังต่อไปนี้**
| Requirement | สถานะ | รายละเอียดในโปรเจกต์ |
|---|---|---|
| **5.3.1** เรียกดูเอกสารที่เกี่ยวข้องและตรวจสอบจากระบบงานคดีปกครองได้โดยตรง | ⚠️ | ตัว viewer แสดงไฟล์ PDF ที่ระบุใน DB ได้ แต่**ไม่มีการเชื่อมต่อระบบงานคดีปกครอง** (ไม่มี integration/API ดึงเอกสารโดยตรง) และไม่มีหน้า upload — หน้า `/` อ่านรายการจาก DB เท่านั้น (sample data seed ด้วย SQL) — ผูกกับ gap 5.3.14 (API) และ 5.3.16 (integrate) |
| **5.3.2** ระบบต้องไม่สามารถแก้ไขไฟล์ต้นฉบับได้ (Non-Destructive) | ✅ | เป็น **invariant หลักของโปรเจกต์** (CLAUDE.md ข้อ 1): annotation เก็บใน DB เป็นพิกัด % เท่านั้น, ไฟล์ PDF ใน `public/` ถูกอ่านอย่างเดียวผ่าน path guard (`lib/server-pdf.ts`), export PDF (F8) สร้างไฟล์ใหม่จากสำเนาในหน่วยความจำ (pdf-lib load แล้ว save คืน bytes ใหม่) — ไม่เขียนทับต้นฉบับเด็ดขาด |
| **5.3.3** จัดเก็บข้อมูล Bookmark แยกจากเอกสารโดยเก็บไว้ในฐานข้อมูล และสามารถนำข้อมูลดังกล่าวมาแสดงได้ | ✅ | เป็น**สถาปัตยกรรมแกนกลางของโปรเจกต์** (invariant #1–2): bookmark เก็บใน PostgreSQL ตาราง `bookmarks` เป็นพิกัด/ข้อมูลล้วน (pageIndex, ชื่อ, โครงสร้าง parent_id) แยกจากไฟล์ PDF ใน `public/` — นำกลับมาแสดงได้ทั้งแท็บ Bookmarks ใน sidebar (scroll ไปหน้านั้น), หน้าสรุป F15 และ export |
| **5.3.4** รูปแบบเอกสารที่รองรับได้ — ไฟล์เอกสาร PDF และไฟล์รูปภาพ (jpg, jpeg, png, tif) เป็นอย่างน้อย | ❌ | **รองรับ PDF เท่านั้น** — viewer ใช้ pdfjs-dist ล้วน ไม่มีการรับไฟล์รูปภาพ (ไม่เคยอยู่ในแผน) |
| **5.3.5** บันทึกความเห็น (Bookmark) ทั้งแบบกรอกข้อความ (Text Box) และเขียนด้วยมือ (Freehand Drawing) | ✅ | TEXTBOX (F17) + DRAW freehand ดินสอ/ไฮไลต์เตอร์ + รองรับสไตลัส pressure (F7) |
| **5.3.6** เพิ่ม ลบ แก้ไขความเห็นในกล่องข้อความได้ | ✅ | TEXTBOX (F17): สร้างกล่องข้อความ + แก้ไขข้อความในกล่องโดยตรง (editor รองรับตัวหนาบางส่วน — rich text) + ลบ/ย้าย/ปรับขนาด ของตัวเอง (F1, F13) |
---
## 2. ภาพที่ 2 — ข้อ 5.3.7–5.3.25 (รายละเอียด bookmark + ข้อกำหนดระบบ)
| Requirement | สถานะ | รายละเอียดในโปรเจกต์ |
|---|---|---|
| **5.3.7** เพิ่ม แก้ไข ลบ ข้อความที่เขียนหรือวาดได้ เช่น สีหรือขนาด | ✅ | ครบทั้งวงจร: สร้าง annotation ทุกชนิด (HIGHLIGHT/DRAW/SHAPE/TEXTBOX/STAMP) + **แก้ไขสี/ความหนา/ขนาด** ผ่านแผง selection (F1 — `AnnotationSelectionLayer`: แถบสี + ความหนา, TEXTBOX ปรับ fontSize ได้) + ย้าย/ปรับขนาด + F13 แก้ไขข้อความ + ลบของตัวเอง + F19 version history |
| **5.3.8** ข้อมูลบันทึก bookmark ต้องอ้างถึงไฟล์เอกสาร หน้าเอกสาร วันที่บันทึก และผู้บันทึก | ✅ | ตาราง `bookmarks` มีครบทั้ง 4: `file_id` (FK → ไฟล์เอกสาร), `page_index` (หน้าเอกสาร), `created_at` (วันที่บันทึก), `user_id` (ผู้บันทึก) — นำมาแสดงในแท็บ Bookmarks, หน้าสรุป F15 และ export |
| **5.3.9** กำหนดความเป็นส่วนตัวของ bookmark: ส่วนบุคคล (Private) / กลุ่ม (Group) | ❌ | ไม่มี Private/Group visibility — ทุกคนเห็นทุกรายการ (ไม่มี auth/roles โดยเจตนา) |
| **5.3.10** แสดง bookmark ของเอกสารแต่ละไฟล์ หรือชุดของเอกสารได้ | ⚠️ | รายไฟล์ ✅ — แท็บ Bookmarks ใน sidebar แสดง bookmark ทั้งหมดของไฟล์ที่เปิด (ผูก `file_id`) + หน้าสรุป F15 / แต่**ชุดของเอกสาร** ❌ — ไม่มี concept การจัดกลุ่มไฟล์ (document set) และไม่มีหน้าแสดง bookmark รวมข้ามหลายไฟล์ |
| **5.3.11** แสดง bookmark ตามลำดับหน้าได้ | ⚠️ | แท็บ Bookmarks แสดงเลขหน้าต่อรายการ ("หน้า N") แต่เรียงตาม**เวลาสร้าง** ไม่ใช่ลำดับหน้า (`AnnotationSidebar.tsx:753` — root เรียงตามเวลาสร้าง) และหน้าสรุป F15 ไม่มี bookmark — แก้ง่าย: sort roots ของต้นไม้ตาม `pageIndex` |
| **5.3.12** สามารถกำหนดเงื่อนไขการค้นหา bookmarks ได้ | ❌ | ช่องค้นหา (F14) ครอบเฉพาะ annotations เท่านั้น (`searchAnnotations` query ตาราง `annotations`) — **ค้นหา bookmark ยังไม่ได้เลย** ยิ่งไม่มีการกำหนดเงื่อนไข (เช่น ตามหน้า/ผู้บันทึก/ช่วงวันที่) |
| **5.3.13** แสดงผลรายการหน้าของเอกสารที่ bookmark ไว้ | ✅ | แท็บ Bookmarks ใน sidebar + หน้าสรุป (F15) |
| **5.3.14** จัดทำชุด API ดึงข้อมูล bookmark สำหรับเชื่อมต่อระบบอื่น | ❌ | ไม่มี REST API ภายนอก — มีเฉพาะ SSE stream ภายใน + export routes สำหรับผู้ใช้ (F11 export/import JSON ถูกถอดออกจากโค้ดแล้ว) |
| **5.3.15** ส่งออก (Export) รายการ bookmark เป็น PDF | ⚠️ | ใกล้เคียง: export PDF ฝัง annotation + bookmark เป็น **PDF outline** (F8 — `app/api/annotations/export/route.ts`) + export CSV สรุปรายการ (F16) — แต่ไม่ใช่ "รายงานรายการ bookmark" แบบเอกสาร PDF ตรง ๆ |
| **5.3.16** หน้าจอเชื่อมต่อกับระบบอื่น (Integrate) เป็น modal หรือ iframe | ❌ | เป็น standalone Next.js app — ไม่มีโหมด embed |
| **5.3.17** รองรับ Web Browser: Google Chrome, Microsoft Edge, Safari | ✅ | Web app มาตรฐาน — ทดสอบ desktop + iOS/Android แล้ว |
| **5.3.18** ใช้งานบน Tablet, iPad — Web Application ภาษา HTML5 | ✅ | Touch support เต็มรูปแบบ: ไฮไลต์ผ่านปุ่มลอย, ลาก handles, วาดด้วยนิ้ว (commit `b3b884c`) |
| **5.3.19** Responsive แสดงผลได้ทุกความละเอียดหน้าจอ | ✅ | sidebar จอกลาง/จอเล็ก, PDF fit จอเล็ก, ปุ่มขนาด touch-44px (commit `1c2f4c8`) |
| **5.3.20** ใช้งานหลายผู้ใช้พร้อมกัน (Multi-user) จากหลายเครื่อง | ✅ | Realtime sync ผ่าน SSE (`hooks/useAnnotationRealtime.ts`) — แก้ไข/ลบ ของใครของมันบนไฟล์เดียวกันได้พร้อมกัน |
| **5.3.21** การรับรองตัวผู้ใช้งาน (Authentication) | ❌ | ไม่มีระบบ auth — anonymous identity จาก localStorage โดยเจตนา (out of scope ตามแผน) |
| **5.3.22** ใช้งานร่วมกับระบบงานอื่นผ่าน API | ❌ | ไม่มี |
| **5.3.23** ติดตั้ง On-Premise บน Server ภายใน หรือ Cloud | ⚠️ | สถาปัตยกรรม Next.js + PostgreSQL deploy ได้ทั้งสองรูปแบบทางเทคนิค แต่ไม่มีข้อกำหนด/packaging พิเศษ (หมายเหตุ: SSE pub/sub เป็น in-memory — รันหลาย instance ต้องเพิ่ม Redis/LISTEN-NOTIFY ก่อน) |
| **5.3.24** รองรับมาตรฐานความมั่นคงปลอดภัยของหน่วยงาน | ❌ | ไม่มี auth/audit log/encryption policy — ไม่ตอบโจทย์ในระดับนี้ |
| **5.3.25** ระบบมีความพร้อมในการให้บริการ (Availability) อย่างน้อย 99.5% ตลอดเวลาการให้บริการ | ⚠️ | ยังไม่มีการออกแบบเรื่อง SLA/uptime — ไม่มี health check, monitoring, แผน failover/backups — เป็นข้อกำหนดระดับ infrastructure (กลุ่ม D) ที่ต้องออกแบบตอนวางระบบ production จริง |
---
## 3. สรุป Gap รายกลุ่ม
### กลุ่ม A — ของที่ out of scope โดยเจตนาตามแผนเฟสแรก
ข้อที่ต้องเปลี่ยนขอบเขตโปรเจกต์ + ออกแบบเพิ่มก่อน implement:
- Authentication ผู้ใช้งาน + ระบบสิทธิ์ (5.3.21, 5.3.24)
- Private/Group visibility ของ bookmark (5.3.9)
- ชุด API สำหรับระบบภายนอก (5.3.14, 5.3.22)
- โหมด Integrate แบบ modal/iframe (5.3.16)
- เรียกดูเอกสารจากระบบงานคดีปกครองโดยตรง (5.3.1)
### กลุ่ม B — ฟังก์ชันที่มีอยู่แล้ว ต่อยอดเล็กน้อยได้เลย
งานขนาดเล็ก–กลาง ไม่กระทบสถาปัตยกรรม:
- **ค้นหา bookmark พร้อมเงื่อนไข** (5.3.12) — เพิ่มการค้นหาตาราง `bookmarks` (ชื่อ/โน้ต) + รองรับเงื่อนไขเช่นหน้าเอกสาร/ผู้บันทึก/ช่วงวันที่
- **เรียง bookmark ตามลำดับหน้า** (5.3.11) — sort roots ของต้นไม้ bookmark ตาม `pageIndex` แทนเวลาสร้าง
- **รายงานรายการ bookmark เป็น PDF** (5.3.15) — ต่อจากหน้าสรุป F15 หรือ export route เดิม
### กลุ่ม C — ของใหม่ที่ไม่เคยอยู่ในแผน
- **รองรับไฟล์รูปภาพ jpg/jpeg/png/tif** (5.3.4) — กระทบ viewer เป็นวงกว้าง (pdfjs-dist อ่าน PDF เท่านั้น ต้องเพิ่ม image viewer path แยก + คิดเรื่องพิกัด % ของหน้ารูปภาพ)
- **ชุดของเอกสาร (document set) + แสดง bookmark รวมหลายไฟล์** (5.3.10) — ต้องเพิ่ม data model จัดกลุ่มไฟล์ + หน้ารวม bookmark ข้ามไฟล์ (ตอนนี้ไฟล์เป็นลิสต์แบนหน้าเดียว)
### กลุ่ม D — infrastructure (ระยะยาว)
- On-Premise/Cloud packaging (5.3.23) — รวมถึงเตรียม multi-instance สำหรับ SSE
- มาตรฐานความปลอดภัยของหน่วยงาน (5.3.24) — ผูกกับ auth ในกลุ่ม A
- Availability ≥ 99.5% (5.3.25) — SLA/uptime, monitoring, failover, backup ระดับ production
---
## 4. ข้อเสนอแนะลำดับความสำคัญ
1. **ทำกลุ่ม B ก่อน** — คุ้มค่าต่อแรง เห็นผลไว เสริมฟีเจอร์ bookmark ให้ครบ requirement โดยไม่แตะสถาปัตยกรรม
2. **ตัดสินใจเรื่องรูปภาพ (กลุ่ม C)** — เป็น requirement ที่ชัดเจนใน 5.3.4 แต่แพงที่สุดในเชิงโครงสร้าง ควรตัดสินใจก่อนว่าจะอยู่ในขอบเขตจริงไหม
3. **กลุ่ม A ตามด้วย D** — ต้องอัปเดต `docs/PLAN.md` ขยายขอบเขตอย่างเป็นทางการก่อน implement ตามกระบวนการของโปรเจกต์

View file

@ -32,7 +32,7 @@ describe("annotationTypeLabel", () => {
expect( expect(
annotationTypeLabel(dto("HIGHLIGHT", { text: "a", color: "#000", rects: [], style: "underline" })), annotationTypeLabel(dto("HIGHLIGHT", { text: "a", color: "#000", rects: [], style: "underline" })),
).toBe("เส้นใต้"); ).toBe("เส้นใต้");
expect(annotationTypeLabel(dto("HIGHLIGHT", { text: "a", color: "#000", rects: [] }))).toBe("ไฮไลต์"); expect(annotationTypeLabel(dto("HIGHLIGHT", { text: "a", color: "#000", rects: [] }))).toBe("ไฮไลต์ข้อความ");
}); });
it("ครบทุกชนิดที่เหลือ", () => { it("ครบทุกชนิดที่เหลือ", () => {

View file

@ -14,7 +14,7 @@ import type {
/** ชื่อรูปแบบการมาร์กข้อความ (HIGHLIGHT.style) */ /** ชื่อรูปแบบการมาร์กข้อความ (HIGHLIGHT.style) */
export const HIGHLIGHT_STYLE_LABELS: Record<HighlightStyle, string> = { export const HIGHLIGHT_STYLE_LABELS: Record<HighlightStyle, string> = {
highlight: "ไฮไลต์", highlight: "ไฮไลต์ข้อความ",
underline: "เส้นใต้", underline: "เส้นใต้",
strikethrough: "ขีดฆ่า", strikethrough: "ขีดฆ่า",
squiggle: "ขยุกขยิก", squiggle: "ขยุกขยิก",
@ -49,7 +49,9 @@ export function effectiveDrawOpacity(d: { pen?: DrawPen; opacity?: number }): nu
// ค่า default ตอนสร้าง TEXTBOX (F17/B2) — fontSize เป็น % ของความกว้างหน้า // ค่า default ตอนสร้าง TEXTBOX (F17/B2) — fontSize เป็น % ของความกว้างหน้า
export const TEXTBOX_DEFAULT_COLOR = "#111827"; export const TEXTBOX_DEFAULT_COLOR = "#111827";
export const TEXTBOX_DEFAULT_FONT_SIZE_PCT = 1.2; // M2: 2.5% ≈ 15px ที่หน้า A4 (595pt, scale 1) — อ่านออกทันทีที่พิมพ์เสร็จ (เดิม 1.2% ≈ 7px
// เล็กกว่า preset ต่ำสุด 10px ของ NumberCombo ด้วยซ้ำ) / กระทบเฉพาะกล่องที่สร้างใหม่เท่านั้น
export const TEXTBOX_DEFAULT_FONT_SIZE_PCT = 2.5;
// STAMP preset (F18/B5, D7 — คงที่ในโค้ด) + ค่าการแสดงผลที่ render กับ export ใช้ร่วมกัน // STAMP preset (F18/B5, D7 — คงที่ในโค้ด) + ค่าการแสดงผลที่ render กับ export ใช้ร่วมกัน
export const STAMP_PRESETS: readonly string[] = [ export const STAMP_PRESETS: readonly string[] = [