/** * CommentPinManager — Figma-style comment pins on the canvas. * Follows the PresenceManager pattern: overlay DOM layer with camera tracking. */ import * as Automerge from "@automerge/automerge"; import type { CommunitySync } from "./community-sync"; import type { CommentPinAnchor, CommentPinMessage, CommentPinData, } from "../shared/comment-pin-types"; interface SpaceMember { did: string; displayName: string | null; username: string; } interface RNoteItem { id: string; title: string; type: string; updated_at: string; } export class CommentPinManager { #container: HTMLElement; #canvasContent: HTMLElement; #sync: CommunitySync; #spaceSlug: string; #panX = 0; #panY = 0; #scale = 1; #pinLayer: HTMLElement; #popover: HTMLElement; #placementMode = false; #showResolved = false; #openPinId: string | null = null; #members: SpaceMember[] | null = null; #mentionDropdown: HTMLElement | null = null; #notes: RNoteItem[] | null = null; constructor( container: HTMLElement, canvasContent: HTMLElement, sync: CommunitySync, spaceSlug: string, ) { this.#container = container; this.#canvasContent = canvasContent; this.#sync = sync; this.#spaceSlug = spaceSlug; // Create pin overlay layer this.#pinLayer = document.createElement("div"); this.#pinLayer.id = "comment-pins-layer"; this.#pinLayer.style.cssText = ` position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 9998; overflow: visible; `; this.#container.appendChild(this.#pinLayer); // Create shared popover this.#popover = document.createElement("div"); this.#popover.id = "comment-pin-popover"; this.#popover.style.cssText = ` display: none; position: absolute; z-index: 10001; background: #1e1e2e; border: 1px solid #444; border-radius: 10px; width: 300px; max-height: 400px; overflow: hidden; box-shadow: 0 8px 32px rgba(0,0,0,0.5); pointer-events: auto; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #e0e0e0; font-size: 13px; `; this.#container.appendChild(this.#popover); // Listen for remote comment pin changes this.#sync.addEventListener("comment-pins-changed", () => this.#renderAllPins()); this.#sync.addEventListener("synced", () => this.#renderAllPins()); // Listen for shape transforms to track attached pins this.#canvasContent.addEventListener("folk-transform", (e: Event) => { const target = e.target as HTMLElement; if (target?.id) this.#refreshPinsForShape(target.id); }); // Close popover on outside click document.addEventListener("pointerdown", (e) => { if (this.#popover.style.display === "none") return; if (this.#popover.contains(e.target as Node)) return; // Don't close if clicking a pin marker if ((e.target as HTMLElement)?.closest?.(".comment-pin-marker")) return; this.closePopover(); }); } // ── Camera ── setCamera(panX: number, panY: number, scale: number) { this.#panX = panX; this.#panY = panY; this.#scale = scale; this.#repositionAllPins(); this.#repositionPopover(); // Hide pins when zoomed out very far this.#pinLayer.style.display = scale < 0.15 ? "none" : ""; } // ── Placement Mode ── get placementMode() { return this.#placementMode; } enterPinPlacementMode() { this.#placementMode = true; this.#container.style.cursor = "crosshair"; } exitPinPlacementMode() { this.#placementMode = false; this.#container.style.cursor = ""; } // ── Pin CRUD ── createPin(anchor: CommentPinAnchor): string { const pinId = crypto.randomUUID(); const did = this.#getLocalDID(); const name = this.#getLocalUsername(); const pinData: CommentPinData = { id: pinId, anchor, resolved: false, messages: [], createdAt: Date.now(), createdBy: did, createdByName: name, }; const newDoc = Automerge.change(this.#sync.doc, "Add comment pin", (doc) => { if (!doc.commentPins) doc.commentPins = {}; doc.commentPins[pinId] = JSON.parse(JSON.stringify(pinData)); }); this.#sync._applyDocChange(newDoc); this.#renderAllPins(); this.#openPinPopover(pinId, true); return pinId; } createPinOnShape(shapeId: string) { const shape = document.getElementById(shapeId) as HTMLElement | null; if (!shape) return; const anchor: CommentPinAnchor = { type: "shape", shapeId, offsetX: 0, offsetY: 0, }; this.createPin(anchor); } handleCanvasClick(worldX: number, worldY: number, clientX?: number, clientY?: number) { if (!this.#placementMode) return false; // Check if clicking on a shape — elementFromPoint needs viewport coords let shapeEl: HTMLElement | null = null; if (clientX !== undefined && clientY !== undefined) { shapeEl = document.elementFromPoint(clientX, clientY) ?.closest?.("folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-prompt, folk-zine-gen, folk-image-gen, folk-video-gen, folk-embed, folk-chat, folk-rapp") as HTMLElement | null; } let anchor: CommentPinAnchor; if (shapeEl?.id) { const shapeData = this.#sync.doc.shapes?.[shapeEl.id]; if (shapeData) { anchor = { type: "shape", shapeId: shapeEl.id, offsetX: worldX - shapeData.x, offsetY: worldY - shapeData.y, }; } else { anchor = { type: "canvas", offsetX: worldX, offsetY: worldY }; } } else { anchor = { type: "canvas", offsetX: worldX, offsetY: worldY }; } this.createPin(anchor); this.exitPinPlacementMode(); return true; } addMessage(pinId: string, text: string) { const did = this.#getLocalDID(); const name = this.#getLocalUsername(); // Extract @mentions const mentionedDids = this.#extractMentionDids(text); const msg: CommentPinMessage = { id: crypto.randomUUID(), authorId: did, authorName: name, text, mentionedDids: mentionedDids.length > 0 ? mentionedDids : undefined, createdAt: Date.now(), }; const newDoc = Automerge.change(this.#sync.doc, "Add comment", (doc) => { if (doc.commentPins?.[pinId]) { doc.commentPins[pinId].messages.push(JSON.parse(JSON.stringify(msg))); } }); this.#sync._applyDocChange(newDoc); // Fire-and-forget notification for mentions if (mentionedDids.length > 0) { this.#notifyMentions(pinId, did, name, mentionedDids); } // Re-render popover if open if (this.#openPinId === pinId) { this.#openPinPopover(pinId, false); } this.#renderAllPins(); } resolvePin(pinId: string) { const newDoc = Automerge.change(this.#sync.doc, "Resolve comment pin", (doc) => { if (doc.commentPins?.[pinId]) { doc.commentPins[pinId].resolved = true; } }); this.#sync._applyDocChange(newDoc); this.closePopover(); this.#renderAllPins(); } unresolvePin(pinId: string) { const newDoc = Automerge.change(this.#sync.doc, "Unresolve comment pin", (doc) => { if (doc.commentPins?.[pinId]) { doc.commentPins[pinId].resolved = false; } }); this.#sync._applyDocChange(newDoc); this.#renderAllPins(); } deletePin(pinId: string) { const newDoc = Automerge.change(this.#sync.doc, "Delete comment pin", (doc) => { if (doc.commentPins?.[pinId]) { delete doc.commentPins[pinId]; } }); this.#sync._applyDocChange(newDoc); this.closePopover(); this.#renderAllPins(); } linkNote(pinId: string, noteId: string, noteTitle: string) { const newDoc = Automerge.change(this.#sync.doc, "Link note to pin", (doc) => { if (doc.commentPins?.[pinId]) { doc.commentPins[pinId].linkedNoteId = noteId; doc.commentPins[pinId].linkedNoteTitle = noteTitle; } }); this.#sync._applyDocChange(newDoc); if (this.#openPinId === pinId) this.#openPinPopover(pinId, false); this.#renderAllPins(); } unlinkNote(pinId: string) { const newDoc = Automerge.change(this.#sync.doc, "Unlink note from pin", (doc) => { if (doc.commentPins?.[pinId]) { delete (doc.commentPins[pinId] as any).linkedNoteId; delete (doc.commentPins[pinId] as any).linkedNoteTitle; } }); this.#sync._applyDocChange(newDoc); if (this.#openPinId === pinId) this.#openPinPopover(pinId, false); this.#renderAllPins(); } toggleShowResolved() { this.#showResolved = !this.#showResolved; this.#renderAllPins(); return this.#showResolved; } closePopover() { this.#popover.style.display = "none"; this.#openPinId = null; this.#closeMentionDropdown(); } openPinById(pinId: string) { const pins = this.#sync.doc.commentPins || {}; if (!pins[pinId]) return; this.#renderAllPins(); this.#openPinPopover(pinId, false); // Pan to the pin const pos = this.#getPinWorldPosition(pins[pinId]); if (pos) { // Dispatch a custom event so canvas.html can animate to position this.#container.dispatchEvent( new CustomEvent("comment-pin-navigate", { detail: { x: pos.x, y: pos.y } }), ); } } // ── Rendering ── #renderAllPins() { const pins = this.#sync.doc.commentPins || {}; const sortedPins = Object.values(pins).sort((a, b) => a.createdAt - b.createdAt); // Detect orphaned pins (shape deleted) const orphanedPins = new Set(); for (const pin of sortedPins) { if (pin.anchor.type === "shape" && pin.anchor.shapeId) { const shapeExists = this.#sync.doc.shapes?.[pin.anchor.shapeId]; const shapeDeleted = shapeExists && (shapeExists as any).deleted === true; if (!shapeExists || shapeDeleted) { orphanedPins.add(pin.id); } } } // Clear existing markers this.#pinLayer.innerHTML = ""; let displayIndex = 0; for (const pin of sortedPins) { displayIndex++; if (pin.resolved && !this.#showResolved) continue; const pos = this.#getPinWorldPosition(pin); if (!pos) continue; const screenX = pos.x * this.#scale + this.#panX; const screenY = pos.y * this.#scale + this.#panY; const isOrphaned = orphanedPins.has(pin.id); const marker = document.createElement("div"); marker.className = "comment-pin-marker"; marker.dataset.pinId = pin.id; marker.style.cssText = ` position: absolute; left: ${screenX}px; top: ${screenY}px; width: 28px; height: 28px; transform: translate(-50%, -100%); pointer-events: auto; cursor: pointer; transition: left 0.05s, top 0.05s; z-index: ${pin.id === this.#openPinId ? 10000 : 9998}; `; const bgColor = pin.resolved ? "#666" : isOrphaned ? "#ef4444" : "#8b5cf6"; const border = isOrphaned ? "2px dashed #fbbf24" : "2px solid rgba(255,255,255,0.3)"; const opacity = pin.resolved ? "0.5" : "1"; marker.innerHTML = `
${displayIndex}
${this.#scale >= 0.4 && pin.messages.length > 0 ? `
${pin.messages.length}
` : ""} `; marker.addEventListener("click", (e) => { e.stopPropagation(); this.#openPinPopover(pin.id, false); }); this.#pinLayer.appendChild(marker); } } #repositionAllPins() { const pins = this.#sync.doc.commentPins || {}; const markers = this.#pinLayer.querySelectorAll(".comment-pin-marker") as NodeListOf; for (const marker of markers) { const pinId = marker.dataset.pinId; if (!pinId || !pins[pinId]) continue; const pos = this.#getPinWorldPosition(pins[pinId]); if (!pos) continue; const screenX = pos.x * this.#scale + this.#panX; const screenY = pos.y * this.#scale + this.#panY; marker.style.left = `${screenX}px`; marker.style.top = `${screenY}px`; // Hide labels when zoomed out const badge = marker.querySelector("div[style*='top: -6px']") as HTMLElement | null; if (badge) badge.style.display = this.#scale >= 0.4 ? "" : "none"; } } #repositionPopover() { if (!this.#openPinId || this.#popover.style.display === "none") return; const pins = this.#sync.doc.commentPins || {}; const pin = pins[this.#openPinId]; if (!pin) return; const pos = this.#getPinWorldPosition(pin); if (!pos) return; const screenX = pos.x * this.#scale + this.#panX; const screenY = pos.y * this.#scale + this.#panY; this.#popover.style.left = `${screenX + 16}px`; this.#popover.style.top = `${screenY}px`; } #refreshPinsForShape(shapeId: string) { const pins = this.#sync.doc.commentPins || {}; let needsReposition = false; for (const pin of Object.values(pins)) { if (pin.anchor.type === "shape" && pin.anchor.shapeId === shapeId) { needsReposition = true; break; } } if (needsReposition) { this.#repositionAllPins(); this.#repositionPopover(); } } #getPinWorldPosition(pin: CommentPinData): { x: number; y: number } | null { if (pin.anchor.type === "canvas") { return { x: pin.anchor.offsetX, y: pin.anchor.offsetY }; } if (pin.anchor.type === "shape" && pin.anchor.shapeId) { const shapeData = this.#sync.doc.shapes?.[pin.anchor.shapeId]; if (!shapeData || (shapeData as any).deleted) { // Orphaned — fall back to anchor offset as world coords return { x: pin.anchor.offsetX, y: pin.anchor.offsetY }; } return { x: shapeData.x + pin.anchor.offsetX, y: shapeData.y + pin.anchor.offsetY, }; } return null; } // ── Popover ── #openPinPopover(pinId: string, focusInput: boolean) { const pins = this.#sync.doc.commentPins || {}; const pin = pins[pinId]; if (!pin) return; this.#openPinId = pinId; const pos = this.#getPinWorldPosition(pin); if (!pos) return; const screenX = pos.x * this.#scale + this.#panX; const screenY = pos.y * this.#scale + this.#panY; // Sequential pin number const sortedPins = Object.values(pins).sort((a, b) => a.createdAt - b.createdAt); const pinIndex = sortedPins.findIndex((p) => p.id === pinId) + 1; const isOrphaned = pin.anchor.type === "shape" && pin.anchor.shapeId && (!this.#sync.doc.shapes?.[pin.anchor.shapeId] || (this.#sync.doc.shapes[pin.anchor.shapeId] as any).deleted); let html = ``; // Header html += `
Pin #${pinIndex}
${pin.resolved ? `` : ``}
`; if (isOrphaned) { html += `
⚠ Attached shape was deleted
`; } // Linked rNote if (pin.linkedNoteId) { html += `
📝 ${this.#escapeHtml(pin.linkedNoteTitle || "Linked note")}
`; } // 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.#escapeHtml(msg.authorName)} ${time}
${this.#formatMessageText(msg.text)}
`; } html += `
`; } // Input + link note row html += `
${!pin.linkedNoteId ? `
` : ""}
`; this.#popover.innerHTML = html; this.#popover.style.display = "block"; this.#popover.style.left = `${screenX + 16}px`; this.#popover.style.top = `${screenY}px`; // Wire up actions this.#popover.querySelectorAll(".cp-action").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); else if (action === "remind") this.#createReminder(pinId); else if (action === "unlink-note") this.unlinkNote(pinId); }); }); // Wire up input const input = this.#popover.querySelector(".cp-input") as HTMLInputElement; const sendBtn = this.#popover.querySelector(".cp-send") as HTMLButtonElement; const submitComment = () => { const text = input.value.trim(); if (!text) return; this.addMessage(pinId, text); input.value = ""; }; 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)); // Link note button const linkBtn = this.#popover.querySelector('[data-action="link-note"]') as HTMLButtonElement | null; const notePicker = this.#popover.querySelector(".cp-note-picker") as HTMLElement | null; if (linkBtn && notePicker) { linkBtn.addEventListener("click", async () => { if (notePicker.style.display !== "none") { notePicker.style.display = "none"; return; } notePicker.innerHTML = `
Loading notes...
`; notePicker.style.display = "block"; const notes = await this.#fetchNotes(); if (notes.length === 0) { notePicker.innerHTML = `
No notes in this space
`; return; } notePicker.innerHTML = ""; for (const note of notes) { const item = document.createElement("div"); item.className = "cp-note-item"; item.textContent = note.title || "Untitled"; item.addEventListener("click", () => { this.linkNote(pinId, note.id, note.title || "Untitled"); }); notePicker.appendChild(item); } }); } if (focusInput) { requestAnimationFrame(() => input.focus()); } } // ── @Mention Autocomplete ── async #fetchMembers() { if (this.#members) return this.#members; try { const sess = JSON.parse(localStorage.getItem("encryptid_session") || "{}"); const res = await fetch(`/${this.#spaceSlug}/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 #fetchNotes(): Promise { if (this.#notes) return this.#notes; try { const sess = JSON.parse(localStorage.getItem("encryptid_session") || "{}"); const res = await fetch(`/${this.#spaceSlug}/rnotes/api/notes?limit=50`, { headers: sess?.accessToken ? { Authorization: `Bearer ${sess.accessToken}` } : {}, }); if (!res.ok) return []; const data = await res.json(); this.#notes = (data.notes || []).map((n: any) => ({ id: n.id, title: n.title, type: n.type, updated_at: n.updated_at, })); return this.#notes!; } catch { return []; } } async #handleMentionInput(input: HTMLInputElement) { const val = input.value; const cursorPos = input.selectionStart || 0; // Find @ before cursor 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.className = "cp-mention-dropdown"; 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.#escapeHtml(m.displayName || m.username)} @${this.#escapeHtml(m.username)} `; item.addEventListener("mousedown", (e) => { e.preventDefault(); // prevent input blur 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 inputContainer = input.closest("div[style*='position: relative']"); if (inputContainer) { inputContainer.appendChild(dropdown); } this.#mentionDropdown = dropdown; } #closeMentionDropdown() { if (this.#mentionDropdown) { this.#mentionDropdown.remove(); this.#mentionDropdown = null; } } #extractMentionDids(text: string): string[] { const mentions = text.match(/@(\w+)/g); if (!mentions || !this.#members) return []; const dids: string[] = []; for (const mention of mentions) { const username = mention.slice(1).toLowerCase(); const member = this.#members.find( (m) => m.username.toLowerCase() === username, ); if (member) dids.push(member.did); } return dids; } // ── Notifications ── async #notifyMentions( pinId: string, authorDid: string, authorName: string, mentionedDids: string[], ) { const pins = this.#sync.doc.commentPins || {}; const sortedPins = Object.values(pins).sort((a, b) => a.createdAt - b.createdAt); const pinIndex = sortedPins.findIndex((p) => p.id === pinId) + 1; try { const sess = JSON.parse(localStorage.getItem("encryptid_session") || "{}"); await fetch(`/${this.#spaceSlug}/api/comment-pins/notify-mention`, { method: "POST", headers: { "Content-Type": "application/json", ...(sess?.accessToken ? { Authorization: `Bearer ${sess.accessToken}` } : {}), }, body: JSON.stringify({ pinId, authorDid, authorName, mentionedDids, pinIndex, }), }); } catch { // Silent fail — notification is best-effort } } // ── Reminder ── async #createReminder(pinId: string) { const pins = this.#sync.doc.commentPins || {}; const pin = pins[pinId]; if (!pin) return; const sortedPins = Object.values(pins).sort((a, b) => a.createdAt - b.createdAt); const pinIndex = sortedPins.findIndex((p) => p.id === pinId) + 1; const firstMsg = pin.messages[0]?.text || "Comment pin"; // Tomorrow 9 AM const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setHours(9, 0, 0, 0); // Fetch user email let email: string | false = false; try { const sess = JSON.parse(localStorage.getItem("encryptid_session") || "{}"); if (sess?.accessToken) { const r = await fetch("/auth/api/account/security", { headers: { Authorization: `Bearer ${sess.accessToken}` }, }); if (r.ok) { const d = await r.json(); email = d.emailAddress || false; } } } catch {} try { const body: Record = { title: `Comment #${pinIndex}: ${firstMsg.slice(0, 50)}`, remindAt: tomorrow.getTime(), allDay: true, syncToCalendar: true, sourceModule: "canvas-comments", sourceEntityId: pinId, sourceLabel: "Canvas Comment", sourceColor: "#8b5cf6", }; if (email) body.notifyEmail = email; const res = await fetch(`/${this.#spaceSlug}/rschedule/api/reminders`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (res.ok) { // Show brief feedback in popover const fb = document.createElement("div"); fb.style.cssText = "padding: 6px 12px; background: #1a3a1a; color: #4ade80; font-size: 12px; text-align: center;"; fb.textContent = "✓ Reminder set for tomorrow"; this.#popover.prepend(fb); setTimeout(() => fb.remove(), 2500); } } catch { // Silent fail } } // ── Helpers ── #getLocalDID(): string { try { const sess = JSON.parse(localStorage.getItem("encryptid_session") || ""); return sess?.claims?.sub || sess?.claims?.did || "anonymous"; } catch { return "anonymous"; } } #getLocalUsername(): string { return localStorage.getItem("rspace-username") || "Anonymous"; } #escapeHtml(text: string): string { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } #formatMessageText(text: string): string { // Escape HTML first let safe = this.#escapeHtml(text); // Highlight @mentions safe = safe.replace( /@(\w+)/g, '@$1', ); return safe; } destroy() { this.#pinLayer.remove(); this.#popover.remove(); } }