@customviews-js/customviews 1.2.0 → 1.3.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.
Files changed (42) hide show
  1. package/dist/custom-views.core.cjs.js +1235 -14
  2. package/dist/custom-views.core.cjs.js.map +1 -1
  3. package/dist/custom-views.core.esm.js +1235 -14
  4. package/dist/custom-views.core.esm.js.map +1 -1
  5. package/dist/custom-views.esm.js +1235 -14
  6. package/dist/custom-views.esm.js.map +1 -1
  7. package/dist/custom-views.js +1235 -14
  8. package/dist/custom-views.js.map +1 -1
  9. package/dist/custom-views.min.js +2 -2
  10. package/dist/custom-views.min.js.map +1 -1
  11. package/dist/types/core/anchor-engine.d.ts +55 -0
  12. package/dist/types/core/anchor-engine.d.ts.map +1 -0
  13. package/dist/types/core/config.d.ts +3 -0
  14. package/dist/types/core/config.d.ts.map +1 -0
  15. package/dist/types/core/core.d.ts +10 -1
  16. package/dist/types/core/core.d.ts.map +1 -1
  17. package/dist/types/core/custom-elements.d.ts +2 -2
  18. package/dist/types/core/custom-elements.d.ts.map +1 -1
  19. package/dist/types/core/focus-manager.d.ts +38 -0
  20. package/dist/types/core/focus-manager.d.ts.map +1 -0
  21. package/dist/types/core/share-button.d.ts +13 -0
  22. package/dist/types/core/share-button.d.ts.map +1 -0
  23. package/dist/types/core/share-manager.d.ts +70 -0
  24. package/dist/types/core/share-manager.d.ts.map +1 -0
  25. package/dist/types/core/toast-manager.d.ts +12 -0
  26. package/dist/types/core/toast-manager.d.ts.map +1 -0
  27. package/dist/types/core/url-state-manager.d.ts +6 -2
  28. package/dist/types/core/url-state-manager.d.ts.map +1 -1
  29. package/dist/types/core/widget.d.ts.map +1 -1
  30. package/dist/types/styles/focus-mode-styles.d.ts +8 -0
  31. package/dist/types/styles/focus-mode-styles.d.ts.map +1 -0
  32. package/dist/types/styles/share-button-styles.d.ts +3 -0
  33. package/dist/types/styles/share-button-styles.d.ts.map +1 -0
  34. package/dist/types/styles/share-mode-styles.d.ts +10 -0
  35. package/dist/types/styles/share-mode-styles.d.ts.map +1 -0
  36. package/dist/types/styles/toast-styles.d.ts +4 -0
  37. package/dist/types/styles/toast-styles.d.ts.map +1 -0
  38. package/dist/types/types/types.d.ts +7 -0
  39. package/dist/types/types/types.d.ts.map +1 -1
  40. package/dist/types/utils/icons.d.ts +2 -0
  41. package/dist/types/utils/icons.d.ts.map +1 -1
  42. package/package.json +2 -2
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * @customviews-js/customviews v1.2.0
2
+ * @customviews-js/customviews v1.3.0
3
3
  * (c) 2025 Chan Ger Teck
4
4
  * Released under the MIT License.
5
5
  */
@@ -160,7 +160,9 @@
160
160
  return url.toString();
161
161
  }
162
162
  /**
163
- * Encode state into URL-safe string (Toggles and Tabs only currently)
163
+ * Encode state into URL-safe string
164
+ *
165
+ * (Covers Toggles, Tabs and Focus currently)
164
166
  */
165
167
  static encodeState(state) {
166
168
  try {
@@ -174,6 +176,10 @@
174
176
  if (state.tabs && Object.keys(state.tabs).length > 0) {
175
177
  compact.g = Object.entries(state.tabs);
176
178
  }
179
+ // Add focus if present
180
+ if (state.focus && state.focus.length > 0) {
181
+ compact.f = state.focus;
182
+ }
177
183
  // Convert to JSON and encode
178
184
  const json = JSON.stringify(compact);
179
185
  let encoded;
@@ -195,7 +201,9 @@
195
201
  }
196
202
  }
197
203
  /**
198
- * Decode custom state from URL parameter (Toggles and Tabs only currently)
204
+ * Decode custom state from URL parameter
205
+ *
206
+ * (Covers Toggles, Tabs and Focus currently)
199
207
  */
200
208
  static decodeState(encoded) {
201
209
  try {
@@ -234,6 +242,10 @@
234
242
  }
235
243
  }
236
244
  }
245
+ // Reconstruct Focus
246
+ if (Array.isArray(compact.f)) {
247
+ state.focus = compact.f;
248
+ }
237
249
  return state;
238
250
  }
239
251
  catch (error) {
@@ -418,6 +430,12 @@
418
430
  </svg>
419
431
  `.trim();
420
432
  }
433
+ function getShareIcon() {
434
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
435
+ <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"/>
436
+ <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"/>
437
+ </svg>`;
438
+ }
421
439
 
422
440
  // Constants for selectors
423
441
  const TABGROUP_SELECTOR$1 = 'cv-tabgroup';
@@ -1382,6 +1400,1185 @@ ${TAB_STYLES}
1382
1400
  document.head.appendChild(style);
1383
1401
  }
1384
1402
 
1403
+ const TOAST_STYLE_ID = 'cv-toast-styles';
1404
+ const TOAST_CLASS = 'cv-toast-notification';
1405
+ const TOAST_STYLES = `
1406
+ .cv-toast-notification {
1407
+ position: fixed;
1408
+ top: 20px;
1409
+ left: 50%;
1410
+ transform: translateX(-50%);
1411
+ background-color: #323232;
1412
+ color: white;
1413
+ padding: 12px 24px;
1414
+ border-radius: 4px;
1415
+ z-index: 100000;
1416
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
1417
+ opacity: 0;
1418
+ transition: opacity 0.3s ease;
1419
+ pointer-events: none; /* Let clicks pass through if needed, though usually it blocks */
1420
+ font-family: system-ui, -apple-system, sans-serif;
1421
+ font-size: 14px;
1422
+ }
1423
+ `;
1424
+
1425
+ /**
1426
+ * Manages toast notifications for the application.
1427
+ */
1428
+ class ToastManager {
1429
+ static isStyleInjected = false;
1430
+ static toastEl = null;
1431
+ static navTimeout = null;
1432
+ static fadeTimeout = null;
1433
+ static show(message, duration = 2500) {
1434
+ this.injectStyles();
1435
+ // specific reuse logic
1436
+ if (!this.toastEl) {
1437
+ this.toastEl = document.createElement('div');
1438
+ this.toastEl.className = TOAST_CLASS;
1439
+ document.body.appendChild(this.toastEl);
1440
+ }
1441
+ // Reset state
1442
+ this.toastEl.textContent = message;
1443
+ this.toastEl.style.opacity = '0';
1444
+ this.toastEl.style.display = 'block';
1445
+ // Clear any pending dismissal
1446
+ if (this.navTimeout)
1447
+ clearTimeout(this.navTimeout);
1448
+ if (this.fadeTimeout)
1449
+ clearTimeout(this.fadeTimeout);
1450
+ // Trigger reflow & fade in
1451
+ requestAnimationFrame(() => {
1452
+ if (this.toastEl)
1453
+ this.toastEl.style.opacity = '1';
1454
+ });
1455
+ // Schedule fade out
1456
+ this.navTimeout = setTimeout(() => {
1457
+ if (this.toastEl)
1458
+ this.toastEl.style.opacity = '0';
1459
+ this.fadeTimeout = setTimeout(() => {
1460
+ if (this.toastEl)
1461
+ this.toastEl.style.display = 'none';
1462
+ }, 300);
1463
+ }, duration);
1464
+ }
1465
+ static injectStyles() {
1466
+ if (this.isStyleInjected)
1467
+ return;
1468
+ if (document.getElementById(TOAST_STYLE_ID)) {
1469
+ this.isStyleInjected = true;
1470
+ return;
1471
+ }
1472
+ const style = document.createElement('style');
1473
+ style.id = TOAST_STYLE_ID;
1474
+ style.innerHTML = TOAST_STYLES;
1475
+ document.head.appendChild(style);
1476
+ this.isStyleInjected = true;
1477
+ }
1478
+ }
1479
+
1480
+ /**
1481
+ * Engine for generating and resolving robust anchors.
1482
+ *
1483
+ * It implements a simple anchor generation and resolution algorithm that uses a combination of
1484
+ * structural, contextual, and content-based hints to generate a unique anchor for a given DOM element.
1485
+ *
1486
+ * The anchor is generated by first creating an AnchorDescriptor for the element, which contains
1487
+ * information about the element's tag, index, parent ID, and text content. This descriptor is then
1488
+ * serialized into a URL-safe string using a minification algorithm.
1489
+ *
1490
+ * The anchor is then resolved by searching for the element in the DOM using the serialized string.
1491
+ *
1492
+ */
1493
+ class AnchorEngine {
1494
+ /**
1495
+ * Generates a simple hash code for a string.
1496
+ *
1497
+ * It takes each character's Unicode code point and uses it to update the hash value.
1498
+ */
1499
+ static hashCode(str) {
1500
+ let hash = 0;
1501
+ if (str.length === 0)
1502
+ return hash;
1503
+ for (let i = 0; i < str.length; i++) {
1504
+ const char = str.charCodeAt(i);
1505
+ hash = ((hash << 5) - hash) + char;
1506
+ hash = hash & hash; // Convert to 32bit integer
1507
+ }
1508
+ return hash;
1509
+ }
1510
+ /**
1511
+ * Normalizes text content by removing excessive whitespace.
1512
+ *
1513
+ * It trims leading and trailing whitespace and replaces multiple spaces with a single space.
1514
+ */
1515
+ static normalizeText(text) {
1516
+ return text.trim().replace(/\s+/g, ' ');
1517
+ }
1518
+ /**
1519
+ * Creates an AnchorDescriptor for a given DOM element.
1520
+ */
1521
+ static createDescriptor(el) {
1522
+ const tag = el.tagName;
1523
+ const textContent = el.textContent || "";
1524
+ const normalizedText = this.normalizeText(textContent);
1525
+ // Find nearest parent with an ID
1526
+ let parentId;
1527
+ let parent = el.parentElement;
1528
+ while (parent) {
1529
+ if (parent.id) {
1530
+ parentId = parent.id;
1531
+ break;
1532
+ }
1533
+ parent = parent.parentElement;
1534
+ }
1535
+ // Calculate index relative to the container (either the found parent or document.body)
1536
+ const container = parent || document.body;
1537
+ const siblings = Array.from(container.querySelectorAll(tag));
1538
+ // Index is the position of the element in the list of siblings, where siblings are those of the same tag.
1539
+ const index = siblings.indexOf(el);
1540
+ const descriptor = {
1541
+ tag,
1542
+ index: index !== -1 ? index : 0,
1543
+ textSnippet: normalizedText.substring(0, 32),
1544
+ textHash: this.hashCode(normalizedText)
1545
+ };
1546
+ if (parentId) {
1547
+ descriptor.parentId = parentId;
1548
+ }
1549
+ return descriptor;
1550
+ }
1551
+ /**
1552
+ * Serializes a list of AnchorDescriptors into a URL-safe string.
1553
+ */
1554
+ static serialize(descriptors) {
1555
+ // Minify keys for compactness
1556
+ const minified = descriptors.map(d => ({
1557
+ t: d.tag,
1558
+ i: d.index,
1559
+ p: d.parentId,
1560
+ s: d.textSnippet,
1561
+ h: d.textHash
1562
+ }));
1563
+ const json = JSON.stringify(minified);
1564
+ // Base64 encode
1565
+ return btoa(encodeURIComponent(json));
1566
+ }
1567
+ /**
1568
+ * Deserializes a URL-safe string back into a list of AnchorDescriptors.
1569
+ */
1570
+ static deserialize(encoded) {
1571
+ try {
1572
+ const json = decodeURIComponent(atob(encoded));
1573
+ const minified = JSON.parse(json);
1574
+ return minified.map((m) => ({
1575
+ tag: m.t,
1576
+ index: m.i,
1577
+ parentId: m.p,
1578
+ textSnippet: m.s,
1579
+ textHash: m.h
1580
+ }));
1581
+ }
1582
+ catch (e) {
1583
+ console.error("Failed to deserialize anchor:", e);
1584
+ return [];
1585
+ }
1586
+ }
1587
+ /**
1588
+ * Finds the best DOM element match for a descriptor.
1589
+ */
1590
+ static resolve(root, descriptor) {
1591
+ // 1. Scope
1592
+ let scope = root;
1593
+ if (descriptor.parentId) {
1594
+ const foundParent = root.querySelector(`#${descriptor.parentId}`);
1595
+ if (foundParent instanceof HTMLElement) {
1596
+ scope = foundParent;
1597
+ }
1598
+ else {
1599
+ // Fallback: if parent ID not found, search global root - document.body
1600
+ const globalParent = document.getElementById(descriptor.parentId);
1601
+ if (globalParent) {
1602
+ scope = globalParent;
1603
+ }
1604
+ }
1605
+ }
1606
+ // 2. Candidate Search
1607
+ const candidates = Array.from(scope.querySelectorAll(descriptor.tag));
1608
+ // 3. Scoring
1609
+ let bestMatch = null;
1610
+ let highestScore = 0;
1611
+ candidates.forEach((candidate) => {
1612
+ let score = 0;
1613
+ const text = this.normalizeText(candidate.textContent || "");
1614
+ // Exact Text Match (Hash check is faster proxy for full string compare, but let's check hash first)
1615
+ if (this.hashCode(text) === descriptor.textHash) {
1616
+ score += 50;
1617
+ }
1618
+ else if (text.startsWith(descriptor.textSnippet)) {
1619
+ // Fuzzy Text Match (Snippet) - +30 score
1620
+ score += 30;
1621
+ }
1622
+ // Structural Match (Index)
1623
+ // We need to re-calculate index of this candidate to compare with descriptor.index
1624
+ // The descriptor.index is relative to the *found* parentId container.
1625
+ // So we must compare index within the scope we are searching.
1626
+ const siblings = Array.from(scope.querySelectorAll(descriptor.tag));
1627
+ const index = siblings.indexOf(candidate);
1628
+ if (index === descriptor.index) {
1629
+ score += 10;
1630
+ }
1631
+ if (score > highestScore) {
1632
+ highestScore = score;
1633
+ bestMatch = candidate;
1634
+ }
1635
+ });
1636
+ // 4. Winner
1637
+ if (highestScore > 30) {
1638
+ return bestMatch;
1639
+ }
1640
+ return null;
1641
+ }
1642
+ }
1643
+
1644
+ const SHARE_MODE_STYLE_ID = 'cv-share-mode-styles';
1645
+ const FLOATING_ACTION_BAR_ID = 'cv-floating-action-bar';
1646
+ const HOVER_HELPER_ID = 'cv-hover-helper';
1647
+ const HIGHLIGHT_TARGET_CLASS = 'cv-highlight-target';
1648
+ const SELECTED_CLASS = 'cv-share-selected';
1649
+ /**
1650
+ * CSS styles to be injected during Share Mode.
1651
+ */
1652
+ const SHARE_MODE_STYLES = `
1653
+ body.cv-share-mode {
1654
+ cursor: default;
1655
+ }
1656
+
1657
+ /* Highlight outlines */
1658
+ .${HIGHLIGHT_TARGET_CLASS} {
1659
+ outline: 2px dashed #0078D4 !important;
1660
+ outline-offset: 2px;
1661
+ cursor: crosshair;
1662
+ }
1663
+
1664
+ .${SELECTED_CLASS} {
1665
+ outline: 3px solid #005a9e !important;
1666
+ outline-offset: 2px;
1667
+ background-color: rgba(0, 120, 212, 0.05);
1668
+ }
1669
+
1670
+ /* Floating Action Bar */
1671
+ #${FLOATING_ACTION_BAR_ID} {
1672
+ position: fixed;
1673
+ bottom: 20px;
1674
+ left: 50%;
1675
+ transform: translateX(-50%);
1676
+ background-color: #2c2c2c;
1677
+ color: #f1f1f1;
1678
+ border-radius: 8px;
1679
+ padding: 12px 20px;
1680
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
1681
+ display: flex;
1682
+ align-items: center;
1683
+ gap: 16px;
1684
+ z-index: 99999;
1685
+ font-family: system-ui, -apple-system, sans-serif;
1686
+ font-size: 14px;
1687
+ border: 1px solid #4a4a4a;
1688
+ }
1689
+
1690
+ #${FLOATING_ACTION_BAR_ID} .cv-action-button {
1691
+ background-color: #0078D4;
1692
+ color: white;
1693
+ border: none;
1694
+ padding: 8px 14px;
1695
+ border-radius: 5px;
1696
+ cursor: pointer;
1697
+ font-weight: 500;
1698
+ transition: background-color 0.2s;
1699
+ }
1700
+
1701
+ #${FLOATING_ACTION_BAR_ID} .cv-action-button:hover {
1702
+ background-color: #005a9e;
1703
+ }
1704
+
1705
+ #${FLOATING_ACTION_BAR_ID} .cv-action-button.clear {
1706
+ background-color: #5a5a5a;
1707
+ }
1708
+ #${FLOATING_ACTION_BAR_ID} .cv-action-button.clear:hover {
1709
+ background-color: #4a4a4a;
1710
+ }
1711
+ #${FLOATING_ACTION_BAR_ID} .cv-action-button.clear:hover {
1712
+ background-color: #4a4a4a;
1713
+ }
1714
+
1715
+ #${FLOATING_ACTION_BAR_ID} .cv-action-button.preview {
1716
+ background-color: #106ebe;
1717
+ }
1718
+ #${FLOATING_ACTION_BAR_ID} .cv-action-button.preview:hover {
1719
+ background-color: #005a9e;
1720
+ }
1721
+ #${FLOATING_ACTION_BAR_ID} .cv-action-button.exit {
1722
+ background-color: #d13438;
1723
+ }
1724
+ #${FLOATING_ACTION_BAR_ID} .cv-action-button.exit:hover {
1725
+ background-color: #a42628;
1726
+ }
1727
+
1728
+ /* Hover Helper (Smart Label & Level Up) */
1729
+ #${HOVER_HELPER_ID} {
1730
+ position: fixed;
1731
+ z-index: 99999;
1732
+ background-color: #333;
1733
+ color: white;
1734
+ padding: 4px 8px;
1735
+ border-radius: 4px;
1736
+ font-size: 12px;
1737
+ font-family: monospace;
1738
+ display: none;
1739
+ pointer-events: auto; /* Allow clicking buttons inside */
1740
+ align-items: center;
1741
+ gap: 8px;
1742
+ box-shadow: 0 2px 5px rgba(0,0,0,0.2);
1743
+ }
1744
+
1745
+ #${HOVER_HELPER_ID} button {
1746
+ background: #555;
1747
+ border: none;
1748
+ color: white;
1749
+ border-radius: 3px;
1750
+ cursor: pointer;
1751
+ padding: 2px 6px;
1752
+ font-size: 14px;
1753
+ line-height: 1;
1754
+ }
1755
+ #${HOVER_HELPER_ID} button:hover {
1756
+ background: #777;
1757
+ }
1758
+
1759
+ `;
1760
+
1761
+ const CV_CUSTOM_ELEMENTS = 'cv-tabgroup, cv-toggle';
1762
+ const SHAREABLE_SELECTOR = 'div, p, blockquote, pre, li, h1, h2, h3, h4, h5, h6, [data-share], ' + CV_CUSTOM_ELEMENTS;
1763
+ /**
1764
+ * Manages the "Share Mode" for creating custom focus links.
1765
+ * Implementing Robust Granular Sharing with "Innermost Wins" and "Level Up" UI.
1766
+ */
1767
+ class ShareManager {
1768
+ isActive = false;
1769
+ selectedElements = new Set();
1770
+ floatingBarEl = null;
1771
+ helperEl = null;
1772
+ currentHoverTarget = null;
1773
+ excludedTags;
1774
+ excludedIds;
1775
+ boundHandleHover;
1776
+ boundHandleClick;
1777
+ boundHandleKeydown;
1778
+ constructor(options) {
1779
+ this.excludedTags = new Set(options.excludedTags.map(t => t.toUpperCase()));
1780
+ this.excludedIds = new Set(options.excludedIds);
1781
+ this.boundHandleHover = this.handleHover.bind(this);
1782
+ this.boundHandleClick = this.handleClick.bind(this);
1783
+ this.boundHandleKeydown = this.handleKeydown.bind(this);
1784
+ }
1785
+ listeners = [];
1786
+ addStateChangeListener(listener) {
1787
+ this.listeners.push(listener);
1788
+ }
1789
+ removeStateChangeListener(listener) {
1790
+ this.listeners = this.listeners.filter(l => l !== listener);
1791
+ }
1792
+ notifyListeners() {
1793
+ this.listeners.forEach(listener => listener(this.isActive));
1794
+ }
1795
+ toggleShareMode() {
1796
+ this.isActive = !this.isActive;
1797
+ if (this.isActive) {
1798
+ this.activate();
1799
+ }
1800
+ else {
1801
+ this.cleanup();
1802
+ }
1803
+ this.notifyListeners();
1804
+ }
1805
+ /**
1806
+ * Activates the share mode.
1807
+ * Injects styles, creates floating bar, and helper element.
1808
+ * Adds event listeners for hover and click.
1809
+ */
1810
+ activate() {
1811
+ this.injectStyles();
1812
+ this.createFloatingBar();
1813
+ this.helperEl = this.createHelperPopover();
1814
+ // Event Listeners
1815
+ document.addEventListener('mouseover', this.boundHandleHover, true);
1816
+ document.addEventListener('click', this.boundHandleClick, true);
1817
+ document.addEventListener('keydown', this.boundHandleKeydown, true);
1818
+ }
1819
+ injectStyles() {
1820
+ const styleElement = document.createElement('style');
1821
+ styleElement.id = SHARE_MODE_STYLE_ID;
1822
+ styleElement.innerHTML = SHARE_MODE_STYLES;
1823
+ document.head.appendChild(styleElement);
1824
+ }
1825
+ /**
1826
+ * Creates the hover helper element that shows up when hovering over a shareable element.
1827
+ */
1828
+ createHelperPopover() {
1829
+ const div = document.createElement('div');
1830
+ div.id = HOVER_HELPER_ID;
1831
+ div.innerHTML = `
1832
+ <span id="cv-helper-tag">TAG</span>
1833
+ <button id="cv-helper-select-btn" title="Select This Element">✓</button>
1834
+ <button id="cv-helper-up-btn" title="Select Parent">↰</button>
1835
+ `;
1836
+ document.body.appendChild(div);
1837
+ // Select parent button
1838
+ div.querySelector('#cv-helper-up-btn')?.addEventListener('click', (e) => {
1839
+ e.preventDefault();
1840
+ e.stopPropagation();
1841
+ this.handleSelectParent();
1842
+ });
1843
+ // Select element button
1844
+ div.querySelector('#cv-helper-select-btn')?.addEventListener('click', (e) => {
1845
+ e.preventDefault();
1846
+ e.stopPropagation();
1847
+ if (this.currentHoverTarget) {
1848
+ this.toggleSelection(this.currentHoverTarget);
1849
+ }
1850
+ });
1851
+ return div;
1852
+ }
1853
+ /**
1854
+ * Handles mouse hover events.
1855
+ *
1856
+ * This function is called when the user hovers over an element.
1857
+ * It checks if the element is shareable and highlights it.
1858
+ * If a parent element is already selected, it highlights the parent instead,
1859
+ * allowing the helper to remain visible for the selected parent.
1860
+ *
1861
+ * @param e The mouse event triggered by the hover.
1862
+ */
1863
+ handleHover(e) {
1864
+ if (!this.isActive)
1865
+ return;
1866
+ // Check if we are hovering over the helper itself
1867
+ if (this.helperEl && this.helperEl.contains(e.target)) {
1868
+ return;
1869
+ }
1870
+ const target = e.target;
1871
+ // Exclude by Tag or ID
1872
+ const upperTag = target.tagName.toUpperCase();
1873
+ if (this.excludedTags.has(upperTag) || (target.id && this.excludedIds.has(target.id))) {
1874
+ return;
1875
+ }
1876
+ // Check closest excluded (for nested elements in excluded regions)
1877
+ let ancestor = target.parentElement;
1878
+ while (ancestor) {
1879
+ if (this.excludedTags.has(ancestor.tagName.toUpperCase()) || (ancestor.id && this.excludedIds.has(ancestor.id))) {
1880
+ return;
1881
+ }
1882
+ ancestor = ancestor.parentElement;
1883
+ }
1884
+ // Find closest shareable parent element
1885
+ const shareablePart = target.closest(SHAREABLE_SELECTOR);
1886
+ if (!shareablePart) {
1887
+ this.clearHover();
1888
+ return;
1889
+ }
1890
+ // Cast to HTMLElement
1891
+ const finalTarget = shareablePart;
1892
+ // Check if any ancestor is already selected. If so, do NOT highlight this child.
1893
+ // Instead, highlight (or keep highlighted) the SELECTED PARENT so the user can see the helper for it.
1894
+ let parent = finalTarget.parentElement;
1895
+ let selectedAncestor = null;
1896
+ // Loop outwards until we find a selected parent or reach the top
1897
+ while (parent) {
1898
+ if (this.selectedElements.has(parent)) {
1899
+ selectedAncestor = parent;
1900
+ break;
1901
+ }
1902
+ parent = parent.parentElement;
1903
+ }
1904
+ if (selectedAncestor) {
1905
+ // If we are hovering deep inside a selected block, show the helper for that block
1906
+ this.setNewHoverTarget(selectedAncestor);
1907
+ return;
1908
+ }
1909
+ // stop bubbling to parent
1910
+ // when element found for highlight
1911
+ e.stopPropagation();
1912
+ // If we are already on this target, do nothing (and keep it selected/highlighted)
1913
+ if (this.currentHoverTarget === finalTarget)
1914
+ return;
1915
+ // Highlight
1916
+ this.setNewHoverTarget(finalTarget);
1917
+ }
1918
+ setNewHoverTarget(target) {
1919
+ if (this.currentHoverTarget) {
1920
+ this.currentHoverTarget.classList.remove(HIGHLIGHT_TARGET_CLASS);
1921
+ }
1922
+ this.currentHoverTarget = target;
1923
+ this.currentHoverTarget.classList.add(HIGHLIGHT_TARGET_CLASS);
1924
+ this.positionHelper(target);
1925
+ }
1926
+ positionHelper(target) {
1927
+ if (!this.helperEl)
1928
+ return;
1929
+ const rect = target.getBoundingClientRect();
1930
+ const tagLabel = this.helperEl.querySelector('#cv-helper-tag');
1931
+ const upBtn = this.helperEl.querySelector('#cv-helper-up-btn');
1932
+ if (tagLabel)
1933
+ tagLabel.textContent = target.tagName;
1934
+ // Position at top-right of the element
1935
+ // Prevent going off-screen
1936
+ let top = rect.top - 20;
1937
+ if (top < 0)
1938
+ top = rect.top + 10; // Flip down if too close to top
1939
+ let left = rect.right - 80;
1940
+ if (left < 0)
1941
+ left = 10;
1942
+ this.helperEl.style.display = 'flex';
1943
+ this.helperEl.style.top = `${top}px`;
1944
+ this.helperEl.style.left = `${left}px`;
1945
+ // Update Select Button State (Tick or Cross)
1946
+ const selectBtn = this.helperEl.querySelector('#cv-helper-select-btn');
1947
+ if (selectBtn) {
1948
+ if (this.selectedElements.has(target)) {
1949
+ selectBtn.textContent = '✕';
1950
+ selectBtn.title = 'Deselect This Element';
1951
+ selectBtn.style.backgroundColor = '#d13438'; // Reddish
1952
+ }
1953
+ else {
1954
+ selectBtn.textContent = '✓';
1955
+ selectBtn.title = 'Select This Element';
1956
+ selectBtn.style.backgroundColor = ''; // Reset
1957
+ }
1958
+ }
1959
+ // Ancestry Check
1960
+ const parent = target.parentElement;
1961
+ const parentIsShareable = parent && parent.matches(SHAREABLE_SELECTOR);
1962
+ if (parentIsShareable) {
1963
+ upBtn.style.display = 'inline-block';
1964
+ }
1965
+ else {
1966
+ upBtn.style.display = 'none';
1967
+ }
1968
+ }
1969
+ handleSelectParent() {
1970
+ if (this.currentHoverTarget && this.currentHoverTarget.parentElement) {
1971
+ const parent = this.currentHoverTarget.parentElement;
1972
+ if (parent.matches(SHAREABLE_SELECTOR)) {
1973
+ this.setNewHoverTarget(parent);
1974
+ }
1975
+ }
1976
+ }
1977
+ handleClick(e) {
1978
+ if (!this.isActive)
1979
+ return;
1980
+ // If clicking helper
1981
+ if (this.helperEl && this.helperEl.contains(e.target))
1982
+ return;
1983
+ // If clicking floating bar
1984
+ if (this.floatingBarEl && this.floatingBarEl.contains(e.target))
1985
+ return;
1986
+ e.preventDefault();
1987
+ e.stopPropagation();
1988
+ if (this.currentHoverTarget) {
1989
+ this.toggleSelection(this.currentHoverTarget);
1990
+ }
1991
+ }
1992
+ handleKeydown(e) {
1993
+ if (!this.isActive)
1994
+ return;
1995
+ if (e.key === 'Escape') {
1996
+ e.preventDefault();
1997
+ e.stopPropagation();
1998
+ this.toggleShareMode();
1999
+ }
2000
+ }
2001
+ /**
2002
+ * Toggles the selection state of a given HTML element.
2003
+ * Implements selection logic:
2004
+ * - If an ancestor of the element is already selected, the click is ignored.
2005
+ * - If the element being selected is a parent of already selected elements, those children are deselected.
2006
+ * @param el The HTMLElement to toggle selection for.
2007
+ */
2008
+ toggleSelection(el) {
2009
+ if (this.selectedElements.has(el)) {
2010
+ this.selectedElements.delete(el);
2011
+ el.classList.remove(SELECTED_CLASS);
2012
+ }
2013
+ else {
2014
+ // Selection Logic
2015
+ // Scenario A: Selecting a Parent -> Remove children selected while selecting parent
2016
+ // Scenario B: Selecting a Child -> Ignore if ancestor selected (or handle)
2017
+ // B. Check if any ancestor is already selected, return if any (Scenario B)
2018
+ let parent = el.parentElement;
2019
+ while (parent) {
2020
+ if (this.selectedElements.has(parent)) {
2021
+ // Ancestor is selected. Ignore click.
2022
+ return;
2023
+ }
2024
+ parent = parent.parentElement;
2025
+ }
2026
+ // A. Check if any children are selected (Scenario A)
2027
+ // We must iterate over currently selected elements
2028
+ const toRemove = [];
2029
+ this.selectedElements.forEach(selected => {
2030
+ if (el.contains(selected) && el !== selected) {
2031
+ toRemove.push(selected);
2032
+ }
2033
+ });
2034
+ toRemove.forEach(child => {
2035
+ this.selectedElements.delete(child);
2036
+ child.classList.remove(SELECTED_CLASS);
2037
+ });
2038
+ // Add new selection
2039
+ this.selectedElements.add(el);
2040
+ el.classList.add(SELECTED_CLASS);
2041
+ }
2042
+ this.updateFloatingBarCount();
2043
+ }
2044
+ createFloatingBar() {
2045
+ const bar = document.createElement('div');
2046
+ bar.id = FLOATING_ACTION_BAR_ID;
2047
+ bar.innerHTML = `
2048
+ <span id="cv-selected-count">0 items selected</span>
2049
+ <button class="cv-action-button clear">Clear All</button>
2050
+ <button class="cv-action-button preview">Preview</button>
2051
+ <button class="cv-action-button generate">Generate Link</button>
2052
+ <button class="cv-action-button exit">Exit</button>
2053
+ `;
2054
+ document.body.appendChild(bar);
2055
+ this.floatingBarEl = bar;
2056
+ bar.querySelector('.clear')?.addEventListener('click', () => this.clearAll());
2057
+ bar.querySelector('.preview')?.addEventListener('click', () => this.previewLink());
2058
+ bar.querySelector('.generate')?.addEventListener('click', () => this.generateLink());
2059
+ bar.querySelector('.exit')?.addEventListener('click', () => this.toggleShareMode());
2060
+ }
2061
+ updateFloatingBarCount() {
2062
+ if (this.floatingBarEl) {
2063
+ const countElement = this.floatingBarEl.querySelector('#cv-selected-count');
2064
+ if (countElement) {
2065
+ const count = this.selectedElements.size;
2066
+ countElement.textContent = `${count} item${count === 1 ? '' : 's'} selected`;
2067
+ }
2068
+ }
2069
+ }
2070
+ clearAll() {
2071
+ this.selectedElements.forEach(el => el.classList.remove('cv-share-selected'));
2072
+ this.selectedElements.clear();
2073
+ this.updateFloatingBarCount();
2074
+ }
2075
+ getShareUrl() {
2076
+ if (this.selectedElements.size === 0) {
2077
+ return null;
2078
+ }
2079
+ const descriptors = Array.from(this.selectedElements).map(el => AnchorEngine.createDescriptor(el));
2080
+ const serialized = AnchorEngine.serialize(descriptors);
2081
+ const url = new URL(window.location.href);
2082
+ url.searchParams.set('cv-focus', serialized);
2083
+ return url;
2084
+ }
2085
+ async generateLink() {
2086
+ const url = this.getShareUrl();
2087
+ if (!url) {
2088
+ ToastManager.show('Please select at least one item.');
2089
+ return;
2090
+ }
2091
+ try {
2092
+ await navigator.clipboard.writeText(url.toString());
2093
+ ToastManager.show('Link copied to clipboard!');
2094
+ }
2095
+ catch (e) {
2096
+ console.error('Clipboard failed', e);
2097
+ ToastManager.show('Failed to copy link.');
2098
+ }
2099
+ }
2100
+ previewLink() {
2101
+ const url = this.getShareUrl();
2102
+ if (!url) {
2103
+ ToastManager.show('Please select at least one item.');
2104
+ return;
2105
+ }
2106
+ window.open(url.toString(), '_blank');
2107
+ }
2108
+ clearHover() {
2109
+ if (this.currentHoverTarget) {
2110
+ this.currentHoverTarget.classList.remove(HIGHLIGHT_TARGET_CLASS);
2111
+ this.currentHoverTarget = null;
2112
+ }
2113
+ if (this.helperEl) {
2114
+ this.helperEl.style.display = 'none';
2115
+ }
2116
+ }
2117
+ cleanup() {
2118
+ document.body.classList.remove('cv-share-mode');
2119
+ this.clearAll();
2120
+ const style = document.getElementById(SHARE_MODE_STYLE_ID);
2121
+ if (style)
2122
+ document.head.removeChild(style);
2123
+ if (this.floatingBarEl) {
2124
+ document.body.removeChild(this.floatingBarEl);
2125
+ this.floatingBarEl = null;
2126
+ }
2127
+ if (this.helperEl) {
2128
+ document.body.removeChild(this.helperEl);
2129
+ this.helperEl = null;
2130
+ }
2131
+ if (this.currentHoverTarget) {
2132
+ this.currentHoverTarget.classList.remove(HIGHLIGHT_TARGET_CLASS);
2133
+ this.currentHoverTarget = null;
2134
+ }
2135
+ document.removeEventListener('mouseover', this.boundHandleHover, true);
2136
+ document.removeEventListener('click', this.boundHandleClick, true);
2137
+ document.removeEventListener('keydown', this.boundHandleKeydown, true);
2138
+ this.isActive = false;
2139
+ }
2140
+ }
2141
+
2142
+ const SHARE_BUTTON_ID = 'cv-share-button';
2143
+ const SHARE_BUTTON_STYLES = `
2144
+ #${SHARE_BUTTON_ID} {
2145
+ position: fixed;
2146
+ bottom: 20px;
2147
+ right: 100px;
2148
+ width: 50px;
2149
+ height: 50px;
2150
+ border-radius: 50%;
2151
+ background-color: #007bff; /* Primary Blue */
2152
+ color: white;
2153
+ border: none;
2154
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08); /* Drop shadow */
2155
+ z-index: 9998; /* Below modals (9999) but above content */
2156
+ cursor: pointer;
2157
+ display: flex;
2158
+ align-items: center;
2159
+ justify-content: center;
2160
+ transition: opacity 0.3s ease, transform 0.3s ease, background-color 0.2s;
2161
+ opacity: 1;
2162
+ transform: scale(1);
2163
+ padding: 0;
2164
+ margin: 0;
2165
+ }
2166
+
2167
+ #${SHARE_BUTTON_ID}:hover {
2168
+ background-color: #0056b3; /* Darker blue on hover */
2169
+ transform: scale(1.05);
2170
+ }
2171
+
2172
+ #${SHARE_BUTTON_ID}:active {
2173
+ transform: scale(0.95);
2174
+ }
2175
+
2176
+ #${SHARE_BUTTON_ID}.cv-hidden {
2177
+ opacity: 0;
2178
+ pointer-events: none;
2179
+ transform: scale(0.8);
2180
+ }
2181
+
2182
+ @media print {
2183
+ #${SHARE_BUTTON_ID} {
2184
+ display: none !important;
2185
+ }
2186
+ }
2187
+
2188
+ #${SHARE_BUTTON_ID} svg {
2189
+ width: 24px;
2190
+ height: 24px;
2191
+ fill: currentColor;
2192
+ }
2193
+ `;
2194
+
2195
+ class ShareButton {
2196
+ shareManager;
2197
+ button = null;
2198
+ boundHandleClick;
2199
+ constructor(shareManager) {
2200
+ this.shareManager = shareManager;
2201
+ this.boundHandleClick = () => this.shareManager.toggleShareMode();
2202
+ }
2203
+ init() {
2204
+ if (this.button)
2205
+ return;
2206
+ this.injectStyles();
2207
+ this.button = this.createButton();
2208
+ document.body.appendChild(this.button);
2209
+ // Subscribe to share manager state changes
2210
+ this.shareManager.addStateChangeListener((isActive) => {
2211
+ this.setShareModeActive(isActive);
2212
+ });
2213
+ }
2214
+ destroy() {
2215
+ if (this.button) {
2216
+ this.button.remove();
2217
+ this.button = null;
2218
+ }
2219
+ }
2220
+ createButton() {
2221
+ const btn = document.createElement('button');
2222
+ btn.id = SHARE_BUTTON_ID;
2223
+ btn.setAttribute('aria-label', 'Share specific sections');
2224
+ btn.title = 'Select sections to share';
2225
+ // Using the link icon from utils, ensuring it's white via CSS currentColor
2226
+ btn.innerHTML = getShareIcon();
2227
+ btn.addEventListener('click', this.boundHandleClick);
2228
+ return btn;
2229
+ }
2230
+ injectStyles() {
2231
+ if (!document.getElementById('cv-share-button-styles')) {
2232
+ const style = document.createElement('style');
2233
+ style.id = 'cv-share-button-styles';
2234
+ style.textContent = SHARE_BUTTON_STYLES;
2235
+ document.head.appendChild(style);
2236
+ }
2237
+ }
2238
+ setShareModeActive(isActive) {
2239
+ if (!this.button)
2240
+ return;
2241
+ if (isActive) {
2242
+ this.button.classList.add('cv-hidden');
2243
+ }
2244
+ else {
2245
+ this.button.classList.remove('cv-hidden');
2246
+ }
2247
+ }
2248
+ }
2249
+
2250
+ const FOCUS_MODE_STYLE_ID = 'cv-focus-mode-styles';
2251
+ const BODY_FOCUS_CLASS = 'cv-focus-mode';
2252
+ const HIDDEN_CLASS = 'cv-focus-hidden';
2253
+ const FOCUSED_CLASS = 'cv-focused-element';
2254
+ const DIVIDER_CLASS = 'cv-context-divider';
2255
+ const EXIT_BANNER_ID = 'cv-exit-focus-banner';
2256
+ const styles = `
2257
+ body.${BODY_FOCUS_CLASS} {
2258
+ /* e.g. potentially hide scrollbars or adjust layout */
2259
+ }
2260
+
2261
+ .${HIDDEN_CLASS} {
2262
+ display: none !important;
2263
+ }
2264
+
2265
+ .${FOCUSED_CLASS} {
2266
+ /* No visual style for focused elements, just logic class for now. Can add borders for debugging*/
2267
+ }
2268
+
2269
+ .${DIVIDER_CLASS} {
2270
+ padding: 12px;
2271
+ margin: 16px 0;
2272
+ background-color: #f8f8f8;
2273
+ border-top: 1px dashed #ccc;
2274
+ border-bottom: 1px dashed #ccc;
2275
+ color: #555;
2276
+ text-align: center;
2277
+ cursor: pointer;
2278
+ font-family: system-ui, sans-serif;
2279
+ font-size: 13px;
2280
+ transition: background-color 0.2s;
2281
+ }
2282
+ .${DIVIDER_CLASS}:hover {
2283
+ background-color: #e8e8e8;
2284
+ color: #333;
2285
+ }
2286
+
2287
+ #${EXIT_BANNER_ID} {
2288
+ position: sticky;
2289
+ top: 0;
2290
+ left: 0;
2291
+ right: 0;
2292
+ background-color: #0078D4;
2293
+ color: white;
2294
+ padding: 10px 20px;
2295
+ display: flex;
2296
+ align-items: center;
2297
+ justify-content: center;
2298
+ gap: 16px;
2299
+ z-index: 100000;
2300
+ font-family: system-ui, sans-serif;
2301
+ box-shadow: 0 2px 8px rgba(0,0,0,0.2);
2302
+ }
2303
+
2304
+ #${EXIT_BANNER_ID} button {
2305
+ background: white;
2306
+ color: #0078D4;
2307
+ border: none;
2308
+ padding: 4px 12px;
2309
+ border-radius: 4px;
2310
+ cursor: pointer;
2311
+ font-weight: 600;
2312
+ }
2313
+ #${EXIT_BANNER_ID} button:hover {
2314
+ background: #f0f0f0;
2315
+ }
2316
+ `;
2317
+ const FOCUS_MODE_STYLES = styles;
2318
+
2319
+ /**
2320
+ * Manages the "Focus Mode" (Presentation View).
2321
+ * Parses the URL for robust anchors, resolves them, hides irrelevant content, and inserts context dividers.
2322
+ */
2323
+ const FOCUS_PARAM = 'cv-focus';
2324
+ class FocusManager {
2325
+ rootEl;
2326
+ hiddenElements = new Set();
2327
+ dividers = [];
2328
+ exitBanner = null;
2329
+ excludedTags;
2330
+ excludedIds;
2331
+ constructor(rootEl, options) {
2332
+ this.rootEl = rootEl;
2333
+ this.excludedTags = new Set(options.excludedTags.map(t => t.toUpperCase()));
2334
+ this.excludedIds = new Set(options.excludedIds);
2335
+ }
2336
+ /**
2337
+ * Initializes the Focus Manager. Checks URL for focus parameter.
2338
+ */
2339
+ init() {
2340
+ this.handleUrlChange();
2341
+ }
2342
+ handleUrlChange() {
2343
+ const urlParams = new URLSearchParams(window.location.search);
2344
+ const encodedDescriptors = urlParams.get(FOCUS_PARAM);
2345
+ if (encodedDescriptors) {
2346
+ this.applyFocusMode(encodedDescriptors);
2347
+ }
2348
+ else {
2349
+ // encoding missing, ensure we exit focus mode if active
2350
+ if (document.body.classList.contains(BODY_FOCUS_CLASS)) {
2351
+ this.exitFocusMode();
2352
+ }
2353
+ }
2354
+ }
2355
+ /**
2356
+ * Applies Focus Mode based on encoded descriptors.
2357
+ */
2358
+ applyFocusMode(encodedDescriptors) {
2359
+ const descriptors = AnchorEngine.deserialize(encodedDescriptors);
2360
+ if (!descriptors || descriptors.length === 0)
2361
+ return;
2362
+ // Resolve anchors to DOM elements
2363
+ const targets = [];
2364
+ descriptors.forEach(desc => {
2365
+ const el = AnchorEngine.resolve(this.rootEl, desc);
2366
+ if (el) {
2367
+ targets.push(el);
2368
+ }
2369
+ });
2370
+ if (targets.length === 0) {
2371
+ ToastManager.show("Some shared sections could not be found.");
2372
+ return;
2373
+ }
2374
+ if (targets.length < descriptors.length) {
2375
+ ToastManager.show("Some shared sections could not be found.");
2376
+ }
2377
+ this.injectStyles();
2378
+ document.body.classList.add(BODY_FOCUS_CLASS);
2379
+ this.renderFocusedView(targets);
2380
+ this.showExitBanner();
2381
+ }
2382
+ injectStyles() {
2383
+ if (document.getElementById(FOCUS_MODE_STYLE_ID))
2384
+ return;
2385
+ const style = document.createElement('style');
2386
+ style.id = FOCUS_MODE_STYLE_ID;
2387
+ style.textContent = FOCUS_MODE_STYLES;
2388
+ document.head.appendChild(style);
2389
+ }
2390
+ /**
2391
+ * Hides irrelevant content and adds dividers.
2392
+ */
2393
+ renderFocusedView(targets) {
2394
+ // 1. Mark targets
2395
+ targets.forEach(t => t.classList.add(FOCUSED_CLASS));
2396
+ // 2. We need to hide siblings of targets (and their ancestors up to root generally,
2397
+ // but "siblings between focused zones" suggests we are mostly looking at a flat list or specific nesting).
2398
+ //
2399
+ // "All sibling elements between the focused zones are collapsed and hidden."
2400
+ // "If a user selects a parent element, all of its child elements must be visible."
2401
+ //
2402
+ // Algorithm:
2403
+ // Walk up from each target to finding the common container?
2404
+ // Or just assume targets are somewhat related.
2405
+ //
2406
+ // Let's implement a robust "Hide Siblings" approach.
2407
+ // For every target, we ensure it is visible.
2408
+ // We look at its siblings. If a sibling is NOT a target AND NOT an ancestor of a target, we hide it.
2409
+ // We need to identify all "Keep Visible" elements (targets + ancestors)
2410
+ const keepVisible = new Set();
2411
+ targets.forEach(t => {
2412
+ let curr = t;
2413
+ while (curr && curr !== document.body && curr !== document.documentElement) {
2414
+ keepVisible.add(curr);
2415
+ curr = curr.parentElement;
2416
+ }
2417
+ });
2418
+ // Now iterate through siblings of "Keep Visible" elements?
2419
+ // Actually, we can just walk the tree or iterate siblings of targets/ancestors?
2420
+ //
2421
+ // Improved Algorithm:
2422
+ // 1. Collect all direct siblings of every element in keepVisible set.
2423
+ // 2. If a sibling is NOT in keepVisible, hide it.
2424
+ // To avoid processing the entire DOM, we start from targets and walk up.
2425
+ keepVisible.forEach(el => {
2426
+ if (el === document.body)
2427
+ return; // Don't hide siblings of body (scripts etc) unless we are sure.
2428
+ // Actually usually we want to hide siblings of the content container.
2429
+ const parent = el.parentElement;
2430
+ if (!parent)
2431
+ return;
2432
+ // FIX: "Parent Dominance"
2433
+ // If the parent itself is a target (or we otherwise decided its whole content is meaningful),
2434
+ // then we should NOT hide anything inside it.
2435
+ // We check if 'parent' is one of the explicitly resolved targets.
2436
+ // We can check if it has the FOCUSED_CLASS class, since we added it in step 1.
2437
+ if (parent.classList.contains(FOCUSED_CLASS)) {
2438
+ return;
2439
+ }
2440
+ // Using children because we want element nodes
2441
+ Array.from(parent.children).forEach(child => {
2442
+ if (child instanceof HTMLElement && !keepVisible.has(child)) {
2443
+ this.hideElement(child);
2444
+ }
2445
+ });
2446
+ });
2447
+ // 3. Insert Dividers
2448
+ // We process each container that has hidden elements
2449
+ const processedContainers = new Set();
2450
+ keepVisible.forEach(el => {
2451
+ const parent = el.parentElement;
2452
+ if (parent && !processedContainers.has(parent)) {
2453
+ this.insertDividersForContainer(parent);
2454
+ processedContainers.add(parent);
2455
+ }
2456
+ });
2457
+ }
2458
+ hideElement(el) {
2459
+ if (this.hiddenElements.has(el))
2460
+ return; // Already hidden
2461
+ // Exclude by Tag
2462
+ if (this.excludedTags.has(el.tagName.toUpperCase()))
2463
+ return;
2464
+ // Exclude by ID (if strictly matching)
2465
+ if (el.id && this.excludedIds.has(el.id))
2466
+ return;
2467
+ // Also don't hide things that are aria-hidden
2468
+ if (el.getAttribute('aria-hidden') === 'true')
2469
+ return;
2470
+ // Exclude Toast Notification
2471
+ if (el.classList.contains(TOAST_CLASS))
2472
+ return;
2473
+ // We check if it is already hidden (e.g. by previous focus mode run? No, isActive check handles that)
2474
+ // Just mark it.
2475
+ el.classList.add(HIDDEN_CLASS);
2476
+ this.hiddenElements.add(el);
2477
+ }
2478
+ insertDividersForContainer(container) {
2479
+ const children = Array.from(container.children);
2480
+ let hiddenCount = 0;
2481
+ let hiddenGroupStart = null;
2482
+ children.forEach((child) => {
2483
+ if (child.classList.contains(HIDDEN_CLASS)) {
2484
+ if (hiddenCount === 0)
2485
+ hiddenGroupStart = child;
2486
+ hiddenCount++;
2487
+ }
2488
+ else {
2489
+ // Found a visible element. Was there a hidden group before this?
2490
+ if (hiddenCount > 0 && hiddenGroupStart) {
2491
+ this.createDivider(container, hiddenGroupStart, hiddenCount);
2492
+ hiddenCount = 0;
2493
+ hiddenGroupStart = null;
2494
+ }
2495
+ }
2496
+ });
2497
+ // Trailing hidden group
2498
+ if (hiddenCount > 0 && hiddenGroupStart) {
2499
+ this.createDivider(container, hiddenGroupStart, hiddenCount);
2500
+ }
2501
+ }
2502
+ createDivider(container, insertBeforeEl, count) {
2503
+ const divider = document.createElement('div');
2504
+ divider.className = DIVIDER_CLASS;
2505
+ divider.textContent = `... ${count} section${count > 1 ? 's' : ''} hidden (Click to expand) ...`;
2506
+ divider.onclick = () => this.expandContext(insertBeforeEl, count, divider);
2507
+ container.insertBefore(divider, insertBeforeEl);
2508
+ this.dividers.push(divider);
2509
+ }
2510
+ expandContext(firstHidden, count, divider) {
2511
+ // Divider is inserted BEFORE firstHidden.
2512
+ // So firstHidden is the first element to reveal.
2513
+ let curr = firstHidden;
2514
+ let expanded = 0;
2515
+ while (curr && expanded < count) {
2516
+ if (curr instanceof HTMLElement && curr.classList.contains(HIDDEN_CLASS)) {
2517
+ curr.classList.remove(HIDDEN_CLASS);
2518
+ this.hiddenElements.delete(curr);
2519
+ }
2520
+ curr = curr.nextElementSibling;
2521
+ // Note: If nested dividers or other elements exist, they shouldn't count?
2522
+ // "Children" iteration in insertDividers covered direct children.
2523
+ // sibling iteration also covers direct children.
2524
+ // We assume contiguous hidden siblings.
2525
+ expanded++;
2526
+ }
2527
+ divider.remove();
2528
+ const idx = this.dividers.indexOf(divider);
2529
+ if (idx > -1)
2530
+ this.dividers.splice(idx, 1);
2531
+ // If no more hidden elements, remove the banner
2532
+ if (this.hiddenElements.size === 0) {
2533
+ this.removeExitBanner();
2534
+ }
2535
+ }
2536
+ removeExitBanner() {
2537
+ if (this.exitBanner) {
2538
+ this.exitBanner.remove();
2539
+ this.exitBanner = null;
2540
+ }
2541
+ }
2542
+ /**
2543
+ * Override of renderFocusedView with robust logic
2544
+ */
2545
+ // (We use the class method `renderFocusedView` and internal helpers)
2546
+ showExitBanner() {
2547
+ if (document.getElementById(EXIT_BANNER_ID))
2548
+ return;
2549
+ const banner = document.createElement('div');
2550
+ banner.id = EXIT_BANNER_ID;
2551
+ banner.innerHTML = `
2552
+ <span>You are viewing a focused selection.</span>
2553
+ <button id="cv-exit-focus-btn">Show Full Page</button>
2554
+ `;
2555
+ document.body.prepend(banner); // Top of body
2556
+ banner.querySelector('button')?.addEventListener('click', () => this.exitFocusMode());
2557
+ this.exitBanner = banner;
2558
+ }
2559
+ exitFocusMode() {
2560
+ document.body.classList.remove(BODY_FOCUS_CLASS);
2561
+ // Show all hidden elements
2562
+ this.hiddenElements.forEach(el => el.classList.remove(HIDDEN_CLASS));
2563
+ this.hiddenElements.clear();
2564
+ // Remove dividers
2565
+ this.dividers.forEach(d => d.remove());
2566
+ this.dividers = [];
2567
+ // Remove styling from targets
2568
+ const targets = document.querySelectorAll(`.${FOCUSED_CLASS}`);
2569
+ targets.forEach(t => t.classList.remove(FOCUSED_CLASS));
2570
+ // Remove banner
2571
+ this.removeExitBanner();
2572
+ // Update URL
2573
+ const url = new URL(window.location.href);
2574
+ url.searchParams.delete(FOCUS_PARAM);
2575
+ window.history.pushState({}, '', url.toString());
2576
+ }
2577
+ }
2578
+
2579
+ const DEFAULT_EXCLUDED_TAGS = ['HEADER', 'NAV', 'FOOTER', 'SCRIPT', 'STYLE'];
2580
+ const DEFAULT_EXCLUDED_IDS = ['cv-floating-action-bar', 'cv-hover-helper', 'cv-toast-notification'];
2581
+
1385
2582
  const TOGGLE_SELECTOR = "[data-cv-toggle], [data-customviews-toggle], cv-toggle";
