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:
+139
-2
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user