fix(rnotes): Google Docs-like comment sidebar, fix suggestions + duplicate extensions
- Fix duplicate tiptap extension warnings by disabling link/underline in StarterKit v3 (which now includes them by default) - Move comment panel from metaZone (destroyed by renderMeta) to dedicated comment sidebar next to the editor, Google Docs style - Add click-on-highlight to open comment thread in sidebar - New comment creation shows inline textarea with auto-focus - Fix suggestion plugin: pass view getter instead of broken state.view access - Improve comment panel styling: avatars, Google Docs yellow active border, cleaner thread layout, Ctrl+Enter to submit, Escape to cancel - Bump folk-notes-app cache version to v=7 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8ba14a0e15
commit
110b733f94
|
|
@ -119,50 +119,128 @@ class NotesCommentPanel extends HTMLElement {
|
|||
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();
|
||||
const { authorId: currentUserId, authorName: currentUserName } = this.getSessionInfo();
|
||||
const initials = (name: string) => name.split(/\s+/).map(w => w[0] || '').join('').slice(0, 2).toUpperCase() || '?';
|
||||
const avatarColor = (id: string) => {
|
||||
let h = 0;
|
||||
for (let i = 0; i < id.length; i++) h = id.charCodeAt(i) + ((h << 5) - h);
|
||||
return `hsl(${Math.abs(h) % 360}, 55%, 55%)`;
|
||||
};
|
||||
|
||||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; }
|
||||
.panel { border-left: 1px solid var(--rs-border, #e5e7eb); padding: 12px; font-family: system-ui, sans-serif; font-size: 13px; max-height: 80vh; overflow-y: auto; }
|
||||
.panel-title { font-weight: 600; font-size: 14px; margin-bottom: 12px; color: var(--rs-text-primary, #111); display: flex; justify-content: space-between; align-items: center; }
|
||||
.thread { margin-bottom: 16px; padding: 10px; border-radius: 8px; background: var(--rs-bg-surface, #fff); border: 1px solid var(--rs-border-subtle, #f0f0f0); cursor: pointer; transition: border-color 0.15s; }
|
||||
.thread:hover { border-color: var(--rs-border, #e5e7eb); }
|
||||
.thread.active { border-color: var(--rs-primary, #3b82f6); }
|
||||
.thread.resolved { opacity: 0.6; }
|
||||
.thread-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
||||
.thread-author { font-weight: 600; color: var(--rs-text-primary, #111); }
|
||||
.thread-time { color: var(--rs-text-muted, #999); font-size: 11px; }
|
||||
.thread-actions { display: flex; gap: 4px; }
|
||||
.thread-action { padding: 2px 6px; border: none; background: none; color: var(--rs-text-secondary, #666); cursor: pointer; font-size: 11px; border-radius: 4px; }
|
||||
.thread-action:hover { background: var(--rs-bg-hover, #f5f5f5); }
|
||||
.message { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--rs-border-subtle, #f0f0f0); }
|
||||
:host { display: block; font-family: system-ui, -apple-system, sans-serif; font-size: 13px; }
|
||||
.panel { padding: 8px 12px; overflow-y: auto; max-height: calc(100vh - 180px); }
|
||||
.panel-title {
|
||||
font-weight: 600; font-size: 13px; padding: 8px 0;
|
||||
color: var(--rs-text-secondary, #666);
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
border-bottom: 1px solid var(--rs-border-subtle, #f0f0f0);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.thread {
|
||||
margin-bottom: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--rs-bg-surface, #fff);
|
||||
border: 1px solid var(--rs-border-subtle, #e8e8e8);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
.thread:hover { box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
|
||||
.thread.active {
|
||||
border-left-color: #fbbc04;
|
||||
box-shadow: 0 1px 6px rgba(251, 188, 4, 0.2);
|
||||
background: color-mix(in srgb, #fbbc04 4%, var(--rs-bg-surface, #fff));
|
||||
}
|
||||
.thread.resolved { opacity: 0.5; }
|
||||
.thread.resolved:hover { opacity: 0.7; }
|
||||
|
||||
/* Author row with avatar */
|
||||
.thread-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.avatar {
|
||||
width: 26px; height: 26px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 11px; font-weight: 600; color: #fff; flex-shrink: 0;
|
||||
}
|
||||
.header-info { flex: 1; min-width: 0; }
|
||||
.thread-author { font-weight: 600; font-size: 13px; color: var(--rs-text-primary, #111); }
|
||||
.thread-time { color: var(--rs-text-muted, #999); font-size: 11px; margin-left: 6px; }
|
||||
|
||||
/* Messages */
|
||||
.message { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--rs-border-subtle, #f0f0f0); }
|
||||
.message-header { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
|
||||
.message-avatar { width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 9px; font-weight: 600; color: #fff; flex-shrink: 0; }
|
||||
.message-author { font-weight: 500; font-size: 12px; color: var(--rs-text-secondary, #666); }
|
||||
.message-text { margin-top: 2px; color: var(--rs-text-primary, #111); line-height: 1.4; }
|
||||
.reply-form { margin-top: 8px; display: flex; gap: 6px; }
|
||||
.reply-input { flex: 1; padding: 6px 8px; border: 1px solid var(--rs-input-border, #ddd); border-radius: 6px; font-size: 12px; background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111); }
|
||||
.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:hover { opacity: 0.9; }
|
||||
.message-time { font-size: 10px; color: var(--rs-text-muted, #aaa); }
|
||||
.message-text { margin-top: 2px; color: var(--rs-text-primary, #111); line-height: 1.5; padding-left: 26px; }
|
||||
.first-message-text { color: var(--rs-text-primary, #111); line-height: 1.5; }
|
||||
|
||||
/* Reply form — Google Docs style */
|
||||
.reply-form { margin-top: 10px; border-top: 1px solid var(--rs-border-subtle, #f0f0f0); padding-top: 10px; }
|
||||
.reply-input {
|
||||
width: 100%; padding: 8px 10px; border: 1px solid var(--rs-input-border, #ddd);
|
||||
border-radius: 8px; font-size: 13px; font-family: inherit;
|
||||
background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111);
|
||||
resize: none; min-height: 36px;
|
||||
}
|
||||
.reply-input:focus { border-color: #1a73e8; outline: none; box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.15); }
|
||||
.reply-input::placeholder { color: var(--rs-text-muted, #999); }
|
||||
.reply-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 6px; }
|
||||
.reply-btn {
|
||||
padding: 6px 14px; border: none; background: #1a73e8; color: #fff;
|
||||
border-radius: 6px; font-size: 12px; cursor: pointer; font-weight: 500;
|
||||
}
|
||||
.reply-btn:hover { background: #1557b0; }
|
||||
.reply-cancel-btn {
|
||||
padding: 6px 14px; border: none; background: transparent; color: var(--rs-text-secondary, #666);
|
||||
border-radius: 6px; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.reply-cancel-btn:hover { background: var(--rs-bg-hover, #f5f5f5); }
|
||||
|
||||
/* Thread actions */
|
||||
.thread-actions { display: flex; gap: 2px; margin-top: 8px; justify-content: flex-end; }
|
||||
.thread-action {
|
||||
padding: 4px 8px; border: none; background: none;
|
||||
color: var(--rs-text-muted, #999); cursor: pointer;
|
||||
font-size: 11px; border-radius: 4px;
|
||||
}
|
||||
.thread-action:hover { background: var(--rs-bg-hover, #f5f5f5); color: var(--rs-text-primary, #111); }
|
||||
.thread-action.resolve-btn { color: #1a73e8; }
|
||||
.thread-action.resolve-btn:hover { background: color-mix(in srgb, #1a73e8 8%, transparent); }
|
||||
|
||||
/* 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:hover { border-color: #1a73e8; }
|
||||
.reaction-pill.active { border-color: #1a73e8; background: color-mix(in srgb, #1a73e8 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); }
|
||||
.reaction-add:hover { border-color: #1a73e8; 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-btn:hover { border-color: #1a73e8; }
|
||||
.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); }
|
||||
|
||||
/* New comment input — shown when thread has no messages */
|
||||
.new-comment-form { margin-top: 4px; }
|
||||
.new-comment-input {
|
||||
width: 100%; padding: 8px 10px; border: 1px solid var(--rs-input-border, #ddd);
|
||||
border-radius: 8px; font-size: 13px; font-family: inherit;
|
||||
background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111);
|
||||
resize: none; min-height: 60px;
|
||||
}
|
||||
.new-comment-input:focus { border-color: #1a73e8; outline: none; box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.15); }
|
||||
.new-comment-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 6px; }
|
||||
</style>
|
||||
<div class="panel">
|
||||
<div class="panel-title">
|
||||
|
|
@ -171,19 +249,43 @@ class NotesCommentPanel extends HTMLElement {
|
|||
${threads.map(thread => {
|
||||
const reactions = thread.reactions || {};
|
||||
const reactionEntries = Object.entries(reactions).filter(([, users]) => users.length > 0);
|
||||
const isActive = thread.id === this._activeThreadId;
|
||||
const hasMessages = thread.messages.length > 0;
|
||||
const firstMsg = thread.messages[0];
|
||||
const authorName = firstMsg?.authorName || currentUserName;
|
||||
const authorId = firstMsg?.authorId || currentUserId;
|
||||
|
||||
return `
|
||||
<div class="thread ${thread.id === this._activeThreadId ? 'active' : ''} ${thread.resolved ? 'resolved' : ''}" data-thread="${thread.id}">
|
||||
<div class="thread ${isActive ? 'active' : ''} ${thread.resolved ? 'resolved' : ''}" data-thread="${thread.id}">
|
||||
<div class="thread-header">
|
||||
<span class="thread-author">${esc(thread.messages[0]?.authorName || 'Anonymous')}</span>
|
||||
<span class="thread-time">${timeAgo(thread.createdAt)}</span>
|
||||
</div>
|
||||
${thread.messages.map(msg => `
|
||||
<div class="message">
|
||||
<div class="message-author">${esc(msg.authorName)}</div>
|
||||
<div class="message-text">${esc(msg.text)}</div>
|
||||
<div class="avatar" style="background: ${avatarColor(authorId)}">${initials(authorName)}</div>
|
||||
<div class="header-info">
|
||||
<span class="thread-author">${esc(authorName)}</span>
|
||||
<span class="thread-time">${timeAgo(thread.createdAt)}</span>
|
||||
</div>
|
||||
`).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>' : ''}
|
||||
</div>
|
||||
${hasMessages ? `
|
||||
<div class="first-message-text">${esc(firstMsg.text)}</div>
|
||||
${thread.messages.slice(1).map(msg => `
|
||||
<div class="message">
|
||||
<div class="message-header">
|
||||
<div class="message-avatar" style="background: ${avatarColor(msg.authorId)}">${initials(msg.authorName)}</div>
|
||||
<span class="message-author">${esc(msg.authorName)}</span>
|
||||
<span class="message-time">${timeAgo(msg.createdAt)}</span>
|
||||
</div>
|
||||
<div class="message-text">${esc(msg.text)}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
` : `
|
||||
<div class="new-comment-form">
|
||||
<textarea class="new-comment-input" placeholder="Add your comment..." data-new-thread="${thread.id}" autofocus></textarea>
|
||||
<div class="new-comment-actions">
|
||||
<button class="reply-cancel-btn" data-cancel-new="${thread.id}">Cancel</button>
|
||||
<button class="reply-btn" data-submit-new="${thread.id}">Comment</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
${hasMessages && reactionEntries.length > 0 ? `
|
||||
<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>
|
||||
|
|
@ -193,19 +295,28 @@ class NotesCommentPanel extends HTMLElement {
|
|||
<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>
|
||||
` : ''}
|
||||
${hasMessages && thread.reminderAt ? `
|
||||
<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">
|
||||
<span class="reminder-badge">⏰ ${formatDate(thread.reminderAt)}</span>
|
||||
<button class="reminder-clear" data-remind-clear="${thread.id}">✕</button>
|
||||
</div>
|
||||
` : ''}
|
||||
${hasMessages ? `
|
||||
<div class="reply-form">
|
||||
<input class="reply-input" placeholder="Reply..." data-thread="${thread.id}">
|
||||
<button class="reply-btn" data-reply="${thread.id}">Reply</button>
|
||||
<div class="reply-actions">
|
||||
<button class="reply-btn" data-reply="${thread.id}">Reply</button>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="thread-actions">
|
||||
<button class="thread-action" data-resolve="${thread.id}">${thread.resolved ? 'Re-open' : 'Resolve'}</button>
|
||||
${hasMessages ? `
|
||||
<button class="reaction-add" data-react-add="${thread.id}" title="React" style="font-size:13px">+</button>
|
||||
<button class="thread-action" data-remind-set="${thread.id}" title="Set reminder">⏰</button>
|
||||
<input type="date" class="reminder-date-input" data-remind-input="${thread.id}" style="display:none">
|
||||
` : ''}
|
||||
<button class="thread-action resolve-btn" data-resolve="${thread.id}">${thread.resolved ? 'Re-open' : 'Resolve'}</button>
|
||||
<button class="thread-action" data-delete="${thread.id}">Delete</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -1216,14 +1216,17 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
|||
const useYjs = !isDemo && isEditable;
|
||||
|
||||
this.contentZone.innerHTML = `
|
||||
<div class="editor-wrapper">
|
||||
<input class="editable-title" id="note-title-input" value="${this.esc(note.title)}" placeholder="Note title...">
|
||||
${isEditable ? this.renderToolbar() : ''}
|
||||
<div class="collab-status-bar" id="collab-status-bar" style="display:none">
|
||||
<span class="collab-status-dot"></span>
|
||||
<span class="collab-status-text"></span>
|
||||
<div class="editor-with-comments">
|
||||
<div class="editor-wrapper">
|
||||
<input class="editable-title" id="note-title-input" value="${this.esc(note.title)}" placeholder="Note title...">
|
||||
${isEditable ? this.renderToolbar() : ''}
|
||||
<div class="collab-status-bar" id="collab-status-bar" style="display:none">
|
||||
<span class="collab-status-dot"></span>
|
||||
<span class="collab-status-text"></span>
|
||||
</div>
|
||||
<div class="tiptap-container" id="tiptap-container"></div>
|
||||
</div>
|
||||
<div class="tiptap-container" id="tiptap-container"></div>
|
||||
<div class="comment-sidebar" id="comment-sidebar"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
@ -1257,6 +1260,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>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%)</code></pre><p><em>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%)</code></pre><p><em>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%)</code></pre><p><em>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%)</code></pre><p><em>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%)</code></pre><p><em>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%)</code></pre><p><em>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%)</code></pre><p><em>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%)</code></pre><p><em>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%)</code></pre><p><em>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%)</code></pre><p><em>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 ── */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1615,7 +1615,7 @@ routes.get("/", (c) => {
|
|||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-notes-app space="${space}"></folk-notes-app>`,
|
||||
scripts: `<script type="module" src="/modules/rnotes/folk-notes-app.js?v=6"></script>`,
|
||||
scripts: `<script type="module" src="/modules/rnotes/folk-notes-app.js?v=7"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rnotes/notes.css?v=5">`,
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue