@unhingged/vizu-core 0.1.12 → 0.1.13

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/index.d.cts CHANGED
@@ -677,6 +677,13 @@ declare class Vizu {
677
677
  off<E extends VizuEventName>(event: E, handler: VizuEventHandler<E>): void;
678
678
  setUser(user: VizuUser | null): void;
679
679
  getUser(): VizuUser | null;
680
+ /**
681
+ * Fetch the list of users who can be @-mentioned on the current
682
+ * workspace. Used by the popover's mention dropdown; hosts can also
683
+ * call this directly to build their own picker. Returns [] for
684
+ * non-cloud adapters (no workspace, no member list to fetch from).
685
+ */
686
+ searchMentionable(): Promise<MentionableUser[]>;
680
687
  /**
681
688
  * Whether the full Vizu UI (pill, highlighter, markers, popover) can
682
689
  * be mounted right now. Cloud-mode workspaces hold off until the
package/dist/index.d.ts CHANGED
@@ -677,6 +677,13 @@ declare class Vizu {
677
677
  off<E extends VizuEventName>(event: E, handler: VizuEventHandler<E>): void;
678
678
  setUser(user: VizuUser | null): void;
679
679
  getUser(): VizuUser | null;
680
+ /**
681
+ * Fetch the list of users who can be @-mentioned on the current
682
+ * workspace. Used by the popover's mention dropdown; hosts can also
683
+ * call this directly to build their own picker. Returns [] for
684
+ * non-cloud adapters (no workspace, no member list to fetch from).
685
+ */
686
+ searchMentionable(): Promise<MentionableUser[]>;
680
687
  /**
681
688
  * Whether the full Vizu UI (pill, highlighter, markers, popover) can
682
689
  * be mounted right now. Cloud-mode workspaces hold off until the
package/dist/index.js CHANGED
@@ -1235,6 +1235,10 @@ var Popover = class {
1235
1235
  this.pendingRefs = {};
1236
1236
  /** Attachments uploaded during this session, persisted into `comment.attachments` on save. */
1237
1237
  this.pendingAttachments = [];
1238
+ /** Open mention picker element, if any. Held so the next openMentionPicker / close calls can tear it down. */
1239
+ this.mentionPicker = null;
1240
+ /** Dismiss handlers wired to document for the open picker; cleared in closeMentionPicker. */
1241
+ this.mentionPickerDismiss = null;
1238
1242
  this.handleEditorKey = (e) => {
1239
1243
  const editor = e.currentTarget;
1240
1244
  if (e.key === "#") {
@@ -1341,7 +1345,7 @@ var Popover = class {
1341
1345
  else if (action === "save") this.save();
1342
1346
  else if (action === "mention") {
1343
1347
  e.preventDefault();
1344
- this.insertMentionChip(this.currentUser);
1348
+ this.openMentionPicker(target);
1345
1349
  } else if (action === "upload") {
1346
1350
  e.preventDefault();
1347
1351
  const input = this.el.querySelector('[data-vz="file-input"]');
@@ -1450,6 +1454,7 @@ var Popover = class {
1450
1454
  this.position();
1451
1455
  }
1452
1456
  close() {
1457
+ this.closeMentionPicker();
1453
1458
  this.el.style.display = "none";
1454
1459
  this.anchorTargets = [];
1455
1460
  this.anchorFingerprints = [];
@@ -1507,7 +1512,7 @@ var Popover = class {
1507
1512
  <div class="vz-popover-actions">
1508
1513
  <button class="vz-btn vz-btn-ghost" data-vz="close">Close</button>
1509
1514
  ${this.callbacks.canUploadAttachments() ? `<button class="vz-btn vz-btn-ghost vz-btn-upload" data-vz="upload" title="Attach an image">+ image</button>` : ""}
1510
- ${this.currentUser ? `<button class="vz-btn vz-btn-ghost vz-btn-mention" data-vz="mention" title="Mention ${escapeHtml(this.currentUser.name)}">@ mention</button>` : ""}
1515
+ ${this.currentUser ? `<button class="vz-btn vz-btn-ghost vz-btn-mention" data-vz="mention" title="Mention a teammate">@ mention</button>` : ""}
1511
1516
  <button class="vz-btn vz-btn-primary" data-vz="save">Save</button>
1512
1517
  </div>
1513
1518
  <input type="file" class="vz-file-input" data-vz="file-input" accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml" hidden />
@@ -1676,11 +1681,104 @@ var Popover = class {
1676
1681
  for (const child of editor.childNodes) walk(child);
1677
1682
  return parts.join("").trim();
1678
1683
  }
1684
+ /**
1685
+ * Open a dropdown listing the workspace's mentionable users. The user
1686
+ * clicks one to insert a mention chip for them; Esc or an outside
1687
+ * click closes the dropdown.
1688
+ *
1689
+ * The dropdown is fetched fresh on every open so member adds/removes
1690
+ * in another tab are picked up without a popover reopen. Falls back
1691
+ * to silent no-op if `onMentionSearch` isn't wired (non-cloud
1692
+ * adapters) or returns an empty list.
1693
+ */
1694
+ async openMentionPicker(anchor) {
1695
+ this.closeMentionPicker();
1696
+ if (!this.callbacks.onMentionSearch) return;
1697
+ const picker = document.createElement("div");
1698
+ picker.className = "vz-mention-picker";
1699
+ picker.setAttribute("data-vz-ignore", "");
1700
+ picker.innerHTML = `<div class="vz-mention-loading">Loading\u2026</div>`;
1701
+ this.el.appendChild(picker);
1702
+ this.mentionPicker = picker;
1703
+ this.positionMentionPicker(picker, anchor);
1704
+ let users = [];
1705
+ try {
1706
+ users = await this.callbacks.onMentionSearch();
1707
+ } catch {
1708
+ }
1709
+ if (this.mentionPicker !== picker) return;
1710
+ if (users.length === 0) {
1711
+ picker.innerHTML = `<div class="vz-mention-empty">No teammates to mention yet.</div>`;
1712
+ this.positionMentionPicker(picker, anchor);
1713
+ this.wireMentionPickerDismiss();
1714
+ return;
1715
+ }
1716
+ const itemsHtml = users.map((u) => {
1717
+ 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>`;
1718
+ 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>`;
1719
+ }).join("");
1720
+ picker.innerHTML = itemsHtml;
1721
+ this.positionMentionPicker(picker, anchor);
1722
+ picker.addEventListener("click", (e) => {
1723
+ const btn = e.target?.closest(".vz-mention-item");
1724
+ if (!btn) return;
1725
+ e.preventDefault();
1726
+ e.stopPropagation();
1727
+ const id = btn.getAttribute("data-mention-id") || "";
1728
+ const name = btn.getAttribute("data-mention-name") || "";
1729
+ if (id && name) this.insertMentionChip({ id, name });
1730
+ this.closeMentionPicker();
1731
+ });
1732
+ this.wireMentionPickerDismiss();
1733
+ }
1734
+ /** Wires Esc + outside-click handlers to close the open picker. */
1735
+ wireMentionPickerDismiss() {
1736
+ if (this.mentionPickerDismiss) return;
1737
+ const onKey = (e) => {
1738
+ if (e.key === "Escape") {
1739
+ e.preventDefault();
1740
+ this.closeMentionPicker();
1741
+ }
1742
+ };
1743
+ const onOutside = (e) => {
1744
+ if (!this.mentionPicker) return;
1745
+ if (this.mentionPicker.contains(e.target)) return;
1746
+ this.closeMentionPicker();
1747
+ };
1748
+ document.addEventListener("keydown", onKey, true);
1749
+ document.addEventListener("mousedown", onOutside, true);
1750
+ this.mentionPickerDismiss = () => {
1751
+ document.removeEventListener("keydown", onKey, true);
1752
+ document.removeEventListener("mousedown", onOutside, true);
1753
+ };
1754
+ }
1755
+ closeMentionPicker() {
1756
+ if (this.mentionPickerDismiss) {
1757
+ this.mentionPickerDismiss();
1758
+ this.mentionPickerDismiss = null;
1759
+ }
1760
+ if (this.mentionPicker) {
1761
+ this.mentionPicker.remove();
1762
+ this.mentionPicker = null;
1763
+ }
1764
+ }
1765
+ /**
1766
+ * Place the picker below the anchor button. Stays within the
1767
+ * popover's right edge so we don't overflow the popover frame.
1768
+ */
1769
+ positionMentionPicker(picker, anchor) {
1770
+ const popRect = this.el.getBoundingClientRect();
1771
+ const btnRect = anchor.getBoundingClientRect();
1772
+ const left = btnRect.left - popRect.left;
1773
+ const top = btnRect.bottom - popRect.top + 4;
1774
+ picker.style.left = `${left}px`;
1775
+ picker.style.top = `${top}px`;
1776
+ }
1679
1777
  /**
1680
1778
  * Insert a mention chip for the given user into the editor at the
1681
- * current caret position. Owner-only ACL in v1: the only callable
1682
- * "mentionable" user is the current Vizu user (= workspace owner).
1683
- * Future versions can expand this with an autocomplete dropdown.
1779
+ * current caret position. Accepts any user-like shape with a required
1780
+ * `id` + `name` — covers both the current VizuUser (for self-mention)
1781
+ * and MentionableUser (from the workspace member picker).
1684
1782
  */
1685
1783
  insertMentionChip(user) {
1686
1784
  if (!user || !user.id) return;
@@ -1757,6 +1855,7 @@ var Popover = class {
1757
1855
  this.el.style.left = left + "px";
1758
1856
  }
1759
1857
  destroy() {
1858
+ this.closeMentionPicker();
1760
1859
  this.el.removeEventListener("click", this.handleClick);
1761
1860
  this.el.remove();
1762
1861
  }
@@ -2535,6 +2634,71 @@ var STYLES = `
2535
2634
  }
2536
2635
  .vz-btn-primary:hover { background: color-mix(in srgb, var(--vz-accent) 88%, white); }
2537
2636
 
2637
+ /* Mention picker \u2014 floating dropdown anchored under the @ mention button */
2638
+ .vz-mention-picker {
2639
+ position: absolute;
2640
+ z-index: 10;
2641
+ min-width: 200px;
2642
+ max-width: 280px;
2643
+ max-height: 240px;
2644
+ overflow-y: auto;
2645
+ padding: 4px;
2646
+ background: var(--vz-bg-popover, #1c1c1c);
2647
+ border: 1px solid var(--vz-border, rgba(255,255,255,0.08));
2648
+ border-radius: 8px;
2649
+ box-shadow: 0 12px 28px rgba(0,0,0,0.4), 0 2px 6px rgba(0,0,0,0.3);
2650
+ display: flex;
2651
+ flex-direction: column;
2652
+ gap: 1px;
2653
+ }
2654
+ .vz-mention-loading,
2655
+ .vz-mention-empty {
2656
+ padding: 10px 12px;
2657
+ font-size: 12px;
2658
+ color: var(--vz-text-muted);
2659
+ text-align: center;
2660
+ }
2661
+ .vz-mention-item {
2662
+ display: flex;
2663
+ align-items: center;
2664
+ gap: 8px;
2665
+ padding: 7px 10px;
2666
+ border-radius: 6px;
2667
+ background: transparent;
2668
+ border: none;
2669
+ color: #fafafa;
2670
+ font-size: 13px;
2671
+ text-align: left;
2672
+ cursor: pointer;
2673
+ transition: background 80ms;
2674
+ }
2675
+ .vz-mention-item:hover,
2676
+ .vz-mention-item:focus-visible {
2677
+ background: var(--vz-bg-hover, rgba(255,255,255,0.06));
2678
+ outline: none;
2679
+ }
2680
+ .vz-mention-avatar {
2681
+ display: inline-flex;
2682
+ align-items: center;
2683
+ justify-content: center;
2684
+ width: 22px; height: 22px;
2685
+ border-radius: 50%;
2686
+ font-size: 10px; font-weight: 600;
2687
+ color: var(--vz-marker-fg, #0a0a0a);
2688
+ background: var(--vz-accent, #ff8b6e);
2689
+ flex-shrink: 0;
2690
+ overflow: hidden;
2691
+ }
2692
+ .vz-mention-avatar img {
2693
+ width: 100%; height: 100%; object-fit: cover;
2694
+ }
2695
+ .vz-mention-item-name {
2696
+ flex: 1;
2697
+ white-space: nowrap;
2698
+ overflow: hidden;
2699
+ text-overflow: ellipsis;
2700
+ }
2701
+
2538
2702
  /* Sidebar */
2539
2703
  .vz-sidebar {
2540
2704
  position: fixed;
@@ -3302,6 +3466,18 @@ var Vizu = class {
3302
3466
  getUser() {
3303
3467
  return this.user;
3304
3468
  }
3469
+ /**
3470
+ * Fetch the list of users who can be @-mentioned on the current
3471
+ * workspace. Used by the popover's mention dropdown; hosts can also
3472
+ * call this directly to build their own picker. Returns [] for
3473
+ * non-cloud adapters (no workspace, no member list to fetch from).
3474
+ */
3475
+ async searchMentionable() {
3476
+ if (this.storage instanceof CloudStorageAdapter) {
3477
+ return this.storage.searchMentionable();
3478
+ }
3479
+ return [];
3480
+ }
3305
3481
  /**
3306
3482
  * Whether the full Vizu UI (pill, highlighter, markers, popover) can
3307
3483
  * be mounted right now. Cloud-mode workspaces hold off until the
@@ -3657,7 +3833,8 @@ var Vizu = class {
3657
3833
  onClose: () => this.closePopover(),
3658
3834
  onStartReferencePick: (onPicked, onCancel) => this.startPicking(onPicked, onCancel),
3659
3835
  onJumpToReference: (commentId, refId2) => this.jumpToReference(commentId, refId2),
3660
- onAnchorsChanged: (fps) => this.syncActiveAnchorOutlines(fps)
3836
+ onAnchorsChanged: (fps) => this.syncActiveAnchorOutlines(fps),
3837
+ onMentionSearch: () => this.searchMentionable()
3661
3838
  });
3662
3839
  this.popover.setUser(this.user);
3663
3840
  this.sidebar = new Sidebar(this.root, {