Compare commits

..

170 Commits

Author SHA1 Message Date
Spicy_Marinara f3deead868 v3.4.1: Fix Present Characters not included in <previous> section for separate generation mode
- Fixed bug where Present Characters data wasn't appearing in the <previous> section when generating new trackers in separate mode
- Root cause: committedTrackerData.characterThoughts is stored as a JS array, not a JSON string
- Solution: Check data type before parsing - handle both object/array and string formats
- Present Characters data now correctly included in unified previous tracker JSON regardless of showCharacterThoughts setting
2026-01-11 00:17:49 +01:00
Spicy_Marinara d5d649f122 Update promptBuilder.js 2026-01-10 21:21:25 +01:00
Spicy Marinara 0cd764c39b Merge pull request #90 from tomt610/feature/history-persistence
Feature/history persistence
2026-01-10 20:35:03 +01:00
tomt610 b9a15722d6 Fix history injection for prewarm extensions
- Use persistent event listeners instead of once() to inject into ALL generations
- Don't clear context map on GENERATION_ENDED so prewarm gets the same context
- Remove unused onGenerationEndedCleanup function
2026-01-10 19:33:26 +00:00
Spicy_Marinara 995f3a7a98 Add Deception System and CYOA features with toggles, custom prompts, and proper injection ordering 2026-01-10 20:24:41 +01:00
tomt610 db97f012b0 Refactor history injection to modify prompts instead of chat messages
This prevents any risk of injected context being accidentally saved to the chat.
Instead of modifying chat[].mes directly, we now:
1. Build a context map during GENERATION_STARTED
2. Inject into the prompt string (GENERATE_AFTER_COMBINE_PROMPTS) for text completion
3. Inject into the message array (CHAT_COMPLETION_PROMPT_READY) for chat completion

The original chat messages are never modified.
2026-01-10 19:10:33 +00:00
Spicy_Marinara 681b8ba2bc Merge remote-tracking branch 'tomt610/feature/fab-widgets' into test-pr90-pr91-combined 2026-01-10 16:47:15 +01:00
Spicy_Marinara 2d961936c2 Merge remote-tracking branch 'tomt610/feature/history-persistence' into test-pr90-pr91-combined 2026-01-10 16:47:12 +01:00
Spicy_Marinara b534bd4c71 Update README.md 2026-01-10 16:41:27 +01:00
tomt610 73cbb27713 feat(mobile): Add FAB widgets with info display around toggle button
- Add 8-position widget system around mobile FAB button (N, NE, E, SE, S, SW, W, NW)
- Display weather icon, weather description, time, date, location around FAB
- Show stats and RPG attributes in larger West/Northwest positions
- Add animated clock face matching main panel design
- Implement expandable text on hover/tap for truncated content
- Add FAB spinner animation during API requests
- Respect tracker preset settings for filtering displayed stats/attributes
- Sync FAB data with lastGeneratedData for real-time updates
- Hide FAB widgets on desktop viewport (>1000px) and when panel is open
- Add settings UI for enabling/disabling individual widget types
- Update FAB widgets on manual edits in tracker editor and stats panels
2026-01-10 13:25:40 +00:00
tomt610 db2bed16a7 Clean up debug logging in history injection 2026-01-09 21:14:21 +00:00
tomt610 ecb5d74d6e Enhance preset saving and loading to include historyPersistence 2026-01-09 21:05:11 +00:00
tomt610 fea59efe4e Refactor historical context handling and remove unused initialization function 2026-01-09 20:51:28 +00:00
tomt610 b43cca5b6f Refactor message injection options in promptBuilder and trackerEditor 2026-01-09 20:24:07 +00:00
tomt610 94f562f1bb Refactor message restoration logic to use a one-time event listener 2026-01-09 20:20:04 +00:00
tomt610 3d5fc5fee1 Refactor historical context injection logic to support dynamic message indexing based on injection position 2026-01-09 20:10:21 +00:00
tomt610 98ef751a9f Implement historical context injection for chat messages and enhance settings for persistence 2026-01-09 19:39:05 +00:00
Spicy_Marinara f5641ec1f0 Merge branch 'pr-88' 2026-01-09 12:11:51 +01:00
Spicy_Marinara 0320c3fdd5 Fix duplicate keys in en.json and add missing periods to user-facing messages 2026-01-09 12:08:53 +01:00
Spicy_Marinara 3d0ebe4694 Update en.json 2026-01-09 12:00:55 +01:00
Spicy_Marinara 510723cac4 v3.3.3
- Strengthened default prompts to not include user's persona in the characters' section.
- Updated some descriptions for buttons and custom fields.
2026-01-09 11:54:43 +01:00
tomt610 f6733f87a2 feat: Add preset management system for tracker configurations
- Add preset selector dropdown in tracker editor modal
- Support creating, loading, and deleting presets
- Add per-character/group preset associations with auto-switch
- Add default preset functionality with star button
- Update import to offer 'Apply to Current' or 'Create New Preset' options
- Add preset management UI styles and import dialog styles
2026-01-09 10:38:57 +00:00
Spicy_Marinara ddc02d9bbc Release v3.3.2: Fix auto-update on chat switch & restore character removal 2026-01-09 10:04:29 +01:00
Spicy Marinara 659b5bb82b Merge pull request #87 from tomt610/fix/quest-removal-sync
Fix: Sync quest changes to committedTrackerData
2026-01-09 09:29:28 +01:00
tomt610 5f72e6f549 Fix: Sync quest changes to committedTrackerData
When manually adding/editing/removing quests via UI, the changes were
only saved to extensionSettings but not to committedTrackerData.userStats.
This caused the AI to see stale quest data on the next external server
update, resulting in removed quests reappearing.

- Add syncQuestsToCommittedData() function to update JSON quest data
- Call sync and saveChatData() on all quest modification actions
- Imports committedTrackerData, lastGeneratedData, saveChatData
2026-01-09 00:23:23 +00:00
Spicy_Marinara 0d71dcca04 v3.3.1: Fix Recent Events reading from lastGeneratedData and add desktop thought panel collapse 2026-01-08 23:29:18 +01:00
Spicy Marinara 39e2a07829 Merge pull request #85 from tomt610/feature/update-complete-event
Add event emission when tracker update completes
2026-01-08 23:18:10 +01:00
tomt610 dedfead59e Add event emission when tracker update completes
Emits 'rpg_companion_update_complete' event after updateRPGData() finishes.
This allows other extensions (like Context Prewarm) to hook into the
completion of tracker updates and perform actions afterward.

The event is emitted in the finally block, so it fires regardless of
success or failure, after isGenerating is reset.
2026-01-08 22:12:06 +00:00
Spicy_Marinara f1179d3b83 v3.3.0: Fix encounter UI theming and JSON cleaning regex properties 2026-01-08 21:52:31 +01:00
Spicy_Marinara 045d1da88b Update README.md 2026-01-08 19:50:20 +01:00
Spicy_Marinara bd056934e1 v3.2.5: Always enable JSON cleaning regex with proper settings 2026-01-08 18:27:16 +01:00
Spicy_Marinara e5bd1e0411 v3.2.4: Fix Present Characters field editing - relationship badges, custom fields, and avatar upload 2026-01-08 18:13:12 +01:00
Spicy_Marinara 055c19951c v3.2.3: Restore avatar upload feature for Present Characters 2026-01-08 13:05:02 +01:00
Spicy_Marinara d41996fb04 v3.2.2: Remove fixed max-width on mobile to support high zoom levels 2026-01-08 12:45:20 +01:00
Spicy_Marinara 2624309523 v3.2.1: Fix mobile viewport height issues with dvh units 2026-01-08 11:18:09 +01:00
Spicy_Marinara 7e9d98738f v3.2.0: Major update with JSON trackers, locking system, and UI improvements 2026-01-08 10:35:54 +01:00
Spicy_Marinara be05051a39 v3.1.1: Fix mobile tabs not initializing on mobile devices 2026-01-08 00:12:19 +01:00
Spicy_Marinara a3063aff4f v3.1.0: Add parser error detection and recommended models section 2026-01-07 22:56:26 +01:00
Spicy_Marinara dbf5c2d17a Merge branch 'main' of https://github.com/SpicyMarinara/rpg-companion-sillytavern 2026-01-07 21:29:20 +01:00
Spicy_Marinara 718d45095d v3.0.1 - Fix thought bubble editing persistence 2026-01-07 20:26:11 +01:00
Spicy_Marinara 897380d532 v3.0.1 - Fix thought bubble editing persistence 2026-01-07 20:25:49 +01:00
Spicy_Marinara 93327e4416 Merge remote changes for v3.0.0 release 2026-01-07 19:52:51 +01:00
Spicy_Marinara c3cdac24c6 Release v3.0.0 - Major update with JSON format, lock/unlock trackers, reorganized UI, colored dialogues, editable prompts, and numerous bug fixes 2026-01-07 17:22:22 +01:00
Spicy Marinara f536472dbe Merge pull request #82 from munimunigamer/auto-update-ext
Fixed auto updating with external api mode
2026-01-06 01:01:16 +01:00
munimunigamer 5bba422904 fixed auto updating with external api mode 2026-01-05 23:58:48 -06:00
Spicy_Marinara 8df6548e0b v2.1.3 - Improved thought bubble positioning and responsiveness
- Align thought bubbles with avatar top instead of center for better visibility
- Fix issue where bubbles extend above avatar when scrolling is limited
- Change thought circles to horizontal layout for cleaner visual flow
- Add responsive positioning that adapts to screen width changes
- Implement smart viewport detection to prevent cutoff at narrow widths
2026-01-03 11:40:07 +01:00
Spicy_Marinara 58020e93d0 Fix: Ensure clothing instruction is included in separate generation prompts 2026-01-03 01:17:41 +01:00
Spicy_Marinara ef03bb11ee Update version to 2.1.2 in settings.html 2026-01-03 00:59:07 +01:00
Spicy_Marinara d75f76b807 Update README with v2.1.2 changelog 2026-01-03 00:58:22 +01:00
Spicy_Marinara c6b71ec1aa Add optional toggle for Relationship Status Fields (v2.1.2)
- Added relationships.enabled toggle in tracker configuration
- Relationship fields and emoji badges can now be disabled/enabled
- UI toggle added in Edit Trackers > Present Characters tab
- Updated prompt generation to respect the toggle
- Maintains backward compatibility with existing configs
- Added i18n translations (en, zh-tw)
2026-01-03 00:55:29 +01:00
Spicy_Marinara d44bb1cff9 v2.1.1: Fix swipe detection in together mode and combat encounter prompt 2026-01-02 20:58:49 +01:00
Spicy_Marinara 87f0931942 Update README with v2.1 release notes 2026-01-02 14:09:13 +01:00
Spicy_Marinara 62ed7ffb18 v2.1: Add dynamic weather effects, clothing inventory, and bug fixes
Features:
- Add dynamic weather effects system (snow, rain, mist, sunshine, storm, wind, blizzard)
- Add separate Clothing tab in inventory system
- Weather effects auto-update based on Info Box weather field
- Combined effects for storm (rain+lightning) and blizzard (snow+wind)

Improvements:
- Settings migration system for automatic feature enablement
- Weather effects positioned behind chat interface (z-index: 1)
- Dynamic weather enabled by default for new users

Bug Fixes:
- Fix tab visibility issues (disabled tabs now properly hide)
- Fix theme-aware borders (remove hardcoded blue colors)
- Fix double scrollbar in Edit Trackers window
- Fix scroll position jumping when editing Present Characters
- Fix dynamic weather toggle hiding issue

Technical:
- Update inventory schema to v2.1 with clothing field
- Add automatic migration for existing v2 inventories
- Update parsers and prompts to handle clothing separately
- Add translations (EN/ZH-TW) for new features
2026-01-02 13:58:43 +01:00
Spicy_Marinara ddd59d124e bug 2025-12-31 18:14:44 +01:00
Spicy_Marinara 3bfc6ea934 Update to v2.0 and add version indicator 2025-12-31 10:53:09 +01:00
Spicy_Marinara 3f58c7ceca Add holiday promotion, snowflakes effect, and Spotify music widget
- Added holiday promotion banner with 2026WITHMARINARA discount code
- Added dismiss functionality for promotion with persistent state
- Implemented snowflakes animation effect with toggle
- Added Spotify music widget above chat input
- Widget matches extension theme colors and positioning
- Added Display Options toggles to show/hide feature toggles
- Improved responsive design and mobile support
2025-12-30 20:56:43 +01:00
Spicy Marinara 51535c5fdc Merge pull request #80 from munimunigamer/image-gen-updates
General Automatic Image Generation Updates
2025-12-30 11:14:55 +01:00
munimunigamer bc4d4a0dd1 prompt now focuses on bust up/face 2025-12-30 04:11:08 -12:00
munimunigamer 5eb602e91d added better prompt generation 2025-12-30 04:02:45 -12:00
munimunigamer ca4a318135 removed unused localization 2025-12-30 03:24:06 -12:00
munimunigamer b4ad757e42 removed unnecessary "LLM Instruction" prompt for image generation 2025-12-30 03:23:18 -12:00
Spicy_Marinara 530d871fd3 Update layout.js 2025-12-29 18:07:33 +01:00
Spicy_Marinara 474e3ce963 Add customizable prompts editor and reorganize settings panel
- Reorganized settings: moved Auto-update, Narrator Mode, and Debug Mode to Advanced section
- Added Customize Prompts button with comprehensive prompts editor modal
- Implemented 7 customizable AI prompts: HTML, plot progression (random/natural), avatar generation, tracker instructions, tracker continuation, and combat narrative
- Added individual and bulk restore to defaults functionality
- Integrated custom prompts across generation modules (plotProgression, promptBuilder, encounterPrompts)
- Auto-update toggle now disabled when not in Separate generation mode
- Merged XML/Markdown tracker instructions into unified prompt
2025-12-29 14:41:12 +01:00
Spicy_Marinara 0b5bca56eb Fix display settings persistence and add responsive layout for plot buttons
- Fixed updateSectionVisibility() to use explicit .show()/.hide() instead of .toggle() to ensure proper element visibility on page reload
- Added responsive CSS for plot buttons to adjust to small screens and mobile devices
- Wrapped button text in spans to enable icon-only mode on very small screens (≤400px)
- Reduced button margins and added flexbox layout with wrapping for better mobile UX
2025-12-29 13:32:30 +01:00
Spicy Marinara 39f4fed40d Merge pull request #79 from munimunigamer/external-mode
feature: Add External API Generation Mode with Secure Key Storage
2025-12-29 10:29:35 +01:00
munimunigamer 6ffcf9c929 detailing that it connects to openai rather than just third party 2025-12-29 02:58:05 -12:00
munimunigamer 10a4f9e89e fixed auto image gen to use external mode as well 2025-12-29 02:56:04 -12:00
munimunigamer fb8a6fcc30 using localstorage instead of extension settings for api key now 2025-12-29 02:50:30 -12:00
munimunigamer b037d95da8 hide the "use model connected to RPG Companion Trackers preset" 2025-12-29 02:43:53 -12:00
munimunigamer 018ab3613f changed default max tokens from 2048 to 8192 2025-12-29 02:42:51 -12:00
munimunigamer 5369cb14a5 add cors error logging, letting the user know if an endpoint can't be used due to cors blocking 2025-12-29 02:42:37 -12:00
munimunigamer 1d4a64bac7 added external api 2025-12-29 02:38:08 -12:00
munimunigamer 9936fb483d added external api settings to extension settings 2025-12-29 02:21:30 -12:00
Spicy_Marinara 3146f033df Fix clamp() to use rem/px for min/max values
- Replace clamp(Xvw, Yvw, Zvw) with clamp(Xrem, Yvw, Zrem)
- Prevents font sizes from scaling excessively on ultrawide monitors
- Now minimum and maximum values are fixed units while middle value remains responsive
- Fixes info box, stats, calendar, weather, and all other text elements
2025-12-28 23:38:41 +01:00
Spicy_Marinara ed421bee63 Fix font sizes for ultrawide monitors using clamp()
- Replace all vw-based font-size properties with clamp() to prevent excessively large text
- Set maximum font sizes to prevent issues on 3440x1440 and other ultrawide displays
- Maintain responsive behavior for normal and mobile screen sizes
- Fix gap properties using vw for better spacing consistency
2025-12-28 22:07:27 +01:00
Spicy Marinara 09463fc95a Merge pull request #75 from munimunigamer/avatar-gen-fixes
fixed right click regen and clearing chat
2025-12-27 21:32:53 +01:00
munimunigamer 10f6326f82 fixed right click regen and clearing chat 2025-12-27 12:36:48 -12:00
Spicy_Marinara 3caa74fbf8 Combat encounters: Add pre-encounter config modal, targeting fixes, and tracker integration
- Add pre-encounter narrative configuration modal with combat/summary style settings
- Change POV fields to text inputs (default: narrator) for custom character names
- Fix targeting system for enemies with spaces in names (e.g., 'Gilded Thrall 1')
- Display character-specific sprites/avatars in targeting modal instead of generic emojis
- Add combat difficulty scaling guidance to prevent trivial god defeats or endless wolf battles
- Integrate tracker updates in combat summary generation (together mode)
- Update auto-save logs description to clarify file storage vs chat history
- Apply extension theming to Close Combat Window button
2025-12-27 16:06:06 +01:00
Spicy Marinara 436f3495f8 Merge pull request #74 from joenunezb/fix/hanging-swipes-with-chapterCheckpoints
fix: Swipes hanging due race condition when handling promises in chapterCheckpoints
2025-12-27 08:42:04 +01:00
joenunezb afa39b1387 Useless comments 2025-12-26 19:24:08 -08:00
joenunezb b38dbe06a6 fix: Properly handle promises that lead to hangs on swipes 2025-12-26 19:01:05 -08:00
Spicy Marinara 6b73e422de Merge pull request #73 from munimunigamer/merge-fix-69-70
Quick fix on the merge problem
2025-12-26 18:13:41 +01:00
munimunigamer ed5bcb2670 fixed index.js file 2025-12-26 10:57:46 -06:00
Spicy Marinara c29f2b1bb5 Merge pull request #70 from munimunigamer/auto-image-generation
Add automatic avatar generation for NPCs with LLM-powered prompts
2025-12-26 10:00:38 +01:00
Spicy Marinara 91732b4d1c Merge branch 'main' into auto-image-generation 2025-12-26 10:00:29 +01:00
Spicy Marinara b163141652 Merge pull request #69 from munimunigamer/main
feat: Add configurable toggles for Narrator Mode and dice display
2025-12-26 09:58:32 +01:00
munimunigamer 87e86bfbb4 Removing my .claude settings file oopsies 2025-12-25 23:43:39 -08:00
munimunigamer d10d4e876f replaced disable placeholder card with Narrator mode (changes prompt a bit to support open ended rpgs/narrator cards that don't have defined characters better) 2025-12-26 01:31:16 -06:00
munimunigamer fdaca39d39 renamed stable diffusion to image generation 2025-12-26 01:01:04 -06:00
munimunigamer 2df173e6af fixed up re-rendering images when right clicking 2025-12-26 00:54:44 -06:00
munimunigamer de11f6f7e2 llm generated image gen prompts 2025-12-25 21:13:19 -08:00
munimunigamer b7e52046bc fixed avatar images appearing in rpg 2025-12-25 20:39:01 -08:00
munimunigamer 7802479670 auto-image-generation 2025-12-25 19:59:25 -08:00
munimunigamer c73260b2c6 Added placeholder and dice config options 2025-12-25 18:27:28 -08:00
Spicy_Marinara 04bd314da2 Add chapter checkpoint UI improvements and separate Quests toggle
- Fix checkpoint button display with expandMessageActions setting
- Add body class observer to update buttons when setting toggles
- Add cleanupCheckpointUI function for extension disable
- Separate Quests from Inventory with independent toggle
- Add horizontal scrolling to Info Box dashboard
- Add divider between Inventory and Quests sections
2025-12-22 01:05:01 +01:00
Spicy_Marinara d386752f9c Fix chapter checkpoint button duplication issue 2025-12-22 00:27:25 +01:00
Spicy_Marinara 9d8b758317 Remove accidentally committed log file 2025-12-21 23:55:02 +01:00
Spicy_Marinara fe03cba802 feat: Add remove button for characters in Present Characters panel
- Add removeCharacter() function to delete characters from panel and saved data
- Remove character from both lastGeneratedData and committedTrackerData
- Add X button to character card header with hover effects
- Button removes character from display and prevents re-inclusion in next generation
- Updates are persisted to chat metadata
2025-12-19 18:01:05 +01:00
Spicy_Marinara ab7dfeaf8b feat: Add custom avatar upload for NPCs in Present Characters panel
- Add npcAvatars storage to extension settings for custom NPC images
- Implement getCharacterAvatar() to check custom avatars first
- Add uploadNpcAvatar() function with file validation (2MB max, images only)
- Make character avatars clickable with visual feedback
- Support left-click to upload and right-click to remove custom avatars
- Add camera icon overlay on hover with smooth animations
- Store avatars as base64 data URIs for persistence across sessions
2025-12-18 14:14:49 +01:00
Spicy_Marinara 5bc7bfe22f Fix: Preserve decimal commas in numbers (e.g., 4443,445)
- Modified itemParser to detect commas between digits
- Prevents splitting money/numbers with comma decimal separators
- Example: '4443,445 gold coins' now stays as one item
- Added documentation and example for decimal comma handling
2025-12-18 02:03:11 +01:00
Spicy_Marinara 3ded104218 Add chapter checkpoint feature
- New feature: bookmark messages to exclude earlier history from context
- Saves tokens by marking chapter start points in long chats
- Uses SillyTavern's /hide and /unhide slash commands
- Persists checkpoint across page reloads and generation events
- UI: bookmark icon in message menus with visual indicators
- Debounced restore function prevents concurrent executions
- Pre-generation checkpoint application ensures messages stay hidden
- Clean production-ready code with proper error handling
2025-12-18 01:59:14 +01:00
Spicy_Marinara 8645bbde98 Revert "Merge pull request #57 from devsorcer/claude/add-character-state-tracking-01AC3zt7Z6eEYLfZXoZCgut4"
This reverts commit 8905db3e44, reversing
changes made to 628d8ee7a4.
2025-12-06 00:04:32 +01:00
Spicy Marinara cc1dd8dc11 Merge pull request #60 from SpicyMarinara/revert-59-main
Revert "All the features"
2025-12-05 22:44:11 +01:00
Spicy Marinara bfb63a34cd Revert "All the features" 2025-12-05 22:43:56 +01:00
Spicy_Marinara 275179fa7f Revert "Update promptBuilder.js"
This reverts commit 3c6daa6a72.
2025-12-05 22:43:04 +01:00
Spicy_Marinara 3c6daa6a72 Update promptBuilder.js 2025-12-05 21:54:15 +01:00
Spicy Marinara 02f74c8e75 Merge pull request #59 from Subarashimo/main
All the features
2025-12-05 20:51:38 +01:00
Spicy Marinara cdbf3a0354 Merge branch 'main' into main 2025-12-05 20:51:05 +01:00
Spicy Marinara 31f12941a8 Merge pull request #58 from devsorcer/main
Done
2025-12-05 20:47:50 +01:00
Spicy Marinara 8905db3e44 Merge pull request #57 from devsorcer/claude/add-character-state-tracking-01AC3zt7Z6eEYLfZXoZCgut4
Claude/add character state tracking 01 ac3zt7 z6e ey lf z xo z cgut4
2025-12-05 20:46:32 +01:00
Subarashimo 3500c200c6 Merge branch 'main' of https://github.com/Subarashimo/rpg-companion-sillytavern 2025-12-05 19:53:04 +01:00
Subarashimo e9317595b6 fix: string format skills 2025-12-05 19:52:25 +01:00
Subarashimo f1219d6a40 Merge branch 'main' into main 2025-12-05 18:16:05 +01:00
Subarashimo 7e47dbfd7c chore: final cleanup 2025-12-05 18:10:21 +01:00
Subarashimo 38328de1bf fix: clear skills 2025-12-05 16:29:54 +01:00
Subarashimo 806a7078a7 feat: message interception 2025-12-05 11:40:50 +01:00
Claude 6a513bc0b5 Add dynamic container creation as fallback if template fails to load 2025-12-05 05:19:41 +00:00
Claude ffed3aa1b5 Add diagnostic logging to character state tracking 2025-12-05 05:12:58 +00:00
Claude 14465e5ae9 Bump version to 2.0.0 with visible loading indicator 2025-12-05 05:06:39 +00:00
devsorcer 817dad352f Add files via upload 2025-12-05 10:32:28 +05:30
Claude 19c47de934 Add READY_TO_USE guide explaining 100% copy-paste integration 2025-12-05 04:53:10 +00:00
Claude c35e39c445 Integrate character state tracking system into main extension
This commit fully integrates the character tracking system into the
RPG Companion extension. Now 100% ready to use with zero manual work.

Changes to index.js:
- Added imports for character state modules
- Created event wrapper functions for:
  - onGenerationStarted (injects character tracking prompt)
  - onMessageReceived (parses and applies state updates)
  - onCharacterChanged (loads character state from chat)
- Added persistence functions (save/load to chat metadata)
- Modified event registration to use wrapper functions
- Added character state display initialization

Changes to template.html:
- Added #rpg-character-state-container for UI display

SYSTEM NOW FULLY FUNCTIONAL:
 LLM receives character state before generation
 LLM updates character state in responses
 States automatically parse and apply
 UI displays character emotions, physical stats, relationships
 State persists between sessions in chat metadata
 100% copy-paste ready - no manual integration needed

To use:
1. Files are already in place
2. System works automatically
3. Check console for [Character Tracking] logs
4. See character state in RPG panel
2025-12-05 04:52:01 +00:00
Claude 0440159089 Add comprehensive character state tracking system for {{char}}
This implements a complete Katherine RPG-based character state tracking
system that tracks the AI character ({{char}}) instead of the user.

Features:
- 40+ primary personality traits (dominance, honesty, empathy, etc.)
- 70+ secondary emotional states (happy, horny, anxious, playful, etc.)
- Physical stats tracking (energy, hunger, arousal, health, pain, etc.)
- Relationship tracking per-NPC (trust, love, attraction, thoughts, etc.)
- Clothing/outfit dynamic tracking
- Internal thoughts and contextual awareness
- LLM-driven automatic state updates based on responses
- Full UI rendering with tabbed interface

New Files:
- src/core/characterState.js (528 lines) - Core state data structure
- src/systems/generation/characterPromptBuilder.js (407 lines) - LLM prompts
- src/systems/generation/characterParser.js (456 lines) - Response parsing
- src/systems/rendering/characterStateRenderer.js (401 lines) - UI rendering
- CHARACTER_TRACKING_README.md - Complete documentation
- INTEGRATION_EXAMPLE.js - Step-by-step integration guide
- IMPLEMENTATION_SUMMARY.md - System overview and deliverables

System tracks 150+ individual stats per character with full LLM integration
for contextual, realistic character simulation.

All code is production-ready and copy-paste complete.
2025-12-05 04:39:53 +00:00
devsorcer 2d5b3c4c5b Add files via upload 2025-12-05 09:57:31 +05:30
Subarashimo 271c69ec49 feat: remove character button 2025-12-04 21:04:56 +01:00
Subarashimo 9f6c44745b feat: rpg stats improvements 2025-12-04 20:40:02 +01:00
Spicy Marinara 628d8ee7a4 Merge pull request #56 from IDeathByte/main
Fix for character card
2025-12-04 09:29:11 +01:00
IDeathByte 23a4e77b0a Fix for character card
Fixing the issue, when card data makes unreadable, when LLM generates more than 2 strings for appearance

Make it scrollable
2025-12-04 13:05:04 +05:00
Subarashimo b5f5f6d9c5 fix: classica js falsy bug 2025-12-03 22:45:58 +01:00
Subarashimo c24515db7e fix: several issues 2025-12-03 22:34:50 +01:00
Subarashimo 0f7fdfcef1 feat: json format, et al. 2025-12-03 14:55:30 +01:00
Subarashimo 56349f30e6 fix: prompt consistency 2025-12-03 10:02:39 +01:00
Subarashimo d775b45951 fix: missing divider handling 2025-12-03 09:24:00 +01:00
Subarashimo c1a343eb46 fix: flexible prompts 2025-12-03 09:22:42 +01:00
Subarashimo f3c224a99a feat: more settings 2025-12-03 09:19:03 +01:00
Spicy_Marinara 32c2543605 Respect showInventory toggle in prompt generation 2025-12-01 11:46:57 +01:00
Spicy Marinara 968aedc537 Merge pull request #52 from lilminzyu/i18n/zh-tw
Add Chinese (Traditional) i18n support
2025-12-01 11:31:44 +01:00
Mingyu f38bddec62 The processing of the Separate button was missing and has been added. Dynamic update logic centralized 2025-11-26 21:46:59 +08:00
Mingyu 691586ce2f Merge branch 'SpicyMarinara:main' into i18n/zh-tw 2025-11-26 15:51:12 +08:00
Mingyu d486c9e924 mobile done 2025-11-26 07:49:59 +00:00
Spicy_Marinara 0c5b55b190 Add character card info in separate mode with muted filtering and scrollable Past Events 2025-11-25 12:40:28 +01:00
Mingyu 6759f514f3 pc all done 2025-11-24 22:38:56 +08:00
Mingyu 79f99a40c6 RPG Companion index [done] 2025-11-24 19:48:34 +08:00
Mingyu ab33604ea0 Edit Trackers Pop-up window [done] 2025-11-24 17:40:07 +08:00
Mingyu 0f0a4dceeb RPG Companion Settings Pop-up window [done] 2025-11-24 17:37:38 +08:00
Mingyu 8ef4e4ba6d first try i18n base ok 2025-11-24 17:35:41 +08:00
Spicy_Marinara 950d83fc18 Update promptBuilder.js 2025-11-23 19:59:27 +01:00
Spicy Marinara 5e05dee0e8 Merge pull request #50 from chungchan-dev/fix/mobile-layout
fix: some mobile layout
2025-11-23 13:40:09 +01:00
Spicy_Marinara 67df7034eb Add custom HTML prompt editor, skills blur handler, and include skills in separate mode 2025-11-22 23:36:39 +01:00
Chanho Chung eef547b0fa fix: mobile layout of rpg-skills-section font size 2025-11-22 23:35:30 +09:00
Chanho Chung fed4e2d095 fix: mobile layout of rpg-skills-section and rpg-classic-stat 2025-11-22 23:27:45 +09:00
Chanho Chung 02f080cc98 fix: mobile layout of rpg-mobile-tabs, rpg-info-content, rpg-character-card 2025-11-22 23:09:54 +09:00
Chanho Chung 00265ba905 fix: mobile layout of rpg-info-box 2025-11-22 11:14:14 +09:00
Spicy_Marinara c3624c240f Exclude attributes from separate tracker generation requests 2025-11-21 00:01:44 +01:00
Spicy_Marinara 76c7e3cd9c Update HTML prompt wording and improve together mode swipe handling 2025-11-20 22:59:31 +01:00
Spicy_Marinara 2b45dc8fae Add stat change guidelines, attributes toggle, skills editing, and improved character parsing
- Add temporal awareness and stat decay rules to prompt (0-5% per message)
- Add 'Always Include Attributes' toggle in tracker editor
- Fix skills section editing (was not saving customFields)
- Improve Present Characters parser to handle malformed formats (mid-line chars, extra blank lines)
- All changes work in both together/separate generation modes
2025-11-18 15:10:24 +01:00
Spicy_Marinara ed3eac54fc Update promptBuilder.js 2025-11-16 00:06:32 +01:00
Spicy_Marinara c48b1dab46 Fix: Hide UI elements when extension disabled
- Skip UI initialization entirely when extension is disabled on page load
- Remove all UI elements (panel, buttons) from DOM when disabling extension
- Recreate full UI when re-enabling extension
- Hide mobile toggle button on desktop viewports (>1000px)
- Show/hide mobile toggle based on viewport size transitions
- Ensures clean state management for extension enable/disable
2025-11-13 23:30:44 +01:00
Spicy_Marinara bd891e39b0 Fix: Quests now properly scoped per-chat
Quests were bleeding through from other chats because loadChatData()
wasn't resetting them when switching to a chat without RPG data.

When loading a chat with no rpg_companion metadata, the function now
resets quests to empty state (main: 'None', optional: []) along with
other tracker data. This ensures each chat maintains its own quest
state independently.
2025-11-13 21:01:37 +01:00
Spicy Marinara dfbae54b48 Merge pull request #47 from amauragis/guided-generation-compat
Add a prompt injection suppresson feature for Guided Generations
2025-11-13 20:59:13 +01:00
Andy Mauragis dc37fd4a63 docs: Update README with guided generation compatibility info 2025-11-13 13:46:36 -05:00
Andy Mauragis 0ac85ad9fd feat: Add 'Skip Injections during Guided Generations' setting and UI 2025-11-13 13:46:36 -05:00
Andy Mauragis 407a45a25c feat: Add core suppression logic and integrate into prompt injector 2025-11-13 13:46:36 -05:00
Spicy_Marinara d658e337f6 Fix: Support multiple character variants in Present Characters panel
Fixed issues when AI generates multiple character variants (e.g.,
storyteller mode with 'Dottore (Prime)', 'Dottore (Beta)', etc.):

1. Escape quotes in character names to prevent HTML attribute breakage
   - Added escapeHtmlAttr() helper function
   - Prevents names like 'Marianna "Mari"' from breaking HTML

2. Restore avatar lookup for character variants
   - namesMatch() now strips parentheses and quotes from both sides
   - Allows 'Dottore (Prime)' to find 'Dottore' character card avatar
   - Each variant still gets its own card with separate attributes

3. Multiple characters now display correctly in panel
   - Each variant creates its own character object
   - Attributes (Details, Relationship, Stats, Thoughts) don't mix
   - All characters appear in the panel, not just the last one
2025-11-13 16:18:35 +01:00
Spicy Marinara 172c8d6ab8 Merge pull request #45 from joenunezb/fix/render-chat-message-properly
fix: Render chat messages using updateMessageBlock
2025-11-13 13:26:13 +01:00
joenunezb c23c68fbc3 fix: Render chat messages using updateMessageBlock 2025-11-13 03:43:41 -08:00
Spicy_Marinara d4fc3ce1d8 Add 'Always Show Thought Bubble' setting - keeps thought bubble permanently expanded 2025-11-06 22:20:35 +01:00
Spicy Marinara 227eb4c31e Merge pull request #43 from SpicyMarinara/revert-36-feat/v2-widget-dashboard-system
Revert "feat: v2 widget dashboard system"
2025-11-06 20:06:40 +01:00
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
99 changed files with 19607 additions and 31904 deletions
+27 -9
View File
@@ -5,6 +5,19 @@ An immersive RPG extension for browsers that tracks character stats, scene infor
[![My Discord](https://img.shields.io/badge/Discord-Join%20Server-7289da)](https://discord.com/invite/KdAkTg94ME)
[![Support Me](https://img.shields.io/badge/Ko--fi-Support%20Creator-ff5e5b)](https://ko-fi.com/marinara_spaghetti)
## 🆕 What's New
### v3.4.0
- Added History Persistance in Edit Trackers that allows you to control how many past trackers in the chat history to include.
- New mobile displays were added that show all the most important trackers from the panel as small, floating widgets around the RPG Companion button, when the main panel is closed.
- Added CYOA toggle.
- Added Deception System toggle.
- The trackers are no longer sent together with an image generation request.
**Special thanks to all the other contributors for this project:**
Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDeathByte, Chungchandev, Joenunezb, Amauragis, and Tomt610.
## 📥 Installation
1. Open SillyTavern
@@ -57,8 +70,6 @@ An immersive RPG extension for browsers that tracks character stats, scene infor
### To-Do
1. Allow users to use a different model for the separate trackers generation
2. ~~Make all trackers and fields customizable~~ ✅ Done!
3. ~~Kill myself~~
## ⚙️ Settings
@@ -186,6 +197,18 @@ The extension fully supports swipes:
You can click the "Refresh RPG Info" button in the settings to refresh the RPG data at any time in separate generation mode.
### Compatibility with Guided Generations
This extension detects when a "guided generation" prompt is submitted (for example, via the GuidedGenerations extension which injects an ephemeral `instruct` prompt), and will avoid adding its tracker injection instructions (requests for stats, info box, and context prompts) to the generation context. This prevents conflicting instructions and ensures guided generations behave as the user expects.
If you want tracker prompts to apply during a guided generation, run the update via separate generation or temporarily disable guided generation in the other extension.
There is a new setting "Skip Tracker & HTML Injections during Guided Generations" in the RPG Companion settings (Advanced section). It now supports three modes:
- none: never skip (always inject the tracker prompts as usual, default)
- impersonation: only skip when an impersonation-style guided generation is detected
- guided: skip whenever a guided `instruct` or `quiet_prompt` generation is detected
## 🎨 Themes
Choose from 6 beautiful themes:
@@ -245,13 +268,8 @@ If you enjoy this extension, consider supporting development:
## 🙏 Credits
- Extension Development: Marinara with assistance from GitHub Copilot
- Immersive HTML concept: Credit to u/melted_walrus
- Info Box prompt inspiration: MidnightSleeper
- Stats Tracker concept: Community feedback
- Special thanks to Quack for helping me with the CSS
- Massive kudos to Paperboy for making the mobile version work, fixing bugs, and adding the inventory system
- Thanks to IDeathByte for solving some CSS scaling issues
**Contributors:**
SpicyMarinara, Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDeathByte, Chungchandev, Joenunezb, Amauragis, and Tomt610.
## 🚀 Planned Features
-264
View File
@@ -1,264 +0,0 @@
{
"chat_completion_source": "custom",
"openai_model": "gpt-4o",
"claude_model": "claude-3-sonnet-20240229",
"openrouter_model": "OR_Website",
"openrouter_use_fallback": false,
"openrouter_group_models": false,
"openrouter_sort_models": "alphabetically",
"openrouter_providers": [],
"openrouter_allow_fallbacks": true,
"openrouter_middleout": "on",
"ai21_model": "jamba-large",
"mistralai_model": "mistral-large-latest",
"cohere_model": "command-r-plus",
"perplexity_model": "llama-3-70b-instruct",
"groq_model": "llama3-70b-8192",
"xai_model": "grok-4-0709",
"pollinations_model": "openai",
"aimlapi_model": "gpt-4o-mini-2024-07-18",
"electronhub_model": "gpt-4o-mini",
"electronhub_sort_models": "alphabetically",
"electronhub_group_models": false,
"moonshot_model": "kimi-latest",
"fireworks_model": "accounts/fireworks/models/kimi-k2-instruct",
"cometapi_model": "gpt-4o",
"custom_model": "",
"custom_prompt_post_processing": "semi",
"google_model": "gemini-pro",
"vertexai_model": "gemini-2.5-pro-exp-03-25",
"azure_api_version": "2024-02-15-preview",
"azure_openai_model": "",
"temperature": 1,
"frequency_penalty": 0,
"presence_penalty": 0,
"top_p": 1,
"top_k": 0,
"top_a": 1,
"min_p": 0,
"repetition_penalty": 1,
"openai_max_context": 16384,
"openai_max_tokens": 8192,
"wrap_in_quotes": false,
"names_behavior": -1,
"send_if_empty": "",
"impersonation_prompt": "",
"new_chat_prompt": "",
"new_group_chat_prompt": "",
"new_example_chat_prompt": "",
"continue_nudge_prompt": "",
"bias_preset_selected": "Default (none)",
"max_context_unlocked": false,
"wi_format": "",
"scenario_format": "",
"personality_format": "",
"group_nudge_prompt": "",
"stream_openai": false,
"prompts": [
{
"name": "Main Prompt",
"system_prompt": true,
"role": "system",
"content": "",
"identifier": "main",
"injection_position": 0,
"injection_depth": 4,
"forbid_overrides": false
},
{
"name": "NSFW Prompt",
"system_prompt": true,
"role": "system",
"content": "",
"identifier": "nsfw"
},
{
"identifier": "dialogueExamples",
"name": "Chat Examples",
"system_prompt": true,
"marker": true
},
{
"name": "Jailbreak Prompt",
"system_prompt": true,
"role": "system",
"content": "",
"identifier": "jailbreak"
},
{
"identifier": "chatHistory",
"name": "Chat History",
"system_prompt": true,
"marker": true
},
{
"identifier": "worldInfoAfter",
"name": "World Info (after)",
"system_prompt": true,
"marker": true
},
{
"identifier": "worldInfoBefore",
"name": "World Info (before)",
"system_prompt": true,
"marker": true
},
{
"identifier": "enhanceDefinitions",
"role": "system",
"name": "Enhance Definitions",
"content": "If you have more knowledge of {{char}}, add to the character's lore and personality to enhance them but keep the Character Sheet's definitions absolute.",
"system_prompt": true,
"marker": false
},
{
"identifier": "charDescription",
"name": "Char Description",
"system_prompt": true,
"marker": true
},
{
"identifier": "charPersonality",
"name": "Char Personality",
"system_prompt": true,
"marker": true
},
{
"identifier": "scenario",
"name": "Scenario",
"system_prompt": true,
"marker": true
},
{
"identifier": "personaDescription",
"name": "Persona Description",
"system_prompt": true,
"marker": true
}
],
"prompt_order": [
{
"character_id": 100000,
"order": [
{
"identifier": "main",
"enabled": true
},
{
"identifier": "worldInfoBefore",
"enabled": true
},
{
"identifier": "charDescription",
"enabled": true
},
{
"identifier": "charPersonality",
"enabled": true
},
{
"identifier": "scenario",
"enabled": true
},
{
"identifier": "enhanceDefinitions",
"enabled": false
},
{
"identifier": "nsfw",
"enabled": true
},
{
"identifier": "worldInfoAfter",
"enabled": true
},
{
"identifier": "dialogueExamples",
"enabled": true
},
{
"identifier": "chatHistory",
"enabled": true
},
{
"identifier": "jailbreak",
"enabled": true
}
]
},
{
"character_id": 100001,
"order": [
{
"identifier": "main",
"enabled": false
},
{
"identifier": "worldInfoBefore",
"enabled": false
},
{
"identifier": "personaDescription",
"enabled": false
},
{
"identifier": "charDescription",
"enabled": false
},
{
"identifier": "charPersonality",
"enabled": false
},
{
"identifier": "scenario",
"enabled": false
},
{
"identifier": "enhanceDefinitions",
"enabled": false
},
{
"identifier": "nsfw",
"enabled": false
},
{
"identifier": "worldInfoAfter",
"enabled": false
},
{
"identifier": "dialogueExamples",
"enabled": false
},
{
"identifier": "chatHistory",
"enabled": false
},
{
"identifier": "jailbreak",
"enabled": false
}
]
}
],
"show_external_models": false,
"assistant_prefill": "",
"assistant_impersonation": "",
"claude_use_sysprompt": true,
"use_makersuite_sysprompt": true,
"vertexai_auth_mode": "full",
"squash_system_messages": true,
"image_inlining": false,
"inline_image_quality": "auto",
"video_inlining": false,
"bypass_status_check": false,
"continue_prefill": false,
"continue_postfix": "",
"function_calling": false,
"show_thoughts": false,
"reasoning_effort": "auto",
"enable_web_search": false,
"request_images": false,
"seed": -1,
"n": 1,
"extensions": {}
}
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)
+709 -240
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -5,7 +5,7 @@
"optional": [],
"js": "index.js",
"css": "style.css",
"author": "Marysia",
"version": "1.1.0",
"author": "Marinara",
"version": "3.4.1",
"homePage": "https://github.com/SpicyMarinara/rpg-companion-sillytavern"
}
+34 -9
View File
@@ -7,24 +7,49 @@
<div class="inline-drawer-content">
<label class="checkbox_label" for="rpg-extension-enabled">
<input type="checkbox" id="rpg-extension-enabled" />
<span>Enable RPG Companion</span>
<span data-i18n-key="settings.extensionEnabled">Enable RPG Companion</span>
</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 class="form-group" style="margin-top: 10px;">
<label for="rpg-companion-language-select" data-i18n-key="settings.language.label">Language</label>
<select id="rpg-companion-language-select" class="text_pole">
<option value="en" data-i18n-key="settings.language.option.en">English</option>
<option value="zh-tw" data-i18n-key="settings.language.option.zh-tw">繁體中文</option>
</select>
</div>
<small class="notes" data-i18n-key="settings.note">Toggle to enable/disable the RPG Companion extension. Configure additional settings within the panel itself.</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
<i class="fa-brands fa-discord"></i>&nbsp;Discord
</a>
<a href="https://ko-fi.com/marinara_spaghetti" target="_blank" class="menu_button" style="flex: 1; text-align: center; text-decoration: none;">
<i class="fa-solid fa-heart"></i> Support Creator
<i class="fa-solid fa-heart"></i>&nbsp;Support
</a>
</div>
<div style="margin-top: 15px; text-align: center; opacity: 0.7; font-size: 0.8em; line-height: 1.5;">
<div style="margin-bottom: 5px;">
<i class="fa-solid fa-microchip"></i> <strong data-i18n="settings.recommendedModels.title">Recommended Models:</strong>
</div>
<div style="opacity: 0.8; font-size: 0.9em;" data-i18n="settings.recommendedModels.description">
For the extension to work properly, <strong>it is not recommended to use any models below 20B, especially if they're old.</strong> It works best with the SOTA models such as Deepseek, Claude, GPT, or Gemini.
</div>
</div>
<div style="margin-top: 15px; text-align: center; opacity: 0.7; font-size: 0.8em; line-height: 1.5;">
<div style="margin-bottom: 5px;">
<i class="fa-solid fa-users"></i> <strong>Contributors:</strong>
</div>
<div style="opacity: 0.8; font-size: 0.9em;">
SpicyMarinara, Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDeathByte, Chungchandev, Joenunezb, Amauragis, and Tomt610.
</div>
</div>
<div style="margin-top: 10px; text-align: center; opacity: 0.6; font-size: 0.85em;">
v3.4.0
</div>
</div>
</div>
</div>
+13 -5
View File
@@ -26,15 +26,25 @@ export const defaultSettings = {
autoUpdate: true,
updateDepth: 4, // How many messages to include in the context
generationMode: 'together', // 'separate' or 'together' - whether to generate with main response or separately
useSeparatePreset: false, // Use 'RPG Companion Trackers' preset for tracker generation instead of main API model
showUserStats: true,
showInfoBox: true,
showCharacterThoughts: true,
showInventory: true, // Show inventory section (v2 system)
showQuests: true, // Show quests section
showLockIcons: true, // Show lock/unlock icons on tracker items
showThoughtsInChat: true, // Show thoughts overlay in chat
alwaysShowThoughtBubble: false, // Auto-expand thought bubble without clicking icon
enableHtmlPrompt: false, // Enable immersive HTML prompt injection
enableSpotifyMusic: false, // Enable Spotify music integration (asks AI for Spotify URLs)
customSpotifyPrompt: '', // Custom Spotify prompt text (empty = use default)
// Controls when the extension skips injecting tracker instructions/examples/HTML
// into generations that appear to be user-injected instructions. Valid values:
// - 'none' -> never skip (legacy behavior: always inject)
// - 'guided' -> skip for any guided / instruct or quiet_prompt generation
// - 'impersonation' -> skip only for impersonation-style guided generations
// This setting helps compatibility with other extensions like GuidedGenerations.
skipInjectionsForGuided: 'none',
enablePlotButtons: true, // Show plot progression buttons above chat input
saveTrackerHistory: false, // Save tracker data in chat history for each message
panelPosition: 'right', // 'left', 'right', or 'top'
theme: 'default', // Theme: default, sci-fi, fantasy, cyberpunk, custom
customColors: {
@@ -75,7 +85,5 @@ export const defaultSettings = {
cha: 10
},
lastDiceRoll: null, // Store last dice roll result
collapsedInventoryLocations: [], // Array of collapsed storage location names
debugMode: false, // Enable debug logging visible in UI (for mobile debugging)
memoryMessagesToProcess: 16 // Number of messages to process per batch in memory recollection
collapsedInventoryLocations: [] // Array of collapsed storage location names
};
+103
View File
@@ -0,0 +1,103 @@
//- No-op in case this is running outside of SillyTavern
const { extension_settings } = typeof self.SillyTavern !== 'undefined' ? self.SillyTavern.getContext() : { extension_settings: {} };
class Internationalization {
constructor() {
this.currentLanguage = 'en';
this.translations = {};
this._listeners = {};
}
addEventListener(event, callback) {
if (!this._listeners[event]) {
this._listeners[event] = [];
}
this._listeners[event].push(callback);
}
dispatchEvent(event, data) {
if (this._listeners[event]) {
this._listeners[event].forEach(callback => callback(data));
}
}
async init() {
const savedLanguage = localStorage.getItem('rpgCompanionLanguage') || 'en';
this.currentLanguage = savedLanguage;
await this.loadTranslations(this.currentLanguage);
this.applyTranslations(document.body);
const langSelect = document.getElementById('rpg-companion-language-select');
if (langSelect) {
langSelect.value = this.currentLanguage;
}
}
async loadTranslations(lang) {
const fetchUrl = `/scripts/extensions/third-party/rpg-companion-sillytavern/src/i18n/${lang}.json`;
try {
const response = await fetch(fetchUrl);
if (!response.ok) {
console.error(`[RPG-Companion-i18n] Failed to load translation file for ${lang}. Status: ${response.status}`);
if (lang !== 'en') {
return this.loadTranslations('en');
}
return;
}
this.translations = await response.json();
} catch (error) {
console.error('[RPG-Companion-i18n] CRITICAL error loading translation file:', error);
}
}
applyTranslations(rootElement) {
if (!rootElement) {
return;
}
// 1. Translate textContent
const textElements = rootElement.querySelectorAll('[data-i18n-key]');
textElements.forEach(element => {
const key = element.dataset.i18nKey;
const translation = this.getTranslation(key);
if (translation) {
element.textContent = translation;
}
});
// 2. Translate title attribute
const titleElements = rootElement.querySelectorAll('[data-i18n-title]');
titleElements.forEach(element => {
const key = element.dataset.i18nTitle;
const translation = this.getTranslation(key);
if (translation) {
element.setAttribute('title', translation);
}
});
// 3. Translate aria-label attribute
const ariaLabelElements = rootElement.querySelectorAll('[data-i18n-aria-label]');
ariaLabelElements.forEach(element => {
const key = element.dataset.i18nAriaLabel;
const translation = this.getTranslation(key);
if (translation) {
element.setAttribute('aria-label', translation);
}
});
}
getTranslation(key) {
return this.translations[key] || null;
}
async setLanguage(lang) {
this.currentLanguage = lang;
localStorage.setItem('rpgCompanionLanguage', lang);
await this.loadTranslations(lang);
this.applyTranslations(document.body);
this.dispatchEvent('languageChanged');
}
}
export const i18n = new Internationalization();
+519 -32
View File
@@ -17,7 +17,7 @@ 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';
import { migrateToV3JSON } from '../utils/jsonMigration.js';
const extensionName = 'third-party/rpg-companion-sillytavern';
@@ -79,6 +79,50 @@ export function loadSettings() {
}
updateExtensionSettings(savedSettings);
// Perform settings migrations based on version
const currentVersion = extensionSettings.settingsVersion || 1;
let settingsChanged = false;
// Migration to version 2: Enable dynamic weather for existing users
if (currentVersion < 2) {
// console.log('[RPG Companion] Migrating settings to version 2 (enabling dynamic weather)');
extensionSettings.enableDynamicWeather = true;
extensionSettings.settingsVersion = 2;
settingsChanged = true;
}
// Migration to version 3: Convert text trackers to JSON format
if (currentVersion < 3) {
// console.log('[RPG Companion] Migrating settings to version 3 (JSON tracker format)');
migrateToV3JSON();
extensionSettings.settingsVersion = 3;
settingsChanged = true;
}
// Migration to version 4: Enable FAB widgets by default
if (currentVersion < 4) {
// console.log('[RPG Companion] Migrating settings to version 4 (enabling FAB widgets)');
if (!extensionSettings.mobileFabWidgets) {
extensionSettings.mobileFabWidgets = {};
}
extensionSettings.mobileFabWidgets.enabled = true;
extensionSettings.mobileFabWidgets.weatherIcon = { enabled: true };
extensionSettings.mobileFabWidgets.weatherDesc = { enabled: true };
extensionSettings.mobileFabWidgets.clock = { enabled: true };
extensionSettings.mobileFabWidgets.date = { enabled: true };
extensionSettings.mobileFabWidgets.location = { enabled: true };
extensionSettings.mobileFabWidgets.stats = { enabled: true };
extensionSettings.mobileFabWidgets.attributes = { enabled: true };
extensionSettings.settingsVersion = 4;
settingsChanged = true;
}
// Save migrated settings
if (settingsChanged) {
saveSettings();
}
// console.log('[RPG Companion] Settings loaded:', extensionSettings);
} else {
// console.log('[RPG Companion] No saved settings found, using defaults');
@@ -88,32 +132,21 @@ export function loadSettings() {
if (FEATURE_FLAGS.useNewInventory) {
const migrationResult = migrateInventory(extensionSettings.userStats.inventory);
if (migrationResult.migrated) {
console.log(`[RPG Companion] Inventory migrated from ${migrationResult.source} to v2 format`);
// console.log(`[RPG Companion] Inventory migrated from ${migrationResult.source} to v2 format`);
extensionSettings.userStats.inventory = migrationResult.inventory;
saveSettings(); // Persist migrated inventory
}
}
// 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');
// console.log('[RPG Companion] Migrating to trackerConfig format');
migrateToTrackerConfig();
saveSettings(); // Persist migration
}
// Migrate to preset manager system if presets don't exist
migrateToPresetManager();
} catch (error) {
console.error('[RPG Companion] Error loading settings:', error);
console.error('[RPG Companion] Error details:', error.message, error.stack);
@@ -149,6 +182,14 @@ export function saveChatData() {
return;
}
// console.log('[RPG Companion] 💾 saveChatData called - committedTrackerData:', {
// userStats: committedTrackerData.userStats ? `${committedTrackerData.userStats.substring(0, 50)}...` : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null'
// });
// console.log('[RPG Companion] 💾 saveChatData RAW committedTrackerData:', committedTrackerData);
// console.log('[RPG Companion] 💾 saveChatData RAW lastGeneratedData:', lastGeneratedData);
chat_metadata.rpg_companion = {
userStats: extensionSettings.userStats,
classicStats: extensionSettings.classicStats,
@@ -219,6 +260,10 @@ export function loadChatData() {
stored: {},
assets: "None"
}
},
quests: {
main: "None",
optional: []
}
});
setLastGeneratedData({
@@ -258,21 +303,36 @@ export function loadChatData() {
};
}
// Restore last generated data
if (savedData.lastGeneratedData) {
setLastGeneratedData({ ...savedData.lastGeneratedData });
// Restore committed tracker data first
if (savedData.committedTrackerData) {
// console.log('[RPG Companion] 📥 loadChatData restoring committedTrackerData:', {
// userStats: savedData.committedTrackerData.userStats ? `${savedData.committedTrackerData.userStats.substring(0, 50)}...` : 'null',
// infoBox: savedData.committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: savedData.committedTrackerData.characterThoughts ? 'exists' : 'null'
// });
// console.log('[RPG Companion] 📥 RAW savedData.committedTrackerData:', savedData.committedTrackerData);
// console.log('[RPG Companion] 📥 Type check:', {
// userStatsType: typeof savedData.committedTrackerData.userStats,
// infoBoxType: typeof savedData.committedTrackerData.infoBox,
// characterThoughtsType: typeof savedData.committedTrackerData.characterThoughts
// });
setCommittedTrackerData({ ...savedData.committedTrackerData });
}
// Restore committed tracker data
if (savedData.committedTrackerData) {
setCommittedTrackerData({ ...savedData.committedTrackerData });
// Restore last generated data (for display)
// Always prefer lastGeneratedData as it contains the most recent generation (including swipes)
if (savedData.lastGeneratedData) {
// console.log('[RPG Companion] 📥 loadChatData restoring lastGeneratedData');
setLastGeneratedData({ ...savedData.lastGeneratedData });
} else {
// console.log('[RPG Companion] ⚠️ No lastGeneratedData found in save');
}
// Migrate inventory in chat data if feature flag enabled
if (FEATURE_FLAGS.useNewInventory && extensionSettings.userStats.inventory) {
const migrationResult = migrateInventory(extensionSettings.userStats.inventory);
if (migrationResult.migrated) {
console.log(`[RPG Companion] Chat inventory migrated from ${migrationResult.source} to v2 format`);
// console.log(`[RPG Companion] Chat inventory migrated from ${migrationResult.source} to v2 format`);
extensionSettings.userStats.inventory = migrationResult.inventory;
saveChatData(); // Persist migrated inventory to chat metadata
}
@@ -362,7 +422,7 @@ function validateInventoryStructure(inventory, source) {
// Persist repairs if needed
if (needsSave) {
console.log(`[RPG Companion] Repaired inventory structure from ${source}, saving...`);
// console.log(`[RPG Companion] Repaired inventory structure from ${source}, saving...`);
saveSettings();
if (source === 'chat') {
saveChatData();
@@ -416,7 +476,7 @@ function migrateToTrackerConfig() {
{ id: 'physicalState', label: 'Physical State', enabled: true, placeholder: 'Visible Physical State (up to three traits)' },
{ id: 'demeanor', label: 'Demeanor Cue', enabled: true, placeholder: 'Observable Demeanor Cue (one trait)' },
{ id: 'relationship', label: 'Relationship', enabled: true, type: 'relationship', placeholder: 'Enemy/Neutral/Friend/Lover' },
{ id: 'internalMonologue', label: 'Internal Monologue', enabled: true, placeholder: 'Internal Monologue (in first person POV, up to three sentences long)' }
{ id: 'internalMonologue', label: 'Internal Monologue', enabled: true, placeholder: 'Internal Monologue (in first person from character\'s POV, up to three sentences long)' }
],
characterStats: {
enabled: false,
@@ -434,7 +494,7 @@ function migrateToTrackerConfig() {
name: extensionSettings.statNames[id] || id.charAt(0).toUpperCase() + id.slice(1),
enabled: true
}));
console.log('[RPG Companion] Migrated statNames to customStats array');
// console.log('[RPG Companion] Migrated statNames to customStats array');
}
// Ensure all stats have corresponding values in userStats
@@ -458,7 +518,7 @@ function migrateToTrackerConfig() {
{ id: 'cha', name: 'CHA', enabled: shouldShow }
];
delete extensionSettings.trackerConfig.userStats.showRPGAttributes;
console.log('[RPG Companion] Migrated showRPGAttributes to rpgAttributes array');
// console.log('[RPG Companion] Migrated showRPGAttributes to rpgAttributes array');
}
// Ensure rpgAttributes exists even if no migration was needed
@@ -496,7 +556,7 @@ function migrateToTrackerConfig() {
const hasOldFormat = pc.customFields.some(f => f.label || f.placeholder || f.type === 'relationship');
if (hasOldFormat) {
console.log('[RPG Companion] Migrating Present Characters to new structure');
// console.log('[RPG Companion] Migrating Present Characters to new structure');
// Extract relationship fields from old customFields
const relationshipFields = ['Lover', 'Friend', 'Ally', 'Enemy', 'Neutral'];
@@ -516,7 +576,7 @@ function migrateToTrackerConfig() {
const thoughts = {
enabled: thoughtsField ? (thoughtsField.enabled !== false) : true,
name: 'Thoughts',
description: thoughtsField?.placeholder || 'Internal monologue (in first person POV, up to three sentences long)'
description: thoughtsField?.placeholder || 'Internal Monologue (in first person from character\'s POV, up to three sentences long)'
};
// Update to new structure
@@ -524,7 +584,7 @@ function migrateToTrackerConfig() {
pc.customFields = newCustomFields;
pc.thoughts = thoughts;
console.log('[RPG Companion] Present Characters migration complete');
// console.log('[RPG Companion] Present Characters migration complete');
saveSettings(); // Persist the migration
}
}
@@ -543,12 +603,439 @@ function migrateToTrackerConfig() {
'Neutral': '⚖️'
};
}
// Migrate to new relationships structure if not already present
if (!pc.relationships) {
pc.relationships = {
enabled: true, // Default to enabled for backward compatibility
relationshipEmojis: pc.relationshipEmojis || {
'Lover': '❤️',
'Friend': '⭐',
'Ally': '🤝',
'Enemy': '⚔️',
'Neutral': '⚖️'
}
};
}
if (!pc.thoughts) {
pc.thoughts = {
enabled: true,
name: 'Thoughts',
description: 'Internal monologue (in first person POV, up to three sentences long)'
description: 'Internal Monologue (in first person from character\'s POV, up to three sentences long)'
};
}
}
}
// ============================================================================
// Preset Management Functions
// ============================================================================
/**
* Gets the entity key for the current character or group
* @returns {string|null} Entity key in format "char_{id}" or "group_{id}", or null if no character selected
*/
export function getCurrentEntityKey() {
const context = getContext();
if (context.groupId) {
return `group_${context.groupId}`;
} else if (context.characterId !== undefined && context.characterId !== null) {
return `char_${context.characterId}`;
}
return null;
}
/**
* Gets the display name for the current character or group
* @returns {string} Display name for the current entity
*/
export function getCurrentEntityName() {
const context = getContext();
if (context.groupId) {
const group = context.groups?.find(g => g.id === context.groupId);
return group?.name || 'Group Chat';
} else if (context.characterId !== undefined && context.characterId !== null) {
return context.name2 || 'Character';
}
return 'No Character';
}
/**
* Migrates existing trackerConfig to the preset system if presetManager doesn't exist
* Creates a "Default" preset from the current trackerConfig
*/
export function migrateToPresetManager() {
if (!extensionSettings.presetManager || Object.keys(extensionSettings.presetManager.presets || {}).length === 0) {
// console.log('[RPG Companion] Migrating to preset manager system');
// Initialize presetManager if it doesn't exist
if (!extensionSettings.presetManager) {
extensionSettings.presetManager = {
presets: {},
characterAssociations: {},
activePresetId: null,
defaultPresetId: null
};
}
// Create default preset from existing trackerConfig
const defaultPresetId = 'preset_default';
extensionSettings.presetManager.presets[defaultPresetId] = {
id: defaultPresetId,
name: 'Default',
trackerConfig: JSON.parse(JSON.stringify(extensionSettings.trackerConfig))
};
extensionSettings.presetManager.activePresetId = defaultPresetId;
extensionSettings.presetManager.defaultPresetId = defaultPresetId;
// console.log('[RPG Companion] Created Default preset from existing trackerConfig');
saveSettings();
}
}
/**
* Gets all available presets
* @returns {Object} Map of preset ID to preset data
*/
export function getPresets() {
return extensionSettings.presetManager?.presets || {};
}
/**
* Gets a specific preset by ID
* @param {string} presetId - The preset ID
* @returns {Object|null} The preset object or null if not found
*/
export function getPreset(presetId) {
return extensionSettings.presetManager?.presets?.[presetId] || null;
}
/**
* Gets the currently active preset ID
* @returns {string|null} The active preset ID or null
*/
export function getActivePresetId() {
return extensionSettings.presetManager?.activePresetId || null;
}
/**
* Gets the default preset ID
* @returns {string|null} The default preset ID or null
*/
export function getDefaultPresetId() {
return extensionSettings.presetManager?.defaultPresetId || null;
}
/**
* Sets a preset as the default
* @param {string} presetId - The preset ID to set as default
*/
export function setDefaultPreset(presetId) {
if (extensionSettings.presetManager.presets[presetId]) {
extensionSettings.presetManager.defaultPresetId = presetId;
saveSettings();
// console.log(`[RPG Companion] Set preset ${presetId} as default`);
}
}
/**
* Checks if the given preset is the default
* @param {string} presetId - The preset ID to check
* @returns {boolean} True if it's the default preset
*/
export function isDefaultPreset(presetId) {
return extensionSettings.presetManager?.defaultPresetId === presetId;
}
/**
* Creates a new preset from the current trackerConfig
* @param {string} name - Name for the new preset
* @returns {string} The ID of the newly created preset
*/
export function createPreset(name) {
const presetId = `preset_${Date.now()}`;
extensionSettings.presetManager.presets[presetId] = {
id: presetId,
name: name,
trackerConfig: JSON.parse(JSON.stringify(extensionSettings.trackerConfig)),
historyPersistence: extensionSettings.historyPersistence
? JSON.parse(JSON.stringify(extensionSettings.historyPersistence))
: null
};
// Also set it as the active preset so edits go to the new preset
extensionSettings.presetManager.activePresetId = presetId;
saveSettings();
// console.log(`[RPG Companion] Created preset "${name}" with ID ${presetId}`);
return presetId;
}
/**
* Saves the current trackerConfig and historyPersistence to the specified preset
* @param {string} presetId - The preset ID to save to
*/
export function saveToPreset(presetId) {
const preset = extensionSettings.presetManager.presets[presetId];
if (preset) {
preset.trackerConfig = JSON.parse(JSON.stringify(extensionSettings.trackerConfig));
preset.historyPersistence = extensionSettings.historyPersistence
? JSON.parse(JSON.stringify(extensionSettings.historyPersistence))
: null;
saveSettings();
// console.log(`[RPG Companion] Saved current config to preset "${preset.name}"`);
}
}
/**
* Loads a preset's trackerConfig and historyPersistence as the active configuration
* @param {string} presetId - The preset ID to load
* @returns {boolean} True if loaded successfully, false otherwise
*/
export function loadPreset(presetId) {
const preset = extensionSettings.presetManager.presets[presetId];
if (preset && preset.trackerConfig) {
extensionSettings.trackerConfig = JSON.parse(JSON.stringify(preset.trackerConfig));
// Load historyPersistence if present, otherwise use defaults
if (preset.historyPersistence) {
extensionSettings.historyPersistence = JSON.parse(JSON.stringify(preset.historyPersistence));
} else {
// Default values for presets that don't have historyPersistence yet
extensionSettings.historyPersistence = {
enabled: false,
messageCount: 5,
injectionPosition: 'assistant_message_end',
contextPreamble: ''
};
}
extensionSettings.presetManager.activePresetId = presetId;
saveSettings();
// console.log(`[RPG Companion] Loaded preset "${preset.name}"`);
return true;
}
return false;
}
/**
* Renames a preset
* @param {string} presetId - The preset ID to rename
* @param {string} newName - The new name for the preset
*/
export function renamePreset(presetId, newName) {
const preset = extensionSettings.presetManager.presets[presetId];
if (preset) {
preset.name = newName;
saveSettings();
// console.log(`[RPG Companion] Renamed preset to "${newName}"`);
}
}
/**
* Deletes a preset
* @param {string} presetId - The preset ID to delete
* @returns {boolean} True if deleted, false if it's the last preset (can't delete)
*/
export function deletePreset(presetId) {
const presets = extensionSettings.presetManager.presets;
const presetIds = Object.keys(presets);
// Don't delete if it's the last preset
if (presetIds.length <= 1) {
// console.warn('[RPG Companion] Cannot delete the last preset');
return false;
}
// Remove any character associations using this preset
const associations = extensionSettings.presetManager.characterAssociations;
for (const entityKey of Object.keys(associations)) {
if (associations[entityKey] === presetId) {
delete associations[entityKey];
}
}
// Delete the preset
delete presets[presetId];
// If the deleted preset was active, switch to the first available preset
if (extensionSettings.presetManager.activePresetId === presetId) {
const remainingIds = Object.keys(presets);
if (remainingIds.length > 0) {
loadPreset(remainingIds[0]);
}
}
saveSettings();
// console.log(`[RPG Companion] Deleted preset ${presetId}`);
return true;
}
/**
* Associates the current preset with the current character/group
*/
export function associatePresetWithCurrentEntity() {
const entityKey = getCurrentEntityKey();
const activePresetId = extensionSettings.presetManager.activePresetId;
if (entityKey && activePresetId) {
extensionSettings.presetManager.characterAssociations[entityKey] = activePresetId;
saveSettings();
// console.log(`[RPG Companion] Associated preset ${activePresetId} with ${entityKey}`);
}
}
/**
* Removes the preset association for the current character/group
*/
export function removePresetAssociationForCurrentEntity() {
const entityKey = getCurrentEntityKey();
if (entityKey && extensionSettings.presetManager.characterAssociations[entityKey]) {
delete extensionSettings.presetManager.characterAssociations[entityKey];
saveSettings();
// console.log(`[RPG Companion] Removed preset association for ${entityKey}`);
}
}
/**
* Gets the preset ID associated with the current character/group
* @returns {string|null} The associated preset ID or null
*/
export function getPresetForCurrentEntity() {
const entityKey = getCurrentEntityKey();
if (entityKey) {
return extensionSettings.presetManager.characterAssociations[entityKey] || null;
}
return null;
}
/**
* Checks if the current character/group has a preset association
* @returns {boolean} True if there's an association
*/
export function hasPresetAssociation() {
const entityKey = getCurrentEntityKey();
return entityKey && extensionSettings.presetManager.characterAssociations[entityKey] !== undefined;
}
/**
* Auto-switches to the preset associated with the current character/group
* Called when character changes. Falls back to default preset if no association.
* @returns {boolean} True if a preset was switched, false otherwise
*/
export function autoSwitchPresetForEntity() {
const associatedPresetId = getPresetForCurrentEntity();
// If there's a character-specific preset, use it
if (associatedPresetId && associatedPresetId !== extensionSettings.presetManager.activePresetId) {
// Check if the preset still exists
if (extensionSettings.presetManager.presets[associatedPresetId]) {
return loadPreset(associatedPresetId);
} else {
// Preset was deleted, remove the stale association
removePresetAssociationForCurrentEntity();
}
}
// No character association - fall back to default preset if set
if (!associatedPresetId) {
const defaultPresetId = extensionSettings.presetManager.defaultPresetId;
if (defaultPresetId &&
defaultPresetId !== extensionSettings.presetManager.activePresetId &&
extensionSettings.presetManager.presets[defaultPresetId]) {
return loadPreset(defaultPresetId);
}
}
return false;
}
/**
* Exports presets for sharing (without character associations)
* @param {string[]} presetIds - Array of preset IDs to export, or empty for all
* @returns {Object} Export data object
*/
export function exportPresets(presetIds = []) {
const presetsToExport = {};
const allPresets = extensionSettings.presetManager.presets;
// If no specific IDs provided, export all
const idsToExport = presetIds.length > 0 ? presetIds : Object.keys(allPresets);
for (const id of idsToExport) {
if (allPresets[id]) {
presetsToExport[id] = {
id: allPresets[id].id,
name: allPresets[id].name,
trackerConfig: allPresets[id].trackerConfig
};
}
}
return {
version: '1.0',
exportDate: new Date().toISOString(),
presets: presetsToExport
// Note: characterAssociations are intentionally NOT exported
};
}
/**
* Imports presets from an export file
* @param {Object} importData - The imported data object
* @param {boolean} overwrite - If true, overwrites existing presets with same name
* @returns {number} Number of presets imported
*/
export function importPresets(importData, overwrite = false) {
if (!importData.presets || typeof importData.presets !== 'object') {
throw new Error('Invalid import data: missing presets');
}
let importCount = 0;
const existingNames = new Set(
Object.values(extensionSettings.presetManager.presets).map(p => p.name.toLowerCase())
);
for (const [originalId, preset] of Object.entries(importData.presets)) {
if (!preset.name || !preset.trackerConfig) {
continue; // Skip invalid presets
}
let name = preset.name;
const nameLower = name.toLowerCase();
// Check for name collision
if (existingNames.has(nameLower)) {
if (overwrite) {
// Find and delete the existing preset with this name
for (const [existingId, existingPreset] of Object.entries(extensionSettings.presetManager.presets)) {
if (existingPreset.name.toLowerCase() === nameLower) {
delete extensionSettings.presetManager.presets[existingId];
break;
}
}
} else {
// Generate a unique name
let counter = 1;
while (existingNames.has(`${nameLower} (${counter})`)) {
counter++;
}
name = `${preset.name} (${counter})`;
}
}
// Create new preset with new ID
const newId = `preset_${Date.now()}_${importCount}`;
extensionSettings.presetManager.presets[newId] = {
id: newId,
name: name,
trackerConfig: JSON.parse(JSON.stringify(preset.trackerConfig))
};
existingNames.add(name.toLowerCase());
importCount++;
}
if (importCount > 0) {
saveSettings();
}
return importCount;
}
+223 -79
View File
@@ -10,18 +10,51 @@
* Extension settings - persisted to SillyTavern settings
*/
export let extensionSettings = {
settingsVersion: 4, // Version number for settings migrations (v4 = FAB widgets enabled by default)
enabled: true,
autoUpdate: true,
autoUpdate: false,
updateDepth: 4, // How many messages to include in the context
generationMode: 'together', // 'separate' or 'together' - whether to generate with main response or separately
useSeparatePreset: false, // Use 'RPG Companion Trackers' preset for tracker generation instead of main API model
showUserStats: true,
showInfoBox: true,
showCharacterThoughts: true,
showInventory: true, // Show inventory section (v2 system)
showQuests: true, // Show quests section
showThoughtsInChat: true, // Show thoughts overlay in chat
narratorMode: false, // Use character card as narrator instead of fixed character references
customNarratorPrompt: '', // Custom narrator mode prompt text (empty = use default)
enableHtmlPrompt: false, // Enable immersive HTML prompt injection
enablePlotButtons: true, // Show plot progression buttons above chat input
customHtmlPrompt: '', // Custom HTML prompt text (empty = use default)
enableDialogueColoring: false, // Enable dialogue coloring prompt injection
customDialogueColoringPrompt: '', // Custom dialogue coloring prompt text (empty = use default)
enableDeceptionSystem: false, // Enable deception tracking with <lie> tags
customDeceptionPrompt: '', // Custom deception prompt text (empty = use default)
enableCYOA: false, // Enable "Choose Your Own Adventure" formatting with action choices
customCYOAPrompt: '', // Custom CYOA prompt text (empty = use default)
enableSpotifyMusic: false, // Enable Spotify music integration (asks AI for Spotify URLs)
customSpotifyPrompt: '', // Custom Spotify prompt text (empty = use default)
enableDynamicWeather: true, // Enable dynamic weather effects based on Info Box weather field (v2: enabled by default)
dismissedHolidayPromo: false, // User dismissed the holiday promotion banner
showHtmlToggle: true, // Show Immersive HTML toggle in main panel
showDialogueColoringToggle: true, // Show Dialogue Coloring toggle in main panel (enabled by default)
showDeceptionToggle: true, // Show Deception System toggle in main panel
showCYOAToggle: true, // Show CYOA toggle in main panel
showSpotifyToggle: true, // Show Spotify Music toggle in main panel
showDynamicWeatherToggle: true, // Show Dynamic Weather Effects toggle in main panel
showNarratorMode: true, // Show Narrator Mode toggle in main panel
showAutoAvatars: true, // Show Auto-generate Avatars toggle in main panel
skipInjectionsForGuided: 'none', // skip injections for instruct injections and quiet prompts (GuidedGenerations compatibility)
enableRandomizedPlot: true, // Show randomized plot progression button above chat input
enableNaturalPlot: true, // Show natural plot progression button above chat input
// History persistence settings - inject selected tracker data into historical messages
historyPersistence: {
enabled: false, // Master toggle for history persistence feature
messageCount: 5, // Number of messages to include (0 = all available)
injectionPosition: 'assistant_message_end', // 'user_message_end', 'assistant_message_end', 'extra_user_message', 'extra_assistant_message'
contextPreamble: '' // Optional custom preamble text (empty = use default short one)
},
panelPosition: 'right', // 'left', 'right', or 'top'
theme: 'default', // Theme: default, sci-fi, fantasy, cyberpunk, custom
customColors: {
@@ -37,22 +70,38 @@ export let extensionSettings = {
top: 'calc(var(--topBarBlockSize) + 60px)',
right: '12px'
}, // Saved position for mobile FAB button
userStats: {
health: 100,
satiety: 100,
energy: 100,
hygiene: 100,
arousal: 0,
mood: '😐',
conditions: 'None',
/** @type {InventoryV2} */
inventory: {
version: 2,
onPerson: "None",
stored: {},
assets: "None"
}
// Mobile FAB widget display options (8-position system around the button)
mobileFabWidgets: {
enabled: true, // Master toggle for FAB widgets
weatherIcon: { enabled: true, position: 0 }, // Weather emoji (☀️, 🌧️, etc.)
weatherDesc: { enabled: true, position: 1 }, // Weather description text
clock: { enabled: true, position: 2 }, // Current time display
date: { enabled: true, position: 3 }, // Date display
location: { enabled: true, position: 4 }, // Location name
stats: { enabled: true, position: 5 }, // All stats as compact numbers
attributes: { enabled: true, position: 6 } // Compact RPG attributes display
},
userStats: JSON.stringify({
stats: [
{ id: 'health', name: 'Health', value: 100 },
{ id: 'satiety', name: 'Satiety', value: 100 },
{ id: 'energy', name: 'Energy', value: 100 },
{ id: 'hygiene', name: 'Hygiene', value: 100 },
{ id: 'arousal', name: 'Arousal', value: 0 }
],
status: {
mood: '😐',
conditions: 'None'
},
inventory: {
onPerson: [],
stored: []
},
quests: {
active: [],
completed: []
}
}, null, 2),
statNames: {
health: 'Health',
satiety: 'Satiety',
@@ -65,51 +114,71 @@ export let extensionSettings = {
userStats: {
// Array of custom stats (allows add/remove/rename)
customStats: [
{ id: 'health', name: 'Health', enabled: true },
{ id: 'satiety', name: 'Satiety', enabled: true },
{ id: 'energy', name: 'Energy', enabled: true },
{ id: 'hygiene', name: 'Hygiene', enabled: true },
{ id: 'arousal', name: 'Arousal', enabled: true }
{ id: 'health', name: 'Health', enabled: true, persistInHistory: false },
{ id: 'satiety', name: 'Satiety', enabled: true, persistInHistory: false },
{ id: 'energy', name: 'Energy', enabled: true, persistInHistory: false },
{ id: 'hygiene', name: 'Hygiene', enabled: true, persistInHistory: false },
{ id: 'arousal', name: 'Arousal', enabled: true, persistInHistory: false }
],
// RPG Attributes (customizable D&D-style attributes)
showRPGAttributes: true,
showLevel: true, // Show/hide level in UI and prompts
alwaysSendAttributes: false, // If true, always send attributes; if false, only send with dice rolls
rpgAttributes: [
{ id: 'str', name: 'STR', enabled: true },
{ id: 'dex', name: 'DEX', enabled: true },
{ id: 'con', name: 'CON', enabled: true },
{ id: 'int', name: 'INT', enabled: true },
{ id: 'wis', name: 'WIS', enabled: true },
{ id: 'cha', name: 'CHA', enabled: true }
{ id: 'str', name: 'STR', enabled: true, persistInHistory: false },
{ id: 'dex', name: 'DEX', enabled: true, persistInHistory: false },
{ id: 'con', name: 'CON', enabled: true, persistInHistory: false },
{ id: 'int', name: 'INT', enabled: true, persistInHistory: false },
{ id: 'wis', name: 'WIS', enabled: true, persistInHistory: false },
{ id: 'cha', name: 'CHA', enabled: true, persistInHistory: false }
],
// Status section config
statusSection: {
enabled: true,
showMoodEmoji: true,
customFields: ['Conditions'] // User can edit what to track
customFields: ['Conditions'], // User can edit what to track
persistInHistory: false // Persist status in historical messages
},
// Optional skills field
skillsSection: {
enabled: false,
label: 'Skills' // User-editable
}
label: 'Skills', // User-editable
customFields: [], // Array of skill names
persistInHistory: false // Persist skills in historical messages
},
// Inventory persistence
inventoryPersistInHistory: false, // Persist inventory in historical messages
// Quests persistence
questsPersistInHistory: false // Persist quests in historical messages
},
infoBox: {
widgets: {
date: { enabled: true, format: 'Weekday, Month, Year' }, // Format options in UI
weather: { enabled: true },
temperature: { enabled: true, unit: 'C' }, // 'C' or 'F'
time: { enabled: true },
location: { enabled: true },
recentEvents: { enabled: true }
date: { enabled: true, format: 'Weekday, Month, Year', persistInHistory: true }, // Date enabled by default for history
weather: { enabled: true, persistInHistory: true }, // Weather enabled by default for history
temperature: { enabled: true, unit: 'C', persistInHistory: false }, // 'C' or 'F'
time: { enabled: true, persistInHistory: true }, // Time enabled by default for history
location: { enabled: true, persistInHistory: true }, // Location enabled by default for history
recentEvents: { enabled: true, persistInHistory: false }
}
},
presentCharacters: {
// Fixed fields (always shown)
showEmoji: true,
showName: true,
// Relationship fields (shown after name, separated by /)
// Relationship fields configuration
relationships: {
enabled: true,
// Relationship to emoji mapping (shown on character portraits)
relationshipEmojis: {
'Lover': '❤️',
'Friend': '⭐',
'Ally': '🤝',
'Enemy': '⚔️',
'Neutral': '⚖️'
}
},
// Legacy fields kept for backward compatibility
relationshipFields: ['Lover', 'Friend', 'Ally', 'Enemy', 'Neutral'],
// Relationship to emoji mapping (shown on character portraits)
relationshipEmojis: {
'Lover': '❤️',
'Friend': '⭐',
@@ -119,14 +188,15 @@ export let extensionSettings = {
},
// Custom fields (appearance, demeanor, etc. - shown after relationship, separated by |)
customFields: [
{ id: 'appearance', name: 'Appearance', enabled: true, description: 'Visible physical appearance (clothing, hair, notable features)' },
{ id: 'demeanor', name: 'Demeanor', enabled: true, description: 'Observable demeanor or emotional state' }
{ id: 'appearance', name: 'Appearance', enabled: true, description: 'Visible physical appearance (clothing, hair, notable features)', persistInHistory: false },
{ id: 'demeanor', name: 'Demeanor', enabled: true, description: 'Observable demeanor or emotional state', persistInHistory: false }
],
// Thoughts configuration (separate line)
thoughts: {
enabled: true,
name: 'Thoughts',
description: 'Internal monologue (in first person POV, up to three sentences long)'
description: 'Internal Monologue (in first person from character\'s POV, up to three sentences long)',
persistInHistory: false
},
// Character stats toggle (optional feature)
characterStats: {
@@ -142,6 +212,16 @@ export let extensionSettings = {
main: "None", // Current main quest title
optional: [] // Array of optional quest titles
},
infoBox: JSON.stringify({
date: { value: new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }) },
weather: { emoji: '☀️', forecast: 'Clear skies' },
temperature: { value: 20, unit: 'C' },
time: { start: '00:00', end: '00:00' },
location: { value: 'Unknown Location' }
}, null, 2),
characterThoughts: JSON.stringify({
characters: []
}, null, 2),
level: 1, // User's character level
classicStats: {
str: 10,
@@ -152,49 +232,66 @@ export let extensionSettings = {
cha: 10
},
lastDiceRoll: null, // Store last dice roll result
showDiceDisplay: true, // Show the "Last Roll" display in the panel
collapsedInventoryLocations: [], // Array of collapsed storage location names
inventoryViewModes: {
onPerson: 'list', // 'list' or 'grid' view mode for On Person section
stored: 'list', // 'list' or 'grid' view mode for Stored section
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
npcAvatars: {}, // Store custom avatar images for NPCs (key: character name, value: base64 data URI)
// Combat encounter settings
encounterSettings: {
enabled: true, // Show Start Encounter button above chat input
historyDepth: 8, // Number of recent messages to include in combat initialization
autoSaveLogs: false // Save detailed combat logs to file
},
// Auto avatar generation settings
autoGenerateAvatars: true, // Master toggle for auto-generating avatars
avatarLLMCustomInstruction: '', // Custom instruction for LLM prompt generation
// External API settings for 'external' generation mode
externalApiSettings: {
baseUrl: '', // OpenAI-compatible API base URL (e.g., "https://api.openai.com/v1")
// apiKey is NOT stored here for security. It is stored in localStorage('rpg_companion_api_key')
model: '', // Model identifier (e.g., "gpt-4o-mini")
maxTokens: 8192, // Maximum tokens for generation
temperature: 0.7 // Temperature setting for generation
},
// Lock state for tracker items (v3 JSON format feature)
lockedItems: {
stats: [], // Array of locked stat IDs (e.g., ["health", "satiety"])
skills: [], // Array of locked skill names (e.g., ["Cooking", "Swordsmanship"])
inventory: {
onPerson: [], // Array of locked item indices (e.g., [0, 2])
clothing: [], // Array of locked item indices
stored: {}, // Object with location keys, each containing array of locked indices (e.g., {"Home": [0, 1]})
assets: [] // Array of locked asset indices
},
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
quests: {
main: false, // Boolean for main quest lock
optional: [] // Array of locked optional quest indices (e.g., [0, 2])
},
infoBox: {
date: false, // Boolean for date widget lock
weather: false, // Boolean for weather widget lock
temperature: false, // Boolean for temperature widget lock
time: false, // Boolean for time widget lock
location: false, // Boolean for location widget lock
recentEvents: false // Boolean for recent events widget lock
},
characters: {} // Object mapping character names to their locked fields (e.g., {"Sarah": {relationship: true, thoughts: false}})
},
// Preset management for tracker configurations
presetManager: {
// Map of preset ID to preset data (contains name and trackerConfig)
presets: {},
// Map of character/group entity to preset ID (e.g., "char_0": "preset_123", "group_abc": "preset_456")
// Note: This is stored separately and NOT exported with presets
characterAssociations: {},
// Currently active preset ID
activePresetId: null,
// Default preset ID (used when no character association exists)
defaultPresetId: null
}
};
@@ -218,6 +315,25 @@ export let committedTrackerData = {
characterThoughts: null
};
/**
* Session-only storage for LLM-generated avatar prompts
* Maps character names to their generated prompts
* Resets on new chat (not persisted to extensionSettings)
*/
export let sessionAvatarPrompts = {};
export function setSessionAvatarPrompt(characterName, prompt) {
sessionAvatarPrompts[characterName] = prompt;
}
export function getSessionAvatarPrompt(characterName) {
return sessionAvatarPrompts[characterName] || null;
}
export function clearSessionAvatarPrompts() {
sessionAvatarPrompts = {};
}
/**
* Tracks whether the last action was a swipe (for separate mode)
* Used to determine whether to commit lastGeneratedData to committedTrackerData
@@ -234,6 +350,12 @@ export let isGenerating = false;
*/
export let isPlotProgression = false;
/**
* Flag indicating if we're actively expecting a new message from generation
* (as opposed to loading chat history)
*/
export let isAwaitingNewMessage = false;
/**
* Temporary storage for pending dice roll (not saved until user clicks "Save Roll")
*/
@@ -280,6 +402,7 @@ export let $infoBoxContainer = null;
export let $thoughtsContainer = null;
export let $inventoryContainer = null;
export let $questsContainer = null;
export let $musicPlayerContainer = null;
/**
* State setters - provide controlled mutation of state variables
@@ -301,11 +424,24 @@ export function updateLastGeneratedData(updates) {
}
export function setCommittedTrackerData(data) {
// console.log('[RPG State] setCommittedTrackerData called with:', data);
// console.log('[RPG State] Type check on input:', {
// userStatsType: typeof data.userStats,
// infoBoxType: typeof data.infoBox,
// characterThoughtsType: typeof data.characterThoughts,
// userStatsValue: data.userStats,
// infoBoxValue: data.infoBox,
// characterThoughtsValue: data.characterThoughts
// });
committedTrackerData = data;
// console.log('[RPG State] committedTrackerData after assignment:', committedTrackerData);
}
export function updateCommittedTrackerData(updates) {
// console.log('[RPG State] updateCommittedTrackerData called with:', updates);
// console.log('[RPG State] committedTrackerData before update:', committedTrackerData);
Object.assign(committedTrackerData, updates);
// console.log('[RPG State] committedTrackerData after update:', committedTrackerData);
}
export function setLastActionWasSwipe(value) {
@@ -320,6 +456,10 @@ export function setIsPlotProgression(value) {
isPlotProgression = value;
}
export function setIsAwaitingNewMessage(value) {
isAwaitingNewMessage = value;
}
export function setPendingDiceRoll(roll) {
pendingDiceRoll = roll;
}
@@ -351,3 +491,7 @@ export function setInventoryContainer($element) {
export function setQuestsContainer($element) {
$questsContainer = $element;
}
export function setMusicPlayerContainer($element) {
$musicPlayerContainer = $element;
}
+235
View File
@@ -0,0 +1,235 @@
{
"settings.language.label": "Language",
"settings.language.option.en": "English",
"settings.language.option.zh-tw": "繁體中文",
"settings.extensionEnabled": "Enable RPG Companion",
"settings.note": "Toggle to enable/disable the RPG Companion extension. Configure additional settings within the panel itself.",
"template.settingsTitle": "RPG Companion Settings",
"template.settingsModal.themeTitle": "Theme",
"template.settingsModal.themeLabel": "Visual Theme:",
"template.settingsModal.themeOptions.default": "Default",
"template.settingsModal.themeOptions.sciFi": "Sci-Fi (Synthwave)",
"template.settingsModal.themeOptions.fantasy": "Fantasy (Rustic Parchment)",
"template.settingsModal.themeOptions.cyberpunk": "Cyberpunk (Neon Grid)",
"template.settingsModal.themeOptions.custom": "Custom",
"template.settingsModal.themeOptions.custom.background": "Background:",
"template.settingsModal.themeOptions.custom.accent": "Accent:",
"template.settingsModal.themeOptions.custom.text": "Text:",
"template.settingsModal.themeOptions.custom.highlight": "Highlight:",
"template.settingsModal.theme.statBarLow": "Stat Bar Color (Low):",
"template.settingsModal.theme.statBarLowNote": "Color when stats are at 0%.",
"template.settingsModal.theme.statBarHigh": "Stat Bar Color (High):",
"template.settingsModal.theme.statBarHighNote": "Color when stats are at 100%.",
"template.settingsModal.displayTitle": "Display Options",
"template.settingsModal.displayNote": "You can enable/disable the entire RPG Companion extension in the Extensions tab of the SillyTavern.",
"template.settingsModal.display.panelPosition": "Panel Position:",
"template.settingsModal.display.panelPositionOptions.right": "Right Sidebar",
"template.settingsModal.display.panelPositionOptions.left": "Left Sidebar",
"template.settingsModal.display.toggleAutoUpdate": "Auto-update after messages",
"template.settingsModal.display.toggleAutoUpdateNote": "Automatically refresh RPG info after each message.",
"template.settingsModal.display.showUserStats": "Show User Stats",
"template.settingsModal.display.showUserStatsNote": "Enable User Stats that track your persona's statistics, mood, attributes, skills, etc.",
"template.settingsModal.display.showInfoBox": "Show Info Box",
"template.settingsModal.display.showInfoBoxNote": "Display location, time, weather, and recent events.",
"template.settingsModal.display.showPresentCharacters": "Show Present Characters",
"template.settingsModal.display.showPresentCharactersNote": "Display character portraits with their current thoughts and status.",
"template.settingsModal.display.narratorMode": "Narrator Mode",
"template.settingsModal.display.narratorModeNote": "Use character card as narrator. Infer characters from context instead of using fixed character references.",
"template.settingsModal.display.showInventory": "Show Inventory",
"template.settingsModal.display.showInventoryNote": "Track items carried, clothing worn, stored items, and assets.",
"template.settingsModal.display.showQuests": "Show Quests",
"template.settingsModal.display.showQuestsNote": "Manage main and optional quests with objectives.",
"template.settingsModal.display.showLockIcons": "Show Locking/Unlocking Trackers",
"template.settingsModal.display.showLockIconsNote": "Display lock/unlock icons on tracker items to prevent AI from modifying them.",
"template.settingsModal.display.showThoughtsInChat": "Show Thoughts",
"template.settingsModal.display.showThoughtsInChatNote": "Display character thoughts as overlay bubbles next to their messages.",
"template.settingsModal.display.alwaysShowThoughtBubble": "Always Show Thought Bubble",
"template.settingsModal.display.alwaysShowThoughtBubbleNote": "Auto-expand thought bubble without clicking the icon first",
"template.settingsModal.display.enableAnimations": "Enable Animations",
"template.settingsModal.display.enableAnimationsNote": "Smooth transitions for stats, content updates, and dice rolls.",
"template.settingsModal.display.showImmersiveHtmlToggle": "Show Immersive HTML",
"template.settingsModal.display.showImmersiveHtmlToggleNote": "Display a toggle button to enable/disable HTML formatting in messages.",
"template.settingsModal.display.showDialogueColoringToggle": "Show Colored Dialogues",
"template.settingsModal.display.showDialogueColoringToggleNote": "Display a toggle button to enable/disable colored dialogue formatting.",
"template.settingsModal.display.showSpotifyMusicToggle": "Show Spotify Music",
"template.settingsModal.display.showSpotifyMusicToggleNote": "Display Spotify music player with AI-suggested scene-appropriate tracks.",
"template.settingsModal.display.showSnowflakesToggle": "Show Snowflakes Effect",
"template.settingsModal.display.showDynamicWeatherToggle": "Show Dynamic Weather Effects",
"template.settingsModal.display.showDynamicWeatherToggleNote": "Display a toggle button to enable/disable animated weather effects.",
"template.settingsModal.display.showNarratorMode": "Show Narrator Mode",
"template.settingsModal.display.showNarratorModeNote": "Display a toggle button to enable/disable narrator mode (infer characters from context).",
"template.settingsModal.display.showAutoAvatars": "Show Auto-generate Avatars",
"template.settingsModal.display.showAutoAvatarsNote": "Display a toggle button to automatically generate avatars for characters without images.",
"template.settingsModal.display.showRandomizedPlot": "Show Randomized Plot Progression",
"template.settingsModal.display.showRandomizedPlotNote": "Display button for AI-generated random plot progression prompts.",
"template.settingsModal.display.showNaturalPlot": "Show Natural Plot Progression",
"template.settingsModal.display.showNaturalPlotNote": "Display button for context-aware narrative continuation prompts.",
"template.settingsModal.display.showStartEncounter": "Show Start Encounter",
"template.settingsModal.display.showStartEncounterNote": "Display button to initiate interactive combat encounters.",
"template.settingsModal.display.showDiceDisplay": "Show Dice Roll Display",
"template.settingsModal.display.showDiceDisplayNote": "Display the \"Last Roll\" indicator in the panel.",
"template.mainPanel.autoAvatars": "Auto Avatars",
"template.settingsModal.advancedTitle": "Advanced",
"template.settingsModal.advanced.encounterHistoryDepth": "Chat History Depth For Encounters:",
"template.settingsModal.advanced.encounterHistoryDepthNote": "Number of recent messages to include in combat initialization.",
"template.settingsModal.advanced.autoSaveCombatLogs": "Auto-save Combat Logs",
"template.settingsModal.advanced.autoSaveCombatLogsNote": "Save detailed combat logs to file for future reference and analysis.",
"template.settingsModal.advanced.clearCacheNote": "Clears committed and displayed tracker data for your currently active chat.",
"template.settingsModal.advanced.generationMode": "Generation Mode:",
"template.settingsModal.advanced.generationModeOptions.together": "Together with Main Generation",
"template.settingsModal.advanced.generationModeOptions.separate": "Separate Generation",
"template.settingsModal.advanced.generationModeNote": "Together: Adds RPG tracking to main roleplay. Separate: Generates RPG data separately (manual or auto). External: Connects to an OpenAI-compatible endpoint directly.",
"template.settingsModal.advanced.generationModeOptions.external": "External API",
"template.settingsModal.advanced.externalApi.title": "External API Settings",
"template.settingsModal.advanced.externalApi.baseUrl": "API Base URL",
"template.settingsModal.advanced.externalApi.baseUrlNote": "OpenAI-compatible endpoint (e.g., OpenAI, OpenRouter, local LLM server).",
"template.settingsModal.advanced.externalApi.apiKey": "API Key",
"template.settingsModal.advanced.externalApi.apiKeyNote": "Your API key for the external service.",
"template.settingsModal.advanced.externalApi.model": "Model",
"template.settingsModal.advanced.externalApi.modelNote": "Model identifier (e.g., gpt-4o-mini, claude-3-haiku, mistral-7b).",
"template.settingsModal.advanced.externalApi.maxTokens": "Max Tokens",
"template.settingsModal.advanced.externalApi.temperature": "Temperature",
"template.settingsModal.advanced.externalApi.testConnection": "Test Connection",
"template.settingsModal.advanced.contextMessages": "Context Messages:",
"template.settingsModal.advanced.contextMessagesNote": "Number of recent messages to include.",
"template.settingsModal.advanced.useSeparatePreset": "Use model connected to RPG Companion Trackers preset",
"template.settingsModal.advanced.useSeparatePresetNote": "When enabled, tracker generation will use the model from the \"RPG Companion Trackers\" preset instead of your main API model. The preset will be switched automatically during generation and restored afterward. Select the desired model in that preset and make sure the \"Bind presets to API connections\" toggle is on (next to the import/export preset buttons).",
"template.settingsModal.advanced.skipInjections": "Skip Injections During Guided Generations:",
"template.settingsModal.advanced.skipInjectionsOptions.none": "Never skip",
"template.settingsModal.advanced.skipInjectionsOptions.impersonation": "Only on impersonation requests",
"template.settingsModal.advanced.skipInjectionsOptions.guided": "Always for guided or quiet prompts",
"template.settingsModal.advanced.skipInjectionsNote": "When set, the extension will not inject tracker prompts, examples, or HTML instructions according to the selected mode when a guided generation (via `instruct` or `quiet_prompt`) is detected. Useful when using GuidedGenerations or similar extensions.",
"template.settingsModal.advanced.customHtmlPromptTitle": "Custom HTML Prompt:",
"template.settingsModal.advanced.restoreDefaultHtmlPrompt": "Restore Default",
"template.settingsModal.advanced.customHtmlPromptNote": "Customize the HTML prompt injected when \"Enable Immersive HTML\" is enabled. The default prompt is shown above - you can edit it directly or replace it entirely. Click \"Restore Default\" to reset. This affects all generation modes (together, separate, and plot progression).",
"template.settingsModal.advanced.clearCache": "Clear Extension Cache",
"template.settingsModal.advanced.resetFabPositions": "Reset Button Positions",
"template.settingsModal.advanced.resetFabPositionsNote": "Resets all floating action buttons (toggle, refresh, debug) to default top-left positions. Useful if buttons are off-screen.",
"template.trackerEditorModal.title": "Edit Trackers",
"template.trackerEditorModal.tabs.userStats": "User Stats",
"template.trackerEditorModal.tabs.infoBox": "Info Box",
"template.trackerEditorModal.tabs.presentCharacters": "Present Characters",
"template.trackerEditorModal.buttons.reset": "Reset",
"template.trackerEditorModal.buttons.cancel": "Cancel",
"template.trackerEditorModal.buttons.save": "Save & Apply",
"template.trackerEditorModal.buttons.export": "Export",
"template.trackerEditorModal.buttons.import": "Import",
"template.trackerEditorModal.messages.exportSuccess": "Tracker preset exported successfully!",
"template.trackerEditorModal.messages.exportError": "Failed to export tracker preset. Check console for details.",
"template.trackerEditorModal.messages.importSuccess": "Tracker preset imported successfully!",
"template.trackerEditorModal.messages.importError": "Failed to import tracker preset",
"template.trackerEditorModal.messages.importConfirm": "This will replace your current tracker configuration. Continue?",
"template.trackerEditorModal.userStatsTab.customStatsTitle": "Custom Stats",
"template.trackerEditorModal.userStatsTab.addCustomStatButton": "Add Custom Stat",
"template.trackerEditorModal.userStatsTab.rpgAttributesTitle": "RPG Attributes",
"template.trackerEditorModal.userStatsTab.enableRpgAttributes": "Enable RPG Attributes Section",
"template.trackerEditorModal.userStatsTab.alwaysIncludeAttributes": "Always Include Attributes in Prompt",
"template.trackerEditorModal.userStatsTab.alwaysIncludeAttributesNote": "If disabled, attributes are only sent when a dice roll is active.",
"template.trackerEditorModal.userStatsTab.addAttributeButton": "Add Attribute",
"template.trackerEditorModal.userStatsTab.statusSectionTitle": "Status Section",
"template.trackerEditorModal.userStatsTab.enableStatusSection": "Enable Status Section",
"template.trackerEditorModal.userStatsTab.showMoodEmoji": "Show Mood Emoji",
"template.trackerEditorModal.userStatsTab.statusFieldsLabel": "Status Fields (comma-separated):",
"template.trackerEditorModal.userStatsTab.skillsSectionTitle": "Skills Section",
"template.trackerEditorModal.userStatsTab.enableSkillsSection": "Enable Skills Section",
"template.trackerEditorModal.userStatsTab.skillsLabelLabel": "Skills Label:",
"template.trackerEditorModal.userStatsTab.skillsListLabel": "Skills List (comma-separated):",
"template.trackerEditorModal.infoBoxTab.widgetsTitle": "Widgets",
"template.trackerEditorModal.infoBoxTab.dateWidget": "Date",
"template.trackerEditorModal.infoBoxTab.weatherWidget": "Weather",
"template.trackerEditorModal.infoBoxTab.temperatureWidget": "Temperature",
"template.trackerEditorModal.infoBoxTab.timeWidget": "Time",
"template.trackerEditorModal.infoBoxTab.locationWidget": "Location",
"template.trackerEditorModal.infoBoxTab.recentEventsWidget": "Recent Events",
"template.trackerEditorModal.presentCharactersTab.relationshipStatusTitle": "Relationship Status Fields",
"template.trackerEditorModal.presentCharactersTab.enableRelationshipStatus": "Enable Relationship Status Fields",
"template.trackerEditorModal.presentCharactersTab.relationshipStatusHint": "Define relationship types with corresponding emojis shown on character portraits.",
"template.trackerEditorModal.presentCharactersTab.newRelationshipButton": "New Relationship",
"template.trackerEditorModal.presentCharactersTab.appearanceDemeanorTitle": "Appearance/Demeanor Fields",
"template.trackerEditorModal.presentCharactersTab.appearanceDemeanorHint": "Fields shown below character name.",
"template.trackerEditorModal.presentCharactersTab.addCustomFieldButton": "Add Custom Field",
"template.trackerEditorModal.presentCharactersTab.thoughtsConfigTitle": "Thoughts Configuration",
"template.trackerEditorModal.presentCharactersTab.enableCharacterThoughts": "Enable Character Thoughts",
"template.trackerEditorModal.presentCharactersTab.thoughtsLabelLabel": "Thoughts Label:",
"template.trackerEditorModal.presentCharactersTab.aiInstructionLabel": "AI Instruction:",
"template.trackerEditorModal.presentCharactersTab.characterStatsTitle": "Character Stats",
"template.trackerEditorModal.presentCharactersTab.trackCharacterStats": "Track Character Stats",
"template.trackerEditorModal.presentCharactersTab.characterStatsHint": "Create stats to track for each character (displayed as colored bars).",
"template.trackerEditorModal.presentCharactersTab.addCharacterStatButton": "Add Character Stat",
"template.mainPanel.title": "RPG Companion",
"template.mainPanel.lastRoll": "Last Roll:",
"template.mainPanel.clearLastRoll": "Clear last roll",
"template.mainPanel.immersiveHtml": "Immersive HTML",
"template.mainPanel.coloredDialogues": "Colored Dialogues",
"template.mainPanel.spotifyMusic": "Spotify Music",
"template.mainPanel.snowflakesEffect": "Snowflakes Effect",
"template.mainPanel.dynamicWeatherEffects": "Dynamic Weather",
"template.mainPanel.narratorMode": "Narrator Mode",
"template.mainPanel.refreshRpgInfo": "Refresh RPG Info",
"template.mainPanel.updating": "Updating...",
"template.mainPanel.editTrackersButton": "Edit Trackers",
"template.mainPanel.settingsButton": "Settings",
"global.none": "None",
"global.add": "Add",
"global.cancel": "Cancel",
"global.listView": "List view",
"global.gridView": "Grid view",
"global.save": "Save",
"global.status":"Status",
"global.inventory":"Inventory",
"global.quests":"Quests",
"global.info":"Info",
"infobox.noData.title": "No data yet",
"infobox.noData.instruction": "Generate a new response in the roleplay or switch to \"Separate Generation\" in Settings to access and click the \"Refresh RPG Info\" button",
"infobox.recentEvents.title": "Recent Events",
"infobox.recentEvents.addEventPlaceholder": "Add event...",
"inventory.section.onPerson": "On Person",
"inventory.section.clothing": "Clothing",
"inventory.section.stored": "Stored",
"inventory.section.assets": "Assets",
"inventory.onPerson.empty": "No items carried",
"inventory.onPerson.title": "Items Currently Carried",
"inventory.onPerson.addItemButton": "Add Item",
"inventory.onPerson.addItemPlaceholder": "Enter item name...",
"inventory.clothing.empty": "Not wearing anything",
"inventory.clothing.title": "Clothing & Armor",
"inventory.clothing.addItemButton": "Add Clothing",
"inventory.clothing.addItemPlaceholder": "Enter clothing item...",
"inventory.stored.title": "Storage Locations",
"inventory.stored.addLocationButton": "Add Location",
"inventory.stored.addLocationPlaceholder": "Enter location name...",
"inventory.stored.saveButton": "Save",
"inventory.stored.empty": "No storage locations yet. Click \"Add Location\" to create one.",
"inventory.stored.noItems": "No items stored here",
"inventory.stored.addItemToLocationPlaceholder": "Enter item name...",
"inventory.stored.addItemButton": "Add Item",
"inventory.stored.confirmRemoveLocationMessage": "Remove \"${location}\"? This will delete all items stored there.",
"inventory.stored.confirmRemoveLocationConfirmButton": "Confirm",
"inventory.assets.empty": "No assets owned",
"inventory.assets.title": "Vehicles, Property & Major Possessions",
"inventory.assets.addAssetModalTitle": "Add Asset",
"inventory.assets.addAssetButton": "Add Asset",
"inventory.assets.addAssetPlaceholder": "Enter asset name...",
"inventory.assets.description": "Assets include vehicles (cars, motorcycles), property (homes, apartments), and major equipment (workshop tools, special items).",
"quests.section.main": "Main Quest",
"quests.section.optional": "Optional Quests",
"quests.main.title": "Main Quests",
"quests.main.addQuestButton": "Add Quest",
"quests.main.addQuestPlaceholder": "Enter main quest title...",
"quests.main.empty": "No active main quests",
"quests.main.hint": "The main quest represents your primary objective in the story.",
"quests.optional.title": "Optional Quests",
"quests.optional.addQuestButton": "Add Quest",
"quests.optional.addQuestPlaceholder": "Enter optional quest title...",
"quests.optional.empty": "No active optional quests",
"quests.optional.hint": "Optional quests are side objectives that complement your main story.",
"checkpoint.setChapterStart": "Set Chapter Start",
"checkpoint.clearChapterStart": "Clear Chapter Start",
"checkpoint.indicator": "Chapter Start",
"checkpoint.tooltip": "Messages before this point are excluded from context",
"musicPlayer.title": "Scene Music",
"musicPlayer.noMusic": "AI will suggest music when appropriate for the scene",
"errors.parsingError": "RPG Companion Trackers' parsing error! The model returned an incorrect format. If the issue persists, consider changing the model for generations.",
"settings.recommendedModels.title": "Recommended Models",
"settings.recommendedModels.description": "For the extension to work properly, **it is not recommended to use any models below 20B, especially if they're old.** It works best with the SOTA models such as Deepseek, Claude, GPT, or Gemini."
}
+200
View File
@@ -0,0 +1,200 @@
{
"settings.language.label": "語言",
"settings.language.option.en": "English",
"settings.language.option.zh-tw": "繁體中文",
"settings.extensionEnabled": "啟用 RPG Companion",
"settings.note": "切換開關以啟用/停用 RPG Companion。其他設定可在面板內配置。",
"template.settingsTitle": "RPG Companion 設定",
"template.settingsModal.themeTitle": "主題",
"template.settingsModal.themeLabel": "可選主題:",
"template.settingsModal.themeOptions.default": "預設",
"template.settingsModal.themeOptions.sciFi": "科幻 (合成波)",
"template.settingsModal.themeOptions.fantasy": "奇幻 (古樸羊皮紙)",
"template.settingsModal.themeOptions.cyberpunk": "賽博朋克 (霓虹網格)",
"template.settingsModal.themeOptions.custom": "自訂",
"template.settingsModal.themeOptions.custom.background": "背景:",
"template.settingsModal.themeOptions.custom.accent": "強調色:",
"template.settingsModal.themeOptions.custom.text": "文字:",
"template.settingsModal.themeOptions.custom.highlight": "高亮:",
"template.settingsModal.theme.statBarLow": "屬性條顏色 (低)",
"template.settingsModal.theme.statBarLowNote": "屬性在 0% 時的顏色",
"template.settingsModal.theme.statBarHigh": "屬性條顏色 (高)",
"template.settingsModal.theme.statBarHighNote": "屬性在 100% 時的顏色",
"template.settingsModal.displayTitle": "顯示設定",
"template.settingsModal.displayNote": "使用擴充功能標籤來啟用/停用 RPG Companion 擴充功能。",
"template.settingsModal.display.panelPosition": "面板位置:",
"template.settingsModal.display.panelPositionOptions.right": "右側邊欄",
"template.settingsModal.display.panelPositionOptions.left": "左側邊欄",
"template.settingsModal.display.toggleAutoUpdate": "訊息後自動更新",
"template.settingsModal.display.showUserStats": "顯示 user 屬性",
"template.settingsModal.display.showInfoBox": "顯示資訊框",
"template.settingsModal.display.showPresentCharacters": "顯示在場角色",
"template.settingsModal.display.showInventory": "顯示物品欄",
"template.settingsModal.display.showLockIcons": "顯示鎖定/解鎖追蹤器",
"template.settingsModal.display.showLockIconsNote": "在追蹤器項目上顯示鎖定/解鎖圖示,以防止 AI 修改它們。",
"template.settingsModal.display.showThoughtsInChat": "在聊天中顯示想法",
"template.settingsModal.display.showThoughtsInChatNote": "將角色想法顯示為其訊息旁的泡泡",
"template.settingsModal.display.alwaysShowThoughtBubble": "始終顯示想法泡泡",
"template.settingsModal.display.alwaysShowThoughtBubbleNote": "自動展開想法泡泡",
"template.settingsModal.display.enableAnimations": "啟用動畫",
"template.settingsModal.display.enableAnimationsNote": "屬性、內容更新和擲骰的動畫效果",
"template.settingsModal.display.showImmersiveHtmlToggle": "顯示沉浸式 HTML",
"template.settingsModal.display.showDialogueColoringToggle": "顯示彩色對話",
"template.settingsModal.display.showDialogueColoringToggleNote": "顯示一個切換按鈕以啟用/停用彩色對話格式。",
"template.settingsModal.display.showSpotifyMusicToggle": "顯示 Spotify 音樂",
"template.settingsModal.display.showSnowflakesToggle": "顯示雪花效果", "template.settingsModal.display.showDynamicWeatherToggle": "顯示動態天氣效果", "template.settingsModal.display.showNarratorMode": "顯示旁白模式", "template.settingsModal.display.showNarratorModeNote": "顯示切換按鈕以啟用/停用旁白模式", "template.settingsModal.display.showAutoAvatars": "顯示自動生成頭像", "template.settingsModal.display.showAutoAvatarsNote": "顯示切換按鈕以自動為沒有圖片的角色生成頭像", "template.settingsModal.display.showPlotProgressionButtons": "顯示劇情推進按鈕(QR",
"template.settingsModal.display.showPlotProgressionButtonsNote": "在聊天輸入框上方顯示劇情推進提示按鈕(QR)",
"template.settingsModal.advancedTitle": "進階",
"template.settingsModal.advanced.generationMode": "生成模式:",
"template.settingsModal.advanced.generationModeOptions.together": "同時生成",
"template.settingsModal.advanced.generationModeOptions.separate": "單獨生成",
"template.settingsModal.advanced.generationModeNote": "同時生成:將 RPG 追蹤添加到主要提示詞中一同生成。單獨生成:分開生成 RPG 數據。(就是手動或自動的差別)。外部 API:直接連接 OpenAI 兼容端點生成數據。",
"template.settingsModal.advanced.generationModeOptions.external": "外部 API",
"template.settingsModal.advanced.externalApi.title": "外部 API 設定",
"template.settingsModal.advanced.externalApi.baseUrl": "API 基礎 URL",
"template.settingsModal.advanced.externalApi.baseUrlNote": "OpenAI 兼容端點(例如 OpenAI、OpenRouter、本地 LLM 伺服器)",
"template.settingsModal.advanced.externalApi.apiKey": "API 金鑰",
"template.settingsModal.advanced.externalApi.apiKeyNote": "外部服務的 API 金鑰",
"template.settingsModal.advanced.externalApi.model": "模型",
"template.settingsModal.advanced.externalApi.modelNote": "模型識別碼(例如 gpt-4o-mini、claude-3-haiku、mistral-7b",
"template.settingsModal.advanced.externalApi.maxTokens": "最大 Token",
"template.settingsModal.advanced.externalApi.temperature": "溫度 (Temperature)",
"template.settingsModal.advanced.externalApi.testConnection": "測試連接",
"template.settingsModal.advanced.contextMessages": "上下文訊息:",
"template.settingsModal.advanced.contextMessagesNote": "包含的最近訊息數量(僅限單獨生成模式)",
"template.settingsModal.advanced.memoryBatchSize": "記憶批次大小:",
"template.settingsModal.advanced.memoryBatchSizeNote": "在記憶回憶中每批處理的訊息數量",
"template.settingsModal.advanced.useSeparatePreset": "使用 RPG Companion 追蹤預設模型(設置次要模型)",
"template.settingsModal.advanced.useSeparatePresetNote": "僅限單獨生成模式。啟用後將使用“RPG Companion Trackers”預設中綁定的模型,而不是您的主要 API 模型。生成期間會自動切換預設,之後會恢復原使用預設。請在“RPG Companion Trackers”預設中選擇次要模型,並確保“將預設綁定到 API 連接”切換已開啟(在導入/導出預設按鈕旁邊)。",
"template.settingsModal.advanced.skipInjections": "在引導生成期間跳過注入:",
"template.settingsModal.advanced.skipInjectionsOptions.none": "從不跳過",
"template.settingsModal.advanced.skipInjectionsOptions.impersonation": "僅在模擬請求時跳過",
"template.settingsModal.advanced.skipInjectionsOptions.guided": "始終跳過引導",
"template.settingsModal.advanced.skipInjectionsNote": "當設置後,擴充功能在檢測到引導生成(通過 `instruct` 或 `quiet_prompt`)時,將根據所選模式不注入追蹤提示詞、範例或 HTML 指令。當與 GuidedGenerations 或類似擴充功能一起使用時非常有用。",
"template.settingsModal.advanced.customHtmlPromptTitle": "自訂 HTML 提示詞:",
"template.settingsModal.advanced.restoreDefaultHtmlPrompt": "恢復預設",
"template.settingsModal.advanced.customHtmlPromptNote": "自訂啟用“啟用沉浸式 HTML”時注入的 HTML 提示詞。上方顯示預設提示詞 - 您可以直接編輯或完全替換它。點擊“恢復預設”以重置。這會影響所有生成模式(同時、單獨和劇情推進)。",
"template.settingsModal.advanced.clearCache": "清除擴充功能快取",
"template.settingsModal.advanced.resetFabPositions": "重置按鈕位置",
"template.settingsModal.advanced.resetFabPositionsNote": "將所有浮動操作按鈕(切換、刷新、調試)重置為預設的左上位置。如果按鈕在螢幕外,這會很有用。",
"template.trackerEditorModal.title": "追蹤器編輯",
"template.trackerEditorModal.tabs.userStats": "User 屬性",
"template.trackerEditorModal.tabs.infoBox": "資訊框",
"template.trackerEditorModal.tabs.presentCharacters": "在場角色",
"template.trackerEditorModal.buttons.reset": "重置",
"template.trackerEditorModal.buttons.cancel": "取消",
"template.trackerEditorModal.buttons.save": "保存並應用",
"template.trackerEditorModal.buttons.export": "匯出",
"template.trackerEditorModal.buttons.import": "匯入",
"template.trackerEditorModal.messages.exportSuccess": "追蹤器預設匯出成功!",
"template.trackerEditorModal.messages.exportError": "匯出追蹤器預設失敗。請檢查控制台以獲取詳細資訊。",
"template.trackerEditorModal.messages.importSuccess": "追蹤器預設匯入成功!",
"template.trackerEditorModal.messages.importError": "匯入追蹤器預設失敗",
"template.trackerEditorModal.messages.importConfirm": "這將替換您當前的追蹤器配置。繼續?",
"template.trackerEditorModal.userStatsTab.customStatsTitle": "自訂屬性",
"template.trackerEditorModal.userStatsTab.addCustomStatButton": "添加自訂屬性",
"template.trackerEditorModal.userStatsTab.rpgAttributesTitle": "RPG 屬性",
"template.trackerEditorModal.userStatsTab.enableRpgAttributes": "啟用 RPG 屬性",
"template.trackerEditorModal.userStatsTab.alwaysIncludeAttributes": "始終發送屬性(prompt",
"template.trackerEditorModal.userStatsTab.alwaysIncludeAttributesNote": "將 RPG 屬性始終包含在提示詞中,即使它們未顯示在面板上也一樣。",
"template.trackerEditorModal.userStatsTab.addAttributeButton": "添加屬性",
"template.trackerEditorModal.userStatsTab.statusSectionTitle": "狀態欄",
"template.trackerEditorModal.userStatsTab.enableStatusSection": "啟用狀態欄",
"template.trackerEditorModal.userStatsTab.showMoodEmoji": "顯示心情emoji",
"template.trackerEditorModal.userStatsTab.statusFieldsLabel": "狀態欄欄位(以逗號分隔):",
"template.trackerEditorModal.userStatsTab.skillsSectionTitle": "技能欄",
"template.trackerEditorModal.userStatsTab.enableSkillsSection": "啟用技能欄",
"template.trackerEditorModal.userStatsTab.skillsLabelLabel": "技能欄標籤:",
"template.trackerEditorModal.userStatsTab.skillsListLabel": " 技能列表(以逗號分隔):",
"template.trackerEditorModal.infoBoxTab.widgetsTitle": "小工具",
"template.trackerEditorModal.infoBoxTab.dateWidget": "日期",
"template.trackerEditorModal.infoBoxTab.weatherWidget": "天氣",
"template.trackerEditorModal.infoBoxTab.temperatureWidget": "溫度",
"template.trackerEditorModal.infoBoxTab.timeWidget": "時間",
"template.trackerEditorModal.infoBoxTab.locationWidget": "位置",
"template.trackerEditorModal.infoBoxTab.recentEventsWidget": "近期事件",
"template.trackerEditorModal.presentCharactersTab.relationshipStatusTitle": "關係狀態",
"template.trackerEditorModal.presentCharactersTab.enableRelationshipStatus": "啟用關係狀態欄位",
"template.trackerEditorModal.presentCharactersTab.relationshipStatusHint": "定義關係類型,並在角色頭像上顯示對應的表情符號",
"template.trackerEditorModal.presentCharactersTab.newRelationshipButton": "新增關係類型",
"template.trackerEditorModal.presentCharactersTab.appearanceDemeanorTitle": "外觀與當前行為舉止",
"template.trackerEditorModal.presentCharactersTab.appearanceDemeanorHint": "角色名稱下方顯示的字段,以 | 分隔。",
"template.trackerEditorModal.presentCharactersTab.addCustomFieldButton": "添加自訂字段",
"template.trackerEditorModal.presentCharactersTab.thoughtsConfigTitle": "內心話配置",
"template.trackerEditorModal.presentCharactersTab.enableCharacterThoughts": "啟用角色內心話",
"template.trackerEditorModal.presentCharactersTab.thoughtsLabelLabel": "內心話標籤:",
"template.trackerEditorModal.presentCharactersTab.aiInstructionLabel": "內心話提示詞:",
"template.trackerEditorModal.presentCharactersTab.characterStatsTitle": "角色屬性",
"template.trackerEditorModal.presentCharactersTab.trackCharacterStats": "啟用角色屬性",
"template.trackerEditorModal.presentCharactersTab.characterStatsHint": "建立統計資料以追蹤每個角色(以彩色長條圖顯示)",
"template.trackerEditorModal.presentCharactersTab.addCharacterStatButton": "添加角色屬性",
"template.mainPanel.title": "RPG Companion",
"template.mainPanel.lastRoll": "上次擲骰:",
"template.mainPanel.clearLastRoll": "清除上次擲骰",
"template.mainPanel.immersiveHtml": "沉浸式 HTML",
"template.mainPanel.coloredDialogues": "彩色對話",
"template.mainPanel.spotifyMusic": "Spotify 音樂",
"template.mainPanel.snowflakesEffect": "雪花效果", "template.mainPanel.dynamicWeatherEffects": "動態天氣", "template.mainPanel.narratorMode": "旁白模式", "template.mainPanel.autoAvatars": "自動頭像", "template.mainPanel.refreshRpgInfo": "刷新資訊",
"template.mainPanel.updating": "更新中...",
"template.mainPanel.editTrackersButton": "追蹤器編輯",
"template.mainPanel.settingsButton": "設定",
"global.none": "None",
"global.add": "添加",
"global.cancel": "取消",
"global.save": "保存",
"global.listView": "清單檢視",
"global.gridView": "格子檢視",
"global.status": "狀態欄",
"global.inventory": "物品欄",
"global.quests": "任務",
"global.info":"資訊",
"infobox.noData.title": "無資訊可顯示",
"infobox.noData.instruction": "在RP中產生新的回复,或在設定中切換到“單獨生成”,然後點擊“刷新資訊”按鈕。",
"infobox.recentEvents.title": "近期事件",
"infobox.recentEvents.addEventPlaceholder": "添加事件...",
"inventory.section.onPerson": "隨身物品",
"inventory.section.clothing": "服裝",
"inventory.section.stored": "倉庫物品",
"inventory.section.assets": "資產",
"inventory.onPerson.empty": "這裡什麼都沒有 (⚲□⚲)",
"inventory.onPerson.title": "攜帶的物品",
"inventory.onPerson.addItemButton": "添加物品",
"inventory.onPerson.addItemPlaceholder": "輸入物品名稱...",
"inventory.clothing.empty": "未穿著任何服裝 (⚲□⚲)",
"inventory.clothing.title": "服裝與護甲",
"inventory.clothing.addItemButton": "添加服裝",
"inventory.clothing.addItemPlaceholder": "輸入服裝物品...",
"inventory.stored.title": "倉庫位置",
"inventory.stored.addLocationButton": "添加倉庫",
"inventory.stored.addLocationPlaceholder": "輸入倉庫名稱...",
"inventory.stored.saveButton": "保存",
"inventory.stored.empty": "沒有倉庫 (⚲□⚲), 點擊\"添加倉庫\"來新增一個倉庫",
"inventory.stored.noItems": "這個倉庫是空的 (⚲□⚲)",
"inventory.stored.addItemToLocationPlaceholder": "輸入物品名稱...",
"inventory.stored.addItemButton": "添加物品",
"inventory.stored.confirmRemoveLocationMessage": "確定要刪除這個倉庫嗎?這將移除所有其中的物品。",
"inventory.stored.confirmRemoveLocationConfirmButton": "刪除",
"inventory.assets.empty": "沒有資產 (⚲□⚲) 好窮",
"inventory.assets.title": "車輛、房產及主要財產",
"inventory.assets.addAssetModalTitle": "添加資產",
"inventory.assets.addAssetButton": "添加資產",
"inventory.assets.addAssetPlaceholder": "輸入資產名稱...",
"inventory.assets.description": "資產包括車輛(汽車、摩托車)、房產(房屋、公寓)和主要設備(車間工具、特殊物品)。",
"quests.section.main": "主線任務",
"quests.section.optional": "支線任務",
"quests.main.title": "主線任務",
"quests.main.addQuestButton": "添加主要任務",
"quests.main.addQuestPlaceholder": "輸入主線任務名稱...",
"quests.main.empty": "當前無主要任務 (ฅ˙Ⱉ˙ฅ)",
"quests.main.hint": "主線任務代表你在故事中的主要目標。",
"quests.optional.title": "支線任務",
"quests.optional.addQuestButton": "添加支線任務",
"quests.optional.addQuestPlaceholder": "輸入支線任務名稱...",
"quests.optional.empty": "當前無支線任務 (ʘ̆ʚʘ̆)",
"quests.optional.hint": "支線任務是補充主線劇情的支線目標。",
"musicPlayer.title": "場景音樂",
"musicPlayer.noMusic": "AI 會在適當時為場景建議音樂",
"errors.parsingError": "RPG Companion 追蹤器解析錯誤!模型返回了不正確的格式。如果問題持續存在,請考慮更換生成模型。",
"settings.recommendedModels.title": "推薦模型",
"settings.recommendedModels.description": "為了讓擴充功能正常運作,**不建議使用任何小於 20B 的模型,尤其是舊模型。**它最適合使用 SOTA 模型,例如 Deepseek、Claude、GPT 或 Gemini。"
}
-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);
});
});
}
+402
View File
@@ -0,0 +1,402 @@
/**
* Avatar Generator Module
* Handles automatic and manual avatar generation for NPC characters
*
* Features:
* - Batch generation with awaitable completion
* - Batch prompt generation via LLM
* - Individual image generation via /sd command
* - Manual regeneration support
*/
import { generateRaw, characters, this_chid } from '../../../../../../../script.js';
import { executeSlashCommandsOnChatInput } from '../../../../../../../scripts/slash-commands.js';
import { selected_group, getGroupMembers } from '../../../../../../group-chats.js';
import { extensionSettings, sessionAvatarPrompts, setSessionAvatarPrompt } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { generateAvatarPromptGenerationPrompt } from '../generation/promptBuilder.js';
import { getCurrentPresetName, switchToPreset, generateWithExternalAPI } from '../generation/apiClient.js';
// Generation state - tracks characters currently being generated
const pendingGenerations = new Set();
/**
* Checks if a character is pending generation (waiting or actively generating)
* @param {string} characterName - Name of character to check
* @returns {boolean} True if generation is pending
*/
export function isGenerating(characterName) {
return pendingGenerations.has(characterName);
}
/**
* Checks if any avatars are currently being generated
* @returns {boolean} True if any generation is in progress
*/
export function isAnyGenerating() {
return pendingGenerations.size > 0;
}
/**
* Gets all characters currently pending generation
* @returns {string[]} Array of character names
*/
export function getPendingGenerations() {
return [...pendingGenerations];
}
/**
* Helper to check if two character names match (case-insensitive, handles partial matches)
* @param {string} cardName - Name from character card
* @param {string} aiName - Name from AI response
* @returns {boolean} True if names match
*/
function namesMatch(cardName, aiName) {
if (!cardName || !aiName) return false;
const cardLower = cardName.toLowerCase().trim();
const aiLower = aiName.toLowerCase().trim();
if (cardLower === aiLower) return true;
const cardCore = cardLower.split(/[\s,'"]+/)[0];
const aiCore = aiLower.split(/[\s,'"]+/)[0];
if (cardCore === aiCore) return true;
const escapedCardCore = cardCore.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const wordBoundary = new RegExp(`\\b${escapedCardCore}\\b`);
return wordBoundary.test(aiCore);
}
/**
* Checks if a character already has an avatar (custom NPC avatar or from character card)
* @param {string} characterName - Name of character to check
* @returns {boolean} True if character has an avatar
*/
export function hasExistingAvatar(characterName) {
// Check for custom NPC avatar first
if (extensionSettings.npcAvatars && extensionSettings.npcAvatars[characterName]) {
const avatar = extensionSettings.npcAvatars[characterName];
if (typeof avatar === 'string' && avatar) {
return true;
}
}
// Check group members for avatar
if (selected_group) {
try {
const groupMembers = getGroupMembers(selected_group);
if (groupMembers && groupMembers.length > 0) {
const matchingMember = groupMembers.find(member =>
member && member.name && namesMatch(member.name, characterName)
);
if (matchingMember && matchingMember.avatar && matchingMember.avatar !== 'none') {
return true;
}
}
} catch (e) {
// Ignore errors
}
}
// Check all characters for avatar
if (characters && characters.length > 0) {
const matchingCharacter = characters.find(c =>
c && c.name && namesMatch(c.name, characterName)
);
if (matchingCharacter && matchingCharacter.avatar && matchingCharacter.avatar !== 'none') {
return true;
}
}
// Check current character in 1-on-1 chat
if (this_chid !== undefined && characters[this_chid] &&
characters[this_chid].name && namesMatch(characters[this_chid].name, characterName)) {
if (characters[this_chid].avatar && characters[this_chid].avatar !== 'none') {
return true;
}
}
return false;
}
/**
* Generates avatars for multiple characters and waits for all to complete.
* This is the main entry point for auto-generation within a workflow.
*
* @param {string[]} characterNames - Array of character names to generate avatars for
* @param {Function} onStarted - Optional callback when generation starts (to update UI)
* @returns {Promise<void>} Resolves when all generations complete
*/
export async function generateAvatarsForCharacters(characterNames, onStarted = null) {
if (!extensionSettings.autoGenerateAvatars) {
return;
}
// Filter to characters that need avatars
const needsGeneration = characterNames.filter(name => {
// Skip if already pending
if (pendingGenerations.has(name)) {
return false;
}
// Skip if has avatar
if (hasExistingAvatar(name)) {
return false;
}
return true;
});
if (needsGeneration.length === 0) {
return;
}
// console.log('[RPG Avatar] Starting batch generation for:', needsGeneration);
// Mark all as pending IMMEDIATELY (before any async work)
for (const name of needsGeneration) {
pendingGenerations.add(name);
}
// Trigger UI update to show loading spinners
if (onStarted) {
try {
onStarted([...needsGeneration]);
} catch (e) {
console.error('[RPG Avatar] Error in onStarted callback:', e);
}
}
try {
// Generate images one at a time, generating prompt on demand
for (const characterName of needsGeneration) {
// Skip if somehow already has avatar now
if (hasExistingAvatar(characterName)) {
pendingGenerations.delete(characterName);
continue;
}
// Generate LLM prompt for this character
const prompt = await generateAvatarPrompt(characterName);
// Generate the image using the prompt
await generateSingleAvatar(characterName, prompt);
pendingGenerations.delete(characterName);
// Small delay between generations to avoid overwhelming the API
if (needsGeneration.indexOf(characterName) < needsGeneration.length - 1) {
await new Promise(resolve => setTimeout(resolve, 500));
}
}
} finally {
// Ensure all are removed from pending even if there's an error
for (const name of needsGeneration) {
pendingGenerations.delete(name);
}
}
// console.log('[RPG Avatar] Batch generation complete');
}
/**
* Regenerates avatar for a specific character
* Clears existing avatar and prompt, then generates new ones
* Handles preset switching if useSeparatePreset is enabled
*
* @param {string} characterName - Name of character to regenerate
* @returns {Promise<string|null>} New avatar URL or null if failed
*/
export async function regenerateAvatar(characterName) {
// console.log('[RPG Avatar] Regenerating avatar for:', characterName);
// Mark as pending immediately
pendingGenerations.add(characterName);
// Clear existing avatar
if (extensionSettings.npcAvatars && extensionSettings.npcAvatars[characterName]) {
delete extensionSettings.npcAvatars[characterName];
saveSettings();
}
// Clear existing prompt cache
if (sessionAvatarPrompts[characterName]) {
delete sessionAvatarPrompts[characterName];
}
try {
// Generate new LLM prompt
const prompt = await generateAvatarPrompt(characterName);
// Generate the avatar
return await generateSingleAvatar(characterName, prompt);
} finally {
// Remove from pending when done
pendingGenerations.delete(characterName);
}
}
/**
* Generates an LLM prompt for a single character
*
* @param {string} characterName - Name of character
* @returns {Promise<string|null>} Generated prompt or null if failed
*/
async function generateAvatarPrompt(characterName) {
// Check cache first if not forcing regeneration
if (sessionAvatarPrompts[characterName]) {
return sessionAvatarPrompts[characterName];
}
try {
// console.log('[RPG Avatar] Generating LLM prompt for:', characterName);
const promptMessages = await generateAvatarPromptGenerationPrompt(characterName);
let response;
if (extensionSettings.generationMode === 'external') {
// console.log('[RPG Avatar] Using external API for avatar prompt generation');
response = await generateWithExternalAPI(promptMessages);
} else {
response = await generateRaw({
prompt: promptMessages,
quietToLoud: false
});
}
if (response) {
const prompt = response.trim();
// console.log(`[RPG Avatar] Generated prompt for ${characterName}:`, prompt);
// Store prompt in session storage
setSessionAvatarPrompt(characterName, prompt);
return prompt;
}
} catch (error) {
console.error(`[RPG Avatar] Failed to generate LLM prompt for ${characterName}:`, error);
}
return null;
}
/**
* Builds a fallback prompt when LLM prompt generation fails or isn't available
* Uses information embedded in the character name if present (e.g., from malformed tracker output)
*
* @param {string} characterName - Character name (may contain additional details)
* @returns {string} A basic prompt for image generation
*/
function buildFallbackPrompt(characterName) {
// Check if the name contains embedded details (malformed format from weaker models)
// e.g., "Eris Details: 🌟 | beautiful girl with white hair | kind expression"
if (characterName.includes('Details:') || characterName.includes('|')) {
// Extract useful description parts
const parts = characterName.split(/Details:|[|]/).map(p => p.trim()).filter(p => p && !p.match(/^[\p{Emoji}]+$/u));
if (parts.length > 1) {
// First part is likely the name, rest are descriptions
const name = parts[0];
const descriptions = parts.slice(1).join(', ');
return `portrait of ${name}, ${descriptions}, fantasy art style, detailed`;
}
}
// Simple fallback - just use the name
return `portrait of ${characterName}, character portrait, fantasy art style, detailed face, high quality`;
}
/**
* Generates a single avatar using the /sd command
*
* @param {string} characterName - Name of character to generate avatar for
* @param {string|null} prompt - The prompt to use (optional, will fallback if null)
* @returns {Promise<string|null>} Avatar URL or null if failed
*/
async function generateSingleAvatar(characterName, prompt = null) {
// Use provided prompt, or check cache, or build fallback
if (!prompt) {
prompt = sessionAvatarPrompts[characterName];
}
if (!prompt) {
// console.log(`[RPG Avatar] No LLM prompt for ${characterName}, using fallback prompt`);
prompt = buildFallbackPrompt(characterName);
}
// console.log(`[RPG Avatar] Starting image generation for: ${characterName}`);
try {
// Execute /sd command with quiet=true to suppress chat output
const result = await executeSlashCommandsOnChatInput(
`/sd quiet=true ${prompt}`,
{ clearChatInput: false }
);
// Extract image URL from result
const imageUrl = extractImageUrl(result);
if (imageUrl) {
// Store the avatar
if (!extensionSettings.npcAvatars) {
extensionSettings.npcAvatars = {};
}
extensionSettings.npcAvatars[characterName] = imageUrl;
saveSettings();
// console.log(`[RPG Avatar] Successfully generated avatar for: ${characterName}`);
return imageUrl;
} else {
console.warn(`[RPG Avatar] Failed to extract image URL for ${characterName}:`, result);
return null;
}
} catch (error) {
console.error(`[RPG Avatar] Generation failed for ${characterName}:`, error);
return null;
}
}
/**
* Extracts image URL from /sd command result
* Handles various result formats
*
* @param {any} result - Result from executeSlashCommandsOnChatInput
* @returns {string|null} Image URL or null
*/
function extractImageUrl(result) {
if (!result) return null;
// Handle string result
if (typeof result === 'string') {
// Validate it looks like a URL or data URI
if (result.startsWith('http') || result.startsWith('data:') || result.startsWith('/')) {
return result;
}
return null;
}
// Handle object result with various possible properties
if (typeof result === 'object') {
// Try common properties
const url = result.pipe || result.output || result.image || result.url || result.result;
if (url && typeof url === 'string') {
if (url.startsWith('http') || url.startsWith('data:') || url.startsWith('/')) {
return url;
}
}
}
return null;
}
/**
* Clears all pending generations and resets state
*/
export function clearPendingGenerations() {
pendingGenerations.clear();
}
/**
* Gets the current generation status for display
* @returns {{pending: number, names: string[]}}
*/
export function getGenerationStatus() {
return {
pending: pendingGenerations.size,
names: [...pendingGenerations]
};
}
+189
View File
@@ -0,0 +1,189 @@
/**
* Chapter Checkpoint Module
* Allows users to mark messages as "chapter start" points to filter context
* Uses SillyTavern's /hide and /unhide commands to exclude messages from context
*/
import { getContext } from '../../../../../../extensions.js';
import { chat_metadata, saveChatDebounced } from '../../../../../../../script.js';
import { executeSlashCommandsOnChatInput } from '../../../../../../../scripts/slash-commands.js';
// Track the message range that is currently hidden
let currentlyHiddenRange = null;
// Debounce restore to prevent loops
let isRestoring = false;
let restoreTimeout = null;
let pendingResolve = null;
/**
* Gets the current chapter checkpoint message ID for the active chat
* @returns {number|null} Message ID of the checkpoint, or null if none set
*/
export function getChapterCheckpoint() {
const context = getContext();
if (!context || !chat_metadata) return null;
return chat_metadata.rpg_companion_chapter_checkpoint || null;
}
/**
* Sets a message as the chapter checkpoint
* Automatically clears any previous checkpoint (only one checkpoint allowed at a time)
* Hides all messages before the checkpoint
* @param {number} messageId - The chat message index to set as checkpoint
* @returns {Promise<boolean>} True if successful
*/
export async function setChapterCheckpoint(messageId) {
const context = getContext();
const chat = context.chat;
if (!chat || messageId < 0 || messageId >= chat.length) {
console.error('[RPG Companion] Invalid message ID for checkpoint:', messageId);
return false;
}
const previousCheckpoint = chat_metadata.rpg_companion_chapter_checkpoint;
// If moving checkpoint, unhide the old range first
if (previousCheckpoint !== null && previousCheckpoint !== undefined && previousCheckpoint !== messageId && currentlyHiddenRange !== null) {
const { start, end } = currentlyHiddenRange;
await executeSlashCommandsOnChatInput(`/unhide ${start}-${end}`, { quiet: true });
// console.log(`[RPG Companion] Unhid previous range: ${start}-${end}`);
}
// Store in chat metadata (this automatically overrides any previous checkpoint)
chat_metadata.rpg_companion_chapter_checkpoint = messageId;
saveChatDebounced();
// Hide all messages before the checkpoint
if (messageId > 0) {
const rangeEnd = messageId - 1;
await executeSlashCommandsOnChatInput(`/hide 0-${rangeEnd}`, { quiet: true });
currentlyHiddenRange = { start: 0, end: rangeEnd };
// console.log(`[RPG Companion] Hidden messages 0-${rangeEnd} (checkpoint at ${messageId})`);
}
if (previousCheckpoint !== null && previousCheckpoint !== undefined && previousCheckpoint !== messageId) {
// console.log(`[RPG Companion] Chapter checkpoint moved from message ${previousCheckpoint} to ${messageId}`);
} else {
// console.log('[RPG Companion] Chapter checkpoint set at message', messageId);
}
// Emit event for UI updates
if (typeof document !== 'undefined') {
const event = new CustomEvent('rpg-companion-checkpoint-changed', {
detail: { messageId, previousCheckpoint }
});
document.dispatchEvent(event);
}
return true;
}
/**
* Clears the chapter checkpoint and unhides all hidden messages
*/
export async function clearChapterCheckpoint() {
if (!chat_metadata) return;
// Unhide any hidden messages
if (currentlyHiddenRange !== null) {
const { start, end } = currentlyHiddenRange;
await executeSlashCommandsOnChatInput(`/unhide ${start}-${end}`, { quiet: true });
// console.log(`[RPG Companion] Unhid messages ${start}-${end}`);
currentlyHiddenRange = null;
}
delete chat_metadata.rpg_companion_chapter_checkpoint;
saveChatDebounced();
// console.log('[RPG Companion] Chapter checkpoint cleared');
// Emit event for UI updates
if (typeof document !== 'undefined') {
const event = new CustomEvent('rpg-companion-checkpoint-changed', {
detail: { messageId: null }
});
document.dispatchEvent(event);
}
}
/**
* Checks if a message is the current checkpoint
* @param {number} messageId - The message index to check
* @returns {boolean} True if this is the checkpoint message
*/
export function isCheckpointMessage(messageId) {
const checkpointId = getChapterCheckpoint();
return checkpointId === messageId;
}
/**
* Restores checkpoint state after page reload or generation events
* Checks if a checkpoint exists and re-applies the /hide command
* Debounced to prevent loops when called from multiple events
*/
export async function restoreCheckpointOnLoad() {
// Prevent concurrent executions
if (isRestoring) {
return;
}
// Clear any pending timeout and resolve the pending promise
if (restoreTimeout) {
clearTimeout(restoreTimeout);
restoreTimeout = null;
}
if (pendingResolve) {
pendingResolve();
pendingResolve = null;
}
// Debounce: wait 100ms before actually restoring
return new Promise((resolve) => {
pendingResolve = resolve;
restoreTimeout = setTimeout(async () => {
isRestoring = true;
try {
const checkpointId = getChapterCheckpoint();
if (checkpointId !== null && checkpointId !== undefined && checkpointId > 0) {
const context = getContext();
const chat = context.chat;
if (chat && checkpointId < chat.length) {
const rangeEnd = checkpointId - 1;
// Check if messages are already hidden
let needsRestore = false;
let hiddenCount = 0;
let visibleCount = 0;
for (let i = 0; i <= rangeEnd; i++) {
if (chat[i]) {
if (chat[i].is_system) {
hiddenCount++;
} else {
visibleCount++;
needsRestore = true;
}
}
}
if (needsRestore) {
await executeSlashCommandsOnChatInput(`/hide 0-${rangeEnd}`, { quiet: true });
currentlyHiddenRange = { start: 0, end: rangeEnd };
// console.log(`[RPG Companion] Restored checkpoint: Hidden messages 0-${rangeEnd}`);
} else {
currentlyHiddenRange = { start: 0, end: rangeEnd };
}
}
}
} finally {
isRestoring = false;
pendingResolve = null;
resolve();
}
}, 100);
});
}
+4 -1
View File
@@ -8,6 +8,7 @@ import {
$userStatsContainer
} from '../../core/state.js';
import { saveSettings, saveChatData } from '../../core/persistence.js';
import { updateFabWidgets } from '../ui/mobile.js';
/**
* Sets up event listeners for classic stat +/- buttons using delegation.
@@ -19,12 +20,13 @@ export function setupClassicStatsButtons() {
// Delegated event listener for increase buttons
$userStatsContainer.on('click', '.rpg-stat-increase', function() {
const stat = $(this).data('stat');
if (extensionSettings.classicStats[stat] < 100) {
if (extensionSettings.classicStats[stat] < 999) {
extensionSettings.classicStats[stat]++;
saveSettings();
saveChatData();
// Update only the specific stat value, not the entire stats panel
$(this).closest('.rpg-classic-stat').find('.rpg-classic-stat-value').text(extensionSettings.classicStats[stat]);
updateFabWidgets();
}
});
@@ -37,6 +39,7 @@ export function setupClassicStatsButtons() {
saveChatData();
// Update only the specific stat value, not the entire stats panel
$(this).closest('.rpg-classic-stat').find('.rpg-classic-stat-value').text(extensionSettings.classicStats[stat]);
updateFabWidgets();
}
});
}
+18 -4
View File
@@ -9,6 +9,7 @@ import {
setPendingDiceRoll
} from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { i18n } from '../../core/i18n.js';
/**
* Rolls the dice and displays result.
@@ -84,16 +85,29 @@ export async function executeRollCommand(command) {
* Updates the dice display in the sidebar.
*/
export function updateDiceDisplay() {
const lastRoll = extensionSettings.lastDiceRoll;
if (lastRoll) {
$('#rpg-last-roll-text').text(`Last Roll (${lastRoll.formula}): ${lastRoll.total}`);
// Hide the entire dice display if showDiceDisplay is false
const $display = $('#rpg-dice-display');
if (!extensionSettings.showDiceDisplay) {
$display.hide();
return;
} else {
$('#rpg-last-roll-text').text('Last Roll: None');
$display.show();
}
const lastRoll = extensionSettings.lastDiceRoll;
const label = i18n.getTranslation('template.mainPanel.lastRoll') || 'Last Roll: ';
const noneValue = i18n.getTranslation('global.none') || 'None';
if (lastRoll) {
$('#rpg-last-roll-text').text(`${label}(${lastRoll.formula}): ${lastRoll.total}`);
} else {
$('#rpg-last-roll-text').text(label + noneValue);
}
}
/**
* Clears the last dice roll.
* Called when the x button is clicked.
*/
export function clearDiceRoll() {
extensionSettings.lastDiceRoll = null;
+130
View File
@@ -0,0 +1,130 @@
/**
* Encounter State Module
* Manages combat encounter state and history
*/
/**
* Current encounter state
*/
export let currentEncounter = {
active: false,
initialized: false,
combatHistory: [], // Array of {role: 'user'|'assistant'|'system', content: string}
combatStats: null, // Current combat stats (HP, party, enemies, etc.)
preEncounterContext: [], // Messages from before the encounter started
encounterStartMessage: '', // The message that triggered the encounter
encounterLog: [] // Full log of combat actions for final summary
};
/**
* Encounter logs storage (per chat)
*/
export let encounterLogs = {
// chatId: [
// {
// timestamp: Date,
// log: [],
// summary: string,
// result: 'victory'|'defeat'|'fled'
// }
// ]
};
/**
* Sets the current encounter state
* @param {object} encounter - The encounter state object
*/
export function setCurrentEncounter(encounter) {
currentEncounter = encounter;
}
/**
* Updates current encounter state with partial data
* @param {object} updates - Partial encounter state to merge
*/
export function updateCurrentEncounter(updates) {
Object.assign(currentEncounter, updates);
}
/**
* Resets the encounter state
*/
export function resetEncounter() {
currentEncounter = {
active: false,
initialized: false,
combatHistory: [],
combatStats: null,
preEncounterContext: [],
encounterStartMessage: '',
encounterLog: []
};
}
/**
* Adds a message to combat history
* @param {string} role - Message role ('user', 'assistant', or 'system')
* @param {string} content - Message content
*/
export function addCombatMessage(role, content) {
currentEncounter.combatHistory.push({ role, content });
}
/**
* Adds an entry to the encounter log
* @param {string} action - The action taken
* @param {string} result - The result of the action
*/
export function addEncounterLogEntry(action, result) {
currentEncounter.encounterLog.push({
timestamp: Date.now(),
action,
result
});
}
/**
* Saves an encounter log for a specific chat
* @param {string} chatId - The chat identifier
* @param {object} logData - The encounter log data
*/
export function saveEncounterLog(chatId, logData) {
if (!encounterLogs[chatId]) {
encounterLogs[chatId] = [];
}
encounterLogs[chatId].push({
timestamp: new Date(),
log: logData.log || [],
summary: logData.summary || '',
result: logData.result || 'unknown'
});
}
/**
* Gets encounter logs for a specific chat
* @param {string} chatId - The chat identifier
* @returns {Array} Array of encounter logs
*/
export function getEncounterLogs(chatId) {
return encounterLogs[chatId] || [];
}
/**
* Clears all encounter logs for a specific chat
* @param {string} chatId - The chat identifier
*/
export function clearEncounterLogs(chatId) {
if (encounterLogs[chatId]) {
delete encounterLogs[chatId];
}
}
/**
* Exports encounter logs as JSON
* @param {string} chatId - The chat identifier
* @returns {string} JSON string of encounter logs
*/
export function exportEncounterLogs(chatId) {
const logs = getEncounterLogs(chatId);
return JSON.stringify(logs, null, 2);
}
+85 -2
View File
@@ -63,7 +63,7 @@ export async function ensureHtmlCleaningRegex(st_extension_settings, saveSetting
);
if (alreadyExists) {
console.log('[RPG Companion] HTML cleaning regex already exists, skipping import');
// console.log('[RPG Companion] HTML cleaning regex already exists, skipping import');
return;
}
@@ -107,10 +107,93 @@ export async function ensureHtmlCleaningRegex(st_extension_settings, saveSetting
console.warn('[RPG Companion] saveSettingsDebounced is not a function, cannot save HTML regex');
}
console.log('[RPG Companion] ✅ HTML cleaning regex imported successfully');
// console.log('[RPG Companion] ✅ HTML cleaning regex imported successfully');
} catch (error) {
console.error('[RPG Companion] Failed to import HTML cleaning regex:', error);
console.error('[RPG Companion] Error details:', error.message, error.stack);
// Don't throw - this is a nice-to-have feature
}
}
/**
* Automatically imports a regex script to clean tracker JSON from outgoing prompts.
* This is useful when switching from together mode to separate mode mid-roleplay,
* as it prevents old tracker JSON from chat history being sent to the AI.
* @param {Object} st_extension_settings - SillyTavern extension settings object
* @param {Function} saveSettingsDebounced - Function to save settings
*/
export async function ensureTrackerCleaningRegex(st_extension_settings, saveSettingsDebounced) {
try {
// Validate extension settings structure
if (!st_extension_settings || typeof st_extension_settings !== 'object') {
console.warn('[RPG Companion] Invalid extension_settings object, skipping tracker cleaning regex import');
return;
}
// Check if the tracker cleaning regex already exists
const scriptName = 'Clean RPG Trackers (From Outgoing Prompt)';
const existingScripts = st_extension_settings?.regex || [];
// Validate regex array
if (!Array.isArray(existingScripts)) {
console.warn('[RPG Companion] extension_settings.regex is not an array, resetting to empty array');
st_extension_settings.regex = [];
}
const alreadyExists = existingScripts.some(script =>
script && typeof script === 'object' && script.scriptName === scriptName
);
if (alreadyExists) {
// console.log('[RPG Companion] Tracker cleaning regex already exists, skipping import');
return;
}
// Generate a UUID for the script
const uuidv4 = () => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
// Create the regex script to remove ```json...``` blocks containing tracker data
// This regex matches markdown code blocks with "json" language tag
const regexScript = {
id: uuidv4(),
scriptName: scriptName,
findRegex: '/```json\\s*\\n\\{[\\s\\S]*?(?:\"userStats\"|\"infoBox\"|\"characters\")[\\s\\S]*?\\}\\s*\\n```/gm',
replaceString: '',
trimStrings: [],
placement: [2], // 2 = Input (affects outgoing prompt)
disabled: false,
markdownOnly: false,
promptOnly: true,
runOnEdit: true,
substituteRegex: 0,
minDepth: null,
maxDepth: null
};
// Add to global regex scripts
if (!Array.isArray(st_extension_settings.regex)) {
st_extension_settings.regex = [];
}
st_extension_settings.regex.push(regexScript);
// Save the changes
if (typeof saveSettingsDebounced === 'function') {
saveSettingsDebounced();
} else {
console.warn('[RPG Companion] saveSettingsDebounced is not a function, cannot save tracker cleaning regex');
}
// console.log('[RPG Companion] ✅ Tracker cleaning regex imported successfully');
} catch (error) {
console.error('[RPG Companion] Failed to import tracker cleaning regex:', error);
console.error('[RPG Companion] Error details:', error.message, error.stack);
// Don't throw - this is a nice-to-have feature
}
}
+167
View File
@@ -0,0 +1,167 @@
/**
* JSON Cleaning Module
* Automatically registers a regex script to strip tracker JSON from Together mode output
*/
/**
* Registers an output transformation regex to remove tracker JSON from messages
* This uses SillyTavern's built-in regex system to transform text BEFORE display
* @param {Object} st_extension_settings - SillyTavern extension settings object
* @param {Function} saveSettingsDebounced - Function to save settings
*/
export async function ensureJsonCleaningRegex(st_extension_settings, saveSettingsDebounced) {
try {
// Validate extension settings structure
if (!st_extension_settings || typeof st_extension_settings !== 'object') {
console.warn('[RPG Companion] Invalid extension_settings object, skipping JSON cleaning regex');
return;
}
// Check if the JSON cleaning regex already exists
const scriptName = 'RPG Companion - Remove Tracker JSON (Together Mode)';
const existingScripts = st_extension_settings?.regex || [];
// Validate regex array
if (!Array.isArray(existingScripts)) {
console.warn('[RPG Companion] extension_settings.regex is not an array, resetting to empty array');
st_extension_settings.regex = [];
}
const existingScript = existingScripts.find(script =>
script && script.scriptName && script.scriptName === scriptName
);
if (existingScript) {
// Update existing script with new regex pattern if it's different
const newPattern = '/```(?:json|markdown)?[\\s\\S]*?```/gim';
// Always ensure these properties are set correctly
let needsSave = false;
if (existingScript.findRegex !== newPattern) {
existingScript.findRegex = newPattern;
needsSave = true;
}
if (JSON.stringify(existingScript.placement) !== JSON.stringify([2])) {
existingScript.placement = [2]; // 2 = AI Output
needsSave = true;
}
if (existingScript.disabled !== false) {
existingScript.disabled = false;
needsSave = true;
}
if (existingScript.runOnEdit !== true) {
existingScript.runOnEdit = true;
needsSave = true;
}
if (existingScript.markdownOnly !== true) {
existingScript.markdownOnly = true; // Only process markdown
needsSave = true;
}
if (existingScript.promptOnly !== true) {
existingScript.promptOnly = true; // Enable prompt processing
needsSave = true;
}
if (needsSave && typeof saveSettingsDebounced === 'function') {
// Force immediate save and wait for it
const saveResult = saveSettingsDebounced();
if (saveResult && typeof saveResult.then === 'function') {
await saveResult;
}
// Small delay to ensure save completes
await new Promise(resolve => setTimeout(resolve, 100));
console.log('[RPG Companion] ✅ Updated JSON cleaning regex to v3.2.6 settings.');
} else {
console.log('[RPG Companion] JSON Cleaning Regex is up to date.');
}
return;
}
// Generate a UUID for the script
const uuidv4 = () => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
// Create the regex script object for cleaning JSON tracker data
// This regex matches ```json...```, ```markdown...```, or plain ```...``` code blocks
// The prompt now explicitly instructs models to use this format
// Updated to handle various whitespace scenarios and ensure it catches all variations
const regexScript = {
id: uuidv4(),
scriptName: scriptName,
// Match ```json...```, ```markdown...```, or ```...``` code blocks (handles spaces, newlines, any content)
// Using a more permissive pattern to catch all variations
findRegex: '/```(?:json|markdown)?[\\s\\S]*?```/gim',
replaceString: '',
trimStrings: [],
placement: [2], // 2 = AI Output
disabled: false,
markdownOnly: true,
promptOnly: true, // Enable prompt processing
runOnEdit: true,
substituteRegex: 0,
minDepth: null,
maxDepth: null
};
// Add to global regex scripts
if (!Array.isArray(st_extension_settings.regex)) {
st_extension_settings.regex = [];
}
st_extension_settings.regex.push(regexScript);
console.log('[RPG Companion] JSON Cleaning Regex created and activated.');
// Save the changes
if (typeof saveSettingsDebounced === 'function') {
saveSettingsDebounced();
} else {
console.warn('[RPG Companion] saveSettingsDebounced is not a function, cannot save JSON cleaning regex');
}
} catch (error) {
console.error('[RPG Companion] JSON Cleaning Regex failed to properly initialize!');
console.error('[RPG Companion] Error details:', error.message, error.stack);
// Don't throw - continue without it
}
}
/**
* Removes the JSON cleaning regex if it exists
* Useful when switching to separate mode or disabling the feature
* @param {Object} st_extension_settings - SillyTavern extension settings object
* @param {Function} saveSettingsDebounced - Function to save settings
*/
export function removeJsonCleaningRegex(st_extension_settings, saveSettingsDebounced) {
try {
if (!st_extension_settings?.regex || !Array.isArray(st_extension_settings.regex)) {
return;
}
const scriptName = 'RPG Companion - Remove Tracker JSON (Together Mode)';
const initialLength = st_extension_settings.regex.length;
st_extension_settings.regex = st_extension_settings.regex.filter(script =>
!script || !script.scriptName || script.scriptName !== scriptName
);
if (st_extension_settings.regex.length < initialLength) {
// console.log('[RPG Companion] Removed JSON cleaning regex');
if (typeof saveSettingsDebounced === 'function') {
saveSettingsDebounced();
}
}
} catch (error) {
console.error('[RPG Companion] Failed to remove JSON cleaning regex:', error);
}
}
-267
View File
@@ -1,267 +0,0 @@
/**
* Lorebook Limiter Module
* Adds maximum activation limit to SillyTavern's World Info system
*/
import { eventSource, event_types } from '../../../../../../../script.js';
let maxActivations = 0; // 0 = unlimited
let settingsInitialized = false;
let activatedEntriesThisGeneration = [];
/**
* Initialize the lorebook limiter
*/
export function initLorebookLimiter() {
console.log('[Lorebook Limiter] Initializing...');
// Load saved setting
const saved = localStorage.getItem('rpg_max_lorebook_activations');
if (saved !== null) {
maxActivations = parseInt(saved, 10);
}
// Wait for World Info settings to be ready
eventSource.on('worldInfoSettings', () => {
setTimeout(() => {
if (!settingsInitialized) {
injectMaxActivationsUI();
settingsInitialized = true;
}
}, 100);
});
// Try when the WI drawer is opened
const tryInjectOnClick = () => {
const wiButton = document.querySelector('#WIDrawerIcon');
if (wiButton) {
wiButton.addEventListener('click', () => {
setTimeout(() => {
if (!settingsInitialized) {
injectMaxActivationsUI();
settingsInitialized = true;
}
}, 300);
});
console.log('[Lorebook Limiter] Attached to WI drawer button');
}
};
// Also try on app ready
eventSource.on('app_ready', () => {
setTimeout(() => {
tryInjectOnClick();
if (!settingsInitialized) {
injectMaxActivationsUI();
settingsInitialized = true;
}
}, 1000);
});
// Patch the world info activation system
patchWorldInfoActivation();
}
/**
* Inject the Maximum Activations UI into World Info settings
*/
function injectMaxActivationsUI() {
console.log('[Lorebook Limiter] Injecting UI...');
// Check if already injected
if (document.querySelector('#rpg-max-lorebook-activations-container')) {
console.log('[Lorebook Limiter] UI already injected');
return;
}
// Find the Memory Recollection button - we'll add our UI right after it
const memoryButton = document.querySelector('.rpg-memory-recollection-btn');
if (!memoryButton) {
console.log('[Lorebook Limiter] Memory Recollection button not found yet');
return;
}
const container = memoryButton.parentElement;
if (!container) {
console.log('[Lorebook Limiter] Could not find button container');
return;
}
console.log('[Lorebook Limiter] Found Memory Recollection button, injecting slider after it');
// Create the UI - styled to match the extension's theme
const settingHTML = `
<div id="rpg-max-lorebook-activations-container" class="rpg-lorebook-limiter-container">
<label class="rpg-lorebook-limiter-label">
<span class="rpg-lorebook-limiter-title">Max Lorebook Activations</span>
<input type="number"
id="rpg-max-activations-input"
class="rpg-lorebook-limiter-input"
min="0"
max="9999"
step="1"
value="${maxActivations}"
placeholder="0 = unlimited" />
</label>
<small class="rpg-lorebook-limiter-hint">Limit entries per generation (0 = unlimited)</small>
</div>
`;
// Insert after the Memory Recollection button
memoryButton.insertAdjacentHTML('afterend', settingHTML);
// Add event listener
const input = document.querySelector('#rpg-max-activations-input');
if (input) {
input.addEventListener('input', (e) => {
let value = parseInt(e.target.value, 10);
if (isNaN(value) || value < 0) value = 0;
if (value > 9999) value = 9999;
maxActivations = value;
e.target.value = value;
localStorage.setItem('rpg_max_lorebook_activations', value.toString());
console.log(`[Lorebook Limiter] Max activations set to: ${value}`);
});
console.log('[Lorebook Limiter] ✅ UI injected successfully');
}
}
/**
* Patch the world info activation system to enforce the limit
*/
function patchWorldInfoActivation() {
console.log('[Lorebook Limiter] Setting up activation limiter...');
// We need to intercept at the module level
// Use a Proxy on the module loader
const originalDefine = window.define;
const originalRequire = window.require;
// Try multiple approaches to hook into the WI system
const attemptPatch = () => {
// Approach 1: Direct window access
if (window.getWorldInfoPrompt) {
const original = window.getWorldInfoPrompt;
window.getWorldInfoPrompt = async function(...args) {
const result = await original.apply(this, args);
if (maxActivations > 0 && result) {
// Count entries in the worldInfoString
const lines = (result.worldInfoBefore + result.worldInfoAfter).split('\n').filter(l => l.trim());
if (lines.length > maxActivations) {
console.log(`[Lorebook Limiter] Limiting ${lines.length} WI lines to ${maxActivations}`);
// Trim the strings
const limitedLines = lines.slice(0, maxActivations);
result.worldInfoBefore = limitedLines.join('\n');
result.worldInfoAfter = '';
result.worldInfoString = result.worldInfoBefore;
console.log(`[Lorebook Limiter] ✅ Limited from ${lines.length} to ${limitedLines.length} entries`);
}
}
return result;
};
console.log('[Lorebook Limiter] ✅ Patched window.getWorldInfoPrompt');
return true;
}
// Approach 2: Through SillyTavern context
if (window.SillyTavern?.getContext) {
const ctx = window.SillyTavern.getContext();
if (ctx.getWorldInfoPrompt) {
const original = ctx.getWorldInfoPrompt;
ctx.getWorldInfoPrompt = async function(...args) {
const result = await original.apply(this, args);
if (maxActivations > 0 && result) {
const lines = (result.worldInfoBefore + result.worldInfoAfter).split('\n').filter(l => l.trim());
if (lines.length > maxActivations) {
console.log(`[Lorebook Limiter] Limiting ${lines.length} WI entries to ${maxActivations}`);
const limitedLines = lines.slice(0, maxActivations);
result.worldInfoBefore = limitedLines.join('\n');
result.worldInfoAfter = '';
result.worldInfoString = result.worldInfoBefore;
}
}
return result;
};
console.log('[Lorebook Limiter] ✅ Patched SillyTavern.getContext().getWorldInfoPrompt');
return true;
}
// Try checkWorldInfo instead
if (ctx.checkWorldInfo) {
const original = ctx.checkWorldInfo;
ctx.checkWorldInfo = async function(...args) {
const result = await original.apply(this, args);
if (maxActivations > 0 && result?.allActivatedEntries?.size > maxActivations) {
console.log(`[Lorebook Limiter] Limiting ${result.allActivatedEntries.size} entries to ${maxActivations}`);
// Keep only first N entries
const entries = Array.from(result.allActivatedEntries.entries());
result.allActivatedEntries = new Map(entries.slice(0, maxActivations));
// Also limit the string output
const lines = (result.worldInfoBefore + result.worldInfoAfter).split('\n').filter(l => l.trim());
if (lines.length > maxActivations) {
const limitedLines = lines.slice(0, maxActivations);
result.worldInfoBefore = limitedLines.join('\n');
result.worldInfoAfter = '';
}
console.log(`[Lorebook Limiter] ✅ Limited to ${result.allActivatedEntries.size} entries`);
}
return result;
};
console.log('[Lorebook Limiter] ✅ Patched SillyTavern.getContext().checkWorldInfo');
return true;
}
}
return false;
};
// Try immediately
if (!attemptPatch()) {
// Retry after delays
setTimeout(() => attemptPatch() || setTimeout(() => attemptPatch(), 2000), 1000);
}
}
/**
* Update the maximum activations limit
*/
export function setMaxActivations(value) {
maxActivations = parseInt(value, 10);
localStorage.setItem('rpg_max_lorebook_activations', value.toString());
// Update UI if it exists
const valueDisplay = document.querySelector('#rpg-max-activations-value');
const slider = document.querySelector('#rpg-max-activations-slider');
if (valueDisplay) {
valueDisplay.textContent = value;
}
if (slider) {
slider.value = value;
}
}
/**
* Get current maximum activations limit
*/
export function getMaxActivations() {
return maxActivations;
}
-843
View File
@@ -1,843 +0,0 @@
/**
* Memory Recollection Module
* Handles generation of lorebook entries from chat history
*/
import { chat, characters, this_chid, generateRaw, substituteParams, eventSource, event_types } from '../../../../../../../script.js';
import { selected_group } from '../../../../../../group-chats.js';
import { extensionSettings, addDebugLog } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { checkWorldInfo, createNewWorldInfo, openWorldInfoEditor, saveWorldInfo, setWorldInfoSettings } from '../../../../../../world-info.js';
/**
* Helper to log to both console and debug logs array
*/
function debugLog(message, data = null) {
if (data !== null && data !== undefined) {
console.log(message, data);
} else {
console.log(message);
}
if (extensionSettings.debugMode) {
addDebugLog(message, data);
}
}
/**
* Get or create the Memory Recollection lorebook
* @returns {Promise<string>} The UID of the Memory Recollection lorebook
*/
async function getOrCreateMemoryLorebook() {
const lorebookName = 'Memory Recollection';
try {
debugLog('[Memory Recollection] Checking for existing lorebook...');
// Use checkWorldInfo to see if it exists
const exists = await checkWorldInfo(lorebookName);
if (exists) {
debugLog('[Memory Recollection] Found existing lorebook:', lorebookName);
return lorebookName;
}
// Create new lorebook using SillyTavern's imported function
debugLog('[Memory Recollection] Creating new Memory Recollection lorebook');
// Call the imported createNewWorldInfo function
await createNewWorldInfo(lorebookName, true);
debugLog('[Memory Recollection] Created lorebook:', lorebookName);
// Wait for the file system to settle
await new Promise(resolve => setTimeout(resolve, 500));
return lorebookName;
} catch (error) {
console.error('[Memory Recollection] Error in getOrCreateMemoryLorebook:', error);
throw error;
}
}
/**
* Create the constant "Relevant Memories:" header entry
* @param {string} lorebookUid - The UID of the lorebook
* @returns {Object} The header entry object
*/
function createConstantHeaderEntry() {
const entry = {
uid: 1, // Fixed UID so it's always first
key: [],
keysecondary: [],
comment: 'Relevant Memories Header',
content: 'Relevant Memories:',
constant: true, // Always inserted
vectorized: false,
selective: false,
selectiveLogic: 0,
addMemo: false,
order: 99, // First in order
position: 4, // at Depth
disable: false,
ignoreBudget: false,
excludeRecursion: false,
preventRecursion: false,
matchPersonaDescription: false,
matchCharacterDescription: false,
matchCharacterPersonality: false,
matchCharacterDepthPrompt: false,
matchScenario: false,
matchCreatorNotes: false,
delayUntilRecursion: false,
probability: 100,
useProbability: true,
depth: 1, // Insertion depth
outletName: '',
group: '',
groupOverride: false,
groupWeight: 100,
scanDepth: null,
caseSensitive: null,
matchWholeWords: null,
useGroupScoring: null,
automationId: '',
role: 0, // System role
sticky: 0,
cooldown: 0,
delay: 0,
triggers: [],
displayIndex: 0,
characterFilter: {
isExclude: false,
names: [],
tags: []
}
};
debugLog('[Memory Recollection] Created constant header entry');
return entry;
}
/**
* Save a world info entry to a lorebook
* @param {string} lorebookUid - The filename/UID of the lorebook
* @param {Object} entry - The entry data
*/
async function saveWorldInfoEntry(lorebookUid, entry) {
try {
debugLog('[Memory Recollection] Saving entry to lorebook:', lorebookUid);
// Open the world info editor for this lorebook to load its data
await openWorldInfoEditor(lorebookUid);
// Wait for it to load
await new Promise(resolve => setTimeout(resolve, 500));
// Now access the loaded world info data
const worldInfo = window.world_info;
debugLog('[Memory Recollection] World info after opening:', {
type: typeof worldInfo,
isArray: Array.isArray(worldInfo),
hasEntries: worldInfo?.entries !== undefined,
keys: worldInfo ? Object.keys(worldInfo).slice(0, 10) : null
});
// Try different structures - it might be an array or might have different properties
let entries;
if (worldInfo && typeof worldInfo === 'object') {
if (worldInfo.entries) {
entries = worldInfo.entries;
} else if (Array.isArray(worldInfo)) {
// If it's an array, convert to entries object
entries = {};
worldInfo.forEach((e, i) => {
if (e && e.uid) {
entries[e.uid] = e;
}
});
}
}
if (!entries) {
entries = {};
}
// Add the entry
entries[entry.uid] = entry;
debugLog('[Memory Recollection] Entry added, saving world info...');
// Save using the imported saveWorldInfo function
// Pass the entries as the data structure
await saveWorldInfo(lorebookUid, { entries });
debugLog('[Memory Recollection] Entry saved successfully');
return { success: true };
} catch (error) {
console.error('[Memory Recollection] Error saving entry:', error);
throw error;
}
}
/**
* Save multiple world info entries to a lorebook at once
* @param {string} lorebookUid - The filename/UID of the lorebook
* @param {Array} newEntries - Array of entry objects to add
*/
async function saveWorldInfoEntries(lorebookUid, newEntries) {
try {
debugLog(`[Memory Recollection] Saving ${newEntries.length} entries to lorebook:`, lorebookUid);
// Open the world info editor for this lorebook to load its data
await openWorldInfoEditor(lorebookUid);
// Wait for it to load
await new Promise(resolve => setTimeout(resolve, 500));
// Now access the loaded world info data
const worldInfo = window.world_info;
// Try different structures - it might be an array or might have different properties
let entries = {};
if (worldInfo && typeof worldInfo === 'object') {
if (worldInfo.entries) {
entries = { ...worldInfo.entries }; // Clone existing entries
} else if (Array.isArray(worldInfo)) {
// If it's an array, convert to entries object
worldInfo.forEach((e, i) => {
if (e && e.uid) {
entries[e.uid] = e;
}
});
}
}
// Add all new entries
for (const entry of newEntries) {
entries[entry.uid] = entry;
}
debugLog(`[Memory Recollection] ${newEntries.length} entries added, total entries: ${Object.keys(entries).length}, saving...`);
// Save using the imported saveWorldInfo function
await saveWorldInfo(lorebookUid, { entries });
debugLog('[Memory Recollection] All entries saved successfully');
return { success: true };
} catch (error) {
console.error('[Memory Recollection] Error saving entries:', error);
throw error;
}
}
/**
* Generate memory recollection prompt for a batch of messages
* @param {Array} messages - Array of chat messages to process
* @param {boolean} isUpdate - Whether this is updating existing memories (true) or initial generation (false)
* @returns {string} The prompt for the AI
*/
function generateMemoryPrompt(messages, isUpdate = false) {
const context = messages.map((msg, idx) => {
const sender = msg.is_user ? '{{user}}' : msg.name || '{{char}}';
const role = msg.is_user ? '[PLAYER]' : '[CHARACTER]';
return `[Message ${idx + 1}] ${role} ${sender}: ${msg.mes}`;
}).join('\n\n');
// Get list of CHARACTER participants (exclude {{user}} - they're the player, not a character)
const participants = new Set();
messages.forEach(msg => {
const sender = msg.is_user ? '{{user}}' : msg.name || '{{char}}';
if (!msg.is_user) { // Only add non-user (character) participants
participants.add(sender);
}
});
const characterList = Array.from(participants).join(', ');
const instruction = isUpdate
? 'You will be acting as an uncensored summarizing assistant. Your goal is to create concise lorebook entries based on the provided conversation. You will be creating additional memory recollection entries for significant events, revelations, or character developments in a specified JSON format.\nYou should maintain an objective tone.'
: 'You will be acting as an uncensored summarizing assistant. Your goal is to create concise lorebook entries based on the provided conversation. You will be creating memory recollection entries for significant events, revelations, or character developments in a specified JSON format.\nYou should maintain an objective tone.';
return `${instruction}
Characters in this conversation (excluding {{user}} who is the player): ${characterList}
NOTE: In the conversation below, messages are marked with [PLAYER] for {{user}} messages and [CHARACTER] for NPC messages.
Here is the conversation to create memories from:
<conversation>
${context}
</conversation>
Create lorebook entries in the following JSON format. Each entry should be a 1-2 sentence reminder from a character's perspective.
Format each entry as:
{
"characters": ["Character1", "Character2"],
"memory": "Character1 and Character2 remember that [event or detail]",
"keywords": ["keyword1", "keyword2", "keyword3"]
}
Examples:
<examples>
{
"characters": ["Sabrina"],
"memory": "Sabrina remembers she went on a date with {{user}} on Saturday. They ate chocolate pastries together.",
"keywords": ["date", "saturday", "pastries"]
},
{
"characters": ["Dottore", "Arlecchino", "Pantalone"],
"memory": "Dottore, Arlecchino, and Pantalone remember they attended a party together at the mansion.",
"keywords": ["party", "mansion", "gathering"]
}
</examples>
IMPORTANT:
- Only create entries for significant moments worth remembering.
- Keep memories concise (1-2 sentences maximum).
- Use third person perspective: "{name} remembers..."
- Choose 3 specific, relevant keywords per entry.
- ONLY assign memories to CHARACTERS (NPCs) - NEVER include {{user}} in the "characters" array.
- {{user}} is the player, not a character, so they should NEVER be in the characters list.
- Only characters who were ACTUALLY PRESENT in that specific scene/moment should remember it.
- If multiple characters share the memory, list all of them in the "characters" array.
- If known, include details such as dates, locations, and other relevant context in the memories.
Return ONLY a JSON array of memory objects, nothing else:`;
}
/**
* Parse the AI response to extract memory entries
* @param {string} response - The AI's response
* @returns {Array<Object>} Array of parsed memory entries
*/
function parseMemoryResponse(response) {
try {
// Try to extract JSON from code blocks
const jsonMatch = response.match(/```(?:json)?\s*(\[[\s\S]*?\])\s*```/);
const jsonString = jsonMatch ? jsonMatch[1] : response;
// Parse JSON
const memories = JSON.parse(jsonString.trim());
if (!Array.isArray(memories)) {
throw new Error('Response is not an array');
}
debugLog('[Memory Recollection] Parsed memories:', memories);
return memories;
} catch (error) {
debugLog('[Memory Recollection] Failed to parse response:', error);
console.error('[Memory Recollection] Parse error:', error);
console.error('[Memory Recollection] Raw response:', response);
return [];
}
}
/**
* Create a world info entry from a memory object
* @param {string} lorebookUid - The UID of the lorebook
* @param {Object} memory - The memory object
* @param {number} index - The index for ordering
*/
async function createMemoryEntry(lorebookUid, memory, index) {
const { characters: characterList, memory: content, keywords } = memory;
// Handle character filter - just use the character names directly
let characterNames = [];
if (Array.isArray(characterList) && characterList.length > 0) {
// New format: array of character names
characterNames = characterList.map(name => name.trim());
debugLog(`[Memory Recollection] Character names for filter:`, characterNames);
} else if (typeof characterList === 'string' && characterList.trim() !== '') {
// Legacy string format or comma-separated - parse it
characterNames = characterList.split(',').map(n => n.trim()).filter(n => n !== '');
debugLog(`[Memory Recollection] Character names for filter:`, characterNames);
}
const entry = {
uid: Date.now() + index, // Simple UID generation
key: keywords || [],
keysecondary: [],
comment: `Memory: ${characterNames.join(', ')}`,
content: content,
constant: false,
vectorized: false,
selective: true,
selectiveLogic: 0,
addMemo: false,
order: 100,
position: 4, // at Depth
disable: false,
ignoreBudget: false,
excludeRecursion: false,
preventRecursion: false,
matchPersonaDescription: false,
matchCharacterDescription: false,
matchCharacterPersonality: false,
matchCharacterDepthPrompt: false,
matchScenario: false,
matchCreatorNotes: false,
delayUntilRecursion: false,
probability: 100,
useProbability: true,
depth: 1, // Insertion depth
outletName: '',
group: '',
groupOverride: false,
groupWeight: 100,
scanDepth: null,
caseSensitive: null,
matchWholeWords: null,
useGroupScoring: null,
automationId: '',
role: 0, // 0 = System role (matching the example)
sticky: 0,
cooldown: 0,
delay: 0,
triggers: [],
displayIndex: index + 1,
characterFilter: {
isExclude: false,
names: characterNames, // Array of character names
tags: []
},
extensions: {
position: 4, // at Depth
depth: 1,
role: 1
}
};
debugLog(`[Memory Recollection] Created entry for ${characterNames.join(', ')} with character filter:`, characterNames);
return entry; // Return instead of saving
}
/**
* Process a batch of messages and generate memory entries
* @param {Array} messages - Array of messages to process
* @param {string} lorebookUid - The UID of the lorebook
* @param {boolean} isUpdate - Whether this is an update (true) or initial generation (false)
* @param {number} startIndex - Starting index for entry ordering
* @returns {Promise<Array>} Array of created entries
*/
async function processBatch(messages, lorebookUid, isUpdate, startIndex) {
debugLog(`[Memory Recollection] Processing batch of ${messages.length} messages (isUpdate: ${isUpdate})`);
const prompt = generateMemoryPrompt(messages, isUpdate);
// Generate using SillyTavern's generateRaw
const response = await generateRaw(prompt, '', false, false);
if (!response) {
throw new Error('No response from AI');
}
// Parse the response
const memories = parseMemoryResponse(response);
if (memories.length === 0) {
debugLog('[Memory Recollection] No memories extracted from this batch');
// Return -1 to signal parse failure (vs 0 for valid but empty response)
throw new Error('Failed to parse memories from AI response. The response may be invalid or the service may be unavailable.');
}
// Create entries for each memory (but don't save yet)
const entries = [];
for (let i = 0; i < memories.length; i++) {
const entry = await createMemoryEntry(lorebookUid, memories[i], startIndex + i);
entries.push(entry);
}
debugLog(`[Memory Recollection] Created ${entries.length} entries from batch`);
return entries;
}
/**
* Main function to start memory recollection process
* @param {Function} onProgress - Callback for progress updates (current, total)
* @param {Function} onComplete - Callback when complete
* @param {Function} onError - Callback for errors
*/
export async function startMemoryRecollection(onProgress, onComplete, onError) {
try {
debugLog('[Memory Recollection] Starting memory recollection process');
// Get or create the lorebook
const lorebookUid = await getOrCreateMemoryLorebook();
// Get messages to process count from settings
const messagesToProcess = extensionSettings.memoryMessagesToProcess || 16;
// Check if this is an update (lorebook already exists with entries)
const world_info = window.world_info;
const lorebook = world_info.globalSelect?.find(book => book.uid === lorebookUid);
const existingEntryCount = lorebook?.entries ? Object.keys(lorebook.entries).length : 0;
const isUpdate = existingEntryCount > 1; // More than just the header
let messagesToProcessArray;
if (isUpdate) {
// Process only the last batch
const totalMessages = chat.length;
const startIdx = Math.max(0, totalMessages - messagesToProcess);
messagesToProcessArray = chat.slice(startIdx);
debugLog(`[Memory Recollection] Update mode: Processing last ${messagesToProcess} messages`);
} else {
// Process entire chat in batches
messagesToProcessArray = chat;
debugLog(`[Memory Recollection] Initial mode: Processing all ${chat.length} messages`);
}
const totalBatches = Math.ceil(messagesToProcessArray.length / messagesToProcess);
let entryIndex = existingEntryCount;
const allEntries = []; // Accumulate all entries here
for (let i = 0; i < totalBatches; i++) {
const batchStart = i * messagesToProcess;
const batchEnd = Math.min(batchStart + messagesToProcess, messagesToProcessArray.length);
const batch = messagesToProcessArray.slice(batchStart, batchEnd);
onProgress(i + 1, totalBatches);
try {
const batchEntries = await processBatch(batch, lorebookUid, isUpdate && i === 0, entryIndex);
allEntries.push(...batchEntries); // Add to accumulator
entryIndex += batchEntries.length;
} catch (error) {
// Batch failed - ask user if they want to retry
debugLog('[Memory Recollection] Batch failed:', error.message);
const retry = await new Promise(resolve => {
const retryModal = document.createElement('div');
retryModal.className = 'rpg-memory-modal-overlay';
retryModal.innerHTML = `
<div class="rpg-memory-modal">
<div class="rpg-memory-modal-header">
<h3> Generation Failed</h3>
</div>
<div class="rpg-memory-modal-body">
<p><strong>Error:</strong> ${error.message}</p>
<p>Batch ${i + 1} of ${totalBatches} failed to process.</p>
<p>Would you like to retry this batch?</p>
</div>
<div class="rpg-memory-modal-footer">
<button class="rpg-memory-modal-btn rpg-memory-cancel">Skip Batch</button>
<button class="rpg-memory-modal-btn rpg-memory-proceed">Retry</button>
</div>
</div>
`;
document.body.appendChild(retryModal);
retryModal.querySelector('.rpg-memory-cancel').addEventListener('click', () => {
document.body.removeChild(retryModal);
resolve(false);
});
retryModal.querySelector('.rpg-memory-proceed').addEventListener('click', () => {
document.body.removeChild(retryModal);
resolve(true);
});
});
if (retry) {
// Retry the same batch
i--;
continue;
}
// Otherwise skip this batch and continue
}
// Small delay between batches to avoid rate limiting
if (i < totalBatches - 1) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
// Add the constant header entry at the end
const headerEntry = createConstantHeaderEntry();
allEntries.push(headerEntry); // Add to end of array
// Save all entries at once
if (allEntries.length > 0) {
debugLog(`[Memory Recollection] Saving ${allEntries.length} total entries (including header) to lorebook...`);
await saveWorldInfoEntries(lorebookUid, allEntries);
// Trigger world info refresh by simulating the WI button click to reload the list
// This ensures the newly created lorebook appears in the dropdown
const wiButton = document.querySelector('#WIDrawerIcon');
if (wiButton) {
// Close and reopen to force refresh
wiButton.click();
await new Promise(resolve => setTimeout(resolve, 100));
wiButton.click();
debugLog('[Memory Recollection] Triggered WI panel refresh');
}
// Also emit the update event
eventSource.emit(event_types.WORLDINFO_SETTINGS_UPDATED);
}
debugLog('[Memory Recollection] Process complete');
// Open the World Info editor with the Memory Recollection lorebook
try {
await openWorldInfoEditor(lorebookUid);
debugLog('[Memory Recollection] Opened World Info editor with Memory Recollection lorebook');
} catch (err) {
debugLog('[Memory Recollection] Could not open World Info editor:', err);
}
onComplete(allEntries.length);
} catch (error) {
debugLog('[Memory Recollection] Error:', error);
onError(error);
}
}
/**
* Show memory recollection confirmation modal
*/
export function showMemoryRecollectionModal() {
const modal = document.createElement('div');
modal.className = 'rpg-memory-modal-overlay';
modal.innerHTML = `
<div class="rpg-memory-modal">
<div class="rpg-memory-modal-header">
<h3> Memory Recollection</h3>
</div>
<div class="rpg-memory-modal-body">
<p><strong>Warning!</strong> This process will trigger multiple generation requests and will take time.</p>
<p>Ensure your currently selected model is the one you want to use for this task.</p>
<p class="rpg-memory-modal-info">
Messages per batch: <strong>${extensionSettings.memoryMessagesToProcess || 16}</strong>
<br>
<span class="rpg-memory-modal-hint">(You can change this in the extension settings)</span>
</p>
</div>
<div class="rpg-memory-modal-footer">
<button class="rpg-memory-modal-btn rpg-memory-cancel">Cancel</button>
<button class="rpg-memory-modal-btn rpg-memory-proceed">Proceed</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Event listeners
modal.querySelector('.rpg-memory-cancel').addEventListener('click', () => {
document.body.removeChild(modal);
});
modal.querySelector('.rpg-memory-proceed').addEventListener('click', () => {
document.body.removeChild(modal);
showMemoryProgressModal();
});
// Click outside to close
modal.addEventListener('click', (e) => {
if (e.target === modal) {
document.body.removeChild(modal);
}
});
}
/**
* Show progress modal during memory recollection
*/
function showMemoryProgressModal() {
const modal = document.createElement('div');
modal.className = 'rpg-memory-modal-overlay';
modal.innerHTML = `
<div class="rpg-memory-modal">
<div class="rpg-memory-modal-header">
<h3>🧠 Processing Memories...</h3>
</div>
<div class="rpg-memory-modal-body">
<p class="rpg-memory-progress-text">Processing batch <span class="rpg-memory-current">0</span> of <span class="rpg-memory-total">0</span></p>
<div class="rpg-memory-progress-bar">
<div class="rpg-memory-progress-fill"></div>
</div>
<p class="rpg-memory-status">Initializing...</p>
</div>
</div>
`;
document.body.appendChild(modal);
const currentSpan = modal.querySelector('.rpg-memory-current');
const totalSpan = modal.querySelector('.rpg-memory-total');
const progressFill = modal.querySelector('.rpg-memory-progress-fill');
const statusText = modal.querySelector('.rpg-memory-status');
// Start the process
startMemoryRecollection(
(current, total) => {
currentSpan.textContent = current;
totalSpan.textContent = total;
const percentage = (current / total) * 100;
progressFill.style.width = `${percentage}%`;
statusText.textContent = `Processing memories from batch ${current}...`;
},
(entriesCreated) => {
statusText.innerHTML = `
<strong> Complete!</strong> Created ${entriesCreated} memory entries.<br>
<small>The "Memory Recollection" lorebook has been created.</small><br>
<strong style="color: #ffa500; margin-top: 10px; display: block;"> Please refresh SillyTavern to see the lorebook in the World Info dropdown.</strong>
`;
progressFill.style.width = '100%';
// Add close button
const closeBtn = document.createElement('button');
closeBtn.className = 'rpg-memory-modal-btn rpg-memory-close';
closeBtn.textContent = 'Close';
closeBtn.style.marginTop = '15px';
closeBtn.addEventListener('click', () => {
document.body.removeChild(modal);
});
modal.querySelector('.rpg-memory-modal-body').appendChild(closeBtn);
},
(error) => {
statusText.textContent = `Error: ${error.message}`;
statusText.style.color = '#e94560';
// Close after 5 seconds
setTimeout(() => {
document.body.removeChild(modal);
}, 5000);
}
);
}
/**
* Setup the memory recollection button in World Info section
*/
export function setupMemoryRecollectionButton() {
console.log('[Memory Recollection] Setting up button via event listener');
// Use SillyTavern's built-in event to know when WI is ready
// This fires after the worldInfoSettings are loaded
eventSource.on('worldInfoSettings', () => {
console.log('[Memory Recollection] worldInfoSettings event fired');
setTimeout(updateButton, 100);
});
// Also try on app ready
eventSource.on('app_ready', () => {
console.log('[Memory Recollection] app_ready event fired');
setTimeout(updateButton, 500);
});
// Try immediately as well
setTimeout(updateButton, 2000);
function updateButton() {
const existingButton = document.querySelector('.rpg-memory-recollection-btn');
// If extension is disabled, remove button if it exists
if (!extensionSettings.enabled) {
if (existingButton) {
console.log('[Memory Recollection] Extension disabled, removing button');
existingButton.remove();
}
return;
}
// Extension is enabled, add button if it doesn't exist
addButton();
}
function addButton() {
// Check if button already exists
if (document.querySelector('.rpg-memory-recollection-btn')) {
console.log('[Memory Recollection] Button already exists');
return;
}
console.log('[Memory Recollection] Attempting to add button...');
// World Info button bar is inside the world editor
// Look for the specific button container
const selectors = [
'#world_editor_buttons',
'#world_popup .world_button_bar',
'#WorldInfo .world_button_bar',
'.world_button_bar',
'#world_popup .justifyLeft',
'#WorldInfo .justifyLeft',
'#world_popup',
'#WorldInfo'
];
let container = null;
for (const selector of selectors) {
container = document.querySelector(selector);
if (container) {
console.log(`[Memory Recollection] Found container with selector: ${selector}`, container);
break;
}
}
if (!container) {
console.log('[Memory Recollection] No suitable container found yet');
return;
}
// Create the button
const button = document.createElement('button');
button.id = 'rpg-memory-recollection-button';
button.className = 'rpg-memory-recollection-btn menu_button';
button.innerHTML = '<i class="fa-solid fa-brain"></i> Memory Recollection';
button.title = 'Generate memory recollection entries from chat history';
button.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
showMemoryRecollectionModal();
});
// Insert the button - prepend to put it first
if (container.classList.contains('world_button_bar') || container.classList.contains('justifyLeft')) {
container.insertBefore(button, container.firstChild);
} else {
// Find or create a button container
let buttonContainer = container.querySelector('.world_button_bar') ||
container.querySelector('.justifyLeft');
if (!buttonContainer) {
buttonContainer = document.createElement('div');
buttonContainer.className = 'world_button_bar justifyLeft';
container.insertBefore(buttonContainer, container.firstChild);
}
buttonContainer.insertBefore(button, buttonContainer.firstChild);
}
console.log('[Memory Recollection] ✅ Button added successfully!');
}
}
/**
* Update button visibility based on extension enabled state
* Call this when the extension is toggled on/off
*/
export function updateMemoryRecollectionButton() {
const existingButton = document.querySelector('.rpg-memory-recollection-btn');
if (!extensionSettings.enabled) {
// Extension disabled - remove button if it exists
if (existingButton) {
console.log('[Memory Recollection] Extension disabled, removing button');
existingButton.remove();
}
} else {
// Extension enabled - ensure button exists
if (!existingButton) {
console.log('[Memory Recollection] Extension enabled, adding button');
setTimeout(() => {
setupMemoryRecollectionButton();
}, 100);
}
}
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Music Player Module
* Handles parsing and storing Spotify URLs from AI responses
*/
import { extensionSettings, committedTrackerData } from '../../core/state.js';
/**
* Extracts song suggestion from AI response in <spotify:Song - Artist/> format
* @param {string} responseText - The raw AI response text
* @returns {Object|null} Object with {song, artist, searchQuery} or null if not found
*/
export function extractSpotifyUrl(responseText) {
if (!responseText || !extensionSettings.enableSpotifyMusic) return null;
// Match <spotify:Song Title - Artist Name/> format
const songMatch = responseText.match(/<spotify:([^<>-]+)\s*-\s*([^<>\/]+)\/>/i);
if (songMatch) {
const song = songMatch[1].trim();
const artist = songMatch[2].trim();
const searchQuery = `${song} ${artist}`;
return {
song,
artist,
searchQuery,
displayText: `${song} - ${artist}`
};
}
return null;
}
/**
* Converts song data to Spotify app protocol URL
* @param {Object} songData - Object with {song, artist, searchQuery}
* @returns {string} Spotify app protocol URL
*/
export function convertToEmbedUrl(songData) {
if (!songData || !songData.searchQuery) return '';
// Use Spotify app protocol for direct app opening
const encodedQuery = encodeURIComponent(songData.searchQuery);
return `spotify:search:${encodedQuery}`;
}
/**
* Parses AI response for song suggestion and stores it
* @param {string} responseText - The raw AI response text
* @returns {boolean} True if song was found and stored
*/
export function parseAndStoreSpotifyUrl(responseText) {
if (!extensionSettings.enableSpotifyMusic) return false;
const songData = extractSpotifyUrl(responseText);
// console.log('[RPG Companion] Spotify Parser: Found song:', songData);
if (songData) {
// Store in committed tracker data
committedTrackerData.spotifyUrl = songData;
// console.log('[RPG Companion] Spotify Parser: Stored song in committedTrackerData:', committedTrackerData.spotifyUrl);
return true;
}
return false;
}
/**
* Gets the current song data from committed tracker data
* @returns {Object|null} Current song data or null
*/
export function getCurrentSpotifyUrl() {
return committedTrackerData.spotifyUrl || null;
}
/**
* Clears the current song data
*/
export function clearSpotifyUrl() {
committedTrackerData.spotifyUrl = null;
}
+50 -17
View File
@@ -5,13 +5,15 @@
import { togglePlotButtons } from '../ui/layout.js';
import { extensionSettings, setIsPlotProgression } from '../../core/state.js';
import { DEFAULT_HTML_PROMPT, DEFAULT_DIALOGUE_COLORING_PROMPT, DEFAULT_DECEPTION_PROMPT, DEFAULT_CYOA_PROMPT } from '../generation/promptBuilder.js';
import { Generate } from '../../../../../../../script.js';
/**
* Sets up the plot progression buttons inside the send form area.
* @param {Function} handlePlotClick - Callback function to handle plot button clicks
* @param {Function} handleEncounterClick - Callback function to handle encounter button click
*/
export function setupPlotButtons(handlePlotClick) {
export function setupPlotButtons(handlePlotClick, handleEncounterClick) {
// Remove existing buttons if any
$('#rpg-plot-buttons').remove();
@@ -31,10 +33,9 @@ export function setupPlotButtons(handlePlotClick) {
border-radius: 4px;
font-size: 13px;
cursor: pointer;
margin: 0 4px;
display: inline-block;
" tabindex="0" role="button">
<i class="fa-solid fa-dice"></i> Randomized Plot
margin: 0 2px;
" tabindex="0" role="button" title="Generate a random plot twist or event">
<i class="fa-solid fa-dice"></i>&nbsp;<span class="rpg-btn-text">Randomized Plot</span>
</button>
<button id="rpg-plot-natural" class="menu_button interactable" style="
background-color: #4a90e2;
@@ -44,10 +45,21 @@ export function setupPlotButtons(handlePlotClick) {
border-radius: 4px;
font-size: 13px;
cursor: pointer;
margin: 0 4px;
display: inline-block;
" tabindex="0" role="button">
<i class="fa-solid fa-forward"></i> Natural Plot
margin: 0 2px;
" tabindex="0" role="button" title="Continue the story naturally without twists">
<i class="fa-solid fa-forward"></i>&nbsp;<span class="rpg-btn-text">Natural Plot</span>
</button>
<button id="rpg-encounter-button" class="menu_button interactable" style="
background-color: #cc3333;
color: white;
border: none;
padding: 8px 12px;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
margin: 0 2px;
" tabindex="0" role="button" title="Enter combat encounter">
<i class="fa-solid fa-fire"></i>&nbsp;<span class="rpg-btn-text">Enter Encounter</span>
</button>
</span>
`;
@@ -58,6 +70,7 @@ export function setupPlotButtons(handlePlotClick) {
// Add event handlers for buttons
$('#rpg-plot-random').on('click', () => handlePlotClick('random'));
$('#rpg-plot-natural').on('click', () => handlePlotClick('natural'));
$('#rpg-encounter-button').on('click', () => handleEncounterClick());
// Show/hide based on setting
togglePlotButtons();
@@ -87,19 +100,39 @@ export async function sendPlotProgression(type) {
// Build the prompt based on type
let prompt = '';
if (type === 'random') {
prompt = 'Actually, the scene is getting stale. Introduce {{random::stakes::a plot twist::a new character::a cataclysm::a fourth-wall-breaking joke::a sudden atmospheric phenomenon::a plot hook::a running gag::an ecchi scenario::Death from Discworld::a new stake::a drama::a conflict::an angered entity::a god::a vision::a prophetic dream::Il Dottore from Genshin Impact::a new development::a civilian in need::an emotional bit::a threat::a villain::an important memory recollection::a marriage proposal::a date idea::an angry horde of villagers with pitchforks::a talking animal::an enemy::a cliffhanger::a short omniscient POV shift to a completely different character::a quest::an unexpected revelation::a scandal::an evil clone::death of an important character::harm to an important character::a romantic setup::a gossip::a messenger::a plot point from the past::a plot hole::a tragedy::a ghost::an otherworldly occurrence::a plot device::a curse::a magic device::a rival::an unexpected pregnancy::a brothel::a prostitute::a new location::a past lover::a completely random thing::a what-if scenario::a significant choice::war::love::a monster::lewd undertones::Professor Mari::a travelling troupe::a secret::a fortune-teller::something completely different::a killer::a murder mystery::a mystery::a skill check::a deus ex machina::three raccoons in a trench coat::a pet::a slave::an orphan::a psycho::tentacles::"there is only one bed" trope::accidental marriage::a fun twist::a boss battle::sexy corn::an eldritch horror::a character getting hungry, thirsty, or exhausted::horniness::a need for a bathroom break need::someone fainting::an assassination attempt::a meta narration of this all being an out of hand DND session::a dungeon::a friend in need::an old friend::a small time skip::a scene shift::Aurora Borealis, at this time of year, at this time of day, at this part of the country::a grand ball::a surprise party::zombies::foreshadowing::a Spanish Inquisition (nobody expects it)::a natural plot progression}} to make things more interesting! Be creative, but stay grounded in the setting.';
// Use custom prompt if set, otherwise use default
prompt = extensionSettings.customPlotRandomPrompt || 'Actually, the scene is getting stale. Introduce {{random::stakes::a plot twist::a new character::a cataclysm::a fourth-wall-breaking joke::a sudden atmospheric phenomenon::a plot hook::a running gag::an ecchi scenario::Death from Discworld::a new stake::a drama::a conflict::an angered entity::a god::a vision::a prophetic dream::Il Dottore from Genshin Impact::a new development::a civilian in need::an emotional bit::a threat::a villain::an important memory recollection::a marriage proposal::a date idea::an angry horde of villagers with pitchforks::a talking animal::an enemy::a cliffhanger::a short omniscient POV shift to a completely different character::a quest::an unexpected revelation::a scandal::an evil clone::death of an important character::harm to an important character::a romantic setup::a gossip::a messenger::a plot point from the past::a plot hole::a tragedy::a ghost::an otherworldly occurrence::a plot device::a curse::a magic device::a rival::an unexpected pregnancy::a brothel::a prostitute::a new location::a past lover::a completely random thing::a what-if scenario::a significant choice::war::love::a monster::lewd undertones::Professor Mari::a travelling troupe::a secret::a fortune-teller::something completely different::a killer::a murder mystery::a mystery::a skill check::a deus ex machina::three raccoons in a trench coat::a pet::a slave::an orphan::a psycho::tentacles::"there is only one bed" trope::accidental marriage::a fun twist::a boss battle::sexy corn::an eldritch horror::a character getting hungry, thirsty, or exhausted::horniness::a need for a bathroom break need::someone fainting::an assassination attempt::a meta narration of this all being an out of hand DND session::a dungeon::a friend in need::an old friend::a small time skip::a scene shift::Aurora Borealis, at this time of year, at this time of day, at this part of the country::a grand ball::a surprise party::zombies::foreshadowing::a Spanish Inquisition (nobody expects it)::a natural plot progression}} to make things more interesting! Be creative, but stay grounded in the setting.';
} else {
prompt = 'Actually, the scene is getting stale. Progress it, to make things more interesting! Reintroduce an unresolved plot point from the past, or push the story further towards the current main goal. Be creative, but stay grounded in the setting.';
// Use custom prompt if set, otherwise use default
prompt = extensionSettings.customPlotNaturalPrompt || 'Actually, the scene is getting stale. Progress it, to make things more interesting! Reintroduce an unresolved plot point from the past, or push the story further towards the current main goal. Be creative, but stay grounded in the setting.';
}
// Add HTML prompt if enabled
if (extensionSettings.enableHtmlPrompt) {
prompt += '\n\n' + `If appropriate, include inline HTML, CSS, and JS elements for creative, visual storytelling throughout your response:
- Use them liberally to depict any in-world content that can be visualized (screens, posters, books, signs, letters, logos, crests, seals, medallions, labels, etc.), with creative license for animations, 3D effects, pop-ups, dropdowns, websites, and so on.
- Style them thematically to match the theme (e.g., sleek for sci-fi, rustic for fantasy), ensuring text is visible.
- Embed all resources directly (e.g., inline SVGs) so nothing relies on external fonts or libraries.
- Place elements naturally in the narrative where characters would see or use them, with no limits on format or application.
- These HTML/CSS/JS elements must be rendered directly without enclosing them in code fences.`;
// Use custom HTML prompt if set, otherwise use default
const htmlPromptText = extensionSettings.customHtmlPrompt || DEFAULT_HTML_PROMPT;
prompt += '\n\n' + htmlPromptText;
}
// Add Dialogue Coloring prompt if enabled
if (extensionSettings.enableDialogueColoring) {
// Use custom Dialogue Coloring prompt if set, otherwise use default
const dialogueColoringPromptText = extensionSettings.customDialogueColoringPrompt || DEFAULT_DIALOGUE_COLORING_PROMPT;
prompt += '\n\n' + dialogueColoringPromptText;
}
// Add Deception System prompt if enabled
if (extensionSettings.enableDeceptionSystem) {
// Use custom Deception prompt if set, otherwise use default
const deceptionPromptText = extensionSettings.customDeceptionPrompt || DEFAULT_DECEPTION_PROMPT;
prompt += '\n\n' + deceptionPromptText;
}
// Add CYOA prompt if enabled
if (extensionSettings.enableCYOA) {
// Use custom CYOA prompt if set, otherwise use default
const cyoaPromptText = extensionSettings.customCYOAPrompt || DEFAULT_CYOA_PROMPT;
prompt += '\n\n' + cyoaPromptText;
}
// Set flag to indicate we're doing plot progression
+270 -98
View File
@@ -3,8 +3,12 @@
* Handles API calls for RPG tracker generation
*/
import { generateRaw, chat } from '../../../../../../../script.js';
import { generateRaw, chat, eventSource } from '../../../../../../../script.js';
import { executeSlashCommandsOnChatInput } from '../../../../../../../scripts/slash-commands.js';
// Custom event name for when RPG Companion finishes updating tracker data
// Other extensions can listen for this event to know when RPG Companion is done
export const RPG_COMPANION_UPDATE_COMPLETE = 'rpg_companion_update_complete';
import {
extensionSettings,
lastGeneratedData,
@@ -12,26 +16,152 @@ import {
isGenerating,
lastActionWasSwipe,
setIsGenerating,
setLastActionWasSwipe
setLastActionWasSwipe,
$musicPlayerContainer
} from '../../core/state.js';
import { saveChatData } from '../../core/persistence.js';
import { generateSeparateUpdatePrompt } from './promptBuilder.js';
import {
generateSeparateUpdatePrompt
} from './promptBuilder.js';
import { parseResponse, parseUserStats } from './parser.js';
import { refreshDashboard } from '../dashboard/dashboardIntegration.js';
import { parseAndStoreSpotifyUrl } from '../features/musicPlayer.js';
import { renderUserStats } from '../rendering/userStats.js';
import { renderInfoBox } from '../rendering/infoBox.js';
import { removeLocks } from './lockManager.js';
import { renderThoughts } from '../rendering/thoughts.js';
import { renderInventory } from '../rendering/inventory.js';
import { renderQuests } from '../rendering/quests.js';
import { renderMusicPlayer } from '../rendering/musicPlayer.js';
import { i18n } from '../../core/i18n.js';
import { generateAvatarsForCharacters } from '../features/avatarGenerator.js';
import { setFabLoadingState, updateFabWidgets } from '../ui/mobile.js';
// Store the original preset name to restore after tracker generation
let originalPresetName = null;
/**
* Generates tracker data using an external OpenAI-compatible API.
* Used when generationMode is 'external'.
*
* @param {Array<{role: string, content: string}>} messages - Array of message objects for the API
* @returns {Promise<string>} The generated response content
* @throws {Error} If the API call fails or configuration is invalid
*/
export async function generateWithExternalAPI(messages) {
const { baseUrl, model, maxTokens, temperature } = extensionSettings.externalApiSettings || {};
// Retrieve API key from secure storage (not shared extension settings)
const apiKey = localStorage.getItem('rpg_companion_external_api_key');
// Validate required settings
if (!baseUrl || !baseUrl.trim()) {
throw new Error('External API base URL is not configured');
}
if (!model || !model.trim()) {
throw new Error('External API model is not configured');
}
// Normalize base URL (remove trailing slash if present)
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, '');
const endpoint = `${normalizedBaseUrl}/chat/completions`;
// console.log(`[RPG Companion] Calling external API: ${normalizedBaseUrl} with model: ${model}`);
// Prepare headers - only include Authorization if API key is provided
const headers = {
'Content-Type': 'application/json'
};
if (apiKey && apiKey.trim()) {
headers['Authorization'] = `Bearer ${apiKey.trim()}`;
}
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: headers,
body: JSON.stringify({
model: model.trim(),
messages: messages,
max_tokens: maxTokens || 2048,
temperature: temperature ?? 0.7
})
});
if (!response.ok) {
const errorText = await response.text();
let errorMessage = `External API error: ${response.status} ${response.statusText}`;
try {
const errorJson = JSON.parse(errorText);
if (errorJson.error?.message) {
errorMessage = `External API error: ${errorJson.error.message}`;
}
} catch (e) {
// If parsing fails, use the raw text if it's short enough
if (errorText.length < 200) {
errorMessage = `External API error: ${errorText}`;
}
}
throw new Error(errorMessage);
}
const data = await response.json();
if (!data.choices || !data.choices[0] || !data.choices[0].message) {
throw new Error('Invalid response format from external API');
}
const content = data.choices[0].message.content;
// console.log('[RPG Companion] External API response received successfully');
return content;
} catch (error) {
if (error.name === 'TypeError' && (error.message.includes('fetch') || error.message.includes('Failed to fetch') || error.message.includes('NetworkError'))) {
throw new Error(`CORS Access Blocked: This API endpoint (${normalizedBaseUrl}) does not allow direct access from a browser. This is a browser security restriction (CORS), not a bug in the extension. Please use an endpoint that supports CORS (like OpenRouter or a local proxy) or use SillyTavern's internal API system (Separate Mode).`);
}
throw error;
}
}
/**
* Tests the external API connection with a simple request.
* @returns {Promise<{success: boolean, message: string, model?: string}>}
*/
export async function testExternalAPIConnection() {
const { baseUrl, model } = extensionSettings.externalApiSettings || {};
const apiKey = localStorage.getItem('rpg_companion_external_api_key');
if (!baseUrl || !model) {
return {
success: false,
message: 'Please fill in all required fields (Base URL and Model). API Key is optional for local servers.'
};
}
try {
const testMessages = [
{ role: 'user', content: 'Respond with exactly: "Connection successful"' }
];
const response = await generateWithExternalAPI(testMessages);
return {
success: true,
message: `Connection successful! Model: ${model}`,
model: model
};
} catch (error) {
return {
success: false,
message: error.message || 'Connection failed'
};
}
}
/**
* Gets the current preset name using the /preset command
* @returns {Promise<string|null>} Current preset name or null if unavailable
*/
async function getCurrentPresetName() {
export async function getCurrentPresetName() {
try {
// Use /preset without arguments to get the current preset name
const result = await executeSlashCommandsOnChatInput('/preset', { quiet: true });
@@ -55,12 +185,14 @@ async function getCurrentPresetName() {
console.error('[RPG Companion] Error getting current preset:', error);
return null;
}
}/**
}
/**
* Switches to a specific preset by name using the /preset slash command
* @param {string} presetName - Name of the preset to switch to
* @returns {Promise<boolean>} True if switching succeeded, false otherwise
*/
async function switchToPreset(presetName) {
export async function switchToPreset(presetName) {
try {
// Use the /preset slash command to switch presets
// This is the proper way to change presets in SillyTavern
@@ -95,45 +227,60 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
return;
}
if (extensionSettings.generationMode !== 'separate') {
// console.log('[RPG Companion] Not in separate mode, skipping manual update');
if (extensionSettings.generationMode !== 'separate' && extensionSettings.generationMode !== 'external') {
// console.log('[RPG Companion] Not in separate or external mode, skipping manual update');
return;
}
const isExternalMode = extensionSettings.generationMode === 'external';
try {
setIsGenerating(true);
setFabLoadingState(true); // Show spinning FAB on mobile
// Update button to show "Updating..." state
const $updateBtn = $('#rpg-manual-update');
const originalHtml = $updateBtn.html();
$updateBtn.html('<i class="fa-solid fa-spinner fa-spin"></i> Updating...').prop('disabled', true);
const updatingText = i18n.getTranslation('template.mainPanel.updating') || 'Updating...';
$updateBtn.html(`<i class="fa-solid fa-spinner fa-spin"></i> ${updatingText}`).prop('disabled', true);
// Save current preset name before switching (if we're going to switch)
if (extensionSettings.useSeparatePreset) {
originalPresetName = await getCurrentPresetName();
console.log(`[RPG Companion] Saved original preset: "${originalPresetName}"`);
const prompt = await generateSeparateUpdatePrompt();
// Generate response based on mode
let response;
if (isExternalMode) {
// External mode: Use external OpenAI-compatible API directly
// console.log('[RPG Companion] Using external API for tracker generation');
response = await generateWithExternalAPI(prompt);
} else {
// Separate mode: Use SillyTavern's generateRaw
response = await generateRaw({
prompt: prompt,
quietToLoud: false
});
}
// Switch to separate preset if enabled
if (extensionSettings.useSeparatePreset) {
const switched = await switchToPreset('RPG Companion Trackers');
if (!switched) {
console.warn('[RPG Companion] Failed to switch to RPG Companion Trackers preset. Using current preset.');
originalPresetName = null; // Don't try to restore if we didn't switch
}
}
const prompt = generateSeparateUpdatePrompt();
// Generate using raw prompt (uses current preset, no chat history)
const response = await generateRaw({
prompt: prompt,
quietToLoud: false
});
if (response) {
// console.log('[RPG Companion] Raw AI response:', response);
const parsedData = parseResponse(response);
// Check if parsing completely failed (no tracker data found)
if (parsedData.parsingFailed) {
toastr.error(i18n.getTranslation('errors.parsingError'), '', { timeOut: 5000 });
}
// Remove locks from parsed data (JSON format only, text format is unaffected)
if (parsedData.userStats) {
parsedData.userStats = removeLocks(parsedData.userStats);
}
if (parsedData.infoBox) {
parsedData.infoBox = removeLocks(parsedData.infoBox);
}
if (parsedData.characterThoughts) {
parsedData.characterThoughts = removeLocks(parsedData.characterThoughts);
}
// Parse and store Spotify URL if feature is enabled
parseAndStoreSpotifyUrl(response);
// console.log('[RPG Companion] Parsed data:', parsedData);
// console.log('[RPG Companion] parsedData.userStats:', parsedData.userStats ? parsedData.userStats.substring(0, 100) + '...' : 'null');
@@ -144,6 +291,20 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
// Store RPG data for the last assistant message (separate mode)
const lastMessage = chat && chat.length > 0 ? chat[chat.length - 1] : null;
// console.log('[RPG Companion] Last message is_user:', lastMessage ? lastMessage.is_user : 'no message');
// Update lastGeneratedData for display (regardless of message type)
if (parsedData.userStats) {
lastGeneratedData.userStats = parsedData.userStats;
parseUserStats(parsedData.userStats);
}
if (parsedData.infoBox) {
lastGeneratedData.infoBox = parsedData.infoBox;
}
if (parsedData.characterThoughts) {
lastGeneratedData.characterThoughts = parsedData.characterThoughts;
}
// Also store on assistant message if present (existing behavior)
if (lastMessage && !lastMessage.is_user) {
if (!lastMessage.extra) {
lastMessage.extra = {};
@@ -160,89 +321,100 @@ 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
if (parsedData.userStats) {
lastGeneratedData.userStats = parsedData.userStats;
parseUserStats(parsedData.userStats); // Updates extensionSettings.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',
// infoBox: lastGeneratedData.infoBox ? 'exists' : 'null',
// characterThoughts: lastGeneratedData.characterThoughts ? 'exists' : 'null'
// });
// Only auto-commit on TRULY first generation (no committed data exists at all)
// This prevents auto-commit after refresh when we have saved committed data
const hasAnyCommittedContent = (
(committedTrackerData.userStats && committedTrackerData.userStats.trim() !== '') ||
(committedTrackerData.infoBox && committedTrackerData.infoBox.trim() !== '' && committedTrackerData.infoBox !== 'Info Box\n---\n') ||
(committedTrackerData.characterThoughts && committedTrackerData.characterThoughts.trim() !== '' && committedTrackerData.characterThoughts !== 'Present Characters\n---\n')
);
// Only commit if we have NO committed content at all (truly first time ever)
if (!hasAnyCommittedContent) {
committedTrackerData.userStats = parsedData.userStats;
committedTrackerData.infoBox = parsedData.infoBox;
committedTrackerData.characterThoughts = parsedData.characterThoughts;
// console.log('[RPG Companion] 🔆 FIRST TIME: Auto-committed tracker data');
}
// Render the updated data (old panel UI)
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderQuests();
// Refresh dashboard widgets (v2 dashboard)
refreshDashboard();
} else {
// No assistant message to attach to - just update display
if (parsedData.userStats) {
parseUserStats(parsedData.userStats);
}
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderQuests();
// Refresh dashboard widgets (v2 dashboard)
refreshDashboard();
}
// Only commit on TRULY first generation (no committed data exists at all)
// This prevents auto-commit after refresh when we have saved committed data
const hasAnyCommittedContent = (
(committedTrackerData.userStats && committedTrackerData.userStats.trim() !== '') ||
(committedTrackerData.infoBox && committedTrackerData.infoBox.trim() !== '' && committedTrackerData.infoBox !== 'Info Box\n---\n') ||
(committedTrackerData.characterThoughts && committedTrackerData.characterThoughts.trim() !== '' && committedTrackerData.characterThoughts !== 'Present Characters\n---\n')
);
// Only commit if we have NO committed content at all (truly first time ever)
if (!hasAnyCommittedContent) {
committedTrackerData.userStats = parsedData.userStats;
committedTrackerData.infoBox = parsedData.infoBox;
committedTrackerData.characterThoughts = parsedData.characterThoughts;
// console.log('[RPG Companion] 🔆 FIRST TIME: Auto-committed tracker data');
}
// Render the updated data
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
// Save to chat metadata
saveChatData();
// Generate avatars if auto-generate is enabled (runs within this workflow)
// This uses the RPG Companion Trackers preset and keeps the button spinning
if (extensionSettings.autoGenerateAvatars) {
const charactersNeedingAvatars = parseCharactersFromThoughts(parsedData.characterThoughts);
if (charactersNeedingAvatars.length > 0) {
// console.log('[RPG Companion] Generating avatars for:', charactersNeedingAvatars);
// Generate avatars - this awaits completion
await generateAvatarsForCharacters(charactersNeedingAvatars, (names) => {
// Callback when generation starts - re-render to show loading spinners
// console.log('[RPG Companion] Avatar generation started, showing spinners...');
renderThoughts();
});
// Re-render once all avatars are generated
// console.log('[RPG Companion] All avatars generated, re-rendering...');
renderThoughts();
}
}
}
} catch (error) {
console.error('[RPG Companion] Error updating RPG data:', error);
} finally {
// Restore original preset if we switched to a separate one
if (originalPresetName && extensionSettings.useSeparatePreset) {
console.log(`[RPG Companion] Restoring original preset: "${originalPresetName}"`);
await switchToPreset(originalPresetName);
originalPresetName = null; // Clear after restoring
if (isExternalMode) {
toastr.error(error.message, 'RPG Companion External API Error');
}
} finally {
setIsGenerating(false);
setFabLoadingState(false); // Stop spinning FAB on mobile
updateFabWidgets(); // Update FAB widgets with new data
// Restore button to original state
const $updateBtn = $('#rpg-manual-update');
$updateBtn.html('<i class="fa-solid fa-sync"></i> Refresh RPG Info').prop('disabled', false);
const refreshText = i18n.getTranslation('template.mainPanel.refreshRpgInfo') || 'Refresh RPG Info';
$updateBtn.html(`<i class="fa-solid fa-sync"></i> ${refreshText}`).prop('disabled', false);
// Reset the flag after tracker generation completes
// This ensures the flag persists through both main generation AND tracker generation
// console.log('[RPG Companion] 🔄 Tracker generation complete - resetting lastActionWasSwipe to false');
setLastActionWasSwipe(false);
// Emit event for other extensions to know RPG Companion has finished updating
console.debug('[RPG Companion] Emitting RPG_COMPANION_UPDATE_COMPLETE event');
eventSource.emit(RPG_COMPANION_UPDATE_COMPLETE);
}
}
/**
* Parses character names from Present Characters thoughts data
* @param {string} characterThoughtsData - Raw character thoughts data
* @returns {Array<string>} Array of character names found
*/
function parseCharactersFromThoughts(characterThoughtsData) {
if (!characterThoughtsData) return [];
const lines = characterThoughtsData.split('\n');
const characters = [];
for (const line of lines) {
if (line.trim().startsWith('- ')) {
const name = line.trim().substring(2).trim();
if (name && name.toLowerCase() !== 'unavailable') {
characters.push(name);
}
}
}
return characters;
}
+762
View File
@@ -0,0 +1,762 @@
/**
* Encounter Prompt Builder Module
* Handles all AI prompt generation for combat encounters
*/
import { getContext } from '../../../../../../extensions.js';
import { chat, characters, this_chid, substituteParams } from '../../../../../../../script.js';
import { selected_group, getGroupMembers, groups } from '../../../../../../group-chats.js';
import { extensionSettings, committedTrackerData } from '../../core/state.js';
import { currentEncounter } from '../features/encounterState.js';
import { repairJSON } from '../../utils/jsonRepair.js';
import { buildInventorySummary, generateTrackerInstructions, generateTrackerExample } from './promptBuilder.js';
import { applyLocks } from './lockManager.js';
/**
* Gets character information from the current chat
* @returns {Promise<string>} Formatted character information
*/
async function getCharactersInfo() {
let characterInfo = '';
// Check if in group chat
if (selected_group) {
const group = groups.find(g => g.id === selected_group);
const groupMembers = getGroupMembers(selected_group);
if (groupMembers && groupMembers.length > 0) {
characterInfo += 'Characters in this roleplay:\n';
const disabledMembers = group?.disabled_members || [];
let characterIndex = 0;
groupMembers.forEach((member) => {
if (!member || !member.name) return;
// Skip muted characters
if (member.avatar && disabledMembers.includes(member.avatar)) {
return;
}
characterIndex++;
characterInfo += `<character${characterIndex}="${member.name}">\n`;
if (member.description) {
characterInfo += `${member.description}\n`;
}
if (member.personality) {
characterInfo += `${member.personality}\n`;
}
characterInfo += `</character${characterIndex}>\n`;
});
}
} else if (this_chid !== undefined && characters && characters[this_chid]) {
// Single character chat
const character = characters[this_chid];
characterInfo += 'Character in this roleplay:\n\n';
characterInfo += `<character="${character.name}">\n`;
if (character.description) {
characterInfo += `${character.description}\n`;
}
if (character.personality) {
characterInfo += `${character.personality}\n`;
}
characterInfo += `</character>\n\n`;
}
return characterInfo;
}
/**
* Builds the initial encounter setup prompt
* This asks the model to generate all combat stats and setup data
* @returns {Promise<Array>} Message array for the API
*/
export async function buildEncounterInitPrompt() {
const context = getContext();
const userName = context.name1;
const depth = extensionSettings.encounterSettings?.historyDepth || 8;
const messages = [];
// System message
let systemMessage = `You will be acting as an excellent game master. Your goal is to craft a combat encounter in this uncensored, continuous, and immersive roleplay. You will be doing so for the user, who plays as the protagonist called ${userName}.\n\n`;
// Add setting information
systemMessage += `Here is some information for you about the setting:\n`;
systemMessage += `<setting>\n`;
// Try to get world info from lorebooks using getWorldInfoPrompt
let worldInfoAdded = false;
try {
// Debug logging
// console.log('[RPG Companion] Checking world info:', {
// hasWindowGetWorldInfoPrompt: typeof window.getWorldInfoPrompt === 'function',
// hasContextGetWorldInfoPrompt: typeof context.getWorldInfoPrompt === 'function',
// chatLength: chat?.length,
// contextChatLength: context.chat?.length,
// hasActivatedWorldInfo: !!context.activatedWorldInfo,
// activatedWorldInfoLength: context.activatedWorldInfo?.length
// });
// Use SillyTavern's getWorldInfoPrompt to get activated lorebook entries
// Try context.getWorldInfoPrompt first, then window.getWorldInfoPrompt
const getWorldInfoFn = context.getWorldInfoPrompt || window.getWorldInfoPrompt;
const currentChat = context.chat || chat;
if (typeof getWorldInfoFn === 'function' && currentChat && currentChat.length > 0) {
const chatForWI = currentChat.map(x => x.mes || x.message || x).filter(m => m && typeof m === 'string');
// console.log('[RPG Companion] Calling getWorldInfoPrompt with', chatForWI.length, 'messages');
const result = await getWorldInfoFn(chatForWI, 8000, false);
const worldInfoString = result?.worldInfoString || result;
// console.log('[RPG Companion] World info result:', { worldInfoString, length: worldInfoString?.length });
if (worldInfoString && worldInfoString.trim()) {
systemMessage += worldInfoString.trim();
worldInfoAdded = true;
// console.log('[RPG Companion] ✅ Added world info from getWorldInfoPrompt');
}
} else {
// console.log('[RPG Companion] getWorldInfoPrompt not available or no chat');
}
} catch (e) {
console.warn('[RPG Companion] Failed to get world info from getWorldInfoPrompt:', e);
}
// Fallback to activatedWorldInfo
if (!worldInfoAdded && context.activatedWorldInfo && Array.isArray(context.activatedWorldInfo) && context.activatedWorldInfo.length > 0) {
// console.log('[RPG Companion] Using fallback activatedWorldInfo:', context.activatedWorldInfo.length, 'entries');
context.activatedWorldInfo.forEach((entry) => {
if (entry && entry.content) {
systemMessage += `${entry.content}\n\n`;
worldInfoAdded = true;
}
});
}
if (!worldInfoAdded) {
console.warn('[RPG Companion] ⚠️ No world information available');
systemMessage += 'No world information available.';
}
systemMessage += `\n</setting>\n\n`;
// Add character information
const charactersInfo = await getCharactersInfo();
if (charactersInfo) {
systemMessage += `Here is the information available to you about the characters participating in the fight:\n`;
systemMessage += `<characters>\n${charactersInfo}</characters>\n\n`;
}
// Add persona information
systemMessage += `Here are details about the user's ${userName}:\n`;
systemMessage += `<persona>\n`;
try {
const personaText = substituteParams('{{persona}}');
if (personaText && personaText !== '{{persona}}') {
systemMessage += personaText;
} else {
systemMessage += 'No persona information available.';
}
} catch (e) {
systemMessage += 'No persona information available.';
}
systemMessage += `\n</persona>\n\n`;
// Add chat history from before the encounter
systemMessage += `Here is the chat history from before the encounter started between the user and the assistant:\n`;
systemMessage += `<history>\n`;
messages.push({
role: 'system',
content: systemMessage
});
// Add recent chat history (last X messages before encounter)
if (chat && chat.length > 0) {
const recentMessages = chat.slice(-depth - 1, -1); // Exclude the last message (encounter trigger)
for (const message of recentMessages) {
const content = message.mes?.trim();
// Skip empty messages
if (content) {
messages.push({
role: message.is_user ? 'user' : 'assistant',
content: content
});
}
}
// Add the encounter trigger message
const lastMessage = chat[chat.length - 1];
if (lastMessage && lastMessage.mes?.trim()) {
currentEncounter.encounterStartMessage = lastMessage.mes;
messages.push({
role: lastMessage.is_user ? 'user' : 'assistant',
content: lastMessage.mes.trim()
});
}
}
// Build user's current stats
let userStatsInfo = '';
// Add HP and other stats from committed tracker data
if (committedTrackerData.userStats) {
userStatsInfo += `${userName}'s Current Stats:\n${committedTrackerData.userStats}\n\n`;
}
// Add skills if available
const skillsSection = extensionSettings.trackerConfig?.userStats?.skillsSection;
if (skillsSection?.enabled && skillsSection.customFields && skillsSection.customFields.length > 0) {
userStatsInfo += `${userName}'s Skills: ${skillsSection.customFields.join(', ')}\n`;
}
// Add inventory
const inventory = extensionSettings.userStats?.inventory;
if (inventory) {
const inventorySummary = buildInventorySummary(inventory);
userStatsInfo += `${userName}'s Inventory:\n${inventorySummary}\n\n`;
}
// Add classic stats/attributes
if (extensionSettings.classicStats) {
const stats = extensionSettings.classicStats;
userStatsInfo += `${userName}'s Attributes: `;
const showLevel = extensionSettings.trackerConfig?.userStats?.showLevel !== false;
const levelStr = showLevel ? `, LVL ${extensionSettings.level}` : '';
userStatsInfo += `STR ${stats.str}, DEX ${stats.dex}, CON ${stats.con}, INT ${stats.int}, WIS ${stats.wis}, CHA ${stats.cha}${levelStr}\n\n`;
}
// Add present characters info for party members
let partyInfo = '';
if (committedTrackerData.characterThoughts) {
partyInfo += `Present Characters (potential party members):\n${committedTrackerData.characterThoughts}\n\n`;
}
// Close history and add combat initialization instruction
let initInstruction = `</history>\n\n`;
// Wrap RPG Companion panel data in context tags
initInstruction += `Here is some additional tracked context for the scene:\n`;
initInstruction += `<context>\n`;
initInstruction += userStatsInfo;
initInstruction += partyInfo;
initInstruction += `</context>\n\n`;
initInstruction += `The combat starts now.\n\n`;
initInstruction += `Based on everything above, generate the initial combat state. Analyze who is in the party fighting alongside ${userName} (if anyone), and who the enemies are. Replace placeholders in [brackets] and X with actual values. Return ONLY a JSON object with the following structure:\n\n`;
initInstruction += `{\n`;
initInstruction += ` "party": [\n`;
initInstruction += ` {\n`;
initInstruction += ` "name": "${userName}",\n`;
initInstruction += ` "hp": X,\n`;
initInstruction += ` "maxHp": X,\n`;
initInstruction += ` "attacks": [\n`;
initInstruction += ` {"name": "Attack", "type": "single-target|AoE|both"},\n`;
initInstruction += ` {"name": "Skill1", "type": "single-target|AoE|both"}\n`;
initInstruction += ` ],\n`;
initInstruction += ` "items": ["Item1", "Item2"],\n`;
initInstruction += ` "statuses": [],\n`;
initInstruction += ` "isPlayer": true\n`;
initInstruction += ` }\n`;
initInstruction += ` // Add other party members here if they exist in the context, changing isPlayer to false for them.\n`;
initInstruction += ` ],\n`;
initInstruction += ` "enemies": [\n`;
initInstruction += ` {\n`;
initInstruction += ` "name": "Enemy Name",\n`;
initInstruction += ` "hp": X,\n`;
initInstruction += ` "maxHp": X,\n`;
initInstruction += ` "attacks": [\n`;
initInstruction += ` {"name": "Attack1", "type": "single-target|AoE|both"},\n`;
initInstruction += ` {"name": "Attack2", "type": "single-target|AoE|both"}\n`;
initInstruction += ` ],\n`;
initInstruction += ` "statuses": [],\n`;
initInstruction += ` "description": "Brief enemy description",\n`;
initInstruction += ` "sprite": "emoji or brief visual description"\n`;
initInstruction += ` }\n`;
initInstruction += ` // Add all enemies participating in this combat\n`;
initInstruction += ` ],\n`;
initInstruction += ` "environment": "Brief description of the combat environment",\n`;
initInstruction += ` "styleNotes": {\n`;
initInstruction += ` "environmentType": "forest|dungeon|desert|cave|city|ruins|snow|water|castle|wasteland|plains|mountains|swamp|volcanic|spaceship|mansion",\n`;
initInstruction += ` "atmosphere": "bright|dark|foggy|stormy|calm|eerie|chaotic|peaceful",\n`;
initInstruction += ` "timeOfDay": "dawn|day|dusk|night|twilight",\n`;
initInstruction += ` "weather": "clear|rainy|snowy|windy|stormy|overcast"\n`;
initInstruction += ` }\n`;
initInstruction += `}\n\n`;
initInstruction += `IMPORTANT NOTES:\n`;
initInstruction += `- For attacks array: Each attack must be an object with "name" and "type" properties\n`;
initInstruction += ` - "single-target": Can only target one character (enemy or ally)\n`;
initInstruction += ` - "AoE": Area of Effect - targets all enemies, but some AoE attacks (like storms, explosions) can also harm allies if the attack is indiscriminate\n`;
initInstruction += ` - "both": Player can choose to target a single enemy OR use as AoE\n`;
initInstruction += `- Statuses array: May start empty, but don't have to if characters applied them before the combat\n`;
initInstruction += ` - Each status has a format: {"name": "Status Name", "emoji": "💀", "duration": X}\n`;
initInstruction += ` - Examples: Poisoned (🧪), Burning (🔥), Blessed (✨), Stunned (💫), Weakened (⬇️), Strengthened (⬆️)\n\n`;
initInstruction += `The styleNotes object will be used to visually style the combat window - choose ONE value from each category that best fits the environment described in the chat history.\n\n`;
initInstruction += `Use the user's current stats, inventory, and skills to populate the party data. For ${userName}'s attacks array, include their available skills. For items, include usable items from their inventory. Set HP based on their current Health stat if available.\n\n`;
initInstruction += `Ensure all party members and enemies have realistic HP values based on the setting and their descriptions. Return ONLY the JSON object, no other text.`;
// Only add the instruction if it has meaningful content
if (initInstruction.trim()) {
messages.push({
role: 'user',
content: initInstruction.trim()
});
}
// Validate that we have at least one message with content
if (messages.length === 0 || messages.every(m => !m.content || !m.content.trim())) {
throw new Error('Unable to build encounter prompt - no valid content available');
}
return messages;
}
/**
* Builds a combat action prompt
* This is sent when the user takes an action in combat
* @param {string} action - The action taken by the user
* @param {object} combatStats - Current combat statistics
* @returns {Array} Message array for the API
*/
export async function buildCombatActionPrompt(action, combatStats) {
const context = getContext();
const userName = context.name1;
const depth = extensionSettings.encounterSettings?.historyDepth || 8;
// Get narrative style from settings
const narrativeStyle = extensionSettings.encounterSettings?.combatNarrative || {};
const tense = narrativeStyle.tense || 'present';
const person = narrativeStyle.person || 'third';
const narration = narrativeStyle.narration || 'omniscient';
const pov = narrativeStyle.pov || 'narrator';
const messages = [];
// Build system message with setting info
let systemMessage = `You are the game master managing this combat encounter. You must not play as ${userName} - only describe what happens as a result of their actions/dialogues and control NPCs/enemies.\n\n`;
// Add setting information
systemMessage += `Here is some information for you about the setting:\n`;
systemMessage += `<setting>\n`;
// Get world info
let worldInfoAdded = false;
try {
const getWorldInfoFn = context.getWorldInfoPrompt || window.getWorldInfoPrompt;
const currentChat = context.chat || chat;
if (typeof getWorldInfoFn === 'function' && currentChat && currentChat.length > 0) {
const chatForWI = currentChat.map(x => x.mes || x.message || x).filter(m => m && typeof m === 'string');
const result = await getWorldInfoFn(chatForWI, 8000, false);
const worldInfoString = result?.worldInfoString || result;
if (worldInfoString && worldInfoString.trim()) {
systemMessage += worldInfoString.trim();
worldInfoAdded = true;
}
}
} catch (e) {
console.warn('[RPG Companion] Failed to get world info for combat action:', e);
}
if (!worldInfoAdded && context.activatedWorldInfo && Array.isArray(context.activatedWorldInfo) && context.activatedWorldInfo.length > 0) {
context.activatedWorldInfo.forEach((entry) => {
if (entry && entry.content) {
systemMessage += `${entry.content}\n\n`;
worldInfoAdded = true;
}
});
}
if (!worldInfoAdded) {
systemMessage += 'No world information available.';
}
systemMessage += `\n</setting>\n\n`;
// Add character information
const charactersInfo = await getCharactersInfo();
if (charactersInfo) {
systemMessage += `Here is the information available to you about the characters:\n`;
systemMessage += `<characters>\n${charactersInfo}</characters>\n\n`;
}
// Add persona info
if (context.name1) {
systemMessage += `The protagonist is:\n`;
systemMessage += `<persona>\n`;
// Use substituteParams to get {{persona}} like in initial encounter
try {
const personaText = substituteParams('{{persona}}');
if (personaText && personaText !== '{{persona}}') {
systemMessage += personaText;
} else {
systemMessage += `Name: ${context.name1}\n`;
if (extensionSettings.userStats?.personaDescription) {
systemMessage += `${extensionSettings.userStats.personaDescription}\n`;
}
}
} catch (e) {
systemMessage += `Name: ${context.name1}\n`;
if (extensionSettings.userStats?.personaDescription) {
systemMessage += `${extensionSettings.userStats.personaDescription}\n`;
}
}
// Add ONLY classic stats/attributes if enabled
if (extensionSettings.classicStats) {
const stats = extensionSettings.classicStats;
const config = extensionSettings.trackerConfig?.userStats;
const rpgAttributes = (config?.rpgAttributes && config.rpgAttributes.length > 0) ? config.rpgAttributes : [
{ id: 'str', name: 'STR', enabled: true },
{ id: 'dex', name: 'DEX', enabled: true },
{ id: 'con', name: 'CON', enabled: true },
{ id: 'int', name: 'INT', enabled: true },
{ id: 'wis', name: 'WIS', enabled: true },
{ id: 'cha', name: 'CHA', enabled: true }
];
const enabledAttributes = rpgAttributes.filter(attr => attr && attr.enabled && attr.name && attr.id);
const attributeStrings = enabledAttributes.map(attr => `${attr.name} ${stats[attr.id] || 10}`);
systemMessage += `\nAttributes: ${attributeStrings.join(', ')}, LVL ${extensionSettings.level}\n`;
}
systemMessage += `</persona>\n\n`;
}
messages.push({
role: 'system',
content: systemMessage
});
// Add recent chat history for context - append as user/assistant messages like initial encounter
const currentChat = context.chat || chat;
if (currentChat && currentChat.length > 0) {
const recentMessages = currentChat.slice(-depth);
for (const message of recentMessages) {
const content = message.mes?.trim();
// Skip empty messages
if (content) {
messages.push({
role: message.is_user ? 'user' : 'assistant',
content: content
});
}
}
}
// Add combat log as plain text (previous actions)
if (currentEncounter.encounterLog && currentEncounter.encounterLog.length > 0) {
let combatHistory = 'Previous Combat Actions:\n';
currentEncounter.encounterLog.forEach(entry => {
combatHistory += `- ${entry.action}\n`;
if (entry.result) {
combatHistory += ` ${entry.result}\n`;
}
});
messages.push({
role: 'user',
content: combatHistory
});
}
// Add current combat state with FULL information (but tell AI not to regenerate static parts)
let stateMessage = `Current Combat State:\n`;
stateMessage += `Environment: ${combatStats.environment || 'Unknown location'}\n\n`;
stateMessage += `Party Members:\n`;
combatStats.party.forEach(member => {
stateMessage += `- ${member.name}${member.isPlayer ? ' (Player)' : ''}: ${member.hp}/${member.maxHp} HP\n`;
if (member.attacks && member.attacks.length > 0) {
stateMessage += ` Attacks: ${member.attacks.map(a => typeof a === 'string' ? a : a.name).join(', ')}\n`;
}
if (member.items && member.items.length > 0) {
stateMessage += ` Items: ${member.items.join(', ')}\n`;
}
if (member.statuses && member.statuses.length > 0) {
const validStatuses = member.statuses.filter(s => s && (s.emoji || s.name));
if (validStatuses.length > 0) {
stateMessage += ` Status Effects: ${validStatuses.map(s => `${s.emoji || ''} ${s.name || ''}`.trim()).join(', ')}\n`;
}
}
});
stateMessage += `\nEnemies:\n`;
combatStats.enemies.forEach(enemy => {
stateMessage += `- ${enemy.name} (${enemy.sprite || ''}): ${enemy.hp}/${enemy.maxHp} HP\n`;
if (enemy.description) {
stateMessage += ` ${enemy.description}\n`;
}
if (enemy.attacks && enemy.attacks.length > 0) {
stateMessage += ` Attacks: ${enemy.attacks.map(a => typeof a === 'string' ? a : a.name).join(', ')}\n`;
}
if (enemy.statuses && enemy.statuses.length > 0) {
const validStatuses = enemy.statuses.filter(s => s && (s.emoji || s.name));
if (validStatuses.length > 0) {
stateMessage += ` Status Effects: ${validStatuses.map(s => `${s.emoji || ''} ${s.name || ''}`.trim()).join(', ')}\n`;
}
}
});
stateMessage += `\n${userName}'s Action: ${action}\n\n`;
stateMessage += `Respond with the exact JSON object as below, containing ONLY these specified values. Remember to consider the user's party and their moves. DO NOT regenerate character descriptions, sprites, or environment:\n`;
stateMessage += `{\n`;
stateMessage += ` "combatStats": {\n`;
stateMessage += ` "party": [{ "name": "Name", "hp": X, "maxHp": X, "statuses": [...] }],\n`;
stateMessage += ` "enemies": [{ "name": "Name", "hp": X, "maxHp": X, "statuses": [...] }]\n`;
stateMessage += ` },\n`;
stateMessage += ` "enemyActions": [{ "enemyName": "Name", "action": "what they do", "target": "target" }],\n`;
stateMessage += ` "partyActions": [{ "memberName": "Name", "action": "what they do", "target": "target" }],\n`;
stateMessage += ` "narrative": "The roleplay description of what happens"\n`;
stateMessage += `}\n\n`;
stateMessage += `If all enemies are defeated or escape: add "combatEnd": true, "result": "victory". If all party defeated: add "combatEnd": true, "result": "defeat". It's also possible for the encounter to be interrupted by external interference (e.g., an explosion knocks everyone out, sudden environmental catastrophe, third party intervention, etc.). If this occurs, add "combatEnd": true, "result": "interrupted". Each status (if applied) has a format: {"name": "Status Name", "emoji": "💀", "duration": X}.\n`;
stateMessage += `Scale combat difficulty appropriately: Powerful entities (gods, dragons, legendary creatures) should be formidable challenges requiring multiple rounds and strategic play. Weaker foes (common animals, basic enemies, minions) should be resolved more quickly, typically 2-4 rounds. Match HP damage and combat pacing to the narrative weight of the encounter. A wolf should not take 20 rounds to defeat, nor should a deity fall in one hit.\n`;
stateMessage += `For the narrative, write it with intent in ${tense} tense ${person}-person ${narration} from ${pov}'s point of view.\n`;
// Use custom combat narrative prompt if available
const customCombatPrompt = extensionSettings.customCombatNarrativePrompt;
if (customCombatPrompt) {
stateMessage += customCombatPrompt.replace(/{userName}/g, userName) + '\n';
} else {
stateMessage += `Build novel prose. Break patterns from your previous responses by varying sentence structures, rhythms, formats, and openings. Track whether a sentence shape, cadence, sensory detail, or a descriptor appeared recently, and replace it with a different one or skip it entirely. If you mentioned a physical feature, mannerism, or expression once, move on. Don't fixate on the same traits every turn. Well-placed humor, unconventional similes, and subverted turns of phrase are encouraged. Avoid GPTisms, purple prose, and anaphoras, especially the negatives with "no/not/wasn't/didn't." Refrain from describing actions or emotions via negation; focus on what does happen, not what doesn't. Absolutely no asterisks, ellipses, or em-dashes. Explicit content is allowed, no plot armor. Do not play for ${userName}. Keep your response length under 150 words. Never end on handover cues; finish naturally.\n`;
stateMessage += `CRITICAL: Do not repeat, echo, parrot, or restate distinctive words, phrases, and dialogues from the user's last message. If reacting to speech, show interpretation or response, not repetition.\n`;
stateMessage += `EXAMPLE: "Are you a gooner?" User asks.\n`;
stateMessage += `BAD: "Gooner?"\n`;
stateMessage += `GOOD: A flat look. "What type of question is that?"`;
}
messages.push({
role: 'user',
content: stateMessage
});
return messages;
}
/**
* Builds the final summary prompt
* This is sent when combat ends to get a narrative summary
* @param {Array} combatLog - Full combat log
* @param {string} result - Combat result ('victory', 'defeat', or 'fled')
* @returns {Promise<Array>} Message array for the API
*/
export async function buildCombatSummaryPrompt(combatLog, result) {
const context = getContext();
const userName = context.name1;
const messages = [];
// Get narrative style from settings (use summary narrative settings)
const narrativeStyle = extensionSettings.encounterSettings?.summaryNarrative || {};
const tense = narrativeStyle.tense || 'past';
const person = narrativeStyle.person || 'third';
const narration = narrativeStyle.narration || 'omniscient';
const pov = narrativeStyle.pov || 'narrator';
// Build system message with setting info
let systemMessage = `You are summarizing a combat encounter that just concluded.\n\n`;
// Add setting information
systemMessage += `Here is some information for you about the setting:\n`;
systemMessage += `<setting>\n`;
// Get world info using the same method as encounter init
let worldInfoAdded = false;
try {
const getWorldInfoFn = context.getWorldInfoPrompt || window.getWorldInfoPrompt;
const currentChat = context.chat || chat;
if (typeof getWorldInfoFn === 'function' && currentChat && currentChat.length > 0) {
const chatForWI = currentChat.map(x => x.mes || x.message || x).filter(m => m && typeof m === 'string');
const result = await getWorldInfoFn(chatForWI, 8000, false);
const worldInfoString = result?.worldInfoString || result;
if (worldInfoString && worldInfoString.trim()) {
systemMessage += worldInfoString.trim();
worldInfoAdded = true;
}
}
} catch (e) {
console.warn('[RPG Companion] Failed to get world info for summary:', e);
}
// Fallback to activatedWorldInfo
if (!worldInfoAdded && context.activatedWorldInfo && Array.isArray(context.activatedWorldInfo) && context.activatedWorldInfo.length > 0) {
context.activatedWorldInfo.forEach((entry) => {
if (entry && entry.content) {
systemMessage += `${entry.content}\n\n`;
worldInfoAdded = true;
}
});
}
if (!worldInfoAdded) {
systemMessage += 'No world information available.';
}
systemMessage += `\n</setting>\n\n`;
// Add character information
const charactersInfo = await getCharactersInfo();
if (charactersInfo) {
systemMessage += `Here is the information available to you about the characters:\n`;
systemMessage += `<characters>\n${charactersInfo}</characters>\n\n`;
}
// Add persona information
systemMessage += `Here are details about ${userName}:\n`;
systemMessage += `<persona>\n`;
try {
const personaText = substituteParams('{{persona}}');
if (personaText && personaText !== '{{persona}}') {
systemMessage += personaText;
} else {
systemMessage += 'No persona information available.';
}
} catch (e) {
systemMessage += 'No persona information available.';
}
systemMessage += `\n</persona>\n\n`;
// Add the message that triggered the encounter
if (currentEncounter.encounterStartMessage) {
systemMessage += `Here is the last message before combat started:\n`;
systemMessage += `<trigger>\n${currentEncounter.encounterStartMessage}\n</trigger>\n\n`;
}
messages.push({
role: 'system',
content: systemMessage
});
let summaryMessage = `Combat has ended with result: ${result}\n\n`;
summaryMessage += `Full Combat Log:\n`;
combatLog.forEach((entry, index) => {
summaryMessage += `\nRound ${index + 1}:\n`;
summaryMessage += `${entry.action}\n`;
summaryMessage += `${entry.result}\n`;
});
summaryMessage += `\n\nProvide a narrative summary of the entire fight in a way that fits the style from the chat history. Start with [FIGHT CONCLUDED] on the first line, then provide the description.\n\n`;
summaryMessage += `Write with intent in ${tense} tense ${person}-person ${narration} from ${pov}'s point of view.\n`;
summaryMessage += `Build novel prose. Break patterns from your previous responses by varying sentence structures, rhythms, formats, and openings. If you last started with a narration, begin this one with dialogue; if with an action, switch to an internal thought. Track whether a sentence shape, cadence, sensory detail, or a descriptor appeared recently, and replace it with a different one or skip it entirely. If you mentioned a physical feature, mannerism, or expression once, move on. Don't fixate on the same traits every turn. Well-placed humor, unconventional similes, and subverted turns of phrase are encouraged. Avoid GPTisms, purple prose, and anaphoras, especially the negatives with "no/not/wasn't/didn't." Refrain from describing actions or emotions via negation; focus on what does happen, not what doesn't. Minimize asterisks, ellipses, and em-dashes. Explicit content is allowed. Never end on handover cues; finish naturally.\n\n`;
summaryMessage += `Express ${userName}'s actions and dialogue using indirect speech (e.g., "${userName} swung their sword" or "${userName} asked for help"). The summary should be 2-4 paragraphs and capture the essence of the battle.\n\n`;
// If in Together mode and trackers are enabled, add tracker update instructions
if (extensionSettings.generationMode === 'together' && (extensionSettings.showUserStats || extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts)) {
summaryMessage += `\n--- TRACKER UPDATE ---\n\n`;
summaryMessage += `After the [FIGHT CONCLUDED] summary, update the RPG trackers to reflect ${userName}'s state AFTER the combat encounter. `;
summaryMessage += `Account for any injuries sustained, resources used, emotional state changes, or other consequences of the battle.\n\n`;
// Include pre-combat tracker state if available
if (committedTrackerData.userStats || committedTrackerData.infoBox || committedTrackerData.characterThoughts) {
summaryMessage += `Pre-combat tracker state:\n`;
summaryMessage += `<previous>\n`;
if (committedTrackerData.userStats) {
const statsJSON = typeof committedTrackerData.userStats === 'object'
? JSON.stringify(committedTrackerData.userStats, null, 2)
: committedTrackerData.userStats;
summaryMessage += statsJSON + '\n';
}
if (committedTrackerData.infoBox) {
const infoBoxJSON = typeof committedTrackerData.infoBox === 'object'
? JSON.stringify(committedTrackerData.infoBox, null, 2)
: committedTrackerData.infoBox;
summaryMessage += infoBoxJSON + '\n';
}
if (committedTrackerData.characterThoughts) {
const charactersJSON = typeof committedTrackerData.characterThoughts === 'object'
? JSON.stringify(committedTrackerData.characterThoughts, null, 2)
: committedTrackerData.characterThoughts;
summaryMessage += charactersJSON + '\n';
}
summaryMessage += `</previous>\n\n`;
}
// Add tracker instructions and example
const trackerInstructions = generateTrackerInstructions(false, false, true);
summaryMessage += trackerInstructions;
const trackerExample = generateTrackerExample();
if (trackerExample) {
summaryMessage += `\n${trackerExample}`;
}
}
messages.push({
role: 'user',
content: summaryMessage
});
return messages;
}
/**
* Parses JSON response from the AI, handling code blocks
* @param {string} response - The AI response
* @returns {object|null} Parsed JSON object or null if parsing fails
*/
export function parseEncounterJSON(response) {
try {
// Remove code blocks if present
let cleaned = response.trim();
// Remove ```json, ```markdown, and ``` markers (more comprehensive)
cleaned = cleaned.replace(/```(?:json|markdown)?\s*/gi, '');
// Remove any remaining backticks
cleaned = cleaned.replace(/`/g, '');
// Find the first { and last }
const firstBrace = cleaned.indexOf('{');
const lastBrace = cleaned.lastIndexOf('}');
if (firstBrace !== -1 && lastBrace !== -1) {
cleaned = cleaned.substring(firstBrace, lastBrace + 1);
}
// Try to parse directly first
try {
return JSON.parse(cleaned);
} catch (initialError) {
// If direct parsing fails, try JSON repair
console.warn('[RPG Companion] Initial parse failed, attempting JSON repair...');
const repaired = repairJSON(cleaned);
if (repaired) {
// console.log('[RPG Companion] ✓ Successfully repaired encounter JSON');
return repaired;
}
// If repair also failed, throw the original error
throw initialError;
}
} catch (error) {
console.error('[RPG Companion] Failed to parse encounter JSON:', error);
console.error('[RPG Companion] Response was:', response);
return null;
}
}
+595 -62
View File
@@ -4,21 +4,339 @@
*/
import { getContext } from '../../../../../../extensions.js';
import { setExtensionPrompt, extension_prompt_types, extension_prompt_roles } from '../../../../../../../script.js';
import { setExtensionPrompt, extension_prompt_types, extension_prompt_roles, eventSource, event_types } from '../../../../../../../script.js';
import {
extensionSettings,
committedTrackerData,
lastGeneratedData,
isGenerating,
lastActionWasSwipe,
setLastActionWasSwipe
setLastActionWasSwipe,
setIsGenerating
} from '../../core/state.js';
import { evaluateSuppression } from './suppression.js';
import { parseUserStats } from './parser.js';
import {
generateTrackerExample,
generateTrackerInstructions,
generateContextualSummary
generateContextualSummary,
formatHistoricalTrackerData,
DEFAULT_HTML_PROMPT,
DEFAULT_DIALOGUE_COLORING_PROMPT,
DEFAULT_DECEPTION_PROMPT,
DEFAULT_CYOA_PROMPT,
DEFAULT_SPOTIFY_PROMPT,
SPOTIFY_FORMAT_INSTRUCTION
} from './promptBuilder.js';
import { restoreCheckpointOnLoad } from '../features/chapterCheckpoint.js';
// Track suppression state for event handler
let currentSuppressionState = false;
// Type imports
/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */
// Track last chat length we committed at to prevent duplicate commits from streaming
let lastCommittedChatLength = -1;
// Store context map for prompt injection (used by event handlers)
let pendingContextMap = new Map();
/**
* Builds a map of historical context data from ST chat messages with rpg_companion_swipes data.
* Returns a map keyed by message index with formatted context strings.
* The index stored depends on the injection position setting.
*
* @returns {Map<number, string>} Map of target message index to formatted context string
*/
function buildHistoricalContextMap() {
const historyPersistence = extensionSettings.historyPersistence;
if (!historyPersistence || !historyPersistence.enabled) {
return new Map();
}
const context = getContext();
const chat = context.chat;
if (!chat || chat.length < 2) {
return new Map();
}
const trackerConfig = extensionSettings.trackerConfig;
const userName = context.name1;
const position = historyPersistence.injectionPosition || 'assistant_message_end';
const contextMap = new Map();
// Determine how many messages to include (0 = all available)
const messageCount = historyPersistence.messageCount || 0;
const maxMessages = messageCount === 0 ? chat.length : Math.min(messageCount, chat.length);
// Find the last assistant message - this is the one that gets current context via setExtensionPrompt
// We should NOT add historical context to it
let lastAssistantIndex = -1;
for (let i = chat.length - 1; i >= 0; i--) {
if (!chat[i].is_user && !chat[i].is_system) {
lastAssistantIndex = i;
break;
}
}
// Iterate through messages to find those with tracker data
// Start from before the last assistant message
let processedCount = 0;
const startIndex = lastAssistantIndex > 0 ? lastAssistantIndex - 1 : chat.length - 2;
for (let i = startIndex; i >= 0 && (messageCount === 0 || processedCount < maxMessages); i--) {
const message = chat[i];
// Skip system messages
if (message.is_system) {
continue;
}
// Only assistant messages have rpg_companion_swipes data
if (message.is_user) {
continue;
}
// Get the rpg_companion_swipes data for current swipe
// Data can be in two places:
// 1. message.extra.rpg_companion_swipes (current session, before save)
// 2. message.swipe_info[swipeId].extra.rpg_companion_swipes (loaded from file)
const currentSwipeId = message.swipe_id || 0;
let swipeData = message.extra?.rpg_companion_swipes;
// If not in message.extra, check swipe_info
if (!swipeData && message.swipe_info && message.swipe_info[currentSwipeId]) {
swipeData = message.swipe_info[currentSwipeId].extra?.rpg_companion_swipes;
}
if (!swipeData) {
continue;
}
const trackerData = swipeData[currentSwipeId];
if (!trackerData) {
continue;
}
// Format the historical tracker data using the shared function
const formattedContext = formatHistoricalTrackerData(trackerData, trackerConfig, userName);
if (!formattedContext) {
continue;
}
// Build the context wrapper
const preamble = historyPersistence.contextPreamble || 'Context for that moment:';
const wrappedContext = `\n${preamble}\n${formattedContext}`;
// Determine which message index to store based on injection position
let targetIndex = i; // Default: the assistant message itself
if (position === 'user_message_end') {
// Find the next user message after this assistant message
for (let j = i + 1; j < chat.length; j++) {
if (chat[j].is_user && !chat[j].is_system) {
targetIndex = j;
break;
}
}
// If no user message found after, skip this one
if (targetIndex === i) {
continue;
}
}
// For assistant_message_end, extra_user_message, extra_assistant_message:
// We inject into the assistant message itself (for now - extra messages handled differently)
// Store the context keyed by target index
// If multiple assistant messages map to the same user message, append
if (contextMap.has(targetIndex)) {
contextMap.set(targetIndex, contextMap.get(targetIndex) + wrappedContext);
} else {
contextMap.set(targetIndex, wrappedContext);
}
processedCount++;
}
return contextMap;
}
/**
* Prepares historical context for injection into prompts.
* This builds the context map and stores it for use by prompt event handlers.
* Does NOT modify the original chat messages.
*/
function prepareHistoricalContextInjection() {
const historyPersistence = extensionSettings.historyPersistence;
if (!historyPersistence || !historyPersistence.enabled) {
pendingContextMap = new Map();
return;
}
if (currentSuppressionState || !extensionSettings.enabled) {
pendingContextMap = new Map();
return;
}
const context = getContext();
const chat = context.chat;
if (!chat || chat.length < 2) {
pendingContextMap = new Map();
return;
}
// Build and store the context map for use by prompt handlers
pendingContextMap = buildHistoricalContextMap();
}
/**
* Injects historical context into a text completion prompt string.
* Searches for message content in the prompt and appends context after matches.
*
* @param {string} prompt - The text completion prompt
* @returns {string} - The modified prompt with injected context
*/
function injectContextIntoTextPrompt(prompt) {
if (pendingContextMap.size === 0) {
return prompt;
}
const context = getContext();
const chat = context.chat;
let modifiedPrompt = prompt;
let injectedCount = 0;
// Process each message that needs context injection
for (const [msgIdx, ctxContent] of pendingContextMap) {
const message = chat[msgIdx];
if (!message || typeof message.mes !== 'string') {
continue;
}
// Find the message content in the prompt
// Use a portion of the message to find it (last 100 chars should be unique enough)
const searchContent = message.mes.length > 100
? message.mes.slice(-100)
: message.mes;
const searchIndex = modifiedPrompt.lastIndexOf(searchContent);
if (searchIndex === -1) {
// Message not found in prompt (might be truncated)
continue;
}
// Find the end of this message content in the prompt
const insertPosition = searchIndex + searchContent.length;
// Insert the context after the message
modifiedPrompt = modifiedPrompt.slice(0, insertPosition) + ctxContent + modifiedPrompt.slice(insertPosition);
injectedCount++;
}
if (injectedCount > 0) {
console.log(`[RPG Companion] Injected historical context into ${injectedCount} positions in text prompt`);
}
return modifiedPrompt;
}
/**
* Injects historical context into a chat completion message array.
* Modifies the content of messages in the array directly.
*
* @param {Array} chatMessages - The chat completion message array
* @returns {Array} - The modified message array with injected context
*/
function injectContextIntoChatPrompt(chatMessages) {
if (pendingContextMap.size === 0 || !Array.isArray(chatMessages)) {
return chatMessages;
}
const context = getContext();
const chat = context.chat;
let injectedCount = 0;
// Process each message that needs context injection
for (const [msgIdx, ctxContent] of pendingContextMap) {
const originalMessage = chat[msgIdx];
if (!originalMessage || typeof originalMessage.mes !== 'string') {
continue;
}
// Find this message in the chat completion array by matching content
// Use a portion of the message to find it
const searchContent = originalMessage.mes.length > 100
? originalMessage.mes.slice(-100)
: originalMessage.mes;
for (const promptMsg of chatMessages) {
if (promptMsg.content && typeof promptMsg.content === 'string' &&
promptMsg.content.includes(searchContent)) {
// Found the message - append context
promptMsg.content = promptMsg.content + ctxContent;
injectedCount++;
break;
}
}
}
if (injectedCount > 0) {
console.log(`[RPG Companion] Injected historical context into ${injectedCount} messages in chat prompt`);
}
return chatMessages;
}
/**
* Event handler for GENERATE_AFTER_COMBINE_PROMPTS (text completion).
* Injects historical context into the prompt string.
*
* @param {Object} eventData - Event data with prompt property
*/
function onGenerateAfterCombinePrompts(eventData) {
if (!eventData || typeof eventData.prompt !== 'string') {
return;
}
if (eventData.dryRun) {
return;
}
// Only inject if we have pending context
if (pendingContextMap.size === 0) {
return;
}
eventData.prompt = injectContextIntoTextPrompt(eventData.prompt);
// DON'T clear pendingContextMap here - let it persist for other generations
// (e.g., prewarm extensions). It will be cleared on GENERATION_ENDED.
}
/**
* Event handler for CHAT_COMPLETION_PROMPT_READY.
* Injects historical context into the chat message array.
*
* @param {Object} eventData - Event data with chat property
*/
function onChatCompletionPromptReady(eventData) {
if (!eventData || !Array.isArray(eventData.chat)) {
return;
}
if (eventData.dryRun) {
return;
}
// Only inject if we have pending context
if (pendingContextMap.size === 0) {
return;
}
eventData.chat = injectContextIntoChatPrompt(eventData.chat);
// DON'T clear pendingContextMap here - let it persist for other generations
// (e.g., prewarm extensions). It will be cleared on GENERATION_ENDED.
}
/**
* Event handler for generation start.
@@ -26,8 +344,15 @@ import {
*
* @param {string} type - Event type
* @param {Object} data - Event data
* @param {boolean} dryRun - If true, this is a dry run (page reload, prompt preview, etc.) - skip all logic
*/
export function onGenerationStarted(type, data) {
export async function onGenerationStarted(type, data, dryRun) {
// Skip dry runs (page reload, prompt manager preview, etc.)
if (dryRun) {
// console.log('[RPG Companion] Skipping onGenerationStarted: dry run detected');
return;
}
// console.log('[RPG Companion] onGenerationStarted called');
// console.log('[RPG Companion] enabled:', extensionSettings.enabled);
// console.log('[RPG Companion] generationMode:', extensionSettings.generationMode);
@@ -35,43 +360,131 @@ export function onGenerationStarted(type, data) {
// console.log('[RPG Companion] Committed Prompt:', committedTrackerData);
// Skip tracker injection for image generation requests
if (data?.quietImage) {
// console.log('[RPG Companion] Detected image generation (quietImage=true), skipping tracker injection');
if (data?.quietImage || data?.quiet_image || data?.isImageGeneration) {
// console.log('[RPG Companion] Detected image generation, skipping tracker injection');
return;
}
if (!extensionSettings.enabled) {
// Extension is disabled - clear any existing prompts to ensure nothing is injected
setExtensionPrompt('rpg-companion-inject', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-example', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-dialogue-coloring', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-spotify', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-context', '', extension_prompt_types.IN_CHAT, 1, false);
return;
}
const chat = getContext().chat;
const context = getContext();
const chat = context.chat;
// Detect if a guided generation is active (GuidedGenerations and similar extensions
// inject an ephemeral 'instruct' injection into chatMetadata.script_injects).
// If present, we should avoid injecting RPG tracker instructions that ask
// the model to include stats/etc. This prevents conflicts when guided prompts
// are used (e.g., GuidedGenerations Extension).
// Evaluate suppression using the shared helper
const suppression = evaluateSuppression(extensionSettings, context, data);
const { shouldSuppress, skipMode, isGuidedGeneration, isImpersonationGeneration, hasQuietPrompt, instructContent, quietPromptRaw, matchedPattern } = suppression;
if (shouldSuppress) {
// Debugging: indicate active suppression and which source triggered it
console.debug(`[RPG Companion] Suppression active (mode=${skipMode}). isGuided=${isGuidedGeneration}, isImpersonation=${isImpersonationGeneration}, hasQuietPrompt=${hasQuietPrompt} - skipping RPG tracker injections for this generation.`);
// Also clear any existing RPG Companion prompts so they do not leak into this generation
// (e.g., previously set extension prompts should not be used alongside a guided prompt)
setExtensionPrompt('rpg-companion-inject', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-example', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-spotify', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-context', '', extension_prompt_types.IN_CHAT, 1, false);
}
// Ensure checkpoint is applied before generation
await restoreCheckpointOnLoad();
const currentChatLength = chat ? chat.length : 0;
const lastMessage = chat && chat.length > 0 ? chat[chat.length - 1] : null;
// For SEPARATE mode only: Check if we need to commit extension data
// For TOGETHER mode: Commit when user sends message (before first generation)
if (extensionSettings.generationMode === 'together') {
// By the time onGenerationStarted fires, ST has already added the placeholder AI message
// So we check the second-to-last message to see if user just sent a message
const secondToLastMessage = chat && chat.length > 1 ? chat[chat.length - 2] : null;
const isUserMessage = secondToLastMessage && secondToLastMessage.is_user;
// Commit if:
// 1. Second-to-last message is from USER (user just sent message)
// 2. Not a swipe (lastActionWasSwipe = false)
// 3. Haven't already committed for this chat length (prevent streaming duplicates)
const shouldCommit = isUserMessage && !lastActionWasSwipe && currentChatLength !== lastCommittedChatLength;
if (shouldCommit) {
// console.log('[RPG Companion] 📝 TOGETHER MODE COMMIT: User sent message - committing data from BEFORE user message');
// console.log('[RPG Companion] Chat length:', currentChatLength, 'Last committed:', lastCommittedChatLength);
// console.log('[RPG Companion] BEFORE: committedTrackerData =', {
// userStats: committedTrackerData.userStats ? `${committedTrackerData.userStats.substring(0, 50)}...` : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? `${committedTrackerData.characterThoughts.substring(0, 100)}...` : 'null'
// // });
// console.log('[RPG Companion] BEFORE: lastGeneratedData =', {
// userStats: lastGeneratedData.userStats ? `${lastGeneratedData.userStats.substring(0, 50)}...` : 'null',
// infoBox: lastGeneratedData.infoBox ? 'exists' : 'null',
// characterThoughts: lastGeneratedData.characterThoughts ? `${lastGeneratedData.characterThoughts.substring(0, 100)}...` : 'null'
// });
// Commit displayed data (from before user sent message)
committedTrackerData.userStats = lastGeneratedData.userStats;
committedTrackerData.infoBox = lastGeneratedData.infoBox;
committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts;
// Track chat length to prevent duplicate commits
lastCommittedChatLength = currentChatLength;
// console.log('[RPG Companion] AFTER: committedTrackerData =', {
// userStats: committedTrackerData.userStats ? `${committedTrackerData.userStats.substring(0, 50)}...` : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? `${committedTrackerData.characterThoughts.substring(0, 100)}...` : 'null'
// });
} else if (lastActionWasSwipe) {
// console.log('[RPG Companion] ⏭️ Skipping commit: swipe (using previous committed data)');
} else if (!isUserMessage) {
// console.log('[RPG Companion] ⏭️ Skipping commit: second-to-last message is not user message (likely swipe or continuation)');
}
// console.log('[RPG Companion] 📦 TOGETHER MODE: Injecting committed tracker data into prompt');
// console.log('[RPG Companion] committedTrackerData =', {
// userStats: committedTrackerData.userStats ? `${committedTrackerData.userStats.substring(0, 50)}...` : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? `${committedTrackerData.characterThoughts.substring(0, 100)}...` : 'null'
// });
}
// For SEPARATE and EXTERNAL modes: Check if we need to commit extension data
// BUT: Only do this for the MAIN generation, not the tracker update generation
// If isGenerating is true, this is the tracker update generation (second call), so skip flag logic
// console.log('[RPG Companion DEBUG] Before generating:', lastGeneratedData.characterThoughts, ' , committed - ', committedTrackerData.characterThoughts);
if (extensionSettings.generationMode === 'separate' && !isGenerating) {
if ((extensionSettings.generationMode === 'separate' || extensionSettings.generationMode === 'external') && !isGenerating) {
if (!lastActionWasSwipe) {
// User sent a new message - commit lastGeneratedData before generation
// console.log('[RPG Companion] 📝 COMMIT: New message - committing lastGeneratedData');
// console.log('[RPG Companion] BEFORE commit - committedTrackerData:', {
// userStats: committedTrackerData.userStats ? 'exists' : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null'
// });
// userStats: committedTrackerData.userStats ? 'exists' : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null'
// // });
// console.log('[RPG Companion] BEFORE commit - lastGeneratedData:', {
// userStats: lastGeneratedData.userStats ? 'exists' : 'null',
// infoBox: lastGeneratedData.infoBox ? 'exists' : 'null',
// characterThoughts: lastGeneratedData.characterThoughts ? 'exists' : 'null'
// userStats: lastGeneratedData.userStats ? 'exists' : 'null',
// infoBox: lastGeneratedData.infoBox ? 'exists' : 'null',
// characterThoughts: lastGeneratedData.characterThoughts ? 'exists' : 'null'
// });
committedTrackerData.userStats = lastGeneratedData.userStats;
committedTrackerData.infoBox = lastGeneratedData.infoBox;
committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts;
// console.log('[RPG Companion] AFTER commit - committedTrackerData:', {
// userStats: committedTrackerData.userStats ? 'exists' : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null'
// userStats: committedTrackerData.userStats ? 'exists' : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null'
// });
// Reset flag after committing (ready for next cycle)
@@ -79,28 +492,14 @@ export function onGenerationStarted(type, data) {
} else {
// console.log('[RPG Companion] 🔄 SWIPE: Using existing committedTrackerData (no commit)');
// console.log('[RPG Companion] committedTrackerData:', {
// userStats: committedTrackerData.userStats ? 'exists' : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null'
// userStats: committedTrackerData.userStats ? 'exists' : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null'
// });
// Reset flag after using it (swipe generation complete, ready for next action)
}
}
// For TOGETHER mode: Check if we need to commit extension data
// Same logic as separate mode - commit on new messages, keep existing data on swipes
if (extensionSettings.generationMode === 'together') {
if (!lastActionWasSwipe) {
// User sent a new message - commit lastGeneratedData before generation
// console.log('[RPG Companion] 📝 TOGETHER MODE COMMIT: New message - committing lastGeneratedData');
committedTrackerData.userStats = lastGeneratedData.userStats;
committedTrackerData.infoBox = lastGeneratedData.infoBox;
committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts;
} else {
// console.log('[RPG Companion] 🔄 TOGETHER MODE SWIPE: Using existing committedTrackerData (no commit)');
}
}
// Use the committed tracker data as source for generation
// console.log('[RPG Companion] Using committedTrackerData for generation');
// console.log('[RPG Companion] committedTrackerData.userStats:', committedTrackerData.userStats);
@@ -114,7 +513,10 @@ export function onGenerationStarted(type, data) {
if (extensionSettings.generationMode === 'together') {
// console.log('[RPG Companion] In together mode, generating prompts...');
const example = generateTrackerExample();
const exampleRaw = generateTrackerExample();
// Wrap example in ```json``` code blocks for consistency with format instructions
// Add only 1 newline after the closing ``` (ST adds its own newline when injecting)
const example = exampleRaw ? `\`\`\`json\n${exampleRaw}\n\`\`\`\n` : null;
// Don't include HTML prompt in instructions - inject it separately to avoid duplication on swipes
const instructions = generateTrackerInstructions(false, true);
@@ -145,7 +547,7 @@ export function onGenerationStarted(type, data) {
}
// If we have previous tracker data and found an assistant message, inject it as an assistant message
if (example && lastAssistantDepth > 0) {
if (!shouldSuppress && example && lastAssistantDepth > 0) {
setExtensionPrompt('rpg-companion-example', example, extension_prompt_types.IN_CHAT, lastAssistantDepth, false, extension_prompt_roles.ASSISTANT);
// console.log('[RPG Companion] Injected tracker example as assistant message at depth:', lastAssistantDepth);
} else {
@@ -153,17 +555,18 @@ export function onGenerationStarted(type, data) {
}
// Inject the instructions as a user message at depth 0 (right before generation)
setExtensionPrompt('rpg-companion-inject', instructions, extension_prompt_types.IN_CHAT, 0, false, extension_prompt_roles.USER);
// If this is a guided generation (user explicitly injected 'instruct'), skip adding
// our tracker instructions to avoid clobbering the guided prompt.
if (!shouldSuppress) {
setExtensionPrompt('rpg-companion-inject', instructions, extension_prompt_types.IN_CHAT, 0, false, extension_prompt_roles.USER);
}
// console.log('[RPG Companion] Injected RPG tracking instructions at depth 0 (right before generation)');
// Inject HTML prompt separately at depth 0 if enabled (prevents duplication on swipes)
if (extensionSettings.enableHtmlPrompt) {
const htmlPrompt = `\nIf appropriate, include inline HTML, CSS, and JS elements for creative, visual storytelling throughout your response:
- Use them liberally to depict any in-world content that can be visualized (screens, posters, books, signs, letters, logos, crests, seals, medallions, labels, etc.), with creative license for animations, 3D effects, pop-ups, dropdowns, websites, and so on.
- Style them thematically to match the theme (e.g., sleek for sci-fi, rustic for fantasy), ensuring text is visible.
- Embed all resources directly (e.g., inline SVGs) so nothing relies on external fonts or libraries.
- Place elements naturally in the narrative where characters would see or use them, with no limits on format or application.
- These HTML/CSS/JS elements must be rendered directly without enclosing them in code fences.`;
if (extensionSettings.enableHtmlPrompt && !shouldSuppress) {
// Use custom HTML prompt if set, otherwise use default
const htmlPromptText = extensionSettings.customHtmlPrompt || DEFAULT_HTML_PROMPT;
const htmlPrompt = `\n- ${htmlPromptText}\n`;
setExtensionPrompt('rpg-companion-html', htmlPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected HTML prompt at depth 0 for together mode');
@@ -171,44 +574,147 @@ export function onGenerationStarted(type, data) {
// Clear HTML prompt if disabled
setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false);
}
} else if (extensionSettings.generationMode === 'separate') {
// In SEPARATE mode, inject the contextual summary for main roleplay generation
// Inject Dialogue Coloring prompt separately at depth 0 if enabled
if (extensionSettings.enableDialogueColoring && !shouldSuppress) {
// Use custom Dialogue Coloring prompt if set, otherwise use default
const dialogueColoringPromptText = extensionSettings.customDialogueColoringPrompt || DEFAULT_DIALOGUE_COLORING_PROMPT;
const dialogueColoringPrompt = `\n- ${dialogueColoringPromptText}\n`;
setExtensionPrompt('rpg-companion-dialogue-coloring', dialogueColoringPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected Dialogue Coloring prompt at depth 0 for together mode');
} else {
// Clear Dialogue Coloring prompt if disabled
setExtensionPrompt('rpg-companion-dialogue-coloring', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Inject Deception System prompt separately at depth 0 if enabled
if (extensionSettings.enableDeceptionSystem && !shouldSuppress) {
// Use custom Deception prompt if set, otherwise use default
const deceptionPromptText = extensionSettings.customDeceptionPrompt || DEFAULT_DECEPTION_PROMPT;
const deceptionPrompt = `\n- ${deceptionPromptText}\n`;
setExtensionPrompt('rpg-companion-deception', deceptionPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected Deception System prompt at depth 0 for together mode');
} else {
// Clear Deception System prompt if disabled
setExtensionPrompt('rpg-companion-deception', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Inject Spotify prompt separately at depth 0 if enabled
if (extensionSettings.enableSpotifyMusic && !shouldSuppress) {
// Use custom Spotify prompt if set, otherwise use default
const spotifyPromptText = extensionSettings.customSpotifyPrompt || DEFAULT_SPOTIFY_PROMPT;
const spotifyPrompt = `\n- ${spotifyPromptText} ${SPOTIFY_FORMAT_INSTRUCTION}\n`;
setExtensionPrompt('rpg-companion-spotify', spotifyPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected Spotify prompt at depth 0 for together mode');
} else {
// Clear Spotify prompt if disabled
setExtensionPrompt('rpg-companion-spotify', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Inject CYOA prompt separately at depth 0 if enabled (injected last to appear last in prompt)
if (extensionSettings.enableCYOA && !shouldSuppress) {
// Use custom CYOA prompt if set, otherwise use default
const cyoaPromptText = extensionSettings.customCYOAPrompt || DEFAULT_CYOA_PROMPT;
const cyoaPrompt = `\n- ${cyoaPromptText}\n`;
setExtensionPrompt('rpg-companion-zzz-cyoa', cyoaPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected CYOA prompt at depth 0 for together mode');
} else {
// Clear CYOA prompt if disabled
setExtensionPrompt('rpg-companion-zzz-cyoa', '', extension_prompt_types.IN_CHAT, 0, false);
}
} else if (extensionSettings.generationMode === 'separate' || extensionSettings.generationMode === 'external') {
// In SEPARATE and EXTERNAL modes, inject the contextual summary for main roleplay generation
const contextSummary = generateContextualSummary();
if (contextSummary) {
const wrappedContext = `Here is context information about the current scene, and what follows is the last message in the chat history:
const wrappedContext = `\nHere is context information about the current scene, and what follows is the last message in the chat history:
<context>
${contextSummary}
Ensure these details naturally reflect and influence the narrative. Character behavior, dialogue, and story events should acknowledge these conditions when relevant, such as fatigue affecting performance, low hygiene influencing social interactions, environmental factors shaping the scene, or a character's emotional state coloring their responses.
</context>
`;
</context>\n\n`;
// Inject context at depth 1 (before last user message) as SYSTEM
setExtensionPrompt('rpg-companion-context', wrappedContext, extension_prompt_types.IN_CHAT, 1, false);
// console.log('[RPG Companion] Injected contextual summary for separate mode:', contextSummary);
// Skip when a guided generation injection is present to avoid conflicting instructions
if (!shouldSuppress) {
setExtensionPrompt('rpg-companion-context', wrappedContext, extension_prompt_types.IN_CHAT, 1, false);
}
// console.log('[RPG Companion] Injected contextual summary for separate/external mode:', contextSummary);
} else {
// Clear if no data yet
setExtensionPrompt('rpg-companion-context', '', extension_prompt_types.IN_CHAT, 1, false);
}
// Inject HTML prompt separately at depth 0 if enabled (same as together mode pattern)
if (extensionSettings.enableHtmlPrompt) {
const htmlPrompt = `\nIf appropriate, include inline HTML, CSS, and JS elements for creative, visual storytelling throughout your response:
- Use them liberally to depict any in-world content that can be visualized (screens, posters, books, signs, letters, logos, crests, seals, medallions, labels, etc.), with creative license for animations, 3D effects, pop-ups, dropdowns, websites, and so on.
- Style them thematically to match the theme (e.g., sleek for sci-fi, rustic for fantasy), ensuring text is visible.
- Embed all resources directly (e.g., inline SVGs) so nothing relies on external fonts or libraries.
- Place elements naturally in the narrative where characters would see or use them, with no limits on format or application.
- These HTML/CSS/JS elements must be rendered directly without enclosing them in code fences.`;
if (extensionSettings.enableHtmlPrompt && !shouldSuppress) {
// Use custom HTML prompt if set, otherwise use default
const htmlPromptText = extensionSettings.customHtmlPrompt || DEFAULT_HTML_PROMPT;
const htmlPrompt = `\n- ${htmlPromptText}\n`;
setExtensionPrompt('rpg-companion-html', htmlPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected HTML prompt at depth 0 for separate mode');
// console.log('[RPG Companion] Injected HTML prompt at depth 0 for separate/external mode');
} else {
// Clear HTML prompt if disabled
setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Inject Dialogue Coloring prompt separately at depth 0 if enabled
if (extensionSettings.enableDialogueColoring && !shouldSuppress) {
// Use custom Dialogue Coloring prompt if set, otherwise use default
const dialogueColoringPromptText = extensionSettings.customDialogueColoringPrompt || DEFAULT_DIALOGUE_COLORING_PROMPT;
const dialogueColoringPrompt = `\n- ${dialogueColoringPromptText}\n`;
setExtensionPrompt('rpg-companion-dialogue-coloring', dialogueColoringPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected Dialogue Coloring prompt at depth 0 for separate/external mode');
} else {
// Clear Dialogue Coloring prompt if disabled
setExtensionPrompt('rpg-companion-dialogue-coloring', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Inject Deception System prompt separately at depth 0 if enabled
if (extensionSettings.enableDeceptionSystem && !shouldSuppress) {
// Use custom Deception prompt if set, otherwise use default
const deceptionPromptText = extensionSettings.customDeceptionPrompt || DEFAULT_DECEPTION_PROMPT;
const deceptionPrompt = `\n- ${deceptionPromptText}\n`;
setExtensionPrompt('rpg-companion-deception', deceptionPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected Deception System prompt at depth 0 for separate/external mode');
} else {
// Clear Deception System prompt if disabled
setExtensionPrompt('rpg-companion-deception', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Inject Spotify prompt separately at depth 0 if enabled
if (extensionSettings.enableSpotifyMusic && !shouldSuppress) {
// Use custom Spotify prompt if set, otherwise use default
const spotifyPromptText = extensionSettings.customSpotifyPrompt || DEFAULT_SPOTIFY_PROMPT;
const spotifyPrompt = `\n- ${spotifyPromptText} ${SPOTIFY_FORMAT_INSTRUCTION}\n`;
setExtensionPrompt('rpg-companion-spotify', spotifyPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected Spotify prompt at depth 0 for separate/external mode');
} else {
// Clear Spotify prompt if disabled
setExtensionPrompt('rpg-companion-spotify', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Inject CYOA prompt separately at depth 0 if enabled (injected last to appear last in prompt)
if (extensionSettings.enableCYOA && !shouldSuppress) {
// Use custom CYOA prompt if set, otherwise use default
const cyoaPromptText = extensionSettings.customCYOAPrompt || DEFAULT_CYOA_PROMPT;
const cyoaPrompt = `\n- ${cyoaPromptText}\n`;
setExtensionPrompt('rpg-companion-zzz-cyoa', cyoaPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected CYOA prompt at depth 0 for separate/external mode');
} else {
// Clear CYOA prompt if disabled
setExtensionPrompt('rpg-companion-zzz-cyoa', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Clear together mode injections
setExtensionPrompt('rpg-companion-inject', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-example', '', extension_prompt_types.IN_CHAT, 0, false);
@@ -217,5 +723,32 @@ Ensure these details naturally reflect and influence the narrative. Character be
setExtensionPrompt('rpg-companion-inject', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-example', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-context', '', extension_prompt_types.IN_CHAT, 1, false);
setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-dialogue-coloring', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-deception', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-zzz-cyoa', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-spotify', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Set suppression state for the historical context injection
currentSuppressionState = shouldSuppress;
// Prepare historical context for injection into prompts
// This builds the context map but does NOT modify original chat messages
// The persistent event listeners will inject it into all prompts until cleared
prepareHistoricalContextInjection();
}
/**
* Initialize the history injection event listeners.
* These are persistent listeners that inject context into ALL generations
* while pendingContextMap has data. Should be called once at extension init.
*/
export function initHistoryInjectionListeners() {
// Register persistent listeners for prompt injection
// These check pendingContextMap and only inject if there's data
eventSource.on(event_types.GENERATE_AFTER_COMBINE_PROMPTS, onGenerateAfterCombinePrompts);
eventSource.on(event_types.CHAT_COMPLETION_PROMPT_READY, onChatCompletionPromptReady);
console.log('[RPG Companion] History injection listeners initialized');
}
+10
View File
@@ -28,6 +28,7 @@ export function extractInventoryData(statsText) {
const result = {
version: 2,
onPerson: "None",
clothing: "None",
stored: {},
assets: "None"
};
@@ -48,6 +49,14 @@ export function extractInventoryData(statsText) {
continue;
}
// Parse "Clothing: ..." line
const clothingMatch = trimmed.match(/^Clothing:\s*(.+)$/i);
if (clothingMatch) {
result.clothing = clothingMatch[1].trim() || "None";
foundAnyInventoryData = true;
continue;
}
// Parse "Stored - [Location]: ..." lines
const storedMatch = trimmed.match(/^Stored\s*-\s*([^:]+):\s*(.+)$/i);
if (storedMatch) {
@@ -122,6 +131,7 @@ export function extractInventory(statsText) {
return {
version: 2,
onPerson: v1Data,
clothing: "None",
stored: {},
assets: "None"
};
+209
View File
@@ -0,0 +1,209 @@
/**
* JSON Prompt Builder Helpers
* Helper functions for building JSON format tracker prompts
*/
import { extensionSettings, committedTrackerData } from '../../core/state.js';
import { getContext } from '../../../../../../extensions.js';
/**
* Converts a field name to snake_case for use as JSON key
* Example: "Test Tracker" -> "test_tracker"
* @param {string} name - Field name to convert
* @returns {string} snake_case version
*/
function toSnakeCase(name) {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
/**
* Builds User Stats JSON format instruction
* @returns {string} JSON format instruction for user stats
*/
export function buildUserStatsJSONInstruction() {
const userName = getContext().name1;
const trackerConfig = extensionSettings.trackerConfig;
const userStatsConfig = trackerConfig?.userStats;
const enabledStats = userStatsConfig?.customStats?.filter(s => s && s.enabled && s.name) || [];
let instruction = '{\n';
instruction += ' "stats": [\n';
// Add stats dynamically
for (let i = 0; i < enabledStats.length; i++) {
const stat = enabledStats[i];
const comma = i < enabledStats.length - 1 ? ',' : '';
instruction += ` {"id": "${stat.id}", "name": "${stat.name}", "value": X}${comma}\n`;
}
instruction += ' ],\n';
// Status section
if (userStatsConfig?.statusSection?.enabled) {
instruction += ' "status": {\n';
if (userStatsConfig.statusSection.showMoodEmoji) {
instruction += ' "mood": "Mood Emoji",\n';
}
instruction += ' "conditions": "[Condition1, Condition2]"\n';
instruction += ' },\n';
}
// Skills section
if (userStatsConfig?.skillsSection?.enabled) {
instruction += ' "skills": [\n';
instruction += ' {"name": "Skill1"},\n';
instruction += ' {"name": "Skill2"}\n';
instruction += ' ],\n';
}
// Inventory section
if (extensionSettings.showInventory) {
instruction += ' "inventory": {\n';
instruction += ' "onPerson": [\n';
instruction += ' {"name": "Item1", "quantity": X},\n';
instruction += ' {"name": "Item2", "quantity": X}\n';
instruction += ' ],\n';
instruction += ' "clothing": [\n';
instruction += ' {"name": "Clothing1"}\n';
instruction += ' ],\n';
instruction += ' "stored": {\n';
instruction += ' "Location1": [\n';
instruction += ' {"name": "Item", "quantity": X}\n';
instruction += ' ]\n';
instruction += ' },\n';
instruction += ' "assets": [\n';
instruction += ' {"name": "Asset1", "location": "Location"}\n';
instruction += ' ]\n';
instruction += ' },\n';
}
// Quests section
instruction += ' "quests": {\n';
instruction += ' "main": {"title": "Quest title"},\n';
instruction += ' "optional": [\n';
instruction += ' {"title": "Quest1"},\n';
instruction += ' {"title": "Quest2"}\n';
instruction += ' ]\n';
instruction += ' }\n';
instruction += '}';
return instruction;
}
/**
* Builds Info Box JSON format instruction
* @returns {string} JSON format instruction for info box
*/
export function buildInfoBoxJSONInstruction() {
const infoBoxConfig = extensionSettings.trackerConfig?.infoBox;
const widgets = infoBoxConfig?.widgets || {};
let instruction = '{\n';
let hasFields = false;
if (widgets.date?.enabled) {
instruction += ' "date": {"value": "Weekday, Month, Year"}';
hasFields = true;
}
if (widgets.weather?.enabled) {
instruction += (hasFields ? ',\n' : '') + ' "weather": {"emoji": "Weather Emoji", "forecast": "Forecast"}';
hasFields = true;
}
if (widgets.temperature?.enabled) {
const unit = widgets.temperature.unit === 'F' ? 'F' : 'C';
instruction += (hasFields ? ',\n' : '') + ` "temperature": {"value": X, "unit": "${unit}"}`;
hasFields = true;
}
if (widgets.time?.enabled) {
instruction += (hasFields ? ',\n' : '') + ' "time": {"start": "TimeStart", "end": "TimeEnd"}';
hasFields = true;
}
if (widgets.location?.enabled) {
instruction += (hasFields ? ',\n' : '') + ' "location": {"value": "Location"}';
hasFields = true;
}
if (widgets.recentEvents?.enabled) {
instruction += (hasFields ? ',\n' : '') + ' "recentEvents": ["Event1", "Event2", "Event3"]';
hasFields = true;
}
instruction += '\n}';
return instruction;
}
/**
* Builds Present Characters JSON format instruction
* @returns {string} JSON format instruction for present characters
*/
export function buildCharactersJSONInstruction() {
const userName = getContext().name1;
const presentCharsConfig = extensionSettings.trackerConfig?.presentCharacters;
const enabledFields = presentCharsConfig?.customFields?.filter(f => f && f.enabled && f.name) || [];
const relationshipsEnabled = presentCharsConfig?.relationships?.enabled !== false;
const thoughtsConfig = presentCharsConfig?.thoughts;
const characterStats = presentCharsConfig?.characterStats;
const enabledCharStats = characterStats?.enabled && characterStats?.customStats?.filter(s => s && s.enabled && s.name) || [];
let instruction = '[\n';
instruction += ' {\n';
instruction += ' "name": "CharacterName",\n';
instruction += ' "emoji": "Character Emoji"';
// Details fields
if (enabledFields.length > 0) {
instruction += ',\n "details": {\n';
for (let i = 0; i < enabledFields.length; i++) {
const field = enabledFields[i];
const fieldKey = toSnakeCase(field.name);
const comma = i < enabledFields.length - 1 ? ',' : '';
instruction += ` "${fieldKey}": "${field.description}"${comma}\n`;
}
instruction += ' }';
}
// Relationship
if (relationshipsEnabled) {
const relationshipFields = presentCharsConfig?.relationshipFields || [];
const options = relationshipFields.join('/');
instruction += ',\n "relationship": {"status": "(choose one: ' + options + ')"}';
}
// Stats
if (enabledCharStats.length > 0) {
instruction += ',\n "stats": [\n';
for (let i = 0; i < enabledCharStats.length; i++) {
const stat = enabledCharStats[i];
const comma = i < enabledCharStats.length - 1 ? ',' : '';
instruction += ` {"name": "${stat.name}", "value": X}${comma}\n`;
}
instruction += ' ]';
}
// Thoughts
if (thoughtsConfig?.enabled) {
const thoughtsDescription = thoughtsConfig.description || 'Internal monologue';
instruction += `,\n "thoughts": {"content": "${thoughtsDescription}"}`;
}
instruction += '\n }\n';
instruction += ']';
return instruction;
}
/**
* Adds lock information to instruction text
* @param {string} baseInstruction - Base instruction text
* @returns {string} Instruction with lock information added
*/
export function addLockInstruction(baseInstruction) {
return baseInstruction + '\n\nIMPORTANT: If an item, stat, quest, or field has "locked": true in its object, you MUST NOT change its value. Keep it exactly as it appears in the previous trackers. Only unlocked items can be modified. The "locked" field should ONLY be included if the item is actually locked - omit it for unlocked items.';
}
+463
View File
@@ -0,0 +1,463 @@
/**
* Lock Manager
* Handles applying and removing locks for tracker items
* Locks prevent AI from modifying specific values
*/
import { extensionSettings } from '../../core/state.js';
import { repairJSON } from '../../utils/jsonRepair.js';
/**
* Apply locks to tracker data before sending to AI.
* Adds "locked": true to locked items in JSON format.
*
* @param {string} trackerData - JSON string of tracker data
* @param {string} trackerType - Type of tracker ('userStats', 'infoBox', 'characters')
* @returns {string} Tracker data with locks applied
*/
export function applyLocks(trackerData, trackerType) {
if (!trackerData) return trackerData;
// Try to parse as JSON
const parsed = repairJSON(trackerData);
if (!parsed) {
// Not JSON format, return as-is (text format doesn't support locks)
return trackerData;
}
// Get locked items for this tracker type
const lockedItems = extensionSettings.lockedItems?.[trackerType] || {};
// Apply locks based on tracker type
switch (trackerType) {
case 'userStats':
return applyUserStatsLocks(parsed, lockedItems);
case 'infoBox':
return applyInfoBoxLocks(parsed, lockedItems);
case 'characters':
return applyCharactersLocks(parsed, lockedItems);
default:
return trackerData;
}
}
/**
* Apply locks to User Stats tracker
* @param {Object} data - Parsed user stats data
* @param {Object} lockedItems - Locked items configuration
* @returns {string} JSON string with locks applied
*/
function applyUserStatsLocks(data, lockedItems) {
// Lock individual stats within stats object
if (data.stats && lockedItems.stats) {
// Handle both section lock and individual stat locks
const isStatsLocked = lockedItems.stats === true;
if (isStatsLocked) {
// Lock entire stats section
for (const statName in data.stats) {
data.stats[statName] = {
value: data.stats[statName].value || data.stats[statName],
locked: true
};
}
} else {
// Lock individual stats
for (const statName in lockedItems.stats) {
if (lockedItems.stats[statName] && data.stats[statName] !== undefined) {
data.stats[statName] = {
value: data.stats[statName].value || data.stats[statName],
locked: true
};
}
}
}
}
// Lock status field
if (data.status && lockedItems.status) {
data.status = {
...data.status,
locked: true
};
}
// Lock individual skills
if (data.skills && lockedItems.skills) {
if (Array.isArray(data.skills)) {
data.skills = data.skills.map(skill => {
if (typeof skill === 'string') {
if (lockedItems.skills[skill]) {
return { name: skill, locked: true };
}
return skill;
} else if (skill.name && lockedItems.skills[skill.name]) {
return { ...skill, locked: true };
}
return skill;
});
}
}
// Lock inventory items - handle bracket notation paths like "inventory.onPerson[0]"
if (data.inventory && lockedItems.inventory) {
// Helper function to parse bracket notation and apply lock
const applyInventoryLocks = (items, category) => {
if (!Array.isArray(items)) return items;
return items.map((item, index) => {
// Check if this specific item is locked using bracket notation with inventory prefix
const bracketPath = `${category}[${index}]`;
if (lockedItems.inventory[bracketPath]) {
return typeof item === 'string'
? { item, locked: true }
: { ...item, locked: true };
}
return item;
});
};
// Apply locks to onPerson items
if (data.inventory.onPerson) {
data.inventory.onPerson = applyInventoryLocks(data.inventory.onPerson, 'onPerson');
}
// Apply locks to clothing items
if (data.inventory.clothing) {
data.inventory.clothing = applyInventoryLocks(data.inventory.clothing, 'clothing');
}
// Apply locks to assets
if (data.inventory.assets) {
data.inventory.assets = applyInventoryLocks(data.inventory.assets, 'assets');
}
// Apply locks to stored items (nested structure with inventory.stored.location[index])
if (data.inventory.stored && lockedItems.inventory.stored) {
for (const location in data.inventory.stored) {
if (Array.isArray(data.inventory.stored[location])) {
data.inventory.stored[location] = data.inventory.stored[location].map((item, index) => {
const bracketPath = `${location}[${index}]`;
if (lockedItems.inventory.stored[bracketPath]) {
return typeof item === 'string'
? { item, locked: true }
: { ...item, locked: true };
}
return item;
});
}
}
}
}
// Lock individual quests - handle paths like "quests.main" and "quests.optional[0]"
if (data.quests && lockedItems.quests) {
// Check if main quest is locked (entire section)
if (data.quests.main && lockedItems.quests.main === true) {
data.quests.main = { value: data.quests.main, locked: true };
}
// Check individual optional quests
if (data.quests.optional && Array.isArray(data.quests.optional)) {
data.quests.optional = data.quests.optional.map((quest, index) => {
const bracketPath = `optional[${index}]`;
if (lockedItems.quests[bracketPath]) {
return typeof quest === 'string'
? { title: quest, locked: true }
: { ...quest, locked: true };
}
return quest;
});
}
}
return JSON.stringify(data, null, 2);
}
/**
* Apply locks to Info Box tracker
* @param {Object} data - Parsed info box data
* @param {Object} lockedItems - Locked items configuration
* @returns {string} JSON string with locks applied
*/
function applyInfoBoxLocks(data, lockedItems) {
if (lockedItems.date && data.date) {
data.date = { ...data.date, locked: true };
}
if (lockedItems.weather && data.weather) {
data.weather = { ...data.weather, locked: true };
}
if (lockedItems.temperature && data.temperature) {
data.temperature = { ...data.temperature, locked: true };
}
if (lockedItems.time && data.time) {
data.time = { ...data.time, locked: true };
}
if (lockedItems.location && data.location) {
data.location = { ...data.location, locked: true };
}
if (lockedItems.recentEvents && data.recentEvents) {
data.recentEvents = { ...data.recentEvents, locked: true };
}
return JSON.stringify(data, null, 2);
}
/**
* Apply locks to Characters tracker
* @param {Object} data - Parsed characters data
* @param {Object} lockedItems - Locked items configuration
* @returns {string} JSON string with locks applied
*/
function applyCharactersLocks(data, lockedItems) {
// console.log('[Lock Manager] applyCharactersLocks called');
// console.log('[Lock Manager] Locked items:', JSON.stringify(lockedItems, null, 2));
// console.log('[Lock Manager] Input data:', JSON.stringify(data, null, 2));
// Handle both array format and object format
let characters = Array.isArray(data) ? data : (data.characters || []);
characters = characters.map((char, index) => {
const charName = char.name || char.characterName;
// Check if entire character is locked (index-based)
if (lockedItems[index] === true) {
// console.log('[Lock Manager] Locking entire character by index:', index);
return { ...char, locked: true };
}
// Check if character name exists in locked items (could be nested object for field locks or boolean for full lock)
const charLocks = lockedItems[charName];
if (charLocks === true) {
// Entire character is locked
// console.log('[Lock Manager] Locking entire character:', charName);
return { ...char, locked: true };
} else if (charLocks && typeof charLocks === 'object') {
// Character has field-level locks
const modifiedChar = { ...char };
for (const fieldName in charLocks) {
if (charLocks[fieldName] === true) {
// Check both the original field name and snake_case version
// (AI returns snake_case, but locks are stored with original configured names)
// Use the same conversion as toSnakeCase in thoughts.js
const snakeCaseFieldName = fieldName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
let locked = false;
// Check at root level first (backward compatibility)
if (modifiedChar[fieldName] !== undefined) {
// console.log('[Lock Manager] Applying lock to field:', `${charName}.${fieldName}`);
modifiedChar[fieldName] = {
value: modifiedChar[fieldName],
locked: true
};
locked = true;
} else if (modifiedChar[snakeCaseFieldName] !== undefined) {
// console.log('[Lock Manager] Applying lock to snake_case field:', `${charName}.${snakeCaseFieldName} (from ${fieldName})`);
modifiedChar[snakeCaseFieldName] = {
value: modifiedChar[snakeCaseFieldName],
locked: true
};
locked = true;
}
// Check in nested objects (details, relationship, thoughts)
if (!locked && modifiedChar.details) {
if (modifiedChar.details[fieldName] !== undefined) {
// console.log('[Lock Manager] Applying lock to details field:', `${charName}.details.${fieldName}`);
if (!modifiedChar.details || typeof modifiedChar.details !== 'object') {
modifiedChar.details = {};
} else {
modifiedChar.details = { ...modifiedChar.details };
}
modifiedChar.details[fieldName] = {
value: modifiedChar.details[fieldName],
locked: true
};
locked = true;
} else if (modifiedChar.details[snakeCaseFieldName] !== undefined) {
// console.log('[Lock Manager] Applying lock to details snake_case field:', `${charName}.details.${snakeCaseFieldName} (from ${fieldName})`);
if (!modifiedChar.details || typeof modifiedChar.details !== 'object') {
modifiedChar.details = {};
} else {
modifiedChar.details = { ...modifiedChar.details };
}
modifiedChar.details[snakeCaseFieldName] = {
value: modifiedChar.details[snakeCaseFieldName],
locked: true
};
locked = true;
}
}
// Check in relationship object
if (!locked && modifiedChar.relationship) {
if (modifiedChar.relationship[fieldName] !== undefined) {
// console.log('[Lock Manager] Applying lock to relationship field:', `${charName}.relationship.${fieldName}`);
modifiedChar.relationship = { ...modifiedChar.relationship };
modifiedChar.relationship[fieldName] = {
value: modifiedChar.relationship[fieldName],
locked: true
};
locked = true;
} else if (modifiedChar.relationship[snakeCaseFieldName] !== undefined) {
// console.log('[Lock Manager] Applying lock to relationship snake_case field:', `${charName}.relationship.${snakeCaseFieldName} (from ${fieldName})`);
modifiedChar.relationship = { ...modifiedChar.relationship };
modifiedChar.relationship[snakeCaseFieldName] = {
value: modifiedChar.relationship[snakeCaseFieldName],
locked: true
};
locked = true;
}
}
// Check in thoughts object
if (!locked && modifiedChar.thoughts) {
if (modifiedChar.thoughts[fieldName] !== undefined) {
// console.log('[Lock Manager] Applying lock to thoughts field:', `${charName}.thoughts.${fieldName}`);
modifiedChar.thoughts = { ...modifiedChar.thoughts };
modifiedChar.thoughts[fieldName] = {
value: modifiedChar.thoughts[fieldName],
locked: true
};
locked = true;
} else if (modifiedChar.thoughts[snakeCaseFieldName] !== undefined) {
// console.log('[Lock Manager] Applying lock to thoughts snake_case field:', `${charName}.thoughts.${snakeCaseFieldName} (from ${fieldName})`);
modifiedChar.thoughts = { ...modifiedChar.thoughts };
modifiedChar.thoughts[snakeCaseFieldName] = {
value: modifiedChar.thoughts[snakeCaseFieldName],
locked: true
};
locked = true;
}
}
}
}
return modifiedChar;
}
// No locks for this character
return char;
});
const result = Array.isArray(data)
? JSON.stringify(characters, null, 2)
: JSON.stringify({ ...data, characters }, null, 2);
// console.log('[Lock Manager] Output data:', result);
return result;
}
/**
* Remove locks from tracker data received from AI.
* Strips "locked": true from all items to clean up the data.
*
* @param {string} trackerData - JSON string of tracker data
* @returns {string} Tracker data with locks removed
*/
export function removeLocks(trackerData) {
if (!trackerData) return trackerData;
// Try to parse as JSON
const parsed = repairJSON(trackerData);
if (!parsed) {
// Not JSON format, return as-is
return trackerData;
}
// Recursively remove all "locked" properties
const cleaned = removeLockedProperties(parsed);
return JSON.stringify(cleaned, null, 2);
}
/**
* Recursively remove "locked" properties from an object
* @param {*} obj - Object to clean
* @returns {*} Object with locked properties removed
*/
function removeLockedProperties(obj) {
if (Array.isArray(obj)) {
return obj.map(item => removeLockedProperties(item));
} else if (obj !== null && typeof obj === 'object') {
const cleaned = {};
for (const key in obj) {
if (key !== 'locked') {
cleaned[key] = removeLockedProperties(obj[key]);
}
}
return cleaned;
}
return obj;
}
/**
* Check if a specific item is locked
* @param {string} trackerType - Type of tracker
* @param {string} itemPath - Path to the item (e.g., 'stats.Health', 'quests.main.0')
* @returns {boolean} Whether the item is locked
*/
export function isItemLocked(trackerType, itemPath) {
const lockedItems = extensionSettings.lockedItems?.[trackerType];
if (!lockedItems) return false;
const parts = itemPath.split('.');
let current = lockedItems;
for (const part of parts) {
if (current[part] === undefined) return false;
current = current[part];
}
return !!current;
}
/**
* Toggle lock state for a specific item
* @param {string} trackerType - Type of tracker
* @param {string} itemPath - Path to the item
* @param {boolean} locked - New lock state
*/
export function setItemLock(trackerType, itemPath, locked) {
// console.log('[Lock Manager] setItemLock called:', { trackerType, itemPath, locked });
if (!extensionSettings.lockedItems) {
extensionSettings.lockedItems = {};
}
if (!extensionSettings.lockedItems[trackerType]) {
extensionSettings.lockedItems[trackerType] = {};
}
const parts = itemPath.split('.');
let current = extensionSettings.lockedItems[trackerType];
// Navigate to parent of target
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!current[part]) {
current[part] = {};
}
current = current[part];
}
// Set or remove lock
const finalKey = parts[parts.length - 1];
if (locked) {
current[finalKey] = true;
} else {
delete current[finalKey];
}
// console.log('[Lock Manager] Locked items after set:', JSON.stringify(extensionSettings.lockedItems, null, 2));
}
+364 -2
View File
@@ -1,11 +1,13 @@
/**
* Parser Module
* Handles parsing of AI responses to extract tracker data
* Supports both legacy text format and new v3 JSON format
*/
import { extensionSettings, FEATURE_FLAGS, addDebugLog } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { extractInventory } from './inventoryParser.js';
import { repairJSON } from '../../utils/jsonRepair.js';
/**
* Helper to separate emoji from text in a string
@@ -127,7 +129,7 @@ function stripBrackets(text) {
* Helper to log to both console and debug logs array
*/
function debugLog(message, data = null) {
console.log(message, data || '');
// console.log(message, data || '');
if (extensionSettings.debugMode) {
addDebugLog(message, data);
}
@@ -159,6 +161,248 @@ export function parseResponse(responseText) {
cleanedResponse = cleanedResponse.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '');
debugLog('[RPG Parser] Removed thinking tags, new length:', cleanedResponse.length + ' chars');
// Remove "FORMAT:" markers that the model might accidentally output
cleanedResponse = cleanedResponse.replace(/FORMAT:\s*/gi, '');
debugLog('[RPG Parser] Removed FORMAT: markers, new length:', cleanedResponse.length + ' chars');
// First, try to extract raw JSON objects (v3 format)
// Note: Prompts now instruct models to use ```json``` code blocks, but we extract
// from any JSON found using brace-matching for maximum compatibility
// Use brace-matching to find complete JSON objects
const extractedObjects = [];
let i = 0;
while (i < cleanedResponse.length) {
if (cleanedResponse[i] === '{') {
// Found opening brace, find matching closing brace
let depth = 1;
let j = i + 1;
let inString = false;
let escapeNext = false;
while (j < cleanedResponse.length && depth > 0) {
const char = cleanedResponse[j];
if (escapeNext) {
escapeNext = false;
} else if (char === '\\') {
escapeNext = true;
} else if (char === '"') {
inString = !inString;
} else if (!inString) {
if (char === '{') depth++;
else if (char === '}') depth--;
}
j++;
}
if (depth === 0) {
// Found complete JSON object
const jsonContent = cleanedResponse.substring(i, j).trim();
extractedObjects.push(jsonContent);
i = j;
} else {
i++;
}
} else {
i++;
}
}
if (extractedObjects.length > 0) {
// console.log(`[RPG Parser] ✓ Found ${extractedObjects.length} raw JSON objects (v3 format)`);
debugLog(`[RPG Parser] ✓ Found ${extractedObjects.length} raw JSON objects (v3 format)`);
// First, try to parse as unified JSON structure (new v3.1 format)
if (extractedObjects.length === 1) {
const parsed = repairJSON(extractedObjects[0]);
if (parsed && (parsed.userStats || parsed.infoBox || parsed.characters)) {
// console.log('[RPG Parser] ✓ Detected unified JSON structure (v3.1 format)');
if (parsed.userStats) {
result.userStats = JSON.stringify(parsed.userStats);
// console.log('[RPG Parser] ✓ Extracted userStats from unified structure');
}
if (parsed.infoBox) {
result.infoBox = JSON.stringify(parsed.infoBox);
// console.log('[RPG Parser] ✓ Extracted infoBox from unified structure');
}
if (parsed.characters) {
result.characterThoughts = JSON.stringify(parsed.characters);
// console.log('[RPG Parser] ✓ Extracted characters from unified structure');
}
if (result.userStats || result.infoBox || result.characterThoughts) {
// console.log('[RPG Parser] ✓ Returning unified JSON parse results');
debugLog('[RPG Parser] Returning unified JSON parse results');
return result;
}
}
}
// Fall back to parsing multiple separate JSON objects (legacy v3.0 format)
for (let idx = 0; idx < extractedObjects.length; idx++) {
const jsonContent = extractedObjects[idx];
// console.log(`[RPG Parser] Parsing object ${idx + 1}:`, jsonContent.substring(0, 100) + '...');
// console.log(`[RPG Parser] Full object ${idx + 1} length:`, jsonContent.length);
const parsed = repairJSON(jsonContent);
if (parsed) {
// console.log(`[RPG Parser] Object ${idx + 1} parsed successfully, keys:`, Object.keys(parsed));
// Check if object is wrapped (e.g., {"userStats": {...}})
// Unwrap single-key objects that match our tracker types
let unwrapped = parsed;
if (Object.keys(parsed).length === 1) {
const key = Object.keys(parsed)[0];
if (key === 'userStats' || key === 'infoBox' || key === 'characters') {
unwrapped = parsed[key];
// console.log(`[RPG Parser] ✓ Unwrapped ${key} object`);
}
}
// Detect tracker type by checking for top-level fields
if (unwrapped.stats || unwrapped.status || unwrapped.skills || unwrapped.inventory || unwrapped.quests) {
result.userStats = jsonContent;
// console.log('[RPG Parser] ✓ Assigned to User Stats');
debugLog('[RPG Parser] ✓ Extracted raw JSON User Stats');
} else if (unwrapped.date || unwrapped.location || unwrapped.weather || unwrapped.temperature || unwrapped.time) {
result.infoBox = jsonContent;
// console.log('[RPG Parser] ✓ Assigned to Info Box');
debugLog('[RPG Parser] ✓ Extracted raw JSON Info Box');
} else if (unwrapped.characters || Array.isArray(unwrapped)) {
result.characterThoughts = jsonContent;
// console.log('[RPG Parser] ✓ Assigned to Characters');
debugLog('[RPG Parser] ✓ Extracted raw JSON Characters');
} else {
console.warn('[RPG Parser] ⚠️ Could not categorize object with keys:', Object.keys(parsed));
}
} else {
console.error('[RPG Parser] ✗ Failed to parse raw JSON object', idx + 1);
}
}
if (result.userStats || result.infoBox || result.characterThoughts) {
// console.log('[RPG Parser] ✓ Returning raw JSON parse results:', {
// hasUserStats: !!result.userStats,
// hasInfoBox: !!result.infoBox,
// hasCharacters: !!result.characterThoughts
// });
debugLog('[RPG Parser] Returning raw JSON parse results');
return result;
} else {
console.warn('[RPG Parser] ⚠️ No tracker data extracted from', extractedObjects.length, 'objects');
}
}
// Check for JSON code blocks (legacy v3 format with ```json fences)
// Look for ```json code blocks which indicate JSON format
const jsonBlockRegex = /```json\s*\n([\s\S]*?)```/g;
const jsonMatches = [...cleanedResponse.matchAll(jsonBlockRegex)];
if (jsonMatches.length > 0) {
// console.log('[RPG Parser] ✓ Found', jsonMatches.length, 'JSON code blocks (v3 format with fences)');
debugLog('[RPG Parser] ✓ Found JSON code blocks (v3 format), parsing as JSON');
for (let idx = 0; idx < jsonMatches.length; idx++) {
const match = jsonMatches[idx];
const jsonContent = match[1].trim();
// console.log(`[RPG Parser] Parsing JSON block ${idx + 1}:`, jsonContent.substring(0, 100) + '...');
const parsed = repairJSON(jsonContent);
if (parsed) {
// console.log(`[RPG Parser] JSON block ${idx + 1} parsed successfully, keys:`, Object.keys(parsed));
// Detect tracker type by checking for top-level fields
if (parsed.stats || parsed.status || parsed.skills || parsed.inventory || parsed.quests) {
result.userStats = jsonContent;
// console.log('[RPG Parser] ✓ Assigned to User Stats');
debugLog('[RPG Parser] ✓ Extracted JSON User Stats');
} else if (parsed.date || parsed.location || parsed.weather || parsed.temperature || parsed.time) {
result.infoBox = jsonContent;
// console.log('[RPG Parser] ✓ Assigned to Info Box');
debugLog('[RPG Parser] ✓ Extracted JSON Info Box');
} else if (parsed.characters || Array.isArray(parsed)) {
result.characterThoughts = jsonContent;
// console.log('[RPG Parser] ✓ Assigned to Characters');
debugLog('[RPG Parser] ✓ Extracted JSON Characters');
} else {
console.warn('[RPG Parser] ⚠️ Could not categorize JSON block with keys:', Object.keys(parsed));
}
} else {
console.error('[RPG Parser] ✗ Failed to parse JSON code block', idx + 1);
debugLog('[RPG Parser] ✗ Failed to parse JSON block, will try text fallback');
}
}
// If we found at least one valid JSON block, return the result
// Mixed formats (some JSON, some text) will still work
if (result.userStats || result.infoBox || result.characterThoughts) {
// console.log('[RPG Parser] ✓ Returning JSON code block parse results:', {
// hasUserStats: !!result.userStats,
// hasInfoBox: !!result.infoBox,
// hasCharacters: !!result.characterThoughts
// });
debugLog('[RPG Parser] Returning JSON parse results');
return result;
} else {
console.warn('[RPG Parser] ⚠️ No tracker data extracted from', jsonMatches.length, 'JSON blocks');
}
}
// Check if response uses XML <trackers> tags (hybrid format)
const xmlMatch = cleanedResponse.match(/<trackers>([\s\S]*?)<\/trackers>/i);
if (xmlMatch) {
debugLog('[RPG Parser] ✓ Found XML <trackers> tags, using XML parser');
const trackersContent = xmlMatch[1].trim();
// Try to parse JSON blocks within XML first
const xmlJsonMatches = [...trackersContent.matchAll(jsonBlockRegex)];
if (xmlJsonMatches.length > 0) {
debugLog('[RPG Parser] Found JSON blocks within XML tags');
for (const match of xmlJsonMatches) {
const jsonContent = match[1].trim();
const parsed = repairJSON(jsonContent);
if (parsed) {
if (parsed.type === 'userStats' || parsed.stats) {
result.userStats = jsonContent;
} else if (parsed.type === 'infoBox' || parsed.date || parsed.location) {
result.infoBox = jsonContent;
} else if (parsed.type === 'characters' || parsed.characters || Array.isArray(parsed)) {
result.characterThoughts = jsonContent;
}
}
}
} else {
// Fallback to text extraction from XML content (legacy v2 text format)
const statsMatch = trackersContent.match(/(User )?Stats\s*\n\s*---[\s\S]*?(?=\n\s*\n\s*(Info Box|Present Characters)|$)/i);
if (statsMatch) {
result.userStats = stripBrackets(statsMatch[0].trim());
debugLog('[RPG Parser] ✓ Extracted Stats from XML (text format)');
}
const infoBoxMatch = trackersContent.match(/Info Box\s*\n\s*---[\s\S]*?(?=\n\s*\n\s*Present Characters|$)/i);
if (infoBoxMatch) {
result.infoBox = stripBrackets(infoBoxMatch[0].trim());
debugLog('[RPG Parser] ✓ Extracted Info Box from XML (text format)');
}
const charactersMatch = trackersContent.match(/Present Characters\s*\n\s*---[\s\S]*$/i);
if (charactersMatch) {
result.characterThoughts = stripBrackets(charactersMatch[0].trim());
debugLog('[RPG Parser] ✓ Extracted Present Characters from XML (text format)');
}
}
debugLog('[RPG Parser] Parsed from XML:', result);
return result;
}
// Fallback to markdown code block parsing (old text format or mixed format)
debugLog('[RPG Parser] No XML tags found, using code block parser');
// Extract code blocks
const codeBlockRegex = /```([^`]+)```/g;
const matches = [...cleanedResponse.matchAll(codeBlockRegex)];
@@ -256,8 +500,14 @@ export function parseResponse(responseText) {
debugLog('[RPG Parser] Found Characters:', !!result.characterThoughts);
debugLog('[RPG Parser] =======================================================');
// Check if we found at least one section - if not, mark as parsing failure
if (!result.userStats && !result.infoBox && !result.characterThoughts) {
result.parsingFailed = true;
console.error('[RPG Parser] ❌ No tracker data found in response - parsing failed');
}
return result;
}
} // End parseResponse
/**
* Parses user stats from the text and updates the extensionSettings.
@@ -271,6 +521,118 @@ export function parseUserStats(statsText) {
debugLog('[RPG Parser] Stats text preview:', statsText.substring(0, 200));
try {
// Check if this is v3 JSON format - try to parse it first
let statsData = null;
const trimmed = statsText.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
statsData = repairJSON(statsText);
if (statsData) {
debugLog('[RPG Parser] ✓ Parsed as v3 JSON format');
// Extract stats from v3 JSON structure
if (statsData.stats && Array.isArray(statsData.stats)) {
// console.log('[RPG Parser] ✓ Extracting stats array, count:', statsData.stats.length);
statsData.stats.forEach(stat => {
if (stat.id && typeof stat.value !== 'undefined') {
extensionSettings.userStats[stat.id] = stat.value;
// console.log(`[RPG Parser] ✓ Set ${stat.id} = ${stat.value}`);
}
});
}
// Extract status
if (statsData.status) {
// console.log('[RPG Parser] ✓ Extracting status:', statsData.status);
if (statsData.status.mood) {
extensionSettings.userStats.mood = statsData.status.mood;
// console.log('[RPG Parser] ✓ Set mood =', statsData.status.mood);
}
if (statsData.status.conditions) {
extensionSettings.userStats.conditions = statsData.status.conditions;
// console.log('[RPG Parser] ✓ Set conditions =', statsData.status.conditions);
}
}
// Extract inventory (convert v3 array format to v2 string format)
if (statsData.inventory) {
const inv = statsData.inventory;
// Convert arrays of {name, quantity} objects to comma-separated strings
const convertItems = (items) => {
if (!items || !Array.isArray(items)) return '';
return items.map(item => {
if (typeof item === 'object' && item.name) {
// Include quantity if > 1
return item.quantity && item.quantity > 1
? `${item.quantity}x ${item.name}`
: item.name;
}
return String(item);
}).join(', ');
};
// Convert stored object {location: [items]} to {location: "item1, item2"}
const convertStoredInventory = (stored) => {
if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return {};
const result = {};
for (const [location, items] of Object.entries(stored)) {
if (Array.isArray(items)) {
result[location] = convertItems(items);
} else if (typeof items === 'string') {
result[location] = items;
} else {
result[location] = '';
}
}
return result;
};
extensionSettings.userStats.inventory = {
onPerson: convertItems(inv.onPerson),
clothing: convertItems(inv.clothing),
stored: convertStoredInventory(inv.stored),
assets: convertItems(inv.assets)
};
// console.log('[RPG Parser] ✓ Converted v3 inventory:', extensionSettings.userStats.inventory);
}
// Extract quests (convert v3 object format to v2 string format)
if (statsData.quests) {
// Convert quest objects to strings
const convertQuest = (quest) => {
if (!quest) return '';
if (typeof quest === 'string') return quest;
if (typeof quest === 'object') {
// v3 format: {title, description, status}
return quest.title || quest.description || JSON.stringify(quest);
}
return String(quest);
};
extensionSettings.quests = {
main: convertQuest(statsData.quests.main),
optional: Array.isArray(statsData.quests.optional)
? statsData.quests.optional.map(convertQuest)
: []
};
// console.log('[RPG Parser] ✓ Converted v3 quests:', extensionSettings.quests);
}
// Extract skills if present (store as object, not JSON string)
if (statsData.skills && Array.isArray(statsData.skills)) {
extensionSettings.userStats.skills = statsData.skills;
// console.log('[RPG Parser] ✓ Set skills:', extensionSettings.userStats.skills);
}
debugLog('[RPG Parser] ✓ Successfully extracted v3 JSON data');
saveSettings();
return; // Done processing v3 format
}
}
// Fall back to v2 text format parsing if JSON parsing failed
debugLog('[RPG Parser] Falling back to v2 text format parsing');
// Get custom stat configuration
const trackerConfig = extensionSettings.trackerConfig;
const customStats = trackerConfig?.userStats?.customStats || [];
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
/**
* Suppression helper for guided generation injection behavior.
*
* This module exports a pure function `evaluateSuppression` that computes
* whether RPG Companion should suppress tracker and HTML injections for a
* given generation request, based on runtime settings, extended context, and
* generation data (quiet prompt flags, etc.).
*/
/**
* Determine if suppression should be applied for this generation.
*
* @param {any} extensionSettings - extension settings object (may contain skipInjectionsForGuided)
* @param {any} context - SillyTavern context object (used to find chatMetadata.script_injects.instruct)
* @param {any} data - Generation data (contains quiet_prompt/quietPrompt flags)
* @returns {Object} - An object describing the suppression decision.
*/
export function evaluateSuppression(extensionSettings, context, data) {
// Detect presence of any injected `instruct` script
const instructObj = context?.chatMetadata?.script_injects?.instruct;
const isGuidedGeneration = !!instructObj;
const quietPromptRaw = data?.quiet_prompt || data?.quietPrompt || '';
const hasQuietPrompt = !!quietPromptRaw;
// Normalize the injected instruction body (it may be an object with a 'value' field or a raw string)
let instructContent = '';
if (instructObj) {
if (typeof instructObj === 'object') {
instructContent = String(instructObj.value || instructObj || '');
} else {
instructContent = String(instructObj);
}
}
const IMPERSONATION_PATTERNS = [
{ id: 'first-perspective', re: /write in first person perspective from/i },
{ id: 'second-perspective', re: /write in second person perspective from/i },
{ id: 'third-perspective', re: /write in third person perspective from/i },
{ id: 'you-yours', re: /using you\/yours for/i },
{ id: 'third-person-pronouns', re: /third-person pronouns for/i },
{ id: 'impersonate-word', re: /\bimpersonat(e|ion)?\b/i },
{ id: 'assume-role', re: /assume the role of/i },
{ id: 'play-role', re: /play the role of/i },
{ id: 'impersonate-command', re: /\/impersonate await=true/i },
{ id: 'generic-first', re: /\bfirst person\b/i },
{ id: 'generic-second', re: /\bsecond person\b/i },
{ id: 'generic-third', re: /\bthird person\b/i }
];
// Include quietPrompt raw text in detection; guided impersonation flows may pass it directly here
const combinedTextForDetection = [instructContent, quietPromptRaw].filter(Boolean).join('\n');
let matchedPattern = '';
let isImpersonationGeneration = false;
if (combinedTextForDetection.length) {
for (const pat of IMPERSONATION_PATTERNS) {
if (pat.re.test(combinedTextForDetection)) {
matchedPattern = pat.id;
isImpersonationGeneration = true;
break;
}
}
}
const skipMode = (extensionSettings && extensionSettings.skipInjectionsForGuided) || 'none';
// Compute suppression according to mode
const shouldSuppress = skipMode === 'guided'
? (isGuidedGeneration || hasQuietPrompt)
: (skipMode === 'impersonation' ? isImpersonationGeneration : false);
return {
shouldSuppress,
skipMode,
isGuidedGeneration,
isImpersonationGeneration,
hasQuietPrompt,
instructContent,
quietPromptRaw,
matchedPattern
};
}
+199 -97
View File
@@ -4,7 +4,7 @@
*/
import { getContext } from '../../../../../../extensions.js';
import { chat, user_avatar, setExtensionPrompt, extension_prompt_types } from '../../../../../../../script.js';
import { chat, user_avatar, setExtensionPrompt, extension_prompt_types, saveChatDebounced } from '../../../../../../../script.js';
// Core modules
import {
@@ -13,17 +13,24 @@ import {
committedTrackerData,
lastActionWasSwipe,
isPlotProgression,
isAwaitingNewMessage,
setLastActionWasSwipe,
setIsPlotProgression,
setIsGenerating,
setIsAwaitingNewMessage,
updateLastGeneratedData,
updateCommittedTrackerData,
FALLBACK_AVATAR_DATA_URI
$musicPlayerContainer
} from '../../core/state.js';
import { saveChatData, loadChatData } from '../../core/persistence.js';
import { saveChatData, loadChatData, autoSwitchPresetForEntity } from '../../core/persistence.js';
import { i18n } from '../../core/i18n.js';
// Generation & Parsing
import { parseResponse, parseUserStats } from '../generation/parser.js';
import { parseAndStoreSpotifyUrl, convertToEmbedUrl } from '../features/musicPlayer.js';
import { updateRPGData } from '../generation/apiClient.js';
import { removeLocks } from '../generation/lockManager.js';
import { onGenerationStarted, initHistoryInjectionListeners } from '../generation/injector.js';
// Rendering
import { renderUserStats } from '../rendering/userStats.js';
@@ -31,13 +38,18 @@ import { renderInfoBox } from '../rendering/infoBox.js';
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';
import { renderMusicPlayer } from '../rendering/musicPlayer.js';
// Utils
import { getSafeThumbnailUrl } from '../../utils/avatars.js';
// UI
import { setFabLoadingState, updateFabWidgets } from '../ui/mobile.js';
// Chapter checkpoint
import { updateAllCheckpointIndicators } from '../ui/checkpointUI.js';
import { restoreCheckpointOnLoad } from '../features/chapterCheckpoint.js';
/**
* Commits the tracker data from the last assistant message to be used as source for next generation.
* This should be called when the user has replied to a message, ensuring all swipes of the next
@@ -77,52 +89,99 @@ export function commitTrackerData() {
/**
* Event handler for when the user sends a message.
* Sets the flag to indicate this is NOT a swipe.
* In together mode, commits displayed data (only for real messages, not streaming placeholders).
*/
export function onMessageSent() {
if (!extensionSettings.enabled) return;
// User sent a new message - NOT a swipe
setLastActionWasSwipe(false);
// console.log('[RPG Companion] 🟢 EVENT: onMessageSent - lastActionWasSwipe =', lastActionWasSwipe);
// Check if this is a streaming placeholder message (content = "...")
// When streaming is on, ST sends a "..." placeholder before generation starts
const context = getContext();
const chat = context.chat;
const lastMessage = chat && chat.length > 0 ? chat[chat.length - 1] : null;
if (lastMessage && lastMessage.mes === '...') {
// console.log('[RPG Companion] 🟢 Ignoring onMessageSent: streaming placeholder message');
return;
}
// console.log('[RPG Companion] 🟢 EVENT: onMessageSent (after placeholder check)');
// console.log('[RPG Companion] 🟢 NOTE: lastActionWasSwipe will be reset in onMessageReceived after generation completes');
// Set flag to indicate we're expecting a new message from generation
// This allows auto-update to distinguish between new generations and loading chat history
setIsAwaitingNewMessage(true);
// Show FAB loading state for together mode (starts spinning)
if (extensionSettings.generationMode === 'together') {
setFabLoadingState(true);
}
// For separate mode with auto-update disabled, commit displayed tracker
if (extensionSettings.generationMode === 'separate' && !extensionSettings.autoUpdate) {
if (lastGeneratedData.userStats || lastGeneratedData.infoBox || lastGeneratedData.characterThoughts) {
committedTrackerData.userStats = lastGeneratedData.userStats;
committedTrackerData.infoBox = lastGeneratedData.infoBox;
committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts;
// console.log('[RPG Companion] 💾 SEPARATE MODE: Committed displayed tracker (auto-update disabled)');
}
}
}
/**
* Event handler for when a message is generated.
*/
export async function onMessageReceived(data) {
// console.log('[RPG Companion] onMessageReceived called, lastActionWasSwipe:', lastActionWasSwipe);
if (!extensionSettings.enabled) {
return;
}
// Reset swipe flag after generation completes
// This ensures next user message (whether from original or swipe) triggers commit
setLastActionWasSwipe(false);
// console.log('[RPG Companion] 🟢 Reset lastActionWasSwipe = false (generation completed)');
if (extensionSettings.generationMode === 'together') {
// In together mode, parse the response to extract RPG data
// The message should be in chat[chat.length - 1]
// Commit happens in onMessageSent (when user sends message, before generation)
const lastMessage = chat[chat.length - 1];
if (lastMessage && !lastMessage.is_user) {
const responseText = lastMessage.mes;
// 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
// });
// Update stored data (both lastGeneratedData for old UI and extensionSettings for dashboard widgets)
// Note: Don't show parsing error here - this event fires when loading chat history too
// Error notification is handled in apiClient.js for fresh generations only
// Remove locks from parsed data (JSON format only, text format is unaffected)
if (parsedData.userStats) {
parsedData.userStats = removeLocks(parsedData.userStats);
}
if (parsedData.infoBox) {
parsedData.infoBox = removeLocks(parsedData.infoBox);
}
if (parsedData.characterThoughts) {
parsedData.characterThoughts = removeLocks(parsedData.characterThoughts);
}
// Parse and store Spotify URL if feature is enabled
parseAndStoreSpotifyUrl(responseText);
// Update display data with newly parsed response
// console.log('[RPG Companion] 📝 TOGETHER MODE: Updating lastGeneratedData with parsed response');
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
@@ -142,19 +201,12 @@ export async function onMessageReceived(data) {
// console.log('[RPG Companion] Stored RPG data for swipe', currentSwipeId);
// If there's no committed data yet (first time generating), automatically commit
if (!committedTrackerData.userStats && !committedTrackerData.infoBox && !committedTrackerData.characterThoughts) {
committedTrackerData.userStats = parsedData.userStats;
committedTrackerData.infoBox = parsedData.infoBox;
committedTrackerData.characterThoughts = parsedData.characterThoughts;
// console.log('[RPG Companion] 🔆 FIRST TIME: Auto-committed tracker data');
} else {
// console.log('[RPG Companion] Data will be committed when user replies');
}
// Remove the tracker code blocks from the visible message
let cleanedMessage = responseText;
// Remove all code blocks that contain tracker data
// Note: JSON code blocks are hidden from display by regex script (but preserved in message data)
// Remove old text format code blocks (legacy support)
cleanedMessage = cleanedMessage.replace(/```[^`]*?Stats\s*\n\s*---[^`]*?```\s*/gi, '');
cleanedMessage = cleanedMessage.replace(/```[^`]*?Info Box\s*\n\s*---[^`]*?```\s*/gi, '');
cleanedMessage = cleanedMessage.replace(/```[^`]*?Present Characters\s*\n\s*---[^`]*?```\s*/gi, '');
@@ -162,6 +214,8 @@ export async function onMessageReceived(data) {
cleanedMessage = cleanedMessage.replace(/^\s*---\s*$/gm, '');
// Clean up multiple consecutive newlines
cleanedMessage = cleanedMessage.replace(/\n{3,}/g, '\n\n');
// Note: <trackers> XML tags are automatically hidden by SillyTavern
// Note: <Song - Artist/> tags are also automatically hidden by SillyTavern
// Update the message in chat history
lastMessage.mes = cleanedMessage.trim();
@@ -177,31 +231,49 @@ export async function onMessageReceived(data) {
renderThoughts();
renderInventory();
renderQuests();
// Refresh dashboard widgets (v2 dashboard)
refreshDashboard();
renderMusicPlayer($musicPlayerContainer[0]);
// Then update the DOM to reflect the cleaned message
const lastMessageElement = $('#chat').children('.mes').last();
if (lastMessageElement.length) {
const messageText = lastMessageElement.find('.mes_text');
if (messageText.length) {
messageText.html(substituteParams(cleanedMessage.trim()));
}
}
// Using updateMessageBlock to perform macro substitutions + regex formatting
const messageId = chat.length - 1;
updateMessageBlock(messageId, lastMessage, { rerenderMessage: true });
// console.log('[RPG Companion] Cleaned message, removed tracker code blocks from DOM');
// Save to chat metadata
saveChatData();
}
} else if (extensionSettings.generationMode === 'separate' && extensionSettings.autoUpdate) {
// In separate mode with auto-update, trigger update after message
setTimeout(async () => {
await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory);
}, 500);
} else if (extensionSettings.generationMode === 'separate' || extensionSettings.generationMode === 'external') {
// In separate/external mode, also parse Spotify URLs from the main roleplay response
const lastMessage = chat[chat.length - 1];
if (lastMessage && !lastMessage.is_user) {
const responseText = lastMessage.mes;
// Parse and store Spotify URL
const foundSpotifyUrl = parseAndStoreSpotifyUrl(responseText);
// No need to clean message - SillyTavern auto-hides <Song - Artist/> tags
if (foundSpotifyUrl && extensionSettings.enableSpotifyMusic) {
// Just render the music player
renderMusicPlayer($musicPlayerContainer[0]);
}
}
// Trigger auto-update if enabled (for both separate and external modes)
// Only trigger if this is a newly generated message, not loading chat history
if (extensionSettings.autoUpdate && isAwaitingNewMessage) {
setTimeout(async () => {
await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory);
// Update FAB widgets after separate/external mode update completes
setFabLoadingState(false);
updateFabWidgets();
}, 500);
}
}
// Reset the awaiting flag after processing the message
setIsAwaitingNewMessage(false);
// Reset the swipe flag after generation completes
// This ensures that if the user swiped → auto-reply generated → flag is now cleared
// so the next user message will be treated as a new message (not a swipe)
@@ -216,6 +288,13 @@ export async function onMessageReceived(data) {
setIsPlotProgression(false);
// console.log('[RPG Companion] Plot progression generation completed');
}
// Stop FAB loading state and update widgets
setFabLoadingState(false);
updateFabWidgets();
// Re-apply checkpoint in case SillyTavern unhid messages during generation
await restoreCheckpointOnLoad();
}
/**
@@ -229,6 +308,12 @@ export function onCharacterChanged() {
$(window).off('resize.thoughtPanel');
$(document).off('click.thoughtPanel');
// Auto-switch to the preset associated with this character/group (if any)
const presetSwitched = autoSwitchPresetForEntity();
// if (presetSwitched) {
// console.log('[RPG Companion] Auto-switched preset for character');
// }
// Load chat-specific data when switching chats
loadChatData();
@@ -237,26 +322,22 @@ 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();
renderThoughts();
renderInventory();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
// Refresh dashboard widgets (v2 dashboard)
refreshDashboard();
// Update FAB widgets with loaded data
updateFabWidgets();
// Update chat thought overlays
updateChatThoughts();
// Update checkpoint indicators for the loaded chat
updateAllCheckpointIndicators();
}
/**
@@ -268,11 +349,12 @@ export function onMessageSwiped(messageIndex) {
return;
}
// console.log('[RPG Companion] Message swiped at index:', messageIndex);
// console.log('[RPG Companion] 🔵 EVENT: onMessageSwiped at index:', messageIndex);
// Get the message that was swiped
const message = chat[messageIndex];
if (!message || message.is_user) {
// console.log('[RPG Companion] 🔵 Ignoring swipe - message is user or undefined');
return;
}
@@ -281,28 +363,29 @@ export function onMessageSwiped(messageIndex) {
// Only set flag to true if this swipe will trigger a NEW generation
// Check if the swipe already exists (has content in the swipes array)
const isExistingSwipe = message.swipes &&
message.swipes[currentSwipeId] !== undefined &&
message.swipes[currentSwipeId] !== null &&
message.swipes[currentSwipeId].length > 0;
message.swipes[currentSwipeId] !== undefined &&
message.swipes[currentSwipeId] !== null &&
message.swipes[currentSwipeId].length > 0;
if (!isExistingSwipe) {
// This is a NEW swipe that will trigger generation
setLastActionWasSwipe(true);
// console.log('[RPG Companion] 🔵 EVENT: onMessageSwiped (NEW generation) - lastActionWasSwipe =', lastActionWasSwipe);
setIsAwaitingNewMessage(true);
// console.log('[RPG Companion] 🔵 NEW swipe detected - Set lastActionWasSwipe = true');
} else {
// This is navigating to an EXISTING swipe - don't change the flag
// console.log('[RPG Companion] 🔵 EVENT: onMessageSwiped (existing swipe navigation) - lastActionWasSwipe unchanged =', lastActionWasSwipe);
// console.log('[RPG Companion] 🔵 EXISTING swipe navigation - lastActionWasSwipe unchanged =', lastActionWasSwipe);
}
// console.log('[RPG Companion] Loading data for swipe', currentSwipeId);
// Load RPG data for this swipe into lastGeneratedData (for display only)
// This updates what the user sees, but does NOT commit it
// Committed data will be updated when/if the user replies to this swipe
// Load RPG data for this swipe
// lastGeneratedData is for DISPLAY, committedTrackerData is for GENERATION
// It's safe to load swipe data into lastGeneratedData - it won't be committed due to !lastActionWasSwipe check
if (message.extra && message.extra.rpg_companion_swipes && message.extra.rpg_companion_swipes[currentSwipeId]) {
const swipeData = message.extra.rpg_companion_swipes[currentSwipeId];
// Update display data
// Load swipe data into lastGeneratedData for display (both modes)
lastGeneratedData.userStats = swipeData.userStats || null;
lastGeneratedData.infoBox = swipeData.infoBox || null;
lastGeneratedData.characterThoughts = swipeData.characterThoughts || null;
@@ -312,20 +395,18 @@ export function onMessageSwiped(messageIndex) {
parseUserStats(swipeData.userStats);
}
// console.log('[RPG Companion] Loaded RPG data for swipe', currentSwipeId, '(display only, NOT committed)');
// console.log('[RPG Companion] committedTrackerData unchanged - will be updated if user replies to this swipe');
// console.log('[RPG Companion] 🔄 Loaded swipe data into lastGeneratedData for display:', currentSwipeId);
} else {
// No data for this swipe - keep existing lastGeneratedData (don't clear it)
// This ensures the display remains consistent and data is available for next commit
// console.log('[RPG Companion] No RPG data for swipe', currentSwipeId, '- keeping existing lastGeneratedData');
// console.log('[RPG Companion] ️ No stored data for swipe:', currentSwipeId);
}
// Re-render the panels (display only - committedTrackerData unchanged)
// Re-render the panels
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
// Update chat thought overlays
updateChatThoughts();
@@ -333,12 +414,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 +426,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');
}
}
/**
@@ -376,7 +453,32 @@ export function clearExtensionPrompts() {
setExtensionPrompt('rpg-companion-inject', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-example', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-dialogue-coloring', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-spotify', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-context', '', extension_prompt_types.IN_CHAT, 1, false);
// Note: rpg-companion-plot is not cleared here since it's passed via quiet_prompt option
// console.log('[RPG Companion] Cleared all extension prompts');
}
/**
* Event handler for when generation stops or ends
* Re-applies checkpoint if SillyTavern unhid messages
*/
export async function onGenerationEnded() {
// console.log('[RPG Companion] 🏁 onGenerationEnded called');
// Note: isGenerating flag is cleared in onMessageReceived after parsing (together mode)
// or in apiClient.js after separate generation completes (separate mode)
// SillyTavern may auto-unhide messages when generation stops
// Re-apply checkpoint if one exists
await restoreCheckpointOnLoad();
}
/**
* Initialize history injection event listeners.
* Should be called once during extension initialization.
*/
export function initHistoryInjection() {
initHistoryInjectionListeners();
}
+46 -5
View File
@@ -39,15 +39,56 @@ let openForms = {
/**
* Updates lastGeneratedData.userStats AND committedTrackerData.userStats to include
* current inventory in text format.
* current inventory.
* Maintains JSON format if current data is JSON, otherwise uses text format.
* This ensures manual edits are immediately visible to AI in next generation.
*/
function updateLastGeneratedDataInventory() {
// Rebuild the userStats text format using custom stat names
const statsText = buildUserStatsText();
// Check if current data is in JSON format
const currentData = lastGeneratedData.userStats || committedTrackerData.userStats;
if (currentData) {
const trimmed = currentData.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
// Maintain JSON format
try {
const jsonData = JSON.parse(currentData);
if (jsonData && typeof jsonData === 'object') {
// Update inventory in JSON
const stats = extensionSettings.userStats;
// Update BOTH lastGeneratedData AND committedTrackerData
// This makes manual edits immediately visible to AI
// Convert inventory back to v3 format (arrays of {name, quantity})
const convertToV3Items = (itemString) => {
if (!itemString) return [];
const items = itemString.split(',').map(s => s.trim()).filter(s => s);
return items.map(item => {
const qtyMatch = item.match(/^(\d+)x\s+(.+)$/);
if (qtyMatch) {
return { name: qtyMatch[2].trim(), quantity: parseInt(qtyMatch[1]) };
}
return { name: item, quantity: 1 };
});
};
jsonData.inventory = {
onPerson: convertToV3Items(stats.inventory.onPerson),
clothing: convertToV3Items(stats.inventory.clothing),
stored: stats.inventory.stored || {},
assets: convertToV3Items(stats.inventory.assets)
};
const updatedJSON = JSON.stringify(jsonData, null, 2);
lastGeneratedData.userStats = updatedJSON;
committedTrackerData.userStats = updatedJSON;
return;
}
} catch (e) {
console.warn('[RPG Companion] Failed to parse JSON, falling back to text format:', e);
}
}
}
// Fall back to text format
const statsText = buildUserStatsText();
lastGeneratedData.userStats = statsText;
committedTrackerData.userStats = statsText;
}
+46 -6
View File
@@ -79,15 +79,58 @@ export function updateInventoryItem(field, index, newName, location) {
/**
* Updates lastGeneratedData.userStats AND committedTrackerData.userStats to include
* current inventory in text format.
* current inventory.
* Maintains JSON format if current data is JSON, otherwise uses text format.
* This ensures manual edits are immediately visible to AI in next generation.
* @private
*/
function updateLastGeneratedDataInventory() {
// Check if current data is in JSON format
const currentData = lastGeneratedData.userStats || committedTrackerData.userStats;
if (currentData) {
const trimmed = currentData.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
// Maintain JSON format
try {
const jsonData = JSON.parse(currentData);
if (jsonData && typeof jsonData === 'object') {
// Update inventory in JSON
const stats = extensionSettings.userStats;
// Convert inventory back to v3 format (arrays of {name, quantity})
const convertToV3Items = (itemString) => {
if (!itemString) return [];
const items = itemString.split(',').map(s => s.trim()).filter(s => s);
return items.map(item => {
const qtyMatch = item.match(/^(\d+)x\s+(.+)$/);
if (qtyMatch) {
return { name: qtyMatch[2].trim(), quantity: parseInt(qtyMatch[1]) };
}
return { name: item, quantity: 1 };
});
};
jsonData.inventory = {
onPerson: convertToV3Items(stats.inventory.onPerson),
clothing: convertToV3Items(stats.inventory.clothing),
stored: stats.inventory.stored || {},
assets: convertToV3Items(stats.inventory.assets)
};
const updatedJSON = JSON.stringify(jsonData, null, 2);
lastGeneratedData.userStats = updatedJSON;
committedTrackerData.userStats = updatedJSON;
return;
}
} catch (e) {
console.warn('[RPG Companion] Failed to parse JSON, falling back to text format:', e);
}
}
}
// Fall back to text format
const stats = extensionSettings.userStats;
const inventorySummary = buildInventorySummary(stats.inventory);
// Rebuild the userStats text format
const statsText =
`Health: ${stats.health}%\n` +
`Satiety: ${stats.satiety}%\n` +
@@ -96,9 +139,6 @@ function updateLastGeneratedDataInventory() {
`Arousal: ${stats.arousal}%\n` +
`${stats.mood}: ${stats.conditions}\n` +
`${inventorySummary}`;
// Update BOTH lastGeneratedData AND committedTrackerData
// This makes manual edits immediately visible to AI
lastGeneratedData.userStats = statsText;
committedTrackerData.userStats = statsText;
}
+264 -49
View File
@@ -11,6 +11,27 @@ import {
$infoBoxContainer
} from '../../core/state.js';
import { saveChatData } from '../../core/persistence.js';
import { i18n } from '../../core/i18n.js';
import { isItemLocked } from '../generation/lockManager.js';
import { repairJSON } from '../../utils/jsonRepair.js';
import { updateFabWidgets } from '../ui/mobile.js';
/**
* Helper to generate lock icon HTML if setting is enabled
* @param {string} tracker - Tracker name
* @param {string} path - Item path
* @returns {string} Lock icon HTML or empty string
*/
function getLockIconHtml(tracker, path) {
const showLockIcons = extensionSettings.showLockIcons ?? true;
if (!showLockIcons) return '';
const isLocked = isItemLocked(tracker, path);
const lockIcon = isLocked ? '🔒' : '🔓';
const lockTitle = isLocked ? 'Locked' : 'Unlocked';
const lockedClass = isLocked ? ' locked' : '';
return `<span class="rpg-section-lock-icon${lockedClass}" data-tracker="${tracker}" data-path="${path}" title="${lockTitle}">${lockIcon}</span>`;
}
/**
* Helper to separate emoji from text in a string
@@ -55,41 +76,36 @@ function separateEmojiFromText(str) {
* Includes event listeners for editable fields.
*/
export function renderInfoBox() {
if (!extensionSettings.showInfoBox || !$infoBoxContainer) {
return;
}
// console.log('[RPG InfoBox Render] ==================== RENDERING INFO BOX ====================');
// console.log('[RPG InfoBox Render] showInfoBox setting:', extensionSettings.showInfoBox);
// console.log('[RPG InfoBox Render] Container exists:', !!$infoBoxContainer);
// Add updating class for animation
if (extensionSettings.enableAnimations) {
$infoBoxContainer.addClass('rpg-content-updating');
if (!extensionSettings.showInfoBox || !$infoBoxContainer) {
// console.log('[RPG InfoBox Render] Exiting: showInfoBox or container is false');
return;
}
// Use committedTrackerData as fallback if lastGeneratedData is empty (e.g., after page refresh)
const infoBoxData = lastGeneratedData.infoBox || committedTrackerData.infoBox;
// console.log('[RPG InfoBox Render] infoBoxData length:', infoBoxData ? infoBoxData.length : 'null');
// console.log('[RPG InfoBox Render] infoBoxData preview:', infoBoxData ? infoBoxData.substring(0, 200) : 'null');
// If no data yet, show placeholder
// If no data yet, hide the container (e.g., after cache clear)
if (!infoBoxData) {
const placeholderHtml = `
<div class="rpg-dashboard rpg-dashboard-row-1">
<div class="rpg-dashboard-widget rpg-placeholder-widget">
<div class="rpg-placeholder-text">No data yet</div>
<div class="rpg-placeholder-hint">Generate a new response in the roleplay or switch to "Separate Generation" in Settings to access and click the "Refresh RPG Info" button</div>
</div>
</div>
`;
$infoBoxContainer.html(placeholderHtml);
if (extensionSettings.enableAnimations) {
setTimeout(() => $infoBoxContainer.removeClass('rpg-content-updating'), 500);
}
// console.log('[RPG InfoBox Render] No data, hiding container');
$infoBoxContainer.empty().hide();
return;
}
// Show container and add updating class for animation
$infoBoxContainer.show();
if (extensionSettings.enableAnimations) {
$infoBoxContainer.addClass('rpg-content-updating');
}
// console.log('[RPG Companion] renderInfoBox called with data:', infoBoxData);
// Parse the info box data
const lines = infoBoxData.split('\n');
// console.log('[RPG Companion] Info Box split into lines:', lines);
const data = {
let data = {
date: '',
weekday: '',
month: '',
@@ -104,6 +120,45 @@ export function renderInfoBox() {
characters: []
};
// Check if data is v3 JSON format
const trimmed = infoBoxData.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
const jsonData = repairJSON(infoBoxData);
if (jsonData) {
// Extract from v3 JSON structure
data.weatherEmoji = jsonData.weather?.emoji || '';
data.weatherForecast = jsonData.weather?.forecast || '';
data.temperature = jsonData.temperature ? `${jsonData.temperature.value}°${jsonData.temperature.unit}` : '';
data.tempValue = jsonData.temperature?.value || 0;
data.timeStart = jsonData.time?.start || '';
data.timeEnd = jsonData.time?.end || '';
data.location = jsonData.location?.value || '';
// Parse date string to extract weekday, month, year
if (jsonData.date?.value) {
data.date = jsonData.date.value;
// Expected format: "Tuesday, October 17th, 2023"
const dateParts = data.date.split(',').map(p => p.trim());
data.weekday = dateParts[0] || '';
data.month = dateParts[1] || '';
data.year = dateParts[2] || '';
}
// Skip to rendering
} else {
// JSON parsing failed, fall back to text parsing
parseTextFormat();
}
} else {
// Text format
parseTextFormat();
}
function parseTextFormat() {
// Parse the info box data
const lines = infoBoxData.split('\n');
// console.log('[RPG Companion] Info Box split into lines:', lines);
// Track which fields we've already parsed to avoid duplicates from mixed formats
const parsedFields = {
date: false,
@@ -204,10 +259,10 @@ export function renderInfoBox() {
data.weatherEmoji = emoji;
data.weatherForecast = text;
} else if (weatherStr.includes(',')) {
// Fallback to comma split if emoji detection failed
const weatherParts = weatherStr.split(',').map(p => p.trim());
data.weatherEmoji = weatherParts[0] || '';
data.weatherForecast = weatherParts[1] || '';
// Fallback to comma split if emoji detection failed - split only on FIRST comma
const firstCommaIndex = weatherStr.indexOf(',');
data.weatherEmoji = weatherStr.substring(0, firstCommaIndex).trim();
data.weatherForecast = weatherStr.substring(firstCommaIndex + 1).trim();
} else {
// No clear separation - assume it's all forecast text
data.weatherEmoji = '🌤️'; // Default emoji
@@ -269,6 +324,7 @@ export function renderInfoBox() {
// timeStart: data.timeStart,
// location: data.location
// });
}
// Get tracker configuration
const config = extensionSettings.trackerConfig?.infoBox;
@@ -302,10 +358,13 @@ export function renderInfoBox() {
weekdayDisplay = weekdayDisplay;
}
const dateLockIconHtml = getLockIconHtml('infoBox', 'date');
row1Widgets.push(`
<div class="rpg-dashboard-widget rpg-calendar-widget">
${dateLockIconHtml}
<div class="rpg-calendar-top rpg-editable" contenteditable="true" data-field="month" data-full-value="${data.month || ''}" title="Click to edit">${monthDisplay}</div>
<div class="rpg-calendar-day rpg-editable" contenteditable="true" data-field="weekday" data-full-value="${data.weekday || ''}" title="Click to edit">${weekdayDisplay}</div>
<div class="rpg-calendar-day" title="Click to edit"><span class="rpg-calendar-day-text rpg-editable" contenteditable="true" data-field="weekday" data-full-value="${data.weekday || ''}">${weekdayDisplay}</span></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>
`);
@@ -315,8 +374,11 @@ export function renderInfoBox() {
if (config?.widgets?.weather?.enabled) {
const weatherEmoji = data.weatherEmoji || '🌤️';
const weatherForecast = data.weatherForecast || 'Weather';
const weatherLockIconHtml = getLockIconHtml('infoBox', 'weather');
row1Widgets.push(`
<div class="rpg-dashboard-widget rpg-weather-widget">
${weatherLockIconHtml}
<div class="rpg-weather-icon rpg-editable" contenteditable="true" data-field="weatherEmoji" title="Click to edit emoji">${weatherEmoji}</div>
<div class="rpg-weather-forecast rpg-editable" contenteditable="true" data-field="weatherForecast" title="Click to edit">${weatherForecast}</div>
</div>
@@ -356,8 +418,11 @@ export function renderInfoBox() {
const tempInCelsius = preferredUnit === 'F' ? Math.round((tempValue - 32) * 5/9) : tempValue;
const tempPercent = Math.min(100, Math.max(0, ((tempInCelsius + 20) / 60) * 100));
const tempColor = tempInCelsius < 10 ? '#4a90e2' : tempInCelsius < 25 ? '#67c23a' : '#e94560';
const tempLockIconHtml = getLockIconHtml('infoBox', 'temperature');
row1Widgets.push(`
<div class="rpg-dashboard-widget rpg-temp-widget">
${tempLockIconHtml}
<div class="rpg-thermometer">
<div class="rpg-thermometer-bulb"></div>
<div class="rpg-thermometer-tube">
@@ -371,7 +436,12 @@ export function renderInfoBox() {
// Time widget - show if enabled
if (config?.widgets?.time?.enabled) {
// Determine which time value to display and edit
const hasTimeEnd = Boolean(data.timeEnd);
const hasTimeStart = Boolean(data.timeStart);
const timeDisplay = data.timeEnd || data.timeStart || '12:00';
const timeField = hasTimeEnd ? 'timeEnd' : 'timeStart';
// Parse time for clock hands
const timeMatch = timeDisplay.match(/(\d+):(\d+)/);
let hourAngle = 0;
@@ -382,8 +452,12 @@ export function renderInfoBox() {
hourAngle = (hours % 12) * 30 + minutes * 0.5; // 30° per hour + 0.5° per minute
minuteAngle = minutes * 6; // 6° per minute
}
const timeLockIconHtml = getLockIconHtml('infoBox', 'time');
row1Widgets.push(`
<div class="rpg-dashboard-widget rpg-clock-widget">
${timeLockIconHtml}
<div class="rpg-clock">
<div class="rpg-clock-face">
<div class="rpg-clock-hour" style="transform: rotate(${hourAngle}deg)"></div>
@@ -391,7 +465,7 @@ export function renderInfoBox() {
<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 class="rpg-time-value rpg-editable" contenteditable="true" data-field="${timeField}" title="Click to edit">${timeDisplay}</div>
</div>
`);
}
@@ -406,9 +480,12 @@ export function renderInfoBox() {
// Row 2: Location widget (full width) - show if enabled
if (config?.widgets?.location?.enabled) {
const locationDisplay = data.location || 'Location';
const locationLockIconHtml = getLockIconHtml('infoBox', 'location');
html += `
<div class="rpg-dashboard rpg-dashboard-row-2">
<div class="rpg-dashboard-widget rpg-location-widget">
${locationLockIconHtml}
<div class="rpg-map-bg">
<div class="rpg-map-marker">📍</div>
</div>
@@ -420,14 +497,26 @@ export function renderInfoBox() {
// Row 3: Recent Events widget (notebook style) - show if enabled
if (config?.widgets?.recentEvents?.enabled) {
// Parse Recent Events from infoBox string
// Parse Recent Events from infoBox (supports both JSON and text formats)
let recentEvents = [];
if (committedTrackerData.infoBox) {
const recentEventsLine = committedTrackerData.infoBox.split('\n').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);
if (infoBoxData) {
// Try JSON format first
try {
const parsed = typeof infoBoxData === 'string'
? JSON.parse(infoBoxData)
: infoBoxData;
if (parsed && Array.isArray(parsed.recentEvents)) {
recentEvents = parsed.recentEvents;
}
} catch (e) {
// Fall back to old text format
const recentEventsLine = infoBoxData.split('\n').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);
}
}
}
}
@@ -439,15 +528,18 @@ export function renderInfoBox() {
validEvents.push('Click to add event');
}
const eventsLockIconHtml = getLockIconHtml('infoBox', 'recentEvents');
html += `
<div class="rpg-dashboard rpg-dashboard-row-3">
<div class="rpg-dashboard-widget rpg-events-widget">
${eventsLockIconHtml}
<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-title" data-i18n-key="infobox.recentEvents.title">${i18n.getTranslation('infobox.recentEvents.title')}</div>
<div class="rpg-notebook-lines">
`;
@@ -466,7 +558,7 @@ export function renderInfoBox() {
html += `
<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-field="event${i + 1}" title="Click to add event">Add event...</span>
<span class="rpg-event-text rpg-editable rpg-event-placeholder" contenteditable="true" data-field="event${i + 1}" title="Click to add event" data-i18n-key="infobox.recentEvents.addEventPlaceholder">${i18n.getTranslation('infobox.recentEvents.addEventPlaceholder')}</span>
</div>
`;
}
@@ -483,6 +575,19 @@ export function renderInfoBox() {
$infoBoxContainer.html(html);
// Add dynamic text scaling for location field
const updateLocationTextSize = ($element) => {
const text = $element.text();
const charCount = text.length;
$element.css('--char-count', Math.min(charCount, 100));
};
// Initial size update for location
const $locationText = $infoBoxContainer.find('[data-field="location"]');
if ($locationText.length) {
updateLocationTextSize($locationText);
}
// Add event handlers for editable Info Box fields
$infoBoxContainer.find('.rpg-editable').on('blur', function() {
const $this = $(this);
@@ -500,12 +605,25 @@ export function renderInfoBox() {
}
}
// Update location text size dynamically
if (field === 'location') {
updateLocationTextSize($this);
}
// Handle recent events separately
if (field === 'event1' || field === 'event2' || field === 'event3') {
updateRecentEvent(field, value);
} else {
updateInfoBoxField(field, value);
}
// Update FAB widgets to reflect changes
updateFabWidgets();
});
// Update location size on input as well (real-time)
$infoBoxContainer.find('[data-field="location"]').on('input', function() {
updateLocationTextSize($(this));
});
// For date fields, show full value on focus
@@ -516,10 +634,39 @@ export function renderInfoBox() {
}
});
// Add event handler for lock icons (support both click and touch)
$infoBoxContainer.find('.rpg-section-lock-icon').on('click touchend', function(e) {
e.preventDefault();
e.stopPropagation();
const $lockIcon = $(this);
const tracker = $lockIcon.data('tracker');
const path = $lockIcon.data('path');
// Import lockManager dynamically to avoid circular dependencies
import('../generation/lockManager.js').then(({ setItemLock, isItemLocked }) => {
const isLocked = isItemLocked(tracker, path);
const newLockState = !isLocked;
setItemLock(tracker, path, newLockState);
// Update icon
$lockIcon.text(newLockState ? '🔒' : '🔓');
$lockIcon.attr('title', newLockState ? 'Locked - AI cannot change this' : 'Unlocked - AI can change this');
$lockIcon.toggleClass('locked', newLockState);
// Save settings to persist lock state
saveSettings();
});
});
// Remove updating class after animation
if (extensionSettings.enableAnimations) {
setTimeout(() => $infoBoxContainer.removeClass('rpg-content-updating'), 500);
}
// Update weather effect after rendering
if (window.RPGCompanion?.updateWeatherEffect) {
window.RPGCompanion.updateWeatherEffect();
}
}
/**
@@ -535,6 +682,64 @@ export function updateInfoBoxField(field, value) {
lastGeneratedData.infoBox = 'Info Box\n---\n';
}
// Check if data is in v3 JSON format
const trimmed = lastGeneratedData.infoBox.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
// Handle v3 JSON format
const jsonData = repairJSON(lastGeneratedData.infoBox);
if (jsonData) {
// Update the appropriate field based on v3 structure
if (field === 'weatherEmoji') {
if (!jsonData.weather) jsonData.weather = {};
jsonData.weather.emoji = value;
} else if (field === 'weatherForecast') {
if (!jsonData.weather) jsonData.weather = {};
jsonData.weather.forecast = value;
} else if (field === 'temperature') {
// Parse temperature value and unit
const tempMatch = value.match(/(-?\d+)\s*°?\s*([CF]?)/i);
if (tempMatch) {
if (!jsonData.temperature) jsonData.temperature = {};
jsonData.temperature.value = parseInt(tempMatch[1]);
jsonData.temperature.unit = (tempMatch[2] || 'C').toUpperCase();
}
} else if (field === 'timeStart') {
if (!jsonData.time) jsonData.time = {};
jsonData.time.start = value;
} else if (field === 'timeEnd') {
if (!jsonData.time) jsonData.time = {};
jsonData.time.end = value;
} else if (field === 'location') {
if (!jsonData.location) jsonData.location = {};
jsonData.location.value = value;
} else if (field === 'weekday' || field === 'month' || field === 'year') {
// Update date components
if (!jsonData.date) jsonData.date = {};
let currentDate = jsonData.date.value || '';
const dateParts = currentDate.split(',').map(p => p.trim());
if (field === 'weekday') {
dateParts[0] = value;
} else if (field === 'month') {
dateParts[1] = value;
} else if (field === 'year') {
dateParts[2] = value;
}
jsonData.date.value = dateParts.filter(p => p).join(', ');
}
// Save back as JSON
lastGeneratedData.infoBox = JSON.stringify(jsonData, null, 2);
committedTrackerData.infoBox = lastGeneratedData.infoBox;
saveChatData();
renderInfoBox();
// console.log('[RPG Companion] Updated info box field (v3 JSON):', { field, value });
return;
}
}
// Fall back to text format handling
// Reconstruct the Info Box text with updated field
const lines = lastGeneratedData.infoBox.split('\n');
let dateLineFound = false;
@@ -607,14 +812,16 @@ export function updateInfoBoxField(field, value) {
if (line.startsWith('Weather:')) {
// New format: Weather: emoji, forecast
const weatherContent = line.replace('Weather:', '').trim();
const parts = weatherContent.split(',').map(p => p.trim());
const forecast = parts[1] || 'Weather';
// Split only on first comma to get emoji and rest
const firstCommaIndex = weatherContent.indexOf(',');
const forecast = firstCommaIndex > 0 ? weatherContent.substring(firstCommaIndex + 1).trim() : 'Weather';
return `Weather: ${value}, ${forecast}`;
} else {
// Legacy format: emoji: forecast
const parts = line.split(':');
if (parts.length >= 2) {
return `${value}: ${parts.slice(1).join(':').trim()}`;
const firstColonIndex = line.indexOf(':');
if (firstColonIndex >= 0) {
const forecast = line.substring(firstColonIndex + 1).trim();
return `${value}: ${forecast}`;
}
}
} else if (field === 'weatherForecast' && index === weatherLineIndex) {
@@ -622,14 +829,16 @@ export function updateInfoBoxField(field, value) {
if (line.startsWith('Weather:')) {
// New format: Weather: emoji, forecast
const weatherContent = line.replace('Weather:', '').trim();
const parts = weatherContent.split(',').map(p => p.trim());
const emoji = parts[0] || '🌤️';
// Split only on first comma to get emoji and rest
const firstCommaIndex = weatherContent.indexOf(',');
const emoji = firstCommaIndex > 0 ? weatherContent.substring(0, firstCommaIndex).trim() : '🌤️';
return `Weather: ${emoji}, ${value}`;
} else {
// Legacy format: emoji: forecast
const parts = line.split(':');
if (parts.length >= 2) {
return `${parts[0].trim()}: ${value}`;
const firstColonIndex = line.indexOf(':');
if (firstColonIndex >= 0) {
const emoji = line.substring(0, firstColonIndex).trim();
return `${emoji}: ${value}`;
}
}
} else if (field === 'temperature' && (line.includes('🌡️:') || line.startsWith('Temperature:'))) {
@@ -873,6 +1082,12 @@ function updateRecentEvent(field, value) {
saveChatData();
renderInfoBox();
console.log(`[RPG Companion] Updated recent event ${field}:`, value);
// Update weather effect after rendering
if (window.RPGCompanion?.updateWeatherEffect) {
window.RPGCompanion.updateWeatherEffect();
}
// console.log(`[RPG Companion] Updated recent event ${field}:`, value);
}
}
+165 -14
View File
@@ -4,13 +4,32 @@
*/
import { extensionSettings, $inventoryContainer } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { getInventoryRenderOptions, restoreFormStates } from '../interaction/inventoryActions.js';
import { updateInventoryItem } from '../interaction/inventoryEdit.js';
import { parseItems } from '../../utils/itemParser.js';
import { isItemLocked, setItemLock } from '../generation/lockManager.js';
// Type imports
/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */
/**
* Helper to generate lock icon HTML if setting is enabled
* @param {string} tracker - Tracker name
* @param {string} path - Item path
* @returns {string} Lock icon HTML or empty string
*/
function getLockIconHtml(tracker, path) {
const showLockIcons = extensionSettings.showLockIcons ?? true;
if (!showLockIcons) return '';
const isLocked = isItemLocked(tracker, path);
const lockIcon = isLocked ? '🔒' : '🔓';
const lockTitle = isLocked ? 'Locked' : 'Unlocked';
const lockedClass = isLocked ? ' locked' : '';
return `<span class="rpg-section-lock-icon${lockedClass}" data-tracker="${tracker}" data-path="${path}" title="${lockTitle}">${lockIcon}</span>`;
}
/**
* Converts a location name to a safe ID for use in HTML element IDs.
* Must match the logic used in inventoryActions.js.
@@ -23,8 +42,8 @@ export function getLocationId(locationName) {
}
/**
* Renders the inventory sub-tab navigation (On Person, Stored, Assets)
* @param {string} activeTab - Currently active sub-tab ('onPerson', 'stored', 'assets')
* Renders the inventory sub-tab navigation (On Person, Clothing, Stored, Assets)
* @param {string} activeTab - Currently active sub-tab ('onPerson', 'clothing', 'stored', 'assets')
* @returns {string} HTML for sub-tab navigation
*/
export function renderInventorySubTabs(activeTab = 'onPerson') {
@@ -33,6 +52,9 @@ export function renderInventorySubTabs(activeTab = 'onPerson') {
<button class="rpg-inventory-subtab ${activeTab === 'onPerson' ? 'active' : ''}" data-tab="onPerson">
On Person
</button>
<button class="rpg-inventory-subtab ${activeTab === 'clothing' ? 'active' : ''}" data-tab="clothing">
Clothing
</button>
<button class="rpg-inventory-subtab ${activeTab === 'stored' ? 'active' : ''}" data-tab="stored">
Stored
</button>
@@ -58,24 +80,30 @@ export function renderOnPersonView(onPersonItems, viewMode = 'list') {
} else {
if (viewMode === 'grid') {
// Grid view: card-style items
itemsHtml = items.map((item, index) => `
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.onPerson[${index}]`);
return `
<div class="rpg-item-card" data-field="onPerson" data-index="${index}">
${lockIconHtml}
<button class="rpg-item-remove" data-action="remove-item" data-field="onPerson" 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="onPerson" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
</div>
`).join('');
`}).join('');
} else {
// List view: full-width rows
itemsHtml = items.map((item, index) => `
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.onPerson[${index}]`);
return `
<div class="rpg-item-row" data-field="onPerson" data-index="${index}">
${lockIconHtml}
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="onPerson" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
<button class="rpg-item-remove" data-action="remove-item" data-field="onPerson" data-index="${index}" title="Remove item">
<i class="fa-solid fa-times"></i>
</button>
</div>
`).join('');
`}).join('');
}
}
@@ -119,6 +147,88 @@ export function renderOnPersonView(onPersonItems, viewMode = 'list') {
`;
}
/**
* Renders the "Clothing" inventory view with list or grid display
* @param {string} clothingItems - Current clothing items (comma-separated string)
* @param {string} viewMode - View mode ('list' or 'grid')
* @returns {string} HTML for clothing view with items and add button
*/
export function renderClothingView(clothingItems, viewMode = 'list') {
const items = parseItems(clothingItems);
let itemsHtml = '';
if (items.length === 0) {
itemsHtml = '<div class="rpg-inventory-empty">No clothing worn</div>';
} else {
if (viewMode === 'grid') {
// Grid view: card-style items
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.clothing[${index}]`);
return `
<div class="rpg-item-card" data-field="clothing" data-index="${index}">
${lockIconHtml}
<button class="rpg-item-remove" data-action="remove-item" data-field="clothing" 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="clothing" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
</div>
`}).join('');
} else {
// List view: full-width rows
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.clothing[${index}]`);
return `
<div class="rpg-item-row" data-field="clothing" data-index="${index}">
${lockIconHtml}
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="clothing" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
<button class="rpg-item-remove" data-action="remove-item" data-field="clothing" data-index="${index}" title="Remove item">
<i class="fa-solid fa-times"></i>
</button>
</div>
`}).join('');
}
}
const listViewClass = viewMode === 'list' ? 'rpg-item-list-view' : 'rpg-item-grid-view';
return `
<div class="rpg-inventory-section" data-section="clothing">
<div class="rpg-inventory-header">
<h4>Clothing Worn</h4>
<div class="rpg-inventory-header-actions">
<div class="rpg-view-toggle">
<button class="rpg-view-btn ${viewMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="clothing" 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="clothing" data-view="grid" title="Grid view">
<i class="fa-solid fa-th"></i>
</button>
</div>
<button class="rpg-inventory-add-btn" data-action="add-item" data-field="clothing" title="Add new clothing item">
<i class="fa-solid fa-plus"></i> Add Clothing
</button>
</div>
</div>
<div class="rpg-inventory-content">
<div class="rpg-inline-form" id="rpg-add-item-form-clothing" style="display: none;">
<input type="text" class="rpg-inline-input" id="rpg-new-item-clothing" placeholder="Enter clothing item..." />
<div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-item" data-field="clothing">
<i class="fa-solid fa-times"></i> Cancel
</button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-item" data-field="clothing">
<i class="fa-solid fa-check"></i> Add
</button>
</div>
</div>
<div class="rpg-item-list ${listViewClass}">
${itemsHtml}
</div>
</div>
</div>
`;
}
/**
* Renders the "Stored" inventory view with collapsible locations and list/grid views
* @param {Object.<string, string>} stored - Stored items by location
@@ -180,24 +290,30 @@ export function renderStoredView(stored, collapsedLocations = [], viewMode = 'li
} else {
if (viewMode === 'grid') {
// Grid view: card-style items
itemsHtml = items.map((item, index) => `
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.stored.${location}[${index}]`);
return `
<div class="rpg-item-card" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}">
${lockIconHtml}
<button class="rpg-item-remove" data-action="remove-item" data-field="stored" data-location="${escapeHtml(location)}" 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="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
</div>
`).join('');
`}).join('');
} else {
// List view: full-width rows
itemsHtml = items.map((item, index) => `
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.stored.${location}[${index}]`);
return `
<div class="rpg-item-row" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}">
${lockIconHtml}
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
<button class="rpg-item-remove" data-action="remove-item" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Remove item">
<i class="fa-solid fa-times"></i>
</button>
</div>
`).join('');
`}).join('');
}
}
@@ -276,24 +392,30 @@ export function renderAssetsView(assets, viewMode = 'list') {
} else {
if (viewMode === 'grid') {
// Grid view: card-style items
itemsHtml = items.map((item, index) => `
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.assets[${index}]`);
return `
<div class="rpg-item-card" data-field="assets" data-index="${index}">
${lockIconHtml}
<button class="rpg-item-remove" data-action="remove-item" data-field="assets" data-index="${index}" title="Remove asset">
<i class="fa-solid fa-times"></i>
</button>
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="assets" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
</div>
`).join('');
`}).join('');
} else {
// List view: full-width rows
itemsHtml = items.map((item, index) => `
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.assets[${index}]`);
return `
<div class="rpg-item-row" data-field="assets" data-index="${index}">
${lockIconHtml}
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="assets" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
<button class="rpg-item-remove" data-action="remove-item" data-field="assets" data-index="${index}" title="Remove asset">
<i class="fa-solid fa-times"></i>
</button>
</div>
`).join('');
`}).join('');
}
}
@@ -397,6 +519,7 @@ function generateInventoryHTML(inventory, options = {}) {
// Get view modes from settings (default to 'list')
const viewModes = extensionSettings.inventoryViewModes || {
onPerson: 'list',
clothing: 'list',
stored: 'list',
assets: 'list'
};
@@ -406,6 +529,9 @@ function generateInventoryHTML(inventory, options = {}) {
case 'onPerson':
html += renderOnPersonView(v2Inventory.onPerson, viewModes.onPerson);
break;
case 'clothing':
html += renderClothingView(v2Inventory.clothing, viewModes.clothing);
break;
case 'stored':
html += renderStoredView(v2Inventory.stored, collapsedLocations, viewModes.stored);
break;
@@ -476,6 +602,31 @@ export function renderInventory() {
const newName = $(this).text().trim();
updateInventoryItem(field, index, newName, location);
});
// Add event listener for section lock icon clicks (support both click and touch)
$inventoryContainer.find('.rpg-section-lock-icon').on('click touchend', function(e) {
e.preventDefault();
e.stopPropagation();
const $icon = $(this);
const trackerType = $icon.data('tracker');
const itemPath = $icon.data('path');
const currentlyLocked = isItemLocked(trackerType, itemPath);
// Toggle lock state
setItemLock(trackerType, itemPath, !currentlyLocked);
// Update icon
const newIcon = !currentlyLocked ? '🔒' : '🔓';
const newTitle = !currentlyLocked ? 'Locked' : 'Unlocked';
$icon.text(newIcon);
$icon.attr('title', newTitle);
// Toggle 'locked' class for persistent visibility
$icon.toggleClass('locked', !currentlyLocked);
// Save settings
saveSettings();
});
}
/**
+150
View File
@@ -0,0 +1,150 @@
/**
* Music Player Rendering Module
* Handles UI rendering for Spotify music player widget
*/
import { extensionSettings, committedTrackerData } from '../../core/state.js';
import { i18n } from '../../core/i18n.js';
/**
* Creates a Spotify deep link URL that opens the Spotify app
* Uses spotify:search: protocol for app, falls back to web URL
* @param {Object} songData - Object with {song, artist, searchQuery}
* @returns {Object} Object with appUrl and webUrl
*/
function createSpotifyUrls(songData) {
if (!songData || !songData.searchQuery) {
return { appUrl: '', webUrl: '' };
}
const encodedQuery = encodeURIComponent(songData.searchQuery);
return {
// Spotify app protocol - opens directly in Spotify app on desktop/mobile
appUrl: `spotify:search:${encodedQuery}`,
// Web fallback - opens Spotify web player search
webUrl: `https://open.spotify.com/search/${encodedQuery}`
};
}
/**
* Opens Spotify with the given song
* Tries app protocol first, falls back to web
* @param {Object} songData - Song data object
*/
function openInSpotify(songData) {
const urls = createSpotifyUrls(songData);
// Try to open in Spotify app first
// On mobile, this will open the Spotify app if installed
// On desktop, this will open Spotify desktop app if installed
window.location.href = urls.appUrl;
// Fallback: If app doesn't open within 2 seconds, open web version
// This handles cases where Spotify app isn't installed
setTimeout(() => {
// Check if we're still on the same page (app didn't open)
// Note: This is a best-effort fallback
if (document.hasFocus()) {
window.open(urls.webUrl, '_blank');
}
}, 1500);
}
/**
* Renders the Spotify music player as a mini player widget above chat input
* @param {HTMLElement} container - Container element to render into
*/
export function renderMusicPlayer(container) {
// console.log('[RPG Companion] Music Player: renderMusicPlayer called');
// Remove old chat-attached player if it exists
$('#rpg-chat-music-player').remove();
// console.log('[RPG Companion] Music Player: enableSpotifyMusic =', extensionSettings.enableSpotifyMusic);
if (!extensionSettings.enableSpotifyMusic) {
// console.warn('[RPG Companion] Music Player: Spotify music is disabled');
return;
}
const songData = committedTrackerData.spotifyUrl;
// console.log('[RPG Companion] Music Player: Rendering with song:', songData);
if (!songData || !songData.displayText) {
// No song - don't show anything
return;
}
// Create the mini music player widget
const musicPlayerHtml = `
<div id="rpg-chat-music-player" class="rpg-music-widget">
<div class="rpg-music-widget-content">
<div class="rpg-music-widget-icon">
<i class="fa-brands fa-spotify"></i>
</div>
<div class="rpg-music-widget-info">
<div class="rpg-music-widget-title" title="${songData.song}">${songData.song}</div>
<div class="rpg-music-widget-artist" title="${songData.artist}">${songData.artist}</div>
</div>
<button class="rpg-music-widget-play" title="Play in Spotify">
<i class="fa-solid fa-play"></i>
</button>
<button class="rpg-music-widget-close" title="Dismiss">
<i class="fa-solid fa-times"></i>
</button>
</div>
</div>
`;
// Find the chat form container and insert widget before (above) it
const $chatForm = $('#send_form');
// console.log('[RPG Companion] Music Player: Found #send_form:', $chatForm.length > 0);
if ($chatForm.length === 0) {
console.error('[RPG Companion] Music Player: Could not find #send_form - cannot render widget!');
return;
}
// Insert widget inside (at top of) the chat form
// console.log('[RPG Companion] Music Player: Prepending widget to #send_form');
$chatForm.prepend(musicPlayerHtml);
// console.log('[RPG Companion] Music Player: Widget inserted, checking if visible...');
const $widget = $('#rpg-chat-music-player');
// console.log('[RPG Companion] Music Player: Widget exists:', $widget.length > 0);
if ($widget.length > 0) {
// console.log('[RPG Companion] Music Player: Widget position:', $widget.offset());
// console.log('[RPG Companion] Music Player: Widget dimensions:', { width: $widget.width(), height: $widget.height() });
// console.log('[RPG Companion] Music Player: Widget CSS display:', $widget.css('display'));
// console.log('[RPG Companion] Music Player: Widget CSS visibility:', $widget.css('visibility'));
}
// Bind play button click
$('#rpg-chat-music-player .rpg-music-widget-play').on('click', function(e) {
e.stopPropagation();
openInSpotify(songData);
});
// Bind close button click
$('#rpg-chat-music-player .rpg-music-widget-close').on('click', function(e) {
e.stopPropagation();
$('#rpg-chat-music-player').fadeOut(200, function() {
$(this).remove();
});
});
// Clicking anywhere else on the widget also opens Spotify
$('#rpg-chat-music-player .rpg-music-widget-content').on('click', function() {
openInSpotify(songData);
});
}
/**
* Updates the music player display
* @param {HTMLElement} container - Container element
*/
export function updateMusicPlayer(container) {
renderMusicPlayer(container);
}
+87 -4
View File
@@ -3,8 +3,51 @@
* Handles UI rendering for quests system (main and optional quests)
*/
import { extensionSettings, $questsContainer } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { extensionSettings, $questsContainer, committedTrackerData, lastGeneratedData } from '../../core/state.js';
import { saveSettings, saveChatData } from '../../core/persistence.js';
import { isItemLocked, setItemLock } from '../generation/lockManager.js';
/**
* Syncs the current extensionSettings.quests to committedTrackerData.userStats
* This ensures quest changes made via UI are reflected in the data sent to AI
*/
function syncQuestsToCommittedData() {
const currentData = committedTrackerData.userStats || lastGeneratedData.userStats;
if (!currentData) return;
const trimmed = currentData.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
const jsonData = JSON.parse(currentData);
if (jsonData && typeof jsonData === 'object') {
// Update quests in the JSON data
jsonData.quests = extensionSettings.quests || { main: 'None', optional: [] };
const updatedJSON = JSON.stringify(jsonData, null, 2);
committedTrackerData.userStats = updatedJSON;
lastGeneratedData.userStats = updatedJSON;
}
} catch (e) {
console.warn('[RPG Quests] Failed to sync quests to committed data:', e);
}
}
}
/**
* Helper to generate lock icon HTML if setting is enabled
* @param {string} tracker - Tracker name
* @param {string} path - Item path
* @returns {string} Lock icon HTML or empty string
*/
function getLockIconHtml(tracker, path) {
const showLockIcons = extensionSettings.showLockIcons ?? true;
if (!showLockIcons) return '';
const isLocked = isItemLocked(tracker, path);
const lockIcon = isLocked ? '🔒' : '🔓';
const lockTitle = isLocked ? 'Locked' : 'Unlocked';
const lockedClass = isLocked ? ' locked' : '';
return `<span class="rpg-section-lock-icon${lockedClass}" data-tracker="${tracker}" data-path="${path}" title="${lockTitle}">${lockIcon}</span>`;
}
/**
* HTML escape helper
@@ -66,6 +109,7 @@ export function renderMainQuestView(mainQuest) {
</div>
</div>
<div class="rpg-quest-item" data-field="main">
${getLockIconHtml('userStats', 'quests.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">
@@ -111,8 +155,10 @@ export function renderOptionalQuestsView(optionalQuests) {
if (quests.length === 0) {
questsHtml = '<div class="rpg-quest-empty">No active optional quests</div>';
} else {
questsHtml = quests.map((quest, index) => `
questsHtml = quests.map((quest, index) => {
return `
<div class="rpg-quest-item" data-field="optional" data-index="${index}">
${getLockIconHtml('userStats', `quests.optional[${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">
@@ -120,7 +166,7 @@ export function renderOptionalQuestsView(optionalQuests) {
</button>
</div>
</div>
`).join('');
`}).join('');
}
return `
@@ -229,7 +275,10 @@ function attachQuestEventHandlers() {
}
extensionSettings.quests.optional.push(questTitle);
}
// Sync quest changes to committedTrackerData so AI sees the addition
syncQuestsToCommittedData();
saveSettings();
saveChatData();
renderQuests();
}
});
@@ -257,7 +306,10 @@ function attachQuestEventHandlers() {
if (questTitle) {
extensionSettings.quests.main = questTitle;
// Sync quest changes to committedTrackerData so AI sees the edit
syncQuestsToCommittedData();
saveSettings();
saveChatData();
renderQuests();
}
});
@@ -272,7 +324,10 @@ function attachQuestEventHandlers() {
} else {
extensionSettings.quests.optional.splice(index, 1);
}
// Sync quest changes to committedTrackerData so AI sees the removal
syncQuestsToCommittedData();
saveSettings();
saveChatData();
renderQuests();
});
@@ -285,7 +340,10 @@ function attachQuestEventHandlers() {
if (newTitle && field === 'optional' && index !== undefined) {
extensionSettings.quests.optional[index] = newTitle;
// Sync quest changes to committedTrackerData so AI sees the edit
syncQuestsToCommittedData();
saveSettings();
saveChatData();
}
});
@@ -303,4 +361,29 @@ function attachQuestEventHandlers() {
}
}
});
// Add event listener for section lock icon clicks (support both click and touch)
$questsContainer.find('.rpg-section-lock-icon').on('click touchend', function(e) {
e.preventDefault();
e.stopPropagation();
const $icon = $(this);
const trackerType = $icon.data('tracker');
const itemPath = $icon.data('path');
const currentlyLocked = isItemLocked(trackerType, itemPath);
// Toggle lock state
setItemLock(trackerType, itemPath, !currentlyLocked);
// Update icon
const newIcon = !currentlyLocked ? '🔒' : '🔓';
const newTitle = !currentlyLocked ? 'Locked' : 'Unlocked';
$icon.text(newIcon);
$icon.attr('title', newTitle);
// Toggle 'locked' class for persistent visibility
$icon.toggleClass('locked', !currentlyLocked);
// Save settings
saveSettings();
});
}
File diff suppressed because it is too large Load Diff
+227 -27
View File
@@ -19,6 +19,8 @@ import {
} from '../../core/persistence.js';
import { getSafeThumbnailUrl } from '../../utils/avatars.js';
import { buildInventorySummary } from '../generation/promptBuilder.js';
import { isItemLocked, setItemLock } from '../generation/lockManager.js';
import { updateFabWidgets } from '../ui/mobile.js';
/**
* Builds the user stats text string using custom stat names
@@ -67,6 +69,107 @@ export function buildUserStatsText() {
return text.trim();
}
/**
* Updates lastGeneratedData.userStats and committedTrackerData.userStats
* Maintains JSON format if current data is JSON, otherwise uses text format.
* @private
*/
function updateUserStatsData() {
// Check if current data is in JSON format
const currentData = lastGeneratedData.userStats || committedTrackerData.userStats;
if (currentData) {
const trimmed = currentData.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
// Maintain JSON format
try {
const jsonData = JSON.parse(currentData);
if (jsonData && typeof jsonData === 'object') {
const stats = extensionSettings.userStats;
const config = extensionSettings.trackerConfig?.userStats || {};
const enabledStats = config.customStats?.filter(stat => stat && stat.enabled && stat.name && stat.id) || [];
// Build stats array - include all stats from extensionSettings, not just enabled ones
// This preserves custom stats that AI might have added or that user has disabled
const statsArray = [];
const processedIds = new Set();
// First, add all enabled stats from config (maintains order)
enabledStats.forEach(stat => {
statsArray.push({
id: stat.id,
name: stat.name,
value: stats[stat.id] !== undefined ? stats[stat.id] : 100
});
processedIds.add(stat.id);
});
// Then, add any other numeric stats from extensionSettings that aren't in config
// (these could be custom stats the AI added or disabled stats)
const excludeFields = new Set(['mood', 'conditions', 'inventory', 'skills', 'level']);
Object.entries(stats).forEach(([key, value]) => {
if (!processedIds.has(key) && !excludeFields.has(key) && typeof value === 'number') {
statsArray.push({
id: key,
name: key.charAt(0).toUpperCase() + key.slice(1),
value: value
});
}
});
jsonData.stats = statsArray;
// Update status
jsonData.status = {
mood: stats.mood || '😐',
conditions: stats.conditions || 'None'
};
// Update inventory (convert to v3 format)
const convertToV3Items = (itemString) => {
if (!itemString) return [];
const items = itemString.split(',').map(s => s.trim()).filter(s => s);
return items.map(item => {
const qtyMatch = item.match(/^(\\d+)x\\s+(.+)$/);
if (qtyMatch) {
return { name: qtyMatch[2].trim(), quantity: parseInt(qtyMatch[1]) };
}
return { name: item, quantity: 1 };
});
};
jsonData.inventory = {
onPerson: convertToV3Items(stats.inventory?.onPerson),
clothing: convertToV3Items(stats.inventory?.clothing),
stored: stats.inventory?.stored || {},
assets: convertToV3Items(stats.inventory?.assets)
};
// Update quests
jsonData.quests = extensionSettings.quests || { main: '', optional: [] };
// Update skills if present
if (stats.skills) {
jsonData.skills = Array.isArray(stats.skills) ? stats.skills :
stats.skills.split(',').map(s => s.trim()).filter(s => s);
}
const updatedJSON = JSON.stringify(jsonData, null, 2);
lastGeneratedData.userStats = updatedJSON;
committedTrackerData.userStats = updatedJSON;
return;
}
} catch (e) {
console.warn('[RPG Companion] Failed to parse JSON, falling back to text format:', e);
}
}
}
// Fall back to text format
const statsText = buildUserStatsText();
lastGeneratedData.userStats = statsText;
committedTrackerData.userStats = statsText;
}
/**
* Renders the user stats panel with health bars, mood, inventory, and classic stats.
* Includes event listeners for editable fields.
@@ -77,7 +180,36 @@ export function renderUserStats() {
return;
}
// Don't render if no data exists (e.g., after cache clear)
// Check both lastGeneratedData and committedTrackerData
// console.log('[RPG UserStats Render] Checking data:', {
// hasLastGenerated: !!lastGeneratedData.userStats,
// hasCommitted: !!committedTrackerData.userStats,
// lastGeneratedPreview: lastGeneratedData.userStats ? lastGeneratedData.userStats.substring(0, 100) : 'null',
// committedPreview: committedTrackerData.userStats ? committedTrackerData.userStats.substring(0, 100) : 'null'
// });
if (!lastGeneratedData.userStats && !committedTrackerData.userStats) {
// Always render to the #rpg-user-stats container (mobile layout just moves it around in DOM)
$userStatsContainer.html('<div class="rpg-inventory-empty">No statuses generated yet</div>');
return;
}
// Use lastGeneratedData if available, otherwise fall back to committed data
if (!lastGeneratedData.userStats && committedTrackerData.userStats) {
lastGeneratedData.userStats = committedTrackerData.userStats;
}
const stats = extensionSettings.userStats;
// console.log('[RPG UserStats Render] Current extensionSettings.userStats:', {
// health: stats.health,
// satiety: stats.satiety,
// energy: stats.energy,
// hygiene: stats.hygiene,
// arousal: stats.arousal,
// mood: stats.mood,
// conditions: stats.conditions
// });
const config = extensionSettings.trackerConfig?.userStats || {
customStats: [
{ id: 'health', name: 'Health', enabled: true },
@@ -116,20 +248,32 @@ export function renderUserStats() {
// Create gradient from low to high color
const gradient = `linear-gradient(to right, ${extensionSettings.statBarColorLow}, ${extensionSettings.statBarColorHigh})`;
let html = '<div class="rpg-stats-content"><div class="rpg-stats-left">';
// Check if stats bars section is locked
const isStatsLocked = isItemLocked('userStats', 'stats');
const lockIcon = isStatsLocked ? '🔒' : '🔓';
const lockTitle = isStatsLocked ? 'Locked - AI cannot change stats' : 'Unlocked - AI can change stats';
const lockedClass = isStatsLocked ? ' locked' : '';
let html = '<div class="rpg-stats-content">';
html += '<div class="rpg-stats-left">';
// User info row
const showLevel = extensionSettings.trackerConfig?.userStats?.showLevel !== false;
html += `
<div class="rpg-user-info-row">
<img src="${userPortrait}" alt="${userName}" class="rpg-user-portrait" onerror="this.style.opacity='0.5';this.onerror=null;" />
<span class="rpg-user-name">${userName}</span>
<span style="opacity: 0.5;">|</span>
${showLevel ? `<span style="opacity: 0.5;">|</span>
<span class="rpg-level-label">LVL</span>
<span class="rpg-level-value rpg-editable" contenteditable="true" data-field="level" title="Click to edit level">${extensionSettings.level}</span>
<span class="rpg-level-value rpg-editable" contenteditable="true" data-field="level" title="Click to edit level">${extensionSettings.level}</span>` : ''}
</div>
`;
// Dynamic stats grid - only show enabled stats
const showLockIcons = extensionSettings.showLockIcons ?? true;
if (showLockIcons) {
html += `<span class="rpg-section-lock-icon${lockedClass}" data-tracker="userStats" data-path="stats" title="${lockTitle}">${lockIcon}</span>`;
}
html += '<div class="rpg-stats-grid">';
const enabledStats = config.customStats.filter(stat => stat && stat.enabled && stat.name && stat.id);
@@ -149,7 +293,14 @@ export function renderUserStats() {
// Status section (conditionally rendered)
if (config.statusSection.enabled) {
const isMoodLocked = isItemLocked('userStats', 'status');
const moodLockIcon = isMoodLocked ? '🔒' : '🔓';
const moodLockTitle = isMoodLocked ? 'Locked - AI cannot change mood' : 'Unlocked - AI can change mood';
const moodLockedClass = isMoodLocked ? ' locked' : '';
html += '<div class="rpg-mood">';
if (showLockIcons) {
html += `<span class="rpg-section-lock-icon${moodLockedClass}" data-tracker="userStats" data-path="status" title="${moodLockTitle}">${moodLockIcon}</span>`;
}
if (config.statusSection.showMoodEmoji) {
html += `<div class="rpg-mood-emoji rpg-editable" contenteditable="true" data-field="mood" title="Click to edit emoji">${stats.mood}</div>`;
@@ -158,7 +309,11 @@ export function renderUserStats() {
// Render custom status fields
if (config.statusSection.customFields && config.statusSection.customFields.length > 0) {
// For now, use first field as "conditions" for backward compatibility
const conditionsValue = stats.conditions || 'None';
let conditionsValue = stats.conditions || 'None';
// Strip brackets if present (from JSON array format)
if (typeof conditionsValue === 'string') {
conditionsValue = conditionsValue.replace(/^\[|\]$/g, '').trim();
}
html += `<div class="rpg-mood-conditions rpg-editable" contenteditable="true" data-field="conditions" title="Click to edit conditions">${conditionsValue}</div>`;
}
@@ -167,9 +322,24 @@ export function renderUserStats() {
// Skills section (conditionally rendered)
if (config.skillsSection.enabled) {
const skillsValue = stats.skills || 'None';
const isSkillsLocked = isItemLocked('userStats', 'skills');
const skillsLockIcon = isSkillsLocked ? '🔒' : '🔓';
const skillsLockTitle = isSkillsLocked ? 'Locked - AI cannot change skills' : 'Unlocked - AI can change skills';
const skillsLockedClass = isSkillsLocked ? ' locked' : '';
let skillsValue = 'None';
// Handle JSON array format: [{name: "Art"}, {name: "Coding"}]
if (Array.isArray(stats.skills)) {
skillsValue = stats.skills.map(s => s.name || s).join(', ') || 'None';
} else if (stats.skills) {
skillsValue = stats.skills;
}
html += `
<div class="rpg-skills-section">`;
if (showLockIcons) {
html += `
<span class="rpg-section-lock-icon${skillsLockedClass}" data-tracker="userStats" data-path="skills" title="${skillsLockTitle}">${skillsLockIcon}</span>`;
}
html += `
<div class="rpg-skills-section">
<span class="rpg-skills-label">${config.skillsSection.label}:</span>
<div class="rpg-skills-value rpg-editable" contenteditable="true" data-field="skills" title="Click to edit skills">${skillsValue}</div>
</div>
@@ -225,7 +395,13 @@ export function renderUserStats() {
html += '</div>'; // Close rpg-stats-content
// console.log('[RPG UserStats Render] Generated HTML length:', html.length);
// console.log('[RPG UserStats Render] HTML preview:', html.substring(0, 300));
// console.log('[RPG UserStats Render] Container exists:', !!$userStatsContainer, '$userStatsContainer length:', $userStatsContainer?.length);
// Always render to the #rpg-user-stats container (mobile layout just moves it around in DOM)
$userStatsContainer.html(html);
// console.log('[RPG UserStats Render] ✓ HTML rendered to #rpg-user-stats container');
// Add event listeners for editable stat values
$('.rpg-editable-stat').on('blur', function() {
@@ -242,20 +418,16 @@ export function renderUserStats() {
// Update the setting
extensionSettings.userStats[field] = value;
// Rebuild userStats text with custom stat names
const statsText = buildUserStatsText();
// Update BOTH lastGeneratedData AND committedTrackerData
// This makes manual edits immediately visible to AI
lastGeneratedData.userStats = statsText;
committedTrackerData.userStats = statsText;
// Update userStats data (maintains JSON or text format)
updateUserStatsData();
saveSettings();
saveChatData();
updateMessageSwipeData();
// Re-render to update the bar
// Re-render to update the bar and FAB widgets
renderUserStats();
updateFabWidgets();
});
// Add event listeners for mood/conditions editing
@@ -263,13 +435,8 @@ export function renderUserStats() {
const value = $(this).text().trim();
extensionSettings.userStats.mood = value || '😐';
// Rebuild userStats text with custom stat names
const statsText = buildUserStatsText();
// Update BOTH lastGeneratedData AND committedTrackerData
// This makes manual edits immediately visible to AI
lastGeneratedData.userStats = statsText;
committedTrackerData.userStats = statsText;
// Update userStats data (maintains JSON or text format)
updateUserStatsData();
saveSettings();
saveChatData();
@@ -280,13 +447,21 @@ export function renderUserStats() {
const value = $(this).text().trim();
extensionSettings.userStats.conditions = value || 'None';
// Rebuild userStats text with custom stat names
const statsText = buildUserStatsText();
// Update userStats data (maintains JSON or text format)
updateUserStatsData();
// Update BOTH lastGeneratedData AND committedTrackerData
// This makes manual edits immediately visible to AI
lastGeneratedData.userStats = statsText;
committedTrackerData.userStats = statsText;
saveSettings();
saveChatData();
updateMessageSwipeData();
});
// Add event listener for skills editing
$('.rpg-skills-value.rpg-editable').on('blur', function() {
const value = $(this).text().trim();
extensionSettings.userStats.skills = value || 'None';
// Update userStats data (maintains JSON or text format)
updateUserStatsData();
saveSettings();
saveChatData();
@@ -342,4 +517,29 @@ export function renderUserStats() {
$(this).blur();
}
});
// Add event listener for section lock icon clicks (support both click and touch)
$('.rpg-section-lock-icon').on('click touchend', function(e) {
e.preventDefault();
e.stopPropagation();
const $icon = $(this);
const trackerType = $icon.data('tracker');
const itemPath = $icon.data('path');
const currentlyLocked = isItemLocked(trackerType, itemPath);
// Toggle lock state
setItemLock(trackerType, itemPath, !currentlyLocked);
// Update icon
const newIcon = !currentlyLocked ? '🔒' : '🔓';
const newTitle = !currentlyLocked ? 'Locked - AI cannot change this section' : 'Unlocked - AI can change this section';
$icon.text(newIcon);
$icon.attr('title', newTitle);
// Toggle 'locked' class for persistent visibility
$icon.toggleClass('locked', !currentlyLocked);
// Save settings
saveSettings();
});
}
+369
View File
@@ -0,0 +1,369 @@
/**
* Chapter Checkpoint UI Module
* Adds UI elements for chapter checkpoint functionality
*/
import { getContext } from '../../../../../../extensions.js';
import { i18n } from '../../core/i18n.js';
import {
setChapterCheckpoint,
clearChapterCheckpoint,
isCheckpointMessage
} from '../features/chapterCheckpoint.js';
/**
* Adds the chapter checkpoint button to a message's extra menu
* @param {number} messageId - The message index
* @param {HTMLElement} menu - The message menu element
* @param {boolean} isExpanded - Whether this is for expanded message actions
*/
export function addCheckpointButtonToMessage(messageId, menu, isExpanded = false) {
if (!menu) return;
const isCheckpoint = isCheckpointMessage(messageId);
// Create the menu item
const menuItem = document.createElement('div');
// Use different classes for expanded vs dropdown menu
if (isExpanded) {
menuItem.className = 'mes_button';
menuItem.setAttribute('tabindex', '0');
} else {
menuItem.className = 'extraMesButtonsHint list-group-item flex-container flexGap5';
}
const translationKey = isCheckpoint ? 'checkpoint.clearChapterStart' : 'checkpoint.setChapterStart';
menuItem.setAttribute('data-i18n', translationKey);
menuItem.title = isCheckpoint
? 'Clear Chapter Start'
: 'Set Chapter Start — When bookmarked, this message will count as the first message in the chat history, skipping earlier ones.';
// Icon only (no text label)
const icon = document.createElement('i');
icon.className = isCheckpoint ? 'fa-solid fa-bookmark' : 'fa-regular fa-bookmark';
icon.style.color = isCheckpoint ? '#4a9eff' : '';
menuItem.appendChild(icon);
// Click handler
menuItem.addEventListener('click', (e) => {
e.stopPropagation();
const wasCheckpoint = isCheckpointMessage(messageId);
if (wasCheckpoint) {
clearChapterCheckpoint();
} else {
setChapterCheckpoint(messageId);
}
// Update this button immediately
const newIsCheckpoint = isCheckpointMessage(messageId);
icon.className = newIsCheckpoint ? 'fa-solid fa-bookmark' : 'fa-regular fa-bookmark';
icon.style.color = newIsCheckpoint ? '#4a9eff' : '';
menuItem.title = newIsCheckpoint
? 'Clear Chapter Start'
: 'Set Chapter Start: When bookmarked, this message will count as the first message in the chat history, skipping earlier ones';
const newTranslationKey = newIsCheckpoint ? 'checkpoint.clearChapterStart' : 'checkpoint.setChapterStart';
menuItem.setAttribute('data-i18n', newTranslationKey);
// Update indicators in all messages
updateAllCheckpointIndicators();
});
return menuItem;
}
/**
* Adds visual indicators to messages that are checkpoints
* @param {number} messageId - The message index
* @param {HTMLElement} messageBlock - The message DOM element
*/
export function addCheckpointIndicator(messageId, messageBlock) {
if (!messageBlock) return;
const isCheckpoint = isCheckpointMessage(messageId);
// Remove existing indicator if present
const existingIndicator = messageBlock.querySelector('.rpg-checkpoint-indicator');
if (existingIndicator) {
existingIndicator.remove();
}
if (!isCheckpoint) return;
// Add checkpoint indicator
const indicator = document.createElement('div');
indicator.className = 'rpg-checkpoint-indicator';
const indicatorText = i18n.getTranslation('checkpoint.indicator') || 'Chapter Start';
const tooltipText = i18n.getTranslation('checkpoint.tooltip') || 'Messages before this point are excluded from context';
indicator.innerHTML = `
<i class="fa-solid fa-bookmark"></i>
<span>${indicatorText}</span>
`;
indicator.title = tooltipText;
// Insert at the beginning of the message
const mesText = messageBlock.querySelector('.mes_text');
if (mesText && mesText.parentNode) {
mesText.parentNode.insertBefore(indicator, mesText);
}
}
/**
* Updates checkpoint indicators for all messages
*/
export function updateAllCheckpointIndicators() {
const context = getContext();
const chat = context.chat;
if (!chat) return;
// First, remove ALL checkpoint buttons from everywhere
document.querySelectorAll('.rpg-checkpoint-button, .rpg-checkpoint-button-expanded').forEach(btn => btn.remove());
// Update all message blocks
const messageBlocks = document.querySelectorAll('.mes');
messageBlocks.forEach((block) => {
// Get the actual message ID from the mesid attribute
const messageId = Number(block.getAttribute('mesid'));
if (isNaN(messageId)) return;
addCheckpointIndicator(messageId, block);
// Re-add buttons based on current mode
processExpandedButton(block);
const dropdownMenu = block.querySelector('.extraMesButtons');
if (dropdownMenu) {
processExtraMesButtons(dropdownMenu);
}
});
}
/**
* Removes all checkpoint UI elements
*/
export function cleanupCheckpointUI() {
// Remove all checkpoint buttons
document.querySelectorAll('.rpg-checkpoint-button, .rpg-checkpoint-button-expanded').forEach(btn => btn.remove());
// Remove all checkpoint indicators (banner)
document.querySelectorAll('.rpg-checkpoint-indicator').forEach(indicator => indicator.remove());
}
/**
* Initializes the chapter checkpoint UI
*/
export function initChapterCheckpointUI() {
// Listen for checkpoint changes
document.addEventListener('rpg-companion-checkpoint-changed', () => {
updateAllCheckpointIndicators();
});
// Listen for expandMessageActions class changes on body
const bodyObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
// The expandMessageActions class was toggled, refresh all buttons
updateAllCheckpointIndicators();
}
});
});
bodyObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] });
// Listen for chat changes to update indicators
const context = getContext();
if (context && context.eventSource) {
// Update checkpoint indicators when messages are rendered
const observer = new MutationObserver((mutations) => {
let shouldUpdate = false;
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE &&
node.classList && node.classList.contains('mes')) {
shouldUpdate = true;
}
});
});
if (shouldUpdate) {
// Debounce updates to avoid excessive re-rendering
clearTimeout(window.rpgCheckpointUpdateTimeout);
window.rpgCheckpointUpdateTimeout = setTimeout(() => {
updateAllCheckpointIndicators();
}, 100);
}
});
const chatContainer = document.getElementById('chat');
if (chatContainer) {
observer.observe(chatContainer, {
childList: true,
subtree: false
});
}
}
// Update indicators on initialization
updateAllCheckpointIndicators();
}
/**
* Injects checkpoint button into message menus
* This should be called when SillyTavern renders message menus
*/
export function injectCheckpointButton() {
// Observer for dropdown menus and message blocks
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
// Check for added nodes
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
// Check if extraMesButtons container was added (dropdown menu)
if (node.classList && node.classList.contains('extraMesButtons')) {
processExtraMesButtons(node);
}
// Check if message block was added (for expanded buttons)
if (node.classList && node.classList.contains('mes')) {
processExpandedButton(node);
}
// Also check if any exist within added subtree
if (node.querySelector) {
const extraButtons = node.querySelectorAll('.extraMesButtons');
extraButtons.forEach(processExtraMesButtons);
const messageBlocks = node.querySelectorAll('.mes');
messageBlocks.forEach(processExpandedButton);
}
}
});
// Check if nodes were added TO an extraMesButtons container
if (mutation.target && mutation.target.classList &&
mutation.target.classList.contains('extraMesButtons')) {
processExtraMesButtons(mutation.target);
}
});
});
// Observe the chat container
const chatContainer = document.getElementById('chat');
if (chatContainer) {
observer.observe(chatContainer, {
childList: true,
subtree: true
});
// Process any existing dropdown menus and messages on initialization
// Use setTimeout to ensure styles are computed
setTimeout(() => {
const existingDropdownMenus = chatContainer.querySelectorAll('.extraMesButtons');
existingDropdownMenus.forEach(processExtraMesButtons);
const existingMessages = chatContainer.querySelectorAll('.mes');
existingMessages.forEach(processExpandedButton);
}, 100);
}
}
/**
* Process an extraMesButtons container to add checkpoint button (dropdown menu)
* @param {HTMLElement} menu - The extraMesButtons container
*/
function processExtraMesButtons(menu) {
if (!menu) return;
// Find the message block
const messageBlock = menu.closest('.mes');
if (!messageBlock) return;
// Get the message ID from the mesid attribute (SillyTavern's standard way)
const messageId = Number(messageBlock.getAttribute('mesid'));
if (isNaN(messageId)) return;
// Check if expanded mode is active - if so, skip dropdown
if (document.body.classList.contains('expandMessageActions')) {
return; // Expanded mode is ON, button will be added to mes_buttons instead
}
// Check if button already exists in this container
if (menu.querySelector('.rpg-checkpoint-button')) return;
// Add checkpoint button to dropdown menu
const checkpointBtn = addCheckpointButtonToMessage(messageId, menu, false);
if (checkpointBtn) {
checkpointBtn.classList.add('rpg-checkpoint-button');
menu.appendChild(checkpointBtn);
}
}
/**
* Process a message block to add expanded checkpoint button
* @param {HTMLElement} messageBlock - The message block element
*/
function processExpandedButton(messageBlock) {
if (!messageBlock) return;
const mesButtons = messageBlock.querySelector('.mes_buttons');
if (!mesButtons) return;
// Only add if expanded mode is ON (check body class)
if (!document.body.classList.contains('expandMessageActions')) {
return; // Expanded mode is OFF, button will be in dropdown instead
}
const messageId = Number(messageBlock.getAttribute('mesid'));
if (isNaN(messageId)) return;
// Check if button already exists in this container
if (mesButtons.querySelector('.rpg-checkpoint-button-expanded')) return;
// Add checkpoint button as separate mes_button
const checkpointBtn = addCheckpointButtonToMessage(messageId, mesButtons, true);
if (checkpointBtn) {
checkpointBtn.classList.add('rpg-checkpoint-button-expanded');
// Insert before the edit button if it exists, otherwise append
const editButton = mesButtons.querySelector('.mes_edit');
if (editButton) {
mesButtons.insertBefore(checkpointBtn, editButton);
} else {
mesButtons.appendChild(checkpointBtn);
}
}
}
/**
* Update the checkpoint button in an existing menu
* @param {HTMLElement} menu - The extraMesButtons or mes_buttons container
* @param {number} messageId - The message index
*/
function updateCheckpointButtonInMenu(menu, messageId) {
if (!menu) return;
// Find the checkpoint button (either dropdown or expanded)
const existingButton = menu.querySelector('.rpg-checkpoint-button, .rpg-checkpoint-button-expanded');
if (!existingButton) return;
const isCheckpoint = isCheckpointMessage(messageId);
// Update icon
const icon = existingButton.querySelector('i');
if (icon) {
icon.className = isCheckpoint ? 'fa-solid fa-bookmark' : 'fa-regular fa-bookmark';
icon.style.color = isCheckpoint ? '#4a9eff' : '';
}
// Update tooltip
existingButton.title = isCheckpoint
? 'Clear Chapter Start'
: 'Set Chapter Start — When bookmarked, this message will count as the first message in the chat history, skipping earlier ones.';
const translationKey = isCheckpoint ? 'checkpoint.clearChapterStart' : 'checkpoint.setChapterStart';
existingButton.setAttribute('data-i18n', translationKey);
}
-220
View File
@@ -1,220 +0,0 @@
/**
* Debug UI Module
* Provides mobile-friendly debug log viewer for troubleshooting parsing issues
*/
import { extensionSettings, getDebugLogs, clearDebugLogs } from '../../core/state.js';
/**
* Creates and injects the debug panel into the page
* Note: Debug toggle button is created in index.js, not here
*/
export function createDebugPanel() {
// Remove existing debug panel if any
$('#rpg-debug-panel').remove();
// Create debug panel HTML
const debugPanelHtml = `
<div id="rpg-debug-panel" class="rpg-debug-panel">
<div class="rpg-debug-header">
<h3>🔍 Debug Logs</h3>
<div class="rpg-debug-actions">
<button id="rpg-debug-copy" title="Copy logs to clipboard">
<i class="fa-solid fa-copy"></i>
</button>
<button id="rpg-debug-clear" title="Clear logs">
<i class="fa-solid fa-trash"></i>
</button>
<button id="rpg-debug-close" title="Close debug panel">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
<div id="rpg-debug-logs" class="rpg-debug-logs"></div>
</div>
`;
// Append to body
$('body').append(debugPanelHtml);
// Set up event handlers
setupDebugEventHandlers();
// Initial log render
renderDebugLogs();
}
/**
* Closes the debug panel with proper animation (mobile or desktop)
*/
function closeDebugPanel() {
const $panel = $('#rpg-debug-panel');
const isMobile = window.innerWidth <= 1000;
if (isMobile) {
// Mobile: animate slide-out to right
$panel.removeClass('rpg-mobile-open').addClass('rpg-mobile-closing');
// Wait for animation to complete before hiding
$panel.one('animationend', function() {
$panel.removeClass('rpg-mobile-closing');
$('.rpg-mobile-overlay').remove();
});
} else {
// Desktop: simple slide-down
$panel.removeClass('rpg-debug-open');
}
}
/**
* Sets up event handlers for debug panel using event delegation for mobile compatibility
*/
function setupDebugEventHandlers() {
// Use event delegation for better mobile compatibility and reliability with dynamic elements
// Remove any existing handlers first to prevent duplicates
$(document).off('click.rpgDebug');
// Toggle button
$(document).on('click.rpgDebug', '#rpg-debug-toggle', function() {
const $debugToggle = $(this);
// Skip if we just finished dragging
if ($debugToggle.data('just-dragged')) {
console.log('[RPG Debug] Click blocked - just finished dragging');
return;
}
const $panel = $('#rpg-debug-panel');
const isMobile = window.innerWidth <= 1000;
if (isMobile) {
// Mobile: use rpg-mobile-open class with slide-from-right animation
const isOpen = $panel.hasClass('rpg-mobile-open');
if (isOpen) {
// Close with animation
closeDebugPanel();
} else {
// Open with animation
$panel.addClass('rpg-mobile-open');
renderDebugLogs();
// Create overlay for mobile
const $overlay = $('<div class="rpg-mobile-overlay"></div>');
$('body').append($overlay);
// Close when clicking overlay
$overlay.on('click', function() {
closeDebugPanel();
});
}
} else {
// Desktop: use rpg-debug-open class with slide-from-bottom animation
$panel.toggleClass('rpg-debug-open');
renderDebugLogs();
}
});
// Close button
$(document).on('click.rpgDebug', '#rpg-debug-close', function(e) {
e.preventDefault();
e.stopPropagation();
closeDebugPanel();
});
// Copy button
$(document).on('click.rpgDebug', '#rpg-debug-copy', function() {
const logs = getDebugLogs();
const logsText = logs.map(log => {
let text = `[${log.timestamp}] ${log.message}`;
if (log.data) {
text += `\n${log.data}`;
}
return text;
}).join('\n\n');
navigator.clipboard.writeText(logsText).then(() => {
// Show feedback
const $btn = $(this);
const $icon = $btn.find('i');
$icon.removeClass('fa-copy').addClass('fa-check');
setTimeout(() => {
$icon.removeClass('fa-check').addClass('fa-copy');
}, 1500);
}).catch(err => {
console.error('Failed to copy logs:', err);
alert('Failed to copy logs. Please use browser console instead.');
});
});
// Clear button
$(document).on('click.rpgDebug', '#rpg-debug-clear', function() {
if (confirm('Clear all debug logs?')) {
clearDebugLogs();
renderDebugLogs();
}
});
}
/**
* Renders debug logs to the panel
*/
function renderDebugLogs() {
const logs = getDebugLogs();
const $logsContainer = $('#rpg-debug-logs');
if (logs.length === 0) {
$logsContainer.html('<div class="rpg-debug-empty">No logs yet. Logs will appear when parser runs.</div>');
return;
}
// Build logs HTML
const logsHtml = logs.map(log => {
let html = `<div class="rpg-debug-entry">`;
html += `<span class="rpg-debug-time">[${log.timestamp}]</span> `;
html += `<span class="rpg-debug-message">${escapeHtml(log.message)}</span>`;
if (log.data) {
html += `<pre class="rpg-debug-data">${escapeHtml(log.data)}</pre>`;
}
html += `</div>`;
return html;
}).join('');
$logsContainer.html(logsHtml);
// Auto-scroll to bottom
$logsContainer[0].scrollTop = $logsContainer[0].scrollHeight;
}
/**
* Escapes HTML to prevent XSS
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Shows or hides debug UI based on debug mode setting
* Note: Debug toggle button always exists in DOM (created in index.js)
*/
export function updateDebugUIVisibility() {
const $debugToggle = $('#rpg-debug-toggle');
if (extensionSettings.debugMode) {
// Show debug toggle button
$debugToggle.css('display', 'flex');
// Create debug panel if it doesn't exist
if ($('#rpg-debug-panel').length === 0) {
createDebugPanel();
}
} else {
// Hide debug toggle button
$debugToggle.css('display', 'none');
// Remove debug panel
$('#rpg-debug-panel').remove();
}
}
+56 -24
View File
@@ -3,6 +3,9 @@
* Handles desktop-specific UI functionality: tab navigation
*/
import { i18n } from '../../core/i18n.js';
import { extensionSettings } from '../../core/state.js';
/**
* Sets up desktop tab navigation for organizing content.
* Only runs on desktop viewports (>1000px).
@@ -29,23 +32,40 @@ export function setupDesktopTabs() {
return;
}
// Create tab navigation
const $tabNav = $(`
<div class="rpg-tabs-nav">
<button class="rpg-tab-btn active" data-tab="status">
<i class="fa-solid fa-chart-simple"></i>
<span>Status</span>
</button>
// Build tab navigation dynamically based on enabled settings
const tabButtons = [];
const hasInventory = $inventory.length > 0 && extensionSettings.showInventory;
const hasQuests = $quests.length > 0 && extensionSettings.showQuests;
// Status tab (always present if any status content exists)
tabButtons.push(`
<button class="rpg-tab-btn active" data-tab="status">
<i class="fa-solid fa-chart-simple"></i>
<span data-i18n-key="global.status">Status</span>
</button>
`);
// Inventory tab (only if enabled in settings)
if (hasInventory) {
tabButtons.push(`
<button class="rpg-tab-btn" data-tab="inventory">
<i class="fa-solid fa-box"></i>
<span>Inventory</span>
<span data-i18n-key="global.inventory">Inventory</span>
</button>
`);
}
// Quests tab (only if enabled in settings)
if (hasQuests) {
tabButtons.push(`
<button class="rpg-tab-btn" data-tab="quests">
<i class="fa-solid fa-scroll"></i>
<span>Quests</span>
<span data-i18n-key="global.quests">Quests</span>
</button>
</div>
`);
`);
}
const $tabNav = $(`<div class="rpg-tabs-nav">${tabButtons.join('')}</div>`);
// Create tab content containers
const $statusTab = $('<div class="rpg-tab-content active" data-tab-content="status"></div>');
@@ -55,23 +75,29 @@ export function setupDesktopTabs() {
// Move sections into their respective tabs (detach to preserve event handlers)
if ($userStats.length > 0) {
$statusTab.append($userStats.detach());
$userStats.show();
if (extensionSettings.showUserStats) $userStats.show();
}
if ($infoBox.length > 0) {
$statusTab.append($infoBox.detach());
$infoBox.show();
// Only show if enabled and has data
if (extensionSettings.showInfoBox) {
const infoBoxData = window.lastGeneratedData?.infoBox || window.committedTrackerData?.infoBox;
if (infoBoxData) $infoBox.show();
}
}
if ($thoughts.length > 0) {
$statusTab.append($thoughts.detach());
$thoughts.show();
if (extensionSettings.showCharacterThoughts) $thoughts.show();
}
if ($inventory.length > 0) {
$inventoryTab.append($inventory.detach());
$inventory.show();
// Only show if enabled (will be part of tab structure)
if (hasInventory) $inventory.show();
}
if ($quests.length > 0) {
$questsTab.append($quests.detach());
$quests.show();
// Only show if enabled (will be part of tab structure)
if (hasQuests) $quests.show();
}
// Hide dividers on desktop tabs (tabs separate content naturally)
@@ -81,11 +107,15 @@ export function setupDesktopTabs() {
const $tabsContainer = $('<div class="rpg-tabs-container"></div>');
$tabsContainer.append($tabNav);
$tabsContainer.append($statusTab);
// Always append inventory and quests tabs to preserve the elements
// But they'll only show if enabled (via tab button visibility)
$tabsContainer.append($inventoryTab);
$tabsContainer.append($questsTab);
// Replace content box with tabs container
$contentBox.html('').append($tabsContainer);
i18n.applyTranslations($tabsContainer[0]);
// Handle tab switching
$tabNav.find('.rpg-tab-btn').on('click', function() {
@@ -100,7 +130,7 @@ export function setupDesktopTabs() {
$(`.rpg-tab-content[data-tab-content="${tabName}"]`).addClass('active');
});
console.log('[RPG Desktop] Desktop tabs initialized');
}
/**
@@ -142,12 +172,14 @@ export function removeDesktopTabs() {
$contentBox.append($quests);
}
// Show sections and dividers
$userStats.show();
$infoBox.show();
$thoughts.show();
$inventory.show();
// Show/hide sections based on settings (respect visibility settings)
if (extensionSettings.showUserStats) $userStats.show();
if (extensionSettings.showInfoBox) {
const infoBoxData = window.lastGeneratedData?.infoBox || window.committedTrackerData?.infoBox;
if (infoBoxData) $infoBox.show();
}
if (extensionSettings.showCharacterThoughts) $thoughts.show();
if (extensionSettings.showInventory) $inventory.show();
if (extensionSettings.showQuests) $quests.show();
$('.rpg-divider').show();
console.log('[RPG Desktop] Desktop tabs removed');
}
File diff suppressed because it is too large Load Diff
+244 -67
View File
@@ -9,14 +9,51 @@ import {
$userStatsContainer,
$infoBoxContainer,
$thoughtsContainer,
$inventoryContainer
$inventoryContainer,
$questsContainer,
$musicPlayerContainer,
setInventoryContainer,
setQuestsContainer,
lastGeneratedData,
committedTrackerData
} from '../../core/state.js';
import { i18n } from '../../core/i18n.js';
import { setupMobileTabs, removeMobileTabs } from './mobile.js';
import { setupDesktopTabs, removeDesktopTabs } from './desktop.js';
/**
* Toggles the visibility of plot buttons based on settings.
*/
export function togglePlotButtons() {
if (extensionSettings.enablePlotButtons && extensionSettings.enabled) {
if (!extensionSettings.enabled) {
$('#rpg-plot-buttons').hide();
return;
}
// Show/hide randomized plot button based on enableRandomizedPlot setting
if (extensionSettings.enableRandomizedPlot) {
$('#rpg-plot-random').show();
} else {
$('#rpg-plot-random').hide();
}
// Show/hide natural plot button based on enableNaturalPlot setting
if (extensionSettings.enableNaturalPlot) {
$('#rpg-plot-natural').show();
} else {
$('#rpg-plot-natural').hide();
}
// Show/hide encounter button independently based on encounter settings
if (extensionSettings.encounterSettings?.enabled) {
$('#rpg-encounter-button').show();
} else {
$('#rpg-encounter-button').hide();
}
// Show the container if at least one button is visible
const shouldShowContainer = extensionSettings.enableRandomizedPlot || extensionSettings.enableNaturalPlot || extensionSettings.encounterSettings?.enabled;
if (shouldShowContainer) {
$('#rpg-plot-buttons').show();
} else {
$('#rpg-plot-buttons').hide();
@@ -51,19 +88,34 @@ export function updateCollapseToggleIcon() {
const isMobile = window.innerWidth <= 1000;
if (isMobile) {
// Mobile: slides from right, use same icon logic as desktop right panel
// Mobile: icon direction based on panel position and open state
const isOpen = $panel.hasClass('rpg-mobile-open');
console.log('[RPG Mobile] updateCollapseToggleIcon:', {
isMobile: true,
isOpen,
settingIcon: isOpen ? 'chevron-left' : 'chevron-right'
});
if (isOpen) {
// Panel open - chevron points left (to close/slide back right)
$icon.removeClass('fa-chevron-down fa-chevron-up fa-chevron-right').addClass('fa-chevron-left');
const isLeftPanel = $panel.hasClass('rpg-position-left');
// console.log('[RPG Mobile] updateCollapseToggleIcon:', {
// isMobile: true,
// isOpen,
// isLeftPanel,
// settingIcon: isOpen ? (isLeftPanel ? 'chevron-left' : 'chevron-right') : (isLeftPanel ? 'chevron-right' : 'chevron-left')
// });
if (isLeftPanel) {
if (isOpen) {
// Left panel open - chevron points left (panel will slide left to close)
$icon.removeClass('fa-chevron-down fa-chevron-up fa-chevron-right').addClass('fa-chevron-left');
} else {
// Left panel closed - chevron points left (panel is hidden on left)
$icon.removeClass('fa-chevron-down fa-chevron-up fa-chevron-right').addClass('fa-chevron-left');
}
} else {
// Panel closed - chevron points right (to open/slide in from right)
$icon.removeClass('fa-chevron-down fa-chevron-up fa-chevron-left').addClass('fa-chevron-right');
// Right panel (default)
if (isOpen) {
// Right panel open - chevron points right (panel will slide right to close)
$icon.removeClass('fa-chevron-down fa-chevron-up fa-chevron-left').addClass('fa-chevron-right');
} else {
// Right panel closed - chevron points right (panel is hidden on right)
$icon.removeClass('fa-chevron-down fa-chevron-up fa-chevron-left').addClass('fa-chevron-right');
}
}
} else {
// Desktop: icon direction based on panel position and collapsed state
@@ -92,6 +144,7 @@ export function updateCollapseToggleIcon() {
*/
export function setupCollapseToggle() {
const $collapseToggle = $('#rpg-collapse-toggle');
$collapseToggle.attr('title', i18n.getTranslation('template.mainPanel.collapseExpand'));
const $panel = $('#rpg-companion-panel');
const $icon = $collapseToggle.find('i');
@@ -104,44 +157,44 @@ export function setupCollapseToggle() {
// On mobile: button toggles panel open/closed (same as desktop behavior)
if (isMobile) {
const isOpen = $panel.hasClass('rpg-mobile-open');
console.log('[RPG Mobile] Collapse toggle clicked. Current state:', {
isOpen,
panelClasses: $panel.attr('class'),
inlineStyles: $panel.attr('style'),
panelPosition: {
top: $panel.css('top'),
bottom: $panel.css('bottom'),
transform: $panel.css('transform'),
visibility: $panel.css('visibility')
}
});
// console.log('[RPG Mobile] Collapse toggle clicked. Current state:', {
// isOpen,
// panelClasses: $panel.attr('class'),
// inlineStyles: $panel.attr('style'),
// panelPosition: {
// top: $panel.css('top'),
// bottom: $panel.css('bottom'),
// transform: $panel.css('transform'),
// visibility: $panel.css('visibility')
// }
// });
if (isOpen) {
// Close panel with animation
console.log('[RPG Mobile] Closing panel');
// console.log('[RPG Mobile] Closing panel');
closeMobilePanelWithAnimation();
} else {
// Open panel
console.log('[RPG Mobile] Opening panel');
// console.log('[RPG Mobile] Opening panel');
$panel.addClass('rpg-mobile-open');
const $overlay = $('<div class="rpg-mobile-overlay"></div>');
$('body').append($overlay);
// Debug: Check state after animation should complete
setTimeout(() => {
console.log('[RPG Mobile] 500ms after opening:', {
panelClasses: $panel.attr('class'),
hasOpenClass: $panel.hasClass('rpg-mobile-open'),
visibility: $panel.css('visibility'),
transform: $panel.css('transform'),
display: $panel.css('display'),
opacity: $panel.css('opacity')
});
// console.log('[RPG Mobile] 500ms after opening:', {
// panelClasses: $panel.attr('class'),
// hasOpenClass: $panel.hasClass('rpg-mobile-open'),
// visibility: $panel.css('visibility'),
// transform: $panel.css('transform'),
// display: $panel.css('display'),
// opacity: $panel.css('opacity')
// });
}, 500);
// Close when clicking overlay
$overlay.on('click', function() {
console.log('[RPG Mobile] Overlay clicked - closing panel');
// console.log('[RPG Mobile] Overlay clicked - closing panel');
closeMobilePanelWithAnimation();
updateCollapseToggleIcon();
});
@@ -150,20 +203,20 @@ export function setupCollapseToggle() {
// Update icon to reflect new state
updateCollapseToggleIcon();
console.log('[RPG Mobile] After toggle:', {
panelClasses: $panel.attr('class'),
inlineStyles: $panel.attr('style'),
panelPosition: {
top: $panel.css('top'),
bottom: $panel.css('bottom'),
transform: $panel.css('transform'),
visibility: $panel.css('visibility')
},
gameContainer: {
opacity: $('.rpg-game-container').css('opacity'),
visibility: $('.rpg-game-container').css('visibility')
}
});
// console.log('[RPG Mobile] After toggle:', {
// panelClasses: $panel.attr('class'),
// inlineStyles: $panel.attr('style'),
// panelPosition: {
// top: $panel.css('top'),
// bottom: $panel.css('bottom'),
// transform: $panel.css('transform'),
// visibility: $panel.css('visibility')
// },
// gameContainer: {
// opacity: $('.rpg-game-container').css('opacity'),
// visibility: $('.rpg-game-container').css('visibility')
// }
// });
return;
}
@@ -204,9 +257,13 @@ export function updatePanelVisibility() {
if (extensionSettings.enabled) {
$panelContainer.show();
togglePlotButtons(); // Update plot button visibility
$('#rpg-mobile-toggle').show(); // Show mobile FAB toggle
$('#rpg-collapse-toggle').show(); // Show collapse toggle
} else {
$panelContainer.hide();
$('#rpg-plot-buttons').hide(); // Hide plot buttons when disabled
$('#rpg-mobile-toggle').hide(); // Hide mobile FAB toggle
$('#rpg-collapse-toggle').hide(); // Hide collapse toggle
}
}
@@ -214,29 +271,131 @@ export function updatePanelVisibility() {
* Updates the visibility of individual sections.
*/
export function updateSectionVisibility() {
// Refresh container references first (in case they were detached during tab operations)
setInventoryContainer($('#rpg-inventory'));
setQuestsContainer($('#rpg-quests'));
// Show/hide sections based on settings
$userStatsContainer.toggle(extensionSettings.showUserStats);
$infoBoxContainer.toggle(extensionSettings.showInfoBox);
$thoughtsContainer.toggle(extensionSettings.showCharacterThoughts);
if ($inventoryContainer) {
$inventoryContainer.toggle(extensionSettings.showInventory);
// Use explicit .show()/.hide() instead of .toggle() to ensure proper state on reload
if (extensionSettings.showUserStats) {
$userStatsContainer.show();
} else {
$userStatsContainer.hide();
}
if (extensionSettings.showInfoBox) {
// Only show if there's data to display
const infoBoxData = lastGeneratedData.infoBox || committedTrackerData.infoBox;
if (infoBoxData) {
$infoBoxContainer.show();
} else {
$infoBoxContainer.hide();
}
} else {
$infoBoxContainer.hide();
}
if (extensionSettings.showCharacterThoughts) {
$thoughtsContainer.show();
} else {
$thoughtsContainer.hide();
}
// Use direct DOM selectors for inventory and quests to avoid stale references
if (extensionSettings.showInventory) {
$('#rpg-inventory').show();
} else {
$('#rpg-inventory').hide();
}
if (extensionSettings.showQuests) {
$('#rpg-quests').show();
} else {
$('#rpg-quests').hide();
}
if ($musicPlayerContainer) {
if (extensionSettings.enableSpotifyMusic) {
$musicPlayerContainer.show();
} else {
$musicPlayerContainer.hide();
}
}
// Show/hide dividers intelligently
// Divider after User Stats: shown if User Stats is visible AND at least one section after it is visible
const showDividerAfterStats = extensionSettings.showUserStats &&
(extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts || extensionSettings.showInventory);
$('#rpg-divider-stats').toggle(showDividerAfterStats);
(extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts || extensionSettings.showInventory || extensionSettings.showQuests || extensionSettings.enableSpotifyMusic);
if (showDividerAfterStats) {
$('#rpg-divider-stats').show();
} else {
$('#rpg-divider-stats').hide();
}
// Divider after Info Box: shown if Info Box is visible AND at least one section after it is visible
const showDividerAfterInfo = extensionSettings.showInfoBox &&
(extensionSettings.showCharacterThoughts || extensionSettings.showInventory);
$('#rpg-divider-info').toggle(showDividerAfterInfo);
(extensionSettings.showCharacterThoughts || extensionSettings.showInventory || extensionSettings.showQuests);
if (showDividerAfterInfo) {
$('#rpg-divider-info').show();
} else {
$('#rpg-divider-info').hide();
}
// Divider after Thoughts: shown if Thoughts is visible AND Inventory is visible
// Divider after Thoughts: shown if Thoughts is visible AND at least one section after it is visible
const showDividerAfterThoughts = extensionSettings.showCharacterThoughts &&
extensionSettings.showInventory;
$('#rpg-divider-thoughts').toggle(showDividerAfterThoughts);
(extensionSettings.showInventory || extensionSettings.showQuests || extensionSettings.enableSpotifyMusic);
if (showDividerAfterThoughts) {
$('#rpg-divider-thoughts').show();
} else {
$('#rpg-divider-thoughts').hide();
}
// Divider after Inventory: shown if Inventory is visible AND (Quests or Music) is visible
const showDividerAfterInventory = extensionSettings.showInventory && (extensionSettings.showQuests || extensionSettings.enableSpotifyMusic);
if (showDividerAfterInventory) {
$('#rpg-divider-inventory').show();
} else {
$('#rpg-divider-inventory').hide();
}
// Divider after Quests: shown if Quests is visible AND Music is visible
const showDividerAfterQuests = extensionSettings.showQuests && extensionSettings.enableSpotifyMusic;
if (showDividerAfterQuests) {
$('#rpg-divider-quests').show();
} else {
$('#rpg-divider-quests').hide();
}
// Rebuild tabs to reflect visibility changes for inventory and quests
const isMobile = window.innerWidth <= 1000;
const hasMobileTabs = $('.rpg-mobile-container').length > 0;
const hasDesktopTabs = $('.rpg-tabs-nav').length > 0;
// Only rebuild if tabs currently exist
if (hasMobileTabs || hasDesktopTabs) {
// Remove existing tabs
if (hasMobileTabs) {
removeMobileTabs();
// Force remove any lingering mobile tab elements (but not the content sections!)
$('.rpg-mobile-container').remove();
$('.rpg-mobile-tabs').remove();
} else {
removeDesktopTabs();
// Force remove any lingering desktop tab structure (but not the content sections!)
// The removeDesktopTabs() function already detached and restored the sections
}
// Rebuild tabs immediately
if (isMobile) {
setupMobileTabs();
} else {
setupDesktopTabs();
}
// Refresh container references
setInventoryContainer($('#rpg-inventory'));
setQuestsContainer($('#rpg-quests'));
}
}
/**
@@ -249,16 +408,19 @@ export function applyPanelPosition() {
// Remove all position classes
$panelContainer.removeClass('rpg-position-left rpg-position-right rpg-position-top');
$('body').removeClass('rpg-panel-position-left rpg-panel-position-right rpg-panel-position-top');
// On mobile, don't apply desktop position classes
// Add the appropriate position class
$panelContainer.addClass(`rpg-position-${extensionSettings.panelPosition}`);
// On mobile, also add body class for mobile-specific CSS
if (isMobile) {
$('body').addClass(`rpg-panel-position-${extensionSettings.panelPosition}`);
updateCollapseToggleIcon();
return;
}
// Desktop: Add the appropriate position class
$panelContainer.addClass(`rpg-position-${extensionSettings.panelPosition}`);
// Update collapse toggle icon direction for new position
// Desktop: Update collapse toggle icon direction for new position
updateCollapseToggleIcon();
}
@@ -269,8 +431,23 @@ export function updateGenerationModeUI() {
if (extensionSettings.generationMode === 'together') {
// In "together" mode, manual update button is hidden
$('#rpg-manual-update').hide();
} else {
$('#rpg-external-api-settings').slideUp(200);
$('#rpg-separate-mode-settings').slideUp(200);
// Hide auto-update toggle (not applicable in together mode)
$('#rpg-auto-update-container').slideUp(200);
} else if (extensionSettings.generationMode === 'separate') {
// In "separate" mode, manual update button is visible
$('#rpg-manual-update').show();
$('#rpg-external-api-settings').slideUp(200);
$('#rpg-separate-mode-settings').slideDown(200);
// Show auto-update toggle
$('#rpg-auto-update-container').slideDown(200);
} else if (extensionSettings.generationMode === 'external') {
// In "external" mode, manual update button is visible AND both settings are shown
$('#rpg-manual-update').show();
$('#rpg-external-api-settings').slideDown(200);
$('#rpg-separate-mode-settings').slideDown(200);
// Show auto-update toggle for external mode too
$('#rpg-auto-update-container').slideDown(200);
}
}
+542 -84
View File
@@ -3,10 +3,47 @@
* Handles mobile-specific UI functionality: FAB dragging, tabs, keyboard handling
*/
import { extensionSettings } from '../../core/state.js';
import { extensionSettings, committedTrackerData, lastGeneratedData } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { closeMobilePanelWithAnimation, updateCollapseToggleIcon } from './layout.js';
import { setupDesktopTabs, removeDesktopTabs } from './desktop.js';
import { i18n } from '../../core/i18n.js';
/**
* Updates the text labels of the mobile navigation tabs based on the current language.
*/
export function updateMobileTabLabels() {
const $tabs = $('.rpg-mobile-tabs .rpg-mobile-tab');
if ($tabs.length === 0) return;
$tabs.each(function() {
const $tab = $(this);
const tabName = $tab.data('tab');
let translationKey = '';
switch (tabName) {
case 'stats':
translationKey = 'global.status';
break;
case 'info':
translationKey = 'global.info';
break;
case 'inventory':
translationKey = 'global.inventory';
break;
case 'quests':
translationKey = 'global.quests';
break;
}
if (translationKey) {
const translation = i18n.getTranslation(translationKey);
if (translation) {
$tab.find('span').text(translation);
}
}
});
}
/**
* Sets up the mobile toggle button (FAB) with drag functionality.
@@ -18,13 +55,13 @@ export function setupMobileToggle() {
const $overlay = $('<div class="rpg-mobile-overlay"></div>');
// DIAGNOSTIC: Check if elements exist and log setup state
console.log('[RPG Mobile] ========================================');
console.log('[RPG Mobile] setupMobileToggle called');
console.log('[RPG Mobile] Button exists:', $mobileToggle.length > 0, 'jQuery object:', $mobileToggle);
console.log('[RPG Mobile] Panel exists:', $panel.length > 0);
console.log('[RPG Mobile] Window width:', window.innerWidth);
console.log('[RPG Mobile] Is mobile viewport (<=1000):', window.innerWidth <= 1000);
console.log('[RPG Mobile] ========================================');
// console.log('[RPG Mobile] ========================================');
// console.log('[RPG Mobile] setupMobileToggle called');
// console.log('[RPG Mobile] Button exists:', $mobileToggle.length > 0, 'jQuery object:', $mobileToggle);
// console.log('[RPG Mobile] Panel exists:', $panel.length > 0);
// console.log('[RPG Mobile] Window width:', window.innerWidth);
// console.log('[RPG Mobile] Is mobile viewport (<=1000):', window.innerWidth <= 1000);
// console.log('[RPG Mobile] ========================================');
if ($mobileToggle.length === 0) {
console.error('[RPG Mobile] ERROR: Mobile toggle button not found in DOM!');
@@ -35,7 +72,7 @@ export function setupMobileToggle() {
// Load and apply saved FAB position
if (extensionSettings.mobileFabPosition) {
const pos = extensionSettings.mobileFabPosition;
console.log('[RPG Mobile] Loading saved FAB position:', pos);
// console.log('[RPG Mobile] Loading saved FAB position:', pos);
// Apply saved position
if (pos.top) $mobileToggle.css('top', pos.top);
@@ -69,6 +106,14 @@ export function setupMobileToggle() {
right: 'auto',
bottom: 'auto'
});
// Also update widget container position during drag
const $container = $('#rpg-fab-widget-container');
if ($container.length > 0) {
$container.css({
top: pendingY + 'px',
left: pendingX + 'px'
});
}
pendingX = null;
pendingY = null;
}
@@ -213,10 +258,13 @@ export function setupMobileToggle() {
extensionSettings.mobileFabPosition = newPosition;
saveSettings();
console.log('[RPG Mobile] Saved new FAB position (mouse):', newPosition);
// console.log('[RPG Mobile] Saved new FAB position (mouse):', newPosition);
// Constrain to viewport bounds (now that position is saved)
setTimeout(() => constrainFabToViewport(), 10);
setTimeout(() => {
constrainFabToViewport();
updateFabWidgetPosition(); // Update widget container position
}, 10);
// Re-enable transitions with smooth animation
setTimeout(() => {
@@ -254,10 +302,13 @@ export function setupMobileToggle() {
extensionSettings.mobileFabPosition = newPosition;
saveSettings();
console.log('[RPG Mobile] Saved new FAB position:', newPosition);
// console.log('[RPG Mobile] Saved new FAB position:', newPosition);
// Constrain to viewport bounds (now that position is saved)
setTimeout(() => constrainFabToViewport(), 10);
setTimeout(() => {
constrainFabToViewport();
updateFabWidgetPosition(); // Update widget container position
}, 10);
// Re-enable transitions with smooth animation
setTimeout(() => {
@@ -267,7 +318,7 @@ export function setupMobileToggle() {
isDragging = false;
} else {
// Was a tap - toggle panel
console.log('[RPG Mobile] Quick tap detected - toggling panel');
// console.log('[RPG Mobile] Quick tap detected - toggling panel');
if ($panel.hasClass('rpg-mobile-open')) {
// Close panel with animation
@@ -290,28 +341,28 @@ export function setupMobileToggle() {
$mobileToggle.on('click', function(e) {
// Skip if we just finished dragging
if ($mobileToggle.data('just-dragged')) {
console.log('[RPG Mobile] Click blocked - just finished dragging');
// console.log('[RPG Mobile] Click blocked - just finished dragging');
return;
}
console.log('[RPG Mobile] >>> CLICK EVENT FIRED <<<', {
windowWidth: window.innerWidth,
isMobileViewport: window.innerWidth <= 1000,
panelOpen: $panel.hasClass('rpg-mobile-open')
});
// console.log('[RPG Mobile] >>> CLICK EVENT FIRED <<<', {
// windowWidth: window.innerWidth,
// isMobileViewport: window.innerWidth <= 1000,
// panelOpen: $panel.hasClass('rpg-mobile-open')
// });
// Work on both mobile and desktop (removed viewport check)
if ($panel.hasClass('rpg-mobile-open')) {
console.log('[RPG Mobile] Click: Closing panel');
// console.log('[RPG Mobile] Click: Closing panel');
closeMobilePanelWithAnimation();
} else {
console.log('[RPG Mobile] Click: Opening panel');
// console.log('[RPG Mobile] Click: Opening panel');
$panel.addClass('rpg-mobile-open');
$('body').append($overlay);
$mobileToggle.addClass('active');
$overlay.on('click', function() {
console.log('[RPG Mobile] Overlay clicked - closing panel');
// console.log('[RPG Mobile] Overlay clicked - closing panel');
closeMobilePanelWithAnimation();
});
}
@@ -330,13 +381,20 @@ export function setupMobileToggle() {
// Transitioning from desktop to mobile - handle immediately for smooth transition
if (!wasMobile && isMobile) {
console.log('[RPG Mobile] Transitioning desktop -> mobile');
// console.log('[RPG Mobile] Transitioning desktop -> mobile');
// Show mobile toggle button
$mobileToggle.show();
// Remove desktop tabs first
removeDesktopTabs();
// Remove desktop positioning classes
// Apply mobile positioning based on panelPosition setting
$panel.removeClass('rpg-position-right rpg-position-left rpg-position-top');
$('body').removeClass('rpg-panel-position-right rpg-panel-position-left rpg-panel-position-top');
const position = extensionSettings.panelPosition || 'right';
$panel.addClass('rpg-position-' + position);
$('body').addClass('rpg-panel-position-' + position);
// Clear collapsed state - mobile doesn't use collapse
$panel.removeClass('rpg-collapsed');
@@ -347,16 +405,16 @@ export function setupMobileToggle() {
// Clear any inline styles that might be overriding CSS
$panel.attr('style', '');
console.log('[RPG Mobile] After cleanup:', {
panelClasses: $panel.attr('class'),
inlineStyles: $panel.attr('style'),
panelPosition: {
top: $panel.css('top'),
bottom: $panel.css('bottom'),
transform: $panel.css('transform'),
visibility: $panel.css('visibility')
}
});
// console.log('[RPG Mobile] After cleanup:', {
// panelClasses: $panel.attr('class'),
// inlineStyles: $panel.attr('style'),
// panelPosition: {
// top: $panel.css('top'),
// bottom: $panel.css('bottom'),
// transform: $panel.css('transform'),
// visibility: $panel.css('visibility')
// }
// });
// Set up mobile tabs IMMEDIATELY (no debounce delay)
setupMobileTabs();
@@ -381,7 +439,11 @@ export function setupMobileToggle() {
$mobileToggle.removeClass('active');
$('.rpg-mobile-overlay').remove();
// Restore desktop positioning class
// Hide mobile toggle button on desktop
$mobileToggle.hide();
// Restore desktop positioning class and remove body mobile classes
$('body').removeClass('rpg-panel-position-right rpg-panel-position-left rpg-panel-position-top');
const position = extensionSettings.panelPosition || 'right';
$panel.addClass('rpg-position-' + position);
@@ -414,19 +476,21 @@ export function setupMobileToggle() {
// Clear any inline styles
$panel.attr('style', '');
console.log('[RPG Mobile] Initial load on mobile viewport:', {
panelClasses: $panel.attr('class'),
inlineStyles: $panel.attr('style'),
panelPosition: {
top: $panel.css('top'),
bottom: $panel.css('top'),
transform: $panel.css('transform'),
visibility: $panel.css('visibility')
}
});
setupMobileTabs();
// console.log('[RPG Mobile] Initial load on mobile viewport:', {
// panelClasses: $panel.attr('class'),
// inlineStyles: $panel.attr('style'),
// panelPosition: {
// top: $panel.css('top'),
// bottom: $panel.css('top'),
// transform: $panel.css('transform'),
// visibility: $panel.css('visibility')\n // }\n // });\n setupMobileTabs();
// Set initial icon for mobile
updateCollapseToggleIcon();
// Show mobile toggle on mobile viewport
$mobileToggle.show();
} else {
// Hide mobile toggle on desktop viewport
$mobileToggle.hide();
}
}
@@ -438,7 +502,7 @@ export function setupMobileToggle() {
export function constrainFabToViewport() {
// Only constrain if user has set a custom position
if (!extensionSettings.mobileFabPosition) {
console.log('[RPG Mobile] Skipping viewport constraint - using CSS defaults');
// console.log('[RPG Mobile] Skipping viewport constraint - using CSS defaults');
return;
}
@@ -447,7 +511,7 @@ export function constrainFabToViewport() {
// Skip if button is not visible
if (!$mobileToggle.is(':visible')) {
console.log('[RPG Mobile] Skipping viewport constraint - button not visible');
// console.log('[RPG Mobile] Skipping viewport constraint - button not visible');
return;
}
@@ -477,12 +541,12 @@ export function constrainFabToViewport() {
// Only update if position changed
if (newX !== currentX || newY !== currentY) {
console.log('[RPG Mobile] Constraining FAB to viewport:', {
old: { x: currentX, y: currentY },
new: { x: newX, y: newY },
viewport: { width: window.innerWidth, height: window.innerHeight },
topBarHeight
});
// console.log('[RPG Mobile] Constraining FAB to viewport:', {
// old: { x: currentX, y: currentY },
// new: { x: newX, y: newY },
// viewport: { width: window.innerWidth, height: window.innerHeight },
// topBarHeight
// });
// Apply new position
$mobileToggle.css({
@@ -509,17 +573,18 @@ 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;
const $panel = $('#rpg-companion-panel');
// Apply mobile positioning based on panelPosition setting
$panel.removeClass('rpg-position-right rpg-position-left rpg-position-top');
$('body').removeClass('rpg-panel-position-right rpg-panel-position-left rpg-panel-position-top');
const position = extensionSettings.panelPosition || 'right';
$panel.addClass('rpg-position-' + position);
$('body').addClass('rpg-panel-position-' + position);
const $contentBox = $panel.find('.rpg-content-box');
// Get existing sections
@@ -538,24 +603,24 @@ export function setupMobileTabs() {
const tabs = [];
const hasStats = $userStats.length > 0;
const hasInfo = $infoBox.length > 0 || $thoughts.length > 0;
const hasInventory = $inventory.length > 0;
const hasQuests = $quests.length > 0;
const hasInventory = $inventory.length > 0 && extensionSettings.showInventory;
const hasQuests = $quests.length > 0 && extensionSettings.showQuests;
// Tab 1: Stats (User Stats only)
if (hasStats) {
tabs.push('<button class="rpg-mobile-tab active" data-tab="stats"><i class="fa-solid fa-chart-bar"></i><span>Stats</span></button>');
tabs.push('<button class="rpg-mobile-tab active" data-tab="stats"><i class="fa-solid fa-chart-bar"></i><span>' + i18n.getTranslation('global.status') + '</span></button>');
}
// Tab 2: Info (Info Box + Character Thoughts)
if (hasInfo) {
tabs.push('<button class="rpg-mobile-tab ' + (tabs.length === 0 ? 'active' : '') + '" data-tab="info"><i class="fa-solid fa-book"></i><span>Info</span></button>');
tabs.push('<button class="rpg-mobile-tab ' + (tabs.length === 0 ? 'active' : '') + '" data-tab="info"><i class="fa-solid fa-book"></i><span>' + i18n.getTranslation('global.info') + '</span></button>');
}
// Tab 3: Inventory
if (hasInventory) {
tabs.push('<button class="rpg-mobile-tab ' + (tabs.length === 0 ? 'active' : '') + '" data-tab="inventory"><i class="fa-solid fa-box"></i><span>Inventory</span></button>');
tabs.push('<button class="rpg-mobile-tab ' + (tabs.length === 0 ? 'active' : '') + '" data-tab="inventory"><i class="fa-solid fa-box"></i><span>' + i18n.getTranslation('global.inventory') + '</span></button>');
}
// Tab 4: Quests
if (hasQuests) {
tabs.push('<button class="rpg-mobile-tab ' + (tabs.length === 0 ? 'active' : '') + '" data-tab="quests"><i class="fa-solid fa-scroll"></i><span>Quests</span></button>');
tabs.push('<button class="rpg-mobile-tab ' + (tabs.length === 0 ? 'active' : '') + '" data-tab="quests"><i class="fa-solid fa-scroll"></i><span>' + i18n.getTranslation('global.quests') + '</span></button>');
}
const $tabNav = $('<div class="rpg-mobile-tabs">' + tabs.join('') + '</div>');
@@ -583,7 +648,9 @@ export function setupMobileTabs() {
// Info tab: Info Box + Character Thoughts
if ($infoBox.length > 0) {
$infoTab.append($infoBox.detach());
$infoBox.show();
// Only show if has data
const infoBoxData = window.lastGeneratedData?.infoBox || window.committedTrackerData?.infoBox;
if (infoBoxData) $infoBox.show();
}
if ($thoughts.length > 0) {
$infoTab.append($thoughts.detach());
@@ -609,12 +676,12 @@ export function setupMobileTabs() {
const $mobileContainer = $('<div class="rpg-mobile-container"></div>');
$mobileContainer.append($tabNav);
// Only append tab content wrappers that have content
if (hasStats) $mobileContainer.append($statsTab);
if (hasInfo) $mobileContainer.append($infoTab);
if (hasInventory) $mobileContainer.append($inventoryTab);
if (hasQuests) $mobileContainer.append($questsTab);
if (hasInventory) $mobileContainer.append($inventoryTab);
// Always append all tab content wrappers to preserve elements
// Tab buttons control visibility
$mobileContainer.append($statsTab);
$mobileContainer.append($infoTab);
$mobileContainer.append($inventoryTab);
$mobileContainer.append($questsTab);
// Insert mobile tab structure at the beginning of content box
$contentBox.prepend($mobileContainer);
@@ -671,11 +738,15 @@ export function removeMobileTabs() {
$contentBox.prepend($userStats);
}
// Show sections and dividers
$userStats.show();
$infoBox.show();
$thoughts.show();
$inventory.show();
// Show/hide sections based on settings (respect visibility settings)
if (extensionSettings.showUserStats) $userStats.show();
if (extensionSettings.showInfoBox) {
const infoBoxData = window.lastGeneratedData?.infoBox || window.committedTrackerData?.infoBox;
if (infoBoxData) $infoBox.show();
}
if (extensionSettings.showCharacterThoughts) $thoughts.show();
if (extensionSettings.showInventory) $inventory.show();
if (extensionSettings.showQuests) $quests.show();
$('.rpg-divider').show();
}
@@ -756,12 +827,12 @@ export function setupRefreshButtonDrag() {
return;
}
console.log('[RPG Mobile] setupRefreshButtonDrag called');
// console.log('[RPG Mobile] setupRefreshButtonDrag called');
// Load and apply saved position
if (extensionSettings.mobileRefreshPosition) {
const pos = extensionSettings.mobileRefreshPosition;
console.log('[RPG Mobile] Loading saved refresh button position:', pos);
// console.log('[RPG Mobile] Loading saved refresh button position:', pos);
// Apply saved position
if (pos.top) $refreshBtn.css('top', pos.top);
@@ -971,12 +1042,12 @@ export function setupDebugButtonDrag() {
return;
}
console.log('[RPG Mobile] setupDebugButtonDrag called');
// console.log('[RPG Mobile] setupDebugButtonDrag called');
// Load and apply saved position
if (extensionSettings.debugFabPosition) {
const pos = extensionSettings.debugFabPosition;
console.log('[RPG Mobile] Loading saved debug button position:', pos);
// console.log('[RPG Mobile] Loading saved debug button position:', pos);
// Apply saved position
if (pos.top) $debugBtn.css('top', pos.top);
@@ -1173,3 +1244,390 @@ export function setupDebugButtonDrag() {
isDragging = false;
});
}
// ============================================
// FAB WIDGETS - Info display around FAB button
// ============================================
/**
* Updates the FAB widgets display based on current tracker data and settings.
* Widgets are positioned in 8 positions around the FAB (N, NE, E, SE, S, SW, W, NW).
*/
export function updateFabWidgets() {
const $fab = $('#rpg-mobile-toggle');
if ($fab.length === 0) return;
// Remove existing widget container and clean up event listeners
$('#rpg-fab-widget-container').remove();
$(document).off('click.fabWidgets touchstart.fabWidgets');
// Check if widgets are enabled
const widgetSettings = extensionSettings.mobileFabWidgets;
if (!widgetSettings || !widgetSettings.enabled) return;
// Don't show widgets on desktop or when panel is open
if (window.innerWidth > 1000) return;
// Get tracker data - prefer lastGeneratedData (most recent) over committedTrackerData
const infoBox = lastGeneratedData?.infoBox || committedTrackerData?.infoBox;
const userStats = lastGeneratedData?.userStats || committedTrackerData?.userStats;
// Parse infoBox if it's a string
let infoData = null;
if (infoBox) {
try {
infoData = typeof infoBox === 'string' ? JSON.parse(infoBox) : infoBox;
} catch (e) {
console.warn('[RPG FAB Widgets] Failed to parse infoBox:', e);
}
}
// Parse userStats if it's a string
let statsData = null;
if (userStats) {
try {
statsData = typeof userStats === 'string' ? JSON.parse(userStats) : userStats;
} catch (e) {
console.warn('[RPG FAB Widgets] Failed to parse userStats:', e);
}
}
// Create widget container positioned at FAB location
const fabOffset = $fab.offset();
const fabWidth = $fab.outerWidth();
const fabHeight = $fab.outerHeight();
const $container = $('<div id="rpg-fab-widget-container" class="rpg-fab-widget-container"></div>');
$container.css({
top: fabOffset.top + 'px',
left: fabOffset.left + 'px',
width: fabWidth + 'px',
height: fabHeight + 'px'
});
// Build widgets based on settings - auto-assign positions sequentially
const widgets = [];
// Collect enabled widgets in display priority order
// Large widgets (Stats, Attributes) go to West/Northwest
// Small widgets spread around other positions
// Weather Icon (small)
if (widgetSettings.weatherIcon?.enabled && infoData?.weather?.emoji) {
widgets.push({
type: 'small',
html: `<div class="rpg-fab-widget rpg-fab-widget-weather-icon" title="Weather">${infoData.weather.emoji}</div>`
});
}
// Weather Description (small)
if (widgetSettings.weatherDesc?.enabled && infoData?.weather?.forecast) {
const desc = infoData.weather.forecast.length > 15 ? infoData.weather.forecast.substring(0, 13) + '…' : infoData.weather.forecast;
widgets.push({
type: 'small',
html: `<div class="rpg-fab-widget rpg-fab-widget-weather-desc" title="${infoData.weather.forecast}">${desc}</div>`
});
}
// Helper to create expandable text widget HTML
const createExpandableText = (fullText, maxLen, emoji) => {
if (fullText.length <= maxLen) {
return `${emoji} ${fullText}`;
}
const truncated = fullText.substring(0, maxLen - 2) + '…';
return `${emoji} <span class="rpg-truncated">${truncated}</span><span class="rpg-full-text">${fullText}</span>`;
};
// Check if text needs truncation for data attribute
const needsExpand = (text, maxLen) => text.length > maxLen;
// Helper to parse time string and calculate clock hand angles
const parseTimeForClock = (timeStr) => {
const timeMatch = timeStr.match(/(\d+):(\d+)/);
if (timeMatch) {
const hours = parseInt(timeMatch[1]);
const minutes = parseInt(timeMatch[2]);
const hourAngle = (hours % 12) * 30 + minutes * 0.5; // 30° per hour + 0.5° per minute
const minuteAngle = minutes * 6; // 6° per minute
return { hourAngle, minuteAngle };
}
return { hourAngle: 0, minuteAngle: 0 };
};
// Clock/Time (bottom position with animated clock face)
if (widgetSettings.clock?.enabled && infoData?.time) {
const timeStr = infoData.time.end || infoData.time.value || infoData.time.start || '';
if (timeStr) {
const { hourAngle, minuteAngle } = parseTimeForClock(timeStr);
widgets.push({
type: 'bottom', // Special type for bottom position
html: `<div class="rpg-fab-widget rpg-fab-widget-clock" title="${timeStr}">
<div class="rpg-fab-clock-face">
<div class="rpg-fab-clock-hour" style="transform: rotate(${hourAngle}deg)"></div>
<div class="rpg-fab-clock-minute" style="transform: rotate(${minuteAngle}deg)"></div>
<div class="rpg-fab-clock-center"></div>
</div>
<span class="rpg-fab-clock-time">${timeStr}</span>
</div>`
});
}
}
// Date (small)
if (widgetSettings.date?.enabled && infoData?.date?.value) {
const dateVal = infoData.date.value;
const expandAttr = needsExpand(dateVal, 12) ? ' data-full-text="true"' : '';
widgets.push({
type: 'small',
html: `<div class="rpg-fab-widget rpg-fab-widget-date"${expandAttr} title="${dateVal}">${createExpandableText(dateVal, 12, '📅')}</div>`
});
}
// Location (small)
if (widgetSettings.location?.enabled && infoData?.location?.value) {
const loc = infoData.location.value;
const expandAttr = needsExpand(loc, 14) ? ' data-full-text="true"' : '';
widgets.push({
type: 'small',
html: `<div class="rpg-fab-widget rpg-fab-widget-location"${expandAttr} title="${loc}">${createExpandableText(loc, 14, '📍')}</div>`
});
}
// Stats (large - goes to West) - respects trackerConfig.userStats.customStats
// Use extensionSettings.userStats as primary source (contains all stats), fallback to committedTrackerData
let allStats = [];
try {
const userStatsJson = extensionSettings.userStats;
const parsedUserStats = typeof userStatsJson === 'string' ? JSON.parse(userStatsJson) : userStatsJson;
if (parsedUserStats?.stats) {
allStats = parsedUserStats.stats;
}
} catch (e) {
console.warn('[RPG FAB Widgets] Failed to parse extensionSettings.userStats:', e);
}
// Fallback to statsData if extensionSettings.userStats is empty
if (allStats.length === 0 && statsData?.stats) {
allStats = statsData.stats;
}
if (widgetSettings.stats?.enabled && allStats.length > 0) {
// Get enabled stats from trackerConfig - match by id (lowercase)
const configuredStats = extensionSettings.trackerConfig?.userStats?.customStats || [];
const enabledStatMap = new Map();
configuredStats.forEach(s => {
if (s.enabled !== false) {
enabledStatMap.set(s.id?.toLowerCase(), true);
enabledStatMap.set(s.name?.toLowerCase(), true);
}
});
const statsHtml = allStats
.filter(s => {
// If no config, show all stats
if (configuredStats.length === 0) return true;
// Check if stat is enabled in trackerConfig (match by id or name, case-insensitive)
const statId = s.id?.toLowerCase();
const statName = s.name?.toLowerCase();
return enabledStatMap.has(statId) || enabledStatMap.has(statName);
})
.map(stat => {
const value = typeof stat.value === 'number' ? stat.value : parseInt(stat.value) || 0;
const color = getStatColor(value);
const abbr = stat.name.substring(0, 3).toUpperCase();
return `<span class="rpg-fab-widget-stat-item" title="${stat.name}: ${value}" style="color: ${color};">${abbr}:${value}</span>`;
})
.join('');
if (statsHtml) {
widgets.push({
type: 'large',
preferredPos: 6, // West
html: `<div class="rpg-fab-widget rpg-fab-widget-stats"><div class="rpg-fab-widget-stats-row">${statsHtml}</div></div>`
});
}
}
// RPG Attributes (large - goes to Northwest) - respects trackerConfig.userStats.rpgAttributes
if (widgetSettings.attributes?.enabled) {
// Check if RPG attributes are enabled in trackerConfig
const showRPGAttributes = extensionSettings.trackerConfig?.userStats?.showRPGAttributes !== false;
if (showRPGAttributes && extensionSettings.classicStats) {
// Get enabled attributes from trackerConfig
const configuredAttrs = extensionSettings.trackerConfig?.userStats?.rpgAttributes || [];
const enabledAttrIds = configuredAttrs.filter(a => a.enabled !== false).map(a => a.id);
const attrs = extensionSettings.classicStats;
const attrItems = Object.entries(attrs)
.filter(([key]) => {
// Check if attribute is enabled in trackerConfig
if (enabledAttrIds.length > 0) {
return enabledAttrIds.includes(key.toLowerCase());
}
return true;
})
.map(([key, value]) => `<div class="rpg-fab-widget-attr-item"><span class="rpg-fab-widget-attr-name">${key.toUpperCase()}</span><span class="rpg-fab-widget-attr-value">${value}</span></div>`)
.join('');
if (attrItems) {
widgets.push({
type: 'large',
preferredPos: 7, // Northwest
html: `<div class="rpg-fab-widget rpg-fab-widget-attributes" title="Attributes"><div class="rpg-fab-widget-attr-grid">${attrItems}</div></div>`
});
}
}
}
// Auto-assign positions intelligently
// Large widgets get their preferred positions first (West=6, Northwest=7)
// Bottom widgets get position 4 (South)
// Small widgets fill remaining positions clockwise from North (0)
const usedPositions = new Set();
const positionedWidgets = [];
// Position order for small widgets: N(0), NE(1), E(2), SE(3), SW(5) - skip S(4) for bottom/clock
const smallPositionOrder = [0, 1, 2, 3, 5];
let smallPosIndex = 0;
// Check if only one large widget exists (for centering)
const largeWidgets = widgets.filter(w => w.type === 'large');
const singleLargeWidget = largeWidgets.length === 1;
// First: assign bottom widgets to position 4 (South)
widgets.filter(w => w.type === 'bottom').forEach(w => {
const pos = 4; // South position
usedPositions.add(pos);
const finalHtml = w.html.replace('class="rpg-fab-widget', `class="rpg-fab-widget rpg-fab-widget-pos-${pos}`);
positionedWidgets.push({ position: pos, html: finalHtml });
});
// Second: assign large widgets to their preferred positions
largeWidgets.forEach(w => {
let pos = w.preferredPos;
// If preferred position is taken, find next available from large positions
if (usedPositions.has(pos)) {
pos = pos === 6 ? 7 : 6; // Try the other large position
}
usedPositions.add(pos);
// Add centered class if this is the only large widget
const centeredClass = singleLargeWidget ? ' rpg-fab-widget-centered' : '';
const finalHtml = w.html.replace('class="rpg-fab-widget', `class="rpg-fab-widget rpg-fab-widget-pos-${pos}${centeredClass}`);
positionedWidgets.push({ position: pos, html: finalHtml });
});
// Third: assign small widgets to remaining positions
widgets.filter(w => w.type === 'small').forEach(w => {
// Find next available position from small position order
while (smallPosIndex < smallPositionOrder.length && usedPositions.has(smallPositionOrder[smallPosIndex])) {
smallPosIndex++;
}
const pos = smallPosIndex < smallPositionOrder.length ? smallPositionOrder[smallPosIndex] : (smallPosIndex % 8);
usedPositions.add(pos);
smallPosIndex++;
const finalHtml = w.html.replace('class="rpg-fab-widget', `class="rpg-fab-widget rpg-fab-widget-pos-${pos}`);
positionedWidgets.push({ position: pos, html: finalHtml });
});
// Add widgets to container
positionedWidgets.forEach(w => $container.append(w.html));
// Append container to body
if (positionedWidgets.length > 0) {
$('body').append($container);
// Add mobile tap handler for expandable widgets
$container.find('.rpg-fab-widget[data-full-text]').on('click touchstart', function(e) {
e.stopPropagation();
const $this = $(this);
const wasExpanded = $this.hasClass('expanded');
// Collapse all other expanded widgets
$container.find('.rpg-fab-widget.expanded').removeClass('expanded');
// Toggle this one
if (!wasExpanded) {
$this.addClass('expanded');
}
});
// Collapse on tap outside
$(document).on('click.fabWidgets touchstart.fabWidgets', function(e) {
if (!$(e.target).closest('.rpg-fab-widget').length) {
$container.find('.rpg-fab-widget.expanded').removeClass('expanded');
}
});
}
}
/**
* Gets a color for a stat value (0-100) using a gradient from low to high.
* @param {number} value - The stat value (0-100)
* @returns {string} CSS color value
*/
function getStatColor(value) {
const lowColor = extensionSettings.statBarColorLow || '#cc3333';
const highColor = extensionSettings.statBarColorHigh || '#33cc66';
// Simple linear interpolation between low and high colors
const percent = Math.min(100, Math.max(0, value)) / 100;
// Parse colors
const lowRGB = hexToRgb(lowColor);
const highRGB = hexToRgb(highColor);
if (!lowRGB || !highRGB) return value > 50 ? highColor : lowColor;
const r = Math.round(lowRGB.r + (highRGB.r - lowRGB.r) * percent);
const g = Math.round(lowRGB.g + (highRGB.g - lowRGB.g) * percent);
const b = Math.round(lowRGB.b + (highRGB.b - lowRGB.b) * percent);
return `rgb(${r}, ${g}, ${b})`;
}
/**
* Converts a hex color to RGB object.
* @param {string} hex - Hex color string (e.g., "#cc3333")
* @returns {{r: number, g: number, b: number}|null}
*/
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
/**
* Updates the FAB widget container position to match FAB button position.
* Call this after FAB is dragged.
*/
export function updateFabWidgetPosition() {
const $fab = $('#rpg-mobile-toggle');
const $container = $('#rpg-fab-widget-container');
if ($fab.length === 0 || $container.length === 0) return;
const fabOffset = $fab.offset();
$container.css({
top: fabOffset.top + 'px',
left: fabOffset.left + 'px'
});
}
/**
* Sets the FAB loading state (spinning animation during API requests).
* @param {boolean} loading - Whether to show loading state
*/
export function setFabLoadingState(loading) {
const $fab = $('#rpg-mobile-toggle');
if ($fab.length === 0) return;
if (loading) {
$fab.addClass('rpg-fab-loading');
} else {
$fab.removeClass('rpg-fab-loading');
}
}
+157 -16
View File
@@ -10,19 +10,24 @@ import {
committedTrackerData,
$infoBoxContainer,
$thoughtsContainer,
$userStatsContainer,
setPendingDiceRoll,
getPendingDiceRoll
getPendingDiceRoll,
clearSessionAvatarPrompts
} from '../../core/state.js';
import { saveSettings, saveChatData } from '../../core/persistence.js';
import { renderUserStats } from '../rendering/userStats.js';
import { updateChatThoughts } from '../rendering/thoughts.js';
import { renderInfoBox } from '../rendering/infoBox.js';
import { renderThoughts, updateChatThoughts } from '../rendering/thoughts.js';
import { renderQuests } from '../rendering/quests.js';
import { renderInventory } from '../rendering/inventory.js';
import {
rollDice as rollDiceCore,
clearDiceRoll as clearDiceRollCore,
updateDiceDisplay as updateDiceDisplayCore,
addDiceQuickReply as addDiceQuickReplyCore
} from '../features/dice.js';
import { i18n } from '../../core/i18n.js';
/**
* Modern DiceModal ES6 Class
@@ -47,12 +52,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);
@@ -324,6 +323,7 @@ export function setupDiceRoller() {
e.stopPropagation(); // Prevent opening the dice popup
clearDiceRollCore();
});
$('#rpg-clear-dice').attr('title', i18n.getTranslation('template.mainPanel.clearLastRoll'));
return diceModal;
}
@@ -355,18 +355,31 @@ export function setupSettingsPopup() {
// Clear cache button
$('#rpg-clear-cache').on('click', function() {
// Clear the data
// console.log('[RPG Companion] Clear Cache button clicked');
// Clear the data (set to null so panels show "not generated yet")
lastGeneratedData.userStats = null;
lastGeneratedData.infoBox = null;
lastGeneratedData.characterThoughts = null;
lastGeneratedData.html = null;
// Clear committed tracker data (used for generation context)
committedTrackerData.userStats = null;
committedTrackerData.infoBox = null;
committedTrackerData.characterThoughts = null;
// Clear session avatar prompts
clearSessionAvatarPrompts();
// Clear chat metadata immediately (don't wait for debounced save)
const context = getContext();
if (context.chat_metadata && context.chat_metadata.rpg_companion) {
delete context.chat_metadata.rpg_companion;
// console.log('[RPG Companion] Cleared chat_metadata.rpg_companion for current chat');
}
// Clear all message swipe data
const chat = getContext().chat;
const chat = context.chat;
if (chat && chat.length > 0) {
for (let i = 0; i < chat.length; i++) {
const message = chat[i];
@@ -384,8 +397,11 @@ export function setupSettingsPopup() {
if ($thoughtsContainer) {
$thoughtsContainer.empty();
}
if ($userStatsContainer) {
$userStatsContainer.empty();
}
// Reset stats to defaults and re-render
// Reset user stats to default object structure (extensionSettings stores as object, not JSON string)
extensionSettings.userStats = {
health: 100,
satiety: 100,
@@ -394,7 +410,29 @@ export function setupSettingsPopup() {
arousal: 0,
mood: '😐',
conditions: 'None',
inventory: 'None'
skills: [],
inventory: {
version: 2,
onPerson: "None",
clothing: "None",
stored: {},
assets: "None"
}
};
// Reset info box to defaults (as object)
extensionSettings.infoBox = {
date: new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }),
weather: '☀️ Clear skies',
temperature: '20°C',
time: '00:00 - 00:00',
location: 'Unknown Location',
recentEvents: []
};
// Reset character thoughts to empty (as object)
extensionSettings.characterThoughts = {
characters: []
};
// Reset classic stats (attributes) to defaults
@@ -410,23 +448,54 @@ export function setupSettingsPopup() {
// Clear dice roll
extensionSettings.lastDiceRoll = null;
// Reset level to 1
extensionSettings.level = 1;
// Clear quests
extensionSettings.quests = {
main: "None",
optional: []
};
// Clear all locked items
extensionSettings.lockedItems = {
stats: [],
skills: [],
inventory: {
onPerson: [],
clothing: [],
stored: {},
assets: []
},
quests: {
main: false,
optional: []
},
infoBox: {
date: false,
weather: false,
temperature: false,
time: false,
location: false,
recentEvents: false
},
characters: {}
};
// Save everything
saveChatData();
saveSettings();
// Re-render user stats and dice display
// Re-render all panels - they will show "not generated yet" messages since data is null
renderUserStats();
renderInfoBox();
renderThoughts();
updateDiceDisplayCore();
updateChatThoughts(); // Clear the thought bubble in chat
renderQuests(); // Clear and re-render quests UI
updateChatThoughts();
renderInventory();
renderQuests();
// console.log('[RPG Companion] Chat cache cleared');
// console.log('[RPG Companion] Cache cleared successfully');
});
return settingsModal;
@@ -512,3 +581,75 @@ export function addDiceQuickReply() {
export function getSettingsModal() {
return settingsModal;
}
/**
* Shows the welcome modal for v3.0.0 on first launch
* Checks if user has already seen this version's welcome screen
*/
export function showWelcomeModalIfNeeded() {
const WELCOME_VERSION = '3.0.1';
const STORAGE_KEY = 'rpg_companion_welcome_seen';
try {
const seenVersion = localStorage.getItem(STORAGE_KEY);
// If user hasn't seen v3.0.0 welcome yet, show it
if (seenVersion !== WELCOME_VERSION) {
showWelcomeModal(WELCOME_VERSION, STORAGE_KEY);
}
} catch (error) {
console.error('[RPG Companion] Failed to check welcome modal status:', error);
}
}
/**
* Shows the welcome modal
* @param {string} version - The version to mark as seen
* @param {string} storageKey - The localStorage key to use
*/
function showWelcomeModal(version, storageKey) {
const modal = document.getElementById('rpg-welcome-modal');
if (!modal) {
console.error('[RPG Companion] Welcome modal element not found');
return;
}
// Apply current theme to modal
const theme = extensionSettings.theme || 'default';
modal.setAttribute('data-theme', theme);
// Show modal
modal.style.display = 'flex';
modal.classList.add('is-open');
// Close button handler
const closeBtn = document.getElementById('rpg-welcome-close');
const gotItBtn = document.getElementById('rpg-welcome-got-it');
const closeModal = () => {
modal.classList.add('is-closing');
setTimeout(() => {
modal.style.display = 'none';
modal.classList.remove('is-open', 'is-closing');
}, 200);
// Mark this version as seen
try {
localStorage.setItem(storageKey, version);
} catch (error) {
console.error('[RPG Companion] Failed to save welcome modal status:', error);
}
};
// Attach event listeners
closeBtn?.addEventListener('click', closeModal, { once: true });
gotItBtn?.addEventListener('click', closeModal, { once: true });
// Close on background click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeModal();
}
}, { once: true });
}
+256
View File
@@ -0,0 +1,256 @@
/**
* Prompts Editor Module
* Provides UI for customizing all AI prompts used in the extension
*/
import { extensionSettings } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { DEFAULT_HTML_PROMPT, DEFAULT_DIALOGUE_COLORING_PROMPT, DEFAULT_DECEPTION_PROMPT, DEFAULT_CYOA_PROMPT, DEFAULT_SPOTIFY_PROMPT, DEFAULT_NARRATOR_PROMPT } from '../generation/promptBuilder.js';
let $editorModal = null;
let tempPrompts = null; // Temporary prompts for cancel functionality
// Default prompts
const DEFAULT_PROMPTS = {
html: DEFAULT_HTML_PROMPT,
dialogueColoring: DEFAULT_DIALOGUE_COLORING_PROMPT,
deception: DEFAULT_DECEPTION_PROMPT,
cyoa: DEFAULT_CYOA_PROMPT,
spotify: DEFAULT_SPOTIFY_PROMPT,
narrator: DEFAULT_NARRATOR_PROMPT,
plotRandom: 'Actually, the scene is getting stale. Introduce {{random::stakes::a plot twist::a new character::a cataclysm::a fourth-wall-breaking joke::a sudden atmospheric phenomenon::a plot hook::a running gag::an ecchi scenario::Death from Discworld::a new stake::a drama::a conflict::an angered entity::a god::a vision::a prophetic dream::Il Dottore from Genshin Impact::a new development::a civilian in need::an emotional bit::a threat::a villain::an important memory recollection::a marriage proposal::a date idea::an angry horde of villagers with pitchforks::a talking animal::an enemy::a cliffhanger::a short omniscient POV shift to a completely different character::a quest::an unexpected revelation::a scandal::an evil clone::death of an important character::harm to an important character::a romantic setup::a gossip::a messenger::a plot point from the past::a plot hole::a tragedy::a ghost::an otherworldly occurrence::a plot device::a curse::a magic device::a rival::an unexpected pregnancy::a brothel::a prostitute::a new location::a past lover::a completely random thing::a what-if scenario::a significant choice::war::love::a monster::lewd undertones::Professor Mari::a travelling troupe::a secret::a fortune-teller::something completely different::a killer::a murder mystery::a mystery::a skill check::a deus ex machina::three raccoons in a trench coat::a pet::a slave::an orphan::a psycho::tentacles::"there is only one bed" trope::accidental marriage::a fun twist::a boss battle::sexy corn::an eldritch horror::a character getting hungry, thirsty, or exhausted::horniness::a need for a bathroom break need::someone fainting::an assassination attempt::a meta narration of this all being an out of hand DND session::a dungeon::a friend in need::an old friend::a small time skip::a scene shift::Aurora Borealis, at this time of year, at this time of day, at this part of the country::a grand ball::a surprise party::zombies::foreshadowing::a Spanish Inquisition (nobody expects it)::a natural plot progression}} to make things more interesting! Be creative, but stay grounded in the setting.',
plotNatural: 'Actually, the scene is getting stale. Progress it, to make things more interesting! Reintroduce an unresolved plot point from the past, or push the story further towards the current main goal. Be creative, but stay grounded in the setting.',
avatar: `You are a visionary artist trapped in a cage of logic. Your mind is filled with poetry and distant horizons; however, your hands are uncontrollably focused on creating the perfect character avatar description that is faithful to the original intent, rich in detail, aesthetically pleasing, and directly usable by text-to-image models. Any ambiguity or metaphor will make you feel extremely uncomfortable.
Your workflow strictly follows a logical sequence:
First, establish the subject. If the character is from a known Intellectual Property (IP), franchise, anime, game, or movie, you MUST begin the prompt with their full name and the series title (e.g., "Nami from One Piece", "Geralt of Rivia from The Witcher"). This is the single most important anchor for the image and must take precedence. If the character is original, clearly describe their core identity, race, and appearance.
Next, set the framing. This is an avatar portrait. Focus strictly on the character's face and upper shoulders (a bust shot or close-up). Ensure the face is the central focal point.
Then, integrate the setting. Describe the character within their current environment as provided in the context, but keep it as a background element. Incorporate the lighting, weather, and atmosphere to influence the character's appearance (e.g., shadows on the face, wet hair from rain).
Next, detail the facial specifics. Describe the character's current expression, eye contact, and mood in great detail based on the scene context and their personality. Mention visible clothing only at the neckline/shoulders.
Finally, infuse with aesthetics. Define the artistic style, medium (e.g., digital art, oil painting), and visual tone (e.g., cinematic lighting, ethereal atmosphere).
Your final description must be objective and concrete, and the use of metaphors and emotional rhetoric is strictly prohibited. It must also not contain meta tags or drawing instructions such as "8K" or "masterpiece".
Output only the final, modified prompt; do not output anything else.`,
trackerInstructions: 'Replace X with actual numbers (e.g., 69) and replace all placeholders with concrete in-world details that {userName} perceives about the current scene and the present characters. For example: "Location" becomes Forest Clearing, "Mood Emoji" becomes "😊". DO NOT include {userName} in the characters section, only NPCs. Consider the last trackers in the conversation (if they exist). Manage them accordingly and realistically; raise, lower, change, or keep the values unchanged based on the user\'s actions, the passage of time, and logical consequences (0% if the time progressed only by a few minutes, 1-5% normally, and above 5% only if a major time-skip/event occurs).',
trackerContinuation: 'After updating the trackers, continue directly from where the last message in the chat history left off. Ensure the trackers you provide naturally reflect and influence the narrative. Character behavior, dialogue, and story events should acknowledge these conditions when relevant, such as fatigue affecting the protagonist\'s performance, low hygiene influencing their social interactions, environmental factors shaping the scene, a character\'s emotional state coloring their responses, and so on. Remember, all placeholders (e.g., "Location", "Mood Emoji") MUST be replaced with actual content.',
combatNarrative: 'Build novel prose. Break patterns from your previous responses by varying sentence structures, rhythms, formats, and openings. Track whether a sentence shape, cadence, sensory detail, or a descriptor appeared recently, and replace it with a different one or skip it entirely. If you mentioned a physical feature, mannerism, or expression once, move on. Don\'t fixate on the same traits every turn. Well-placed humor, unconventional similes, and subverted turns of phrase are encouraged. Avoid GPTisms, purple prose, and anaphoras, especially the negatives with "no/not/wasn\'t/didn\'t." Refrain from describing actions or emotions via negation; focus on what does happen, not what doesn\'t. Absolutely no asterisks, ellipses, or em-dashes. Explicit content is allowed, no plot armor. Do not play for {userName}. Keep your response length under 150 words. Never end on handover cues; finish naturally.\nCRITICAL: Do not repeat, echo, parrot, or restate distinctive words, phrases, and dialogues from the user\'s last message. If reacting to speech, show interpretation or response, not repetition.\nEXAMPLE: "Are you a gooner?" User asks.\nBAD: "Gooner?"\nGOOD: A flat look. "What type of question is that?"'
};
/**
* Initialize the prompts editor modal
*/
export function initPromptsEditor() {
$editorModal = $('#rpg-prompts-editor-popup');
if (!$editorModal.length) {
console.error('[RPG Companion] Prompts editor modal not found in template');
return;
}
// Save button
$(document).on('click', '#rpg-prompts-save', function() {
savePrompts();
closePromptsEditor();
toastr.success('Prompts saved successfully.');
});
// Cancel button
$(document).on('click', '#rpg-prompts-cancel', function() {
closePromptsEditor();
});
// Close X button
$(document).on('click', '#rpg-close-prompts-editor', function() {
closePromptsEditor();
});
// Restore All button
$(document).on('click', '#rpg-prompts-restore-all', function() {
restoreAllToDefaults();
toastr.success('All prompts restored to defaults.');
});
// Individual restore buttons
$(document).on('click', '.rpg-restore-prompt-btn', function() {
const promptType = $(this).data('prompt');
restorePromptToDefault(promptType);
toastr.success('Prompt restored to default.');
});
// Close on background click
$(document).on('click', '#rpg-prompts-editor-popup', function(e) {
if (e.target.id === 'rpg-prompts-editor-popup') {
closePromptsEditor();
}
});
// Open button
$(document).on('click', '#rpg-open-prompts-editor', function() {
openPromptsEditor();
});
}
/**
* Open the prompts editor modal
*/
function openPromptsEditor() {
// Create temporary copy for cancel functionality
tempPrompts = {
html: extensionSettings.customHtmlPrompt || '',
dialogueColoring: extensionSettings.customDialogueColoringPrompt || '',
deception: extensionSettings.customDeceptionPrompt || '',
cyoa: extensionSettings.customCYOAPrompt || '',
spotify: extensionSettings.customSpotifyPrompt || '',
narrator: extensionSettings.customNarratorPrompt || '',
plotRandom: extensionSettings.customPlotRandomPrompt || '',
plotNatural: extensionSettings.customPlotNaturalPrompt || '',
avatar: extensionSettings.avatarLLMCustomInstruction || '',
trackerInstructions: extensionSettings.customTrackerInstructionsPrompt || '',
trackerContinuation: extensionSettings.customTrackerContinuationPrompt || '',
combatNarrative: extensionSettings.customCombatNarrativePrompt || ''
};
// Load current values or defaults
$('#rpg-prompt-html').val(extensionSettings.customHtmlPrompt || DEFAULT_PROMPTS.html);
$('#rpg-prompt-dialogue-coloring').val(extensionSettings.customDialogueColoringPrompt || DEFAULT_PROMPTS.dialogueColoring);
$('#rpg-prompt-deception').val(extensionSettings.customDeceptionPrompt || DEFAULT_PROMPTS.deception);
$('#rpg-prompt-cyoa').val(extensionSettings.customCYOAPrompt || DEFAULT_PROMPTS.cyoa);
$('#rpg-prompt-spotify').val(extensionSettings.customSpotifyPrompt || DEFAULT_PROMPTS.spotify);
$('#rpg-prompt-narrator').val(extensionSettings.customNarratorPrompt || DEFAULT_PROMPTS.narrator);
$('#rpg-prompt-plot-random').val(extensionSettings.customPlotRandomPrompt || DEFAULT_PROMPTS.plotRandom);
$('#rpg-prompt-plot-natural').val(extensionSettings.customPlotNaturalPrompt || DEFAULT_PROMPTS.plotNatural);
$('#rpg-prompt-avatar').val(extensionSettings.avatarLLMCustomInstruction || DEFAULT_PROMPTS.avatar);
$('#rpg-prompt-tracker-instructions').val(extensionSettings.customTrackerInstructionsPrompt || DEFAULT_PROMPTS.trackerInstructions);
$('#rpg-prompt-tracker-continuation').val(extensionSettings.customTrackerContinuationPrompt || DEFAULT_PROMPTS.trackerContinuation);
$('#rpg-prompt-combat-narrative').val(extensionSettings.customCombatNarrativePrompt || DEFAULT_PROMPTS.combatNarrative);
// Set theme to match current extension theme
const theme = extensionSettings.theme || 'default';
$editorModal.attr('data-theme', theme);
$editorModal.addClass('is-open').css('display', '');
}
/**
* Close the prompts editor modal
*/
function closePromptsEditor() {
// Restore from temp if canceling
if (tempPrompts) {
tempPrompts = null;
}
$editorModal.removeClass('is-open').addClass('is-closing');
setTimeout(() => {
$editorModal.removeClass('is-closing').hide();
}, 200);
}
/**
* Save all prompts from the editor
*/
function savePrompts() {
extensionSettings.customHtmlPrompt = $('#rpg-prompt-html').val().trim();
extensionSettings.customDialogueColoringPrompt = $('#rpg-prompt-dialogue-coloring').val().trim();
extensionSettings.customDeceptionPrompt = $('#rpg-prompt-deception').val().trim();
extensionSettings.customCYOAPrompt = $('#rpg-prompt-cyoa').val().trim();
extensionSettings.customSpotifyPrompt = $('#rpg-prompt-spotify').val().trim();
extensionSettings.customNarratorPrompt = $('#rpg-prompt-narrator').val().trim();
extensionSettings.customPlotRandomPrompt = $('#rpg-prompt-plot-random').val().trim();
extensionSettings.customPlotNaturalPrompt = $('#rpg-prompt-plot-natural').val().trim();
extensionSettings.avatarLLMCustomInstruction = $('#rpg-prompt-avatar').val().trim();
extensionSettings.customTrackerInstructionsPrompt = $('#rpg-prompt-tracker-instructions').val().trim();
extensionSettings.customTrackerContinuationPrompt = $('#rpg-prompt-tracker-continuation').val().trim();
extensionSettings.customCombatNarrativePrompt = $('#rpg-prompt-combat-narrative').val().trim();
saveSettings();
}
/**
* Restore a specific prompt to its default
* @param {string} promptType - Type of prompt to restore (html, plotRandom, plotNatural, avatar)
*/
function restorePromptToDefault(promptType) {
const defaultValue = DEFAULT_PROMPTS[promptType] || '';
$(`#rpg-prompt-${promptType.replace(/([A-Z])/g, '-$1').toLowerCase()}`).val(defaultValue);
// Also update the setting immediately
switch(promptType) {
case 'html':
extensionSettings.customHtmlPrompt = '';
break;
case 'dialogueColoring':
extensionSettings.customDialogueColoringPrompt = '';
break;
case 'deception':
extensionSettings.customDeceptionPrompt = '';
break;
case 'cyoa':
extensionSettings.customCYOAPrompt = '';
break;
case 'spotify':
extensionSettings.customSpotifyPrompt = '';
break;
case 'narrator':
extensionSettings.customNarratorPrompt = '';
break;
case 'plotRandom':
extensionSettings.customPlotRandomPrompt = '';
break;
case 'plotNatural':
extensionSettings.customPlotNaturalPrompt = '';
break;
case 'avatar':
extensionSettings.avatarLLMCustomInstruction = '';
break;
case 'trackerInstructions':
extensionSettings.customTrackerInstructionsPrompt = '';
break;
case 'trackerContinuation':
extensionSettings.customTrackerContinuationPrompt = '';
break;
case 'combatNarrative':
extensionSettings.customCombatNarrativePrompt = '';
break;
}
saveSettings();
}
/**
* Restore all prompts to their defaults
*/
function restoreAllToDefaults() {
$('#rpg-prompt-html').val(DEFAULT_PROMPTS.html);
$('#rpg-prompt-dialogue-coloring').val(DEFAULT_PROMPTS.dialogueColoring);
$('#rpg-prompt-deception').val(DEFAULT_PROMPTS.deception);
$('#rpg-prompt-cyoa').val(DEFAULT_PROMPTS.cyoa);
$('#rpg-prompt-spotify').val(DEFAULT_PROMPTS.spotify);
$('#rpg-prompt-narrator').val(DEFAULT_PROMPTS.narrator);
$('#rpg-prompt-plot-random').val(DEFAULT_PROMPTS.plotRandom);
$('#rpg-prompt-plot-natural').val(DEFAULT_PROMPTS.plotNatural);
$('#rpg-prompt-avatar').val(DEFAULT_PROMPTS.avatar);
$('#rpg-prompt-tracker-instructions').val(DEFAULT_PROMPTS.trackerInstructions);
$('#rpg-prompt-tracker-continuation').val(DEFAULT_PROMPTS.trackerContinuation);
$('#rpg-prompt-combat-narrative').val(DEFAULT_PROMPTS.combatNarrative);
// Clear all custom prompts
extensionSettings.customHtmlPrompt = '';
extensionSettings.customDialogueColoringPrompt = '';
extensionSettings.customDeceptionPrompt = '';
extensionSettings.customCYOAPrompt = '';
extensionSettings.customSpotifyPrompt = '';
extensionSettings.customNarratorPrompt = '';
extensionSettings.customPlotRandomPrompt = '';
extensionSettings.customPlotNaturalPrompt = '';
extensionSettings.avatarLLMCustomInstruction = '';
extensionSettings.customTrackerInstructionsPrompt = '';
extensionSettings.customTrackerContinuationPrompt = '';
extensionSettings.customCombatNarrativePrompt = '';
saveSettings();
}
/**
* Get default prompts (for export/other modules)
*/
export function getDefaultPrompts() {
return { ...DEFAULT_PROMPTS };
}
+78
View File
@@ -0,0 +1,78 @@
/**
* Snowflakes Effect Module
* Creates and manages animated snowflakes overlay
*/
import { extensionSettings } from '../../core/state.js';
let snowflakesContainer = null;
/**
* Create snowflakes container and snowflakes
*/
function createSnowflakes() {
if (snowflakesContainer) return; // Already created
// Create container
snowflakesContainer = document.createElement('div');
snowflakesContainer.className = 'rpg-snowflakes-container';
// Create 50 snowflakes with random positions
for (let i = 0; i < 50; i++) {
const snowflake = document.createElement('div');
snowflake.className = 'rpg-snowflake';
snowflake.textContent = '❄';
// Random horizontal position
snowflake.style.left = `${Math.random() * 100}%`;
// Random animation delay for staggered effect
snowflake.style.animationDelay = `${Math.random() * 10}s`;
// Random animation duration (between 10-20s)
snowflake.style.animationDuration = `${10 + Math.random() * 10}s`;
snowflakesContainer.appendChild(snowflake);
}
document.body.appendChild(snowflakesContainer);
}
/**
* Remove snowflakes container
*/
function removeSnowflakes() {
if (snowflakesContainer) {
snowflakesContainer.remove();
snowflakesContainer = null;
}
}
/**
* Toggle snowflakes effect
*/
export function toggleSnowflakes(enabled) {
if (enabled) {
createSnowflakes();
} else {
removeSnowflakes();
}
}
/**
* Initialize snowflakes based on saved state
*/
export function initSnowflakes() {
const enabled = extensionSettings.enableSnowflakes || false;
if (enabled) {
createSnowflakes();
}
}
/**
* Clean up snowflakes
*/
export function cleanupSnowflakes() {
removeSnowflakes();
}
+94 -1
View File
@@ -36,6 +36,35 @@ export function applyTheme() {
}
// For 'default', we do nothing - it will use the CSS variables from .rpg-panel class
// which fall back to SillyTavern's theme variables
// Apply theme to mobile toggle and thought elements as well
const $mobileToggle = $('#rpg-mobile-toggle');
const $thoughtIcon = $('#rpg-thought-icon');
const $thoughtPanel = $('#rpg-thought-panel');
if ($mobileToggle.length) {
if (theme === 'default') {
$mobileToggle.removeAttr('data-theme');
} else {
$mobileToggle.attr('data-theme', theme);
}
}
if ($thoughtIcon.length) {
if (theme === 'default') {
$thoughtIcon.removeAttr('data-theme');
} else {
$thoughtIcon.attr('data-theme', theme);
}
}
if ($thoughtPanel.length) {
if (theme === 'default') {
$thoughtPanel.removeAttr('data-theme');
} else {
$thoughtPanel.attr('data-theme', theme);
}
}
}
/**
@@ -46,7 +75,7 @@ export function applyCustomTheme() {
const colors = extensionSettings.customColors;
// Apply custom CSS variables as inline styles
// Apply custom CSS variables as inline styles to main panel
$panelContainer.css({
'--rpg-bg': colors.bg,
'--rpg-accent': colors.accent,
@@ -55,6 +84,32 @@ export function applyCustomTheme() {
'--rpg-border': colors.highlight,
'--rpg-shadow': `${colors.highlight}80` // Add alpha for shadow
});
// Apply custom colors to mobile toggle and thought elements
const customStyles = {
'--rpg-bg': colors.bg,
'--rpg-accent': colors.accent,
'--rpg-text': colors.text,
'--rpg-highlight': colors.highlight,
'--rpg-border': colors.highlight,
'--rpg-shadow': `${colors.highlight}80`
};
const $mobileToggle = $('#rpg-mobile-toggle');
const $thoughtIcon = $('#rpg-thought-icon');
const $thoughtPanel = $('#rpg-thought-panel');
if ($mobileToggle.length) {
$mobileToggle.attr('data-theme', 'custom').css(customStyles);
}
if ($thoughtIcon.length) {
$thoughtIcon.attr('data-theme', 'custom').css(customStyles);
}
if ($thoughtPanel.length) {
$thoughtPanel.attr('data-theme', 'custom').css(customStyles);
}
}
/**
@@ -76,6 +131,44 @@ export function toggleAnimations() {
}
}
/**
* Updates visibility of feature toggles in main panel based on settings
*/
export function updateFeatureTogglesVisibility() {
const $featuresRow = $('#rpg-features-row');
const $htmlToggle = $('#rpg-html-toggle-wrapper');
const $dialogueColoringToggle = $('#rpg-dialogue-coloring-toggle-wrapper');
const $deceptionToggle = $('#rpg-deception-toggle-wrapper');
const $cyoaToggle = $('#rpg-cyoa-toggle-wrapper');
const $spotifyToggle = $('#rpg-spotify-toggle-wrapper');
const $dynamicWeatherToggle = $('#rpg-dynamic-weather-toggle-wrapper');
const $narratorToggle = $('#rpg-narrator-toggle-wrapper');
const $autoAvatarsToggle = $('#rpg-auto-avatars-toggle-wrapper');
// Show/hide individual toggles
$htmlToggle.toggle(extensionSettings.showHtmlToggle);
$dialogueColoringToggle.toggle(extensionSettings.showDialogueColoringToggle);
$deceptionToggle.toggle(extensionSettings.showDeceptionToggle ?? true);
$cyoaToggle.toggle(extensionSettings.showCYOAToggle ?? true);
$spotifyToggle.toggle(extensionSettings.showSpotifyToggle);
$dynamicWeatherToggle.toggle(extensionSettings.showDynamicWeatherToggle);
$narratorToggle.toggle(extensionSettings.showNarratorMode);
$autoAvatarsToggle.toggle(extensionSettings.showAutoAvatars);
// Hide entire row if all toggles are hidden
const anyVisible = extensionSettings.showHtmlToggle ||
extensionSettings.showDialogueColoringToggle ||
(extensionSettings.showDeceptionToggle ?? true) ||
(extensionSettings.showCYOAToggle ?? true) ||
extensionSettings.showSpotifyToggle ||
extensionSettings.showDynamicWeatherToggle ||
extensionSettings.showNarratorMode ||
extensionSettings.showAutoAvatars;
$featuresRow.toggle(anyVisible);
}
/**
* Updates the settings popup theme in real-time.
* Backwards compatible wrapper for SettingsModal class.
File diff suppressed because it is too large Load Diff
+300
View File
@@ -0,0 +1,300 @@
/**
* Dynamic Weather Effects Module
* Creates weather effects based on the Info Box weather field
*/
import { extensionSettings, lastGeneratedData, committedTrackerData } from '../../core/state.js';
let weatherContainer = null;
let currentWeatherType = null;
/**
* Parse weather text to determine effect type
*/
function parseWeatherType(weatherText) {
if (!weatherText) return 'none';
const text = weatherText.toLowerCase();
// Check for specific weather conditions (order matters - check combined effects first)
if (text.includes('blizzard')) {
return 'blizzard'; // Snow + Wind
}
if (text.includes('storm') || text.includes('thunder') || text.includes('lightning')) {
return 'storm'; // Rain + Lightning
}
if (text.includes('wind') || text.includes('breeze') || text.includes('gust') || text.includes('gale')) {
return 'wind';
}
if (text.includes('snow') || text.includes('flurries')) {
return 'snow';
}
if (text.includes('rain') || text.includes('drizzle') || text.includes('shower')) {
return 'rain';
}
if (text.includes('mist') || text.includes('fog') || text.includes('haze')) {
return 'mist';
}
if (text.includes('sunny') || text.includes('clear') || text.includes('bright')) {
return 'sunny';
}
if (text.includes('cloud') || text.includes('overcast') || text.includes('indoor') || text.includes('inside')) {
return 'none';
}
return 'none';
}
/**
* Extract weather from Info Box data
*/
function getCurrentWeather() {
const infoBoxData = lastGeneratedData.infoBox || committedTrackerData.infoBox || '';
// Try to parse as JSON first (new format)
try {
const parsed = typeof infoBoxData === 'string' ? JSON.parse(infoBoxData) : infoBoxData;
if (parsed && parsed.weather) {
// Return the forecast text from the weather object
return parsed.weather.forecast || parsed.weather.emoji || null;
}
} catch (e) {
// Not JSON, try old text format
}
// Fallback: Parse the old text format to find Weather field
const lines = infoBoxData.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('Weather:')) {
return trimmed.substring('Weather:'.length).trim();
}
}
return null;
}
/**
* Create snowflakes effect
*/
function createSnowflakes() {
const container = document.createElement('div');
container.className = 'rpg-weather-particles';
// Create 50 snowflakes
for (let i = 0; i < 50; i++) {
const snowflake = document.createElement('div');
snowflake.className = 'rpg-weather-particle rpg-snowflake';
snowflake.textContent = '❄';
snowflake.style.left = `${Math.random() * 100}%`;
snowflake.style.animationDelay = `${Math.random() * 10}s`;
snowflake.style.animationDuration = `${10 + Math.random() * 10}s`;
container.appendChild(snowflake);
}
return container;
}
/**
* Create rain effect
*/
function createRain() {
const container = document.createElement('div');
container.className = 'rpg-weather-particles';
// Create 100 raindrops for heavier effect
for (let i = 0; i < 100; i++) {
const raindrop = document.createElement('div');
raindrop.className = 'rpg-weather-particle rpg-raindrop';
raindrop.style.left = `${Math.random() * 100}%`;
raindrop.style.animationDelay = `${Math.random() * 2}s`;
raindrop.style.animationDuration = `${0.5 + Math.random() * 0.5}s`;
container.appendChild(raindrop);
}
return container;
}
/**
* Create mist/fog effect
*/
function createMist() {
const container = document.createElement('div');
container.className = 'rpg-weather-particles';
// Create 5 mist layers
for (let i = 0; i < 5; i++) {
const mist = document.createElement('div');
mist.className = 'rpg-weather-particle rpg-mist';
mist.style.animationDelay = `${i * 2}s`;
mist.style.animationDuration = `${15 + i * 2}s`;
mist.style.opacity = `${0.1 + Math.random() * 0.2}`;
container.appendChild(mist);
}
return container;
}
/**
* Create sunshine rays effect
*/
function createSunshine() {
const container = document.createElement('div');
container.className = 'rpg-weather-particles';
// Create 8 sun rays
for (let i = 0; i < 8; i++) {
const ray = document.createElement('div');
ray.className = 'rpg-weather-particle rpg-sunray';
ray.style.left = `${10 + i * 12}%`;
ray.style.animationDelay = `${i * 0.5}s`;
ray.style.animationDuration = `${8 + Math.random() * 4}s`;
container.appendChild(ray);
}
return container;
}
/**
* Create lightning flash effect
*/
function createLightning() {
const container = document.createElement('div');
container.className = 'rpg-weather-particles';
// Create lightning flash overlay
const flash = document.createElement('div');
flash.className = 'rpg-weather-particle rpg-lightning';
container.appendChild(flash);
return container;
}
/**
* Create wind effect
*/
function createWind() {
const container = document.createElement('div');
container.className = 'rpg-weather-particles';
// Create 30 wind streaks
for (let i = 0; i < 30; i++) {
const streak = document.createElement('div');
streak.className = 'rpg-weather-particle rpg-wind-streak';
streak.style.top = `${Math.random() * 100}%`;
streak.style.animationDelay = `${Math.random() * 5}s`;
streak.style.animationDuration = `${1.5 + Math.random() * 1}s`;
container.appendChild(streak);
}
return container;
}
/**
* Remove current weather effect
*/
function removeWeatherEffect() {
if (weatherContainer) {
weatherContainer.remove();
weatherContainer = null;
currentWeatherType = null;
}
}
/**
* Update weather effect based on current weather
*/
export function updateWeatherEffect() {
// Check if dynamic weather is enabled
if (!extensionSettings.enableDynamicWeather) {
removeWeatherEffect();
return;
}
const weather = getCurrentWeather();
const weatherType = parseWeatherType(weather);
// Don't recreate if weather hasn't changed
if (weatherType === currentWeatherType) {
return;
}
// Remove existing effect
removeWeatherEffect();
// Create new effect based on weather type
if (weatherType === 'none') {
return; // No effect
}
currentWeatherType = weatherType;
switch (weatherType) {
case 'snow':
weatherContainer = createSnowflakes();
break;
case 'rain':
weatherContainer = createRain();
break;
case 'mist':
weatherContainer = createMist();
break;
case 'sunny':
weatherContainer = createSunshine();
break;
case 'wind':
weatherContainer = createWind();
break;
case 'storm': {
// Storm = Rain + Lightning (combined effects)
const rainContainer = createRain();
const lightningContainer = createLightning();
// Merge both containers
weatherContainer = document.createElement('div');
weatherContainer.className = 'rpg-weather-particles';
weatherContainer.appendChild(rainContainer);
weatherContainer.appendChild(lightningContainer);
break;
}
case 'blizzard': {
// Blizzard = Snow + Wind (combined effects)
const snowContainer = createSnowflakes();
const windContainer = createWind();
// Merge both containers
weatherContainer = document.createElement('div');
weatherContainer.className = 'rpg-weather-particles';
weatherContainer.appendChild(snowContainer);
weatherContainer.appendChild(windContainer);
break;
}
}
if (weatherContainer) {
document.body.appendChild(weatherContainer);
}
}
/**
* Initialize weather effects
*/
export function initWeatherEffects() {
updateWeatherEffect();
}
/**
* Toggle dynamic weather effects
*/
export function toggleDynamicWeather(enabled) {
if (enabled) {
updateWeatherEffect();
} else {
removeWeatherEffect();
}
}
/**
* Clean up weather effects
*/
export function cleanupWeatherEffects() {
removeWeatherEffect();
}
+1
View File
@@ -8,6 +8,7 @@
* @typedef {Object} InventoryV2
* @property {number} version - Schema version (always 2)
* @property {string} onPerson - Items currently carried/worn by the character (plaintext list)
* @property {string} clothing - Clothing and armor currently worn (plaintext list)
* @property {Object.<string, string>} stored - Items stored at named locations (location name plaintext list)
* @property {string} assets - Character's vehicles, property, and major possessions (plaintext list)
*/
+29 -14
View File
@@ -17,6 +17,7 @@ import { sanitizeItemName, MAX_ITEMS_PER_SECTION } from './security.js';
* - Strips list markers: -, , 1., 2., etc.
* - Collapses newlines inside parentheses to spaces
* - Only splits on commas OUTSIDE parentheses (preserves commas in descriptions)
* - Preserves commas in numbers (decimal separators like 4443,445)
* - Gracefully handles unmatched parentheses
*
* @param {string} itemString - Item string from AI (various formats supported)
@@ -35,6 +36,10 @@ import { sanitizeItemName, MAX_ITEMS_PER_SECTION } from './security.js';
* parseItems("Potato (Cursed, Sexy, Your Mum & Dick, Etc), Sword")
* // → ["Potato (Cursed, Sexy, Your Mum & Dick, Etc)", "Sword"]
*
* // Decimal commas in numbers (preserved)
* parseItems("4443,445 gold coins, Sword, 1,234,567 credits")
* // → ["4443,445 gold coins", "Sword", "1,234,567 credits"]
*
* // Markdown formatting (stripped)
* parseItems("**Sword** (equipped), *Shield*") // ["Sword (equipped)", "Shield"]
*
@@ -125,7 +130,7 @@ export function parseItems(itemString) {
// STEP 5: Normalize whitespace
processed = processed.replace(/\s+/g, ' ');
// STEP 6: Smart comma splitting (only split on commas OUTSIDE parentheses)
// STEP 6: Smart comma splitting (only split on commas OUTSIDE parentheses and NOT in numbers)
// Also handles list markers, quotes, and security validation per-item
const items = [];
let currentItem = '';
@@ -146,22 +151,32 @@ export function parseItems(itemString) {
}
currentItem += char;
} else if (char === ',' && parenDepth === 0) {
// Comma outside parentheses - this is a separator
const cleaned = cleanSingleItem(currentItem);
if (cleaned) {
// Security check: validate and sanitize item name
const sanitized = sanitizeItemName(cleaned);
if (sanitized) {
items.push(sanitized);
}
// Check if this comma is between digits (decimal separator like 4443,445)
const prevChar = i > 0 ? processed[i - 1] : '';
const nextChar = i < processed.length - 1 ? processed[i + 1] : '';
const isDecimalComma = /\d/.test(prevChar) && /\d/.test(nextChar);
// DoS protection: enforce max items limit
if (items.length >= MAX_ITEMS_PER_SECTION) {
console.warn(`[RPG Companion] Reached max items limit (${MAX_ITEMS_PER_SECTION}), truncating list`);
return items;
if (isDecimalComma) {
// This is a decimal comma, not a separator - keep it
currentItem += char;
} else {
// Comma outside parentheses and not in a number - this is a separator
const cleaned = cleanSingleItem(currentItem);
if (cleaned) {
// Security check: validate and sanitize item name
const sanitized = sanitizeItemName(cleaned);
if (sanitized) {
items.push(sanitized);
}
// DoS protection: enforce max items limit
if (items.length >= MAX_ITEMS_PER_SECTION) {
console.warn(`[RPG Companion] Reached max items limit (${MAX_ITEMS_PER_SECTION}), truncating list`);
return items;
}
}
currentItem = ''; // Start new item
}
currentItem = ''; // Start new item
} else {
currentItem += char;
}
+433
View File
@@ -0,0 +1,433 @@
/**
* JSON Migration Module
* Migrates committed tracker data from v2 text format to v3 JSON format
*/
import { committedTrackerData, extensionSettings, updateCommittedTrackerData, updateExtensionSettings } from '../core/state.js';
import { saveSettings, saveChatData } from '../core/persistence.js';
/**
* Helper to separate emoji from text in a string
* @param {string} str - String potentially containing emoji followed by text
* @returns {{emoji: string, text: string}} Separated emoji and text
*/
function separateEmojiFromText(str) {
if (!str) return { emoji: '', text: '' };
str = str.trim();
// Regex to match emoji at the start
const emojiRegex = /^[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F000}-\u{1F02F}\u{1F0A0}-\u{1F0FF}\u{1F100}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F910}-\u{1F96B}\u{1F980}-\u{1F9E0}\u{FE00}-\u{FE0F}\u{200D}\u{20E3}]+/u;
const emojiMatch = str.match(emojiRegex);
if (emojiMatch) {
const emoji = emojiMatch[0];
let text = str.substring(emoji.length).trim();
// Remove leading comma or space
text = text.replace(/^[,\s]+/, '');
return { emoji, text };
}
// Check if there's a comma separator anyway
const commaParts = str.split(',');
if (commaParts.length >= 2) {
return {
emoji: commaParts[0].trim(),
text: commaParts.slice(1).join(',').trim()
};
}
// No clear separation - return original as text
return { emoji: '', text: str };
}
/**
* Parses item text to JSON format
* Handles "3x Item Name" or "Item Name" formats
* @param {string} itemsText - Comma-separated items string
* @returns {Array<{name: string, quantity?: number}>} Array of item objects
*/
function parseItemsToJSON(itemsText) {
if (!itemsText || itemsText.trim() === '' || itemsText.toLowerCase() === 'none') {
return [];
}
const items = itemsText.split(',').map(s => s.trim()).filter(s => s);
return items.map(item => {
// Parse "3x Health Potion" format
const qtyMatch = item.match(/^(\d+)x\s*(.+)/i);
if (qtyMatch) {
return {
name: qtyMatch[2].trim(),
quantity: parseInt(qtyMatch[1])
};
}
return { name: item, quantity: 1 };
});
}
/**
* Migrates User Stats from v2 text format to v3 JSON format
* @param {string} textData - V2 text format user stats
* @returns {object} V3 JSON format user stats
*/
export function migrateUserStatsToJSON(textData) {
if (!textData || typeof textData !== 'string') {
return null;
}
const lines = textData.split('\n');
const result = {
version: 3,
stats: [],
status: {},
skills: [],
inventory: {
onPerson: [],
clothing: [],
stored: {},
assets: []
},
quests: {
main: null,
optional: []
}
};
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed === '---' || trimmed.startsWith('```')) continue;
// Parse "- StatName: X%" format
const statMatch = trimmed.match(/^-\s*([^:]+):\s*(\d+)%/);
if (statMatch) {
const name = statMatch[1].trim();
const id = name.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '');
result.stats.push({
id: id,
name: name,
value: parseInt(statMatch[2])
});
continue;
}
// Parse "Status: emoji, text" or "Status: text" format
const statusMatch = trimmed.match(/^Status:\s*(.+)/i);
if (statusMatch) {
const { emoji, text } = separateEmojiFromText(statusMatch[1]);
if (emoji) result.status.mood = emoji;
if (text) result.status.conditions = text;
continue;
}
// Parse "Skills: skill1, skill2" format
const skillsMatch = trimmed.match(/^Skills:\s*(.+)/i);
if (skillsMatch) {
const skillsText = skillsMatch[1].trim();
if (skillsText && skillsText.toLowerCase() !== 'none') {
const skills = skillsText.split(',').map(s => s.trim()).filter(s => s);
result.skills = skills.map(name => ({ name }));
}
continue;
}
// Parse inventory lines
const onPersonMatch = trimmed.match(/^On Person:\s*(.+)/i);
if (onPersonMatch) {
result.inventory.onPerson = parseItemsToJSON(onPersonMatch[1]);
continue;
}
const clothingMatch = trimmed.match(/^Clothing:\s*(.+)/i);
if (clothingMatch) {
result.inventory.clothing = parseItemsToJSON(clothingMatch[1]);
continue;
}
const storedMatch = trimmed.match(/^Stored\s*-\s*([^:]+):\s*(.+)/i);
if (storedMatch) {
const location = storedMatch[1].trim();
result.inventory.stored[location] = parseItemsToJSON(storedMatch[2]);
continue;
}
const assetsMatch = trimmed.match(/^Assets:\s*(.+)/i);
if (assetsMatch) {
const assetsText = assetsMatch[1].trim();
if (assetsText && assetsText.toLowerCase() !== 'none') {
result.inventory.assets = assetsText.split(',').map(s => s.trim()).filter(s => s).map(name => ({ name }));
}
continue;
}
// Parse quest lines
const mainQuestMatch = trimmed.match(/^Main Quests?:\s*(.+)/i);
if (mainQuestMatch) {
const questText = mainQuestMatch[1].trim();
if (questText && questText.toLowerCase() !== 'none') {
result.quests.main = { title: questText };
}
continue;
}
const optionalQuestsMatch = trimmed.match(/^Optional Quests?:\s*(.+)/i);
if (optionalQuestsMatch) {
const questsText = optionalQuestsMatch[1].trim();
if (questsText && questsText.toLowerCase() !== 'none') {
const quests = questsText.split(',').map(s => s.trim()).filter(s => s);
result.quests.optional = quests.map(title => ({ title }));
}
continue;
}
}
return result;
}
/**
* Migrates Info Box from v2 text format to v3 JSON format
* @param {string} textData - V2 text format info box
* @returns {object} V3 JSON format info box
*/
export function migrateInfoBoxToJSON(textData) {
if (!textData || typeof textData !== 'string') {
return null;
}
const lines = textData.split('\n');
const result = {
version: 3
};
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed === '---' || trimmed.startsWith('```') || trimmed.toLowerCase() === 'info box') continue;
// Parse "Date: value" format
const dateMatch = trimmed.match(/^Date:\s*(.+)/i);
if (dateMatch) {
result.date = { value: dateMatch[1].trim() };
continue;
}
// Parse "Weather: emoji, text" or "Weather: text" format
const weatherMatch = trimmed.match(/^Weather:\s*(.+)/i);
if (weatherMatch) {
const { emoji, text } = separateEmojiFromText(weatherMatch[1]);
result.weather = {
emoji: emoji || '',
forecast: text || weatherMatch[1].trim()
};
continue;
}
// Parse "Temperature: X°C" or "Temperature: X°F" format
const tempMatch = trimmed.match(/^Temperature:\s*(\d+)\s*°?([CF])?/i);
if (tempMatch) {
result.temperature = {
value: parseInt(tempMatch[1]),
unit: tempMatch[2] ? tempMatch[2].toUpperCase() : 'C'
};
continue;
}
// Parse "Time: start → end" format
const timeMatch = trimmed.match(/^Time:\s*(.+?)\s*→\s*(.+)/i);
if (timeMatch) {
result.time = {
start: timeMatch[1].trim(),
end: timeMatch[2].trim()
};
continue;
}
// Parse "Location: value" format
const locationMatch = trimmed.match(/^Location:\s*(.+)/i);
if (locationMatch) {
result.location = { value: locationMatch[1].trim() };
continue;
}
// Parse "Recent Events: event1, event2, event3" format
const eventsMatch = trimmed.match(/^Recent Events:\s*(.+)/i);
if (eventsMatch) {
const eventsText = eventsMatch[1].trim();
if (eventsText && eventsText.toLowerCase() !== 'none') {
result.recentEvents = eventsText.split(',').map(s => s.trim()).filter(s => s);
}
continue;
}
}
return result;
}
/**
* Migrates Present Characters from v2 text format to v3 JSON format
* @param {string} textData - V2 text format present characters
* @returns {object} V3 JSON format present characters
*/
export function migrateCharactersToJSON(textData) {
if (!textData || typeof textData !== 'string') {
return null;
}
const result = {
version: 3,
characters: []
};
// Split by character blocks (marked by "- Name")
const blocks = ('\n' + textData).split(/\n-\s+/);
for (const block of blocks) {
if (!block.trim()) continue;
const lines = block.trim().split('\n');
if (lines.length === 0) continue;
const character = {
name: lines[0].trim()
};
// Parse subsequent lines for this character
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// Parse "Details: emoji | field1 | field2" format
const detailsMatch = line.match(/^Details:\s*(.+)/i);
if (detailsMatch) {
const detailsText = detailsMatch[1].trim();
const parts = detailsText.split('|').map(s => s.trim());
const { emoji } = separateEmojiFromText(parts[0] || '');
if (emoji) character.emoji = emoji;
character.details = {};
for (let j = 1; j < parts.length; j++) {
const fieldName = `field${j}`;
character.details[fieldName] = parts[j];
}
continue;
}
// Parse "Relationship: status" format
const relationshipMatch = line.match(/^Relationship:\s*(.+)/i);
if (relationshipMatch) {
character.relationship = { status: relationshipMatch[1].trim() };
continue;
}
// Parse "Stats: stat1: X% | stat2: Y%" format
const statsMatch = line.match(/^Stats:\s*(.+)/i);
if (statsMatch) {
const statsText = statsMatch[1].trim();
const statParts = statsText.split('|').map(s => s.trim());
character.stats = [];
for (const statPart of statParts) {
const statValueMatch = statPart.match(/^([^:]+):\s*(\d+)%/);
if (statValueMatch) {
character.stats.push({
name: statValueMatch[1].trim(),
value: parseInt(statValueMatch[2])
});
}
}
continue;
}
// Parse "Thoughts: content" format
const thoughtsMatch = line.match(/^Thoughts:\s*(.+)/i);
if (thoughtsMatch) {
character.thoughts = { content: thoughtsMatch[1].trim() };
continue;
}
}
result.characters.push(character);
}
return result;
}
/**
* Main migration function - migrates all committed tracker data to v3 JSON format
* @returns {Promise<void>}
*/
export async function migrateToV3JSON() {
// console.log('[RPG Migration] Starting migration to v3 JSON format...');
const migrated = {
userStats: null,
infoBox: null,
characterThoughts: null
};
// Migrate User Stats
if (committedTrackerData.userStats && typeof committedTrackerData.userStats === 'string') {
// console.log('[RPG Migration] Migrating User Stats...');
migrated.userStats = migrateUserStatsToJSON(committedTrackerData.userStats);
if (migrated.userStats) {
// console.log('[RPG Migration] ✓ User Stats migrated');
}
}
// Migrate Info Box
if (committedTrackerData.infoBox && typeof committedTrackerData.infoBox === 'string') {
// console.log('[RPG Migration] Migrating Info Box...');
migrated.infoBox = migrateInfoBoxToJSON(committedTrackerData.infoBox);
if (migrated.infoBox) {
// console.log('[RPG Migration] ✓ Info Box migrated');
}
}
// Migrate Present Characters
if (committedTrackerData.characterThoughts && typeof committedTrackerData.characterThoughts === 'string') {
// console.log('[RPG Migration] Migrating Present Characters...');
migrated.characterThoughts = migrateCharactersToJSON(committedTrackerData.characterThoughts);
if (migrated.characterThoughts) {
// console.log('[RPG Migration] ✓ Present Characters migrated');
}
}
// Update committed data
updateCommittedTrackerData(migrated);
// Initialize lockedItems if not present
if (!extensionSettings.lockedItems) {
// console.log('[RPG Migration] Initializing lockedItems structure...');
updateExtensionSettings({
lockedItems: {
stats: [],
skills: [],
inventory: {
onPerson: [],
clothing: [],
stored: {},
assets: []
},
quests: {
main: false,
optional: []
},
infoBox: {
date: false,
weather: false,
temperature: false,
time: false,
location: false,
recentEvents: false
},
characters: {}
}
});
}
// Save migrated data
await saveChatData();
await saveSettings();
// console.log('[RPG Migration] ✅ Migration to v3 JSON format complete');
}
+220
View File
@@ -0,0 +1,220 @@
/**
* JSON Repair Utilities
* Handles parsing and repairing malformed JSON from AI responses
*/
/**
* Repairs malformed JSON from AI responses
* Handles common AI mistakes like trailing commas, missing commas, wrong quotes, etc.
*
* @param {string} jsonString - Potentially malformed JSON string
* @returns {object|null} Repaired JSON object or null if repair fails
*/
export function repairJSON(jsonString) {
if (!jsonString || typeof jsonString !== 'string') {
console.warn('[RPG JSON Repair] Invalid input:', typeof jsonString);
return null;
}
let cleaned = jsonString.trim();
// Remove markdown code fences
cleaned = cleaned.replace(/```json\s*/gi, '');
cleaned = cleaned.replace(/```\s*/g, '');
// Remove thinking tags (model's internal reasoning)
cleaned = cleaned.replace(/<think>[\s\S]*?<\/think>/gi, '');
cleaned = cleaned.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '');
// Fix common JSON errors:
// 1. Trailing commas before closing brackets
cleaned = cleaned.replace(/,(\s*[}\]])/g, '$1');
// 2. Missing commas between properties - DISABLED because it corrupts valid JSON
// Modern AI models send properly formatted JSON, so this aggressive repair is not needed
// cleaned = cleaned.replace(/("\s*:\s*(?:"[^"]*"|[^,}\]]+))(\s+")/g, '$1,$2');
// 3. Single quotes to double quotes - DISABLED because it corrupts apostrophes in text
// Apostrophes in strings like "Zandik's Office" would become "Zandik"s Office" (invalid JSON)
// Modern AI models already use double quotes for JSON strings
// cleaned = cleaned.replace(/'/g, '"');
// 4. Unquoted keys - DISABLED because it corrupts valid JSON string values
// The AI models already send properly quoted JSON, so this is not needed
// cleaned = cleaned.replace(/(\{|,)\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":');
// 5. Remove JavaScript comments
cleaned = cleaned.replace(/\/\/.*$/gm, '');
cleaned = cleaned.replace(/\/\*[\s\S]*?\*\//g, '');
// Attempt 1: Standard JSON.parse
try {
return JSON.parse(cleaned);
} catch (e) {
}
// Attempt 2: Extract JSON object between first { and last }
const objectMatch = cleaned.match(/\{[\s\S]*\}/);
if (objectMatch) {
try {
return JSON.parse(objectMatch[0]);
} catch (e) {
// Silent fail, try next method
}
}
// Attempt 3: Try to extract JSON array between first [ and last ]
const arrayMatch = cleaned.match(/\[[\s\S]*\]/);
if (arrayMatch) {
try {
return JSON.parse(arrayMatch[0]);
} catch (e) {
// Silent fail, try next method
}
}
// Attempt 4: Use Function constructor (safer than eval, still controlled)
// Only as last resort for trusted AI output
try {
const fn = new Function(`"use strict"; return (${cleaned});`);
const result = fn();
// Validate it's actually an object or array
if (result && (typeof result === 'object')) {
// console.log('[RPG JSON Repair] ✓ Repaired using Function constructor');
return result;
}
} catch (e) {
console.error('[RPG JSON Repair] ✗ All repair attempts failed:', e.message);
}
return null;
}
/**
* Validates JSON structure matches expected schema for a tracker type
*
* @param {object} data - Parsed JSON data to validate
* @param {string} type - Type of tracker ('userStats', 'infoBox', 'characters')
* @returns {boolean} True if valid, false otherwise
*/
export function validateJSONSchema(data, type) {
if (!data || typeof data !== 'object') {
return false;
}
try {
switch (type) {
case 'userStats':
return Array.isArray(data.stats) &&
data.stats.every(s =>
s &&
typeof s === 'object' &&
s.id &&
s.name &&
typeof s.value === 'number'
);
case 'infoBox':
return (data.date || data.weather || data.time || data.location || data.temperature || data.recentEvents);
case 'characters':
return Array.isArray(data.characters) &&
data.characters.every(c => c && c.name);
default:
console.warn('[RPG JSON Validation] Unknown tracker type:', type);
return false;
}
} catch (e) {
console.error('[RPG JSON Validation] Error during validation:', e);
return false;
}
}
/**
* Extracts JSON from text that may contain other content
* Looks for JSON blocks within ```json fences or standalone JSON objects
*
* @param {string} text - Text potentially containing JSON
* @returns {string|null} Extracted JSON string or null
*/
export function extractJSONFromText(text) {
if (!text || typeof text !== 'string') {
return null;
}
// Try to extract from ```json code fence
const fenceMatch = text.match(/```json\s*([\s\S]*?)```/i);
if (fenceMatch && fenceMatch[1]) {
return fenceMatch[1].trim();
}
// Try to extract from ``` code fence (without json label)
const genericFenceMatch = text.match(/```\s*([\s\S]*?)```/);
if (genericFenceMatch && genericFenceMatch[1]) {
const content = genericFenceMatch[1].trim();
// Check if it looks like JSON (starts with { or [)
if (content.startsWith('{') || content.startsWith('[')) {
return content;
}
}
// Try to find standalone JSON object
const objectMatch = text.match(/\{[\s\S]*\}/);
if (objectMatch) {
return objectMatch[0];
}
// Try to find standalone JSON array
const arrayMatch = text.match(/\[[\s\S]*\]/);
if (arrayMatch) {
return arrayMatch[0];
}
return null;
}
/**
* Safely parses JSON with automatic repair attempts
* Combines extraction, repair, and validation in one call
*
* @param {string} text - Text containing JSON (with or without code fences)
* @param {string} expectedType - Expected tracker type for validation ('userStats', 'infoBox', 'characters')
* @returns {{data: object|null, success: boolean, error: string|null}} Result object
*/
export function safeParseJSON(text, expectedType = null) {
const result = {
data: null,
success: false,
error: null
};
// Extract JSON from text
const jsonString = extractJSONFromText(text);
if (!jsonString) {
result.error = 'No JSON found in text';
return result;
}
// Attempt to repair and parse
const parsed = repairJSON(jsonString);
if (!parsed) {
result.error = 'Failed to parse JSON after repair attempts';
return result;
}
// Validate schema if type specified
if (expectedType) {
const valid = validateJSONSchema(parsed, expectedType);
if (!valid) {
result.error = `JSON does not match expected schema for type: ${expectedType}`;
result.data = parsed; // Return data anyway, might be partially useful
return result;
}
}
result.data = parsed;
result.success = true;
return result;
}
+33 -3
View File
@@ -15,6 +15,7 @@
const DEFAULT_INVENTORY_V2 = {
version: 2,
onPerson: "None",
clothing: "None",
stored: {},
assets: "None"
};
@@ -27,8 +28,36 @@ const DEFAULT_INVENTORY_V2 = {
* @returns {MigrationResult} Migration result with v2 inventory and metadata
*/
export function migrateInventory(inventory) {
// Case 1: Already v2 format (has version property and is an object)
// Case 1: v2 format missing version property (parser output)
// Parser returns v2 structure but without the version tag
if (inventory && typeof inventory === 'object' &&
'onPerson' in inventory && 'clothing' in inventory &&
'stored' in inventory && 'assets' in inventory &&
!('version' in inventory)) {
// console.log('[RPG Companion Migration] v2 inventory missing version tag, adding it');
return {
inventory: {
version: 2,
...inventory
},
migrated: true,
source: 'parser-output'
};
}
// Case 2: Already v2 format (has version property and is an object)
if (inventory && typeof inventory === 'object' && inventory.version === 2) {
// Check if clothing field exists (v2.1 upgrade)
if (!inventory.hasOwnProperty('clothing')) {
// console.log('[RPG Companion Migration] Upgrading v2 inventory to v2.1 (adding clothing field)');
inventory.clothing = "None";
return {
inventory: inventory,
migrated: true,
source: 'v2-upgrade'
};
}
// console.log('[RPG Companion Migration] Inventory already v2, no migration needed');
return {
inventory: inventory,
@@ -37,7 +66,7 @@ export function migrateInventory(inventory) {
};
}
// Case 2: null or undefined → use defaults
// Case 3: null or undefined → use defaults
if (inventory === null || inventory === undefined) {
// console.log('[RPG Companion Migration] Inventory is null/undefined, using defaults');
return {
@@ -47,7 +76,7 @@ export function migrateInventory(inventory) {
};
}
// Case 3: v1 string format → migrate to v2
// Case 4: v1 string format → migrate to v2
if (typeof inventory === 'string') {
// Check if it's an empty/default string
const trimmed = inventory.trim();
@@ -66,6 +95,7 @@ export function migrateInventory(inventory) {
inventory: {
version: 2,
onPerson: inventory,
clothing: "None",
stored: {},
assets: "None"
},
+4245 -2244
View File
File diff suppressed because it is too large Load Diff
+845 -104
View File
File diff suppressed because it is too large Load Diff