- ${thread.messages.map(msg => `
-
-
${esc(msg.authorName)}
-
${esc(msg.text)}
+
${initials(authorName)}
+
- `).join('')}
- ${thread.messages.length === 0 ? '
Click to add a comment...
' : ''}
+
+ ${hasMessages ? `
+
${esc(firstMsg.text)}
+ ${thread.messages.slice(1).map(msg => `
+
+ `).join('')}
+ ` : `
+
+ `}
+ ${hasMessages && reactionEntries.length > 0 ? `
${reactionEntries.map(([emoji, users]) => `
@@ -193,19 +295,28 @@ class NotesCommentPanel extends HTMLElement {
${REACTION_EMOJIS.map(e => ``).join('')}
+ ` : ''}
+ ${hasMessages && thread.reminderAt ? `
- ${thread.reminderAt
- ? `⏰ ${formatDate(thread.reminderAt)}`
- : ``
- }
-
+ ⏰ ${formatDate(thread.reminderAt)}
+
+ ` : ''}
+ ${hasMessages ? `
+ ` : ''}
-
+ ${hasMessages ? `
+
+
+
+ ` : ''}
+
`;
@@ -214,12 +325,21 @@ class NotesCommentPanel extends HTMLElement {
`;
this.wireEvents();
+
+ // Auto-focus new comment textarea
+ requestAnimationFrame(() => {
+ const newInput = this.shadow.querySelector('.new-comment-input') as HTMLTextAreaElement;
+ if (newInput) newInput.focus();
+ });
}
private wireEvents() {
// Click thread to scroll editor to it
this.shadow.querySelectorAll('.thread[data-thread]').forEach(el => {
el.addEventListener('click', (e) => {
+ // Don't handle clicks on inputs/buttons/textareas
+ const target = e.target as HTMLElement;
+ if (target.closest('input, textarea, button')) return;
const threadId = (el as HTMLElement).dataset.thread;
if (!threadId || !this._editor) return;
this._activeThreadId = threadId;
@@ -236,6 +356,46 @@ class NotesCommentPanel extends HTMLElement {
});
});
+ // New comment submit (thread with no messages yet)
+ this.shadow.querySelectorAll('[data-submit-new]').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const threadId = (btn as HTMLElement).dataset.submitNew;
+ if (!threadId) return;
+ const textarea = this.shadow.querySelector(`textarea[data-new-thread="${threadId}"]`) as HTMLTextAreaElement;
+ const text = textarea?.value?.trim();
+ if (!text) return;
+ this.addReply(threadId, text);
+ });
+ });
+
+ // New comment cancel — delete the empty thread
+ this.shadow.querySelectorAll('[data-cancel-new]').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const threadId = (btn as HTMLElement).dataset.cancelNew;
+ if (threadId) this.deleteThread(threadId);
+ });
+ });
+
+ // New comment textarea — Ctrl+Enter to submit, Escape to cancel
+ this.shadow.querySelectorAll('.new-comment-input').forEach(textarea => {
+ textarea.addEventListener('keydown', (e) => {
+ const ke = e as KeyboardEvent;
+ if (ke.key === 'Enter' && (ke.ctrlKey || ke.metaKey)) {
+ e.stopPropagation();
+ const threadId = (textarea as HTMLTextAreaElement).dataset.newThread;
+ const text = (textarea as HTMLTextAreaElement).value.trim();
+ if (threadId && text) this.addReply(threadId, text);
+ } else if (ke.key === 'Escape') {
+ e.stopPropagation();
+ const threadId = (textarea as HTMLTextAreaElement).dataset.newThread;
+ if (threadId) this.deleteThread(threadId);
+ }
+ });
+ textarea.addEventListener('click', (e) => e.stopPropagation());
+ });
+
// Reply
this.shadow.querySelectorAll('[data-reply]').forEach(btn => {
btn.addEventListener('click', (e) => {
diff --git a/modules/rnotes/components/folk-notes-app.ts b/modules/rnotes/components/folk-notes-app.ts
index 8397615..ba5d9e8 100644
--- a/modules/rnotes/components/folk-notes-app.ts
+++ b/modules/rnotes/components/folk-notes-app.ts
@@ -1216,14 +1216,17 @@ Gear: EUR 400 (10%)
Maya is tracking expenses in rF
const useYjs = !isDemo && isEditable;
this.contentZone.innerHTML = `
-
-
- ${isEditable ? this.renderToolbar() : ''}
-
-
-
+
`;
@@ -1257,6 +1260,7 @@ Gear: EUR 400 (10%)
Maya is tracking expenses in rF
this.wireTitleInput(note, isEditable, isDemo);
this.attachToolbarListeners();
+ this.wireCommentHighlightClicks();
}
/** Mount TipTap with Yjs collaboration (real-time co-editing). */
@@ -1306,6 +1310,8 @@ Gear: EUR 400 (10%)Maya is tracking expenses in rF
codeBlock: false,
heading: { levels: [1, 2, 3, 4] },
undoRedo: false, // Yjs has its own undo/redo
+ link: false,
+ underline: false,
}),
Link.configure({ openOnClick: false }),
Image, TaskList, TaskItem.configure({ nested: true }),
@@ -1329,6 +1335,7 @@ Gear: EUR 400 (10%)Maya is tracking expenses in rF
const s = this.getSessionInfo();
return { authorId: s.userId, authorName: s.username };
},
+ () => this.editor?.view ?? null,
);
this.editor.registerPlugin(suggestionPlugin);
@@ -1379,7 +1386,7 @@ Gear: EUR 400 (10%)Maya is tracking expenses in rF
element: container,
editable: isEditable,
extensions: [
- StarterKit.configure({ codeBlock: false, heading: { levels: [1, 2, 3, 4] } }),
+ StarterKit.configure({ codeBlock: false, heading: { levels: [1, 2, 3, 4] }, link: false, underline: false }),
Link.configure({ openOnClick: false }),
Image, TaskList, TaskItem.configure({ nested: true }),
Placeholder.configure({ placeholder: 'Start writing, or type / for commands...' }),
@@ -1656,7 +1663,7 @@ Gear: EUR 400 (10%)Maya is tracking expenses in rF
this.editor = new Editor({
element: container, editable: isEditable,
extensions: [
- StarterKit.configure({ codeBlock: false, heading: { levels: [1, 2, 3] } }),
+ StarterKit.configure({ codeBlock: false, heading: { levels: [1, 2, 3] }, link: false, underline: false }),
Link.configure({ openOnClick: false }), Image,
Placeholder.configure({ placeholder: note.type === 'CLIP' ? 'Clipped content...' : 'Add notes about this bookmark...' }),
Typography, Underline,
@@ -1726,7 +1733,7 @@ Gear: EUR 400 (10%)Maya is tracking expenses in rF
this.editor = new Editor({
element: container, editable: isEditable,
extensions: [
- StarterKit.configure({ codeBlock: false }), Link.configure({ openOnClick: false }),
+ StarterKit.configure({ codeBlock: false, link: false, underline: false }), Link.configure({ openOnClick: false }),
Placeholder.configure({ placeholder: 'Add a caption or notes...' }), Typography, Underline,
],
content,
@@ -1795,7 +1802,7 @@ Gear: EUR 400 (10%)Maya is tracking expenses in rF
this.editor = new Editor({
element: container, editable: isEditable,
extensions: [
- StarterKit.configure({ codeBlock: false }), Link.configure({ openOnClick: false }),
+ StarterKit.configure({ codeBlock: false, link: false, underline: false }), Link.configure({ openOnClick: false }),
Placeholder.configure({ placeholder: 'Transcript will appear here...' }), Typography, Underline,
],
content,
@@ -2300,10 +2307,13 @@ Gear: EUR 400 (10%)Maya is tracking expenses in rF
/** Show comment panel for a specific thread. */
private showCommentPanel(threadId?: string) {
+ const sidebar = this.shadow.getElementById('comment-sidebar');
+ if (!sidebar) return;
+
let panel = this.shadow.querySelector('notes-comment-panel') as any;
if (!panel) {
panel = document.createElement('notes-comment-panel');
- this.metaZone.appendChild(panel);
+ sidebar.appendChild(panel);
// Listen for demo thread mutations from comment panel
panel.addEventListener('comment-demo-mutation', (e: CustomEvent) => {
const { noteId, threads } = e.detail;
@@ -2322,6 +2332,44 @@ Gear: EUR 400 (10%)Maya is tracking expenses in rF
} else {
panel.demoThreads = null;
}
+
+ // Show sidebar when there are comments
+ sidebar.classList.add('has-comments');
+ }
+
+ /** Hide comment sidebar when no comments exist. */
+ private hideCommentPanel() {
+ const sidebar = this.shadow.getElementById('comment-sidebar');
+ if (sidebar) sidebar.classList.remove('has-comments');
+ }
+
+ /** Wire click handling on comment highlights in the editor to open comment panel. */
+ private wireCommentHighlightClicks() {
+ if (!this.editor) return;
+
+ // On selection change, check if cursor is inside a comment mark
+ this.editor.on('selectionUpdate', () => {
+ if (!this.editor) return;
+ const { $from } = this.editor.state.selection;
+ const commentMark = $from.marks().find(m => m.type.name === 'comment');
+ if (commentMark) {
+ const threadId = commentMark.attrs.threadId;
+ if (threadId) this.showCommentPanel(threadId);
+ }
+ });
+
+ // Direct click on comment highlight in the DOM
+ const container = this.shadow.getElementById('tiptap-container');
+ if (container) {
+ container.addEventListener('click', (e) => {
+ const target = e.target as HTMLElement;
+ const highlight = target.closest?.('.comment-highlight') as HTMLElement;
+ if (highlight) {
+ const threadId = highlight.getAttribute('data-thread-id');
+ if (threadId) this.showCommentPanel(threadId);
+ }
+ });
+ }
}
private toggleDictation(btn: HTMLElement) {
@@ -2994,6 +3042,40 @@ Gear: EUR 400 (10%)Maya is tracking expenses in rF
.notes-right-col #content-zone { flex: 1; overflow-y: auto; padding: 20px; }
.notes-right-col #meta-zone { padding: 0 20px 12px; }
+ /* ── Google Docs-like comment sidebar layout ── */
+ .editor-with-comments {
+ display: flex;
+ gap: 0;
+ min-height: 100%;
+ }
+ .editor-with-comments > .editor-wrapper {
+ flex: 1;
+ min-width: 0;
+ }
+ .comment-sidebar {
+ width: 0;
+ overflow: hidden;
+ transition: width 0.2s ease;
+ flex-shrink: 0;
+ }
+ .comment-sidebar.has-comments {
+ width: 280px;
+ border-left: 1px solid var(--rs-border, #e5e7eb);
+ }
+ @media (max-width: 768px) {
+ .comment-sidebar.has-comments { width: 240px; }
+ }
+ @media (max-width: 480px) {
+ .editor-with-comments { flex-direction: column; }
+ .comment-sidebar.has-comments {
+ width: 100%;
+ border-left: none;
+ border-top: 1px solid var(--rs-border, #e5e7eb);
+ max-height: 250px;
+ overflow-y: auto;
+ }
+ }
+
/* Empty state */
.editor-empty-state {
display: flex; flex-direction: column; align-items: center;
@@ -3474,19 +3556,20 @@ Gear: EUR 400 (10%)Maya is tracking expenses in rF
margin-left: 2px;
}
- /* ── Collaboration: Comment Highlights ── */
+ /* ── Collaboration: Comment Highlights (Google Docs style) ── */
.tiptap-container .tiptap .comment-highlight {
- background: rgba(250, 204, 21, 0.25);
- border-bottom: 2px solid rgba(250, 204, 21, 0.5);
+ background: rgba(251, 188, 4, 0.2);
+ border-bottom: 2px solid rgba(251, 188, 4, 0.5);
cursor: pointer;
transition: background 0.15s;
+ border-radius: 1px;
}
.tiptap-container .tiptap .comment-highlight:hover {
- background: rgba(250, 204, 21, 0.4);
+ background: rgba(251, 188, 4, 0.35);
}
.tiptap-container .tiptap .comment-highlight.resolved {
- background: rgba(250, 204, 21, 0.08);
- border-bottom-color: rgba(250, 204, 21, 0.15);
+ background: rgba(251, 188, 4, 0.06);
+ border-bottom-color: rgba(251, 188, 4, 0.12);
}
/* ── Collaboration: Suggestions ── */
diff --git a/modules/rnotes/components/suggestion-plugin.ts b/modules/rnotes/components/suggestion-plugin.ts
index 67fcc11..04078e3 100644
--- a/modules/rnotes/components/suggestion-plugin.ts
+++ b/modules/rnotes/components/suggestion-plugin.ts
@@ -10,6 +10,7 @@
*/
import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state';
+import type { EditorView } from '@tiptap/pm/view';
import type { Editor } from '@tiptap/core';
const pluginKey = new PluginKey('suggestion-plugin');
@@ -24,10 +25,12 @@ interface SuggestionPluginState {
* Create the suggestion mode ProseMirror plugin.
* @param getSuggesting - callback that returns current suggesting mode state
* @param getAuthor - callback that returns { authorId, authorName }
+ * @param getView - callback that returns the EditorView (needed to dispatch replacement transactions)
*/
export function createSuggestionPlugin(
getSuggesting: () => boolean,
getAuthor: () => { authorId: string; authorName: string },
+ getView?: () => EditorView | null,
): Plugin {
return new Plugin({
key: pluginKey,
@@ -125,10 +128,9 @@ export function createSuggestionPlugin(
});
if (blocked && newTr.docChanged) {
- // Dispatch our modified transaction instead
- // We need to use view.dispatch in the next tick
+ // Dispatch our modified transaction instead on the next tick
setTimeout(() => {
- const view = (state as any).view;
+ const view = getView?.();
if (view) view.dispatch(newTr);
}, 0);
return false; // Block the original transaction
diff --git a/modules/rnotes/mod.ts b/modules/rnotes/mod.ts
index e65b7c3..04c7ecb 100644
--- a/modules/rnotes/mod.ts
+++ b/modules/rnotes/mod.ts
@@ -1615,7 +1615,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: ``,
- scripts: ``,
+ scripts: ``,
styles: ``,
}));
});