จอสัมผัส (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
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:
parent
b3b884c736
commit
df0392ea45
17 changed files with 1085 additions and 215 deletions
173
.claude/agents/ui-designer.md
Normal file
173
.claude/agents/ui-designer.md
Normal 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.
|
||||
|
|
@ -20,13 +20,15 @@ paths:
|
|||
- 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>`
|
||||
- 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
|
||||
- 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
|
||||
- **จอสัมผัส (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/วางตรายาง)
|
||||
- กรอบ 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 กล่องกว้างคงที่ (`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)
|
||||
- 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 เปิดอยู่)
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export default async function SummaryPage({
|
|||
const pages = Array.from(byPage.entries()).sort((x, y) => x[0] - y[0]);
|
||||
|
||||
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">
|
||||
<h1 className="min-w-0 flex-1 truncate text-lg font-bold text-primary">
|
||||
สรุปหมายเหตุ — {file.title}
|
||||
|
|
|
|||
|
|
@ -2,20 +2,35 @@ import fs from "node:fs";
|
|||
import path from "node:path";
|
||||
|
||||
import fontkit from "@pdf-lib/fontkit";
|
||||
import { PDFDocument, LineCapStyle, rgb, type PDFFont } from "pdf-lib";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import {
|
||||
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 { PdfPathError, readPublicPdf } from "@/lib/server-pdf";
|
||||
import {
|
||||
annotationReplies,
|
||||
annotations,
|
||||
bookmarks,
|
||||
files,
|
||||
toAnnotationDTO,
|
||||
type AnnotationType,
|
||||
type BookmarkRow,
|
||||
type CommentData,
|
||||
type DrawData,
|
||||
type HighlightData,
|
||||
type RedactionData,
|
||||
type ReplyRow,
|
||||
type ShapeData,
|
||||
type StampData,
|
||||
type TextboxData,
|
||||
|
|
@ -27,28 +42,39 @@ import {
|
|||
encodeRFC5987,
|
||||
hexToRgb01,
|
||||
wrapStyledText,
|
||||
wrapTextByWidth,
|
||||
type Rot,
|
||||
type StyledRun,
|
||||
} 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 { STAMP_HEIGHT_RATIO, textboxMaxWidthPct } from "@/utils/geometry";
|
||||
import { STAMP_COLOR, effectiveDrawOpacity, stampFitFontSize } from "@/utils/annotation-display";
|
||||
import {
|
||||
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
|
||||
// เป็นสำเนาในหน่วยความจำ แล้ว save() คืน bytes ใหม่ ไม่เขียนทับไฟล์เดิม
|
||||
// - วาดต่อหน้าตามลำดับ: HIGHLIGHT → DRAW → SHAPE → REDACTION (ดำทึบ) → COMMENT หมุด
|
||||
// - วาดต่อหน้าตามลำดับ: HIGHLIGHT → DRAW → SHAPE → REDACTION (ดำทึบ) → TEXTBOX/STAMP
|
||||
// (+ข้อความถ้ามีฟอนต์ Sarabun — D5: ไม่มีฟอนต์ต้องไม่พัง)
|
||||
// - COMMENT เป็น sticky note จริงของ PDF (/Subtype /Text — คลิกหมุดที่ reader เปิด
|
||||
// popup อ่าน message + replies), bookmarks เป็น PDF outline (แผง Bookmarks)
|
||||
|
||||
const UUID_RE =
|
||||
/^[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");
|
||||
|
||||
// ลำดับวาดต่อหน้าตามสเปก §3 F8 ข้อ 3: HIGHLIGHT → DRAW → SHAPE → REDACTION → COMMENT
|
||||
// (REDACTION ดำทึบต้องทับทุกอย่างที่สร้างก่อนหน้า และหมุด COMMENT ต้องไม่ถูกกลบ — D4)
|
||||
// ลำดับวาดต่อหน้าตามสเปก §3 F8 ข้อ 3: HIGHLIGHT → DRAW → SHAPE → REDACTION → TEXTBOX → STAMP
|
||||
// (REDACTION ดำทึบต้องทับทุกอย่างที่สร้างก่อนหน้า — D4) — COMMENT ไม่เข้าลำดับนี้แล้ว:
|
||||
// เป็น annotation object ที่ reader วาดเหนือ content เสมอ จึงไม่มีปัญหาถูกกลบ
|
||||
const TYPE_ORDER: Record<AnnotationType, number> = {
|
||||
HIGHLIGHT: 0,
|
||||
DRAW: 1,
|
||||
|
|
@ -59,6 +85,126 @@ const TYPE_ORDER: Record<AnnotationType, number> = {
|
|||
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) {
|
||||
const url = new URL(req.url);
|
||||
const fileId = url.searchParams.get("fileId") ?? "";
|
||||
|
|
@ -118,6 +264,22 @@ export async function GET(req: Request) {
|
|||
.map(toAnnotationDTO)
|
||||
.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) {
|
||||
const page = doc.getPage(Math.min(a.pageIndex, doc.getPageCount() - 1));
|
||||
const pageSize = page.getSize();
|
||||
|
|
@ -294,43 +456,27 @@ export async function GET(req: Request) {
|
|||
break;
|
||||
}
|
||||
case "COMMENT": {
|
||||
// sticky note จริงของ PDF (/Subtype /Text) — คลิกหมุดที่ reader แล้วเปิด popup
|
||||
// อ่าน message + replies (F9); ไม่ต้องมี /AP เพราะ reader วาด icon เอง
|
||||
const d = a.data as CommentData;
|
||||
const pin = toPdf(pxPoint(d.position));
|
||||
page.drawCircle({
|
||||
x: pin.x,
|
||||
y: pin.y,
|
||||
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 parts = [d.message];
|
||||
for (const r of repliesByAnnotation.get(a.id) ?? []) {
|
||||
parts.push(`— ${r.userName}: ${r.message}`);
|
||||
}
|
||||
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;
|
||||
}
|
||||
case "TEXTBOX": {
|
||||
|
|
@ -451,8 +597,33 @@ export async function GET(req: Request) {
|
|||
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 filename = encodeRFC5987(`${file.title}-annotated.pdf`);
|
||||
return new Response(new Uint8Array(out), {
|
||||
|
|
|
|||
121
app/globals.css
121
app/globals.css
|
|
@ -81,8 +81,7 @@
|
|||
|
||||
/* ─── UX review 2026-09-09 ข้อ 4: ปุ่ม header เล็กกว่ามาตรฐาน touch 44×44 ─────
|
||||
ขยายเฉพาะอุปกรณ์จอสัมผัส — ครอบปุ่ม toggle/page nav/zoom/tool group
|
||||
(รวมปุ่ม dropdown ของ tool)/undo-redo/bookmark/export + ปุ่ม summary (Link)
|
||||
แต่ไม่โดนปุ่มใน dropdown panel (สี/ความหนา) ที่ซ้อนลึกกว่าอีกชั้น */
|
||||
(รวมปุ่ม dropdown ของ tool)/undo-redo/bookmark/export + ปุ่ม summary (Link) */
|
||||
@media (pointer: coarse) {
|
||||
header .touch-44 > button,
|
||||
header .touch-44 > div > button,
|
||||
|
|
@ -96,4 +95,122 @@
|
|||
align-items: 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export default function Select({
|
|||
return (
|
||||
<div className="relative">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import { AlignCenter, AlignLeft, AlignRight, BookmarkPlus, Trash2 } from "lucide-react";
|
||||
import { AlignCenter, AlignLeft, AlignRight, BookmarkPlus, Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
import type {
|
||||
AnnotationData,
|
||||
|
|
@ -83,6 +83,7 @@ export default function AnnotationSelectionLayer({
|
|||
onCommit,
|
||||
onDelete,
|
||||
onBookmark,
|
||||
onStartEdit,
|
||||
}: {
|
||||
annotation: AnnotationDTO;
|
||||
/** ขนาด px ของหน้า (จาก width/height ที่คำนวณจาก scale) */
|
||||
|
|
@ -101,6 +102,9 @@ export default function AnnotationSelectionLayer({
|
|||
onDelete: (id: string) => void;
|
||||
/** N2: ปุ่ม bookmark ในแผง — เปิด popup ที่ตำแหน่ง (yOffset %) ของ annotation นี้ */
|
||||
onBookmark: (yOffsetPct: number) => void;
|
||||
/** M1: ปุ่ม "แก้ไขข้อความ" ของ TEXTBOX — เข้าโหมดแก้ไขโดยไม่ต้อง double-click
|
||||
* (จอสัมผัสไม่มี dblclick ที่เชื่อถือได้: iOS double-tap = zoom หน้า) */
|
||||
onStartEdit?: (id: string) => void;
|
||||
}) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
|
|
@ -401,7 +405,7 @@ export default function AnnotationSelectionLayer({
|
|||
{/* ขอบกรอบ 4 ด้าน — ลาก = translate ทั้งกลุ่ม (touch-none: จอสัมผัสลาก
|
||||
ได้จริง ไม่ถูก browser ยึดเป็น scroll แล้วส่ง pointercancel) */}
|
||||
<div
|
||||
className="absolute cursor-move touch-none"
|
||||
className="absolute cursor-move touch-none sel-strip"
|
||||
style={{
|
||||
left: boxPx.x - STRIP_PX / 2,
|
||||
top: boxPx.y - STRIP_PX / 2,
|
||||
|
|
@ -415,7 +419,7 @@ export default function AnnotationSelectionLayer({
|
|||
onPointerCancel={cancelDrag}
|
||||
/>
|
||||
<div
|
||||
className="absolute cursor-move touch-none"
|
||||
className="absolute cursor-move touch-none sel-strip"
|
||||
style={{
|
||||
left: boxPx.x - STRIP_PX / 2,
|
||||
top: boxPx.y + boxPx.height - STRIP_PX / 2,
|
||||
|
|
@ -429,7 +433,7 @@ export default function AnnotationSelectionLayer({
|
|||
onPointerCancel={cancelDrag}
|
||||
/>
|
||||
<div
|
||||
className={`absolute touch-none ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
|
||||
className={`absolute touch-none sel-strip ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
|
||||
style={{
|
||||
left: boxPx.x - STRIP_PX / 2,
|
||||
top: boxPx.y,
|
||||
|
|
@ -445,7 +449,7 @@ export default function AnnotationSelectionLayer({
|
|||
onPointerCancel={cancelDrag}
|
||||
/>
|
||||
<div
|
||||
className={`absolute touch-none ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
|
||||
className={`absolute touch-none sel-strip ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
|
||||
style={{
|
||||
left: boxPx.x + boxPx.width - STRIP_PX / 2,
|
||||
top: boxPx.y,
|
||||
|
|
@ -466,7 +470,7 @@ export default function AnnotationSelectionLayer({
|
|||
{!isFixedTextbox && corners.map((c) => (
|
||||
<div
|
||||
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={{
|
||||
left: c.x - HANDLE_PX / 2,
|
||||
top: c.y - HANDLE_PX / 2,
|
||||
|
|
@ -487,13 +491,15 @@ export default function AnnotationSelectionLayer({
|
|||
|
||||
{/* แผงเครื่องมือของรายการที่เลือก — ตัวควบคุมเดียวกับ dropdown ของ tool ผู้สร้าง
|
||||
(สี/ความหนา/ขนาด/รูปแบบตามชนิด) + ปุ่มลบท้ายแผง — แสดงทุกชนิดรวม HIGHLIGHT
|
||||
(ไม่มี handle ย้าย/ขยาย แต่เปลี่ยนสี/รูปแบบ/ลบได้) — เฉพาะรายการของตัวเอง */}
|
||||
(ไม่มี handle ย้าย/ขยาย แต่เปลี่ยนสี/รูปแบบ/ลบได้) — เฉพาะรายการของตัวเอง
|
||||
clamp แนวนอน: รายการชิดขวาหน้า (จอแคบ fit-width) ต้องไม่ทำแผง w-60 ล้นขอบขวา
|
||||
จนปุ่มลบ/ตัวควบคุมหลุดจอ (เจอตอน verify จอ 390px — ปุ่มลบครึ่งเดียวจิ้มได้ยาก) */}
|
||||
{editable && !disabled && (
|
||||
<div
|
||||
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={{
|
||||
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,
|
||||
transform: openAbove ? "translateY(-100%)" : undefined,
|
||||
pointerEvents: "auto",
|
||||
|
|
@ -578,6 +584,18 @@ export default function AnnotationSelectionLayer({
|
|||
)}
|
||||
{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="สี">
|
||||
<ColorSwatches
|
||||
value={textboxData.color}
|
||||
|
|
@ -660,7 +678,7 @@ export default function AnnotationSelectionLayer({
|
|||
type="button"
|
||||
aria-label="บันทึก 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)}
|
||||
>
|
||||
<BookmarkPlus className="h-3.5 w-3.5" />
|
||||
|
|
@ -669,7 +687,7 @@ export default function AnnotationSelectionLayer({
|
|||
type="button"
|
||||
aria-label="ลบรายการนี้"
|
||||
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)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import Link from "next/link";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useOptimistic,
|
||||
|
|
@ -11,6 +12,7 @@ import {
|
|||
type MouseEvent as ReactMouseEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
AlignCenter,
|
||||
AlignLeft,
|
||||
|
|
@ -42,6 +44,7 @@ import {
|
|||
Type,
|
||||
Stamp,
|
||||
Undo2,
|
||||
X,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
type LucideIcon,
|
||||
|
|
@ -195,6 +198,21 @@ function viewerHeaderHeight(): number {
|
|||
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
|
||||
* (เว้นระยะห่างจาก toolbar 40px ให้รายการไม่ติดแถบเครื่องมือเกินไป) */
|
||||
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 }[] = [
|
||||
{ key: "select", label: "เลือกข้อความ", Icon: MousePointer2 },
|
||||
{ key: "pan", label: "เลื่อนหน้า (pan)", Icon: Hand },
|
||||
{ key: "highlight", label: "มาร์กข้อความ (highlight)", Icon: Highlighter },
|
||||
{ key: "draw", label: "วาดเส้น", Icon: Pencil },
|
||||
{ key: "comment", label: "วาง comment", Icon: MessageSquarePlus },
|
||||
{ key: "highlight", label: "มาร์กข้อความ (H)", Icon: Highlighter },
|
||||
{ key: "draw", label: "วาดเส้น (D)", Icon: Pencil },
|
||||
{ key: "comment", label: "วาง comment (C)", Icon: MessageSquarePlus },
|
||||
{ key: "shape", label: "รูปทรง", Icon: Shapes },
|
||||
{ key: "erase", label: "ลบด้วยการลาก (eraser)", Icon: Eraser },
|
||||
{ key: "redact", label: "ปิดพราง (redaction)", Icon: EyeOff },
|
||||
|
|
@ -366,6 +384,13 @@ const TOOLS: { key: Tool; label: string; Icon: LucideIcon }[] = [
|
|||
{ 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 ไฮไลต์)
|
||||
* ทุกชนิดยกเว้น COMMENT — เปิด composer พิมพ์ก่อนบันทึก และคลิก pin = เลือกอยู่แล้ว */
|
||||
const AUTO_SELECT_TYPES = new Set<AnnotationType>([
|
||||
|
|
@ -580,6 +605,7 @@ function PdfPage({
|
|||
onShapeEnd,
|
||||
onRedactionEnd,
|
||||
onErase,
|
||||
onTextLayerRendered,
|
||||
}: {
|
||||
pdfjs: typeof import("pdfjs-dist");
|
||||
doc: import("pdfjs-dist").PDFDocumentProxy;
|
||||
|
|
@ -637,6 +663,9 @@ function PdfPage({
|
|||
onShapeEnd: (data: ShapeData) => void;
|
||||
onRedactionEnd: (data: RedactionData) => 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 textLayerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
@ -683,6 +712,8 @@ function PdfPage({
|
|||
viewport,
|
||||
});
|
||||
await textLayer.render();
|
||||
// M6: รายงาน span count ของหน้านี้ (0 = ไฟล์สแกนไม่มี text layer — hint ไฮไลต์ไม่ได้)
|
||||
if (!cancelled) onTextLayerRendered?.(pageNumber - 1, container.querySelectorAll("span").length);
|
||||
})().catch((err) => {
|
||||
if (!cancelled) {
|
||||
console.error(`render หน้า ${pageNumber} ไม่สำเร็จ`, err);
|
||||
|
|
@ -693,6 +724,9 @@ function PdfPage({
|
|||
cancelled = true;
|
||||
renderTask?.cancel(); // กัน memory leak (rules/pdf-viewer.md)
|
||||
};
|
||||
// onTextLayerRendered ตั้งใจไม่อยู่ใน deps: callback ต้องเป็น useCallback [] เสถียร
|
||||
// เท่านั้น (handleTextLayerRendered) — ถ้าวันหน้าทำให้ identity เปลี่ยนได้ ต้องย้าย
|
||||
// ไปเก็บใน ref ก่อน ไม่งั้น effect นี้จะไม่รันซ้ำและค่า span count ของ chip จะเก่า
|
||||
}, [pdfjs, doc, pageNumber, width, height]);
|
||||
|
||||
const highlights = annotations.filter(isHighlight);
|
||||
|
|
@ -924,7 +958,7 @@ function PdfPage({
|
|||
<button
|
||||
type="button"
|
||||
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
|
||||
? "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"
|
||||
|
|
@ -987,7 +1021,7 @@ function PdfPage({
|
|||
<div className="mt-1 flex justify-end gap-1.5 text-[11px]">
|
||||
<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)}
|
||||
>
|
||||
ยกเลิก
|
||||
|
|
@ -995,7 +1029,7 @@ function PdfPage({
|
|||
<button
|
||||
type="button"
|
||||
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={() => {
|
||||
const msg = commentEditDraft.trim();
|
||||
if (!msg) return;
|
||||
|
|
@ -1061,6 +1095,7 @@ function PdfPage({
|
|||
onCommit={onAnnotationUpdate}
|
||||
onDelete={onErase}
|
||||
onBookmark={(y) => onAnnotationBookmark(selected, y)}
|
||||
onStartEdit={textbox.startEdit}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
|
@ -1112,11 +1147,12 @@ export default function PDFViewer({
|
|||
const [highlightOpacityPct, setHighlightOpacityPct] = useState(100);
|
||||
const [highlightColor, setHighlightColor] = useState(HIGHLIGHT_COLORS[0]);
|
||||
const [highlightMenuOpen, setHighlightMenuOpen] = useState(false);
|
||||
// UX review 2026-09-09 ข้อ 2: เมนู w-60 ของปุ่ม tool ที่อยู่ครึ่งขวาของจอ (หรือ wrap ลงแถวล่าง)
|
||||
// อาจล้นขอบขวา — วัดตำแหน่งปุ่มตอนคลิก แล้วเลือก anchor left-0/right-0 ให้เมนูอยู่ในจอ
|
||||
// (anchor ตายตัวใช้ไม่ได้: ปุ่มเดียวกันอยู่ชิดขวาบนจอกว้าง แต่ชิดซ้ายบนจอแคบที่ wrap)
|
||||
// ค่าเขียนทับใหม่ทุกครั้งที่เปิดเมนู จึงไม่ต้องเคลียร์ตอนปิด
|
||||
const [menuFlip, setMenuFlip] = useState<Record<string, boolean>>({});
|
||||
// C1: เมนู tool ทั้ง 5 + popup bookmark render ผ่าน createPortal เป็น position:fixed ตาม
|
||||
// พิกัดจริงของปุ่มตอนคลิก (fixedMenuPos clamp ไว้ในจอ) — เดิมเป็น absolute ใน scroll
|
||||
// container ของแถบ tool ที่ต้องสลับ overflow ตอนเปิด ทำให้ scroll position รีเซ็ต เมนู
|
||||
// เปิดนอกจอบนมือถือ และทั้ง app เอียงเลื่อนแนวนอนได้ / ค่าเขียนใหม่ทุกครั้งที่เปิดเมนู
|
||||
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 [user, setUser] = useState<LocalUser | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
|
|
@ -1244,6 +1280,70 @@ export default function PDFViewer({
|
|||
// anonymous identity จาก localStorage — ต้องรันใน effect (SSR ไม่มี localStorage)
|
||||
useEffect(() => {
|
||||
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)
|
||||
|
|
@ -1407,6 +1507,27 @@ export default function PDFViewer({
|
|||
const drawMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const textboxMenuRef = 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 สวนทางการเลื่อน)
|
||||
const panRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const [grabbing, setGrabbing] = useState(false);
|
||||
|
|
@ -1417,9 +1538,14 @@ export default function PDFViewer({
|
|||
{ open: drawMenuOpen, ref: drawMenuRef, close: () => setDrawMenuOpen(false) },
|
||||
{ open: textboxMenuOpen, ref: textboxMenuRef, close: () => setTextboxMenuOpen(false) },
|
||||
{ open: highlightMenuOpen, ref: highlightMenuRef, close: () => setHighlightMenuOpen(false) },
|
||||
{ open: !!bookmarkComposer, ref: bookmarkWrapRef, close: () => setBookmarkComposer(null) },
|
||||
];
|
||||
if (!menus.some((m) => m.open)) return;
|
||||
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) {
|
||||
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) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
markMenuClosed(); // M4: Escape แล้วแตะหน้าเร็ว ๆ ก็ต้องไม่สร้างรายการ
|
||||
for (const m of menus) m.close();
|
||||
}
|
||||
};
|
||||
|
|
@ -1436,7 +1563,7 @@ export default function PDFViewer({
|
|||
document.removeEventListener("mousedown", onMouseDown);
|
||||
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,
|
||||
// Esc ปิด popup/deselect — ข้ามทุก shortcut เมื่อ focus อยู่ใน input/textarea/
|
||||
|
|
@ -1778,7 +1905,7 @@ export default function PDFViewer({
|
|||
|
||||
/** จอสัมผัส: selection ล่าสุดที่หยุดนิ่งแล้ว (snapshot) + ตำแหน่งปุ่มยืนยัน —
|
||||
* บน touch ไม่มี mouseup หลังคลุมข้อความด้วยนิ้ว (synthetic mouse events เกิด
|
||||
* เฉพาะ tap) จึง commit ผ่านปุ่ม "ไฮไลต์" ลอยใต้ selection แทน — ปุ่มอ่านจาก
|
||||
* เฉพาะ tap) จึง commit ผ่านปุ่ม "ไฮไลต์ข้อความ" ลอยใต้ selection แทน — ปุ่มอ่านจาก
|
||||
* snapshot ใน ref เพราะ iOS เคลียร์ selection ทันทีที่แตะปุ่ม ก่อน click ยิง */
|
||||
const [touchConfirm, setTouchConfirm] = useState<{
|
||||
pageIndex: number;
|
||||
|
|
@ -1807,10 +1934,22 @@ export default function PDFViewer({
|
|||
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() {
|
||||
const pos = currentViewPosition();
|
||||
if (!pos) return;
|
||||
setBookmarkError(null);
|
||||
positionBookmarkPopup();
|
||||
// ชื่อ default = ข้อความที่เพิ่งคลุม ไม่มี = "หน้า N" — pre-fill ในช่องเลย (กด Enter ยอมรับได้ทันที)
|
||||
setBookmarkComposer({
|
||||
...pos,
|
||||
|
|
@ -1830,6 +1969,7 @@ export default function PDFViewer({
|
|||
a.type === "COMMENT";
|
||||
const text = meaningful ? normalizeBookmarkTitle(annotationPreviewText(a)) : "";
|
||||
setBookmarkError(null);
|
||||
positionBookmarkPopup();
|
||||
setBookmarkComposer({
|
||||
pageIndex: a.pageIndex,
|
||||
yOffset: clamp(yOffsetPct, 0, 100),
|
||||
|
|
@ -1844,11 +1984,15 @@ export default function PDFViewer({
|
|||
const el = pageWrapRefs.current[currentPage];
|
||||
if (!el) return null;
|
||||
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;
|
||||
return {
|
||||
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;
|
||||
}
|
||||
// จอสัมผัส: หลังคลุมข้อความด้วยนิ้วไม่มี mouseup ให้ commit (synthetic mouse
|
||||
// events เกิดเฉพาะ tap) → tool highlight แสดงปุ่ม "ไฮไลต์" ลอยใต้ selection
|
||||
// events เกิดเฉพาะ tap) → tool highlight แสดงปุ่ม "ไฮไลต์ข้อความ" ลอยใต้ selection
|
||||
// แทนการสร้างตอนปล่อยเมาส์ — เฉพาะอุปกรณ์ที่ pointer หลักเป็น coarse (เมาส์
|
||||
// ยังใช้ mouseup ตามเดิม ไม่โผล่ปุ่มมารบกวน)
|
||||
const coarse = window.matchMedia("(pointer: coarse)").matches;
|
||||
|
|
@ -2174,7 +2318,7 @@ export default function PDFViewer({
|
|||
}, [tool]);
|
||||
|
||||
/** สร้าง HIGHLIGHT จาก selection ที่อ่านได้ — ใช้ร่วมกันทั้ง desktop (mouseup บนหน้า)
|
||||
* และจอสัมผัส (ปุ่ม "ไฮไลต์" ลอยใต้ selection) */
|
||||
* และจอสัมผัส (ปุ่ม "ไฮไลต์ข้อความ" ลอยใต้ selection) */
|
||||
function commitHighlight(
|
||||
pageIndex: number,
|
||||
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 จะถึงปุ่ม —
|
||||
* จึงผูกกับ onPointerDown ที่ยิงก่อน click ด้วย) */
|
||||
function handleTouchConfirmHighlight() {
|
||||
|
|
@ -2282,6 +2426,9 @@ export default function PDFViewer({
|
|||
// composer/วางตรายาง (คลิกล้วน ๆ selection ถูกยุบตั้งแต่ mousedown จึงไม่โดน guard นี้)
|
||||
const selection = window.getSelection();
|
||||
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)
|
||||
// คลิกลงหน้า = เลิกเลือก annotation ก่อนทุก tool — แผงเครื่องมือของกรอบ selection
|
||||
// ต้องปิดเมื่อคลิกไปสร้างรายการใหม่ (comment/textbox/stamp เดิม return ก่อนถึง
|
||||
|
|
@ -2349,14 +2496,10 @@ export default function PDFViewer({
|
|||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
const pageTop = r.top + window.scrollY;
|
||||
// คืนมุมมองตอนบันทึก: จุด yOffset (ซึ่งเก็บจากกึ่งกลางจอตอนสร้าง) กลับไปอยู่
|
||||
// กึ่งกลางพื้นที่มองเห็นใต้ header — เดิมวางจุดนี้ไว้ที่ขอบบน viewport เลยเลื่อน
|
||||
// เกินลงล่างครึ่งจอ (bookmark หน้าแรกจึงดูเหมือน "ไม่ขึ้นบนสุด")
|
||||
const center = (viewerHeaderHeight() + window.innerHeight) / 2;
|
||||
window.scrollTo({
|
||||
top: Math.max(0, pageTop + (r.height * b.yOffset) / 100 - center),
|
||||
behavior: "smooth",
|
||||
});
|
||||
// ดันจุด yOffset กลับมาชิดใต้ sticky header — pattern เดียวกับ goToAnnotation
|
||||
// ที่ดันขอบบน bbox (yOffset เก็บจาก "ขอบบนของสิ่งที่ดูอยู่" ตอนสร้าง กดแล้วจึงได้
|
||||
// มุมมองเดิมพอดี ไม่ต้องชดเชยครึ่งจอ)
|
||||
scrollToBelowHeader(pageTop + (r.height * b.yOffset) / 100);
|
||||
}
|
||||
|
||||
function goToAnnotation(a: AnnotationDTO) {
|
||||
|
|
@ -2437,6 +2580,9 @@ export default function PDFViewer({
|
|||
|
||||
function switchTool(t: Tool) {
|
||||
setTool(t);
|
||||
// M4: ปิดเมนูเพราะเปลี่ยน tool — คลิกที่ตามมาต้องไม่กลายเป็นการวางรายการ (เฉพาะเมื่อ
|
||||
// มีเมนูเปิดจริง: คลิกปุ่ม tool เดี่ยวไม่มีเมนูให้ปิด ไม่ต้อง mark กันกลืนคลิกแรก)
|
||||
if (anyMenuOpen) markMenuClosed();
|
||||
setStampMenuOpen(false);
|
||||
setShapeMenuOpen(false);
|
||||
setDrawMenuOpen(false);
|
||||
|
|
@ -2504,32 +2650,48 @@ export default function PDFViewer({
|
|||
/ < lg = overlay เลื่อนเข้า-ออก กว้างเป็นสัดส่วนของจอกว้างกว่า 288px
|
||||
(ปิดบน desktop ใช้ lg:hidden ออกจาก flow → main เต็มจอ) */}
|
||||
<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
|
||||
? "max-lg:translate-x-0"
|
||||
: "max-lg:pointer-events-none max-lg:-translate-x-full lg:hidden"
|
||||
}`}
|
||||
>
|
||||
<AnnotationSidebar
|
||||
annotations={visibleAnnotations}
|
||||
bookmarks={optimisticBookmarks}
|
||||
fileId={file.id}
|
||||
userId={user?.userId ?? ""}
|
||||
userFilter={userFilter}
|
||||
users={users}
|
||||
onUserFilterChange={setUserFilter}
|
||||
replyCounts={replyCounts}
|
||||
onDeleteAnnotation={(id) => void handleDeleteAnnotation(id)}
|
||||
onUpdateAnnotation={(id, data) => {
|
||||
void handleUpdateAnnotation(id, data);
|
||||
}}
|
||||
onDeleteBookmark={(id) => void handleDeleteBookmark(id)}
|
||||
onAddSubBookmark={(parentId) => void handleAddSubBookmark(parentId)}
|
||||
onUpdateBookmark={(id, patch) => void handleUpdateBookmark(id, patch)}
|
||||
onGoToBookmark={goToBookmark}
|
||||
onGoToAnnotation={goToAnnotation}
|
||||
selectedId={selectedId}
|
||||
/>
|
||||
{/* 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
|
||||
annotations={visibleAnnotations}
|
||||
bookmarks={optimisticBookmarks}
|
||||
fileId={file.id}
|
||||
userId={user?.userId ?? ""}
|
||||
userFilter={userFilter}
|
||||
users={users}
|
||||
onUserFilterChange={setUserFilter}
|
||||
replyCounts={replyCounts}
|
||||
onDeleteAnnotation={(id) => void handleDeleteAnnotation(id)}
|
||||
onUpdateAnnotation={(id, data) => {
|
||||
void handleUpdateAnnotation(id, data);
|
||||
}}
|
||||
onDeleteBookmark={(id) => void handleDeleteBookmark(id)}
|
||||
onAddSubBookmark={(parentId) => void handleAddSubBookmark(parentId)}
|
||||
onUpdateBookmark={(id, patch) => void handleUpdateBookmark(id, patch)}
|
||||
onGoToBookmark={goToBookmark}
|
||||
onGoToAnnotation={goToAnnotation}
|
||||
selectedId={selectedId}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
|
|
@ -2543,13 +2705,14 @@ export default function PDFViewer({
|
|||
title="สลับแผงข้าง"
|
||||
aria-label={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)}
|
||||
>
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
</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}
|
||||
</h1>
|
||||
|
||||
|
|
@ -2560,7 +2723,7 @@ export default function PDFViewer({
|
|||
type="button"
|
||||
aria-label="หน้าก่อนหน้า"
|
||||
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)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
|
|
@ -2572,7 +2735,7 @@ export default function PDFViewer({
|
|||
type="button"
|
||||
aria-label="หน้าถัดไป"
|
||||
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)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
|
|
@ -2582,7 +2745,7 @@ export default function PDFViewer({
|
|||
<button
|
||||
type="button"
|
||||
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))}
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
|
|
@ -2593,7 +2756,7 @@ export default function PDFViewer({
|
|||
<button
|
||||
type="button"
|
||||
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))}
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
|
|
@ -2606,25 +2769,36 @@ export default function PDFViewer({
|
|||
<Link
|
||||
href={`/annotate/${file.id}/summary`}
|
||||
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" />
|
||||
</Link>
|
||||
</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 ตรงที่ปุ่มเอง
|
||||
(F5/F18) — container ไม่ใช้ overflow-hidden เพื่อให้เมนูยื่นออกได้ มุมมน
|
||||
ของ active bg จึงจัดที่ปุ่มแรก/สุดท้ายแทน
|
||||
UX review 2026-09-09: ปุ่ม wrap ภายในกรอบได้บนจอแคบ */}
|
||||
<div className="flex flex-wrap rounded-lg border border-slate-300">
|
||||
UX review 2026-09-09: บน desktop ปุ่ม wrap ภายในกรอบได้ / M5 2026-09-14:
|
||||
จอแคบเป็นแถวเดียว ไหลตามแถบทั้งแถวที่เลื่อนแนวนอน */}
|
||||
<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) => {
|
||||
const corner = i === 0 ? "rounded-l-lg" : i === TOOLS.length - 1 ? "rounded-r-lg" : "";
|
||||
const activeCls = tool === key
|
||||
? "bg-primary text-white"
|
||||
: "text-slate-500 hover:bg-slate-100";
|
||||
const activeCls = `${
|
||||
tool === key ? "bg-primary text-white" : "text-slate-500 hover:bg-slate-100"
|
||||
} ${FOCUS_RING_CLS}`;
|
||||
|
||||
// Highlight — dropdown เลือกรูปแบบมาร์ก (4 แบบ) + สี; คลุดข้อความแล้วสร้างทันที
|
||||
if (key === "highlight") {
|
||||
|
|
@ -2638,10 +2812,9 @@ export default function PDFViewer({
|
|||
aria-label={label}
|
||||
aria-haspopup="menu"
|
||||
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) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
setMenuFlip((m) => ({ ...m, highlight: r.left + 248 > window.innerWidth }));
|
||||
openToolMenu(e.currentTarget);
|
||||
if (tool === "highlight") setHighlightMenuOpen((o) => !o);
|
||||
else {
|
||||
switchTool("highlight");
|
||||
|
|
@ -2652,10 +2825,24 @@ export default function PDFViewer({
|
|||
<CurrentIcon className="h-4 w-4" />
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
{highlightMenuOpen && (
|
||||
{highlightMenuOpen &&
|
||||
menuPos &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<div
|
||||
data-tool-menu=""
|
||||
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 }) => (
|
||||
<button
|
||||
|
|
@ -2688,7 +2875,8 @@ export default function PDFViewer({
|
|||
onChange={setHighlightOpacityPct}
|
||||
/>
|
||||
</PanelRow>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -2705,10 +2893,9 @@ export default function PDFViewer({
|
|||
aria-label={label}
|
||||
aria-haspopup="menu"
|
||||
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) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
setMenuFlip((m) => ({ ...m, draw: r.left + 248 > window.innerWidth }));
|
||||
openToolMenu(e.currentTarget);
|
||||
if (tool === "draw") setDrawMenuOpen((o) => !o);
|
||||
else {
|
||||
switchTool("draw"); // switchTool ปิดเมนูก่อน แล้วค่อยเปิดทีหลัง
|
||||
|
|
@ -2719,10 +2906,22 @@ export default function PDFViewer({
|
|||
<CurrentPenIcon className="h-4 w-4" />
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
{drawMenuOpen && (
|
||||
{drawMenuOpen &&
|
||||
menuPos &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<div
|
||||
data-tool-menu=""
|
||||
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) => {
|
||||
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">
|
||||
เมื่อวาดเส้นเกือบตรงแล้วปล่อยเมาส์ ระบบจะ Snap ให้เป็นเส้นตรงอัตโนมัติ — และถ้ากด Shift ค้างจะบังคับให้เส้นตรง
|
||||
</p>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -2794,10 +2994,9 @@ export default function PDFViewer({
|
|||
aria-label={label}
|
||||
aria-haspopup="menu"
|
||||
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) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
setMenuFlip((m) => ({ ...m, shape: r.left + 248 > window.innerWidth }));
|
||||
openToolMenu(e.currentTarget);
|
||||
if (tool === "shape") setShapeMenuOpen((o) => !o);
|
||||
else {
|
||||
switchTool("shape"); // switchTool ปิดเมนูก่อน แล้วค่อยเปิดทีหลัง
|
||||
|
|
@ -2808,10 +3007,22 @@ export default function PDFViewer({
|
|||
<Icon className="h-4 w-4" />
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
{shapeMenuOpen && (
|
||||
{shapeMenuOpen &&
|
||||
menuPos &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<div
|
||||
data-tool-menu=""
|
||||
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 }) => (
|
||||
<button
|
||||
|
|
@ -2843,7 +3054,8 @@ export default function PDFViewer({
|
|||
onChange={(v) => setShapeWidthPct(v / 10)}
|
||||
/>
|
||||
</PanelRow>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -2859,10 +3071,9 @@ export default function PDFViewer({
|
|||
aria-label={label}
|
||||
aria-haspopup="menu"
|
||||
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) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
setMenuFlip((m) => ({ ...m, stamp: r.left + 248 > window.innerWidth }));
|
||||
openToolMenu(e.currentTarget);
|
||||
if (tool === "stamp") setStampMenuOpen((o) => !o);
|
||||
else {
|
||||
switchTool("stamp");
|
||||
|
|
@ -2873,10 +3084,22 @@ export default function PDFViewer({
|
|||
<Icon className="h-4 w-4" />
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
{stampMenuOpen && (
|
||||
{stampMenuOpen &&
|
||||
menuPos &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<div
|
||||
data-tool-menu=""
|
||||
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) => (
|
||||
<button
|
||||
|
|
@ -2909,7 +3132,8 @@ export default function PDFViewer({
|
|||
onChange={setStampScalePct}
|
||||
/>
|
||||
</PanelRow>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -2925,10 +3149,9 @@ export default function PDFViewer({
|
|||
aria-label={label}
|
||||
aria-haspopup="menu"
|
||||
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) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
setMenuFlip((m) => ({ ...m, textbox: r.left + 248 > window.innerWidth }));
|
||||
openToolMenu(e.currentTarget);
|
||||
if (tool === "textbox") setTextboxMenuOpen((o) => !o);
|
||||
else {
|
||||
switchTool("textbox"); // switchTool ปิดเมนูก่อน แล้วค่อยเปิดทีหลัง
|
||||
|
|
@ -2939,10 +3162,22 @@ export default function PDFViewer({
|
|||
<Icon className="h-4 w-4" />
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
{textboxMenuOpen && (
|
||||
{textboxMenuOpen &&
|
||||
menuPos &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<div
|
||||
data-tool-menu=""
|
||||
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="สี">
|
||||
<ColorSwatches value={textboxColor} onChange={setTextboxColor} />
|
||||
|
|
@ -3015,7 +3250,8 @@ export default function PDFViewer({
|
|||
))}
|
||||
</div>
|
||||
</PanelRow>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -3027,7 +3263,7 @@ export default function PDFViewer({
|
|||
type="button"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className={`px-2 py-1.5 transition ${corner} ${activeCls}`}
|
||||
className={`px-2 py-2 transition ${corner} ${activeCls}`}
|
||||
onClick={() => switchTool(key)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
|
|
@ -3044,14 +3280,17 @@ export default function PDFViewer({
|
|||
</p>
|
||||
)}
|
||||
|
||||
{/* F2: Undo/Redo — ติดกลุ่มปุ่ม tool ในแถวเดียวกัน, disabled ตาม stack */}
|
||||
<div className="flex items-center rounded-lg border border-slate-300">
|
||||
{/* F2: Undo/Redo — ติดกลุ่มปุ่ม tool ในแถวเดียวกัน, disabled ตาม stack
|
||||
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
|
||||
type="button"
|
||||
title="Undo (Ctrl+Z)"
|
||||
aria-label="ย้อนการกระทำล่าสุด"
|
||||
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()}
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
|
|
@ -3061,7 +3300,7 @@ export default function PDFViewer({
|
|||
title="Redo (Ctrl+Shift+Z)"
|
||||
aria-label="ทำซ้ำการกระทำ"
|
||||
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()}
|
||||
>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
|
|
@ -3070,24 +3309,49 @@ export default function PDFViewer({
|
|||
|
||||
{/* N2: ปุ่ม 🔖 เปิด popup บันทึก bookmark — ชื่อ/โน้ต/วางระดับบนสุดหรือใต้หัวข้อ
|
||||
(อยู่แถบ tool ชิดขวา — popup เปิดลงด้านล่างทับหน้าเอกสาร) */}
|
||||
<div className="relative ml-auto">
|
||||
<div ref={bookmarkWrapRef} className="relative ml-auto max-lg:shrink-0">
|
||||
<button
|
||||
ref={bookmarkBtnRef}
|
||||
type="button"
|
||||
title={`เพิ่ม bookmark (หน้า ${currentPage + 1})`}
|
||||
title={`เพิ่ม bookmark หน้า ${currentPage + 1} (B)`}
|
||||
aria-label={`เพิ่ม bookmark หน้า ${currentPage + 1} (B)`}
|
||||
disabled={!user}
|
||||
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" />
|
||||
</button>
|
||||
{bookmarkComposer && (
|
||||
{bookmarkComposer &&
|
||||
bookmarkPos &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<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()}
|
||||
>
|
||||
<p className="text-[11px] font-semibold text-slate-700">
|
||||
บันทึก bookmark — หน้า {bookmarkComposer.pageIndex + 1}
|
||||
</p>
|
||||
{/* บอกจุดที่ bookmark จะชี้ (เก็บจากตำแหน่งที่กำลังดูอยู่ตอนเปิด popup)
|
||||
กันความเข้าใจผิดว่าชี้ที่หัวข้อที่ตั้งชื่อไว้ */}
|
||||
<p className="mt-0.5 text-[11px] text-slate-400">
|
||||
จะชี้ที่: หน้า {bookmarkComposer.pageIndex + 1} ·{" "}
|
||||
{bookmarkComposer.yOffset <= 25
|
||||
? "ส่วนบน"
|
||||
: bookmarkComposer.yOffset <= 75
|
||||
? "ราวกลาง"
|
||||
: "ส่วนล่าง"}{" "}
|
||||
ของหน้า
|
||||
</p>
|
||||
<input
|
||||
autoFocus
|
||||
onFocus={(e) => e.target.select()} // เลือกข้อความ default ทั้งหมด — พิมพ์ทับ/Enter ยอมรับได้ทันที
|
||||
|
|
@ -3162,15 +3426,17 @@ export default function PDFViewer({
|
|||
บันทึก
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* F8/C1: export PDF ฝัง annotation (redaction ติดไปเสมอ — D4) */}
|
||||
<button
|
||||
type="button"
|
||||
title="Export PDF พร้อม annotation"
|
||||
className="rounded-lg border border-slate-300 px-2 py-1.5 text-slate-500 hover:bg-slate-100"
|
||||
title="Export PDF พร้อม annotation (เปิดแท็บใหม่)"
|
||||
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={() =>
|
||||
window.open(`/api/annotations/export?fileId=${file.id}`, "_blank")
|
||||
}
|
||||
|
|
@ -3178,6 +3444,11 @@ export default function PDFViewer({
|
|||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<main
|
||||
|
|
@ -3214,6 +3485,16 @@ export default function PDFViewer({
|
|||
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 ? (
|
||||
<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;
|
||||
void handleCreateAnnotation(i, "REDACTION", data);
|
||||
}}
|
||||
onTextLayerRendered={handleTextLayerRendered}
|
||||
onErase={(id) => void handleDeleteAnnotation(id)}
|
||||
/>
|
||||
) : null}
|
||||
|
|
@ -3391,7 +3673,7 @@ export default function PDFViewer({
|
|||
className="h-3.5 w-3.5 rounded-[2px]"
|
||||
style={{ backgroundColor: highlightColor }}
|
||||
/>
|
||||
ไฮไลต์
|
||||
ไฮไลต์ข้อความ
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -3404,7 +3686,7 @@ export default function PDFViewer({
|
|||
|
||||
{composer && (
|
||||
<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={{
|
||||
left: clamp(composer.x, 8, Math.max(8, window.innerWidth - 304)),
|
||||
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">
|
||||
<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)}
|
||||
>
|
||||
ยกเลิก
|
||||
|
|
@ -3435,7 +3717,7 @@ export default function PDFViewer({
|
|||
<button
|
||||
type="button"
|
||||
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()}
|
||||
>
|
||||
บันทึก
|
||||
|
|
|
|||
|
|
@ -244,7 +244,7 @@ export default function TextboxStampLayer({
|
|||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
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) =>
|
||||
editingId === t.id ? (
|
||||
<TextboxEditor
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export const INK_COLORS = [
|
|||
export function PanelRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<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}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -36,7 +36,7 @@ export function PanelRow({ label, children }: { label: string; children: ReactNo
|
|||
/** แถวปุ่มสีวงกลม preset + ปุ่มสีรุ้ง (color picker เลือกสีอื่นได้ไม่จำกัด) */
|
||||
export function ColorSwatches({ value, onChange }: { value: string; onChange: (c: string) => void }) {
|
||||
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) => (
|
||||
<button
|
||||
key={c}
|
||||
|
|
@ -155,7 +155,7 @@ export function NumberCombo({
|
|||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="relative"
|
||||
className="number-combo relative"
|
||||
onKeyDown={(e) => {
|
||||
// Esc ปิดแค่ dropdown นี้ก่อน (stopPropagation ไม่ให้ปิดทั้งเมนู/กรอบ)
|
||||
if (e.key === "Escape" && open) {
|
||||
|
|
@ -181,26 +181,28 @@ export function NumberCombo({
|
|||
if (e.key !== "Escape") e.stopPropagation(); // พิมพ์ในช่องต้องไม่ชนคีย์ลัด F3 (Esc ปล่อยไหลไปปิดเมนู)
|
||||
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">
|
||||
{/* combo-step: ปุ่ม ▲▼ ซ้อนแนวตั้ง 2 ชั้น — ต้องหลุดจาก rule touch 36px
|
||||
ของ dropdown (globals.css) ไม่งั้นคอลัมน์สูง 72px ดึงทั้งแถวบวมตาม */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="เพิ่มค่า"
|
||||
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)}
|
||||
>
|
||||
<ChevronUp className="h-2.5 w-2.5" />
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="ลดค่า"
|
||||
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)}
|
||||
>
|
||||
<ChevronDown className="h-2.5 w-2.5" />
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -260,7 +262,7 @@ export const HIGHLIGHT_STYLE_BTNS: {
|
|||
label: string;
|
||||
Icon: ComponentType<{ className?: string }>;
|
||||
}[] = [
|
||||
{ key: "highlight", label: "ไฮไลต์", Icon: Highlighter },
|
||||
{ key: "highlight", label: "ไฮไลต์ข้อความ", Icon: Highlighter },
|
||||
{ key: "underline", label: "เส้นใต้", Icon: Underline },
|
||||
{ key: "strikethrough", label: "ขีดฆ่า", Icon: Strikethrough },
|
||||
{ key: "squiggle", label: "ขยุกขยิก", Icon: SquiggleIcon },
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ import type {
|
|||
import { annotationNote, annotationPreviewText } from "@/utils/annotation-display";
|
||||
|
||||
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") {
|
||||
const style = (data as HighlightData).style ?? "highlight";
|
||||
if (style === "underline") return <Underline className={cls} />;
|
||||
|
|
@ -376,7 +376,7 @@ export default function AnnotationSidebar({
|
|||
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"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-slate-400">วางที่</p>
|
||||
<p className="mt-1 text-[11px] text-slate-500">วางที่</p>
|
||||
<Select
|
||||
value={eb.parentId ?? ""}
|
||||
onChange={(e) =>
|
||||
|
|
@ -415,7 +415,7 @@ export default function AnnotationSidebar({
|
|||
<button
|
||||
type="button"
|
||||
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)}
|
||||
>
|
||||
{collapsed ? (
|
||||
|
|
@ -445,10 +445,10 @@ export default function AnnotationSidebar({
|
|||
>
|
||||
{b.title}
|
||||
{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 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 && (
|
||||
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-slate-500">
|
||||
{b.note}
|
||||
|
|
@ -462,7 +462,7 @@ export default function AnnotationSidebar({
|
|||
type="button"
|
||||
aria-label="แก้ไข bookmark"
|
||||
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)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
|
|
@ -470,7 +470,7 @@ export default function AnnotationSidebar({
|
|||
<button
|
||||
type="button"
|
||||
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)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
|
|
@ -483,7 +483,7 @@ export default function AnnotationSidebar({
|
|||
type="button"
|
||||
aria-label="เพิ่มหน้าที่ดูอยู่เป็น 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)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
|
|
@ -526,7 +526,7 @@ export default function AnnotationSidebar({
|
|||
<TypeIcon type={a.type} data={a.data} />
|
||||
<span className="min-w-0">
|
||||
<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.type === "COMMENT" && (replyCounts[a.id] ?? 0) > 0
|
||||
? ` · ตอบ ${replyCounts[a.id]}`
|
||||
|
|
@ -544,7 +544,7 @@ export default function AnnotationSidebar({
|
|||
? "text-emerald-500"
|
||||
: copyFailedId === a.id
|
||||
? "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)}
|
||||
>
|
||||
|
|
@ -567,7 +567,7 @@ export default function AnnotationSidebar({
|
|||
className={`shrink-0 ${
|
||||
note
|
||||
? "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))}
|
||||
>
|
||||
|
|
@ -579,7 +579,7 @@ export default function AnnotationSidebar({
|
|||
type="button"
|
||||
aria-label="ดูประวัติการเปลี่ยนแปลง"
|
||||
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)}
|
||||
>
|
||||
<Clock className="h-4 w-4" />
|
||||
|
|
@ -587,7 +587,7 @@ export default function AnnotationSidebar({
|
|||
<button
|
||||
type="button"
|
||||
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)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
|
|
@ -665,7 +665,7 @@ export default function AnnotationSidebar({
|
|||
onSubmit={(e) => void runSearch(e)}
|
||||
>
|
||||
<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
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
|
|
@ -685,13 +685,13 @@ export default function AnnotationSidebar({
|
|||
|
||||
{searchResults !== null && (
|
||||
<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})`
|
||||
: "ผลการค้นหา"}
|
||||
</p>
|
||||
{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">
|
||||
{searchResults.map((a) => (
|
||||
|
|
@ -708,7 +708,7 @@ export default function AnnotationSidebar({
|
|||
<TypeIcon type={a.type} data={a.data} />
|
||||
<span className="min-w-0">
|
||||
<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}
|
||||
</span>
|
||||
</span>
|
||||
|
|
@ -723,8 +723,8 @@ export default function AnnotationSidebar({
|
|||
<div className="flex shrink-0 border-b border-slate-200">
|
||||
{(
|
||||
[
|
||||
["bookmarks", `Bookmarks (${bookmarks.length})`],
|
||||
["annotations", `Annotations (${annotations.length})`],
|
||||
["bookmarks", `ที่คั่นหน้า (${bookmarks.length})`],
|
||||
["annotations", `หมายเหตุ (${annotations.length})`],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<button
|
||||
|
|
@ -733,7 +733,7 @@ export default function AnnotationSidebar({
|
|||
className={`flex-1 px-2 py-2 text-xs font-medium transition ${
|
||||
tab === key
|
||||
? "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)}
|
||||
>
|
||||
|
|
@ -745,7 +745,7 @@ export default function AnnotationSidebar({
|
|||
<div ref={listRef} className="flex-1 overflow-y-auto p-2">
|
||||
{tab === "bookmarks" ? (
|
||||
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 เพื่อเพิ่ม
|
||||
</p>
|
||||
) : (
|
||||
|
|
@ -757,14 +757,17 @@ export default function AnnotationSidebar({
|
|||
</ul>
|
||||
)
|
||||
) : annotations.length === 0 ? (
|
||||
<p className="p-4 text-center text-xs text-slate-400">
|
||||
ยังไม่มี annotation — คลุมดำ / วาดเส้น / คลิกวาง comment บนหน้าเอกสาร
|
||||
<p className="p-4 text-center text-xs text-slate-500">
|
||||
ยังไม่มี annotation — คลุมข้อความ / วาดเส้น / คลิกวาง comment บนหน้าเอกสาร
|
||||
<span className="mt-1 block text-[11px]">
|
||||
คีย์ลัด: H มาร์ก · D วาด · C comment · B คั่นหน้า · Ctrl+Z ย้อน
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{pendingItems.length > 0 && (
|
||||
<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>
|
||||
<ul className="space-y-1">{pendingItems.map(renderAnnotationRow)}</ul>
|
||||
|
|
@ -772,7 +775,7 @@ export default function AnnotationSidebar({
|
|||
)}
|
||||
{resolvedItems.length > 0 && (
|
||||
<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>
|
||||
<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="w-80 rounded-lg border border-slate-200 bg-white p-4 shadow-xl">
|
||||
<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)}
|
||||
</p>
|
||||
{historyError && (
|
||||
|
|
@ -803,10 +806,10 @@ export default function AnnotationSidebar({
|
|||
</p>
|
||||
)}
|
||||
{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 && (
|
||||
<p className="mt-3 text-xs text-slate-400">
|
||||
<p className="mt-3 text-xs text-slate-500">
|
||||
ยังไม่มีบันทึกการเปลี่ยนแปลง (เกิดขึ้นเมื่อแก้ไขหรือลบรายการนี้)
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -818,7 +821,7 @@ export default function AnnotationSidebar({
|
|||
<span className="block text-xs font-medium">
|
||||
{v.kind === "delete" ? "ถูกลบ" : "ถูกแก้ไข"}
|
||||
</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")}
|
||||
</span>
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -151,8 +151,10 @@ Index: `(file_id)`
|
|||
- **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
|
||||
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)
|
||||
4. ตอบ `application/pdf` + `Content-Disposition: attachment; filename="<title>-annotated.pdf"`
|
||||
3. ลำดับวาดต่อหน้า: HIGHLIGHT rect (fill alpha ~0.35) → DRAW polyline (strokeWidth % → pt) → SHAPE → REDACTION **ดำทึบ #000 ทับจริง** → TEXTBOX/STAMP ถ้าทำแล้ว (F17/F18)
|
||||
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
|
||||
|
||||
### 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 |
|
||||
| F6 | B3 | tool `erase` + `hitTestPath` | F5 | ลากผ่านเส้น/รูปทรงของตัวเอง → หายทันที; ของคนอื่นไม่หาย; แท็บ B เห็นการลบ |
|
||||
| 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) |
|
||||
| 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 ขนาดจริง) |
|
||||
|
|
@ -278,7 +280,7 @@ Dependencies เพิ่มเติม: F3 ← F2 · F12/F13/F19 ← F1 · F15
|
|||
### 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 กำพร้า
|
||||
- **การสร้าง:** ปุ่ม 🔖 บน 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"
|
||||
- **ปุ่ม bookmark ในแผง selection (F1 toolbar):** ปุ่ม 🔖 ท้ายแผงเครื่องมือของ annotation ที่เลือก → เปิด popup ที่หน้า + yOffset ตามขอบบน bbox ของรายการนั้น โดยชื่อ default = ข้อความของรายการ (HIGHLIGHT/TEXTBOX/STAMP/COMMENT — ชนิดที่ไม่มีข้อความจริงใช้ "หน้า N")
|
||||
- **แก้ไข (✏ เจ้าของเท่านั้น):** inline ใน sidebar — แก้ชื่อ + โน้ต + ย้ายหัวข้อ (select กรองเป้าหมายที่ย้ายได้จริงตามเกณฑ์ depth+height และตัดตัวเอง/ลูกหลานออก — server ตรวจซ้ำพร้อม lock)
|
||||
|
|
|
|||
BIN
docs/requirement/1.png
Normal file
BIN
docs/requirement/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
BIN
docs/requirement/2.png
Normal file
BIN
docs/requirement/2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 359 KiB |
98
docs/requirement/gap-analysis.md
Normal file
98
docs/requirement/gap-analysis.md
Normal 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 ตามกระบวนการของโปรเจกต์
|
||||
|
|
@ -32,7 +32,7 @@ describe("annotationTypeLabel", () => {
|
|||
expect(
|
||||
annotationTypeLabel(dto("HIGHLIGHT", { text: "a", color: "#000", rects: [], style: "underline" })),
|
||||
).toBe("เส้นใต้");
|
||||
expect(annotationTypeLabel(dto("HIGHLIGHT", { text: "a", color: "#000", rects: [] }))).toBe("ไฮไลต์");
|
||||
expect(annotationTypeLabel(dto("HIGHLIGHT", { text: "a", color: "#000", rects: [] }))).toBe("ไฮไลต์ข้อความ");
|
||||
});
|
||||
|
||||
it("ครบทุกชนิดที่เหลือ", () => {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import type {
|
|||
|
||||
/** ชื่อรูปแบบการมาร์กข้อความ (HIGHLIGHT.style) */
|
||||
export const HIGHLIGHT_STYLE_LABELS: Record<HighlightStyle, string> = {
|
||||
highlight: "ไฮไลต์",
|
||||
highlight: "ไฮไลต์ข้อความ",
|
||||
underline: "เส้นใต้",
|
||||
strikethrough: "ขีดฆ่า",
|
||||
squiggle: "ขยุกขยิก",
|
||||
|
|
@ -49,7 +49,9 @@ export function effectiveDrawOpacity(d: { pen?: DrawPen; opacity?: number }): nu
|
|||
|
||||
// ค่า default ตอนสร้าง TEXTBOX (F17/B2) — fontSize เป็น % ของความกว้างหน้า
|
||||
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 ใช้ร่วมกัน
|
||||
export const STAMP_PRESETS: readonly string[] = [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue