Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d58c321c07 |
@@ -1,5 +1,61 @@
|
|||||||
# Changelog - FlowDeck
|
# Changelog - FlowDeck
|
||||||
|
|
||||||
|
## v7.67.0 (2026-10-08) — Réactions dans le tiroir Comments (+ suppression) et refonte du menu de sélection
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Les réactions vivent dans le tiroir « Comments »** : section **Reactions**
|
||||||
|
au-dessus des fils — émoji, compte (si >1) et **texte ancré** lu dans le DOM
|
||||||
|
(`reactionList()` / `_anchorText()`, offsets source hors pastilles). L'état
|
||||||
|
vide « No comments yet » ne s'affiche plus s'il reste des réactions.
|
||||||
|
- **Suppression d'une réaction — deux entrées, un seul chemin** :
|
||||||
|
- **✕** sur la ligne de la section Reactions (visible uniquement sur **mes**
|
||||||
|
réactions) ;
|
||||||
|
- au **survol de la pastille**, l'émoji que j'ai posé devient une **croix**
|
||||||
|
(`::after`, aucun nœud ajouté → offsets source inchangés), un clic retire.
|
||||||
|
- les deux appellent `removeReaction()` → `POST /reactions` (toggle) →
|
||||||
|
`loadReactions()` + repeindre les ancres.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Refonte du menu de sélection façon capture** :
|
||||||
|
- ligne 2 `[A] B I U T Tx` (le barré est conservé — absent de la maquette,
|
||||||
|
décision volontaire pour ne pas perdre la fonction) ;
|
||||||
|
- ligne 3 `🔗 S </> √x …` (la réaction en est sortie) ;
|
||||||
|
- **ligne 4 = une rangée scannée** : `💬 Comment` (libellé) à gauche,
|
||||||
|
`🙂+` réaction à droite (`.ft-row-split`) ;
|
||||||
|
- entête **Skills** avec `≡` à droite (`.ft-skills-head::after`), liste
|
||||||
|
**scrollable** (`.ft-skills`, `max-height` + `overflow-y`) ;
|
||||||
|
- **✨ Ask AI retiré** de la toolbar : doublon exact de « Edit with AI » du
|
||||||
|
pied (même `fmtAskAI()`).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Flake de gate corrigé (racine)** : `test_v55.py::TestOGParser::
|
||||||
|
test_fetch_og_metadata_success` dépendait d'un **vrai** `socket.getaddrinfo
|
||||||
|
("example.com")` via le garde SSRF `_is_public_host` ; sous `pytest -n auto`
|
||||||
|
un `socket.timeout` (non couvert par `except socket.gaierror`) remontait
|
||||||
|
dans le `except Exception` de `fetch_og_metadata` → repli silencieux
|
||||||
|
(`title='example.com'`) → AssertionError, 3 fois sur 5 runs.
|
||||||
|
- `_is_public_host` catch désormais **`OSError`** : le garde renvoie un
|
||||||
|
booléen, il ne lève jamais (les autres appelants gagnent aussi) ;
|
||||||
|
- fixture `stub_dns` sur `TestOGParser` (même motif que
|
||||||
|
`test_agent_web_tools.stub_dns`) : zéro DNS dans les tests OG.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- `tests/test_v767_reactions_drawer.py` (3) ; `tests/test_v762_selection_toolbar.py`
|
||||||
|
(ligne 4 + styles) ; `tests/test_v764_comments_reactions.py` (position de
|
||||||
|
`fmtReact`).
|
||||||
|
- **e2e** : `v764_comments_reactions` étendu — section Reactions dans le tiroir
|
||||||
|
(texte ancré), **suppression A** (✕ du tiroir → pastille disparue),
|
||||||
|
**suppression B** (croix `::after` visible seulement au survol, puis clic) ;
|
||||||
|
`v762_selection_toolbar` — `Comment` + `ft-row-split .ft-react`,
|
||||||
|
`ft-skills-head` = « Skills », plus de bouton ✨.
|
||||||
|
- `pytest tests/ -q -n auto` → **1390 passed, 0 failed** (flake corrigé),
|
||||||
|
`ruff check app tests` → OK.
|
||||||
|
|
||||||
|
|
||||||
## v7.66.0 (2026-10-08) — Escape du menu slash retire le « / » + bouton « commenter » à droite du bloc
|
## v7.66.0 (2026-10-08) — Escape du menu slash retire le « / » + bouton « commenter » à droite du bloc
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+23
@@ -1501,6 +1501,29 @@ Détails livrés :
|
|||||||
`tests/test_v766_slash_esc_comment_btn.py` (3) ; `pytest` **1386 passed**,
|
`tests/test_v766_slash_esc_comment_btn.py` (3) ; `pytest` **1386 passed**,
|
||||||
`ruff` OK ; `VERSION` + `app/main.py` = 7.66.0
|
`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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
|
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# WORKLOAD — FlowDeck Notion Clone
|
# 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.67.0 (réactions dans le tiroir Comments + suppression, refonte du menu de sélection) | **Statut**: EN COURS 🔄
|
||||||
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
|
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
|
||||||
|
|
||||||
## Avancement Global
|
## Avancement Global
|
||||||
|
|||||||
+1
-1
@@ -186,7 +186,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="7.66.0",
|
version="7.67.0",
|
||||||
docs_url="/docs",
|
docs_url="/docs",
|
||||||
redoc_url="/redoc",
|
redoc_url="/redoc",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ def _is_public_host(host: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
infos = socket.getaddrinfo(host, None)
|
infos = socket.getaddrinfo(host, None)
|
||||||
except socket.gaierror:
|
except OSError:
|
||||||
|
# gaierror, socket.timeout, herror… : le garde-fou doit TOUJOURS
|
||||||
|
# renvoyer un booléen, jamais lever — sinon l'appelant tombe dans son
|
||||||
|
# `except Exception` (bookmarks) au lieu d'un refus explicite.
|
||||||
return False
|
return False
|
||||||
for info in infos:
|
for info in infos:
|
||||||
address = info[4][0]
|
address = info[4][0]
|
||||||
|
|||||||
@@ -590,7 +590,20 @@
|
|||||||
<button type="button" class="topbar-btn" @click="toggleComments()" title="Close">✕</button>
|
<button type="button" class="topbar-btn" @click="toggleComments()" title="Close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="comments-drawer-list" style="flex:1;overflow-y:auto;padding:12px 16px;">
|
<div class="comments-drawer-list" style="flex:1;overflow-y:auto;padding:12px 16px;">
|
||||||
<div x-show="comments.length === 0" style="text-align:center;color:var(--text-dim,#999);padding:32px 0;">
|
<!-- v7.67 : les réactions emoji du texte vivent aussi ici (✕ = retirer) -->
|
||||||
|
<div class="ct-reactions" x-show="reactionList().length" x-cloak style="margin-bottom:14px;">
|
||||||
|
<div class="ct-section-title">Reactions</div>
|
||||||
|
<template x-for="r in reactionList()" :key="r.key">
|
||||||
|
<div class="ct-reaction">
|
||||||
|
<span class="ct-re-emoji" x-text="r.emoji"></span>
|
||||||
|
<span class="ct-re-count" x-show="r.count > 1" x-text="r.count"></span>
|
||||||
|
<span class="ct-re-text" x-text="r.text"></span>
|
||||||
|
<button type="button" class="ct-act" title="Remove reaction"
|
||||||
|
x-show="r.mine" @click="removeReaction(r)">✕</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div x-show="comments.length === 0 && !reactionList().length" style="text-align:center;color:var(--text-dim,#999);padding:32px 0;">
|
||||||
No comments yet. Select some text and click 💬 Comment.
|
No comments yet. Select some text and click 💬 Comment.
|
||||||
</div>
|
</div>
|
||||||
<template x-for="c in comments" :key="c.id">
|
<template x-for="c in comments" :key="c.id">
|
||||||
@@ -666,23 +679,25 @@
|
|||||||
<div class="ft-row">
|
<div class="ft-row">
|
||||||
<button type="button" class="ft-item" @click="fmtLink()" data-tooltip="Link">{{ fd_icon("link",15) }}</button>
|
<button type="button" class="ft-item" @click="fmtLink()" data-tooltip="Link">{{ fd_icon("link",15) }}</button>
|
||||||
<button type="button" class="ft-item" @click="fmtHighlight()" data-tooltip="Highlight"><span class="ft-hl">S</span></button>
|
<button type="button" class="ft-item" @click="fmtHighlight()" data-tooltip="Highlight"><span class="ft-hl">S</span></button>
|
||||||
<button type="button" class="ft-item ft-react" @click="fmtReact()" data-tooltip="React to selected text"><span class="ft-react-ico">🙂<span class="ft-react-plus">+</span></span></button>
|
|
||||||
<button type="button" class="ft-item ft-code-ico" @click="fmtCodeBlock()" data-tooltip="Code block"></></button>
|
<button type="button" class="ft-item ft-code-ico" @click="fmtCodeBlock()" data-tooltip="Code block"></></button>
|
||||||
<button type="button" class="ft-item" @click="fmtEquation()" data-tooltip="Equation">√x</button>
|
<button type="button" class="ft-item" @click="fmtEquation()" data-tooltip="Equation">√x</button>
|
||||||
<button type="button" class="ft-item ft-more" @click="fmtMore($event)" data-tooltip="More actions">…</button>
|
<button type="button" class="ft-item ft-more" @click="fmtMore($event)" data-tooltip="More actions">…</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Ligne 4 : actions -->
|
<!-- Ligne 4 : Comment à gauche, réaction à droite (le ✨ Ask AI sauté :
|
||||||
<div class="ft-row">
|
doublon exact du bouton du pied, même action) -->
|
||||||
<button type="button" class="ft-item" @click="fmtComment()" data-tooltip="Comment — Ctrl+⇧+M">{{ fd_icon("message-square",15) }}</button>
|
<div class="ft-row ft-row-split">
|
||||||
<button type="button" class="ft-item" @click="fmtAskAI()" data-tooltip="Ask AI — Ctrl+J">{{ fd_icon("sparkles",15) }}</button>
|
<button type="button" class="ft-item ft-comment" @click="fmtComment()" data-tooltip="Comment — Ctrl+⇧+M">{{ fd_icon("message-square",15) }}<span>Comment</span></button>
|
||||||
|
<button type="button" class="ft-item ft-react" @click="fmtReact()" data-tooltip="React to selected text"><span class="ft-react-ico">🙂<span class="ft-react-plus">+</span></span></button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Section « Skills » -->
|
<!-- Section « Skills » -->
|
||||||
<div class="ft-sep"></div>
|
<div class="ft-sep"></div>
|
||||||
<div class="ft-group">Skills</div>
|
<div class="ft-group ft-skills-head">Skills</div>
|
||||||
|
<div class="ft-skills">
|
||||||
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Improve the writing of this text (wording, clarity, style). Keep the meaning, language and format. Reply ONLY with the improved text.')" data-tooltip="Run on this block">Improve writing</button>
|
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Improve the writing of this text (wording, clarity, style). Keep the meaning, language and format. Reply ONLY with the improved text.')" data-tooltip="Run on this block">Improve writing</button>
|
||||||
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Proofread this text: fix spelling and grammar mistakes only. Reply ONLY with the corrected text.')" data-tooltip="Run on this block">Proofread</button>
|
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Proofread this text: fix spelling and grammar mistakes only. Reply ONLY with the corrected text.')" data-tooltip="Run on this block">Proofread</button>
|
||||||
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Explain this text in simpler terms, in the same language. Reply ONLY with the explanation.')" data-tooltip="Run on this block">Explain</button>
|
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Explain this text in simpler terms, in the same language. Reply ONLY with the explanation.')" data-tooltip="Run on this block">Explain</button>
|
||||||
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Reformat this text for readability (structure, spacing, lists where useful) without changing its meaning. Reply ONLY with the reformatted text.')" data-tooltip="Run on this block">Reformat</button>
|
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Reformat this text for readability (structure, spacing, lists where useful) without changing its meaning. Reply ONLY with the reformatted text.')" data-tooltip="Run on this block">Reformat</button>
|
||||||
|
</div>
|
||||||
<!-- Pied de page -->
|
<!-- Pied de page -->
|
||||||
<div class="ft-sep"></div>
|
<div class="ft-sep"></div>
|
||||||
<button type="button" class="ft-item ft-foot" @click="fmtEditAI()" data-tooltip="Edit with AI">
|
<button type="button" class="ft-item ft-foot" @click="fmtEditAI()" data-tooltip="Edit with AI">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "FlowDeck",
|
"title": "FlowDeck",
|
||||||
"version": "7.66.0"
|
"version": "7.67.0"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/auth/register": {
|
"/auth/register": {
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ test('toolbar de sélection : structure, sous-menus, persistance', async ({ page
|
|||||||
await expect(bar.locator('.ft-skill')).toHaveCount(4);
|
await expect(bar.locator('.ft-skill')).toHaveCount(4);
|
||||||
await expect(bar.locator('.ft-skill').first()).toHaveText('Improve writing');
|
await expect(bar.locator('.ft-skill').first()).toHaveText('Improve writing');
|
||||||
await expect(bar.locator('.ft-foot')).toContainText('Edit with AI');
|
await expect(bar.locator('.ft-foot')).toContainText('Edit with AI');
|
||||||
|
// refonte v7.67 : ligne 4 = Comment (libellé) à gauche, réaction à droite ;
|
||||||
|
// entête Skills avec ≡ ; plus de bouton ✨ Ask AI (doublon du pied)
|
||||||
|
await expect(bar.locator('.ft-comment')).toContainText('Comment');
|
||||||
|
await expect(bar.locator('.ft-row-split .ft-react')).toHaveCount(1);
|
||||||
|
await expect(bar.locator('.ft-skills-head')).toHaveText('Skills');
|
||||||
|
await expect(bar.locator('[data-tooltip^="Ask AI"]')).toHaveCount(0);
|
||||||
// géométrie : sous la sélection, entièrement dans le viewport
|
// géométrie : sous la sélection, entièrement dans le viewport
|
||||||
const barBox = await bar.boundingBox();
|
const barBox = await bar.boundingBox();
|
||||||
const selBox = await page.evaluate(() => {
|
const selBox = await page.evaluate(() => {
|
||||||
|
|||||||
@@ -168,4 +168,34 @@ test('réaction emoji + commentaires ancrés', async ({ page }) => {
|
|||||||
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveCount(1, { timeout: 10000 });
|
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveCount(1, { timeout: 10000 });
|
||||||
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveText('reaction a un');
|
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveText('reaction a un');
|
||||||
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1, { timeout: 10000 });
|
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1, { timeout: 10000 });
|
||||||
|
|
||||||
|
// ── 9. v7.67 : la réaction vit aussi dans le tiroir Comments ──
|
||||||
|
await page.locator('.topbar-btn[title="Comments"]').click();
|
||||||
|
const re = page.locator('.ct-reactions');
|
||||||
|
await expect(re).toBeVisible();
|
||||||
|
await expect(re.locator('.ct-re-emoji')).toHaveCount(1);
|
||||||
|
await expect(re.locator('.ct-re-text')).toHaveText('Prise de reaction a un texte');
|
||||||
|
await page.screenshot({ path: 'shots/v764-drawer-reactions.png' });
|
||||||
|
|
||||||
|
// ── 10. suppression A : ✕ de la section Réactions ──
|
||||||
|
await re.locator('.ct-reaction .ct-act').click();
|
||||||
|
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(0);
|
||||||
|
await expect(re).toBeHidden();
|
||||||
|
|
||||||
|
// ── 11. suppression B : la croix survolée de la pastille ──
|
||||||
|
await page.locator('.topbar-btn[title="Comments"]').click(); // referme le tiroir
|
||||||
|
await selectBlock(page);
|
||||||
|
await page.locator('.format-toolbar .ft-react').click();
|
||||||
|
const picker2 = page.locator('.fd-icon-picker');
|
||||||
|
await expect(picker2).toBeVisible();
|
||||||
|
await picker2.locator('.fdip-emoji').first().click();
|
||||||
|
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1);
|
||||||
|
const emo = page.locator('#_blocksCt .fd-react-emo.is-mine');
|
||||||
|
await expect(emo).toHaveCount(1);
|
||||||
|
// la croix n'apparaît qu'au survol (::after)
|
||||||
|
await expect.poll(() => emo.evaluate((el) => getComputedStyle(el, '::after').display)).toBe('none');
|
||||||
|
await page.locator('#_blocksCt .fd-react-chip').hover();
|
||||||
|
await expect.poll(() => emo.evaluate((el) => getComputedStyle(el, '::after').display)).toBe('flex');
|
||||||
|
await emo.click();
|
||||||
|
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3040,6 +3040,30 @@ button.breadcrumb-current { max-width: 260px; }
|
|||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── v7.67 : refonte façon capture — ligne Comment | réaction aux 2 extrémités,
|
||||||
|
entête « Skills » avec ≡ à droite, liste Skills scrollable ── */
|
||||||
|
.format-toolbar .ft-row-split {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.format-toolbar .ft-comment {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.format-toolbar .ft-skills-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.format-toolbar .ft-skills-head::after {
|
||||||
|
content: '≡';
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.format-toolbar .ft-skills {
|
||||||
|
max-height: 132px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* Séparateurs, groupe « Skills », pied de page */
|
/* Séparateurs, groupe « Skills », pied de page */
|
||||||
.format-toolbar .ft-sep {
|
.format-toolbar .ft-sep {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
@@ -3212,6 +3236,28 @@ button.breadcrumb-current { max-width: 260px; }
|
|||||||
.fd-react-chip img { width: 12px; height: 12px; object-fit: contain; }
|
.fd-react-chip img { width: 12px; height: 12px; object-fit: contain; }
|
||||||
.fd-react-n { font-size: 10px; color: var(--text-dim); }
|
.fd-react-n { font-size: 10px; color: var(--text-dim); }
|
||||||
|
|
||||||
|
/* v7.67 : suppression d'une réaction — au survol de la pastille, l'émoji que
|
||||||
|
J'ai posé devient une croix (un seul clic = retirer). Pas de reflow : c'est
|
||||||
|
un ::after, les offsets source ne bougent pas. */
|
||||||
|
.fd-react-emo { position: relative; display: inline-flex; align-items: center; }
|
||||||
|
.fd-react-emo.is-mine::after {
|
||||||
|
content: '✕';
|
||||||
|
position: absolute;
|
||||||
|
inset: -3px;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-dim);
|
||||||
|
background: var(--bg-modal, #1f1f1f);
|
||||||
|
border: 1px solid var(--border, #333);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.fd-react-chip:hover .fd-react-emo.is-mine::after { display: flex; }
|
||||||
|
.fd-react-emo.is-mine:hover::after { color: #e5484d; border-color: #e5484d; }
|
||||||
|
|
||||||
/* Tiroir de commentaires (fil + saisie) */
|
/* Tiroir de commentaires (fil + saisie) */
|
||||||
.comment-thread {
|
.comment-thread {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -3251,6 +3297,37 @@ button.breadcrumb-current { max-width: 260px; }
|
|||||||
.ct-menu-item.is-danger { color: #e5484d; }
|
.ct-menu-item.is-danger { color: #e5484d; }
|
||||||
.ct-body { font-size: 13px; white-space: pre-wrap; margin-top: 6px; color: var(--text-primary); }
|
.ct-body { font-size: 13px; white-space: pre-wrap; margin-top: 6px; color: var(--text-primary); }
|
||||||
.ct-anchor-label { font-size: 10px; color: var(--text-dim); margin-top: 6px; }
|
.ct-anchor-label { font-size: 10px; color: var(--text-dim); margin-top: 6px; }
|
||||||
|
|
||||||
|
/* v7.67 : section Réactions du tiroir (liste des plages réagies + retirer) */
|
||||||
|
.ct-section-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.ct-reaction {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
background: var(--bg-secondary, #2a2a2a);
|
||||||
|
border: 1px solid var(--border, #333);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.ct-re-emoji { font-size: 14px; line-height: 1; flex-shrink: 0; }
|
||||||
|
.ct-re-count { font-size: 11px; color: var(--text-dim); }
|
||||||
|
.ct-re-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.comments-drawer-input { border-top: 1px solid var(--border, #333); padding: 12px 16px; }
|
.comments-drawer-input { border-top: 1px solid var(--border, #333); padding: 12px 16px; }
|
||||||
.ct-input-row { display: flex; gap: 6px; align-items: flex-end; }
|
.ct-input-row { display: flex; gap: 6px; align-items: flex-end; }
|
||||||
.ct-input-row textarea {
|
.ct-input-row textarea {
|
||||||
|
|||||||
@@ -2695,6 +2695,30 @@ applyAIBlocks(text){
|
|||||||
else this.showToast((d&&d.detail)||'Réaction impossible','error');
|
else this.showToast((d&&d.detail)||'Réaction impossible','error');
|
||||||
}catch(e){this.showToast('Réaction impossible','error');}
|
}catch(e){this.showToast('Réaction impossible','error');}
|
||||||
},
|
},
|
||||||
|
// ── v7.67 : les réactions vivent aussi dans le tiroir Comments ──
|
||||||
|
// (une ligne par plage × émoji, déjà groupées par l'API)
|
||||||
|
reactionList(){
|
||||||
|
return (this.reactions||[]).map(r=>{
|
||||||
|
const bid=r.block_id,start=parseInt(r.anchor_start,10),end=parseInt(r.anchor_end,10);
|
||||||
|
return {key:bid+':'+start+':'+end+':'+r.emoji,bid:bid,start:start,end:end,emoji:r.emoji,
|
||||||
|
count:parseInt(r.count,10)||1,mine:!!parseInt(r.mine,10),
|
||||||
|
text:this._anchorText(bid,start,end)};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// suppression : POST toggle (le backend retire MA ligne si elle existe)
|
||||||
|
removeReaction(r){if(!r)return;this.toggleReaction(r.bid,r.start,r.end,r.emoji);},
|
||||||
|
// texte source d'une plage ancrée (hors pastilles .fd-react-chip)
|
||||||
|
_anchorText(bid,start,end){
|
||||||
|
const el=this.getEl(bid);if(!el||!(end>start))return '';
|
||||||
|
const w=document.createTreeWalker(el,NodeFilter.SHOW_TEXT);let n,pos=0,txt='';
|
||||||
|
while((n=w.nextNode())){
|
||||||
|
if(n.parentElement&&n.parentElement.closest('.fd-react-chip'))continue;
|
||||||
|
const len=n.nodeValue.length,a=Math.max(start,pos),b=Math.min(end,pos+len);
|
||||||
|
if(b>a)txt+=n.nodeValue.slice(a-pos,b-pos);
|
||||||
|
pos+=len;if(pos>=end)break;
|
||||||
|
}
|
||||||
|
return txt;
|
||||||
|
},
|
||||||
openThread(cid){
|
openThread(cid){
|
||||||
this.commentsOpen=true;this.commentMenu=null;
|
this.commentsOpen=true;this.commentMenu=null;
|
||||||
const self=this;
|
const self=this;
|
||||||
@@ -2751,7 +2775,13 @@ applyAIBlocks(text){
|
|||||||
chip.setAttribute('data-react-bid',g.bid);
|
chip.setAttribute('data-react-bid',g.bid);
|
||||||
chip.setAttribute('data-react-start',String(g.start));
|
chip.setAttribute('data-react-start',String(g.start));
|
||||||
chip.setAttribute('data-react-end',String(g.end));
|
chip.setAttribute('data-react-end',String(g.end));
|
||||||
chip.innerHTML=g.items.map(function(i){return '<span class="fd-react-emo">'+_emojiHtml(i.emoji)+'</span>';}).join('')
|
// chaque émoji porte son data-emoji ; `is-mine` = j'ai réagi →
|
||||||
|
// la croix de suppression (CSS ::after au survol) ne s'affiche
|
||||||
|
// que là, un clic sur les autres rouvre le sélecteur.
|
||||||
|
chip.innerHTML=g.items.map(function(i){
|
||||||
|
const q=String(i.emoji||'').replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<');
|
||||||
|
return '<span class="fd-react-emo'+(parseInt(i.mine,10)?' is-mine':'')+'" data-emoji="'+q+'">'+_emojiHtml(i.emoji)+'</span>';
|
||||||
|
}).join('')
|
||||||
+(n>1?'<span class="fd-react-n">'+n+'</span>':'');
|
+(n>1?'<span class="fd-react-n">'+n+'</span>':'');
|
||||||
sp.parentNode.insertBefore(chip,sp.nextSibling);
|
sp.parentNode.insertBefore(chip,sp.nextSibling);
|
||||||
});
|
});
|
||||||
@@ -3098,6 +3128,12 @@ applyAIBlocks(text){
|
|||||||
const bid=chip.getAttribute('data-react-bid');
|
const bid=chip.getAttribute('data-react-bid');
|
||||||
const start=parseInt(chip.getAttribute('data-react-start'),10);
|
const start=parseInt(chip.getAttribute('data-react-start'),10);
|
||||||
const end=parseInt(chip.getAttribute('data-react-end'),10);
|
const end=parseInt(chip.getAttribute('data-react-end'),10);
|
||||||
|
// v7.67 : clic sur la croix d'un émoji à moi → retire MA réaction
|
||||||
|
const emo=t.closest('.fd-react-emo');
|
||||||
|
if(emo&&emo.classList.contains('is-mine')){
|
||||||
|
d.removeReaction({bid:bid,start:start,end:end,emoji:emo.getAttribute('data-emoji')});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const el=d.getEl(bid);const rect=chip.getBoundingClientRect();
|
const el=d.getEl(bid);const rect=chip.getBoundingClientRect();
|
||||||
if(!window.FDIconPicker||!el||isNaN(start)||isNaN(end))return;
|
if(!window.FDIconPicker||!el||isNaN(start)||isNaN(end))return;
|
||||||
window.FDIconPicker.openFor({
|
window.FDIconPicker.openFor({
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ Covers the five roadmap points:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
|
import socket
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -209,7 +211,27 @@ _OG_HTML = """<!doctype html><html><head>
|
|||||||
</head><body>hi</body></html>"""
|
</head><body>hi</body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
#: IP publique factice — TestOGParser ne doit dépendre ni du réseau HTTP
|
||||||
|
#: (MockTransport) ni du DNS : `_is_public_host` résout l'hôte pour décider si
|
||||||
|
#: l'IP est publique, et cette résolution échoue sous `pytest -n auto` (même
|
||||||
|
#: piège que test_agent_web_tools.stub_dns — d'où un flake récurrent ici).
|
||||||
|
PUBLIC_IP = "93.184.216.34"
|
||||||
|
|
||||||
|
|
||||||
class TestOGParser:
|
class TestOGParser:
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def stub_dns(self, monkeypatch):
|
||||||
|
real = socket.getaddrinfo
|
||||||
|
|
||||||
|
def fake_getaddrinfo(host, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(host)
|
||||||
|
except ValueError:
|
||||||
|
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (PUBLIC_IP, 0))]
|
||||||
|
return real(host, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
|
||||||
|
|
||||||
def test_parse_og_handles_attribute_order(self):
|
def test_parse_og_handles_attribute_order(self):
|
||||||
from app.services.og_fetcher import parse_og
|
from app.services.og_fetcher import parse_og
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ def test_toolbar_markup_matches_spec():
|
|||||||
"fmtApply('strikeThrough')", "fmtCode()", # ligne 2 : A B I U T Tx
|
"fmtApply('strikeThrough')", "fmtCode()", # ligne 2 : A B I U T Tx
|
||||||
"fmtLink()", "fmtHighlight()", "fmtCodeBlock()", "fmtEquation()",
|
"fmtLink()", "fmtHighlight()", "fmtCodeBlock()", "fmtEquation()",
|
||||||
"fmtMore($event)", # ligne 3 : 🔗 S </> √x …
|
"fmtMore($event)", # ligne 3 : 🔗 S </> √x …
|
||||||
"fmtComment()", "fmtAskAI()", # ligne 4 : 💬 ✨
|
"fmtComment()", "fmtReact()", # ligne 4 : 💬 Comment | 🙂 réaction
|
||||||
"ft-group", "Improve writing", "Proofread", "Explain", "Reformat",
|
"ft-group", "Improve writing", "Proofread", "Explain", "Reformat",
|
||||||
"Edit with AI", "Alt+⇧+E", # pied de page
|
"Edit with AI", "Alt+⇧+E", # pied de page
|
||||||
]
|
]
|
||||||
@@ -41,7 +41,23 @@ def test_toolbar_markup_matches_spec():
|
|||||||
assert "data-tooltip-html=" in chunk
|
assert "data-tooltip-html=" in chunk
|
||||||
# icônes réellement définies dans le jeu d'icônes
|
# icônes réellement définies dans le jeu d'icônes
|
||||||
assert 'fd_icon("message-square",15)' in chunk
|
assert 'fd_icon("message-square",15)' in chunk
|
||||||
assert 'fd_icon("sparkles",15)' in chunk and 'fd_icon("link",15)' in chunk
|
assert 'fd_icon("link",15)' in chunk
|
||||||
|
|
||||||
|
|
||||||
|
def test_toolbar_row4_matches_reference_layout():
|
||||||
|
"""v7.67 — ligne Comment à gauche, réaction à droite, Skills avec ≡."""
|
||||||
|
chunk = _slice(TPL, 'class="format-toolbar"', "Copy Link Toast")
|
||||||
|
# ligne 4 = une seule rangée scannée, boutons aux 2 extrémités
|
||||||
|
assert 'ft-row-split' in chunk
|
||||||
|
assert '<span>Comment</span>' in chunk # libellé, pas juste l'icône
|
||||||
|
assert chunk.index("fmtComment()") < chunk.index("fmtReact()")
|
||||||
|
# l'✨ Ask AI a sauté : doublon exact du « Edit with AI » du pied
|
||||||
|
assert '"fmtAskAI()"' not in chunk
|
||||||
|
# entête Skills (≡ en CSS) + liste scrollable
|
||||||
|
assert 'ft-skills-head' in chunk and 'ft-skills' in chunk
|
||||||
|
for sel in (".format-toolbar .ft-row-split {", ".format-toolbar .ft-comment {",
|
||||||
|
".format-toolbar .ft-skills-head::after {", ".format-toolbar .ft-skills {"):
|
||||||
|
assert sel in CSS, sel
|
||||||
|
|
||||||
|
|
||||||
# ── Actions : câblées vers de vraies méthodes, sous-menus réutilisés ────────
|
# ── Actions : câblées vers de vraies méthodes, sous-menus réutilisés ────────
|
||||||
|
|||||||
@@ -71,9 +71,9 @@ def test_toggle_reaction_api(client):
|
|||||||
def test_toolbar_has_react_button():
|
def test_toolbar_has_react_button():
|
||||||
assert 'fmtReact()' in TPL
|
assert 'fmtReact()' in TPL
|
||||||
assert 'data-tooltip="React to selected text"' in TPL
|
assert 'data-tooltip="React to selected text"' in TPL
|
||||||
# position : 3ᵉ ligne, après le surlignage, avant le code
|
# position (v7.67) : 4ᵉ ligne, à droite de « Comment », après les outils
|
||||||
chunk = TPL[TPL.index('class="format-toolbar"'):TPL.index("Copy Link Toast")]
|
chunk = TPL[TPL.index('class="format-toolbar"'):TPL.index("Copy Link Toast")]
|
||||||
order = ["fmtHighlight()", "fmtReact()", "fmtCodeBlock()", "fmtMore($event)"]
|
order = ["fmtMore($event)", "fmtComment()", "fmtReact()", "ft-group"]
|
||||||
positions = [chunk.index(o) for o in order]
|
positions = [chunk.index(o) for o in order]
|
||||||
assert positions == sorted(positions), positions
|
assert positions == sorted(positions), positions
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user