diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fe6659..7d198d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog - FlowDeck +## v7.66.0 (2026-10-08) — Escape du menu slash retire le « / » + bouton « commenter » à droite du bloc + +### Added + +- **Bouton « commenter ce bloc » à droite du bloc** (`.block-comment-btn`, + gouttière 64px, visible au survol/focus comme le handle) : icône **bulle de + conversation avec un +**, `onclick="E.commentBlock(idx)"` → tiroir de + commentaires ancré sur le bloc (ou sur la sélection vivante s'il y en a une). + Masqué sur mobile (2 breakpoints, comme le handle) et sur page verrouillée. + - remplace l'ancien `.block-actions` de `renderBlock` : rendu en `opacity:0` + **sans règle de survol** (jamais de classe `.visible`) → un `+` invisible + qui capait les clics dans la gouttière droite. + +### Fixed + +- **Escape ferme le menu slash ET retire le « / »** : nouvelle `SM.escClose()` + appelée par les **deux** chemins de fermeture (focus dans le bloc → `onKd`, + focus dans l'entrée du menu → listener `document`). Ne touche au caractère + que si c'est le `/` d'ouverture, remet le caret et sauvegarde (`sync` + + `autoSave`). +- **`fmtComment()` restaure la sélection de la toolbar** (`_fmtRestore()`) + avant d'ancrer : `commentBlock()` ne se replie plus sur `this._sel` (plage + potentiellement périmée reprise par le bouton de droite / le menu de bloc). + +### Tests + +- **e2e** `e2e/v766_slash_esc_comment_btn.spec.js` : « / » → menu visible → + Escape → menu fermé et bloc vide ; bouton présent, `opacity` 0 → 1 au + survol, **à droite** du wrapper (boîte mesurée), clic → tiroir → ancre = le + bloc entier. +- `tests/test_v766_slash_esc_comment_btn.py` (3) ; `pytest tests/ -q -n auto` + → **1386 passed**, `ruff check app tests` → OK. + + ## v7.65.0 (2026-10-08) — Comment ancré sur la SÉLECTION de texte (pas le bloc) + retrait du bouton bleu « 💬 Comment » ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index fe98df5..8d64af3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1482,6 +1482,25 @@ Détails livrés : reload, `#_commentSelBtn` absent ; `pytest` 1383 passed, `ruff` OK ; `VERSION` + `app/main.py` = 7.65.0 +### v7.66.0 — Escape du menu slash + bouton commenter à droite ✅ (2026-10-08) +> **Objectif** : Esc efface le `/` qui a ouvert le menu slash, et chaque bloc +> gagne un bouton de commentaire à droite. **COMPLETED**. + +- [x] **`SM.escClose()`** — un seul implément, appelé par les deux chemins + (focus dans le bloc → `onKd`, focus dans l'entrée du menu → listener + `document`) ; retire le `/` d'ouverture seulement, remet le caret, `sync` + + `autoSave` +- [x] **Bouton à droite du bloc** — `.block-comment-btn` (bulle + `+`, + `E.commentBlock(idx)`), gouttière 64px, visible au survol, masqué mobile + + page verrouillée ; remplace l'ancien `.block-actions` mort (opacity:0 sans + survol → `+` invisible qui capait les clics) +- [x] **`fmtComment()`** restaure la plage de la toolbar avant d'ancrer ; + `commentBlock()` ne se replie plus sur `this._sel` périmé +- [x] **Tests** — e2e `e2e/v766_slash_esc_comment_btn.spec.js` (Esc → menu + fermé + bloc vide ; bouton à droite mesuré, ancre = bloc entier), + `tests/test_v766_slash_esc_comment_btn.py` (3) ; `pytest` **1386 passed**, + `ruff` OK ; `VERSION` + `app/main.py` = 7.66.0 + --- ## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code) diff --git a/VERSION b/VERSION index 1a97721..52b5df3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.65.0 +7.66.0 diff --git a/WORKLOAD.md b/WORKLOAD.md index dca987b..1fc588b 100644 --- a/WORKLOAD.md +++ b/WORKLOAD.md @@ -1,6 +1,6 @@ # WORKLOAD — FlowDeck Notion Clone -> **Début**: 2026-07-08 | **Version**: v7.65.0 (comment ancré sur la sélection de texte, bouton bleu retiré) | **Statut**: EN COURS 🔄 +> **Début**: 2026-07-08 | **Version**: v7.66.0 (Escape du menu slash retire le / + bouton commenter à droite du bloc) | **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 d26c21f..156316a 100644 --- a/app/main.py +++ b/app/main.py @@ -186,7 +186,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.65.0", + version="7.66.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/commit_msg.txt b/commit_msg.txt new file mode 100644 index 0000000..9af52e2 --- /dev/null +++ b/commit_msg.txt @@ -0,0 +1,18 @@ +feat: Escape du menu slash retire le « / » + bouton « commenter » à droite du bloc (v7.66.0) + +- SM.escClose() : un seul implément, appelé par les deux chemins de fermeture + du menu slash (focus dans le bloc → onKd, focus dans l'entrée du menu → + listener document). Retire le « / » d'ouverture seulement, remet le caret, + sync() + autoSave(). +- Bouton .block-comment-btn à droite du bloc (gouttière 64px) : icône bulle de + conversation + , visible au survol/focus comme le handle, masqué sur mobile + (2 breakpoints) et sur page verrouillée ; onclick = E.commentBlock(idx). + Remplace l'ancien .block-actions de renderBlock (opacity:0 sans règle de + survol → un + invisible qui capait les clics dans la gouttière). +- fmtComment() restaure la plage de la toolbar (_fmtRestore) avant d'ancrer : + commentBlock() ne se replie plus sur this._sel (plage périmée éventuelle). + +Tests: pytest tests/ -q -n auto → 1386 passed ; ruff check app tests → OK ; +e2e v766 (Esc → menu fermé + bloc vide ; bouton masqué → visible au survol, +à droite du wrapper, ancre = bloc visé) + v764/v762/v7641/v763/ +regression_editor_mount → 7 passed sur :8081. diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json index 691a830..7e1ad91 100644 --- a/docs/openapi-v2.json +++ b/docs/openapi-v2.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "FlowDeck", - "version": "7.65.0" + "version": "7.66.0" }, "paths": { "/auth/register": { diff --git a/e2e/v766_slash_esc_comment_btn.spec.js b/e2e/v766_slash_esc_comment_btn.spec.js new file mode 100644 index 0000000..a377964 --- /dev/null +++ b/e2e/v766_slash_esc_comment_btn.spec.js @@ -0,0 +1,99 @@ +const { test, expect } = require('@playwright/test'); + +/** + * v7.66 — deux ajustements éditeur : + * - « / » ouvre le menu slash → Escape ferme le menu ET retire le « / » + * - survol d'un bloc → bouton à droite (bulle + ) qui commente le bloc + * 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' }); + +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=v766 ${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: 'v766', 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; +} + +test('Escape du menu slash retire le caractère /', async ({ page }) => { + await login(page); + await newPage(page, [{ id: 'b1', type: 'paragraph', content: '' }]); + const blk = page.locator('#_blocksCt [data-bid]').first(); + await blk.click(); + await page.keyboard.type('/'); + await expect(page.locator('#_slashMenu')).toBeVisible({ timeout: 5000 }); + await page.keyboard.press('Escape'); + await expect(page.locator('#_slashMenu')).toBeHidden(); + // le « / » a été retiré — pas de caractère résiduel + await expect(blk).toHaveText(''); + await page.screenshot({ path: 'shots/v766-slash-esc.png' }); +}); + +test('bouton commenter à droite du bloc', async ({ page }) => { + await login(page); + // 2 blocs : l'éditeur focalise le 1er au chargement (`:focus-within` le + // montre) → on mesure sur le 2e, ni focalisé ni survolé. + await newPage(page, [ + { id: 'b1', type: 'paragraph', content: 'Premier bloc' }, + { id: 'b2', type: 'paragraph', content: 'Un bloc a commenter' }, + ]); + await expect(page.locator('#_blocksCt .block-comment-btn')).toHaveCount(2); + const wrap = page.locator('#_blocksCt .block-wrapper').nth(1); + const btn = page.locator('#_blocksCt .block-comment-btn').nth(1); + + // masqué tant qu'on ne survole pas / ne focalise pas le bloc + await expect.poll(() => btn.evaluate((el) => getComputedStyle(el).opacity)).toBe('0'); + await wrap.hover(); + await expect.poll(() => btn.evaluate((el) => getComputedStyle(el).opacity)).toBe('1'); + + // bien à droite du bloc (gouttière 64px) + const wb = await wrap.boundingBox(); + const bb = await btn.boundingBox(); + expect(bb.x).toBeGreaterThan(wb.x + wb.width - 1); + await page.screenshot({ path: 'shots/v766-comment-btn.png' }); + + // clic → tiroir → envoi → ancre = le bloc visé (et pas le 1er) + await btn.click(); + const drawer = page.locator('.comments-drawer'); + await expect(drawer).toBeVisible(); + await drawer.locator('textarea').fill('Commentaire via le bouton de droite'); + await drawer.locator('.ct-send').click(); + await expect(page.locator('.comment-thread')).toHaveCount(1); + await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveText('Un bloc a commenter'); +}); diff --git a/static/css/app.css b/static/css/app.css index 977b40e..1fc0441 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -2371,6 +2371,35 @@ button.breadcrumb-current { max-width: 260px; } color: var(--text-primary); } +/* ── v7.66 : bouton « commenter ce bloc » — à droite du bloc (gouttière 64px), + bulle de conversation + , visible au survol comme le handle ── */ +.block-comment-btn { + position: absolute; + right: -60px; + top: 4px; + width: 22px; + height: 22px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + border-radius: 3px; + color: var(--text-dim); + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease; +} +.block-wrapper:hover .block-comment-btn, +.block-wrapper:focus-within .block-comment-btn { + opacity: 1; +} +.block-comment-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + /* ── v5.10.0: selection multiple + menu contexte bloc ── */ .block-wrapper.selected { background: rgba(35,131,226,0.06); @@ -3288,6 +3317,7 @@ button.breadcrumb-current { max-width: 260px; } .block-handle { display: none; } .block-insert-btn { display: none; } .block-actions { display: none; } + .block-comment-btn { display: none; } .editor-statusbar { left: 0; } } @@ -3855,6 +3885,7 @@ button.breadcrumb-current { max-width: 260px; } .block-handle { display: none; } .block-insert-btn { display: none; } .block-actions { display: none; } + .block-comment-btn { display: none; } .page-title-block::before { display: none; } .page-title-block .page-icon-emoji { margin-left: 0; } .page-title-block .page-title-input { padding-left: 0; } @@ -4998,6 +5029,7 @@ a.fd-wiki-link:hover { border-bottom-color: var(--accent, #2383e2); } font-weight: 500; } body.page-locked .block-handle, +body.page-locked .block-comment-btn, body.page-locked .block-add-btn, body.page-locked .format-toolbar, body.page-locked .get-started-toolbar { display: none !important; } diff --git a/static/js/page_editor_scripts.js b/static/js/page_editor_scripts.js index 2af7cc5..892a308 100644 --- a/static/js/page_editor_scripts.js +++ b/static/js/page_editor_scripts.js @@ -463,6 +463,27 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON. SM._e.style.left=Math.min(r.left,window.innerWidth-340)+'px'; SM._rs();setTimeout(()=>SM._in.focus(),10);SM._o=true;}, close(){if(SM._e)SM._e.style.display='none';SM._o=false;SM._in&&(SM._in.value='');if(SM._pv)SM._pv.style.display='none';MTB.close();}, + // v7.66 : Escape ferme le menu ET retire le « / » qui l'a ouvert. + escClose(){ + const i=SM._i;SM.close(); + const E=window.E;if(!E||i<0||i>=E.blocks.length)return; + const el=E.getEl(E.blocks[i].id);if(!el)return; + const w=document.createTreeWalker(el,NodeFilter.SHOW_TEXT);let n; + while((n=w.nextNode())){ + if(n.parentElement&&n.parentElement.closest('.fd-react-chip'))continue; + const v=n.nodeValue,at=v.indexOf('/'); + if(at>=0){ + if(v.slice(0,at).trim())return; // pas le / d'ouverture → on ne touche à rien + n.nodeValue=v.slice(0,at)+v.slice(at+1); + E.sync();E.dirty=true;E.autoSave(); + el.focus(); + const r=document.createRange();r.setStart(n,Math.min(at,n.nodeValue.length));r.collapse(true); + const s=window.getSelection();s.removeAllRanges();s.addRange(r); + return; + } + if(v.trim())return; // 1er texte non vide sans / → rien à retirer + } + }, // Ligne d'item du slash (partagée par la popup desktop et la feuille mobile) _slashItemHtml(c){return `