feat(canvas): simplify comment pins — optional notes + link rNotes

Pins no longer require a message. Added "Link existing rNote" picker
to attach space notes to pins. Linked notes show as clickable links
in the popover with unlink option.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-22 19:13:58 -07:00
parent 07e3c7348c
commit 48d11f5ec9
2 changed files with 124 additions and 18 deletions

View File

@ -17,6 +17,13 @@ interface SpaceMember {
username: string; username: string;
} }
interface RNoteItem {
id: string;
title: string;
type: string;
updated_at: string;
}
export class CommentPinManager { export class CommentPinManager {
#container: HTMLElement; #container: HTMLElement;
#canvasContent: HTMLElement; #canvasContent: HTMLElement;
@ -32,6 +39,7 @@ export class CommentPinManager {
#openPinId: string | null = null; #openPinId: string | null = null;
#members: SpaceMember[] | null = null; #members: SpaceMember[] | null = null;
#mentionDropdown: HTMLElement | null = null; #mentionDropdown: HTMLElement | null = null;
#notes: RNoteItem[] | null = null;
constructor( constructor(
container: HTMLElement, container: HTMLElement,
@ -255,6 +263,30 @@ export class CommentPinManager {
this.#renderAllPins(); 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() { toggleShowResolved() {
this.#showResolved = !this.#showResolved; this.#showResolved = !this.#showResolved;
this.#renderAllPins(); this.#renderAllPins();
@ -468,7 +500,20 @@ export class CommentPinManager {
(!this.#sync.doc.shapes?.[pin.anchor.shapeId] || (!this.#sync.doc.shapes?.[pin.anchor.shapeId] ||
(this.#sync.doc.shapes[pin.anchor.shapeId] as any).deleted); (this.#sync.doc.shapes[pin.anchor.shapeId] as any).deleted);
let html = ` 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;"> <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> <span style="font-weight: 700; color: #a78bfa;">Pin #${pinIndex}</span>
<div style="display: flex; gap: 4px;"> <div style="display: flex; gap: 4px;">
@ -479,18 +524,31 @@ export class CommentPinManager {
<button class="cp-action" data-action="delete" title="Delete" style="color: #ef4444;"></button> <button class="cp-action" data-action="delete" title="Delete" style="color: #ef4444;"></button>
</div> </div>
</div> </div>
${isOrphaned ? `<div style="padding: 6px 12px; background: #44200a; color: #fbbf24; font-size: 11px;">⚠ Attached shape was deleted</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 // Messages
if (pin.messages.length > 0) { if (pin.messages.length > 0) {
html += `<div style="max-height: 240px; overflow-y: auto; padding: 8px 12px;">`; html += `<div style="max-height: 200px; overflow-y: auto; padding: 8px 12px;">`;
for (const msg of pin.messages) { for (const msg of pin.messages) {
const time = new Date(msg.createdAt).toLocaleString(undefined, { const time = new Date(msg.createdAt).toLocaleString(undefined, {
month: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}); });
html += ` html += `
<div style="margin-bottom: 10px;"> <div style="margin-bottom: 10px;">
@ -505,11 +563,11 @@ export class CommentPinManager {
html += `</div>`; html += `</div>`;
} }
// Input // Input + link note row
html += ` html += `
<div style="padding: 8px 12px; border-top: 1px solid #333; position: relative;"> <div style="padding: 8px 12px; border-top: 1px solid #333; position: relative;">
<div style="display: flex; gap: 6px;"> <div style="display: flex; gap: 6px;">
<input type="text" class="cp-input" placeholder="Add a comment... (@ to mention)" <input type="text" class="cp-input" placeholder="Add a note... (@ to mention)"
style="flex: 1; background: #2a2a3a; border: 1px solid #444; border-radius: 6px; style="flex: 1; background: #2a2a3a; border: 1px solid #444; border-radius: 6px;
padding: 6px 10px; color: #e0e0e0; font-size: 13px; outline: none;" padding: 6px 10px; color: #e0e0e0; font-size: 13px; outline: none;"
/> />
@ -518,6 +576,13 @@ export class CommentPinManager {
padding: 6px 12px; cursor: pointer; font-size: 13px; font-weight: 600; padding: 6px 12px; cursor: pointer; font-size: 13px; font-weight: 600;
">Send</button> ">Send</button>
</div> </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> </div>
`; `;
@ -534,6 +599,7 @@ export class CommentPinManager {
else if (action === "unresolve") this.unresolvePin(pinId); else if (action === "unresolve") this.unresolvePin(pinId);
else if (action === "delete") this.deletePin(pinId); else if (action === "delete") this.deletePin(pinId);
else if (action === "remind") this.#createReminder(pinId); else if (action === "remind") this.#createReminder(pinId);
else if (action === "unlink-note") this.unlinkNote(pinId);
}); });
}); });
@ -562,18 +628,38 @@ export class CommentPinManager {
// @mention autocomplete // @mention autocomplete
input.addEventListener("input", () => this.#handleMentionInput(input)); 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) { if (focusInput) {
requestAnimationFrame(() => input.focus()); requestAnimationFrame(() => input.focus());
} }
// Style action buttons
const style = document.createElement("style");
style.textContent = `
.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; }
`;
this.#popover.prepend(style);
} }
// ── @Mention Autocomplete ── // ── @Mention Autocomplete ──
@ -594,6 +680,24 @@ export class CommentPinManager {
} }
} }
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) { async #handleMentionInput(input: HTMLInputElement) {
const val = input.value; const val = input.value;
const cursorPos = input.selectionStart || 0; const cursorPos = input.selectionStart || 0;

View File

@ -27,4 +27,6 @@ export interface CommentPinData {
createdAt: number; createdAt: number;
createdBy: string; // DID createdBy: string; // DID
createdByName: string; createdByName: string;
linkedNoteId?: string; // rNotes note ID
linkedNoteTitle?: string; // cached title for display
} }