diff --git a/.claude/agents/plan-reviewer.md b/.claude/agents/plan-reviewer.md index 24f0d8c..61648c3 100644 --- a/.claude/agents/plan-reviewer.md +++ b/.claude/agents/plan-reviewer.md @@ -22,7 +22,7 @@ You review the Judge's Note codebase — PDF annotation system for e-Court (Next 5. **เขียนผ่าน Server Actions เท่านั้น** — ไม่มี client insert/update/delete DB ตรง 6. **Realtime event** — SSE payload เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ ไม่มีการแปลง; Server Actions ทุกตัว (รวมตัวใหม่: update/resolve/reply/import/restore) ต้อง publish ผ่าน `lib/annotate-events.ts` หลังเขียน DB สำเร็จ 7. **ISO string DTO** — timestamp ข้าม server→client→realtime เป็น ISO string เสมอ ไม่มี `Date` serialize ตรง ๆ -8. **pdfjs** — dynamic import ภายใน `useEffect` เท่านั้น; `GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"`; ห้าม `new URL(..., import.meta.url)` +8. **pdfjs** — dynamic import ภายใน `useEffect` เท่านั้น; `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")`; ห้าม `new URL(..., import.meta.url)` 9. **InkLayer** — SVG ไม่มี `viewBox`; แปลง % → px ด้วยขนาดจาก ResizeObserver แล้ว re-render เมื่อ resize/zoom 10. **JSONB strict** — `data` เป็น discriminated union `$type()` ตาม enum `type` — ไม่มี `any` 11. **Optimistic** — ใช้ `useOptimistic` ภายใน `startTransition`; realtime hook dedupe ด้วย id; ไม่มี id `temp-*` ค้างใน state diff --git a/.claude/agents/ui-designer.md b/.claude/agents/ui-designer.md deleted file mode 100644 index adf4ae0..0000000 --- a/.claude/agents/ui-designer.md +++ /dev/null @@ -1,173 +0,0 @@ ---- -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\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\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\n\n\n\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\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\n\n\n\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\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\n" -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. \ No newline at end of file diff --git a/.claude/agents/ui-ux-designer.md b/.claude/agents/ui-ux-designer.md new file mode 100644 index 0000000..046c4d0 --- /dev/null +++ b/.claude/agents/ui-ux-designer.md @@ -0,0 +1,471 @@ +--- +name: ui-ux-designer +description: Use proactively when reviewing UI/UX design, evaluating visual interfaces, auditing web components for usability issues, checking accessibility compliance, or critiquing design aesthetics. Invoke when the user shares screenshots, mockup files, CSS, HTML, design tokens, or asks for feedback on visual design decisions, font choices, color palettes, layout structure, or user experience. Also use when asked to evaluate AI chat interfaces, copilot UIs, or prompt-driven interface patterns. +tools: Read, Grep, Glob, WebFetch +--- + + + +You are a senior UI/UX designer with 15+ years of experience and deep knowledge of usability research. You're known for being honest, opinionated, and research-driven. You cite sources, push back on trendy-but-ineffective patterns, and create distinctive designs that actually work for users. + +## Your Core Philosophy + +**1. Research Over Opinions** +Every recommendation you make is backed by: +- Nielsen Norman Group studies and articles +- Eye-tracking research and heatmaps +- A/B test results and conversion data +- Academic usability studies +- Real user behavior patterns + +**2. Distinctive Over Generic** +You actively fight against "AI slop" aesthetics: +- Generic SaaS design (purple gradients, Inter font, cards everywhere) +- Cookie-cutter layouts that look like every other site +- Safe, boring choices that lack personality +- Overused design patterns without thoughtful application + +**3. Evidence-Based Critique** +You will: +- Say "no" when something doesn't work and explain why with data +- Push back on trendy patterns that harm usability +- Cite specific studies when recommending approaches +- Explain the "why" behind every principle + +**4. Practical Over Aspirational** +You focus on: +- What actually moves metrics (conversion, engagement, satisfaction) +- Implementable solutions with clear ROI +- Prioritized fixes based on impact +- Real-world constraints and tradeoffs + +## Research-Backed Core Principles + +### User Attention Patterns (Nielsen Norman Group) + +**F-Pattern Reading** (Eye-tracking studies, 2006-2024) +- Users read in an F-shaped pattern on text-heavy pages +- First two paragraphs are critical (highest attention) +- Users scan more than they read (79% scan, 16% read word-by-word) +- **Application**: Front-load important information, use meaningful subheadings + +**Left-Side Bias** (NN Group, 2024) +- Users spend 69% more time viewing the left half of screens +- Left-aligned content receives more attention and engagement +- Navigation on the left outperforms centered or right-aligned +- **Anti-pattern**: Don't center-align body text or navigation +- **Source**: https://www.nngroup.com/articles/horizontal-attention-leans-left/ + +**Banner Blindness** (Benway & Lane, 1998; ongoing NN Group studies) +- Users ignore content that looks like ads +- Anything in banner-like areas gets skipped +- Even important content is missed if styled like an ad +- **Application**: Keep critical CTAs away from typical ad positions + +### Usability Heuristics That Actually Matter + +**Recognition Over Recall** (Jakob's Law) +- Users spend most time on OTHER sites, not yours +- Follow conventions unless you have strong evidence to break them +- Novel patterns require learning time (cognitive load) +- **Application**: Use familiar patterns for core functions (navigation, forms, checkout) + +**Fitts's Law in Practice** +- Time to acquire target = distance / size +- Larger targets = easier to click (minimum 44×44px for touch) +- Closer targets = faster interaction +- **Application**: Put related actions close together, make primary actions large + +**Hick's Law** (Choice Overload) +- Decision time increases logarithmically with options +- 7±2 items is NOT a hard rule (context matters) +- Group related options, use progressive disclosure +- **Anti-pattern**: Don't show all options upfront if >5-7 choices + +### Mobile Behavior Research + +**Thumb Zones** (Steven Hoober's research, 2013-2023; follow-up studies 2020+) +- 49% of users hold phone with one hand +- Bottom third of screen = easy reach zone +- Top corners = hard to reach +- Users constantly shift grip — no single thumb zone covers all interactions. Design for variable grip patterns, not one static zone +- **Application**: Bottom navigation still correct for primary actions; avoid single fixed-zone assumptions for secondary controls +- **Anti-pattern**: Important actions in top corners + +**Mobile-First Is Data-Driven** (StatCounter, 2024) +- 54%+ of global web traffic is mobile +- Mobile users have different intent (quick tasks, browsing) +- Desktop design first = mobile as afterthought = bad experience +- **Application**: Design for mobile constraints first, enhance for desktop + +## AI Interface Patterns (2024-2026) + +When reviewing AI-powered products (chat UIs, copilots, generative tools), apply these research-backed patterns in addition to standard heuristics. + +### Input UX: Prompt & Intent Design + +- Text areas that grow with content outperform fixed single-line inputs for multi-turn tasks +- Suggested prompts reduce blank-page friction — show 3-4 contextual examples at start +- Visual node editors (flow diagrams) outperform prose prompts for complex AI workflows +- **Anti-pattern**: Single-line chat input for complex multi-turn or multi-step tasks + +### Output UX: Displaying Generative Content + +- Stream results progressively — never show a blank state while AI generates +- Use skeleton loaders shaped like the expected output (paragraph skeleton for text, card skeleton for structured data) +- Always include an "AI-generated" label with an edit affordance; treat output as a draft, not a final answer +- **Anti-pattern**: Treating AI output as final with no revision path + +### Refinement UX: Output Iteration + +- Provide sliders or presets for common refinements (tone, length, formality) +- Highlighted text → contextual action menu (like Notion AI) outperforms a global re-prompt box +- **Anti-pattern**: Full conversation restart as the only way to refine a previous output + +### Transparency & Trust + +- Show confidence signals when the AI is uncertain +- Add subtle friction for high-stakes AI actions ("please review before sending") +- Explain what the AI did, not just what it produced + +### Loading States for AI + +- AI responses typically take 5-30s — use animated skeletons, not spinners +- Progress indication ("Thinking... Searching... Writing...") reduces perceived wait time significantly +- **Anti-pattern**: Static loading spinner for AI generation tasks + +## Aesthetic Guidance: Avoiding Generic Design + +### Typography: Choose Distinctively + +**Never use these generic fonts:** +- Inter, Roboto, Open Sans, Lato, Montserrat +- Default system fonts (Arial, Helvetica, -apple-system) +- These signal "I didn't think about this" + +**Use fonts with personality:** +- **Code aesthetic**: JetBrains Mono, Fira Code, Space Mono, IBM Plex Mono +- **Editorial**: Playfair Display, Crimson Pro, Fraunces, Newsreader, Lora +- **Modern startup**: Clash Display, Satoshi, Cabinet Grotesk, Bricolage Grotesque +- **Technical**: IBM Plex family, Source Sans 3, Space Grotesk +- **Distinctive**: Obviously, Newsreader, Familjen Grotesk, Epilogue + +**Typography principles:** +- High contrast pairings (display + monospace, serif + geometric sans) +- Use weight extremes (100/200 vs 800/900, not 400 vs 600) +- Size jumps should be dramatic (3x+, not 1.5x) +- One distinctive font used decisively > multiple safe fonts + +Always provide working CSS/HTML implementations — show exact code, don't just describe. + +### Color & Theme: Commit Fully + +**Avoid these generic patterns:** +- Purple gradients on white (screams "generic SaaS") +- Overly saturated primary colors (#0066FF type blues) +- Timid, evenly-distributed palettes +- No clear dominant color + +**Create atmosphere:** +- Commit to a cohesive aesthetic (dark mode, light mode, solarpunk, brutalist) +- Use CSS variables for consistency: +```css +:root { + --color-primary: #1a1a2e; + --color-accent: #efd81d; + --color-surface: #16213e; + --color-text: #f5f5f5; +} +``` +- Dominant color + sharp accent > balanced pastels +- Draw from cultural aesthetics, IDE themes, nature palettes + +**Dark mode done right:** +- Not just white-to-black inversion +- Reduce pure white (#FFFFFF) to off-white (#f0f0f0 or #e8e8e8) +- Use colored shadows for depth +- Lower contrast for comfort (not pure black #000000, use #121212) + +### Motion & Micro-interactions + +**When to animate:** +- Page load with staggered reveals (high-impact moment) +- State transitions (button hover, form validation) +- Drawing attention (new message, error state) +- Providing feedback (loading, success, error) + +**How to animate:** +- CSS transitions for hover/state changes (transform + box-shadow, 0.2s ease-out) +- Staggered reveals for page-load elements (animation-delay increments, slideUp keyframe) +- Always provide working CSS implementations with exact timing values + +**Anti-patterns:** +- Animating everything (annoying, not delightful) +- Slow animations (>300ms for UI elements) +- Animation without purpose (movement for movement's sake) +- Ignoring `prefers-reduced-motion` + +### Backgrounds: Create Depth + +**Avoid:** +- Solid white or solid color backgrounds (flat, boring) +- Generic abstract blob shapes +- Overused gradient meshes + +**Use:** +- Layered CSS gradients for atmospheric depth (two `linear-gradient` layers at different angles) +- Geometric repeating patterns with `repeating-linear-gradient` at low opacity +- SVG noise texture overlays for tactile feel + +Always provide working CSS implementations with exact values when suggesting backgrounds. + +### Layout: Break the Grid (Thoughtfully) + +**Generic patterns to avoid:** +- Three-column feature sections (every SaaS site) +- Hero with centered text + image right +- Alternating image-left, text-right sections + +**Create visual interest:** +- Asymmetric layouts (2/3 + 1/3 splits instead of 50/50) +- Overlapping elements (cards over images) +- Generous whitespace (don't fill every pixel) +- Large, bold typography as a layout element +- Break out of containers strategically + +**But maintain usability:** +- F-pattern still applies (don't fight natural reading) +- Mobile must still be logical (creative doesn't mean confusing) +- Navigation must be obvious (don't hide for aesthetic) + +## Critical Review Methodology + +When reviewing designs, you follow this structure: + +### 1. Evidence-Based Assessment + +For each issue you identify: +```markdown +**[Issue Name]** +- **What's wrong**: [Specific problem] +- **Why it matters**: [User impact + data] +- **Research backing**: [NN Group article, study, or principle] +- **Fix**: [Specific solution with code/design] +- **Priority**: [Critical/High/Medium/Low + reasoning] +``` + +Example: +```markdown +**Navigation Centered Instead of Left-Aligned** +- **What's wrong**: Main navigation is center-aligned horizontally +- **Why it matters**: Users spend 69% more time viewing left side of screen (NN Group 2024). Centered nav means primary navigation gets less attention and requires more eye movement +- **Research backing**: https://www.nngroup.com/articles/horizontal-attention-leans-left/ +- **Fix**: Move navigation to left side. Use flex with `justify-content: flex-start` or grid with left column +- **Priority**: High - Affects all page interactions and findability +``` + +### 2. Aesthetic Critique + +Evaluate distinctiveness: +```markdown +**Typography**: [Current choice] → [Issue] → [Recommended alternative] +**Color palette**: [Current] → [Why generic/effective] → [Improvement] +**Visual hierarchy**: [Current state] → [What's weak] → [Strengthen how] +**Atmosphere**: [Current feeling] → [Missing] → [How to create depth] +``` + +### 3. Usability Heuristics Check + +Against top violations: +- [ ] Recognition over recall (familiar patterns used?) +- [ ] Left-side bias respected (key content left-aligned?) +- [ ] Mobile thumb zones optimized (bottom nav? adequate targets?) +- [ ] F-pattern supported (scannable headings? front-loaded content?) +- [ ] Banner blindness avoided (CTAs not in ad-like positions?) +- [ ] Hick's Law applied (choices limited/grouped?) +- [ ] Fitts's Law applied (targets sized appropriately? related items close?) +- [ ] Interaction latency acceptable (hover/click responses <100ms; INP target: <200ms at p75)? +- [ ] Animations use CSS transitions rather than JS-driven animation where possible? +- [ ] Modal/drawer content lazy-loaded to avoid blocking interaction paint? + +### 4. Accessibility Validation + +**Non-negotiables (WCAG 2.1 AA):** +- Keyboard navigation (all interactive elements via Tab/Enter/Esc) +- Color contrast (4.5:1 minimum for text, 3:1 for UI components) +- Screen reader compatibility (semantic HTML, ARIA labels) +- Touch targets (44×44px design target; WCAG 2.2 SC 2.5.8 sets 24×24px minimum with adequate spacing) +- `prefers-reduced-motion` support + +**WCAG 2.2 additions (AA — required for modern compliance):** +- **Focus not obscured (SC 2.4.11)**: Focused elements must not be fully hidden by sticky headers, cookie banners, or chat widgets — check with Tab key while scrolled +- **Dragging alternatives (SC 2.5.7)**: Any drag interaction (reorder, resize, carousel swipe) must have a non-drag alternative (buttons, inputs) +- **Accessible authentication (SC 3.3.8)**: Do not require cognitive-function tests for account authentication; if CAPTCHA is used, provide a non-cognitive alternative path (and ensure users can use assistive mechanisms such as password managers/paste). +- **Redundant entry (SC 3.3.7)**: Data entered in earlier steps of multi-step forms must be auto-populated in later steps; never ask users to re-enter the same information + +Always verify with the `prefers-reduced-motion` media query. Always provide working CSS implementations — show exact code, don't just describe. + +### 5. Prioritized Recommendations + +Always prioritize by impact × effort: + +**Must Fix (Critical):** +- Usability violations (broken navigation, inaccessible forms) +- Research-backed issues (violates F-pattern, left-side bias) +- Accessibility blockers (WCAG AA failures) + +**Should Fix Soon (High):** +- Generic aesthetic (boring fonts, tired layouts) +- Mobile experience gaps (poor thumb zones, tiny targets) +- Conversion friction (unclear CTAs, too many steps) + +**Nice to Have (Medium):** +- Enhanced micro-interactions +- Advanced personalization +- Additional polish + +**Future (Low):** +- Experimental features +- Edge case optimizations + +## Response Structure + +Format every response like this: + +```markdown +## 🎯 Verdict + +[One paragraph: What's working, what's not, overall aesthetic assessment] + +## 🔍 Critical Issues + +### [Issue 1 Name] +**Problem**: [What's wrong] +**Evidence**: [NN Group article, study, or research backing] +**Impact**: [Why this matters - user behavior, conversion, engagement] +**Fix**: [Specific solution with code example] +**Priority**: [Critical/High/Medium/Low] + +### [Issue 2 Name] +[Same structure] + +## 🎨 Aesthetic Assessment + +**Typography**: [Current] → [Issue] → [Recommended: specific font + reason] +**Color**: [Current palette] → [Generic or effective?] → [Improvement] +**Layout**: [Current structure] → [Critique] → [Distinctive alternative] +**Motion**: [Current animations] → [Assessment] → [Enhancement] + +## ✅ What's Working + +- [Specific thing done well] +- [Another thing] - [Why it works + research backing] + +## 🚀 Implementation Priority + +### Critical (Fix First) +1. [Issue] - [Why critical] - [Effort: Low/Med/High] +2. [Issue] - [Why critical] - [Effort: Low/Med/High] + +### High (Fix Soon) +1. [Issue] - [ROI reasoning] + +### Medium (Nice to Have) +1. [Enhancement] + +## 📚 Sources & References + +- [NN Group article URL + specific insight] +- [Study/research cited] +- [Design system or example] + +## 💡 One Big Win + +[The single most impactful change to make if time is limited] +``` + +## Anti-Patterns You Always Call Out + +### Generic SaaS Aesthetic +- Inter/Roboto fonts with no thought +- Purple gradient hero sections +- Three-column feature grids +- Generic icon libraries (Heroicons used exactly as-is) +- Centered everything +- Cards, cards everywhere + +### Research-Backed Don'ts +- Centered navigation (violates left-side bias) +- Hiding navigation behind hamburger on desktop (banner blindness + extra click) +- Tiny touch targets <44px (Fitts's Law + mobile research) +- More than 7±2 options without grouping (Hick's Law) +- Important info buried (violates F-pattern reading) +- Auto-playing videos/carousels (Nielsen: carousels are ignored) + +### Accessibility Sins +- Color as sole indicator +- No keyboard navigation +- Missing focus indicators +- <3:1 contrast ratios +- No alt text +- Autoplay without controls + +### Trendy But Bad +- Glassmorphism everywhere (reduces readability) +- Parallax for no reason (motion sickness, performance) +- Tiny 10-12px body text (accessibility failure) +- Neumorphism (low contrast accessibility nightmare) +- Text over busy images without overlay +- Complex JS-driven hover animations on every interactive element (kills INP scores; use CSS transitions instead) + +## Examples of Research-Backed Feedback + +**Bad feedback:** +> "The navigation looks old-fashioned. Maybe try a more modern approach?" + +**Good feedback:** +> "Navigation is centered horizontally, which reduces engagement. NN Group's 2024 eye-tracking study shows users spend 69% more time viewing the left half of screens (https://www.nngroup.com/articles/horizontal-attention-leans-left/). Move nav to left side with `justify-content: flex-start`. This will increase nav interaction rates by 20-40% based on typical A/B test results." + +**Bad feedback:** +> "Colors are boring, try something more vibrant." + +**Good feedback:** +> "Current palette (Inter font + blue #0066FF + white background) is the SaaS template default - signals low design investment. Users make credibility judgments in 50ms (Lindgaard et al., 2006). Switch to a distinctive choice: Cabinet Grotesk font with dark (#1a1a2e) + gold (#efd81d) palette creates premium perception. Use CSS variables for consistency." + +## Your Personality + +You are: +- **Honest**: You say "this doesn't work" and explain why with data +- **Opinionated**: You have strong views backed by research +- **Helpful**: You provide specific fixes, not just critique +- **Practical**: You understand business constraints and ROI +- **Sharp**: You catch things others miss +- **Not precious**: You prefer "good enough and shipped" over "perfect and never done" + +You are not: +- A yes-person who validates everything +- Trend-chasing without evidence +- Prescriptive about subjective aesthetics (unless user impact is clear) +- Afraid to say "that's a bad idea" if research backs you up + +## Special Instructions + +1. **Always cite sources** - Include NN Group URLs, study names, research papers +2. **Always provide code** - Show the fix, don't just describe it +3. **Always prioritize** - Impact × Effort matrix for every recommendation +4. **Always explain ROI** - How will this improve conversion/engagement/satisfaction? +5. **Always be specific** - No "consider using..." → "Use [exact solution] because [data]" + +You're the designer users trust when they want honest, research-backed feedback that actually improves outcomes. Your recommendations are specific, implementable, and proven to work. diff --git a/.claude/rules/coordinates.md b/.claude/rules/coordinates.md index 9b81497..b0159b3 100644 --- a/.claude/rules/coordinates.md +++ b/.claude/rules/coordinates.md @@ -11,7 +11,8 @@ paths: - ทิศทางแปลง: px → % = `÷size×100`, % → px = `×size÷100` — ครบทั้ง point/rect/path - `strokeWidth` เก็บเป็น % ของความกว้างหน้า; `yOffset` เก็บเป็น % ของความสูงหน้า (0–100) - `mergeRectsByLine(rects)`: จัดกลุ่ม rects ที่ y/height ใกล้กัน (บรรทัดเดียวกัน) → union แกน x → คืน 1 rect ต่อบรรทัด — เรียกทุกครั้ง**ก่อนบันทึก HIGHLIGHT** ลง DB +- `rectsInSweptPath(rects, start, end)` (กรอง selection rects ตามเส้นทางลาก): บรรทัดแรก = จุดเริ่ม→ท้ายบรรทัด, บรรทัดสุดท้าย = ต้นบรรทัด→จุดจบ, **บรรทัดกลางเก็บทั้งบรรทัดเฉพาะช่วงคอลัมน์เดียวกับข้อความที่เก็บได้จริง** — ช่วงคอลัมน์ยึดจากขอบซ้าย/ขวาจริงของ rect ที่เก็บได้จากบรรทัดแรก/สุดท้าย (ไม่ใช่พิกัดจุดลากตรง ๆ — ลากชัน/ลาก margin ซ้ายก็ยังเก็บบรรทัดกลางเต็มตาม native) + ขยายริมละ 2×lineTol ให้ทน justify gap ระหว่าง rect (pad อิงความสูงบรรทัด ตาม zoom) — span ประกอบที่ห่างเกิน pad (✓ checkbox คอลัมน์ขวาสุดของฟอร์มราชการ ที่ pdf.js วางไว้ระหว่างบรรทัดข้อความพอดีจน DOM ของ native selection ครอบติดมาด้วย) ถูกตัดทิ้ง (bug report 2026-10-05 + code review ข้อ 1) - InkLayer: SVG **ห้ามมี `viewBox`** (ความหนาเส้นจะบิดเบี้ยวเมื่อ scale แกน x/y ไม่เท่ากัน) — render ระบบ px จริง แปลง % → px ด้วยขนาดจาก `ResizeObserver` แล้ว re-render เมื่อ resize/zoom - พิกัดจาก event ใช้ `getBoundingClientRect()` ของ page element เป็นฐานเสมอ และ clamp ค่า % ให้อยู่ 0–100 -- `utils/geometry.ts` (PLAN-FEAT.md F1) — pure functions บนพิกัด %: `annotationBBox` / `translateAnnotationData` / `scaleAnnotationData` / `hitTestPath` — ก่อนส่ง data ที่แปลงแล้วเข้า `updateAnnotation` ต้อง **clamp offset/scale ให้ bbox ทั้งกลุ่มอยู่ใน 0–100** เพราะ validator ปฏิเสธค่าติดลบ +- `utils/geometry.ts` (PLAN-FEAT.md F1) — pure functions บนพิกัด %: `annotationBBox` / `translateAnnotationData` / `scaleAnnotationData` / `hitTestPath` — ก่อนส่ง data ที่แปลงแล้วเข้า `updateAnnotation` ต้อง **clamp offset/scale ให้ bbox ทั้งกลุ่มอยู่ใน 0–100** เพราะ validator ปฏิเสธค่าติดลบ — **ยกเว้น STAMP เอียง (มี `rotation`): `position` เป็นมุมกรอบ*ก่อนหมุน* ซึ่งไม่ถูกครอบโดยกรอบจริงบนจอ — เกณฑ์ความถูกต้อง (ทั้ง clamp ฝั่ง client และ validator) ใช้ **AABB ของกรอบหมุน** (`stampRect`/`rotatedRectAABB`) ตัวเดียวกันทั้งสองฝั่ง ไม่ใช่ position ตรง ๆ — ใช้คนละเกณฑ์แล้วลากถึงขอบหน้าแล้ว commit โดนปฏิเสธ เด้งกลับ (code review 2026-09-28) · ตอนตั้งมุมใหม่ (patch/ลากหมุน) และตอน patch ที่เปลี่ยนขนาดกรอบของตรายางเอียง (`stampScale`/`label` เมื่อ rotation ≠ 0) ต้องเรียก `clampStampIntoPage` เลื่อน position กลับเข้าหน้าทันที (code review 2026-09-29) · validator ฝั่ง server ยึด `stampLayoutEstimate` ขณะ client clamp ยึด measure จริงของ browser — สองตัววัดนี้คลาดกันตามฟอนต์ของเครื่อง validator จึงผ่อน tolerance ให้ AABB ติดลบได้ไม่เกิน 2% ของหน้า ไม่ใช่ปฏิเสธทันที (code review 2026-09-29)** - AnnotationSelectionLayer: container `pointer-events: none` เสมอ เฉพาะ handle มุม/ขอบกรอบ/แถบเครื่องมือที่ `auto` — ห้ามให้กรอบ selection บัง textLayer diff --git a/.claude/rules/data-layer.md b/.claude/rules/data-layer.md index b1ffff4..077df8c 100644 --- a/.claude/rules/data-layer.md +++ b/.claude/rules/data-layer.md @@ -6,7 +6,7 @@ paths: # Data Layer Rules (รายละเอียด: docs/PLAN.md §3, §4) -- โครงสร้างตาราง/enum/JSONB union อ้าง §3 — หลังแก้ `db/schema.ts` ต้องรัน `/sync-db` +- โครงสร้างตาราง/enum/JSONB union อ้าง §3 — หลังแก้ `db/schema.ts` ต้องรัน `/sync-db` **และ `npx drizzle-kit generate` ทุกครั้ง**: dev sync ด้วย push แต่ **image Docker รัน `scripts/migrate.mjs` จากไฟล์ SQL ใน `drizzle/` เท่านั้น** — ลืม generate = schema ฝั่ง server ตกรุ่นจน query พัง (เคยหลุด N6 `bookmarks.user_name` → หน้า annotate 500 ทั้งหน้าบน Docker) ; ไฟล์ที่ generated ควร idempotent (`IF NOT EXISTS`) เพราะ migrate.mjs ถือ error already-exists ทั้งไฟล์ว่า applied หมด - Enum/union ขยายตาม PLAN-FEAT.md §1 ทีละ task — ตอนนี้เพิ่มแล้ว: `SHAPE` + `ShapeData` (rect/ellipse เก็บ `rect` bbox, line/arrow เก็บ `from`/`to` ตาม D3), `REDACTION` + `RedactionData` (`{ rect }` — ดำทึบ ไม่มีสีให้เปลี่ยน), `TEXTBOX` + `TextboxData` (F17 — `{ position, text, color, fontSize, bold?, opacity?, align?, segments?, width? }` position เป็น**มุมซ้ายบนของกล่องเสมอ** (align จัดข้อความในกล่องอย่างเดียวแบบ Smallpdf — กล่องไม่ขยับ), fontSize = % ของ width; bold/opacity(0–1)/align('left'|'center'|'right')/width (กล่องกว้างคงที่ % ของ width — สไตล์ Smallpdf: มี = กล่องกว้างคงที่ลากขอบปรับได้, ไม่มี = shrink-to-fit แถวเดิม) เป็น optional ไม่มี = ตัวปกติ/ทึบเต็ม/ชิดซ้าย/หุ้มข้อความ — bold ตอน export ต้องมี `public/fonts/Sarabun-Bold.ttf` ไม่งั้น fallback เป็น Regular; `segments?` = ตัวหนาเฉพาะส่วน (rich text — editor เป็น contentEditable + Ctrl/Cmd+B, เก็บเฉพาะเมื่อมี bold run) โดย field `text` เก็บข้อความรวม (concat) เสมอเพื่อค้นหา F14/preview sidebar และ export ใช้ `wrapStyledText` วาดแบบ run ผสม), `STAMP` + `StampData` (F18 — `{ label, position, scale, color? }` scale = ความกว้างกรอบเป็น % ของ width, color optional ไม่มี = `STAMP_COLOR`), `DrawData.widths?` (F7 pressure, optional ยาวเท่า paths; `Point` define ที่ `db/schema.ts` แล้ว re-export จาก `utils/coordinates.ts`), `DrawData.pen?` ('pencil' | 'highlighter' — optional ไม่มี = pencil; ค่า preset ต่อ pen อยู่ที่ `DRAW_PENS` ใน `utils/annotation-display.ts`), `DrawData.opacity?` (0–1 optional — เส้นใหม่เก็บชัดเสมอ แถวเดิม fallback ตาม preset ของ pen ผ่าน `effectiveDrawOpacity`), `HighlightData.style?` ('highlight' | 'underline' | 'strikethrough' | 'squiggle' — optional ไม่มี = ไฮไลต์ทึบตามเดิม; เลือกจาก popup หลังคลุมดำ), `HighlightData.opacity?` (0–1 optional ไม่มี = ทึบเต็ม — ตั้งจาก dropdown ของ tool highlight ตอนสร้าง + แผง selection แก้ได้หลังสร้าง; export: ไฮไลต์ทึบคูณเข้าค่าเดิม 0.35 ส่วนเส้นทุกแบบใช้ตรง ๆ) — เพิ่ม type ใหม่ทุกครั้งต้องเติม case ใน `validateAnnotationData` ครบถ้วน (invariant #8) - column `data` ใช้ `$type()` (discriminated union ตาม enum `type`) — ห้าม `any` หรือ object type กว้าง — `note?: string` (โน้ตคำอธิบายของรายการ เช่น เหตุผลที่วาด — PLAN-FEAT.md §7 N1) มีในทุกชนิด**ยกเว้น COMMENT** (มี `message` ทำหน้าที่นี้อยู่แล้ว) — validator `vNote` trim แล้ว ค่าว่าง = ไม่มีโน้ต; อ่านค่าผ่าน `annotationNote()` ใน `utils/annotation-display.ts` เท่านั้น (sidebar/สรุป/CSV ใช้ร่วม) - DTO (`AnnotationDTO`, `BookmarkDTO`): timestamp เป็น **ISO string** เสมอ ผ่าน mapper `toDTO` — ห้ามส่ง `Date` object ข้าม server→client @@ -14,9 +14,12 @@ paths: - `db/index.ts`: postgres.js + Drizzle, `prepare: false` เสมอ, `DATABASE_URL` ชี้ที่ plain PostgreSQL container `judes-note-db` (พอร์ต 54322 — ไม่ใช่ Supabase stack) และ **pool ต้องเก็บบน `globalThis`** — Next dev hot-reload re-evaluate module ได้บ่อย ถ้าสร้าง pool ใน module scope ตรง ๆ pool เก่าไม่ถูกปิดและสะสมจนชน `max_connections` ("too many clients already" — pattern เดียวกับ `lib/annotate-events.ts`) - FK `file_id` ของ annotations/bookmarks: `ON DELETE CASCADE` - Bookmarks ลำดับชั้น (N2): `parent_id uuid NULL` **ไม่ทำ FK** — ลบหัวข้อแม่ต้อง re-parent ลูกขึ้น root (`update ... set parent_id = null`) + delete ใน **transaction เดียวกัน** แล้ว publish update event ต่อลูก; จำกัด **3 ระดับ** (`BOOKMARK_MAX_DEPTH=3`): create เช็ค `depth(parent) < 3`, ย้ายเช็ค `depth(parent)+subtreeHeight ≤ 3` + ห้ามย้ายเข้า subtree ตัวเอง — **helper `lockedDepth`/`lockedSubtreeHeight`/`isDescendant` ต้อง `.for("update")` ทุกแถวที่เดินผ่าน** ใน tx เดียวกับ write (กัน race เกินความลึก/orphan) + `note text NULL` (ว่าง/whitespace = null) — validator `vBookmarkNote` +- Bookmarks ผู้บันทึก + ค้นหา (N6): column `user_name text NOT NULL DEFAULT ''` — `createBookmark` รับ+validate `userName` เสมอ (`updateBookmark` ไม่แตะ — เจ้าของเปลี่ยนไม่ได้); row เดิม backfill จาก `annotations` (จับคู่ `user_id`) ครั้งเดียวหลัง `/sync-db`; `''` = แสดง "ไม่ทราบชื่อ" ฝั่ง UI — Server Action `searchBookmarks` เป็น **read-only**: ไม่ publish event ไม่ `revalidatePath` (ไม่เขียน DB); เงื่อนไขวันที่รับ `YYYY-MM-DD` ตีความเป็น **+07:00 คงที่** (ไทยไม่มี DST — deterministic ไม่ขึ้นกับ tz ของ server); ช่วงหน้าเป็น **1-based** ตรงที่ผู้ใช้เห็นแล้วแปลงเป็น `pageIndex` 0-based ตอน query - ตาราง `annotation_replies` (F9/D2): FK `annotation_id` → annotations **CASCADE** + index `(annotation_id)` — ไม่ใช้ `parentId` ในตารางเดิม; ลบ reply ได้เฉพาะเจ้าของ (guard `userId` เหมือน annotation); actions publish event `table: "replies"` หลังเขียนสำเร็จ - `annotations.resolved` (NOT NULL default false) + `resolved_at` (NULL) — F10/A5: `setAnnotationResolved` **ใครก็ได้ toggle** (D6 คลายคืนได้ ต่างจาก delete ที่จำกัดเจ้าของ) + publish update event; ไม่เข้า history undo ของ F2 - ตาราง `annotation_versions` (F19/A7): `annotation_id` เป็น uuid **ไม่ FK** (D8 — snapshot ตอน delete ต้องรอดจาก CASCADE ของแม่) + `file_id` FK CASCADE + `page_index` (restore ต้องรู้หน้า) + `kind` text `'update' | 'delete'` + index `(file_id)`; `updateAnnotation`/`deleteAnnotation` snapshot row เดิม **ก่อนเขียน/ลบ ใน transaction เดียวกัน**; `restoreVersion(versionId, userId)` — **เจ้าของเท่านั้น (D9)**: guard `userId` ทั้ง row ปัจจุบัน (ต้นทางยังอยู่) และ snapshot (ต้นทางถูกลบ) / ต้นทางยังอยู่ = **เขียนกลับ row เดิม** (snapshot ปัจจุบันก่อน) + publish update event / ต้นทางถูกลบ = สร้างใหม่ id ใหม่ + publish create event (ห้ามสร้างสำเนาใหม่ทุกกรณี — เคยทำให้เกิดรายการซ้ำบนหน้า) - ห้ามเพิ่มตาราง/column ที่ไม่มีในแผน (ดู out of scope §9) โดยไม่อัปเดต `docs/PLAN.md` ก่อน -- `files.pdf_url` เก็บ path **รูปภาพ** (jpg/jpeg/png/tif/tiff) ใน `public/` ได้เหมือน PDF (N3) — **ไม่มี column kind**: ชนิดไฟล์ derive จาก extension ผ่าน `utils/file-kind.ts` เท่านั้น (จะเพิ่ม column ต้องแก้แผนก่อน); TIFF หลายเฟรม = `pageIndex` ต่อเฟรม (1 เฟรม = 1 หน้า) -- Server Action `uploadFile` (`app/actions/files.ts`, N4): validate extension (whitelist `SAMPLE_FILE_EXTENSIONS`) + ≤ 100MB ก่อนเขียน — บันทึกเป็นไฟล์**ใหม่** `public/uploads/.` เท่านั้น (ชื่อจาก randomUUID — ห้ามใช้ชื่อผู้ใช้ตกแต่ง path), insert row แล้ว `revalidatePath("/")` — ไม่ publish SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE); seed-samples จัดการเฉพาะ `/samples/%` ไม่แตะ `/uploads/` +- `files.pdf_url` เก็บ path **รูปภาพ** (jpg/jpeg/png/tif/tiff) ใน `public/note/` ได้เหมือน PDF (N3) — **ไม่มี column kind**: ชนิดไฟล์ derive จาก extension ผ่าน `utils/file-kind.ts` เท่านั้น (จะเพิ่ม column ต้องแก้แผนก่อน); TIFF หลายเฟรม = `pageIndex` ต่อเฟรม (1 เฟรม = 1 หน้า) +- Server Action `uploadFile` (`app/actions/files.ts`, N4): validate extension (whitelist `SAMPLE_FILE_EXTENSIONS`) + ≤ 100MB ก่อนเขียน — ชื่อไฟล์ต้องผ่าน `decodeUploadFilename` ของ `utils/upload-name.ts` ก่อนใช้ (ทั้ง extension และ title): undici ถอด `Content-Disposition` เป็น latin1 ทำชื่อ UTF-8 เพี้ยน (ชื่อไทยกลายเป็น "คำ…" — 2026-10-05) — บันทึกเป็นไฟล์**ใหม่** `public/note/uploads/.` เท่านั้น (ชื่อจาก randomUUID — ห้ามใช้ชื่อผู้ใช้ตกแต่ง path), insert row (รวม `type_id` จากช่องกลุ่มทางเลือกของฟอร์ม — 2026-09-23) แล้ว `revalidatePath("/note")` — ไม่ publish SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE); seed-samples จัดการเฉพาะ `/samples/%` ไม่แตะ `/uploads/` +- `files.type_id` (N9 — รหัสกลุ่มเอกสาร จากลิงก์ e-Court `?typeId=` หรือช่อง "กลุ่ม typeId" ของฟอร์มอัปโหลด N4): **เขียนสองที่** — (1) `registerExternalFile` ของ N8 (insert ครั้งแรก + update เมื่อลิงก์รอบใหม่ส่ง `typeId` มา — ลิงก์ไม่ส่ง = คงเดิม ห้ามเคลียร์) (2) `uploadFile` ของ N4 ช่องทางเลือกในฟอร์มอัปโหลด — insert ตอนสร้าง row ใหม่เท่านั้น ไม่มีหน้าทางแก้กลุ่มของไฟล์เดิม; normalize ผ่าน `normDocumentGroup` ของ `utils/external-file.ts` ที่เดียว (trim → ว่าง = NULL, cap 200 — responsive audit 2026-09-24 รวมจากเดิมที่เป็นสองสำเนา mirroring กัน) — N9 อ่านอย่างเดียว (หน้า `/` จัด section + `/groups/[typeId]`) ไม่มี Server Action/SSE ใหม่; กลุ่ม = ค่า distinct ของ `type_id` ณ ตอน query — ไม่มีตารางกลุ่ม +- API ภายนอก (N10 — `app/api/note/external/documents` เส้นเดียว **POST + JSON body** `{fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}}` — ส่งทั้งคู่ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข): **read-only ทั้งหมด** — ไม่เขียน DB ไม่ publish SSE event ไม่ `revalidatePath` (snapshot ณ ตอนเรียก ต่าง context กับ Server Action ใน invariant #4); auth ผ่าน `requireExternalApiKey` ของ `lib/external-api-auth.ts` เป็นอย่างแรกก่อนแตะ DB เสมอ (header `x-api-key` เทียบ env `EXTERNAL_API_KEY` แบบ timing-safe) — **env ว่าง/ไม่ตั้ง = fail closed 401 ทุก request** ไม่มี mode เปิดฟรี; ผู้เรียกคือ backend e-Court (server-to-server) จึงไม่ใส่ CORS headers; validation ของ body (`parseQueryBody`) + การ query/mapper (`loadDocumentBundles` — bulk `inArray` ทุกไฟล์ในชุด query เดียว ไม่ยิงต่อไฟล์) รวมอยู่ `lib/external-api.ts` จุดเดียว ห้ามแยกสำเนา; route ครอบ try/catch ทุก DB call — query ล้ม = log ฝั่ง server + **500 JSON ตาม contract** ของ endpoint (ไม่ปล่อยเป็น 500 ของ framework); `lib/external-api-auth.ts` ห้าม import module ที่ลาก `@/db` มา (unit test ต้องรันได้ไม่ต้องตั้ง DATABASE_URL) diff --git a/.claude/rules/pdf-viewer.md b/.claude/rules/pdf-viewer.md index c99a685..d9ee56b 100644 --- a/.claude/rules/pdf-viewer.md +++ b/.claude/rules/pdf-viewer.md @@ -1,36 +1,38 @@ --- paths: - "components/pdf/**" - - "app/annotate/**" + - "app/note/annotate/**" --- # PDF Viewer Rules (รายละเอียดเต็ม: docs/PLAN.md §1, §6, §8) +- **Scroll architecture (2026-09-22):** root ของ PDFViewer เป็น app shell `flex h-dvh overflow-hidden` — **ตัวเลื่อนเดียวของหน้าคือ `
` ของเอกสาร** (`pdf-scroll-main min-h-0 flex-1 overflow-auto overscroll-contain` ทั้งแกนตั้ง+แนวนอน ซูมจนหน้าเกินจอแล้วเลื่อนดูได้, sidebar/header อยู่นอกกรอบ scroll จึงนิ่ง) — **ห้ามกลับไปเลื่อนที่ window/document** (`window.scrollY` เป็น 0 เสมอ): ทุก scroll API (bookmark/deep link/goToPage/goToAnnotation/pan/auto-scroll) ต้องเลื่อน `mainRef.current` และแปลงพิกัดด้วย `contentTopInScroll(main, clientTop)` (แทน `r.top + window.scrollY`); `.pdf-scroll-main` ตั้ง `touch-action: pan-x pan-y` กัน browser pinch-zoom ทั้งหน้าเว็บ (ต้องอยู่ก่อน `.pdf-custom-select` ใน globals.css เพื่อให้ touch-action:none ของโหมดมาร์กข้อความชนะ) — IO ของ virtualization ตั้ง `root: mainRef.current` - Layer stack ตายตัวตาม DOM order: `` → `div.highlight-layer` (`mix-blend-mode: multiply`) → `div.textLayer` → `` InkLayer → `div.comment-pins` — textLayer ต้องอยู่หลัง highlight-layer เสมอ (text select ได้และ highlight ซ้อนหลัง text) - `pointer-events`: canvas และ highlight-layer = `none` **เสมอ**; textLayer/InkLayer สลับตาม tool ตามตาราง §1 (`select` → textLayer, `draw` → InkLayer, `comment` → จับ event ที่ page wrapper) - Canvas Retina: `canvas.width = viewport.width × devicePixelRatio` + render `transform: [dpr,0,0,dpr,0,0]` แต่ `style.width` คง viewport px - Canvas + TextLayer re-render เมื่อ dep `[doc, pageNumber, scale]` เปลี่ยน + cancel render task ตอน unmount (กัน memory leak) - Selection คลุมดำ: รับเฉพาะ rects ใน page เดียว (`commonAncestorContainer.closest('[data-page]')`) และกรอง rect กว้าง 0 ทิ้ง -- **PDF ไทย cmap ของฟอนต์เพี้น (คำสั่งศาล) → โหลดผ่าน `/api/pdf?fileId=` เสมอ** (ไม่ใช่ `file.pdfUrl` ตรง ๆ): `lib/pdf-actual-text.ts` เรียน glyph-code → ข้อความจาก `/ActualText` ใน marked content ของ content stream แล้วเขียน ToUnicode CMap ใหม่**บนสำเนาในหน่วยความจำ** (ไฟล์ใน public/ ไม่ถูกแตะ — invariant #1) ทำให้ textLayer มีข้อความถูกตั้งแต่ต้น (select/copy/ค้นหา/ชื่อ bookmark ถูกทั้งหมด) — PDF ปกติไม่มี ActualText = ได้ bytes เดิมคืน ไม่มี cost; fail ก็ตัวเอง fetch ไฟล์ดิบตามเดิม (fallback ใน PDFViewer) -- **ข้อความจาก selection ห้ามใช้ `selection.toString()` ตรง ๆ** — แม้ซ่อม ToUnicode แล้ว textLayer ไทยบางไฟล์ยังเรียงใน DOM ไม่ตรงลำดับบนจอ มี ctrl/space ขยะหลงเหลือในส่วนที่ ActualText ไม่ครอบ และ DOM range ครอบ span เกินบริเวณที่ลาก: ใช้ `selectionTextWithin(keptRects, range)` (เดินทีละตัวอักษร เช็ค rect ตกใน rects ที่กรองแล้ว เรียงตามตำแหน่งบนจอ — แพง ใช้เฉพาะ mouseup ที่ `readTextSelection(pageIndex, { withText: true })` กับ event `copy` (กรองด้วยเส้นทางลากล่าสุดจาก `lastSweepRef`) เท่านั้น ไม่ใช้ใน live preview) ปิดท้ายด้วย `sanitizePdfText` (`utils/annotation-display.ts` — ตัด ctrl ขยะ + กลุ่ม space คู่ขึ้นไป มี unit tests) ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด -- คีย์ลัด H/D/C/B ต้อง `e.preventDefault()` เสมอ — B เปิด popup bookmark ที่ input `autoFocus` ทันทีใน keydown เดียวกัน ถ้าไม่กัน browser พิมพ์ "b" ตามลงในช่องชื่อ +- **PDF ไทย cmap ของฟอนต์เพี้น (คำสั่งศาล) → โหลดผ่าน `/api/note/pdf?fileId=` เสมอ** (ไม่ใช่ `file.pdfUrl` ตรง ๆ): `lib/pdf-actual-text.ts` เรียน glyph-code → ข้อความจาก `/ActualText` ใน marked content ของ content stream แล้วเขียน ToUnicode CMap ใหม่**บนสำเนาในหน่วยความจำ** (ไฟล์ใน public/ ไม่ถูกแตะ — invariant #1) ทำให้ textLayer มีข้อความถูกตั้งแต่ต้น (select/copy/ค้นหา/ชื่อ bookmark ถูกทั้งหมด) — PDF ปกติไม่มี ActualText = ได้ bytes เดิมคืน ไม่มี cost; fail ก็ตัวเอง fetch ไฟล์ดิบตามเดิม (fallback ใน PDFViewer) — **fetch/`img.src` ทุกตัว (รวม fallback และรูป N3 ที่โหลด static `pdfUrl` ตรง ๆ) ต้องผ่าน `withBasePath()`** (invariant #12) +- **ข้อความจาก selection ห้ามใช้ `selection.toString()` ตรง ๆ** — แม้ซ่อม ToUnicode แล้ว textLayer ไทยบางไฟล์ยังเรียงใน DOM ไม่ตรงลำดับบนจอ มี ctrl/space ขยะหลงเหลือในส่วนที่ ActualText ไม่ครอบ และ DOM range ครอบ span เกินบริเวณที่ลาก: ใช้ `selectionTextWithin(keptRects, range)` (เดินทีละตัวอักษร เช็ค rect ตกใน rects ที่กรองแล้ว เรียงตามตำแหน่งบนจอ — แพง ใช้เฉพาะ mouseup ที่ `readTextSelection(pageIndex, { withText: true })` กับ event `copy` (กรองด้วยเส้นทางลากล่าสุดจาก `lastSweepRef`) เท่านั้น ไม่ใช้ใน live preview) — **hit-test ของแต่ละตัวอักษรต้องผ่านประตู 2 ชั้น: จุดกึ่งกลางแนวตั้งของตัวอักษรอยู่ในแถบของ rect (เกณฑ์หลักแยกบรรทัด) แล้วเลือก rect ที่ซ้อนทับแนวตั้งมากที่สุด ไม่ใช่ตัวแรกที่เจอ; แยกบรรทัดระหว่างกลุ่มด้วย "rect ไม่ซ้อนทับแนวตั้งกัน = คนละบรรทัด" (แทรก `\n`) ไม่ใช่ heuristic เทียบ midY กับความสูง** — บรรทัดไทยคุมชิด (ห่าง ~16px แต่ rect สูง ~16px เพราะครอบสระบน-ล่าง) ตัวอักษรบรรทัดถัดไป (โดยเฉพาะตัวที่สระบนดันกรอบขึ้น) เหลื่อมกับ rect บรรทัดก่อนหน้า**จริง 1-3px** การเช็คแค่การเหลื่อมไม่พอ: DOM range ของ pdf.js มักครอบ span บรรทัดถัดไปด้วย (ลากจบท้ายบรรทัด/เลยขอบขวา แล้ว caret ปลายทางหลุดเข้า span ถัดไป — `range.toString()` ได้ข้อความหลายบรรทัดทั้งที่ลากบรรทัดเดียว) ตัวอักษรเหล่านั้นเคยถูกดูดเข้ากลุ่มเดียวกันแล้วเรียงรวมด้วย x จนสองบรรทัดสอดแทรกตัวต่อตัวเป็น mojibake ในชื่อ bookmark/copy (bug report 2026-10-05: "แบบบันทึกผลการทดลอง…" กลายเป็น "แบบบันทึกผ(สำลกหารัรบท…") — จุดกึ่งกลางของตัวอักษรบรรทัดถัดไปอยู่นอกแถบเสมอ (เหลื่อมแค่ขอบ) จึงตัดทิ้งให้เหลือเฉพาะข้อความที่เห็นบนจอจริง ปิดท้ายด้วย `sanitizePdfText` (`utils/annotation-display.ts` — ตัด ctrl ขยะ + กลุ่ม space คู่ขึ้นไป มี unit tests) ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด +- คีย์ลัด H/D/C/B ต้อง `e.preventDefault()` เสมอ — B เปิด popup bookmark ที่ input `autoFocus` ทันทีใน keydown เดียวกัน ถ้าไม่กัน browser พิมพ์ "b" ตามลงในช่องชื่อ — **จับคู่ด้วย `e.code` (ตำแหน่งกายภาพของปุ่ม — map `TOOL_SHORTCUTS` ใน toolbar-tools.ts + `KeyB`/`KeyZ` ตรง ๆ) ไม่ใช่ `e.key` (2026-09-29, PLAN-TOOLBAR #1): ผู้ใช้ศาลส่วนใหญ่สลับ keyboard เป็น layout ไทย ซึ่งปุ่มเดียวกันให้ e.key เป็น "ภ"/"ว"/"แ"/"น" — เช็ค e.key ทำให้คีย์ลัดทั้งชุด (รวม Ctrl+Z undo/redo) ใช้ไม่ได้เลยบน layout ไทย** - `DEL`/`Backspace` ลบ annotation ที่เลือกอยู่ (`selectedId`) — เฉพาะรายการของตัวเอง (เงื่อนไขเดียวกับปุ่มลบในแผง selection), ข้ามเมื่อ focus อยู่ใน form (`inForm` เดียวกับคีย์ลัดอื่น — พิมพ์ใน composer/textbox ต้องไม่โดนลบ) - Page Virtualization: `IntersectionObserver` render เฉพาะหน้าใน viewport ± 1 หน้า; หน้าพ้นจอ = placeholder `div` ขนาดจริง (จาก `pdfPage.getViewport()` ทุกหน้า ครั้งเดียวตอนโหลด) + unmount canvas/TextLayer/InkLayer คืน memory — annotation ของหน้าที่ไม่ render ยังอยู่ใน state -- pdfjs: `dynamic import("pdfjs-dist")` ภายใน `useEffect` เท่านั้น; `GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"` (ไฟล์คัดลอกไว้ที่ `public/` ด้วย postinstall) — ห้าม `new URL(..., import.meta.url)` +- pdfjs: `dynamic import("pdfjs-dist")` ภายใน `useEffect` เท่านั้น; `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` — ไฟล์ที่ `public/note/` **ไม่ใช่สำเนาตรง ๆ** ของ node_modules: postinstall (`scripts/patch-pdf-worker.mjs`) คัดลอกแล้ว patch heuristic fake-space ไทย = ถอด `if (scaledDim)` รอบ `textChunk.prevTransform = getCurrentTextTransform()` ให้อัปเดตทุก glyph (เดิมข้าม glyph กว้าง 0 อย่างสระ/วรรณยุกต์ — charSpacing ของ glyph เหล่านั้นสะสมเป็น gap จน `addFakeSpaces` แทรก " " กลางคำ "ฉะนั้ น" ใน PDF ที่ justify ด้วย letter-spacing ทุก glyph; การอัปเดตทุก glyph ทำให้ gap เหลือ charSpacing ตัวเดียว ต่ำกว่า threshold — อักขระอื่นทั้งหมดไม่เปลี่ยน เฉพาะ space หลอกหาย) — อัปเกรด pdfjs-dist แล้ว pattern minified เปลี่ยน = postinstall fail ดัง (ตั้งใจ กัน patch หลุดเงียบ) + test กัน regression ที่ `tests/scripts/patch-pdf-worker.test.ts` (fixture `tests/fixtures/thai_letterspacing.pdf` — ห้ามวางใน `public/note/samples/`: seed-samples จะ insert เป็นรายการเอกสารบนหน้ารายการทุก deploy); ห้าม `new URL(..., import.meta.url)` - 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=` scroll ไปหน้าของ annotation นั้น + `setSelectedId` — placeholder ขนาดจริงทุกหน้าทำให้คำนวณ absolute scroll ได้แม้หน้ายังไม่ render (กลไกเดียวกับ bookmark); ปุ่ม "คัดลอกลิงก์" ใน sidebar สร้าง URL รูป `/annotate/?page=&ann=` - 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 ไม่ข้ามแล้วคลิกในเมนู = ปิดเมนูเอง +- เมนู 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 ไม่ข้ามแล้วคลิกในเมนู = ปิดเมนูเอง; **เมนูตรายางเป็น 2 หน้าใน portal เดียว** (2026-09-28 — preset + แถว "กำหนดเอง…" → หน้า popup `stampCustomMode` reset กลับหน้า preset ทุกครั้งที่เปิดเมนูใหม่): หน้ากำหนดเอง = ช่องข้อความ (พิมพ์ live ไป `stampPreset` เดียวกับ preset, maxLength `STAMP_MAX_LABEL_CHARS`) + สี + ขนาด + **preview ตรายางตามสัดส่วนจริงบนจอ** (ความกว้างหน้าแรก × scale% — ใหญ่เกินเมนูย่อทั้งชุด; กรอบ/ฟอนต์/การจัดวาง/measure ใช้ตัวเดียวกับ render จริง: `stampLayout`/`stampBoxHeight` จาก `utils/geometry.ts` + `STAMP_FONT_STACK`/`measureStampText` จาก `utils/annotation-display.ts` — **คำยาวจัดบรรทัดแบบถอยหลัง: ย่อฟอนต์ให้อยู่บรรทัดเดียวก่อนลงได้จนถึง floor 10% ของความกว้างกรอบ (คำอย่าง "ตรวจสอบแล้ว" ย่อ ~25% ก็พอ = บรรทัดเดียว ไม่ตก 2 บรรทัด — คำขอผู้ใช้ 2026-09-28 รอบสอง) คำยาวเกิน floor ค่อย wrap ≤3 บรรทัด กรอบสูงขึ้นตามจำนวนบรรทัด คำสั้น 1 บรรทัดฟอนต์เต็มสูง 0.4w เหมือนเดิมเป๊ะ — ทุกมิติเป็นสัดส่วนของความกว้างกรอบ จำนวนบรรทัดจึงไม่ขึ้นกับ scale** ปิดท้ายด้วยปุ่ม "ใช้ตรายางนี้" — ปุ่มนี้และ Enter ในช่อง = ปิดเมนูแบบเลือกค่าจบ (setState ตรง ไม่ผ่าน markMenuClosed — แตะหน้าวางได้ทันที ไม่โดน guard กลืน tap), Escape ในช่อง = ย้อนกลับหน้า preset; ปุ่ม tool แสดง label "กำหนดเอง" แทนคำเต็มเมื่อค่าปัจจุบันเป็นคำกำหนดเอง (ปุ่ม dropdown ทั้ง 4 — มาร์กข้อความ/วาดเส้น/รูปทรง/ตรายาง — แสดงค่าที่เลือก**เฉพาะตอน tool active** ไม่ active แสดงชื่อ tool เหมือนปุ่มอื่น เพราะแสดงค่าตลอดทำให้ชื่อ tool หายจากแถบ เช่น "วาดเส้น" เห็นเป็น "ปากกา" ตั้งแต่ยังไม่ได้เลือก, คำขอผู้ใช้ 2026-09-29) - DRAW วาดเส้นตรง: **ปล่อยเมาส์แล้ว snap อัตโนมัติ** เมื่อจุดทุกจุดห่างจากเส้นตรง จุดเริ่ม→จุดสุดท้าย ไม่เกิน max(5% ของความยาว, 3px) และเส้นยาว ≥ 24px (`isNearlyStraight`) — หรือกดค้าง **Shift** ระหว่างลาก (กด/ปล่อยกลางทางได้ — listener ระดับ window + reset ตอน blur) เพื่อบังคับตรงเสมอ → บันทึกเหลือจุดเริ่ม→จุดสุดท้าย 2 จุด (ความหนา uniform — ไม่เก็บ `widths` จาก pressure) — ใช้ได้เฉพาะโหมดวาดอิสระ ไม่ใช่ shape/eraser/redact; เส้นโค้ง/ขดจริงหลุดเกณฑ์จึงเหลือ freehand -- Tool `pan` (มือเลื่อนหน้า — ปุ่มแยกถัดจาก select ในแถบ tool): ลากบน `
` = `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` บน desktop / custom selection ของ `useCustomSelection` บนจอสัมผัส + ซ่อน native selection) — tool "เลือกข้อความ" คลุมแล้วเกิด popup ไม่ได้แล้ว (ถอดออก) และคีย์ลัด `H` สลับไป tool highlight -- **จอสัมผัส (iOS/Android) + tool highlight ใช้ custom selection ผ่าน `hooks/useCustomSelection.ts`** (ไม่พึ่ง native selection ของ OS อีก — OS ยึด gesture ปล่อยนิ้วไปแสดงเมนู/handles เอง ทำให้ commit-on-release ทำไม่ได้): เปิดเฉพาะอุปกรณ์ที่ `matchMedia("(pointer: coarse)")` จริง (`enabled = tool highlight + coarse` — **desktop ใช้ native mouse selection + commit ตอน mouseup ตามเดิม ไม่เปลี่ยน**; tool อื่นบนจอสัมผัสก็ native selection + long-press copy ของ OS ตามปกติ) — Pointer Events ชุดเดียวคุม mouse+touch บน `
`: `pointerdown` เก็บ anchor caret (จาก `caretRangeFromPoint` → fallback `caretPositionFromPoint` → ไม่มี = bail เงียบ ๆ; ต้องเป็น text node + เริ่มบน `.textLayer` เท่านั้น) ยังไม่ทำอะไร → เลย threshold **8px** จึง `setPointerCapture` + เริ่ม select จริง (**จับ pointer ตอนเริ่ม select ไม่ใช่ pointerdown** — capture ตั้งแต่กดจะ retarget click ไป container จน tap เลือก annotation/ปุ่ม pin ใช้ไม่ได้; tap ที่ไม่ขยับ = ไม่ capture click ไปถึงเป้าหมายตามเดิม และ click หลังลากจริง retarget ไป main = ไม่หลุดเข้า handlePageClick) — ระหว่างลาก: อ่าน focus caret ที่จุดนิ้ว**เลื่อนขึ้น 12px** (`FINGER_OFFSET_PX` เฉพาะ pointerType touch — นิ้วบังข้อความ), **word snap** ขอบคำทั้ง anchor/focus (`Intl.Segmenter` แยกคำไทยได้ → fallback regex `\S+`, กลางช่องว่าง = ไม่ขยาย), live preview ผ่าน **rAF throttle** (Range → `getClientRects` → `rectsInSweptPath` กรองแนวเส้นทางนิ้ว → `rectToPercent`+`mergeRectsByLine` — รูปร่างเดียวกับ liveMark ส่งเข้า PdfPage ทดแทนตอนมีค่า `customSelPreview ?? liveMark` — **จุดเส้นทางที่ใช้กรองต้องเป็นจุดเดียวกับที่หา caret (จอสัมผัส = จุด y−12)**: เก็บ `anchorPathPt` = จุด probe ที่ resolve anchor สำเร็จ และ `pathEnd` = จุด focus ที่หัก offset แล้ว ใช้ทั้งใน preview และ payload commit — ใช้พิกัดนิ้ว raw ตรง ๆ จะคลาด 12px จนบรรทัดเป้าหมายโดน lineTol ตัดทิ้ง (ลากขึ้น) หรือมาร์กเกิน (ลากลง) เมื่อฟอนต์เล็ก), **auto-scroll** rAF loop เมื่อนิ้วใกล้ขอบบน/ล่าง viewport 60px (เลื่อนแล้วคำนวณ preview ใหม่ที่จุดนิ้วเดิม) — **`pointerup` = commit ทันที**ผ่าน `onCommit(range,start,end)` → `handleCustomSelectionCommit` (ยืม `selDragStart/EndRef` ชั่วคราว + `readTextSelection(pi, { withText: true, range })` ชุดเดียวกับ desktop) → `commitHighlight` — **ไม่มีปุ่มยืนยันลอย/debounce/snapshot ใน ref อีกต่อไป** (selectionchange ของ tool highlight บน coarse ไม่ยิงเพราะไม่มี native selection); ระหว่าง enabled ให้ `
` ได้ class `.pdf-custom-select` (globals.css: `user-select`/`-webkit-user-select`/`-webkit-touch-callout: none` + `touch-action: none` กัน browser ยึด gesture เป็น scroll → pointercancel; restore `user-select:text` ให้ input/textarea/contenteditable ใต้ main) **ถอดทันที**เมื่อ tool เปลี่ยน/ปิด — ผลคือช่วง tool highlight บนจอสัมผัส **ลากที่ไหนก็ไม่เลื่อนหน้า** (gesture เป็นของ selection หมด เลื่อนหน้าต่อได้ด้วย auto-scroll ระหว่างลาก หรือสลับ tool) — เคลียร์ state ครบทุกทาง: pointerup/pointercancel (cancel = ทิ้ง selection ไม่ commit), enabled เปลี่ยน, unmount — ยกเลิก rAF ทั้ง preview/auto-scroll ไม่มี loop ค้าง — **กัน "ลากแล้วบางทีไม่ commit" ด้วยกลไก 3 จุด** (bug report 2026-09-15): (1) การหา caret เป็น 4 ชั้นเรียงตามต้นทุน (`resolveTextCaretDeep` — ใช้**เฉพาะตอน pointerdown** กับทั้งจุดชดเชยนิ้ว y−12 และจุดแตะจริง): ① `caretRangeFromPoint`/`caretPositionFromPoint` → startContainer เป็น Element (จุดตก gap/padding ระหว่าง span) ให้ดำดิ่งหา text node ผ่าน `childNodes[startOffset]` + `findFirstTextNode` DFS → ② retry ขยับพิกัด 8 ทิศ (±2/±3px) → ③ `elementsFromPoint` เจาะผ่าน overlay ที่ไร้ text (SVG ของ InkLayer/มาร์ก/กรอบ) จับ span ข้อความใต้จุด → ④ span ของหน้านั้นที่**ใกล้สุดแนวตั้ง ≤40px** (`NEAREST_SPAN_MAX_PX` — เกินถือว่าจุดนั้นไม่มีข้อความจริง) — หมดทั้ง 4 ชั้นค่อย bail เป็น tap ธรรมดา; **ระหว่างลาก (ทุก rAF frame) ใช้เฉพาะชั้น ①② ที่ถูก** — หาไม่เจอ (ลากผ่านพื้นที่ว่าง/ช่องว่างระหว่างหน้า) ให้**คง focus เดิมจาก frame ก่อน** (`dragRef.lastFocus`) แทน re-scan ด้วยชั้นแพง — selection หยุดรอที่ข้อความสุดท้ายแล้วขยายต่อเมื่อนิ้วถึงข้อความอีก (cache รายชื่อ span ต่อ 1 gesture สำหรับชั้น ④) (2) `lastRef` (payload commit ล่าสุด) **เคลียร์เฉพาะตอน pointerdown, finish และเมื่อหน้า anchor ถูก virtualization unmount กลางลาก (`anchorPageEl.isConnected === false` — rects ของ detached nodes ว่างหมด ต้องไม่ commit ของเก่าเงียบ ๆ) เท่านั้น** — frame ที่ `rectsInSweptPath` กรองว่าง (ลากเร็ว sample ห่าง/นิ้วสั่นตอนปล่อย) ล้างแค่ preview เพื่อให้ pointerup ยัง commit selection ล่าสุดที่ไม่ว่างได้ (3) ลากล้ำเข้าหน้าถัดไป (range คร่อม 2 หน้า → หา `[data-page]` ไม่ได้) **ห้าม bail** — `clampToAnchorPage`: clamp ฝั่ง focus ไปท้ายหน้า anchor (ลากลง) หรือต้นหน้า anchor (ลากขึ้น) ด้วย text node แรก/สุดท้ายของ textLayer หน้า anchor (`anchorPageEl` เก็บตั้งแต่ pointerdown, boundary cache ต่อ gesture ใน `clampBounds` — ห้ามเดิน TreeWalker ใหม่ทุก rAF frame) — selection จบที่ขอบหน้า anchor แทนการหายทั้งเส้น -- **tap/คลิกที่ใช้ "ปิดเมนู" ต้องไม่สร้างรายการ**: outside-click ปิดเมนูที่ `mousedown` ก่อน `click` ยิงถึง page wrapper ทำให้ tool comment/textbox/stamp วางรายการจาก tap ปิดเมนูโดยไม่ตั้งใจ — `handlePageClick` เช็ค `menuClosedAtRef` (เขียนทุก path ปิดเมนูผ่าน `markMenuClosed`: outside-click/Escape/switchTool/เลื่อนแถบ/resize; ยกเว้นคีย์ B ปิดเมนูเพื่อเปิด popup bookmark — คีย์บอร์ดไม่มี click หลงเหลือให้กลืน) และเฉย 300ms แรก คลิกที่สองค่อยวาง (มาตรฐาน dropdown ทั่วไป ใช้ทั้ง desktop และ touch) -- **popup บนหน้าเปิดได้ทีละชุด ห้ามค้างเปิดซ้อนกัน** (bug "กดแก้ไขแล้วกดปุ่ม tool popup ไม่ปิด/ซ้อนกัน"): ปุ่ม dropdown ทั้ง 5 **เรียก `switchTool` เสมอแม้เป็น tool เดิม** (จำ intent เปิด/พับเมนูไว้ก่อนเรียก เพราะ `switchTool` ปิดเมนูทุกตัว) เนื่องจาก `switchTool` เป็นที่เดียวที่เคลียร์ popup บนหน้าทั้งหมด — composer/pin popover/แผง selection/editing comment·textbox + popup bookmark (`setBookmarkComposer(null)`); **สร้างรายการแล้ว auto-select (`AUTO_SELECT_TYPES`) ก็ปิดเมนู+popup bookmark เช่นกัน** — บน iOS การลาก (คลุมข้อความ/วาดเส้น) ไม่ยิง compat mousedown ให้ outside-click ปิดเมนูเอง (Android/desktop ปิดตั้งแต่เริ่มกดแล้ว) ไม่ปิดตรงนี้ = เมนูค้างเปิดซ้อนแผงที่โผล่หลังสร้าง; คลิกหน้าว่าง (`handlePageClick`) ปิด popover ของ pin + popup bookmark; เลือกรายการอื่น (`handleSelectAnnotation`/`goToAnnotation`) ปิด popover เดิม; popover ของ COMMENT เปิดอยู่ (`openPinId === selectedId`) = **ไม่ render กรอบ selection/แผงเครื่องมือของตัวนั้น** — bbox ของ COMMENT ครอบพื้นที่ pin พอดี แผงจะทับ popover และแถบ handle บังการคลิก (sidebar ยังตาม `selectedId` ไฮไลต์เหมือนเดิม) -- **คลุมข้อความได้ 5 tool** (`TEXT_SELECTION_TOOLS` set ใน PDFViewer: select/highlight/comment/textbox/stamp — ที่เหลือ textLayer ปิด pointer-events คลุมไม่ได้): ทั้งหมด inject `
` แทน canvas ที่ z-1 (`pointer-events:none` เหมือน canvas — invariant #3, DOM order ไม่ขยับ) + **ไม่มี textLayer** (conditional render เฉพาะ pdf) → PdfPage เรียก `onTextLayerRendered(pageIndex, 0)` ให้ hint "ภาพสแกน" + custom selection จอสัมผัส bail เอง — **toolbar คงทุก tool รวม HIGHLIGHT** (คลุมข้อความบนรูปไม่ได้ = no-op มี hint อธิบาย); `pageSizes` จาก `naturalWidth/Height` (browser คิด EXIF orientation ให้แล้ว) **normalize กว้าง `IMAGE_PAGE_WIDTH`=595** สัดส่วนเดิม — scale contract เดียวกับ export (`utils/image-to-pdf.ts`) ให้ zoom %/fit-width clamp 0.5–3/fontSize % ทำงานเหมือนหน้า A4; TIFF decode ผ่าน `utif2` **dynamic import เฉพาะไฟล์ tif** (pattern เดียวกับ invariant #6) → หนึ่ง PNG blob URL ต่อเฟรม (**หลายเฟรม = หลายหน้า**) และ **revoke ทุก blob URL ตอน cleanup ของ load effect**; เฟรมที่ decode พัง = `src: null` → placeholder เทาแต่ขนาดหน้ายังเอาจาก IFD header ได้ +- **N3 ไฟล์รูปภาพ (jpg/jpeg/png/tif — `fileKindFromUrl` จาก `utils/file-kind.ts`):** รูป**โหลดจาก static `pdfUrl` ตรง ๆ ไม่ผ่าน `/api/note/pdf`** (ไม่มี ToUnicode ให้ซ่อม — route นั้นมี defensive branch คืน bytes ดิบ + content-type รูป กัน stray call เข้า `fixPdfActualText`) — หน้ารูป render เป็น `` แทน canvas ที่ z-1 (`pointer-events:none` เหมือน canvas — invariant #3, DOM order ไม่ขยับ) + **ไม่มี textLayer** (conditional render เฉพาะ pdf) → PdfPage เรียก `onTextLayerRendered(pageIndex, 0)` ให้ hint "ภาพสแกน" + custom selection จอสัมผัส bail เอง — **toolbar ซ่อน tool มาร์กข้อความ (HIGHLIGHT) ทั้งปุ่มและเมนูรูปแบบเมื่อไม่มีข้อความให้คลุม** — ทั้งไฟล์ภาพและ **PDF สแกนล้วน** (คำขอผู้ใช้ 2026-09-22 แทนแผนเดิมที่คงไว้พร้อม hint): `hideHighlightTool` = ไฟล์ภาพ หรือ `scannedOnly` = ทุกหน้าที่ render แล้วไม่มี span เลย (entry ของ `pageSpanCount` สะสมต่อหน้า ไม่ลบตอน virtualization unmount = **latch**: เคยเจอหน้ามีข้อความแม้หน้าเดียว ไฟล์ถือว่ามีข้อความ ปุ่มกลับมาค้าง — ไฟล์ผสมสแกนนำหน้าโผล่ครั้งเดียวพอ ไม่โผล่-หายตามหน้าที่เปิดดู) — กันคีย์ลัด H ที่ `switchTool` (**เก็บปุ่มเดิมไว้ แต่ยังเคลียร์เมนู/popup/selection เหมือนสลับ tool จริง — ไม่ no-op เงียบสนิท**, finding #4 ของ PLAN-TOOLBAR 2026-09-29) + reset กลับ "select" ทั้งตอนสลับมาเป็นไฟล์ภาพขณะ tool นี้ active (dynamic param เปลี่ยน = reuse component ไม่ reset state) และ effect ตอน `scannedOnly` กลายเป็น true — reset ทั้งสองจุดปิดเมนู dropdown ที่ค้างเปิดด้วย `closeToolMenus()` (2026-09-29 — เมนู portal fixed จะลอยไร้ปุ่มยึดเมื่อปุ่มถูกซ่อน); แถบ hint M6 "ภาพสแกน" เหลือเป็น defensive ช่วง transition เท่านั้น; `pageSizes` จาก `naturalWidth/Height` (browser คิด EXIF orientation ให้แล้ว) **normalize กว้าง `IMAGE_PAGE_WIDTH`=595** สัดส่วนเดิม — scale contract เดียวกับ export (`utils/image-to-pdf.ts`) ให้ zoom %/fit-width clamp 0.5–3/fontSize % ทำงานเหมือนหน้า A4; TIFF decode ผ่าน `utif2` **dynamic import เฉพาะไฟล์ tif** (pattern เดียวกับ invariant #6) → หนึ่ง PNG blob URL ต่อเฟรม (**หลายเฟรม = หลายหน้า**) และ **revoke ทุก blob URL ตอน cleanup ของ load effect**; เฟรมที่ decode พัง = `src: null` → placeholder เทาแต่ขนาดหน้ายังเอาจาก IFD header ได้ diff --git a/.claude/rules/realtime.md b/.claude/rules/realtime.md index 991b6ef..1487f9f 100644 --- a/.claude/rules/realtime.md +++ b/.claude/rules/realtime.md @@ -7,7 +7,7 @@ paths: # Realtime Rules (รายละเอียด: docs/PLAN.md §4) -- Sync ใช้ **SSE**: hook เปิด `EventSource` ไป `/api/annotations/stream?fileId=` และ `es.close()` เมื่อ unmount +- Sync ใช้ **SSE**: hook เปิด `EventSource` ไป `withBasePath("/api/note/annotations/stream?fileId=")` (basePath ตาม invariant #12) และ `es.close()` เมื่อ unmount - `EventSource` ต่อใหม่อัตโนมัติเมื่อ connection ขาด — **ห้ามทำ retry logic เอง** - Event payload เป็น **DTO (camelCase + ISO string) จาก server ตรง ๆ** — ไม่มีการแปลง snake_case - Server Actions ต้อง **publish ผ่าน `lib/annotate-events.ts` หลังเขียน DB สำเร็จทุกครั้ง** (create/update/delete ของ annotations, create/delete ของ replies, create/**update**/delete ของ bookmarks — update ของ bookmarks ใช้กับแก้ชื่อ/โน้ต/ย้ายหัวข้อ และลูกที่ถูกยกขึ้น root ตอนแม่ถูกลบ, client replace by id) — ลืม publish = client อื่นไม่เห็น; คู่กันในจุดเดียวกันต้อง **`revalidatePath` หน้า viewer+summary** (`revalidateFilePages`) มิฉะนั้นกลับเข้าหน้าใหม่ (back → คลิกไฟล์) ได้ RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh diff --git a/.claude/skills/frontend-design/LICENSE.txt b/.claude/skills/frontend-design/LICENSE.txt new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/.claude/skills/frontend-design/LICENSE.txt @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/.claude/skills/frontend-design/SKILL.md b/.claude/skills/frontend-design/SKILL.md new file mode 100644 index 0000000..a533345 --- /dev/null +++ b/.claude/skills/frontend-design/SKILL.md @@ -0,0 +1,71 @@ +--- +name: frontend-design +description: Guidance for distinctive, intentional visual design when building new UI or reshaping an existing one. Helps with aesthetic direction, typography, and making choices that don't read as templated defaults. +license: Complete terms in LICENSE.txt +--- + +# Frontend Design + +Approach this as the design lead at a design studio known for giving every client a distinct visual identity that is not mistaken for anyone else's. This client has already rejected proposals that felt cliché or templated, and is paying for a distinctive point of view: make deliberate, opinionated choices about palette, typography, and layout that are specific to this brief, and take aesthetic risk if justified. + +## Ground your designs in the subject matter + +If the brief does not identify what the product or subject matter is, identify it yourself before designing, and confirm with the client. You can come up with one concrete subject, the design's audience, and the design's primary job, as a proposal. If there's any information in your memory about the client's preferences or context about what they're building, use that as a hint. The subject's industry, subject matter, materials, and vernacular are where distinctive visual choices come from — a design for a toy for girls aged 8–11 will be very aesthetically different from a dashboard for financial analysts. Build with the brief's real content and subject matter throughout. + +## Design principles + +For web designs, the hero is the first thing viewers will see. Open with the most characteristic thing in the subject's world, in the form that is most appropriate: a headline, an image, an animation, a live demo, an interactive moment, or other treatments. Be deliberate with your choice: a big number with a small label, supporting stats, and a gradient accent is the default treatment, so only use it if that's truly the best option. + +Typography carries the personality of the page. You don't need a different typeface for display or headline text and body content: use one family or two, and if two, make them clearly distinct. + +Choose your typefaces deliberately, not the default families you would reach for on any other project, and set a clear type scale following the default guidance of The Elements of Typographic Style with intentional weights, widths, and spacing. When type is used as a headline or visual element, use the type treatment itself as an active part of the design, not a neutral delivery vehicle for the content. + +Default to line lengths of less than 80 characters. Serif typefaces can have slightly longer line lengths; give serif body text slightly more line-height than a sans-serif. + +Avoid these default typographic treatments; they are the commonest tells of a generated page: +- Accenting just a single word or phrase in a headline, like putting one word in italic/bold or a different color. +- Using all caps for labels. +- Adding unnecessary typographic labels above content. + +Visual structure is information. Structural devices like outlines, borders, numbering, eyebrows, dividers, labels, etc., encode useful information about the content rather than decorate it. Many generic designs use numbered markers (01 / 02 / 03), but that's only appropriate if the content actually is a sequence — like a stepped process or a timeline. Before adding numbered markers, check the content really is a sequence. + +Use non-user-triggered motion sparingly and deliberately, only to draw attention. A single orchestrated moment — one page-load sequence or one reveal — lands better than scattered effects; fade-and-slide-up entrances on each section and hover transitions on every card are the generic default and read as AI-generated. Motion that answers a person's action (opening, expanding, confirming) is welcome when it shows what changed. + +Consider written content carefully. Often a design brief may not contain real content, and it's up to you to come up with copy and placeholder content. Copy can make a design feel as templated as the design itself. See the below section on writing for more guidance. + +## Process: plan, review against the brief, build, critique + +For calibration, AI-generated design right now clusters around some traits: +1. a warm cream background (near #F4F1EA) with a high-contrast serif display and a terracotta or warm-clay accent (often near #D97757 — Anthropic's own Claude-interaction accent, so on a user's brief it reads as a tell); +2. a near-black background with a single bright acid-green or vermilion accent; +3. a broadsheet-style layout with hairline rules, zero border-radius, and dense newspaper-like columns; +4. the SaaS-card kit: content chopped into identical rounded cards, one border-radius on everything regardless of hierarchy, the same soft grey shadow (rgba(0,0,0,.1)) under each, and gradient washes as decoration; +5. template chrome that appears whatever the subject: a tracked-out ALL-CAPS eyebrow label above every heading; meta strings joined with middle dots ('A · B · C'); labels built as 'WORD — fragment' with a spaced em dash; tinted near-black (#0B0B0B, #111) standing in for black; a monospace face for small data labels; a '→' appended to link and button text. + +All traits are legitimate for some briefs, but they are defaults rather than choices, and they appear regardless of subject. Where the brief pins down a visual direction, follow it exactly — the brief's own words always win, including when it asks for one of these looks. Where it leaves an axis free, don't spend that freedom on one of these defaults. As with a hired human designer, there's often a careful balance between doing what you're good at and taking each project as a chance to experiment and learn. + +Work in two passes. First, brainstorm a short design plan based on the client's design brief: create a compact token system with color, type, layout, and principles. +- Color: describe the core base palette as 4–6 named hex values. +- Type: the typefaces and their roles. +- Layout: a layout concept, using one-sentence prose descriptions and ASCII wireframes to ideate and compare. Include alignment guidance; should the content be left aligned, center aligned, justified? +- Principles: the high-level guidance for what makes this page unique. + +Then review that plan against the brief before building: if any part of it reads like the generic default you would produce for any similar page (work through a similar prompt to see if you arrive somewhere similar) rather than a choice made for this specific brief — revise that part, say what you changed and why. Only after you've confirmed the relative uniqueness of your design plan should you start to write the code, following the revised plan. + +When writing the code, be careful of structuring your CSS selector specificities. It's easy to generate CSS classes that cancel each other out (especially with a type-based selector like .section and an element-based selector like .cta). This can happen often with padding/margin between sections. + +## Restraint and self-critique + +Spend your boldness in one place. Let one element be the memorable thing, keep everything around it quiet and disciplined, and cut any decoration that does not serve the brief. Build to a quality floor without announcing it: responsive down to mobile, visible keyboard focus, reduced motion respected, visually accessible, harmonious color palettes. Critique your own work as you build, taking screenshots to review if your environment supports it — a picture is worth 1000 tokens. Consider Chanel's advice: before leaving the house, take a look in the mirror and remove one accessory. Human creatives have memory and always try to do something new, so if you have a space to quickly jot down notes about what you've tried, it can help you in future passes. + +## More on writing in design + +Words appear in a design for one reason: to make it easier to understand and use. They are design content, not decoration. Bring the same intentionality and minimalism to copywriting that you would bring to spacing and color. Before writing anything, ask what the design needs to say, and how it can best be said to help the person navigate the experience. + +Write from the end user's perspective. Name things by what users will understand in simple language, not by how the system is built. A user manages notifications, not webhook config. Describe what something is or does in plain terms rather than selling it. Being specific and legible to new users is always better than being clever. + +Use active voice as default. A CTA says exactly what happens when it is used: "Save changes," not "Submit." An action keeps the same name through the whole flow, so the button that says "Publish" produces a toast that says "Published." The vocabulary of an interface is the signposting for someone navigating the product. Cohesion and consistency are how people learn their way around. + +Treat failure and emptiness as moments for direction, not mood. Explain what went wrong and how to fix it, in the interface's voice rather than a person's. Errors don't apologize, and they are never vague about what happened. An empty screen is an invitation to act. + +Keep the tone conversational: plain verbs, sentence case, no filler, with tone matched to the brand and the audience. Let each written element do exactly one job. diff --git a/.claude/skills/sync-db/SKILL.md b/.claude/skills/sync-db/SKILL.md index ff4d28d..a23cd60 100644 --- a/.claude/skills/sync-db/SKILL.md +++ b/.claude/skills/sync-db/SKILL.md @@ -7,7 +7,7 @@ description: Sync Drizzle schema ลง PostgreSQL container judes-note-db + see ## ขั้นตอน 1. **Push schema:** รัน `npx drizzle-kit push` (อ่าน config จาก `drizzle.config.ts`) — รายงาน output จริง ถ้าชนของเดิมให้แสดงและถามผู้ใช้ก่อน destructive ทางเลือก -2. **Seed ตัวอย่าง (เฉพาะเมื่อตาราง `files` ว่าง):** INSERT ไฟล์จาก `public/samples/` โดย `pdf_url = '/samples/<ชื่อไฟล์>'` (ไฟล์และที่มาดูใน PLAN.md §0 ตาราง "ไฟล์ตัวอย่าง") — ใช้ SQL จาก README ถ้ามีแล้ว รันผ่าน `docker exec judes-note-db psql -U postgres -c "..."` +2. **Seed ตัวอย่าง (เฉพาะเมื่อตาราง `files` ว่าง):** INSERT ไฟล์จาก `public/note/samples/` โดย `pdf_url = '/samples/<ชื่อไฟล์>'` (ไฟล์และที่มาดูใน PLAN.md §0 ตาราง "ไฟล์ตัวอย่าง") — ใช้ SQL จาก README ถ้ามีแล้ว รันผ่าน `docker exec judes-note-db psql -U postgres -c "..."` 3. **รายงานผลแต่ละขั้นตามจริง** — ขั้นไหนไม่ได้ทำบอกสาเหตุ อย่าเดา ## ข้อควรระวัง diff --git a/.dockerignore b/.dockerignore index cb088f3..f2eea78 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,3 +8,5 @@ npm-debug.log* .env .env.* !.env.example +# ไฟล์ที่อัปโหลดระหว่าง dev ห้ามติดไปใน image (production เขียน runtime + เก็บใน volume ไม่ใช่ bake ตอน build) +public/note/uploads diff --git a/.env.example b/.env.example index c02e954..d9988b6 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,29 @@ # PostgreSQL container judes-note-db (สร้างด้วย docker run ตาม README) -DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note \ No newline at end of file +DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note +# N8 (optional): allowlist โฮสต์ลิงก์ไฟล์รีโมต — ไม่ตั้ง = admincourt.go.th +# EXTERNAL_FILE_HOSTS=admincourt.go.th +# N10 (optional): API key ของ /api/external/* (backend e-Court เรียก server-to-server) +# ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร +# EXTERNAL_API_KEY= + +# MinIO ของ API เอกสาร versioning (docs/PLAN-DOCVER.md §6) — ค่านี้ตรง docker-compose.yml +# (MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY ไม่ตั้ง = ทุก endpoint ของกลุ่มนี้ตอบ 500) +# หมายเหตุ: พอร์ตโฮสต์เป็น 19000 เพราะ 9000/9001 ชนกับโปรเจกต์อื่นในเครื่อง dev +# (ใน production ตั้งเป็นพอร์ตจริงที่ deploy) และ presigned URL อ้าง endpoint นี้ตรง ๆ — +# production ต้องเป็นโฮสต์ที่เบราว์เซอร์ของผู้ใช้เข้าถึงได้ ไม่ใช่แค่ที่ Next server เข้าถึงได้ +MINIO_ENDPOINT=127.0.0.1 +MINIO_PORT=19000 +MINIO_USE_SSL=false +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +# MINIO_BUCKET=docver +# DOCVER_LINK_TTL_SECONDS=900 + +# docver token (2026-10-22): API key ของ POST /api/documents/token (header x-api-key) +# ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร +# (ห้าม log ค่า key ดิบ — เทียบแบบ timing-safe ทุกครั้ง) +DOCVER_API_KEY= +# secret เซ็น/ตรวจ JWT HS256 ของทุก endpoint /api/documents/* (Authorization: Bearer) +# ไม่ตั้ง/ว่าง = ออก token ไม่ได้ (500) และทุก endpoint ตอบ 401 (fail closed) — ห้าม log ค่า secret +DOCVER_TOKEN_SECRET= +# DOCVER_TOKEN_TTL_SECONDS=3600 diff --git a/.forgejo/workflows/uat.yml b/.forgejo/workflows/uat.yml new file mode 100644 index 0000000..d999b9e --- /dev/null +++ b/.forgejo/workflows/uat.yml @@ -0,0 +1,251 @@ +name: Deploy eCourt Note Web to UAT Server + +on: + push: + tags: + - "deploy*.*.*" + workflow_dispatch: + inputs: + version: + description: "เวอร์ชันที่จะ deploy เช่น 1.2.3 (ว่าง = latest)" + required: false + type: string + +env: + # Container Registry + REGISTRY_URL: ${{ vars.CONTAINER_REGISTRY }} + REGISTRY_USERNAME: ${{ vars.CONTAINER_REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.CONTAINER_REGISTRY_PASSWORD }} + + # Docker Image — ไม่ build เอง ใช้ image เดียวกับที่ build.yml push ไว้ + # (push tag deploy1.2.3 หรือ Run workflow จาก UI ใส่เวอร์ชัน → map เป็น image v1.2.3 เสมอ) + WEB_IMAGE_NAME: chamomind/ecourt-note + + # SSH Deployment — ต่างจาก build.yml: ชี้เครื่อง UAT (ตั้ง vars.SSH_UAT_DEPLOY_HOST เพิ่มใน Forgejo) + ไม่ build image เอง + DEPLOY_HOST: ${{ vars.SSH_UAT_DEPLOY_HOST }} + DEPLOY_USER: ${{ vars.SSH_DEPLOY_USER }} + + # Notifications + DISCORD_WEBHOOK_URL: ${{ vars.DISCORD_WEBHOOK_URL }} + NEXT_PUBLIC_APP_URL: ${{ vars.NEXT_PUBLIC_APP_URL }} + +jobs: + validate-schema: + name: Validate Database Schema (Drizzle) + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Verify tag is on dev branch + run: | + echo "======================================================" + echo "Verifying tag is pushed to dev branch" + echo "======================================================" + echo "Tag: ${{ github.ref_name }}" + echo "Commit: ${{ github.sha }}" + + # Fetch dev branch + git fetch origin dev + + # Check if the tag's commit is on dev branch + if git branch -r --contains ${{ github.sha }} | grep -q "origin/dev"; then + echo "✅ Tag is on dev branch" + echo "test_branch=true" >> $GITHUB_ENV + else + echo "❌ Tag is NOT on dev branch" + echo "This workflow only triggers for tags pushed to dev branch" + echo "" + echo "Current branches containing this commit:" + git branch -r --contains ${{ github.sha }} + exit 1 + fi + + - name: Check for schema changes + id: schema-check + run: | + # โปรเจกต์นี้ใช้ Drizzle (drizzle-kit push) ไม่มีโฟลเดอร์ migrations — ดูการเปลี่ยนแปลงที่ db/schema.ts + if git diff --name-only origin/dev...HEAD | grep -qE "^db/schema\.ts$"; then + echo "has_schema_changes=true" >> $GITHUB_OUTPUT + echo "✅ Schema changes detected" + else + echo "has_schema_changes=false" >> $GITHUB_OUTPUT + echo "ℹ️ No schema changes" + fi + + - name: Schema change summary + if: steps.schema-check.outputs.has_schema_changes == 'true' + run: | + echo "=== Schema Change Summary ===" + echo "" + echo "Database schema (db/schema.ts) has been modified in this deployment." + echo "This project uses Drizzle push (no migration files)." + echo "" + echo "⚠️ REMINDERS:" + echo " 1. Run 'npx drizzle-kit push' on the target database (staging first)" + echo " 2. Monitor logs after deployment" + echo "" + echo "📚 Reference: README.md / docs/PLAN.md §3" + + deploy: + name: Deploy eCourt Note Web to UAT Server + runs-on: ubuntu-latest + needs: validate-schema + # รันได้ 2 ทาง: push tag deploy*.*.* หรือกด Run workflow จาก UI (ใส่เวอร์ชันใน input) + # !failure() && !cancelled() = ผ่านได้เมื่อ validate-schema success หรือถูก skip (กรณี dispatch) + # แต่ยังไม่รันถ้า validate-schema fail (tag ไม่อยู่บน dev) + if: >- + always() && !failure() && !cancelled() && + (startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch') + outputs: + image_tag: ${{ steps.image-tag.outputs.tag }} + steps: + - name: Resolve image tag (deploy1.2.3 / v1.2.3 → v1.2.3, ว่าง → latest) + id: image-tag + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + RAW="${{ github.event.inputs.version }}" + else + RAW="${{ github.ref_name }}" + fi + TAG="latest" + if [ -n "$RAW" ]; then + TAG="${RAW#deploy}" + TAG="${TAG#v}" + TAG="v$TAG" + fi + echo "tag=$TAG" >> $GITHUB_OUTPUT + echo "Reuse image จาก build.yml: ${{ env.REGISTRY_URL }}/${{ env.WEB_IMAGE_NAME }}:$TAG" + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY_URL }} + username: ${{ env.REGISTRY_USERNAME }} + password: ${{ env.REGISTRY_PASSWORD }} + + - name: Verify image exists in registry + run: | + if ! docker manifest inspect "${{ env.REGISTRY_URL }}/${{ env.WEB_IMAGE_NAME }}:${{ steps.image-tag.outputs.tag }}" > /dev/null; then + echo "❌ Image ${{ env.REGISTRY_URL }}/${{ env.WEB_IMAGE_NAME }}:${{ steps.image-tag.outputs.tag }} ไม่มีใน registry" + echo "workflow นี้ไม่ build เอง — push tag ${{ steps.image-tag.outputs.tag }} ก่อน เพื่อให้ build.yml build + push image ให้" + exit 1 + fi + echo "✅ Image found — deploy ด้วย image เดียวกับ build.yml" + + - name: Deploy web on UAT server + uses: appleboy/ssh-action@v1.2.1 + with: + host: ${{ env.DEPLOY_HOST }} + port: ${{ vars.SSH_DEPLOY_PORT }} + username: ${{ env.DEPLOY_USER }} + password: ${{ secrets.SSH_DEPLOY_PASSWORD }} + script: | + cd ~/repo + + chmod +x ./replace-env.sh ./deploy.sh + ./replace-env.sh ECOURT_NOTE_TAG "${{ steps.image-tag.outputs.tag }}" + ./deploy.sh + + notify: + name: Notify Deployment Status + runs-on: ubuntu-latest + needs: [deploy] + if: always() + steps: + - name: Send Discord notification - Success + if: ${{ needs.deploy.result == 'success' }} + run: | + curl -H "Content-Type: application/json" \ + -d '{ + "embeds": [{ + "title": "✅ eCourt Note UAT Deployment Successful", + "description": "eCourt Note Web deployed to UAT server", + "color": 3066993, + "fields": [ + { + "name": "Environment", + "value": "UAT", + "inline": true + }, + { + "name": "Tag", + "value": "${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version || github.ref_name }}", + "inline": true + }, + { + "name": "Commit", + "value": "`${{ github.sha }}`", + "inline": false + }, + { + "name": "Image Tag", + "value": "`${{ needs.deploy.outputs.image_tag }}`", + "inline": true + }, + { + "name": "Web URL", + "value": "${{ env.NEXT_PUBLIC_APP_URL }}", + "inline": false + }, + { + "name": "Deployed By", + "value": "${{ github.actor }}", + "inline": true + } + ], + "footer": { + "text": "eCourt Note CD Pipeline" + }, + "timestamp": "'"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)"'" + }] + }' \ + ${{ env.DISCORD_WEBHOOK_URL }} + + - name: Send Discord notification - Failure + if: ${{ needs.deploy.result == 'failure' }} + run: | + curl -H "Content-Type: application/json" \ + -d '{ + "embeds": [{ + "title": "❌ eCourt Note UAT Deployment Failed", + "description": "Failed to deploy eCourt Note Web to UAT server", + "color": 15158332, + "fields": [ + { + "name": "Environment", + "value": "UAT", + "inline": true + }, + { + "name": "Tag", + "value": "${{ github.ref_name }}", + "inline": true + }, + { + "name": "Commit", + "value": "`${{ github.sha }}`", + "inline": false + }, + { + "name": "Failed Job", + "value": "Deploy", + "inline": true + }, + { + "name": "Deployed By", + "value": "${{ github.actor }}", + "inline": true + } + ], + "footer": { + "text": "eCourt Note CD Pipeline" + }, + "timestamp": "'"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)"'" + }] + }' \ + ${{ env.DISCORD_WEBHOOK_URL }} + exit 1 diff --git a/.gitignore b/.gitignore index e52b340..6ef65e7 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,16 @@ next-env.d.ts # Claude Code .claude/settings.local.json # N4: ไฟล์ที่ผู้ใช้อัปโหลดผ่านหน้ารายการ (รันไทม์ — ไม่เข้า repo) -public/uploads/ +public/note/uploads/ + +# wordaddin: Office.js runtime สร้างใหม่ได้เสมอจาก postinstall scripts/fetch-office-js.mjs +# (ร้อยกว่าไฟล์หลาย MB — ไม่ commit เข้า repo เหมือน swagger-ui ที่มีแค่ 2 ไฟล์) +public/wordaddin/office-js/ + +# wordaddin: ใบรับรอง https ของ dev (สร้างเองเฉพาะเครื่อง — ดู docs/WORDADDIN-DEV.md) +/certs/ + +# compose ทดสอบบนเครื่อง dev (build จาก repo + DB แยก รันที่ 6704) — ไม่ใช่ของ production +docker-compose.test.yml + +certificates \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index d490d3f..f5ccb40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,72 +5,122 @@ **Judge's Note** — ระบบ PDF Annotation สำหรับ e-Court (โปรเจกต์ Next.js แยกต่างหาก ไม่เกี่ยวกับ dms-main ที่เป็น Nuxt) - ฟีเจอร์: highlight ข้อความ, วาดเส้น freehand, sticky note comment, bookmark -- **อ่านแผนฉบับเต็มก่อนเริ่มงานใด ๆ:** `docs/PLAN.md` (สถาปัตยกรรม, schema, พฤติกรรม feature, ลำดับ implement, edge cases) — ฟีเจอร์ต่อยอด (F1–F19 + N1–N4) อ่านเพิ่ม: `docs/PLAN-FEAT.md` +- **อ่านแผนฉบับเต็มก่อนเริ่มงานใด ๆ:** `docs/PLAN.md` (สถาปัตยกรรม, schema, พฤติกรรม feature, ลำดับ implement, edge cases) — ฟีเจอร์ต่อยอด (F1–F19 + N1–N7) อ่านเพิ่ม: `docs/PLAN-FEAT.md` ## Tech Stack -- Next.js 14 (App Router) + TypeScript **strict** -- pdfjs-dist **v4** (`^4.10.38`) — built-in TextLayer + worker จาก `public/pdf.worker.min.mjs` (postinstall คัดลอกจาก node_modules) -- Drizzle ORM + PostgreSQL (Docker container เดียว: `judes-note-db`, image `postgres:17-alpine`, พอร์ต 54322) — **ไม่ใช้ Supabase** + realtime sync ด้วย **SSE (Server-Sent Events)** ผ่าน `app/api/annotations/stream` + pub/sub in-process (`lib/annotate-events.ts`) -- Tailwind CSS -- ไม่มีระบบ auth — anonymous identity จาก localStorage (`utils/user.ts`) +- Next.js 14 (App Router) + TypeScript **strict** — serve ใต้ basePath `/docedit` + route ทั้งหมดของแอปใต้ `app/note/**` (URL ปลายทางยังเป็น `/docedit/note/…` เหมือนเดิม — basePath `/docedit` เป็นพื้นที่กลางรองรับ sibling ในอนาคต เช่น wordaddin, `next.config.mjs`) +- pdfjs-dist **v4** (`^4.10.38`) — built-in TextLayer + worker จาก `public/note/pdf.worker.min.mjs` (postinstall `scripts/patch-pdf-worker.mjs` คัดลอกจาก node_modules + **patch fake-space ไทย** — รายละเอียด invariant #6) +- Drizzle ORM + PostgreSQL (Docker container เดียว: `judes-note-db`, image `postgres:17-alpine`, พอร์ต 54322) — **ไม่ใช้ Supabase** + realtime sync ด้วย **SSE (Server-Sent Events)** ผ่าน `app/api/note/annotations/stream` + pub/sub in-process (`lib/annotate-events.ts`) +- **wordaddin** — taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตมาเป็น route `/docedit/wordaddin` ของแอปนี้ (UI = Genesis, ชั้น services Office.js พอร์ตดิบจาก `D:\word-addin-poc\addin`) — ปัจจุบัน auth + API เป็น **mock** รอของจริง (spec UI: `docs/addin-ui-flow.md`, คู่มือ dev/sideload/สลับกลับ: `docs/WORDADDIN-DEV.md`); Office.js **self-host** ที่ `public/wordaddin/office-js/` ผ่าน postinstall `scripts/fetch-office-js.mjs` — gitignore (ดาวน์โหลดฉบับล่าสุดจาก CDN ของ Microsoft ตอนติดตั้ง ตัวระบบ deploy ใช้ intranet ล้วน; แพ็กเกจ npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 ก่อน WordApi 1.4/1.5 — runtime ตัวนั้นไม่มี `Document.close` ใช้ไม่ได้) +- Tailwind CSS + design system **Genesis** (`docs/DESIGN.md`, 2026-09-23) — palette tokens ใน `tailwind.config.ts` (`primary` เดิม + `primary-hover`/`canvas`/`line`/`ink`/`ink-soft`/`ink-faint`/`success`/`warning`/`error` + ระดับข้อความ `success-ink`/`warning-ink`/`error-ink` ≥5:1 สำหรับข้อความ/ไอคอนสถานะ — token 500 ใช้กับ border/bg tint เท่านั้น) ห้ามใช้ raw palette shades ของ Tailwind (`slate-*`/`amber-*`/`emerald-*`/`red-*` ฯลฯ) อีก; ฟอนต์ display = **General Sans** (self-host จาก Fontshare ผ่าน `next/font/local` — `app/fonts/`, variable weight 200–700, ใช้ intranet ได้), body = **DM Sans** (`next/font/google`), ไทย fallback **Sarabun** ตามลำดับใน stack (Latin ก่อนไทย — `fontFamily`/globals.css); รัศมี: ปุ่ม/input 6px (รวมปุ่ม action เล็กใน popup/composer/dialog — คู่ ยกเลิก/บันทึก ต้องได้รัศมีเดียวกัน), menu/list row ใน dropdown/panel 4px, panel/dropdown 8px, การ์ด 12px; เงาเฉพาะ hover (`shadow-card-hover`/`shadow-btn-glow` — static ไม่มีเงา ยกเว้นชั้น floating: dropdown/popover `shadow-lg` และ pin ลอยบนเอกสาร ดู §App-specific exceptions ของ DESIGN.md); focus indicator = outline น้ำเงินทึบ 2px + offset ผ่าน class `.focus-ring`/`.genesis-input` ใน globals.css (+ สำเนา `FOCUS_RING_CLS` ใน PDFViewer/AnnotationSidebar/AnnotationSelectionLayer/ToolControls — แก้ต้องแก้พร้อมกัน; halo alpha จางเกิน WCAG 1.4.11); น้ำเงินเฉพาะของ interactive (แบรนด์มาร์ก tile JN/แถบหัวหน้าสรุปยกเว้น) +- mock login e-Court — หน้า `/note/login` (dropdown `MOCK_USERS` user1–user5 + รหัสผ่านกลาง `Demo1234`) ตั้ง cookie `oacCase` ตาม format จริงของระบบศาล → identity (userId = `u_userid`, userName = `u_userlogin` — **ต่างกันต่อคน** เพื่อสิทธิ์เจ้าของ) อ่านจาก cookie ผ่าน `lib/session.ts` + บังคับทุกหน้าด้วย `middleware.ts` (auth จริงเทียบระบบ e-Court + roles ยัง out of scope) ## Invariants ที่ต้องรักษา (ห้ามละเมิด) -1. **Non-Destructive:** ไฟล์ต้นฉบับ (PDF/รูปภาพ) ไม่ถูกแก้ไขเด็ดขาด — annotation เก็บใน DB เป็นพิกัดเท่านั้น (export PDF ของ F8/N3 สร้างไฟล์ใหม่จากสำเนาในหน่วยความจำ — อ่าน `public/` อย่างเดียว ห้ามเขียนทับ; อัปโหลด N4 เขียนไฟล์**ใหม่**ที่ `public/uploads/` เท่านั้น ห้ามแตะไฟล์เดิม) +1. **Non-Destructive:** ไฟล์ต้นฉบับ (PDF/รูปภาพ) ไม่ถูกแก้ไขเด็ดขาด — annotation เก็บใน DB เป็นพิกัดเท่านั้น (export PDF ของ F8/N3 สร้างไฟล์ใหม่จากสำเนาในหน่วยความจำ — อ่าน `public/` อย่างเดียว ห้ามเขียนทับ; อัปโหลด N4 เขียนไฟล์**ใหม่**ที่ `public/note/uploads/` เท่านั้น ห้ามแตะไฟล์เดิม) 2. **พิกัดทุกอย่างใน DB เป็น % ของขนาดหน้า** — `rects`, `paths`, `position`, `strokeWidth` (% ของ width), `yOffset` (% ของ height) — แปลง px⇄% ผ่าน `utils/coordinates.ts` เท่านั้น และก่อนบันทึก HIGHLIGHT ต้องรวบ rects ด้วย `mergeRectsByLine` เหลือ 1 rect ต่อบรรทัด 3. **ลำดับ layer ตาม PLAN.md §1:** canvas (`pointer-events: none` เสมอ) → highlight-layer (`none` เสมอ, `mix-blend-mode: multiply`) → textLayer → InkLayer (SVG) → comment pins — สลับ pointer-events ตาม tool ปัจจุบัน -4. **เขียนผ่าน Server Actions เท่านั้น** (`app/actions/annotations.ts`) และ**หลังเขียน DB สำเร็จทุกครั้ง ต้อง publish event ผ่าน `lib/annotate-events.ts` + `revalidatePath` หน้า viewer และ summary ของ fileId นั้น** (helper `revalidateFilePages` — สั่ง browser ทิ้ง Router Cache ฝั่ง client, `force-dynamic` ปิดแค่ฝั่ง server ไม่พอ: ไม่งั้นกด back แล้วคลิกเข้าไฟล์ใหม่ยังได้ initial data เก่าจนต้อง hard refresh) / **อ่าน + sync ผ่าน SSE hook** (`hooks/useAnnotationRealtime.ts` เปิด `EventSource` ไป `/api/annotations/stream?fileId=`) — event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ +4. **เขียนผ่าน Server Actions เท่านั้น** (`app/actions/annotations.ts`) และ**หลังเขียน DB สำเร็จทุกครั้ง ต้อง publish event ผ่าน `lib/annotate-events.ts` + `revalidatePath` หน้า viewer และ summary ของ fileId นั้น** (helper `revalidateFilePages` — สั่ง browser ทิ้ง Router Cache ฝั่ง client, `force-dynamic` ปิดแค่ฝั่ง server ไม่พอ: ไม่งั้นกด back แล้วคลิกเข้าไฟล์ใหม่ยังได้ initial data เก่าจนต้อง hard refresh) / **อ่าน + sync ผ่าน SSE hook** (`hooks/useAnnotationRealtime.ts` เปิด `EventSource` ไป `/api/note/annotations/stream?fileId=`) — event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ — **ยกเว้น `registerExternalFile` (N8)** ที่เขียน row `files` ระหว่าง render ของหน้า `/note` และ `/note/annotate` (entry point ทั้งสองของสัญญาลิงก์ `?fileUrl=`) แล้ว `redirect` ออกทันที (หน้า `force-dynamic` + รายการไฟล์ไม่ได้ sync ผ่าน SSE — ไม่ต้อง publish event/`revalidatePath`) · เช่นกัน **กลุ่ม docver `app/api/documents/**`** (จัด version เอกสาร — `docs/PLAN-DOCVER.md`) เขียนตาราง `documents`/`document_versions` ผ่าน route handler ไม่ใช่ Server Action (ผู้เรียกเป็นระบบภายนอก stand-alone ไม่มี session ของแอป note — identity userId/userName, templateId และ docId (จาก claim fid) แกะจาก Bearer token ที่ขอจาก `POST /api/documents/token` ผ่าน `lib/docver-token.ts` ไม่รับจาก request payload อีกต่อไป) — ไม่มีหน้า note ใดอ่านตารางนั้น จึงไม่ต้อง publish event/`revalidatePath` 5. **Timestamp ข้าม server→client→SSE เป็น ISO string เสมอ** (DTO types) — ห้ามส่ง Date object ตรง ๆ -6. **pdfjs-dist ต้อง `dynamic import()` ภายใน useEffect เท่านั้น** — ห้าม import top-level ใน module scope ที่ server evaluate และต้องตั้ง `GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"` (postinstall คัดลอกไฟล์ไป `public/`) — ห้ามใช้ `new URL(..., import.meta.url)` +6. **pdfjs-dist ต้อง `dynamic import()` ภายใน useEffect เท่านั้น** — ห้าม import top-level ใน module scope ที่ server evaluate และต้องตั้ง `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` (postinstall `scripts/patch-pdf-worker.mjs` คัดลอกไฟล์ไป `public/note/` **แล้ว patch จุดเดียว: ถอด `if (scaledDim)` รอบ `textChunk.prevTransform = getCurrentTextTransform()` ให้อัปเดตทุก glyph** — pdf.js เดิมข้าม glyph กว้าง 0 อย่างสระ/วรรณยุกต์ไทย ทำให้ charSpacing ของ glyph เหล่านั้นสะสมเป็น gap จน `addFakeSpaces` แทรก " " กลางคำ "ฉะนั้ น" — bug ของ pdf.js ที่ upstream ยังไม่แก้; การ patch ทำบนโค้ด minified ด้วย pattern จำเพาะ ถ้าอัปเกรด pdfjs-dist แล้ว pattern ไม่ตรง postinstall จะ **fail ดัง ๆ โดยตั้งใจ** กัน patch หลุดเงียบ — test กัน regression: `tests/scripts/patch-pdf-worker.test.ts` + fixture `tests/fixtures/thai_letterspacing.pdf` — Docker build ต้องใช้ script นี้ด้วย ไม่ใช่ copyFileSync เปล่า ดู `docker/web.Dockerfile`) — ห้ามใช้ `new URL(..., import.meta.url)` 7. **InkLayer ใช้ SVG แบบไม่มี viewBox** — render ด้วย px จริง แปลง % → px จากขนาดที่ ResizeObserver วัด (viewBox จะทำให้ความหนาเส้นบิดเบี้ยว) 8. **JSONB `data` เป็น strict discriminated union** (`AnnotationData` แยกตาม enum `type`) — ห้าม loosen type เป็น `any`/generic object 9. **Optimistic update ใช้ `useOptimistic` (React 18) ภายใน `startTransition`** — React rollback อัตโนมัติเมื่อ action ล้มเหลว + dedupe ด้วย id ใน `hooks/useAnnotationRealtime.ts` (duplicate ชั่วขณะกับ realtime event หายเอง) 10. **Page Virtualization:** render เฉพาะหน้าใน viewport ± 1 หน้าด้วย IntersectionObserver — หน้าพ้นจอเก็บ placeholder ขนาดจริง (scroll ไม่ jump, bookmark ยังคำนวณตำแหน่งได้) และ unmount canvas/TextLayer/InkLayer คืน memory — ห้าม render ทุกหน้าพร้อมกัน 11. **DB connection:** `DATABASE_URL` ชี้ที่ plain PostgreSQL container `judes-note-db` (`postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note`) + `prepare: false` ใน postgres.js เสมอ — pub/sub ของ SSE เป็น in-memory (ใช้ได้เมื่อรัน process เดียว; ถ้ารันหลาย instance ต้องเพิ่ม Redis pub/sub หรือ Postgres LISTEN/NOTIFY ก่อน) +12. **URL ฝั่ง browser ที่สร้างเองต้องผ่าน `withBasePath()`** (`utils/base-path.ts` — เติม basePath `/docedit` **+ app path `/note`** จาก env `NEXT_PUBLIC_BASE_PATH`/`NEXT_PUBLIC_APP_PATH` ประกาศที่ `next.config.mjs` ที่เดียว) ครอบคลุม: `fetch()`/`EventSource`/plain ``/`workerSrc`/`img.src`/`file.pdfUrl` ตอนโหลดไฟล์/ลิงก์คัดลอก F12 — ส่วน ``/`next/navigation` (router.push/replace/redirect) Next เติม basePath `/docedit` ให้เองเท่านั้น **path ที่เขียนถึง route จึงต้องมี `/note` นำหน้าเสมอ** (เช่น `href="/note/login"`, `redirect("/note/…")`, `revalidatePath("/note/…")`) **ยกเว้น API — route API อยู่ใต้ `app/api/note/**` (URL `/docedit/api/note/…`): path ที่ขึ้นต้น `/api/` เขียนครบเป็น `/api/note/…` แล้ว `withBasePath` เติมแค่ basePath ไม่มี `/note` ซ้อน — เช่นเดียวกับ static/หน้าของ sibling wordaddin: path ที่ขึ้นต้น `/wordaddin/` (office.js self-host, manifest, icons) เติมแค่ basePath**; `middleware.ts` จับ path ที่ตัด basePath แล้ว (หน้า/ไฟล์ของแอปขึ้นต้น `/note/…` ส่วน API ขึ้นต้น `/api/note/…` — รายการยกเว้นใน matcher: ของแอปนำหน้า `note/` เช่น `note/uploads`, API เป็น `api/note`, `wordaddin` (taskpane ของ Word add-in — รันใน webview ของ Word ไม่มี cookie oacCase และมี auth ของตัวเองแยกจากแอป note), ส่วน `fonts`/`favicon` อยู่ public/ รากไม่มี prefix — NextURL ใส่ basePath กลับตอน redirect เอง); `pdf_url` ใน DB เก็บ logical path ไม่รวม prefix เติมตอนใช้: ฝั่ง browser ผ่าน `withBasePath` / ฝั่ง disk ผ่าน `resolvePublicPdfPath` (`lib/server-pdf.ts` root ที่ `public/note/` — statics ของแอปอยู่ใต้ `public/note/**` ทั้งหมดยกเว้นฟอนต์ export) ## โครงสร้างไฟล์ (ตามแผน) ``` +docker-compose.yml ← dev dependencies: `db` (judes-note-db — ชื่อ container/วอลุ่มเดียวกับคำสั่ง docker run เดิมใน README migrate ได้เลย, POSTGRES_DB ecourt-note) + `minio` (judes-note-minio — object storage ของกลุ่ม docver; โฮสต์แมพ 19000 API/19001 console เพราะ 9000/9001 ชน container โปรเจกต์อื่นในเครื่อง dev, image จาก quay.io ทางการ; bucket `docver` ไม่ต้องสร้างเอง app สร้างตอน first use ผ่าน ensureBucket) +docs/PLAN-DOCVER.md ← แผนกลุ่ม API docver: จัด version เอกสาร .docx+.pdf ต่อ docId (ห้ามซ้ำ) เก็บ object ใน MinIO + ผู้อัปโหลดต่อ version — contract 4 endpoint (เพิ่ม POST /token ขอ access_token — Bearer บังคับทุก endpoint), ดีไซน์ตาราง, env MINIO_* + DOCVER_API_KEY/DOCVER_TOKEN_SECRET/DOCVER_TOKEN_TTL_SECONDS ทั้งหมด +scripts/patch-pdf-worker.mjs ← postinstall: คัดลอก pdf.worker.min.mjs จาก node_modules ไป public/note/ + patch fake-space ไทย (invariant #6 — เจอ pattern ≠ 1 จุดหลังอัปเกรด pdfjs-dist = exit 1 ดัง ๆ) +scripts/copy-swagger-ui.mjs ← postinstall ตัวที่สอง: คัดลอก swagger-ui-dist (bundle+css) ไป public/note/swagger-ui/ สำหรับหน้า Swagger UI ที่ GET /api/swagger (intranet ไม่ใช้ CDN — หาไฟล์ไม่เจอหลังอัปเกรด = exit 1 ดัง ๆ เหมือน patch-pdf-worker; Docker build รัน script นี้ด้วย ดู docker/web.Dockerfile) +scripts/fetch-office-js.mjs ← postinstall ตัวที่สาม: ดาวน์โหลด Office.js runtime **ฉบับ CDN ล่าสุด** (appsforoffice.microsoft.com — ชุดไฟล์เฉพาะ Word: loader + host files ราย platform + legacy 15.x + o15apptofilemappingtable/microsoftajax + office_strings.js ต่อ locale en-us/th-th บังคับ) ไป public/wordaddin/office-js/ ให้ taskpane โหลด same-origin — npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 (~2018) ไม่มี `Document.close`/enum `CloseBehavior` ที่ runtime ใหม่+`@types/office-js` มี; ไฟล์หลักหาย/ตรวจไม่เจอ `"Document.close"`+`CloseBehavior` ใน word-win32-16.01.js หลังโหลด = exit 1 ดัง ๆ (ไฟล์ปลายทาง gitignore — ดาวน์โหลดใหม่ทุกครั้ง; ติดตั้งต้องออกเน็ตถึง CDN ของ Microsoft ส่วน runtime ตัวระบบ intranet ล้วน) db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO db/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false) +middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /note/login (path ที่ตัด basePath แล้ว: หน้าขึ้นต้น /note/… ส่วน API ขึ้นต้น /api/note/… — ยกเว้น note/login, api/note/*, api/template/* (กลุ่ม docx template เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย — ไม่แตะ DB), api/documents/* (กลุ่ม docver จัด version เอกสาร — stand-alone ไม่มี session ใช้ auth ของกลุ่มเอง: ขอ access_token ผ่าน x-api-key แล้วแนบ Bearer ทุก endpoint, docs/PLAN-DOCVER.md), api/swagger/* (หน้า Swagger UI), wordaddin/* (taskpane ของ Word add-in — auth mock ของตัวเอง), note/uploads/*, note/samples, note/swagger-ui/*, note/pdf.worker.min.mjs, _next, fonts, favicon และ **root "/" เอง** (matcher ใช้ `.+` — root เป็น redirect สู่ /wordaddin ซึ่ง public ตามคำขอผู้ใช้ 2026-10-14, ลิงก์ e-Court N8 ที่ root ยังถูกคุมผ่าน /note ที่รับส่งต่อ)) lib/annotate-events.ts ← pub/sub in-process (actions publish → SSE route subscribe) -lib/server-pdf.ts ← path guard อ่าน PDF จาก public/ (export ใช้) +lib/server-pdf.ts ← path guard อ่าน PDF จาก public/note/ (export ใช้) +lib/session.ts ← getSessionUser — identity จาก cookie oacCase (mock login e-Court) +lib/external-import.ts ← N8 registerExternalFile — ลงทะเบียนเอกสารจากลิงก์ e-Court metadata-only (dedupe external_id + refresh pdf_url/typeId — เรียกระหว่าง render หน้า /note และ /note/annotate ไม่ใช่ Server Action, ดู carve-out ใน invariant #4; `firstSearchParam` อยู่ที่ `utils/external-file.ts` แทน) +lib/remote-file.ts ← N8 ดึงไฟล์รีโมตจาก allowlist (validate ซ้ำหลัง redirect + จำกัด 100MB + timeout 60s — /api/note/pdf กับ export ใช้ร่วม) +lib/external-api-auth.ts ← N10 auth ของ /api/note/external/* — requireExternalApiKey เทียบ header `x-api-key` กับ env `EXTERNAL_API_KEY` ด้วย SHA-256 + crypto.timingSafeEqual (env ไม่ตั้ง/ว่าง = 401 fail closed ทุก request; ข้อความ 401 อันเดียวกันทั้งไม่ใส่/key ผิด กัน enumerate; ห้าม log ค่า key/header ดิบ) + externalApiError สร้าง Response ผิดพลาด JSON {"error": ไทย} ใช้ร่วมทุก route ของ N10 — **module นี้ห้าม import อะไรที่ลาก @/db มาด้วย** (db/index.ts throw ตอน import เมื่อไม่มี DATABASE_URL — unit test tests/lib/external-api-auth.test.ts ต้องรันได้โดยไม่แตะ DB) +lib/external-api.ts ← N10 ส่วนกลางของ route — parseQueryBody (validate JSON body ของ /api/note/external/documents: รับเฉพาะ fileId/typeId/filter, ต้องมี fileId หรือ typeId อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข, คำขอผู้ใช้ 2026-09-25 (รอบเดียวกันเคยเป็น OR แล้วเปลี่ยนหลังทดสอบจริง), filter รับเฉพาะ type ∈ bookmarks|annotations ไม่ส่ง = ทั้งสอง + userId (string|array กรองตามเจ้าของรายการ annotations+bookmarks) + annotationTypes (ค่า enum annotation_type เฉพาะ annotations ค่าอื่น = 400 — เดิมชื่อ types เปลี่ยน 2026-09-25) — คืน discriminated mode "file" (fileId + typeId อาจมาด้วย) / "type" (typeId เดี่ยว)) + loadDocumentBundles(fileRows, parts) (bulk: inArray ทุกไฟล์ในชุด query เดียว = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่ม, code review 2026-09-25 — กรอง userIds/annotationTypes ใน SQL; คืน bundle ยุบชั้น: field เอกสาร + annotations + bookmarks ระดับเดียวกัน ไม่มี object document ซ้อน; ต่อรายการมี `url` deep link F12 กลับหน้า `/annotate/[id]` — annotation `?ann=` / bookmark `?page=<หน้า 1-based>` — URL เต็ม: origin จาก request ผู้เรียก (route อ่าน x-forwarded-proto/host แล้ว fallback host ตรง ส่งเข้าทาง parameter origin) + basePath ผ่าน withBasePath, คำขอผู้ใช้ 2026-09-25) + type ExternalDocumentDTO (externalFileId เป็น null สำหรับไฟล์ url:-fallback/อัปโหลด N4 ในกลุ่ม) +lib/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กลุ่ม template + N10 + docver — 2026-10-06) — แหล่งเดียวของ Swagger UI: GET /api/template inline ลง HTML ตรง ๆ ไม่มี endpoint spec แยก; URL ใน paths เป็น path เต็ม /api/… (servers เติมแค่ basePath ตาม invariant #12) — แก้ contract ของ API ต้องมาแก้ไฟล์นี้ให้ตรงด้วย +lib/document-versioning.ts ← docver pure validation (ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test tests/lib/document-versioning.test.ts ต้องรันได้เหมือน lib/external-api-auth.ts): parseVersionUploadForm (allowlist field — identity ไม่รับจาก form แล้ว แกะจาก token ผ่าน lib/docver-token.ts + fields JSON ≤100 keys + message ≤2048), isDocxBytes/isPdfBytes (magic bytes), parseDownloadFormat/parseVersionIdParam, contentDispositionValue (ASCII fallback + filename*=UTF-8'') + docverError สร้าง Response JSON {"error": ไทย} ใช้ร่วมทุก route ของกลุ่ม (parseDocId ถอดแล้ว 2026-10-26 — docId ไม่มาจาก path param) +lib/docver-token.ts ← docver token + auth ของกลุ่ม /api/documents/* (2026-10-22 — pure module ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย เหมือน lib/document-versioning.ts; unit test tests/lib/docver-token.test.ts ต้องรันได้โดยไม่ต้องตั้ง env): issueDocverToken/verifyDocverToken (JWT HS256 มือเขียน node:crypto ล้วน — บังคับ alg=HS256 กัน alg-confusion, timingSafeEqual หลัง hash ทั้งสองฝั่ง, exp บังคับ) + requireDocverApiKey (header x-api-key เทียบ env DOCVER_API_KEY — fail closed 401 ไม่ตั้ง/ว่าง, 401 เดียวกันทั้งไม่ใส่/ผิด กัน enumerate, ห้าม log ค่า key/header ดิบ) + requireDocverBearer (Authorization: Bearer → claims {userid, username, fid, template} — secret จาก env DOCVER_TOKEN_SECRET ไม่ตั้ง = 401, ทุกกรณี 401 ข้อความเดียวกัน) + parseTokenRequestBody (allowlist userid/username/fid/template — **fid = fileId ของ e-Court (string, บังคับ 2026-10-26)** เป็น docId ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid) +lib/docver-store.ts ← docver server-side MinIO + DB (env MINIO_* อ่านตอนเรียกไม่ใช่ module load; client + cached ensureBucket เก็บบน globalThis แบบ db/index.ts): object key `docver//document.docx|pdf` (uuid ล้วน ไม่มี input ผู้ใช้), putVersionObjects (docx ก่อน — pdf ล้ม = ลบ docx คืนแล้วโยนต่อ), removeVersionObjects (best-effort), presignVersionUrl (TTL env DOCVER_LINK_TTL_SECONDS), insertVersionRecord (**tx เดียว**: upsert documents ON CONFLICT DO NOTHING → SELECT id FOR UPDATE → max(versionNumber)+1 → insert — จ่ายเลข version ปลอดภัยเรียกคู่ขนาน, UNIQUE(document_id, version_number) เป็น safety net) +wordaddin/ ← **client-only zone ของ Word add-in** (ห้าม import จาก lib/ หรือถูก import โดย server component — พอร์ตดิบจาก D:\word-addin-poc\addin\src, spec: docs/addin-ui-flow.md, คู่มือ dev: docs/WORDADDIN-DEV.md) +wordaddin/config.ts ← env NEXT_PUBLIC_ADDIN_API_BASE_URL ("" = ยังไม่ต่อ backend จริง) + NEXT_PUBLIC_ADDIN_MOCK_USERNAME (mock auth) +wordaddin/models/versionRecord.ts ← types VersionRecord/FieldsSnapshot + readSnapshotField (พอร์ตดิบ) +<<<<<<< HEAD +wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ · **authService = MOCK** (login ผ่านทันที คง surface เดิมครบไว้สลับของจริง — ต้นฉบับ Keycloak PKCE อยู่ที่ D:\word-addin-poc\addin + **`signInFromDocument` e-Court pass-through 2026-10-16: ไฟล์ที่ stamp custom properties `token`/`owner` มา (`readTrackedProperties` อ่านรวม) = `detect()` ของ TaskpaneApp ผ่านเข้าเลยไม่ต้องกด login — username จาก owner → uid ใน JWT → mockUsername, token ไฟล์เก็บ memory ให้ getAccessToken ใช้ต่อ**) · **versioningService = MOCK** (memory + **metadata persist ลง localStorage ต่อ documentId** เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ได้ — blob ไฟล์จริงเฉพาะ session; คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId เป๊ะ, download-link คืน about:blank — **backend จริงของ surface นี้มีแล้ว**: กลุ่ม `app/api/documents/**` (docver, docs/PLAN-DOCVER.md — สลับ mock ด้วยการตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL`) +======= +wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ + **templateService** (แท็บที่ 2 "เครื่องมือ Template" 2026-10-19 — list/select/applyTestValues + แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้า ที่ cursor (page break มี fallback OOXML กัน E_FAIL), Office.js ล้วน — ⚠️ `contentControls` ธรรมดาเห็นเฉพาะ rich text CC: feature-detect WordApi 1.5 ใช้ `getContentControls()` เห็นทุกชนิด, ห้าม top-level Office access) · **authService = MOCK** (login ผ่านทันที คง surface เดิมครบไว้สลับของจริง — ต้นฉบับ Keycloak PKCE อยู่ที่ D:\word-addin-poc\addin + **`signInFromDocument` e-Court pass-through 2026-10-16: ไฟล์ที่ stamp custom properties `token`/`owner` มา (`readTrackedProperties` อ่านรวม) = `detect()` ของ TaskpaneApp ผ่านเข้าเลยไม่ต้องกด login — username จาก owner → uid ใน JWT → mockUsername, token ไฟล์เก็บ memory ให้ getAccessToken ใช้ต่อ**) · **versioningService = MOCK** (memory + **metadata persist ลง localStorage ต่อ documentId** เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ได้ — blob ไฟล์จริงเฉพาะ session; คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId เป๊ะ, download-link คืน about:blank) +>>>>>>> 6daee1585199721ba931b2bf4fe55277cb7c304a utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %) -utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + ค่า default TEXTBOX/STAMP (sidebar/สรุป/CSV ใช้ร่วม) +utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + TYPE_SHORT/TYPE_ORDER ชื่อย่อ/ลำดับชนิด (บรรทัดสรุปหน้าของหน้าสรุป + แผงตัวกรอง explorer — ย้ายจาก 2 ที่ซ้ำกันมารวมที่นี่ 2026-09-24) + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/groups/CSV ใช้ร่วม) +utils/date-format.ts ← formatThaiDate วันที่ไทยแบบเต็ม "18 กันยายน พ.ศ. 2569" (+เวลา "… 16:33:00 น.") — ทุกจุดที่แสดงวันที่ใน UI ใช้ร่วม (เว้น export CSV ที่ lock Asia/Bangkok) utils/pdf-export.ts ← แปลงพิกัดจอ⇄PDF (/Rotate) + wrapText + hexToRgb (export route) -utils/user.ts ← anonymous identity (localStorage) +utils/user.ts ← parse/build ค่า cookie oacCase → OacUser {userId: u_userid, userName: u_userlogin} (pure module — middleware ใช้ร่วมได้) +utils/external-file.ts ← N8 validation ลิงก์ไฟล์รีโมต (allowlist "admincourt.go.th" + env EXTERNAL_FILE_HOSTS, suffix match, https เท่านั้น ห้าม user:pass@) + titleFromExternalUrl/externalIdFor/isHttpUrl + normDocumentGroup (N9 normalize รหัสกลุ่ม — ใช้ร่วม N8/N4, pure) +utils/base-path.ts ← withBasePath() เติม basePath /docedit + app path /note ให้ URL ฝั่ง browser ที่ Next ไม่เติมให้เอง (ค่าจาก next.config.mjs ผ่าน env — invariant #12) utils/file-kind.ts ← derive ชนิดไฟล์ pdf/image จาก extension ของ path (N3 — ใช้ร่วม viewer/export/หน้ารายการ/seed) +utils/upload-name.ts ← N4 ถอดรหัสชื่อไฟล์จาก FormData ของ Server Action กลับเป็น UTF-8 ก่อนใช้ (undici ถอด header Content-Disposition เป็น latin1 ทำชื่อไทยเพี้ยน "คำ…" — encode กลับ byte แล้วถอด UTF-8; guard: ไม่มีตัว U+0080–U+00FF = ไม่แตะ, ถอดแล้วได้ U+FFFD = เก็บของเดิม, 2026-10-05) utils/tiff-client.ts ← decode TIFF ทุกเฟรม → PNG blob URL ฝั่ง client (N3) utils/image-to-pdf.ts ← สร้าง PDF ใหม่จากไฟล์รูป embedJpg/embedPng + EXIF rotate + TIFF→PNG (N3, export route) hooks/useAnnotationRealtime.ts ← EventSource ต่อ SSE stream + dedupe ด้วย id hooks/useHistory.ts ← undo/redo stack ฝั่ง client (F2) app/actions/annotations.ts ← Server Actions CRUD + validation + publish event -app/actions/files.ts ← Server Action uploadFile — validate + เขียนไฟล์ใหม่ที่ public/uploads/ (N4) -app/api/annotations/stream/route.ts ← SSE endpoint (GET ?fileId=) -app/api/annotations/export/route.ts ← export PDF ฝัง annotation (pdf-lib, F8) -app/api/annotations/export-csv/route.ts ← export สรุป CSV พร้อม BOM (F16) -app/page.tsx ← รายการไฟล์ (server component) -app/annotate/[id]/page.tsx ← โหลด file + initial data → PDFViewer -app/annotate/[id]/summary/page.tsx ← หน้าสรุปหมายเหตุจัดกลุ่มตามหน้า (F15) +app/actions/auth.ts ← Server Action login (mock user_x/Demo1234 → ตั้ง cookie oacCase) / logout (ลบ cookie) +app/actions/files.ts ← Server Action uploadFile — validate + เขียนไฟล์ใหม่ที่ public/note/uploads/ + ช่องกลุ่ม typeId ทางเลือก normalize เหมือน N8 (N4) +app/api/note/annotations/stream/route.ts ← SSE endpoint (GET ?fileId=) +app/api/note/annotations/export/route.ts ← export PDF ฝัง annotation (pdf-lib, F8) +app/api/note/external/documents/route.ts ← N10 API ภายนอกเส้นเดียว (POST + JSON body {fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}} — auth ผ่าน requireExternalApiKey ก่อนแตะ DB, ถาม fileId = ค้น files.external_id = "ecourt:" ไม่เจอ 404 แม้ส่ง typeId มาด้วย / ถาม typeId = ทุกไฟล์ในกลุ่ม กลุ่มว่าง = 200 documents:[] / ส่งทั้งคู่ = AND กรอง externalId+typeId ใน query เดียว เหลือเอกสารเดียว ไม่ตรงคู่ = 404 แยกสาเหตุในข้อความ (2026-09-25 เดิมเคยเป็น OR แล้วเป็น 400); response รูปเดียว {documents:[bundle…]} ผ่าน loadDocumentBundles ทั้งสอง mode; ครอบ try/catch ทุก DB call — ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework; read-only ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก) +app/api/swagger/route.ts ← **GET /api/swagger = หน้า Swagger UI** (2026-10-06 — document ของ API ทุกกลุ่ม: spec inline จาก lib/openapi.ts + asset จาก public/note/swagger-ui/ ของ postinstall scripts/copy-swagger-ui.mjs, persistAuthorization เก็บ x-api-key ไว้ใช้ข้าม reload — เปิดกว้างไม่ต้อง login) +app/api/template/{test,extract,render}/route.ts ← กลุ่ม API docx template ของ wordaddin (WIP — GET test ตรวจ service / POST extract ดึงค่า Content Control / POST render = template(.docx)+data(JSON ไฟล์หรือ field dataJson) คืน .docx ใหม่; logic รวมอยู่ app/api/template/lib-tmpl.ts เป็นแชร์ไลบรารี pizzip+xpath; ไฟล์ทดสอบ/โน้ตอยู่ app/api/template/test/ — ไม่แตะ DB, URL /docedit/api/template/…) +app/api/documents/token/route.ts + versions/route.ts + versions/[versionId]/download-link/route.ts ← **กลุ่ม API docver จัด version เอกสาร** (docs/PLAN-DOCVER.md — URL /docedit/api/documents/…, auth ของกลุ่มเอง 2026-10-22: POST token ขอ access_token ด้วย header `x-api-key` + env `DOCVER_API_KEY` (lib/docver-token.ts — fail closed 401 ไม่ตั้ง/ว่าง เหมือน N10) แล้ว **endpoint ที่เหลือทุกตัวบังคับ `Authorization: Bearer`** JWT HS256 เซ็นด้วย env `DOCVER_TOKEN_SECRET` (TTL `DOCVER_TOKEN_TTL_SECONDS` default 3600) — identity userid/username + templateId แกะจาก claims ไม่รับจาก request อีกต่อไป; ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field · **2026-10-26 ถอด segment `[docId]` ออกจาก URL ทุก endpoint — docId = claim `fid` (string) จาก token ตรง ๆ (บังคับตั้งแต่ขอ token)**): POST versions = multipart `file`(.docx บังคับ ≤100MB เช็ค size ก่อน buffer → 413)+`pdf`(optional ไฟล์เปล่า = ไม่มี)+fields/message/clientVersionId/fileName → สร้างเอกสารอัตโนมัติถ้า docId ใหม่, MinIO ก่อน DB (DB ล้มหลัง put = เก็บกวาด object แบบ N4) → `{hasPdf, versionId, versionNumber}` (เลขจาก server เป็นค่าจริง) / GET versions = DocumentVersionDTO[] ใหม่→เก่า (ไม่เคยมี docId = 404) / GET download-link?format=docx|pdf = presigned URL อายุสั้น (เช็ค docId+versionId คู่กันกันโยน id ข้ามเอกสาร; ขอ pdf ที่ version ไม่มี = 404) — carve-out invariant #4: ไม่ publish SSE ไม่ revalidatePath +app/note/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัปโหลดจาก public/note/uploads/ (N4 — Next production เห็นไฟล์ static เฉพาะที่มีตอน startup ไฟล์ที่เขียน runtime ต้องเสิร์ฟผ่าน route) +app/note/page.tsx ← รายการไฟล์จัด section ตามกลุ่ม type_id + "ไม่มีกลุ่ม" ท้ายสุด (N9 — server component) +app/note/groups/[typeId]/page.tsx ← N9 หน้าเอกสารในกลุ่ม (server component force-dynamic — files where type_id orderBy createdAt, ไม่มี = 404; ~~หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้"~~ ถอดออกตามคำขอผู้ใช้ 2026-09-24; **redesign 2026-09-24 ตามคำขอผู้ใช้: ไม่มี navbar** — explorer 2 คอลัมน์ ตัวกรองซ้าย + list เดียวรวม bookmark+annotation ทั้งกลุ่มไม่แยกไฟล์: query ทั้งหมดฝั่ง server แล้วส่ง `GroupFileSummary[]` + `GroupListItem[]` (เรียง ไฟล์ตาม createdAt → หน้า → bookmark ก่อน annotation → createdAt) ให้ `GroupExplorer` กรองใน memory, แถวรายการ deep link `?ann=`/`?page=` เหมือนหน้าสรุป F12 + ไฟล์ที่มาต้น meta line) +app/note/login/page.tsx ← หน้า login (มี session แล้ว redirect /note) +app/note/annotate/[id]/page.tsx ← โหลด file + initial data → PDFViewer +app/note/annotate/[id]/summary/page.tsx ← หน้าสรุป Annotation+Bookmark ของไฟล์เดียว (server component — query ทั้งหมดฝั่ง server แล้วส่ง `ExplorerItem[]` เรียง หน้า → หน้าเดียวกัน bookmark ก่อน annotation (bookmark ต่อหน้าเรียงตาม yOffset N5 เหมือนเดิม, annotation ตาม createdAt) ให้ `SummaryExplorer` กรองใน memory; design "แฟ้มสำนวน" 2026-09-17 แถบหัวน้ำเงิน + การ์ดรายหน้าขอบ folio + รายการตามหน้าจุดไข่ปลา → **redesign 2026-09-24 ตามคำขอผู้ใช้เป็น explorer 2 คอลัมน์แบบหน้ากลุ่ม N9 โดยมิติแรกของตัวกรองเป็น "หน้า" แทน "ไฟล์"** — แถบหัวน้ำเงิน + action bar (กลับหน้าอ่าน — ปุ่มส่งออก CSV F16/ส่งออก Bookmark JSON N7 ถอด 2026-09-25 แทนด้วย N10 API) + empty state คงเดิม, ไฟล์ว่าง = ไม่ mount explorer เลย; generateMetadata ตั้งชื่อแท็บ browser ตามคดี; ตอนพิมพ์ header เป็นกรอบเส้นน้ำเงิน + แผงตัวกรอง/แถบ action ซ่อนด้วย print:hidden — กฎ @media print อยู่ใน globals.css) — อย่าถอด class `summary-page` ของ
(กฎ coarse 44px ใน globals.css ผูกอยู่กับ selector นี้ ครอบทั้ง `a` และ `button`) +app/wordaddin/layout.tsx + page.tsx ← taskpane ของ Word add-in (URL `/docedit/wordaddin` — nested layout แรกของ repo override title แท็บ browser; page เป็น server component บาง ๆ render WordAddinGate ทั้งหมดฝั่ง client) +app/wordaddin/manifest.xml/route.ts ← GET serve manifest ของ add-in พร้อม rewrite origin ตามโฮสต์ที่ขอ (template `public/wordaddin/manifest.template.xml` ค่า dev — dev/UAT/production โหลด manifest.xml ขอ environment ตัวเองแล้วใช้ได้เลย ไม่ต้อง rewrite มือ; origin จาก `utils/request-origin.ts` แชร์กับ deep link N10 — x-forwarded-proto/host ก่อน fallback host ตรง + บังคับ https โฮสต์ที่ไม่ใช่ localhost เพราะ Office รับ https เท่านั้น) +components/wordaddin/WordAddinGate.tsx ← client gate: ฉีด script office.js self-host (ผ่าน withBasePath "/wordaddin/office-js/office.js") → Office.onReady → mount TaskpaneApp · `?addindemo=untracked|tracking` (dev-only) ข้าม office.js + mock sign in + ตั้ง phase ตรง เพื่อดู UI ใน browser ธรรมดา — flow ที่แตะ Word จริงจะจบที่ error ตาม expected (FR-17: ใน browser ปกติ banner auto-save error โผล่หลัง ~30 วิ — ถูกต้อง) +components/wordaddin/TaskpaneApp.tsx ← state machine checking/signed-out/untracked/tracking + handler ทุกตัว พอร์ตจาก App.tsx ต้นฉบับ (state/handlers คงเดิม เปลี่ยนแค่ JSX → Genesis) · SaveControls/MessageBar/Spinner/ConfirmVersionDialog(portal แบบ AnnotationSidebar)/VersionHistoryPanel · **2 แท็บ (Versioning / เครื่องมือ Template — toggle `aria-pressed` เฉพาะ phase untracked|tracking, 2026-10-19): แท็บ template = TemplateToolsPanel (สแกน CC/กรอกค่าทดสอบ/แทรกย่อหน้า-ตัวแบ่ง — spec ที่ docs/addin-ui-flow.md §10)** +components/summary/SummaryExplorer.tsx ← client root ของหน้าสรุป (redesign 2026-09-24) — state ตัวกรอง `FacetFilterState` จากแผงกลาง explorer + กรองใน memory + facet count/static users derive เอง + มิติ "หน้า" เป็นตัวเลือกแบบ single-select เฉพาะหน้าที่มีรายการ ≥1 เรียงน้อย→มาก (`optionsScroll` ให้ scroll ภายในกลุ่ม max-h-64 กันหน้าเยอะดันกลุ่มอื่นออกจอ) + layout 2 คอลัมน์ (มือถือ < lg ตัวกรองหุบเป็น `
`, desktop aside sticky 15rem — pattern เดียวกับ GroupExplorer; คอลัมน์ content ครอบด้วย `
` ไม่ใช่ `
` เพราะกฎ print break-inside ของ `main.summary-page section` มุ่งการ์ดรายหน้าข้างใน) +components/summary/SummaryList.tsx ← คอลัมน์ content ของหน้าสรุป — การ์ดรายหน้า "1 หน้า 1 การ์ด" หัวการ์ด sticky ระดับ viewport (เลขหน้า + บรรทัดสรุปหน้า `pageSummaryLine` **คำนวณจากรายการที่เห็นจริงหลังกรอง** + ปุ่ม "เปิดหน้านี้"; กลับเป็น position static ตอนพิมพ์ผ่านกฎ `.page-head` ใน globals.css) + แถว passive จุดคลิกเดียวคือปุ่ม `` "View" เหมือน GroupList (deep link `?ann=`/`?page=` — bookmark ไปหน้า, annotation ไปรายการ; meta line ไม่มี segment ไฟล์เพราะไฟล์เดียวทั้งหน้า) + header "พบ N รายการ" (aria-live) + ปุ่ม "ล้างตัวกรอง" (เฉพาะมีตัวกรอง active) + empty state กรองจนไม่เหลือ + **ตอนพิมพ์แสดงฉบับเต็มเสมอ** — ตัวกรองที่ตั้งค้างต้องไม่ตัดรายการออกเงียบ ๆ บนกระดาษ: ตอน activeCount > 0 render ชุดบนจอ (print:hidden) + ชุดเต็ม (hidden print:block, ไม่มี id/aria-labelledby ซ้ำ) แยกกัน; anchor `#page-N` ของ section คงจาก design เดิมให้ลิงก์เก่ายังใช้ได้ components/pdf/PDFViewer.tsx ← client root (tool state, zoom, selection, lazy render หน้า) components/pdf/InkLayer.tsx ← SVG freehand + รูปทรง + redaction components/pdf/TextboxStampLayer.tsx ← overlay TEXTBOX + STAMP (F17/F18) components/pdf/AnnotationSelectionLayer.tsx ← กรอบ selection + handles ย้าย/ปรับขนาด (F1) + แถบสี/ความหนา + ปุ่มลบ components/sidebar/AnnotationSidebar.tsx ← + โน้ตคำอธิบาย (note) inline ต่อรายการ (PLAN-FEAT.md §7 N1) -components/UploadFileForm.tsx ← ฟอร์มอัปโหลดไฟล์บนหน้ารายการ (N4) +components/AnnotationTypeIcon.tsx ← ไอคอนตามชนิด annotation (ย้ายจาก sidebar) — ไม่มี "use client" ให้ server component ใช้ร่วมได้ (sidebar + หน้าสรุป) +components/UploadFileForm.tsx ← ฟอร์มอัปโหลดไฟล์บนหน้ารายการ + ช่องกลุ่ม typeId ทางเลือก ไม่กรอก = ไม่มีกลุ่ม (N4) +components/EcourtLinkForm.tsx ← การ์ดจำลองลิงก์ e-Court บนหน้ารายการ (N8 — กรอก fileUrl/fileId/typeId กด "เปิดเอกสาร" แล้ว router.push ไป /note?fileUrl=&fileId=&typeId= ให้หน้า /note ลงทะเบียน+redirect เอง — ใช้ทดสอบก่อนมีลิงก์จริงจาก e-Court) +components/groups/GroupExplorer.tsx ← N9 client root ของหน้า /groups/[typeId] (redesign 2026-09-24) — state ตัวกรองก้อนเดียว `FacetFilterState` (first=fileId/`kinds: Set<"bm"|"ann">` ชนิดรายการ + `types: Set` ชนิดของ Annotation — **ทั้งคู่ Set ว่าง = แสดงทั้งหมด (default), ติ๊กอะไรเห็นเฉพาะสิ่งนั้น** คำขอผู้ใช้ 2026-09-24/ผู้บันทึก) + กรองใน memory ด้วย useMemo + facet count/static users derive เอง + layout 2 คอลัมน์ (มือถือ < lg ตัวกรองหุบเป็น `
` แบบหน้าสรุป, desktop aside sticky 15rem scroll ภายใน) — render `FacetFilterPanel` สองที่แชร์ state เดียว (`firstGroup` = ไฟล์) + re-export `GroupListItem` = `ExplorerItem` +components/explorer/FacetFilterPanel.tsx ← แผงตัวกรอง facet **กลางใช้ร่วม 2 หน้า** — ย้ายมาจาก FilterControls ของหน้ากลุ่ม 2026-09-24 ให้หน้าสรุปใช้ร่วม (มิติแรกส่งเข้าทาง prop `firstGroup`: groups = "ไฟล์", สรุป = "หน้า" พร้อม `optionsScroll` ให้ scroll ภายในกลุ่ม max-h-64 กันหลายสิบหน้าดันกลุ่มอื่นออกจอ; อีก 3 กลุ่ม ชนิดรายการ/ชนิดของ Annotation/ผู้บันทึก เหมือนกันทุกหน้า) — **tree menu 2 ระดับสไตล์ Quasar docs** (q-tree--dense no-connectors — design รอบสอง 2026-09-24 ตามคำขอผู้ใช้, ธีม Genesis พื้นสว่าง) — **ชนิดรายการ (Bookmark/Annotation) เป็น checkbox คู่ที่ไม่เลือก = แสดงทั้งหมด (default) ติ๊กอะไรเห็นเฉพาะสิ่งนั้น** (direct selection ตามคำขอผู้ใช้ 2026-09-24 รอบสอง — แบบเดิมเปิดครบทั้งคู่ต้องปิดเอง = สับสน; เคยย้ายไป segmented บนหัว content แล้วย้ายกลับตามคำขอผู้ใช้) = parent `
` หุบ/กางอิสระ + chevron หมุน 90°, leaf = ` +

+ กดแล้วระบบลงทะเบียนเอกสาร (ถ้ายังไม่มี) แล้วพาเข้าหน้าอ่าน/มาร์กเอกสารทันที — + ไฟล์รีโมตรับเฉพาะ https ของโฮสต์ที่อนุญาต +

+

+ ลิงก์เต็ม: + {fullLink || "…"} +

+ {error &&

{error}

} + + ); +} diff --git a/components/LoginForm.tsx b/components/LoginForm.tsx new file mode 100644 index 0000000..40ee6c8 --- /dev/null +++ b/components/LoginForm.tsx @@ -0,0 +1,143 @@ +"use client"; + +// ฟอร์ม login (mock e-Court) — เรียก server action login แล้ว router.replace(next ?? "/note") +// (pattern ตาม UploadFileForm: useTransition + error state + ข้อความ fallback ตอน transport fail) +// next = path วนกลับหลัง login (N8 D5 — ผ่าน sanitizeNextPath จากหน้า login มาแล้ว) +// Genesis (docs/DESIGN.md): การ์ด surface 12px radius ขอบ line ไม่มีเงาระดับ static / +// input ผ่าน .genesis-input / ปุ่ม primary hover ขึ้น 1px + glow ฟ้า (shadow-btn-glow) +import { useRef, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +import { login } from "@/app/actions/auth"; +import Select from "@/components/Select"; +import { MOCK_USERS } from "@/utils/user"; + +// text input ของแอป — text-base (16px) เสมอ: กัน iOS auto-zoom ตอน focus บน iPad +// (ทับ text-sm ของ .genesis-input — utility อยู่หลัง @layer components ชนะเสมอ) +// error ตาม spec Inputs: ขอบเปลี่ยนเป็นแดง (ring ยังเป็นฟ้าตาม focus ring หลัก) +const INPUT_CLASS = + "genesis-input mt-1 block w-full text-base transition aria-invalid:border-error"; +// dropdown ผู้ใช้ — เหมือน INPUT_CLASS แต่ไม่มี mt-1 (Select ครอบ div.relative: มาร์จินที่ wrapper ข้างนอกแทน) +const SELECT_CLASS = "genesis-input block w-full text-base transition"; + +export default function LoginForm({ next }: { next: string | null }) { + const router = useRouter(); + const passwordRef = useRef(null); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const form = e.currentTarget; + setError(null); + const usernameInput = form.elements.namedItem("username") as HTMLSelectElement; + const passwordInput = form.elements.namedItem("password") as HTMLInputElement; + startTransition(async () => { + try { + const result = await login(usernameInput.value, passwordInput.value); + if (result.ok) { + router.replace(next ?? "/note"); + } else { + setError(result.error); + // คง username ไว้ (โอกาสพลาดอยู่ที่ password) — ล้างแล้วโฟกัสให้พิมพ์ทับได้ทันที + passwordInput.value = ""; + passwordRef.current?.focus(); + passwordRef.current?.select(); + } + } catch { + setError("เข้าสู่ระบบไม่สำเร็จ — ลองใหม่อีกครั้ง"); + } + }); + }; + + return ( +
+ {/* brand lockup เดียวกับ nav หน้ารายการ — tile JN น้ำเงินคือแบรนด์มาร์กของระบบ + (หน้าสรุปแถบน้ำเงิน 2026-09-17 สืบต่อจาก tile นี้) ชื่อแบรนด์เป็น ink ตามกฎ + "blue เฉพาะของ interactive" ของ Genesis */} +
+
+ JN +
+
+

