diff --git a/CHANGELOG.md b/CHANGELOG.md index ad420cd..ad63555 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,60 @@ # Changelog - FlowDeck +## v7.68.0 (2026-10-08) — Colonnes rendues, tables IA, copier/couper/coller multi-blocs + +### Fixed + +- **Colonnes / équation / TOC n'étaient JAMAIS rendus** : `renderBlock()` + n'appelait pas `renderColumnsBlock()` / `renderMathBlock()` / `renderTOC()` + (3 renderers morts depuis l'extraction v7.22) → un bloc colonnes — créé par + le slash « Column list », par **Turn into → 2/3/4/5 colonnes** — tombait dans + le rendu générique : un paragraphe vide. Dispatch ajouté. + - la **× de suppression de colonne** était en `opacity:0` **sans règle de + survol** (invisible mais cliquable) → visible au survol du bloc colonne. +- **Tables rendues par l'IA** : `_tblIsData`/`_tblIsSep` exigeaient un `|` de + tête → le GFM sans pipe (`A | B` / `--- | ---`, ce que produit souvent le + modèle) devenait des paragraphes. Le pipe de tête est **optionnel** en GFM ; + garde conservée : une ligne de séparation doit faire ≥2 cellules (un « texte » + suivi d'un « --- » reste un séparateur de section). `md2b` et `paste2b` + partagent les helpers → alignés d'office. +- **Coller un SEUL bloc structuré** (table, code, titre) retombait en texte + brut (`if(parsed.length<=1) return fallback()`) → seul un bloc paragraphe + (une ligne) garde le comportement inline. +- **« Copy link to block »** du menu de bloc : `data-bm-act="link"` alors que + le handler attend `'copy-link'` → l'action ne faisait rien. + +### Added + +- **Copier / couper une sélection de texte MULTI-BLOCS** : + - `_clipState()` (sélection vivante traversant ≥2 blocs) + `_clipMarkdown()` + (début partiel → blocs entiers sérialisés par `blocksToMarkdown` → fin + partielle) ; `clipCopy()` / `clipCut()` (la coupure garde la tête du 1ᵉʳ + bloc et la queue du dernier, le milieu part) ; + - listeners **`copy` / `cut`** sur `#_blocksCt` : Ctrl+C / Ctrl+X passent par + les événements natifs du navigateur ; dans un seul bloc, on laisse le + navigateur faire (copie native) ; + - groupe **Clipboard** (`Cut Ctrl+X` / `Copy Ctrl+C` / `Paste Ctrl+V`) en + tête du menu du clic droit, avec la sélection **figée à l'ouverture** du + menu (la recherche prend le focus et détruit la sélection) ; + - clic droit **sur un bloc sélectionné** → la sélection multi-blocs (handle + + Maj-clic) est conservée et le Clipboard agit dessus (`copySelectedBlocks`) ; + - `pasteBlocks()` colle après le dernier bloc quand aucun bloc n'est focalisé. + +### Tests + +- `tests/test_v768_columns_clipboard.py` (4). +- **e2e** `e2e/v768_columns_clipboard.spec.js` (4) : slash « Column list » + + « Add column » + **Turn into → 2 colonnes** (bloc `.block-column` réellement + rendu), table IA **sans pipe de tête** (vrai `.ftable-editor` + garde + « texte »/« --- »), copier→`readText` → couper (blocs réduits à la tête + + queue) → coller (blocs revenus), clic droit → groupe Clipboard → Copy. + - le raccourci clavier n'est pas déclenché en headless Chromium (pas de + presse-papier OS) : le test passe par `document.execCommand('copy'/'cut')`, + qui émet les **mêmes** événements que Ctrl+C/X. +- `pytest tests/ -q -n auto` → **1394 passed, 0 failed**, `ruff check app tests` + → OK. + + ## v7.67.0 (2026-10-08) — Réactions dans le tiroir Comments (+ suppression) et refonte du menu de sélection ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 9e331d8..eee1a48 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1524,6 +1524,27 @@ Détails livrés : `pytest` **1390 passed / 0 failed**, `ruff` OK ; `VERSION` + `app/main.py` = 7.67.0 +### v7.68.0 — Colonnes rendues, tables IA, copier/couper/coller multi-blocs ✅ (2026-10-08) +> **Objectif** : les colonnes (slash + Turn into) marchent, l'IA crée de vrais +> tableaux, et une sélection multi-blocs se copie/coupe/colle. **COMPLETED**. + +- [x] **Renderers morts branchés** — `renderBlock()` n'appelait jamais + `renderColumnsBlock()` / `renderMathBlock()` / `renderTOC()` → bloc colonnes, + équation et TOC rendaient un paragraphe vide (bug rapporté) ; × de + suppression de colonne visible au survol (était invisible mais cliquable) +- [x] **Tables IA** — `_tblIsData`/`_tblIsSep` acceptent le GFM **sans pipe de + tête** (séparateur ≥2 cellules pour ne pas avaler « texte » + « --- ») ; + `md2b` et `paste2b` alignés d'office +- [x] **Coller** un seul bloc structuré (table/code/titre) = vraie insertion ; + « Copy link to block » réparé (`link` → `copy-link`) +- [x] **Copier/couper multi-blocs** — `_clipState()`/`_clipMarkdown()`/ + `clipCopy()`/`clipCut()`, listeners `copy`/`cut` (Ctrl+C/X), groupe + **Clipboard** dans le menu du clic droit (sélection figée à l'ouverture), + clic droit sur un bloc sélectionné = sélection multi-blocs conservée +- [x] **Tests** — `tests/test_v768_columns_clipboard.py` (4), e2e + `v768_columns_clipboard.spec.js` (4) ; `pytest` **1394 passed / 0 failed**, + `ruff` OK ; `VERSION` + `app/main.py` = 7.68.0 + --- ## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code) diff --git a/VERSION b/VERSION index 6a09420..97e05c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.67.0 +7.68.0 diff --git a/WORKLOAD.md b/WORKLOAD.md index 7fe61f4..c50b139 100644 --- a/WORKLOAD.md +++ b/WORKLOAD.md @@ -1,6 +1,6 @@ # WORKLOAD — FlowDeck Notion Clone -> **Début**: 2026-07-08 | **Version**: v7.67.0 (réactions dans le tiroir Comments + suppression, refonte du menu de sélection) | **Statut**: EN COURS 🔄 +> **Début**: 2026-07-08 | **Version**: v7.68.0 (colonnes rendues, tables IA sans pipe de tête, copier/couper/coller multi-blocs) | **Statut**: EN COURS 🔄 > **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0` ## Avancement Global diff --git a/app/main.py b/app/main.py index 458accb..36a1de1 100644 --- a/app/main.py +++ b/app/main.py @@ -186,7 +186,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.67.0", + version="7.68.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json index e888bec..b17f922 100644 --- a/docs/openapi-v2.json +++ b/docs/openapi-v2.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "FlowDeck", - "version": "7.67.0" + "version": "7.68.0" }, "paths": { "/auth/register": { diff --git a/e2e/v768_columns_clipboard.spec.js b/e2e/v768_columns_clipboard.spec.js new file mode 100644 index 0000000..e866003 --- /dev/null +++ b/e2e/v768_columns_clipboard.spec.js @@ -0,0 +1,162 @@ +const { test, expect } = require('@playwright/test'); + +/** + * v7.68 — colonnes rendues (slash + Turn into), tables IA sans pipe de tête, + * copier/couper/coller d'une sélection multi-blocs (Ctrl+C/X/V + clic droit). + * Instance de test attendue sur FD_BASE_URL (défaut 8081). + */ +const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081'; +const USER = process.env.FD_USER || 'e2e@flowdeck.local'; +const PASS = process.env.FD_PASS || 'e2e-secret-123'; + +test.use({ serviceWorkers: 'block', permissions: ['clipboard-read', 'clipboard-write'] }); + +async function login(page) { + await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' }); + await page.fill('#email', USER); + await page.fill('#password', PASS); + await page.click('.btn-primary'); + const ok = await page + .waitForURL('**/workspaces', { timeout: 8000 }) + .then(() => true) + .catch(() => false); + if (!ok) { + const resp = await page.request.post(`${FD_BASE}/auth/register`, { + data: { email: USER, password: PASS, name: 'E2E' }, + }); + if (resp.status() !== 409) expect(resp.ok()).toBeTruthy(); + await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' }); + } +} + +async function newPage(page, body) { + const cookies = await page.context().cookies(); + const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || ''; + const created = await page.request.post( + `${FD_BASE}/board/api/pages?title=v768 ${Date.now()}`, + { headers: { 'X-CSRF-Token': csrf } } + ); + expect(created.status(), await created.text()).toBeLessThan(400); + const { id } = await created.json(); + if (body) { + const bs = await page.request.post(`${FD_BASE}/board/api/pages/${id}/blocks`, { + headers: { 'X-CSRF-Token': csrf }, + data: { title: 'v768', blocks: body }, + }); + expect(bs.status(), await bs.text()).toBeLessThan(400); + } + await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 }); + return id; +} + +// sélection multi-blocs (du milieu du 1er au milieu du 3e) + fin de mouseup +async function selectAcross(page) { + await page.evaluate(() => { + const el1 = document.querySelector('#_blocksCt [data-bid]'); + const blocks = document.querySelectorAll('#_blocksCt [data-bid]'); + const el3 = blocks[blocks.length - 1]; + const r = document.createRange(); + r.setStart(el1.firstChild, 0); + r.setEnd(el3.firstChild, el3.firstChild.nodeValue.length); + const s = getSelection(); + s.removeAllRanges(); + s.addRange(r); + }); +} + +test('colonnes : slash + Turn into 2 colonnes', async ({ page }) => { + await login(page); + await newPage(page, [{ id: 'b1', type: 'paragraph', content: '' }]); + await page.click('#_blocksCt [data-bid]'); + await page.keyboard.type('/'); + await expect(page.locator('#_slashMenu')).toBeVisible({ timeout: 5000 }); + await page.keyboard.type('columns'); + await page.locator('#_slashMenu .slash-item[data-sid="columns"]').click(); + // le bloc colonnes est RENDU (il tombait dans le rendu générique = vide) + await expect(page.locator('#_blocksCt .block-columns')).toHaveCount(1); + await expect(page.locator('#_blocksCt .block-column')).toHaveCount(1); + await page.locator('#_blocksCt .columns-add-btn').click(); + await expect(page.locator('#_blocksCt .block-column')).toHaveCount(2); + await page.screenshot({ path: 'shots/v768-columns-slash.png' }); + + // Turn into → 2 colonnes + await newPage(page, [{ id: 'b1', type: 'paragraph', content: 'Pour le menu' }]); + await page.locator('#_blocksCt [data-bid]').first().click({ button: 'right' }); + const menu = page.locator('#blockMenu'); + await expect(menu).toBeVisible(); + await menu.locator('[data-sub="turn"]').hover(); + const sub = page.locator('#blockMenuSub'); + await expect(sub).toBeVisible(); + await sub.locator('[data-bm-act="turn-type"][data-cols="2"]').click(); + await expect(page.locator('#_blocksCt .block-column')).toHaveCount(2); +}); + +test('table IA sans pipe de tête devient un vrai tableau', async ({ page }) => { + await login(page); + await newPage(page, [{ id: 'b1', type: 'paragraph', content: '' }]); + await page.evaluate(() => { + window.E.applyAIBlocks('Nom | Rôle\n--- | ---\nAlice | Dev\nBob | Test'); + }); + await expect(page.locator('#_blocksCt .ftable-editor')).toHaveCount(1); + await expect(page.locator('#_blocksCt .ftable-cell').first()).toHaveText('Nom'); + await expect(page.locator('#_blocksCt .ftable-cell').last()).toHaveText('Test'); + // la ligne « texte » suivie d'« --- » ne doit PAS devenir un tableau + await page.evaluate(() => { + window.E.applyAIBlocks('juste du texte\n---\nune phrase'); + }); + await expect(page.locator('#_blocksCt .ftable-editor')).toHaveCount(1); + await page.screenshot({ path: 'shots/v768-ai-table.png' }); +}); + +test('Ctrl+C / Ctrl+X / Ctrl+V sur une sélection multi-blocs', async ({ page }) => { + await login(page); + await newPage(page, [ + { id: 'b1', type: 'paragraph', content: 'Premier bloc' }, + { id: 'b2', type: 'heading_2', content: 'Titre deux' }, + { id: 'b3', type: 'paragraph', content: 'Troisieme bloc' }, + ]); + // le raccourci clavier nécessite un éditeur focalisé (sinon Chromium + // n'émet pas l'événement copy) + await page.locator('#_blocksCt [data-bid]').first().click(); + await selectAcross(page); + // headless Chromium n'émet PAS le raccourci clavier (pas de presse-papier + // OS) : execCommand('copy') déclenche le MÊME événement copy → notre handler + await page.evaluate(() => document.execCommand('copy')); + const clip = await page.evaluate(() => navigator.clipboard.readText()); + expect(clip).toContain('Premier bloc'); + expect(clip).toContain('## Titre deux'); // le titre sort en markdown + + // coupe : les 3 blocs deviennent 2 blocs vides (tête + queue) + await selectAcross(page); + await page.evaluate(() => document.execCommand('cut')); + await expect(page.locator('#_blocksCt [data-bid]')).toHaveCount(2); + await expect(page.locator('#_blocksCt [data-bid]').first()).toHaveText(''); + + // colle : les 3 blocs reviennent APRÈS le bloc du curseur (comme l'entrée + // Paste du menu) → 2 blocs restants + 3 collés = 5 + await page.evaluate(() => window.E.pasteBlocks()); + await expect(page.locator('#_blocksCt [data-bid]')).toHaveCount(5, { timeout: 5000 }); + await expect(page.locator('#_blocksCt [data-bid]').nth(1)).toContainText('Premier bloc'); + await expect(page.locator('#_blocksCt [data-bid]').nth(2)).toContainText('Titre deux'); +}); + +test('clic droit avec une sélection → groupe Clipboard', async ({ page }) => { + await login(page); + await newPage(page, [ + { id: 'b1', type: 'paragraph', content: 'Alpha bloc' }, + { id: 'b2', type: 'paragraph', content: 'Beta bloc' }, + ]); + await selectAcross(page); + await page.locator('#_blocksCt [data-bid]').first().click({ button: 'right', position: { x: 4, y: 4 } }); + const menu = page.locator('#blockMenu'); + await expect(menu).toBeVisible(); + await expect(menu.locator('[data-bm-act="clip-copy"]')).toBeVisible(); + await expect(menu.locator('[data-bm-act="clip-cut"]')).toBeVisible(); + await expect(menu.locator('[data-bm-act="clip-paste"]')).toBeVisible(); + await page.screenshot({ path: 'shots/v768-ctx-clipboard.png' }); + await menu.locator('[data-bm-act="clip-copy"]').click(); + const clip = await page.evaluate(() => navigator.clipboard.readText()); + expect(clip).toContain('Alpha bloc'); + expect(clip).toContain('Beta bloc'); +}); diff --git a/static/css/app.css b/static/css/app.css index 0d2c1f5..bafe56a 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -2865,6 +2865,11 @@ button.breadcrumb-current { max-width: 260px; } border-radius: 50%; line-height: 1; } +/* v7.68 : la × de suppression de colonne était en opacity:0 SANS règle de + survol → invisible et pourtant cliquable (même piège que .block-actions) */ +.block-column-wrapper:hover .block-actions { + opacity: 1; +} .block-column { outline: none; min-height: 24px; diff --git a/static/js/page_editor_scripts.js b/static/js/page_editor_scripts.js index a46581a..a9f9b1b 100644 --- a/static/js/page_editor_scripts.js +++ b/static/js/page_editor_scripts.js @@ -236,8 +236,11 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON. /* ═══════════ Simple Table block (v4.8.0) ═══════════ */ function _tblSplitLine(s){s=(s||'').trim();if(s.charAt(0)==='|')s=s.slice(1);if(s.charAt(s.length-1)==='|')s=s.slice(0,-1);return s.split('|').map(function(x){return x.trim();});} - function _tblIsSep(s){s=(s||'').trim();if(!s||s.charAt(0)!=='|')return false;var cells=_tblSplitLine(s);if(!cells.length)return false;return cells.every(function(c){return /^:?-{1,}:?$/.test(c);});} - function _tblIsData(s){s=(s||'').trim();return /^\|/.test(s)&&s.indexOf('|',1)>=0;} + // v7.68 : le pipe de tête est OPTIONNEL en GFM — l'IA rend souvent + // « A | B » / « --- | --- ». Une ligne de séparation doit garder ≥2 cellules, + // sinon « texte » suivi d'un « --- » deviendrait un tableau. + function _tblIsSep(s){s=(s||'').trim();if(!s)return false;var cells=_tblSplitLine(s);if(cells.length<2)return false;return cells.every(function(c){return /^:?-{1,}:?$/.test(c);});} + function _tblIsData(s){s=(s||'').trim();return s.indexOf('|')>=0&&_tblSplitLine(s).length>=2;} function _tblNew(rows,cols){var out=[];for(var i=0;iw)index=w;b.rows.forEach(function(r){r.splice(index,0,'');});} @@ -744,6 +747,12 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON. if(b.type==='table')return renderTableBlock(b,idx); if(b.type==='divider')return `

