kash-shell 0.3.17__py3-none-any.whl → 0.3.18__py3-none-any.whl
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.
- kash/actions/core/minify_html.py +41 -0
- kash/commands/base/show_command.py +11 -1
- kash/config/colors.py +6 -2
- kash/docs/markdown/topics/a1_what_is_kash.md +52 -23
- kash/docs/markdown/topics/a2_installation.md +17 -30
- kash/docs/markdown/topics/a3_getting_started.md +5 -19
- kash/exec/action_exec.py +1 -1
- kash/exec/fetch_url_metadata.py +9 -0
- kash/exec/precondition_registry.py +3 -3
- kash/file_storage/file_store.py +18 -1
- kash/llm_utils/llm_features.py +5 -1
- kash/llm_utils/llms.py +18 -8
- kash/media_base/media_cache.py +48 -24
- kash/media_base/media_services.py +63 -14
- kash/media_base/services/local_file_media.py +9 -1
- kash/model/items_model.py +4 -5
- kash/model/media_model.py +9 -1
- kash/model/params_model.py +9 -3
- kash/utils/common/function_inspect.py +97 -1
- kash/utils/common/testing.py +58 -0
- kash/utils/common/url_slice.py +329 -0
- kash/utils/file_utils/file_formats.py +1 -1
- kash/utils/text_handling/markdown_utils.py +424 -16
- kash/web_gen/templates/base_styles.css.jinja +137 -15
- kash/web_gen/templates/base_webpage.html.jinja +13 -17
- kash/web_gen/templates/components/toc_scripts.js.jinja +319 -0
- kash/web_gen/templates/components/toc_styles.css.jinja +284 -0
- kash/web_gen/templates/components/tooltip_scripts.js.jinja +730 -0
- kash/web_gen/templates/components/tooltip_styles.css.jinja +482 -0
- kash/web_gen/templates/content_styles.css.jinja +13 -8
- kash/web_gen/templates/simple_webpage.html.jinja +15 -481
- kash/workspaces/workspaces.py +10 -1
- {kash_shell-0.3.17.dist-info → kash_shell-0.3.18.dist-info}/METADATA +75 -72
- {kash_shell-0.3.17.dist-info → kash_shell-0.3.18.dist-info}/RECORD +37 -30
- {kash_shell-0.3.17.dist-info → kash_shell-0.3.18.dist-info}/WHEEL +0 -0
- {kash_shell-0.3.17.dist-info → kash_shell-0.3.18.dist-info}/entry_points.txt +0 -0
- {kash_shell-0.3.17.dist-info → kash_shell-0.3.18.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
/* -----------------------------------------------------------------------------
|
|
2
|
+
Core tooltip functionality and utilities
|
|
3
|
+
----------------------------------------------------------------------------- */
|
|
4
|
+
|
|
5
|
+
// Tooltip configuration constants
|
|
6
|
+
const TOOLTIP_CONFIG = {
|
|
7
|
+
// Timing delays (in milliseconds)
|
|
8
|
+
delays: {
|
|
9
|
+
show: 500, // Delay before showing tooltip
|
|
10
|
+
hide: 1500, // Default delay before hiding tooltip
|
|
11
|
+
hideWideRight: 2000, // Delay for wide-right tooltips (farther away)
|
|
12
|
+
hideMovingToward: 500, // Shorter delay when mouse moving toward tooltip
|
|
13
|
+
hideLinkClick: 300, // Delay after clicking a link in tooltip
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
// Spacing and sizing (in pixels)
|
|
17
|
+
spacing: {
|
|
18
|
+
viewportMargin: 10, // Minimum distance from viewport edges
|
|
19
|
+
tooltipGap: 10, // Gap between trigger and tooltip
|
|
20
|
+
wideRightMargin: 16, // Margin for wide-right positioning (1rem)
|
|
21
|
+
mouseTrackMargin: 20, // Margin for mouse movement detection
|
|
22
|
+
mobileTopMargin: 20, // Top margin for mobile positioning
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
// Default dimensions
|
|
26
|
+
dimensions: {
|
|
27
|
+
defaultWidth: 320, // Fallback tooltip width
|
|
28
|
+
defaultHeight: 200, // Fallback tooltip height
|
|
29
|
+
minWideRightWidth: 320, // Minimum space needed for wide-right
|
|
30
|
+
mobileMaxWidth: 768, // Maximum width for mobile tooltips (48rem = 768px at 16px base)
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
// Breakpoints (match CSS breakpoints)
|
|
34
|
+
breakpoints: {
|
|
35
|
+
mobile: 640, // Mobile breakpoint (actual mobile devices)
|
|
36
|
+
tablet: 768, // Tablet breakpoint
|
|
37
|
+
wide: 1200, // Wide screen breakpoint
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
// Content limits
|
|
41
|
+
content: {
|
|
42
|
+
maxFootnoteLength: 400, // Maximum characters for footnote tooltips
|
|
43
|
+
minFootnoteLength: 5, // Minimum characters to show tooltip
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Tooltip utility functions
|
|
48
|
+
const TooltipUtils = {
|
|
49
|
+
// Sanitize text content for safe HTML attribute usage
|
|
50
|
+
sanitizeText(text) {
|
|
51
|
+
return text
|
|
52
|
+
.replace(/\s+/g, ' ') // Normalize whitespace
|
|
53
|
+
.trim()
|
|
54
|
+
.replace(/"/g, '"') // Escape quotes
|
|
55
|
+
.replace(/</g, '<') // Escape HTML
|
|
56
|
+
.replace(/>/g, '>');
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
// Extract clean content from an element
|
|
60
|
+
extractContent(element, html = false) {
|
|
61
|
+
// Clone the element to avoid modifying the original
|
|
62
|
+
const clone = element.cloneNode(true);
|
|
63
|
+
|
|
64
|
+
// Remove unwanted elements (like the footnote back-link)
|
|
65
|
+
const backLinks = clone.querySelectorAll('.footnote');
|
|
66
|
+
backLinks.forEach(link => link.remove());
|
|
67
|
+
|
|
68
|
+
// Return HTML or text content
|
|
69
|
+
return html ? (clone.innerHTML || '') : (clone.textContent || clone.innerText || '');
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
// Extract clean text content from an element
|
|
73
|
+
extractTextContent(element) {
|
|
74
|
+
return this.extractContent(element, false);
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
// Extract clean HTML content from an element (preserving links)
|
|
78
|
+
extractHtmlContent(element) {
|
|
79
|
+
return this.extractContent(element, true);
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
// Determine optimal tooltip position based on element location and screen size
|
|
83
|
+
getOptimalPosition(element) {
|
|
84
|
+
const viewportWidth = window.innerWidth;
|
|
85
|
+
|
|
86
|
+
// Check if element is inside a table - if so, always use bottom-right positioning
|
|
87
|
+
// Tables often bleed to the right edge, making wide-right positioning problematic
|
|
88
|
+
if (element.closest('table')) {
|
|
89
|
+
return 'bottom-right';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// On wide screens, try to use the sidebar on the right of the main content
|
|
93
|
+
if (viewportWidth >= TOOLTIP_CONFIG.breakpoints.wide) {
|
|
94
|
+
const mainContent = document.getElementById('main-content');
|
|
95
|
+
if (mainContent) {
|
|
96
|
+
const contentRect = mainContent.getBoundingClientRect();
|
|
97
|
+
const available = viewportWidth - contentRect.right - TOOLTIP_CONFIG.spacing.wideRightMargin;
|
|
98
|
+
if (available >= TOOLTIP_CONFIG.dimensions.minWideRightWidth) {
|
|
99
|
+
return 'wide-right';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// For medium and small screens, always start with bottom-right
|
|
105
|
+
return 'bottom-right';
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/* -----------------------------------------------------------------------------
|
|
110
|
+
Generic tooltip creation and management functions
|
|
111
|
+
----------------------------------------------------------------------------- */
|
|
112
|
+
|
|
113
|
+
// Advanced tooltip manager with real DOM elements
|
|
114
|
+
const TooltipManager = {
|
|
115
|
+
activeTooltips: new Map(), // Track active tooltip states
|
|
116
|
+
|
|
117
|
+
// Add tooltip to any element with HTML content
|
|
118
|
+
addTooltip(element, htmlContent, position = 'auto') {
|
|
119
|
+
if (!element || !htmlContent) return;
|
|
120
|
+
|
|
121
|
+
// Check if tooltip already exists for this element
|
|
122
|
+
if (this.activeTooltips.has(element)) {
|
|
123
|
+
console.debug('Tooltip already exists for element, skipping creation');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const actualPosition = position === 'auto' ?
|
|
128
|
+
TooltipUtils.getOptimalPosition(element) : position;
|
|
129
|
+
|
|
130
|
+
// Create real DOM tooltip element
|
|
131
|
+
const tooltipElement = this.createTooltipElement(htmlContent, actualPosition);
|
|
132
|
+
|
|
133
|
+
// Mark the trigger element
|
|
134
|
+
element.setAttribute('data-tooltip-trigger', 'true');
|
|
135
|
+
|
|
136
|
+
// Determine if we should append to body
|
|
137
|
+
const viewportWidth = window.innerWidth;
|
|
138
|
+
const shouldAppendToBody = actualPosition === 'wide-right' ||
|
|
139
|
+
viewportWidth <= TOOLTIP_CONFIG.breakpoints.mobile;
|
|
140
|
+
|
|
141
|
+
// Append tooltip to appropriate parent
|
|
142
|
+
if (shouldAppendToBody) {
|
|
143
|
+
document.body.appendChild(tooltipElement);
|
|
144
|
+
element._bodyTooltip = tooltipElement; // Store reference for cleanup
|
|
145
|
+
} else {
|
|
146
|
+
element.appendChild(tooltipElement);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Set up enhanced hover behavior
|
|
150
|
+
this.setupAdvancedHover(element, tooltipElement, actualPosition);
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
// Create a real DOM tooltip element
|
|
154
|
+
createTooltipElement(htmlContent, position) {
|
|
155
|
+
const tooltip = document.createElement('div');
|
|
156
|
+
tooltip.className = `tooltip-element tooltip-${position}`;
|
|
157
|
+
|
|
158
|
+
// Add footnote-specific class if content contains footnote or links
|
|
159
|
+
if (htmlContent.includes('footnote') || htmlContent.includes('<a')) {
|
|
160
|
+
tooltip.classList.add('footnote-element');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
tooltip.innerHTML = htmlContent;
|
|
164
|
+
return tooltip;
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
// Set up advanced hover behavior with delays and mouse tracking
|
|
168
|
+
setupAdvancedHover(triggerElement, tooltipElement, position) {
|
|
169
|
+
const tooltipState = {
|
|
170
|
+
triggerElement,
|
|
171
|
+
tooltipElement,
|
|
172
|
+
position,
|
|
173
|
+
showTimeout: null,
|
|
174
|
+
hideTimeout: null,
|
|
175
|
+
isVisible: false
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
this.activeTooltips.set(triggerElement, tooltipState);
|
|
179
|
+
|
|
180
|
+
// Event handlers
|
|
181
|
+
const handlers = {
|
|
182
|
+
triggerEnter: (e) => this.handleTriggerEnter(tooltipState, e),
|
|
183
|
+
triggerLeave: (e) => this.handleTriggerLeave(tooltipState, e),
|
|
184
|
+
tooltipEnter: () => this.handleTooltipEnter(tooltipState),
|
|
185
|
+
tooltipLeave: () => this.handleTooltipLeave(tooltipState),
|
|
186
|
+
tooltipClick: (e) => this.handleTooltipClick(tooltipState, e)
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// Add event listeners
|
|
190
|
+
triggerElement.addEventListener('mouseenter', handlers.triggerEnter);
|
|
191
|
+
triggerElement.addEventListener('mouseleave', handlers.triggerLeave);
|
|
192
|
+
tooltipElement.addEventListener('mouseenter', handlers.tooltipEnter);
|
|
193
|
+
tooltipElement.addEventListener('mouseleave', handlers.tooltipLeave);
|
|
194
|
+
tooltipElement.addEventListener('click', handlers.tooltipClick);
|
|
195
|
+
|
|
196
|
+
// Store cleanup function
|
|
197
|
+
tooltipState.cleanupListeners = () => {
|
|
198
|
+
triggerElement.removeEventListener('mouseenter', handlers.triggerEnter);
|
|
199
|
+
triggerElement.removeEventListener('mouseleave', handlers.triggerLeave);
|
|
200
|
+
tooltipElement.removeEventListener('mouseenter', handlers.tooltipEnter);
|
|
201
|
+
tooltipElement.removeEventListener('mouseleave', handlers.tooltipLeave);
|
|
202
|
+
tooltipElement.removeEventListener('click', handlers.tooltipClick);
|
|
203
|
+
};
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
// Handle trigger element mouse enter
|
|
207
|
+
handleTriggerEnter(tooltipState, event) {
|
|
208
|
+
// Cancel any pending hide
|
|
209
|
+
this.clearTimeout(tooltipState, 'hideTimeout');
|
|
210
|
+
|
|
211
|
+
// Start show timer
|
|
212
|
+
if (!tooltipState.isVisible && !tooltipState.showTimeout) {
|
|
213
|
+
tooltipState.showTimeout = setTimeout(() => {
|
|
214
|
+
tooltipState.showTimeout = null;
|
|
215
|
+
this.showTooltip(tooltipState, event);
|
|
216
|
+
}, TOOLTIP_CONFIG.delays.show);
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
// Handle trigger element mouse leave
|
|
221
|
+
handleTriggerLeave(tooltipState, event) {
|
|
222
|
+
// Cancel any pending show
|
|
223
|
+
this.clearTimeout(tooltipState, 'showTimeout');
|
|
224
|
+
|
|
225
|
+
// Start hide timer if tooltip is visible
|
|
226
|
+
if (tooltipState.isVisible) {
|
|
227
|
+
this.startHideTimer(tooltipState, event);
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
// Handle tooltip mouse enter
|
|
232
|
+
handleTooltipEnter(tooltipState) {
|
|
233
|
+
this.clearTimeout(tooltipState, 'hideTimeout');
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
// Handle tooltip mouse leave
|
|
237
|
+
handleTooltipLeave(tooltipState) {
|
|
238
|
+
if (tooltipState.isVisible) {
|
|
239
|
+
this.startHideTimer(tooltipState);
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
// Handle clicks within tooltip
|
|
244
|
+
handleTooltipClick(tooltipState, event) {
|
|
245
|
+
// Allow clicks on links within tooltips
|
|
246
|
+
if (event.target.tagName === 'A') {
|
|
247
|
+
event.stopPropagation();
|
|
248
|
+
// Keep tooltip open briefly after clicking a link
|
|
249
|
+
this.clearTimeout(tooltipState, 'hideTimeout');
|
|
250
|
+
tooltipState.hideTimeout = setTimeout(() => {
|
|
251
|
+
this.hideTooltip(tooltipState);
|
|
252
|
+
}, TOOLTIP_CONFIG.delays.hideLinkClick);
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
// Clear a specific timeout
|
|
257
|
+
clearTimeout(tooltipState, timeoutName) {
|
|
258
|
+
if (tooltipState[timeoutName]) {
|
|
259
|
+
clearTimeout(tooltipState[timeoutName]);
|
|
260
|
+
tooltipState[timeoutName] = null;
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
// Show tooltip with coordination of other tooltips
|
|
265
|
+
showTooltip(tooltipState, event = null) {
|
|
266
|
+
// Close any other open tooltips immediately when showing a new one
|
|
267
|
+
this.closeAllTooltips(tooltipState.triggerElement);
|
|
268
|
+
|
|
269
|
+
// Clear hide timeout and check if already visible
|
|
270
|
+
this.clearTimeout(tooltipState, 'hideTimeout');
|
|
271
|
+
if (tooltipState.isVisible) return;
|
|
272
|
+
|
|
273
|
+
// Prepare tooltip for positioning
|
|
274
|
+
this.prepareTooltipForDisplay(tooltipState.tooltipElement);
|
|
275
|
+
|
|
276
|
+
// Position tooltip based on type
|
|
277
|
+
if (tooltipState.position === 'wide-right') {
|
|
278
|
+
this.positionWideRightTooltip(tooltipState.tooltipElement, tooltipState.triggerElement);
|
|
279
|
+
} else {
|
|
280
|
+
this.ensureTooltipWithinBounds(tooltipState.tooltipElement, tooltipState.triggerElement, tooltipState.position);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Make tooltip visible after positioning
|
|
284
|
+
this.makeTooltipVisible(tooltipState);
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
// Prepare tooltip element for display
|
|
288
|
+
prepareTooltipForDisplay(tooltipElement) {
|
|
289
|
+
tooltipElement.style.opacity = '0';
|
|
290
|
+
tooltipElement.style.visibility = 'hidden';
|
|
291
|
+
tooltipElement.classList.remove('tooltip-visible');
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
// Make tooltip visible with proper timing
|
|
295
|
+
makeTooltipVisible(tooltipState) {
|
|
296
|
+
// Double requestAnimationFrame to ensure layout is complete
|
|
297
|
+
requestAnimationFrame(() => {
|
|
298
|
+
requestAnimationFrame(() => {
|
|
299
|
+
tooltipState.tooltipElement.style.opacity = '';
|
|
300
|
+
tooltipState.tooltipElement.style.visibility = '';
|
|
301
|
+
tooltipState.tooltipElement.classList.add('tooltip-visible');
|
|
302
|
+
tooltipState.isVisible = true;
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
// Position wide-right tooltips
|
|
308
|
+
positionWideRightTooltip(tooltipElement, triggerElement) {
|
|
309
|
+
const triggerRect = triggerElement.getBoundingClientRect();
|
|
310
|
+
const mainContent = document.getElementById('main-content');
|
|
311
|
+
const viewportWidth = window.innerWidth;
|
|
312
|
+
const viewportHeight = window.innerHeight;
|
|
313
|
+
|
|
314
|
+
if (!mainContent) return;
|
|
315
|
+
|
|
316
|
+
const contentRect = mainContent.getBoundingClientRect();
|
|
317
|
+
const tooltipWidth = tooltipElement.offsetWidth || TOOLTIP_CONFIG.dimensions.defaultWidth;
|
|
318
|
+
const tooltipHeight = tooltipElement.offsetHeight || TOOLTIP_CONFIG.dimensions.defaultHeight;
|
|
319
|
+
|
|
320
|
+
// Calculate horizontal position
|
|
321
|
+
let leftPosition = contentRect.right + TOOLTIP_CONFIG.spacing.wideRightMargin;
|
|
322
|
+
const maxWidth = viewportWidth - leftPosition - TOOLTIP_CONFIG.spacing.wideRightMargin;
|
|
323
|
+
|
|
324
|
+
if (tooltipWidth > maxWidth) {
|
|
325
|
+
tooltipElement.style.maxWidth = `${maxWidth}px`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const finalWidth = Math.min(tooltipWidth, maxWidth);
|
|
329
|
+
if (leftPosition + finalWidth + TOOLTIP_CONFIG.spacing.wideRightMargin > viewportWidth) {
|
|
330
|
+
leftPosition = viewportWidth - finalWidth - TOOLTIP_CONFIG.spacing.wideRightMargin;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Calculate vertical position (centered on trigger)
|
|
334
|
+
let topPosition = triggerRect.top + (triggerRect.height / 2) - (tooltipHeight / 2);
|
|
335
|
+
const minTop = TOOLTIP_CONFIG.spacing.mobileTopMargin;
|
|
336
|
+
const maxTop = viewportHeight - tooltipHeight - TOOLTIP_CONFIG.spacing.mobileTopMargin;
|
|
337
|
+
topPosition = Math.max(minTop, Math.min(topPosition, maxTop));
|
|
338
|
+
|
|
339
|
+
// Apply positioning
|
|
340
|
+
tooltipElement.style.left = `${leftPosition}px`;
|
|
341
|
+
tooltipElement.style.top = `${topPosition}px`;
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
// Ensure standard tooltips stay within viewport bounds
|
|
345
|
+
ensureTooltipWithinBounds(tooltipElement, triggerElement, position) {
|
|
346
|
+
const viewportWidth = window.innerWidth;
|
|
347
|
+
const viewportHeight = window.innerHeight;
|
|
348
|
+
|
|
349
|
+
// Skip for wide screens (they use wide-right positioning)
|
|
350
|
+
if (viewportWidth >= TOOLTIP_CONFIG.breakpoints.wide) return;
|
|
351
|
+
|
|
352
|
+
// Get dimensions
|
|
353
|
+
const dimensions = this.measureTooltip(tooltipElement);
|
|
354
|
+
const triggerRect = triggerElement.getBoundingClientRect();
|
|
355
|
+
|
|
356
|
+
// Use bottom-centered positioning for narrow screens
|
|
357
|
+
if (viewportWidth <= TOOLTIP_CONFIG.breakpoints.mobile) {
|
|
358
|
+
this.positionBottomCenteredTooltip(tooltipElement, dimensions.width, viewportWidth);
|
|
359
|
+
} else {
|
|
360
|
+
this.positionMediumTooltip(tooltipElement, triggerRect, dimensions, viewportWidth, viewportHeight);
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
|
|
364
|
+
// Measure tooltip dimensions
|
|
365
|
+
measureTooltip(tooltipElement) {
|
|
366
|
+
// Temporarily make visible but transparent to measure
|
|
367
|
+
const originalStyles = {
|
|
368
|
+
opacity: tooltipElement.style.opacity,
|
|
369
|
+
pointerEvents: tooltipElement.style.pointerEvents,
|
|
370
|
+
display: tooltipElement.style.display
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
tooltipElement.style.opacity = '0';
|
|
374
|
+
tooltipElement.style.pointerEvents = 'none';
|
|
375
|
+
tooltipElement.style.display = 'block';
|
|
376
|
+
|
|
377
|
+
const rect = tooltipElement.getBoundingClientRect();
|
|
378
|
+
const dimensions = {
|
|
379
|
+
width: rect.width || tooltipElement.offsetWidth || TOOLTIP_CONFIG.dimensions.defaultWidth,
|
|
380
|
+
height: rect.height || tooltipElement.offsetHeight || TOOLTIP_CONFIG.dimensions.defaultHeight
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// Restore original styles
|
|
384
|
+
Object.assign(tooltipElement.style, originalStyles);
|
|
385
|
+
|
|
386
|
+
return dimensions;
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
// Position tooltip at bottom center for narrow screens
|
|
390
|
+
positionBottomCenteredTooltip(tooltipElement, tooltipWidth, viewportWidth) {
|
|
391
|
+
// Simple approach: full viewport width minus margins
|
|
392
|
+
const margin = 16; // 1rem margin on all sides
|
|
393
|
+
|
|
394
|
+
// Set tooltip to use viewport width minus margins
|
|
395
|
+
tooltipElement.style.width = `calc(100vw - ${2 * margin}px)`;
|
|
396
|
+
tooltipElement.style.maxWidth = 'none';
|
|
397
|
+
|
|
398
|
+
// Apply positioning - 1rem from left and bottom
|
|
399
|
+
Object.assign(tooltipElement.style, {
|
|
400
|
+
position: 'fixed',
|
|
401
|
+
left: `${margin}px`,
|
|
402
|
+
right: 'auto',
|
|
403
|
+
bottom: `${margin}px`,
|
|
404
|
+
top: 'auto',
|
|
405
|
+
transform: 'none'
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// Update classes
|
|
409
|
+
this.updatePositionClasses(tooltipElement, 'mobile-bottom');
|
|
410
|
+
|
|
411
|
+
// Remove arrow for mobile
|
|
412
|
+
this.removeArrowStyles(tooltipElement);
|
|
413
|
+
},
|
|
414
|
+
|
|
415
|
+
// Position tooltip for medium screens
|
|
416
|
+
positionMediumTooltip(tooltipElement, triggerRect, dimensions, viewportWidth, viewportHeight) {
|
|
417
|
+
const margin = TOOLTIP_CONFIG.spacing.viewportMargin;
|
|
418
|
+
const gap = TOOLTIP_CONFIG.spacing.tooltipGap;
|
|
419
|
+
|
|
420
|
+
// Try bottom-right first
|
|
421
|
+
let idealLeft = triggerRect.left;
|
|
422
|
+
let idealTop = triggerRect.bottom + gap;
|
|
423
|
+
|
|
424
|
+
// Check if it fits below
|
|
425
|
+
const fitsBelow = (idealTop + dimensions.height + margin) <= viewportHeight;
|
|
426
|
+
if (!fitsBelow) {
|
|
427
|
+
idealTop = triggerRect.top - dimensions.height - gap;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Constrain to viewport
|
|
431
|
+
const finalLeft = Math.max(margin, Math.min(idealLeft, viewportWidth - dimensions.width - margin));
|
|
432
|
+
const finalTop = Math.max(margin, Math.min(idealTop, viewportHeight - dimensions.height - margin));
|
|
433
|
+
|
|
434
|
+
// Apply positioning
|
|
435
|
+
Object.assign(tooltipElement.style, {
|
|
436
|
+
position: 'fixed',
|
|
437
|
+
left: `${finalLeft}px`,
|
|
438
|
+
top: `${finalTop}px`,
|
|
439
|
+
right: 'auto',
|
|
440
|
+
bottom: 'auto',
|
|
441
|
+
transform: 'none'
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// Update classes and arrow
|
|
445
|
+
const actualPosition = fitsBelow ? 'bottom-right' : 'top-right';
|
|
446
|
+
this.updatePositionClasses(tooltipElement, actualPosition);
|
|
447
|
+
this.updateArrowPosition(tooltipElement, triggerRect, finalLeft, finalTop, dimensions.width, dimensions.height, actualPosition);
|
|
448
|
+
},
|
|
449
|
+
|
|
450
|
+
// Update position classes on tooltip element
|
|
451
|
+
updatePositionClasses(tooltipElement, newPosition) {
|
|
452
|
+
const positionClasses = ['bottom-right', 'top-right', 'bottom-left', 'top-left', 'bottom', 'top', 'left', 'right', 'mobile-bottom'];
|
|
453
|
+
positionClasses.forEach(cls => tooltipElement.classList.remove(`tooltip-${cls}`));
|
|
454
|
+
tooltipElement.classList.add(`tooltip-${newPosition}`);
|
|
455
|
+
},
|
|
456
|
+
|
|
457
|
+
// Remove arrow styles
|
|
458
|
+
removeArrowStyles(tooltipElement) {
|
|
459
|
+
const existingStyle = tooltipElement.querySelector('style');
|
|
460
|
+
if (existingStyle) existingStyle.remove();
|
|
461
|
+
},
|
|
462
|
+
|
|
463
|
+
// Update arrow position to point to trigger
|
|
464
|
+
updateArrowPosition(tooltipElement, triggerRect, tooltipLeft, tooltipTop, tooltipWidth, tooltipHeight, position) {
|
|
465
|
+
// Create unique ID for this tooltip
|
|
466
|
+
const tooltipId = `tooltip-${Math.random().toString(36).substr(2, 9)}`;
|
|
467
|
+
tooltipElement.setAttribute('data-tooltip-id', tooltipId);
|
|
468
|
+
|
|
469
|
+
// Remove existing arrow style
|
|
470
|
+
this.removeArrowStyles(tooltipElement);
|
|
471
|
+
|
|
472
|
+
// Calculate arrow position
|
|
473
|
+
const triggerCenterX = triggerRect.left + (triggerRect.width / 2);
|
|
474
|
+
const arrowX = triggerCenterX - tooltipLeft;
|
|
475
|
+
const arrowXPercent = (arrowX / tooltipWidth) * 100;
|
|
476
|
+
const clampedPercent = Math.max(10, Math.min(90, arrowXPercent));
|
|
477
|
+
|
|
478
|
+
// Generate arrow style based on position
|
|
479
|
+
const arrowStyle = this.generateArrowStyle(tooltipId, position, clampedPercent);
|
|
480
|
+
|
|
481
|
+
// Apply arrow style
|
|
482
|
+
if (arrowStyle) {
|
|
483
|
+
const styleElement = document.createElement('style');
|
|
484
|
+
styleElement.textContent = arrowStyle;
|
|
485
|
+
tooltipElement.appendChild(styleElement);
|
|
486
|
+
}
|
|
487
|
+
},
|
|
488
|
+
|
|
489
|
+
// Generate CSS for arrow positioning
|
|
490
|
+
generateArrowStyle(tooltipId, position, xPercent) {
|
|
491
|
+
const baseStyle = `
|
|
492
|
+
[data-tooltip-id="${tooltipId}"]::after {
|
|
493
|
+
left: ${xPercent}% !important;
|
|
494
|
+
right: auto !important;
|
|
495
|
+
transform: translateX(-50%) !important;
|
|
496
|
+
`;
|
|
497
|
+
|
|
498
|
+
if (position === 'bottom-right' || position === 'bottom-left') {
|
|
499
|
+
return baseStyle + `
|
|
500
|
+
bottom: 100% !important;
|
|
501
|
+
top: auto !important;
|
|
502
|
+
border-bottom-color: var(--color-border-hint) !important;
|
|
503
|
+
border-top-color: transparent !important;
|
|
504
|
+
border-left-color: transparent !important;
|
|
505
|
+
border-right-color: transparent !important;
|
|
506
|
+
margin-bottom: -5px !important;
|
|
507
|
+
}`;
|
|
508
|
+
} else if (position === 'top-right' || position === 'top-left') {
|
|
509
|
+
return baseStyle + `
|
|
510
|
+
top: 100% !important;
|
|
511
|
+
bottom: auto !important;
|
|
512
|
+
border-top-color: var(--color-border-hint) !important;
|
|
513
|
+
border-bottom-color: transparent !important;
|
|
514
|
+
border-left-color: transparent !important;
|
|
515
|
+
border-right-color: transparent !important;
|
|
516
|
+
margin-top: -5px !important;
|
|
517
|
+
}`;
|
|
518
|
+
}
|
|
519
|
+
return null;
|
|
520
|
+
},
|
|
521
|
+
|
|
522
|
+
// Start the hide timer with appropriate delay
|
|
523
|
+
startHideTimer(tooltipState, event = null) {
|
|
524
|
+
const isWideRight = tooltipState.position === 'wide-right';
|
|
525
|
+
let delay = isWideRight ? TOOLTIP_CONFIG.delays.hideWideRight : TOOLTIP_CONFIG.delays.hide;
|
|
526
|
+
|
|
527
|
+
// Check if mouse is moving toward tooltip (for non-wide-right)
|
|
528
|
+
if (event && !isWideRight) {
|
|
529
|
+
const rect = tooltipState.triggerElement.getBoundingClientRect();
|
|
530
|
+
if (this.isMouseMovingTowardTooltip(rect, event.clientX, event.clientY, tooltipState.position)) {
|
|
531
|
+
delay = TOOLTIP_CONFIG.delays.hideMovingToward;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
tooltipState.hideTimeout = setTimeout(() => {
|
|
536
|
+
this.hideTooltip(tooltipState);
|
|
537
|
+
}, delay);
|
|
538
|
+
},
|
|
539
|
+
|
|
540
|
+
// Hide tooltip
|
|
541
|
+
hideTooltip(tooltipState) {
|
|
542
|
+
this.clearTimeout(tooltipState, 'showTimeout');
|
|
543
|
+
tooltipState.tooltipElement.classList.remove('tooltip-visible');
|
|
544
|
+
tooltipState.isVisible = false;
|
|
545
|
+
tooltipState.hideTimeout = null;
|
|
546
|
+
},
|
|
547
|
+
|
|
548
|
+
// Close all tooltips immediately (for cleanup)
|
|
549
|
+
closeAllTooltips(exceptElement = null) {
|
|
550
|
+
this.activeTooltips.forEach((tooltipState, element) => {
|
|
551
|
+
if (element !== exceptElement && tooltipState.isVisible) {
|
|
552
|
+
this.clearTimeout(tooltipState, 'hideTimeout');
|
|
553
|
+
this.clearTimeout(tooltipState, 'showTimeout');
|
|
554
|
+
this.hideTooltip(tooltipState);
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
},
|
|
558
|
+
|
|
559
|
+
// Close all tooltips with delay when showing a new one
|
|
560
|
+
closeAllTooltipsWithDelay(exceptElement = null) {
|
|
561
|
+
this.activeTooltips.forEach((tooltipState, element) => {
|
|
562
|
+
if (element !== exceptElement) {
|
|
563
|
+
this.clearTimeout(tooltipState, 'showTimeout');
|
|
564
|
+
|
|
565
|
+
if (tooltipState.isVisible && !tooltipState.hideTimeout) {
|
|
566
|
+
tooltipState.hideTimeout = setTimeout(() => {
|
|
567
|
+
this.hideTooltip(tooltipState);
|
|
568
|
+
}, TOOLTIP_CONFIG.delays.show);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
},
|
|
573
|
+
|
|
574
|
+
// Check if mouse is moving toward tooltip area
|
|
575
|
+
isMouseMovingTowardTooltip(elementRect, mouseX, mouseY, position) {
|
|
576
|
+
if (position === 'wide-right' || position === 'mobile-bottom') return false;
|
|
577
|
+
|
|
578
|
+
const margin = TOOLTIP_CONFIG.spacing.mouseTrackMargin;
|
|
579
|
+
|
|
580
|
+
if (position === 'bottom-right' || position === 'bottom-left') {
|
|
581
|
+
return mouseY > elementRect.bottom &&
|
|
582
|
+
mouseX >= elementRect.left - margin &&
|
|
583
|
+
mouseX <= elementRect.right + margin;
|
|
584
|
+
} else if (position === 'top-right' || position === 'top-left') {
|
|
585
|
+
return mouseY < elementRect.top &&
|
|
586
|
+
mouseX >= elementRect.left - margin &&
|
|
587
|
+
mouseX <= elementRect.right + margin;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return false;
|
|
591
|
+
},
|
|
592
|
+
|
|
593
|
+
// Remove tooltip from element
|
|
594
|
+
removeTooltip(element) {
|
|
595
|
+
if (!element) return;
|
|
596
|
+
|
|
597
|
+
const tooltipState = this.activeTooltips.get(element);
|
|
598
|
+
if (!tooltipState) return;
|
|
599
|
+
|
|
600
|
+
// Clear all timeouts
|
|
601
|
+
this.clearTimeout(tooltipState, 'hideTimeout');
|
|
602
|
+
this.clearTimeout(tooltipState, 'showTimeout');
|
|
603
|
+
|
|
604
|
+
// Remove tooltip element
|
|
605
|
+
if (tooltipState.tooltipElement?.parentNode) {
|
|
606
|
+
tooltipState.tooltipElement.parentNode.removeChild(tooltipState.tooltipElement);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Remove tooltip reference
|
|
610
|
+
if (element._bodyTooltip?.parentNode) {
|
|
611
|
+
element._bodyTooltip.parentNode.removeChild(element._bodyTooltip);
|
|
612
|
+
delete element._bodyTooltip;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Cleanup listeners
|
|
616
|
+
if (tooltipState.cleanupListeners) {
|
|
617
|
+
tooltipState.cleanupListeners();
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Clean up tracking
|
|
621
|
+
this.activeTooltips.delete(element);
|
|
622
|
+
element.removeAttribute('data-tooltip');
|
|
623
|
+
element.removeAttribute('data-tooltip-position');
|
|
624
|
+
element.removeAttribute('data-tooltip-trigger');
|
|
625
|
+
},
|
|
626
|
+
|
|
627
|
+
// Handle window resize to reposition tooltips
|
|
628
|
+
handleResize() {
|
|
629
|
+
this.activeTooltips.forEach((tooltipState, element) => {
|
|
630
|
+
const shouldBeWideRight = TooltipUtils.getOptimalPosition(element) === 'wide-right';
|
|
631
|
+
const isWideRight = tooltipState.position === 'wide-right';
|
|
632
|
+
|
|
633
|
+
if (shouldBeWideRight !== isWideRight) {
|
|
634
|
+
const htmlContent = tooltipState.tooltipElement.innerHTML;
|
|
635
|
+
this.removeTooltip(element);
|
|
636
|
+
this.addTooltip(element, htmlContent, 'auto');
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
// Handle window resize events
|
|
643
|
+
window.addEventListener('resize', () => {
|
|
644
|
+
TooltipManager.handleResize();
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
/* -----------------------------------------------------------------------------
|
|
648
|
+
Automatic footnote detection and tooltip creation
|
|
649
|
+
----------------------------------------------------------------------------- */
|
|
650
|
+
|
|
651
|
+
// Initialize footnote tooltips
|
|
652
|
+
function initFootnoteTooltips() {
|
|
653
|
+
console.debug('Initializing footnote tooltips...');
|
|
654
|
+
|
|
655
|
+
const footnoteRefs = document.querySelectorAll('.footnote-ref a[href^="#fn-"]');
|
|
656
|
+
let tooltipsAdded = 0;
|
|
657
|
+
|
|
658
|
+
footnoteRefs.forEach(refLink => {
|
|
659
|
+
try {
|
|
660
|
+
const href = refLink.getAttribute('href');
|
|
661
|
+
if (!href || !href.startsWith('#fn-')) return;
|
|
662
|
+
|
|
663
|
+
const footnoteId = href.substring(1);
|
|
664
|
+
const footnoteElement = document.getElementById(footnoteId);
|
|
665
|
+
if (!footnoteElement) {
|
|
666
|
+
console.debug(`Footnote element not found for ID: ${footnoteId}`);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Extract and validate content
|
|
671
|
+
const footnoteHtml = TooltipUtils.extractHtmlContent(footnoteElement);
|
|
672
|
+
const footnoteText = TooltipUtils.extractTextContent(footnoteElement);
|
|
673
|
+
|
|
674
|
+
if (!footnoteText || footnoteText.length < TOOLTIP_CONFIG.content.minFootnoteLength) {
|
|
675
|
+
console.debug(`Footnote text too short or empty for ID: ${footnoteId}`);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Truncate if needed
|
|
680
|
+
const displayContent = truncateFootnoteContent(footnoteHtml, footnoteText);
|
|
681
|
+
|
|
682
|
+
// Add tooltip
|
|
683
|
+
TooltipManager.addTooltip(refLink, displayContent, 'auto');
|
|
684
|
+
tooltipsAdded++;
|
|
685
|
+
|
|
686
|
+
console.debug(`Added tooltip for footnote ${footnoteId}: "${footnoteText.substring(0, 50)}..."`);
|
|
687
|
+
} catch (error) {
|
|
688
|
+
console.error('Error processing footnote reference:', refLink, error);
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
console.debug(`Footnote tooltips initialized: ${tooltipsAdded} tooltips added`);
|
|
693
|
+
return tooltipsAdded;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Truncate footnote content if too long
|
|
697
|
+
function truncateFootnoteContent(html, text) {
|
|
698
|
+
if (text.length <= TOOLTIP_CONFIG.content.maxFootnoteLength) {
|
|
699
|
+
return html;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const tempDiv = document.createElement('div');
|
|
703
|
+
tempDiv.innerHTML = html;
|
|
704
|
+
const textContent = tempDiv.textContent || tempDiv.innerText || '';
|
|
705
|
+
|
|
706
|
+
if (textContent.length > TOOLTIP_CONFIG.content.maxFootnoteLength) {
|
|
707
|
+
tempDiv.textContent = textContent.substring(0, TOOLTIP_CONFIG.content.maxFootnoteLength) + '...';
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
return tempDiv.innerHTML;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Initialize all tooltips
|
|
714
|
+
function initTooltips() {
|
|
715
|
+
console.debug('Starting tooltip initialization...');
|
|
716
|
+
|
|
717
|
+
try {
|
|
718
|
+
const footnoteCount = initFootnoteTooltips();
|
|
719
|
+
console.debug(`Tooltip initialization complete. Total footnote tooltips: ${footnoteCount}`);
|
|
720
|
+
} catch (error) {
|
|
721
|
+
console.error('Error during tooltip initialization:', error);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Initialize when DOM is ready
|
|
726
|
+
if (document.readyState === 'loading') {
|
|
727
|
+
document.addEventListener('DOMContentLoaded', initTooltips);
|
|
728
|
+
} else {
|
|
729
|
+
initTooltips();
|
|
730
|
+
}
|