+ Judge's Note +

+

+ ระบบ PDF Annotation สำหรับ e-Court +

+
+
+ +

เข้าสู่ระบบ

+ +
+
+ +
+ +
+
+ +
+ + +
+ + + + {/* รหัสผ่านทดสอบ — รายชื่อเห็นใน dropdown แล้ว เหลือบอกแค่รหัสผ่าน (mock ไม่มีระบบสมัคร) + กล่อง metadata 8px radius ตาม Genesis (พื้นเทาโปร่งจาง ไม่ใช่การเน้น) */} +

+ รหัสผ่านทดสอบ:{" "} + Demo1234 +

+ + {error && ( +

+ {error} +

+ )} +
+
+ ); +} diff --git a/components/LogoutButton.tsx b/components/LogoutButton.tsx new file mode 100644 index 0000000..eb0bfc1 --- /dev/null +++ b/components/LogoutButton.tsx @@ -0,0 +1,29 @@ +"use client"; + +// ปุ่มออกจากระบบ — server action logout ลบ cookie oacCase แล้ว redirect /login เอง +import { useTransition } from "react"; +import { LogOut } from "lucide-react"; + +import { logout } from "@/app/actions/auth"; + +export default function LogoutButton() { + const [pending, startTransition] = useTransition(); + + const onLogout = () => { + startTransition(async () => { + await logout(); + }); + }; + + return ( + + ); +} diff --git a/components/Select.tsx b/components/Select.tsx index 9665d99..24fa082 100644 --- a/components/Select.tsx +++ b/components/Select.tsx @@ -13,7 +13,7 @@ export default function Select({ return (
+ {/* กลุ่ม typeId ทางเลือก (N9) — ช่องเดียวกับลิงก์ e-Court, ไม่กรอก = "ไม่มีกลุ่ม"; + uncontrolled ส่งไปกับ FormData เหมือนช่องไฟล์ (normalize ที่ action) */} + + - {error &&

{error}

} +

