diff --git a/CHANGELOG.md b/CHANGELOG.md index f8b519d..76471e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,49 @@ # Changelog - FlowDeck +## v7.63.0 (2026-10-08) — Menus mobiles façon Notion (barre au « / » + Insert block plein écran) + +### Added + +- **Barre horizontale mobile** — sur un viewport ≤ 768 px, taper `/` sur une ligne + vide affiche une **barre flottante défilante sous le curseur** (au lieu de la popup + desktop), 15 boutons dans l'ordre Notion : `+▾`, `Turn into▾`, `@`, 💬, 🖼, 🗑, + `←`, `→`, `↑`, `↓`, `↩`, `↪`, `Color▾`, `More▾`, `⌨` : + - `+▾` → **feuille plein écran « Insert block »** : titre centré, bouton bleu + « Cancel », liste verticale par groupes (Basic blocks / AI writing / Media / + Data / Synced / Actions) avec icône, nom et **raccourci markdown** à droite + (`#`, `##`, `-`, `1.`, `[]`, `>`, `"`, `---`…) — items partagés avec la popup + desktop (`SM._slashItemHtml`), choix → `applySlash` → tout se referme. + - `Turn into▾` / `Color▾` → feuilles réutilisant **les mêmes générateurs d'items** + que les sous-menus du menu de bloc (`_blockTurnItems` / `_blockColorItems` + extraits de `_blockTurnMenuOpen` / `_blockColorMenuOpen`). + - `More▾` → menu contextuel de bloc existant (recherche + 10 actions + pied). + - `@` insère `@` et ouvre le sélecteur de mention ; 💬 commentaire ; 🖼 insère un + bloc image ; 🗑 supprime ; `⌨` masque le clavier ; `↩ ↪` annuler/rétablir. +- **Déplacement de bloc `moveBlock(bid, dir)`** — `↑↓` réordonne (y compris dans un + parent toggle/columns), `→` indente dans le frère précédent (toggle/columns), + `←` ressort à la racine ; garde-fous avec toast (déjà en haut/bas, déjà à la + racine, pas de cible). +- **Fermeture automatique** de la barre quand le `/` n'est plus au début du bloc ; + taps dans la barre/la feuille n'essaient plus de fermer le menu (exclusion + `MTB.contains` sur le handler mousedown hors-zone). +- **Raccourcis slash alignés sur les préfixes markdown** (description Notion) : + `-` (puces), `[]` (à faire), `>` (toggle), `---` (séparateur) — partagés avec la + popup desktop ; groupe `SYNCED` libellé « Synced ». + +### Tests + +- `tests/test_v763_mobile_menus.py` : ordre des 15 boutons, câblage de chaque + action, structure de la feuille (titre/Cancel/groupes), branchement mobile, + styles — et **`moveBlock` exécuté pour de vrai sous node** (ordre, indent, + outdent, garde-fous). +- **e2e Playwright mobile** `e2e/v763_mobile_menus.spec.js` (viewport 390×844, + `hasTouch`) : barre visible au `/` avec les 15 boutons dans l'ordre + `overflow-x:auto` + et popup desktop masquée, « Insert block » (groupes, raccourci `#` de Heading 1) + → le bloc devient `heading_1`, « Turn into » (coche du type courant), « Color » + (3 groupes), « More » (menu de bloc + pied), `↑` qui réordonne réellement les blocs. +- `tests/test_v761_block_menu.py` adapté aux extractions `_blockTurnItems` / + `_blockColorItems`. + ## v7.62.0 (2026-10-08) — Menu contextuel au highlight de texte (toolbar façon Notion) ### Added diff --git a/ROADMAP.md b/ROADMAP.md index c67ec3c..350cef2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1419,6 +1419,25 @@ Détails livrés : - [x] **Fix « toolbar muette sous Alpine v3 »** — `ed.__x` (API v2) n'existe plus : `showFmt`/`hideCommentBtn`/mentions ne s'exécutaient jamais → accès via `_xed()` (`_x_dataStack[0]`) ; ancre de sélection élément gérée ; `checkMd()` recevait un id au lieu d'un index (exception à chaque espace) - [x] **Tests** — `tests/test_v762_selection_toolbar.py` (roundtrip `gtMd`/`mdEsc` réel sous node) + **e2e Playwright** `e2e/v762_selection_toolbar.spec.js` (structure, sous-menus, géométrie, persistance bold/highlight après reload) ; `VERSION` + `app/main.py` = 7.62.0 +### v7.63.0 — Menus mobiles façon Notion ✅ (2026-10-08) +> **Objectif** : parité des menus mobiles (Android sombre) — barre contextuelle au +> « / » + menu « Insert block » plein écran. **COMPLETED**. + +- [x] **Barre horizontale défilante** sous le curseur (mobile ≤ 768 px) — 15 boutons + dans l'ordre Notion : `+▾`, `Turn into▾`, `@`, 💬, 🖼, 🗑, `←→↑↓`, `↩↪`, `Color▾`, + `More▾`, `⌨` ; `overflow-x:auto`, fermeture auto quand le `/` disparaît, taps + exclus du handler « clic hors-zone » +- [x] **Feuille plein écran « Insert block »** (`+`) — titre centré + « Cancel » bleu, + groupes CMDS avec raccourcis markdown (items partagés avec la popup desktop) ; + `Turn into` / `Color` réutilisent les générateurs extraits (`_blockTurnItems`, + `_blockColorItems`) ; `More` ouvre le menu de bloc existant +- [x] **`moveBlock(bid, dir)`** — ↑↓ ordre (racine + enfants toggle/columns), + → indent dans le frère précédent, ← outdent, garde-fous toast +- [x] **Raccourcis slash** alignés sur les préfixes markdown (`-`, `[]`, `>`, `---`) +- [x] **Tests** — `tests/test_v763_mobile_menus.py` (+ `moveBlock` exécuté sous node), + e2e mobile `e2e/v763_mobile_menus.spec.js` (390×844) ; `VERSION` + `app/main.py` + = 7.63.0 + --- ## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code) diff --git a/VERSION b/VERSION index 3fa542c..23b308f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.62.0 +7.63.0 diff --git a/WORKLOAD.md b/WORKLOAD.md index 40d5fe2..ed90c9d 100644 --- a/WORKLOAD.md +++ b/WORKLOAD.md @@ -1,6 +1,6 @@ # WORKLOAD — FlowDeck Notion Clone -> **Début**: 2026-07-08 | **Version**: v7.62.0 (menu contextuel au highlight de texte — toolbar 4 lignes façon Notion) | **Statut**: EN COURS 🔄 +> **Début**: 2026-07-08 | **Version**: v7.63.0 (menus mobiles — barre horizontale au « / » + Insert block plein écran) | **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 2e0d1f9..0667c46 100644 --- a/app/main.py +++ b/app/main.py @@ -186,7 +186,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.62.0", + version="7.63.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json index 277bd21..0f1387e 100644 --- a/docs/openapi-v2.json +++ b/docs/openapi-v2.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "FlowDeck", - "version": "7.62.0" + "version": "7.63.0" }, "paths": { "/auth/register": { diff --git a/e2e/v763_mobile_menus.spec.js b/e2e/v763_mobile_menus.spec.js new file mode 100644 index 0000000..7fbb4e8 --- /dev/null +++ b/e2e/v763_mobile_menus.spec.js @@ -0,0 +1,125 @@ +const { test, expect } = require('@playwright/test'); + +/** + * v7.63 — menus mobiles façon Notion (viewport 390×844) : + * - « / » → barre horizontale défilante sous le curseur (15 boutons, ordre du doc) + * - « + » → feuille plein écran « Insert block » (titre centré + Cancel, groupes, + * raccourcis) → choix d'un type = applySlash + * - « Turn into » / « Color » → feuilles réutilisant les sous-menus de bloc + * - « More » → menu contextuel de bloc (desktop) + * 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', viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true }); + +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) { + 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=Mobile menus ${Date.now()}`, + { headers: { 'X-CSRF-Token': csrf } } + ); + expect(created.status(), await created.text()).toBeLessThan(400); + const { id } = await created.json(); + await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 }); + return id; +} + +test('barre mobile au / + feuilles Insert block / Turn into / More', async ({ page }) => { + await login(page); + await newPage(page); + await page.click('#_blocksCt [data-bid]'); + await page.keyboard.type('/'); + + // ── 1. la barre apparaît, 15 boutons dans l'ordre de la description ── + const bar = page.locator('#fdMobileBar'); + await expect(bar).toBeVisible({ timeout: 5000 }); + const keys = await page.evaluate(() => + Array.from(document.querySelectorAll('#fdMobileBar [data-mtb]')).map((b) => b.dataset.mtb)); + expect(keys).toEqual(['plus', 'turn', 'at', 'comment', 'image', 'trash', + 'left', 'right', 'up', 'down', 'undo', 'redo', 'color', 'more', 'kbd']); + // défilement latéral + const overflowX = await bar.evaluate((el) => getComputedStyle(el).overflowX); + expect(overflowX).toBe('auto'); + // pas de popup desktop en parallèle + await expect(page.locator('#_slashMenu')).toBeHidden(); + await page.screenshot({ path: 'shots/v763-bar.png' }); + + // ── 2. « + » → feuille plein écran « Insert block » ── + await bar.locator('[data-mtb="plus"]').click(); + const sheet = page.locator('.fd-msheet'); + await expect(sheet).toBeVisible(); + await expect(sheet.locator('.fd-msheet-title')).toHaveText('Insert block'); + await expect(sheet.locator('.fd-msheet-cancel')).toHaveText('Cancel'); + await expect(sheet.locator('.sm-group').first()).toHaveText('Basic blocks'); + await expect(sheet.locator('.slash-item', { hasText: 'Heading 1' })).toHaveCount(1); + await page.screenshot({ path: 'shots/v763-insert.png' }); + + // ── 3. choix d'un type → applySlash (le bloc devient un titre) ── + await sheet.locator('.slash-item', { hasText: 'Heading 1' }).click(); + await expect(sheet).toHaveCount(0); + await expect(bar).toBeHidden(); + expect(await page.evaluate(() => window.E.blocks[0].type)).toBe('heading_1'); + + // ── 4. « Turn into » → feuille réutilisant les items du sous-menu de bloc ── + await page.keyboard.type('/'); + await expect(bar).toBeVisible(); + await bar.locator('[data-mtb="turn"]').click(); + await expect(sheet.locator('.fd-msheet-title')).toHaveText('Turn into'); + await expect(sheet.locator('.bm-item', { hasText: 'Bulleted list' })).toHaveCount(1); + await expect(sheet.locator('.bm-check')).toHaveCount(1); // type courant coché + await page.screenshot({ path: 'shots/v763-turn.png' }); + await sheet.locator('.fd-msheet-cancel').click(); + await expect(sheet).toHaveCount(0); + expect(await page.evaluate(() => window.E.blocks[0].type)).toBe('heading_1'); + + // ── 5. « Color » → feuille avec les 3 groupes ── + await page.evaluate(() => localStorage.setItem('fdLastBlockColor', 'color|#E5484D')); + await bar.locator('[data-mtb="color"]').click(); + for (const g of ['Last used', 'Text color', 'Background color']) { + await expect(sheet.locator('.bm-group', { hasText: g })).toHaveCount(1); + } + await sheet.locator('.fd-msheet-cancel').click(); + + // ── 6. « More » → menu contextuel de bloc (recherche + pied) ── + await bar.locator('[data-mtb="more"]').click(); + const menu = page.locator('#blockMenu'); + await expect(menu).toBeVisible(); + await expect(menu.locator('.bm-search')).toHaveAttribute('placeholder', 'Search actions...'); + await expect(menu.locator('.bm-foot')).toContainText('Last edited by'); + await page.keyboard.press('Escape'); + await expect(menu).toBeHidden(); + + // ── 7. ↑ déplace réellement le bloc ── (bloc 0 = titre, bloc 1 = paragraphe) + await page.click('#_blocksCt [data-bid]'); // bloc 0 (contient '/') + await page.keyboard.press('Enter'); + await page.waitForTimeout(150); // le focus du nouveau bloc est différé (60 ms) + await page.keyboard.type('/'); // bloc neuf et vide → slash exact = ouverture + await expect(bar).toBeVisible(); // SM._i = 1 (bloc du bas) + await bar.locator('[data-mtb="up"]').click(); + expect(await page.evaluate(() => window.E.blocks.map((b) => b.type).join(','))) + .toBe('paragraph,heading_1'); + await page.screenshot({ path: 'shots/v763-moved.png' }); +}); diff --git a/static/css/app.css b/static/css/app.css index c9cf824..4801ddd 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -3056,6 +3056,104 @@ button.breadcrumb-current { max-width: 260px; } color: #6b6b6b; } +/* ══ v7.63 — Menus mobiles façon Notion : barre horizontale au « / » ══ + (défilement latéral) + feuille plein écran Insert block / Turn into / Color ══ */ +.fd-mtb { + position: fixed; + z-index: 1300; + display: none; + gap: 2px; + padding: 5px 6px; + max-width: calc(100vw - 16px); + overflow-x: auto; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; + background: var(--bg-modal); + border: 1px solid var(--border-light); + border-radius: 10px; + box-shadow: 0 6px 20px rgba(0,0,0,.45); +} +.fd-mtb::-webkit-scrollbar { display: none; } +.fd-mtb .fd-mtb-item { + flex: 0 0 auto; + min-width: 38px; + height: 38px; + padding: 0 8px; + border: none; + background: transparent; + border-radius: 6px; + color: var(--text-primary); + font-size: 16px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + cursor: pointer; +} +.fd-mtb .fd-mtb-item:active { background: var(--bg-hover); } +.fd-mtb .fd-mtb-wide { font-size: 13px; font-weight: 600; padding: 0 10px; } +.fd-mtb .fd-mtb-caret { font-size: 10px; color: var(--text-dim); } + +/* Feuille plein écran (titre centré + Cancel bleu à droite, liste qui défile) */ +.fd-msheet { + position: fixed; + inset: 0; + z-index: 1500; + display: flex; + flex-direction: column; + background: var(--bg-secondary, #191919); +} +.fd-msheet-head { + position: relative; + display: flex; + align-items: center; + justify-content: center; + padding: 14px 16px; + border-bottom: 1px solid var(--border); + flex: 0 0 auto; +} +.fd-msheet-title { font-size: 15px; font-weight: 600; color: var(--text-primary); } +.fd-msheet-cancel { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--accent, #2383E2); + font-size: 14px; + font-weight: 600; + padding: 6px; + cursor: pointer; +} +/* la liste porte la classe block-menu (hérite des styles .bm-item/.bm-group) + mais doit couvrir tout l'écran, sans la chrome du menu flottant */ +.fd-msheet-list.block-menu { + max-width: none; + min-width: 0; + background: none; + border: none; + border-radius: 0; + box-shadow: none; + padding: 6px 8px 40px; + flex: 1 1 auto; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} +.fd-msheet .bm-item { padding: 12px 10px; font-size: 15px; } +.fd-msheet .bm-group { padding: 14px 10px 4px; } +.fd-msheet .sm-group { + padding: 14px 10px 4px; + font-size: 11px; + font-weight: 600; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: .4px; +} +.fd-msheet .slash-item { padding: 12px 10px; font-size: 15px; } +.fd-msheet .slash-item:active { background: var(--bg-hover); } + /* EDITOR STATUSBAR */ .editor-statusbar { position: fixed; diff --git a/static/js/page_editor_scripts.js b/static/js/page_editor_scripts.js index ea970bd..c7bb868 100644 --- a/static/js/page_editor_scripts.js +++ b/static/js/page_editor_scripts.js @@ -320,8 +320,9 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON. } // ── Block type shortcuts (shown on the right of each slash item) ── - const _SHORTCUT={text:'T',heading_1:'#',heading_2:'##',heading_3:'###',heading_4:'####',bulleted_list:'•',numbered_list:'1.',to_do:'[ ]',toggle:'▸',quote:'"',callout:'!',table_of_contents:'TOC',math:'∑',columns:'‖',divider:'———',code:'<>',table:'|',image:'[img]',embed:'[🔗]',bookmark:'[🔖]',video:'[🎬]',audio:'[🎵]',database:'DB',ai_write:'/ai',ai_summarize:'/ai',ai_translate:'/ai',ai_continue:'/ai',duplicate:'Ctrl+D',turn_into:'⋯',copy_link:'#',delete:'⌫'}; - const _GROUP_LABEL={BASIC:'Basic blocks',AI:'AI writing',MEDIA:'Media',DATA:'Data',ACTIONS:'Actions'}; + // Raccourcis = préfixes markdown (alignés sur la description mobile Notion : -, [], >, ---) + const _SHORTCUT={text:'T',heading_1:'#',heading_2:'##',heading_3:'###',heading_4:'####',bulleted_list:'-',numbered_list:'1.',to_do:'[]',toggle:'>',quote:'"',callout:'!',table_of_contents:'TOC',math:'∑',columns:'‖',divider:'---',code:'<>',table:'|',image:'[img]',embed:'[🔗]',bookmark:'[🔖]',video:'[🎬]',audio:'[🎵]',database:'DB',ai_write:'/ai',ai_summarize:'/ai',ai_translate:'/ai',ai_continue:'/ai',duplicate:'Ctrl+D',turn_into:'⋯',copy_link:'#',delete:'⌫'}; + const _GROUP_LABEL={BASIC:'Basic blocks',AI:'AI writing',MEDIA:'Media',DATA:'Data',ACTIONS:'Actions',SYNCED:'Synced'}; // ── Hover preview card (matches Notion's visual hint) ── function _previewHtml(blockId){ const p=function(inner,cls){return '