handhold-sdk 1.0.0
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.
- package/README.md +427 -0
- package/dist/d44f1fe8001e32f8e74d.svg +3056 -0
- package/dist/fonts/Phosphor.ttf +0 -0
- package/dist/fonts/Phosphor.woff +0 -0
- package/dist/fonts/Phosphor.woff2 +0 -0
- package/dist/handhold.min.js +1 -0
- package/package.json +114 -0
- package/src/HandHold.js +1599 -0
- package/src/agent/VENDOR.md +203 -0
- package/src/agent/agent/AgentLoop.js +366 -0
- package/src/agent/agent/prompts.js +163 -0
- package/src/agent/agent/tools.js +148 -0
- package/src/agent/controller.js +235 -0
- package/src/agent/dom/actions.js +456 -0
- package/src/agent/dom/domTree.js +1761 -0
- package/src/agent/dom/redact.js +98 -0
- package/src/agent/dom/serializer.js +332 -0
- package/src/agent/dom/utils.js +99 -0
- package/src/agent/index.js +169 -0
- package/src/agent/llm/connector.js +143 -0
- package/src/agent/package.json +3 -0
- package/src/agent/policy/PolicyGuard.js +172 -0
- package/src/agent/site/manifest.js +145 -0
- package/src/agent/ui/ChatWidget.js +219 -0
- package/src/agent-mode/AgentMode.js +429 -0
- package/src/api/APIClient.js +258 -0
- package/src/core/Config.js +207 -0
- package/src/core/UiState.js +83 -0
- package/src/core/defaults.js +20 -0
- package/src/events.js +59 -0
- package/src/index.js +77 -0
- package/src/intent/ActionVerbMap.js +324 -0
- package/src/intent/IntentExtractor.js +317 -0
- package/src/observers/ElementScanner.js +284 -0
- package/src/observers/NavigationObserver.js +182 -0
- package/src/observers/PageObserver.js +293 -0
- package/src/session/SessionManager.js +402 -0
- package/src/session/SessionStorage.js +148 -0
- package/src/ui/HelpWidget.js +1189 -0
- package/src/ui/UICoach.js +1725 -0
- package/src/ui/components/Button.css +137 -0
- package/src/ui/components/Button.js +102 -0
- package/src/ui/widget.css +599 -0
- package/src/utils/Logger.js +132 -0
- package/src/utils/MarkdownRenderer.js +39 -0
- package/src/utils/domUtils.js +350 -0
- package/src/utils/eventBus.js +102 -0
- package/src/utils/index.js +8 -0
- package/src/utils/stringUtils.js +202 -0
- package/types/index.d.ts +538 -0
|
@@ -0,0 +1,1725 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand Hold SDK - UI Coach
|
|
3
|
+
* Handles visual UI guidance with element highlighting, overlays, arrows, and dimming
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import eventBus from '../utils/eventBus.js';
|
|
7
|
+
import { MarkdownRenderer } from '../utils/MarkdownRenderer.js';
|
|
8
|
+
import '@phosphor-icons/web/regular';
|
|
9
|
+
import { Button, ButtonVariant, ButtonSize } from './components/Button.js';
|
|
10
|
+
|
|
11
|
+
class UICoach {
|
|
12
|
+
constructor(config) {
|
|
13
|
+
this.config = config;
|
|
14
|
+
this.overlay = null;
|
|
15
|
+
this.spotlight = null;
|
|
16
|
+
this.highlightBox = null;
|
|
17
|
+
this.arrow = null;
|
|
18
|
+
this.tooltip = null;
|
|
19
|
+
this.currentElement = null;
|
|
20
|
+
this.resizeObserver = null;
|
|
21
|
+
this.isHighlighting = false;
|
|
22
|
+
this.selectionButton = null;
|
|
23
|
+
this.selectionEnabled = false;
|
|
24
|
+
this.selectionText = '';
|
|
25
|
+
this._selectionHandler = null;
|
|
26
|
+
this._selectionHideHandler = null;
|
|
27
|
+
|
|
28
|
+
// Inject styles
|
|
29
|
+
this._injectStyles();
|
|
30
|
+
|
|
31
|
+
// Listen for scan requests
|
|
32
|
+
eventBus.on('ui:scan_request', (data) => this._onScanRequest(data));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Handle scan request animation
|
|
37
|
+
* @param {Object} data - { selector }
|
|
38
|
+
*/
|
|
39
|
+
_onScanRequest(data) {
|
|
40
|
+
const selector = data.selector;
|
|
41
|
+
if (!selector) return;
|
|
42
|
+
|
|
43
|
+
const element = document.querySelector(selector);
|
|
44
|
+
if (!element) return;
|
|
45
|
+
|
|
46
|
+
// Scroll into view first
|
|
47
|
+
try {
|
|
48
|
+
element.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
|
|
49
|
+
} catch (e) {
|
|
50
|
+
element.scrollIntoView(true);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Create scanner if needed
|
|
54
|
+
if (!this.scanner) {
|
|
55
|
+
this._createScanner();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Get element position
|
|
59
|
+
const rect = element.getBoundingClientRect();
|
|
60
|
+
|
|
61
|
+
// Position scanner initially at center of screen
|
|
62
|
+
const startX = window.innerWidth / 2 - 30;
|
|
63
|
+
const startY = window.innerHeight / 2 - 30;
|
|
64
|
+
|
|
65
|
+
this.scanner.style.left = `${startX}px`;
|
|
66
|
+
this.scanner.style.top = `${startY}px`;
|
|
67
|
+
this.scanner.classList.add('active');
|
|
68
|
+
|
|
69
|
+
// Animate to target after a tiny delay to ensure CSS transition works
|
|
70
|
+
requestAnimationFrame(() => {
|
|
71
|
+
setTimeout(() => {
|
|
72
|
+
// Recalculate rect in case scroll happened
|
|
73
|
+
const updatedRect = element.getBoundingClientRect();
|
|
74
|
+
const targetX = updatedRect.left + (updatedRect.width / 2) - 30;
|
|
75
|
+
const targetY = updatedRect.top + (updatedRect.height / 2) - 30;
|
|
76
|
+
|
|
77
|
+
this.scanner.style.left = `${targetX}px`;
|
|
78
|
+
this.scanner.style.top = `${targetY}px`;
|
|
79
|
+
}, 50);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Hide after animation
|
|
83
|
+
setTimeout(() => {
|
|
84
|
+
this.scanner.classList.remove('active');
|
|
85
|
+
}, 1500);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Create scanner element (magnifying glass)
|
|
90
|
+
*/
|
|
91
|
+
_createScanner() {
|
|
92
|
+
this.scanner = document.createElement('div');
|
|
93
|
+
this.scanner.className = 'handhold-scanner';
|
|
94
|
+
// Use a Phosphor-style magnifying glass icon for better visibility
|
|
95
|
+
this.scanner.innerHTML = `
|
|
96
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
97
|
+
<circle cx="11" cy="11" r="7"></circle>
|
|
98
|
+
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
|
99
|
+
</svg>
|
|
100
|
+
`;
|
|
101
|
+
document.body.appendChild(this.scanner);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Inject CSS styles for highlighting
|
|
106
|
+
*/
|
|
107
|
+
_injectStyles() {
|
|
108
|
+
const existingStyles = document.getElementById('handhold-uicoach-styles');
|
|
109
|
+
if (existingStyles) {
|
|
110
|
+
existingStyles.remove();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const primaryColor = this.config.primaryColor || '#004AF5';
|
|
114
|
+
const primaryRgb = this._hexToRgb(primaryColor);
|
|
115
|
+
const zIndex = this.config.zIndex || 999990;
|
|
116
|
+
const isDark = this.config.theme === 'dark';
|
|
117
|
+
|
|
118
|
+
const styles = document.createElement('style');
|
|
119
|
+
styles.id = 'handhold-uicoach-styles';
|
|
120
|
+
styles.textContent = `
|
|
121
|
+
:root {
|
|
122
|
+
--hh-coach-bg: ${isDark ? 'rgba(30, 41, 59, 0.75)' : 'rgba(255, 255, 255, 0.9)'};
|
|
123
|
+
--hh-coach-backdrop: blur(24px);
|
|
124
|
+
--hh-coach-fg: ${isDark ? '#f8fafc' : '#334155'};
|
|
125
|
+
--hh-coach-border: ${isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.6)'};
|
|
126
|
+
--hh-coach-primary: ${primaryColor};
|
|
127
|
+
--hh-coach-primary-rgb: ${primaryRgb};
|
|
128
|
+
--hh-coach-primary-fg: #ffffff;
|
|
129
|
+
--hh-coach-primary-dark: ${this._adjustColor(primaryColor, -20)};
|
|
130
|
+
--hh-coach-secondary: ${isDark ? 'rgba(51, 65, 85, 0.6)' : '#f1f5f9'};
|
|
131
|
+
--hh-coach-secondary-fg: ${isDark ? '#f8fafc' : '#475569'};
|
|
132
|
+
--hh-coach-muted: ${isDark ? '#94a3b8' : '#64748b'};
|
|
133
|
+
--hh-coach-shadow: ${isDark ? '0 20px 40px -5px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05)' : '0 24px 48px -12px rgba(50, 50, 93, 0.15), 0 12px 24px -8px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(255, 255, 255, 0.5)'};
|
|
134
|
+
--hh-coach-radius: 24px;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* Overlay background */
|
|
138
|
+
.handhold-overlay {
|
|
139
|
+
position: fixed;
|
|
140
|
+
top: 0;
|
|
141
|
+
left: 0;
|
|
142
|
+
width: 100vw;
|
|
143
|
+
height: 100vh;
|
|
144
|
+
background: transparent;
|
|
145
|
+
z-index: ${zIndex + 1};
|
|
146
|
+
pointer-events: none;
|
|
147
|
+
opacity: 0;
|
|
148
|
+
transition: opacity 0.4s ease;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.handhold-overlay.active {
|
|
152
|
+
opacity: 1;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* Highlight box around element */
|
|
156
|
+
.handhold-highlight {
|
|
157
|
+
position: fixed;
|
|
158
|
+
border: 2px solid var(--hh-coach-primary);
|
|
159
|
+
border-radius: 12px;
|
|
160
|
+
z-index: ${zIndex + 3};
|
|
161
|
+
pointer-events: none;
|
|
162
|
+
box-shadow: 0 0 0 4px rgba(var(--hh-coach-primary-rgb), 0.2);
|
|
163
|
+
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
|
164
|
+
animation: handhold-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
@keyframes handhold-pulse {
|
|
168
|
+
0%, 100% { opacity: 1; }
|
|
169
|
+
50% { opacity: .7; }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* Scanning Animation (Magnifying Glass) */
|
|
173
|
+
.handhold-scanner {
|
|
174
|
+
position: fixed;
|
|
175
|
+
width: 60px;
|
|
176
|
+
height: 60px;
|
|
177
|
+
pointer-events: none;
|
|
178
|
+
z-index: ${zIndex + 10};
|
|
179
|
+
transition: top 0.4s ease-in-out, left 0.4s ease-in-out;
|
|
180
|
+
opacity: 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.handhold-scanner.active {
|
|
184
|
+
opacity: 1;
|
|
185
|
+
animation: handhold-scan-float 3s infinite ease-in-out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.handhold-scanner svg {
|
|
189
|
+
width: 100%;
|
|
190
|
+
height: 100%;
|
|
191
|
+
filter: drop-shadow(0 4px 6px rgba(0,0,0,0.1));
|
|
192
|
+
color: var(--hh-coach-primary);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
@keyframes handhold-scan-float {
|
|
196
|
+
0%, 100% { transform: translateY(0) rotate(0deg); }
|
|
197
|
+
25% { transform: translateY(-10px) rotate(-5deg); }
|
|
198
|
+
75% { transform: translateY(5px) rotate(5deg); }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/* Arrow pointing to element */
|
|
202
|
+
.handhold-arrow {
|
|
203
|
+
position: fixed;
|
|
204
|
+
z-index: ${zIndex + 4};
|
|
205
|
+
pointer-events: none;
|
|
206
|
+
animation: handhold-bounce 1s ease-in-out infinite;
|
|
207
|
+
filter: drop-shadow(0 4px 8px rgba(0,0,0,0.15));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.handhold-arrow svg {
|
|
211
|
+
width: 36px;
|
|
212
|
+
height: 36px;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.handhold-arrow svg path {
|
|
216
|
+
fill: var(--hh-coach-primary);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
@keyframes handhold-bounce {
|
|
220
|
+
0%, 100% { transform: translateY(0); }
|
|
221
|
+
50% { transform: translateY(-8px); }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/* Direction-specific arrow positions */
|
|
225
|
+
.handhold-arrow.top { transform-origin: center bottom; }
|
|
226
|
+
.handhold-arrow.bottom { transform-origin: center top; }
|
|
227
|
+
.handhold-arrow.left { transform-origin: right center; }
|
|
228
|
+
.handhold-arrow.right { transform-origin: left center; }
|
|
229
|
+
|
|
230
|
+
.handhold-arrow.bottom svg { transform: rotate(180deg); }
|
|
231
|
+
.handhold-arrow.left svg { transform: rotate(-90deg); }
|
|
232
|
+
.handhold-arrow.right svg { transform: rotate(90deg); }
|
|
233
|
+
|
|
234
|
+
/* Step tooltip */
|
|
235
|
+
.handhold-step-tooltip {
|
|
236
|
+
position: fixed;
|
|
237
|
+
background: var(--hh-coach-bg);
|
|
238
|
+
backdrop-filter: var(--hh-coach-backdrop);
|
|
239
|
+
-webkit-backdrop-filter: var(--hh-coach-backdrop);
|
|
240
|
+
border: 1px solid var(--hh-coach-border);
|
|
241
|
+
border-radius: 16px;
|
|
242
|
+
box-shadow: var(--hh-coach-shadow);
|
|
243
|
+
padding: 16px 20px;
|
|
244
|
+
max-width: 300px;
|
|
245
|
+
z-index: ${zIndex + 5};
|
|
246
|
+
pointer-events: auto;
|
|
247
|
+
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
248
|
+
font-size: 14px;
|
|
249
|
+
line-height: 1.5;
|
|
250
|
+
color: var(--hh-coach-fg);
|
|
251
|
+
animation: handhold-fadeIn 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
@keyframes handhold-fadeIn {
|
|
255
|
+
from { opacity: 0; transform: translateY(12px) scale(0.96); }
|
|
256
|
+
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.handhold-step-tooltip.dark {
|
|
260
|
+
/* handled by variables now */
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.handhold-step-tooltip-text {
|
|
264
|
+
margin: 0 0 16px 0;
|
|
265
|
+
font-weight: 500;
|
|
266
|
+
color: var(--hh-coach-fg);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.handhold-step-tooltip-actions {
|
|
270
|
+
display: flex;
|
|
271
|
+
justify-content: flex-end;
|
|
272
|
+
gap: 8px;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.handhold-step-tooltip-btn {
|
|
276
|
+
padding: 8px 16px;
|
|
277
|
+
border-radius: 10px;
|
|
278
|
+
border: 1px solid transparent;
|
|
279
|
+
cursor: pointer;
|
|
280
|
+
font-size: 13px;
|
|
281
|
+
font-weight: 600;
|
|
282
|
+
transition: all 0.2s;
|
|
283
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.handhold-step-tooltip-btn.primary {
|
|
287
|
+
background: var(--hh-coach-primary);
|
|
288
|
+
color: var(--hh-coach-primary-fg);
|
|
289
|
+
box-shadow: 0 8px 20px -4px rgba(0, 0, 0, 0.2);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.handhold-step-tooltip-btn.primary:hover {
|
|
293
|
+
transform: none;
|
|
294
|
+
background: var(--hh-coach-primary-dark);
|
|
295
|
+
box-shadow: 0 12px 24px -4px rgba(0, 0, 0, 0.25);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.handhold-step-tooltip-btn.secondary {
|
|
299
|
+
background: var(--hh-coach-secondary);
|
|
300
|
+
color: var(--hh-coach-secondary-fg);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.handhold-step-tooltip-btn.secondary:hover {
|
|
304
|
+
background: ${isDark ? 'rgba(51, 65, 85, 0.8)' : '#e2e8f0'};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/* Explanation Modal */
|
|
308
|
+
.handhold-explain-modal-overlay {
|
|
309
|
+
position: fixed;
|
|
310
|
+
top: 0;
|
|
311
|
+
left: 0;
|
|
312
|
+
width: 100vw;
|
|
313
|
+
height: 100vh;
|
|
314
|
+
background: rgba(0, 0, 0, 0.2);
|
|
315
|
+
backdrop-filter: blur(2px);
|
|
316
|
+
z-index: ${zIndex + 10};
|
|
317
|
+
opacity: 0;
|
|
318
|
+
transition: opacity 0.2s ease;
|
|
319
|
+
pointer-events: none;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.handhold-explain-modal-overlay.active {
|
|
323
|
+
opacity: 1;
|
|
324
|
+
pointer-events: auto;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
.handhold-explain-modal {
|
|
328
|
+
position: fixed;
|
|
329
|
+
top: 20px;
|
|
330
|
+
right: 20px;
|
|
331
|
+
bottom: 20px;
|
|
332
|
+
height: auto;
|
|
333
|
+
background: #FFFFFF;
|
|
334
|
+
border: 1px solid #E2E8F0;
|
|
335
|
+
border-radius: 6px;
|
|
336
|
+
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
|
337
|
+
width: 380px;
|
|
338
|
+
max-width: calc(100vw - 40px);
|
|
339
|
+
z-index: ${zIndex + 11};
|
|
340
|
+
font-family: 'Manrope', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
341
|
+
font-size: 14px;
|
|
342
|
+
line-height: 1.5;
|
|
343
|
+
color: #0F172A;
|
|
344
|
+
transform: translateX(calc(100% + 20px));
|
|
345
|
+
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
346
|
+
display: flex;
|
|
347
|
+
flex-direction: column;
|
|
348
|
+
gap: 0;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
.handhold-explain-modal.active {
|
|
352
|
+
transform: translateX(0);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
.handhold-explain-header {
|
|
356
|
+
display: flex;
|
|
357
|
+
justify-content: space-between;
|
|
358
|
+
align-items: center;
|
|
359
|
+
padding: 12px 16px;
|
|
360
|
+
border-bottom: 2px solid #E2E8F0;
|
|
361
|
+
flex-shrink: 0;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
.handhold-explain-title {
|
|
365
|
+
font-weight: 700;
|
|
366
|
+
font-size: 16px;
|
|
367
|
+
margin: 0;
|
|
368
|
+
color: #0F172A; /* slate-900 */
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
.handhold-explain-close-icon {
|
|
372
|
+
background: none;
|
|
373
|
+
border: none;
|
|
374
|
+
cursor: pointer;
|
|
375
|
+
padding: 0;
|
|
376
|
+
width: 56px;
|
|
377
|
+
height: 56px;
|
|
378
|
+
color: #64748B; /* slate-500 */
|
|
379
|
+
display: flex;
|
|
380
|
+
align-items: center;
|
|
381
|
+
justify-content: center;
|
|
382
|
+
border-radius: 4px;
|
|
383
|
+
transition: all 0.2s;
|
|
384
|
+
margin-right: -4px;
|
|
385
|
+
font-size: 24px; /* For Phosphor icon */
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
.handhold-explain-close-icon:hover {
|
|
389
|
+
background: #F1F5F9; /* slate-100 */
|
|
390
|
+
color: #0F172A;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
.handhold-explain-content {
|
|
394
|
+
padding: 16px;
|
|
395
|
+
overflow-y: auto;
|
|
396
|
+
flex: 1;
|
|
397
|
+
display: flex;
|
|
398
|
+
flex-direction: column;
|
|
399
|
+
gap: 12px; /* Gap between group and close button */
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
.handhold-explain-body-group {
|
|
403
|
+
display: flex;
|
|
404
|
+
flex-direction: column;
|
|
405
|
+
gap: 8px; /* Closer gap between term and text */
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
.handhold-explain-term {
|
|
409
|
+
font-weight: 700;
|
|
410
|
+
font-size: 18px;
|
|
411
|
+
color: #0F172A; /* slate-900 */
|
|
412
|
+
line-height: 1.3;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
.handhold-explain-text {
|
|
416
|
+
font-size: 14px;
|
|
417
|
+
line-height: 1.6;
|
|
418
|
+
color: #64748B; /* slate-500 */
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/* Markdown Styles */
|
|
422
|
+
.handhold-explain-text p {
|
|
423
|
+
margin-bottom: 0px;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
.handhold-explain-text strong {
|
|
427
|
+
font-weight: 700;
|
|
428
|
+
color: #334155; /* slate-700 */
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
.handhold-explain-text em {
|
|
432
|
+
font-style: italic;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
.handhold-explain-text ul, .handhold-explain-text ol {
|
|
436
|
+
margin-left: 20px;
|
|
437
|
+
margin-bottom: 12px;
|
|
438
|
+
list-style-type: disc;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
.handhold-explain-text ol {
|
|
442
|
+
list-style-type: decimal;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
.handhold-explain-text li {
|
|
446
|
+
margin-bottom: 4px;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
.handhold-explain-text code {
|
|
450
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
451
|
+
font-size: 0.9em;
|
|
452
|
+
background-color: #F1F5F9; /* slate-100 */
|
|
453
|
+
padding: 2px 4px;
|
|
454
|
+
border-radius: 4px;
|
|
455
|
+
color: #0F172A; /* slate-900 */
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
.handhold-explain-text pre {
|
|
459
|
+
background-color: #F8FAFC; /* slate-50 */
|
|
460
|
+
border: 1px solid #E2E8F0; /* slate-200 */
|
|
461
|
+
border-radius: 6px;
|
|
462
|
+
padding: 12px;
|
|
463
|
+
overflow-x: auto;
|
|
464
|
+
margin-bottom: 12px;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
.handhold-explain-text pre code {
|
|
468
|
+
background-color: transparent;
|
|
469
|
+
padding: 0;
|
|
470
|
+
color: inherit;
|
|
471
|
+
font-size: 0.85em;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
.handhold-explain-text blockquote {
|
|
475
|
+
border-left: 4px solid #E2E8F0; /* slate-200 */
|
|
476
|
+
padding-left: 12px;
|
|
477
|
+
color: #64748B; /* slate-500 */
|
|
478
|
+
font-style: italic;
|
|
479
|
+
margin-bottom: 12px;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
.handhold-explain-text h1, .handhold-explain-text h2, .handhold-explain-text h3, .handhold-explain-text h4 {
|
|
483
|
+
font-weight: 600;
|
|
484
|
+
color: #1E293B; /* slate-800 */
|
|
485
|
+
margin-top: 16px;
|
|
486
|
+
margin-bottom: 8px;
|
|
487
|
+
line-height: 1.4;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
.handhold-explain-text h1 { font-size: 1.5em; }
|
|
491
|
+
.handhold-explain-text h2 { font-size: 1.3em; }
|
|
492
|
+
.handhold-explain-text h3 { font-size: 1.1em; }
|
|
493
|
+
|
|
494
|
+
.handhold-explain-text a {
|
|
495
|
+
color: var(--hh-coach-primary);
|
|
496
|
+
text-decoration: none;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
.handhold-explain-text a:hover {
|
|
500
|
+
text-decoration: underline;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
.handhold-explain-text hr {
|
|
504
|
+
border: 0;
|
|
505
|
+
border-top: 1px solid #E2E8F0; /* slate-200 */
|
|
506
|
+
margin: 24px 0;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
.handhold-explain-text img {
|
|
510
|
+
max-width: 100%;
|
|
511
|
+
height: auto;
|
|
512
|
+
border-radius: 6px;
|
|
513
|
+
margin: 16px 0;
|
|
514
|
+
border: 1px solid #E2E8F0;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
.handhold-explain-text table {
|
|
518
|
+
width: 100%;
|
|
519
|
+
border-collapse: collapse;
|
|
520
|
+
margin-bottom: 16px;
|
|
521
|
+
font-size: 0.9em;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
.handhold-explain-text th, .handhold-explain-text td {
|
|
525
|
+
border: 1px solid #E2E8F0;
|
|
526
|
+
padding: 8px 12px;
|
|
527
|
+
text-align: left;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
.handhold-explain-text th {
|
|
531
|
+
background-color: #F8FAFC; /* slate-50 */
|
|
532
|
+
font-weight: 600;
|
|
533
|
+
color: #1E293B; /* slate-800 */
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
.handhold-explain-text tr:nth-child(even) {
|
|
537
|
+
background-color: #F8FAFC; /* slate-50 */
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
.handhold-explain-footer-close {
|
|
541
|
+
width: 100%;
|
|
542
|
+
padding: 8px 16px;
|
|
543
|
+
background: #FFFFFF;
|
|
544
|
+
border: 2px solid #E2E8F0;
|
|
545
|
+
border-radius: 6px;
|
|
546
|
+
color: #0F172A;
|
|
547
|
+
font-weight: 500;
|
|
548
|
+
font-size: 14px;
|
|
549
|
+
cursor: pointer;
|
|
550
|
+
transition: all 0.2s;
|
|
551
|
+
display: flex;
|
|
552
|
+
align-items: center;
|
|
553
|
+
justify-content: center;
|
|
554
|
+
margin-top: auto;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
.handhold-explain-footer-close:hover {
|
|
558
|
+
background: #F8FAFC; /* slate-50 */
|
|
559
|
+
border-color: #CBD5E1; /* slate-300 */
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
.handhold-explain-loading {
|
|
563
|
+
display: flex;
|
|
564
|
+
flex-direction: column;
|
|
565
|
+
align-items: center;
|
|
566
|
+
justify-content: center;
|
|
567
|
+
padding: 40px;
|
|
568
|
+
color: #64748B;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
.handhold-explain-spinner {
|
|
572
|
+
width: 32px;
|
|
573
|
+
height: 32px;
|
|
574
|
+
border: 3px solid #E2E8F0;
|
|
575
|
+
border-top-color: #0F172A;
|
|
576
|
+
border-radius: 50%;
|
|
577
|
+
animation: handhold-spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
|
578
|
+
margin-bottom: 16px;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
@keyframes handhold-spin {
|
|
582
|
+
from { transform: rotate(0deg); }
|
|
583
|
+
to { transform: rotate(360deg); }
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/* Selection Explain Button */
|
|
587
|
+
.handhold-selection-explain {
|
|
588
|
+
position: fixed;
|
|
589
|
+
z-index: ${zIndex + 6};
|
|
590
|
+
display: inline-flex;
|
|
591
|
+
height: 40px;
|
|
592
|
+
padding: 0 16px;
|
|
593
|
+
flex-direction: row;
|
|
594
|
+
justify-content: center;
|
|
595
|
+
align-items: center;
|
|
596
|
+
gap: 8px;
|
|
597
|
+
|
|
598
|
+
border-radius: 9999px;
|
|
599
|
+
border: 1px solid #004AF5;
|
|
600
|
+
background: #004AF5;
|
|
601
|
+
|
|
602
|
+
/* shadow/xs */
|
|
603
|
+
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
|
604
|
+
|
|
605
|
+
color: #FFFFFF;
|
|
606
|
+
font-size: 14px;
|
|
607
|
+
font-weight: 600;
|
|
608
|
+
|
|
609
|
+
cursor: pointer;
|
|
610
|
+
transform: translate(-50%, -100%) scale(0.9);
|
|
611
|
+
opacity: 0;
|
|
612
|
+
pointer-events: none;
|
|
613
|
+
transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
614
|
+
font-family: 'Manrope', Circular Std, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
.handhold-selection-explain i, .handhold-selection-explain svg {
|
|
618
|
+
font-size: 20px;
|
|
619
|
+
display: flex;
|
|
620
|
+
align-items: center;
|
|
621
|
+
justify-content: center;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
.handhold-selection-explain:hover {
|
|
625
|
+
transform: translate(-50%, -100%) scale(1);
|
|
626
|
+
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
|
627
|
+
background: #003BC4; /* paidhr-blue-600 */
|
|
628
|
+
border-color: #003BC4;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
.handhold-selection-explain.visible {
|
|
632
|
+
opacity: 1;
|
|
633
|
+
pointer-events: auto;
|
|
634
|
+
transform: translate(-50%, -120%) scale(1);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/* Error State */
|
|
638
|
+
.handhold-explain-error {
|
|
639
|
+
display: flex;
|
|
640
|
+
flex-direction: column;
|
|
641
|
+
align-items: center;
|
|
642
|
+
justify-content: center;
|
|
643
|
+
padding: 40px 20px;
|
|
644
|
+
text-align: center;
|
|
645
|
+
height: 100%;
|
|
646
|
+
color: #64748B;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
.handhold-explain-error-icon {
|
|
650
|
+
width: 48px;
|
|
651
|
+
height: 48px;
|
|
652
|
+
background: #FEF2F2; /* red-50 */
|
|
653
|
+
color: #EF4444; /* red-500 */
|
|
654
|
+
border-radius: 50%;
|
|
655
|
+
display: flex;
|
|
656
|
+
align-items: center;
|
|
657
|
+
justify-content: center;
|
|
658
|
+
margin-bottom: 16px;
|
|
659
|
+
font-size: 24px;
|
|
660
|
+
font-weight: 700;
|
|
661
|
+
font-family: ui-sans-serif, system-ui, sans-serif;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
.handhold-explain-error-title {
|
|
665
|
+
font-weight: 700;
|
|
666
|
+
font-size: 16px;
|
|
667
|
+
color: #1E293B;
|
|
668
|
+
margin-bottom: 8px;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
.handhold-explain-error-text {
|
|
672
|
+
font-size: 14px;
|
|
673
|
+
line-height: 1.5;
|
|
674
|
+
margin-bottom: 24px;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
.handhold-explain-retry-btn {
|
|
678
|
+
background: #FFFFFF;
|
|
679
|
+
border: 1px solid #E2E8F0;
|
|
680
|
+
border-radius: 6px;
|
|
681
|
+
padding: 8px 16px;
|
|
682
|
+
font-size: 14px;
|
|
683
|
+
font-weight: 600;
|
|
684
|
+
color: #0F172A;
|
|
685
|
+
cursor: pointer;
|
|
686
|
+
transition: all 0.2s;
|
|
687
|
+
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
.handhold-explain-retry-btn:hover {
|
|
691
|
+
background: #F8FAFC;
|
|
692
|
+
border-color: #CBD5E1;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/* Skeleton Loader */
|
|
696
|
+
.handhold-skeleton {
|
|
697
|
+
animation: handhold-pulse-bg 1.5s infinite ease-in-out;
|
|
698
|
+
background-color: ${isDark ? '#334155' : '#E2E8F0'};
|
|
699
|
+
border-radius: 4px;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
@keyframes handhold-pulse-bg {
|
|
703
|
+
0% { opacity: 0.6; }
|
|
704
|
+
50% { opacity: 1; }
|
|
705
|
+
100% { opacity: 0.6; }
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
.handhold-explain-skeleton-container {
|
|
709
|
+
padding: 16px;
|
|
710
|
+
display: flex;
|
|
711
|
+
flex-direction: column;
|
|
712
|
+
gap: 24px;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
.handhold-skeleton-group {
|
|
716
|
+
display: flex;
|
|
717
|
+
flex-direction: column;
|
|
718
|
+
gap: 8px;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
.handhold-skeleton-text {
|
|
722
|
+
height: 14px;
|
|
723
|
+
width: 100%;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
.handhold-skeleton-text.short {
|
|
727
|
+
width: 60%;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
.handhold-skeleton-text.medium {
|
|
731
|
+
width: 80%;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
.handhold-skeleton-term {
|
|
735
|
+
height: 20px;
|
|
736
|
+
width: 40%;
|
|
737
|
+
margin-bottom: 4px;
|
|
738
|
+
}
|
|
739
|
+
`;
|
|
740
|
+
|
|
741
|
+
document.head.appendChild(styles);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Adjust color brightness
|
|
746
|
+
*/
|
|
747
|
+
_adjustColor(color, amount) {
|
|
748
|
+
const hex = color.replace('#', '');
|
|
749
|
+
const num = parseInt(hex, 16);
|
|
750
|
+
let r = (num >> 16) + amount;
|
|
751
|
+
if (r > 255) r = 255; else if (r < 0) r = 0;
|
|
752
|
+
let b = ((num >> 8) & 0x00FF) + amount;
|
|
753
|
+
if (b > 255) b = 255; else if (b < 0) b = 0;
|
|
754
|
+
let g = (num & 0x0000FF) + amount;
|
|
755
|
+
if (g > 255) g = 255; else if (g < 0) g = 0;
|
|
756
|
+
return `#${(g | (b << 8) | (r << 16)).toString(16).padStart(6, '0')}`;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Convert hex to rgb
|
|
761
|
+
*/
|
|
762
|
+
_hexToRgb(hex) {
|
|
763
|
+
const num = parseInt(hex.replace('#', ''), 16);
|
|
764
|
+
const r = (num >> 16) & 255;
|
|
765
|
+
const g = (num >> 8) & 255;
|
|
766
|
+
const b = num & 255;
|
|
767
|
+
return `${r}, ${g}, ${b}`;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Darken a hex color
|
|
772
|
+
*/
|
|
773
|
+
_darkenColor(hex, percent) {
|
|
774
|
+
const num = parseInt(hex.replace('#', ''), 16);
|
|
775
|
+
const amt = Math.round(2.55 * percent);
|
|
776
|
+
const R = Math.max(0, (num >> 16) - amt);
|
|
777
|
+
const G = Math.max(0, ((num >> 8) & 0x00FF) - amt);
|
|
778
|
+
const B = Math.max(0, (num & 0x0000FF) - amt);
|
|
779
|
+
return `#${(1 << 24 | R << 16 | G << 8 | B).toString(16).slice(1)}`;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Highlight a UI element
|
|
784
|
+
* @param {string} selector - CSS selector for element
|
|
785
|
+
* @param {Object} options - { message, showArrow, showTooltip, arrowPosition }
|
|
786
|
+
* @returns {boolean} - Success status
|
|
787
|
+
*/
|
|
788
|
+
highlightElement(selector, options = {}) {
|
|
789
|
+
const element = document.querySelector(selector);
|
|
790
|
+
|
|
791
|
+
if (!element) {
|
|
792
|
+
console.warn(`[HandHold UICoach] Element not found: ${selector}`);
|
|
793
|
+
eventBus.emit('highlight:not_found', { selector });
|
|
794
|
+
return Promise.resolve(false);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Scroll element into view first to ensure visibility (guarded: scrollIntoView
|
|
798
|
+
// is absent in some environments, and the old catch re-threw by calling it again)
|
|
799
|
+
if (typeof element.scrollIntoView === 'function') {
|
|
800
|
+
try {
|
|
801
|
+
element.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
|
|
802
|
+
} catch (e) {
|
|
803
|
+
try { element.scrollIntoView(true); } catch (_) { /* not supported */ }
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Phase 5.3: resolve with the ACTUAL outcome after the highlight is built,
|
|
808
|
+
// so a caller can fall back when the element detached (SPA re-render) between
|
|
809
|
+
// the query and the delayed build. Previously this returned true synchronously
|
|
810
|
+
// before the setTimeout ran, so the fallback could never trigger on that race.
|
|
811
|
+
return new Promise((resolve) => {
|
|
812
|
+
// Wait for scroll to potentially finish or at least start
|
|
813
|
+
setTimeout(() => {
|
|
814
|
+
// The element may have been removed while we waited — bail so the caller
|
|
815
|
+
// can re-match instead of silently doing nothing.
|
|
816
|
+
if (!element.isConnected) {
|
|
817
|
+
eventBus.emit('highlight:not_found', { selector });
|
|
818
|
+
resolve(false);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
// Clear previous highlight
|
|
822
|
+
this.clearHighlight();
|
|
823
|
+
|
|
824
|
+
this.currentElement = element;
|
|
825
|
+
this.isHighlighting = true;
|
|
826
|
+
|
|
827
|
+
// Add click listener to the element itself to auto-advance
|
|
828
|
+
if (options.onClick) {
|
|
829
|
+
this._elementClickListener = options.onClick;
|
|
830
|
+
element.addEventListener('click', this._elementClickListener, { once: true });
|
|
831
|
+
|
|
832
|
+
// Also listen for input/focus events if it's an input element
|
|
833
|
+
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.isContentEditable) {
|
|
834
|
+
this._elementInputListener = () => {
|
|
835
|
+
// Remove listeners immediately to prevent double firing
|
|
836
|
+
element.removeEventListener('input', this._elementInputListener);
|
|
837
|
+
if (this._elementClickListener) {
|
|
838
|
+
element.removeEventListener('click', this._elementClickListener);
|
|
839
|
+
}
|
|
840
|
+
options.onClick();
|
|
841
|
+
};
|
|
842
|
+
element.addEventListener('input', this._elementInputListener, { once: true });
|
|
843
|
+
|
|
844
|
+
// Also treat focus as interaction if requested?
|
|
845
|
+
// User said "click into... and start typing", so 'input' is safest.
|
|
846
|
+
// But for checkboxes/radios 'change' is better.
|
|
847
|
+
if (element.type === 'checkbox' || element.type === 'radio') {
|
|
848
|
+
element.addEventListener('change', this._elementInputListener, { once: true });
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Create overlay (for dimming)
|
|
854
|
+
this._createOverlay();
|
|
855
|
+
|
|
856
|
+
// Create highlight box
|
|
857
|
+
this._createHighlightBox(element);
|
|
858
|
+
|
|
859
|
+
// Create arrow
|
|
860
|
+
if (options.showArrow !== false) {
|
|
861
|
+
this._createArrow(element, options.arrowPosition);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Create tooltip if message provided
|
|
865
|
+
if (options.message && options.showTooltip !== false) {
|
|
866
|
+
// Pass arrow position preference to tooltip for smarter placement relative to arrow
|
|
867
|
+
const tooltipOptions = { ...options, preferredPosition: options.arrowPosition };
|
|
868
|
+
this._createTooltip(element, options.message, tooltipOptions);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Watch for element position changes
|
|
872
|
+
this._watchElementPosition(element);
|
|
873
|
+
|
|
874
|
+
eventBus.emit('highlight:shown', { selector, element });
|
|
875
|
+
resolve(true);
|
|
876
|
+
}, 100); // Short delay to allow layout to settle
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Create overlay background
|
|
882
|
+
*/
|
|
883
|
+
_createOverlay() {
|
|
884
|
+
this.overlay = document.createElement('div');
|
|
885
|
+
this.overlay.className = 'handhold-overlay';
|
|
886
|
+
this.overlay.id = 'handhold-overlay';
|
|
887
|
+
document.body.appendChild(this.overlay);
|
|
888
|
+
|
|
889
|
+
// Trigger animation
|
|
890
|
+
requestAnimationFrame(() => {
|
|
891
|
+
if (this.overlay) {
|
|
892
|
+
this.overlay.classList.add('active');
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Create highlight box around element
|
|
899
|
+
*/
|
|
900
|
+
_createHighlightBox(element) {
|
|
901
|
+
const rect = element.getBoundingClientRect();
|
|
902
|
+
const padding = 4;
|
|
903
|
+
|
|
904
|
+
this.highlightBox = document.createElement('div');
|
|
905
|
+
this.highlightBox.className = 'handhold-highlight';
|
|
906
|
+
this.highlightBox.id = 'handhold-highlight';
|
|
907
|
+
this.highlightBox.style.cssText = `
|
|
908
|
+
top: ${rect.top - padding}px;
|
|
909
|
+
left: ${rect.left - padding}px;
|
|
910
|
+
width: ${rect.width + padding * 2}px;
|
|
911
|
+
height: ${rect.height + padding * 2}px;
|
|
912
|
+
`;
|
|
913
|
+
|
|
914
|
+
document.body.appendChild(this.highlightBox);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Create arrow pointing to element
|
|
919
|
+
*/
|
|
920
|
+
_createArrow(element, preferredPosition) {
|
|
921
|
+
const rect = element.getBoundingClientRect();
|
|
922
|
+
const arrowSize = 40;
|
|
923
|
+
const offset = 10;
|
|
924
|
+
|
|
925
|
+
// Determine best arrow position
|
|
926
|
+
const position = preferredPosition || this._calculateArrowPosition(rect);
|
|
927
|
+
|
|
928
|
+
this.arrow = document.createElement('div');
|
|
929
|
+
this.arrow.className = `handhold-arrow ${position}`;
|
|
930
|
+
this.arrow.id = 'handhold-arrow';
|
|
931
|
+
|
|
932
|
+
// Arrow SVG
|
|
933
|
+
this.arrow.innerHTML = `
|
|
934
|
+
<svg viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
|
|
935
|
+
<path d="M20 5 L35 35 L20 28 L5 35 Z" />
|
|
936
|
+
</svg>
|
|
937
|
+
`;
|
|
938
|
+
|
|
939
|
+
// Position arrow based on direction
|
|
940
|
+
let top, left;
|
|
941
|
+
switch (position) {
|
|
942
|
+
case 'top':
|
|
943
|
+
top = rect.top - arrowSize - offset;
|
|
944
|
+
left = rect.left + (rect.width / 2) - (arrowSize / 2);
|
|
945
|
+
break;
|
|
946
|
+
case 'bottom':
|
|
947
|
+
top = rect.bottom + offset;
|
|
948
|
+
left = rect.left + (rect.width / 2) - (arrowSize / 2);
|
|
949
|
+
break;
|
|
950
|
+
case 'left':
|
|
951
|
+
top = rect.top + (rect.height / 2) - (arrowSize / 2);
|
|
952
|
+
left = rect.left - arrowSize - offset;
|
|
953
|
+
break;
|
|
954
|
+
case 'right':
|
|
955
|
+
top = rect.top + (rect.height / 2) - (arrowSize / 2);
|
|
956
|
+
left = rect.right + offset;
|
|
957
|
+
break;
|
|
958
|
+
default:
|
|
959
|
+
top = rect.top - arrowSize - offset;
|
|
960
|
+
left = rect.left + (rect.width / 2) - (arrowSize / 2);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
this.arrow.style.cssText = `top: ${top}px; left: ${left}px;`;
|
|
964
|
+
document.body.appendChild(this.arrow);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Calculate best arrow position based on element position
|
|
969
|
+
*/
|
|
970
|
+
_calculateArrowPosition(rect) {
|
|
971
|
+
const viewport = {
|
|
972
|
+
width: window.innerWidth,
|
|
973
|
+
height: window.innerHeight,
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
// Check available space in each direction
|
|
977
|
+
const spaceAbove = rect.top;
|
|
978
|
+
const spaceBelow = viewport.height - rect.bottom;
|
|
979
|
+
const spaceLeft = rect.left;
|
|
980
|
+
const spaceRight = viewport.width - rect.right;
|
|
981
|
+
|
|
982
|
+
const minSpace = 60;
|
|
983
|
+
|
|
984
|
+
// Prefer top, then bottom, then sides
|
|
985
|
+
if (spaceAbove >= minSpace) return 'top';
|
|
986
|
+
if (spaceBelow >= minSpace) return 'bottom';
|
|
987
|
+
if (spaceLeft >= minSpace) return 'left';
|
|
988
|
+
if (spaceRight >= minSpace) return 'right';
|
|
989
|
+
|
|
990
|
+
return 'top';
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Create tooltip near element
|
|
995
|
+
*/
|
|
996
|
+
_createTooltip(element, message, options = {}) {
|
|
997
|
+
const rect = element.getBoundingClientRect();
|
|
998
|
+
|
|
999
|
+
this.tooltip = document.createElement('div');
|
|
1000
|
+
this.tooltip.className = `handhold-step-tooltip ${this.config.theme === 'dark' ? 'dark' : ''}`;
|
|
1001
|
+
this.tooltip.id = 'handhold-step-tooltip';
|
|
1002
|
+
|
|
1003
|
+
const showDoneButton = options.showDoneButton !== false;
|
|
1004
|
+
const showSkipButton = options.showSkipButton === true;
|
|
1005
|
+
|
|
1006
|
+
this.tooltip.innerHTML = `
|
|
1007
|
+
<p class="handhold-step-tooltip-text">${message}</p>
|
|
1008
|
+
<div class="handhold-step-tooltip-actions">
|
|
1009
|
+
${showSkipButton ? new Button({
|
|
1010
|
+
text: 'Skip',
|
|
1011
|
+
variant: ButtonVariant.SECONDARY,
|
|
1012
|
+
size: ButtonSize.SMALL,
|
|
1013
|
+
className: 'handhold-step-tooltip-btn',
|
|
1014
|
+
attributes: { 'data-action': 'skip' }
|
|
1015
|
+
}).render() : ''}
|
|
1016
|
+
${showDoneButton ? new Button({
|
|
1017
|
+
text: 'Got it',
|
|
1018
|
+
variant: ButtonVariant.PRIMARY,
|
|
1019
|
+
size: ButtonSize.SMALL,
|
|
1020
|
+
className: 'handhold-step-tooltip-btn',
|
|
1021
|
+
attributes: { 'data-action': 'done' }
|
|
1022
|
+
}).render() : ''}
|
|
1023
|
+
</div>
|
|
1024
|
+
`;
|
|
1025
|
+
|
|
1026
|
+
// Position tooltip logic
|
|
1027
|
+
const tooltipWidth = 280;
|
|
1028
|
+
// We can't know height yet as it's not in DOM, assume roughly 150px
|
|
1029
|
+
const estimatedHeight = 150;
|
|
1030
|
+
const margin = 12;
|
|
1031
|
+
|
|
1032
|
+
const viewport = {
|
|
1033
|
+
width: window.innerWidth,
|
|
1034
|
+
height: window.innerHeight
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
// Default: try placing it on the side with most space
|
|
1038
|
+
// Similar to arrow logic but with collision detection against the element itself
|
|
1039
|
+
|
|
1040
|
+
// Check available space
|
|
1041
|
+
const spaceTop = rect.top;
|
|
1042
|
+
const spaceBottom = viewport.height - rect.bottom;
|
|
1043
|
+
const spaceLeft = rect.left;
|
|
1044
|
+
const spaceRight = viewport.width - rect.right;
|
|
1045
|
+
|
|
1046
|
+
let top, left;
|
|
1047
|
+
|
|
1048
|
+
// Prefer bottom if space allows
|
|
1049
|
+
if (spaceBottom >= estimatedHeight + margin) {
|
|
1050
|
+
top = rect.bottom + margin + 10; // +10 for arrow offset
|
|
1051
|
+
left = rect.left + (rect.width / 2) - (tooltipWidth / 2);
|
|
1052
|
+
}
|
|
1053
|
+
// Else try top
|
|
1054
|
+
else if (spaceTop >= estimatedHeight + margin) {
|
|
1055
|
+
top = rect.top - estimatedHeight - margin;
|
|
1056
|
+
left = rect.left + (rect.width / 2) - (tooltipWidth / 2);
|
|
1057
|
+
}
|
|
1058
|
+
// Else try right
|
|
1059
|
+
else if (spaceRight >= tooltipWidth + margin) {
|
|
1060
|
+
top = rect.top;
|
|
1061
|
+
left = rect.right + margin;
|
|
1062
|
+
}
|
|
1063
|
+
// Else try left
|
|
1064
|
+
else if (spaceLeft >= tooltipWidth + margin) {
|
|
1065
|
+
top = rect.top;
|
|
1066
|
+
left = rect.left - tooltipWidth - margin;
|
|
1067
|
+
}
|
|
1068
|
+
// Fallback: Stick to bottom but force it on screen (might overlap if screen really small)
|
|
1069
|
+
else {
|
|
1070
|
+
top = rect.bottom + margin;
|
|
1071
|
+
left = rect.left;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// Horizontal boundary check (keep within viewport)
|
|
1075
|
+
left = Math.max(10, Math.min(left, viewport.width - tooltipWidth - 10));
|
|
1076
|
+
|
|
1077
|
+
// Vertical boundary check
|
|
1078
|
+
if (top + estimatedHeight > viewport.height) {
|
|
1079
|
+
top = viewport.height - estimatedHeight - 10;
|
|
1080
|
+
}
|
|
1081
|
+
if (top < 10) {
|
|
1082
|
+
top = 10;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// Final Collision Check: Ensure we don't overlap the target element
|
|
1086
|
+
// If tooltip rect overlaps element rect, move it
|
|
1087
|
+
const tooltipRect = {
|
|
1088
|
+
top: top,
|
|
1089
|
+
bottom: top + estimatedHeight,
|
|
1090
|
+
left: left,
|
|
1091
|
+
right: left + tooltipWidth
|
|
1092
|
+
};
|
|
1093
|
+
|
|
1094
|
+
const elementRect = {
|
|
1095
|
+
top: rect.top,
|
|
1096
|
+
bottom: rect.bottom,
|
|
1097
|
+
left: rect.left,
|
|
1098
|
+
right: rect.right
|
|
1099
|
+
};
|
|
1100
|
+
|
|
1101
|
+
const overlap = !(tooltipRect.right < elementRect.left ||
|
|
1102
|
+
tooltipRect.left > elementRect.right ||
|
|
1103
|
+
tooltipRect.bottom < elementRect.top ||
|
|
1104
|
+
tooltipRect.top > elementRect.bottom);
|
|
1105
|
+
|
|
1106
|
+
if (overlap) {
|
|
1107
|
+
// If overlapping, try to move it away aggressively
|
|
1108
|
+
// If mostly overlapping vertically, move sideways
|
|
1109
|
+
if (spaceRight > spaceLeft) {
|
|
1110
|
+
left = rect.right + margin;
|
|
1111
|
+
} else {
|
|
1112
|
+
left = rect.left - tooltipWidth - margin;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Clamp again
|
|
1116
|
+
left = Math.max(10, Math.min(left, viewport.width - tooltipWidth - 10));
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
this.tooltip.style.cssText = `top: ${top}px; left: ${left}px;`;
|
|
1120
|
+
|
|
1121
|
+
// Add event listeners
|
|
1122
|
+
this.tooltip.addEventListener('click', (e) => {
|
|
1123
|
+
const target = e.target;
|
|
1124
|
+
const action = target.dataset.action;
|
|
1125
|
+
|
|
1126
|
+
if (action) {
|
|
1127
|
+
// Disable button to prevent double-clicks
|
|
1128
|
+
if (target.tagName === 'BUTTON') {
|
|
1129
|
+
target.disabled = true;
|
|
1130
|
+
// target.textContent = '...'; // Removed to prevent conflicts with text-analysis extensions
|
|
1131
|
+
target.style.opacity = '0.7';
|
|
1132
|
+
target.style.cursor = 'wait';
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
try {
|
|
1136
|
+
if (action === 'done') {
|
|
1137
|
+
eventBus.emit('step:done');
|
|
1138
|
+
} else if (action === 'skip') {
|
|
1139
|
+
eventBus.emit('step:skip');
|
|
1140
|
+
}
|
|
1141
|
+
} catch (error) {
|
|
1142
|
+
console.error('[HandHold] Error handling click action:', error);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
document.body.appendChild(this.tooltip);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Watch for element position changes
|
|
1152
|
+
*/
|
|
1153
|
+
_watchElementPosition(element) {
|
|
1154
|
+
// Use ResizeObserver to detect size changes
|
|
1155
|
+
if (window.ResizeObserver) {
|
|
1156
|
+
this.resizeObserver = new ResizeObserver(() => {
|
|
1157
|
+
this._updateHighlightPosition();
|
|
1158
|
+
});
|
|
1159
|
+
this.resizeObserver.observe(element);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// Also update on scroll and resize
|
|
1163
|
+
this._boundUpdatePosition = () => this._updateHighlightPosition();
|
|
1164
|
+
window.addEventListener('scroll', this._boundUpdatePosition, { passive: true });
|
|
1165
|
+
window.addEventListener('resize', this._boundUpdatePosition, { passive: true });
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* Update highlight position (for scroll/resize)
|
|
1170
|
+
*/
|
|
1171
|
+
_updateHighlightPosition() {
|
|
1172
|
+
if (!this.currentElement || !this.isHighlighting) return;
|
|
1173
|
+
|
|
1174
|
+
const rect = this.currentElement.getBoundingClientRect();
|
|
1175
|
+
const padding = 4;
|
|
1176
|
+
|
|
1177
|
+
if (this.highlightBox) {
|
|
1178
|
+
this.highlightBox.style.top = `${rect.top - padding}px`;
|
|
1179
|
+
this.highlightBox.style.left = `${rect.left - padding}px`;
|
|
1180
|
+
this.highlightBox.style.width = `${rect.width + padding * 2}px`;
|
|
1181
|
+
this.highlightBox.style.height = `${rect.height + padding * 2}px`;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// Update arrow position too
|
|
1185
|
+
if (this.arrow) {
|
|
1186
|
+
const position = this.arrow.className.split(' ').find(c => ['top', 'bottom', 'left', 'right'].includes(c));
|
|
1187
|
+
const arrowSize = 40;
|
|
1188
|
+
const offset = 10;
|
|
1189
|
+
let top, left;
|
|
1190
|
+
|
|
1191
|
+
switch (position) {
|
|
1192
|
+
case 'top':
|
|
1193
|
+
top = rect.top - arrowSize - offset;
|
|
1194
|
+
left = rect.left + (rect.width / 2) - (arrowSize / 2);
|
|
1195
|
+
break;
|
|
1196
|
+
case 'bottom':
|
|
1197
|
+
top = rect.bottom + offset;
|
|
1198
|
+
left = rect.left + (rect.width / 2) - (arrowSize / 2);
|
|
1199
|
+
break;
|
|
1200
|
+
case 'left':
|
|
1201
|
+
top = rect.top + (rect.height / 2) - (arrowSize / 2);
|
|
1202
|
+
left = rect.left - arrowSize - offset;
|
|
1203
|
+
break;
|
|
1204
|
+
case 'right':
|
|
1205
|
+
top = rect.top + (rect.height / 2) - (arrowSize / 2);
|
|
1206
|
+
left = rect.right + offset;
|
|
1207
|
+
break;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
this.arrow.style.top = `${top}px`;
|
|
1211
|
+
this.arrow.style.left = `${left}px`;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* Clear all highlights
|
|
1217
|
+
*/
|
|
1218
|
+
clearHighlight() {
|
|
1219
|
+
// Remove click listener if exists
|
|
1220
|
+
if (this.currentElement && this._elementClickListener) {
|
|
1221
|
+
this.currentElement.removeEventListener('click', this._elementClickListener);
|
|
1222
|
+
this._elementClickListener = null;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
this.isHighlighting = false;
|
|
1226
|
+
this.currentElement = null;
|
|
1227
|
+
|
|
1228
|
+
// Remove elements
|
|
1229
|
+
if (this.overlay) {
|
|
1230
|
+
this.overlay.remove();
|
|
1231
|
+
this.overlay = null;
|
|
1232
|
+
}
|
|
1233
|
+
if (this.spotlight) {
|
|
1234
|
+
this.spotlight.remove();
|
|
1235
|
+
this.spotlight = null;
|
|
1236
|
+
}
|
|
1237
|
+
if (this.highlightBox) {
|
|
1238
|
+
this.highlightBox.remove();
|
|
1239
|
+
this.highlightBox = null;
|
|
1240
|
+
}
|
|
1241
|
+
if (this.arrow) {
|
|
1242
|
+
this.arrow.remove();
|
|
1243
|
+
this.arrow = null;
|
|
1244
|
+
}
|
|
1245
|
+
if (this.tooltip) {
|
|
1246
|
+
this.tooltip.remove();
|
|
1247
|
+
this.tooltip = null;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// Clean up observers and listeners
|
|
1251
|
+
if (this.resizeObserver) {
|
|
1252
|
+
this.resizeObserver.disconnect();
|
|
1253
|
+
this.resizeObserver = null;
|
|
1254
|
+
}
|
|
1255
|
+
if (this._boundUpdatePosition) {
|
|
1256
|
+
window.removeEventListener('scroll', this._boundUpdatePosition);
|
|
1257
|
+
window.removeEventListener('resize', this._boundUpdatePosition);
|
|
1258
|
+
this._boundUpdatePosition = null;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
eventBus.emit('highlight:cleared');
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* Update tooltip message
|
|
1266
|
+
*/
|
|
1267
|
+
updateTooltipMessage(message) {
|
|
1268
|
+
if (this.tooltip) {
|
|
1269
|
+
const textEl = this.tooltip.querySelector('.handhold-step-tooltip-text');
|
|
1270
|
+
if (textEl) {
|
|
1271
|
+
textEl.textContent = message;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
enableSelectionExplain() {
|
|
1277
|
+
if (this.selectionEnabled) return;
|
|
1278
|
+
this.selectionEnabled = true;
|
|
1279
|
+
this._createSelectionButton();
|
|
1280
|
+
this._selectionHandler = () => this._handleSelection();
|
|
1281
|
+
this._selectionHideHandler = (e) => {
|
|
1282
|
+
// Check if event target is valid Node before checking contains
|
|
1283
|
+
if (this.selectionButton && e.target instanceof Node && this.selectionButton.contains(e.target)) return;
|
|
1284
|
+
this._hideSelectionButton();
|
|
1285
|
+
};
|
|
1286
|
+
document.addEventListener('mouseup', this._selectionHandler);
|
|
1287
|
+
document.addEventListener('keyup', this._selectionHandler);
|
|
1288
|
+
document.addEventListener('scroll', this._selectionHideHandler, true);
|
|
1289
|
+
document.addEventListener('mousedown', this._selectionHideHandler);
|
|
1290
|
+
// Hide on window blur (tab switch) or visibility change
|
|
1291
|
+
window.addEventListener('blur', this._selectionHideHandler);
|
|
1292
|
+
document.addEventListener('visibilitychange', this._selectionHideHandler);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
disableSelectionExplain() {
|
|
1296
|
+
if (!this.selectionEnabled) return;
|
|
1297
|
+
this.selectionEnabled = false;
|
|
1298
|
+
|
|
1299
|
+
// Clear debounce timer
|
|
1300
|
+
if (this._selectionDebounce) {
|
|
1301
|
+
clearTimeout(this._selectionDebounce);
|
|
1302
|
+
this._selectionDebounce = null;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
if (this._selectionHandler) {
|
|
1306
|
+
document.removeEventListener('mouseup', this._selectionHandler);
|
|
1307
|
+
document.removeEventListener('keyup', this._selectionHandler);
|
|
1308
|
+
this._selectionHandler = null;
|
|
1309
|
+
}
|
|
1310
|
+
if (this._selectionHideHandler) {
|
|
1311
|
+
document.removeEventListener('scroll', this._selectionHideHandler, true);
|
|
1312
|
+
document.removeEventListener('mousedown', this._selectionHideHandler);
|
|
1313
|
+
window.removeEventListener('blur', this._selectionHideHandler);
|
|
1314
|
+
document.removeEventListener('visibilitychange', this._selectionHideHandler);
|
|
1315
|
+
this._selectionHideHandler = null;
|
|
1316
|
+
}
|
|
1317
|
+
this._hideSelectionButton();
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
_createSelectionButton() {
|
|
1321
|
+
if (this.selectionButton) return;
|
|
1322
|
+
|
|
1323
|
+
this.selectionButton = new Button({
|
|
1324
|
+
text: 'Explain this',
|
|
1325
|
+
icon: '<i class="ph ph-question"></i>',
|
|
1326
|
+
variant: ButtonVariant.PRIMARY,
|
|
1327
|
+
className: 'handhold-selection-explain',
|
|
1328
|
+
attributes: { type: 'button' },
|
|
1329
|
+
onClick: (e) => {
|
|
1330
|
+
e.preventDefault();
|
|
1331
|
+
e.stopPropagation();
|
|
1332
|
+
const text = this.selectionText;
|
|
1333
|
+
this._hideSelectionButton();
|
|
1334
|
+
if (text) {
|
|
1335
|
+
// Capture page context (truncated to ~15k chars)
|
|
1336
|
+
let context = document.body.innerText || '';
|
|
1337
|
+
if (context.length > 15000) {
|
|
1338
|
+
context = context.substring(0, 15000) + '...[truncated]';
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
eventBus.emit('selection:explain', { text, context });
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}).create();
|
|
1345
|
+
|
|
1346
|
+
// Use mousedown to prevent focus loss issues
|
|
1347
|
+
this.selectionButton.addEventListener('mousedown', (e) => e.stopPropagation());
|
|
1348
|
+
|
|
1349
|
+
document.body.appendChild(this.selectionButton);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/**
|
|
1353
|
+
* Expand selection to nearest meaningful container
|
|
1354
|
+
* @param {Selection} selection
|
|
1355
|
+
* @returns {Range|null}
|
|
1356
|
+
*/
|
|
1357
|
+
_getExpansionRange(selection) {
|
|
1358
|
+
if (!selection.rangeCount) return null;
|
|
1359
|
+
|
|
1360
|
+
const range = selection.getRangeAt(0);
|
|
1361
|
+
const container = range.commonAncestorContainer;
|
|
1362
|
+
|
|
1363
|
+
// Ensure we have an element, not a text node
|
|
1364
|
+
let element = container.nodeType === Node.TEXT_NODE ? container.parentElement : container;
|
|
1365
|
+
|
|
1366
|
+
// Define "meaningful" containers
|
|
1367
|
+
const meaningfulTags = ['P', 'DIV', 'SPAN', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'TD', 'TH', 'BUTTON', 'A', 'LABEL', 'SECTION', 'ARTICLE', 'ASIDE'];
|
|
1368
|
+
|
|
1369
|
+
// Scenario B: Multi-Element Selection
|
|
1370
|
+
// If common ancestor contains both start and end nodes but they are different
|
|
1371
|
+
if (range.startContainer !== range.endContainer) {
|
|
1372
|
+
// Safety check for very large containers
|
|
1373
|
+
const isTooLarge = ['BODY', 'MAIN'].includes(element.tagName) || (element.textContent && element.textContent.length > 2000);
|
|
1374
|
+
|
|
1375
|
+
if (isTooLarge) {
|
|
1376
|
+
// Fallback: return original range to avoid selecting entire page
|
|
1377
|
+
return range;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// Expand to full content of the common ancestor
|
|
1381
|
+
const newRange = document.createRange();
|
|
1382
|
+
newRange.selectNodeContents(element);
|
|
1383
|
+
return newRange;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// Scenario A/C: Single Element/Partial Selection
|
|
1387
|
+
// Walk up to find best container
|
|
1388
|
+
let targetElement = element;
|
|
1389
|
+
let attempts = 0;
|
|
1390
|
+
while (targetElement && targetElement.parentElement && attempts < 5) {
|
|
1391
|
+
// Stop if we hit a meaningful tag
|
|
1392
|
+
if (meaningfulTags.includes(targetElement.tagName)) {
|
|
1393
|
+
break;
|
|
1394
|
+
}
|
|
1395
|
+
// Stop if growing too large
|
|
1396
|
+
if (targetElement.textContent && targetElement.textContent.length > 500) {
|
|
1397
|
+
break;
|
|
1398
|
+
}
|
|
1399
|
+
// Stop if we hit root-ish things
|
|
1400
|
+
if (['BODY', 'HTML', 'MAIN'].includes(targetElement.tagName)) {
|
|
1401
|
+
break;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
targetElement = targetElement.parentElement;
|
|
1405
|
+
attempts++;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
if (targetElement) {
|
|
1409
|
+
const newRange = document.createRange();
|
|
1410
|
+
newRange.selectNodeContents(targetElement);
|
|
1411
|
+
return newRange;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
return range;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
_handleSelection() {
|
|
1418
|
+
// Clear existing debounce
|
|
1419
|
+
if (this._selectionDebounce) {
|
|
1420
|
+
clearTimeout(this._selectionDebounce);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// Debounce to prevent flickering on double clicks/drags
|
|
1424
|
+
this._selectionDebounce = setTimeout(() => {
|
|
1425
|
+
const selection = window.getSelection();
|
|
1426
|
+
|
|
1427
|
+
// Basic validation
|
|
1428
|
+
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
|
|
1429
|
+
this._hideSelectionButton();
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// Initial check on original selection to avoid expanding in ignored areas
|
|
1434
|
+
let range = selection.getRangeAt(0);
|
|
1435
|
+
let container = range.commonAncestorContainer;
|
|
1436
|
+
let element = container.nodeType === Node.ELEMENT_NODE ? container : container.parentElement;
|
|
1437
|
+
|
|
1438
|
+
if (!element) {
|
|
1439
|
+
this._hideSelectionButton();
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
// Ignore if inside our own UI
|
|
1444
|
+
if (element.closest('#handhold-container') ||
|
|
1445
|
+
element.closest('.handhold-selection-explain') ||
|
|
1446
|
+
element.closest('.handhold-explain-modal')) {
|
|
1447
|
+
this._hideSelectionButton();
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// Ignore input fields
|
|
1452
|
+
if (element.closest('input, textarea, [contenteditable="true"]')) {
|
|
1453
|
+
this._hideSelectionButton();
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// --- EXPANSION LOGIC ---
|
|
1458
|
+
const expandedRange = this._getExpansionRange(selection);
|
|
1459
|
+
if (expandedRange) {
|
|
1460
|
+
// Apply expansion
|
|
1461
|
+
selection.removeAllRanges();
|
|
1462
|
+
selection.addRange(expandedRange);
|
|
1463
|
+
|
|
1464
|
+
// Update range reference
|
|
1465
|
+
range = expandedRange;
|
|
1466
|
+
}
|
|
1467
|
+
// -----------------------
|
|
1468
|
+
|
|
1469
|
+
const text = selection.toString().trim();
|
|
1470
|
+
// Length check - ensure we have meaningful content
|
|
1471
|
+
if (!text || text.length < 2) {
|
|
1472
|
+
this._hideSelectionButton();
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
const rect = range.getBoundingClientRect();
|
|
1477
|
+
|
|
1478
|
+
// Ignore zero-width rects (hidden elements or empty selections)
|
|
1479
|
+
if (!rect || rect.width === 0 || rect.height === 0) {
|
|
1480
|
+
this._hideSelectionButton();
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
this.selectionText = text;
|
|
1485
|
+
this._showSelectionButton(rect);
|
|
1486
|
+
}, 200); // 200ms delay
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
_showSelectionButton(rect) {
|
|
1490
|
+
if (!this.selectionButton) return;
|
|
1491
|
+
const top = Math.max(8, rect.top - 8);
|
|
1492
|
+
const left = rect.left + rect.width / 2;
|
|
1493
|
+
this.selectionButton.style.top = `${top}px`;
|
|
1494
|
+
this.selectionButton.style.left = `${left}px`;
|
|
1495
|
+
this.selectionButton.classList.add('visible');
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
_hideSelectionButton() {
|
|
1499
|
+
if (!this.selectionButton) return;
|
|
1500
|
+
this.selectionText = '';
|
|
1501
|
+
this.selectionButton.classList.remove('visible');
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
/**
|
|
1505
|
+
* Check if currently highlighting
|
|
1506
|
+
*/
|
|
1507
|
+
isActive() {
|
|
1508
|
+
return this.isHighlighting;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/**
|
|
1512
|
+
* Get current highlighted element
|
|
1513
|
+
*/
|
|
1514
|
+
getCurrentElement() {
|
|
1515
|
+
return this.currentElement;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/**
|
|
1519
|
+
* Destroy the UI Coach (cleanup)
|
|
1520
|
+
*/
|
|
1521
|
+
destroy() {
|
|
1522
|
+
this.clearHighlight();
|
|
1523
|
+
this.disableSelectionExplain();
|
|
1524
|
+
if (this.selectionButton) {
|
|
1525
|
+
this.selectionButton.remove();
|
|
1526
|
+
this.selectionButton = null;
|
|
1527
|
+
}
|
|
1528
|
+
this._removeExplainModal();
|
|
1529
|
+
|
|
1530
|
+
// Remove injected styles
|
|
1531
|
+
const styles = document.getElementById('handhold-uicoach-styles');
|
|
1532
|
+
if (styles) {
|
|
1533
|
+
styles.remove();
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
/**
|
|
1538
|
+
* Show explanation modal for a term
|
|
1539
|
+
* @param {Object} data - { terms: [{term, definition}], summary }
|
|
1540
|
+
*/
|
|
1541
|
+
showExplainModal(data) {
|
|
1542
|
+
this._createExplainModal();
|
|
1543
|
+
|
|
1544
|
+
// Build content HTML for multiple terms
|
|
1545
|
+
const termsHtml = (data.terms || []).map(item => {
|
|
1546
|
+
// If term is just the full selection (likely error fallback), skip it
|
|
1547
|
+
// But if it's a real term, show it.
|
|
1548
|
+
// We can use a heuristic: if term length > 100, it's probably the selection text, not a "term"
|
|
1549
|
+
const isLongTerm = item.term && item.term.length > 100;
|
|
1550
|
+
|
|
1551
|
+
const definitionHtml = MarkdownRenderer.parse(item.definition);
|
|
1552
|
+
return `
|
|
1553
|
+
<div class="handhold-explain-body-group">
|
|
1554
|
+
${!isLongTerm ? `<div class="handhold-explain-term">${this._escapeHtml(item.term)}</div>` : ''}
|
|
1555
|
+
<div class="handhold-explain-text">${definitionHtml}</div>
|
|
1556
|
+
</div>
|
|
1557
|
+
`;
|
|
1558
|
+
}).join('');
|
|
1559
|
+
|
|
1560
|
+
const body = this.explainModal.querySelector('.handhold-explain-content');
|
|
1561
|
+
body.innerHTML = `
|
|
1562
|
+
${termsHtml}
|
|
1563
|
+
${new Button({
|
|
1564
|
+
text: 'Close',
|
|
1565
|
+
variant: ButtonVariant.OUTLINE,
|
|
1566
|
+
className: 'handhold-explain-footer-close'
|
|
1567
|
+
}).render()}
|
|
1568
|
+
`;
|
|
1569
|
+
|
|
1570
|
+
// Add listeners
|
|
1571
|
+
const closeBtn = body.querySelector('.handhold-explain-footer-close');
|
|
1572
|
+
if (closeBtn) {
|
|
1573
|
+
closeBtn.addEventListener('click', () => this._hideModal());
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// Show
|
|
1577
|
+
this._showModal();
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/**
|
|
1581
|
+
* Show loading state in explain modal
|
|
1582
|
+
* @param {string} term
|
|
1583
|
+
*/
|
|
1584
|
+
showExplainLoading(term) {
|
|
1585
|
+
this._createExplainModal();
|
|
1586
|
+
|
|
1587
|
+
const body = this.explainModal.querySelector('.handhold-explain-content');
|
|
1588
|
+
body.innerHTML = `
|
|
1589
|
+
<div class="handhold-explain-skeleton-container">
|
|
1590
|
+
<div class="handhold-skeleton-group">
|
|
1591
|
+
<div class="handhold-skeleton handhold-skeleton-term"></div>
|
|
1592
|
+
<div class="handhold-skeleton handhold-skeleton-text"></div>
|
|
1593
|
+
<div class="handhold-skeleton handhold-skeleton-text medium"></div>
|
|
1594
|
+
</div>
|
|
1595
|
+
<div class="handhold-skeleton-group">
|
|
1596
|
+
<div class="handhold-skeleton handhold-skeleton-term"></div>
|
|
1597
|
+
<div class="handhold-skeleton handhold-skeleton-text"></div>
|
|
1598
|
+
<div class="handhold-skeleton handhold-skeleton-text"></div>
|
|
1599
|
+
<div class="handhold-skeleton handhold-skeleton-text short"></div>
|
|
1600
|
+
</div>
|
|
1601
|
+
<div class="handhold-explain-loading-message" style="display: flex; align-items: center; gap: 10px; margin-top: 8px; color: #64748B; font-size: 13px; font-weight: 500;">
|
|
1602
|
+
<div style="width: 16px; height: 16px; border: 2px solid #CBD5E1; border-top-color: #334155; border-radius: 50%; animation: handhold-spin 1s linear infinite;"></div>
|
|
1603
|
+
<span>Consulting knowledgebase...</span>
|
|
1604
|
+
</div>
|
|
1605
|
+
</div>
|
|
1606
|
+
`;
|
|
1607
|
+
|
|
1608
|
+
this._showModal();
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
/**
|
|
1612
|
+
* Show error state in explain modal
|
|
1613
|
+
* @param {string} term
|
|
1614
|
+
* @param {Function} onRetry
|
|
1615
|
+
*/
|
|
1616
|
+
showExplainError(term, onRetry) {
|
|
1617
|
+
this._createExplainModal();
|
|
1618
|
+
|
|
1619
|
+
const body = this.explainModal.querySelector('.handhold-explain-content');
|
|
1620
|
+
body.innerHTML = `
|
|
1621
|
+
<div class="handhold-explain-error" style="display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; text-align: center; height: 100%;">
|
|
1622
|
+
<div style="width: 112px; height: 112px; margin-bottom: 8px; flex-shrink: 0; display: flex; align-items: center; justify-content: center;">
|
|
1623
|
+
<img src="/illustration-error.svg" alt="Error" style="width: 100%; height: 100%; object-fit: contain;" />
|
|
1624
|
+
</div>
|
|
1625
|
+
<div class="handhold-explain-error-title" style="font-size: 18px; font-weight: 700; color: #0F172A; margin-bottom: 4px; line-height: 1.5;">We're sorry, something went wrong</div>
|
|
1626
|
+
<div class="handhold-explain-error-text" style="font-size: 14px; color: #64748B; margin-bottom: 16px; line-height: 1.5; padding: 0 8px;">We encountered an issue while trying to pull the definitions of the selected terms please try again.</div>
|
|
1627
|
+
${new Button({
|
|
1628
|
+
text: 'Try again',
|
|
1629
|
+
variant: ButtonVariant.PRIMARY,
|
|
1630
|
+
className: 'handhold-explain-retry-btn',
|
|
1631
|
+
attributes: { 'style': 'width: 100%; justify-content: center;' }
|
|
1632
|
+
}).render()}
|
|
1633
|
+
</div>
|
|
1634
|
+
`;
|
|
1635
|
+
|
|
1636
|
+
// Add listeners
|
|
1637
|
+
const retryBtn = body.querySelector('.handhold-explain-retry-btn');
|
|
1638
|
+
if (retryBtn && onRetry) {
|
|
1639
|
+
retryBtn.addEventListener('click', () => {
|
|
1640
|
+
onRetry();
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
const closeBtn = body.querySelector('.handhold-explain-footer-close');
|
|
1645
|
+
if (closeBtn) {
|
|
1646
|
+
closeBtn.addEventListener('click', () => this._hideModal());
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
this._showModal();
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
_createExplainModal() {
|
|
1653
|
+
if (this.explainModal) return;
|
|
1654
|
+
|
|
1655
|
+
// Create overlay
|
|
1656
|
+
this.explainOverlay = document.createElement('div');
|
|
1657
|
+
this.explainOverlay.className = 'handhold-explain-modal-overlay';
|
|
1658
|
+
document.body.appendChild(this.explainOverlay);
|
|
1659
|
+
|
|
1660
|
+
// Create modal
|
|
1661
|
+
this.explainModal = document.createElement('div');
|
|
1662
|
+
this.explainModal.className = `handhold-explain-modal ${this.config.theme === 'dark' ? 'dark' : ''}`;
|
|
1663
|
+
this.explainModal.innerHTML = `
|
|
1664
|
+
<div class="handhold-explain-header">
|
|
1665
|
+
<h3 class="handhold-explain-title">Glossary / FAQs</h3>
|
|
1666
|
+
${new Button({
|
|
1667
|
+
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>',
|
|
1668
|
+
variant: ButtonVariant.GHOST,
|
|
1669
|
+
size: ButtonSize.ICON_LG,
|
|
1670
|
+
className: 'handhold-explain-close-icon',
|
|
1671
|
+
attributes: { 'aria-label': 'Close' }
|
|
1672
|
+
}).render()}
|
|
1673
|
+
</div>
|
|
1674
|
+
<div class="handhold-explain-content"></div>
|
|
1675
|
+
`;
|
|
1676
|
+
document.body.appendChild(this.explainModal);
|
|
1677
|
+
|
|
1678
|
+
// Events
|
|
1679
|
+
this.explainOverlay.addEventListener('click', () => this._hideModal());
|
|
1680
|
+
this.explainModal.querySelector('.handhold-explain-close-icon').addEventListener('click', () => this._hideModal());
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
_showModal() {
|
|
1684
|
+
if (!this.explainModal) return;
|
|
1685
|
+
|
|
1686
|
+
// Side panel animation
|
|
1687
|
+
requestAnimationFrame(() => {
|
|
1688
|
+
this.explainOverlay.classList.add('active');
|
|
1689
|
+
this.explainModal.classList.add('active');
|
|
1690
|
+
});
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
_hideModal() {
|
|
1694
|
+
if (!this.explainModal) return;
|
|
1695
|
+
this.explainOverlay.classList.remove('active');
|
|
1696
|
+
this.explainModal.classList.remove('active');
|
|
1697
|
+
|
|
1698
|
+
// Remove from DOM after transition to prevent overlay blocking
|
|
1699
|
+
setTimeout(() => {
|
|
1700
|
+
this._removeExplainModal();
|
|
1701
|
+
}, 250);
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
_removeExplainModal() {
|
|
1705
|
+
if (this.explainModal) {
|
|
1706
|
+
this.explainModal.remove();
|
|
1707
|
+
this.explainModal = null;
|
|
1708
|
+
}
|
|
1709
|
+
if (this.explainOverlay) {
|
|
1710
|
+
this.explainOverlay.remove();
|
|
1711
|
+
this.explainOverlay = null;
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
_escapeHtml(value) {
|
|
1716
|
+
return String(value)
|
|
1717
|
+
.replace(/&/g, '&')
|
|
1718
|
+
.replace(/</g, '<')
|
|
1719
|
+
.replace(/>/g, '>')
|
|
1720
|
+
.replace(/"/g, '"')
|
|
1721
|
+
.replace(/'/g, ''');
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
export default UICoach;
|