Add inline comments on text passages

Collaborative annotation system:
- Select text + click "Commenter" → enter author (E/M) + comment
- Highlighted text with author-colored underline (green=Etienne, terra=Myriam)
- Click highlighted passage → popup shows comment, author, date
- Delete comment from popup
- Comments stored as marks in HTML (no separate table needed)
- Custom TipTap Mark extension loaded via esm.sh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Etienne Delvarre
2026-05-29 11:42:14 +02:00
parent 0b763ae9f4
commit 751db0c85c
+139 -2
View File
@@ -7,10 +7,34 @@
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Lora:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Lora:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js"></script>
<script type="module"> <script type="module">
import { Editor } from 'https://esm.sh/@tiptap/core@2.11.7'; import { Editor, Mark } from 'https://esm.sh/@tiptap/core@2.11.7';
import StarterKit from 'https://esm.sh/@tiptap/starter-kit@2.11.7'; import StarterKit from 'https://esm.sh/@tiptap/starter-kit@2.11.7';
const Comment = Mark.create({
name: 'comment',
addAttributes() {
return {
text: { default: '' },
author: { default: 'E' },
date: { default: '' },
};
},
parseHTML() {
return [{ tag: 'span[data-comment]' }];
},
renderHTML({ HTMLAttributes }) {
return ['span', {
'data-comment': HTMLAttributes.text,
'data-author': HTMLAttributes.author,
'data-date': HTMLAttributes.date,
class: `comment-mark comment-${(HTMLAttributes.author || 'E').toLowerCase()}`,
}, 0];
},
});
window.TiptapEditor = Editor; window.TiptapEditor = Editor;
window.TiptapStarterKit = StarterKit; window.TiptapStarterKit = StarterKit;
window.TiptapComment = Comment;
window.dispatchEvent(new Event('tiptap-loaded')); window.dispatchEvent(new Event('tiptap-loaded'));
</script> </script>
<style> <style>
@@ -343,6 +367,27 @@ window.dispatchEvent(new Event('tiptap-loaded'));
.editor-saving.show { opacity: 1; } .editor-saving.show { opacity: 1; }
.word-count { font-size: 12px; color: var(--text-hint); padding: 8px 32px; border-top: 1px solid var(--border-light); background: var(--bg); } .word-count { font-size: 12px; color: var(--text-hint); padding: 8px 32px; border-top: 1px solid var(--border-light); background: var(--bg); }
/* Comments */
.comment-mark { cursor: pointer; border-radius: 2px; padding: 1px 0; }
.comment-mark.comment-e { background: rgba(106,122,66,0.15); border-bottom: 2px solid rgba(106,122,66,0.4); }
.comment-mark.comment-m { background: rgba(184,92,58,0.15); border-bottom: 2px solid rgba(184,92,58,0.4); }
.dark .comment-mark.comment-e { background: rgba(138,170,90,0.2); border-bottom-color: rgba(138,170,90,0.4); }
.dark .comment-mark.comment-m { background: rgba(212,132,90,0.2); border-bottom-color: rgba(212,132,90,0.4); }
.comment-popup { position: fixed; z-index: 300; background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px; padding: 14px 18px; box-shadow: 0 4px 20px var(--shadow-hover); max-width: 320px; min-width: 200px; }
.comment-popup-author { font-size: 12px; font-weight: 600; margin-bottom: 4px; }
.comment-popup-author.e { color: var(--olive); }
.comment-popup-author.m { color: var(--terra); }
.comment-popup-date { font-size: 11px; color: var(--text-light); margin-bottom: 8px; }
.comment-popup-text { font-size: 14px; color: var(--text); line-height: 1.5; }
.comment-popup-actions { margin-top: 10px; display: flex; gap: 8px; justify-content: flex-end; }
.comment-popup-actions button { font-size: 12px; padding: 4px 12px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--text-muted); cursor: pointer; font-family: 'Lora', serif; }
.comment-popup-actions button:hover { background: var(--bg-alt); color: var(--text); }
.comment-popup-actions button.delete { border-color: #8b3030; color: #8b3030; }
.comment-popup-actions button.delete:hover { background: #8b3030; color: #fff; }
.toolbar-sep { width: 1px; background: var(--border); margin: 4px 6px; }
/* Responsive */ /* Responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
.sidebar { display: none; } .sidebar { display: none; }
@@ -485,6 +530,15 @@ window.dispatchEvent(new Event('tiptap-loaded'));
</div> </div>
</div> </div>
<div class="editor-saving" id="editor-saving">Enregistré</div> <div class="editor-saving" id="editor-saving">Enregistré</div>
<div class="comment-popup" id="comment-popup" style="display:none;">
<div class="comment-popup-author" id="comment-popup-author"></div>
<div class="comment-popup-date" id="comment-popup-date"></div>
<div class="comment-popup-text" id="comment-popup-text"></div>
<div class="comment-popup-actions">
<button onclick="closeCommentPopup()">Fermer</button>
<button class="delete" onclick="deleteComment()">Supprimer</button>
</div>
</div>
<div class="vision-saving" id="vision-saving">Enregistré</div> <div class="vision-saving" id="vision-saving">Enregistré</div>
@@ -1467,6 +1521,7 @@ function initEditor(html) {
const Editor = window.TiptapEditor; const Editor = window.TiptapEditor;
const StarterKit = window.TiptapStarterKit; const StarterKit = window.TiptapStarterKit;
const CommentMark = window.TiptapComment;
if (!Editor || !StarterKit) { if (!Editor || !StarterKit) {
// Fallback: contenteditable div if TipTap CDN not loaded // Fallback: contenteditable div if TipTap CDN not loaded
@@ -1479,9 +1534,12 @@ function initEditor(html) {
return; return;
} }
const extensions = [StarterKit];
if (CommentMark) extensions.push(CommentMark);
tiptapEditor = new Editor({ tiptapEditor = new Editor({
element: document.getElementById('editor-content'), element: document.getElementById('editor-content'),
extensions: [StarterKit], extensions,
content: html || '<p></p>', content: html || '<p></p>',
onUpdate: () => { onUpdate: () => {
clearTimeout(saveTimeout); clearTimeout(saveTimeout);
@@ -1490,6 +1548,12 @@ function initEditor(html) {
}, },
}); });
// Listen for clicks on comment marks
document.getElementById('editor-content').addEventListener('click', (e) => {
const mark = e.target.closest('.comment-mark');
if (mark) showCommentPopup(mark, e);
});
buildToolbar(); buildToolbar();
} }
@@ -1517,6 +1581,18 @@ function buildToolbar() {
bar.appendChild(btn); bar.appendChild(btn);
}); });
// Separator + Comment button
const sep = document.createElement('span');
sep.className = 'toolbar-sep';
bar.appendChild(sep);
const commentBtn = document.createElement('button');
commentBtn.innerHTML = 'Commenter';
commentBtn.title = 'Ajouter un commentaire sur la sélection';
commentBtn.style.cssText = 'font-size:13px;';
commentBtn.onclick = () => addComment();
bar.appendChild(commentBtn);
// Update active states on selection change // Update active states on selection change
tiptapEditor.on('selectionUpdate', () => { tiptapEditor.on('selectionUpdate', () => {
bar.querySelectorAll('button').forEach((btn, i) => { bar.querySelectorAll('button').forEach((btn, i) => {
@@ -1593,6 +1669,67 @@ async function loadWordCounts() {
} catch (e) { /* silent */ } } catch (e) { /* silent */ }
} }
loadWordCounts(); loadWordCounts();
// ── Comments ──
function addComment() {
if (!tiptapEditor) return;
const { from, to } = tiptapEditor.state.selection;
if (from === to) { alert('Sélectionnez du texte pour commenter.'); return; }
const author = prompt('Auteur (E ou M) :', 'E');
if (!author) return;
const text = prompt('Commentaire :');
if (!text) return;
const now = new Date().toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' });
tiptapEditor.chain().focus()
.setMark('comment', { text, author: author.toUpperCase(), date: now })
.run();
}
let activeCommentMark = null;
function showCommentPopup(markEl, event) {
activeCommentMark = markEl;
const popup = document.getElementById('comment-popup');
const author = markEl.getAttribute('data-author') || 'E';
const authorName = author === 'M' ? 'Myriam' : 'Etienne';
document.getElementById('comment-popup-author').textContent = authorName;
document.getElementById('comment-popup-author').className = `comment-popup-author ${author.toLowerCase()}`;
document.getElementById('comment-popup-date').textContent = markEl.getAttribute('data-date') || '';
document.getElementById('comment-popup-text').textContent = markEl.getAttribute('data-comment') || '';
// Position near click
const rect = markEl.getBoundingClientRect();
popup.style.display = 'block';
popup.style.top = (rect.bottom + 8) + 'px';
popup.style.left = Math.min(rect.left, window.innerWidth - 340) + 'px';
}
function closeCommentPopup() {
document.getElementById('comment-popup').style.display = 'none';
activeCommentMark = null;
}
function deleteComment() {
if (!tiptapEditor || !activeCommentMark) return;
// Find the position of this mark in the editor and remove it
tiptapEditor.chain().focus().unsetMark('comment').run();
closeCommentPopup();
// Trigger save
clearTimeout(saveTimeout);
saveTimeout = setTimeout(saveContent, 500);
}
// Close popup on click outside
document.addEventListener('click', (e) => {
const popup = document.getElementById('comment-popup');
if (popup.style.display === 'block' && !popup.contains(e.target) && !e.target.closest('.comment-mark')) {
closeCommentPopup();
}
});
</script> </script>
</body> </body>
</html> </html>