@unhingged/vizu-core 0.1.13 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/auto.cjs CHANGED
@@ -1265,8 +1265,50 @@ var Popover = class {
1265
1265
  this.mentionPicker = null;
1266
1266
  /** Dismiss handlers wired to document for the open picker; cleared in closeMentionPicker. */
1267
1267
  this.mentionPickerDismiss = null;
1268
+ /** All mentionable users fetched for the open picker — pre-filter list. */
1269
+ this.mentionPickerUsers = [];
1270
+ /** Currently visible subset of `mentionPickerUsers` after the user's filter. */
1271
+ this.mentionPickerFiltered = [];
1272
+ /** Highlighted index in `mentionPickerFiltered`. Up/Down arrows mutate this. */
1273
+ this.mentionPickerSelected = 0;
1274
+ /**
1275
+ * 'manual' = opened from the @ mention button (separate search input).
1276
+ * 'editor' = opened by typing `@` in the editor; filter is derived
1277
+ * from the text after `@` in the editor itself, no search input.
1278
+ */
1279
+ this.mentionPickerMode = "manual";
1280
+ /**
1281
+ * In editor mode: a Range covering `@<query>` in the editor's text.
1282
+ * Used to replace that span with the mention chip on commit, and to
1283
+ * recompute the bounding rect when re-positioning after typing.
1284
+ */
1285
+ this.mentionPickerEditorRange = null;
1268
1286
  this.handleEditorKey = (e) => {
1269
1287
  const editor = e.currentTarget;
1288
+ if (this.mentionPicker && this.mentionPickerMode === "editor") {
1289
+ if (e.key === "ArrowDown") {
1290
+ e.preventDefault();
1291
+ this.moveMentionPickerSelection(1);
1292
+ return;
1293
+ }
1294
+ if (e.key === "ArrowUp") {
1295
+ e.preventDefault();
1296
+ this.moveMentionPickerSelection(-1);
1297
+ return;
1298
+ }
1299
+ if (e.key === "Enter" || e.key === "Tab") {
1300
+ if (this.mentionPickerFiltered.length > 0) {
1301
+ e.preventDefault();
1302
+ this.commitMentionPickerSelection();
1303
+ return;
1304
+ }
1305
+ }
1306
+ if (e.key === "Escape") {
1307
+ e.preventDefault();
1308
+ this.closeMentionPicker();
1309
+ return;
1310
+ }
1311
+ }
1270
1312
  if (e.key === "#") {
1271
1313
  e.preventDefault();
1272
1314
  const savedRange = this.saveRange();
@@ -1289,6 +1331,21 @@ var Popover = class {
1289
1331
  this.callbacks.onClose();
1290
1332
  }
1291
1333
  };
1334
+ /**
1335
+ * Fires after every keystroke in the editor (post-insertion). Looks
1336
+ * for an `@<query>` pattern adjacent to the caret. If found, opens
1337
+ * (or refreshes) the mention picker in editor mode. If the trigger
1338
+ * disappears (user typed a space, backspaced over @, etc.), closes
1339
+ * the picker.
1340
+ */
1341
+ this.handleEditorInput = () => {
1342
+ const trigger = this.detectMentionTrigger();
1343
+ if (trigger) {
1344
+ void this.openOrUpdateEditorMentionPicker(trigger.range, trigger.query);
1345
+ } else if (this.mentionPicker && this.mentionPickerMode === "editor") {
1346
+ this.closeMentionPicker();
1347
+ }
1348
+ };
1292
1349
  this.handleEditorPaste = (e) => {
1293
1350
  if (this.callbacks.canUploadAttachments()) {
1294
1351
  const items = e.clipboardData?.items ?? null;
@@ -1549,6 +1606,7 @@ var Popover = class {
1549
1606
  const editor = this.el.querySelector(".vz-textarea");
1550
1607
  editor.focus();
1551
1608
  editor.addEventListener("keydown", this.handleEditorKey);
1609
+ editor.addEventListener("input", this.handleEditorInput);
1552
1610
  editor.addEventListener("paste", this.handleEditorPaste);
1553
1611
  const wrap = this.el.querySelector(".vz-editor-wrap");
1554
1612
  if (wrap && this.callbacks.canUploadAttachments()) {
@@ -1578,6 +1636,37 @@ var Popover = class {
1578
1636
  <div class="vz-anchor-list">${chips}</div>
1579
1637
  `;
1580
1638
  }
1639
+ /**
1640
+ * Walk back from the caret looking for the most recent `@` that
1641
+ * starts a mention trigger — must be at start-of-text-node or
1642
+ * preceded by whitespace, and there must be no whitespace between
1643
+ * `@` and the caret. Returns the Range covering `@<query>` plus
1644
+ * the bare query string.
1645
+ */
1646
+ detectMentionTrigger() {
1647
+ const sel = window.getSelection();
1648
+ if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;
1649
+ const caret = sel.getRangeAt(0);
1650
+ const node = caret.startContainer;
1651
+ if (node.nodeType !== Node.TEXT_NODE) return null;
1652
+ const text = node.textContent ?? "";
1653
+ const offset = caret.startOffset;
1654
+ for (let i = offset - 1; i >= 0; i--) {
1655
+ const ch = text[i];
1656
+ if (ch === "@") {
1657
+ const prev = i > 0 ? text[i - 1] : "";
1658
+ if (prev !== "" && !/\s/.test(prev)) return null;
1659
+ const query = text.slice(i + 1, offset);
1660
+ if (/\s/.test(query)) return null;
1661
+ const range = document.createRange();
1662
+ range.setStart(node, i);
1663
+ range.setEnd(node, offset);
1664
+ return { range, query };
1665
+ }
1666
+ if (/\s/.test(ch)) return null;
1667
+ }
1668
+ return null;
1669
+ }
1581
1670
  async uploadAndAppend(file) {
1582
1671
  if (!this.callbacks.canUploadAttachments()) return;
1583
1672
  const placeholderId = "upload-" + Math.random().toString(36).slice(2, 9);
@@ -1708,54 +1797,195 @@ var Popover = class {
1708
1797
  return parts.join("").trim();
1709
1798
  }
1710
1799
  /**
1711
- * Open a dropdown listing the workspace's mentionable users. The user
1712
- * clicks one to insert a mention chip for them; Esc or an outside
1713
- * click closes the dropdown.
1714
- *
1715
- * The dropdown is fetched fresh on every open so member adds/removes
1716
- * in another tab are picked up without a popover reopen. Falls back
1717
- * to silent no-op if `onMentionSearch` isn't wired (non-cloud
1718
- * adapters) or returns an empty list.
1800
+ * Manual-mode picker: opens from the @ mention button. Renders a
1801
+ * search input above the list; the user types into the input to
1802
+ * filter, arrows + Enter to pick. The selection inserts a mention
1803
+ * chip at the editor's current caret (no `@<query>` replacement —
1804
+ * the user didn't type one).
1719
1805
  */
1720
1806
  async openMentionPicker(anchor) {
1721
1807
  this.closeMentionPicker();
1722
1808
  if (!this.callbacks.onMentionSearch) return;
1809
+ this.mentionPickerMode = "manual";
1810
+ this.mentionPickerEditorRange = null;
1811
+ const picker = this.createMentionPickerShell();
1812
+ this.positionMentionPickerByElement(picker, anchor);
1813
+ const users = await this.fetchMentionables();
1814
+ if (this.mentionPicker !== picker) return;
1815
+ this.mentionPickerUsers = users;
1816
+ this.renderMentionPickerBody("");
1817
+ this.positionMentionPickerByElement(picker, anchor);
1818
+ const input = picker.querySelector(".vz-mention-search");
1819
+ input?.focus();
1820
+ this.wireMentionPickerDismiss();
1821
+ }
1822
+ /**
1823
+ * Editor-mode picker: opens because the user typed `@` in the
1824
+ * editor. No search input — the editor IS the search; the query is
1825
+ * the text after `@`. On commit, replaces the `@<query>` range with
1826
+ * the chip.
1827
+ */
1828
+ async openOrUpdateEditorMentionPicker(range, query) {
1829
+ this.mentionPickerEditorRange = range;
1830
+ if (!this.mentionPicker || this.mentionPickerMode !== "editor") {
1831
+ this.closeMentionPicker();
1832
+ this.mentionPickerMode = "editor";
1833
+ const picker = this.createMentionPickerShell({ showSearch: false });
1834
+ this.positionMentionPickerByRange(picker, range);
1835
+ const users = await this.fetchMentionables();
1836
+ if (this.mentionPicker !== picker) return;
1837
+ this.mentionPickerUsers = users;
1838
+ this.renderMentionPickerBody(query);
1839
+ this.positionMentionPickerByRange(picker, range);
1840
+ this.wireMentionPickerDismiss();
1841
+ return;
1842
+ }
1843
+ this.renderMentionPickerBody(query);
1844
+ this.positionMentionPickerByRange(this.mentionPicker, range);
1845
+ }
1846
+ /**
1847
+ * Build the picker DOM scaffold and attach it to the popover. Does
1848
+ * NOT fetch users or render the body — caller's responsibility.
1849
+ * Search input is hidden in editor mode where the user already has
1850
+ * a perfectly good text-entry surface (the editor itself).
1851
+ */
1852
+ createMentionPickerShell({ showSearch = true } = {}) {
1723
1853
  const picker = document.createElement("div");
1724
1854
  picker.className = "vz-mention-picker";
1725
1855
  picker.setAttribute("data-vz-ignore", "");
1726
- picker.innerHTML = `<div class="vz-mention-loading">Loading\u2026</div>`;
1856
+ picker.innerHTML = `
1857
+ ${showSearch ? `<input class="vz-mention-search" type="text" placeholder="Search teammates\u2026" autocomplete="off" spellcheck="false" />` : ""}
1858
+ <div class="vz-mention-list" data-vz="mention-list">
1859
+ <div class="vz-mention-loading">Loading\u2026</div>
1860
+ </div>
1861
+ `;
1727
1862
  this.el.appendChild(picker);
1728
1863
  this.mentionPicker = picker;
1729
- this.positionMentionPicker(picker, anchor);
1730
- let users = [];
1864
+ this.mentionPickerFiltered = [];
1865
+ this.mentionPickerSelected = 0;
1866
+ picker.addEventListener("mousemove", (e) => {
1867
+ const btn = e.target?.closest(".vz-mention-item");
1868
+ if (!btn) return;
1869
+ const idx = Number(btn.getAttribute("data-mention-index") ?? -1);
1870
+ if (idx >= 0 && idx !== this.mentionPickerSelected) {
1871
+ this.mentionPickerSelected = idx;
1872
+ this.refreshMentionPickerHighlight();
1873
+ }
1874
+ });
1875
+ picker.addEventListener("click", (e) => {
1876
+ const btn = e.target?.closest(".vz-mention-item");
1877
+ if (!btn) return;
1878
+ e.preventDefault();
1879
+ e.stopPropagation();
1880
+ const idx = Number(btn.getAttribute("data-mention-index") ?? -1);
1881
+ if (idx >= 0) {
1882
+ this.mentionPickerSelected = idx;
1883
+ this.commitMentionPickerSelection();
1884
+ }
1885
+ });
1886
+ if (showSearch) {
1887
+ const input = picker.querySelector(".vz-mention-search");
1888
+ input?.addEventListener("input", () => this.renderMentionPickerBody(input.value));
1889
+ input?.addEventListener("keydown", (e) => {
1890
+ if (e.key === "ArrowDown") {
1891
+ e.preventDefault();
1892
+ this.moveMentionPickerSelection(1);
1893
+ } else if (e.key === "ArrowUp") {
1894
+ e.preventDefault();
1895
+ this.moveMentionPickerSelection(-1);
1896
+ } else if (e.key === "Enter" || e.key === "Tab") {
1897
+ if (this.mentionPickerFiltered.length > 0) {
1898
+ e.preventDefault();
1899
+ this.commitMentionPickerSelection();
1900
+ }
1901
+ } else if (e.key === "Escape") {
1902
+ e.preventDefault();
1903
+ this.closeMentionPicker();
1904
+ }
1905
+ });
1906
+ }
1907
+ return picker;
1908
+ }
1909
+ /** Fetch the mentionable list. Swallows errors → []. */
1910
+ async fetchMentionables() {
1911
+ if (!this.callbacks.onMentionSearch) return [];
1731
1912
  try {
1732
- users = await this.callbacks.onMentionSearch();
1913
+ return await this.callbacks.onMentionSearch();
1733
1914
  } catch {
1915
+ return [];
1734
1916
  }
1735
- if (this.mentionPicker !== picker) return;
1736
- if (users.length === 0) {
1737
- picker.innerHTML = `<div class="vz-mention-empty">No teammates to mention yet.</div>`;
1738
- this.positionMentionPicker(picker, anchor);
1739
- this.wireMentionPickerDismiss();
1917
+ }
1918
+ /**
1919
+ * Re-filter the cached users by `query`, render the list, reset
1920
+ * selected to 0. Called on every search input change and on every
1921
+ * editor input event while in editor mode.
1922
+ */
1923
+ renderMentionPickerBody(query) {
1924
+ const picker = this.mentionPicker;
1925
+ if (!picker) return;
1926
+ const list = picker.querySelector('[data-vz="mention-list"]');
1927
+ if (!list) return;
1928
+ const q = query.toLowerCase().trim();
1929
+ this.mentionPickerFiltered = q ? this.mentionPickerUsers.filter((u) => u.name.toLowerCase().includes(q)) : this.mentionPickerUsers.slice();
1930
+ this.mentionPickerSelected = 0;
1931
+ if (this.mentionPickerFiltered.length === 0) {
1932
+ list.innerHTML = `<div class="vz-mention-empty">${this.mentionPickerUsers.length === 0 ? "No teammates to mention yet." : "No teammates match."}</div>`;
1740
1933
  return;
1741
1934
  }
1742
- const itemsHtml = users.map((u) => {
1935
+ list.innerHTML = this.mentionPickerFiltered.map((u, i) => {
1743
1936
  const avatar = u.avatarUrl ? `<img class="vz-mention-avatar" src="${escapeHtml(u.avatarUrl)}" alt="" />` : `<span class="vz-mention-avatar vz-mention-avatar-initials">${escapeHtml(initials(u.name) || "?")}</span>`;
1744
- return `<button class="vz-mention-item" type="button" data-mention-id="${escapeHtml(u.id)}" data-mention-name="${escapeHtml(u.name)}">${avatar}<span class="vz-mention-item-name">${escapeHtml(u.name)}</span></button>`;
1937
+ return `<button class="vz-mention-item${i === 0 ? " is-selected" : ""}" type="button" data-mention-index="${i}">${avatar}<span class="vz-mention-item-name">${escapeHtml(u.name)}</span></button>`;
1745
1938
  }).join("");
1746
- picker.innerHTML = itemsHtml;
1747
- this.positionMentionPicker(picker, anchor);
1748
- picker.addEventListener("click", (e) => {
1749
- const btn = e.target?.closest(".vz-mention-item");
1750
- if (!btn) return;
1751
- e.preventDefault();
1752
- e.stopPropagation();
1753
- const id = btn.getAttribute("data-mention-id") || "";
1754
- const name = btn.getAttribute("data-mention-name") || "";
1755
- if (id && name) this.insertMentionChip({ id, name });
1756
- this.closeMentionPicker();
1757
- });
1758
- this.wireMentionPickerDismiss();
1939
+ }
1940
+ moveMentionPickerSelection(delta) {
1941
+ if (this.mentionPickerFiltered.length === 0) return;
1942
+ const n = this.mentionPickerFiltered.length;
1943
+ this.mentionPickerSelected = (this.mentionPickerSelected + delta + n) % n;
1944
+ this.refreshMentionPickerHighlight();
1945
+ }
1946
+ refreshMentionPickerHighlight() {
1947
+ const picker = this.mentionPicker;
1948
+ if (!picker) return;
1949
+ const items = picker.querySelectorAll(".vz-mention-item");
1950
+ items.forEach((el, i) => el.classList.toggle("is-selected", i === this.mentionPickerSelected));
1951
+ items[this.mentionPickerSelected]?.scrollIntoView({ block: "nearest" });
1952
+ }
1953
+ commitMentionPickerSelection() {
1954
+ const user = this.mentionPickerFiltered[this.mentionPickerSelected];
1955
+ if (!user) return;
1956
+ if (this.mentionPickerMode === "editor" && this.mentionPickerEditorRange) {
1957
+ this.replaceRangeWithMentionChip(this.mentionPickerEditorRange, user);
1958
+ } else {
1959
+ this.insertMentionChip(user);
1960
+ }
1961
+ this.closeMentionPicker();
1962
+ }
1963
+ /**
1964
+ * Swap a Range covering `@<query>` for a mention chip + trailing
1965
+ * space, then place the caret after the space so the user can keep
1966
+ * typing. Used only in editor-trigger mode.
1967
+ */
1968
+ replaceRangeWithMentionChip(range, user) {
1969
+ const editor = this.el.querySelector(".vz-textarea");
1970
+ if (!editor) return;
1971
+ const chip = document.createElement("span");
1972
+ chip.className = "vz-chip vz-chip-mention";
1973
+ chip.contentEditable = "false";
1974
+ chip.setAttribute("data-mention-id", user.id);
1975
+ chip.textContent = "@" + user.name;
1976
+ range.deleteContents();
1977
+ range.insertNode(chip);
1978
+ const space = document.createTextNode(" ");
1979
+ chip.after(space);
1980
+ const sel = window.getSelection();
1981
+ if (sel) {
1982
+ const after = document.createRange();
1983
+ after.setStartAfter(space);
1984
+ after.collapse(true);
1985
+ sel.removeAllRanges();
1986
+ sel.addRange(after);
1987
+ }
1988
+ editor.focus();
1759
1989
  }
1760
1990
  /** Wires Esc + outside-click handlers to close the open picker. */
1761
1991
  wireMentionPickerDismiss() {
@@ -1769,6 +1999,10 @@ var Popover = class {
1769
1999
  const onOutside = (e) => {
1770
2000
  if (!this.mentionPicker) return;
1771
2001
  if (this.mentionPicker.contains(e.target)) return;
2002
+ if (this.mentionPickerMode === "editor") {
2003
+ const editor = this.el.querySelector(".vz-textarea");
2004
+ if (editor && editor.contains(e.target)) return;
2005
+ }
1772
2006
  this.closeMentionPicker();
1773
2007
  };
1774
2008
  document.addEventListener("keydown", onKey, true);
@@ -1787,18 +2021,23 @@ var Popover = class {
1787
2021
  this.mentionPicker.remove();
1788
2022
  this.mentionPicker = null;
1789
2023
  }
2024
+ this.mentionPickerFiltered = [];
2025
+ this.mentionPickerSelected = 0;
2026
+ this.mentionPickerEditorRange = null;
1790
2027
  }
1791
- /**
1792
- * Place the picker below the anchor button. Stays within the
1793
- * popover's right edge so we don't overflow the popover frame.
1794
- */
1795
- positionMentionPicker(picker, anchor) {
2028
+ /** Position the picker below the given anchor (the @ mention button). */
2029
+ positionMentionPickerByElement(picker, anchor) {
1796
2030
  const popRect = this.el.getBoundingClientRect();
1797
2031
  const btnRect = anchor.getBoundingClientRect();
1798
- const left = btnRect.left - popRect.left;
1799
- const top = btnRect.bottom - popRect.top + 4;
1800
- picker.style.left = `${left}px`;
1801
- picker.style.top = `${top}px`;
2032
+ picker.style.left = `${btnRect.left - popRect.left}px`;
2033
+ picker.style.top = `${btnRect.bottom - popRect.top + 4}px`;
2034
+ }
2035
+ /** Position the picker below the `@<query>` text range in the editor. */
2036
+ positionMentionPickerByRange(picker, range) {
2037
+ const popRect = this.el.getBoundingClientRect();
2038
+ const rect = range.getBoundingClientRect();
2039
+ picker.style.left = `${rect.left - popRect.left}px`;
2040
+ picker.style.top = `${rect.bottom - popRect.top + 4}px`;
1802
2041
  }
1803
2042
  /**
1804
2043
  * Insert a mention chip for the given user into the editor at the
@@ -2677,6 +2916,26 @@ var STYLES = `
2677
2916
  flex-direction: column;
2678
2917
  gap: 1px;
2679
2918
  }
2919
+ .vz-mention-search {
2920
+ width: 100%;
2921
+ padding: 7px 9px;
2922
+ margin-bottom: 4px;
2923
+ background: rgba(255,255,255,0.04);
2924
+ border: 1px solid var(--vz-border, rgba(255,255,255,0.08));
2925
+ border-radius: 6px;
2926
+ color: #fafafa;
2927
+ font-size: 13px;
2928
+ outline: none;
2929
+ box-sizing: border-box;
2930
+ }
2931
+ .vz-mention-search:focus { border-color: var(--vz-accent, #ff8b6e); }
2932
+ .vz-mention-search::placeholder { color: var(--vz-text-muted); }
2933
+ .vz-mention-list {
2934
+ display: flex;
2935
+ flex-direction: column;
2936
+ gap: 1px;
2937
+ min-height: 0;
2938
+ }
2680
2939
  .vz-mention-loading,
2681
2940
  .vz-mention-empty {
2682
2941
  padding: 10px 12px;
@@ -2698,9 +2957,9 @@ var STYLES = `
2698
2957
  cursor: pointer;
2699
2958
  transition: background 80ms;
2700
2959
  }
2701
- .vz-mention-item:hover,
2960
+ .vz-mention-item.is-selected,
2702
2961
  .vz-mention-item:focus-visible {
2703
- background: var(--vz-bg-hover, rgba(255,255,255,0.06));
2962
+ background: var(--vz-bg-hover, rgba(255,255,255,0.08));
2704
2963
  outline: none;
2705
2964
  }
2706
2965
  .vz-mention-avatar {