@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/auto.cjs CHANGED
@@ -1261,6 +1261,10 @@ var Popover = class {
1261
1261
  this.pendingRefs = {};
1262
1262
  /** Attachments uploaded during this session, persisted into `comment.attachments` on save. */
1263
1263
  this.pendingAttachments = [];
1264
+ /** Open mention picker element, if any. Held so the next openMentionPicker / close calls can tear it down. */
1265
+ this.mentionPicker = null;
1266
+ /** Dismiss handlers wired to document for the open picker; cleared in closeMentionPicker. */
1267
+ this.mentionPickerDismiss = null;
1264
1268
  this.handleEditorKey = (e) => {
1265
1269
  const editor = e.currentTarget;
1266
1270
  if (e.key === "#") {
@@ -1367,7 +1371,7 @@ var Popover = class {
1367
1371
  else if (action === "save") this.save();
1368
1372
  else if (action === "mention") {
1369
1373
  e.preventDefault();
1370
- this.insertMentionChip(this.currentUser);
1374
+ this.openMentionPicker(target);
1371
1375
  } else if (action === "upload") {
1372
1376
  e.preventDefault();
1373
1377
  const input = this.el.querySelector('[data-vz="file-input"]');
@@ -1476,6 +1480,7 @@ var Popover = class {
1476
1480
  this.position();
1477
1481
  }
1478
1482
  close() {
1483
+ this.closeMentionPicker();
1479
1484
  this.el.style.display = "none";
1480
1485
  this.anchorTargets = [];
1481
1486
  this.anchorFingerprints = [];
@@ -1533,7 +1538,7 @@ var Popover = class {
1533
1538
  <div class="vz-popover-actions">
1534
1539
  <button class="vz-btn vz-btn-ghost" data-vz="close">Close</button>
1535
1540
  ${this.callbacks.canUploadAttachments() ? `<button class="vz-btn vz-btn-ghost vz-btn-upload" data-vz="upload" title="Attach an image">+ image</button>` : ""}
1536
- ${this.currentUser ? `<button class="vz-btn vz-btn-ghost vz-btn-mention" data-vz="mention" title="Mention ${escapeHtml(this.currentUser.name)}">@ mention</button>` : ""}
1541
+ ${this.currentUser ? `<button class="vz-btn vz-btn-ghost vz-btn-mention" data-vz="mention" title="Mention a teammate">@ mention</button>` : ""}
1537
1542
  <button class="vz-btn vz-btn-primary" data-vz="save">Save</button>
1538
1543
  </div>
1539
1544
  <input type="file" class="vz-file-input" data-vz="file-input" accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml" hidden />
@@ -1702,11 +1707,104 @@ var Popover = class {
1702
1707
  for (const child of editor.childNodes) walk(child);
1703
1708
  return parts.join("").trim();
1704
1709
  }
1710
+ /**
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.
1719
+ */
1720
+ async openMentionPicker(anchor) {
1721
+ this.closeMentionPicker();
1722
+ if (!this.callbacks.onMentionSearch) return;
1723
+ const picker = document.createElement("div");
1724
+ picker.className = "vz-mention-picker";
1725
+ picker.setAttribute("data-vz-ignore", "");
1726
+ picker.innerHTML = `<div class="vz-mention-loading">Loading\u2026</div>`;
1727
+ this.el.appendChild(picker);
1728
+ this.mentionPicker = picker;
1729
+ this.positionMentionPicker(picker, anchor);
1730
+ let users = [];
1731
+ try {
1732
+ users = await this.callbacks.onMentionSearch();
1733
+ } catch {
1734
+ }
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();
1740
+ return;
1741
+ }
1742
+ const itemsHtml = users.map((u) => {
1743
+ 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>`;
1745
+ }).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();
1759
+ }
1760
+ /** Wires Esc + outside-click handlers to close the open picker. */
1761
+ wireMentionPickerDismiss() {
1762
+ if (this.mentionPickerDismiss) return;
1763
+ const onKey = (e) => {
1764
+ if (e.key === "Escape") {
1765
+ e.preventDefault();
1766
+ this.closeMentionPicker();
1767
+ }
1768
+ };
1769
+ const onOutside = (e) => {
1770
+ if (!this.mentionPicker) return;
1771
+ if (this.mentionPicker.contains(e.target)) return;
1772
+ this.closeMentionPicker();
1773
+ };
1774
+ document.addEventListener("keydown", onKey, true);
1775
+ document.addEventListener("mousedown", onOutside, true);
1776
+ this.mentionPickerDismiss = () => {
1777
+ document.removeEventListener("keydown", onKey, true);
1778
+ document.removeEventListener("mousedown", onOutside, true);
1779
+ };
1780
+ }
1781
+ closeMentionPicker() {
1782
+ if (this.mentionPickerDismiss) {
1783
+ this.mentionPickerDismiss();
1784
+ this.mentionPickerDismiss = null;
1785
+ }
1786
+ if (this.mentionPicker) {
1787
+ this.mentionPicker.remove();
1788
+ this.mentionPicker = null;
1789
+ }
1790
+ }
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) {
1796
+ const popRect = this.el.getBoundingClientRect();
1797
+ 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`;
1802
+ }
1705
1803
  /**
1706
1804
  * Insert a mention chip for the given user into the editor at the
1707
- * current caret position. Owner-only ACL in v1: the only callable
1708
- * "mentionable" user is the current Vizu user (= workspace owner).
1709
- * Future versions can expand this with an autocomplete dropdown.
1805
+ * current caret position. Accepts any user-like shape with a required
1806
+ * `id` + `name` — covers both the current VizuUser (for self-mention)
1807
+ * and MentionableUser (from the workspace member picker).
1710
1808
  */
1711
1809
  insertMentionChip(user) {
1712
1810
  if (!user || !user.id) return;
@@ -1783,6 +1881,7 @@ var Popover = class {
1783
1881
  this.el.style.left = left + "px";
1784
1882
  }
1785
1883
  destroy() {
1884
+ this.closeMentionPicker();
1786
1885
  this.el.removeEventListener("click", this.handleClick);
1787
1886
  this.el.remove();
1788
1887
  }
@@ -2561,6 +2660,71 @@ var STYLES = `
2561
2660
  }
2562
2661
  .vz-btn-primary:hover { background: color-mix(in srgb, var(--vz-accent) 88%, white); }
2563
2662
 
2663
+ /* Mention picker \u2014 floating dropdown anchored under the @ mention button */
2664
+ .vz-mention-picker {
2665
+ position: absolute;
2666
+ z-index: 10;
2667
+ min-width: 200px;
2668
+ max-width: 280px;
2669
+ max-height: 240px;
2670
+ overflow-y: auto;
2671
+ padding: 4px;
2672
+ background: var(--vz-bg-popover, #1c1c1c);
2673
+ border: 1px solid var(--vz-border, rgba(255,255,255,0.08));
2674
+ border-radius: 8px;
2675
+ box-shadow: 0 12px 28px rgba(0,0,0,0.4), 0 2px 6px rgba(0,0,0,0.3);
2676
+ display: flex;
2677
+ flex-direction: column;
2678
+ gap: 1px;
2679
+ }
2680
+ .vz-mention-loading,
2681
+ .vz-mention-empty {
2682
+ padding: 10px 12px;
2683
+ font-size: 12px;
2684
+ color: var(--vz-text-muted);
2685
+ text-align: center;
2686
+ }
2687
+ .vz-mention-item {
2688
+ display: flex;
2689
+ align-items: center;
2690
+ gap: 8px;
2691
+ padding: 7px 10px;
2692
+ border-radius: 6px;
2693
+ background: transparent;
2694
+ border: none;
2695
+ color: #fafafa;
2696
+ font-size: 13px;
2697
+ text-align: left;
2698
+ cursor: pointer;
2699
+ transition: background 80ms;
2700
+ }
2701
+ .vz-mention-item:hover,
2702
+ .vz-mention-item:focus-visible {
2703
+ background: var(--vz-bg-hover, rgba(255,255,255,0.06));
2704
+ outline: none;
2705
+ }
2706
+ .vz-mention-avatar {
2707
+ display: inline-flex;
2708
+ align-items: center;
2709
+ justify-content: center;
2710
+ width: 22px; height: 22px;
2711
+ border-radius: 50%;
2712
+ font-size: 10px; font-weight: 600;
2713
+ color: var(--vz-marker-fg, #0a0a0a);
2714
+ background: var(--vz-accent, #ff8b6e);
2715
+ flex-shrink: 0;
2716
+ overflow: hidden;
2717
+ }
2718
+ .vz-mention-avatar img {
2719
+ width: 100%; height: 100%; object-fit: cover;
2720
+ }
2721
+ .vz-mention-item-name {
2722
+ flex: 1;
2723
+ white-space: nowrap;
2724
+ overflow: hidden;
2725
+ text-overflow: ellipsis;
2726
+ }
2727
+
2564
2728
  /* Sidebar */
2565
2729
  .vz-sidebar {
2566
2730
  position: fixed;
@@ -3320,22 +3484,35 @@ var Vizu = class {
3320
3484
  this.user = user;
3321
3485
  this.popover?.setUser(user);
3322
3486
  this.pill?.setUser(user);
3323
- if (this.pill) {
3324
- if (this.shouldShowPill()) this.pill.show();
3325
- else this.pill.hide();
3326
- }
3327
3487
  this.bus.emit("user:changed", { user });
3488
+ if (this.enabled && !this.root && this.shouldMount()) {
3489
+ this.deferred(() => this.mount());
3490
+ }
3328
3491
  }
3329
3492
  getUser() {
3330
3493
  return this.user;
3331
3494
  }
3332
3495
  /**
3333
- * Whether the pill belongs on-screen right now. Cloud-mode workspaces
3334
- * keep it hidden until the user is signed in; non-cloud usage (local /
3335
- * memory storage) has no auth and always shows it. Recomputed on every
3336
- * setUser and on mount.
3496
+ * Fetch the list of users who can be @-mentioned on the current
3497
+ * workspace. Used by the popover's mention dropdown; hosts can also
3498
+ * call this directly to build their own picker. Returns [] for
3499
+ * non-cloud adapters (no workspace, no member list to fetch from).
3337
3500
  */
3338
- shouldShowPill() {
3501
+ async searchMentionable() {
3502
+ if (this.storage instanceof CloudStorageAdapter) {
3503
+ return this.storage.searchMentionable();
3504
+ }
3505
+ return [];
3506
+ }
3507
+ /**
3508
+ * Whether the full Vizu UI (pill, highlighter, markers, popover) can
3509
+ * be mounted right now. Cloud-mode workspaces hold off until the
3510
+ * user is signed in; non-cloud usage (local / memory storage) has no
3511
+ * auth concept and mounts immediately. Re-checked from `enable()`
3512
+ * and from `setUser()` so that the moment auth resolves, the UI
3513
+ * appears without the user needing to press the shortcut again.
3514
+ */
3515
+ shouldMount() {
3339
3516
  if (!(this.storage instanceof CloudStorageAdapter)) return true;
3340
3517
  return this.user !== null;
3341
3518
  }
@@ -3362,7 +3539,9 @@ var Vizu = class {
3362
3539
  if (this.enabled) return;
3363
3540
  this.enabled = true;
3364
3541
  this.bus.emit("enabled", {});
3365
- this.deferred(() => this.mount());
3542
+ if (this.shouldMount()) {
3543
+ this.deferred(() => this.mount());
3544
+ }
3366
3545
  }
3367
3546
  disable() {
3368
3547
  if (!this.enabled) return;
@@ -3680,7 +3859,8 @@ var Vizu = class {
3680
3859
  onClose: () => this.closePopover(),
3681
3860
  onStartReferencePick: (onPicked, onCancel) => this.startPicking(onPicked, onCancel),
3682
3861
  onJumpToReference: (commentId, refId2) => this.jumpToReference(commentId, refId2),
3683
- onAnchorsChanged: (fps) => this.syncActiveAnchorOutlines(fps)
3862
+ onAnchorsChanged: (fps) => this.syncActiveAnchorOutlines(fps),
3863
+ onMentionSearch: () => this.searchMentionable()
3684
3864
  });
3685
3865
  this.popover.setUser(this.user);
3686
3866
  this.sidebar = new Sidebar(this.root, {
@@ -3702,7 +3882,7 @@ var Vizu = class {
3702
3882
  this.pill.setUser(this.user);
3703
3883
  this.pill.setComments(this.comments);
3704
3884
  this.pill.setMultiSelectState(this.multiSelectMode, this.pendingFingerprints.length);
3705
- if (this.shouldShowPill()) this.pill.show();
3885
+ this.pill.show();
3706
3886
  this.highlighter = new Highlighter(
3707
3887
  this.root,
3708
3888
  this.opts.ignoreSelectors ?? [],