Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db6bf03a00 | ||
|
|
c031bf67de | ||
|
|
ab6977733d | ||
|
|
00c5e2a60d | ||
|
|
858649e3c3 |
@@ -23,6 +23,7 @@ uv.lock
|
|||||||
*.db-*
|
*.db-*
|
||||||
test-commit.md
|
test-commit.md
|
||||||
upload_test.txt
|
upload_test.txt
|
||||||
|
commit_msg.txt
|
||||||
e2e/node_modules/
|
e2e/node_modules/
|
||||||
e2e/shots/
|
e2e/shots/
|
||||||
e2e/test-results/
|
e2e/test-results/
|
||||||
|
|||||||
@@ -1,5 +1,87 @@
|
|||||||
# Changelog - FlowDeck
|
# Changelog - FlowDeck
|
||||||
|
|
||||||
|
## v7.66.0 (2026-10-08) — Escape du menu slash retire le « / » + bouton « commenter » à droite du bloc
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Bouton « commenter ce bloc » à droite du bloc** (`.block-comment-btn`,
|
||||||
|
gouttière 64px, visible au survol/focus comme le handle) : icône **bulle de
|
||||||
|
conversation avec un +**, `onclick="E.commentBlock(idx)"` → tiroir de
|
||||||
|
commentaires ancré sur le bloc (ou sur la sélection vivante s'il y en a une).
|
||||||
|
Icône = tracé `message-square` (Feather) + `+` centré.
|
||||||
|
Masqué sur mobile (2 breakpoints, comme le handle) et sur page verrouillée.
|
||||||
|
- remplace l'ancien `.block-actions` de `renderBlock` : rendu en `opacity:0`
|
||||||
|
**sans règle de survol** (jamais de classe `.visible`) → un `+` invisible
|
||||||
|
qui capait les clics dans la gouttière droite.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Escape ferme le menu slash ET retire le « / »** : nouvelle `SM.escClose()`
|
||||||
|
appelée par les **deux** chemins de fermeture (focus dans le bloc → `onKd`,
|
||||||
|
focus dans l'entrée du menu → listener `document`). Ne touche au caractère
|
||||||
|
que si c'est le `/` d'ouverture, remet le caret et sauvegarde (`sync` +
|
||||||
|
`autoSave`).
|
||||||
|
- **`fmtComment()` restaure la sélection de la toolbar** (`_fmtRestore()`)
|
||||||
|
avant d'ancrer : `commentBlock()` ne se replie plus sur `this._sel` (plage
|
||||||
|
potentiellement périmée reprise par le bouton de droite / le menu de bloc).
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- **e2e** `e2e/v766_slash_esc_comment_btn.spec.js` : « / » → menu visible →
|
||||||
|
Escape → menu fermé et bloc vide ; bouton présent, `opacity` 0 → 1 au
|
||||||
|
survol, **à droite** du wrapper (boîte mesurée), clic → tiroir → ancre = le
|
||||||
|
bloc entier.
|
||||||
|
- `tests/test_v766_slash_esc_comment_btn.py` (3) ; `pytest tests/ -q -n auto`
|
||||||
|
→ **1386 passed**, `ruff check app tests` → OK.
|
||||||
|
|
||||||
|
|
||||||
|
## v7.65.0 (2026-10-08) — Comment ancré sur la SÉLECTION de texte (pas le bloc) + retrait du bouton bleu « 💬 Comment »
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`commentBlock()` ancre sur la sélection de texte** : la plage vivante dans le
|
||||||
|
bloc est utilisée d'abord (plage de la toolbar de sélection en repli, le clic
|
||||||
|
sur le bouton ayant pu la faire disparaître du DOM), sinon tout le bloc.
|
||||||
|
Les offsets passent par `_srcOffset()` → `anchor_start/anchor_end` = offsets
|
||||||
|
dans le texte source, donc l'ancre jaune ne couvre que le texte sélectionné
|
||||||
|
(`_paintAnchors` / réactions inchangés).
|
||||||
|
- **Retiré le bouton bleu flottant « 💬 Comment »** (`#_commentSelBtn`) qui
|
||||||
|
apparaissait au-dessus de la sélection : le déclencheur est le bouton 💬 de
|
||||||
|
la toolbar de sélection, le menu de bloc (💬 Comment, Ctrl+⇧+M) et le raccourci.
|
||||||
|
`showCommentBtn()`/`hideCommentBtn()` et le listener `mousedown` associés sont
|
||||||
|
supprimés.
|
||||||
|
- Fermer le tiroir (`toggleComments`) remet `_commentSel` à zéro : un brouillon
|
||||||
|
abandonné ne peut plus attacher le commentaire suivant à une ancienne plage.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- **e2e** `e2e/v764_comments_reactions.spec.js` : sélection d'une **sous-chaîne**
|
||||||
|
(`reaction a un`) → toolbar 💬 → tiroir → envoi ; assert `#_commentSelBtn`
|
||||||
|
absent et `.fd-anchor[data-cid]` = **uniquement** la plage sélectionnée
|
||||||
|
(avant **et** après reload).
|
||||||
|
- `pytest tests/ -q -n auto` → **1383 passed**, `ruff check app tests` → OK.
|
||||||
|
|
||||||
|
|
||||||
|
## v7.64.1 (2026-10-08) — Fix : Delete sur un bloc vide supprime le bloc
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **La touche Delete ne supprimait pas le bloc vide** : seul `Backspace` avait la
|
||||||
|
garde « bloc sans texte → supprimer le bloc ». La branche couvre désormais les
|
||||||
|
**deux touches** (`onKd`, une seule garde partagée) — Delete vide le bloc puis
|
||||||
|
le supprime au coup suivant, comme Backspace ; le dernier bloc de la page n'est
|
||||||
|
jamais supprimé (l'éditeur garde un paragraphe).
|
||||||
|
- Le vide est jugé avec **`gtTok(el)`** (le texte lu à la sauvegarde) et non
|
||||||
|
`textContent` : une pastille de réaction seule dans le bloc ne l'empêche plus
|
||||||
|
d'être supprimé.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- **e2e** `e2e/v7641_delete_key.spec.js` : texte → Delete (bloc vidé, pas
|
||||||
|
supprimé) → Delete (bloc supprimé, le précédent reste) → dernier bloc vide
|
||||||
|
jamais supprimé.
|
||||||
|
|
||||||
|
|
||||||
## v7.64.0 (2026-10-08) — Commentaires façon Notion : ancres jaunes, tiroir refondu, réactions sur texte
|
## v7.64.0 (2026-10-08) — Commentaires façon Notion : ancres jaunes, tiroir refondu, réactions sur texte
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+41
@@ -1459,6 +1459,47 @@ Détails livrés :
|
|||||||
rouvrir
|
rouvrir
|
||||||
- [x] **Tests** — `tests/test_v764_comments_reactions.py` (8, dont toggle API réel),
|
- [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
|
e2e `e2e/v764_comments_reactions.spec.js` ; `VERSION` + `app/main.py` = 7.64.0
|
||||||
|
- [x] **v7.64.1 — Fix Delete sur bloc vide** : la garde « texte vide → supprimer
|
||||||
|
le bloc » couvre `Backspace` **et** `Delete` (une seule branche dans `onKd`),
|
||||||
|
vide jugé via `gtTok()` (ignore les pastilles) ; e2e
|
||||||
|
`e2e/v7641_delete_key.spec.js`
|
||||||
|
|
||||||
|
### v7.65.0 — Comment ancré sur la sélection de texte ✅ (2026-10-08)
|
||||||
|
> **Objectif** : le commentaire porte sur le texte sélectionné dans le bloc,
|
||||||
|
> pas sur le bloc entier, et le bouton bleu flottant disparaît. **COMPLETED**.
|
||||||
|
|
||||||
|
- [x] **Ancrage sur la plage** — `commentBlock()` prend la sélection vivante dans
|
||||||
|
le bloc (repli : la plage mémorisée par la toolbar, qui survit au clic du
|
||||||
|
bouton), sinon tout le bloc ; offsets calculés par `_srcOffset()` →
|
||||||
|
`anchor_start/anchor_end` exacts dans le texte source
|
||||||
|
- [x] **Bouton bleu « 💬 Comment » retiré** — `#_commentSelBtn`,
|
||||||
|
`showCommentBtn()`, `hideCommentBtn()` et leur listener `mousedown` supprimés ;
|
||||||
|
déclencheurs restants = 💬 de la toolbar de sélection, menu de bloc,
|
||||||
|
Ctrl+⇧+M
|
||||||
|
- [x] **Tiroir** — fermer remet `_commentSel` à zéro (pas d'ancrage périmé)
|
||||||
|
- [x] **Tests** — e2e `e2e/v764_comments_reactions.spec.js` : sous-sélection
|
||||||
|
`reaction a un` → `.fd-anchor[data-cid]` = la plage seule, avant/après
|
||||||
|
reload, `#_commentSelBtn` absent ; `pytest` 1383 passed, `ruff` OK ;
|
||||||
|
`VERSION` + `app/main.py` = 7.65.0
|
||||||
|
|
||||||
|
### v7.66.0 — Escape du menu slash + bouton commenter à droite ✅ (2026-10-08)
|
||||||
|
> **Objectif** : Esc efface le `/` qui a ouvert le menu slash, et chaque bloc
|
||||||
|
> gagne un bouton de commentaire à droite. **COMPLETED**.
|
||||||
|
|
||||||
|
- [x] **`SM.escClose()`** — un seul implément, appelé par les deux chemins
|
||||||
|
(focus dans le bloc → `onKd`, focus dans l'entrée du menu → listener
|
||||||
|
`document`) ; retire le `/` d'ouverture seulement, remet le caret, `sync` +
|
||||||
|
`autoSave`
|
||||||
|
- [x] **Bouton à droite du bloc** — `.block-comment-btn` (bulle + `+`,
|
||||||
|
`E.commentBlock(idx)`), gouttière 64px, visible au survol, masqué mobile +
|
||||||
|
page verrouillée ; remplace l'ancien `.block-actions` mort (opacity:0 sans
|
||||||
|
survol → `+` invisible qui capait les clics)
|
||||||
|
- [x] **`fmtComment()`** restaure la plage de la toolbar avant d'ancrer ;
|
||||||
|
`commentBlock()` ne se replie plus sur `this._sel` périmé
|
||||||
|
- [x] **Tests** — e2e `e2e/v766_slash_esc_comment_btn.spec.js` (Esc → menu
|
||||||
|
fermé + bloc vide ; bouton à droite mesuré, ancre = bloc entier),
|
||||||
|
`tests/test_v766_slash_esc_comment_btn.py` (3) ; `pytest` **1386 passed**,
|
||||||
|
`ruff` OK ; `VERSION` + `app/main.py` = 7.66.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# WORKLOAD — FlowDeck Notion Clone
|
# WORKLOAD — FlowDeck Notion Clone
|
||||||
|
|
||||||
> **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 🔄
|
> **Début**: 2026-07-08 | **Version**: v7.66.0 (Escape du menu slash retire le / + bouton commenter à droite du bloc) | **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.64.0",
|
version="7.66.0",
|
||||||
docs_url="/docs",
|
docs_url="/docs",
|
||||||
redoc_url="/redoc",
|
redoc_url="/redoc",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
|||||||
@@ -578,15 +578,6 @@
|
|||||||
background:var(--bg-modal,#1f1f1f);border:1px solid var(--border,#333);border-radius:10px;
|
background:var(--bg-modal,#1f1f1f);border:1px solid var(--border,#333);border-radius:10px;
|
||||||
box-shadow:var(--shadow-modal);padding:6px;"></div>
|
box-shadow:var(--shadow-modal);padding:6px;"></div>
|
||||||
|
|
||||||
<!-- ═══════════ Inline comment trigger on selection (v4.9.0) ═══════════ -->
|
|
||||||
<button type="button" id="_commentSelBtn"
|
|
||||||
style="display:none;position:fixed;z-index:1100;padding:7px 12px;font-size:13px;font-weight:600;
|
|
||||||
color:#fff;background:var(--accent,#2383E2);border:none;border-radius:8px;cursor:pointer;
|
|
||||||
box-shadow:0 4px 16px rgba(0,0,0,.35);" title="Comment on selection"
|
|
||||||
@click="edCall('commentOnSelection')">
|
|
||||||
💬 Comment
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- ═══════════ Comments drawer (v4.9.0 · refonte v7.64 façon Notion) ═══════════ -->
|
<!-- ═══════════ Comments drawer (v4.9.0 · refonte v7.64 façon Notion) ═══════════ -->
|
||||||
<div class="comments-drawer" x-show="commentsOpen" x-cloak x-transition
|
<div class="comments-drawer" x-show="commentsOpen" x-cloak x-transition
|
||||||
style="position:fixed;top:var(--topbar-height,44px);right:0;bottom:0;width:340px;max-width:92vw;
|
style="position:fixed;top:var(--topbar-height,44px);right:0;bottom:0;width:340px;max-width:92vw;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "FlowDeck",
|
"title": "FlowDeck",
|
||||||
"version": "7.64.0"
|
"version": "7.66.0"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/auth/register": {
|
"/auth/register": {
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v7.64.1 — Delete sur un bloc vide supprime le bloc (comme Backspace).
|
||||||
|
* 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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Delete : vide le bloc puis supprime le bloc vide', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
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=Delete key ${Date.now()}`,
|
||||||
|
{ headers: { 'X-CSRF-Token': csrf } }
|
||||||
|
);
|
||||||
|
expect(created.status(), await created.text()).toBeLessThan(400);
|
||||||
|
const { id } = await created.json();
|
||||||
|
const bs = await page.request.post(`${FD_BASE}/board/api/pages/${id}/blocks`, {
|
||||||
|
headers: { 'X-CSRF-Token': csrf },
|
||||||
|
data: { title: 'Delete key', blocks: [
|
||||||
|
{ id: 'b1', type: 'paragraph', content: 'hello' },
|
||||||
|
{ id: 'b2', type: 'paragraph', content: 'world' },
|
||||||
|
] },
|
||||||
|
});
|
||||||
|
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 });
|
||||||
|
|
||||||
|
// ── 1. Delete avec du texte : suppression avant le curseur (comportement natif) ──
|
||||||
|
await page.locator('#_blocksCt [data-bid]').nth(1).click();
|
||||||
|
await page.keyboard.press('Control+a');
|
||||||
|
await page.keyboard.press('Delete');
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
const contents = await page.evaluate(() => {
|
||||||
|
window.E.sync();
|
||||||
|
return window.E.blocks.map((b) => b.content);
|
||||||
|
});
|
||||||
|
expect(contents[0]).toBe('hello');
|
||||||
|
expect(contents[1].trim()).toBe(''); // bloc vidé (un <br> résiduel), mais toujours là
|
||||||
|
|
||||||
|
// ── 2. Delete sur le bloc vide : le bloc est supprimé ──
|
||||||
|
await page.keyboard.press('Delete');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
expect(await page.evaluate(() => window.E.blocks.length)).toBe(1);
|
||||||
|
expect(await page.evaluate(() => window.E.blocks[0].content)).toBe('hello');
|
||||||
|
await page.screenshot({ path: 'shots/v7641-delete.png' });
|
||||||
|
|
||||||
|
// ── 3. dernier bloc vide : jamais supprimé (l'éditeur garde un bloc) ──
|
||||||
|
await page.locator('#_blocksCt [data-bid]').first().click();
|
||||||
|
await page.keyboard.press('Control+a');
|
||||||
|
await page.keyboard.press('Delete');
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
await page.keyboard.press('Delete');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
expect(await page.evaluate(() => window.E.blocks.length)).toBe(1);
|
||||||
|
expect(await page.evaluate(() => window.E.blocks[0].type)).toBe('paragraph');
|
||||||
|
});
|
||||||
@@ -54,6 +54,30 @@ async function newPage(page, body) {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sélectionne une SOUS-chaîne du bloc (ancre = plage, pas le bloc entier)
|
||||||
|
async function selectSub(page, sub) {
|
||||||
|
await page.evaluate((sub) => {
|
||||||
|
const el = document.querySelector('#_blocksCt [data-bid]');
|
||||||
|
const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||||
|
let n;
|
||||||
|
while ((n = w.nextNode())) {
|
||||||
|
if (n.parentElement && n.parentElement.closest('.fd-react-chip')) continue;
|
||||||
|
const i = n.nodeValue.indexOf(sub);
|
||||||
|
if (i < 0) continue;
|
||||||
|
const r = document.createRange();
|
||||||
|
r.setStart(n, i);
|
||||||
|
r.setEnd(n, i + sub.length);
|
||||||
|
const s = getSelection();
|
||||||
|
s.removeAllRanges();
|
||||||
|
s.addRange(r);
|
||||||
|
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error('sous-chaîne introuvable : ' + sub);
|
||||||
|
}, sub);
|
||||||
|
await expect(page.locator('.format-toolbar')).toBeVisible({ timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
// sélectionne tout le bloc et simule la fin du glisser-souris
|
// sélectionne tout le bloc et simule la fin du glisser-souris
|
||||||
async function selectBlock(page) {
|
async function selectBlock(page) {
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
@@ -100,10 +124,10 @@ test('réaction emoji + commentaires ancrés', async ({ page }) => {
|
|||||||
expect(content).toBe('Prise de reaction a un texte'); // la pastille n'est pas du contenu
|
expect(content).toBe('Prise de reaction a un texte'); // la pastille n'est pas du contenu
|
||||||
await page.screenshot({ path: 'shots/v764-chip.png' });
|
await page.screenshot({ path: 'shots/v764-chip.png' });
|
||||||
|
|
||||||
// ── 4. commentaire : bouton 💬 → tiroir refondu → envoi ──
|
// ── 4. commentaire sur une SOUS-sélection : toolbar 💬 → tiroir refondu → envoi ──
|
||||||
await selectBlock(page);
|
await expect(page.locator('#_commentSelBtn')).toHaveCount(0); // bouton bleu retiré
|
||||||
await expect(page.locator('#_commentSelBtn')).toBeVisible();
|
await selectSub(page, 'reaction a un');
|
||||||
await page.locator('#_commentSelBtn').click();
|
await page.locator('.format-toolbar .ft-item[data-tooltip="Comment — Ctrl+⇧+M"]').click();
|
||||||
const drawer = page.locator('.comments-drawer');
|
const drawer = page.locator('.comments-drawer');
|
||||||
await expect(drawer).toBeVisible();
|
await expect(drawer).toBeVisible();
|
||||||
await drawer.locator('textarea').fill('Ceci est mon premier commentaire');
|
await drawer.locator('textarea').fill('Ceci est mon premier commentaire');
|
||||||
@@ -115,7 +139,9 @@ test('réaction emoji + commentaires ancrés', async ({ page }) => {
|
|||||||
await expect(thread.locator('.ct-name')).not.toHaveText('');
|
await expect(thread.locator('.ct-name')).not.toHaveText('');
|
||||||
await expect(thread.locator('.ct-act')).toHaveCount(2); // ✓ + ⋯
|
await expect(thread.locator('.ct-act')).toHaveCount(2); // ✓ + ⋯
|
||||||
await expect(thread.locator('.ct-body')).toHaveText('Ceci est mon premier commentaire');
|
await expect(thread.locator('.ct-body')).toHaveText('Ceci est mon premier commentaire');
|
||||||
|
// l'ancre jaune = UNIQUEMENT la plage sélectionnée, pas le bloc
|
||||||
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveCount(1);
|
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveCount(1);
|
||||||
|
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveText('reaction a un');
|
||||||
await page.screenshot({ path: 'shots/v764-drawer.png' });
|
await page.screenshot({ path: 'shots/v764-drawer.png' });
|
||||||
|
|
||||||
// ── 5. menu ⋯ du fil (Copy link / Delete) ──
|
// ── 5. menu ⋯ du fil (Copy link / Delete) ──
|
||||||
@@ -140,5 +166,6 @@ test('réaction emoji + commentaires ancrés', async ({ page }) => {
|
|||||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
|
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-anchor[data-cid]')).toHaveCount(1, { timeout: 10000 });
|
||||||
|
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveText('reaction a un');
|
||||||
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1, { timeout: 10000 });
|
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1, { timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v7.66 — deux ajustements éditeur :
|
||||||
|
* - « / » ouvre le menu slash → Escape ferme le menu ET retire le « / »
|
||||||
|
* - survol d'un bloc → bouton à droite (bulle + ) qui commente le bloc
|
||||||
|
* Instance de test attendue sur FD_BASE_URL (défaut 8081).
|
||||||
|
*/
|
||||||
|
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
|
||||||
|
const USER = process.env.FD_USER || '[email protected]';
|
||||||
|
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||||
|
|
||||||
|
test.use({ serviceWorkers: 'block' });
|
||||||
|
|
||||||
|
async function login(page) {
|
||||||
|
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.fill('#email', USER);
|
||||||
|
await page.fill('#password', PASS);
|
||||||
|
await page.click('.btn-primary');
|
||||||
|
const ok = await page
|
||||||
|
.waitForURL('**/workspaces', { timeout: 8000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
if (!ok) {
|
||||||
|
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
|
||||||
|
data: { email: USER, password: PASS, name: 'E2E' },
|
||||||
|
});
|
||||||
|
if (resp.status() !== 409) expect(resp.ok()).toBeTruthy();
|
||||||
|
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newPage(page, body) {
|
||||||
|
const cookies = await page.context().cookies();
|
||||||
|
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
|
||||||
|
const created = await page.request.post(
|
||||||
|
`${FD_BASE}/board/api/pages?title=v766 ${Date.now()}`,
|
||||||
|
{ headers: { 'X-CSRF-Token': csrf } }
|
||||||
|
);
|
||||||
|
expect(created.status(), await created.text()).toBeLessThan(400);
|
||||||
|
const { id } = await created.json();
|
||||||
|
if (body) {
|
||||||
|
const bs = await page.request.post(`${FD_BASE}/board/api/pages/${id}/blocks`, {
|
||||||
|
headers: { 'X-CSRF-Token': csrf },
|
||||||
|
data: { title: 'v766', blocks: body },
|
||||||
|
});
|
||||||
|
expect(bs.status(), await bs.text()).toBeLessThan(400);
|
||||||
|
}
|
||||||
|
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Escape du menu slash retire le caractère /', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
await newPage(page, [{ id: 'b1', type: 'paragraph', content: '' }]);
|
||||||
|
const blk = page.locator('#_blocksCt [data-bid]').first();
|
||||||
|
await blk.click();
|
||||||
|
await page.keyboard.type('/');
|
||||||
|
await expect(page.locator('#_slashMenu')).toBeVisible({ timeout: 5000 });
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(page.locator('#_slashMenu')).toBeHidden();
|
||||||
|
// le « / » a été retiré — pas de caractère résiduel
|
||||||
|
await expect(blk).toHaveText('');
|
||||||
|
await page.screenshot({ path: 'shots/v766-slash-esc.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bouton commenter à droite du bloc', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
// 2 blocs : l'éditeur focalise le 1er au chargement (`:focus-within` le
|
||||||
|
// montre) → on mesure sur le 2e, ni focalisé ni survolé.
|
||||||
|
await newPage(page, [
|
||||||
|
{ id: 'b1', type: 'paragraph', content: 'Premier bloc' },
|
||||||
|
{ id: 'b2', type: 'paragraph', content: 'Un bloc a commenter' },
|
||||||
|
]);
|
||||||
|
await expect(page.locator('#_blocksCt .block-comment-btn')).toHaveCount(2);
|
||||||
|
const wrap = page.locator('#_blocksCt .block-wrapper').nth(1);
|
||||||
|
const btn = page.locator('#_blocksCt .block-comment-btn').nth(1);
|
||||||
|
|
||||||
|
// masqué tant qu'on ne survole pas / ne focalise pas le bloc
|
||||||
|
await expect.poll(() => btn.evaluate((el) => getComputedStyle(el).opacity)).toBe('0');
|
||||||
|
await wrap.hover();
|
||||||
|
await expect.poll(() => btn.evaluate((el) => getComputedStyle(el).opacity)).toBe('1');
|
||||||
|
|
||||||
|
// bien à droite du bloc (gouttière 64px)
|
||||||
|
const wb = await wrap.boundingBox();
|
||||||
|
const bb = await btn.boundingBox();
|
||||||
|
expect(bb.x).toBeGreaterThan(wb.x + wb.width - 1);
|
||||||
|
await page.screenshot({ path: 'shots/v766-comment-btn.png' });
|
||||||
|
|
||||||
|
// clic → tiroir → envoi → ancre = le bloc visé (et pas le 1er)
|
||||||
|
await btn.click();
|
||||||
|
const drawer = page.locator('.comments-drawer');
|
||||||
|
await expect(drawer).toBeVisible();
|
||||||
|
await drawer.locator('textarea').fill('Commentaire via le bouton de droite');
|
||||||
|
await drawer.locator('.ct-send').click();
|
||||||
|
await expect(page.locator('.comment-thread')).toHaveCount(1);
|
||||||
|
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveText('Un bloc a commenter');
|
||||||
|
});
|
||||||
@@ -2371,6 +2371,35 @@ button.breadcrumb-current { max-width: 260px; }
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── v7.66 : bouton « commenter ce bloc » — à droite du bloc (gouttière 64px),
|
||||||
|
bulle de conversation + , visible au survol comme le handle ── */
|
||||||
|
.block-comment-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: -60px;
|
||||||
|
top: 4px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.block-wrapper:hover .block-comment-btn,
|
||||||
|
.block-wrapper:focus-within .block-comment-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.block-comment-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── v5.10.0: selection multiple + menu contexte bloc ── */
|
/* ── v5.10.0: selection multiple + menu contexte bloc ── */
|
||||||
.block-wrapper.selected {
|
.block-wrapper.selected {
|
||||||
background: rgba(35,131,226,0.06);
|
background: rgba(35,131,226,0.06);
|
||||||
@@ -3288,6 +3317,7 @@ button.breadcrumb-current { max-width: 260px; }
|
|||||||
.block-handle { display: none; }
|
.block-handle { display: none; }
|
||||||
.block-insert-btn { display: none; }
|
.block-insert-btn { display: none; }
|
||||||
.block-actions { display: none; }
|
.block-actions { display: none; }
|
||||||
|
.block-comment-btn { display: none; }
|
||||||
.editor-statusbar { left: 0; }
|
.editor-statusbar { left: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3855,6 +3885,7 @@ button.breadcrumb-current { max-width: 260px; }
|
|||||||
.block-handle { display: none; }
|
.block-handle { display: none; }
|
||||||
.block-insert-btn { display: none; }
|
.block-insert-btn { display: none; }
|
||||||
.block-actions { display: none; }
|
.block-actions { display: none; }
|
||||||
|
.block-comment-btn { display: none; }
|
||||||
.page-title-block::before { display: none; }
|
.page-title-block::before { display: none; }
|
||||||
.page-title-block .page-icon-emoji { margin-left: 0; }
|
.page-title-block .page-icon-emoji { margin-left: 0; }
|
||||||
.page-title-block .page-title-input { padding-left: 0; }
|
.page-title-block .page-title-input { padding-left: 0; }
|
||||||
@@ -4998,6 +5029,7 @@ a.fd-wiki-link:hover { border-bottom-color: var(--accent, #2383e2); }
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
body.page-locked .block-handle,
|
body.page-locked .block-handle,
|
||||||
|
body.page-locked .block-comment-btn,
|
||||||
body.page-locked .block-add-btn,
|
body.page-locked .block-add-btn,
|
||||||
body.page-locked .format-toolbar,
|
body.page-locked .format-toolbar,
|
||||||
body.page-locked .get-started-toolbar { display: none !important; }
|
body.page-locked .get-started-toolbar { display: none !important; }
|
||||||
|
|||||||
@@ -463,6 +463,27 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
|||||||
SM._e.style.left=Math.min(r.left,window.innerWidth-340)+'px';
|
SM._e.style.left=Math.min(r.left,window.innerWidth-340)+'px';
|
||||||
SM._rs();setTimeout(()=>SM._in.focus(),10);SM._o=true;},
|
SM._rs();setTimeout(()=>SM._in.focus(),10);SM._o=true;},
|
||||||
close(){if(SM._e)SM._e.style.display='none';SM._o=false;SM._in&&(SM._in.value='');if(SM._pv)SM._pv.style.display='none';MTB.close();},
|
close(){if(SM._e)SM._e.style.display='none';SM._o=false;SM._in&&(SM._in.value='');if(SM._pv)SM._pv.style.display='none';MTB.close();},
|
||||||
|
// v7.66 : Escape ferme le menu ET retire le « / » qui l'a ouvert.
|
||||||
|
escClose(){
|
||||||
|
const i=SM._i;SM.close();
|
||||||
|
const E=window.E;if(!E||i<0||i>=E.blocks.length)return;
|
||||||
|
const el=E.getEl(E.blocks[i].id);if(!el)return;
|
||||||
|
const w=document.createTreeWalker(el,NodeFilter.SHOW_TEXT);let n;
|
||||||
|
while((n=w.nextNode())){
|
||||||
|
if(n.parentElement&&n.parentElement.closest('.fd-react-chip'))continue;
|
||||||
|
const v=n.nodeValue,at=v.indexOf('/');
|
||||||
|
if(at>=0){
|
||||||
|
if(v.slice(0,at).trim())return; // pas le / d'ouverture → on ne touche à rien
|
||||||
|
n.nodeValue=v.slice(0,at)+v.slice(at+1);
|
||||||
|
E.sync();E.dirty=true;E.autoSave();
|
||||||
|
el.focus();
|
||||||
|
const r=document.createRange();r.setStart(n,Math.min(at,n.nodeValue.length));r.collapse(true);
|
||||||
|
const s=window.getSelection();s.removeAllRanges();s.addRange(r);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(v.trim())return; // 1er texte non vide sans / → rien à retirer
|
||||||
|
}
|
||||||
|
},
|
||||||
// Ligne d'item du slash (partagée par la popup desktop et la feuille mobile)
|
// Ligne d'item du slash (partagée par la popup desktop et la feuille mobile)
|
||||||
_slashItemHtml(c){return `<div class="slash-item" data-sid="${c.id}" style="display:flex;align-items:center;gap:10px;padding:7px 10px;font-size:14px;color:var(--text-primary);cursor:pointer;border-radius:5px">
|
_slashItemHtml(c){return `<div class="slash-item" data-sid="${c.id}" style="display:flex;align-items:center;gap:10px;padding:7px 10px;font-size:14px;color:var(--text-primary);cursor:pointer;border-radius:5px">
|
||||||
<span class="slash-item-icon" style="width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-size:15px;background:var(--bg-secondary);border-radius:4px;flex-shrink:0">${c.icon}</span>
|
<span class="slash-item-icon" style="width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-size:15px;background:var(--bg-secondary);border-radius:4px;flex-shrink:0">${c.icon}</span>
|
||||||
@@ -476,7 +497,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
|||||||
_kd(e){if(e.key==='ArrowDown'){e.preventDefault();SM._s=Math.min(SM._s+1,SM._it.length-1);SM._rs();}
|
_kd(e){if(e.key==='ArrowDown'){e.preventDefault();SM._s=Math.min(SM._s+1,SM._it.length-1);SM._rs();}
|
||||||
else if(e.key==='ArrowUp'){e.preventDefault();SM._s=Math.max(SM._s-1,0);SM._rs();}
|
else if(e.key==='ArrowUp'){e.preventDefault();SM._s=Math.max(SM._s-1,0);SM._rs();}
|
||||||
else if(e.key==='Enter'){e.preventDefault();const c=SM._it[SM._s];if(c)SM.sel(c.id);}
|
else if(e.key==='Enter'){e.preventDefault();const c=SM._it[SM._s];if(c)SM.sel(c.id);}
|
||||||
else if(e.key==='Escape'){e.preventDefault();SM.close();}},
|
else if(e.key==='Escape'){e.preventDefault();SM.escClose();}},
|
||||||
_rs(){const its=SM._li.querySelectorAll('.slash-item');its.forEach((el,i)=>{el.classList.toggle('selected',i===SM._s);});const cur=its[SM._s];if(cur&&cur.scrollIntoView)cur.scrollIntoView({block:'nearest'});},
|
_rs(){const its=SM._li.querySelectorAll('.slash-item');its.forEach((el,i)=>{el.classList.toggle('selected',i===SM._s);});const cur=its[SM._s];if(cur&&cur.scrollIntoView)cur.scrollIntoView({block:'nearest'});},
|
||||||
sel(id){if(window.E)window.E.applySlash(id);SM.close();},
|
sel(id){if(window.E)window.E.applySlash(id);SM.close();},
|
||||||
get isOpen(){return SM._o;}};
|
get isOpen(){return SM._o;}};
|
||||||
@@ -789,14 +810,17 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
|||||||
let s=idx; while(s>0&&bs[s-1]&&bs[s-1].type==='numbered_list')s--;
|
let s=idx; while(s>0&&bs[s-1]&&bs[s-1].type==='numbered_list')s--;
|
||||||
numAttr=` data-num="${idx-s+1}"`;
|
numAttr=` data-num="${idx-s+1}"`;
|
||||||
}
|
}
|
||||||
const actions=idx>=0?`<div class="block-actions"><button class="block-add-btn" onclick="E.addAfter(${idx})">+</button></div>`:'';
|
// v7.66 : bouton « commenter ce bloc » à droite du bloc (gouttière 64px),
|
||||||
|
// icône bulle de conversation + . Remplace l'ancien .block-actions mort
|
||||||
|
// (opacity:0 sans règle de survol → un + invisible qui capait les clics).
|
||||||
|
const cmtBtn=idx>=0?`<button type="button" class="block-comment-btn" title="Comment on block" aria-label="Comment on block" onclick="E.commentBlock(${idx})"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><path d="M12 8v5M9.5 10.5h5"/></svg></button>`:'';
|
||||||
// + « ajouter un bloc dessous », juste avant le handle (gouttière 64px)
|
// + « ajouter un bloc dessous », juste avant le handle (gouttière 64px)
|
||||||
const insertBtn=idx>=0?`<button type="button" class="block-insert-btn" title="Add block below" aria-label="Add block below" onclick="E.addAfter(${idx})">+</button>`:'';
|
const insertBtn=idx>=0?`<button type="button" class="block-insert-btn" title="Add block below" aria-label="Add block below" onclick="E.addAfter(${idx})">+</button>`:'';
|
||||||
return `<div class="block-wrapper" data-id="${b.id}">
|
return `<div class="block-wrapper" data-id="${b.id}">
|
||||||
${insertBtn}
|
${insertBtn}
|
||||||
<div class="block-handle"><svg viewBox="0 0 16 16" width="14" height="14"><circle cx="4" cy="3" r="1.3"/><circle cx="11" cy="3" r="1.3"/><circle cx="4" cy="8" r="1.3"/><circle cx="11" cy="8" r="1.3"/><circle cx="4" cy="13" r="1.3"/><circle cx="11" cy="13" r="1.3"/></svg></div>
|
<div class="block-handle"><svg viewBox="0 0 16 16" width="14" height="14"><circle cx="4" cy="3" r="1.3"/><circle cx="11" cy="3" r="1.3"/><circle cx="4" cy="8" r="1.3"/><circle cx="11" cy="8" r="1.3"/><circle cx="4" cy="13" r="1.3"/><circle cx="11" cy="13" r="1.3"/></svg></div>
|
||||||
${actions}
|
|
||||||
<${tag}${style}${bidAttr}${numAttr} class="${cls}">${inner}</${tag}>
|
<${tag}${style}${bidAttr}${numAttr} class="${cls}">${inner}</${tag}>
|
||||||
|
${cmtBtn}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1865,11 +1889,21 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
|||||||
setBlockColor(idx,key,val){if(idx<0||idx>=this.blocks.length)return;const targets=this._blockTargets(idx);const v=(!val||val==='default')?null:val;this.sync();this.pushHistory();targets.forEach(i=>{const b=this.blocks[i];if(!b)return;if(!b.style)b.style={};if(v==null)delete b.style[key];else b.style[key]=v;});if(v!=null){try{localStorage.setItem('fdLastBlockColor',key+'|'+v);}catch(e){}}this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();},
|
setBlockColor(idx,key,val){if(idx<0||idx>=this.blocks.length)return;const targets=this._blockTargets(idx);const v=(!val||val==='default')?null:val;this.sync();this.pushHistory();targets.forEach(i=>{const b=this.blocks[i];if(!b)return;if(!b.style)b.style={};if(v==null)delete b.style[key];else b.style[key]=v;});if(v!=null){try{localStorage.setItem('fdLastBlockColor',key+'|'+v);}catch(e){}}this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();},
|
||||||
// ── v7.61 : actions du menu contextuel de bloc ──
|
// ── v7.61 : actions du menu contextuel de bloc ──
|
||||||
commentBlock(idx){
|
commentBlock(idx){
|
||||||
|
// L'ancre est la SÉLECTION de texte vivante dans ce bloc, sinon tout
|
||||||
|
// le bloc. La toolbar de sélection restaure sa plage AVANT (fmtComment)
|
||||||
|
// — pas de repli sur `this._sel` ici : sinon le bouton de droite / le
|
||||||
|
// menu de bloc reprendraient une sélection périmée.
|
||||||
const b=this.blocks[idx];if(!b)return;const el=this.getEl(b.id);if(!el)return;
|
const b=this.blocks[idx];if(!b)return;const el=this.getEl(b.id);if(!el)return;
|
||||||
const r=document.createRange();r.selectNodeContents(el);
|
let r=null;const s=window.getSelection();
|
||||||
const s=window.getSelection();s.removeAllRanges();s.addRange(r);
|
if(s&&s.rangeCount&&!s.isCollapsed){
|
||||||
this.showCommentBtn();
|
const rr=s.getRangeAt(0);
|
||||||
if(!this._commentSel||!this._commentSel.range){this.showToast('Sélectionnez du texte pour commenter','error');return;}
|
if(el.contains(rr.startContainer)&&el.contains(rr.endContainer))r=rr.cloneRange();
|
||||||
|
}
|
||||||
|
if(!r){r=document.createRange();r.selectNodeContents(el);}
|
||||||
|
const start=_srcOffset(el,r.startContainer,r.startOffset);
|
||||||
|
const end=_srcOffset(el,r.endContainer,r.endOffset);
|
||||||
|
if(!(end>start)){this.showToast('Sélectionnez du texte pour commenter','error');return;}
|
||||||
|
this._commentSel={range:r,bid:el.dataset.bid,start:start,end:end};
|
||||||
this.commentOnSelection();
|
this.commentOnSelection();
|
||||||
},
|
},
|
||||||
// v7.62 : renommé — « askAI() » (sans argument, plus bas) l'écrasait,
|
// v7.62 : renommé — « askAI() » (sans argument, plus bas) l'écrasait,
|
||||||
@@ -2373,7 +2407,7 @@ applyAIBlocks(text){
|
|||||||
const ab=this.getActiveBlock();if(!ab)return;
|
const ab=this.getActiveBlock();if(!ab)return;
|
||||||
const{el,bid,idx}=ab;const block=this.blocks[idx];if(!block)return;
|
const{el,bid,idx}=ab;const block=this.blocks[idx];if(!block)return;
|
||||||
if(SM.isOpen){
|
if(SM.isOpen){
|
||||||
if(e.key==='Escape'){e.preventDefault();SM.close();return;}
|
if(e.key==='Escape'){e.preventDefault();SM.escClose();return;}
|
||||||
if(e.key==='ArrowDown'||e.key==='ArrowUp'||e.key==='Enter'){e.preventDefault();return;}
|
if(e.key==='ArrowDown'||e.key==='ArrowUp'||e.key==='Enter'){e.preventDefault();return;}
|
||||||
if(e.key==='Backspace'&&!SM._q&&(el.textContent||'').trim()==='/'){e.preventDefault();SM.close();return;}
|
if(e.key==='Backspace'&&!SM._q&&(el.textContent||'').trim()==='/'){e.preventDefault();SM.close();return;}
|
||||||
// La requête peut être tapée DANS le bloc (« /numbered list ») si le
|
// La requête peut être tapée DANS le bloc (« /numbered list ») si le
|
||||||
@@ -2417,7 +2451,10 @@ applyAIBlocks(text){
|
|||||||
}
|
}
|
||||||
if(e.key==='ArrowUp'){const _sp=splitCaret(el);if(_sp&&!_sp.before.trim()&&idx>0){e.preventDefault();const prev=this.getEl(this.blocks[idx-1]?.id);if(prev){prev.focus();ce(prev);}}return;}
|
if(e.key==='ArrowUp'){const _sp=splitCaret(el);if(_sp&&!_sp.before.trim()&&idx>0){e.preventDefault();const prev=this.getEl(this.blocks[idx-1]?.id);if(prev){prev.focus();ce(prev);}}return;}
|
||||||
if(e.key==='ArrowDown'){const _sp=splitCaret(el);if(_sp&&!_sp.after.trim()&&idx<this.blocks.length-1){e.preventDefault();const next=this.getEl(this.blocks[idx+1]?.id);if(next){next.focus();cs(next);}}return;}
|
if(e.key==='ArrowDown'){const _sp=splitCaret(el);if(_sp&&!_sp.after.trim()&&idx<this.blocks.length-1){e.preventDefault();const next=this.getEl(this.blocks[idx+1]?.id);if(next){next.focus();cs(next);}}return;}
|
||||||
if(e.key==='Backspace'){if(!(el.textContent||'').trim()){this.sync();if(block.type!=='paragraph'){e.preventDefault();this.pushHistory();block.type='paragraph';block.content='';this.render();_rtSync();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}else if(this.blocks.length>1){e.preventDefault();this.removeBlock(idx);}}return;}
|
// Backspace / Delete sur un bloc vide → supprimer le bloc (même garde
|
||||||
|
// pour les deux touches ; le texte restant est lu comme à la sauvegarde
|
||||||
|
// pour ignorer les annotations .fd-react-chip)
|
||||||
|
if(e.key==='Backspace'||e.key==='Delete'){if(!(gtTok(el)||'').trim()){this.sync();if(block.type!=='paragraph'){e.preventDefault();this.pushHistory();block.type='paragraph';block.content='';this.render();_rtSync();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}else if(this.blocks.length>1){e.preventDefault();this.removeBlock(idx);}}return;}
|
||||||
if(e.key===' '){const i3=idx;setTimeout(()=>this.checkMd(i3),15);}
|
if(e.key===' '){const i3=idx;setTimeout(()=>this.checkMd(i3),15);}
|
||||||
if((e.ctrlKey||e.metaKey)&&!e.altKey){
|
if((e.ctrlKey||e.metaKey)&&!e.altKey){
|
||||||
if(e.key.toLowerCase()==='z'){e.preventDefault();if(e.shiftKey)this.redo();else this.undo();return;}
|
if(e.key.toLowerCase()==='z'){e.preventDefault();if(e.shiftKey)this.redo();else this.undo();return;}
|
||||||
@@ -2578,7 +2615,7 @@ applyAIBlocks(text){
|
|||||||
// « … » → le menu contextuel de bloc (recherche + Text + pied métadonnées)
|
// « … » → le menu contextuel de bloc (recherche + Text + pied métadonnées)
|
||||||
fmtMore(ev){const bid=this._fmtBid();if(!bid)return;const r=ev.currentTarget.getBoundingClientRect();
|
fmtMore(ev){const bid=this._fmtBid();if(!bid)return;const r=ev.currentTarget.getBoundingClientRect();
|
||||||
this.fmt.open=false;blockMenuOpen(Math.max(8,Math.min(r.left-190,window.innerWidth-250)),r.bottom+6,bid);},
|
this.fmt.open=false;blockMenuOpen(Math.max(8,Math.min(r.left-190,window.innerWidth-250)),r.bottom+6,bid);},
|
||||||
fmtComment(){this.fmt.open=false;const i=this.fmt.idx;if(i>=0)this.commentBlock(i);},
|
fmtComment(){this.fmt.open=false;const i=this.fmt.idx;if(i<0)return;this._fmtRestore();this.commentBlock(i);},
|
||||||
fmtAskAI(){this.fmt.open=false;const i=this.fmt.idx;if(i>=0)this.askAIBlock(i);},
|
fmtAskAI(){this.fmt.open=false;const i=this.fmt.idx;if(i>=0)this.askAIBlock(i);},
|
||||||
fmtEditAI(){this.fmtAskAI();},
|
fmtEditAI(){this.fmtAskAI();},
|
||||||
fmtSkill(tpl){this.fmt.open=false;const i=this.fmt.idx;if(i>=0)this.runSkill(i,tpl);},
|
fmtSkill(tpl){this.fmt.open=false;const i=this.fmt.idx;if(i>=0)this.runSkill(i,tpl);},
|
||||||
@@ -2604,7 +2641,7 @@ applyAIBlocks(text){
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
// ── v4.9.0: Collaboration — comments & mentions ──
|
// ── v4.9.0: Collaboration — comments & mentions ──
|
||||||
toggleComments(){this.commentsOpen=!this.commentsOpen;if(this.commentsOpen)this.loadComments();},
|
toggleComments(){if(this.commentsOpen){this.commentsOpen=false;this._commentSel={range:null,bid:null,start:0,end:0};return;}this.commentsOpen=true;this.loadComments();},
|
||||||
async loadComments(){
|
async loadComments(){
|
||||||
try{const r=await fetch('/api/pages/'+this.pid+'/comments',{credentials:'same-origin'});const d=await r.json();
|
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;}
|
this.comments=d.comments||[];this.commentCount=this.comments.length;}
|
||||||
@@ -2627,7 +2664,7 @@ applyAIBlocks(text){
|
|||||||
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},
|
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},
|
||||||
body:JSON.stringify({body:text,anchor_block_id:sel.bid||null,anchor_start:sel.bid?sel.start:null,anchor_end:sel.bid?sel.end:null})});
|
body:JSON.stringify({body:text,anchor_block_id:sel.bid||null,anchor_start:sel.bid?sel.start:null,anchor_end:sel.bid?sel.end:null})});
|
||||||
const d=await r.json();
|
const d=await r.json();
|
||||||
if(d.status==='created'){this.commentDraft='';this._commentSel={range:null,bid:null,start:0,end:0};this.hideCommentBtn();await this.loadComments();this.showToast('Comment added');}
|
if(d.status==='created'){this.commentDraft='';this._commentSel={range:null,bid:null,start:0,end:0};await this.loadComments();this.showToast('Comment added');}
|
||||||
else{this.showToast(d.detail||'Failed to add comment');}
|
else{this.showToast(d.detail||'Failed to add comment');}
|
||||||
}catch(e){this.showToast('Failed to add comment');}
|
}catch(e){this.showToast('Failed to add comment');}
|
||||||
},
|
},
|
||||||
@@ -2721,32 +2758,9 @@ applyAIBlocks(text){
|
|||||||
},
|
},
|
||||||
commentOnSelection(){
|
commentOnSelection(){
|
||||||
const sel=this._commentSel;if(!sel.range)return;
|
const sel=this._commentSel;if(!sel.range)return;
|
||||||
const bid=sel.bid||'';this.commentsOpen=true;this.commentDraft='';
|
this.commentsOpen=true;this.commentDraft='';
|
||||||
const btn=document.getElementById('_commentSelBtn');if(btn)btn.style.display='none';
|
|
||||||
this.$nextTick(()=>{const ta=document.querySelector('.comments-drawer textarea');if(ta)ta.focus();});
|
this.$nextTick(()=>{const ta=document.querySelector('.comments-drawer textarea');if(ta)ta.focus();});
|
||||||
},
|
},
|
||||||
showCommentBtn(){
|
|
||||||
const s=window.getSelection();if(!s||s.isCollapsed)return;
|
|
||||||
const ct=document.getElementById('_blocksCt');if(!ct||!ct.contains(s.anchorNode))return;
|
|
||||||
// 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 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();
|
|
||||||
const btn=document.getElementById('_commentSelBtn');if(!btn)return;
|
|
||||||
btn.style.display='block';
|
|
||||||
const bw=btn.offsetWidth||110,bh=btn.offsetHeight||34;
|
|
||||||
let x=r.left+r.width/2-bw/2,y=r.top-bh-8;
|
|
||||||
x=Math.min(Math.max(8,x),window.innerWidth-bw-8);if(y<8)y=r.bottom+8;
|
|
||||||
btn.style.left=x+'px';btn.style.top=y+'px';
|
|
||||||
},
|
|
||||||
hideCommentBtn(){const btn=document.getElementById('_commentSelBtn');if(btn)btn.style.display='none';},
|
|
||||||
// ── @mention autocomplete (v4.9.0 users + v5.11.0 pages & dates) ──
|
// ── @mention autocomplete (v4.9.0 users + v5.11.0 pages & dates) ──
|
||||||
mentionOpen:false,mentionQuery:'',mentionResults:[],mentionPages:[],mentionEl:null,
|
mentionOpen:false,mentionQuery:'',mentionResults:[],mentionPages:[],mentionEl:null,
|
||||||
openMention(el){
|
openMention(el){
|
||||||
@@ -3071,7 +3085,7 @@ applyAIBlocks(text){
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
// v7.62 : data Alpine du wrapper éditeur — Alpine v3 n'expose plus __x
|
// v7.62 : data Alpine du wrapper éditeur — Alpine v3 n'expose plus __x
|
||||||
// (l'ancien accès rendait muets showFmt/hideCommentBtn/mentions).
|
// (l'ancien accès rendait muets showFmt/mentions).
|
||||||
function _xed(el){ if(!el) return null; if(el.__x&&el.__x.$data) return el.__x.$data;
|
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; }
|
return (el._x_dataStack&&el._x_dataStack[0])||null; }
|
||||||
// v7.64 : clic sur une ancre jaune → ouvre et centre le fil de discussion ;
|
// v7.64 : clic sur une ancre jaune → ouvre et centre le fil de discussion ;
|
||||||
@@ -3111,9 +3125,8 @@ applyAIBlocks(text){
|
|||||||
if(!ct?.contains(s.anchorNode)){d.fmt.open=false;d._sel=null;return;}
|
if(!ct?.contains(s.anchorNode)){d.fmt.open=false;d._sel=null;return;}
|
||||||
const bel0=s.anchorNode&&(s.anchorNode.nodeType===1?s.anchorNode:s.anchorNode.parentElement);
|
const bel0=s.anchorNode&&(s.anchorNode.nodeType===1?s.anchorNode:s.anchorNode.parentElement);
|
||||||
const bel=bel0?bel0.closest('[data-bid]'):null;
|
const bel=bel0?bel0.closest('[data-bid]'):null;
|
||||||
if(bel){const idx=d.getIdx(bel.dataset.bid);if(idx>=0)d.showFmt(idx);d.showCommentBtn();}else{d.hideCommentBtn();}
|
if(bel){const idx=d.getIdx(bel.dataset.bid);if(idx>=0)d.showFmt(idx);}
|
||||||
},80);});
|
},80);});
|
||||||
document.addEventListener('mousedown',()=>{setTimeout(()=>{const d=_xed(document.querySelector('.page-editor-wrapper'));if(d)d.hideCommentBtn();},10);});
|
|
||||||
document.addEventListener('keyup',(e)=>{
|
document.addEventListener('keyup',(e)=>{
|
||||||
const d=_xed(document.querySelector('.page-editor-wrapper'));if(!d)return;
|
const d=_xed(document.querySelector('.page-editor-wrapper'));if(!d)return;
|
||||||
if(!d.mentionOpen){if(e.key!=='@')return;}
|
if(!d.mentionOpen){if(e.key!=='@')return;}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""7.66.0 — Escape du menu slash retire le « / » + bouton commenter à droite du bloc."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
EDITOR_JS = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
|
||||||
|
APP_CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_slash_escape_removes_the_slash():
|
||||||
|
# une seule implémentation, appelée par les deux chemins (focus dans le bloc
|
||||||
|
# → onKd ; focus dans l'entrée du menu → listener document)
|
||||||
|
assert "escClose(){" in EDITOR_JS
|
||||||
|
assert EDITOR_JS.count("SM.escClose();") == 2
|
||||||
|
# ne retire que le « / » d'ouverture, remet le caret et sauvegarde
|
||||||
|
assert "n.nodeValue=v.slice(0,at)+v.slice(at+1);" in EDITOR_JS
|
||||||
|
assert "E.sync();E.dirty=true;E.autoSave();" in EDITOR_JS
|
||||||
|
|
||||||
|
|
||||||
|
def test_comment_button_on_the_right_of_the_block():
|
||||||
|
# émis APRÈS le contenu du bloc → à droite dans le flex du wrapper
|
||||||
|
i_tpl = EDITOR_JS.index("const cmtBtn=")
|
||||||
|
i_content = EDITOR_JS.index('class="${cls}">${inner}</${tag}>', i_tpl)
|
||||||
|
i_btn = EDITOR_JS.index("${cmtBtn}", i_tpl)
|
||||||
|
assert i_content < i_btn
|
||||||
|
# icône bulle de conversation +, ancre = commentaire du bloc
|
||||||
|
assert 'onclick="E.commentBlock(${idx})"' in EDITOR_JS
|
||||||
|
assert EDITOR_JS.count('<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5') == 1 # bulle (message-square)
|
||||||
|
assert '<path d="M12 8v5M9.5 10.5h5"/>' in EDITOR_JS # le + dans la bulle
|
||||||
|
# l'ancien .block-actions mort (opacity:0 sans survol) a disparu de renderBlock
|
||||||
|
assert "const actions=idx>=0?" not in EDITOR_JS
|
||||||
|
|
||||||
|
|
||||||
|
def test_comment_button_style_and_visibility():
|
||||||
|
assert ".block-comment-btn {" in APP_CSS
|
||||||
|
assert "right: -60px;" in APP_CSS # gouttière 64px, comme .block-actions
|
||||||
|
assert ".block-wrapper:hover .block-comment-btn," in APP_CSS
|
||||||
|
assert ".block-comment-btn:hover {" in APP_CSS
|
||||||
|
# masqué partout où le handle l'est : mobile (2 breakpoints) + page verrouillée
|
||||||
|
assert APP_CSS.count(".block-comment-btn { display: none; }") == 2
|
||||||
|
assert "body.page-locked .block-comment-btn," in APP_CSS
|
||||||
Reference in New Issue
Block a user