diff --git a/modules/rnotes/components/comment-panel.ts b/modules/rnotes/components/comment-panel.ts index 30b234f..341bb3d 100644 --- a/modules/rnotes/components/comment-panel.ts +++ b/modules/rnotes/components/comment-panel.ts @@ -4,6 +4,8 @@ * Shows threaded comments anchored to highlighted text in the editor. * Comment thread data is stored in Automerge, while the highlight mark * 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'; @@ -23,6 +25,9 @@ interface CommentThread { resolved: boolean; messages: CommentMessage[]; createdAt: number; + reactions?: Record; + reminderAt?: number; + reminderId?: string; } interface NotebookDoc { @@ -33,6 +38,8 @@ interface NotebookDoc { [key: string]: any; } +const REACTION_EMOJIS = ['👍', '👎', '❤️', '🎉', '😂', '😮', '🔥']; + class NotesCommentPanel extends HTMLElement { private shadow: ShadowRoot; private _noteId: string | null = null; @@ -40,6 +47,8 @@ class NotesCommentPanel extends HTMLElement { private _subscribedDocId: string | null = null; private _activeThreadId: string | null = null; private _editor: Editor | null = null; + private _demoThreads: Record | null = null; + private _space = ''; constructor() { super(); @@ -51,8 +60,33 @@ class NotesCommentPanel extends HTMLElement { set subscribedDocId(v: string | null) { this._subscribedDocId = v; } set activeThreadId(v: string | null) { this._activeThreadId = v; this.render(); } set editor(v: Editor | null) { this._editor = v; } + set space(v: string) { this._space = v; } + set demoThreads(v: Record | 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[] { + // 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 []; const item = this._doc.items?.[this._noteId]; if (!item?.comments) return []; @@ -60,6 +94,15 @@ class NotesCommentPanel extends HTMLElement { .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() { const threads = this.getThreads(); if (threads.length === 0 && !this._activeThreadId) { @@ -75,6 +118,8 @@ class NotesCommentPanel extends HTMLElement { if (diff < 86400000) return `${Math.floor(diff / 3600000)}h 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 = `
Comments (${threads.filter(t => !t.resolved).length})
- ${threads.map(thread => ` + ${threads.map(thread => { + const reactions = thread.reactions || {}; + const reactionEntries = Object.entries(reactions).filter(([, users]) => users.length > 0); + return `
${esc(thread.messages[0]?.authorName || 'Anonymous')} @@ -117,6 +184,22 @@ class NotesCommentPanel extends HTMLElement {
`).join('')} ${thread.messages.length === 0 ? '
Click to add a comment...
' : ''} +
+ ${reactionEntries.map(([emoji, users]) => ` + + `).join('')} + +
+
+ ${REACTION_EMOJIS.map(e => ``).join('')} +
+
+ ${thread.reminderAt + ? `⏰ ${formatDate(thread.reminderAt)}` + : `` + } + +
@@ -125,8 +208,8 @@ class NotesCommentPanel extends HTMLElement {
-
- `).join('')} +
`; + }).join('')} `; @@ -140,7 +223,6 @@ class NotesCommentPanel extends HTMLElement { const threadId = (el as HTMLElement).dataset.thread; if (!threadId || !this._editor) return; this._activeThreadId = threadId; - // Find the comment mark in the editor and scroll to it 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); @@ -201,40 +283,255 @@ class NotesCommentPanel extends HTMLElement { 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) { + 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; const runtime = (window as any).__rspaceOfflineRuntime; 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; runtime.change(this._subscribedDocId as DocumentId, 'Add comment reply', (d: NotebookDoc) => { const item = d.items[noteId]; if (!item?.comments?.[threadId]) return; const thread = item.comments[threadId] as any; if (!thread.messages) thread.messages = []; - thread.messages.push({ - id: `m_${Date.now()}`, - authorId, - authorName, - text, - createdAt: Date.now(), - }); + thread.messages.push(msg); }); this._doc = runtime.get(this._subscribedDocId as DocumentId); 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 { + try { + const s = JSON.parse(localStorage.getItem('encryptid_session') || '{}'); + if (s?.accessToken) return { 'Authorization': 'Bearer ' + s.accessToken }; + } catch {} + return {}; + } + 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; const runtime = (window as any).__rspaceOfflineRuntime; if (!runtime?.isInitialized) return; @@ -247,30 +544,38 @@ class NotesCommentPanel extends HTMLElement { }); this._doc = runtime.get(this._subscribedDocId as DocumentId); - // Also update the mark in the editor - if (this._editor) { - 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; - } - }); - } - } - + const thread = this._doc?.items?.[this._noteId]?.comments?.[threadId]; + if (thread) this.updateEditorResolveMark(threadId, thread.resolved); 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) { + 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; const runtime = (window as any).__rspaceOfflineRuntime; if (!runtime?.isInitialized) return; @@ -284,25 +589,26 @@ class NotesCommentPanel extends HTMLElement { }); this._doc = runtime.get(this._subscribedDocId as DocumentId); - // Remove the comment mark from the editor - 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); - } - } - + this.removeEditorCommentMark(threadId); if (this._activeThreadId === threadId) this._activeThreadId = null; 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); diff --git a/modules/rnotes/components/folk-notes-app.ts b/modules/rnotes/components/folk-notes-app.ts index 8aa45f3..8397615 100644 --- a/modules/rnotes/components/folk-notes-app.ts +++ b/modules/rnotes/components/folk-notes-app.ts @@ -197,6 +197,7 @@ class FolkNotesApp extends HTMLElement { // ── Demo data ── private demoNotebooks: (Notebook & { notes: Note[] })[] = []; + private _demoThreads = new Map>(); constructor() { super(); @@ -2251,7 +2252,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF .setMark('comment', { threadId, resolved: false }) .run(); - // Create thread in Automerge + // Create thread in Automerge or demo storage const noteId = this.editorNoteId; if (noteId && this.doc?.items?.[noteId] && this.subscribedDocId) { const runtime = (window as any).__rspaceOfflineRuntime; @@ -2269,6 +2270,15 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF }); 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 @@ -2294,12 +2304,24 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF if (!panel) { panel = document.createElement('notes-comment-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.doc = this.doc; panel.subscribedDocId = this.subscribedDocId; panel.activeThreadId = threadId || null; 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) { diff --git a/website/canvas.html b/website/canvas.html index 3a05c71..08d746a 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -4733,8 +4733,6 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest const SNAP_THRESHOLD = 8; const SNAP_COLOR = "#14b8a6"; 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 = ''; } - // 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) => { if (!activeDragShape || e.target !== activeDragShape) return; - if (snapCorrecting) { - snapCorrecting = false; - return; - } - - const shape = activeDragShape; const cur = e.current; const prev = e.previous; - const dx = cur.x - prev.x; - const dy = cur.y - prev.y; + if (cur.x === prev.x && cur.y === prev.y) return; - // Only snap during moves, not resize/rotate - if (dx === 0 && dy === 0) return; - - // 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 targets = getSnapTargets(activeDragShape); + const { guides } = computeSnaps( + cur.x, cur.y, activeDragShape.width, activeDragShape.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) { renderSnapGuides(guides); } 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) function onShapeMoveStart(shape) { activeDragShape = shape; - unsnapX = shape.x; - unsnapY = shape.y; - snapCorrecting = false; } function onShapeMoveEnd() { if (!activeDragShape) return; + const shape = activeDragShape; activeDragShape = null; 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", () => {