From 400a827e3f5f72c7fd34d79ca2a42f9800fe58b9 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 8 Oct 2026 10:46:14 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20commentaires=20fa=C3=A7on=20Notion=20?= =?UTF-8?q?=E2=80=94=20ancres=20jaunes,=20tiroir=20refondu,=20r=C3=A9actio?= =?UTF-8?q?ns=20emoji=20sur=20texte=20(v7.64.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Réaction sur texte : bouton 🙂+ (« React to selected text ») dans la toolbar de sélection → sélecteur d'émoji EXISTANT en mode réaction (en-tête = texte sélectionné + ✋, onglets masqués, ligne Recent au-dessus de la grille People, Filter… et barre de catégories avec + custom inchangés) - Backend : migration 37 text_reactions (UNIQUE plage × emoji × utilisateur → POST idempotent) + GET/POST /api/pages/{id}/reactions (GET groupé par plage emoji/count/mine, POST toggle, 400 sur plage vide) - Affichage : plage commentée/réagie peinte en jaune (_paintAnchors après render/loadComments/loadReactions), pastille 😀+compte après la plage, clic pastille = rouvrir le sélecteur, clic ancre = ouvrir et centrer le fil - Pastilles = annotations : gtTok/gtMd les ignorent (y compris le chemin rapide innerText) et les offsets _srcOffset/_srcNodeLen les excluent → le texte sauvé est identique (vérifié dans le e2e) - Tiroir refondu : carte de fil (avatar, nom, horodatage, ✓ toggle resolve/reopen, ⋯ → Copy link #cmt-/Delete), saisie « Add a comment... » avec @ et envoi ↑/Entrée ; #cmt- ouvre et centre le fil au chargement ; loadComments + loadReactions à l'ouverture (commentCount enfin renseigné) - Fixes : resolveComment ne pouvait pas rouvrir (toujours resolved:true) ; showCommentBtn ignorait les sélections à ancre élément (bouton 💬 jamais affiché) - Tests : tests/test_v764_comments_reactions.py (8, dont toggle API réel), e2e/v764_comments_reactions.spec.js (8 étapes) — 3/3 specs e2e verts, suite complète 1383 passed / 0 failed, ruff OK, OpenAPI 7.64.0 (526 chemins) --- CHANGELOG.md | 63 +++++++ ROADMAP.md | 22 +++ VERSION | 2 +- WORKLOAD.md | 2 +- app/main.py | 2 +- app/migrations.py | 26 +++ app/routers/collaboration.py | 64 +++++++ app/templates/_icon_picker.html | 18 +- app/templates/_page_editor_content.html | 58 +++--- docs/openapi-v2.json | 93 +++++++++- e2e/v764_comments_reactions.spec.js | 144 +++++++++++++++ static/css/app.css | 89 +++++++++ static/js/_icon_picker_2.js | 6 +- static/js/page_editor_scripts.js | 231 +++++++++++++++++++++++- tests/test_v764_comments_reactions.py | 139 ++++++++++++++ 15 files changed, 920 insertions(+), 39 deletions(-) create mode 100644 e2e/v764_comments_reactions.spec.js create mode 100644 tests/test_v764_comments_reactions.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 76471e1..ffba989 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,68 @@ # Changelog - FlowDeck +## v7.64.0 (2026-10-08) — Commentaires façon Notion : ancres jaunes, tiroir refondu, réactions sur texte + +### Added + +- **Réactions emoji sur un texte sélectionné** : + - bouton **🙂+** dans la 3ᵉ ligne de la toolbar de sélection, infobulle + « React to selected text » → ouvre le **sélecteur d'émoji existant** en mode + réaction : en-tête = texte sélectionné (+ ✋), onglets masqués (emoji only), + ligne **Recent** au-dessus de la grille **People**, Filter… et barre de + catégories inchangées (dont `+` émoji custom). + - **stockage** : migration 37 (`text_reactions`, une ligne par plage × émoji × + utilisateur, `UNIQUE` qui rend le POST idempotent) + endpoints + `GET/POST /api/pages/{id}/reactions` (GET groupé par plage : `emoji/count/mine`, + POST = toggle avec validation de plage non vide). + - **affichage** : la plage est peinte en jaune (`.fd-anchor-react`) avec une + **pastille** juste après (`😀` + compte si >1) ; clic sur la pastille → + réouvre le sélecteur pour cette plage (ajout/suppression). + - les pastilles ne sont **pas** du contenu : `gtTok`/`gtMd` les ignorent et les + offsets `_srcOffset/_srcNodeLen` les excluent → `sync()` conserve le texte + d'origine (vérifié dans le e2e) et les ancres ne décalent jamais. +- **Ancres de commentaires visibles** — `_paintAnchors()` (appelé après chaque + `render()`, après `loadComments` et `loadReactions`) entoure la plage commentée + d'un **jaune** (`.fd-anchor`, gris si résolu) ; **clic sur l'ancre → le tiroir + s'ouvre et centre le fil** (flash de surlignage). +- **Tiroir de commentaires refondu** (image 1) : fil en carte arrondie avec + **avatar coloré + nom + horodatage + ✓ (resolve/reopen) + ⋯** (menu Copy link / + Delete), étiquette « 💬 on selected text », et champ de saisie **« Add a + comment... »** avec **@** (insère la mention) et **bouton circulaire ↑** (envoi, + `Entrée` aussi) — remplace le bouton « Comment ». +- **Liens de commentaire** : `Copy link` copie `…#cmt-` ; au chargement d'une + page avec ce hash, le fil correspondant s'ouvre et se centre. +- **Chargement initial** : `loadComments()` + `loadReactions()` à l'ouverture de la + page → les ancres/pastilles sont peintes dès le premier rendu (et `commentCount` + du tiroir est enfin renseigné). + +### Fixed + +- **`resolveComment` ne pouvait pas rouvrir** : il envoyait toujours + `resolved:true` → accepte désormais l'objet du fil et bascule + (`resolveComment(c)`), le bouton ✓ du fil est donc réellement un toggle. +- **`showCommentBtn` ignorait les sélections à ancre « élément »** (double-clic, + sélection programmatique) → `closest('[data-bid]')` partait du wrapper et le + bouton 💬 n'apparaissait pas → gère les 2 types de nœud (même fix que `showFmt` + en v7.62). +- **`gtTok` chemin rapide (`innerText`) incluait les pastilles de réaction** → + sélecteur enrichi (`.fd-react-chip`) pour basculer sur le marcheur qui les + exclut : le texte sauvé reste identique. + +### Tests + +- `tests/test_v764_comments_reactions.py` (8 tests) : table + migration 37, + endpoints enregistrés, **toggle API réel** (added → removed → added, grouping, + 400 sur plage vide), markup toolbar/tiroir, peinture des ancres, mode réaction + du sélecteur. +- **e2e Playwright** `e2e/v764_comments_reactions.spec.js` : bouton 🙂+ avec la + bonne infobulle → sélecteur (en-tête = texte, Filter…, onglets masqués) → + pastille + plage jaune **avec contenu inchangé après `sync()`** → 💬 → tiroir + (avatar, nom, ✓, ⋯, Copy link) → ancre jaune cliquable → ✓ résout → reload : + ancres et pastilles rechargées depuis la base ; **3/3 specs e2e verts** + (`--workers=1`). +- **Suite complète** : `pytest tests/ -q -n auto` → **1383 passed / 0 failed**, + `ruff check app tests` → All checks passed. + ## v7.63.0 (2026-10-08) — Menus mobiles façon Notion (barre au « / » + Insert block plein écran) ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 350cef2..88373eb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1438,6 +1438,28 @@ Détails livrés : e2e mobile `e2e/v763_mobile_menus.spec.js` (390×844) ; `VERSION` + `app/main.py` = 7.63.0 +### v7.64.0 — Commentaires & réactions façon Notion ✅ (2026-10-08) +> **Objectif** : affichage/prise de commentaire identique à Notion + réaction emoji +> sur le texte sélectionné. **COMPLETED**. + +- [x] **Réaction sur texte** — bouton 🙂+ (« React to selected text ») dans la + toolbar de sélection → sélecteur d'émoji **existant** en mode réaction (en-tête = + texte sélectionné + ✋, onglets masqués, Recent au-dessus de People, Filter…, + catégories + `+` custom) ; plage jaune + pastille (emoji + compte), clic pastille + = rouvrir le sélecteur pour la plage +- [x] **Backend** — migration 37 `text_reactions` (UNIQUE plage × emoji × user) + + `GET/POST /api/pages/{id}/reactions` (GET groupé, POST toggle) ; `gtTok`/`gtMd` + ignorent les pastilles et `_srcOffset` exclut les annotations des offsets → le + texte sauvé est identique +- [x] **Ancres jaunes** — `_paintAnchors()` après chaque `render()`/chargement ; + clic sur l'ancre → tiroir ouvert + fil centré (flash) ; résolu = gris +- [x] **Tiroir refondu** — carte de fil (avatar, nom, horodatage, ✓ toggle + resolve/reopen, ⋯ → Copy link `#cmt-` / Delete), saisie « Add a comment... » + avec `@` et envoi `↑`/Entrée ; `resolveComment(c)` corrige l'impossibilité de + rouvrir +- [x] **Tests** — `tests/test_v764_comments_reactions.py` (8, dont toggle API réel), + e2e `e2e/v764_comments_reactions.spec.js` ; `VERSION` + `app/main.py` = 7.64.0 + --- ## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code) diff --git a/VERSION b/VERSION index 23b308f..41f66b4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.63.0 +7.64.0 diff --git a/WORKLOAD.md b/WORKLOAD.md index ed90c9d..432e92a 100644 --- a/WORKLOAD.md +++ b/WORKLOAD.md @@ -1,6 +1,6 @@ # WORKLOAD — FlowDeck Notion Clone -> **Début**: 2026-07-08 | **Version**: v7.63.0 (menus mobiles — barre horizontale au « / » + Insert block plein écran) | **Statut**: EN COURS 🔄 +> **Début**: 2026-07-08 | **Version**: v7.64.0 (commentaires façon Notion : ancres jaunes, tiroir refondu, réactions emoji sur texte) | **Statut**: EN COURS 🔄 > **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 diff --git a/app/main.py b/app/main.py index 0667c46..67abd28 100644 --- a/app/main.py +++ b/app/main.py @@ -186,7 +186,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.63.0", + version="7.64.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/app/migrations.py b/app/migrations.py index ec6e96f..6d9361a 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -1680,6 +1680,32 @@ def _migration_agent_memory(conn: sqlite3.Connection) -> None: ) +@register(37, "v7.64.0: réactions emoji sur un texte sélectionné") +def _migration_text_reactions(conn: sqlite3.Connection) -> None: + """Réactions emoji ancrées sur une plage de texte (façon Notion) : miroir des + ancres de commentaires (block_id + offsets dans le texte rendu). Une ligne par + (plage, emoji, utilisateur) → le POST agit comme un toggle et le GET regroupe + par plage pour afficher la pastille d'émojis.""" + conn.execute( + """ + CREATE TABLE IF NOT EXISTS text_reactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE, + block_id TEXT NOT NULL, + anchor_start INTEGER NOT NULL, + anchor_end INTEGER NOT NULL, + emoji TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(page_id, block_id, anchor_start, anchor_end, emoji, user_id) + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_text_reactions_page ON text_reactions(page_id)" + ) + + @register(36, "v7.59.1: mistral — modèles par défaut hors plan basique") def _migration_mistral_default_model(conn: sqlite3.Connection) -> None: """mistral-large-latest / pixtral-large-latest renvoient 403 diff --git a/app/routers/collaboration.py b/app/routers/collaboration.py index 67ff54d..20799b1 100644 --- a/app/routers/collaboration.py +++ b/app/routers/collaboration.py @@ -206,3 +206,67 @@ def delete_comment(request: Request, comment_id: int): conn.execute("DELETE FROM comments WHERE id=? OR parent_id=?", (comment_id, comment_id)) conn.commit() return {"id": comment_id, "status": "deleted"} + + +# ── v7.64.0 : réactions emoji sur un texte sélectionné ────────────────────── + +@router.get("/pages/{page_id}/reactions") +def list_reactions(request: Request, page_id: int): + """Réactions regroupées par plage de texte : {block_id, start, end, emoji, count, mine}.""" + user = _current_user(request) + with get_conn() as conn: + if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone(): + raise HTTPException(404, "Page not found") + rows = conn.execute( + """SELECT block_id, anchor_start, anchor_end, emoji, + COUNT(*) AS count, + MAX(CASE WHEN user_id=? THEN 1 ELSE 0 END) AS mine + FROM text_reactions + WHERE page_id=? + GROUP BY block_id, anchor_start, anchor_end, emoji + ORDER BY MIN(id)""", + (user["id"], page_id), + ).fetchall() + return {"page_id": page_id, "reactions": [dict(r) for r in rows]} + + +@router.post("/pages/{page_id}/reactions") +def toggle_reaction(request: Request, page_id: int, body: dict = Body(default={})): + """Ajoute ou retire la réaction de l'utilisateur sur une plage de texte (toggle).""" + user = _current_user(request) + block = (body.get("block_id") or "").strip() + emoji = (body.get("emoji") or "").strip() + try: + start = int(body.get("anchor_start")) + end = int(body.get("anchor_end")) + except (TypeError, ValueError): + raise HTTPException(400, "anchor offsets required") from None + if not block or not emoji or start < 0 or end <= start: + raise HTTPException(400, "block_id, emoji and a non-empty range are required") + uid = user["id"] + with get_conn() as conn: + if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone(): + raise HTTPException(404, "Page not found") + conn.execute( + "INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)", + (uid, user.get("login", "admin"), user.get("full_name", "Admin")), + ) + existing = conn.execute( + """SELECT id FROM text_reactions + WHERE page_id=? AND block_id=? AND anchor_start=? AND anchor_end=? + AND emoji=? AND user_id=?""", + (page_id, block, start, end, emoji, uid), + ).fetchone() + if existing: + conn.execute("DELETE FROM text_reactions WHERE id=?", (existing["id"],)) + status = "removed" + else: + conn.execute( + """INSERT INTO text_reactions + (page_id, block_id, anchor_start, anchor_end, emoji, user_id) + VALUES (?,?,?,?,?,?)""", + (page_id, block, start, end, emoji, uid), + ) + status = "added" + conn.commit() + return {"status": status, "emoji": emoji} diff --git a/app/templates/_icon_picker.html b/app/templates/_icon_picker.html index 8723750..62ed32f 100644 --- a/app/templates/_icon_picker.html +++ b/app/templates/_icon_picker.html @@ -16,6 +16,9 @@