fix(shell): header button reliability — history, settings, comments
History panel: lazy doc acquisition from CommunitySync/offline runtime on open + listen for community-sync-ready event for late connections. Space settings: reposition panel after every re-render (async data loads were destroying inline positioning styles), clamp max-height to viewport, fix wrong global name (__rspaceCommunitySync → __communitySync). Comment bell: render DOM once, update badge without innerHTML churn, listen for community-sync-ready event, cache sync reference. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c35f39380e
commit
772e5e4352
|
|
@ -17,6 +17,7 @@ export class RStackCommentBell extends HTMLElement {
|
||||||
#shadow: ShadowRoot;
|
#shadow: ShadowRoot;
|
||||||
#count = 0;
|
#count = 0;
|
||||||
#pollTimer: ReturnType<typeof setInterval> | null = null;
|
#pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
#syncRef: any = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
@ -25,27 +26,42 @@ export class RStackCommentBell extends HTMLElement {
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.#render();
|
this.#render();
|
||||||
|
// Try to pick up existing CommunitySync
|
||||||
|
this.#syncRef = (window as any).__communitySync || null;
|
||||||
this.#refreshCount();
|
this.#refreshCount();
|
||||||
this.#pollTimer = setInterval(() => this.#refreshCount(), POLL_INTERVAL);
|
this.#pollTimer = setInterval(() => this.#refreshCount(), POLL_INTERVAL);
|
||||||
window.addEventListener("comment-pins-changed", this.#onPinsChanged);
|
window.addEventListener("comment-pins-changed", this.#onPinsChanged);
|
||||||
|
// Listen for CommunitySync becoming available (may connect after mount)
|
||||||
|
document.addEventListener("community-sync-ready", this.#onSyncReady);
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
if (this.#pollTimer) clearInterval(this.#pollTimer);
|
if (this.#pollTimer) clearInterval(this.#pollTimer);
|
||||||
window.removeEventListener("comment-pins-changed", this.#onPinsChanged);
|
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 = () => {
|
#onPinsChanged = () => {
|
||||||
this.#refreshCount();
|
this.#refreshCount();
|
||||||
};
|
};
|
||||||
|
|
||||||
#refreshCount() {
|
#refreshCount() {
|
||||||
const sync = (window as any).__communitySync;
|
// 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;
|
const pins = sync?.doc?.commentPins;
|
||||||
if (!pins) {
|
if (!pins) {
|
||||||
if (this.#count !== 0) {
|
if (this.#count !== 0) {
|
||||||
this.#count = 0;
|
this.#count = 0;
|
||||||
this.#render();
|
this.#updateBadge();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -54,23 +70,19 @@ export class RStackCommentBell extends HTMLElement {
|
||||||
).length;
|
).length;
|
||||||
if (newCount !== this.#count) {
|
if (newCount !== this.#count) {
|
||||||
this.#count = newCount;
|
this.#count = newCount;
|
||||||
this.#render();
|
this.#updateBadge();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Full render — only called once in connectedCallback. */
|
||||||
#render() {
|
#render() {
|
||||||
const badge =
|
|
||||||
this.#count > 0
|
|
||||||
? `<span class="badge">${this.#count > 99 ? "99+" : this.#count}</span>`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
this.#shadow.innerHTML = `
|
this.#shadow.innerHTML = `
|
||||||
<style>${STYLES}</style>
|
<style>${STYLES}</style>
|
||||||
<button class="comment-btn" aria-label="Leave Comment" title="Leave Comment (/)">
|
<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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
${badge}
|
<span class="badge" style="display:none"></span>
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -82,6 +94,18 @@ export class RStackCommentBell extends HTMLElement {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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") {
|
static define(tag = "rstack-comment-bell") {
|
||||||
if (!customElements.get(tag)) customElements.define(tag, RStackCommentBell);
|
if (!customElements.get(tag)) customElements.define(tag, RStackCommentBell);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,20 +58,49 @@ export class RStackHistoryPanel extends HTMLElement {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private _syncReadyHandler = (e: Event) => {
|
||||||
|
const sync = (e as CustomEvent).detail?.sync;
|
||||||
|
if (sync?.doc) {
|
||||||
|
this._doc = sync.doc;
|
||||||
|
if (this._open) {
|
||||||
|
this._refreshHistory();
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
if (!this.shadowRoot) this.attachShadow({ mode: "open" });
|
if (!this.shadowRoot) this.attachShadow({ mode: "open" });
|
||||||
this._render();
|
this._render();
|
||||||
|
// Pick up CommunitySync when it connects (may be after mount)
|
||||||
|
document.addEventListener("community-sync-ready", this._syncReadyHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
document.removeEventListener("click", this._clickOutsideHandler, true);
|
document.removeEventListener("click", this._clickOutsideHandler, true);
|
||||||
|
document.removeEventListener("community-sync-ready", this._syncReadyHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
open() {
|
open() {
|
||||||
this._open = true;
|
this._open = true;
|
||||||
|
// Lazy doc acquisition: try CommunitySync global first, then offline runtime
|
||||||
|
if (!this._doc) {
|
||||||
|
const sync = (window as any).__communitySync;
|
||||||
|
if (sync?.doc) {
|
||||||
|
this._doc = sync.doc;
|
||||||
|
} else {
|
||||||
|
const rt = (window as any).__rspaceOfflineRuntime;
|
||||||
|
if (rt?.documentManager) {
|
||||||
|
const docs = rt.documentManager.listAll?.() || [];
|
||||||
|
if (docs.length > 0) {
|
||||||
|
const doc = rt.documentManager.get(docs[0]);
|
||||||
|
if (doc) this._doc = doc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
this._refreshHistory();
|
this._refreshHistory();
|
||||||
this._render();
|
this._render();
|
||||||
this._positionPanel();
|
|
||||||
document.getElementById("history-btn")?.classList.add("active");
|
document.getElementById("history-btn")?.classList.add("active");
|
||||||
document.addEventListener("click", this._clickOutsideHandler, true);
|
document.addEventListener("click", this._clickOutsideHandler, true);
|
||||||
}
|
}
|
||||||
|
|
@ -88,13 +117,16 @@ export class RStackHistoryPanel extends HTMLElement {
|
||||||
if (this._open) this.close(); else this.open();
|
if (this._open) this.close(); else this.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Position the panel below the history button, right-aligned to button. */
|
/** Position the panel below the history button, right-aligned to button, clamped to viewport. */
|
||||||
private _positionPanel() {
|
private _positionPanel() {
|
||||||
const panel = this.shadowRoot?.querySelector(".panel") as HTMLElement | null;
|
const panel = this.shadowRoot?.querySelector(".panel") as HTMLElement | null;
|
||||||
const btn = document.getElementById("history-btn");
|
const btn = document.getElementById("history-btn");
|
||||||
if (!panel || !btn) return;
|
if (!panel || !btn) return;
|
||||||
const rect = btn.getBoundingClientRect();
|
const rect = btn.getBoundingClientRect();
|
||||||
panel.style.top = `${rect.bottom + 6}px`;
|
const topPos = rect.bottom + 6;
|
||||||
|
panel.style.top = `${topPos}px`;
|
||||||
|
// Clamp max-height so panel never overflows bottom of viewport
|
||||||
|
panel.style.maxHeight = `${window.innerHeight - topPos - 8}px`;
|
||||||
// Right-align to button's right edge, but clamp so left edge stays on screen
|
// Right-align to button's right edge, but clamp so left edge stays on screen
|
||||||
const rightOffset = window.innerWidth - rect.right;
|
const rightOffset = window.innerWidth - rect.right;
|
||||||
panel.style.right = `${rightOffset}px`;
|
panel.style.right = `${rightOffset}px`;
|
||||||
|
|
@ -369,6 +401,8 @@ export class RStackHistoryPanel extends HTMLElement {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this._bindEvents();
|
this._bindEvents();
|
||||||
|
// Re-position after every render since innerHTML replacement destroys inline styles
|
||||||
|
if (this._open) this._positionPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _renderTimeMachineDetails() {
|
private _renderTimeMachineDetails() {
|
||||||
|
|
|
||||||
|
|
@ -115,22 +115,25 @@ export class RStackSpaceSettings extends HTMLElement {
|
||||||
if (this._open) this.close(); else this.open();
|
if (this._open) this.close(); else this.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Position the panel below the settings button, left-aligned with screen clamping. */
|
/** Position the panel below the settings button, clamped to viewport. */
|
||||||
private _positionPanel() {
|
private _positionPanel() {
|
||||||
const panel = this.shadowRoot?.querySelector(".panel") as HTMLElement | null;
|
const panel = this.shadowRoot?.querySelector(".panel") as HTMLElement | null;
|
||||||
const btn = document.getElementById("settings-btn");
|
const btn = document.getElementById("settings-btn");
|
||||||
if (!panel || !btn) return;
|
if (!panel || !btn) return;
|
||||||
const rect = btn.getBoundingClientRect();
|
const rect = btn.getBoundingClientRect();
|
||||||
const panelWidth = Math.min(380, window.innerWidth * 0.9);
|
const panelWidth = Math.min(380, window.innerWidth * 0.9);
|
||||||
|
const topPos = rect.bottom + 6;
|
||||||
// Align panel's left edge with the button, but clamp so it stays on screen
|
// Align panel's left edge with the button, but clamp so it stays on screen
|
||||||
let left = rect.left;
|
let left = rect.left;
|
||||||
if (left + panelWidth > window.innerWidth - 8) {
|
if (left + panelWidth > window.innerWidth - 8) {
|
||||||
left = window.innerWidth - panelWidth - 8;
|
left = window.innerWidth - panelWidth - 8;
|
||||||
}
|
}
|
||||||
if (left < 8) left = 8;
|
if (left < 8) left = 8;
|
||||||
panel.style.top = `${rect.bottom + 6}px`;
|
panel.style.top = `${topPos}px`;
|
||||||
panel.style.left = `${left}px`;
|
panel.style.left = `${left}px`;
|
||||||
panel.style.right = "auto";
|
panel.style.right = "auto";
|
||||||
|
// Clamp max-height so panel never overflows bottom of viewport
|
||||||
|
panel.style.maxHeight = `${window.innerHeight - topPos - 8}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _loadData() {
|
private async _loadData() {
|
||||||
|
|
@ -140,7 +143,7 @@ export class RStackSpaceSettings extends HTMLElement {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
|
|
||||||
// Load community data from WS-synced doc (via global)
|
// Load community data from WS-synced doc (via global)
|
||||||
const sync = (window as any).__rspaceCommunitySync;
|
const sync = (window as any).__communitySync;
|
||||||
if (sync?.doc) {
|
if (sync?.doc) {
|
||||||
const data = sync.doc;
|
const data = sync.doc;
|
||||||
this._ownerDID = data.meta?.ownerDID || "";
|
this._ownerDID = data.meta?.ownerDID || "";
|
||||||
|
|
@ -297,6 +300,9 @@ export class RStackSpaceSettings extends HTMLElement {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track whether this is a re-render (panel already exists)
|
||||||
|
const isRerender = !!this.shadowRoot.querySelector(".panel");
|
||||||
|
|
||||||
const roleOptions = (currentRole: string) => {
|
const roleOptions = (currentRole: string) => {
|
||||||
const roles = ["viewer", "member", "moderator", "admin"];
|
const roles = ["viewer", "member", "moderator", "admin"];
|
||||||
return roles.map(r => `<option value="${r}" ${r === currentRole ? "selected" : ""}>${r}</option>`).join("");
|
return roles.map(r => `<option value="${r}" ${r === currentRole ? "selected" : ""}>${r}</option>`).join("");
|
||||||
|
|
@ -469,6 +475,8 @@ export class RStackSpaceSettings extends HTMLElement {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this._bindEvents();
|
this._bindEvents();
|
||||||
|
// Re-position after every render since innerHTML replacement destroys inline styles
|
||||||
|
this._positionPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _bindEvents() {
|
private _bindEvents() {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue