feat: menu contextuel de bloc refondu façon Notion — 10 actions, sous-menus et raccourcis (v7.61.0)
- 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
This commit is contained in:
@@ -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
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "FlowDeck",
|
||||
"version": "7.60.0"
|
||||
"version": "7.61.0"
|
||||
},
|
||||
"paths": {
|
||||
"/auth/register": {
|
||||
|
||||
+25
-1
@@ -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; }
|
||||
|
||||
@@ -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,'"').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?'<span class="bm-check">✓</span>'
|
||||
:(opts.sub?'<span class="bm-arrow">▸</span>'
|
||||
:(opts.shortcut?'<span class="bm-hint">'+opts.shortcut+'</span>':''));
|
||||
return '<div class="bm-item'+(opts.danger?' bm-danger':'')+(opts.current?' bm-current':'')+'"'+attr+'>'
|
||||
+(icon!=null?'<span class="bm-ico">'+icon+'</span>':'')+label+right+'</div>';
|
||||
}
|
||||
// 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 '<div class="bm-foot"><div>Last edited by '+esc(who||'you')+'</div>'
|
||||
+(when?'<div>'+esc(when)+'</div>':'')
|
||||
+'<div>'+words+' word'+(words===1?'':'s')+', '+content.length+' character'+(content.length===1?'':'s')+'</div></div>';
|
||||
}
|
||||
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+='<button type="button" data-bm-act="color" data-color-key="color" data-color-val="'+c+'" title="Text color" style="background:'+(c==='default'?'transparent':c)+'" class="bm-swatch'+(c==='default'?' bm-default':'')+'">'+(c==='default'?'A':'')+'</button>';});
|
||||
_BLOCK_COLORS_BG.forEach(function(c){colorsBg+='<button type="button" data-bm-act="color" data-color-key="bgColor" data-color-val="'+c+'" title="Background color" style="background:'+(c==='default'?'transparent':c)+'" class="bm-swatch'+(c==='default'?' bm-default':'')+'"></button>';});
|
||||
d.innerHTML='<div class="bm-item" data-bm-act="turn" data-bid="'+b.id+'"><span class="bm-ico">↩</span>Turn into<span class="bm-arrow">▸</span></div>'
|
||||
+'<div class="bm-sep"></div>'
|
||||
+'<div class="bm-item" data-bm-act="duplicate" data-bid="'+b.id+'"><span class="bm-ico">▣</span>Duplicate <span class="bm-hint">'+(_rtMac()?'⌘D':'Ctrl+D')+'</span></div>'
|
||||
+'<div class="bm-item" data-bm-act="copy-link" data-bid="'+b.id+'"><span class="bm-ico">🔗</span>Copy link to block</div>'
|
||||
+'<div class="bm-sep"></div>'
|
||||
+'<div class="bm-item" data-bm-act="copy-blocks" data-bid="'+b.id+'"><span class="bm-ico">⎘</span>Copy blocks <span class="bm-hint">'+(_rtMac()?'⌘C':'Ctrl+C')+'</span></div>'
|
||||
+'<div class="bm-item" data-bm-act="cut-blocks" data-bid="'+b.id+'"><span class="bm-ico">✂</span>Cut blocks <span class="bm-hint">'+(_rtMac()?'⌘X':'Ctrl+X')+'</span></div>'
|
||||
+'<div class="bm-item" data-bm-act="paste-blocks" data-bid="'+b.id+'"><span class="bm-ico">⎘</span>Paste blocks <span class="bm-hint">'+(_rtMac()?'⌘V':'Ctrl+V')+'</span></div>'
|
||||
+'<div class="bm-sep"></div>'
|
||||
+'<div class="bm-item" data-bm-act="move-to" data-bid="'+b.id+'"><span class="bm-ico">↪</span>Move to…</div>'
|
||||
+'<div class="bm-sep"></div>'
|
||||
+'<div class="bm-colors"><div class="bm-colors-label">Text</div><div class="bm-swatches">'+colorsText+'</div></div>'
|
||||
+'<div class="bm-colors"><div class="bm-colors-label">Background</div><div class="bm-swatches">'+colorsBg+'</div></div>'
|
||||
+'<div class="bm-sep"></div>'
|
||||
+'<div class="bm-item bm-danger" data-bm-act="delete" data-bid="'+b.id+'"><span class="bm-ico">✕</span>Delete <span class="bm-hint">⌫</span></div>';
|
||||
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='<input class="bm-search" type="text" placeholder="Search actions..." autocomplete="off" spellcheck="false">'
|
||||
+'<div class="bm-group">Text</div>'
|
||||
+_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+='<div class="bm-item" data-bm-act="turn-type" data-type="'+t.id+'" data-bid="'+bid+'">'+t.name+'</div>';});
|
||||
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+='<div class="bm-group">Last used</div>'
|
||||
+_blockMenuItem('color','<span style="color:'+(lv==='default'?'var(--text-secondary)':lv)+'">A</span>',
|
||||
nm,{bid:bid,colorKey:lk,colorVal:lv,shortcut:_BM_SHORTCUTS.color});
|
||||
}
|
||||
html+='<div class="bm-group">Text color</div>';
|
||||
_BLOCK_COLORS_TEXT.forEach(function(v,n){
|
||||
html+=_blockMenuItem('color','<span style="color:'+(v==='default'?'var(--text-secondary)':v)+'">A</span>',
|
||||
_BLOCK_COLOR_NAMES[n]+' text',{bid:bid,colorKey:'color',colorVal:v,
|
||||
check:v===curT,current:v===curT});
|
||||
});
|
||||
html+='<div class="bm-group">Background color</div>';
|
||||
_BLOCK_COLORS_BG.forEach(function(v,n){
|
||||
html+=_blockMenuItem('color','<span class="bm-dot" style="background:'+(v==='default'?'var(--text-tertiary)':v)+'"></span>',
|
||||
_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='<div class="bm-empty">Chargement…</div>';
|
||||
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='<div class="bm-empty">Aucune compétence enregistrée — créez-en une dans le panneau Agent.</div>';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='<div class="bm-empty">Compétences indisponibles.</div>';});
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -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(){{}}
|
||||
|
||||
@@ -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 "'<div class=\"bm-group\">Text</div>'" 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='<input"):src_index("+_blockMenuMeta(b);")]
|
||||
positions = [chunk.index(o) for o in order]
|
||||
assert positions == sorted(positions), positions
|
||||
# pied de page : auteur / heure / taille
|
||||
meta = _slice("function _blockMenuMeta(", "\n function blockMenuOpen(")
|
||||
for token in ("Last edited by ", "word", "character", "Today at ",
|
||||
".um-name", "updatedAt"):
|
||||
assert token in meta, token
|
||||
|
||||
|
||||
def src_index(needle: str) -> 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 "'<span class=\"bm-check\">✓</span>'" 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}</div>" 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
|
||||
Reference in New Issue
Block a user