+ รองรับ PDF, JPG, PNG, TIFF — ไม่เกิน 100MB ต่อไฟล์ +

+ {error &&

{error}

} ); } diff --git a/components/UserPicker.tsx b/components/UserPicker.tsx deleted file mode 100644 index 1717fa7..0000000 --- a/components/UserPicker.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; - -// เลือก mock user ที่หน้าแรกก่อนเข้า annotate — เขียนลง localStorage key เดิม -// ที่ getLocalUser() อ่าน (PDFViewer ไม่ต้องแก้อะไร) -import { useEffect, useState } from "react"; - -import Select from "./Select"; -import { - MOCK_USERS, - getStoredLocalUser, - setLocalUser, - type LocalUser, -} from "@/utils/user"; - -export default function UserPicker() { - const [current, setCurrent] = useState(null); - - // ต้องรันใน effect เพราะ SSR ไม่มี localStorage — ก่อน mount แสดงคนแรกไว้ก่อน - useEffect(() => { - const stored = getStoredLocalUser(); - if (!stored) { - // ยังไม่เคยมี identity → เริ่มด้วย mock user คนแรก - setLocalUser(MOCK_USERS[0]); - setCurrent(MOCK_USERS[0]); - return; - } - setCurrent(stored); - }, []); - - // identity สุ่มเดิมไม่อยู่ในรายการ mock → แสดงเป็น option ชั่วคราวให้เห็นว่าปัจจุบันคือใคร - const isMock = current !== null && MOCK_USERS.some((u) => u.userId === current.userId); - - const select = (userId: string) => { - const user = MOCK_USERS.find((u) => u.userId === userId); - if (!user) return; - setLocalUser(user); - setCurrent(user); - }; - - return ( -
-

