830 lines
26 KiB
TypeScript
830 lines
26 KiB
TypeScript
/**
|
|
* <rstack-module-comments> — 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<T extends Record<string, any>>(docId: DocumentId, schema: any): Promise<any>;
|
|
unsubscribe(docId: DocumentId): void;
|
|
change<T>(docId: DocumentId, message: string, fn: (doc: T) => void): void;
|
|
get<T>(docId: DocumentId): any;
|
|
onChange<T>(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<typeof setTimeout> | null = null;
|
|
#runtimePollInterval: ReturnType<typeof setInterval> | 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<ModuleCommentsDoc>(this.#docId, moduleCommentsSchema);
|
|
|
|
this.#unsubChange = runtime.onChange<ModuleCommentsDoc>(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<ModuleCommentsDoc>(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<ModuleCommentsDoc>(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<ModuleCommentsDoc>(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<ModuleCommentsDoc>(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<ModuleCommentsDoc>(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<ModuleCommentsDoc>(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 = `<style>${POPOVER_STYLES}</style>`;
|
|
|
|
// Header
|
|
html += `
|
|
<div class="mcp-header">
|
|
<span class="mcp-pin-num">Pin #${pinIndex}</span>
|
|
<div class="mcp-actions">
|
|
${pin.resolved
|
|
? `<button class="mcp-btn" data-action="unresolve" title="Reopen">↩</button>`
|
|
: `<button class="mcp-btn" data-action="resolve" title="Resolve">✓</button>`}
|
|
<button class="mcp-btn mcp-btn-danger" data-action="delete" title="Delete">✕</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Element anchor label
|
|
html += `<div class="mcp-anchor-label">${this.#esc(pin.anchor.elementId)}</div>`;
|
|
|
|
// Messages
|
|
if (pin.messages.length > 0) {
|
|
html += `<div class="mcp-messages">`;
|
|
for (const msg of pin.messages) {
|
|
const time = new Date(msg.createdAt).toLocaleString(undefined, {
|
|
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
|
|
});
|
|
html += `
|
|
<div class="mcp-msg">
|
|
<div class="mcp-msg-top">
|
|
<span class="mcp-msg-author">${this.#esc(msg.authorName)}</span>
|
|
<span class="mcp-msg-time">${time}</span>
|
|
</div>
|
|
<div class="mcp-msg-text">${this.#formatText(msg.text)}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
html += `</div>`;
|
|
}
|
|
|
|
// Input row
|
|
html += `
|
|
<div class="mcp-input-row">
|
|
<input type="text" class="mcp-input" placeholder="Add a comment... (@ to mention)" />
|
|
<button class="mcp-send">Send</button>
|
|
</div>
|
|
`;
|
|
|
|
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 = `
|
|
<span style="font-weight:600;">${this.#esc(m.displayName || m.username)}</span>
|
|
<span style="color:#888;">@${this.#esc(m.username)}</span>
|
|
`;
|
|
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, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
#formatText(text: string): string {
|
|
// Escape first, then render @mentions with highlight
|
|
let escaped = this.#esc(text);
|
|
escaped = escaped.replace(/@(\w+)/g, '<span style="color:#14b8a6;font-weight:600;">@$1</span>');
|
|
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;
|
|
}
|
|
`;
|