kash-shell 0.3.18__py3-none-any.whl → 0.3.21__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/{markdownify.py → markdownify_html.py} +3 -6
- kash/commands/workspace/workspace_commands.py +10 -88
- kash/config/colors.py +8 -6
- kash/config/text_styles.py +2 -0
- kash/docs/markdown/topics/a1_what_is_kash.md +1 -1
- kash/docs/markdown/topics/b1_kash_overview.md +34 -45
- kash/exec/__init__.py +3 -0
- kash/exec/action_decorators.py +20 -5
- kash/exec/action_exec.py +2 -2
- kash/exec/{fetch_url_metadata.py → fetch_url_items.py} +42 -14
- kash/exec/llm_transforms.py +1 -1
- kash/exec/shell_callable_action.py +1 -1
- kash/file_storage/file_store.py +7 -1
- kash/file_storage/store_filenames.py +4 -0
- kash/help/function_param_info.py +1 -1
- kash/help/help_pages.py +1 -1
- kash/help/help_printing.py +1 -1
- kash/llm_utils/llm_completion.py +1 -1
- kash/model/actions_model.py +6 -0
- kash/model/items_model.py +18 -3
- kash/shell/output/shell_output.py +15 -0
- kash/utils/api_utils/api_retries.py +305 -0
- kash/utils/api_utils/cache_requests_limited.py +84 -0
- kash/utils/api_utils/gather_limited.py +987 -0
- kash/utils/api_utils/progress_protocol.py +299 -0
- kash/utils/common/function_inspect.py +66 -1
- kash/utils/common/parse_docstring.py +347 -0
- kash/utils/common/testing.py +10 -7
- kash/utils/rich_custom/multitask_status.py +631 -0
- kash/utils/text_handling/escape_html_tags.py +16 -11
- kash/utils/text_handling/markdown_render.py +1 -0
- kash/web_content/web_extract.py +34 -15
- kash/web_content/web_page_model.py +10 -1
- kash/web_gen/templates/base_styles.css.jinja +26 -20
- kash/web_gen/templates/components/toc_styles.css.jinja +1 -1
- kash/web_gen/templates/components/tooltip_scripts.js.jinja +171 -19
- kash/web_gen/templates/components/tooltip_styles.css.jinja +23 -8
- {kash_shell-0.3.18.dist-info → kash_shell-0.3.21.dist-info}/METADATA +4 -2
- {kash_shell-0.3.18.dist-info → kash_shell-0.3.21.dist-info}/RECORD +42 -37
- kash/help/docstring_utils.py +0 -111
- {kash_shell-0.3.18.dist-info → kash_shell-0.3.21.dist-info}/WHEEL +0 -0
- {kash_shell-0.3.18.dist-info → kash_shell-0.3.21.dist-info}/entry_points.txt +0 -0
- {kash_shell-0.3.18.dist-info → kash_shell-0.3.21.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import re
|
|
2
2
|
from collections.abc import Set
|
|
3
3
|
|
|
4
|
-
HTML_IN_MD_TAGS = frozenset(
|
|
5
|
-
"""
|
|
4
|
+
HTML_IN_MD_TAGS = frozenset(
|
|
5
|
+
["div", "span", "i", "b", "em", "sup", "sub", "br", "details", "summary"]
|
|
6
|
+
)
|
|
7
|
+
"""
|
|
8
|
+
These are tags that have reasonable usage in Markdown so typically would be preserved.
|
|
9
|
+
Note we want `<i>` because it's used for icons like `<i data-feather="list"></i>`.
|
|
10
|
+
"""
|
|
6
11
|
|
|
7
12
|
ALLOWED_BARE_PROTOS = frozenset(["http://", "https://", "file://"])
|
|
8
13
|
|
|
@@ -68,7 +73,7 @@ def escape_html_tags(
|
|
|
68
73
|
def test_escape_html_tags():
|
|
69
74
|
"""Tests the escape_html_tags function with various cases."""
|
|
70
75
|
|
|
71
|
-
#
|
|
76
|
+
# Basic Whitelist Check (Default)
|
|
72
77
|
assert escape_html_tags("<div>Test</div>") == "<div>Test</div>"
|
|
73
78
|
assert escape_html_tags("<span>Test</span>") == "<span>Test</span>"
|
|
74
79
|
assert escape_html_tags("<br>") == "<br>"
|
|
@@ -77,21 +82,21 @@ def test_escape_html_tags():
|
|
|
77
82
|
== "<details><summary>Sum</summary>Det</details>"
|
|
78
83
|
)
|
|
79
84
|
|
|
80
|
-
#
|
|
85
|
+
# Basic Escape Check
|
|
81
86
|
assert escape_html_tags("<p>Test</p>") == "<p>Test</p>"
|
|
82
87
|
assert escape_html_tags("<script>alert('x');</script>") == "<script>alert('x');</script>"
|
|
83
88
|
assert escape_html_tags("<img>") == "<img>"
|
|
84
89
|
|
|
85
|
-
#
|
|
90
|
+
# Case Insensitivity
|
|
86
91
|
assert escape_html_tags("<DiV>Case</DiV>") == "<DiV>Case</DiV>" # Whitelisted
|
|
87
92
|
assert escape_html_tags("<P>Test</P>") == "<P>Test</P>" # Escaped
|
|
88
93
|
|
|
89
|
-
#
|
|
94
|
+
# Self-closing tags
|
|
90
95
|
assert escape_html_tags("<br/>") == "<br/>" # Whitelisted
|
|
91
96
|
assert escape_html_tags("<br />") == "<br />" # Whitelisted
|
|
92
97
|
assert escape_html_tags("<img/>") == "<img/>" # Escaped
|
|
93
98
|
|
|
94
|
-
#
|
|
99
|
+
# Tags with Attributes
|
|
95
100
|
assert (
|
|
96
101
|
escape_html_tags('<div class="foo">Test</div>') == '<div class="foo">Test</div>'
|
|
97
102
|
) # Whitelisted
|
|
@@ -102,7 +107,7 @@ def test_escape_html_tags():
|
|
|
102
107
|
assert escape_html_tags('<p class="foo">Test</p>') == '<p class="foo">Test</p>' # Escaped
|
|
103
108
|
assert escape_html_tags('<img src="a.jpg"/>') == '<img src="a.jpg"/>' # Escaped
|
|
104
109
|
|
|
105
|
-
#
|
|
110
|
+
# Markdown URL Handling
|
|
106
111
|
url_md = "Check <https://example.com> and <http://test.org/path>"
|
|
107
112
|
assert escape_html_tags(url_md, allow_bare_md_urls=True) == url_md
|
|
108
113
|
assert (
|
|
@@ -127,7 +132,7 @@ def test_escape_html_tags():
|
|
|
127
132
|
== "</https://example.com>"
|
|
128
133
|
) # Closing URL-like is escaped
|
|
129
134
|
|
|
130
|
-
#
|
|
135
|
+
# Nested/Malformed '<' and Edge Cases
|
|
131
136
|
assert escape_html_tags("<<script>>") == "<<script>>" # Escaped non-tag <
|
|
132
137
|
assert escape_html_tags("<div><p>nested</p></div>") == "<div><p>nested</p></div>"
|
|
133
138
|
assert escape_html_tags("<div<span") == "<div<span" # Incomplete tags are escaped
|
|
@@ -140,7 +145,7 @@ def test_escape_html_tags():
|
|
|
140
145
|
assert escape_html_tags("< >") == "< >"
|
|
141
146
|
assert escape_html_tags("< / div >") == "< / div >" # Whitelisted closing tag with spaces
|
|
142
147
|
|
|
143
|
-
#
|
|
148
|
+
# Mixed Content Combination
|
|
144
149
|
complex_html = "<DiV class='A'>Hello <Br/> <p>World</p> <https://link.com> </DiV>"
|
|
145
150
|
expected_complex_allowed = (
|
|
146
151
|
"<DiV class='A'>Hello <Br/> <p>World</p> <https://link.com> </DiV>"
|
|
@@ -151,6 +156,6 @@ def test_escape_html_tags():
|
|
|
151
156
|
assert escape_html_tags(complex_html, allow_bare_md_urls=True) == expected_complex_allowed
|
|
152
157
|
assert escape_html_tags(complex_html, allow_bare_md_urls=False) == expected_complex_disallowed
|
|
153
158
|
|
|
154
|
-
#
|
|
159
|
+
# Empty/No Tags
|
|
155
160
|
assert escape_html_tags("") == ""
|
|
156
161
|
assert escape_html_tags("Just plain text, no tags.") == "Just plain text, no tags."
|
kash/web_content/web_extract.py
CHANGED
|
@@ -1,38 +1,57 @@
|
|
|
1
1
|
from funlog import log_calls
|
|
2
2
|
|
|
3
3
|
from kash.utils.common.url import Url
|
|
4
|
+
from kash.utils.file_utils.file_formats_model import file_format_info
|
|
4
5
|
from kash.web_content.canon_url import thumbnail_url
|
|
5
6
|
from kash.web_content.file_cache_utils import cache_file
|
|
6
7
|
from kash.web_content.web_extract_justext import extract_text_justext
|
|
7
|
-
from kash.web_content.web_fetch import fetch_url
|
|
8
8
|
from kash.web_content.web_page_model import PageExtractor, WebPageData
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
@log_calls(level="message")
|
|
12
|
-
def
|
|
12
|
+
def fetch_page_content(
|
|
13
13
|
url: Url,
|
|
14
|
+
*,
|
|
14
15
|
refetch: bool = False,
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
cache: bool = True,
|
|
17
|
+
text_extractor: PageExtractor = extract_text_justext,
|
|
17
18
|
) -> WebPageData:
|
|
18
19
|
"""
|
|
19
20
|
Fetches a URL and extracts the title, description, and content.
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
Always uses the content cache, at least temporarily.
|
|
22
|
+
|
|
23
|
+
Force re-fetching and updating the cache by setting `refetch` to true.
|
|
24
|
+
Cached file path is returned in the content, unless `cache` is false,
|
|
25
|
+
in case the cached content is deleted.
|
|
26
|
+
|
|
27
|
+
For HTML and other text files, uses the `text_extractor` to extract
|
|
28
|
+
clean text and page metadata.
|
|
22
29
|
"""
|
|
23
30
|
expiration_sec = 0 if refetch else None
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
|
|
32
|
+
path = cache_file(url, expiration_sec=expiration_sec).content.path
|
|
33
|
+
format_info = file_format_info(path)
|
|
34
|
+
|
|
35
|
+
content = None
|
|
36
|
+
if format_info.format and format_info.format.is_text:
|
|
37
|
+
content = path.read_bytes()
|
|
38
|
+
page_data = text_extractor(url, content)
|
|
29
39
|
else:
|
|
30
|
-
|
|
31
|
-
page_data = extractor(url, response.content)
|
|
40
|
+
page_data = WebPageData(url)
|
|
32
41
|
|
|
33
|
-
# Add
|
|
42
|
+
# Add file format info (for both HTML/text and all other file types).
|
|
43
|
+
|
|
44
|
+
page_data.format_info = format_info
|
|
45
|
+
|
|
46
|
+
# Add a thumbnail, if known for this URL.
|
|
34
47
|
page_data.thumbnail_url = thumbnail_url(url)
|
|
35
48
|
|
|
49
|
+
# Return the local cache path if we will be keeping it.
|
|
50
|
+
if cache:
|
|
51
|
+
page_data.saved_content = path
|
|
52
|
+
else:
|
|
53
|
+
path.unlink()
|
|
54
|
+
|
|
36
55
|
return page_data
|
|
37
56
|
|
|
38
57
|
|
|
@@ -53,5 +72,5 @@ if __name__ == "__main__":
|
|
|
53
72
|
|
|
54
73
|
for url in sample_urls:
|
|
55
74
|
print(f"URL: {url}")
|
|
56
|
-
print(
|
|
75
|
+
print(fetch_page_content(Url(url)))
|
|
57
76
|
print()
|
|
@@ -5,12 +5,19 @@ from prettyfmt import abbrev_obj
|
|
|
5
5
|
from pydantic.dataclasses import dataclass
|
|
6
6
|
|
|
7
7
|
from kash.utils.common.url import Url
|
|
8
|
+
from kash.utils.file_utils.file_formats_model import FileFormatInfo
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
@dataclass
|
|
11
12
|
class WebPageData:
|
|
12
13
|
"""
|
|
13
|
-
Data about a web page, including URL, title and optionally description and
|
|
14
|
+
Data about a web page, including URL, title and optionally description and
|
|
15
|
+
extracted content.
|
|
16
|
+
|
|
17
|
+
The `text` field should be a clean text version of the page, if available.
|
|
18
|
+
The `clean_html` field should be a clean HTML version of the page, if available.
|
|
19
|
+
The `saved_content` is optional but can be used to reference the original content,
|
|
20
|
+
especially for large or non-text content.
|
|
14
21
|
"""
|
|
15
22
|
|
|
16
23
|
locator: Url | Path
|
|
@@ -19,6 +26,8 @@ class WebPageData:
|
|
|
19
26
|
description: str | None = None
|
|
20
27
|
text: str | None = None
|
|
21
28
|
clean_html: str | None = None
|
|
29
|
+
saved_content: Path | None = None
|
|
30
|
+
format_info: FileFormatInfo | None = None
|
|
22
31
|
thumbnail_url: Url | None = None
|
|
23
32
|
|
|
24
33
|
def __repr__(self):
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
--font-sans: "Source Sans 3 Variable", sans-serif, "Hack Nerd Font";
|
|
6
6
|
--font-serif: "PT Serif", serif, "Hack Nerd Font";
|
|
7
7
|
/* Source Sans 3 Variable better at these weights. */
|
|
8
|
-
--font-weight-sans-medium:
|
|
8
|
+
--font-weight-sans-medium: 550;
|
|
9
9
|
--font-weight-sans-bold: 650;
|
|
10
10
|
--font-mono: "Hack Nerd Font", "Menlo", "DejaVu Sans Mono", Consolas, "Lucida Console", monospace;
|
|
11
11
|
|
|
@@ -156,7 +156,13 @@ h4 {
|
|
|
156
156
|
margin-bottom: 0.7rem;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
h5
|
|
159
|
+
h5 {
|
|
160
|
+
font-size: 1rem;
|
|
161
|
+
margin-top: 0.7rem;
|
|
162
|
+
margin-bottom: 0.5rem;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
h6 {
|
|
160
166
|
font-size: 1rem;
|
|
161
167
|
margin-top: 0.7rem;
|
|
162
168
|
margin-bottom: 0.5rem;
|
|
@@ -368,15 +374,15 @@ hr:before {
|
|
|
368
374
|
|
|
369
375
|
.long-text h3 {
|
|
370
376
|
font-family: var(--font-sans);
|
|
371
|
-
font-weight:
|
|
377
|
+
font-weight: 550;
|
|
372
378
|
text-transform: uppercase;
|
|
373
379
|
letter-spacing: 0.025em;
|
|
374
380
|
}
|
|
375
381
|
|
|
376
382
|
.long-text h4 {
|
|
377
|
-
font-family: var(--font-
|
|
378
|
-
font-weight:
|
|
379
|
-
|
|
383
|
+
font-family: var(--font-serif);
|
|
384
|
+
font-weight: 400;
|
|
385
|
+
font-style: italic;
|
|
380
386
|
}
|
|
381
387
|
|
|
382
388
|
.long-text h5 {
|
|
@@ -435,6 +441,10 @@ hr:before {
|
|
|
435
441
|
{% endblock long_text_styles %}
|
|
436
442
|
|
|
437
443
|
{% block table_styles %}
|
|
444
|
+
table, th, td, tbody tr {
|
|
445
|
+
transition: background-color 0.4s ease-in-out, border-color 0.4s ease-in-out;
|
|
446
|
+
}
|
|
447
|
+
|
|
438
448
|
table {
|
|
439
449
|
font-family: var(--font-sans);
|
|
440
450
|
font-size: var(--font-size-small);
|
|
@@ -453,6 +463,7 @@ th {
|
|
|
453
463
|
letter-spacing: 0.03em;
|
|
454
464
|
border-bottom: 1px solid var(--color-border-hint);
|
|
455
465
|
line-height: 1.2;
|
|
466
|
+
background-color: var(--color-bg-alt-solid);
|
|
456
467
|
}
|
|
457
468
|
|
|
458
469
|
th, td {
|
|
@@ -461,10 +472,6 @@ th, td {
|
|
|
461
472
|
min-width: 6rem;
|
|
462
473
|
}
|
|
463
474
|
|
|
464
|
-
th {
|
|
465
|
-
background-color: var(--color-bg-alt-solid);
|
|
466
|
-
}
|
|
467
|
-
|
|
468
475
|
tbody tr:nth-child(even) {
|
|
469
476
|
background-color: var(--color-bg-alt-solid);
|
|
470
477
|
}
|
|
@@ -480,7 +487,12 @@ tbody tr:nth-child(even) {
|
|
|
480
487
|
transform: translateX(-50%);
|
|
481
488
|
/* Prevent container from expanding beyond its content area */
|
|
482
489
|
overflow-x: auto;
|
|
483
|
-
overflow-y:
|
|
490
|
+
overflow-y: hidden; /* Whole height of table shown, no vertical scrolling. */
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
.table-container table {
|
|
494
|
+
/* Tricky: Need this to prevent bogus extra horizontal scroll while keeping normal sizing */
|
|
495
|
+
contain: content;
|
|
484
496
|
}
|
|
485
497
|
|
|
486
498
|
/* When TOC is present, simplify table container positioning */
|
|
@@ -540,7 +552,6 @@ sup {
|
|
|
540
552
|
width: calc(100vw - 6rem);
|
|
541
553
|
/* Ensure container doesn't expand beyond its width */
|
|
542
554
|
max-width: calc(100vw - 6rem);
|
|
543
|
-
contain: layout inline-size;
|
|
544
555
|
}
|
|
545
556
|
|
|
546
557
|
/* Apply shadow to long-text containers on larger screens */
|
|
@@ -590,8 +601,6 @@ sup {
|
|
|
590
601
|
|
|
591
602
|
/* Ensure horizontal scroll works properly without expanding container */
|
|
592
603
|
overflow-x: auto;
|
|
593
|
-
overflow-y: visible;
|
|
594
|
-
contain: layout inline-size;
|
|
595
604
|
}
|
|
596
605
|
|
|
597
606
|
.content-with-toc.has-toc table {
|
|
@@ -634,20 +643,17 @@ sup {
|
|
|
634
643
|
|
|
635
644
|
/* Make table containers scrollable without affecting page layout */
|
|
636
645
|
.table-container {
|
|
637
|
-
width: calc(100vw - 3rem); /* Fixed width instead of max-width */
|
|
638
|
-
max-width: calc(100vw - 3rem);
|
|
639
646
|
overflow-x: auto;
|
|
640
|
-
overflow-y: visible; /* Ensure vertical overflow is not hidden */
|
|
641
647
|
transform: none;
|
|
642
648
|
left: 0;
|
|
643
649
|
position: relative;
|
|
644
650
|
margin-left: auto;
|
|
645
|
-
margin-right:
|
|
651
|
+
margin-right: 0; /* Extend to right edge */
|
|
646
652
|
/* Prevent container from expanding beyond its width */
|
|
647
653
|
box-sizing: border-box;
|
|
648
|
-
|
|
654
|
+
width: calc(100vw - 1.5rem); /* Full width minus left margin */
|
|
649
655
|
}
|
|
650
|
-
|
|
656
|
+
|
|
651
657
|
table {
|
|
652
658
|
font-size: var(--font-size-smaller);
|
|
653
659
|
/* Tables can be wider than container on mobile */
|
|
@@ -7,8 +7,8 @@ const TOOLTIP_CONFIG = {
|
|
|
7
7
|
// Timing delays (in milliseconds)
|
|
8
8
|
delays: {
|
|
9
9
|
show: 500, // Delay before showing tooltip
|
|
10
|
-
hide:
|
|
11
|
-
hideWideRight:
|
|
10
|
+
hide: 2500, // Default delay before hiding tooltip
|
|
11
|
+
hideWideRight: 4000, // Delay for wide-right tooltips (farther away)
|
|
12
12
|
hideMovingToward: 500, // Shorter delay when mouse moving toward tooltip
|
|
13
13
|
hideLinkClick: 300, // Delay after clicking a link in tooltip
|
|
14
14
|
},
|
|
@@ -79,6 +79,14 @@ const TooltipUtils = {
|
|
|
79
79
|
return this.extractContent(element, true);
|
|
80
80
|
},
|
|
81
81
|
|
|
82
|
+
// Add footnote navigation button to tooltip content
|
|
83
|
+
addFootnoteNavigationButton(content, footnoteId) {
|
|
84
|
+
const navButton = `<span class="footnote-nav-container">
|
|
85
|
+
<a href="#${footnoteId}" class="footnote" data-footnote-id="${footnoteId}" title="Go to footnote"> ↓ </a>
|
|
86
|
+
</span>`;
|
|
87
|
+
return content + navButton;
|
|
88
|
+
},
|
|
89
|
+
|
|
82
90
|
// Determine optimal tooltip position based on element location and screen size
|
|
83
91
|
getOptimalPosition(element) {
|
|
84
92
|
const viewportWidth = window.innerWidth;
|
|
@@ -106,6 +114,69 @@ const TooltipUtils = {
|
|
|
106
114
|
}
|
|
107
115
|
};
|
|
108
116
|
|
|
117
|
+
/* -----------------------------------------------------------------------------
|
|
118
|
+
Mobile tap-outside-to-close functionality
|
|
119
|
+
----------------------------------------------------------------------------- */
|
|
120
|
+
|
|
121
|
+
// Mobile interaction manager
|
|
122
|
+
const MobileInteractionManager = {
|
|
123
|
+
isActive: false,
|
|
124
|
+
|
|
125
|
+
// Initialize mobile interaction handling
|
|
126
|
+
init() {
|
|
127
|
+
if (this.isActive) return;
|
|
128
|
+
|
|
129
|
+
// Only activate on mobile screens
|
|
130
|
+
if (window.innerWidth <= TOOLTIP_CONFIG.breakpoints.mobile) {
|
|
131
|
+
this.activate();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Listen for resize events to activate/deactivate
|
|
135
|
+
window.addEventListener('resize', () => {
|
|
136
|
+
if (window.innerWidth <= TOOLTIP_CONFIG.breakpoints.mobile) {
|
|
137
|
+
this.activate();
|
|
138
|
+
} else {
|
|
139
|
+
this.deactivate();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
// Activate mobile interaction handling
|
|
145
|
+
activate() {
|
|
146
|
+
if (this.isActive) return;
|
|
147
|
+
this.isActive = true;
|
|
148
|
+
document.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: true });
|
|
149
|
+
console.debug('Mobile interaction manager activated');
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
// Deactivate mobile interaction handling
|
|
153
|
+
deactivate() {
|
|
154
|
+
if (!this.isActive) return;
|
|
155
|
+
this.isActive = false;
|
|
156
|
+
document.removeEventListener('touchstart', this.handleTouchStart.bind(this));
|
|
157
|
+
console.debug('Mobile interaction manager deactivated');
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
// Handle touch start events
|
|
161
|
+
handleTouchStart(event) {
|
|
162
|
+
// Check if any tooltip is visible
|
|
163
|
+
const hasVisibleTooltips = Array.from(TooltipManager.activeTooltips.values())
|
|
164
|
+
.some(state => state.isVisible);
|
|
165
|
+
|
|
166
|
+
if (!hasVisibleTooltips) return;
|
|
167
|
+
|
|
168
|
+
// Check if touch is outside all tooltips
|
|
169
|
+
const touchedElement = event.target;
|
|
170
|
+
const isInsideTooltip = touchedElement.closest('.tooltip-element');
|
|
171
|
+
const isTooltipTrigger = touchedElement.closest('[data-tooltip-trigger]');
|
|
172
|
+
|
|
173
|
+
if (!isInsideTooltip && !isTooltipTrigger) {
|
|
174
|
+
// Close all tooltips
|
|
175
|
+
TooltipManager.closeAllTooltips();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
109
180
|
/* -----------------------------------------------------------------------------
|
|
110
181
|
Generic tooltip creation and management functions
|
|
111
182
|
----------------------------------------------------------------------------- */
|
|
@@ -115,7 +186,7 @@ const TooltipManager = {
|
|
|
115
186
|
activeTooltips: new Map(), // Track active tooltip states
|
|
116
187
|
|
|
117
188
|
// Add tooltip to any element with HTML content
|
|
118
|
-
addTooltip(element, htmlContent, position = 'auto') {
|
|
189
|
+
addTooltip(element, htmlContent, position = 'auto', options = {}) {
|
|
119
190
|
if (!element || !htmlContent) return;
|
|
120
191
|
|
|
121
192
|
// Check if tooltip already exists for this element
|
|
@@ -128,7 +199,7 @@ const TooltipManager = {
|
|
|
128
199
|
TooltipUtils.getOptimalPosition(element) : position;
|
|
129
200
|
|
|
130
201
|
// Create real DOM tooltip element
|
|
131
|
-
const tooltipElement = this.createTooltipElement(htmlContent, actualPosition);
|
|
202
|
+
const tooltipElement = this.createTooltipElement(htmlContent, actualPosition, options);
|
|
132
203
|
|
|
133
204
|
// Mark the trigger element
|
|
134
205
|
element.setAttribute('data-tooltip-trigger', 'true');
|
|
@@ -147,29 +218,62 @@ const TooltipManager = {
|
|
|
147
218
|
}
|
|
148
219
|
|
|
149
220
|
// Set up enhanced hover behavior
|
|
150
|
-
this.setupAdvancedHover(element, tooltipElement, actualPosition);
|
|
221
|
+
this.setupAdvancedHover(element, tooltipElement, actualPosition, options);
|
|
151
222
|
},
|
|
152
223
|
|
|
153
224
|
// Create a real DOM tooltip element
|
|
154
|
-
createTooltipElement(htmlContent, position) {
|
|
225
|
+
createTooltipElement(htmlContent, position, options = {}) {
|
|
155
226
|
const tooltip = document.createElement('div');
|
|
156
227
|
tooltip.className = `tooltip-element tooltip-${position}`;
|
|
157
228
|
|
|
158
229
|
// Add footnote-specific class if content contains footnote or links
|
|
159
|
-
if (htmlContent.includes('footnote') || htmlContent.includes('<a')) {
|
|
230
|
+
if (htmlContent.includes('footnote') || htmlContent.includes('<a') || options.isFootnote) {
|
|
160
231
|
tooltip.classList.add('footnote-element');
|
|
161
232
|
}
|
|
162
233
|
|
|
163
234
|
tooltip.innerHTML = htmlContent;
|
|
235
|
+
|
|
236
|
+
// Set up footnote navigation button if present
|
|
237
|
+
if (options.isFootnote) {
|
|
238
|
+
this.setupFootnoteNavigation(tooltip, options.footnoteId);
|
|
239
|
+
}
|
|
240
|
+
|
|
164
241
|
return tooltip;
|
|
165
242
|
},
|
|
166
243
|
|
|
244
|
+
// Set up footnote navigation link functionality
|
|
245
|
+
setupFootnoteNavigation(tooltipElement, footnoteId) {
|
|
246
|
+
const navLink = tooltipElement.querySelector('.footnote-nav-container .footnote');
|
|
247
|
+
if (navLink) {
|
|
248
|
+
navLink.addEventListener('click', (e) => {
|
|
249
|
+
e.preventDefault();
|
|
250
|
+
e.stopPropagation();
|
|
251
|
+
|
|
252
|
+
// Navigate to the footnote
|
|
253
|
+
const footnoteElement = document.getElementById(footnoteId);
|
|
254
|
+
if (footnoteElement) {
|
|
255
|
+
footnoteElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
256
|
+
|
|
257
|
+
// Close tooltip after navigation
|
|
258
|
+
setTimeout(() => {
|
|
259
|
+
const tooltipState = Array.from(this.activeTooltips.values())
|
|
260
|
+
.find(state => state.tooltipElement === tooltipElement);
|
|
261
|
+
if (tooltipState) {
|
|
262
|
+
this.hideTooltip(tooltipState);
|
|
263
|
+
}
|
|
264
|
+
}, TOOLTIP_CONFIG.delays.hideLinkClick);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
|
|
167
270
|
// Set up advanced hover behavior with delays and mouse tracking
|
|
168
|
-
setupAdvancedHover(triggerElement, tooltipElement, position) {
|
|
271
|
+
setupAdvancedHover(triggerElement, tooltipElement, position, options = {}) {
|
|
169
272
|
const tooltipState = {
|
|
170
273
|
triggerElement,
|
|
171
274
|
tooltipElement,
|
|
172
275
|
position,
|
|
276
|
+
options,
|
|
173
277
|
showTimeout: null,
|
|
174
278
|
hideTimeout: null,
|
|
175
279
|
isVisible: false
|
|
@@ -181,6 +285,7 @@ const TooltipManager = {
|
|
|
181
285
|
const handlers = {
|
|
182
286
|
triggerEnter: (e) => this.handleTriggerEnter(tooltipState, e),
|
|
183
287
|
triggerLeave: (e) => this.handleTriggerLeave(tooltipState, e),
|
|
288
|
+
triggerTouch: (e) => this.handleTriggerTouch(tooltipState, e),
|
|
184
289
|
tooltipEnter: () => this.handleTooltipEnter(tooltipState),
|
|
185
290
|
tooltipLeave: () => this.handleTooltipLeave(tooltipState),
|
|
186
291
|
tooltipClick: (e) => this.handleTooltipClick(tooltipState, e)
|
|
@@ -189,6 +294,7 @@ const TooltipManager = {
|
|
|
189
294
|
// Add event listeners
|
|
190
295
|
triggerElement.addEventListener('mouseenter', handlers.triggerEnter);
|
|
191
296
|
triggerElement.addEventListener('mouseleave', handlers.triggerLeave);
|
|
297
|
+
triggerElement.addEventListener('touchstart', handlers.triggerTouch, { passive: false });
|
|
192
298
|
tooltipElement.addEventListener('mouseenter', handlers.tooltipEnter);
|
|
193
299
|
tooltipElement.addEventListener('mouseleave', handlers.tooltipLeave);
|
|
194
300
|
tooltipElement.addEventListener('click', handlers.tooltipClick);
|
|
@@ -197,6 +303,7 @@ const TooltipManager = {
|
|
|
197
303
|
tooltipState.cleanupListeners = () => {
|
|
198
304
|
triggerElement.removeEventListener('mouseenter', handlers.triggerEnter);
|
|
199
305
|
triggerElement.removeEventListener('mouseleave', handlers.triggerLeave);
|
|
306
|
+
triggerElement.removeEventListener('touchstart', handlers.triggerTouch);
|
|
200
307
|
tooltipElement.removeEventListener('mouseenter', handlers.tooltipEnter);
|
|
201
308
|
tooltipElement.removeEventListener('mouseleave', handlers.tooltipLeave);
|
|
202
309
|
tooltipElement.removeEventListener('click', handlers.tooltipClick);
|
|
@@ -228,6 +335,26 @@ const TooltipManager = {
|
|
|
228
335
|
}
|
|
229
336
|
},
|
|
230
337
|
|
|
338
|
+
// Handle touch events on trigger elements (mobile)
|
|
339
|
+
handleTriggerTouch(tooltipState, event) {
|
|
340
|
+
// Prevent default to avoid triggering mouse events and navigation
|
|
341
|
+
event.preventDefault();
|
|
342
|
+
event.stopPropagation();
|
|
343
|
+
|
|
344
|
+
// If tooltip is already visible, hide it
|
|
345
|
+
if (tooltipState.isVisible) {
|
|
346
|
+
this.hideTooltip(tooltipState);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Cancel any pending timers
|
|
351
|
+
this.clearTimeout(tooltipState, 'hideTimeout');
|
|
352
|
+
this.clearTimeout(tooltipState, 'showTimeout');
|
|
353
|
+
|
|
354
|
+
// Show tooltip immediately on touch (no delay like hover)
|
|
355
|
+
this.showTooltip(tooltipState, event);
|
|
356
|
+
},
|
|
357
|
+
|
|
231
358
|
// Handle tooltip mouse enter
|
|
232
359
|
handleTooltipEnter(tooltipState) {
|
|
233
360
|
this.clearTimeout(tooltipState, 'hideTimeout');
|
|
@@ -242,14 +369,17 @@ const TooltipManager = {
|
|
|
242
369
|
|
|
243
370
|
// Handle clicks within tooltip
|
|
244
371
|
handleTooltipClick(tooltipState, event) {
|
|
245
|
-
// Allow clicks on links within tooltips
|
|
246
|
-
if (event.target.tagName === 'A'
|
|
372
|
+
// Allow clicks on all links and buttons within tooltips
|
|
373
|
+
if (event.target.tagName === 'A' || event.target.tagName === 'BUTTON' ||
|
|
374
|
+
event.target.closest('a') || event.target.closest('button')) {
|
|
247
375
|
event.stopPropagation();
|
|
248
|
-
// Keep tooltip open briefly after clicking a link
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
376
|
+
// Keep tooltip open briefly after clicking a link (except footnote nav link)
|
|
377
|
+
if (!event.target.closest('.footnote-nav-container')) {
|
|
378
|
+
this.clearTimeout(tooltipState, 'hideTimeout');
|
|
379
|
+
tooltipState.hideTimeout = setTimeout(() => {
|
|
380
|
+
this.hideTooltip(tooltipState);
|
|
381
|
+
}, TOOLTIP_CONFIG.delays.hideLinkClick);
|
|
382
|
+
}
|
|
253
383
|
}
|
|
254
384
|
},
|
|
255
385
|
|
|
@@ -632,8 +762,9 @@ const TooltipManager = {
|
|
|
632
762
|
|
|
633
763
|
if (shouldBeWideRight !== isWideRight) {
|
|
634
764
|
const htmlContent = tooltipState.tooltipElement.innerHTML;
|
|
765
|
+
const options = tooltipState.options;
|
|
635
766
|
this.removeTooltip(element);
|
|
636
|
-
this.addTooltip(element, htmlContent, 'auto');
|
|
767
|
+
this.addTooltip(element, htmlContent, 'auto', options);
|
|
637
768
|
}
|
|
638
769
|
});
|
|
639
770
|
}
|
|
@@ -667,6 +798,18 @@ function initFootnoteTooltips() {
|
|
|
667
798
|
return;
|
|
668
799
|
}
|
|
669
800
|
|
|
801
|
+
// Prevent default action immediately for all footnote reference links
|
|
802
|
+
refLink.addEventListener('click', (e) => {
|
|
803
|
+
e.preventDefault();
|
|
804
|
+
return false;
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
// Also prevent default on touch events for mobile
|
|
808
|
+
refLink.addEventListener('touchend', (e) => {
|
|
809
|
+
e.preventDefault();
|
|
810
|
+
return false;
|
|
811
|
+
});
|
|
812
|
+
|
|
670
813
|
// Extract and validate content
|
|
671
814
|
const footnoteHtml = TooltipUtils.extractHtmlContent(footnoteElement);
|
|
672
815
|
const footnoteText = TooltipUtils.extractTextContent(footnoteElement);
|
|
@@ -677,10 +820,16 @@ function initFootnoteTooltips() {
|
|
|
677
820
|
}
|
|
678
821
|
|
|
679
822
|
// Truncate if needed
|
|
680
|
-
|
|
823
|
+
let displayContent = truncateFootnoteContent(footnoteHtml, footnoteText);
|
|
681
824
|
|
|
682
|
-
// Add
|
|
683
|
-
|
|
825
|
+
// Add footnote navigation button
|
|
826
|
+
displayContent = TooltipUtils.addFootnoteNavigationButton(displayContent, footnoteId);
|
|
827
|
+
|
|
828
|
+
// Add tooltip with footnote options
|
|
829
|
+
TooltipManager.addTooltip(refLink, displayContent, 'auto', {
|
|
830
|
+
isFootnote: true,
|
|
831
|
+
footnoteId: footnoteId
|
|
832
|
+
});
|
|
684
833
|
tooltipsAdded++;
|
|
685
834
|
|
|
686
835
|
console.debug(`Added tooltip for footnote ${footnoteId}: "${footnoteText.substring(0, 50)}..."`);
|
|
@@ -715,6 +864,9 @@ function initTooltips() {
|
|
|
715
864
|
console.debug('Starting tooltip initialization...');
|
|
716
865
|
|
|
717
866
|
try {
|
|
867
|
+
// Initialize mobile interaction manager
|
|
868
|
+
MobileInteractionManager.init();
|
|
869
|
+
|
|
718
870
|
const footnoteCount = initFootnoteTooltips();
|
|
719
871
|
console.debug(`Tooltip initialization complete. Total footnote tooltips: ${footnoteCount}`);
|
|
720
872
|
} catch (error) {
|