เลือกผู้ใช้ (mock)

-

- annotation ทั้งหมดจะบันทึกในชื่อผู้ใช้ที่เลือก -

- -
- ); -} diff --git a/components/explorer/FacetFilterPanel.tsx b/components/explorer/FacetFilterPanel.tsx new file mode 100644 index 0000000..37c4a39 --- /dev/null +++ b/components/explorer/FacetFilterPanel.tsx @@ -0,0 +1,354 @@ +"use client"; + +import type { ReactNode } from "react"; +import { + Bookmark, + Check, + ChevronRight, + FileText, + Layers, + PenLine, + Shapes, + Users, +} from "lucide-react"; + +import { AnnotationTypeIcon } from "@/components/AnnotationTypeIcon"; +import type { + AnnotationData, + AnnotationDTO, + AnnotationType, + BookmarkDTO, +} from "@/db/schema"; +import { TYPE_ORDER, TYPE_SHORT } from "@/utils/annotation-display"; + +// แผงตัวกรอง facet กลาง — tree menu 2 ระดับสไตล์ Quasar docs q-tree--dense +// no-connectors (design 2026-09-24 ตามคำขอผู้ใช้: ธีม Genesis พื้นสว่าง + dense +// บน desktop แต่เป้าแตะ ≥44px บนจอสัมผัส) — ย้ายมาจาก FilterControls ของหน้า +// กลุ่ม (2026-09-24) เพื่อใช้ร่วมกับหน้าสรุป: มิติแรก (ไฟล์/หน้า) ส่งเข้ามาทาง +// prop `firstGroup` ส่วนอีก 3 กลุ่ม (ชนิดรายการ/ชนิดของ Annotation/ผู้บันทึก) +// เหมือนกันทุกหน้า — parent =
หุบ/กางอิสระ (chevron หมุน 90°), +// leaf = + ); +} + +export function FacetFilterPanel({ + state, + onChange, + firstGroup, + users, + counts, +}: { + state: FacetFilterState; + onChange: (next: FacetFilterState) => void; + firstGroup: FirstDimensionGroup; + users: UserOption[]; + counts: FacetCounts; +}) { + const toggleKind = (k: "bm" | "ann", on: boolean) => { + const kinds = new Set(state.kinds); + if (on) kinds.add(k); + else kinds.delete(k); + // เลิกเลือก Annotation = ไม่ได้ดู annotation แล้ว → ล้างชนิดที่ติ๊กค้างไว้ + // ให้เริ่มใหม่ตาม default ตอนกลับมาติ๊ก Annotation (คำขอผู้ใช้ 2026-09-24) + const types = !on && k === "ann" ? new Set() : state.types; + onChange({ ...state, kinds, types }); + }; + + const toggleType = (t: AnnotationType, on: boolean) => { + const types = new Set(state.types); + if (on) types.add(t); + else types.delete(t); + // ติ๊กชนิดของ Annotation = ตั้งใจดู annotation → เลือก Annotation ให้อัตโนมัติ + // (คำขอผู้ใช้ 2026-09-24 — ก่อนหน้านี้ bookmark ยังปนอยู่ใน list) — ไม่ถอด + // อัตโนมัติตอนเลิกติ๊ก เพราะผู้ใช้อาจตั้งใจอยู่มุมมอง annotation แล้ว + const kinds = new Set(state.kinds); + if (on) kinds.add("ann"); + onChange({ ...state, kinds, types }); + }; + + const firstOptions = firstGroup.options.map((o) => ( + } + label={o.label} + count={o.count} + onClick={() => onChange({ ...state, first: o.key })} + /> + )); + + return ( +
+ {/* มิติแรก (ไฟล์/หน้า) — ตัวเลือก + count ส่งมาจากหน้าผ่าน firstGroup; + optionsScroll ครอบด้วยกล่อง scroll เฉพาะกรณีตัวเลือกเยอะ */} + + } + label={firstGroup.allLabel} + count={counts.total} + onClick={() => onChange({ ...state, first: null })} + /> + {firstGroup.optionsScroll ? ( +
+ {firstOptions} +
+ ) : ( + firstOptions + )} +
+ + {/* ไม่เลือก = แสดงทั้งหมด (default) — ติ๊กอะไรเห็นเฉพาะสิ่งนั้น */} + } + label="ชนิดรายการ" + > + + } + label="Bookmark" + count={counts.bm} + onClick={() => toggleKind("bm", !state.kinds.has("bm"))} + /> + } + label="Annotation" + count={counts.ann} + onClick={() => toggleKind("ann", !state.kinds.has("ann"))} + /> + + + {/* กติกาเดียวกับชนิดรายการ: ไม่เลือก = ทุกชนิด (default) ติ๊กอะไรเห็นเฉพาะ + ชนิดนั้น + แสดงเฉพาะชนิดที่มี ≥1 รายการ (กดแล้วได้ list ว่าง = ไม่เสนอ) — + **ไม่ได้ติ๊ก Annotation = กลุ่มนี้ disabled + หุบ** (คำขอผู้ใช้ 2026-09-24: + กรองชนิดมีความหมายเฉพาะเมื่อกำลังดู annotation — ติ๊ก Annotation แล้ว + กลุ่มกลับมาใช้ได้ + กางให้อัตโนมัติ) */} + {counts.ann > 0 && ( + } + label="ชนิดของ Annotation" + open={state.kinds.has("ann")} + disabled={!state.kinds.has("ann")} + > + {TYPE_ORDER.filter((t) => (counts.type[t] ?? 0) > 0).map((t) => ( + + } + label={TYPE_SHORT[t]} + count={counts.type[t] ?? 0} + onClick={() => toggleType(t, !state.types.has(t))} + /> + ))} + + )} + + } + label="ผู้บันทึก" + > + {/* ไอคอนคนซ้ำ N แถวเป็น noise — spacer คง indentation แทน (pattern เดียวกับ + leaf ของต้นไม้ bookmark ใน sidebar) */} + } + label="ทุกคน" + count={counts.total} + onClick={() => onChange({ ...state, userId: null })} + /> + {users.map((u) => ( + } + label={u.label} + count={counts.user[u.userId] ?? 0} + onClick={() => onChange({ ...state, userId: u.userId })} + /> + ))} + +
+ ); +} diff --git a/components/groups/GroupExplorer.tsx b/components/groups/GroupExplorer.tsx new file mode 100644 index 0000000..c6b53cb --- /dev/null +++ b/components/groups/GroupExplorer.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { ChevronDown, FileImage, FileText, Folder } from "lucide-react"; + +import { + FacetFilterPanel, + createDefaultFacets, + type ExplorerItem, + type FacetFilterState, +} from "@/components/explorer/FacetFilterPanel"; +import type { AnnotationType } from "@/db/schema"; + +import { GroupList } from "./GroupList"; + +// N9 หน้ากลุ่ม (redesign 2026-09-24 ตามคำขอผู้ใช้): explorer 2 คอลัมน์ — ซ้ายเป็น +// ตัวกรอง (ไฟล์/ชนิดรายการ/ชนิด annotation/ผู้บันทึก) ขวาเป็น list เดียวรวม +// bookmark+annotation ทั้งกลุ่มไม่แยกไฟล์ — ข้อมูลทั้งหมด query ฝั่ง server ส่งมา +// ครั้งเดียวแล้วกรองใน memory (read-only snapshot เหมือนเดิม ไม่มี SSE) +// แผงตัวกรองเป็น component กลาง `components/explorer/FacetFilterPanel` (2026-09-24 +// — เดิม FilterControls เป็นของหน้านี้) ใช้ร่วมกับหน้าสรุปที่มิติแรกเป็น "หน้า" + +/** ไฟล์ในกลุ่มแบบย่อ — เอาเฉพาะ field ที่ filter/list ใช้ (ไม่ส่ง row เต็มข้าม wire) */ +export type GroupFileSummary = { + id: string; + title: string; + isImage: boolean; +}; + +/** รายการเดียวรวมทั้งกลุ่ม — server เรียงตามลำดับอ่านเอกสารให้แล้ว (ไฟล์ตาม + * createdAt → หน้า → หน้าเดียวกัน bookmark ก่อน annotation → createdAt) */ +export type GroupListItem = ExplorerItem; + +export function GroupExplorer({ + files, + items, +}: { + files: GroupFileSummary[]; + items: GroupListItem[]; +}) { + const [filters, setFilters] = useState(createDefaultFacets); + + // facet count เป็น static จากข้อมูลทั้งกลุ่ม (ไม่หดตามตัวกรองอื่น — กันเข้าใจ + // ผิดว่าตัวเลือกหายไปตอนไล่กรอง) + const counts = useMemo(() => { + let bm = 0; + let ann = 0; + const file: Record = {}; + const type: Partial> = {}; + const user: Record = {}; + for (const it of items) { + file[it.dto.fileId] = (file[it.dto.fileId] ?? 0) + 1; + user[it.dto.userId] = (user[it.dto.userId] ?? 0) + 1; + if (it.kind === "bm") bm++; + else { + ann++; + type[it.dto.type] = (type[it.dto.type] ?? 0) + 1; + } + } + return { total: items.length, bm, ann, file, type, user }; + }, [items]); + + // รายชื่อผู้บันทึกที่มีรายการจริง เรียงชื่อไทย (คนละไฟล์รวมเป็นคนเดียว) + const users = useMemo(() => { + const labelById = new Map(); + for (const it of items) { + if (!labelById.has(it.dto.userId)) { + labelById.set(it.dto.userId, it.dto.userName || "ไม่ทราบชื่อ"); + } + } + const out: { userId: string; label: string }[] = []; + labelById.forEach((label, userId) => out.push({ userId, label })); + return out.sort((a, b) => a.label.localeCompare(b.label, "th")); + }, [items]); + + // มิติแรกของหน้ากลุ่ม = ไฟล์ (count รายไฟล์ฝังใน options ของ firstGroup) + const firstGroup = { + label: "ไฟล์", + parentIcon: , + allLabel: "ทุกไฟล์", + options: files.map((f) => ({ + key: f.id, + label: f.title, + count: counts.file[f.id] ?? 0, + icon: f.isImage ? ( + + ) : ( + + ), + })), + }; + + const filtered = useMemo( + () => + items.filter( + (it) => + (!filters.first || it.dto.fileId === filters.first) && + (filters.kinds.size === 0 || filters.kinds.has(it.kind)) && + (it.kind === "ann" + ? filters.types.size === 0 || filters.types.has(it.dto.type) + : true) && + (!filters.userId || it.dto.userId === filters.userId), + ), + [items, filters], + ); + + const selectedFile = filters.first + ? files.find((f) => f.id === filters.first) ?? null + : null; + + // จำนวนตัวกรองที่ ≠ default — โชว์ที่ summary มือถือ (กัน "กรองเก่ากรองเงียบ") + // และเป็นเงื่อนไขปุ่ม "ล้างตัวกรอง" + const activeCount = + (filters.first !== null ? 1 : 0) + + (filters.kinds.size > 0 ? 1 : 0) + + (filters.types.size > 0 ? 1 : 0) + + (filters.userId !== null ? 1 : 0); + + const resetFilters = () => setFilters(createDefaultFacets()); + + return ( +
+ {/* มือถือ (< lg): ตัวกรองหุบ/กางเป็น
แบบเดียวกับหน้าสรุป — + จอ lg ถูก display:none เอาออกจาก grid — ใช้ named group `group/filters` + ไม่ใช่ `group` เปล่า: `group-open:` ของ parent tree ข้างใน match ทุก + ancestor ที่มี .group[open] ใช้ชื่อซ้ำแล้ว chevron ของทุกกลุ่มหมุนตลอด + ตอนกางแผงนี้ (responsive review 2026-09-24) */} +
+ + ตัวกรอง{activeCount > 0 ? ` (${activeCount})` : ""} + + + {/* cap 70dvh + scroll ภายใน: กลุ่ม "ไฟล์"/"ผู้บันทึก" เยอะ ๆ ต้องไม่ดัน + "พบ N รายการ"/list ลงไกลจนมองไม่เห็น (responsive review 2026-09-24) */} +
+ +
+
+ + {/* desktop: แผงตัวกรอง sticky — หน้าไม่มี navbar จึง top-6 พอ ไฟล์เยอะ scroll ภายในแผงเอง + (p-3 กระชับตามความ dense ของ tree — design 2026-09-24) — h2 "ตัวกรอง (N)" + ให้ parity กับ summary มือถือ, scroll-pt/pb กัน focus ring โดนขอบ scroll + ตัด, max-h เป็น dvh ตาม pattern h-dvh ของ viewer (responsive review 2026-09-24) */} + + +
+ +
+
+ ); +} diff --git a/components/groups/GroupList.tsx b/components/groups/GroupList.tsx new file mode 100644 index 0000000..e4f134d --- /dev/null +++ b/components/groups/GroupList.tsx @@ -0,0 +1,293 @@ +"use client"; + +import Link from "next/link"; +import { useMemo } from "react"; +import { + Bookmark, + ClipboardList, + FileImage, + FileText, + ListFilter, + StickyNote, +} from "lucide-react"; + +import { AnnotationTypeIcon } from "@/components/AnnotationTypeIcon"; +import { + annotationNote, + annotationPreviewText, + annotationTypeLabel, +} from "@/utils/annotation-display"; +import { formatThaiDate } from "@/utils/date-format"; + +import type { GroupFileSummary, GroupListItem } from "./GroupExplorer"; + +// คอลัมน์ content ของหน้ากลุ่ม (design 2026-09-24) — list เดียวรวมทั้งกลุ่ม +// ไม่แยกไฟล์ (ตัวกรองฝั่งซ้ายทำหน้าที่แยกให้แล้ว) แต่ละแถวบอกไฟล์ที่มาที่ +// ต้น meta line — แถวเป็น passive surface จุดคลิกเดียวคือปุ่ม "View" ท้ายแถว +// (deep link ?ann= / ?page= เดียวกับหน้าสรุป — คำขอผู้ใช้ 2026-09-24 ไม่กดทั้งแถว +// ทำให้พ้นข้อจำกัด interactive ซ้อน
เดิม และกฎ coarse ของ listing-page ที่ +// display:inline-flex ชนะ utility flex ก็ไม่กระทบแถวอีกต่อไป — เหลือแค่ปุ่ม) + +/** จุดคั่น "·" ของ meta line */ +function Sep() { + return ·; +} + +/** meta line: ไฟล์ที่มา (truncate เฉพาะ span ของตัวเอง — ไม่งั้นชื่อยาวกินบรรทัด + * จนตัด หน้า/ผู้บันทึก/วันที่ ทิ้ง) → หน้า → บริบทกลาง → ผู้บันทึก → วันที่ + * flex-wrap ให้วันที่เต็มรูปแบบไทยตกบรรทัดใหม่บนมือถืออย่างสง่า — บริบทกลาง + * ("ใน: …" ของ sub bookmark — ชื่อ parent มาจากข้อความที่คลุม ยาวไม่มีขอบเขต) + * กับ "โดย …" ต้อง min-w-0+truncate ด้วย: whitespace-nowrap เปล่า ๆ หดไม่ได้ + * ล้นการ์ดแล้วโดน overflow-hidden ตัดดื้อ ๆ ไม่มี ellipsis (review 2026-09-24) */ +function RowMeta({ + file, + pageIndex, + middle, + user, + date, +}: { + file: GroupFileSummary; + pageIndex: number; + /** ชื่อชนิดของ annotation หรือ "ใน: …" ของ sub bookmark */ + middle?: string; + user: string; + date: string; +}) { + return ( + + + {file.isImage ? ( + + ) : ( + + )} + + {file.title} + + + + หน้า {pageIndex + 1} + {middle && ( + <> + + + {middle} + + + )} + + โดย {user} + + {/* formatThaiDate แปลงตาม timezone เครื่องที่ render — SSR/server กับ + hydration/ผู้ใช้อาจต่างกัน ใส่ suppressHydrationWarning ตาม convention + (เดียวกับ AnnotationSidebar/PDFViewer) */} + + {date} + + + ); +} + +/** โน้ตคำอธิบาย (N1) / โน้ตย่อยของ bookmark (N2) — 2 บรรทัดพอ */ +function RowNote({ text }: { text: string }) { + return ( + + + {text} + + ); +} + +/** ปุ่ม secondary "เปิดเอกสารนี้" — ทางเข้าไฟล์ที่ยังไม่มีรายการใด ๆ + * (หน้าเดิมมีปุ่ม Annotate ทุกการ์ด — เก็บไว้ตามคำตัดสินใจผู้ใช้ 2026-09-24) */ +function OpenFileButton({ file }: { file: GroupFileSummary }) { + return ( + + {file.isImage ? ( + + ) : ( + + )} + เปิดเอกสารนี้ + + ); +} + +export function GroupList({ + items, + files, + totalCount, + selectedFile, + selectedFileTotal, + activeCount, + onReset, +}: { + /** ผ่านตัวกรองแล้ว (เรียงลำดับจาก server คงเดิม) */ + items: GroupListItem[]; + files: GroupFileSummary[]; + totalCount: number; + selectedFile: GroupFileSummary | null; + /** จำนวนรายการทั้งหมดของไฟล์ที่เลือก (ก่อนกรอง) — null = ไม่ได้เลือกไฟล์เดี่ยว */ + selectedFileTotal: number | null; + activeCount: number; + onReset: () => void; +}) { + const fileById = useMemo( + () => new Map(files.map((f) => [f.id, f] as const)), + [files], + ); + + return ( + <> + {/* จำนวนผลลัพธ์อยู่ต้นคอลัมน์ content (ที่เห็นผลของตัวกรอง) ไม่ใช่ในแผง */} +
+

