feat: commentaires façon Notion — ancres jaunes, tiroir refondu, réactions emoji sur texte (v7.64.0)
FlowDeck CI / lint (push) Successful in 2m5s
FlowDeck CI / test (push) Failing after 3h5m38s
FlowDeck CI / docker (push) Skipped

- Réaction sur texte : bouton 🙂+ (« React to selected text ») dans la toolbar
  de sélection → sélecteur d'émoji EXISTANT en mode réaction (en-tête = texte
  sélectionné + ✋, onglets masqués, ligne Recent au-dessus de la grille
  People, Filter… et barre de catégories avec + custom inchangés)
- Backend : migration 37 text_reactions (UNIQUE plage × emoji × utilisateur →
  POST idempotent) + GET/POST /api/pages/{id}/reactions (GET groupé par plage
  emoji/count/mine, POST toggle, 400 sur plage vide)
- Affichage : plage commentée/réagie peinte en jaune (_paintAnchors après
  render/loadComments/loadReactions), pastille 😀+compte après la plage, clic
  pastille = rouvrir le sélecteur, clic ancre = ouvrir et centrer le fil
- Pastilles = annotations : gtTok/gtMd les ignorent (y compris le chemin
  rapide innerText) et les offsets _srcOffset/_srcNodeLen les excluent → le
  texte sauvé est identique (vérifié dans le e2e)
