/** * — Spatial comment pins on rApp module pages. * * Anchors Figma-style threaded comment markers to `data-collab-id` elements. * Stores pins in a per-space Automerge doc: `{space}:module-comments:pins`. * * Attributes: `module-id`, `space` (both set by shell). * * Events emitted: * - `module-comment-pins-changed` on window when doc changes * * Events consumed: * - `comment-pin-activate` on window → enter placement mode * - `module-comment-pin-focus` on window → scroll to + highlight a pin */ import { moduleCommentsSchema, moduleCommentsDocId } from '../module-comment-schemas'; import type { ModuleCommentPin, ModuleCommentsDoc } from '../module-comment-types'; import type { CommentPinMessage } from '../comment-pin-types'; import type { DocumentId } from '../local-first/document'; import { getModuleApiBase } from '../url-helpers'; interface SpaceMember { userDID: string; username: string; displayName?: string; } type OfflineRuntime = { isInitialized: boolean; subscribe>(docId: DocumentId, schema: any): Promise; unsubscribe(docId: DocumentId): void; change(docId: DocumentId, message: string, fn: (doc: T) => void): void; get(docId: DocumentId): any; onChange(docId: DocumentId, cb: (doc: any) => void): () => void; }; export class RStackModuleComments extends HTMLElement { #moduleId = ''; #space = ''; #docId: DocumentId | null = null; #runtime: OfflineRuntime | null = null; #unsubChange: (() => void) | null = null; #overlay: HTMLDivElement | null = null; #popover: HTMLDivElement | null = null; #placementMode = false; #activePinId: string | null = null; #members: SpaceMember[] | null = null; #mentionDropdown: HTMLDivElement | null = null; // Observers #resizeObs: ResizeObserver | null = null; #mutationObs: MutationObserver | null = null; #intersectionObs: IntersectionObserver | null = null; #scrollHandler: (() => void) | null = null; #repositionTimer: ReturnType | null = null; #runtimePollInterval: ReturnType | null = null; connectedCallback() { this.#moduleId = this.getAttribute('module-id') || ''; this.#space = this.getAttribute('space') || ''; if (!this.#moduleId || !this.#space || this.#moduleId === 'rspace') return; this.#docId = moduleCommentsDocId(this.#space); // Create overlay layer this.#overlay = document.createElement('div'); this.#overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:9998;'; document.body.appendChild(this.#overlay); // Create popover container this.#popover = document.createElement('div'); this.#popover.style.cssText = ` display:none;position:fixed;z-index:10001;width:340px;max-height:400px; background:#1e1e2e;border:1px solid #444;border-radius:10px; box-shadow:0 8px 30px rgba(0,0,0,0.4);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; color:#e0e0e0;font-size:13px;overflow:hidden;pointer-events:auto; `; document.body.appendChild(this.#popover); // Try connecting to runtime this.#tryConnect(); // Listen for events window.addEventListener('comment-pin-activate', this.#onActivate); window.addEventListener('module-comment-pin-focus', this.#onPinFocus as EventListener); document.addEventListener('pointerdown', this.#onDocumentClick); } disconnectedCallback() { if (this.#unsubChange) this.#unsubChange(); if (this.#runtimePollInterval) clearInterval(this.#runtimePollInterval); if (this.#resizeObs) this.#resizeObs.disconnect(); if (this.#mutationObs) this.#mutationObs.disconnect(); if (this.#intersectionObs) this.#intersectionObs.disconnect(); if (this.#scrollHandler) window.removeEventListener('scroll', this.#scrollHandler, true); if (this.#repositionTimer) clearTimeout(this.#repositionTimer); if (this.#overlay) this.#overlay.remove(); if (this.#popover) this.#popover.remove(); window.removeEventListener('comment-pin-activate', this.#onActivate); window.removeEventListener('module-comment-pin-focus', this.#onPinFocus as EventListener); document.removeEventListener('pointerdown', this.#onDocumentClick); this.#exitPlacementMode(); } // ── Runtime Connection ── #tryConnect() { const runtime = (window as any).__rspaceOfflineRuntime as OfflineRuntime | undefined; if (runtime?.isInitialized) { this.#onRuntimeReady(runtime); } else { let polls = 0; this.#runtimePollInterval = setInterval(() => { const rt = (window as any).__rspaceOfflineRuntime as OfflineRuntime | undefined; if (rt?.isInitialized) { clearInterval(this.#runtimePollInterval!); this.#runtimePollInterval = null; this.#onRuntimeReady(rt); } else if (++polls > 20) { clearInterval(this.#runtimePollInterval!); this.#runtimePollInterval = null; } }, 500); } } async #onRuntimeReady(runtime: OfflineRuntime) { this.#runtime = runtime; if (!this.#docId) return; await runtime.subscribe(this.#docId, moduleCommentsSchema); this.#unsubChange = runtime.onChange(this.#docId, () => { this.#renderPins(); window.dispatchEvent(new CustomEvent('module-comment-pins-changed')); }); // Set up observers for repositioning this.#setupObservers(); this.#renderPins(); } // ── Observers ── #setupObservers() { // Debounced reposition const debouncedReposition = () => { if (this.#repositionTimer) clearTimeout(this.#repositionTimer); this.#repositionTimer = setTimeout(() => this.#renderPins(), 100); }; // Scroll (capture phase for inner scrollable elements) this.#scrollHandler = debouncedReposition; window.addEventListener('scroll', this.#scrollHandler, true); // Resize this.#resizeObs = new ResizeObserver(debouncedReposition); this.#resizeObs.observe(document.body); // DOM mutations (elements added/removed) this.#mutationObs = new MutationObserver(debouncedReposition); this.#mutationObs.observe(document.body, { childList: true, subtree: true }); } // ── Pin Rendering ── #getDoc(): ModuleCommentsDoc | undefined { if (!this.#runtime || !this.#docId) return undefined; return this.#runtime.get(this.#docId); } #getPinsForModule(): [string, ModuleCommentPin][] { const doc = this.#getDoc(); if (!doc?.pins) return []; return Object.entries(doc.pins).filter( ([, pin]) => pin.anchor.moduleId === this.#moduleId ); } #findCollabEl(id: string): Element | null { const sel = `[data-collab-id="${CSS.escape(id)}"]`; const found = document.querySelector(sel); if (found) return found; // Walk into shadow roots (one level deep — rApp components) for (const el of document.querySelectorAll('*')) { if (el.shadowRoot) { const inner = el.shadowRoot.querySelector(sel); if (inner) return inner; } } return null; } #renderPins() { if (!this.#overlay) return; const pins = this.#getPinsForModule(); // Clear existing markers this.#overlay.innerHTML = ''; for (const [pinId, pin] of pins) { const el = this.#findCollabEl(pin.anchor.elementId); if (!el) continue; const rect = el.getBoundingClientRect(); // Skip if off-screen if (rect.bottom < 0 || rect.top > window.innerHeight || rect.right < 0 || rect.left > window.innerWidth) continue; const marker = document.createElement('div'); marker.dataset.pinId = pinId; const isActive = this.#activePinId === pinId; const isResolved = pin.resolved; const msgCount = pin.messages?.length || 0; marker.style.cssText = ` position:fixed; left:${rect.right - 12}px; top:${rect.top - 8}px; width:24px;height:24px; border-radius:50% 50% 50% 0; transform:rotate(-45deg); cursor:pointer; pointer-events:auto; display:flex;align-items:center;justify-content:center; font-size:10px;font-weight:700;color:white; transition:all 0.15s ease; box-shadow:0 2px 8px rgba(0,0,0,0.3); ${isResolved ? 'background:#64748b;opacity:0.6;' : isActive ? 'background:#8b5cf6;transform:rotate(-45deg) scale(1.15);' : 'background:#14b8a6;'} `; // Badge with message count if (msgCount > 0) { const badge = document.createElement('span'); badge.textContent = String(msgCount); badge.style.cssText = 'transform:rotate(45deg);'; marker.appendChild(badge); } marker.addEventListener('click', (e) => { e.stopPropagation(); this.#openPopover(pinId, rect.right + 8, rect.top); }); this.#overlay.appendChild(marker); } } // ── Placement Mode ── #onActivate = () => { // Only handle if we're on a module page (not canvas) if (this.#moduleId === 'rspace' || !this.#runtime) return; if (this.#placementMode) { this.#exitPlacementMode(); } else { this.#enterPlacementMode(); } }; #enterPlacementMode() { this.#placementMode = true; document.body.style.cursor = 'crosshair'; // Highlight valid targets this.#highlightCollabElements(true); } #exitPlacementMode() { this.#placementMode = false; document.body.style.cursor = ''; this.#highlightCollabElements(false); } #highlightCollabElements(show: boolean) { const els = document.querySelectorAll('[data-collab-id]'); els.forEach((el) => { (el as HTMLElement).style.outline = show ? '2px dashed rgba(20,184,166,0.4)' : ''; (el as HTMLElement).style.outlineOffset = show ? '2px' : ''; }); // Also check shadow roots for (const el of document.querySelectorAll('*')) { if (el.shadowRoot) { el.shadowRoot.querySelectorAll('[data-collab-id]').forEach((inner) => { (inner as HTMLElement).style.outline = show ? '2px dashed rgba(20,184,166,0.4)' : ''; (inner as HTMLElement).style.outlineOffset = show ? '2px' : ''; }); } } } #onDocumentClick = (e: PointerEvent) => { if (!this.#placementMode) { // Close popover on outside click if (this.#popover?.style.display !== 'none' && !this.#popover?.contains(e.target as Node) && !(e.target as HTMLElement)?.closest?.('[data-pin-id]')) { this.#closePopover(); } return; } const target = (e.target as HTMLElement).closest('[data-collab-id]'); if (!target) return; // Ignore clicks not on collab elements e.preventDefault(); e.stopPropagation(); const elementId = target.getAttribute('data-collab-id')!; this.#exitPlacementMode(); this.#createPin(elementId); }; // ── CRDT Operations ── #getUserInfo(): { did: string; name: string } { try { const sess = JSON.parse(localStorage.getItem('encryptid_session') || '{}'); return { did: sess.did || 'anon', name: sess.username || sess.displayName || 'Anonymous', }; } catch { return { did: 'anon', name: 'Anonymous' }; } } #createPin(elementId: string) { if (!this.#runtime || !this.#docId) return; const user = this.#getUserInfo(); const pinId = crypto.randomUUID(); this.#runtime.change(this.#docId, 'Add module comment pin', (doc) => { doc.pins[pinId] = { id: pinId, anchor: { type: 'element', elementId, moduleId: this.#moduleId }, resolved: false, messages: [], createdAt: Date.now(), createdBy: user.did, createdByName: user.name, }; }); // Open popover immediately for the new pin requestAnimationFrame(() => { const el = this.#findCollabEl(elementId); if (el) { const rect = el.getBoundingClientRect(); this.#openPopover(pinId, rect.right + 8, rect.top, true); } }); } #addMessage(pinId: string, text: string) { if (!this.#runtime || !this.#docId) return; const user = this.#getUserInfo(); const msgId = crypto.randomUUID(); // Extract @mentions const mentionedDids = this.#extractMentions(text); this.#runtime.change(this.#docId, 'Add comment', (doc) => { const pin = doc.pins[pinId]; if (!pin) return; pin.messages.push({ id: msgId, authorId: user.did, authorName: user.name, text, mentionedDids: mentionedDids.length > 0 ? mentionedDids : undefined, createdAt: Date.now(), }); }); // Notify space members this.#notifySpaceMembers(pinId, text, user, mentionedDids); // Notify @mentioned users if (mentionedDids.length > 0) { this.#notifyMentions(pinId, user, mentionedDids); } } #resolvePin(pinId: string) { if (!this.#runtime || !this.#docId) return; this.#runtime.change(this.#docId, 'Resolve pin', (doc) => { const pin = doc.pins[pinId]; if (pin) pin.resolved = true; }); this.#closePopover(); } #unresolvePin(pinId: string) { if (!this.#runtime || !this.#docId) return; this.#runtime.change(this.#docId, 'Unresolve pin', (doc) => { const pin = doc.pins[pinId]; if (pin) pin.resolved = false; }); } #deletePin(pinId: string) { if (!this.#runtime || !this.#docId) return; this.#runtime.change(this.#docId, 'Delete pin', (doc) => { delete doc.pins[pinId]; }); this.#closePopover(); } // ── Notifications ── async #notifySpaceMembers(pinId: string, text: string, user: { did: string; name: string }, mentionedDids: string[]) { const doc = this.#getDoc(); if (!doc?.pins[pinId]) return; const pin = doc.pins[pinId]; const isReply = pin.messages.length > 1; const pinIndex = Object.values(doc.pins) .sort((a, b) => a.createdAt - b.createdAt) .findIndex((p) => p.id === pinId) + 1; try { const base = getModuleApiBase('rspace'); await fetch(`${base}/api/comment-pins/notify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pinId, authorDid: user.did, authorName: user.name, text, pinIndex, isReply, mentionedDids, moduleId: this.#moduleId, }), }); } catch { /* fire and forget */ } } async #notifyMentions(pinId: string, user: { did: string; name: string }, mentionedDids: string[]) { const doc = this.#getDoc(); if (!doc?.pins[pinId]) return; const pinIndex = Object.values(doc.pins) .sort((a, b) => a.createdAt - b.createdAt) .findIndex((p) => p.id === pinId) + 1; try { const base = getModuleApiBase('rspace'); await fetch(`${base}/api/comment-pins/notify-mention`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pinId, authorDid: user.did, authorName: user.name, mentionedDids, pinIndex, moduleId: this.#moduleId, }), }); } catch { /* fire and forget */ } } #extractMentions(text: string): string[] { if (!this.#members) return []; const matches = text.match(/@(\w+)/g); if (!matches) return []; const dids: string[] = []; for (const match of matches) { const username = match.slice(1).toLowerCase(); const member = this.#members.find( (m) => m.username.toLowerCase() === username ); if (member) dids.push(member.userDID); } return dids; } // ── Popover ── #openPopover(pinId: string, x: number, y: number, focusInput = false) { if (!this.#popover) return; this.#activePinId = pinId; const doc = this.#getDoc(); const pin = doc?.pins[pinId]; if (!pin) return; const sortedPins = Object.values(doc!.pins).sort((a, b) => a.createdAt - b.createdAt); const pinIndex = sortedPins.findIndex((p) => p.id === pinId) + 1; let html = ``; // Header html += `
Pin #${pinIndex}
${pin.resolved ? `` : ``}
`; // Element anchor label html += `
${this.#esc(pin.anchor.elementId)}
`; // Messages if (pin.messages.length > 0) { html += `
`; for (const msg of pin.messages) { const time = new Date(msg.createdAt).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', }); html += `
${this.#esc(msg.authorName)} ${time}
${this.#formatText(msg.text)}
`; } html += `
`; } // Input row html += `
`; this.#popover.innerHTML = html; this.#popover.style.display = 'block'; // Position — keep within viewport const popW = 340, popH = 400; const clampedX = Math.min(x, window.innerWidth - popW - 8); const clampedY = Math.min(Math.max(y, 8), window.innerHeight - popH - 8); this.#popover.style.left = `${clampedX}px`; this.#popover.style.top = `${clampedY}px`; // Wire actions this.#popover.querySelectorAll('.mcp-btn').forEach((btn) => { btn.addEventListener('click', (e) => { const action = (e.currentTarget as HTMLElement).dataset.action; if (action === 'resolve') this.#resolvePin(pinId); else if (action === 'unresolve') this.#unresolvePin(pinId); else if (action === 'delete') this.#deletePin(pinId); }); }); // Wire input const input = this.#popover.querySelector('.mcp-input') as HTMLInputElement; const sendBtn = this.#popover.querySelector('.mcp-send') as HTMLButtonElement; const submitComment = () => { const text = input.value.trim(); if (!text) return; this.#addMessage(pinId, text); input.value = ''; // Re-render popover to show new message requestAnimationFrame(() => { const el = this.#findCollabEl(pin.anchor.elementId); if (el) { const rect = el.getBoundingClientRect(); this.#openPopover(pinId, rect.right + 8, rect.top); } }); }; sendBtn.addEventListener('click', submitComment); input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submitComment(); } if (e.key === 'Escape') this.#closePopover(); }); // @mention autocomplete input.addEventListener('input', () => this.#handleMentionInput(input)); // Prevent popover clicks from closing it this.#popover.addEventListener('pointerdown', (e) => e.stopPropagation()); if (focusInput) { requestAnimationFrame(() => input.focus()); } this.#renderPins(); // Update active state on markers } #closePopover() { if (this.#popover) this.#popover.style.display = 'none'; this.#activePinId = null; this.#closeMentionDropdown(); this.#renderPins(); } // ── Pin Focus (from bell) ── #onPinFocus = (e: CustomEvent) => { const { pinId, elementId } = e.detail || {}; if (!pinId) return; const doc = this.#getDoc(); const pin = doc?.pins[pinId]; if (!pin || pin.anchor.moduleId !== this.#moduleId) return; const el = this.#findCollabEl(pin.anchor.elementId || elementId); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); requestAnimationFrame(() => { const rect = el.getBoundingClientRect(); this.#openPopover(pinId, rect.right + 8, rect.top); }); } }; // ── @Mention Autocomplete ── async #fetchMembers() { if (this.#members) return this.#members; try { const sess = JSON.parse(localStorage.getItem('encryptid_session') || '{}'); const res = await fetch(`${getModuleApiBase('rspace')}/api/space-members`, { headers: sess?.accessToken ? { Authorization: `Bearer ${sess.accessToken}` } : {}, }); if (!res.ok) return []; const data = await res.json(); this.#members = data.members || []; return this.#members!; } catch { return []; } } async #handleMentionInput(input: HTMLInputElement) { const val = input.value; const cursorPos = input.selectionStart || 0; const before = val.slice(0, cursorPos); const atMatch = before.match(/@(\w*)$/); if (!atMatch) { this.#closeMentionDropdown(); return; } const query = atMatch[1].toLowerCase(); const members = await this.#fetchMembers(); const filtered = members.filter( (m) => m.username.toLowerCase().includes(query) || (m.displayName && m.displayName.toLowerCase().includes(query)), ); if (filtered.length === 0) { this.#closeMentionDropdown(); return; } this.#showMentionDropdown(filtered, input, atMatch.index!); } #showMentionDropdown(members: SpaceMember[], input: HTMLInputElement, atIndex: number) { this.#closeMentionDropdown(); const dropdown = document.createElement('div'); dropdown.style.cssText = ` position:absolute;bottom:100%;left:12px;right:12px; background:#2a2a3a;border:1px solid #555;border-radius:6px; max-height:150px;overflow-y:auto;z-index:10002; `; for (const m of members.slice(0, 8)) { const item = document.createElement('div'); item.style.cssText = 'padding:6px 10px;cursor:pointer;font-size:12px;display:flex;justify-content:space-between;'; item.innerHTML = ` ${this.#esc(m.displayName || m.username)} @${this.#esc(m.username)} `; item.addEventListener('mousedown', (e) => { e.preventDefault(); const val = input.value; const before = val.slice(0, atIndex); const after = val.slice(input.selectionStart || atIndex); input.value = `${before}@${m.username} ${after}`; input.focus(); const newPos = atIndex + m.username.length + 2; input.setSelectionRange(newPos, newPos); this.#closeMentionDropdown(); }); item.addEventListener('mouseenter', () => { item.style.background = '#3a3a4a'; }); item.addEventListener('mouseleave', () => { item.style.background = ''; }); dropdown.appendChild(item); } const inputRow = this.#popover?.querySelector('.mcp-input-row'); if (inputRow) { (inputRow as HTMLElement).style.position = 'relative'; inputRow.appendChild(dropdown); } this.#mentionDropdown = dropdown; } #closeMentionDropdown() { if (this.#mentionDropdown) { this.#mentionDropdown.remove(); this.#mentionDropdown = null; } } // ── Helpers ── #esc(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } #formatText(text: string): string { // Escape first, then render @mentions with highlight let escaped = this.#esc(text); escaped = escaped.replace(/@(\w+)/g, '@$1'); return escaped; } static define(tag = 'rstack-module-comments') { if (!customElements.get(tag)) customElements.define(tag, RStackModuleComments); } } // ── Popover Styles ── const POPOVER_STYLES = ` .mcp-header { padding: 10px 12px; border-bottom: 1px solid #333; display: flex; align-items: center; justify-content: space-between; } .mcp-pin-num { font-weight: 700; color: #14b8a6; } .mcp-actions { display: flex; gap: 4px; } .mcp-btn { background: none; border: 1px solid #444; border-radius: 4px; padding: 2px 6px; cursor: pointer; color: #ccc; font-size: 13px; } .mcp-btn:hover { background: #333; } .mcp-btn-danger { color: #ef4444; } .mcp-btn-danger:hover { background: #3a1a1a; } .mcp-anchor-label { padding: 4px 12px; font-size: 11px; color: #64748b; border-bottom: 1px solid #333; background: rgba(255,255,255,0.02); } .mcp-messages { max-height: 200px; overflow-y: auto; padding: 8px 12px; } .mcp-msg { margin-bottom: 10px; } .mcp-msg-top { display: flex; justify-content: space-between; align-items: baseline; } .mcp-msg-author { font-weight: 600; color: #c4b5fd; font-size: 12px; } .mcp-msg-time { color: #666; font-size: 10px; } .mcp-msg-text { margin-top: 3px; line-height: 1.4; word-break: break-word; } .mcp-input-row { padding: 8px 12px; border-top: 1px solid #333; display: flex; gap: 6px; } .mcp-input { flex: 1; background: #2a2a3a; border: 1px solid #444; border-radius: 6px; padding: 6px 10px; color: #e0e0e0; font-size: 13px; outline: none; } .mcp-input:focus { border-color: #14b8a6; } .mcp-send { background: #14b8a6; color: white; border: none; border-radius: 6px; padding: 6px 12px; cursor: pointer; font-size: 13px; font-weight: 600; } .mcp-send:hover { background: #0d9488; } `;