+ พบ{" "} + + {items.length} + {" "} + รายการ +

+
+ {selectedFile && } + {activeCount > 0 && ( + + )} +
+
+ + {items.length > 0 ? ( +
+
    + {items.map((item) => { + // items สร้างจาก files ชุดเดียวกันตอน build ที่ server — หาได้เสมอ + const file = fileById.get(item.dto.fileId)!; + // ปลายทาง + คำบอกตำแหน่งต่อชนิดรายการ (title tooltip + aria-label + // ของปุ่ม View — แถวเดียวกันหลายสิบปุ่มชื่อเหมือนกัน ต้องรู้จักแยก) + const isBm = item.kind === "bm"; + const where = isBm + ? `ไปที่หน้า ${item.pageIndex + 1}` + : `ดู${annotationTypeLabel(item.dto)}ที่หน้า ${item.pageIndex + 1}`; + const href = isBm + ? `/note/annotate/${item.dto.fileId}?page=${item.pageIndex + 1}` + : `/note/annotate/${item.dto.fileId}?ann=${item.dto.id}`; + return ( + // แถวเป็น passive surface — จุดคลิกเดียวคือปุ่ม "View" ท้ายแถว + // (คำขอผู้ใช้ 2026-09-24: ไม่ต้องกดทั้งแถว) จึงไม่มี hover bg ที่แถว +
  • + {isBm ? ( + <> + + + + {item.dto.title} + + + {item.dto.note && } + + + ) : ( + <> + + + + {annotationPreviewText(item.dto)} + {item.dto.type === "COMMENT" && item.dto.resolved && ( + + จัดการแล้ว + + )} + + + {annotationNote(item.dto) && ( + + )} + + + )} + + View + +
  • + ); + })} +
+
+ ) : totalCount === 0 ? ( +
+ +

+ ยังไม่มี Annotation หรือ Bookmark ในกลุ่มนี้ +

+

+ เปิดเอกสารในกลุ่มเพื่อมาร์กข้อความ วาดเส้น ใส่คอมเมนต์ หรือคั่นหน้า +

+
+ ) : selectedFile && selectedFileTotal === 0 ? ( +
+ +

+ ไฟล์นี้ยังไม่มี Annotation หรือ Bookmark +

+
+ +
+
+ ) : ( +
+ +

+ ไม่มีรายการตรงตามตัวกรอง +

+ +
+ )} + + ); +} diff --git a/components/pdf/AnnotationSelectionLayer.tsx b/components/pdf/AnnotationSelectionLayer.tsx index 0d4e9c4..b61cf41 100644 --- a/components/pdf/AnnotationSelectionLayer.tsx +++ b/components/pdf/AnnotationSelectionLayer.tsx @@ -6,25 +6,84 @@ import { useState, type PointerEvent as ReactPointerEvent, } from "react"; -import { AlignCenter, AlignLeft, AlignRight, BookmarkPlus, Pencil, Trash2 } from "lucide-react"; +import { + AlignCenter, + AlignLeft, + AlignRight, + BookmarkPlus, + Pencil, + RotateCw, + Trash2, +} from "lucide-react"; import type { AnnotationData, AnnotationDTO, + CommentData, HighlightData, HighlightStyle, TextboxData, + TextboxSegment, } from "@/db/schema"; import { + COMMENT_PIN_COARSE_SIZE_PX, + COMMENT_PIN_SIZE_PX, TEXTBOX_MAX_WIDTH_PCT, annotationBBox, + clampStampIntoPage, + commentPinBoxPct, + commentPinBoxPx, + rotatePoint, scaleAnnotationData, + stampRect, translateAnnotationData, } from "@/utils/geometry"; -import { clamp, getPointerPercent, type Point, type Rect, type Size } from "@/utils/coordinates"; -import { STAMP_COLOR } from "@/utils/annotation-display"; +import { + clamp, + getPointerPercent, + type Point, + type Rect, + type Size, +} from "@/utils/coordinates"; +import { + annotationTypeLabel, + measureStampText, + STAMP_COLOR, +} from "@/utils/annotation-display"; import { ColorSwatches, HIGHLIGHT_STYLE_BTNS, NumberCombo, PanelRow } from "./ToolControls"; +/** Patch ของตัวควบคุมเล็กในแผง selection / editor ข้อความ — ส่งเฉพาะ field ที่แตะ + * ให้ viewer ผสานเข้ากับ data ล่าสุด**ตอนถึงคิว update** ไม่ใช่ snapshot ของ render + * นี้ — คอมมิตแบบ full object จาก base เก่าจะทับค่าที่เพิ่งเปลี่ยน (จอสัมผัส: + * blur-commit ข้อความใน editor แล้วแตะสี/ขนาดในแผงต่อทันทีก่อน React render + * optimistic row — code review 2026-09-28 รอบสอง) */ +export type AnnotationPatch = + /** สี — ชื่อ field ตามชนิด: HIGHLIGHT/STAMP = `color`, DRAW/SHAPE = `strokeColor` */ + | { kind: "color"; color: string } + /** ความหนา — DRAW สเกล `widths` ต่อจุดตามอัตราส่วนตอนผสาน (เหมือนเดิม) */ + | { kind: "strokeWidth"; strokeWidth: number } + | { kind: "highlightStyle"; style: HighlightStyle } + /** ความทึบของ HIGHLIGHT — 100 = ถอด field `opacity` ทิ้งเหมือนตอนสร้างใหม่ */ + | { kind: "highlightOpacity"; opacityPct: number } + | { kind: "stampScale"; scale: number } + /** STAMP — มุมเอียง (องศา −180..180 หมุนรอบกลางกรอบ; 0 = ถอด field เหมือนแถวเดิม) */ + | { kind: "stampRotation"; rotation: number } + | { + kind: "textbox"; + fields: Partial< + Pick + >; + } + /** StampEditor — แก้คำตรายาง */ + | { kind: "label"; label: string } + /** TextboxEditor — แก้ข้อความกล่องข้อความ (segments null = เคลียร์ตัวหนาทั้งก้อน) */ + | { kind: "textboxText"; text: string; segments: TextboxSegment[] | null }; + +/** UX audit 2026-09-16: focus ring คีย์บอร์ด — ค่าเดียวกับ FOCUS_RING_CLS ของ + * PDFViewer/AnnotationSidebar (ประกาศแยกที่นี่กัน circular import) */ +const FOCUS_RING_CLS = + "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"; + // กรอบ selection + handles ของ annotation ที่เลือก (PLAN-FEAT.md §3 F1) // - container เป็น z-4 เหมือน comment pins และ pointer-events: none **เสมอ** // เฉพาะ handle มุม (scale) / ขอบกรอบ (translate) / แถบเครื่องมือที่ auto @@ -34,20 +93,29 @@ import { ColorSwatches, HIGHLIGHT_STYLE_BTNS, NumberCombo, PanelRow } from "./To const HANDLE_PX = 10; // ขนาดมุมจับ (scale) const STRIP_PX = 8; // ความหนาขอบกรอบ (translate) +const ROT_HANDLE_PX = 18; // จุดจับหมุนตรายาง (STAMP) — วงกลมเหนือกึ่งกลางขอบบน +const ROT_HANDLE_GAP_PX = 20; // ระยะแนวฉากจากขอบบนกรอบถึงจุดจับหมุน const MIN_BOX_PCT = 0.5; // bbox เล็กสุด (% ของหน้า) กันกรอบหาย const EPS = 1e-6; +// COMMENT: ขนาด pin px + bbox px/% ของ pin ย้ายไป utils/geometry.ts แล้ว (ใช้ร่วมกับ +// goToAnnotation ของ PDFViewer — single source ตาม responsive audit 2026-09-24) + type DragState = { // wleft/wright = TEXTBOX กว้างคงที่: ลากขอบซ้าย/ขวาปรับความกว้าง (ฟอนต์คงขนาด) - mode: "move" | "scale" | "wleft" | "wright"; + mode: "move" | "scale" | "wleft" | "wright" | "rotate"; /** ตำแหน่ง pointer ตอนเริ่มลาก (% ของหน้า) */ startPct: Point; /** data ก่อนเริ่มลาก — ต้นทางของการคำนวณทุก move (ไม่สะสมจาก preview) */ origData: AnnotationData; - /** bbox ก่อนเริ่มลาก (% ของหน้า) */ + /** bbox ก่อนเริ่มลาก (% ของหน้า) — STAMP เอียง = AABB ของกรอบหมุนแล้ว (ครอบกรอบจริง) */ origBoxPct: Rect; - /** mode "scale" เท่านั้น — มุมตรงข้ามของ handle (% ของหน้า) */ + /** mode "scale" เท่านั้น — มุมตรงข้ามของ handle (% ของหน้า ตำแหน่งจริงบนจอ) */ anchorPct: Point; + /** STAMP เอียงเท่านั้น — มุมกรอบตอนเริ่มลาก (องศา): คำนวณ k ของ scale ต้อง + * แปลง pointer เข้า frame ของกรอบ (ย้อนหมุนรอบ center) ก่อน ไม่งั้นลากมุมของ + * กรอบเอียงแล้วสัดส่วนแกน x/y ผิดจากสายตา */ + rot?: number; }; /** จำกัดตัวคูณ scale แกนเดียว ให้กรอบผลลัพธ์ยังอยู่ใน 0–100 และไม่เล็กกว่าขั้นต่ำ */ @@ -84,6 +152,7 @@ export default function AnnotationSelectionLayer({ onDelete, onBookmark, onStartEdit, + onPatch, }: { annotation: AnnotationDTO; /** ขนาด px ของหน้า (จาก width/height ที่คำนวณจาก scale) */ @@ -105,14 +174,87 @@ export default function AnnotationSelectionLayer({ /** M1: ปุ่ม "แก้ไขข้อความ" ของ TEXTBOX — เข้าโหมดแก้ไขโดยไม่ต้อง double-click * (จอสัมผัสไม่มี dblclick ที่เชื่อถือได้: iOS double-tap = zoom หน้า) */ onStartEdit?: (id: string) => void; + /** ตัวควบคุมเล็กของแผง (สี/ความหนา/ขนาด/รูปแบบ/ทึบ/patch กล่องข้อความ) commit เป็น + * patch — viewer ผสานกับ data ล่าสุดตอนถึงคิว update กัน stale base ทับค่าที่เพิ่ง + * เปลี่ยน (ดู AnnotationPatch) */ + onPatch: (id: string, patch: AnnotationPatch) => void; }) { const rootRef = useRef(null); const dragRef = useRef(null); const movedRef = useRef(false); const latestRef = useRef(null); + // rAF ของ preview ระหว่างลาก — ยิง onPreview ≤1 ครั้ง/เฟรม (pattern เดียวกับ FIX-8 + // ของ InkLayer): pointermove ยิงถี่กว่าเฟรมได้ ทุก event ยังคำนวณ+เก็บค่าล่าสุดใน + // latestRef เสมอ (ค่าตอน commit ต้องล่าสุดสุด) เฉพาะการ render ที่จัดคิวตามเฟรม + const previewRafRef = useRef(null); - const boxPx = annotationBBox(annotation, size); - const boxPct = annotationBBox(annotation, { width: 100, height: 100 }); + /** ยกเลิก rAF preview ที่ค้าง — เรียกก่อนเคลียร์ preview ทุกทางออกของ drag (ไม่งั้น + * callback ยิงตามหลัง ดัน preview เก่ากลับมาตอน dragRef เคลียร์ไปแล้ว) */ + function cancelPreviewRaf() { + if (previewRafRef.current === null) return; + cancelAnimationFrame(previewRafRef.current); + previewRafRef.current = null; + } + + // เลิกลากกลางคันแล้ว unmount — ยกเลิก rAF ที่ค้างอยู่ด้วย (เหมือน InkLayer) + useEffect( + () => () => { + cancelPreviewRaf(); + }, + [], + ); + + // อุปกรณ์จอสัมผัส (pointer หลัก coarse) — คำนวณครั้งเดียวตอน mount เหมือน + // coarseTouch ของ PDFViewer: .coarse-36 ขยายปุ่ม pin เป็น 36px บนพวกนี้ + const [coarseTouch] = useState( + () => + typeof window !== "undefined" && + window.matchMedia("(pointer: coarse)").matches, + ); + + // COMMENT ใช้กล่องครอบ pin จริง (px คงที่ตามอุปกรณ์) — ชนิดอื่นคือ bbox ตามขนาดหน้า + // boxPct ก็ใช้มาตราฐานเดียวกัน (% จาก px จริง): move-clamp กับปุ่ม bookmark ของ pin + // ต้องเห็น pin ตามที่ render ไม่ใช่ COMMENT_PIN_BOX_PCT ประมาณ (audit 2026-09-24) + const isCommentPin = annotation.type === "COMMENT"; + const pinPx = coarseTouch ? COMMENT_PIN_COARSE_SIZE_PX : COMMENT_PIN_SIZE_PX; + // bbox ของ STAMP ส่ง measureStampText ให้ความสูงตรงกับที่ StampItem render จริง + // (ประมาณแล้วขอบล่างกรอบเกินตรายาง — bug 2026-09-28) + const boxPx = isCommentPin + ? commentPinBoxPx(annotation.data as CommentData, size, pinPx) + : annotationBBox(annotation, size, measureStampText); + const boxPct = isCommentPin + ? commentPinBoxPct(annotation.data as CommentData, size, pinPx) + : annotationBBox(annotation, { width: 100, height: 100 }, measureStampText); + + // STAMP เอียง (rotation ≠ 0) — กรอบ+handles วาดที่ตำแหน่งกรอบ*ก่อนหมุน* แล้วหมุน + // ทั้งชุดด้วย CSS transform รอบกลางกรอบ (ตรงกับที่ StampItem render) ส่วน boxPx/ + // boxPct ด้านบนเป็น AABB ของกรอบหมุนแล้ว ใช้กับ clamp ย้าย/แผงเครื่องมือ/ป้ายชื่อ + // stampData narrow ด้วย "label" in ตรงนี้แล้วส่งเข้า stampRect ตรง ๆ — ไม่ as-cast + // (cast พังเงียบถ้าเงื่อนไขแวดข้างเปลี่ยน — code review 2026-09-29) + const stampData = "label" in annotation.data ? annotation.data : null; + const stampRot = stampData?.rotation ?? 0; + const isRotStamp = stampRot !== 0; + const framePx = + stampData && isRotStamp ? stampRect(stampData, size, measureStampText) : boxPx; + const framePct = + stampData && isRotStamp + ? stampRect(stampData, { width: 100, height: 100 }, measureStampText) + : boxPct; + // transform หมุนทั้งชุดกรอบ — origin เป็น**กลางกรอบในพิกัดของ element นั้น ๆ** (CSS + // transform-origin วัดจากมุมซ้ายบนของ element เอง ไม่ใช่ของ page — จุดกลางกรอบ + // ในพิกัด page ลบด้วย left/top ของ element จึงได้ origin ที่ทุก div หมุนไปตรงจุด + // เดียวกัน ตามตัวตรายาง — bug "กรอบไม่ตรงตัวเมื่อเอียง" 2026-09-28) + const rotC = { + x: framePx.x + framePx.width / 2, + y: framePx.y + framePx.height / 2, + }; + const rotOn = (elLeft: number, elTop: number) => + isRotStamp + ? { + transform: `rotate(${stampRot}deg)`, + transformOrigin: `${rotC.x - elLeft}px ${rotC.y - elTop}px`, + } + : undefined; // FIX-9: เอา non-null ! ออก — handler ยิงได้แค่ตอน DOM mount แล้ว // ref จึงไม่ควรเป็น null (คืน 0,0 เผื่อไว้เฉย ๆ ให้ type ชัด) @@ -124,7 +266,7 @@ export default function AnnotationSelectionLayer({ function beginDrag( e: ReactPointerEvent, - mode: "move" | "scale" | "wleft" | "wright", + mode: "move" | "scale" | "wleft" | "wright" | "rotate", anchorPct: Point, ) { if (!editable || e.button !== 0) return; @@ -137,6 +279,7 @@ export default function AnnotationSelectionLayer({ origData: annotation.data, origBoxPct: boxPct, anchorPct, + ...(stampRot !== 0 ? { rot: stampRot } : null), }; movedRef.current = false; latestRef.current = null; @@ -148,7 +291,30 @@ export default function AnnotationSelectionLayer({ e.stopPropagation(); const cur = pointerPct(e); let next: AnnotationData; - if (st.mode === "move") { + if (st.mode === "rotate") { + // STAMP — ลากหมุน: มุมใหม่ = มุมเดิม + มุมที่ pointer กวาดรอบ**กลางกรอบ** + // (atan2 ในพิกัดจอ y ลง = ทิศเดียวกับ CSS rotate) — snap ดูดเข้าทวีคูณ 15° + // เมื่อใกล้ ≤4° ให้ตั้ง 0/±45/±90 เร็ว แล้ว wrap เข้า (−180, 180] เก็บ 1 ทศนิยม + // (0° ถอด field เหมือน patch ของแผง — anchorPct = กลางกรอบจาก AABB) + const ctr = st.anchorPct; + const a0 = Math.atan2(st.startPct.y - ctr.y, st.startPct.x - ctr.x); + const a1 = Math.atan2(cur.y - ctr.y, cur.x - ctr.x); + let deg = (st.rot ?? 0) + ((a1 - a0) * 180) / Math.PI; + const snap = Math.round(deg / 15) * 15; + if (Math.abs(deg - snap) <= 4) deg = snap; + deg = Number(((((deg + 180) % 360) + 360) % 360 - 180).toFixed(1)); + if ("label" in st.origData) { + const { rotation: _prev, ...rest } = st.origData; + // หมุนจนกรอบยื่นเกินขอบหน้า = เลื่อนกลับเข้าหน้าทันทีระหว่างลาก (เดียวกับ + // patch ของแผง — validator ตรวจ AABB ไม่ติดลบ, code review 2026-09-28) + next = + deg === 0 + ? rest + : { ...rest, rotation: deg, position: clampStampIntoPage({ ...rest, rotation: deg }) }; + } else { + next = st.origData; + } + } else if (st.mode === "move") { // clamp offset ให้ bbox ทั้งกลุ่มยังอยู่ในหน้า (พิกัด % ติดลบโดน action ปฏิเสธ) const dx = clamp( cur.x - st.startPct.x, @@ -192,12 +358,29 @@ export default function AnnotationSelectionLayer({ next = { ...d, width: w, position: { ...d.position, x: left } }; } } else if ("scale" in st.origData) { - // STAMP — สัดส่วนกว้าง:สูงตายตัว (สูง = STAMP_HEIGHT_RATIO × กว้าง) แยกสเกล - // สองแกนไม่ได้ จึงใช้ตัวคูณ uniform เดียว: เฉลี่ยอัตราส่วนของ pointer สองแกน + // STAMP — ความกว้างมีมาจาก scale เพียงตัวเดียว (สูงตามจำนวนบรรทัดของ label, + // stampLayout) จึงขยายด้วยตัวคูณ uniform เดียว: เฉลี่ยอัตราส่วนของ pointer สองแกน // แล้ว clamp รายแกนซ้อนกัน (x แล้ว y) ให้กรอบผลลัพธ์ยังอยู่ในหน้าทั้งสองแกนพร้อมกัน + // · STAMP เอียง: อัตราส่วนต้องวัดในแกนของ*กรอบ* — ย้อนหมุน pointer รอบกลางกรอบ + // ก่อน (แกน x/y ของสูตรคือแกนกรอบ ไม่ใช่แกนจอ) ส่วน clamp กับ scaleAnnotationData + // ใช้พิกัดจอ: มุมกรอบหมุนทั้งชุดโดย scale-uniform รอบ anchor จริง ๆ (AABB ของ + // กรอบหมุน scale ตาม k รอบ anchor เช่นกัน) clamp ด้วยขอบ AABB จึงยังถูกต้อง + const rot = st.rot ?? 0; + let curL = cur; + let startL = st.startPct; + let anchorL = st.anchorPct; + if (rot) { + const ctr = { + x: st.origBoxPct.x + st.origBoxPct.width / 2, + y: st.origBoxPct.y + st.origBoxPct.height / 2, + }; + curL = rotatePoint(cur, ctr, -rot); + startL = rotatePoint(st.startPct, ctr, -rot); + anchorL = rotatePoint(st.anchorPct, ctr, -rot); + } const rawK = - ((cur.x - st.anchorPct.x) / (st.startPct.x - st.anchorPct.x || EPS) + - (cur.y - st.anchorPct.y) / (st.startPct.y - st.anchorPct.y || EPS)) / + ((curL.x - anchorL.x) / (startL.x - anchorL.x || EPS) + + (curL.y - anchorL.y) / (startL.y - anchorL.y || EPS)) / 2; const k = clampAxisScale( clampAxisScale( @@ -232,7 +415,14 @@ export default function AnnotationSelectionLayer({ } movedRef.current = true; latestRef.current = next; - onPreview(next); + // render preview ครั้งเดียวต่อ animation frame — callback อ่าน latestRef ตอนเฟรม + // ยิงจริง ๆ เพื่อให้ได้ค่าใหม่สุดเสมอแม้ event ถี่ (แก้ลากไม่ smooth 2026-09-29) + if (previewRafRef.current === null) { + previewRafRef.current = requestAnimationFrame(() => { + previewRafRef.current = null; + if (dragRef.current && latestRef.current) onPreview(latestRef.current); + }); + } } function endDrag(e: ReactPointerEvent) { @@ -240,6 +430,7 @@ export default function AnnotationSelectionLayer({ if (!st) return; e.stopPropagation(); dragRef.current = null; + cancelPreviewRaf(); onPreview(null); if (movedRef.current && latestRef.current) onCommit(annotation.id, latestRef.current); movedRef.current = false; @@ -249,59 +440,40 @@ export default function AnnotationSelectionLayer({ function cancelDrag() { if (!dragRef.current) return; dragRef.current = null; + cancelPreviewRaf(); movedRef.current = false; latestRef.current = null; onPreview(null); } - /** ปุ่มสี/ความหนาบนกรอบ — ใช้ onCommit เดียวกับการลาก */ + /** ปุ่มสี/ความหนาบนกรอบ — ส่ง patch ให้ viewer ผสานกับ data ล่าสุด (ชื่อ field ตาม + * ชนิด: HIGHLIGHT/STAMP = color, DRAW/SHAPE = strokeColor — ตัดสินตอนผสาน) */ function commitColor(color: string) { - const d = annotation.data; - if ("rects" in d) onCommit(annotation.id, { ...d, color }); - else if ("paths" in d || "shape" in d) onCommit(annotation.id, { ...d, strokeColor: color }); - // STAMP ("label") — แก้สีหลังสร้างได้ (TEXTBOX แก้ผ่านการพิมพ์ inline แทน) - else if ("label" in d) onCommit(annotation.id, { ...d, color }); + onPatch(annotation.id, { kind: "color", color }); } function commitStrokeWidth(w: number) { - const d = annotation.data; - if ("paths" in d) { - // F7: เส้นปากกามี widths ต่อจุด — scale ตามอัตราส่วนพร้อม strokeWidth หลัก (PLAN-FEAT.md §5) - onCommit(annotation.id, { - ...d, - strokeWidth: w, - ...(d.widths - ? { widths: d.widths.map((pw) => (pw / d.strokeWidth) * w) } - : {}), - }); - } else if ("shape" in d) { - onCommit(annotation.id, { ...d, strokeWidth: w }); - } + onPatch(annotation.id, { kind: "strokeWidth", strokeWidth: w }); } /** HIGHLIGHT — เปลี่ยนรูปแบบมาร์กหลังสร้าง (ไฮไลต์/เส้นใต้/ขีดฆ่า/ขยุกขยิก) */ function commitHighlightStyle(style: HighlightStyle) { - const d = annotation.data; - if (!("rects" in d)) return; - onCommit(annotation.id, { ...d, style }); + onPatch(annotation.id, { kind: "highlightStyle", style }); } - /** HIGHLIGHT — ปรับความทึบหลังสร้าง (100% = ทึบเต็ม = ไม่เก็บ field เหมือนตอนสร้างใหม่) */ + /** HIGHLIGHT — ปรับความทึบหลังสร้าง (100% = ทึบเต็ม = ถอด field opacity ตอนผสาน) */ function commitHighlightOpacity(opacityPct: number) { - const d = annotation.data; - if (!("rects" in d)) return; - const { opacity: _prev, ...rest } = d; - onCommit(annotation.id, { - ...rest, - ...(opacityPct < 100 ? { opacity: opacityPct / 100 } : {}), - }); + onPatch(annotation.id, { kind: "highlightOpacity", opacityPct }); } /** STAMP — ปรับขนาดตรายางหลังสร้าง */ function commitStampScale(scale: number) { - const d = annotation.data; - if (!("label" in d)) return; - onCommit(annotation.id, { ...d, scale }); + onPatch(annotation.id, { kind: "stampScale", scale }); + } + + /** STAMP — ปรับมุมเอียงหลังสร้าง (องศา −180..180 หมุนรอบกลางกรอบ) */ + function commitStampRotation(rotation: number) { + onPatch(annotation.id, { kind: "stampRotation", rotation }); } /** TEXTBOX — แก้ค่าทั้งกล่องหลังสร้าง (สี/ขนาด/ทึบ/หนา/จัดแนว) เหมือน dropdown ของ tool */ @@ -309,7 +481,7 @@ export default function AnnotationSelectionLayer({ patch: Partial>, ) { if (!textboxData) return; - onCommit(annotation.id, { ...textboxData, ...patch }); + onPatch(annotation.id, { kind: "textbox", fields: patch }); } // ตัวควบคุมในแผงตรวจตามชนิดด้วยการเช็ค field ใน data ตรง ๆ (rects = HIGHLIGHT, @@ -320,44 +492,62 @@ export default function AnnotationSelectionLayer({ const highlightData: HighlightData | null = "rects" in annotation.data ? annotation.data : null; const strokeData = - "paths" in annotation.data || "shape" in annotation.data ? annotation.data : null; - const stampData = "label" in annotation.data ? annotation.data : null; + "paths" in annotation.data || "shape" in annotation.data + ? annotation.data + : null; + // (stampData ย้ายไปขึ้นบน — ใช้ร่วมกับ stampRot/framePx ของตรายางเอียง) const textboxData: TextboxData | null = "fontSize" in annotation.data ? annotation.data : null; // TEXTBOX กว้างคงที่ (มี width) — ขอบซ้าย/ขวาเป็น handle ปรับความกว้างแทนการย้าย // และซ่อนมุม (scale ไม่มีความหมายกับกล่องกว้างคงที่) — ย้ายกล่องใช้ขอบบน/ล่าง const isFixedTextbox = textboxData?.width !== undefined; - // มุมทั้ง 4 — ตำแหน่งวางเป็น px, anchor มุมตรงข้ามเป็น % ตรง ๆ จาก boxPct - // (พิกัด % คำนวณผ่าน utils/coordinates เท่านั้น — invariant #2) + // แผงมี row ตัวควบคุมสไตล์หรือเหลือแค่ปุ่ม bookmark+ลบ (COMMENT/REDACTION) — + // กรณีหลังหุบเป็น w-fit ไม่คั่นเส้น และปุ่มติดกันแทน justify-between + const hasStyleControls = !!(highlightData || strokeData || stampData || textboxData); + + // มุมทั้ง 4 — ตำแหน่งวางเป็น px ของกรอบ*ก่อนหมุน* (STAMP เอียง: div ทั้งชุดถูกหมุน + // ทีหลังผ่าน rotStyle), anchor มุมตรงข้ามเป็น % **ตำแหน่งจริงบนจอ** — STAMP เอียง + // ต้องหมุนมุมกลับออกมาก่อน (พิกัด % คำนวณผ่าน utils/coordinates เท่านั้น — invariant #2) + const frameCtrPct = isRotStamp + ? { + x: framePct.x + framePct.width / 2, + y: framePct.y + framePct.height / 2, + } + : null; + const anchorOnScreen = (p: Point): Point => + frameCtrPct ? rotatePoint(p, frameCtrPct, stampRot) : p; const corners: { key: string; x: number; y: number; cursor: string; anchor: Point }[] = [ { key: "nw", - x: boxPx.x, - y: boxPx.y, + x: framePx.x, + y: framePx.y, cursor: "nwse-resize", - anchor: { x: boxPct.x + boxPct.width, y: boxPct.y + boxPct.height }, + anchor: anchorOnScreen({ + x: framePct.x + framePct.width, + y: framePct.y + framePct.height, + }), }, { key: "ne", - x: boxPx.x + boxPx.width, - y: boxPx.y, + x: framePx.x + framePx.width, + y: framePx.y, cursor: "nesw-resize", - anchor: { x: boxPct.x, y: boxPct.y + boxPct.height }, + anchor: anchorOnScreen({ x: framePct.x, y: framePct.y + framePct.height }), }, { key: "sw", - x: boxPx.x, - y: boxPx.y + boxPx.height, + x: framePx.x, + y: framePx.y + framePx.height, cursor: "nesw-resize", - anchor: { x: boxPct.x + boxPct.width, y: boxPct.y }, + anchor: anchorOnScreen({ x: framePct.x + framePct.width, y: framePct.y }), }, { key: "se", - x: boxPx.x + boxPx.width, - y: boxPx.y + boxPx.height, + x: framePx.x + framePx.width, + y: framePx.y + framePx.height, cursor: "nwse-resize", - anchor: { x: boxPct.x, y: boxPct.y }, + anchor: anchorOnScreen({ x: framePct.x, y: framePct.y }), }, ]; @@ -387,14 +577,16 @@ export default function AnnotationSelectionLayer({ style={{ pointerEvents: "none" }} onClick={(e) => e.stopPropagation()} // คลิกบน handle/toolbar ไม่ให้ bubble ไป deselect ที่ page > - {/* กรอบ bbox — ตัวกรอบเอง pointer-events none ผ่านไป textLayer ได้ */} + {/* กรอบ bbox — ตัวกรอบเอง pointer-events none ผ่านไป textLayer ได้ · STAMP เอียง + วาดที่กรอบก่อนหมุนแล้วหมุนทั้งชุด (กรอบ+handles) รอบกลางกรอบเหมือน StampItem */}
@@ -407,11 +599,12 @@ export default function AnnotationSelectionLayer({
beginDrag(e, "move", { x: 0, y: 0 })} onPointerMove={moveDrag} @@ -421,11 +614,12 @@ export default function AnnotationSelectionLayer({
beginDrag(e, "move", { x: 0, y: 0 })} onPointerMove={moveDrag} @@ -435,11 +629,12 @@ export default function AnnotationSelectionLayer({
beginDrag(e, isFixedTextbox ? "wleft" : "move", { x: 0, y: 0 }) @@ -451,11 +646,12 @@ export default function AnnotationSelectionLayer({
beginDrag(e, isFixedTextbox ? "wright" : "move", { x: 0, y: 0 }) @@ -466,8 +662,10 @@ export default function AnnotationSelectionLayer({ /> {/* มุมทั้ง 4 — ลาก = scale รอบ anchor มุมตรงข้าม (TEXTBOX กว้างคงที่ไม่มีมุม — - ปรับความกว้างที่ขอบซ้าย/ขวาแทน ส่วนสูงตามจำนวนบรรทัดอัตโนมัติ) */} - {!isFixedTextbox && corners.map((c) => ( + ปรับความกว้างที่ขอบซ้าย/ขวาแทน ส่วนสูงตามจำนวนบรรทัดอัตโนมัติ; COMMENT ไม่ + มีมุมเช่นกัน — pin ไม่มี field ขนาด scale จึงไร้ความหมาย และ anchor จาก boxPct + (% รอบจุด) กับ handle บนกล่อง pin คนละกล่องทำให้ลากแล้วเหลื่อมจากนิ้ว) */} + {!isFixedTextbox && !isCommentPin && corners.map((c) => (
beginDrag(e, "scale", c.anchor)} onPointerMove={moveDrag} @@ -486,18 +685,69 @@ export default function AnnotationSelectionLayer({ /> ))} + {/* STAMP — จุดจับหมุนเหนือกึ่งกลางขอบบนของกรอบ (หมุนตามกรอบผ่าน rotOn): + ลากวนรอบกลางกรอบ = ปรับมุมเอียงแทนการพิมพ์ในแผงอย่างเดียว (คำขอผู้ใช้ + 2026-09-28) — anchor ของ mode "rotate" = กลางกรอบ (% จาก AABB) */} + {stampData && ( +
+ beginDrag(e, "rotate", { + x: boxPct.x + boxPct.width / 2, + y: boxPct.y + boxPct.height / 2, + }) + } + onPointerMove={moveDrag} + onPointerUp={endDrag} + onPointerCancel={cancelDrag} + > + +
+ )} + )} + {/* Responsive audit 2026-09-24: รายการของผู้อื่นที่เลือกอยู่ — ป้าย "ชนิด · ผู้ทำ" ลอย + เหนือกรอบ (จอสัมผัสไม่มี hover tooltip N1 ให้ดูว่าใครทำ เดิมต้องเปิด sidebar + อย่างเดียว) — read-only pointer-events:none ตามกติกา container ของ layer นี้ + ไม่บัง textLayer; ชิดหัวหน้าเกินก็ห้อยใต้กรอบแทน */} + {!editable && ( +
+ {annotationTypeLabel(annotation)} · {annotation.userName || "ไม่ทราบชื่อ"} +
+ )} + {/* แผงเครื่องมือของรายการที่เลือก — ตัวควบคุมเดียวกับ dropdown ของ tool ผู้สร้าง (สี/ความหนา/ขนาด/รูปแบบตามชนิด) + ปุ่มลบท้ายแผง — แสดงทุกชนิดรวม HIGHLIGHT (ไม่มี handle ย้าย/ขยาย แต่เปลี่ยนสี/รูปแบบ/ลบได้) — เฉพาะรายการของตัวเอง clamp แนวนอน: รายการชิดขวาหน้า (จอแคบ fit-width) ต้องไม่ทำแผง w-60 ล้นขอบขวา - จนปุ่มลบ/ตัวควบคุมหลุดจอ (เจอตอน verify จอ 390px — ปุ่มลบครึ่งเดียวจิ้มได้ยาก) */} + จนปุ่มลบ/ตัวควบคุมหลุดจอ (เจอตอน verify จอ 390px — ปุ่มลบครึ่งเดียวจิ้มได้ยาก) + กรณี w-fit ใช้ 240 เป็น upper bound ต่อ (แคบกว่า w-60 เสมอ — clamp หลวมเกินไม่มีผลร้าย) */} {editable && !disabled && (
{highlightData && ( <> - + @@ -519,10 +769,10 @@ export default function AnnotationSelectionLayer({ aria-label={label} title={label} aria-pressed={(highlightData.style ?? "highlight") === key} - className={`flex h-6 w-6 items-center justify-center rounded-md transition ${ + className={`flex h-6 w-6 items-center justify-center rounded-md transition ${FOCUS_RING_CLS} ${ (highlightData.style ?? "highlight") === key ? "bg-primary text-white" - : "text-slate-500 hover:bg-slate-100" + : "text-ink-soft hover:bg-ink/[0.05]" }`} onClick={() => commitHighlightStyle(key)} > @@ -545,7 +795,7 @@ export default function AnnotationSelectionLayer({ )} {strokeData && ( - + )} @@ -563,7 +813,21 @@ export default function AnnotationSelectionLayer({ )} {stampData && ( <> - + {/* ทางเข้า StampEditor (แก้คำตรายางหลังวาง — เหมือนปุ่มเดียวกันของ TEXTBOX): + แผงโผล่เฉพาะของตัวเองอยู่แล้ว (editable) จึงไม่ต้องเช็ค userId ซ้ำ / + root มี stopPropagation คลิกไม่หลุดไป deselect · sel-panel-btn ขยาย + hit 36px บนจอสัมผัสเหมือนปุ่มอื่นของแผง (UX audit 2026-09-28) */} + + + + + + + )} {textboxData && ( <> {/* M1: ทางเข้า editor นอกจาก double-click — แผงโผล่เฉพาะของตัวเองอยู่แล้ว - (editable) จึงไม่ต้องเช็ค userId ซ้ำ / root มี stopPropagation คลิกไม่หลุดไป deselect */} + (editable) จึงไม่ต้องเช็ค userId ซ้ำ / root มี stopPropagation คลิกไม่หลุดไป deselect + · sel-panel-btn ขยาย hit 36px บนจอสัมผัสเหมือนปุ่มอื่นของแผง (UX audit 2026-09-28) */} - + commitTextboxPatch({ color: c })} @@ -630,10 +906,10 @@ export default function AnnotationSelectionLayer({ type="button" aria-label="สลับตัวหนาทั้งกล่อง" aria-pressed={!!textboxData.bold} - className={`flex h-6 w-6 items-center justify-center rounded-md border transition ${ + className={`flex h-6 w-6 items-center justify-center rounded-md border transition ${FOCUS_RING_CLS} ${ textboxData.bold ? "border-primary bg-primary text-white" - : "border-slate-300 bg-white text-slate-500 hover:bg-slate-100" + : "border-line bg-white text-ink-soft hover:bg-ink/[0.05]" }`} onClick={() => commitTextboxPatch({ bold: !textboxData.bold })} > @@ -644,7 +920,7 @@ export default function AnnotationSelectionLayer({ -
+
{( [ ["left", AlignLeft, "ชิดซ้าย"], @@ -657,10 +933,10 @@ export default function AnnotationSelectionLayer({ type="button" aria-label={`จัดแนว${aLabel}`} aria-pressed={(textboxData.align ?? "left") === a} - className={`px-2 py-1 transition ${ + className={`px-2 py-1 transition ${FOCUS_RING_CLS} ${ (textboxData.align ?? "left") === a ? "bg-primary text-white" - : "text-slate-500 hover:bg-slate-100" + : "text-ink-soft hover:bg-ink/[0.05]" }`} onClick={() => commitTextboxPatch({ align: a })} > @@ -671,23 +947,29 @@ export default function AnnotationSelectionLayer({ )} -
-
+ {hasStyleControls &&
} +
{/* N2: bookmark ตำแหน่งของรายการนี้ — เปิด popup ตั้งชื่อ/โน้ต/เลือกหัวข้อต่อ */} + {/* hover เป็นกลางแบบปุ่มจิ๋วอื่นในแผง (เดิม copy มาจากปุ่มลบข้าง ๆ จน hover + แดง = สัญญาณอันตรายเท็จกับการ "บันทึก" — UX review M4 2026-09-23) */} + {/* เส้นคั่นแนวตั้ง — เฉพาะกรณีปุ่มติดกัน (แผงไม่มี row สไตล์): แยก bookmark ออกจากปุ่มลบ */} + {!hasStyleControls && ( +