feat: menus mobiles façon Notion — barre horizontale au « / » + Insert block plein écran (v7.63.0)
- 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)
This commit is contained in:
@@ -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 "'<span class=\"bm-check\">✓</span>'" 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}</div>" in color, group
|
||||
names = ["'Default'", "'Gray'", "'Brown'", "'Orange'", "'Yellow'",
|
||||
|
||||
@@ -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 »
|
||||
Reference in New Issue
Block a user