Compare commits

..
5 Commits
Author SHA1 Message Date
bruno 7cf922106f fix: colonnes rendues, tables IA sans pipe de tête, copier/couper/coller multi-blocs (v7.68.0)
FlowDeck CI / lint (push) Successful in 1m33s
FlowDeck CI / test (push) Failing after 18m53s
FlowDeck CI / docker (push) Skipped
Colonnes / équation / TOC
- renderBlock() n'appelait JAMAIS renderColumnsBlock()/renderMathBlock()/renderTOC()
  (3 renderers morts) → un bloc colonnes (slash « Column list », Turn into
  2/3/4/5 colonnes) tombait dans le rendu générique = paragraphe vide.
  Dispatch ajouté ; la × de suppression de colonne (opacity:0 sans survol)
  devient visible au survol.

Tables rendues par l'IA
- _tblIsData/_tblIsSep exigeaient un | de tête → le GFM sans pipe (A | B /
  --- | ---) devenait des paragraphes. Pipe de tête optionnel, séparateur
  gardé à ≥2 cellules (« texte » + « --- » reste un séparateur). md2b et
  paste2b partagent les helpers.

Copier / couper / coller
- Sélection de texte MULTI-BLOCS : _clipState()/_clipMarkdown() (début partiel
  → blocs entiers via blocksToMarkdown → fin partielle), clipCopy()/clipCut()
  (coupure = tête du 1er bloc + queue du dernier), listeners copy/cut (Ctrl+C/X
  via les événements natifs), groupe Clipboard (Cut/Copy/Paste) dans le menu du
  clic droit avec la sélection figée à l'ouverture (la recherche prend le focus),
  clic droit sur un bloc sélectionné = sélection multi-blocs conservée.
- Coller un seul bloc structuré (table/code/titre) = vraie insertion (avant :
  texte brut) ; pasteBlocks colle après le dernier bloc sans focus.
- « Copy link to block » réparé (data-bm-act link → copy-link).

Tests: pytest tests/ -q -n auto → 1394 passed / 0 failed ; ruff OK ;
e2e v768_columns_clipboard (4 : slash+Turn into colonnes, table IA sans pipe,
copier/couper/coller, clic droit Clipboard) + v762/v764/v766/v7641/v763/
regression_editor_mount → verts sur :8081 (image flowdeck:v7.68.0).
2026-10-08 15:23:46 -04:00
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
21 changed files with 1010 additions and 36 deletions
+1
View File
@@ -23,6 +23,7 @@ uv.lock
*.db-* *.db-*
test-commit.md test-commit.md
upload_test.txt upload_test.txt
commit_msg.txt
e2e/node_modules/ e2e/node_modules/
e2e/shots/ e2e/shots/
e2e/test-results/ e2e/test-results/
+146
View File
@@ -1,5 +1,151 @@
# Changelog - FlowDeck # Changelog - FlowDeck
## v7.68.0 (2026-10-08) — Colonnes rendues, tables IA, copier/couper/coller multi-blocs
### Fixed
- **Colonnes / équation / TOC n'étaient JAMAIS rendus** : `renderBlock()`
n'appelait pas `renderColumnsBlock()` / `renderMathBlock()` / `renderTOC()`
(3 renderers morts depuis l'extraction v7.22) → un bloc colonnes — créé par
le slash « Column list », par **Turn into → 2/3/4/5 colonnes** — tombait dans
le rendu générique : un paragraphe vide. Dispatch ajouté.
- la **× de suppression de colonne** était en `opacity:0` **sans règle de
survol** (invisible mais cliquable) → visible au survol du bloc colonne.
- **Tables rendues par l'IA** : `_tblIsData`/`_tblIsSep` exigeaient un `|` de
tête → le GFM sans pipe (`A | B` / `--- | ---`, ce que produit souvent le
modèle) devenait des paragraphes. Le pipe de tête est **optionnel** en GFM ;
garde conservée : une ligne de séparation doit faire ≥2 cellules (un « texte »
suivi d'un « --- » reste un séparateur de section). `md2b` et `paste2b`
partagent les helpers → alignés d'office.
- **Coller un SEUL bloc structuré** (table, code, titre) retombait en texte
brut (`if(parsed.length<=1) return fallback()`) → seul un bloc paragraphe
(une ligne) garde le comportement inline.
- **« Copy link to block »** du menu de bloc : `data-bm-act="link"` alors que
le handler attend `'copy-link'` → l'action ne faisait rien.
### Added
- **Copier / couper une sélection de texte MULTI-BLOCS** :
- `_clipState()` (sélection vivante traversant ≥2 blocs) + `_clipMarkdown()`
(début partiel → blocs entiers sérialisés par `blocksToMarkdown` → fin
partielle) ; `clipCopy()` / `clipCut()` (la coupure garde la tête du 1ᵉʳ
bloc et la queue du dernier, le milieu part) ;
- listeners **`copy` / `cut`** sur `#_blocksCt` : Ctrl+C / Ctrl+X passent par
les événements natifs du navigateur ; dans un seul bloc, on laisse le
navigateur faire (copie native) ;
- groupe **Clipboard** (`Cut Ctrl+X` / `Copy Ctrl+C` / `Paste Ctrl+V`) en
tête du menu du clic droit, avec la sélection **figée à l'ouverture** du
menu (la recherche prend le focus et détruit la sélection) ;
- clic droit **sur un bloc sélectionné** → la sélection multi-blocs (handle +
Maj-clic) est conservée et le Clipboard agit dessus (`copySelectedBlocks`) ;
- `pasteBlocks()` colle après le dernier bloc quand aucun bloc n'est focalisé.
### Tests
- `tests/test_v768_columns_clipboard.py` (4).
- **e2e** `e2e/v768_columns_clipboard.spec.js` (4) : slash « Column list » +
« Add column » + **Turn into → 2 colonnes** (bloc `.block-column` réellement
rendu), table IA **sans pipe de tête** (vrai `.ftable-editor` + garde
« texte »/« --- »), copier→`readText` → couper (blocs réduits à la tête +
queue) → coller (blocs revenus), clic droit → groupe Clipboard → Copy.
- le raccourci clavier n'est pas déclenché en headless Chromium (pas de
presse-papier OS) : le test passe par `document.execCommand('copy'/'cut')`,
qui émet les **mêmes** événements que Ctrl+C/X.
- `pytest tests/ -q -n auto` → **1394 passed, 0 failed**, `ruff check app tests`
→ OK.
## 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 » ## v7.65.0 (2026-10-08) — Comment ancré sur la SÉLECTION de texte (pas le bloc) + retrait du bouton bleu « 💬 Comment »
### Changed ### Changed
+63
View File
@@ -1482,6 +1482,69 @@ Détails livrés :
reload, `#_commentSelBtn` absent ; `pytest` 1383 passed, `ruff` OK ; reload, `#_commentSelBtn` absent ; `pytest` 1383 passed, `ruff` OK ;
`VERSION` + `app/main.py` = 7.65.0 `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
### v7.68.0 — Colonnes rendues, tables IA, copier/couper/coller multi-blocs ✅ (2026-10-08)
> **Objectif** : les colonnes (slash + Turn into) marchent, l'IA crée de vrais
> tableaux, et une sélection multi-blocs se copie/coupe/colle. **COMPLETED**.
- [x] **Renderers morts branchés** — `renderBlock()` n'appelait jamais
`renderColumnsBlock()` / `renderMathBlock()` / `renderTOC()` → bloc colonnes,
équation et TOC rendaient un paragraphe vide (bug rapporté) ; × de
suppression de colonne visible au survol (était invisible mais cliquable)
- [x] **Tables IA** — `_tblIsData`/`_tblIsSep` acceptent le GFM **sans pipe de
tête** (séparateur ≥2 cellules pour ne pas avaler « texte » + « --- ») ;
`md2b` et `paste2b` alignés d'office
- [x] **Coller** un seul bloc structuré (table/code/titre) = vraie insertion ;
« Copy link to block » réparé (`link` → `copy-link`)
- [x] **Copier/couper multi-blocs** — `_clipState()`/`_clipMarkdown()`/
`clipCopy()`/`clipCut()`, listeners `copy`/`cut` (Ctrl+C/X), groupe
**Clipboard** dans le menu du clic droit (sélection figée à l'ouverture),
clic droit sur un bloc sélectionné = sélection multi-blocs conservée
- [x] **Tests** — `tests/test_v768_columns_clipboard.py` (4), e2e
`v768_columns_clipboard.spec.js` (4) ; `pytest` **1394 passed / 0 failed**,
`ruff` OK ; `VERSION` + `app/main.py` = 7.68.0
--- ---
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code) ## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
+1 -1
View File
@@ -1 +1 @@
7.65.0 7.68.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone # WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.65.0 (comment ancré sur la sélection de texte, bouton bleu retiré) | **Statut**: EN COURS 🔄 > **Début**: 2026-07-08 | **Version**: v7.68.0 (colonnes rendues, tables IA sans pipe de tête, copier/couper/coller multi-blocs) | **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` > **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 ## Avancement Global
+1 -1
View File
@@ -186,7 +186,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version="7.65.0", version="7.68.0",
docs_url="/docs", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
lifespan=lifespan, lifespan=lifespan,
+4 -1
View File
@@ -26,7 +26,10 @@ def _is_public_host(host: str) -> bool:
return False return False
try: try:
infos = socket.getaddrinfo(host, None) 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 return False
for info in infos: for info in infos:
address = info[4][0] address = info[4][0]
+22 -7
View File
@@ -590,7 +590,20 @@
<button type="button" class="topbar-btn" @click="toggleComments()" title="Close">✕</button> <button type="button" class="topbar-btn" @click="toggleComments()" title="Close">✕</button>
</div> </div>
<div class="comments-drawer-list" style="flex:1;overflow-y:auto;padding:12px 16px;"> <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. No comments yet. Select some text and click 💬 Comment.
</div> </div>
<template x-for="c in comments" :key="c.id"> <template x-for="c in comments" :key="c.id">
@@ -666,23 +679,25 @@
<div class="ft-row"> <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="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" @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 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" @click="fmtEquation()" data-tooltip="Equation">√x</button>
<button type="button" class="ft-item ft-more" @click="fmtMore($event)" data-tooltip="More actions">…</button> <button type="button" class="ft-item ft-more" @click="fmtMore($event)" data-tooltip="More actions">…</button>
</div> </div>
<!-- Ligne 4 : actions --> <!-- Ligne 4 : Comment à gauche, réaction à droite (le ✨ Ask AI sauté :
<div class="ft-row"> doublon exact du bouton du pied, même action) -->
<button type="button" class="ft-item" @click="fmtComment()" data-tooltip="Comment — Ctrl+⇧+M">{{ fd_icon("message-square",15) }}</button> <div class="ft-row ft-row-split">
<button type="button" class="ft-item" @click="fmtAskAI()" data-tooltip="Ask AI — Ctrl+J">{{ fd_icon("sparkles",15) }}</button> <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> </div>
<!-- Section « Skills » --> <!-- Section « Skills » -->
<div class="ft-sep"></div> <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('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('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('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> <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 --> <!-- Pied de page -->
<div class="ft-sep"></div> <div class="ft-sep"></div>
<button type="button" class="ft-item ft-foot" @click="fmtEditAI()" data-tooltip="Edit with AI"> <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", "openapi": "3.1.0",
"info": { "info": {
"title": "FlowDeck", "title": "FlowDeck",
"version": "7.65.0" "version": "7.68.0"
}, },
"paths": { "paths": {
"/auth/register": { "/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')).toHaveCount(4);
await expect(bar.locator('.ft-skill').first()).toHaveText('Improve writing'); await expect(bar.locator('.ft-skill').first()).toHaveText('Improve writing');
await expect(bar.locator('.ft-foot')).toContainText('Edit with AI'); 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 // géométrie : sous la sélection, entièrement dans le viewport
const barBox = await bar.boundingBox(); const barBox = await bar.boundingBox();
const selBox = await page.evaluate(() => { const selBox = await page.evaluate(() => {
+30
View File
@@ -168,4 +168,34 @@ test('réaction emoji + commentaires ancrés', async ({ page }) => {
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveCount(1, { timeout: 10000 }); 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-anchor[data-cid]')).toHaveText('reaction a un');
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1, { timeout: 10000 }); 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');
});
+162
View File
@@ -0,0 +1,162 @@
const { test, expect } = require('@playwright/test');
/**
* v7.68 — colonnes rendues (slash + Turn into), tables IA sans pipe de tête,
* copier/couper/coller d'une sélection multi-blocs (Ctrl+C/X/V + clic droit).
* 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', permissions: ['clipboard-read', 'clipboard-write'] });
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=v768 ${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: 'v768', 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élection multi-blocs (du milieu du 1er au milieu du 3e) + fin de mouseup
async function selectAcross(page) {
await page.evaluate(() => {
const el1 = document.querySelector('#_blocksCt [data-bid]');
const blocks = document.querySelectorAll('#_blocksCt [data-bid]');
const el3 = blocks[blocks.length - 1];
const r = document.createRange();
r.setStart(el1.firstChild, 0);
r.setEnd(el3.firstChild, el3.firstChild.nodeValue.length);
const s = getSelection();
s.removeAllRanges();
s.addRange(r);
});
}
test('colonnes : slash + Turn into 2 colonnes', async ({ page }) => {
await login(page);
await newPage(page, [{ id: 'b1', type: 'paragraph', content: '' }]);
await page.click('#_blocksCt [data-bid]');
await page.keyboard.type('/');
await expect(page.locator('#_slashMenu')).toBeVisible({ timeout: 5000 });
await page.keyboard.type('columns');
await page.locator('#_slashMenu .slash-item[data-sid="columns"]').click();
// le bloc colonnes est RENDU (il tombait dans le rendu générique = vide)
await expect(page.locator('#_blocksCt .block-columns')).toHaveCount(1);
await expect(page.locator('#_blocksCt .block-column')).toHaveCount(1);
await page.locator('#_blocksCt .columns-add-btn').click();
await expect(page.locator('#_blocksCt .block-column')).toHaveCount(2);
await page.screenshot({ path: 'shots/v768-columns-slash.png' });
// Turn into → 2 colonnes
await newPage(page, [{ id: 'b1', type: 'paragraph', content: 'Pour le menu' }]);
await page.locator('#_blocksCt [data-bid]').first().click({ button: 'right' });
const menu = page.locator('#blockMenu');
await expect(menu).toBeVisible();
await menu.locator('[data-sub="turn"]').hover();
const sub = page.locator('#blockMenuSub');
await expect(sub).toBeVisible();
await sub.locator('[data-bm-act="turn-type"][data-cols="2"]').click();
await expect(page.locator('#_blocksCt .block-column')).toHaveCount(2);
});
test('table IA sans pipe de tête devient un vrai tableau', async ({ page }) => {
await login(page);
await newPage(page, [{ id: 'b1', type: 'paragraph', content: '' }]);
await page.evaluate(() => {
window.E.applyAIBlocks('Nom | Rôle\n--- | ---\nAlice | Dev\nBob | Test');
});
await expect(page.locator('#_blocksCt .ftable-editor')).toHaveCount(1);
await expect(page.locator('#_blocksCt .ftable-cell').first()).toHaveText('Nom');
await expect(page.locator('#_blocksCt .ftable-cell').last()).toHaveText('Test');
// la ligne « texte » suivie d'« --- » ne doit PAS devenir un tableau
await page.evaluate(() => {
window.E.applyAIBlocks('juste du texte\n---\nune phrase');
});
await expect(page.locator('#_blocksCt .ftable-editor')).toHaveCount(1);
await page.screenshot({ path: 'shots/v768-ai-table.png' });
});
test('Ctrl+C / Ctrl+X / Ctrl+V sur une sélection multi-blocs', async ({ page }) => {
await login(page);
await newPage(page, [
{ id: 'b1', type: 'paragraph', content: 'Premier bloc' },
{ id: 'b2', type: 'heading_2', content: 'Titre deux' },
{ id: 'b3', type: 'paragraph', content: 'Troisieme bloc' },
]);
// le raccourci clavier nécessite un éditeur focalisé (sinon Chromium
// n'émet pas l'événement copy)
await page.locator('#_blocksCt [data-bid]').first().click();
await selectAcross(page);
// headless Chromium n'émet PAS le raccourci clavier (pas de presse-papier
// OS) : execCommand('copy') déclenche le MÊME événement copy → notre handler
await page.evaluate(() => document.execCommand('copy'));
const clip = await page.evaluate(() => navigator.clipboard.readText());
expect(clip).toContain('Premier bloc');
expect(clip).toContain('## Titre deux'); // le titre sort en markdown
// coupe : les 3 blocs deviennent 2 blocs vides (tête + queue)
await selectAcross(page);
await page.evaluate(() => document.execCommand('cut'));
await expect(page.locator('#_blocksCt [data-bid]')).toHaveCount(2);
await expect(page.locator('#_blocksCt [data-bid]').first()).toHaveText('');
// colle : les 3 blocs reviennent APRÈS le bloc du curseur (comme l'entrée
// Paste du menu) → 2 blocs restants + 3 collés = 5
await page.evaluate(() => window.E.pasteBlocks());
await expect(page.locator('#_blocksCt [data-bid]')).toHaveCount(5, { timeout: 5000 });
await expect(page.locator('#_blocksCt [data-bid]').nth(1)).toContainText('Premier bloc');
await expect(page.locator('#_blocksCt [data-bid]').nth(2)).toContainText('Titre deux');
});
test('clic droit avec une sélection → groupe Clipboard', async ({ page }) => {
await login(page);
await newPage(page, [
{ id: 'b1', type: 'paragraph', content: 'Alpha bloc' },
{ id: 'b2', type: 'paragraph', content: 'Beta bloc' },
]);
await selectAcross(page);
await page.locator('#_blocksCt [data-bid]').first().click({ button: 'right', position: { x: 4, y: 4 } });
const menu = page.locator('#blockMenu');
await expect(menu).toBeVisible();
await expect(menu.locator('[data-bm-act="clip-copy"]')).toBeVisible();
await expect(menu.locator('[data-bm-act="clip-cut"]')).toBeVisible();
await expect(menu.locator('[data-bm-act="clip-paste"]')).toBeVisible();
await page.screenshot({ path: 'shots/v768-ctx-clipboard.png' });
await menu.locator('[data-bm-act="clip-copy"]').click();
const clip = await page.evaluate(() => navigator.clipboard.readText());
expect(clip).toContain('Alpha bloc');
expect(clip).toContain('Beta bloc');
});
+114
View File
@@ -2371,6 +2371,35 @@ button.breadcrumb-current { max-width: 260px; }
color: var(--text-primary); 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 ── */ /* ── v5.10.0: selection multiple + menu contexte bloc ── */
.block-wrapper.selected { .block-wrapper.selected {
background: rgba(35,131,226,0.06); background: rgba(35,131,226,0.06);
@@ -2836,6 +2865,11 @@ button.breadcrumb-current { max-width: 260px; }
border-radius: 50%; border-radius: 50%;
line-height: 1; line-height: 1;
} }
/* v7.68 : la × de suppression de colonne était en opacity:0 SANS règle de
survol → invisible et pourtant cliquable (même piège que .block-actions) */
.block-column-wrapper:hover .block-actions {
opacity: 1;
}
.block-column { .block-column {
outline: none; outline: none;
min-height: 24px; min-height: 24px;
@@ -3011,6 +3045,30 @@ button.breadcrumb-current { max-width: 260px; }
letter-spacing: 1px; 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 */ /* Séparateurs, groupe « Skills », pied de page */
.format-toolbar .ft-sep { .format-toolbar .ft-sep {
height: 1px; height: 1px;
@@ -3183,6 +3241,28 @@ button.breadcrumb-current { max-width: 260px; }
.fd-react-chip img { width: 12px; height: 12px; object-fit: contain; } .fd-react-chip img { width: 12px; height: 12px; object-fit: contain; }
.fd-react-n { font-size: 10px; color: var(--text-dim); } .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) */ /* Tiroir de commentaires (fil + saisie) */
.comment-thread { .comment-thread {
position: relative; position: relative;
@@ -3222,6 +3302,37 @@ button.breadcrumb-current { max-width: 260px; }
.ct-menu-item.is-danger { color: #e5484d; } .ct-menu-item.is-danger { color: #e5484d; }
.ct-body { font-size: 13px; white-space: pre-wrap; margin-top: 6px; color: var(--text-primary); } .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; } .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; } .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 { display: flex; gap: 6px; align-items: flex-end; }
.ct-input-row textarea { .ct-input-row textarea {
@@ -3288,6 +3399,7 @@ button.breadcrumb-current { max-width: 260px; }
.block-handle { display: none; } .block-handle { display: none; }
.block-insert-btn { display: none; } .block-insert-btn { display: none; }
.block-actions { display: none; } .block-actions { display: none; }
.block-comment-btn { display: none; }
.editor-statusbar { left: 0; } .editor-statusbar { left: 0; }
} }
@@ -3855,6 +3967,7 @@ button.breadcrumb-current { max-width: 260px; }
.block-handle { display: none; } .block-handle { display: none; }
.block-insert-btn { display: none; } .block-insert-btn { display: none; }
.block-actions { display: none; } .block-actions { display: none; }
.block-comment-btn { display: none; }
.page-title-block::before { display: none; } .page-title-block::before { display: none; }
.page-title-block .page-icon-emoji { margin-left: 0; } .page-title-block .page-icon-emoji { margin-left: 0; }
.page-title-block .page-title-input { padding-left: 0; } .page-title-block .page-title-input { padding-left: 0; }
@@ -4998,6 +5111,7 @@ a.fd-wiki-link:hover { border-bottom-color: var(--accent, #2383e2); }
font-weight: 500; font-weight: 500;
} }
body.page-locked .block-handle, body.page-locked .block-handle,
body.page-locked .block-comment-btn,
body.page-locked .block-add-btn, body.page-locked .block-add-btn,
body.page-locked .format-toolbar, body.page-locked .format-toolbar,
body.page-locked .get-started-toolbar { display: none !important; } body.page-locked .get-started-toolbar { display: none !important; }
+172 -20
View File
@@ -236,8 +236,11 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
/* ═══════════ Simple Table block (v4.8.0) ═══════════ */ /* ═══════════ Simple Table block (v4.8.0) ═══════════ */
function _tblSplitLine(s){s=(s||'').trim();if(s.charAt(0)==='|')s=s.slice(1);if(s.charAt(s.length-1)==='|')s=s.slice(0,-1);return s.split('|').map(function(x){return x.trim();});} function _tblSplitLine(s){s=(s||'').trim();if(s.charAt(0)==='|')s=s.slice(1);if(s.charAt(s.length-1)==='|')s=s.slice(0,-1);return s.split('|').map(function(x){return x.trim();});}
function _tblIsSep(s){s=(s||'').trim();if(!s||s.charAt(0)!=='|')return false;var cells=_tblSplitLine(s);if(!cells.length)return false;return cells.every(function(c){return /^:?-{1,}:?$/.test(c);});} // v7.68 : le pipe de tête est OPTIONNEL en GFM — l'IA rend souvent
function _tblIsData(s){s=(s||'').trim();return /^\|/.test(s)&&s.indexOf('|',1)>=0;} // « A | B » / « --- | --- ». Une ligne de séparation doit garder ≥2 cellules,
// sinon « texte » suivi d'un « --- » deviendrait un tableau.
function _tblIsSep(s){s=(s||'').trim();if(!s)return false;var cells=_tblSplitLine(s);if(cells.length<2)return false;return cells.every(function(c){return /^:?-{1,}:?$/.test(c);});}
function _tblIsData(s){s=(s||'').trim();return s.indexOf('|')>=0&&_tblSplitLine(s).length>=2;}
function _tblNew(rows,cols){var out=[];for(var i=0;i<rows;i++){var r=new Array(cols);for(var j=0;j<cols;j++)r[j]='';out.push(r);}return out;} function _tblNew(rows,cols){var out=[];for(var i=0;i<rows;i++){var r=new Array(cols);for(var j=0;j<cols;j++)r[j]='';out.push(r);}return out;}
function _tblClone(b){b.rows=(b.rows||[]).map(function(r){return (r||[]).slice();});return b;} function _tblClone(b){b.rows=(b.rows||[]).map(function(r){return (r||[]).slice();});return b;}
function _tblAddCol(b,index){_tblClone(b);var w=b.rows.length?b.rows[0].length:1;if(index<0)index=0;if(index>w)index=w;b.rows.forEach(function(r){r.splice(index,0,'');});} function _tblAddCol(b,index){_tblClone(b);var w=b.rows.length?b.rows[0].length:1;if(index<0)index=0;if(index>w)index=w;b.rows.forEach(function(r){r.splice(index,0,'');});}
@@ -463,6 +466,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._e.style.left=Math.min(r.left,window.innerWidth-340)+'px';
SM._rs();setTimeout(()=>SM._in.focus(),10);SM._o=true;}, 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();}, 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) // 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"> _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-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 +500,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();} _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==='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==='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'});}, _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();}, sel(id){if(window.E)window.E.applySlash(id);SM.close();},
get isOpen(){return SM._o;}}; get isOpen(){return SM._o;}};
@@ -723,6 +747,12 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
if(b.type==='table')return renderTableBlock(b,idx); if(b.type==='table')return renderTableBlock(b,idx);
if(b.type==='divider')return `<div class="block-wrapper" data-id="${b.id}"><hr class="block-divider"></div>`; if(b.type==='divider')return `<div class="block-wrapper" data-id="${b.id}"><hr class="block-divider"></div>`;
if(b.type==='button')return renderAutomationButton(b,idx); if(b.type==='button')return renderAutomationButton(b,idx);
// v7.68 : ces trois renderers existaient mais n'étaient JAMAIS appelés —
// un bloc `columns`/`math`/`table_of_contents` tombait dans le rendu générique
// (un paragraphe vide). Colonnes (slash + Turn into) = bug rapporté.
if(b.type==='columns')return renderColumnsBlock(b);
if(b.type==='math')return renderMathBlock(b);
if(b.type==='table_of_contents')return renderTOC(b);
if(b.type==='image')return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-image" style="text-align:center;padding:12px 0;"><img src="${b.src||''}" alt="${esc(b.alt||'')}" class="fd-img" data-full="${b.src||''}" style="max-width:100%;max-height:70vh;border-radius:8px;display:block;margin:0 auto;cursor:zoom-in;" onerror="this.style.display='none';this.parentElement.innerHTML='<div style=\\'padding:40px;color:var(--text-tertiary)\\'>Image not found</div>'" onclick="E._openLightbox(this)"></div></div>`; if(b.type==='image')return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-image" style="text-align:center;padding:12px 0;"><img src="${b.src||''}" alt="${esc(b.alt||'')}" class="fd-img" data-full="${b.src||''}" style="max-width:100%;max-height:70vh;border-radius:8px;display:block;margin:0 auto;cursor:zoom-in;" onerror="this.style.display='none';this.parentElement.innerHTML='<div style=\\'padding:40px;color:var(--text-tertiary)\\'>Image not found</div>'" onclick="E._openLightbox(this)"></div></div>`;
if(b.type==='video'){ if(b.type==='video'){
const src=b.src||'';return src?`<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-video"><video controls preload="metadata" style="max-width:100%;border-radius:8px;display:block;margin:0 auto;"><source src="${src}"></video></div></div>`:`<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-video" style="text-align:center;padding:60px 20px;background:var(--bg-secondary);border:1px dashed var(--border);border-radius:8px;cursor:pointer;" onclick="E.setVideoSrc('${b.id}')"><div style="font-size:48px;margin-bottom:12px;">🎬</div><div style="font-size:14px;color:var(--text-dim);">Click to add video URL</div></div></div>`; const src=b.src||'';return src?`<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-video"><video controls preload="metadata" style="max-width:100%;border-radius:8px;display:block;margin:0 auto;"><source src="${src}"></video></div></div>`:`<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-video" style="text-align:center;padding:60px 20px;background:var(--bg-secondary);border:1px dashed var(--border);border-radius:8px;cursor:pointer;" onclick="E.setVideoSrc('${b.id}')"><div style="font-size:48px;margin-bottom:12px;">🎬</div><div style="font-size:14px;color:var(--text-dim);">Click to add video URL</div></div></div>`;
@@ -789,14 +819,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--; let s=idx; while(s>0&&bs[s-1]&&bs[s-1].type==='numbered_list')s--;
numAttr=` data-num="${idx-s+1}"`; 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) // + « 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>`:''; 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}"> return `<div class="block-wrapper" data-id="${b.id}">
${insertBtn} ${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> <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}> <${tag}${style}${bidAttr}${numAttr} class="${cls}">${inner}</${tag}>
${cmtBtn}
</div>`; </div>`;
} }
@@ -1161,6 +1194,16 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
else if(act==='suggest'){E.suggestEdit(idx);} else if(act==='suggest'){E.suggestEdit(idx);}
else if(act==='ai'){E.askAIBlock(idx);} else if(act==='ai'){E.askAIBlock(idx);}
else if(act==='skill'){E.runSkill(idx,t.getAttribute('data-prompt')||'');} else if(act==='skill'){E.runSkill(idx,t.getAttribute('data-prompt')||'');}
// v7.68 : Clipboard du menu (sélection multi-blocs gérée par copySelectedBlocks)
else if(act==='clip-cut'){
if(_selSet.size>1)E.cutSelectedBlocks();
else{var mst=document.getElementById('blockMenu');const md=E.clipCut?E.clipCut(mst&&mst._clipSt):null;if(md!=null&&navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(md);E.showToast('Sélection coupée');}}
}
else if(act==='clip-copy'){
if(_selSet.size>1)E.copySelectedBlocks();
else{var mst2=document.getElementById('blockMenu');const md=E.clipCopy?E.clipCopy(mst2&&mst2._clipSt):null;if(md!=null&&navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(md);E.showToast('Sélection copiée');}}
}
else if(act==='clip-paste'){E.pasteBlocks();}
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==='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;} else if(act==='turn-type'){E.turnInto(idx,t.getAttribute('data-type'),parseInt(t.getAttribute('data-cols')||'0',10)||0);_blockMenuClose();return;}
_blockMenuClose(); _blockMenuClose();
@@ -1205,11 +1248,24 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
var b=E.blocks[idx];if(!b)return; var b=E.blocks[idx];if(!b)return;
var d=document.createElement('div');d.id='blockMenu';d.className='block-menu';d.setAttribute('data-bid',b.id);d.style.cssText='position:fixed;z-index:4000;'; var d=document.createElement('div');d.id='blockMenu';d.className='block-menu';d.setAttribute('data-bid',b.id);d.style.cssText='position:fixed;z-index:4000;';
var S=_BM_SHORTCUTS; var S=_BM_SHORTCUTS;
// v7.68 : Clipboard — une sélection de texte multi-blocs OU des blocs
// sélectionnés (handle + Maj-clic) donnent Cut/Copy ; Paste colle après.
// la sélection est détruite quand la recherche prend le focus : on la
// fige ici et les actions Clipboard la reprennent telle quelle.
var clipSt=(E._clipState?E._clipState():null);
d._clipSt=clipSt;
var clip=(_selSet.size>1||clipSt)
?'<div class="bm-group">Clipboard</div>'
+_blockMenuItem('clip-cut','✂','Cut',{shortcut:'Ctrl+X'})
+_blockMenuItem('clip-copy','⧉','Copy',{shortcut:'Ctrl+C'})
+_blockMenuItem('clip-paste','📋','Paste',{shortcut:'Ctrl+V'})
:'';
d.innerHTML='<input class="bm-search" type="text" placeholder="Search actions..." autocomplete="off" spellcheck="false">' d.innerHTML='<input class="bm-search" type="text" placeholder="Search actions..." autocomplete="off" spellcheck="false">'
+clip
+'<div class="bm-group">Text</div>' +'<div class="bm-group">Text</div>'
+_blockMenuItem('submenu','↩','Turn into',{sub:'turn'}) +_blockMenuItem('submenu','↩','Turn into',{sub:'turn'})
+_blockMenuItem('submenu','🎨','Color',{sub:'color'}) +_blockMenuItem('submenu','🎨','Color',{sub:'color'})
+_blockMenuItem('link','🔗','Copy link to block',{shortcut:S.link}) +_blockMenuItem('copy-link','🔗','Copy link to block',{shortcut:S.link})
+_blockMenuItem('duplicate','▣','Duplicate',{shortcut:S.duplicate}) +_blockMenuItem('duplicate','▣','Duplicate',{shortcut:S.duplicate})
+_blockMenuItem('move-to','↪','Move to…',{shortcut:S.move}) +_blockMenuItem('move-to','↪','Move to…',{shortcut:S.move})
+_blockMenuItem('delete','✕','Delete',{shortcut:S.delete,danger:true}) +_blockMenuItem('delete','✕','Delete',{shortcut:S.delete,danger:true})
@@ -1225,7 +1281,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
d.querySelectorAll('.bm-item').forEach(function(it){ d.querySelectorAll('.bm-item').forEach(function(it){
it.style.display=(q&&it.textContent.toLowerCase().indexOf(q)<0)?'none':''; it.style.display=(q&&it.textContent.toLowerCase().indexOf(q)<0)?'none':'';
}); });
var g=d.querySelector('.bm-group');if(g)g.style.display=q?'none':''; d.querySelectorAll('.bm-group').forEach(function(g){g.style.display=q?'none':'';});
}); });
d.addEventListener('mouseover',function(ev){ d.addEventListener('mouseover',function(ev){
var it=ev.target&&ev.target.closest?ev.target.closest('[data-sub]'):null; var it=ev.target&&ev.target.closest?ev.target.closest('[data-sub]'):null;
@@ -1353,6 +1409,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
if(!(ev.target&&ev.target.closest))return; if(!(ev.target&&ev.target.closest))return;
if(ev.target.closest('#blockMenu')||ev.target.closest('#blockMenuSub')||ev.target.closest('#fdMoveOverlay'))return; if(ev.target.closest('#blockMenu')||ev.target.closest('#blockMenuSub')||ev.target.closest('#fdMoveOverlay'))return;
if(ev.target.closest('.block-handle'))return; if(ev.target.closest('.block-handle'))return;
if(ev.button===2){const w0=ev.target.closest('.block-wrapper');if(w0&&_selSet.has(w0.getAttribute('data-id')))return;}
if(_selSet.size)_selClear(); if(_selSet.size)_selClear();
_blockMenuClose(); _blockMenuClose();
} }
@@ -1439,7 +1496,15 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
ct.addEventListener('click',e=>{const h=e.target&&e.target.closest?e.target.closest('.block-handle'):null;if(!h)return;const w=h.closest('.block-wrapper');if(!w)return;const wid=w.getAttribute('data-id');if(!wid)return; ct.addEventListener('click',e=>{const h=e.target&&e.target.closest?e.target.closest('.block-handle'):null;if(!h)return;const w=h.closest('.block-wrapper');if(!w)return;const wid=w.getAttribute('data-id');if(!wid)return;
if(e.shiftKey){_selToggle(wid);return;} if(e.shiftKey){_selToggle(wid);return;}
_selClear();blockMenuOpen(e.clientX,e.clientY,wid);}); _selClear();blockMenuOpen(e.clientX,e.clientY,wid);});
ct.addEventListener('contextmenu',e=>{if(!(e.target&&e.target.closest))return;if(e.target.closest('.ftable-cell')||e.target.closest('#_tblMenu'))return;const c=e.target.closest('.block-content, .block-wrapper, .block-column-wrapper');const el0=e.target.closest('[data-bid]');if(!c)return;const w=e.target.closest('.block-wrapper');const wid=w?w.getAttribute('data-id'):null;const bid=wid||(el0?el0.getAttribute('data-bid'):null);if(!bid)return;e.preventDefault();_selClear();blockMenuOpen(e.clientX,e.clientY,bid);}); ct.addEventListener('contextmenu',e=>{if(!(e.target&&e.target.closest))return;if(e.target.closest('.ftable-cell')||e.target.closest('#_tblMenu'))return;const c=e.target.closest('.block-content, .block-wrapper, .block-column-wrapper');const el0=e.target.closest('[data-bid]');if(!c)return;const w=e.target.closest('.block-wrapper');const wid=w?w.getAttribute('data-id'):null;const bid=wid||(el0?el0.getAttribute('data-bid'):null);if(!bid)return;e.preventDefault();
// v7.68 : clic droit SUR un bloc sélectionné → on garde la sélection
// multi-blocs (le menu Clipboard coupe/copie ces blocs-là)
if(!(_selSet.size>1&&_selSet.has(wid)))_selClear();
blockMenuOpen(e.clientX,e.clientY,bid);});
// v7.68 : Ctrl+C / Ctrl+X sur une sélection multi-blocs → markdown.
// Sous un bloc unique, on laisse le navigateur (copie native).
ct.addEventListener('copy',function(ev){const E=window.E;if(!E||!E.clipCopy)return;const md=E.clipCopy();if(md==null)return;ev.preventDefault();ev.clipboardData.setData('text/plain',md);E.showToast('Sélection copiée');});
ct.addEventListener('cut',function(ev){const E=window.E;if(!E||!E.clipCut)return;const md=E.clipCut();if(md==null)return;ev.preventDefault();ev.clipboardData.setData('text/plain',md);E.showToast('Sélection coupée');});
document.addEventListener('mousedown',_blockDocDown); document.addEventListener('mousedown',_blockDocDown);
if(window.location.hash&&window.location.hash.indexOf('#fdblk-')===0){const hb=window.location.hash.slice(7);setTimeout(()=>{const selEl=document.querySelector(`[data-bid="${hb}"]`);if(selEl&&selEl.scrollIntoView)selEl.scrollIntoView({behavior:'smooth',block:'center'});if(selEl){selEl.focus();ce(selEl);}},500);} if(window.location.hash&&window.location.hash.indexOf('#fdblk-')===0){const hb=window.location.hash.slice(7);setTimeout(()=>{const selEl=document.querySelector(`[data-bid="${hb}"]`);if(selEl&&selEl.scrollIntoView)selEl.scrollIntoView({behavior:'smooth',block:'center'});if(selEl){selEl.focus();ce(selEl);}},500);}
},100); },100);
@@ -1865,19 +1930,16 @@ 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();}, 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 ── // ── v7.61 : actions du menu contextuel de bloc ──
commentBlock(idx){ commentBlock(idx){
// L'ancre est la SÉLECTION de texte si elle vit dans ce bloc (la // L'ancre est la SÉLECTION de texte vivante dans ce bloc, sinon tout
// sélection de la toolbar aussi : le clic sur le bouton peut l'avoir // le bloc. La toolbar de sélection restaure sa plage AVANT (fmtComment)
// fait disparaître du DOM), sinon tout le bloc. // — 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 b=this.blocks[idx];if(!b)return;const el=this.getEl(b.id);if(!el)return;
let r=null;const s=window.getSelection(); let r=null;const s=window.getSelection();
if(s&&s.rangeCount&&!s.isCollapsed){ if(s&&s.rangeCount&&!s.isCollapsed){
const rr=s.getRangeAt(0); const rr=s.getRangeAt(0);
if(el.contains(rr.startContainer)&&el.contains(rr.endContainer))r=rr.cloneRange(); 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);} if(!r){r=document.createRange();r.selectNodeContents(el);}
const start=_srcOffset(el,r.startContainer,r.startOffset); const start=_srcOffset(el,r.startContainer,r.startOffset);
const end=_srcOffset(el,r.endContainer,r.endOffset); const end=_srcOffset(el,r.endContainer,r.endOffset);
@@ -1939,7 +2001,58 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
_blocksToMarkdown(blocks){return blocks.map(blocksToMarkdown).join('\n\n');}, _blocksToMarkdown(blocks){return blocks.map(blocksToMarkdown).join('\n\n');},
copySelectedBlocks(){const indices=_selIndicesSorted();if(!indices.length)return;const selected=indices.map(i=>this.blocks[i]).filter(Boolean);if(!selected.length)return;const md=this._blocksToMarkdown(selected);if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(md).then(()=>this.showToast('Blocs copiés')).catch(()=>this.showToast('Copie impossible','error'));}else{this.showToast(md,'error');}}, copySelectedBlocks(){const indices=_selIndicesSorted();if(!indices.length)return;const selected=indices.map(i=>this.blocks[i]).filter(Boolean);if(!selected.length)return;const md=this._blocksToMarkdown(selected);if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(md).then(()=>this.showToast('Blocs copiés')).catch(()=>this.showToast('Copie impossible','error'));}else{this.showToast(md,'error');}},
cutSelectedBlocks(){const indices=_selIndicesSorted();if(!indices.length)return;this.sync();this.pushHistory();const selected=indices.map(i=>this.blocks[i]).filter(Boolean);if(!selected.length)return;const md=this._blocksToMarkdown(selected);if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(md).then(()=>{});}this.blocks=this.blocks.filter((b,i)=>indices.indexOf(i)<0);if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();this.showToast('Blocs coupés');}, cutSelectedBlocks(){const indices=_selIndicesSorted();if(!indices.length)return;this.sync();this.pushHistory();const selected=indices.map(i=>this.blocks[i]).filter(Boolean);if(!selected.length)return;const md=this._blocksToMarkdown(selected);if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(md).then(()=>{});}this.blocks=this.blocks.filter((b,i)=>indices.indexOf(i)<0);if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();this.showToast('Blocs coupés');},
async pasteBlocks(){const idx=this._focusedIdx();if(idx<0)return;try{const text=await navigator.clipboard.readText();if(!text||!text.trim())return;const parsed=this.md2b(text);if(!parsed.length)return;this.sync();this.pushHistory();this.blocks.splice(idx+1,0,...parsed);this.dirty=true;this.autoSave();this.render();_rtSync();this._focusBid(parsed[parsed.length-1].id);_selClear();}catch(e){if(e.name==='NotAllowedError'){this.showToast('Permission presse-papier refusée');}else{this.showToast('Coller impossible','error');}}}, async pasteBlocks(){let idx=this._focusedIdx();if(idx<0)idx=this.blocks.length-1;if(idx<0)return;try{const text=await navigator.clipboard.readText();if(!text||!text.trim())return;const parsed=this.md2b(text);if(!parsed.length)return;this.sync();this.pushHistory();this.blocks.splice(idx+1,0,...parsed);this.dirty=true;this.autoSave();this.render();_rtSync();this._focusBid(parsed[parsed.length-1].id);_selClear();}catch(e){if(e.name==='NotAllowedError'){this.showToast('Permission presse-papier refusée');}else{this.showToast('Coller impossible','error');}}},
// ── v7.68 : copier/couper une SÉLECTION DE TEXTE MULTI-BLOCS ──
// Sélection vivante qui traverse ≥2 blocs → plage ; sinon null (le
// navigateur gère tout seul la sélection à l'intérieur d'un bloc).
_clipState(){
const s=window.getSelection();
if(!s||!s.rangeCount||s.isCollapsed)return null;
const r=s.getRangeAt(0);
const up=n=>n?(n.nodeType===1?n:n.parentElement):null;
const se=up(r.startContainer), ee=up(r.endContainer);
const sblk=se&&se.closest?se.closest('[data-bid]'):null;
const eblk=ee&&ee.closest?ee.closest('[data-bid]'):null;
if(!sblk||!eblk)return null;
const si=this.getIdx(sblk.dataset.bid), ei=this.getIdx(eblk.dataset.bid);
if(si<0||ei<0||si>=ei)return null;
return {si:si,ei:ei,
startOff:_srcOffset(sblk,r.startContainer,r.startOffset),
endOff:_srcOffset(eblk,r.endContainer,r.endOffset)};
},
// blocs dont la sélection ne couvre qu'une PARTIE (les autres = entiers)
_clipHeavy(b){return ['table','code','columns','math','image','embed','divider','meeting','button','table_of_contents'].indexOf(b.type)>=0;},
_clipMarkdown(st){
const parts=[];
for(let i=st.si;i<=st.ei;i++){
const b=this.blocks[i];if(!b)continue;
if(i===st.si&&i===st.ei)return this._anchorText(b.id,st.startOff,st.endOff);
if(i===st.si||i===st.ei){
if(this._clipHeavy(b)){parts.push(blocksToMarkdown(b));continue;}
const el=this.getEl(b.id);
parts.push(i===st.si
? this._anchorText(b.id,st.startOff,el?_srcNodeLen(el):0)
: this._anchorText(b.id,0,st.endOff));
}else parts.push(blocksToMarkdown(b));
}
return parts.join('\n\n');
},
// renvoie le markdown copié, ou null si la sélection n'est pas multi-blocs
clipCopy(st){st=st||this._clipState();if(!st)return null;this.sync();return this._clipMarkdown(st);},
clipCut(st){
st=st||this._clipState();if(!st)return null;
this.sync();this.pushHistory();
const md=this._clipMarkdown(st);
const first=this.blocks[st.si],last=this.blocks[st.ei];
const lastEl=this.getEl(last.id);
if(!this._clipHeavy(first))first.content=this._anchorText(first.id,0,st.startOff);
if(!this._clipHeavy(last))last.content=this._anchorText(last.id,st.endOff,lastEl?_srcNodeLen(lastEl):0);
this.blocks=this.blocks.slice(0,st.si+1).concat([last],this.blocks.slice(st.ei+1));
if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];
this.dirty=true;this.autoSave();this.render();_rtSync();
this._focusBid(first.id);
return md;
},
_focusedIdx(){const a=this.getActiveBlock();return a?a.idx:-1;}, _focusedIdx(){const a=this.getActiveBlock();return a?a.idx:-1;},
async moveBlockToPage(idx,pageId){if(idx<0||idx>=this.blocks.length||pageId===this.pid)return;this.sync();this.pushHistory();const block=this.blocks[idx];const self=this;const csrf = getCsrf();;this.showToast('Déplacement du bloc…');try{const r=await fetch(`/board/api/pages/${pageId}`,{credentials:'same-origin'});const d=await r.json();let tblocks=[];if(d.content_format==='blocks'&&d.content){try{tblocks=JSON.parse(d.content);}catch(e){tblocks=[];}}tblocks.push(block);await fetch(`/board/api/pages/${pageId}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify({title:d.title||'',blocks:tblocks})}); async moveBlockToPage(idx,pageId){if(idx<0||idx>=this.blocks.length||pageId===this.pid)return;this.sync();this.pushHistory();const block=this.blocks[idx];const self=this;const csrf = getCsrf();;this.showToast('Déplacement du bloc…');try{const r=await fetch(`/board/api/pages/${pageId}`,{credentials:'same-origin'});const d=await r.json();let tblocks=[];if(d.content_format==='blocks'&&d.content){try{tblocks=JSON.parse(d.content);}catch(e){tblocks=[];}}tblocks.push(block);await fetch(`/board/api/pages/${pageId}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify({title:d.title||'',blocks:tblocks})});
if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();this.showToast('Bloc déplacé'); if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();this.showToast('Bloc déplacé');
@@ -2386,7 +2499,7 @@ applyAIBlocks(text){
const ab=this.getActiveBlock();if(!ab)return; const ab=this.getActiveBlock();if(!ab)return;
const{el,bid,idx}=ab;const block=this.blocks[idx];if(!block)return; const{el,bid,idx}=ab;const block=this.blocks[idx];if(!block)return;
if(SM.isOpen){ 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==='ArrowDown'||e.key==='ArrowUp'||e.key==='Enter'){e.preventDefault();return;}
if(e.key==='Backspace'&&!SM._q&&(el.textContent||'').trim()==='/'){e.preventDefault();SM.close();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 // La requête peut être tapée DANS le bloc (« /numbered list ») si le
@@ -2594,7 +2707,7 @@ applyAIBlocks(text){
// « … » → le menu contextuel de bloc (recherche + Text + pied métadonnées) // « … » → 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(); 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);}, 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);}, fmtAskAI(){this.fmt.open=false;const i=this.fmt.idx;if(i>=0)this.askAIBlock(i);},
fmtEditAI(){this.fmtAskAI();}, fmtEditAI(){this.fmtAskAI();},
fmtSkill(tpl){this.fmt.open=false;const i=this.fmt.idx;if(i>=0)this.runSkill(i,tpl);}, fmtSkill(tpl){this.fmt.open=false;const i=this.fmt.idx;if(i>=0)this.runSkill(i,tpl);},
@@ -2674,6 +2787,30 @@ applyAIBlocks(text){
else this.showToast((d&&d.detail)||'Réaction impossible','error'); else this.showToast((d&&d.detail)||'Réaction impossible','error');
}catch(e){this.showToast('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){ openThread(cid){
this.commentsOpen=true;this.commentMenu=null; this.commentsOpen=true;this.commentMenu=null;
const self=this; const self=this;
@@ -2730,7 +2867,13 @@ applyAIBlocks(text){
chip.setAttribute('data-react-bid',g.bid); chip.setAttribute('data-react-bid',g.bid);
chip.setAttribute('data-react-start',String(g.start)); chip.setAttribute('data-react-start',String(g.start));
chip.setAttribute('data-react-end',String(g.end)); 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>':''); +(n>1?'<span class="fd-react-n">'+n+'</span>':'');
sp.parentNode.insertBefore(chip,sp.nextSibling); sp.parentNode.insertBefore(chip,sp.nextSibling);
}); });
@@ -2921,7 +3064,10 @@ applyAIBlocks(text){
const btype=block.type||'paragraph'; const btype=block.type||'paragraph';
if(['code','table','math','image','embed','meeting','divider','table_of_contents','columns'].indexOf(btype)>=0)return fallback(); if(['code','table','math','image','embed','meeting','divider','table_of_contents','columns'].indexOf(btype)>=0)return fallback();
const parsed=this.paste2b(text); const parsed=this.paste2b(text);
if(parsed.length<=1)return fallback(); if(!parsed.length)return fallback();
// un seul bloc PARAGRAPHE = coller une ligne → texte inline (comme avant) ;
// un seul bloc structuré (table, code, titre…) = vraie insertion de bloc.
if(parsed.length===1&&parsed[0].type==='paragraph')return fallback();
const sp=splitCaret(bel); const sp=splitCaret(bel);
let before='',after=''; let before='',after='';
if(sp){before=sp.before;after=sp.after;}else{before=gt(bel)||'';} if(sp){before=sp.before;after=sp.after;}else{before=gt(bel)||'';}
@@ -3077,6 +3223,12 @@ applyAIBlocks(text){
const bid=chip.getAttribute('data-react-bid'); const bid=chip.getAttribute('data-react-bid');
const start=parseInt(chip.getAttribute('data-react-start'),10); const start=parseInt(chip.getAttribute('data-react-start'),10);
const end=parseInt(chip.getAttribute('data-react-end'),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(); const el=d.getEl(bid);const rect=chip.getBoundingClientRect();
if(!window.FDIconPicker||!el||isNaN(start)||isNaN(end))return; if(!window.FDIconPicker||!el||isNaN(start)||isNaN(end))return;
window.FDIconPicker.openFor({ window.FDIconPicker.openFor({
+22
View File
@@ -10,8 +10,10 @@ Covers the five roadmap points:
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import ipaddress
import json import json
import secrets import secrets
import socket
from pathlib import Path from pathlib import Path
import httpx import httpx
@@ -209,7 +211,27 @@ _OG_HTML = """<!doctype html><html><head>
</head><body>hi</body></html>""" </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: 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): def test_parse_og_handles_attribute_order(self):
from app.services.og_fetcher import parse_og 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 "fmtApply('strikeThrough')", "fmtCode()", # ligne 2 : A B I U T Tx
"fmtLink()", "fmtHighlight()", "fmtCodeBlock()", "fmtEquation()", "fmtLink()", "fmtHighlight()", "fmtCodeBlock()", "fmtEquation()",
"fmtMore($event)", # ligne 3 : 🔗 S </> √x … "fmtMore($event)", # ligne 3 : 🔗 S </> √x …
"fmtComment()", "fmtAskAI()", # ligne 4 : 💬 ✨ "fmtComment()", "fmtReact()", # ligne 4 : 💬 Comment | 🙂 réaction
"ft-group", "Improve writing", "Proofread", "Explain", "Reformat", "ft-group", "Improve writing", "Proofread", "Explain", "Reformat",
"Edit with AI", "Alt+⇧+E", # pied de page "Edit with AI", "Alt+⇧+E", # pied de page
] ]
@@ -41,7 +41,23 @@ def test_toolbar_markup_matches_spec():
assert "data-tooltip-html=" in chunk assert "data-tooltip-html=" in chunk
# icônes réellement définies dans le jeu d'icônes # icônes réellement définies dans le jeu d'icônes
assert 'fd_icon("message-square",15)' in chunk 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 ──────── # ── 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(): def test_toolbar_has_react_button():
assert 'fmtReact()' in TPL assert 'fmtReact()' in TPL
assert 'data-tooltip="React to selected text"' 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")] 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] positions = [chunk.index(o) for o in order]
assert positions == sorted(positions), positions 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
+60
View File
@@ -0,0 +1,60 @@
"""7.68.0 — colonnes/math/TOC rendus + copier-couper-coller multi-blocs."""
from pathlib import Path
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 _fn(name: str, span: int = 1500) -> str:
i = SRC.index(name + "(")
return SRC[i:i + span]
def test_renderers_are_actually_dispatched():
# les trois renderers existaient mais n'étaient JAMAIS appelés →
# colonnes (slash + Turn into), équation et TOC rendaient un paragraphe vide
dispatch = _fn("renderBlock")
for call in ("renderColumnsBlock(b)", "renderMathBlock(b)", "renderTOC(b)"):
assert call in dispatch, call
assert SRC.count("function " + call.split("(")[0] + "(") == 1
# la suppression d'une colonne n'est plus un bouton invisible
assert ".block-column-wrapper:hover .block-actions {" in CSS
def test_gfm_tables_without_leading_pipe():
# l'IA rend souvent « A | B » / « --- | --- » — le pipe de tête est optionnel
assert "s.indexOf('|')>=0&&_tblSplitLine(s).length>=2" in SRC
# une ligne de séparation doit rester ≥2 cellules (sinon « texte » + « --- »)
assert "if(cells.length<2)return false;return cells.every" in SRC
# md2b ET paste2b passent par les mêmes helpers (alignés)
assert SRC.count("_tblIsSep(lines[i+1])") == 2
def test_clipboard_multi_block_selection():
# état : sélection vivante traversant ≥2 blocs ; sinon null (natif)
for tok in ("_clipState(){", "si>=ei)return null;", "_srcOffset(sblk,r.startContainer,r.startOffset)",
"_srcOffset(eblk,r.endContainer,r.endOffset)"):
assert tok in SRC, tok
# markdown : début partiel → blocs entiers → fin partielle
assert "_clipMarkdown(st){" in SRC
assert "blocksToMarkdown(b)" in SRC
# coupure : premier/trois restent (têtes/queues), milieu supprimé
assert "this.blocks.slice(0,st.si+1).concat([last],this.blocks.slice(st.ei+1))" in SRC
# Ctrl+C / Ctrl+X : listeners copy/cut (le clavier natif les déclenche)
assert "ct.addEventListener('copy'," in SRC
assert "ct.addEventListener('cut'," in SRC
assert "ev.clipboardData.setData('text/plain',md)" in SRC
# menu clic droit : groupe Clipboard Cut/Copy/Paste
assert "'<div class=\"bm-group\">Clipboard</div>'" in SRC
for act in ("clip-cut", "clip-copy", "clip-paste"):
assert f"data-bm-act=\"{act}\"" in SRC or f"'{act}'" in SRC, act
# « Copy link to block » : l'act du menu doit être celle du handler
assert "_blockMenuItem('copy-link'," in SRC
assert "act==='copy-link'" in SRC
def test_paste_single_structured_block_is_inserted():
# un seul bloc structuré collé (table, code, titre) ne retombe plus en texte brut
assert "if(parsed.length===1&&parsed[0].type==='paragraph')return fallback();" in SRC