Compare commits

...
6 Commits
Author SHA1 Message Date
bruno d58c321c07 feat: réactions dans le tiroir Comments (+ suppression) et refonte du menu de sélection (v7.67.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Tiroir Comments
- Section « Reactions » au-dessus des fils : émoji, compte (si >1) et texte
  ancré lu dans le DOM (reactionList() / _anchorText(), offsets source hors
  pastilles) ; l'état vide ne s'affiche plus s'il reste des réactions.
- Suppression : ✕ sur la ligne du tiroir (mes réactions) ET croix ::after au
  survol de la pastille sur l'émoji que j'ai posé (aucun nœud ajouté → offsets
  intacts). Deux entrées → removeReaction() → POST toggle → loadReactions().

Menu de sélection (maquette ASCII)
- Ligne 4 = rangée scannée : 💬 Comment (libellé) à gauche, 🙂+ réaction à
  droite (.ft-row-split) ; la réaction sort de la ligne 3.
- Entête Skills avec ≡ à droite (::after), liste Skills scrollable.
- ✨ Ask AI retiré de la toolbar : doublon exact de « Edit with AI » du pied.
- Écart volontaire à la maquette : le barré (T) de la ligne 2 est conservé.

Fix racine (flake de gate)
- _is_public_host attrape OSError (le garde SSRF renvoie un booléen, il ne
  lève plus) : un socket.timeout DNS ne bascule plus fetch_og_metadata dans
  son except Exception → repli silencieux → AssertionError.
- fixture stub_dns sur TestOGParser (même motif que test_agent_web_tools) :
  zéro vrai DNS dans les tests OG.

Tests: pytest tests/ -q -n auto → 1390 passed / 0 failed (flake corrigé) ;
ruff check app tests → OK ; e2e v764 (réactions dans le tiroir + suppression
A/B), v762 (nouvelle ligne 4), v766, v7641, v763 → 6 passed sur :8081
(image flowdeck:v7.67.0 rebuild + redéployée).
2026-10-08 14:25:48 -04:00
bruno db6bf03a00 fix: icône du bouton « commenter » = bulle Feather + (message-square) (v7.66.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Chemin SVG dessiné à la main remplacé par le message-square éprouvé de Feather
(viewBox 24) + un « + » centré dans la bulle — même famille de traits que les
autres icônes outline de l'éditeur. Le test statique suit.

Gates: pytest tests/ -q -n auto → 1386 passed (1 flake connu et non lié
test_v55::TestOGParser::test_fetch_og_metadata_success : échoue parfois sous
-xdist, passe seul et passe au run complet final) ; ruff OK ;
e2e v766 (2) + v764 (1) → 3 passed sur :8081 (image flowdeck:v7.66.0
rebuild + redéployée).
2026-10-08 13:09:22 -04:00
bruno c031bf67de chore: ne pas versionner commit_msg.txt (fichier de message ajouté par erreur en v7.66.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Ajouté à .gitignore + retiré de l'index ; le message de commit de v7.66.0
(ab69777) l'avait embarqué via `git add -A`.
2026-10-08 12:47:33 -04:00
bruno ab6977733d feat: Escape du menu slash retire le « / » + bouton « commenter » à droite du bloc (v7.66.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- SM.escClose() : un seul implément, appelé par les deux chemins de fermeture
  du menu slash (focus dans le bloc → onKd, focus dans l'entrée du menu →
  listener document). Retire le « / » d'ouverture seulement, remet le caret,
  sync() + autoSave().
- Bouton .block-comment-btn à droite du bloc (gouttière 64px) : icône bulle de
  conversation + , visible au survol/focus comme le handle, masqué sur mobile
  (2 breakpoints) et sur page verrouillée ; onclick = E.commentBlock(idx).
  Remplace l'ancien .block-actions de renderBlock (opacity:0 sans règle de
  survol → un + invisible qui capait les clics dans la gouttière).
- fmtComment() restaure la plage de la toolbar (_fmtRestore) avant d'ancrer :
  commentBlock() ne se replie plus sur this._sel (plage périmée éventuelle).

Tests: pytest tests/ -q -n auto → 1386 passed ; ruff check app tests → OK ;
e2e v766 (Esc → menu fermé + bloc vide ; bouton masqué → visible au survol,
à droite du wrapper, ancre = bloc visé) + v764/v762/v7641/v763/
regression_editor_mount → 7 passed sur :8081.
2026-10-08 12:47:00 -04:00
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
20 changed files with 804 additions and 69 deletions
+1
View File
@@ -23,6 +23,7 @@ uv.lock
*.db-*
test-commit.md
upload_test.txt
commit_msg.txt
e2e/node_modules/
e2e/shots/
e2e/test-results/
+138
View File
@@ -1,5 +1,143 @@
# Changelog - FlowDeck
## v7.67.0 (2026-10-08) — Réactions dans le tiroir Comments (+ suppression) et refonte du menu de sélection
### Added
- **Les réactions vivent dans le tiroir « Comments »** : section **Reactions**
au-dessus des fils — émoji, compte (si >1) et **texte ancré** lu dans le DOM
(`reactionList()` / `_anchorText()`, offsets source hors pastilles). L'état
vide « No comments yet » ne s'affiche plus s'il reste des réactions.
- **Suppression d'une réaction — deux entrées, un seul chemin** :
- **✕** sur la ligne de la section Reactions (visible uniquement sur **mes**
réactions) ;
- au **survol de la pastille**, l'émoji que j'ai posé devient une **croix**
(`::after`, aucun nœud ajouté → offsets source inchangés), un clic retire.
- les deux appellent `removeReaction()` → `POST /reactions` (toggle) →
`loadReactions()` + repeindre les ancres.
### Changed
- **Refonte du menu de sélection façon capture** :
- ligne 2 `[A] B I U T Tx` (le barré est conservé — absent de la maquette,
décision volontaire pour ne pas perdre la fonction) ;
- ligne 3 `🔗 S </> √x …` (la réaction en est sortie) ;
- **ligne 4 = une rangée scannée** : `💬 Comment` (libellé) à gauche,
`🙂+` réaction à droite (`.ft-row-split`) ;
- entête **Skills** avec `≡` à droite (`.ft-skills-head::after`), liste
**scrollable** (`.ft-skills`, `max-height` + `overflow-y`) ;
- **✨ Ask AI retiré** de la toolbar : doublon exact de « Edit with AI » du
pied (même `fmtAskAI()`).
### Fixed
- **Flake de gate corrigé (racine)** : `test_v55.py::TestOGParser::
test_fetch_og_metadata_success` dépendait d'un **vrai** `socket.getaddrinfo
("example.com")` via le garde SSRF `_is_public_host` ; sous `pytest -n auto`
un `socket.timeout` (non couvert par `except socket.gaierror`) remontait
dans le `except Exception` de `fetch_og_metadata` → repli silencieux
(`title='example.com'`) → AssertionError, 3 fois sur 5 runs.
- `_is_public_host` catch désormais **`OSError`** : le garde renvoie un
booléen, il ne lève jamais (les autres appelants gagnent aussi) ;
- fixture `stub_dns` sur `TestOGParser` (même motif que
`test_agent_web_tools.stub_dns`) : zéro DNS dans les tests OG.
### Tests
- `tests/test_v767_reactions_drawer.py` (3) ; `tests/test_v762_selection_toolbar.py`
(ligne 4 + styles) ; `tests/test_v764_comments_reactions.py` (position de
`fmtReact`).
- **e2e** : `v764_comments_reactions` étendu — section Reactions dans le tiroir
(texte ancré), **suppression A** (✕ du tiroir → pastille disparue),
**suppression B** (croix `::after` visible seulement au survol, puis clic) ;
`v762_selection_toolbar` — `Comment` + `ft-row-split .ft-react`,
`ft-skills-head` = « Skills », plus de bouton ✨.
- `pytest tests/ -q -n auto` → **1390 passed, 0 failed** (flake corrigé),
`ruff check app tests` → OK.
## v7.66.0 (2026-10-08) — Escape du menu slash retire le « / » + bouton « commenter » à droite du bloc
### Added
- **Bouton « commenter ce bloc » à droite du bloc** (`.block-comment-btn`,
gouttière 64px, visible au survol/focus comme le handle) : icône **bulle de
conversation avec un +**, `onclick="E.commentBlock(idx)"` → tiroir de
commentaires ancré sur le bloc (ou sur la sélection vivante s'il y en a une).
Icône = tracé `message-square` (Feather) + `+` centré.
Masqué sur mobile (2 breakpoints, comme le handle) et sur page verrouillée.
- remplace l'ancien `.block-actions` de `renderBlock` : rendu en `opacity:0`
**sans règle de survol** (jamais de classe `.visible`) → un `+` invisible
qui capait les clics dans la gouttière droite.
### Fixed
- **Escape ferme le menu slash ET retire le « / »** : nouvelle `SM.escClose()`
appelée par les **deux** chemins de fermeture (focus dans le bloc → `onKd`,
focus dans l'entrée du menu → listener `document`). Ne touche au caractère
que si c'est le `/` d'ouverture, remet le caret et sauvegarde (`sync` +
`autoSave`).
- **`fmtComment()` restaure la sélection de la toolbar** (`_fmtRestore()`)
avant d'ancrer : `commentBlock()` ne se replie plus sur `this._sel` (plage
potentiellement périmée reprise par le bouton de droite / le menu de bloc).
### Tests
- **e2e** `e2e/v766_slash_esc_comment_btn.spec.js` : « / » → menu visible →
Escape → menu fermé et bloc vide ; bouton présent, `opacity` 0 → 1 au
survol, **à droite** du wrapper (boîte mesurée), clic → tiroir → ancre = le
bloc entier.
- `tests/test_v766_slash_esc_comment_btn.py` (3) ; `pytest tests/ -q -n auto`
→ **1386 passed**, `ruff check app tests` → OK.
## 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
+64
View File
@@ -1459,6 +1459,70 @@ Détails livrés :
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
### v7.66.0 — Escape du menu slash + bouton commenter à droite ✅ (2026-10-08)
> **Objectif** : Esc efface le `/` qui a ouvert le menu slash, et chaque bloc
> gagne un bouton de commentaire à droite. **COMPLETED**.
- [x] **`SM.escClose()`** — un seul implément, appelé par les deux chemins
(focus dans le bloc → `onKd`, focus dans l'entrée du menu → listener
`document`) ; retire le `/` d'ouverture seulement, remet le caret, `sync` +
`autoSave`
- [x] **Bouton à droite du bloc** — `.block-comment-btn` (bulle + `+`,
`E.commentBlock(idx)`), gouttière 64px, visible au survol, masqué mobile +
page verrouillée ; remplace l'ancien `.block-actions` mort (opacity:0 sans
survol → `+` invisible qui capait les clics)
- [x] **`fmtComment()`** restaure la plage de la toolbar avant d'ancrer ;
`commentBlock()` ne se replie plus sur `this._sel` périmé
- [x] **Tests** — e2e `e2e/v766_slash_esc_comment_btn.spec.js` (Esc → menu
fermé + bloc vide ; bouton à droite mesuré, ancre = bloc entier),
`tests/test_v766_slash_esc_comment_btn.py` (3) ; `pytest` **1386 passed**,
`ruff` OK ; `VERSION` + `app/main.py` = 7.66.0
### v7.67.0 — Réactions dans le tiroir + suppression + refonte du menu de sélection ✅ (2026-10-08)
> **Objectif** : une réaction s'ajoute/supprime depuis le tiroir Comments, et
> le menu de sélection correspond à la maquette ASCII fournie. **COMPLETED**.
- [x] **Section « Reactions » du tiroir** — `reactionList()` + `_anchorText()`
(texte de la plage lu dans le DOM, hors pastilles), compte si >1, état vide
adapté ; `removeReaction()` = POST toggle
- [x] **Suppression** — ✕ dans le tiroir (mes réactions seulement) **et**
croix `::after` au survol de la pastille sur l'émoji que j'ai posé (aucun
nœud ajouté → offsets source intacts)
- [x] **Refonte du menu** — ligne 4 scannée `💬 Comment | 🙂 réaction`,
réaction sortie de la ligne 3, entête `Skills ≡`, liste Skills scrollable,
`✨ Ask AI` retiré (doublon exact du pied)
- écart volontaire à la maquette : le **barré (T)** de la ligne 2 est
conservé (il n'y figure pas) pour ne pas perdre la fonction
- [x] **Flake de gate corrigé** — `_is_public_host` attrape `OSError` (le
garde SSRF ne lève plus) + fixture `stub_dns` sur `TestOGParser`
(le test faisait un vrai `getaddrinfo("example.com")`, échec sous `-n auto`)
- [x] **Tests** — `tests/test_v767_reactions_drawer.py` (3), e2e v764 étendu
(réactions dans le tiroir + suppression A/B), e2e v762 (nouvelle ligne 4) ;
`pytest` **1390 passed / 0 failed**, `ruff` OK ; `VERSION` +
`app/main.py` = 7.67.0
---
+1 -1
View File
@@ -1 +1 @@
7.64.0
7.67.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.64.0 (commentaires façon Notion : ancres jaunes, tiroir refondu, réactions emoji sur texte) | **Statut**: EN COURS 🔄
> **Début**: 2026-07-08 | **Version**: v7.67.0 (réactions dans le tiroir Comments + suppression, refonte du menu de sélection) | **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.64.0",
version="7.67.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+4 -1
View File
@@ -26,7 +26,10 @@ def _is_public_host(host: str) -> bool:
return False
try:
infos = socket.getaddrinfo(host, None)
except socket.gaierror:
except OSError:
# gaierror, socket.timeout, herror… : le garde-fou doit TOUJOURS
# renvoyer un booléen, jamais lever — sinon l'appelant tombe dans son
# `except Exception` (bookmarks) au lieu d'un refus explicite.
return False
for info in infos:
address = info[4][0]
+22 -16
View File
@@ -578,15 +578,6 @@
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 · 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:340px;max-width:92vw;
@@ -599,7 +590,20 @@
<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;">
<div x-show="comments.length === 0" style="text-align:center;color:var(--text-dim,#999);padding:32px 0;">
<!-- v7.67 : les réactions emoji du texte vivent aussi ici (✕ = retirer) -->
<div class="ct-reactions" x-show="reactionList().length" x-cloak style="margin-bottom:14px;">
<div class="ct-section-title">Reactions</div>
<template x-for="r in reactionList()" :key="r.key">
<div class="ct-reaction">
<span class="ct-re-emoji" x-text="r.emoji"></span>
<span class="ct-re-count" x-show="r.count > 1" x-text="r.count"></span>
<span class="ct-re-text" x-text="r.text"></span>
<button type="button" class="ct-act" title="Remove reaction"
x-show="r.mine" @click="removeReaction(r)">✕</button>
</div>
</template>
</div>
<div x-show="comments.length === 0 && !reactionList().length" style="text-align:center;color:var(--text-dim,#999);padding:32px 0;">
No comments yet. Select some text and click 💬 Comment.
</div>
<template x-for="c in comments" :key="c.id">
@@ -675,23 +679,25 @@
<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>
<!-- Ligne 4 : Comment à gauche, réaction à droite (le ✨ Ask AI sauté :
doublon exact du bouton du pied, même action) -->
<div class="ft-row ft-row-split">
<button type="button" class="ft-item ft-comment" @click="fmtComment()" data-tooltip="Comment — Ctrl+⇧+M">{{ fd_icon("message-square",15) }}<span>Comment</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>
</div>
<!-- Section « Skills » -->
<div class="ft-sep"></div>
<div class="ft-group">Skills</div>
<div class="ft-group ft-skills-head">Skills</div>
<div class="ft-skills">
<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>
</div>
<!-- Pied de page -->
<div class="ft-sep"></div>
<button type="button" class="ft-item ft-foot" @click="fmtEditAI()" data-tooltip="Edit with AI">
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.64.0"
"version": "7.67.0"
},
"paths": {
"/auth/register": {
+6
View File
@@ -70,6 +70,12 @@ test('toolbar de sélection : structure, sous-menus, persistance', async ({ page
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');
// refonte v7.67 : ligne 4 = Comment (libellé) à gauche, réaction à droite ;
// entête Skills avec ≡ ; plus de bouton ✨ Ask AI (doublon du pied)
await expect(bar.locator('.ft-comment')).toContainText('Comment');
await expect(bar.locator('.ft-row-split .ft-react')).toHaveCount(1);
await expect(bar.locator('.ft-skills-head')).toHaveText('Skills');
await expect(bar.locator('[data-tooltip^="Ask AI"]')).toHaveCount(0);
// géométrie : sous la sélection, entièrement dans le viewport
const barBox = await bar.boundingBox();
const selBox = await page.evaluate(() => {
+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');
});
+61 -4
View File
@@ -54,6 +54,30 @@ async function newPage(page, body) {
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(() => {
@@ -100,10 +124,10 @@ test('réaction emoji + commentaires ancrés', async ({ page }) => {
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 : bouton 💬 → tiroir refondu → envoi ──
await selectBlock(page);
await expect(page.locator('#_commentSelBtn')).toBeVisible();
await page.locator('#_commentSelBtn').click();
// ── 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');
@@ -115,7 +139,9 @@ test('réaction emoji + commentaires ancrés', async ({ page }) => {
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) ──
@@ -140,5 +166,36 @@ test('réaction emoji + commentaires ancrés', async ({ page }) => {
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 });
// ── 9. v7.67 : la réaction vit aussi dans le tiroir Comments ──
await page.locator('.topbar-btn[title="Comments"]').click();
const re = page.locator('.ct-reactions');
await expect(re).toBeVisible();
await expect(re.locator('.ct-re-emoji')).toHaveCount(1);
await expect(re.locator('.ct-re-text')).toHaveText('Prise de reaction a un texte');
await page.screenshot({ path: 'shots/v764-drawer-reactions.png' });
// ── 10. suppression A : ✕ de la section Réactions ──
await re.locator('.ct-reaction .ct-act').click();
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(0);
await expect(re).toBeHidden();
// ── 11. suppression B : la croix survolée de la pastille ──
await page.locator('.topbar-btn[title="Comments"]').click(); // referme le tiroir
await selectBlock(page);
await page.locator('.format-toolbar .ft-react').click();
const picker2 = page.locator('.fd-icon-picker');
await expect(picker2).toBeVisible();
await picker2.locator('.fdip-emoji').first().click();
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1);
const emo = page.locator('#_blocksCt .fd-react-emo.is-mine');
await expect(emo).toHaveCount(1);
// la croix n'apparaît qu'au survol (::after)
await expect.poll(() => emo.evaluate((el) => getComputedStyle(el, '::after').display)).toBe('none');
await page.locator('#_blocksCt .fd-react-chip').hover();
await expect.poll(() => emo.evaluate((el) => getComputedStyle(el, '::after').display)).toBe('flex');
await emo.click();
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(0);
});
+99
View File
@@ -0,0 +1,99 @@
const { test, expect } = require('@playwright/test');
/**
* v7.66 — deux ajustements éditeur :
* - « / » ouvre le menu slash → Escape ferme le menu ET retire le « / »
* - survol d'un bloc → bouton à droite (bulle + ) qui commente le bloc
* 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=v766 ${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: 'v766', 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;
}
test('Escape du menu slash retire le caractère /', async ({ page }) => {
await login(page);
await newPage(page, [{ id: 'b1', type: 'paragraph', content: '' }]);
const blk = page.locator('#_blocksCt [data-bid]').first();
await blk.click();
await page.keyboard.type('/');
await expect(page.locator('#_slashMenu')).toBeVisible({ timeout: 5000 });
await page.keyboard.press('Escape');
await expect(page.locator('#_slashMenu')).toBeHidden();
// le « / » a été retiré — pas de caractère résiduel
await expect(blk).toHaveText('');
await page.screenshot({ path: 'shots/v766-slash-esc.png' });
});
test('bouton commenter à droite du bloc', async ({ page }) => {
await login(page);
// 2 blocs : l'éditeur focalise le 1er au chargement (`:focus-within` le
// montre) → on mesure sur le 2e, ni focalisé ni survolé.
await newPage(page, [
{ id: 'b1', type: 'paragraph', content: 'Premier bloc' },
{ id: 'b2', type: 'paragraph', content: 'Un bloc a commenter' },
]);
await expect(page.locator('#_blocksCt .block-comment-btn')).toHaveCount(2);
const wrap = page.locator('#_blocksCt .block-wrapper').nth(1);
const btn = page.locator('#_blocksCt .block-comment-btn').nth(1);
// masqué tant qu'on ne survole pas / ne focalise pas le bloc
await expect.poll(() => btn.evaluate((el) => getComputedStyle(el).opacity)).toBe('0');
await wrap.hover();
await expect.poll(() => btn.evaluate((el) => getComputedStyle(el).opacity)).toBe('1');
// bien à droite du bloc (gouttière 64px)
const wb = await wrap.boundingBox();
const bb = await btn.boundingBox();
expect(bb.x).toBeGreaterThan(wb.x + wb.width - 1);
await page.screenshot({ path: 'shots/v766-comment-btn.png' });
// clic → tiroir → envoi → ancre = le bloc visé (et pas le 1er)
await btn.click();
const drawer = page.locator('.comments-drawer');
await expect(drawer).toBeVisible();
await drawer.locator('textarea').fill('Commentaire via le bouton de droite');
await drawer.locator('.ct-send').click();
await expect(page.locator('.comment-thread')).toHaveCount(1);
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveText('Un bloc a commenter');
});
+109
View File
@@ -2371,6 +2371,35 @@ button.breadcrumb-current { max-width: 260px; }
color: var(--text-primary);
}
/* ── v7.66 : bouton « commenter ce bloc » — à droite du bloc (gouttière 64px),
bulle de conversation + , visible au survol comme le handle ── */
.block-comment-btn {
position: absolute;
right: -60px;
top: 4px;
width: 22px;
height: 22px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: 3px;
color: var(--text-dim);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease;
}
.block-wrapper:hover .block-comment-btn,
.block-wrapper:focus-within .block-comment-btn {
opacity: 1;
}
.block-comment-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* ── v5.10.0: selection multiple + menu contexte bloc ── */
.block-wrapper.selected {
background: rgba(35,131,226,0.06);
@@ -3011,6 +3040,30 @@ button.breadcrumb-current { max-width: 260px; }
letter-spacing: 1px;
}
/* ── v7.67 : refonte façon capture — ligne Comment | réaction aux 2 extrémités,
entête « Skills » avec ≡ à droite, liste Skills scrollable ── */
.format-toolbar .ft-row-split {
justify-content: space-between;
gap: 4px;
}
.format-toolbar .ft-comment {
justify-content: flex-start;
}
.format-toolbar .ft-skills-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.format-toolbar .ft-skills-head::after {
content: '≡';
font-size: 13px;
color: var(--text-dim);
}
.format-toolbar .ft-skills {
max-height: 132px;
overflow-y: auto;
}
/* Séparateurs, groupe « Skills », pied de page */
.format-toolbar .ft-sep {
height: 1px;
@@ -3183,6 +3236,28 @@ button.breadcrumb-current { max-width: 260px; }
.fd-react-chip img { width: 12px; height: 12px; object-fit: contain; }
.fd-react-n { font-size: 10px; color: var(--text-dim); }
/* v7.67 : suppression d'une réaction — au survol de la pastille, l'émoji que
J'ai posé devient une croix (un seul clic = retirer). Pas de reflow : c'est
un ::after, les offsets source ne bougent pas. */
.fd-react-emo { position: relative; display: inline-flex; align-items: center; }
.fd-react-emo.is-mine::after {
content: '✕';
position: absolute;
inset: -3px;
display: none;
align-items: center;
justify-content: center;
font-size: 9px;
line-height: 1;
color: var(--text-dim);
background: var(--bg-modal, #1f1f1f);
border: 1px solid var(--border, #333);
border-radius: 50%;
cursor: pointer;
}
.fd-react-chip:hover .fd-react-emo.is-mine::after { display: flex; }
.fd-react-emo.is-mine:hover::after { color: #e5484d; border-color: #e5484d; }
/* Tiroir de commentaires (fil + saisie) */
.comment-thread {
position: relative;
@@ -3222,6 +3297,37 @@ button.breadcrumb-current { max-width: 260px; }
.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; }
/* v7.67 : section Réactions du tiroir (liste des plages réagies + retirer) */
.ct-section-title {
font-size: 11px;
font-weight: 600;
letter-spacing: .5px;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 8px;
}
.ct-reaction {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
margin-bottom: 6px;
background: var(--bg-secondary, #2a2a2a);
border: 1px solid var(--border, #333);
border-radius: 8px;
font-size: 13px;
}
.ct-re-emoji { font-size: 14px; line-height: 1; flex-shrink: 0; }
.ct-re-count { font-size: 11px; color: var(--text-dim); }
.ct-re-text {
flex: 1;
min-width: 0;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.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 {
@@ -3288,6 +3394,7 @@ button.breadcrumb-current { max-width: 260px; }
.block-handle { display: none; }
.block-insert-btn { display: none; }
.block-actions { display: none; }
.block-comment-btn { display: none; }
.editor-statusbar { left: 0; }
}
@@ -3855,6 +3962,7 @@ button.breadcrumb-current { max-width: 260px; }
.block-handle { display: none; }
.block-insert-btn { display: none; }
.block-actions { display: none; }
.block-comment-btn { display: none; }
.page-title-block::before { display: none; }
.page-title-block .page-icon-emoji { margin-left: 0; }
.page-title-block .page-title-input { padding-left: 0; }
@@ -4998,6 +5106,7 @@ a.fd-wiki-link:hover { border-bottom-color: var(--accent, #2383e2); }
font-weight: 500;
}
body.page-locked .block-handle,
body.page-locked .block-comment-btn,
body.page-locked .block-add-btn,
body.page-locked .format-toolbar,
body.page-locked .get-started-toolbar { display: none !important; }
+89 -40
View File
@@ -463,6 +463,27 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
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';MTB.close();},
// v7.66 : Escape ferme le menu ET retire le « / » qui l'a ouvert.
escClose(){
const i=SM._i;SM.close();
const E=window.E;if(!E||i<0||i>=E.blocks.length)return;
const el=E.getEl(E.blocks[i].id);if(!el)return;
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 v=n.nodeValue,at=v.indexOf('/');
if(at>=0){
if(v.slice(0,at).trim())return; // pas le / d'ouverture → on ne touche à rien
n.nodeValue=v.slice(0,at)+v.slice(at+1);
E.sync();E.dirty=true;E.autoSave();
el.focus();
const r=document.createRange();r.setStart(n,Math.min(at,n.nodeValue.length));r.collapse(true);
const s=window.getSelection();s.removeAllRanges();s.addRange(r);
return;
}
if(v.trim())return; // 1er texte non vide sans / → rien à retirer
}
},
// 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>
@@ -476,7 +497,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
_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();}
else if(e.key==='Enter'){e.preventDefault();const c=SM._it[SM._s];if(c)SM.sel(c.id);}
else if(e.key==='Escape'){e.preventDefault();SM.close();}},
else if(e.key==='Escape'){e.preventDefault();SM.escClose();}},
_rs(){const its=SM._li.querySelectorAll('.slash-item');its.forEach((el,i)=>{el.classList.toggle('selected',i===SM._s);});const cur=its[SM._s];if(cur&&cur.scrollIntoView)cur.scrollIntoView({block:'nearest'});},
sel(id){if(window.E)window.E.applySlash(id);SM.close();},
get isOpen(){return SM._o;}};
@@ -789,14 +810,17 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
let s=idx; while(s>0&&bs[s-1]&&bs[s-1].type==='numbered_list')s--;
numAttr=` data-num="${idx-s+1}"`;
}
const actions=idx>=0?`<div class="block-actions"><button class="block-add-btn" onclick="E.addAfter(${idx})">+</button></div>`:'';
// v7.66 : bouton « commenter ce bloc » à droite du bloc (gouttière 64px),
// icône bulle de conversation + . Remplace l'ancien .block-actions mort
// (opacity:0 sans règle de survol → un + invisible qui capait les clics).
const cmtBtn=idx>=0?`<button type="button" class="block-comment-btn" title="Comment on block" aria-label="Comment on block" onclick="E.commentBlock(${idx})"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><path d="M12 8v5M9.5 10.5h5"/></svg></button>`:'';
// + « ajouter un bloc dessous », juste avant le handle (gouttière 64px)
const insertBtn=idx>=0?`<button type="button" class="block-insert-btn" title="Add block below" aria-label="Add block below" onclick="E.addAfter(${idx})">+</button>`:'';
return `<div class="block-wrapper" data-id="${b.id}">
${insertBtn}
<div class="block-handle"><svg viewBox="0 0 16 16" width="14" height="14"><circle cx="4" cy="3" r="1.3"/><circle cx="11" cy="3" r="1.3"/><circle cx="4" cy="8" r="1.3"/><circle cx="11" cy="8" r="1.3"/><circle cx="4" cy="13" r="1.3"/><circle cx="11" cy="13" r="1.3"/></svg></div>
${actions}
<${tag}${style}${bidAttr}${numAttr} class="${cls}">${inner}</${tag}>
${cmtBtn}
</div>`;
}
@@ -1865,11 +1889,21 @@ 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 vivante dans ce bloc, sinon tout
// le bloc. La toolbar de sélection restaure sa plage AVANT (fmtComment)
// — pas de repli sur `this._sel` ici : sinon le bouton de droite / le
// menu de bloc reprendraient une sélection périmée.
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){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();
},
// v7.62 : renommé — « askAI() » (sans argument, plus bas) l'écrasait,
@@ -2373,7 +2407,7 @@ applyAIBlocks(text){
const ab=this.getActiveBlock();if(!ab)return;
const{el,bid,idx}=ab;const block=this.blocks[idx];if(!block)return;
if(SM.isOpen){
if(e.key==='Escape'){e.preventDefault();SM.close();return;}
if(e.key==='Escape'){e.preventDefault();SM.escClose();return;}
if(e.key==='ArrowDown'||e.key==='ArrowUp'||e.key==='Enter'){e.preventDefault();return;}
if(e.key==='Backspace'&&!SM._q&&(el.textContent||'').trim()==='/'){e.preventDefault();SM.close();return;}
// La requête peut être tapée DANS le bloc (« /numbered list ») si le
@@ -2417,7 +2451,10 @@ 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;}
// 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;}
@@ -2578,7 +2615,7 @@ applyAIBlocks(text){
// « … » → 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);},
fmtComment(){this.fmt.open=false;const i=this.fmt.idx;if(i<0)return;this._fmtRestore();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);},
@@ -2604,7 +2641,7 @@ applyAIBlocks(text){
});
},
// ── v4.9.0: Collaboration — comments & mentions ──
toggleComments(){this.commentsOpen=!this.commentsOpen;if(this.commentsOpen)this.loadComments();},
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;}
@@ -2627,7 +2664,7 @@ 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');}
},
@@ -2658,6 +2695,30 @@ applyAIBlocks(text){
else this.showToast((d&&d.detail)||'Réaction impossible','error');
}catch(e){this.showToast('Réaction impossible','error');}
},
// ── v7.67 : les réactions vivent aussi dans le tiroir Comments ──
// (une ligne par plage × émoji, déjà groupées par l'API)
reactionList(){
return (this.reactions||[]).map(r=>{
const bid=r.block_id,start=parseInt(r.anchor_start,10),end=parseInt(r.anchor_end,10);
return {key:bid+':'+start+':'+end+':'+r.emoji,bid:bid,start:start,end:end,emoji:r.emoji,
count:parseInt(r.count,10)||1,mine:!!parseInt(r.mine,10),
text:this._anchorText(bid,start,end)};
});
},
// suppression : POST toggle (le backend retire MA ligne si elle existe)
removeReaction(r){if(!r)return;this.toggleReaction(r.bid,r.start,r.end,r.emoji);},
// texte source d'une plage ancrée (hors pastilles .fd-react-chip)
_anchorText(bid,start,end){
const el=this.getEl(bid);if(!el||!(end>start))return '';
const w=document.createTreeWalker(el,NodeFilter.SHOW_TEXT);let n,pos=0,txt='';
while((n=w.nextNode())){
if(n.parentElement&&n.parentElement.closest('.fd-react-chip'))continue;
const len=n.nodeValue.length,a=Math.max(start,pos),b=Math.min(end,pos+len);
if(b>a)txt+=n.nodeValue.slice(a-pos,b-pos);
pos+=len;if(pos>=end)break;
}
return txt;
},
openThread(cid){
this.commentsOpen=true;this.commentMenu=null;
const self=this;
@@ -2714,39 +2775,22 @@ applyAIBlocks(text){
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('')
// chaque émoji porte son data-emoji ; `is-mine` = j'ai réagi →
// la croix de suppression (CSS ::after au survol) ne s'affiche
// que là, un clic sur les autres rouvre le sélecteur.
chip.innerHTML=g.items.map(function(i){
const q=String(i.emoji||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;');
return '<span class="fd-react-emo'+(parseInt(i.mine,10)?' is-mine':'')+'" data-emoji="'+q+'">'+_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;
// ancre = nœud texte ou élément (comme showFmt depuis v7.62)
const anEl=s.anchorNode?(s.anchorNode.nodeType===1?s.anchorNode:s.anchorNode.parentElement):null;
const bel=anEl?anEl.closest('[data-bid]'):null;
if(!bel)return;
const bid=bel.dataset.bid;
const span0=this._selSpan(bel,s.getRangeAt(0));
const start=span0.start;
const end=span0.end;
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){
@@ -3071,7 +3115,7 @@ applyAIBlocks(text){
};
}
// v7.62 : data Alpine du wrapper éditeur — Alpine v3 n'expose plus __x
// (l'ancien accès rendait muets showFmt/hideCommentBtn/mentions).
// (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 ;
@@ -3084,6 +3128,12 @@ applyAIBlocks(text){
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);
// v7.67 : clic sur la croix d'un émoji à moi → retire MA réaction
const emo=t.closest('.fd-react-emo');
if(emo&&emo.classList.contains('is-mine')){
d.removeReaction({bid:bid,start:start,end:end,emoji:emo.getAttribute('data-emoji')});
return;
}
const el=d.getEl(bid);const rect=chip.getBoundingClientRect();
if(!window.FDIconPicker||!el||isNaN(start)||isNaN(end))return;
window.FDIconPicker.openFor({
@@ -3111,9 +3161,8 @@ applyAIBlocks(text){
if(!ct?.contains(s.anchorNode)){d.fmt.open=false;d._sel=null;return;}
const bel0=s.anchorNode&&(s.anchorNode.nodeType===1?s.anchorNode:s.anchorNode.parentElement);
const bel=bel0?bel0.closest('[data-bid]'):null;
if(bel){const idx=d.getIdx(bel.dataset.bid);if(idx>=0)d.showFmt(idx);d.showCommentBtn();}else{d.hideCommentBtn();}
if(bel){const idx=d.getIdx(bel.dataset.bid);if(idx>=0)d.showFmt(idx);}
},80);});
document.addEventListener('mousedown',()=>{setTimeout(()=>{const d=_xed(document.querySelector('.page-editor-wrapper'));if(d)d.hideCommentBtn();},10);});
document.addEventListener('keyup',(e)=>{
const d=_xed(document.querySelector('.page-editor-wrapper'));if(!d)return;
if(!d.mentionOpen){if(e.key!=='@')return;}
+22
View File
@@ -10,8 +10,10 @@ Covers the five roadmap points:
from __future__ import annotations
import asyncio
import ipaddress
import json
import secrets
import socket
from pathlib import Path
import httpx
@@ -209,7 +211,27 @@ _OG_HTML = """<!doctype html><html><head>
</head><body>hi</body></html>"""
#: IP publique factice — TestOGParser ne doit dépendre ni du réseau HTTP
#: (MockTransport) ni du DNS : `_is_public_host` résout l'hôte pour décider si
#: l'IP est publique, et cette résolution échoue sous `pytest -n auto` (même
#: piège que test_agent_web_tools.stub_dns — d'où un flake récurrent ici).
PUBLIC_IP = "93.184.216.34"
class TestOGParser:
@pytest.fixture(autouse=True)
def stub_dns(self, monkeypatch):
real = socket.getaddrinfo
def fake_getaddrinfo(host, *args, **kwargs):
try:
ipaddress.ip_address(host)
except ValueError:
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (PUBLIC_IP, 0))]
return real(host, *args, **kwargs)
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
def test_parse_og_handles_attribute_order(self):
from app.services.og_fetcher import parse_og
+18 -2
View File
@@ -29,7 +29,7 @@ def test_toolbar_markup_matches_spec():
"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 : 💬 ✨
"fmtComment()", "fmtReact()", # ligne 4 : 💬 Comment | 🙂 réaction
"ft-group", "Improve writing", "Proofread", "Explain", "Reformat",
"Edit with AI", "Alt+⇧+E", # pied de page
]
@@ -41,7 +41,23 @@ def test_toolbar_markup_matches_spec():
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
assert 'fd_icon("link",15)' in chunk
def test_toolbar_row4_matches_reference_layout():
"""v7.67 — ligne Comment à gauche, réaction à droite, Skills avec ≡."""
chunk = _slice(TPL, 'class="format-toolbar"', "Copy Link Toast")
# ligne 4 = une seule rangée scannée, boutons aux 2 extrémités
assert 'ft-row-split' in chunk
assert '<span>Comment</span>' in chunk # libellé, pas juste l'icône
assert chunk.index("fmtComment()") < chunk.index("fmtReact()")
# l'✨ Ask AI a sauté : doublon exact du « Edit with AI » du pied
assert '"fmtAskAI()"' not in chunk
# entête Skills (≡ en CSS) + liste scrollable
assert 'ft-skills-head' in chunk and 'ft-skills' in chunk
for sel in (".format-toolbar .ft-row-split {", ".format-toolbar .ft-comment {",
".format-toolbar .ft-skills-head::after {", ".format-toolbar .ft-skills {"):
assert sel in CSS, sel
# ── Actions : câblées vers de vraies méthodes, sous-menus réutilisés ────────
+2 -2
View File
@@ -71,9 +71,9 @@ def test_toggle_reaction_api(client):
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
# position (v7.67) : 4ᵉ ligne, à droite de « Comment », après les outils
chunk = TPL[TPL.index('class="format-toolbar"'):TPL.index("Copy Link Toast")]
order = ["fmtHighlight()", "fmtReact()", "fmtCodeBlock()", "fmtMore($event)"]
order = ["fmtMore($event)", "fmtComment()", "fmtReact()", "ft-group"]
positions = [chunk.index(o) for o in order]
assert positions == sorted(positions), positions
+41
View File
@@ -0,0 +1,41 @@
"""7.66.0 — Escape du menu slash retire le « / » + bouton commenter à droite du bloc."""
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
EDITOR_JS = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
APP_CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
def test_slash_escape_removes_the_slash():
# une seule implémentation, appelée par les deux chemins (focus dans le bloc
# → onKd ; focus dans l'entrée du menu → listener document)
assert "escClose(){" in EDITOR_JS
assert EDITOR_JS.count("SM.escClose();") == 2
# ne retire que le « / » d'ouverture, remet le caret et sauvegarde
assert "n.nodeValue=v.slice(0,at)+v.slice(at+1);" in EDITOR_JS
assert "E.sync();E.dirty=true;E.autoSave();" in EDITOR_JS
def test_comment_button_on_the_right_of_the_block():
# émis APRÈS le contenu du bloc → à droite dans le flex du wrapper
i_tpl = EDITOR_JS.index("const cmtBtn=")
i_content = EDITOR_JS.index('class="${cls}">${inner}</${tag}>', i_tpl)
i_btn = EDITOR_JS.index("${cmtBtn}", i_tpl)
assert i_content < i_btn
# icône bulle de conversation +, ancre = commentaire du bloc
assert 'onclick="E.commentBlock(${idx})"' in EDITOR_JS
assert EDITOR_JS.count('<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5') == 1 # bulle (message-square)
assert '<path d="M12 8v5M9.5 10.5h5"/>' in EDITOR_JS # le + dans la bulle
# l'ancien .block-actions mort (opacity:0 sans survol) a disparu de renderBlock
assert "const actions=idx>=0?" not in EDITOR_JS
def test_comment_button_style_and_visibility():
assert ".block-comment-btn {" in APP_CSS
assert "right: -60px;" in APP_CSS # gouttière 64px, comme .block-actions
assert ".block-wrapper:hover .block-comment-btn," in APP_CSS
assert ".block-comment-btn:hover {" in APP_CSS
# masqué partout où le handle l'est : mobile (2 breakpoints) + page verrouillée
assert APP_CSS.count(".block-comment-btn { display: none; }") == 2
assert "body.page-locked .block-comment-btn," in APP_CSS
+44
View File
@@ -0,0 +1,44 @@
"""7.67.0 — réactions emoji visibles dans le tiroir Comments + suppression."""
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")
def test_drawer_lists_reactions():
# section « Reactions » du tiroir : une ligne par plage × émoji
assert 'class="ct-reactions"' in TPL
assert 'x-for="r in reactionList()"' in TPL
assert '<span class="ct-re-emoji" x-text="r.emoji"></span>' in TPL
assert '<span class="ct-re-text" x-text="r.text"></span>' in TPL
# l'état vide ne s'affiche plus s'il reste des réactions
assert "comments.length === 0 && !reactionList().length" in TPL
# méthode : groupes déjà prêts côté API, texte source lu dans le DOM
assert "reactionList(){" in SRC
assert "_anchorText(bid,start,end)" in SRC
# le texte source ignore les pastilles (offsets identiques à sync())
assert "closest('.fd-react-chip'))continue;" in SRC
def test_reaction_removable_from_the_drawer():
assert 'title="Remove reaction"' in TPL
# le ✕ n'apparaît que sur MA réaction (les autres comptes sont en lecture)
assert 'x-show="r.mine" @click="removeReaction(r)"' in TPL
# suppression = POST toggle (le backend DELETE si ma ligne existe déjà)
assert "removeReaction(r){if(!r)return;this.toggleReaction(r.bid,r.start,r.end,r.emoji);}" in SRC
for sel in (".ct-section-title {", ".ct-reaction {", ".ct-re-emoji {", ".ct-re-text {"):
assert sel in CSS, sel
def test_reaction_removable_from_the_chip():
# chaque émoji de la pastille porte son data-emoji, `is-mine` = j'ai réagi
assert 'data-emoji="' in SRC
assert "parseInt(i.mine,10)?' is-mine':''" in SRC
assert ("d.removeReaction({bid:bid,start:start,end:end,"
"emoji:emo.getAttribute('data-emoji')})") in SRC
# la croix est un ::after (aucun nœud ajouté → offsets source inchangés)
assert ".fd-react-emo.is-mine::after {" in CSS
assert ".fd-react-chip:hover .fd-react-emo.is-mine::after { display: flex; }" in CSS