Compare commits

...
7 Commits
Author SHA1 Message Date
bruno 7468e497c6 fix: le bouton close de Settings ferme en un clic (v7.69.4)
FlowDeck CI / lint (push) Successful in 1m34s
FlowDeck CI / test (push) Failing after 15m40s
FlowDeck CI / docker (push) Skipped
Le bouton close de Settings devait être cliqué deux fois. Symptomatique depuis
le menu utilisateur ; depuis /workspaces ça marchait.

Cause : closeSettings() faisait history.back() PUIS window.location.reload()
après un délai FIXE de 100 ms. Le retour est une navigation asynchrone : quand
elle n'est pas terminée en 100 ms, le reload() retombe sur /settings et le
panneau « se ferme puis revient ». La profondeur d'historique diffère selon le
parcours — d'où le caractère intermittent et le fait que /workspaces
échappait au symptôme.

Correctif : location.replace() vers la page d'origine. Une seule navigation
déterministe — ni history.back(), ni popstate, ni rechargement différé. Aucun
chemin ne peut plus se terminer sans navigation. Effet secondaire assumé et
souhaitable : « Retour » ne rouvre plus Settings.

La branche window.opener ne fait plus de return inconditionnel : window.close()
échoue en silence quand l'onglet n'a pas été ouvert par du script, et l'ancien
code abandonnait alors sans navigation de repli — le bouton semblait mort.

CONTRÔLE NÉGATIF FAIT — le même spec a été exécuté contre deux builds :
  ancien closeSettings() -> le parcours menu-utilisateur ÉCHOUE
                            (Received: "…/settings"), /workspaces passe ;
  correctif              -> les deux passent.
Le test verrouille aussi que le panneau position:fixed n'est plus peint
(.settings-overlay absent), qui est le symptôme exact rapporté.

Correction de diagnostic : le diagnostic posé en v7.69.3 (« Alpine ne lie pas
le composant x-data="settingsInit()" ») était FAUX — un artefact de mon banc
d'essai target="_blank", pas le parcours réel. La page étant réactive après le
premier clic, le handler s'exécutait bien. ROADMAP.md et CHANGELOG.md § v7.69.3
corrigés pour ne pas laisser une piste fausse en héritage.

pytest 1421 passed / 0 failed · ruff OK · OpenAPI 526 chemins / 7.69.4
e2e 18/18 verts sur l'instance déployée
2026-10-08 22:02:08 -04:00
bruno 162eb257c7 fix: la section Aide est positionnée comme Settings (v7.69.3)
FlowDeck CI / lint (push) Successful in 1m32s
FlowDeck CI / test (push) Failing after 16m17s
FlowDeck CI / docker (push) Skipped
La section Aide et la section Settings partagent le même gabarit
(`.settings-overlay` / `.settings-panel`) et la même feuille de style
(`static/css/settings.css`), mais affichaient deux rendus différents.

Cause : `help.html` neutralisait son overlay via un style INLINE sur la balise
— `position:static`, `background:none`, `backdrop-filter:none`, `padding:0` —
et imposait par-dessus un panneau `width:100%;max-width:1050px;
height:calc(100vh - 120px);margin:0 auto`. Settings, lui, laissait
`settings.css` faire son travail : overlay `fixed;inset:0` avec fond flouté,
panneau `85vh` centré par `margin:auto`.

Résultat perçu : Aide s'affichait comme un bloc posé dans la page, Settings
comme une modale centrée floutant l'arrière-plan — malgré un code quasi
identique.

Correctif : les surcharges inline sont retirées de `help.html`. La géométrie
vient désormais entièrement de `settings.css`, commun aux deux pages. Aucune
valeur n'est dupliquée ; les deux balises sont strictement identiques.

Vérifié en e2e en comparant les géométries CALCULÉES des deux pages (pas des
valeurs de code) : overlay `fixed` avec fond opère des deux côtés, panneau
1050x765 centré à top:68px, bouton close à 13/13px du coin du panneau — les
deux jeux de mesures sont identiques.

Tests : `tests/test_v7693_help_positioning.py` (5) verrouille l'absence de
surcharges inline et l'identité de balise avec Settings ;
`e2e/v7693_help_positioning.spec.js` compare les géométries calculées.

NON INCLUS — double-clic sur le bouton close de Settings :
le bug a été REPRODUIT en e2e et sa cause racine identifiée, mais le correctif
n'est volontairement PAS dans ce commit. Voir ROADMAP.md § v7.69.3 pour le
relevé complet et la prochaine étape.

pytest 1421 passed / 0 failed · ruff OK · e2e 15/15 verts sur l'instance déployée
2026-10-08 21:07:17 -04:00
bruno b9a776b626 fix: le favicon est enfin le vrai logo, plus un placeholder (v7.69.2)
FlowDeck CI / lint (push) Successful in 1m34s
FlowDeck CI / test (push) Failing after 3h6m20s
FlowDeck CI / docker (push) Skipped
Le favicon était un carré bleu #2383E2 avec les lettres « FD » écrites en
<text> SVG : génériques, sans aucun lien avec la marque, et rendues avec la
police installée chez le visiteur (donc instables).

static/favicon.ico est désormais généré depuis assets/logo.jpg — la même
source que la bannière et les icônes PWA. 16/32/48 px dans un seul fichier,
un seul <link> pour tous les navigateurs, 4,1 Ko.

Pourquoi un fond sombre et pas le logo nu :
  le logo est blanc à ~82 % (mesuré via Pillow — 50,7 % de blanc pur, plus
  32 % de blancs proches). Posé nu, il se dissoudrait sur un onglet clair,
  qui est le cas nominal de Firefox et de Safari. Il est donc posé sur un
  carré #191919 arrondi, LE MÊME fond que les icônes PWA générées en
  v7.69.0, ce qui garde l'identité cohérente entre onglet, écran d'accueil
  et partage.

Contraste mesuré à 16 px : σ = 106 avec le fond sombre, contre σ = 49 pour
le logo seul — le fond sombre double littéralement la lisibilité à cette
taille. Le test statique verrouille les tailles embarquées dans l'ICO.

Mise à jour des 5 templates qui pointaient vers l'ancien SVG (base, import,
landing, public_page, welcome) + la liste de pré-cache du service worker.
static/favicon.svg SUPPRIMÉ : plus aucune référence dans le dépôt, le test
le vérifie pour éviter qu'il revienne.

pytest 1416 passed / 0 failed · ruff OK · OpenAPI 526 chemins / 7.69.2
2026-10-08 19:41:37 -04:00
bruno 674baf6954 fix: og:image et twitter:image en URL absolue (v7.69.1)
FlowDeck CI / lint (push) Successful in 1m34s
FlowDeck CI / test (push) Failing after 3h12m7s
FlowDeck CI / docker (push) Skipped
Les meta `og:image` / `twitter:image` ajoutés en v7.69.0 pointaient vers une
URL relative (`/static/img/logo-512.jpg`). Les crawlers — LinkedIn, Slack,
Discord, iMessage — ignorent une URL relative : la carte de partage de FlowDeck
était donc vide partout où un lien est collé.

Correction via le motif déjà en place dans `app/templating.py` : un global
Jinja `app_base_url()` construit à partir de `settings.app_base_url`, qui est
LA MÊME source que les liens de partage de pages (`collaboration.py:32`).
Si cette valeur est mal renseignée, ces liens l'étaient déjà — comportement
cohérent plutôt qu'une deuxième source de vérité à maintenir.

Lecture paresseuse de `settings` dans la fonction, à l'image de
`_plugin_enabled` : un import au chargement du module créerait un import
circulaire.

Rendu vérifié sur l'instance déployée :
  og:image" content="http://localhost:8080/static/img/logo-512.jpg

Tests : `test_og_image_url_is_absolute_via_base_url` vérifie le global ET
l'absence de la forme relative dans les deux templates. Piège évité au passage
— la forme absolue CONTIENT l'ancienne en sous-chaîne, donc l'assertion
négative porte sur `content="/static/img/` (le guillemet avant la barre) et
non sur l'URL complète.

pytest 1413 passed / 0 failed · ruff OK · OpenAPI 526 chemins / 7.69.1
e2e 14/14 verts sur l'instance redéployée
2026-10-08 19:00:13 -04:00
bruno 3d3b46b58b fix: sélection multi-blocs fonctionnelle (Ctrl+C/X, drag souris, clic droit) + identité visuelle (v7.69.0)
FlowDeck CI / lint (push) Successful in 1m34s
FlowDeck CI / test (push) Failing after 17m31s
FlowDeck CI / docker (push) Skipped
Trois bugs d'éditeur, chacun avec sa cause racine prouvée par trace
d'événements — le mécanisme Clipboard livré en v7.68 était inutilisable.

1) Ctrl+C / Ctrl+X multi-blocs ne faisaient RIEN.
   Cause : le navigateur ne sait pas sérialiser une sélection qui traverse
   deux `contenteditable` (chaque bloc en a un). `getSelection().toString()`
   renvoie `""` et Chromium n'émet AUCUN événement `copy` — les handlers
   `ct.addEventListener('copy')` n'étaient donc jamais atteints.
   → le raccourci est intercepté au keydown (capture sur document, garde
   `closest('#_blocksCt')` pour ne pas voler Ctrl+C ailleurs) et le markdown
   est écrit dans le presse-papier par nos soins. Sélection mono-bloc =
   comportement natif inchangé.

2) Glisser la souris d'un bloc à l'autre ne sélectionnait qu'un bloc.
   Cause : `mouseup` (on reconstruit la plage) → `click` → `focusin` → le
   navigateur efface la sélection. La plage était reconstruite puis détruite
   dans la foulée.
   → la plage reconstruite est mise en attente (`_mdSelRange`) et restaurée
   au tour de boucle suivant si le focus l'a effectivement effacée. Chaque
   nouveau geste (mousedown) remet l'attente à zéro, donc un clic pour
   placer le curseur reste normal.

3) Le clic droit effaçait le surlignage.
   Cause : `.bm-search` prenait le focus 10 ms après l'ouverture du menu.
   → le menu ne vole plus le focus quand un texte est sélectionné. Les
   actions Clipboard utilisaient déjà la sélection figée à l'ouverture
   (`d._clipSt`) ; le `preventDefault` du clic droit est remonté sur
   `mousedown` (sur `contextmenu` il arrive trop tard).

Identité visuelle (bannière + logo du projet ajoutés à la racine) :
- assets optimisés servis : `static/img/banner.webp` 22,5 Ko (l'original
  fait 1,6 Mo) + JPEG de secours, `logo.webp` 5,3 Ko + `logo-512.jpg` ;
  originaux conservés dans `assets/` pour régénération.
- icônes PWA régénérées à partir du vrai logo (72 → 512 px, marge
  maskable sur 192/512) + apple-touch-icon.
- landing : logo dans la barre de navigation, bannière en <picture>
  (WebP + JPEG) avec dimensions déclarées (pas de décalage de mise en page).
- `og:image`, `twitter:card` et `meta description` ajoutés sur la landing
  ET dans base.html — la landing a son propre <head>.