`; if(b.type==='button')return renderAutomationButton(b,idx); + // v7.68 : ces trois renderers existaient mais n'étaient JAMAIS appelés — + // un bloc `columns`/`math`/`table_of_contents` tombait dans le rendu générique + // (un paragraphe vide). Colonnes (slash + Turn into) = bug rapporté. + if(b.type==='columns')return renderColumnsBlock(b); + if(b.type==='math')return renderMathBlock(b); + if(b.type==='table_of_contents')return renderTOC(b); if(b.type==='image')return `
${esc(b.alt||'')}
`; if(b.type==='video'){ const src=b.src||'';return src?`
`:`
🎬
Click to add video URL
`; @@ -1185,6 +1194,16 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON. else if(act==='suggest'){E.suggestEdit(idx);} else if(act==='ai'){E.askAIBlock(idx);} else if(act==='skill'){E.runSkill(idx,t.getAttribute('data-prompt')||'');} + // v7.68 : Clipboard du menu (sélection multi-blocs gérée par copySelectedBlocks) + else if(act==='clip-cut'){ + if(_selSet.size>1)E.cutSelectedBlocks(); + else{var mst=document.getElementById('blockMenu');const md=E.clipCut?E.clipCut(mst&&mst._clipSt):null;if(md!=null&&navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(md);E.showToast('Sélection coupée');}} + } + else if(act==='clip-copy'){ + if(_selSet.size>1)E.copySelectedBlocks(); + else{var mst2=document.getElementById('blockMenu');const md=E.clipCopy?E.clipCopy(mst2&&mst2._clipSt):null;if(md!=null&&navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(md);E.showToast('Sélection copiée');}} + } + else if(act==='clip-paste'){E.pasteBlocks();} 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'),parseInt(t.getAttribute('data-cols')||'0',10)||0);_blockMenuClose();return;} _blockMenuClose(); @@ -1229,11 +1248,24 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON. var b=E.blocks[idx];if(!b)return; 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; + // v7.68 : Clipboard — une sélection de texte multi-blocs OU des blocs + // sélectionnés (handle + Maj-clic) donnent Cut/Copy ; Paste colle après. + // la sélection est détruite quand la recherche prend le focus : on la + // fige ici et les actions Clipboard la reprennent telle quelle. + var clipSt=(E._clipState?E._clipState():null); + d._clipSt=clipSt; + var clip=(_selSet.size>1||clipSt) + ?'
Clipboard
' + +_blockMenuItem('clip-cut','✂','Cut',{shortcut:'Ctrl+X'}) + +_blockMenuItem('clip-copy','⧉','Copy',{shortcut:'Ctrl+C'}) + +_blockMenuItem('clip-paste','📋','Paste',{shortcut:'Ctrl+V'}) + :''; d.innerHTML='' + +clip +'
Text
' +_blockMenuItem('submenu','↩','Turn into',{sub:'turn'}) +_blockMenuItem('submenu','🎨','Color',{sub:'color'}) - +_blockMenuItem('link','🔗','Copy link to block',{shortcut:S.link}) + +_blockMenuItem('copy-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}) @@ -1249,7 +1281,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON. 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.querySelectorAll('.bm-group').forEach(function(g){g.style.display=q?'none':'';}); }); d.addEventListener('mouseover',function(ev){ var it=ev.target&&ev.target.closest?ev.target.closest('[data-sub]'):null; @@ -1377,6 +1409,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON. if(!(ev.target&&ev.target.closest))return; if(ev.target.closest('#blockMenu')||ev.target.closest('#blockMenuSub')||ev.target.closest('#fdMoveOverlay'))return; if(ev.target.closest('.block-handle'))return; + if(ev.button===2){const w0=ev.target.closest('.block-wrapper');if(w0&&_selSet.has(w0.getAttribute('data-id')))return;} if(_selSet.size)_selClear(); _blockMenuClose(); } @@ -1463,7 +1496,15 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON. ct.addEventListener('click',e=>{const h=e.target&&e.target.closest?e.target.closest('.block-handle'):null;if(!h)return;const w=h.closest('.block-wrapper');if(!w)return;const wid=w.getAttribute('data-id');if(!wid)return; if(e.shiftKey){_selToggle(wid);return;} _selClear();blockMenuOpen(e.clientX,e.clientY,wid);}); - ct.addEventListener('contextmenu',e=>{if(!(e.target&&e.target.closest))return;if(e.target.closest('.ftable-cell')||e.target.closest('#_tblMenu'))return;const c=e.target.closest('.block-content, .block-wrapper, .block-column-wrapper');const el0=e.target.closest('[data-bid]');if(!c)return;const w=e.target.closest('.block-wrapper');const wid=w?w.getAttribute('data-id'):null;const bid=wid||(el0?el0.getAttribute('data-bid'):null);if(!bid)return;e.preventDefault();_selClear();blockMenuOpen(e.clientX,e.clientY,bid);}); + ct.addEventListener('contextmenu',e=>{if(!(e.target&&e.target.closest))return;if(e.target.closest('.ftable-cell')||e.target.closest('#_tblMenu'))return;const c=e.target.closest('.block-content, .block-wrapper, .block-column-wrapper');const el0=e.target.closest('[data-bid]');if(!c)return;const w=e.target.closest('.block-wrapper');const wid=w?w.getAttribute('data-id'):null;const bid=wid||(el0?el0.getAttribute('data-bid'):null);if(!bid)return;e.preventDefault(); + // v7.68 : clic droit SUR un bloc sélectionné → on garde la sélection + // multi-blocs (le menu Clipboard coupe/copie ces blocs-là) + if(!(_selSet.size>1&&_selSet.has(wid)))_selClear(); + 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');}); 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); @@ -1960,7 +2001,58 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu _blocksToMarkdown(blocks){return blocks.map(blocksToMarkdown).join('\n\n');}, 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(){const idx=this._focusedIdx();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');}}}, + 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.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). + _clipState(){ + const s=window.getSelection(); + if(!s||!s.rangeCount||s.isCollapsed)return null; + const r=s.getRangeAt(0); + const up=n=>n?(n.nodeType===1?n:n.parentElement):null; + const se=up(r.startContainer), ee=up(r.endContainer); + const sblk=se&&se.closest?se.closest('[data-bid]'):null; + 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; + return {si:si,ei:ei, + startOff:_srcOffset(sblk,r.startContainer,r.startOffset), + endOff:_srcOffset(eblk,r.endContainer,r.endOffset)}; + }, + // blocs dont la sélection ne couvre qu'une PARTIE (les autres = entiers) + _clipHeavy(b){return ['table','code','columns','math','image','embed','divider','meeting','button','table_of_contents'].indexOf(b.type)>=0;}, + _clipMarkdown(st){ + const parts=[]; + for(let i=st.si;i<=st.ei;i++){ + const b=this.blocks[i];if(!b)continue; + if(i===st.si&&i===st.ei)return this._anchorText(b.id,st.startOff,st.endOff); + if(i===st.si||i===st.ei){ + if(this._clipHeavy(b)){parts.push(blocksToMarkdown(b));continue;} + const el=this.getEl(b.id); + parts.push(i===st.si + ? this._anchorText(b.id,st.startOff,el?_srcNodeLen(el):0) + : this._anchorText(b.id,0,st.endOff)); + }else parts.push(blocksToMarkdown(b)); + } + return parts.join('\n\n'); + }, + // renvoie le markdown copié, ou null si la sélection n'est pas multi-blocs + clipCopy(st){st=st||this._clipState();if(!st)return null;this.sync();return this._clipMarkdown(st);}, + clipCut(st){ + st=st||this._clipState();if(!st)return null; + this.sync();this.pushHistory(); + const md=this._clipMarkdown(st); + 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); + if(!this._clipHeavy(last))last.content=this._anchorText(last.id,st.endOff,lastEl?_srcNodeLen(lastEl):0); + this.blocks=this.blocks.slice(0,st.si+1).concat([last],this.blocks.slice(st.ei+1)); + if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')]; + this.dirty=true;this.autoSave();this.render();_rtSync(); + this._focusBid(first.id); + return md; + }, _focusedIdx(){const a=this.getActiveBlock();return a?a.idx:-1;}, async moveBlockToPage(idx,pageId){if(idx<0||idx>=this.blocks.length||pageId===this.pid)return;this.sync();this.pushHistory();const block=this.blocks[idx];const self=this;const csrf = getCsrf();;this.showToast('Déplacement du bloc…');try{const r=await fetch(`/board/api/pages/${pageId}`,{credentials:'same-origin'});const d=await r.json();let tblocks=[];if(d.content_format==='blocks'&&d.content){try{tblocks=JSON.parse(d.content);}catch(e){tblocks=[];}}tblocks.push(block);await fetch(`/board/api/pages/${pageId}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify({title:d.title||'',blocks:tblocks})}); if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();this.showToast('Bloc déplacé'); @@ -2972,7 +3064,10 @@ applyAIBlocks(text){ const btype=block.type||'paragraph'; if(['code','table','math','image','embed','meeting','divider','table_of_contents','columns'].indexOf(btype)>=0)return fallback(); const parsed=this.paste2b(text); - if(parsed.length<=1)return fallback(); + if(!parsed.length)return fallback(); + // un seul bloc PARAGRAPHE = coller une ligne → texte inline (comme avant) ; + // un seul bloc structuré (table, code, titre…) = vraie insertion de bloc. + if(parsed.length===1&&parsed[0].type==='paragraph')return fallback(); const sp=splitCaret(bel); let before='',after=''; if(sp){before=sp.before;after=sp.after;}else{before=gt(bel)||'';} diff --git a/tests/test_v768_columns_clipboard.py b/tests/test_v768_columns_clipboard.py new file mode 100644 index 0000000..87df5e6 --- /dev/null +++ b/tests/test_v768_columns_clipboard.py @@ -0,0 +1,60 @@ +"""7.68.0 — colonnes/math/TOC rendus + copier-couper-coller multi-blocs.""" + +from pathlib import Path + +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 _fn(name: str, span: int = 1500) -> str: + i = SRC.index(name + "(") + return SRC[i:i + span] + + +def test_renderers_are_actually_dispatched(): + # les trois renderers existaient mais n'étaient JAMAIS appelés → + # colonnes (slash + Turn into), équation et TOC rendaient un paragraphe vide + dispatch = _fn("renderBlock") + for call in ("renderColumnsBlock(b)", "renderMathBlock(b)", "renderTOC(b)"): + assert call in dispatch, call + assert SRC.count("function " + call.split("(")[0] + "(") == 1 + # la suppression d'une colonne n'est plus un bouton invisible + assert ".block-column-wrapper:hover .block-actions {" in CSS + + +def test_gfm_tables_without_leading_pipe(): + # l'IA rend souvent « A | B » / « --- | --- » — le pipe de tête est optionnel + assert "s.indexOf('|')>=0&&_tblSplitLine(s).length>=2" in SRC + # une ligne de séparation doit rester ≥2 cellules (sinon « texte » + « --- ») + assert "if(cells.length<2)return false;return cells.every" in SRC + # md2b ET paste2b passent par les mêmes helpers (alignés) + assert SRC.count("_tblIsSep(lines[i+1])") == 2 + + +def test_clipboard_multi_block_selection(): + # état : sélection vivante traversant ≥2 blocs ; sinon null (natif) + for tok in ("_clipState(){", "si>=ei)return null;", "_srcOffset(sblk,r.startContainer,r.startOffset)", + "_srcOffset(eblk,r.endContainer,r.endOffset)"): + assert tok in SRC, tok + # markdown : début partiel → blocs entiers → fin partielle + assert "_clipMarkdown(st){" in SRC + assert "blocksToMarkdown(b)" in SRC + # coupure : premier/trois restent (têtes/queues), milieu supprimé + assert "this.blocks.slice(0,st.si+1).concat([last],this.blocks.slice(st.ei+1))" in SRC + # Ctrl+C / Ctrl+X : listeners copy/cut (le clavier natif les déclenche) + assert "ct.addEventListener('copy'," in SRC + assert "ct.addEventListener('cut'," in SRC + assert "ev.clipboardData.setData('text/plain',md)" in SRC + # menu clic droit : groupe Clipboard Cut/Copy/Paste + assert "'
Clipboard
'" in SRC + for act in ("clip-cut", "clip-copy", "clip-paste"): + assert f"data-bm-act=\"{act}\"" in SRC or f"'{act}'" in SRC, act + # « Copy link to block » : l'act du menu doit être celle du handler + assert "_blockMenuItem('copy-link'," in SRC + assert "act==='copy-link'" in SRC + + +def test_paste_single_structured_block_is_inserted(): + # un seul bloc structuré collé (table, code, titre) ne retombe plus en texte brut + assert "if(parsed.length===1&&parsed[0].type==='paragraph')return fallback();" in SRC