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:
parent
07e3c7348c
commit
48d11f5ec9
|
|
@ -17,6 +17,13 @@ interface SpaceMember {
|
|||
username: string;
|
||||
}
|
||||
|
||||
interface RNoteItem {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export class CommentPinManager {
|
||||
#container: HTMLElement;
|
||||
#canvasContent: HTMLElement;
|
||||
|
|
@ -32,6 +39,7 @@ export class CommentPinManager {
|
|||
#openPinId: string | null = null;
|
||||
#members: SpaceMember[] | null = null;
|
||||
#mentionDropdown: HTMLElement | null = null;
|
||||
#notes: RNoteItem[] | null = null;
|
||||
|
||||
constructor(
|
||||
container: HTMLElement,
|
||||
|
|
@ -255,6 +263,30 @@ export class CommentPinManager {
|
|||
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();
|
||||
|
|
@ -468,7 +500,20 @@ export class CommentPinManager {
|
|||
(!this.#sync.doc.shapes?.[pin.anchor.shapeId] ||
|
||||
(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;">
|
||||
<span style="font-weight: 700; color: #a78bfa;">Pin #${pinIndex}</span>
|
||||
<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>
|
||||
</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
|
||||
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) {
|
||||
const time = new Date(msg.createdAt).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
month: "short", day: "numeric", hour: "numeric", minute: "2-digit",
|
||||
});
|
||||
html += `
|
||||
<div style="margin-bottom: 10px;">
|
||||
|
|
@ -505,11 +563,11 @@ export class CommentPinManager {
|
|||
html += `</div>`;
|
||||
}
|
||||
|
||||
// Input
|
||||
// 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 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;
|
||||
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;
|
||||
">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>
|
||||
`;
|
||||
|
||||
|
|
@ -534,6 +599,7 @@ export class CommentPinManager {
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -562,18 +628,38 @@ export class CommentPinManager {
|
|||
// @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());
|
||||
}
|
||||
|
||||
// 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 ──
|
||||
|
|
@ -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) {
|
||||
const val = input.value;
|
||||
const cursorPos = input.selectionStart || 0;
|
||||
|
|
|
|||
|
|
@ -27,4 +27,6 @@ export interface CommentPinData {
|
|||
createdAt: number;
|
||||
createdBy: string; // DID
|
||||
createdByName: string;
|
||||
linkedNoteId?: string; // rNotes note ID
|
||||
linkedNoteTitle?: string; // cached title for display
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue