Compare commits

...

2 Commits

Author SHA1 Message Date
Spicy Marinara fd9adce068 Revert "feat: v2 widget dashboard system" 2025-11-06 20:06:26 +01:00
Spicy Marinara ba45e499e1 Merge pull request #42 from SpicyMarinara/revert-40-feat/responsive-dashboard-layout
Revert "feat: responsive dashboard layout"
2025-11-06 20:05:52 +01:00
51 changed files with 199 additions and 28555 deletions
File diff suppressed because it is too large Load Diff
-266
View File
@@ -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
-869
View File
@@ -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)
+6 -84
View File
@@ -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
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderQuests();
}
// Setup remaining UI components
// Render initial data if available
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderQuests();
updateDiceDisplay();
setupDiceRoller();
setupClassicStatsButtons();
-6
View File
@@ -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
-15
View File
@@ -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
View File
@@ -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
};
/**
-251
View File
@@ -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>
-350
View File
@@ -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>
-644
View File
@@ -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
-691
View File
@@ -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();
}
}
-710
View File
@@ -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');
}
}
-463
View File
@@ -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
-230
View File
@@ -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);
});
}
-667
View File
@@ -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>
-220
View File
@@ -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');
}
}
-626
View File
@@ -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');
}
}
-394
View File
@@ -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>
-724
View File
@@ -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>
-258
View File
@@ -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');
}
}
-467
View File
@@ -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>
-472
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/**
* 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>
`;
}
-255
View File
@@ -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);
});
});
}
+3 -12
View File
@@ -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
+23 -53
View File
@@ -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');
}
}
/**
-7
View File
@@ -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;
-6
View File
@@ -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);
+1 -10
View File
@@ -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');
}
/**
+156 -1857
View File
File diff suppressed because it is too large Load Diff
+9 -6
View File
@@ -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>