938 lines
30 KiB
TypeScript
938 lines
30 KiB
TypeScript
/**
|
|
* 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) {
|
|
if (!this.#placementMode) return false;
|
|
|
|
// Check if clicking on a shape
|
|
const shapeEl = document.elementFromPoint(
|
|
worldX * this.#scale + this.#panX,
|
|
worldY * this.#scale + this.#panY,
|
|
)?.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 shapeRect = shapeEl.getBoundingClientRect();
|
|
const canvasRect = this.#container.getBoundingClientRect();
|
|
// Offset relative to shape origin
|
|
const shapeWorldX = (shapeRect.left - canvasRect.left - this.#panX) / this.#scale;
|
|
const shapeWorldY = (shapeRect.top - canvasRect.top - this.#panY) / this.#scale;
|
|
anchor = {
|
|
type: "shape",
|
|
shapeId: shapeEl.id,
|
|
offsetX: worldX - shapeWorldX,
|
|
offsetY: worldY - shapeWorldY,
|
|
};
|
|
} 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<string>();
|
|
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 = `
|
|
<div style="
|
|
width: 28px; height: 28px;
|
|
background: ${bgColor}; border-radius: 50% 50% 50% 0;
|
|
transform: rotate(-45deg);
|
|
display: flex; align-items: center; justify-content: center;
|
|
border: ${border}; opacity: ${opacity};
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
">
|
|
<span style="
|
|
transform: rotate(45deg);
|
|
color: white; font-size: 11px; font-weight: 700;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
">${displayIndex}</span>
|
|
</div>
|
|
${this.#scale >= 0.4 && pin.messages.length > 0 ? `
|
|
<div style="
|
|
position: absolute; top: -6px; right: -6px;
|
|
background: #ef4444; color: white; font-size: 9px;
|
|
min-width: 16px; height: 16px; border-radius: 8px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-weight: 700; transform: rotate(45deg);
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
">${pin.messages.length}</div>
|
|
` : ""}
|
|
`;
|
|
|
|
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<HTMLElement>;
|
|
|
|
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 = `<style>
|
|
.cp-action { background: none; border: 1px solid #444; border-radius: 4px;
|
|
padding: 2px 6px; cursor: pointer; color: #ccc; font-size: 13px; }
|
|
.cp-action:hover { background: #333; }
|
|
.cp-link-btn { background: none; border: 1px solid #444; border-radius: 6px;
|
|
padding: 4px 8px; cursor: pointer; color: #a78bfa; font-size: 12px; width: 100%; text-align: left; }
|
|
.cp-link-btn:hover { background: #2a2a3a; }
|
|
.cp-note-item { padding: 6px 10px; cursor: pointer; font-size: 12px; border-bottom: 1px solid #333; }
|
|
.cp-note-item:hover { background: #3a3a4a; }
|
|
.cp-note-item:last-child { border-bottom: none; }
|
|
</style>`;
|
|
|
|
// Header
|
|
html += `
|
|
<div style="padding: 10px 12px; border-bottom: 1px solid #333; display: flex; align-items: center; justify-content: space-between;">
|
|
<span style="font-weight: 700; color: #a78bfa;">Pin #${pinIndex}</span>
|
|
<div style="display: flex; gap: 4px;">
|
|
${pin.resolved
|
|
? `<button class="cp-action" data-action="unresolve" title="Reopen">↩</button>`
|
|
: `<button class="cp-action" data-action="resolve" title="Resolve">✓</button>`}
|
|
<button class="cp-action" data-action="remind" title="Set reminder">📅</button>
|
|
<button class="cp-action" data-action="delete" title="Delete" style="color: #ef4444;">✕</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
if (isOrphaned) {
|
|
html += `<div style="padding: 6px 12px; background: #44200a; color: #fbbf24; font-size: 11px;">⚠ Attached shape was deleted</div>`;
|
|
}
|
|
|
|
// Linked rNote
|
|
if (pin.linkedNoteId) {
|
|
html += `
|
|
<div style="padding: 8px 12px; background: #1a1a2e; border-bottom: 1px solid #333; display: flex; align-items: center; justify-content: space-between;">
|
|
<a href="/${this.#spaceSlug}/rnotes#${pin.linkedNoteId}" target="_blank"
|
|
style="color: #a78bfa; text-decoration: none; font-size: 12px; font-weight: 600;">
|
|
📝 ${this.#escapeHtml(pin.linkedNoteTitle || "Linked note")}
|
|
</a>
|
|
<button class="cp-action" data-action="unlink-note" title="Unlink" style="font-size: 10px;">✕</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Messages
|
|
if (pin.messages.length > 0) {
|
|
html += `<div style="max-height: 200px; overflow-y: auto; padding: 8px 12px;">`;
|
|
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 style="margin-bottom: 10px;">
|
|
<div style="display: flex; justify-content: space-between; align-items: baseline;">
|
|
<span style="font-weight: 600; color: #c4b5fd; font-size: 12px;">${this.#escapeHtml(msg.authorName)}</span>
|
|
<span style="color: #666; font-size: 10px;">${time}</span>
|
|
</div>
|
|
<div style="margin-top: 3px; line-height: 1.4; word-break: break-word;">${this.#formatMessageText(msg.text)}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
html += `</div>`;
|
|
}
|
|
|
|
// Input + link note row
|
|
html += `
|
|
<div style="padding: 8px 12px; border-top: 1px solid #333; position: relative;">
|
|
<div style="display: flex; gap: 6px;">
|
|
<input type="text" class="cp-input" placeholder="Add a note... (@ to mention)"
|
|
style="flex: 1; background: #2a2a3a; border: 1px solid #444; border-radius: 6px;
|
|
padding: 6px 10px; color: #e0e0e0; font-size: 13px; outline: none;"
|
|
/>
|
|
<button class="cp-send" style="
|
|
background: #8b5cf6; color: white; border: none; border-radius: 6px;
|
|
padding: 6px 12px; cursor: pointer; font-size: 13px; font-weight: 600;
|
|
">Send</button>
|
|
</div>
|
|
${!pin.linkedNoteId ? `
|
|
<div style="margin-top: 6px;">
|
|
<button class="cp-link-btn" data-action="link-note">📝 Link existing rNote...</button>
|
|
<div class="cp-note-picker" style="display: none; max-height: 150px; overflow-y: auto;
|
|
background: #2a2a3a; border: 1px solid #555; border-radius: 6px; margin-top: 4px;"></div>
|
|
</div>
|
|
` : ""}
|
|
</div>
|
|
`;
|
|
|
|
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 = `<div style="padding: 8px; color: #888; font-size: 12px;">Loading notes...</div>`;
|
|
notePicker.style.display = "block";
|
|
const notes = await this.#fetchNotes();
|
|
if (notes.length === 0) {
|
|
notePicker.innerHTML = `<div style="padding: 8px; color: #888; font-size: 12px;">No notes in this space</div>`;
|
|
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<RNoteItem[]> {
|
|
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 = `
|
|
<span style="font-weight: 600;">${this.#escapeHtml(m.displayName || m.username)}</span>
|
|
<span style="color: #888;">@${this.#escapeHtml(m.username)}</span>
|
|
`;
|
|
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<string, any> = {
|
|
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,
|
|
'<span style="color: #a78bfa; font-weight: 600;">@$1</span>',
|
|
);
|
|
return safe;
|
|
}
|
|
|
|
destroy() {
|
|
this.#pinLayer.remove();
|
|
this.#popover.remove();
|
|
}
|
|
}
|