feat(rnotes,canvas): comments in demo mode, emoji reactions, reminders + inline picker modal
- rNotes comments now work in demo mode via in-memory thread storage - Added emoji reactions (7-emoji palette) and date reminders on comment threads - Reminders integrate with rSchedule API for persistent notifications - Canvas toolbar: split Note into "Blank Note" (always available) and "From rNotes" (picker) - Replaced browser prompt()-based pickFromList with showPickerModal (dark-themed, searchable, keyboard nav) - Updated pickTrip, destination, and booking handlers to use the new modal Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1ae0609f14
commit
3f496d9fc6
|
|
@ -4,6 +4,8 @@
|
||||||
* Shows threaded comments anchored to highlighted text in the editor.
|
* Shows threaded comments anchored to highlighted text in the editor.
|
||||||
* Comment thread data is stored in Automerge, while the highlight mark
|
* Comment thread data is stored in Automerge, while the highlight mark
|
||||||
* position is stored in Yjs (part of the document content).
|
* position is stored in Yjs (part of the document content).
|
||||||
|
*
|
||||||
|
* Supports: demo mode (in-memory), emoji reactions, date reminders.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Editor } from '@tiptap/core';
|
import type { Editor } from '@tiptap/core';
|
||||||
|
|
@ -23,6 +25,9 @@ interface CommentThread {
|
||||||
resolved: boolean;
|
resolved: boolean;
|
||||||
messages: CommentMessage[];
|
messages: CommentMessage[];
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
reactions?: Record<string, string[]>;
|
||||||
|
reminderAt?: number;
|
||||||
|
reminderId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NotebookDoc {
|
interface NotebookDoc {
|
||||||
|
|
@ -33,6 +38,8 @@ interface NotebookDoc {
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REACTION_EMOJIS = ['👍', '👎', '❤️', '🎉', '😂', '😮', '🔥'];
|
||||||
|
|
||||||
class NotesCommentPanel extends HTMLElement {
|
class NotesCommentPanel extends HTMLElement {
|
||||||
private shadow: ShadowRoot;
|
private shadow: ShadowRoot;
|
||||||
private _noteId: string | null = null;
|
private _noteId: string | null = null;
|
||||||
|
|
@ -40,6 +47,8 @@ class NotesCommentPanel extends HTMLElement {
|
||||||
private _subscribedDocId: string | null = null;
|
private _subscribedDocId: string | null = null;
|
||||||
private _activeThreadId: string | null = null;
|
private _activeThreadId: string | null = null;
|
||||||
private _editor: Editor | null = null;
|
private _editor: Editor | null = null;
|
||||||
|
private _demoThreads: Record<string, CommentThread> | null = null;
|
||||||
|
private _space = '';
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
@ -51,8 +60,33 @@ class NotesCommentPanel extends HTMLElement {
|
||||||
set subscribedDocId(v: string | null) { this._subscribedDocId = v; }
|
set subscribedDocId(v: string | null) { this._subscribedDocId = v; }
|
||||||
set activeThreadId(v: string | null) { this._activeThreadId = v; this.render(); }
|
set activeThreadId(v: string | null) { this._activeThreadId = v; this.render(); }
|
||||||
set editor(v: Editor | null) { this._editor = v; }
|
set editor(v: Editor | null) { this._editor = v; }
|
||||||
|
set space(v: string) { this._space = v; }
|
||||||
|
set demoThreads(v: Record<string, CommentThread> | null) {
|
||||||
|
this._demoThreads = v;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private get isDemo(): boolean {
|
||||||
|
return this._space === 'demo';
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSessionInfo(): { authorName: string; authorId: string } {
|
||||||
|
try {
|
||||||
|
const sess = JSON.parse(localStorage.getItem('encryptid_session') || '{}');
|
||||||
|
return {
|
||||||
|
authorName: sess?.username || sess?.displayName || 'Anonymous',
|
||||||
|
authorId: sess?.userId || sess?.sub || 'anon',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { authorName: 'Anonymous', authorId: 'anon' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private getThreads(): CommentThread[] {
|
private getThreads(): CommentThread[] {
|
||||||
|
// Demo threads take priority
|
||||||
|
if (this._demoThreads) {
|
||||||
|
return Object.values(this._demoThreads).sort((a, b) => a.createdAt - b.createdAt);
|
||||||
|
}
|
||||||
if (!this._doc || !this._noteId) return [];
|
if (!this._doc || !this._noteId) return [];
|
||||||
const item = this._doc.items?.[this._noteId];
|
const item = this._doc.items?.[this._noteId];
|
||||||
if (!item?.comments) return [];
|
if (!item?.comments) return [];
|
||||||
|
|
@ -60,6 +94,15 @@ class NotesCommentPanel extends HTMLElement {
|
||||||
.sort((a, b) => a.createdAt - b.createdAt);
|
.sort((a, b) => a.createdAt - b.createdAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private dispatchDemoMutation() {
|
||||||
|
if (!this._demoThreads || !this._noteId) return;
|
||||||
|
this.dispatchEvent(new CustomEvent('comment-demo-mutation', {
|
||||||
|
detail: { noteId: this._noteId, threads: { ...this._demoThreads } },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
private render() {
|
private render() {
|
||||||
const threads = this.getThreads();
|
const threads = this.getThreads();
|
||||||
if (threads.length === 0 && !this._activeThreadId) {
|
if (threads.length === 0 && !this._activeThreadId) {
|
||||||
|
|
@ -75,6 +118,8 @@ class NotesCommentPanel extends HTMLElement {
|
||||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||||
return `${Math.floor(diff / 86400000)}d ago`;
|
return `${Math.floor(diff / 86400000)}d ago`;
|
||||||
};
|
};
|
||||||
|
const formatDate = (ts: number) => new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
const { authorId: currentUserId } = this.getSessionInfo();
|
||||||
|
|
||||||
this.shadow.innerHTML = `
|
this.shadow.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -99,12 +144,34 @@ class NotesCommentPanel extends HTMLElement {
|
||||||
.reply-input:focus { border-color: var(--rs-primary, #3b82f6); outline: none; }
|
.reply-input:focus { border-color: var(--rs-primary, #3b82f6); outline: none; }
|
||||||
.reply-btn { padding: 6px 10px; border: none; background: var(--rs-primary, #3b82f6); color: #fff; border-radius: 6px; font-size: 12px; cursor: pointer; font-weight: 500; }
|
.reply-btn { padding: 6px 10px; border: none; background: var(--rs-primary, #3b82f6); color: #fff; border-radius: 6px; font-size: 12px; cursor: pointer; font-weight: 500; }
|
||||||
.reply-btn:hover { opacity: 0.9; }
|
.reply-btn:hover { opacity: 0.9; }
|
||||||
|
/* Reactions */
|
||||||
|
.reactions-row { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; align-items: center; }
|
||||||
|
.reaction-pill { display: inline-flex; align-items: center; gap: 2px; padding: 2px 6px; border-radius: 12px; border: 1px solid var(--rs-border-subtle, #e0e0e0); background: var(--rs-bg-surface, #fff); font-size: 12px; cursor: pointer; transition: all 0.15s; user-select: none; }
|
||||||
|
.reaction-pill:hover { border-color: var(--rs-primary, #3b82f6); }
|
||||||
|
.reaction-pill.active { border-color: var(--rs-primary, #3b82f6); background: color-mix(in srgb, var(--rs-primary, #3b82f6) 10%, transparent); }
|
||||||
|
.reaction-pill .count { font-size: 11px; color: var(--rs-text-secondary, #666); }
|
||||||
|
.reaction-add { padding: 2px 6px; border-radius: 12px; border: 1px dashed var(--rs-border-subtle, #ddd); background: none; font-size: 12px; cursor: pointer; color: var(--rs-text-muted, #999); }
|
||||||
|
.reaction-add:hover { border-color: var(--rs-primary, #3b82f6); color: var(--rs-text-primary, #111); }
|
||||||
|
.emoji-picker { display: none; flex-wrap: wrap; gap: 2px; padding: 4px; background: var(--rs-bg-surface, #fff); border: 1px solid var(--rs-border, #e5e7eb); border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.12); margin-top: 4px; }
|
||||||
|
.emoji-picker.open { display: flex; }
|
||||||
|
.emoji-pick { padding: 4px 6px; border: none; background: none; font-size: 16px; cursor: pointer; border-radius: 4px; }
|
||||||
|
.emoji-pick:hover { background: var(--rs-bg-hover, #f5f5f5); }
|
||||||
|
/* Reminders */
|
||||||
|
.reminder-row { margin-top: 6px; display: flex; align-items: center; gap: 6px; font-size: 12px; }
|
||||||
|
.reminder-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 12px; background: color-mix(in srgb, var(--rs-warning, #f59e0b) 15%, transparent); color: var(--rs-text-primary, #111); font-size: 11px; }
|
||||||
|
.reminder-btn { padding: 2px 8px; border: 1px solid var(--rs-border-subtle, #ddd); border-radius: 12px; background: none; font-size: 11px; cursor: pointer; color: var(--rs-text-secondary, #666); }
|
||||||
|
.reminder-btn:hover { border-color: var(--rs-primary, #3b82f6); }
|
||||||
|
.reminder-clear { padding: 1px 4px; border: none; background: none; font-size: 10px; cursor: pointer; color: var(--rs-text-muted, #999); }
|
||||||
|
.reminder-date-input { padding: 2px 6px; border: 1px solid var(--rs-input-border, #ddd); border-radius: 6px; font-size: 11px; background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111); }
|
||||||
</style>
|
</style>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-title">
|
<div class="panel-title">
|
||||||
<span>Comments (${threads.filter(t => !t.resolved).length})</span>
|
<span>Comments (${threads.filter(t => !t.resolved).length})</span>
|
||||||
</div>
|
</div>
|
||||||
${threads.map(thread => `
|
${threads.map(thread => {
|
||||||
|
const reactions = thread.reactions || {};
|
||||||
|
const reactionEntries = Object.entries(reactions).filter(([, users]) => users.length > 0);
|
||||||
|
return `
|
||||||
<div class="thread ${thread.id === this._activeThreadId ? 'active' : ''} ${thread.resolved ? 'resolved' : ''}" data-thread="${thread.id}">
|
<div class="thread ${thread.id === this._activeThreadId ? 'active' : ''} ${thread.resolved ? 'resolved' : ''}" data-thread="${thread.id}">
|
||||||
<div class="thread-header">
|
<div class="thread-header">
|
||||||
<span class="thread-author">${esc(thread.messages[0]?.authorName || 'Anonymous')}</span>
|
<span class="thread-author">${esc(thread.messages[0]?.authorName || 'Anonymous')}</span>
|
||||||
|
|
@ -117,6 +184,22 @@ class NotesCommentPanel extends HTMLElement {
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
${thread.messages.length === 0 ? '<div class="message"><div class="message-text" style="color: var(--rs-text-muted, #999)">Click to add a comment...</div></div>' : ''}
|
${thread.messages.length === 0 ? '<div class="message"><div class="message-text" style="color: var(--rs-text-muted, #999)">Click to add a comment...</div></div>' : ''}
|
||||||
|
<div class="reactions-row">
|
||||||
|
${reactionEntries.map(([emoji, users]) => `
|
||||||
|
<button class="reaction-pill ${users.includes(currentUserId) ? 'active' : ''}" data-react-thread="${thread.id}" data-react-emoji="${emoji}">${emoji} <span class="count">${users.length}</span></button>
|
||||||
|
`).join('')}
|
||||||
|
<button class="reaction-add" data-react-add="${thread.id}">+</button>
|
||||||
|
</div>
|
||||||
|
<div class="emoji-picker" data-picker="${thread.id}">
|
||||||
|
${REACTION_EMOJIS.map(e => `<button class="emoji-pick" data-pick-thread="${thread.id}" data-pick-emoji="${e}">${e}</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
<div class="reminder-row">
|
||||||
|
${thread.reminderAt
|
||||||
|
? `<span class="reminder-badge">⏰ ${formatDate(thread.reminderAt)}</span><button class="reminder-clear" data-remind-clear="${thread.id}">✕</button>`
|
||||||
|
: `<button class="reminder-btn" data-remind-set="${thread.id}">⏰ Remind me</button>`
|
||||||
|
}
|
||||||
|
<input type="date" class="reminder-date-input" data-remind-input="${thread.id}" style="display:none">
|
||||||
|
</div>
|
||||||
<div class="reply-form">
|
<div class="reply-form">
|
||||||
<input class="reply-input" placeholder="Reply..." data-thread="${thread.id}">
|
<input class="reply-input" placeholder="Reply..." data-thread="${thread.id}">
|
||||||
<button class="reply-btn" data-reply="${thread.id}">Reply</button>
|
<button class="reply-btn" data-reply="${thread.id}">Reply</button>
|
||||||
|
|
@ -125,8 +208,8 @@ class NotesCommentPanel extends HTMLElement {
|
||||||
<button class="thread-action" data-resolve="${thread.id}">${thread.resolved ? 'Re-open' : 'Resolve'}</button>
|
<button class="thread-action" data-resolve="${thread.id}">${thread.resolved ? 'Re-open' : 'Resolve'}</button>
|
||||||
<button class="thread-action" data-delete="${thread.id}">Delete</button>
|
<button class="thread-action" data-delete="${thread.id}">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>`;
|
||||||
`).join('')}
|
}).join('')}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -140,7 +223,6 @@ class NotesCommentPanel extends HTMLElement {
|
||||||
const threadId = (el as HTMLElement).dataset.thread;
|
const threadId = (el as HTMLElement).dataset.thread;
|
||||||
if (!threadId || !this._editor) return;
|
if (!threadId || !this._editor) return;
|
||||||
this._activeThreadId = threadId;
|
this._activeThreadId = threadId;
|
||||||
// Find the comment mark in the editor and scroll to it
|
|
||||||
this._editor.state.doc.descendants((node, pos) => {
|
this._editor.state.doc.descendants((node, pos) => {
|
||||||
if (!node.isText) return;
|
if (!node.isText) return;
|
||||||
const mark = node.marks.find(m => m.type.name === 'comment' && m.attrs.threadId === threadId);
|
const mark = node.marks.find(m => m.type.name === 'comment' && m.attrs.threadId === threadId);
|
||||||
|
|
@ -201,40 +283,255 @@ class NotesCommentPanel extends HTMLElement {
|
||||||
if (threadId) this.deleteThread(threadId);
|
if (threadId) this.deleteThread(threadId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reaction pill toggle (existing reaction)
|
||||||
|
this.shadow.querySelectorAll('[data-react-thread]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const el = btn as HTMLElement;
|
||||||
|
this.toggleReaction(el.dataset.reactThread!, el.dataset.reactEmoji!);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reaction add "+" button — toggle emoji picker
|
||||||
|
this.shadow.querySelectorAll('[data-react-add]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const threadId = (btn as HTMLElement).dataset.reactAdd!;
|
||||||
|
const picker = this.shadow.querySelector(`[data-picker="${threadId}"]`);
|
||||||
|
if (picker) picker.classList.toggle('open');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emoji picker buttons
|
||||||
|
this.shadow.querySelectorAll('[data-pick-thread]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const el = btn as HTMLElement;
|
||||||
|
this.toggleReaction(el.dataset.pickThread!, el.dataset.pickEmoji!);
|
||||||
|
// Close picker
|
||||||
|
const picker = this.shadow.querySelector(`[data-picker="${el.dataset.pickThread}"]`);
|
||||||
|
if (picker) picker.classList.remove('open');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reminder "set" button
|
||||||
|
this.shadow.querySelectorAll('[data-remind-set]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const threadId = (btn as HTMLElement).dataset.remindSet!;
|
||||||
|
const input = this.shadow.querySelector(`[data-remind-input="${threadId}"]`) as HTMLInputElement;
|
||||||
|
if (input) {
|
||||||
|
input.style.display = input.style.display === 'none' ? 'inline-block' : 'none';
|
||||||
|
if (input.style.display !== 'none') input.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reminder date change
|
||||||
|
this.shadow.querySelectorAll('[data-remind-input]').forEach(input => {
|
||||||
|
input.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
input.addEventListener('change', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const threadId = (input as HTMLInputElement).dataset.remindInput!;
|
||||||
|
const val = (input as HTMLInputElement).value;
|
||||||
|
if (val) this.setReminder(threadId, new Date(val + 'T09:00:00').getTime());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reminder clear
|
||||||
|
this.shadow.querySelectorAll('[data-remind-clear]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const threadId = (btn as HTMLElement).dataset.remindClear!;
|
||||||
|
this.clearReminder(threadId);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private addReply(threadId: string, text: string) {
|
private addReply(threadId: string, text: string) {
|
||||||
|
const { authorName, authorId } = this.getSessionInfo();
|
||||||
|
const msg: CommentMessage = {
|
||||||
|
id: `m_${Date.now()}`,
|
||||||
|
authorId,
|
||||||
|
authorName,
|
||||||
|
text,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this._demoThreads) {
|
||||||
|
const thread = this._demoThreads[threadId];
|
||||||
|
if (!thread) return;
|
||||||
|
if (!thread.messages) thread.messages = [];
|
||||||
|
thread.messages.push(msg);
|
||||||
|
this.dispatchDemoMutation();
|
||||||
|
this.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this._noteId || !this._subscribedDocId) return;
|
if (!this._noteId || !this._subscribedDocId) return;
|
||||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||||
if (!runtime?.isInitialized) return;
|
if (!runtime?.isInitialized) return;
|
||||||
|
|
||||||
let authorName = 'Anonymous';
|
|
||||||
let authorId = 'anon';
|
|
||||||
try {
|
|
||||||
const sess = JSON.parse(localStorage.getItem('encryptid_session') || '{}');
|
|
||||||
authorName = sess?.username || sess?.displayName || 'Anonymous';
|
|
||||||
authorId = sess?.userId || sess?.sub || 'anon';
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
const noteId = this._noteId;
|
const noteId = this._noteId;
|
||||||
runtime.change(this._subscribedDocId as DocumentId, 'Add comment reply', (d: NotebookDoc) => {
|
runtime.change(this._subscribedDocId as DocumentId, 'Add comment reply', (d: NotebookDoc) => {
|
||||||
const item = d.items[noteId];
|
const item = d.items[noteId];
|
||||||
if (!item?.comments?.[threadId]) return;
|
if (!item?.comments?.[threadId]) return;
|
||||||
const thread = item.comments[threadId] as any;
|
const thread = item.comments[threadId] as any;
|
||||||
if (!thread.messages) thread.messages = [];
|
if (!thread.messages) thread.messages = [];
|
||||||
thread.messages.push({
|
thread.messages.push(msg);
|
||||||
id: `m_${Date.now()}`,
|
|
||||||
authorId,
|
|
||||||
authorName,
|
|
||||||
text,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
this._doc = runtime.get(this._subscribedDocId as DocumentId);
|
this._doc = runtime.get(this._subscribedDocId as DocumentId);
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private toggleReaction(threadId: string, emoji: string) {
|
||||||
|
const { authorId } = this.getSessionInfo();
|
||||||
|
|
||||||
|
if (this._demoThreads) {
|
||||||
|
const thread = this._demoThreads[threadId];
|
||||||
|
if (!thread) return;
|
||||||
|
if (!thread.reactions) thread.reactions = {};
|
||||||
|
if (!thread.reactions[emoji]) thread.reactions[emoji] = [];
|
||||||
|
const idx = thread.reactions[emoji].indexOf(authorId);
|
||||||
|
if (idx >= 0) thread.reactions[emoji].splice(idx, 1);
|
||||||
|
else thread.reactions[emoji].push(authorId);
|
||||||
|
this.dispatchDemoMutation();
|
||||||
|
this.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._noteId || !this._subscribedDocId) return;
|
||||||
|
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||||
|
if (!runtime?.isInitialized) return;
|
||||||
|
|
||||||
|
const noteId = this._noteId;
|
||||||
|
runtime.change(this._subscribedDocId as DocumentId, 'Toggle reaction', (d: NotebookDoc) => {
|
||||||
|
const item = d.items[noteId];
|
||||||
|
if (!item?.comments?.[threadId]) return;
|
||||||
|
const thread = item.comments[threadId] as any;
|
||||||
|
if (!thread.reactions) thread.reactions = {};
|
||||||
|
if (!thread.reactions[emoji]) thread.reactions[emoji] = [];
|
||||||
|
const users: string[] = thread.reactions[emoji];
|
||||||
|
const idx = users.indexOf(authorId);
|
||||||
|
if (idx >= 0) users.splice(idx, 1);
|
||||||
|
else users.push(authorId);
|
||||||
|
});
|
||||||
|
this._doc = runtime.get(this._subscribedDocId as DocumentId);
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setReminder(threadId: string, reminderAt: number) {
|
||||||
|
// Set reminder on thread
|
||||||
|
let reminderId: string | undefined;
|
||||||
|
|
||||||
|
// Try creating a reminder via rSchedule API (non-demo only)
|
||||||
|
if (!this.isDemo && this._space) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/${this._space}/rschedule/api/reminders`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: `Comment reminder`,
|
||||||
|
remindAt: new Date(reminderAt).toISOString(),
|
||||||
|
allDay: true,
|
||||||
|
sourceModule: 'rnotes',
|
||||||
|
sourceEntityId: threadId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
reminderId = data.id;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._demoThreads) {
|
||||||
|
const thread = this._demoThreads[threadId];
|
||||||
|
if (thread) {
|
||||||
|
thread.reminderAt = reminderAt;
|
||||||
|
if (reminderId) thread.reminderId = reminderId;
|
||||||
|
}
|
||||||
|
this.dispatchDemoMutation();
|
||||||
|
this.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._noteId || !this._subscribedDocId) return;
|
||||||
|
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||||
|
if (!runtime?.isInitialized) return;
|
||||||
|
|
||||||
|
const noteId = this._noteId;
|
||||||
|
runtime.change(this._subscribedDocId as DocumentId, 'Set comment reminder', (d: NotebookDoc) => {
|
||||||
|
const item = d.items[noteId];
|
||||||
|
if (!item?.comments?.[threadId]) return;
|
||||||
|
const thread = item.comments[threadId] as any;
|
||||||
|
thread.reminderAt = reminderAt;
|
||||||
|
if (reminderId) thread.reminderId = reminderId;
|
||||||
|
});
|
||||||
|
this._doc = runtime.get(this._subscribedDocId as DocumentId);
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async clearReminder(threadId: string) {
|
||||||
|
// Get existing reminderId before clearing
|
||||||
|
const threads = this.getThreads();
|
||||||
|
const thread = threads.find(t => t.id === threadId);
|
||||||
|
const reminderId = thread?.reminderId;
|
||||||
|
|
||||||
|
// Delete from rSchedule if exists
|
||||||
|
if (reminderId && !this.isDemo && this._space) {
|
||||||
|
try {
|
||||||
|
await fetch(`/${this._space}/rschedule/api/reminders/${reminderId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: this.authHeaders(),
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._demoThreads) {
|
||||||
|
const t = this._demoThreads[threadId];
|
||||||
|
if (t) { delete t.reminderAt; delete t.reminderId; }
|
||||||
|
this.dispatchDemoMutation();
|
||||||
|
this.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._noteId || !this._subscribedDocId) return;
|
||||||
|
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||||
|
if (!runtime?.isInitialized) return;
|
||||||
|
|
||||||
|
const noteId = this._noteId;
|
||||||
|
runtime.change(this._subscribedDocId as DocumentId, 'Clear comment reminder', (d: NotebookDoc) => {
|
||||||
|
const item = d.items[noteId];
|
||||||
|
if (!item?.comments?.[threadId]) return;
|
||||||
|
const t = item.comments[threadId] as any;
|
||||||
|
delete t.reminderAt;
|
||||||
|
delete t.reminderId;
|
||||||
|
});
|
||||||
|
this._doc = runtime.get(this._subscribedDocId as DocumentId);
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private authHeaders(): Record<string, string> {
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(localStorage.getItem('encryptid_session') || '{}');
|
||||||
|
if (s?.accessToken) return { 'Authorization': 'Bearer ' + s.accessToken };
|
||||||
|
} catch {}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
private toggleResolve(threadId: string) {
|
private toggleResolve(threadId: string) {
|
||||||
|
if (this._demoThreads) {
|
||||||
|
const thread = this._demoThreads[threadId];
|
||||||
|
if (thread) thread.resolved = !thread.resolved;
|
||||||
|
this.dispatchDemoMutation();
|
||||||
|
// Update editor mark
|
||||||
|
this.updateEditorResolveMark(threadId, this._demoThreads[threadId]?.resolved ?? false);
|
||||||
|
this.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this._noteId || !this._subscribedDocId) return;
|
if (!this._noteId || !this._subscribedDocId) return;
|
||||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||||
if (!runtime?.isInitialized) return;
|
if (!runtime?.isInitialized) return;
|
||||||
|
|
@ -247,30 +544,38 @@ class NotesCommentPanel extends HTMLElement {
|
||||||
});
|
});
|
||||||
this._doc = runtime.get(this._subscribedDocId as DocumentId);
|
this._doc = runtime.get(this._subscribedDocId as DocumentId);
|
||||||
|
|
||||||
// Also update the mark in the editor
|
const thread = this._doc?.items?.[this._noteId]?.comments?.[threadId];
|
||||||
if (this._editor) {
|
if (thread) this.updateEditorResolveMark(threadId, thread.resolved);
|
||||||
const thread = this._doc?.items?.[this._noteId]?.comments?.[threadId];
|
|
||||||
if (thread) {
|
|
||||||
this._editor.state.doc.descendants((node, pos) => {
|
|
||||||
if (!node.isText) return;
|
|
||||||
const mark = node.marks.find(m => m.type.name === 'comment' && m.attrs.threadId === threadId);
|
|
||||||
if (mark) {
|
|
||||||
const { tr } = this._editor!.state;
|
|
||||||
tr.removeMark(pos, pos + node.nodeSize, mark);
|
|
||||||
tr.addMark(pos, pos + node.nodeSize,
|
|
||||||
this._editor!.schema.marks.comment.create({ threadId, resolved: thread.resolved })
|
|
||||||
);
|
|
||||||
this._editor!.view.dispatch(tr);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private updateEditorResolveMark(threadId: string, resolved: boolean) {
|
||||||
|
if (!this._editor) return;
|
||||||
|
this._editor.state.doc.descendants((node, pos) => {
|
||||||
|
if (!node.isText) return;
|
||||||
|
const mark = node.marks.find(m => m.type.name === 'comment' && m.attrs.threadId === threadId);
|
||||||
|
if (mark) {
|
||||||
|
const { tr } = this._editor!.state;
|
||||||
|
tr.removeMark(pos, pos + node.nodeSize, mark);
|
||||||
|
tr.addMark(pos, pos + node.nodeSize,
|
||||||
|
this._editor!.schema.marks.comment.create({ threadId, resolved })
|
||||||
|
);
|
||||||
|
this._editor!.view.dispatch(tr);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private deleteThread(threadId: string) {
|
private deleteThread(threadId: string) {
|
||||||
|
if (this._demoThreads) {
|
||||||
|
delete this._demoThreads[threadId];
|
||||||
|
this.dispatchDemoMutation();
|
||||||
|
this.removeEditorCommentMark(threadId);
|
||||||
|
if (this._activeThreadId === threadId) this._activeThreadId = null;
|
||||||
|
this.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this._noteId || !this._subscribedDocId) return;
|
if (!this._noteId || !this._subscribedDocId) return;
|
||||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||||
if (!runtime?.isInitialized) return;
|
if (!runtime?.isInitialized) return;
|
||||||
|
|
@ -284,25 +589,26 @@ class NotesCommentPanel extends HTMLElement {
|
||||||
});
|
});
|
||||||
this._doc = runtime.get(this._subscribedDocId as DocumentId);
|
this._doc = runtime.get(this._subscribedDocId as DocumentId);
|
||||||
|
|
||||||
// Remove the comment mark from the editor
|
this.removeEditorCommentMark(threadId);
|
||||||
if (this._editor) {
|
|
||||||
const { state } = this._editor;
|
|
||||||
const { tr } = state;
|
|
||||||
state.doc.descendants((node, pos) => {
|
|
||||||
if (!node.isText) return;
|
|
||||||
const mark = node.marks.find(m => m.type.name === 'comment' && m.attrs.threadId === threadId);
|
|
||||||
if (mark) {
|
|
||||||
tr.removeMark(pos, pos + node.nodeSize, mark);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (tr.docChanged) {
|
|
||||||
this._editor.view.dispatch(tr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._activeThreadId === threadId) this._activeThreadId = null;
|
if (this._activeThreadId === threadId) this._activeThreadId = null;
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private removeEditorCommentMark(threadId: string) {
|
||||||
|
if (!this._editor) return;
|
||||||
|
const { state } = this._editor;
|
||||||
|
const { tr } = state;
|
||||||
|
state.doc.descendants((node, pos) => {
|
||||||
|
if (!node.isText) return;
|
||||||
|
const mark = node.marks.find(m => m.type.name === 'comment' && m.attrs.threadId === threadId);
|
||||||
|
if (mark) {
|
||||||
|
tr.removeMark(pos, pos + node.nodeSize, mark);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (tr.docChanged) {
|
||||||
|
this._editor.view.dispatch(tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('notes-comment-panel', NotesCommentPanel);
|
customElements.define('notes-comment-panel', NotesCommentPanel);
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,7 @@ class FolkNotesApp extends HTMLElement {
|
||||||
|
|
||||||
// ── Demo data ──
|
// ── Demo data ──
|
||||||
private demoNotebooks: (Notebook & { notes: Note[] })[] = [];
|
private demoNotebooks: (Notebook & { notes: Note[] })[] = [];
|
||||||
|
private _demoThreads = new Map<string, Record<string, any>>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
@ -2251,7 +2252,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
.setMark('comment', { threadId, resolved: false })
|
.setMark('comment', { threadId, resolved: false })
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
// Create thread in Automerge
|
// Create thread in Automerge or demo storage
|
||||||
const noteId = this.editorNoteId;
|
const noteId = this.editorNoteId;
|
||||||
if (noteId && this.doc?.items?.[noteId] && this.subscribedDocId) {
|
if (noteId && this.doc?.items?.[noteId] && this.subscribedDocId) {
|
||||||
const runtime = (window as any).__rspaceOfflineRuntime;
|
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||||
|
|
@ -2269,6 +2270,15 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
});
|
});
|
||||||
this.doc = runtime.get(this.subscribedDocId as DocumentId);
|
this.doc = runtime.get(this.subscribedDocId as DocumentId);
|
||||||
}
|
}
|
||||||
|
} else if (this.space === 'demo' && noteId) {
|
||||||
|
if (!this._demoThreads.has(noteId)) this._demoThreads.set(noteId, {});
|
||||||
|
this._demoThreads.get(noteId)![threadId] = {
|
||||||
|
id: threadId,
|
||||||
|
anchor: `${from}-${to}`,
|
||||||
|
resolved: false,
|
||||||
|
messages: [],
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open comment panel
|
// Open comment panel
|
||||||
|
|
@ -2294,12 +2304,24 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
if (!panel) {
|
if (!panel) {
|
||||||
panel = document.createElement('notes-comment-panel');
|
panel = document.createElement('notes-comment-panel');
|
||||||
this.metaZone.appendChild(panel);
|
this.metaZone.appendChild(panel);
|
||||||
|
// Listen for demo thread mutations from comment panel
|
||||||
|
panel.addEventListener('comment-demo-mutation', (e: CustomEvent) => {
|
||||||
|
const { noteId, threads } = e.detail;
|
||||||
|
if (noteId) this._demoThreads.set(noteId, threads);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
panel.noteId = this.editorNoteId;
|
panel.noteId = this.editorNoteId;
|
||||||
panel.doc = this.doc;
|
panel.doc = this.doc;
|
||||||
panel.subscribedDocId = this.subscribedDocId;
|
panel.subscribedDocId = this.subscribedDocId;
|
||||||
panel.activeThreadId = threadId || null;
|
panel.activeThreadId = threadId || null;
|
||||||
panel.editor = this.editor;
|
panel.editor = this.editor;
|
||||||
|
panel.space = this.space;
|
||||||
|
// Pass demo threads if in demo mode
|
||||||
|
if (this.space === 'demo' && this.editorNoteId) {
|
||||||
|
panel.demoThreads = this._demoThreads.get(this.editorNoteId) ?? null;
|
||||||
|
} else {
|
||||||
|
panel.demoThreads = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private toggleDictation(btn: HTMLElement) {
|
private toggleDictation(btn: HTMLElement) {
|
||||||
|
|
|
||||||
|
|
@ -4733,8 +4733,6 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
|
||||||
const SNAP_THRESHOLD = 8;
|
const SNAP_THRESHOLD = 8;
|
||||||
const SNAP_COLOR = "#14b8a6";
|
const SNAP_COLOR = "#14b8a6";
|
||||||
let activeDragShape = null;
|
let activeDragShape = null;
|
||||||
let unsnapX = 0, unsnapY = 0;
|
|
||||||
let snapCorrecting = false;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -4841,45 +4839,19 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
|
||||||
snapOverlay.textContent = '';
|
snapOverlay.textContent = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capturing listener: intercept folk-transform during drag to apply snap
|
// Capturing listener: show snap guides during drag (no position correction)
|
||||||
canvasContent.addEventListener("folk-transform", (e) => {
|
canvasContent.addEventListener("folk-transform", (e) => {
|
||||||
if (!activeDragShape || e.target !== activeDragShape) return;
|
if (!activeDragShape || e.target !== activeDragShape) return;
|
||||||
|
|
||||||
if (snapCorrecting) {
|
|
||||||
snapCorrecting = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shape = activeDragShape;
|
|
||||||
const cur = e.current;
|
const cur = e.current;
|
||||||
const prev = e.previous;
|
const prev = e.previous;
|
||||||
const dx = cur.x - prev.x;
|
if (cur.x === prev.x && cur.y === prev.y) return;
|
||||||
const dy = cur.y - prev.y;
|
|
||||||
|
|
||||||
// Only snap during moves, not resize/rotate
|
const targets = getSnapTargets(activeDragShape);
|
||||||
if (dx === 0 && dy === 0) return;
|
const { guides } = computeSnaps(
|
||||||
|
cur.x, cur.y, activeDragShape.width, activeDragShape.height, targets
|
||||||
// Use the shape's current position as the raw (unsnapped) target
|
|
||||||
unsnapX = cur.x;
|
|
||||||
unsnapY = cur.y;
|
|
||||||
|
|
||||||
const targets = getSnapTargets(shape);
|
|
||||||
const { snapX, snapY, guides } = computeSnaps(
|
|
||||||
unsnapX, unsnapY, shape.width, shape.height, targets
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const finalX = snapX !== null ? snapX : unsnapX;
|
|
||||||
const finalY = snapY !== null ? snapY : unsnapY;
|
|
||||||
|
|
||||||
// Apply snap correction if position differs from where drag placed it
|
|
||||||
if (Math.abs(finalX - cur.x) > 0.1 || Math.abs(finalY - cur.y) > 0.1) {
|
|
||||||
cur.x = finalX;
|
|
||||||
cur.y = finalY;
|
|
||||||
snapCorrecting = true;
|
|
||||||
shape.x = finalX;
|
|
||||||
shape.y = finalY;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (guides.length > 0) {
|
if (guides.length > 0) {
|
||||||
renderSnapGuides(guides);
|
renderSnapGuides(guides);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -7420,14 +7392,20 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
|
||||||
// Snap guide drag hooks (implementation above, near snap overlay)
|
// Snap guide drag hooks (implementation above, near snap overlay)
|
||||||
function onShapeMoveStart(shape) {
|
function onShapeMoveStart(shape) {
|
||||||
activeDragShape = shape;
|
activeDragShape = shape;
|
||||||
unsnapX = shape.x;
|
|
||||||
unsnapY = shape.y;
|
|
||||||
snapCorrecting = false;
|
|
||||||
}
|
}
|
||||||
function onShapeMoveEnd() {
|
function onShapeMoveEnd() {
|
||||||
if (!activeDragShape) return;
|
if (!activeDragShape) return;
|
||||||
|
const shape = activeDragShape;
|
||||||
activeDragShape = null;
|
activeDragShape = null;
|
||||||
clearSnapGuides();
|
clearSnapGuides();
|
||||||
|
|
||||||
|
// Snap to nearest edge on release
|
||||||
|
const targets = getSnapTargets(shape);
|
||||||
|
const { snapX, snapY } = computeSnaps(
|
||||||
|
shape.x, shape.y, shape.width, shape.height, targets
|
||||||
|
);
|
||||||
|
if (snapX !== null) shape.x = snapX;
|
||||||
|
if (snapY !== null) shape.y = snapY;
|
||||||
}
|
}
|
||||||
|
|
||||||
rwPrev.addEventListener("click", () => {
|
rwPrev.addEventListener("click", () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue