Compare commits

...
12 Commits
Author SHA1 Message Date
bruno 699165469d feat: menus mobiles façon Notion — barre horizontale au « / » + Insert block plein écran (v7.63.0)
FlowDeck CI / lint (push) Successful in 1m32s
FlowDeck CI / test (push) Failing after 21m41s
FlowDeck CI / docker (push) Skipped
- 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)
2026-10-08 09:36:37 -04:00
bruno 3a6445ca4e feat: menu contextuel au highlight de texte — toolbar 4 lignes façon Notion (v7.62.0)
FlowDeck CI / lint (push) Successful in 1m32s
FlowDeck CI / test (push) Failing after 20m17s
FlowDeck CI / docker (push) Skipped
- Toolbar de sélection sous le surlignage : ligne de style (T + type de bloc
  dynamique → sous-menu Turn into), A/B/I/U/T barré/Tx, lien/surlignage/
  code/équation/…, Comment + Ask AI, section grise Skills (4 actions) et pied
  « Edit with AI » (Alt+⇧+E) ; positionnée sous la sélection (au-dessus si
  manque de place), fermée au clic hors sélection
- « … » → menu contextuel de bloc existant (recherche « Search actions… »,
  groupe Text, 10 actions + raccourcis, pied auteur/heure/taille)
- Info-bulle claire à droite des éléments du menu : data-tooltip-html +
  classe .tooltip-light dans le tooltip global (app.js)
- Persistance du formatage inline : sérialiseur gtMd() (DOM formaté → source
  markdown) branché sur sync() ; mdEsc() rend __…__ (u), ==…== (mark) et
  $$…$$ (équation inline KaTeX, data-tex conservé)
- Fixes racine : askAI(idx) écrasé par askAI() → askAIBlock(idx) (menu de bloc,
  Ctrl+J, Alt+⇧+E) ; ed.__x (Alpine v2) inexistant sous v3 → _xed() via
  _x_dataStack[0] (showFmt/hideCommentBtn/mentions ne s'exécutaient jamais) ;
  ancre de sélection nœud élément gérée ; checkMd() appelé avec un id au lieu
  de l'index (exception à chaque barre d'espace)
- Tests : tests/test_v762_selection_toolbar.py (roundtrip gtMd/mdEsc sous node)
  + e2e/v762_selection_toolbar.spec.js (structure, sous-menus, géométrie,
  persistance bold/highlight après reload) ; suite complète 1368 passed / 0
  failed, ruff OK, OpenAPI régénérée 7.62.0 (525 chemins)
2026-10-08 08:39:39 -04:00
bruno 033490c464 feat: menu contextuel de bloc refondu façon Notion — 10 actions, sous-menus et raccourcis (v7.61.0)
FlowDeck CI / lint (push) Successful in 2m16s
FlowDeck CI / test (push) Failing after 20m32s
FlowDeck CI / docker (push) Skipped
- panneau de gauche : recherche « Search actions... », section « Text », les
  10 actions dans l'ordre Notion (Turn into, Color, Copy link Alt+⇧+L,
  Duplicate Ctrl+D, Move to Ctrl+⇧+P, Delete Del, Comment Ctrl+⇧+M,
  Suggest edits Ctrl+⇧+Alt+X, Ask AI Ctrl+J, Skills) + pied métadonnées
  (auteur / heure de dernière édition / mots-caractères du bloc)
- panneau de droite au survol (fermeture différée) : Turn into avec ✓ du
  type actuel + Heading 1-4, listes, To-do, Toggle, Code, Quote, Callout,
  Block equation, Synced block, 2/3/4/5 columns (info-bulle « Create N
  columns of blocks ») ; Color avec Last used / Text color / Background
  color (10 couleurs nommées, ✓ courante, carré de couleur) ; Skills =
  compétences réelles de l'espace appliquées au bloc via l'IA
- turnInto(idx,type,cols) : crée N colonnes, sélecteur de source pour les
  blocs synchronisés ; setBlockColor mémorise la dernière couleur (Ctrl+⇧+H)
- nouvelles actions éditeur : commentBlock (sélection + tiroir), askAI
  (composeur sans effacer le bloc), suggestEdit / runSkill (IA, insertion
  sous le bloc), applyLastColor
- raccourcis : handler clavier dédié (Ctrl+J, Ctrl+⇧+H, Ctrl+⇧+M,
  Ctrl+⇧+Alt+X, Alt+⇧+L, Ctrl+⇧+P, Ctrl+D sans double, Del seulement avec le
  menu ouvert, Échap) — les entrées Copy/Cut/Paste du menu sont retirées
  (Ctrl+C/X/V conservés)
- styles : .bm-group, .bm-foot, .bm-check, .bm-current, .bm-dot, sous-menu
  60vh, recherche focalisée à l'ouverture
- tests : tests/test_v761_block_menu.py (9 tests, dont node : colonnes) +
  test_v7593 adapté ; suite complète 1362 passed / 0 failed, ruff 0, eslint 0
- livraison : VERSION + app/main = 7.61.0, OpenAPI 525 chemins, CHANGELOG
2026-10-07 23:09:21 -04:00
bruno f132885b08 feat: bouton + à gauche du handle de bloc — insère un bloc dessous (v7.60.0)
FlowDeck CI / lint (push) Successful in 2m15s
FlowDeck CI / test (push) Failing after 19m27s
FlowDeck CI / docker (push) Skipped
- nouveau bouton .block-insert-btn émis AVANT le handle (6 points) dans le
  wrapper de chaque bloc de premier niveau : clic → E.addAfter(idx) →
  addAt(idx+1) → bloc inséré sous le courant et focusé (mécanisme déjà en
  place, réutilisé tel quel)
- position : left:-64px (gouttière .blocks-container = 64px), -56px dans le
  breakpoint où la gouttière passe à 32px ; apparaît au survol/focus comme le
  handle ; masqué avec lui sur écran étroit (2 breakpoints)
- aria-label + title « Add block below »
- tests : tests/test_v760_block_insert_btn.py (2 tests : ordre dans le
  wrapper, câblage E.addAfter, positions/masquages CSS)
- livraison : VERSION + app/main = 7.60.0, OpenAPI 525 chemins, CHANGELOG
2026-10-07 22:25:27 -04:00
bruno a79bdc1641 fix: « Turn into » du menu contextuel ne fonctionnait pas (v7.59.3)
FlowDeck CI / lint (push) Successful in 2m20s
FlowDeck CI / test (push) Failing after 3h12m11s
FlowDeck CI / docker (push) Skipped
- cause : la garde de turnInto() faisait _BLOCK_TURN_TYPES.indexOf(type) sur
  un tableau d'objets {id,name} → toujours -1 → retour immédiat après
  l'ouverture du sous-menu, aucun changement de format
- correction : validation par id (_BLOCK_TURN_TYPES.some(t=>t.id===type))
- même fonction : convertir un texte en To-do / Toggle vidait le contenu
  (exclusion par erreur) ; seuls les types sans champ texte (image, embed,
  meeting, divider, table_of_contents, database, button, columns) le vident
- tests : tests/test_v7593_turn_into.py (4 tests, dont 1 comportemental node
  qui exécute la vraie turnInto() extraite de la source)
- livraison : VERSION + app/main = 7.59.3, OpenAPI 525 chemins, CHANGELOG
2026-10-07 21:32:22 -04:00
bruno a67bd252aa fix: aide (bouton ✕), Insert below qui perdait le format, listes numérotées invisibles (v7.59.2)
FlowDeck CI / lint (push) Successful in 2m16s
FlowDeck CI / test (push) Failing after 18m25s
FlowDeck CI / docker (push) Skipped
- aide : bouton ✕ en haut à droite du panneau (classe partagée
  settings-close, 44 px mobile) + helpInit.close() (retour en arrière,
  sinon /workspaces) ; .settings-panel en position:relative pour ancrer
  hamburger + ✕ au panneau
- éditeur « Insert below » (/Write with AI) : insère le markdown source
  conservé à la génération (AIC._md) au lieu du texte extrait du HTML rendu
  (_resultText() reste seulement en repli) → titres/listes/gras conservés
- listes numérotées : aucun numéro n'était affiché (CSS = retrait seul) ;
  le rang des blocs numbered_list contigus est calculé au rendu (data-num)
  et rendu via content: attr(data-num) — /numbered list et l'insertion
  agent affichent 1, 2, 3
- md2b : cases à cocher - [ ] / - [x] testées avant la branche des puces
  (aligné sur paste2b) + séparateur 1) accepté (CommonMark)
- menu slash : requête tapée dans le bloc (/numbered list) répercutée dans
  le filtre du menu quand le focus n'a pas rejoint l'entrée
- tests : tests/test_v7592_ui_fixes.py (5 tests, dont 1 comportemental node
  qui exécute le vrai md2b) ; ruff I001 préexistant sur test_agent.py
- livraison : VERSION + app/main = 7.59.2, OpenAPI 525 chemins, CHANGELOG
2026-10-07 20:29:02 -04:00
bruno 39d34ac866 fix(llm): Mistral 403 tier_not_allowed — défauts tous-plans + sonde chat + migration 36 (v7.59.1)
FlowDeck CI / lint (push) Failing after 2m8s
FlowDeck CI / test (push) Failing after 20m4s
FlowDeck CI / docker (push) Skipped
La clé fonctionne: /v1/models 200 + chat 200 (vérifié live). L'échec venait
du modèle: mistral-large-latest (défaut PROVIDERS + tête de liste) n'est pas
servi par les plans d'abonnement basiques → 403 code 1910 « tier_not_allowed »,
et le Test connection sélectionnait systématiquement ce premier candidat.

- PROVIDERS mistral default → mistral-small-latest (tous plans)
- PROVIDER_MODELS réordonnée: tous-plans d'abord (ordre du test de connexion)
- mistral ∈ _CHAT_VALIDATED_PROVIDERS: le fetch modèles ne garde que les
  modèles réellement servis (46 listés → 25 utilisables avec la clé du compte)
- migration 36: default_model bloqué (large/pixtral-large) reset en base

Test live déployé: {ok:true, model:mistral-small-latest, reply:PONG,
verified:true} · tests/test_agent.py 53/53 · nouveau test d'ancrage
test_mistral_defaults_are_tier_safe
2026-10-07 16:25:04 -04:00
bruno efa3d57e93 feat(mobile): side-nav settings + scroll drawer + aide refondue settings-style (v7.59.0)
FlowDeck CI / lint (push) Successful in 2m15s
FlowDeck CI / test (push) Failing after 23m33s
FlowDeck CI / docker (push) Skipped
- Sidebar mobile: display:block du breakpoint ≤768px cassait flex:1 de
  .sidebar-scroll → molette/touch incapables de scroller le drawer. Flex
  restauré. Gate: wheel scroll scrollTop > 0.
- Settings mobile: nav horizontale remplacée par une side-navigation gauche
  drawer (hamburger .settings-menu-btn + backdrop, fermeture auto au choix).
  Squelette panneau extrait vers static/css/settings.css (partagé).
- /help refaite au complet: template help.html (13 sections couvrant toute
  l'app — éditeur, databases/vues, tâches, workspaces, collab, agent IA,
  API v2, PWA, SSO, raccourcis, tips) avec le même panneau 2 colonnes +
  drawer mobile; route réduite à un render; help.js (Alpine.data helpInit).

E2E mobile_regression 6/6 + smoke 2/2 · pytest app+pwa 230/230
2026-10-07 15:36:05 -04:00
bruno 992200a90a fix(mobile): hamburger ouvrait sidebarCollapsed au lieu du drawer + layout settings responsive (v7.58.1)
FlowDeck CI / lint (push) Successful in 2m15s
FlowDeck CI / test (push) Failing after 21m14s
FlowDeck CI / docker (push) Skipped
Le handler du hamburger était une expression Alpine avec un effet de bord
dans un ternaire (sidebarCollapsed ? toggleSidebar() : (mobileSidebarOpen = true, ...)).
Évaluée sans eval dans le build CSP d'Alpine, elle ne produisait aucun effet
visible sur mobile: le bouton ne faisait rien (prouvé via Alpine.evaluate@390px,
open=false). Remplacée par la méthode fdHamburgerClick() (matchMedia 768px:
drawer mobile, sinon toggle desktop).

Settings: panel 1050px + nav verticale 200px inutilisable sur 390px →
@media 768px: plein écran (dvh), nav en onglets horizontaux scrollables,
rows en colonne, prov-grid 1 colonne, close 44px.

+ gate e2e mobile_regression.spec.js (drawer open/close + viewport settings)
2026-10-07 14:27:33 -04:00
bruno 21b96afa12 merge: origin/develop → main (contenu déjà intégré, arbre identique)
FlowDeck CI / lint (push) Successful in 2m16s
FlowDeck CI / test (push) Failing after 18m47s
FlowDeck CI / docker (push) Skipped
Vérification : merge-tree propre, diff de l'arbre fusionné vs main vide —
les 4 fichiers de 0f86294 sont déjà en main. Merge d'alignement de branche.
2026-10-07 12:08:32 -04:00
bruno 16f73fe39e feat: Add plugins — catalogue on/off à effet réel (v7.58.0, phase 8/8)
FlowDeck CI / lint (push) Successful in 2m14s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 2h7m52s
- app/services/plugins.py + migration 35 : table plugins (slug, name,
  description, enabled) pré-remplie avec 3 modules câblés — web-tools,
  web-clipper, automations ; ligne absente = activé (défaut sûr)
- automations OFF → dépendance FastAPI posée à l'include_router dans main.py
  (aucun router touché) → toutes les routes /workspace/automations* refusées +
  garde de tick du scheduler en arrière-plan
