feat: commentaire ancré sur la SÉLECTION de texte (pas le bloc) + retrait du bouton bleu « 💬 Comment » (v7.65.0)
FlowDeck CI / lint (push) Successful in 1m34s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 12m52s

- commentBlock() : l'ancre est la sélection vivante dans le bloc (repli : la
  plage mémorisée par la toolbar de sélection, qui survit au clic du bouton),
  sinon tout le bloc ; offsets via _srcOffset() → anchor_start/anchor_end
  exacts dans le texte source, l'ancre jaune ne couvre que le texte choisi.
- Suppression de #_commentSelBtn (bouton bleu flottant « 💬 Comment » au-dessus
  de la sélection), de showCommentBtn()/hideCommentBtn() et du listener
  mousedown associé ; déclencheurs restants : 💬 de la toolbar de sélection,
  menu de bloc, Ctrl+⇧+M.
- toggleComments() remet _commentSel à zéro à la fermeture du tiroir : un
  brouillon abandonné ne peut plus ancrer le commentaire suivant ailleurs.
- e2e v764_comments_reactions : sous-sélection « reaction a un » → ancre = la
  plage seule (avant/après reload), #_commentSelBtn absent.

Tests: pytest tests/ -q -n auto → 1383 passed ; ruff check app tests → OK ;
e2e v764/v762/v763/v7641/regression_editor_mount → 5 passed sur :8081.
This commit is contained in:
2026-10-08 12:19:56 -04:00
parent 858649e3c3
commit 00c5e2a60d
9 changed files with 102 additions and 50 deletions
+27
View File
@@ -1,5 +1,32 @@
# Changelog - FlowDeck
## v7.65.0 (2026-10-08) — Comment ancré sur la SÉLECTION de texte (pas le bloc) + retrait du bouton bleu « 💬 Comment »
### Changed
- **`commentBlock()` ancre sur la sélection de texte** : la plage vivante dans le
bloc est utilisée d'abord (plage de la toolbar de sélection en repli, le clic
sur le bouton ayant pu la faire disparaître du DOM), sinon tout le bloc.
Les offsets passent par `_srcOffset()` → `anchor_start/anchor_end` = offsets
dans le texte source, donc l'ancre jaune ne couvre que le texte sélectionné
(`_paintAnchors` / réactions inchangés).
- **Retiré le bouton bleu flottant « 💬 Comment »** (`#_commentSelBtn`) qui
apparaissait au-dessus de la sélection : le déclencheur est le bouton 💬 de
la toolbar de sélection, le menu de bloc (💬 Comment, Ctrl+⇧+M) et le raccourci.
`showCommentBtn()`/`hideCommentBtn()` et le listener `mousedown` associés sont
supprimés.
- Fermer le tiroir (`toggleComments`) remet `_commentSel` à zéro : un brouillon
abandonné ne peut plus attacher le commentaire suivant à une ancienne plage.
### Tests
- **e2e** `e2e/v764_comments_reactions.spec.js` : sélection d'une **sous-chaîne**
(`reaction a un`) → toolbar 💬 → tiroir → envoi ; assert `#_commentSelBtn`
absent et `.fd-anchor[data-cid]` = **uniquement** la plage sélectionnée
(avant **et** après reload).
- `pytest tests/ -q -n auto` → **1383 passed**, `ruff check app tests` → OK.
## v7.64.1 (2026-10-08) — Fix : Delete sur un bloc vide supprime le bloc
### Fixed
+18
View File
@@ -1464,6 +1464,24 @@ Détails livrés :
vide jugé via `gtTok()` (ignore les pastilles) ; e2e
`e2e/v7641_delete_key.spec.js`
### v7.65.0 — Comment ancré sur la sélection de texte ✅ (2026-10-08)
> **Objectif** : le commentaire porte sur le texte sélectionné dans le bloc,
> pas sur le bloc entier, et le bouton bleu flottant disparaît. **COMPLETED**.
- [x] **Ancrage sur la plage** — `commentBlock()` prend la sélection vivante dans
le bloc (repli : la plage mémorisée par la toolbar, qui survit au clic du
bouton), sinon tout le bloc ; offsets calculés par `_srcOffset()` →
`anchor_start/anchor_end` exacts dans le texte source
- [x] **Bouton bleu « 💬 Comment » retiré** — `#_commentSelBtn`,
`showCommentBtn()`, `hideCommentBtn()` et leur listener `mousedown` supprimés ;
déclencheurs restants = 💬 de la toolbar de sélection, menu de bloc,
Ctrl+⇧+M
- [x] **Tiroir** — fermer remet `_commentSel` à zéro (pas d'ancrage périmé)
- [x] **Tests** — e2e `e2e/v764_comments_reactions.spec.js` : sous-sélection
`reaction a un` → `.fd-anchor[data-cid]` = la plage seule, avant/après
reload, `#_commentSelBtn` absent ; `pytest` 1383 passed, `ruff` OK ;
`VERSION` + `app/main.py` = 7.65.0
---
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
+1 -1
View File
@@ -1 +1 @@
7.64.1
7.65.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.64.1 (fix Delete sur bloc vide → suppression du bloc) | **Statut**: EN COURS 🔄
> **Début**: 2026-07-08 | **Version**: v7.65.0 (comment ancré sur la sélection de texte, bouton bleu retiré) | **Statut**: EN COURS 🔄
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
## Avancement Global
+1 -1
View File
@@ -186,7 +186,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.64.1",
version="7.65.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
-9
View File
@@ -578,15 +578,6 @@
background:var(--bg-modal,#1f1f1f);border:1px solid var(--border,#333);border-radius:10px;
box-shadow:var(--shadow-modal);padding:6px;"></div>
<!-- ═══════════ Inline comment trigger on selection (v4.9.0) ═══════════ -->
<button type="button" id="_commentSelBtn"
style="display:none;position:fixed;z-index:1100;padding:7px 12px;font-size:13px;font-weight:600;
color:#fff;background:var(--accent,#2383E2);border:none;border-radius:8px;cursor:pointer;
box-shadow:0 4px 16px rgba(0,0,0,.35);" title="Comment on selection"
@click="edCall('commentOnSelection')">
💬 Comment
</button>
<!-- ═══════════ Comments drawer (v4.9.0 · refonte v7.64 façon Notion) ═══════════ -->
<div class="comments-drawer" x-show="commentsOpen" x-cloak x-transition
style="position:fixed;top:var(--topbar-height,44px);right:0;bottom:0;width:340px;max-width:92vw;
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.64.1"
"version": "7.65.0"
},
"paths": {
"/auth/register": {
+31 -4
View File
@@ -54,6 +54,30 @@ async function newPage(page, body) {
return id;
}
// sélectionne une SOUS-chaîne du bloc (ancre = plage, pas le bloc entier)
async function selectSub(page, sub) {
await page.evaluate((sub) => {
const el = document.querySelector('#_blocksCt [data-bid]');
const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
let n;
while ((n = w.nextNode())) {
if (n.parentElement && n.parentElement.closest('.fd-react-chip')) continue;
const i = n.nodeValue.indexOf(sub);
if (i < 0) continue;
const r = document.createRange();
r.setStart(n, i);
r.setEnd(n, i + sub.length);
const s = getSelection();
s.removeAllRanges();
s.addRange(r);
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
return;
}
throw new Error('sous-chaîne introuvable : ' + sub);
}, sub);
await expect(page.locator('.format-toolbar')).toBeVisible({ timeout: 5000 });
}
// sélectionne tout le bloc et simule la fin du glisser-souris
async function selectBlock(page) {
await page.evaluate(() => {
@@ -100,10 +124,10 @@ test('réaction emoji + commentaires ancrés', async ({ page }) => {
expect(content).toBe('Prise de reaction a un texte'); // la pastille n'est pas du contenu
await page.screenshot({ path: 'shots/v764-chip.png' });
// ── 4. commentaire : bouton 💬 → tiroir refondu → envoi ──
await selectBlock(page);
await expect(page.locator('#_commentSelBtn')).toBeVisible();
await page.locator('#_commentSelBtn').click();
// ── 4. commentaire sur une SOUS-sélection : toolbar 💬 → tiroir refondu → envoi ──
await expect(page.locator('#_commentSelBtn')).toHaveCount(0); // bouton bleu retiré
await selectSub(page, 'reaction a un');
await page.locator('.format-toolbar .ft-item[data-tooltip="Comment — Ctrl+⇧+M"]').click();
const drawer = page.locator('.comments-drawer');
await expect(drawer).toBeVisible();
await drawer.locator('textarea').fill('Ceci est mon premier commentaire');
@@ -115,7 +139,9 @@ test('réaction emoji + commentaires ancrés', async ({ page }) => {
await expect(thread.locator('.ct-name')).not.toHaveText('');
await expect(thread.locator('.ct-act')).toHaveCount(2); // ✓ + ⋯
await expect(thread.locator('.ct-body')).toHaveText('Ceci est mon premier commentaire');
// l'ancre jaune = UNIQUEMENT la plage sélectionnée, pas le bloc
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveCount(1);
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveText('reaction a un');
await page.screenshot({ path: 'shots/v764-drawer.png' });
// ── 5. menu ⋯ du fil (Copy link / Delete) ──
@@ -140,5 +166,6 @@ test('réaction emoji + commentaires ancrés', async ({ page }) => {
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveCount(1, { timeout: 10000 });
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveText('reaction a un');
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1, { timeout: 10000 });
});
+22 -33
View File
@@ -1865,11 +1865,24 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
setBlockColor(idx,key,val){if(idx<0||idx>=this.blocks.length)return;const targets=this._blockTargets(idx);const v=(!val||val==='default')?null:val;this.sync();this.pushHistory();targets.forEach(i=>{const b=this.blocks[i];if(!b)return;if(!b.style)b.style={};if(v==null)delete b.style[key];else b.style[key]=v;});if(v!=null){try{localStorage.setItem('fdLastBlockColor',key+'|'+v);}catch(e){}}this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();},
// ── v7.61 : actions du menu contextuel de bloc ──
commentBlock(idx){
// L'ancre est la SÉLECTION de texte si elle vit dans ce bloc (la
// sélection de la toolbar aussi : le clic sur le bouton peut l'avoir
// fait disparaître du DOM), sinon tout le bloc.
const b=this.blocks[idx];if(!b)return;const el=this.getEl(b.id);if(!el)return;
const r=document.createRange();r.selectNodeContents(el);
const s=window.getSelection();s.removeAllRanges();s.addRange(r);
this.showCommentBtn();
if(!this._commentSel||!this._commentSel.range){this.showToast('Sélectionnez du texte pour commenter','error');return;}
let r=null;const s=window.getSelection();
if(s&&s.rangeCount&&!s.isCollapsed){
const rr=s.getRangeAt(0);
if(el.contains(rr.startContainer)&&el.contains(rr.endContainer))r=rr.cloneRange();
}
if(!r&&this._sel&&this._sel.range&&this._sel.idx===idx
&&el.contains(this._sel.range.startContainer)&&el.contains(this._sel.range.endContainer)){
r=this._sel.range.cloneRange();
}
if(!r){r=document.createRange();r.selectNodeContents(el);}
const start=_srcOffset(el,r.startContainer,r.startOffset);
const end=_srcOffset(el,r.endContainer,r.endOffset);
if(!(end>start)){this.showToast('Sélectionnez du texte pour commenter','error');return;}
this._commentSel={range:r,bid:el.dataset.bid,start:start,end:end};
this.commentOnSelection();
},
// v7.62 : renommé — « askAI() » (sans argument, plus bas) l'écrasait,
@@ -2607,7 +2620,7 @@ applyAIBlocks(text){
});
},
// ── v4.9.0: Collaboration — comments & mentions ──
toggleComments(){this.commentsOpen=!this.commentsOpen;if(this.commentsOpen)this.loadComments();},
toggleComments(){if(this.commentsOpen){this.commentsOpen=false;this._commentSel={range:null,bid:null,start:0,end:0};return;}this.commentsOpen=true;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;}
@@ -2630,7 +2643,7 @@ applyAIBlocks(text){
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},
body:JSON.stringify({body:text,anchor_block_id:sel.bid||null,anchor_start:sel.bid?sel.start:null,anchor_end:sel.bid?sel.end:null})});
const d=await r.json();
if(d.status==='created'){this.commentDraft='';this._commentSel={range:null,bid:null,start:0,end:0};this.hideCommentBtn();await this.loadComments();this.showToast('Comment added');}
if(d.status==='created'){this.commentDraft='';this._commentSel={range:null,bid:null,start:0,end:0};await this.loadComments();this.showToast('Comment added');}
else{this.showToast(d.detail||'Failed to add comment');}
}catch(e){this.showToast('Failed to add comment');}
},
@@ -2724,32 +2737,9 @@ applyAIBlocks(text){
},
commentOnSelection(){
const sel=this._commentSel;if(!sel.range)return;
const bid=sel.bid||'';this.commentsOpen=true;this.commentDraft='';
const btn=document.getElementById('_commentSelBtn');if(btn)btn.style.display='none';
this.commentsOpen=true;this.commentDraft='';
this.$nextTick(()=>{const ta=document.querySelector('.comments-drawer textarea');if(ta)ta.focus();});
},
showCommentBtn(){
const s=window.getSelection();if(!s||s.isCollapsed)return;
const ct=document.getElementById('_blocksCt');if(!ct||!ct.contains(s.anchorNode))return;
// 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 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();
const btn=document.getElementById('_commentSelBtn');if(!btn)return;
btn.style.display='block';
const bw=btn.offsetWidth||110,bh=btn.offsetHeight||34;
let x=r.left+r.width/2-bw/2,y=r.top-bh-8;
x=Math.min(Math.max(8,x),window.innerWidth-bw-8);if(y<8)y=r.bottom+8;
btn.style.left=x+'px';btn.style.top=y+'px';
},
hideCommentBtn(){const btn=document.getElementById('_commentSelBtn');if(btn)btn.style.display='none';},
// ── @mention autocomplete (v4.9.0 users + v5.11.0 pages & dates) ──
mentionOpen:false,mentionQuery:'',mentionResults:[],mentionPages:[],mentionEl:null,
openMention(el){
@@ -3074,7 +3064,7 @@ applyAIBlocks(text){
};
}
// v7.62 : data Alpine du wrapper éditeur — Alpine v3 n'expose plus __x
// (l'ancien accès rendait muets showFmt/hideCommentBtn/mentions).
// (l'ancien accès rendait muets showFmt/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 ;
@@ -3114,9 +3104,8 @@ applyAIBlocks(text){
if(!ct?.contains(s.anchorNode)){d.fmt.open=false;d._sel=null;return;}
const bel0=s.anchorNode&&(s.anchorNode.nodeType===1?s.anchorNode:s.anchorNode.parentElement);
const bel=bel0?bel0.closest('[data-bid]'):null;
if(bel){const idx=d.getIdx(bel.dataset.bid);if(idx>=0)d.showFmt(idx);d.showCommentBtn();}else{d.hideCommentBtn();}
if(bel){const idx=d.getIdx(bel.dataset.bid);if(idx>=0)d.showFmt(idx);}
},80);});
document.addEventListener('mousedown',()=>{setTimeout(()=>{const d=_xed(document.querySelector('.page-editor-wrapper'));if(d)d.hideCommentBtn();},10);});
document.addEventListener('keyup',(e)=>{
const d=_xed(document.querySelector('.page-editor-wrapper'));if(!d)return;
if(!d.mentionOpen){if(e.key!=='@')return;}