Compare commits

...
10 Commits
Author SHA1 Message Date
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 0f86294abc merge: main → develop (sync) 2026-07-20 11:02:42 -04:00
30 changed files with 2148 additions and 308 deletions
+231
View File
@@ -1,5 +1,236 @@
# Changelog - FlowDeck
## 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
+11
View File
@@ -1408,6 +1408,17 @@ 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
---
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
+1 -1
View File
@@ -1 +1 @@
7.58.0
7.62.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.62.0 (menu contextuel au highlight de texte — toolbar 4 lignes façon Notion) | **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
+1 -1
View File
@@ -186,7 +186,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.58.0",
version="7.62.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+21
View File
@@ -1678,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,
)
+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)
+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 = (
+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 %}
+11 -17
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,14 +113,20 @@
.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>
+3 -3
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.58.0"
"version": "7.62.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": {
@@ -30444,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();
});
+190 -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,146 @@ 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;
}
/* EDITOR STATUSBAR */
.editor-statusbar {
@@ -2925,6 +3099,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 +3575,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 +3600,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 +3666,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 +3754,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;}
}
+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;
},
};
}
}
+399 -56
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){
@@ -419,7 +473,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 +486,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 +532,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 +654,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 +975,216 @@ 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 _blockTurnMenuOpen(x,y,bid){
var d=_blockSubShell(x,y);
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});
});
d.innerHTML=html;
}
function _blockColorMenuOpen(x,y,bid){
var d=_blockSubShell(x,y);
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});
});
d.innerHTML=html;
}
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 +1453,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 +1723,39 @@ 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');
},
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 +2212,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){
@@ -2004,7 +2252,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 +2374,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 +2773,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 +2815,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 +2864,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.
+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
+170
View File
@@ -0,0 +1,170 @@
"""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():
turn = _slice("function _blockTurnMenuOpen(", "\n function _blockColorMenuOpen(")
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 _blockColorMenuOpen(", "\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