feat: commentaires façon Notion — ancres jaunes, tiroir refondu, réactions emoji sur texte (v7.64.0)
FlowDeck CI / lint (push) Successful in 2m5s
FlowDeck CI / test (push) Failing after 3h5m38s
FlowDeck CI / docker (push) Skipped

- Réaction sur texte : bouton 🙂+ (« React to selected text ») dans la toolbar
  de sélection → sélecteur d'émoji EXISTANT en mode réaction (en-tête = texte
  sélectionné + ✋, onglets masqués, ligne Recent au-dessus de la grille
  People, Filter… et barre de catégories avec + custom inchangés)
- Backend : migration 37 text_reactions (UNIQUE plage × emoji × utilisateur →
  POST idempotent) + GET/POST /api/pages/{id}/reactions (GET groupé par plage
  emoji/count/mine, POST toggle, 400 sur plage vide)
- Affichage : plage commentée/réagie peinte en jaune (_paintAnchors après
  render/loadComments/loadReactions), pastille 😀+compte après la plage, clic
  pastille = rouvrir le sélecteur, clic ancre = ouvrir et centrer le fil
- Pastilles = annotations : gtTok/gtMd les ignorent (y compris le chemin
  rapide innerText) et les offsets _srcOffset/_srcNodeLen les excluent → le
  texte sauvé est identique (vérifié dans le e2e)
