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