feat(ux): move comment button to header bar with unresolved badge
Move the "Leave Comment" button from the bottom canvas toolbar to the top header bar as <rstack-comment-bell>, positioned left of the notification bell. Shows a red badge with unresolved comment pin count. Wires canvas via comment-pin-activate/comment-pins-changed custom events. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
38053eee34
commit
f5de97c60c
|
|
@ -243,6 +243,7 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
<div class="rstack-header__right">
|
<div class="rstack-header__right">
|
||||||
<a class="rstack-header__demo-btn" ${spaceSlug === "demo" ? 'data-hide' : ''} href="${shellDemoUrl}">Try Demo</a>
|
<a class="rstack-header__demo-btn" ${spaceSlug === "demo" ? 'data-hide' : ''} href="${shellDemoUrl}">Try Demo</a>
|
||||||
<rstack-offline-indicator></rstack-offline-indicator>
|
<rstack-offline-indicator></rstack-offline-indicator>
|
||||||
|
<rstack-comment-bell></rstack-comment-bell>
|
||||||
<rstack-notification-bell></rstack-notification-bell>
|
<rstack-notification-bell></rstack-notification-bell>
|
||||||
<rstack-share-panel></rstack-share-panel>
|
<rstack-share-panel></rstack-share-panel>
|
||||||
<rstack-identity></rstack-identity>
|
<rstack-identity></rstack-identity>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
/**
|
||||||
|
* <rstack-comment-bell> — Comment button with unresolved-count badge.
|
||||||
|
*
|
||||||
|
* Shows a chat-bubble icon in the header bar. Badge displays the count
|
||||||
|
* of unresolved comment pins on the current canvas. Clicking dispatches
|
||||||
|
* a `comment-pin-activate` event on `window` so canvas.html can enter
|
||||||
|
* pin-placement mode.
|
||||||
|
*
|
||||||
|
* Data source: `window.__communitySync?.doc?.commentPins`
|
||||||
|
* Listens for `comment-pins-changed` on `window` (re-dispatched by canvas).
|
||||||
|
* Polls every 5s as fallback (sync may appear after component mounts).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const POLL_INTERVAL = 5_000;
|
||||||
|
|
||||||
|
export class RStackCommentBell extends HTMLElement {
|
||||||
|
#shadow: ShadowRoot;
|
||||||
|
#count = 0;
|
||||||
|
#pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.#shadow = this.attachShadow({ mode: "open" });
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.#render();
|
||||||
|
this.#refreshCount();
|
||||||
|
this.#pollTimer = setInterval(() => this.#refreshCount(), POLL_INTERVAL);
|
||||||
|
window.addEventListener("comment-pins-changed", this.#onPinsChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
if (this.#pollTimer) clearInterval(this.#pollTimer);
|
||||||
|
window.removeEventListener("comment-pins-changed", this.#onPinsChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
#onPinsChanged = () => {
|
||||||
|
this.#refreshCount();
|
||||||
|
};
|
||||||
|
|
||||||
|
#refreshCount() {
|
||||||
|
const sync = (window as any).__communitySync;
|
||||||
|
const pins = sync?.doc?.commentPins;
|
||||||
|
if (!pins) {
|
||||||
|
if (this.#count !== 0) {
|
||||||
|
this.#count = 0;
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newCount = Object.values(pins).filter(
|
||||||
|
(p: any) => !p.resolved
|
||||||
|
).length;
|
||||||
|
if (newCount !== this.#count) {
|
||||||
|
this.#count = newCount;
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#render() {
|
||||||
|
const badge =
|
||||||
|
this.#count > 0
|
||||||
|
? `<span class="badge">${this.#count > 99 ? "99+" : this.#count}</span>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
this.#shadow.innerHTML = `
|
||||||
|
<style>${STYLES}</style>
|
||||||
|
<button class="comment-btn" aria-label="Leave Comment" title="Leave Comment (/)">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/>
|
||||||
|
</svg>
|
||||||
|
${badge}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.#shadow
|
||||||
|
.querySelector(".comment-btn")
|
||||||
|
?.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
window.dispatchEvent(new CustomEvent("comment-pin-activate"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static define(tag = "rstack-comment-bell") {
|
||||||
|
if (!customElements.get(tag)) customElements.define(tag, RStackCommentBell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const STYLES = `
|
||||||
|
:host {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-btn {
|
||||||
|
position: relative;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--rs-text-muted, #94a3b8);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.comment-btn:hover {
|
||||||
|
color: var(--rs-text-primary, #e2e8f0);
|
||||||
|
background: var(--rs-bg-hover, rgba(255,255,255,0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.comment-btn { display: none; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
@ -2198,9 +2198,6 @@
|
||||||
<button class="tool-btn" id="tool-text" title="Note (T)">
|
<button class="tool-btn" id="tool-text" title="Note (T)">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 7 4 4 20 4 20 7"/><line x1="12" y1="4" x2="12" y2="20"/><line x1="8" y1="20" x2="16" y2="20"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 7 4 4 20 4 20 7"/><line x1="12" y1="4" x2="12" y2="20"/><line x1="8" y1="20" x2="16" y2="20"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="tool-btn" id="tool-comment" title="Leave Comment (/)">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/></svg>
|
|
||||||
</button>
|
|
||||||
<span class="tool-sep"></span>
|
<span class="tool-sep"></span>
|
||||||
<button class="tool-btn" id="tool-eraser" title="Eraser (E)">
|
<button class="tool-btn" id="tool-eraser" title="Eraser (E)">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 20H7L3 16a1 1 0 010-1.4l9.6-9.6a1 1 0 011.4 0l7 7a1 1 0 010 1.4L15 20"/><line x1="18" y1="13" x2="11" y2="6"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 20H7L3 16a1 1 0 010-1.4l9.6-9.6a1 1 0 011.4 0l7 7a1 1 0 010 1.4L15 20"/><line x1="18" y1="13" x2="11" y2="6"/></svg>
|
||||||
|
|
@ -4070,7 +4067,7 @@
|
||||||
else if (key === "t") { document.getElementById("tool-text")?.click(); }
|
else if (key === "t") { document.getElementById("tool-text")?.click(); }
|
||||||
else if (key === "a") { toggleConnectMode(); }
|
else if (key === "a") { toggleConnectMode(); }
|
||||||
else if (key === "e") { document.getElementById("tool-eraser")?.click(); }
|
else if (key === "e") { document.getElementById("tool-eraser")?.click(); }
|
||||||
else if (key === "/") { document.getElementById("tool-comment")?.click(); }
|
else if (key === "/") { window.dispatchEvent(new CustomEvent("comment-pin-activate")); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a shape, add to canvas, and register for sync.
|
// Create a shape, add to canvas, and register for sync.
|
||||||
|
|
@ -5199,9 +5196,7 @@
|
||||||
function syncBottomToolbar() {
|
function syncBottomToolbar() {
|
||||||
bottomToolBtns.forEach(b => b.classList.remove("active"));
|
bottomToolBtns.forEach(b => b.classList.remove("active"));
|
||||||
|
|
||||||
if (pinManager?.placementMode) {
|
if (wbTool) {
|
||||||
document.getElementById("tool-comment")?.classList.add("active");
|
|
||||||
} else if (wbTool) {
|
|
||||||
const map = { pencil: "tool-pencil", line: "tool-line", rect: "tool-rect", circle: "tool-circle", eraser: "tool-eraser" };
|
const map = { pencil: "tool-pencil", line: "tool-line", rect: "tool-rect", circle: "tool-circle", eraser: "tool-eraser" };
|
||||||
document.getElementById(map[wbTool])?.classList.add("active");
|
document.getElementById(map[wbTool])?.classList.add("active");
|
||||||
} else if (connectMode) {
|
} else if (connectMode) {
|
||||||
|
|
@ -5253,14 +5248,13 @@
|
||||||
setWbTool("eraser");
|
setWbTool("eraser");
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Comment tool ──
|
// ── Comment tool (header bell triggers this via custom event) ──
|
||||||
function exitCommentMode() {
|
function exitCommentMode() {
|
||||||
if (pinManager.placementMode) {
|
if (pinManager.placementMode) {
|
||||||
pinManager.exitPinPlacementMode();
|
pinManager.exitPinPlacementMode();
|
||||||
document.getElementById("tool-comment").classList.remove("active");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.getElementById("tool-comment").addEventListener("click", () => {
|
window.addEventListener("comment-pin-activate", () => {
|
||||||
if (pendingTool) clearPendingTool();
|
if (pendingTool) clearPendingTool();
|
||||||
if (wbTool) setWbTool(null);
|
if (wbTool) setWbTool(null);
|
||||||
if (connectMode) {
|
if (connectMode) {
|
||||||
|
|
@ -5273,8 +5267,7 @@
|
||||||
syncBottomToolbar();
|
syncBottomToolbar();
|
||||||
} else {
|
} else {
|
||||||
pinManager.enterPinPlacementMode();
|
pinManager.enterPinPlacementMode();
|
||||||
bottomToolBtns.forEach(b => b.classList.remove("active"));
|
syncBottomToolbar();
|
||||||
document.getElementById("tool-comment").classList.add("active");
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -5634,7 +5627,6 @@
|
||||||
const worldX = (e.clientX - rect.left - panX) / scale;
|
const worldX = (e.clientX - rect.left - panX) / scale;
|
||||||
const worldY = (e.clientY - rect.top - panY) / scale;
|
const worldY = (e.clientY - rect.top - panY) / scale;
|
||||||
pinManager.handleCanvasClick(worldX, worldY, e.clientX, e.clientY);
|
pinManager.handleCanvasClick(worldX, worldY, e.clientX, e.clientY);
|
||||||
document.getElementById("tool-comment").classList.remove("active");
|
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
// Eraser for any folk-shape — capturing phase intercepts
|
// Eraser for any folk-shape — capturing phase intercepts
|
||||||
|
|
@ -6293,6 +6285,11 @@
|
||||||
if (memoryPanel.classList.contains("open")) renderMemoryPanel();
|
if (memoryPanel.classList.contains("open")) renderMemoryPanel();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Re-dispatch comment-pins-changed on window so header bell can update
|
||||||
|
sync.addEventListener("comment-pins-changed", () => {
|
||||||
|
window.dispatchEvent(new CustomEvent("comment-pins-changed"));
|
||||||
|
});
|
||||||
|
|
||||||
// Navigate to comment pin position
|
// Navigate to comment pin position
|
||||||
canvas.addEventListener("comment-pin-navigate", (e) => {
|
canvas.addEventListener("comment-pin-navigate", (e) => {
|
||||||
const { x, y } = e.detail;
|
const { x, y } = e.detail;
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { RStackModuleSetup } from "../shared/components/rstack-module-setup";
|
||||||
import { RStackHistoryPanel } from "../shared/components/rstack-history-panel";
|
import { RStackHistoryPanel } from "../shared/components/rstack-history-panel";
|
||||||
import { RStackOfflineIndicator } from "../shared/components/rstack-offline-indicator";
|
import { RStackOfflineIndicator } from "../shared/components/rstack-offline-indicator";
|
||||||
import { RStackSharePanel } from "../shared/components/rstack-share-panel";
|
import { RStackSharePanel } from "../shared/components/rstack-share-panel";
|
||||||
|
import { RStackCommentBell } from "../shared/components/rstack-comment-bell";
|
||||||
import { RStackCollabOverlay } from "../shared/components/rstack-collab-overlay";
|
import { RStackCollabOverlay } from "../shared/components/rstack-collab-overlay";
|
||||||
import { RStackUserDashboard } from "../shared/components/rstack-user-dashboard";
|
import { RStackUserDashboard } from "../shared/components/rstack-user-dashboard";
|
||||||
import { rspaceNavUrl } from "../shared/url-helpers";
|
import { rspaceNavUrl } from "../shared/url-helpers";
|
||||||
|
|
@ -42,6 +43,7 @@ RStackModuleSetup.define();
|
||||||
RStackHistoryPanel.define();
|
RStackHistoryPanel.define();
|
||||||
RStackOfflineIndicator.define();
|
RStackOfflineIndicator.define();
|
||||||
RStackSharePanel.define();
|
RStackSharePanel.define();
|
||||||
|
RStackCommentBell.define();
|
||||||
RStackCollabOverlay.define();
|
RStackCollabOverlay.define();
|
||||||
RStackUserDashboard.define();
|
RStackUserDashboard.define();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue