fix: sélection multi-blocs fonctionnelle (Ctrl+C/X, drag souris, clic droit) + identité visuelle (v7.69.0)
Trois bugs d'éditeur, chacun avec sa cause racine prouvée par trace
d'événements — le mécanisme Clipboard livré en v7.68 était inutilisable.
1) Ctrl+C / Ctrl+X multi-blocs ne faisaient RIEN.
Cause : le navigateur ne sait pas sérialiser une sélection qui traverse
deux `contenteditable` (chaque bloc en a un). `getSelection().toString()`
renvoie `""` et Chromium n'émet AUCUN événement `copy` — les handlers
`ct.addEventListener('copy')` n'étaient donc jamais atteints.
→ le raccourci est intercepté au keydown (capture sur document, garde
`closest('#_blocksCt')` pour ne pas voler Ctrl+C ailleurs) et le markdown
est écrit dans le presse-papier par nos soins. Sélection mono-bloc =
comportement natif inchangé.
2) Glisser la souris d'un bloc à l'autre ne sélectionnait qu'un bloc.
Cause : `mouseup` (on reconstruit la plage) → `click` → `focusin` → le
navigateur efface la sélection. La plage était reconstruite puis détruite
dans la foulée.
→ la plage reconstruite est mise en attente (`_mdSelRange`) et restaurée
au tour de boucle suivant si le focus l'a effectivement effacée. Chaque
nouveau geste (mousedown) remet l'attente à zéro, donc un clic pour
placer le curseur reste normal.
3) Le clic droit effaçait le surlignage.
Cause : `.bm-search` prenait le focus 10 ms après l'ouverture du menu.
→ le menu ne vole plus le focus quand un texte est sélectionné. Les
actions Clipboard utilisaient déjà la sélection figée à l'ouverture
(`d._clipSt`) ; le `preventDefault` du clic droit est remonté sur
`mousedown` (sur `contextmenu` il arrive trop tard).
Identité visuelle (bannière + logo du projet ajoutés à la racine) :
- assets optimisés servis : `static/img/banner.webp` 22,5 Ko (l'original
fait 1,6 Mo) + JPEG de secours, `logo.webp` 5,3 Ko + `logo-512.jpg` ;
originaux conservés dans `assets/` pour régénération.
- icônes PWA régénérées à partir du vrai logo (72 → 512 px, marge
maskable sur 192/512) + apple-touch-icon.
- landing : logo dans la barre de navigation, bannière en <picture>
(WebP + JPEG) avec dimensions déclarées (pas de décalage de mise en page).
- `og:image`, `twitter:card` et `meta description` ajoutés sur la landing
ET dans base.html — la landing a son propre <head>.
Tests : `tests/test_v769_selection_persistence.py` (19 statiques) +
`e2e/v769_selection_persistence.spec.js` (5, vrais gestes : drag souris,
Ctrl+C/Ctrl+X au clavier, clic droit). `e2e/v768_columns_clipboard.spec.js`
corrigé : les gestes simulés (`execCommand`, plage fabriquée en evaluate)
sont remplacés par de vrais gestes, et le token statique obsolète
`si>=ei)return null;` aligné sur la garde `isCollapsed` réelle.
pytest 1413 passed / 0 failed · ruff OK · OpenAPI 526 chemins / 7.69.0
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 999 B After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 624 B After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 767 B After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
@@ -163,6 +163,15 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
}
|
||||
return t;
|
||||
}
|
||||
// v7.69 : position de caret à des coordonnées — la sélection native ne
|
||||
// franchit pas les blocs (chacun a son contenteditable), on la rebâtit.
|
||||
function _caretAt(x,y){
|
||||
if(document.caretPositionFromPoint){const q=document.caretPositionFromPoint(x,y);
|
||||
if(q&&q.offsetNode){const r=document.createRange();r.setStart(q.offsetNode,q.offset||0);r.collapse(true);return r;}}
|
||||
if(document.caretRangeFromPoint)return document.caretRangeFromPoint(x,y);
|
||||
return null;
|
||||
}
|
||||
function _blkOf(n){let e=n&&(n.nodeType===1?n:n.parentElement);return e&&e.closest?e.closest('[data-bid]'):null;}
|
||||
function _emojiHtml(v){
|
||||
const s=String(v||'');
|
||||
if(/^(https?:|\/)/.test(s))return '<img src="'+esc(s)+'" alt="">';
|
||||
@@ -1121,6 +1130,8 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
|
||||
// ═══════════ v5.10.0 — Interactions de bloc : sélection multiple, menu ⋮⋮, undo/redo ═══════════
|
||||
let _selSet=new Set();
|
||||
var _mdSelDown=null; // v7.69 : point de départ du glisser de sélection
|
||||
var _mdSelRange=null; // v7.69 : plage reconstruite, à restaurer après le collapse du focus
|
||||
function _selHas(bid){return _selSet.has(bid);}
|
||||
function _selToggle(bid){if(!bid)return;if(_selSet.has(bid))_selSet.delete(bid);else _selSet.add(bid);
|
||||
document.querySelectorAll('.block-wrapper[data-id="'+bid+'"]').forEach(function(w){w.classList.toggle('selected',_selSet.has(bid));});}
|
||||
@@ -1294,7 +1305,13 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
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);
|
||||
// v7.69 : on ne vole PAS le focus quand un texte est sélectionné — sinon
|
||||
// le navigateur efface le surlignage dès que le menu apparaît (les
|
||||
// actions Clipboard gardent de toute façon la sélection figée ci-dessus).
|
||||
var si=d.querySelector('.bm-search');
|
||||
var sLive=window.getSelection();
|
||||
var hasSel=sLive&&sLive.rangeCount&&!sLive.isCollapsed;
|
||||
if(si&&!hasSel)setTimeout(function(){si.focus();},10);
|
||||
}
|
||||
// Panneau de droite : coquille commune des 3 sous-menus
|
||||
function _blockSubShell(x,y){
|
||||
@@ -1503,8 +1520,51 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
blockMenuOpen(e.clientX,e.clientY,bid);});
|
||||
// v7.68 : Ctrl+C / Ctrl+X sur une sélection multi-blocs → markdown.
|
||||
// Sous un bloc unique, on laisse le navigateur (copie native).
|
||||
ct.addEventListener('copy',function(ev){const E=window.E;if(!E||!E.clipCopy)return;const md=E.clipCopy();if(md==null)return;ev.preventDefault();ev.clipboardData.setData('text/plain',md);E.showToast('Sélection copiée');});
|
||||
ct.addEventListener('cut',function(ev){const E=window.E;if(!E||!E.clipCut)return;const md=E.clipCut();if(md==null)return;ev.preventDefault();ev.clipboardData.setData('text/plain',md);E.showToast('Sélection coupée');});
|
||||
ct.addEventListener('copy',function(ev){const E=window.E;if(!E||!E._clipState)return;const st=E._clipState();if(!st||st.si>=st.ei)return;const md=E.clipCopy(st);if(md==null)return;ev.preventDefault();ev.clipboardData.setData('text/plain',md);E.showToast('Sélection copiée');});
|
||||
ct.addEventListener('cut',function(ev){const E=window.E;if(!E||!E._clipState)return;const st=E._clipState();if(!st||st.si>=st.ei)return;const md=E.clipCut(st);if(md==null)return;ev.preventDefault();ev.clipboardData.setData('text/plain',md);E.showToast('Sélection coupée');});
|
||||
// v7.69 : Ctrl+C/X sur une sélection MULTI-BLOCS. Le navigateur ne
|
||||
// sait pas sérialiser une sélection qui traverse deux contenteditable
|
||||
// : il n'émet AUCUN événement copy et le raccourci ne fait rien. On
|
||||
// prend donc le raccourci à la main. (Un menu contextuel ne peut pas
|
||||
// faire confiance à getSelection(), d'où d._clipSt figé à l'ouverture.)
|
||||
document.addEventListener('keydown',function(ev){
|
||||
if(!(ev.ctrlKey||ev.metaKey))return;
|
||||
const k=(ev.key||'').toLowerCase();
|
||||
if(k!=='c'&&k!=='x')return;
|
||||
if(!ev.target||!ev.target.closest||!ev.target.closest('#_blocksCt'))return; // pas dans la recherche d'un menu
|
||||
const E=window.E;if(!E||!E._clipState)return;
|
||||
const st=E._clipState();if(!st||st.si>=st.ei)return; // sélection mono-bloc → natif
|
||||
const md=(k==='x'?E.clipCut(st):E.clipCopy(st));
|
||||
if(md==null)return;
|
||||
ev.preventDefault();ev.stopPropagation();
|
||||
const done=()=>E.showToast(k==='x'?'Sélection coupée':'Sélection copiée');
|
||||
if(navigator.clipboard&&navigator.clipboard.writeText)navigator.clipboard.writeText(md).then(done).catch(()=>{});else done();
|
||||
},true);
|
||||
// v7.69 : glisser de sélection entre blocs + clic droit préservant la sélection
|
||||
ct.addEventListener('mousedown',function(ev){
|
||||
_mdSelRange=null; // un nouveau geste remet à zéro la sélection en attente
|
||||
if(ev.button===2){const s=window.getSelection();if(s&&s.rangeCount&&!s.isCollapsed)ev.preventDefault();return;}
|
||||
if(ev.button===0)_mdSelDown={x:ev.clientX,y:ev.clientY};
|
||||
},true);
|
||||
// en CAPTURE : SortableJS (initié sur ce même conteneur) peut
|
||||
// stopPropagation en fin de glisser → le bubble n'atteint plus document
|
||||
document.addEventListener('mouseup',function(ev){
|
||||
const d=_mdSelDown;_mdSelDown=null;
|
||||
if(!d||ev.button!==0)return;
|
||||
if(Math.abs(ev.clientX-d.x)<4&&Math.abs(ev.clientY-d.y)<4)return;
|
||||
const E=window.E;if(E&&E._buildSelAt)E._buildSelAt(d.x,d.y,ev.clientX,ev.clientY);
|
||||
},true);
|
||||
// v7.69 : le « click » d'un contenteditable efface toute sélection
|
||||
// existante (focusin → collapse). On la remet juste après.
|
||||
document.addEventListener('focusin',function(){
|
||||
if(!_mdSelRange)return;
|
||||
var r=_mdSelRange;_mdSelRange=null;
|
||||
setTimeout(function(){
|
||||
var s=window.getSelection();
|
||||
if(s&&s.rangeCount&&!s.isCollapsed)return; // survécue : rien à faire
|
||||
try{s.removeAllRanges();s.addRange(r);}catch(e){}
|
||||
},0);
|
||||
},true);
|
||||
document.addEventListener('mousedown',_blockDocDown);
|
||||
if(window.location.hash&&window.location.hash.indexOf('#fdblk-')===0){const hb=window.location.hash.slice(7);setTimeout(()=>{const selEl=document.querySelector(`[data-bid="${hb}"]`);if(selEl&&selEl.scrollIntoView)selEl.scrollIntoView({behavior:'smooth',block:'center'});if(selEl){selEl.focus();ce(selEl);}},500);}
|
||||
},100);
|
||||
@@ -2002,6 +2062,45 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
copySelectedBlocks(){const indices=_selIndicesSorted();if(!indices.length)return;const selected=indices.map(i=>this.blocks[i]).filter(Boolean);if(!selected.length)return;const md=this._blocksToMarkdown(selected);if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(md).then(()=>this.showToast('Blocs copiés')).catch(()=>this.showToast('Copie impossible','error'));}else{this.showToast(md,'error');}},
|
||||
cutSelectedBlocks(){const indices=_selIndicesSorted();if(!indices.length)return;this.sync();this.pushHistory();const selected=indices.map(i=>this.blocks[i]).filter(Boolean);if(!selected.length)return;const md=this._blocksToMarkdown(selected);if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(md).then(()=>{});}this.blocks=this.blocks.filter((b,i)=>indices.indexOf(i)<0);if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();this.showToast('Blocs coupés');},
|
||||
async pasteBlocks(){let idx=this._focusedIdx();if(idx<0)idx=this.blocks.length-1;if(idx<0)return;try{const text=await navigator.clipboard.readText();if(!text||!text.trim())return;const parsed=this.md2b(text);if(!parsed.length)return;this.sync();this.pushHistory();this.blocks.splice(idx+1,0,...parsed);this.dirty=true;this.autoSave();this.render();_rtSync();this._focusBid(parsed[parsed.length-1].id);_selClear();}catch(e){if(e.name==='NotAllowedError'){this.showToast('Permission presse-papier refusée');}else{this.showToast('Coller impossible','error');}}},
|
||||
// v7.69 : glisser la souris de bloc A vers bloc B — le navigateur ne
|
||||
// sélectionne jamais à travers deux contenteditable, on reconstruit la
|
||||
// plage au relâchement avec les carets sous le pointeur.
|
||||
_buildSelAt(x0,y0,x1,y1){
|
||||
const r0=_caretAt(x0,y0),r1=_caretAt(x1,y1);
|
||||
if(!r0||!r1)return;
|
||||
const b0=_blkOf(r0.startContainer),b1=_blkOf(r1.startContainer);
|
||||
if(!b0||!b1||b0===b1)return; // même bloc → la sélection native suffit
|
||||
const a=document.createRange();
|
||||
if(r0.compareBoundaryPoints(Range.START_TO_START,r1)<=0){
|
||||
a.setStart(r0.startContainer,r0.startOffset);a.setEnd(r1.startContainer,r1.startOffset);
|
||||
}else{
|
||||
a.setStart(r1.startContainer,r1.startOffset);a.setEnd(r0.startContainer,r0.startOffset);
|
||||
}
|
||||
if(a.collapsed)return;
|
||||
const s=window.getSelection();s.removeAllRanges();s.addRange(a);
|
||||
_mdSelRange=a; // le « click » suivant va effacer ça : on garde la copie
|
||||
},
|
||||
// v7.69 : Maj+↓ / Maj+↑ au début/fin d'un bloc → la sélection saute
|
||||
// dans le bloc voisin (extension native impossible : blocs séparés).
|
||||
_shiftBlockExtend(dir){
|
||||
// le bloc de départ est celui du BOUT MOBILE de la sélection (le
|
||||
// focus), pas l'élément focalisé : après une première extension le
|
||||
// caret vit déjà dans le bloc voisin.
|
||||
const s=window.getSelection();if(!s||!s.rangeCount)return false;
|
||||
const fn=s.focusNode;if(!fn)return false;
|
||||
const sbl=_blkOf(fn);if(!sbl)return false;
|
||||
const si=this.getIdx(sbl.dataset.bid);if(si<0)return false;
|
||||
const off=_srcOffset(sbl,fn,s.focusOffset),len=_srcNodeLen(sbl);
|
||||
if(dir>0&&off<len)return false; // le navigateur étend encore dans le bloc
|
||||
if(dir<0&&off>0)return false;
|
||||
const nb=this.blocks[si+dir];if(!nb)return false;
|
||||
const nel=this.getEl(nb.id);if(!nel)return false;
|
||||
const r=s.getRangeAt(0).cloneRange();
|
||||
if(dir>0)r.setEnd(nel,nel.childNodes.length);else r.setStart(nel,0);
|
||||
if(r.collapsed)return false;
|
||||
s.removeAllRanges();s.addRange(r);
|
||||
return true;
|
||||
},
|
||||
// ── v7.68 : copier/couper une SÉLECTION DE TEXTE MULTI-BLOCS ──
|
||||
// Sélection vivante qui traverse ≥2 blocs → plage ; sinon null (le
|
||||
// navigateur gère tout seul la sélection à l'intérieur d'un bloc).
|
||||
@@ -2015,7 +2114,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
const eblk=ee&&ee.closest?ee.closest('[data-bid]'):null;
|
||||
if(!sblk||!eblk)return null;
|
||||
const si=this.getIdx(sblk.dataset.bid), ei=this.getIdx(eblk.dataset.bid);
|
||||
if(si<0||ei<0||si>=ei)return null;
|
||||
if(si<0||ei<0||si>ei)return null;
|
||||
return {si:si,ei:ei,
|
||||
startOff:_srcOffset(sblk,r.startContainer,r.startOffset),
|
||||
endOff:_srcOffset(eblk,r.endContainer,r.endOffset)};
|
||||
@@ -2043,6 +2142,13 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
st=st||this._clipState();if(!st)return null;
|
||||
this.sync();this.pushHistory();
|
||||
const md=this._clipMarkdown(st);
|
||||
if(st.si===st.ei){
|
||||
const b=this.blocks[st.si],bel=this.getEl(b.id);
|
||||
b.content=this._anchorText(b.id,0,st.startOff)+this._anchorText(b.id,st.endOff,bel?_srcNodeLen(bel):0);
|
||||
this.dirty=true;this.autoSave();this.render();_rtSync();
|
||||
this._focusBid(b.id);
|
||||
return md;
|
||||
}
|
||||
const first=this.blocks[st.si],last=this.blocks[st.ei];
|
||||
const lastEl=this.getEl(last.id);
|
||||
if(!this._clipHeavy(first))first.content=this._anchorText(first.id,0,st.startOff);
|
||||
@@ -2510,6 +2616,11 @@ applyAIBlocks(text){
|
||||
}
|
||||
return;
|
||||
}
|
||||
// v7.69 : Maj+↓ / Maj+↑ franchit le bloc (les blocs sont des
|
||||
// contenteditable distincts : le navigateur ne peut pas étendre)
|
||||
if(e.shiftKey&&(e.key==='ArrowDown'||e.key==='ArrowUp')){
|
||||
if(this._shiftBlockExtend(e.key==='ArrowDown'?1:-1)){e.preventDefault();return;}
|
||||
}
|
||||
if(window.AIAC&&window.AIAC.active){
|
||||
if(e.key==='Tab'){e.preventDefault();e.stopPropagation();window.AIAC.accept();return;}
|
||||
if(e.key==='Escape'){e.preventDefault();window.AIAC.clear();return;}
|
||||
|
||||