Compare commits

...
5 Commits
Author SHA1 Message Date
bruno 00c5e2a60d feat: commentaire ancré sur la SÉLECTION de texte (pas le bloc) + retrait du bouton bleu « 💬 Comment » (v7.65.0)
FlowDeck CI / lint (push) Successful in 1m34s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 12m52s
- commentBlock() : l'ancre est la sélection vivante dans le bloc (repli : la
  plage mémorisée par la toolbar de sélection, qui survit au clic du bouton),
  sinon tout le bloc ; offsets via _srcOffset() → anchor_start/anchor_end
  exacts dans le texte source, l'ancre jaune ne couvre que le texte choisi.
- Suppression de #_commentSelBtn (bouton bleu flottant « 💬 Comment » au-dessus
  de la sélection), de showCommentBtn()/hideCommentBtn() et du listener
  mousedown associé ; déclencheurs restants : 💬 de la toolbar de sélection,
  menu de bloc, Ctrl+⇧+M.
- toggleComments() remet _commentSel à zéro à la fermeture du tiroir : un
  brouillon abandonné ne peut plus ancrer le commentaire suivant ailleurs.
- e2e v764_comments_reactions : sous-sélection « reaction a un » → ancre = la
  plage seule (avant/après reload), #_commentSelBtn absent.

Tests: pytest tests/ -q -n auto → 1383 passed ; ruff check app tests → OK ;
e2e v764/v762/v763/v7641/regression_editor_mount → 5 passed sur :8081.
2026-10-08 12:19:56 -04:00
bruno 858649e3c3 fix: la touche Delete supprime maintenant le bloc vide (comme Backspace) (v7.64.1)
FlowDeck CI / lint (push) Successful in 1m34s
FlowDeck CI / test (push) Failing after 18m41s
FlowDeck CI / docker (push) Skipped
- onKd : la garde « bloc sans texte → supprimer le bloc » couvre Backspace ET
  Delete (une seule branche partagée) — Delete vide d'abord le bloc, puis le
  supprime au coup suivant ; le dernier bloc de la page n'est jamais supprimé
  (l'éditeur garde un paragraphe)
- le vide est jugé avec gtTok(el) (le texte lu à la sauvegarde) et non
  textContent : une pastille de réaction seule n'empêche plus la suppression
- Tests : e2e/v7641_delete_key.spec.js (texte → Delete vide le bloc → Delete
  supprime le bloc → dernier bloc jamais supprimé) ; 4/4 specs e2e verts,
  suite complète 1383 passed / 0 failed, ruff OK, OpenAPI 7.64.1 (526 chemins)
2026-10-08 11:48:16 -04:00
bruno 400a827e3f feat: commentaires façon Notion — ancres jaunes, tiroir refondu, réactions emoji sur texte (v7.64.0)
FlowDeck CI / lint (push) Successful in 2m5s
FlowDeck CI / test (push) Failing after 3h5m38s
FlowDeck CI / docker (push) Skipped
- Réaction sur texte : bouton 🙂+ (« React to selected text ») dans la toolbar
  de sélection → sélecteur d'émoji EXISTANT en mode réaction (en-tête = texte
  sélectionné + ✋, onglets masqués, ligne Recent au-dessus de la grille
  People, Filter… et barre de catégories avec + custom inchangés)
- Backend : migration 37 text_reactions (UNIQUE plage × emoji × utilisateur →
  POST idempotent) + GET/POST /api/pages/{id}/reactions (GET groupé par plage
  emoji/count/mine, POST toggle, 400 sur plage vide)
- Affichage : plage commentée/réagie peinte en jaune (_paintAnchors après
  render/loadComments/loadReactions), pastille 😀+compte après la plage, clic
  pastille = rouvrir le sélecteur, clic ancre = ouvrir et centrer le fil
- Pastilles = annotations : gtTok/gtMd les ignorent (y compris le chemin
  rapide innerText) et les offsets _srcOffset/_srcNodeLen les excluent → le
  texte sauvé est identique (vérifié dans le e2e)
- Tiroir refondu : carte de fil (avatar, nom, horodatage, ✓ toggle
  resolve/reopen, ⋯ → Copy link #cmt-<id>/Delete), saisie « Add a comment... »
  avec @ et envoi ↑/Entrée ; #cmt-<id> ouvre et centre le fil au chargement ;
  loadComments + loadReactions à l'ouverture (commentCount enfin renseigné)
- Fixes : resolveComment ne pouvait pas rouvrir (toujours resolved:true) ;
  showCommentBtn ignorait les sélections à ancre élément (bouton 💬 jamais
  affiché)
- Tests : tests/test_v764_comments_reactions.py (8, dont toggle API réel),
  e2e/v764_comments_reactions.spec.js (8 étapes) — 3/3 specs e2e verts,
  suite complète 1383 passed / 0 failed, ruff OK, OpenAPI 7.64.0 (526 chemins)
2026-10-08 10:46:14 -04:00
bruno 699165469d feat: menus mobiles façon Notion — barre horizontale au « / » + Insert block plein écran (v7.63.0)
FlowDeck CI / lint (push) Successful in 1m32s
FlowDeck CI / test (push) Failing after 21m41s
FlowDeck CI / docker (push) Skipped
- Barre flottante défilante sous le curseur (mobile ≤ 768 px) au lieu de la
  popup desktop, 15 boutons dans l'ordre de la description : +▾, Turn into▾,
  @, 💬, 🖼, 🗑, ←, →, ↑, ↓, ↩, ↪, Color▾, More▾, ⌨
- +▾ → feuille plein écran « Insert block » (titre centré, Cancel bleu, groupes
  Basic blocks / AI writing / Media / Data / Synced / Actions, raccourcis
  markdown à droite) ; items partagés avec la popup desktop (SM._slashItemHtml)
- Turn into ▾ / Color ▾ → feuilles réutilisant les générateurs extraits
  _blockTurnItems / _blockColorItems (extraits de _blockTurnMenuOpen /
  _blockColorMenuOpen, comportement desktop inchangé)
- More ▾ → menu contextuel de bloc existant ; @ insère + ouvre les mentions ;
  💬 commentaire ; 🖼 bloc image ; 🗑 suppression ; ⌨ masque le clavier ;
  ↩ ↪ annuler/rétablir
- moveBlock(bid, dir) : ↑↓ réordonne (racine + enfants toggle/columns),
  → indent dans le frère précédent, ← outdent, garde-fous avec toast
- Fermeture auto de la barre quand le « / » n'est plus en tête de bloc ; taps
  dans la barre/les feuilles exclus du handler « clic hors-zone » ; Cancel ne
  referme que la feuille (la barre reste tant que le / est tapé)
- Raccourcis slash alignés sur les préfixes markdown (-, [], >, ---), groupe
  SYNCED libellé « Synced »
- Tests : tests/test_v763_mobile_menus.py (ordre, câblage, structure, styles +
  moveBlock exécuté sous node), e2e/v763_mobile_menus.spec.js (390×844, 1
  passed) ; test_v761 adapté aux extractions ; suite complète 1375 passed / 0
  failed, ruff OK, OpenAPI 7.63.0 (525 chemins)
2026-10-08 09:36:37 -04:00
bruno 3a6445ca4e feat: menu contextuel au highlight de texte — toolbar 4 lignes façon Notion (v7.62.0)
FlowDeck CI / lint (push) Successful in 1m32s
FlowDeck CI / test (push) Failing after 20m17s
FlowDeck CI / docker (push) Skipped
- Toolbar de sélection sous le surlignage : ligne de style (T + type de bloc
  dynamique → sous-menu Turn into), A/B/I/U/T barré/Tx, lien/surlignage/
  code/équation/…, Comment + Ask AI, section grise Skills (4 actions) et pied
  « Edit with AI » (Alt+⇧+E) ; positionnée sous la sélection (au-dessus si
  manque de place), fermée au clic hors sélection
- « … » → menu contextuel de bloc existant (recherche « Search actions… »,
  groupe Text, 10 actions + raccourcis, pied auteur/heure/taille)
- Info-bulle claire à droite des éléments du menu : data-tooltip-html +
  classe .tooltip-light dans le tooltip global (app.js)
- Persistance du formatage inline : sérialiseur gtMd() (DOM formaté → source
  markdown) branché sur sync() ; mdEsc() rend __…__ (u), ==…== (mark) et
  $$…$$ (équation inline KaTeX, data-tex conservé)
- Fixes racine : askAI(idx) écrasé par askAI() → askAIBlock(idx) (menu de bloc,
  Ctrl+J, Alt+⇧+E) ; ed.__x (Alpine v2) inexistant sous v3 → _xed() via
  _x_dataStack[0] (showFmt/hideCommentBtn/mentions ne s'exécutaient jamais) ;
  ancre de sélection nœud élément gérée ; checkMd() appelé avec un id au lieu
  de l'index (exception à chaque barre d'espace)
- Tests : tests/test_v762_selection_toolbar.py (roundtrip gtMd/mdEsc sous node)
  + e2e/v762_selection_toolbar.spec.js (structure, sous-menus, géométrie,
  persistance bold/highlight après reload) ; suite complète 1368 passed / 0
  failed, ruff OK, OpenAPI régénérée 7.62.0 (525 chemins)
2026-10-08 08:39:39 -04:00
22 changed files with 2328 additions and 127 deletions
+217
View File
@@ -1,5 +1,222 @@
# Changelog - FlowDeck
## v7.65.0 (2026-10-08) — Comment ancré sur la SÉLECTION de texte (pas le bloc) + retrait du bouton bleu « 💬 Comment »
### Changed
- **`commentBlock()` ancre sur la sélection de texte** : la plage vivante dans le
bloc est utilisée d'abord (plage de la toolbar de sélection en repli, le clic
sur le bouton ayant pu la faire disparaître du DOM), sinon tout le bloc.
Les offsets passent par `_srcOffset()` → `anchor_start/anchor_end` = offsets
dans le texte source, donc l'ancre jaune ne couvre que le texte sélectionné
(`_paintAnchors` / réactions inchangés).
- **Retiré le bouton bleu flottant « 💬 Comment »** (`#_commentSelBtn`) qui
apparaissait au-dessus de la sélection : le déclencheur est le bouton 💬 de
la toolbar de sélection, le menu de bloc (💬 Comment, Ctrl+⇧+M) et le raccourci.
`showCommentBtn()`/`hideCommentBtn()` et le listener `mousedown` associés sont
supprimés.
- Fermer le tiroir (`toggleComments`) remet `_commentSel` à zéro : un brouillon
abandonné ne peut plus attacher le commentaire suivant à une ancienne plage.
### Tests
- **e2e** `e2e/v764_comments_reactions.spec.js` : sélection d'une **sous-chaîne**
(`reaction a un`) → toolbar 💬 → tiroir → envoi ; assert `#_commentSelBtn`
absent et `.fd-anchor[data-cid]` = **uniquement** la plage sélectionnée
(avant **et** après reload).
- `pytest tests/ -q -n auto` → **1383 passed**, `ruff check app tests` → OK.
## v7.64.1 (2026-10-08) — Fix : Delete sur un bloc vide supprime le bloc
### Fixed
- **La touche Delete ne supprimait pas le bloc vide** : seul `Backspace` avait la
garde « bloc sans texte → supprimer le bloc ». La branche couvre désormais les
**deux touches** (`onKd`, une seule garde partagée) — Delete vide le bloc puis
le supprime au coup suivant, comme Backspace ; le dernier bloc de la page n'est
jamais supprimé (l'éditeur garde un paragraphe).
- Le vide est jugé avec **`gtTok(el)`** (le texte lu à la sauvegarde) et non
`textContent` : une pastille de réaction seule dans le bloc ne l'empêche plus
d'être supprimé.
### Tests
- **e2e** `e2e/v7641_delete_key.spec.js` : texte → Delete (bloc vidé, pas
supprimé) → Delete (bloc supprimé, le précédent reste) → dernier bloc vide
jamais supprimé.
## v7.64.0 (2026-10-08) — Commentaires façon Notion : ancres jaunes, tiroir refondu, réactions sur texte
### Added
- **Réactions emoji sur un texte sélectionné** :
- bouton **🙂+** dans la 3ᵉ ligne de la toolbar de sélection, infobulle
« React to selected text » → ouvre le **sélecteur d'émoji existant** en mode
réaction : en-tête = texte sélectionné (+ ✋), onglets masqués (emoji only),
ligne **Recent** au-dessus de la grille **People**, Filter… et barre de
catégories inchangées (dont `+` émoji custom).
- **stockage** : migration 37 (`text_reactions`, une ligne par plage × émoji ×
utilisateur, `UNIQUE` qui rend le POST idempotent) + endpoints
`GET/POST /api/pages/{id}/reactions` (GET groupé par plage : `emoji/count/mine`,
POST = toggle avec validation de plage non vide).
- **affichage** : la plage est peinte en jaune (`.fd-anchor-react`) avec une
**pastille** juste après (`😀` + compte si >1) ; clic sur la pastille →
réouvre le sélecteur pour cette plage (ajout/suppression).
- les pastilles ne sont **pas** du contenu : `gtTok`/`gtMd` les ignorent et les
offsets `_srcOffset/_srcNodeLen` les excluent → `sync()` conserve le texte
d'origine (vérifié dans le e2e) et les ancres ne décalent jamais.
- **Ancres de commentaires visibles** — `_paintAnchors()` (appelé après chaque
`render()`, après `loadComments` et `loadReactions`) entoure la plage commentée
d'un **jaune** (`.fd-anchor`, gris si résolu) ; **clic sur l'ancre → le tiroir
s'ouvre et centre le fil** (flash de surlignage).
- **Tiroir de commentaires refondu** (image 1) : fil en carte arrondie avec
**avatar coloré + nom + horodatage + ✓ (resolve/reopen) + ⋯** (menu Copy link /
Delete), étiquette « 💬 on selected text », et champ de saisie **« Add a
comment... »** avec **@** (insère la mention) et **bouton circulaire ↑** (envoi,
`Entrée` aussi) — remplace le bouton « Comment ».
- **Liens de commentaire** : `Copy link` copie `…#cmt-<id>` ; au chargement d'une
page avec ce hash, le fil correspondant s'ouvre et se centre.
- **Chargement initial** : `loadComments()` + `loadReactions()` à l'ouverture de la
page → les ancres/pastilles sont peintes dès le premier rendu (et `commentCount`
du tiroir est enfin renseigné).
### Fixed
- **`resolveComment` ne pouvait pas rouvrir** : il envoyait toujours
`resolved:true` → accepte désormais l'objet du fil et bascule
(`resolveComment(c)`), le bouton ✓ du fil est donc réellement un toggle.
- **`showCommentBtn` ignorait les sélections à ancre « élément »** (double-clic,
sélection programmatique) → `closest('[data-bid]')` partait du wrapper et le
bouton 💬 n'apparaissait pas → gère les 2 types de nœud (même fix que `showFmt`
en v7.62).
- **`gtTok` chemin rapide (`innerText`) incluait les pastilles de réaction** →
sélecteur enrichi (`.fd-react-chip`) pour basculer sur le marcheur qui les
exclut : le texte sauvé reste identique.
### Tests
- `tests/test_v764_comments_reactions.py` (8 tests) : table + migration 37,
endpoints enregistrés, **toggle API réel** (added → removed → added, grouping,
400 sur plage vide), markup toolbar/tiroir, peinture des ancres, mode réaction
du sélecteur.
- **e2e Playwright** `e2e/v764_comments_reactions.spec.js` : bouton 🙂+ avec la
bonne infobulle → sélecteur (en-tête = texte, Filter…, onglets masqués) →
pastille + plage jaune **avec contenu inchangé après `sync()`** → 💬 → tiroir
(avatar, nom, ✓, ⋯, Copy link) → ancre jaune cliquable → ✓ résout → reload :
ancres et pastilles rechargées depuis la base ; **3/3 specs e2e verts**
(`--workers=1`).
- **Suite complète** : `pytest tests/ -q -n auto` → **1383 passed / 0 failed**,
`ruff check app tests` → All checks passed.
## v7.63.0 (2026-10-08) — Menus mobiles façon Notion (barre au « / » + Insert block plein écran)
### Added
- **Barre horizontale mobile** — sur un viewport ≤ 768 px, taper `/` sur une ligne
vide affiche une **barre flottante défilante sous le curseur** (au lieu de la popup
desktop), 15 boutons dans l'ordre Notion : `+▾`, `Turn into▾`, `@`, 💬, 🖼, 🗑,
`←`, `→`, `↑`, `↓`, `↩`, `↪`, `Color▾`, `More▾`, `⌨` :
- `+▾` → **feuille plein écran « Insert block »** : titre centré, bouton bleu
« Cancel », liste verticale par groupes (Basic blocks / AI writing / Media /
Data / Synced / Actions) avec icône, nom et **raccourci markdown** à droite
(`#`, `##`, `-`, `1.`, `[]`, `>`, `"`, `---`…) — items partagés avec la popup
desktop (`SM._slashItemHtml`), choix → `applySlash` → tout se referme.
- `Turn into▾` / `Color▾` → feuilles réutilisant **les mêmes générateurs d'items**
que les sous-menus du menu de bloc (`_blockTurnItems` / `_blockColorItems`
extraits de `_blockTurnMenuOpen` / `_blockColorMenuOpen`).
- `More▾` → menu contextuel de bloc existant (recherche + 10 actions + pied).
- `@` insère `@` et ouvre le sélecteur de mention ; 💬 commentaire ; 🖼 insère un
bloc image ; 🗑 supprime ; `⌨` masque le clavier ; `↩ ↪` annuler/rétablir.
- **Déplacement de bloc `moveBlock(bid, dir)`** — `↑↓` réordonne (y compris dans un
parent toggle/columns), `→` indente dans le frère précédent (toggle/columns),
`←` ressort à la racine ; garde-fous avec toast (déjà en haut/bas, déjà à la
racine, pas de cible).
- **Fermeture automatique** de la barre quand le `/` n'est plus au début du bloc ;
taps dans la barre/la feuille n'essaient plus de fermer le menu (exclusion
`MTB.contains` sur le handler mousedown hors-zone).
- **Raccourcis slash alignés sur les préfixes markdown** (description Notion) :
`-` (puces), `[]` (à faire), `>` (toggle), `---` (séparateur) — partagés avec la
popup desktop ; groupe `SYNCED` libellé « Synced ».
### Tests
- `tests/test_v763_mobile_menus.py` : ordre des 15 boutons, câblage de chaque
action, structure de la feuille (titre/Cancel/groupes), branchement mobile,
styles — et **`moveBlock` exécuté pour de vrai sous node** (ordre, indent,
outdent, garde-fous).
- **e2e Playwright mobile** `e2e/v763_mobile_menus.spec.js` (viewport 390×844,
`hasTouch`) : barre visible au `/` avec les 15 boutons dans l'ordre + `overflow-x:auto`
et popup desktop masquée, « Insert block » (groupes, raccourci `#` de Heading 1)
→ le bloc devient `heading_1`, « Turn into » (coche du type courant), « Color »
(3 groupes), « More » (menu de bloc + pied), `↑` qui réordonne réellement les blocs.
- `tests/test_v761_block_menu.py` adapté aux extractions `_blockTurnItems` /
`_blockColorItems`.
## v7.62.0 (2026-10-08) — Menu contextuel au highlight de texte (toolbar façon Notion)
### Added
- **Toolbar de sélection** — dès qu'un texte est surligné dans l'éditeur, un menu
flottant apparaît **sous la sélection** (au-dessus si manque de place), centré et
clampé au viewport, en 4 lignes comme dans Notion :
1. **Style de texte** — `T` + libellé **dynamique** du type de bloc (Normal Text,
Heading 1… ; le type courant) + `›` → ouvre le sous-menu **Turn into** existant
(coche sur le type actuel, fermeture au survol sortant).
2. **A** (sous-menu Color : texte/fond + dernière couleur), **B** gras, *I* italique,
**U** souligné, **T** barré, **Tx** code inline.
3. 🔗 lien, **S** surlignage (`<mark>`), `</>` bloc code, **√x** équation inline
(prompt LaTeX → KaTeX), **…** → menu contextuel de bloc (recherche + 10 actions +
pied « Last edited by … / N words, M characters »).
4. 💬 Comment (`Ctrl+⇧+M`), ✨ Ask AI (`Ctrl+J`).
puis la section grise **Skills** (Improve writing, Proofread, Explain, Reformat →
`runSkill` sur le bloc) et le pied **Edit with AI** `Alt+⇧+E`.
- **Info-bulle claire à droite** des éléments du menu — extension du système de
tooltip existant (`app.js`) : `data-tooltip-html` (ex. en italique + description
pour la ligne 1) + classe `.tooltip-light`, position à droite de l'élément (débord
→ à gauche), sous l'élément hors toolbar.
- **Persistance du formatage inline** : nouveau sérialiseur **`gtMd()`** (DOM formaté
→ source markdown) appelé par `sync()` (blocs + enfants toggle/columns) — le
formatage posé ressort en `**gras**`, `*ital*`, `__souligné__`, `~~barré~~`,
`` `code` ``, `==surlignage==`, `[texte](url)` ; les chips wiki gardent leur token.
`mdEsc()` rend en plus `__…__` (`<u>`), `==…==` (`<mark>`) et `$$…$$`
(équation inline KaTeX, `data-tex` conservé pour l'aller-retour).
- **Fermeture propre** : clic hors sélection → le menu se referme ; clic **dans** la
toolbar → sélection et menu intacts (`@mousedown.prevent` + garde `mouseup`).
### Fixed
- **`askAI(idx)` était écrasé par `askAI()`** (même clé dans l'objet éditeur, définie
plus bas) : « Ask AI » du menu de bloc et `Ctrl+J` ouvraient l'IA de **page** au
lieu du composeur du bloc → renommé **`askAIBlock(idx)`** (appels : menu de bloc,
`Ctrl+J`, `Alt+⇧+E`).
- **`ed.__x` introuvable sous Alpine v3** — le data de `.page-editor-wrapper` était
lu via `ed.__x` (API Alpine v2, plus exposée depuis le bundle CSP) : `showFmt`,
`hideCommentBtn` et les handlers de mentions ne s'exécutaient **jamais** → la
toolbar de sélection n'apparaissait pas. Accès unifié via `_xed()`
(`_x_dataStack[0]`, repli `__x`), 4 sites éditeur.
- **Ancre de sélection** — `closest('[data-bid]')` partait du parent de l'ancre :
quand l'ancre est un nœud **élément** (sélection programmée, double-clic), le bloc
n'était jamais trouvé → `showFmt` pas appelé. Gestion des 2 types de nœud.
- **Barre d'espace = exception** — `checkMd()` recevait l'**id** du bloc au lieu de
l'index : chaque espace levait `Cannot read properties of undefined (reading 'id')`
et les raccourcis markdown (`# `, `- `) ne se déclenchaient jamais.
### Tests
- `tests/test_v762_selection_toolbar.py` : ordre des 4 lignes + Skills + pied,
câblage réel des actions, persistance `gtMd`/`mdEsc` (**roundtrip réel sous node**),
placement/fermeture, styles ; `tests/test_v761_block_menu.py` adapté
(`askAIBlock`).
- **e2e Playwright** `e2e/v762_selection_toolbar.spec.js` (instance 8081) : structure
des 4 lignes, sous-menu Turn into (coche du type courant), menu « … » (recherche +
pied), géométrie (sous la sélection, dans le viewport), **persistance du gras et du
surlignage après reload**, fermeture hors sélection → 1 passed (captures dans
`e2e/shots/` — répertoire gitignoré).
- **Suite complète** : `pytest tests/ -q -n auto` → **1368 passed / 0 failed**,
`ruff check app tests` → All checks passed.
## v7.61.0 (2026-10-08) — Menu contextuel de bloc refondu façon Notion
### Added
+74
View File
@@ -1408,6 +1408,80 @@ Détails livrés :
- [x] **Tests follow-ups** — `tests/test_v73_wiki_polish.py` (58 → **72 tests**) : sidebar cross-workspace, page teamspace (owner 200 / outsider 404 API), auto-follow comment, notif throttlée, unfurl gitea/github + endpoint, KPI + dashboards multi-DB
- [x] **Suite complète** — `python -m pytest -n auto` → **1016 passed** (était 981 passed / 21 failed en SSO)
### v7.62.0 — Menu contextuel au highlight de texte ✅ (2026-10-08)
> **Objectif** : menu façon Notion (mode sombre) au surlignage de texte dans l'éditeur. **COMPLETED**.
- [x] **Toolbar de sélection 4 lignes** — `T` + type de bloc dynamique + `›` (sous-menu Turn into existant) / `A` `B` `I` `U` `T` barré `Tx` / 🔗 `S` `</>` `√x` `…` / 💬 `✨`, section grise **Skills** (Improve writing, Proofread, Explain, Reformat → `runSkill`) et pied **Edit with AI** `Alt+⇧+E` — affichée **sous** la sélection (au-dessus si pas de place), clampée au viewport, fermeture au clic hors sélection
- [x] **`…` → menu contextuel de bloc** déjà existant (recherche « Search actions… », groupe Text, 10 actions + raccourcis, pied métadonnées auteur/heure/taille) — pas de doublon de menu
- [x] **Persistance du formatage inline** — sérialiseur `gtMd()` (DOM → source markdown) branché sur `sync()` ; `mdEsc()` rend `__…__`, `==…==`, `$$…$$` (KaTeX inline) — avant, `sync()` relisait du texte brut et perdait tout le formatage au prochain rendu
- [x] **Info-bulle claire à droite** — `data-tooltip-html` + `.tooltip-light` dans le tooltip global (`app.js`)
- [x] **Fix `askAI(idx)` écrasé par `askAI()`** — « Ask AI » / `Ctrl+J` ouvraient l'IA de page → `askAIBlock(idx)`
- [x] **Fix « toolbar muette sous Alpine v3 »** — `ed.__x` (API v2) n'existe plus : `showFmt`/`hideCommentBtn`/mentions ne s'exécutaient jamais → accès via `_xed()` (`_x_dataStack[0]`) ; ancre de sélection élément gérée ; `checkMd()` recevait un id au lieu d'un index (exception à chaque espace)
- [x] **Tests** — `tests/test_v762_selection_toolbar.py` (roundtrip `gtMd`/`mdEsc` réel sous node) + **e2e Playwright** `e2e/v762_selection_toolbar.spec.js` (structure, sous-menus, géométrie, persistance bold/highlight après reload) ; `VERSION` + `app/main.py` = 7.62.0
### v7.63.0 — Menus mobiles façon Notion ✅ (2026-10-08)
> **Objectif** : parité des menus mobiles (Android sombre) — barre contextuelle au
> « / » + menu « Insert block » plein écran. **COMPLETED**.
- [x] **Barre horizontale défilante** sous le curseur (mobile ≤ 768 px) — 15 boutons
dans l'ordre Notion : `+▾`, `Turn into▾`, `@`, 💬, 🖼, 🗑, `←→↑↓`, `↩↪`, `Color▾`,
`More▾`, `⌨` ; `overflow-x:auto`, fermeture auto quand le `/` disparaît, taps
exclus du handler « clic hors-zone »
- [x] **Feuille plein écran « Insert block »** (`+`) — titre centré + « Cancel » bleu,
groupes CMDS avec raccourcis markdown (items partagés avec la popup desktop) ;
`Turn into` / `Color` réutilisent les générateurs extraits (`_blockTurnItems`,
`_blockColorItems`) ; `More` ouvre le menu de bloc existant
- [x] **`moveBlock(bid, dir)`** — ↑↓ ordre (racine + enfants toggle/columns),
→ indent dans le frère précédent, ← outdent, garde-fous toast
- [x] **Raccourcis slash** alignés sur les préfixes markdown (`-`, `[]`, `>`, `---`)
- [x] **Tests** — `tests/test_v763_mobile_menus.py` (+ `moveBlock` exécuté sous node),
e2e mobile `e2e/v763_mobile_menus.spec.js` (390×844) ; `VERSION` + `app/main.py`
= 7.63.0
### v7.64.0 — Commentaires & réactions façon Notion ✅ (2026-10-08)
> **Objectif** : affichage/prise de commentaire identique à Notion + réaction emoji
> sur le texte sélectionné. **COMPLETED**.
- [x] **Réaction sur texte** — bouton 🙂+ (« React to selected text ») dans la
toolbar de sélection → sélecteur d'émoji **existant** en mode réaction (en-tête =
texte sélectionné + ✋, onglets masqués, Recent au-dessus de People, Filter…,
catégories + `+` custom) ; plage jaune + pastille (emoji + compte), clic pastille
= rouvrir le sélecteur pour la plage
- [x] **Backend** — migration 37 `text_reactions` (UNIQUE plage × emoji × user) +
`GET/POST /api/pages/{id}/reactions` (GET groupé, POST toggle) ; `gtTok`/`gtMd`
ignorent les pastilles et `_srcOffset` exclut les annotations des offsets → le
texte sauvé est identique
- [x] **Ancres jaunes** — `_paintAnchors()` après chaque `render()`/chargement ;
clic sur l'ancre → tiroir ouvert + fil centré (flash) ; résolu = gris
- [x] **Tiroir refondu** — carte de fil (avatar, nom, horodatage, ✓ toggle
resolve/reopen, ⋯ → Copy link `#cmt-<id>` / Delete), saisie « Add a comment... »
avec `@` et envoi `↑`/Entrée ; `resolveComment(c)` corrige l'impossibilité de
rouvrir
- [x] **Tests** — `tests/test_v764_comments_reactions.py` (8, dont toggle API réel),
e2e `e2e/v764_comments_reactions.spec.js` ; `VERSION` + `app/main.py` = 7.64.0
- [x] **v7.64.1 — Fix Delete sur bloc vide** : la garde « texte vide → supprimer
le bloc » couvre `Backspace` **et** `Delete` (une seule branche dans `onKd`),
vide jugé via `gtTok()` (ignore les pastilles) ; e2e
`e2e/v7641_delete_key.spec.js`
### v7.65.0 — Comment ancré sur la sélection de texte ✅ (2026-10-08)
> **Objectif** : le commentaire porte sur le texte sélectionné dans le bloc,
> pas sur le bloc entier, et le bouton bleu flottant disparaît. **COMPLETED**.
- [x] **Ancrage sur la plage** — `commentBlock()` prend la sélection vivante dans
le bloc (repli : la plage mémorisée par la toolbar, qui survit au clic du
bouton), sinon tout le bloc ; offsets calculés par `_srcOffset()` →
`anchor_start/anchor_end` exacts dans le texte source
- [x] **Bouton bleu « 💬 Comment » retiré** — `#_commentSelBtn`,
`showCommentBtn()`, `hideCommentBtn()` et leur listener `mousedown` supprimés ;
déclencheurs restants = 💬 de la toolbar de sélection, menu de bloc,
Ctrl+⇧+M
- [x] **Tiroir** — fermer remet `_commentSel` à zéro (pas d'ancrage périmé)
- [x] **Tests** — e2e `e2e/v764_comments_reactions.spec.js` : sous-sélection
`reaction a un` → `.fd-anchor[data-cid]` = la plage seule, avant/après
reload, `#_commentSelBtn` absent ; `pytest` 1383 passed, `ruff` OK ;
`VERSION` + `app/main.py` = 7.65.0
---
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
+1 -1
View File
@@ -1 +1 @@
7.61.0
7.65.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.65.0 (comment ancré sur la sélection de texte, bouton bleu retiré) | **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.61.0",
version="7.65.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+26
View File
@@ -1680,6 +1680,32 @@ def _migration_agent_memory(conn: sqlite3.Connection) -> None:
)
@register(37, "v7.64.0: réactions emoji sur un texte sélectionné")
def _migration_text_reactions(conn: sqlite3.Connection) -> None:
"""Réactions emoji ancrées sur une plage de texte (façon Notion) : miroir des
ancres de commentaires (block_id + offsets dans le texte rendu). Une ligne par
(plage, emoji, utilisateur) → le POST agit comme un toggle et le GET regroupe
par plage pour afficher la pastille d'émojis."""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS text_reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
block_id TEXT NOT NULL,
anchor_start INTEGER NOT NULL,
anchor_end INTEGER NOT NULL,
emoji TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(page_id, block_id, anchor_start, anchor_end, emoji, user_id)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_text_reactions_page ON text_reactions(page_id)"
)
@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
+64
View File
@@ -206,3 +206,67 @@ def delete_comment(request: Request, comment_id: int):
conn.execute("DELETE FROM comments WHERE id=? OR parent_id=?", (comment_id, comment_id))
conn.commit()
return {"id": comment_id, "status": "deleted"}
# ── v7.64.0 : réactions emoji sur un texte sélectionné ──────────────────────
@router.get("/pages/{page_id}/reactions")
def list_reactions(request: Request, page_id: int):
"""Réactions regroupées par plage de texte : {block_id, start, end, emoji, count, mine}."""
user = _current_user(request)
with get_conn() as conn:
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
raise HTTPException(404, "Page not found")
rows = conn.execute(
"""SELECT block_id, anchor_start, anchor_end, emoji,
COUNT(*) AS count,
MAX(CASE WHEN user_id=? THEN 1 ELSE 0 END) AS mine
FROM text_reactions
WHERE page_id=?
GROUP BY block_id, anchor_start, anchor_end, emoji
ORDER BY MIN(id)""",
(user["id"], page_id),
).fetchall()
return {"page_id": page_id, "reactions": [dict(r) for r in rows]}
@router.post("/pages/{page_id}/reactions")
def toggle_reaction(request: Request, page_id: int, body: dict = Body(default={})):
"""Ajoute ou retire la réaction de l'utilisateur sur une plage de texte (toggle)."""
user = _current_user(request)
block = (body.get("block_id") or "").strip()
emoji = (body.get("emoji") or "").strip()
try:
start = int(body.get("anchor_start"))
end = int(body.get("anchor_end"))
except (TypeError, ValueError):
raise HTTPException(400, "anchor offsets required") from None
if not block or not emoji or start < 0 or end <= start:
raise HTTPException(400, "block_id, emoji and a non-empty range are required")
uid = user["id"]
with get_conn() as conn:
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
raise HTTPException(404, "Page not found")
conn.execute(
"INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
(uid, user.get("login", "admin"), user.get("full_name", "Admin")),
)
existing = conn.execute(
"""SELECT id FROM text_reactions
WHERE page_id=? AND block_id=? AND anchor_start=? AND anchor_end=?
AND emoji=? AND user_id=?""",
(page_id, block, start, end, emoji, uid),
).fetchone()
if existing:
conn.execute("DELETE FROM text_reactions WHERE id=?", (existing["id"],))
status = "removed"
else:
conn.execute(
"""INSERT INTO text_reactions
(page_id, block_id, anchor_start, anchor_end, emoji, user_id)
VALUES (?,?,?,?,?,?)""",
(page_id, block, start, end, emoji, uid),
)
status = "added"
conn.commit()
return {"status": status, "emoji": emoji}
+17 -1
View File
@@ -16,6 +16,9 @@
<style>
.fd-icon-picker{position:fixed;top:0;left:0;width:344px;background:var(--bg-modal);border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,.28);z-index:2200;display:flex;flex-direction:column;max-height:440px;overflow:hidden;padding:0;}
.fd-icon-picker .fdip-title{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--border);}
.fd-icon-picker .fdip-title-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:600;color:var(--text-primary);}
.fd-icon-picker .fdip-title-grip{opacity:.6;font-size:14px;}
.fd-icon-picker .fdip-tabs{display:flex;align-items:center;gap:2px;padding:8px 10px 6px;border-bottom:1px solid var(--border);}
.fd-icon-picker .fdip-tab{background:transparent;border:none;color:var(--text-secondary);font-size:13px;padding:5px 10px;border-radius:6px;cursor:pointer;}
.fd-icon-picker .fdip-tab:hover{background:var(--bg-hover);}
@@ -70,7 +73,11 @@
@click.outside="$store.fdIconPicker.customModal || $store.fdIconPicker.close()"
@keydown.escape.window="$store.fdIconPicker.close()"
:style="'top:' + $store.fdIconPicker.y + 'px;left:' + $store.fdIconPicker.x + 'px'">
<div class="fdip-tabs">
<div class="fdip-title" x-show="$store.fdIconPicker.title" x-cloak>
<span class="fdip-title-text" x-text="$store.fdIconPicker.title"></span>
<span class="fdip-title-grip" title="Drag">✋</span>
</div>
<div class="fdip-tabs" x-show="!$store.fdIconPicker.title">
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'emoji' }" @click="$store.fdIconPicker.setTab('emoji')">Emoji</button>
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'icons' }" @click="$store.fdIconPicker.setTab('icons')">Icons</button>
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'upload' }" @click="$store.fdIconPicker.setTab('upload')">Upload</button>
@@ -93,6 +100,15 @@
<div class="fdip-body">
<template x-if="$store.fdIconPicker.tab === 'emoji'">
<div>
<!-- v7.64 : mode réaction — les récents au-dessus de la grille (façon Notion) -->
<div x-show="$store.fdIconPicker.title && $store.fdIconPicker.category !== 'recent' && !$store.fdIconPicker.query && $store.fdIconPicker.recent.length">
<div class="fdip-section">Recent</div>
<div class="fdip-grid">
<template x-for="e in $store.fdIconPicker.recent.slice(0, 8)" :key="'rt' + e">
<button class="fdip-emoji" @click="$store.fdIconPicker.pick($store.fdIconPicker.withTone(e))" x-text="$store.fdIconPicker.withTone(e)"></button>
</template>
</div>
</div>
<div class="fdip-section" x-show="$store.fdIconPicker.category === 'recent' && !$store.fdIconPicker.query">Recent</div>
<div class="fdip-grid">
<template x-for="e in $store.fdIconPicker.items()" :key="e">
+81 -38
View File
@@ -578,24 +578,15 @@
background:var(--bg-modal,#1f1f1f);border:1px solid var(--border,#333);border-radius:10px;
box-shadow:var(--shadow-modal);padding:6px;"></div>
<!-- ═══════════ Inline comment trigger on selection (v4.9.0) ═══════════ -->
<button type="button" id="_commentSelBtn"
style="display:none;position:fixed;z-index:1100;padding:7px 12px;font-size:13px;font-weight:600;
color:#fff;background:var(--accent,#2383E2);border:none;border-radius:8px;cursor:pointer;
box-shadow:0 4px 16px rgba(0,0,0,.35);" title="Comment on selection"
@click="edCall('commentOnSelection')">
💬 Comment
</button>
<!-- ═══════════ Comments drawer (v4.9.0) ═══════════ -->
<!-- ═══════════ Comments drawer (v4.9.0 · refonte v7.64 façon Notion) ═══════════ -->
<div class="comments-drawer" x-show="commentsOpen" x-cloak x-transition
style="position:fixed;top:var(--topbar-height,44px);right:0;bottom:0;width:320px;max-width:92vw;
style="position:fixed;top:var(--topbar-height,44px);right:0;bottom:0;width:340px;max-width:92vw;
background:var(--bg-primary,#1c1c1c);border-left:1px solid var(--border,#333);
box-shadow:-8px 0 30px rgba(0,0,0,.25);z-index:900;display:flex;flex-direction:column;">
<div class="comments-drawer-header"
style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;
border-bottom:1px solid var(--border,#333);font-weight:600;font-size:14px;">
<span>Comments</span>
<span>Comments <span style="color:var(--text-dim);font-weight:400;" x-text="'(' + commentCount + ')'"></span></span>
<button type="button" class="topbar-btn" @click="toggleComments()" title="Close">✕</button>
</div>
<div class="comments-drawer-list" style="flex:1;overflow-y:auto;padding:12px 16px;">
@@ -603,49 +594,101 @@
No comments yet. Select some text and click 💬 Comment.
</div>
<template x-for="c in comments" :key="c.id">
<div class="comment-thread" style="margin-bottom:18px;"
:style="c.anchor_block_id ? 'border-left:3px solid var(--accent);padding-left:10px;' : ''">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
<div style="width:22px;height:22px;border-radius:50%;background:#3A3A3A;color:#fff;
display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;"
<div class="comment-thread" :data-thread="c.id"
:class="{ 'is-resolved': c.resolved, 'has-anchor': !!c.anchor_block_id }">
<div class="ct-head">
<div class="ct-avatar"
:style="{ background: c.author && c.author.avatar_color ? c.author.avatar_color : '#3A3A3A' }">
<span x-text="(c.author ? (c.author.full_name || c.author.login || '?') : '?').charAt(0).toUpperCase()"></span>
</div>
<span style="font-size:12px;font-weight:600;" x-text="c.author ? (c.author.full_name || c.author.login) : 'Unknown'"></span>
<span style="font-size:11px;color:var(--text-dim,#999);margin-left:auto;" x-text="fmtTime(c.created_at)"></span>
</div>
<div style="font-size:13px;white-space:pre-wrap;margin-bottom:6px;" x-text="c.body"></div>
<div style="display:flex;gap:8px;align-items:center;">
<button class="btn-sm" style="font-size:11px;" @click="resolveComment(c.id)"
x-text="c.resolved ? '↪ Reopen' : '✔ Resolve'"></button>
<button class="btn-sm" style="font-size:11px;" x-show="c.user_id === currentUserId"
@click="deleteComment(c.id)">🗑 Delete</button>
<span class="ct-name" x-text="c.author ? (c.author.full_name || c.author.login) : 'Unknown'"></span>
<span class="ct-time" x-text="fmtTime(c.created_at)"></span>
<div class="ct-actions">
<button type="button" class="ct-act" :title="c.resolved ? 'Reopen' : 'Resolve'"
@click="resolveComment(c)"
:class="{ 'is-on': c.resolved }">✓</button>
<button type="button" class="ct-act" title="More"
x-show="c.user_id === currentUserId"
@click.stop="commentMenu = commentMenu === c.id ? null : c.id">⋯</button>
</div>
<div class="ct-menu" x-cloak x-show="commentMenu === c.id" @click.outside="commentMenu = null">
<button type="button" class="ct-menu-item" @click="copyCommentLink(c.id)">🔗 Copy link</button>
<button type="button" class="ct-menu-item is-danger" @click="deleteComment(c.id)">🗑 Delete</button>
</div>
</div>
<div class="ct-body" x-text="c.body"></div>
<div class="ct-anchor-label" x-show="!!c.anchor_block_id && !c.resolved">💬 on selected text</div>
</div>
</template>
</div>
<div class="comments-drawer-input" style="border-top:1px solid var(--border,#333);padding:12px 16px;">
<textarea x-model="commentDraft" rows="2"
placeholder="Add a comment... Use @ to mention someone."
style="width:100%;background:var(--bg-secondary,#2a2a2a);border:1px solid var(--border,#333);
border-radius:8px;color:var(--text);font-size:13px;padding:8px 10px;resize:none;outline:none;"></textarea>
<div style="display:flex;justify-content:flex-end;margin-top:8px;">
<button class="btn btn-primary" style="font-size:12px;padding:6px 14px;" @click="addPageComment()">Comment</button>
<div class="comments-drawer-input">
<div class="ct-input-row">
<textarea x-model="commentDraft" rows="1"
placeholder="Add a comment..."
@keydown.enter.exact.prevent="addPageComment()"></textarea>
<div class="ct-input-actions">
<button type="button" class="ct-act" title="Mention" @click="commentAt()">@</button>
<button type="button" class="ct-send" title="Send"
:disabled="!(commentDraft || '').trim()" @click="addPageComment()">↑</button>
</div>
</div>
</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-react" @click="fmtReact()" data-tooltip="React to selected text"><span class="ft-react-ico">🙂<span class="ft-react-plus">+</span></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 ═══════════ -->
+93 -2
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.61.0"
"version": "7.65.0"
},
"paths": {
"/auth/register": {
@@ -15368,6 +15368,97 @@
}
}
},
"/api/pages/{page_id}/reactions": {
"get": {
"tags": [
"collaboration"
],
"summary": "List Reactions",
"description": "Réactions regroupées par plage de texte : {block_id, start, end, emoji, count, mine}.",
"operationId": "list_reactions_api_pages__page_id__reactions_get",
"parameters": [
{
"name": "page_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"title": "Page Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"post": {
"tags": [
"collaboration"
],
"summary": "Toggle Reaction",
"description": "Ajoute ou retire la réaction de l'utilisateur sur une plage de texte (toggle).",
"operationId": "toggle_reaction_api_pages__page_id__reactions_post",
"parameters": [
{
"name": "page_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"title": "Page Id"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/custom-emojis": {
"get": {
"tags": [
@@ -30444,4 +30535,4 @@
}
}
}
}
}
+127
View File
@@ -0,0 +1,127 @@
const { test, expect } = require('@playwright/test');
/**
* v7.62 — menu contextuel au highlight de texte (toolbar façon Notion).
* Vérifie : les 4 lignes + Skills + pied, le menu « … », le sous-menu
* « Turn into », et surtout la PERSISTANCE du formatage après reload
* (sync() relit le DOM formaté via gtMd → source markdown).
* Instance de test attendue sur FD_BASE_URL (défaut 8081).
*/
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
test.use({ serviceWorkers: 'block' });
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page
.waitForURL('**/workspaces', { timeout: 8000 })
.then(() => true)
.catch(() => false);
if (!ok) {
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
data: { email: USER, password: PASS, name: 'E2E' },
});
if (resp.status() !== 409) expect(resp.ok()).toBeTruthy();
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
}
}
// Sélectionne tout le bloc et simule la fin du glisser-souris
async function selectBlock(page) {
await page.evaluate(() => {
const el = document.querySelector('#_blocksCt [data-bid]');
const r = document.createRange();
r.selectNodeContents(el);
const s = getSelection();
s.removeAllRanges();
s.addRange(r);
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
});
await expect(page.locator('.format-toolbar')).toBeVisible({ timeout: 5000 });
}
test('toolbar de sélection : structure, sous-menus, persistance', async ({ page }) => {
await login(page);
const cookies = await page.context().cookies();
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
const created = await page.request.post(
`${FD_BASE}/board/api/pages?title=Menu highlight ${Date.now()}`,
{ headers: { 'X-CSRF-Token': csrf } }
);
expect(created.status(), await created.text()).toBeLessThan(400);
const { id } = await created.json();
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
await page.click('#_blocksCt [data-bid]');
await page.keyboard.type('Menu lors d un highlight de texte');
// ── 1. structure : 4 lignes + Skills + pied ──
await selectBlock(page);
const bar = page.locator('.format-toolbar');
await expect(bar.locator('.ft-row')).toHaveCount(4);
await expect(bar.locator('.ft-style')).toContainText('Normal Text');
await expect(bar.locator('.ft-group')).toHaveText('Skills');
await expect(bar.locator('.ft-skill')).toHaveCount(4);
await expect(bar.locator('.ft-skill').first()).toHaveText('Improve writing');
await expect(bar.locator('.ft-foot')).toContainText('Edit with AI');
// géométrie : sous la sélection, entièrement dans le viewport
const barBox = await bar.boundingBox();
const selBox = await page.evaluate(() => {
const r = getSelection().getRangeAt(0).getBoundingClientRect();
return { bottom: r.bottom };
});
expect(barBox.y).toBeGreaterThanOrEqual(selBox.bottom);
expect(barBox.y + barBox.height).toBeLessThanOrEqual(900);
expect(barBox.x).toBeGreaterThanOrEqual(0);
expect(barBox.x + barBox.width).toBeLessThanOrEqual(1440);
await page.screenshot({ path: 'shots/v762-toolbar.png' });
// ── 2. ligne 1 → sous-menu « Turn into » ──
await bar.locator('.ft-style').click();
const sub = page.locator('#blockMenuSub');
await expect(sub).toBeVisible();
await expect(sub).toContainText('Heading 4');
await expect(sub.locator('.bm-check')).toHaveCount(1); // type courant coché
await page.screenshot({ path: 'shots/v762-turn-submenu.png' });
await page.keyboard.press('Escape');
// ── 3. « … » → menu contextuel de bloc (recherche + pied) ──
await selectBlock(page);
await bar.locator('.ft-more').click();
const menu = page.locator('#blockMenu');
await expect(menu).toBeVisible();
await expect(menu.locator('.bm-search')).toHaveAttribute('placeholder', 'Search actions...');
await expect(menu).toContainText('Copy link to block');
await expect(menu.locator('.bm-foot')).toContainText('Last edited by');
await page.screenshot({ path: 'shots/v762-more-menu.png' });
await page.keyboard.press('Escape');
await expect(menu).toBeHidden();
// ── 4. persistance : B (gras) survit au reload ──
await selectBlock(page);
await page.getByRole('button', { name: 'B', exact: true }).click();
await expect(page.locator('#_blocksCt [data-bid] b, #_blocksCt [data-bid] strong')).toHaveCount(1);
await page.waitForTimeout(2500); // autosave 1,5 s
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
await expect(page.locator('#_blocksCt [data-bid] strong')).toHaveCount(1); // rendu mdEsc
// ── 5. persistance : S (surlignage) survit au reload ──
await selectBlock(page);
await bar.locator('.ft-hl').click();
await expect(page.locator('#_blocksCt [data-bid] mark')).toHaveCount(1);
await page.waitForTimeout(2500);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
await expect(page.locator('#_blocksCt [data-bid] mark')).toHaveCount(1);
// ── 6. fermeture hors sélection ──
await page.mouse.click(10, 400);
await expect(page.locator('.format-toolbar')).toBeHidden();
});
+125
View File
@@ -0,0 +1,125 @@
const { test, expect } = require('@playwright/test');
/**
* v7.63 — menus mobiles façon Notion (viewport 390×844) :
* - « / » → barre horizontale défilante sous le curseur (15 boutons, ordre du doc)
* - « + » → feuille plein écran « Insert block » (titre centré + Cancel, groupes,
* raccourcis) → choix d'un type = applySlash
* - « Turn into » / « Color » → feuilles réutilisant les sous-menus de bloc
* - « More » → menu contextuel de bloc (desktop)
* Instance de test attendue sur FD_BASE_URL (défaut 8081).
*/
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
test.use({ serviceWorkers: 'block', viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page
.waitForURL('**/workspaces', { timeout: 8000 })
.then(() => true)
.catch(() => false);
if (!ok) {
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
data: { email: USER, password: PASS, name: 'E2E' },
});
if (resp.status() !== 409) expect(resp.ok()).toBeTruthy();
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
}
}
async function newPage(page) {
const cookies = await page.context().cookies();
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
const created = await page.request.post(
`${FD_BASE}/board/api/pages?title=Mobile menus ${Date.now()}`,
{ headers: { 'X-CSRF-Token': csrf } }
);
expect(created.status(), await created.text()).toBeLessThan(400);
const { id } = await created.json();
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
return id;
}
test('barre mobile au / + feuilles Insert block / Turn into / More', async ({ page }) => {
await login(page);
await newPage(page);
await page.click('#_blocksCt [data-bid]');
await page.keyboard.type('/');
// ── 1. la barre apparaît, 15 boutons dans l'ordre de la description ──
const bar = page.locator('#fdMobileBar');
await expect(bar).toBeVisible({ timeout: 5000 });
const keys = await page.evaluate(() =>
Array.from(document.querySelectorAll('#fdMobileBar [data-mtb]')).map((b) => b.dataset.mtb));
expect(keys).toEqual(['plus', 'turn', 'at', 'comment', 'image', 'trash',
'left', 'right', 'up', 'down', 'undo', 'redo', 'color', 'more', 'kbd']);
// défilement latéral
const overflowX = await bar.evaluate((el) => getComputedStyle(el).overflowX);
expect(overflowX).toBe('auto');
// pas de popup desktop en parallèle
await expect(page.locator('#_slashMenu')).toBeHidden();
await page.screenshot({ path: 'shots/v763-bar.png' });
// ── 2. « + » → feuille plein écran « Insert block » ──
await bar.locator('[data-mtb="plus"]').click();
const sheet = page.locator('.fd-msheet');
await expect(sheet).toBeVisible();
await expect(sheet.locator('.fd-msheet-title')).toHaveText('Insert block');
await expect(sheet.locator('.fd-msheet-cancel')).toHaveText('Cancel');
await expect(sheet.locator('.sm-group').first()).toHaveText('Basic blocks');
await expect(sheet.locator('.slash-item', { hasText: 'Heading 1' })).toHaveCount(1);
await page.screenshot({ path: 'shots/v763-insert.png' });
// ── 3. choix d'un type → applySlash (le bloc devient un titre) ──
await sheet.locator('.slash-item', { hasText: 'Heading 1' }).click();
await expect(sheet).toHaveCount(0);
await expect(bar).toBeHidden();
expect(await page.evaluate(() => window.E.blocks[0].type)).toBe('heading_1');
// ── 4. « Turn into » → feuille réutilisant les items du sous-menu de bloc ──
await page.keyboard.type('/');
await expect(bar).toBeVisible();
await bar.locator('[data-mtb="turn"]').click();
await expect(sheet.locator('.fd-msheet-title')).toHaveText('Turn into');
await expect(sheet.locator('.bm-item', { hasText: 'Bulleted list' })).toHaveCount(1);
await expect(sheet.locator('.bm-check')).toHaveCount(1); // type courant coché
await page.screenshot({ path: 'shots/v763-turn.png' });
await sheet.locator('.fd-msheet-cancel').click();
await expect(sheet).toHaveCount(0);
expect(await page.evaluate(() => window.E.blocks[0].type)).toBe('heading_1');
// ── 5. « Color » → feuille avec les 3 groupes ──
await page.evaluate(() => localStorage.setItem('fdLastBlockColor', 'color|#E5484D'));
await bar.locator('[data-mtb="color"]').click();
for (const g of ['Last used', 'Text color', 'Background color']) {
await expect(sheet.locator('.bm-group', { hasText: g })).toHaveCount(1);
}
await sheet.locator('.fd-msheet-cancel').click();
// ── 6. « More » → menu contextuel de bloc (recherche + pied) ──
await bar.locator('[data-mtb="more"]').click();
const menu = page.locator('#blockMenu');
await expect(menu).toBeVisible();
await expect(menu.locator('.bm-search')).toHaveAttribute('placeholder', 'Search actions...');
await expect(menu.locator('.bm-foot')).toContainText('Last edited by');
await page.keyboard.press('Escape');
await expect(menu).toBeHidden();
// ── 7. ↑ déplace réellement le bloc ── (bloc 0 = titre, bloc 1 = paragraphe)
await page.click('#_blocksCt [data-bid]'); // bloc 0 (contient '/')
await page.keyboard.press('Enter');
await page.waitForTimeout(150); // le focus du nouveau bloc est différé (60 ms)
await page.keyboard.type('/'); // bloc neuf et vide → slash exact = ouverture
await expect(bar).toBeVisible(); // SM._i = 1 (bloc du bas)
await bar.locator('[data-mtb="up"]').click();
expect(await page.evaluate(() => window.E.blocks.map((b) => b.type).join(',')))
.toBe('paragraph,heading_1');
await page.screenshot({ path: 'shots/v763-moved.png' });
});
+80
View File
@@ -0,0 +1,80 @@
const { test, expect } = require('@playwright/test');
/**
* v7.64.1 — Delete sur un bloc vide supprime le bloc (comme Backspace).
* 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' });
}
}
test('Delete : vide le bloc puis supprime le bloc vide', 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=Delete key ${Date.now()}`,
{ headers: { 'X-CSRF-Token': csrf } }
);
expect(created.status(), await created.text()).toBeLessThan(400);
const { id } = await created.json();
const bs = await page.request.post(`${FD_BASE}/board/api/pages/${id}/blocks`, {
headers: { 'X-CSRF-Token': csrf },
data: { title: 'Delete key', blocks: [
{ id: 'b1', type: 'paragraph', content: 'hello' },
{ id: 'b2', type: 'paragraph', content: 'world' },
] },
});
expect(bs.status(), await bs.text()).toBeLessThan(400);
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
// ── 1. Delete avec du texte : suppression avant le curseur (comportement natif) ──
await page.locator('#_blocksCt [data-bid]').nth(1).click();
await page.keyboard.press('Control+a');
await page.keyboard.press('Delete');
await page.waitForTimeout(200);
const contents = await page.evaluate(() => {
window.E.sync();
return window.E.blocks.map((b) => b.content);
});
expect(contents[0]).toBe('hello');
expect(contents[1].trim()).toBe(''); // bloc vidé (un <br> résiduel), mais toujours là
// ── 2. Delete sur le bloc vide : le bloc est supprimé ──
await page.keyboard.press('Delete');
await page.waitForTimeout(300);
expect(await page.evaluate(() => window.E.blocks.length)).toBe(1);
expect(await page.evaluate(() => window.E.blocks[0].content)).toBe('hello');
await page.screenshot({ path: 'shots/v7641-delete.png' });
// ── 3. dernier bloc vide : jamais supprimé (l'éditeur garde un bloc) ──
await page.locator('#_blocksCt [data-bid]').first().click();
await page.keyboard.press('Control+a');
await page.keyboard.press('Delete');
await page.waitForTimeout(150);
await page.keyboard.press('Delete');
await page.waitForTimeout(300);
expect(await page.evaluate(() => window.E.blocks.length)).toBe(1);
expect(await page.evaluate(() => window.E.blocks[0].type)).toBe('paragraph');
});
+171
View File
@@ -0,0 +1,171 @@
const { test, expect } = require('@playwright/test');
/**
* v7.64 — commentaires & réactions façon Notion :
* - sélection → bouton 🙂+ (« React to selected text ») → sélecteur d'émoji avec
* le texte sélectionné en en-tête → pastille + plage jaune, contenu inchangé
* - sélection → 💬 → tiroir refondu (avatar, nom, ✓, ⋯, saisie avec @ et ↑) →
* fil ancré + plage jaune, clic sur l'ancre rouvre le fil, ✓ résout
* - reload : ancres et pastilles repeintes depuis la base
* 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' });
}
}
async function newPage(page, body) {
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=Comments ${Date.now()}`,
{ headers: { 'X-CSRF-Token': csrf } }
);
expect(created.status(), await created.text()).toBeLessThan(400);
const { id } = await created.json();
if (body) {
const bs = await page.request.post(`${FD_BASE}/board/api/pages/${id}/blocks`, {
headers: { 'X-CSRF-Token': csrf },
data: { title: 'Comments', blocks: body },
});
expect(bs.status(), await bs.text()).toBeLessThan(400);
}
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
return id;
}
// sélectionne une SOUS-chaîne du bloc (ancre = plage, pas le bloc entier)
async function selectSub(page, sub) {
await page.evaluate((sub) => {
const el = document.querySelector('#_blocksCt [data-bid]');
const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
let n;
while ((n = w.nextNode())) {
if (n.parentElement && n.parentElement.closest('.fd-react-chip')) continue;
const i = n.nodeValue.indexOf(sub);
if (i < 0) continue;
const r = document.createRange();
r.setStart(n, i);
r.setEnd(n, i + sub.length);
const s = getSelection();
s.removeAllRanges();
s.addRange(r);
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
return;
}
throw new Error('sous-chaîne introuvable : ' + sub);
}, sub);
await expect(page.locator('.format-toolbar')).toBeVisible({ timeout: 5000 });
}
// 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('réaction emoji + commentaires ancrés', async ({ page }) => {
await login(page);
await newPage(page, [{ id: 'b1', type: 'paragraph', content: 'Prise de reaction a un texte' }]);
await expect(page.locator('#_blocksCt [data-bid]')).toHaveText('Prise de reaction a un texte');
// ── 1. bouton réaction dans la toolbar ──
await selectBlock(page);
const react = page.locator('.format-toolbar .ft-react');
await expect(react).toHaveAttribute('data-tooltip', 'React to selected text');
await page.screenshot({ path: 'shots/v764-toolbar-react.png' });
await react.click();
// ── 2. sélecteur : en-tête = texte sélectionné, Filter…, Recent/People ──
const picker = page.locator('.fd-icon-picker');
await expect(picker).toBeVisible();
await expect(picker.locator('.fdip-title-text')).toHaveText('Prise de reaction a un texte');
await expect(picker.locator('.fdip-search')).toHaveAttribute('placeholder', 'Filter...');
await expect(picker.locator('.fdip-tabs')).toBeHidden(); // mode réaction : emoji only
await page.screenshot({ path: 'shots/v764-picker.png' });
await picker.locator('.fdip-emoji').first().click();
await expect(picker).toBeHidden();
// ── 3. pastille + plage jaune, contenu du bloc inchangé ──
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1);
await expect(page.locator('#_blocksCt .fd-anchor-react')).toHaveCount(1);
const content = await page.evaluate(() => {
window.E.sync();
return window.E.blocks[0].content;
});
expect(content).toBe('Prise de reaction a un texte'); // la pastille n'est pas du contenu
await page.screenshot({ path: 'shots/v764-chip.png' });
// ── 4. commentaire sur une SOUS-sélection : toolbar 💬 → tiroir refondu → envoi ──
await expect(page.locator('#_commentSelBtn')).toHaveCount(0); // bouton bleu retiré
await selectSub(page, 'reaction a un');
await page.locator('.format-toolbar .ft-item[data-tooltip="Comment — Ctrl+⇧+M"]').click();
const drawer = page.locator('.comments-drawer');
await expect(drawer).toBeVisible();
await drawer.locator('textarea').fill('Ceci est mon premier commentaire');
await expect(drawer.locator('.ct-send')).toBeEnabled();
await drawer.locator('.ct-send').click();
const thread = page.locator('.comment-thread');
await expect(thread).toHaveCount(1);
await expect(thread.locator('.ct-avatar')).toHaveCount(1);
await expect(thread.locator('.ct-name')).not.toHaveText('');
await expect(thread.locator('.ct-act')).toHaveCount(2); // ✓ + ⋯
await expect(thread.locator('.ct-body')).toHaveText('Ceci est mon premier commentaire');
// l'ancre jaune = UNIQUEMENT la plage sélectionnée, pas le bloc
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveCount(1);
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveText('reaction a un');
await page.screenshot({ path: 'shots/v764-drawer.png' });
// ── 5. menu ⋯ du fil (Copy link / Delete) ──
await thread.locator('.ct-act').nth(1).click();
await expect(thread.locator('.ct-menu')).toBeVisible();
await expect(thread.locator('.ct-menu-item').first()).toContainText('Copy link');
await page.keyboard.press('Escape');
await page.locator('.comments-drawer-header .topbar-btn').click();
await expect(drawer).toBeHidden();
// ── 6. clic sur l'ancre jaune → le fil se rouvre ──
await page.locator('#_blocksCt .fd-anchor[data-cid]').click();
await expect(drawer).toBeVisible();
await expect(page.locator('.comment-thread')).toHaveCount(1);
// ── 7. ✓ résout le fil (ancre en gris) ──
await page.locator('.comment-thread .ct-act').first().click();
await expect(page.locator('.comment-thread.is-resolved')).toHaveCount(1);
await expect(page.locator('#_blocksCt .fd-anchor.fd-anchor-done')).toHaveCount(1);
// ── 8. reload : ancres + pastilles rechargées depuis la base ──
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveCount(1, { timeout: 10000 });
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveText('reaction a un');
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1, { timeout: 10000 });
});
+307 -9
View File
@@ -2915,35 +2915,333 @@ button.breadcrumb-current { max-width: 260px; }
font-size: 14px;
}
/* FORMAT TOOLBAR (FLOATING) */
/* FORMAT TOOLBAR (FLOATING) — menu contextuel au highlight (v7.62, façon Notion) */
.format-toolbar {
position: fixed;
z-index: 998;
display: flex;
align-items: center;
gap: 2px;
flex-direction: column;
align-items: stretch;
gap: 1px;
min-width: 216px;
max-height: calc(100vh - 16px);
overflow-y: auto;
background: var(--bg-modal);
border: 1px solid var(--border-light);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
padding: 4px 6px;
padding: 6px;
}
.format-toolbar button {
width: 30px;
.format-toolbar .ft-row {
display: flex;
align-items: center;
gap: 2px;
}
.format-toolbar .ft-item {
height: 28px;
min-width: 28px;
padding: 0 7px;
border: none;
background: transparent;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
color: var(--text-primary);
font-size: 14px;
font-size: 13px;
cursor: pointer;
transition: background 0.1s;
}
.format-toolbar button:hover {
.format-toolbar .ft-item:hover {
background: var(--bg-hover);
}
.format-toolbar .ft-item svg {
display: block;
}
/* Ligne 1 : style de texte (libellé du type de bloc + sous-menu) */
.format-toolbar .ft-style {
width: 100%;
justify-content: flex-start;
}
.format-toolbar .ft-style-ico {
font-weight: 600;
color: var(--text-secondary);
}
.format-toolbar .ft-style-name {
flex: 1;
text-align: left;
}
.format-toolbar .ft-caret {
color: var(--text-dim);
font-size: 15px;
line-height: 1;
}
/* « A » : couleur du texte (barre colorée sous la lettre) */
.format-toolbar .ft-a-letter {
position: relative;
font-weight: 600;
}
.format-toolbar .ft-a-letter::after {
content: '';
position: absolute;
left: 1px;
right: 1px;
bottom: -4px;
height: 3px;
border-radius: 2px;
background: var(--accent);
}
.format-toolbar .ft-tx,
.format-toolbar .ft-code-ico {
font-size: 12px;
font-family: var(--font-mono, monospace);
}
.format-toolbar .ft-hl {
border-radius: 2px;
padding: 0 3px;
background: rgba(255,197,61,.28);
}
.format-toolbar .ft-more {
font-size: 16px;
letter-spacing: 1px;
}
/* Séparateurs, groupe « Skills », pied de page */
.format-toolbar .ft-sep {
height: 1px;
background: var(--border);
margin: 5px 2px;
}
.format-toolbar .ft-group {
font-size: 11px;
color: var(--text-dim);
padding: 3px 7px 2px;
cursor: default;
}
.format-toolbar .ft-skill {
height: 26px;
justify-content: flex-start;
font-size: 13px;
color: var(--text-secondary);
}
.format-toolbar .ft-foot {
height: 26px;
width: 100%;
justify-content: space-between;
font-size: 12px;
color: var(--text-secondary);
}
.format-toolbar .ft-foot kbd {
font-family: inherit;
font-size: 11px;
color: var(--text-dim);
}
/* Info-bulle des éléments du menu de sélection : bulle claire, à droite */
.tooltip.tooltip-light {
background: #ffffff;
border-color: #e3e3e3;
color: #191919;
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
white-space: normal;
max-width: 240px;
line-height: 1.35;
}
.tooltip.tooltip-light em {
color: #6b6b6b;
}
/* ══ v7.63 — Menus mobiles façon Notion : barre horizontale au « / » ══
(défilement latéral) + feuille plein écran Insert block / Turn into / Color ══ */
.fd-mtb {
position: fixed;
z-index: 1300;
display: none;
gap: 2px;
padding: 5px 6px;
max-width: calc(100vw - 16px);
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
background: var(--bg-modal);
border: 1px solid var(--border-light);
border-radius: 10px;
box-shadow: 0 6px 20px rgba(0,0,0,.45);
}
.fd-mtb::-webkit-scrollbar { display: none; }
.fd-mtb .fd-mtb-item {
flex: 0 0 auto;
min-width: 38px;
height: 38px;
padding: 0 8px;
border: none;
background: transparent;
border-radius: 6px;
color: var(--text-primary);
font-size: 16px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
cursor: pointer;
}
.fd-mtb .fd-mtb-item:active { background: var(--bg-hover); }
.fd-mtb .fd-mtb-wide { font-size: 13px; font-weight: 600; padding: 0 10px; }
.fd-mtb .fd-mtb-caret { font-size: 10px; color: var(--text-dim); }
/* Feuille plein écran (titre centré + Cancel bleu à droite, liste qui défile) */
.fd-msheet {
position: fixed;
inset: 0;
z-index: 1500;
display: flex;
flex-direction: column;
background: var(--bg-secondary, #191919);
}
.fd-msheet-head {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
flex: 0 0 auto;
}
.fd-msheet-title { font-size: 15px; font-weight: 600; color: var(--text-primary); }
.fd-msheet-cancel {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--accent, #2383E2);
font-size: 14px;
font-weight: 600;
padding: 6px;
cursor: pointer;
}
/* la liste porte la classe block-menu (hérite des styles .bm-item/.bm-group)
mais doit couvrir tout l'écran, sans la chrome du menu flottant */
.fd-msheet-list.block-menu {
max-width: none;
min-width: 0;
background: none;
border: none;
border-radius: 0;
box-shadow: none;
padding: 6px 8px 40px;
flex: 1 1 auto;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.fd-msheet .bm-item { padding: 12px 10px; font-size: 15px; }
.fd-msheet .bm-group { padding: 14px 10px 4px; }
.fd-msheet .sm-group {
padding: 14px 10px 4px;
font-size: 11px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: .4px;
}
.fd-msheet .slash-item { padding: 12px 10px; font-size: 15px; }
.fd-msheet .slash-item:active { background: var(--bg-hover); }
/* ══ v7.64 — Commentaires & réactions façon Notion ══
Ancres jaunes (texte commenté), pastilles de réaction, tiroir refondu ══ */
.fd-anchor {
background: rgba(255, 213, 0, .32);
border-radius: 2px;
cursor: pointer;
}
.fd-anchor-done { background: rgba(255, 255, 255, .10); }
.fd-anchor-react { background: rgba(255, 213, 0, .28); }
.fd-react-chip {
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: 4px;
padding: 0 5px;
height: 19px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-secondary);
cursor: pointer;
vertical-align: middle;
font-size: 11px;
line-height: 1;
user-select: none;
}
.fd-react-chip:hover { background: var(--bg-hover); }
.fd-react-chip img { width: 12px; height: 12px; object-fit: contain; }
.fd-react-n { font-size: 10px; color: var(--text-dim); }
/* Tiroir de commentaires (fil + saisie) */
.comment-thread {
position: relative;
margin-bottom: 12px;
padding: 10px 12px;
background: var(--bg-secondary, #2a2a2a);
border: 1px solid var(--border, #333);
border-radius: 10px;
}
.comment-thread.has-anchor { border-left: 3px solid var(--accent, #2383E2); }
.comment-thread.is-resolved { opacity: .55; }
.thread-flash { outline: 2px solid var(--accent, #2383E2); outline-offset: 2px; }
.ct-head { display: flex; align-items: center; gap: 8px; position: relative; }
.ct-avatar {
width: 24px; height: 24px; border-radius: 50%; color: #fff; flex-shrink: 0;
display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700;
}
.ct-name { font-size: 12px; font-weight: 600; color: var(--text-primary); }
.ct-time { font-size: 11px; color: var(--text-dim); margin-left: auto; }
.ct-actions { display: flex; gap: 2px; margin-left: 2px; }
.ct-act {
width: 24px; height: 24px; border: none; background: transparent; border-radius: 5px;
color: var(--text-dim); font-size: 13px; line-height: 1; cursor: pointer;
}
.ct-act:hover { background: var(--bg-hover); color: var(--text-primary); }
.ct-act.is-on { color: var(--accent, #2383E2); }
.ct-menu {
position: absolute; right: 0; top: 28px; z-index: 6; min-width: 156px; padding: 4px;
background: var(--bg-modal); border: 1px solid var(--border); border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,.4);
}
.ct-menu-item {
display: block; width: 100%; text-align: left; padding: 7px 10px; font-size: 13px;
color: var(--text-primary); background: none; border: none; border-radius: 5px; cursor: pointer;
}
.ct-menu-item:hover { background: var(--bg-hover); }
.ct-menu-item.is-danger { color: #e5484d; }
.ct-body { font-size: 13px; white-space: pre-wrap; margin-top: 6px; color: var(--text-primary); }
.ct-anchor-label { font-size: 10px; color: var(--text-dim); margin-top: 6px; }
.comments-drawer-input { border-top: 1px solid var(--border, #333); padding: 12px 16px; }
.ct-input-row { display: flex; gap: 6px; align-items: flex-end; }
.ct-input-row textarea {
flex: 1; min-width: 0; background: var(--bg-secondary, #2a2a2a);
border: 1px solid var(--border, #333); border-radius: 8px; color: var(--text-primary);
font-size: 13px; font-family: inherit; line-height: 1.4; padding: 8px 10px; resize: none; outline: none;
}
.ct-input-row textarea:focus { border-color: var(--accent, #2383E2); }
.ct-input-actions { display: flex; gap: 4px; align-items: center; padding-bottom: 2px; }
.ct-send {
width: 30px; height: 30px; border-radius: 50%; background: var(--accent, #2383E2); color: #fff;
border: none; font-size: 15px; line-height: 1; cursor: pointer; flex-shrink: 0;
}
.ct-send:disabled { opacity: .4; cursor: default; }
/* Bouton « React to selected text » de la toolbar de sélection */
.ft-react-ico { position: relative; font-size: 15px; line-height: 1; }
.ft-react-plus {
position: absolute; right: -5px; top: -4px; font-size: 9px; font-weight: 700; color: var(--text-dim);
}
/* EDITOR STATUSBAR */
.editor-statusbar {
@@ -3643,7 +3941,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; }
}
/* ═══════════════════════════════════════════════════════════
+5 -1
View File
@@ -40,7 +40,7 @@
if (window.Alpine) window.Alpine.__fdIconPicker = true;
Alpine.store('fdIconPicker', {
open: false, x: 0, y: 0, tab: 'emoji', query: '', category: 'people',
open: false, x: 0, y: 0, tab: 'emoji', query: '', category: 'people', title: '',
skinTone: 0, toneOpen: false, recent: [], custom: [], customLoaded: false,
customModal: false, customName: '', customPreview: '', customFile: null, customBusy: false,
onPick: null, onRemove: null, _cx: 0, _cy: 0,
@@ -50,6 +50,9 @@
opts = opts || {};
this.onPick = opts.onPick || null;
this.onRemove = opts.onRemove || null;
// v7.64 : mode « réaction » — en-tête = texte sélectionné, emoji only
this.title = opts.title || '';
if (this.title) { this.tab = 'emoji'; this.category = 'people'; }
this.query = '';
this.toneOpen = false;
this.customModal = false;
@@ -78,6 +81,7 @@
close() {
this.open = false;
this.title = '';
this.customModal = false;
this.toneOpen = false;
this.onPick = null;
+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';
+484 -65
View File
@@ -36,7 +36,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
// [[fdpage:ID]] / [[fddate:YYYY-MM-DD]] tokens.
function gtTok(e){
if(!e) return '';
if(!e.querySelector||!e.querySelector('.fd-wiki-chip,.fd-wiki-date')) return gt(e);
if(!e.querySelector||!e.querySelector('.fd-wiki-chip,.fd-wiki-date,.fd-react-chip')) return gt(e); // le chemin rapide (innerText) inclurait les pastilles
let out='';
(function walk(node){
for(const n of node.childNodes){
@@ -44,6 +44,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
if(n.nodeType===1){
if(n.classList&&n.classList.contains('fd-wiki-chip')){out+=n.getAttribute('data-token')||'';continue;}
if(n.classList&&n.classList.contains('fd-wiki-date')){out+=n.getAttribute('data-token')||'';continue;}
if(n.classList&&n.classList.contains('fd-react-chip')){continue;} // annotation, pas du contenu
if(n.tagName==='BR'){out+='\n';continue;}
walk(n);
}
@@ -52,6 +53,121 @@ 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-react-chip')){continue;} // annotation, pas du contenu
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);
}
// v7.64 : marquage d'une plage commentée/réagie (jaune). Ne change pas le
// texte lu par sync() : les spans n'ajoutent que des nœuds d'annotation.
function _wrapAnchored(el,start,end,cls,attrs){
if(!(end>start))return null;
let pos=0,sN=null,sO=0,eN=null,eO=0;
const walker=document.createTreeWalker(el,NodeFilter.SHOW_TEXT);
let n=walker.nextNode();
while(n){
const len=n.nodeValue.length;
if(!sN&&pos+len>start){sN=n;sO=start-pos;}
if(sN&&pos+len>=end){eN=n;eO=end-pos;break;}
pos+=len;n=walker.nextNode();
}
if(!sN||!eN)return null;
const r=document.createRange();
try{r.setStart(sN,sO);r.setEnd(eN,eO);}catch(e){return null;}
const span=document.createElement('span');span.className=cls;
span.setAttribute('contenteditable','false');
if(attrs)for(const k in attrs)span.setAttribute('data-'+k,attrs[k]);
try{r.surroundContents(span);}catch(e){try{span.appendChild(r.extractContents());r.insertNode(span);}catch(e2){return null;}}
return span;
}
// Offsets dans le texte SOURCE d'un bloc : les pastilles .fd-react-chip
// (annotations) ne comptent pas, comme dans gtTok/gtMd.
function _srcNodeLen(node){
if(!node)return 0;
if(node.nodeType===3){
const pe=node.parentElement;
if(pe&&pe.closest&&pe.closest('.fd-react-chip'))return 0;
return node.nodeValue.length;
}
if(node.nodeType!==1)return 0;
if(node.classList&&node.classList.contains('fd-react-chip'))return 0;
let t=0;const kids=node.childNodes;
for(let i=0;i<kids.length;i++)t+=_srcNodeLen(kids[i]);
return t;
}
function _srcOffset(root,container,offset){
if(!container)return 0;
if(container.nodeType===1){
let t=0;const kids=container.childNodes;
for(let i=0;i<offset&&i<kids.length;i++)t+=_srcNodeLen(kids[i]);
return t;
}
if(container.nodeType!==3)return 0;
let t=0;
const walker=document.createTreeWalker(root,NodeFilter.SHOW_TEXT);
let n;
while((n=walker.nextNode())){
if(n===container){
const pe=n.parentElement;
if(pe&&pe.closest&&pe.closest('.fd-react-chip'))return t;
return t+Math.max(0,Math.min(offset,n.nodeValue.length));
}
t+=_srcNodeLen(n);
}
return t;
}
function _emojiHtml(v){
const s=String(v||'');
if(/^(https?:|\/)/.test(s))return '<img src="'+esc(s)+'" alt="">';
return esc(s);
}
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 +189,9 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
.replace(/\*\*([^*]+)\*\*/g,'<strong>$1</strong>')
.replace(/\*([^*]+)\*/g,'<em>$1</em>')
.replace(/~~([^~]+)~~/g,'<s>$1</s>')
.replace(/__([^_]+)__/g,'<u>$1</u>') // souligné (toolbar de sélection)
.replace(/==([^=]+)==/g,'<mark>$1</mark>') // surlignage
.replace(/\$\$([^$]+)\$\$/g,function(m,t){return '<span class="fd-math-inline" data-tex="'+t.replace(/"/g,'&quot;')+'"></span>';}) // équation inline
.replace(/\[\[fdpage:(\d+)\]\]/g,function(m,pid){
return '<a class="fd-wiki-chip" contenteditable="false" href="/pages/'+pid+'" data-token="'+m+'" data-pid="'+pid+'" title="Linked page"><span class="fd-wiki-label">…</span></a>';})
.replace(/\[\[fddate:([0-9]{4}-[0-9]{2}-[0-9]{2})(?:T[0-9:]{5,8})?\]\]/g,function(m,iso){
@@ -266,8 +385,9 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
}
// ── Block type shortcuts (shown on the right of each slash item) ──
const _SHORTCUT={text:'T',heading_1:'#',heading_2:'##',heading_3:'###',heading_4:'####',bulleted_list:'•',numbered_list:'1.',to_do:'[ ]',toggle:'▸',quote:'"',callout:'!',table_of_contents:'TOC',math:'∑',columns:'‖',divider:'———',code:'<>',table:'|',image:'[img]',embed:'[🔗]',bookmark:'[🔖]',video:'[🎬]',audio:'[🎵]',database:'DB',ai_write:'/ai',ai_summarize:'/ai',ai_translate:'/ai',ai_continue:'/ai',duplicate:'Ctrl+D',turn_into:'⋯',copy_link:'#',delete:'⌫'};
const _GROUP_LABEL={BASIC:'Basic blocks',AI:'AI writing',MEDIA:'Media',DATA:'Data',ACTIONS:'Actions'};
// Raccourcis = préfixes markdown (alignés sur la description mobile Notion : -, [], >, ---)
const _SHORTCUT={text:'T',heading_1:'#',heading_2:'##',heading_3:'###',heading_4:'####',bulleted_list:'-',numbered_list:'1.',to_do:'[]',toggle:'>',quote:'"',callout:'!',table_of_contents:'TOC',math:'∑',columns:'‖',divider:'---',code:'<>',table:'|',image:'[img]',embed:'[🔗]',bookmark:'[🔖]',video:'[🎬]',audio:'[🎵]',database:'DB',ai_write:'/ai',ai_summarize:'/ai',ai_translate:'/ai',ai_continue:'/ai',duplicate:'Ctrl+D',turn_into:'⋯',copy_link:'#',delete:'⌫'};
const _GROUP_LABEL={BASIC:'Basic blocks',AI:'AI writing',MEDIA:'Media',DATA:'Data',ACTIONS:'Actions',SYNCED:'Synced'};
// ── Hover preview card (matches Notion's visual hint) ──
function _previewHtml(blockId){
const p=function(inner,cls){return '<div class="slash-prev" style="padding:0">'+inner+'</div>';};
@@ -311,7 +431,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
inp.oninput=()=>SM.flt();
e.appendChild(list);e.appendChild(footer);e.appendChild(inp);
SM._e=e;SM._in=inp;SM._li=list;SM._f=footer;
document.addEventListener('mousedown',ev=>{if(!e.contains(ev.target))SM.close();});
document.addEventListener('mousedown',ev=>{if(!e.contains(ev.target)&&!MTB.contains(ev.target))SM.close();});
if(!SM._doc){
SM._doc=true;
document.addEventListener('keydown',function(ev){
@@ -336,19 +456,22 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
SM._pv.style.left=left+'px'; SM._pv.style.top=top+'px'; SM._pv.style.display='block';
},
open(i,el){if(!SM._e)SM.init();SM.close();SM._i=i;SM._s=0;SM._in.value='';SM.flt();
// v7.63 : mobile → barre horizontale façon Notion sous le curseur
if(_fdMobile()){SM._o=true;MTB.open(i,el);return;}
const r=el.getBoundingClientRect();SM._e.style.display='flex';
SM._e.style.top=Math.max(4,(r.bottom+4>window.innerHeight-360)?r.top-364:r.bottom+4)+'px';
SM._e.style.left=Math.min(r.left,window.innerWidth-340)+'px';
SM._rs();setTimeout(()=>SM._in.focus(),10);SM._o=true;},
close(){if(SM._e)SM._e.style.display='none';SM._o=false;SM._in&&(SM._in.value='');if(SM._pv)SM._pv.style.display='none';},
close(){if(SM._e)SM._e.style.display='none';SM._o=false;SM._in&&(SM._in.value='');if(SM._pv)SM._pv.style.display='none';MTB.close();},
// Ligne d'item du slash (partagée par la popup desktop et la feuille mobile)
_slashItemHtml(c){return `<div class="slash-item" data-sid="${c.id}" style="display:flex;align-items:center;gap:10px;padding:7px 10px;font-size:14px;color:var(--text-primary);cursor:pointer;border-radius:5px">
<span class="slash-item-icon" style="width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-size:15px;background:var(--bg-secondary);border-radius:4px;flex-shrink:0">${c.icon}</span>
<span class="slash-item-name" style="flex:1;font-weight:500">${c.name}</span>
<span class="slash-item-hint" style="font-size:12px;color:var(--text-dim);font-family:var(--font-mono);opacity:.7">${_SHORTCUT[c.id]||''}</span></div>`;},
flt(){if(!SM._li)return;SM._it=[];const q=(SM._in.value||'').toLowerCase();SM._q=q;let h='';
for(const g of CMDS){let items=g.items;if(q)items=items.filter(c=>c.name.toLowerCase().includes(q)||c.id.includes(q));if(!items.length)continue;
h+=`<div class="sm-group" style="padding:6px 10px 3px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.4px">${_GROUP_LABEL[g.name]||g.name}</div>`;
items.forEach(c=>{SM._it.push(c);
h+=`<div class="slash-item" data-sid="${c.id}" style="display:flex;align-items:center;gap:10px;padding:7px 10px;font-size:14px;color:var(--text-primary);cursor:pointer;border-radius:5px">
<span class="slash-item-icon" style="width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-size:15px;background:var(--bg-secondary);border-radius:4px;flex-shrink:0">${c.icon}</span>
<span class="slash-item-name" style="flex:1;font-weight:500">${c.name}</span>
<span class="slash-item-hint" style="font-size:12px;color:var(--text-dim);font-family:var(--font-mono);opacity:.7">${_SHORTCUT[c.id]||''}</span></div>`;});}
items.forEach(c=>{SM._it.push(c);h+=SM._slashItemHtml(c);});}
SM._li.innerHTML=h;SM._rs();},
_kd(e){if(e.key==='ArrowDown'){e.preventDefault();SM._s=Math.min(SM._s+1,SM._it.length-1);SM._rs();}
else if(e.key==='ArrowUp'){e.preventDefault();SM._s=Math.max(SM._s-1,0);SM._rs();}
@@ -358,6 +481,64 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
sel(id){if(window.E)window.E.applySlash(id);SM.close();},
get isOpen(){return SM._o;}};
// ═══════════ v7.63.0 — Menus mobiles façon Notion : barre horizontale sous le curseur
// + feuille plein écran « Insert block » / « Turn into » / « Color ». Tout est
// branché sur les actions existantes (applySlash, turnInto, blockMenu, moveBlock…).
function _fdMobile(){try{return window.matchMedia('(max-width: 768px)').matches;}catch(e){return false;}}
const MTB={
_b:null,_sheet:null,
contains(t){return !!t&&((this._b&&this._b.contains(t))||(this._sheet&&this._sheet.contains(t)));},
el(){ if(this._b)return this._b;
const wide=['plus','turn','color','more'];
const items=[['plus','+<span class="fd-mtb-caret">▾</span>'],['turn','Turn into<span class="fd-mtb-caret">▾</span>'],
['at','@'],['comment','💬'],['image','🖼️'],['trash','🗑️'],
['left','←'],['right','→'],['up','↑'],['down','↓'],['undo','↩'],['redo','↪'],
['color','Color<span class="fd-mtb-caret">▾</span>'],['more','More<span class="fd-mtb-caret">▾</span>'],['kbd','⌨️']];
const d=document.createElement('div');d.id='fdMobileBar';d.className='fd-mtb';
d.innerHTML=items.map(it=>'<button type="button" class="fd-mtb-item'+(wide.indexOf(it[0])>=0?' fd-mtb-wide':'')+'" data-mtb="'+it[0]+'">'+it[1]+'</button>').join('');
d.addEventListener('click',ev=>{const bt=ev.target.closest('[data-mtb]');if(bt)MTB.act(bt.getAttribute('data-mtb'),bt);});
document.body.appendChild(d);this._b=d;return d;},
open(i,el){const d=this.el();d.style.display='flex';
const r=el.getBoundingClientRect();
const w=Math.min(d.scrollWidth||280,window.innerWidth-16);
d.style.left=Math.max(8,Math.min(r.left,window.innerWidth-w-8))+'px';
let top=r.bottom+6;if(top+54>window.innerHeight-8)top=Math.max(8,r.top-54);
d.style.top=top+'px';d.scrollLeft=0;},
close(){if(this._b)this._b.style.display='none';this.closeSheet();},
closeSheet(){if(this._sheet){this._sheet.remove();this._sheet=null;}},
sheet(title,html,handler){
this.closeSheet();
const s=document.createElement('div');s.className='fd-msheet';
s.innerHTML='<div class="fd-msheet-head"><span class="fd-msheet-title">'+title+'</span><button type="button" class="fd-msheet-cancel">Cancel</button></div><div class="fd-msheet-list block-menu">'+html+'</div>';
s.querySelector('.fd-msheet-cancel').addEventListener('click',()=>{const E=window.E;this.closeSheet();if(E&&SM._i>=0&&E.blocks[SM._i])E._focusBid(E.blocks[SM._i].id);});
s.addEventListener('click',handler);
document.body.appendChild(s);this._sheet=s;},
_insertHtml(){let h='';for(const g of CMDS){if(!g.items||!g.items.length)continue;
h+='<div class="sm-group">'+(_GROUP_LABEL[g.name]||g.name)+'</div>';
g.items.forEach(c=>{h+=SM._slashItemHtml(c);});}return h;},
_bmAct(ev){if(!ev.target||!ev.target.closest||!ev.target.closest('[data-bm-act]'))return;_blockMenuClick(ev);this.closeSheet();},
act(k,btn){
const E=window.E;if(!E)return;
const idx=SM._i;const b=(idx>=0&&idx<E.blocks.length)?E.blocks[idx]:null;
const bid=b?b.id:'';
if(k==='plus'){this.sheet('Insert block',this._insertHtml(),ev=>{const it=ev.target.closest('.slash-item');if(it)SM.sel(it.dataset.sid);});return;}
if(k==='turn'){this.sheet('Turn into',_blockTurnItems(bid),ev=>this._bmAct(ev));return;}
if(k==='color'){this.sheet('Color',_blockColorItems(bid),ev=>this._bmAct(ev));return;}
if(k==='more'){const el=E.getEl(bid);const r=el?el.getBoundingClientRect():btn.getBoundingClientRect();
this.close();blockMenuOpen(Math.max(8,Math.min(r.left,window.innerWidth-250)),Math.min(r.bottom+6,window.innerHeight-120),bid);return;}
if(k==='kbd'){const a=document.activeElement;if(a&&a.blur)a.blur();return;}
if(!b)return;
if(k==='at'){const el=E.getEl(bid);if(!el)return;el.focus();document.execCommand('insertText',false,'@');
E.dirty=true;E.autoSave();E.openMention(el);return;}
if(k==='comment'){E.commentBlock(idx);return;}
if(k==='image'){E.applySlash('image');return;}
if(k==='trash'){E.deleteSelected(idx);return;}
if(k==='up'||k==='down'||k==='left'||k==='right'){E.moveBlock(bid,k);return;}
if(k==='undo'){E.undo();return;}
if(k==='redo'){E.redo();return;}
}
};
// ═══════════ v5.11.0 Wiki-link picker ([[ → page search) ═══════════
const WM={_o:false,_idx:-1,_s:0,_it:[],_root:null,_in:null,_li:null,_pv:null,
mk(){ if(this._root)return this._root;
@@ -962,6 +1143,8 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
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')||_blockMenuBid();
@@ -976,7 +1159,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
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.askAI(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'),parseInt(t.getAttribute('data-cols')||'0',10)||0);_blockMenuClose();return;}
@@ -1069,8 +1252,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
d.style.left=x+'px';d.style.top=y+'px';
return d;
}
function _blockTurnMenuOpen(x,y,bid){
var d=_blockSubShell(x,y);
function _blockTurnItems(bid){
var E=window.E;var cur='';
if(E){var i=E.getIdx(bid);if(i>=0&&E.blocks[i])cur=E.blocks[i].type||'';}
var html='';
@@ -1080,10 +1262,13 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
title:t.cols?('Create '+t.cols+' columns of blocks'):'',
check:on,current:on});
});
d.innerHTML=html;
return html;
}
function _blockColorMenuOpen(x,y,bid){
function _blockTurnMenuOpen(x,y,bid){
var d=_blockSubShell(x,y);
d.innerHTML=_blockTurnItems(bid);
}
function _blockColorItems(bid){
var E=window.E;var idx=E?E.getIdx(bid):-1;var b=idx>=0?E.blocks[idx]:null;
var curT=(b&&b.style&&b.style.color)||'default';
var curB=(b&&b.style&&b.style.bgColor)||'default';
@@ -1111,7 +1296,11 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
_BLOCK_COLOR_NAMES[n]+' background',{bid:bid,colorKey:'bgColor',colorVal:v,
check:v===curB,current:v===curB});
});
d.innerHTML=html;
return html;
}
function _blockColorMenuOpen(x,y,bid){
var d=_blockSubShell(x,y);
d.innerHTML=_blockColorItems(bid);
}
function _blockSkillsMenuOpen(x,y,bid){
var d=_blockSubShell(x,y);
@@ -1187,7 +1376,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
inviteEmail:'',invitePermission:'edit',inviteUserId:null,inviteGroupId:null,inviteGroups:[],inviteUsers:[],inviteSuggestOpen:false,inviteSel:-1,accessList:[],
toastVisible:false,toastMsg:'',
// ── v4.9.0: Collaboration — comments & mentions ──
commentsOpen:false,commentDraft:'',comments:[],commentCount:0,
commentsOpen:false,commentDraft:'',comments:[],commentCount:0,reactions:[],commentMenu:null,
currentUserId:PD.user_id||0,
_commentSel:{range:null,bid:null,start:0,end:0},
_notifiedMentions:{},
@@ -1239,6 +1428,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
this.insertTable=function(){this.sync();this.pushHistory();var nb={id:genId(),type:'table',content:'',rows:_tblNew(3,3)};this.blocks.push(nb);this.dirty=true;this.autoSave();this.render();_rtSync();setTimeout(function(){_tblFocus(nb.id,0,0);},60);};
SM.init();_tblInitDelegate();this.render();
this._applyLockMode();this._applyLayout();_wikiResolve();
this.loadComments();this.loadReactions(); // ancres jaunes + pastilles (v7.64)
const tEl=document.getElementById('_titleEl');
if(tEl){const syncT=()=>{tEl.classList.toggle('empty',!(tEl.textContent||'').trim());};syncT();tEl.addEventListener('input',syncT);tEl.addEventListener('blur',syncT);}
if(this.contentFormat==='blocks'&&window.__fdRT&&window.__fdRT.start){setTimeout(()=>{try{window.__fdRT.start(this);}catch(e){}},150);}
@@ -1397,14 +1587,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);
@@ -1413,6 +1604,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
ct.querySelectorAll('[data-bid]').forEach(el=>{el.addEventListener('input',()=>{self.dirty=true;self.autoSave();if(el.hasAttribute&&el.hasAttribute('data-placeholder'))el.classList.toggle('empty',!(el.textContent||'').trim());if(window.AIAC)window.AIAC.schedule(el.getAttribute('data-bid'));})});
ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/<br[^>]*>/gi,'').trim()!==''?el.innerHTML:'';el.classList.toggle('empty',!el.textContent.trim());});
_selApply();
try{this._paintAnchors();}catch(e){} // ancres jaunes + pastilles (v7.64)
var self2=this;
setTimeout(function(){
if(window._wikiResolve)_wikiResolve();
@@ -1673,14 +1865,29 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
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){
// L'ancre est la SÉLECTION de texte si elle vit dans ce bloc (la
// sélection de la toolbar aussi : le clic sur le bouton peut l'avoir
// fait disparaître du DOM), sinon tout le bloc.
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;}
let r=null;const s=window.getSelection();
if(s&&s.rangeCount&&!s.isCollapsed){
const rr=s.getRangeAt(0);
if(el.contains(rr.startContainer)&&el.contains(rr.endContainer))r=rr.cloneRange();
}
if(!r&&this._sel&&this._sel.range&&this._sel.idx===idx
&&el.contains(this._sel.range.startContainer)&&el.contains(this._sel.range.endContainer)){
r=this._sel.range.cloneRange();
}
if(!r){r=document.createRange();r.selectNodeContents(el);}
const start=_srcOffset(el,r.startContainer,r.startOffset);
const end=_srcOffset(el,r.endContainer,r.endOffset);
if(!(end>start)){this.showToast('Sélectionnez du texte pour commenter','error');return;}
this._commentSel={range:r,bid:el.dataset.bid,start:start,end:end};
this.commentOnSelection();
},
askAI(idx){const b=this.blocks[idx];if(!b)return;if(window.AIC)window.AIC.open(idx);},
// 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…');
@@ -1691,6 +1898,35 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
this.showToast('FlowDeck AI : compétence en cours…');
this._aiWritingRun('write',idx,{prompt:String(tpl||'')+'\n\nContenu du bloc :\n'+b.content},'insert');
},
// v7.63 : déplacement de bloc depuis la barre mobile
// (↑↓ = ordre, → = indent dans le frère précédent, ← = sortir du parent)
moveBlock(bid,dir){
const top=this.blocks.findIndex(b=>b.id===bid);
const pi=this.blocks.findIndex(b=>Array.isArray(b.children)&&b.children.some(c=>c.id===bid));
const parent=pi>=0?this.blocks[pi]:null;
const list=parent?parent.children:this.blocks;
const k=list.findIndex(b=>b.id===bid);
if(k<0){this.showToast('Bloc introuvable','error');return;}
if(dir==='up'||dir==='down'){
const j=dir==='up'?k-1:k+1;
if(j<0||j>=list.length){this.showToast(dir==='up'?'Déjà en haut':'Déjà en bas','error');return;}
this.sync();this.pushHistory();
const mv=list.splice(k,1)[0];list.splice(j,0,mv);
}else if(dir==='right'){
if(parent){this.showToast('Déjà indenté','error');return;}
const prev=this.blocks[top-1];
if(!prev||!Array.isArray(prev.children)||(prev.type!=='toggle'&&prev.type!=='columns')){this.showToast('Pas de bloc cible à droite','error');return;}
this.sync();this.pushHistory();
if(prev.type==='toggle')prev.expanded=true;
const mv=this.blocks.splice(top,1)[0];prev.children.push(mv);
}else{
if(!parent){this.showToast('Déjà à la racine','error');return;}
this.sync();this.pushHistory();
const mv=parent.children.splice(k,1)[0];
this.blocks.splice(pi+1,0,mv);
}
this.dirty=true;this.autoSave();this.render();_rtSync();this._focusBid(bid);
},
applyLastColor(idx){
let v='';try{v=localStorage.getItem('fdLastBlockColor')||'';}catch(e){}
const i=v.indexOf('|');
@@ -2166,7 +2402,9 @@ applyAIBlocks(text){
if(e.key==='Escape'){e.preventDefault();window.AIAC.clear();return;}
if(e.key==='Enter'||e.key==='Backspace'){window.AIAC.clear();}
}
if(e.key.length===1||e.key==='Backspace'||e.key==='Delete'){const i2=idx;setTimeout(()=>{const e2=this.getEl(this.blocks[i2]?.id);if(e2&&e2.textContent?.trim()==='/'&&!SM.isOpen&&!WM.isOpen)SM.open(i2,e2);
if(e.key.length===1||e.key==='Backspace'||e.key==='Delete'){const i2=idx;setTimeout(()=>{const e2=this.getEl(this.blocks[i2]?.id);
if(e2&&e2.textContent?.trim()==='/'&&!SM.isOpen&&!WM.isOpen){SM.open(i2,e2);}
else if(SM.isOpen){const t=(e2&&e2.textContent)||'';if(t.charAt(0)!=='/')SM.close();}
if(e2&&!SM.isOpen&&!WM.isOpen){const t=gtTok(e2)||'';const mm=t.match(/\[\[$/);if(mm){WM.open(i2,e2);}}},15);}
if(e.key===' '){
if(block.type==='paragraph'&&!(block.content||'').trim()&&!(el.textContent||'').trim()&&!SM.isOpen&&window.AIC){
@@ -2192,8 +2430,11 @@ 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);}
// Backspace / Delete sur un bloc vide → supprimer le bloc (même garde
// pour les deux touches ; le texte restant est lu comme à la sauvegarde
// pour ignorer les annotations .fd-react-chip)
if(e.key==='Backspace'||e.key==='Delete'){if(!(gtTok(el)||'').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 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;}
@@ -2315,12 +2556,83 @@ 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);},
// offsets d'une Range dans le texte rendu d'un bloc (partagé bouton 💬 + réaction)
_selSpan(bel,range){return {start:_srcOffset(bel,range.startContainer,range.startOffset),
end:_srcOffset(bel,range.endContainer,range.endOffset)};},
// « React to selected text » (v7.64) : sélecteur d'émoji avec le texte
// sélectionné en en-tête → toggle de la réaction ancrée sur la plage
fmtReact(){
if(!this._sel)return;
const E=this;const bid=this._fmtBid();const bel=this.getEl(bid);
if(!bel||!window.FDIconPicker)return;
const span=this._selSpan(bel,this._sel.range);
if(span.end<=span.start)return;
const text=this._sel.range.toString();
const r=this._sel.range.getBoundingClientRect();
this.fmt.open=false;
window.FDIconPicker.openFor({
x:Math.min(Math.max(8,r.left-40),window.innerWidth-360),
y:Math.min(Math.max(8,r.bottom+8),window.innerHeight-260),
title:text.length>90?text.slice(0,90)+'…':text,
onPick:function(emoji){E.toggleReaction(bid,span.start,span.end,emoji);}
});
},
// ── 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=[];}},
toggleComments(){if(this.commentsOpen){this.commentsOpen=false;this._commentSel={range:null,bid:null,start:0,end:0};return;}this.commentsOpen=true;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=[];}
this._paintAnchors();
// #cmt-<id> (copié depuis le menu « … » du fil) → ouvre et centre le fil
if(!this._cmtHashDone&&location.hash.indexOf('#cmt-')===0){
this._cmtHashDone=true;
const cid=parseInt(location.hash.slice(5),10);
if(!isNaN(cid))setTimeout(()=>this.openThread(cid),300);
}
},
fmtTime(s){if(!s)return '';const t=new Date((String(s).includes('T')||String(s).includes('Z'))?s:(s+'Z'));if(isNaN(t.getTime()))t=new Date(s);const diff=Math.floor((Date.now()-t.getTime())/1000);if(diff<60)return 'just now';if(diff<3600)return Math.floor(diff/60)+'m ago';if(diff<86400)return Math.floor(diff/3600)+'h ago';return Math.floor(diff/86400)+'d ago';},
csrfTok(){return getCsrf();},
async addPageComment(){
@@ -2331,38 +2643,103 @@ applyAIBlocks(text){
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},
body:JSON.stringify({body:text,anchor_block_id:sel.bid||null,anchor_start:sel.bid?sel.start:null,anchor_end:sel.bid?sel.end:null})});
const d=await r.json();
if(d.status==='created'){this.commentDraft='';this._commentSel={range:null,bid:null,start:0,end:0};this.hideCommentBtn();await this.loadComments();this.showToast('Comment added');}
if(d.status==='created'){this.commentDraft='';this._commentSel={range:null,bid:null,start:0,end:0};await this.loadComments();this.showToast('Comment added');}
else{this.showToast(d.detail||'Failed to add comment');}
}catch(e){this.showToast('Failed to add comment');}
},
async resolveComment(id){try{await fetch('/api/comments/'+id,{method:'PUT',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({resolved:true})});await this.loadComments();}catch(e){}},
async deleteComment(id){if(!confirm('Delete this comment?'))return;try{await fetch('/api/comments/'+id,{method:'DELETE',credentials:'same-origin',headers:{'X-CSRF-Token':this.csrfTok()}});await this.loadComments();}catch(e){}},
async resolveComment(c){
// accepte l'objet (le fil → bascule resolve/reopen) ou un id brut
let id=c,resolved=true;
if(c&&typeof c==='object'){id=c.id;resolved=!c.resolved;}
if(!id)return;
try{await fetch('/api/comments/'+id,{method:'PUT',credentials:'same-origin',
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},
body:JSON.stringify({resolved:resolved})});
this.commentMenu=null;await this.loadComments();}catch(e){}
},
async deleteComment(id){if(!confirm('Delete this comment?'))return;try{await fetch('/api/comments/'+id,{method:'DELETE',credentials:'same-origin',headers:{'X-CSRF-Token':this.csrfTok()}});this.commentMenu=null;await this.loadComments();}catch(e){}},
// ── v7.64 : réactions emoji sur texte + ancres commentées ──
async loadReactions(){
try{const r=await fetch('/api/pages/'+this.pid+'/reactions',{credentials:'same-origin'});
const d=await r.json();this.reactions=(d&&d.reactions)||[];}catch(e){this.reactions=[];}
this._paintAnchors();
},
async toggleReaction(bid,start,end,emoji){
try{
const r=await fetch('/api/pages/'+this.pid+'/reactions',{method:'POST',credentials:'same-origin',
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},
body:JSON.stringify({block_id:bid,anchor_start:start,anchor_end:end,emoji:emoji})});
const d=await r.json();
if(d.status==='added'||d.status==='removed'){await this.loadReactions();}
else this.showToast((d&&d.detail)||'Réaction impossible','error');
}catch(e){this.showToast('Réaction impossible','error');}
},
openThread(cid){
this.commentsOpen=true;this.commentMenu=null;
const self=this;
this.loadComments().then(function(){self.$nextTick(function(){
const el=document.querySelector('[data-thread="'+cid+'"]');
if(el&&el.scrollIntoView){el.scrollIntoView({block:'center',behavior:'smooth'});
el.classList.add('thread-flash');setTimeout(function(){el.classList.remove('thread-flash');},1500);}
});});
},
commentAt(){
this.commentDraft=(this.commentDraft||'')+'@';
this.$nextTick(function(){const ta=document.querySelector('.comments-drawer textarea');
if(ta){ta.focus();ta.selectionStart=ta.selectionEnd=ta.value.length;}});
},
copyCommentLink(id){
const url=location.origin+location.pathname+'#cmt-'+id;
this.commentMenu=null;
if(navigator.clipboard&&navigator.clipboard.writeText){
navigator.clipboard.writeText(url).then(()=>this.showToast('Lien du commentaire copié')).catch(()=>this.showToast(url,'error'));
} else this.showToast(url,'error');
},
// Recalcule les ancres jaunes (commentaires) + pastilles de réaction.
// Le texte des blocs est inchangé : sync() lit la même chaîne.
_paintAnchors(){
const ct=document.getElementById('_blocksCt');if(!ct)return;
ct.querySelectorAll('.fd-react-chip').forEach(function(el){el.remove();});
ct.querySelectorAll('.fd-anchor').forEach(function(el){
const par=el.parentNode;if(!par)return;
while(el.firstChild)par.insertBefore(el.firstChild,el);
par.removeChild(el);par.normalize();
});
const self=this;
(this.comments||[]).forEach(function(c){
if(!c.anchor_block_id||c.anchor_start==null||c.anchor_end==null)return;
const el=self.getEl(c.anchor_block_id);if(!el)return;
_wrapAnchored(el,parseInt(c.anchor_start,10),parseInt(c.anchor_end,10),
'fd-anchor'+(c.resolved?' fd-anchor-done':''),{cid:c.id});
});
const groups={};
(this.reactions||[]).forEach(function(r){
const k=r.block_id+':'+r.anchor_start+':'+r.anchor_end;
if(!groups[k])groups[k]={bid:r.block_id,start:parseInt(r.anchor_start,10),end:parseInt(r.anchor_end,10),items:[]};
groups[k].items.push(r);
});
Object.keys(groups).forEach(function(k){
const g=groups[k];
const el=self.getEl(g.bid);if(!el)return;
const sp=_wrapAnchored(el,g.start,g.end,'fd-anchor fd-anchor-react',null);
if(!sp||!sp.parentNode)return;
const n=g.items.reduce(function(a,i){return a+(parseInt(i.count,10)||1);},0);
const chip=document.createElement('span');
chip.className='fd-react-chip';
chip.setAttribute('contenteditable','false');
chip.setAttribute('data-react-bid',g.bid);
chip.setAttribute('data-react-start',String(g.start));
chip.setAttribute('data-react-end',String(g.end));
chip.innerHTML=g.items.map(function(i){return '<span class="fd-react-emo">'+_emojiHtml(i.emoji)+'</span>';}).join('')
+(n>1?'<span class="fd-react-n">'+n+'</span>':'');
sp.parentNode.insertBefore(chip,sp.nextSibling);
});
},
commentOnSelection(){
const sel=this._commentSel;if(!sel.range)return;
const bid=sel.bid||'';this.commentsOpen=true;this.commentDraft='';
const btn=document.getElementById('_commentSelBtn');if(btn)btn.style.display='none';
this.commentsOpen=true;this.commentDraft='';
this.$nextTick(()=>{const ta=document.querySelector('.comments-drawer textarea');if(ta)ta.focus();});
},
showCommentBtn(){
const s=window.getSelection();if(!s||s.isCollapsed)return;
const ct=document.getElementById('_blocksCt');if(!ct||!ct.contains(s.anchorNode))return;
const bel=s.anchorNode.parentElement? s.anchorNode.parentElement.closest('[data-bid]'):null;
if(!bel)return;
const bid=bel.dataset.bid;
const range=s.getRangeAt(0).cloneRange();range.selectNodeContents(bel);range.setEnd(s.getRangeAt(0).startContainer,s.getRangeAt(0).startOffset);
const start=range.toString().length;
const end=start+s.toString().length;
if(end<=start)return;
this._commentSel={range:s.getRangeAt(0).cloneRange(),bid:bid,start:start,end:end};
const r=s.getRangeAt(0).getBoundingClientRect();
const btn=document.getElementById('_commentSelBtn');if(!btn)return;
btn.style.display='block';
const bw=btn.offsetWidth||110,bh=btn.offsetHeight||34;
let x=r.left+r.width/2-bw/2,y=r.top-bh-8;
x=Math.min(Math.max(8,x),window.innerWidth-bw-8);if(y<8)y=r.bottom+8;
btn.style.left=x+'px';btn.style.top=y+'px';
},
hideCommentBtn(){const btn=document.getElementById('_commentSelBtn');if(btn)btn.style.display='none';},
// ── @mention autocomplete (v4.9.0 users + v5.11.0 pages & dates) ──
mentionOpen:false,mentionQuery:'',mentionResults:[],mentionPages:[],mentionEl:null,
openMention(el){
@@ -2686,11 +3063,52 @@ applyAIBlocks(text){
}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/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; }
// v7.64 : clic sur une ancre jaune → ouvre et centre le fil de discussion ;
// clic sur une pastille de réaction → réouvre le sélecteur pour cette plage.
document.addEventListener('click',function(ev){
const t=ev.target;if(!t||!t.closest)return;
const d=_xed(document.querySelector('.page-editor-wrapper'));if(!d)return;
const chip=t.closest('.fd-react-chip');
if(chip){
const bid=chip.getAttribute('data-react-bid');
const start=parseInt(chip.getAttribute('data-react-start'),10);
const end=parseInt(chip.getAttribute('data-react-end'),10);
const el=d.getEl(bid);const rect=chip.getBoundingClientRect();
if(!window.FDIconPicker||!el||isNaN(start)||isNaN(end))return;
window.FDIconPicker.openFor({
x:Math.min(Math.max(8,rect.left-40),window.innerWidth-360),
y:Math.min(Math.max(8,rect.bottom+8),window.innerHeight-260),
title:(el.textContent||'').slice(start,end),
onPick:function(emoji){d.toggleReaction(bid,start,end,emoji);}
});
return;
}
const anch=t.closest('.fd-anchor[data-cid]');
if(anch&&d.openThread){
const cid=parseInt(anch.getAttribute('data-cid'),10);
if(!isNaN(cid))d.openThread(cid);
}
});
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);}
},80);});
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){
@@ -2701,8 +3119,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();
});
@@ -2763,11 +3181,12 @@ applyAIBlocks(text){
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.askAI(i1); return; }
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') {
+6 -3
View File
@@ -48,7 +48,8 @@ def test_actions_are_all_dispatched():
"comment", "suggest", "ai", "skill", "color", "turn-type"):
assert f"act==='{act}'" in dispatch, act
# méthodes réelles côté éditeur
for m in ("commentBlock(idx)", "askAI(idx)", "suggestEdit(idx)",
# 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
@@ -59,7 +60,9 @@ def test_actions_are_all_dispatched():
# ── Sous-menus ──────────────────────────────────────────────────────────────
def test_turn_into_submenu():
turn = _slice("function _blockTurnMenuOpen(", "\n function _blockColorMenuOpen(")
# v7.63 : le générateur d'items a été extrait (_blockTurnItems) pour être
# réutilisé par la feuille mobile — le test vise le générateur.
turn = _slice("function _blockTurnItems(", "\n function _blockColorItems(")
assert "_BLOCK_TURN_MENU" in turn and "check:on" in turn # ✓ du type actuel
assert "'<span class=\"bm-check\">✓</span>'" in SRC
for name in ("Text", "Heading 1", "Heading 4", "Bulleted list",
@@ -74,7 +77,7 @@ def test_turn_into_submenu():
def test_color_submenu():
color = _slice("function _blockColorMenuOpen(", "\n function _blockSkillsMenuOpen(")
color = _slice("function _blockColorItems(", "\n function _blockSkillsMenuOpen(")
for group in ("Last used", "Text color", "Background color"):
assert f">{group}</div>" in color, group
names = ["'Default'", "'Gray'", "'Brown'", "'Orange'", "'Yellow'",
+155
View File
@@ -0,0 +1,155 @@
"""7.62.0 — menu contextuel au highlight de texte (toolbar façon Notion)."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
SRC = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
TPL = (ROOT / "app" / "templates" / "_page_editor_content.html").read_text(encoding="utf-8")
CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
APPJS = (ROOT / "static" / "js" / "app.js").read_text(encoding="utf-8")
def _slice(src: str, start: str, end: str) -> str:
i = src.index(start)
return src[i:src.index(end, i)]
# ── Markup : les 4 lignes + Skills + pied, dans l'ordre de la description ───
def test_toolbar_markup_matches_spec():
chunk = _slice(TPL, 'class="format-toolbar"', "Copy Link Toast")
order = [
"fmtStyle($event)", # ligne 1 : Normal Text ›
"fmtApply('bold')", "fmtApply('italic')", "fmtApply('underline')",
"fmtApply('strikeThrough')", "fmtCode()", # ligne 2 : A B I U T Tx
"fmtLink()", "fmtHighlight()", "fmtCodeBlock()", "fmtEquation()",
"fmtMore($event)", # ligne 3 : 🔗 S </> √x …
"fmtComment()", "fmtAskAI()", # ligne 4 : 💬 ✨
"ft-group", "Improve writing", "Proofread", "Explain", "Reformat",
"Edit with AI", "Alt+⇧+E", # pied de page
]
positions = [chunk.index(o) for o in order]
assert positions == sorted(positions), positions
# ligne 1 dynamique (type du bloc) + chevron de sous-menu
assert "fmtStyleName()" in chunk and "ft-caret" in chunk
# info-bulle HTML (exemple en italique + description)
assert "data-tooltip-html=" in chunk
# icônes réellement définies dans le jeu d'icônes
assert 'fd_icon("message-square",15)' in chunk
assert 'fd_icon("sparkles",15)' in chunk and 'fd_icon("link",15)' in chunk
# ── Actions : câblées vers de vraies méthodes, sous-menus réutilisés ────────
def test_actions_are_wired_to_real_methods():
for m in ("fmtStyle(ev)", "fmtColor(ev)", "fmtApply(f)", "fmtCode()",
"fmtHighlight()", "fmtCodeBlock()", "fmtLink()", "fmtEquation()",
"fmtMore(ev)", "fmtComment()", "fmtAskAI()", "fmtSkill(tpl)",
"fmtEditAI()", "askAIBlock(idx)"):
assert m in SRC, m
# pas de doublon de menu : on rouvre les sous-menus/menus déjà existants
assert "_blockTurnMenuOpen(p.x,p.y,bid)" in SRC
assert "_blockColorMenuOpen(p.x,p.y,bid)" in SRC
assert "blockMenuOpen(" in SRC
# sous-menu ouvert depuis la toolbar : fermeture au survol sortant
assert "function _subHoverClose()" in SRC
# Ctrl+J et Alt+⇧+E ouvrent l'IA du BLOC (l'ancien askAI(idx) était écrasé
# par askAI() défini plus bas dans le même objet)
assert "E.askAIBlock(i1)" in SRC
assert "k === 'e'" in SRC
assert "askAI(idx)" not in SRC
# ── Persistance du formatage : sync() relit le DOM formaté en markdown ─────
def test_formatting_survives_sync():
assert "function gtMd(" in SRC
assert "gtMd(el)" in SRC and "gtMd(che)" in SRC # sync() + enfants toggle/columns
for tok in ("'**'+inner+'**'", "'*'+inner+'*'", "'__'+inner+'__'",
"'~~'+inner+'~~'", "'`'+inner+'`'", "'=='+inner+'=='"):
assert tok in SRC, tok
# mdEsc rend les nouveaux marqueurs (souligné, surlignage, équation inline)
for re_ in ("/__([^_]+)__/g", "/==([^=]+)==/g", "/\\$\\$([^$]+)\\$\\$/g"):
assert re_ in SRC, re_
# les chips wiki gardent leur token brut
assert "fd-wiki-chip" in SRC
# ── Placement & fermeture ──────────────────────────────────────────────────
def test_menu_position_and_close():
assert "r.bottom+8" in SRC # sous la sélection (bas de l'écran : au-dessus)
assert "d.fmt.open=false;d._sel=null" in SRC # clic ailleurs → fermeture
assert "closest('.format-toolbar')" in SRC # clic dans la toolbar ≠ clic ailleurs
assert "@mousedown.prevent" in TPL # la sélection survit au clic
# ── Styles ────────────────────────────────────────────────────────────────
def test_menu_styles():
for sel in (".format-toolbar .ft-row {", ".format-toolbar .ft-item {",
".format-toolbar .ft-style {", ".format-toolbar .ft-group {",
".format-toolbar .ft-sep {", ".format-toolbar .ft-foot {",
".tooltip.tooltip-light {"):
assert sel in CSS, sel
# l'info-bulle claire s'applique aux éléments du menu de sélection
assert "data-tooltip-html" in APPJS and "tooltip-light" in APPJS
# ── Comportement réel : gtMd (DOM → source markdown) + mdEsc, sous node ────
def test_gt_md_and_md_esc_roundtrip_for_real():
node = shutil.which("node")
if not node:
pytest.skip("node absent : test comportemental ignoré")
gtmd = _slice(SRC, "function gtMd(", " // Enveloppe/désenveloppe")
mdesc = _slice(SRC, "function mdEsc(", " // v5.11.0: labels of [[fdpage:ID]]")
script = f"""
var window = {{}}; // gtMd fait window.gtMd=…
function gtTok(e){{ return e.__raw || ''; }}
function fdDateLabel(iso){{ return iso; }}
{gtmd}
{mdesc}
// DOM minimal (gtMd ne lit que childNodes / tagName / classList / attributs)
function T(s){{ return {{nodeType:3, nodeValue:s}}; }}
function E(tag, attrs, kids){{
attrs = attrs || {{}};
const cls = String(attrs['class'] || '').split(/\\s+/);
return {{nodeType:1, tagName:tag, childNodes:kids || [],
classList:{{contains:c=>cls.indexOf(c) >= 0}},
getAttribute:k=>(k in attrs ? attrs[k] : null),
hasAttribute:k=>(k in attrs)}};
}}
// querySelector truthy → chemin « formaté » de gtMd (gtTok jamais appelé)
const root = E('DIV', {{'class':''}}, [
E('B', {{}}, [T('gras')]),
E('EM', {{}}, [T('italique')]),
E('U', {{}}, [T('souligne')]),
E('S', {{}}, [T('barre')]),
E('CODE', {{}}, [T('code')]),
E('MARK', {{}}, [T('surligne')]),
E('A', {{'href':'https://example.com/x'}}, [T('lien')]),
E('SPAN', {{'class':'fd-wiki-chip', 'data-token':'[[fdpage:42]]'}}),
E('SPAN', {{'class':'fd-math-inline', 'data-tex':'x^2'}}),
T(' fin')
]);
root.querySelector = () => ({{}}); // force le chemin formaté
const md = gtMd(root);
const html = mdEsc(md);
console.log(JSON.stringify({{md:md, html:html}}));
"""
out = subprocess.run([node, "-e", script], capture_output=True, text=True,
timeout=30, encoding="utf-8")
assert out.returncode == 0, out.stderr
data = json.loads(out.stdout)
assert data["md"] == ("**gras***italique*__souligne__~~barre~~`code`==surligne=="
"[lien](https://example.com/x)[[fdpage:42]]$$x^2$$ fin"), data["md"]
for token in ("<strong>gras</strong>", "<u>souligne</u>", "<mark>surligne</mark>",
"<code>code</code>", 'href="https://example.com/x"',
'class="fd-wiki-chip"', 'data-tex="x^2"'):
assert token in data["html"], token
+136
View File
@@ -0,0 +1,136 @@
"""7.63.0 — menus mobiles façon Notion : barre horizontale au « / » + feuille plein écran."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
SRC = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
def _slice(src: str, start: str, end: str) -> str:
i = src.index(start)
return src[i:src.index(end, i)]
# ── Barre horizontale : les 15 boutons, dans l'ordre de la description ─────
def test_bar_items_in_order():
chunk = _slice(SRC, "const MTB={", "open(i,el){const d=this.el()")
order = ["['plus'", "['turn'", "['at'", "['comment'", "['image'", "['trash'",
"['left'", "['right'", "['up'", "['down'", "['undo'", "['redo'",
"['color'", "['more'", "['kbd'"]
positions = [chunk.index(o) for o in order]
assert positions == sorted(positions), positions
# les 4 entrées « texte + chevron » de la description
for w in ("'plus'", "'turn'", "'color'", "'more'"):
assert w in chunk, w
assert "fd-mtb-wide" in chunk and "fd-mtb-caret" in chunk
# ── Chaque bouton → une action réelle (pas de bouton mort) ─────────────────
def test_bar_actions_are_real():
act = _slice(SRC, "act(k,btn){", "\n };")
for token in ("sheet('Insert block'", "sheet('Turn into'", "sheet('Color'",
"blockMenuOpen(", "a.blur()", "E.openMention(el)",
"E.commentBlock(idx)", "E.applySlash('image')", "E.deleteSelected(idx)",
"E.moveBlock(bid,k)", "E.undo()", "E.redo()"):
assert token in act, token
# « More » ouvre bien le menu contextuel de bloc existant (desktop)
assert "this.close();blockMenuOpen(" in act
# ── Feuille plein écran : titre centré + Cancel + listes réutilisées ───────
def test_fullscreen_sheet():
sheet = _slice(SRC, "sheet(title,html,handler){", "_insertHtml(){")
for token in ("fd-msheet-head", "fd-msheet-title", "fd-msheet-cancel", "Cancel",
"fd-msheet-list block-menu"):
assert token in sheet, token
# contenu : items slash partagés + sous-menus de bloc partagés
ins = _slice(SRC, "_insertHtml(){", "_bmAct(")
assert "_GROUP_LABEL[g.name]" in ins and "_slashItemHtml" in ins
assert "function _blockTurnItems(bid)" in SRC and "function _blockColorItems(bid)" in SRC
# choix d'un item → applySlash via SM.sel (ferme tout)
assert "SM.sel(it.dataset.sid)" in SRC
# ── Branchement sur le slash : mobile = barre, desktop = popup intacte ─────
def test_mobile_hook():
for token in ("function _fdMobile()", "max-width: 768px",
"if(_fdMobile()){SM._o=true;MTB.open(i,el);return;}",
"MTB.close();", "!MTB.contains(ev.target)",
"else if(SM.isOpen)"):
assert token in SRC, token
# le popup desktop (positionnement + focus de la recherche) est toujours là
assert "SM._e.style.display='flex'" in SRC and "SM._in.focus()" in SRC
# la barre se referme quand le / disparaît
assert "if(t.charAt(0)!=='/')SM.close();" in SRC
# ── Déplacement de bloc (↑↓ ordre, → indent, ← outdent) ────────────────────
def test_move_block_logic():
m = _slice(SRC, "moveBlock(bid,dir){", "applyLastColor(idx){")
for token in ("dir==='up'", "dir==='down'", "dir==='right'", "parent.children",
"pushHistory()", "this.sync()", "_focusBid(bid)"):
assert token in m, token
# ── Styles ────────────────────────────────────────────────────────────────
def test_styles():
for sel in (".fd-mtb {", ".fd-mtb .fd-mtb-item {", ".fd-mtb .fd-mtb-wide {",
".fd-msheet {", ".fd-msheet-head {", ".fd-msheet-cancel {",
".fd-msheet-list.block-menu {", ".fd-msheet .bm-item {",
".fd-msheet .slash-item {"):
assert sel in CSS, sel
# ── Comportement réel : moveBlock sous node ───────────────────────────────
def test_move_block_for_real():
node = shutil.which("node")
if not node:
pytest.skip("node absent : test comportemental ignoré")
method = _slice(SRC, "moveBlock(bid,dir){", "applyLastColor(idx){")
script = f"""
function _rtSync(){{}}
const E = {{
blocks:[{{id:'a',type:'paragraph',content:'A'}},
{{id:'tg',type:'toggle',content:'T',expanded:true,children:[]}},
{{id:'c',type:'paragraph',content:'C'}}],
sync(){{}}, pushHistory(){{}}, dirty:false, autoSave(){{}}, render(){{}},
_focusBid(){{}}, showToast(){{}}, msgs:[],
{method}
}};
E.moveBlock('c','right'); // C s'indente dans le toggle
const indent = {{kids:E.blocks[1].children.map(b=>b.id).join(','), top:E.blocks.map(b=>b.id).join(',')}};
E.moveBlock('c','left'); // C ressort à la racine : a,tg,c
const top2 = E.blocks.map(b=>b.id).join(',');
E.moveBlock('a','down'); // A descend : tg,a,c
const ordre1 = E.blocks.map(b=>b.id).join(',');
E.moveBlock('c','up'); // C remonte : tg,c,a
const ordre2 = E.blocks.map(b=>b.id).join(',');
E.moveBlock('c','up'); // encore : c,tg,a
const ordre3 = E.blocks.map(b=>b.id).join(',');
E.moveBlock('c','up'); // déjà en haut → toast, inchangé
const ordre4 = E.blocks.map(b=>b.id).join(',');
console.log(JSON.stringify({{indent:indent, top2:top2, ordre1:ordre1, ordre2:ordre2, ordre3:ordre3, ordre4:ordre4}}));
"""
out = subprocess.run([node, "-e", script], capture_output=True, text=True,
timeout=30, encoding="utf-8")
assert out.returncode == 0, out.stderr
d = json.loads(out.stdout)
assert d["indent"] == {"kids": "c", "top": "a,tg"}, d
assert d["top2"] == "a,tg,c", d
assert d["ordre1"] == "tg,a,c", d
assert d["ordre2"] == "tg,c,a", d
assert d["ordre3"] == "c,tg,a", d
assert d["ordre4"] == "c,tg,a", d # garde « déjà en haut »
+139
View File
@@ -0,0 +1,139 @@
"""7.64.0 — commentaires façon Notion (ancres jaunes + tiroir) & réactions sur texte."""
from pathlib import Path
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")
PICKER = (ROOT / "app" / "templates" / "_icon_picker.html").read_text(encoding="utf-8")
PSTORE = (ROOT / "static" / "js" / "_icon_picker_2.js").read_text(encoding="utf-8")
# ── Backend : migration + endpoints ────────────────────────────────────────
def test_text_reactions_table_exists(client): # le fixture client initialise le schéma
from app.db import get_conn
with get_conn() as conn:
cols = {r[1] for r in conn.execute("PRAGMA table_info(text_reactions)").fetchall()}
assert {"id", "page_id", "block_id", "anchor_start", "anchor_end", "emoji",
"user_id", "created_at"} <= cols, cols
def test_reactions_endpoints_registered():
from app.routers.collaboration import router
methods = {(r.path, m) for r in router.routes for m in r.methods}
assert ("/api/pages/{page_id}/reactions", "GET") in methods
assert ("/api/pages/{page_id}/reactions", "POST") in methods
def test_toggle_reaction_api(client):
"""Le POST bascule (added → removed → added) et le GET regroupe par plage."""
from app.auth.session import SessionManager
from app.db import get_conn
with get_conn() as conn:
conn.execute("INSERT INTO users (login, full_name, email) VALUES (?,?,?)",
("v764u", "V764 User", "[email protected]"))
uid = conn.execute("SELECT id FROM users WHERE login='v764u'").fetchone()["id"]
cur = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format) VALUES (?,?,?,?)",
("u764", "React Page", "[]", "blocks"))
pid = cur.lastrowid
conn.commit()
cookies = {"flowdeck_session": SessionManager.create_session(
{"id": uid, "login": "v764u", "is_admin": 0})}
payload = {"block_id": "b1", "anchor_start": 0, "anchor_end": 5, "emoji": "👍"}
r = client.post(f"/api/pages/{pid}/reactions", json=payload, cookies=cookies)
assert r.status_code == 200 and r.json()["status"] == "added"
r = client.post(f"/api/pages/{pid}/reactions", json=payload, cookies=cookies)
assert r.status_code == 200 and r.json()["status"] == "removed"
r = client.post(f"/api/pages/{pid}/reactions", json=payload, cookies=cookies)
assert r.json()["status"] == "added"
r = client.get(f"/api/pages/{pid}/reactions", cookies=cookies)
assert r.status_code == 200
rows = r.json()["reactions"]
assert len(rows) == 1
assert rows[0]["emoji"] == "👍" and rows[0]["count"] == 1 and rows[0]["mine"] == 1
# plage vide → 400
bad = dict(payload, anchor_start=4, anchor_end=4)
r = client.post(f"/api/pages/{pid}/reactions", json=bad, cookies=cookies)
assert r.status_code == 400
# ── Toolbar : bouton réaction ──────────────────────────────────────────────
def test_toolbar_has_react_button():
assert 'fmtReact()' in TPL
assert 'data-tooltip="React to selected text"' in TPL
# position : 3ᵉ ligne, après le surlignage, avant le code
chunk = TPL[TPL.index('class="format-toolbar"'):TPL.index("Copy Link Toast")]
order = ["fmtHighlight()", "fmtReact()", "fmtCodeBlock()", "fmtMore($event)"]
positions = [chunk.index(o) for o in order]
assert positions == sorted(positions), positions
def test_fmt_react_opens_picker_with_selection():
js = SRC[SRC.index("fmtReact(){"):SRC.index("toggleComments(){")]
for token in ("FDIconPicker.openFor", "title:text", "onPick:",
"toggleReaction(bid,span.start,span.end,emoji)", "_selSpan(bel,this._sel.range)"):
assert token in js, token
# ── Ancres commentées + pastilles ──────────────────────────────────────────
def test_anchors_painting():
for token in ("function _wrapAnchored(", "_paintAnchors()", "fd-anchor",
"fd-react-chip", "data-react-bid", "data-react-start", "openThread(cid)",
"loadReactions()", "toggleReaction(bid,start,end,emoji)"):
assert token in SRC, token
# repeint après chaque reconstruction du bloc ET après le chargement
assert "this._paintAnchors();}catch(e){}" in SRC # fin de render()
assert "this._paintAnchors();\n" in SRC # fin de loadComments/loadReactions
# la pastille n'est pas du contenu lu/sauvé
assert "n.classList.contains('fd-react-chip')" in SRC # gtTok
assert "cl.contains('fd-react-chip')" in SRC # gtMd
# offsets source (les annotations ne décalent pas les ancres)
assert "function _srcOffset(" in SRC and "_srcNodeLen(" in SRC
# clic ancre → fil ; clic pastille → sélecteur
assert "t.closest('.fd-react-chip')" in SRC
assert "t.closest('.fd-anchor[data-cid]')" in SRC
# ── Tiroir de commentaires (image 1) ───────────────────────────────────────
def test_drawer_markup():
for token in ('class="ct-head"', 'class="ct-avatar"', 'class="ct-name"',
'class="ct-time"', 'class="ct-actions"', 'class="ct-menu"',
"copyCommentLink(c.id)", "deleteComment(c.id)", "resolveComment(c)",
':data-thread="c.id"', 'placeholder="Add a comment..."',
"commentAt()", "class=\"ct-send\"", "addPageComment()"):
assert token in TPL, token
# le bouton ✓ bascule resolve/reopen (l'objet, pas l'id seul)
assert "resolveComment(c)" in TPL and "async resolveComment(c)" in SRC
# hash #cmt- → centrage du fil
assert "#cmt-" in SRC and "scrollIntoView" in SRC
# styles du tiroir
for sel in (".comment-thread {", ".ct-head {", ".ct-avatar {", ".ct-menu {",
".ct-input-row {", ".ct-send {", ".fd-anchor {", ".fd-react-chip {"):
assert sel in CSS, sel
# ── Sélecteur d'émoji (image 3) : mode réaction ────────────────────────────
def test_picker_reaction_mode():
for token in ("fdip-title", "fdip-title-text", "fdip-title-grip",
"$store.fdIconPicker.title", "Filter...", "Recent"):
assert token in PICKER, token
for token in ("this.title = opts.title || ''", "this.category = 'people'",
"this.title = ''"):
assert token in PSTORE, token
# la ligne « Recent » au-dessus de la grille n'apparaît qu'en mode réaction
assert "$store.fdIconPicker.title && $store.fdIconPicker.category !== 'recent'" in PICKER
# onglets masqués en mode réaction (emoji only)
assert 'x-show="!$store.fdIconPicker.title"' in PICKER