Tests : `tests/test_v769_selection_persistence.py` (19 statiques) +
`e2e/v769_selection_persistence.spec.js` (5, vrais gestes : drag souris,
Ctrl+C/Ctrl+X au clavier, clic droit). `e2e/v768_columns_clipboard.spec.js`
corrigé : les gestes simulés (`execCommand`, plage fabriquée en evaluate)
sont remplacés par de vrais gestes, et le token statique obsolète
`si>=ei)return null;` aligné sur la garde `isCollapsed` réelle.

pytest 1413 passed / 0 failed · ruff OK · OpenAPI 526 chemins / 7.69.0
2026-10-08 17:27:14 -04:00
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
49 changed files with 1730 additions and 56 deletions
+257
View File
@@ -1,5 +1,262 @@
# Changelog - FlowDeck
## v7.69.4 (2026-10-08) — Le close de Settings ferme vraiment
### Fixed
- **Le bouton close de Settings devait être cliqué deux fois** — symptomatique
depuis le menu utilisateur ; depuis `/workspaces` ça marchait.
- Cause : `closeSettings()` faisait `history.back()` **puis**
`window.location.reload()` après un délai **fixe de 100 ms**. Le retour est
une navigation asynchrone : quand elle n'est pas terminée en 100 ms, le
`reload()` retombe sur `/settings` et le panneau « se ferme puis revient ».
La profondeur d'historique diffère selon le parcours, d'où le caractère
intermittant et le fait que `/workspaces` échappait au symptôme.
- → `location.replace()` vers la page d'origine : **une seule navigation
déterministe**, ni `history.back()`, ni `popstate`, ni rechargement différé.
Plus aucun chemin ne peut se terminer sans bouger. Effet secondaire
souhaitable : « Retour » ne rouvre plus Settings.
- La branche `window.opener` ne fait plus de `return` inconditionnel :
`window.close()` échoue en silence quand l'onglet n'a pas été ouvert par du
script, et l'ancien code abandonnait alors sans navigation de repli.
### Tests
- `e2e/v7694_settings_close.spec.js` — **contrôle négatif fait** : le même
spec a été exécuté contre deux builds.
- ancien `closeSettings()` → le parcours menu-utilisateur **échoue**
(`Received: "…/settings"`), `/workspaces` passe ;
- correctif → **les deux passent**.
Le test verrouille aussi que le panneau `position:fixed` n'est plus peint
(`.settings-overlay` absent), qui est le symptôme exact rapporté.
### Correctifs de diagnostic
- Le diagnostic posé en v7.69.3 (« Alpine ne lie pas le composant ») était
**faux** — un artefact de mon banc d'essai `target="_blank"`, pas le parcours
réel. Corrigé dans `ROADMAP.md`.
## v7.69.3 (2026-10-08) — La section Aide est positionnée comme Settings
### Fixed
- **Aide s'affichait autrement que Settings** alors qu'ils partagent le même
gabarit (`settings-overlay` / `settings-panel`) et le même CSS. `help.html`
neutralisait son overlay **en style inline** : `position:static`,
`background:none`, `backdrop-filter:none`, `padding:0`, et forçait un panneau
`100% / max-width:1050px / height:calc(100vh - 120px)`. Résultat : Aide
était un bloc posé dans la page, Settings une modale centrée floutant le fond.
- → les surcharges inline sont retirées ; la géométrie vient entièrement de
`settings.css`, commun aux deux pages. Vérifié en e2e : **mêmes valeurs
mesurées** des deux côtés (overlay `fixed` avec fond, panneau 1050×765
centré à `top:68px`, bouton close à 13/13 px du coin du panneau).
### Tests
- `tests/test_v7693_help_positioning.py` (5) verrouillent l'absence de
surcharge inline et l'identité de balise avec Settings ;
`e2e/v7693_help_positioning.spec.js` compare les géométries calculées.
> **Corrigé en v7.69.4** : le double-clic sur le bouton close de Settings.
> Le diagnostic porté ici (« Alpine ne lie pas le composant ») était **faux**,
> voir `CHANGELOG.md § v7.69.4`.
## v7.69.2 (2026-10-08) — Le favicon est enfin le vrai logo
### Fixed
- **Le favicon était un placeholder** : un carré bleu `#2383E2` avec les lettres
« FD » écrites en `<text>` SVG — génériques, sans lien avec la marque, et
rendus avec la police du visiteur.
- → `static/favicon.ico` généré depuis `assets/logo.jpg` : 16/32/48 px dans un
seul fichier (un `<link>` pour tous les navigateurs), 4,1 Ko.
**Pourquoi un fond sombre** : le logo est blanc à ~82 % (mesuré : 50,7 % de
blanc pur + 32 % de blancs proches). Posé nu, il disposerait sur un onglet clair
— cas nominal de Firefox et Safari en thème clair. Il est donc posé sur un
carré `#191919` arrondi, **le même fond que les icônes PWA** générées en v7.69.0,
ce qui garde une identité cohérente. Contraste mesuré à 16 px : σ = 106 (contre
σ = 49 pour le logo seul — le fond sombre double la lisibilité).
Mise à jour des **5 templates** qui référençaient l'ancien SVG (`base`,
`import`, `landing`, `public_page`, `welcome`) + la liste de pré-cache du
service worker. `static/favicon.svg` **supprimé** : plus aucune référence.
### Tests
- `test_favicon_is_the_real_logo` (tailles ICO, absence du placeholder) +
`test_every_template_points_at_the_real_favicon` (5 templates + `sw.js`).
## v7.69.1 (2026-10-08) — `og:image` en URL absolue
### Fixed
- **`og:image` / `twitter:image` étaient des URL relatives** — les crawlers
(LinkedIn, Slack, Discord, iMessage…) ignorent une URL relative : la carte de
partage était vide. → global Jinja `app_base_url()` construit à partir de
`settings.app_base_url`, **la même source que les liens de partage de pages**
(`collaboration.py`) : si elle est mal renseignée, ces liens l'étaient déjà.
Lecture paresseuse, comme `_plugin_enabled`, pour éviter l'import circulaire.
## v7.69.0 (2026-10-08) — La sélection multi-blocs fonctionne enfin (+ identité visuelle)
### Fixed
- **Ctrl+C / Ctrl+X multi-blocs ne faisaient RIEN** — cause racine : le navigateur
ne sait pas sérialiser une sélection qui traverse deux `contenteditable` (chaque
bloc en a un). Résultat : `getSelection().toString()` renvoie `""` et Chromium
**n'émet aucun événement `copy`** — nos handlers `ct.addEventListener('copy')`
n'étaient donc jamais atteints, et le raccourci mourait en silence.
→ le raccourci est maintenant intercepté au `keydown` (capture sur `document`,
garde `closest('#_blocksCt')` pour ne pas voler Ctrl+C ailleurs) : on écrit le
markdown dans le presse-papier nous-mêmes. La sélection mono-bloc reste native.
- `Ctrl+X` supprime les blocs traversés en conservant la tête du premier et la
queue du dernier.
- **Glisser la souris d'un bloc à l'autre sélectionnait un seul bloc** — cause
racine : la séquence complète est `mouseup` (on reconstruit la plage) → `click`
→ `focusin` → **le navigateur efface la sélection**. La plage était donc
reconstruite puis détruite dans la foulée. → la plage reconstruite est mise en
attente (`_mdSelRange`) et restaurée au tour de boucle suivant, si le focus
l'a effectivement effacée. Un nouveau geste (mousedown) remet l'attente à zéro,
donc un clic pour placer le curseur reste normal.
- **Le clic droit effaçait le surlignage** — la recherche du menu prenait le
focus 10 ms après son ouverture, ce qui détruisait la sélection. → on ne vole
plus le focus quand un texte est sélectionné. (Les actions Clipboard utilisaient
déjà la sélection figée à l'ouverture du menu : `d._clipSt`.)
- Le `preventDefault` du clic droit est bien sur `mousedown` (sur `contextmenu`
il arrive trop tard — le curseur a déjà déplacé la sélection).
### Added
- **Identité visuelle** : la bannière et le logo du projet sont intégrés.
- assets optimisés : `static/img/banner.webp` **22,5 Ko** (l'original fait
1,6 Mo) + `banner.jpg` de secours, `logo.webp` **5,3 Ko** + `logo-512.jpg` ;
originaux conservés dans `assets/` pour régénération.
- icônes PWA régénérées à partir du vrai logo (72 → 512 px, marge maskable
sur 192/512) + `apple-touch-icon`.
- landing : logo dans la barre de navigation, bannière en `<picture>`
(WebP + JPEG) avec dimensions déclarées pour éviter tout décalage de mise en
page ; `og:image`, `twitter:card` et `meta description` ajoutés au `<head>`.
### Tests
- `tests/test_v769_selection_persistence.py` (19 tests statiques) +
`e2e/v769_selection_persistence.spec.js` (5 tests, **vrais gestes** : glisser
de souris, `Ctrl+C`/`Ctrl+X` au clavier, clic droit).
- `e2e/v768_columns_clipboard.spec.js` : les gestes simulés (`execCommand`,
plage fabriquée en `evaluate`) sont remplacés par de vrais gestes.
## 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
+125
View File
@@ -1501,6 +1501,131 @@ Détails livrés :
`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
### v7.69.0 — La sélection multi-blocs fonctionne enfin ✅ (2026-10-08)
**Contexte** : v7.68.0 livrait la mécanique Clipboard (plage multi-blocs, handlers
`copy`/`cut`, groupe Clipboard du menu contextuel) mais elle était **inutilisable en
pratique** — trois rapports d'usage : Ctrl+C ne copie rien, le glisser souris ne
sélectionne qu'un bloc, le clic droit efface le surlignage.
**Causes racines trouvées (trace d'événements à l'appui, pas de supposition)** :
| Symptôme | Cause réelle |
|----------|--------------|
| Ctrl+C/X muet | le navigateur **n'émet aucun événement `copy`** quand la sélection traverse deux `contenteditable` : `getSelection().toString()` renvoie `""`, donc nos handlers n'étaient jamais atteints |
| Glisser = 1 bloc | séquence `mouseup` → `click` → `focusin` → **collapse** : la plage reconstruite mourait dans la foulée |
| Clic droit efface | `.bm-search` prenait le focus 10 ms après l'ouverture du menu |
**Corrections** : interception du raccourci au `keydown` (capture `document`,
garde `closest('#_blocksCt')`) ; plage reconstruite mise en attente
(`_mdSelRange`) et restaurée au tour suivant, remise à zéro à chaque nouveau
geste ; le menu ne vole plus le focus quand un texte est sélectionné ;
`preventDefault` du clic droit remonté sur `mousedown` (sur `contextmenu` c'est
trop tard).
**Identité visuelle** : bannière + logo intégrés — WebP 22,5 Ko / 5,3 Ko (les
originaux faisaient 1,6 Mo / 1,1 Mo), JPEG de secours, icônes PWA régénérées à
partir du vrai logo, `<picture>` sur la landing, `og:image`/`twitter:card`.
**Vérification** : `tests/test_v769_selection_persistence.py` (19 statiques) +
`e2e/v769_selection_persistence.spec.js` (5, **vrais gestes**) ; le spec v768 a
été corrigé pour remplacer `execCommand` et les plages fabriquées par de vrais
gestes.
**Piège de test relevé au passage** : les `···` dans les échecs Playwright sont
le marqueur de troncature du message d'erreur, **pas** un bug produit — une heure
passée à chercher une fuite inexistante. Vérifier `JSON.stringify()` de la valeur
réelle avant de croire un affichage tronqué.
---
### v7.69.3 — Aide alignée sur Settings ✅ · double-clic close ⏳ ouvert (2026-10-08)
**Livré** : la section Aide partage maintenant le positionnement exact de la
section Settings. `help.html` neutralisait son overlay en style inline
(`position:static`, fond transparent, sans blur, sans padding) et forçait un
panneau `1050px / 100vh-120px` — les deux pages pourtant identiques au CSS
près produisaient deux rendus. Surcharges retirées ; e2e compare les
géométries calculées : **identiques des deux côtés** (overlay `fixed` avec
fond, panneau 1050×765 centré, close à 13/13 px du coin).
**Terminé en v7.69.4** — double-clic sur le bouton close de Settings.
Le diagnostic posé en v7.69.3 était **FAUX** : je pensais qu'Alpine ne liait
pas le composant (`_x_dataStack` absent), donc que le handler était inerte.
La question à l'utilisateur a tranché : *« après le premier clic la page est
réactive »* → Alpine lie bien le composant, le handler s'exécute. Le scénario
`target="_blank"` qui m'avait donné `_x_dataStack` absent était un artefact de
mon propre banc d'essai, pas le parcours réel. **Ne pas relire ce diagnostic.**
Cause réelle : `closeSettings()` faisait `history.back()` puis
`window.location.reload()` après un délai **fixe de 100 ms**. Entre les deux,
le retour est une navigation asynchrone ; quand elle n'est pas terminée en
100 ms, le `reload()` retombe sur `/settings` — le panneau « se ferme puis
revient ». Depuis `/workspaces`, la profondeur d'historique diffère et le
retour arrive à temps : d'où l'absence de symptôme sur ce seul parcours.
Correctif : `location.replace()` vers la page d'origine. Une seule navigation
déterministe, ni `history.back()`, ni `popstate`, ni rechargement différé.
Effet secondaire assumé et souhaitable : « Retour » ne rouvre plus Settings.
**Preuve avant/après (contrôle négatif fait)** : même spec, deux builds.
- ancien `closeSettings()` → le test du parcours menu-utilisateur **ÉCHOUE**
(`Received: "…/settings"`), celui de `/workspaces` passe ;
- correctif → **les deux passent**.
**Piège relevé** : ma « réfutation » de la thèse de course était fausse. J'avais
interposé 400 ms via `page.route()`, qui intercepte les requêtes de sous-ressources
— pas la navigation du document lors d'un `history.back()`, servie depuis le
bfcache. Le délai n'a donc jamais été appliqué. Un contrôle négatif (rebuild
avec l'ancien code) aurait tranché en un tour ; il a fallu plusieurs tours pour
le faire.
---
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
+1 -1
View File
@@ -1 +1 @@
7.66.0
7.69.4
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.66.0 (Escape du menu slash retire le / + bouton commenter à droite du bloc) | **Statut**: EN COURS 🔄
> **Début**: 2026-07-08 | **Version**: v7.69.4 (sélection multi-blocs fonctionnelle ; identité visuelle logo/bannière/favicon ; Aide alignée sur Settings ; close Settings réparé)
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
## Avancement Global
+1 -1
View File
@@ -186,7 +186,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.66.0",
version="7.69.4",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+4 -1
View File
@@ -26,7 +26,10 @@ def _is_public_host(host: str) -> bool:
return False
try:
infos = socket.getaddrinfo(host, None)
except socket.gaierror:
except OSError:
# gaierror, socket.timeout, herror… : le garde-fou doit TOUJOURS
# renvoyer un booléen, jamais lever — sinon l'appelant tombe dans son
# `except Exception` (bookmarks) au lieu d'un refus explicite.
return False
for info in infos:
address = info[4][0]
+22 -7
View File
@@ -590,7 +590,20 @@
<button type="button" class="topbar-btn" @click="toggleComments()" title="Close">✕</button>
</div>
<div class="comments-drawer-list" style="flex:1;overflow-y:auto;padding:12px 16px;">
<div x-show="comments.length === 0" style="text-align:center;color:var(--text-dim,#999);padding:32px 0;">
<!-- v7.67 : les réactions emoji du texte vivent aussi ici (✕ = retirer) -->
<div class="ct-reactions" x-show="reactionList().length" x-cloak style="margin-bottom:14px;">
<div class="ct-section-title">Reactions</div>
<template x-for="r in reactionList()" :key="r.key">
<div class="ct-reaction">
<span class="ct-re-emoji" x-text="r.emoji"></span>
<span class="ct-re-count" x-show="r.count > 1" x-text="r.count"></span>
<span class="ct-re-text" x-text="r.text"></span>
<button type="button" class="ct-act" title="Remove reaction"
x-show="r.mine" @click="removeReaction(r)">✕</button>
</div>
</template>
</div>
<div x-show="comments.length === 0 && !reactionList().length" style="text-align:center;color:var(--text-dim,#999);padding:32px 0;">
No comments yet. Select some text and click 💬 Comment.
</div>
<template x-for="c in comments" :key="c.id">
@@ -666,23 +679,25 @@
<div class="ft-row">
<button type="button" class="ft-item" @click="fmtLink()" data-tooltip="Link">{{ fd_icon("link",15) }}</button>
<button type="button" class="ft-item" @click="fmtHighlight()" data-tooltip="Highlight"><span class="ft-hl">S</span></button>
<button type="button" class="ft-item ft-react" @click="fmtReact()" data-tooltip="React to selected text"><span class="ft-react-ico">🙂<span class="ft-react-plus">+</span></span></button>
<button type="button" class="ft-item ft-code-ico" @click="fmtCodeBlock()" data-tooltip="Code block">&lt;/&gt;</button>
<button type="button" class="ft-item" @click="fmtEquation()" data-tooltip="Equation">√x</button>
<button type="button" class="ft-item ft-more" @click="fmtMore($event)" data-tooltip="More actions">…</button>
</div>
<!-- Ligne 4 : actions -->
<div class="ft-row">
<button type="button" class="ft-item" @click="fmtComment()" data-tooltip="Comment — Ctrl+⇧+M">{{ fd_icon("message-square",15) }}</button>
<button type="button" class="ft-item" @click="fmtAskAI()" data-tooltip="Ask AI — Ctrl+J">{{ fd_icon("sparkles",15) }}</button>
<!-- Ligne 4 : Comment à gauche, réaction à droite (le ✨ Ask AI sauté :
doublon exact du bouton du pied, même action) -->
<div class="ft-row ft-row-split">
<button type="button" class="ft-item ft-comment" @click="fmtComment()" data-tooltip="Comment — Ctrl+⇧+M">{{ fd_icon("message-square",15) }}<span>Comment</span></button>
<button type="button" class="ft-item ft-react" @click="fmtReact()" data-tooltip="React to selected text"><span class="ft-react-ico">🙂<span class="ft-react-plus">+</span></span></button>
</div>
<!-- Section « Skills » -->
<div class="ft-sep"></div>
<div class="ft-group">Skills</div>
<div class="ft-group ft-skills-head">Skills</div>
<div class="ft-skills">
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Improve the writing of this text (wording, clarity, style). Keep the meaning, language and format. Reply ONLY with the improved text.')" data-tooltip="Run on this block">Improve writing</button>
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Proofread this text: fix spelling and grammar mistakes only. Reply ONLY with the corrected text.')" data-tooltip="Run on this block">Proofread</button>
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Explain this text in simpler terms, in the same language. Reply ONLY with the explanation.')" data-tooltip="Run on this block">Explain</button>
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Reformat this text for readability (structure, spacing, lists where useful) without changing its meaning. Reply ONLY with the reformatted text.')" data-tooltip="Run on this block">Reformat</button>
</div>
<!-- Pied de page -->
<div class="ft-sep"></div>
<button type="button" class="ft-item ft-foot" @click="fmtEditAI()" data-tooltip="Edit with AI">
+12 -1
View File
@@ -4,10 +4,21 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlowDeck — {% block title_prefix %}Home{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="icon" href="/static/favicon.ico" sizes="48x48">
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#191919">
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<!-- v7.69 : identité visuelle (logo + bannière du projet) -->
<meta name="description" content="FlowDeck — espace de travail style Notion auto-hébergé, intégré à Gitea/GitHub.">
<meta property="og:type" content="website">
<meta property="og:site_name" content="FlowDeck">
<meta property="og:title" content="FlowDeck — votre espace Notion auto-hébergé">
<meta property="og:description" content="Éditeur de blocs, Kanban, bases de données et intégration Gitea/GitHub, sur votre propre serveur.">
<meta property="og:image" content="{{ app_base_url() }}/static/img/logo-512.jpg">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="FlowDeck — votre espace Notion auto-hébergé">
<meta name="twitter:description" content="Éditeur de blocs, Kanban, bases de données et intégration Gitea/GitHub.">
<meta name="twitter:image" content="{{ app_base_url() }}/static/img/logo-512.jpg">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="/static/css/app.css?v={{ asset_version }}">
+5 -2
View File
@@ -30,8 +30,11 @@
.help-badge.sso{background:rgba(217,115,13,.18);color:#E0952B;}
</style>
<div class="settings-overlay" x-data="helpInit()" style="background:none;backdrop-filter:none;position:static;z-index:1;padding:0;">
<div class="settings-panel" style="width:100%;max-width:1050px;height:calc(100vh - 120px);margin:0 auto;" @keydown.escape="navOpen = false">
<!-- v7.69.3 : positionnement IDENTIQUE à la section Settings — les surcharges
inline (position:static / background:none / panneau 1050px) faisaient
d'Aide un bloc posé dans la page au lieu de l'overlay centré de Settings. -->
<div class="settings-overlay" x-data="helpInit()">
<div class="settings-panel" @keydown.escape="navOpen = false">
<button class="settings-menu-btn" @click="navOpen = !navOpen" title="Sections" aria-label="Open sections menu">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Importer — FlowDeck</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="icon" href="/static/favicon.ico" sizes="48x48">
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
<style>
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;--warn:#D9730D;}
+37 -2
View File
@@ -5,12 +5,23 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% from '_icons.html' import fd_icon %}
<title>FlowDeck — All-in-one workspace</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="icon" href="/static/favicon.ico" sizes="48x48">
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#191919">
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<!-- v7.69 : identité visuelle — la landing est la page la plus partagée -->
<meta name="description" content="FlowDeck — espace de travail style Notion auto-hébergé, intégré à Gitea/GitHub. Éditeur de blocs, Kanban, bases de données.">
<meta property="og:type" content="website">
<meta property="og:site_name" content="FlowDeck">
<meta property="og:title" content="FlowDeck — votre espace Notion auto-hébergé">
<meta property="og:description" content="Éditeur de blocs, Kanban, bases de données et intégration Gitea/GitHub, sur votre propre serveur.">
<meta property="og:image" content="{{ app_base_url() }}/static/img/logo-512.jpg">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="FlowDeck — votre espace Notion auto-hébergé">
<meta name="twitter:description" content="Éditeur de blocs, Kanban, bases de données et intégration Gitea/GitHub.">
<meta name="twitter:image" content="{{ app_base_url() }}/static/img/logo-512.jpg">
<style>
:root {
--bg: #191919;
@@ -112,6 +123,22 @@
padding:12px 28px;
}
/* v7.69 : identité visuelle */
.brand-mark{border-radius:7px;flex:none;}
.hero-visual{
max-width:1060px;
margin:0 auto 72px;
padding:0 32px;
}
.hero-visual img{
width:100%;
height:auto;
display:block;
border-radius:14px;
border:1px solid var(--border);
box-shadow:0 30px 70px -30px rgba(0,0,0,.5);
}
/* Features grid */
.features{
display:grid;
@@ -148,7 +175,7 @@
<nav class="landing-nav">
<a href="/" class="landing-logo">
<span>{{ fd_icon("book",20) }}</span> FlowDeck
<img class="brand-mark" src="/static/img/logo.webp" alt="" width="26" height="26"> FlowDeck
</a>
<div class="landing-nav-actions">
<a href="/auth/login?provider=local" class="btn btn-secondary">Log in</a>
@@ -169,6 +196,14 @@
</div>
</section>
<!-- v7.69 : bannière du projet (asset optimisé : WebP 22 KB, JPEG en secours) -->
<div class="hero-visual">
<picture>
<source srcset="/static/img/banner.webp" type="image/webp">
<img src="/static/img/banner.jpg" alt="FlowDeck — éditeur de blocs, Kanban et bases de données" width="2400" height="793" loading="lazy" decoding="async">
</picture>
</div>
<div class="features">
<div class="feature-card">
<div class="feature-icon">{{ fd_icon("edit",24) }}</div>
+1 -1
View File
@@ -5,7 +5,7 @@
{% from '_icons.html' import fd_icon %}
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }} — FlowDeck</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="icon" href="/static/favicon.ico" sizes="48x48">
<style>
:root {
--bg: #191919;
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bienvenue sur FlowDeck</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="icon" href="/static/favicon.ico" sizes="48x48">
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
<style>
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;}
+12
View File
@@ -79,6 +79,18 @@ ENV.globals["csp_nonce"] = lambda: CSP_NONCE.get()
ENV.globals["csrf_token"] = lambda: CSRF_TOKEN.get()
# Plugins activés : conditionne les blocs de UI rendus côté serveur.
ENV.globals["plugin_enabled"] = lambda slug: _plugin_enabled(slug)
# v7.69 : URL de base absolue pour og:image / twitter:image — les crawlers
# ignorent les URL relatives. Lecture paresseuse comme _plugin_enabled
# (évite l'import circulaire au chargement du module). Même source que les
# liens de partage de pages (collaboration.py) : si elle est mal renseignée,
# ces liens le sont déjà.
ENV.globals["app_base_url"] = lambda: _app_base_url()
def _app_base_url() -> str:
from app.config import settings
return (settings.app_base_url or "").rstrip("/")
def _plugin_enabled(slug: str) -> bool:
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.66.0"
"version": "7.69.4"
},
"paths": {
"/auth/register": {
+6
View File
@@ -70,6 +70,12 @@ test('toolbar de sélection : structure, sous-menus, persistance', async ({ page
await expect(bar.locator('.ft-skill')).toHaveCount(4);
await expect(bar.locator('.ft-skill').first()).toHaveText('Improve writing');
await expect(bar.locator('.ft-foot')).toContainText('Edit with AI');
// refonte v7.67 : ligne 4 = Comment (libellé) à gauche, réaction à droite ;
// entête Skills avec ≡ ; plus de bouton ✨ Ask AI (doublon du pied)
await expect(bar.locator('.ft-comment')).toContainText('Comment');
await expect(bar.locator('.ft-row-split .ft-react')).toHaveCount(1);
await expect(bar.locator('.ft-skills-head')).toHaveText('Skills');
await expect(bar.locator('[data-tooltip^="Ask AI"]')).toHaveCount(0);
// géométrie : sous la sélection, entièrement dans le viewport
const barBox = await bar.boundingBox();
const selBox = await page.evaluate(() => {
+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]')).toHaveText('reaction a un');
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1, { timeout: 10000 });
// ── 9. v7.67 : la réaction vit aussi dans le tiroir Comments ──
await page.locator('.topbar-btn[title="Comments"]').click();
const re = page.locator('.ct-reactions');
await expect(re).toBeVisible();
await expect(re.locator('.ct-re-emoji')).toHaveCount(1);
await expect(re.locator('.ct-re-text')).toHaveText('Prise de reaction a un texte');
await page.screenshot({ path: 'shots/v764-drawer-reactions.png' });
// ── 10. suppression A : ✕ de la section Réactions ──
await re.locator('.ct-reaction .ct-act').click();
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(0);
await expect(re).toBeHidden();
// ── 11. suppression B : la croix survolée de la pastille ──
await page.locator('.topbar-btn[title="Comments"]').click(); // referme le tiroir
await selectBlock(page);
await page.locator('.format-toolbar .ft-react').click();
const picker2 = page.locator('.fd-icon-picker');
await expect(picker2).toBeVisible();
await picker2.locator('.fdip-emoji').first().click();
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1);
const emo = page.locator('#_blocksCt .fd-react-emo.is-mine');
await expect(emo).toHaveCount(1);
// la croix n'apparaît qu'au survol (::after)
await expect.poll(() => emo.evaluate((el) => getComputedStyle(el, '::after').display)).toBe('none');
await page.locator('#_blocksCt .fd-react-chip').hover();
await expect.poll(() => emo.evaluate((el) => getComputedStyle(el, '::after').display)).toBe('flex');
await emo.click();
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(0);
});
+185
View File
@@ -0,0 +1,185 @@
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;
}
// VRAIE sélection multi-blocs : glisser la souris du 1er bloc jusqu'au 3e
// (le navigateur seul ne franchit jamais deux contenteditable)
async function dragAcross(page, last = 2) {
const b1 = await page.locator('#_blocksCt [data-bid]').first().boundingBox();
const b3 = await page.locator('#_blocksCt [data-bid]').nth(last).boundingBox();
// on part du tout début du 1er bloc et on va jusqu'à la fin du dernier
await page.mouse.move(b1.x + 2, b1.y + b1.height / 2);
await page.mouse.down();
for (let i = 1; i <= 8; i++) {
await page.mouse.move(b1.x + 2, b1.y + b1.height / 2 + ((b3.y - b1.y) * i) / 8);
}
await page.mouse.move(b3.x + b3.width - 2, b3.y + b3.height / 2);
await page.mouse.up();
}
// sélection multi-blocs au CLAVIER : caret en fin de bloc 1, Maj+↓ Maj+↓
async function shiftExtend(page) {
// VRAI geste : un clic met le caret dans le bloc, End va à sa fin,
// Maj+↓ l'étend ensuite dans les blocs suivants.
const el = page.locator('#_blocksCt [data-bid]').first();
const b = await el.boundingBox();
await page.mouse.click(b.x + 2, b.y + b.height / 2);
await page.keyboard.press('End');
await page.keyboard.press('Shift+ArrowDown');
await page.keyboard.press('Shift+ArrowDown');
await page.waitForTimeout(80);
}
// état de la sélection côté éditeur
const clipState = (page) => page.evaluate(() => window.E._clipState());
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' },
]);
// sélection multi-blocs au CLAVIER (Maj+↓) — geste réel
await shiftExtend(page);
const st = await clipState(page);
expect(st.si).toBe(0);
expect(st.ei).toBe(2);
// VRAI raccourci clavier (pas execCommand) : c'est lui qui était cassé
await page.keyboard.press('Control+c');
await page.waitForTimeout(250);
const clip = await page.evaluate(() => navigator.clipboard.readText());
expect(clip).toContain('## Titre deux'); // le titre sort en markdown
expect(clip).toContain('Troisieme bloc');
// coupe (raccourci Ctrl+X) : on recouvre les 3 blocs du début à la fin
await dragAcross(page);
await page.keyboard.press('Control+x');
await page.waitForTimeout(300);
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.keyboard.press('Control+v');
await page.waitForTimeout(300);
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 dragAcross(page, 1);
// la sélection multi-blocs doit SURVIVRE au clic droit (le navigateur
// déplacerait sinon le curseur dans le bloc cliqué)
expect((await clipState(page)).ei).toBe(1);
await page.locator('#_blocksCt [data-bid]').nth(1).click({ button: 'right', position: { x: 30, y: 6 } });
expect((await clipState(page)).ei).toBe(1);
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');
});
+62
View File
@@ -0,0 +1,62 @@
// v7.69.3 — la section Aide doit avoir le MÊME positionnement que Settings
const { test, expect } = require('@playwright/test');
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';
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');
await page.waitForURL('**/workspaces', { timeout: 8000 }).catch(() => {});
}
// géométrie de l'overlay + du panneau, telle que perçue à l'écran
const geo = (page) =>
page.evaluate(() => {
const ov = document.querySelector('.settings-overlay');
const pn = document.querySelector('.settings-panel');
const o = getComputedStyle(ov);
const p = getComputedStyle(pn);
const r = pn.getBoundingClientRect();
return {
ovPosition: o.position,
ovBgSet: o.backgroundColor !== 'rgba(0, 0, 0, 0)',
pnWidth: Math.round(r.width),
pnHeight: Math.round(r.height),
pnCentered: Math.abs(r.left - (window.innerWidth - r.width) / 2) < 24,
pnTop: Math.round(r.top),
closeTop: Math.round(document.querySelector('.settings-close').getBoundingClientRect().top - r.top),
closeRight: Math.round(r.right - document.querySelector('.settings-close').getBoundingClientRect().right),
};
});
test('Aide et Settings partagent le même positionnement', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/help`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(400);
const h = await geo(page);
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(400);
const s = await geo(page);
console.log('HELP =', JSON.stringify(h));
console.log('SETTING=', JSON.stringify(s));
// l'overlay de Aide n'est plus désactivé en inline
expect(h.ovPosition).toBe(s.ovPosition);
expect(h.ovPosition).toBe('fixed');
expect(h.ovBgSet).toBe(true);
// panneau centré et de la même taille que Settings
expect(h.pnCentered).toBe(true);
expect(h.pnWidth).toBe(s.pnWidth);
expect(h.pnHeight).toBe(s.pnHeight);
// le bouton close occupe la même place relative au panneau
expect(h.closeTop).toBe(s.closeTop);
expect(h.closeRight).toBe(s.closeRight);
await page.goto(`${FD_BASE}/help`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(300);
await page.screenshot({ path: 'shots/v7693-help-overlay.png' });
});
+72
View File
@@ -0,0 +1,72 @@
// v7.69.4 — le bouton close de Settings doit fermer le panneau, y compris
// quand Settings est ouvert depuis le MENU UTILISATEUR (navigation partielle).
//
// `shouldIntercept()` accepte `/settings`, donc ce clic passe par fdLoad()
// (htmx + history.pushState) SANS rechargement de page. L'ancien closeSettings()
// faisait `history.back()` → popstate → swap PARTIEL de .main-wrapper, et le
// panneau `position:fixed` restait peint : « ça ferme mais ça reste ouvert ».
// Depuis /workspaces, c'est un chargement complet → pas de symptôme.
const { test, expect } = require('@playwright/test');
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';
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');
await page.waitForURL('**/workspaces', { timeout: 8000 }).catch(() => {});
}
const csrfOf = async (page) => {
const c = await page.context().cookies();
return (c.find((x) => x.name === 'csrf_token') || {}).value || '';
};
async function makeWorkspace(page) {
const r = await page.request.post(`${FD_BASE}/api/workspaces`, {
headers: { 'X-CSRF-Token': await csrfOf(page) },
data: { name: `e2e-cls-${Date.now()}-${Math.random().toString(16).slice(2, 6)}` },
});
const j = await r.json();
return j.id || (j.workspace && j.workspace.id);
}
// ouvre Settings depuis le menu utilisateur — le chemin qui provoque le bug
async function openSettingsFromUserMenu(page) {
await page.locator('.workspace-avatar, .user-avatar').first().click();
await page.locator('.um-item[href="/settings"]').first().click();
await page.waitForSelector('button.settings-close', { timeout: 8000 });
}
test('close ferme Settings ouvert depuis le menu utilisateur (nav partielle)', async ({ page }) => {
await login(page);
const id = await makeWorkspace(page);
await page.goto(`${FD_BASE}/local-workspace?ws=${id}`, { waitUntil: 'domcontentloaded' });
await openSettingsFromUserMenu(page);
expect(page.url()).toContain('/settings');
await page.locator('button.settings-close').click();
await page.waitForTimeout(900);
// plus sur /settings…
expect(page.url()).not.toContain('/settings');
expect(page.url()).toContain('/local-workspace');
// …et surtout : le panneau `position:fixed` ne doit PLUS être peint. C'est
// le symptôme exact rapporté (« ça ferme mais ça reste ouvert »).
expect(await page.locator('.settings-overlay').count()).toBe(0);
await expect(page.locator('button.settings-close')).toHaveCount(0);
});
test('close ferme Settings ouvert par chargement complet depuis /workspaces', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await openSettingsFromUserMenu(page);
expect(page.url()).toContain('/settings');
await page.locator('button.settings-close').click();
await page.waitForTimeout(900);
expect(page.url()).not.toContain('/settings');
expect(await page.locator('.settings-overlay').count()).toBe(0);
});
+164
View File
@@ -0,0 +1,164 @@
// v7.69.0 — la sélection multi-blocs SURVIT aux gestes qui l'effaçaient :
// 1) glisser la souris d'un bloc à l'autre
// 2) Ctrl+C sur cette sélection
// 3) clic droit → le menu n'efface plus le surlignage
const { test, expect } = require('@playwright/test');
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) {
await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } });
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=sel ${Date.now()}`, {
headers: { 'X-CSRF-Token': csrf },
});
const { id } = await created.json();
if (body) {
await page.request.post(`${FD_BASE}/board/api/pages/${id}/blocks`, {
headers: { 'X-CSRF-Token': csrf },
data: { title: 'sel', blocks: body },
});
}
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
}
const BLOCKS = [
{ id: 'b1', type: 'paragraph', content: 'Premier bloc' },
{ id: 'b2', type: 'heading_2', content: 'Titre deux' },
{ id: 'b3', type: 'paragraph', content: 'Troisieme bloc' },
];
// état natif de la sélection
const nativeSel = (page) =>
page.evaluate(() => {
const s = window.getSelection();
return { rc: s.rangeCount, coll: s.isCollapsed, txt: s.toString().slice(0, 40) };
});
const clipState = (page) => page.evaluate(() => window.E._clipState());
// VRAI glisser de souris : le navigateur seul ne franchit jamais deux
// contenteditable — c'est _buildSelAt qui reconstruit la plage.
async function dragAcross(page, last = 2) {
const b1 = await page.locator('#_blocksCt [data-bid]').first().boundingBox();
const bEnd = await page.locator('#_blocksCt [data-bid]').nth(last).boundingBox();
// on part du tout début du 1er bloc et on va jusqu'à la fin du dernier
await page.mouse.move(b1.x + 2, b1.y + b1.height / 2);
await page.mouse.down();
for (let i = 1; i <= 8; i++) {
await page.mouse.move(b1.x + 2, b1.y + b1.height / 2 + ((bEnd.y - b1.y) * i) / 8);
}
await page.mouse.move(bEnd.x + bEnd.width - 2, bEnd.y + bEnd.height / 2);
await page.mouse.up();
await page.waitForTimeout(80);
}
test('glisser la souris sur 3 blocs construit une sélection multi-blocs qui survit', async ({ page }) => {
await login(page);
await newPage(page, BLOCKS);
await dragAcross(page);
// la plage reconstruite ne doit PAS être effacée par le « click » suivant
const s = await nativeSel(page);
expect(s.coll).toBe(false);
expect(s.rc).toBe(1);
const st = await clipState(page);
expect(st.si).toBe(0);
expect(st.ei).toBe(2);
await page.screenshot({ path: 'shots/v769-drag.png' });
});
// VRAI raccourci clavier (pas execCommand) : c'est ce qui était cassé.
// Maj+↓ depuis la fin du bloc 1 → la sélection traverse les 3 blocs.
async function keyboardExtend(page) {
const b1 = await page.locator('#_blocksCt [data-bid]').first().boundingBox();
await page.mouse.click(b1.x + 2, b1.y + b1.height / 2);
await page.keyboard.press('End');
await page.keyboard.press('Shift+ArrowDown');
await page.keyboard.press('Shift+ArrowDown');
await page.waitForTimeout(80);
}
test('Ctrl+C copie la sélection multi-blocs en markdown', async ({ page }) => {
await login(page);
await newPage(page, BLOCKS);
await keyboardExtend(page);
expect((await clipState(page)).ei).toBe(2);
await page.keyboard.press('Control+c'); // le vrai raccourci
await page.waitForTimeout(250);
const clip = await page.evaluate(() => navigator.clipboard.readText());
expect(clip).toContain('## Titre deux'); // le titre sort en markdown
expect(clip).toContain('Troisieme bloc');
// Ctrl+X retire les blocs traversés (tête + queue restent)
await page.keyboard.press('Control+x');
await page.waitForTimeout(300);
await expect(page.locator('#_blocksCt [data-bid]')).toHaveCount(2);
});
test("clic droit : le menu s'ouvre SANS effacer le surlignage", async ({ page }) => {
await login(page);
await newPage(page, BLOCKS);
await dragAcross(page);
expect((await nativeSel(page)).coll).toBe(false);
await page.locator('#_blocksCt [data-bid]').nth(1).click({ button: 'right', position: { x: 30, y: 6 } });
await page.waitForTimeout(150);
// le surlignage reste visible
const s = await nativeSel(page);
expect(s.coll).toBe(false);
expect(s.txt.length).toBeGreaterThan(0);
// …et le groupe Clipboard est bien proposé
const menu = page.locator('#blockMenu');
await expect(menu).toBeVisible();
await expect(menu).toContainText('Clipboard');
await expect(menu.locator('[data-bm-act="clip-copy"]')).toBeVisible();
await page.screenshot({ path: 'shots/v769-rightclick.png' });
});
test('clic droit → Copy via le menu colle bien le markdown figé', async ({ page }) => {
await login(page);
await newPage(page, BLOCKS);
await dragAcross(page);
await page.locator('#_blocksCt [data-bid]').nth(1).click({ button: 'right', position: { x: 30, y: 6 } });
const menu = page.locator('#blockMenu');
await expect(menu.locator('[data-bm-act="clip-copy"]')).toBeVisible();
await menu.locator('[data-bm-act="clip-copy"]').click();
await page.waitForTimeout(200);
const clip = await page.evaluate(() => navigator.clipboard.readText());
expect(clip).toContain('Premier bloc');
expect(clip).toContain('## Titre deux');
});
test('un clic simple dans un bloc place quand même le curseur (pas de sélection fantôme)', async ({ page }) => {
await login(page);
await newPage(page, BLOCKS);
await dragAcross(page);
// le clic suivant est un NOUVEL geste : il doit effacer la sélection
const b1 = await page.locator('#_blocksCt [data-bid]').first().boundingBox();
await page.mouse.click(b1.x + 15, b1.y + b1.height / 2);
await page.waitForTimeout(120);
const st = await clipState(page);
expect(st).toBeNull();
});
+82
View File
@@ -2865,6 +2865,11 @@ button.breadcrumb-current { max-width: 260px; }
border-radius: 50%;
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 {
outline: none;
min-height: 24px;
@@ -3040,6 +3045,30 @@ button.breadcrumb-current { max-width: 260px; }
letter-spacing: 1px;
}
/* ── v7.67 : refonte façon capture — ligne Comment | réaction aux 2 extrémités,
entête « Skills » avec ≡ à droite, liste Skills scrollable ── */
.format-toolbar .ft-row-split {
justify-content: space-between;
gap: 4px;
}
.format-toolbar .ft-comment {
justify-content: flex-start;
}
.format-toolbar .ft-skills-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.format-toolbar .ft-skills-head::after {
content: '≡';
font-size: 13px;
color: var(--text-dim);
}
.format-toolbar .ft-skills {
max-height: 132px;
overflow-y: auto;
}
/* Séparateurs, groupe « Skills », pied de page */
.format-toolbar .ft-sep {
height: 1px;
@@ -3212,6 +3241,28 @@ button.breadcrumb-current { max-width: 260px; }
.fd-react-chip img { width: 12px; height: 12px; object-fit: contain; }
.fd-react-n { font-size: 10px; color: var(--text-dim); }
/* v7.67 : suppression d'une réaction — au survol de la pastille, l'émoji que
J'ai posé devient une croix (un seul clic = retirer). Pas de reflow : c'est
un ::after, les offsets source ne bougent pas. */
.fd-react-emo { position: relative; display: inline-flex; align-items: center; }
.fd-react-emo.is-mine::after {
content: '✕';
position: absolute;
inset: -3px;
display: none;
align-items: center;
justify-content: center;
font-size: 9px;
line-height: 1;
color: var(--text-dim);
background: var(--bg-modal, #1f1f1f);
border: 1px solid var(--border, #333);
border-radius: 50%;
cursor: pointer;
}
.fd-react-chip:hover .fd-react-emo.is-mine::after { display: flex; }
.fd-react-emo.is-mine:hover::after { color: #e5484d; border-color: #e5484d; }
/* Tiroir de commentaires (fil + saisie) */
.comment-thread {
position: relative;
@@ -3251,6 +3302,37 @@ button.breadcrumb-current { max-width: 260px; }
.ct-menu-item.is-danger { color: #e5484d; }
.ct-body { font-size: 13px; white-space: pre-wrap; margin-top: 6px; color: var(--text-primary); }
.ct-anchor-label { font-size: 10px; color: var(--text-dim); margin-top: 6px; }
/* v7.67 : section Réactions du tiroir (liste des plages réagies + retirer) */
.ct-section-title {
font-size: 11px;
font-weight: 600;
letter-spacing: .5px;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 8px;
}
.ct-reaction {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
margin-bottom: 6px;
background: var(--bg-secondary, #2a2a2a);
border: 1px solid var(--border, #333);
border-radius: 8px;
font-size: 13px;
}
.ct-re-emoji { font-size: 14px; line-height: 1; flex-shrink: 0; }
.ct-re-count { font-size: 11px; color: var(--text-dim); }
.ct-re-text {
flex: 1;
min-width: 0;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.comments-drawer-input { border-top: 1px solid var(--border, #333); padding: 12px 16px; }
.ct-input-row { display: flex; gap: 6px; align-items: flex-end; }
.ct-input-row textarea {
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#2383E2"/>
<text x="16" y="22" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#fff">FD</text>
</svg>

Before

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 999 B

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 624 B

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 767 B

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

+251 -9
View File
@@ -163,6 +163,15 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
}
return t;
}
// v7.69 : position de caret à des coordonnées — la sélection native ne
// franchit pas les blocs (chacun a son contenteditable), on la rebâtit.
function _caretAt(x,y){
if(document.caretPositionFromPoint){const q=document.caretPositionFromPoint(x,y);
if(q&&q.offsetNode){const r=document.createRange();r.setStart(q.offsetNode,q.offset||0);r.collapse(true);return r;}}
if(document.caretRangeFromPoint)return document.caretRangeFromPoint(x,y);
return null;
}
function _blkOf(n){let e=n&&(n.nodeType===1?n:n.parentElement);return e&&e.closest?e.closest('[data-bid]'):null;}
function _emojiHtml(v){
const s=String(v||'');
if(/^(https?:|\/)/.test(s))return '<img src="'+esc(s)+'" alt="">';
@@ -236,8 +245,11 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
/* ═══════════ 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 _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);});}
function _tblIsData(s){s=(s||'').trim();return /^\|/.test(s)&&s.indexOf('|',1)>=0;}
// v7.68 : le pipe de tête est OPTIONNEL en GFM — l'IA rend souvent
// « 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 _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,'');});}
@@ -744,6 +756,12 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
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==='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==='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>`;
@@ -1112,6 +1130,8 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
// ═══════════ v5.10.0 — Interactions de bloc : sélection multiple, menu ⋮⋮, undo/redo ═══════════
let _selSet=new Set();
var _mdSelDown=null; // v7.69 : point de départ du glisser de sélection
var _mdSelRange=null; // v7.69 : plage reconstruite, à restaurer après le collapse du focus
function _selHas(bid){return _selSet.has(bid);}
function _selToggle(bid){if(!bid)return;if(_selSet.has(bid))_selSet.delete(bid);else _selSet.add(bid);
document.querySelectorAll('.block-wrapper[data-id="'+bid+'"]').forEach(function(w){w.classList.toggle('selected',_selSet.has(bid));});}
@@ -1185,6 +1205,16 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
else if(act==='suggest'){E.suggestEdit(idx);}
else if(act==='ai'){E.askAIBlock(idx);}
else if(act==='skill'){E.runSkill(idx,t.getAttribute('data-prompt')||'');}
// 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==='turn-type'){E.turnInto(idx,t.getAttribute('data-type'),parseInt(t.getAttribute('data-cols')||'0',10)||0);_blockMenuClose();return;}
_blockMenuClose();
@@ -1229,11 +1259,24 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
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 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">'
+clip
+'<div class="bm-group">Text</div>'
+_blockMenuItem('submenu','↩','Turn into',{sub:'turn'})
+_blockMenuItem('submenu','🎨','Color',{sub:'color'})
+_blockMenuItem('link','🔗','Copy link to block',{shortcut:S.link})
+_blockMenuItem('copy-link','🔗','Copy link to block',{shortcut:S.link})
+_blockMenuItem('duplicate','▣','Duplicate',{shortcut:S.duplicate})
+_blockMenuItem('move-to','↪','Move to…',{shortcut:S.move})
+_blockMenuItem('delete','✕','Delete',{shortcut:S.delete,danger:true})
@@ -1249,7 +1292,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
d.querySelectorAll('.bm-item').forEach(function(it){
it.style.display=(q&&it.textContent.toLowerCase().indexOf(q)<0)?'none':'';
});
var g=d.querySelector('.bm-group');if(g)g.style.display=q?'none':'';
d.querySelectorAll('.bm-group').forEach(function(g){g.style.display=q?'none':'';});
});
d.addEventListener('mouseover',function(ev){
var it=ev.target&&ev.target.closest?ev.target.closest('[data-sub]'):null;
@@ -1262,7 +1305,13 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
x=Math.min(Math.max(8,x),window.innerWidth-r.width-8);
y=Math.min(Math.max(8,y),window.innerHeight-r.height-8);
d.style.left=x+'px';d.style.top=y+'px';
var si=d.querySelector('.bm-search');if(si)setTimeout(function(){si.focus();},10);
// v7.69 : on ne vole PAS le focus quand un texte est sélectionné — sinon
// le navigateur efface le surlignage dès que le menu apparaît (les
// actions Clipboard gardent de toute façon la sélection figée ci-dessus).
var si=d.querySelector('.bm-search');
var sLive=window.getSelection();
var hasSel=sLive&&sLive.rangeCount&&!sLive.isCollapsed;
if(si&&!hasSel)setTimeout(function(){si.focus();},10);
}
// Panneau de droite : coquille commune des 3 sous-menus
function _blockSubShell(x,y){
@@ -1377,6 +1426,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
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('.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();
_blockMenuClose();
}
@@ -1463,7 +1513,58 @@ 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;
if(e.shiftKey){_selToggle(wid);return;}
_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._clipState)return;const st=E._clipState();if(!st||st.si>=st.ei)return;const md=E.clipCopy(st);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._clipState)return;const st=E._clipState();if(!st||st.si>=st.ei)return;const md=E.clipCut(st);if(md==null)return;ev.preventDefault();ev.clipboardData.setData('text/plain',md);E.showToast('Sélection coupée');});
// v7.69 : Ctrl+C/X sur une sélection MULTI-BLOCS. Le navigateur ne
// sait pas sérialiser une sélection qui traverse deux contenteditable
// : il n'émet AUCUN événement copy et le raccourci ne fait rien. On
// prend donc le raccourci à la main. (Un menu contextuel ne peut pas
// faire confiance à getSelection(), d'où d._clipSt figé à l'ouverture.)
document.addEventListener('keydown',function(ev){
if(!(ev.ctrlKey||ev.metaKey))return;
const k=(ev.key||'').toLowerCase();
if(k!=='c'&&k!=='x')return;
if(!ev.target||!ev.target.closest||!ev.target.closest('#_blocksCt'))return; // pas dans la recherche d'un menu
const E=window.E;if(!E||!E._clipState)return;
const st=E._clipState();if(!st||st.si>=st.ei)return; // sélection mono-bloc → natif
const md=(k==='x'?E.clipCut(st):E.clipCopy(st));
if(md==null)return;
ev.preventDefault();ev.stopPropagation();
const done=()=>E.showToast(k==='x'?'Sélection coupée':'Sélection copiée');
if(navigator.clipboard&&navigator.clipboard.writeText)navigator.clipboard.writeText(md).then(done).catch(()=>{});else done();
},true);
// v7.69 : glisser de sélection entre blocs + clic droit préservant la sélection
ct.addEventListener('mousedown',function(ev){
_mdSelRange=null; // un nouveau geste remet à zéro la sélection en attente
if(ev.button===2){const s=window.getSelection();if(s&&s.rangeCount&&!s.isCollapsed)ev.preventDefault();return;}
if(ev.button===0)_mdSelDown={x:ev.clientX,y:ev.clientY};
},true);
// en CAPTURE : SortableJS (initié sur ce même conteneur) peut
// stopPropagation en fin de glisser → le bubble n'atteint plus document
document.addEventListener('mouseup',function(ev){
const d=_mdSelDown;_mdSelDown=null;
if(!d||ev.button!==0)return;
if(Math.abs(ev.clientX-d.x)<4&&Math.abs(ev.clientY-d.y)<4)return;
const E=window.E;if(E&&E._buildSelAt)E._buildSelAt(d.x,d.y,ev.clientX,ev.clientY);
},true);
// v7.69 : le « click » d'un contenteditable efface toute sélection
// existante (focusin → collapse). On la remet juste après.
document.addEventListener('focusin',function(){
if(!_mdSelRange)return;
var r=_mdSelRange;_mdSelRange=null;
setTimeout(function(){
var s=window.getSelection();
if(s&&s.rangeCount&&!s.isCollapsed)return; // survécue : rien à faire
try{s.removeAllRanges();s.addRange(r);}catch(e){}
},0);
},true);
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);}
},100);
@@ -1960,7 +2061,104 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
_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');}},
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.69 : glisser la souris de bloc A vers bloc B — le navigateur ne
// sélectionne jamais à travers deux contenteditable, on reconstruit la
// plage au relâchement avec les carets sous le pointeur.
_buildSelAt(x0,y0,x1,y1){
const r0=_caretAt(x0,y0),r1=_caretAt(x1,y1);
if(!r0||!r1)return;
const b0=_blkOf(r0.startContainer),b1=_blkOf(r1.startContainer);
if(!b0||!b1||b0===b1)return; // même bloc → la sélection native suffit
const a=document.createRange();
if(r0.compareBoundaryPoints(Range.START_TO_START,r1)<=0){
a.setStart(r0.startContainer,r0.startOffset);a.setEnd(r1.startContainer,r1.startOffset);
}else{
a.setStart(r1.startContainer,r1.startOffset);a.setEnd(r0.startContainer,r0.startOffset);
}
if(a.collapsed)return;
const s=window.getSelection();s.removeAllRanges();s.addRange(a);
_mdSelRange=a; // le « click » suivant va effacer ça : on garde la copie
},
// v7.69 : Maj+↓ / Maj+↑ au début/fin d'un bloc → la sélection saute
// dans le bloc voisin (extension native impossible : blocs séparés).
_shiftBlockExtend(dir){
// le bloc de départ est celui du BOUT MOBILE de la sélection (le
// focus), pas l'élément focalisé : après une première extension le
// caret vit déjà dans le bloc voisin.
const s=window.getSelection();if(!s||!s.rangeCount)return false;
const fn=s.focusNode;if(!fn)return false;
const sbl=_blkOf(fn);if(!sbl)return false;
const si=this.getIdx(sbl.dataset.bid);if(si<0)return false;
const off=_srcOffset(sbl,fn,s.focusOffset),len=_srcNodeLen(sbl);
if(dir>0&&off<len)return false; // le navigateur étend encore dans le bloc
if(dir<0&&off>0)return false;
const nb=this.blocks[si+dir];if(!nb)return false;
const nel=this.getEl(nb.id);if(!nel)return false;
const r=s.getRangeAt(0).cloneRange();
if(dir>0)r.setEnd(nel,nel.childNodes.length);else r.setStart(nel,0);
if(r.collapsed)return false;
s.removeAllRanges();s.addRange(r);
return true;
},
// ── 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);
if(st.si===st.ei){
const b=this.blocks[st.si],bel=this.getEl(b.id);
b.content=this._anchorText(b.id,0,st.startOff)+this._anchorText(b.id,st.endOff,bel?_srcNodeLen(bel):0);
this.dirty=true;this.autoSave();this.render();_rtSync();
this._focusBid(b.id);
return md;
}
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;},
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é');
@@ -2418,6 +2616,11 @@ applyAIBlocks(text){
}
return;
}
// v7.69 : Maj+↓ / Maj+↑ franchit le bloc (les blocs sont des
// contenteditable distincts : le navigateur ne peut pas étendre)
if(e.shiftKey&&(e.key==='ArrowDown'||e.key==='ArrowUp')){
if(this._shiftBlockExtend(e.key==='ArrowDown'?1:-1)){e.preventDefault();return;}
}
if(window.AIAC&&window.AIAC.active){
if(e.key==='Tab'){e.preventDefault();e.stopPropagation();window.AIAC.accept();return;}
if(e.key==='Escape'){e.preventDefault();window.AIAC.clear();return;}
@@ -2695,6 +2898,30 @@ applyAIBlocks(text){
else this.showToast((d&&d.detail)||'Réaction impossible','error');
}catch(e){this.showToast('Réaction impossible','error');}
},
// ── v7.67 : les réactions vivent aussi dans le tiroir Comments ──
// (une ligne par plage × émoji, déjà groupées par l'API)
reactionList(){
return (this.reactions||[]).map(r=>{
const bid=r.block_id,start=parseInt(r.anchor_start,10),end=parseInt(r.anchor_end,10);
return {key:bid+':'+start+':'+end+':'+r.emoji,bid:bid,start:start,end:end,emoji:r.emoji,
count:parseInt(r.count,10)||1,mine:!!parseInt(r.mine,10),
text:this._anchorText(bid,start,end)};
});
},
// suppression : POST toggle (le backend retire MA ligne si elle existe)
removeReaction(r){if(!r)return;this.toggleReaction(r.bid,r.start,r.end,r.emoji);},
// texte source d'une plage ancrée (hors pastilles .fd-react-chip)
_anchorText(bid,start,end){
const el=this.getEl(bid);if(!el||!(end>start))return '';
const w=document.createTreeWalker(el,NodeFilter.SHOW_TEXT);let n,pos=0,txt='';
while((n=w.nextNode())){
if(n.parentElement&&n.parentElement.closest('.fd-react-chip'))continue;
const len=n.nodeValue.length,a=Math.max(start,pos),b=Math.min(end,pos+len);
if(b>a)txt+=n.nodeValue.slice(a-pos,b-pos);
pos+=len;if(pos>=end)break;
}
return txt;
},
openThread(cid){
this.commentsOpen=true;this.commentMenu=null;
const self=this;
@@ -2751,7 +2978,13 @@ applyAIBlocks(text){
chip.setAttribute('data-react-bid',g.bid);
chip.setAttribute('data-react-start',String(g.start));
chip.setAttribute('data-react-end',String(g.end));
chip.innerHTML=g.items.map(function(i){return '<span class="fd-react-emo">'+_emojiHtml(i.emoji)+'</span>';}).join('')
// chaque émoji porte son data-emoji ; `is-mine` = j'ai réagi →
// la croix de suppression (CSS ::after au survol) ne s'affiche
// que là, un clic sur les autres rouvre le sélecteur.
chip.innerHTML=g.items.map(function(i){
const q=String(i.emoji||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;');
return '<span class="fd-react-emo'+(parseInt(i.mine,10)?' is-mine':'')+'" data-emoji="'+q+'">'+_emojiHtml(i.emoji)+'</span>';
}).join('')
+(n>1?'<span class="fd-react-n">'+n+'</span>':'');
sp.parentNode.insertBefore(chip,sp.nextSibling);
});
@@ -2942,7 +3175,10 @@ applyAIBlocks(text){
const btype=block.type||'paragraph';
if(['code','table','math','image','embed','meeting','divider','table_of_contents','columns'].indexOf(btype)>=0)return fallback();
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);
let before='',after='';
if(sp){before=sp.before;after=sp.after;}else{before=gt(bel)||'';}
@@ -3098,6 +3334,12 @@ applyAIBlocks(text){
const bid=chip.getAttribute('data-react-bid');
const start=parseInt(chip.getAttribute('data-react-start'),10);
const end=parseInt(chip.getAttribute('data-react-end'),10);
// v7.67 : clic sur la croix d'un émoji à moi → retire MA réaction
const emo=t.closest('.fd-react-emo');
if(emo&&emo.classList.contains('is-mine')){
d.removeReaction({bid:bid,start:start,end:end,emoji:emo.getAttribute('data-emoji')});
return;
}
const el=d.getEl(bid);const rect=chip.getBoundingClientRect();
if(!window.FDIconPicker||!el||isNaN(start)||isNaN(end))return;
window.FDIconPicker.openFor({
+19 -18
View File
@@ -1215,26 +1215,27 @@ function settingsInit() {
finally { this.backupRunning = false; }
},
closeSettings() {
if (window.opener && !window.opener.closed) {
window.opener.location.reload();
window.close();
return;
}
// If navigated directly to settings, redirect to workspaces (forces refresh)
if (!document.referrer || document.referrer === window.location.href) {
window.location.href = '/workspaces?ts=' + Date.now();
return;
}
// Go back and force refresh
// v7.69.4 — « ça ferme mais ça reste ouvert » quand Settings est ouvert
// depuis le menu utilisateur : `shouldIntercept()` accepte `/settings`,
// donc ce clic passe par fdLoad() (htmx + history.pushState) SANS
// rechargement de page. L'ancien history.back() déclenchait alors un
// popstate → swap PARTIEL de .main-wrapper, et le panneau `position:
// fixed` restait peint au-dessus. Depuis /workspaces c'est un
// chargement complet, d'où l'absence de symptôme.
// → navigation explicite et déterministe : une seule requête, tout le
// document est détruit, aucun chemin ne peut se terminer sans bouger.
var prev = document.referrer;
if (prev && prev.includes('/settings')) {
// Came from another settings tab — go back two steps then refresh
history.go(-2);
setTimeout(function() { window.location.reload(); }, 100);
} else {
history.back();
setTimeout(function() { window.location.reload(); }, 100);
if (window.opener && !window.opener.closed) {
// fermeture au mieux — on ne peut PAS vérifier qu'elle a réussi,
// donc on enchaîne toujours sur la navigation ci-dessous.
try { window.opener.location.reload(); } catch (e) { /* opener cross-origin */ }
try { window.close(); } catch (e) { /* ignoré */ }
}
var back = '/workspaces';
if (prev && prev.indexOf(location.origin) === 0 && prev.indexOf('/settings') < 0) {
back = prev.split('#')[0];
}
location.replace(back + (back.indexOf('?') >= 0 ? '&' : '?') + 'ts=' + Date.now());
},
};
}
+1 -1
View File
@@ -30,7 +30,7 @@ const PRECACHE_URLS = [
'/static/js/katex.min.js?v=0.16.11',
'/static/js/prism.min.js',
// Icônes & métadonnées
'/static/favicon.svg',
'/static/favicon.ico',
'/static/manifest.json',
'/static/icons/icon-192x192.png',
'/static/icons/icon-512x512.png',
+22
View File
@@ -10,8 +10,10 @@ Covers the five roadmap points:
from __future__ import annotations
import asyncio
import ipaddress
import json
import secrets
import socket
from pathlib import Path
import httpx
@@ -209,7 +211,27 @@ _OG_HTML = """<!doctype html><html><head>
</head><body>hi</body></html>"""
#: IP publique factice — TestOGParser ne doit dépendre ni du réseau HTTP
#: (MockTransport) ni du DNS : `_is_public_host` résout l'hôte pour décider si
#: l'IP est publique, et cette résolution échoue sous `pytest -n auto` (même
#: piège que test_agent_web_tools.stub_dns — d'où un flake récurrent ici).
PUBLIC_IP = "93.184.216.34"
class TestOGParser:
@pytest.fixture(autouse=True)
def stub_dns(self, monkeypatch):
real = socket.getaddrinfo
def fake_getaddrinfo(host, *args, **kwargs):
try:
ipaddress.ip_address(host)
except ValueError:
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (PUBLIC_IP, 0))]
return real(host, *args, **kwargs)
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
def test_parse_og_handles_attribute_order(self):
from app.services.og_fetcher import parse_og
+18 -2
View File
@@ -29,7 +29,7 @@ def test_toolbar_markup_matches_spec():
"fmtApply('strikeThrough')", "fmtCode()", # ligne 2 : A B I U T Tx
"fmtLink()", "fmtHighlight()", "fmtCodeBlock()", "fmtEquation()",
"fmtMore($event)", # ligne 3 : 🔗 S </> √x …
"fmtComment()", "fmtAskAI()", # ligne 4 : 💬 ✨
"fmtComment()", "fmtReact()", # ligne 4 : 💬 Comment | 🙂 réaction
"ft-group", "Improve writing", "Proofread", "Explain", "Reformat",
"Edit with AI", "Alt+⇧+E", # pied de page
]
@@ -41,7 +41,23 @@ def test_toolbar_markup_matches_spec():
assert "data-tooltip-html=" in chunk
# icônes réellement définies dans le jeu d'icônes
assert 'fd_icon("message-square",15)' in chunk
assert 'fd_icon("sparkles",15)' in chunk and 'fd_icon("link",15)' in chunk
assert 'fd_icon("link",15)' in chunk
def test_toolbar_row4_matches_reference_layout():
"""v7.67 — ligne Comment à gauche, réaction à droite, Skills avec ≡."""
chunk = _slice(TPL, 'class="format-toolbar"', "Copy Link Toast")
# ligne 4 = une seule rangée scannée, boutons aux 2 extrémités
assert 'ft-row-split' in chunk
assert '<span>Comment</span>' in chunk # libellé, pas juste l'icône
assert chunk.index("fmtComment()") < chunk.index("fmtReact()")
# l'✨ Ask AI a sauté : doublon exact du « Edit with AI » du pied
assert '"fmtAskAI()"' not in chunk
# entête Skills (≡ en CSS) + liste scrollable
assert 'ft-skills-head' in chunk and 'ft-skills' in chunk
for sel in (".format-toolbar .ft-row-split {", ".format-toolbar .ft-comment {",
".format-toolbar .ft-skills-head::after {", ".format-toolbar .ft-skills {"):
assert sel in CSS, sel
# ── Actions : câblées vers de vraies méthodes, sous-menus réutilisés ────────
+2 -2
View File
@@ -71,9 +71,9 @@ def test_toggle_reaction_api(client):
def test_toolbar_has_react_button():
assert 'fmtReact()' in TPL
assert 'data-tooltip="React to selected text"' in TPL
# position : 3ᵉ ligne, après le surlignage, avant le code
# position (v7.67) : 4ᵉ ligne, à droite de « Comment », après les outils
chunk = TPL[TPL.index('class="format-toolbar"'):TPL.index("Copy Link Toast")]
order = ["fmtHighlight()", "fmtReact()", "fmtCodeBlock()", "fmtMore($event)"]
order = ["fmtMore($event)", "fmtComment()", "fmtReact()", "ft-group"]
positions = [chunk.index(o) for o in order]
assert positions == sorted(positions), positions
+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
+66
View File
@@ -0,0 +1,66 @@
"""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(){", "!s||!s.rangeCount||s.isCollapsed)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
# copy/cut restent branchés (menu contextuel, execCommand)…
assert "ct.addEventListener('copy'," in SRC
assert "ct.addEventListener('cut'," in SRC
assert "ev.clipboardData.setData('text/plain',md)" in SRC
# …mais le VRAI raccourci clavier est intercepté au keydown : le navigateur
# n'émet aucun événement copy pour une sélection à travers deux
# contenteditable (v7.69).
assert "document.addEventListener('keydown'," in SRC
assert "if(k!=='c'&&k!=='x')return;" 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
+53
View File
@@ -0,0 +1,53 @@
"""v7.69.3 — la section Aide doit être positionnée comme la section Settings.
Auparavant `help.html` neutralisait l'overlay en inline
(`position:static;background:none;backdrop-filter:none`) et forçait un panneau
`1050px / 100vh-120px` : Aide s'affichait comme un bloc posé dans la page
tandis que Settings était une modale centrée. Le même gabarit partagé
(`.settings-overlay` / `.settings-panel`) produisait donc deux rendus.
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SETTINGS = (ROOT / "app" / "templates" / "settings.html").read_text(encoding="utf-8")
HELP = (ROOT / "app" / "templates" / "help.html").read_text(encoding="utf-8")
def _opening_tag(src: str, css_class: str) -> str:
"""La balise d'ouverture qui porte cette classe, sans ses attributs."""
line = next(row for row in src.splitlines() if f'class="{css_class}"' in row)
# coupe avant x-data ET avant n'importe quel handler @keydown(.escape)
return line.split(" x-data=")[0].split(" @keydown")[0].strip()
class TestHelpMatchesSettingsPositioning:
def test_help_overlay_no_longer_disables_itself(self):
# l'overlay d'Aide EST celui de Settings : aucune surcharge inline ne
# peut subsister sur cette balise (le positionnement vient du CSS
# partagé). On asserte sur la BALISE, pas sur le fichier entier — le
# commentaire de tête du template mentionne ces valeurs à l'infinitif.
assert _opening_tag(HELP, "settings-overlay") == '<div class="settings-overlay"'
def test_overlay_markup_is_identical(self):
assert _opening_tag(HELP, "settings-overlay") == _opening_tag(
SETTINGS, "settings-overlay"
)
def test_panel_carries_no_inline_geometry(self):
# toute la géométrie vient de settings.css, partagé avec Settings
assert _opening_tag(HELP, "settings-panel") == '<div class="settings-panel"'
assert "max-width:1050px" not in HELP
assert "calc(100vh - 120px)" not in HELP
def test_both_pages_use_the_shared_stylesheet(self):
# le CSS qui pose l'overlay centré est bien chargé par les deux
base = (ROOT / "app" / "templates" / "base.html").read_text(encoding="utf-8")
assert "/static/css/settings.css" in base
def test_close_button_keeps_the_same_slot(self):
# même classe que Settings -> même position absolue dans le panneau
for src, name in ((HELP, "help"), (SETTINGS, "settings")):
assert 'class="settings-close"' in src, name
+172
View File
@@ -0,0 +1,172 @@
"""v7.69.0 — sélection multi-blocs qui survit aux gestes + identité visuelle.
Trois bugs d'éditeur, une cause chacune :
1. Ctrl+C multi-blocs : le navigateur ne sérialise PAS une sélection qui
traverse deux contenteditable → il n'émet aucun événement copy → le
raccourci ne fait rien. On l'intercepte au keydown.
2. Glisser la souris : le « click » du contenteditable efface la sélection
juste après qu'on l'a reconstruite (mouseup → click → focusin → collapse).
3. Clic droit : la recherche du menu prenait le focus et effaçait le surlignage.
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
JS = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
def _norm(src: str) -> str:
return "".join(src.split())
# ── 1. Ctrl+C / Ctrl+X multi-blocs : interception clavier ──
class TestKeyboardShortcut:
def test_ctrl_shortcut_is_bound_on_document(self):
# capture sur document : le focus peut être n'importe où dans l'éditeur
assert _norm("document.addEventListener('keydown',function(ev){") in _norm(JS)
def test_shortcut_handles_both_c_and_x(self):
assert _norm("if(k!=='c'&&k!=='x')return;") in _norm(JS)
def test_shortcut_guards_outside_the_editor(self):
# ne doit JAMAIS voler Ctrl+C quand le focus est dans un champ du menu
assert "closest('#_blocksCt')" in JS
def test_shortcut_falls_back_to_native_for_single_block(self):
# sélection mono-bloc → on laisse le navigateur faire son travail
assert _norm("const st=E._clipState();if(!st||st.si>=st.ei)return;") in _norm(JS)
def test_shortcut_writes_to_clipboard_and_prevents_default(self):
assert _norm("ev.preventDefault();ev.stopPropagation();") in _norm(JS)
assert "navigator.clipboard.writeText(md)" in JS
def test_shortcut_is_registered_next_to_the_copy_listeners(self):
# le handler clavier vit au même endroit que copy/cut (bloc ct de v7.68)
cut = JS.index("ct.addEventListener('cut'")
shortcut = JS.index("closest('#_blocksCt')")
assert cut < shortcut < JS.index("ct.addEventListener('mousedown'")
# ── 2. La sélection reconstruite survit au collapse du focus ──
class TestSelectionPersistence:
def test_pending_range_variable_exists(self):
assert "var _mdSelRange=null;" in JS
def test_build_sel_at_stashes_the_range(self):
# sans cette copie, le « click » suivant détruit la sélection
assert _norm("s.removeAllRanges();s.addRange(a);") in _norm(JS)
assert _norm("_mdSelRange=a;") in _norm(JS)
def test_new_gesture_clears_the_pending_range(self):
# un clic ultérieur doit placer un caret normal, pas ressusciter l'ancien
assert _norm("_mdSelRange=null;") in _norm(JS)
def test_focusin_restores_after_the_synchronous_collapse(self):
# focusin se déclenche AVANT le collapse : la restauration doit être
# reportée au tour de boucle suivant (setTimeout 0)
assert "document.addEventListener('focusin'" in JS
restore = JS[JS.index("document.addEventListener('focusin'") :]
assert "setTimeout(function(){" in restore.split("document.addEventListener('mousedown'")[0]
assert "s.addRange(r)" in restore
def test_restore_is_a_noop_when_the_selection_survived(self):
assert _norm("if(s&&s.rangeCount&&!s.isCollapsed)return;") in _norm(JS)
# ── 3. Le menu contextuel n'efface plus le surlignage ──
class TestContextMenuKeepsHighlight:
def test_search_field_only_steals_focus_without_a_selection(self):
assert _norm("if(si&&!hasSel)setTimeout(function(){si.focus();},10);") in _norm(JS)
def test_right_click_prevents_default_to_freeze_the_selection(self):
# le preventDefault doit être sur mousedown (contextmenu arrive trop tard)
assert _norm("if(ev.button===2){const s=window.getSelection();if(s&&s.rangeCount&&!s.isCollapsed)ev.preventDefault();return;}") in _norm(JS)
def test_menu_freezes_the_clipboard_state_at_open_time(self):
# getSelection() n'est plus fiable une fois le menu ouvert
assert _norm("var clipSt=(E._clipState?E._clipState():null);") in _norm(JS)
assert "d._clipSt=clipSt;" in JS
# ── 4. Identité visuelle (logo + bannière du projet) ──
class TestBranding:
def test_banner_and_logo_assets_exist_and_are_optimised(self):
img = ROOT / "static" / "img"
banner, logo = img / "banner.webp", img / "logo.webp"
assert banner.exists() and logo.exists()
# les originaux pèsent des Mo : les assets servis doivent rester légers
assert banner.stat().st_size < 200_000
assert logo.stat().st_size < 100_000
# un JPEG de secours existe pour les crawlers qui n'aiment pas le WebP
assert (img / "banner.jpg").exists()
assert (img / "logo-512.jpg").exists()
def test_source_images_are_kept_for_regeneration(self):
src = ROOT / "assets"
assert (src / "banner.jpg").exists()
assert (src / "logo.jpg").exists()
def test_pwa_icons_generated_from_the_real_logo(self):
icons = ROOT / "static" / "icons"
for size in (72, 96, 128, 144, 152, 192, 384, 512):
f = icons / f"icon-{size}x{size}.png"
assert f.exists(), f"icône {size} manquante"
assert (icons / "apple-touch-icon.png").exists()
def test_base_head_declares_open_graph(self):
base = (ROOT / "app" / "templates" / "base.html").read_text(encoding="utf-8")
# URL ABSOLUE : les crawlers ignorent les URL relatives
assert 'content="{{ app_base_url() }}/static/img/logo-512.jpg"' in base
assert 'name="twitter:card" content="summary_large_image"' in base
assert '<meta name="description"' in base
def test_og_image_url_is_absolute_via_base_url(self):
# le global expose la même source que les liens de partage de pages
templating = (ROOT / "app" / "templating.py").read_text(encoding="utf-8")
assert 'ENV.globals["app_base_url"]' in templating
for tpl in ("base.html", "landing.html"):
src = (ROOT / "app" / "templates" / tpl).read_text(encoding="utf-8")
assert 'content="{{ app_base_url() }}/static/img/logo-512.jpg"' in src, tpl
# forme RELATIVE interdite (le guillemet avant la barre est ce qui
# distingue : la bonne valeur contient l'ancienne en sous-chaîne)
assert 'content="/static/img/' not in src, tpl
def test_favicon_is_the_real_logo(self):
"""L'ancien favicon était un placeholder « FD » en SVG — pas la marque."""
ico = ROOT / "static" / "favicon.ico"
assert ico.exists()
assert ico.stat().st_size < 20_000 # un favicon pèse des Ko, pas des Mo
# 16/32/48 : la taille de l'onglet ET le repli rétro
from PIL import Image
assert Image.open(ico).info.get("sizes") >= {(16, 16), (32, 32), (48, 48)}
# le placeholder SVG doit avoir disparu
assert not (ROOT / "static" / "favicon.svg").exists()
def test_every_template_points_at_the_real_favicon(self):
for tpl in ("base.html", "import.html", "landing.html", "public_page.html", "welcome.html"):
src = (ROOT / "app" / "templates" / tpl).read_text(encoding="utf-8")
assert 'rel="icon" href="/static/favicon.ico"' in src, tpl
assert "favicon.svg" not in src, tpl
# le service worker pré-met en cache la bonne URL
sw = (ROOT / "static" / "sw.js").read_text(encoding="utf-8")
assert "'/static/favicon.ico'," in sw
assert "favicon.svg" not in sw
def test_landing_shows_logo_and_banner(self):
landing = (ROOT / "app" / "templates" / "landing.html").read_text(encoding="utf-8")
# le logo remplace l'icône générique dans la barre de navigation
assert 'class="brand-mark" src="/static/img/logo.webp"' in landing
# la bannière est servie en WebP avec secours JPEG
assert '<source srcset="/static/img/banner.webp" type="image/webp">' in landing
assert 'src="/static/img/banner.jpg"' in landing
# l'image ne doit pas déclencher de refont : dimensions déclarées
assert 'width="2400" height="793"' in landing