- 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)
173 lines
8.2 KiB
Python
173 lines
8.2 KiB
Python
"""7.61.0 — menu contextuel de bloc refondu façon Notion (actions + raccourcis)."""
|
|
|
|
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(start: str, end: str) -> str:
|
|
i = SRC.index(start)
|
|
j = SRC.index(end, i)
|
|
return SRC[i:j].rstrip()
|
|
|
|
|
|
# ── Panneau de gauche ───────────────────────────────────────────────────────
|
|
|
|
def test_left_panel_matches_spec():
|
|
# recherche, groupe « Text », les 10 actions dans l'ordre, pied métadonnées
|
|
assert 'placeholder="Search actions..."' in SRC
|
|
assert "'<div class=\"bm-group\">Text</div>'" in SRC
|
|
order = ["'↩','Turn into'", "'🎨','Color'", "'🔗','Copy link to block'",
|
|
"'▣','Duplicate'", "'↪','Move to…'", "'✕','Delete'",
|
|
"'💬','Comment'", "'✎','Suggest edits'", "'✨','Ask AI'",
|
|
"'📚','Skills'"]
|
|
chunk = SRC[src_index("d.innerHTML='<input"):src_index("+_blockMenuMeta(b);")]
|
|
positions = [chunk.index(o) for o in order]
|
|
assert positions == sorted(positions), positions
|
|
# pied de page : auteur / heure / taille
|
|
meta = _slice("function _blockMenuMeta(", "\n function blockMenuOpen(")
|
|
for token in ("Last edited by ", "word", "character", "Today at ",
|
|
".um-name", "updatedAt"):
|
|
assert token in meta, token
|
|
|
|
|
|
def src_index(needle: str) -> int:
|
|
return SRC.index(needle)
|
|
|
|
|
|
def test_actions_are_all_dispatched():
|
|
dispatch = _slice("function _blockMenuClick(", "\n function _blockMenuItem(")
|
|
for act in ("submenu", "duplicate", "copy-link", "move-to", "delete",
|
|
"comment", "suggest", "ai", "skill", "color", "turn-type"):
|
|
assert f"act==='{act}'" in dispatch, act
|
|
# méthodes réelles côté éditeur
|
|
# v7.62 : « askAI(idx) » renommé askAIBlock(idx) — « askAI() » l'écrasait
|
|
for m in ("commentBlock(idx)", "askAIBlock(idx)", "suggestEdit(idx)",
|
|
"runSkill(idx,tpl)", "applyLastColor(idx)",
|
|
"copyBlockLink(bid)", "deleteSelected(idx)", "duplicateBlock("):
|
|
assert m in SRC, m
|
|
# menu : pas d'entrée « copier/couper/coller les blocs » (raccourcis conservés)
|
|
assert "act==='copy-blocks'" not in dispatch
|
|
|
|
|
|
# ── Sous-menus ──────────────────────────────────────────────────────────────
|
|
|
|
def test_turn_into_submenu():
|
|
# 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",
|
|
"Numbered list", "To-do list", "Toggle list", "Code", "Quote",
|
|
"Callout", "Block equation", "Synced block",
|
|
"2 columns", "5 columns"):
|
|
assert f"'{name}'" in SRC, name
|
|
assert "Create '+t.cols+' columns of blocks" in SRC # info-bulle
|
|
assert "data-cols" in SRC and "cols>=2" in SRC # 2..5 colonnes
|
|
# Page / Page in / Toggle heading : types inexistants dans FlowDeck → absents
|
|
assert "'Page in'" not in SRC and "'Toggle heading 1'" not in SRC
|
|
|
|
|
|
def test_color_submenu():
|
|
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'",
|
|
"'Green'", "'Blue'", "'Purple'", "'Pink'", "'Red'"]
|
|
arr = _slice("const _BLOCK_COLOR_NAMES=", "];")
|
|
for n in names:
|
|
assert n in arr, n
|
|
assert "bm-dot" in color # carré de couleur (fond)
|
|
assert "_BM_SHORTCUTS.color" in color # raccourci « Last used »
|
|
assert "fdLastBlockColor" in SRC # mémoire de la dernière couleur
|
|
|
|
|
|
def test_skills_submenu_uses_real_skills():
|
|
skills = _slice("function _blockSkillsMenuOpen(", "\n function _rtMac(")
|
|
assert "fetch('/api/agent/skills'" in skills
|
|
assert "act==='skill'" in SRC or "'skill'" in skills
|
|
assert "prompt_template" in skills
|
|
assert "_aiWritingRun('write',idx" in SRC # exécution réelle via l'IA
|
|
|
|
|
|
# ── Survol / fermeture ──────────────────────────────────────────────────────
|
|
|
|
def test_hover_submenus_and_close():
|
|
for token in ("_blockSubOpen(kind,bid,anchor)", "data-sub",
|
|
"closest('[data-sub]')", "mouseleave", "_blockSubCloseLater",
|
|
"function _blockMenuClose()", "_blockDocDown"):
|
|
assert token in SRC, token
|
|
assert "si.focus()" in SRC # recherche focalisée à l'ouverture
|
|
|
|
|
|
# ── Raccourcis ──────────────────────────────────────────────────────────────
|
|
|
|
def test_shortcuts_implemented():
|
|
keys = _slice("// ── v7.61 : raccourcis du menu contextuel de bloc ──",
|
|
"document.addEventListener('keydown', function (e) {\n if (!(e.ctrlKey || e.metaKey)")
|
|
combos = {
|
|
"k === 'j'": "Ctrl+J (Ask AI)",
|
|
"k === 'h'": "Ctrl+⇧+H (dernière couleur)",
|
|
"k === 'm'": "Ctrl+⇧+M (commenter)",
|
|
"k === 'x'": "Ctrl+⇧+Alt+X (suggest edits)",
|
|
"k === 'l'": "Alt+⇧+L (copier le lien)",
|
|
"k === 'p'": "Ctrl+⇧+P (déplacer vers…)",
|
|
"k === 'd'": "Ctrl+D (dupliquer)",
|
|
"k === 'delete'": "Del (supprimer, menu ouvert uniquement)",
|
|
"k === 'escape'": "Échap (fermer)",
|
|
}
|
|
for token, label in combos.items():
|
|
assert token in keys, label
|
|
# Del n'agit jamais pendant la saisie
|
|
assert "tgt.isContentEditable" in keys
|
|
# le libellé affiché dans le menu correspond aux combinaisons réelles
|
|
shortcuts = _slice("const _BM_SHORTCUTS=", ";")
|
|
for hint in ("Alt+⇧+L", "Ctrl+D", "Ctrl+⇧+P", "Del", "Ctrl+⇧+M",
|
|
"Ctrl+⇧+Alt+X", "Ctrl+J", "Ctrl+⇧+H"):
|
|
assert hint in shortcuts, hint
|
|
|
|
|
|
# ── Comportement réel : conversion en colonnes (node) ───────────────────────
|
|
|
|
def test_turn_into_columns_creates_children_for_real():
|
|
node = shutil.which("node")
|
|
if not node:
|
|
pytest.skip("node absent : test comportemental ignoré")
|
|
types = _slice("const _BLOCK_TURN_TYPES=[", "];")
|
|
method = _slice("turnInto(idx,type", "setBlockColor(idx,key,val){")
|
|
script = f"""
|
|
function _rtSync(){{}}
|
|
function _selClear(){{}}
|
|
var _n=0; function genId(){{return 'g'+(++_n);}}
|
|
{types}];
|
|
const E = {{
|
|
blocks:[{{id:'b1',type:'paragraph',content:'Titre'}}],
|
|
_blockTargets(i){{return [i];}}, sync(){{}}, pushHistory(){{}}, dirty:false,
|
|
autoSave(){{}}, render(){{}}, getEl(){{return null;}}, _pickSyncedBlock(){{}},
|
|
{method}
|
|
}};
|
|
E.turnInto(0,'columns',3);
|
|
console.log(JSON.stringify({{type:E.blocks[0].type, kids:E.blocks[0].children.length}}));
|
|
"""
|
|
out = subprocess.run([node, "-e", script], capture_output=True, text=True,
|
|
timeout=30, encoding="utf-8")
|
|
assert out.returncode == 0, out.stderr
|
|
assert json.loads(out.stdout) == {"type": "columns", "kids": 3}
|
|
|
|
|
|
# ── Styles ──────────────────────────────────────────────────────────────────
|
|
|
|
def test_menu_styles():
|
|
for sel in (".block-menu .bm-group {", ".block-menu .bm-foot {",
|
|
".block-menu .bm-check {", ".block-menu .bm-dot {",
|
|
".block-menu-sub { min-width: 230px; max-height: 60vh;"):
|
|
assert sel in CSS, sel
|