- Tiroir refondu : carte de fil (avatar, nom, horodatage, ✓ toggle
  resolve/reopen, ⋯ → Copy link #cmt-<id>/Delete), saisie « Add a comment... »
  avec @ et envoi ↑/Entrée ; #cmt-<id> ouvre et centre le fil au chargement ;
  loadComments + loadReactions à l'ouverture (commentCount enfin renseigné)
- Fixes : resolveComment ne pouvait pas rouvrir (toujours resolved:true) ;
  showCommentBtn ignorait les sélections à ancre élément (bouton 💬 jamais
  affiché)
- Tests : tests/test_v764_comments_reactions.py (8, dont toggle API réel),
  e2e/v764_comments_reactions.spec.js (8 étapes) — 3/3 specs e2e verts,
  suite complète 1383 passed / 0 failed, ruff OK, OpenAPI 7.64.0 (526 chemins)
This commit is contained in:
2026-10-08 10:46:14 -04:00
parent 699165469d
commit 400a827e3f
15 changed files with 920 additions and 39 deletions
+63
View File
@@ -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-<id>` ; au chargement d'une
page avec ce hash, le fil correspondant s'ouvre et se centre.
- **Chargement initial** : `loadComments()` + `loadReactions()` à l'ouverture de la
page → les ancres/pastilles sont peintes dès le premier rendu (et `commentCount`
du tiroir est enfin renseigné).
### Fixed
- **`resolveComment` ne pouvait pas rouvrir** : il envoyait toujours
`resolved:true` → accepte désormais l'objet du fil et bascule
(`resolveComment(c)`), le bouton ✓ du fil est donc réellement un toggle.
- **`showCommentBtn` ignorait les sélections à ancre « élément »** (double-clic,
sélection programmatique) → `closest('[data-bid]')` partait du wrapper et le
bouton 💬 n'apparaissait pas → gère les 2 types de nœud (même fix que `showFmt`
en v7.62).
- **`gtTok` chemin rapide (`innerText`) incluait les pastilles de réaction** →
sélecteur enrichi (`.fd-react-chip`) pour basculer sur le marcheur qui les
exclut : le texte sauvé reste identique.
### Tests
- `tests/test_v764_comments_reactions.py` (8 tests) : table + migration 37,
endpoints enregistrés, **toggle API réel** (added → removed → added, grouping,
400 sur plage vide), markup toolbar/tiroir, peinture des ancres, mode réaction
du sélecteur.
- **e2e Playwright** `e2e/v764_comments_reactions.spec.js` : bouton 🙂+ avec la
bonne infobulle → sélecteur (en-tête = texte, Filter…, onglets masqués) →
pastille + plage jaune **avec contenu inchangé après `sync()`** → 💬 → tiroir
(avatar, nom, ✓, ⋯, Copy link) → ancre jaune cliquable → ✓ résout → reload :
ancres et pastilles rechargées depuis la base ; **3/3 specs e2e verts**
(`--workers=1`).
- **Suite complète** : `pytest tests/ -q -n auto` → **1383 passed / 0 failed**,
`ruff check app tests` → All checks passed.
## v7.63.0 (2026-10-08) — Menus mobiles façon Notion (barre au « / » + Insert block plein écran)
### Added
+22
View File
@@ -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-<id>` / Delete), saisie « Add a comment... »
avec `@` et envoi `↑`/Entrée ; `resolveComment(c)` corrige l'impossibilité de
rouvrir
- [x] **Tests** — `tests/test_v764_comments_reactions.py` (8, dont toggle API réel),
e2e `e2e/v764_comments_reactions.spec.js` ; `VERSION` + `app/main.py` = 7.64.0
---
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
+1 -1
View File
@@ -1 +1 @@
7.63.0
7.64.0
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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,
+26
View File
@@ -1680,6 +1680,32 @@ def _migration_agent_memory(conn: sqlite3.Connection) -> None:
)
@register(37, "v7.64.0: réactions emoji sur un texte sélectionné")
def _migration_text_reactions(conn: sqlite3.Connection) -> None:
"""Réactions emoji ancrées sur une plage de texte (façon Notion) : miroir des
ancres de commentaires (block_id + offsets dans le texte rendu). Une ligne par
(plage, emoji, utilisateur) → le POST agit comme un toggle et le GET regroupe
par plage pour afficher la pastille d'émojis."""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS text_reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
block_id TEXT NOT NULL,
anchor_start INTEGER NOT NULL,
anchor_end INTEGER NOT NULL,
emoji TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(page_id, block_id, anchor_start, anchor_end, emoji, user_id)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_text_reactions_page ON text_reactions(page_id)"
)
@register(36, "v7.59.1: mistral — modèles par défaut hors plan basique")
def _migration_mistral_default_model(conn: sqlite3.Connection) -> None:
"""mistral-large-latest / pixtral-large-latest renvoient 403
+64
View File
@@ -206,3 +206,67 @@ def delete_comment(request: Request, comment_id: int):
conn.execute("DELETE FROM comments WHERE id=? OR parent_id=?", (comment_id, comment_id))
conn.commit()
return {"id": comment_id, "status": "deleted"}
# ── v7.64.0 : réactions emoji sur un texte sélectionné ──────────────────────
@router.get("/pages/{page_id}/reactions")
def list_reactions(request: Request, page_id: int):
"""Réactions regroupées par plage de texte : {block_id, start, end, emoji, count, mine}."""
user = _current_user(request)
with get_conn() as conn:
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
raise HTTPException(404, "Page not found")
rows = conn.execute(
"""SELECT block_id, anchor_start, anchor_end, emoji,
COUNT(*) AS count,
MAX(CASE WHEN user_id=? THEN 1 ELSE 0 END) AS mine
FROM text_reactions
WHERE page_id=?
GROUP BY block_id, anchor_start, anchor_end, emoji
ORDER BY MIN(id)""",
(user["id"], page_id),
).fetchall()
return {"page_id": page_id, "reactions": [dict(r) for r in rows]}
@router.post("/pages/{page_id}/reactions")
def toggle_reaction(request: Request, page_id: int, body: dict = Body(default={})):
"""Ajoute ou retire la réaction de l'utilisateur sur une plage de texte (toggle)."""
user = _current_user(request)
block = (body.get("block_id") or "").strip()
emoji = (body.get("emoji") or "").strip()
try:
start = int(body.get("anchor_start"))
end = int(body.get("anchor_end"))
except (TypeError, ValueError):
raise HTTPException(400, "anchor offsets required") from None
if not block or not emoji or start < 0 or end <= start:
raise HTTPException(400, "block_id, emoji and a non-empty range are required")
uid = user["id"]
with get_conn() as conn:
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
raise HTTPException(404, "Page not found")
conn.execute(
"INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
(uid, user.get("login", "admin"), user.get("full_name", "Admin")),
)
existing = conn.execute(
"""SELECT id FROM text_reactions
WHERE page_id=? AND block_id=? AND anchor_start=? AND anchor_end=?
AND emoji=? AND user_id=?""",
(page_id, block, start, end, emoji, uid),
).fetchone()
if existing:
conn.execute("DELETE FROM text_reactions WHERE id=?", (existing["id"],))
status = "removed"
else:
conn.execute(
"""INSERT INTO text_reactions
(page_id, block_id, anchor_start, anchor_end, emoji, user_id)
VALUES (?,?,?,?,?,?)""",
(page_id, block, start, end, emoji, uid),
)
status = "added"
conn.commit()
return {"status": status, "emoji": emoji}
+17 -1
View File
@@ -16,6 +16,9 @@
<style>
.fd-icon-picker{position:fixed;top:0;left:0;width:344px;background:var(--bg-modal);border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,.28);z-index:2200;display:flex;flex-direction:column;max-height:440px;overflow:hidden;padding:0;}
.fd-icon-picker .fdip-title{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--border);}
.fd-icon-picker .fdip-title-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:600;color:var(--text-primary);}
.fd-icon-picker .fdip-title-grip{opacity:.6;font-size:14px;}
.fd-icon-picker .fdip-tabs{display:flex;align-items:center;gap:2px;padding:8px 10px 6px;border-bottom:1px solid var(--border);}
.fd-icon-picker .fdip-tab{background:transparent;border:none;color:var(--text-secondary);font-size:13px;padding:5px 10px;border-radius:6px;cursor:pointer;}
.fd-icon-picker .fdip-tab:hover{background:var(--bg-hover);}
@@ -70,7 +73,11 @@
@click.outside="$store.fdIconPicker.customModal || $store.fdIconPicker.close()"
@keydown.escape.window="$store.fdIconPicker.close()"
:style="'top:' + $store.fdIconPicker.y + 'px;left:' + $store.fdIconPicker.x + 'px'">
<div class="fdip-tabs">
<div class="fdip-title" x-show="$store.fdIconPicker.title" x-cloak>
<span class="fdip-title-text" x-text="$store.fdIconPicker.title"></span>
<span class="fdip-title-grip" title="Drag">✋</span>
</div>
<div class="fdip-tabs" x-show="!$store.fdIconPicker.title">
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'emoji' }" @click="$store.fdIconPicker.setTab('emoji')">Emoji</button>
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'icons' }" @click="$store.fdIconPicker.setTab('icons')">Icons</button>
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'upload' }" @click="$store.fdIconPicker.setTab('upload')">Upload</button>
@@ -93,6 +100,15 @@
<div class="fdip-body">
<template x-if="$store.fdIconPicker.tab === 'emoji'">
<div>
<!-- v7.64 : mode réaction — les récents au-dessus de la grille (façon Notion) -->
<div x-show="$store.fdIconPicker.title && $store.fdIconPicker.category !== 'recent' && !$store.fdIconPicker.query && $store.fdIconPicker.recent.length">
<div class="fdip-section">Recent</div>
<div class="fdip-grid">
<template x-for="e in $store.fdIconPicker.recent.slice(0, 8)" :key="'rt' + e">
<button class="fdip-emoji" @click="$store.fdIconPicker.pick($store.fdIconPicker.withTone(e))" x-text="$store.fdIconPicker.withTone(e)"></button>
</template>
</div>
</div>
<div class="fdip-section" x-show="$store.fdIconPicker.category === 'recent' && !$store.fdIconPicker.query">Recent</div>
<div class="fdip-grid">
<template x-for="e in $store.fdIconPicker.items()" :key="e">
+34 -24
View File
@@ -587,15 +587,15 @@
💬 Comment
</button>
<!-- ═══════════ Comments drawer (v4.9.0) ═══════════ -->
<!-- ═══════════ Comments drawer (v4.9.0 · refonte v7.64 façon Notion) ═══════════ -->
<div class="comments-drawer" x-show="commentsOpen" x-cloak x-transition
style="position:fixed;top:var(--topbar-height,44px);right:0;bottom:0;width:320px;max-width:92vw;
style="position:fixed;top:var(--topbar-height,44px);right:0;bottom:0;width:340px;max-width:92vw;
background:var(--bg-primary,#1c1c1c);border-left:1px solid var(--border,#333);
box-shadow:-8px 0 30px rgba(0,0,0,.25);z-index:900;display:flex;flex-direction:column;">
<div class="comments-drawer-header"
style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;
border-bottom:1px solid var(--border,#333);font-weight:600;font-size:14px;">
<span>Comments</span>
<span>Comments <span style="color:var(--text-dim);font-weight:400;" x-text="'(' + commentCount + ')'"></span></span>
<button type="button" class="topbar-btn" @click="toggleComments()" title="Close">✕</button>
</div>
<div class="comments-drawer-list" style="flex:1;overflow-y:auto;padding:12px 16px;">
@@ -603,34 +603,43 @@
No comments yet. Select some text and click 💬 Comment.
</div>
<template x-for="c in comments" :key="c.id">
<div class="comment-thread" style="margin-bottom:18px;"
:style="c.anchor_block_id ? 'border-left:3px solid var(--accent);padding-left:10px;' : ''">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
<div style="width:22px;height:22px;border-radius:50%;background:#3A3A3A;color:#fff;
display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;"
<div class="comment-thread" :data-thread="c.id"
:class="{ 'is-resolved': c.resolved, 'has-anchor': !!c.anchor_block_id }">
<div class="ct-head">
<div class="ct-avatar"
:style="{ background: c.author && c.author.avatar_color ? c.author.avatar_color : '#3A3A3A' }">
<span x-text="(c.author ? (c.author.full_name || c.author.login || '?') : '?').charAt(0).toUpperCase()"></span>
</div>
<span style="font-size:12px;font-weight:600;" x-text="c.author ? (c.author.full_name || c.author.login) : 'Unknown'"></span>
<span style="font-size:11px;color:var(--text-dim,#999);margin-left:auto;" x-text="fmtTime(c.created_at)"></span>
</div>
<div style="font-size:13px;white-space:pre-wrap;margin-bottom:6px;" x-text="c.body"></div>
<div style="display:flex;gap:8px;align-items:center;">
<button class="btn-sm" style="font-size:11px;" @click="resolveComment(c.id)"
x-text="c.resolved ? '↪ Reopen' : '✔ Resolve'"></button>
<button class="btn-sm" style="font-size:11px;" x-show="c.user_id === currentUserId"
@click="deleteComment(c.id)">🗑 Delete</button>
<span class="ct-name" x-text="c.author ? (c.author.full_name || c.author.login) : 'Unknown'"></span>
<span class="ct-time" x-text="fmtTime(c.created_at)"></span>
<div class="ct-actions">
<button type="button" class="ct-act" :title="c.resolved ? 'Reopen' : 'Resolve'"
@click="resolveComment(c)"
:class="{ 'is-on': c.resolved }">✓</button>
<button type="button" class="ct-act" title="More"
x-show="c.user_id === currentUserId"
@click.stop="commentMenu = commentMenu === c.id ? null : c.id">⋯</button>
</div>
<div class="ct-menu" x-cloak x-show="commentMenu === c.id" @click.outside="commentMenu = null">
<button type="button" class="ct-menu-item" @click="copyCommentLink(c.id)">🔗 Copy link</button>
<button type="button" class="ct-menu-item is-danger" @click="deleteComment(c.id)">🗑 Delete</button>
</div>
</div>
<div class="ct-body" x-text="c.body"></div>
<div class="ct-anchor-label" x-show="!!c.anchor_block_id && !c.resolved">💬 on selected text</div>
</div>
</template>
</div>
<div class="comments-drawer-input" style="border-top:1px solid var(--border,#333);padding:12px 16px;">
<textarea x-model="commentDraft" rows="2"
placeholder="Add a comment... Use @ to mention someone."
style="width:100%;background:var(--bg-secondary,#2a2a2a);border:1px solid var(--border,#333);
border-radius:8px;color:var(--text);font-size:13px;padding:8px 10px;resize:none;outline:none;"></textarea>
<div style="display:flex;justify-content:flex-end;margin-top:8px;">
<button class="btn btn-primary" style="font-size:12px;padding:6px 14px;" @click="addPageComment()">Comment</button>
<div class="comments-drawer-input">
<div class="ct-input-row">
<textarea x-model="commentDraft" rows="1"
placeholder="Add a comment..."
@keydown.enter.exact.prevent="addPageComment()"></textarea>
<div class="ct-input-actions">
<button type="button" class="ct-act" title="Mention" @click="commentAt()">@</button>
<button type="button" class="ct-send" title="Send"
:disabled="!(commentDraft || '').trim()" @click="addPageComment()">↑</button>
</div>
</div>
</div>
</div>
@@ -666,6 +675,7 @@
<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>
+92 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.63.0"
"version": "7.64.0"
},
"paths": {
"/auth/register": {
@@ -15368,6 +15368,97 @@
}
}
},
"/api/pages/{page_id}/reactions": {
"get": {
"tags": [
"collaboration"
],
"summary": "List Reactions",
"description": "Réactions regroupées par plage de texte : {block_id, start, end, emoji, count, mine}.",
"operationId": "list_reactions_api_pages__page_id__reactions_get",
"parameters": [
{
"name": "page_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"title": "Page Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"post": {
"tags": [
"collaboration"
],
"summary": "Toggle Reaction",
"description": "Ajoute ou retire la réaction de l'utilisateur sur une plage de texte (toggle).",
"operationId": "toggle_reaction_api_pages__page_id__reactions_post",
"parameters": [
{
"name": "page_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"title": "Page Id"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/custom-emojis": {
"get": {
"tags": [
+144
View File
@@ -0,0 +1,144 @@
const { test, expect } = require('@playwright/test');
/**
* v7.64 — commentaires & réactions façon Notion :
* - sélection → bouton 🙂+ (« React to selected text ») → sélecteur d'émoji avec
* le texte sélectionné en en-tête → pastille + plage jaune, contenu inchangé
* - sélection → 💬 → tiroir refondu (avatar, nom, ✓, ⋯, saisie avec @ et ↑) →
* fil ancré + plage jaune, clic sur l'ancre rouvre le fil, ✓ résout
* - reload : ancres et pastilles repeintes depuis la base
* Instance de test attendue sur FD_BASE_URL (défaut 8081).
*/
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
test.use({ serviceWorkers: 'block' });
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page
.waitForURL('**/workspaces', { timeout: 8000 })
.then(() => true)
.catch(() => false);
if (!ok) {
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
data: { email: USER, password: PASS, name: 'E2E' },
});
if (resp.status() !== 409) expect(resp.ok()).toBeTruthy();
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
}
}
async function newPage(page, body) {
const cookies = await page.context().cookies();
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
const created = await page.request.post(
`${FD_BASE}/board/api/pages?title=Comments ${Date.now()}`,
{ headers: { 'X-CSRF-Token': csrf } }
);
expect(created.status(), await created.text()).toBeLessThan(400);
const { id } = await created.json();
if (body) {
const bs = await page.request.post(`${FD_BASE}/board/api/pages/${id}/blocks`, {
headers: { 'X-CSRF-Token': csrf },
data: { title: 'Comments', blocks: body },
});
expect(bs.status(), await bs.text()).toBeLessThan(400);
}
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
return id;
}
// sélectionne tout le bloc et simule la fin du glisser-souris
async function selectBlock(page) {
await page.evaluate(() => {
const el = document.querySelector('#_blocksCt [data-bid]');
const r = document.createRange();
r.selectNodeContents(el);
const s = getSelection();
s.removeAllRanges();
s.addRange(r);
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
});
await expect(page.locator('.format-toolbar')).toBeVisible({ timeout: 5000 });
}
test('réaction emoji + commentaires ancrés', async ({ page }) => {
await login(page);
await newPage(page, [{ id: 'b1', type: 'paragraph', content: 'Prise de reaction a un texte' }]);
await expect(page.locator('#_blocksCt [data-bid]')).toHaveText('Prise de reaction a un texte');
// ── 1. bouton réaction dans la toolbar ──
await selectBlock(page);
const react = page.locator('.format-toolbar .ft-react');
await expect(react).toHaveAttribute('data-tooltip', 'React to selected text');
await page.screenshot({ path: 'shots/v764-toolbar-react.png' });
await react.click();
// ── 2. sélecteur : en-tête = texte sélectionné, Filter…, Recent/People ──
const picker = page.locator('.fd-icon-picker');
await expect(picker).toBeVisible();
await expect(picker.locator('.fdip-title-text')).toHaveText('Prise de reaction a un texte');
await expect(picker.locator('.fdip-search')).toHaveAttribute('placeholder', 'Filter...');
await expect(picker.locator('.fdip-tabs')).toBeHidden(); // mode réaction : emoji only
await page.screenshot({ path: 'shots/v764-picker.png' });
await picker.locator('.fdip-emoji').first().click();
await expect(picker).toBeHidden();
// ── 3. pastille + plage jaune, contenu du bloc inchangé ──
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1);
await expect(page.locator('#_blocksCt .fd-anchor-react')).toHaveCount(1);
const content = await page.evaluate(() => {
window.E.sync();
return window.E.blocks[0].content;
});
expect(content).toBe('Prise de reaction a un texte'); // la pastille n'est pas du contenu
await page.screenshot({ path: 'shots/v764-chip.png' });
// ── 4. commentaire : bouton 💬 → tiroir refondu → envoi ──
await selectBlock(page);
await expect(page.locator('#_commentSelBtn')).toBeVisible();
await page.locator('#_commentSelBtn').click();
const drawer = page.locator('.comments-drawer');
await expect(drawer).toBeVisible();
await drawer.locator('textarea').fill('Ceci est mon premier commentaire');
await expect(drawer.locator('.ct-send')).toBeEnabled();
await drawer.locator('.ct-send').click();
const thread = page.locator('.comment-thread');
await expect(thread).toHaveCount(1);
await expect(thread.locator('.ct-avatar')).toHaveCount(1);
await expect(thread.locator('.ct-name')).not.toHaveText('');
await expect(thread.locator('.ct-act')).toHaveCount(2); // ✓ + ⋯
await expect(thread.locator('.ct-body')).toHaveText('Ceci est mon premier commentaire');
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveCount(1);
await page.screenshot({ path: 'shots/v764-drawer.png' });
// ── 5. menu ⋯ du fil (Copy link / Delete) ──
await thread.locator('.ct-act').nth(1).click();
await expect(thread.locator('.ct-menu')).toBeVisible();
await expect(thread.locator('.ct-menu-item').first()).toContainText('Copy link');
await page.keyboard.press('Escape');
await page.locator('.comments-drawer-header .topbar-btn').click();
await expect(drawer).toBeHidden();
// ── 6. clic sur l'ancre jaune → le fil se rouvre ──
await page.locator('#_blocksCt .fd-anchor[data-cid]').click();
await expect(drawer).toBeVisible();
await expect(page.locator('.comment-thread')).toHaveCount(1);
// ── 7. ✓ résout le fil (ancre en gris) ──
await page.locator('.comment-thread .ct-act').first().click();
await expect(page.locator('.comment-thread.is-resolved')).toHaveCount(1);
await expect(page.locator('#_blocksCt .fd-anchor.fd-anchor-done')).toHaveCount(1);
// ── 8. reload : ancres + pastilles rechargées depuis la base ──
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveCount(1, { timeout: 10000 });
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1, { timeout: 10000 });
});
+89
View File
@@ -3154,6 +3154,95 @@ button.breadcrumb-current { max-width: 260px; }
.fd-msheet .slash-item { padding: 12px 10px; font-size: 15px; }
.fd-msheet .slash-item:active { background: var(--bg-hover); }
/* ══ v7.64 — Commentaires & réactions façon Notion ══
Ancres jaunes (texte commenté), pastilles de réaction, tiroir refondu ══ */
.fd-anchor {
background: rgba(255, 213, 0, .32);
border-radius: 2px;
cursor: pointer;
}
.fd-anchor-done { background: rgba(255, 255, 255, .10); }
.fd-anchor-react { background: rgba(255, 213, 0, .28); }
.fd-react-chip {
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: 4px;
padding: 0 5px;
height: 19px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-secondary);
cursor: pointer;
vertical-align: middle;
font-size: 11px;
line-height: 1;
user-select: none;
}
.fd-react-chip:hover { background: var(--bg-hover); }
.fd-react-chip img { width: 12px; height: 12px; object-fit: contain; }
.fd-react-n { font-size: 10px; color: var(--text-dim); }
/* Tiroir de commentaires (fil + saisie) */
.comment-thread {
position: relative;
margin-bottom: 12px;
padding: 10px 12px;
background: var(--bg-secondary, #2a2a2a);
border: 1px solid var(--border, #333);
border-radius: 10px;
}
.comment-thread.has-anchor { border-left: 3px solid var(--accent, #2383E2); }
.comment-thread.is-resolved { opacity: .55; }
.thread-flash { outline: 2px solid var(--accent, #2383E2); outline-offset: 2px; }
.ct-head { display: flex; align-items: center; gap: 8px; position: relative; }
.ct-avatar {
width: 24px; height: 24px; border-radius: 50%; color: #fff; flex-shrink: 0;
display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700;
}
.ct-name { font-size: 12px; font-weight: 600; color: var(--text-primary); }
.ct-time { font-size: 11px; color: var(--text-dim); margin-left: auto; }
.ct-actions { display: flex; gap: 2px; margin-left: 2px; }
.ct-act {
width: 24px; height: 24px; border: none; background: transparent; border-radius: 5px;
color: var(--text-dim); font-size: 13px; line-height: 1; cursor: pointer;
}
.ct-act:hover { background: var(--bg-hover); color: var(--text-primary); }
.ct-act.is-on { color: var(--accent, #2383E2); }
.ct-menu {
position: absolute; right: 0; top: 28px; z-index: 6; min-width: 156px; padding: 4px;
background: var(--bg-modal); border: 1px solid var(--border); border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,.4);
}
.ct-menu-item {
display: block; width: 100%; text-align: left; padding: 7px 10px; font-size: 13px;
color: var(--text-primary); background: none; border: none; border-radius: 5px; cursor: pointer;
}
.ct-menu-item:hover { background: var(--bg-hover); }
.ct-menu-item.is-danger { color: #e5484d; }
.ct-body { font-size: 13px; white-space: pre-wrap; margin-top: 6px; color: var(--text-primary); }
.ct-anchor-label { font-size: 10px; color: var(--text-dim); margin-top: 6px; }
.comments-drawer-input { border-top: 1px solid var(--border, #333); padding: 12px 16px; }
.ct-input-row { display: flex; gap: 6px; align-items: flex-end; }
.ct-input-row textarea {
flex: 1; min-width: 0; background: var(--bg-secondary, #2a2a2a);
border: 1px solid var(--border, #333); border-radius: 8px; color: var(--text-primary);
font-size: 13px; font-family: inherit; line-height: 1.4; padding: 8px 10px; resize: none; outline: none;
}
.ct-input-row textarea:focus { border-color: var(--accent, #2383E2); }
.ct-input-actions { display: flex; gap: 4px; align-items: center; padding-bottom: 2px; }
.ct-send {
width: 30px; height: 30px; border-radius: 50%; background: var(--accent, #2383E2); color: #fff;
border: none; font-size: 15px; line-height: 1; cursor: pointer; flex-shrink: 0;
}
.ct-send:disabled { opacity: .4; cursor: default; }
/* Bouton « React to selected text » de la toolbar de sélection */
.ft-react-ico { position: relative; font-size: 15px; line-height: 1; }
.ft-react-plus {
position: absolute; right: -5px; top: -4px; font-size: 9px; font-weight: 700; color: var(--text-dim);
}
/* EDITOR STATUSBAR */
.editor-statusbar {
position: fixed;
+5 -1
View File
@@ -40,7 +40,7 @@
if (window.Alpine) window.Alpine.__fdIconPicker = true;
Alpine.store('fdIconPicker', {
open: false, x: 0, y: 0, tab: 'emoji', query: '', category: 'people',
open: false, x: 0, y: 0, tab: 'emoji', query: '', category: 'people', title: '',
skinTone: 0, toneOpen: false, recent: [], custom: [], customLoaded: false,
customModal: false, customName: '', customPreview: '', customFile: null, customBusy: false,
onPick: null, onRemove: null, _cx: 0, _cy: 0,
@@ -50,6 +50,9 @@
opts = opts || {};
this.onPick = opts.onPick || null;
this.onRemove = opts.onRemove || null;
// v7.64 : mode « réaction » — en-tête = texte sélectionné, emoji only
this.title = opts.title || '';
if (this.title) { this.tab = 'emoji'; this.category = 'people'; }
this.query = '';
this.toneOpen = false;
this.customModal = false;
@@ -78,6 +81,7 @@
close() {
this.open = false;
this.title = '';
this.customModal = false;
this.toneOpen = false;
this.onPick = null;
+222 -9
View File
@@ -36,7 +36,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
// [[fdpage:ID]] / [[fddate:YYYY-MM-DD]] tokens.
function gtTok(e){
if(!e) return '';
if(!e.querySelector||!e.querySelector('.fd-wiki-chip,.fd-wiki-date')) return gt(e);
if(!e.querySelector||!e.querySelector('.fd-wiki-chip,.fd-wiki-date,.fd-react-chip')) return gt(e); // le chemin rapide (innerText) inclurait les pastilles
let out='';
(function walk(node){
for(const n of node.childNodes){
@@ -44,6 +44,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
if(n.nodeType===1){
if(n.classList&&n.classList.contains('fd-wiki-chip')){out+=n.getAttribute('data-token')||'';continue;}
if(n.classList&&n.classList.contains('fd-wiki-date')){out+=n.getAttribute('data-token')||'';continue;}
if(n.classList&&n.classList.contains('fd-react-chip')){continue;} // annotation, pas du contenu
if(n.tagName==='BR'){out+='\n';continue;}
walk(n);
}
@@ -67,6 +68,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
if(n.nodeType===3){out+=n.nodeValue;continue;}
if(n.nodeType!==1)continue;
var cl=n.classList;
if(cl&&cl.contains('fd-react-chip')){continue;} // annotation, pas du contenu
if(cl&&(cl.contains('fd-wiki-chip')||cl.contains('fd-wiki-date'))){out+=n.getAttribute('data-token')||'';continue;}
if(n.tagName==='BR'){out+='\n';continue;}
if(cl&&cl.contains('fd-math-inline')){out+='$$'+(n.getAttribute('data-tex')||'')+'$$';continue;}
@@ -103,6 +105,69 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
var s2=window.getSelection(),nr=document.createRange();
nr.selectNodeContents(el);s2.removeAllRanges();s2.addRange(nr);
}
// v7.64 : marquage d'une plage commentée/réagie (jaune). Ne change pas le
// texte lu par sync() : les spans n'ajoutent que des nœuds d'annotation.
function _wrapAnchored(el,start,end,cls,attrs){
if(!(end>start))return null;
let pos=0,sN=null,sO=0,eN=null,eO=0;
const walker=document.createTreeWalker(el,NodeFilter.SHOW_TEXT);
let n=walker.nextNode();
while(n){
const len=n.nodeValue.length;
if(!sN&&pos+len>start){sN=n;sO=start-pos;}
if(sN&&pos+len>=end){eN=n;eO=end-pos;break;}
pos+=len;n=walker.nextNode();
}
if(!sN||!eN)return null;
const r=document.createRange();
try{r.setStart(sN,sO);r.setEnd(eN,eO);}catch(e){return null;}
const span=document.createElement('span');span.className=cls;
span.setAttribute('contenteditable','false');
if(attrs)for(const k in attrs)span.setAttribute('data-'+k,attrs[k]);
try{r.surroundContents(span);}catch(e){try{span.appendChild(r.extractContents());r.insertNode(span);}catch(e2){return null;}}
return span;
}
// Offsets dans le texte SOURCE d'un bloc : les pastilles .fd-react-chip
// (annotations) ne comptent pas, comme dans gtTok/gtMd.
function _srcNodeLen(node){
if(!node)return 0;
if(node.nodeType===3){
const pe=node.parentElement;
if(pe&&pe.closest&&pe.closest('.fd-react-chip'))return 0;
return node.nodeValue.length;
}
if(node.nodeType!==1)return 0;
if(node.classList&&node.classList.contains('fd-react-chip'))return 0;
let t=0;const kids=node.childNodes;
for(let i=0;i<kids.length;i++)t+=_srcNodeLen(kids[i]);
return t;
}
function _srcOffset(root,container,offset){
if(!container)return 0;
if(container.nodeType===1){
let t=0;const kids=container.childNodes;
for(let i=0;i<offset&&i<kids.length;i++)t+=_srcNodeLen(kids[i]);
return t;
}
if(container.nodeType!==3)return 0;
let t=0;
const walker=document.createTreeWalker(root,NodeFilter.SHOW_TEXT);
let n;
while((n=walker.nextNode())){
if(n===container){
const pe=n.parentElement;
if(pe&&pe.closest&&pe.closest('.fd-react-chip'))return t;
return t+Math.max(0,Math.min(offset,n.nodeValue.length));
}
t+=_srcNodeLen(n);
}
return t;
}
function _emojiHtml(v){
const s=String(v||'');
if(/^(https?:|\/)/.test(s))return '<img src="'+esc(s)+'" alt="">';
return esc(s);
}
function splitCaret(e){
const s=window.getSelection(); if(!s.rangeCount||!s.rangeCount)return null; const sel=s.getRangeAt(0);
const MK='\uF000FD'; const mark=document.createElement('span'); mark.textContent=MK;
@@ -1311,7 +1376,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
inviteEmail:'',invitePermission:'edit',inviteUserId:null,inviteGroupId:null,inviteGroups:[],inviteUsers:[],inviteSuggestOpen:false,inviteSel:-1,accessList:[],
toastVisible:false,toastMsg:'',
// ── v4.9.0: Collaboration — comments & mentions ──
commentsOpen:false,commentDraft:'',comments:[],commentCount:0,
commentsOpen:false,commentDraft:'',comments:[],commentCount:0,reactions:[],commentMenu:null,
currentUserId:PD.user_id||0,
_commentSel:{range:null,bid:null,start:0,end:0},
_notifiedMentions:{},
@@ -1363,6 +1428,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
this.insertTable=function(){this.sync();this.pushHistory();var nb={id:genId(),type:'table',content:'',rows:_tblNew(3,3)};this.blocks.push(nb);this.dirty=true;this.autoSave();this.render();_rtSync();setTimeout(function(){_tblFocus(nb.id,0,0);},60);};
SM.init();_tblInitDelegate();this.render();
this._applyLockMode();this._applyLayout();_wikiResolve();
this.loadComments();this.loadReactions(); // ancres jaunes + pastilles (v7.64)
const tEl=document.getElementById('_titleEl');
if(tEl){const syncT=()=>{tEl.classList.toggle('empty',!(tEl.textContent||'').trim());};syncT();tEl.addEventListener('input',syncT);tEl.addEventListener('blur',syncT);}
if(this.contentFormat==='blocks'&&window.__fdRT&&window.__fdRT.start){setTimeout(()=>{try{window.__fdRT.start(this);}catch(e){}},150);}
@@ -1538,6 +1604,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
ct.querySelectorAll('[data-bid]').forEach(el=>{el.addEventListener('input',()=>{self.dirty=true;self.autoSave();if(el.hasAttribute&&el.hasAttribute('data-placeholder'))el.classList.toggle('empty',!(el.textContent||'').trim());if(window.AIAC)window.AIAC.schedule(el.getAttribute('data-bid'));})});
ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/<br[^>]*>/gi,'').trim()!==''?el.innerHTML:'';el.classList.toggle('empty',!el.textContent.trim());});
_selApply();
try{this._paintAnchors();}catch(e){} // ancres jaunes + pastilles (v7.64)
var self2=this;
setTimeout(function(){
if(window._wikiResolve)_wikiResolve();
@@ -2515,9 +2582,41 @@ applyAIBlocks(text){
fmtAskAI(){this.fmt.open=false;const i=this.fmt.idx;if(i>=0)this.askAIBlock(i);},
fmtEditAI(){this.fmtAskAI();},
fmtSkill(tpl){this.fmt.open=false;const i=this.fmt.idx;if(i>=0)this.runSkill(i,tpl);},
// offsets d'une Range dans le texte rendu d'un bloc (partagé bouton 💬 + réaction)
_selSpan(bel,range){return {start:_srcOffset(bel,range.startContainer,range.startOffset),
end:_srcOffset(bel,range.endContainer,range.endOffset)};},
// « React to selected text » (v7.64) : sélecteur d'émoji avec le texte
// sélectionné en en-tête → toggle de la réaction ancrée sur la plage
fmtReact(){
if(!this._sel)return;
const E=this;const bid=this._fmtBid();const bel=this.getEl(bid);
if(!bel||!window.FDIconPicker)return;
const span=this._selSpan(bel,this._sel.range);
if(span.end<=span.start)return;
const text=this._sel.range.toString();
const r=this._sel.range.getBoundingClientRect();
this.fmt.open=false;
window.FDIconPicker.openFor({
x:Math.min(Math.max(8,r.left-40),window.innerWidth-360),
y:Math.min(Math.max(8,r.bottom+8),window.innerHeight-260),
title:text.length>90?text.slice(0,90)+'…':text,
onPick:function(emoji){E.toggleReaction(bid,span.start,span.end,emoji);}
});
},
// ── v4.9.0: Collaboration — comments & mentions ──
toggleComments(){this.commentsOpen=!this.commentsOpen;if(this.commentsOpen)this.loadComments();},
async loadComments(){try{const r=await fetch('/api/pages/'+this.pid+'/comments',{credentials:'same-origin'});const d=await r.json();this.comments=d.comments||[];this.commentCount=this.comments.length;}catch(e){this.comments=[];}},
async loadComments(){
try{const r=await fetch('/api/pages/'+this.pid+'/comments',{credentials:'same-origin'});const d=await r.json();
this.comments=d.comments||[];this.commentCount=this.comments.length;}
catch(e){this.comments=[];}
this._paintAnchors();
// #cmt-<id> (copié depuis le menu « … » du fil) → ouvre et centre le fil
if(!this._cmtHashDone&&location.hash.indexOf('#cmt-')===0){
this._cmtHashDone=true;
const cid=parseInt(location.hash.slice(5),10);
if(!isNaN(cid))setTimeout(()=>this.openThread(cid),300);
}
},
fmtTime(s){if(!s)return '';const t=new Date((String(s).includes('T')||String(s).includes('Z'))?s:(s+'Z'));if(isNaN(t.getTime()))t=new Date(s);const diff=Math.floor((Date.now()-t.getTime())/1000);if(diff<60)return 'just now';if(diff<3600)return Math.floor(diff/60)+'m ago';if(diff<86400)return Math.floor(diff/3600)+'h ago';return Math.floor(diff/86400)+'d ago';},
csrfTok(){return getCsrf();},
async addPageComment(){
@@ -2532,8 +2631,94 @@ applyAIBlocks(text){
else{this.showToast(d.detail||'Failed to add comment');}
}catch(e){this.showToast('Failed to add comment');}
},
async resolveComment(id){try{await fetch('/api/comments/'+id,{method:'PUT',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({resolved:true})});await this.loadComments();}catch(e){}},
async deleteComment(id){if(!confirm('Delete this comment?'))return;try{await fetch('/api/comments/'+id,{method:'DELETE',credentials:'same-origin',headers:{'X-CSRF-Token':this.csrfTok()}});await this.loadComments();}catch(e){}},
async resolveComment(c){
// accepte l'objet (le fil → bascule resolve/reopen) ou un id brut
let id=c,resolved=true;
if(c&&typeof c==='object'){id=c.id;resolved=!c.resolved;}
if(!id)return;
try{await fetch('/api/comments/'+id,{method:'PUT',credentials:'same-origin',
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},
body:JSON.stringify({resolved:resolved})});
this.commentMenu=null;await this.loadComments();}catch(e){}
},
async deleteComment(id){if(!confirm('Delete this comment?'))return;try{await fetch('/api/comments/'+id,{method:'DELETE',credentials:'same-origin',headers:{'X-CSRF-Token':this.csrfTok()}});this.commentMenu=null;await this.loadComments();}catch(e){}},
// ── v7.64 : réactions emoji sur texte + ancres commentées ──
async loadReactions(){
try{const r=await fetch('/api/pages/'+this.pid+'/reactions',{credentials:'same-origin'});
const d=await r.json();this.reactions=(d&&d.reactions)||[];}catch(e){this.reactions=[];}
this._paintAnchors();
},
async toggleReaction(bid,start,end,emoji){
try{
const r=await fetch('/api/pages/'+this.pid+'/reactions',{method:'POST',credentials:'same-origin',
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},
body:JSON.stringify({block_id:bid,anchor_start:start,anchor_end:end,emoji:emoji})});
const d=await r.json();
if(d.status==='added'||d.status==='removed'){await this.loadReactions();}
else this.showToast((d&&d.detail)||'Réaction impossible','error');
}catch(e){this.showToast('Réaction impossible','error');}
},
openThread(cid){
this.commentsOpen=true;this.commentMenu=null;
const self=this;
this.loadComments().then(function(){self.$nextTick(function(){
const el=document.querySelector('[data-thread="'+cid+'"]');
if(el&&el.scrollIntoView){el.scrollIntoView({block:'center',behavior:'smooth'});
el.classList.add('thread-flash');setTimeout(function(){el.classList.remove('thread-flash');},1500);}
});});
},
commentAt(){
this.commentDraft=(this.commentDraft||'')+'@';
this.$nextTick(function(){const ta=document.querySelector('.comments-drawer textarea');
if(ta){ta.focus();ta.selectionStart=ta.selectionEnd=ta.value.length;}});
},
copyCommentLink(id){
const url=location.origin+location.pathname+'#cmt-'+id;
this.commentMenu=null;
if(navigator.clipboard&&navigator.clipboard.writeText){
navigator.clipboard.writeText(url).then(()=>this.showToast('Lien du commentaire copié')).catch(()=>this.showToast(url,'error'));
} else this.showToast(url,'error');
},
// Recalcule les ancres jaunes (commentaires) + pastilles de réaction.
// Le texte des blocs est inchangé : sync() lit la même chaîne.
_paintAnchors(){
const ct=document.getElementById('_blocksCt');if(!ct)return;
ct.querySelectorAll('.fd-react-chip').forEach(function(el){el.remove();});
ct.querySelectorAll('.fd-anchor').forEach(function(el){
const par=el.parentNode;if(!par)return;
while(el.firstChild)par.insertBefore(el.firstChild,el);
par.removeChild(el);par.normalize();
});
const self=this;
(this.comments||[]).forEach(function(c){
if(!c.anchor_block_id||c.anchor_start==null||c.anchor_end==null)return;
const el=self.getEl(c.anchor_block_id);if(!el)return;
_wrapAnchored(el,parseInt(c.anchor_start,10),parseInt(c.anchor_end,10),
'fd-anchor'+(c.resolved?' fd-anchor-done':''),{cid:c.id});
});
const groups={};
(this.reactions||[]).forEach(function(r){
const k=r.block_id+':'+r.anchor_start+':'+r.anchor_end;
if(!groups[k])groups[k]={bid:r.block_id,start:parseInt(r.anchor_start,10),end:parseInt(r.anchor_end,10),items:[]};
groups[k].items.push(r);
});
Object.keys(groups).forEach(function(k){
const g=groups[k];
const el=self.getEl(g.bid);if(!el)return;
const sp=_wrapAnchored(el,g.start,g.end,'fd-anchor fd-anchor-react',null);
if(!sp||!sp.parentNode)return;
const n=g.items.reduce(function(a,i){return a+(parseInt(i.count,10)||1);},0);
const chip=document.createElement('span');
chip.className='fd-react-chip';
chip.setAttribute('contenteditable','false');
chip.setAttribute('data-react-bid',g.bid);
chip.setAttribute('data-react-start',String(g.start));
chip.setAttribute('data-react-end',String(g.end));
chip.innerHTML=g.items.map(function(i){return '<span class="fd-react-emo">'+_emojiHtml(i.emoji)+'</span>';}).join('')
+(n>1?'<span class="fd-react-n">'+n+'</span>':'');
sp.parentNode.insertBefore(chip,sp.nextSibling);
});
},
commentOnSelection(){
const sel=this._commentSel;if(!sel.range)return;
const bid=sel.bid||'';this.commentsOpen=true;this.commentDraft='';
@@ -2543,12 +2728,14 @@ applyAIBlocks(text){
showCommentBtn(){
const s=window.getSelection();if(!s||s.isCollapsed)return;
const ct=document.getElementById('_blocksCt');if(!ct||!ct.contains(s.anchorNode))return;
const bel=s.anchorNode.parentElement? s.anchorNode.parentElement.closest('[data-bid]'):null;
// ancre = nœud texte ou élément (comme showFmt depuis v7.62)
const anEl=s.anchorNode?(s.anchorNode.nodeType===1?s.anchorNode:s.anchorNode.parentElement):null;
const bel=anEl?anEl.closest('[data-bid]'):null;
if(!bel)return;
const bid=bel.dataset.bid;
const range=s.getRangeAt(0).cloneRange();range.selectNodeContents(bel);range.setEnd(s.getRangeAt(0).startContainer,s.getRangeAt(0).startOffset);
const start=range.toString().length;
const end=start+s.toString().length;
const span0=this._selSpan(bel,s.getRangeAt(0));
const start=span0.start;
const end=span0.end;
if(end<=start)return;
this._commentSel={range:s.getRangeAt(0).cloneRange(),bid:bid,start:start,end:end};
const r=s.getRangeAt(0).getBoundingClientRect();
@@ -2887,6 +3074,32 @@ applyAIBlocks(text){
// (l'ancien accès rendait muets showFmt/hideCommentBtn/mentions).
function _xed(el){ if(!el) return null; if(el.__x&&el.__x.$data) return el.__x.$data;
return (el._x_dataStack&&el._x_dataStack[0])||null; }
// v7.64 : clic sur une ancre jaune → ouvre et centre le fil de discussion ;
// clic sur une pastille de réaction → réouvre le sélecteur pour cette plage.
document.addEventListener('click',function(ev){
const t=ev.target;if(!t||!t.closest)return;
const d=_xed(document.querySelector('.page-editor-wrapper'));if(!d)return;
const chip=t.closest('.fd-react-chip');
if(chip){
const bid=chip.getAttribute('data-react-bid');
const start=parseInt(chip.getAttribute('data-react-start'),10);
const end=parseInt(chip.getAttribute('data-react-end'),10);
const el=d.getEl(bid);const rect=chip.getBoundingClientRect();
if(!window.FDIconPicker||!el||isNaN(start)||isNaN(end))return;
window.FDIconPicker.openFor({
x:Math.min(Math.max(8,rect.left-40),window.innerWidth-360),
y:Math.min(Math.max(8,rect.bottom+8),window.innerHeight-260),
title:(el.textContent||'').slice(start,end),
onPick:function(emoji){d.toggleReaction(bid,start,end,emoji);}
});
return;
}
const anch=t.closest('.fd-anchor[data-cid]');
if(anch&&d.openThread){
const cid=parseInt(anch.getAttribute('data-cid'),10);
if(!isNaN(cid))d.openThread(cid);
}
});
document.addEventListener('mouseup',(ev)=>{setTimeout(()=>{
// v7.62 : clic dans la toolbar → on laisse la sélection (et le menu) intacts ;
// sélection relâchée/cliquée ailleurs → le menu de sélection se ferme.
+139
View File
@@ -0,0 +1,139 @@
"""7.64.0 — commentaires façon Notion (ancres jaunes + tiroir) & réactions sur texte."""
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SRC = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
TPL = (ROOT / "app" / "templates" / "_page_editor_content.html").read_text(encoding="utf-8")
CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
PICKER = (ROOT / "app" / "templates" / "_icon_picker.html").read_text(encoding="utf-8")
PSTORE = (ROOT / "static" / "js" / "_icon_picker_2.js").read_text(encoding="utf-8")
# ── Backend : migration + endpoints ────────────────────────────────────────
def test_text_reactions_table_exists(client): # le fixture client initialise le schéma
from app.db import get_conn
with get_conn() as conn:
cols = {r[1] for r in conn.execute("PRAGMA table_info(text_reactions)").fetchall()}
assert {"id", "page_id", "block_id", "anchor_start", "anchor_end", "emoji",
"user_id", "created_at"} <= cols, cols
def test_reactions_endpoints_registered():
from app.routers.collaboration import router
methods = {(r.path, m) for r in router.routes for m in r.methods}
assert ("/api/pages/{page_id}/reactions", "GET") in methods
assert ("/api/pages/{page_id}/reactions", "POST") in methods
def test_toggle_reaction_api(client):
"""Le POST bascule (added → removed → added) et le GET regroupe par plage."""
from app.auth.session import SessionManager
from app.db import get_conn
with get_conn() as conn:
conn.execute("INSERT INTO users (login, full_name, email) VALUES (?,?,?)",
("v764u", "V764 User", "[email protected]"))
uid = conn.execute("SELECT id FROM users WHERE login='v764u'").fetchone()["id"]
cur = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format) VALUES (?,?,?,?)",
("u764", "React Page", "[]", "blocks"))
pid = cur.lastrowid
conn.commit()
cookies = {"flowdeck_session": SessionManager.create_session(
{"id": uid, "login": "v764u", "is_admin": 0})}
payload = {"block_id": "b1", "anchor_start": 0, "anchor_end": 5, "emoji": "👍"}
r = client.post(f"/api/pages/{pid}/reactions", json=payload, cookies=cookies)
assert r.status_code == 200 and r.json()["status"] == "added"
r = client.post(f"/api/pages/{pid}/reactions", json=payload, cookies=cookies)
assert r.status_code == 200 and r.json()["status"] == "removed"
r = client.post(f"/api/pages/{pid}/reactions", json=payload, cookies=cookies)
assert r.json()["status"] == "added"
r = client.get(f"/api/pages/{pid}/reactions", cookies=cookies)
assert r.status_code == 200
rows = r.json()["reactions"]
assert len(rows) == 1
assert rows[0]["emoji"] == "👍" and rows[0]["count"] == 1 and rows[0]["mine"] == 1
# plage vide → 400
bad = dict(payload, anchor_start=4, anchor_end=4)
r = client.post(f"/api/pages/{pid}/reactions", json=bad, cookies=cookies)
assert r.status_code == 400
# ── Toolbar : bouton réaction ──────────────────────────────────────────────
def test_toolbar_has_react_button():
assert 'fmtReact()' in TPL
assert 'data-tooltip="React to selected text"' in TPL
# position : 3ᵉ ligne, après le surlignage, avant le code
chunk = TPL[TPL.index('class="format-toolbar"'):TPL.index("Copy Link Toast")]
order = ["fmtHighlight()", "fmtReact()", "fmtCodeBlock()", "fmtMore($event)"]
positions = [chunk.index(o) for o in order]
assert positions == sorted(positions), positions
def test_fmt_react_opens_picker_with_selection():
js = SRC[SRC.index("fmtReact(){"):SRC.index("toggleComments(){")]
for token in ("FDIconPicker.openFor", "title:text", "onPick:",
"toggleReaction(bid,span.start,span.end,emoji)", "_selSpan(bel,this._sel.range)"):
assert token in js, token
# ── Ancres commentées + pastilles ──────────────────────────────────────────
def test_anchors_painting():
for token in ("function _wrapAnchored(", "_paintAnchors()", "fd-anchor",
"fd-react-chip", "data-react-bid", "data-react-start", "openThread(cid)",
"loadReactions()", "toggleReaction(bid,start,end,emoji)"):
assert token in SRC, token
# repeint après chaque reconstruction du bloc ET après le chargement
assert "this._paintAnchors();}catch(e){}" in SRC # fin de render()
assert "this._paintAnchors();\n" in SRC # fin de loadComments/loadReactions
# la pastille n'est pas du contenu lu/sauvé
assert "n.classList.contains('fd-react-chip')" in SRC # gtTok
assert "cl.contains('fd-react-chip')" in SRC # gtMd
# offsets source (les annotations ne décalent pas les ancres)
assert "function _srcOffset(" in SRC and "_srcNodeLen(" in SRC
# clic ancre → fil ; clic pastille → sélecteur
assert "t.closest('.fd-react-chip')" in SRC
assert "t.closest('.fd-anchor[data-cid]')" in SRC
# ── Tiroir de commentaires (image 1) ───────────────────────────────────────
def test_drawer_markup():
for token in ('class="ct-head"', 'class="ct-avatar"', 'class="ct-name"',
'class="ct-time"', 'class="ct-actions"', 'class="ct-menu"',
"copyCommentLink(c.id)", "deleteComment(c.id)", "resolveComment(c)",
':data-thread="c.id"', 'placeholder="Add a comment..."',
"commentAt()", "class=\"ct-send\"", "addPageComment()"):
assert token in TPL, token
# le bouton ✓ bascule resolve/reopen (l'objet, pas l'id seul)
assert "resolveComment(c)" in TPL and "async resolveComment(c)" in SRC
# hash #cmt- → centrage du fil
assert "#cmt-" in SRC and "scrollIntoView" in SRC
# styles du tiroir
for sel in (".comment-thread {", ".ct-head {", ".ct-avatar {", ".ct-menu {",
".ct-input-row {", ".ct-send {", ".fd-anchor {", ".fd-react-chip {"):
assert sel in CSS, sel
# ── Sélecteur d'émoji (image 3) : mode réaction ────────────────────────────
def test_picker_reaction_mode():
for token in ("fdip-title", "fdip-title-text", "fdip-title-grip",
"$store.fdIconPicker.title", "Filter...", "Recent"):
assert token in PICKER, token
for token in ("this.title = opts.title || ''", "this.category = 'people'",
"this.title = ''"):
assert token in PSTORE, token
# la ligne « Recent » au-dessus de la grille n'apparaît qu'en mode réaction
assert "$store.fdIconPicker.title && $store.fdIconPicker.category !== 'recent'" in PICKER
# onglets masqués en mode réaction (emoji only)
assert 'x-show="!$store.fdIconPicker.title"' in PICKER