- web-clipper OFF → GET /extensions et tout /api/v2/web-clipper/* refusés
- web-tools OFF → web_search et fetch_url retirés du schéma ET de execute()
  via ToolRegistry._all() : le LLM ne les voit plus
- UI rendue côté serveur : global Jinja plugin_enabled(slug) — nav
  « Extensions » / « Automations » en {% if %} (absentes du DOM), sections
  conditionnées en x-show dans settings.html
- menu + : l'entrée « Add plugins » devient vivante (fini disabled:true) —
  liste des 3 plugins avec bascule, GET/PATCH /api/agent/plugins[/slug]
  (slug inconnu → 404, 401 sans session)
- tests : tests/test_v758_plugins.py (10 tests) — routes refusées (302 hors
  /api, 404 JSON pour /api*), outils retirés, nav disparue, persistance,
  câblage ; assertions disabled:true == 0 dans les tests des phases 1/3/4/5/7
- livraison : VERSION + app/main = 7.58.0, OpenAPI 525 chemins, CHANGELOG,
  ROADMAP phase 8 cochée (menu + complet), avenant phase 8 (docs)
2026-10-07 10:19:25 -04:00
bruno 0f86294abc merge: main → develop (sync) 2026-07-20 11:02:42 -04:00
45 changed files with 3173 additions and 350 deletions
+305
View File
@@ -1,5 +1,310 @@
# 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
- **Toolbar de sélection** — dès qu'un texte est surligné dans l'éditeur, un menu
flottant apparaît **sous la sélection** (au-dessus si manque de place), centré et
clampé au viewport, en 4 lignes comme dans Notion :
1. **Style de texte** — `T` + libellé **dynamique** du type de bloc (Normal Text,
Heading 1… ; le type courant) + `›` → ouvre le sous-menu **Turn into** existant
(coche sur le type actuel, fermeture au survol sortant).
2. **A** (sous-menu Color : texte/fond + dernière couleur), **B** gras, *I* italique,
**U** souligné, **T** barré, **Tx** code inline.
3. 🔗 lien, **S** surlignage (`<mark>`), `</>` bloc code, **√x** équation inline
(prompt LaTeX → KaTeX), **…** → menu contextuel de bloc (recherche + 10 actions +
pied « Last edited by … / N words, M characters »).
4. 💬 Comment (`Ctrl+⇧+M`), ✨ Ask AI (`Ctrl+J`).
puis la section grise **Skills** (Improve writing, Proofread, Explain, Reformat →
`runSkill` sur le bloc) et le pied **Edit with AI** `Alt+⇧+E`.
- **Info-bulle claire à droite** des éléments du menu — extension du système de
tooltip existant (`app.js`) : `data-tooltip-html` (ex. en italique + description
pour la ligne 1) + classe `.tooltip-light`, position à droite de l'élément (débord
→ à gauche), sous l'élément hors toolbar.
- **Persistance du formatage inline** : nouveau sérialiseur **`gtMd()`** (DOM formaté
→ source markdown) appelé par `sync()` (blocs + enfants toggle/columns) — le
formatage posé ressort en `**gras**`, `*ital*`, `__souligné__`, `~~barré~~`,
`` `code` ``, `==surlignage==`, `[texte](url)` ; les chips wiki gardent leur token.
`mdEsc()` rend en plus `__…__` (`<u>`), `==…==` (`<mark>`) et `$$…$$`
(équation inline KaTeX, `data-tex` conservé pour l'aller-retour).
- **Fermeture propre** : clic hors sélection → le menu se referme ; clic **dans** la
toolbar → sélection et menu intacts (`@mousedown.prevent` + garde `mouseup`).
### Fixed
- **`askAI(idx)` était écrasé par `askAI()`** (même clé dans l'objet éditeur, définie
plus bas) : « Ask AI » du menu de bloc et `Ctrl+J` ouvraient l'IA de **page** au
lieu du composeur du bloc → renommé **`askAIBlock(idx)`** (appels : menu de bloc,
`Ctrl+J`, `Alt+⇧+E`).
- **`ed.__x` introuvable sous Alpine v3** — le data de `.page-editor-wrapper` était
lu via `ed.__x` (API Alpine v2, plus exposée depuis le bundle CSP) : `showFmt`,
`hideCommentBtn` et les handlers de mentions ne s'exécutaient **jamais** → la
toolbar de sélection n'apparaissait pas. Accès unifié via `_xed()`
(`_x_dataStack[0]`, repli `__x`), 4 sites éditeur.
- **Ancre de sélection** — `closest('[data-bid]')` partait du parent de l'ancre :
quand l'ancre est un nœud **élément** (sélection programmée, double-clic), le bloc
n'était jamais trouvé → `showFmt` pas appelé. Gestion des 2 types de nœud.
- **Barre d'espace = exception** — `checkMd()` recevait l'**id** du bloc au lieu de
l'index : chaque espace levait `Cannot read properties of undefined (reading 'id')`
et les raccourcis markdown (`# `, `- `) ne se déclenchaient jamais.
### Tests
- `tests/test_v762_selection_toolbar.py` : ordre des 4 lignes + Skills + pied,
câblage réel des actions, persistance `gtMd`/`mdEsc` (**roundtrip réel sous node**),
placement/fermeture, styles ; `tests/test_v761_block_menu.py` adapté
(`askAIBlock`).
- **e2e Playwright** `e2e/v762_selection_toolbar.spec.js` (instance 8081) : structure
des 4 lignes, sous-menu Turn into (coche du type courant), menu « … » (recherche +
pied), géométrie (sous la sélection, dans le viewport), **persistance du gras et du
surlignage après reload**, fermeture hors sélection → 1 passed (captures dans
`e2e/shots/` — répertoire gitignoré).
- **Suite complète** : `pytest tests/ -q -n auto` → **1368 passed / 0 failed**,
`ruff check app tests` → All checks passed.
## v7.61.0 (2026-10-08) — Menu contextuel de bloc refondu façon Notion
### Added
- **Panneau de gauche** : barre de recherche « Search actions... » (filtre en
direct), titre de section « Text », puis les 10 actions dans l'ordre Notion :
Turn into ›, Color ›, Copy link to block (`Alt+⇧+L`), Duplicate (`Ctrl+D`),
Move to (`Ctrl+⇧+P`), Delete (`Del`), Comment (`Ctrl+⇧+M`), Suggest edits
(`Ctrl+⇧+Alt+X`), Ask AI (`Ctrl+J`), Skills › — avec les raccourcis
affichés, et le pied de page « Last edited by … / Today at H:MM / N words,
M characters » (auteur lu dans le compte, heure = `updatedAt` du bloc page,
taille calculée sur le contenu du bloc).
- **Sous-menu « Turn into »** (survol du panneau de gauche → panneau de
droite, fermeture différée) : Text ✓ (type actuel coché), Heading 1-4,
Bulleted list, Numbered list, To-do list, Toggle list, Code, Quote,
Callout, Block equation, Synced block, et **2/3/4/5 columns** avec
info-bulle « Create N columns of blocks » ; `turnInto(idx, type, cols)`
crée désormais N colonnes et ouvre le sélecteur de source pour les blocs
synchronisés.
- **Sous-menu « Color »** : sections **Last used** (raccourci `Ctrl+⇧+H`),
**Text color** (Default, Gray, Brown, Orange, Yellow ✓, Green, Blue,
Purple, Pink, Red — glyphe « A » à la couleur) et **Background color**
(même palette avec carré de couleur) ; la couleur utilisée est cochée, la
dernière est mémorisée (`localStorage fdLastBlockColor`).
- **Sous-menu « Skills »** : liste réelle des compétences de l'espace
(`GET /api/agent/skills`) → applique le `prompt_template` au contenu du
bloc via l'IA et insère le résultat dessous.
- **Raccourcis** — handler clavier dédié : `Ctrl+J` (Ask AI), `Ctrl+⇧+H`
(dernière couleur), `Ctrl+⇧+M` (commenter le bloc : sélection du contenu +
tiroir de commentaires), `Ctrl+⇧+Alt+X` (suggérer une édition → résultat
inséré dessous), `Alt+⇧+L` (copier le lien), `Ctrl+⇧+P` (déplacer vers…),
`Ctrl+D` (dupliquer — déjà géré par la saisie, pas de double), `Del`
(**seulement avec le menu ouvert**, jamais pendant la saisie), `Échap`
(fermer).
- **Actions Suggest edits / Ask AI / Comment / Skills** réelles côté éditeur
(`suggestEdit`, `askAI`, `commentBlock`, `runSkill`) — Ask AI ouvre le
composeur inline sans effacer le contenu du bloc.
- **Styles** : `.bm-group`, `.bm-foot`, `.bm-check`, `.bm-current`,
`.bm-dot`, sous-menu `60vh` défilable, recherche focalisée à l'ouverture.
- **Tests** — `tests/test_v761_block_menu.py` : **9 tests** (structure et
ordre du panneau, dispatch des 10 actions, 3 sous-menus, survol/fermeture,
les 9 raccourcis, comportement node : `turnInto(…, 'columns', 3)` → 3
colonnes, styles) + `test_v7593` adapté à la nouvelle signature.
### Removed
- Entrées **Copy / Cut / Paste blocks** du menu (absentes de la spécification
Notion) — les raccourcis `Ctrl+C/X/V` sur blocs sélectionnés sont conservés.
- Pastilles de couleurs inline : remplacées par le sous-menu Color nommé
(palette portée à 10 couleurs alignées Notion : +Brown, Pink réel).
## v7.60.0 (2026-10-07) — Éditeur : bouton + à gauche du handle de bloc
### Added
- **Bouton `+` à gauche du handle** (les 6 points qui ouvrent le menu ⋮⋮ /
servent au drag) : clique → **nouveau bloc inséré sous le bloc courant**,
via le mécanisme déjà en place (`E.addAfter(idx)` → `addAt(idx+1)`).
- Positionné dans la gouttière (`left:-64px`, gouttière `.blocks-container`
de 64px, `-56px` quand elle passe à 32px), apparaît au survol/focus du bloc
comme le handle, masqué avec lui sur écran étroit (≤768px et le second
breakpoint — aucun débordement horizontal ajouté au conteneur.
- `aria-label` + `title` « Add block below » (accessibilité).
- **Tests** — `tests/test_v760_block_insert_btn.py` : **2 tests** (ordre
dans le wrapper, câblage `E.addAfter`, positions/masquages CSS).
## v7.59.3 (2026-10-07) — Correction : « Turn into » du menu contextuel
### Fixed
- **« Turn into… » ne faisait rien** — la garde validait le type avec
`_BLOCK_TURN_TYPES.indexOf(type)` alors que ce tableau contient des **objets**
`{id, name}` : `indexOf` d'une chaîne renvoyait toujours `-1` → `turnInto()`
retournait immédiatement après l'ouverture du sous-menu. Correction :
`_BLOCK_TURN_TYPES.some(t=>t.id===type)`.
- **Texte conservé** — convertir un bloc en **To-do** ou **Toggle** vidait le
contenu (ces deux types étaient exclus de la conservation par erreur) ; seul
un type sans champ texte (`image`, `embed`, `divider`, `button`, `columns`,
…) le vide désormais, comme dans `Notion`.
- **Tests** — `tests/test_v7593_turn_into.py` : **4 tests**, dont 1
comportemental **node** qui exécute la vraie `turnInto()` extraite de la
source : conversion effective du type, texte conservé (To-do = étiquette +
case décochée), contenu vidé pour un type sans champ texte.
## v7.59.2 (2026-10-07) — Corrections : aide, « Insert below », listes numérotées
### Fixed
- **Aide** — bouton **✕** en haut à droite du panneau (classe partagée
`settings-close` : 32 px desktop / **44 px mobile**) + méthode `close()` dans
`help.js` (retour à la page précédente, sinon `/workspaces`) ;
`.settings-panel` passé en `position:relative` pour ancrer les boutons au
panneau (hamburger + ✕) plutôt qu'à la page.
- **Éditeur — « Insert below » (/Write with AI)** : on insère le **markdown
source** conservé à la génération (`AIC._md`) au lieu du texte extrait du
HTML rendu (`_resultText()` ne reste qu'en repli pour les messages d'erreur) —
titres, listes, gras et numéros survivent à l'insertion.
- **Listes numérotées** — elles n'avaient **aucun numéro au rendu** : le CSS
ne déclarait que le retrait, jamais le marqueur. Le rang des blocs
`numbered_list` contigus est désormais calculé au rendu (`data-num`) et
affiché via `content: attr(data-num) ". "` → **`/numbered list` et
l'insertion depuis l'assistant affichent enfin 1, 2, 3**.
- **`md2b`** (markdown → blocs, chemin agent) : les cases à cocher `- [ ]` /
`- [x]` sont testées **avant** la branche des puces (une ligne `- [ ]`
était avalée par cette branche) — aligné sur l'ordre déjà correct de
`paste2b` ; le séparateur `1)` est accepté (CommonMark).
- **Menu slash** — une requête tapée **dans le bloc** (`/numbered list`, focus
qui n'a pas rejoint l'entrée du menu) est répercutée dans le filtre au lieu
d'être ignorée.
- **Tests** — `tests/test_v7592_ui_fixes.py` : **5 tests**, dont 1 test
comportemental **node** qui exécute le vrai `md2b` extrait de la source.
- `ruff` : ordre d'imports de `tests/test_agent.py` (I001 préexistant au pull).
## v7.59.1 (2026-10-07) — Fix fournisseur Mistral (clé valide, 403 tier_not_allowed)
### Fixed
- **Mistral « Test connection » échouait malgré une clé valide** — cause
racine : `mistral-large-latest` (modèle par défaut + tête de liste) n'est
pas servi par les plans d'abonnement basiques → `403 tier_not_allowed`
(code 1910). La clé elle-même fonctionne (`/v1/models` 200, chat 200 sur
small/medium/nemo/codestral — vérifié en direct).
- `PROVIDERS["mistral"]` default → `mistral-small-latest` (tous plans).
- `PROVIDER_MODELS["mistral"]` réordonnée : modèles tous-plans d'abord
(le test de connexion sélectionne le premier candidat).
- `mistral` ajouté à `_CHAT_VALIDATED_PROVIDERS` : le fetch des modèles
sonde chat/completions et exclut les modèles hors plan (46 listés →
25 réellement utilisables avec la clé du compte).
- **Migration 36** : `default_model` des lignes mistral pointant sur un
modèle bloqué réinitialisé (→ nouveau défaut), `llm_config.model` idem.
### Tests
- `tests/test_agent.py::test_mistral_defaults_are_tier_safe` · suite agent
OK · test de connexion live sur l'instance déployée : `{"ok":true,
"model":"mistral-small-latest","reply":"PONG","verified":true}`.
## v7.59.0 (2026-10-07) — Mobile : drawer réglable, side-nav settings, aide refondue
### Fixed
- **Scroll souris du drawer sidebar mobile** — `display:block` du breakpoint
≤768px cassait le `flex:1` de `.sidebar-scroll` (hauteur = contenu, jamais
de zone scrollable). Remis en `display:flex;flex-direction:column`.
- **Rate limiter du routeur de test** — fenêtre remise à zéro à chaque login
: suite e2e via un seul login `beforeAll` + cookies `storageState`.
### Changed
- **Settings mobile : le menu devient une side-navigation gauche** ouverte
par un hamburger (`.settings-menu-btn`) avec backdrop, au lieu des onglets
horizontaux ; fermeture auto au choix de section. Squelette panneau
(overlay/panel/nav) extrait de `settings.html` vers
`/static/css/settings.css` partagé avec l'aide.
- **Page `/help` refondue au complet** — ancien monologue HTML inline de la
route remplacé par `app/templates/help.html` : même panneau 2 colonnes que
settings, nav latérale en 13 sections couvrant toute l'app (démarrer,
éditeur, bases & vues, tâches/dépendances, workspaces/forges, collaboration,
bibliothèque/recherche, agent IA & automations, API v2/webhooks/clipper,
PWA offline, comptes & SSO, raccourcis, tips) ; `static/js/help.js`
(`Alpine.data helpInit`).
### Tests
- Gate e2e `mobile_regression.spec.js` : 6/6 (drawer + wheel scroll +
side-nav settings + aide mobile/desktop) · `test_app.py` +
`test_pwa_offline.py` : 230/230.
## v7.58.0 (2026-10-06) — Add plugins : catalogue on/off à effet réel (phase 8/8)
### Added
- **`app/services/plugins.py` + migration 35** — table `plugins` (`slug`,
`name`, `description`, `enabled`) pré-remplie avec **3 modules câblés** :
`web-tools`, `web-clipper`, `automations`. Ligne absente = activé (défaut
sûr : rien n'est coupé par accident).
- **Effet réel, jamais un simple drapeau** :
- `automations` OFF → dépendance FastAPI posée **à l'`include_router`**
(3 lignes dans `main.py`, aucun router touché) → toutes les routes
`/workspace/automations*` refusées, **et** le scheduler de fond passe en
veille (garde sur chaque tick) ;
- `web-clipper` OFF → `GET /extensions` + tout `/api/v2/web-clipper/*`
refusés ;
- `web-tools` OFF → `web_search` et `fetch_url` retirés du **schéma** de
l'agent et de `execute()` (`ToolRegistry._all()`) : le LLM ne les voit plus.
- **UI masquée côté serveur** — nouveau global Jinja `plugin_enabled(slug)` :
`settings.html` rend la nav « Extensions » / « Automations » sous
`{% if %}` (élément **absent** du DOM, pas seulement caché) et conditionne
les sections en `x-show`.
- **Menu +** — l'entrée « Add plugins » devient une vraie section : liste des
3 plugins (état + description) avec bascule ;
`GET /api/agent/plugins` + `PATCH /api/agent/plugins/{slug}` (slug inconnu →
404, 401 sans session).
- **Tests** — `tests/test_v758_plugins.py` : **10 tests** — catalogue,
persistance en base, routes refusées (302 hors `/api` + 404 JSON pour
`/api*`, handler unifié de `main.py`), outils retirés du registre, nav
disparue du `/settings` rendu, câblage du menu, 401.
## v7.57.0 (2026-10-06) — Connecteurs : Discord, Telegram, Teams, MCP (phase 7/8)
### Added
+56 -9
View File
@@ -284,7 +284,7 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
---
## v7.51.0 → v7.58.0 — Menu + de l'assistant : hub de contexte (phases 1-7 ✅ 2026-10-06 · phase 8 planifiée)
## v7.51.0 → v7.58.0 — Menu + de l'assistant : hub de contexte (phases 1-8 ✅ 2026-10-06 — menu + complet)
> **Objectif** : faire du bouton **+** du panneau agent (à gauche de la zone d'édition)
> un menu à sections, tel que demandé :
@@ -496,18 +496,35 @@ CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j.
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.57.0**, OpenAPI régénéré,
CHANGELOG, ce ROADMAP
### Phase 8 — v7.58.0 — Add plugins · effort M
### Phase 8 — v7.58.0 — Add plugins · effort M ✅
- [ ] **Registre `plugins`** (`slug`, `name`, `description`, `enabled`) — état persisté
- [ ] **Catalogue dans le menu +** — bascule on/off à effet **réel** : un plugin
désactivé retire vraiment sa route/UI (au minimum 3 plugins câblés : web tools de
l'agent, web clipper, automations)
- [ ] **Tests** — plugin OFF = route refusée + UI absente (pas seulement un drapeau lu)
- [x] **Registre `plugins`** (`slug`, `name`, `description`, `enabled`) —
migration **35**, table pré-remplie avec les **3 modules** ; ligne absente =
activé (défaut sûr)
- [x] **Catalogue dans le menu +** — l'entrée « Add plugins » devient vivante
(fini le `disabled:true`) : liste des 3 plugins + bascule via
`GET/PATCH /api/agent/plugins[/slug]`
- [x] **Bascule on/off à effet réel** :
- `automations` → **toutes** les routes `/workspace/automations*` refusées
(dépendance FastAPI posée **à l'`include_router`**, aucun router touché) +
scheduler de fond en veille (garde par tick) ;
- `web-clipper` → `GET /extensions` + `/api/v2/web-clipper/*` refusés ;
- `web-tools` → `web_search` / `fetch_url` retirés du **schéma** et de
`execute()` (`ToolRegistry._all()`) ;
- UI → nav « Extensions » / « Automations » rendues sous
`{% if plugin_enabled(...) %}` (global Jinja, élément **absent** du DOM),
sections conditionnées en `x-show`
- [x] **Tests** — `tests/test_v758_plugins.py` : **10 tests** — plugin OFF =
route refusée (302 hors `/api`, 404 JSON pour `/api*`) + UI absente + outil
retiré, persistance, 401 sans session
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.58.0**, OpenAPI régénéré
(525 chemins), CHANGELOG, ce ROADMAP
---
*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 7 livrées le
2026-10-06 (v7.51.0 → v7.57.0)**, phase 8 (plugins) à valider avant « go ».
*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 8 livrées le
2026-10-06 (v7.51.0 → v7.58.0) : le menu + est complet** (aucune section
« bientôt »). Toute extension ultérieure = nouvelle phase à valider.
Chaque phase se clôt par tests verts, bump de version, CHANGELOG et ROADMAP à jour.*
---
@@ -1391,6 +1408,36 @@ Détails livrés :
- [x] **Tests follow-ups** — `tests/test_v73_wiki_polish.py` (58 → **72 tests**) : sidebar cross-workspace, page teamspace (owner 200 / outsider 404 API), auto-follow comment, notif throttlée, unfurl gitea/github + endpoint, KPI + dashboards multi-DB
- [x] **Suite complète** — `python -m pytest -n auto` → **1016 passed** (était 981 passed / 21 failed en SSO)
### v7.62.0 — Menu contextuel au highlight de texte ✅ (2026-10-08)
> **Objectif** : menu façon Notion (mode sombre) au surlignage de texte dans l'éditeur. **COMPLETED**.
- [x] **Toolbar de sélection 4 lignes** — `T` + type de bloc dynamique + `›` (sous-menu Turn into existant) / `A` `B` `I` `U` `T` barré `Tx` / 🔗 `S` `</>` `√x` `…` / 💬 `✨`, section grise **Skills** (Improve writing, Proofread, Explain, Reformat → `runSkill`) et pied **Edit with AI** `Alt+⇧+E` — affichée **sous** la sélection (au-dessus si pas de place), clampée au viewport, fermeture au clic hors sélection
- [x] **`…` → menu contextuel de bloc** déjà existant (recherche « Search actions… », groupe Text, 10 actions + raccourcis, pied métadonnées auteur/heure/taille) — pas de doublon de menu
- [x] **Persistance du formatage inline** — sérialiseur `gtMd()` (DOM → source markdown) branché sur `sync()` ; `mdEsc()` rend `__…__`, `==…==`, `$$…$$` (KaTeX inline) — avant, `sync()` relisait du texte brut et perdait tout le formatage au prochain rendu
- [x] **Info-bulle claire à droite** — `data-tooltip-html` + `.tooltip-light` dans le tooltip global (`app.js`)
- [x] **Fix `askAI(idx)` écrasé par `askAI()`** — « Ask AI » / `Ctrl+J` ouvraient l'IA de page → `askAIBlock(idx)`
- [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)
+1 -1
View File
@@ -1 +1 @@
7.57.0
7.63.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.50.2 (peek de Library nu — iframe pleine, comme local-workspace) | **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
+11 -5
View File
@@ -5,7 +5,7 @@ import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi import Depends, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as _StarHTTPException
@@ -62,6 +62,7 @@ from app.routers.web_clipper import router as web_clipper_router
from app.routers.webauthn import router as webauthn_router
from app.routers.wiki import router as wiki_router
from app.routers.workers import router as workers_router
from app.services import plugins as plugins_service
from app.services.webhook_outbound import init_webhook_tables
logging.basicConfig(
@@ -185,7 +186,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.57.0",
version="7.63.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
@@ -235,7 +236,9 @@ app.include_router(sharing.router)
app.include_router(sidebar_config.router)
app.include_router(export.router)
app.include_router(notifications_router)
app.include_router(automations_router)
# Plugin « automations » OFF → chaque route de ce router renvoie 404
app.include_router(automations_router,
dependencies=[Depends(plugins_service.plugin_required("automations"))])
app.include_router(collaboration_router)
app.include_router(emoji_router)
app.include_router(realtime_router)
@@ -247,8 +250,11 @@ app.include_router(sync.router)
app.include_router(imports_router)
app.include_router(import_page_router)
app.include_router(permissions_router)
app.include_router(web_clipper_api_router)
app.include_router(web_clipper_router)
# Plugin « web-clipper » OFF → page /extensions + API clip refusées
app.include_router(web_clipper_api_router,
dependencies=[Depends(plugins_service.plugin_required("web-clipper"))])
app.include_router(web_clipper_router,
dependencies=[Depends(plugins_service.plugin_required("web-clipper"))])
app.include_router(api_v2_router)
app.include_router(api_v2_agent_router)
app.include_router(sites_router)
+45
View File
@@ -1586,6 +1586,30 @@ def _migration_my_tasks_mapping(conn: sqlite3.Connection) -> None:
)
@register(35, "v7.58.0: registre des plugins (on/off à effet réel)")
def _migration_plugins(conn: sqlite3.Connection) -> None:
"""Catalogue de l'instance : 3 modules câblés (routes/UI/outils réels)."""
conn.execute(
"""CREATE TABLE IF NOT EXISTS plugins (
slug TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1
)"""
)
conn.executemany(
"INSERT OR IGNORE INTO plugins (slug, name, description, enabled) VALUES (?,?,?,1)",
[
("web-tools", "Outils web de l'agent",
"Retire web_search et fetch_url du registre d'outils de l'agent"),
("web-clipper", "Web Clipper",
"Coupe la page /extensions et l'API /api/v2/web-clipper/*"),
("automations", "Automations",
"Coupe /workspace/automations* et le scheduler en arrière-plan"),
],
)
@register(34, "v7.57.0: connecteurs — kind/auth/tools_json (Discord, Telegram, MCP)")
def _migration_connector_kinds(conn: sqlite3.Connection) -> None:
"""Discord (auth « Bot »), Telegram (jeton dans l'URL via `{secret}`) et
@@ -1654,3 +1678,24 @@ def _migration_agent_memory(conn: sqlite3.Connection) -> None:
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)"""
)
@register(36, "v7.59.1: mistral — modèles par défaut hors plan basique")
def _migration_mistral_default_model(conn: sqlite3.Connection) -> None:
"""mistral-large-latest / pixtral-large-latest renvoient 403
« tier_not_allowed » sur les plans d'abonnement basiques : la clé est
valide mais le test de connexion et l'agent échouaient. Reset du
default_model stocké → vide = nouveau défaut du provider (small-latest,
servi par tous les plans).
"""
blocked = ("mistral-large-latest", "pixtral-large-latest")
conn.execute(
"UPDATE user_llm_keys SET default_model='' "
"WHERE provider='mistral' AND default_model IN (?,?)",
blocked,
)
conn.execute(
"UPDATE llm_config SET model='mistral-small-latest' "
"WHERE provider='mistral' AND model IN (?,?)",
blocked,
)
+18 -1
View File
@@ -17,7 +17,7 @@ from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse
from app.auth.session import get_current_user
from app.config import settings
from app.db import get_conn
from app.services import connectors, oauth_connectors, skill_gallery
from app.services import connectors, oauth_connectors, plugins, skill_gallery
from app.services.agent_engine import AgentEngine, undo_action
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient
from app.services.llm_config import (
@@ -647,6 +647,23 @@ def list_connectors_route(request: Request):
return {"connectors": connectors.list_connectors(user_id)}
@router.get("/plugins")
def list_plugins_route(request: Request):
"""Catalogue des plugins de l'instance (menu + → « Add plugins »)."""
_current_user_id(request)
return {"plugins": plugins.list_plugins()}
@router.patch("/plugins/{slug}")
def set_plugin_route(request: Request, slug: str, body: dict = Body(default={})):
"""Bascule un plugin : l'effet est réel (routes/UI/outils), pas un drapeau."""
_current_user_id(request)
try:
return plugins.set_enabled(slug, bool(body.get("enabled")))
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/connectors")
def create_connectors_route(request: Request, body: dict = Body(default={})):
"""Ajoute un connecteur personnalisé — l'URL est validée (garde SSRF)."""
+2 -201
View File
@@ -251,209 +251,10 @@ def accounts_page(request: Request):
@router.get("/help", response_class=HTMLResponse)
def help_page(request: Request):
"""Comprehensive help & documentation page."""
"""Help & documentation page — settings-style panel with side navigation."""
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, [])
# Render via a block-based template so content_html lands in {% block content %}
block_tpl = env.from_string(
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
)
return HTMLResponse(block_tpl.render(
**sidebar,
request=request,
page_title="Help",
title_prefix="Help",
page_icon="❓",
content_html="""<style>
.help-page{max-width:900px;margin:0 auto;padding:40px 24px 80px;}
.help-hero{text-align:center;margin-bottom:48px;}
.help-hero h1{font-size:32px;font-weight:800;margin:0 0 8px;}
.help-hero p{font-size:16px;color:var(--text-dim);max-width:500px;margin:0 auto;}
.help-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;margin-bottom:48px;}
.help-card{background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:24px;transition:border-color .15s;}
.help-card:hover{border-color:rgba(255,255,255,.12);}
.help-card h3{font-size:15px;font-weight:600;margin:0 0 4px;display:flex;align-items:center;gap:8px;}
.help-card .icon{font-size:20px;}
.help-card p{font-size:13px;color:var(--text-dim);line-height:1.5;margin:8px 0 0;}
.help-card ul{list-style:none;padding:0;margin:12px 0 0;}
.help-card li{font-size:13px;padding:3px 0;color:var(--text-dim);}
.help-card li::before{content:'• ';color:var(--accent);}
.help-section{margin-bottom:48px;}
.help-section h2{font-size:20px;font-weight:700;margin:0 0 16px;padding-bottom:8px;border-bottom:1px solid var(--border);}
.help-kbd{display:inline-block;padding:2px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;font-family:monospace;font-size:12px;color:var(--text);min-width:16px;text-align:center;}
.help-shortcut-row{display:flex;align-items:center;gap:12px;padding:8px 12px;border-radius:6px;margin-bottom:2px;}
.help-shortcut-row:hover{background:var(--bg-hover);}
.help-shortcut-row .keys{display:flex;gap:4px;min-width:140px;}
.help-shortcut-row .desc{font-size:13px;color:var(--text-dim);}
.help-badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:11px;font-weight:600;}
.help-badge.local{background:rgba(35,131,226,.15);color:#2C8CEB;}
.help-badge.gitea{background:rgba(0,200,100,.15);color:#00CC66;}
.help-badge.github{background:rgba(130,80,220,.15);color:#A060F0;}
.help-badge.sso{background:rgba(217,115,13,.18);color:#E0952B;}
</style>
<div class="help-page">
<div class="help-hero">
<h1>❓ FlowDeck Help</h1>
<p>Everything you need to know about your Notion-style workspace with Gitea & GitHub integration.</p>
</div>
<div class="help-grid">
<div class="help-card">
<h3><span class="icon">🚀</span>Getting Started</h3>
<p>FlowDeck is your private, self-hosted workspace. Create pages, organize projects, and integrate with your Git forge.</p>
<ul>
<li>Create a workspace from the <b>Workspaces</b> page</li>
<li>Click <b>📄 New Page</b> in the sidebar to start writing</li>
<li>Use <span class="help-kbd">Ctrl+N</span> anywhere to create a page</li>
</ul>
</div>
<div class="help-card">
<h3><span class="icon">📝</span>Pages & Editor</h3>
<p>Notion-style block editor with slash commands, markdown shortcuts, and rich formatting.</p>
<ul>
<li>Type <span class="help-kbd">/</span> for the slash command menu</li>
<li>Drag & drop pages in the sidebar to reorganize</li>
<li>Right-click for context menu (duplicate, rename, delete)</li>
</ul>
</div>
<div class="help-card">
<h3><span class="icon">{{ fd_icon("folder",16) }}</span>Workspaces</h3>
<p>Organize your work into separate workspaces. Each has its own pages and files.</p>
<ul>
<li><span class="help-badge local">Local</span> Files stored on your server</li>
<li><span class="help-badge gitea">Gitea</span> Connect to browse & edit repos</li>
<li><span class="help-badge github">GitHub</span> Connect via Settings → Integrations</li>
</ul>
</div>
<div class="help-card">
<h3><span class="icon">🦎</span>Gitea Integration</h3>
<p>Connect your Gitea account to access repositories directly from FlowDeck.</p>
<ul>
<li>Go to <b>Settings → Integrations</b> to connect</li>
<li>Browse repo file trees in the sidebar</li>
<li>Create & edit files with commit messages</li>
<li>Sync labels as tags</li>
</ul>
</div>
<div class="help-card">
<h3><span class="icon">🌐</span>Sharing & Publishing</h3>
<p>Share pages with collaborators or publish them to the web.</p>
<ul>
<li>Click <b>Share</b> in the page editor top-right</li>
<li>Share with specific users or get a public link</li>
<li>Publish to make a page visible at <code>/p/your-slug</code></li>
</ul>
</div>
<div class="help-card">
<h3><span class="icon">📚</span>Library, Trash & Tasks</h3>
<p>Find all your content in one place with powerful filtering.</p>
<ul>
<li><b>Library</b> — Tabs for Recents, Favorites, Shared, Published</li>
<li><b>Trash</b> — Soft-deleted pages (30-day retention)</li>
<li><b>My Tasks</b> — Aggregated tasks from all collections</li>
</ul>
</div>
<div class="help-card">
<h3><span class="icon">📶</span>Offline & PWA</h3>
<p>Install FlowDeck as an app and keep working without a connection.</p>
<ul>
<li><b>Install</b> — browser menu → <i>Install app</i> / <i>Add to Home Screen</i></li>
<li>Edits made offline are queued locally and synced automatically</li>
<li>A <b>⟳</b> marker shows pages with pending changes</li>
</ul>
</div>
</div>
<div class="help-section">
<h2>⌨️ Keyboard Shortcuts</h2>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">N</span></div><div class="desc">Create new page</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">K</span></div><div class="desc">Quick find / command palette</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">S</span></div><div class="desc">Save current page</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">F2</span></div><div class="desc">Rename selected item</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Delete</span></div><div class="desc">Move selected item to trash</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Escape</span></div><div class="desc">Close modal / cancel editing</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Enter</span></div><div class="desc">Open selected page</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">O</span></div><div class="desc">New AI chat (in footer)</div></div>
</div>
<div class="help-section">
<h2>🔐 Authentication</h2>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
FlowDeck supports three authentication methods:<br>
<span class="help-badge local">Local</span> Email + password — create an account on the login page.<br>
<span class="help-badge gitea">Gitea OAuth</span> Login with your Gitea account. Your repos appear as workspaces.<br>
<span class="help-badge github">GitHub OAuth</span> Login or link your GitHub account in Settings → Integrations.<br><br>
<b>Tip:</b> You can connect Gitea/GitHub to an existing local account — your identity stays as your local user.
</p>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
<span class="help-badge sso">SSO</span> <b>Enterprise SSO</b> (v6.7.0) — sign in with your organization account.<br>
<i>For administrators:</i> open <b>Settings → Admin → SSO / Enterprise</b> and pick a provider:<br>
• <b>SAML 2.0</b> — paste the IdP <i>Entity ID</i>, <i>SSO URL</i> and signing certificate, then give the IdP this
<code>/auth/saml/metadata</code> link (it contains the SP Entity ID, ACS URL and certificate).<br>
• <b>OpenID Connect</b> — paste the <i>Issuer URL</i>, <i>Client ID</i> and <i>Client Secret</i> (PKCE is used, scopes default to <code>openid profile email</code>).<br>
• <b>Provisioning</b> — accounts are created automatically on first login, groups from the IdP map to workspace roles,
and <i>SSO only</i> disables local login (admins keep their local door). Every attempt is audited in
<b>Settings → Admin → SSO / Enterprise</b> (login history).
</p>
</div>
<div class="help-section">
<h2>📶 Offline mode (PWA)</h2>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
FlowDeck is a Progressive Web App: pages you visited stay available offline and your
edits are saved locally, then synchronised when the connection returns.<br><br>
<b>Install:</b> open your browser menu and choose <i>Install app</i> (Chrome/Edge) or
<i>Add to Home Screen</i> (Safari/iOS). FlowDeck then opens in its own window.<br>
<b>Offline editing:</b> while offline, the editor stores changes in the browser
(IndexedDB) and shows an offline banner with the number of pending changes. A
<b>⟳</b> icon appears next to pages that have unsynced edits.<br>
<b>Reconnection:</b> the queue is replayed automatically (and via Background Sync).
A spinner badge appears while syncing, followed by a confirmation toast.<br>
<b>Conflicts:</b> if a page changed on the server, the latest edit wins and a notice is
shown. If a page was deleted server-side, your offline copy is recreated as an orphan
page. If a page with the same title already exists, the offline copy is renamed
<i>“Title (copie offline)”</i>.
</p>
</div>
<div class="help-section">
<h2>🔌 API publique v2</h2>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
FlowDeck exposes a full REST API under <b>/api/v2</b> for third-party integrations.<br>
<b>Auth:</b> create a token in Settings → API tokens, then send it as
<code>Authorization: Bearer &lt;token&gt;</code>. Tokens carry scopes
<code>read</code>, <code>write</code> or <code>admin</code> (a higher scope implies the lower ones).<br>
<b>Features:</b> CRUD on collections, pages, properties, views, comments, notifications,
favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&amp;offset=</code> +
<code>X-Total-Count</code>), filters (<code>filter[prop]=value</code>), sorting, full-text search
(<code>/api/v2/search</code>), idempotency (<code>Idempotency-Key</code>) and RFC 7807 error bodies.<br>
<b>Reference:</b> interactive OpenAPI docs at <a href="/docs" target="_blank" rel="noopener">/docs</a>
(also <code>/redoc</code>, <code>docs/openapi-v2.json</code>).
</p>
</div>
<div class="help-section">
<h2>💡 Tips</h2>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
• Toggle the sidebar with the <b>«</b> button in the top-left corner.<br>
• Switch between workspaces using the dropdown menu in the sidebar header.<br>
• The <b>Private</b> section appears when a remote workspace is active — files here stay local.<br>
• Hover over any sidebar item to see action buttons (favorite, share, delete).<br>
• Use <b>Ctrl+Click</b> or <b>Shift+Click</b> to multi-select items in the sidebar.
</p>
</div>
</div>"""
))
return ENV.get_template("help.html").render(**sidebar, request=request)
@router.get("/accounts/settings", response_class=HTMLResponse)
+4 -1
View File
@@ -27,7 +27,7 @@ import time
from datetime import UTC, datetime, timedelta
from app.db import get_conn
from app.services import notifications
from app.services import notifications, plugins
from app.services.http_client import shared_client
logger = logging.getLogger(__name__)
@@ -479,6 +479,9 @@ async def automation_scheduler():
"""Background loop: fire due cron automations (checked every 60s)."""
while True:
try:
if not plugins.is_enabled("automations"): # plugin OFF = rien de planifié
await asyncio.sleep(60)
continue
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM automations WHERE trigger_type='cron' AND enabled=1"
+8 -3
View File
@@ -37,7 +37,9 @@ logger = logging.getLogger(__name__)
PROVIDERS = {
"openai": ("https://api.openai.com/v1", "gpt-4o"),
"anthropic": ("https://api.anthropic.com/v1", "claude-opus-4-8"),
"mistral": ("https://api.mistral.ai/v1", "mistral-large-latest"),
# mistral-small est servi par tous les plans d'abonnement ;
# mistral-large → 403 tier_not_allowed sur les plans basiques.
"mistral": ("https://api.mistral.ai/v1", "mistral-small-latest"),
"cohere": ("https://api.cohere.ai/compatibility/v1", "command-a-plus-05-2026"),
"google": ("https://generativelanguage.googleapis.com/v1beta/openai", "gemini-2.0-flash"),
"groq": ("https://api.groq.com/openai/v1", "llama-3.3-70b-versatile"),
@@ -94,8 +96,11 @@ PROVIDER_LABELS: dict[str, str] = {
PROVIDER_MODELS: dict[str, list[str]] = {
"openai": ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "o3-mini", "gpt-4-turbo"],
"anthropic": ["claude-opus-4-8", "claude-sonnet-4-5", "claude-3-5-sonnet", "claude-haiku-4-5"],
"mistral": ["mistral-large-latest", "mistral-medium-latest", "mistral-small-latest",
"codestral-latest", "open-mistral-nemo", "pixtral-large-latest"],
# Ordre = ordre de sélection du test de connexion : les modèles servis par
# tous les plans d'abord (large/pixtral-large = 403 tier_not_allowed en plan
# basique), les modèles à plan élevé en fin de liste.
"mistral": ["mistral-small-latest", "mistral-medium-latest", "open-mistral-nemo",
"codestral-latest", "mistral-large-latest", "pixtral-large-latest"],
"cohere": ["command-a-plus-05-2026", "command-r-plus", "command-r", "command-a-03-2025"],
"google": ["gemini-2.0-flash", "gemini-2.0-flash-lite", "gemini-1.5-pro", "gemini-1.5-flash"],
"groq": ["llama-3.3-70b-versatile", "llama-3.1-8b-instant",
+4 -1
View File
@@ -22,7 +22,10 @@ from app.services.llm_client import PROVIDER_LABELS, PROVIDER_MODELS, PROVIDERS
# before being exposed as "usable models". NVIDIA exposes all of its catalog
# (embeddings, rerank, image/video/audio gen…) many of which answer 404 on
# chat completions — the exact failure the user hit.
_CHAT_VALIDATED_PROVIDERS = frozenset({"nvidia"})
# Mistral : le /models liste des modèles hors du plan d'abonnement du compte
# (403 « tier_not_allowed » ex. mistral-large sur plan basique) — la sonde
# chat ne garde que ceux réellement servis par la CLÉ de l'utilisateur.
_CHAT_VALIDATED_PROVIDERS = frozenset({"nvidia", "mistral"})
# Markers that identify clearly non-chat models (embeddings, rerank, media gen…).
_NON_CHAT_MARKERS = (
+77
View File
@@ -0,0 +1,77 @@
"""Plugins de l'instance — on/off à **effet réel** (v7.58.0).
Chaque plugin câble une chose concrète, jamais un simple drapeau :
- ``web-tools`` → retiré du registre d'outils de l'agent (schéma + exécution)
- ``web-clipper`` → routers ``/extensions`` et ``/api/v2/web-clipper/*`` refusés
- ``automations`` → routes ``/workspace/automations*`` refusées + scheduler
Le garde de route est une **dépendance FastAPI posée à l'`include_router`**
(``main.py``) : 3 lignes, aucun fichier de router touché.
"""
from __future__ import annotations
from fastapi import HTTPException, Request
from app.db import get_conn
# slug → (nom affiché, description affichée)
CATALOG: list[tuple[str, str, str]] = [
("web-tools", "Outils web de l'agent",
"Retire web_search et fetch_url du registre d'outils de l'agent"),
("web-clipper", "Web Clipper",
"Coupe la page /extensions et l'API /api/v2/web-clipper/*"),
("automations", "Automations",
"Coupe /workspace/automations* et le scheduler en arrière-plan"),
]
# slug → outils LLM retirés du registre quand le plugin est OFF
PLUGIN_TOOLS: dict[str, tuple[str, ...]] = {"web-tools": ("web_search", "fetch_url")}
def is_enabled(slug: str) -> bool:
"""Ligne absente = activé (défaut sûr : ne rien couper par accident)."""
with get_conn() as conn:
row = conn.execute("SELECT enabled FROM plugins WHERE slug=?", (slug,)).fetchone()
return True if row is None else bool(row["enabled"])
def list_plugins() -> list[dict]:
with get_conn() as conn:
rows = {r["slug"]: bool(r["enabled"])
for r in conn.execute("SELECT slug, enabled FROM plugins")}
return [{"slug": slug, "name": name, "description": desc,
"enabled": rows.get(slug, True)}
for slug, name, desc in CATALOG]
def set_enabled(slug: str, enabled: bool) -> dict:
if not any(s == slug for s, _, _ in CATALOG):
raise ValueError(f"Plugin inconnu: {slug}")
name = next(n for s, n, _ in CATALOG if s == slug)
desc = next(d for s, _, d in CATALOG if s == slug)
with get_conn() as conn:
# SQLite vérifie NOT NULL AVANT l'upsert : il faut fournir name/description.
conn.execute(
"INSERT INTO plugins (slug, name, description, enabled) VALUES (?,?,?,?) "
"ON CONFLICT(slug) DO UPDATE SET enabled=excluded.enabled",
(slug, name, desc, 1 if enabled else 0))
conn.commit()
return next(p for p in list_plugins() if p["slug"] == slug)
def plugin_required(slug: str):
"""Dépendance FastAPI : 404 dès que le plugin est désactivé.
`Request` doit être importé au **module** : les annotations sont des
chaînes (`from __future__ import annotations`) et FastAPI les résout dans
les globales du module — sinon il lit un query param → 422.
"""
def dep(request: Request) -> None:
# annotation Request OBLIGATOIRE : sans elle FastAPI lit un query param → 422
if not is_enabled(slug):
raise HTTPException(status_code=404, detail=f"Plugin désactivé: {slug}")
return dep
+7 -1
View File
@@ -1172,10 +1172,16 @@ class ToolRegistry:
tools = dict(self.tools)
try:
from app.services import mcp_client
from app.services import plugins as plugins_service
for entry in mcp_client.cached_tools():
tools[entry["name"]] = McpTool(entry)
# plugin « web-tools » OFF → outils web retirés du schéma ET de execute()
for slug, names in plugins_service.PLUGIN_TOOLS.items():
if not plugins_service.is_enabled(slug):
for name in names:
tools.pop(name, None)
except Exception: # noqa: BLE001 — la base ne doit jamais casser un run
logger.exception("MCP dynamic tools load failed")
logger.exception("dynamic tools load failed")
return tools
def list(self, scope: dict | None = None) -> list[str]:
+1 -1
View File
@@ -37,7 +37,7 @@
{% if not hide_hamburger %}
<button class="hamburger-btn"
:class="{ 'hamburger-desktop-show': sidebarCollapsed }"
@click="{{ hamburger_click|default('sidebarCollapsed ? toggleSidebar() : (mobileSidebarOpen = true, sidebarCollapsed = false)') }}"
@click="{{ hamburger_click|default('fdHamburgerClick()') }}"
title="Toggle sidebar">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"/>
+47 -5
View File
@@ -635,17 +635,59 @@
</div>
</div>
<!-- ═══════════ Menu contextuel au highlight de texte (v7.62.0, façon Notion) ═══════════ -->
<div
class="format-toolbar"
x-show="fmt.open"
x-cloak
:style="{top:fmt.top+'px',left:fmt.left+'px'}"
@mousedown.prevent
>
<button @click="fmtApply('bold')"><strong>B</strong></button>
<button @click="fmtApply('italic')"><em>I</em></button>
<button @click="fmtApply('underline')"><u>U</u></button>
<button @click="fmtApply('strikeThrough')"><s>S</s></button>
<button @click="fmtLink()">{{ fd_icon("link",14) }}</button>
<!-- Ligne 1 : style de texte -->
<div class="ft-row">
<button type="button" class="ft-item ft-style"
@click="fmtStyle($event)"
data-tooltip-html="<em>To be the foremost driver…</em><br>Just start writing with plain text">
<span class="ft-style-ico">T</span>
<span class="ft-style-name" x-text="fmtStyleName()"></span>
<span class="ft-caret">›</span>
</button>
</div>
<!-- Ligne 2 : formatage de base -->
<div class="ft-row">
<button type="button" class="ft-item ft-a" @click="fmtColor($event)" data-tooltip="Color — text and background colors"><span class="ft-a-letter">A</span></button>
<button type="button" class="ft-item" @click="fmtApply('bold')" data-tooltip="Bold — Ctrl+B"><strong>B</strong></button>
<button type="button" class="ft-item" @click="fmtApply('italic')" data-tooltip="Italic — Ctrl+I"><em>I</em></button>
<button type="button" class="ft-item" @click="fmtApply('underline')" data-tooltip="Underline"><u>U</u></button>
<button type="button" class="ft-item" @click="fmtApply('strikeThrough')" data-tooltip="Strikethrough"><s>T</s></button>
<button type="button" class="ft-item ft-tx" @click="fmtCode()" data-tooltip="Inline code">Tx</button>
</div>
<!-- Ligne 3 : outils avancés -->
<div class="ft-row">
<button type="button" class="ft-item" @click="fmtLink()" data-tooltip="Link">{{ fd_icon("link",15) }}</button>
<button type="button" class="ft-item" @click="fmtHighlight()" data-tooltip="Highlight"><span class="ft-hl">S</span></button>
<button type="button" class="ft-item ft-code-ico" @click="fmtCodeBlock()" data-tooltip="Code block">&lt;/&gt;</button>
<button type="button" class="ft-item" @click="fmtEquation()" data-tooltip="Equation">√x</button>
<button type="button" class="ft-item ft-more" @click="fmtMore($event)" data-tooltip="More actions">…</button>
</div>
<!-- Ligne 4 : actions -->
<div class="ft-row">
<button type="button" class="ft-item" @click="fmtComment()" data-tooltip="Comment — Ctrl+⇧+M">{{ fd_icon("message-square",15) }}</button>
<button type="button" class="ft-item" @click="fmtAskAI()" data-tooltip="Ask AI — Ctrl+J">{{ fd_icon("sparkles",15) }}</button>
</div>
<!-- Section « Skills » -->
<div class="ft-sep"></div>
<div class="ft-group">Skills</div>
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Improve the writing of this text (wording, clarity, style). Keep the meaning, language and format. Reply ONLY with the improved text.')" data-tooltip="Run on this block">Improve writing</button>
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Proofread this text: fix spelling and grammar mistakes only. Reply ONLY with the corrected text.')" data-tooltip="Run on this block">Proofread</button>
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Explain this text in simpler terms, in the same language. Reply ONLY with the explanation.')" data-tooltip="Run on this block">Explain</button>
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Reformat this text for readability (structure, spacing, lists where useful) without changing its meaning. Reply ONLY with the reformatted text.')" data-tooltip="Run on this block">Reformat</button>
<!-- Pied de page -->
<div class="ft-sep"></div>
<button type="button" class="ft-item ft-foot" @click="fmtEditAI()" data-tooltip="Edit with AI">
<span>Edit with AI</span>
<kbd>Alt+⇧+E</kbd>
</button>
</div>
<!-- ═══════════ Copy Link Toast ═══════════ -->
+12
View File
@@ -13,6 +13,7 @@
<link rel="stylesheet" href="/static/css/app.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/design-tokens.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/components.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/settings.css?v={{ asset_version }}">
<!-- My Tasks + page « /db » : lies dans le SHELL et non dans la zone
swappée. Un <link> à l'intérieur de `.main-wrapper` peut être retiré par
htmx lors d'une navigation partielle : la page revenait alors sans
@@ -1944,6 +1945,17 @@
this.sidebarCollapsed = !this.sidebarCollapsed;
},
// Hamburger: mobile opens the drawer (assignment expressions are illegal
// in the Alpine CSP build, hence this method), desktop toggles collapse.
fdHamburgerClick() {
if (window.matchMedia('(max-width: 768px)').matches) {
this.mobileSidebarOpen = true;
this.sidebarCollapsed = false;
} else {
this.toggleSidebar();
}
},
closeSidebar() {
if (this.sidebarPeek) { this.sidebarPeek = false; return; }
this.sidebarCollapsed = true;
+249
View File
@@ -0,0 +1,249 @@
{% extends "base.html" %}
{% block page_title %}Help{% endblock %}
{% block title_prefix %}Help{% endblock %}
{% block page_icon %}{{ fd_icon("help-circle",18) }}{% endblock %}
{% block topbar %}
{% set page_icon = "help-circle" %}
{% set page_title = "Help" %}
{% include '_header.html' %}
{% endblock %}
{% block content %}
{# Same panel skeleton as settings (shared /static/css/settings.css) #}
<style>
.help-body{margin-bottom:24px;max-width:760px;}
.help-body p{font-size:14px;color:var(--text-secondary);line-height:1.65;margin:0 0 10px;}
.help-body ul{margin:0 0 14px;padding-left:18px;}
.help-body li{font-size:14px;color:var(--text-secondary);line-height:1.6;margin-bottom:4px;}
.help-body code{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;padding:1px 6px;font-size:12.5px;}
.help-body h3{font-size:13px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim);margin:22px 0 8px;padding-bottom:4px;border-bottom:1px solid var(--border);}
.help-kbd{display:inline-block;padding:2px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;font-family:monospace;font-size:12px;color:var(--text);min-width:16px;text-align:center;}
.help-shortcut-row{display:flex;align-items:center;gap:12px;padding:8px 12px;border-radius:6px;margin-bottom:2px;}
.help-shortcut-row:hover{background:var(--bg-hover);}
.help-shortcut-row .keys{display:flex;gap:4px;min-width:140px;}
.help-shortcut-row .desc{font-size:13px;color:var(--text-dim);}
.help-badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:11px;font-weight:600;}
.help-badge.local{background:rgba(35,131,226,.15);color:#2C8CEB;}
.help-badge.gitea{background:rgba(0,200,100,.15);color:#00CC66;}
.help-badge.github{background:rgba(130,80,220,.15);color:#A060F0;}
.help-badge.sso{background:rgba(217,115,13,.18);color:#E0952B;}
</style>
<div class="settings-overlay" x-data="helpInit()" style="background:none;backdrop-filter:none;position:static;z-index:1;padding:0;">
<div class="settings-panel" style="width:100%;max-width:1050px;height:calc(100vh - 120px);margin:0 auto;" @keydown.escape="navOpen = false">
<button class="settings-menu-btn" @click="navOpen = !navOpen" title="Sections" aria-label="Open sections menu">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<button class="settings-close" @click="close()" title="Close" aria-label="Close help">×</button>
<!-- Mobile drawer backdrop -->
<div class="settings-nav-backdrop" x-show="navOpen" x-cloak @click="navOpen = false"></div>
<!-- Left navigation -->
<div class="settings-nav" :class="{ 'nav-open': navOpen }" @click.capture="closeNavOnMobile()">
<div class="settings-nav-header">Basics</div>
<div class="settings-nav-item" :class="{ active: section==='start' }" @click="go('start')">🚀 Getting Started</div>
<div class="settings-nav-item" :class="{ active: section==='editor' }" @click="go('editor')">📝 Pages &amp; Editor</div>
<div class="settings-nav-item" :class="{ active: section==='databases' }" @click="go('databases')">🗄️ Databases &amp; Views</div>
<div class="settings-nav-item" :class="{ active: section==='tasks' }" @click="go('tasks')">✅ Tasks &amp; Dependencies</div>
<div class="settings-nav-header">Workspace</div>
<div class="settings-nav-item" :class="{ active: section==='workspaces' }" @click="go('workspaces')">📁 Workspaces &amp; Forges</div>
<div class="settings-nav-item" :class="{ active: section==='collab' }" @click="go('collab')">👥 Collaboration</div>
<div class="settings-nav-item" :class="{ active: section==='nav' }" @click="go('nav')">📚 Library, Trash &amp; Search</div>
<div class="settings-nav-header">Features</div>
<div class="settings-nav-item" :class="{ active: section==='agent' }" @click="go('agent')">🤖 AI Agent &amp; Automations</div>
<div class="settings-nav-item" :class="{ active: section==='integrations' }" @click="go('integrations')">🔌 Integrations &amp; API</div>
<div class="settings-nav-item" :class="{ active: section==='pwa' }" @click="go('pwa')">📶 Offline &amp; PWA</div>
<div class="settings-nav-item" :class="{ active: section==='auth' }" @click="go('auth')">🔐 Accounts &amp; SSO</div>
<div class="settings-nav-header">Reference</div>
<div class="settings-nav-item" :class="{ active: section==='shortcuts' }" @click="go('shortcuts')">⌨️ Keyboard Shortcuts</div>
<div class="settings-nav-item" :class="{ active: section==='tips' }" @click="go('tips')">💡 Tips</div>
</div>
<!-- Content -->
<div class="settings-content">
<div class="help-body" x-show="section==='start'">
<h2>Getting Started</h2>
<p>FlowDeck is your private, self-hosted Notion-style workspace — pages, databases, and Git-forge (Gitea / GitHub) integration in one app.</p>
<h3>First steps</h3>
<ul>
<li>Open <b>Workspaces</b> (<code>/workspaces</code>) to pick or create a workspace — local or connected to a forge.</li>
<li>Click <b>+ New page</b> in the sidebar to start writing, or press <span class="help-kbd">Ctrl</span>+<span class="help-kbd">N</span>.</li>
<li>Use the <b>Agent &amp; IA</b> section in Settings to plug an LLM provider (OpenAI, Ollama, DeepInfra…) if you want AI features.</li>
</ul>
<h3>The sidebar</h3>
<ul>
<li>The workspace header (top-left) switches workspaces and opens your account menu.</li>
<li>Tabs <b>Home / Chat / Meetings / Inbox</b> swap the sidebar content; pages are a drag-and-drop tree.</li>
<li>On mobile the sidebar is a drawer: open it with the hamburger button, close it by tapping outside or swiping left.</li>
</ul>
</div>
<div class="help-body" x-show="section==='editor'">
<h2>Pages &amp; Editor</h2>
<p>Notion-style block editor: every line is a block you can drag, convert, and nest.</p>
<h3>Blocks</h3>
<ul>
<li>Type <code>/</code> for the slash menu — headings, callouts, toggles, code, math (KaTeX), tables, images, embeds, ToC…</li>
<li>Markdown shortcuts as you type: <code># </code> heading, <code>- </code> bullet, <code>1. </code> numbered, <code>&gt; </code> quote, <code>[] </code> todo, <code>``` </code> code.</li>
<li>Drag the handle left of a block to move or nest it; <span class="help-kbd">Tab</span>/<span class="help-kbd">Shift+Tab</span> indent/outdent.</li>
<li>Inline formatting: bold, italic, code, links, colors via the selection toolbar.</li>
</ul>
<h3>Page features</h3>
<ul>
<li>Cover image and icon on every page (click the top of the page).</li>
<li>Inline databases: add a table/board/calendar collection directly in a page.</li>
<li>Comments (threaded), page history with snapshots, backlinks.</li>
<li>Right-click any sidebar item for context menu: duplicate, rename, move, delete.</li>
</ul>
</div>
<div class="help-body" x-show="section==='databases'">
<h2>Databases &amp; Views</h2>
<p>Collections are databases with a JSON schema, independent of Gitea. <b>21 property types</b>: title, text, number, select, multi-select, status, date, person, checkbox, url, email, phone, files, unique_id, relation, rollup, formula, and auto-properties.</p>
<h3>Views</h3>
<ul>
<li><b>Table</b> — sortable columns, inline editing.</li>
<li><b>Board (Kanban)</b> — group by select/status, drag &amp; drop between columns.</li>
<li><b>Calendar</b> — month grid by date property.</li>
<li><b>Gallery</b> — visual cards, configurable card size.</li>
<li><b>List</b> — compact rows with preview.</li>
<li><b>Timeline</b> — Gantt bars from date ranges.</li>
<li><b>Status Overview / Team Load</b> — donut + stacked-bar dashboards.</li>
</ul>
<h3>Relations, rollups, formulas</h3>
<ul>
<li>Relations are bidirectional: link rows across collections.</li>
<li>Rollups aggregate related rows (12 aggregations: sum, count, average, min, max…).</li>
<li>Formulas support 19 functions over row properties.</li>
<li>Filters, sorts and grouping apply per-view.</li>
</ul>
</div>
<div class="help-body" x-show="section==='tasks'">
<h2>Tasks &amp; Dependencies</h2>
<ul>
<li>Todo blocks anywhere in pages become checkboxes; the <b>My Tasks</b> page aggregates every task in every collection you can access.</li>
<li>My Tasks views: <b>All / Today / Overdue / Next 7 days</b>.</li>
<li><b>Sub-items</b>: unlimited hierarchy — a parent row is Done when all children are Done (status aggregate).</li>
<li><b>Dependencies</b>: Blocking / Blocked by between rows; transitioning a blocked task is refused until its blocker is done.</li>
</ul>
</div>
<div class="help-body" x-show="section==='workspaces'">
<h2>Workspaces &amp; Forges</h2>
<p>Organize work into separate workspaces, each with its own page tree, collections and members.</p>
<ul>
<li><span class="help-badge local">Local</span> Pages and files stored on your server (SQLite under <code>/data</code>).</li>
<li><span class="help-badge gitea">Gitea</span> Connect your Gitea account (Settings → Integrations): repos appear as workspaces, browse the file tree in the sidebar, create/edit files with commit messages, sync labels as tags.</li>
<li><span class="help-badge github">GitHub</span> Connect via Settings → Integrations for repo browsing.</li>
<li>Gitea legacy boards are auto-adapted into FlowDeck collections (GiteaBoardCompat).</li>
<li>The <b>Private</b> section appears when a remote workspace is active — those pages stay local.</li>
</ul>
</div>
<div class="help-body" x-show="section==='collab'">
<h2>Collaboration</h2>
<ul>
<li><b>Roles</b>: workspace members are admin / editor / commenter / viewer.</li>
<li><b>Sharing</b>: the Share button (top-right of a page) — share with specific users or publish a read-only public link (<code>/p/your-slug</code>).</li>
<li><b>Teamspaces</b> (wiki): shared knowledge spaces with their own pages.</li>
<li><b>Meetings</b>: meeting notes with an action-items panel.</li>
<li><b>Sprints</b>: time-box collections of tasks, with burndown-ready statuses.</li>
<li><b>Notifications</b>: bell in the topbar for mentions, assignments, comments.</li>
<li>Live editing: changes propagate to other open tabs (real-time sync).</li>
</ul>
</div>
<div class="help-body" x-show="section==='nav'">
<h2>Library, Trash &amp; Search</h2>
<ul>
<li><b>Library</b> (<code>/library</code>) — tabs for Recents, Favorites, Shared, Published, with filtering.</li>
<li><b>Trash</b> (<code>/trash</code>) — soft-deleted pages, 30-day retention, restore or purge.</li>
<li><b>Ctrl+K</b> — command palette: fuzzy search across pages, jump anywhere, start an AI answer.</li>
<li>Full-text search API: <code>/api/v2/search</code>.</li>
</ul>
</div>
<div class="help-body" x-show="section==='agent'">
<h2>AI Agent &amp; Automations</h2>
<ul>
<li><b>Chat sidebar</b> (Chat tab): conversations with your configured LLM; pages can be attached as context.</li>
<li><b>Agent panel</b> on pages: propose edits, generate content, answer questions about the workspace.</li>
<li><b>Skill marketplace</b>: export/import portable skills, install presets from the <code>/</code> gallery.</li>
<li><b>Automations</b> (Settings → Automations): visual pipeline of trigger → condition → delay → action (webhook, notification, page update…), with run history and legacy JSON conversion.</li>
<li>Configure providers in <b>Settings → Agent &amp; IA</b>: per-provider API keys, model lists, offline mode without any provider.</li>
</ul>
</div>
<div class="help-body" x-show="section==='integrations'">
<h2>Integrations &amp; API</h2>
<ul>
<li><b>Public API v2</b> — <code>/api/v2</code>: CRUD on collections, pages, properties, views, comments, notifications, favorites, tags, sharing, sprints, templates. Bearer tokens (Settings → API tokens) with scopes <code>read</code>/<code>write</code>/<code>admin</code>, pagination, filters, sorting, idempotency keys, RFC 7807 errors. Interactive docs at <a href="/docs" target="_blank" rel="noopener">/docs</a>.</li>
<li><b>Agent API</b> — <code>/api/v2/agents/*</code>: agents, conversations, synchronous JSON run, action journal with rollback, external trigger.</li>
<li><b>Webhooks</b>: outgoing events (create/update/delete) managed in Settings → Integrations; incoming Gitea webhooks keep boards in sync.</li>
<li><b>Web Clipper</b> — browser extension (Manifest V3): clip article / selection / bookmark / screenshot straight into FlowDeck.</li>
<li><b>Import / Export</b>: CSV import, JSON/CSV/Markdown export.</li>
</ul>
</div>
<div class="help-body" x-show="section==='pwa'">
<h2>Offline mode (PWA)</h2>
<p>FlowDeck is a Progressive Web App: pages you visited stay available offline and your edits are saved locally, then synchronised when the connection returns.</p>
<ul>
<li><b>Install:</b> browser menu → <i>Install app</i> (Chrome/Edge) or <i>Add to Home Screen</i> (Safari/iOS).</li>
<li><b>Offline editing:</b> while offline, the editor stores changes in the browser (IndexedDB) and shows an offline banner with the number of pending changes. A ⟳ icon marks pages with unsynced edits.</li>
<li><b>Reconnection:</b> the queue is replayed automatically (and via Background Sync); a spinner badge appears while syncing, then a confirmation toast.</li>
<li><b>Conflicts:</b> the latest edit wins with a notice; server-side-deleted pages are recreated as orphan pages; title collisions get an <i>“…(copie offline)”</i> suffix.</li>
</ul>
</div>
<div class="help-body" x-show="section==='auth'">
<h2>Accounts &amp; SSO</h2>
<p>
<span class="help-badge local">Local</span> Email + password — create an account on the login page.<br><br>
<span class="help-badge gitea">Gitea OAuth</span> / <span class="help-badge github">GitHub OAuth</span> — sign in with your forge; your repos become workspaces. You can link a forge to an existing local account — your identity stays local.<br><br>
<span class="help-badge sso">SSO</span> <b>Enterprise SSO</b> — in <b>Settings → Admin → SSO / Enterprise</b>:
</p>
<ul>
<li><b>SAML 2.0</b> — paste IdP Entity ID, SSO URL and signing certificate; give the IdP your <code>/auth/saml/metadata</code> link.</li>
<li><b>OpenID Connect</b> — Issuer URL, Client ID and Client Secret (PKCE, scopes <code>openid profile email</code>).</li>
<li>Just-in-time provisioning, IdP groups mapped to workspace roles, <i>SSO only</i> mode disables local login (admins keep their door). Every attempt is audited in the login history.</li>
</ul>
<p>Sessions and API tokens are managed in Settings → Sessions / API tokens.</p>
</div>
<div class="help-body" x-show="section==='shortcuts'">
<h2>Keyboard Shortcuts</h2>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">K</span></div><div class="desc">Command palette / quick find</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">N</span></div><div class="desc">Create new page</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">S</span></div><div class="desc">Save current page</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">O</span></div><div class="desc">New AI chat (footer)</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">/</span></div><div class="desc">Slash command menu (in editor)</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">F2</span></div><div class="desc">Rename selected item</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Delete</span></div><div class="desc">Move selected item to trash</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Enter</span></div><div class="desc">Open selected page</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Tab</span> / <span class="help-kbd">Shift</span>+<span class="help-kbd">Tab</span></div><div class="desc">Indent / outdent block</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Escape</span></div><div class="desc">Close modal / cancel editing</div></div>
</div>
<div class="help-body" x-show="section==='tips'">
<h2>Tips</h2>
<ul>
<li>Toggle the desktop sidebar with the « button; collapsed, hover the left edge to peek it.</li>
<li><b>Ctrl+Click</b> / <b>Shift+Click</b> multi-select in the sidebar tree.</li>
<li>Hover any sidebar item for quick actions (favorite, share, delete).</li>
<li>Drag pages into the tree to reorganize; drop onto a page to nest it.</li>
<li>The section <b>⋮</b> menus reorder or hide sidebar sections to taste.</li>
<li>Dark/light theme toggle is in the topbar and persists across reloads.</li>
</ul>
</div>
</div>
</div>
</div>
<script data-cfasync="false" src="/static/js/help.js?v={{ asset_version }}"></script>
{% endblock %}
+15 -21
View File
@@ -11,16 +11,6 @@
{% block content %}
<style>
.settings-overlay{position:fixed;inset:0;z-index:500;display:flex;background:rgba(0,0,0,.4);backdrop-filter:blur(2px);}
.settings-panel{display:flex;width:1050px;max-width:98vw;height:85vh;margin:auto;background:var(--bg-primary);border:1px solid var(--border-strong);border-radius:14px;box-shadow:var(--shadow-modal);overflow:hidden;}
.settings-nav{width:200px;min-width:200px;background:var(--bg-sidebar);border-right:1px solid var(--border);padding:8px 0;overflow-y:auto;}
.settings-nav-header{padding:12px 16px;font-size:13px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;}
.settings-nav-item{display:flex;align-items:center;gap:8px;padding:6px 16px;font-size:13px;color:var(--text-secondary);cursor:pointer;transition:background 100ms;border-radius:0;}
.settings-nav-item:hover{background:var(--bg-hover);}
.settings-nav-item.active{background:rgba(35,131,226,.12);color:var(--text);font-weight:500;}
.settings-content{flex:1;overflow-y:auto;padding:24px 32px;}
.settings-content h2{font-size:18px;font-weight:600;margin-bottom:4px;}
.settings-content .section-desc{font-size:12px;color:var(--text-dim);margin-bottom:24px;}
.setting-group{margin-bottom:28px;}
.setting-group h3{font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim);margin-bottom:8px;padding-bottom:4px;border-bottom:1px solid var(--border);}
.setting-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--border);}
@@ -69,9 +59,7 @@
.status-dot.active{background:var(--toast-success-bg);}
.status-dot.inactive{background:var(--danger);}
/* Close button */
.settings-close{position:absolute;top:12px;right:12px;width:32px;height:32px;background:none;border:none;color:var(--text-dim);font-size:20px;cursor:pointer;border-radius:6px;display:flex;align-items:center;justify-content:center;}
.settings-close:hover{background:var(--bg-hover);color:var(--text);}
/* Close button — shared styles in /static/css/settings.css */
/* Agent & IA — status summary + provider cards */
.llm-summary{display:flex;gap:14px;align-items:flex-start;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:12px;padding:14px 16px;margin-bottom:22px;flex-wrap:wrap;}
@@ -125,26 +113,32 @@
.llm-detail .prov-field-row{margin-top:12px;}
.llm-detail-empty{display:flex;align-items:center;justify-content:center;height:260px;font-size:13px;color:var(--text-dim);text-align:center;padding:0 20px;}
@media (max-width:760px){.llm-layout{grid-template-columns:1fr;}.llm-list{max-height:220px;}}
/* ── Mobile side-nav drawer: shared settings.css handles overlay/nav/panel ── */
</style>
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="historyBack()">
<div class="settings-panel" style="position:relative;">
<div class="settings-overlay" x-data="settingsInit()">
<div class="settings-panel" style="position:relative;" @keydown.escape="historyBack()">
<button class="settings-menu-btn" @click="navOpen = !navOpen" title="Sections" aria-label="Open sections menu">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<button class="settings-close" @click="closeSettings()" title="Close">×</button>
<!-- Left navigation -->
<div class="settings-nav">
<!-- Left navigation (mobile: side-navigation drawer via hamburger + backdrop) -->
<div class="settings-nav-backdrop" x-show="navOpen" x-cloak @click="navOpen = false"></div>
<div class="settings-nav" :class="{ 'nav-open': navOpen }" @click.capture="closeNavOnMobile()">
<div class="settings-nav-header">Account</div>
<div class="settings-nav-item" :class="{ active: activeSection==='account' }" @click="activeSection='account'"><span class="nav-icon-inline">{{ fd_icon("user",14) }}</span> My account</div>
<div class="settings-nav-item" :class="{ active: activeSection==='notifications' }" @click="activeSection='notifications'"><span class="nav-icon-inline">{{ fd_icon("bell",14) }}</span> Notifications</div>
<div class="settings-nav-item" :class="{ active: activeSection==='api-tokens' }" @click="navTo('api-tokens')"><span class="nav-icon-inline">{{ fd_icon("key",14) }}</span> API tokens</div>
<div class="settings-nav-item" :class="{ active: activeSection==='sessions' }" @click="navTo('sessions')"><span class="nav-icon-inline">{{ fd_icon("users",14) }}</span> Sessions</div>
<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="navTo('extensions')"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>
{% if plugin_enabled('web-clipper') %}<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="navTo('extensions')"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>{% endif %}
<div class="settings-nav-header">Workspace</div>
<div class="settings-nav-item" :class="{ active: activeSection==='workspace' }" @click="activeSection='workspace'">{{ fd_icon("file",14) }} General</div>
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
<div class="settings-nav-header">Features</div>
<div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">{{ fd_icon("link",14) }} Integrations</div>
<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="navTo('automations')">{{ fd_icon("zap",14) }} Automations</div>
{% if plugin_enabled('automations') %}<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="navTo('automations')">{{ fd_icon("zap",14) }} Automations</div>{% endif %}
<div class="settings-nav-item" :class="{ active: activeSection==='llm' }" @click="activeSection='llm'">{{ fd_icon("bot",14) }} Agent &amp; IA</div>
<!-- Admin nav: only visible to admins -->
<template x-if="userIsAdmin">
@@ -423,7 +417,7 @@
</div>
<!-- Extensions (Web Clipper) -->
<div x-show="activeSection==='extensions'">
<div x-show="{{ 'true' if plugin_enabled('web-clipper') else 'false' }} &amp;&amp; activeSection==='extensions'">
<h2>Extensions</h2>
<p class="section-desc">FlowDeck Web Clipper — capture web content directly into FlowDeck. Manage connected browsers.</p>
<div class="setting-group">
@@ -584,7 +578,7 @@
</div>
<!-- Automations -->
<div x-show="activeSection==='automations'">
<div x-show="{{ 'true' if plugin_enabled('automations') else 'false' }} &amp;&amp; activeSection==='automations'">
<h2>Automations</h2>
<p class="section-desc">Règles if-this-then-that : déclencheur + condition + action. Utilisables aussi via le bloc bouton dans l'éditeur de page.</p>
+8
View File
@@ -77,3 +77,11 @@ except OSError: # pragma: no cover
ENV.globals["asset_version"] = ASSET_VERSION
ENV.globals["csp_nonce"] = lambda: CSP_NONCE.get()
ENV.globals["csrf_token"] = lambda: CSRF_TOKEN.get()
# Plugins activés : conditionne les blocs de UI rendus côté serveur.
ENV.globals["plugin_enabled"] = lambda slug: _plugin_enabled(slug)
def _plugin_enabled(slug: str) -> bool:
from app.services.plugins import is_enabled
return is_enabled(slug)
+22
View File
@@ -223,6 +223,28 @@ Chaque phase se clôt par : `ruff` + suite verte, bump de version, CHANGELOG, RO
`initialize` / `tools/list` / `tools/call` des serveurs streamables courants.
Upgrade : client SSE persistant si un serveur ne répond qu'en flux.
## 9ter. Avenant phase 8 (v7.58.0) — décisions livrées
- **Garde de route = dépendance posée à l'`include_router`** (`main.py`), pas
de décorateur par route ni de middleware : 3 lignes, aucun router modifié.
⚠️ l'annotation `request: Request` doit être importée **au module** : avec
`from __future__ import annotations` les annotations sont des chaînes et
FastAPI les résout dans les globales du module — sinon il lit un query param
→ 422 (piège réel, corrigé en cours de phase).
- **Effet réel aux 4 niveaux** : routes (dépendance), arrière-plan (garde de
tick du scheduler), outils (`ToolRegistry._all()`), UI (rendu serveur).
- **UI : `{% if %}` pour la nav, `x-show` pour les sections** — la nav est
**absente du DOM** (test « UI absente » au sens propre) ; les grosses
sections ne sont pas restructurées, leur booléen est rendu au serveur.
- **Handler unifié des 404** (comportement historique conservé) : hors `/api`
une route refusée **redirige 302 → /workspaces**, `/api*` répond 404 JSON —
les tests assertent les deux formes au lieu de réécrire le handler.
- **SQLite** : `INSERT … ON CONFLICT` évalue NOT NULL **avant** l'upsert →
fournir toutes les colonnes NOT NULL, même pour un simple changement d'état.
- `ponytail:` plafond assumé — pas de classe-adapter par plugin ni de
« marketplace » : ajouter un plugin = 1 tuple dans `CATALOG` (+ 1 garde si
son effet n'est pas déjà couvert).
## 10. Hors périmètre
- Écriture dans les sources distantes (Google Docs, Slack…) — lecture seule au départ.
+75 -3
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.57.0"
"version": "7.63.0"
},
"paths": {
"/auth/register": {
@@ -1098,7 +1098,7 @@
"dashboard"
],
"summary": "Help Page",
"description": "Comprehensive help & documentation page.",
"description": "Help & documentation page — settings-style panel with side navigation.",
"operationId": "help_page_help_get",
"responses": {
"200": {
@@ -16299,6 +16299,78 @@
}
}
},
"/api/agent/plugins": {
"get": {
"tags": [
"agent"
],
"summary": "List Plugins Route",
"description": "Catalogue des plugins de l'instance (menu + → « Add plugins »).",
"operationId": "list_plugins_route_api_agent_plugins_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
"/api/agent/plugins/{slug}": {
"patch": {
"tags": [
"agent"
],
"summary": "Set Plugin Route",
"description": "Bascule un plugin : l'effet est réel (routes/UI/outils), pas un drapeau.",
"operationId": "set_plugin_route_api_agent_plugins__slug__patch",
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Slug"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/agent/connectors/{connector_id}": {
"patch": {
"tags": [
@@ -30372,4 +30444,4 @@
}
}
}
}
}
+163
View File
@@ -0,0 +1,163 @@
// Mobile regression: hamburger drawer, wheel scroll, settings side-nav, help page.
const { test, expect } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-pass-123';
// One login for the whole file (app rate limiter: 60 req/min per IP) — reused
// as storageState by every test.
let STATE = null;
test.beforeAll(async ({ browser }) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
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: 10000 }).then(() => true).catch(() => false);
if (!ok) {
const resp = await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E Mobile' } });
if (!resp.ok() && resp.status() !== 409) throw new Error(`register ${resp.status()}`);
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');
await page.waitForURL('**/workspaces', { timeout: 10000 });
}
STATE = await ctx.storageState();
await ctx.close();
});
async function login(page) {
// Session reused from the single beforeAll login (rate limiter: 60 req/min/IP)
await page.context().addCookies(STATE.cookies);
}
test.describe('mobile', () => {
test.use({ viewport: { width: 390, height: 844 } });
test('hamburger opens mobile sidebar drawer', async ({ page }) => {
const errs = [];
page.on('pageerror', (e) => errs.push(e.message));
await login(page);
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
const burger = page.locator('.hamburger-btn');
await expect(burger).toBeVisible();
// Sidebar starts off-canvas
const sidebar = page.locator('#sidebar');
const boxBefore = await sidebar.boundingBox();
expect(boxBefore.x).toBeLessThan(0);
await burger.click();
await expect(sidebar).toHaveClass(/mobile-open/, { timeout: 5000 });
const boxAfter = await sidebar.boundingBox();
expect(boxAfter.x).toBeGreaterThanOrEqual(0);
expect(errs).toEqual([]);
// Overlay tap closes it
await page.locator('.sidebar-overlay').click({ position: { x: 350, y: 400 } });
await expect(sidebar).not.toHaveClass(/mobile-open/, { timeout: 5000 });
});
test('mobile sidebar drawer scrolls with mouse wheel', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
await page.locator('.hamburger-btn').click();
await page.waitForSelector('#sidebar.mobile-open');
const sc = page.locator('.sidebar-scroll');
await expect.poll(async () => sc.evaluate((el) => el.scrollHeight > el.clientHeight), { timeout: 3000 }).toBe(true);
const box = await sc.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + Math.min(box.height / 2, 400));
await page.mouse.wheel(0, 300);
await expect.poll(async () => sc.evaluate((el) => el.scrollTop), { timeout: 3000 }).toBeGreaterThan(0);
});
test('settings page fits mobile viewport', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
const panel = page.locator('.settings-panel');
await expect(panel).toBeVisible();
const box = await panel.boundingBox();
expect(box.width).toBeLessThanOrEqual(390);
expect(box.height).toBeLessThanOrEqual(844);
// No horizontal page overflow
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow).toBeLessThanOrEqual(0);
});
test('settings mobile nav is a hamburger side drawer', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
const burger = page.locator('.settings-menu-btn');
await expect(burger).toBeVisible();
const nav = page.locator('.settings-nav');
const before = await nav.boundingBox();
expect(before.x + before.width).toBeLessThanOrEqual(1);
await burger.click();
await expect(nav).toHaveClass(/nav-open/);
// Nav item switches section AND closes the drawer
await page.locator('.settings-nav-item', { hasText: 'Tags' }).click();
await expect(nav).not.toHaveClass(/nav-open/);
await expect(page.locator('h2', { hasText: 'Tags' }).first()).toBeVisible();
// Re-open and dismiss via backdrop
await burger.click();
await expect(nav).toHaveClass(/nav-open/);
await page.locator('.settings-nav-backdrop').click({ position: { x: 375, y: 500 } });
await expect(nav).not.toHaveClass(/nav-open/);
});
test('help page uses settings-style side navigation', async ({ page }) => {
const errs = [];
page.on('pageerror', (e) => errs.push(e.message));
await login(page);
await page.goto(`${FD_BASE}/help`, { waitUntil: 'domcontentloaded' });
// Same panel skeleton as settings
await expect(page.locator('.settings-panel')).toBeVisible();
await expect(page.locator('.settings-menu-btn')).toBeVisible();
// Mobile: nav drawer behind hamburger
const nav = page.locator('.settings-nav');
await expect(nav).not.toHaveClass(/nav-open/);
await page.locator('.settings-menu-btn').click();
await expect(nav).toHaveClass(/nav-open/);
await page.locator('.settings-nav-item', { hasText: 'Databases' }).click();
await expect(nav).not.toHaveClass(/nav-open/);
await expect(page.locator('h2', { hasText: 'Databases & Views' })).toBeVisible();
// App-wide coverage: all sections exist in the nav
const sections = ['Getting Started', 'Pages & Editor', 'Databases & Views', 'Tasks & Dependencies',
'Workspaces & Forges', 'Collaboration', 'Library, Trash & Search', 'AI Agent & Automations',
'Integrations & API', 'Offline & PWA', 'Accounts & SSO', 'Keyboard Shortcuts', 'Tips'];
const labels = (await nav.locator('.settings-nav-item').allInnerTexts()).join(' ');
for (const s of sections) expect(labels, s).toContain(s);
expect(errs).toEqual([]);
});
});
test.describe('desktop', () => {
test.use({ viewport: { width: 1440, height: 900 } });
test('settings and help keep the 2-column layout on desktop', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.settings-menu-btn')).toBeHidden();
const nav = page.locator('.settings-nav');
await expect(nav).toBeVisible();
const navBox = await nav.boundingBox();
expect(navBox.height).toBeGreaterThan(navBox.width);
await page.goto(`${FD_BASE}/help`, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.settings-menu-btn')).toBeHidden();
await page.locator('.settings-nav-item', { hasText: 'SSO' }).click();
await expect(page.locator('h2', { hasText: 'Accounts & SSO' })).toBeVisible();
await expect(page.locator('.settings-content')).toContainText('IndexedDB');
});
});
+37
View File
@@ -0,0 +1,37 @@
const { test } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
test.use({ viewport: { width: 390, height: 844 } });
test('diagnose mobile sidebar wheel scroll', async ({ page }) => {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', process.env.FD_USER || '[email protected]');
await page.fill('#password', process.env.FD_PASS || 'e2e-pass-123');
await page.click('.btn-primary');
await page.waitForURL('**/workspaces', { timeout: 10000 }).catch(() => {});
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => document.querySelector('.hamburger-btn'));
await page.locator('.hamburger-btn').click();
await page.waitForSelector('#sidebar.mobile-open');
const info = await page.evaluate(() => {
const sb = document.getElementById('sidebar');
const sc = document.querySelector('.sidebar-scroll');
const cs = getComputedStyle(sb);
const ccs = sc ? getComputedStyle(sc) : null;
return {
sidebar: { display: cs.display, height: cs.height, overflow: cs.overflow, sh: sb.scrollHeight, ch: sb.clientHeight },
scroll: sc ? { top: sc.scrollTop, sh: sc.scrollHeight, ch: sc.clientHeight, oy: ccs.overflowY, flex: ccs.flex, rect: JSON.stringify(sc.getBoundingClientRect()) } : null,
wheelTarget: (() => { const el = document.elementFromPoint(150, 400); return el ? el.className : 'none'; })(),
};
});
console.log('DIAG:', JSON.stringify(info, null, 1));
await page.mouse.move(150, 400);
await page.mouse.wheel(0, 300);
await page.waitForTimeout(300);
const after = await page.evaluate(() => {
const sc = document.querySelector('.sidebar-scroll');
return { scTop: sc.scrollTop, scSH: sc.scrollHeight, scCH: sc.clientHeight, bodyTop: document.scrollingElement.scrollTop };
});
console.log('AFTER WHEEL:', JSON.stringify(after));
});
+127
View File
@@ -0,0 +1,127 @@
const { test, expect } = require('@playwright/test');
/**
* v7.62 — menu contextuel au highlight de texte (toolbar façon Notion).
* Vérifie : les 4 lignes + Skills + pied, le menu « … », le sous-menu
* « Turn into », et surtout la PERSISTANCE du formatage après reload
* (sync() relit le DOM formaté via gtMd → source markdown).
* 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 || '[email protected]';
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' });
}
}
// Sélectionne tout le bloc et simule la fin du glisser-souris
async function selectBlock(page) {
await page.evaluate(() => {
const el = document.querySelector('#_blocksCt [data-bid]');
const r = document.createRange();
r.selectNodeContents(el);
const s = getSelection();
s.removeAllRanges();
s.addRange(r);
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
});
await expect(page.locator('.format-toolbar')).toBeVisible({ timeout: 5000 });
}
test('toolbar de sélection : structure, sous-menus, persistance', async ({ page }) => {
await login(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=Menu highlight ${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 });
await page.click('#_blocksCt [data-bid]');
await page.keyboard.type('Menu lors d un highlight de texte');
// ── 1. structure : 4 lignes + Skills + pied ──
await selectBlock(page);
const bar = page.locator('.format-toolbar');
await expect(bar.locator('.ft-row')).toHaveCount(4);
await expect(bar.locator('.ft-style')).toContainText('Normal Text');
await expect(bar.locator('.ft-group')).toHaveText('Skills');
await expect(bar.locator('.ft-skill')).toHaveCount(4);
await expect(bar.locator('.ft-skill').first()).toHaveText('Improve writing');
await expect(bar.locator('.ft-foot')).toContainText('Edit with AI');
// géométrie : sous la sélection, entièrement dans le viewport
const barBox = await bar.boundingBox();
const selBox = await page.evaluate(() => {
const r = getSelection().getRangeAt(0).getBoundingClientRect();
return { bottom: r.bottom };
});
expect(barBox.y).toBeGreaterThanOrEqual(selBox.bottom);
expect(barBox.y + barBox.height).toBeLessThanOrEqual(900);
expect(barBox.x).toBeGreaterThanOrEqual(0);
expect(barBox.x + barBox.width).toBeLessThanOrEqual(1440);
await page.screenshot({ path: 'shots/v762-toolbar.png' });
// ── 2. ligne 1 → sous-menu « Turn into » ──
await bar.locator('.ft-style').click();
const sub = page.locator('#blockMenuSub');
await expect(sub).toBeVisible();
await expect(sub).toContainText('Heading 4');
await expect(sub.locator('.bm-check')).toHaveCount(1); // type courant coché
await page.screenshot({ path: 'shots/v762-turn-submenu.png' });
await page.keyboard.press('Escape');
// ── 3. « … » → menu contextuel de bloc (recherche + pied) ──
await selectBlock(page);
await bar.locator('.ft-more').click();
const menu = page.locator('#blockMenu');
await expect(menu).toBeVisible();
await expect(menu.locator('.bm-search')).toHaveAttribute('placeholder', 'Search actions...');
await expect(menu).toContainText('Copy link to block');
await expect(menu.locator('.bm-foot')).toContainText('Last edited by');
await page.screenshot({ path: 'shots/v762-more-menu.png' });
await page.keyboard.press('Escape');
await expect(menu).toBeHidden();
// ── 4. persistance : B (gras) survit au reload ──
await selectBlock(page);
await page.getByRole('button', { name: 'B', exact: true }).click();
await expect(page.locator('#_blocksCt [data-bid] b, #_blocksCt [data-bid] strong')).toHaveCount(1);
await page.waitForTimeout(2500); // autosave 1,5 s
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
await expect(page.locator('#_blocksCt [data-bid] strong')).toHaveCount(1); // rendu mdEsc
// ── 5. persistance : S (surlignage) survit au reload ──
await selectBlock(page);
await bar.locator('.ft-hl').click();
await expect(page.locator('#_blocksCt [data-bid] mark')).toHaveCount(1);
await page.waitForTimeout(2500);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
await expect(page.locator('#_blocksCt [data-bid] mark')).toHaveCount(1);
// ── 6. fermeture hors sélection ──
await page.mouse.click(10, 400);
await expect(page.locator('.format-toolbar')).toBeHidden();
});
+125
View File
@@ -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 || '[email protected]';
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' });
});
+288 -13
View File
@@ -2341,6 +2341,36 @@ button.breadcrumb-current { max-width: 260px; }
cursor: grabbing;
}
/* + « ajouter un bloc dessous » — juste à gauche du handle (gouttière 64px) */
.block-insert-btn {
position: absolute;
left: -64px;
top: 5px;
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);
font-size: 17px;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease;
}
.block-wrapper:hover .block-insert-btn,
.block-wrapper:focus-within .block-insert-btn {
opacity: 1;
}
.block-insert-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);
@@ -2376,6 +2406,30 @@ button.breadcrumb-current { max-width: 260px; }
.block-menu .bm-item.bm-danger .bm-ico { color: #e5484d; }
.block-menu .bm-item.bm-danger:hover { background: rgba(229,72,77,.12); }
.block-menu .bm-sep { height: 1px; background: var(--border, #2a2a2a); margin: 5px 4px; }
/* ── v7.61 : menu contextuel type Notion — recherche, sections, pied, sous-menus ── */
.block-menu .bm-group {
padding: 9px 9px 3px;
font-size: 10px;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: .5px;
}
.block-menu .bm-foot {
border-top: 1px solid var(--border, #2a2a2a);
margin-top: 6px;
padding: 8px 9px 6px;
font-size: 11.5px;
line-height: 1.55;
color: var(--text-tertiary);
}
.block-menu .bm-check { margin-left: auto; color: var(--accent, #2383e2); font-size: 12px; }
.block-menu .bm-item.bm-current { background: var(--bg-secondary); }
.block-menu .bm-dot {
display: inline-block; width: 12px; height: 12px; border-radius: 3px;
border: 1px solid var(--border, #333); vertical-align: -2px;
}
.block-menu .bm-search:focus { border-color: var(--accent, #2383e2); }
.block-menu .bm-colors { padding: 6px 9px; }
.block-menu .bm-colors-label { font-size: 10px; font-weight: 600; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: .4px; margin-bottom: 6px; }
.block-menu .bm-swatches { display: flex; flex-wrap: wrap; gap: 6px; }
@@ -2406,7 +2460,7 @@ button.breadcrumb-current { max-width: 260px; }
color: var(--text-secondary); cursor: pointer; font-size: 12px; border-radius: 6px;
}
.block-menu .bm-cancel:hover { background: var(--bg-hover); }
.block-menu-sub { min-width: 180px; max-height: 320px; overflow-y: auto; }
.block-menu-sub { min-width: 230px; max-height: 60vh; overflow-y: auto; }
.ftable-hdr-toggle { margin-left: 12px; font-size: 12px; color: var(--text-tertiary); display:inline-flex; align-items:center; gap:2px; }
.ftable-hdr-toggle label { display:inline-flex; align-items:center; gap:4px; cursor:pointer; }
@@ -2476,11 +2530,20 @@ button.breadcrumb-current { max-width: 260px; }
font-size: 16px;
}
/* Numbered list */
/* Numbered list — data-num est calculé au rendu (rangée contiguë) */
.block-numbered {
padding-left: 22px;
padding-left: 32px;
position: relative;
}
.block-numbered[data-num]::before {
content: attr(data-num) ". ";
position: absolute;
left: 0;
top: 0;
min-width: 24px;
color: var(--text-primary);
font-size: 16px;
}
/* To-do */
.block-todo {
@@ -2852,35 +2915,244 @@ button.breadcrumb-current { max-width: 260px; }
font-size: 14px;
}
/* FORMAT TOOLBAR (FLOATING) */
/* FORMAT TOOLBAR (FLOATING) — menu contextuel au highlight (v7.62, façon Notion) */
.format-toolbar {
position: fixed;
z-index: 998;
display: flex;
align-items: center;
gap: 2px;
flex-direction: column;
align-items: stretch;
gap: 1px;
min-width: 216px;
max-height: calc(100vh - 16px);
overflow-y: auto;
background: var(--bg-modal);
border: 1px solid var(--border-light);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
padding: 4px 6px;
padding: 6px;
}
.format-toolbar button {
width: 30px;
.format-toolbar .ft-row {
display: flex;
align-items: center;
gap: 2px;
}
.format-toolbar .ft-item {
height: 28px;
min-width: 28px;
padding: 0 7px;
border: none;
background: transparent;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
color: var(--text-primary);
font-size: 14px;
font-size: 13px;
cursor: pointer;
transition: background 0.1s;
}
.format-toolbar button:hover {
.format-toolbar .ft-item:hover {
background: var(--bg-hover);
}
.format-toolbar .ft-item svg {
display: block;
}
/* Ligne 1 : style de texte (libellé du type de bloc + sous-menu) */
.format-toolbar .ft-style {
width: 100%;
justify-content: flex-start;
}
.format-toolbar .ft-style-ico {
font-weight: 600;
color: var(--text-secondary);
}
.format-toolbar .ft-style-name {
flex: 1;
text-align: left;
}
.format-toolbar .ft-caret {
color: var(--text-dim);
font-size: 15px;
line-height: 1;
}
/* « A » : couleur du texte (barre colorée sous la lettre) */
.format-toolbar .ft-a-letter {
position: relative;
font-weight: 600;
}
.format-toolbar .ft-a-letter::after {
content: '';
position: absolute;
left: 1px;
right: 1px;
bottom: -4px;
height: 3px;
border-radius: 2px;
background: var(--accent);
}
.format-toolbar .ft-tx,
.format-toolbar .ft-code-ico {
font-size: 12px;
font-family: var(--font-mono, monospace);
}
.format-toolbar .ft-hl {
border-radius: 2px;
padding: 0 3px;
background: rgba(255,197,61,.28);
}
.format-toolbar .ft-more {
font-size: 16px;
letter-spacing: 1px;
}
/* Séparateurs, groupe « Skills », pied de page */
.format-toolbar .ft-sep {
height: 1px;
background: var(--border);
margin: 5px 2px;
}
.format-toolbar .ft-group {
font-size: 11px;
color: var(--text-dim);
padding: 3px 7px 2px;
cursor: default;
}
.format-toolbar .ft-skill {
height: 26px;
justify-content: flex-start;
font-size: 13px;
color: var(--text-secondary);
}
.format-toolbar .ft-foot {
height: 26px;
width: 100%;
justify-content: space-between;
font-size: 12px;
color: var(--text-secondary);
}
.format-toolbar .ft-foot kbd {
font-family: inherit;
font-size: 11px;
color: var(--text-dim);
}
/* Info-bulle des éléments du menu de sélection : bulle claire, à droite */
.tooltip.tooltip-light {
background: #ffffff;
border-color: #e3e3e3;
color: #191919;
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
white-space: normal;
max-width: 240px;
line-height: 1.35;
}
.tooltip.tooltip-light em {
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 {
@@ -2925,6 +3197,7 @@ button.breadcrumb-current { max-width: 260px; }
.page-title-input { font-size: 28px; }
.blocks-container { padding: 8px 16px 96px; }
.block-handle { display: none; }
.block-insert-btn { display: none; }
.block-actions { display: none; }
.editor-statusbar { left: 0; }
}
@@ -3400,6 +3673,7 @@ button.breadcrumb-current { max-width: 260px; }
.page-cover-area { padding: 32px 32px 8px; }
.blocks-container { padding: 8px 32px 80px; }
.block-handle { left: -32px; }
.block-insert-btn { left: -56px; }
.page-title-block::before { left: -32px; }
.page-title-input { font-size: 32px; }
@@ -3424,7 +3698,7 @@ button.breadcrumb-current { max-width: 260px; }
/* ── Sidebar mobile ── */
.sidebar {
display: block;
display: flex; flex-direction: column; /* block cassait flex:1 de .sidebar-scroll → drawer impossible à scroller */
position: fixed !important;
top: 0; left: 0;
bottom: 0; width: 85vw; max-width: 320px;
@@ -3490,6 +3764,7 @@ button.breadcrumb-current { max-width: 260px; }
.page-title-input { font-size: 24px; }
.blocks-container { padding: 8px 16px 96px; }
.block-handle { display: none; }
.block-insert-btn { display: none; }
.block-actions { display: none; }
.page-title-block::before { display: none; }
.page-title-block .page-icon-emoji { margin-left: 0; }
@@ -3577,7 +3852,7 @@ button.breadcrumb-current { max-width: 260px; }
.slash-menu { width: 90vw; max-width: 90vw; left: 5vw !important; }
/* ── Format Toolbar ── */
.format-toolbar { left: 8px !important; right: 8px; flex-wrap: wrap; justify-content: center; }
.format-toolbar { left: 8px !important; right: 8px; }
}
/* ═══════════════════════════════════════════════════════════
+34
View File
@@ -0,0 +1,34 @@
/* ═══ Panel skeleton shared by /settings and /help (Notion-style modal) ═══ */
.settings-overlay{position:fixed;inset:0;z-index:500;display:flex;background:rgba(0,0,0,.4);backdrop-filter:blur(2px);}
.settings-panel{position:relative;display:flex;width:1050px;max-width:98vw;height:85vh;margin:auto;background:var(--bg-primary);border:1px solid var(--border-strong);border-radius:14px;box-shadow:var(--shadow-modal);overflow:hidden;}
.settings-nav{width:200px;min-width:200px;background:var(--bg-sidebar);border-right:1px solid var(--border);padding:8px 0;overflow-y:auto;}
.settings-nav-header{padding:12px 16px;font-size:13px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;}
.settings-nav-item{display:flex;align-items:center;gap:8px;padding:6px 16px;font-size:13px;color:var(--text-secondary);cursor:pointer;transition:background 100ms;border-radius:0;}
.settings-nav-item:hover{background:var(--bg-hover);}
.settings-nav-item.active{background:rgba(35,131,226,.12);color:var(--text);font-weight:500;}
.settings-content{flex:1;overflow-y:auto;padding:24px 32px;}
.settings-content h2{font-size:18px;font-weight:600;margin-bottom:4px;}
.settings-content .section-desc{font-size:12px;color:var(--text-dim);margin-bottom:24px;}
.settings-close{position:absolute;top:12px;right:12px;width:32px;height:32px;background:none;border:none;color:var(--text-dim);font-size:20px;cursor:pointer;border-radius:6px;display:flex;align-items:center;justify-content:center;text-decoration:none;}
.settings-close:hover{background:var(--bg-hover);color:var(--text);}
/* Mobile hamburger for the panel nav — hidden on desktop */
.settings-menu-btn{display:none;position:absolute;top:8px;left:8px;z-index:20;width:40px;height:40px;border:none;border-radius:8px;background:var(--bg-tertiary);color:var(--text);align-items:center;justify-content:center;cursor:pointer;}
.settings-nav-backdrop{display:none;}
/* ── Mobile: nav becomes a left side-navigation drawer (hamburger opens it) ── */
@media (max-width:768px){
.settings-overlay{background:var(--bg-primary);backdrop-filter:none;}
.settings-panel{flex-direction:column;width:100vw;max-width:100vw;height:100dvh;max-height:100dvh;margin:0;border:none;border-radius:0;box-shadow:none;}
.settings-menu-btn{display:flex;}
.settings-nav{position:fixed;top:0;left:0;bottom:0;width:260px;max-width:80vw;min-width:0;z-index:600;transform:translateX(-100%);transition:transform .2s cubic-bezier(0.4,0,0.2,1);border-right:1px solid var(--border);box-shadow:4px 0 24px rgba(0,0,0,.4);padding-top:8px;}
.settings-nav.nav-open{transform:translateX(0);}
.settings-nav-item{padding:10px 16px;}
.settings-nav-backdrop{display:block;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:599;}
.settings-content{padding:56px 16px 16px;}
.settings-close{top:8px;right:8px;width:44px;height:44px;background:var(--bg-tertiary);}
.setting-row{flex-wrap:wrap;}
.setting-control{margin-left:0;width:100%;}
.prov-grid{grid-template-columns:1fr;}
.avatar-section{gap:10px;}
}
+41 -2
View File
@@ -44,7 +44,7 @@
{key:'canvases', icon:'🎨', label:'Design System – Canevas',
sub:'Galerie de canevas : créer, insérer, enregistrer', action:'canvases', chev:true},
{key:'plugins', icon:'🧩', label:'Add plugins',
sub:'Modules on/off — bientôt (phase 8)', disabled:true},
sub:'Modules on/off — effet réel : routes, UI, outils', action:'plugins', chev:true},
{key:'memory', icon:'🧠', label:'Mémoire (on/off)',
sub:'Résumés des échanges injectés à chaque run', action:'memory'}
];
@@ -74,7 +74,7 @@
skillForm: {id: null, name: '', description: '', prompt_template: ''},
galleryFilter: '',
canvases: [], canvas: null,
connectors: [], connector: null,
connectors: [], connector: null, plugins: [],
connectorForm: {name: '', url: '', secret: '', kind: 'custom',
auth: 'bearer', hint: ''},
cmdFocus: -1, slashDismiss: false,
@@ -1148,6 +1148,7 @@
if(s === 'connector-new') return 'Ajouter un connecteur';
if(s === 'canvases') return 'Design System – Canevas';
if(s === 'canvas') return this.canvas ? this.canvas.name : 'Canevas';
if(s === 'plugins') return 'Add plugins';
return 'Ajouter au contexte';
},
get plusItems(){
@@ -1163,6 +1164,7 @@
if(s === 'connector-new') return [];
if(s === 'canvases') return this._canvasItems();
if(s === 'canvas') return this._canvasActionItems();
if(s === 'plugins') return this._pluginItems();
if(s === 'root'){
// l'entrée Mémoire reflète l'état réel du toggle de la conversation
var on = this.memoryOn;
@@ -1274,6 +1276,39 @@
var tools = (c.kind === 'mcp' && c.tools_count) ? ' · ' + c.tools_count + ' outil(s)' : '';
return mark + (c.detail || c.status) + tools + (c.enabled ? '' : ' · désactivé');
},
// ── Plugins de l'instance (v7.58.0) ──
fetchPlugins(){
var self = this;
fetch('/api/agent/plugins').then(function(r){ return r.json(); })
.then(function(d){ self.plugins = (d && d.plugins) || []; })
.catch(function(){ self.plugins = []; });
},
_pluginItems(){
var out = (this.plugins || []).map(function(pl){
return {key:'pl-' + pl.slug, icon: pl.enabled ? '✅' : '⛔',
label: pl.name,
sub: (pl.enabled ? 'Activé' : 'Désactivé') + ' — ' + pl.description,
action:'plugin-toggle', slug: pl.slug};
});
if(!out.length) out.push({key:'pl-none', _sep:'Aucun plugin'});
out.push({key:'pl-hint', _sep:
'OFF = routes refusées, UI masquée, outils retirés'});
return out;
},
pluginToggle(slug){
var self = this;
var pl = (this.plugins || []).filter(function(x){ return x.slug === slug; })[0];
if(!pl) return;
fetch('/api/agent/plugins/' + encodeURIComponent(slug), {
method:'PATCH', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({enabled: !pl.enabled})
}).then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); })
.then(function(res){
if(!res.ok){ self.toast((res.d && res.d.detail) || 'Bascule impossible.', true); return; }
self.toast(res.d.enabled ? 'Plugin activé.' : 'Plugin désactivé.');
self.fetchPlugins();
}).catch(function(){ self.toast('Bascule impossible (réseau).', true); });
},
_connectorItems(){
var self = this;
var out = (this.connectors || []).map(function(c){
@@ -1579,6 +1614,10 @@
auth:'bearer', hint:''};
this.plusSection = 'connector-new'; this.plusFocus = -1; return;
}
if(it.action === 'plugins'){
this.fetchPlugins(); this.plusSection = 'plugins'; this.plusFocus = 0; return;
}
if(it.action === 'plugin-toggle'){ this.pluginToggle(it.slug); return; }
if(it.action === 'connector-probe'){ this.connectorProbe(); return; }
if(it.action === 'connector-toggle'){ this.connectorToggle(); return; }
if(it.action === 'connector-delete'){ this.connectorDelete(); return; }
+18 -5
View File
@@ -84,19 +84,32 @@
// Tooltip system
document.addEventListener('mouseover', function(e) {
const target = e.target.closest('[data-tooltip]');
const target = e.target.closest('[data-tooltip], [data-tooltip-html]');
if (!target) return;
const tip = document.getElementById('tooltip');
if (!tip) return;
tip.textContent = target.getAttribute('data-tooltip');
const html = target.getAttribute('data-tooltip-html');
if (html) tip.innerHTML = html;
else tip.textContent = target.getAttribute('data-tooltip') || '';
// v7.62 : éléments du menu de sélection → info-bulle claire à droite (façon Notion)
const inToolbar = !!target.closest('.format-toolbar');
tip.classList.toggle('tooltip-light', inToolbar);
tip.style.display = 'block';
const rect = target.getBoundingClientRect();
tip.style.left = rect.left + 'px';
tip.style.top = (rect.bottom + 4) + 'px';
if (inToolbar) {
tip.style.left = (rect.right + 8) + 'px';
tip.style.top = Math.max(8, rect.top + rect.height / 2 - tip.offsetHeight / 2) + 'px';
if (rect.right + 8 + tip.offsetWidth > window.innerWidth) {
tip.style.left = Math.max(8, rect.left - tip.offsetWidth - 8) + 'px';
}
} else {
tip.style.left = rect.left + 'px';
tip.style.top = (rect.bottom + 4) + 'px';
}
});
document.addEventListener('mouseout', function(e) {
const target = e.target.closest('[data-tooltip]');
const target = e.target.closest('[data-tooltip], [data-tooltip-html]');
if (!target) return;
const tip = document.getElementById('tooltip');
if (tip) tip.style.display = 'none';
+23
View File
@@ -0,0 +1,23 @@
if (!window.__fdHelpScriptLoaded) {
window.__fdHelpScriptLoaded = true;
// Alpine.data register (CSP build ne résout pas les globales window)
if (window.Alpine) { Alpine.data('helpInit', helpInit); fdRefreshXData('helpInit'); } else document.addEventListener('alpine:init', function () { Alpine.data('helpInit', helpInit); });
function helpInit() {
return {
section: 'start',
navOpen: false,
closeNavOnMobile() { this.navOpen = false; },
// Bouton ✕ (haut droite, visible aussi en mobile) : ferme l'aide.
close() {
if (document.referrer && document.referrer !== location.href) { history.back(); }
else { location.href = '/workspaces'; }
},
go(sec) {
this.section = sec;
this.navOpen = false;
var c = document.querySelector('.settings-content');
if (c) c.scrollTop = 0;
},
};
}
}
+508 -66
View File
@@ -52,6 +52,57 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
return out;
}
window.gtTok=gtTok;
// v7.62.0 : lecture d'un bloc vers sa source markdown, en passant par le
// DOM formaté (bold/italic/underline/strike/code/mark/liens inline posés
// par le menu de sélection). Sans ça, sync() relisait du texte brut et
// perdait tout le formatage au prochain render(). Sans balise de mise en
// forme, on retombe sur gtTok (comportement identique à l'ancien).
function gtMd(e){
if(!e) return '';
if(!e.querySelector||!e.querySelector('strong,b,em,i,u,s,del,strike,code,mark,a[href]')) return gtTok(e);
function w(node){
var out='';
for(var i=0;i<node.childNodes.length;i++){
var n=node.childNodes[i];
if(n.nodeType===3){out+=n.nodeValue;continue;}
if(n.nodeType!==1)continue;
var cl=n.classList;
if(cl&&(cl.contains('fd-wiki-chip')||cl.contains('fd-wiki-date'))){out+=n.getAttribute('data-token')||'';continue;}
if(n.tagName==='BR'){out+='\n';continue;}
if(cl&&cl.contains('fd-math-inline')){out+='$$'+(n.getAttribute('data-tex')||'')+'$$';continue;}
var t=n.tagName, inner=w(n);
if((t==='STRONG'||t==='B')&&inner){out+='**'+inner+'**';continue;}
if((t==='EM'||t==='I')&&inner){out+='*'+inner+'*';continue;}
if(t==='U'&&inner){out+='__'+inner+'__';continue;}
if((t==='S'||t==='DEL'||t==='STRIKE')&&inner){out+='~~'+inner+'~~';continue;}
if(t==='CODE'&&inner&&!(n.hasAttribute&&n.hasAttribute('data-bid'))){out+='`'+inner+'`';continue;}
if(t==='MARK'&&inner){out+='=='+inner+'==';continue;}
if(t==='A'){var h=n.getAttribute('href')||'';out+=/^https?:/i.test(h)?'['+(inner||h)+']('+h+')':inner;continue;}
out+=inner;
}
return out;
}
return w(e);
}
window.gtMd=gtMd;
// Enveloppe/désenveloppe une sélection dans une balise inline (code, mark)
function _selWrapHost(r,tag){
var a=r.startContainer.nodeType===1?r.startContainer:r.startContainer.parentElement;
var b=r.endContainer.nodeType===1?r.endContainer:r.endContainer.parentElement;
a=a&&a.closest(tag);b=b&&b.closest(tag);
if(!a||a!==b)return null;
return (a.hasAttribute&&a.hasAttribute('data-bid'))?null:a; // <code> racine d'un bloc code : ne pas dépiler
}
function _selWrapToggle(tag){
var s=window.getSelection();if(!s.rangeCount)return;
var r=s.getRangeAt(0);
var host=_selWrapHost(r,tag);
if(host){var p=host.parentNode;while(host.firstChild)p.insertBefore(host.firstChild,host);p.removeChild(host);p.normalize();return;}
var el=document.createElement(tag);
try{r.surroundContents(el);}catch(e){el.appendChild(r.extractContents());r.insertNode(el);}
var s2=window.getSelection(),nr=document.createRange();
nr.selectNodeContents(el);s2.removeAllRanges();s2.addRange(nr);
}
function splitCaret(e){
const s=window.getSelection(); if(!s.rangeCount||!s.rangeCount)return null; const sel=s.getRangeAt(0);
const MK='\uF000FD'; const mark=document.createElement('span'); mark.textContent=MK;
@@ -73,6 +124,9 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
.replace(/\*\*([^*]+)\*\*/g,'<strong>$1</strong>')
.replace(/\*([^*]+)\*/g,'<em>$1</em>')
.replace(/~~([^~]+)~~/g,'<s>$1</s>')
.replace(/__([^_]+)__/g,'<u>$1</u>') // souligné (toolbar de sélection)
.replace(/==([^=]+)==/g,'<mark>$1</mark>') // surlignage
.replace(/\$\$([^$]+)\$\$/g,function(m,t){return '<span class="fd-math-inline" data-tex="'+t.replace(/"/g,'&quot;')+'"></span>';}) // équation inline
.replace(/\[\[fdpage:(\d+)\]\]/g,function(m,pid){
return '<a class="fd-wiki-chip" contenteditable="false" href="/pages/'+pid+'" data-token="'+m+'" data-pid="'+pid+'" title="Linked page"><span class="fd-wiki-label">…</span></a>';})
.replace(/\[\[fddate:([0-9]{4}-[0-9]{2}-[0-9]{2})(?:T[0-9:]{5,8})?\]\]/g,function(m,iso){
@@ -266,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 '<div class="slash-prev" style="padding:0">'+inner+'</div>';};
@@ -311,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){
@@ -336,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 `<div class="slash-item" data-sid="${c.id}" style="display:flex;align-items:center;gap:10px;padding:7px 10px;font-size:14px;color:var(--text-primary);cursor:pointer;border-radius:5px">
<span class="slash-item-icon" style="width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-size:15px;background:var(--bg-secondary);border-radius:4px;flex-shrink:0">${c.icon}</span>
<span class="slash-item-name" style="flex:1;font-weight:500">${c.name}</span>
<span class="slash-item-hint" style="font-size:12px;color:var(--text-dim);font-family:var(--font-mono);opacity:.7">${_SHORTCUT[c.id]||''}</span></div>`;},
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+=`<div class="sm-group" style="padding:6px 10px 3px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.4px">${_GROUP_LABEL[g.name]||g.name}</div>`;
items.forEach(c=>{SM._it.push(c);
h+=`<div class="slash-item" data-sid="${c.id}" style="display:flex;align-items:center;gap:10px;padding:7px 10px;font-size:14px;color:var(--text-primary);cursor:pointer;border-radius:5px">
<span class="slash-item-icon" style="width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-size:15px;background:var(--bg-secondary);border-radius:4px;flex-shrink:0">${c.icon}</span>
<span class="slash-item-name" style="flex:1;font-weight:500">${c.name}</span>
<span class="slash-item-hint" style="font-size:12px;color:var(--text-dim);font-family:var(--font-mono);opacity:.7">${_SHORTCUT[c.id]||''}</span></div>`;});}
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();}
@@ -358,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','+<span class="fd-mtb-caret">▾</span>'],['turn','Turn into<span class="fd-mtb-caret">▾</span>'],
['at','@'],['comment','💬'],['image','🖼️'],['trash','🗑️'],
['left','←'],['right','→'],['up','↑'],['down','↓'],['undo','↩'],['redo','↪'],
['color','Color<span class="fd-mtb-caret">▾</span>'],['more','More<span class="fd-mtb-caret">▾</span>'],['kbd','⌨️']];
const d=document.createElement('div');d.id='fdMobileBar';d.className='fd-mtb';
d.innerHTML=items.map(it=>'<button type="button" class="fd-mtb-item'+(wide.indexOf(it[0])>=0?' fd-mtb-wide':'')+'" data-mtb="'+it[0]+'">'+it[1]+'</button>').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='<div class="fd-msheet-head"><span class="fd-msheet-title">'+title+'</span><button type="button" class="fd-msheet-cancel">Cancel</button></div><div class="fd-msheet-list block-menu">'+html+'</div>';
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+='<div class="sm-group">'+(_GROUP_LABEL[g.name]||g.name)+'</div>';
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<E.blocks.length)?E.blocks[idx]:null;
const bid=b?b.id:'';
if(k==='plus'){this.sheet('Insert block',this._insertHtml(),ev=>{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;
@@ -419,7 +535,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
// ═══════════ Inline AI composer (Notion AI-style) ═══════════
// Space on an empty paragraph → "Edit with AI" pill → Enter → "Brewing…"
// → framed result → "Insert below" → applyAIBlocks.
const AIC={_open:false,_idx:-1,_stage:'',_html:'',
const AIC={_open:false,_idx:-1,_stage:'',_html:'',_md:'',
ROOT:null,
mk(){ if(this.ROOT) return this.ROOT;
const d=document.createElement('div'); d.id='_aiC'; d.style.cssText='position:fixed;z-index:1200;width:420px;max-width:92vw;display:none';
@@ -432,7 +548,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
this.paint(); this.ROOT.style.display='block'; this._open=true; this._focusIngest();
document.addEventListener('mousedown',function onDoc(ev){ if(!self.ROOT.contains(ev.target)){ self.close(); document.removeEventListener('mousedown',onDoc); } });
},
close(){ if(this.ROOT)this.ROOT.style.display='none'; this._open=false; this._html=''; if(window.E)window.E.dirty=true; this._stage=''; },
close(){ if(this.ROOT)this.ROOT.style.display='none'; this._open=false; this._html=''; this._md=''; if(window.E)window.E.dirty=true; this._stage=''; },
_anchorRect(idx){ const E=window.E; if(!E||!E.blocks)return{left:Math.max(24,window.innerWidth/2-210),top:window.innerHeight/2,bottom:window.innerHeight/2,width:420};
const b=E.blocks[idx]; const el=E.getEl(b?b.id:null); if(el){const r=el.getBoundingClientRect();return r;} return {left:24,top:120,bottom:180,width:420}; },
_focusIngest(){ const i=this.ROOT.querySelector('.aici-input'); if(i){i.focus();} },
@@ -478,12 +594,17 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
const E=window.E; const ctx=(typeof window.fdGetPageContext==='function')?String(window.fdGetPageContext()||''):'';
if(!(window.fdAgent&&window.fdAgent.generate)){ this._err('Agent IA non configuré'); return; }
window.fdAgent.generate(prompt, ctx).then(function(text){
self._html=self._mdToHtml(text||''); self._stage='result'; self.paint();
// On conserve le MARKDOWN source : c'est lui qui est inséré (md2b),
// pas le texte extrait du HTML rendu qui perd titres/listes/gras.
self._md=String(text||'');
self._html=self._mdToHtml(self._md); self._stage='result'; self.paint();
}).catch(function(err){ self._err((err&&err.message)?err.message:'Erreur'); });
},
_err(msg){ this._html='<p style="color:#e5484d">⚠ '+esc(msg)+'</p>'; this._stage='result'; this.paint(); },
_err(msg){ this._md=''; this._html='<p style="color:#e5484d">⚠ '+esc(msg)+'</p>'; this._stage='result'; this.paint(); },
insert(){ const E=window.E; if(!E||typeof E.applyAIBlocks!=='function')return; E.sync();
const result=this._resultText(); if(!result)return; E.applyAIBlocks(result); E.dirty=true; E.autoSave(); this.close(); },
// Markdown source d'abord ; repli sur le texte visible (erreurs).
const result=String(this._md||'').trim()||this._resultText(); if(!result)return;
E.applyAIBlocks(result); E.dirty=true; E.autoSave(); this.close(); },
_resultText(){ const el=this.ROOT&&this.ROOT.querySelector('.aici-result'); if(!el)return ''; const r=document.createRange();r.selectNodeContents(el);return r.toString(); },
toast(m){ if(window.showToast)window.showToast(m,'info'); }
};
@@ -595,11 +716,22 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
const cls=tag==='div'?`block-content ${b.type==='bulleted_list'?'block-bullet':b.type==='numbered_list'?'block-numbered':b.type==='to_do'?'block-todo':b.type==='toggle'?'block-toggle':b.type==='code'?'block-code':b.type==='callout'?'block-callout':''}`:`block-content block-${tag}`;
const style=b.type==='callout'?` style="background:${(b.style&&b.style.bgColor)||'var(--blue-bg)'}${(b.style&&b.style.color)?';color:'+b.style.color:''}"`:((b.style&&(b.style.bgColor||b.style.color))?` style="${(b.style.bgColor?'background:'+b.style.bgColor+';':'')+(b.style.color?'color:'+b.style.color+';':'')}"`:'');
const bidAttr=b.type.startsWith('heading_')?` id="h-${(b.id||'').replace(/[^a-zA-Z0-9]/g,'')}"`:'';
// Numéro d'ordre : rang des blocs `numbered_list` contigus (1,2,3…) ;
// passé en attribut data-num → le CSS le rend hors du contenu éditable.
let numAttr='';
if(b.type==='numbered_list'&&idx>=0){
const bs=(window.E&&window.E.blocks)||[];
let s=idx; while(s>0&&bs[s-1]&&bs[s-1].type==='numbered_list')s--;
numAttr=` data-num="${idx-s+1}"`;
}
const actions=idx>=0?`<div class="block-actions"><button class="block-add-btn" onclick="E.addAfter(${idx})">+</button></div>`:'';
// + « ajouter un bloc dessous », juste avant le handle (gouttière 64px)
const insertBtn=idx>=0?`<button type="button" class="block-insert-btn" title="Add block below" aria-label="Add block below" onclick="E.addAfter(${idx})">+</button>`:'';
return `<div class="block-wrapper" data-id="${b.id}">
${insertBtn}
<div class="block-handle"><svg viewBox="0 0 16 16" width="14" height="14"><circle cx="4" cy="3" r="1.3"/><circle cx="11" cy="3" r="1.3"/><circle cx="4" cy="8" r="1.3"/><circle cx="11" cy="8" r="1.3"/><circle cx="4" cy="13" r="1.3"/><circle cx="11" cy="13" r="1.3"/></svg></div>
${actions}
<${tag}${style}${bidAttr} class="${cls}">${inner}</${tag}>
<${tag}${style}${bidAttr}${numAttr} class="${cls}">${inner}</${tag}>
</div>`;
}
@@ -905,74 +1037,222 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
{id:'numbered_list',name:'Numbered list'},{id:'to_do',name:'To-do'},{id:'toggle',name:'Toggle'},
{id:'quote',name:'Quote'},{id:'callout',name:'Callout'},{id:'table',name:'Table'},
{id:'math',name:'Math equation'},{id:'columns',name:'Column list'},{id:'button',name:'Button / Automation'},
{id:'code',name:'Code block'}
{id:'code',name:'Code block'},{id:'synced',name:'Synced block'}
];
const _BLOCK_COLORS_TEXT=['default','#E5484D','#F76B15','#FFC53D','#46A758','#3E63DD','#8E4EC6','#12A594','#94A3B8'];
const _BLOCK_COLORS_BG=['default','rgba(229,72,77,.15)','rgba(247,107,21,.15)','rgba(255,197,61,.15)','rgba(70,154,88,.15)','rgba(62,99,221,.15)','rgba(142,78,198,.15)','rgba(18,165,148,.15)','rgba(148,163,184,.2)'];
function _blockMenuClose(){var m=document.getElementById('blockMenu');if(m)m.remove();m=document.getElementById('blockMenuSub');if(m)m.remove();}
// Sous-menu « Turn into » — ordre Notion ; id ∈ _BLOCK_TURN_TYPES, cols = nb de colonnes
const _BLOCK_TURN_MENU=[
{id:'paragraph',name:'Text',icon:'T'},{id:'heading_1',name:'Heading 1',icon:'H1'},
{id:'heading_2',name:'Heading 2',icon:'H2'},{id:'heading_3',name:'Heading 3',icon:'H3'},
{id:'heading_4',name:'Heading 4',icon:'H4'},{id:'bulleted_list',name:'Bulleted list',icon:'•'},
{id:'numbered_list',name:'Numbered list',icon:'1.'},{id:'to_do',name:'To-do list',icon:'☑'},
{id:'toggle',name:'Toggle list',icon:'▸'},{id:'code',name:'Code',icon:'&lt;/&gt;'},
{id:'quote',name:'Quote',icon:'”'},{id:'callout',name:'Callout',icon:'ⓘ'},
{id:'math',name:'Block equation',icon:'∑'},{id:'synced',name:'Synced block',icon:'⛓'},
{id:'columns',name:'2 columns',icon:'▦',cols:2},{id:'columns',name:'3 columns',icon:'▦',cols:3},
{id:'columns',name:'4 columns',icon:'▦',cols:4},{id:'columns',name:'5 columns',icon:'▦',cols:5}
];
// Couleurs texte/fond alignées par index — noms Notion (Dernière utilisée ailleurs)
const _BLOCK_COLOR_NAMES=['Default','Gray','Brown','Orange','Yellow','Green','Blue','Purple','Pink','Red'];
const _BLOCK_COLORS_TEXT=['default','#94A3B8','#8A6642','#F76B15','#FFC53D','#46A758','#3E63DD','#8E4EC6','#E5539B','#E5484D'];
const _BLOCK_COLORS_BG=['default','rgba(148,163,184,.15)','rgba(138,102,66,.15)','rgba(247,107,21,.15)','rgba(255,197,61,.15)','rgba(70,154,88,.15)','rgba(62,99,221,.15)','rgba(142,78,198,.15)','rgba(229,83,155,.15)','rgba(229,72,77,.15)'];
// Raccourcis affichés — implémentés par le handler clavier en bas de fichier
const _BM_SHORTCUTS={link:'Alt+⇧+L',duplicate:'Ctrl+D',move:'Ctrl+⇧+P',delete:'Del',comment:'Ctrl+⇧+M',suggest:'Ctrl+⇧+Alt+X',ai:'Ctrl+J',color:'Ctrl+⇧+H'};
var _bmSubKind='',_bmSubTimer=null;
function _blockMenuClose(){var m=document.getElementById('blockMenu');if(m)m.remove();m=document.getElementById('blockMenuSub');if(m)m.remove();clearTimeout(_bmSubTimer);_bmSubKind='';}
function _blockAttr(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');}
function _blockMenuBid(){var m=document.getElementById('blockMenu');return m?(m.getAttribute('data-bid')||''):'';}
// Sous-menu ouvert au survol de l'entrée correspondante (panneau de droite)
function _blockSubOpen(kind,bid,anchor){
if(!kind||!bid)return;
var m=document.getElementById('blockMenu');if(!m)return;
if(_bmSubKind===kind&&document.getElementById('blockMenuSub'))return;
clearTimeout(_bmSubTimer);_bmSubKind=kind;
var r=m.getBoundingClientRect();
var a=anchor&&anchor.getBoundingClientRect?anchor.getBoundingClientRect():r;
var top=Math.max(8,Math.min(a.top,window.innerHeight-60));
if(kind==='turn')_blockTurnMenuOpen(r.right+4,top,bid);
else if(kind==='color')_blockColorMenuOpen(r.right+4,top,bid);
else if(kind==='skills')_blockSkillsMenuOpen(r.right+4,top,bid);
var s=document.getElementById('blockMenuSub');
if(s){s.addEventListener('mouseenter',function(){clearTimeout(_bmSubTimer);});
s.addEventListener('mouseleave',function(){_blockSubCloseLater();});}
}
function _blockSubCloseLater(){clearTimeout(_bmSubTimer);_bmSubTimer=setTimeout(function(){var s=document.getElementById('blockMenuSub');if(s&&!s.matches(':hover')){s.remove();_bmSubKind='';}},260);}
// Sous-menu ouvert depuis la toolbar de sélection (pas de #blockMenu parent)
function _subHoverClose(){var s=document.getElementById('blockMenuSub');if(s)s.addEventListener('mouseleave',function(){_blockSubCloseLater();});}
function _blockMenuClick(ev){
var t=ev.target.closest&&ev.target.closest('[data-bm-act]');if(!t)return;
var act=t.getAttribute('data-bm-act');var bid=t.getAttribute('data-bid');
var act=t.getAttribute('data-bm-act');var bid=t.getAttribute('data-bid')||_blockMenuBid();
if(!act)return;
ev.preventDefault();ev.stopPropagation();
var E=window.E;if(!E)return;
var idx=bid?E.getIdx(bid):-1;
if(act==='turn'){var m=document.getElementById('blockMenu');if(m){var r=m.getBoundingClientRect();_blockTurnMenuOpen(r.right+4,r.top,bid||'');}return;}
if(act==='submenu'){_blockSubOpen(t.getAttribute('data-sub'),bid,t);return;}
if(act==='duplicate'){E.duplicateBlock(idx);}
else if(act==='copy-link'){E.copyBlockLink(bid||'');}
else if(act==='copy-blocks'){E.copySelectedBlocks();}
else if(act==='cut-blocks'){E.cutSelectedBlocks();}
else if(act==='paste-blocks'){E.pasteBlocks();}
else if(act==='move-to'){_blockMoveToOpen(idx);}
else if(act==='delete'){E.deleteSelected(idx);}
else if(act==='comment'){E.commentBlock(idx);}
else if(act==='suggest'){E.suggestEdit(idx);}
else if(act==='ai'){E.askAIBlock(idx);}
else if(act==='skill'){E.runSkill(idx,t.getAttribute('data-prompt')||'');}
else if(act==='color'){var key=t.getAttribute('data-color-key'),val=t.getAttribute('data-color-val');E.setBlockColor(idx,key,val);}
else if(act==='turn-type'){E.turnInto(idx,t.getAttribute('data-type'));_blockMenuClose();}
else if(act==='turn-type'){E.turnInto(idx,t.getAttribute('data-type'),parseInt(t.getAttribute('data-cols')||'0',10)||0);_blockMenuClose();return;}
_blockMenuClose();
}
function _blockMenuItem(act,icon,label,opts){
opts=opts||{};
var attr=' data-bm-act="'+act+'"';
if(opts.bid)attr+=' data-bid="'+_blockAttr(opts.bid)+'"';
if(opts.sub)attr+=' data-sub="'+opts.sub+'"';
if(opts.colorKey)attr+=' data-color-key="'+opts.colorKey+'" data-color-val="'+_blockAttr(opts.colorVal||'')+'"';
if(opts.prompt!=null)attr+=' data-prompt="'+_blockAttr(opts.prompt)+'"';
if(opts.type)attr+=' data-type="'+opts.type+'"';
if(opts.cols)attr+=' data-cols="'+opts.cols+'"';
if(opts.title)attr+=' title="'+_blockAttr(opts.title)+'"';
var right=opts.check?'<span class="bm-check">✓</span>'
:(opts.sub?'<span class="bm-arrow">▸</span>'
:(opts.shortcut?'<span class="bm-hint">'+opts.shortcut+'</span>':''));
return '<div class="bm-item'+(opts.danger?' bm-danger':'')+(opts.current?' bm-current':'')+'"'+attr+'>'
+(icon!=null?'<span class="bm-ico">'+icon+'</span>':'')+label+right+'</div>';
}
// Métadonnées du pied de page : auteur / heure de dernière édition / taille du bloc
function _blockMenuMeta(b){
var E=window.E||{};
var who='';
try{var n=document.querySelector('.um-name')||document.querySelector('.workspace-name');who=n?(n.textContent||'').trim():'';}catch(e){}
var when='';
if(E.updatedAt){var d=new Date(E.updatedAt);if(!isNaN(d)){
var same=d.toDateString()===new Date().toDateString();
var hm=d.toLocaleTimeString([],{hour:'numeric',minute:'2-digit'});
when=(same?'Today at ':(d.toLocaleDateString([])+' at '))+hm;}}
var content=(b&&b.content)||'';
var trimmed=content.trim();
var words=trimmed?trimmed.split(/\s+/).length:0;
return '<div class="bm-foot"><div>Last edited by '+esc(who||'you')+'</div>'
+(when?'<div>'+esc(when)+'</div>':'')
+'<div>'+words+' word'+(words===1?'':'s')+', '+content.length+' character'+(content.length===1?'':'s')+'</div></div>';
}
function blockMenuOpen(x,y,bid){
_blockMenuClose();
var E=window.E;if(!E)return;
var idx=E.getIdx(bid);if(idx<0)return;
var b=E.blocks[idx];if(!b)return;
var d=document.createElement('div');d.id='blockMenu';d.className='block-menu';d.style.cssText='position:fixed;z-index:4000;';
var colorsText='',colorsBg='';
_BLOCK_COLORS_TEXT.forEach(function(c){colorsText+='<button type="button" data-bm-act="color" data-color-key="color" data-color-val="'+c+'" title="Text color" style="background:'+(c==='default'?'transparent':c)+'" class="bm-swatch'+(c==='default'?' bm-default':'')+'">'+(c==='default'?'A':'')+'</button>';});
_BLOCK_COLORS_BG.forEach(function(c){colorsBg+='<button type="button" data-bm-act="color" data-color-key="bgColor" data-color-val="'+c+'" title="Background color" style="background:'+(c==='default'?'transparent':c)+'" class="bm-swatch'+(c==='default'?' bm-default':'')+'"></button>';});
d.innerHTML='<div class="bm-item" data-bm-act="turn" data-bid="'+b.id+'"><span class="bm-ico">↩</span>Turn into<span class="bm-arrow">▸</span></div>'
+'<div class="bm-sep"></div>'
+'<div class="bm-item" data-bm-act="duplicate" data-bid="'+b.id+'"><span class="bm-ico">▣</span>Duplicate <span class="bm-hint">'+(_rtMac()?'⌘D':'Ctrl+D')+'</span></div>'
+'<div class="bm-item" data-bm-act="copy-link" data-bid="'+b.id+'"><span class="bm-ico">🔗</span>Copy link to block</div>'
+'<div class="bm-sep"></div>'
+'<div class="bm-item" data-bm-act="copy-blocks" data-bid="'+b.id+'"><span class="bm-ico">⎘</span>Copy blocks <span class="bm-hint">'+(_rtMac()?'⌘C':'Ctrl+C')+'</span></div>'
+'<div class="bm-item" data-bm-act="cut-blocks" data-bid="'+b.id+'"><span class="bm-ico">✂</span>Cut blocks <span class="bm-hint">'+(_rtMac()?'⌘X':'Ctrl+X')+'</span></div>'
+'<div class="bm-item" data-bm-act="paste-blocks" data-bid="'+b.id+'"><span class="bm-ico">⎘</span>Paste blocks <span class="bm-hint">'+(_rtMac()?'⌘V':'Ctrl+V')+'</span></div>'
+'<div class="bm-sep"></div>'
+'<div class="bm-item" data-bm-act="move-to" data-bid="'+b.id+'"><span class="bm-ico">↪</span>Move to…</div>'
+'<div class="bm-sep"></div>'
+'<div class="bm-colors"><div class="bm-colors-label">Text</div><div class="bm-swatches">'+colorsText+'</div></div>'
+'<div class="bm-colors"><div class="bm-colors-label">Background</div><div class="bm-swatches">'+colorsBg+'</div></div>'
+'<div class="bm-sep"></div>'
+'<div class="bm-item bm-danger" data-bm-act="delete" data-bid="'+b.id+'"><span class="bm-ico">✕</span>Delete <span class="bm-hint">⌫</span></div>';
var d=document.createElement('div');d.id='blockMenu';d.className='block-menu';d.setAttribute('data-bid',b.id);d.style.cssText='position:fixed;z-index:4000;';
var S=_BM_SHORTCUTS;
d.innerHTML='<input class="bm-search" type="text" placeholder="Search actions..." autocomplete="off" spellcheck="false">'
+'<div class="bm-group">Text</div>'
+_blockMenuItem('submenu','↩','Turn into',{sub:'turn'})
+_blockMenuItem('submenu','🎨','Color',{sub:'color'})
+_blockMenuItem('link','🔗','Copy link to block',{shortcut:S.link})
+_blockMenuItem('duplicate','▣','Duplicate',{shortcut:S.duplicate})
+_blockMenuItem('move-to','↪','Move to…',{shortcut:S.move})
+_blockMenuItem('delete','✕','Delete',{shortcut:S.delete,danger:true})
+_blockMenuItem('comment','💬','Comment',{shortcut:S.comment})
+_blockMenuItem('suggest','✎','Suggest edits',{shortcut:S.suggest})
+_blockMenuItem('ai','✨','Ask AI',{shortcut:S.ai})
+_blockMenuItem('submenu','📚','Skills',{sub:'skills'})
+_blockMenuMeta(b);
d.addEventListener('click',_blockMenuClick);
d.addEventListener('input',function(ev){
if(!ev.target.classList||!ev.target.classList.contains('bm-search'))return;
var q=(ev.target.value||'').toLowerCase();
d.querySelectorAll('.bm-item').forEach(function(it){
it.style.display=(q&&it.textContent.toLowerCase().indexOf(q)<0)?'none':'';
});
var g=d.querySelector('.bm-group');if(g)g.style.display=q?'none':'';
});
d.addEventListener('mouseover',function(ev){
var it=ev.target&&ev.target.closest?ev.target.closest('[data-sub]'):null;
if(it)_blockSubOpen(it.getAttribute('data-sub'),b.id,it);
});
d.addEventListener('mouseleave',function(){_blockSubCloseLater();});
d.addEventListener('contextmenu',function(ev){ev.preventDefault();_blockMenuClose();});
document.body.appendChild(d);
var r=d.getBoundingClientRect();
x=Math.min(Math.max(8,x),window.innerWidth-r.width-8);
y=Math.min(Math.max(8,y),window.innerHeight-r.height-8);
d.style.left=x+'px';d.style.top=y+'px';
var si=d.querySelector('.bm-search');if(si)setTimeout(function(){si.focus();},10);
}
function _blockTurnMenuOpen(x,y,bid){
// Panneau de droite : coquille commune des 3 sous-menus
function _blockSubShell(x,y){
var m=document.getElementById('blockMenuSub');if(m)m.remove();
var d=document.createElement('div');d.id='blockMenuSub';d.className='block-menu block-menu-sub';d.style.cssText='position:fixed;z-index:4001;';
var html='';
_BLOCK_TURN_TYPES.forEach(function(t){html+='<div class="bm-item" data-bm-act="turn-type" data-type="'+t.id+'" data-bid="'+bid+'">'+t.name+'</div>';});
d.innerHTML=html;
d.addEventListener('click',_blockMenuClick);
document.body.appendChild(d);
var r=d.getBoundingClientRect();
x=Math.min(Math.max(8,x),window.innerWidth-r.width-8);
y=Math.min(Math.max(8,y),window.innerHeight-r.height-8);
d.style.left=x+'px';d.style.top=y+'px';
return d;
}
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='';
_BLOCK_TURN_MENU.forEach(function(t){
var on=(t.id===cur&&!t.cols);
html+=_blockMenuItem('turn-type',t.icon,t.name,{bid:bid,type:t.id,cols:t.cols,
title:t.cols?('Create '+t.cols+' columns of blocks'):'',
check:on,current:on});
});
return html;
}
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';
var last='';try{last=localStorage.getItem('fdLastBlockColor')||'';}catch(e){}
var html='';
if(last){
var i=last.indexOf('|');var lk=i<0?'color':last.slice(0,i);var lv=last.slice(i+1);
var isBg=(lk==='bgColor');
var arr=isBg?_BLOCK_COLORS_BG:_BLOCK_COLORS_TEXT;
var ni=arr.indexOf(lv);
var nm=(ni>=0?_BLOCK_COLOR_NAMES[ni]:'Custom')+(isBg?' background':' text');
html+='<div class="bm-group">Last used</div>'
+_blockMenuItem('color','<span style="color:'+(lv==='default'?'var(--text-secondary)':lv)+'">A</span>',
nm,{bid:bid,colorKey:lk,colorVal:lv,shortcut:_BM_SHORTCUTS.color});
}
html+='<div class="bm-group">Text color</div>';
_BLOCK_COLORS_TEXT.forEach(function(v,n){
html+=_blockMenuItem('color','<span style="color:'+(v==='default'?'var(--text-secondary)':v)+'">A</span>',
_BLOCK_COLOR_NAMES[n]+' text',{bid:bid,colorKey:'color',colorVal:v,
check:v===curT,current:v===curT});
});
html+='<div class="bm-group">Background color</div>';
_BLOCK_COLORS_BG.forEach(function(v,n){
html+=_blockMenuItem('color','<span class="bm-dot" style="background:'+(v==='default'?'var(--text-tertiary)':v)+'"></span>',
_BLOCK_COLOR_NAMES[n]+' background',{bid:bid,colorKey:'bgColor',colorVal:v,
check:v===curB,current:v===curB});
});
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);
d.innerHTML='<div class="bm-empty">Chargement…</div>';
fetch('/api/agent/skills',{credentials:'same-origin'})
.then(function(r){return r.json();})
.then(function(res){
if(!document.body.contains(d))return;
var list=(res&&res.skills)||[];
if(!list.length){d.innerHTML='<div class="bm-empty">Aucune compétence enregistrée — créez-en une dans le panneau Agent.</div>';return;}
var html='';
list.forEach(function(s){
html+=_blockMenuItem('skill','📚',esc(String(s.name||'Skill')),{bid:bid,prompt:s.prompt_template||''});
});
d.innerHTML=html;
})
.catch(function(){if(document.body.contains(d))d.innerHTML='<div class="bm-empty">Compétences indisponibles.</div>';});
}
function _rtMac(){return /Mac|iPhone|iPad/.test(navigator.userAgent);}
function _blockMoveToOpen(idx){
@@ -1241,14 +1521,15 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
sync(){const ct=document.getElementById('_blocksCt');if(!ct)return;for(const b of this.blocks){if(b.type==='table'){const wrap=ct.querySelector(`[data-bid="${b.id}"].ftable-editor`);if(wrap){const rows=[];wrap.querySelectorAll('tr.ftable-row').forEach(function(tr){const cells=tr.querySelectorAll('th.ftable-cell,td.ftable-cell');if(!cells.length)return;const rowarr=[];cells.forEach(function(cell){rowarr.push(cell.textContent||'');});rows.push(rowarr);});if(rows.length)b.rows=rows;}continue;}
if(b.type==='divider'||b.type==='image'||b.type==='embed'||b.type==='table_of_contents'||b.type==='button')continue;
if(b.type==='math'){const te=ct.querySelector(`[data-math-bid="${b.id}"]`);if(te)b.content=te.value||'';continue;}
const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el&&!el.classList.contains('toggle-title'))b.content=gtTok(el)||'';else if(el)b.content=gtTok(el)||'';
if(b.type==='toggle'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gtTok(che)||'';});
if(b.type==='columns'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gtTok(che)||'';});
const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el&&!el.classList.contains('toggle-title'))b.content=gtMd(el)||'';else if(el)b.content=gtMd(el)||'';
if(b.type==='toggle'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gtMd(che)||'';});
if(b.type==='columns'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gtMd(che)||'';});
}},
render(){const ct=document.getElementById('_blocksCt');if(!ct)return;let html='';for(let i=0;i<this.blocks.length;i++)html+=renderBlock(this.blocks[i],i);ct.innerHTML=html;
// Render math blocks with KaTeX
var self=this;
ct.querySelectorAll('.math-display').forEach(function(el){var bid=el.dataset.bid;var b=self.blocks.find(function(x){return x.id===bid;});self._renderKatex(el,b?b.content:'');});
ct.querySelectorAll('.fd-math-inline').forEach(function(el){var tex=el.getAttribute('data-tex')||'';if(window.katex&&tex){try{el.innerHTML=window.katex.renderToString(tex,{displayMode:false,throwOnError:false});}catch(e){el.textContent=tex;}}else if(tex&&!el.textContent){el.textContent=tex;}});
ct.querySelectorAll('.math-editor').forEach(function(ta){var disp=ct.querySelector(`.math-display[data-bid="${ta.dataset.mathBid}"]`);ta.addEventListener('input',function(){self._renderKatex(disp,ta.value);self.dirty=true;self.autoSave();});});
// Initialize inline database blocks (scripts in innerHTML don't execute)
setTimeout(()=>{ct.querySelectorAll('.block-embed-collection').forEach(el=>{const cid=parseInt(el.id.replace('inline-db-',''));if(cid&&window.FlowDeckDB)window.FlowDeckDB.renderInto(el,cid,null);});},50);
@@ -1510,10 +1791,68 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
// ── v5.10.0: Turn into / couleurs / liens / déplacement ──
_blockTargets(idx){const sel=_selIndicesSorted();return(sel.includes(idx))?sel:[idx];},
turnInto(idx,type){if(idx<0||idx>=this.blocks.length)return;if(_BLOCK_TURN_TYPES.indexOf(type)<0)return;const targets=this._blockTargets(idx);const focusId=this.blocks[idx].id;this.sync();this.pushHistory();targets.forEach(i=>{const b=this.blocks[i];if(!b)return;if(b.type===type)return;const keep=(b.content||'').trim();b.type=type;if(type!=='to_do'&&type!=='toggle'&&keep&&['image','embed','meeting','divider','table_of_contents','database','button','columns'].indexOf(b.type)<0){b.content=keep;}else{b.content='';}if(type==='toggle'){b.expanded=true;if(!Array.isArray(b.children))b.children=[];}if(type==='to_do')b.checked=false;if(type==='table'&&!Array.isArray(b.rows))b.rows=_tblNew(3,3);if(type==='code'&&!b.language)b.language='Plain Text';if(type==='columns'&&(!Array.isArray(b.children)||!b.children.length))b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='button'){b.automation_id=0;b.automation_name='';}});
turnInto(idx,type,cols){if(idx<0||idx>=this.blocks.length)return;if(!_BLOCK_TURN_TYPES.some(t=>t.id===type))return;const targets=this._blockTargets(idx);const focusId=this.blocks[idx].id;this.sync();this.pushHistory();targets.forEach(i=>{const b=this.blocks[i];if(!b)return;if(b.type===type)return;const keep=(b.content||'').trim();b.type=type;if(keep&&['image','embed','meeting','divider','table_of_contents','database','button','columns'].indexOf(b.type)<0){b.content=keep;}else{b.content='';}if(type==='toggle'){b.expanded=true;if(!Array.isArray(b.children))b.children=[];}if(type==='to_do')b.checked=false;if(type==='table'&&!Array.isArray(b.rows))b.rows=_tblNew(3,3);if(type==='code'&&!b.language)b.language='Plain Text';if(type==='columns'&&(!Array.isArray(b.children)||!b.children.length))b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='columns'&&cols>=2){b.children=Array.from({length:cols},()=>({id:genId(),type:'paragraph',content:''}));}if(type==='synced'){b.synced_id=0;b._synced_content=[];b._synced_title='';}if(type==='button'){b.automation_id=0;b.automation_name='';}});
if(type==='synced')setTimeout(()=>this._pickSyncedBlock(idx),80);
this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();
setTimeout(()=>{const bid=this.blocks[idx]?this.blocks[idx].id:focusId;if(type==='table'){_tblFocus(bid,0,0);}else{const el=this.getEl(bid);if(el){el.focus();ce(el);}}},60);},
setBlockColor(idx,key,val){if(idx<0||idx>=this.blocks.length)return;const targets=this._blockTargets(idx);const v=(!val||val==='default')?null:val;this.sync();this.pushHistory();targets.forEach(i=>{const b=this.blocks[i];if(!b)return;if(!b.style)b.style={};if(v==null)delete b.style[key];else b.style[key]=v;});this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();},
setBlockColor(idx,key,val){if(idx<0||idx>=this.blocks.length)return;const targets=this._blockTargets(idx);const v=(!val||val==='default')?null:val;this.sync();this.pushHistory();targets.forEach(i=>{const b=this.blocks[i];if(!b)return;if(!b.style)b.style={};if(v==null)delete b.style[key];else b.style[key]=v;});if(v!=null){try{localStorage.setItem('fdLastBlockColor',key+'|'+v);}catch(e){}}this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();},
// ── v7.61 : actions du menu contextuel de bloc ──
commentBlock(idx){
const b=this.blocks[idx];if(!b)return;const el=this.getEl(b.id);if(!el)return;
const r=document.createRange();r.selectNodeContents(el);
const s=window.getSelection();s.removeAllRanges();s.addRange(r);
this.showCommentBtn();
if(!this._commentSel||!this._commentSel.range){this.showToast('Sélectionnez du texte pour commenter','error');return;}
this.commentOnSelection();
},
// v7.62 : renommé — « askAI() » (sans argument, plus bas) l'écrasait,
// le menu de bloc et Ctrl+J ouvraient l'IA de page au lieu du bloc.
askAIBlock(idx){const b=this.blocks[idx];if(!b)return;if(window.AIC)window.AIC.open(idx);},
suggestEdit(idx){
const b=this.blocks[idx];if(!b||!(b.content||'').trim()){this.showToast('Bloc vide','error');return;}
this.showToast('FlowDeck AI : suggestion en cours…');
this._aiWritingRun('write',idx,{prompt:'Améliore ce bloc (grammaire, clarté, style) en gardant le sens, la langue et le format. Réponds UNIQUEMENT avec le contenu amélioré, sans préambule.\n\nBloc :\n'+b.content},'insert');
},
runSkill(idx,tpl){
const b=this.blocks[idx];if(!b||!(b.content||'').trim()){this.showToast('Bloc vide','error');return;}
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('|');
if(i<0){this.showToast('Aucune couleur récente','error');return;}
this.setBlockColor(idx,v.slice(0,i),v.slice(i+1));
},
copyBlockLink(bid){const url=window.location.origin+window.location.pathname+'#fdblk-'+bid;if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(url).then(()=>this.showToast('Lien du bloc copié')).catch(()=>this.showToast('Copie impossible','error'));}else{this.showToast(url,'error');}},
deleteSelected(idx){const indices=_selIndicesSorted();if(indices.length<=1)return this.removeBlock(idx);this.sync();this.pushHistory();const keep=this.blocks.filter((b,i)=>indices.indexOf(i)<0);this.blocks=keep.length?keep:[this.mkB('paragraph','')];const fi=Math.min(idx,this.blocks.length-1);this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);},
// ── v5.11: Copy / Cut / Paste selected blocks ──
@@ -1970,6 +2309,12 @@ applyAIBlocks(text){
if(e.key==='Escape'){e.preventDefault();SM.close();return;}
if(e.key==='ArrowDown'||e.key==='ArrowUp'||e.key==='Enter'){e.preventDefault();return;}
if(e.key==='Backspace'&&!SM._q&&(el.textContent||'').trim()==='/'){e.preventDefault();SM.close();return;}
// La requête peut être tapée DANS le bloc (« /numbered list ») si le
// focus n'a pas rejoint l'entrée du menu → on la répercute au filtre.
if(SM._in&&(e.key.length===1||e.key==='Backspace')){
const q=(el.textContent||'').replace(/^\s*\//,'');
if(SM._in.value!==q){SM._in.value=q;SM.flt();}
}
return;
}
if(window.AIAC&&window.AIAC.active){
@@ -1977,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){
@@ -2004,7 +2351,7 @@ applyAIBlocks(text){
if(e.key==='ArrowUp'){const _sp=splitCaret(el);if(_sp&&!_sp.before.trim()&&idx>0){e.preventDefault();const prev=this.getEl(this.blocks[idx-1]?.id);if(prev){prev.focus();ce(prev);}}return;}
if(e.key==='ArrowDown'){const _sp=splitCaret(el);if(_sp&&!_sp.after.trim()&&idx<this.blocks.length-1){e.preventDefault();const next=this.getEl(this.blocks[idx+1]?.id);if(next){next.focus();cs(next);}}return;}
if(e.key==='Backspace'){if(!(el.textContent||'').trim()){this.sync();if(block.type!=='paragraph'){e.preventDefault();this.pushHistory();block.type='paragraph';block.content='';this.render();_rtSync();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}else if(this.blocks.length>1){e.preventDefault();this.removeBlock(idx);}}return;}
if(e.key===' '){const b2=bid;setTimeout(()=>this.checkMd(b2),15);}
if(e.key===' '){const i3=idx;setTimeout(()=>this.checkMd(i3),15);}
if((e.ctrlKey||e.metaKey)&&!e.altKey){
if(e.key.toLowerCase()==='z'){e.preventDefault();if(e.shiftKey)this.redo();else this.undo();return;}
if(e.key.toLowerCase()==='y'){e.preventDefault();this.redo();return;}
@@ -2126,9 +2473,48 @@ applyAIBlocks(text){
this.render();this.dirty=true;this.autoSave();_rtSync();
}catch(e){this.showToast('Failed to load synced blocks','error');}
},
showFmt(idx){const s=window.getSelection();if(!s.rangeCount||s.isCollapsed){this.fmt.open=false;return;}const r=s.getRangeAt(0).getBoundingClientRect();this.fmt.open=true;this.fmt.idx=idx;this.fmt.top=Math.max(r.top-44,0);this.fmt.left=Math.min(r.left+r.width/2-120,window.innerWidth-260);this._sel={range:s.getRangeAt(0).cloneRange(),idx};},
fmtApply(f){this.fmt.open=false;if(!this._sel)return;const s=window.getSelection();s.removeAllRanges();s.addRange(this._sel.range);document.execCommand(f==='strikeThrough'?'strikeThrough':f);this._sel=null;this.dirty=true;this.autoSave();},
fmtLink(){this.fmt.open=false;const u=prompt('URL:');if(u){document.execCommand('createLink',false,u);this.dirty=true;this.autoSave();}},
// ── v7.62.0 : menu contextuel au highlight de texte (façon Notion) ──
showFmt(idx){const s=window.getSelection();if(!s.rangeCount||s.isCollapsed){this.fmt.open=false;this._sel=null;return;}const r=s.getRangeAt(0).getBoundingClientRect();
const H=300,W=230;let top=r.bottom+8;if(top+H>window.innerHeight-8)top=Math.max(8,r.top-H);
this.fmt.open=true;this.fmt.idx=idx;this.fmt.top=top;
this.fmt.left=Math.max(8,Math.min(r.left+r.width/2-W/2,window.innerWidth-W-8));
this._sel={range:s.getRangeAt(0).cloneRange(),idx};},
_fmtRestore(){const s=window.getSelection();if(this._sel&&this._sel.range){s.removeAllRanges();s.addRange(this._sel.range);}},
_fmtBid(){const b=this.blocks[this.fmt.idx];return b?b.id:'';},
_fmtSaved(){this.sync();this.dirty=true;this.autoSave();},
// style de la 1ʳᵉ ligne : libellé du type de bloc courant
fmtStyleName(){const b=this.blocks[this.fmt.idx];const t=b?b.type:'paragraph';
const m={paragraph:'Normal Text',heading_1:'Heading 1',heading_2:'Heading 2',heading_3:'Heading 3',heading_4:'Heading 4',
bulleted_list:'Bulleted list',numbered_list:'Numbered list',to_do:'To-do list',toggle:'Toggle list',quote:'Quote',
callout:'Callout',code:'Code',math:'Block equation',divider:'Divider',table:'Table',image:'Image',embed:'Embed',button:'Button'};
return m[t]||'Normal Text';},
_fmtSubPos(ev){const r=ev.currentTarget.getBoundingClientRect();
return {x:Math.min(r.right+4,window.innerWidth-250),y:Math.min(Math.max(8,r.top),window.innerHeight-140)};},
// « Normal Text › » → sous-menu Turn into (menu de bloc existant)
fmtStyle(ev){const bid=this._fmtBid();if(!bid)return;const p=this._fmtSubPos(ev);_blockTurnMenuOpen(p.x,p.y,bid);_subHoverClose();},
// « A » → sous-menu Color
fmtColor(ev){const bid=this._fmtBid();if(!bid)return;const p=this._fmtSubPos(ev);_blockColorMenuOpen(p.x,p.y,bid);_subHoverClose();},
// mise en forme de base : le navigateur fait le toggle, sync() persiste (gtMd)
fmtApply(f){if(!this._sel)return;const idx=this.fmt.idx;this._fmtRestore();document.execCommand(f);this._fmtSaved();this.showFmt(idx);},
fmtCode(){if(!this._sel)return;this._fmtRestore();_selWrapToggle('code');this._fmtSaved();this.fmt.open=false;},
fmtHighlight(){if(!this._sel)return;this._fmtRestore();_selWrapToggle('mark');this._fmtSaved();this.fmt.open=false;},
fmtCodeBlock(){this.fmt.open=false;const i=this.fmt.idx;if(i>=0)this.turnInto(i,'code');},
fmtLink(){if(!this._sel)return;this._fmtRestore();const u=prompt('URL:');if(!u)return;
document.execCommand('createLink',false,u);this._fmtSaved();this.fmt.open=false;},
fmtEquation(){if(!this._sel)return;this._fmtRestore();const s=window.getSelection();if(!s.rangeCount)return;
const tex=prompt('Equation (LaTeX):',s.toString()||'x^2');if(tex===null||!tex.trim())return;
const span=document.createElement('span');span.className='fd-math-inline';span.setAttribute('data-tex',tex.trim());
if(window.katex){try{span.innerHTML=window.katex.renderToString(tex.trim(),{displayMode:false,throwOnError:false});}catch(e){span.textContent=tex;}}
else span.textContent=tex;
const r=s.getRangeAt(0);r.deleteContents();r.insertNode(span);
this._fmtSaved();this.fmt.open=false;},
// « … » → le menu contextuel de bloc (recherche + Text + pied métadonnées)
fmtMore(ev){const bid=this._fmtBid();if(!bid)return;const r=ev.currentTarget.getBoundingClientRect();
this.fmt.open=false;blockMenuOpen(Math.max(8,Math.min(r.left-190,window.innerWidth-250)),r.bottom+6,bid);},
fmtComment(){this.fmt.open=false;const i=this.fmt.idx;if(i>=0)this.commentBlock(i);},
fmtAskAI(){this.fmt.open=false;const i=this.fmt.idx;if(i>=0)this.askAIBlock(i);},
fmtEditAI(){this.fmtAskAI();},
fmtSkill(tpl){this.fmt.open=false;const i=this.fmt.idx;if(i>=0)this.runSkill(i,tpl);},
// ── v4.9.0: Collaboration — comments & mentions ──
toggleComments(){this.commentsOpen=!this.commentsOpen;if(this.commentsOpen)this.loadComments();},
async loadComments(){try{const r=await fetch('/api/pages/'+this.pid+'/comments',{credentials:'same-origin'});const d=await r.json();this.comments=d.comments||[];this.commentCount=this.comments.length;}catch(e){this.comments=[];}},
@@ -2486,20 +2872,38 @@ applyAIBlocks(text){
else if(t.startsWith('### ')&&!t.startsWith('#### '))bl.push(this.mkB('heading_3',t.substring(4)));
else if(t.startsWith('#### '))bl.push(this.mkB('heading_4',t.substring(5)));
else if(t==='---'||t==='***')bl.push(this.mkB('divider',''));
else if(/^[-*+] /.test(t))bl.push(this.mkB('bulleted_list',t.substring(2)));
else if(/^\d+\. /.test(t))bl.push(this.mkB('numbered_list',t.replace(/^\d+\. /,'')));
// AVANT les puces : « - [ ] … » commence aussi par « - » →
// sinon la case à cocher de l'agent devenait une puce.
else if(t.startsWith('- [ ] ')||t.startsWith('* [ ] '))bl.push(this.mkB('to_do',t.substring(6)));
else if(t.startsWith('- [x] ')||t.startsWith('* [x] '))bl.push(this.mkB('to_do',t.substring(6),{checked:true}));
else if(/^[-*+] /.test(t))bl.push(this.mkB('bulleted_list',t.substring(2)));
else if(/^\d+[.)] /.test(t))bl.push(this.mkB('numbered_list',t.replace(/^\d+[.)] /,'')));
else if(t.startsWith('> '))bl.push(this.mkB('quote',t.substring(2)));
else bl.push(this.mkB('paragraph',t));
}return bl;},
};
}
document.addEventListener('mouseup',()=>{setTimeout(()=>{const s=window.getSelection();if(!s||s.isCollapsed)return;const ed=document.querySelector('.page-editor-wrapper');if(!ed?.__x)return;const d=ed.__x.$data;const ct=document.getElementById('_blocksCt');if(!ct?.contains(s.anchorNode))return;const bel=s.anchorNode.parentElement?.closest('[data-bid]');if(bel){const idx=d.getIdx(bel.dataset.bid);if(idx>=0)d.showFmt(idx);d.showCommentBtn();}else{d.hideCommentBtn();}},80);});
document.addEventListener('mousedown',()=>{setTimeout(()=>{const ed=document.querySelector('.page-editor-wrapper');if(ed?.__x)ed.__x.$data.hideCommentBtn();},10);});
// v7.62 : data Alpine du wrapper éditeur — Alpine v3 n'expose plus __x
// (l'ancien accès rendait muets showFmt/hideCommentBtn/mentions).
function _xed(el){ if(!el) return null; if(el.__x&&el.__x.$data) return el.__x.$data;
return (el._x_dataStack&&el._x_dataStack[0])||null; }
document.addEventListener('mouseup',(ev)=>{setTimeout(()=>{
// v7.62 : clic dans la toolbar → on laisse la sélection (et le menu) intacts ;
// sélection relâchée/cliquée ailleurs → le menu de sélection se ferme.
if(ev.target&&ev.target.closest&&ev.target.closest('.format-toolbar'))return;
const d=_xed(document.querySelector('.page-editor-wrapper'));if(!d)return;
const s=window.getSelection();
if(!s||s.isCollapsed){d.fmt.open=false;d._sel=null;return;}
const ct=document.getElementById('_blocksCt');
if(!ct?.contains(s.anchorNode)){d.fmt.open=false;d._sel=null;return;}
const bel0=s.anchorNode&&(s.anchorNode.nodeType===1?s.anchorNode:s.anchorNode.parentElement);
const bel=bel0?bel0.closest('[data-bid]'):null;
if(bel){const idx=d.getIdx(bel.dataset.bid);if(idx>=0)d.showFmt(idx);d.showCommentBtn();}else{d.hideCommentBtn();}
},80);});
document.addEventListener('mousedown',()=>{setTimeout(()=>{const d=_xed(document.querySelector('.page-editor-wrapper'));if(d)d.hideCommentBtn();},10);});
document.addEventListener('keyup',(e)=>{
const ed=document.querySelector('.page-editor-wrapper');if(!ed?.__x)return;
const d=ed.__x.$data;if(!d.mentionOpen){if(e.key!=='@')return;}
const d=_xed(document.querySelector('.page-editor-wrapper'));if(!d)return;
if(!d.mentionOpen){if(e.key!=='@')return;}
const el=document.activeElement;if(!el||!el.hasAttribute('data-bid'))return;
const ct=document.getElementById('_blocksCt');if(!ct||!ct.contains(el))return;
if(d.mentionOpen){
@@ -2510,8 +2914,8 @@ applyAIBlocks(text){
d.openMention(el);
},true);
document.addEventListener('click',(e)=>{
const ed=document.querySelector('.page-editor-wrapper');if(!ed?.__x)return;
const d=ed.__x.$data;if(!d.mentionOpen)return;
const d=_xed(document.querySelector('.page-editor-wrapper'));if(!d)return;
if(!d.mentionOpen)return;
const menu=document.getElementById('_mentionMenu');
if(menu&&!menu.contains(e.target))d.closeMention();
});
@@ -2559,6 +2963,44 @@ applyAIBlocks(text){
// `window.E` survit aux navigations partielles : on exige donc que
// l'editeur soit reellement monte (`#_blocksCt`) avant d'agir, sinon
// Ctrl+S sauvegarderait un editeur detache depuis une autre page.
// ── v7.61 : raccourcis du menu contextuel de bloc ──
document.addEventListener('keydown', function (e) {
var E = window.E;
if (!E || !document.getElementById('_blocksCt')) return;
var k = String(e.key || '').toLowerCase();
var ctrl = e.ctrlKey || e.metaKey, sh = e.shiftKey, alt = e.altKey;
var menu = document.getElementById('blockMenu');
var menuBid = menu ? (menu.getAttribute('data-bid') || '') : '';
function curIdx(){
if (menuBid) { var mi = E.getIdx(menuBid); if (mi >= 0) return mi; }
var ab = E.getActiveBlock();
return ab && ab.idx >= 0 ? ab.idx : -1;
}
if (ctrl && !sh && !alt && k === 'j') { e.preventDefault(); var i1 = curIdx(); if (i1 >= 0) E.askAIBlock(i1); return; }
if (ctrl && sh && !alt && k === 'h') { e.preventDefault(); var i2 = curIdx(); if (i2 >= 0) E.applyLastColor(i2); return; }
if (ctrl && sh && !alt && k === 'm') { e.preventDefault(); var i3 = curIdx(); if (i3 >= 0) E.commentBlock(i3); return; }
if (ctrl && sh && alt && k === 'x') { e.preventDefault(); var i4 = curIdx(); if (i4 >= 0) E.suggestEdit(i4); return; }
if (alt && sh && !ctrl && k === 'l') { e.preventDefault(); var i5 = curIdx(); if (i5 >= 0) E.copyBlockLink(E.blocks[i5].id); return; }
if (alt && sh && !ctrl && k === 'e') { e.preventDefault(); var i7 = curIdx(); if (i7 >= 0) E.askAIBlock(i7); return; }
if (ctrl && sh && !alt && k === 'p') { e.preventDefault(); var i6 = curIdx(); if (i6 >= 0) _blockMoveToOpen(i6); return; }
// Ctrl+D : déjà géré par la saisie quand le bloc a le focus (pas de doublon)
if (ctrl && !sh && !alt && k === 'd') {
var ab = E.getActiveBlock();
if (ab && document.activeElement === ab.el) return;
var idc = ab && ab.idx >= 0 ? ab.idx : (menuBid ? E.getIdx(menuBid) : -1);
if (idc >= 0) { e.preventDefault(); E.duplicateBlock(idc); }
return;
}
// Del : supprime le bloc SEULEMENT avec le menu ouvert (jamais pendant la saisie)
if (k === 'delete' && !ctrl && !sh && !alt && menu) {
var tgt = e.target;
if (tgt && (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA' || tgt.isContentEditable)) return;
var i8 = curIdx();
if (i8 >= 0) { e.preventDefault(); E.deleteSelected(i8); }
return;
}
if (k === 'escape' && menu) { _blockMenuClose(); return; }
});
document.addEventListener('keydown', function (e) {
if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return;
if (String(e.key).toLowerCase() !== 's') return;
+3
View File
@@ -18,6 +18,9 @@ function settingsInit() {
fmtAuditDate(e) { return new Date(e.at).toLocaleString(); },
activeSection: 'account',
// Mobile side-navigation drawer for the settings panel (hamburger in topbar)
navOpen: false,
closeNavOnMobile() { this.navOpen = false; },
allTags: [],
newTagName: '',
newTagColor: '#787774',
+15
View File
@@ -89,6 +89,21 @@ def test_llm_client_providers_listed(client):
assert name in PROVIDERS
def test_mistral_defaults_are_tier_safe(client):
"""La clé Mistral plan basique → 403 « tier_not_allowed » sur mistral-large/pixtral-large.
Le modèle par défaut et le premier de la liste du test de connexion doivent
être des modèles servis par tous les plans."""
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS
tier_blocked = {"mistral-large-latest", "pixtral-large-latest"}
assert PROVIDERS["mistral"][1] not in tier_blocked
assert PROVIDER_MODELS["mistral"][0] not in tier_blocked
# le fallback du modèle retiré (PROVIDERS default) est présent dans la liste
assert PROVIDERS["mistral"][1] in PROVIDER_MODELS["mistral"]
# et la liste des modèles Mistral est validée chat à chaque fetch (403 exclus)
from app.services.llm_config import _CHAT_VALIDATED_PROVIDERS
assert "mistral" in _CHAT_VALIDATED_PROVIDERS
def test_llm_client_openai_compatible_bases(client):
"""Providers that need an OpenAI-compatible surface must point at it.
+2 -2
View File
@@ -104,8 +104,8 @@ def test_plus_menu_sections_and_disabled_states():
for key in ("'files'", "'skills'", "'connectors'", "'canvases'", "'plugins'", "'memory'"):
assert f"key:{key}" in root, key
assert "action:'files'" in root and "action:'skills'" in root
# phases 1-5 livrées → il reste 1 section « bientôt » (plugins)
assert root.count("disabled:true") == 1
# v7.58.0 : menu complet — plus aucune section « bientôt » (plugins vivant)
assert root.count("disabled:true") == 0
files = src.split("var FD_PLUS_FILES = [", 1)[1].split("];", 1)[0]
assert "action:'search'" in files and "action:'browse'" in files
+1 -2
View File
@@ -64,8 +64,7 @@ def test_canvases_menu_wired(client):
src = JS_PATH.read_text(encoding="utf-8")
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert "action:'canvases'" in root
# phases 1-5 livrées → 1 section « bientôt » restante (plugins)
assert root.count("disabled:true") == 1
assert root.count("disabled:true") == 0 # v7.58.0 : menu complet
for action in ("'canvas'", "'canvas-create'", "'canvas-insert'", "'canvas-save'"):
assert action in src, action
# piège /use : la clé builtin part DANS LE BODY, pas en query string
+1 -2
View File
@@ -129,8 +129,7 @@ def test_memory_menu_toggle_wired():
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert "action:'memory'" in root
assert "bientôt (phase 4)" not in root
# phases 1-5 livrées → seul Plugins reste désactivé
assert root.count("disabled:true") == 1
assert root.count("disabled:true") == 0 # v7.58.0 : menu complet
assert "toggleMemory()" in src
assert "memory_enabled: next" in src
engine = ENGINE.read_text(encoding="utf-8")
+1 -2
View File
@@ -158,8 +158,7 @@ def test_connectors_menu_wired(client):
src = JS_PATH.read_text(encoding="utf-8")
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert "action:'connectors'" in root
# phases 1-5 livrées → seule la section Plugins reste « bientôt »
assert root.count("disabled:true") == 1
assert root.count("disabled:true") == 0 # v7.58.0 : menu complet
for action in ("'connector'", "'connector-new'", "'connector-probe'",
"'connector-toggle'", "'connector-delete'"):
assert action in src, action
+2 -2
View File
@@ -194,9 +194,9 @@ def test_connectors_menu_wired_for_presets(client):
for token in ('value="discord"', 'value="telegram"', 'value="mcp"',
'connectorForm.kind'):
assert token in html, token
# 1 seule section « bientôt » restante (plugins)
# v7.58.0 : plus aucune section « bientôt » (plugins rendu vivant)
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert root.count("disabled:true") == 1
assert root.count("disabled:true") == 0
resp = client.get("/accounts")
assert resp.status_code == 200 and "connectorPreset" in resp.text
+142
View File
@@ -0,0 +1,142 @@
"""v7.58.0 — Add plugins : registre persistant + on/off à effet réel."""
import asyncio
from pathlib import Path
from conftest import anon_csrf
from app.services import plugins
from app.services.tool_registry import ToolRegistry
ROOT = Path(__file__).resolve().parent.parent
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
PANEL_HTML = ROOT / "app" / "templates" / "agent_panel.html"
SETTINGS_HTML = ROOT / "app" / "templates" / "settings.html"
AUTOMATIONS_PY = ROOT / "app" / "services" / "automations.py"
def _set(client, slug: str, enabled: bool) -> dict:
r = client.patch(f"/api/agent/plugins/{slug}", json={"enabled": enabled})
assert r.status_code == 200, r.text
return r.json()
# ── Catalogue ───────────────────────────────────────────────────────────────
def test_catalog_lists_the_three_wired_plugins(client):
data = client.get("/api/agent/plugins").json()["plugins"]
assert [p["slug"] for p in data] == ["web-tools", "web-clipper", "automations"]
assert all(p["enabled"] for p in data)
assert all(p["name"] and p["description"] for p in data)
def test_unknown_slug_is_404(client):
assert client.patch("/api/agent/plugins/nope",
json={"enabled": False}).status_code == 404
def test_toggle_persists_in_db(client):
_set(client, "web-clipper", False)
assert plugins.is_enabled("web-clipper") is False # relu depuis la base
_set(client, "web-clipper", True)
assert plugins.is_enabled("web-clipper") is True
# ── Effet réel : routes refusées ────────────────────────────────────────────
def test_automations_routes_refused_when_plugin_off(client):
# 404 hors /api = redirection vers /workspaces (handler unifié de main.py)
def get():
return client.get("/workspace/automations", follow_redirects=False)
assert get().status_code == 200
_set(client, "automations", False)
assert get().status_code == 302
assert get().headers["location"] == "/workspaces"
assert client.post("/workspace/automations", json={},
follow_redirects=False).status_code == 302
assert plugins.is_enabled("automations") is False
_set(client, "automations", True)
assert get().status_code == 200
def test_scheduler_tick_guarded_when_automations_off(client):
# le scheduler doit vérifier le plugin avant chaque tick ( effet réel, pas un
# drapeau lu nulle part )
src = AUTOMATIONS_PY.read_text(encoding="utf-8")
assert 'plugins.is_enabled("automations")' in src
_set(client, "automations", False)
assert plugins.is_enabled("automations") is False
def test_web_clipper_api_and_page_refused_when_plugin_off(client):
def page():
return client.get("/extensions", follow_redirects=False)
assert client.get("/api/v2/web-clipper/status").status_code == 200
assert page().status_code == 200
_set(client, "web-clipper", False)
assert client.get("/api/v2/web-clipper/status").status_code == 404
assert client.post("/api/v2/web-clipper/clip", json={}).status_code == 404
assert page().status_code == 302 and page().headers["location"] == "/workspaces"
_set(client, "web-clipper", True)
assert client.get("/api/v2/web-clipper/status").status_code == 200
assert page().status_code == 200
# ── Effet réel : outils de l'agent ──────────────────────────────────────────
def test_web_tools_leaves_the_llm_registry_when_plugin_off(client):
names = {t["name"] for t in ToolRegistry().schema()}
assert {"web_search", "fetch_url"} <= names
_set(client, "web-tools", False)
names = {t["name"] for t in ToolRegistry().schema()}
assert not ({"web_search", "fetch_url"} & names)
res = asyncio.run(ToolRegistry().execute("fetch_url",
{"url": "https://example.com"}))
assert res.status == "error" and "inconnu" in res.message.lower()
_set(client, "web-tools", True)
names = {t["name"] for t in ToolRegistry().schema()}
assert {"web_search", "fetch_url"} <= names
# ── UI : settings rendu côté serveur + menu + ──────────────────────────────
def test_settings_hides_disabled_plugins(client):
html = client.get("/settings").text
assert "navTo('extensions')" in html and "navTo('automations')" in html # ON
_set(client, "automations", False)
_set(client, "web-clipper", False)
html = client.get("/settings").text
assert "navTo('automations')" not in html # nav retirée
assert "navTo('extensions')" not in html
assert "x-show=\"false &amp;&amp; activeSection==='automations'\"" in html
_set(client, "automations", True)
_set(client, "web-clipper", True)
html = client.get("/settings").text
assert "navTo('automations')" in html and "navTo('extensions')" in html
def test_menu_catalogue_is_live(client):
src = JS_PATH.read_text(encoding="utf-8")
for token in ("action:'plugins'", "_pluginItems()", "pluginToggle(slug)",
"/api/agent/plugins", "Add plugins"):
assert token in src, token
# la section n'est plus une coquille « bientôt »
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert "disabled:true" not in root
panel = PANEL_HTML.read_text(encoding="utf-8")
assert "plusSection!=='root'" in panel # en-tête/rétour génériques
settings = SETTINGS_HTML.read_text(encoding="utf-8")
assert settings.count("plugin_enabled(") >= 4
def test_plugins_routes_require_session(client):
anon_csrf(client)
assert client.get("/api/agent/plugins").status_code == 401
assert client.patch("/api/agent/plugins/automations",
json={"enabled": False}).status_code == 401
+100
View File
@@ -0,0 +1,100 @@
"""7.59.2 — corrections : bouton ✕ de l'aide, « Insert below » qui perd le
format, listes numérotées invisibles (agent + /numbered list)."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
HELP_HTML = ROOT / "app" / "templates" / "help.html"
HELP_JS = ROOT / "static" / "js" / "help.js"
EDITOR_JS = ROOT / "static" / "js" / "page_editor_scripts.js"
APP_CSS = ROOT / "static" / "css" / "app.css"
SETTINGS_CSS = ROOT / "static" / "css" / "settings.css"
# ── 1. Aide : fermeture en haut à droite (mobile compris) ───────────────────
def test_help_page_has_close_button():
html = HELP_HTML.read_text(encoding="utf-8")
js = HELP_JS.read_text(encoding="utf-8")
css = SETTINGS_CSS.read_text(encoding="utf-8")
assert 'class="settings-close"' in html # bouton ✕ présent
assert '@click="close()"' in html
assert "close()" in js and "history.back()" in js # méthode réelle
assert ".settings-close{" in css # style partagé (32/44px)
assert "position:relative" in css.split(".settings-panel{")[1][:40]
# ── 2. Insert below : on insère le MARKDOWN, pas le texte du HTML rendu ─────
def test_ai_insert_below_keeps_source_markdown():
src = EDITOR_JS.read_text(encoding="utf-8")
assert "self._md=String(text||'')" in src # source conservée
assert "String(this._md||'').trim()||this._resultText()" in src # md d'abord
assert src.count("this._md='';") >= 2 # close() + _err()
# l'ancien chemin (texte extrait du DOM rendu) n'est plus que le repli
assert "_resultText(){ const el=this.ROOT&&this.ROOT.querySelector('.aici-result')" in src
# ── 3. Listes numérotées : numéros réellement affichés ──────────────────────
def test_numbered_list_renders_its_number():
src = EDITOR_JS.read_text(encoding="utf-8")
css = APP_CSS.read_text(encoding="utf-8")
assert "let numAttr=''" in src and "data-num=" in src # rangée contiguë → rang
assert ".block-numbered[data-num]::before" in css
assert 'content: attr(data-num) ". "' in css
# md2b accepte « 1. » et « 1) » (CommonMark) pour l'insertion agent
assert r"/^\d+[.)] /" in src
def test_slash_query_typed_in_block_is_synced_to_filter():
src = EDITOR_JS.read_text(encoding="utf-8")
# « /numbered list » tapé dans le bloc → répercuté dans l'entrée du menu
assert "SM._in.value=q" in src
assert "SM.flt();" in src
# ── md2b : test comportemental sur la source réelle (node) ──────────────────
def _md2b_source() -> str:
src = EDITOR_JS.read_text(encoding="utf-8")
i = src.index("md2b(md){")
j = src.index("return bl;},", i) + len("return bl;},")
snippet = src[i:j].rstrip().rstrip(",") # « md2b(md){…} »
return snippet
def test_md2b_converts_numbered_lists_for_real():
node = shutil.which("node")
if not node:
pytest.skip("node absent : test comportemental md2b ignoré")
md = ("# Titre\n"
"1. premier\n"
"2. second\n"
"3) troisième\n"
"- puce\n"
"- [ ] à faire\n"
"> citation\n")
script = (
"function _tblIsData(){return false;}\n"
"function _tblSplitLine(){return [];}\n"
"const editor={mkB(t,c,e){const o={type:t,content:c||''};"
"if(e)Object.assign(o,e);return o;},\n"
f"{_md2b_source()}}};\n"
f"console.log(JSON.stringify(editor.md2b({json.dumps(md)})));\n"
)
out = subprocess.run([node, "-e", script], capture_output=True, text=True,
timeout=30, encoding="utf-8")
assert out.returncode == 0, out.stderr
blocks = json.loads(out.stdout)
assert [b["type"] for b in blocks] == [
"heading_1",
"numbered_list", "numbered_list", "numbered_list", # 1. 2. 3)
"bulleted_list", "to_do", "quote",
]
assert [b["content"] for b in blocks[1:4]] == ["premier", "second", "troisième"]
+82
View File
@@ -0,0 +1,82 @@
"""7.59.3 — « Turn into » du menu contextuel : garde cassée + texte conservé."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
EDITOR_JS = ROOT / "static" / "js" / "page_editor_scripts.js"
SRC = EDITOR_JS.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()
def test_turn_into_guard_matches_objects_not_strings():
# BUG : indexOf() sur un tableau d'objets → toujours -1 → retour immédiat.
assert "_BLOCK_TURN_TYPES.indexOf(type)" not in SRC
assert "_BLOCK_TURN_TYPES.some(t=>t.id===type)" in SRC
def test_turn_into_menu_wiring():
# menu ⋮⋮ → « Turn into » → sous-menu → choix de type
assert "_blockMenuItem('submenu','↩','Turn into',{sub:'turn'})" in SRC
assert "'turn-type'" in SRC # construit par _blockMenuItem
assert "act==='turn-type'" in SRC
assert "_blockTurnMenuOpen(" in SRC
def test_turn_into_keeps_text_for_to_do_and_toggle():
# Convertir un texte en To-do / Toggle ne doit plus effacer le contenu.
line = [ln for ln in SRC.splitlines()
if ln.strip().startswith("turnInto(idx,type")][0]
assert "type!=='to_do'&&type!=='toggle'" not in line
assert "if(keep&&['image','embed','meeting','divider'" in line
# ── Comportement réel : la méthode extraite de la source, exécutée sous node ─
def test_turn_into_converts_blocks_for_real():
node = shutil.which("node")
if not node:
pytest.skip("node absent : test comportemental turnInto ignoré")
types = _slice("const _BLOCK_TURN_TYPES=[", "];")
method = _slice("turnInto(idx,type", "setBlockColor(idx,key,val){")
script = f"""
function _rtSync(){{}}
function _selClear(){{}}
{types}];
const E = {{
blocks: [{{id:'b1',type:'paragraph',content:'Bonjour'}}],
_blockTargets(i){{return [i];}},
sync(){{}}, pushHistory(){{}}, dirty:false,
autoSave(){{}}, render(){{}}, getEl(){{return null;}},
{method}
}};
E.turnInto(0,'heading_2');
const heading = {{type:E.blocks[0].type, content:E.blocks[0].content}};
E.turnInto(0,'to_do');
const to_do = {{type:E.blocks[0].type, content:E.blocks[0].content, checked:E.blocks[0].checked}};
E.turnInto(0,'button'); // type sans texte → contenu vidé
const button = {{type:E.blocks[0].type, content:E.blocks[0].content}};
console.log(JSON.stringify({{heading:heading, to_do:to_do, button:button}}));
"""
out = subprocess.run([node, "-e", script], capture_output=True, text=True,
timeout=30, encoding="utf-8")
assert out.returncode == 0, out.stderr
res = json.loads(out.stdout)
# 1) le changement de format a lieu (avant la correction : aucune conversion)
assert res["heading"] == {"type": "heading_2", "content": "Bonjour"}
# 2) To-do : texte conservé comme étiquette, case décochée
assert res["to_do"] == {"type": "to_do", "content": "Bonjour", "checked": False}
# 3) Button (type sans texte) → contenu vidé
assert res["button"] == {"type": "button", "content": ""}
+29
View File
@@ -0,0 +1,29 @@
"""7.60.0 — bouton + à gauche du handle : ajoute un bloc sous le courant."""
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
EDITOR_JS = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
APP_CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
def test_button_rendered_left_of_the_handle():
# le + est émis AVANT le handle dans le wrapper du bloc
i_btn = EDITOR_JS.index("class=\"block-insert-btn\"")
i_handle = EDITOR_JS.index("<div class=\"block-handle\">", i_btn)
assert i_btn < i_handle
# clic → insertion sous le bloc courant (mécanisme déjà existant)
assert 'class="block-insert-btn" title="Add block below" aria-label="Add block below" onclick="E.addAfter(${idx})"' in EDITOR_JS
# réservé aux blocs de premier niveau (idx valide), comme .block-actions
assert "const insertBtn=idx>=0?" in EDITOR_JS
assert "addAfter(idx,type){return this.addAt(idx+1,type);}" in EDITOR_JS
def test_button_style_and_visibility():
assert ".block-insert-btn {" in APP_CSS
assert "left: -64px;" in APP_CSS # gouttière .blocks-container (64px)
assert ".block-wrapper:hover .block-insert-btn," in APP_CSS
assert ".block-insert-btn:hover {" in APP_CSS
# suit le handle quand la gouttière passe à 32px, masqué partout où il l'est
assert ".block-insert-btn { left: -56px; }" in APP_CSS
assert APP_CSS.count(".block-insert-btn { display: none; }") == 2
+172
View File
@@ -0,0 +1,172 @@
"""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
+155
View File
@@ -0,0 +1,155 @@
"""7.62.0 — menu contextuel au highlight de texte (toolbar façon Notion)."""
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")
TPL = (ROOT / "app" / "templates" / "_page_editor_content.html").read_text(encoding="utf-8")
CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
APPJS = (ROOT / "static" / "js" / "app.js").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)]
# ── Markup : les 4 lignes + Skills + pied, dans l'ordre de la description ───
def test_toolbar_markup_matches_spec():
chunk = _slice(TPL, 'class="format-toolbar"', "Copy Link Toast")
order = [
"fmtStyle($event)", # ligne 1 : Normal Text ›
"fmtApply('bold')", "fmtApply('italic')", "fmtApply('underline')",
"fmtApply('strikeThrough')", "fmtCode()", # ligne 2 : A B I U T Tx
"fmtLink()", "fmtHighlight()", "fmtCodeBlock()", "fmtEquation()",
"fmtMore($event)", # ligne 3 : 🔗 S </> √x …
"fmtComment()", "fmtAskAI()", # ligne 4 : 💬 ✨
"ft-group", "Improve writing", "Proofread", "Explain", "Reformat",
"Edit with AI", "Alt+⇧+E", # pied de page
]
positions = [chunk.index(o) for o in order]
assert positions == sorted(positions), positions
# ligne 1 dynamique (type du bloc) + chevron de sous-menu
assert "fmtStyleName()" in chunk and "ft-caret" in chunk
# info-bulle HTML (exemple en italique + description)
assert "data-tooltip-html=" in chunk
# icônes réellement définies dans le jeu d'icônes
assert 'fd_icon("message-square",15)' in chunk
assert 'fd_icon("sparkles",15)' in chunk and 'fd_icon("link",15)' in chunk
# ── Actions : câblées vers de vraies méthodes, sous-menus réutilisés ────────
def test_actions_are_wired_to_real_methods():
for m in ("fmtStyle(ev)", "fmtColor(ev)", "fmtApply(f)", "fmtCode()",
"fmtHighlight()", "fmtCodeBlock()", "fmtLink()", "fmtEquation()",
"fmtMore(ev)", "fmtComment()", "fmtAskAI()", "fmtSkill(tpl)",
"fmtEditAI()", "askAIBlock(idx)"):
assert m in SRC, m
# pas de doublon de menu : on rouvre les sous-menus/menus déjà existants
assert "_blockTurnMenuOpen(p.x,p.y,bid)" in SRC
assert "_blockColorMenuOpen(p.x,p.y,bid)" in SRC
assert "blockMenuOpen(" in SRC
# sous-menu ouvert depuis la toolbar : fermeture au survol sortant
assert "function _subHoverClose()" in SRC
# Ctrl+J et Alt+⇧+E ouvrent l'IA du BLOC (l'ancien askAI(idx) était écrasé
# par askAI() défini plus bas dans le même objet)
assert "E.askAIBlock(i1)" in SRC
assert "k === 'e'" in SRC
assert "askAI(idx)" not in SRC
# ── Persistance du formatage : sync() relit le DOM formaté en markdown ─────
def test_formatting_survives_sync():
assert "function gtMd(" in SRC
assert "gtMd(el)" in SRC and "gtMd(che)" in SRC # sync() + enfants toggle/columns
for tok in ("'**'+inner+'**'", "'*'+inner+'*'", "'__'+inner+'__'",
"'~~'+inner+'~~'", "'`'+inner+'`'", "'=='+inner+'=='"):
assert tok in SRC, tok
# mdEsc rend les nouveaux marqueurs (souligné, surlignage, équation inline)
for re_ in ("/__([^_]+)__/g", "/==([^=]+)==/g", "/\\$\\$([^$]+)\\$\\$/g"):
assert re_ in SRC, re_
# les chips wiki gardent leur token brut
assert "fd-wiki-chip" in SRC
# ── Placement & fermeture ──────────────────────────────────────────────────
def test_menu_position_and_close():
assert "r.bottom+8" in SRC # sous la sélection (bas de l'écran : au-dessus)
assert "d.fmt.open=false;d._sel=null" in SRC # clic ailleurs → fermeture
assert "closest('.format-toolbar')" in SRC # clic dans la toolbar ≠ clic ailleurs
assert "@mousedown.prevent" in TPL # la sélection survit au clic
# ── Styles ────────────────────────────────────────────────────────────────
def test_menu_styles():
for sel in (".format-toolbar .ft-row {", ".format-toolbar .ft-item {",
".format-toolbar .ft-style {", ".format-toolbar .ft-group {",
".format-toolbar .ft-sep {", ".format-toolbar .ft-foot {",
".tooltip.tooltip-light {"):
assert sel in CSS, sel
# l'info-bulle claire s'applique aux éléments du menu de sélection
assert "data-tooltip-html" in APPJS and "tooltip-light" in APPJS
# ── Comportement réel : gtMd (DOM → source markdown) + mdEsc, sous node ────
def test_gt_md_and_md_esc_roundtrip_for_real():
node = shutil.which("node")
if not node:
pytest.skip("node absent : test comportemental ignoré")
gtmd = _slice(SRC, "function gtMd(", " // Enveloppe/désenveloppe")
mdesc = _slice(SRC, "function mdEsc(", " // v5.11.0: labels of [[fdpage:ID]]")
script = f"""
var window = {{}}; // gtMd fait window.gtMd=…
function gtTok(e){{ return e.__raw || ''; }}
function fdDateLabel(iso){{ return iso; }}
{gtmd}
{mdesc}
// DOM minimal (gtMd ne lit que childNodes / tagName / classList / attributs)
function T(s){{ return {{nodeType:3, nodeValue:s}}; }}
function E(tag, attrs, kids){{
attrs = attrs || {{}};
const cls = String(attrs['class'] || '').split(/\\s+/);
return {{nodeType:1, tagName:tag, childNodes:kids || [],
classList:{{contains:c=>cls.indexOf(c) >= 0}},
getAttribute:k=>(k in attrs ? attrs[k] : null),
hasAttribute:k=>(k in attrs)}};
}}
// querySelector truthy → chemin « formaté » de gtMd (gtTok jamais appelé)
const root = E('DIV', {{'class':''}}, [
E('B', {{}}, [T('gras')]),
E('EM', {{}}, [T('italique')]),
E('U', {{}}, [T('souligne')]),
E('S', {{}}, [T('barre')]),
E('CODE', {{}}, [T('code')]),
E('MARK', {{}}, [T('surligne')]),
E('A', {{'href':'https://example.com/x'}}, [T('lien')]),
E('SPAN', {{'class':'fd-wiki-chip', 'data-token':'[[fdpage:42]]'}}),
E('SPAN', {{'class':'fd-math-inline', 'data-tex':'x^2'}}),
T(' fin')
]);
root.querySelector = () => ({{}}); // force le chemin formaté
const md = gtMd(root);
const html = mdEsc(md);
console.log(JSON.stringify({{md:md, html:html}}));
"""
out = subprocess.run([node, "-e", script], capture_output=True, text=True,
timeout=30, encoding="utf-8")
assert out.returncode == 0, out.stderr
data = json.loads(out.stdout)
assert data["md"] == ("**gras***italique*__souligne__~~barre~~`code`==surligne=="
"[lien](https://example.com/x)[[fdpage:42]]$$x^2$$ fin"), data["md"]
for token in ("<strong>gras</strong>", "<u>souligne</u>", "<mark>surligne</mark>",
"<code>code</code>", 'href="https://example.com/x"',
'class="fd-wiki-chip"', 'data-tex="x^2"'):
assert token in data["html"], token
+136
View File
@@ -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 »