1386
2583
  const TABGROUP_SELECTOR = 'cv-tabgroup';
1387
2584
  class CustomViewsCore {
@@ -1390,6 +2587,9 @@ ${TAB_STYLES}
1390
2587
  persistenceManager;
1391
2588
  visibilityManager;
1392
2589
  observer = null;
2590
+ shareManager;
2591
+ shareButton;
2592
+ focusManager;
1393
2593
  componentRegistry = {
1394
2594
  toggles: new Set(),
1395
2595
  tabGroups: new Set(),
@@ -1406,6 +2606,22 @@ ${TAB_STYLES}
1406
2606
  this.visibilityManager = new VisibilityManager();
1407
2607
  this.showUrlEnabled = opt.showUrl ?? false;
1408
2608
  this.lastAppliedState = this.cloneState(this.getComputedDefaultState());
2609
+ // Resolve Exclusions
2610
+ const excludedTags = [...DEFAULT_EXCLUDED_TAGS, ...(this.config.shareExclusions?.tags || [])];
2611
+ const excludedIds = [...DEFAULT_EXCLUDED_IDS, ...(this.config.shareExclusions?.ids || [])];
2612
+ const commonOptions = { excludedTags, excludedIds };
2613
+ this.shareManager = new ShareManager(commonOptions);
2614
+ this.shareButton = new ShareButton(this.shareManager);
2615
+ this.focusManager = new FocusManager(this.rootEl, commonOptions);
2616
+ }
2617
+ getShareManager() {
2618
+ return this.shareManager;
2619
+ }
2620
+ /**
2621
+ * Toggles the share mode on or off.
2622
+ */
2623
+ toggleShareMode() {
2624
+ this.shareManager.toggleShareMode();
1409
2625
  }
1410
2626
  /**
1411
2627
  * Scan the given element for toggles and tab groups, register them
@@ -1438,7 +2654,7 @@ ${TAB_STYLES}
1438
2654
  return newComponentsFound;
1439
2655
  }
1440
2656
  /**
1441
- * Unscan the given element for toggles and tab groups, de-register them
2657
+ * Unscan the given element for toggles and tab groups, de-register them from registry
1442
2658
  */
1443
2659
  unscan(element) {
1444
2660
  // Unscan for toggles
@@ -1542,8 +2758,11 @@ ${TAB_STYLES}
1542
2758
  // For session history, clicks on back/forward button
1543
2759
  window.addEventListener("popstate", () => {
1544
2760
  this.loadAndCallApplyState();
2761
+ this.focusManager.handleUrlChange();
1545
2762
  });
1546
2763
  this.loadAndCallApplyState();
2764
+ this.focusManager.init();
2765
+ this.shareButton.init();
1547
2766
  this.initObserver();
1548
2767
  }
1549
2768
  initializeNewComponents() {
@@ -1565,7 +2784,7 @@ ${TAB_STYLES}
1565
2784
  const currentToggles = this.getCurrentActiveToggles();
1566
2785
  const newState = {
1567
2786
  toggles: currentToggles,
1568
- tabs: currentTabs
2787
+ tabs: currentTabs,
1569
2788
  };
1570
2789
  // 2. Apply state with scroll anchor information
1571
2790
  this.applyState(newState, {
@@ -1833,10 +3052,11 @@ ${TAB_STYLES}
1833
3052
  }
1834
3053
 
1835
3054
  /**
1836
- * Custom Elements for Tab Groups and Tabs
3055
+ * Defines the custom elements used by CustomViews.
1837
3056
  */
1838
3057
  /**
1839
- * <cv-tab> element - represents a single tab panel
3058
+ * `<cv-tab>`: A custom element representing a single tab panel within a tab group.
3059
+ * Its content is displayed when the corresponding tab is active.
1840
3060
  */
1841
3061
  class CVTab extends HTMLElement {
1842
3062
  connectedCallback() {
@@ -1844,7 +3064,8 @@ ${TAB_STYLES}
1844
3064
  }
1845
3065
  }
1846
3066
  /**
1847
- * <cv-tabgroup> element - represents a group of tabs
3067
+ * `<cv-tabgroup>`: A custom element that encapsulates a set of tabs (`<cv-tab>`).
3068
+ * It manages the tab navigation and content visibility for the group.
1848
3069
  */
1849
3070
  class CVTabgroup extends HTMLElement {
1850
3071
  connectedCallback() {
@@ -1860,7 +3081,7 @@ ${TAB_STYLES}
1860
3081
  }
1861
3082
  }
1862
3083
  /**
1863
- * <cv-toggle> element - represents a toggleable content block
3084
+ * `<cv-toggle>`: A custom element for creating a toggleable content block.
1864
3085
  */
1865
3086
  class CVToggle extends HTMLElement {
1866
3087
  connectedCallback() {
@@ -1868,8 +3089,8 @@ ${TAB_STYLES}
1868
3089
  }
1869
3090
  }
1870
3091
  /**
1871
- * <cv-tab-header> element - represents tab header with rich HTML formatting
1872
- * Content is extracted and used in the navigation link
3092
+ * `<cv-tab-header>`: A semantic container for a tab's header content.
3093
+ * The content of this element is used to create the navigation link for the tab.
1873
3094
  */
1874
3095
  class CVTabHeader extends HTMLElement {
1875
3096
  connectedCallback() {
@@ -1877,8 +3098,7 @@ ${TAB_STYLES}
1877
3098
  }
1878
3099
  }
1879
3100
  /**
1880
- * <cv-tab-body> element - represents tab body content
1881
- * Semantic container for tab panel content
3101
+ * `<cv-tab-body>`: A semantic container for the main content of a tab panel.
1882
3102
  */
1883
3103
  class CVTabBody extends HTMLElement {
1884
3104
  connectedCallback() {
@@ -1886,7 +3106,7 @@ ${TAB_STYLES}
1886
3106
  }
1887
3107
  }
1888
3108
  /**
1889
- * Register custom elements
3109
+ * Registers all CustomViews custom elements with the CustomElementRegistry.
1890
3110
  */
1891
3111
  function registerCustomElements() {
1892
3112
  // Only register if not already defined
@@ -3310,6 +4530,7 @@ ${TAB_STYLES}
3310
4530
  <span>Copy Shareable URL</span>
3311
4531
  <span class="cv-share-btn-icon">${getCopyIcon()}</span>
3312
4532
  </button>
4533
+
3313
4534
  </footer>
3314
4535
  </div>
3315
4536
  `;