@customviews-js/customviews 1.2.0 → 1.4.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/dist/custom-views.core.cjs.js +1409 -55
- package/dist/custom-views.core.cjs.js.map +1 -1
- package/dist/custom-views.core.esm.js +1409 -55
- package/dist/custom-views.core.esm.js.map +1 -1
- package/dist/custom-views.esm.js +1409 -55
- package/dist/custom-views.esm.js.map +1 -1
- package/dist/custom-views.js +1409 -55
- package/dist/custom-views.js.map +1 -1
- package/dist/custom-views.min.js +2 -2
- package/dist/custom-views.min.js.map +1 -1
- package/dist/types/core/anchor-engine.d.ts +55 -0
- package/dist/types/core/anchor-engine.d.ts.map +1 -0
- package/dist/types/core/config.d.ts +3 -0
- package/dist/types/core/config.d.ts.map +1 -0
- package/dist/types/core/core.d.ts +9 -1
- package/dist/types/core/core.d.ts.map +1 -1
- package/dist/types/core/custom-elements.d.ts +2 -2
- package/dist/types/core/custom-elements.d.ts.map +1 -1
- package/dist/types/core/focus-manager.d.ts +38 -0
- package/dist/types/core/focus-manager.d.ts.map +1 -0
- package/dist/types/core/share-manager.d.ts +70 -0
- package/dist/types/core/share-manager.d.ts.map +1 -0
- package/dist/types/core/toast-manager.d.ts +12 -0
- package/dist/types/core/toast-manager.d.ts.map +1 -0
- package/dist/types/core/url-state-manager.d.ts +6 -2
- package/dist/types/core/url-state-manager.d.ts.map +1 -1
- package/dist/types/core/widget.d.ts +1 -0
- package/dist/types/core/widget.d.ts.map +1 -1
- package/dist/types/styles/focus-mode-styles.d.ts +8 -0
- package/dist/types/styles/focus-mode-styles.d.ts.map +1 -0
- package/dist/types/styles/share-mode-styles.d.ts +10 -0
- package/dist/types/styles/share-mode-styles.d.ts.map +1 -0
- package/dist/types/styles/toast-styles.d.ts +4 -0
- package/dist/types/styles/toast-styles.d.ts.map +1 -0
- package/dist/types/styles/widget-styles.d.ts +1 -1
- package/dist/types/styles/widget-styles.d.ts.map +1 -1
- package/dist/types/types/types.d.ts +7 -0
- package/dist/types/types/types.d.ts.map +1 -1
- package/dist/types/utils/icons.d.ts +6 -0
- package/dist/types/utils/icons.d.ts.map +1 -1
- package/package.json +2 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* @customviews-js/customviews v1.
|
|
2
|
+
* @customviews-js/customviews v1.4.0
|
|
3
3
|
* (c) 2025 Chan Ger Teck
|
|
4
4
|
* Released under the MIT License.
|
|
5
5
|
*/
|
|
@@ -156,7 +156,9 @@ class URLStateManager {
|
|
|
156
156
|
return url.toString();
|
|
157
157
|
}
|
|
158
158
|
/**
|
|
159
|
-
* Encode state into URL-safe string
|
|
159
|
+
* Encode state into URL-safe string
|
|
160
|
+
*
|
|
161
|
+
* (Covers Toggles, Tabs and Focus currently)
|
|
160
162
|
*/
|
|
161
163
|
static encodeState(state) {
|
|
162
164
|
try {
|
|
@@ -170,6 +172,10 @@ class URLStateManager {
|
|
|
170
172
|
if (state.tabs && Object.keys(state.tabs).length > 0) {
|
|
171
173
|
compact.g = Object.entries(state.tabs);
|
|
172
174
|
}
|
|
175
|
+
// Add focus if present
|
|
176
|
+
if (state.focus && state.focus.length > 0) {
|
|
177
|
+
compact.f = state.focus;
|
|
178
|
+
}
|
|
173
179
|
// Convert to JSON and encode
|
|
174
180
|
const json = JSON.stringify(compact);
|
|
175
181
|
let encoded;
|
|
@@ -191,7 +197,9 @@ class URLStateManager {
|
|
|
191
197
|
}
|
|
192
198
|
}
|
|
193
199
|
/**
|
|
194
|
-
* Decode custom state from URL parameter
|
|
200
|
+
* Decode custom state from URL parameter
|
|
201
|
+
*
|
|
202
|
+
* (Covers Toggles, Tabs and Focus currently)
|
|
195
203
|
*/
|
|
196
204
|
static decodeState(encoded) {
|
|
197
205
|
try {
|
|
@@ -230,6 +238,10 @@ class URLStateManager {
|
|
|
230
238
|
}
|
|
231
239
|
}
|
|
232
240
|
}
|
|
241
|
+
// Reconstruct Focus
|
|
242
|
+
if (Array.isArray(compact.f)) {
|
|
243
|
+
state.focus = compact.f;
|
|
244
|
+
}
|
|
233
245
|
return state;
|
|
234
246
|
}
|
|
235
247
|
catch (error) {
|
|
@@ -414,6 +426,20 @@ function getPinIcon(isPinned = false) {
|
|
|
414
426
|
</svg>
|
|
415
427
|
`.trim();
|
|
416
428
|
}
|
|
429
|
+
function getShareIcon() {
|
|
430
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
|
431
|
+
<path fill="currentColor" d="M18 8h-2a1 1 0 0 0 0 2h2v8H6v-8h2a1 1 0 0 0 0-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8a2 2 0 0 0-2-2z"/>
|
|
432
|
+
<path fill="currentColor" d="M11 6.41V12a1 1 0 0 0 2 0V6.41l1.29 1.3a1 1 0 0 0 1.42 0a1 1 0 0 0 0-1.42l-3-3a1 1 0 0 0-1.42 0l-3 3a1 1 0 1 0 1.42 1.42L11 6.41z"/>
|
|
433
|
+
</svg>`;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* GitHub icon for footer link
|
|
437
|
+
*/
|
|
438
|
+
function getGitHubIcon() {
|
|
439
|
+
return `<svg viewBox="0 0 98 96" width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
|
440
|
+
<path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"/>
|
|
441
|
+
</svg>`;
|
|
442
|
+
}
|
|
417
443
|
|
|
418
444
|
// Constants for selectors
|
|
419
445
|
const TABGROUP_SELECTOR$1 = 'cv-tabgroup';
|
|
@@ -1378,6 +1404,1077 @@ function injectCoreStyles() {
|
|
|
1378
1404
|
document.head.appendChild(style);
|
|
1379
1405
|
}
|
|
1380
1406
|
|
|
1407
|
+
const TOAST_STYLE_ID = 'cv-toast-styles';
|
|
1408
|
+
const TOAST_CLASS = 'cv-toast-notification';
|
|
1409
|
+
const TOAST_STYLES = `
|
|
1410
|
+
.cv-toast-notification {
|
|
1411
|
+
position: fixed;
|
|
1412
|
+
top: 20px;
|
|
1413
|
+
left: 50%;
|
|
1414
|
+
transform: translateX(-50%);
|
|
1415
|
+
background-color: #323232;
|
|
1416
|
+
color: white;
|
|
1417
|
+
padding: 12px 24px;
|
|
1418
|
+
border-radius: 4px;
|
|
1419
|
+
z-index: 100000;
|
|
1420
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
1421
|
+
opacity: 0;
|
|
1422
|
+
transition: opacity 0.3s ease;
|
|
1423
|
+
pointer-events: none; /* Let clicks pass through if needed, though usually it blocks */
|
|
1424
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
1425
|
+
font-size: 14px;
|
|
1426
|
+
}
|
|
1427
|
+
`;
|
|
1428
|
+
|
|
1429
|
+
/**
|
|
1430
|
+
* Manages toast notifications for the application.
|
|
1431
|
+
*/
|
|
1432
|
+
class ToastManager {
|
|
1433
|
+
static isStyleInjected = false;
|
|
1434
|
+
static toastEl = null;
|
|
1435
|
+
static navTimeout = null;
|
|
1436
|
+
static fadeTimeout = null;
|
|
1437
|
+
static show(message, duration = 2500) {
|
|
1438
|
+
this.injectStyles();
|
|
1439
|
+
// specific reuse logic
|
|
1440
|
+
if (!this.toastEl) {
|
|
1441
|
+
this.toastEl = document.createElement('div');
|
|
1442
|
+
this.toastEl.className = TOAST_CLASS;
|
|
1443
|
+
document.body.appendChild(this.toastEl);
|
|
1444
|
+
}
|
|
1445
|
+
// Reset state
|
|
1446
|
+
this.toastEl.textContent = message;
|
|
1447
|
+
this.toastEl.style.opacity = '0';
|
|
1448
|
+
this.toastEl.style.display = 'block';
|
|
1449
|
+
// Clear any pending dismissal
|
|
1450
|
+
if (this.navTimeout)
|
|
1451
|
+
clearTimeout(this.navTimeout);
|
|
1452
|
+
if (this.fadeTimeout)
|
|
1453
|
+
clearTimeout(this.fadeTimeout);
|
|
1454
|
+
// Trigger reflow & fade in
|
|
1455
|
+
requestAnimationFrame(() => {
|
|
1456
|
+
if (this.toastEl)
|
|
1457
|
+
this.toastEl.style.opacity = '1';
|
|
1458
|
+
});
|
|
1459
|
+
// Schedule fade out
|
|
1460
|
+
this.navTimeout = setTimeout(() => {
|
|
1461
|
+
if (this.toastEl)
|
|
1462
|
+
this.toastEl.style.opacity = '0';
|
|
1463
|
+
this.fadeTimeout = setTimeout(() => {
|
|
1464
|
+
if (this.toastEl)
|
|
1465
|
+
this.toastEl.style.display = 'none';
|
|
1466
|
+
}, 300);
|
|
1467
|
+
}, duration);
|
|
1468
|
+
}
|
|
1469
|
+
static injectStyles() {
|
|
1470
|
+
if (this.isStyleInjected)
|
|
1471
|
+
return;
|
|
1472
|
+
if (document.getElementById(TOAST_STYLE_ID)) {
|
|
1473
|
+
this.isStyleInjected = true;
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
const style = document.createElement('style');
|
|
1477
|
+
style.id = TOAST_STYLE_ID;
|
|
1478
|
+
style.innerHTML = TOAST_STYLES;
|
|
1479
|
+
document.head.appendChild(style);
|
|
1480
|
+
this.isStyleInjected = true;
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/**
|
|
1485
|
+
* Engine for generating and resolving robust anchors.
|
|
1486
|
+
*
|
|
1487
|
+
* It implements a simple anchor generation and resolution algorithm that uses a combination of
|
|
1488
|
+
* structural, contextual, and content-based hints to generate a unique anchor for a given DOM element.
|
|
1489
|
+
*
|
|
1490
|
+
* The anchor is generated by first creating an AnchorDescriptor for the element, which contains
|
|
1491
|
+
* information about the element's tag, index, parent ID, and text content. This descriptor is then
|
|
1492
|
+
* serialized into a URL-safe string using a minification algorithm.
|
|
1493
|
+
*
|
|
1494
|
+
* The anchor is then resolved by searching for the element in the DOM using the serialized string.
|
|
1495
|
+
*
|
|
1496
|
+
*/
|
|
1497
|
+
class AnchorEngine {
|
|
1498
|
+
/**
|
|
1499
|
+
* Generates a simple hash code for a string.
|
|
1500
|
+
*
|
|
1501
|
+
* It takes each character's Unicode code point and uses it to update the hash value.
|
|
1502
|
+
*/
|
|
1503
|
+
static hashCode(str) {
|
|
1504
|
+
let hash = 0;
|
|
1505
|
+
if (str.length === 0)
|
|
1506
|
+
return hash;
|
|
1507
|
+
for (let i = 0; i < str.length; i++) {
|
|
1508
|
+
const char = str.charCodeAt(i);
|
|
1509
|
+
hash = ((hash << 5) - hash) + char;
|
|
1510
|
+
hash = hash & hash; // Convert to 32bit integer
|
|
1511
|
+
}
|
|
1512
|
+
return hash;
|
|
1513
|
+
}
|
|
1514
|
+
/**
|
|
1515
|
+
* Normalizes text content by removing excessive whitespace.
|
|
1516
|
+
*
|
|
1517
|
+
* It trims leading and trailing whitespace and replaces multiple spaces with a single space.
|
|
1518
|
+
*/
|
|
1519
|
+
static normalizeText(text) {
|
|
1520
|
+
return text.trim().replace(/\s+/g, ' ');
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* Creates an AnchorDescriptor for a given DOM element.
|
|
1524
|
+
*/
|
|
1525
|
+
static createDescriptor(el) {
|
|
1526
|
+
const tag = el.tagName;
|
|
1527
|
+
const textContent = el.textContent || "";
|
|
1528
|
+
const normalizedText = this.normalizeText(textContent);
|
|
1529
|
+
// Find nearest parent with an ID
|
|
1530
|
+
let parentId;
|
|
1531
|
+
let parent = el.parentElement;
|
|
1532
|
+
while (parent) {
|
|
1533
|
+
if (parent.id) {
|
|
1534
|
+
parentId = parent.id;
|
|
1535
|
+
break;
|
|
1536
|
+
}
|
|
1537
|
+
parent = parent.parentElement;
|
|
1538
|
+
}
|
|
1539
|
+
// Calculate index relative to the container (either the found parent or document.body)
|
|
1540
|
+
const container = parent || document.body;
|
|
1541
|
+
const siblings = Array.from(container.querySelectorAll(tag));
|
|
1542
|
+
// Index is the position of the element in the list of siblings, where siblings are those of the same tag.
|
|
1543
|
+
const index = siblings.indexOf(el);
|
|
1544
|
+
const descriptor = {
|
|
1545
|
+
tag,
|
|
1546
|
+
index: index !== -1 ? index : 0,
|
|
1547
|
+
textSnippet: normalizedText.substring(0, 32),
|
|
1548
|
+
textHash: this.hashCode(normalizedText)
|
|
1549
|
+
};
|
|
1550
|
+
if (parentId) {
|
|
1551
|
+
descriptor.parentId = parentId;
|
|
1552
|
+
}
|
|
1553
|
+
return descriptor;
|
|
1554
|
+
}
|
|
1555
|
+
/**
|
|
1556
|
+
* Serializes a list of AnchorDescriptors into a URL-safe string.
|
|
1557
|
+
*/
|
|
1558
|
+
static serialize(descriptors) {
|
|
1559
|
+
// Minify keys for compactness
|
|
1560
|
+
const minified = descriptors.map(d => ({
|
|
1561
|
+
t: d.tag,
|
|
1562
|
+
i: d.index,
|
|
1563
|
+
p: d.parentId,
|
|
1564
|
+
s: d.textSnippet,
|
|
1565
|
+
h: d.textHash
|
|
1566
|
+
}));
|
|
1567
|
+
const json = JSON.stringify(minified);
|
|
1568
|
+
// Base64 encode
|
|
1569
|
+
return btoa(encodeURIComponent(json));
|
|
1570
|
+
}
|
|
1571
|
+
/**
|
|
1572
|
+
* Deserializes a URL-safe string back into a list of AnchorDescriptors.
|
|
1573
|
+
*/
|
|
1574
|
+
static deserialize(encoded) {
|
|
1575
|
+
try {
|
|
1576
|
+
const json = decodeURIComponent(atob(encoded));
|
|
1577
|
+
const minified = JSON.parse(json);
|
|
1578
|
+
return minified.map((m) => ({
|
|
1579
|
+
tag: m.t,
|
|
1580
|
+
index: m.i,
|
|
1581
|
+
parentId: m.p,
|
|
1582
|
+
textSnippet: m.s,
|
|
1583
|
+
textHash: m.h
|
|
1584
|
+
}));
|
|
1585
|
+
}
|
|
1586
|
+
catch (e) {
|
|
1587
|
+
console.error("Failed to deserialize anchor:", e);
|
|
1588
|
+
return [];
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
/**
|
|
1592
|
+
* Finds the best DOM element match for a descriptor.
|
|
1593
|
+
*/
|
|
1594
|
+
static resolve(root, descriptor) {
|
|
1595
|
+
// 1. Scope
|
|
1596
|
+
let scope = root;
|
|
1597
|
+
if (descriptor.parentId) {
|
|
1598
|
+
const foundParent = root.querySelector(`#${descriptor.parentId}`);
|
|
1599
|
+
if (foundParent instanceof HTMLElement) {
|
|
1600
|
+
scope = foundParent;
|
|
1601
|
+
}
|
|
1602
|
+
else {
|
|
1603
|
+
// Fallback: if parent ID not found, search global root - document.body
|
|
1604
|
+
const globalParent = document.getElementById(descriptor.parentId);
|
|
1605
|
+
if (globalParent) {
|
|
1606
|
+
scope = globalParent;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
// 2. Candidate Search
|
|
1611
|
+
const candidates = Array.from(scope.querySelectorAll(descriptor.tag));
|
|
1612
|
+
// 3. Scoring
|
|
1613
|
+
let bestMatch = null;
|
|
1614
|
+
let highestScore = 0;
|
|
1615
|
+
candidates.forEach((candidate) => {
|
|
1616
|
+
let score = 0;
|
|
1617
|
+
const text = this.normalizeText(candidate.textContent || "");
|
|
1618
|
+
// Exact Text Match (Hash check is faster proxy for full string compare, but let's check hash first)
|
|
1619
|
+
if (this.hashCode(text) === descriptor.textHash) {
|
|
1620
|
+
score += 50;
|
|
1621
|
+
}
|
|
1622
|
+
else if (text.startsWith(descriptor.textSnippet)) {
|
|
1623
|
+
// Fuzzy Text Match (Snippet) - +30 score
|
|
1624
|
+
score += 30;
|
|
1625
|
+
}
|
|
1626
|
+
// Structural Match (Index)
|
|
1627
|
+
// We need to re-calculate index of this candidate to compare with descriptor.index
|
|
1628
|
+
// The descriptor.index is relative to the *found* parentId container.
|
|
1629
|
+
// So we must compare index within the scope we are searching.
|
|
1630
|
+
const siblings = Array.from(scope.querySelectorAll(descriptor.tag));
|
|
1631
|
+
const index = siblings.indexOf(candidate);
|
|
1632
|
+
if (index === descriptor.index) {
|
|
1633
|
+
score += 10;
|
|
1634
|
+
}
|
|
1635
|
+
if (score > highestScore) {
|
|
1636
|
+
highestScore = score;
|
|
1637
|
+
bestMatch = candidate;
|
|
1638
|
+
}
|
|
1639
|
+
});
|
|
1640
|
+
// 4. Winner
|
|
1641
|
+
if (highestScore > 30) {
|
|
1642
|
+
return bestMatch;
|
|
1643
|
+
}
|
|
1644
|
+
return null;
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
const SHARE_MODE_STYLE_ID = 'cv-share-mode-styles';
|
|
1649
|
+
const FLOATING_ACTION_BAR_ID = 'cv-floating-action-bar';
|
|
1650
|
+
const HOVER_HELPER_ID = 'cv-hover-helper';
|
|
1651
|
+
const HIGHLIGHT_TARGET_CLASS = 'cv-highlight-target';
|
|
1652
|
+
const SELECTED_CLASS = 'cv-share-selected';
|
|
1653
|
+
/**
|
|
1654
|
+
* CSS styles to be injected during Share Mode.
|
|
1655
|
+
*/
|
|
1656
|
+
const SHARE_MODE_STYLES = `
|
|
1657
|
+
body.cv-share-mode {
|
|
1658
|
+
cursor: default;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
/* Highlight outlines */
|
|
1662
|
+
.${HIGHLIGHT_TARGET_CLASS} {
|
|
1663
|
+
outline: 2px dashed #0078D4 !important;
|
|
1664
|
+
outline-offset: 2px;
|
|
1665
|
+
cursor: crosshair;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
.${SELECTED_CLASS} {
|
|
1669
|
+
outline: 3px solid #005a9e !important;
|
|
1670
|
+
outline-offset: 2px;
|
|
1671
|
+
background-color: rgba(0, 120, 212, 0.05);
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
/* Floating Action Bar */
|
|
1675
|
+
#${FLOATING_ACTION_BAR_ID} {
|
|
1676
|
+
position: fixed;
|
|
1677
|
+
bottom: 20px;
|
|
1678
|
+
left: 50%;
|
|
1679
|
+
transform: translateX(-50%);
|
|
1680
|
+
background-color: #2c2c2c;
|
|
1681
|
+
color: #f1f1f1;
|
|
1682
|
+
border-radius: 8px;
|
|
1683
|
+
padding: 12px 20px;
|
|
1684
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
1685
|
+
display: flex;
|
|
1686
|
+
align-items: center;
|
|
1687
|
+
gap: 16px;
|
|
1688
|
+
z-index: 99999;
|
|
1689
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
1690
|
+
font-size: 14px;
|
|
1691
|
+
border: 1px solid #4a4a4a;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
#${FLOATING_ACTION_BAR_ID} .cv-action-button {
|
|
1695
|
+
background-color: #0078D4;
|
|
1696
|
+
color: white;
|
|
1697
|
+
border: none;
|
|
1698
|
+
padding: 8px 14px;
|
|
1699
|
+
border-radius: 5px;
|
|
1700
|
+
cursor: pointer;
|
|
1701
|
+
font-weight: 500;
|
|
1702
|
+
transition: background-color 0.2s;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
#${FLOATING_ACTION_BAR_ID} .cv-action-button:hover {
|
|
1706
|
+
background-color: #005a9e;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
#${FLOATING_ACTION_BAR_ID} .cv-action-button.clear {
|
|
1710
|
+
background-color: #5a5a5a;
|
|
1711
|
+
}
|
|
1712
|
+
#${FLOATING_ACTION_BAR_ID} .cv-action-button.clear:hover {
|
|
1713
|
+
background-color: #4a4a4a;
|
|
1714
|
+
}
|
|
1715
|
+
#${FLOATING_ACTION_BAR_ID} .cv-action-button.clear:hover {
|
|
1716
|
+
background-color: #4a4a4a;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
#${FLOATING_ACTION_BAR_ID} .cv-action-button.preview {
|
|
1720
|
+
background-color: #106ebe;
|
|
1721
|
+
}
|
|
1722
|
+
#${FLOATING_ACTION_BAR_ID} .cv-action-button.preview:hover {
|
|
1723
|
+
background-color: #005a9e;
|
|
1724
|
+
}
|
|
1725
|
+
#${FLOATING_ACTION_BAR_ID} .cv-action-button.exit {
|
|
1726
|
+
background-color: #d13438;
|
|
1727
|
+
}
|
|
1728
|
+
#${FLOATING_ACTION_BAR_ID} .cv-action-button.exit:hover {
|
|
1729
|
+
background-color: #a42628;
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
/* Hover Helper (Smart Label & Level Up) */
|
|
1733
|
+
#${HOVER_HELPER_ID} {
|
|
1734
|
+
position: fixed;
|
|
1735
|
+
z-index: 99999;
|
|
1736
|
+
background-color: #333;
|
|
1737
|
+
color: white;
|
|
1738
|
+
padding: 4px 8px;
|
|
1739
|
+
border-radius: 4px;
|
|
1740
|
+
font-size: 12px;
|
|
1741
|
+
font-family: monospace;
|
|
1742
|
+
display: none;
|
|
1743
|
+
pointer-events: auto; /* Allow clicking buttons inside */
|
|
1744
|
+
align-items: center;
|
|
1745
|
+
gap: 8px;
|
|
1746
|
+
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
#${HOVER_HELPER_ID} button {
|
|
1750
|
+
background: #555;
|
|
1751
|
+
border: none;
|
|
1752
|
+
color: white;
|
|
1753
|
+
border-radius: 3px;
|
|
1754
|
+
cursor: pointer;
|
|
1755
|
+
padding: 2px 6px;
|
|
1756
|
+
font-size: 14px;
|
|
1757
|
+
line-height: 1;
|
|
1758
|
+
}
|
|
1759
|
+
#${HOVER_HELPER_ID} button:hover {
|
|
1760
|
+
background: #777;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
`;
|
|
1764
|
+
|
|
1765
|
+
const CV_CUSTOM_ELEMENTS = 'cv-tabgroup, cv-toggle';
|
|
1766
|
+
const SHAREABLE_SELECTOR = 'div, p, blockquote, pre, li, h1, h2, h3, h4, h5, h6, [data-share], ' + CV_CUSTOM_ELEMENTS;
|
|
1767
|
+
/**
|
|
1768
|
+
* Manages the "Share Mode" for creating custom focus links.
|
|
1769
|
+
* Implementing Robust Granular Sharing with "Innermost Wins" and "Level Up" UI.
|
|
1770
|
+
*/
|
|
1771
|
+
class ShareManager {
|
|
1772
|
+
isActive = false;
|
|
1773
|
+
selectedElements = new Set();
|
|
1774
|
+
floatingBarEl = null;
|
|
1775
|
+
helperEl = null;
|
|
1776
|
+
currentHoverTarget = null;
|
|
1777
|
+
excludedTags;
|
|
1778
|
+
excludedIds;
|
|
1779
|
+
boundHandleHover;
|
|
1780
|
+
boundHandleClick;
|
|
1781
|
+
boundHandleKeydown;
|
|
1782
|
+
constructor(options) {
|
|
1783
|
+
this.excludedTags = new Set(options.excludedTags.map(t => t.toUpperCase()));
|
|
1784
|
+
this.excludedIds = new Set(options.excludedIds);
|
|
1785
|
+
this.boundHandleHover = this.handleHover.bind(this);
|
|
1786
|
+
this.boundHandleClick = this.handleClick.bind(this);
|
|
1787
|
+
this.boundHandleKeydown = this.handleKeydown.bind(this);
|
|
1788
|
+
}
|
|
1789
|
+
listeners = [];
|
|
1790
|
+
addStateChangeListener(listener) {
|
|
1791
|
+
this.listeners.push(listener);
|
|
1792
|
+
}
|
|
1793
|
+
removeStateChangeListener(listener) {
|
|
1794
|
+
this.listeners = this.listeners.filter(l => l !== listener);
|
|
1795
|
+
}
|
|
1796
|
+
notifyListeners() {
|
|
1797
|
+
this.listeners.forEach(listener => listener(this.isActive));
|
|
1798
|
+
}
|
|
1799
|
+
toggleShareMode() {
|
|
1800
|
+
this.isActive = !this.isActive;
|
|
1801
|
+
if (this.isActive) {
|
|
1802
|
+
this.activate();
|
|
1803
|
+
}
|
|
1804
|
+
else {
|
|
1805
|
+
this.cleanup();
|
|
1806
|
+
}
|
|
1807
|
+
this.notifyListeners();
|
|
1808
|
+
}
|
|
1809
|
+
/**
|
|
1810
|
+
* Activates the share mode.
|
|
1811
|
+
* Injects styles, creates floating bar, and helper element.
|
|
1812
|
+
* Adds event listeners for hover and click.
|
|
1813
|
+
*/
|
|
1814
|
+
activate() {
|
|
1815
|
+
this.injectStyles();
|
|
1816
|
+
this.createFloatingBar();
|
|
1817
|
+
this.helperEl = this.createHelperPopover();
|
|
1818
|
+
// Event Listeners
|
|
1819
|
+
document.addEventListener('mouseover', this.boundHandleHover, true);
|
|
1820
|
+
document.addEventListener('click', this.boundHandleClick, true);
|
|
1821
|
+
document.addEventListener('keydown', this.boundHandleKeydown, true);
|
|
1822
|
+
}
|
|
1823
|
+
injectStyles() {
|
|
1824
|
+
const styleElement = document.createElement('style');
|
|
1825
|
+
styleElement.id = SHARE_MODE_STYLE_ID;
|
|
1826
|
+
styleElement.innerHTML = SHARE_MODE_STYLES;
|
|
1827
|
+
document.head.appendChild(styleElement);
|
|
1828
|
+
}
|
|
1829
|
+
/**
|
|
1830
|
+
* Creates the hover helper element that shows up when hovering over a shareable element.
|
|
1831
|
+
*/
|
|
1832
|
+
createHelperPopover() {
|
|
1833
|
+
const div = document.createElement('div');
|
|
1834
|
+
div.id = HOVER_HELPER_ID;
|
|
1835
|
+
div.innerHTML = `
|
|
1836
|
+
<span id="cv-helper-tag">TAG</span>
|
|
1837
|
+
<button id="cv-helper-select-btn" title="Select This Element">✓</button>
|
|
1838
|
+
<button id="cv-helper-up-btn" title="Select Parent">↰</button>
|
|
1839
|
+
`;
|
|
1840
|
+
document.body.appendChild(div);
|
|
1841
|
+
// Select parent button
|
|
1842
|
+
div.querySelector('#cv-helper-up-btn')?.addEventListener('click', (e) => {
|
|
1843
|
+
e.preventDefault();
|
|
1844
|
+
e.stopPropagation();
|
|
1845
|
+
this.handleSelectParent();
|
|
1846
|
+
});
|
|
1847
|
+
// Select element button
|
|
1848
|
+
div.querySelector('#cv-helper-select-btn')?.addEventListener('click', (e) => {
|
|
1849
|
+
e.preventDefault();
|
|
1850
|
+
e.stopPropagation();
|
|
1851
|
+
if (this.currentHoverTarget) {
|
|
1852
|
+
this.toggleSelection(this.currentHoverTarget);
|
|
1853
|
+
}
|
|
1854
|
+
});
|
|
1855
|
+
return div;
|
|
1856
|
+
}
|
|
1857
|
+
/**
|
|
1858
|
+
* Handles mouse hover events.
|
|
1859
|
+
*
|
|
1860
|
+
* This function is called when the user hovers over an element.
|
|
1861
|
+
* It checks if the element is shareable and highlights it.
|
|
1862
|
+
* If a parent element is already selected, it highlights the parent instead,
|
|
1863
|
+
* allowing the helper to remain visible for the selected parent.
|
|
1864
|
+
*
|
|
1865
|
+
* @param e The mouse event triggered by the hover.
|
|
1866
|
+
*/
|
|
1867
|
+
handleHover(e) {
|
|
1868
|
+
if (!this.isActive)
|
|
1869
|
+
return;
|
|
1870
|
+
// Check if we are hovering over the helper itself
|
|
1871
|
+
if (this.helperEl && this.helperEl.contains(e.target)) {
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1874
|
+
const target = e.target;
|
|
1875
|
+
// Exclude by Tag or ID
|
|
1876
|
+
const upperTag = target.tagName.toUpperCase();
|
|
1877
|
+
if (this.excludedTags.has(upperTag) || (target.id && this.excludedIds.has(target.id))) {
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
// Check closest excluded (for nested elements in excluded regions)
|
|
1881
|
+
let ancestor = target.parentElement;
|
|
1882
|
+
while (ancestor) {
|
|
1883
|
+
if (this.excludedTags.has(ancestor.tagName.toUpperCase()) || (ancestor.id && this.excludedIds.has(ancestor.id))) {
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
ancestor = ancestor.parentElement;
|
|
1887
|
+
}
|
|
1888
|
+
// Find closest shareable parent element
|
|
1889
|
+
const shareablePart = target.closest(SHAREABLE_SELECTOR);
|
|
1890
|
+
if (!shareablePart) {
|
|
1891
|
+
this.clearHover();
|
|
1892
|
+
return;
|
|
1893
|
+
}
|
|
1894
|
+
// Cast to HTMLElement
|
|
1895
|
+
const finalTarget = shareablePart;
|
|
1896
|
+
// Check if any ancestor is already selected. If so, do NOT highlight this child.
|
|
1897
|
+
// Instead, highlight (or keep highlighted) the SELECTED PARENT so the user can see the helper for it.
|
|
1898
|
+
let parent = finalTarget.parentElement;
|
|
1899
|
+
let selectedAncestor = null;
|
|
1900
|
+
// Loop outwards until we find a selected parent or reach the top
|
|
1901
|
+
while (parent) {
|
|
1902
|
+
if (this.selectedElements.has(parent)) {
|
|
1903
|
+
selectedAncestor = parent;
|
|
1904
|
+
break;
|
|
1905
|
+
}
|
|
1906
|
+
parent = parent.parentElement;
|
|
1907
|
+
}
|
|
1908
|
+
if (selectedAncestor) {
|
|
1909
|
+
// If we are hovering deep inside a selected block, show the helper for that block
|
|
1910
|
+
this.setNewHoverTarget(selectedAncestor);
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
// stop bubbling to parent
|
|
1914
|
+
// when element found for highlight
|
|
1915
|
+
e.stopPropagation();
|
|
1916
|
+
// If we are already on this target, do nothing (and keep it selected/highlighted)
|
|
1917
|
+
if (this.currentHoverTarget === finalTarget)
|
|
1918
|
+
return;
|
|
1919
|
+
// Highlight
|
|
1920
|
+
this.setNewHoverTarget(finalTarget);
|
|
1921
|
+
}
|
|
1922
|
+
setNewHoverTarget(target) {
|
|
1923
|
+
if (this.currentHoverTarget) {
|
|
1924
|
+
this.currentHoverTarget.classList.remove(HIGHLIGHT_TARGET_CLASS);
|
|
1925
|
+
}
|
|
1926
|
+
this.currentHoverTarget = target;
|
|
1927
|
+
this.currentHoverTarget.classList.add(HIGHLIGHT_TARGET_CLASS);
|
|
1928
|
+
this.positionHelper(target);
|
|
1929
|
+
}
|
|
1930
|
+
positionHelper(target) {
|
|
1931
|
+
if (!this.helperEl)
|
|
1932
|
+
return;
|
|
1933
|
+
const rect = target.getBoundingClientRect();
|
|
1934
|
+
const tagLabel = this.helperEl.querySelector('#cv-helper-tag');
|
|
1935
|
+
const upBtn = this.helperEl.querySelector('#cv-helper-up-btn');
|
|
1936
|
+
if (tagLabel)
|
|
1937
|
+
tagLabel.textContent = target.tagName;
|
|
1938
|
+
// Position at top-right of the element
|
|
1939
|
+
// Prevent going off-screen
|
|
1940
|
+
let top = rect.top - 20;
|
|
1941
|
+
if (top < 0)
|
|
1942
|
+
top = rect.top + 10; // Flip down if too close to top
|
|
1943
|
+
let left = rect.right - 80;
|
|
1944
|
+
if (left < 0)
|
|
1945
|
+
left = 10;
|
|
1946
|
+
this.helperEl.style.display = 'flex';
|
|
1947
|
+
this.helperEl.style.top = `${top}px`;
|
|
1948
|
+
this.helperEl.style.left = `${left}px`;
|
|
1949
|
+
// Update Select Button State (Tick or Cross)
|
|
1950
|
+
const selectBtn = this.helperEl.querySelector('#cv-helper-select-btn');
|
|
1951
|
+
if (selectBtn) {
|
|
1952
|
+
if (this.selectedElements.has(target)) {
|
|
1953
|
+
selectBtn.textContent = '✕';
|
|
1954
|
+
selectBtn.title = 'Deselect This Element';
|
|
1955
|
+
selectBtn.style.backgroundColor = '#d13438'; // Reddish
|
|
1956
|
+
}
|
|
1957
|
+
else {
|
|
1958
|
+
selectBtn.textContent = '✓';
|
|
1959
|
+
selectBtn.title = 'Select This Element';
|
|
1960
|
+
selectBtn.style.backgroundColor = ''; // Reset
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
// Ancestry Check
|
|
1964
|
+
const parent = target.parentElement;
|
|
1965
|
+
const parentIsShareable = parent && parent.matches(SHAREABLE_SELECTOR);
|
|
1966
|
+
if (parentIsShareable) {
|
|
1967
|
+
upBtn.style.display = 'inline-block';
|
|
1968
|
+
}
|
|
1969
|
+
else {
|
|
1970
|
+
upBtn.style.display = 'none';
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
handleSelectParent() {
|
|
1974
|
+
if (this.currentHoverTarget && this.currentHoverTarget.parentElement) {
|
|
1975
|
+
const parent = this.currentHoverTarget.parentElement;
|
|
1976
|
+
if (parent.matches(SHAREABLE_SELECTOR)) {
|
|
1977
|
+
this.setNewHoverTarget(parent);
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
handleClick(e) {
|
|
1982
|
+
if (!this.isActive)
|
|
1983
|
+
return;
|
|
1984
|
+
// If clicking helper
|
|
1985
|
+
if (this.helperEl && this.helperEl.contains(e.target))
|
|
1986
|
+
return;
|
|
1987
|
+
// If clicking floating bar
|
|
1988
|
+
if (this.floatingBarEl && this.floatingBarEl.contains(e.target))
|
|
1989
|
+
return;
|
|
1990
|
+
e.preventDefault();
|
|
1991
|
+
e.stopPropagation();
|
|
1992
|
+
if (this.currentHoverTarget) {
|
|
1993
|
+
this.toggleSelection(this.currentHoverTarget);
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
handleKeydown(e) {
|
|
1997
|
+
if (!this.isActive)
|
|
1998
|
+
return;
|
|
1999
|
+
if (e.key === 'Escape') {
|
|
2000
|
+
e.preventDefault();
|
|
2001
|
+
e.stopPropagation();
|
|
2002
|
+
this.toggleShareMode();
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
/**
|
|
2006
|
+
* Toggles the selection state of a given HTML element.
|
|
2007
|
+
* Implements selection logic:
|
|
2008
|
+
* - If an ancestor of the element is already selected, the click is ignored.
|
|
2009
|
+
* - If the element being selected is a parent of already selected elements, those children are deselected.
|
|
2010
|
+
* @param el The HTMLElement to toggle selection for.
|
|
2011
|
+
*/
|
|
2012
|
+
toggleSelection(el) {
|
|
2013
|
+
if (this.selectedElements.has(el)) {
|
|
2014
|
+
this.selectedElements.delete(el);
|
|
2015
|
+
el.classList.remove(SELECTED_CLASS);
|
|
2016
|
+
}
|
|
2017
|
+
else {
|
|
2018
|
+
// Selection Logic
|
|
2019
|
+
// Scenario A: Selecting a Parent -> Remove children selected while selecting parent
|
|
2020
|
+
// Scenario B: Selecting a Child -> Ignore if ancestor selected (or handle)
|
|
2021
|
+
// B. Check if any ancestor is already selected, return if any (Scenario B)
|
|
2022
|
+
let parent = el.parentElement;
|
|
2023
|
+
while (parent) {
|
|
2024
|
+
if (this.selectedElements.has(parent)) {
|
|
2025
|
+
// Ancestor is selected. Ignore click.
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
parent = parent.parentElement;
|
|
2029
|
+
}
|
|
2030
|
+
// A. Check if any children are selected (Scenario A)
|
|
2031
|
+
// We must iterate over currently selected elements
|
|
2032
|
+
const toRemove = [];
|
|
2033
|
+
this.selectedElements.forEach(selected => {
|
|
2034
|
+
if (el.contains(selected) && el !== selected) {
|
|
2035
|
+
toRemove.push(selected);
|
|
2036
|
+
}
|
|
2037
|
+
});
|
|
2038
|
+
toRemove.forEach(child => {
|
|
2039
|
+
this.selectedElements.delete(child);
|
|
2040
|
+
child.classList.remove(SELECTED_CLASS);
|
|
2041
|
+
});
|
|
2042
|
+
// Add new selection
|
|
2043
|
+
this.selectedElements.add(el);
|
|
2044
|
+
el.classList.add(SELECTED_CLASS);
|
|
2045
|
+
}
|
|
2046
|
+
this.updateFloatingBarCount();
|
|
2047
|
+
}
|
|
2048
|
+
createFloatingBar() {
|
|
2049
|
+
const bar = document.createElement('div');
|
|
2050
|
+
bar.id = FLOATING_ACTION_BAR_ID;
|
|
2051
|
+
bar.innerHTML = `
|
|
2052
|
+
<span id="cv-selected-count">0 items selected</span>
|
|
2053
|
+
<button class="cv-action-button clear">Clear All</button>
|
|
2054
|
+
<button class="cv-action-button preview">Preview</button>
|
|
2055
|
+
<button class="cv-action-button generate">Generate Link</button>
|
|
2056
|
+
<button class="cv-action-button exit">Exit</button>
|
|
2057
|
+
`;
|
|
2058
|
+
document.body.appendChild(bar);
|
|
2059
|
+
this.floatingBarEl = bar;
|
|
2060
|
+
bar.querySelector('.clear')?.addEventListener('click', () => this.clearAll());
|
|
2061
|
+
bar.querySelector('.preview')?.addEventListener('click', () => this.previewLink());
|
|
2062
|
+
bar.querySelector('.generate')?.addEventListener('click', () => this.generateLink());
|
|
2063
|
+
bar.querySelector('.exit')?.addEventListener('click', () => this.toggleShareMode());
|
|
2064
|
+
}
|
|
2065
|
+
updateFloatingBarCount() {
|
|
2066
|
+
if (this.floatingBarEl) {
|
|
2067
|
+
const countElement = this.floatingBarEl.querySelector('#cv-selected-count');
|
|
2068
|
+
if (countElement) {
|
|
2069
|
+
const count = this.selectedElements.size;
|
|
2070
|
+
countElement.textContent = `${count} item${count === 1 ? '' : 's'} selected`;
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
clearAll() {
|
|
2075
|
+
this.selectedElements.forEach(el => el.classList.remove('cv-share-selected'));
|
|
2076
|
+
this.selectedElements.clear();
|
|
2077
|
+
this.updateFloatingBarCount();
|
|
2078
|
+
}
|
|
2079
|
+
getShareUrl() {
|
|
2080
|
+
if (this.selectedElements.size === 0) {
|
|
2081
|
+
return null;
|
|
2082
|
+
}
|
|
2083
|
+
const descriptors = Array.from(this.selectedElements).map(el => AnchorEngine.createDescriptor(el));
|
|
2084
|
+
const serialized = AnchorEngine.serialize(descriptors);
|
|
2085
|
+
const url = new URL(window.location.href);
|
|
2086
|
+
url.searchParams.set('cv-focus', serialized);
|
|
2087
|
+
return url;
|
|
2088
|
+
}
|
|
2089
|
+
async generateLink() {
|
|
2090
|
+
const url = this.getShareUrl();
|
|
2091
|
+
if (!url) {
|
|
2092
|
+
ToastManager.show('Please select at least one item.');
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
try {
|
|
2096
|
+
await navigator.clipboard.writeText(url.toString());
|
|
2097
|
+
ToastManager.show('Link copied to clipboard!');
|
|
2098
|
+
}
|
|
2099
|
+
catch (e) {
|
|
2100
|
+
console.error('Clipboard failed', e);
|
|
2101
|
+
ToastManager.show('Failed to copy link.');
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
previewLink() {
|
|
2105
|
+
const url = this.getShareUrl();
|
|
2106
|
+
if (!url) {
|
|
2107
|
+
ToastManager.show('Please select at least one item.');
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
window.open(url.toString(), '_blank');
|
|
2111
|
+
}
|
|
2112
|
+
clearHover() {
|
|
2113
|
+
if (this.currentHoverTarget) {
|
|
2114
|
+
this.currentHoverTarget.classList.remove(HIGHLIGHT_TARGET_CLASS);
|
|
2115
|
+
this.currentHoverTarget = null;
|
|
2116
|
+
}
|
|
2117
|
+
if (this.helperEl) {
|
|
2118
|
+
this.helperEl.style.display = 'none';
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
cleanup() {
|
|
2122
|
+
document.body.classList.remove('cv-share-mode');
|
|
2123
|
+
this.clearAll();
|
|
2124
|
+
const style = document.getElementById(SHARE_MODE_STYLE_ID);
|
|
2125
|
+
if (style)
|
|
2126
|
+
document.head.removeChild(style);
|
|
2127
|
+
if (this.floatingBarEl) {
|
|
2128
|
+
document.body.removeChild(this.floatingBarEl);
|
|
2129
|
+
this.floatingBarEl = null;
|
|
2130
|
+
}
|
|
2131
|
+
if (this.helperEl) {
|
|
2132
|
+
document.body.removeChild(this.helperEl);
|
|
2133
|
+
this.helperEl = null;
|
|
2134
|
+
}
|
|
2135
|
+
if (this.currentHoverTarget) {
|
|
2136
|
+
this.currentHoverTarget.classList.remove(HIGHLIGHT_TARGET_CLASS);
|
|
2137
|
+
this.currentHoverTarget = null;
|
|
2138
|
+
}
|
|
2139
|
+
document.removeEventListener('mouseover', this.boundHandleHover, true);
|
|
2140
|
+
document.removeEventListener('click', this.boundHandleClick, true);
|
|
2141
|
+
document.removeEventListener('keydown', this.boundHandleKeydown, true);
|
|
2142
|
+
this.isActive = false;
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
const FOCUS_MODE_STYLE_ID = 'cv-focus-mode-styles';
|
|
2147
|
+
const BODY_FOCUS_CLASS = 'cv-focus-mode';
|
|
2148
|
+
const HIDDEN_CLASS = 'cv-focus-hidden';
|
|
2149
|
+
const FOCUSED_CLASS = 'cv-focused-element';
|
|
2150
|
+
const DIVIDER_CLASS = 'cv-context-divider';
|
|
2151
|
+
const EXIT_BANNER_ID = 'cv-exit-focus-banner';
|
|
2152
|
+
const styles = `
|
|
2153
|
+
body.${BODY_FOCUS_CLASS} {
|
|
2154
|
+
/* e.g. potentially hide scrollbars or adjust layout */
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
.${HIDDEN_CLASS} {
|
|
2158
|
+
display: none !important;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
.${FOCUSED_CLASS} {
|
|
2162
|
+
/* No visual style for focused elements, just logic class for now. Can add borders for debugging*/
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
.${DIVIDER_CLASS} {
|
|
2166
|
+
padding: 12px;
|
|
2167
|
+
margin: 16px 0;
|
|
2168
|
+
background-color: #f8f8f8;
|
|
2169
|
+
border-top: 1px dashed #ccc;
|
|
2170
|
+
border-bottom: 1px dashed #ccc;
|
|
2171
|
+
color: #555;
|
|
2172
|
+
text-align: center;
|
|
2173
|
+
cursor: pointer;
|
|
2174
|
+
font-family: system-ui, sans-serif;
|
|
2175
|
+
font-size: 13px;
|
|
2176
|
+
transition: background-color 0.2s;
|
|
2177
|
+
}
|
|
2178
|
+
.${DIVIDER_CLASS}:hover {
|
|
2179
|
+
background-color: #e8e8e8;
|
|
2180
|
+
color: #333;
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
#${EXIT_BANNER_ID} {
|
|
2184
|
+
position: sticky;
|
|
2185
|
+
top: 0;
|
|
2186
|
+
left: 0;
|
|
2187
|
+
right: 0;
|
|
2188
|
+
background-color: #0078D4;
|
|
2189
|
+
color: white;
|
|
2190
|
+
padding: 10px 20px;
|
|
2191
|
+
display: flex;
|
|
2192
|
+
align-items: center;
|
|
2193
|
+
justify-content: center;
|
|
2194
|
+
gap: 16px;
|
|
2195
|
+
z-index: 100000;
|
|
2196
|
+
font-family: system-ui, sans-serif;
|
|
2197
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
#${EXIT_BANNER_ID} button {
|
|
2201
|
+
background: white;
|
|
2202
|
+
color: #0078D4;
|
|
2203
|
+
border: none;
|
|
2204
|
+
padding: 4px 12px;
|
|
2205
|
+
border-radius: 4px;
|
|
2206
|
+
cursor: pointer;
|
|
2207
|
+
font-weight: 600;
|
|
2208
|
+
}
|
|
2209
|
+
#${EXIT_BANNER_ID} button:hover {
|
|
2210
|
+
background: #f0f0f0;
|
|
2211
|
+
}
|
|
2212
|
+
`;
|
|
2213
|
+
const FOCUS_MODE_STYLES = styles;
|
|
2214
|
+
|
|
2215
|
+
/**
|
|
2216
|
+
* Manages the "Focus Mode" (Presentation View).
|
|
2217
|
+
* Parses the URL for robust anchors, resolves them, hides irrelevant content, and inserts context dividers.
|
|
2218
|
+
*/
|
|
2219
|
+
const FOCUS_PARAM = 'cv-focus';
|
|
2220
|
+
class FocusManager {
|
|
2221
|
+
rootEl;
|
|
2222
|
+
hiddenElements = new Set();
|
|
2223
|
+
dividers = [];
|
|
2224
|
+
exitBanner = null;
|
|
2225
|
+
excludedTags;
|
|
2226
|
+
excludedIds;
|
|
2227
|
+
constructor(rootEl, options) {
|
|
2228
|
+
this.rootEl = rootEl;
|
|
2229
|
+
this.excludedTags = new Set(options.excludedTags.map(t => t.toUpperCase()));
|
|
2230
|
+
this.excludedIds = new Set(options.excludedIds);
|
|
2231
|
+
}
|
|
2232
|
+
/**
|
|
2233
|
+
* Initializes the Focus Manager. Checks URL for focus parameter.
|
|
2234
|
+
*/
|
|
2235
|
+
init() {
|
|
2236
|
+
this.handleUrlChange();
|
|
2237
|
+
}
|
|
2238
|
+
handleUrlChange() {
|
|
2239
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
2240
|
+
const encodedDescriptors = urlParams.get(FOCUS_PARAM);
|
|
2241
|
+
if (encodedDescriptors) {
|
|
2242
|
+
this.applyFocusMode(encodedDescriptors);
|
|
2243
|
+
}
|
|
2244
|
+
else {
|
|
2245
|
+
// encoding missing, ensure we exit focus mode if active
|
|
2246
|
+
if (document.body.classList.contains(BODY_FOCUS_CLASS)) {
|
|
2247
|
+
this.exitFocusMode();
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
/**
|
|
2252
|
+
* Applies Focus Mode based on encoded descriptors.
|
|
2253
|
+
*/
|
|
2254
|
+
applyFocusMode(encodedDescriptors) {
|
|
2255
|
+
const descriptors = AnchorEngine.deserialize(encodedDescriptors);
|
|
2256
|
+
if (!descriptors || descriptors.length === 0)
|
|
2257
|
+
return;
|
|
2258
|
+
// Resolve anchors to DOM elements
|
|
2259
|
+
const targets = [];
|
|
2260
|
+
descriptors.forEach(desc => {
|
|
2261
|
+
const el = AnchorEngine.resolve(this.rootEl, desc);
|
|
2262
|
+
if (el) {
|
|
2263
|
+
targets.push(el);
|
|
2264
|
+
}
|
|
2265
|
+
});
|
|
2266
|
+
if (targets.length === 0) {
|
|
2267
|
+
ToastManager.show("Some shared sections could not be found.");
|
|
2268
|
+
return;
|
|
2269
|
+
}
|
|
2270
|
+
if (targets.length < descriptors.length) {
|
|
2271
|
+
ToastManager.show("Some shared sections could not be found.");
|
|
2272
|
+
}
|
|
2273
|
+
this.injectStyles();
|
|
2274
|
+
document.body.classList.add(BODY_FOCUS_CLASS);
|
|
2275
|
+
this.renderFocusedView(targets);
|
|
2276
|
+
this.showExitBanner();
|
|
2277
|
+
}
|
|
2278
|
+
injectStyles() {
|
|
2279
|
+
if (document.getElementById(FOCUS_MODE_STYLE_ID))
|
|
2280
|
+
return;
|
|
2281
|
+
const style = document.createElement('style');
|
|
2282
|
+
style.id = FOCUS_MODE_STYLE_ID;
|
|
2283
|
+
style.textContent = FOCUS_MODE_STYLES;
|
|
2284
|
+
document.head.appendChild(style);
|
|
2285
|
+
}
|
|
2286
|
+
/**
|
|
2287
|
+
* Hides irrelevant content and adds dividers.
|
|
2288
|
+
*/
|
|
2289
|
+
renderFocusedView(targets) {
|
|
2290
|
+
// 1. Mark targets
|
|
2291
|
+
targets.forEach(t => t.classList.add(FOCUSED_CLASS));
|
|
2292
|
+
// 2. We need to hide siblings of targets (and their ancestors up to root generally,
|
|
2293
|
+
// but "siblings between focused zones" suggests we are mostly looking at a flat list or specific nesting).
|
|
2294
|
+
//
|
|
2295
|
+
// "All sibling elements between the focused zones are collapsed and hidden."
|
|
2296
|
+
// "If a user selects a parent element, all of its child elements must be visible."
|
|
2297
|
+
//
|
|
2298
|
+
// Algorithm:
|
|
2299
|
+
// Walk up from each target to finding the common container?
|
|
2300
|
+
// Or just assume targets are somewhat related.
|
|
2301
|
+
//
|
|
2302
|
+
// Let's implement a robust "Hide Siblings" approach.
|
|
2303
|
+
// For every target, we ensure it is visible.
|
|
2304
|
+
// We look at its siblings. If a sibling is NOT a target AND NOT an ancestor of a target, we hide it.
|
|
2305
|
+
// We need to identify all "Keep Visible" elements (targets + ancestors)
|
|
2306
|
+
const keepVisible = new Set();
|
|
2307
|
+
targets.forEach(t => {
|
|
2308
|
+
let curr = t;
|
|
2309
|
+
while (curr && curr !== document.body && curr !== document.documentElement) {
|
|
2310
|
+
keepVisible.add(curr);
|
|
2311
|
+
curr = curr.parentElement;
|
|
2312
|
+
}
|
|
2313
|
+
});
|
|
2314
|
+
// Now iterate through siblings of "Keep Visible" elements?
|
|
2315
|
+
// Actually, we can just walk the tree or iterate siblings of targets/ancestors?
|
|
2316
|
+
//
|
|
2317
|
+
// Improved Algorithm:
|
|
2318
|
+
// 1. Collect all direct siblings of every element in keepVisible set.
|
|
2319
|
+
// 2. If a sibling is NOT in keepVisible, hide it.
|
|
2320
|
+
// To avoid processing the entire DOM, we start from targets and walk up.
|
|
2321
|
+
keepVisible.forEach(el => {
|
|
2322
|
+
if (el === document.body)
|
|
2323
|
+
return; // Don't hide siblings of body (scripts etc) unless we are sure.
|
|
2324
|
+
// Actually usually we want to hide siblings of the content container.
|
|
2325
|
+
const parent = el.parentElement;
|
|
2326
|
+
if (!parent)
|
|
2327
|
+
return;
|
|
2328
|
+
// FIX: "Parent Dominance"
|
|
2329
|
+
// If the parent itself is a target (or we otherwise decided its whole content is meaningful),
|
|
2330
|
+
// then we should NOT hide anything inside it.
|
|
2331
|
+
// We check if 'parent' is one of the explicitly resolved targets.
|
|
2332
|
+
// We can check if it has the FOCUSED_CLASS class, since we added it in step 1.
|
|
2333
|
+
if (parent.classList.contains(FOCUSED_CLASS)) {
|
|
2334
|
+
return;
|
|
2335
|
+
}
|
|
2336
|
+
// Using children because we want element nodes
|
|
2337
|
+
Array.from(parent.children).forEach(child => {
|
|
2338
|
+
if (child instanceof HTMLElement && !keepVisible.has(child)) {
|
|
2339
|
+
this.hideElement(child);
|
|
2340
|
+
}
|
|
2341
|
+
});
|
|
2342
|
+
});
|
|
2343
|
+
// 3. Insert Dividers
|
|
2344
|
+
// We process each container that has hidden elements
|
|
2345
|
+
const processedContainers = new Set();
|
|
2346
|
+
keepVisible.forEach(el => {
|
|
2347
|
+
const parent = el.parentElement;
|
|
2348
|
+
if (parent && !processedContainers.has(parent)) {
|
|
2349
|
+
this.insertDividersForContainer(parent);
|
|
2350
|
+
processedContainers.add(parent);
|
|
2351
|
+
}
|
|
2352
|
+
});
|
|
2353
|
+
}
|
|
2354
|
+
hideElement(el) {
|
|
2355
|
+
if (this.hiddenElements.has(el))
|
|
2356
|
+
return; // Already hidden
|
|
2357
|
+
// Exclude by Tag
|
|
2358
|
+
if (this.excludedTags.has(el.tagName.toUpperCase()))
|
|
2359
|
+
return;
|
|
2360
|
+
// Exclude by ID (if strictly matching)
|
|
2361
|
+
if (el.id && this.excludedIds.has(el.id))
|
|
2362
|
+
return;
|
|
2363
|
+
// Also don't hide things that are aria-hidden
|
|
2364
|
+
if (el.getAttribute('aria-hidden') === 'true')
|
|
2365
|
+
return;
|
|
2366
|
+
// Exclude Toast Notification
|
|
2367
|
+
if (el.classList.contains(TOAST_CLASS))
|
|
2368
|
+
return;
|
|
2369
|
+
// We check if it is already hidden (e.g. by previous focus mode run? No, isActive check handles that)
|
|
2370
|
+
// Just mark it.
|
|
2371
|
+
el.classList.add(HIDDEN_CLASS);
|
|
2372
|
+
this.hiddenElements.add(el);
|
|
2373
|
+
}
|
|
2374
|
+
insertDividersForContainer(container) {
|
|
2375
|
+
const children = Array.from(container.children);
|
|
2376
|
+
let hiddenCount = 0;
|
|
2377
|
+
let hiddenGroupStart = null;
|
|
2378
|
+
children.forEach((child) => {
|
|
2379
|
+
if (child.classList.contains(HIDDEN_CLASS)) {
|
|
2380
|
+
if (hiddenCount === 0)
|
|
2381
|
+
hiddenGroupStart = child;
|
|
2382
|
+
hiddenCount++;
|
|
2383
|
+
}
|
|
2384
|
+
else {
|
|
2385
|
+
// Found a visible element. Was there a hidden group before this?
|
|
2386
|
+
if (hiddenCount > 0 && hiddenGroupStart) {
|
|
2387
|
+
this.createDivider(container, hiddenGroupStart, hiddenCount);
|
|
2388
|
+
hiddenCount = 0;
|
|
2389
|
+
hiddenGroupStart = null;
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
});
|
|
2393
|
+
// Trailing hidden group
|
|
2394
|
+
if (hiddenCount > 0 && hiddenGroupStart) {
|
|
2395
|
+
this.createDivider(container, hiddenGroupStart, hiddenCount);
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
createDivider(container, insertBeforeEl, count) {
|
|
2399
|
+
const divider = document.createElement('div');
|
|
2400
|
+
divider.className = DIVIDER_CLASS;
|
|
2401
|
+
divider.textContent = `... ${count} section${count > 1 ? 's' : ''} hidden (Click to expand) ...`;
|
|
2402
|
+
divider.onclick = () => this.expandContext(insertBeforeEl, count, divider);
|
|
2403
|
+
container.insertBefore(divider, insertBeforeEl);
|
|
2404
|
+
this.dividers.push(divider);
|
|
2405
|
+
}
|
|
2406
|
+
expandContext(firstHidden, count, divider) {
|
|
2407
|
+
// Divider is inserted BEFORE firstHidden.
|
|
2408
|
+
// So firstHidden is the first element to reveal.
|
|
2409
|
+
let curr = firstHidden;
|
|
2410
|
+
let expanded = 0;
|
|
2411
|
+
while (curr && expanded < count) {
|
|
2412
|
+
if (curr instanceof HTMLElement && curr.classList.contains(HIDDEN_CLASS)) {
|
|
2413
|
+
curr.classList.remove(HIDDEN_CLASS);
|
|
2414
|
+
this.hiddenElements.delete(curr);
|
|
2415
|
+
}
|
|
2416
|
+
curr = curr.nextElementSibling;
|
|
2417
|
+
// Note: If nested dividers or other elements exist, they shouldn't count?
|
|
2418
|
+
// "Children" iteration in insertDividers covered direct children.
|
|
2419
|
+
// sibling iteration also covers direct children.
|
|
2420
|
+
// We assume contiguous hidden siblings.
|
|
2421
|
+
expanded++;
|
|
2422
|
+
}
|
|
2423
|
+
divider.remove();
|
|
2424
|
+
const idx = this.dividers.indexOf(divider);
|
|
2425
|
+
if (idx > -1)
|
|
2426
|
+
this.dividers.splice(idx, 1);
|
|
2427
|
+
// If no more hidden elements, remove the banner
|
|
2428
|
+
if (this.hiddenElements.size === 0) {
|
|
2429
|
+
this.removeExitBanner();
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
removeExitBanner() {
|
|
2433
|
+
if (this.exitBanner) {
|
|
2434
|
+
this.exitBanner.remove();
|
|
2435
|
+
this.exitBanner = null;
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
/**
|
|
2439
|
+
* Override of renderFocusedView with robust logic
|
|
2440
|
+
*/
|
|
2441
|
+
// (We use the class method `renderFocusedView` and internal helpers)
|
|
2442
|
+
showExitBanner() {
|
|
2443
|
+
if (document.getElementById(EXIT_BANNER_ID))
|
|
2444
|
+
return;
|
|
2445
|
+
const banner = document.createElement('div');
|
|
2446
|
+
banner.id = EXIT_BANNER_ID;
|
|
2447
|
+
banner.innerHTML = `
|
|
2448
|
+
<span>You are viewing a focused selection.</span>
|
|
2449
|
+
<button id="cv-exit-focus-btn">Show Full Page</button>
|
|
2450
|
+
`;
|
|
2451
|
+
document.body.prepend(banner); // Top of body
|
|
2452
|
+
banner.querySelector('button')?.addEventListener('click', () => this.exitFocusMode());
|
|
2453
|
+
this.exitBanner = banner;
|
|
2454
|
+
}
|
|
2455
|
+
exitFocusMode() {
|
|
2456
|
+
document.body.classList.remove(BODY_FOCUS_CLASS);
|
|
2457
|
+
// Show all hidden elements
|
|
2458
|
+
this.hiddenElements.forEach(el => el.classList.remove(HIDDEN_CLASS));
|
|
2459
|
+
this.hiddenElements.clear();
|
|
2460
|
+
// Remove dividers
|
|
2461
|
+
this.dividers.forEach(d => d.remove());
|
|
2462
|
+
this.dividers = [];
|
|
2463
|
+
// Remove styling from targets
|
|
2464
|
+
const targets = document.querySelectorAll(`.${FOCUSED_CLASS}`);
|
|
2465
|
+
targets.forEach(t => t.classList.remove(FOCUSED_CLASS));
|
|
2466
|
+
// Remove banner
|
|
2467
|
+
this.removeExitBanner();
|
|
2468
|
+
// Update URL
|
|
2469
|
+
const url = new URL(window.location.href);
|
|
2470
|
+
url.searchParams.delete(FOCUS_PARAM);
|
|
2471
|
+
window.history.pushState({}, '', url.toString());
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
const DEFAULT_EXCLUDED_TAGS = ['HEADER', 'NAV', 'FOOTER', 'SCRIPT', 'STYLE'];
|
|
2476
|
+
const DEFAULT_EXCLUDED_IDS = ['cv-floating-action-bar', 'cv-hover-helper', 'cv-toast-notification'];
|
|
2477
|
+
|
|
1381
2478
|
const TOGGLE_SELECTOR = "[data-cv-toggle], [data-customviews-toggle], cv-toggle";
|
|
1382
2479
|
const TABGROUP_SELECTOR = 'cv-tabgroup';
|
|
1383
2480
|
class CustomViewsCore {
|
|
@@ -1386,6 +2483,8 @@ class CustomViewsCore {
|
|
|
1386
2483
|
persistenceManager;
|
|
1387
2484
|
visibilityManager;
|
|
1388
2485
|
observer = null;
|
|
2486
|
+
shareManager;
|
|
2487
|
+
focusManager;
|
|
1389
2488
|
componentRegistry = {
|
|
1390
2489
|
toggles: new Set(),
|
|
1391
2490
|
tabGroups: new Set(),
|
|
@@ -1402,6 +2501,21 @@ class CustomViewsCore {
|
|
|
1402
2501
|
this.visibilityManager = new VisibilityManager();
|
|
1403
2502
|
this.showUrlEnabled = opt.showUrl ?? false;
|
|
1404
2503
|
this.lastAppliedState = this.cloneState(this.getComputedDefaultState());
|
|
2504
|
+
// Resolve Exclusions
|
|
2505
|
+
const excludedTags = [...DEFAULT_EXCLUDED_TAGS, ...(this.config.shareExclusions?.tags || [])];
|
|
2506
|
+
const excludedIds = [...DEFAULT_EXCLUDED_IDS, ...(this.config.shareExclusions?.ids || [])];
|
|
2507
|
+
const commonOptions = { excludedTags, excludedIds };
|
|
2508
|
+
this.shareManager = new ShareManager(commonOptions);
|
|
2509
|
+
this.focusManager = new FocusManager(this.rootEl, commonOptions);
|
|
2510
|
+
}
|
|
2511
|
+
getShareManager() {
|
|
2512
|
+
return this.shareManager;
|
|
2513
|
+
}
|
|
2514
|
+
/**
|
|
2515
|
+
* Toggles the share mode on or off.
|
|
2516
|
+
*/
|
|
2517
|
+
toggleShareMode() {
|
|
2518
|
+
this.shareManager.toggleShareMode();
|
|
1405
2519
|
}
|
|
1406
2520
|
/**
|
|
1407
2521
|
* Scan the given element for toggles and tab groups, register them
|
|
@@ -1434,7 +2548,7 @@ class CustomViewsCore {
|
|
|
1434
2548
|
return newComponentsFound;
|
|
1435
2549
|
}
|
|
1436
2550
|
/**
|
|
1437
|
-
* Unscan the given element for toggles and tab groups, de-register them
|
|
2551
|
+
* Unscan the given element for toggles and tab groups, de-register them from registry
|
|
1438
2552
|
*/
|
|
1439
2553
|
unscan(element) {
|
|
1440
2554
|
// Unscan for toggles
|
|
@@ -1538,8 +2652,10 @@ class CustomViewsCore {
|
|
|
1538
2652
|
// For session history, clicks on back/forward button
|
|
1539
2653
|
window.addEventListener("popstate", () => {
|
|
1540
2654
|
this.loadAndCallApplyState();
|
|
2655
|
+
this.focusManager.handleUrlChange();
|
|
1541
2656
|
});
|
|
1542
2657
|
this.loadAndCallApplyState();
|
|
2658
|
+
this.focusManager.init();
|
|
1543
2659
|
this.initObserver();
|
|
1544
2660
|
}
|
|
1545
2661
|
initializeNewComponents() {
|
|
@@ -1561,7 +2677,7 @@ class CustomViewsCore {
|
|
|
1561
2677
|
const currentToggles = this.getCurrentActiveToggles();
|
|
1562
2678
|
const newState = {
|
|
1563
2679
|
toggles: currentToggles,
|
|
1564
|
-
tabs: currentTabs
|
|
2680
|
+
tabs: currentTabs,
|
|
1565
2681
|
};
|
|
1566
2682
|
// 2. Apply state with scroll anchor information
|
|
1567
2683
|
this.applyState(newState, {
|
|
@@ -1829,10 +2945,11 @@ function prependBaseUrl(path, baseUrl) {
|
|
|
1829
2945
|
}
|
|
1830
2946
|
|
|
1831
2947
|
/**
|
|
1832
|
-
*
|
|
2948
|
+
* Defines the custom elements used by CustomViews.
|
|
1833
2949
|
*/
|
|
1834
2950
|
/**
|
|
1835
|
-
*
|
|
2951
|
+
* `<cv-tab>`: A custom element representing a single tab panel within a tab group.
|
|
2952
|
+
* Its content is displayed when the corresponding tab is active.
|
|
1836
2953
|
*/
|
|
1837
2954
|
class CVTab extends HTMLElement {
|
|
1838
2955
|
connectedCallback() {
|
|
@@ -1840,7 +2957,8 @@ class CVTab extends HTMLElement {
|
|
|
1840
2957
|
}
|
|
1841
2958
|
}
|
|
1842
2959
|
/**
|
|
1843
|
-
*
|
|
2960
|
+
* `<cv-tabgroup>`: A custom element that encapsulates a set of tabs (`<cv-tab>`).
|
|
2961
|
+
* It manages the tab navigation and content visibility for the group.
|
|
1844
2962
|
*/
|
|
1845
2963
|
class CVTabgroup extends HTMLElement {
|
|
1846
2964
|
connectedCallback() {
|
|
@@ -1856,7 +2974,7 @@ class CVTabgroup extends HTMLElement {
|
|
|
1856
2974
|
}
|
|
1857
2975
|
}
|
|
1858
2976
|
/**
|
|
1859
|
-
*
|
|
2977
|
+
* `<cv-toggle>`: A custom element for creating a toggleable content block.
|
|
1860
2978
|
*/
|
|
1861
2979
|
class CVToggle extends HTMLElement {
|
|
1862
2980
|
connectedCallback() {
|
|
@@ -1864,8 +2982,8 @@ class CVToggle extends HTMLElement {
|
|
|
1864
2982
|
}
|
|
1865
2983
|
}
|
|
1866
2984
|
/**
|
|
1867
|
-
*
|
|
1868
|
-
*
|
|
2985
|
+
* `<cv-tab-header>`: A semantic container for a tab's header content.
|
|
2986
|
+
* The content of this element is used to create the navigation link for the tab.
|
|
1869
2987
|
*/
|
|
1870
2988
|
class CVTabHeader extends HTMLElement {
|
|
1871
2989
|
connectedCallback() {
|
|
@@ -1873,8 +2991,7 @@ class CVTabHeader extends HTMLElement {
|
|
|
1873
2991
|
}
|
|
1874
2992
|
}
|
|
1875
2993
|
/**
|
|
1876
|
-
*
|
|
1877
|
-
* Semantic container for tab panel content
|
|
2994
|
+
* `<cv-tab-body>`: A semantic container for the main content of a tab panel.
|
|
1878
2995
|
*/
|
|
1879
2996
|
class CVTabBody extends HTMLElement {
|
|
1880
2997
|
connectedCallback() {
|
|
@@ -1882,7 +2999,7 @@ class CVTabBody extends HTMLElement {
|
|
|
1882
2999
|
}
|
|
1883
3000
|
}
|
|
1884
3001
|
/**
|
|
1885
|
-
*
|
|
3002
|
+
* Registers all CustomViews custom elements with the CustomElementRegistry.
|
|
1886
3003
|
*/
|
|
1887
3004
|
function registerCustomElements() {
|
|
1888
3005
|
// Only register if not already defined
|
|
@@ -2847,6 +3964,33 @@ const WIDGET_STYLES = `
|
|
|
2847
3964
|
padding: 0.75rem;
|
|
2848
3965
|
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
|
2849
3966
|
}
|
|
3967
|
+
|
|
3968
|
+
.cv-footer-link {
|
|
3969
|
+
display: flex;
|
|
3970
|
+
align-items: center;
|
|
3971
|
+
justify-content: center;
|
|
3972
|
+
gap: 0.5rem;
|
|
3973
|
+
font-size: 0.75rem;
|
|
3974
|
+
color: rgba(0, 0, 0, 0.5);
|
|
3975
|
+
text-decoration: none;
|
|
3976
|
+
transition: color 0.2s ease;
|
|
3977
|
+
}
|
|
3978
|
+
|
|
3979
|
+
.cv-footer-link:hover {
|
|
3980
|
+
color: #3e84f4;
|
|
3981
|
+
}
|
|
3982
|
+
|
|
3983
|
+
.cv-footer-link svg {
|
|
3984
|
+
opacity: 0.8;
|
|
3985
|
+
}
|
|
3986
|
+
|
|
3987
|
+
.cv-widget-theme-dark .cv-footer-link {
|
|
3988
|
+
color: rgba(255, 255, 255, 0.4);
|
|
3989
|
+
}
|
|
3990
|
+
|
|
3991
|
+
.cv-widget-theme-dark .cv-footer-link:hover {
|
|
3992
|
+
color: #60a5fa;
|
|
3993
|
+
}
|
|
2850
3994
|
|
|
2851
3995
|
.cv-reset-btn,
|
|
2852
3996
|
.cv-share-btn {
|
|
@@ -3038,6 +4182,154 @@ const WIDGET_STYLES = `
|
|
|
3038
4182
|
display: none !important;
|
|
3039
4183
|
}
|
|
3040
4184
|
}
|
|
4185
|
+
/* Widget Modal Tabs */
|
|
4186
|
+
.cv-modal-tabs {
|
|
4187
|
+
display: flex;
|
|
4188
|
+
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
|
4189
|
+
margin-bottom: 0.5rem;
|
|
4190
|
+
}
|
|
4191
|
+
|
|
4192
|
+
.cv-tab-content > .cv-content-section + .cv-content-section {
|
|
4193
|
+
margin-top: 1.5rem;
|
|
4194
|
+
}
|
|
4195
|
+
|
|
4196
|
+
.cv-modal-tab {
|
|
4197
|
+
padding: 0.75rem 1.5rem;
|
|
4198
|
+
font-size: 0.875rem;
|
|
4199
|
+
font-weight: 500;
|
|
4200
|
+
color: rgba(0, 0, 0, 0.6);
|
|
4201
|
+
background: none;
|
|
4202
|
+
border: none;
|
|
4203
|
+
border-bottom: 2px solid transparent;
|
|
4204
|
+
cursor: pointer;
|
|
4205
|
+
transition: all 0.2s ease;
|
|
4206
|
+
}
|
|
4207
|
+
|
|
4208
|
+
.cv-modal-tab:hover {
|
|
4209
|
+
color: rgba(0, 0, 0, 0.9);
|
|
4210
|
+
}
|
|
4211
|
+
|
|
4212
|
+
.cv-modal-tab.active {
|
|
4213
|
+
color: #3e84f4;
|
|
4214
|
+
border-bottom-color: #3e84f4;
|
|
4215
|
+
}
|
|
4216
|
+
|
|
4217
|
+
.cv-tab-content {
|
|
4218
|
+
display: none;
|
|
4219
|
+
animation: fadeIn 0.3s ease;
|
|
4220
|
+
}
|
|
4221
|
+
|
|
4222
|
+
.cv-tab-content.active {
|
|
4223
|
+
display: block;
|
|
4224
|
+
}
|
|
4225
|
+
|
|
4226
|
+
/* Share Tab Content */
|
|
4227
|
+
.cv-share-content {
|
|
4228
|
+
display: flex;
|
|
4229
|
+
flex-direction: column;
|
|
4230
|
+
gap: 1rem;
|
|
4231
|
+
padding: 1rem 0;
|
|
4232
|
+
align-items: center;
|
|
4233
|
+
text-align: center;
|
|
4234
|
+
}
|
|
4235
|
+
|
|
4236
|
+
.cv-share-instruction {
|
|
4237
|
+
font-size: 0.9rem;
|
|
4238
|
+
color: rgba(0, 0, 0, 0.7);
|
|
4239
|
+
margin-bottom: 1rem;
|
|
4240
|
+
}
|
|
4241
|
+
|
|
4242
|
+
.cv-share-action-btn {
|
|
4243
|
+
display: flex;
|
|
4244
|
+
align-items: center;
|
|
4245
|
+
justify-content: center;
|
|
4246
|
+
gap: 0.5rem;
|
|
4247
|
+
width: 100%;
|
|
4248
|
+
padding: 12px 16px;
|
|
4249
|
+
background: white;
|
|
4250
|
+
color: #333;
|
|
4251
|
+
border: 1px solid rgba(0, 0, 0, 0.15);
|
|
4252
|
+
border-radius: 6px;
|
|
4253
|
+
cursor: pointer;
|
|
4254
|
+
font-size: 0.9rem;
|
|
4255
|
+
font-weight: 500;
|
|
4256
|
+
transition: all 0.2s ease;
|
|
4257
|
+
}
|
|
4258
|
+
|
|
4259
|
+
.cv-share-action-btn:hover {
|
|
4260
|
+
background: #f8f9fa;
|
|
4261
|
+
border-color: rgba(0, 0, 0, 0.25);
|
|
4262
|
+
transform: translateY(-1px);
|
|
4263
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
|
4264
|
+
}
|
|
4265
|
+
|
|
4266
|
+
.cv-share-action-btn.primary {
|
|
4267
|
+
background: #3e84f4;
|
|
4268
|
+
color: white;
|
|
4269
|
+
border-color: #3e84f4;
|
|
4270
|
+
}
|
|
4271
|
+
|
|
4272
|
+
.cv-share-action-btn.primary:hover {
|
|
4273
|
+
background: #2b74e6;
|
|
4274
|
+
border-color: #2b74e6;
|
|
4275
|
+
}
|
|
4276
|
+
|
|
4277
|
+
.cv-done-btn {
|
|
4278
|
+
padding: 0.375rem 1rem;
|
|
4279
|
+
background: #3e84f4;
|
|
4280
|
+
color: white;
|
|
4281
|
+
border: none;
|
|
4282
|
+
border-radius: 0.5rem;
|
|
4283
|
+
font-weight: 600;
|
|
4284
|
+
font-size: 0.875rem;
|
|
4285
|
+
cursor: pointer;
|
|
4286
|
+
transition: all 0.2s ease;
|
|
4287
|
+
}
|
|
4288
|
+
|
|
4289
|
+
.cv-done-btn:hover {
|
|
4290
|
+
background: #2b74e6;
|
|
4291
|
+
}
|
|
4292
|
+
|
|
4293
|
+
/* Dark Theme Adjustments */
|
|
4294
|
+
.cv-widget-theme-dark .cv-modal-tabs {
|
|
4295
|
+
border-color: rgba(255, 255, 255, 0.1);
|
|
4296
|
+
}
|
|
4297
|
+
|
|
4298
|
+
.cv-widget-theme-dark .cv-modal-tab {
|
|
4299
|
+
color: rgba(255, 255, 255, 0.6);
|
|
4300
|
+
}
|
|
4301
|
+
|
|
4302
|
+
.cv-widget-theme-dark .cv-modal-tab:hover {
|
|
4303
|
+
color: rgba(255, 255, 255, 0.9);
|
|
4304
|
+
}
|
|
4305
|
+
|
|
4306
|
+
.cv-widget-theme-dark .cv-modal-tab.active {
|
|
4307
|
+
color: #60a5fa;
|
|
4308
|
+
border-bottom-color: #60a5fa;
|
|
4309
|
+
}
|
|
4310
|
+
|
|
4311
|
+
.cv-widget-theme-dark .cv-share-instruction {
|
|
4312
|
+
color: rgba(255, 255, 255, 0.7);
|
|
4313
|
+
}
|
|
4314
|
+
|
|
4315
|
+
.cv-widget-theme-dark .cv-share-action-btn {
|
|
4316
|
+
background: #1a202c;
|
|
4317
|
+
color: white;
|
|
4318
|
+
border-color: rgba(255, 255, 255, 0.15);
|
|
4319
|
+
}
|
|
4320
|
+
|
|
4321
|
+
.cv-widget-theme-dark .cv-share-action-btn:hover {
|
|
4322
|
+
background: #2d3748;
|
|
4323
|
+
}
|
|
4324
|
+
|
|
4325
|
+
.cv-widget-theme-dark .cv-share-action-btn.primary {
|
|
4326
|
+
background: #3e84f4;
|
|
4327
|
+
border-color: #3e84f4;
|
|
4328
|
+
}
|
|
4329
|
+
|
|
4330
|
+
.cv-widget-theme-dark .cv-share-action-btn.primary:hover {
|
|
4331
|
+
background: #2b74e6;
|
|
4332
|
+
}
|
|
3041
4333
|
`;
|
|
3042
4334
|
/**
|
|
3043
4335
|
* Inject widget styles into the document head
|
|
@@ -3060,6 +4352,7 @@ class CustomViewsWidget {
|
|
|
3060
4352
|
_hasVisibleConfig = false;
|
|
3061
4353
|
pageToggleIds = new Set();
|
|
3062
4354
|
pageTabIds = new Set();
|
|
4355
|
+
currentTab = 'customize';
|
|
3063
4356
|
// Modal state
|
|
3064
4357
|
stateModal = null;
|
|
3065
4358
|
constructor(options) {
|
|
@@ -3235,12 +4528,12 @@ class CustomViewsWidget {
|
|
|
3235
4528
|
</div>
|
|
3236
4529
|
<div class="cv-tabgroup-info">
|
|
3237
4530
|
<div class="cv-tabgroup-title-container">
|
|
3238
|
-
<p class="cv-tabgroup-title">
|
|
4531
|
+
<p class="cv-tabgroup-title">Show only the selected tab</p>
|
|
3239
4532
|
</div>
|
|
3240
|
-
<p class="cv-tabgroup-description">
|
|
4533
|
+
<p class="cv-tabgroup-description">Hide the navigation headers</p>
|
|
3241
4534
|
</div>
|
|
3242
4535
|
<label class="cv-toggle-switch cv-nav-toggle">
|
|
3243
|
-
<input class="cv-nav-pref-input" type="checkbox" ${initialNavsVisible ? '
|
|
4536
|
+
<input class="cv-nav-pref-input" type="checkbox" ${initialNavsVisible ? '' : 'checked'} aria-label="Show only the selected tab" />
|
|
3244
4537
|
<span class="cv-switch-bg"></span>
|
|
3245
4538
|
<span class="cv-switch-knob"></span>
|
|
3246
4539
|
</label>
|
|
@@ -3276,36 +4569,64 @@ class CustomViewsWidget {
|
|
|
3276
4569
|
<main class="cv-modal-main">
|
|
3277
4570
|
${this.options.description ? `<p class="cv-modal-description">${this.options.description}</p>` : ''}
|
|
3278
4571
|
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
<
|
|
3282
|
-
|
|
3283
|
-
|
|
4572
|
+
<div class="cv-modal-tabs">
|
|
4573
|
+
<button class="cv-modal-tab ${this.currentTab === 'customize' ? 'active' : ''}" data-tab="customize">Customize</button>
|
|
4574
|
+
<button class="cv-modal-tab ${this.currentTab === 'share' ? 'active' : ''}" data-tab="share">Share</button>
|
|
4575
|
+
</div>
|
|
4576
|
+
|
|
4577
|
+
<div class="cv-tab-content ${this.currentTab === 'customize' ? 'active' : ''}" data-content="customize">
|
|
4578
|
+
${visibleToggles.length ? `
|
|
4579
|
+
<div class="cv-content-section">
|
|
4580
|
+
<div class="cv-section-heading">Toggles</div>
|
|
4581
|
+
<div class="cv-toggles-container">
|
|
4582
|
+
${toggleControlsHtml}
|
|
4583
|
+
</div>
|
|
4584
|
+
</div>
|
|
4585
|
+
` : ''}
|
|
4586
|
+
|
|
4587
|
+
${this.options.showTabGroups && tabGroups && tabGroups.length > 0 ? `
|
|
4588
|
+
<div class="cv-content-section">
|
|
4589
|
+
<div class="cv-section-heading">Tab Groups</div>
|
|
4590
|
+
<div class="cv-tabgroups-container">
|
|
4591
|
+
${tabGroupControlsHTML}
|
|
4592
|
+
</div>
|
|
3284
4593
|
</div>
|
|
4594
|
+
` : ''}
|
|
3285
4595
|
</div>
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
4596
|
+
|
|
4597
|
+
<div class="cv-tab-content ${this.currentTab === 'share' ? 'active' : ''}" data-content="share">
|
|
4598
|
+
<div class="cv-share-content">
|
|
4599
|
+
<div class="cv-share-instruction">
|
|
4600
|
+
Create a shareable link for your current customization, or select specific parts of the page to share.
|
|
4601
|
+
</div>
|
|
4602
|
+
|
|
4603
|
+
<button class="cv-share-action-btn primary cv-start-share-btn">
|
|
4604
|
+
<span class="cv-btn-icon">${getShareIcon()}</span>
|
|
4605
|
+
<span>Select elements to share</span>
|
|
4606
|
+
</button>
|
|
4607
|
+
|
|
4608
|
+
<button class="cv-share-action-btn cv-copy-url-btn">
|
|
4609
|
+
<span class="cv-btn-icon">${getCopyIcon()}</span>
|
|
4610
|
+
<span>Copy Shareable URL of Settings</span>
|
|
4611
|
+
</button>
|
|
3293
4612
|
</div>
|
|
3294
4613
|
</div>
|
|
3295
|
-
` : ''}
|
|
3296
4614
|
</main>
|
|
3297
4615
|
|
|
3298
4616
|
<footer class="cv-modal-footer">
|
|
3299
4617
|
${this.options.showReset ? `
|
|
3300
|
-
<button class="cv-reset-btn">
|
|
4618
|
+
<button class="cv-reset-btn" title="Reset to Default">
|
|
3301
4619
|
<span class="cv-reset-btn-icon">${getResetIcon()}</span>
|
|
3302
|
-
<span>Reset
|
|
3303
|
-
</button>
|
|
3304
|
-
` : ''}
|
|
3305
|
-
<button class="cv-share-btn">
|
|
3306
|
-
<span>Copy Shareable URL</span>
|
|
3307
|
-
<span class="cv-share-btn-icon">${getCopyIcon()}</span>
|
|
4620
|
+
<span>Reset</span>
|
|
3308
4621
|
</button>
|
|
4622
|
+
` : '<div></div>'}
|
|
4623
|
+
|
|
4624
|
+
<a href="https://github.com/customviews-js/customviews" target="_blank" class="cv-footer-link">
|
|
4625
|
+
${getGitHubIcon()}
|
|
4626
|
+
<span>View on GitHub</span>
|
|
4627
|
+
</a>
|
|
4628
|
+
|
|
4629
|
+
<button class="cv-done-btn">Done</button>
|
|
3309
4630
|
</footer>
|
|
3310
4631
|
</div>
|
|
3311
4632
|
`;
|
|
@@ -3326,20 +4647,6 @@ class CustomViewsWidget {
|
|
|
3326
4647
|
this.closeModal();
|
|
3327
4648
|
return;
|
|
3328
4649
|
}
|
|
3329
|
-
// Copy URL button
|
|
3330
|
-
if (target.closest('.cv-share-btn')) {
|
|
3331
|
-
this.copyShareableURL();
|
|
3332
|
-
const copyUrlBtn = target.closest('.cv-share-btn');
|
|
3333
|
-
const iconContainer = copyUrlBtn?.querySelector('.cv-share-btn-icon');
|
|
3334
|
-
if (iconContainer) {
|
|
3335
|
-
const originalIcon = iconContainer.innerHTML;
|
|
3336
|
-
iconContainer.innerHTML = getTickIcon();
|
|
3337
|
-
setTimeout(() => {
|
|
3338
|
-
iconContainer.innerHTML = originalIcon;
|
|
3339
|
-
}, 3000);
|
|
3340
|
-
}
|
|
3341
|
-
return;
|
|
3342
|
-
}
|
|
3343
4650
|
// Reset to default button
|
|
3344
4651
|
if (target.closest('.cv-reset-btn')) {
|
|
3345
4652
|
const resetBtn = target.closest('.cv-reset-btn');
|
|
@@ -3356,6 +4663,11 @@ class CustomViewsWidget {
|
|
|
3356
4663
|
}, 600);
|
|
3357
4664
|
return;
|
|
3358
4665
|
}
|
|
4666
|
+
// Done button
|
|
4667
|
+
if (target.closest('.cv-done-btn')) {
|
|
4668
|
+
this.closeModal();
|
|
4669
|
+
return;
|
|
4670
|
+
}
|
|
3359
4671
|
// Overlay click to close
|
|
3360
4672
|
if (e.target === this.stateModal) {
|
|
3361
4673
|
this.closeModal();
|
|
@@ -3416,10 +4728,10 @@ class CustomViewsWidget {
|
|
|
3416
4728
|
navIcon.innerHTML = isVisible ? getNavHeadingOnIcon() : getNavHeadingOffIcon();
|
|
3417
4729
|
}
|
|
3418
4730
|
};
|
|
3419
|
-
navHeaderCard.addEventListener('mouseenter', () => updateIcon(tabNavToggle.checked, true));
|
|
3420
|
-
navHeaderCard.addEventListener('mouseleave', () => updateIcon(tabNavToggle.checked, false));
|
|
4731
|
+
navHeaderCard.addEventListener('mouseenter', () => updateIcon(!tabNavToggle.checked, true));
|
|
4732
|
+
navHeaderCard.addEventListener('mouseleave', () => updateIcon(!tabNavToggle.checked, false));
|
|
3421
4733
|
tabNavToggle.addEventListener('change', () => {
|
|
3422
|
-
const visible = tabNavToggle.checked;
|
|
4734
|
+
const visible = !tabNavToggle.checked;
|
|
3423
4735
|
updateIcon(visible, false);
|
|
3424
4736
|
this.core.persistTabNavVisibility(visible);
|
|
3425
4737
|
try {
|
|
@@ -3430,6 +4742,48 @@ class CustomViewsWidget {
|
|
|
3430
4742
|
}
|
|
3431
4743
|
});
|
|
3432
4744
|
}
|
|
4745
|
+
// Tab switching
|
|
4746
|
+
const tabs = this.stateModal.querySelectorAll('.cv-modal-tab');
|
|
4747
|
+
tabs.forEach(tab => {
|
|
4748
|
+
tab.addEventListener('click', () => {
|
|
4749
|
+
const tabId = tab.dataset.tab;
|
|
4750
|
+
if (tabId === 'customize' || tabId === 'share') {
|
|
4751
|
+
this.currentTab = tabId;
|
|
4752
|
+
// Update UI without full re-render
|
|
4753
|
+
tabs.forEach(t => t.classList.remove('active'));
|
|
4754
|
+
tab.classList.add('active');
|
|
4755
|
+
const contents = this.stateModal?.querySelectorAll('.cv-tab-content');
|
|
4756
|
+
contents?.forEach(c => {
|
|
4757
|
+
c.classList.remove('active');
|
|
4758
|
+
if (c.dataset.content === tabId) {
|
|
4759
|
+
c.classList.add('active');
|
|
4760
|
+
}
|
|
4761
|
+
});
|
|
4762
|
+
}
|
|
4763
|
+
});
|
|
4764
|
+
});
|
|
4765
|
+
// Share buttons (inside content)
|
|
4766
|
+
const startShareBtn = this.stateModal.querySelector('.cv-start-share-btn');
|
|
4767
|
+
if (startShareBtn) {
|
|
4768
|
+
startShareBtn.addEventListener('click', () => {
|
|
4769
|
+
this.closeModal();
|
|
4770
|
+
this.core.toggleShareMode();
|
|
4771
|
+
});
|
|
4772
|
+
}
|
|
4773
|
+
const copyUrlBtn = this.stateModal.querySelector('.cv-copy-url-btn');
|
|
4774
|
+
if (copyUrlBtn) {
|
|
4775
|
+
copyUrlBtn.addEventListener('click', () => {
|
|
4776
|
+
this.copyShareableURL();
|
|
4777
|
+
const iconContainer = copyUrlBtn.querySelector('.cv-btn-icon');
|
|
4778
|
+
if (iconContainer) {
|
|
4779
|
+
const originalIcon = iconContainer.innerHTML;
|
|
4780
|
+
iconContainer.innerHTML = getTickIcon();
|
|
4781
|
+
setTimeout(() => {
|
|
4782
|
+
iconContainer.innerHTML = originalIcon;
|
|
4783
|
+
}, 2000);
|
|
4784
|
+
}
|
|
4785
|
+
});
|
|
4786
|
+
}
|
|
3433
4787
|
}
|
|
3434
4788
|
/**
|
|
3435
4789
|
* Apply theme class to the modal overlay based on options
|
|
@@ -3525,7 +4879,7 @@ class CustomViewsWidget {
|
|
|
3525
4879
|
const tabNavToggle = this.stateModal.querySelector('.cv-nav-pref-input');
|
|
3526
4880
|
const navIcon = this.stateModal?.querySelector('#cv-nav-icon');
|
|
3527
4881
|
if (tabNavToggle) {
|
|
3528
|
-
tabNavToggle.checked = navPref;
|
|
4882
|
+
tabNavToggle.checked = !navPref;
|
|
3529
4883
|
// Ensure UI matches actual visibility
|
|
3530
4884
|
TabManager.setNavsVisibility(document.body, navPref);
|
|
3531
4885
|
// Update the nav icon to reflect the current state
|