From 033490c46463dddd1a44ef410e1aacb7f56a2891 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 7 Oct 2026 23:09:21 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20menu=20contextuel=20de=20bloc=20refondu?= =?UTF-8?q?=20fa=C3=A7on=20Notion=20=E2=80=94=2010=20actions,=20sous-menus?= =?UTF-8?q?=20et=20raccourcis=20(v7.61.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - panneau de gauche : recherche « Search actions... », section « Text », les 10 actions dans l'ordre Notion (Turn into, Color, Copy link Alt+⇧+L, Duplicate Ctrl+D, Move to Ctrl+⇧+P, Delete Del, Comment Ctrl+⇧+M, Suggest edits Ctrl+⇧+Alt+X, Ask AI Ctrl+J, Skills) + pied métadonnées (auteur / heure de dernière édition / mots-caractères du bloc) - panneau de droite au survol (fermeture différée) : Turn into avec ✓ du type actuel + Heading 1-4, listes, To-do, Toggle, Code, Quote, Callout, Block equation, Synced block, 2/3/4/5 columns (info-bulle « Create N columns of blocks ») ; Color avec Last used / Text color / Background color (10 couleurs nommées, ✓ courante, carré de couleur) ; Skills = compétences réelles de l'espace appliquées au bloc via l'IA - turnInto(idx,type,cols) : crée N colonnes, sélecteur de source pour les blocs synchronisés ; setBlockColor mémorise la dernière couleur (Ctrl+⇧+H) - nouvelles actions éditeur : commentBlock (sélection + tiroir), askAI (composeur sans effacer le bloc), suggestEdit / runSkill (IA, insertion sous le bloc), applyLastColor - raccourcis : handler clavier dédié (Ctrl+J, Ctrl+⇧+H, Ctrl+⇧+M, Ctrl+⇧+Alt+X, Alt+⇧+L, Ctrl+⇧+P, Ctrl+D sans double, Del seulement avec le menu ouvert, Échap) — les entrées Copy/Cut/Paste du menu sont retirées (Ctrl+C/X/V conservés) - styles : .bm-group, .bm-foot, .bm-check, .bm-current, .bm-dot, sous-menu 60vh, recherche focalisée à l'ouverture - tests : tests/test_v761_block_menu.py (9 tests, dont node : colonnes) + test_v7593 adapté ; suite complète 1362 passed / 0 failed, ruff 0, eslint 0 - livraison : VERSION + app/main = 7.61.0, OpenAPI 525 chemins, CHANGELOG --- CHANGELOG.md | 51 ++++++ VERSION | 2 +- app/main.py | 2 +- docs/openapi-v2.json | 2 +- static/css/app.css | 26 ++- static/js/page_editor_scripts.js | 274 +++++++++++++++++++++++++++---- tests/test_v7593_turn_into.py | 8 +- tests/test_v761_block_menu.py | 169 +++++++++++++++++++ 8 files changed, 491 insertions(+), 43 deletions(-) create mode 100644 tests/test_v761_block_menu.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a85e7e7..0d7d063 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,56 @@ # Changelog - FlowDeck +## v7.61.0 (2026-10-08) — Menu contextuel de bloc refondu façon Notion + +### Added + +- **Panneau de gauche** : barre de recherche « Search actions... » (filtre en + direct), titre de section « Text », puis les 10 actions dans l'ordre Notion : + Turn into ›, Color ›, Copy link to block (`Alt+⇧+L`), Duplicate (`Ctrl+D`), + Move to (`Ctrl+⇧+P`), Delete (`Del`), Comment (`Ctrl+⇧+M`), Suggest edits + (`Ctrl+⇧+Alt+X`), Ask AI (`Ctrl+J`), Skills › — avec les raccourcis + affichés, et le pied de page « Last edited by … / Today at H:MM / N words, + M characters » (auteur lu dans le compte, heure = `updatedAt` du bloc page, + taille calculée sur le contenu du bloc). +- **Sous-menu « Turn into »** (survol du panneau de gauche → panneau de + droite, fermeture différée) : Text ✓ (type actuel coché), Heading 1-4, + Bulleted list, Numbered list, To-do list, Toggle list, Code, Quote, + Callout, Block equation, Synced block, et **2/3/4/5 columns** avec + info-bulle « Create N columns of blocks » ; `turnInto(idx, type, cols)` + crée désormais N colonnes et ouvre le sélecteur de source pour les blocs + synchronisés. +- **Sous-menu « Color »** : sections **Last used** (raccourci `Ctrl+⇧+H`), + **Text color** (Default, Gray, Brown, Orange, Yellow ✓, Green, Blue, + Purple, Pink, Red — glyphe « A » à la couleur) et **Background color** + (même palette avec carré de couleur) ; la couleur utilisée est cochée, la + dernière est mémorisée (`localStorage fdLastBlockColor`). +- **Sous-menu « Skills »** : liste réelle des compétences de l'espace + (`GET /api/agent/skills`) → applique le `prompt_template` au contenu du + bloc via l'IA et insère le résultat dessous. +- **Raccourcis** — handler clavier dédié : `Ctrl+J` (Ask AI), `Ctrl+⇧+H` + (dernière couleur), `Ctrl+⇧+M` (commenter le bloc : sélection du contenu + + tiroir de commentaires), `Ctrl+⇧+Alt+X` (suggérer une édition → résultat + inséré dessous), `Alt+⇧+L` (copier le lien), `Ctrl+⇧+P` (déplacer vers…), + `Ctrl+D` (dupliquer — déjà géré par la saisie, pas de double), `Del` + (**seulement avec le menu ouvert**, jamais pendant la saisie), `Échap` + (fermer). +- **Actions Suggest edits / Ask AI / Comment / Skills** réelles côté éditeur + (`suggestEdit`, `askAI`, `commentBlock`, `runSkill`) — Ask AI ouvre le + composeur inline sans effacer le contenu du bloc. +- **Styles** : `.bm-group`, `.bm-foot`, `.bm-check`, `.bm-current`, + `.bm-dot`, sous-menu `60vh` défilable, recherche focalisée à l'ouverture. +- **Tests** — `tests/test_v761_block_menu.py` : **9 tests** (structure et + ordre du panneau, dispatch des 10 actions, 3 sous-menus, survol/fermeture, + les 9 raccourcis, comportement node : `turnInto(…, 'columns', 3)` → 3 + colonnes, styles) + `test_v7593` adapté à la nouvelle signature. + +### Removed + +- Entrées **Copy / Cut / Paste blocks** du menu (absentes de la spécification + Notion) — les raccourcis `Ctrl+C/X/V` sur blocs sélectionnés sont conservés. +- Pastilles de couleurs inline : remplacées par le sous-menu Color nommé + (palette portée à 10 couleurs alignées Notion : +Brown, Pink réel). + ## v7.60.0 (2026-10-07) — Éditeur : bouton + à gauche du handle de bloc ### Added diff --git a/VERSION b/VERSION index 22861a9..4a41042 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.60.0 +7.61.0 diff --git a/app/main.py b/app/main.py index 6407807..94c75c6 100644 --- a/app/main.py +++ b/app/main.py @@ -186,7 +186,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.60.0", + version="7.61.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json index 497cdc6..fc5cdcb 100644 --- a/docs/openapi-v2.json +++ b/docs/openapi-v2.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "FlowDeck", - "version": "7.60.0" + "version": "7.61.0" }, "paths": { "/auth/register": { diff --git a/static/css/app.css b/static/css/app.css index 07801cd..e27fe7d 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -2406,6 +2406,30 @@ button.breadcrumb-current { max-width: 260px; } .block-menu .bm-item.bm-danger .bm-ico { color: #e5484d; } .block-menu .bm-item.bm-danger:hover { background: rgba(229,72,77,.12); } .block-menu .bm-sep { height: 1px; background: var(--border, #2a2a2a); margin: 5px 4px; } +/* ── v7.61 : menu contextuel type Notion — recherche, sections, pied, sous-menus ── */ +.block-menu .bm-group { + padding: 9px 9px 3px; + font-size: 10px; + font-weight: 600; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: .5px; +} +.block-menu .bm-foot { + border-top: 1px solid var(--border, #2a2a2a); + margin-top: 6px; + padding: 8px 9px 6px; + font-size: 11.5px; + line-height: 1.55; + color: var(--text-tertiary); +} +.block-menu .bm-check { margin-left: auto; color: var(--accent, #2383e2); font-size: 12px; } +.block-menu .bm-item.bm-current { background: var(--bg-secondary); } +.block-menu .bm-dot { + display: inline-block; width: 12px; height: 12px; border-radius: 3px; + border: 1px solid var(--border, #333); vertical-align: -2px; +} +.block-menu .bm-search:focus { border-color: var(--accent, #2383e2); } .block-menu .bm-colors { padding: 6px 9px; } .block-menu .bm-colors-label { font-size: 10px; font-weight: 600; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: .4px; margin-bottom: 6px; } .block-menu .bm-swatches { display: flex; flex-wrap: wrap; gap: 6px; } @@ -2436,7 +2460,7 @@ button.breadcrumb-current { max-width: 260px; } color: var(--text-secondary); cursor: pointer; font-size: 12px; border-radius: 6px; } .block-menu .bm-cancel:hover { background: var(--bg-hover); } -.block-menu-sub { min-width: 180px; max-height: 320px; overflow-y: auto; } +.block-menu-sub { min-width: 230px; max-height: 60vh; overflow-y: auto; } .ftable-hdr-toggle { margin-left: 12px; font-size: 12px; color: var(--text-tertiary); display:inline-flex; align-items:center; gap:2px; } .ftable-hdr-toggle label { display:inline-flex; align-items:center; gap:4px; cursor:pointer; } diff --git a/static/js/page_editor_scripts.js b/static/js/page_editor_scripts.js index 878c343..a3aaf54 100644 --- a/static/js/page_editor_scripts.js +++ b/static/js/page_editor_scripts.js @@ -921,74 +921,214 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON. {id:'numbered_list',name:'Numbered list'},{id:'to_do',name:'To-do'},{id:'toggle',name:'Toggle'}, {id:'quote',name:'Quote'},{id:'callout',name:'Callout'},{id:'table',name:'Table'}, {id:'math',name:'Math equation'},{id:'columns',name:'Column list'},{id:'button',name:'Button / Automation'}, - {id:'code',name:'Code block'} + {id:'code',name:'Code block'},{id:'synced',name:'Synced block'} ]; - const _BLOCK_COLORS_TEXT=['default','#E5484D','#F76B15','#FFC53D','#46A758','#3E63DD','#8E4EC6','#12A594','#94A3B8']; - const _BLOCK_COLORS_BG=['default','rgba(229,72,77,.15)','rgba(247,107,21,.15)','rgba(255,197,61,.15)','rgba(70,154,88,.15)','rgba(62,99,221,.15)','rgba(142,78,198,.15)','rgba(18,165,148,.15)','rgba(148,163,184,.2)']; - function _blockMenuClose(){var m=document.getElementById('blockMenu');if(m)m.remove();m=document.getElementById('blockMenuSub');if(m)m.remove();} + // Sous-menu « Turn into » — ordre Notion ; id ∈ _BLOCK_TURN_TYPES, cols = nb de colonnes + const _BLOCK_TURN_MENU=[ + {id:'paragraph',name:'Text',icon:'T'},{id:'heading_1',name:'Heading 1',icon:'H1'}, + {id:'heading_2',name:'Heading 2',icon:'H2'},{id:'heading_3',name:'Heading 3',icon:'H3'}, + {id:'heading_4',name:'Heading 4',icon:'H4'},{id:'bulleted_list',name:'Bulleted list',icon:'•'}, + {id:'numbered_list',name:'Numbered list',icon:'1.'},{id:'to_do',name:'To-do list',icon:'☑'}, + {id:'toggle',name:'Toggle list',icon:'▸'},{id:'code',name:'Code',icon:'</>'}, + {id:'quote',name:'Quote',icon:'”'},{id:'callout',name:'Callout',icon:'ⓘ'}, + {id:'math',name:'Block equation',icon:'∑'},{id:'synced',name:'Synced block',icon:'⛓'}, + {id:'columns',name:'2 columns',icon:'▦',cols:2},{id:'columns',name:'3 columns',icon:'▦',cols:3}, + {id:'columns',name:'4 columns',icon:'▦',cols:4},{id:'columns',name:'5 columns',icon:'▦',cols:5} + ]; + // Couleurs texte/fond alignées par index — noms Notion (Dernière utilisée ailleurs) + const _BLOCK_COLOR_NAMES=['Default','Gray','Brown','Orange','Yellow','Green','Blue','Purple','Pink','Red']; + const _BLOCK_COLORS_TEXT=['default','#94A3B8','#8A6642','#F76B15','#FFC53D','#46A758','#3E63DD','#8E4EC6','#E5539B','#E5484D']; + const _BLOCK_COLORS_BG=['default','rgba(148,163,184,.15)','rgba(138,102,66,.15)','rgba(247,107,21,.15)','rgba(255,197,61,.15)','rgba(70,154,88,.15)','rgba(62,99,221,.15)','rgba(142,78,198,.15)','rgba(229,83,155,.15)','rgba(229,72,77,.15)']; + // Raccourcis affichés — implémentés par le handler clavier en bas de fichier + const _BM_SHORTCUTS={link:'Alt+⇧+L',duplicate:'Ctrl+D',move:'Ctrl+⇧+P',delete:'Del',comment:'Ctrl+⇧+M',suggest:'Ctrl+⇧+Alt+X',ai:'Ctrl+J',color:'Ctrl+⇧+H'}; + var _bmSubKind='',_bmSubTimer=null; + function _blockMenuClose(){var m=document.getElementById('blockMenu');if(m)m.remove();m=document.getElementById('blockMenuSub');if(m)m.remove();clearTimeout(_bmSubTimer);_bmSubKind='';} + function _blockAttr(s){return String(s==null?'':s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');} + function _blockMenuBid(){var m=document.getElementById('blockMenu');return m?(m.getAttribute('data-bid')||''):'';} + // Sous-menu ouvert au survol de l'entrée correspondante (panneau de droite) + function _blockSubOpen(kind,bid,anchor){ + if(!kind||!bid)return; + var m=document.getElementById('blockMenu');if(!m)return; + if(_bmSubKind===kind&&document.getElementById('blockMenuSub'))return; + clearTimeout(_bmSubTimer);_bmSubKind=kind; + var r=m.getBoundingClientRect(); + var a=anchor&&anchor.getBoundingClientRect?anchor.getBoundingClientRect():r; + var top=Math.max(8,Math.min(a.top,window.innerHeight-60)); + if(kind==='turn')_blockTurnMenuOpen(r.right+4,top,bid); + else if(kind==='color')_blockColorMenuOpen(r.right+4,top,bid); + else if(kind==='skills')_blockSkillsMenuOpen(r.right+4,top,bid); + var s=document.getElementById('blockMenuSub'); + if(s){s.addEventListener('mouseenter',function(){clearTimeout(_bmSubTimer);}); + s.addEventListener('mouseleave',function(){_blockSubCloseLater();});} + } + function _blockSubCloseLater(){clearTimeout(_bmSubTimer);_bmSubTimer=setTimeout(function(){var s=document.getElementById('blockMenuSub');if(s&&!s.matches(':hover')){s.remove();_bmSubKind='';}},260);} function _blockMenuClick(ev){ var t=ev.target.closest&&ev.target.closest('[data-bm-act]');if(!t)return; - var act=t.getAttribute('data-bm-act');var bid=t.getAttribute('data-bid'); + var act=t.getAttribute('data-bm-act');var bid=t.getAttribute('data-bid')||_blockMenuBid(); if(!act)return; ev.preventDefault();ev.stopPropagation(); var E=window.E;if(!E)return; var idx=bid?E.getIdx(bid):-1; - if(act==='turn'){var m=document.getElementById('blockMenu');if(m){var r=m.getBoundingClientRect();_blockTurnMenuOpen(r.right+4,r.top,bid||'');}return;} + if(act==='submenu'){_blockSubOpen(t.getAttribute('data-sub'),bid,t);return;} if(act==='duplicate'){E.duplicateBlock(idx);} else if(act==='copy-link'){E.copyBlockLink(bid||'');} - else if(act==='copy-blocks'){E.copySelectedBlocks();} - else if(act==='cut-blocks'){E.cutSelectedBlocks();} - else if(act==='paste-blocks'){E.pasteBlocks();} else if(act==='move-to'){_blockMoveToOpen(idx);} else if(act==='delete'){E.deleteSelected(idx);} + else if(act==='comment'){E.commentBlock(idx);} + else if(act==='suggest'){E.suggestEdit(idx);} + else if(act==='ai'){E.askAI(idx);} + else if(act==='skill'){E.runSkill(idx,t.getAttribute('data-prompt')||'');} else if(act==='color'){var key=t.getAttribute('data-color-key'),val=t.getAttribute('data-color-val');E.setBlockColor(idx,key,val);} - else if(act==='turn-type'){E.turnInto(idx,t.getAttribute('data-type'));_blockMenuClose();} + else if(act==='turn-type'){E.turnInto(idx,t.getAttribute('data-type'),parseInt(t.getAttribute('data-cols')||'0',10)||0);_blockMenuClose();return;} _blockMenuClose(); } + function _blockMenuItem(act,icon,label,opts){ + opts=opts||{}; + var attr=' data-bm-act="'+act+'"'; + if(opts.bid)attr+=' data-bid="'+_blockAttr(opts.bid)+'"'; + if(opts.sub)attr+=' data-sub="'+opts.sub+'"'; + if(opts.colorKey)attr+=' data-color-key="'+opts.colorKey+'" data-color-val="'+_blockAttr(opts.colorVal||'')+'"'; + if(opts.prompt!=null)attr+=' data-prompt="'+_blockAttr(opts.prompt)+'"'; + if(opts.type)attr+=' data-type="'+opts.type+'"'; + if(opts.cols)attr+=' data-cols="'+opts.cols+'"'; + if(opts.title)attr+=' title="'+_blockAttr(opts.title)+'"'; + var right=opts.check?'✓' + :(opts.sub?'▸' + :(opts.shortcut?''+opts.shortcut+'':'')); + return '
' + +(icon!=null?''+icon+'':'')+label+right+'
'; + } + // Métadonnées du pied de page : auteur / heure de dernière édition / taille du bloc + function _blockMenuMeta(b){ + var E=window.E||{}; + var who=''; + try{var n=document.querySelector('.um-name')||document.querySelector('.workspace-name');who=n?(n.textContent||'').trim():'';}catch(e){} + var when=''; + if(E.updatedAt){var d=new Date(E.updatedAt);if(!isNaN(d)){ + var same=d.toDateString()===new Date().toDateString(); + var hm=d.toLocaleTimeString([],{hour:'numeric',minute:'2-digit'}); + when=(same?'Today at ':(d.toLocaleDateString([])+' at '))+hm;}} + var content=(b&&b.content)||''; + var trimmed=content.trim(); + var words=trimmed?trimmed.split(/\s+/).length:0; + return '
Last edited by '+esc(who||'you')+'
' + +(when?'
'+esc(when)+'
':'') + +'
'+words+' word'+(words===1?'':'s')+', '+content.length+' character'+(content.length===1?'':'s')+'
'; + } function blockMenuOpen(x,y,bid){ _blockMenuClose(); var E=window.E;if(!E)return; var idx=E.getIdx(bid);if(idx<0)return; var b=E.blocks[idx];if(!b)return; - var d=document.createElement('div');d.id='blockMenu';d.className='block-menu';d.style.cssText='position:fixed;z-index:4000;'; - var colorsText='',colorsBg=''; - _BLOCK_COLORS_TEXT.forEach(function(c){colorsText+='';}); - _BLOCK_COLORS_BG.forEach(function(c){colorsBg+='';}); - d.innerHTML='
↩Turn into▸
' - +'
' - +'
▣Duplicate '+(_rtMac()?'⌘D':'Ctrl+D')+'
' - +'
🔗Copy link to block
' - +'
' - +'
⎘Copy blocks '+(_rtMac()?'⌘C':'Ctrl+C')+'
' - +'
✂Cut blocks '+(_rtMac()?'⌘X':'Ctrl+X')+'
' - +'
⎘Paste blocks '+(_rtMac()?'⌘V':'Ctrl+V')+'
' - +'
' - +'
↪Move to…
' - +'
' - +'
Text
'+colorsText+'
' - +'
Background
'+colorsBg+'
' - +'
' - +'
✕Delete ⌫
'; + var d=document.createElement('div');d.id='blockMenu';d.className='block-menu';d.setAttribute('data-bid',b.id);d.style.cssText='position:fixed;z-index:4000;'; + var S=_BM_SHORTCUTS; + d.innerHTML='' + +'
Text
' + +_blockMenuItem('submenu','↩','Turn into',{sub:'turn'}) + +_blockMenuItem('submenu','🎨','Color',{sub:'color'}) + +_blockMenuItem('link','🔗','Copy link to block',{shortcut:S.link}) + +_blockMenuItem('duplicate','▣','Duplicate',{shortcut:S.duplicate}) + +_blockMenuItem('move-to','↪','Move to…',{shortcut:S.move}) + +_blockMenuItem('delete','✕','Delete',{shortcut:S.delete,danger:true}) + +_blockMenuItem('comment','💬','Comment',{shortcut:S.comment}) + +_blockMenuItem('suggest','✎','Suggest edits',{shortcut:S.suggest}) + +_blockMenuItem('ai','✨','Ask AI',{shortcut:S.ai}) + +_blockMenuItem('submenu','📚','Skills',{sub:'skills'}) + +_blockMenuMeta(b); d.addEventListener('click',_blockMenuClick); + d.addEventListener('input',function(ev){ + if(!ev.target.classList||!ev.target.classList.contains('bm-search'))return; + var q=(ev.target.value||'').toLowerCase(); + d.querySelectorAll('.bm-item').forEach(function(it){ + it.style.display=(q&&it.textContent.toLowerCase().indexOf(q)<0)?'none':''; + }); + var g=d.querySelector('.bm-group');if(g)g.style.display=q?'none':''; + }); + d.addEventListener('mouseover',function(ev){ + var it=ev.target&&ev.target.closest?ev.target.closest('[data-sub]'):null; + if(it)_blockSubOpen(it.getAttribute('data-sub'),b.id,it); + }); + d.addEventListener('mouseleave',function(){_blockSubCloseLater();}); d.addEventListener('contextmenu',function(ev){ev.preventDefault();_blockMenuClose();}); document.body.appendChild(d); var r=d.getBoundingClientRect(); x=Math.min(Math.max(8,x),window.innerWidth-r.width-8); y=Math.min(Math.max(8,y),window.innerHeight-r.height-8); d.style.left=x+'px';d.style.top=y+'px'; + var si=d.querySelector('.bm-search');if(si)setTimeout(function(){si.focus();},10); } - function _blockTurnMenuOpen(x,y,bid){ + // Panneau de droite : coquille commune des 3 sous-menus + function _blockSubShell(x,y){ var m=document.getElementById('blockMenuSub');if(m)m.remove(); var d=document.createElement('div');d.id='blockMenuSub';d.className='block-menu block-menu-sub';d.style.cssText='position:fixed;z-index:4001;'; - var html=''; - _BLOCK_TURN_TYPES.forEach(function(t){html+='
'+t.name+'
';}); - d.innerHTML=html; d.addEventListener('click',_blockMenuClick); document.body.appendChild(d); var r=d.getBoundingClientRect(); x=Math.min(Math.max(8,x),window.innerWidth-r.width-8); y=Math.min(Math.max(8,y),window.innerHeight-r.height-8); d.style.left=x+'px';d.style.top=y+'px'; + return d; + } + function _blockTurnMenuOpen(x,y,bid){ + var d=_blockSubShell(x,y); + var E=window.E;var cur=''; + if(E){var i=E.getIdx(bid);if(i>=0&&E.blocks[i])cur=E.blocks[i].type||'';} + var html=''; + _BLOCK_TURN_MENU.forEach(function(t){ + var on=(t.id===cur&&!t.cols); + html+=_blockMenuItem('turn-type',t.icon,t.name,{bid:bid,type:t.id,cols:t.cols, + title:t.cols?('Create '+t.cols+' columns of blocks'):'', + check:on,current:on}); + }); + d.innerHTML=html; + } + function _blockColorMenuOpen(x,y,bid){ + var d=_blockSubShell(x,y); + var E=window.E;var idx=E?E.getIdx(bid):-1;var b=idx>=0?E.blocks[idx]:null; + var curT=(b&&b.style&&b.style.color)||'default'; + var curB=(b&&b.style&&b.style.bgColor)||'default'; + var last='';try{last=localStorage.getItem('fdLastBlockColor')||'';}catch(e){} + var html=''; + if(last){ + var i=last.indexOf('|');var lk=i<0?'color':last.slice(0,i);var lv=last.slice(i+1); + var isBg=(lk==='bgColor'); + var arr=isBg?_BLOCK_COLORS_BG:_BLOCK_COLORS_TEXT; + var ni=arr.indexOf(lv); + var nm=(ni>=0?_BLOCK_COLOR_NAMES[ni]:'Custom')+(isBg?' background':' text'); + html+='
Last used
' + +_blockMenuItem('color','A', + nm,{bid:bid,colorKey:lk,colorVal:lv,shortcut:_BM_SHORTCUTS.color}); + } + html+='
Text color
'; + _BLOCK_COLORS_TEXT.forEach(function(v,n){ + html+=_blockMenuItem('color','A', + _BLOCK_COLOR_NAMES[n]+' text',{bid:bid,colorKey:'color',colorVal:v, + check:v===curT,current:v===curT}); + }); + html+='
Background color
'; + _BLOCK_COLORS_BG.forEach(function(v,n){ + html+=_blockMenuItem('color','', + _BLOCK_COLOR_NAMES[n]+' background',{bid:bid,colorKey:'bgColor',colorVal:v, + check:v===curB,current:v===curB}); + }); + d.innerHTML=html; + } + function _blockSkillsMenuOpen(x,y,bid){ + var d=_blockSubShell(x,y); + d.innerHTML='
Chargement…
'; + fetch('/api/agent/skills',{credentials:'same-origin'}) + .then(function(r){return r.json();}) + .then(function(res){ + if(!document.body.contains(d))return; + var list=(res&&res.skills)||[]; + if(!list.length){d.innerHTML='
Aucune compétence enregistrée — créez-en une dans le panneau Agent.
';return;} + var html=''; + list.forEach(function(s){ + html+=_blockMenuItem('skill','📚',esc(String(s.name||'Skill')),{bid:bid,prompt:s.prompt_template||''}); + }); + d.innerHTML=html; + }) + .catch(function(){if(document.body.contains(d))d.innerHTML='
Compétences indisponibles.
';}); } function _rtMac(){return /Mac|iPhone|iPad/.test(navigator.userAgent);} function _blockMoveToOpen(idx){ @@ -1526,10 +1666,37 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu // ── v5.10.0: Turn into / couleurs / liens / déplacement ── _blockTargets(idx){const sel=_selIndicesSorted();return(sel.includes(idx))?sel:[idx];}, - turnInto(idx,type){if(idx<0||idx>=this.blocks.length)return;if(!_BLOCK_TURN_TYPES.some(t=>t.id===type))return;const targets=this._blockTargets(idx);const focusId=this.blocks[idx].id;this.sync();this.pushHistory();targets.forEach(i=>{const b=this.blocks[i];if(!b)return;if(b.type===type)return;const keep=(b.content||'').trim();b.type=type;if(keep&&['image','embed','meeting','divider','table_of_contents','database','button','columns'].indexOf(b.type)<0){b.content=keep;}else{b.content='';}if(type==='toggle'){b.expanded=true;if(!Array.isArray(b.children))b.children=[];}if(type==='to_do')b.checked=false;if(type==='table'&&!Array.isArray(b.rows))b.rows=_tblNew(3,3);if(type==='code'&&!b.language)b.language='Plain Text';if(type==='columns'&&(!Array.isArray(b.children)||!b.children.length))b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='button'){b.automation_id=0;b.automation_name='';}}); + turnInto(idx,type,cols){if(idx<0||idx>=this.blocks.length)return;if(!_BLOCK_TURN_TYPES.some(t=>t.id===type))return;const targets=this._blockTargets(idx);const focusId=this.blocks[idx].id;this.sync();this.pushHistory();targets.forEach(i=>{const b=this.blocks[i];if(!b)return;if(b.type===type)return;const keep=(b.content||'').trim();b.type=type;if(keep&&['image','embed','meeting','divider','table_of_contents','database','button','columns'].indexOf(b.type)<0){b.content=keep;}else{b.content='';}if(type==='toggle'){b.expanded=true;if(!Array.isArray(b.children))b.children=[];}if(type==='to_do')b.checked=false;if(type==='table'&&!Array.isArray(b.rows))b.rows=_tblNew(3,3);if(type==='code'&&!b.language)b.language='Plain Text';if(type==='columns'&&(!Array.isArray(b.children)||!b.children.length))b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='columns'&&cols>=2){b.children=Array.from({length:cols},()=>({id:genId(),type:'paragraph',content:''}));}if(type==='synced'){b.synced_id=0;b._synced_content=[];b._synced_title='';}if(type==='button'){b.automation_id=0;b.automation_name='';}}); + if(type==='synced')setTimeout(()=>this._pickSyncedBlock(idx),80); this.dirty=true;this.autoSave();this.render();_rtSync();_selClear(); setTimeout(()=>{const bid=this.blocks[idx]?this.blocks[idx].id:focusId;if(type==='table'){_tblFocus(bid,0,0);}else{const el=this.getEl(bid);if(el){el.focus();ce(el);}}},60);}, - 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;});this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();}, + 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){ + 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;} + this.commentOnSelection(); + }, + askAI(idx){const b=this.blocks[idx];if(!b)return;if(window.AIC)window.AIC.open(idx);}, + suggestEdit(idx){ + const b=this.blocks[idx];if(!b||!(b.content||'').trim()){this.showToast('Bloc vide','error');return;} + this.showToast('FlowDeck AI : suggestion en cours…'); + this._aiWritingRun('write',idx,{prompt:'Améliore ce bloc (grammaire, clarté, style) en gardant le sens, la langue et le format. Réponds UNIQUEMENT avec le contenu amélioré, sans préambule.\n\nBloc :\n'+b.content},'insert'); + }, + runSkill(idx,tpl){ + const b=this.blocks[idx];if(!b||!(b.content||'').trim()){this.showToast('Bloc vide','error');return;} + this.showToast('FlowDeck AI : compétence en cours…'); + this._aiWritingRun('write',idx,{prompt:String(tpl||'')+'\n\nContenu du bloc :\n'+b.content},'insert'); + }, + applyLastColor(idx){ + let v='';try{v=localStorage.getItem('fdLastBlockColor')||'';}catch(e){} + const i=v.indexOf('|'); + if(i<0){this.showToast('Aucune couleur récente','error');return;} + this.setBlockColor(idx,v.slice(0,i),v.slice(i+1)); + }, copyBlockLink(bid){const url=window.location.origin+window.location.pathname+'#fdblk-'+bid;if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(url).then(()=>this.showToast('Lien du bloc copié')).catch(()=>this.showToast('Copie impossible','error'));}else{this.showToast(url,'error');}}, deleteSelected(idx){const indices=_selIndicesSorted();if(indices.length<=1)return this.removeBlock(idx);this.sync();this.pushHistory();const keep=this.blocks.filter((b,i)=>indices.indexOf(i)<0);this.blocks=keep.length?keep:[this.mkB('paragraph','')];const fi=Math.min(idx,this.blocks.length-1);this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);}, // ── v5.11: Copy / Cut / Paste selected blocks ── @@ -2583,6 +2750,43 @@ applyAIBlocks(text){ // `window.E` survit aux navigations partielles : on exige donc que // l'editeur soit reellement monte (`#_blocksCt`) avant d'agir, sinon // Ctrl+S sauvegarderait un editeur detache depuis une autre page. + // ── v7.61 : raccourcis du menu contextuel de bloc ── + document.addEventListener('keydown', function (e) { + var E = window.E; + if (!E || !document.getElementById('_blocksCt')) return; + var k = String(e.key || '').toLowerCase(); + var ctrl = e.ctrlKey || e.metaKey, sh = e.shiftKey, alt = e.altKey; + var menu = document.getElementById('blockMenu'); + var menuBid = menu ? (menu.getAttribute('data-bid') || '') : ''; + function curIdx(){ + if (menuBid) { var mi = E.getIdx(menuBid); if (mi >= 0) return mi; } + var ab = E.getActiveBlock(); + return ab && ab.idx >= 0 ? ab.idx : -1; + } + if (ctrl && !sh && !alt && k === 'j') { e.preventDefault(); var i1 = curIdx(); if (i1 >= 0) E.askAI(i1); return; } + if (ctrl && sh && !alt && k === 'h') { e.preventDefault(); var i2 = curIdx(); if (i2 >= 0) E.applyLastColor(i2); return; } + if (ctrl && sh && !alt && k === 'm') { e.preventDefault(); var i3 = curIdx(); if (i3 >= 0) E.commentBlock(i3); return; } + if (ctrl && sh && alt && k === 'x') { e.preventDefault(); var i4 = curIdx(); if (i4 >= 0) E.suggestEdit(i4); return; } + if (alt && sh && !ctrl && k === 'l') { e.preventDefault(); var i5 = curIdx(); if (i5 >= 0) E.copyBlockLink(E.blocks[i5].id); return; } + if (ctrl && sh && !alt && k === 'p') { e.preventDefault(); var i6 = curIdx(); if (i6 >= 0) _blockMoveToOpen(i6); return; } + // Ctrl+D : déjà géré par la saisie quand le bloc a le focus (pas de doublon) + if (ctrl && !sh && !alt && k === 'd') { + var ab = E.getActiveBlock(); + if (ab && document.activeElement === ab.el) return; + var idc = ab && ab.idx >= 0 ? ab.idx : (menuBid ? E.getIdx(menuBid) : -1); + if (idc >= 0) { e.preventDefault(); E.duplicateBlock(idc); } + return; + } + // Del : supprime le bloc SEULEMENT avec le menu ouvert (jamais pendant la saisie) + if (k === 'delete' && !ctrl && !sh && !alt && menu) { + var tgt = e.target; + if (tgt && (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA' || tgt.isContentEditable)) return; + var i8 = curIdx(); + if (i8 >= 0) { e.preventDefault(); E.deleteSelected(i8); } + return; + } + if (k === 'escape' && menu) { _blockMenuClose(); return; } + }); document.addEventListener('keydown', function (e) { if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return; if (String(e.key).toLowerCase() !== 's') return; diff --git a/tests/test_v7593_turn_into.py b/tests/test_v7593_turn_into.py index 389993d..1680867 100644 --- a/tests/test_v7593_turn_into.py +++ b/tests/test_v7593_turn_into.py @@ -26,8 +26,8 @@ def test_turn_into_guard_matches_objects_not_strings(): def test_turn_into_menu_wiring(): # menu ⋮⋮ → « Turn into » → sous-menu → choix de type - assert 'data-bm-act="turn"' in SRC - assert "data-bm-act=\\\"turn-type\\\"" in SRC or 'data-bm-act="turn-type"' in SRC + assert "_blockMenuItem('submenu','↩','Turn into',{sub:'turn'})" in SRC + assert "'turn-type'" in SRC # construit par _blockMenuItem assert "act==='turn-type'" in SRC assert "_blockTurnMenuOpen(" in SRC @@ -35,7 +35,7 @@ def test_turn_into_menu_wiring(): def test_turn_into_keeps_text_for_to_do_and_toggle(): # Convertir un texte en To-do / Toggle ne doit plus effacer le contenu. line = [ln for ln in SRC.splitlines() - if ln.strip().startswith("turnInto(idx,type){")][0] + if ln.strip().startswith("turnInto(idx,type")][0] assert "type!=='to_do'&&type!=='toggle'" not in line assert "if(keep&&['image','embed','meeting','divider'" in line @@ -47,7 +47,7 @@ def test_turn_into_converts_blocks_for_real(): if not node: pytest.skip("node absent : test comportemental turnInto ignoré") types = _slice("const _BLOCK_TURN_TYPES=[", "];") - method = _slice("turnInto(idx,type){", "setBlockColor(idx,key,val){") + method = _slice("turnInto(idx,type", "setBlockColor(idx,key,val){") script = f""" function _rtSync(){{}} function _selClear(){{}} diff --git a/tests/test_v761_block_menu.py b/tests/test_v761_block_menu.py new file mode 100644 index 0000000..70519aa --- /dev/null +++ b/tests/test_v761_block_menu.py @@ -0,0 +1,169 @@ +"""7.61.0 — menu contextuel de bloc refondu façon Notion (actions + raccourcis).""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +SRC = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8") +CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8") + + +def _slice(start: str, end: str) -> str: + i = SRC.index(start) + j = SRC.index(end, i) + return SRC[i:j].rstrip() + + +# ── Panneau de gauche ─────────────────────────────────────────────────────── + +def test_left_panel_matches_spec(): + # recherche, groupe « Text », les 10 actions dans l'ordre, pied métadonnées + assert 'placeholder="Search actions..."' in SRC + assert "'
Text
'" in SRC + order = ["'↩','Turn into'", "'🎨','Color'", "'🔗','Copy link to block'", + "'▣','Duplicate'", "'↪','Move to…'", "'✕','Delete'", + "'💬','Comment'", "'✎','Suggest edits'", "'✨','Ask AI'", + "'📚','Skills'"] + chunk = SRC[src_index("d.innerHTML=' int: + return SRC.index(needle) + + +def test_actions_are_all_dispatched(): + dispatch = _slice("function _blockMenuClick(", "\n function _blockMenuItem(") + for act in ("submenu", "duplicate", "copy-link", "move-to", "delete", + "comment", "suggest", "ai", "skill", "color", "turn-type"): + assert f"act==='{act}'" in dispatch, act + # méthodes réelles côté éditeur + for m in ("commentBlock(idx)", "askAI(idx)", "suggestEdit(idx)", + "runSkill(idx,tpl)", "applyLastColor(idx)", + "copyBlockLink(bid)", "deleteSelected(idx)", "duplicateBlock("): + assert m in SRC, m + # menu : pas d'entrée « copier/couper/coller les blocs » (raccourcis conservés) + assert "act==='copy-blocks'" not in dispatch + + +# ── Sous-menus ────────────────────────────────────────────────────────────── + +def test_turn_into_submenu(): + turn = _slice("function _blockTurnMenuOpen(", "\n function _blockColorMenuOpen(") + assert "_BLOCK_TURN_MENU" in turn and "check:on" in turn # ✓ du type actuel + assert "'✓'" in SRC + for name in ("Text", "Heading 1", "Heading 4", "Bulleted list", + "Numbered list", "To-do list", "Toggle list", "Code", "Quote", + "Callout", "Block equation", "Synced block", + "2 columns", "5 columns"): + assert f"'{name}'" in SRC, name + assert "Create '+t.cols+' columns of blocks" in SRC # info-bulle + assert "data-cols" in SRC and "cols>=2" in SRC # 2..5 colonnes + # Page / Page in / Toggle heading : types inexistants dans FlowDeck → absents + assert "'Page in'" not in SRC and "'Toggle heading 1'" not in SRC + + +def test_color_submenu(): + color = _slice("function _blockColorMenuOpen(", "\n function _blockSkillsMenuOpen(") + for group in ("Last used", "Text color", "Background color"): + assert f">{group}" in color, group + names = ["'Default'", "'Gray'", "'Brown'", "'Orange'", "'Yellow'", + "'Green'", "'Blue'", "'Purple'", "'Pink'", "'Red'"] + arr = _slice("const _BLOCK_COLOR_NAMES=", "];") + for n in names: + assert n in arr, n + assert "bm-dot" in color # carré de couleur (fond) + assert "_BM_SHORTCUTS.color" in color # raccourci « Last used » + assert "fdLastBlockColor" in SRC # mémoire de la dernière couleur + + +def test_skills_submenu_uses_real_skills(): + skills = _slice("function _blockSkillsMenuOpen(", "\n function _rtMac(") + assert "fetch('/api/agent/skills'" in skills + assert "act==='skill'" in SRC or "'skill'" in skills + assert "prompt_template" in skills + assert "_aiWritingRun('write',idx" in SRC # exécution réelle via l'IA + + +# ── Survol / fermeture ────────────────────────────────────────────────────── + +def test_hover_submenus_and_close(): + for token in ("_blockSubOpen(kind,bid,anchor)", "data-sub", + "closest('[data-sub]')", "mouseleave", "_blockSubCloseLater", + "function _blockMenuClose()", "_blockDocDown"): + assert token in SRC, token + assert "si.focus()" in SRC # recherche focalisée à l'ouverture + + +# ── Raccourcis ────────────────────────────────────────────────────────────── + +def test_shortcuts_implemented(): + keys = _slice("// ── v7.61 : raccourcis du menu contextuel de bloc ──", + "document.addEventListener('keydown', function (e) {\n if (!(e.ctrlKey || e.metaKey)") + combos = { + "k === 'j'": "Ctrl+J (Ask AI)", + "k === 'h'": "Ctrl+⇧+H (dernière couleur)", + "k === 'm'": "Ctrl+⇧+M (commenter)", + "k === 'x'": "Ctrl+⇧+Alt+X (suggest edits)", + "k === 'l'": "Alt+⇧+L (copier le lien)", + "k === 'p'": "Ctrl+⇧+P (déplacer vers…)", + "k === 'd'": "Ctrl+D (dupliquer)", + "k === 'delete'": "Del (supprimer, menu ouvert uniquement)", + "k === 'escape'": "Échap (fermer)", + } + for token, label in combos.items(): + assert token in keys, label + # Del n'agit jamais pendant la saisie + assert "tgt.isContentEditable" in keys + # le libellé affiché dans le menu correspond aux combinaisons réelles + shortcuts = _slice("const _BM_SHORTCUTS=", ";") + for hint in ("Alt+⇧+L", "Ctrl+D", "Ctrl+⇧+P", "Del", "Ctrl+⇧+M", + "Ctrl+⇧+Alt+X", "Ctrl+J", "Ctrl+⇧+H"): + assert hint in shortcuts, hint + + +# ── Comportement réel : conversion en colonnes (node) ─────────────────────── + +def test_turn_into_columns_creates_children_for_real(): + node = shutil.which("node") + if not node: + pytest.skip("node absent : test comportemental ignoré") + types = _slice("const _BLOCK_TURN_TYPES=[", "];") + method = _slice("turnInto(idx,type", "setBlockColor(idx,key,val){") + script = f""" +function _rtSync(){{}} +function _selClear(){{}} +var _n=0; function genId(){{return 'g'+(++_n);}} +{types}]; +const E = {{ + blocks:[{{id:'b1',type:'paragraph',content:'Titre'}}], + _blockTargets(i){{return [i];}}, sync(){{}}, pushHistory(){{}}, dirty:false, + autoSave(){{}}, render(){{}}, getEl(){{return null;}}, _pickSyncedBlock(){{}}, + {method} +}}; +E.turnInto(0,'columns',3); +console.log(JSON.stringify({{type:E.blocks[0].type, kids:E.blocks[0].children.length}})); +""" + out = subprocess.run([node, "-e", script], capture_output=True, text=True, + timeout=30, encoding="utf-8") + assert out.returncode == 0, out.stderr + assert json.loads(out.stdout) == {"type": "columns", "kids": 3} + + +# ── Styles ────────────────────────────────────────────────────────────────── + +def test_menu_styles(): + for sel in (".block-menu .bm-group {", ".block-menu .bm-foot {", + ".block-menu .bm-check {", ".block-menu .bm-dot {", + ".block-menu-sub { min-width: 230px; max-height: 60vh;"): + assert sel in CSS, sel