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;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue