@carmaclouds/core 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/dist/cache/CacheManager.d.ts.map +1 -0
  2. package/dist/cache/CacheManager.js +131 -0
  3. package/dist/cache/CacheManager.js.map +1 -0
  4. package/dist/index.d.ts +18 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +22 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/ir/index.d.ts +11 -0
  9. package/dist/ir/index.d.ts.map +1 -0
  10. package/dist/ir/index.js +9 -0
  11. package/dist/ir/index.js.map +1 -0
  12. package/dist/ir/normalize.d.ts +10 -0
  13. package/dist/ir/normalize.d.ts.map +1 -0
  14. package/dist/ir/normalize.js +207 -0
  15. package/dist/ir/normalize.js.map +1 -0
  16. package/dist/ir/persistence.d.ts +26 -0
  17. package/dist/ir/persistence.d.ts.map +1 -0
  18. package/dist/ir/persistence.js +21 -0
  19. package/dist/ir/persistence.js.map +1 -0
  20. package/dist/ir/sync.d.ts +12 -0
  21. package/dist/ir/sync.d.ts.map +1 -0
  22. package/dist/ir/sync.js +36 -0
  23. package/dist/ir/sync.js.map +1 -0
  24. package/dist/ir/types.d.ts +143 -0
  25. package/dist/ir/types.d.ts.map +1 -0
  26. package/dist/ir/types.js +13 -0
  27. package/dist/ir/types.js.map +1 -0
  28. package/dist/ir/views/dnd5e.d.ts +40 -0
  29. package/dist/ir/views/dnd5e.d.ts.map +1 -0
  30. package/dist/ir/views/dnd5e.js +50 -0
  31. package/dist/ir/views/dnd5e.js.map +1 -0
  32. package/dist/render/character.d.ts +19 -0
  33. package/dist/render/character.d.ts.map +1 -0
  34. package/dist/render/character.js +156 -0
  35. package/dist/render/character.js.map +1 -0
  36. package/dist/render/h.d.ts +27 -0
  37. package/dist/render/h.d.ts.map +1 -0
  38. package/dist/render/h.js +64 -0
  39. package/dist/render/h.js.map +1 -0
  40. package/dist/render/index.d.ts +11 -0
  41. package/dist/render/index.d.ts.map +1 -0
  42. package/dist/render/index.js +8 -0
  43. package/dist/render/index.js.map +1 -0
  44. package/dist/render/mount.d.ts +31 -0
  45. package/dist/render/mount.d.ts.map +1 -0
  46. package/dist/render/mount.js +63 -0
  47. package/dist/render/mount.js.map +1 -0
  48. package/dist/supabase/fields.d.ts.map +1 -0
  49. package/dist/supabase/fields.js +120 -0
  50. package/dist/supabase/fields.js.map +1 -0
  51. package/dist/types/character.d.ts.map +1 -0
  52. package/dist/types/character.js +5 -0
  53. package/dist/types/character.js.map +1 -0
  54. package/package.json +73 -0
  55. package/src/browser.js +51 -0
  56. package/src/cache/CacheManager.ts +174 -0
  57. package/src/common/browser-polyfill.js +319 -0
  58. package/src/common/debug.js +123 -0
  59. package/src/common/html-utils.js +134 -0
  60. package/src/common/theme-manager.js +265 -0
  61. package/src/index.ts +25 -0
  62. package/src/ir/__fixtures__/dnd5e-character.json +75962 -0
  63. package/src/ir/__fixtures__/non-dnd-character.json +14218 -0
  64. package/src/ir/index.ts +10 -0
  65. package/src/ir/normalize.ts +245 -0
  66. package/src/ir/persistence.ts +37 -0
  67. package/src/ir/sync.ts +49 -0
  68. package/src/ir/types.ts +161 -0
  69. package/src/ir/views/dnd5e.ts +94 -0
  70. package/src/lib/indexeddb-cache.js +320 -0
  71. package/src/modules/action-announcements.js +102 -0
  72. package/src/modules/action-display.js +1557 -0
  73. package/src/modules/action-executor.js +860 -0
  74. package/src/modules/action-filters.js +167 -0
  75. package/src/modules/action-options.js +117 -0
  76. package/src/modules/card-creator.js +142 -0
  77. package/src/modules/character-portrait.js +169 -0
  78. package/src/modules/character-trait-popups.js +959 -0
  79. package/src/modules/character-traits.js +814 -0
  80. package/src/modules/class-feature-edge-cases.js +1320 -0
  81. package/src/modules/color-utils.js +69 -0
  82. package/src/modules/combat-maneuver-edge-cases.js +660 -0
  83. package/src/modules/companions-manager.js +178 -0
  84. package/src/modules/concentration-tracker.js +178 -0
  85. package/src/modules/data-manager.js +514 -0
  86. package/src/modules/dice-roller.js +719 -0
  87. package/src/modules/effects-manager.js +743 -0
  88. package/src/modules/feature-modals.js +1264 -0
  89. package/src/modules/formula-resolver.js +444 -0
  90. package/src/modules/gm-mode.js +184 -0
  91. package/src/modules/health-modals.js +399 -0
  92. package/src/modules/hp-management.js +752 -0
  93. package/src/modules/inventory-manager.js +242 -0
  94. package/src/modules/macro-system.js +825 -0
  95. package/src/modules/notification-system.js +92 -0
  96. package/src/modules/racial-feature-edge-cases.js +746 -0
  97. package/src/modules/resource-manager.js +775 -0
  98. package/src/modules/sheet-builder.js +654 -0
  99. package/src/modules/spell-action-modals.js +583 -0
  100. package/src/modules/spell-cards.js +602 -0
  101. package/src/modules/spell-casting.js +723 -0
  102. package/src/modules/spell-display.js +314 -0
  103. package/src/modules/spell-edge-cases.js +509 -0
  104. package/src/modules/spell-macros.js +201 -0
  105. package/src/modules/spell-modals.js +1221 -0
  106. package/src/modules/spell-slots.js +224 -0
  107. package/src/modules/status-bar-bridge.js +101 -0
  108. package/src/modules/ui-utilities.js +284 -0
  109. package/src/modules/warlock-invocations.js +219 -0
  110. package/src/modules/window-management.js +211 -0
  111. package/src/render/character.ts +234 -0
  112. package/src/render/h.ts +74 -0
  113. package/src/render/index.ts +10 -0
  114. package/src/render/mount.ts +94 -0
  115. package/src/supabase/client.js +1383 -0
  116. package/src/supabase/config.js +60 -0
  117. package/src/supabase/fields.ts +129 -0
  118. package/src/types/character.ts +85 -0
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Action Filters Module
3
+ *
4
+ * Handles action filtering, categorization, and rebuilding.
5
+ * Loaded as a plain script (no ES6 modules) to export to window.
6
+ *
7
+ * Functions exported to globalThis:
8
+ * - categorizeAction(action)
9
+ * - initializeActionFilters()
10
+ * - rebuildActions()
11
+ *
12
+ * State variables exported to globalThis:
13
+ * - actionFilters
14
+ */
15
+
16
+ (function() {
17
+ 'use strict';
18
+
19
+ // ===== STATE =====
20
+
21
+ // Filter state for actions
22
+ const actionFilters = {
23
+ actionType: 'all',
24
+ category: 'all',
25
+ search: ''
26
+ };
27
+
28
+ // ===== FUNCTIONS =====
29
+
30
+ /**
31
+ * Helper function to categorize an action
32
+ * @param {Object} action - Action object
33
+ * @returns {string} Category: 'healing', 'damage', or 'utility'
34
+ */
35
+ function categorizeAction(action) {
36
+ const name = (action.name || '').toLowerCase();
37
+ const damageType = (action.damageType || '').toLowerCase();
38
+ const actionType = (action.actionType || '').toLowerCase();
39
+
40
+ // Check for healing based on damage type or name
41
+ if (damageType.includes('heal') || name.includes('heal') || name.includes('cure')) {
42
+ return 'healing';
43
+ }
44
+
45
+ // Check for damage based on:
46
+ // 1. Actual damage formula with dice
47
+ if (action.damage && action.damage.includes('d')) {
48
+ return 'damage';
49
+ }
50
+
51
+ // 2. Has attack roll (weapons, attacks)
52
+ if (action.attackRoll && action.attackRoll !== '(none)') {
53
+ return 'damage';
54
+ }
55
+
56
+ // 3. Action type is explicitly 'attack'
57
+ if (actionType === 'attack') {
58
+ return 'damage';
59
+ }
60
+
61
+ // 4. Name contains attack/weapon keywords
62
+ if (name.includes('attack') || name.includes('strike') || name.includes('bow') ||
63
+ name.includes('sword') || name.includes('axe') || name.includes('weapon')) {
64
+ return 'damage';
65
+ }
66
+
67
+ // Everything else is utility
68
+ return 'utility';
69
+ }
70
+
71
+ /**
72
+ * Initialize action filter event listeners
73
+ */
74
+ function initializeActionFilters() {
75
+ // Actions search filter
76
+ const actionsSearch = document.getElementById('actions-search');
77
+ if (actionsSearch) {
78
+ actionsSearch.addEventListener('input', (e) => {
79
+ actionFilters.search = e.target.value.toLowerCase();
80
+ rebuildActions();
81
+ });
82
+ }
83
+
84
+ // Action type filters (action, bonus action, reaction, etc.)
85
+ document.querySelectorAll('[data-type="action-type"]').forEach(btn => {
86
+ btn.addEventListener('click', () => {
87
+ actionFilters.actionType = btn.dataset.filter;
88
+ document.querySelectorAll('[data-type="action-type"]').forEach(b => b.classList.remove('active'));
89
+ btn.classList.add('active');
90
+ rebuildActions();
91
+ });
92
+ });
93
+
94
+ // Action category filters (damage, healing, utility)
95
+ document.querySelectorAll('[data-type="action-category"]').forEach(btn => {
96
+ btn.addEventListener('click', () => {
97
+ actionFilters.category = btn.dataset.filter;
98
+ document.querySelectorAll('[data-type="action-category"]').forEach(b => b.classList.remove('active'));
99
+ btn.classList.add('active');
100
+ rebuildActions();
101
+ });
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Rebuild actions with current filters
107
+ */
108
+ function rebuildActions() {
109
+ if (!characterData || !characterData.actions) return;
110
+
111
+ // Filter actions based on current filter state
112
+ let filteredActions = characterData.actions.filter(action => {
113
+ // Filter by action type (action, bonus, reaction, free)
114
+ if (actionFilters.actionType !== 'all') {
115
+ const actionType = (action.actionType || '').toLowerCase();
116
+ if (actionType !== actionFilters.actionType) {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ // Filter by category (damage, healing, utility)
122
+ if (actionFilters.category !== 'all') {
123
+ const category = categorizeAction(action);
124
+ if (category !== actionFilters.category) {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ // Filter by search term
130
+ if (actionFilters.search) {
131
+ const searchLower = actionFilters.search.toLowerCase();
132
+ const nameMatch = (action.name || '').toLowerCase().includes(searchLower);
133
+ const descMatch = (action.description || '').toLowerCase().includes(searchLower);
134
+ const summaryMatch = (action.summary || '').toLowerCase().includes(searchLower);
135
+ if (!nameMatch && !descMatch && !summaryMatch) {
136
+ return false;
137
+ }
138
+ }
139
+
140
+ return true;
141
+ });
142
+
143
+ debug.log(`🔍 Filtered actions: ${filteredActions.length}/${characterData.actions.length} (type=${actionFilters.actionType}, category=${actionFilters.category}, search="${actionFilters.search}")`);
144
+
145
+ const container = document.getElementById('actions-container');
146
+ buildActionsDisplay(container, filteredActions);
147
+ }
148
+
149
+ // ===== EXPORTS =====
150
+
151
+ window.categorizeAction = categorizeAction;
152
+ window.initializeActionFilters = initializeActionFilters;
153
+ window.rebuildActions = rebuildActions;
154
+
155
+ // Export state variable with getter and setter
156
+ Object.defineProperty(globalThis, 'actionFilters', {
157
+ get: () => actionFilters,
158
+ set: (value) => {
159
+ if (value && typeof value === 'object') {
160
+ Object.assign(actionFilters, value);
161
+ }
162
+ }
163
+ });
164
+
165
+ console.log('✅ Action Filters module loaded');
166
+
167
+ })();
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Action Options Module
3
+ *
4
+ * Handles generation of action options (attack, damage, healing buttons).
5
+ * Loaded as a plain script (no ES6 modules) to export to globalThis.
6
+ *
7
+ * Functions exported to globalThis:
8
+ * - getActionOptions(action)
9
+ */
10
+
11
+ (function() {
12
+ 'use strict';
13
+
14
+ /**
15
+ * Get available action options (attack/damage rolls) with edge case modifications
16
+ * @param {Object} action - Action object
17
+ * @returns {Object} Object with { options: [], skipNormalButtons: boolean }
18
+ */
19
+ function getActionOptions(action) {
20
+ const options = [];
21
+
22
+ // Check for attack
23
+ if (action.attackRoll) {
24
+ // Convert to full formula if it's just a number (legacy data)
25
+ let formula = action.attackRoll;
26
+ if (typeof formula === 'number' || (typeof formula === 'string' && !formula.includes('d20'))) {
27
+ const bonus = parseInt(formula);
28
+ formula = bonus >= 0 ? `1d20+${bonus}` : `1d20${bonus}`;
29
+ }
30
+
31
+ options.push({
32
+ type: 'attack',
33
+ label: '🎯 Attack Roll',
34
+ formula: formula,
35
+ icon: '🎯',
36
+ color: '#e74c3c'
37
+ });
38
+ }
39
+
40
+ // Check for damage/healing rolls
41
+ debug.log(`🎲 Action "${action.name}" damage check:`, {
42
+ damage: action.damage,
43
+ damageType: typeof action.damage,
44
+ damageValue: action.damage,
45
+ attackRoll: action.attackRoll
46
+ });
47
+
48
+ // Handle damage as string or extract from object
49
+ let damageFormula = action.damage;
50
+ if (typeof action.damage === 'object' && action.damage !== null) {
51
+ damageFormula = action.damage.value || action.damage.calculation || action.damage.formula || '';
52
+ debug.log(`🔍 Extracted damage from object: "${damageFormula}"`);
53
+ }
54
+
55
+ const isValidDiceFormula = damageFormula && typeof damageFormula === 'string' && (/\d*d\d+/.test(damageFormula) || /\d*d\d+/.test(damageFormula.replace(/\s*\+\s*/g, '+')));
56
+ debug.log(`✅ Damage validation result: isValid=${isValidDiceFormula}, formula="${damageFormula}"`);
57
+ if (isValidDiceFormula) {
58
+ const isHealing = action.damageType && action.damageType.toLowerCase().includes('heal');
59
+ const isTempHP = action.damageType && (
60
+ action.damageType.toLowerCase() === 'temphp' ||
61
+ action.damageType.toLowerCase() === 'temporary' ||
62
+ action.damageType.toLowerCase().includes('temp')
63
+ );
64
+
65
+ // Use different text for healing vs damage vs features
66
+ let btnText;
67
+ if (isHealing) {
68
+ btnText = '💚 Heal';
69
+ } else if (action.actionType === 'feature' || !action.attackRoll) {
70
+ btnText = '🎲 Roll';
71
+ } else {
72
+ btnText = '💥 Damage Roll';
73
+ }
74
+
75
+ options.push({
76
+ type: isHealing ? 'healing' : (isTempHP ? 'temphp' : 'damage'),
77
+ label: btnText,
78
+ formula: damageFormula,
79
+ icon: isTempHP ? '🛡️' : (isHealing ? '💚' : '💥'),
80
+ color: isTempHP ? '#3498db' : (isHealing ? '#27ae60' : '#e67e22')
81
+ });
82
+ }
83
+
84
+ // Apply edge case modifications
85
+ let edgeCaseResult;
86
+
87
+ // Check class feature edge cases first
88
+ if (isClassFeatureEdgeCase(action.name)) {
89
+ edgeCaseResult = applyClassFeatureEdgeCaseModifications(action, options);
90
+ debug.log(`🔍 Edge case applied for "${action.name}": skipNormalButtons = ${edgeCaseResult.skipNormalButtons}`);
91
+ }
92
+ // Check racial feature edge cases
93
+ else if (isRacialFeatureEdgeCase(action.name)) {
94
+ edgeCaseResult = applyRacialFeatureEdgeCaseModifications(action, options);
95
+ debug.log(`🔍 Edge case applied for "${action.name}": skipNormalButtons = ${edgeCaseResult.skipNormalButtons}`);
96
+ }
97
+ // Check combat maneuver edge cases
98
+ else if (isCombatManeuverEdgeCase(action.name)) {
99
+ edgeCaseResult = applyCombatManeuverEdgeCaseModifications(action, options);
100
+ debug.log(`🔍 Edge case applied for "${action.name}": skipNormalButtons = ${edgeCaseResult.skipNormalButtons}`);
101
+ }
102
+ // Default - no edge cases
103
+ else {
104
+ edgeCaseResult = { options, skipNormalButtons: false };
105
+ debug.log(`🔍 No edge case for "${action.name}": skipNormalButtons = false`);
106
+ }
107
+
108
+ return edgeCaseResult;
109
+ }
110
+
111
+ // ===== EXPORTS =====
112
+
113
+ globalThis.getActionOptions = getActionOptions;
114
+
115
+ console.log('✅ Action Options module loaded');
116
+
117
+ })();
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Card Creator Utilities
3
+ * Functions for creating UI card elements
4
+ */
5
+
6
+ /**
7
+ * Create a simple card element
8
+ * @param {string} title - Card title
9
+ * @param {string} main - Main content (e.g., bonus value)
10
+ * @param {string} sub - Sub content (optional)
11
+ * @param {Function} onClick - Click handler
12
+ * @returns {HTMLElement} Card element
13
+ */
14
+ function createCard(title, main, sub, onClick) {
15
+ const card = document.createElement('div');
16
+ card.className = 'card';
17
+ card.innerHTML = `
18
+ <strong>${title}</strong><br>
19
+ <span class="bonus">${main}</span><br>
20
+ ${sub ? `<span class="bonus">${sub}</span>` : ''}
21
+ `;
22
+ card.addEventListener('click', onClick);
23
+ return card;
24
+ }
25
+
26
+ /**
27
+ * Create a spell card element
28
+ * @param {object} spell - Spell data object
29
+ * @param {number} index - Spell index
30
+ * @returns {HTMLElement} Spell card element
31
+ */
32
+ function createSpellCard(spell, index) {
33
+ const card = document.createElement('div');
34
+ card.className = 'spell-card';
35
+
36
+ const header = document.createElement('div');
37
+ header.className = 'spell-header';
38
+
39
+ // Build tags string
40
+ let tags = '';
41
+ if (spell.concentration) {
42
+ tags += '<span class="concentration-tag">🧠 Concentration</span>';
43
+ }
44
+ if (spell.ritual) {
45
+ tags += '<span class="ritual-tag">📖 Ritual</span>';
46
+ }
47
+
48
+ header.innerHTML = `
49
+ <div>
50
+ <span style="font-weight: bold;">${spell.name}</span>
51
+ ${spell.level ? `<span style="margin-left: 10px; color: var(--text-secondary);">Level ${spell.level}</span>` : ''}
52
+ ${tags}
53
+ </div>
54
+ <div style="display: flex; gap: 8px;">
55
+ <button class="cast-btn" data-spell-index="${index}">✨ Cast</button>
56
+ <button class="toggle-btn">▼ Details</button>
57
+ </div>
58
+ `;
59
+
60
+ const desc = document.createElement('div');
61
+ desc.className = 'spell-description';
62
+ desc.id = `spell-desc-${index}`;
63
+ desc.innerHTML = `
64
+ ${spell.castingTime ? `<div><strong>Casting Time:</strong> ${spell.castingTime}</div>` : ''}
65
+ ${spell.range ? `<div><strong>Range:</strong> ${spell.range}</div>` : ''}
66
+ ${spell.components ? `<div><strong>Components:</strong> ${spell.components}</div>` : ''}
67
+ ${spell.duration ? `<div><strong>Duration:</strong> ${spell.duration}</div>` : ''}
68
+ ${spell.school ? `<div><strong>School:</strong> ${spell.school}</div>` : ''}
69
+ ${spell.source ? `<div><strong>Source:</strong> ${spell.source}</div>` : ''}
70
+ ${spell.description ? `<div style="margin-top: 10px;">${spell.description}</div>` : ''}
71
+ `;
72
+
73
+ card.appendChild(header);
74
+ card.appendChild(desc);
75
+
76
+ // Toggle description visibility
77
+ const toggleBtn = header.querySelector('.toggle-btn');
78
+ toggleBtn.addEventListener('click', (e) => {
79
+ e.stopPropagation();
80
+ desc.classList.toggle('expanded');
81
+ toggleBtn.textContent = desc.classList.contains('expanded') ? '▲ Hide' : '▼ Details';
82
+ });
83
+
84
+ return card;
85
+ }
86
+
87
+ /**
88
+ * Create an action card element
89
+ * @param {object} action - Action data object
90
+ * @param {number} index - Action index
91
+ * @returns {HTMLElement} Action card element
92
+ */
93
+ function createActionCard(action, index) {
94
+ const card = document.createElement('div');
95
+ card.className = 'action-card';
96
+
97
+ const header = document.createElement('div');
98
+ header.innerHTML = `
99
+ <div style="display: flex; justify-content: space-between; align-items: center;">
100
+ <div>
101
+ <strong>${action.name || 'Action'}</strong>
102
+ ${action.uses ? `<span class="uses-badge">${action.uses} uses</span>` : ''}
103
+ </div>
104
+ <div class="action-buttons" style="display: flex; gap: 8px;">
105
+ ${action.attackRoll ? `<button class="attack-btn" data-action-index="${index}">⚔️ Attack</button>` : ''}
106
+ ${action.damage ? `<button class="damage-btn" data-action-index="${index}">💥 Damage</button>` : ''}
107
+ ${action.uses ? `<button class="use-btn" data-action-index="${index}">✨ Use</button>` : ''}
108
+ <button class="toggle-btn">▼</button>
109
+ </div>
110
+ </div>
111
+ `;
112
+
113
+ const desc = document.createElement('div');
114
+ desc.className = 'action-description';
115
+ desc.id = `action-desc-${index}`;
116
+ if (action.description) {
117
+ desc.innerHTML = `<div>${action.description}</div>`;
118
+ }
119
+
120
+ card.appendChild(header);
121
+ card.appendChild(desc);
122
+
123
+ // Toggle description visibility
124
+ const toggleBtn = header.querySelector('.toggle-btn');
125
+ toggleBtn.addEventListener('click', (e) => {
126
+ e.stopPropagation();
127
+ desc.classList.toggle('expanded');
128
+ toggleBtn.textContent = desc.classList.contains('expanded') ? '▲' : '▼';
129
+ });
130
+
131
+ return card;
132
+ }
133
+
134
+ // Export for use in other modules
135
+ if (typeof module !== 'undefined' && module.exports) {
136
+ module.exports = { createCard, createSpellCard, createActionCard };
137
+ }
138
+
139
+ // Make available globally for popup-sheet.js
140
+ if (typeof window !== 'undefined') {
141
+ window.CardCreator = { createCard, createSpellCard, createActionCard };
142
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Character Portrait Module
3
+ *
4
+ * Handles character portrait display with circular cropping
5
+ * Exports to globalThis for use across all extensions
6
+ */
7
+
8
+ (function() {
9
+ 'use strict';
10
+
11
+ /**
12
+ * Crop an image to a circle with optional colored border
13
+ * @param {string} imageUrl - URL of the image to crop
14
+ * @param {number} size - Size of the output image (default: 200)
15
+ * @param {string} borderColor - Color of the border (default: null for no border)
16
+ * @param {number} borderWidth - Width of the border in pixels (default: 4)
17
+ * @returns {Promise<string>} - Data URL of the cropped circular image
18
+ */
19
+ function cropToCircle(imageUrl, size = 200, borderColor = null, borderWidth = 4) {
20
+ return new Promise((resolve, reject) => {
21
+ const img = new Image();
22
+ img.crossOrigin = 'anonymous'; // Handle CORS
23
+
24
+ img.onload = () => {
25
+ try {
26
+ // Create canvas
27
+ const canvas = document.createElement('canvas');
28
+ canvas.width = size;
29
+ canvas.height = size;
30
+ const ctx = canvas.getContext('2d');
31
+
32
+ // Calculate the radius for the image circle (accounting for border)
33
+ const imageRadius = borderColor ? (size / 2) - borderWidth : (size / 2);
34
+
35
+ // Draw circular clip path for the image
36
+ ctx.save();
37
+ ctx.beginPath();
38
+ ctx.arc(size / 2, size / 2, imageRadius, 0, Math.PI * 2);
39
+ ctx.closePath();
40
+ ctx.clip();
41
+
42
+ // Calculate scaling to cover the circle (crop to fit)
43
+ const scale = Math.max((imageRadius * 2) / img.width, (imageRadius * 2) / img.height);
44
+ const scaledWidth = img.width * scale;
45
+ const scaledHeight = img.height * scale;
46
+
47
+ // Center the image
48
+ const x = (size - scaledWidth) / 2;
49
+ const y = (size - scaledHeight) / 2;
50
+
51
+ // Draw image
52
+ ctx.drawImage(img, x, y, scaledWidth, scaledHeight);
53
+ ctx.restore();
54
+
55
+ // Draw border if color is provided
56
+ if (borderColor) {
57
+ ctx.beginPath();
58
+ ctx.arc(size / 2, size / 2, imageRadius, 0, Math.PI * 2);
59
+ ctx.strokeStyle = borderColor;
60
+ ctx.lineWidth = borderWidth;
61
+ ctx.stroke();
62
+ }
63
+
64
+ // Convert to data URL
65
+ resolve(canvas.toDataURL('image/png'));
66
+ } catch (error) {
67
+ reject(error);
68
+ }
69
+ };
70
+
71
+ img.onerror = () => {
72
+ reject(new Error('Failed to load image'));
73
+ };
74
+
75
+ img.src = imageUrl;
76
+ });
77
+ }
78
+
79
+ // Fallback debug object if not available
80
+ const debug = window.debug || {
81
+ log: console.log.bind(console),
82
+ warn: console.warn.bind(console),
83
+ error: console.error.bind(console)
84
+ };
85
+
86
+ /**
87
+ * Display character portrait in the specified element
88
+ * @param {string} elementId - ID of the img element to display portrait in
89
+ * @param {object} characterData - Character data object
90
+ * @param {number} size - Size of the portrait (default: 120)
91
+ */
92
+ async function displayCharacterPortrait(elementId, characterData, size = 120) {
93
+ const portraitElement = document.getElementById(elementId);
94
+
95
+ if (!portraitElement) {
96
+ debug.warn(`⚠️ Portrait element not found: ${elementId}`);
97
+ return;
98
+ }
99
+
100
+ if (!characterData) {
101
+ debug.warn('⚠️ No character data provided for portrait');
102
+ return;
103
+ }
104
+
105
+ // Try multiple possible portrait URL fields
106
+ const portraitUrl = characterData.picture
107
+ || characterData.avatarPicture
108
+ || characterData.avatar
109
+ || characterData.image
110
+ || (characterData.rawDiceCloudData && characterData.rawDiceCloudData.creature && characterData.rawDiceCloudData.creature.picture)
111
+ || (characterData.raw && characterData.raw.creature && characterData.raw.creature.picture)
112
+ || (characterData.raw && characterData.raw.picture)
113
+ || (characterData.creature && characterData.creature.picture)
114
+ || null;
115
+
116
+ if (!portraitUrl) {
117
+ debug.log('ℹ️ No portrait URL found in character data');
118
+ portraitElement.style.display = 'none';
119
+ return;
120
+ }
121
+
122
+ debug.log('🖼️ Displaying portrait from URL:', portraitUrl);
123
+
124
+ // Get the character's notification color for the border
125
+ const borderColor = characterData.notificationColor || '#3498db';
126
+ const borderWidth = 4;
127
+
128
+ // Clear any existing content
129
+ portraitElement.innerHTML = '';
130
+
131
+ // Create img element
132
+ const img = document.createElement('img');
133
+ img.style.width = '100%';
134
+ img.style.height = '100%';
135
+ img.style.objectFit = 'cover';
136
+ img.style.borderRadius = '50%';
137
+ img.alt = `${characterData.name || 'Character'} portrait`;
138
+
139
+ try {
140
+ // Try to crop to circle if cropToCircle is available
141
+ if (typeof cropToCircle === 'function') {
142
+ const croppedUrl = await cropToCircle(portraitUrl, size, borderColor, borderWidth);
143
+ img.src = croppedUrl;
144
+ portraitElement.appendChild(img);
145
+ portraitElement.style.display = 'block';
146
+ debug.log('✅ Portrait displayed (cropped with border)');
147
+ } else {
148
+ // Fallback: display directly without cropping
149
+ img.src = portraitUrl;
150
+ portraitElement.appendChild(img);
151
+ portraitElement.style.display = 'block';
152
+ debug.log('✅ Portrait displayed (uncropped)');
153
+ }
154
+ } catch (error) {
155
+ debug.warn('⚠️ Failed to crop portrait:', error);
156
+ // Fallback to original image
157
+ img.src = portraitUrl;
158
+ portraitElement.appendChild(img);
159
+ portraitElement.style.display = 'block';
160
+ debug.log('✅ Portrait displayed (uncropped fallback)');
161
+ }
162
+ }
163
+
164
+ // Export to global scope
165
+ globalThis.cropToCircle = cropToCircle;
166
+ globalThis.displayCharacterPortrait = displayCharacterPortrait;
167
+
168
+ debug.log('✅ Character Portrait module loaded');
169
+ })();