@unhingged/vizu-core 0.1.11 → 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.cjs CHANGED
@@ -1271,6 +1271,10 @@ var Popover = class {
1271
1271
  this.pendingRefs = {};
1272
1272
  /** Attachments uploaded during this session, persisted into `comment.attachments` on save. */
1273
1273
  this.pendingAttachments = [];
1274
+ /** Open mention picker element, if any. Held so the next openMentionPicker / close calls can tear it down. */
1275
+ this.mentionPicker = null;
1276
+ /** Dismiss handlers wired to document for the open picker; cleared in closeMentionPicker. */
1277
+ this.mentionPickerDismiss = null;
1274
1278
  this.handleEditorKey = (e) => {
1275
1279
  const editor = e.currentTarget;
1276
1280
  if (e.key === "#") {
@@ -1377,7 +1381,7 @@ var Popover = class {
1377
1381
  else if (action === "save") this.save();
1378
1382
  else if (action === "mention") {
1379
1383
  e.preventDefault();
1380
- this.insertMentionChip(this.currentUser);
1384
+ this.openMentionPicker(target);
1381
1385
  } else if (action === "upload") {
1382
1386
  e.preventDefault();
1383
1387
  const input = this.el.querySelector('[data-vz="file-input"]');
@@ -1486,6 +1490,7 @@ var Popover = class {
1486
1490
  this.position();
1487
1491
  }
1488
1492
  close() {
1493
+ this.closeMentionPicker();
1489
1494
  this.el.style.display = "none";
1490
1495
  this.anchorTargets = [];
1491
1496
  this.anchorFingerprints = [];
@@ -1543,7 +1548,7 @@ var Popover = class {
1543
1548
  <div class="vz-popover-actions">
1544
1549
  <button class="vz-btn vz-btn-ghost" data-vz="close">Close</button>
1545
1550
  ${this.callbacks.canUploadAttachments() ? `<button class="vz-btn vz-btn-ghost vz-btn-upload" data-vz="upload" title="Attach an image">+ image</button>` : ""}
1546
- ${this.currentUser ? `<button class="vz-btn vz-btn-ghost vz-btn-mention" data-vz="mention" title="Mention ${escapeHtml(this.currentUser.name)}">@ mention</button>` : ""}
1551
+ ${this.currentUser ? `<button class="vz-btn vz-btn-ghost vz-btn-mention" data-vz="mention" title="Mention a teammate">@ mention</button>` : ""}
1547
1552
  <button class="vz-btn vz-btn-primary" data-vz="save">Save</button>
1548
1553
  </div>
1549
1554
  <input type="file" class="vz-file-input" data-vz="file-input" accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml" hidden />
@@ -1712,11 +1717,104 @@ var Popover = class {
1712
1717
  for (const child of editor.childNodes) walk(child);
1713
1718
  return parts.join("").trim();
1714
1719
  }
1720
+ /**
1721
+ * Open a dropdown listing the workspace's mentionable users. The user
1722
+ * clicks one to insert a mention chip for them; Esc or an outside
1723
+ * click closes the dropdown.
1724
+ *
1725
+ * The dropdown is fetched fresh on every open so member adds/removes
1726
+ * in another tab are picked up without a popover reopen. Falls back
1727
+ * to silent no-op if `onMentionSearch` isn't wired (non-cloud
1728
+ * adapters) or returns an empty list.
1729
+ */
1730
+ async openMentionPicker(anchor) {
1731
+ this.closeMentionPicker();
1732
+ if (!this.callbacks.onMentionSearch) return;
1733
+ const picker = document.createElement("div");
1734
+ picker.className = "vz-mention-picker";
1735
+ picker.setAttribute("data-vz-ignore", "");
1736
+ picker.innerHTML = `<div class="vz-mention-loading">Loading\u2026</div>`;
1737
+ this.el.appendChild(picker);
1738
+ this.mentionPicker = picker;
1739
+ this.positionMentionPicker(picker, anchor);
1740
+ let users = [];
1741
+ try {
1742
+ users = await this.callbacks.onMentionSearch();
1743
+ } catch {
1744
+ }
1745
+ if (this.mentionPicker !== picker) return;
1746
+ if (users.length === 0) {
1747
+ picker.innerHTML = `<div class="vz-mention-empty">No teammates to mention yet.</div>`;
1748
+ this.positionMentionPicker(picker, anchor);
1749
+ this.wireMentionPickerDismiss();
1750
+ return;
1751
+ }
1752
+ const itemsHtml = users.map((u) => {
1753
+ 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>`;
1754
+ 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>`;
1755
+ }).join("");
1756
+ picker.innerHTML = itemsHtml;
1757
+ this.positionMentionPicker(picker, anchor);
1758
+ picker.addEventListener("click", (e) => {
1759
+ const btn = e.target?.closest(".vz-mention-item");
1760
+ if (!btn) return;
1761
+ e.preventDefault();
1762
+ e.stopPropagation();
1763
+ const id = btn.getAttribute("data-mention-id") || "";
1764
+ const name = btn.getAttribute("data-mention-name") || "";
1765
+ if (id && name) this.insertMentionChip({ id, name });
1766
+ this.closeMentionPicker();
1767
+ });
1768
+ this.wireMentionPickerDismiss();
1769
+ }
1770
+ /** Wires Esc + outside-click handlers to close the open picker. */
1771
+ wireMentionPickerDismiss() {
1772
+ if (this.mentionPickerDismiss) return;
1773
+ const onKey = (e) => {
1774
+ if (e.key === "Escape") {
1775
+ e.preventDefault();
1776
+ this.closeMentionPicker();
1777
+ }
1778
+ };
1779
+ const onOutside = (e) => {
1780
+ if (!this.mentionPicker) return;
1781
+ if (this.mentionPicker.contains(e.target)) return;
1782
+ this.closeMentionPicker();
1783
+ };
1784
+ document.addEventListener("keydown", onKey, true);
1785
+ document.addEventListener("mousedown", onOutside, true);
1786
+ this.mentionPickerDismiss = () => {
1787
+ document.removeEventListener("keydown", onKey, true);
1788
+ document.removeEventListener("mousedown", onOutside, true);
1789
+ };
1790
+ }
1791
+ closeMentionPicker() {
1792
+ if (this.mentionPickerDismiss) {
1793
+ this.mentionPickerDismiss();
1794
+ this.mentionPickerDismiss = null;
1795
+ }
1796
+ if (this.mentionPicker) {
1797
+ this.mentionPicker.remove();
1798
+ this.mentionPicker = null;
1799
+ }
1800
+ }
1801
+ /**
1802
+ * Place the picker below the anchor button. Stays within the
1803
+ * popover's right edge so we don't overflow the popover frame.
1804
+ */
1805
+ positionMentionPicker(picker, anchor) {
1806
+ const popRect = this.el.getBoundingClientRect();
1807
+ const btnRect = anchor.getBoundingClientRect();
1808
+ const left = btnRect.left - popRect.left;
1809
+ const top = btnRect.bottom - popRect.top + 4;
1810
+ picker.style.left = `${left}px`;
1811
+ picker.style.top = `${top}px`;
1812
+ }
1715
1813
  /**
1716
1814
  * Insert a mention chip for the given user into the editor at the
1717
- * current caret position. Owner-only ACL in v1: the only callable
1718
- * "mentionable" user is the current Vizu user (= workspace owner).
1719
- * Future versions can expand this with an autocomplete dropdown.
1815
+ * current caret position. Accepts any user-like shape with a required
1816
+ * `id` + `name` — covers both the current VizuUser (for self-mention)
1817
+ * and MentionableUser (from the workspace member picker).
1720
1818
  */
1721
1819
  insertMentionChip(user) {
1722
1820
  if (!user || !user.id) return;
@@ -1793,6 +1891,7 @@ var Popover = class {
1793
1891
  this.el.style.left = left + "px";
1794
1892
  }
1795
1893
  destroy() {
1894
+ this.closeMentionPicker();
1796
1895
  this.el.removeEventListener("click", this.handleClick);
1797
1896
  this.el.remove();
1798
1897
  }
@@ -2571,6 +2670,71 @@ var STYLES = `
2571
2670
  }
2572
2671
  .vz-btn-primary:hover { background: color-mix(in srgb, var(--vz-accent) 88%, white); }
2573
2672
 
2673
+ /* Mention picker \u2014 floating dropdown anchored under the @ mention button */
2674
+ .vz-mention-picker {
2675
+ position: absolute;
2676
+ z-index: 10;
2677
+ min-width: 200px;
2678
+ max-width: 280px;
2679
+ max-height: 240px;
2680
+ overflow-y: auto;
2681
+ padding: 4px;
2682
+ background: var(--vz-bg-popover, #1c1c1c);
2683
+ border: 1px solid var(--vz-border, rgba(255,255,255,0.08));
2684
+ border-radius: 8px;
2685
+ box-shadow: 0 12px 28px rgba(0,0,0,0.4), 0 2px 6px rgba(0,0,0,0.3);
2686
+ display: flex;
2687
+ flex-direction: column;
2688
+ gap: 1px;
2689
+ }
2690
+ .vz-mention-loading,
2691
+ .vz-mention-empty {
2692
+ padding: 10px 12px;
2693
+ font-size: 12px;
2694
+ color: var(--vz-text-muted);
2695
+ text-align: center;
2696
+ }
2697
+ .vz-mention-item {
2698
+ display: flex;
2699
+ align-items: center;
2700
+ gap: 8px;
2701
+ padding: 7px 10px;
2702
+ border-radius: 6px;
2703
+ background: transparent;
2704
+ border: none;
2705
+ color: #fafafa;
2706
+ font-size: 13px;
2707
+ text-align: left;
2708
+ cursor: pointer;
2709
+ transition: background 80ms;
2710
+ }
2711
+ .vz-mention-item:hover,
2712
+ .vz-mention-item:focus-visible {
2713
+ background: var(--vz-bg-hover, rgba(255,255,255,0.06));
2714
+ outline: none;
2715
+ }
2716
+ .vz-mention-avatar {
2717
+ display: inline-flex;
2718
+ align-items: center;
2719
+ justify-content: center;
2720
+ width: 22px; height: 22px;
2721
+ border-radius: 50%;
2722
+ font-size: 10px; font-weight: 600;
2723
+ color: var(--vz-marker-fg, #0a0a0a);
2724
+ background: var(--vz-accent, #ff8b6e);
2725
+ flex-shrink: 0;
2726
+ overflow: hidden;
2727
+ }
2728
+ .vz-mention-avatar img {
2729
+ width: 100%; height: 100%; object-fit: cover;
2730
+ }
2731
+ .vz-mention-item-name {
2732
+ flex: 1;
2733
+ white-space: nowrap;
2734
+ overflow: hidden;
2735
+ text-overflow: ellipsis;
2736
+ }
2737
+
2574
2738
  /* Sidebar */
2575
2739
  .vz-sidebar {
2576
2740
  position: fixed;
@@ -3330,22 +3494,35 @@ var Vizu = class {
3330
3494
  this.user = user;
3331
3495
  this.popover?.setUser(user);
3332
3496
  this.pill?.setUser(user);
3333
- if (this.pill) {
3334
- if (this.shouldShowPill()) this.pill.show();
3335
- else this.pill.hide();
3336
- }
3337
3497
  this.bus.emit("user:changed", { user });
3498
+ if (this.enabled && !this.root && this.shouldMount()) {
3499
+ this.deferred(() => this.mount());
3500
+ }
3338
3501
  }
3339
3502
  getUser() {
3340
3503
  return this.user;
3341
3504
  }
3342
3505
  /**
3343
- * Whether the pill belongs on-screen right now. Cloud-mode workspaces
3344
- * keep it hidden until the user is signed in; non-cloud usage (local /
3345
- * memory storage) has no auth and always shows it. Recomputed on every
3346
- * setUser and on mount.
3506
+ * Fetch the list of users who can be @-mentioned on the current
3507
+ * workspace. Used by the popover's mention dropdown; hosts can also
3508
+ * call this directly to build their own picker. Returns [] for
3509
+ * non-cloud adapters (no workspace, no member list to fetch from).
3347
3510
  */
3348
- shouldShowPill() {
3511
+ async searchMentionable() {
3512
+ if (this.storage instanceof CloudStorageAdapter) {
3513
+ return this.storage.searchMentionable();
3514
+ }
3515
+ return [];
3516
+ }
3517
+ /**
3518
+ * Whether the full Vizu UI (pill, highlighter, markers, popover) can
3519
+ * be mounted right now. Cloud-mode workspaces hold off until the
3520
+ * user is signed in; non-cloud usage (local / memory storage) has no
3521
+ * auth concept and mounts immediately. Re-checked from `enable()`
3522
+ * and from `setUser()` so that the moment auth resolves, the UI
3523
+ * appears without the user needing to press the shortcut again.
3524
+ */
3525
+ shouldMount() {
3349
3526
  if (!(this.storage instanceof CloudStorageAdapter)) return true;
3350
3527
  return this.user !== null;
3351
3528
  }
@@ -3372,7 +3549,9 @@ var Vizu = class {
3372
3549
  if (this.enabled) return;
3373
3550
  this.enabled = true;
3374
3551
  this.bus.emit("enabled", {});
3375
- this.deferred(() => this.mount());
3552
+ if (this.shouldMount()) {
3553
+ this.deferred(() => this.mount());
3554
+ }
3376
3555
  }
3377
3556
  disable() {
3378
3557
  if (!this.enabled) return;
@@ -3690,7 +3869,8 @@ var Vizu = class {
3690
3869
  onClose: () => this.closePopover(),
3691
3870
  onStartReferencePick: (onPicked, onCancel) => this.startPicking(onPicked, onCancel),
3692
3871
  onJumpToReference: (commentId, refId2) => this.jumpToReference(commentId, refId2),
3693
- onAnchorsChanged: (fps) => this.syncActiveAnchorOutlines(fps)
3872
+ onAnchorsChanged: (fps) => this.syncActiveAnchorOutlines(fps),
3873
+ onMentionSearch: () => this.searchMentionable()
3694
3874
  });
3695
3875
  this.popover.setUser(this.user);
3696
3876
  this.sidebar = new Sidebar(this.root, {
@@ -3712,7 +3892,7 @@ var Vizu = class {
3712
3892
  this.pill.setUser(this.user);
3713
3893
  this.pill.setComments(this.comments);
3714
3894
  this.pill.setMultiSelectState(this.multiSelectMode, this.pendingFingerprints.length);
3715
- if (this.shouldShowPill()) this.pill.show();
3895
+ this.pill.show();
3716
3896
  this.highlighter = new Highlighter(
3717
3897
  this.root,
3718
3898
  this.opts.ignoreSelectors ?? [],