Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cf922106f | ||
|
|
d58c321c07 |
+111
@@ -1,5 +1,116 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.68.0 (2026-10-08) — Colonnes rendues, tables IA, copier/couper/coller multi-blocs
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Colonnes / équation / TOC n'étaient JAMAIS rendus** : `renderBlock()`
|
||||
n'appelait pas `renderColumnsBlock()` / `renderMathBlock()` / `renderTOC()`
|
||||
(3 renderers morts depuis l'extraction v7.22) → un bloc colonnes — créé par
|
||||
le slash « Column list », par **Turn into → 2/3/4/5 colonnes** — tombait dans
|
||||
le rendu générique : un paragraphe vide. Dispatch ajouté.
|
||||
- la **× de suppression de colonne** était en `opacity:0` **sans règle de
|
||||
survol** (invisible mais cliquable) → visible au survol du bloc colonne.
|
||||
- **Tables rendues par l'IA** : `_tblIsData`/`_tblIsSep` exigeaient un `|` de
|
||||
tête → le GFM sans pipe (`A | B` / `--- | ---`, ce que produit souvent le
|
||||
modèle) devenait des paragraphes. Le pipe de tête est **optionnel** en GFM ;
|
||||
garde conservée : une ligne de séparation doit faire ≥2 cellules (un « texte »
|
||||
suivi d'un « --- » reste un séparateur de section). `md2b` et `paste2b`
|
||||
partagent les helpers → alignés d'office.
|
||||
- **Coller un SEUL bloc structuré** (table, code, titre) retombait en texte
|
||||
brut (`if(parsed.length<=1) return fallback()`) → seul un bloc paragraphe
|
||||
(une ligne) garde le comportement inline.
|
||||
- **« Copy link to block »** du menu de bloc : `data-bm-act="link"` alors que
|
||||
le handler attend `'copy-link'` → l'action ne faisait rien.
|
||||
|
||||
### Added
|
||||
|
||||
- **Copier / couper une sélection de texte MULTI-BLOCS** :
|
||||
- `_clipState()` (sélection vivante traversant ≥2 blocs) + `_clipMarkdown()`
|
||||
(début partiel → blocs entiers sérialisés par `blocksToMarkdown` → fin
|
||||
partielle) ; `clipCopy()` / `clipCut()` (la coupure garde la tête du 1ᵉʳ
|
||||
bloc et la queue du dernier, le milieu part) ;
|
||||
- listeners **`copy` / `cut`** sur `#_blocksCt` : Ctrl+C / Ctrl+X passent par
|
||||
les événements natifs du navigateur ; dans un seul bloc, on laisse le
|
||||
navigateur faire (copie native) ;
|
||||
- groupe **Clipboard** (`Cut Ctrl+X` / `Copy Ctrl+C` / `Paste Ctrl+V`) en
|
||||
tête du menu du clic droit, avec la sélection **figée à l'ouverture** du
|
||||
menu (la recherche prend le focus et détruit la sélection) ;
|
||||
- clic droit **sur un bloc sélectionné** → la sélection multi-blocs (handle +
|
||||
Maj-clic) est conservée et le Clipboard agit dessus (`copySelectedBlocks`) ;
|
||||
- `pasteBlocks()` colle après le dernier bloc quand aucun bloc n'est focalisé.
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v768_columns_clipboard.py` (4).
|
||||
- **e2e** `e2e/v768_columns_clipboard.spec.js` (4) : slash « Column list » +
|
||||
« Add column » + **Turn into → 2 colonnes** (bloc `.block-column` réellement
|
||||
rendu), table IA **sans pipe de tête** (vrai `.ftable-editor` + garde
|
||||
« texte »/« --- »), copier→`readText` → couper (blocs réduits à la tête +
|
||||
queue) → coller (blocs revenus), clic droit → groupe Clipboard → Copy.
|
||||
- le raccourci clavier n'est pas déclenché en headless Chromium (pas de
|
||||
presse-papier OS) : le test passe par `document.execCommand('copy'/'cut')`,
|
||||
qui émet les **mêmes** événements que Ctrl+C/X.
|
||||
- `pytest tests/ -q -n auto` → **1394 passed, 0 failed**, `ruff check app tests`
|
||||
→ OK.
|
||||
|
||||
|
||||
## v7.67.0 (2026-10-08) — Réactions dans le tiroir Comments (+ suppression) et refonte du menu de sélection
|
||||
|
||||
### Added
|
||||
|
||||
- **Les réactions vivent dans le tiroir « Comments »** : section **Reactions**
|
||||
au-dessus des fils — émoji, compte (si >1) et **texte ancré** lu dans le DOM
|
||||
(`reactionList()` / `_anchorText()`, offsets source hors pastilles). L'état
|
||||
vide « No comments yet » ne s'affiche plus s'il reste des réactions.
|
||||
- **Suppression d'une réaction — deux entrées, un seul chemin** :
|
||||
- **✕** sur la ligne de la section Reactions (visible uniquement sur **mes**
|
||||
réactions) ;
|
||||
- au **survol de la pastille**, l'émoji que j'ai posé devient une **croix**
|
||||
(`::after`, aucun nœud ajouté → offsets source inchangés), un clic retire.
|
||||
- les deux appellent `removeReaction()` → `POST /reactions` (toggle) →
|
||||
`loadReactions()` + repeindre les ancres.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Refonte du menu de sélection façon capture** :
|
||||
- ligne 2 `[A] B I U T Tx` (le barré est conservé — absent de la maquette,
|
||||
décision volontaire pour ne pas perdre la fonction) ;
|
||||
- ligne 3 `🔗 S </> √x …` (la réaction en est sortie) ;
|
||||
- **ligne 4 = une rangée scannée** : `💬 Comment` (libellé) à gauche,
|
||||
`🙂+` réaction à droite (`.ft-row-split`) ;
|
||||
- entête **Skills** avec `≡` à droite (`.ft-skills-head::after`), liste
|
||||
**scrollable** (`.ft-skills`, `max-height` + `overflow-y`) ;
|
||||
- **✨ Ask AI retiré** de la toolbar : doublon exact de « Edit with AI » du
|
||||
pied (même `fmtAskAI()`).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Flake de gate corrigé (racine)** : `test_v55.py::TestOGParser::
|
||||
test_fetch_og_metadata_success` dépendait d'un **vrai** `socket.getaddrinfo
|
||||
("example.com")` via le garde SSRF `_is_public_host` ; sous `pytest -n auto`
|
||||
un `socket.timeout` (non couvert par `except socket.gaierror`) remontait
|
||||
dans le `except Exception` de `fetch_og_metadata` → repli silencieux
|
||||
(`title='example.com'`) → AssertionError, 3 fois sur 5 runs.
|
||||
- `_is_public_host` catch désormais **`OSError`** : le garde renvoie un
|
||||
booléen, il ne lève jamais (les autres appelants gagnent aussi) ;
|
||||
- fixture `stub_dns` sur `TestOGParser` (même motif que
|
||||
`test_agent_web_tools.stub_dns`) : zéro DNS dans les tests OG.
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v767_reactions_drawer.py` (3) ; `tests/test_v762_selection_toolbar.py`
|
||||
(ligne 4 + styles) ; `tests/test_v764_comments_reactions.py` (position de
|
||||
`fmtReact`).
|
||||
- **e2e** : `v764_comments_reactions` étendu — section Reactions dans le tiroir
|
||||
(texte ancré), **suppression A** (✕ du tiroir → pastille disparue),
|
||||
**suppression B** (croix `::after` visible seulement au survol, puis clic) ;
|
||||
`v762_selection_toolbar` — `Comment` + `ft-row-split .ft-react`,
|
||||
`ft-skills-head` = « Skills », plus de bouton ✨.
|
||||
- `pytest tests/ -q -n auto` → **1390 passed, 0 failed** (flake corrigé),
|
||||
`ruff check app tests` → OK.
|
||||
|
||||
|
||||
## v7.66.0 (2026-10-08) — Escape du menu slash retire le « / » + bouton « commenter » à droite du bloc
|
||||
|
||||
### Added
|
||||
|
||||
+44
@@ -1501,6 +1501,50 @@ Détails livrés :
|
||||
`tests/test_v766_slash_esc_comment_btn.py` (3) ; `pytest` **1386 passed**,
|
||||
`ruff` OK ; `VERSION` + `app/main.py` = 7.66.0
|
||||
|
||||
### v7.67.0 — Réactions dans le tiroir + suppression + refonte du menu de sélection ✅ (2026-10-08)
|
||||
> **Objectif** : une réaction s'ajoute/supprime depuis le tiroir Comments, et
|
||||
> le menu de sélection correspond à la maquette ASCII fournie. **COMPLETED**.
|
||||
|
||||
- [x] **Section « Reactions » du tiroir** — `reactionList()` + `_anchorText()`
|
||||
(texte de la plage lu dans le DOM, hors pastilles), compte si >1, état vide
|
||||
adapté ; `removeReaction()` = POST toggle
|
||||
- [x] **Suppression** — ✕ dans le tiroir (mes réactions seulement) **et**
|
||||
croix `::after` au survol de la pastille sur l'émoji que j'ai posé (aucun
|
||||
nœud ajouté → offsets source intacts)
|
||||
- [x] **Refonte du menu** — ligne 4 scannée `💬 Comment | 🙂 réaction`,
|
||||
réaction sortie de la ligne 3, entête `Skills ≡`, liste Skills scrollable,
|
||||
`✨ Ask AI` retiré (doublon exact du pied)
|
||||
- écart volontaire à la maquette : le **barré (T)** de la ligne 2 est
|
||||
conservé (il n'y figure pas) pour ne pas perdre la fonction
|
||||
- [x] **Flake de gate corrigé** — `_is_public_host` attrape `OSError` (le
|
||||
garde SSRF ne lève plus) + fixture `stub_dns` sur `TestOGParser`
|
||||
(le test faisait un vrai `getaddrinfo("example.com")`, échec sous `-n auto`)
|
||||
- [x] **Tests** — `tests/test_v767_reactions_drawer.py` (3), e2e v764 étendu
|
||||
(réactions dans le tiroir + suppression A/B), e2e v762 (nouvelle ligne 4) ;
|
||||
`pytest` **1390 passed / 0 failed**, `ruff` OK ; `VERSION` +
|
||||
`app/main.py` = 7.67.0
|
||||
|
||||
### v7.68.0 — Colonnes rendues, tables IA, copier/couper/coller multi-blocs ✅ (2026-10-08)
|
||||
> **Objectif** : les colonnes (slash + Turn into) marchent, l'IA crée de vrais
|
||||
> tableaux, et une sélection multi-blocs se copie/coupe/colle. **COMPLETED**.
|
||||
|
||||
- [x] **Renderers morts branchés** — `renderBlock()` n'appelait jamais
|
||||
`renderColumnsBlock()` / `renderMathBlock()` / `renderTOC()` → bloc colonnes,
|
||||
équation et TOC rendaient un paragraphe vide (bug rapporté) ; × de
|
||||
suppression de colonne visible au survol (était invisible mais cliquable)
|
||||
- [x] **Tables IA** — `_tblIsData`/`_tblIsSep` acceptent le GFM **sans pipe de
|
||||
tête** (séparateur ≥2 cellules pour ne pas avaler « texte » + « --- ») ;
|
||||
`md2b` et `paste2b` alignés d'office
|
||||
- [x] **Coller** un seul bloc structuré (table/code/titre) = vraie insertion ;
|
||||
« Copy link to block » réparé (`link` → `copy-link`)
|
||||
- [x] **Copier/couper multi-blocs** — `_clipState()`/`_clipMarkdown()`/
|
||||
`clipCopy()`/`clipCut()`, listeners `copy`/`cut` (Ctrl+C/X), groupe
|
||||
**Clipboard** dans le menu du clic droit (sélection figée à l'ouverture),
|
||||
clic droit sur un bloc sélectionné = sélection multi-blocs conservée
|
||||
- [x] **Tests** — `tests/test_v768_columns_clipboard.py` (4), e2e
|
||||
`v768_columns_clipboard.spec.js` (4) ; `pytest` **1394 passed / 0 failed**,
|
||||
`ruff` OK ; `VERSION` + `app/main.py` = 7.68.0
|
||||
|
||||
---
|
||||
|
||||
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v7.66.0 (Escape du menu slash retire le / + bouton commenter à droite du bloc) | **Statut**: EN COURS 🔄
|
||||
> **Début**: 2026-07-08 | **Version**: v7.68.0 (colonnes rendues, tables IA sans pipe de tête, copier/couper/coller multi-blocs) | **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
@@ -186,7 +186,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.66.0",
|
||||
version="7.68.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
@@ -26,7 +26,10 @@ def _is_public_host(host: str) -> bool:
|
||||
return False
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, None)
|
||||
except socket.gaierror:
|
||||
except OSError:
|
||||
# gaierror, socket.timeout, herror… : le garde-fou doit TOUJOURS
|
||||
# renvoyer un booléen, jamais lever — sinon l'appelant tombe dans son
|
||||
# `except Exception` (bookmarks) au lieu d'un refus explicite.
|
||||
return False
|
||||
for info in infos:
|
||||
address = info[4][0]
|
||||
|
||||
@@ -590,7 +590,20 @@
|
||||
<button type="button" class="topbar-btn" @click="toggleComments()" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="comments-drawer-list" style="flex:1;overflow-y:auto;padding:12px 16px;">
|
||||
<div x-show="comments.length === 0" style="text-align:center;color:var(--text-dim,#999);padding:32px 0;">
|
||||
<!-- v7.67 : les réactions emoji du texte vivent aussi ici (✕ = retirer) -->
|
||||
<div class="ct-reactions" x-show="reactionList().length" x-cloak style="margin-bottom:14px;">
|
||||
<div class="ct-section-title">Reactions</div>
|
||||
<template x-for="r in reactionList()" :key="r.key">
|
||||
<div class="ct-reaction">
|
||||
<span class="ct-re-emoji" x-text="r.emoji"></span>
|
||||
<span class="ct-re-count" x-show="r.count > 1" x-text="r.count"></span>
|
||||
<span class="ct-re-text" x-text="r.text"></span>
|
||||
<button type="button" class="ct-act" title="Remove reaction"
|
||||
x-show="r.mine" @click="removeReaction(r)">✕</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div x-show="comments.length === 0 && !reactionList().length" style="text-align:center;color:var(--text-dim,#999);padding:32px 0;">
|
||||
No comments yet. Select some text and click 💬 Comment.
|
||||
</div>
|
||||
<template x-for="c in comments" :key="c.id">
|
||||
@@ -666,23 +679,25 @@
|
||||
<div class="ft-row">
|
||||
<button type="button" class="ft-item" @click="fmtLink()" data-tooltip="Link">{{ fd_icon("link",15) }}</button>
|
||||
<button type="button" class="ft-item" @click="fmtHighlight()" data-tooltip="Highlight"><span class="ft-hl">S</span></button>
|
||||
<button type="button" class="ft-item ft-react" @click="fmtReact()" data-tooltip="React to selected text"><span class="ft-react-ico">🙂<span class="ft-react-plus">+</span></span></button>
|
||||
<button type="button" class="ft-item ft-code-ico" @click="fmtCodeBlock()" data-tooltip="Code block"></></button>
|
||||
<button type="button" class="ft-item" @click="fmtEquation()" data-tooltip="Equation">√x</button>
|
||||
<button type="button" class="ft-item ft-more" @click="fmtMore($event)" data-tooltip="More actions">…</button>
|
||||
</div>
|
||||
<!-- Ligne 4 : actions -->
|
||||
<div class="ft-row">
|
||||
<button type="button" class="ft-item" @click="fmtComment()" data-tooltip="Comment — Ctrl+⇧+M">{{ fd_icon("message-square",15) }}</button>
|
||||
<button type="button" class="ft-item" @click="fmtAskAI()" data-tooltip="Ask AI — Ctrl+J">{{ fd_icon("sparkles",15) }}</button>
|
||||
<!-- Ligne 4 : Comment à gauche, réaction à droite (le ✨ Ask AI sauté :
|
||||
doublon exact du bouton du pied, même action) -->
|
||||
<div class="ft-row ft-row-split">
|
||||
<button type="button" class="ft-item ft-comment" @click="fmtComment()" data-tooltip="Comment — Ctrl+⇧+M">{{ fd_icon("message-square",15) }}<span>Comment</span></button>
|
||||
<button type="button" class="ft-item ft-react" @click="fmtReact()" data-tooltip="React to selected text"><span class="ft-react-ico">🙂<span class="ft-react-plus">+</span></span></button>
|
||||
</div>
|
||||
<!-- Section « Skills » -->
|
||||
<div class="ft-sep"></div>
|
||||
<div class="ft-group">Skills</div>
|
||||
<div class="ft-group ft-skills-head">Skills</div>
|
||||
<div class="ft-skills">
|
||||
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Improve the writing of this text (wording, clarity, style). Keep the meaning, language and format. Reply ONLY with the improved text.')" data-tooltip="Run on this block">Improve writing</button>
|
||||
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Proofread this text: fix spelling and grammar mistakes only. Reply ONLY with the corrected text.')" data-tooltip="Run on this block">Proofread</button>
|
||||
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Explain this text in simpler terms, in the same language. Reply ONLY with the explanation.')" data-tooltip="Run on this block">Explain</button>
|
||||
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Reformat this text for readability (structure, spacing, lists where useful) without changing its meaning. Reply ONLY with the reformatted text.')" data-tooltip="Run on this block">Reformat</button>
|
||||
</div>
|
||||
<!-- Pied de page -->
|
||||
<div class="ft-sep"></div>
|
||||
<button type="button" class="ft-item ft-foot" @click="fmtEditAI()" data-tooltip="Edit with AI">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "FlowDeck",
|
||||
"version": "7.66.0"
|
||||
"version": "7.68.0"
|
||||
},
|
||||
"paths": {
|
||||
"/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').first()).toHaveText('Improve writing');
|
||||
await expect(bar.locator('.ft-foot')).toContainText('Edit with AI');
|
||||
// refonte v7.67 : ligne 4 = Comment (libellé) à gauche, réaction à droite ;
|
||||
// entête Skills avec ≡ ; plus de bouton ✨ Ask AI (doublon du pied)
|
||||
await expect(bar.locator('.ft-comment')).toContainText('Comment');
|
||||
await expect(bar.locator('.ft-row-split .ft-react')).toHaveCount(1);
|
||||
await expect(bar.locator('.ft-skills-head')).toHaveText('Skills');
|
||||
await expect(bar.locator('[data-tooltip^="Ask AI"]')).toHaveCount(0);
|
||||
// géométrie : sous la sélection, entièrement dans le viewport
|
||||
const barBox = await bar.boundingBox();
|
||||
const selBox = await page.evaluate(() => {
|
||||
|
||||
@@ -168,4 +168,34 @@ test('réaction emoji + commentaires ancrés', async ({ page }) => {
|
||||
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveCount(1, { timeout: 10000 });
|
||||
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveText('reaction a un');
|
||||
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1, { timeout: 10000 });
|
||||
|
||||
// ── 9. v7.67 : la réaction vit aussi dans le tiroir Comments ──
|
||||
await page.locator('.topbar-btn[title="Comments"]').click();
|
||||
const re = page.locator('.ct-reactions');
|
||||
await expect(re).toBeVisible();
|
||||
await expect(re.locator('.ct-re-emoji')).toHaveCount(1);
|
||||
await expect(re.locator('.ct-re-text')).toHaveText('Prise de reaction a un texte');
|
||||
await page.screenshot({ path: 'shots/v764-drawer-reactions.png' });
|
||||
|
||||
// ── 10. suppression A : ✕ de la section Réactions ──
|
||||
await re.locator('.ct-reaction .ct-act').click();
|
||||
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(0);
|
||||
await expect(re).toBeHidden();
|
||||
|
||||
// ── 11. suppression B : la croix survolée de la pastille ──
|
||||
await page.locator('.topbar-btn[title="Comments"]').click(); // referme le tiroir
|
||||
await selectBlock(page);
|
||||
await page.locator('.format-toolbar .ft-react').click();
|
||||
const picker2 = page.locator('.fd-icon-picker');
|
||||
await expect(picker2).toBeVisible();
|
||||
await picker2.locator('.fdip-emoji').first().click();
|
||||
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1);
|
||||
const emo = page.locator('#_blocksCt .fd-react-emo.is-mine');
|
||||
await expect(emo).toHaveCount(1);
|
||||
// la croix n'apparaît qu'au survol (::after)
|
||||
await expect.poll(() => emo.evaluate((el) => getComputedStyle(el, '::after').display)).toBe('none');
|
||||
await page.locator('#_blocksCt .fd-react-chip').hover();
|
||||
await expect.poll(() => emo.evaluate((el) => getComputedStyle(el, '::after').display)).toBe('flex');
|
||||
await emo.click();
|
||||
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
/**
|
||||
* v7.68 — colonnes rendues (slash + Turn into), tables IA sans pipe de tête,
|
||||
* copier/couper/coller d'une sélection multi-blocs (Ctrl+C/X/V + clic droit).
|
||||
* Instance de test attendue sur FD_BASE_URL (défaut 8081).
|
||||
*/
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
|
||||
const USER = process.env.FD_USER || '[email protected]';
|
||||
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||
|
||||
test.use({ serviceWorkers: 'block', permissions: ['clipboard-read', 'clipboard-write'] });
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
const ok = await page
|
||||
.waitForURL('**/workspaces', { timeout: 8000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!ok) {
|
||||
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
|
||||
data: { email: USER, password: PASS, name: 'E2E' },
|
||||
});
|
||||
if (resp.status() !== 409) expect(resp.ok()).toBeTruthy();
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
}
|
||||
}
|
||||
|
||||
async function newPage(page, body) {
|
||||
const cookies = await page.context().cookies();
|
||||
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
|
||||
const created = await page.request.post(
|
||||
`${FD_BASE}/board/api/pages?title=v768 ${Date.now()}`,
|
||||
{ headers: { 'X-CSRF-Token': csrf } }
|
||||
);
|
||||
expect(created.status(), await created.text()).toBeLessThan(400);
|
||||
const { id } = await created.json();
|
||||
if (body) {
|
||||
const bs = await page.request.post(`${FD_BASE}/board/api/pages/${id}/blocks`, {
|
||||
headers: { 'X-CSRF-Token': csrf },
|
||||
data: { title: 'v768', blocks: body },
|
||||
});
|
||||
expect(bs.status(), await bs.text()).toBeLessThan(400);
|
||||
}
|
||||
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
|
||||
return id;
|
||||
}
|
||||
|
||||
// sélection multi-blocs (du milieu du 1er au milieu du 3e) + fin de mouseup
|
||||
async function selectAcross(page) {
|
||||
await page.evaluate(() => {
|
||||
const el1 = document.querySelector('#_blocksCt [data-bid]');
|
||||
const blocks = document.querySelectorAll('#_blocksCt [data-bid]');
|
||||
const el3 = blocks[blocks.length - 1];
|
||||
const r = document.createRange();
|
||||
r.setStart(el1.firstChild, 0);
|
||||
r.setEnd(el3.firstChild, el3.firstChild.nodeValue.length);
|
||||
const s = getSelection();
|
||||
s.removeAllRanges();
|
||||
s.addRange(r);
|
||||
});
|
||||
}
|
||||
|
||||
test('colonnes : slash + Turn into 2 colonnes', async ({ page }) => {
|
||||
await login(page);
|
||||
await newPage(page, [{ id: 'b1', type: 'paragraph', content: '' }]);
|
||||
await page.click('#_blocksCt [data-bid]');
|
||||
await page.keyboard.type('/');
|
||||
await expect(page.locator('#_slashMenu')).toBeVisible({ timeout: 5000 });
|
||||
await page.keyboard.type('columns');
|
||||
await page.locator('#_slashMenu .slash-item[data-sid="columns"]').click();
|
||||
// le bloc colonnes est RENDU (il tombait dans le rendu générique = vide)
|
||||
await expect(page.locator('#_blocksCt .block-columns')).toHaveCount(1);
|
||||
await expect(page.locator('#_blocksCt .block-column')).toHaveCount(1);
|
||||
await page.locator('#_blocksCt .columns-add-btn').click();
|
||||
await expect(page.locator('#_blocksCt .block-column')).toHaveCount(2);
|
||||
await page.screenshot({ path: 'shots/v768-columns-slash.png' });
|
||||
|
||||
// Turn into → 2 colonnes
|
||||
await newPage(page, [{ id: 'b1', type: 'paragraph', content: 'Pour le menu' }]);
|
||||
await page.locator('#_blocksCt [data-bid]').first().click({ button: 'right' });
|
||||
const menu = page.locator('#blockMenu');
|
||||
await expect(menu).toBeVisible();
|
||||
await menu.locator('[data-sub="turn"]').hover();
|
||||
const sub = page.locator('#blockMenuSub');
|
||||
await expect(sub).toBeVisible();
|
||||
await sub.locator('[data-bm-act="turn-type"][data-cols="2"]').click();
|
||||
await expect(page.locator('#_blocksCt .block-column')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('table IA sans pipe de tête devient un vrai tableau', async ({ page }) => {
|
||||
await login(page);
|
||||
await newPage(page, [{ id: 'b1', type: 'paragraph', content: '' }]);
|
||||
await page.evaluate(() => {
|
||||
window.E.applyAIBlocks('Nom | Rôle\n--- | ---\nAlice | Dev\nBob | Test');
|
||||
});
|
||||
await expect(page.locator('#_blocksCt .ftable-editor')).toHaveCount(1);
|
||||
await expect(page.locator('#_blocksCt .ftable-cell').first()).toHaveText('Nom');
|
||||
await expect(page.locator('#_blocksCt .ftable-cell').last()).toHaveText('Test');
|
||||
// la ligne « texte » suivie d'« --- » ne doit PAS devenir un tableau
|
||||
await page.evaluate(() => {
|
||||
window.E.applyAIBlocks('juste du texte\n---\nune phrase');
|
||||
});
|
||||
await expect(page.locator('#_blocksCt .ftable-editor')).toHaveCount(1);
|
||||
await page.screenshot({ path: 'shots/v768-ai-table.png' });
|
||||
});
|
||||
|
||||
test('Ctrl+C / Ctrl+X / Ctrl+V sur une sélection multi-blocs', async ({ page }) => {
|
||||
await login(page);
|
||||
await newPage(page, [
|
||||
{ id: 'b1', type: 'paragraph', content: 'Premier bloc' },
|
||||
{ id: 'b2', type: 'heading_2', content: 'Titre deux' },
|
||||
{ id: 'b3', type: 'paragraph', content: 'Troisieme bloc' },
|
||||
]);
|
||||
// le raccourci clavier nécessite un éditeur focalisé (sinon Chromium
|
||||
// n'émet pas l'événement copy)
|
||||
await page.locator('#_blocksCt [data-bid]').first().click();
|
||||
await selectAcross(page);
|
||||
// headless Chromium n'émet PAS le raccourci clavier (pas de presse-papier
|
||||
// OS) : execCommand('copy') déclenche le MÊME événement copy → notre handler
|
||||
await page.evaluate(() => document.execCommand('copy'));
|
||||
const clip = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(clip).toContain('Premier bloc');
|
||||
expect(clip).toContain('## Titre deux'); // le titre sort en markdown
|
||||
|
||||
// coupe : les 3 blocs deviennent 2 blocs vides (tête + queue)
|
||||
await selectAcross(page);
|
||||
await page.evaluate(() => document.execCommand('cut'));
|
||||
await expect(page.locator('#_blocksCt [data-bid]')).toHaveCount(2);
|
||||
await expect(page.locator('#_blocksCt [data-bid]').first()).toHaveText('');
|
||||
|
||||
// colle : les 3 blocs reviennent APRÈS le bloc du curseur (comme l'entrée
|
||||
// Paste du menu) → 2 blocs restants + 3 collés = 5
|
||||
await page.evaluate(() => window.E.pasteBlocks());
|
||||
await expect(page.locator('#_blocksCt [data-bid]')).toHaveCount(5, { timeout: 5000 });
|
||||
await expect(page.locator('#_blocksCt [data-bid]').nth(1)).toContainText('Premier bloc');
|
||||
await expect(page.locator('#_blocksCt [data-bid]').nth(2)).toContainText('Titre deux');
|
||||
});
|
||||
|
||||
test('clic droit avec une sélection → groupe Clipboard', async ({ page }) => {
|
||||
await login(page);
|
||||
await newPage(page, [
|
||||
{ id: 'b1', type: 'paragraph', content: 'Alpha bloc' },
|
||||
{ id: 'b2', type: 'paragraph', content: 'Beta bloc' },
|
||||
]);
|
||||
await selectAcross(page);
|
||||
await page.locator('#_blocksCt [data-bid]').first().click({ button: 'right', position: { x: 4, y: 4 } });
|
||||
const menu = page.locator('#blockMenu');
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.locator('[data-bm-act="clip-copy"]')).toBeVisible();
|
||||
await expect(menu.locator('[data-bm-act="clip-cut"]')).toBeVisible();
|
||||
await expect(menu.locator('[data-bm-act="clip-paste"]')).toBeVisible();
|
||||
await page.screenshot({ path: 'shots/v768-ctx-clipboard.png' });
|
||||
await menu.locator('[data-bm-act="clip-copy"]').click();
|
||||
const clip = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(clip).toContain('Alpha bloc');
|
||||
expect(clip).toContain('Beta bloc');
|
||||
});
|
||||
@@ -2865,6 +2865,11 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
border-radius: 50%;
|
||||
line-height: 1;
|
||||
}
|
||||
/* v7.68 : la × de suppression de colonne était en opacity:0 SANS règle de
|
||||
survol → invisible et pourtant cliquable (même piège que .block-actions) */
|
||||
.block-column-wrapper:hover .block-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
.block-column {
|
||||
outline: none;
|
||||
min-height: 24px;
|
||||
@@ -3040,6 +3045,30 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* ── v7.67 : refonte façon capture — ligne Comment | réaction aux 2 extrémités,
|
||||
entête « Skills » avec ≡ à droite, liste Skills scrollable ── */
|
||||
.format-toolbar .ft-row-split {
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
}
|
||||
.format-toolbar .ft-comment {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.format-toolbar .ft-skills-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.format-toolbar .ft-skills-head::after {
|
||||
content: '≡';
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.format-toolbar .ft-skills {
|
||||
max-height: 132px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Séparateurs, groupe « Skills », pied de page */
|
||||
.format-toolbar .ft-sep {
|
||||
height: 1px;
|
||||
@@ -3212,6 +3241,28 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
.fd-react-chip img { width: 12px; height: 12px; object-fit: contain; }
|
||||
.fd-react-n { font-size: 10px; color: var(--text-dim); }
|
||||
|
||||
/* v7.67 : suppression d'une réaction — au survol de la pastille, l'émoji que
|
||||
J'ai posé devient une croix (un seul clic = retirer). Pas de reflow : c'est
|
||||
un ::after, les offsets source ne bougent pas. */
|
||||
.fd-react-emo { position: relative; display: inline-flex; align-items: center; }
|
||||
.fd-react-emo.is-mine::after {
|
||||
content: '✕';
|
||||
position: absolute;
|
||||
inset: -3px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
color: var(--text-dim);
|
||||
background: var(--bg-modal, #1f1f1f);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.fd-react-chip:hover .fd-react-emo.is-mine::after { display: flex; }
|
||||
.fd-react-emo.is-mine:hover::after { color: #e5484d; border-color: #e5484d; }
|
||||
|
||||
/* Tiroir de commentaires (fil + saisie) */
|
||||
.comment-thread {
|
||||
position: relative;
|
||||
@@ -3251,6 +3302,37 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
.ct-menu-item.is-danger { color: #e5484d; }
|
||||
.ct-body { font-size: 13px; white-space: pre-wrap; margin-top: 6px; color: var(--text-primary); }
|
||||
.ct-anchor-label { font-size: 10px; color: var(--text-dim); margin-top: 6px; }
|
||||
|
||||
/* v7.67 : section Réactions du tiroir (liste des plages réagies + retirer) */
|
||||
.ct-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ct-reaction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 6px;
|
||||
background: var(--bg-secondary, #2a2a2a);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.ct-re-emoji { font-size: 14px; line-height: 1; flex-shrink: 0; }
|
||||
.ct-re-count { font-size: 11px; color: var(--text-dim); }
|
||||
.ct-re-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.comments-drawer-input { border-top: 1px solid var(--border, #333); padding: 12px 16px; }
|
||||
.ct-input-row { display: flex; gap: 6px; align-items: flex-end; }
|
||||
.ct-input-row textarea {
|
||||
|
||||
@@ -236,8 +236,11 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
|
||||
/* ═══════════ Simple Table block (v4.8.0) ═══════════ */
|
||||
function _tblSplitLine(s){s=(s||'').trim();if(s.charAt(0)==='|')s=s.slice(1);if(s.charAt(s.length-1)==='|')s=s.slice(0,-1);return s.split('|').map(function(x){return x.trim();});}
|
||||
function _tblIsSep(s){s=(s||'').trim();if(!s||s.charAt(0)!=='|')return false;var cells=_tblSplitLine(s);if(!cells.length)return false;return cells.every(function(c){return /^:?-{1,}:?$/.test(c);});}
|
||||
function _tblIsData(s){s=(s||'').trim();return /^\|/.test(s)&&s.indexOf('|',1)>=0;}
|
||||
// v7.68 : le pipe de tête est OPTIONNEL en GFM — l'IA rend souvent
|
||||
// « A | B » / « --- | --- ». Une ligne de séparation doit garder ≥2 cellules,
|
||||
// sinon « texte » suivi d'un « --- » deviendrait un tableau.
|
||||
function _tblIsSep(s){s=(s||'').trim();if(!s)return false;var cells=_tblSplitLine(s);if(cells.length<2)return false;return cells.every(function(c){return /^:?-{1,}:?$/.test(c);});}
|
||||
function _tblIsData(s){s=(s||'').trim();return s.indexOf('|')>=0&&_tblSplitLine(s).length>=2;}
|
||||
function _tblNew(rows,cols){var out=[];for(var i=0;i<rows;i++){var r=new Array(cols);for(var j=0;j<cols;j++)r[j]='';out.push(r);}return out;}
|
||||
function _tblClone(b){b.rows=(b.rows||[]).map(function(r){return (r||[]).slice();});return b;}
|
||||
function _tblAddCol(b,index){_tblClone(b);var w=b.rows.length?b.rows[0].length:1;if(index<0)index=0;if(index>w)index=w;b.rows.forEach(function(r){r.splice(index,0,'');});}
|
||||
@@ -744,6 +747,12 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
if(b.type==='table')return renderTableBlock(b,idx);
|
||||
if(b.type==='divider')return `<div class="block-wrapper" data-id="${b.id}"><hr class="block-divider"></div>`;
|
||||
if(b.type==='button')return renderAutomationButton(b,idx);
|
||||
// v7.68 : ces trois renderers existaient mais n'étaient JAMAIS appelés —
|
||||
// un bloc `columns`/`math`/`table_of_contents` tombait dans le rendu générique
|
||||
// (un paragraphe vide). Colonnes (slash + Turn into) = bug rapporté.
|
||||
if(b.type==='columns')return renderColumnsBlock(b);
|
||||
if(b.type==='math')return renderMathBlock(b);
|
||||
if(b.type==='table_of_contents')return renderTOC(b);
|
||||
if(b.type==='image')return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-image" style="text-align:center;padding:12px 0;"><img src="${b.src||''}" alt="${esc(b.alt||'')}" class="fd-img" data-full="${b.src||''}" style="max-width:100%;max-height:70vh;border-radius:8px;display:block;margin:0 auto;cursor:zoom-in;" onerror="this.style.display='none';this.parentElement.innerHTML='<div style=\\'padding:40px;color:var(--text-tertiary)\\'>Image not found</div>'" onclick="E._openLightbox(this)"></div></div>`;
|
||||
if(b.type==='video'){
|
||||
const src=b.src||'';return src?`<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-video"><video controls preload="metadata" style="max-width:100%;border-radius:8px;display:block;margin:0 auto;"><source src="${src}"></video></div></div>`:`<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-video" style="text-align:center;padding:60px 20px;background:var(--bg-secondary);border:1px dashed var(--border);border-radius:8px;cursor:pointer;" onclick="E.setVideoSrc('${b.id}')"><div style="font-size:48px;margin-bottom:12px;">🎬</div><div style="font-size:14px;color:var(--text-dim);">Click to add video URL</div></div></div>`;
|
||||
@@ -1185,6 +1194,16 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
else if(act==='suggest'){E.suggestEdit(idx);}
|
||||
else if(act==='ai'){E.askAIBlock(idx);}
|
||||
else if(act==='skill'){E.runSkill(idx,t.getAttribute('data-prompt')||'');}
|
||||
// v7.68 : Clipboard du menu (sélection multi-blocs gérée par copySelectedBlocks)
|
||||
else if(act==='clip-cut'){
|
||||
if(_selSet.size>1)E.cutSelectedBlocks();
|
||||
else{var mst=document.getElementById('blockMenu');const md=E.clipCut?E.clipCut(mst&&mst._clipSt):null;if(md!=null&&navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(md);E.showToast('Sélection coupée');}}
|
||||
}
|
||||
else if(act==='clip-copy'){
|
||||
if(_selSet.size>1)E.copySelectedBlocks();
|
||||
else{var mst2=document.getElementById('blockMenu');const md=E.clipCopy?E.clipCopy(mst2&&mst2._clipSt):null;if(md!=null&&navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(md);E.showToast('Sélection copiée');}}
|
||||
}
|
||||
else if(act==='clip-paste'){E.pasteBlocks();}
|
||||
else if(act==='color'){var key=t.getAttribute('data-color-key'),val=t.getAttribute('data-color-val');E.setBlockColor(idx,key,val);}
|
||||
else if(act==='turn-type'){E.turnInto(idx,t.getAttribute('data-type'),parseInt(t.getAttribute('data-cols')||'0',10)||0);_blockMenuClose();return;}
|
||||
_blockMenuClose();
|
||||
@@ -1229,11 +1248,24 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
var b=E.blocks[idx];if(!b)return;
|
||||
var d=document.createElement('div');d.id='blockMenu';d.className='block-menu';d.setAttribute('data-bid',b.id);d.style.cssText='position:fixed;z-index:4000;';
|
||||
var S=_BM_SHORTCUTS;
|
||||
// v7.68 : Clipboard — une sélection de texte multi-blocs OU des blocs
|
||||
// sélectionnés (handle + Maj-clic) donnent Cut/Copy ; Paste colle après.
|
||||
// la sélection est détruite quand la recherche prend le focus : on la
|
||||
// fige ici et les actions Clipboard la reprennent telle quelle.
|
||||
var clipSt=(E._clipState?E._clipState():null);
|
||||
d._clipSt=clipSt;
|
||||
var clip=(_selSet.size>1||clipSt)
|
||||
?'<div class="bm-group">Clipboard</div>'
|
||||
+_blockMenuItem('clip-cut','✂','Cut',{shortcut:'Ctrl+X'})
|
||||
+_blockMenuItem('clip-copy','⧉','Copy',{shortcut:'Ctrl+C'})
|
||||
+_blockMenuItem('clip-paste','📋','Paste',{shortcut:'Ctrl+V'})
|
||||
:'';
|
||||
d.innerHTML='<input class="bm-search" type="text" placeholder="Search actions..." autocomplete="off" spellcheck="false">'
|
||||
+clip
|
||||
+'<div class="bm-group">Text</div>'
|
||||
+_blockMenuItem('submenu','↩','Turn into',{sub:'turn'})
|
||||
+_blockMenuItem('submenu','🎨','Color',{sub:'color'})
|
||||
+_blockMenuItem('link','🔗','Copy link to block',{shortcut:S.link})
|
||||
+_blockMenuItem('copy-link','🔗','Copy link to block',{shortcut:S.link})
|
||||
+_blockMenuItem('duplicate','▣','Duplicate',{shortcut:S.duplicate})
|
||||
+_blockMenuItem('move-to','↪','Move to…',{shortcut:S.move})
|
||||
+_blockMenuItem('delete','✕','Delete',{shortcut:S.delete,danger:true})
|
||||
@@ -1249,7 +1281,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
d.querySelectorAll('.bm-item').forEach(function(it){
|
||||
it.style.display=(q&&it.textContent.toLowerCase().indexOf(q)<0)?'none':'';
|
||||
});
|
||||
var g=d.querySelector('.bm-group');if(g)g.style.display=q?'none':'';
|
||||
d.querySelectorAll('.bm-group').forEach(function(g){g.style.display=q?'none':'';});
|
||||
});
|
||||
d.addEventListener('mouseover',function(ev){
|
||||
var it=ev.target&&ev.target.closest?ev.target.closest('[data-sub]'):null;
|
||||
@@ -1377,6 +1409,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
if(!(ev.target&&ev.target.closest))return;
|
||||
if(ev.target.closest('#blockMenu')||ev.target.closest('#blockMenuSub')||ev.target.closest('#fdMoveOverlay'))return;
|
||||
if(ev.target.closest('.block-handle'))return;
|
||||
if(ev.button===2){const w0=ev.target.closest('.block-wrapper');if(w0&&_selSet.has(w0.getAttribute('data-id')))return;}
|
||||
if(_selSet.size)_selClear();
|
||||
_blockMenuClose();
|
||||
}
|
||||
@@ -1463,7 +1496,15 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
ct.addEventListener('click',e=>{const h=e.target&&e.target.closest?e.target.closest('.block-handle'):null;if(!h)return;const w=h.closest('.block-wrapper');if(!w)return;const wid=w.getAttribute('data-id');if(!wid)return;
|
||||
if(e.shiftKey){_selToggle(wid);return;}
|
||||
_selClear();blockMenuOpen(e.clientX,e.clientY,wid);});
|
||||
ct.addEventListener('contextmenu',e=>{if(!(e.target&&e.target.closest))return;if(e.target.closest('.ftable-cell')||e.target.closest('#_tblMenu'))return;const c=e.target.closest('.block-content, .block-wrapper, .block-column-wrapper');const el0=e.target.closest('[data-bid]');if(!c)return;const w=e.target.closest('.block-wrapper');const wid=w?w.getAttribute('data-id'):null;const bid=wid||(el0?el0.getAttribute('data-bid'):null);if(!bid)return;e.preventDefault();_selClear();blockMenuOpen(e.clientX,e.clientY,bid);});
|
||||
ct.addEventListener('contextmenu',e=>{if(!(e.target&&e.target.closest))return;if(e.target.closest('.ftable-cell')||e.target.closest('#_tblMenu'))return;const c=e.target.closest('.block-content, .block-wrapper, .block-column-wrapper');const el0=e.target.closest('[data-bid]');if(!c)return;const w=e.target.closest('.block-wrapper');const wid=w?w.getAttribute('data-id'):null;const bid=wid||(el0?el0.getAttribute('data-bid'):null);if(!bid)return;e.preventDefault();
|
||||
// v7.68 : clic droit SUR un bloc sélectionné → on garde la sélection
|
||||
// multi-blocs (le menu Clipboard coupe/copie ces blocs-là)
|
||||
if(!(_selSet.size>1&&_selSet.has(wid)))_selClear();
|
||||
blockMenuOpen(e.clientX,e.clientY,bid);});
|
||||
// v7.68 : Ctrl+C / Ctrl+X sur une sélection multi-blocs → markdown.
|
||||
// Sous un bloc unique, on laisse le navigateur (copie native).
|
||||
ct.addEventListener('copy',function(ev){const E=window.E;if(!E||!E.clipCopy)return;const md=E.clipCopy();if(md==null)return;ev.preventDefault();ev.clipboardData.setData('text/plain',md);E.showToast('Sélection copiée');});
|
||||
ct.addEventListener('cut',function(ev){const E=window.E;if(!E||!E.clipCut)return;const md=E.clipCut();if(md==null)return;ev.preventDefault();ev.clipboardData.setData('text/plain',md);E.showToast('Sélection coupée');});
|
||||
document.addEventListener('mousedown',_blockDocDown);
|
||||
if(window.location.hash&&window.location.hash.indexOf('#fdblk-')===0){const hb=window.location.hash.slice(7);setTimeout(()=>{const selEl=document.querySelector(`[data-bid="${hb}"]`);if(selEl&&selEl.scrollIntoView)selEl.scrollIntoView({behavior:'smooth',block:'center'});if(selEl){selEl.focus();ce(selEl);}},500);}
|
||||
},100);
|
||||
@@ -1960,7 +2001,58 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
_blocksToMarkdown(blocks){return blocks.map(blocksToMarkdown).join('\n\n');},
|
||||
copySelectedBlocks(){const indices=_selIndicesSorted();if(!indices.length)return;const selected=indices.map(i=>this.blocks[i]).filter(Boolean);if(!selected.length)return;const md=this._blocksToMarkdown(selected);if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(md).then(()=>this.showToast('Blocs copiés')).catch(()=>this.showToast('Copie impossible','error'));}else{this.showToast(md,'error');}},
|
||||
cutSelectedBlocks(){const indices=_selIndicesSorted();if(!indices.length)return;this.sync();this.pushHistory();const selected=indices.map(i=>this.blocks[i]).filter(Boolean);if(!selected.length)return;const md=this._blocksToMarkdown(selected);if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(md).then(()=>{});}this.blocks=this.blocks.filter((b,i)=>indices.indexOf(i)<0);if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();this.showToast('Blocs coupés');},
|
||||
async pasteBlocks(){const idx=this._focusedIdx();if(idx<0)return;try{const text=await navigator.clipboard.readText();if(!text||!text.trim())return;const parsed=this.md2b(text);if(!parsed.length)return;this.sync();this.pushHistory();this.blocks.splice(idx+1,0,...parsed);this.dirty=true;this.autoSave();this.render();_rtSync();this._focusBid(parsed[parsed.length-1].id);_selClear();}catch(e){if(e.name==='NotAllowedError'){this.showToast('Permission presse-papier refusée');}else{this.showToast('Coller impossible','error');}}},
|
||||
async pasteBlocks(){let idx=this._focusedIdx();if(idx<0)idx=this.blocks.length-1;if(idx<0)return;try{const text=await navigator.clipboard.readText();if(!text||!text.trim())return;const parsed=this.md2b(text);if(!parsed.length)return;this.sync();this.pushHistory();this.blocks.splice(idx+1,0,...parsed);this.dirty=true;this.autoSave();this.render();_rtSync();this._focusBid(parsed[parsed.length-1].id);_selClear();}catch(e){if(e.name==='NotAllowedError'){this.showToast('Permission presse-papier refusée');}else{this.showToast('Coller impossible','error');}}},
|
||||
// ── v7.68 : copier/couper une SÉLECTION DE TEXTE MULTI-BLOCS ──
|
||||
// Sélection vivante qui traverse ≥2 blocs → plage ; sinon null (le
|
||||
// navigateur gère tout seul la sélection à l'intérieur d'un bloc).
|
||||
_clipState(){
|
||||
const s=window.getSelection();
|
||||
if(!s||!s.rangeCount||s.isCollapsed)return null;
|
||||
const r=s.getRangeAt(0);
|
||||
const up=n=>n?(n.nodeType===1?n:n.parentElement):null;
|
||||
const se=up(r.startContainer), ee=up(r.endContainer);
|
||||
const sblk=se&&se.closest?se.closest('[data-bid]'):null;
|
||||
const eblk=ee&&ee.closest?ee.closest('[data-bid]'):null;
|
||||
if(!sblk||!eblk)return null;
|
||||
const si=this.getIdx(sblk.dataset.bid), ei=this.getIdx(eblk.dataset.bid);
|
||||
if(si<0||ei<0||si>=ei)return null;
|
||||
return {si:si,ei:ei,
|
||||
startOff:_srcOffset(sblk,r.startContainer,r.startOffset),
|
||||
endOff:_srcOffset(eblk,r.endContainer,r.endOffset)};
|
||||
},
|
||||
// blocs dont la sélection ne couvre qu'une PARTIE (les autres = entiers)
|
||||
_clipHeavy(b){return ['table','code','columns','math','image','embed','divider','meeting','button','table_of_contents'].indexOf(b.type)>=0;},
|
||||
_clipMarkdown(st){
|
||||
const parts=[];
|
||||
for(let i=st.si;i<=st.ei;i++){
|
||||
const b=this.blocks[i];if(!b)continue;
|
||||
if(i===st.si&&i===st.ei)return this._anchorText(b.id,st.startOff,st.endOff);
|
||||
if(i===st.si||i===st.ei){
|
||||
if(this._clipHeavy(b)){parts.push(blocksToMarkdown(b));continue;}
|
||||
const el=this.getEl(b.id);
|
||||
parts.push(i===st.si
|
||||
? this._anchorText(b.id,st.startOff,el?_srcNodeLen(el):0)
|
||||
: this._anchorText(b.id,0,st.endOff));
|
||||
}else parts.push(blocksToMarkdown(b));
|
||||
}
|
||||
return parts.join('\n\n');
|
||||
},
|
||||
// renvoie le markdown copié, ou null si la sélection n'est pas multi-blocs
|
||||
clipCopy(st){st=st||this._clipState();if(!st)return null;this.sync();return this._clipMarkdown(st);},
|
||||
clipCut(st){
|
||||
st=st||this._clipState();if(!st)return null;
|
||||
this.sync();this.pushHistory();
|
||||
const md=this._clipMarkdown(st);
|
||||
const first=this.blocks[st.si],last=this.blocks[st.ei];
|
||||
const lastEl=this.getEl(last.id);
|
||||
if(!this._clipHeavy(first))first.content=this._anchorText(first.id,0,st.startOff);
|
||||
if(!this._clipHeavy(last))last.content=this._anchorText(last.id,st.endOff,lastEl?_srcNodeLen(lastEl):0);
|
||||
this.blocks=this.blocks.slice(0,st.si+1).concat([last],this.blocks.slice(st.ei+1));
|
||||
if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];
|
||||
this.dirty=true;this.autoSave();this.render();_rtSync();
|
||||
this._focusBid(first.id);
|
||||
return md;
|
||||
},
|
||||
_focusedIdx(){const a=this.getActiveBlock();return a?a.idx:-1;},
|
||||
async moveBlockToPage(idx,pageId){if(idx<0||idx>=this.blocks.length||pageId===this.pid)return;this.sync();this.pushHistory();const block=this.blocks[idx];const self=this;const csrf = getCsrf();;this.showToast('Déplacement du bloc…');try{const r=await fetch(`/board/api/pages/${pageId}`,{credentials:'same-origin'});const d=await r.json();let tblocks=[];if(d.content_format==='blocks'&&d.content){try{tblocks=JSON.parse(d.content);}catch(e){tblocks=[];}}tblocks.push(block);await fetch(`/board/api/pages/${pageId}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify({title:d.title||'',blocks:tblocks})});
|
||||
if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();this.showToast('Bloc déplacé');
|
||||
@@ -2695,6 +2787,30 @@ applyAIBlocks(text){
|
||||
else this.showToast((d&&d.detail)||'Réaction impossible','error');
|
||||
}catch(e){this.showToast('Réaction impossible','error');}
|
||||
},
|
||||
// ── v7.67 : les réactions vivent aussi dans le tiroir Comments ──
|
||||
// (une ligne par plage × émoji, déjà groupées par l'API)
|
||||
reactionList(){
|
||||
return (this.reactions||[]).map(r=>{
|
||||
const bid=r.block_id,start=parseInt(r.anchor_start,10),end=parseInt(r.anchor_end,10);
|
||||
return {key:bid+':'+start+':'+end+':'+r.emoji,bid:bid,start:start,end:end,emoji:r.emoji,
|
||||
count:parseInt(r.count,10)||1,mine:!!parseInt(r.mine,10),
|
||||
text:this._anchorText(bid,start,end)};
|
||||
});
|
||||
},
|
||||
// suppression : POST toggle (le backend retire MA ligne si elle existe)
|
||||
removeReaction(r){if(!r)return;this.toggleReaction(r.bid,r.start,r.end,r.emoji);},
|
||||
// texte source d'une plage ancrée (hors pastilles .fd-react-chip)
|
||||
_anchorText(bid,start,end){
|
||||
const el=this.getEl(bid);if(!el||!(end>start))return '';
|
||||
const w=document.createTreeWalker(el,NodeFilter.SHOW_TEXT);let n,pos=0,txt='';
|
||||
while((n=w.nextNode())){
|
||||
if(n.parentElement&&n.parentElement.closest('.fd-react-chip'))continue;
|
||||
const len=n.nodeValue.length,a=Math.max(start,pos),b=Math.min(end,pos+len);
|
||||
if(b>a)txt+=n.nodeValue.slice(a-pos,b-pos);
|
||||
pos+=len;if(pos>=end)break;
|
||||
}
|
||||
return txt;
|
||||
},
|
||||
openThread(cid){
|
||||
this.commentsOpen=true;this.commentMenu=null;
|
||||
const self=this;
|
||||
@@ -2751,7 +2867,13 @@ applyAIBlocks(text){
|
||||
chip.setAttribute('data-react-bid',g.bid);
|
||||
chip.setAttribute('data-react-start',String(g.start));
|
||||
chip.setAttribute('data-react-end',String(g.end));
|
||||
chip.innerHTML=g.items.map(function(i){return '<span class="fd-react-emo">'+_emojiHtml(i.emoji)+'</span>';}).join('')
|
||||
// chaque émoji porte son data-emoji ; `is-mine` = j'ai réagi →
|
||||
// la croix de suppression (CSS ::after au survol) ne s'affiche
|
||||
// que là, un clic sur les autres rouvre le sélecteur.
|
||||
chip.innerHTML=g.items.map(function(i){
|
||||
const q=String(i.emoji||'').replace(/&/g,'&').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>':'');
|
||||
sp.parentNode.insertBefore(chip,sp.nextSibling);
|
||||
});
|
||||
@@ -2942,7 +3064,10 @@ applyAIBlocks(text){
|
||||
const btype=block.type||'paragraph';
|
||||
if(['code','table','math','image','embed','meeting','divider','table_of_contents','columns'].indexOf(btype)>=0)return fallback();
|
||||
const parsed=this.paste2b(text);
|
||||
if(parsed.length<=1)return fallback();
|
||||
if(!parsed.length)return fallback();
|
||||
// un seul bloc PARAGRAPHE = coller une ligne → texte inline (comme avant) ;
|
||||
// un seul bloc structuré (table, code, titre…) = vraie insertion de bloc.
|
||||
if(parsed.length===1&&parsed[0].type==='paragraph')return fallback();
|
||||
const sp=splitCaret(bel);
|
||||
let before='',after='';
|
||||
if(sp){before=sp.before;after=sp.after;}else{before=gt(bel)||'';}
|
||||
@@ -3098,6 +3223,12 @@ applyAIBlocks(text){
|
||||
const bid=chip.getAttribute('data-react-bid');
|
||||
const start=parseInt(chip.getAttribute('data-react-start'),10);
|
||||
const end=parseInt(chip.getAttribute('data-react-end'),10);
|
||||
// v7.67 : clic sur la croix d'un émoji à moi → retire MA réaction
|
||||
const emo=t.closest('.fd-react-emo');
|
||||
if(emo&&emo.classList.contains('is-mine')){
|
||||
d.removeReaction({bid:bid,start:start,end:end,emoji:emo.getAttribute('data-emoji')});
|
||||
return;
|
||||
}
|
||||
const el=d.getEl(bid);const rect=chip.getBoundingClientRect();
|
||||
if(!window.FDIconPicker||!el||isNaN(start)||isNaN(end))return;
|
||||
window.FDIconPicker.openFor({
|
||||
|
||||
@@ -10,8 +10,10 @@ Covers the five roadmap points:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import json
|
||||
import secrets
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
@@ -209,7 +211,27 @@ _OG_HTML = """<!doctype html><html><head>
|
||||
</head><body>hi</body></html>"""
|
||||
|
||||
|
||||
#: IP publique factice — TestOGParser ne doit dépendre ni du réseau HTTP
|
||||
#: (MockTransport) ni du DNS : `_is_public_host` résout l'hôte pour décider si
|
||||
#: l'IP est publique, et cette résolution échoue sous `pytest -n auto` (même
|
||||
#: piège que test_agent_web_tools.stub_dns — d'où un flake récurrent ici).
|
||||
PUBLIC_IP = "93.184.216.34"
|
||||
|
||||
|
||||
class TestOGParser:
|
||||
@pytest.fixture(autouse=True)
|
||||
def stub_dns(self, monkeypatch):
|
||||
real = socket.getaddrinfo
|
||||
|
||||
def fake_getaddrinfo(host, *args, **kwargs):
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (PUBLIC_IP, 0))]
|
||||
return real(host, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
|
||||
|
||||
def test_parse_og_handles_attribute_order(self):
|
||||
from app.services.og_fetcher import parse_og
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ def test_toolbar_markup_matches_spec():
|
||||
"fmtApply('strikeThrough')", "fmtCode()", # ligne 2 : A B I U T Tx
|
||||
"fmtLink()", "fmtHighlight()", "fmtCodeBlock()", "fmtEquation()",
|
||||
"fmtMore($event)", # ligne 3 : 🔗 S </> √x …
|
||||
"fmtComment()", "fmtAskAI()", # ligne 4 : 💬 ✨
|
||||
"fmtComment()", "fmtReact()", # ligne 4 : 💬 Comment | 🙂 réaction
|
||||
"ft-group", "Improve writing", "Proofread", "Explain", "Reformat",
|
||||
"Edit with AI", "Alt+⇧+E", # pied de page
|
||||
]
|
||||
@@ -41,7 +41,23 @@ def test_toolbar_markup_matches_spec():
|
||||
assert "data-tooltip-html=" in chunk
|
||||
# icônes réellement définies dans le jeu d'icônes
|
||||
assert 'fd_icon("message-square",15)' in chunk
|
||||
assert 'fd_icon("sparkles",15)' in chunk and 'fd_icon("link",15)' in chunk
|
||||
assert 'fd_icon("link",15)' in chunk
|
||||
|
||||
|
||||
def test_toolbar_row4_matches_reference_layout():
|
||||
"""v7.67 — ligne Comment à gauche, réaction à droite, Skills avec ≡."""
|
||||
chunk = _slice(TPL, 'class="format-toolbar"', "Copy Link Toast")
|
||||
# ligne 4 = une seule rangée scannée, boutons aux 2 extrémités
|
||||
assert 'ft-row-split' in chunk
|
||||
assert '<span>Comment</span>' in chunk # libellé, pas juste l'icône
|
||||
assert chunk.index("fmtComment()") < chunk.index("fmtReact()")
|
||||
# l'✨ Ask AI a sauté : doublon exact du « Edit with AI » du pied
|
||||
assert '"fmtAskAI()"' not in chunk
|
||||
# entête Skills (≡ en CSS) + liste scrollable
|
||||
assert 'ft-skills-head' in chunk and 'ft-skills' in chunk
|
||||
for sel in (".format-toolbar .ft-row-split {", ".format-toolbar .ft-comment {",
|
||||
".format-toolbar .ft-skills-head::after {", ".format-toolbar .ft-skills {"):
|
||||
assert sel in CSS, sel
|
||||
|
||||
|
||||
# ── Actions : câblées vers de vraies méthodes, sous-menus réutilisés ────────
|
||||
|
||||
@@ -71,9 +71,9 @@ def test_toggle_reaction_api(client):
|
||||
def test_toolbar_has_react_button():
|
||||
assert 'fmtReact()' in TPL
|
||||
assert 'data-tooltip="React to selected text"' in TPL
|
||||
# position : 3ᵉ ligne, après le surlignage, avant le code
|
||||
# position (v7.67) : 4ᵉ ligne, à droite de « Comment », après les outils
|
||||
chunk = TPL[TPL.index('class="format-toolbar"'):TPL.index("Copy Link Toast")]
|
||||
order = ["fmtHighlight()", "fmtReact()", "fmtCodeBlock()", "fmtMore($event)"]
|
||||
order = ["fmtMore($event)", "fmtComment()", "fmtReact()", "ft-group"]
|
||||
positions = [chunk.index(o) for o in order]
|
||||
assert positions == sorted(positions), positions
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,60 @@
|
||||
"""7.68.0 — colonnes/math/TOC rendus + copier-couper-coller multi-blocs."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SRC = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
|
||||
CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _fn(name: str, span: int = 1500) -> str:
|
||||
i = SRC.index(name + "(")
|
||||
return SRC[i:i + span]
|
||||
|
||||
|
||||
def test_renderers_are_actually_dispatched():
|
||||
# les trois renderers existaient mais n'étaient JAMAIS appelés →
|
||||
# colonnes (slash + Turn into), équation et TOC rendaient un paragraphe vide
|
||||
dispatch = _fn("renderBlock")
|
||||
for call in ("renderColumnsBlock(b)", "renderMathBlock(b)", "renderTOC(b)"):
|
||||
assert call in dispatch, call
|
||||
assert SRC.count("function " + call.split("(")[0] + "(") == 1
|
||||
# la suppression d'une colonne n'est plus un bouton invisible
|
||||
assert ".block-column-wrapper:hover .block-actions {" in CSS
|
||||
|
||||
|
||||
def test_gfm_tables_without_leading_pipe():
|
||||
# l'IA rend souvent « A | B » / « --- | --- » — le pipe de tête est optionnel
|
||||
assert "s.indexOf('|')>=0&&_tblSplitLine(s).length>=2" in SRC
|
||||
# une ligne de séparation doit rester ≥2 cellules (sinon « texte » + « --- »)
|
||||
assert "if(cells.length<2)return false;return cells.every" in SRC
|
||||
# md2b ET paste2b passent par les mêmes helpers (alignés)
|
||||
assert SRC.count("_tblIsSep(lines[i+1])") == 2
|
||||
|
||||
|
||||
def test_clipboard_multi_block_selection():
|
||||
# état : sélection vivante traversant ≥2 blocs ; sinon null (natif)
|
||||
for tok in ("_clipState(){", "si>=ei)return null;", "_srcOffset(sblk,r.startContainer,r.startOffset)",
|
||||
"_srcOffset(eblk,r.endContainer,r.endOffset)"):
|
||||
assert tok in SRC, tok
|
||||
# markdown : début partiel → blocs entiers → fin partielle
|
||||
assert "_clipMarkdown(st){" in SRC
|
||||
assert "blocksToMarkdown(b)" in SRC
|
||||
# coupure : premier/trois restent (têtes/queues), milieu supprimé
|
||||
assert "this.blocks.slice(0,st.si+1).concat([last],this.blocks.slice(st.ei+1))" in SRC
|
||||
# Ctrl+C / Ctrl+X : listeners copy/cut (le clavier natif les déclenche)
|
||||
assert "ct.addEventListener('copy'," in SRC
|
||||
assert "ct.addEventListener('cut'," in SRC
|
||||
assert "ev.clipboardData.setData('text/plain',md)" in SRC
|
||||
# menu clic droit : groupe Clipboard Cut/Copy/Paste
|
||||
assert "'<div class=\"bm-group\">Clipboard</div>'" in SRC
|
||||
for act in ("clip-cut", "clip-copy", "clip-paste"):
|
||||
assert f"data-bm-act=\"{act}\"" in SRC or f"'{act}'" in SRC, act
|
||||
# « Copy link to block » : l'act du menu doit être celle du handler
|
||||
assert "_blockMenuItem('copy-link'," in SRC
|
||||
assert "act==='copy-link'" in SRC
|
||||
|
||||
|
||||
def test_paste_single_structured_block_is_inserted():
|
||||
# un seul bloc structuré collé (table, code, titre) ne retombe plus en texte brut
|
||||
assert "if(parsed.length===1&&parsed[0].type==='paragraph')return fallback();" in SRC
|
||||
Reference in New Issue
Block a user