161 lines
4.5 KiB
TypeScript
161 lines
4.5 KiB
TypeScript
/**
|
|
* <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;
|
|
#syncRef: any = null;
|
|
|
|
constructor() {
|
|
super();
|
|
this.#shadow = this.attachShadow({ mode: "open" });
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.#render();
|
|
// Try to pick up existing CommunitySync
|
|
this.#syncRef = (window as any).__communitySync || null;
|
|
this.#refreshCount();
|
|
this.#pollTimer = setInterval(() => this.#refreshCount(), POLL_INTERVAL);
|
|
window.addEventListener("comment-pins-changed", this.#onPinsChanged);
|
|
// Listen for CommunitySync becoming available (may connect after mount)
|
|
document.addEventListener("community-sync-ready", this.#onSyncReady);
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
if (this.#pollTimer) clearInterval(this.#pollTimer);
|
|
window.removeEventListener("comment-pins-changed", this.#onPinsChanged);
|
|
document.removeEventListener("community-sync-ready", this.#onSyncReady);
|
|
}
|
|
|
|
#onSyncReady = (e: Event) => {
|
|
const sync = (e as CustomEvent).detail?.sync;
|
|
if (sync) {
|
|
this.#syncRef = sync;
|
|
this.#refreshCount();
|
|
}
|
|
};
|
|
|
|
#onPinsChanged = () => {
|
|
this.#refreshCount();
|
|
};
|
|
|
|
#refreshCount() {
|
|
// Prefer cached ref, fall back to global
|
|
const sync = this.#syncRef || (window as any).__communitySync;
|
|
if (sync && !this.#syncRef) this.#syncRef = sync;
|
|
const pins = sync?.doc?.commentPins;
|
|
if (!pins) {
|
|
if (this.#count !== 0) {
|
|
this.#count = 0;
|
|
this.#updateBadge();
|
|
}
|
|
return;
|
|
}
|
|
const newCount = Object.values(pins).filter(
|
|
(p: any) => !p.resolved
|
|
).length;
|
|
if (newCount !== this.#count) {
|
|
this.#count = newCount;
|
|
this.#updateBadge();
|
|
}
|
|
}
|
|
|
|
/** Full render — only called once in connectedCallback. */
|
|
#render() {
|
|
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>
|
|
<span class="badge" style="display:none"></span>
|
|
</button>
|
|
`;
|
|
|
|
this.#shadow
|
|
.querySelector(".comment-btn")
|
|
?.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
window.dispatchEvent(new CustomEvent("comment-pin-activate"));
|
|
});
|
|
}
|
|
|
|
/** Lightweight badge update — no innerHTML churn. */
|
|
#updateBadge() {
|
|
const badge = this.#shadow.querySelector(".badge") as HTMLElement | null;
|
|
if (!badge) return;
|
|
if (this.#count > 0) {
|
|
badge.textContent = this.#count > 99 ? "99+" : String(this.#count);
|
|
badge.style.display = "";
|
|
} else {
|
|
badge.style.display = "none";
|
|
}
|
|
}
|
|
|
|
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; }
|
|
}
|
|
`;
|