From 699165469dd20093a1aba16fc01f2d6db65adbb4 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 8 Oct 2026 09:36:37 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20menus=20mobiles=20fa=C3=A7on=20Notion?= =?UTF-8?q?=20=E2=80=94=20barre=20horizontale=20au=20=C2=AB=20/=20=C2=BB?= =?UTF-8?q?=20+=20Insert=20block=20plein=20=C3=A9cran=20(v7.63.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Barre flottante défilante sous le curseur (mobile ≤ 768 px) au lieu de la popup desktop, 15 boutons dans l'ordre de la description : +▾, Turn into▾, @, 💬, 🖼, 🗑, ←, →, ↑, ↓, ↩, ↪, Color▾, More▾, ⌨ - +▾ → feuille plein écran « Insert block » (titre centré, Cancel bleu, groupes Basic blocks / AI writing / Media / Data / Synced / Actions, raccourcis markdown à droite) ; items partagés avec la popup desktop (SM._slashItemHtml) - Turn into ▾ / Color ▾ → feuilles réutilisant les générateurs extraits _blockTurnItems / _blockColorItems (extraits de _blockTurnMenuOpen / _blockColorMenuOpen, comportement desktop inchangé) - More ▾ → menu contextuel de bloc existant ; @ insère + ouvre les mentions ; 💬 commentaire ; 🖼 bloc image ; 🗑 suppression ; ⌨ masque le clavier ; ↩ ↪ annuler/rétablir - moveBlock(bid, dir) : ↑↓ réordonne (racine + enfants toggle/columns), → indent dans le frère précédent, ← outdent, garde-fous avec toast - Fermeture auto de la barre quand le « / » n'est plus en tête de bloc ; taps dans la barre/les feuilles exclus du handler « clic hors-zone » ; Cancel ne referme que la feuille (la barre reste tant que le / est tapé) - Raccourcis slash alignés sur les préfixes markdown (-, [], >, ---), groupe SYNCED libellé « Synced » - Tests : tests/test_v763_mobile_menus.py (ordre, câblage, structure, styles + moveBlock exécuté sous node), e2e/v763_mobile_menus.spec.js (390×844, 1 passed) ; test_v761 adapté aux extractions ; suite complète 1375 passed / 0 failed, ruff OK, OpenAPI 7.63.0 (525 chemins) --- CHANGELOG.md | 44 ++++++++++ ROADMAP.md | 19 +++++ VERSION | 2 +- WORKLOAD.md | 2 +- app/main.py | 2 +- docs/openapi-v2.json | 2 +- e2e/v763_mobile_menus.spec.js | 125 ++++++++++++++++++++++++++++ static/css/app.css | 98 ++++++++++++++++++++++ static/js/page_editor_scripts.js | 129 +++++++++++++++++++++++++---- tests/test_v761_block_menu.py | 6 +- tests/test_v763_mobile_menus.py | 136 +++++++++++++++++++++++++++++++ 11 files changed, 544 insertions(+), 21 deletions(-) create mode 100644 e2e/v763_mobile_menus.spec.js create mode 100644 tests/test_v763_mobile_menus.py 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 '
'+inner+'
';}; @@ -365,7 +366,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON. inp.oninput=()=>SM.flt(); e.appendChild(list);e.appendChild(footer);e.appendChild(inp); SM._e=e;SM._in=inp;SM._li=list;SM._f=footer; - document.addEventListener('mousedown',ev=>{if(!e.contains(ev.target))SM.close();}); + document.addEventListener('mousedown',ev=>{if(!e.contains(ev.target)&&!MTB.contains(ev.target))SM.close();}); if(!SM._doc){ SM._doc=true; document.addEventListener('keydown',function(ev){ @@ -390,19 +391,22 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON. SM._pv.style.left=left+'px'; SM._pv.style.top=top+'px'; SM._pv.style.display='block'; }, open(i,el){if(!SM._e)SM.init();SM.close();SM._i=i;SM._s=0;SM._in.value='';SM.flt(); + // v7.63 : mobile → barre horizontale façon Notion sous le curseur + if(_fdMobile()){SM._o=true;MTB.open(i,el);return;} const r=el.getBoundingClientRect();SM._e.style.display='flex'; SM._e.style.top=Math.max(4,(r.bottom+4>window.innerHeight-360)?r.top-364:r.bottom+4)+'px'; 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';}, + 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();}, + // Ligne d'item du slash (partagée par la popup desktop et la feuille mobile) + _slashItemHtml(c){return `
+ ${c.icon} + ${c.name} + ${_SHORTCUT[c.id]||''}
`;}, flt(){if(!SM._li)return;SM._it=[];const q=(SM._in.value||'').toLowerCase();SM._q=q;let h=''; for(const g of CMDS){let items=g.items;if(q)items=items.filter(c=>c.name.toLowerCase().includes(q)||c.id.includes(q));if(!items.length)continue; h+=`
${_GROUP_LABEL[g.name]||g.name}
`; - items.forEach(c=>{SM._it.push(c); - h+=`
- ${c.icon} - ${c.name} - ${_SHORTCUT[c.id]||''}
`;});} + items.forEach(c=>{SM._it.push(c);h+=SM._slashItemHtml(c);});} SM._li.innerHTML=h;SM._rs();}, _kd(e){if(e.key==='ArrowDown'){e.preventDefault();SM._s=Math.min(SM._s+1,SM._it.length-1);SM._rs();} else if(e.key==='ArrowUp'){e.preventDefault();SM._s=Math.max(SM._s-1,0);SM._rs();} @@ -412,6 +416,64 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON. sel(id){if(window.E)window.E.applySlash(id);SM.close();}, get isOpen(){return SM._o;}}; + // ═══════════ v7.63.0 — Menus mobiles façon Notion : barre horizontale sous le curseur + // + feuille plein écran « Insert block » / « Turn into » / « Color ». Tout est + // branché sur les actions existantes (applySlash, turnInto, blockMenu, moveBlock…). + function _fdMobile(){try{return window.matchMedia('(max-width: 768px)').matches;}catch(e){return false;}} + const MTB={ + _b:null,_sheet:null, + contains(t){return !!t&&((this._b&&this._b.contains(t))||(this._sheet&&this._sheet.contains(t)));}, + el(){ if(this._b)return this._b; + const wide=['plus','turn','color','more']; + const items=[['plus','+▾'],['turn','Turn into▾'], + ['at','@'],['comment','💬'],['image','🖼️'],['trash','🗑️'], + ['left','←'],['right','→'],['up','↑'],['down','↓'],['undo','↩'],['redo','↪'], + ['color','Color▾'],['more','More▾'],['kbd','⌨️']]; + const d=document.createElement('div');d.id='fdMobileBar';d.className='fd-mtb'; + d.innerHTML=items.map(it=>'').join(''); + d.addEventListener('click',ev=>{const bt=ev.target.closest('[data-mtb]');if(bt)MTB.act(bt.getAttribute('data-mtb'),bt);}); + document.body.appendChild(d);this._b=d;return d;}, + open(i,el){const d=this.el();d.style.display='flex'; + const r=el.getBoundingClientRect(); + const w=Math.min(d.scrollWidth||280,window.innerWidth-16); + d.style.left=Math.max(8,Math.min(r.left,window.innerWidth-w-8))+'px'; + let top=r.bottom+6;if(top+54>window.innerHeight-8)top=Math.max(8,r.top-54); + d.style.top=top+'px';d.scrollLeft=0;}, + close(){if(this._b)this._b.style.display='none';this.closeSheet();}, + closeSheet(){if(this._sheet){this._sheet.remove();this._sheet=null;}}, + sheet(title,html,handler){ + this.closeSheet(); + const s=document.createElement('div');s.className='fd-msheet'; + s.innerHTML='
'+title+'
'+html+'
'; + s.querySelector('.fd-msheet-cancel').addEventListener('click',()=>{const E=window.E;this.closeSheet();if(E&&SM._i>=0&&E.blocks[SM._i])E._focusBid(E.blocks[SM._i].id);}); + s.addEventListener('click',handler); + document.body.appendChild(s);this._sheet=s;}, + _insertHtml(){let h='';for(const g of CMDS){if(!g.items||!g.items.length)continue; + h+='
'+(_GROUP_LABEL[g.name]||g.name)+'
'; + g.items.forEach(c=>{h+=SM._slashItemHtml(c);});}return h;}, + _bmAct(ev){if(!ev.target||!ev.target.closest||!ev.target.closest('[data-bm-act]'))return;_blockMenuClick(ev);this.closeSheet();}, + act(k,btn){ + const E=window.E;if(!E)return; + const idx=SM._i;const b=(idx>=0&&idx{const it=ev.target.closest('.slash-item');if(it)SM.sel(it.dataset.sid);});return;} + if(k==='turn'){this.sheet('Turn into',_blockTurnItems(bid),ev=>this._bmAct(ev));return;} + if(k==='color'){this.sheet('Color',_blockColorItems(bid),ev=>this._bmAct(ev));return;} + if(k==='more'){const el=E.getEl(bid);const r=el?el.getBoundingClientRect():btn.getBoundingClientRect(); + this.close();blockMenuOpen(Math.max(8,Math.min(r.left,window.innerWidth-250)),Math.min(r.bottom+6,window.innerHeight-120),bid);return;} + if(k==='kbd'){const a=document.activeElement;if(a&&a.blur)a.blur();return;} + if(!b)return; + if(k==='at'){const el=E.getEl(bid);if(!el)return;el.focus();document.execCommand('insertText',false,'@'); + E.dirty=true;E.autoSave();E.openMention(el);return;} + if(k==='comment'){E.commentBlock(idx);return;} + if(k==='image'){E.applySlash('image');return;} + if(k==='trash'){E.deleteSelected(idx);return;} + if(k==='up'||k==='down'||k==='left'||k==='right'){E.moveBlock(bid,k);return;} + if(k==='undo'){E.undo();return;} + if(k==='redo'){E.redo();return;} + } + }; + // ═══════════ v5.11.0 Wiki-link picker ([[ → page search) ═══════════ const WM={_o:false,_idx:-1,_s:0,_it:[],_root:null,_in:null,_li:null,_pv:null, mk(){ if(this._root)return this._root; @@ -1125,8 +1187,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON. d.style.left=x+'px';d.style.top=y+'px'; return d; } - function _blockTurnMenuOpen(x,y,bid){ - var d=_blockSubShell(x,y); + function _blockTurnItems(bid){ 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=''; @@ -1136,10 +1197,13 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON. title:t.cols?('Create '+t.cols+' columns of blocks'):'', check:on,current:on}); }); - d.innerHTML=html; + return html; } - function _blockColorMenuOpen(x,y,bid){ + function _blockTurnMenuOpen(x,y,bid){ var d=_blockSubShell(x,y); + d.innerHTML=_blockTurnItems(bid); + } + function _blockColorItems(bid){ 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'; @@ -1167,7 +1231,11 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON. _BLOCK_COLOR_NAMES[n]+' background',{bid:bid,colorKey:'bgColor',colorVal:v, check:v===curB,current:v===curB}); }); - d.innerHTML=html; + return html; + } + function _blockColorMenuOpen(x,y,bid){ + var d=_blockSubShell(x,y); + d.innerHTML=_blockColorItems(bid); } function _blockSkillsMenuOpen(x,y,bid){ var d=_blockSubShell(x,y); @@ -1750,6 +1818,35 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu this.showToast('FlowDeck AI : compétence en cours…'); this._aiWritingRun('write',idx,{prompt:String(tpl||'')+'\n\nContenu du bloc :\n'+b.content},'insert'); }, + // v7.63 : déplacement de bloc depuis la barre mobile + // (↑↓ = ordre, → = indent dans le frère précédent, ← = sortir du parent) + moveBlock(bid,dir){ + const top=this.blocks.findIndex(b=>b.id===bid); + const pi=this.blocks.findIndex(b=>Array.isArray(b.children)&&b.children.some(c=>c.id===bid)); + const parent=pi>=0?this.blocks[pi]:null; + const list=parent?parent.children:this.blocks; + const k=list.findIndex(b=>b.id===bid); + if(k<0){this.showToast('Bloc introuvable','error');return;} + if(dir==='up'||dir==='down'){ + const j=dir==='up'?k-1:k+1; + if(j<0||j>=list.length){this.showToast(dir==='up'?'Déjà en haut':'Déjà en bas','error');return;} + this.sync();this.pushHistory(); + const mv=list.splice(k,1)[0];list.splice(j,0,mv); + }else if(dir==='right'){ + if(parent){this.showToast('Déjà indenté','error');return;} + const prev=this.blocks[top-1]; + if(!prev||!Array.isArray(prev.children)||(prev.type!=='toggle'&&prev.type!=='columns')){this.showToast('Pas de bloc cible à droite','error');return;} + this.sync();this.pushHistory(); + if(prev.type==='toggle')prev.expanded=true; + const mv=this.blocks.splice(top,1)[0];prev.children.push(mv); + }else{ + if(!parent){this.showToast('Déjà à la racine','error');return;} + this.sync();this.pushHistory(); + const mv=parent.children.splice(k,1)[0]; + this.blocks.splice(pi+1,0,mv); + } + this.dirty=true;this.autoSave();this.render();_rtSync();this._focusBid(bid); + }, applyLastColor(idx){ let v='';try{v=localStorage.getItem('fdLastBlockColor')||'';}catch(e){} const i=v.indexOf('|'); @@ -2225,7 +2322,9 @@ applyAIBlocks(text){ if(e.key==='Escape'){e.preventDefault();window.AIAC.clear();return;} if(e.key==='Enter'||e.key==='Backspace'){window.AIAC.clear();} } - if(e.key.length===1||e.key==='Backspace'||e.key==='Delete'){const i2=idx;setTimeout(()=>{const e2=this.getEl(this.blocks[i2]?.id);if(e2&&e2.textContent?.trim()==='/'&&!SM.isOpen&&!WM.isOpen)SM.open(i2,e2); + if(e.key.length===1||e.key==='Backspace'||e.key==='Delete'){const i2=idx;setTimeout(()=>{const e2=this.getEl(this.blocks[i2]?.id); + if(e2&&e2.textContent?.trim()==='/'&&!SM.isOpen&&!WM.isOpen){SM.open(i2,e2);} + else if(SM.isOpen){const t=(e2&&e2.textContent)||'';if(t.charAt(0)!=='/')SM.close();} if(e2&&!SM.isOpen&&!WM.isOpen){const t=gtTok(e2)||'';const mm=t.match(/\[\[$/);if(mm){WM.open(i2,e2);}}},15);} if(e.key===' '){ if(block.type==='paragraph'&&!(block.content||'').trim()&&!(el.textContent||'').trim()&&!SM.isOpen&&window.AIC){ diff --git a/tests/test_v761_block_menu.py b/tests/test_v761_block_menu.py index ee67886..c915ef3 100644 --- a/tests/test_v761_block_menu.py +++ b/tests/test_v761_block_menu.py @@ -60,7 +60,9 @@ def test_actions_are_all_dispatched(): # ── Sous-menus ────────────────────────────────────────────────────────────── def test_turn_into_submenu(): - turn = _slice("function _blockTurnMenuOpen(", "\n function _blockColorMenuOpen(") + # v7.63 : le générateur d'items a été extrait (_blockTurnItems) pour être + # réutilisé par la feuille mobile — le test vise le générateur. + turn = _slice("function _blockTurnItems(", "\n function _blockColorItems(") assert "_BLOCK_TURN_MENU" in turn and "check:on" in turn # ✓ du type actuel assert "'✓'" in SRC for name in ("Text", "Heading 1", "Heading 4", "Bulleted list", @@ -75,7 +77,7 @@ def test_turn_into_submenu(): def test_color_submenu(): - color = _slice("function _blockColorMenuOpen(", "\n function _blockSkillsMenuOpen(") + color = _slice("function _blockColorItems(", "\n function _blockSkillsMenuOpen(") for group in ("Last used", "Text color", "Background color"): assert f">{group}" in color, group names = ["'Default'", "'Gray'", "'Brown'", "'Orange'", "'Yellow'", diff --git a/tests/test_v763_mobile_menus.py b/tests/test_v763_mobile_menus.py new file mode 100644 index 0000000..9e57fe1 --- /dev/null +++ b/tests/test_v763_mobile_menus.py @@ -0,0 +1,136 @@ +"""7.63.0 — menus mobiles façon Notion : barre horizontale au « / » + feuille plein écran.""" + +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(src: str, start: str, end: str) -> str: + i = src.index(start) + return src[i:src.index(end, i)] + + +# ── Barre horizontale : les 15 boutons, dans l'ordre de la description ───── + +def test_bar_items_in_order(): + chunk = _slice(SRC, "const MTB={", "open(i,el){const d=this.el()") + order = ["['plus'", "['turn'", "['at'", "['comment'", "['image'", "['trash'", + "['left'", "['right'", "['up'", "['down'", "['undo'", "['redo'", + "['color'", "['more'", "['kbd'"] + positions = [chunk.index(o) for o in order] + assert positions == sorted(positions), positions + # les 4 entrées « texte + chevron » de la description + for w in ("'plus'", "'turn'", "'color'", "'more'"): + assert w in chunk, w + assert "fd-mtb-wide" in chunk and "fd-mtb-caret" in chunk + + +# ── Chaque bouton → une action réelle (pas de bouton mort) ───────────────── + +def test_bar_actions_are_real(): + act = _slice(SRC, "act(k,btn){", "\n };") + for token in ("sheet('Insert block'", "sheet('Turn into'", "sheet('Color'", + "blockMenuOpen(", "a.blur()", "E.openMention(el)", + "E.commentBlock(idx)", "E.applySlash('image')", "E.deleteSelected(idx)", + "E.moveBlock(bid,k)", "E.undo()", "E.redo()"): + assert token in act, token + # « More » ouvre bien le menu contextuel de bloc existant (desktop) + assert "this.close();blockMenuOpen(" in act + + +# ── Feuille plein écran : titre centré + Cancel + listes réutilisées ─────── + +def test_fullscreen_sheet(): + sheet = _slice(SRC, "sheet(title,html,handler){", "_insertHtml(){") + for token in ("fd-msheet-head", "fd-msheet-title", "fd-msheet-cancel", "Cancel", + "fd-msheet-list block-menu"): + assert token in sheet, token + # contenu : items slash partagés + sous-menus de bloc partagés + ins = _slice(SRC, "_insertHtml(){", "_bmAct(") + assert "_GROUP_LABEL[g.name]" in ins and "_slashItemHtml" in ins + assert "function _blockTurnItems(bid)" in SRC and "function _blockColorItems(bid)" in SRC + # choix d'un item → applySlash via SM.sel (ferme tout) + assert "SM.sel(it.dataset.sid)" in SRC + + +# ── Branchement sur le slash : mobile = barre, desktop = popup intacte ───── + +def test_mobile_hook(): + for token in ("function _fdMobile()", "max-width: 768px", + "if(_fdMobile()){SM._o=true;MTB.open(i,el);return;}", + "MTB.close();", "!MTB.contains(ev.target)", + "else if(SM.isOpen)"): + assert token in SRC, token + # le popup desktop (positionnement + focus de la recherche) est toujours là + assert "SM._e.style.display='flex'" in SRC and "SM._in.focus()" in SRC + # la barre se referme quand le / disparaît + assert "if(t.charAt(0)!=='/')SM.close();" in SRC + + +# ── Déplacement de bloc (↑↓ ordre, → indent, ← outdent) ──────────────────── + +def test_move_block_logic(): + m = _slice(SRC, "moveBlock(bid,dir){", "applyLastColor(idx){") + for token in ("dir==='up'", "dir==='down'", "dir==='right'", "parent.children", + "pushHistory()", "this.sync()", "_focusBid(bid)"): + assert token in m, token + + +# ── Styles ──────────────────────────────────────────────────────────────── + +def test_styles(): + for sel in (".fd-mtb {", ".fd-mtb .fd-mtb-item {", ".fd-mtb .fd-mtb-wide {", + ".fd-msheet {", ".fd-msheet-head {", ".fd-msheet-cancel {", + ".fd-msheet-list.block-menu {", ".fd-msheet .bm-item {", + ".fd-msheet .slash-item {"): + assert sel in CSS, sel + + +# ── Comportement réel : moveBlock sous node ─────────────────────────────── + +def test_move_block_for_real(): + node = shutil.which("node") + if not node: + pytest.skip("node absent : test comportemental ignoré") + method = _slice(SRC, "moveBlock(bid,dir){", "applyLastColor(idx){") + script = f""" +function _rtSync(){{}} +const E = {{ + blocks:[{{id:'a',type:'paragraph',content:'A'}}, + {{id:'tg',type:'toggle',content:'T',expanded:true,children:[]}}, + {{id:'c',type:'paragraph',content:'C'}}], + sync(){{}}, pushHistory(){{}}, dirty:false, autoSave(){{}}, render(){{}}, + _focusBid(){{}}, showToast(){{}}, msgs:[], + {method} +}}; +E.moveBlock('c','right'); // C s'indente dans le toggle +const indent = {{kids:E.blocks[1].children.map(b=>b.id).join(','), top:E.blocks.map(b=>b.id).join(',')}}; +E.moveBlock('c','left'); // C ressort à la racine : a,tg,c +const top2 = E.blocks.map(b=>b.id).join(','); +E.moveBlock('a','down'); // A descend : tg,a,c +const ordre1 = E.blocks.map(b=>b.id).join(','); +E.moveBlock('c','up'); // C remonte : tg,c,a +const ordre2 = E.blocks.map(b=>b.id).join(','); +E.moveBlock('c','up'); // encore : c,tg,a +const ordre3 = E.blocks.map(b=>b.id).join(','); +E.moveBlock('c','up'); // déjà en haut → toast, inchangé +const ordre4 = E.blocks.map(b=>b.id).join(','); +console.log(JSON.stringify({{indent:indent, top2:top2, ordre1:ordre1, ordre2:ordre2, ordre3:ordre3, ordre4:ordre4}})); +""" + out = subprocess.run([node, "-e", script], capture_output=True, text=True, + timeout=30, encoding="utf-8") + assert out.returncode == 0, out.stderr + d = json.loads(out.stdout) + assert d["indent"] == {"kids": "c", "top": "a,tg"}, d + assert d["top2"] == "a,tg,c", d + assert d["ordre1"] == "tg,a,c", d + assert d["ordre2"] == "tg,c,a", d + assert d["ordre3"] == "c,tg,a", d + assert d["ordre4"] == "c,tg,a", d # garde « déjà en haut »