Revert "feat: v2 widget dashboard system"
This commit is contained in:
File diff suppressed because it is too large
Load Diff
-266
@@ -1,266 +0,0 @@
|
||||
# RPG Companion Documentation
|
||||
|
||||
This directory contains all design and implementation documentation for RPG Companion v2.0.
|
||||
|
||||
---
|
||||
|
||||
## Documentation Index
|
||||
|
||||
### Implementation
|
||||
|
||||
- **[IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md)** - Complete implementation roadmap
|
||||
- 8 epics with detailed tasks and subtasks
|
||||
- Checkboxes for progress tracking
|
||||
- Dependencies and timeline estimates
|
||||
- Each task builds on the previous one
|
||||
|
||||
### Feature Design
|
||||
|
||||
- **[Widget Dashboard System](./features/widget-dashboard-system.md)** - Dashboard architecture
|
||||
- Dynamic tabs with create/rename/delete
|
||||
- Widget grid system with drag-and-drop
|
||||
- Edit mode and layout persistence
|
||||
- Mobile responsive design
|
||||
- Widget development guide
|
||||
|
||||
- **[Schema System Architecture](./features/schema-system-architecture.md)** - Schema system design
|
||||
- Entity-Component-System (ECS) pattern
|
||||
- YAML-based system definitions
|
||||
- Formula engine with @ references
|
||||
- Character instance validation
|
||||
- Storage layer (IndexedDB + File System API)
|
||||
- AI prompt generation and parsing
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### For Developers
|
||||
|
||||
1. **Start here:** Read [IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md)
|
||||
2. **Understand the dashboard:** Read [Widget Dashboard System](./features/widget-dashboard-system.md)
|
||||
3. **Understand schemas:** Read [Schema System Architecture](./features/schema-system-architecture.md)
|
||||
4. **Pick a task:** Find unchecked tasks in implementation plan
|
||||
5. **Build incrementally:** Each task builds on previous ones
|
||||
|
||||
### For Contributors
|
||||
|
||||
- All major features documented in `/docs/features/`
|
||||
- Implementation plan tracks progress with checkboxes
|
||||
- Each epic is a major deliverable
|
||||
- Commit messages should reference task numbers
|
||||
- Example: `feat: implement grid engine core (Task 1.1)`
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
RPG Companion v2.0 Architecture
|
||||
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ User Interface Layer │
|
||||
│ ┌───────────────┐ ┌───────────────┐ ┌──────────────┐│
|
||||
│ │ Tab Navigator │ │ Widget Grid │ │ Edit Mode UI ││
|
||||
│ └───────────────┘ └───────────────┘ └──────────────┘│
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Widget System Layer │
|
||||
│ ┌───────────────┐ ┌───────────────┐ ┌──────────────┐│
|
||||
│ │ Widget │ │ Grid Engine │ │ Drag & Drop ││
|
||||
│ │ Registry │ │ │ │ Handler ││
|
||||
│ └───────────────┘ └───────────────┘ └──────────────┘│
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Schema System Layer │
|
||||
│ ┌───────────────┐ ┌───────────────┐ ┌──────────────┐│
|
||||
│ │ Schema │ │ Formula │ │ Character ││
|
||||
│ │ Validator │ │ Engine │ │ Manager ││
|
||||
│ └───────────────┘ └───────────────┘ └──────────────┘│
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Storage Layer │
|
||||
│ ┌───────────────┐ ┌───────────────┐ ┌──────────────┐│
|
||||
│ │ IndexedDB │ │ File System │ │ Extension ││
|
||||
│ │ │ │ Access API │ │ Settings ││
|
||||
│ └───────────────┘ └───────────────┘ └──────────────┘│
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Widget Dashboard
|
||||
- **Dynamic Tabs:** Users create unlimited tabs with custom names
|
||||
- **Widget Grid:** 12-column responsive grid with drag-and-drop
|
||||
- **Edit Mode:** Visual editor for arranging widgets
|
||||
- **Persistence:** Layouts save automatically
|
||||
|
||||
### Schema System
|
||||
- **System Definition:** YAML files define RPG system rules
|
||||
- **Character Instance:** JSON data validated against schema
|
||||
- **Formula Engine:** Calculate derived stats with @ references
|
||||
- **AI Integration:** Dynamic prompts and parsing based on schema
|
||||
|
||||
### Progressive Enhancement
|
||||
- **No Modes:** Single flexible system with toggles
|
||||
- **Backward Compatible:** Existing features work without schemas
|
||||
- **Opt-In Complexity:** Users enable advanced features when ready
|
||||
|
||||
---
|
||||
|
||||
## Epics Overview
|
||||
|
||||
| # | Epic | Status | Duration | Description |
|
||||
|---|------|--------|----------|-------------|
|
||||
| 1 | Dashboard Infrastructure | Not Started | 2 weeks | Core grid engine, tabs, drag-and-drop |
|
||||
| 2 | Widget Conversion | Not Started | 2-3 weeks | Convert existing sections to widgets |
|
||||
| 3 | Schema Infrastructure | Not Started | 3-4 weeks | YAML parser, formula engine, validation |
|
||||
| 4 | Schema-Driven Widgets | Not Started | 3-4 weeks | Widgets that render from schemas |
|
||||
| 5 | Schema Editor UI | Not Started | 2-3 weeks | YAML editor and visual builder |
|
||||
| 6 | AI Integration | Not Started | 2-3 weeks | Schema-based prompts and parsing |
|
||||
| 7 | Polish & Mobile | Not Started | 2-3 weeks | Responsive, animations, accessibility |
|
||||
| 8 | Documentation | Not Started | 1-2 weeks | User docs, migration, templates |
|
||||
|
||||
**Total Estimated Time:** 12-14 weeks (3-3.5 months)
|
||||
|
||||
---
|
||||
|
||||
## Design Principles
|
||||
|
||||
### KISS (Keep It Simple, Stupid)
|
||||
- Vanilla JavaScript, no frameworks
|
||||
- Progressive enhancement over feature flags
|
||||
- Clear APIs over clever abstractions
|
||||
|
||||
### User Freedom
|
||||
> "This is SillyTavern - users should be able to do whatever the fuck they want"
|
||||
|
||||
- No arbitrary limitations
|
||||
- Everything customizable
|
||||
- Full GUI editing
|
||||
- Import/export everything
|
||||
|
||||
### Backward Compatibility
|
||||
- Existing features must keep working
|
||||
- Graceful fallbacks everywhere
|
||||
- Migration wizard for v1.x users
|
||||
- No data loss scenarios
|
||||
|
||||
### Performance First
|
||||
- Widgets lazy-load
|
||||
- Formulas memoized
|
||||
- Drag-and-drop throttled
|
||||
- Mobile optimized
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
### Before Starting a Task
|
||||
|
||||
1. Read the task description in [IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md)
|
||||
2. Check dependencies are complete
|
||||
3. Review relevant design docs
|
||||
4. Understand acceptance criteria
|
||||
|
||||
### While Working
|
||||
|
||||
1. Mark task in progress (comment or `[~]`)
|
||||
2. Follow code style in CLAUDE.md
|
||||
3. Test incrementally
|
||||
4. Check console for errors
|
||||
5. Add debug logging
|
||||
|
||||
### After Completing
|
||||
|
||||
1. Test acceptance criteria
|
||||
2. Mark task complete (`[x]`)
|
||||
3. Commit with conventional commit message
|
||||
4. Update epic progress
|
||||
5. Document any blockers or deviations
|
||||
|
||||
### Commit Message Format
|
||||
|
||||
```
|
||||
type(scope): description
|
||||
|
||||
Examples:
|
||||
feat(dashboard): implement grid engine core (Task 1.1)
|
||||
fix(widgets): resolve user stats rendering bug
|
||||
docs(schema): add formula engine examples
|
||||
refactor(storage): optimize IndexedDB queries
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Manual Testing
|
||||
- Test in SillyTavern with extension enabled
|
||||
- Check console for errors
|
||||
- Test on different screen sizes
|
||||
- Verify data persistence
|
||||
- Test edge cases
|
||||
|
||||
### Browser Compatibility
|
||||
- Chrome/Chromium (primary)
|
||||
- Firefox
|
||||
- Safari (if possible)
|
||||
- Mobile browsers
|
||||
|
||||
### Accessibility
|
||||
- Keyboard navigation
|
||||
- Screen reader support
|
||||
- Focus indicators
|
||||
- Color contrast
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
### Getting Help
|
||||
|
||||
- Check [CLAUDE.md](../CLAUDE.md) for development guidelines
|
||||
- Review relevant design docs in `/docs/features/`
|
||||
- Check implementation plan for dependencies
|
||||
- Ask questions in Discord
|
||||
|
||||
### Reporting Issues
|
||||
|
||||
When stuck or blocked:
|
||||
- Document the blocker in implementation plan
|
||||
- Include error messages and logs
|
||||
- Describe what you tried
|
||||
- Note which task is blocked
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Ideas for post-v2.0:
|
||||
|
||||
- Widget marketplace for community widgets
|
||||
- Layout templates for different RPG systems
|
||||
- Widget linking (skills affect stats, etc.)
|
||||
- Conditional widget visibility
|
||||
- Real-time collaboration
|
||||
- Cloud sync
|
||||
- Advanced formula functions
|
||||
- Visual node-based formula editor
|
||||
- Drag-and-drop formula builder
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](../LICENSE) for details (AGPL-3.0).
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-10-23
|
||||
**Version:** 2.0.0-dev
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,869 +0,0 @@
|
||||
# Widget Dashboard System
|
||||
|
||||
**Status:** Design Phase
|
||||
**Priority:** Critical (Foundation for Schema System)
|
||||
**Target Version:** 2.0.0
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Transform RPG Companion from a static, hardcoded panel into a fully customizable widget-based dashboard where users can create tabs, drag-and-drop widgets, and arrange their perfect RPG tracking interface.
|
||||
|
||||
### Core Philosophy
|
||||
> "This is SillyTavern - users should be able to do whatever the fuck they want"
|
||||
|
||||
No "modes", no training wheels, no limitations. Just pure customization.
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. Dynamic Tabs
|
||||
- **User-created tabs**: Create unlimited tabs with custom names
|
||||
- **Tab management**: Rename, delete, reorder, duplicate tabs
|
||||
- **Default tabs**: Ships with "Status" and "Inventory" (user can modify/delete)
|
||||
- **Tab icons**: Optional emoji/icon per tab
|
||||
- **Tab context**: Each tab has independent widget layout
|
||||
|
||||
### 2. Widget Grid System
|
||||
- **12-column responsive grid** (like Bootstrap)
|
||||
- **Variable row height** (default: 80px, user-configurable)
|
||||
- **Drag-and-drop** with smooth animations
|
||||
- **Auto-snap to grid** positions (toggleable)
|
||||
- **Resize handles** on widget corners
|
||||
- **Collision detection** and auto-reflow
|
||||
|
||||
### 3. Widget Library
|
||||
|
||||
#### Core Widgets (Always Available)
|
||||
```javascript
|
||||
{
|
||||
userStats: {
|
||||
name: 'User Stats',
|
||||
icon: '❤️',
|
||||
description: 'Health, energy, satiety, hygiene, arousal bars',
|
||||
minSize: { w: 2, h: 2 },
|
||||
defaultSize: { w: 4, h: 3 },
|
||||
requiresSchema: false
|
||||
},
|
||||
|
||||
infoBox: {
|
||||
name: 'Info Box',
|
||||
icon: '📅',
|
||||
description: 'Date, weather, temperature, time, location dashboard',
|
||||
minSize: { w: 3, h: 2 },
|
||||
defaultSize: { w: 6, h: 2 },
|
||||
requiresSchema: false
|
||||
},
|
||||
|
||||
presentCharacters: {
|
||||
name: 'Present Characters',
|
||||
icon: '👥',
|
||||
description: 'Character cards with avatars and traits',
|
||||
minSize: { w: 2, h: 2 },
|
||||
defaultSize: { w: 6, h: 3 },
|
||||
requiresSchema: false
|
||||
},
|
||||
|
||||
inventory: {
|
||||
name: 'Inventory',
|
||||
icon: '🎒',
|
||||
description: 'On Person, Stored, Assets with list/grid views',
|
||||
minSize: { w: 3, h: 3 },
|
||||
defaultSize: { w: 6, h: 4 },
|
||||
requiresSchema: false
|
||||
},
|
||||
|
||||
classicStats: {
|
||||
name: 'Classic Stats',
|
||||
icon: '🎲',
|
||||
description: 'D&D-style STR/DEX/CON/INT/WIS/CHA with +/- buttons',
|
||||
minSize: { w: 2, h: 2 },
|
||||
defaultSize: { w: 3, h: 3 },
|
||||
requiresSchema: false
|
||||
},
|
||||
|
||||
diceRoller: {
|
||||
name: 'Dice Roller',
|
||||
icon: '🎲',
|
||||
description: 'Interactive dice roller with formula input',
|
||||
minSize: { w: 2, h: 1 },
|
||||
defaultSize: { w: 3, h: 2 },
|
||||
requiresSchema: false
|
||||
},
|
||||
|
||||
lastRoll: {
|
||||
name: 'Last Roll',
|
||||
icon: '🎯',
|
||||
description: 'Display of most recent dice roll result',
|
||||
minSize: { w: 1, h: 1 },
|
||||
defaultSize: { w: 2, h: 1 },
|
||||
requiresSchema: false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Schema-Driven Widgets (Require Active Schema)
|
||||
```javascript
|
||||
{
|
||||
customStats: {
|
||||
name: 'Custom Stats',
|
||||
icon: '📊',
|
||||
description: 'Schema-defined stats with formula support',
|
||||
minSize: { w: 2, h: 2 },
|
||||
defaultSize: { w: 4, h: 3 },
|
||||
requiresSchema: true
|
||||
},
|
||||
|
||||
skills: {
|
||||
name: 'Skills',
|
||||
icon: '⚔️',
|
||||
description: 'Schema-defined skills with progression',
|
||||
minSize: { w: 2, h: 3 },
|
||||
defaultSize: { w: 4, h: 4 },
|
||||
requiresSchema: true
|
||||
},
|
||||
|
||||
relationships: {
|
||||
name: 'Relationships',
|
||||
icon: '💕',
|
||||
description: 'Character relationship tracker with affection values',
|
||||
minSize: { w: 3, h: 2 },
|
||||
defaultSize: { w: 6, h: 3 },
|
||||
requiresSchema: true
|
||||
},
|
||||
|
||||
quests: {
|
||||
name: 'Quest Log',
|
||||
icon: '📜',
|
||||
description: 'Active/completed quests with objectives',
|
||||
minSize: { w: 3, h: 3 },
|
||||
defaultSize: { w: 6, h: 4 },
|
||||
requiresSchema: true
|
||||
},
|
||||
|
||||
statusEffects: {
|
||||
name: 'Status Effects',
|
||||
icon: '✨',
|
||||
description: 'Active buffs/debuffs with duration tracking',
|
||||
minSize: { w: 2, h: 2 },
|
||||
defaultSize: { w: 4, h: 2 },
|
||||
requiresSchema: true
|
||||
},
|
||||
|
||||
resources: {
|
||||
name: 'Resources',
|
||||
icon: '⚡',
|
||||
description: 'Schema-defined resource pools (mana, stamina, etc.)',
|
||||
minSize: { w: 2, h: 2 },
|
||||
defaultSize: { w: 3, h: 2 },
|
||||
requiresSchema: true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Meta Widgets
|
||||
```javascript
|
||||
{
|
||||
schemaEditor: {
|
||||
name: 'Schema Editor',
|
||||
icon: '⚙️',
|
||||
description: 'Inline YAML/visual editor for system schema',
|
||||
minSize: { w: 4, h: 4 },
|
||||
defaultSize: { w: 8, h: 6 },
|
||||
requiresSchema: false
|
||||
},
|
||||
|
||||
debugConsole: {
|
||||
name: 'Debug Console',
|
||||
icon: '🐛',
|
||||
description: 'Parser logs and debug output (mobile-friendly)',
|
||||
minSize: { w: 3, h: 2 },
|
||||
defaultSize: { w: 6, h: 3 },
|
||||
requiresSchema: false
|
||||
},
|
||||
|
||||
quickSettings: {
|
||||
name: 'Quick Settings',
|
||||
icon: '⚙️',
|
||||
description: 'Most-used settings without opening modal',
|
||||
minSize: { w: 2, h: 2 },
|
||||
defaultSize: { w: 3, h: 3 },
|
||||
requiresSchema: false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User Interface Design
|
||||
|
||||
### Edit Mode Toggle
|
||||
|
||||
**View Mode** (Default):
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ RPG Companion [⚙️] [Edit] [×] │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ Combat │ Social │ Inventory │ Lore │ + │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
│ │
|
||||
│ [Widgets render here in locked positions] │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Edit Mode** (Active):
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ RPG Companion [Save] [Cancel] [Reset] │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ Combat │ Social │ + │ [Rename] [Delete] │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
│ ┌─ Widget Library ────────────┐ │
|
||||
│ │ Core Widgets: │ ┌──────────────┐ │
|
||||
│ │ [+ User Stats] │ │ Widget │ [×] [↔] │
|
||||
│ │ [+ Info Box] │ │ (draggable) │ │
|
||||
│ │ [+ Present Characters] │ └──────────────┘ │
|
||||
│ │ [+ Inventory] │ │
|
||||
│ │ [+ Classic Stats] │ [Drop widgets here] │
|
||||
│ │ │ [12-column grid visible] │
|
||||
│ │ Schema Widgets: │ │
|
||||
│ │ [+ Skills] (need schema) │ │
|
||||
│ │ [+ Relationships] │ │
|
||||
│ │ [+ Quests] │ │
|
||||
│ └────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Widget Header (Edit Mode)
|
||||
|
||||
Each widget shows controls when in edit mode:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ User Stats [↔] [×] [⚙]│ ← Drag, Delete, Settings
|
||||
├─────────────────────────────────────┤
|
||||
│ │
|
||||
│ [Widget content] │
|
||||
│ │
|
||||
└─────────────────────────────────────┘
|
||||
↖ Resize handle
|
||||
```
|
||||
|
||||
### Grid Visualization
|
||||
|
||||
When in edit mode, show semi-transparent grid lines:
|
||||
|
||||
```
|
||||
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐ ← 12 columns
|
||||
│ │ │ │ │ │ │ │ │ │ │ │ │
|
||||
├─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┤
|
||||
│ │ ← Rows (80px each)
|
||||
├───────────────────────┤
|
||||
│ │
|
||||
└───────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mobile Behavior
|
||||
|
||||
### Responsive Strategy
|
||||
|
||||
**Mobile (≤1000px width):**
|
||||
- Force single-column layout (widgets stack vertically)
|
||||
- Maintain user's widget order from desktop
|
||||
- Allow drag-to-reorder within column
|
||||
- No resize handles (fixed width = 100%)
|
||||
- Tabs become horizontal scrollable
|
||||
|
||||
**Example Mobile View:**
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ Combat ▼ │ ← Dropdown for tabs
|
||||
└──────────────────────┘
|
||||
│ User Stats │
|
||||
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
|
||||
├──────────────────────┤
|
||||
│ Skills │
|
||||
│ - Lockpicking: 75 │
|
||||
│ - Stealth: 60 │
|
||||
├──────────────────────┤
|
||||
│ Inventory │
|
||||
│ On Person: 3 items │
|
||||
└──────────────────────┘
|
||||
[drag handles for reorder]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Structure
|
||||
|
||||
### Dashboard Configuration
|
||||
|
||||
Stored in `extensionSettings.dashboard`:
|
||||
|
||||
```javascript
|
||||
extensionSettings.dashboard = {
|
||||
version: 2, // Dashboard config version
|
||||
|
||||
gridConfig: {
|
||||
columns: 12, // Grid columns
|
||||
rowHeight: 80, // Pixels per row
|
||||
gap: 12, // Gap between widgets (px)
|
||||
snapToGrid: true, // Auto-snap enabled
|
||||
showGrid: true // Show grid lines in edit mode
|
||||
},
|
||||
|
||||
tabs: [
|
||||
{
|
||||
id: 'tab-combat', // Unique ID (generated)
|
||||
name: 'Combat', // User-editable name
|
||||
icon: '⚔️', // Optional emoji/icon
|
||||
order: 0, // Tab order
|
||||
widgets: [
|
||||
{
|
||||
id: 'widget-1', // Unique widget instance ID
|
||||
type: 'userStats', // Widget type from registry
|
||||
x: 0, // Grid column (0-11)
|
||||
y: 0, // Grid row (0-infinity)
|
||||
w: 4, // Width in columns
|
||||
h: 3, // Height in rows
|
||||
config: { // Widget-specific config
|
||||
showClassicStats: true,
|
||||
statBarStyle: 'gradient'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'widget-2',
|
||||
type: 'skills',
|
||||
x: 4,
|
||||
y: 0,
|
||||
w: 4,
|
||||
h: 4,
|
||||
config: {
|
||||
category: 'Combat',
|
||||
sortBy: 'value'
|
||||
}
|
||||
}
|
||||
// ... more widgets
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'tab-social',
|
||||
name: 'Social',
|
||||
icon: '💬',
|
||||
order: 1,
|
||||
widgets: [
|
||||
// ... widgets for this tab
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
defaultTab: 'tab-combat' // Which tab to show on load
|
||||
};
|
||||
```
|
||||
|
||||
### Default Layout
|
||||
|
||||
First-time users get this default layout:
|
||||
|
||||
```javascript
|
||||
const DEFAULT_DASHBOARD = {
|
||||
tabs: [
|
||||
{
|
||||
id: 'tab-status',
|
||||
name: 'Status',
|
||||
icon: '📊',
|
||||
widgets: [
|
||||
{ type: 'userStats', x: 0, y: 0, w: 6, h: 3 },
|
||||
{ type: 'infoBox', x: 6, y: 0, w: 6, h: 2 },
|
||||
{ type: 'presentCharacters', x: 0, y: 3, w: 12, h: 3 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'tab-inventory',
|
||||
name: 'Inventory',
|
||||
icon: '🎒',
|
||||
widgets: [
|
||||
{ type: 'inventory', x: 0, y: 0, w: 12, h: 6 }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Architecture
|
||||
|
||||
### Module Structure
|
||||
|
||||
```
|
||||
src/systems/dashboard/
|
||||
├── gridEngine.js # Core grid layout engine
|
||||
├── widgetRegistry.js # Widget type definitions
|
||||
├── dragDrop.js # Drag-and-drop logic
|
||||
├── tabManager.js # Tab CRUD operations
|
||||
├── layoutPersistence.js # Save/load layouts
|
||||
└── editMode.js # Edit mode UI state
|
||||
```
|
||||
|
||||
### Widget Registry System
|
||||
|
||||
```javascript
|
||||
// src/systems/dashboard/widgetRegistry.js
|
||||
|
||||
export class WidgetRegistry {
|
||||
constructor() {
|
||||
this.widgets = new Map();
|
||||
}
|
||||
|
||||
register(type, definition) {
|
||||
this.widgets.set(type, {
|
||||
...definition,
|
||||
render: definition.render.bind(definition)
|
||||
});
|
||||
}
|
||||
|
||||
get(type) {
|
||||
return this.widgets.get(type);
|
||||
}
|
||||
|
||||
getAvailable(hasSchema = false) {
|
||||
return Array.from(this.widgets.values())
|
||||
.filter(w => !w.requiresSchema || hasSchema);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage:
|
||||
const registry = new WidgetRegistry();
|
||||
|
||||
registry.register('userStats', {
|
||||
name: 'User Stats',
|
||||
icon: '❤️',
|
||||
minSize: { w: 2, h: 2 },
|
||||
defaultSize: { w: 4, h: 3 },
|
||||
requiresSchema: false,
|
||||
|
||||
render(container, config) {
|
||||
// Reuse existing renderUserStats() logic
|
||||
renderUserStats(container, config);
|
||||
},
|
||||
|
||||
getConfig() {
|
||||
// Return editable config options for settings
|
||||
return {
|
||||
showClassicStats: { type: 'boolean', default: true },
|
||||
statBarStyle: { type: 'select', options: ['solid', 'gradient'] }
|
||||
};
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Grid Engine
|
||||
|
||||
```javascript
|
||||
// src/systems/dashboard/gridEngine.js
|
||||
|
||||
export class GridEngine {
|
||||
constructor(config) {
|
||||
this.columns = config.columns || 12;
|
||||
this.rowHeight = config.rowHeight || 80;
|
||||
this.gap = config.gap || 12;
|
||||
this.snapToGrid = config.snapToGrid !== false;
|
||||
}
|
||||
|
||||
// Calculate widget pixel position from grid coordinates
|
||||
getPixelPosition(widget) {
|
||||
const colWidth = (this.containerWidth - (this.gap * (this.columns + 1))) / this.columns;
|
||||
|
||||
return {
|
||||
left: widget.x * (colWidth + this.gap) + this.gap,
|
||||
top: widget.y * (this.rowHeight + this.gap) + this.gap,
|
||||
width: widget.w * colWidth + (widget.w - 1) * this.gap,
|
||||
height: widget.h * this.rowHeight + (widget.h - 1) * this.gap
|
||||
};
|
||||
}
|
||||
|
||||
// Snap pixel position to nearest grid cell
|
||||
snapToCell(pixelX, pixelY) {
|
||||
const colWidth = (this.containerWidth - (this.gap * (this.columns + 1))) / this.columns;
|
||||
const x = Math.round((pixelX - this.gap) / (colWidth + this.gap));
|
||||
const y = Math.round((pixelY - this.gap) / (this.rowHeight + this.gap));
|
||||
|
||||
return {
|
||||
x: Math.max(0, Math.min(x, this.columns - 1)),
|
||||
y: Math.max(0, y)
|
||||
};
|
||||
}
|
||||
|
||||
// Check for collisions with other widgets
|
||||
detectCollision(widget, widgets) {
|
||||
return widgets.some(other => {
|
||||
if (other.id === widget.id) return false;
|
||||
|
||||
return !(
|
||||
widget.x + widget.w <= other.x ||
|
||||
widget.x >= other.x + other.w ||
|
||||
widget.y + widget.h <= other.y ||
|
||||
widget.y >= other.y + other.h
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Reflow widgets after position change
|
||||
reflow(widgets) {
|
||||
// Sort by y position, then x
|
||||
const sorted = [...widgets].sort((a, b) => {
|
||||
if (a.y !== b.y) return a.y - b.y;
|
||||
return a.x - b.x;
|
||||
});
|
||||
|
||||
// Push down any overlapping widgets
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const widget = sorted[i];
|
||||
|
||||
while (this.detectCollision(widget, sorted.slice(0, i))) {
|
||||
widget.y++;
|
||||
}
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Drag-and-Drop Handler
|
||||
|
||||
```javascript
|
||||
// src/systems/dashboard/dragDrop.js
|
||||
|
||||
export class DragDropHandler {
|
||||
constructor(gridEngine, onDrop) {
|
||||
this.gridEngine = gridEngine;
|
||||
this.onDrop = onDrop;
|
||||
this.draggedWidget = null;
|
||||
this.dragOffset = { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
initWidget(widgetElement, widgetData) {
|
||||
const handle = widgetElement.querySelector('.widget-drag-handle');
|
||||
|
||||
handle.addEventListener('mousedown', (e) => {
|
||||
this.startDrag(e, widgetElement, widgetData);
|
||||
});
|
||||
}
|
||||
|
||||
startDrag(e, element, widget) {
|
||||
e.preventDefault();
|
||||
|
||||
this.draggedWidget = widget;
|
||||
const rect = element.getBoundingClientRect();
|
||||
this.dragOffset = {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top
|
||||
};
|
||||
|
||||
element.classList.add('dragging');
|
||||
|
||||
document.addEventListener('mousemove', this.onMouseMove);
|
||||
document.addEventListener('mouseup', this.onMouseUp);
|
||||
}
|
||||
|
||||
onMouseMove = (e) => {
|
||||
if (!this.draggedWidget) return;
|
||||
|
||||
const pixelX = e.clientX - this.dragOffset.x;
|
||||
const pixelY = e.clientY - this.dragOffset.y;
|
||||
|
||||
if (this.gridEngine.snapToGrid) {
|
||||
const gridPos = this.gridEngine.snapToCell(pixelX, pixelY);
|
||||
this.draggedWidget.x = gridPos.x;
|
||||
this.draggedWidget.y = gridPos.y;
|
||||
} else {
|
||||
// Free-form positioning (convert to grid on drop)
|
||||
this.draggedWidget.pixelX = pixelX;
|
||||
this.draggedWidget.pixelY = pixelY;
|
||||
}
|
||||
|
||||
this.onDrop(this.draggedWidget);
|
||||
}
|
||||
|
||||
onMouseUp = (e) => {
|
||||
if (!this.draggedWidget) return;
|
||||
|
||||
document.querySelector('.dragging')?.classList.remove('dragging');
|
||||
|
||||
// Final snap to grid
|
||||
if (this.draggedWidget.pixelX !== undefined) {
|
||||
const gridPos = this.gridEngine.snapToCell(
|
||||
this.draggedWidget.pixelX,
|
||||
this.draggedWidget.pixelY
|
||||
);
|
||||
this.draggedWidget.x = gridPos.x;
|
||||
this.draggedWidget.y = gridPos.y;
|
||||
delete this.draggedWidget.pixelX;
|
||||
delete this.draggedWidget.pixelY;
|
||||
}
|
||||
|
||||
this.onDrop(this.draggedWidget, true); // true = drop complete
|
||||
this.draggedWidget = null;
|
||||
|
||||
document.removeEventListener('mousemove', this.onMouseMove);
|
||||
document.removeEventListener('mouseup', this.onMouseUp);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Widget Development Guide
|
||||
|
||||
### Creating a New Widget
|
||||
|
||||
```javascript
|
||||
// 1. Define widget in registry
|
||||
registry.register('myCustomWidget', {
|
||||
name: 'My Custom Widget',
|
||||
icon: '🎨',
|
||||
description: 'Does something cool',
|
||||
minSize: { w: 2, h: 2 },
|
||||
defaultSize: { w: 4, h: 3 },
|
||||
requiresSchema: false,
|
||||
|
||||
// Render function receives container and config
|
||||
render(container, config) {
|
||||
const html = `
|
||||
<div class="my-widget">
|
||||
<h4>${config.title || 'My Widget'}</h4>
|
||||
<div class="my-widget-content">
|
||||
<!-- Widget content here -->
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Set up event listeners
|
||||
container.querySelector('.my-widget').addEventListener('click', () => {
|
||||
console.log('Widget clicked!');
|
||||
});
|
||||
},
|
||||
|
||||
// Define configurable options
|
||||
getConfig() {
|
||||
return {
|
||||
title: {
|
||||
type: 'text',
|
||||
label: 'Widget Title',
|
||||
default: 'My Widget'
|
||||
},
|
||||
color: {
|
||||
type: 'color',
|
||||
label: 'Accent Color',
|
||||
default: '#e94560'
|
||||
},
|
||||
showBorder: {
|
||||
type: 'boolean',
|
||||
label: 'Show Border',
|
||||
default: true
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
// Called when widget config changes
|
||||
onConfigChange(newConfig, container) {
|
||||
this.render(container, newConfig);
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Widget automatically available in dashboard
|
||||
```
|
||||
|
||||
### Widget Lifecycle
|
||||
|
||||
```javascript
|
||||
// Widget instance lifecycle:
|
||||
1. User adds widget to tab
|
||||
→ registry.get(type) returns definition
|
||||
→ Generate unique widget ID
|
||||
→ Assign default size and position
|
||||
|
||||
2. Dashboard renders widget
|
||||
→ Create container element
|
||||
→ Call widget.render(container, config)
|
||||
→ Apply positioning/sizing CSS
|
||||
|
||||
3. User enters edit mode
|
||||
→ Show drag handle and resize controls
|
||||
→ Enable drag/drop handlers
|
||||
|
||||
4. User changes widget config
|
||||
→ Call widget.onConfigChange(newConfig)
|
||||
→ Widget re-renders with new config
|
||||
|
||||
5. User removes widget
|
||||
→ Clean up event listeners
|
||||
→ Remove from layout array
|
||||
→ Reflow remaining widgets
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Settings Integration
|
||||
|
||||
### Widget Management Section
|
||||
|
||||
Add to existing Settings modal:
|
||||
|
||||
```html
|
||||
<div class="inline-drawer">
|
||||
<div class="inline-drawer-toggle inline-drawer-header">
|
||||
<b>Dashboard Layout</b>
|
||||
</div>
|
||||
<div class="inline-drawer-content">
|
||||
|
||||
<!-- Available Widgets -->
|
||||
<h4>Available Widgets</h4>
|
||||
<div class="widget-toggles">
|
||||
<label><input type="checkbox" checked disabled> User Stats</label>
|
||||
<label><input type="checkbox" checked disabled> Info Box</label>
|
||||
<label><input type="checkbox" checked disabled> Present Characters</label>
|
||||
<label><input type="checkbox" checked disabled> Inventory</label>
|
||||
<label><input type="checkbox" checked> Classic Stats</label>
|
||||
<label><input type="checkbox" checked> Dice Roller</label>
|
||||
<label><input type="checkbox"> Skills (requires schema)</label>
|
||||
<label><input type="checkbox"> Relationships (requires schema)</label>
|
||||
<label><input type="checkbox"> Quests (requires schema)</label>
|
||||
</div>
|
||||
|
||||
<!-- Grid Configuration -->
|
||||
<h4>Grid Settings</h4>
|
||||
<label>Columns: <input type="number" value="12" min="6" max="24"></label>
|
||||
<label>Row Height: <input type="number" value="80" min="40" max="200"> px</label>
|
||||
<label>Gap: <input type="number" value="12" min="0" max="32"> px</label>
|
||||
<label><input type="checkbox" checked> Snap to grid</label>
|
||||
<label><input type="checkbox" checked> Show grid in edit mode</label>
|
||||
|
||||
<!-- Layout Actions -->
|
||||
<h4>Layout Actions</h4>
|
||||
<button id="dashboard-edit-layout">Edit Layout</button>
|
||||
<button id="dashboard-reset-layout">Reset to Default</button>
|
||||
<button id="dashboard-export-layout">Export Layout</button>
|
||||
<button id="dashboard-import-layout">Import Layout</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technical Considerations
|
||||
|
||||
### Performance
|
||||
|
||||
- **Virtualization**: Only render visible widgets (especially on mobile)
|
||||
- **Throttle drag updates**: Use RAF (requestAnimationFrame) for smooth dragging
|
||||
- **Debounce saves**: Don't save layout on every drag - wait 500ms after drop
|
||||
- **Lazy load widgets**: Only load widget code when first used
|
||||
|
||||
### Browser Compatibility
|
||||
|
||||
- **CSS Grid**: Fallback to flexbox for older browsers
|
||||
- **Drag API**: Use mouse events instead of HTML5 Drag API (better cross-browser)
|
||||
- **Touch events**: Support both mouse and touch for mobile
|
||||
- **LocalStorage**: Store layout in extensionSettings, backed up to localStorage
|
||||
|
||||
### Accessibility
|
||||
|
||||
- **Keyboard navigation**: Tab through widgets, Enter to edit
|
||||
- **Screen readers**: Proper ARIA labels on all controls
|
||||
- **Focus indicators**: Clear visual focus states
|
||||
- **Skip links**: "Skip to widget X" for keyboard users
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Infrastructure
|
||||
- Implement grid engine and widget registry
|
||||
- Add dashboard config to extensionSettings
|
||||
- Create default layout from current structure
|
||||
|
||||
### Phase 2: Edit Mode
|
||||
- Build edit mode toggle and UI
|
||||
- Implement drag-and-drop for existing widgets
|
||||
- Add tab management (create/rename/delete)
|
||||
|
||||
### Phase 3: Widget Conversion
|
||||
- Convert existing sections to widgets:
|
||||
- userStats widget (reuse renderUserStats)
|
||||
- infoBox widget (reuse renderInfoBox)
|
||||
- presentCharacters widget (reuse renderThoughts)
|
||||
- inventory widget (reuse renderInventory)
|
||||
- classicStats widget (extract from userStats)
|
||||
|
||||
### Phase 4: New Widgets
|
||||
- Implement schema-driven widgets:
|
||||
- skills widget
|
||||
- relationships widget
|
||||
- quests widget
|
||||
- statusEffects widget
|
||||
|
||||
### Phase 5: Polish
|
||||
- Mobile responsive refinements
|
||||
- Animation polish
|
||||
- Settings integration
|
||||
- Documentation and tutorials
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- **Widget marketplace**: Share custom widgets with community
|
||||
- **Layout templates**: Pre-made layouts for different RPG systems
|
||||
- **Widget linking**: Connect widgets (e.g., skills affect stats)
|
||||
- **Conditional visibility**: Show/hide widgets based on conditions
|
||||
- **Widget themes**: Per-widget color/style overrides
|
||||
- **Nested tabs**: Tabs within widgets for complex UIs
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Grid Library**: Use existing library (Gridstack.js) or build custom?
|
||||
- **Pro Gridstack**: Battle-tested, feature-rich, responsive
|
||||
- **Pro Custom**: No dependencies, lighter weight, full control
|
||||
|
||||
2. **Schema Editor Widget**: Should it be a widget or always-modal?
|
||||
- **Widget**: More flexible positioning, can be in dedicated tab
|
||||
- **Modal**: Cleaner separation, larger working area
|
||||
|
||||
3. **Mobile Tab Limit**: Should we limit tabs on mobile?
|
||||
- **Unlimited**: Let users manage, use dropdown/scroll
|
||||
- **Limited**: Force max 5 tabs, rest in "More" menu
|
||||
|
||||
4. **Widget State**: Where to store widget-specific state (not config)?
|
||||
- **Per-widget**: Each widget manages its own state
|
||||
- **Global**: Dashboard state manager for all widgets
|
||||
- **Hybrid**: Widgets can opt into global state management
|
||||
|
||||
5. **Undo/Redo**: Should layout changes support undo/redo?
|
||||
- **Yes**: Better UX, prevents accidental deletions
|
||||
- **No**: Adds complexity, users can import previous layout
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- ✅ Users can create/delete/rename tabs without code
|
||||
- ✅ Users can drag-and-drop widgets to any position
|
||||
- ✅ Layout persists across sessions
|
||||
- ✅ Mobile users get functional (even if stacked) layout
|
||||
- ✅ Existing functionality works as widgets (no regressions)
|
||||
- ✅ Schema-driven widgets only appear when schema active
|
||||
- ✅ Export/import layouts works reliably
|
||||
- ✅ Edit mode is intuitive (no tutorial needed)
|
||||
@@ -129,14 +129,6 @@ import {
|
||||
clearExtensionPrompts
|
||||
} from './src/systems/integration/sillytavern.js';
|
||||
|
||||
// Dashboard v2 System
|
||||
import {
|
||||
initializeDashboard,
|
||||
createDefaultLayout,
|
||||
refreshDashboard,
|
||||
getDashboardManager
|
||||
} from './src/systems/dashboard/dashboardIntegration.js';
|
||||
|
||||
// Old state variable declarations removed - now imported from core modules
|
||||
// (extensionSettings, lastGeneratedData, committedTrackerData, etc. are now in src/core/state.js)
|
||||
|
||||
@@ -444,82 +436,12 @@ async function initUI() {
|
||||
// Setup collapse/expand toggle button
|
||||
setupCollapseToggle();
|
||||
|
||||
// Initialize Dashboard v2 System
|
||||
try {
|
||||
console.log('[RPG Companion] Initializing Dashboard v2...');
|
||||
|
||||
// Prepare dependencies for widgets
|
||||
const dashboardDependencies = {
|
||||
// Data accessors
|
||||
getContext: () => getContext(),
|
||||
getExtensionSettings: () => extensionSettings,
|
||||
getUserAvatar: () => user_avatar,
|
||||
getCharacters: () => characters,
|
||||
getCurrentCharId: () => this_chid,
|
||||
getGroupMembers: () => getGroupMembers(),
|
||||
getFallbackAvatar: () => FALLBACK_AVATAR_DATA_URI,
|
||||
getAvatarUrl: (type, avatar) => getThumbnailUrl(type, avatar),
|
||||
getCharacterThoughts: () => extensionSettings.characterThoughts || '',
|
||||
getInfoBoxData: () => extensionSettings.infoBoxData || 'Info Box\n---\n',
|
||||
|
||||
// Data setters
|
||||
setCharacterThoughts: (value) => {
|
||||
extensionSettings.characterThoughts = value;
|
||||
saveSettings();
|
||||
},
|
||||
setInfoBoxData: (value) => {
|
||||
extensionSettings.infoBoxData = value;
|
||||
saveSettings();
|
||||
},
|
||||
|
||||
// Event callbacks
|
||||
onDataChange: (dataType, field, value, extra) => {
|
||||
console.log(`[RPG Companion] Dashboard data changed: ${dataType}.${field}`, value);
|
||||
saveSettings();
|
||||
saveChatData();
|
||||
updateMessageSwipeData();
|
||||
},
|
||||
|
||||
onStatsChange: (category, field, value) => {
|
||||
console.log(`[RPG Companion] Stats changed: ${category}.${field}`, value);
|
||||
saveSettings();
|
||||
saveChatData();
|
||||
updateMessageSwipeData();
|
||||
},
|
||||
|
||||
onDashboardChange: (data) => {
|
||||
console.log('[RPG Companion] Dashboard layout changed');
|
||||
saveSettings();
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize dashboard
|
||||
console.log('[RPG Companion] Current dashboard settings:', extensionSettings.dashboard);
|
||||
const manager = await initializeDashboard(dashboardDependencies);
|
||||
|
||||
if (manager) {
|
||||
console.log('[RPG Companion] Dashboard v2 initialized successfully');
|
||||
console.log('[RPG Companion] Manager instance:', manager);
|
||||
|
||||
// Dashboard manager already loaded its layout in init() via loadLayout()
|
||||
// No need to load again here - that would overwrite the migrated values
|
||||
console.log('[RPG Companion] Dashboard initialized and layout loaded via layoutPersistence');
|
||||
} else {
|
||||
console.warn('[RPG Companion] Dashboard initialization returned null, falling back to legacy rendering');
|
||||
throw new Error('Dashboard initialization failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[RPG Companion] Dashboard v2 initialization failed, using legacy rendering:', error);
|
||||
|
||||
// Fallback to legacy rendering
|
||||
// Render initial data if available
|
||||
renderUserStats();
|
||||
renderInfoBox();
|
||||
renderThoughts();
|
||||
renderInventory();
|
||||
renderQuests();
|
||||
}
|
||||
|
||||
// Setup remaining UI components
|
||||
updateDiceDisplay();
|
||||
setupDiceRoller();
|
||||
setupClassicStatsButtons();
|
||||
|
||||
@@ -11,12 +11,6 @@
|
||||
</label>
|
||||
<small class="notes">Toggle to enable/disable the RPG Companion extension. Configure additional settings within the panel itself.</small>
|
||||
|
||||
<label class="checkbox_label" for="rpg-toggle-html-prompt" style="margin-top: 10px;">
|
||||
<input type="checkbox" id="rpg-toggle-html-prompt" />
|
||||
<span>Enable Immersive HTML</span>
|
||||
</label>
|
||||
<small class="notes">Enables HTML formatting in AI responses for more immersive roleplay. This affects how tracker data is embedded in prompts.</small>
|
||||
|
||||
<div style="margin-top: 10px; display: flex; gap: 10px;">
|
||||
<a href="https://discord.com/invite/KdAkTg94ME" target="_blank" class="menu_button" style="flex: 1; text-align: center; text-decoration: none;">
|
||||
<i class="fa-brands fa-discord"></i> Discord
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from './state.js';
|
||||
import { migrateInventory } from '../utils/migration.js';
|
||||
import { validateStoredInventory, cleanItemString } from '../utils/security.js';
|
||||
import { generateDefaultDashboard, migrateV1ToV2Dashboard, validateDashboardConfig } from '../systems/dashboard/defaultLayout.js';
|
||||
|
||||
const extensionName = 'third-party/rpg-companion-sillytavern';
|
||||
|
||||
@@ -94,20 +93,6 @@ export function loadSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate to v2.0 dashboard if not present
|
||||
if (!extensionSettings.dashboard || !extensionSettings.dashboard.tabs || extensionSettings.dashboard.tabs.length === 0) {
|
||||
console.log('[RPG Companion] Dashboard v2.0 not found, migrating from v1.x');
|
||||
extensionSettings.dashboard = migrateV1ToV2Dashboard(extensionSettings);
|
||||
saveSettings(); // Persist migrated dashboard
|
||||
} else {
|
||||
// Validate existing dashboard config
|
||||
if (!validateDashboardConfig(extensionSettings.dashboard)) {
|
||||
console.warn('[RPG Companion] Dashboard config invalid, regenerating default');
|
||||
extensionSettings.dashboard = generateDefaultDashboard();
|
||||
saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate to trackerConfig if it doesn't exist
|
||||
if (!extensionSettings.trackerConfig) {
|
||||
console.log('[RPG Companion] Migrating to trackerConfig format');
|
||||
|
||||
+1
-37
@@ -159,43 +159,7 @@ export let extensionSettings = {
|
||||
assets: 'list' // 'list' or 'grid' view mode for Assets section
|
||||
},
|
||||
debugMode: false, // Enable debug logging visible in UI (for mobile debugging)
|
||||
memoryMessagesToProcess: 16, // Number of messages to process per batch in memory recollection
|
||||
|
||||
// Dashboard v2.0 Configuration
|
||||
dashboard: {
|
||||
version: 2, // Dashboard config version
|
||||
|
||||
gridConfig: {
|
||||
// Columns calculated dynamically by GridEngine (2-4 based on panel width)
|
||||
// Mobile (≤1000px screen): always 2 columns
|
||||
// Desktop (>1000px screen): 2-4 columns based on panel width
|
||||
rowHeight: 5, // rem units for responsive scaling
|
||||
gap: 0.75, // rem units (was 12px)
|
||||
snapToGrid: true, // Auto-snap enabled
|
||||
showGrid: true // Show grid lines in edit mode
|
||||
},
|
||||
|
||||
tabs: [
|
||||
// Default tabs will be generated by generateDefaultDashboard()
|
||||
// Structure:
|
||||
// {
|
||||
// id: 'tab-status',
|
||||
// name: 'Status',
|
||||
// icon: '📊',
|
||||
// order: 0,
|
||||
// widgets: [
|
||||
// {
|
||||
// id: 'widget-1',
|
||||
// type: 'userStats',
|
||||
// x: 0, y: 0, w: 6, h: 3,
|
||||
// config: {}
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
],
|
||||
|
||||
defaultTab: 'tab-status' // Which tab to show on load
|
||||
}
|
||||
memoryMessagesToProcess: 16 // Number of messages to process per batch in memory recollection
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
/**
|
||||
* Confirmation Dialog System
|
||||
*
|
||||
* Provides styled confirmation and alert dialogs to replace native browser popups.
|
||||
* Supports three variants: danger (red), warning (yellow), and info (blue).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Show a confirmation dialog
|
||||
* @param {Object} options - Dialog options
|
||||
* @param {string} options.title - Dialog title
|
||||
* @param {string} options.message - Dialog message
|
||||
* @param {string} [options.variant='danger'] - Dialog variant: 'danger', 'warning', or 'info'
|
||||
* @param {string} [options.confirmText='Confirm'] - Confirm button text
|
||||
* @param {string} [options.cancelText='Cancel'] - Cancel button text
|
||||
* @param {Function} [options.onConfirm] - Callback when confirmed
|
||||
* @param {Function} [options.onCancel] - Callback when cancelled
|
||||
* @returns {Promise<boolean>} Resolves to true if confirmed, false if cancelled
|
||||
*/
|
||||
export function showConfirmDialog(options) {
|
||||
return new Promise((resolve) => {
|
||||
const {
|
||||
title = 'Confirm Action',
|
||||
message = 'Are you sure?',
|
||||
variant = 'danger',
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
onConfirm = null,
|
||||
onCancel = null
|
||||
} = options;
|
||||
|
||||
// Get modal elements
|
||||
const modal = document.getElementById('rpg-confirm-dialog');
|
||||
|
||||
if (!modal) {
|
||||
console.error('[ConfirmDialog] Modal not found');
|
||||
return resolve(false);
|
||||
}
|
||||
|
||||
// CRITICAL: Move modal to document.body on first use to escape panel constraints
|
||||
// The panel has transform in its transition which creates a containing block,
|
||||
// constraining position:fixed children to the panel instead of viewport
|
||||
if (modal.parentElement?.id !== 'document-body-modals') {
|
||||
// Create container for modals at body level (only once)
|
||||
let bodyModalsContainer = document.getElementById('document-body-modals');
|
||||
if (!bodyModalsContainer) {
|
||||
bodyModalsContainer = document.createElement('div');
|
||||
bodyModalsContainer.id = 'document-body-modals';
|
||||
bodyModalsContainer.style.cssText = 'position: fixed; inset: 0; pointer-events: none; z-index: 10000; display: flex; align-items: center; justify-content: center;';
|
||||
document.body.appendChild(bodyModalsContainer);
|
||||
}
|
||||
bodyModalsContainer.appendChild(modal);
|
||||
console.log('[ConfirmDialog] Moved modal to document.body to escape panel constraints');
|
||||
}
|
||||
|
||||
const modalContent = modal.querySelector('.rpg-confirm-content');
|
||||
const icon = document.getElementById('rpg-confirm-icon');
|
||||
const titleEl = document.getElementById('rpg-confirm-title');
|
||||
const messageEl = document.getElementById('rpg-confirm-message');
|
||||
const confirmBtn = document.getElementById('rpg-confirm-confirm');
|
||||
const cancelBtn = document.getElementById('rpg-confirm-cancel');
|
||||
const closeBtn = modal.querySelector('.rpg-confirm-close');
|
||||
|
||||
// Set icon based on variant
|
||||
const iconMap = {
|
||||
danger: 'fa-solid fa-triangle-exclamation',
|
||||
warning: 'fa-solid fa-circle-exclamation',
|
||||
info: 'fa-solid fa-circle-info'
|
||||
};
|
||||
icon.className = `rpg-confirm-icon ${iconMap[variant] || iconMap.danger}`;
|
||||
|
||||
// Set variant class on modal content
|
||||
modalContent.className = `rpg-modal-content rpg-confirm-content rpg-confirm-${variant}`;
|
||||
|
||||
// Set content
|
||||
titleEl.textContent = title;
|
||||
messageEl.textContent = message;
|
||||
confirmBtn.textContent = confirmText;
|
||||
cancelBtn.textContent = cancelText;
|
||||
|
||||
// Show modal
|
||||
modal.style.display = 'flex';
|
||||
|
||||
// Handle confirm
|
||||
const handleConfirm = () => {
|
||||
modal.style.display = 'none';
|
||||
cleanup();
|
||||
if (onConfirm) onConfirm();
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
// Handle cancel
|
||||
const handleCancel = () => {
|
||||
modal.style.display = 'none';
|
||||
cleanup();
|
||||
if (onCancel) onCancel();
|
||||
resolve(false);
|
||||
};
|
||||
|
||||
// Handle keyboard
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleCancel();
|
||||
} else if (e.key === 'Enter') {
|
||||
handleConfirm();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle backdrop click
|
||||
const handleBackdropClick = (e) => {
|
||||
if (e.target === modal) {
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up event listeners
|
||||
const cleanup = () => {
|
||||
confirmBtn.removeEventListener('click', handleConfirm);
|
||||
cancelBtn.removeEventListener('click', handleCancel);
|
||||
closeBtn.removeEventListener('click', handleCancel);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
modal.removeEventListener('click', handleBackdropClick);
|
||||
};
|
||||
|
||||
// Attach event listeners
|
||||
confirmBtn.addEventListener('click', handleConfirm);
|
||||
cancelBtn.addEventListener('click', handleCancel);
|
||||
closeBtn.addEventListener('click', handleCancel);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
modal.addEventListener('click', handleBackdropClick);
|
||||
|
||||
// Focus confirm button
|
||||
setTimeout(() => confirmBtn.focus(), 100);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an alert dialog (info only, single OK button)
|
||||
* @param {Object} options - Dialog options
|
||||
* @param {string} options.title - Dialog title
|
||||
* @param {string} options.message - Dialog message
|
||||
* @param {string} [options.variant='info'] - Dialog variant: 'danger', 'warning', or 'info'
|
||||
* @param {string} [options.okText='OK'] - OK button text
|
||||
* @param {Function} [options.onOk] - Callback when OK clicked
|
||||
* @returns {Promise<void>} Resolves when OK clicked
|
||||
*/
|
||||
export function showAlertDialog(options) {
|
||||
return new Promise((resolve) => {
|
||||
const {
|
||||
title = 'Alert',
|
||||
message = '',
|
||||
variant = 'info',
|
||||
okText = 'OK',
|
||||
onOk = null
|
||||
} = options;
|
||||
|
||||
// Get modal elements
|
||||
const modal = document.getElementById('rpg-confirm-dialog');
|
||||
|
||||
if (!modal) {
|
||||
console.error('[ConfirmDialog] Modal not found');
|
||||
return resolve();
|
||||
}
|
||||
|
||||
// CRITICAL: Move modal to document.body on first use to escape panel constraints
|
||||
// The panel has transform in its transition which creates a containing block,
|
||||
// constraining position:fixed children to the panel instead of viewport
|
||||
if (modal.parentElement?.id !== 'document-body-modals') {
|
||||
// Create container for modals at body level (only once)
|
||||
let bodyModalsContainer = document.getElementById('document-body-modals');
|
||||
if (!bodyModalsContainer) {
|
||||
bodyModalsContainer = document.createElement('div');
|
||||
bodyModalsContainer.id = 'document-body-modals';
|
||||
bodyModalsContainer.style.cssText = 'position: fixed; inset: 0; pointer-events: none; z-index: 10000; display: flex; align-items: center; justify-content: center;';
|
||||
document.body.appendChild(bodyModalsContainer);
|
||||
}
|
||||
bodyModalsContainer.appendChild(modal);
|
||||
console.log('[ConfirmDialog] Moved modal to document.body to escape panel constraints');
|
||||
}
|
||||
|
||||
const modalContent = modal.querySelector('.rpg-confirm-content');
|
||||
const icon = document.getElementById('rpg-confirm-icon');
|
||||
const titleEl = document.getElementById('rpg-confirm-title');
|
||||
const messageEl = document.getElementById('rpg-confirm-message');
|
||||
const confirmBtn = document.getElementById('rpg-confirm-confirm');
|
||||
const cancelBtn = document.getElementById('rpg-confirm-cancel');
|
||||
const closeBtn = modal.querySelector('.rpg-confirm-close');
|
||||
|
||||
// Set icon based on variant
|
||||
const iconMap = {
|
||||
danger: 'fa-solid fa-triangle-exclamation',
|
||||
warning: 'fa-solid fa-circle-exclamation',
|
||||
info: 'fa-solid fa-circle-info'
|
||||
};
|
||||
icon.className = `rpg-confirm-icon ${iconMap[variant] || iconMap.info}`;
|
||||
|
||||
// Set variant class on modal content
|
||||
modalContent.className = `rpg-modal-content rpg-confirm-content rpg-confirm-${variant}`;
|
||||
|
||||
// Set content
|
||||
titleEl.textContent = title;
|
||||
messageEl.textContent = message;
|
||||
confirmBtn.textContent = okText;
|
||||
|
||||
// Hide cancel button for alerts
|
||||
cancelBtn.style.display = 'none';
|
||||
|
||||
// Show modal
|
||||
modal.style.display = 'flex';
|
||||
|
||||
// Handle OK
|
||||
const handleOk = () => {
|
||||
modal.style.display = 'none';
|
||||
cancelBtn.style.display = ''; // Restore for future confirms
|
||||
cleanup();
|
||||
if (onOk) onOk();
|
||||
resolve();
|
||||
};
|
||||
|
||||
// Handle keyboard
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape' || e.key === 'Enter') {
|
||||
handleOk();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle backdrop click
|
||||
const handleBackdropClick = (e) => {
|
||||
if (e.target === modal) {
|
||||
handleOk();
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up event listeners
|
||||
const cleanup = () => {
|
||||
confirmBtn.removeEventListener('click', handleOk);
|
||||
closeBtn.removeEventListener('click', handleOk);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
modal.removeEventListener('click', handleBackdropClick);
|
||||
};
|
||||
|
||||
// Attach event listeners
|
||||
confirmBtn.addEventListener('click', handleOk);
|
||||
closeBtn.addEventListener('click', handleOk);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
modal.addEventListener('click', handleBackdropClick);
|
||||
|
||||
// Focus OK button
|
||||
setTimeout(() => confirmBtn.focus(), 100);
|
||||
});
|
||||
}
|
||||
@@ -1,624 +0,0 @@
|
||||
/**
|
||||
* Dashboard Integration Module
|
||||
*
|
||||
* Handles initialization and integration of the v2 dashboard system
|
||||
* with the main RPG Companion extension.
|
||||
*/
|
||||
|
||||
import { extensionName } from '../../core/config.js';
|
||||
import { extensionSettings } from '../../core/state.js';
|
||||
import { saveSettings } from '../../core/persistence.js';
|
||||
import { renderExtensionTemplateAsync } from '../../../../../../extensions.js';
|
||||
import { DashboardManager } from './dashboardManager.js';
|
||||
import { WidgetRegistry } from './widgetRegistry.js';
|
||||
import { generateDefaultDashboard } from './defaultLayout.js';
|
||||
import { TabScrollManager } from './tabScrollManager.js';
|
||||
import { HeaderOverflowManager } from './headerOverflowManager.js';
|
||||
import { TabContextMenu } from './tabContextMenu.js';
|
||||
import { showConfirmDialog } from './confirmDialog.js';
|
||||
|
||||
// Widget imports
|
||||
import { registerUserInfoWidget } from './widgets/userInfoWidget.js';
|
||||
import { registerUserStatsWidget } from './widgets/userStatsWidget.js';
|
||||
import { registerUserMoodWidget } from './widgets/userMoodWidget.js';
|
||||
import { registerUserAttributesWidget } from './widgets/userAttributesWidget.js';
|
||||
import { registerCalendarWidget, registerWeatherWidget, registerTemperatureWidget, registerClockWidget, registerLocationWidget, registerRecentEventsWidget } from './widgets/infoBoxWidgets.js';
|
||||
import { registerSceneInfoWidget } from './widgets/sceneInfoWidget.js';
|
||||
import { registerPresentCharactersWidget } from './widgets/presentCharactersWidget.js';
|
||||
import { registerInventoryWidget } from './widgets/inventoryWidget.js';
|
||||
import { registerQuestsWidget } from './widgets/questsWidget.js';
|
||||
|
||||
// Global dashboard manager instance
|
||||
let dashboardManager = null;
|
||||
let tabScrollManager = null;
|
||||
let headerOverflowManager = null;
|
||||
let tabContextMenu = null;
|
||||
|
||||
/**
|
||||
* Get the dashboard manager instance
|
||||
*/
|
||||
export function getDashboardManager() {
|
||||
return dashboardManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the dashboard system
|
||||
* @param {Object} dependencies - Dependencies from main extension
|
||||
*/
|
||||
export async function initializeDashboard(dependencies) {
|
||||
console.log('[RPG Companion] Initializing Dashboard v2 System...');
|
||||
|
||||
try {
|
||||
// Load dashboard template
|
||||
const dashboardHtml = await loadDashboardTemplate();
|
||||
|
||||
// Find or create dashboard container in the panel
|
||||
const panelContent = document.querySelector('#rpg-panel-content');
|
||||
if (!panelContent) {
|
||||
console.error('[RPG Companion] Panel content container not found');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Insert dashboard HTML (replacing old content-box)
|
||||
const contentBox = panelContent.querySelector('.rpg-content-box');
|
||||
if (contentBox) {
|
||||
// Replace old content-box with dashboard
|
||||
contentBox.replaceWith(createDashboardContainer(dashboardHtml));
|
||||
} else {
|
||||
// If no content-box, insert dashboard after dice display
|
||||
const diceDisplay = panelContent.querySelector('#rpg-dice-display');
|
||||
if (diceDisplay) {
|
||||
diceDisplay.insertAdjacentHTML('afterend', dashboardHtml);
|
||||
} else {
|
||||
panelContent.insertAdjacentHTML('afterbegin', dashboardHtml);
|
||||
}
|
||||
}
|
||||
|
||||
// Create widget registry
|
||||
const registry = new WidgetRegistry();
|
||||
|
||||
// Register all widgets
|
||||
registerAllWidgets(registry, dependencies);
|
||||
|
||||
// Initialize dashboard manager
|
||||
const container = document.querySelector('#rpg-dashboard-container');
|
||||
if (!container) {
|
||||
console.error('[RPG Companion] Dashboard container not found after template load');
|
||||
return null;
|
||||
}
|
||||
|
||||
dashboardManager = new DashboardManager(container, {
|
||||
registry,
|
||||
autoSave: true,
|
||||
onChange: (data) => {
|
||||
// Handle dashboard changes
|
||||
console.log('[RPG Companion] Dashboard changed:', data);
|
||||
if (dependencies.onDashboardChange) {
|
||||
dependencies.onDashboardChange(data);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize the dashboard
|
||||
await dashboardManager.init();
|
||||
|
||||
// Set default layout (required for reset functionality)
|
||||
const defaultLayout = generateDefaultDashboard();
|
||||
dashboardManager.setDefaultLayout(defaultLayout);
|
||||
console.log('[RPG Companion] Default layout set with', defaultLayout.tabs.length, 'tabs');
|
||||
|
||||
// Initialize previousTrackerConfig to enable widget detection on first load
|
||||
// Without this, detectConfigChanges() returns [] because oldConfig is null
|
||||
const settings = dependencies.getExtensionSettings();
|
||||
if (settings?.trackerConfig && dashboardManager) {
|
||||
dashboardManager.previousTrackerConfig = JSON.parse(JSON.stringify(settings.trackerConfig));
|
||||
console.log('[RPG Companion] Initialized previousTrackerConfig for widget detection');
|
||||
}
|
||||
|
||||
// Set up dashboard event listeners
|
||||
setupDashboardEventListeners(dependencies);
|
||||
|
||||
// Initialize tab scroll manager
|
||||
const tabsContainer = document.querySelector('#rpg-dashboard-tabs');
|
||||
if (tabsContainer) {
|
||||
tabScrollManager = new TabScrollManager(tabsContainer);
|
||||
tabScrollManager.init();
|
||||
}
|
||||
|
||||
// Initialize tab context menu
|
||||
if (tabsContainer && dashboardManager?.tabManager) {
|
||||
tabContextMenu = new TabContextMenu({
|
||||
tabManager: dashboardManager.tabManager,
|
||||
onTabChange: (event, data) => {
|
||||
console.log('[RPG Companion] Tab context menu event:', event, data);
|
||||
// Re-render tabs after tab operations
|
||||
dashboardManager.renderTabs();
|
||||
// Save dashboard state
|
||||
if (dashboardManager.autoSave) {
|
||||
saveSettings();
|
||||
}
|
||||
}
|
||||
});
|
||||
tabContextMenu.init(tabsContainer);
|
||||
}
|
||||
|
||||
// Initialize header overflow manager
|
||||
const headerRight = document.querySelector('#rpg-dashboard-header-right');
|
||||
if (headerRight) {
|
||||
headerOverflowManager = new HeaderOverflowManager(headerRight);
|
||||
headerOverflowManager.init();
|
||||
|
||||
// Wire up editModeManager for menu filtering
|
||||
if (dashboardManager?.editManager) {
|
||||
headerOverflowManager.setEditModeManager(dashboardManager.editManager);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[RPG Companion] Dashboard v2 initialized successfully');
|
||||
return dashboardManager;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[RPG Companion] Failed to initialize dashboard:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load dashboard template HTML
|
||||
*/
|
||||
async function loadDashboardTemplate() {
|
||||
try {
|
||||
// Try to load from dashboardTemplate.html
|
||||
const html = await renderExtensionTemplateAsync(extensionName, 'src/systems/dashboard/dashboardTemplate');
|
||||
return html;
|
||||
} catch (error) {
|
||||
console.warn('[RPG Companion] Could not load dashboard template, using inline HTML');
|
||||
// Fallback to inline template
|
||||
return getInlineDashboardTemplate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create dashboard container div
|
||||
*/
|
||||
function createDashboardContainer(dashboardHtml) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = dashboardHtml;
|
||||
return wrapper.firstElementChild;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get inline dashboard template (fallback)
|
||||
*/
|
||||
function getInlineDashboardTemplate() {
|
||||
return `
|
||||
<div id="rpg-dashboard-container" class="rpg-dashboard-container">
|
||||
<div class="rpg-dashboard-header">
|
||||
<div class="rpg-dashboard-header-left">
|
||||
<div id="rpg-dashboard-tabs" class="rpg-dashboard-tabs"></div>
|
||||
</div>
|
||||
<div class="rpg-dashboard-header-right">
|
||||
<button id="rpg-dashboard-reset-layout" class="rpg-dashboard-btn rpg-reset-layout-btn" title="Reset to Default Layout">
|
||||
<i class="fa-solid fa-rotate-left"></i>
|
||||
</button>
|
||||
<button id="rpg-dashboard-auto-layout" class="rpg-dashboard-btn rpg-auto-layout-btn" title="Auto-Arrange Widgets">
|
||||
<i class="fa-solid fa-table-cells-large"></i>
|
||||
</button>
|
||||
<button id="rpg-dashboard-edit-mode" class="rpg-dashboard-btn rpg-edit-mode-btn" title="Toggle Edit Mode">
|
||||
<i class="fa-solid fa-pen-to-square"></i>
|
||||
</button>
|
||||
<button id="rpg-dashboard-add-widget" class="rpg-dashboard-btn rpg-add-widget-btn" style="display: none;" title="Add Widget">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
</button>
|
||||
<button id="rpg-dashboard-export-layout" class="rpg-dashboard-btn rpg-export-btn" style="display: none;" title="Export Layout">
|
||||
<i class="fa-solid fa-download"></i>
|
||||
</button>
|
||||
<button id="rpg-dashboard-import-layout" class="rpg-dashboard-btn rpg-import-btn" style="display: none;" title="Import Layout">
|
||||
<i class="fa-solid fa-upload"></i>
|
||||
</button>
|
||||
<input type="file" id="rpg-dashboard-import-file" accept=".json" style="display: none;" />
|
||||
</div>
|
||||
</div>
|
||||
<div id="rpg-dashboard-grid" class="rpg-dashboard-grid" data-edit-mode="false"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all available widgets
|
||||
*/
|
||||
function registerAllWidgets(registry, dependencies) {
|
||||
console.log('[RPG Companion] Registering widgets...');
|
||||
|
||||
// User modular widgets
|
||||
registerUserInfoWidget(registry, dependencies);
|
||||
registerUserStatsWidget(registry, dependencies);
|
||||
registerUserMoodWidget(registry, dependencies);
|
||||
registerUserAttributesWidget(registry, dependencies);
|
||||
|
||||
// Scene info widgets
|
||||
registerCalendarWidget(registry, dependencies);
|
||||
registerWeatherWidget(registry, dependencies);
|
||||
registerTemperatureWidget(registry, dependencies);
|
||||
registerClockWidget(registry, dependencies);
|
||||
registerLocationWidget(registry, dependencies);
|
||||
registerRecentEventsWidget(registry, dependencies);
|
||||
registerSceneInfoWidget(registry, dependencies); // Combined multi-view widget
|
||||
|
||||
// Social widgets
|
||||
registerPresentCharactersWidget(registry, dependencies);
|
||||
|
||||
// Inventory widget
|
||||
registerInventoryWidget(registry, dependencies);
|
||||
|
||||
// Quest widget
|
||||
registerQuestsWidget(registry, dependencies);
|
||||
|
||||
console.log(`[RPG Companion] Registered ${registry.getAll().length} widgets`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up dashboard event listeners
|
||||
*/
|
||||
function setupDashboardEventListeners(dependencies) {
|
||||
// Reset layout button
|
||||
const resetLayoutBtn = document.querySelector('#rpg-dashboard-reset-layout');
|
||||
if (resetLayoutBtn) {
|
||||
resetLayoutBtn.addEventListener('click', async () => {
|
||||
if (dashboardManager) {
|
||||
const confirmed = await showConfirmDialog({
|
||||
title: 'Reset Layout?',
|
||||
message: 'This will remove all widgets and reload the default layout. This action cannot be undone.',
|
||||
variant: 'danger',
|
||||
confirmText: 'Reset',
|
||||
cancelText: 'Cancel'
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
console.log('[RPG Companion] Reset layout button clicked');
|
||||
dashboardManager.resetLayout();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-layout button
|
||||
const autoLayoutBtn = document.querySelector('#rpg-dashboard-auto-layout');
|
||||
if (autoLayoutBtn) {
|
||||
autoLayoutBtn.addEventListener('click', async () => {
|
||||
if (dashboardManager) {
|
||||
const confirmed = await showConfirmDialog({
|
||||
title: 'Auto-Arrange All Widgets?',
|
||||
message: 'This will reorganize all widgets across all tabs and may change their positions. This action cannot be undone.',
|
||||
variant: 'warning',
|
||||
confirmText: 'Auto-Arrange',
|
||||
cancelText: 'Cancel'
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
dashboardManager.autoLayoutWidgets();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sort Tab button (layout current tab only)
|
||||
const sortTabBtn = document.querySelector('#rpg-dashboard-sort-tab');
|
||||
if (sortTabBtn) {
|
||||
sortTabBtn.addEventListener('click', () => {
|
||||
if (dashboardManager) {
|
||||
console.log('[RPG Companion] Sort tab button clicked');
|
||||
dashboardManager.autoLayoutCurrentTab();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Edit mode toggle
|
||||
const editModeBtn = document.querySelector('#rpg-dashboard-edit-mode');
|
||||
if (editModeBtn) {
|
||||
editModeBtn.addEventListener('click', () => {
|
||||
if (dashboardManager && dashboardManager.editManager) {
|
||||
console.log('[RPG Companion] Edit button clicked');
|
||||
dashboardManager.editManager.toggleEditMode();
|
||||
// Refresh header overflow menu to reflect edit mode button visibility changes
|
||||
if (headerOverflowManager) {
|
||||
setTimeout(() => headerOverflowManager.refresh(), 50);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Lock/unlock widgets button
|
||||
const lockWidgetsBtn = document.querySelector('#rpg-dashboard-lock-widgets');
|
||||
if (lockWidgetsBtn) {
|
||||
lockWidgetsBtn.addEventListener('click', () => {
|
||||
if (dashboardManager && dashboardManager.editManager) {
|
||||
console.log('[RPG Companion] Lock button clicked');
|
||||
dashboardManager.editManager.toggleLock();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Tracker Settings button (open tracker editor modal)
|
||||
const trackerSettingsBtn = document.querySelector('#rpg-dashboard-tracker-settings');
|
||||
if (trackerSettingsBtn) {
|
||||
trackerSettingsBtn.addEventListener('click', () => {
|
||||
console.log('[RPG Companion] Tracker Settings button clicked');
|
||||
// Trigger the tracker editor button from main UI
|
||||
const trackerEditorBtn = document.getElementById('rpg-open-tracker-editor');
|
||||
if (trackerEditorBtn) {
|
||||
trackerEditorBtn.click();
|
||||
} else {
|
||||
console.warn('[RPG Companion] Tracker editor button not found');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Done button (exit edit mode)
|
||||
const doneBtn = document.querySelector('#rpg-dashboard-done-edit');
|
||||
if (doneBtn) {
|
||||
doneBtn.addEventListener('click', () => {
|
||||
if (dashboardManager && dashboardManager.editManager) {
|
||||
console.log('[RPG Companion] Done button clicked');
|
||||
dashboardManager.editManager.exitEditMode(true); // Save changes
|
||||
// Refresh header overflow menu to reflect edit mode button visibility changes
|
||||
if (headerOverflowManager) {
|
||||
setTimeout(() => headerOverflowManager.refresh(), 50);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add widget button - supports both desktop click and mobile touch
|
||||
const addWidgetBtn = document.querySelector('#rpg-dashboard-add-widget');
|
||||
if (addWidgetBtn) {
|
||||
// Use pointerdown for universal desktop/mobile support
|
||||
const openAddWidget = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (dashboardManager) {
|
||||
showAddWidgetDialog(dashboardManager);
|
||||
}
|
||||
};
|
||||
|
||||
// Listen to both click (desktop) and pointerdown (mobile) for maximum compatibility
|
||||
addWidgetBtn.addEventListener('click', openAddWidget);
|
||||
addWidgetBtn.addEventListener('pointerdown', openAddWidget, { once: true });
|
||||
}
|
||||
|
||||
// Export layout button
|
||||
const exportBtn = document.querySelector('#rpg-dashboard-export-layout');
|
||||
if (exportBtn) {
|
||||
exportBtn.addEventListener('click', () => {
|
||||
if (dashboardManager) {
|
||||
dashboardManager.exportLayout();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Import layout button - trigger file input on click
|
||||
const importBtn = document.querySelector('#rpg-dashboard-import-layout');
|
||||
const importFile = document.querySelector('#rpg-dashboard-import-file');
|
||||
|
||||
if (importBtn && importFile) {
|
||||
console.log('[RPG Companion] Import button and file input initialized');
|
||||
|
||||
// Trigger file picker on button click
|
||||
importBtn.addEventListener('click', (e) => {
|
||||
console.log('[RPG Companion] Import button clicked, triggering file picker');
|
||||
console.log('[RPG Companion] File input element:', importFile);
|
||||
console.log('[RPG Companion] File input visible:', importFile.offsetParent !== null);
|
||||
|
||||
try {
|
||||
// Direct click works on desktop and mobile when input is properly positioned
|
||||
importFile.click();
|
||||
console.log('[RPG Companion] File input click() called successfully');
|
||||
} catch (err) {
|
||||
console.error('[RPG Companion] Error triggering file input:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle file selection
|
||||
importFile.addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
console.log('[RPG Companion] File input change event fired');
|
||||
console.log('[RPG Companion] Selected file:', file);
|
||||
|
||||
if (file) {
|
||||
if (dashboardManager) {
|
||||
console.log('[RPG Companion] Importing layout from:', file.name);
|
||||
dashboardManager.importLayout(file);
|
||||
} else {
|
||||
console.error('[RPG Companion] Dashboard manager not available');
|
||||
}
|
||||
importFile.value = ''; // Reset file input
|
||||
} else {
|
||||
console.warn('[RPG Companion] No file selected');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error('[RPG Companion] Import button or file input not found!', {
|
||||
importBtn,
|
||||
importFile
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show add widget dialog
|
||||
*/
|
||||
function showAddWidgetDialog(manager) {
|
||||
// Get all available widgets
|
||||
const registry = manager.registry;
|
||||
const widgets = registry.getAll();
|
||||
|
||||
// Create widget cards HTML
|
||||
// Note: registry.getAll() returns [{type, definition}, ...] not [[type, definition], ...]
|
||||
const widgetCardsHtml = widgets.map(({type, definition}) => `
|
||||
<div class="rpg-widget-card" data-widget-type="${type}">
|
||||
<div class="rpg-widget-card-icon">${definition.icon}</div>
|
||||
<div class="rpg-widget-card-name">${definition.name}</div>
|
||||
<div class="rpg-widget-card-description">${definition.description}</div>
|
||||
<button class="rpg-widget-card-add" data-widget-type="${type}">
|
||||
<i class="fa-solid fa-plus"></i> Add
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Show modal
|
||||
const modal = document.querySelector('#rpg-add-widget-modal');
|
||||
if (!modal) {
|
||||
console.warn('[RPG Companion] Add widget modal not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// CRITICAL: Move modal to document.body on first use to escape panel constraints
|
||||
// The panel has transform in its transition which creates a containing block,
|
||||
// constraining position:fixed children to the panel instead of viewport
|
||||
if (modal.parentElement?.id !== 'document-body-modals') {
|
||||
// Create container for modals at body level (only once)
|
||||
let bodyModalsContainer = document.getElementById('document-body-modals');
|
||||
if (!bodyModalsContainer) {
|
||||
bodyModalsContainer = document.createElement('div');
|
||||
bodyModalsContainer.id = 'document-body-modals';
|
||||
bodyModalsContainer.style.cssText = 'position: fixed; inset: 0; pointer-events: none; z-index: 10000; display: flex; align-items: center; justify-content: center;';
|
||||
document.body.appendChild(bodyModalsContainer);
|
||||
}
|
||||
bodyModalsContainer.appendChild(modal);
|
||||
console.log('[RPG Companion] Moved Add Widget modal to document.body for proper viewport positioning');
|
||||
|
||||
// Apply theme-aware solid background since modal is now outside panel
|
||||
const panel = document.querySelector('.rpg-panel');
|
||||
const modalContent = modal.querySelector('.rpg-modal-content');
|
||||
if (modalContent) {
|
||||
if (panel && panel.dataset.theme) {
|
||||
modalContent.dataset.theme = panel.dataset.theme;
|
||||
} else if (panel) {
|
||||
// For default theme: read computed colors from panel and apply as solid (1.0 opacity)
|
||||
const computedStyle = window.getComputedStyle(panel);
|
||||
const bgColor = computedStyle.getPropertyValue('--rpg-bg').trim();
|
||||
const accentColor = computedStyle.getPropertyValue('--rpg-accent').trim();
|
||||
|
||||
// Convert rgba with 0.9 opacity to 1.0 opacity
|
||||
const solidBg = bgColor.replace(/rgba\(([^)]+),\s*[\d.]+\)/, 'rgba($1, 1)');
|
||||
const solidAccent = accentColor.replace(/rgba\(([^)]+),\s*[\d.]+\)/, 'rgba($1, 1)');
|
||||
|
||||
modalContent.style.background = `linear-gradient(135deg, ${solidAccent} 0%, ${solidBg} 100%)`;
|
||||
modalContent.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const widgetSelector = modal.querySelector('#rpg-widget-selector');
|
||||
if (widgetSelector) {
|
||||
widgetSelector.innerHTML = widgetCardsHtml;
|
||||
|
||||
// Attach add button handlers
|
||||
widgetSelector.querySelectorAll('.rpg-widget-card-add').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const widgetType = btn.dataset.widgetType;
|
||||
// Use activeTabId property instead of getActiveTabId() method
|
||||
const activeTab = manager.tabManager.activeTabId;
|
||||
|
||||
manager.addWidget(widgetType, activeTab);
|
||||
hideModal('rpg-add-widget-modal');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Show modal with proper pointer events (parent has pointer-events: none)
|
||||
modal.style.display = 'flex';
|
||||
modal.style.pointerEvents = 'auto';
|
||||
|
||||
// Set up modal close handlers
|
||||
modal.querySelectorAll('[data-close="add-widget"]').forEach(btn => {
|
||||
btn.onclick = () => hideModal('rpg-add-widget-modal');
|
||||
});
|
||||
|
||||
// Close on backdrop click
|
||||
modal.onclick = (e) => {
|
||||
if (e.target === modal) {
|
||||
hideModal('rpg-add-widget-modal');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide modal by ID
|
||||
*/
|
||||
function hideModal(modalId) {
|
||||
const modal = document.querySelector(`#${modalId}`);
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default dashboard layout
|
||||
*/
|
||||
export function createDefaultLayout(manager) {
|
||||
if (!manager) {
|
||||
console.warn('[RPG Companion] Cannot create default layout - manager not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[RPG Companion] Creating default dashboard layout with modular widgets...');
|
||||
|
||||
// Use activeTabId property instead of getActiveTabId() method
|
||||
const mainTab = manager.tabManager.activeTabId;
|
||||
|
||||
// Add modular user widgets
|
||||
// Row 0: User Info (avatar, name, level) - full width
|
||||
manager.addWidget('userInfo', mainTab, { x: 0, y: 0, w: 2, h: 1 });
|
||||
|
||||
// Row 1-2: User Stats (health/energy bars) - full width
|
||||
manager.addWidget('userStats', mainTab, { x: 0, y: 1, w: 2, h: 2 });
|
||||
|
||||
// Row 3-4: User Mood (left) + User Attributes (right)
|
||||
manager.addWidget('userMood', mainTab, { x: 0, y: 3, w: 1, h: 1 });
|
||||
manager.addWidget('userAttributes', mainTab, { x: 1, y: 3, w: 1, h: 2 });
|
||||
|
||||
// Row 5-6: Calendar (left) + Weather (right)
|
||||
manager.addWidget('calendar', mainTab, { x: 0, y: 5, w: 1, h: 2 });
|
||||
manager.addWidget('weather', mainTab, { x: 1, y: 5, w: 1, h: 2 });
|
||||
|
||||
// Row 7-8: Temperature (left) + Clock (right)
|
||||
manager.addWidget('temperature', mainTab, { x: 0, y: 7, w: 1, h: 2 });
|
||||
manager.addWidget('clock', mainTab, { x: 1, y: 7, w: 1, h: 2 });
|
||||
|
||||
// Row 9-10: Location (full width)
|
||||
manager.addWidget('location', mainTab, { x: 0, y: 9, w: 2, h: 2 });
|
||||
|
||||
// Row 11-13: Present Characters (full width)
|
||||
manager.addWidget('presentCharacters', mainTab, { x: 0, y: 11, w: 2, h: 3 });
|
||||
|
||||
console.log('[RPG Companion] Default layout created with modular widgets');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh all widgets (called after data updates)
|
||||
*/
|
||||
export function refreshDashboard() {
|
||||
if (dashboardManager && dashboardManager.widgets) {
|
||||
// Re-render all active widgets by accessing the widgets Map directly
|
||||
dashboardManager.widgets.forEach((widgetData, widgetId) => {
|
||||
// Get the widget definition from registry
|
||||
const definition = dashboardManager.registry.get(widgetData.widget.type);
|
||||
if (definition && widgetData.element) {
|
||||
// Re-render the widget content
|
||||
dashboardManager.renderWidgetContent(widgetData.element, widgetData.widget, definition);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy dashboard instance
|
||||
*/
|
||||
export function destroyDashboard() {
|
||||
if (dashboardManager) {
|
||||
console.log('[RPG Companion] Destroying dashboard...');
|
||||
// Clean up would go here
|
||||
dashboardManager = null;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,165 +0,0 @@
|
||||
<!-- RPG Companion v2 Dashboard -->
|
||||
<div id="rpg-dashboard-container" class="rpg-dashboard-container">
|
||||
<!-- Dashboard Header Controls -->
|
||||
<div class="rpg-dashboard-header">
|
||||
<div class="rpg-dashboard-header-left">
|
||||
<!-- Tab Navigation Wrapper (with scroll controls) -->
|
||||
<div class="rpg-tab-nav-wrapper">
|
||||
<!-- Tabs container (will be populated by TabManager) -->
|
||||
<div id="rpg-dashboard-tabs" class="rpg-dashboard-tabs"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rpg-dashboard-header-right" id="rpg-dashboard-header-right">
|
||||
<!-- Priority buttons (always visible) -->
|
||||
<button id="rpg-dashboard-done-edit" class="rpg-dashboard-btn rpg-done-edit-btn rpg-priority-btn" style="display: none;" title="Exit Edit Widget Mode" aria-label="Done editing">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</button>
|
||||
|
||||
<button id="rpg-dashboard-lock-widgets" class="rpg-dashboard-btn rpg-lock-widgets-btn rpg-priority-btn" title="Unlock Widgets" aria-label="Lock/Unlock widgets">
|
||||
<i class="fa-solid fa-lock"></i>
|
||||
</button>
|
||||
|
||||
<button id="rpg-dashboard-edit-mode" class="rpg-dashboard-btn rpg-edit-mode-btn rpg-priority-btn" title="Enter Edit Widget Mode" aria-label="Edit mode">
|
||||
<i class="fa-solid fa-pen-to-square"></i>
|
||||
</button>
|
||||
|
||||
<button id="rpg-dashboard-tracker-settings" class="rpg-dashboard-btn rpg-tracker-settings-btn rpg-priority-btn" title="Tracker Settings - Customize fields, names, and AI instructions" aria-label="Tracker settings">
|
||||
<i class="fa-solid fa-sliders"></i>
|
||||
</button>
|
||||
|
||||
<!-- Full mode buttons (hidden on overflow) -->
|
||||
<button id="rpg-dashboard-reset-layout" class="rpg-dashboard-btn rpg-reset-layout-btn rpg-overflow-btn" title="Reset to Default Layout" aria-label="Reset layout">
|
||||
<i class="fa-solid fa-rotate-left"></i>
|
||||
</button>
|
||||
|
||||
<button id="rpg-dashboard-auto-layout" class="rpg-dashboard-btn rpg-auto-layout-btn rpg-overflow-btn" title="Auto-Arrange All Widgets" aria-label="Auto-arrange all">
|
||||
<i class="fa-solid fa-table-cells-large"></i>
|
||||
</button>
|
||||
|
||||
<button id="rpg-dashboard-sort-tab" class="rpg-dashboard-btn rpg-sort-tab-btn rpg-overflow-btn" title="Sort Current Tab" aria-label="Sort tab">
|
||||
<i class="fa-solid fa-arrow-down-short-wide"></i>
|
||||
</button>
|
||||
|
||||
<button id="rpg-dashboard-add-widget" class="rpg-dashboard-btn rpg-add-widget-btn rpg-overflow-btn rpg-menu-only-btn" style="display: none;" title="Add Widget" aria-label="Add widget">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
</button>
|
||||
|
||||
<button id="rpg-dashboard-export-layout" class="rpg-dashboard-btn rpg-export-btn rpg-overflow-btn rpg-menu-only-btn" style="display: none;" title="Export Layout" aria-label="Export layout">
|
||||
<i class="fa-solid fa-download"></i>
|
||||
</button>
|
||||
|
||||
<button id="rpg-dashboard-import-layout" class="rpg-dashboard-btn rpg-import-btn rpg-overflow-btn rpg-menu-only-btn" style="display: none;" title="Import Layout" aria-label="Import layout">
|
||||
<i class="fa-solid fa-upload"></i>
|
||||
</button>
|
||||
|
||||
<!-- Overflow Menu Button (⋮) - shown in overflow mode -->
|
||||
<button id="rpg-dashboard-overflow-menu" class="rpg-dashboard-btn rpg-overflow-menu-btn" style="display: none;" title="More Options" aria-label="More options" aria-haspopup="true" aria-expanded="false">
|
||||
<i class="fa-solid fa-ellipsis-vertical"></i>
|
||||
</button>
|
||||
|
||||
<!-- Hamburger Menu Button (☰) - shown in compact mode -->
|
||||
<button id="rpg-dashboard-hamburger-menu" class="rpg-dashboard-btn rpg-hamburger-menu-btn" style="display: none;" title="Menu" aria-label="Menu" aria-haspopup="true" aria-expanded="false">
|
||||
<i class="fa-solid fa-bars"></i>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown Menu (populated dynamically) -->
|
||||
<div id="rpg-dashboard-dropdown-menu" class="rpg-dropdown-menu" role="menu" style="display: none;">
|
||||
<!-- Menu items added dynamically -->
|
||||
</div>
|
||||
|
||||
<!-- File input: visually hidden but accessible for mobile compatibility -->
|
||||
<!-- Use 1px size for better browser compatibility while keeping hidden -->
|
||||
<input type="file" id="rpg-dashboard-import-file" accept=".json" style="position: absolute; width: 1px; height: 1px; opacity: 0; overflow: hidden; z-index: -1; pointer-events: auto;" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Grid (will be populated by DashboardManager) -->
|
||||
<div id="rpg-dashboard-grid" class="rpg-dashboard-grid" data-edit-mode="false">
|
||||
<!-- Widgets will be rendered here -->
|
||||
</div>
|
||||
|
||||
<!-- Add Widget Modal -->
|
||||
<div id="rpg-add-widget-modal" class="rpg-modal" style="display: none;">
|
||||
<div class="rpg-modal-content">
|
||||
<div class="rpg-modal-header">
|
||||
<h3>
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
Add Widget
|
||||
</h3>
|
||||
<button class="rpg-modal-close" data-close="add-widget">
|
||||
<i class="fa-solid fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rpg-modal-body">
|
||||
<div class="rpg-widget-grid" id="rpg-widget-selector">
|
||||
<!-- Widget cards will be populated by DashboardManager -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rpg-modal-footer">
|
||||
<button class="rpg-btn-secondary" data-close="add-widget">
|
||||
<i class="fa-solid fa-times"></i>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Widget Configuration Modal -->
|
||||
<div id="rpg-widget-config-modal" class="rpg-modal" style="display: none;">
|
||||
<div class="rpg-modal-content">
|
||||
<div class="rpg-modal-header">
|
||||
<h3>
|
||||
<i class="fa-solid fa-gear"></i>
|
||||
Widget Settings
|
||||
</h3>
|
||||
<button class="rpg-modal-close" data-close="widget-config">
|
||||
<i class="fa-solid fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rpg-modal-body">
|
||||
<div id="rpg-widget-config-form">
|
||||
<!-- Widget config form will be populated dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rpg-modal-footer">
|
||||
<button class="rpg-btn-secondary" data-close="widget-config">
|
||||
<i class="fa-solid fa-times"></i>
|
||||
Cancel
|
||||
</button>
|
||||
<button class="rpg-btn-primary" id="rpg-widget-config-save">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Dialog Modal -->
|
||||
<div id="rpg-confirm-dialog" class="rpg-modal rpg-confirm-modal" style="display: none;">
|
||||
<div class="rpg-modal-content rpg-confirm-content">
|
||||
<div class="rpg-modal-header rpg-confirm-header">
|
||||
<div class="rpg-confirm-header-content">
|
||||
<i id="rpg-confirm-icon" class="rpg-confirm-icon"></i>
|
||||
<h3 id="rpg-confirm-title"></h3>
|
||||
</div>
|
||||
<button class="rpg-modal-close rpg-confirm-close">
|
||||
<i class="fa-solid fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rpg-modal-body rpg-confirm-body">
|
||||
<p id="rpg-confirm-message"></p>
|
||||
</div>
|
||||
|
||||
<div class="rpg-modal-footer rpg-confirm-footer">
|
||||
<button class="rpg-btn-secondary" id="rpg-confirm-cancel"></button>
|
||||
<button class="rpg-btn-primary" id="rpg-confirm-confirm"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,350 +0,0 @@
|
||||
/**
|
||||
* Default Dashboard Layout Generator
|
||||
*
|
||||
* Generates the default dashboard configuration for new users or when resetting layout.
|
||||
* Maps existing v1.x panel structure to v2.0 widget dashboard.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate default dashboard configuration
|
||||
*
|
||||
* Creates a two-tab layout optimized for 2-column side panel:
|
||||
* - "Status" tab: User stats, modular info widgets (calendar, weather, temp, clock, location), present characters
|
||||
* - "Inventory" tab: Full inventory widget
|
||||
*
|
||||
* All positions sized for 2-column grid (w: 1-2, full width = 2).
|
||||
* Layout will adapt if panel width increases to 3-4 columns.
|
||||
*
|
||||
* @returns {Object} Default dashboard configuration
|
||||
*/
|
||||
export function generateDefaultDashboard() {
|
||||
const dashboard = {
|
||||
version: 2,
|
||||
|
||||
gridConfig: {
|
||||
// Columns calculated dynamically by GridEngine (2-4 based on panel width)
|
||||
// Mobile: always 2, Desktop: 2-4 based on width
|
||||
columns: 2, // Default to 2 columns (will be recalculated on init)
|
||||
rowHeight: 5, // rem units for responsive scaling (1080p → 4K → mobile)
|
||||
gap: 0.75, // rem units (scales with screen DPI)
|
||||
snapToGrid: true,
|
||||
showGrid: true
|
||||
},
|
||||
|
||||
tabs: [
|
||||
// Tab 1: Status (User widgets only - compact and focused)
|
||||
{
|
||||
id: 'tab-status',
|
||||
name: 'Status',
|
||||
icon: 'fa-solid fa-user',
|
||||
order: 0,
|
||||
widgets: [
|
||||
// Row 0: User Info (left) + User Mood (top right in 3-col)
|
||||
{
|
||||
id: 'widget-userinfo',
|
||||
type: 'userInfo',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 2,
|
||||
h: 1,
|
||||
config: {}
|
||||
},
|
||||
{
|
||||
id: 'widget-usermood',
|
||||
type: 'userMood',
|
||||
x: 2,
|
||||
y: 0,
|
||||
w: 1,
|
||||
h: 1,
|
||||
config: {}
|
||||
},
|
||||
// Row 1-2: User Stats (health/energy bars)
|
||||
{
|
||||
id: 'widget-userstats',
|
||||
type: 'userStats',
|
||||
x: 0,
|
||||
y: 1,
|
||||
w: 2,
|
||||
h: 2,
|
||||
config: {
|
||||
statBarGradient: true
|
||||
}
|
||||
},
|
||||
// Row 3-4: User Attributes
|
||||
{
|
||||
id: 'widget-userattributes',
|
||||
type: 'userAttributes',
|
||||
x: 0,
|
||||
y: 3,
|
||||
w: 2,
|
||||
h: 2,
|
||||
config: {}
|
||||
}
|
||||
]
|
||||
},
|
||||
// Tab 2: Scene (Combined scene info widget + events + characters)
|
||||
{
|
||||
id: 'tab-scene',
|
||||
name: 'Scene',
|
||||
icon: 'fa-solid fa-map',
|
||||
order: 1,
|
||||
widgets: [
|
||||
// Row 0-1: Scene Info (combined: calendar, weather, temp, clock, location)
|
||||
{
|
||||
id: 'widget-sceneinfo',
|
||||
type: 'sceneInfo',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 2,
|
||||
h: 2,
|
||||
config: {}
|
||||
},
|
||||
// Row 2-3: Recent Events (notebook style, full width)
|
||||
{
|
||||
id: 'widget-recentevents',
|
||||
type: 'recentEvents',
|
||||
x: 0,
|
||||
y: 2,
|
||||
w: 2,
|
||||
h: 2,
|
||||
config: {
|
||||
maxEvents: 3
|
||||
}
|
||||
},
|
||||
// Row 4-7: Present Characters (full width, will expand with auto-layout)
|
||||
{
|
||||
id: 'widget-presentchars',
|
||||
type: 'presentCharacters',
|
||||
x: 0,
|
||||
y: 4,
|
||||
w: 2,
|
||||
h: 4,
|
||||
config: {
|
||||
cardLayout: 'grid',
|
||||
showThoughtBubbles: true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
// Tab 3: Inventory (Full tab for inventory system)
|
||||
{
|
||||
id: 'tab-inventory',
|
||||
name: 'Inventory',
|
||||
icon: 'fa-solid fa-bag-shopping',
|
||||
order: 2,
|
||||
widgets: [
|
||||
{
|
||||
id: 'widget-inventory',
|
||||
type: 'inventory',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 2,
|
||||
h: 6,
|
||||
config: {
|
||||
defaultSubTab: 'onPerson',
|
||||
defaultViewMode: 'list'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
// Tab 4: Quests (Full tab for quest system)
|
||||
{
|
||||
id: 'tab-quests',
|
||||
name: 'Quests',
|
||||
icon: 'fa-solid fa-scroll',
|
||||
order: 3,
|
||||
widgets: [
|
||||
{
|
||||
id: 'widget-quests',
|
||||
type: 'quests',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 2,
|
||||
h: 5,
|
||||
config: {
|
||||
defaultSubTab: 'main'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
defaultTab: 'tab-status'
|
||||
};
|
||||
|
||||
console.log('[DefaultLayout] Generated default dashboard configuration');
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate v1.x settings to v2.0 dashboard
|
||||
*
|
||||
* Converts existing hardcoded panel structure to widget-based layout.
|
||||
* Preserves user's visibility preferences and data.
|
||||
*
|
||||
* @param {Object} oldSettings - v1.x extension settings
|
||||
* @returns {Object} Migrated dashboard configuration
|
||||
*/
|
||||
export function migrateV1ToV2Dashboard(oldSettings) {
|
||||
console.log('[DefaultLayout] Migrating v1.x settings to v2.0 dashboard');
|
||||
|
||||
const dashboard = generateDefaultDashboard();
|
||||
|
||||
// Respect user's visibility preferences from v1.x
|
||||
const statusTab = dashboard.tabs[0];
|
||||
|
||||
// Check trackerConfig for field-level disabling
|
||||
const trackerConfig = oldSettings.trackerConfig;
|
||||
|
||||
// Remove userStats widget if hidden in v1.x OR all stats disabled in trackerConfig
|
||||
const allStatsDisabled = trackerConfig?.userStats?.customStats
|
||||
?.every(stat => !stat.enabled) ?? false;
|
||||
|
||||
if (!oldSettings.showUserStats || allStatsDisabled) {
|
||||
statusTab.widgets = statusTab.widgets.filter(w => w.type !== 'userStats');
|
||||
console.log('[DefaultLayout] Removed userStats widget', allStatsDisabled ? '(all stats disabled in trackerConfig)' : '(was hidden in v1.x)');
|
||||
}
|
||||
|
||||
// Remove infoBox widget if hidden in v1.x
|
||||
// Note: We keep individual info widgets (calendar, weather, etc.) even if fields are disabled
|
||||
// because widgets will show disabled state with link to Tracker Settings
|
||||
if (!oldSettings.showInfoBox) {
|
||||
statusTab.widgets = statusTab.widgets.filter(w => w.type !== 'infoBox');
|
||||
console.log('[DefaultLayout] Removed infoBox widget (was hidden in v1.x)');
|
||||
}
|
||||
|
||||
// Remove presentCharacters widget if hidden in v1.x OR thoughts disabled in trackerConfig
|
||||
const thoughtsDisabled = trackerConfig?.presentCharacters?.thoughts?.enabled === false;
|
||||
|
||||
if (!oldSettings.showCharacterThoughts || thoughtsDisabled) {
|
||||
statusTab.widgets = statusTab.widgets.filter(w => w.type !== 'presentCharacters');
|
||||
console.log('[DefaultLayout] Removed presentCharacters widget', thoughtsDisabled ? '(thoughts disabled in trackerConfig)' : '(was hidden in v1.x)');
|
||||
}
|
||||
|
||||
// Remove inventory tab if it was hidden in v1.x
|
||||
if (!oldSettings.showInventory) {
|
||||
dashboard.tabs = dashboard.tabs.filter(t => t.id !== 'tab-inventory');
|
||||
console.log('[DefaultLayout] Removed inventory tab (was hidden in v1.x)');
|
||||
}
|
||||
|
||||
// If all widgets were hidden on status tab, remove it too
|
||||
if (statusTab.widgets.length === 0) {
|
||||
dashboard.tabs = dashboard.tabs.filter(t => t.id !== 'tab-status');
|
||||
console.log('[DefaultLayout] Removed status tab (all widgets were hidden)');
|
||||
|
||||
// If we still have inventory tab, make it default
|
||||
if (dashboard.tabs.length > 0) {
|
||||
dashboard.defaultTab = dashboard.tabs[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[DefaultLayout] Migration complete - ${dashboard.tabs.length} tabs, ${dashboard.tabs.reduce((sum, t) => sum + t.widgets.length, 0)} widgets`);
|
||||
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate dashboard configuration
|
||||
*
|
||||
* Ensures dashboard config has all required fields and valid structure.
|
||||
*
|
||||
* @param {Object} dashboard - Dashboard configuration to validate
|
||||
* @returns {boolean} True if valid, false otherwise
|
||||
*/
|
||||
export function validateDashboardConfig(dashboard) {
|
||||
if (!dashboard) {
|
||||
console.error('[DefaultLayout] Dashboard config is null or undefined');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!dashboard.version) {
|
||||
console.error('[DefaultLayout] Dashboard config missing version');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!dashboard.gridConfig) {
|
||||
console.error('[DefaultLayout] Dashboard config missing gridConfig');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Array.isArray(dashboard.tabs)) {
|
||||
console.error('[DefaultLayout] Dashboard tabs is not an array');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate each tab
|
||||
for (const tab of dashboard.tabs) {
|
||||
if (!tab.id || !tab.name) {
|
||||
console.error('[DefaultLayout] Tab missing id or name:', tab);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Array.isArray(tab.widgets)) {
|
||||
console.error('[DefaultLayout] Tab widgets is not an array:', tab);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate each widget
|
||||
for (const widget of tab.widgets) {
|
||||
if (!widget.id || !widget.type) {
|
||||
console.error('[DefaultLayout] Widget missing id or type:', widget);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof widget.x !== 'number' || typeof widget.y !== 'number') {
|
||||
console.error('[DefaultLayout] Widget position invalid:', widget);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof widget.w !== 'number' || typeof widget.h !== 'number') {
|
||||
console.error('[DefaultLayout] Widget size invalid:', widget);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get widget count in dashboard
|
||||
*
|
||||
* @param {Object} dashboard - Dashboard configuration
|
||||
* @returns {number} Total number of widgets across all tabs
|
||||
*/
|
||||
export function getWidgetCount(dashboard) {
|
||||
if (!dashboard || !Array.isArray(dashboard.tabs)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return dashboard.tabs.reduce((sum, tab) => {
|
||||
return sum + (Array.isArray(tab.widgets) ? tab.widgets.length : 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find widget by ID across all tabs
|
||||
*
|
||||
* @param {Object} dashboard - Dashboard configuration
|
||||
* @param {string} widgetId - Widget ID to find
|
||||
* @returns {{tabIndex: number, widgetIndex: number, widget: Object}|null}
|
||||
*/
|
||||
export function findWidget(dashboard, widgetId) {
|
||||
if (!dashboard || !Array.isArray(dashboard.tabs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let tabIndex = 0; tabIndex < dashboard.tabs.length; tabIndex++) {
|
||||
const tab = dashboard.tabs[tabIndex];
|
||||
if (!Array.isArray(tab.widgets)) continue;
|
||||
|
||||
for (let widgetIndex = 0; widgetIndex < tab.widgets.length; widgetIndex++) {
|
||||
const widget = tab.widgets[widgetIndex];
|
||||
if (widget.id === widgetId) {
|
||||
return { tabIndex, widgetIndex, widget };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,368 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Default Layout Test</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 20px;
|
||||
color: #e94560;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
background: #16213e;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.test-section h2 {
|
||||
color: #4ecca3;
|
||||
margin-bottom: 10px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #0f3460;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
overflow-x: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.result {
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
border-left: 3px solid #4ecca3;
|
||||
background: #0f3460;
|
||||
}
|
||||
|
||||
.result.pass {
|
||||
border-color: #4ecca3;
|
||||
}
|
||||
|
||||
.result.fail {
|
||||
border-color: #e94560;
|
||||
background: #2a0f1b;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #e94560;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
margin: 5px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #d63651;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
background: #0f3460;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #4ecca3;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🏗️ Default Layout Test Suite</h1>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Test 1: Generate Default Dashboard</h2>
|
||||
<div id="test1-results"></div>
|
||||
<button onclick="test1()">Run Test 1</button>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Test 2: Validate Dashboard Config</h2>
|
||||
<div id="test2-results"></div>
|
||||
<button onclick="test2()">Run Test 2</button>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Test 3: Migrate v1.x Settings</h2>
|
||||
<div id="test3-results"></div>
|
||||
<button onclick="test3()">Run Test 3</button>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Test 4: Find Widget Utility</h2>
|
||||
<div id="test4-results"></div>
|
||||
<button onclick="test4()">Run Test 4</button>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Generated Dashboard JSON</h2>
|
||||
<pre id="dashboard-json"></pre>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Dashboard Statistics</h2>
|
||||
<div id="stats" class="stats"></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<button onclick="runAllTests()">🔄 Run All Tests</button>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
generateDefaultDashboard,
|
||||
migrateV1ToV2Dashboard,
|
||||
validateDashboardConfig,
|
||||
getWidgetCount,
|
||||
findWidget
|
||||
} from './defaultLayout.js';
|
||||
|
||||
let dashboard = null;
|
||||
|
||||
function pass(message) {
|
||||
return `<div class="result pass">✓ ${message}</div>`;
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
return `<div class="result fail">✗ ${message}</div>`;
|
||||
}
|
||||
|
||||
// Test 1: Generate default dashboard
|
||||
window.test1 = function() {
|
||||
const container = document.getElementById('test1-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
try {
|
||||
dashboard = generateDefaultDashboard();
|
||||
container.innerHTML += pass('Generated default dashboard');
|
||||
|
||||
if (dashboard.version === 2) {
|
||||
container.innerHTML += pass(`Dashboard version: ${dashboard.version}`);
|
||||
} else {
|
||||
container.innerHTML += fail(`Wrong version: ${dashboard.version}`);
|
||||
}
|
||||
|
||||
if (dashboard.tabs && dashboard.tabs.length === 2) {
|
||||
container.innerHTML += pass(`Generated ${dashboard.tabs.length} tabs`);
|
||||
} else {
|
||||
container.innerHTML += fail(`Wrong tab count: ${dashboard.tabs?.length || 0}`);
|
||||
}
|
||||
|
||||
const statusTab = dashboard.tabs.find(t => t.id === 'tab-status');
|
||||
if (statusTab && statusTab.widgets.length === 3) {
|
||||
container.innerHTML += pass(`Status tab has ${statusTab.widgets.length} widgets`);
|
||||
} else {
|
||||
container.innerHTML += fail(`Wrong widget count in status tab`);
|
||||
}
|
||||
|
||||
const inventoryTab = dashboard.tabs.find(t => t.id === 'tab-inventory');
|
||||
if (inventoryTab && inventoryTab.widgets.length === 1) {
|
||||
container.innerHTML += pass(`Inventory tab has ${inventoryTab.widgets.length} widget`);
|
||||
} else {
|
||||
container.innerHTML += fail(`Wrong widget count in inventory tab`);
|
||||
}
|
||||
|
||||
updateDashboardDisplay();
|
||||
updateStats();
|
||||
|
||||
} catch (error) {
|
||||
container.innerHTML += fail(`Error: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Test 2: Validate dashboard config
|
||||
window.test2 = function() {
|
||||
const container = document.getElementById('test2-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
if (!dashboard) {
|
||||
container.innerHTML += fail('No dashboard generated yet (run Test 1 first)');
|
||||
return;
|
||||
}
|
||||
|
||||
const valid = validateDashboardConfig(dashboard);
|
||||
if (valid) {
|
||||
container.innerHTML += pass('Dashboard config is valid');
|
||||
} else {
|
||||
container.innerHTML += fail('Dashboard config validation failed');
|
||||
}
|
||||
|
||||
// Test invalid configs
|
||||
const invalidConfigs = [
|
||||
{ config: null, name: 'null config' },
|
||||
{ config: {}, name: 'empty config' },
|
||||
{ config: { version: 2 }, name: 'missing gridConfig' },
|
||||
{ config: { version: 2, gridConfig: {}, tabs: 'not-array' }, name: 'tabs not array' }
|
||||
];
|
||||
|
||||
for (const test of invalidConfigs) {
|
||||
const result = validateDashboardConfig(test.config);
|
||||
if (!result) {
|
||||
container.innerHTML += pass(`Correctly rejected ${test.name}`);
|
||||
} else {
|
||||
container.innerHTML += fail(`Failed to reject ${test.name}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Test 3: Migrate v1.x settings
|
||||
window.test3 = function() {
|
||||
const container = document.getElementById('test3-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
// Simulate v1.x settings with some sections hidden
|
||||
const v1Settings = {
|
||||
showUserStats: true,
|
||||
showInfoBox: false, // Hidden
|
||||
showCharacterThoughts: true,
|
||||
showInventory: true
|
||||
};
|
||||
|
||||
try {
|
||||
const migrated = migrateV1ToV2Dashboard(v1Settings);
|
||||
container.innerHTML += pass('Migrated v1.x settings');
|
||||
|
||||
const statusTab = migrated.tabs.find(t => t.id === 'tab-status');
|
||||
const hasInfoBox = statusTab?.widgets.some(w => w.type === 'infoBox');
|
||||
|
||||
if (!hasInfoBox) {
|
||||
container.innerHTML += pass('Correctly removed hidden infoBox widget');
|
||||
} else {
|
||||
container.innerHTML += fail('Failed to remove hidden infoBox widget');
|
||||
}
|
||||
|
||||
container.innerHTML += `<div class="result">Migrated dashboard has ${migrated.tabs.length} tabs</div>`;
|
||||
container.innerHTML += `<div class="result">Status tab has ${statusTab?.widgets.length || 0} widgets</div>`;
|
||||
|
||||
} catch (error) {
|
||||
container.innerHTML += fail(`Error: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Test 4: Find widget utility
|
||||
window.test4 = function() {
|
||||
const container = document.getElementById('test4-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
if (!dashboard) {
|
||||
container.innerHTML += fail('No dashboard generated yet (run Test 1 first)');
|
||||
return;
|
||||
}
|
||||
|
||||
const found = findWidget(dashboard, 'widget-userstats');
|
||||
if (found) {
|
||||
container.innerHTML += pass(`Found widget: ${found.widget.id} at tab ${found.tabIndex}, widget ${found.widgetIndex}`);
|
||||
container.innerHTML += `<div class="result">Widget type: ${found.widget.type}</div>`;
|
||||
container.innerHTML += `<div class="result">Position: (${found.widget.x}, ${found.widget.y})</div>`;
|
||||
container.innerHTML += `<div class="result">Size: ${found.widget.w}×${found.widget.h}</div>`;
|
||||
} else {
|
||||
container.innerHTML += fail('Failed to find widget-userstats');
|
||||
}
|
||||
|
||||
const notFound = findWidget(dashboard, 'widget-nonexistent');
|
||||
if (!notFound) {
|
||||
container.innerHTML += pass('Correctly returned null for non-existent widget');
|
||||
} else {
|
||||
container.innerHTML += fail('Should return null for non-existent widget');
|
||||
}
|
||||
};
|
||||
|
||||
// Update dashboard JSON display
|
||||
function updateDashboardDisplay() {
|
||||
const jsonContainer = document.getElementById('dashboard-json');
|
||||
if (dashboard) {
|
||||
jsonContainer.textContent = JSON.stringify(dashboard, null, 2);
|
||||
} else {
|
||||
jsonContainer.textContent = '// No dashboard generated yet';
|
||||
}
|
||||
}
|
||||
|
||||
// Update stats
|
||||
function updateStats() {
|
||||
const statsContainer = document.getElementById('stats');
|
||||
|
||||
if (!dashboard) {
|
||||
statsContainer.innerHTML = '<div class="stat-box">No dashboard generated yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const widgetCount = getWidgetCount(dashboard);
|
||||
|
||||
statsContainer.innerHTML = `
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Dashboard Version</div>
|
||||
<div class="stat-value">${dashboard.version}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Total Tabs</div>
|
||||
<div class="stat-value">${dashboard.tabs.length}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Total Widgets</div>
|
||||
<div class="stat-value">${widgetCount}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Grid Columns</div>
|
||||
<div class="stat-value">${dashboard.gridConfig.columns}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Row Height</div>
|
||||
<div class="stat-value">${dashboard.gridConfig.rowHeight}px</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Default Tab</div>
|
||||
<div class="stat-value">${dashboard.defaultTab}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Run all tests
|
||||
window.runAllTests = function() {
|
||||
test1();
|
||||
setTimeout(() => test2(), 100);
|
||||
setTimeout(() => test3(), 200);
|
||||
setTimeout(() => test4(), 300);
|
||||
};
|
||||
|
||||
// Auto-run on load
|
||||
runAllTests();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,644 +0,0 @@
|
||||
/**
|
||||
* Drag-and-Drop Handler
|
||||
*
|
||||
* Handles widget dragging and repositioning with both mouse and touch support.
|
||||
* Provides visual feedback, grid snapping, and collision detection.
|
||||
*/
|
||||
|
||||
// Performance: Disable console logging (console.error still active)
|
||||
const DEBUG = false;
|
||||
const console = DEBUG ? window.console : {
|
||||
log: () => {},
|
||||
warn: () => {},
|
||||
error: window.console.error.bind(window.console)
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {Object} DragState
|
||||
* @property {HTMLElement} element - Element being dragged
|
||||
* @property {Object} widget - Widget data object
|
||||
* @property {number} startX - Initial pointer X
|
||||
* @property {number} startY - Initial pointer Y
|
||||
* @property {number} offsetX - Pointer offset from element top-left
|
||||
* @property {number} offsetY - Pointer offset from element top-left
|
||||
* @property {HTMLElement} ghost - Ghost/preview element
|
||||
* @property {boolean} isDragging - Whether drag is in progress
|
||||
*/
|
||||
|
||||
export class DragDropHandler {
|
||||
/**
|
||||
* @param {Object} gridEngine - GridEngine instance
|
||||
* @param {Object} options - Configuration options
|
||||
*/
|
||||
constructor(gridEngine, options = {}) {
|
||||
this.gridEngine = gridEngine;
|
||||
this.editManager = options.editManager || null; // Reference to EditModeManager for lock state
|
||||
this.dashboardManager = options.dashboardManager || null; // Reference to DashboardManager for cross-tab moves
|
||||
this.options = {
|
||||
showGrid: true,
|
||||
showCollisions: true,
|
||||
enableSnap: true,
|
||||
ghostOpacity: 0.5,
|
||||
touchDelay: 500, // Delay before touch drag starts (ms) - longer delay prevents accidental moves during scrolling
|
||||
mouseMoveThreshold: 5, // Pixels mouse must move before drag starts
|
||||
...options
|
||||
};
|
||||
|
||||
this.dragState = null;
|
||||
this.dragHandlers = new Map();
|
||||
this.gridOverlay = null;
|
||||
this.touchTimer = null;
|
||||
this.mouseDragPending = null; // Tracks potential mouse drag before threshold
|
||||
this.hoveredTab = null; // Currently hovered tab during drag
|
||||
|
||||
// Bound event handlers for cleanup
|
||||
this.boundMouseMove = this.onMouseMove.bind(this);
|
||||
this.boundMouseUp = this.onMouseUp.bind(this);
|
||||
this.boundTouchMove = this.onTouchMove.bind(this);
|
||||
this.boundTouchEnd = this.onTouchEnd.bind(this);
|
||||
this.boundKeyDown = this.onKeyDown.bind(this);
|
||||
this.boundPendingMouseMove = this.onPendingMouseMove.bind(this);
|
||||
this.boundPendingMouseUp = this.onPendingMouseUp.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize drag functionality on a widget element
|
||||
* @param {HTMLElement} element - Widget DOM element
|
||||
* @param {Object} widget - Widget data object
|
||||
* @param {Function} onDragEnd - Callback when drag completes (widget, newX, newY)
|
||||
* @param {Array<Object>} widgets - All widgets (for collision detection)
|
||||
*/
|
||||
initWidget(element, widget, onDragEnd, widgets = []) {
|
||||
// Store handler reference for cleanup
|
||||
const dragHandle = element.querySelector('.drag-handle') || element;
|
||||
|
||||
const mouseDownHandler = (e) => {
|
||||
if (e.button !== 0) return; // Only left mouse button
|
||||
|
||||
// Don't drag if widgets are locked
|
||||
if (this.editManager?.isWidgetsLocked()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't drag if clicking on resize handle or widget controls
|
||||
if (e.target.closest('.resize-handle') || e.target.closest('.widget-edit-controls')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't drag if clicking on interactive elements
|
||||
const interactiveElements = 'input, button, select, textarea, a, [contenteditable="true"]';
|
||||
if (e.target.closest(interactiveElements)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store pending drag info - wait for movement threshold before starting drag
|
||||
this.mouseDragPending = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
element,
|
||||
widget,
|
||||
onDragEnd,
|
||||
widgets,
|
||||
event: e
|
||||
};
|
||||
|
||||
// Add temporary listeners to detect movement or mouseup
|
||||
document.addEventListener('mousemove', this.boundPendingMouseMove);
|
||||
document.addEventListener('mouseup', this.boundPendingMouseUp);
|
||||
};
|
||||
|
||||
const touchStartHandler = (e) => {
|
||||
// Don't drag if widgets are locked
|
||||
if (this.editManager?.isWidgetsLocked()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't drag if touching resize handle or widget controls
|
||||
if (e.target.closest('.resize-handle') || e.target.closest('.widget-edit-controls')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't drag if touching interactive elements
|
||||
const interactiveElements = 'input, button, select, textarea, a, [contenteditable="true"]';
|
||||
if (e.target.closest(interactiveElements)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Delay touch drag to allow scrolling
|
||||
this.touchTimer = setTimeout(() => {
|
||||
e.preventDefault();
|
||||
this.startDrag(e.touches[0], element, widget, onDragEnd, widgets);
|
||||
}, this.options.touchDelay);
|
||||
};
|
||||
|
||||
const touchCancelHandler = () => {
|
||||
if (this.touchTimer) {
|
||||
clearTimeout(this.touchTimer);
|
||||
this.touchTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
dragHandle.addEventListener('mousedown', mouseDownHandler);
|
||||
dragHandle.addEventListener('touchstart', touchStartHandler, { passive: false });
|
||||
dragHandle.addEventListener('touchcancel', touchCancelHandler);
|
||||
dragHandle.addEventListener('touchend', touchCancelHandler);
|
||||
|
||||
// Store handlers for cleanup
|
||||
this.dragHandlers.set(element, {
|
||||
mouseDownHandler,
|
||||
touchStartHandler,
|
||||
touchCancelHandler,
|
||||
dragHandle
|
||||
});
|
||||
|
||||
// Add draggable cursor (unless locked)
|
||||
if (!this.editManager?.isWidgetsLocked()) {
|
||||
dragHandle.style.cursor = 'grab';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove drag functionality from a widget element
|
||||
* @param {HTMLElement} element - Widget DOM element
|
||||
*/
|
||||
destroyWidget(element) {
|
||||
const handlers = this.dragHandlers.get(element);
|
||||
if (!handlers) return;
|
||||
|
||||
const { dragHandle, mouseDownHandler, touchStartHandler, touchCancelHandler } = handlers;
|
||||
|
||||
dragHandle.removeEventListener('mousedown', mouseDownHandler);
|
||||
dragHandle.removeEventListener('touchstart', touchStartHandler);
|
||||
dragHandle.removeEventListener('touchcancel', touchCancelHandler);
|
||||
dragHandle.removeEventListener('touchend', touchCancelHandler);
|
||||
|
||||
this.dragHandlers.delete(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start drag operation
|
||||
* @param {MouseEvent|Touch} e - Pointer event
|
||||
* @param {HTMLElement} element - Element being dragged
|
||||
* @param {Object} widget - Widget data
|
||||
* @param {Function} onDragEnd - Callback when drag completes
|
||||
* @param {Array<Object>} widgets - All widgets (for collision detection)
|
||||
*/
|
||||
startDrag(e, element, widget, onDragEnd, widgets = []) {
|
||||
// Calculate pointer offset from element top-left
|
||||
const rect = element.getBoundingClientRect();
|
||||
const offsetX = e.clientX - rect.left;
|
||||
const offsetY = e.clientY - rect.top;
|
||||
|
||||
// Create ghost element
|
||||
const ghost = this.createGhost(element);
|
||||
|
||||
this.dragState = {
|
||||
element,
|
||||
widget: { ...widget }, // Clone widget data
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
offsetX,
|
||||
offsetY,
|
||||
ghost,
|
||||
isDragging: true,
|
||||
onDragEnd,
|
||||
widgets,
|
||||
originalX: widget.x,
|
||||
originalY: widget.y
|
||||
};
|
||||
|
||||
// Change cursor
|
||||
const dragHandle = element.querySelector('.drag-handle') || element;
|
||||
dragHandle.style.cursor = 'grabbing';
|
||||
|
||||
// Add event listeners
|
||||
document.addEventListener('mousemove', this.boundMouseMove);
|
||||
document.addEventListener('mouseup', this.boundMouseUp);
|
||||
document.addEventListener('touchmove', this.boundTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', this.boundTouchEnd);
|
||||
document.addEventListener('keydown', this.boundKeyDown);
|
||||
|
||||
// Show grid overlay if enabled
|
||||
if (this.options.showGrid) {
|
||||
this.showGridOverlay();
|
||||
}
|
||||
|
||||
// Hide original element
|
||||
element.style.opacity = '0.3';
|
||||
|
||||
console.log('[DragDrop] Started dragging widget:', widget.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse move during drag
|
||||
* @param {MouseEvent} e - Mouse event
|
||||
*/
|
||||
onMouseMove(e) {
|
||||
if (!this.dragState?.isDragging) return;
|
||||
e.preventDefault();
|
||||
this.updateDragPosition(e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle touch move during drag
|
||||
* @param {TouchEvent} e - Touch event
|
||||
*/
|
||||
onTouchMove(e) {
|
||||
if (!this.dragState?.isDragging) return;
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
this.updateDragPosition(touch.clientX, touch.clientY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse move before drag threshold is reached
|
||||
* @param {MouseEvent} e - Mouse event
|
||||
*/
|
||||
onPendingMouseMove(e) {
|
||||
if (!this.mouseDragPending) return;
|
||||
|
||||
const { startX, startY, element, widget, onDragEnd, widgets } = this.mouseDragPending;
|
||||
const deltaX = Math.abs(e.clientX - startX);
|
||||
const deltaY = Math.abs(e.clientY - startY);
|
||||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||
|
||||
// Check if movement threshold exceeded
|
||||
if (distance >= this.options.mouseMoveThreshold) {
|
||||
// Clean up pending listeners
|
||||
document.removeEventListener('mousemove', this.boundPendingMouseMove);
|
||||
document.removeEventListener('mouseup', this.boundPendingMouseUp);
|
||||
|
||||
// Start actual drag
|
||||
this.startDrag(this.mouseDragPending.event, element, widget, onDragEnd, widgets);
|
||||
this.mouseDragPending = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse up before drag threshold is reached (click, not drag)
|
||||
* @param {MouseEvent} e - Mouse event
|
||||
*/
|
||||
onPendingMouseUp(e) {
|
||||
if (!this.mouseDragPending) return;
|
||||
|
||||
// Clean up pending listeners - this was a click, not a drag
|
||||
document.removeEventListener('mousemove', this.boundPendingMouseMove);
|
||||
document.removeEventListener('mouseup', this.boundPendingMouseUp);
|
||||
this.mouseDragPending = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update drag position and visual feedback
|
||||
* @param {number} clientX - Pointer X coordinate
|
||||
* @param {number} clientY - Pointer Y coordinate
|
||||
*/
|
||||
updateDragPosition(clientX, clientY) {
|
||||
const { ghost, offsetX, offsetY, widget } = this.dragState;
|
||||
|
||||
// Position ghost at pointer
|
||||
ghost.style.left = (clientX - offsetX) + 'px';
|
||||
ghost.style.top = (clientY - offsetY) + 'px';
|
||||
|
||||
// Calculate grid position
|
||||
const containerRect = this.gridEngine.container.getBoundingClientRect();
|
||||
const relativeX = clientX - containerRect.left - offsetX;
|
||||
const relativeY = clientY - containerRect.top - offsetY;
|
||||
|
||||
// Snap to grid
|
||||
const snapped = this.gridEngine.snapToCell(relativeX, relativeY);
|
||||
|
||||
// Update widget position for collision detection
|
||||
this.dragState.widget.x = snapped.x;
|
||||
this.dragState.widget.y = snapped.y;
|
||||
|
||||
// Update grid overlay highlighting
|
||||
if (this.gridOverlay) {
|
||||
this.highlightGridCells(snapped.x, snapped.y, widget.w, widget.h);
|
||||
}
|
||||
|
||||
// Check for tab hover (for cross-tab dragging)
|
||||
this.updateTabHover(clientX, clientY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse up - end drag
|
||||
* @param {MouseEvent} e - Mouse event
|
||||
*/
|
||||
onMouseUp(e) {
|
||||
if (!this.dragState?.isDragging) return;
|
||||
e.preventDefault();
|
||||
this.endDrag();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle touch end - end drag
|
||||
* @param {TouchEvent} e - Touch event
|
||||
*/
|
||||
onTouchEnd(e) {
|
||||
if (!this.dragState?.isDragging) return;
|
||||
e.preventDefault();
|
||||
this.endDrag();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard during drag (Escape to cancel)
|
||||
* @param {KeyboardEvent} e - Keyboard event
|
||||
*/
|
||||
onKeyDown(e) {
|
||||
if (!this.dragState?.isDragging) return;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.cancelDrag();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End drag operation and commit position
|
||||
*/
|
||||
endDrag() {
|
||||
if (!this.dragState) return;
|
||||
|
||||
const { element, widget, onDragEnd, widgets, originalX, originalY } = this.dragState;
|
||||
|
||||
// Restore original element
|
||||
element.style.opacity = '1';
|
||||
|
||||
// Change cursor back
|
||||
const dragHandle = element.querySelector('.drag-handle') || element;
|
||||
dragHandle.style.cursor = 'grab';
|
||||
|
||||
// Check if dropped on a tab (cross-tab move)
|
||||
if (this.hoveredTab && this.dashboardManager) {
|
||||
const targetTabId = this.hoveredTab.dataset.tabId;
|
||||
console.log('[DragDrop] Dropped on tab:', targetTabId);
|
||||
|
||||
// Move widget to target tab
|
||||
this.dashboardManager.moveWidgetToTab(widget.id, targetTabId);
|
||||
|
||||
this.cleanup();
|
||||
console.log('[DragDrop] Widget moved to tab:', widget.id, '->', targetTabId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal grid drop - check for collision before committing
|
||||
const otherWidgets = widgets.filter(w => w.id !== widget.id);
|
||||
const collision = this.gridEngine.detectCollision(widget, otherWidgets);
|
||||
|
||||
if (collision) {
|
||||
console.log('[DragDrop] Collision detected, pushing widgets aside and reflowing');
|
||||
|
||||
// Instead of reverting, reflow all widgets to push collisions aside
|
||||
// The reflow algorithm will automatically push overlapping widgets down
|
||||
const allWidgets = [widget, ...otherWidgets];
|
||||
this.gridEngine.reflow(allWidgets);
|
||||
|
||||
console.log('[DragDrop] Reflow complete, widget at:', widget.x, widget.y);
|
||||
}
|
||||
|
||||
// Always commit the position (either the dropped position or reflowed position)
|
||||
if (onDragEnd) {
|
||||
onDragEnd(widget, widget.x, widget.y);
|
||||
}
|
||||
|
||||
this.cleanup();
|
||||
console.log('[DragDrop] Drag completed:', widget.id, `(${widget.x}, ${widget.y})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel drag operation and restore original position
|
||||
*/
|
||||
cancelDrag() {
|
||||
if (!this.dragState) return;
|
||||
|
||||
const { element } = this.dragState;
|
||||
|
||||
// Restore original element
|
||||
element.style.opacity = '1';
|
||||
|
||||
// Change cursor back
|
||||
const dragHandle = element.querySelector('.drag-handle') || element;
|
||||
dragHandle.style.cursor = 'grab';
|
||||
|
||||
this.cleanup();
|
||||
console.log('[DragDrop] Drag cancelled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup after drag ends
|
||||
*/
|
||||
cleanup() {
|
||||
// Remove ghost element
|
||||
if (this.dragState?.ghost) {
|
||||
this.dragState.ghost.remove();
|
||||
}
|
||||
|
||||
// Remove grid overlay
|
||||
this.hideGridOverlay();
|
||||
|
||||
// Clear tab hover highlight
|
||||
this.clearTabHover();
|
||||
|
||||
// Remove event listeners
|
||||
document.removeEventListener('mousemove', this.boundMouseMove);
|
||||
document.removeEventListener('mouseup', this.boundMouseUp);
|
||||
document.removeEventListener('touchmove', this.boundTouchMove);
|
||||
document.removeEventListener('touchend', this.boundTouchEnd);
|
||||
document.removeEventListener('keydown', this.boundKeyDown);
|
||||
document.removeEventListener('mousemove', this.boundPendingMouseMove);
|
||||
document.removeEventListener('mouseup', this.boundPendingMouseUp);
|
||||
|
||||
// Clear touch timer
|
||||
if (this.touchTimer) {
|
||||
clearTimeout(this.touchTimer);
|
||||
this.touchTimer = null;
|
||||
}
|
||||
|
||||
// Clear pending drag state
|
||||
this.mouseDragPending = null;
|
||||
|
||||
this.dragState = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create ghost/preview element
|
||||
* @param {HTMLElement} element - Original element
|
||||
* @returns {HTMLElement} Ghost element
|
||||
*/
|
||||
createGhost(element) {
|
||||
const ghost = element.cloneNode(true);
|
||||
ghost.style.position = 'fixed';
|
||||
ghost.style.opacity = this.options.ghostOpacity;
|
||||
ghost.style.pointerEvents = 'none';
|
||||
ghost.style.zIndex = '10000';
|
||||
ghost.style.width = element.offsetWidth + 'px';
|
||||
ghost.style.height = element.offsetHeight + 'px';
|
||||
ghost.style.transition = 'none';
|
||||
ghost.classList.add('drag-ghost');
|
||||
|
||||
document.body.appendChild(ghost);
|
||||
return ghost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show grid overlay
|
||||
*/
|
||||
showGridOverlay() {
|
||||
if (this.gridOverlay) return;
|
||||
|
||||
// Calculate actual grid height based on widget positions (returns rem)
|
||||
const widgets = this.dragState?.widgets || [];
|
||||
const gridHeightRem = this.gridEngine.calculateGridHeight(widgets);
|
||||
const gridHeightPx = this.gridEngine.remToPixels(gridHeightRem);
|
||||
|
||||
this.gridOverlay = document.createElement('div');
|
||||
this.gridOverlay.className = 'grid-overlay';
|
||||
this.gridOverlay.style.position = 'absolute';
|
||||
this.gridOverlay.style.top = '0';
|
||||
this.gridOverlay.style.left = '0';
|
||||
this.gridOverlay.style.width = '100%';
|
||||
this.gridOverlay.style.height = gridHeightPx + 'px';
|
||||
this.gridOverlay.style.pointerEvents = 'none';
|
||||
this.gridOverlay.style.zIndex = '9999';
|
||||
|
||||
this.gridEngine.container.appendChild(this.gridOverlay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide grid overlay
|
||||
*/
|
||||
hideGridOverlay() {
|
||||
if (this.gridOverlay) {
|
||||
this.gridOverlay.remove();
|
||||
this.gridOverlay = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight grid cells where widget will be placed
|
||||
* @param {number} x - Grid X coordinate
|
||||
* @param {number} y - Grid Y coordinate
|
||||
* @param {number} w - Widget width in grid units
|
||||
* @param {number} h - Widget height in grid units
|
||||
*/
|
||||
highlightGridCells(x, y, w, h) {
|
||||
if (!this.gridOverlay) return;
|
||||
|
||||
// Clear previous highlights
|
||||
this.gridOverlay.innerHTML = '';
|
||||
|
||||
// Convert rem to pixels for calculations
|
||||
const gapPx = this.gridEngine.remToPixels(this.gridEngine.gap);
|
||||
const rowHeightPx = this.gridEngine.remToPixels(this.gridEngine.rowHeight);
|
||||
|
||||
// Calculate column width in pixels
|
||||
const totalGaps = gapPx * (this.gridEngine.columns + 1);
|
||||
const colWidth = (this.gridEngine.containerWidth - totalGaps) / this.gridEngine.columns;
|
||||
|
||||
for (let row = y; row < y + h; row++) {
|
||||
for (let col = x; col < x + w; col++) {
|
||||
const cell = document.createElement('div');
|
||||
cell.style.position = 'absolute';
|
||||
cell.style.left = (col * (colWidth + gapPx) + gapPx) + 'px';
|
||||
cell.style.top = (row * (rowHeightPx + gapPx) + gapPx) + 'px';
|
||||
cell.style.width = colWidth + 'px';
|
||||
cell.style.height = rowHeightPx + 'px';
|
||||
cell.style.backgroundColor = 'rgba(78, 204, 163, 0.3)';
|
||||
cell.style.border = '2px solid rgba(78, 204, 163, 0.6)';
|
||||
cell.style.borderRadius = '4px';
|
||||
cell.style.boxSizing = 'border-box';
|
||||
|
||||
this.gridOverlay.appendChild(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tab hover state during drag
|
||||
* @param {number} clientX - Pointer X coordinate
|
||||
* @param {number} clientY - Pointer Y coordinate
|
||||
*/
|
||||
updateTabHover(clientX, clientY) {
|
||||
if (!this.dragState) return;
|
||||
|
||||
// Find tab element at pointer position
|
||||
const elementAtPoint = document.elementFromPoint(clientX, clientY);
|
||||
const tabElement = elementAtPoint?.closest('.rpg-dashboard-tab');
|
||||
|
||||
// Check if hover state changed
|
||||
if (tabElement !== this.hoveredTab) {
|
||||
// Clear previous highlight
|
||||
if (this.hoveredTab) {
|
||||
this.hoveredTab.classList.remove('drop-target');
|
||||
}
|
||||
|
||||
// Set new hover state
|
||||
this.hoveredTab = tabElement;
|
||||
|
||||
// Add highlight to new tab
|
||||
if (this.hoveredTab) {
|
||||
this.hoveredTab.classList.add('drop-target');
|
||||
console.log('[DragDrop] Hovering over tab:', this.hoveredTab.dataset.tabId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear tab hover highlight
|
||||
*/
|
||||
clearTabHover() {
|
||||
if (this.hoveredTab) {
|
||||
this.hoveredTab.classList.remove('drop-target');
|
||||
this.hoveredTab = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current drag position has collisions
|
||||
* @param {Array<Object>} widgets - Array of other widgets
|
||||
* @returns {boolean} True if collision detected
|
||||
*/
|
||||
hasCollision(widgets) {
|
||||
if (!this.dragState) return false;
|
||||
|
||||
const { widget } = this.dragState;
|
||||
|
||||
// Filter out the widget being dragged
|
||||
const otherWidgets = widgets.filter(w => w.id !== widget.id);
|
||||
|
||||
return this.gridEngine.detectCollision(widget, otherWidgets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current drag state
|
||||
* @returns {DragState|null} Current drag state or null
|
||||
*/
|
||||
getDragState() {
|
||||
return this.dragState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently dragging
|
||||
* @returns {boolean} True if drag in progress
|
||||
*/
|
||||
isDragging() {
|
||||
return this.dragState?.isDragging || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy drag handler and cleanup
|
||||
*/
|
||||
destroy() {
|
||||
// Cancel any ongoing drag
|
||||
if (this.isDragging()) {
|
||||
this.cancelDrag();
|
||||
}
|
||||
|
||||
// Remove all widget handlers
|
||||
for (const element of this.dragHandlers.keys()) {
|
||||
this.destroyWidget(element);
|
||||
}
|
||||
|
||||
this.dragHandlers.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,931 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>Drag & Drop Test (Mobile-Ready)</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
padding: 20px;
|
||||
touch-action: none; /* Prevent default touch behaviors */
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 20px;
|
||||
color: #e94560;
|
||||
font-size: clamp(20px, 5vw, 28px);
|
||||
}
|
||||
|
||||
.test-section {
|
||||
background: #16213e;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.test-section h2 {
|
||||
color: #4ecca3;
|
||||
margin-bottom: 10px;
|
||||
font-size: clamp(16px, 4vw, 18px);
|
||||
}
|
||||
|
||||
/* Grid Container */
|
||||
.grid-container {
|
||||
position: relative;
|
||||
background: #0f3460;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
min-height: 600px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Widget Styles */
|
||||
.widget {
|
||||
position: absolute;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition: box-shadow 0.2s, opacity 0.2s;
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.widget:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.widget:hover {
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.widget.dragging {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.drag-ghost {
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.widget-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.widget-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.widget-title {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.widget-position {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Control Panel */
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #e94560;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 16px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
touch-action: manipulation;
|
||||
min-height: 44px; /* iOS touch target */
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #d63651;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: #4ecca3;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
button.secondary:hover {
|
||||
background: #5edc9f;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
background: #0f3460;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #4ecca3;
|
||||
}
|
||||
|
||||
.hint {
|
||||
background: #0f3460;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hint strong {
|
||||
color: #4ecca3;
|
||||
}
|
||||
|
||||
/* Grid Overlay */
|
||||
.grid-overlay div {
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
/* Mobile optimizations */
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
button {
|
||||
flex: 1 1 calc(50% - 4px);
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.grid-container {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Event log */
|
||||
.event-log {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background: #0f3460;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.event-time {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.event-type {
|
||||
color: #4ecca3;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🎯 Drag & Drop Test (Mobile-Ready)</h1>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Draggable Widgets</h2>
|
||||
<div class="hint">
|
||||
<strong>Desktop:</strong> Click and drag widgets to move them<br>
|
||||
<strong>Mobile:</strong> Touch and hold (150ms), then drag<br>
|
||||
<strong>Keyboard:</strong> Press <kbd>Escape</kbd> to cancel drag
|
||||
</div>
|
||||
<div id="grid-container" class="grid-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Controls</h2>
|
||||
<div class="controls">
|
||||
<button onclick="addWidget()">Add Widget</button>
|
||||
<button onclick="removeWidget()">Remove Last Widget</button>
|
||||
<button onclick="reflowWidgets()" class="secondary">Reflow Grid</button>
|
||||
<button onclick="resetGrid()">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Statistics</h2>
|
||||
<div id="stats" class="stats"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Event Log</h2>
|
||||
<button onclick="clearLog()">Clear Log</button>
|
||||
<div id="event-log" class="event-log"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// GridEngine class (bundled inline)
|
||||
class GridEngine {
|
||||
constructor(config = {}) {
|
||||
this.columns = config.columns || 12;
|
||||
this.rowHeight = config.rowHeight || 80;
|
||||
this.gap = config.gap || 12;
|
||||
this.snapToGrid = config.snapToGrid !== false;
|
||||
this.containerWidth = 0;
|
||||
this.container = config.container;
|
||||
|
||||
if (this.container) {
|
||||
this.updateContainerWidth();
|
||||
}
|
||||
}
|
||||
|
||||
updateContainerWidth() {
|
||||
if (this.container) {
|
||||
this.containerWidth = this.container.offsetWidth - (this.gap * 2);
|
||||
}
|
||||
}
|
||||
|
||||
getPixelPosition(widget) {
|
||||
this.updateContainerWidth();
|
||||
const totalGaps = this.gap * (this.columns + 1);
|
||||
const colWidth = (this.containerWidth - totalGaps) / this.columns;
|
||||
|
||||
const left = widget.x * (colWidth + this.gap) + this.gap;
|
||||
const top = widget.y * (this.rowHeight + this.gap) + this.gap;
|
||||
const width = widget.w * colWidth + (widget.w - 1) * this.gap;
|
||||
const height = widget.h * this.rowHeight + (widget.h - 1) * this.gap;
|
||||
|
||||
return { left, top, width, height };
|
||||
}
|
||||
|
||||
snapToCell(pixelX, pixelY) {
|
||||
this.updateContainerWidth();
|
||||
const totalGaps = this.gap * (this.columns + 1);
|
||||
const colWidth = (this.containerWidth - totalGaps) / this.columns;
|
||||
|
||||
const x = Math.round((pixelX - this.gap) / (colWidth + this.gap));
|
||||
const y = Math.round((pixelY - this.gap) / (this.rowHeight + this.gap));
|
||||
|
||||
return {
|
||||
x: Math.max(0, Math.min(x, this.columns - 1)),
|
||||
y: Math.max(0, y)
|
||||
};
|
||||
}
|
||||
|
||||
detectCollision(widget, widgets) {
|
||||
if (!Array.isArray(widgets) || widgets.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return widgets.some(other => {
|
||||
if (other.id === widget.id) return false;
|
||||
|
||||
const noIntersect = (
|
||||
widget.x + widget.w <= other.x ||
|
||||
widget.x >= other.x + other.w ||
|
||||
widget.y + widget.h <= other.y ||
|
||||
widget.y >= other.y + other.h
|
||||
);
|
||||
|
||||
return !noIntersect;
|
||||
});
|
||||
}
|
||||
|
||||
reflow(widgets) {
|
||||
const sorted = [...widgets].sort((a, b) => {
|
||||
if (a.y !== b.y) return a.y - b.y;
|
||||
return a.x - b.x;
|
||||
});
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
while (this.detectCollision(sorted[i], sorted.slice(0, i))) {
|
||||
sorted[i].y++;
|
||||
}
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
validateWidget(widget) {
|
||||
if (typeof widget.x !== 'number' || typeof widget.y !== 'number') {
|
||||
return false;
|
||||
}
|
||||
if (typeof widget.w !== 'number' || typeof widget.h !== 'number') {
|
||||
return false;
|
||||
}
|
||||
if (widget.x < 0 || widget.x + widget.w > this.columns) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
calculateGridHeight(widgets) {
|
||||
if (!Array.isArray(widgets) || widgets.length === 0) {
|
||||
return this.rowHeight + this.gap * 2;
|
||||
}
|
||||
|
||||
const maxY = Math.max(...widgets.map(w => w.y + w.h));
|
||||
return maxY * (this.rowHeight + this.gap) + this.gap;
|
||||
}
|
||||
}
|
||||
|
||||
// DragDropHandler class (bundled inline)
|
||||
class DragDropHandler {
|
||||
constructor(gridEngine, options = {}) {
|
||||
this.gridEngine = gridEngine;
|
||||
this.options = {
|
||||
showGrid: true,
|
||||
showCollisions: true,
|
||||
enableSnap: true,
|
||||
ghostOpacity: 0.5,
|
||||
touchDelay: 150,
|
||||
...options
|
||||
};
|
||||
|
||||
this.dragState = null;
|
||||
this.dragHandlers = new Map();
|
||||
this.gridOverlay = null;
|
||||
this.touchTimer = null;
|
||||
|
||||
this.boundMouseMove = this.onMouseMove.bind(this);
|
||||
this.boundMouseUp = this.onMouseUp.bind(this);
|
||||
this.boundTouchMove = this.onTouchMove.bind(this);
|
||||
this.boundTouchEnd = this.onTouchEnd.bind(this);
|
||||
this.boundKeyDown = this.onKeyDown.bind(this);
|
||||
}
|
||||
|
||||
initWidget(element, widget, onDragEnd) {
|
||||
const dragHandle = element.querySelector('.drag-handle') || element;
|
||||
|
||||
const mouseDownHandler = (e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
this.startDrag(e, element, widget, onDragEnd);
|
||||
};
|
||||
|
||||
const touchStartHandler = (e) => {
|
||||
this.touchTimer = setTimeout(() => {
|
||||
e.preventDefault();
|
||||
this.startDrag(e.touches[0], element, widget, onDragEnd);
|
||||
}, this.options.touchDelay);
|
||||
};
|
||||
|
||||
const touchCancelHandler = () => {
|
||||
if (this.touchTimer) {
|
||||
clearTimeout(this.touchTimer);
|
||||
this.touchTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
dragHandle.addEventListener('mousedown', mouseDownHandler);
|
||||
dragHandle.addEventListener('touchstart', touchStartHandler, { passive: false });
|
||||
dragHandle.addEventListener('touchcancel', touchCancelHandler);
|
||||
dragHandle.addEventListener('touchend', touchCancelHandler);
|
||||
|
||||
this.dragHandlers.set(element, {
|
||||
mouseDownHandler,
|
||||
touchStartHandler,
|
||||
touchCancelHandler,
|
||||
dragHandle
|
||||
});
|
||||
|
||||
dragHandle.style.cursor = 'grab';
|
||||
}
|
||||
|
||||
destroyWidget(element) {
|
||||
const handlers = this.dragHandlers.get(element);
|
||||
if (!handlers) return;
|
||||
|
||||
const { dragHandle, mouseDownHandler, touchStartHandler, touchCancelHandler } = handlers;
|
||||
|
||||
dragHandle.removeEventListener('mousedown', mouseDownHandler);
|
||||
dragHandle.removeEventListener('touchstart', touchStartHandler);
|
||||
dragHandle.removeEventListener('touchcancel', touchCancelHandler);
|
||||
dragHandle.removeEventListener('touchend', touchCancelHandler);
|
||||
|
||||
this.dragHandlers.delete(element);
|
||||
}
|
||||
|
||||
startDrag(e, element, widget, onDragEnd) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const offsetX = e.clientX - rect.left;
|
||||
const offsetY = e.clientY - rect.top;
|
||||
|
||||
const ghost = this.createGhost(element);
|
||||
|
||||
this.dragState = {
|
||||
element,
|
||||
widget: { ...widget },
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
offsetX,
|
||||
offsetY,
|
||||
ghost,
|
||||
isDragging: true,
|
||||
onDragEnd
|
||||
};
|
||||
|
||||
const dragHandle = element.querySelector('.drag-handle') || element;
|
||||
dragHandle.style.cursor = 'grabbing';
|
||||
|
||||
document.addEventListener('mousemove', this.boundMouseMove);
|
||||
document.addEventListener('mouseup', this.boundMouseUp);
|
||||
document.addEventListener('touchmove', this.boundTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', this.boundTouchEnd);
|
||||
document.addEventListener('keydown', this.boundKeyDown);
|
||||
|
||||
if (this.options.showGrid) {
|
||||
this.showGridOverlay();
|
||||
}
|
||||
|
||||
element.style.opacity = '0.3';
|
||||
element.classList.add('dragging');
|
||||
|
||||
logEvent('Drag Start', { id: widget.id, x: widget.x, y: widget.y });
|
||||
}
|
||||
|
||||
onMouseMove(e) {
|
||||
if (!this.dragState?.isDragging) return;
|
||||
e.preventDefault();
|
||||
this.updateDragPosition(e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
onTouchMove(e) {
|
||||
if (!this.dragState?.isDragging) return;
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
this.updateDragPosition(touch.clientX, touch.clientY);
|
||||
}
|
||||
|
||||
updateDragPosition(clientX, clientY) {
|
||||
const { ghost, offsetX, offsetY, widget } = this.dragState;
|
||||
|
||||
ghost.style.left = (clientX - offsetX) + 'px';
|
||||
ghost.style.top = (clientY - offsetY) + 'px';
|
||||
|
||||
const containerRect = this.gridEngine.container.getBoundingClientRect();
|
||||
const relativeX = clientX - containerRect.left - offsetX;
|
||||
const relativeY = clientY - containerRect.top - offsetY;
|
||||
|
||||
const snapped = this.gridEngine.snapToCell(relativeX, relativeY);
|
||||
|
||||
this.dragState.widget.x = snapped.x;
|
||||
this.dragState.widget.y = snapped.y;
|
||||
|
||||
if (this.gridOverlay) {
|
||||
this.highlightGridCells(snapped.x, snapped.y, widget.w, widget.h);
|
||||
}
|
||||
}
|
||||
|
||||
onMouseUp(e) {
|
||||
if (!this.dragState?.isDragging) return;
|
||||
e.preventDefault();
|
||||
this.endDrag();
|
||||
}
|
||||
|
||||
onTouchEnd(e) {
|
||||
if (!this.dragState?.isDragging) return;
|
||||
e.preventDefault();
|
||||
this.endDrag();
|
||||
}
|
||||
|
||||
onKeyDown(e) {
|
||||
if (!this.dragState?.isDragging) return;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.cancelDrag();
|
||||
}
|
||||
}
|
||||
|
||||
endDrag() {
|
||||
if (!this.dragState) return;
|
||||
|
||||
const { element, widget, onDragEnd } = this.dragState;
|
||||
|
||||
element.style.opacity = '1';
|
||||
element.classList.remove('dragging');
|
||||
|
||||
const dragHandle = element.querySelector('.drag-handle') || element;
|
||||
dragHandle.style.cursor = 'grab';
|
||||
|
||||
if (onDragEnd) {
|
||||
onDragEnd(widget, widget.x, widget.y);
|
||||
}
|
||||
|
||||
logEvent('Drag End', { id: widget.id, x: widget.x, y: widget.y });
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
cancelDrag() {
|
||||
if (!this.dragState) return;
|
||||
|
||||
const { element } = this.dragState;
|
||||
|
||||
element.style.opacity = '1';
|
||||
element.classList.remove('dragging');
|
||||
|
||||
const dragHandle = element.querySelector('.drag-handle') || element;
|
||||
dragHandle.style.cursor = 'grab';
|
||||
|
||||
logEvent('Drag Cancelled', null);
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this.dragState?.ghost) {
|
||||
this.dragState.ghost.remove();
|
||||
}
|
||||
|
||||
this.hideGridOverlay();
|
||||
|
||||
document.removeEventListener('mousemove', this.boundMouseMove);
|
||||
document.removeEventListener('mouseup', this.boundMouseUp);
|
||||
document.removeEventListener('touchmove', this.boundTouchMove);
|
||||
document.removeEventListener('touchend', this.boundTouchEnd);
|
||||
document.removeEventListener('keydown', this.boundKeyDown);
|
||||
|
||||
if (this.touchTimer) {
|
||||
clearTimeout(this.touchTimer);
|
||||
this.touchTimer = null;
|
||||
}
|
||||
|
||||
this.dragState = null;
|
||||
}
|
||||
|
||||
createGhost(element) {
|
||||
const ghost = element.cloneNode(true);
|
||||
ghost.style.position = 'fixed';
|
||||
ghost.style.opacity = this.options.ghostOpacity;
|
||||
ghost.style.pointerEvents = 'none';
|
||||
ghost.style.zIndex = '10000';
|
||||
ghost.style.width = element.offsetWidth + 'px';
|
||||
ghost.style.height = element.offsetHeight + 'px';
|
||||
ghost.style.transition = 'none';
|
||||
ghost.classList.add('drag-ghost');
|
||||
|
||||
document.body.appendChild(ghost);
|
||||
return ghost;
|
||||
}
|
||||
|
||||
showGridOverlay() {
|
||||
if (this.gridOverlay) return;
|
||||
|
||||
this.gridOverlay = document.createElement('div');
|
||||
this.gridOverlay.className = 'grid-overlay';
|
||||
this.gridOverlay.style.position = 'absolute';
|
||||
this.gridOverlay.style.top = '0';
|
||||
this.gridOverlay.style.left = '0';
|
||||
this.gridOverlay.style.width = '100%';
|
||||
this.gridOverlay.style.height = '100%';
|
||||
this.gridOverlay.style.pointerEvents = 'none';
|
||||
this.gridOverlay.style.zIndex = '9999';
|
||||
|
||||
this.gridEngine.container.appendChild(this.gridOverlay);
|
||||
}
|
||||
|
||||
hideGridOverlay() {
|
||||
if (this.gridOverlay) {
|
||||
this.gridOverlay.remove();
|
||||
this.gridOverlay = null;
|
||||
}
|
||||
}
|
||||
|
||||
highlightGridCells(x, y, w, h) {
|
||||
if (!this.gridOverlay) return;
|
||||
|
||||
this.gridOverlay.innerHTML = '';
|
||||
|
||||
const totalGaps = this.gridEngine.gap * (this.gridEngine.columns + 1);
|
||||
const colWidth = (this.gridEngine.containerWidth - totalGaps) / this.gridEngine.columns;
|
||||
|
||||
for (let row = y; row < y + h; row++) {
|
||||
for (let col = x; col < x + w; col++) {
|
||||
const cell = document.createElement('div');
|
||||
cell.style.position = 'absolute';
|
||||
cell.style.left = (col * (colWidth + this.gridEngine.gap) + this.gridEngine.gap) + 'px';
|
||||
cell.style.top = (row * (this.gridEngine.rowHeight + this.gridEngine.gap) + this.gridEngine.gap) + 'px';
|
||||
cell.style.width = colWidth + 'px';
|
||||
cell.style.height = this.gridEngine.rowHeight + 'px';
|
||||
cell.style.backgroundColor = 'rgba(78, 204, 163, 0.3)';
|
||||
cell.style.border = '2px solid rgba(78, 204, 163, 0.6)';
|
||||
cell.style.borderRadius = '4px';
|
||||
cell.style.boxSizing = 'border-box';
|
||||
|
||||
this.gridOverlay.appendChild(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasCollision(widgets) {
|
||||
if (!this.dragState) return false;
|
||||
|
||||
const { widget } = this.dragState;
|
||||
const otherWidgets = widgets.filter(w => w.id !== widget.id);
|
||||
|
||||
return this.gridEngine.detectCollision(widget, otherWidgets);
|
||||
}
|
||||
|
||||
getDragState() {
|
||||
return this.dragState;
|
||||
}
|
||||
|
||||
isDragging() {
|
||||
return this.dragState?.isDragging || false;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.isDragging()) {
|
||||
this.cancelDrag();
|
||||
}
|
||||
|
||||
for (const element of this.dragHandlers.keys()) {
|
||||
this.destroyWidget(element);
|
||||
}
|
||||
|
||||
this.dragHandlers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Test application
|
||||
let gridEngine = null;
|
||||
let dragDropHandler = null;
|
||||
let widgets = [];
|
||||
let widgetElements = new Map();
|
||||
let widgetCounter = 0;
|
||||
|
||||
const widgetTypes = [
|
||||
{ icon: '📊', name: 'Stats', color: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
|
||||
{ icon: '🎒', name: 'Inventory', color: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' },
|
||||
{ icon: '📝', name: 'Notes', color: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' },
|
||||
{ icon: '🗺️', name: 'Map', color: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)' },
|
||||
{ icon: '⚔️', name: 'Combat', color: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)' }
|
||||
];
|
||||
|
||||
function init() {
|
||||
const container = document.getElementById('grid-container');
|
||||
|
||||
gridEngine = new GridEngine({
|
||||
columns: 12,
|
||||
rowHeight: 80,
|
||||
gap: 12,
|
||||
container
|
||||
});
|
||||
|
||||
dragDropHandler = new DragDropHandler(gridEngine, {
|
||||
showGrid: true,
|
||||
ghostOpacity: 0.7,
|
||||
touchDelay: 150
|
||||
});
|
||||
|
||||
// Create initial widgets
|
||||
createInitialWidgets();
|
||||
updateStats();
|
||||
|
||||
// Handle window resize
|
||||
let resizeTimeout;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => {
|
||||
renderAllWidgets();
|
||||
updateStats();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
logEvent('Initialized', { widgets: widgets.length });
|
||||
}
|
||||
|
||||
function createInitialWidgets() {
|
||||
const initialWidgets = [
|
||||
{ x: 0, y: 0, w: 6, h: 3, type: 0 },
|
||||
{ x: 6, y: 0, w: 6, h: 2, type: 1 },
|
||||
{ x: 0, y: 3, w: 4, h: 3, type: 2 },
|
||||
{ x: 4, y: 3, w: 4, h: 3, type: 3 }
|
||||
];
|
||||
|
||||
initialWidgets.forEach(config => {
|
||||
const widget = {
|
||||
id: `widget-${widgetCounter++}`,
|
||||
x: config.x,
|
||||
y: config.y,
|
||||
w: config.w,
|
||||
h: config.h,
|
||||
type: config.type
|
||||
};
|
||||
widgets.push(widget);
|
||||
createWidgetElement(widget);
|
||||
});
|
||||
}
|
||||
|
||||
function createWidgetElement(widget) {
|
||||
const container = document.getElementById('grid-container');
|
||||
const type = widgetTypes[widget.type];
|
||||
|
||||
const element = document.createElement('div');
|
||||
element.className = 'widget';
|
||||
element.style.background = type.color;
|
||||
|
||||
element.innerHTML = `
|
||||
<div class="widget-header">
|
||||
<span class="widget-icon">${type.icon}</span>
|
||||
<span class="widget-title">${type.name}</span>
|
||||
</div>
|
||||
<div class="widget-position">Position: (${widget.x}, ${widget.y})</div>
|
||||
<div class="widget-position">Size: ${widget.w}×${widget.h}</div>
|
||||
`;
|
||||
|
||||
container.appendChild(element);
|
||||
widgetElements.set(widget.id, element);
|
||||
|
||||
// Position widget
|
||||
positionWidget(element, widget);
|
||||
|
||||
// Initialize drag
|
||||
dragDropHandler.initWidget(element, widget, (updatedWidget, newX, newY) => {
|
||||
widget.x = newX;
|
||||
widget.y = newY;
|
||||
positionWidget(element, widget);
|
||||
updateWidgetPosition(element, widget);
|
||||
updateStats();
|
||||
});
|
||||
}
|
||||
|
||||
function positionWidget(element, widget) {
|
||||
const pos = gridEngine.getPixelPosition(widget);
|
||||
element.style.left = pos.left + 'px';
|
||||
element.style.top = pos.top + 'px';
|
||||
element.style.width = pos.width + 'px';
|
||||
element.style.height = pos.height + 'px';
|
||||
}
|
||||
|
||||
function updateWidgetPosition(element, widget) {
|
||||
const posElements = element.querySelectorAll('.widget-position');
|
||||
posElements[0].textContent = `Position: (${widget.x}, ${widget.y})`;
|
||||
}
|
||||
|
||||
function renderAllWidgets() {
|
||||
widgets.forEach(widget => {
|
||||
const element = widgetElements.get(widget.id);
|
||||
if (element) {
|
||||
positionWidget(element, widget);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.addWidget = function() {
|
||||
const randomType = Math.floor(Math.random() * widgetTypes.length);
|
||||
const widget = {
|
||||
id: `widget-${widgetCounter++}`,
|
||||
x: Math.floor(Math.random() * 8),
|
||||
y: Math.floor(Math.random() * 3),
|
||||
w: 4,
|
||||
h: 2,
|
||||
type: randomType
|
||||
};
|
||||
|
||||
widgets.push(widget);
|
||||
createWidgetElement(widget);
|
||||
updateStats();
|
||||
logEvent('Widget Added', { id: widget.id });
|
||||
};
|
||||
|
||||
window.removeWidget = function() {
|
||||
if (widgets.length === 0) return;
|
||||
|
||||
const widget = widgets.pop();
|
||||
const element = widgetElements.get(widget.id);
|
||||
|
||||
if (element) {
|
||||
dragDropHandler.destroyWidget(element);
|
||||
element.remove();
|
||||
widgetElements.delete(widget.id);
|
||||
}
|
||||
|
||||
updateStats();
|
||||
logEvent('Widget Removed', { id: widget.id });
|
||||
};
|
||||
|
||||
window.reflowWidgets = function() {
|
||||
widgets = gridEngine.reflow(widgets);
|
||||
renderAllWidgets();
|
||||
updateStats();
|
||||
logEvent('Grid Reflowed', null);
|
||||
};
|
||||
|
||||
window.resetGrid = function() {
|
||||
// Clear all widgets
|
||||
widgets.forEach(widget => {
|
||||
const element = widgetElements.get(widget.id);
|
||||
if (element) {
|
||||
dragDropHandler.destroyWidget(element);
|
||||
element.remove();
|
||||
}
|
||||
});
|
||||
|
||||
widgets = [];
|
||||
widgetElements.clear();
|
||||
widgetCounter = 0;
|
||||
|
||||
// Recreate initial widgets
|
||||
createInitialWidgets();
|
||||
updateStats();
|
||||
logEvent('Grid Reset', null);
|
||||
};
|
||||
|
||||
function updateStats() {
|
||||
const container = document.getElementById('stats');
|
||||
container.innerHTML = `
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Widgets</div>
|
||||
<div class="stat-value">${widgets.length}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Grid Height</div>
|
||||
<div class="stat-value">${gridEngine.calculateGridHeight(widgets)}px</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Columns</div>
|
||||
<div class="stat-value">${gridEngine.columns}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Container Width</div>
|
||||
<div class="stat-value">${gridEngine.containerWidth}px</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function logEvent(type, data) {
|
||||
const log = document.getElementById('event-log');
|
||||
const time = new Date().toLocaleTimeString();
|
||||
const item = document.createElement('div');
|
||||
item.className = 'event-item';
|
||||
item.innerHTML = `
|
||||
<span class="event-time">${time}</span>
|
||||
<span class="event-type"> ${type}</span>
|
||||
${data ? ` - ${JSON.stringify(data)}` : ''}
|
||||
`;
|
||||
log.insertBefore(item, log.firstChild);
|
||||
|
||||
// Keep only last 50 entries
|
||||
while (log.children.length > 50) {
|
||||
log.removeChild(log.lastChild);
|
||||
}
|
||||
}
|
||||
|
||||
window.clearLog = function() {
|
||||
document.getElementById('event-log').innerHTML = '';
|
||||
};
|
||||
|
||||
// Initialize on load
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,691 +0,0 @@
|
||||
/**
|
||||
* Edit Mode Manager
|
||||
*
|
||||
* Manages dashboard edit mode state and UI.
|
||||
* Handles edit controls, widget library, and layout modifications.
|
||||
*/
|
||||
|
||||
// Performance: Disable console logging (console.error still active)
|
||||
const DEBUG = false;
|
||||
const console = DEBUG ? window.console : {
|
||||
log: () => {},
|
||||
warn: () => {},
|
||||
error: window.console.error.bind(window.console)
|
||||
};
|
||||
|
||||
import { showConfirmDialog } from './confirmDialog.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} EditModeConfig
|
||||
* @property {HTMLElement} container - Dashboard container element
|
||||
* @property {Function} onSave - Callback when saving layout
|
||||
* @property {Function} onCancel - Callback when canceling edit
|
||||
* @property {Function} onWidgetAdd - Callback when adding widget
|
||||
* @property {Function} onWidgetDelete - Callback when deleting widget
|
||||
* @property {Function} onWidgetSettings - Callback when opening widget settings
|
||||
*/
|
||||
|
||||
export class EditModeManager {
|
||||
/**
|
||||
* @param {EditModeConfig} config - Configuration object
|
||||
*/
|
||||
constructor(config) {
|
||||
this.container = config.container;
|
||||
this.editControlsOverlay = config.editControlsOverlay || null; // Overlay container for edit controls
|
||||
this.onSave = config.onSave;
|
||||
this.onCancel = config.onCancel;
|
||||
this.onWidgetAdd = config.onWidgetAdd;
|
||||
this.onWidgetDelete = config.onWidgetDelete;
|
||||
this.onWidgetSettings = config.onWidgetSettings;
|
||||
|
||||
this.isEditMode = false;
|
||||
this.isLocked = true; // Start locked to prevent accidental widget moves
|
||||
this.originalLayout = null;
|
||||
this.gridOverlay = null;
|
||||
this.widgetLibrary = null;
|
||||
this.widgetControlsMap = new Map();
|
||||
|
||||
this.changeListeners = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter edit mode
|
||||
*/
|
||||
enterEditMode() {
|
||||
if (this.isEditMode) return;
|
||||
|
||||
this.isEditMode = true;
|
||||
|
||||
// Store original layout for cancel
|
||||
this.originalLayout = this.captureLayout();
|
||||
|
||||
// Hide edit mode button, show done button (menu-only controls managed by headerOverflowManager)
|
||||
const editModeBtn = document.querySelector('#rpg-dashboard-edit-mode');
|
||||
const doneBtn = document.querySelector('#rpg-dashboard-done-edit');
|
||||
|
||||
if (editModeBtn) editModeBtn.style.display = 'none';
|
||||
if (doneBtn) doneBtn.style.display = '';
|
||||
|
||||
// Disable content editing to prevent keyboard from messing up layout
|
||||
this.disableContentEditing();
|
||||
|
||||
// Add edit class to container
|
||||
this.container.classList.add('edit-mode');
|
||||
|
||||
// Add controls to all currently rendered widgets
|
||||
this.syncAllControls();
|
||||
|
||||
this.notifyChange('editModeEntered');
|
||||
console.log('[EditModeManager] Entered edit mode');
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit edit mode
|
||||
* @param {boolean} save - Whether to save changes
|
||||
*/
|
||||
exitEditMode(save = false) {
|
||||
if (!this.isEditMode) return;
|
||||
|
||||
if (save) {
|
||||
// Save changes
|
||||
if (this.onSave) {
|
||||
this.onSave();
|
||||
}
|
||||
console.log('[EditModeManager] Saved layout changes');
|
||||
} else {
|
||||
// Revert to original layout
|
||||
if (this.onCancel && this.originalLayout) {
|
||||
this.onCancel(this.originalLayout);
|
||||
}
|
||||
console.log('[EditModeManager] Cancelled edit mode');
|
||||
}
|
||||
|
||||
this.isEditMode = false;
|
||||
this.originalLayout = null;
|
||||
|
||||
// Re-enable content editing
|
||||
this.enableContentEditing();
|
||||
|
||||
// Show edit mode button, hide done button (menu-only controls managed by headerOverflowManager)
|
||||
const editModeBtn = document.querySelector('#rpg-dashboard-edit-mode');
|
||||
const doneBtn = document.querySelector('#rpg-dashboard-done-edit');
|
||||
|
||||
if (editModeBtn) editModeBtn.style.display = '';
|
||||
if (doneBtn) doneBtn.style.display = 'none';
|
||||
|
||||
// Remove edit class from container
|
||||
this.container.classList.remove('edit-mode');
|
||||
|
||||
this.notifyChange('editModeExited', { saved: save });
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle edit mode
|
||||
*/
|
||||
toggleEditMode() {
|
||||
if (this.isEditMode) {
|
||||
this.confirmCancel(() => this.exitEditMode(false));
|
||||
} else {
|
||||
this.enterEditMode();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle lock state
|
||||
*/
|
||||
toggleLock() {
|
||||
this.isLocked = !this.isLocked;
|
||||
|
||||
// Update button appearance
|
||||
const lockBtn = document.querySelector('#rpg-dashboard-lock-widgets');
|
||||
if (lockBtn) {
|
||||
const icon = lockBtn.querySelector('i');
|
||||
if (this.isLocked) {
|
||||
icon.className = 'fa-solid fa-lock';
|
||||
lockBtn.title = 'Unlock Widgets';
|
||||
} else {
|
||||
icon.className = 'fa-solid fa-lock-open';
|
||||
lockBtn.title = 'Lock Widgets';
|
||||
}
|
||||
}
|
||||
|
||||
// Add/remove locked class to container for CSS styling
|
||||
if (this.isLocked) {
|
||||
this.container.classList.add('widgets-locked');
|
||||
} else {
|
||||
this.container.classList.remove('widgets-locked');
|
||||
}
|
||||
|
||||
// Notify listeners
|
||||
this.notifyChange('lockStateChanged', { locked: this.isLocked });
|
||||
console.log('[EditModeManager] Lock state:', this.isLocked ? 'LOCKED' : 'UNLOCKED');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if widgets are currently locked
|
||||
* @returns {boolean} True if locked
|
||||
*/
|
||||
isWidgetsLocked() {
|
||||
return this.isLocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable content editing (prevent keyboard popup in edit mode)
|
||||
*/
|
||||
disableContentEditing() {
|
||||
// Find all contenteditable elements within widgets
|
||||
const editableElements = this.container.querySelectorAll('[contenteditable="true"]');
|
||||
editableElements.forEach(element => {
|
||||
element.dataset.wasEditable = 'true';
|
||||
element.contentEditable = 'false';
|
||||
});
|
||||
|
||||
// Also disable input fields (except file inputs which should remain functional)
|
||||
const inputElements = this.container.querySelectorAll('input:not([type="file"]), textarea');
|
||||
inputElements.forEach(element => {
|
||||
element.dataset.wasEnabled = element.disabled ? 'false' : 'true';
|
||||
element.disabled = true;
|
||||
});
|
||||
|
||||
console.log('[EditModeManager] Content editing disabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-enable content editing
|
||||
*/
|
||||
enableContentEditing() {
|
||||
// Re-enable contenteditable elements
|
||||
const editableElements = this.container.querySelectorAll('[data-was-editable="true"]');
|
||||
editableElements.forEach(element => {
|
||||
element.contentEditable = 'true';
|
||||
delete element.dataset.wasEditable;
|
||||
});
|
||||
|
||||
// Re-enable input fields
|
||||
const inputElements = this.container.querySelectorAll('[data-was-enabled="true"]');
|
||||
inputElements.forEach(element => {
|
||||
element.disabled = false;
|
||||
delete element.dataset.wasEnabled;
|
||||
});
|
||||
|
||||
console.log('[EditModeManager] Content editing enabled');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Show grid overlay (now handled via CSS on container)
|
||||
*/
|
||||
showGridOverlay() {
|
||||
// Grid overlay is now pure CSS via .rpg-dashboard-grid[data-edit-mode="true"]
|
||||
// No DOM manipulation needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide grid overlay (now handled via CSS on container)
|
||||
*/
|
||||
hideGridOverlay() {
|
||||
// Grid overlay is now pure CSS via .rpg-dashboard-grid[data-edit-mode="true"]
|
||||
// No DOM manipulation needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Show widget library sidebar
|
||||
*/
|
||||
showWidgetLibrary() {
|
||||
if (this.widgetLibrary) return;
|
||||
|
||||
this.widgetLibrary = document.createElement('div');
|
||||
this.widgetLibrary.className = 'widget-library';
|
||||
this.widgetLibrary.style.position = 'fixed';
|
||||
this.widgetLibrary.style.left = '20px';
|
||||
this.widgetLibrary.style.top = '50%';
|
||||
this.widgetLibrary.style.transform = 'translateY(-50%)';
|
||||
this.widgetLibrary.style.background = '#16213e';
|
||||
this.widgetLibrary.style.borderRadius = '8px';
|
||||
this.widgetLibrary.style.padding = '15px';
|
||||
this.widgetLibrary.style.boxShadow = '0 4px 12px rgba(0,0,0,0.3)';
|
||||
this.widgetLibrary.style.zIndex = '10001';
|
||||
this.widgetLibrary.style.maxWidth = '200px';
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.textContent = 'Widget Library';
|
||||
title.style.fontSize = '14px';
|
||||
title.style.fontWeight = 'bold';
|
||||
title.style.marginBottom = '10px';
|
||||
title.style.color = '#4ecca3';
|
||||
|
||||
this.widgetLibrary.appendChild(title);
|
||||
|
||||
// Widget types
|
||||
const widgetTypes = [
|
||||
{ type: 'userStats', icon: '📊', name: 'User Stats' },
|
||||
{ type: 'infoBox', icon: '📝', name: 'Info Box' },
|
||||
{ type: 'presentCharacters', icon: '👥', name: 'Characters' },
|
||||
{ type: 'inventory', icon: '🎒', name: 'Inventory' },
|
||||
{ type: 'notes', icon: '📔', name: 'Notes' },
|
||||
{ type: 'map', icon: '🗺️', name: 'Map' }
|
||||
];
|
||||
|
||||
widgetTypes.forEach(widget => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'widget-library-item';
|
||||
item.style.display = 'flex';
|
||||
item.style.alignItems = 'center';
|
||||
item.style.gap = '8px';
|
||||
item.style.padding = '10px';
|
||||
item.style.marginBottom = '8px';
|
||||
item.style.background = '#0f3460';
|
||||
item.style.borderRadius = '6px';
|
||||
item.style.cursor = 'pointer';
|
||||
item.style.transition = 'all 0.2s';
|
||||
item.style.userSelect = 'none';
|
||||
|
||||
item.innerHTML = `
|
||||
<span style="font-size: 20px;">${widget.icon}</span>
|
||||
<span style="font-size: 12px;">${widget.name}</span>
|
||||
`;
|
||||
|
||||
item.onmouseenter = () => {
|
||||
item.style.background = '#1a3a5a';
|
||||
item.style.transform = 'scale(1.05)';
|
||||
};
|
||||
|
||||
item.onmouseleave = () => {
|
||||
item.style.background = '#0f3460';
|
||||
item.style.transform = 'scale(1)';
|
||||
};
|
||||
|
||||
item.onclick = () => {
|
||||
if (this.onWidgetAdd) {
|
||||
this.onWidgetAdd(widget.type);
|
||||
}
|
||||
};
|
||||
|
||||
this.widgetLibrary.appendChild(item);
|
||||
});
|
||||
|
||||
document.body.appendChild(this.widgetLibrary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide widget library sidebar
|
||||
*/
|
||||
hideWidgetLibrary() {
|
||||
if (this.widgetLibrary) {
|
||||
this.widgetLibrary.remove();
|
||||
this.widgetLibrary = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add widget controls to a widget element
|
||||
* @param {HTMLElement} element - Widget DOM element
|
||||
* @param {string} widgetId - Widget ID
|
||||
*/
|
||||
addWidgetControls(element, widgetId) {
|
||||
if (this.widgetControlsMap.has(widgetId)) return;
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'widget-edit-controls';
|
||||
controls.style.position = 'absolute';
|
||||
controls.style.top = '4px';
|
||||
controls.style.right = '4px';
|
||||
controls.style.display = 'flex';
|
||||
controls.style.gap = '4px';
|
||||
controls.style.zIndex = '100';
|
||||
controls.style.opacity = '0';
|
||||
controls.style.transition = 'opacity 0.2s';
|
||||
|
||||
// Settings button
|
||||
const settingsBtn = this.createControlButton('⚙', 'Settings');
|
||||
settingsBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.onWidgetSettings) {
|
||||
this.onWidgetSettings(widgetId);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete button
|
||||
const deleteBtn = this.createControlButton('×', 'Delete');
|
||||
deleteBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.confirmDeleteWidget(widgetId);
|
||||
};
|
||||
deleteBtn.style.background = '#e94560';
|
||||
|
||||
controls.appendChild(settingsBtn);
|
||||
controls.appendChild(deleteBtn);
|
||||
|
||||
// Store reference to widget element for positioning
|
||||
controls.dataset.widgetId = widgetId;
|
||||
|
||||
// Append to overlay instead of widget to prevent overflow/scrollbar issues
|
||||
if (this.editControlsOverlay) {
|
||||
this.editControlsOverlay.appendChild(controls);
|
||||
// Position controls to match widget bounds
|
||||
this.updateControlPosition(controls, element);
|
||||
} else {
|
||||
// Fallback to old behavior if overlay not available
|
||||
element.appendChild(controls);
|
||||
}
|
||||
|
||||
// Show controls on hover - keep visible when hovering controls themselves
|
||||
let isHoveringWidget = false;
|
||||
let isHoveringControls = false;
|
||||
let hideTimeout = null;
|
||||
|
||||
const checkAndHideControls = () => {
|
||||
// Clear any existing timeout
|
||||
if (hideTimeout) {
|
||||
clearTimeout(hideTimeout);
|
||||
}
|
||||
|
||||
// Add small delay to allow mouse to move between widget and controls
|
||||
hideTimeout = setTimeout(() => {
|
||||
if (!isHoveringWidget && !isHoveringControls) {
|
||||
controls.style.opacity = '0';
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Widget hover
|
||||
element.addEventListener('mouseenter', () => {
|
||||
isHoveringWidget = true;
|
||||
if (this.isEditMode) {
|
||||
controls.style.opacity = '1';
|
||||
}
|
||||
});
|
||||
|
||||
element.addEventListener('mouseleave', () => {
|
||||
isHoveringWidget = false;
|
||||
checkAndHideControls();
|
||||
});
|
||||
|
||||
// Controls hover - keep visible when hovering the buttons
|
||||
controls.addEventListener('mouseenter', () => {
|
||||
isHoveringControls = true;
|
||||
controls.style.opacity = '1';
|
||||
});
|
||||
|
||||
controls.addEventListener('mouseleave', () => {
|
||||
isHoveringControls = false;
|
||||
checkAndHideControls();
|
||||
});
|
||||
|
||||
this.widgetControlsMap.set(widgetId, { controls, element });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update control position to match widget bounds
|
||||
* @param {HTMLElement} controls - Edit controls container
|
||||
* @param {HTMLElement} element - Widget element
|
||||
*/
|
||||
updateControlPosition(controls, element) {
|
||||
if (!controls || !element) return;
|
||||
|
||||
const overlay = this.editControlsOverlay;
|
||||
if (!overlay) return;
|
||||
|
||||
// Use offset properties for parent-relative positioning
|
||||
// Both widget and overlay are children of the same grid container
|
||||
const widgetLeft = element.offsetLeft;
|
||||
const widgetTop = element.offsetTop;
|
||||
const widgetWidth = element.offsetWidth;
|
||||
|
||||
// Position controls at top-right of widget (4px from top, 4px from right)
|
||||
controls.style.left = `${widgetLeft + widgetWidth - 60}px`; // 60px approximate width of controls
|
||||
controls.style.top = `${widgetTop + 4}px`;
|
||||
controls.style.pointerEvents = 'auto'; // Ensure controls are clickable
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove widget controls from a widget element
|
||||
* @param {string} widgetId - Widget ID
|
||||
*/
|
||||
removeWidgetControls(widgetId) {
|
||||
const data = this.widgetControlsMap.get(widgetId);
|
||||
if (data) {
|
||||
if (data.controls) {
|
||||
data.controls.remove();
|
||||
}
|
||||
this.widgetControlsMap.delete(widgetId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync controls for all currently rendered widgets
|
||||
* Adds controls to widgets that don't have them yet
|
||||
*/
|
||||
syncAllControls() {
|
||||
// Find all widget elements in the grid
|
||||
const gridContainer = this.container.querySelector('#rpg-dashboard-grid');
|
||||
if (!gridContainer) return;
|
||||
|
||||
const widgets = gridContainer.querySelectorAll('.rpg-widget');
|
||||
widgets.forEach(widgetElement => {
|
||||
const widgetId = widgetElement.dataset.widgetId;
|
||||
if (!widgetId) return;
|
||||
|
||||
// Add controls if they don't exist yet
|
||||
if (!this.widgetControlsMap.has(widgetId)) {
|
||||
this.addWidgetControls(widgetElement, widgetId);
|
||||
} else {
|
||||
// Update position if controls already exist
|
||||
const data = this.widgetControlsMap.get(widgetId);
|
||||
if (data && data.controls) {
|
||||
this.updateControlPosition(data.controls, widgetElement);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Note: Content editing disabling is handled by enterEditMode() and onTabChange()
|
||||
// No need to call it here as well
|
||||
|
||||
console.log('[EditModeManager] Synced controls for', widgets.length, 'widgets');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all widget controls
|
||||
* Called when clearing the grid or switching tabs
|
||||
*/
|
||||
removeAllControls() {
|
||||
this.widgetControlsMap.forEach((data, widgetId) => {
|
||||
if (data.controls) {
|
||||
data.controls.remove();
|
||||
}
|
||||
});
|
||||
this.widgetControlsMap.clear();
|
||||
console.log('[EditModeManager] Removed all widget controls');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a control button
|
||||
* @param {string} icon - Button icon/text
|
||||
* @param {string} title - Button title
|
||||
* @returns {HTMLElement} Button element
|
||||
*/
|
||||
createControlButton(icon, title) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'widget-control-btn';
|
||||
btn.textContent = icon;
|
||||
btn.title = title;
|
||||
btn.style.width = '24px';
|
||||
btn.style.height = '24px';
|
||||
btn.style.padding = '0';
|
||||
btn.style.background = '#4ecca3';
|
||||
btn.style.color = 'white';
|
||||
btn.style.border = 'none';
|
||||
btn.style.borderRadius = '4px';
|
||||
btn.style.cursor = 'pointer';
|
||||
btn.style.fontSize = '16px';
|
||||
btn.style.display = 'flex';
|
||||
btn.style.alignItems = 'center';
|
||||
btn.style.justifyContent = 'center';
|
||||
btn.style.transition = 'all 0.2s';
|
||||
|
||||
btn.onmouseenter = () => {
|
||||
btn.style.transform = 'scale(1.1)';
|
||||
btn.style.boxShadow = '0 2px 8px rgba(0,0,0,0.3)';
|
||||
};
|
||||
|
||||
btn.onmouseleave = () => {
|
||||
btn.style.transform = 'scale(1)';
|
||||
btn.style.boxShadow = 'none';
|
||||
};
|
||||
|
||||
return btn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Style a button element
|
||||
* @param {HTMLElement} btn - Button element
|
||||
* @param {string} bg - Background color
|
||||
* @param {string} color - Text color
|
||||
*/
|
||||
styleButton(btn, bg, color) {
|
||||
btn.style.background = bg;
|
||||
btn.style.color = color;
|
||||
btn.style.border = 'none';
|
||||
btn.style.padding = '10px 20px';
|
||||
btn.style.borderRadius = '6px';
|
||||
btn.style.fontSize = '14px';
|
||||
btn.style.fontWeight = 'bold';
|
||||
btn.style.cursor = 'pointer';
|
||||
btn.style.transition = 'all 0.2s';
|
||||
btn.style.boxShadow = '0 2px 8px rgba(0,0,0,0.2)';
|
||||
|
||||
btn.onmouseenter = () => {
|
||||
btn.style.transform = 'translateY(-2px)';
|
||||
btn.style.boxShadow = '0 4px 12px rgba(0,0,0,0.3)';
|
||||
};
|
||||
|
||||
btn.onmouseleave = () => {
|
||||
btn.style.transform = 'translateY(0)';
|
||||
btn.style.boxShadow = '0 2px 8px rgba(0,0,0,0.2)';
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Show confirmation dialog before canceling
|
||||
* @param {Function} onConfirm - Callback if confirmed
|
||||
*/
|
||||
async confirmCancel(onConfirm) {
|
||||
const confirmed = await showConfirmDialog({
|
||||
title: 'Discard Changes?',
|
||||
message: 'You have unsaved changes. Are you sure you want to discard them?',
|
||||
variant: 'warning',
|
||||
confirmText: 'Discard',
|
||||
cancelText: 'Keep Editing'
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
onConfirm();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show confirmation dialog before deleting widget
|
||||
* @param {string} widgetId - Widget ID to delete
|
||||
*/
|
||||
async confirmDeleteWidget(widgetId) {
|
||||
const confirmed = await showConfirmDialog({
|
||||
title: 'Delete Widget?',
|
||||
message: 'Are you sure you want to delete this widget? This action cannot be undone.',
|
||||
variant: 'danger',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel'
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
if (this.onWidgetDelete) {
|
||||
this.onWidgetDelete(widgetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show confirmation dialog before resetting layout
|
||||
* @param {Function} onConfirm - Callback if confirmed
|
||||
*/
|
||||
async confirmReset(onConfirm) {
|
||||
const confirmed = await showConfirmDialog({
|
||||
title: 'Reset Layout?',
|
||||
message: 'This will reset the layout to default. All widgets will be removed and the default layout will be restored.',
|
||||
variant: 'danger',
|
||||
confirmText: 'Reset',
|
||||
cancelText: 'Cancel'
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
onConfirm();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture current layout state
|
||||
* @returns {Object} Layout snapshot
|
||||
*/
|
||||
captureLayout() {
|
||||
// This should capture the current dashboard state
|
||||
// Implementation depends on how dashboard state is stored
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
// Add actual layout data here
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently in edit mode
|
||||
* @returns {boolean} True if in edit mode
|
||||
*/
|
||||
getIsEditMode() {
|
||||
return this.isEditMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register change listener
|
||||
* @param {Function} callback - Callback function (event, data) => void
|
||||
*/
|
||||
onChange(callback) {
|
||||
this.changeListeners.add(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister change listener
|
||||
* @param {Function} callback - Callback to remove
|
||||
*/
|
||||
offChange(callback) {
|
||||
this.changeListeners.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all listeners of a change
|
||||
* @private
|
||||
*/
|
||||
notifyChange(event, data) {
|
||||
this.changeListeners.forEach(callback => {
|
||||
try {
|
||||
callback(event, data);
|
||||
} catch (error) {
|
||||
console.error('[EditModeManager] Error in change listener:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy edit mode manager
|
||||
*/
|
||||
destroy() {
|
||||
// Exit edit mode if active
|
||||
if (this.isEditMode) {
|
||||
this.exitEditMode(false);
|
||||
}
|
||||
|
||||
// Remove all widget controls
|
||||
for (const widgetId of this.widgetControlsMap.keys()) {
|
||||
this.removeWidgetControls(widgetId);
|
||||
}
|
||||
|
||||
this.changeListeners.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,710 +0,0 @@
|
||||
/**
|
||||
* GridEngine - Core grid layout engine for widget dashboard
|
||||
*
|
||||
* Handles grid-based positioning, snapping, collision detection, and auto-reflow.
|
||||
* Uses a responsive 2-4 column grid system that adapts to panel width.
|
||||
* Mobile devices (≤1000px screen width) always use 2 columns.
|
||||
*
|
||||
* @class GridEngine
|
||||
*/
|
||||
|
||||
// Performance: Disable console logging (console.error still active)
|
||||
// Temporarily enabled for debugging auto-arrange onResize issue
|
||||
const DEBUG = true;
|
||||
const console = DEBUG ? window.console : {
|
||||
log: () => {},
|
||||
warn: () => {},
|
||||
error: window.console.error.bind(window.console)
|
||||
};
|
||||
|
||||
export class GridEngine {
|
||||
/**
|
||||
* Initialize grid engine with configuration
|
||||
*
|
||||
* @param {Object} config - Grid configuration
|
||||
* @param {number} [config.rowHeight=5] - Height of each row in rem units
|
||||
* @param {number} [config.gap=0.75] - Gap between widgets in rem units
|
||||
* @param {boolean} [config.snapToGrid=true] - Enable auto-snapping to grid
|
||||
* @param {HTMLElement} [config.container=null] - Container element
|
||||
*/
|
||||
constructor(config = {}) {
|
||||
// Start with 2 columns (safest default for side panel)
|
||||
this.columns = 2;
|
||||
// Use rem for responsive sizing across all resolutions (1080p, 4K, mobile)
|
||||
// Mobile uses smaller rowHeight (3.5rem) to prevent vertical squashing
|
||||
const isMobileViewport = window.innerWidth <= 1000;
|
||||
const defaultRowHeight = isMobileViewport ? 3.5 : 5;
|
||||
this.rowHeight = config.rowHeight || defaultRowHeight; // rem
|
||||
this.gap = config.gap || 0.75; // rem (was 12px)
|
||||
this.snapToGrid = config.snapToGrid !== false;
|
||||
this.container = config.container || null;
|
||||
|
||||
// Widget registry for accessing widget definitions (e.g., maxAutoSize)
|
||||
this.registry = config.registry || null;
|
||||
|
||||
// Container width will be set dynamically
|
||||
this.containerWidth = 0;
|
||||
|
||||
// Callback for column changes (so DashboardManager can re-render)
|
||||
this.onColumnsChange = config.onColumnsChange || null;
|
||||
|
||||
console.log('[GridEngine] Initialized:', {
|
||||
columns: this.columns,
|
||||
rowHeight: this.rowHeight + 'rem',
|
||||
gap: this.gap + 'rem',
|
||||
snapToGrid: this.snapToGrid,
|
||||
isMobile: this.isMobile()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert rem to pixels using current browser font size
|
||||
* @param {number} rem - Value in rem units
|
||||
* @returns {number} Value in pixels
|
||||
*/
|
||||
remToPixels(rem) {
|
||||
const fontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
return rem * fontSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert pixels to rem using current browser font size
|
||||
* @param {number} pixels - Value in pixels
|
||||
* @returns {number} Value in rem
|
||||
*/
|
||||
pixelsToRem(pixels) {
|
||||
const fontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
return pixels / fontSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're on a mobile device
|
||||
* Mobile is defined as screen width ≤ 1000px
|
||||
*
|
||||
* @returns {boolean} True if mobile
|
||||
*/
|
||||
isMobile() {
|
||||
return window.innerWidth <= 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate optimal number of columns based on container width
|
||||
*
|
||||
* Desktop (>1000px screen):
|
||||
* - < 370px: 2 columns
|
||||
* - 370-449px: 3 columns
|
||||
* - ≥ 450px: 4 columns
|
||||
*
|
||||
* Mobile (≤1000px screen):
|
||||
* - Always 2 columns
|
||||
*
|
||||
* @param {number} containerWidth - Container width in pixels
|
||||
* @returns {number} Number of columns (2-4)
|
||||
*/
|
||||
calculateColumns(containerWidth) {
|
||||
// Mobile always uses 2 columns
|
||||
if (this.isMobile()) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Desktop: dynamic 2-4 columns based on panel width
|
||||
if (containerWidth < 370) return 2;
|
||||
if (containerWidth < 450) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set container width (called when container is measured or resized)
|
||||
*
|
||||
* Recalculates column count based on new width and notifies if changed.
|
||||
*
|
||||
* @param {number} width - Container width in pixels
|
||||
* @returns {boolean} True if column count changed, false otherwise
|
||||
*/
|
||||
setContainerWidth(width) {
|
||||
const oldColumns = this.columns;
|
||||
this.containerWidth = width;
|
||||
this.columns = this.calculateColumns(width);
|
||||
|
||||
console.log('[GridEngine] Container width set to:', width, 'Columns:', this.columns);
|
||||
|
||||
// Notify if column count changed (so dashboard can re-render)
|
||||
if (oldColumns !== this.columns && this.onColumnsChange) {
|
||||
console.log('[GridEngine] Column count changed from', oldColumns, 'to', this.columns);
|
||||
this.onColumnsChange(this.columns, oldColumns);
|
||||
return true; // Signal that columns changed
|
||||
}
|
||||
|
||||
return false; // Columns did NOT change
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate pixel position from grid coordinates
|
||||
*
|
||||
* Converts grid-based widget position (x, y, w, h) to actual pixel values
|
||||
* (left, top, width, height) for CSS positioning.
|
||||
* Note: rowHeight and gap are stored in rem, converted to pixels here.
|
||||
*
|
||||
* @param {Object} widget - Widget with grid coordinates
|
||||
* @param {number} widget.x - Grid column position (0-based)
|
||||
* @param {number} widget.y - Grid row position (0-based)
|
||||
* @param {number} widget.w - Width in grid columns
|
||||
* @param {number} widget.h - Height in grid rows
|
||||
* @returns {Object} Pixel coordinates {left, top, width, height}
|
||||
*
|
||||
* @example
|
||||
* // Widget at column 2, row 1, size 4x3
|
||||
* const pixels = gridEngine.getPixelPosition({ x: 2, y: 1, w: 4, h: 3 });
|
||||
* // Returns: { left: 200, top: 100, width: 300, height: 250 }
|
||||
*/
|
||||
getPixelPosition(widget) {
|
||||
if (this.containerWidth === 0) {
|
||||
console.warn('[GridEngine] Container width not set, using default 350px (side panel estimate)');
|
||||
this.containerWidth = 350;
|
||||
this.columns = this.calculateColumns(350); // Recalculate columns for fallback
|
||||
}
|
||||
|
||||
// Convert rem to pixels for calculations
|
||||
const gapPx = this.remToPixels(this.gap);
|
||||
const rowHeightPx = this.remToPixels(this.rowHeight);
|
||||
|
||||
// Calculate column width
|
||||
// Formula: (containerWidth - gaps) / columns
|
||||
// Gaps: (columns + 1) gaps total (one before each column + one after last)
|
||||
const totalGaps = gapPx * (this.columns + 1);
|
||||
const colWidth = (this.containerWidth - totalGaps) / this.columns;
|
||||
|
||||
// Calculate positions
|
||||
// Left: x columns * (colWidth + gap) + initial gap
|
||||
const left = widget.x * (colWidth + gapPx) + gapPx;
|
||||
|
||||
// Top: y rows * (rowHeight + gap) + initial gap
|
||||
const top = widget.y * (rowHeightPx + gapPx) + gapPx;
|
||||
|
||||
// Width: w columns * colWidth + (w - 1) inner gaps
|
||||
const width = widget.w * colWidth + (widget.w - 1) * gapPx;
|
||||
|
||||
// Height: h rows * rowHeight + (h - 1) inner gaps
|
||||
const height = widget.h * rowHeightPx + (widget.h - 1) * gapPx;
|
||||
|
||||
return { left, top, width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate responsive position from grid coordinates
|
||||
*
|
||||
* Returns positions as % of container width (for horizontal) and vh (for vertical).
|
||||
* Widgets are positioned absolutely within the container, so % is relative to container.
|
||||
*
|
||||
* @param {Object} widget - Widget with grid coordinates
|
||||
* @param {number} widget.x - Grid column position (0-based)
|
||||
* @param {number} widget.y - Grid row position (0-based)
|
||||
* @param {number} widget.w - Width in grid columns
|
||||
* @param {number} widget.h - Height in grid rows
|
||||
* @returns {Object} Responsive coordinates {left, top, width, height}
|
||||
*
|
||||
* @example
|
||||
* // Widget at column 0, row 0, size 2x3 in 2-column grid
|
||||
* const pos = gridEngine.getViewportPosition({ x: 0, y: 0, w: 2, h: 3 });
|
||||
* // Returns: { left: "3%", top: "2vh", width: "94%", height: "25vh" }
|
||||
*/
|
||||
getViewportPosition(widget) {
|
||||
if (this.containerWidth === 0) {
|
||||
console.warn('[GridEngine] Container width not set, using default 350px (side panel estimate)');
|
||||
this.containerWidth = 350;
|
||||
this.columns = this.calculateColumns(350);
|
||||
}
|
||||
|
||||
console.log('[GridEngine] getViewportPosition DEBUG:', {
|
||||
widgetId: widget.id,
|
||||
widgetSize: `${widget.w}×${widget.h}`,
|
||||
containerWidth: this.containerWidth,
|
||||
columns: this.columns,
|
||||
gap: this.gap
|
||||
});
|
||||
|
||||
// Calculate column width as % of container
|
||||
const gapPercent = (this.gap / this.containerWidth) * 100;
|
||||
const totalGapsPercent = gapPercent * (this.columns + 1);
|
||||
const colWidthPercent = (100 - totalGapsPercent) / this.columns;
|
||||
|
||||
console.log('[GridEngine] Calculation values:', {
|
||||
gapPercent: gapPercent.toFixed(2) + '%',
|
||||
totalGapsPercent: totalGapsPercent.toFixed(2) + '%',
|
||||
colWidthPercent: colWidthPercent.toFixed(2) + '%'
|
||||
});
|
||||
|
||||
// Calculate positions
|
||||
// Horizontal: % of container (since widgets are absolutely positioned within container)
|
||||
const left = widget.x * (colWidthPercent + gapPercent) + gapPercent;
|
||||
const width = widget.w * colWidthPercent + (widget.w - 1) * gapPercent;
|
||||
|
||||
console.log('[GridEngine] Position calc:', {
|
||||
left: left.toFixed(2) + '%',
|
||||
width: width.toFixed(2) + '%',
|
||||
formula: `${widget.w} * ${colWidthPercent.toFixed(2)}% + ${widget.w - 1} * ${gapPercent.toFixed(2)}%`
|
||||
});
|
||||
|
||||
// Vertical: rem units (scales across all resolutions - 1080p, 4K, mobile)
|
||||
// rem scales with browser font size, which adapts to screen DPI
|
||||
const top = widget.y * (this.rowHeight + this.gap) + this.gap;
|
||||
const height = widget.h * this.rowHeight + (widget.h - 1) * this.gap;
|
||||
|
||||
return {
|
||||
left: `${left.toFixed(2)}%`,
|
||||
top: `${top.toFixed(2)}rem`,
|
||||
width: `${width.toFixed(2)}%`,
|
||||
height: `${height.toFixed(2)}rem`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get widget position for CSS styling
|
||||
* Returns responsive units for scaling across all screen sizes.
|
||||
* Uses % of container for horizontal (adapts to panel width)
|
||||
* Uses vh for vertical (adapts to viewport height)
|
||||
*
|
||||
* @param {Object} widget - Widget with grid coordinates
|
||||
* @returns {Object} Position with %, vh units {left, top, width, height}
|
||||
*/
|
||||
getWidgetPosition(widget) {
|
||||
return this.getViewportPosition(widget);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap pixel coordinates to nearest grid cell
|
||||
*
|
||||
* Converts pixel position (from drag-and-drop) to grid coordinates.
|
||||
* Clamps to valid grid bounds.
|
||||
*
|
||||
* @param {number} pixelX - X coordinate in pixels
|
||||
* @param {number} pixelY - Y coordinate in pixels
|
||||
* @returns {Object} Grid coordinates {x, y}
|
||||
*
|
||||
* @example
|
||||
* // Mouse dragged to pixel (250, 175)
|
||||
* const gridPos = gridEngine.snapToCell(250, 175);
|
||||
* // Returns: { x: 3, y: 2 } (nearest grid cell)
|
||||
*/
|
||||
snapToCell(pixelX, pixelY) {
|
||||
if (this.containerWidth === 0) {
|
||||
console.warn('[GridEngine] Container width not set, using default 350px (side panel estimate)');
|
||||
this.containerWidth = 350;
|
||||
this.columns = this.calculateColumns(350); // Recalculate columns for fallback
|
||||
}
|
||||
|
||||
// Convert rem to pixels for calculations
|
||||
const gapPx = this.remToPixels(this.gap);
|
||||
const rowHeightPx = this.remToPixels(this.rowHeight);
|
||||
|
||||
// Calculate column width
|
||||
const totalGaps = gapPx * (this.columns + 1);
|
||||
const colWidth = (this.containerWidth - totalGaps) / this.columns;
|
||||
|
||||
// Convert pixel to grid coordinates
|
||||
// Reverse of getPixelPosition formula
|
||||
// x = (pixelX - gap) / (colWidth + gap)
|
||||
const x = Math.round((pixelX - gapPx) / (colWidth + gapPx));
|
||||
const y = Math.round((pixelY - gapPx) / (rowHeightPx + gapPx));
|
||||
|
||||
// Clamp to valid grid bounds
|
||||
return {
|
||||
x: Math.max(0, Math.min(x, this.columns - 1)),
|
||||
y: Math.max(0, y) // No maximum Y (infinite rows)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if widget collides with any other widgets
|
||||
*
|
||||
* Uses rectangle intersection algorithm. Two rectangles DON'T intersect if:
|
||||
* - rect1 is completely left of rect2, OR
|
||||
* - rect1 is completely right of rect2, OR
|
||||
* - rect1 is completely above rect2, OR
|
||||
* - rect1 is completely below rect2
|
||||
*
|
||||
* If none of the above are true, they must intersect.
|
||||
*
|
||||
* @param {Object} widget - Widget to check for collisions
|
||||
* @param {Array<Object>} widgets - Array of other widgets to check against
|
||||
* @returns {boolean} True if widget collides with any other widget
|
||||
*
|
||||
* @example
|
||||
* const widget = { x: 2, y: 1, w: 4, h: 3 };
|
||||
* const others = [{ x: 4, y: 2, w: 2, h: 2 }];
|
||||
* const collides = gridEngine.detectCollision(widget, others);
|
||||
* // Returns: true (widgets overlap)
|
||||
*/
|
||||
detectCollision(widget, widgets) {
|
||||
return widgets.some(other => {
|
||||
// Don't collide with self
|
||||
if (other.id === widget.id) return false;
|
||||
|
||||
// Check if rectangles DON'T intersect (then negate)
|
||||
const noIntersect = (
|
||||
widget.x + widget.w <= other.x || // widget is left of other
|
||||
widget.x >= other.x + other.w || // widget is right of other
|
||||
widget.y + widget.h <= other.y || // widget is above other
|
||||
widget.y >= other.y + other.h // widget is below other
|
||||
);
|
||||
|
||||
return !noIntersect; // If they don't NOT intersect, they DO intersect
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflow widgets to remove overlaps
|
||||
*
|
||||
* When a widget is moved and causes collisions, this pushes overlapping
|
||||
* widgets down to make room. Processes widgets in order (top to bottom,
|
||||
* left to right) to ensure consistent layout.
|
||||
*
|
||||
* @param {Array<Object>} widgets - Array of widgets to reflow
|
||||
* @returns {Array<Object>} Reflowed widgets (same array, modified in place)
|
||||
*
|
||||
* @example
|
||||
* // Widget moved to position that overlaps another
|
||||
* const widgets = [
|
||||
* { x: 0, y: 0, w: 4, h: 2 },
|
||||
* { x: 2, y: 0, w: 4, h: 2 } // Overlaps first widget!
|
||||
* ];
|
||||
* gridEngine.reflow(widgets);
|
||||
* // Second widget pushed down: { x: 2, y: 2, w: 4, h: 2 }
|
||||
*/
|
||||
reflow(widgets) {
|
||||
// Sort widgets by position (top to bottom, left to right)
|
||||
// This ensures we process in reading order
|
||||
const sorted = [...widgets].sort((a, b) => {
|
||||
if (a.y !== b.y) return a.y - b.y; // Sort by Y first
|
||||
return a.x - b.x; // Then by X
|
||||
});
|
||||
|
||||
// Process each widget
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const widget = sorted[i];
|
||||
|
||||
// Keep pushing widget down while it collides with any widget before it
|
||||
// (widgets before it in sorted order are already positioned correctly)
|
||||
while (this.detectCollision(widget, sorted.slice(0, i))) {
|
||||
widget.y++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[GridEngine] Reflowed', widgets.length, 'widgets');
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate widget dimensions
|
||||
*
|
||||
* Ensures widget fits within grid bounds and has valid size.
|
||||
*
|
||||
* @param {Object} widget - Widget to validate
|
||||
* @param {Object} minSize - Minimum allowed size {w, h}
|
||||
* @returns {Object} Validated widget (clamped to valid values)
|
||||
*/
|
||||
validateWidget(widget, minSize = { w: 1, h: 1 }) {
|
||||
return {
|
||||
...widget,
|
||||
x: Math.max(0, Math.min(widget.x, this.columns - 1)),
|
||||
y: Math.max(0, widget.y),
|
||||
w: Math.max(minSize.w, Math.min(widget.w, this.columns)),
|
||||
h: Math.max(minSize.h, widget.h)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total grid height needed for all widgets
|
||||
*
|
||||
* @param {Array<Object>} widgets - Array of widgets
|
||||
* @returns {number} Total height in rem units
|
||||
*/
|
||||
calculateGridHeight(widgets) {
|
||||
if (widgets.length === 0) return 0;
|
||||
|
||||
// Find the bottom-most widget
|
||||
const maxY = Math.max(...widgets.map(w => w.y + w.h));
|
||||
|
||||
// Calculate total height including gaps (in rem)
|
||||
return maxY * (this.rowHeight + this.gap) + this.gap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-layout widgets to efficiently use all available space
|
||||
*
|
||||
* Packs widgets in reading order (left to right, top to bottom) with no gaps.
|
||||
* Respects each widget's defined size - only repositions, doesn't resize.
|
||||
* Respects current column count (responsive to panel width).
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Sort widgets (by area or preserve order if requested)
|
||||
* 2. For each widget, keep its defined size (w, h)
|
||||
* 3. Find first available position from top-left
|
||||
* 4. Ensure no overlaps
|
||||
* 5. If widget doesn't fit at preferred size, try narrower widths
|
||||
*
|
||||
* @param {Array<Object>} widgets - Array of widgets to auto-layout
|
||||
* @param {Object} options - Layout options
|
||||
* @param {boolean} [options.preserveOrder=false] - Keep input order instead of sorting by area
|
||||
* @returns {Array<Object>} Re-positioned widgets (same array, modified in place)
|
||||
*/
|
||||
autoLayout(widgets, options = {}) {
|
||||
if (widgets.length === 0) return widgets;
|
||||
|
||||
const preserveOrder = options.preserveOrder || false;
|
||||
|
||||
// Calculate maximum visible rows based on grid container's actual viewport height
|
||||
let maxVisibleRows = 100; // Fallback
|
||||
if (this.container) {
|
||||
// Use grid container's own clientHeight (actual visible viewport area)
|
||||
// Don't use parentElement which includes the header (tabs + buttons)
|
||||
const viewportHeight = this.container.clientHeight; // pixels
|
||||
const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize); // px per rem
|
||||
const viewportHeightRem = viewportHeight / rootFontSize;
|
||||
const rowHeightWithGap = this.rowHeight + this.gap;
|
||||
// Add gap to calculation because last row doesn't need trailing gap
|
||||
// Formula: (height + gap) / (rowHeight + gap) accounts for N rows with N-1 gaps
|
||||
maxVisibleRows = Math.floor((viewportHeightRem + this.gap) / rowHeightWithGap);
|
||||
console.log('[GridEngine] Viewport height:', viewportHeight + 'px', '=', viewportHeightRem.toFixed(2) + 'rem', '→', maxVisibleRows, 'visible rows');
|
||||
}
|
||||
|
||||
console.log('[GridEngine] Auto-layout started:', {
|
||||
widgetCount: widgets.length,
|
||||
columns: this.columns,
|
||||
preserveOrder,
|
||||
maxVisibleRows
|
||||
});
|
||||
|
||||
// Sort widgets (or preserve input order for category-aware layout)
|
||||
const sorted = preserveOrder ? [...widgets] : [...widgets].sort((a, b) => {
|
||||
const areaA = a.w * a.h;
|
||||
const areaB = b.w * b.h;
|
||||
if (areaB !== areaA) return areaB - areaA;
|
||||
// If same area, sort by height (taller first)
|
||||
return b.h - a.h;
|
||||
});
|
||||
|
||||
// Track occupied cells in a 2D grid
|
||||
const occupied = new Map(); // key: "x,y" => widget
|
||||
|
||||
/**
|
||||
* Check if position is free
|
||||
*/
|
||||
const isFree = (x, y, w, h) => {
|
||||
for (let row = y; row < y + h; row++) {
|
||||
for (let col = x; col < x + w; col++) {
|
||||
const key = `${col},${row}`;
|
||||
if (occupied.has(key)) return false;
|
||||
if (col >= this.columns) return false; // Out of bounds
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark cells as occupied
|
||||
*/
|
||||
const markOccupied = (widget, x, y, w, h) => {
|
||||
for (let row = y; row < y + h; row++) {
|
||||
for (let col = x; col < x + w; col++) {
|
||||
occupied.set(`${col},${row}`, widget.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Find first available position for widget of given size
|
||||
*/
|
||||
const findPosition = (w, h) => {
|
||||
// Start from top-left, scan row by row
|
||||
for (let y = 0; y < 1000; y++) { // Max 1000 rows (practical limit)
|
||||
for (let x = 0; x <= this.columns - w; x++) {
|
||||
if (isFree(x, y, w, h)) {
|
||||
return { x, y };
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: stack at bottom (should never happen)
|
||||
return { x: 0, y: 1000 };
|
||||
};
|
||||
|
||||
// Process each widget
|
||||
sorted.forEach(widget => {
|
||||
// Respect widget's defined size - only clamp to grid bounds
|
||||
// Don't force sizes - widgets define their own optimal dimensions
|
||||
let targetW = Math.min(widget.w, this.columns); // Clamp to column count
|
||||
let targetH = widget.h; // Respect widget's height
|
||||
|
||||
// Try to find position for preferred size
|
||||
let pos = findPosition(targetW, targetH);
|
||||
|
||||
// If preferred size doesn't fit well, try smaller widths
|
||||
// (but never go below 1 column)
|
||||
if (pos.y > 100 && targetW > 1) {
|
||||
// Widget would be placed very far down, try narrower width
|
||||
for (let tryW = targetW - 1; tryW >= 1; tryW--) {
|
||||
const tryPos = findPosition(tryW, targetH);
|
||||
if (tryPos.y < pos.y) {
|
||||
// Found better position with narrower width
|
||||
pos = tryPos;
|
||||
targetW = tryW;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update widget position and size
|
||||
widget.x = pos.x;
|
||||
widget.y = pos.y;
|
||||
widget.w = targetW;
|
||||
widget.h = targetH;
|
||||
|
||||
// Mark cells as occupied
|
||||
markOccupied(widget, pos.x, pos.y, targetW, targetH);
|
||||
|
||||
console.log(`[GridEngine] Auto-layout positioned: ${widget.id} at (${pos.x},${pos.y}) size ${targetW}×${targetH}`);
|
||||
});
|
||||
|
||||
// Compact pass: Move widgets up to fill gaps
|
||||
console.log('[GridEngine] Compacting layout to fill gaps...');
|
||||
let compactedCount = 0;
|
||||
|
||||
// Sort widgets by current Y position (process top to bottom)
|
||||
const sortedForCompact = [...sorted].sort((a, b) => a.y - b.y);
|
||||
|
||||
sortedForCompact.forEach(widget => {
|
||||
const originalY = widget.y;
|
||||
|
||||
// Try to move widget up as far as possible
|
||||
for (let tryY = 0; tryY < originalY; tryY++) {
|
||||
// Clear current position from occupied map
|
||||
for (let row = originalY; row < originalY + widget.h; row++) {
|
||||
for (let col = widget.x; col < widget.x + widget.w; col++) {
|
||||
occupied.delete(`${col},${row}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if new position is free
|
||||
if (isFree(widget.x, tryY, widget.w, widget.h)) {
|
||||
// Move widget up
|
||||
widget.y = tryY;
|
||||
markOccupied(widget, widget.x, tryY, widget.w, widget.h);
|
||||
compactedCount++;
|
||||
console.log(`[GridEngine] Compacted ${widget.id} from y=${originalY} to y=${tryY}`);
|
||||
break;
|
||||
} else {
|
||||
// Re-mark original position and continue
|
||||
markOccupied(widget, widget.x, originalY, widget.w, widget.h);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[GridEngine] Compaction complete (${compactedCount} widgets moved up)`);
|
||||
|
||||
// Expansion pass: Try to expand widgets to fill available space
|
||||
console.log('[GridEngine] Expanding widgets to fill available space...');
|
||||
let expandedCount = 0;
|
||||
|
||||
// Sort widgets by position (top-to-bottom, left-to-right) for orderly expansion
|
||||
const sortedForExpand = [...sorted].sort((a, b) => {
|
||||
if (a.y !== b.y) return a.y - b.y; // Top to bottom
|
||||
return a.x - b.x; // Left to right
|
||||
});
|
||||
|
||||
// Helper to get widget max size from registry
|
||||
const getWidgetMaxSize = (widget) => {
|
||||
// Try to get widget definition from registry
|
||||
if (this.registry && widget.type) {
|
||||
const definition = this.registry.get(widget.type);
|
||||
if (definition && definition.maxAutoSize) {
|
||||
// Support maxAutoSize as function (column-aware sizing)
|
||||
if (typeof definition.maxAutoSize === 'function') {
|
||||
return definition.maxAutoSize(this.columns);
|
||||
}
|
||||
// Static maxAutoSize object
|
||||
return definition.maxAutoSize;
|
||||
}
|
||||
}
|
||||
// Default max size if not specified (conservative expansion)
|
||||
return { w: this.columns, h: 3 };
|
||||
};
|
||||
|
||||
sortedForExpand.forEach(widget => {
|
||||
const maxSize = getWidgetMaxSize(widget);
|
||||
const originalW = widget.w;
|
||||
const originalH = widget.h;
|
||||
|
||||
// Try expanding height first (fills vertical gaps) - keep trying until maxSize or collision
|
||||
let expandedH = false;
|
||||
for (let tryH = originalH + 1; tryH <= maxSize.h; tryH++) {
|
||||
// Check if expansion would go beyond visible area
|
||||
// y + h represents the row AFTER the widget ends, so > check (not >=) is correct
|
||||
if (widget.y + tryH > maxVisibleRows) {
|
||||
console.log(`[GridEngine] ${widget.id} cannot expand to h=${tryH} (would exceed visible area: row ${widget.y + tryH} > ${maxVisibleRows})`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Clear current position
|
||||
for (let row = widget.y; row < widget.y + widget.h; row++) {
|
||||
for (let col = widget.x; col < widget.x + widget.w; col++) {
|
||||
occupied.delete(`${col},${row}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if expanded height is free
|
||||
if (isFree(widget.x, widget.y, widget.w, tryH)) {
|
||||
widget.h = tryH;
|
||||
markOccupied(widget, widget.x, widget.y, widget.w, tryH);
|
||||
expandedH = true;
|
||||
expandedCount++;
|
||||
// Continue trying to expand further
|
||||
} else {
|
||||
// Hit a collision, stop expanding height
|
||||
markOccupied(widget, widget.x, widget.y, widget.w, widget.h);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (expandedH) {
|
||||
console.log(`[GridEngine] Expanded ${widget.id} height: ${originalH} → ${widget.h}`);
|
||||
}
|
||||
|
||||
// Try expanding width (fills horizontal gaps) - keep trying until maxSize or collision
|
||||
let expandedW = false;
|
||||
for (let tryW = originalW + 1; tryW <= Math.min(maxSize.w, this.columns); tryW++) {
|
||||
// Clear current position
|
||||
for (let row = widget.y; row < widget.y + widget.h; row++) {
|
||||
for (let col = widget.x; col < widget.x + widget.w; col++) {
|
||||
occupied.delete(`${col},${row}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if expanded width is free
|
||||
if (isFree(widget.x, widget.y, tryW, widget.h)) {
|
||||
widget.w = tryW;
|
||||
markOccupied(widget, widget.x, widget.y, tryW, widget.h);
|
||||
expandedW = true;
|
||||
expandedCount++;
|
||||
// Continue trying to expand further
|
||||
} else {
|
||||
// Hit a collision, stop expanding width
|
||||
markOccupied(widget, widget.x, widget.y, widget.w, widget.h);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (expandedW) {
|
||||
console.log(`[GridEngine] Expanded ${widget.id} width: ${originalW} → ${widget.w}`);
|
||||
}
|
||||
|
||||
if (!expandedH && !expandedW) {
|
||||
// Widget couldn't expand - ensure it's still marked in grid
|
||||
markOccupied(widget, widget.x, widget.y, widget.w, widget.h);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[GridEngine] Expansion complete (${expandedCount} expansions made)`);
|
||||
console.log(`[GridEngine] Auto-layout complete`);
|
||||
return widgets;
|
||||
}
|
||||
}
|
||||
@@ -1,536 +0,0 @@
|
||||
/**
|
||||
* Header Overflow Manager
|
||||
*
|
||||
* Manages responsive button overflow behavior with four modes:
|
||||
* - Full Mode (>900px): All buttons visible
|
||||
* - Overflow Mode (700-900px): Priority buttons + "More" menu
|
||||
* - Compact Mode (400-700px): Priority buttons + Hamburger menu
|
||||
* - Ultra-Compact Mode (<400px): Hamburger menu ONLY
|
||||
*
|
||||
* Uses ResizeObserver for accurate width detection and smooth transitions.
|
||||
*/
|
||||
|
||||
export class HeaderOverflowManager {
|
||||
/**
|
||||
* @param {HTMLElement} headerContainer - The header right container
|
||||
* @param {Object} options - Configuration options
|
||||
*/
|
||||
constructor(headerContainer, options = {}) {
|
||||
this.headerContainer = headerContainer;
|
||||
this.options = {
|
||||
fullModeWidth: 900, // px
|
||||
compactModeWidth: 700, // px
|
||||
ultraCompactModeWidth: 400, // px - New breakpoint for extreme narrowness
|
||||
debounceDelay: 100, // ms
|
||||
...options
|
||||
};
|
||||
|
||||
this.currentMode = 'full';
|
||||
this.menuOpen = false;
|
||||
this.resizeObserver = null;
|
||||
this.resizeTimeout = null;
|
||||
this.editModeManager = null; // Reference to EditModeManager for menu filtering
|
||||
|
||||
// Element references
|
||||
this.priorityButtons = null;
|
||||
this.overflowButtons = null;
|
||||
this.overflowMenuBtn = null;
|
||||
this.hamburgerMenuBtn = null;
|
||||
this.dropdownMenu = null;
|
||||
|
||||
// Bound event handlers
|
||||
this.boundMenuToggle = this.toggleMenu.bind(this);
|
||||
this.boundCloseMenu = this.closeMenu.bind(this);
|
||||
this.boundKeyHandler = this.handleKeyDown.bind(this);
|
||||
this.boundClickOutside = this.handleClickOutside.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set EditModeManager reference for menu filtering
|
||||
* @param {EditModeManager} editModeManager - Edit mode manager instance
|
||||
*/
|
||||
setEditModeManager(editModeManager) {
|
||||
this.editModeManager = editModeManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the overflow manager
|
||||
*/
|
||||
init() {
|
||||
console.log('[HeaderOverflowManager] Initializing...');
|
||||
|
||||
// Get element references
|
||||
this.priorityButtons = Array.from(this.headerContainer.querySelectorAll('.rpg-priority-btn'));
|
||||
this.overflowButtons = Array.from(this.headerContainer.querySelectorAll('.rpg-overflow-btn'));
|
||||
this.overflowMenuBtn = this.headerContainer.querySelector('#rpg-dashboard-overflow-menu');
|
||||
this.hamburgerMenuBtn = this.headerContainer.querySelector('#rpg-dashboard-hamburger-menu');
|
||||
this.dropdownMenu = this.headerContainer.querySelector('#rpg-dashboard-dropdown-menu');
|
||||
|
||||
if (!this.overflowMenuBtn || !this.hamburgerMenuBtn || !this.dropdownMenu) {
|
||||
console.error('[HeaderOverflowManager] Required elements not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up menu toggle listeners
|
||||
this.overflowMenuBtn.addEventListener('click', this.boundMenuToggle);
|
||||
this.hamburgerMenuBtn.addEventListener('click', this.boundMenuToggle);
|
||||
|
||||
// Set up resize observer
|
||||
this.setupResizeObserver();
|
||||
|
||||
// Initial mode detection
|
||||
this.updateMode();
|
||||
|
||||
console.log('[HeaderOverflowManager] Initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up ResizeObserver to monitor container width
|
||||
*/
|
||||
setupResizeObserver() {
|
||||
this.resizeObserver = new ResizeObserver((entries) => {
|
||||
// Debounce resize events
|
||||
if (this.resizeTimeout) {
|
||||
clearTimeout(this.resizeTimeout);
|
||||
}
|
||||
|
||||
this.resizeTimeout = setTimeout(() => {
|
||||
for (const entry of entries) {
|
||||
const width = entry.contentRect.width;
|
||||
this.handleResize(width);
|
||||
}
|
||||
}, this.options.debounceDelay);
|
||||
});
|
||||
|
||||
this.resizeObserver.observe(this.headerContainer);
|
||||
console.log('[HeaderOverflowManager] ResizeObserver set up');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle container resize
|
||||
* @param {number} width - Container width in pixels
|
||||
*/
|
||||
handleResize(width) {
|
||||
let newMode = 'full';
|
||||
|
||||
if (width < this.options.ultraCompactModeWidth) {
|
||||
newMode = 'ultraCompact';
|
||||
} else if (width < this.options.compactModeWidth) {
|
||||
newMode = 'compact';
|
||||
} else if (width < this.options.fullModeWidth) {
|
||||
newMode = 'overflow';
|
||||
}
|
||||
|
||||
if (newMode !== this.currentMode) {
|
||||
console.log(`[HeaderOverflowManager] Mode change: ${this.currentMode} → ${newMode} (width: ${width}px)`);
|
||||
this.currentMode = newMode;
|
||||
this.updateMode();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update UI based on current mode
|
||||
*/
|
||||
updateMode() {
|
||||
// Close menu if open
|
||||
if (this.menuOpen) {
|
||||
this.closeMenu();
|
||||
}
|
||||
|
||||
switch (this.currentMode) {
|
||||
case 'full':
|
||||
this.setFullMode();
|
||||
break;
|
||||
case 'overflow':
|
||||
this.setOverflowMode();
|
||||
break;
|
||||
case 'compact':
|
||||
this.setCompactMode();
|
||||
break;
|
||||
case 'ultraCompact':
|
||||
this.setUltraCompactMode();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full Mode: Show all buttons except menu-only
|
||||
*/
|
||||
setFullMode() {
|
||||
// Show priority buttons
|
||||
this.priorityButtons.forEach(btn => {
|
||||
const inlineStyle = btn.getAttribute('style');
|
||||
if (!inlineStyle || !inlineStyle.includes('display: none')) {
|
||||
btn.style.display = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Show all overflow buttons except menu-only ones
|
||||
this.overflowButtons.forEach(btn => {
|
||||
// Menu-only buttons always stay hidden (managed by menu)
|
||||
if (btn.classList.contains('rpg-menu-only-btn')) {
|
||||
btn.style.display = 'none';
|
||||
btn.dataset.wasVisible = 'true'; // Mark as available for menu
|
||||
} else {
|
||||
// Only show buttons that don't have inline display:none in the template
|
||||
const inlineStyle = btn.getAttribute('style');
|
||||
if (!inlineStyle || !inlineStyle.includes('display: none')) {
|
||||
btn.style.display = '';
|
||||
}
|
||||
// Clear the wasVisible flag for non-menu-only buttons
|
||||
delete btn.dataset.wasVisible;
|
||||
}
|
||||
});
|
||||
|
||||
// Hide menu buttons
|
||||
this.overflowMenuBtn.style.display = 'none';
|
||||
this.hamburgerMenuBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Overflow Mode: Priority buttons + "More" menu
|
||||
*/
|
||||
setOverflowMode() {
|
||||
// Ensure priority buttons are visible
|
||||
this.priorityButtons.forEach(btn => {
|
||||
const inlineStyle = btn.getAttribute('style');
|
||||
if (!inlineStyle || !inlineStyle.includes('display: none')) {
|
||||
btn.style.display = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Hide overflow buttons (will be in dropdown)
|
||||
// Store original visibility before hiding
|
||||
this.overflowButtons.forEach(btn => {
|
||||
// Menu-only buttons are always available in menu
|
||||
if (btn.classList.contains('rpg-menu-only-btn')) {
|
||||
btn.dataset.wasVisible = 'true';
|
||||
} else {
|
||||
const computedStyle = window.getComputedStyle(btn);
|
||||
btn.dataset.wasVisible = computedStyle.display !== 'none' ? 'true' : 'false';
|
||||
}
|
||||
btn.style.display = 'none';
|
||||
});
|
||||
|
||||
// Show overflow menu button
|
||||
this.overflowMenuBtn.style.display = '';
|
||||
this.hamburgerMenuBtn.style.display = 'none';
|
||||
|
||||
// Build menu with overflow buttons only
|
||||
this.buildDropdownMenu(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact Mode: Priority buttons + Hamburger menu
|
||||
*/
|
||||
setCompactMode() {
|
||||
// Ensure priority buttons are visible
|
||||
this.priorityButtons.forEach(btn => {
|
||||
const inlineStyle = btn.getAttribute('style');
|
||||
if (!inlineStyle || !inlineStyle.includes('display: none')) {
|
||||
btn.style.display = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Hide all overflow buttons
|
||||
this.overflowButtons.forEach(btn => {
|
||||
if (btn.classList.contains('rpg-menu-only-btn')) {
|
||||
btn.dataset.wasVisible = 'true';
|
||||
} else {
|
||||
const computedStyle = window.getComputedStyle(btn);
|
||||
btn.dataset.wasVisible = computedStyle.display !== 'none' ? 'true' : 'false';
|
||||
}
|
||||
btn.style.display = 'none';
|
||||
});
|
||||
|
||||
// Show hamburger menu button
|
||||
this.overflowMenuBtn.style.display = 'none';
|
||||
this.hamburgerMenuBtn.style.display = '';
|
||||
|
||||
// Build menu with all buttons (priority + overflow)
|
||||
this.buildDropdownMenu(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ultra-Compact Mode: Hamburger menu ONLY
|
||||
*/
|
||||
setUltraCompactMode() {
|
||||
// Hide priority buttons
|
||||
this.priorityButtons.forEach(btn => {
|
||||
const computedStyle = window.getComputedStyle(btn);
|
||||
btn.dataset.wasVisible = computedStyle.display !== 'none' ? 'true' : 'false';
|
||||
btn.style.display = 'none';
|
||||
});
|
||||
|
||||
// Hide all overflow buttons
|
||||
this.overflowButtons.forEach(btn => {
|
||||
if (btn.classList.contains('rpg-menu-only-btn')) {
|
||||
btn.dataset.wasVisible = 'true';
|
||||
} else {
|
||||
const computedStyle = window.getComputedStyle(btn);
|
||||
btn.dataset.wasVisible = computedStyle.display !== 'none' ? 'true' : 'false';
|
||||
}
|
||||
btn.style.display = 'none';
|
||||
});
|
||||
|
||||
// Show hamburger menu button
|
||||
this.overflowMenuBtn.style.display = 'none';
|
||||
this.hamburgerMenuBtn.style.display = '';
|
||||
|
||||
// Build menu with ALL buttons
|
||||
this.buildDropdownMenu(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build dropdown menu content
|
||||
* @param {boolean} includeAll - Include priority buttons in menu
|
||||
*/
|
||||
buildDropdownMenu(includeAll) {
|
||||
this.dropdownMenu.innerHTML = '';
|
||||
|
||||
// CORRECTED: When includeAll is true, combine priority and overflow buttons.
|
||||
const buttonsToShow = includeAll
|
||||
? [...this.priorityButtons, ...this.overflowButtons]
|
||||
: this.overflowButtons;
|
||||
|
||||
// Filter visible buttons (only include buttons that were visible before being hidden)
|
||||
// Also filter menu-only buttons based on edit mode state
|
||||
const isEditMode = this.editModeManager?.isEditMode || false;
|
||||
const visibleButtons = buttonsToShow.filter(btn => {
|
||||
// Check if button was marked as visible
|
||||
if (btn.dataset.wasVisible !== 'true') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Menu-only buttons only show when in edit mode
|
||||
if (btn.classList.contains('rpg-menu-only-btn')) {
|
||||
return isEditMode;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (visibleButtons.length === 0) {
|
||||
this.dropdownMenu.innerHTML = '<div class="rpg-dropdown-empty">No actions available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Create menu items
|
||||
visibleButtons.forEach(btn => {
|
||||
const menuItem = this.createMenuItem(btn);
|
||||
this.dropdownMenu.appendChild(menuItem);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a menu item from a button
|
||||
* @param {HTMLElement} button - Button element to convert
|
||||
* @returns {HTMLElement} Menu item element
|
||||
*/
|
||||
createMenuItem(button) {
|
||||
const item = document.createElement('button');
|
||||
item.className = 'rpg-dropdown-item';
|
||||
item.setAttribute('role', 'menuitem');
|
||||
|
||||
// Copy icon
|
||||
const icon = button.querySelector('i');
|
||||
if (icon) {
|
||||
item.innerHTML = icon.outerHTML;
|
||||
}
|
||||
|
||||
// Add label
|
||||
const label = document.createElement('span');
|
||||
label.textContent = button.getAttribute('title') || button.getAttribute('aria-label') || 'Action';
|
||||
item.appendChild(label);
|
||||
|
||||
// Copy click handler
|
||||
item.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
button.click();
|
||||
this.closeMenu();
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle menu open/closed
|
||||
*/
|
||||
toggleMenu() {
|
||||
if (this.menuOpen) {
|
||||
this.closeMenu();
|
||||
} else {
|
||||
this.openMenu();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open dropdown menu
|
||||
*/
|
||||
openMenu() {
|
||||
if (this.menuOpen) return;
|
||||
|
||||
this.menuOpen = true;
|
||||
this.dropdownMenu.style.display = 'block';
|
||||
|
||||
// Update aria-expanded
|
||||
const menuBtn = this.currentMode === 'compact' || this.currentMode === 'ultraCompact'
|
||||
? this.hamburgerMenuBtn
|
||||
: this.overflowMenuBtn;
|
||||
menuBtn.setAttribute('aria-expanded', 'true');
|
||||
|
||||
// Add close listeners
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', this.boundClickOutside);
|
||||
document.addEventListener('keydown', this.boundKeyHandler);
|
||||
}, 10);
|
||||
|
||||
// Focus first menu item
|
||||
const firstItem = this.dropdownMenu.querySelector('.rpg-dropdown-item');
|
||||
if (firstItem) {
|
||||
firstItem.focus();
|
||||
}
|
||||
|
||||
console.log('[HeaderOverflowManager] Menu opened');
|
||||
}
|
||||
|
||||
/**
|
||||
* Close dropdown menu
|
||||
*/
|
||||
closeMenu() {
|
||||
if (!this.menuOpen) return;
|
||||
|
||||
this.menuOpen = false;
|
||||
this.dropdownMenu.style.display = 'none';
|
||||
|
||||
// Update aria-expanded
|
||||
this.overflowMenuBtn.setAttribute('aria-expanded', 'false');
|
||||
this.hamburgerMenuBtn.setAttribute('aria-expanded', 'false');
|
||||
|
||||
// Remove close listeners
|
||||
document.removeEventListener('click', this.boundClickOutside);
|
||||
document.removeEventListener('keydown', this.boundKeyHandler);
|
||||
|
||||
console.log('[HeaderOverflowManager] Menu closed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click outside menu
|
||||
* @param {MouseEvent} e - Click event
|
||||
*/
|
||||
handleClickOutside(e) {
|
||||
if (!this.dropdownMenu.contains(e.target) &&
|
||||
!this.overflowMenuBtn.contains(e.target) &&
|
||||
!this.hamburgerMenuBtn.contains(e.target)) {
|
||||
this.closeMenu();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard navigation
|
||||
* @param {KeyboardEvent} e - Keyboard event
|
||||
*/
|
||||
handleKeyDown(e) {
|
||||
if (!this.menuOpen) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
this.closeMenu();
|
||||
// Return focus to menu button
|
||||
const menuBtn = this.currentMode === 'compact' || this.currentMode === 'ultraCompact'
|
||||
? this.hamburgerMenuBtn
|
||||
: this.overflowMenuBtn;
|
||||
menuBtn.focus();
|
||||
break;
|
||||
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
this.focusNextItem();
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
this.focusPreviousItem();
|
||||
break;
|
||||
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
this.focusFirstItem();
|
||||
break;
|
||||
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
this.focusLastItem();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus management helpers
|
||||
*/
|
||||
focusNextItem() {
|
||||
const items = Array.from(this.dropdownMenu.querySelectorAll('.rpg-dropdown-item'));
|
||||
const currentIndex = items.indexOf(document.activeElement);
|
||||
const nextIndex = (currentIndex + 1) % items.length;
|
||||
items[nextIndex]?.focus();
|
||||
}
|
||||
|
||||
focusPreviousItem() {
|
||||
const items = Array.from(this.dropdownMenu.querySelectorAll('.rpg-dropdown-item'));
|
||||
const currentIndex = items.indexOf(document.activeElement);
|
||||
const prevIndex = currentIndex <= 0 ? items.length - 1 : currentIndex - 1;
|
||||
items[prevIndex]?.focus();
|
||||
}
|
||||
|
||||
focusFirstItem() {
|
||||
const firstItem = this.dropdownMenu.querySelector('.rpg-dropdown-item');
|
||||
firstItem?.focus();
|
||||
}
|
||||
|
||||
focusLastItem() {
|
||||
const items = this.dropdownMenu.querySelectorAll('.rpg-dropdown-item');
|
||||
items[items.length - 1]?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh menu (called when edit mode changes)
|
||||
*/
|
||||
refresh() {
|
||||
console.log('[HeaderOverflowManager] Refreshing menu...');
|
||||
if (this.currentMode !== 'full') {
|
||||
this.buildDropdownMenu(this.currentMode === 'compact' || this.currentMode === 'ultraCompact');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the overflow manager
|
||||
*/
|
||||
destroy() {
|
||||
console.log('[HeaderOverflowManager] Destroying...');
|
||||
|
||||
// Disconnect resize observer
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
this.resizeObserver = null;
|
||||
}
|
||||
|
||||
// Clear timeout
|
||||
if (this.resizeTimeout) {
|
||||
clearTimeout(this.resizeTimeout);
|
||||
}
|
||||
|
||||
// Remove event listeners
|
||||
this.overflowMenuBtn?.removeEventListener('click', this.boundMenuToggle);
|
||||
this.hamburgerMenuBtn?.removeEventListener('click', this.boundMenuToggle);
|
||||
document.removeEventListener('click', this.boundClickOutside);
|
||||
document.removeEventListener('keydown', this.boundKeyHandler);
|
||||
|
||||
// Close menu
|
||||
if (this.menuOpen) {
|
||||
this.closeMenu();
|
||||
}
|
||||
|
||||
console.log('[HeaderOverflowManager] Destroyed');
|
||||
}
|
||||
}
|
||||
@@ -1,463 +0,0 @@
|
||||
/**
|
||||
* Layout Persistence System
|
||||
*
|
||||
* Handles saving, loading, importing, and exporting dashboard layouts.
|
||||
* Provides debounced auto-save and manual save operations.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PersistenceConfig
|
||||
* @property {Function} onSave - Callback when layout is saved (layout) => void
|
||||
* @property {Function} onLoad - Callback when layout is loaded (layout) => void
|
||||
* @property {Function} onError - Callback when error occurs (error) => void
|
||||
* @property {number} debounceMs - Debounce delay for auto-save (default: 500ms)
|
||||
*/
|
||||
|
||||
export class LayoutPersistence {
|
||||
/**
|
||||
* @param {PersistenceConfig} config - Configuration object
|
||||
*/
|
||||
constructor(config = {}) {
|
||||
this.onSave = config.onSave;
|
||||
this.onLoad = config.onLoad;
|
||||
this.onError = config.onError;
|
||||
this.debounceMs = config.debounceMs || 500;
|
||||
|
||||
this.saveTimeout = null;
|
||||
this.lastSaveTime = 0;
|
||||
this.isSaving = false;
|
||||
this.pendingSave = false;
|
||||
|
||||
this.changeListeners = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save layout to storage
|
||||
* @param {Object} dashboard - Dashboard configuration
|
||||
* @param {boolean} immediate - Skip debounce if true
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async saveLayout(dashboard, immediate = false) {
|
||||
if (!dashboard) {
|
||||
throw new Error('Dashboard configuration is required');
|
||||
}
|
||||
|
||||
// Validate dashboard structure
|
||||
if (!this.validateDashboard(dashboard)) {
|
||||
throw new Error('Invalid dashboard configuration');
|
||||
}
|
||||
|
||||
if (immediate) {
|
||||
return this.performSave(dashboard);
|
||||
} else {
|
||||
return this.debouncedSave(dashboard);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced save (waits for quiet period)
|
||||
* @param {Object} dashboard - Dashboard configuration
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async debouncedSave(dashboard) {
|
||||
// Clear existing timeout
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
}
|
||||
|
||||
// Set pending flag
|
||||
this.pendingSave = true;
|
||||
|
||||
// Schedule save
|
||||
return new Promise((resolve, reject) => {
|
||||
this.saveTimeout = setTimeout(async () => {
|
||||
try {
|
||||
await this.performSave(dashboard);
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
}, this.debounceMs);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform actual save operation
|
||||
* @param {Object} dashboard - Dashboard configuration
|
||||
* @returns {Promise<void>}
|
||||
* @private
|
||||
*/
|
||||
async performSave(dashboard) {
|
||||
this.isSaving = true;
|
||||
this.notifyChange('saveStarted', { timestamp: Date.now() });
|
||||
|
||||
try {
|
||||
// Clone to avoid mutations
|
||||
const layoutData = JSON.parse(JSON.stringify(dashboard));
|
||||
|
||||
// Add metadata
|
||||
layoutData.metadata = {
|
||||
version: dashboard.version || 2,
|
||||
savedAt: new Date().toISOString(),
|
||||
appVersion: '2.0.0'
|
||||
};
|
||||
|
||||
// Save to localStorage (in real implementation, use extensionSettings)
|
||||
localStorage.setItem('rpg-companion-dashboard', JSON.stringify(layoutData));
|
||||
|
||||
this.lastSaveTime = Date.now();
|
||||
this.isSaving = false;
|
||||
this.pendingSave = false;
|
||||
|
||||
this.notifyChange('saveSuceed', { timestamp: this.lastSaveTime, layout: layoutData });
|
||||
console.log('[LayoutPersistence] Layout saved successfully');
|
||||
|
||||
if (this.onSave) {
|
||||
this.onSave(layoutData);
|
||||
}
|
||||
} catch (error) {
|
||||
this.isSaving = false;
|
||||
this.pendingSave = false;
|
||||
this.notifyChange('saveError', { error });
|
||||
console.error('[LayoutPersistence] Save failed:', error);
|
||||
|
||||
if (this.onError) {
|
||||
this.onError(error);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load layout from storage
|
||||
* @returns {Promise<Object|null>} Dashboard configuration or null if not found
|
||||
*/
|
||||
async loadLayout() {
|
||||
this.notifyChange('loadStarted', { timestamp: Date.now() });
|
||||
|
||||
try {
|
||||
// Load from localStorage (in real implementation, use extensionSettings)
|
||||
const stored = localStorage.getItem('rpg-companion-dashboard');
|
||||
|
||||
if (!stored) {
|
||||
console.log('[LayoutPersistence] No saved layout found');
|
||||
this.notifyChange('loadComplete', { layout: null });
|
||||
return null;
|
||||
}
|
||||
|
||||
const layoutData = JSON.parse(stored);
|
||||
|
||||
// Migrate old pixel values to rem units
|
||||
if (layoutData.gridConfig) {
|
||||
// Check if we have old pixel values (rowHeight > 20 is likely pixels)
|
||||
if (layoutData.gridConfig.rowHeight > 20) {
|
||||
console.log('[LayoutPersistence] Migrating old px values to rem');
|
||||
layoutData.gridConfig.rowHeight = 5; // 80px → 5rem
|
||||
layoutData.gridConfig.gap = 0.75; // 12px → 0.75rem
|
||||
console.log('[LayoutPersistence] Converted gridConfig: rowHeight=5rem, gap=0.75rem');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate loaded data
|
||||
if (!this.validateDashboard(layoutData)) {
|
||||
throw new Error('Loaded layout is invalid');
|
||||
}
|
||||
|
||||
console.log('[LayoutPersistence] Layout loaded successfully');
|
||||
this.notifyChange('loadSuccess', { layout: layoutData });
|
||||
|
||||
if (this.onLoad) {
|
||||
this.onLoad(layoutData);
|
||||
}
|
||||
|
||||
return layoutData;
|
||||
} catch (error) {
|
||||
this.notifyChange('loadError', { error });
|
||||
console.error('[LayoutPersistence] Load failed:', error);
|
||||
|
||||
if (this.onError) {
|
||||
this.onError(error);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export layout as JSON file
|
||||
* @param {Object} dashboard - Dashboard configuration
|
||||
* @param {string} filename - Export filename
|
||||
*/
|
||||
exportLayout(dashboard, filename = 'dashboard-layout.json') {
|
||||
if (!dashboard) {
|
||||
throw new Error('Dashboard configuration is required');
|
||||
}
|
||||
|
||||
if (!this.validateDashboard(dashboard)) {
|
||||
throw new Error('Invalid dashboard configuration');
|
||||
}
|
||||
|
||||
try {
|
||||
// Clone and add metadata
|
||||
const exportData = JSON.parse(JSON.stringify(dashboard));
|
||||
exportData.metadata = {
|
||||
version: dashboard.version || 2,
|
||||
exportedAt: new Date().toISOString(),
|
||||
appVersion: '2.0.0',
|
||||
exportedBy: 'RPG Companion v2.0'
|
||||
};
|
||||
|
||||
// Create blob and download
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
|
||||
type: 'application/json'
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
console.log('[LayoutPersistence] Layout exported:', filename);
|
||||
this.notifyChange('exportSuccess', { filename });
|
||||
} catch (error) {
|
||||
console.error('[LayoutPersistence] Export failed:', error);
|
||||
this.notifyChange('exportError', { error });
|
||||
|
||||
if (this.onError) {
|
||||
this.onError(error);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import layout from JSON file
|
||||
* @param {File} file - JSON file to import
|
||||
* @returns {Promise<Object>} Imported dashboard configuration
|
||||
*/
|
||||
async importLayout(file) {
|
||||
if (!file) {
|
||||
throw new Error('File is required');
|
||||
}
|
||||
|
||||
if (file.type !== 'application/json' && !file.name.endsWith('.json')) {
|
||||
throw new Error('File must be JSON format');
|
||||
}
|
||||
|
||||
this.notifyChange('importStarted', { filename: file.name });
|
||||
|
||||
try {
|
||||
const text = await this.readFileAsText(file);
|
||||
const layoutData = JSON.parse(text);
|
||||
|
||||
// Validate imported data
|
||||
if (!this.validateDashboard(layoutData)) {
|
||||
throw new Error('Imported file contains invalid dashboard configuration');
|
||||
}
|
||||
|
||||
console.log('[LayoutPersistence] Layout imported:', file.name);
|
||||
this.notifyChange('importSuccess', { layout: layoutData, filename: file.name });
|
||||
|
||||
return layoutData;
|
||||
} catch (error) {
|
||||
console.error('[LayoutPersistence] Import failed:', error);
|
||||
this.notifyChange('importError', { error, filename: file.name });
|
||||
|
||||
if (this.onError) {
|
||||
this.onError(error);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset layout to default
|
||||
* @param {Object} defaultDashboard - Default dashboard configuration
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async resetToDefault(defaultDashboard) {
|
||||
if (!defaultDashboard) {
|
||||
throw new Error('Default dashboard configuration is required');
|
||||
}
|
||||
|
||||
if (!this.validateDashboard(defaultDashboard)) {
|
||||
throw new Error('Invalid default dashboard configuration');
|
||||
}
|
||||
|
||||
try {
|
||||
// Clear saved layout
|
||||
localStorage.removeItem('rpg-companion-dashboard');
|
||||
|
||||
// Save default as current
|
||||
await this.saveLayout(defaultDashboard, true);
|
||||
|
||||
console.log('[LayoutPersistence] Layout reset to default');
|
||||
this.notifyChange('resetSuccess', { layout: defaultDashboard });
|
||||
} catch (error) {
|
||||
console.error('[LayoutPersistence] Reset failed:', error);
|
||||
this.notifyChange('resetError', { error });
|
||||
|
||||
if (this.onError) {
|
||||
this.onError(error);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate dashboard configuration
|
||||
* @param {Object} dashboard - Dashboard to validate
|
||||
* @returns {boolean} True if valid
|
||||
* @private
|
||||
*/
|
||||
validateDashboard(dashboard) {
|
||||
if (!dashboard || typeof dashboard !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check required fields
|
||||
if (!dashboard.version || !dashboard.gridConfig || !Array.isArray(dashboard.tabs)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate grid config
|
||||
const grid = dashboard.gridConfig;
|
||||
if (typeof grid.columns !== 'number' || typeof grid.rowHeight !== 'number') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate tabs
|
||||
for (const tab of dashboard.tabs) {
|
||||
if (!tab.id || !tab.name || !Array.isArray(tab.widgets)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate widgets in tab
|
||||
for (const widget of tab.widgets) {
|
||||
if (!widget.id || !widget.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof widget.x !== 'number' || typeof widget.y !== 'number' ||
|
||||
typeof widget.w !== 'number' || typeof widget.h !== 'number') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file as text
|
||||
* @param {File} file - File to read
|
||||
* @returns {Promise<string>} File contents
|
||||
* @private
|
||||
*/
|
||||
readFileAsText(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
resolve(e.target.result);
|
||||
};
|
||||
|
||||
reader.onerror = (e) => {
|
||||
reject(new Error('Failed to read file'));
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if save is pending
|
||||
* @returns {boolean} True if save is pending
|
||||
*/
|
||||
hasPendingSave() {
|
||||
return this.pendingSave;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently saving
|
||||
* @returns {boolean} True if saving
|
||||
*/
|
||||
getIsSaving() {
|
||||
return this.isSaving;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last save time
|
||||
* @returns {number} Timestamp of last save
|
||||
*/
|
||||
getLastSaveTime() {
|
||||
return this.lastSaveTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force pending save to execute immediately
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async flushPendingSave() {
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
this.saveTimeout = null;
|
||||
}
|
||||
|
||||
if (this.pendingSave) {
|
||||
// The pending save will be triggered by the caller
|
||||
console.log('[LayoutPersistence] Flushing pending save');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register change listener
|
||||
* @param {Function} callback - Callback function (event, data) => void
|
||||
*/
|
||||
onChange(callback) {
|
||||
this.changeListeners.add(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister change listener
|
||||
* @param {Function} callback - Callback to remove
|
||||
*/
|
||||
offChange(callback) {
|
||||
this.changeListeners.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all listeners of a change
|
||||
* @private
|
||||
*/
|
||||
notifyChange(event, data) {
|
||||
this.changeListeners.forEach(callback => {
|
||||
try {
|
||||
callback(event, data);
|
||||
} catch (error) {
|
||||
console.error('[LayoutPersistence] Error in change listener:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy persistence manager
|
||||
*/
|
||||
destroy() {
|
||||
// Cancel pending save
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
this.saveTimeout = null;
|
||||
}
|
||||
|
||||
this.changeListeners.clear();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,230 +0,0 @@
|
||||
/**
|
||||
* Prompt Dialog System
|
||||
*
|
||||
* Provides styled prompt dialogs for text input, matching extension theming.
|
||||
* Used for tab renaming, creation, etc.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Show a prompt dialog with text input
|
||||
* @param {Object} options - Dialog options
|
||||
* @param {string} options.title - Dialog title
|
||||
* @param {string} options.message - Dialog message/label
|
||||
* @param {string} [options.defaultValue=''] - Default input value
|
||||
* @param {string} [options.placeholder=''] - Input placeholder
|
||||
* @param {string} [options.confirmText='OK'] - Confirm button text
|
||||
* @param {string} [options.cancelText='Cancel'] - Cancel button text
|
||||
* @param {Function} [options.validator] - Optional validation function (value) => {valid: boolean, error: string}
|
||||
* @returns {Promise<string|null>} Resolves to input value if confirmed, null if cancelled
|
||||
*/
|
||||
export function showPromptDialog(options) {
|
||||
return new Promise((resolve) => {
|
||||
const {
|
||||
title = 'Enter Value',
|
||||
message = '',
|
||||
defaultValue = '',
|
||||
placeholder = '',
|
||||
confirmText = 'OK',
|
||||
cancelText = 'Cancel',
|
||||
validator = null
|
||||
} = options;
|
||||
|
||||
// Create modal container (uses .rpg-modal class for theming)
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'rpg-modal rpg-prompt-modal';
|
||||
modal.style.display = 'flex';
|
||||
|
||||
// Create modal content (uses .rpg-modal-content class for theming)
|
||||
const modalContent = document.createElement('div');
|
||||
modalContent.className = 'rpg-modal-content rpg-prompt-content';
|
||||
|
||||
// Copy theme from panel so modal inherits theme CSS variables
|
||||
const panel = document.querySelector('.rpg-panel');
|
||||
if (panel && panel.dataset.theme) {
|
||||
modalContent.dataset.theme = panel.dataset.theme;
|
||||
modalContent.style.cssText = `
|
||||
min-width: 400px;
|
||||
max-width: 90vw;
|
||||
`;
|
||||
} else {
|
||||
// For default theme: read computed colors from panel and apply as solid (1.0 opacity)
|
||||
const computedStyle = window.getComputedStyle(panel);
|
||||
const bgColor = computedStyle.getPropertyValue('--rpg-bg').trim();
|
||||
const accentColor = computedStyle.getPropertyValue('--rpg-accent').trim();
|
||||
|
||||
// Convert rgba with 0.9 opacity to 1.0 opacity
|
||||
const solidBg = bgColor.replace(/rgba\(([^)]+),\s*[\d.]+\)/, 'rgba($1, 1)');
|
||||
const solidAccent = accentColor.replace(/rgba\(([^)]+),\s*[\d.]+\)/, 'rgba($1, 1)');
|
||||
|
||||
// Apply solid background + ensure full opacity
|
||||
modalContent.style.cssText = `
|
||||
min-width: 400px;
|
||||
max-width: 90vw;
|
||||
background: linear-gradient(135deg, ${solidAccent} 0%, ${solidBg} 100%) !important;
|
||||
opacity: 1 !important;
|
||||
`;
|
||||
}
|
||||
|
||||
// Header (uses .rpg-modal-header class)
|
||||
const header = document.createElement('div');
|
||||
header.className = 'rpg-modal-header';
|
||||
|
||||
const headerContent = document.createElement('div');
|
||||
headerContent.style.display = 'flex';
|
||||
headerContent.style.alignItems = 'center';
|
||||
headerContent.style.gap = '0.5rem';
|
||||
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'fa-solid fa-pencil';
|
||||
icon.style.color = 'var(--rpg-highlight)';
|
||||
|
||||
const titleEl = document.createElement('h3');
|
||||
titleEl.textContent = title;
|
||||
titleEl.style.margin = '0';
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'rpg-modal-close';
|
||||
closeBtn.innerHTML = '<i class="fa-solid fa-times"></i>';
|
||||
|
||||
headerContent.appendChild(icon);
|
||||
headerContent.appendChild(titleEl);
|
||||
header.appendChild(headerContent);
|
||||
header.appendChild(closeBtn);
|
||||
|
||||
// Body (uses .rpg-modal-body class)
|
||||
const body = document.createElement('div');
|
||||
body.className = 'rpg-modal-body';
|
||||
|
||||
if (message) {
|
||||
const messageEl = document.createElement('p');
|
||||
messageEl.textContent = message;
|
||||
messageEl.style.cssText = `
|
||||
margin: 0 0 1rem 0;
|
||||
color: var(--rpg-text);
|
||||
`;
|
||||
body.appendChild(messageEl);
|
||||
}
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = defaultValue;
|
||||
input.placeholder = placeholder;
|
||||
input.style.cssText = `
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background: var(--rpg-accent);
|
||||
border: 1px solid var(--rpg-border);
|
||||
border-radius: 4px;
|
||||
color: var(--rpg-text);
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
`;
|
||||
|
||||
const errorEl = document.createElement('div');
|
||||
errorEl.className = 'rpg-prompt-error';
|
||||
errorEl.style.cssText = `
|
||||
margin-top: 0.5rem;
|
||||
color: var(--rpg-highlight);
|
||||
font-size: 0.875rem;
|
||||
min-height: 1.25rem;
|
||||
`;
|
||||
|
||||
body.appendChild(input);
|
||||
body.appendChild(errorEl);
|
||||
|
||||
// Footer (uses .rpg-modal-footer class)
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'rpg-modal-footer';
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'rpg-btn-secondary';
|
||||
cancelBtn.innerHTML = `<i class="fa-solid fa-times"></i> ${cancelText}`;
|
||||
|
||||
const confirmBtn = document.createElement('button');
|
||||
confirmBtn.className = 'rpg-btn-primary';
|
||||
confirmBtn.innerHTML = `<i class="fa-solid fa-check"></i> ${confirmText}`;
|
||||
|
||||
footer.appendChild(cancelBtn);
|
||||
footer.appendChild(confirmBtn);
|
||||
|
||||
// Assemble modal
|
||||
modalContent.appendChild(header);
|
||||
modalContent.appendChild(body);
|
||||
modalContent.appendChild(footer);
|
||||
modal.appendChild(modalContent);
|
||||
|
||||
// Append to body
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Validation helper
|
||||
const validate = () => {
|
||||
if (!validator) return { valid: true, error: '' };
|
||||
const result = validator(input.value);
|
||||
errorEl.textContent = result.error || '';
|
||||
return result;
|
||||
};
|
||||
|
||||
// Handle confirm
|
||||
const handleConfirm = () => {
|
||||
const validation = validate();
|
||||
if (!validation.valid) {
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
modal.remove();
|
||||
cleanup();
|
||||
resolve(input.value);
|
||||
};
|
||||
|
||||
// Handle cancel
|
||||
const handleCancel = () => {
|
||||
modal.remove();
|
||||
cleanup();
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
// Handle keyboard
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancel();
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleConfirm();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle backdrop click
|
||||
const handleBackdropClick = (e) => {
|
||||
if (e.target === modal) {
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up event listeners
|
||||
const cleanup = () => {
|
||||
confirmBtn.removeEventListener('click', handleConfirm);
|
||||
cancelBtn.removeEventListener('click', handleCancel);
|
||||
closeBtn.removeEventListener('click', handleCancel);
|
||||
input.removeEventListener('keydown', handleKeyDown);
|
||||
modal.removeEventListener('click', handleBackdropClick);
|
||||
};
|
||||
|
||||
// Attach event listeners
|
||||
confirmBtn.addEventListener('click', handleConfirm);
|
||||
cancelBtn.addEventListener('click', handleCancel);
|
||||
closeBtn.addEventListener('click', handleCancel);
|
||||
input.addEventListener('keydown', handleKeyDown);
|
||||
modal.addEventListener('click', handleBackdropClick);
|
||||
|
||||
// Focus input and select default text
|
||||
setTimeout(() => {
|
||||
input.focus();
|
||||
if (defaultValue) {
|
||||
input.select();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
@@ -1,667 +0,0 @@
|
||||
/**
|
||||
* Widget Resize Handler
|
||||
*
|
||||
* Handles widget resizing with mouse and touch support.
|
||||
* Provides visual feedback, grid snapping, and size constraints.
|
||||
*/
|
||||
|
||||
// Performance: Disable console logging (console.error still active)
|
||||
const DEBUG = false;
|
||||
const console = DEBUG ? window.console : {
|
||||
log: () => {},
|
||||
warn: () => {},
|
||||
error: window.console.error.bind(window.console)
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {Object} ResizeState
|
||||
* @property {HTMLElement} element - Element being resized
|
||||
* @property {Object} widget - Widget data object
|
||||
* @property {string} handle - Handle being dragged (e.g., 'se', 'nw', 'n', 's', 'e', 'w')
|
||||
* @property {number} startX - Initial pointer X
|
||||
* @property {number} startY - Initial pointer Y
|
||||
* @property {number} startWidth - Initial widget width (grid units)
|
||||
* @property {number} startHeight - Initial widget height (grid units)
|
||||
* @property {number} startGridX - Initial widget X (grid units)
|
||||
* @property {number} startGridY - Initial widget Y (grid units)
|
||||
* @property {HTMLElement} overlay - Dimension overlay element
|
||||
* @property {boolean} isResizing - Whether resize is in progress
|
||||
*/
|
||||
|
||||
export class ResizeHandler {
|
||||
/**
|
||||
* @param {Object} gridEngine - GridEngine instance
|
||||
* @param {Object} options - Configuration options
|
||||
*/
|
||||
constructor(gridEngine, options = {}) {
|
||||
this.gridEngine = gridEngine;
|
||||
this.editManager = options.editManager || null; // Reference to EditModeManager for lock state
|
||||
this.resizeHandlesOverlay = options.resizeHandlesOverlay || null; // Overlay container for handles
|
||||
this.options = {
|
||||
showDimensions: true,
|
||||
showGrid: true,
|
||||
minWidth: 2,
|
||||
minHeight: 2,
|
||||
maxWidth: 12,
|
||||
maxHeight: 10,
|
||||
touchDelay: 150,
|
||||
...options
|
||||
};
|
||||
|
||||
this.resizeState = null;
|
||||
this.resizeHandlers = new Map();
|
||||
this.gridOverlay = null;
|
||||
this.touchTimer = null;
|
||||
|
||||
// Bound event handlers for cleanup
|
||||
this.boundMouseMove = this.onMouseMove.bind(this);
|
||||
this.boundMouseUp = this.onMouseUp.bind(this);
|
||||
this.boundTouchMove = this.onTouchMove.bind(this);
|
||||
this.boundTouchEnd = this.onTouchEnd.bind(this);
|
||||
this.boundKeyDown = this.onKeyDown.bind(this);
|
||||
|
||||
// Handle types and their cursor styles
|
||||
this.handleTypes = {
|
||||
'nw': 'nwse-resize',
|
||||
'n': 'ns-resize',
|
||||
'ne': 'nesw-resize',
|
||||
'e': 'ew-resize',
|
||||
'se': 'nwse-resize',
|
||||
's': 'ns-resize',
|
||||
'sw': 'nesw-resize',
|
||||
'w': 'ew-resize'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize resize functionality on a widget element
|
||||
* @param {HTMLElement} element - Widget DOM element
|
||||
* @param {Object} widget - Widget data object
|
||||
* @param {Function} onResizeEnd - Callback when resize completes (widget, newW, newH, newX, newY)
|
||||
* @param {Object} constraints - Size constraints {minW, minH, maxW, maxH}
|
||||
* @param {Array<Object>} widgets - All widgets (for grid height calculation)
|
||||
*/
|
||||
initWidget(element, widget, onResizeEnd, constraints = {}, widgets = []) {
|
||||
// Create resize handles
|
||||
const handles = this.createResizeHandles();
|
||||
|
||||
// Store reference to widget element for positioning
|
||||
handles.dataset.widgetId = element.id;
|
||||
|
||||
// Append to overlay instead of widget to prevent overflow/scrollbar issues
|
||||
if (this.resizeHandlesOverlay) {
|
||||
this.resizeHandlesOverlay.appendChild(handles);
|
||||
// Position handles to match widget bounds
|
||||
this.updateHandlePosition(handles, element);
|
||||
} else {
|
||||
// Fallback to old behavior if overlay not available
|
||||
element.appendChild(handles);
|
||||
}
|
||||
|
||||
// Store constraints
|
||||
const widgetConstraints = {
|
||||
minW: constraints.minW || this.options.minWidth,
|
||||
minH: constraints.minH || this.options.minHeight,
|
||||
maxW: constraints.maxW || this.options.maxWidth,
|
||||
maxH: constraints.maxH || this.options.maxHeight
|
||||
};
|
||||
|
||||
// Attach event listeners to each handle
|
||||
const handleElements = handles.querySelectorAll('.resize-handle');
|
||||
const handleListeners = [];
|
||||
|
||||
handleElements.forEach(handleEl => {
|
||||
const handleType = handleEl.dataset.handle;
|
||||
|
||||
const mouseDownHandler = (e) => {
|
||||
if (e.button !== 0) return;
|
||||
// Don't resize if widgets are locked
|
||||
if (this.editManager?.isWidgetsLocked()) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.startResize(e, handleType, element, widget, onResizeEnd, widgetConstraints, widgets);
|
||||
};
|
||||
|
||||
const touchStartHandler = (e) => {
|
||||
// Don't resize if widgets are locked
|
||||
if (this.editManager?.isWidgetsLocked()) {
|
||||
return;
|
||||
}
|
||||
this.touchTimer = setTimeout(() => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.startResize(e.touches[0], handleType, element, widget, onResizeEnd, widgetConstraints, widgets);
|
||||
}, this.options.touchDelay);
|
||||
};
|
||||
|
||||
const touchCancelHandler = () => {
|
||||
if (this.touchTimer) {
|
||||
clearTimeout(this.touchTimer);
|
||||
this.touchTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
handleEl.addEventListener('mousedown', mouseDownHandler);
|
||||
handleEl.addEventListener('touchstart', touchStartHandler, { passive: false });
|
||||
handleEl.addEventListener('touchcancel', touchCancelHandler);
|
||||
handleEl.addEventListener('touchend', touchCancelHandler);
|
||||
|
||||
handleListeners.push({
|
||||
element: handleEl,
|
||||
mouseDownHandler,
|
||||
touchStartHandler,
|
||||
touchCancelHandler
|
||||
});
|
||||
});
|
||||
|
||||
// Store handlers for cleanup
|
||||
this.resizeHandlers.set(element, {
|
||||
handles,
|
||||
handleListeners
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove resize functionality from a widget element
|
||||
* @param {HTMLElement} element - Widget DOM element
|
||||
*/
|
||||
destroyWidget(element) {
|
||||
const handlers = this.resizeHandlers.get(element);
|
||||
if (!handlers) return;
|
||||
|
||||
const { handles, handleListeners } = handlers;
|
||||
|
||||
// Remove event listeners
|
||||
handleListeners.forEach(({ element: handleEl, mouseDownHandler, touchStartHandler, touchCancelHandler }) => {
|
||||
handleEl.removeEventListener('mousedown', mouseDownHandler);
|
||||
handleEl.removeEventListener('touchstart', touchStartHandler);
|
||||
handleEl.removeEventListener('touchcancel', touchCancelHandler);
|
||||
handleEl.removeEventListener('touchend', touchCancelHandler);
|
||||
});
|
||||
|
||||
// Remove handle container
|
||||
handles.remove();
|
||||
|
||||
this.resizeHandlers.delete(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create resize handle elements
|
||||
* @returns {HTMLElement} Container with all resize handles
|
||||
*/
|
||||
createResizeHandles() {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'resize-handles';
|
||||
container.style.position = 'absolute';
|
||||
container.style.inset = '0';
|
||||
container.style.pointerEvents = 'none';
|
||||
|
||||
// Create 8 handles (4 corners + 4 edges)
|
||||
Object.entries(this.handleTypes).forEach(([handleType, cursor]) => {
|
||||
const handle = document.createElement('div');
|
||||
handle.className = `resize-handle resize-handle-${handleType}`;
|
||||
handle.dataset.handle = handleType;
|
||||
handle.style.position = 'absolute';
|
||||
handle.style.pointerEvents = 'auto';
|
||||
handle.style.cursor = cursor;
|
||||
handle.style.width = '12px';
|
||||
handle.style.height = '12px';
|
||||
handle.style.background = 'rgba(78, 204, 163, 0.8)';
|
||||
handle.style.border = '2px solid white';
|
||||
handle.style.borderRadius = '3px';
|
||||
handle.style.zIndex = '100';
|
||||
|
||||
// Position handles
|
||||
// Vertical: -6px offset (adequate gap between rows)
|
||||
if (handleType.includes('n')) handle.style.top = '-6px';
|
||||
if (handleType.includes('s')) handle.style.bottom = '-6px';
|
||||
// Horizontal: -3px offset (prevent overlap when widgets are side-by-side)
|
||||
if (handleType.includes('w')) handle.style.left = '-3px';
|
||||
if (handleType.includes('e')) handle.style.right = '-3px';
|
||||
|
||||
// Center edge handles
|
||||
if (handleType === 'n' || handleType === 's') {
|
||||
handle.style.left = '50%';
|
||||
handle.style.transform = 'translateX(-50%)';
|
||||
}
|
||||
if (handleType === 'w' || handleType === 'e') {
|
||||
handle.style.top = '50%';
|
||||
handle.style.transform = 'translateY(-50%)';
|
||||
}
|
||||
|
||||
container.appendChild(handle);
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update handle container position to match widget bounds
|
||||
* @param {HTMLElement} handles - Resize handles container
|
||||
* @param {HTMLElement} element - Widget element
|
||||
*/
|
||||
updateHandlePosition(handles, element) {
|
||||
if (!handles || !element) return;
|
||||
|
||||
const overlay = this.resizeHandlesOverlay;
|
||||
if (!overlay) return;
|
||||
|
||||
// Use offset properties for parent-relative positioning
|
||||
// Both widget and overlay are children of the same grid container
|
||||
handles.style.left = `${element.offsetLeft}px`;
|
||||
handles.style.top = `${element.offsetTop}px`;
|
||||
handles.style.width = `${element.offsetWidth}px`;
|
||||
handles.style.height = `${element.offsetHeight}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start resize operation
|
||||
* @param {MouseEvent|Touch} e - Pointer event
|
||||
* @param {string} handleType - Handle type (e.g., 'se', 'nw')
|
||||
* @param {HTMLElement} element - Element being resized
|
||||
* @param {Object} widget - Widget data
|
||||
* @param {Function} onResizeEnd - Callback when resize completes
|
||||
* @param {Object} constraints - Size constraints
|
||||
* @param {Array<Object>} widgets - All widgets (for grid height calculation)
|
||||
*/
|
||||
startResize(e, handleType, element, widget, onResizeEnd, constraints, widgets = []) {
|
||||
// Create dimension overlay
|
||||
const overlay = this.createDimensionOverlay();
|
||||
|
||||
this.resizeState = {
|
||||
element,
|
||||
widget: { ...widget },
|
||||
handle: handleType,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
startWidth: widget.w,
|
||||
startHeight: widget.h,
|
||||
startGridX: widget.x,
|
||||
startGridY: widget.y,
|
||||
overlay,
|
||||
isResizing: true,
|
||||
onResizeEnd,
|
||||
constraints,
|
||||
widgets
|
||||
};
|
||||
|
||||
// Add event listeners
|
||||
document.addEventListener('mousemove', this.boundMouseMove);
|
||||
document.addEventListener('mouseup', this.boundMouseUp);
|
||||
document.addEventListener('touchmove', this.boundTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', this.boundTouchEnd);
|
||||
document.addEventListener('keydown', this.boundKeyDown);
|
||||
|
||||
// Show grid overlay
|
||||
if (this.options.showGrid) {
|
||||
this.showGridOverlay();
|
||||
}
|
||||
|
||||
// Add resizing class
|
||||
element.classList.add('resizing');
|
||||
|
||||
console.log('[ResizeHandler] Started resizing widget:', widget.id, 'handle:', handleType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse move during resize
|
||||
* @param {MouseEvent} e - Mouse event
|
||||
*/
|
||||
onMouseMove(e) {
|
||||
if (!this.resizeState?.isResizing) return;
|
||||
e.preventDefault();
|
||||
this.updateResizeSize(e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle touch move during resize
|
||||
* @param {TouchEvent} e - Touch event
|
||||
*/
|
||||
onTouchMove(e) {
|
||||
if (!this.resizeState?.isResizing) return;
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
this.updateResizeSize(touch.clientX, touch.clientY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update resize dimensions
|
||||
* @param {number} clientX - Pointer X coordinate
|
||||
* @param {number} clientY - Pointer Y coordinate
|
||||
*/
|
||||
updateResizeSize(clientX, clientY) {
|
||||
const { widget, handle, startX, startY, startWidth, startHeight, startGridX, startGridY, constraints, element, overlay } = this.resizeState;
|
||||
|
||||
// Calculate pixel delta
|
||||
const deltaX = clientX - startX;
|
||||
const deltaY = clientY - startY;
|
||||
|
||||
// Convert rem to pixels for calculations
|
||||
const gapPx = this.gridEngine.remToPixels(this.gridEngine.gap);
|
||||
const rowHeightPx = this.gridEngine.remToPixels(this.gridEngine.rowHeight);
|
||||
|
||||
// Get column/row size in pixels (containerWidth already set by ResizeObserver in DashboardManager)
|
||||
const totalGaps = gapPx * (this.gridEngine.columns + 1);
|
||||
const colWidth = (this.gridEngine.containerWidth - totalGaps) / this.gridEngine.columns;
|
||||
|
||||
// Convert pixel delta to grid units
|
||||
const deltaGridX = Math.round(deltaX / (colWidth + gapPx));
|
||||
const deltaGridY = Math.round(deltaY / (rowHeightPx + gapPx));
|
||||
|
||||
// Calculate new dimensions based on handle type
|
||||
let newW = startWidth;
|
||||
let newH = startHeight;
|
||||
let newX = startGridX;
|
||||
let newY = startGridY;
|
||||
|
||||
// Handle width changes
|
||||
if (handle.includes('e')) {
|
||||
newW = startWidth + deltaGridX;
|
||||
} else if (handle.includes('w')) {
|
||||
newW = startWidth - deltaGridX;
|
||||
newX = startGridX + deltaGridX;
|
||||
}
|
||||
|
||||
// Handle height changes
|
||||
if (handle.includes('s')) {
|
||||
newH = startHeight + deltaGridY;
|
||||
} else if (handle.includes('n')) {
|
||||
newH = startHeight - deltaGridY;
|
||||
newY = startGridY + deltaGridY;
|
||||
}
|
||||
|
||||
// Apply constraints
|
||||
newW = Math.max(constraints.minW, Math.min(newW, constraints.maxW));
|
||||
newH = Math.max(constraints.minH, Math.min(newH, constraints.maxH));
|
||||
|
||||
// Ensure doesn't exceed grid bounds
|
||||
newW = Math.min(newW, this.gridEngine.columns - newX);
|
||||
|
||||
// Adjust position if resizing from top/left and hit min size
|
||||
if (handle.includes('w') && newW === constraints.minW) {
|
||||
newX = startGridX + startWidth - constraints.minW;
|
||||
}
|
||||
if (handle.includes('n') && newH === constraints.minH) {
|
||||
newY = startGridY + startHeight - constraints.minH;
|
||||
}
|
||||
|
||||
// Update widget dimensions
|
||||
this.resizeState.widget.w = newW;
|
||||
this.resizeState.widget.h = newH;
|
||||
this.resizeState.widget.x = newX;
|
||||
this.resizeState.widget.y = newY;
|
||||
|
||||
// Update element size
|
||||
const pos = this.gridEngine.getPixelPosition(this.resizeState.widget);
|
||||
element.style.width = pos.width + 'px';
|
||||
element.style.height = pos.height + 'px';
|
||||
element.style.left = pos.left + 'px';
|
||||
element.style.top = pos.top + 'px';
|
||||
|
||||
// Update dimension overlay
|
||||
if (overlay) {
|
||||
overlay.textContent = `${newW}×${newH}`;
|
||||
overlay.style.left = (pos.left + pos.width / 2) + 'px';
|
||||
overlay.style.top = (pos.top + pos.height / 2) + 'px';
|
||||
}
|
||||
|
||||
// Update grid overlay
|
||||
if (this.gridOverlay) {
|
||||
this.highlightGridCells(newX, newY, newW, newH);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse up - end resize
|
||||
* @param {MouseEvent} e - Mouse event
|
||||
*/
|
||||
onMouseUp(e) {
|
||||
if (!this.resizeState?.isResizing) return;
|
||||
e.preventDefault();
|
||||
this.endResize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle touch end - end resize
|
||||
* @param {TouchEvent} e - Touch event
|
||||
*/
|
||||
onTouchEnd(e) {
|
||||
if (!this.resizeState?.isResizing) return;
|
||||
e.preventDefault();
|
||||
this.endResize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard during resize (Escape to cancel)
|
||||
* @param {KeyboardEvent} e - Keyboard event
|
||||
*/
|
||||
onKeyDown(e) {
|
||||
if (!this.resizeState?.isResizing) return;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.cancelResize();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End resize operation and commit size
|
||||
*/
|
||||
endResize() {
|
||||
if (!this.resizeState) return;
|
||||
|
||||
const { element, widget, onResizeEnd } = this.resizeState;
|
||||
|
||||
// Remove resizing class
|
||||
element.classList.remove('resizing');
|
||||
|
||||
// Call callback with new dimensions
|
||||
if (onResizeEnd) {
|
||||
onResizeEnd(widget, widget.w, widget.h, widget.x, widget.y);
|
||||
}
|
||||
|
||||
// Update handle positions to match new widget size
|
||||
const handlerData = this.resizeHandlers.get(element);
|
||||
if (handlerData && handlerData.handles) {
|
||||
this.updateHandlePosition(handlerData.handles, element);
|
||||
}
|
||||
|
||||
this.cleanup();
|
||||
console.log('[ResizeHandler] Resize completed:', widget.id, `${widget.w}×${widget.h} at (${widget.x}, ${widget.y})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel resize operation and restore original size
|
||||
*/
|
||||
cancelResize() {
|
||||
if (!this.resizeState) return;
|
||||
|
||||
const { element, startWidth, startHeight, startGridX, startGridY } = this.resizeState;
|
||||
|
||||
// Restore original size
|
||||
const widget = {
|
||||
x: startGridX,
|
||||
y: startGridY,
|
||||
w: startWidth,
|
||||
h: startHeight
|
||||
};
|
||||
|
||||
const pos = this.gridEngine.getPixelPosition(widget);
|
||||
element.style.width = pos.width + 'px';
|
||||
element.style.height = pos.height + 'px';
|
||||
element.style.left = pos.left + 'px';
|
||||
element.style.top = pos.top + 'px';
|
||||
|
||||
// Remove resizing class
|
||||
element.classList.remove('resizing');
|
||||
|
||||
// Update handle positions to match restored widget size
|
||||
const handlerData = this.resizeHandlers.get(element);
|
||||
if (handlerData && handlerData.handles) {
|
||||
this.updateHandlePosition(handlerData.handles, element);
|
||||
}
|
||||
|
||||
this.cleanup();
|
||||
console.log('[ResizeHandler] Resize cancelled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup after resize ends
|
||||
*/
|
||||
cleanup() {
|
||||
// Remove dimension overlay
|
||||
if (this.resizeState?.overlay) {
|
||||
this.resizeState.overlay.remove();
|
||||
}
|
||||
|
||||
// Remove grid overlay
|
||||
this.hideGridOverlay();
|
||||
|
||||
// Remove event listeners
|
||||
document.removeEventListener('mousemove', this.boundMouseMove);
|
||||
document.removeEventListener('mouseup', this.boundMouseUp);
|
||||
document.removeEventListener('touchmove', this.boundTouchMove);
|
||||
document.removeEventListener('touchend', this.boundTouchEnd);
|
||||
document.removeEventListener('keydown', this.boundKeyDown);
|
||||
|
||||
// Clear touch timer
|
||||
if (this.touchTimer) {
|
||||
clearTimeout(this.touchTimer);
|
||||
this.touchTimer = null;
|
||||
}
|
||||
|
||||
this.resizeState = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create dimension overlay element
|
||||
* @returns {HTMLElement} Overlay element
|
||||
*/
|
||||
createDimensionOverlay() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'resize-dimension-overlay';
|
||||
overlay.style.position = 'absolute';
|
||||
overlay.style.background = 'rgba(78, 204, 163, 0.9)';
|
||||
overlay.style.color = 'white';
|
||||
overlay.style.padding = '8px 12px';
|
||||
overlay.style.borderRadius = '6px';
|
||||
overlay.style.fontSize = '14px';
|
||||
overlay.style.fontWeight = 'bold';
|
||||
overlay.style.pointerEvents = 'none';
|
||||
overlay.style.zIndex = '10001';
|
||||
overlay.style.transform = 'translate(-50%, -50%)';
|
||||
overlay.style.whiteSpace = 'nowrap';
|
||||
overlay.style.boxShadow = '0 2px 8px rgba(0,0,0,0.3)';
|
||||
|
||||
this.gridEngine.container.appendChild(overlay);
|
||||
return overlay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show grid overlay
|
||||
*/
|
||||
showGridOverlay() {
|
||||
if (this.gridOverlay) return;
|
||||
|
||||
// Calculate actual grid height based on widget positions (returns rem)
|
||||
const widgets = this.resizeState?.widgets || [];
|
||||
const gridHeightRem = this.gridEngine.calculateGridHeight(widgets);
|
||||
const gridHeightPx = this.gridEngine.remToPixels(gridHeightRem);
|
||||
|
||||
this.gridOverlay = document.createElement('div');
|
||||
this.gridOverlay.className = 'grid-overlay';
|
||||
this.gridOverlay.style.position = 'absolute';
|
||||
this.gridOverlay.style.top = '0';
|
||||
this.gridOverlay.style.left = '0';
|
||||
this.gridOverlay.style.width = '100%';
|
||||
this.gridOverlay.style.height = gridHeightPx + 'px';
|
||||
this.gridOverlay.style.pointerEvents = 'none';
|
||||
this.gridOverlay.style.zIndex = '9999';
|
||||
|
||||
this.gridEngine.container.appendChild(this.gridOverlay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide grid overlay
|
||||
*/
|
||||
hideGridOverlay() {
|
||||
if (this.gridOverlay) {
|
||||
this.gridOverlay.remove();
|
||||
this.gridOverlay = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight grid cells where widget will be placed
|
||||
* @param {number} x - Grid X coordinate
|
||||
* @param {number} y - Grid Y coordinate
|
||||
* @param {number} w - Widget width in grid units
|
||||
* @param {number} h - Widget height in grid units
|
||||
*/
|
||||
highlightGridCells(x, y, w, h) {
|
||||
if (!this.gridOverlay) return;
|
||||
|
||||
// Clear previous highlights
|
||||
this.gridOverlay.innerHTML = '';
|
||||
|
||||
// Convert rem to pixels for calculations
|
||||
const gapPx = this.gridEngine.remToPixels(this.gridEngine.gap);
|
||||
const rowHeightPx = this.gridEngine.remToPixels(this.gridEngine.rowHeight);
|
||||
|
||||
// Calculate column width in pixels
|
||||
const totalGaps = gapPx * (this.gridEngine.columns + 1);
|
||||
const colWidth = (this.gridEngine.containerWidth - totalGaps) / this.gridEngine.columns;
|
||||
|
||||
for (let row = y; row < y + h; row++) {
|
||||
for (let col = x; col < x + w; col++) {
|
||||
const cell = document.createElement('div');
|
||||
cell.style.position = 'absolute';
|
||||
cell.style.left = (col * (colWidth + gapPx) + gapPx) + 'px';
|
||||
cell.style.top = (row * (rowHeightPx + gapPx) + gapPx) + 'px';
|
||||
cell.style.width = colWidth + 'px';
|
||||
cell.style.height = rowHeightPx + 'px';
|
||||
cell.style.backgroundColor = 'rgba(78, 204, 163, 0.3)';
|
||||
cell.style.border = '2px solid rgba(78, 204, 163, 0.6)';
|
||||
cell.style.borderRadius = '4px';
|
||||
cell.style.boxSizing = 'border-box';
|
||||
|
||||
this.gridOverlay.appendChild(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current resize state
|
||||
* @returns {ResizeState|null} Current resize state or null
|
||||
*/
|
||||
getResizeState() {
|
||||
return this.resizeState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently resizing
|
||||
* @returns {boolean} True if resize in progress
|
||||
*/
|
||||
isResizing() {
|
||||
return this.resizeState?.isResizing || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy resize handler and cleanup
|
||||
*/
|
||||
destroy() {
|
||||
// Cancel any ongoing resize
|
||||
if (this.isResizing()) {
|
||||
this.cancelResize();
|
||||
}
|
||||
|
||||
// Remove all widget handlers
|
||||
for (const element of this.resizeHandlers.keys()) {
|
||||
this.destroyWidget(element);
|
||||
}
|
||||
|
||||
this.resizeHandlers.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,949 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>Widget Resize Test (Mobile-Ready)</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
padding: 20px;
|
||||
touch-action: none;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 20px;
|
||||
color: #e94560;
|
||||
font-size: clamp(20px, 5vw, 28px);
|
||||
}
|
||||
|
||||
.test-section {
|
||||
background: #16213e;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.test-section h2 {
|
||||
color: #4ecca3;
|
||||
margin-bottom: 10px;
|
||||
font-size: clamp(16px, 4vw, 18px);
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
position: relative;
|
||||
background: #0f3460;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
min-height: 600px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.widget {
|
||||
position: absolute;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
user-select: none;
|
||||
transition: box-shadow 0.2s;
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.widget:hover {
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.widget.resizing {
|
||||
box-shadow: 0 8px 24px rgba(78, 204, 163, 0.6);
|
||||
border-color: rgba(78, 204, 163, 0.8);
|
||||
}
|
||||
|
||||
.widget-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.widget-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.widget-title {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.widget-info {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Resize handles */
|
||||
.resize-handles {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.widget:hover .resize-handles,
|
||||
.widget.resizing .resize-handles {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.resize-handle:hover {
|
||||
background: rgba(78, 204, 163, 1) !important;
|
||||
transform: scale(1.3) !important;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #e94560;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 16px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
touch-action: manipulation;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #d63651;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: #4ecca3;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
button.secondary:hover {
|
||||
background: #5edc9f;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
background: #0f3460;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #4ecca3;
|
||||
}
|
||||
|
||||
.hint {
|
||||
background: #0f3460;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hint strong {
|
||||
color: #4ecca3;
|
||||
}
|
||||
|
||||
.hint kbd {
|
||||
background: #1a1a2e;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #4ecca3;
|
||||
color: #4ecca3;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.event-log {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background: #0f3460;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.event-time {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.event-type {
|
||||
color: #4ecca3;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
button {
|
||||
flex: 1 1 calc(50% - 4px);
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.grid-container {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>📏 Widget Resize Test (Mobile-Ready)</h1>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Resizable Widgets</h2>
|
||||
<div class="hint">
|
||||
<strong>Desktop:</strong> Hover over widget edges/corners and drag to resize<br>
|
||||
<strong>Mobile:</strong> Touch and hold handles (150ms), then drag<br>
|
||||
<strong>Keyboard:</strong> Press <kbd>Escape</kbd> to cancel resize<br>
|
||||
<strong>Constraints:</strong> Min size 2×2, max size 12×10
|
||||
</div>
|
||||
<div id="grid-container" class="grid-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Controls</h2>
|
||||
<div class="controls">
|
||||
<button onclick="addWidget()">Add Widget</button>
|
||||
<button onclick="removeWidget()">Remove Last Widget</button>
|
||||
<button onclick="resetGrid()">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Statistics</h2>
|
||||
<div id="stats" class="stats"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Event Log</h2>
|
||||
<button onclick="clearLog()">Clear Log</button>
|
||||
<div id="event-log" class="event-log"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// GridEngine class (bundled inline)
|
||||
class GridEngine {
|
||||
constructor(config = {}) {
|
||||
this.columns = config.columns || 12;
|
||||
this.rowHeight = config.rowHeight || 80;
|
||||
this.gap = config.gap || 12;
|
||||
this.containerWidth = 0;
|
||||
this.container = config.container;
|
||||
|
||||
if (this.container) {
|
||||
this.updateContainerWidth();
|
||||
}
|
||||
}
|
||||
|
||||
updateContainerWidth() {
|
||||
if (this.container) {
|
||||
this.containerWidth = this.container.offsetWidth - (this.gap * 2);
|
||||
}
|
||||
}
|
||||
|
||||
getPixelPosition(widget) {
|
||||
this.updateContainerWidth();
|
||||
const totalGaps = this.gap * (this.columns + 1);
|
||||
const colWidth = (this.containerWidth - totalGaps) / this.columns;
|
||||
|
||||
const left = widget.x * (colWidth + this.gap) + this.gap;
|
||||
const top = widget.y * (this.rowHeight + this.gap) + this.gap;
|
||||
const width = widget.w * colWidth + (widget.w - 1) * this.gap;
|
||||
const height = widget.h * this.rowHeight + (widget.h - 1) * this.gap;
|
||||
|
||||
return { left, top, width, height };
|
||||
}
|
||||
}
|
||||
|
||||
// ResizeHandler class (bundled inline)
|
||||
class ResizeHandler {
|
||||
constructor(gridEngine, options = {}) {
|
||||
this.gridEngine = gridEngine;
|
||||
this.options = {
|
||||
showDimensions: true,
|
||||
showGrid: true,
|
||||
minWidth: 2,
|
||||
minHeight: 2,
|
||||
maxWidth: 12,
|
||||
maxHeight: 10,
|
||||
touchDelay: 150,
|
||||
...options
|
||||
};
|
||||
|
||||
this.resizeState = null;
|
||||
this.resizeHandlers = new Map();
|
||||
this.gridOverlay = null;
|
||||
this.touchTimer = null;
|
||||
|
||||
this.boundMouseMove = this.onMouseMove.bind(this);
|
||||
this.boundMouseUp = this.onMouseUp.bind(this);
|
||||
this.boundTouchMove = this.onTouchMove.bind(this);
|
||||
this.boundTouchEnd = this.onTouchEnd.bind(this);
|
||||
this.boundKeyDown = this.onKeyDown.bind(this);
|
||||
|
||||
this.handleTypes = {
|
||||
'nw': 'nwse-resize',
|
||||
'n': 'ns-resize',
|
||||
'ne': 'nesw-resize',
|
||||
'e': 'ew-resize',
|
||||
'se': 'nwse-resize',
|
||||
's': 'ns-resize',
|
||||
'sw': 'nesw-resize',
|
||||
'w': 'ew-resize'
|
||||
};
|
||||
}
|
||||
|
||||
initWidget(element, widget, onResizeEnd, constraints = {}) {
|
||||
const handles = this.createResizeHandles();
|
||||
element.appendChild(handles);
|
||||
|
||||
const widgetConstraints = {
|
||||
minW: constraints.minW || this.options.minWidth,
|
||||
minH: constraints.minH || this.options.minHeight,
|
||||
maxW: constraints.maxW || this.options.maxWidth,
|
||||
maxH: constraints.maxH || this.options.maxHeight
|
||||
};
|
||||
|
||||
const handleElements = handles.querySelectorAll('.resize-handle');
|
||||
const handleListeners = [];
|
||||
|
||||
handleElements.forEach(handleEl => {
|
||||
const handleType = handleEl.dataset.handle;
|
||||
|
||||
const mouseDownHandler = (e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.startResize(e, handleType, element, widget, onResizeEnd, widgetConstraints);
|
||||
};
|
||||
|
||||
const touchStartHandler = (e) => {
|
||||
this.touchTimer = setTimeout(() => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.startResize(e.touches[0], handleType, element, widget, onResizeEnd, widgetConstraints);
|
||||
}, this.options.touchDelay);
|
||||
};
|
||||
|
||||
const touchCancelHandler = () => {
|
||||
if (this.touchTimer) {
|
||||
clearTimeout(this.touchTimer);
|
||||
this.touchTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
handleEl.addEventListener('mousedown', mouseDownHandler);
|
||||
handleEl.addEventListener('touchstart', touchStartHandler, { passive: false });
|
||||
handleEl.addEventListener('touchcancel', touchCancelHandler);
|
||||
handleEl.addEventListener('touchend', touchCancelHandler);
|
||||
|
||||
handleListeners.push({
|
||||
element: handleEl,
|
||||
mouseDownHandler,
|
||||
touchStartHandler,
|
||||
touchCancelHandler
|
||||
});
|
||||
});
|
||||
|
||||
this.resizeHandlers.set(element, {
|
||||
handles,
|
||||
handleListeners
|
||||
});
|
||||
}
|
||||
|
||||
createResizeHandles() {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'resize-handles';
|
||||
container.style.position = 'absolute';
|
||||
container.style.inset = '0';
|
||||
container.style.pointerEvents = 'none';
|
||||
|
||||
Object.entries(this.handleTypes).forEach(([handleType, cursor]) => {
|
||||
const handle = document.createElement('div');
|
||||
handle.className = `resize-handle resize-handle-${handleType}`;
|
||||
handle.dataset.handle = handleType;
|
||||
handle.style.position = 'absolute';
|
||||
handle.style.pointerEvents = 'auto';
|
||||
handle.style.cursor = cursor;
|
||||
handle.style.width = '12px';
|
||||
handle.style.height = '12px';
|
||||
handle.style.background = 'rgba(78, 204, 163, 0.8)';
|
||||
handle.style.border = '2px solid white';
|
||||
handle.style.borderRadius = '3px';
|
||||
handle.style.zIndex = '100';
|
||||
|
||||
if (handleType.includes('n')) handle.style.top = '-6px';
|
||||
if (handleType.includes('s')) handle.style.bottom = '-6px';
|
||||
if (handleType.includes('w')) handle.style.left = '-6px';
|
||||
if (handleType.includes('e')) handle.style.right = '-6px';
|
||||
|
||||
if (handleType === 'n' || handleType === 's') {
|
||||
handle.style.left = '50%';
|
||||
handle.style.transform = 'translateX(-50%)';
|
||||
}
|
||||
if (handleType === 'w' || handleType === 'e') {
|
||||
handle.style.top = '50%';
|
||||
handle.style.transform = 'translateY(-50%)';
|
||||
}
|
||||
|
||||
container.appendChild(handle);
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
startResize(e, handleType, element, widget, onResizeEnd, constraints) {
|
||||
const overlay = this.createDimensionOverlay();
|
||||
|
||||
this.resizeState = {
|
||||
element,
|
||||
widget: { ...widget },
|
||||
handle: handleType,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
startWidth: widget.w,
|
||||
startHeight: widget.h,
|
||||
startGridX: widget.x,
|
||||
startGridY: widget.y,
|
||||
overlay,
|
||||
isResizing: true,
|
||||
onResizeEnd,
|
||||
constraints
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', this.boundMouseMove);
|
||||
document.addEventListener('mouseup', this.boundMouseUp);
|
||||
document.addEventListener('touchmove', this.boundTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', this.boundTouchEnd);
|
||||
document.addEventListener('keydown', this.boundKeyDown);
|
||||
|
||||
if (this.options.showGrid) {
|
||||
this.showGridOverlay();
|
||||
}
|
||||
|
||||
element.classList.add('resizing');
|
||||
|
||||
logEvent('Resize Start', { id: widget.id, handle: handleType });
|
||||
}
|
||||
|
||||
onMouseMove(e) {
|
||||
if (!this.resizeState?.isResizing) return;
|
||||
e.preventDefault();
|
||||
this.updateResizeSize(e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
onTouchMove(e) {
|
||||
if (!this.resizeState?.isResizing) return;
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
this.updateResizeSize(touch.clientX, touch.clientY);
|
||||
}
|
||||
|
||||
updateResizeSize(clientX, clientY) {
|
||||
const { widget, handle, startX, startY, startWidth, startHeight, startGridX, startGridY, constraints, element, overlay } = this.resizeState;
|
||||
|
||||
const deltaX = clientX - startX;
|
||||
const deltaY = clientY - startY;
|
||||
|
||||
this.gridEngine.updateContainerWidth();
|
||||
const totalGaps = this.gridEngine.gap * (this.gridEngine.columns + 1);
|
||||
const colWidth = (this.gridEngine.containerWidth - totalGaps) / this.gridEngine.columns;
|
||||
const rowHeight = this.gridEngine.rowHeight;
|
||||
|
||||
const deltaGridX = Math.round(deltaX / (colWidth + this.gridEngine.gap));
|
||||
const deltaGridY = Math.round(deltaY / (rowHeight + this.gridEngine.gap));
|
||||
|
||||
let newW = startWidth;
|
||||
let newH = startHeight;
|
||||
let newX = startGridX;
|
||||
let newY = startGridY;
|
||||
|
||||
if (handle.includes('e')) {
|
||||
newW = startWidth + deltaGridX;
|
||||
} else if (handle.includes('w')) {
|
||||
newW = startWidth - deltaGridX;
|
||||
newX = startGridX + deltaGridX;
|
||||
}
|
||||
|
||||
if (handle.includes('s')) {
|
||||
newH = startHeight + deltaGridY;
|
||||
} else if (handle.includes('n')) {
|
||||
newH = startHeight - deltaGridY;
|
||||
newY = startGridY + deltaGridY;
|
||||
}
|
||||
|
||||
newW = Math.max(constraints.minW, Math.min(newW, constraints.maxW));
|
||||
newH = Math.max(constraints.minH, Math.min(newH, constraints.maxH));
|
||||
newW = Math.min(newW, this.gridEngine.columns - newX);
|
||||
|
||||
if (handle.includes('w') && newW === constraints.minW) {
|
||||
newX = startGridX + startWidth - constraints.minW;
|
||||
}
|
||||
if (handle.includes('n') && newH === constraints.minH) {
|
||||
newY = startGridY + startHeight - constraints.minH;
|
||||
}
|
||||
|
||||
this.resizeState.widget.w = newW;
|
||||
this.resizeState.widget.h = newH;
|
||||
this.resizeState.widget.x = newX;
|
||||
this.resizeState.widget.y = newY;
|
||||
|
||||
const pos = this.gridEngine.getPixelPosition(this.resizeState.widget);
|
||||
element.style.width = pos.width + 'px';
|
||||
element.style.height = pos.height + 'px';
|
||||
element.style.left = pos.left + 'px';
|
||||
element.style.top = pos.top + 'px';
|
||||
|
||||
if (overlay) {
|
||||
overlay.textContent = `${newW}×${newH}`;
|
||||
overlay.style.left = (pos.left + pos.width / 2) + 'px';
|
||||
overlay.style.top = (pos.top + pos.height / 2) + 'px';
|
||||
}
|
||||
|
||||
if (this.gridOverlay) {
|
||||
this.highlightGridCells(newX, newY, newW, newH);
|
||||
}
|
||||
}
|
||||
|
||||
onMouseUp(e) {
|
||||
if (!this.resizeState?.isResizing) return;
|
||||
e.preventDefault();
|
||||
this.endResize();
|
||||
}
|
||||
|
||||
onTouchEnd(e) {
|
||||
if (!this.resizeState?.isResizing) return;
|
||||
e.preventDefault();
|
||||
this.endResize();
|
||||
}
|
||||
|
||||
onKeyDown(e) {
|
||||
if (!this.resizeState?.isResizing) return;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.cancelResize();
|
||||
}
|
||||
}
|
||||
|
||||
endResize() {
|
||||
if (!this.resizeState) return;
|
||||
|
||||
const { element, widget, onResizeEnd } = this.resizeState;
|
||||
|
||||
element.classList.remove('resizing');
|
||||
|
||||
if (onResizeEnd) {
|
||||
onResizeEnd(widget, widget.w, widget.h, widget.x, widget.y);
|
||||
}
|
||||
|
||||
logEvent('Resize End', { id: widget.id, size: `${widget.w}×${widget.h}` });
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
cancelResize() {
|
||||
if (!this.resizeState) return;
|
||||
|
||||
const { element, startWidth, startHeight, startGridX, startGridY } = this.resizeState;
|
||||
|
||||
const widget = {
|
||||
x: startGridX,
|
||||
y: startGridY,
|
||||
w: startWidth,
|
||||
h: startHeight
|
||||
};
|
||||
|
||||
const pos = this.gridEngine.getPixelPosition(widget);
|
||||
element.style.width = pos.width + 'px';
|
||||
element.style.height = pos.height + 'px';
|
||||
element.style.left = pos.left + 'px';
|
||||
element.style.top = pos.top + 'px';
|
||||
|
||||
element.classList.remove('resizing');
|
||||
|
||||
logEvent('Resize Cancelled', null);
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this.resizeState?.overlay) {
|
||||
this.resizeState.overlay.remove();
|
||||
}
|
||||
|
||||
this.hideGridOverlay();
|
||||
|
||||
document.removeEventListener('mousemove', this.boundMouseMove);
|
||||
document.removeEventListener('mouseup', this.boundMouseUp);
|
||||
document.removeEventListener('touchmove', this.boundTouchMove);
|
||||
document.removeEventListener('touchend', this.boundTouchEnd);
|
||||
document.removeEventListener('keydown', this.boundKeyDown);
|
||||
|
||||
if (this.touchTimer) {
|
||||
clearTimeout(this.touchTimer);
|
||||
this.touchTimer = null;
|
||||
}
|
||||
|
||||
this.resizeState = null;
|
||||
}
|
||||
|
||||
createDimensionOverlay() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'resize-dimension-overlay';
|
||||
overlay.style.position = 'absolute';
|
||||
overlay.style.background = 'rgba(78, 204, 163, 0.9)';
|
||||
overlay.style.color = 'white';
|
||||
overlay.style.padding = '8px 12px';
|
||||
overlay.style.borderRadius = '6px';
|
||||
overlay.style.fontSize = '14px';
|
||||
overlay.style.fontWeight = 'bold';
|
||||
overlay.style.pointerEvents = 'none';
|
||||
overlay.style.zIndex = '10001';
|
||||
overlay.style.transform = 'translate(-50%, -50%)';
|
||||
overlay.style.whiteSpace = 'nowrap';
|
||||
overlay.style.boxShadow = '0 2px 8px rgba(0,0,0,0.3)';
|
||||
|
||||
this.gridEngine.container.appendChild(overlay);
|
||||
return overlay;
|
||||
}
|
||||
|
||||
showGridOverlay() {
|
||||
if (this.gridOverlay) return;
|
||||
|
||||
this.gridOverlay = document.createElement('div');
|
||||
this.gridOverlay.className = 'grid-overlay';
|
||||
this.gridOverlay.style.position = 'absolute';
|
||||
this.gridOverlay.style.top = '0';
|
||||
this.gridOverlay.style.left = '0';
|
||||
this.gridOverlay.style.width = '100%';
|
||||
this.gridOverlay.style.height = '100%';
|
||||
this.gridOverlay.style.pointerEvents = 'none';
|
||||
this.gridOverlay.style.zIndex = '9999';
|
||||
|
||||
this.gridEngine.container.appendChild(this.gridOverlay);
|
||||
}
|
||||
|
||||
hideGridOverlay() {
|
||||
if (this.gridOverlay) {
|
||||
this.gridOverlay.remove();
|
||||
this.gridOverlay = null;
|
||||
}
|
||||
}
|
||||
|
||||
highlightGridCells(x, y, w, h) {
|
||||
if (!this.gridOverlay) return;
|
||||
|
||||
this.gridOverlay.innerHTML = '';
|
||||
|
||||
const totalGaps = this.gridEngine.gap * (this.gridEngine.columns + 1);
|
||||
const colWidth = (this.gridEngine.containerWidth - totalGaps) / this.gridEngine.columns;
|
||||
|
||||
for (let row = y; row < y + h; row++) {
|
||||
for (let col = x; col < x + w; col++) {
|
||||
const cell = document.createElement('div');
|
||||
cell.style.position = 'absolute';
|
||||
cell.style.left = (col * (colWidth + this.gridEngine.gap) + this.gridEngine.gap) + 'px';
|
||||
cell.style.top = (row * (this.gridEngine.rowHeight + this.gridEngine.gap) + this.gridEngine.gap) + 'px';
|
||||
cell.style.width = colWidth + 'px';
|
||||
cell.style.height = this.gridEngine.rowHeight + 'px';
|
||||
cell.style.backgroundColor = 'rgba(78, 204, 163, 0.3)';
|
||||
cell.style.border = '2px solid rgba(78, 204, 163, 0.6)';
|
||||
cell.style.borderRadius = '4px';
|
||||
cell.style.boxSizing = 'border-box';
|
||||
|
||||
this.gridOverlay.appendChild(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test application
|
||||
let gridEngine = null;
|
||||
let resizeHandler = null;
|
||||
let widgets = [];
|
||||
let widgetElements = new Map();
|
||||
let widgetCounter = 0;
|
||||
|
||||
const widgetTypes = [
|
||||
{ icon: '📊', name: 'Stats', color: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
|
||||
{ icon: '🎒', name: 'Inventory', color: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' },
|
||||
{ icon: '📝', name: 'Notes', color: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' },
|
||||
{ icon: '🗺️', name: 'Map', color: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)' }
|
||||
];
|
||||
|
||||
function init() {
|
||||
const container = document.getElementById('grid-container');
|
||||
|
||||
gridEngine = new GridEngine({
|
||||
columns: 12,
|
||||
rowHeight: 80,
|
||||
gap: 12,
|
||||
container
|
||||
});
|
||||
|
||||
resizeHandler = new ResizeHandler(gridEngine, {
|
||||
showDimensions: true,
|
||||
showGrid: true,
|
||||
minWidth: 2,
|
||||
minHeight: 2,
|
||||
maxWidth: 12,
|
||||
maxHeight: 10,
|
||||
touchDelay: 150
|
||||
});
|
||||
|
||||
createInitialWidgets();
|
||||
updateStats();
|
||||
|
||||
let resizeTimeout;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => {
|
||||
renderAllWidgets();
|
||||
updateStats();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
logEvent('Initialized', { widgets: widgets.length });
|
||||
}
|
||||
|
||||
function createInitialWidgets() {
|
||||
const initialWidgets = [
|
||||
{ x: 0, y: 0, w: 6, h: 3, type: 0 },
|
||||
{ x: 6, y: 0, w: 6, h: 2, type: 1 },
|
||||
{ x: 0, y: 3, w: 4, h: 3, type: 2 }
|
||||
];
|
||||
|
||||
initialWidgets.forEach(config => {
|
||||
const widget = {
|
||||
id: `widget-${widgetCounter++}`,
|
||||
x: config.x,
|
||||
y: config.y,
|
||||
w: config.w,
|
||||
h: config.h,
|
||||
type: config.type
|
||||
};
|
||||
widgets.push(widget);
|
||||
createWidgetElement(widget);
|
||||
});
|
||||
}
|
||||
|
||||
function createWidgetElement(widget) {
|
||||
const container = document.getElementById('grid-container');
|
||||
const type = widgetTypes[widget.type];
|
||||
|
||||
const element = document.createElement('div');
|
||||
element.className = 'widget';
|
||||
element.style.background = type.color;
|
||||
|
||||
element.innerHTML = `
|
||||
<div class="widget-header">
|
||||
<span class="widget-icon">${type.icon}</span>
|
||||
<span class="widget-title">${type.name}</span>
|
||||
</div>
|
||||
<div class="widget-info">Position: (${widget.x}, ${widget.y})</div>
|
||||
<div class="widget-info">Size: ${widget.w}×${widget.h}</div>
|
||||
`;
|
||||
|
||||
container.appendChild(element);
|
||||
widgetElements.set(widget.id, element);
|
||||
|
||||
positionWidget(element, widget);
|
||||
|
||||
resizeHandler.initWidget(element, widget, (updatedWidget, newW, newH, newX, newY) => {
|
||||
widget.w = newW;
|
||||
widget.h = newH;
|
||||
widget.x = newX;
|
||||
widget.y = newY;
|
||||
updateWidgetInfo(element, widget);
|
||||
updateStats();
|
||||
}, {
|
||||
minW: 2,
|
||||
minH: 2,
|
||||
maxW: 12,
|
||||
maxH: 10
|
||||
});
|
||||
}
|
||||
|
||||
function positionWidget(element, widget) {
|
||||
const pos = gridEngine.getPixelPosition(widget);
|
||||
element.style.left = pos.left + 'px';
|
||||
element.style.top = pos.top + 'px';
|
||||
element.style.width = pos.width + 'px';
|
||||
element.style.height = pos.height + 'px';
|
||||
}
|
||||
|
||||
function updateWidgetInfo(element, widget) {
|
||||
const infoElements = element.querySelectorAll('.widget-info');
|
||||
infoElements[0].textContent = `Position: (${widget.x}, ${widget.y})`;
|
||||
infoElements[1].textContent = `Size: ${widget.w}×${widget.h}`;
|
||||
}
|
||||
|
||||
function renderAllWidgets() {
|
||||
widgets.forEach(widget => {
|
||||
const element = widgetElements.get(widget.id);
|
||||
if (element) {
|
||||
positionWidget(element, widget);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.addWidget = function() {
|
||||
const randomType = Math.floor(Math.random() * widgetTypes.length);
|
||||
const widget = {
|
||||
id: `widget-${widgetCounter++}`,
|
||||
x: Math.floor(Math.random() * 8),
|
||||
y: Math.floor(Math.random() * 3),
|
||||
w: 4,
|
||||
h: 2,
|
||||
type: randomType
|
||||
};
|
||||
|
||||
widgets.push(widget);
|
||||
createWidgetElement(widget);
|
||||
updateStats();
|
||||
logEvent('Widget Added', { id: widget.id });
|
||||
};
|
||||
|
||||
window.removeWidget = function() {
|
||||
if (widgets.length === 0) return;
|
||||
|
||||
const widget = widgets.pop();
|
||||
const element = widgetElements.get(widget.id);
|
||||
|
||||
if (element) {
|
||||
element.remove();
|
||||
widgetElements.delete(widget.id);
|
||||
}
|
||||
|
||||
updateStats();
|
||||
logEvent('Widget Removed', { id: widget.id });
|
||||
};
|
||||
|
||||
window.resetGrid = function() {
|
||||
widgets.forEach(widget => {
|
||||
const element = widgetElements.get(widget.id);
|
||||
if (element) {
|
||||
element.remove();
|
||||
}
|
||||
});
|
||||
|
||||
widgets = [];
|
||||
widgetElements.clear();
|
||||
widgetCounter = 0;
|
||||
|
||||
createInitialWidgets();
|
||||
updateStats();
|
||||
logEvent('Grid Reset', null);
|
||||
};
|
||||
|
||||
function updateStats() {
|
||||
const container = document.getElementById('stats');
|
||||
const totalSize = widgets.reduce((sum, w) => sum + (w.w * w.h), 0);
|
||||
const avgSize = widgets.length > 0 ? (totalSize / widgets.length).toFixed(1) : 0;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Widgets</div>
|
||||
<div class="stat-value">${widgets.length}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Total Grid Units</div>
|
||||
<div class="stat-value">${totalSize}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Avg Size</div>
|
||||
<div class="stat-value">${avgSize}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Grid Columns</div>
|
||||
<div class="stat-value">${gridEngine.columns}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function logEvent(type, data) {
|
||||
const log = document.getElementById('event-log');
|
||||
const time = new Date().toLocaleTimeString();
|
||||
const item = document.createElement('div');
|
||||
item.className = 'event-item';
|
||||
item.innerHTML = `
|
||||
<span class="event-time">${time}</span>
|
||||
<span class="event-type"> ${type}</span>
|
||||
${data ? ` - ${JSON.stringify(data)}` : ''}
|
||||
`;
|
||||
log.insertBefore(item, log.firstChild);
|
||||
|
||||
while (log.children.length > 50) {
|
||||
log.removeChild(log.lastChild);
|
||||
}
|
||||
}
|
||||
|
||||
window.clearLog = function() {
|
||||
document.getElementById('event-log').innerHTML = '';
|
||||
};
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,220 +0,0 @@
|
||||
/**
|
||||
* Section Manager
|
||||
*
|
||||
* Manages collapsible sections within dashboard tabs for better organization and mobile UX.
|
||||
* Sections group related widgets together with expand/collapse functionality.
|
||||
*
|
||||
* Features:
|
||||
* - Click section header to toggle expand/collapse
|
||||
* - Smooth CSS transitions
|
||||
* - State persistence per tab in dashboard config
|
||||
* - Keyboard accessibility (Enter/Space to toggle)
|
||||
* - ARIA attributes for screen readers
|
||||
*/
|
||||
|
||||
export class SectionManager {
|
||||
/**
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {Function} options.onStateChange - Callback when section state changes
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.options = options;
|
||||
this.sectionStates = new Map(); // sectionId -> {expanded: boolean}
|
||||
|
||||
// Bound event handlers
|
||||
this.boundToggleSection = this.toggleSection.bind(this);
|
||||
this.boundHandleKeyDown = this.handleKeyDown.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize section state from dashboard config
|
||||
* @param {Object} tabConfig - Tab configuration with sections array
|
||||
*/
|
||||
init(tabConfig) {
|
||||
if (!tabConfig || !Array.isArray(tabConfig.sections)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load initial state from config
|
||||
tabConfig.sections.forEach(section => {
|
||||
this.sectionStates.set(section.id, {
|
||||
expanded: section.expanded !== false // Default to expanded
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`[SectionManager] Initialized with ${this.sectionStates.size} sections`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get section state
|
||||
* @param {string} sectionId - Section ID
|
||||
* @returns {boolean} Whether section is expanded
|
||||
*/
|
||||
isExpanded(sectionId) {
|
||||
const state = this.sectionStates.get(sectionId);
|
||||
return state ? state.expanded : true; // Default to expanded
|
||||
}
|
||||
|
||||
/**
|
||||
* Set section state
|
||||
* @param {string} sectionId - Section ID
|
||||
* @param {boolean} expanded - Whether section should be expanded
|
||||
* @param {boolean} notify - Whether to trigger state change callback
|
||||
*/
|
||||
setExpanded(sectionId, expanded, notify = true) {
|
||||
this.sectionStates.set(sectionId, { expanded });
|
||||
|
||||
// Update DOM
|
||||
const sectionHeader = document.querySelector(`[data-section-id="${sectionId}"]`);
|
||||
if (sectionHeader) {
|
||||
const container = sectionHeader.parentElement;
|
||||
const content = container?.querySelector('.rpg-section-content');
|
||||
const chevron = sectionHeader.querySelector('.rpg-section-chevron');
|
||||
|
||||
if (expanded) {
|
||||
container?.classList.remove('collapsed');
|
||||
sectionHeader.setAttribute('aria-expanded', 'true');
|
||||
if (content) content.style.maxHeight = content.scrollHeight + 'px';
|
||||
if (chevron) chevron.style.transform = 'rotate(0deg)';
|
||||
} else {
|
||||
container?.classList.add('collapsed');
|
||||
sectionHeader.setAttribute('aria-expanded', 'false');
|
||||
if (content) content.style.maxHeight = '0';
|
||||
if (chevron) chevron.style.transform = 'rotate(-90deg)';
|
||||
}
|
||||
}
|
||||
|
||||
// Notify state change
|
||||
if (notify && this.options.onStateChange) {
|
||||
this.options.onStateChange(sectionId, expanded);
|
||||
}
|
||||
|
||||
console.log(`[SectionManager] Section '${sectionId}' ${expanded ? 'expanded' : 'collapsed'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle section expand/collapse
|
||||
* @param {Event} event - Click event
|
||||
*/
|
||||
toggleSection(event) {
|
||||
const header = event.currentTarget;
|
||||
const sectionId = header.dataset.sectionId;
|
||||
|
||||
if (!sectionId) {
|
||||
console.warn('[SectionManager] No section ID found on header');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentState = this.isExpanded(sectionId);
|
||||
this.setExpanded(sectionId, !currentState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard events for accessibility
|
||||
* @param {KeyboardEvent} event - Keyboard event
|
||||
*/
|
||||
handleKeyDown(event) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
this.toggleSection(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach event handlers to section header
|
||||
* @param {HTMLElement} header - Section header element
|
||||
*/
|
||||
attachHandlers(header) {
|
||||
header.addEventListener('click', this.boundToggleSection);
|
||||
header.addEventListener('keydown', this.boundHandleKeyDown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach event handlers from section header
|
||||
* @param {HTMLElement} header - Section header element
|
||||
*/
|
||||
detachHandlers(header) {
|
||||
header.removeEventListener('click', this.boundToggleSection);
|
||||
header.removeEventListener('keydown', this.boundHandleKeyDown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render section header HTML
|
||||
* @param {Object} section - Section configuration
|
||||
* @param {string} section.id - Section ID
|
||||
* @param {string} section.name - Section display name
|
||||
* @param {string} section.icon - Section icon (emoji or FontAwesome)
|
||||
* @param {boolean} section.expanded - Whether section starts expanded
|
||||
* @returns {string} Section header HTML
|
||||
*/
|
||||
renderSectionHeader(section) {
|
||||
const expanded = this.isExpanded(section.id);
|
||||
const chevronRotation = expanded ? '0deg' : '-90deg';
|
||||
|
||||
return `
|
||||
<div class="rpg-section">
|
||||
<div class="rpg-section-header"
|
||||
data-section-id="${section.id}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-expanded="${expanded}"
|
||||
aria-label="Toggle ${section.name} section">
|
||||
<span class="rpg-section-icon">${section.icon || '📁'}</span>
|
||||
<span class="rpg-section-name">${section.name}</span>
|
||||
<span class="rpg-section-chevron" style="transform: rotate(${chevronRotation})">
|
||||
<i class="fa-solid fa-chevron-down"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="rpg-section-content" style="max-height: ${expanded ? 'none' : '0'}">
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render section footer HTML
|
||||
* @returns {string} Section footer HTML
|
||||
*/
|
||||
renderSectionFooter() {
|
||||
return `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state for persistence
|
||||
* @returns {Object} Map of sectionId -> expanded state
|
||||
*/
|
||||
getState() {
|
||||
const state = {};
|
||||
this.sectionStates.forEach((value, key) => {
|
||||
state[key] = value.expanded;
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore state from saved data
|
||||
* @param {Object} state - Saved state object
|
||||
*/
|
||||
restoreState(state) {
|
||||
if (!state || typeof state !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.entries(state).forEach(([sectionId, expanded]) => {
|
||||
this.setExpanded(sectionId, expanded, false); // Don't notify on restore
|
||||
});
|
||||
|
||||
console.log(`[SectionManager] Restored state for ${Object.keys(state).length} sections`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup - detach all event handlers
|
||||
*/
|
||||
destroy() {
|
||||
const headers = document.querySelectorAll('.rpg-section-header');
|
||||
headers.forEach(header => this.detachHandlers(header));
|
||||
this.sectionStates.clear();
|
||||
console.log('[SectionManager] Destroyed');
|
||||
}
|
||||
}
|
||||
@@ -1,626 +0,0 @@
|
||||
/**
|
||||
* Tab Context Menu System
|
||||
*
|
||||
* Provides right-click context menu for tab management operations.
|
||||
* Integrates with TabManager for create, rename, duplicate, delete, and icon change.
|
||||
*/
|
||||
|
||||
import { showConfirmDialog } from './confirmDialog.js';
|
||||
import { showPromptDialog } from './promptDialog.js';
|
||||
|
||||
export class TabContextMenu {
|
||||
/**
|
||||
* @param {Object} config - Configuration
|
||||
* @param {TabManager} config.tabManager - Tab manager instance
|
||||
* @param {Function} config.onTabChange - Callback when tabs change
|
||||
*/
|
||||
constructor(config) {
|
||||
this.tabManager = config.tabManager;
|
||||
this.onTabChange = config.onTabChange;
|
||||
this.menu = null;
|
||||
this.currentTabId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize context menu system
|
||||
* @param {HTMLElement} tabsContainer - Container with tab elements
|
||||
*/
|
||||
init(tabsContainer) {
|
||||
if (!tabsContainer) {
|
||||
console.error('[TabContextMenu] Tabs container not provided');
|
||||
return;
|
||||
}
|
||||
|
||||
this.tabsContainer = tabsContainer;
|
||||
|
||||
// Attach context menu handlers to tabs
|
||||
this.attachHandlers();
|
||||
|
||||
console.log('[TabContextMenu] Initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach context menu event handlers to all tabs
|
||||
*/
|
||||
attachHandlers() {
|
||||
if (!this.tabsContainer) return;
|
||||
|
||||
// Long press support for mobile
|
||||
let longPressTimer = null;
|
||||
let longPressTarget = null;
|
||||
let touchStartPos = { x: 0, y: 0 };
|
||||
|
||||
// Desktop: Right-click context menu
|
||||
this.tabsContainer.addEventListener('contextmenu', (e) => {
|
||||
// Find closest tab element
|
||||
const tabElement = e.target.closest('.rpg-dashboard-tab');
|
||||
if (!tabElement) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const tabId = tabElement.dataset.tabId;
|
||||
if (!tabId) return;
|
||||
|
||||
this.showMenu(e.pageX, e.pageY, tabId);
|
||||
});
|
||||
|
||||
// Mobile: Long press support (touch and hold)
|
||||
this.tabsContainer.addEventListener('touchstart', (e) => {
|
||||
const tabElement = e.target.closest('.rpg-dashboard-tab');
|
||||
if (!tabElement) return;
|
||||
|
||||
const tabId = tabElement.dataset.tabId;
|
||||
if (!tabId) return;
|
||||
|
||||
// Store touch position
|
||||
const touch = e.touches[0];
|
||||
touchStartPos = { x: touch.pageX, y: touch.pageY };
|
||||
longPressTarget = { tabId, x: touch.pageX, y: touch.pageY };
|
||||
|
||||
// Start long press timer (500ms)
|
||||
longPressTimer = setTimeout(() => {
|
||||
if (longPressTarget) {
|
||||
// Prevent default touch behavior
|
||||
e.preventDefault();
|
||||
// Show context menu at touch position
|
||||
this.showMenu(longPressTarget.x, longPressTarget.y, longPressTarget.tabId);
|
||||
// Provide haptic feedback if available
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(50);
|
||||
}
|
||||
longPressTarget = null;
|
||||
}
|
||||
}, 500);
|
||||
}, { passive: false });
|
||||
|
||||
// Cancel long press on touch move (if moved too far)
|
||||
this.tabsContainer.addEventListener('touchmove', (e) => {
|
||||
if (!longPressTimer) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
const deltaX = Math.abs(touch.pageX - touchStartPos.x);
|
||||
const deltaY = Math.abs(touch.pageY - touchStartPos.y);
|
||||
|
||||
// Cancel if moved more than 10px
|
||||
if (deltaX > 10 || deltaY > 10) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
longPressTarget = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel long press on touch end (if timer still running)
|
||||
this.tabsContainer.addEventListener('touchend', () => {
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
longPressTarget = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel long press on touch cancel
|
||||
this.tabsContainer.addEventListener('touchcancel', () => {
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
longPressTarget = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Close menu on any click/touch outside
|
||||
document.addEventListener('click', () => this.hideMenu());
|
||||
document.addEventListener('touchstart', (e) => {
|
||||
// Close menu if touching outside context menu
|
||||
if (this.menu && !this.menu.contains(e.target)) {
|
||||
this.hideMenu();
|
||||
}
|
||||
});
|
||||
document.addEventListener('contextmenu', (e) => {
|
||||
// Only hide if right-clicking outside tabs
|
||||
if (!e.target.closest('.rpg-dashboard-tab')) {
|
||||
this.hideMenu();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show context menu at position
|
||||
* @param {number} x - X coordinate
|
||||
* @param {number} y - Y coordinate
|
||||
* @param {string} tabId - Tab ID
|
||||
*/
|
||||
showMenu(x, y, tabId) {
|
||||
this.hideMenu(); // Remove existing menu
|
||||
|
||||
this.currentTabId = tabId;
|
||||
const tab = this.tabManager.getTab(tabId);
|
||||
if (!tab) return;
|
||||
|
||||
// Create menu container (uses CSS variables, themed via data-theme attribute)
|
||||
this.menu = document.createElement('div');
|
||||
this.menu.className = 'rpg-tab-context-menu rpg-modal-content'; // Use .rpg-modal-content for theme styling
|
||||
|
||||
// Copy theme from panel so menu inherits theme-specific styles
|
||||
const panel = document.querySelector('.rpg-panel');
|
||||
if (panel && panel.dataset.theme) {
|
||||
this.menu.dataset.theme = panel.dataset.theme;
|
||||
this.menu.style.cssText = `
|
||||
position: fixed;
|
||||
left: ${x}px;
|
||||
top: ${y}px;
|
||||
z-index: 10002;
|
||||
min-width: 180px;
|
||||
padding: 6px 0;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
`;
|
||||
} else {
|
||||
// For default theme: read computed colors from panel and apply as solid (1.0 opacity)
|
||||
const computedStyle = window.getComputedStyle(panel);
|
||||
const bgColor = computedStyle.getPropertyValue('--rpg-bg').trim();
|
||||
const accentColor = computedStyle.getPropertyValue('--rpg-accent').trim();
|
||||
|
||||
// Convert rgba with 0.9 opacity to 1.0 opacity
|
||||
const solidBg = bgColor.replace(/rgba\(([^)]+),\s*[\d.]+\)/, 'rgba($1, 1)');
|
||||
const solidAccent = accentColor.replace(/rgba\(([^)]+),\s*[\d.]+\)/, 'rgba($1, 1)');
|
||||
|
||||
this.menu.style.cssText = `
|
||||
position: fixed;
|
||||
left: ${x}px;
|
||||
top: ${y}px;
|
||||
z-index: 10002;
|
||||
min-width: 180px;
|
||||
padding: 6px 0;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
background: linear-gradient(135deg, ${solidAccent} 0%, ${solidBg} 100%) !important;
|
||||
opacity: 1 !important;
|
||||
`;
|
||||
}
|
||||
|
||||
// Menu items
|
||||
const items = [
|
||||
{ icon: 'fa-plus', label: 'Add New Tab', action: () => this.handleAddTab() },
|
||||
{ type: 'separator' },
|
||||
{ icon: 'fa-pencil', label: 'Rename Tab', action: () => this.handleRenameTab(tabId) },
|
||||
{ icon: 'fa-icons', label: 'Change Icon', action: () => this.handleChangeIcon(tabId) },
|
||||
{ icon: 'fa-copy', label: 'Duplicate Tab', action: () => this.handleDuplicateTab(tabId) },
|
||||
{ type: 'separator' },
|
||||
{ icon: 'fa-trash', label: 'Delete Tab', action: () => this.handleDeleteTab(tabId), disabled: this.tabManager.getTabCount() === 1, danger: true }
|
||||
];
|
||||
|
||||
items.forEach(item => {
|
||||
if (item.type === 'separator') {
|
||||
const separator = document.createElement('div');
|
||||
separator.style.cssText = `
|
||||
height: 1px;
|
||||
background: var(--rpg-border);
|
||||
margin: 6px 0;
|
||||
`;
|
||||
this.menu.appendChild(separator);
|
||||
return;
|
||||
}
|
||||
|
||||
const menuItem = this.createMenuItem(item);
|
||||
this.menu.appendChild(menuItem);
|
||||
});
|
||||
|
||||
// Append to body
|
||||
document.body.appendChild(this.menu);
|
||||
|
||||
// Adjust position if menu goes off-screen
|
||||
this.adjustMenuPosition();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create menu item element
|
||||
* @param {Object} item - Item config
|
||||
* @returns {HTMLElement} Menu item element
|
||||
*/
|
||||
createMenuItem(item) {
|
||||
const menuItem = document.createElement('div');
|
||||
menuItem.className = 'rpg-tab-context-menu-item';
|
||||
|
||||
const baseColor = item.danger ? 'var(--rpg-highlight)' : 'var(--rpg-text)';
|
||||
const hoverBg = item.danger ? 'rgba(233, 69, 96, 0.3)' : 'rgba(255, 255, 255, 0.1)';
|
||||
|
||||
menuItem.style.cssText = `
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: ${baseColor};
|
||||
font-size: 14px;
|
||||
cursor: ${item.disabled ? 'not-allowed' : 'pointer'};
|
||||
transition: background 0.2s;
|
||||
opacity: ${item.disabled ? '0.5' : '1'};
|
||||
`;
|
||||
|
||||
if (!item.disabled) {
|
||||
menuItem.onmouseenter = () => menuItem.style.background = hoverBg;
|
||||
menuItem.onmouseleave = () => menuItem.style.background = 'transparent';
|
||||
menuItem.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.hideMenu();
|
||||
item.action();
|
||||
};
|
||||
}
|
||||
|
||||
const icon = document.createElement('i');
|
||||
icon.className = `fa-solid ${item.icon}`;
|
||||
icon.style.cssText = `
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
color: ${item.danger ? 'var(--rpg-highlight)' : 'var(--rpg-border)'};
|
||||
`;
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = item.label;
|
||||
|
||||
menuItem.appendChild(icon);
|
||||
menuItem.appendChild(label);
|
||||
|
||||
return menuItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust menu position to stay within viewport
|
||||
*/
|
||||
adjustMenuPosition() {
|
||||
if (!this.menu) return;
|
||||
|
||||
const rect = this.menu.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
let left = parseInt(this.menu.style.left);
|
||||
let top = parseInt(this.menu.style.top);
|
||||
|
||||
// Adjust horizontal position
|
||||
if (rect.right > viewportWidth) {
|
||||
left = viewportWidth - rect.width - 10;
|
||||
}
|
||||
|
||||
// Adjust vertical position
|
||||
if (rect.bottom > viewportHeight) {
|
||||
top = viewportHeight - rect.height - 10;
|
||||
}
|
||||
|
||||
this.menu.style.left = `${Math.max(10, left)}px`;
|
||||
this.menu.style.top = `${Math.max(10, top)}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide context menu
|
||||
*/
|
||||
hideMenu() {
|
||||
if (this.menu) {
|
||||
this.menu.remove();
|
||||
this.menu = null;
|
||||
}
|
||||
this.currentTabId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle: Add New Tab
|
||||
*/
|
||||
async handleAddTab() {
|
||||
const tabName = await showPromptDialog({
|
||||
title: 'Add New Tab',
|
||||
message: 'Enter a name for the new tab:',
|
||||
placeholder: 'e.g., Combat, Exploration, Social',
|
||||
confirmText: 'Create',
|
||||
validator: (value) => {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return { valid: false, error: 'Tab name cannot be empty' };
|
||||
}
|
||||
if (value.trim().length > 30) {
|
||||
return { valid: false, error: 'Tab name too long (max 30 characters)' };
|
||||
}
|
||||
return { valid: true, error: '' };
|
||||
}
|
||||
});
|
||||
|
||||
if (tabName) {
|
||||
const tab = this.tabManager.createTab({
|
||||
name: tabName.trim(),
|
||||
icon: 'fa-solid fa-file'
|
||||
});
|
||||
|
||||
console.log('[TabContextMenu] Created new tab:', tab.name);
|
||||
if (this.onTabChange) this.onTabChange('tabCreated', { tab });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle: Rename Tab
|
||||
* @param {string} tabId - Tab ID
|
||||
*/
|
||||
async handleRenameTab(tabId) {
|
||||
const tab = this.tabManager.getTab(tabId);
|
||||
if (!tab) return;
|
||||
|
||||
const newName = await showPromptDialog({
|
||||
title: 'Rename Tab',
|
||||
message: `Rename "${tab.name}":`,
|
||||
defaultValue: tab.name,
|
||||
placeholder: 'Enter new tab name',
|
||||
confirmText: 'Rename',
|
||||
validator: (value) => {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return { valid: false, error: 'Tab name cannot be empty' };
|
||||
}
|
||||
if (value.trim().length > 30) {
|
||||
return { valid: false, error: 'Tab name too long (max 30 characters)' };
|
||||
}
|
||||
return { valid: true, error: '' };
|
||||
}
|
||||
});
|
||||
|
||||
if (newName && newName.trim() !== tab.name) {
|
||||
const success = this.tabManager.renameTab(tabId, newName.trim());
|
||||
if (success) {
|
||||
console.log('[TabContextMenu] Renamed tab:', tab.name, '→', newName.trim());
|
||||
if (this.onTabChange) this.onTabChange('tabRenamed', { tabId, newName: newName.trim() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle: Change Icon
|
||||
* @param {string} tabId - Tab ID
|
||||
*/
|
||||
async handleChangeIcon(tabId) {
|
||||
const tab = this.tabManager.getTab(tabId);
|
||||
if (!tab) return;
|
||||
|
||||
// Common FontAwesome icon options
|
||||
const iconOptions = [
|
||||
{ icon: 'fa-file', label: 'Document' },
|
||||
{ icon: 'fa-home', label: 'Home' },
|
||||
{ icon: 'fa-user', label: 'User' },
|
||||
{ icon: 'fa-users', label: 'Group' },
|
||||
{ icon: 'fa-heart', label: 'Heart' },
|
||||
{ icon: 'fa-star', label: 'Star' },
|
||||
{ icon: 'fa-flag', label: 'Flag' },
|
||||
{ icon: 'fa-bookmark', label: 'Bookmark' },
|
||||
{ icon: 'fa-map', label: 'Map' },
|
||||
{ icon: 'fa-compass', label: 'Compass' },
|
||||
{ icon: 'fa-shield', label: 'Shield' },
|
||||
{ icon: 'fa-sword', label: 'Sword' },
|
||||
{ icon: 'fa-wand-magic-sparkles', label: 'Magic' },
|
||||
{ icon: 'fa-scroll', label: 'Scroll' },
|
||||
{ icon: 'fa-book', label: 'Book' },
|
||||
{ icon: 'fa-dragon', label: 'Dragon' },
|
||||
{ icon: 'fa-dice-d20', label: 'D20' },
|
||||
{ icon: 'fa-fire', label: 'Fire' },
|
||||
{ icon: 'fa-bolt', label: 'Lightning' },
|
||||
{ icon: 'fa-crown', label: 'Crown' }
|
||||
];
|
||||
|
||||
// Create icon picker modal
|
||||
const newIcon = await this.showIconPicker(iconOptions, tab.icon);
|
||||
if (newIcon && newIcon !== tab.icon) {
|
||||
const success = this.tabManager.changeTabIcon(tabId, `fa-solid ${newIcon}`);
|
||||
if (success) {
|
||||
console.log('[TabContextMenu] Changed tab icon:', tab.name);
|
||||
if (this.onTabChange) this.onTabChange('tabIconChanged', { tabId, newIcon });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show icon picker modal
|
||||
* @param {Array} iconOptions - Array of icon options
|
||||
* @param {string} currentIcon - Currently selected icon
|
||||
* @returns {Promise<string|null>} Selected icon class or null
|
||||
*/
|
||||
showIconPicker(iconOptions, currentIcon) {
|
||||
return new Promise((resolve) => {
|
||||
// Create modal (uses .rpg-modal class for theming)
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'rpg-modal';
|
||||
modal.style.display = 'flex';
|
||||
|
||||
// Modal content (uses .rpg-modal-content class for theming)
|
||||
const content = document.createElement('div');
|
||||
content.className = 'rpg-modal-content';
|
||||
|
||||
// Copy theme from panel so modal inherits theme CSS variables
|
||||
const panel = document.querySelector('.rpg-panel');
|
||||
if (panel && panel.dataset.theme) {
|
||||
content.dataset.theme = panel.dataset.theme;
|
||||
} else {
|
||||
// For default theme: read computed colors from panel and apply as solid (1.0 opacity)
|
||||
const computedStyle = window.getComputedStyle(panel);
|
||||
const bgColor = computedStyle.getPropertyValue('--rpg-bg').trim();
|
||||
const accentColor = computedStyle.getPropertyValue('--rpg-accent').trim();
|
||||
|
||||
// Convert rgba with 0.9 opacity to 1.0 opacity
|
||||
const solidBg = bgColor.replace(/rgba\(([^)]+),\s*[\d.]+\)/, 'rgba($1, 1)');
|
||||
const solidAccent = accentColor.replace(/rgba\(([^)]+),\s*[\d.]+\)/, 'rgba($1, 1)');
|
||||
|
||||
content.style.background = `linear-gradient(135deg, ${solidAccent} 0%, ${solidBg} 100%)`;
|
||||
content.style.opacity = '1';
|
||||
}
|
||||
|
||||
content.style.padding = '1.5rem';
|
||||
content.style.maxWidth = '500px';
|
||||
|
||||
const title = document.createElement('h3');
|
||||
title.textContent = 'Choose Icon';
|
||||
title.style.cssText = `
|
||||
margin: 0 0 1.25rem 0;
|
||||
color: var(--rpg-text);
|
||||
font-size: 1.25rem;
|
||||
`;
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.style.cssText = `
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
`;
|
||||
|
||||
// Extract icon name without fa-solid prefix for comparison
|
||||
const currentIconName = currentIcon.replace('fa-solid ', '');
|
||||
|
||||
iconOptions.forEach(option => {
|
||||
const iconBtn = document.createElement('button');
|
||||
const isSelected = option.icon === currentIconName;
|
||||
|
||||
iconBtn.style.cssText = `
|
||||
padding: 1rem;
|
||||
background: ${isSelected ? 'var(--rpg-highlight)' : 'var(--rpg-accent)'};
|
||||
border: 2px solid ${isSelected ? 'var(--rpg-highlight)' : 'var(--rpg-border)'};
|
||||
border-radius: 6px;
|
||||
color: ${isSelected ? 'white' : 'var(--rpg-text)'};
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
iconBtn.innerHTML = `<i class="fa-solid ${option.icon}"></i>`;
|
||||
iconBtn.title = option.label;
|
||||
|
||||
iconBtn.onmouseenter = () => {
|
||||
if (!isSelected) {
|
||||
iconBtn.style.borderColor = 'var(--rpg-highlight)';
|
||||
iconBtn.style.transform = 'scale(1.05)';
|
||||
}
|
||||
};
|
||||
iconBtn.onmouseleave = () => {
|
||||
if (!isSelected) {
|
||||
iconBtn.style.borderColor = 'var(--rpg-border)';
|
||||
iconBtn.style.transform = 'scale(1)';
|
||||
}
|
||||
};
|
||||
|
||||
iconBtn.onclick = () => {
|
||||
modal.remove();
|
||||
resolve(option.icon);
|
||||
};
|
||||
|
||||
grid.appendChild(iconBtn);
|
||||
});
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'rpg-btn-secondary';
|
||||
cancelBtn.innerHTML = '<i class="fa-solid fa-times"></i> Cancel';
|
||||
cancelBtn.style.width = '100%';
|
||||
cancelBtn.onclick = () => {
|
||||
modal.remove();
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
content.appendChild(title);
|
||||
content.appendChild(grid);
|
||||
content.appendChild(cancelBtn);
|
||||
modal.appendChild(content);
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Close on backdrop click
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
modal.remove();
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
|
||||
// Close on Escape
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
modal.remove();
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle: Duplicate Tab
|
||||
* @param {string} tabId - Tab ID
|
||||
*/
|
||||
async handleDuplicateTab(tabId) {
|
||||
const newTab = this.tabManager.duplicateTab(tabId);
|
||||
if (newTab) {
|
||||
console.log('[TabContextMenu] Duplicated tab:', newTab.name);
|
||||
if (this.onTabChange) this.onTabChange('tabDuplicated', { sourceTabId: tabId, newTab });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle: Delete Tab
|
||||
* @param {string} tabId - Tab ID
|
||||
*/
|
||||
async handleDeleteTab(tabId) {
|
||||
const tab = this.tabManager.getTab(tabId);
|
||||
if (!tab) return;
|
||||
|
||||
// Prevent deleting last tab
|
||||
if (this.tabManager.getTabCount() === 1) {
|
||||
await showConfirmDialog({
|
||||
title: 'Cannot Delete',
|
||||
message: 'You cannot delete the last remaining tab.',
|
||||
variant: 'warning',
|
||||
confirmText: 'OK',
|
||||
cancelText: ''
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await showConfirmDialog({
|
||||
title: 'Delete Tab?',
|
||||
message: `Are you sure you want to delete "${tab.name}"? All widgets in this tab will be removed.`,
|
||||
variant: 'danger',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel'
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
const success = this.tabManager.deleteTab(tabId);
|
||||
if (success) {
|
||||
console.log('[TabContextMenu] Deleted tab:', tab.name);
|
||||
if (this.onTabChange) this.onTabChange('tabDeleted', { tabId, tab });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy context menu system
|
||||
*/
|
||||
destroy() {
|
||||
this.hideMenu();
|
||||
// Event delegation means no need to remove individual handlers
|
||||
console.log('[TabContextMenu] Destroyed');
|
||||
}
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
/**
|
||||
* Tab Management System
|
||||
*
|
||||
* Handles creation, deletion, reordering, and navigation of dashboard tabs.
|
||||
* Provides methods for tab lifecycle management and active tab tracking.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Tab
|
||||
* @property {string} id - Unique tab identifier
|
||||
* @property {string} name - Display name
|
||||
* @property {string} icon - Emoji/icon
|
||||
* @property {number} order - Sort order
|
||||
* @property {Array<Object>} widgets - Widgets in this tab
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TabConfig
|
||||
* @property {string} name - Tab name
|
||||
* @property {string} [icon] - Tab icon (default: 📄)
|
||||
* @property {number} [order] - Tab order (default: append to end)
|
||||
*/
|
||||
|
||||
export class TabManager {
|
||||
/**
|
||||
* @param {Object} dashboard - Dashboard configuration object
|
||||
*/
|
||||
constructor(dashboard) {
|
||||
if (!dashboard || !Array.isArray(dashboard.tabs)) {
|
||||
throw new Error('TabManager requires a valid dashboard with tabs array');
|
||||
}
|
||||
|
||||
this.dashboard = dashboard;
|
||||
this.activeTabId = dashboard.defaultTab || (dashboard.tabs[0]?.id || null);
|
||||
this.changeListeners = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tabs
|
||||
* @returns {Array<Tab>} Array of tabs sorted by order
|
||||
*/
|
||||
getTabs() {
|
||||
return [...this.dashboard.tabs].sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active tab
|
||||
* @returns {Tab|null} Active tab or null
|
||||
*/
|
||||
getActiveTab() {
|
||||
return this.dashboard.tabs.find(t => t.id === this.activeTabId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set active tab
|
||||
* @param {string} tabId - Tab ID to activate
|
||||
* @returns {boolean} True if successful
|
||||
*/
|
||||
setActiveTab(tabId) {
|
||||
const tab = this.dashboard.tabs.find(t => t.id === tabId);
|
||||
if (!tab) {
|
||||
console.error(`[TabManager] Tab not found: ${tabId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.activeTabId = tabId;
|
||||
this.dashboard.defaultTab = tabId;
|
||||
this.notifyChange('activeTabChanged', { tabId });
|
||||
console.log(`[TabManager] Active tab set to: ${tab.name}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new tab
|
||||
* @param {TabConfig} config - Tab configuration
|
||||
* @returns {Tab} Created tab
|
||||
*/
|
||||
createTab(config) {
|
||||
if (!config.name || typeof config.name !== 'string') {
|
||||
throw new Error('Tab name is required');
|
||||
}
|
||||
|
||||
// Generate unique ID
|
||||
const baseId = `tab-${config.name.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
let id = baseId;
|
||||
let counter = 1;
|
||||
while (this.dashboard.tabs.some(t => t.id === id)) {
|
||||
id = `${baseId}-${counter++}`;
|
||||
}
|
||||
|
||||
// Determine order
|
||||
const order = typeof config.order === 'number'
|
||||
? config.order
|
||||
: Math.max(0, ...this.dashboard.tabs.map(t => t.order)) + 1;
|
||||
|
||||
// Create tab
|
||||
const tab = {
|
||||
id,
|
||||
name: config.name,
|
||||
icon: config.icon || 'fa-solid fa-file',
|
||||
order,
|
||||
widgets: []
|
||||
};
|
||||
|
||||
this.dashboard.tabs.push(tab);
|
||||
this.notifyChange('tabCreated', { tab });
|
||||
console.log(`[TabManager] Created tab: ${tab.name} (${id})`);
|
||||
return tab;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename tab
|
||||
* @param {string} tabId - Tab ID
|
||||
* @param {string} newName - New tab name
|
||||
* @returns {boolean} True if successful
|
||||
*/
|
||||
renameTab(tabId, newName) {
|
||||
if (!newName || typeof newName !== 'string') {
|
||||
throw new Error('New name is required');
|
||||
}
|
||||
|
||||
const tab = this.dashboard.tabs.find(t => t.id === tabId);
|
||||
if (!tab) {
|
||||
console.error(`[TabManager] Tab not found: ${tabId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const oldName = tab.name;
|
||||
tab.name = newName;
|
||||
this.notifyChange('tabRenamed', { tabId, oldName, newName });
|
||||
console.log(`[TabManager] Renamed tab: ${oldName} → ${newName}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change tab icon
|
||||
* @param {string} tabId - Tab ID
|
||||
* @param {string} newIcon - New icon
|
||||
* @returns {boolean} True if successful
|
||||
*/
|
||||
changeTabIcon(tabId, newIcon) {
|
||||
const tab = this.dashboard.tabs.find(t => t.id === tabId);
|
||||
if (!tab) {
|
||||
console.error(`[TabManager] Tab not found: ${tabId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const oldIcon = tab.icon;
|
||||
tab.icon = newIcon;
|
||||
this.notifyChange('tabIconChanged', { tabId, oldIcon, newIcon });
|
||||
console.log(`[TabManager] Changed icon for ${tab.name}: ${oldIcon} → ${newIcon}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete tab
|
||||
* @param {string} tabId - Tab ID to delete
|
||||
* @param {boolean} [force=false] - Skip confirmation for single tab
|
||||
* @returns {boolean} True if successful
|
||||
*/
|
||||
deleteTab(tabId, force = false) {
|
||||
const tabIndex = this.dashboard.tabs.findIndex(t => t.id === tabId);
|
||||
if (tabIndex === -1) {
|
||||
console.error(`[TabManager] Tab not found: ${tabId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prevent deleting last tab unless forced
|
||||
if (this.dashboard.tabs.length === 1 && !force) {
|
||||
console.warn('[TabManager] Cannot delete last tab');
|
||||
return false;
|
||||
}
|
||||
|
||||
const tab = this.dashboard.tabs[tabIndex];
|
||||
|
||||
// If deleting active tab, switch to another
|
||||
if (this.activeTabId === tabId) {
|
||||
// Try next tab, then previous, then first available
|
||||
const nextTab = this.dashboard.tabs[tabIndex + 1]
|
||||
|| this.dashboard.tabs[tabIndex - 1]
|
||||
|| this.dashboard.tabs.find(t => t.id !== tabId);
|
||||
|
||||
if (nextTab) {
|
||||
this.setActiveTab(nextTab.id);
|
||||
}
|
||||
}
|
||||
|
||||
this.dashboard.tabs.splice(tabIndex, 1);
|
||||
this.notifyChange('tabDeleted', { tabId, tab });
|
||||
console.log(`[TabManager] Deleted tab: ${tab.name}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate tab
|
||||
* @param {string} tabId - Tab ID to duplicate
|
||||
* @returns {Tab|null} Duplicated tab or null
|
||||
*/
|
||||
duplicateTab(tabId) {
|
||||
const sourceTab = this.dashboard.tabs.find(t => t.id === tabId);
|
||||
if (!sourceTab) {
|
||||
console.error(`[TabManager] Tab not found: ${tabId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create new tab with copied name
|
||||
const copyName = `${sourceTab.name} (Copy)`;
|
||||
const newTab = this.createTab({
|
||||
name: copyName,
|
||||
icon: sourceTab.icon
|
||||
});
|
||||
|
||||
// Deep copy widgets
|
||||
newTab.widgets = sourceTab.widgets.map(widget => {
|
||||
const newWidget = { ...widget };
|
||||
|
||||
// Generate unique widget ID
|
||||
const baseId = widget.id.replace(/-copy-\d+$/, '');
|
||||
let newId = `${baseId}-copy`;
|
||||
let counter = 1;
|
||||
while (this.dashboard.tabs.some(t =>
|
||||
t.widgets.some(w => w.id === newId)
|
||||
)) {
|
||||
newId = `${baseId}-copy-${counter++}`;
|
||||
}
|
||||
newWidget.id = newId;
|
||||
|
||||
// Deep copy config
|
||||
newWidget.config = JSON.parse(JSON.stringify(widget.config || {}));
|
||||
|
||||
return newWidget;
|
||||
});
|
||||
|
||||
this.notifyChange('tabDuplicated', { sourceTabId: tabId, newTab });
|
||||
console.log(`[TabManager] Duplicated tab: ${sourceTab.name} → ${copyName}`);
|
||||
return newTab;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder tabs
|
||||
* @param {Array<string>} tabIds - Ordered array of tab IDs
|
||||
* @returns {boolean} True if successful
|
||||
*/
|
||||
reorderTabs(tabIds) {
|
||||
if (!Array.isArray(tabIds)) {
|
||||
throw new Error('tabIds must be an array');
|
||||
}
|
||||
|
||||
// Validate all tabs exist
|
||||
if (tabIds.length !== this.dashboard.tabs.length) {
|
||||
console.error('[TabManager] Invalid tab count for reordering');
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const id of tabIds) {
|
||||
if (!this.dashboard.tabs.some(t => t.id === id)) {
|
||||
console.error(`[TabManager] Unknown tab ID: ${id}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Update order property
|
||||
tabIds.forEach((id, index) => {
|
||||
const tab = this.dashboard.tabs.find(t => t.id === id);
|
||||
if (tab) {
|
||||
tab.order = index;
|
||||
}
|
||||
});
|
||||
|
||||
this.notifyChange('tabsReordered', { tabIds });
|
||||
console.log('[TabManager] Tabs reordered:', tabIds);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tab by ID
|
||||
* @param {string} tabId - Tab ID
|
||||
* @returns {Tab|null} Tab or null
|
||||
*/
|
||||
getTab(tabId) {
|
||||
return this.dashboard.tabs.find(t => t.id === tabId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tab count
|
||||
* @returns {number} Number of tabs
|
||||
*/
|
||||
getTabCount() {
|
||||
return this.dashboard.tabs.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tab exists
|
||||
* @param {string} tabId - Tab ID
|
||||
* @returns {boolean} True if exists
|
||||
*/
|
||||
hasTab(tabId) {
|
||||
return this.dashboard.tabs.some(t => t.id === tabId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tab index (in sorted order)
|
||||
* @param {string} tabId - Tab ID
|
||||
* @returns {number} Index or -1 if not found
|
||||
*/
|
||||
getTabIndex(tabId) {
|
||||
const sorted = this.getTabs();
|
||||
return sorted.findIndex(t => t.id === tabId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to tab by index (for keyboard shortcuts)
|
||||
* @param {number} index - Tab index (0-based)
|
||||
* @returns {boolean} True if successful
|
||||
*/
|
||||
switchToTabByIndex(index) {
|
||||
const sorted = this.getTabs();
|
||||
if (index < 0 || index >= sorted.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.setActiveTab(sorted[index].id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to next tab
|
||||
* @returns {boolean} True if successful
|
||||
*/
|
||||
switchToNextTab() {
|
||||
const sorted = this.getTabs();
|
||||
const currentIndex = sorted.findIndex(t => t.id === this.activeTabId);
|
||||
const nextIndex = (currentIndex + 1) % sorted.length;
|
||||
return this.setActiveTab(sorted[nextIndex].id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to previous tab
|
||||
* @returns {boolean} True if successful
|
||||
*/
|
||||
switchToPreviousTab() {
|
||||
const sorted = this.getTabs();
|
||||
const currentIndex = sorted.findIndex(t => t.id === this.activeTabId);
|
||||
const prevIndex = (currentIndex - 1 + sorted.length) % sorted.length;
|
||||
return this.setActiveTab(sorted[prevIndex].id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register change listener
|
||||
* @param {Function} callback - Callback function (event, data) => void
|
||||
*/
|
||||
onChange(callback) {
|
||||
this.changeListeners.add(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister change listener
|
||||
* @param {Function} callback - Callback to remove
|
||||
*/
|
||||
offChange(callback) {
|
||||
this.changeListeners.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all listeners of a change
|
||||
* @private
|
||||
*/
|
||||
notifyChange(event, data) {
|
||||
this.changeListeners.forEach(callback => {
|
||||
try {
|
||||
callback(event, data);
|
||||
} catch (error) {
|
||||
console.error('[TabManager] Error in change listener:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics
|
||||
* @returns {Object} Tab statistics
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
totalTabs: this.dashboard.tabs.length,
|
||||
activeTab: this.activeTabId,
|
||||
totalWidgets: this.dashboard.tabs.reduce((sum, t) => sum + t.widgets.length, 0),
|
||||
tabsWithWidgets: this.dashboard.tabs.filter(t => t.widgets.length > 0).length,
|
||||
emptyTabs: this.dashboard.tabs.filter(t => t.widgets.length === 0).length,
|
||||
averageWidgetsPerTab: (
|
||||
this.dashboard.tabs.reduce((sum, t) => sum + t.widgets.length, 0) /
|
||||
this.dashboard.tabs.length
|
||||
).toFixed(1)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,977 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tab Manager Test (Standalone)</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 20px;
|
||||
color: #e94560;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
background: #16213e;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.test-section h2 {
|
||||
color: #4ecca3;
|
||||
margin-bottom: 10px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* Tab Navigation UI */
|
||||
.tab-nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
background: #0f3460;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
background: #16213e;
|
||||
color: #eee;
|
||||
border: 2px solid transparent;
|
||||
padding: 10px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
background: #1f2e4d;
|
||||
border-color: #4ecca3;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
background: #e94560;
|
||||
border-color: #e94560;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tab-button .close-btn {
|
||||
margin-left: 8px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tab-button .close-btn:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.add-tab-btn {
|
||||
background: #4ecca3;
|
||||
color: #1a1a2e;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.add-tab-btn:hover {
|
||||
background: #5edc9f;
|
||||
}
|
||||
|
||||
/* Context Menu */
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
background: #16213e;
|
||||
border: 1px solid #4ecca3;
|
||||
border-radius: 6px;
|
||||
padding: 8px 0;
|
||||
display: none;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.context-menu.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
color: #eee;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
background: #0f3460;
|
||||
}
|
||||
|
||||
.context-menu-item.danger {
|
||||
color: #e94560;
|
||||
}
|
||||
|
||||
/* Test Controls */
|
||||
button {
|
||||
background: #e94560;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
margin: 5px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #d63651;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: #4ecca3;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
button.secondary:hover {
|
||||
background: #5edc9f;
|
||||
}
|
||||
|
||||
.result {
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
border-left: 3px solid #4ecca3;
|
||||
background: #0f3460;
|
||||
}
|
||||
|
||||
.result.pass {
|
||||
border-color: #4ecca3;
|
||||
}
|
||||
|
||||
.result.fail {
|
||||
border-color: #e94560;
|
||||
background: #2a0f1b;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
background: #0f3460;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #4ecca3;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #0f3460;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
overflow-x: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
background: #0f3460;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.event-log {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
padding: 8px;
|
||||
margin: 4px 0;
|
||||
background: #16213e;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.event-item .event-type {
|
||||
color: #4ecca3;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.event-item .event-time {
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.keyboard-hint {
|
||||
background: #0f3460;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.keyboard-hint kbd {
|
||||
background: #1a1a2e;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #4ecca3;
|
||||
color: #4ecca3;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🗂️ Tab Manager Test Suite (Standalone)</h1>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Live Tab Navigation</h2>
|
||||
<div id="tab-nav" class="tab-nav"></div>
|
||||
<div id="tab-content" class="tab-content">
|
||||
<p>Select a tab above to view its widgets</p>
|
||||
</div>
|
||||
<div class="keyboard-hint">
|
||||
<strong>Keyboard Shortcuts:</strong>
|
||||
<kbd>Ctrl+1-9</kbd> Switch to tab 1-9 •
|
||||
<kbd>Ctrl+Tab</kbd> Next tab •
|
||||
<kbd>Ctrl+Shift+Tab</kbd> Previous tab •
|
||||
<kbd>Right-click</kbd> tab for context menu
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Tab Operations</h2>
|
||||
<button onclick="testCreateTab()">Create New Tab</button>
|
||||
<button onclick="testRenameTab()">Rename Active Tab</button>
|
||||
<button onclick="testChangeIcon()">Change Icon</button>
|
||||
<button onclick="testDuplicateTab()">Duplicate Active Tab</button>
|
||||
<button onclick="testDeleteTab()">Delete Active Tab</button>
|
||||
<button onclick="testReorderTabs()">Reorder Tabs</button>
|
||||
<div id="operation-results"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Navigation Tests</h2>
|
||||
<button onclick="testSwitchToIndex(0)">Switch to Tab 1</button>
|
||||
<button onclick="testSwitchToIndex(1)">Switch to Tab 2</button>
|
||||
<button onclick="testNextTab()">Next Tab</button>
|
||||
<button onclick="testPreviousTab()">Previous Tab</button>
|
||||
<div id="navigation-results"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Event Log</h2>
|
||||
<button onclick="clearEventLog()">Clear Log</button>
|
||||
<div id="event-log" class="event-log"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Tab Statistics</h2>
|
||||
<div id="stats" class="stats"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Dashboard State (JSON)</h2>
|
||||
<pre id="dashboard-json"></pre>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<button onclick="runAllTests()" class="secondary">🔄 Run All Tests</button>
|
||||
</div>
|
||||
|
||||
<!-- Context Menu -->
|
||||
<div id="context-menu" class="context-menu">
|
||||
<div class="context-menu-item" onclick="contextRenameTab()">✏️ Rename</div>
|
||||
<div class="context-menu-item" onclick="contextChangeIcon()">🎨 Change Icon</div>
|
||||
<div class="context-menu-item" onclick="contextDuplicateTab()">📋 Duplicate</div>
|
||||
<div class="context-menu-item danger" onclick="contextDeleteTab()">🗑️ Delete</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// TabManager class (bundled inline to avoid CORS)
|
||||
class TabManager {
|
||||
constructor(dashboard) {
|
||||
if (!dashboard || !Array.isArray(dashboard.tabs)) {
|
||||
throw new Error('TabManager requires a valid dashboard with tabs array');
|
||||
}
|
||||
|
||||
this.dashboard = dashboard;
|
||||
this.activeTabId = dashboard.defaultTab || (dashboard.tabs[0]?.id || null);
|
||||
this.changeListeners = new Set();
|
||||
}
|
||||
|
||||
getTabs() {
|
||||
return [...this.dashboard.tabs].sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
getActiveTab() {
|
||||
return this.dashboard.tabs.find(t => t.id === this.activeTabId) || null;
|
||||
}
|
||||
|
||||
setActiveTab(tabId) {
|
||||
const tab = this.dashboard.tabs.find(t => t.id === tabId);
|
||||
if (!tab) {
|
||||
console.error(`[TabManager] Tab not found: ${tabId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.activeTabId = tabId;
|
||||
this.dashboard.defaultTab = tabId;
|
||||
this.notifyChange('activeTabChanged', { tabId });
|
||||
console.log(`[TabManager] Active tab set to: ${tab.name}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
createTab(config) {
|
||||
if (!config.name || typeof config.name !== 'string') {
|
||||
throw new Error('Tab name is required');
|
||||
}
|
||||
|
||||
const baseId = `tab-${config.name.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
let id = baseId;
|
||||
let counter = 1;
|
||||
while (this.dashboard.tabs.some(t => t.id === id)) {
|
||||
id = `${baseId}-${counter++}`;
|
||||
}
|
||||
|
||||
const order = typeof config.order === 'number'
|
||||
? config.order
|
||||
: Math.max(0, ...this.dashboard.tabs.map(t => t.order)) + 1;
|
||||
|
||||
const tab = {
|
||||
id,
|
||||
name: config.name,
|
||||
icon: config.icon || '📄',
|
||||
order,
|
||||
widgets: []
|
||||
};
|
||||
|
||||
this.dashboard.tabs.push(tab);
|
||||
this.notifyChange('tabCreated', { tab });
|
||||
console.log(`[TabManager] Created tab: ${tab.name} (${id})`);
|
||||
return tab;
|
||||
}
|
||||
|
||||
renameTab(tabId, newName) {
|
||||
if (!newName || typeof newName !== 'string') {
|
||||
throw new Error('New name is required');
|
||||
}
|
||||
|
||||
const tab = this.dashboard.tabs.find(t => t.id === tabId);
|
||||
if (!tab) {
|
||||
console.error(`[TabManager] Tab not found: ${tabId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const oldName = tab.name;
|
||||
tab.name = newName;
|
||||
this.notifyChange('tabRenamed', { tabId, oldName, newName });
|
||||
console.log(`[TabManager] Renamed tab: ${oldName} → ${newName}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
changeTabIcon(tabId, newIcon) {
|
||||
const tab = this.dashboard.tabs.find(t => t.id === tabId);
|
||||
if (!tab) {
|
||||
console.error(`[TabManager] Tab not found: ${tabId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const oldIcon = tab.icon;
|
||||
tab.icon = newIcon;
|
||||
this.notifyChange('tabIconChanged', { tabId, oldIcon, newIcon });
|
||||
console.log(`[TabManager] Changed icon for ${tab.name}: ${oldIcon} → ${newIcon}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
deleteTab(tabId, force = false) {
|
||||
const tabIndex = this.dashboard.tabs.findIndex(t => t.id === tabId);
|
||||
if (tabIndex === -1) {
|
||||
console.error(`[TabManager] Tab not found: ${tabId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.dashboard.tabs.length === 1 && !force) {
|
||||
console.warn('[TabManager] Cannot delete last tab');
|
||||
return false;
|
||||
}
|
||||
|
||||
const tab = this.dashboard.tabs[tabIndex];
|
||||
|
||||
if (this.activeTabId === tabId) {
|
||||
const nextTab = this.dashboard.tabs[tabIndex + 1]
|
||||
|| this.dashboard.tabs[tabIndex - 1]
|
||||
|| this.dashboard.tabs.find(t => t.id !== tabId);
|
||||
|
||||
if (nextTab) {
|
||||
this.setActiveTab(nextTab.id);
|
||||
}
|
||||
}
|
||||
|
||||
this.dashboard.tabs.splice(tabIndex, 1);
|
||||
this.notifyChange('tabDeleted', { tabId, tab });
|
||||
console.log(`[TabManager] Deleted tab: ${tab.name}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
duplicateTab(tabId) {
|
||||
const sourceTab = this.dashboard.tabs.find(t => t.id === tabId);
|
||||
if (!sourceTab) {
|
||||
console.error(`[TabManager] Tab not found: ${tabId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const copyName = `${sourceTab.name} (Copy)`;
|
||||
const newTab = this.createTab({
|
||||
name: copyName,
|
||||
icon: sourceTab.icon
|
||||
});
|
||||
|
||||
newTab.widgets = sourceTab.widgets.map(widget => {
|
||||
const newWidget = { ...widget };
|
||||
|
||||
const baseId = widget.id.replace(/-copy-\d+$/, '');
|
||||
let newId = `${baseId}-copy`;
|
||||
let counter = 1;
|
||||
while (this.dashboard.tabs.some(t =>
|
||||
t.widgets.some(w => w.id === newId)
|
||||
)) {
|
||||
newId = `${baseId}-copy-${counter++}`;
|
||||
}
|
||||
newWidget.id = newId;
|
||||
newWidget.config = JSON.parse(JSON.stringify(widget.config || {}));
|
||||
|
||||
return newWidget;
|
||||
});
|
||||
|
||||
this.notifyChange('tabDuplicated', { sourceTabId: tabId, newTab });
|
||||
console.log(`[TabManager] Duplicated tab: ${sourceTab.name} → ${copyName}`);
|
||||
return newTab;
|
||||
}
|
||||
|
||||
reorderTabs(tabIds) {
|
||||
if (!Array.isArray(tabIds)) {
|
||||
throw new Error('tabIds must be an array');
|
||||
}
|
||||
|
||||
if (tabIds.length !== this.dashboard.tabs.length) {
|
||||
console.error('[TabManager] Invalid tab count for reordering');
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const id of tabIds) {
|
||||
if (!this.dashboard.tabs.some(t => t.id === id)) {
|
||||
console.error(`[TabManager] Unknown tab ID: ${id}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
tabIds.forEach((id, index) => {
|
||||
const tab = this.dashboard.tabs.find(t => t.id === id);
|
||||
if (tab) {
|
||||
tab.order = index;
|
||||
}
|
||||
});
|
||||
|
||||
this.notifyChange('tabsReordered', { tabIds });
|
||||
console.log('[TabManager] Tabs reordered:', tabIds);
|
||||
return true;
|
||||
}
|
||||
|
||||
getTab(tabId) {
|
||||
return this.dashboard.tabs.find(t => t.id === tabId) || null;
|
||||
}
|
||||
|
||||
getTabCount() {
|
||||
return this.dashboard.tabs.length;
|
||||
}
|
||||
|
||||
hasTab(tabId) {
|
||||
return this.dashboard.tabs.some(t => t.id === tabId);
|
||||
}
|
||||
|
||||
getTabIndex(tabId) {
|
||||
const sorted = this.getTabs();
|
||||
return sorted.findIndex(t => t.id === tabId);
|
||||
}
|
||||
|
||||
switchToTabByIndex(index) {
|
||||
const sorted = this.getTabs();
|
||||
if (index < 0 || index >= sorted.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.setActiveTab(sorted[index].id);
|
||||
}
|
||||
|
||||
switchToNextTab() {
|
||||
const sorted = this.getTabs();
|
||||
const currentIndex = sorted.findIndex(t => t.id === this.activeTabId);
|
||||
const nextIndex = (currentIndex + 1) % sorted.length;
|
||||
return this.setActiveTab(sorted[nextIndex].id);
|
||||
}
|
||||
|
||||
switchToPreviousTab() {
|
||||
const sorted = this.getTabs();
|
||||
const currentIndex = sorted.findIndex(t => t.id === this.activeTabId);
|
||||
const prevIndex = (currentIndex - 1 + sorted.length) % sorted.length;
|
||||
return this.setActiveTab(sorted[prevIndex].id);
|
||||
}
|
||||
|
||||
onChange(callback) {
|
||||
this.changeListeners.add(callback);
|
||||
}
|
||||
|
||||
offChange(callback) {
|
||||
this.changeListeners.delete(callback);
|
||||
}
|
||||
|
||||
notifyChange(event, data) {
|
||||
this.changeListeners.forEach(callback => {
|
||||
try {
|
||||
callback(event, data);
|
||||
} catch (error) {
|
||||
console.error('[TabManager] Error in change listener:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
totalTabs: this.dashboard.tabs.length,
|
||||
activeTab: this.activeTabId,
|
||||
totalWidgets: this.dashboard.tabs.reduce((sum, t) => sum + t.widgets.length, 0),
|
||||
tabsWithWidgets: this.dashboard.tabs.filter(t => t.widgets.length > 0).length,
|
||||
emptyTabs: this.dashboard.tabs.filter(t => t.widgets.length === 0).length,
|
||||
averageWidgetsPerTab: (
|
||||
this.dashboard.tabs.reduce((sum, t) => sum + t.widgets.length, 0) /
|
||||
this.dashboard.tabs.length
|
||||
).toFixed(1)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Test application code
|
||||
let tabManager = null;
|
||||
let dashboard = null;
|
||||
let contextMenuTabId = null;
|
||||
|
||||
function pass(message) {
|
||||
return `<div class="result pass">✓ ${message}</div>`;
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
return `<div class="result fail">✗ ${message}</div>`;
|
||||
}
|
||||
|
||||
function logEvent(type, data) {
|
||||
const log = document.getElementById('event-log');
|
||||
const time = new Date().toLocaleTimeString();
|
||||
const eventItem = document.createElement('div');
|
||||
eventItem.className = 'event-item';
|
||||
eventItem.innerHTML = `
|
||||
<span class="event-time">${time}</span>
|
||||
<span class="event-type">${type}</span>
|
||||
${data ? `<pre>${JSON.stringify(data, null, 2)}</pre>` : ''}
|
||||
`;
|
||||
log.insertBefore(eventItem, log.firstChild);
|
||||
}
|
||||
|
||||
window.clearEventLog = function() {
|
||||
document.getElementById('event-log').innerHTML = '';
|
||||
};
|
||||
|
||||
function initDashboard() {
|
||||
dashboard = {
|
||||
version: 2,
|
||||
gridConfig: { columns: 12, rowHeight: 80, gap: 12 },
|
||||
tabs: [
|
||||
{
|
||||
id: 'tab-status',
|
||||
name: 'Status',
|
||||
icon: '📊',
|
||||
order: 0,
|
||||
widgets: [
|
||||
{ id: 'widget-1', type: 'userStats', x: 0, y: 0, w: 6, h: 3, config: {} },
|
||||
{ id: 'widget-2', type: 'infoBox', x: 6, y: 0, w: 6, h: 2, config: {} }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'tab-inventory',
|
||||
name: 'Inventory',
|
||||
icon: '🎒',
|
||||
order: 1,
|
||||
widgets: [
|
||||
{ id: 'widget-3', type: 'inventory', x: 0, y: 0, w: 12, h: 6, config: {} }
|
||||
]
|
||||
}
|
||||
],
|
||||
defaultTab: 'tab-status'
|
||||
};
|
||||
|
||||
tabManager = new TabManager(dashboard);
|
||||
|
||||
tabManager.onChange((event, data) => {
|
||||
logEvent(event, data);
|
||||
renderTabs();
|
||||
updateStats();
|
||||
updateDashboardJson();
|
||||
});
|
||||
|
||||
renderTabs();
|
||||
updateStats();
|
||||
updateDashboardJson();
|
||||
}
|
||||
|
||||
function renderTabs() {
|
||||
const nav = document.getElementById('tab-nav');
|
||||
nav.innerHTML = '';
|
||||
|
||||
const tabs = tabManager.getTabs();
|
||||
tabs.forEach(tab => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'tab-button';
|
||||
if (tab.id === tabManager.activeTabId) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
|
||||
btn.innerHTML = `
|
||||
<span>${tab.icon}</span>
|
||||
<span>${tab.name}</span>
|
||||
<span class="close-btn" onclick="event.stopPropagation(); quickDeleteTab('${tab.id}')">×</span>
|
||||
`;
|
||||
|
||||
btn.onclick = (e) => {
|
||||
if (!e.target.classList.contains('close-btn')) {
|
||||
tabManager.setActiveTab(tab.id);
|
||||
renderTabContent();
|
||||
}
|
||||
};
|
||||
|
||||
btn.oncontextmenu = (e) => {
|
||||
e.preventDefault();
|
||||
showContextMenu(e.clientX, e.clientY, tab.id);
|
||||
};
|
||||
|
||||
nav.appendChild(btn);
|
||||
});
|
||||
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.className = 'tab-button add-tab-btn';
|
||||
addBtn.innerHTML = '<span>+</span><span>New Tab</span>';
|
||||
addBtn.onclick = () => testCreateTab();
|
||||
nav.appendChild(addBtn);
|
||||
|
||||
renderTabContent();
|
||||
}
|
||||
|
||||
function renderTabContent() {
|
||||
const content = document.getElementById('tab-content');
|
||||
const activeTab = tabManager.getActiveTab();
|
||||
|
||||
if (!activeTab) {
|
||||
content.innerHTML = '<p>No active tab</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
content.innerHTML = `
|
||||
<h3>${activeTab.icon} ${activeTab.name}</h3>
|
||||
<p><strong>Tab ID:</strong> ${activeTab.id}</p>
|
||||
<p><strong>Widgets:</strong> ${activeTab.widgets.length}</p>
|
||||
<ul>
|
||||
${activeTab.widgets.map(w => `<li>${w.id} (${w.type})</li>`).join('')}
|
||||
</ul>
|
||||
`;
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
const stats = tabManager.getStats();
|
||||
const container = document.getElementById('stats');
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Total Tabs</div>
|
||||
<div class="stat-value">${stats.totalTabs}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Active Tab</div>
|
||||
<div class="stat-value">${stats.activeTab}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Total Widgets</div>
|
||||
<div class="stat-value">${stats.totalWidgets}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Tabs with Widgets</div>
|
||||
<div class="stat-value">${stats.tabsWithWidgets}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Empty Tabs</div>
|
||||
<div class="stat-value">${stats.emptyTabs}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Avg Widgets/Tab</div>
|
||||
<div class="stat-value">${stats.averageWidgetsPerTab}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function updateDashboardJson() {
|
||||
document.getElementById('dashboard-json').textContent =
|
||||
JSON.stringify(dashboard, null, 2);
|
||||
}
|
||||
|
||||
function showContextMenu(x, y, tabId) {
|
||||
contextMenuTabId = tabId;
|
||||
const menu = document.getElementById('context-menu');
|
||||
menu.classList.add('show');
|
||||
menu.style.left = x + 'px';
|
||||
menu.style.top = y + 'px';
|
||||
}
|
||||
|
||||
function hideContextMenu() {
|
||||
document.getElementById('context-menu').classList.remove('show');
|
||||
}
|
||||
|
||||
document.addEventListener('click', hideContextMenu);
|
||||
|
||||
window.contextRenameTab = function() {
|
||||
hideContextMenu();
|
||||
testRenameTab(contextMenuTabId);
|
||||
};
|
||||
|
||||
window.contextChangeIcon = function() {
|
||||
hideContextMenu();
|
||||
testChangeIcon(contextMenuTabId);
|
||||
};
|
||||
|
||||
window.contextDuplicateTab = function() {
|
||||
hideContextMenu();
|
||||
testDuplicateTab(contextMenuTabId);
|
||||
};
|
||||
|
||||
window.contextDeleteTab = function() {
|
||||
hideContextMenu();
|
||||
testDeleteTab(contextMenuTabId);
|
||||
};
|
||||
|
||||
window.quickDeleteTab = function(tabId) {
|
||||
tabManager.deleteTab(tabId);
|
||||
};
|
||||
|
||||
window.testCreateTab = function() {
|
||||
const container = document.getElementById('operation-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
const names = ['Analytics', 'Combat', 'Journal', 'Map', 'Quests'];
|
||||
const icons = ['📈', '⚔️', '📔', '🗺️', '📜'];
|
||||
const randomIndex = Math.floor(Math.random() * names.length);
|
||||
|
||||
try {
|
||||
const tab = tabManager.createTab({
|
||||
name: names[randomIndex],
|
||||
icon: icons[randomIndex]
|
||||
});
|
||||
container.innerHTML += pass(`Created tab: ${tab.icon} ${tab.name}`);
|
||||
} catch (error) {
|
||||
container.innerHTML += fail(`Error: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
window.testRenameTab = function(tabId = null) {
|
||||
const container = document.getElementById('operation-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
const targetId = tabId || tabManager.activeTabId;
|
||||
const tab = tabManager.getTab(targetId);
|
||||
if (!tab) {
|
||||
container.innerHTML += fail('No active tab');
|
||||
return;
|
||||
}
|
||||
|
||||
const newName = prompt(`Rename "${tab.name}" to:`, tab.name + ' (Renamed)');
|
||||
if (newName) {
|
||||
try {
|
||||
tabManager.renameTab(targetId, newName);
|
||||
container.innerHTML += pass(`Renamed to: ${newName}`);
|
||||
} catch (error) {
|
||||
container.innerHTML += fail(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.testChangeIcon = function(tabId = null) {
|
||||
const container = document.getElementById('operation-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
const targetId = tabId || tabManager.activeTabId;
|
||||
const tab = tabManager.getTab(targetId);
|
||||
if (!tab) {
|
||||
container.innerHTML += fail('No active tab');
|
||||
return;
|
||||
}
|
||||
|
||||
const icons = ['🎮', '🎨', '🎭', '🎪', '🎯', '🎲', '🎵', '🎬'];
|
||||
const randomIcon = icons[Math.floor(Math.random() * icons.length)];
|
||||
|
||||
try {
|
||||
tabManager.changeTabIcon(targetId, randomIcon);
|
||||
container.innerHTML += pass(`Changed icon to: ${randomIcon}`);
|
||||
} catch (error) {
|
||||
container.innerHTML += fail(`Error: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
window.testDuplicateTab = function(tabId = null) {
|
||||
const container = document.getElementById('operation-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
const targetId = tabId || tabManager.activeTabId;
|
||||
|
||||
try {
|
||||
const newTab = tabManager.duplicateTab(targetId);
|
||||
if (newTab) {
|
||||
container.innerHTML += pass(`Duplicated: ${newTab.name} (${newTab.widgets.length} widgets copied)`);
|
||||
} else {
|
||||
container.innerHTML += fail('Duplication failed');
|
||||
}
|
||||
} catch (error) {
|
||||
container.innerHTML += fail(`Error: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
window.testDeleteTab = function(tabId = null) {
|
||||
const container = document.getElementById('operation-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
const targetId = tabId || tabManager.activeTabId;
|
||||
const tab = tabManager.getTab(targetId);
|
||||
if (!tab) {
|
||||
container.innerHTML += fail('No active tab');
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm(`Delete tab "${tab.name}"?`)) {
|
||||
try {
|
||||
const success = tabManager.deleteTab(targetId);
|
||||
if (success) {
|
||||
container.innerHTML += pass(`Deleted: ${tab.name}`);
|
||||
} else {
|
||||
container.innerHTML += fail('Cannot delete last tab');
|
||||
}
|
||||
} catch (error) {
|
||||
container.innerHTML += fail(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.testReorderTabs = function() {
|
||||
const container = document.getElementById('operation-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
const tabs = tabManager.getTabs();
|
||||
const reversed = [...tabs].reverse().map(t => t.id);
|
||||
|
||||
try {
|
||||
tabManager.reorderTabs(reversed);
|
||||
container.innerHTML += pass('Tabs reversed');
|
||||
} catch (error) {
|
||||
container.innerHTML += fail(`Error: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
window.testSwitchToIndex = function(index) {
|
||||
const container = document.getElementById('navigation-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
const success = tabManager.switchToTabByIndex(index);
|
||||
if (success) {
|
||||
const tab = tabManager.getActiveTab();
|
||||
container.innerHTML += pass(`Switched to tab ${index + 1}: ${tab.name}`);
|
||||
} else {
|
||||
container.innerHTML += fail(`Tab ${index + 1} does not exist`);
|
||||
}
|
||||
};
|
||||
|
||||
window.testNextTab = function() {
|
||||
const container = document.getElementById('navigation-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
tabManager.switchToNextTab();
|
||||
const tab = tabManager.getActiveTab();
|
||||
container.innerHTML += pass(`Next tab: ${tab.icon} ${tab.name}`);
|
||||
};
|
||||
|
||||
window.testPreviousTab = function() {
|
||||
const container = document.getElementById('navigation-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
tabManager.switchToPreviousTab();
|
||||
const tab = tabManager.getActiveTab();
|
||||
container.innerHTML += pass(`Previous tab: ${tab.icon} ${tab.name}`);
|
||||
};
|
||||
|
||||
window.runAllTests = function() {
|
||||
setTimeout(() => testCreateTab(), 100);
|
||||
setTimeout(() => testRenameTab(), 300);
|
||||
setTimeout(() => testChangeIcon(), 500);
|
||||
setTimeout(() => testDuplicateTab(), 700);
|
||||
setTimeout(() => testNextTab(), 900);
|
||||
setTimeout(() => testPreviousTab(), 1100);
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.ctrlKey && e.key >= '1' && e.key <= '9') {
|
||||
e.preventDefault();
|
||||
const index = parseInt(e.key) - 1;
|
||||
tabManager.switchToTabByIndex(index);
|
||||
}
|
||||
|
||||
if (e.ctrlKey && e.key === 'Tab' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
tabManager.switchToNextTab();
|
||||
}
|
||||
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
tabManager.switchToPreviousTab();
|
||||
}
|
||||
});
|
||||
|
||||
initDashboard();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,724 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tab Manager Test</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 20px;
|
||||
color: #e94560;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
background: #16213e;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.test-section h2 {
|
||||
color: #4ecca3;
|
||||
margin-bottom: 10px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* Tab Navigation UI */
|
||||
.tab-nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
background: #0f3460;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
background: #16213e;
|
||||
color: #eee;
|
||||
border: 2px solid transparent;
|
||||
padding: 10px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
background: #1f2e4d;
|
||||
border-color: #4ecca3;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
background: #e94560;
|
||||
border-color: #e94560;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tab-button .close-btn {
|
||||
margin-left: 8px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tab-button .close-btn:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.add-tab-btn {
|
||||
background: #4ecca3;
|
||||
color: #1a1a2e;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.add-tab-btn:hover {
|
||||
background: #5edc9f;
|
||||
}
|
||||
|
||||
/* Context Menu */
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
background: #16213e;
|
||||
border: 1px solid #4ecca3;
|
||||
border-radius: 6px;
|
||||
padding: 8px 0;
|
||||
display: none;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.context-menu.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
color: #eee;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
background: #0f3460;
|
||||
}
|
||||
|
||||
.context-menu-item.danger {
|
||||
color: #e94560;
|
||||
}
|
||||
|
||||
/* Test Controls */
|
||||
button {
|
||||
background: #e94560;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
margin: 5px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #d63651;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: #4ecca3;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
button.secondary:hover {
|
||||
background: #5edc9f;
|
||||
}
|
||||
|
||||
.result {
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
border-left: 3px solid #4ecca3;
|
||||
background: #0f3460;
|
||||
}
|
||||
|
||||
.result.pass {
|
||||
border-color: #4ecca3;
|
||||
}
|
||||
|
||||
.result.fail {
|
||||
border-color: #e94560;
|
||||
background: #2a0f1b;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
background: #0f3460;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #4ecca3;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #0f3460;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
overflow-x: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
background: #0f3460;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.event-log {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
padding: 8px;
|
||||
margin: 4px 0;
|
||||
background: #16213e;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.event-item .event-type {
|
||||
color: #4ecca3;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.event-item .event-time {
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.keyboard-hint {
|
||||
background: #0f3460;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.keyboard-hint kbd {
|
||||
background: #1a1a2e;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #4ecca3;
|
||||
color: #4ecca3;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🗂️ Tab Manager Test Suite</h1>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Live Tab Navigation</h2>
|
||||
<div id="tab-nav" class="tab-nav"></div>
|
||||
<div id="tab-content" class="tab-content">
|
||||
<p>Select a tab above to view its widgets</p>
|
||||
</div>
|
||||
<div class="keyboard-hint">
|
||||
<strong>Keyboard Shortcuts:</strong>
|
||||
<kbd>Ctrl+1-9</kbd> Switch to tab 1-9 •
|
||||
<kbd>Ctrl+Tab</kbd> Next tab •
|
||||
<kbd>Ctrl+Shift+Tab</kbd> Previous tab •
|
||||
<kbd>Right-click</kbd> tab for context menu
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Tab Operations</h2>
|
||||
<button onclick="testCreateTab()">Create New Tab</button>
|
||||
<button onclick="testRenameTab()">Rename Active Tab</button>
|
||||
<button onclick="testChangeIcon()">Change Icon</button>
|
||||
<button onclick="testDuplicateTab()">Duplicate Active Tab</button>
|
||||
<button onclick="testDeleteTab()">Delete Active Tab</button>
|
||||
<button onclick="testReorderTabs()">Reorder Tabs</button>
|
||||
<div id="operation-results"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Navigation Tests</h2>
|
||||
<button onclick="testSwitchToIndex(0)">Switch to Tab 1</button>
|
||||
<button onclick="testSwitchToIndex(1)">Switch to Tab 2</button>
|
||||
<button onclick="testNextTab()">Next Tab</button>
|
||||
<button onclick="testPreviousTab()">Previous Tab</button>
|
||||
<div id="navigation-results"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Event Log</h2>
|
||||
<button onclick="clearEventLog()">Clear Log</button>
|
||||
<div id="event-log" class="event-log"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Tab Statistics</h2>
|
||||
<div id="stats" class="stats"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Dashboard State (JSON)</h2>
|
||||
<pre id="dashboard-json"></pre>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<button onclick="runAllTests()" class="secondary">🔄 Run All Tests</button>
|
||||
</div>
|
||||
|
||||
<!-- Context Menu -->
|
||||
<div id="context-menu" class="context-menu">
|
||||
<div class="context-menu-item" onclick="contextRenameTab()">✏️ Rename</div>
|
||||
<div class="context-menu-item" onclick="contextChangeIcon()">🎨 Change Icon</div>
|
||||
<div class="context-menu-item" onclick="contextDuplicateTab()">📋 Duplicate</div>
|
||||
<div class="context-menu-item danger" onclick="contextDeleteTab()">🗑️ Delete</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { TabManager } from './tabManager.js';
|
||||
|
||||
let tabManager = null;
|
||||
let dashboard = null;
|
||||
let contextMenuTabId = null;
|
||||
|
||||
function pass(message) {
|
||||
return `<div class="result pass">✓ ${message}</div>`;
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
return `<div class="result fail">✗ ${message}</div>`;
|
||||
}
|
||||
|
||||
function logEvent(type, data) {
|
||||
const log = document.getElementById('event-log');
|
||||
const time = new Date().toLocaleTimeString();
|
||||
const eventItem = document.createElement('div');
|
||||
eventItem.className = 'event-item';
|
||||
eventItem.innerHTML = `
|
||||
<span class="event-time">${time}</span>
|
||||
<span class="event-type">${type}</span>
|
||||
${data ? `<pre>${JSON.stringify(data, null, 2)}</pre>` : ''}
|
||||
`;
|
||||
log.insertBefore(eventItem, log.firstChild);
|
||||
}
|
||||
|
||||
window.clearEventLog = function() {
|
||||
document.getElementById('event-log').innerHTML = '';
|
||||
};
|
||||
|
||||
function initDashboard() {
|
||||
dashboard = {
|
||||
version: 2,
|
||||
gridConfig: { columns: 12, rowHeight: 80, gap: 12 },
|
||||
tabs: [
|
||||
{
|
||||
id: 'tab-status',
|
||||
name: 'Status',
|
||||
icon: '📊',
|
||||
order: 0,
|
||||
widgets: [
|
||||
{ id: 'widget-1', type: 'userStats', x: 0, y: 0, w: 6, h: 3 },
|
||||
{ id: 'widget-2', type: 'infoBox', x: 6, y: 0, w: 6, h: 2 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'tab-inventory',
|
||||
name: 'Inventory',
|
||||
icon: '🎒',
|
||||
order: 1,
|
||||
widgets: [
|
||||
{ id: 'widget-3', type: 'inventory', x: 0, y: 0, w: 12, h: 6 }
|
||||
]
|
||||
}
|
||||
],
|
||||
defaultTab: 'tab-status'
|
||||
};
|
||||
|
||||
tabManager = new TabManager(dashboard);
|
||||
|
||||
// Register change listener
|
||||
tabManager.onChange((event, data) => {
|
||||
logEvent(event, data);
|
||||
renderTabs();
|
||||
updateStats();
|
||||
updateDashboardJson();
|
||||
});
|
||||
|
||||
renderTabs();
|
||||
updateStats();
|
||||
updateDashboardJson();
|
||||
}
|
||||
|
||||
function renderTabs() {
|
||||
const nav = document.getElementById('tab-nav');
|
||||
nav.innerHTML = '';
|
||||
|
||||
const tabs = tabManager.getTabs();
|
||||
tabs.forEach(tab => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'tab-button';
|
||||
if (tab.id === tabManager.activeTabId) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
|
||||
btn.innerHTML = `
|
||||
<span>${tab.icon}</span>
|
||||
<span>${tab.name}</span>
|
||||
<span class="close-btn" onclick="event.stopPropagation(); quickDeleteTab('${tab.id}')">×</span>
|
||||
`;
|
||||
|
||||
btn.onclick = (e) => {
|
||||
if (!e.target.classList.contains('close-btn')) {
|
||||
tabManager.setActiveTab(tab.id);
|
||||
renderTabContent();
|
||||
}
|
||||
};
|
||||
|
||||
btn.oncontextmenu = (e) => {
|
||||
e.preventDefault();
|
||||
showContextMenu(e.clientX, e.clientY, tab.id);
|
||||
};
|
||||
|
||||
nav.appendChild(btn);
|
||||
});
|
||||
|
||||
// Add new tab button
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.className = 'tab-button add-tab-btn';
|
||||
addBtn.innerHTML = '<span>+</span><span>New Tab</span>';
|
||||
addBtn.onclick = () => testCreateTab();
|
||||
nav.appendChild(addBtn);
|
||||
|
||||
renderTabContent();
|
||||
}
|
||||
|
||||
function renderTabContent() {
|
||||
const content = document.getElementById('tab-content');
|
||||
const activeTab = tabManager.getActiveTab();
|
||||
|
||||
if (!activeTab) {
|
||||
content.innerHTML = '<p>No active tab</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
content.innerHTML = `
|
||||
<h3>${activeTab.icon} ${activeTab.name}</h3>
|
||||
<p><strong>Tab ID:</strong> ${activeTab.id}</p>
|
||||
<p><strong>Widgets:</strong> ${activeTab.widgets.length}</p>
|
||||
<ul>
|
||||
${activeTab.widgets.map(w => `<li>${w.id} (${w.type})</li>`).join('')}
|
||||
</ul>
|
||||
`;
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
const stats = tabManager.getStats();
|
||||
const container = document.getElementById('stats');
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Total Tabs</div>
|
||||
<div class="stat-value">${stats.totalTabs}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Active Tab</div>
|
||||
<div class="stat-value">${stats.activeTab}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Total Widgets</div>
|
||||
<div class="stat-value">${stats.totalWidgets}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Tabs with Widgets</div>
|
||||
<div class="stat-value">${stats.tabsWithWidgets}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Empty Tabs</div>
|
||||
<div class="stat-value">${stats.emptyTabs}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Avg Widgets/Tab</div>
|
||||
<div class="stat-value">${stats.averageWidgetsPerTab}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function updateDashboardJson() {
|
||||
document.getElementById('dashboard-json').textContent =
|
||||
JSON.stringify(dashboard, null, 2);
|
||||
}
|
||||
|
||||
// Context Menu
|
||||
function showContextMenu(x, y, tabId) {
|
||||
contextMenuTabId = tabId;
|
||||
const menu = document.getElementById('context-menu');
|
||||
menu.classList.add('show');
|
||||
menu.style.left = x + 'px';
|
||||
menu.style.top = y + 'px';
|
||||
}
|
||||
|
||||
function hideContextMenu() {
|
||||
document.getElementById('context-menu').classList.remove('show');
|
||||
}
|
||||
|
||||
document.addEventListener('click', hideContextMenu);
|
||||
|
||||
window.contextRenameTab = function() {
|
||||
hideContextMenu();
|
||||
testRenameTab(contextMenuTabId);
|
||||
};
|
||||
|
||||
window.contextChangeIcon = function() {
|
||||
hideContextMenu();
|
||||
testChangeIcon(contextMenuTabId);
|
||||
};
|
||||
|
||||
window.contextDuplicateTab = function() {
|
||||
hideContextMenu();
|
||||
testDuplicateTab(contextMenuTabId);
|
||||
};
|
||||
|
||||
window.contextDeleteTab = function() {
|
||||
hideContextMenu();
|
||||
testDeleteTab(contextMenuTabId);
|
||||
};
|
||||
|
||||
window.quickDeleteTab = function(tabId) {
|
||||
tabManager.deleteTab(tabId);
|
||||
};
|
||||
|
||||
// Test Functions
|
||||
window.testCreateTab = function() {
|
||||
const container = document.getElementById('operation-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
const names = ['Analytics', 'Combat', 'Journal', 'Map', 'Quests'];
|
||||
const icons = ['📈', '⚔️', '📔', '🗺️', '📜'];
|
||||
const randomIndex = Math.floor(Math.random() * names.length);
|
||||
|
||||
try {
|
||||
const tab = tabManager.createTab({
|
||||
name: names[randomIndex],
|
||||
icon: icons[randomIndex]
|
||||
});
|
||||
container.innerHTML += pass(`Created tab: ${tab.icon} ${tab.name}`);
|
||||
} catch (error) {
|
||||
container.innerHTML += fail(`Error: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
window.testRenameTab = function(tabId = null) {
|
||||
const container = document.getElementById('operation-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
const targetId = tabId || tabManager.activeTabId;
|
||||
const tab = tabManager.getTab(targetId);
|
||||
if (!tab) {
|
||||
container.innerHTML += fail('No active tab');
|
||||
return;
|
||||
}
|
||||
|
||||
const newName = prompt(`Rename "${tab.name}" to:`, tab.name + ' (Renamed)');
|
||||
if (newName) {
|
||||
try {
|
||||
tabManager.renameTab(targetId, newName);
|
||||
container.innerHTML += pass(`Renamed to: ${newName}`);
|
||||
} catch (error) {
|
||||
container.innerHTML += fail(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.testChangeIcon = function(tabId = null) {
|
||||
const container = document.getElementById('operation-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
const targetId = tabId || tabManager.activeTabId;
|
||||
const tab = tabManager.getTab(targetId);
|
||||
if (!tab) {
|
||||
container.innerHTML += fail('No active tab');
|
||||
return;
|
||||
}
|
||||
|
||||
const icons = ['🎮', '🎨', '🎭', '🎪', '🎯', '🎲', '🎵', '🎬'];
|
||||
const randomIcon = icons[Math.floor(Math.random() * icons.length)];
|
||||
|
||||
try {
|
||||
tabManager.changeTabIcon(targetId, randomIcon);
|
||||
container.innerHTML += pass(`Changed icon to: ${randomIcon}`);
|
||||
} catch (error) {
|
||||
container.innerHTML += fail(`Error: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
window.testDuplicateTab = function(tabId = null) {
|
||||
const container = document.getElementById('operation-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
const targetId = tabId || tabManager.activeTabId;
|
||||
|
||||
try {
|
||||
const newTab = tabManager.duplicateTab(targetId);
|
||||
if (newTab) {
|
||||
container.innerHTML += pass(`Duplicated: ${newTab.name} (${newTab.widgets.length} widgets copied)`);
|
||||
} else {
|
||||
container.innerHTML += fail('Duplication failed');
|
||||
}
|
||||
} catch (error) {
|
||||
container.innerHTML += fail(`Error: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
window.testDeleteTab = function(tabId = null) {
|
||||
const container = document.getElementById('operation-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
const targetId = tabId || tabManager.activeTabId;
|
||||
const tab = tabManager.getTab(targetId);
|
||||
if (!tab) {
|
||||
container.innerHTML += fail('No active tab');
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm(`Delete tab "${tab.name}"?`)) {
|
||||
try {
|
||||
const success = tabManager.deleteTab(targetId);
|
||||
if (success) {
|
||||
container.innerHTML += pass(`Deleted: ${tab.name}`);
|
||||
} else {
|
||||
container.innerHTML += fail('Cannot delete last tab');
|
||||
}
|
||||
} catch (error) {
|
||||
container.innerHTML += fail(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.testReorderTabs = function() {
|
||||
const container = document.getElementById('operation-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
const tabs = tabManager.getTabs();
|
||||
const reversed = [...tabs].reverse().map(t => t.id);
|
||||
|
||||
try {
|
||||
tabManager.reorderTabs(reversed);
|
||||
container.innerHTML += pass('Tabs reversed');
|
||||
} catch (error) {
|
||||
container.innerHTML += fail(`Error: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
window.testSwitchToIndex = function(index) {
|
||||
const container = document.getElementById('navigation-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
const success = tabManager.switchToTabByIndex(index);
|
||||
if (success) {
|
||||
const tab = tabManager.getActiveTab();
|
||||
container.innerHTML += pass(`Switched to tab ${index + 1}: ${tab.name}`);
|
||||
} else {
|
||||
container.innerHTML += fail(`Tab ${index + 1} does not exist`);
|
||||
}
|
||||
};
|
||||
|
||||
window.testNextTab = function() {
|
||||
const container = document.getElementById('navigation-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
tabManager.switchToNextTab();
|
||||
const tab = tabManager.getActiveTab();
|
||||
container.innerHTML += pass(`Next tab: ${tab.icon} ${tab.name}`);
|
||||
};
|
||||
|
||||
window.testPreviousTab = function() {
|
||||
const container = document.getElementById('navigation-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
tabManager.switchToPreviousTab();
|
||||
const tab = tabManager.getActiveTab();
|
||||
container.innerHTML += pass(`Previous tab: ${tab.icon} ${tab.name}`);
|
||||
};
|
||||
|
||||
window.runAllTests = function() {
|
||||
setTimeout(() => testCreateTab(), 100);
|
||||
setTimeout(() => testRenameTab(), 300);
|
||||
setTimeout(() => testChangeIcon(), 500);
|
||||
setTimeout(() => testDuplicateTab(), 700);
|
||||
setTimeout(() => testNextTab(), 900);
|
||||
setTimeout(() => testPreviousTab(), 1100);
|
||||
};
|
||||
|
||||
// Keyboard Shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Ctrl+1-9: Switch to tab by index
|
||||
if (e.ctrlKey && e.key >= '1' && e.key <= '9') {
|
||||
e.preventDefault();
|
||||
const index = parseInt(e.key) - 1;
|
||||
tabManager.switchToTabByIndex(index);
|
||||
}
|
||||
|
||||
// Ctrl+Tab: Next tab
|
||||
if (e.ctrlKey && e.key === 'Tab' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
tabManager.switchToNextTab();
|
||||
}
|
||||
|
||||
// Ctrl+Shift+Tab: Previous tab
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
tabManager.switchToPreviousTab();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize on load
|
||||
initDashboard();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,258 +0,0 @@
|
||||
/**
|
||||
* Tab Scroll Manager
|
||||
*
|
||||
* Handles horizontal scrolling of dashboard tabs with:
|
||||
* - Left/Right navigation arrows
|
||||
* - Edge fade indicators
|
||||
* - Smooth scroll behavior
|
||||
* - Automatic arrow visibility
|
||||
*/
|
||||
|
||||
export class TabScrollManager {
|
||||
/**
|
||||
* @param {HTMLElement} tabContainer - The scrollable tabs container
|
||||
* @param {Object} options - Configuration options
|
||||
*/
|
||||
constructor(tabContainer, options = {}) {
|
||||
this.tabContainer = tabContainer;
|
||||
this.options = {
|
||||
scrollAmount: 200, // px per click
|
||||
smoothScroll: true,
|
||||
showFadeIndicators: true,
|
||||
arrowHideDelay: 2000, // ms after scroll stops
|
||||
...options
|
||||
};
|
||||
|
||||
this.leftArrow = null;
|
||||
this.rightArrow = null;
|
||||
this.leftFade = null;
|
||||
this.rightFade = null;
|
||||
this.scrollTimeout = null;
|
||||
this.isScrolling = false;
|
||||
|
||||
this.boundScrollHandler = this.handleScroll.bind(this);
|
||||
this.boundResizeHandler = this.handleResize.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the scroll manager
|
||||
*/
|
||||
init() {
|
||||
console.log('[TabScrollManager] Initializing...');
|
||||
|
||||
// Create arrow buttons
|
||||
this.createArrows();
|
||||
|
||||
// Create fade indicators if enabled
|
||||
if (this.options.showFadeIndicators) {
|
||||
this.createFadeIndicators();
|
||||
}
|
||||
|
||||
// Set up event listeners
|
||||
this.tabContainer.addEventListener('scroll', this.boundScrollHandler);
|
||||
window.addEventListener('resize', this.boundResizeHandler);
|
||||
|
||||
// Initial state update
|
||||
this.updateScrollState();
|
||||
|
||||
console.log('[TabScrollManager] Initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create left and right arrow buttons
|
||||
*/
|
||||
createArrows() {
|
||||
const wrapper = this.tabContainer.parentElement;
|
||||
|
||||
// Left arrow
|
||||
this.leftArrow = document.createElement('button');
|
||||
this.leftArrow.className = 'rpg-tab-nav-arrow rpg-tab-nav-left';
|
||||
this.leftArrow.innerHTML = '<i class="fa-solid fa-chevron-left"></i>';
|
||||
this.leftArrow.setAttribute('aria-label', 'Scroll tabs left');
|
||||
this.leftArrow.addEventListener('click', () => this.scrollLeft());
|
||||
|
||||
// Right arrow
|
||||
this.rightArrow = document.createElement('button');
|
||||
this.rightArrow.className = 'rpg-tab-nav-arrow rpg-tab-nav-right';
|
||||
this.rightArrow.innerHTML = '<i class="fa-solid fa-chevron-right"></i>';
|
||||
this.rightArrow.setAttribute('aria-label', 'Scroll tabs right');
|
||||
this.rightArrow.addEventListener('click', () => this.scrollRight());
|
||||
|
||||
// Insert arrows
|
||||
wrapper.insertBefore(this.leftArrow, this.tabContainer);
|
||||
wrapper.appendChild(this.rightArrow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create fade indicator overlays
|
||||
*/
|
||||
createFadeIndicators() {
|
||||
const wrapper = this.tabContainer.parentElement;
|
||||
|
||||
// Left fade
|
||||
this.leftFade = document.createElement('div');
|
||||
this.leftFade.className = 'rpg-tab-fade rpg-tab-fade-left';
|
||||
|
||||
// Right fade
|
||||
this.rightFade = document.createElement('div');
|
||||
this.rightFade.className = 'rpg-tab-fade rpg-tab-fade-right';
|
||||
|
||||
// Insert fades
|
||||
wrapper.insertBefore(this.leftFade, this.tabContainer);
|
||||
wrapper.appendChild(this.rightFade);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll tabs to the left
|
||||
*/
|
||||
scrollLeft() {
|
||||
const scrollAmount = this.options.scrollAmount;
|
||||
const targetScroll = Math.max(0, this.tabContainer.scrollLeft - scrollAmount);
|
||||
|
||||
if (this.options.smoothScroll) {
|
||||
this.tabContainer.scrollTo({
|
||||
left: targetScroll,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
} else {
|
||||
this.tabContainer.scrollLeft = targetScroll;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll tabs to the right
|
||||
*/
|
||||
scrollRight() {
|
||||
const scrollAmount = this.options.scrollAmount;
|
||||
const maxScroll = this.tabContainer.scrollWidth - this.tabContainer.clientWidth;
|
||||
const targetScroll = Math.min(maxScroll, this.tabContainer.scrollLeft + scrollAmount);
|
||||
|
||||
if (this.options.smoothScroll) {
|
||||
this.tabContainer.scrollTo({
|
||||
left: targetScroll,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
} else {
|
||||
this.tabContainer.scrollLeft = targetScroll;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle scroll events
|
||||
*/
|
||||
handleScroll() {
|
||||
this.isScrolling = true;
|
||||
|
||||
// Update arrow and fade visibility
|
||||
this.updateScrollState();
|
||||
|
||||
// Clear previous timeout
|
||||
if (this.scrollTimeout) {
|
||||
clearTimeout(this.scrollTimeout);
|
||||
}
|
||||
|
||||
// Hide arrows after scroll stops (optional)
|
||||
if (this.options.arrowHideDelay > 0) {
|
||||
this.scrollTimeout = setTimeout(() => {
|
||||
this.isScrolling = false;
|
||||
this.updateScrollState();
|
||||
}, this.options.arrowHideDelay);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle window resize
|
||||
*/
|
||||
handleResize() {
|
||||
this.updateScrollState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update arrow and fade visibility based on scroll position
|
||||
*/
|
||||
updateScrollState() {
|
||||
const scrollLeft = this.tabContainer.scrollLeft;
|
||||
const scrollWidth = this.tabContainer.scrollWidth;
|
||||
const clientWidth = this.tabContainer.clientWidth;
|
||||
const maxScroll = scrollWidth - clientWidth;
|
||||
|
||||
const isScrollable = scrollWidth > clientWidth;
|
||||
const isAtStart = scrollLeft <= 1; // Small threshold for floating point
|
||||
const isAtEnd = scrollLeft >= maxScroll - 1;
|
||||
|
||||
// Show/hide left arrow
|
||||
if (this.leftArrow) {
|
||||
if (isScrollable && !isAtStart) {
|
||||
this.leftArrow.classList.add('visible');
|
||||
} else {
|
||||
this.leftArrow.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide right arrow
|
||||
if (this.rightArrow) {
|
||||
if (isScrollable && !isAtEnd) {
|
||||
this.rightArrow.classList.add('visible');
|
||||
} else {
|
||||
this.rightArrow.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide fade indicators
|
||||
if (this.leftFade) {
|
||||
if (isScrollable && !isAtStart) {
|
||||
this.leftFade.classList.add('visible');
|
||||
} else {
|
||||
this.leftFade.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
if (this.rightFade) {
|
||||
if (isScrollable && !isAtEnd) {
|
||||
this.rightFade.classList.add('visible');
|
||||
} else {
|
||||
this.rightFade.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll a specific tab into view
|
||||
* @param {HTMLElement} tabElement - Tab element to scroll to
|
||||
*/
|
||||
scrollToTab(tabElement) {
|
||||
if (!tabElement) return;
|
||||
|
||||
tabElement.scrollIntoView({
|
||||
behavior: this.options.smoothScroll ? 'smooth' : 'auto',
|
||||
block: 'nearest',
|
||||
inline: 'center'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the scroll manager
|
||||
*/
|
||||
destroy() {
|
||||
console.log('[TabScrollManager] Destroying...');
|
||||
|
||||
// Remove event listeners
|
||||
this.tabContainer.removeEventListener('scroll', this.boundScrollHandler);
|
||||
window.removeEventListener('resize', this.boundResizeHandler);
|
||||
|
||||
// Clear timeout
|
||||
if (this.scrollTimeout) {
|
||||
clearTimeout(this.scrollTimeout);
|
||||
}
|
||||
|
||||
// Remove arrows
|
||||
if (this.leftArrow) this.leftArrow.remove();
|
||||
if (this.rightArrow) this.rightArrow.remove();
|
||||
|
||||
// Remove fade indicators
|
||||
if (this.leftFade) this.leftFade.remove();
|
||||
if (this.rightFade) this.rightFade.remove();
|
||||
|
||||
console.log('[TabScrollManager] Destroyed');
|
||||
}
|
||||
}
|
||||
@@ -1,467 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GridEngine Test Harness</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 20px;
|
||||
color: #e94560;
|
||||
}
|
||||
|
||||
.controls {
|
||||
background: #16213e;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
background: #e94560;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
margin: 5px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.controls button:hover {
|
||||
background: #d63651;
|
||||
}
|
||||
|
||||
#grid-container {
|
||||
position: relative;
|
||||
width: 1200px;
|
||||
min-height: 600px;
|
||||
background: #0f3460;
|
||||
border: 2px solid #e94560;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.grid-lines {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.grid-lines line {
|
||||
stroke: rgba(233, 69, 96, 0.2);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.grid-lines text {
|
||||
fill: rgba(233, 69, 96, 0.6);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.widget {
|
||||
position: absolute;
|
||||
background: linear-gradient(135deg, #e94560, #d63651);
|
||||
border: 2px solid #fff;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
cursor: move;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.widget.dragging {
|
||||
opacity: 0.7;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.widget.colliding {
|
||||
background: linear-gradient(135deg, #ff6b6b, #ee5a52);
|
||||
border-color: #ffeb3b;
|
||||
}
|
||||
|
||||
.widget-header {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.widget-info {
|
||||
font-size: 11px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.widget-coords {
|
||||
font-size: 10px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
#console {
|
||||
background: #16213e;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#console .log {
|
||||
margin: 2px 0;
|
||||
padding: 2px 5px;
|
||||
border-left: 3px solid #4ecca3;
|
||||
}
|
||||
|
||||
#console .warn {
|
||||
margin: 2px 0;
|
||||
padding: 2px 5px;
|
||||
border-left: 3px solid #ffeb3b;
|
||||
color: #ffeb3b;
|
||||
}
|
||||
|
||||
#console .error {
|
||||
margin: 2px 0;
|
||||
padding: 2px 5px;
|
||||
border-left: 3px solid #e94560;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.stats {
|
||||
background: #16213e;
|
||||
padding: 10px 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #4ecca3;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🎯 GridEngine Test Harness</h1>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Widgets</div>
|
||||
<div class="stat-value" id="stat-widgets">0</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Collisions</div>
|
||||
<div class="stat-value" id="stat-collisions">0</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Grid Height</div>
|
||||
<div class="stat-value" id="stat-height">0px</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button onclick="addTestWidget()">➕ Add Widget</button>
|
||||
<button onclick="testReflow()">🔄 Test Reflow</button>
|
||||
<button onclick="testCollisions()">💥 Test Collisions</button>
|
||||
<button onclick="clearWidgets()">🗑️ Clear All</button>
|
||||
<button onclick="clearConsole()">📋 Clear Console</button>
|
||||
</div>
|
||||
|
||||
<div id="grid-container">
|
||||
<svg class="grid-lines" id="grid-lines"></svg>
|
||||
</div>
|
||||
|
||||
<div id="console"></div>
|
||||
|
||||
<script type="module">
|
||||
import { GridEngine } from './gridEngine.js';
|
||||
|
||||
// Initialize grid engine
|
||||
const gridEngine = new GridEngine({
|
||||
columns: 12,
|
||||
rowHeight: 80,
|
||||
gap: 12,
|
||||
snapToGrid: true
|
||||
});
|
||||
|
||||
// Set container width
|
||||
const container = document.getElementById('grid-container');
|
||||
gridEngine.setContainerWidth(container.offsetWidth);
|
||||
|
||||
// Widgets array
|
||||
let widgets = [];
|
||||
let widgetIdCounter = 0;
|
||||
|
||||
// Drag state
|
||||
let draggedWidget = null;
|
||||
let dragOffset = { x: 0, y: 0 };
|
||||
|
||||
// Console logging
|
||||
function log(message, type = 'log') {
|
||||
const consoleEl = document.getElementById('console');
|
||||
const entry = document.createElement('div');
|
||||
entry.className = type;
|
||||
entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
|
||||
consoleEl.appendChild(entry);
|
||||
consoleEl.scrollTop = consoleEl.scrollHeight;
|
||||
}
|
||||
|
||||
// Override console methods to capture in UI
|
||||
const originalConsole = {
|
||||
log: console.log,
|
||||
warn: console.warn,
|
||||
error: console.error
|
||||
};
|
||||
|
||||
console.log = (...args) => {
|
||||
originalConsole.log(...args);
|
||||
log(args.join(' '), 'log');
|
||||
};
|
||||
|
||||
console.warn = (...args) => {
|
||||
originalConsole.warn(...args);
|
||||
log(args.join(' '), 'warn');
|
||||
};
|
||||
|
||||
console.error = (...args) => {
|
||||
originalConsole.error(...args);
|
||||
log(args.join(' '), 'error');
|
||||
};
|
||||
|
||||
// Draw grid lines
|
||||
function drawGridLines() {
|
||||
const svg = document.getElementById('grid-lines');
|
||||
svg.innerHTML = '';
|
||||
|
||||
const width = container.offsetWidth;
|
||||
const height = gridEngine.calculateGridHeight(widgets) || 600;
|
||||
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||||
|
||||
// Calculate column width
|
||||
const totalGaps = gridEngine.gap * (gridEngine.columns + 1);
|
||||
const colWidth = (width - totalGaps) / gridEngine.columns;
|
||||
|
||||
// Draw vertical column lines
|
||||
for (let i = 0; i <= gridEngine.columns; i++) {
|
||||
const x = i * (colWidth + gridEngine.gap) + gridEngine.gap;
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', x);
|
||||
line.setAttribute('y1', 0);
|
||||
line.setAttribute('x2', x);
|
||||
line.setAttribute('y2', height);
|
||||
svg.appendChild(line);
|
||||
|
||||
// Column number label
|
||||
if (i < gridEngine.columns) {
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', x + colWidth / 2);
|
||||
text.setAttribute('y', 15);
|
||||
text.setAttribute('text-anchor', 'middle');
|
||||
text.textContent = i;
|
||||
svg.appendChild(text);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw horizontal row lines
|
||||
const rows = Math.ceil(height / (gridEngine.rowHeight + gridEngine.gap));
|
||||
for (let i = 0; i <= rows; i++) {
|
||||
const y = i * (gridEngine.rowHeight + gridEngine.gap) + gridEngine.gap;
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', 0);
|
||||
line.setAttribute('y1', y);
|
||||
line.setAttribute('x2', width);
|
||||
line.setAttribute('y2', y);
|
||||
svg.appendChild(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Add test widget
|
||||
window.addTestWidget = function() {
|
||||
const widget = {
|
||||
id: `widget-${widgetIdCounter++}`,
|
||||
x: Math.floor(Math.random() * 9), // Random column (0-8)
|
||||
y: Math.floor(Math.random() * 3), // Random row (0-2)
|
||||
w: Math.floor(Math.random() * 3) + 2, // Width 2-4
|
||||
h: Math.floor(Math.random() * 2) + 2 // Height 2-3
|
||||
};
|
||||
|
||||
// Validate widget
|
||||
const validated = gridEngine.validateWidget(widget, { w: 2, h: 2 });
|
||||
widgets.push(validated);
|
||||
|
||||
console.log(`Added widget: ${validated.id} at (${validated.x}, ${validated.y}) size ${validated.w}x${validated.h}`);
|
||||
|
||||
renderWidgets();
|
||||
};
|
||||
|
||||
// Render all widgets
|
||||
function renderWidgets() {
|
||||
// Clear existing widgets
|
||||
document.querySelectorAll('.widget').forEach(el => el.remove());
|
||||
|
||||
// Render each widget
|
||||
widgets.forEach(widget => {
|
||||
const pixels = gridEngine.getPixelPosition(widget);
|
||||
const colliding = gridEngine.detectCollision(widget, widgets);
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'widget' + (colliding ? ' colliding' : '');
|
||||
div.dataset.widgetId = widget.id;
|
||||
div.style.left = pixels.left + 'px';
|
||||
div.style.top = pixels.top + 'px';
|
||||
div.style.width = pixels.width + 'px';
|
||||
div.style.height = pixels.height + 'px';
|
||||
|
||||
div.innerHTML = `
|
||||
<div class="widget-header">${widget.id}</div>
|
||||
<div class="widget-info">
|
||||
Grid: (${widget.x}, ${widget.y})<br>
|
||||
Size: ${widget.w} × ${widget.h}
|
||||
</div>
|
||||
<div class="widget-coords">
|
||||
Pixels: ${Math.round(pixels.left)}, ${Math.round(pixels.top)}<br>
|
||||
${Math.round(pixels.width)} × ${Math.round(pixels.height)}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add drag listeners
|
||||
div.addEventListener('mousedown', startDrag);
|
||||
|
||||
container.appendChild(div);
|
||||
});
|
||||
|
||||
drawGridLines();
|
||||
updateStats();
|
||||
}
|
||||
|
||||
// Start dragging
|
||||
function startDrag(e) {
|
||||
const widgetId = e.currentTarget.dataset.widgetId;
|
||||
draggedWidget = widgets.find(w => w.id === widgetId);
|
||||
|
||||
if (!draggedWidget) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
dragOffset.x = e.clientX - rect.left;
|
||||
dragOffset.y = e.clientY - rect.top;
|
||||
|
||||
e.currentTarget.classList.add('dragging');
|
||||
|
||||
document.addEventListener('mousemove', onDrag);
|
||||
document.addEventListener('mouseup', stopDrag);
|
||||
}
|
||||
|
||||
// Drag widget
|
||||
function onDrag(e) {
|
||||
if (!draggedWidget) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const pixelX = e.clientX - containerRect.left - dragOffset.x;
|
||||
const pixelY = e.clientY - containerRect.top - dragOffset.y;
|
||||
|
||||
// Snap to grid
|
||||
const gridPos = gridEngine.snapToCell(pixelX, pixelY);
|
||||
draggedWidget.x = gridPos.x;
|
||||
draggedWidget.y = gridPos.y;
|
||||
|
||||
renderWidgets();
|
||||
}
|
||||
|
||||
// Stop dragging
|
||||
function stopDrag(e) {
|
||||
if (draggedWidget) {
|
||||
console.log(`Dropped ${draggedWidget.id} at (${draggedWidget.x}, ${draggedWidget.y})`);
|
||||
document.querySelector('.dragging')?.classList.remove('dragging');
|
||||
draggedWidget = null;
|
||||
}
|
||||
|
||||
document.removeEventListener('mousemove', onDrag);
|
||||
document.removeEventListener('mouseup', stopDrag);
|
||||
|
||||
renderWidgets();
|
||||
}
|
||||
|
||||
// Test reflow
|
||||
window.testReflow = function() {
|
||||
console.log('--- Testing Reflow ---');
|
||||
const before = widgets.map(w => `${w.id}: (${w.x}, ${w.y})`).join(', ');
|
||||
console.log('Before reflow:', before);
|
||||
|
||||
gridEngine.reflow(widgets);
|
||||
|
||||
const after = widgets.map(w => `${w.id}: (${w.x}, ${w.y})`).join(', ');
|
||||
console.log('After reflow:', after);
|
||||
|
||||
renderWidgets();
|
||||
};
|
||||
|
||||
// Test collisions
|
||||
window.testCollisions = function() {
|
||||
console.log('--- Testing Collision Detection ---');
|
||||
widgets.forEach(widget => {
|
||||
const collides = gridEngine.detectCollision(widget, widgets);
|
||||
console.log(`${widget.id}: ${collides ? 'COLLIDING ⚠️' : 'OK ✓'}`);
|
||||
});
|
||||
|
||||
renderWidgets();
|
||||
};
|
||||
|
||||
// Clear widgets
|
||||
window.clearWidgets = function() {
|
||||
widgets = [];
|
||||
widgetIdCounter = 0;
|
||||
console.log('All widgets cleared');
|
||||
renderWidgets();
|
||||
};
|
||||
|
||||
// Clear console
|
||||
window.clearConsole = function() {
|
||||
document.getElementById('console').innerHTML = '';
|
||||
};
|
||||
|
||||
// Update stats
|
||||
function updateStats() {
|
||||
document.getElementById('stat-widgets').textContent = widgets.length;
|
||||
|
||||
const collisions = widgets.filter(w => gridEngine.detectCollision(w, widgets)).length;
|
||||
document.getElementById('stat-collisions').textContent = collisions;
|
||||
|
||||
const height = gridEngine.calculateGridHeight(widgets);
|
||||
document.getElementById('stat-height').textContent = height + 'px';
|
||||
}
|
||||
|
||||
// Initial render
|
||||
drawGridLines();
|
||||
console.log('GridEngine test harness initialized');
|
||||
console.log('Click "Add Widget" to create test widgets');
|
||||
console.log('Drag widgets to test snapping and collision detection');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,472 +0,0 @@
|
||||
/**
|
||||
* Widget Base Utilities
|
||||
*
|
||||
* Provides common utilities for widget development:
|
||||
* - Standard widget HTML structure
|
||||
* - Editable field handlers
|
||||
* - Configuration UI helpers
|
||||
* - Event listener management
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create standard widget container structure
|
||||
* @param {Object} options - Widget options
|
||||
* @param {string} options.title - Widget title
|
||||
* @param {string} options.icon - Widget icon (emoji or FontAwesome class)
|
||||
* @param {string} options.content - Widget content HTML
|
||||
* @param {string} [options.headerClass] - Additional header CSS class
|
||||
* @param {string} [options.contentClass] - Additional content CSS class
|
||||
* @returns {string} Widget HTML
|
||||
*/
|
||||
export function createWidgetContainer({ title, icon, content, headerClass = '', contentClass = '' }) {
|
||||
return `
|
||||
<div class="rpg-widget-container">
|
||||
<div class="rpg-widget-header ${headerClass}">
|
||||
<span class="rpg-widget-icon">${icon}</span>
|
||||
<span class="rpg-widget-title">${title}</span>
|
||||
</div>
|
||||
<div class="rpg-widget-content ${contentClass}">
|
||||
${content}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create editable field with auto-save
|
||||
* @param {Object} options - Field options
|
||||
* @param {string} options.value - Field value
|
||||
* @param {string} options.field - Field name (for data-field attribute)
|
||||
* @param {string} [options.placeholder] - Placeholder text
|
||||
* @param {string} [options.className] - Additional CSS class
|
||||
* @param {Function} [options.onSave] - Callback when field saved
|
||||
* @returns {string} Editable field HTML
|
||||
*/
|
||||
export function createEditableField({ value, field, placeholder = '', className = '', onSave }) {
|
||||
const dataAttr = onSave ? `data-on-save="true"` : '';
|
||||
return `
|
||||
<span class="rpg-editable ${className}"
|
||||
contenteditable="true"
|
||||
data-field="${field}"
|
||||
${dataAttr}
|
||||
title="Click to edit">${value}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach editable field handlers to a container
|
||||
* @param {HTMLElement} container - Container element
|
||||
* @param {Function} onFieldChange - Callback (fieldName, newValue) => void
|
||||
*/
|
||||
export function attachEditableHandlers(container, onFieldChange) {
|
||||
if (!container) return;
|
||||
|
||||
// Find all editable fields
|
||||
const editableFields = container.querySelectorAll('[contenteditable="true"]');
|
||||
|
||||
editableFields.forEach(field => {
|
||||
// Store original value
|
||||
let originalValue = field.textContent.trim();
|
||||
|
||||
// Focus event - select all text
|
||||
field.addEventListener('focus', (e) => {
|
||||
originalValue = e.target.textContent.trim();
|
||||
|
||||
// Select all text
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(e.target);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
});
|
||||
|
||||
// Blur event - save changes
|
||||
field.addEventListener('blur', (e) => {
|
||||
const newValue = e.target.textContent.trim();
|
||||
const fieldName = e.target.dataset.field;
|
||||
|
||||
if (newValue !== originalValue && newValue !== '') {
|
||||
console.log(`[WidgetBase] Field changed: ${fieldName} = ${newValue}`);
|
||||
if (onFieldChange) {
|
||||
onFieldChange(fieldName, newValue);
|
||||
}
|
||||
} else if (newValue === '') {
|
||||
// Restore original if empty
|
||||
e.target.textContent = originalValue;
|
||||
}
|
||||
});
|
||||
|
||||
// Enter key - blur to save
|
||||
field.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.target.blur();
|
||||
}
|
||||
// Escape key - cancel edit
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.target.textContent = originalValue;
|
||||
e.target.blur();
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent paste with formatting
|
||||
field.addEventListener('paste', (e) => {
|
||||
e.preventDefault();
|
||||
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
|
||||
document.execCommand('insertText', false, text);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create progress bar HTML
|
||||
* @param {Object} options - Progress bar options
|
||||
* @param {string} options.label - Label text
|
||||
* @param {number} options.value - Current value (0-100)
|
||||
* @param {string} [options.gradient] - CSS gradient for bar
|
||||
* @param {boolean} [options.editable] - Whether value is editable
|
||||
* @param {string} [options.field] - Field name for editable value
|
||||
* @returns {string} Progress bar HTML
|
||||
*/
|
||||
export function createProgressBar({ label, value, gradient, editable = false, field = '' }) {
|
||||
const barStyle = gradient ? `background: ${gradient}` : '';
|
||||
const valueHtml = editable
|
||||
? `<span class="rpg-stat-value rpg-editable-stat" contenteditable="true" data-field="${field}" title="Click to edit">${value}%</span>`
|
||||
: `<span class="rpg-stat-value">${value}%</span>`;
|
||||
|
||||
return `
|
||||
<div class="rpg-stat-row">
|
||||
<span class="rpg-stat-label">${label}:</span>
|
||||
<div class="rpg-stat-bar" style="${barStyle}">
|
||||
<div class="rpg-stat-fill" style="width: ${100 - value}%"></div>
|
||||
</div>
|
||||
${valueHtml}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update progress bar value
|
||||
* @param {HTMLElement} container - Container element
|
||||
* @param {string} field - Field name
|
||||
* @param {number} newValue - New value (0-100)
|
||||
*/
|
||||
export function updateProgressBar(container, field, newValue) {
|
||||
const valueSpan = container.querySelector(`[data-field="${field}"]`);
|
||||
const fillDiv = valueSpan?.parentElement.querySelector('.rpg-stat-fill');
|
||||
|
||||
if (valueSpan) {
|
||||
valueSpan.textContent = `${newValue}%`;
|
||||
}
|
||||
if (fillDiv) {
|
||||
fillDiv.style.width = `${100 - newValue}%`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create icon button
|
||||
* @param {Object} options - Button options
|
||||
* @param {string} options.icon - FontAwesome icon class or emoji
|
||||
* @param {string} [options.label] - Button label
|
||||
* @param {string} [options.className] - Additional CSS class
|
||||
* @param {string} [options.title] - Tooltip text
|
||||
* @returns {string} Button HTML
|
||||
*/
|
||||
export function createIconButton({ icon, label = '', className = '', title = '' }) {
|
||||
const isFontAwesome = icon.startsWith('fa-');
|
||||
const iconHtml = isFontAwesome
|
||||
? `<i class="${icon}"></i>`
|
||||
: `<span class="rpg-emoji-icon">${icon}</span>`;
|
||||
|
||||
return `
|
||||
<button class="rpg-icon-btn ${className}" title="${title}">
|
||||
${iconHtml}
|
||||
${label ? `<span>${label}</span>` : ''}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create toggle switch
|
||||
* @param {Object} options - Toggle options
|
||||
* @param {string} options.id - Toggle ID
|
||||
* @param {string} options.label - Toggle label
|
||||
* @param {boolean} options.checked - Initial checked state
|
||||
* @param {Function} [options.onChange] - Change callback
|
||||
* @returns {string} Toggle HTML
|
||||
*/
|
||||
export function createToggle({ id, label, checked = false, onChange }) {
|
||||
return `
|
||||
<label class="rpg-toggle-label">
|
||||
<input type="checkbox" id="${id}" ${checked ? 'checked' : ''}>
|
||||
<span class="rpg-toggle-slider"></span>
|
||||
<span class="rpg-toggle-text">${label}</span>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach toggle handler
|
||||
* @param {HTMLElement} container - Container element
|
||||
* @param {string} toggleId - Toggle input ID
|
||||
* @param {Function} onChange - Callback (checked) => void
|
||||
*/
|
||||
export function attachToggleHandler(container, toggleId, onChange) {
|
||||
const toggle = container.querySelector(`#${toggleId}`);
|
||||
if (!toggle) return;
|
||||
|
||||
toggle.addEventListener('change', (e) => {
|
||||
if (onChange) {
|
||||
onChange(e.target.checked);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create select dropdown
|
||||
* @param {Object} options - Select options
|
||||
* @param {string} options.id - Select ID
|
||||
* @param {Array<{value: string, label: string}>} options.options - Options array
|
||||
* @param {string} [options.selected] - Selected value
|
||||
* @param {string} [options.className] - Additional CSS class
|
||||
* @returns {string} Select HTML
|
||||
*/
|
||||
export function createSelect({ id, options, selected = '', className = '' }) {
|
||||
const optionsHtml = options.map(opt =>
|
||||
`<option value="${opt.value}" ${opt.value === selected ? 'selected' : ''}>${opt.label}</option>`
|
||||
).join('');
|
||||
|
||||
return `
|
||||
<select id="${id}" class="rpg-select ${className}">
|
||||
${optionsHtml}
|
||||
</select>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach select handler
|
||||
* @param {HTMLElement} container - Container element
|
||||
* @param {string} selectId - Select element ID
|
||||
* @param {Function} onChange - Callback (value) => void
|
||||
*/
|
||||
export function attachSelectHandler(container, selectId, onChange) {
|
||||
const select = container.querySelector(`#${selectId}`);
|
||||
if (!select) return;
|
||||
|
||||
select.addEventListener('change', (e) => {
|
||||
if (onChange) {
|
||||
onChange(e.target.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create configuration section
|
||||
* @param {Object} options - Config options
|
||||
* @param {string} options.title - Section title
|
||||
* @param {string} options.content - Section content HTML
|
||||
* @param {boolean} [options.collapsible] - Whether section is collapsible
|
||||
* @param {boolean} [options.collapsed] - Initial collapsed state
|
||||
* @returns {string} Config section HTML
|
||||
*/
|
||||
export function createConfigSection({ title, content, collapsible = false, collapsed = false }) {
|
||||
if (!collapsible) {
|
||||
return `
|
||||
<div class="rpg-config-section">
|
||||
<h4 class="rpg-config-title">${title}</h4>
|
||||
<div class="rpg-config-content">
|
||||
${content}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="rpg-config-section ${collapsed ? 'collapsed' : ''}">
|
||||
<h4 class="rpg-config-title rpg-collapsible">
|
||||
${title}
|
||||
<i class="fa-solid fa-chevron-${collapsed ? 'down' : 'up'}"></i>
|
||||
</h4>
|
||||
<div class="rpg-config-content" style="${collapsed ? 'display: none;' : ''}">
|
||||
${content}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach collapsible section handlers
|
||||
* @param {HTMLElement} container - Container element
|
||||
*/
|
||||
export function attachCollapsibleHandlers(container) {
|
||||
const collapsibles = container.querySelectorAll('.rpg-collapsible');
|
||||
|
||||
collapsibles.forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
const section = header.parentElement;
|
||||
const content = section.querySelector('.rpg-config-content');
|
||||
const icon = header.querySelector('i');
|
||||
|
||||
const isCollapsed = section.classList.toggle('collapsed');
|
||||
|
||||
if (isCollapsed) {
|
||||
content.style.display = 'none';
|
||||
icon.className = 'fa-solid fa-chevron-down';
|
||||
} else {
|
||||
content.style.display = 'block';
|
||||
icon.className = 'fa-solid fa-chevron-up';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce function for auto-save
|
||||
* @param {Function} func - Function to debounce
|
||||
* @param {number} wait - Wait time in ms
|
||||
* @returns {Function} Debounced function
|
||||
*/
|
||||
export function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe number parser with fallback
|
||||
* @param {string|number} value - Value to parse
|
||||
* @param {number} fallback - Fallback value
|
||||
* @param {number} [min] - Minimum value
|
||||
* @param {number} [max] - Maximum value
|
||||
* @returns {number} Parsed number
|
||||
*/
|
||||
export function parseNumber(value, fallback, min = -Infinity, max = Infinity) {
|
||||
const num = typeof value === 'string' ? parseInt(value, 10) : value;
|
||||
if (isNaN(num)) return fallback;
|
||||
return Math.max(min, Math.min(max, num));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create loading spinner
|
||||
* @param {string} [text] - Loading text
|
||||
* @returns {string} Loading spinner HTML
|
||||
*/
|
||||
export function createLoadingSpinner(text = 'Loading...') {
|
||||
return `
|
||||
<div class="rpg-loading-spinner">
|
||||
<i class="fa-solid fa-spinner fa-spin"></i>
|
||||
<span>${text}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty state message
|
||||
* @param {Object} options - Empty state options
|
||||
* @param {string} options.icon - Icon (emoji or FA class)
|
||||
* @param {string} options.message - Message text
|
||||
* @param {string} [options.action] - Optional action button HTML
|
||||
* @returns {string} Empty state HTML
|
||||
*/
|
||||
export function createEmptyState({ icon, message, action = '' }) {
|
||||
const isFontAwesome = icon.startsWith('fa-');
|
||||
const iconHtml = isFontAwesome
|
||||
? `<i class="${icon}"></i>`
|
||||
: `<span class="rpg-emoji-icon">${icon}</span>`;
|
||||
|
||||
return `
|
||||
<div class="rpg-empty-state">
|
||||
<div class="rpg-empty-icon">${iconHtml}</div>
|
||||
<p class="rpg-empty-message">${message}</p>
|
||||
${action}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
* @param {string} unsafe - Unsafe string
|
||||
* @returns {string} Escaped string
|
||||
*/
|
||||
export function escapeHtml(unsafe) {
|
||||
return unsafe
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format number with commas
|
||||
* @param {number} num - Number to format
|
||||
* @returns {string} Formatted number
|
||||
*/
|
||||
export function formatNumber(num) {
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text with ellipsis
|
||||
* @param {string} text - Text to truncate
|
||||
* @param {number} maxLength - Maximum length
|
||||
* @returns {string} Truncated text
|
||||
*/
|
||||
export function truncateText(text, maxLength) {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.slice(0, maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create responsive grid for items
|
||||
* @param {Array<string>} items - Array of item HTML
|
||||
* @param {number} [columns] - Number of columns (auto if not specified)
|
||||
* @param {string} [gap] - Gap size (CSS value)
|
||||
* @returns {string} Grid HTML
|
||||
*/
|
||||
export function createGrid(items, columns = null, gap = '12px') {
|
||||
const gridStyle = columns
|
||||
? `grid-template-columns: repeat(${columns}, 1fr); gap: ${gap};`
|
||||
: `grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: ${gap};`;
|
||||
|
||||
return `
|
||||
<div class="rpg-grid" style="display: grid; ${gridStyle}">
|
||||
${items.join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create card component
|
||||
* @param {Object} options - Card options
|
||||
* @param {string} options.title - Card title
|
||||
* @param {string} options.content - Card content
|
||||
* @param {string} [options.icon] - Optional icon
|
||||
* @param {string} [options.footer] - Optional footer HTML
|
||||
* @param {string} [options.className] - Additional CSS class
|
||||
* @returns {string} Card HTML
|
||||
*/
|
||||
export function createCard({ title, content, icon = '', footer = '', className = '' }) {
|
||||
const iconHtml = icon ? `<span class="rpg-card-icon">${icon}</span>` : '';
|
||||
const footerHtml = footer ? `<div class="rpg-card-footer">${footer}</div>` : '';
|
||||
|
||||
return `
|
||||
<div class="rpg-card ${className}">
|
||||
<div class="rpg-card-header">
|
||||
${iconHtml}
|
||||
<h5 class="rpg-card-title">${title}</h5>
|
||||
</div>
|
||||
<div class="rpg-card-body">
|
||||
${content}
|
||||
</div>
|
||||
${footerHtml}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
/**
|
||||
* Widget Definition Type
|
||||
* @typedef {Object} WidgetDefinition
|
||||
* @property {string} name - Display name of the widget
|
||||
* @property {string} icon - Emoji or icon for the widget
|
||||
* @property {string} description - Brief description of widget functionality
|
||||
* @property {{w: number, h: number}} minSize - Minimum grid size (width × height)
|
||||
* @property {{w: number, h: number}} defaultSize - Default grid size when added
|
||||
* @property {boolean} requiresSchema - Whether widget requires active schema to function
|
||||
* @property {Function} render - Render function: (container, config) => void
|
||||
* @property {Function} [getConfig] - Optional: Returns configurable options
|
||||
* @property {Function} [onConfigChange] - Optional: Called when config changes
|
||||
* @property {Function} [onRemove] - Optional: Cleanup when widget removed
|
||||
* @property {Function} [onResize] - Optional: Called when widget resized
|
||||
*/
|
||||
|
||||
/**
|
||||
* Widget Configuration Type
|
||||
* @typedef {Object} WidgetConfig
|
||||
* @property {string} type - Type of config (text, number, boolean, select, color)
|
||||
* @property {string} label - Display label for the config option
|
||||
* @property {*} default - Default value
|
||||
* @property {Array<*>} [options] - Options for select type
|
||||
* @property {number} [min] - Min value for number type
|
||||
* @property {number} [max] - Max value for number type
|
||||
*/
|
||||
|
||||
/**
|
||||
* WidgetRegistry - Central registry for all widget types
|
||||
*
|
||||
* Manages widget definitions and provides methods to register, retrieve,
|
||||
* and filter available widgets based on schema requirements.
|
||||
*
|
||||
* @class WidgetRegistry
|
||||
*/
|
||||
export class WidgetRegistry {
|
||||
/**
|
||||
* Initialize widget registry
|
||||
*/
|
||||
constructor() {
|
||||
/** @type {Map<string, WidgetDefinition>} */
|
||||
this.widgets = new Map();
|
||||
|
||||
console.log('[WidgetRegistry] Initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new widget type
|
||||
*
|
||||
* @param {string} type - Unique identifier for the widget type
|
||||
* @param {WidgetDefinition} definition - Widget definition object
|
||||
* @throws {Error} If widget type already registered
|
||||
*
|
||||
* @example
|
||||
* registry.register('userStats', {
|
||||
* name: 'User Stats',
|
||||
* icon: '❤️',
|
||||
* description: 'Health, energy, satiety bars',
|
||||
* minSize: { w: 2, h: 2 },
|
||||
* defaultSize: { w: 4, h: 3 },
|
||||
* requiresSchema: false,
|
||||
* render: (container, config) => {
|
||||
* container.innerHTML = '<div>User stats here</div>';
|
||||
* }
|
||||
* });
|
||||
*/
|
||||
register(type, definition) {
|
||||
// Validate type
|
||||
if (!type || typeof type !== 'string') {
|
||||
throw new Error('[WidgetRegistry] Widget type must be a non-empty string');
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
if (this.widgets.has(type)) {
|
||||
console.warn(`[WidgetRegistry] Widget type "${type}" already registered, overwriting`);
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
const required = ['name', 'icon', 'description', 'minSize', 'defaultSize', 'requiresSchema', 'render'];
|
||||
for (const field of required) {
|
||||
if (!(field in definition)) {
|
||||
throw new Error(`[WidgetRegistry] Widget definition missing required field: ${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate minSize and defaultSize
|
||||
if (!definition.minSize.w || !definition.minSize.h) {
|
||||
throw new Error('[WidgetRegistry] Widget minSize must have w and h properties');
|
||||
}
|
||||
// defaultSize can be a function (column-aware) or static object
|
||||
if (typeof definition.defaultSize === 'function') {
|
||||
// If function, we can't validate until runtime, skip validation
|
||||
} else if (!definition.defaultSize.w || !definition.defaultSize.h) {
|
||||
throw new Error('[WidgetRegistry] Widget defaultSize must have w and h properties');
|
||||
}
|
||||
|
||||
// Validate render function
|
||||
if (typeof definition.render !== 'function') {
|
||||
throw new Error('[WidgetRegistry] Widget render must be a function');
|
||||
}
|
||||
|
||||
// Store widget definition
|
||||
this.widgets.set(type, {
|
||||
...definition,
|
||||
// Bind render function to maintain 'this' context
|
||||
render: definition.render.bind(definition),
|
||||
// Bind optional lifecycle functions
|
||||
getConfig: definition.getConfig?.bind(definition),
|
||||
onConfigChange: definition.onConfigChange?.bind(definition),
|
||||
onRemove: definition.onRemove?.bind(definition),
|
||||
onResize: definition.onResize?.bind(definition)
|
||||
});
|
||||
|
||||
console.log(`[WidgetRegistry] Registered widget: ${type} (${definition.name})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get widget definition by type
|
||||
*
|
||||
* @param {string} type - Widget type identifier
|
||||
* @returns {WidgetDefinition|undefined} Widget definition or undefined if not found
|
||||
*
|
||||
* @example
|
||||
* const userStatsWidget = registry.get('userStats');
|
||||
* if (userStatsWidget) {
|
||||
* userStatsWidget.render(container, config);
|
||||
* }
|
||||
*/
|
||||
get(type) {
|
||||
const widget = this.widgets.get(type);
|
||||
if (!widget) {
|
||||
console.warn(`[WidgetRegistry] Widget type "${type}" not found`);
|
||||
}
|
||||
return widget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available widgets, optionally filtered by schema requirement
|
||||
*
|
||||
* @param {boolean} [hasSchema=false] - Whether an active schema is present
|
||||
* @returns {Array<{type: string, definition: WidgetDefinition}>} Array of available widgets
|
||||
*
|
||||
* @example
|
||||
* // Get widgets that work without schema
|
||||
* const coreWidgets = registry.getAvailable(false);
|
||||
*
|
||||
* // Get all widgets (schema active)
|
||||
* const allWidgets = registry.getAvailable(true);
|
||||
*/
|
||||
getAvailable(hasSchema = false) {
|
||||
const available = [];
|
||||
|
||||
for (const [type, definition] of this.widgets.entries()) {
|
||||
// If widget requires schema and we don't have one, skip it
|
||||
if (definition.requiresSchema && !hasSchema) {
|
||||
continue;
|
||||
}
|
||||
|
||||
available.push({
|
||||
type,
|
||||
definition
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[WidgetRegistry] Found ${available.length} available widgets (hasSchema: ${hasSchema})`);
|
||||
return available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered widget types (regardless of schema requirement)
|
||||
*
|
||||
* @returns {Array<{type: string, definition: WidgetDefinition}>} All registered widgets
|
||||
*/
|
||||
getAll() {
|
||||
const all = [];
|
||||
for (const [type, definition] of this.widgets.entries()) {
|
||||
all.push({ type, definition });
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if widget type is registered
|
||||
*
|
||||
* @param {string} type - Widget type identifier
|
||||
* @returns {boolean} True if widget type is registered
|
||||
*/
|
||||
has(type) {
|
||||
return this.widgets.has(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a widget type
|
||||
*
|
||||
* @param {string} type - Widget type identifier
|
||||
* @returns {boolean} True if widget was removed, false if not found
|
||||
*
|
||||
* @example
|
||||
* registry.unregister('oldWidget');
|
||||
*/
|
||||
unregister(type) {
|
||||
const existed = this.widgets.delete(type);
|
||||
if (existed) {
|
||||
console.log(`[WidgetRegistry] Unregistered widget: ${type}`);
|
||||
} else {
|
||||
console.warn(`[WidgetRegistry] Cannot unregister "${type}" - not found`);
|
||||
}
|
||||
return existed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of registered widgets
|
||||
*
|
||||
* @returns {number} Number of registered widgets
|
||||
*/
|
||||
count() {
|
||||
return this.widgets.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all registered widgets
|
||||
*
|
||||
* @returns {number} Number of widgets cleared
|
||||
*/
|
||||
clear() {
|
||||
const count = this.widgets.size;
|
||||
this.widgets.clear();
|
||||
console.log(`[WidgetRegistry] Cleared ${count} widgets`);
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about registered widgets
|
||||
*
|
||||
* @returns {Object} Registry statistics
|
||||
*/
|
||||
getStats() {
|
||||
const all = this.getAll();
|
||||
const schemaRequired = all.filter(w => w.definition.requiresSchema).length;
|
||||
const noSchema = all.length - schemaRequired;
|
||||
|
||||
return {
|
||||
total: all.length,
|
||||
requiresSchema: schemaRequired,
|
||||
noSchema: noSchema,
|
||||
types: all.map(w => w.type)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global widget registry instance
|
||||
* @type {WidgetRegistry}
|
||||
*/
|
||||
export const widgetRegistry = new WidgetRegistry();
|
||||
@@ -1,399 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WidgetRegistry Test</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 20px;
|
||||
color: #e94560;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
background: #16213e;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.test-section h2 {
|
||||
color: #4ecca3;
|
||||
margin-bottom: 10px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.test-result {
|
||||
margin: 5px 0;
|
||||
padding: 8px;
|
||||
border-left: 3px solid #4ecca3;
|
||||
background: #0f3460;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.test-result.pass {
|
||||
border-color: #4ecca3;
|
||||
}
|
||||
|
||||
.test-result.fail {
|
||||
border-color: #e94560;
|
||||
background: #2a0f1b;
|
||||
}
|
||||
|
||||
.widget-preview {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background: #0f3460;
|
||||
border: 1px solid #e94560;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
background: #0f3460;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #e94560;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
margin: 5px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #d63651;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.badge.schema {
|
||||
background: #e94560;
|
||||
}
|
||||
|
||||
.badge.core {
|
||||
background: #4ecca3;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🧪 WidgetRegistry Test Suite</h1>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Test 1: Register Core Widgets</h2>
|
||||
<div id="test1-results"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Test 2: Register Schema Widgets</h2>
|
||||
<div id="test2-results"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Test 3: Get Widget by Type</h2>
|
||||
<div id="test3-results"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Test 4: Filter by Schema Availability</h2>
|
||||
<div id="test4-results"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Test 5: Unregister Widget</h2>
|
||||
<div id="test5-results"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Test 6: Widget Rendering</h2>
|
||||
<div id="test6-results"></div>
|
||||
<div id="widget-preview" class="widget-preview"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Registry Statistics</h2>
|
||||
<div id="stats" class="stats"></div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button onclick="runAllTests()">🔄 Re-run All Tests</button>
|
||||
<button onclick="clearRegistry()">🗑️ Clear Registry</button>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { WidgetRegistry } from './widgetRegistry.js';
|
||||
|
||||
let registry = new WidgetRegistry();
|
||||
|
||||
function pass(message) {
|
||||
return `<div class="test-result pass">✓ ${message}</div>`;
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
return `<div class="test-result fail">✗ ${message}</div>`;
|
||||
}
|
||||
|
||||
// Test 1: Register core widgets
|
||||
function test1() {
|
||||
const container = document.getElementById('test1-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
try {
|
||||
registry.register('userStats', {
|
||||
name: 'User Stats',
|
||||
icon: '❤️',
|
||||
description: 'Health, energy, satiety, hygiene, arousal bars',
|
||||
minSize: { w: 2, h: 2 },
|
||||
defaultSize: { w: 4, h: 3 },
|
||||
requiresSchema: false,
|
||||
render: (container, config) => {
|
||||
container.innerHTML = '<div>User Stats Widget</div>';
|
||||
}
|
||||
});
|
||||
container.innerHTML += pass('Registered userStats widget');
|
||||
|
||||
registry.register('infoBox', {
|
||||
name: 'Info Box',
|
||||
icon: '📅',
|
||||
description: 'Date, weather, temperature, time, location',
|
||||
minSize: { w: 3, h: 2 },
|
||||
defaultSize: { w: 6, h: 2 },
|
||||
requiresSchema: false,
|
||||
render: (container) => {
|
||||
container.innerHTML = '<div>Info Box Widget</div>';
|
||||
}
|
||||
});
|
||||
container.innerHTML += pass('Registered infoBox widget');
|
||||
|
||||
registry.register('inventory', {
|
||||
name: 'Inventory',
|
||||
icon: '🎒',
|
||||
description: 'On Person, Stored, Assets',
|
||||
minSize: { w: 3, h: 3 },
|
||||
defaultSize: { w: 6, h: 4 },
|
||||
requiresSchema: false,
|
||||
render: (container) => {
|
||||
container.innerHTML = '<div>Inventory Widget</div>';
|
||||
}
|
||||
});
|
||||
container.innerHTML += pass('Registered inventory widget');
|
||||
|
||||
} catch (error) {
|
||||
container.innerHTML += fail(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 2: Register schema widgets
|
||||
function test2() {
|
||||
const container = document.getElementById('test2-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
try {
|
||||
registry.register('skills', {
|
||||
name: 'Skills',
|
||||
icon: '⚔️',
|
||||
description: 'Schema-defined skills with progression',
|
||||
minSize: { w: 2, h: 3 },
|
||||
defaultSize: { w: 4, h: 4 },
|
||||
requiresSchema: true,
|
||||
render: (container) => {
|
||||
container.innerHTML = '<div>Skills Widget (requires schema)</div>';
|
||||
}
|
||||
});
|
||||
container.innerHTML += pass('Registered skills widget (requiresSchema: true)');
|
||||
|
||||
registry.register('relationships', {
|
||||
name: 'Relationships',
|
||||
icon: '💕',
|
||||
description: 'Character relationship tracker',
|
||||
minSize: { w: 3, h: 2 },
|
||||
defaultSize: { w: 6, h: 3 },
|
||||
requiresSchema: true,
|
||||
render: (container) => {
|
||||
container.innerHTML = '<div>Relationships Widget (requires schema)</div>';
|
||||
}
|
||||
});
|
||||
container.innerHTML += pass('Registered relationships widget (requiresSchema: true)');
|
||||
|
||||
} catch (error) {
|
||||
container.innerHTML += fail(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 3: Get widget by type
|
||||
function test3() {
|
||||
const container = document.getElementById('test3-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
const userStats = registry.get('userStats');
|
||||
if (userStats && userStats.name === 'User Stats') {
|
||||
container.innerHTML += pass(`Retrieved userStats: ${userStats.name}`);
|
||||
} else {
|
||||
container.innerHTML += fail('Failed to retrieve userStats');
|
||||
}
|
||||
|
||||
const nonExistent = registry.get('nonExistent');
|
||||
if (!nonExistent) {
|
||||
container.innerHTML += pass('Correctly returned undefined for non-existent widget');
|
||||
} else {
|
||||
container.innerHTML += fail('Should return undefined for non-existent widget');
|
||||
}
|
||||
}
|
||||
|
||||
// Test 4: Filter by schema availability
|
||||
function test4() {
|
||||
const container = document.getElementById('test4-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
// Get widgets without schema
|
||||
const noSchema = registry.getAvailable(false);
|
||||
container.innerHTML += pass(`Without schema: ${noSchema.length} widgets available`);
|
||||
noSchema.forEach(w => {
|
||||
container.innerHTML += `<div class="test-result">${w.definition.icon} ${w.definition.name} <span class="badge core">CORE</span></div>`;
|
||||
});
|
||||
|
||||
// Get widgets with schema
|
||||
const withSchema = registry.getAvailable(true);
|
||||
container.innerHTML += pass(`With schema: ${withSchema.length} widgets available`);
|
||||
withSchema.forEach(w => {
|
||||
const badge = w.definition.requiresSchema ?
|
||||
'<span class="badge schema">SCHEMA</span>' :
|
||||
'<span class="badge core">CORE</span>';
|
||||
container.innerHTML += `<div class="test-result">${w.definition.icon} ${w.definition.name} ${badge}</div>`;
|
||||
});
|
||||
|
||||
// Verify counts
|
||||
const expectedNoSchema = 3; // userStats, infoBox, inventory
|
||||
const expectedWithSchema = 5; // all widgets
|
||||
if (noSchema.length === expectedNoSchema) {
|
||||
container.innerHTML += pass(`Correct count without schema: ${expectedNoSchema}`);
|
||||
} else {
|
||||
container.innerHTML += fail(`Wrong count without schema: ${noSchema.length} (expected ${expectedNoSchema})`);
|
||||
}
|
||||
|
||||
if (withSchema.length === expectedWithSchema) {
|
||||
container.innerHTML += pass(`Correct count with schema: ${expectedWithSchema}`);
|
||||
} else {
|
||||
container.innerHTML += fail(`Wrong count with schema: ${withSchema.length} (expected ${expectedWithSchema})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 5: Unregister widget
|
||||
function test5() {
|
||||
const container = document.getElementById('test5-results');
|
||||
container.innerHTML = '';
|
||||
|
||||
const countBefore = registry.count();
|
||||
container.innerHTML += `<div class="test-result">Registry has ${countBefore} widgets before unregister</div>`;
|
||||
|
||||
const removed = registry.unregister('inventory');
|
||||
if (removed) {
|
||||
container.innerHTML += pass('Successfully unregistered inventory widget');
|
||||
} else {
|
||||
container.innerHTML += fail('Failed to unregister inventory widget');
|
||||
}
|
||||
|
||||
const countAfter = registry.count();
|
||||
if (countAfter === countBefore - 1) {
|
||||
container.innerHTML += pass(`Registry now has ${countAfter} widgets`);
|
||||
} else {
|
||||
container.innerHTML += fail(`Wrong count after unregister: ${countAfter}`);
|
||||
}
|
||||
|
||||
const gone = registry.get('inventory');
|
||||
if (!gone) {
|
||||
container.innerHTML += pass('Inventory widget no longer retrievable');
|
||||
} else {
|
||||
container.innerHTML += fail('Inventory widget still exists!');
|
||||
}
|
||||
}
|
||||
|
||||
// Test 6: Widget rendering
|
||||
function test6() {
|
||||
const container = document.getElementById('test6-results');
|
||||
const preview = document.getElementById('widget-preview');
|
||||
container.innerHTML = '';
|
||||
preview.innerHTML = '';
|
||||
|
||||
const userStats = registry.get('userStats');
|
||||
if (userStats) {
|
||||
try {
|
||||
userStats.render(preview, {});
|
||||
container.innerHTML += pass('Successfully rendered userStats widget');
|
||||
} catch (error) {
|
||||
container.innerHTML += fail(`Render error: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
container.innerHTML += fail('userStats widget not found');
|
||||
}
|
||||
}
|
||||
|
||||
// Update stats
|
||||
function updateStats() {
|
||||
const statsContainer = document.getElementById('stats');
|
||||
const stats = registry.getStats();
|
||||
|
||||
statsContainer.innerHTML = `
|
||||
<div><strong>Total Widgets:</strong> ${stats.total}</div>
|
||||
<div><strong>Requires Schema:</strong> ${stats.requiresSchema}</div>
|
||||
<div><strong>No Schema Required:</strong> ${stats.noSchema}</div>
|
||||
<div><strong>Registered Types:</strong> ${stats.types.join(', ')}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Run all tests
|
||||
window.runAllTests = function() {
|
||||
// Re-create registry for fresh tests
|
||||
registry = new WidgetRegistry();
|
||||
|
||||
test1();
|
||||
test2();
|
||||
test3();
|
||||
test4();
|
||||
test5();
|
||||
test6();
|
||||
updateStats();
|
||||
};
|
||||
|
||||
// Clear registry
|
||||
window.clearRegistry = function() {
|
||||
const count = registry.clear();
|
||||
alert(`Cleared ${count} widgets from registry`);
|
||||
updateStats();
|
||||
};
|
||||
|
||||
// Run tests on load
|
||||
runAllTests();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,757 +0,0 @@
|
||||
/**
|
||||
* Info Box Widgets (Modular)
|
||||
*
|
||||
* Creates 5 separate, independently draggable widgets:
|
||||
* - Calendar Widget (date, weekday, month, year)
|
||||
* - Weather Widget (emoji + forecast)
|
||||
* - Temperature Widget (thermometer visualization)
|
||||
* - Clock Widget (analog clock + time display)
|
||||
* - Location Widget (map marker + location text)
|
||||
*
|
||||
* Each widget parses shared infoBox data and handles its own edits.
|
||||
* Users can arrange them independently or group them together.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse Info Box data from shared data source
|
||||
* @param {string} infoBoxText - Raw info box text
|
||||
* @returns {Object} Parsed data
|
||||
*/
|
||||
export function parseInfoBoxData(infoBoxText) {
|
||||
if (!infoBoxText) {
|
||||
return {
|
||||
date: '', weekday: '', month: '', year: '',
|
||||
weatherEmoji: '', weatherForecast: '',
|
||||
temperature: '', tempValue: 0,
|
||||
timeStart: '', timeEnd: '',
|
||||
location: '',
|
||||
recentEvents: []
|
||||
};
|
||||
}
|
||||
|
||||
const lines = infoBoxText.split('\n');
|
||||
const data = {
|
||||
date: '', weekday: '', month: '', year: '',
|
||||
weatherEmoji: '', weatherForecast: '',
|
||||
temperature: '', tempValue: 0,
|
||||
timeStart: '', timeEnd: '',
|
||||
location: '',
|
||||
recentEvents: []
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
// Date parsing (text or emoji format)
|
||||
if (line.startsWith('Date:') || line.includes('🗓️:')) {
|
||||
const dateStr = line.replace(/^(Date:|🗓️:)/, '').trim();
|
||||
|
||||
// Try structured comma-separated format (e.g., "Tuesday, 15 January, 2024")
|
||||
if (dateStr.includes(',') && dateStr.split(',').length >= 2) {
|
||||
const dateParts = dateStr.split(',').map(p => p.trim());
|
||||
data.weekday = dateParts[0] || '';
|
||||
data.month = dateParts[1] || '';
|
||||
data.year = dateParts[2] || '';
|
||||
data.date = dateStr;
|
||||
} else {
|
||||
// Unstructured format - store full text for display
|
||||
// Handles: ISO dates, fantasy calendars, prose, stardates
|
||||
data.weekday = '';
|
||||
data.month = dateStr; // Store in month field (primary display)
|
||||
data.year = '';
|
||||
data.date = dateStr;
|
||||
}
|
||||
}
|
||||
// Temperature parsing
|
||||
else if (line.startsWith('Temperature:') || line.includes('🌡️:')) {
|
||||
const tempStr = line.replace(/^(Temperature:|🌡️:)/, '').trim();
|
||||
data.temperature = tempStr;
|
||||
const tempMatch = tempStr.match(/(-?\d+)/);
|
||||
if (tempMatch) {
|
||||
data.tempValue = parseInt(tempMatch[1]);
|
||||
}
|
||||
}
|
||||
// Time parsing
|
||||
else if (line.startsWith('Time:') || line.includes('🕒:')) {
|
||||
const timeStr = line.replace(/^(Time:|🕒:)/, '').trim();
|
||||
data.time = timeStr;
|
||||
const timeParts = timeStr.split('→').map(t => t.trim());
|
||||
data.timeStart = timeParts[0] || '';
|
||||
data.timeEnd = timeParts[1] || '';
|
||||
}
|
||||
// Location parsing
|
||||
else if (line.startsWith('Location:') || line.includes('🗺️:')) {
|
||||
data.location = line.replace(/^(Location:|🗺️:)/, '').trim();
|
||||
}
|
||||
// Weather parsing (text format)
|
||||
else if (line.startsWith('Weather:')) {
|
||||
const weatherStr = line.replace('Weather:', '').trim();
|
||||
|
||||
// Try comma-separated format
|
||||
if (weatherStr.includes(',')) {
|
||||
const parts = weatherStr.split(',');
|
||||
data.weatherEmoji = parts[0].trim();
|
||||
// JOIN remaining parts to preserve multi-part forecasts
|
||||
// e.g., "🌧️, Heavy rain, flooding expected" → emoji="🌧️", forecast="Heavy rain, flooding expected"
|
||||
data.weatherForecast = parts.slice(1).join(', ').trim();
|
||||
} else {
|
||||
// No comma - try to detect emoji prefix
|
||||
const emojiMatch = weatherStr.match(/^([\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]+)\s+(.+)$/u);
|
||||
if (emojiMatch) {
|
||||
data.weatherEmoji = emojiMatch[1];
|
||||
data.weatherForecast = emojiMatch[2];
|
||||
} else {
|
||||
// Pure text description - no emoji
|
||||
// Handles: prose weather like "The air crackles with magical energy"
|
||||
data.weatherEmoji = '';
|
||||
data.weatherForecast = weatherStr;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Weather parsing (legacy emoji format)
|
||||
else if (!data.weatherEmoji && line.includes(':') && !line.includes('Info Box') && !line.includes('---')) {
|
||||
const weatherMatch = line.match(/^\s*([^:]+):\s*(.+)$/);
|
||||
if (weatherMatch) {
|
||||
const potentialEmoji = weatherMatch[1].trim();
|
||||
const forecast = weatherMatch[2].trim();
|
||||
if (potentialEmoji.length <= 5) {
|
||||
data.weatherEmoji = potentialEmoji;
|
||||
data.weatherForecast = forecast;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Recent Events parsing
|
||||
else if (line.startsWith('Recent Events:')) {
|
||||
const eventsString = line.replace('Recent Events:', '').trim();
|
||||
if (eventsString) {
|
||||
data.recentEvents = eventsString.split(',').map(e => e.trim()).filter(e => e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Info Box field in shared data
|
||||
* @param {Object} dependencies - External dependencies
|
||||
* @param {string} field - Field name
|
||||
* @param {string} value - New value
|
||||
*/
|
||||
function updateInfoBoxField(dependencies, field, value) {
|
||||
const { getInfoBoxData, setInfoBoxData, onDataChange } = dependencies;
|
||||
let infoBoxText = getInfoBoxData() || 'Info Box\n---\n';
|
||||
|
||||
const lines = infoBoxText.split('\n');
|
||||
const updatedLines = [...lines];
|
||||
|
||||
// Field-specific update logic
|
||||
if (field === 'weekday' || field === 'month' || field === 'year') {
|
||||
const dateLineIndex = lines.findIndex(l => l.startsWith('Date:') || l.includes('🗓️:'));
|
||||
if (dateLineIndex >= 0) {
|
||||
const parts = lines[dateLineIndex].split(',').map(p => p.trim());
|
||||
const prefix = lines[dateLineIndex].startsWith('Date:') ? 'Date:' : '🗓️:';
|
||||
const weekday = field === 'weekday' ? value : (parts[0] ? parts[0].replace(/^(Date:|🗓️:)/, '').trim() : 'Weekday');
|
||||
const month = field === 'month' ? value : (parts[1] || 'Month');
|
||||
const year = field === 'year' ? value : (parts[2] || 'YEAR');
|
||||
updatedLines[dateLineIndex] = `${prefix} ${weekday}, ${month}, ${year}`;
|
||||
} else {
|
||||
// Create new date line
|
||||
const dividerIndex = lines.findIndex(l => l.includes('---'));
|
||||
const weekday = field === 'weekday' ? value : 'Weekday';
|
||||
const month = field === 'month' ? value : 'Month';
|
||||
const year = field === 'year' ? value : 'YEAR';
|
||||
updatedLines.splice(dividerIndex + 1, 0, `Date: ${weekday}, ${month}, ${year}`);
|
||||
}
|
||||
}
|
||||
else if (field === 'weatherEmoji' || field === 'weatherForecast') {
|
||||
const weatherLineIndex = lines.findIndex(l => l.startsWith('Weather:') || (l.includes(':') && !l.includes('Date:') && !l.includes('Temperature:') && !l.includes('Time:') && !l.includes('Location:') && !l.includes('Info Box') && !l.includes('---')));
|
||||
if (weatherLineIndex >= 0) {
|
||||
const line = lines[weatherLineIndex];
|
||||
if (line.startsWith('Weather:')) {
|
||||
const parts = line.replace('Weather:', '').trim().split(',').map(p => p.trim());
|
||||
const emoji = field === 'weatherEmoji' ? value : (parts[0] || '🌤️');
|
||||
const forecast = field === 'weatherForecast' ? value : (parts[1] || 'Weather');
|
||||
updatedLines[weatherLineIndex] = `Weather: ${emoji}, ${forecast}`;
|
||||
} else {
|
||||
const parts = line.split(':');
|
||||
const emoji = field === 'weatherEmoji' ? value : parts[0].trim();
|
||||
const forecast = field === 'weatherForecast' ? value : parts[1].trim();
|
||||
updatedLines[weatherLineIndex] = `${emoji}: ${forecast}`;
|
||||
}
|
||||
} else {
|
||||
const dividerIndex = lines.findIndex(l => l.includes('---'));
|
||||
const emoji = field === 'weatherEmoji' ? value : '🌤️';
|
||||
const forecast = field === 'weatherForecast' ? value : 'Weather';
|
||||
updatedLines.splice(dividerIndex + 1, 0, `Weather: ${emoji}, ${forecast}`);
|
||||
}
|
||||
}
|
||||
else if (field === 'temperature') {
|
||||
const tempLineIndex = lines.findIndex(l => l.startsWith('Temperature:') || l.includes('🌡️:'));
|
||||
if (tempLineIndex >= 0) {
|
||||
const prefix = lines[tempLineIndex].startsWith('Temperature:') ? 'Temperature:' : '🌡️:';
|
||||
updatedLines[tempLineIndex] = `${prefix} ${value}`;
|
||||
} else {
|
||||
const dividerIndex = lines.findIndex(l => l.includes('---'));
|
||||
updatedLines.splice(dividerIndex + 1, 0, `Temperature: ${value}`);
|
||||
}
|
||||
}
|
||||
else if (field === 'timeStart') {
|
||||
const timeLineIndex = lines.findIndex(l => l.startsWith('Time:') || l.includes('🕒:'));
|
||||
if (timeLineIndex >= 0) {
|
||||
const prefix = lines[timeLineIndex].startsWith('Time:') ? 'Time:' : '🕒:';
|
||||
updatedLines[timeLineIndex] = `${prefix} ${value} → ${value}`;
|
||||
} else {
|
||||
const dividerIndex = lines.findIndex(l => l.includes('---'));
|
||||
updatedLines.splice(dividerIndex + 1, 0, `Time: ${value} → ${value}`);
|
||||
}
|
||||
}
|
||||
else if (field === 'location') {
|
||||
const locationLineIndex = lines.findIndex(l => l.startsWith('Location:') || l.includes('🗺️:'));
|
||||
if (locationLineIndex >= 0) {
|
||||
const prefix = lines[locationLineIndex].startsWith('Location:') ? 'Location:' : '🗺️:';
|
||||
updatedLines[locationLineIndex] = `${prefix} ${value}`;
|
||||
} else {
|
||||
updatedLines.push(`Location: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
const newInfoBoxText = updatedLines.join('\n');
|
||||
setInfoBoxData(newInfoBoxText);
|
||||
if (onDataChange) {
|
||||
onDataChange('infoBox', field, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register Calendar Widget
|
||||
*/
|
||||
export function registerCalendarWidget(registry, dependencies) {
|
||||
registry.register('calendar', {
|
||||
name: 'Calendar',
|
||||
icon: '📅',
|
||||
description: 'Date, weekday, month, and year display',
|
||||
category: 'scene',
|
||||
minSize: { w: 1, h: 1 },
|
||||
defaultSize: { w: 1, h: 1 },
|
||||
maxAutoSize: { w: 1, h: 2 }, // Max size for auto-arrange expansion
|
||||
requiresSchema: false,
|
||||
|
||||
render(container, config = {}) {
|
||||
const { getInfoBoxData } = dependencies;
|
||||
const data = parseInfoBoxData(getInfoBoxData());
|
||||
|
||||
const monthShort = data.month ? data.month.substring(0, 3).toUpperCase() : 'MON';
|
||||
const weekdayShort = data.weekday ? data.weekday.substring(0, 3).toUpperCase() : 'DAY';
|
||||
const yearDisplay = data.year || 'YEAR';
|
||||
|
||||
const html = `
|
||||
<div class="rpg-dashboard-widget rpg-calendar-widget">
|
||||
<div class="rpg-calendar-top rpg-editable" contenteditable="true" data-field="month" data-full-value="${data.month || ''}" title="Click to edit">${monthShort}</div>
|
||||
<div class="rpg-calendar-day rpg-editable" contenteditable="true" data-field="weekday" data-full-value="${data.weekday || ''}" title="Click to edit">${weekdayShort}</div>
|
||||
<div class="rpg-calendar-year rpg-editable" contenteditable="true" data-field="year" data-full-value="${data.year || ''}" title="Click to edit">${yearDisplay}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
attachCalendarHandlers(container, dependencies);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function attachCalendarHandlers(container, dependencies) {
|
||||
const editableFields = container.querySelectorAll('.rpg-editable');
|
||||
|
||||
editableFields.forEach(field => {
|
||||
const fieldName = field.dataset.field;
|
||||
let originalValue = field.dataset.fullValue || field.textContent.trim();
|
||||
|
||||
// Show full value on focus
|
||||
field.addEventListener('focus', () => {
|
||||
const fullValue = field.dataset.fullValue;
|
||||
if (fullValue) {
|
||||
field.textContent = fullValue;
|
||||
}
|
||||
originalValue = field.textContent.trim();
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(field);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
});
|
||||
|
||||
// Save on blur
|
||||
field.addEventListener('blur', () => {
|
||||
const value = field.textContent.trim();
|
||||
if (value && value !== originalValue) {
|
||||
field.dataset.fullValue = value;
|
||||
updateInfoBoxField(dependencies, fieldName, value);
|
||||
}
|
||||
|
||||
// Update display to abbreviated version
|
||||
if (fieldName === 'month' || fieldName === 'weekday') {
|
||||
field.textContent = value.substring(0, 3).toUpperCase();
|
||||
} else {
|
||||
field.textContent = value;
|
||||
}
|
||||
});
|
||||
|
||||
field.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
field.blur();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
field.textContent = originalValue;
|
||||
field.blur();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register Weather Widget
|
||||
*/
|
||||
export function registerWeatherWidget(registry, dependencies) {
|
||||
registry.register('weather', {
|
||||
category: 'scene',
|
||||
name: 'Weather',
|
||||
icon: '🌤️',
|
||||
description: 'Weather emoji and forecast',
|
||||
minSize: { w: 1, h: 1 },
|
||||
defaultSize: { w: 1, h: 1 },
|
||||
maxAutoSize: { w: 1, h: 2 }, // Max size for auto-arrange expansion
|
||||
requiresSchema: false,
|
||||
|
||||
render(container, config = {}) {
|
||||
const { getInfoBoxData } = dependencies;
|
||||
const data = parseInfoBoxData(getInfoBoxData());
|
||||
|
||||
const weatherEmoji = data.weatherEmoji || '🌤️';
|
||||
|
||||
const html = `
|
||||
<div class="rpg-dashboard-widget rpg-weather-widget">
|
||||
<div class="rpg-weather-icon rpg-editable" contenteditable="true" data-field="weatherEmoji" title="Click to edit">${weatherEmoji}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
attachSimpleEditHandlers(container, dependencies);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register Temperature Widget
|
||||
*/
|
||||
export function registerTemperatureWidget(registry, dependencies) {
|
||||
registry.register('temperature', {
|
||||
category: 'scene',
|
||||
name: 'Temperature',
|
||||
icon: '🌡️',
|
||||
description: 'Temperature display with thermometer',
|
||||
minSize: { w: 1, h: 1 },
|
||||
defaultSize: { w: 1, h: 1 },
|
||||
maxAutoSize: { w: 1, h: 2 }, // Max size for auto-arrange expansion
|
||||
requiresSchema: false,
|
||||
|
||||
render(container, config = {}) {
|
||||
const { getInfoBoxData } = dependencies;
|
||||
const data = parseInfoBoxData(getInfoBoxData());
|
||||
|
||||
const tempDisplay = data.temperature || '20°C';
|
||||
const tempValue = data.tempValue || 20;
|
||||
const tempPercent = Math.min(100, Math.max(0, ((tempValue + 20) / 60) * 100));
|
||||
const tempColor = tempValue < 10 ? '#4a90e2' : tempValue < 25 ? '#67c23a' : '#e94560';
|
||||
|
||||
const html = `
|
||||
<div class="rpg-dashboard-widget rpg-temp-widget">
|
||||
<div class="rpg-thermometer">
|
||||
<div class="rpg-thermometer-bulb"></div>
|
||||
<div class="rpg-thermometer-tube">
|
||||
<div class="rpg-thermometer-fill" style="height: ${tempPercent}%; background: ${tempColor}"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpg-temp-value rpg-editable" contenteditable="true" data-field="temperature" title="Click to edit">${tempDisplay}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
attachSimpleEditHandlers(container, dependencies);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register Clock Widget
|
||||
*/
|
||||
export function registerClockWidget(registry, dependencies) {
|
||||
registry.register('clock', {
|
||||
category: 'scene',
|
||||
name: 'Clock',
|
||||
icon: '🕐',
|
||||
description: 'Analog clock with time display',
|
||||
minSize: { w: 1, h: 1 },
|
||||
defaultSize: { w: 1, h: 1 },
|
||||
maxAutoSize: { w: 1, h: 2 }, // Max size for auto-arrange expansion
|
||||
requiresSchema: false,
|
||||
|
||||
render(container, config = {}) {
|
||||
const { getInfoBoxData } = dependencies;
|
||||
const data = parseInfoBoxData(getInfoBoxData());
|
||||
|
||||
const timeDisplay = data.timeEnd || data.timeStart || '12:00';
|
||||
|
||||
// Parse time for clock hands
|
||||
const timeMatch = timeDisplay.match(/(\d+):(\d+)/);
|
||||
let hourAngle = 0;
|
||||
let minuteAngle = 0;
|
||||
if (timeMatch) {
|
||||
const hours = parseInt(timeMatch[1]);
|
||||
const minutes = parseInt(timeMatch[2]);
|
||||
hourAngle = (hours % 12) * 30 + minutes * 0.5;
|
||||
minuteAngle = minutes * 6;
|
||||
}
|
||||
|
||||
const html = `
|
||||
<div class="rpg-dashboard-widget rpg-clock-widget">
|
||||
<div class="rpg-clock">
|
||||
<div class="rpg-clock-face">
|
||||
<div class="rpg-clock-hour" style="transform: rotate(${hourAngle}deg)"></div>
|
||||
<div class="rpg-clock-minute" style="transform: rotate(${minuteAngle}deg)"></div>
|
||||
<div class="rpg-clock-center"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpg-time-value rpg-editable" contenteditable="true" data-field="timeStart" title="Click to edit">${timeDisplay}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
attachSimpleEditHandlers(container, dependencies);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register Location Widget
|
||||
*/
|
||||
export function registerLocationWidget(registry, dependencies) {
|
||||
registry.register('location', {
|
||||
category: 'scene',
|
||||
name: 'Location',
|
||||
icon: '📍',
|
||||
description: 'Map with location display',
|
||||
minSize: { w: 1, h: 2 },
|
||||
defaultSize: { w: 2, h: 2 },
|
||||
maxAutoSize: { w: 2, h: 2 }, // Max size for auto-arrange expansion
|
||||
requiresSchema: false,
|
||||
|
||||
render(container, config = {}) {
|
||||
const { getInfoBoxData } = dependencies;
|
||||
const data = parseInfoBoxData(getInfoBoxData());
|
||||
|
||||
const locationDisplay = data.location || 'Location';
|
||||
|
||||
const html = `
|
||||
<div class="rpg-dashboard-widget rpg-location-widget">
|
||||
<div class="rpg-map-bg">
|
||||
<div class="rpg-map-marker">📍</div>
|
||||
</div>
|
||||
<div class="rpg-location-text rpg-editable" contenteditable="true" data-field="location" title="Click to edit">${locationDisplay}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
attachSimpleEditHandlers(container, dependencies);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach simple edit handlers for single-field widgets
|
||||
*/
|
||||
function attachSimpleEditHandlers(container, dependencies) {
|
||||
const editableFields = container.querySelectorAll('.rpg-editable');
|
||||
|
||||
editableFields.forEach(field => {
|
||||
const fieldName = field.dataset.field;
|
||||
let originalValue = field.textContent.trim();
|
||||
|
||||
field.addEventListener('focus', () => {
|
||||
originalValue = field.textContent.trim();
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(field);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
});
|
||||
|
||||
field.addEventListener('blur', () => {
|
||||
const value = field.textContent.trim();
|
||||
if (value && value !== originalValue) {
|
||||
updateInfoBoxField(dependencies, fieldName, value);
|
||||
}
|
||||
});
|
||||
|
||||
field.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
field.blur();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
field.textContent = originalValue;
|
||||
field.blur();
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent paste with formatting
|
||||
field.addEventListener('paste', (e) => {
|
||||
e.preventDefault();
|
||||
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
|
||||
document.execCommand('insertText', false, text);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register Recent Events Widget
|
||||
* @param {WidgetRegistry} registry - Widget registry instance
|
||||
* @param {Object} dependencies - External dependencies
|
||||
* @param {Function} dependencies.getExtensionSettings - Get extension settings
|
||||
* @param {Function} dependencies.saveSettings - Save settings
|
||||
*/
|
||||
export function registerRecentEventsWidget(registry, dependencies) {
|
||||
registry.register('recentEvents', {
|
||||
name: 'Recent Events',
|
||||
icon: '📝',
|
||||
description: 'Recent events notebook',
|
||||
category: 'scene',
|
||||
minSize: { w: 2, h: 2 },
|
||||
defaultSize: { w: 2, h: 2 },
|
||||
requiresSchema: false,
|
||||
|
||||
/**
|
||||
* Render widget content
|
||||
* @param {HTMLElement} container - Widget container
|
||||
* @param {Object} config - Widget configuration
|
||||
*/
|
||||
render(container, config = {}) {
|
||||
const { getInfoBoxData } = dependencies;
|
||||
const data = parseInfoBoxData(getInfoBoxData());
|
||||
|
||||
// Merge default config with user config
|
||||
const finalConfig = {
|
||||
maxEvents: 3,
|
||||
...config
|
||||
};
|
||||
|
||||
// Get events array (filter out placeholders)
|
||||
let validEvents = data.recentEvents.filter(e =>
|
||||
e && e.trim() &&
|
||||
e !== 'Event 1' && e !== 'Event 2' && e !== 'Event 3' &&
|
||||
e !== 'Click to add event' && e !== 'Add event...'
|
||||
);
|
||||
|
||||
// If no valid events, show at least one placeholder
|
||||
if (validEvents.length === 0) {
|
||||
validEvents = ['Click to add event'];
|
||||
}
|
||||
|
||||
// Build events HTML
|
||||
let eventsHtml = '';
|
||||
|
||||
// Render existing events (max maxEvents)
|
||||
for (let i = 0; i < Math.min(validEvents.length, finalConfig.maxEvents); i++) {
|
||||
eventsHtml += `
|
||||
<div class="rpg-notebook-line">
|
||||
<span class="rpg-bullet">•</span>
|
||||
<span class="rpg-event-text rpg-editable" contenteditable="true" data-event-index="${i}" title="Click to edit">${validEvents[i]}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Add empty placeholders with + icon
|
||||
for (let i = validEvents.length; i < finalConfig.maxEvents; i++) {
|
||||
eventsHtml += `
|
||||
<div class="rpg-notebook-line rpg-event-add">
|
||||
<span class="rpg-bullet">+</span>
|
||||
<span class="rpg-event-text rpg-editable rpg-event-placeholder" contenteditable="true" data-event-index="${i}" title="Click to add event">Add event...</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Render HTML
|
||||
const html = `
|
||||
<div class="rpg-dashboard-widget">
|
||||
<div class="rpg-events-widget">
|
||||
<div class="rpg-notebook-header">
|
||||
<div class="rpg-notebook-ring"></div>
|
||||
<div class="rpg-notebook-ring"></div>
|
||||
<div class="rpg-notebook-ring"></div>
|
||||
</div>
|
||||
<div class="rpg-notebook-title">Recent Events</div>
|
||||
<div class="rpg-notebook-lines">
|
||||
${eventsHtml}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Attach event handlers
|
||||
attachRecentEventsHandlers(container, dependencies);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get configuration options
|
||||
* @returns {Object} Configuration schema
|
||||
*/
|
||||
getConfig() {
|
||||
return {
|
||||
maxEvents: {
|
||||
type: 'number',
|
||||
label: 'Max Events',
|
||||
default: 3,
|
||||
min: 1,
|
||||
max: 5,
|
||||
description: 'Maximum number of events to display'
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle configuration changes
|
||||
* @param {HTMLElement} container - Widget container
|
||||
* @param {Object} newConfig - New configuration
|
||||
*/
|
||||
onConfigChange(container, newConfig) {
|
||||
this.render(container, newConfig);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach event handlers for Recent Events widget
|
||||
* @private
|
||||
*/
|
||||
function attachRecentEventsHandlers(container, dependencies) {
|
||||
const eventFields = container.querySelectorAll('.rpg-editable');
|
||||
|
||||
eventFields.forEach(field => {
|
||||
const eventIndex = parseInt(field.dataset.eventIndex);
|
||||
let originalValue = field.textContent.trim();
|
||||
|
||||
field.addEventListener('focus', () => {
|
||||
originalValue = field.textContent.trim();
|
||||
// Clear placeholder text on focus
|
||||
if (field.classList.contains('rpg-event-placeholder')) {
|
||||
field.textContent = '';
|
||||
}
|
||||
// Select all text
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(field);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
});
|
||||
|
||||
field.addEventListener('blur', () => {
|
||||
const value = field.textContent.trim();
|
||||
|
||||
// Restore placeholder if empty
|
||||
if (!value && field.classList.contains('rpg-event-placeholder')) {
|
||||
field.textContent = 'Add event...';
|
||||
return;
|
||||
}
|
||||
|
||||
// Update if changed
|
||||
if (value !== originalValue) {
|
||||
updateRecentEvent(eventIndex, value, dependencies);
|
||||
}
|
||||
});
|
||||
|
||||
field.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
field.blur();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
field.textContent = originalValue;
|
||||
field.blur();
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent paste with formatting
|
||||
field.addEventListener('paste', (e) => {
|
||||
e.preventDefault();
|
||||
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
|
||||
document.execCommand('insertText', false, text);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific recent event in infoBox data
|
||||
* @private
|
||||
*/
|
||||
function updateRecentEvent(eventIndex, value, dependencies) {
|
||||
const { getInfoBoxData, setInfoBoxData, onDataChange } = dependencies;
|
||||
|
||||
// Parse current infoBox to get existing events
|
||||
const infoBoxData = getInfoBoxData() || '';
|
||||
const lines = infoBoxData.split('\n');
|
||||
let recentEvents = [];
|
||||
|
||||
// Find existing Recent Events line
|
||||
const recentEventsLine = lines.find(line => line.startsWith('Recent Events:'));
|
||||
if (recentEventsLine) {
|
||||
const eventsString = recentEventsLine.replace('Recent Events:', '').trim();
|
||||
if (eventsString) {
|
||||
recentEvents = eventsString.split(',').map(e => e.trim()).filter(e => e);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure array has enough slots
|
||||
while (recentEvents.length <= eventIndex) {
|
||||
recentEvents.push('');
|
||||
}
|
||||
|
||||
// Update the specific event
|
||||
recentEvents[eventIndex] = value;
|
||||
|
||||
// Filter out empty events and rebuild the line
|
||||
const validEvents = recentEvents.filter(e => e && e.trim());
|
||||
const newRecentEventsLine = validEvents.length > 0
|
||||
? `Recent Events: ${validEvents.join(', ')}`
|
||||
: '';
|
||||
|
||||
// Update infoBox with new Recent Events line
|
||||
const updatedLines = lines.filter(line => !line.startsWith('Recent Events:'));
|
||||
if (newRecentEventsLine) {
|
||||
// Add Recent Events line at the end (before any empty lines)
|
||||
let insertIndex = updatedLines.length;
|
||||
for (let i = updatedLines.length - 1; i >= 0; i--) {
|
||||
if (updatedLines[i].trim() !== '') {
|
||||
insertIndex = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
updatedLines.splice(insertIndex, 0, newRecentEventsLine);
|
||||
}
|
||||
|
||||
const updatedInfoBox = updatedLines.join('\n');
|
||||
|
||||
// Save using dependency function (handles all necessary updates)
|
||||
setInfoBoxData(updatedInfoBox);
|
||||
|
||||
// Notify change
|
||||
if (onDataChange) {
|
||||
onDataChange('infoBox', 'recentEvents', value, { eventIndex });
|
||||
}
|
||||
|
||||
console.log(`[Recent Events Widget] Updated event ${eventIndex}: "${value}"`);
|
||||
}
|
||||
@@ -1,958 +0,0 @@
|
||||
/**
|
||||
* Inventory Widget
|
||||
*
|
||||
* Comprehensive inventory management with three sub-tabs:
|
||||
* - On Person: Items currently carried
|
||||
* - Stored: Items in storage locations
|
||||
* - Assets: Vehicles, property, major possessions
|
||||
*
|
||||
* Features:
|
||||
* - List/Grid view modes per sub-tab
|
||||
* - Add/remove items and storage locations
|
||||
* - Collapsible storage locations
|
||||
* - Editable item names
|
||||
* - Inline forms for adding items
|
||||
*/
|
||||
|
||||
import { parseItems, serializeItems } from '../../../utils/itemParser.js';
|
||||
import { sanitizeItemName, sanitizeLocationName } from '../../../utils/security.js';
|
||||
import { showAlertDialog } from '../confirmDialog.js';
|
||||
|
||||
/**
|
||||
* Convert location name to safe HTML ID
|
||||
*/
|
||||
function getLocationId(locationName) {
|
||||
return locationName.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register Inventory Widget
|
||||
*/
|
||||
export function registerInventoryWidget(registry, dependencies) {
|
||||
const { getExtensionSettings, onDataChange } = dependencies;
|
||||
|
||||
// Widget state (per-instance)
|
||||
const widgetStates = new Map();
|
||||
|
||||
function getWidgetState(widgetId) {
|
||||
if (!widgetStates.has(widgetId)) {
|
||||
widgetStates.set(widgetId, {
|
||||
activeSubTab: 'onPerson',
|
||||
collapsedLocations: [],
|
||||
viewModes: {
|
||||
onPerson: 'list',
|
||||
stored: 'list',
|
||||
assets: 'list'
|
||||
}
|
||||
});
|
||||
}
|
||||
return widgetStates.get(widgetId);
|
||||
}
|
||||
|
||||
registry.register('inventory', {
|
||||
name: 'Inventory',
|
||||
icon: '🎒',
|
||||
description: 'Full inventory system with On Person, Stored, and Assets',
|
||||
category: 'inventory',
|
||||
minSize: { w: 2, h: 4 },
|
||||
// Column-aware sizing: compact on mobile, spacious on desktop
|
||||
defaultSize: (columns) => {
|
||||
if (columns <= 2) {
|
||||
return { w: 2, h: 5 }; // Mobile: 2×5 (full width, compact)
|
||||
}
|
||||
return { w: 2, h: 6 }; // Desktop: 2×6 (default)
|
||||
},
|
||||
maxAutoSize: (columns) => {
|
||||
if (columns <= 2) {
|
||||
return { w: 2, h: 8 }; // Mobile: 2×8 max (increased for expansion headroom)
|
||||
}
|
||||
return { w: 3, h: 8 }; // Desktop: 3×8 max (can expand)
|
||||
},
|
||||
requiresSchema: false,
|
||||
|
||||
render(container, config = {}) {
|
||||
const settings = getExtensionSettings();
|
||||
const inventory = settings.userStats.inventory || {
|
||||
version: 2,
|
||||
onPerson: 'None',
|
||||
stored: {},
|
||||
assets: 'None'
|
||||
};
|
||||
|
||||
// Get or create widget state
|
||||
const widgetId = container.closest('.dashboard-widget')?.dataset?.widgetId || 'default';
|
||||
const state = getWidgetState(widgetId);
|
||||
|
||||
// Build HTML
|
||||
const html = `
|
||||
<div class="rpg-inventory-widget" data-widget-id="${widgetId}">
|
||||
${renderSubTabs(state.activeSubTab)}
|
||||
<div class="rpg-inventory-views">
|
||||
${renderActiveView(inventory, state)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Attach event handlers
|
||||
attachInventoryHandlers(container, widgetId, inventory, state, dependencies);
|
||||
},
|
||||
|
||||
getConfig() {
|
||||
return {
|
||||
compactMode: {
|
||||
type: 'boolean',
|
||||
label: 'Compact Mode',
|
||||
default: false
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
onConfigChange(container, newConfig) {
|
||||
this.render(container, newConfig);
|
||||
},
|
||||
|
||||
onResize(container, newW, newH) {
|
||||
// Re-render widget to update internal layout for new dimensions
|
||||
// This ensures sub-tabs, item lists, and storage locations adapt correctly
|
||||
this.render(container, this.config || {});
|
||||
|
||||
// Apply compact mode styling if needed
|
||||
const widget = container.querySelector('.rpg-inventory-widget');
|
||||
if (widget) {
|
||||
if (newW < 6) {
|
||||
widget.classList.add('rpg-inventory-compact');
|
||||
} else {
|
||||
widget.classList.remove('rpg-inventory-compact');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onRemove(widgetId) {
|
||||
// Clean up widget state
|
||||
widgetStates.delete(widgetId);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Render sub-tab navigation
|
||||
*/
|
||||
function renderSubTabs(activeTab) {
|
||||
return `
|
||||
<div class="rpg-inventory-subtabs">
|
||||
<button class="rpg-inventory-subtab ${activeTab === 'onPerson' ? 'active' : ''}" data-tab="onPerson" title="On Person">
|
||||
<i class="fa-solid fa-user"></i>
|
||||
<span class="rpg-subtab-label">On Person</span>
|
||||
</button>
|
||||
<button class="rpg-inventory-subtab ${activeTab === 'stored' ? 'active' : ''}" data-tab="stored" title="Stored">
|
||||
<i class="fa-solid fa-box"></i>
|
||||
<span class="rpg-subtab-label">Stored</span>
|
||||
</button>
|
||||
<button class="rpg-inventory-subtab ${activeTab === 'assets' ? 'active' : ''}" data-tab="assets" title="Assets">
|
||||
<i class="fa-solid fa-building"></i>
|
||||
<span class="rpg-subtab-label">Assets</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render active view based on state
|
||||
*/
|
||||
function renderActiveView(inventory, state) {
|
||||
switch (state.activeSubTab) {
|
||||
case 'onPerson':
|
||||
return renderOnPersonView(inventory.onPerson, state.viewModes.onPerson);
|
||||
case 'stored':
|
||||
return renderStoredView(inventory.stored, state.collapsedLocations, state.viewModes.stored);
|
||||
case 'assets':
|
||||
return renderAssetsView(inventory.assets, state.viewModes.assets);
|
||||
default:
|
||||
return renderOnPersonView(inventory.onPerson, state.viewModes.onPerson);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render On Person view
|
||||
*/
|
||||
function renderOnPersonView(onPersonItems, viewMode) {
|
||||
const items = parseItems(onPersonItems);
|
||||
const itemsHtml = items.length === 0
|
||||
? '<div class="rpg-inventory-empty">No items carried</div>'
|
||||
: renderItemList(items, 'onPerson', null, viewMode);
|
||||
|
||||
return `
|
||||
<div class="rpg-inventory-section" data-section="onPerson">
|
||||
<div class="rpg-inventory-header">
|
||||
<h4>Items Currently Carried</h4>
|
||||
<div class="rpg-inventory-header-actions">
|
||||
${renderViewToggle('onPerson', viewMode)}
|
||||
<button class="rpg-inventory-add-btn" data-action="add-item" data-field="onPerson">
|
||||
<i class="fa-solid fa-plus"></i><span class="rpg-btn-label"> Add Item</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpg-inventory-content">
|
||||
<div class="rpg-inline-form" data-form="add-item-onPerson" style="display: none;">
|
||||
<input type="text" class="rpg-inline-input" placeholder="Enter item name..." />
|
||||
<div class="rpg-inline-buttons">
|
||||
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-item" data-field="onPerson">
|
||||
<i class="fa-solid fa-times"></i> Cancel
|
||||
</button>
|
||||
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-item" data-field="onPerson">
|
||||
<i class="fa-solid fa-check"></i> Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpg-item-list ${viewMode === 'list' ? 'rpg-item-list-view' : 'rpg-item-grid-view'}">
|
||||
${itemsHtml}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Stored view
|
||||
*/
|
||||
function renderStoredView(stored, collapsedLocations, viewMode) {
|
||||
const locations = Object.keys(stored || {});
|
||||
|
||||
let locationsHtml = '';
|
||||
if (locations.length === 0) {
|
||||
locationsHtml = `
|
||||
<div class="rpg-inventory-empty">
|
||||
No storage locations yet. Click "Add Location" to create one.
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
locationsHtml = locations.map(location => {
|
||||
const items = parseItems(stored[location]);
|
||||
const isCollapsed = collapsedLocations.includes(location);
|
||||
const locationId = getLocationId(location);
|
||||
const itemsHtml = items.length === 0
|
||||
? '<div class="rpg-inventory-empty">No items stored here</div>'
|
||||
: renderItemList(items, 'stored', location, viewMode);
|
||||
|
||||
return `
|
||||
<div class="rpg-storage-location ${isCollapsed ? 'collapsed' : ''}" data-location="${escapeHtml(location)}">
|
||||
<div class="rpg-storage-header">
|
||||
<button class="rpg-storage-toggle" data-action="toggle-location" data-location="${escapeHtml(location)}">
|
||||
<i class="fa-solid fa-chevron-${isCollapsed ? 'right' : 'down'}"></i>
|
||||
</button>
|
||||
<h5 class="rpg-storage-name">${escapeHtml(location)}</h5>
|
||||
<div class="rpg-storage-actions">
|
||||
<button class="rpg-inventory-remove-btn" data-action="remove-location" data-location="${escapeHtml(location)}">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpg-storage-content" ${isCollapsed ? 'style="display:none;"' : ''}>
|
||||
<div class="rpg-inline-form" data-form="add-item-stored-${locationId}" style="display: none;">
|
||||
<input type="text" class="rpg-inline-input" placeholder="Enter item name..." />
|
||||
<div class="rpg-inline-buttons">
|
||||
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-item" data-field="stored" data-location="${escapeHtml(location)}">
|
||||
<i class="fa-solid fa-times"></i> Cancel
|
||||
</button>
|
||||
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-item" data-field="stored" data-location="${escapeHtml(location)}">
|
||||
<i class="fa-solid fa-check"></i> Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpg-item-list ${viewMode === 'list' ? 'rpg-item-list-view' : 'rpg-item-grid-view'}">
|
||||
${itemsHtml}
|
||||
</div>
|
||||
<div class="rpg-storage-add-item-container">
|
||||
<button class="rpg-inventory-add-btn" data-action="add-item" data-field="stored" data-location="${escapeHtml(location)}">
|
||||
<i class="fa-solid fa-plus"></i> Add Item
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpg-inline-confirmation" data-confirm="remove-location-${locationId}" style="display: none;">
|
||||
<p>Remove "${escapeHtml(location)}"? This will delete all items stored there.</p>
|
||||
<div class="rpg-inline-buttons">
|
||||
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-remove-location" data-location="${escapeHtml(location)}">
|
||||
<i class="fa-solid fa-times"></i> Cancel
|
||||
</button>
|
||||
<button class="rpg-inline-btn rpg-inline-confirm" data-action="confirm-remove-location" data-location="${escapeHtml(location)}">
|
||||
<i class="fa-solid fa-check"></i> Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="rpg-inventory-section" data-section="stored">
|
||||
<div class="rpg-inventory-header">
|
||||
<h4>Storage Locations</h4>
|
||||
<div class="rpg-inventory-header-actions">
|
||||
${renderViewToggle('stored', viewMode)}
|
||||
<button class="rpg-inventory-add-btn" data-action="add-location">
|
||||
<i class="fa-solid fa-plus"></i><span class="rpg-btn-label"> Add Location</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpg-inventory-content">
|
||||
<div class="rpg-inline-form" data-form="add-location" style="display: none;">
|
||||
<input type="text" class="rpg-inline-input" placeholder="Enter location name..." />
|
||||
<div class="rpg-inline-buttons">
|
||||
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-location">
|
||||
<i class="fa-solid fa-times"></i> Cancel
|
||||
</button>
|
||||
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-location">
|
||||
<i class="fa-solid fa-check"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
${locationsHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Assets view
|
||||
*/
|
||||
function renderAssetsView(assets, viewMode) {
|
||||
const items = parseItems(assets);
|
||||
const itemsHtml = items.length === 0
|
||||
? '<div class="rpg-inventory-empty">No assets owned</div>'
|
||||
: renderItemList(items, 'assets', null, viewMode);
|
||||
|
||||
return `
|
||||
<div class="rpg-inventory-section" data-section="assets">
|
||||
<div class="rpg-inventory-header">
|
||||
<h4>Vehicles, Property & Major Possessions</h4>
|
||||
<div class="rpg-inventory-header-actions">
|
||||
${renderViewToggle('assets', viewMode)}
|
||||
<button class="rpg-inventory-add-btn" data-action="add-item" data-field="assets">
|
||||
<i class="fa-solid fa-plus"></i><span class="rpg-btn-label"> Add Asset</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpg-inventory-content">
|
||||
<div class="rpg-inline-form" data-form="add-item-assets" style="display: none;">
|
||||
<input type="text" class="rpg-inline-input" placeholder="Enter asset name..." />
|
||||
<div class="rpg-inline-buttons">
|
||||
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-item" data-field="assets">
|
||||
<i class="fa-solid fa-times"></i> Cancel
|
||||
</button>
|
||||
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-item" data-field="assets">
|
||||
<i class="fa-solid fa-check"></i> Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpg-item-list ${viewMode === 'list' ? 'rpg-item-list-view' : 'rpg-item-grid-view'}">
|
||||
${itemsHtml}
|
||||
</div>
|
||||
<div class="rpg-inventory-hint">
|
||||
<i class="fa-solid fa-info-circle"></i>
|
||||
Assets include vehicles (cars, motorcycles), property (homes, apartments),
|
||||
and major equipment (workshop tools, special items).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render view toggle buttons
|
||||
*/
|
||||
function renderViewToggle(field, viewMode) {
|
||||
return `
|
||||
<div class="rpg-view-toggle">
|
||||
<button class="rpg-view-btn ${viewMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="${field}" data-view="list" title="List view">
|
||||
<i class="fa-solid fa-list"></i>
|
||||
</button>
|
||||
<button class="rpg-view-btn ${viewMode === 'grid' ? 'active' : ''}" data-action="switch-view" data-field="${field}" data-view="grid" title="Grid view">
|
||||
<i class="fa-solid fa-th"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render item list (list or grid view)
|
||||
*/
|
||||
function renderItemList(items, field, location, viewMode) {
|
||||
const locationAttr = location ? `data-location="${escapeHtml(location)}"` : '';
|
||||
|
||||
if (viewMode === 'grid') {
|
||||
return items.map((item, index) => `
|
||||
<div class="rpg-item-card" data-field="${field}" ${locationAttr} data-index="${index}">
|
||||
<button class="rpg-item-remove" data-action="remove-item" data-field="${field}" ${locationAttr} data-index="${index}" title="Remove item">
|
||||
<i class="fa-solid fa-times"></i>
|
||||
</button>
|
||||
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="${field}" ${locationAttr} data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
return items.map((item, index) => `
|
||||
<div class="rpg-item-row" data-field="${field}" ${locationAttr} data-index="${index}">
|
||||
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="${field}" ${locationAttr} data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
|
||||
<button class="rpg-item-remove" data-action="remove-item" data-field="${field}" ${locationAttr} data-index="${index}" title="Remove item">
|
||||
<i class="fa-solid fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach all event handlers
|
||||
*/
|
||||
function attachInventoryHandlers(container, widgetId, inventory, state, dependencies) {
|
||||
const widget = container.querySelector('.rpg-inventory-widget');
|
||||
if (!widget) return;
|
||||
|
||||
// Sub-tab switching
|
||||
widget.querySelectorAll('.rpg-inventory-subtab').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const tab = btn.dataset.tab;
|
||||
state.activeSubTab = tab;
|
||||
|
||||
// Re-render
|
||||
const settings = getExtensionSettings();
|
||||
const inv = settings.userStats.inventory;
|
||||
widget.querySelector('.rpg-inventory-views').innerHTML = renderActiveView(inv, state);
|
||||
|
||||
// Update active tab styling
|
||||
widget.querySelectorAll('.rpg-inventory-subtab').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
|
||||
// Re-attach handlers for new view
|
||||
attachInventoryHandlers(container, widgetId, inv, state, dependencies);
|
||||
});
|
||||
});
|
||||
|
||||
// View mode toggle
|
||||
widget.querySelectorAll('[data-action="switch-view"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const field = btn.dataset.field;
|
||||
const view = btn.dataset.view;
|
||||
state.viewModes[field] = view;
|
||||
|
||||
// Re-render active view
|
||||
const settings = getExtensionSettings();
|
||||
const inv = settings.userStats.inventory;
|
||||
widget.querySelector('.rpg-inventory-views').innerHTML = renderActiveView(inv, state);
|
||||
attachInventoryHandlers(container, widgetId, inv, state, dependencies);
|
||||
});
|
||||
});
|
||||
|
||||
// Add item button
|
||||
widget.querySelectorAll('[data-action="add-item"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const field = btn.dataset.field;
|
||||
const location = btn.dataset.location;
|
||||
showAddItemForm(widget, field, location);
|
||||
});
|
||||
});
|
||||
|
||||
// Cancel add item
|
||||
widget.querySelectorAll('[data-action="cancel-add-item"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const field = btn.dataset.field;
|
||||
const location = btn.dataset.location;
|
||||
hideAddItemForm(widget, field, location);
|
||||
});
|
||||
});
|
||||
|
||||
// Save add item
|
||||
widget.querySelectorAll('[data-action="save-add-item"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const field = btn.dataset.field;
|
||||
const location = btn.dataset.location;
|
||||
saveAddItem(container, widgetId, field, location, state, dependencies);
|
||||
});
|
||||
});
|
||||
|
||||
// Enter key in add item form
|
||||
widget.querySelectorAll('.rpg-inline-form input').forEach(input => {
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const form = input.closest('.rpg-inline-form');
|
||||
const saveBtn = form.querySelector('[data-action="save-add-item"], [data-action="save-add-location"]');
|
||||
if (saveBtn) saveBtn.click();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
const form = input.closest('.rpg-inline-form');
|
||||
const cancelBtn = form.querySelector('[data-action="cancel-add-item"], [data-action="cancel-add-location"]');
|
||||
if (cancelBtn) cancelBtn.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Remove item
|
||||
widget.querySelectorAll('[data-action="remove-item"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const field = btn.dataset.field;
|
||||
const index = parseInt(btn.dataset.index);
|
||||
const location = btn.dataset.location;
|
||||
removeItem(container, widgetId, field, index, location, state, dependencies);
|
||||
});
|
||||
});
|
||||
|
||||
// Edit item name
|
||||
widget.querySelectorAll('.rpg-item-name.rpg-editable').forEach(field => {
|
||||
let originalValue = field.textContent.trim();
|
||||
|
||||
field.addEventListener('focus', () => {
|
||||
originalValue = field.textContent.trim();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(field);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
});
|
||||
|
||||
field.addEventListener('blur', () => {
|
||||
const newValue = field.textContent.trim();
|
||||
if (newValue && newValue !== originalValue) {
|
||||
const fieldName = field.dataset.field;
|
||||
const index = parseInt(field.dataset.index);
|
||||
const location = field.dataset.location;
|
||||
updateItemName(container, widgetId, fieldName, index, newValue, location, state, dependencies);
|
||||
} else if (!newValue) {
|
||||
field.textContent = originalValue;
|
||||
}
|
||||
});
|
||||
|
||||
field.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
field.blur();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
field.textContent = originalValue;
|
||||
field.blur();
|
||||
}
|
||||
});
|
||||
|
||||
field.addEventListener('paste', (e) => {
|
||||
e.preventDefault();
|
||||
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
|
||||
document.execCommand('insertText', false, text);
|
||||
});
|
||||
});
|
||||
|
||||
// Add location
|
||||
const addLocationBtn = widget.querySelector('[data-action="add-location"]');
|
||||
if (addLocationBtn) {
|
||||
addLocationBtn.addEventListener('click', () => {
|
||||
showAddLocationForm(widget);
|
||||
});
|
||||
}
|
||||
|
||||
// Cancel add location
|
||||
const cancelAddLocationBtn = widget.querySelector('[data-action="cancel-add-location"]');
|
||||
if (cancelAddLocationBtn) {
|
||||
cancelAddLocationBtn.addEventListener('click', () => {
|
||||
hideAddLocationForm(widget);
|
||||
});
|
||||
}
|
||||
|
||||
// Save add location
|
||||
const saveAddLocationBtn = widget.querySelector('[data-action="save-add-location"]');
|
||||
if (saveAddLocationBtn) {
|
||||
saveAddLocationBtn.addEventListener('click', () => {
|
||||
saveAddLocation(container, widgetId, state, dependencies);
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle location collapse
|
||||
widget.querySelectorAll('[data-action="toggle-location"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const location = btn.dataset.location;
|
||||
toggleLocationCollapse(widget, location, state);
|
||||
});
|
||||
});
|
||||
|
||||
// Remove location
|
||||
widget.querySelectorAll('[data-action="remove-location"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const location = btn.dataset.location;
|
||||
showRemoveLocationConfirm(widget, location);
|
||||
});
|
||||
});
|
||||
|
||||
// Cancel remove location
|
||||
widget.querySelectorAll('[data-action="cancel-remove-location"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const location = btn.dataset.location;
|
||||
hideRemoveLocationConfirm(widget, location);
|
||||
});
|
||||
});
|
||||
|
||||
// Confirm remove location
|
||||
widget.querySelectorAll('[data-action="confirm-remove-location"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const location = btn.dataset.location;
|
||||
removeLocation(container, widgetId, location, state, dependencies);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show add item form
|
||||
*/
|
||||
function showAddItemForm(widget, field, location) {
|
||||
let formSelector;
|
||||
if (field === 'stored') {
|
||||
const locationId = getLocationId(location);
|
||||
formSelector = `[data-form="add-item-stored-${locationId}"]`;
|
||||
} else {
|
||||
formSelector = `[data-form="add-item-${field}"]`;
|
||||
}
|
||||
|
||||
const form = widget.querySelector(formSelector);
|
||||
if (form) {
|
||||
form.style.display = 'block';
|
||||
const input = form.querySelector('input');
|
||||
if (input) {
|
||||
input.value = '';
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide add item form
|
||||
*/
|
||||
function hideAddItemForm(widget, field, location) {
|
||||
let formSelector;
|
||||
if (field === 'stored') {
|
||||
const locationId = getLocationId(location);
|
||||
formSelector = `[data-form="add-item-stored-${locationId}"]`;
|
||||
} else {
|
||||
formSelector = `[data-form="add-item-${field}"]`;
|
||||
}
|
||||
|
||||
const form = widget.querySelector(formSelector);
|
||||
if (form) {
|
||||
form.style.display = 'none';
|
||||
const input = form.querySelector('input');
|
||||
if (input) input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save new item
|
||||
*/
|
||||
function saveAddItem(container, widgetId, field, location, state, dependencies) {
|
||||
const widget = container.querySelector('.rpg-inventory-widget');
|
||||
let formSelector;
|
||||
if (field === 'stored') {
|
||||
const locationId = getLocationId(location);
|
||||
formSelector = `[data-form="add-item-stored-${locationId}"]`;
|
||||
} else {
|
||||
formSelector = `[data-form="add-item-${field}"]`;
|
||||
}
|
||||
|
||||
const form = widget.querySelector(formSelector);
|
||||
if (!form) return;
|
||||
|
||||
const input = form.querySelector('input');
|
||||
const rawItemName = input.value.trim();
|
||||
|
||||
if (!rawItemName) {
|
||||
hideAddItemForm(widget, field, location);
|
||||
return;
|
||||
}
|
||||
|
||||
const itemName = sanitizeItemName(rawItemName);
|
||||
if (!itemName) {
|
||||
showAlertDialog({
|
||||
title: 'Invalid Item',
|
||||
message: 'Please enter a valid item name.',
|
||||
variant: 'warning'
|
||||
});
|
||||
hideAddItemForm(widget, field, location);
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = getExtensionSettings();
|
||||
const inventory = settings.userStats.inventory;
|
||||
|
||||
// Get current items
|
||||
let currentString;
|
||||
if (field === 'stored') {
|
||||
currentString = inventory.stored[location] || 'None';
|
||||
} else {
|
||||
currentString = inventory[field] || 'None';
|
||||
}
|
||||
|
||||
const items = parseItems(currentString);
|
||||
items.push(itemName);
|
||||
const newString = serializeItems(items);
|
||||
|
||||
// Save back
|
||||
if (field === 'stored') {
|
||||
inventory.stored[location] = newString;
|
||||
} else {
|
||||
inventory[field] = newString;
|
||||
}
|
||||
|
||||
// Trigger change callback
|
||||
if (onDataChange) {
|
||||
onDataChange('inventory', field, newString, location);
|
||||
}
|
||||
|
||||
hideAddItemForm(widget, field, location);
|
||||
|
||||
// Re-render view
|
||||
widget.querySelector('.rpg-inventory-views').innerHTML = renderActiveView(inventory, state);
|
||||
attachInventoryHandlers(container, widgetId, inventory, state, dependencies);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove item
|
||||
*/
|
||||
function removeItem(container, widgetId, field, index, location, state, dependencies) {
|
||||
const settings = getExtensionSettings();
|
||||
const inventory = settings.userStats.inventory;
|
||||
|
||||
// Get current items
|
||||
let currentString;
|
||||
if (field === 'stored') {
|
||||
currentString = inventory.stored[location] || 'None';
|
||||
} else {
|
||||
currentString = inventory[field] || 'None';
|
||||
}
|
||||
|
||||
const items = parseItems(currentString);
|
||||
items.splice(index, 1);
|
||||
const newString = serializeItems(items);
|
||||
|
||||
// Save back
|
||||
if (field === 'stored') {
|
||||
inventory.stored[location] = newString;
|
||||
} else {
|
||||
inventory[field] = newString;
|
||||
}
|
||||
|
||||
// Trigger change callback
|
||||
if (onDataChange) {
|
||||
onDataChange('inventory', field, newString, location);
|
||||
}
|
||||
|
||||
// Re-render view
|
||||
const widget = container.querySelector('.rpg-inventory-widget');
|
||||
widget.querySelector('.rpg-inventory-views').innerHTML = renderActiveView(inventory, state);
|
||||
attachInventoryHandlers(container, widgetId, inventory, state, dependencies);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update item name
|
||||
*/
|
||||
function updateItemName(container, widgetId, field, index, newName, location, state, dependencies) {
|
||||
const sanitized = sanitizeItemName(newName);
|
||||
if (!sanitized) return;
|
||||
|
||||
const settings = getExtensionSettings();
|
||||
const inventory = settings.userStats.inventory;
|
||||
|
||||
// Get current items
|
||||
let currentString;
|
||||
if (field === 'stored') {
|
||||
currentString = inventory.stored[location] || 'None';
|
||||
} else {
|
||||
currentString = inventory[field] || 'None';
|
||||
}
|
||||
|
||||
const items = parseItems(currentString);
|
||||
items[index] = sanitized;
|
||||
const newString = serializeItems(items);
|
||||
|
||||
// Save back
|
||||
if (field === 'stored') {
|
||||
inventory.stored[location] = newString;
|
||||
} else {
|
||||
inventory[field] = newString;
|
||||
}
|
||||
|
||||
// Trigger change callback
|
||||
if (onDataChange) {
|
||||
onDataChange('inventory', field, newString, location);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show add location form
|
||||
*/
|
||||
function showAddLocationForm(widget) {
|
||||
const form = widget.querySelector('[data-form="add-location"]');
|
||||
if (form) {
|
||||
form.style.display = 'block';
|
||||
const input = form.querySelector('input');
|
||||
if (input) {
|
||||
input.value = '';
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide add location form
|
||||
*/
|
||||
function hideAddLocationForm(widget) {
|
||||
const form = widget.querySelector('[data-form="add-location"]');
|
||||
if (form) {
|
||||
form.style.display = 'none';
|
||||
const input = form.querySelector('input');
|
||||
if (input) input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save new location
|
||||
*/
|
||||
function saveAddLocation(container, widgetId, state, dependencies) {
|
||||
const widget = container.querySelector('.rpg-inventory-widget');
|
||||
const form = widget.querySelector('[data-form="add-location"]');
|
||||
if (!form) return;
|
||||
|
||||
const input = form.querySelector('input');
|
||||
const rawLocationName = input.value.trim();
|
||||
|
||||
if (!rawLocationName) {
|
||||
hideAddLocationForm(widget);
|
||||
return;
|
||||
}
|
||||
|
||||
const locationName = sanitizeLocationName(rawLocationName);
|
||||
if (!locationName) {
|
||||
showAlertDialog({
|
||||
title: 'Invalid Location',
|
||||
message: 'Please enter a valid location name.',
|
||||
variant: 'warning'
|
||||
});
|
||||
hideAddLocationForm(widget);
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = getExtensionSettings();
|
||||
const inventory = settings.userStats.inventory;
|
||||
|
||||
// Check if location already exists
|
||||
if (inventory.stored[locationName]) {
|
||||
showAlertDialog({
|
||||
title: 'Duplicate Location',
|
||||
message: 'A location with this name already exists.',
|
||||
variant: 'warning'
|
||||
});
|
||||
hideAddLocationForm(widget);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add new location
|
||||
inventory.stored[locationName] = 'None';
|
||||
|
||||
// Trigger change callback
|
||||
if (onDataChange) {
|
||||
onDataChange('inventory', 'stored', inventory.stored);
|
||||
}
|
||||
|
||||
hideAddLocationForm(widget);
|
||||
|
||||
// Re-render view
|
||||
widget.querySelector('.rpg-inventory-views').innerHTML = renderActiveView(inventory, state);
|
||||
attachInventoryHandlers(container, widgetId, inventory, state, dependencies);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle location collapse
|
||||
*/
|
||||
function toggleLocationCollapse(widget, location, state) {
|
||||
const index = state.collapsedLocations.indexOf(location);
|
||||
if (index === -1) {
|
||||
state.collapsedLocations.push(location);
|
||||
} else {
|
||||
state.collapsedLocations.splice(index, 1);
|
||||
}
|
||||
|
||||
// Update DOM
|
||||
const locationDiv = widget.querySelector(`.rpg-storage-location[data-location="${location}"]`);
|
||||
if (locationDiv) {
|
||||
const content = locationDiv.querySelector('.rpg-storage-content');
|
||||
const icon = locationDiv.querySelector('.rpg-storage-toggle i');
|
||||
|
||||
if (index === -1) {
|
||||
// Now collapsed
|
||||
locationDiv.classList.add('collapsed');
|
||||
content.style.display = 'none';
|
||||
icon.className = 'fa-solid fa-chevron-right';
|
||||
} else {
|
||||
// Now expanded
|
||||
locationDiv.classList.remove('collapsed');
|
||||
content.style.display = 'block';
|
||||
icon.className = 'fa-solid fa-chevron-down';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show remove location confirmation
|
||||
*/
|
||||
function showRemoveLocationConfirm(widget, location) {
|
||||
const locationId = getLocationId(location);
|
||||
const confirm = widget.querySelector(`[data-confirm="remove-location-${locationId}"]`);
|
||||
if (confirm) {
|
||||
confirm.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide remove location confirmation
|
||||
*/
|
||||
function hideRemoveLocationConfirm(widget, location) {
|
||||
const locationId = getLocationId(location);
|
||||
const confirm = widget.querySelector(`[data-confirm="remove-location-${locationId}"]`);
|
||||
if (confirm) {
|
||||
confirm.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove location
|
||||
*/
|
||||
function removeLocation(container, widgetId, location, state, dependencies) {
|
||||
const settings = getExtensionSettings();
|
||||
const inventory = settings.userStats.inventory;
|
||||
|
||||
delete inventory.stored[location];
|
||||
|
||||
// Remove from collapsed locations
|
||||
const index = state.collapsedLocations.indexOf(location);
|
||||
if (index !== -1) {
|
||||
state.collapsedLocations.splice(index, 1);
|
||||
}
|
||||
|
||||
// Trigger change callback
|
||||
if (onDataChange) {
|
||||
onDataChange('inventory', 'stored', inventory.stored);
|
||||
}
|
||||
|
||||
// Re-render view
|
||||
const widget = container.querySelector('.rpg-inventory-widget');
|
||||
widget.querySelector('.rpg-inventory-views').innerHTML = renderActiveView(inventory, state);
|
||||
attachInventoryHandlers(container, widgetId, inventory, state, dependencies);
|
||||
}
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
/**
|
||||
* Present Characters Widget
|
||||
*
|
||||
* Displays character cards for all characters present in the scene.
|
||||
* Shows:
|
||||
* - Character avatars (matched via fuzzy name matching)
|
||||
* - Character emoji and name
|
||||
* - Traits (status, demeanor)
|
||||
* - Relationship badges (Enemy/Neutral/Friend/Lover)
|
||||
*
|
||||
* All fields are editable and sync back to character thoughts data.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fuzzy name matching for character avatars
|
||||
* Handles exact matches, parenthetical additions, and titles
|
||||
*/
|
||||
function namesMatch(cardName, aiName) {
|
||||
if (!cardName || !aiName) return false;
|
||||
|
||||
// Exact match
|
||||
if (cardName.toLowerCase() === aiName.toLowerCase()) return true;
|
||||
|
||||
// Strip parentheses and match
|
||||
const stripParens = (s) => s.replace(/\s*\([^)]*\)/g, '').trim();
|
||||
const cardCore = stripParens(cardName).toLowerCase();
|
||||
const aiCore = stripParens(aiName).toLowerCase();
|
||||
if (cardCore === aiCore) return true;
|
||||
|
||||
// Check if card name appears as complete word in AI name
|
||||
const escapedCardCore = cardCore.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const wordBoundary = new RegExp(`\\b${escapedCardCore}\\b`);
|
||||
return wordBoundary.test(aiCore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse character thoughts data
|
||||
* Format: [Emoji]: [Name, Traits] | [Relationship] | [Thoughts]
|
||||
* Or: [Emoji]: [Name, Traits] | [Demeanor] | [Relationship] | [Thoughts]
|
||||
*/
|
||||
function parseCharacterThoughts(thoughtsText) {
|
||||
if (!thoughtsText) return [];
|
||||
|
||||
const lines = thoughtsText.split('\n');
|
||||
const presentCharacters = [];
|
||||
let currentChar = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Skip headers, dividers, and empty lines
|
||||
if (!trimmed ||
|
||||
trimmed.includes('Present Characters') ||
|
||||
trimmed.includes('---') ||
|
||||
trimmed.startsWith('```')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// New character entry (starts with -)
|
||||
if (trimmed.startsWith('-')) {
|
||||
// Save previous character
|
||||
if (currentChar && currentChar.name && currentChar.name.toLowerCase() !== 'unavailable') {
|
||||
presentCharacters.push(currentChar);
|
||||
}
|
||||
|
||||
// Start new character
|
||||
const name = trimmed.replace(/^-\s*/, '').trim();
|
||||
currentChar = {
|
||||
name,
|
||||
emoji: '😊', // Default emoji
|
||||
traits: '',
|
||||
relationship: 'Neutral',
|
||||
thoughts: ''
|
||||
};
|
||||
}
|
||||
// Details line: "Details: 🧐 | Trait1, Trait2 | More traits"
|
||||
else if (trimmed.startsWith('Details:') && currentChar) {
|
||||
const detailsText = trimmed.replace('Details:', '').trim();
|
||||
const parts = detailsText.split('|').map(p => p.trim());
|
||||
|
||||
// First part is emoji
|
||||
if (parts[0]) {
|
||||
currentChar.emoji = parts[0];
|
||||
}
|
||||
|
||||
// Remaining parts are traits
|
||||
if (parts.length > 1) {
|
||||
currentChar.traits = parts.slice(1).join(', ');
|
||||
}
|
||||
}
|
||||
// Relationship line: "Relationship: Ally (details)"
|
||||
else if (trimmed.startsWith('Relationship:') && currentChar) {
|
||||
currentChar.relationship = trimmed.replace('Relationship:', '').trim();
|
||||
}
|
||||
// Thoughts line: "Thoughts: ..."
|
||||
else if (trimmed.startsWith('Thoughts:') && currentChar) {
|
||||
currentChar.thoughts = trimmed.replace('Thoughts:', '').trim()
|
||||
.replace(/^["']|["']$/g, ''); // Remove surrounding quotes
|
||||
}
|
||||
// Stats line: "Stats: ..." (optional, currently ignored but could be stored)
|
||||
else if (trimmed.startsWith('Stats:') && currentChar) {
|
||||
// Optional: could parse and store stats if needed
|
||||
// For now, we'll skip it as the widget doesn't display character stats
|
||||
}
|
||||
// Legacy single-line format fallback: "🧐: Name, Traits | Relationship | Thoughts"
|
||||
else if (trimmed.includes('|') && !currentChar) {
|
||||
const parts = trimmed.split('|').map(p => p.trim());
|
||||
|
||||
if (parts.length >= 3) {
|
||||
const firstPart = parts[0].trim();
|
||||
const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/);
|
||||
|
||||
if (emojiMatch) {
|
||||
const emoji = emojiMatch[1].trim();
|
||||
const info = emojiMatch[2].trim();
|
||||
const infoParts = info.split(',').map(p => p.trim());
|
||||
const name = infoParts[0] || '';
|
||||
const traits = infoParts.slice(1).join(', ');
|
||||
const relationship = parts[1].trim();
|
||||
const thoughts = parts[2].trim();
|
||||
|
||||
if (name && name.toLowerCase() !== 'unavailable') {
|
||||
presentCharacters.push({ emoji, name, traits, relationship, thoughts });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save last character
|
||||
if (currentChar && currentChar.name && currentChar.name.toLowerCase() !== 'unavailable') {
|
||||
presentCharacters.push(currentChar);
|
||||
}
|
||||
|
||||
return presentCharacters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find character avatar
|
||||
*/
|
||||
function findCharacterAvatar(charName, dependencies) {
|
||||
const { getCharacters, getGroupMembers, getCurrentCharId, getFallbackAvatar, getAvatarUrl } = dependencies;
|
||||
|
||||
let avatarUrl = getFallbackAvatar();
|
||||
|
||||
// Try group members first if in group chat
|
||||
const groupMembers = getGroupMembers();
|
||||
if (groupMembers && groupMembers.length > 0) {
|
||||
const matchingMember = groupMembers.find(member =>
|
||||
member && member.name && namesMatch(member.name, charName)
|
||||
);
|
||||
if (matchingMember && matchingMember.avatar && matchingMember.avatar !== 'none') {
|
||||
const url = getAvatarUrl('avatar', matchingMember.avatar);
|
||||
if (url) avatarUrl = url;
|
||||
}
|
||||
}
|
||||
|
||||
// Try all characters
|
||||
if (avatarUrl === getFallbackAvatar()) {
|
||||
const characters = getCharacters();
|
||||
if (characters && characters.length > 0) {
|
||||
const matchingChar = characters.find(c =>
|
||||
c && c.name && namesMatch(c.name, charName)
|
||||
);
|
||||
if (matchingChar && matchingChar.avatar && matchingChar.avatar !== 'none') {
|
||||
const url = getAvatarUrl('avatar', matchingChar.avatar);
|
||||
if (url) avatarUrl = url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try current character in 1-on-1 chat
|
||||
if (avatarUrl === getFallbackAvatar()) {
|
||||
const currentCharId = getCurrentCharId();
|
||||
const characters = getCharacters();
|
||||
if (currentCharId !== undefined && characters[currentCharId]) {
|
||||
const currentChar = characters[currentCharId];
|
||||
if (currentChar.name && namesMatch(currentChar.name, charName)) {
|
||||
const url = getAvatarUrl('avatar', currentChar.avatar);
|
||||
if (url) avatarUrl = url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return avatarUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update character field in shared data
|
||||
*/
|
||||
function updateCharacterThoughtsField(dependencies, characterName, field, value) {
|
||||
const { getCharacterThoughts, setCharacterThoughts, onDataChange } = dependencies;
|
||||
let thoughtsText = getCharacterThoughts() || '';
|
||||
|
||||
const lines = thoughtsText.split('\n');
|
||||
let updated = false;
|
||||
|
||||
const updatedLines = lines.map(line => {
|
||||
// Find the line for this character
|
||||
if (line.includes(characterName)) {
|
||||
const parts = line.split('|').map(p => p.trim());
|
||||
if (parts.length >= 3) {
|
||||
const firstPart = parts[0].trim();
|
||||
const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/);
|
||||
|
||||
if (emojiMatch) {
|
||||
let emoji = emojiMatch[1].trim();
|
||||
const info = emojiMatch[2].trim();
|
||||
const infoParts = info.split(',').map(p => p.trim());
|
||||
let name = infoParts[0];
|
||||
let traits = infoParts.slice(1).join(', ');
|
||||
|
||||
let relationship, thoughts;
|
||||
if (parts.length === 3) {
|
||||
relationship = parts[1].trim();
|
||||
thoughts = parts[2].trim();
|
||||
} else {
|
||||
// 4-part format
|
||||
relationship = parts[2].trim();
|
||||
thoughts = parts[3].trim();
|
||||
}
|
||||
|
||||
// Update the specific field
|
||||
if (field === 'emoji') emoji = value;
|
||||
else if (field === 'name') name = value;
|
||||
else if (field === 'traits') traits = value;
|
||||
else if (field === 'relationship') {
|
||||
// Convert emoji to text
|
||||
const relationshipMap = {
|
||||
'⚔️': 'Enemy',
|
||||
'⚖️': 'Neutral',
|
||||
'⭐': 'Friend',
|
||||
'❤️': 'Lover'
|
||||
};
|
||||
relationship = relationshipMap[value] || value;
|
||||
}
|
||||
|
||||
// Reconstruct line
|
||||
const nameAndTraits = traits ? `${name}, ${traits}` : name;
|
||||
updated = true;
|
||||
|
||||
if (parts.length === 3) {
|
||||
return `${emoji}: ${nameAndTraits} | ${relationship} | ${thoughts}`;
|
||||
} else {
|
||||
return `${emoji}: ${nameAndTraits} | ${parts[1].trim()} | ${relationship} | ${thoughts}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return line;
|
||||
});
|
||||
|
||||
if (updated) {
|
||||
const newThoughtsText = updatedLines.join('\n');
|
||||
setCharacterThoughts(newThoughtsText);
|
||||
if (onDataChange) {
|
||||
onDataChange('characterThoughts', field, value, characterName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register Present Characters Widget
|
||||
*/
|
||||
export function registerPresentCharactersWidget(registry, dependencies) {
|
||||
const relationshipEmojis = {
|
||||
'Enemy': '⚔️',
|
||||
'Neutral': '⚖️',
|
||||
'Friend': '⭐',
|
||||
'Lover': '❤️'
|
||||
};
|
||||
|
||||
registry.register('presentCharacters', {
|
||||
name: 'Present Characters',
|
||||
icon: '👥',
|
||||
description: 'Character cards with avatars, traits, and relationships',
|
||||
category: 'scene',
|
||||
minSize: { w: 2, h: 2 },
|
||||
defaultSize: { w: 2, h: 2 }, // Compact size fits both mobile and desktop viewports
|
||||
maxAutoSize: { w: 4, h: 5 }, // Max size for auto-arrange expansion (supports up to 4-col on large displays)
|
||||
requiresSchema: false,
|
||||
|
||||
render(container, config = {}) {
|
||||
const { getCharacterThoughts, getCharacters, getFallbackAvatar } = dependencies;
|
||||
|
||||
const thoughtsText = getCharacterThoughts();
|
||||
const presentCharacters = parseCharacterThoughts(thoughtsText);
|
||||
|
||||
let html = '<div class="rpg-thoughts-content">';
|
||||
|
||||
if (presentCharacters.length === 0) {
|
||||
// Show placeholder
|
||||
const characters = getCharacters();
|
||||
const currentCharId = dependencies.getCurrentCharId();
|
||||
let defaultPortrait = getFallbackAvatar();
|
||||
let defaultName = 'Character';
|
||||
|
||||
if (currentCharId !== undefined && characters[currentCharId]) {
|
||||
defaultPortrait = findCharacterAvatar(characters[currentCharId].name, dependencies);
|
||||
defaultName = characters[currentCharId].name || 'Character';
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="rpg-character-card" data-character-name="${defaultName}">
|
||||
<div class="rpg-character-avatar">
|
||||
<img src="${defaultPortrait}" alt="${defaultName}" onerror="this.style.opacity='0.5';this.onerror=null;" />
|
||||
<div class="rpg-relationship-badge rpg-editable" contenteditable="true" data-character="${defaultName}" data-field="relationship" title="Click to edit (use emoji: ⚔️ ⚖️ ⭐ ❤️)">⚖️</div>
|
||||
</div>
|
||||
<div class="rpg-character-info">
|
||||
<div class="rpg-character-header">
|
||||
<span class="rpg-character-emoji rpg-editable" contenteditable="true" data-character="${defaultName}" data-field="emoji" title="Click to edit emoji">😊</span>
|
||||
<span class="rpg-character-name rpg-editable" contenteditable="true" data-character="${defaultName}" data-field="name" title="Click to edit name">${defaultName}</span>
|
||||
</div>
|
||||
<div class="rpg-character-traits rpg-editable" contenteditable="true" data-character="${defaultName}" data-field="traits" title="Click to edit traits">Traits</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// Render character cards
|
||||
for (const char of presentCharacters) {
|
||||
const characterPortrait = findCharacterAvatar(char.name, dependencies);
|
||||
const relationshipEmoji = relationshipEmojis[char.relationship] || '⚖️';
|
||||
|
||||
html += `
|
||||
<div class="rpg-character-card" data-character-name="${char.name}">
|
||||
<div class="rpg-character-avatar">
|
||||
<img src="${characterPortrait}" alt="${char.name}" onerror="this.style.opacity='0.5';this.onerror=null;" />
|
||||
<div class="rpg-relationship-badge rpg-editable" contenteditable="true" data-character="${char.name}" data-field="relationship" title="Click to edit (use emoji: ⚔️ ⚖️ ⭐ ❤️)">${relationshipEmoji}</div>
|
||||
</div>
|
||||
<div class="rpg-character-info">
|
||||
<div class="rpg-character-header">
|
||||
<span class="rpg-character-emoji rpg-editable" contenteditable="true" data-character="${char.name}" data-field="emoji" title="Click to edit emoji">${char.emoji}</span>
|
||||
<span class="rpg-character-name rpg-editable" contenteditable="true" data-character="${char.name}" data-field="name" title="Click to edit name">${char.name}</span>
|
||||
</div>
|
||||
<div class="rpg-character-traits rpg-editable" contenteditable="true" data-character="${char.name}" data-field="traits" title="Click to edit traits">${char.traits}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
container.innerHTML = html;
|
||||
attachCharacterHandlers(container, dependencies);
|
||||
},
|
||||
|
||||
getConfig() {
|
||||
return {
|
||||
showThoughtsInChat: {
|
||||
type: 'boolean',
|
||||
label: 'Show thought bubbles in chat',
|
||||
default: false
|
||||
},
|
||||
cardLayout: {
|
||||
type: 'select',
|
||||
label: 'Card Layout',
|
||||
default: 'grid',
|
||||
options: [
|
||||
{ value: 'grid', label: 'Grid' },
|
||||
{ value: 'list', label: 'List' },
|
||||
{ value: 'compact', label: 'Compact' }
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach character field edit handlers
|
||||
*/
|
||||
function attachCharacterHandlers(container, dependencies) {
|
||||
const editableFields = container.querySelectorAll('.rpg-editable');
|
||||
|
||||
editableFields.forEach(field => {
|
||||
const characterName = field.dataset.character;
|
||||
const fieldName = field.dataset.field;
|
||||
let originalValue = field.textContent.trim();
|
||||
|
||||
field.addEventListener('focus', () => {
|
||||
originalValue = field.textContent.trim();
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(field);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
});
|
||||
|
||||
field.addEventListener('blur', () => {
|
||||
const value = field.textContent.trim();
|
||||
if (value && value !== originalValue) {
|
||||
updateCharacterThoughtsField(dependencies, characterName, fieldName, value);
|
||||
}
|
||||
});
|
||||
|
||||
field.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
field.blur();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
field.textContent = originalValue;
|
||||
field.blur();
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent paste with formatting
|
||||
field.addEventListener('paste', (e) => {
|
||||
e.preventDefault();
|
||||
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
|
||||
document.execCommand('insertText', false, text);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,472 +0,0 @@
|
||||
/**
|
||||
* Quests Widget
|
||||
*
|
||||
* Quest tracking system with two sub-tabs:
|
||||
* - Main Quest: Single primary objective
|
||||
* - Optional Quests: Multiple side objectives
|
||||
*
|
||||
* Features:
|
||||
* - Add/edit/remove quests
|
||||
* - Inline editing for quest titles
|
||||
* - Sub-tab navigation
|
||||
*/
|
||||
|
||||
import { showAlertDialog } from '../confirmDialog.js';
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the quests sub-tab navigation
|
||||
*/
|
||||
function renderQuestsSubTabs(activeTab = 'main') {
|
||||
return `
|
||||
<div class="rpg-inventory-subtabs">
|
||||
<button class="rpg-inventory-subtab ${activeTab === 'main' ? 'active' : ''}" data-tab="main">
|
||||
<i class="fa-solid fa-scroll"></i>
|
||||
<span class="rpg-subtab-label">Main Quest</span>
|
||||
</button>
|
||||
<button class="rpg-inventory-subtab ${activeTab === 'optional' ? 'active' : ''}" data-tab="optional">
|
||||
<i class="fa-solid fa-list-check"></i>
|
||||
<span class="rpg-subtab-label">Optional</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the main quest view
|
||||
*/
|
||||
function renderMainQuestView(mainQuest) {
|
||||
const questDisplay = (mainQuest && mainQuest !== 'None') ? mainQuest : '';
|
||||
const hasQuest = questDisplay.length > 0;
|
||||
|
||||
return `
|
||||
<div class="rpg-quest-section">
|
||||
<div class="rpg-quest-header">
|
||||
<h3 class="rpg-quest-section-title">Main Quest</h3>
|
||||
${!hasQuest ? `<button class="rpg-add-quest-btn" data-action="add-quest" data-field="main" title="Add main quest">
|
||||
<i class="fa-solid fa-plus"></i><span class="rpg-btn-label"> Add Quest</span>
|
||||
</button>` : ''}
|
||||
</div>
|
||||
<div class="rpg-quest-content">
|
||||
${hasQuest ? `
|
||||
<div class="rpg-inline-form" id="rpg-edit-quest-form-main" style="display: none;">
|
||||
<input type="text" class="rpg-inline-input" id="rpg-edit-quest-main" value="${escapeHtml(questDisplay)}" />
|
||||
<div class="rpg-inline-buttons">
|
||||
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-edit-quest" data-field="main">
|
||||
<i class="fa-solid fa-times"></i> Cancel
|
||||
</button>
|
||||
<button class="rpg-inline-btn rpg-inline-save" data-action="save-edit-quest" data-field="main">
|
||||
<i class="fa-solid fa-check"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpg-quest-item" data-field="main">
|
||||
<div class="rpg-quest-title">${escapeHtml(questDisplay)}</div>
|
||||
<div class="rpg-quest-actions">
|
||||
<button class="rpg-quest-edit" data-action="edit-quest" data-field="main" title="Edit quest">
|
||||
<i class="fa-solid fa-edit"></i>
|
||||
</button>
|
||||
<button class="rpg-quest-remove" data-action="remove-quest" data-field="main" title="Complete/Remove quest">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
` : `
|
||||
<div class="rpg-inline-form" id="rpg-add-quest-form-main" style="display: none;">
|
||||
<input type="text" class="rpg-inline-input" id="rpg-new-quest-main" placeholder="Enter main quest title..." />
|
||||
<div class="rpg-inline-buttons">
|
||||
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-quest" data-field="main">
|
||||
<i class="fa-solid fa-times"></i> Cancel
|
||||
</button>
|
||||
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-quest" data-field="main">
|
||||
<i class="fa-solid fa-check"></i> Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpg-quest-empty">No active main quest</div>
|
||||
`}
|
||||
</div>
|
||||
<div class="rpg-quest-hint">
|
||||
<i class="fa-solid fa-lightbulb"></i>
|
||||
The main quest represents your primary objective in the story.
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the optional quests view
|
||||
*/
|
||||
function renderOptionalQuestsView(optionalQuests) {
|
||||
const quests = optionalQuests.filter(q => q && q !== 'None');
|
||||
|
||||
let questsHtml = '';
|
||||
if (quests.length === 0) {
|
||||
questsHtml = '<div class="rpg-quest-empty">No active optional quests</div>';
|
||||
} else {
|
||||
questsHtml = quests.map((quest, index) => `
|
||||
<div class="rpg-quest-item" data-field="optional" data-index="${index}">
|
||||
<div class="rpg-quest-title rpg-editable" contenteditable="true" data-field="optional" data-index="${index}" title="Click to edit">${escapeHtml(quest)}</div>
|
||||
<div class="rpg-quest-actions">
|
||||
<button class="rpg-quest-remove" data-action="remove-quest" data-field="optional" data-index="${index}" title="Complete/Remove quest">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="rpg-quest-section">
|
||||
<div class="rpg-quest-header">
|
||||
<h3 class="rpg-quest-section-title">Optional Quests</h3>
|
||||
<button class="rpg-add-quest-btn" data-action="add-quest" data-field="optional" title="Add optional quest">
|
||||
<i class="fa-solid fa-plus"></i><span class="rpg-btn-label"> Add Quest</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="rpg-quest-content">
|
||||
<div class="rpg-inline-form" id="rpg-add-quest-form-optional" style="display: none;">
|
||||
<input type="text" class="rpg-inline-input" id="rpg-new-quest-optional" placeholder="Enter optional quest title..." />
|
||||
<div class="rpg-inline-buttons">
|
||||
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-quest" data-field="optional">
|
||||
<i class="fa-solid fa-times"></i> Cancel
|
||||
</button>
|
||||
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-quest" data-field="optional">
|
||||
<i class="fa-solid fa-check"></i> Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpg-quest-list">
|
||||
${questsHtml}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpg-quest-hint">
|
||||
<i class="fa-solid fa-info-circle"></i>
|
||||
Optional quests are side objectives that complement your main story.
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach handlers for quest content (buttons, inputs)
|
||||
* Separated so it can be re-attached after tab switching
|
||||
*/
|
||||
function attachQuestContentHandlers(container, widgetId, state, dependencies) {
|
||||
const { getExtensionSettings, onDataChange } = dependencies;
|
||||
const widgetContainer = container.querySelector('.rpg-quests-widget');
|
||||
|
||||
if (!widgetContainer) return;
|
||||
|
||||
// Add quest button
|
||||
widgetContainer.querySelectorAll('[data-action="add-quest"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const field = btn.dataset.field;
|
||||
const form = widgetContainer.querySelector(`#rpg-add-quest-form-${field}`);
|
||||
const input = widgetContainer.querySelector(`#rpg-new-quest-${field}`);
|
||||
if (form) form.style.display = 'block';
|
||||
if (input) input.focus();
|
||||
});
|
||||
});
|
||||
|
||||
// Cancel add quest
|
||||
widgetContainer.querySelectorAll('[data-action="cancel-add-quest"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const field = btn.dataset.field;
|
||||
const form = widgetContainer.querySelector(`#rpg-add-quest-form-${field}`);
|
||||
const input = widgetContainer.querySelector(`#rpg-new-quest-${field}`);
|
||||
if (form) form.style.display = 'none';
|
||||
if (input) input.value = '';
|
||||
});
|
||||
});
|
||||
|
||||
// Save add quest
|
||||
widgetContainer.querySelectorAll('[data-action="save-add-quest"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const field = btn.dataset.field;
|
||||
const input = widgetContainer.querySelector(`#rpg-new-quest-${field}`);
|
||||
const questTitle = input?.value.trim();
|
||||
|
||||
if (questTitle) {
|
||||
const settings = getExtensionSettings();
|
||||
if (field === 'main') {
|
||||
settings.quests.main = questTitle;
|
||||
} else {
|
||||
if (!settings.quests.optional) {
|
||||
settings.quests.optional = [];
|
||||
}
|
||||
settings.quests.optional.push(questTitle);
|
||||
}
|
||||
|
||||
// Trigger data change callback
|
||||
onDataChange('quests', field, questTitle);
|
||||
|
||||
// Re-render the widget
|
||||
const widgetEl = container.closest('.dashboard-widget');
|
||||
if (widgetEl && widgetEl._widgetInstance) {
|
||||
widgetEl._widgetInstance.render(container, widgetEl._widgetInstance.config);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Edit quest (main only)
|
||||
widgetContainer.querySelectorAll('[data-action="edit-quest"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const field = btn.dataset.field;
|
||||
const form = widgetContainer.querySelector(`#rpg-edit-quest-form-${field}`);
|
||||
const questItem = widgetContainer.querySelector('.rpg-quest-item[data-field="main"]');
|
||||
const input = widgetContainer.querySelector(`#rpg-edit-quest-${field}`);
|
||||
|
||||
if (form) form.style.display = 'block';
|
||||
if (questItem) questItem.style.display = 'none';
|
||||
if (input) input.focus();
|
||||
});
|
||||
});
|
||||
|
||||
// Cancel edit quest
|
||||
widgetContainer.querySelectorAll('[data-action="cancel-edit-quest"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const field = btn.dataset.field;
|
||||
const form = widgetContainer.querySelector(`#rpg-edit-quest-form-${field}`);
|
||||
const questItem = widgetContainer.querySelector('.rpg-quest-item[data-field="main"]');
|
||||
|
||||
if (form) form.style.display = 'none';
|
||||
if (questItem) questItem.style.display = 'flex';
|
||||
});
|
||||
});
|
||||
|
||||
// Save edit quest
|
||||
widgetContainer.querySelectorAll('[data-action="save-edit-quest"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const field = btn.dataset.field;
|
||||
const input = widgetContainer.querySelector(`#rpg-edit-quest-${field}`);
|
||||
const questTitle = input?.value.trim();
|
||||
|
||||
if (questTitle) {
|
||||
const settings = getExtensionSettings();
|
||||
settings.quests.main = questTitle;
|
||||
|
||||
// Trigger data change callback
|
||||
onDataChange('quests', 'main', questTitle);
|
||||
|
||||
// Re-render the widget
|
||||
const widgetEl = container.closest('.dashboard-widget');
|
||||
if (widgetEl && widgetEl._widgetInstance) {
|
||||
widgetEl._widgetInstance.render(container, widgetEl._widgetInstance.config);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Remove quest
|
||||
widgetContainer.querySelectorAll('[data-action="remove-quest"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const field = btn.dataset.field;
|
||||
const index = parseInt(btn.dataset.index);
|
||||
const settings = getExtensionSettings();
|
||||
|
||||
if (field === 'main') {
|
||||
settings.quests.main = 'None';
|
||||
onDataChange('quests', 'main', 'None');
|
||||
} else {
|
||||
if (settings.quests.optional && index !== undefined && !isNaN(index)) {
|
||||
settings.quests.optional.splice(index, 1);
|
||||
onDataChange('quests', 'optional', settings.quests.optional);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-render the widget
|
||||
const widgetEl = container.closest('.dashboard-widget');
|
||||
if (widgetEl && widgetEl._widgetInstance) {
|
||||
widgetEl._widgetInstance.render(container, widgetEl._widgetInstance.config);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Inline editing for optional quests
|
||||
widgetContainer.querySelectorAll('.rpg-quest-title.rpg-editable').forEach(el => {
|
||||
el.addEventListener('blur', () => {
|
||||
const field = el.dataset.field;
|
||||
const index = parseInt(el.dataset.index);
|
||||
const newTitle = el.textContent.trim();
|
||||
const settings = getExtensionSettings();
|
||||
|
||||
if (newTitle && field === 'optional' && index !== undefined && !isNaN(index)) {
|
||||
if (settings.quests.optional && settings.quests.optional[index] !== undefined) {
|
||||
settings.quests.optional[index] = newTitle;
|
||||
onDataChange('quests', 'optional', settings.quests.optional);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Enter key to save in forms
|
||||
widgetContainer.querySelectorAll('.rpg-inline-input').forEach(input => {
|
||||
input.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const inputId = input.id;
|
||||
const isEdit = inputId.includes('edit');
|
||||
const field = inputId.replace('rpg-edit-quest-', '').replace('rpg-new-quest-', '');
|
||||
|
||||
const actionBtn = widgetContainer.querySelector(
|
||||
isEdit
|
||||
? `[data-action="save-edit-quest"][data-field="${field}"]`
|
||||
: `[data-action="save-add-quest"][data-field="${field}"]`
|
||||
);
|
||||
|
||||
if (actionBtn) actionBtn.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach all event handlers for quest widget
|
||||
*/
|
||||
function attachQuestHandlers(container, widgetId, quests, state, dependencies) {
|
||||
const { getExtensionSettings } = dependencies;
|
||||
const widgetContainer = container.querySelector('.rpg-quests-widget');
|
||||
|
||||
if (!widgetContainer) return;
|
||||
|
||||
// Sub-tab switching
|
||||
widgetContainer.querySelectorAll('.rpg-inventory-subtab').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const tab = btn.dataset.tab;
|
||||
state.activeSubTab = tab;
|
||||
|
||||
// Re-render the views container inline
|
||||
const settings = getExtensionSettings();
|
||||
const questData = settings.quests || { main: 'None', optional: [] };
|
||||
|
||||
let contentHtml = '';
|
||||
if (tab === 'main') {
|
||||
contentHtml = renderMainQuestView(questData.main);
|
||||
} else {
|
||||
contentHtml = renderOptionalQuestsView(questData.optional || []);
|
||||
}
|
||||
|
||||
widgetContainer.querySelector('.rpg-quests-views').innerHTML = contentHtml;
|
||||
|
||||
// Update active tab styling
|
||||
widgetContainer.querySelectorAll('.rpg-inventory-subtab').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
|
||||
// Re-attach handlers for the new content
|
||||
attachQuestContentHandlers(container, widgetId, state, dependencies);
|
||||
});
|
||||
});
|
||||
|
||||
// Attach content handlers initially
|
||||
attachQuestContentHandlers(container, widgetId, state, dependencies);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register Quests Widget
|
||||
*/
|
||||
export function registerQuestsWidget(registry, dependencies) {
|
||||
const { getExtensionSettings } = dependencies;
|
||||
|
||||
// Widget state (per-instance)
|
||||
const widgetStates = new Map();
|
||||
|
||||
function getWidgetState(widgetId) {
|
||||
if (!widgetStates.has(widgetId)) {
|
||||
widgetStates.set(widgetId, {
|
||||
activeSubTab: 'main'
|
||||
});
|
||||
}
|
||||
return widgetStates.get(widgetId);
|
||||
}
|
||||
|
||||
registry.register('quests', {
|
||||
name: 'Quests',
|
||||
icon: '<i class="fa-solid fa-scroll"></i>',
|
||||
description: 'Quest tracking with main and optional quests',
|
||||
category: 'quests',
|
||||
minSize: { w: 2, h: 4 },
|
||||
// Column-aware sizing: compact on mobile, spacious on desktop
|
||||
defaultSize: (columns) => {
|
||||
if (columns <= 2) {
|
||||
return { w: 2, h: 4 }; // Mobile: 2×4 (full width, compact)
|
||||
}
|
||||
return { w: 2, h: 5 }; // Desktop: 2×5 (default)
|
||||
},
|
||||
maxAutoSize: (columns) => {
|
||||
if (columns <= 2) {
|
||||
return { w: 2, h: 7 }; // Mobile: 2×7 max (increased for expansion headroom)
|
||||
}
|
||||
return { w: 3, h: 7 }; // Desktop: 3×7 max (can expand)
|
||||
},
|
||||
requiresSchema: false,
|
||||
|
||||
render(container, config = {}) {
|
||||
const settings = getExtensionSettings();
|
||||
const quests = settings.quests || {
|
||||
main: 'None',
|
||||
optional: []
|
||||
};
|
||||
|
||||
// Get or create widget state
|
||||
const widgetId = container.closest('.dashboard-widget')?.dataset?.widgetId || 'default';
|
||||
const state = getWidgetState(widgetId);
|
||||
|
||||
// Build HTML
|
||||
let contentHtml = '';
|
||||
if (state.activeSubTab === 'main') {
|
||||
contentHtml = renderMainQuestView(quests.main);
|
||||
} else {
|
||||
contentHtml = renderOptionalQuestsView(quests.optional || []);
|
||||
}
|
||||
|
||||
const html = `
|
||||
<div class="rpg-quests-widget" data-widget-id="${widgetId}">
|
||||
${renderQuestsSubTabs(state.activeSubTab)}
|
||||
<div class="rpg-quests-views">
|
||||
${contentHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Attach event handlers
|
||||
attachQuestHandlers(container, widgetId, quests, state, dependencies);
|
||||
},
|
||||
|
||||
// Called when widget data changes externally
|
||||
onDataUpdate(container, config = {}) {
|
||||
this.render(container, config);
|
||||
},
|
||||
|
||||
// Called when widget is resized
|
||||
onResize(container, newW, newH) {
|
||||
// Re-render widget to update layout for new dimensions
|
||||
this.render(container, this.config || {});
|
||||
|
||||
// Apply width-aware styling
|
||||
const widget = container.querySelector('.rpg-quests-widget');
|
||||
if (widget) {
|
||||
if (newW >= 3) {
|
||||
// Wide layout: constrain title width
|
||||
widget.classList.add('rpg-quests-wide');
|
||||
widget.classList.remove('rpg-quests-compact');
|
||||
} else {
|
||||
// Narrow layout: compact mode with truncated headers
|
||||
widget.classList.remove('rpg-quests-wide');
|
||||
widget.classList.add('rpg-quests-compact');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
/**
|
||||
* Scene Info Grid Widget
|
||||
*
|
||||
* Displays calendar, weather, temperature, clock, and location in a compact
|
||||
* information-dense grid layout. All data points visible at once for maximum
|
||||
* scannability.
|
||||
*
|
||||
* Design: 2-column grid with location header + 4 data cards
|
||||
* Inspiration: Apple Widgets, Material Design, modern dashboard patterns
|
||||
*/
|
||||
|
||||
import { parseInfoBoxData } from './infoBoxWidgets.js';
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
* @param {string} fullDate - Full date string from infoBox
|
||||
* @param {string} weekday - Weekday name
|
||||
* @param {string} month - Month/day description (e.g. "3rd Day of the Ninth Month")
|
||||
* @returns {Object} Formatted date parts
|
||||
*/
|
||||
function formatDate(fullDate, weekday, month) {
|
||||
if (!fullDate && !month) {
|
||||
return { icon: '📅', value: 'No Date', label: '' };
|
||||
}
|
||||
|
||||
// parseInfoBoxData splits date on commas:
|
||||
// "Tuesday, 3rd Day of the Ninth Month, Autumn, Year..." becomes:
|
||||
// weekday = "Tuesday"
|
||||
// month = "3rd Day of the Ninth Month"
|
||||
// year = "Autumn"
|
||||
// Display the most important part (month/day) with weekday as label
|
||||
|
||||
const displayValue = month || fullDate;
|
||||
const displayLabel = weekday || '';
|
||||
|
||||
return {
|
||||
icon: '📅',
|
||||
value: displayValue,
|
||||
label: displayLabel
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time for display
|
||||
* @param {string} timeStart - Start time
|
||||
* @param {string} timeEnd - End time
|
||||
* @returns {Object} Formatted time parts
|
||||
*/
|
||||
function formatTime(timeStart, timeEnd) {
|
||||
const timeDisplay = timeEnd || timeStart || '12:00';
|
||||
|
||||
return {
|
||||
icon: '🕐',
|
||||
value: timeDisplay,
|
||||
label: '' // Could add timezone if available
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format weather for display
|
||||
* @param {string} weatherEmoji - Weather emoji or symbol string
|
||||
* @param {string} weatherForecast - Weather description
|
||||
* @returns {Object} Formatted weather parts
|
||||
*/
|
||||
function formatWeather(weatherEmoji, weatherForecast) {
|
||||
const forecast = weatherForecast || 'Clear';
|
||||
|
||||
// If no emoji provided, display forecast text only
|
||||
if (!weatherEmoji) {
|
||||
return {
|
||||
icon: '',
|
||||
value: forecast,
|
||||
label: ''
|
||||
};
|
||||
}
|
||||
|
||||
// Validate emoji/symbol (relaxed check)
|
||||
// Allow: actual emojis, custom symbols (+++, ***, etc.)
|
||||
const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
|
||||
const symbolRegex = /^[+*#~\-=_]+$/; // Custom weather symbols
|
||||
const looksLikeEmojiOrSymbol = weatherEmoji.length <= 5 && (
|
||||
emojiRegex.test(weatherEmoji) ||
|
||||
symbolRegex.test(weatherEmoji)
|
||||
);
|
||||
|
||||
if (looksLikeEmojiOrSymbol) {
|
||||
// Valid emoji or symbol - append to forecast
|
||||
return {
|
||||
icon: '',
|
||||
value: `${forecast} ${weatherEmoji}`,
|
||||
label: ''
|
||||
};
|
||||
} else {
|
||||
// weatherEmoji is actually text (e.g., "Clear") - combine with forecast
|
||||
// Handles: prose weather like "The air crackles with magical energy"
|
||||
return {
|
||||
icon: '',
|
||||
value: `${weatherEmoji} ${forecast}`.trim(),
|
||||
label: ''
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format temperature for display
|
||||
* @param {string} temperature - Temperature value
|
||||
* @returns {Object} Formatted temperature parts
|
||||
*/
|
||||
function formatTemp(temperature) {
|
||||
if (!temperature) {
|
||||
return { icon: '🌡️', value: '20°C', label: '' };
|
||||
}
|
||||
|
||||
return {
|
||||
icon: '🌡️',
|
||||
value: temperature,
|
||||
label: '' // Could add "Feels like" if available
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format location for display
|
||||
* @param {string} location - Location name
|
||||
* @returns {Object} Formatted location parts
|
||||
*/
|
||||
function formatLocation(location) {
|
||||
if (!location || location === 'Location') {
|
||||
return { value: 'No Location', label: '' };
|
||||
}
|
||||
|
||||
// Split on FIRST comma only to get primary location + context
|
||||
// Preserves hyphens in names (e.g., "Seol Yi-hwan")
|
||||
// Example: "The Winding Stair, Third Floor, East Wing, Palace, City"
|
||||
// -> value: "The Winding Stair", label: "Third Floor, East Wing, Palace, City"
|
||||
const firstCommaIndex = location.indexOf(',');
|
||||
if (firstCommaIndex !== -1 && firstCommaIndex < location.length - 1) {
|
||||
return {
|
||||
value: location.substring(0, firstCommaIndex).trim(),
|
||||
label: location.substring(firstCommaIndex + 1).trim() // Keep all remaining text
|
||||
};
|
||||
}
|
||||
|
||||
// No comma or comma at end - display full text
|
||||
return {
|
||||
value: location,
|
||||
label: ''
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render info grid item
|
||||
* @param {Object} item - Item data
|
||||
* @param {string} item.icon - Icon emoji (optional)
|
||||
* @param {string} item.value - Primary value
|
||||
* @param {string} item.label - Secondary label
|
||||
* @param {string} field - Field name for editing
|
||||
* @param {string} gridArea - CSS grid area name
|
||||
* @returns {string} HTML for grid item
|
||||
*/
|
||||
function renderInfoItem(item, field, gridArea) {
|
||||
const hasLabel = item.label && item.label !== '';
|
||||
const hasIcon = item.icon && item.icon !== '';
|
||||
const areaClass = gridArea ? `rpg-info-${gridArea}` : '';
|
||||
|
||||
return `
|
||||
<div class="rpg-info-item ${areaClass}" data-field="${field}">
|
||||
${hasIcon ? `<span class="item-icon">${item.icon}</span>` : ''}
|
||||
<div class="item-content">
|
||||
<span class="item-value rpg-editable" contenteditable="true" data-field="${field}" title="Click to edit">${item.value}</span>
|
||||
${hasLabel ? `<span class="item-label">${item.label}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render location header (full width)
|
||||
* @param {Object} location - Location data
|
||||
* @returns {string} HTML for location header
|
||||
*/
|
||||
function renderLocationHeader(location) {
|
||||
const hasDescription = location.label && location.label !== '';
|
||||
|
||||
return `
|
||||
<div class="rpg-info-item rpg-info-location" data-field="location">
|
||||
<span class="item-icon">📍</span>
|
||||
<div class="item-content">
|
||||
<span class="item-value rpg-editable" contenteditable="true" data-field="location" title="Click to edit">${location.value}</span>
|
||||
${hasDescription ? `<span class="item-label">${location.label}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach edit handlers to editable fields
|
||||
* @param {HTMLElement} container - Widget container
|
||||
* @param {Object} dependencies - Widget dependencies
|
||||
*/
|
||||
function attachEditHandlers(container, dependencies) {
|
||||
const editableFields = container.querySelectorAll('.rpg-editable');
|
||||
|
||||
editableFields.forEach(field => {
|
||||
const fieldName = field.dataset.field;
|
||||
let originalValue = field.textContent.trim();
|
||||
|
||||
field.addEventListener('focus', () => {
|
||||
originalValue = field.textContent.trim();
|
||||
|
||||
// Select all text on focus
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(field);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
});
|
||||
|
||||
field.addEventListener('blur', () => {
|
||||
const value = field.textContent.trim();
|
||||
if (value && value !== originalValue) {
|
||||
updateInfoBoxField(dependencies, fieldName, value);
|
||||
}
|
||||
});
|
||||
|
||||
field.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
field.blur();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
field.textContent = originalValue;
|
||||
field.blur();
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent paste with formatting
|
||||
field.addEventListener('paste', (e) => {
|
||||
e.preventDefault();
|
||||
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
|
||||
document.execCommand('insertText', false, text);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update info box field in shared data
|
||||
* @param {Object} dependencies - Widget dependencies
|
||||
* @param {string} field - Field name
|
||||
* @param {string} value - New value
|
||||
*/
|
||||
function updateInfoBoxField(dependencies, field, value) {
|
||||
const { getInfoBoxData, setInfoBoxData, onDataChange } = dependencies;
|
||||
let infoBoxData = getInfoBoxData() || '';
|
||||
|
||||
// Simple replace for now - could be more sophisticated
|
||||
const fieldMap = {
|
||||
'date': /Date: [^\n]+/,
|
||||
'time': /Time: [^\n]+/,
|
||||
'weather': /Weather: [^\n]+/,
|
||||
'temperature': /Temperature: [^\n]+/,
|
||||
'location': /Location: [^\n]+/
|
||||
};
|
||||
|
||||
const pattern = fieldMap[field];
|
||||
if (pattern) {
|
||||
const replacement = `${field.charAt(0).toUpperCase() + field.slice(1)}: ${value}`;
|
||||
if (pattern.test(infoBoxData)) {
|
||||
infoBoxData = infoBoxData.replace(pattern, replacement);
|
||||
} else {
|
||||
infoBoxData += `\n${replacement}`;
|
||||
}
|
||||
|
||||
setInfoBoxData(infoBoxData);
|
||||
if (onDataChange) {
|
||||
onDataChange('infoBox', field, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register Scene Info Widget
|
||||
*/
|
||||
export function registerSceneInfoWidget(registry, dependencies) {
|
||||
registry.register('sceneInfo', {
|
||||
name: 'Scene Info',
|
||||
icon: '🗺️',
|
||||
description: 'Compact scene information grid (calendar, weather, time, location)',
|
||||
category: 'scene',
|
||||
minSize: { w: 2, h: 2 },
|
||||
// Column-aware sizing: compact on mobile, spacious on desktop
|
||||
defaultSize: (columns) => {
|
||||
if (columns <= 2) {
|
||||
return { w: 2, h: 2 }; // Mobile: 2×2 (compact, full width)
|
||||
}
|
||||
return { w: 3, h: 3 }; // Desktop: 3×3 (spacious)
|
||||
},
|
||||
maxAutoSize: (columns) => {
|
||||
if (columns <= 2) {
|
||||
return { w: 2, h: 3 }; // Mobile: 2×3 max (full width)
|
||||
}
|
||||
return { w: 3, h: 3 }; // Desktop: 3×3 max
|
||||
},
|
||||
requiresSchema: false,
|
||||
|
||||
/**
|
||||
* Render the widget
|
||||
* @param {HTMLElement} container - Widget container
|
||||
* @param {Object} config - Widget configuration
|
||||
*/
|
||||
render(container, config = {}) {
|
||||
const { getInfoBoxData } = dependencies;
|
||||
const data = parseInfoBoxData(getInfoBoxData());
|
||||
|
||||
// Format data for display
|
||||
const date = formatDate(data.date, data.weekday, data.month);
|
||||
const time = formatTime(data.timeStart, data.timeEnd);
|
||||
const weather = formatWeather(data.weatherEmoji, data.weatherForecast);
|
||||
const temp = formatTemp(data.temperature);
|
||||
const location = formatLocation(data.location);
|
||||
|
||||
// Build grid HTML
|
||||
const html = `
|
||||
<div class="rpg-scene-info-grid">
|
||||
${renderLocationHeader(location)}
|
||||
${renderInfoItem(date, 'date', 'calendar')}
|
||||
${renderInfoItem(time, 'time', 'clock')}
|
||||
${renderInfoItem(weather, 'weather', 'weather')}
|
||||
${renderInfoItem(temp, 'temperature', 'temperature')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
attachEditHandlers(container, dependencies);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get configuration options
|
||||
* @returns {Object} Configuration schema
|
||||
*/
|
||||
getConfig() {
|
||||
return {
|
||||
showLabels: {
|
||||
type: 'boolean',
|
||||
label: 'Show Secondary Labels',
|
||||
default: true,
|
||||
description: 'Show secondary text (weekday, timezone, etc.)'
|
||||
},
|
||||
compactMode: {
|
||||
type: 'boolean',
|
||||
label: 'Compact Mode',
|
||||
default: false,
|
||||
description: 'Reduce padding and font sizes'
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle configuration changes
|
||||
* @param {HTMLElement} container - Widget container
|
||||
* @param {Object} newConfig - New configuration
|
||||
*/
|
||||
onConfigChange(container, newConfig) {
|
||||
this.render(container, newConfig);
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle widget resize
|
||||
* @param {HTMLElement} container - Widget container
|
||||
* @param {number} newW - New width in grid units
|
||||
* @param {number} newH - New height in grid units
|
||||
*/
|
||||
onResize(container, newW, newH) {
|
||||
// Apply compact mode styling at narrow widths (mirrors mobile layout)
|
||||
const grid = container.querySelector('.rpg-scene-info-grid');
|
||||
if (grid) {
|
||||
if (newW < 3) {
|
||||
// Narrow layout: use mobile-like compact sizing
|
||||
grid.classList.add('rpg-scene-info-compact');
|
||||
} else {
|
||||
// Wide layout: use standard sizing
|
||||
grid.classList.remove('rpg-scene-info-compact');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
/**
|
||||
* User Attributes Widget
|
||||
*
|
||||
* Displays customizable RPG attribute scores with +/- adjustment buttons.
|
||||
* Integrates with Tracker Settings for full attribute customization.
|
||||
*
|
||||
* Features:
|
||||
* - Fully customizable attributes (add/remove/rename via Tracker Settings)
|
||||
* - Custom attribute names (e.g., "STRENGTH" instead of "STR", or add "LCK")
|
||||
* - Widget-level filtering (show subset of globally enabled attributes)
|
||||
* - +/- buttons for quick adjustments (1-20 range)
|
||||
* - Responsive 2-column grid layout
|
||||
* - Smart sizing: auto-adjusts height based on attribute count
|
||||
* - Bi-directional sync with Tracker Editor
|
||||
*/
|
||||
|
||||
import { parseNumber } from '../widgetBase.js';
|
||||
|
||||
/**
|
||||
* Register User Attributes Widget
|
||||
* @param {WidgetRegistry} registry - Widget registry instance
|
||||
* @param {Object} dependencies - External dependencies
|
||||
* @param {Function} dependencies.getExtensionSettings - Get extension settings
|
||||
* @param {Function} dependencies.onStatsChange - Callback when stats change
|
||||
*/
|
||||
export function registerUserAttributesWidget(registry, dependencies) {
|
||||
const {
|
||||
getExtensionSettings,
|
||||
onStatsChange
|
||||
} = dependencies;
|
||||
|
||||
registry.register('userAttributes', {
|
||||
name: 'User Attributes',
|
||||
icon: '⚔️',
|
||||
description: 'Customizable RPG attributes with +/- buttons (STR, DEX, etc.)',
|
||||
category: 'user',
|
||||
minSize: { w: 2, h: 2 },
|
||||
defaultSize: { w: 2, h: 2 },
|
||||
maxAutoSize: { w: 3, h: 5 }, // Max size for auto-arrange expansion
|
||||
requiresSchema: false,
|
||||
|
||||
/**
|
||||
* Render widget content
|
||||
* @param {HTMLElement} container - Widget container
|
||||
* @param {Object} config - Widget configuration
|
||||
*/
|
||||
render(container, config = {}) {
|
||||
const settings = getExtensionSettings();
|
||||
const classicStats = settings.classicStats;
|
||||
const trackerConfig = settings.trackerConfig?.userStats;
|
||||
|
||||
// Get globally enabled attributes from trackerConfig
|
||||
const globallyEnabledAttrs = trackerConfig?.rpgAttributes
|
||||
?.filter(attr => attr.enabled)
|
||||
.map(attr => ({ id: attr.id, name: attr.name })) || [];
|
||||
|
||||
// If no globally enabled attrs, fall back to defaults
|
||||
const availableAttrs = globallyEnabledAttrs.length > 0
|
||||
? globallyEnabledAttrs
|
||||
: [
|
||||
{ id: 'str', name: 'STR' },
|
||||
{ id: 'dex', name: 'DEX' },
|
||||
{ id: 'con', name: 'CON' },
|
||||
{ id: 'int', name: 'INT' },
|
||||
{ id: 'wis', name: 'WIS' },
|
||||
{ id: 'cha', name: 'CHA' }
|
||||
];
|
||||
|
||||
// Apply widget-level filter if specified (support both visibleAttrs and legacy visibleStats)
|
||||
let visibleAttrs = availableAttrs;
|
||||
const filterList = config.visibleAttrs || config.visibleStats;
|
||||
if (filterList && filterList.length > 0) {
|
||||
visibleAttrs = availableAttrs.filter(attr =>
|
||||
filterList.includes(attr.id)
|
||||
);
|
||||
}
|
||||
|
||||
// Merge default config
|
||||
const finalConfig = {
|
||||
showLabels: true,
|
||||
...config
|
||||
};
|
||||
|
||||
// Build stats HTML using custom names from trackerConfig
|
||||
const statsHtml = visibleAttrs.map(attr => `
|
||||
<div class="rpg-classic-stat" data-stat="${attr.id}">
|
||||
${finalConfig.showLabels ? `<span class="rpg-classic-stat-label">${attr.name}</span>` : ''}
|
||||
<div class="rpg-classic-stat-buttons">
|
||||
<button class="rpg-classic-stat-btn rpg-stat-decrease" data-stat="${attr.id}">−</button>
|
||||
<span class="rpg-classic-stat-value">${classicStats[attr.id] || 10}</span>
|
||||
<button class="rpg-classic-stat-btn rpg-stat-increase" data-stat="${attr.id}">+</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Calculate optimal column count based on visible attributes and widget width
|
||||
const attrCount = visibleAttrs.length;
|
||||
const widgetWidth = config._width || this.defaultSize.w; // Get from config or default
|
||||
const optimalCols = calculateOptimalColumns(attrCount, widgetWidth);
|
||||
|
||||
// Render HTML with dynamic grid columns
|
||||
const html = `
|
||||
<div class="rpg-classic-stats">
|
||||
<div class="rpg-classic-stats-grid" style="grid-template-columns: repeat(${optimalCols}, 1fr);">
|
||||
${statsHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Attach event handlers
|
||||
attachEventHandlers(container, settings, onStatsChange);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get configuration options
|
||||
* @returns {Object} Configuration schema
|
||||
*/
|
||||
getConfig() {
|
||||
const settings = getExtensionSettings();
|
||||
const trackerConfig = settings.trackerConfig?.userStats;
|
||||
|
||||
// Get enabled attributes from trackerConfig for options
|
||||
const enabledAttrs = trackerConfig?.rpgAttributes
|
||||
?.filter(attr => attr.enabled)
|
||||
.map(attr => ({ value: attr.id, label: attr.name })) || [
|
||||
{ value: 'str', label: 'STR' },
|
||||
{ value: 'dex', label: 'DEX' },
|
||||
{ value: 'con', label: 'CON' },
|
||||
{ value: 'int', label: 'INT' },
|
||||
{ value: 'wis', label: 'WIS' },
|
||||
{ value: 'cha', label: 'CHA' }
|
||||
];
|
||||
|
||||
return {
|
||||
visibleAttrs: {
|
||||
type: 'multiselect',
|
||||
label: 'Visible Attributes',
|
||||
default: null, // null means "show all enabled attributes"
|
||||
options: enabledAttrs,
|
||||
description: 'Select which attributes to show in this widget (leave empty to show all enabled attributes)',
|
||||
hint: 'To add/remove/rename attributes globally, use Tracker Settings'
|
||||
},
|
||||
showLabels: {
|
||||
type: 'boolean',
|
||||
label: 'Show Stat Labels',
|
||||
default: true
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle configuration changes
|
||||
* @param {HTMLElement} container - Widget container
|
||||
* @param {Object} newConfig - New configuration
|
||||
*/
|
||||
onConfigChange(container, newConfig) {
|
||||
this.render(container, newConfig);
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle widget resize
|
||||
* @param {HTMLElement} container - Widget container
|
||||
* @param {number} newW - New width
|
||||
* @param {number} newH - New height
|
||||
*/
|
||||
onResize(container, newW, newH) {
|
||||
const statsGrid = container.querySelector('.rpg-classic-stats-grid');
|
||||
if (!statsGrid) return;
|
||||
|
||||
// Count visible attributes from DOM
|
||||
const attrCount = statsGrid.querySelectorAll('.rpg-classic-stat').length;
|
||||
|
||||
// Get actual pixel width of container (not grid units)
|
||||
// calculateOptimalColumns expects pixel width to determine if 3 columns fit
|
||||
const containerWidth = container.offsetWidth;
|
||||
|
||||
console.log('[UserAttributes] onResize called:', {
|
||||
gridUnits: `${newW}x${newH}`,
|
||||
pixelWidth: containerWidth,
|
||||
attrCount: attrCount
|
||||
});
|
||||
|
||||
// Recalculate optimal columns based on actual pixel width
|
||||
const optimalCols = calculateOptimalColumns(attrCount, containerWidth);
|
||||
|
||||
console.log('[UserAttributes] Calculated optimal columns:', optimalCols);
|
||||
|
||||
// Apply new grid layout
|
||||
statsGrid.style.gridTemplateColumns = `repeat(${optimalCols}, 1fr)`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Calculate optimal size based on content
|
||||
* Used by smart auto-layout to determine ideal widget dimensions
|
||||
* @param {Object} config - Widget configuration
|
||||
* @returns {Object} Optimal size { w, h }
|
||||
*/
|
||||
getOptimalSize(config = {}) {
|
||||
const settings = getExtensionSettings();
|
||||
const trackerConfig = settings.trackerConfig?.userStats;
|
||||
|
||||
// Count globally enabled attributes
|
||||
const globallyEnabledCount = trackerConfig?.rpgAttributes
|
||||
?.filter(attr => attr.enabled).length || 6;
|
||||
|
||||
// If widget has visibleAttrs override, use that count (support legacy visibleStats too)
|
||||
const filterList = config.visibleAttrs || config.visibleStats;
|
||||
const visibleAttrCount = filterList?.length || globallyEnabledCount;
|
||||
|
||||
// Determine optimal width and columns based on attribute count
|
||||
// For 9 attributes: prefer 3 columns (3×3 grid)
|
||||
// For 6 attributes: prefer 2 columns (3×2 grid)
|
||||
// For 12 attributes: prefer 3 columns (4×3 grid)
|
||||
let optimalWidth = 2; // Default
|
||||
if (visibleAttrCount >= 9) {
|
||||
optimalWidth = 3; // Need wider widget for 3+ columns
|
||||
}
|
||||
|
||||
// Calculate optimal columns for this width
|
||||
const optimalCols = calculateOptimalColumns(visibleAttrCount, optimalWidth);
|
||||
const rows = Math.ceil(visibleAttrCount / optimalCols);
|
||||
|
||||
// Each row needs ~0.7 grid units height
|
||||
const optimalHeight = Math.ceil(rows * 0.7 + 0.5);
|
||||
|
||||
return {
|
||||
w: optimalWidth,
|
||||
h: Math.max(this.minSize.h, optimalHeight)
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate optimal column count for attribute grid
|
||||
* Balances visual layout to minimize orphaned items and create square-ish grids
|
||||
*
|
||||
* @param {number} attrCount - Number of attributes to display
|
||||
* @param {number} widgetWidth - Widget width in grid units (1-4)
|
||||
* @returns {number} Optimal column count (1-4)
|
||||
* @private
|
||||
*/
|
||||
function calculateOptimalColumns(attrCount, widgetWidth) {
|
||||
// Special cases
|
||||
if (attrCount === 0) return 1;
|
||||
if (attrCount === 1) return 1;
|
||||
if (widgetWidth < 2) return 1; // Too narrow for multi-column
|
||||
|
||||
// Cap at 4 columns or attrCount (don't create more columns than items)
|
||||
const maxCols = Math.min(4, widgetWidth, attrCount);
|
||||
|
||||
// Try to find a column count that divides evenly (no orphans)
|
||||
for (let cols = maxCols; cols >= 2; cols--) {
|
||||
if (attrCount % cols === 0) {
|
||||
return cols; // Perfect division!
|
||||
}
|
||||
}
|
||||
|
||||
// No perfect division - use heuristic to minimize orphans and prefer square-ish layouts
|
||||
let bestCols = 2;
|
||||
let bestScore = -Infinity;
|
||||
|
||||
for (let cols = 2; cols <= maxCols; cols++) {
|
||||
const rows = Math.ceil(attrCount / cols);
|
||||
const orphans = (cols * rows) - attrCount; // Empty cells in last row
|
||||
const aspectRatio = rows / cols; // Ideal is ~1.0 (square)
|
||||
|
||||
// Score: prefer fewer orphans (heavily weighted) and square-ish layout
|
||||
// orphanPenalty: 1/(orphans+1) gives 1.0 for no orphans, 0.5 for 1 orphan, 0.33 for 2, etc.
|
||||
// aspectScore: 1/(|aspectRatio-1.0|+0.1) gives higher score for square-ish layouts
|
||||
const orphanPenalty = 1 / (orphans + 1);
|
||||
const aspectScore = 1 / (Math.abs(aspectRatio - 1.0) + 0.1);
|
||||
const score = orphanPenalty * 10 + aspectScore; // Weight orphans heavily
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestCols = cols;
|
||||
}
|
||||
}
|
||||
|
||||
return bestCols;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach event handlers to widget
|
||||
* @private
|
||||
*/
|
||||
function attachEventHandlers(container, settings, onStatsChange) {
|
||||
// Handle classic stat +/- buttons
|
||||
const increaseButtons = container.querySelectorAll('.rpg-stat-increase');
|
||||
const decreaseButtons = container.querySelectorAll('.rpg-stat-decrease');
|
||||
|
||||
increaseButtons.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const statName = btn.dataset.stat;
|
||||
const valueSpan = btn.parentElement.querySelector('.rpg-classic-stat-value');
|
||||
const currentValue = parseNumber(valueSpan.textContent, 10, 1, 20);
|
||||
const newValue = Math.min(20, currentValue + 1);
|
||||
|
||||
valueSpan.textContent = newValue;
|
||||
settings.classicStats[statName] = newValue;
|
||||
|
||||
if (onStatsChange) {
|
||||
onStatsChange('classicStats', statName, newValue);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
decreaseButtons.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const statName = btn.dataset.stat;
|
||||
const valueSpan = btn.parentElement.querySelector('.rpg-classic-stat-value');
|
||||
const currentValue = parseNumber(valueSpan.textContent, 10, 1, 20);
|
||||
const newValue = Math.max(1, currentValue - 1);
|
||||
|
||||
valueSpan.textContent = newValue;
|
||||
settings.classicStats[statName] = newValue;
|
||||
|
||||
if (onStatsChange) {
|
||||
onStatsChange('classicStats', statName, newValue);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
/**
|
||||
* User Info Widget
|
||||
*
|
||||
* Displays user avatar, name, and level.
|
||||
* Compact widget showing basic user identity with editable level.
|
||||
*
|
||||
* Features:
|
||||
* - User portrait/avatar display
|
||||
* - User name from SillyTavern context
|
||||
* - Editable level field (1-100)
|
||||
* - Compact horizontal layout
|
||||
*/
|
||||
|
||||
import { parseNumber } from '../widgetBase.js';
|
||||
|
||||
/**
|
||||
* Register User Info Widget
|
||||
* @param {WidgetRegistry} registry - Widget registry instance
|
||||
* @param {Object} dependencies - External dependencies
|
||||
* @param {Function} dependencies.getContext - Get SillyTavern context
|
||||
* @param {Function} dependencies.getUserAvatar - Get user avatar URL
|
||||
* @param {Function} dependencies.getExtensionSettings - Get extension settings
|
||||
* @param {Function} dependencies.onStatsChange - Callback when stats change
|
||||
*/
|
||||
export function registerUserInfoWidget(registry, dependencies) {
|
||||
const {
|
||||
getContext,
|
||||
getUserAvatar,
|
||||
getAvatarUrl,
|
||||
getFallbackAvatar,
|
||||
getExtensionSettings,
|
||||
onStatsChange
|
||||
} = dependencies;
|
||||
|
||||
registry.register('userInfo', {
|
||||
name: 'User Info',
|
||||
icon: '👤',
|
||||
description: 'User avatar, name, and level display',
|
||||
category: 'user',
|
||||
minSize: { w: 1, h: 1 },
|
||||
// Column-aware default size: start at 2x1 in desktop so mood doesn't block expansion
|
||||
defaultSize: (columns) => {
|
||||
if (columns <= 2) {
|
||||
return { w: 1, h: 1 }; // Mobile: 1x1, horizontal layout
|
||||
}
|
||||
return { w: 2, h: 1 }; // Desktop: 2x1 from the start
|
||||
},
|
||||
// Column-aware max size: same as defaultSize to prevent further expansion
|
||||
maxAutoSize: (columns) => {
|
||||
if (columns <= 2) {
|
||||
return { w: 1, h: 1 }; // Mobile: 1x1, horizontal layout
|
||||
}
|
||||
return { w: 2, h: 1 }; // Desktop: 2x1, mood sits in top-right
|
||||
},
|
||||
requiresSchema: false,
|
||||
|
||||
/**
|
||||
* Render widget content
|
||||
* @param {HTMLElement} container - Widget container
|
||||
* @param {Object} config - Widget configuration
|
||||
*/
|
||||
render(container, config = {}) {
|
||||
const settings = getExtensionSettings();
|
||||
const context = getContext();
|
||||
const userName = context.name1;
|
||||
|
||||
// Get user avatar - use getAvatarUrl to convert filename to proper thumbnail URL
|
||||
let userPortrait = getFallbackAvatar();
|
||||
const rawAvatar = getUserAvatar();
|
||||
|
||||
// Convert raw avatar filename to proper thumbnail URL
|
||||
// getAvatarUrl calls getThumbnailUrl which generates URLs like /thumbnail?type=persona&file=...
|
||||
if (rawAvatar) {
|
||||
userPortrait = getAvatarUrl('persona', rawAvatar);
|
||||
}
|
||||
|
||||
// Merge default config
|
||||
const finalConfig = {
|
||||
showAvatar: true,
|
||||
showName: true,
|
||||
showLevel: true,
|
||||
...config
|
||||
};
|
||||
|
||||
// Build HTML with avatar as background and text overlay
|
||||
const backgroundStyle = finalConfig.showAvatar ?
|
||||
`background-image: url('${userPortrait}'); background-size: contain; background-position: center; background-repeat: no-repeat;` :
|
||||
'';
|
||||
|
||||
const html = `
|
||||
<div class="rpg-user-info-container" style="${backgroundStyle}">
|
||||
<div class="rpg-user-info-text">
|
||||
${finalConfig.showName ? `<div class="rpg-user-name">${userName}</div>` : ''}
|
||||
${finalConfig.showLevel ? `
|
||||
<div class="rpg-user-level">
|
||||
<span class="rpg-level-label">LVL</span>
|
||||
<span class="rpg-level-value rpg-editable" contenteditable="true" data-field="level" title="Click to edit level">${settings.level}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Attach event handlers
|
||||
attachEventHandlers(container, settings, onStatsChange);
|
||||
|
||||
// Set initial layout based on current config size
|
||||
if (config.w !== undefined && config.h !== undefined) {
|
||||
this.onResize(container, config.w, config.h);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get configuration options
|
||||
* @returns {Object} Configuration schema
|
||||
*/
|
||||
getConfig() {
|
||||
return {
|
||||
showAvatar: {
|
||||
type: 'boolean',
|
||||
label: 'Show Avatar',
|
||||
default: true
|
||||
},
|
||||
showName: {
|
||||
type: 'boolean',
|
||||
label: 'Show User Name',
|
||||
default: true
|
||||
},
|
||||
showLevel: {
|
||||
type: 'boolean',
|
||||
label: 'Show Level',
|
||||
default: true
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle configuration changes
|
||||
* @param {HTMLElement} container - Widget container
|
||||
* @param {Object} newConfig - New configuration
|
||||
*/
|
||||
onConfigChange(container, newConfig) {
|
||||
this.render(container, newConfig);
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle widget resize
|
||||
* @param {HTMLElement} container - Widget container
|
||||
* @param {number} newW - New width (grid columns)
|
||||
* @param {number} newH - New height (grid rows)
|
||||
*/
|
||||
onResize(container, newW, newH) {
|
||||
const infoContainer = container.querySelector('.rpg-user-info-container');
|
||||
if (!infoContainer) return;
|
||||
|
||||
// Apply compact mode class at narrow widths for smaller text
|
||||
if (newW < 3) {
|
||||
infoContainer.classList.add('rpg-user-info-compact');
|
||||
} else {
|
||||
infoContainer.classList.remove('rpg-user-info-compact');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach event handlers to widget
|
||||
* @private
|
||||
*/
|
||||
function attachEventHandlers(container, settings, onStatsChange) {
|
||||
// Handle level editing
|
||||
const levelValue = container.querySelector('.rpg-level-value.rpg-editable');
|
||||
if (!levelValue) return;
|
||||
|
||||
let originalLevel = parseNumber(levelValue.textContent.trim(), 1, 1, 100);
|
||||
|
||||
levelValue.addEventListener('focus', () => {
|
||||
originalLevel = parseNumber(levelValue.textContent.trim(), 1, 1, 100);
|
||||
// Select all text
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(levelValue);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
});
|
||||
|
||||
levelValue.addEventListener('blur', () => {
|
||||
const value = parseNumber(levelValue.textContent.trim(), originalLevel, 1, 100);
|
||||
levelValue.textContent = value;
|
||||
|
||||
if (value !== originalLevel) {
|
||||
settings.level = value;
|
||||
if (onStatsChange) {
|
||||
onStatsChange('level', null, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
levelValue.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
levelValue.blur();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
levelValue.textContent = originalLevel;
|
||||
levelValue.blur();
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent paste with formatting
|
||||
levelValue.addEventListener('paste', (e) => {
|
||||
e.preventDefault();
|
||||
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
|
||||
document.execCommand('insertText', false, text);
|
||||
});
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
/**
|
||||
* User Mood Widget
|
||||
*
|
||||
* Displays user's current mood emoji and active conditions.
|
||||
* Compact widget showing emotional state and status effects.
|
||||
*
|
||||
* Features:
|
||||
* - Large mood emoji (editable)
|
||||
* - Conditions/status effects text (editable)
|
||||
* - Responsive layout
|
||||
*/
|
||||
|
||||
/**
|
||||
* Register User Mood Widget
|
||||
* @param {WidgetRegistry} registry - Widget registry instance
|
||||
* @param {Object} dependencies - External dependencies
|
||||
* @param {Function} dependencies.getExtensionSettings - Get extension settings
|
||||
* @param {Function} dependencies.onStatsChange - Callback when stats change
|
||||
*/
|
||||
export function registerUserMoodWidget(registry, dependencies) {
|
||||
const {
|
||||
getExtensionSettings,
|
||||
onStatsChange
|
||||
} = dependencies;
|
||||
|
||||
registry.register('userMood', {
|
||||
name: 'User Mood',
|
||||
icon: '😊',
|
||||
description: 'Mood emoji and active conditions',
|
||||
category: 'user',
|
||||
minSize: { w: 1, h: 1 },
|
||||
defaultSize: { w: 1, h: 1 },
|
||||
maxAutoSize: { w: 1, h: 1 }, // Max size for auto-arrange expansion - stays compact in top right
|
||||
requiresSchema: false,
|
||||
|
||||
/**
|
||||
* Render widget content
|
||||
* @param {HTMLElement} container - Widget container
|
||||
* @param {Object} config - Widget configuration
|
||||
*/
|
||||
render(container, config = {}) {
|
||||
const settings = getExtensionSettings();
|
||||
const stats = settings.userStats;
|
||||
|
||||
// Merge default config
|
||||
const finalConfig = {
|
||||
showMoodEmoji: true,
|
||||
showConditions: true,
|
||||
...config
|
||||
};
|
||||
|
||||
// Build HTML
|
||||
const html = `
|
||||
<div class="rpg-mood">
|
||||
${finalConfig.showMoodEmoji ? `<div class="rpg-mood-emoji rpg-editable" contenteditable="true" data-field="mood" title="Click to edit emoji">${stats.mood}</div>` : ''}
|
||||
${finalConfig.showConditions ? `<div class="rpg-mood-conditions rpg-editable" contenteditable="true" data-field="conditions" title="Click to edit conditions">${stats.conditions}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Attach event handlers
|
||||
attachEventHandlers(container, settings, onStatsChange);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get configuration options
|
||||
* @returns {Object} Configuration schema
|
||||
*/
|
||||
getConfig() {
|
||||
return {
|
||||
showMoodEmoji: {
|
||||
type: 'boolean',
|
||||
label: 'Show Mood Emoji',
|
||||
default: true
|
||||
},
|
||||
showConditions: {
|
||||
type: 'boolean',
|
||||
label: 'Show Conditions',
|
||||
default: true
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle configuration changes
|
||||
* @param {HTMLElement} container - Widget container
|
||||
* @param {Object} newConfig - New configuration
|
||||
*/
|
||||
onConfigChange(container, newConfig) {
|
||||
this.render(container, newConfig);
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle widget resize
|
||||
* @param {HTMLElement} container - Widget container
|
||||
* @param {number} newW - New width
|
||||
* @param {number} newH - New height
|
||||
*/
|
||||
onResize(container, newW, newH) {
|
||||
const mood = container.querySelector('.rpg-mood');
|
||||
const emoji = container.querySelector('.rpg-mood-emoji');
|
||||
const conditions = container.querySelector('.rpg-mood-conditions');
|
||||
if (!mood || !emoji || !conditions) return;
|
||||
|
||||
// Scale based on widget size with balanced proportions
|
||||
if (newW >= 2 && newH >= 2) {
|
||||
// Larger widget: scale up proportionally
|
||||
emoji.style.fontSize = '1.4rem';
|
||||
conditions.style.fontSize = '0.9rem';
|
||||
} else {
|
||||
// Compact 1x1: use CSS defaults (0.9rem / 0.6rem)
|
||||
emoji.style.fontSize = '';
|
||||
conditions.style.fontSize = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach event handlers to widget
|
||||
* @private
|
||||
*/
|
||||
function attachEventHandlers(container, settings, onStatsChange) {
|
||||
// Handle mood emoji editing
|
||||
const moodEmoji = container.querySelector('.rpg-mood-emoji.rpg-editable');
|
||||
if (moodEmoji) {
|
||||
let originalMood = moodEmoji.textContent.trim();
|
||||
|
||||
moodEmoji.addEventListener('focus', () => {
|
||||
originalMood = moodEmoji.textContent.trim();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(moodEmoji);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
});
|
||||
|
||||
moodEmoji.addEventListener('blur', () => {
|
||||
const value = moodEmoji.textContent.trim() || '😐';
|
||||
moodEmoji.textContent = value;
|
||||
|
||||
if (value !== originalMood) {
|
||||
settings.userStats.mood = value;
|
||||
if (onStatsChange) {
|
||||
onStatsChange('userStats', 'mood', value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
moodEmoji.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
moodEmoji.blur();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
moodEmoji.textContent = originalMood;
|
||||
moodEmoji.blur();
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent paste with formatting
|
||||
moodEmoji.addEventListener('paste', (e) => {
|
||||
e.preventDefault();
|
||||
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
|
||||
document.execCommand('insertText', false, text);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle conditions editing
|
||||
const moodConditions = container.querySelector('.rpg-mood-conditions.rpg-editable');
|
||||
if (moodConditions) {
|
||||
let originalConditions = moodConditions.textContent.trim();
|
||||
|
||||
moodConditions.addEventListener('focus', () => {
|
||||
originalConditions = moodConditions.textContent.trim();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(moodConditions);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
});
|
||||
|
||||
moodConditions.addEventListener('blur', () => {
|
||||
const value = moodConditions.textContent.trim() || 'None';
|
||||
moodConditions.textContent = value;
|
||||
|
||||
if (value !== originalConditions) {
|
||||
settings.userStats.conditions = value;
|
||||
if (onStatsChange) {
|
||||
onStatsChange('userStats', 'conditions', value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
moodConditions.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
moodConditions.blur();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
moodConditions.textContent = originalConditions;
|
||||
moodConditions.blur();
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent paste with formatting
|
||||
moodConditions.addEventListener('paste', (e) => {
|
||||
e.preventDefault();
|
||||
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
|
||||
document.execCommand('insertText', false, text);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
/**
|
||||
* User Stats Widget (Refactored - Modular)
|
||||
*
|
||||
* Displays user vital statistics as progress bars:
|
||||
* - Health, Satiety, Energy, Hygiene, Arousal
|
||||
*
|
||||
* Features:
|
||||
* - Editable stat values with live update
|
||||
* - Progress bars with customizable colors
|
||||
* - Configurable visible stats
|
||||
* - Smart content-aware sizing (more bars = needs more height)
|
||||
*/
|
||||
|
||||
import { createProgressBar, attachEditableHandlers, parseNumber } from '../widgetBase.js';
|
||||
|
||||
/**
|
||||
* Register User Stats Widget
|
||||
* @param {WidgetRegistry} registry - Widget registry instance
|
||||
* @param {Object} dependencies - External dependencies
|
||||
* @param {Function} dependencies.getContext - Get SillyTavern context
|
||||
* @param {Function} dependencies.getExtensionSettings - Get extension settings
|
||||
* @param {Function} dependencies.onStatsChange - Callback when stats change
|
||||
*/
|
||||
export function registerUserStatsWidget(registry, dependencies) {
|
||||
const {
|
||||
getExtensionSettings,
|
||||
onStatsChange
|
||||
} = dependencies;
|
||||
|
||||
registry.register('userStats', {
|
||||
name: 'User Stats',
|
||||
icon: '❤️',
|
||||
description: 'Health, energy, satiety bars',
|
||||
category: 'user',
|
||||
minSize: { w: 1, h: 2 },
|
||||
defaultSize: { w: 2, h: 2 },
|
||||
// Column-aware max size: full width in 3-4 col for horizontal spread
|
||||
maxAutoSize: (columns) => {
|
||||
if (columns <= 2) {
|
||||
return { w: 2, h: 2 }; // Mobile: use full 2-col width
|
||||
}
|
||||
return { w: 3, h: 3 }; // Desktop: span 3 columns horizontally
|
||||
},
|
||||
requiresSchema: false,
|
||||
|
||||
/**
|
||||
* Render widget content
|
||||
* @param {HTMLElement} container - Widget container
|
||||
* @param {Object} config - Widget configuration
|
||||
*/
|
||||
render(container, config = {}) {
|
||||
const settings = getExtensionSettings();
|
||||
const stats = settings.userStats;
|
||||
const trackerConfig = settings.trackerConfig?.userStats;
|
||||
|
||||
// Get globally enabled stats from trackerConfig
|
||||
const globallyEnabledStats = trackerConfig?.customStats
|
||||
?.filter(stat => stat.enabled)
|
||||
.map(stat => ({ id: stat.id, name: stat.name })) || [];
|
||||
|
||||
// If no globally enabled stats, fall back to defaults
|
||||
const availableStats = globallyEnabledStats.length > 0
|
||||
? globallyEnabledStats
|
||||
: [
|
||||
{ id: 'health', name: 'Health' },
|
||||
{ id: 'satiety', name: 'Satiety' },
|
||||
{ id: 'energy', name: 'Energy' },
|
||||
{ id: 'hygiene', name: 'Hygiene' },
|
||||
{ id: 'arousal', name: 'Arousal' }
|
||||
];
|
||||
|
||||
// Apply widget-level filter if specified (config.visibleStats overrides)
|
||||
let visibleStats = availableStats;
|
||||
if (config.visibleStats && config.visibleStats.length > 0) {
|
||||
visibleStats = availableStats.filter(stat =>
|
||||
config.visibleStats.includes(stat.id)
|
||||
);
|
||||
}
|
||||
|
||||
// Merge default config with user config
|
||||
const finalConfig = {
|
||||
statBarGradient: true,
|
||||
...config
|
||||
};
|
||||
|
||||
// Create gradient for stat bars
|
||||
const gradient = finalConfig.statBarGradient
|
||||
? `linear-gradient(to right, ${settings.statBarColorLow}, ${settings.statBarColorHigh})`
|
||||
: settings.statBarColorHigh;
|
||||
|
||||
// Build progress bars HTML using trackerConfig names
|
||||
const progressBarsHtml = visibleStats.map(stat => {
|
||||
return createProgressBar({
|
||||
label: stat.name,
|
||||
value: stats[stat.id] || 0,
|
||||
gradient,
|
||||
editable: true,
|
||||
field: stat.id
|
||||
});
|
||||
}).join('');
|
||||
|
||||
// Render HTML
|
||||
const html = `
|
||||
<div class="rpg-stats-content rpg-stats-modular">
|
||||
<div class="rpg-stats-grid">
|
||||
${progressBarsHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Attach event handlers
|
||||
attachEventHandlers(container, settings, onStatsChange);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get configuration options
|
||||
* @returns {Object} Configuration schema
|
||||
*/
|
||||
getConfig() {
|
||||
const settings = getExtensionSettings();
|
||||
const trackerConfig = settings.trackerConfig?.userStats;
|
||||
|
||||
// Get enabled stats from trackerConfig for options
|
||||
const enabledStats = trackerConfig?.customStats
|
||||
?.filter(stat => stat.enabled)
|
||||
.map(stat => ({ value: stat.id, label: stat.name })) || [
|
||||
{ value: 'health', label: 'Health' },
|
||||
{ value: 'satiety', label: 'Satiety' },
|
||||
{ value: 'energy', label: 'Energy' },
|
||||
{ value: 'hygiene', label: 'Hygiene' },
|
||||
{ value: 'arousal', label: 'Arousal' }
|
||||
];
|
||||
|
||||
return {
|
||||
statBarGradient: {
|
||||
type: 'boolean',
|
||||
label: 'Use Gradient for Stat Bars',
|
||||
default: true,
|
||||
description: 'Show progress bars with color gradient from low to high'
|
||||
},
|
||||
visibleStats: {
|
||||
type: 'multiselect',
|
||||
label: 'Visible Stats',
|
||||
default: null, // null means "show all enabled stats"
|
||||
options: enabledStats,
|
||||
description: 'Select which stats to show in this widget (leave empty to show all enabled stats)',
|
||||
hint: 'To add/remove/rename stats globally, use Tracker Settings'
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle configuration changes
|
||||
* @param {HTMLElement} container - Widget container
|
||||
* @param {Object} newConfig - New configuration
|
||||
*/
|
||||
onConfigChange(container, newConfig) {
|
||||
// Re-render with new config
|
||||
this.render(container, newConfig);
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle widget resize
|
||||
* @param {HTMLElement} container - Widget container
|
||||
* @param {number} newW - New width
|
||||
* @param {number} newH - New height
|
||||
*/
|
||||
onResize(container, newW, newH) {
|
||||
// Layout adjustments if needed (currently none)
|
||||
},
|
||||
|
||||
/**
|
||||
* Calculate optimal size based on content
|
||||
* Used by smart auto-layout to determine ideal widget dimensions
|
||||
* @param {Object} config - Widget configuration
|
||||
* @returns {Object} Optimal size { w, h }
|
||||
*/
|
||||
getOptimalSize(config = {}) {
|
||||
const settings = getExtensionSettings();
|
||||
const trackerConfig = settings.trackerConfig?.userStats;
|
||||
|
||||
// Count globally enabled stats
|
||||
const globallyEnabledCount = trackerConfig?.customStats
|
||||
?.filter(stat => stat.enabled).length || 5;
|
||||
|
||||
// If widget has visibleStats override, use that count
|
||||
const visibleStatCount = config.visibleStats?.length || globallyEnabledCount;
|
||||
|
||||
// Each stat bar needs ~0.4 rows of height
|
||||
// Add 0.5 row for padding/margins
|
||||
const optimalHeight = Math.ceil(visibleStatCount * 0.4 + 0.5);
|
||||
|
||||
return {
|
||||
w: 2, // Prefer full width for readability
|
||||
h: Math.max(this.minSize.h, optimalHeight)
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach event handlers to widget
|
||||
* @private
|
||||
*/
|
||||
function attachEventHandlers(container, settings, onStatsChange) {
|
||||
// Handle editable stat value changes (health, satiety, etc.)
|
||||
const editableStats = container.querySelectorAll('.rpg-editable-stat');
|
||||
editableStats.forEach(field => {
|
||||
const fieldName = field.dataset.field;
|
||||
let originalValue = parseNumber(field.textContent.replace('%', '').trim(), 0, 0, 100);
|
||||
|
||||
field.addEventListener('focus', () => {
|
||||
originalValue = parseNumber(field.textContent.replace('%', '').trim(), 0, 0, 100);
|
||||
// Select all text
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(field);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
});
|
||||
|
||||
field.addEventListener('blur', () => {
|
||||
const textValue = field.textContent.replace('%', '').trim();
|
||||
const value = parseNumber(textValue, originalValue, 0, 100);
|
||||
|
||||
// Update display
|
||||
field.textContent = `${value}%`;
|
||||
|
||||
// Update settings if changed
|
||||
if (value !== originalValue) {
|
||||
settings.userStats[fieldName] = value;
|
||||
|
||||
// Update the bar fill
|
||||
const bar = field.parentElement.querySelector('.rpg-stat-fill');
|
||||
if (bar) {
|
||||
bar.style.width = `${100 - value}%`;
|
||||
}
|
||||
|
||||
// Trigger change callback
|
||||
if (onStatsChange) {
|
||||
onStatsChange('userStats', fieldName, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
field.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
field.blur();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
field.textContent = `${originalValue}%`;
|
||||
field.blur();
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent paste with formatting
|
||||
field.addEventListener('paste', (e) => {
|
||||
e.preventDefault();
|
||||
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
|
||||
document.execCommand('insertText', false, text);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
import { saveChatData } from '../../core/persistence.js';
|
||||
import { generateSeparateUpdatePrompt } from './promptBuilder.js';
|
||||
import { parseResponse, parseUserStats } from './parser.js';
|
||||
import { refreshDashboard } from '../dashboard/dashboardIntegration.js';
|
||||
import { renderUserStats } from '../rendering/userStats.js';
|
||||
import { renderInfoBox } from '../rendering/infoBox.js';
|
||||
import { renderThoughts } from '../rendering/thoughts.js';
|
||||
@@ -161,18 +160,16 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
|
||||
|
||||
// console.log('[RPG Companion] Stored separate mode RPG data for message swipe', currentSwipeId);
|
||||
|
||||
// Update lastGeneratedData for display AND future commit, plus extensionSettings for dashboard widgets
|
||||
// Update lastGeneratedData for display AND future commit
|
||||
if (parsedData.userStats) {
|
||||
lastGeneratedData.userStats = parsedData.userStats;
|
||||
parseUserStats(parsedData.userStats); // Updates extensionSettings.userStats
|
||||
parseUserStats(parsedData.userStats);
|
||||
}
|
||||
if (parsedData.infoBox) {
|
||||
lastGeneratedData.infoBox = parsedData.infoBox;
|
||||
extensionSettings.infoBoxData = parsedData.infoBox; // Update for dashboard widgets
|
||||
}
|
||||
if (parsedData.characterThoughts) {
|
||||
lastGeneratedData.characterThoughts = parsedData.characterThoughts;
|
||||
extensionSettings.characterThoughts = parsedData.characterThoughts; // Update for dashboard widgets
|
||||
}
|
||||
// console.log('[RPG Companion] 💾 SEPARATE MODE: Updated lastGeneratedData:', {
|
||||
// userStats: lastGeneratedData.userStats ? 'exists' : 'null',
|
||||
@@ -196,15 +193,12 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
|
||||
// console.log('[RPG Companion] 🔆 FIRST TIME: Auto-committed tracker data');
|
||||
}
|
||||
|
||||
// Render the updated data (old panel UI)
|
||||
// Render the updated data
|
||||
renderUserStats();
|
||||
renderInfoBox();
|
||||
renderThoughts();
|
||||
renderInventory();
|
||||
renderQuests();
|
||||
|
||||
// Refresh dashboard widgets (v2 dashboard)
|
||||
refreshDashboard();
|
||||
} else {
|
||||
// No assistant message to attach to - just update display
|
||||
if (parsedData.userStats) {
|
||||
@@ -215,9 +209,6 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
|
||||
renderThoughts();
|
||||
renderInventory();
|
||||
renderQuests();
|
||||
|
||||
// Refresh dashboard widgets (v2 dashboard)
|
||||
refreshDashboard();
|
||||
}
|
||||
|
||||
// Save to chat metadata
|
||||
|
||||
@@ -16,8 +16,7 @@ import {
|
||||
setLastActionWasSwipe,
|
||||
setIsPlotProgression,
|
||||
updateLastGeneratedData,
|
||||
updateCommittedTrackerData,
|
||||
FALLBACK_AVATAR_DATA_URI
|
||||
updateCommittedTrackerData
|
||||
} from '../../core/state.js';
|
||||
import { saveChatData, loadChatData } from '../../core/persistence.js';
|
||||
|
||||
@@ -32,9 +31,6 @@ import { renderThoughts, updateChatThoughts } from '../rendering/thoughts.js';
|
||||
import { renderInventory } from '../rendering/inventory.js';
|
||||
import { renderQuests } from '../rendering/quests.js';
|
||||
|
||||
// Dashboard
|
||||
import { refreshDashboard } from '../dashboard/dashboardIntegration.js';
|
||||
|
||||
// Utils
|
||||
import { getSafeThumbnailUrl } from '../../utils/avatars.js';
|
||||
|
||||
@@ -103,26 +99,18 @@ export async function onMessageReceived(data) {
|
||||
// console.log('[RPG Companion] Parsing together mode response:', responseText);
|
||||
|
||||
const parsedData = parseResponse(responseText);
|
||||
// console.log('[RPG Companion] Parsed data results:', {
|
||||
// hasUserStats: !!parsedData.userStats,
|
||||
// hasInfoBox: !!parsedData.infoBox,
|
||||
// hasCharacterThoughts: !!parsedData.characterThoughts
|
||||
// });
|
||||
// console.log('[RPG Companion] Parsed data:', parsedData);
|
||||
|
||||
// Update stored data (both lastGeneratedData for old UI and extensionSettings for dashboard widgets)
|
||||
// Update stored data
|
||||
if (parsedData.userStats) {
|
||||
lastGeneratedData.userStats = parsedData.userStats;
|
||||
parseUserStats(parsedData.userStats); // Updates extensionSettings.userStats
|
||||
parseUserStats(parsedData.userStats);
|
||||
}
|
||||
if (parsedData.infoBox) {
|
||||
lastGeneratedData.infoBox = parsedData.infoBox;
|
||||
extensionSettings.infoBoxData = parsedData.infoBox; // Update for dashboard widgets
|
||||
console.log('[RPG Companion] Updated extensionSettings.infoBoxData:', extensionSettings.infoBoxData.substring(0, 100));
|
||||
}
|
||||
if (parsedData.characterThoughts) {
|
||||
lastGeneratedData.characterThoughts = parsedData.characterThoughts;
|
||||
extensionSettings.characterThoughts = parsedData.characterThoughts; // Update for dashboard widgets
|
||||
console.log('[RPG Companion] Updated extensionSettings.characterThoughts:', extensionSettings.characterThoughts.substring(0, 100));
|
||||
}
|
||||
|
||||
// Store RPG data for this specific swipe in the message's extra field
|
||||
@@ -178,9 +166,6 @@ export async function onMessageReceived(data) {
|
||||
renderInventory();
|
||||
renderQuests();
|
||||
|
||||
// Refresh dashboard widgets (v2 dashboard)
|
||||
refreshDashboard();
|
||||
|
||||
// Then update the DOM to reflect the cleaned message
|
||||
const lastMessageElement = $('#chat').children('.mes').last();
|
||||
if (lastMessageElement.length) {
|
||||
@@ -237,14 +222,6 @@ export function onCharacterChanged() {
|
||||
// already contains the committed state from when we last left this chat.
|
||||
// commitTrackerData() will be called naturally when new messages arrive.
|
||||
|
||||
// Populate extensionSettings for dashboard widgets from loaded chat data
|
||||
if (lastGeneratedData.infoBox) {
|
||||
extensionSettings.infoBoxData = lastGeneratedData.infoBox;
|
||||
}
|
||||
if (lastGeneratedData.characterThoughts) {
|
||||
extensionSettings.characterThoughts = lastGeneratedData.characterThoughts;
|
||||
}
|
||||
|
||||
// Re-render with the loaded data
|
||||
renderUserStats();
|
||||
renderInfoBox();
|
||||
@@ -252,9 +229,6 @@ export function onCharacterChanged() {
|
||||
renderInventory();
|
||||
renderQuests();
|
||||
|
||||
// Refresh dashboard widgets (v2 dashboard)
|
||||
refreshDashboard();
|
||||
|
||||
// Update chat thought overlays
|
||||
updateChatThoughts();
|
||||
}
|
||||
@@ -333,12 +307,11 @@ export function onMessageSwiped(messageIndex) {
|
||||
|
||||
/**
|
||||
* Update the persona avatar image when user switches personas
|
||||
* Updates ALL .rpg-user-portrait elements with proper fallback handling
|
||||
*/
|
||||
export function updatePersonaAvatar() {
|
||||
const portraitImgs = document.querySelectorAll('.rpg-user-portrait');
|
||||
if (portraitImgs.length === 0) {
|
||||
// console.log('[RPG Companion] No portrait image elements found in DOM');
|
||||
const portraitImg = document.querySelector('.rpg-user-portrait');
|
||||
if (!portraitImg) {
|
||||
// console.log('[RPG Companion] Portrait image element not found in DOM');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -346,27 +319,24 @@ export function updatePersonaAvatar() {
|
||||
const context = getContext();
|
||||
const currentUserAvatar = context.user_avatar || user_avatar;
|
||||
|
||||
// console.log('[RPG Companion] Updating', portraitImgs.length, 'avatar(s) for:', currentUserAvatar);
|
||||
// console.log('[RPG Companion] Attempting to update persona avatar:', currentUserAvatar);
|
||||
|
||||
// Update each avatar instance
|
||||
portraitImgs.forEach(portraitImg => {
|
||||
// getSafeThumbnailUrl already calls getThumbnailUrl and handles errors
|
||||
// It returns proper URLs like /thumbnail?type=persona&file=... or null
|
||||
const thumbnailUrl = currentUserAvatar ? getSafeThumbnailUrl('persona', currentUserAvatar) : null;
|
||||
const finalUrl = thumbnailUrl || FALLBACK_AVATAR_DATA_URI;
|
||||
// Try to get a valid thumbnail URL using our safe helper
|
||||
if (currentUserAvatar) {
|
||||
const thumbnailUrl = getSafeThumbnailUrl('persona', currentUserAvatar);
|
||||
|
||||
// Set the avatar URL
|
||||
portraitImg.src = finalUrl;
|
||||
|
||||
// Add onerror handler to use fallback if load fails (404, etc.)
|
||||
portraitImg.onerror = () => {
|
||||
if (portraitImg.src !== FALLBACK_AVATAR_DATA_URI) {
|
||||
// console.warn('[RPG Companion] Avatar failed to load, using fallback');
|
||||
portraitImg.src = FALLBACK_AVATAR_DATA_URI;
|
||||
portraitImg.onerror = null; // Prevent infinite loop
|
||||
if (thumbnailUrl) {
|
||||
// Only update the src if we got a valid URL
|
||||
portraitImg.src = thumbnailUrl;
|
||||
// console.log('[RPG Companion] Persona avatar updated successfully');
|
||||
} else {
|
||||
// Don't update the src if we couldn't get a valid URL
|
||||
// This prevents 400 errors and keeps the existing image
|
||||
// console.warn('[RPG Companion] Could not get valid thumbnail URL for persona avatar, keeping existing image');
|
||||
}
|
||||
} else {
|
||||
// console.log('[RPG Companion] No user avatar configured, keeping existing image');
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -509,13 +509,6 @@ export function setupMobileTabs() {
|
||||
const isMobile = window.innerWidth <= 1000;
|
||||
if (!isMobile) return;
|
||||
|
||||
// Check if Dashboard v2 is present - if so, skip mobile tabs (dashboard has its own tab system)
|
||||
const $dashboardContainer = $('#rpg-dashboard-container');
|
||||
if ($dashboardContainer.length > 0) {
|
||||
console.log('[RPG Mobile] Dashboard v2 detected - skipping old mobile tabs setup');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if tabs already exist
|
||||
if ($('.rpg-mobile-tabs').length > 0) return;
|
||||
|
||||
|
||||
@@ -47,12 +47,6 @@ export class DiceModal {
|
||||
open() {
|
||||
if (this.isAnimating) return;
|
||||
|
||||
// CRITICAL: Move modal to document.body on first use to escape any container constraints
|
||||
if (this.modal.parentElement?.tagName !== 'BODY') {
|
||||
document.body.appendChild(this.modal);
|
||||
console.log('[DiceModal] Moved modal to document.body to ensure proper viewport positioning');
|
||||
}
|
||||
|
||||
// Apply theme
|
||||
const theme = extensionSettings.theme;
|
||||
this.modal.setAttribute('data-theme', theme);
|
||||
|
||||
@@ -108,19 +108,10 @@ function applyTrackerConfig() {
|
||||
tempConfig = null; // Clear temp config
|
||||
saveSettings();
|
||||
|
||||
// Re-render all trackers with new config (v1 system - backward compat)
|
||||
// Re-render all trackers with new config
|
||||
renderUserStats();
|
||||
renderInfoBox();
|
||||
renderThoughts();
|
||||
|
||||
// Notify dashboard system of config changes (v2 system - reactive integration)
|
||||
document.dispatchEvent(new CustomEvent('rpg:trackerConfigChanged', {
|
||||
detail: {
|
||||
config: extensionSettings.trackerConfig,
|
||||
source: 'trackerEditor'
|
||||
}
|
||||
}));
|
||||
console.log('[RPG Companion] Tracker config changed event dispatched');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+9
-6
@@ -59,6 +59,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HTML Prompt Toggle -->
|
||||
<div class="rpg-toggle-container">
|
||||
<label class="rpg-toggle-label">
|
||||
<input type="checkbox" id="rpg-toggle-html-prompt">
|
||||
<i class="fa-solid fa-code"></i>
|
||||
<span>Enable Immersive HTML</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Manual Update Button -->
|
||||
<button id="rpg-manual-update" class="rpg-btn-primary rpg-manual-update-btn">
|
||||
<i class="fa-solid fa-sync"></i> Refresh RPG Info
|
||||
@@ -350,12 +359,6 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Help Text -->
|
||||
<div class="rpg-editor-help">
|
||||
<i class="fa-solid fa-circle-info"></i>
|
||||
<span><strong>Tracker Settings</strong> control available fields, names, and AI instructions. To arrange widgets on your dashboard, use <strong>Edit Layout</strong> mode.</span>
|
||||
</div>
|
||||
|
||||
<div class="rpg-settings-popup-body">
|
||||
<!-- Tab contents will be rendered by JavaScript -->
|
||||
<div id="rpg-editor-tab-userStats" class="rpg-editor-tab-content"></div>
|
||||
|
||||
Reference in New Issue
Block a user