- Tiroir refondu : carte de fil (avatar, nom, horodatage, ✓ toggle
  resolve/reopen, ⋯ → Copy link #cmt-<id>/Delete), saisie « Add a comment... »
  avec @ et envoi ↑/Entrée ; #cmt-<id> ouvre et centre le fil au chargement ;
  loadComments + loadReactions à l'ouverture (commentCount enfin renseigné)
- Fixes : resolveComment ne pouvait pas rouvrir (toujours resolved:true) ;
  showCommentBtn ignorait les sélections à ancre élément (bouton 💬 jamais
  affiché)
- Tests : tests/test_v764_comments_reactions.py (8, dont toggle API réel),
  e2e/v764_comments_reactions.spec.js (8 étapes) — 3/3 specs e2e verts,
  suite complète 1383 passed / 0 failed, ruff OK, OpenAPI 7.64.0 (526 chemins)
This commit is contained in:
2026-10-08 10:46:14 -04:00
parent 699165469d
commit 400a827e3f
15 changed files with 920 additions and 39 deletions
+5 -1
View File
@@ -40,7 +40,7 @@
if (window.Alpine) window.Alpine.__fdIconPicker = true;
Alpine.store('fdIconPicker', {
open: false, x: 0, y: 0, tab: 'emoji', query: '', category: 'people',
open: false, x: 0, y: 0, tab: 'emoji', query: '', category: 'people', title: '',
skinTone: 0, toneOpen: false, recent: [], custom: [], customLoaded: false,
customModal: false, customName: '', customPreview: '', customFile: null, customBusy: false,
onPick: null, onRemove: null, _cx: 0, _cy: 0,
@@ -50,6 +50,9 @@
opts = opts || {};
this.onPick = opts.onPick || null;
this.onRemove = opts.onRemove || null;
// v7.64 : mode « réaction » — en-tête = texte sélectionné, emoji only
this.title = opts.title || '';
if (this.title) { this.tab = 'emoji'; this.category = 'people'; }
this.query = '';
this.toneOpen = false;
this.customModal = false;
@@ -78,6 +81,7 @@
close() {
this.open = false;
this.title = '';
this.customModal = false;
this.toneOpen = false;
this.onPick = null;
+222 -9
View File
@@ -36,7 +36,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
// [[fdpage:ID]] / [[fddate:YYYY-MM-DD]] tokens.
function gtTok(e){
if(!e) return '';
if(!e.querySelector||!e.querySelector('.fd-wiki-chip,.fd-wiki-date')) return gt(e);
if(!e.querySelector||!e.querySelector('.fd-wiki-chip,.fd-wiki-date,.fd-react-chip')) return gt(e); // le chemin rapide (innerText) inclurait les pastilles
let out='';
(function walk(node){
for(const n of node.childNodes){
@@ -44,6 +44,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
if(n.nodeType===1){
if(n.classList&&n.classList.contains('fd-wiki-chip')){out+=n.getAttribute('data-token')||'';continue;}
if(n.classList&&n.classList.contains('fd-wiki-date')){out+=n.getAttribute('data-token')||'';continue;}
if(n.classList&&n.classList.contains('fd-react-chip')){continue;} // annotation, pas du contenu
if(n.tagName==='BR'){out+='\n';continue;}
walk(n);
}
@@ -67,6 +68,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
if(n.nodeType===3){out+=n.nodeValue;continue;}
if(n.nodeType!==1)continue;
var cl=n.classList;
if(cl&&cl.contains('fd-react-chip')){continue;} // annotation, pas du contenu
if(cl&&(cl.contains('fd-wiki-chip')||cl.contains('fd-wiki-date'))){out+=n.getAttribute('data-token')||'';continue;}
if(n.tagName==='BR'){out+='\n';continue;}
if(cl&&cl.contains('fd-math-inline')){out+='$$'+(n.getAttribute('data-tex')||'')+'$$';continue;}
@@ -103,6 +105,69 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
var s2=window.getSelection(),nr=document.createRange();
nr.selectNodeContents(el);s2.removeAllRanges();s2.addRange(nr);
}
// v7.64 : marquage d'une plage commentée/réagie (jaune). Ne change pas le
// texte lu par sync() : les spans n'ajoutent que des nœuds d'annotation.
function _wrapAnchored(el,start,end,cls,attrs){
if(!(end>start))return null;
let pos=0,sN=null,sO=0,eN=null,eO=0;
const walker=document.createTreeWalker(el,NodeFilter.SHOW_TEXT);
let n=walker.nextNode();
while(n){
const len=n.nodeValue.length;
if(!sN&&pos+len>start){sN=n;sO=start-pos;}
if(sN&&pos+len>=end){eN=n;eO=end-pos;break;}
pos+=len;n=walker.nextNode();
}
if(!sN||!eN)return null;
const r=document.createRange();
try{r.setStart(sN,sO);r.setEnd(eN,eO);}catch(e){return null;}
const span=document.createElement('span');span.className=cls;
span.setAttribute('contenteditable','false');
if(attrs)for(const k in attrs)span.setAttribute('data-'+k,attrs[k]);
try{r.surroundContents(span);}catch(e){try{span.appendChild(r.extractContents());r.insertNode(span);}catch(e2){return null;}}
return span;
}
// Offsets dans le texte SOURCE d'un bloc : les pastilles .fd-react-chip
// (annotations) ne comptent pas, comme dans gtTok/gtMd.
function _srcNodeLen(node){
if(!node)return 0;
if(node.nodeType===3){
const pe=node.parentElement;
if(pe&&pe.closest&&pe.closest('.fd-react-chip'))return 0;
return node.nodeValue.length;
}
if(node.nodeType!==1)return 0;
if(node.classList&&node.classList.contains('fd-react-chip'))return 0;
let t=0;const kids=node.childNodes;
for(let i=0;i<kids.length;i++)t+=_srcNodeLen(kids[i]);
return t;
}
function _srcOffset(root,container,offset){
if(!container)return 0;
if(container.nodeType===1){
let t=0;const kids=container.childNodes;
for(let i=0;i<offset&&i<kids.length;i++)t+=_srcNodeLen(kids[i]);
return t;
}
if(container.nodeType!==3)return 0;
let t=0;
const walker=document.createTreeWalker(root,NodeFilter.SHOW_TEXT);
let n;
while((n=walker.nextNode())){
if(n===container){
const pe=n.parentElement;
if(pe&&pe.closest&&pe.closest('.fd-react-chip'))return t;
return t+Math.max(0,Math.min(offset,n.nodeValue.length));
}
t+=_srcNodeLen(n);
}
return t;
}
function _emojiHtml(v){
const s=String(v||'');
if(/^(https?:|\/)/.test(s))return '<img src="'+esc(s)+'" alt="">';
return esc(s);
}
function splitCaret(e){
const s=window.getSelection(); if(!s.rangeCount||!s.rangeCount)return null; const sel=s.getRangeAt(0);
const MK='\uF000FD'; const mark=document.createElement('span'); mark.textContent=MK;
@@ -1311,7 +1376,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
inviteEmail:'',invitePermission:'edit',inviteUserId:null,inviteGroupId:null,inviteGroups:[],inviteUsers:[],inviteSuggestOpen:false,inviteSel:-1,accessList:[],
toastVisible:false,toastMsg:'',
// ── v4.9.0: Collaboration — comments & mentions ──
commentsOpen:false,commentDraft:'',comments:[],commentCount:0,
commentsOpen:false,commentDraft:'',comments:[],commentCount:0,reactions:[],commentMenu:null,
currentUserId:PD.user_id||0,
_commentSel:{range:null,bid:null,start:0,end:0},
_notifiedMentions:{},
@@ -1363,6 +1428,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
this.insertTable=function(){this.sync();this.pushHistory();var nb={id:genId(),type:'table',content:'',rows:_tblNew(3,3)};this.blocks.push(nb);this.dirty=true;this.autoSave();this.render();_rtSync();setTimeout(function(){_tblFocus(nb.id,0,0);},60);};
SM.init();_tblInitDelegate();this.render();
this._applyLockMode();this._applyLayout();_wikiResolve();
this.loadComments();this.loadReactions(); // ancres jaunes + pastilles (v7.64)
const tEl=document.getElementById('_titleEl');
if(tEl){const syncT=()=>{tEl.classList.toggle('empty',!(tEl.textContent||'').trim());};syncT();tEl.addEventListener('input',syncT);tEl.addEventListener('blur',syncT);}
if(this.contentFormat==='blocks'&&window.__fdRT&&window.__fdRT.start){setTimeout(()=>{try{window.__fdRT.start(this);}catch(e){}},150);}
@@ -1538,6 +1604,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
ct.querySelectorAll('[data-bid]').forEach(el=>{el.addEventListener('input',()=>{self.dirty=true;self.autoSave();if(el.hasAttribute&&el.hasAttribute('data-placeholder'))el.classList.toggle('empty',!(el.textContent||'').trim());if(window.AIAC)window.AIAC.schedule(el.getAttribute('data-bid'));})});
ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/<br[^>]*>/gi,'').trim()!==''?el.innerHTML:'';el.classList.toggle('empty',!el.textContent.trim());});
_selApply();
try{this._paintAnchors();}catch(e){} // ancres jaunes + pastilles (v7.64)
var self2=this;
setTimeout(function(){
if(window._wikiResolve)_wikiResolve();
@@ -2515,9 +2582,41 @@ applyAIBlocks(text){
fmtAskAI(){this.fmt.open=false;const i=this.fmt.idx;if(i>=0)this.askAIBlock(i);},
fmtEditAI(){this.fmtAskAI();},
fmtSkill(tpl){this.fmt.open=false;const i=this.fmt.idx;if(i>=0)this.runSkill(i,tpl);},
// offsets d'une Range dans le texte rendu d'un bloc (partagé bouton 💬 + réaction)
_selSpan(bel,range){return {start:_srcOffset(bel,range.startContainer,range.startOffset),
end:_srcOffset(bel,range.endContainer,range.endOffset)};},
// « React to selected text » (v7.64) : sélecteur d'émoji avec le texte
// sélectionné en en-tête → toggle de la réaction ancrée sur la plage
fmtReact(){
if(!this._sel)return;
const E=this;const bid=this._fmtBid();const bel=this.getEl(bid);
if(!bel||!window.FDIconPicker)return;
const span=this._selSpan(bel,this._sel.range);
if(span.end<=span.start)return;
const text=this._sel.range.toString();
const r=this._sel.range.getBoundingClientRect();
this.fmt.open=false;
window.FDIconPicker.openFor({
x:Math.min(Math.max(8,r.left-40),window.innerWidth-360),
y:Math.min(Math.max(8,r.bottom+8),window.innerHeight-260),
title:text.length>90?text.slice(0,90)+'…':text,
onPick:function(emoji){E.toggleReaction(bid,span.start,span.end,emoji);}
});
},
// ── v4.9.0: Collaboration — comments & mentions ──
toggleComments(){this.commentsOpen=!this.commentsOpen;if(this.commentsOpen)this.loadComments();},
async loadComments(){try{const r=await fetch('/api/pages/'+this.pid+'/comments',{credentials:'same-origin'});const d=await r.json();this.comments=d.comments||[];this.commentCount=this.comments.length;}catch(e){this.comments=[];}},
async loadComments(){
try{const r=await fetch('/api/pages/'+this.pid+'/comments',{credentials:'same-origin'});const d=await r.json();
this.comments=d.comments||[];this.commentCount=this.comments.length;}
catch(e){this.comments=[];}
this._paintAnchors();
// #cmt-<id> (copié depuis le menu « … » du fil) → ouvre et centre le fil
if(!this._cmtHashDone&&location.hash.indexOf('#cmt-')===0){
this._cmtHashDone=true;
const cid=parseInt(location.hash.slice(5),10);
if(!isNaN(cid))setTimeout(()=>this.openThread(cid),300);
}
},
fmtTime(s){if(!s)return '';const t=new Date((String(s).includes('T')||String(s).includes('Z'))?s:(s+'Z'));if(isNaN(t.getTime()))t=new Date(s);const diff=Math.floor((Date.now()-t.getTime())/1000);if(diff<60)return 'just now';if(diff<3600)return Math.floor(diff/60)+'m ago';if(diff<86400)return Math.floor(diff/3600)+'h ago';return Math.floor(diff/86400)+'d ago';},
csrfTok(){return getCsrf();},
async addPageComment(){
@@ -2532,8 +2631,94 @@ applyAIBlocks(text){
else{this.showToast(d.detail||'Failed to add comment');}
}catch(e){this.showToast('Failed to add comment');}
},
async resolveComment(id){try{await fetch('/api/comments/'+id,{method:'PUT',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({resolved:true})});await this.loadComments();}catch(e){}},
async deleteComment(id){if(!confirm('Delete this comment?'))return;try{await fetch('/api/comments/'+id,{method:'DELETE',credentials:'same-origin',headers:{'X-CSRF-Token':this.csrfTok()}});await this.loadComments();}catch(e){}},
async resolveComment(c){
// accepte l'objet (le fil → bascule resolve/reopen) ou un id brut
let id=c,resolved=true;
if(c&&typeof c==='object'){id=c.id;resolved=!c.resolved;}
if(!id)return;
try{await fetch('/api/comments/'+id,{method:'PUT',credentials:'same-origin',
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},
body:JSON.stringify({resolved:resolved})});
this.commentMenu=null;await this.loadComments();}catch(e){}
},
async deleteComment(id){if(!confirm('Delete this comment?'))return;try{await fetch('/api/comments/'+id,{method:'DELETE',credentials:'same-origin',headers:{'X-CSRF-Token':this.csrfTok()}});this.commentMenu=null;await this.loadComments();}catch(e){}},
// ── v7.64 : réactions emoji sur texte + ancres commentées ──
async loadReactions(){
try{const r=await fetch('/api/pages/'+this.pid+'/reactions',{credentials:'same-origin'});
const d=await r.json();this.reactions=(d&&d.reactions)||[];}catch(e){this.reactions=[];}
this._paintAnchors();
},
async toggleReaction(bid,start,end,emoji){
try{
const r=await fetch('/api/pages/'+this.pid+'/reactions',{method:'POST',credentials:'same-origin',
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},
body:JSON.stringify({block_id:bid,anchor_start:start,anchor_end:end,emoji:emoji})});
const d=await r.json();
if(d.status==='added'||d.status==='removed'){await this.loadReactions();}
else this.showToast((d&&d.detail)||'Réaction impossible','error');
}catch(e){this.showToast('Réaction impossible','error');}
},
openThread(cid){
this.commentsOpen=true;this.commentMenu=null;
const self=this;
this.loadComments().then(function(){self.$nextTick(function(){
const el=document.querySelector('[data-thread="'+cid+'"]');
if(el&&el.scrollIntoView){el.scrollIntoView({block:'center',behavior:'smooth'});
el.classList.add('thread-flash');setTimeout(function(){el.classList.remove('thread-flash');},1500);}
});});
},
commentAt(){
this.commentDraft=(this.commentDraft||'')+'@';
this.$nextTick(function(){const ta=document.querySelector('.comments-drawer textarea');
if(ta){ta.focus();ta.selectionStart=ta.selectionEnd=ta.value.length;}});
},
copyCommentLink(id){
const url=location.origin+location.pathname+'#cmt-'+id;
this.commentMenu=null;
if(navigator.clipboard&&navigator.clipboard.writeText){
navigator.clipboard.writeText(url).then(()=>this.showToast('Lien du commentaire copié')).catch(()=>this.showToast(url,'error'));
} else this.showToast(url,'error');
},
// Recalcule les ancres jaunes (commentaires) + pastilles de réaction.
// Le texte des blocs est inchangé : sync() lit la même chaîne.
_paintAnchors(){
const ct=document.getElementById('_blocksCt');if(!ct)return;
ct.querySelectorAll('.fd-react-chip').forEach(function(el){el.remove();});
ct.querySelectorAll('.fd-anchor').forEach(function(el){
const par=el.parentNode;if(!par)return;
while(el.firstChild)par.insertBefore(el.firstChild,el);
par.removeChild(el);par.normalize();
});
const self=this;
(this.comments||[]).forEach(function(c){
if(!c.anchor_block_id||c.anchor_start==null||c.anchor_end==null)return;
const el=self.getEl(c.anchor_block_id);if(!el)return;
_wrapAnchored(el,parseInt(c.anchor_start,10),parseInt(c.anchor_end,10),
'fd-anchor'+(c.resolved?' fd-anchor-done':''),{cid:c.id});
});
const groups={};
(this.reactions||[]).forEach(function(r){
const k=r.block_id+':'+r.anchor_start+':'+r.anchor_end;
if(!groups[k])groups[k]={bid:r.block_id,start:parseInt(r.anchor_start,10),end:parseInt(r.anchor_end,10),items:[]};
groups[k].items.push(r);
});
Object.keys(groups).forEach(function(k){
const g=groups[k];
const el=self.getEl(g.bid);if(!el)return;
const sp=_wrapAnchored(el,g.start,g.end,'fd-anchor fd-anchor-react',null);
if(!sp||!sp.parentNode)return;
const n=g.items.reduce(function(a,i){return a+(parseInt(i.count,10)||1);},0);
const chip=document.createElement('span');
chip.className='fd-react-chip';
chip.setAttribute('contenteditable','false');
chip.setAttribute('data-react-bid',g.bid);
chip.setAttribute('data-react-start',String(g.start));
chip.setAttribute('data-react-end',String(g.end));
chip.innerHTML=g.items.map(function(i){return '<span class="fd-react-emo">'+_emojiHtml(i.emoji)+'</span>';}).join('')
+(n>1?'<span class="fd-react-n">'+n+'</span>':'');
sp.parentNode.insertBefore(chip,sp.nextSibling);
});
},
commentOnSelection(){
const sel=this._commentSel;if(!sel.range)return;
const bid=sel.bid||'';this.commentsOpen=true;this.commentDraft='';
@@ -2543,12 +2728,14 @@ applyAIBlocks(text){
showCommentBtn(){
const s=window.getSelection();if(!s||s.isCollapsed)return;
const ct=document.getElementById('_blocksCt');if(!ct||!ct.contains(s.anchorNode))return;
const bel=s.anchorNode.parentElement? s.anchorNode.parentElement.closest('[data-bid]'):null;
// ancre = nœud texte ou élément (comme showFmt depuis v7.62)
const anEl=s.anchorNode?(s.anchorNode.nodeType===1?s.anchorNode:s.anchorNode.parentElement):null;
const bel=anEl?anEl.closest('[data-bid]'):null;
if(!bel)return;
const bid=bel.dataset.bid;
const range=s.getRangeAt(0).cloneRange();range.selectNodeContents(bel);range.setEnd(s.getRangeAt(0).startContainer,s.getRangeAt(0).startOffset);
const start=range.toString().length;
const end=start+s.toString().length;
const span0=this._selSpan(bel,s.getRangeAt(0));
const start=span0.start;
const end=span0.end;
if(end<=start)return;
this._commentSel={range:s.getRangeAt(0).cloneRange(),bid:bid,start:start,end:end};
const r=s.getRangeAt(0).getBoundingClientRect();
@@ -2887,6 +3074,32 @@ applyAIBlocks(text){
// (l'ancien accès rendait muets showFmt/hideCommentBtn/mentions).
function _xed(el){ if(!el) return null; if(el.__x&&el.__x.$data) return el.__x.$data;
return (el._x_dataStack&&el._x_dataStack[0])||null; }
// v7.64 : clic sur une ancre jaune → ouvre et centre le fil de discussion ;
// clic sur une pastille de réaction → réouvre le sélecteur pour cette plage.
document.addEventListener('click',function(ev){
const t=ev.target;if(!t||!t.closest)return;
const d=_xed(document.querySelector('.page-editor-wrapper'));if(!d)return;
const chip=t.closest('.fd-react-chip');
if(chip){
const bid=chip.getAttribute('data-react-bid');
const start=parseInt(chip.getAttribute('data-react-start'),10);
const end=parseInt(chip.getAttribute('data-react-end'),10);
const el=d.getEl(bid);const rect=chip.getBoundingClientRect();
if(!window.FDIconPicker||!el||isNaN(start)||isNaN(end))return;
window.FDIconPicker.openFor({
x:Math.min(Math.max(8,rect.left-40),window.innerWidth-360),
y:Math.min(Math.max(8,rect.bottom+8),window.innerHeight-260),
title:(el.textContent||'').slice(start,end),
onPick:function(emoji){d.toggleReaction(bid,start,end,emoji);}
});
return;
}
const anch=t.closest('.fd-anchor[data-cid]');
if(anch&&d.openThread){
const cid=parseInt(anch.getAttribute('data-cid'),10);
if(!isNaN(cid))d.openThread(cid);
}
});
document.addEventListener('mouseup',(ev)=>{setTimeout(()=>{
// v7.62 : clic dans la toolbar → on laisse la sélection (et le menu) intacts ;
// sélection relâchée/cliquée ailleurs → le menu de sélection se ferme.