Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9a776b626 | ||
|
|
674baf6954 | ||
|
|
3d3b46b58b |
@@ -1,5 +1,90 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.69.2 (2026-10-08) — Le favicon est enfin le vrai logo
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Le favicon était un placeholder** : un carré bleu `#2383E2` avec les lettres
|
||||
« FD » écrites en `<text>` SVG — génériques, sans lien avec la marque, et
|
||||
rendus avec la police du visiteur.
|
||||
- → `static/favicon.ico` généré depuis `assets/logo.jpg` : 16/32/48 px dans un
|
||||
seul fichier (un `<link>` pour tous les navigateurs), 4,1 Ko.
|
||||
|
||||
**Pourquoi un fond sombre** : le logo est blanc à ~82 % (mesuré : 50,7 % de
|
||||
blanc pur + 32 % de blancs proches). Posé nu, il disposerait sur un onglet clair
|
||||
— cas nominal de Firefox et Safari en thème clair. Il est donc posé sur un
|
||||
carré `#191919` arrondi, **le même fond que les icônes PWA** générées en v7.69.0,
|
||||
ce qui garde une identité cohérente. Contraste mesuré à 16 px : σ = 106 (contre
|
||||
σ = 49 pour le logo seul — le fond sombre double la lisibilité).
|
||||
|
||||
Mise à jour des **5 templates** qui référençaient l'ancien SVG (`base`,
|
||||
`import`, `landing`, `public_page`, `welcome`) + la liste de pré-cache du
|
||||
service worker. `static/favicon.svg` **supprimé** : plus aucune référence.
|
||||
|
||||
### Tests
|
||||
|
||||
- `test_favicon_is_the_real_logo` (tailles ICO, absence du placeholder) +
|
||||
`test_every_template_points_at_the_real_favicon` (5 templates + `sw.js`).
|
||||
|
||||
## v7.69.1 (2026-10-08) — `og:image` en URL absolue
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`og:image` / `twitter:image` étaient des URL relatives** — les crawlers
|
||||
(LinkedIn, Slack, Discord, iMessage…) ignorent une URL relative : la carte de
|
||||
partage était vide. → global Jinja `app_base_url()` construit à partir de
|
||||
`settings.app_base_url`, **la même source que les liens de partage de pages**
|
||||
(`collaboration.py`) : si elle est mal renseignée, ces liens l'étaient déjà.
|
||||
Lecture paresseuse, comme `_plugin_enabled`, pour éviter l'import circulaire.
|
||||
|
||||
## v7.69.0 (2026-10-08) — La sélection multi-blocs fonctionne enfin (+ identité visuelle)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ctrl+C / Ctrl+X multi-blocs ne faisaient RIEN** — cause racine : le navigateur
|
||||
ne sait pas sérialiser une sélection qui traverse deux `contenteditable` (chaque
|
||||
bloc en a un). Résultat : `getSelection().toString()` renvoie `""` et Chromium
|
||||
**n'émet aucun événement `copy`** — nos handlers `ct.addEventListener('copy')`
|
||||
n'étaient donc jamais atteints, et le raccourci mourait en silence.
|
||||
→ le raccourci est maintenant intercepté au `keydown` (capture sur `document`,
|
||||
garde `closest('#_blocksCt')` pour ne pas voler Ctrl+C ailleurs) : on écrit le
|
||||
markdown dans le presse-papier nous-mêmes. La sélection mono-bloc reste native.
|
||||
- `Ctrl+X` supprime les blocs traversés en conservant la tête du premier et la
|
||||
queue du dernier.
|
||||
- **Glisser la souris d'un bloc à l'autre sélectionnait un seul bloc** — cause
|
||||
racine : la séquence complète est `mouseup` (on reconstruit la plage) → `click`
|
||||
→ `focusin` → **le navigateur efface la sélection**. La plage était donc
|
||||
reconstruite puis détruite dans la foulée. → la plage reconstruite est mise en
|
||||
attente (`_mdSelRange`) et restaurée au tour de boucle suivant, si le focus
|
||||
l'a effectivement effacée. Un nouveau geste (mousedown) remet l'attente à zéro,
|
||||
donc un clic pour placer le curseur reste normal.
|
||||
- **Le clic droit effaçait le surlignage** — la recherche du menu prenait le
|
||||
focus 10 ms après son ouverture, ce qui détruisait la sélection. → on ne vole
|
||||
plus le focus quand un texte est sélectionné. (Les actions Clipboard utilisaient
|
||||
déjà la sélection figée à l'ouverture du menu : `d._clipSt`.)
|
||||
- Le `preventDefault` du clic droit est bien sur `mousedown` (sur `contextmenu`
|
||||
il arrive trop tard — le curseur a déjà déplacé la sélection).
|
||||
|
||||
### Added
|
||||
|
||||
- **Identité visuelle** : la bannière et le logo du projet sont intégrés.
|
||||
- assets optimisés : `static/img/banner.webp` **22,5 Ko** (l'original fait
|
||||
1,6 Mo) + `banner.jpg` de secours, `logo.webp` **5,3 Ko** + `logo-512.jpg` ;
|
||||
originaux conservés dans `assets/` pour régénération.
|
||||
- icônes PWA régénérées à partir du vrai logo (72 → 512 px, marge maskable
|
||||
sur 192/512) + `apple-touch-icon`.
|
||||
- landing : logo dans la barre de navigation, bannière en `<picture>`
|
||||
(WebP + JPEG) avec dimensions déclarées pour éviter tout décalage de mise en
|
||||
page ; `og:image`, `twitter:card` et `meta description` ajoutés au `<head>`.
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v769_selection_persistence.py` (19 tests statiques) +
|
||||
`e2e/v769_selection_persistence.spec.js` (5 tests, **vrais gestes** : glisser
|
||||
de souris, `Ctrl+C`/`Ctrl+X` au clavier, clic droit).
|
||||
- `e2e/v768_columns_clipboard.spec.js` : les gestes simulés (`execCommand`,
|
||||
plage fabriquée en `evaluate`) sont remplacés par de vrais gestes.
|
||||
|
||||
## v7.68.0 (2026-10-08) — Colonnes rendues, tables IA, copier/couper/coller multi-blocs
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1545,6 +1545,42 @@ Détails livrés :
|
||||
`v768_columns_clipboard.spec.js` (4) ; `pytest` **1394 passed / 0 failed**,
|
||||
`ruff` OK ; `VERSION` + `app/main.py` = 7.68.0
|
||||
|
||||
### v7.69.0 — La sélection multi-blocs fonctionne enfin ✅ (2026-10-08)
|
||||
|
||||
**Contexte** : v7.68.0 livrait la mécanique Clipboard (plage multi-blocs, handlers
|
||||
`copy`/`cut`, groupe Clipboard du menu contextuel) mais elle était **inutilisable en
|
||||
pratique** — trois rapports d'usage : Ctrl+C ne copie rien, le glisser souris ne
|
||||
sélectionne qu'un bloc, le clic droit efface le surlignage.
|
||||
|
||||
**Causes racines trouvées (trace d'événements à l'appui, pas de supposition)** :
|
||||
|
||||
| Symptôme | Cause réelle |
|
||||
|----------|--------------|
|
||||
| Ctrl+C/X muet | le navigateur **n'émet aucun événement `copy`** quand la sélection traverse deux `contenteditable` : `getSelection().toString()` renvoie `""`, donc nos handlers n'étaient jamais atteints |
|
||||
| Glisser = 1 bloc | séquence `mouseup` → `click` → `focusin` → **collapse** : la plage reconstruite mourait dans la foulée |
|
||||
| Clic droit efface | `.bm-search` prenait le focus 10 ms après l'ouverture du menu |
|
||||
|
||||
**Corrections** : interception du raccourci au `keydown` (capture `document`,
|
||||
garde `closest('#_blocksCt')`) ; plage reconstruite mise en attente
|
||||
(`_mdSelRange`) et restaurée au tour suivant, remise à zéro à chaque nouveau
|
||||
geste ; le menu ne vole plus le focus quand un texte est sélectionné ;
|
||||
`preventDefault` du clic droit remonté sur `mousedown` (sur `contextmenu` c'est
|
||||
trop tard).
|
||||
|
||||
**Identité visuelle** : bannière + logo intégrés — WebP 22,5 Ko / 5,3 Ko (les
|
||||
originaux faisaient 1,6 Mo / 1,1 Mo), JPEG de secours, icônes PWA régénérées à
|
||||
partir du vrai logo, `<picture>` sur la landing, `og:image`/`twitter:card`.
|
||||
|
||||
**Vérification** : `tests/test_v769_selection_persistence.py` (19 statiques) +
|
||||
`e2e/v769_selection_persistence.spec.js` (5, **vrais gestes**) ; le spec v768 a
|
||||
été corrigé pour remplacer `execCommand` et les plages fabriquées par de vrais
|
||||
gestes.
|
||||
|
||||
**Piège de test relevé au passage** : les `···` dans les échecs Playwright sont
|
||||
le marqueur de troncature du message d'erreur, **pas** un bug produit — une heure
|
||||
passée à chercher une fuite inexistante. Vérifier `JSON.stringify()` de la valeur
|
||||
réelle avant de croire un affichage tronqué.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **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 🔄
|
||||
> **Début**: 2026-07-08 | **Version**: v7.69.2 (sélection multi-blocs fonctionnelle : drag souris + Maj+↓ + Ctrl+C/X, clic droit qui préserve le surlignage ; identité visuelle logo/bannière/favicon)
|
||||
> **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
|
||||
|
||||
@@ -186,7 +186,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.68.0",
|
||||
version="7.69.2",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
@@ -4,10 +4,21 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FlowDeck — {% block title_prefix %}Home{% endblock %}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="icon" href="/static/favicon.ico" sizes="48x48">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<meta name="theme-color" content="#191919">
|
||||
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
|
||||
<!-- v7.69 : identité visuelle (logo + bannière du projet) -->
|
||||
<meta name="description" content="FlowDeck — espace de travail style Notion auto-hébergé, intégré à Gitea/GitHub.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="FlowDeck">
|
||||
<meta property="og:title" content="FlowDeck — votre espace Notion auto-hébergé">
|
||||
<meta property="og:description" content="Éditeur de blocs, Kanban, bases de données et intégration Gitea/GitHub, sur votre propre serveur.">
|
||||
<meta property="og:image" content="{{ app_base_url() }}/static/img/logo-512.jpg">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="FlowDeck — votre espace Notion auto-hébergé">
|
||||
<meta name="twitter:description" content="Éditeur de blocs, Kanban, bases de données et intégration Gitea/GitHub.">
|
||||
<meta name="twitter:image" content="{{ app_base_url() }}/static/img/logo-512.jpg">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="stylesheet" href="/static/css/app.css?v={{ asset_version }}">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Importer — FlowDeck</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="icon" href="/static/favicon.ico" sizes="48x48">
|
||||
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
|
||||
<style>
|
||||
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;--warn:#D9730D;}
|
||||
|
||||
@@ -5,12 +5,23 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{% from '_icons.html' import fd_icon %}
|
||||
<title>FlowDeck — All-in-one workspace</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="icon" href="/static/favicon.ico" sizes="48x48">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<meta name="theme-color" content="#191919">
|
||||
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<!-- v7.69 : identité visuelle — la landing est la page la plus partagée -->
|
||||
<meta name="description" content="FlowDeck — espace de travail style Notion auto-hébergé, intégré à Gitea/GitHub. Éditeur de blocs, Kanban, bases de données.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="FlowDeck">
|
||||
<meta property="og:title" content="FlowDeck — votre espace Notion auto-hébergé">
|
||||
<meta property="og:description" content="Éditeur de blocs, Kanban, bases de données et intégration Gitea/GitHub, sur votre propre serveur.">
|
||||
<meta property="og:image" content="{{ app_base_url() }}/static/img/logo-512.jpg">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="FlowDeck — votre espace Notion auto-hébergé">
|
||||
<meta name="twitter:description" content="Éditeur de blocs, Kanban, bases de données et intégration Gitea/GitHub.">
|
||||
<meta name="twitter:image" content="{{ app_base_url() }}/static/img/logo-512.jpg">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #191919;
|
||||
@@ -112,6 +123,22 @@
|
||||
padding:12px 28px;
|
||||
}
|
||||
|
||||
/* v7.69 : identité visuelle */
|
||||
.brand-mark{border-radius:7px;flex:none;}
|
||||
.hero-visual{
|
||||
max-width:1060px;
|
||||
margin:0 auto 72px;
|
||||
padding:0 32px;
|
||||
}
|
||||
.hero-visual img{
|
||||
width:100%;
|
||||
height:auto;
|
||||
display:block;
|
||||
border-radius:14px;
|
||||
border:1px solid var(--border);
|
||||
box-shadow:0 30px 70px -30px rgba(0,0,0,.5);
|
||||
}
|
||||
|
||||
/* Features grid */
|
||||
.features{
|
||||
display:grid;
|
||||
@@ -148,7 +175,7 @@
|
||||
|
||||
<nav class="landing-nav">
|
||||
<a href="/" class="landing-logo">
|
||||
<span>{{ fd_icon("book",20) }}</span> FlowDeck
|
||||
<img class="brand-mark" src="/static/img/logo.webp" alt="" width="26" height="26"> FlowDeck
|
||||
</a>
|
||||
<div class="landing-nav-actions">
|
||||
<a href="/auth/login?provider=local" class="btn btn-secondary">Log in</a>
|
||||
@@ -169,6 +196,14 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- v7.69 : bannière du projet (asset optimisé : WebP 22 KB, JPEG en secours) -->
|
||||
<div class="hero-visual">
|
||||
<picture>
|
||||
<source srcset="/static/img/banner.webp" type="image/webp">
|
||||
<img src="/static/img/banner.jpg" alt="FlowDeck — éditeur de blocs, Kanban et bases de données" width="2400" height="793" loading="lazy" decoding="async">
|
||||
</picture>
|
||||
</div>
|
||||
|
||||
<div class="features">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">{{ fd_icon("edit",24) }}</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% from '_icons.html' import fd_icon %}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ title }} — FlowDeck</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="icon" href="/static/favicon.ico" sizes="48x48">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #191919;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Bienvenue sur FlowDeck</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="icon" href="/static/favicon.ico" sizes="48x48">
|
||||
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
|
||||
<style>
|
||||
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;}
|
||||
|
||||
@@ -79,6 +79,18 @@ ENV.globals["csp_nonce"] = lambda: CSP_NONCE.get()
|
||||
ENV.globals["csrf_token"] = lambda: CSRF_TOKEN.get()
|
||||
# Plugins activés : conditionne les blocs de UI rendus côté serveur.
|
||||
ENV.globals["plugin_enabled"] = lambda slug: _plugin_enabled(slug)
|
||||
# v7.69 : URL de base absolue pour og:image / twitter:image — les crawlers
|
||||
# ignorent les URL relatives. Lecture paresseuse comme _plugin_enabled
|
||||
# (évite l'import circulaire au chargement du module). Même source que les
|
||||
# liens de partage de pages (collaboration.py) : si elle est mal renseignée,
|
||||
# ces liens le sont déjà.
|
||||
ENV.globals["app_base_url"] = lambda: _app_base_url()
|
||||
|
||||
|
||||
def _app_base_url() -> str:
|
||||
from app.config import settings
|
||||
|
||||
return (settings.app_base_url or "").rstrip("/")
|
||||
|
||||
|
||||
def _plugin_enabled(slug: str) -> bool:
|
||||
|
||||
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "FlowDeck",
|
||||
"version": "7.68.0"
|
||||
"version": "7.69.2"
|
||||
},
|
||||
"paths": {
|
||||
"/auth/register": {
|
||||
|
||||
@@ -50,21 +50,37 @@ async function newPage(page, body) {
|
||||
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);
|
||||
});
|
||||
// VRAIE sélection multi-blocs : glisser la souris du 1er bloc jusqu'au 3e
|
||||
// (le navigateur seul ne franchit jamais deux contenteditable)
|
||||
async function dragAcross(page, last = 2) {
|
||||
const b1 = await page.locator('#_blocksCt [data-bid]').first().boundingBox();
|
||||
const b3 = await page.locator('#_blocksCt [data-bid]').nth(last).boundingBox();
|
||||
// on part du tout début du 1er bloc et on va jusqu'à la fin du dernier
|
||||
await page.mouse.move(b1.x + 2, b1.y + b1.height / 2);
|
||||
await page.mouse.down();
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
await page.mouse.move(b1.x + 2, b1.y + b1.height / 2 + ((b3.y - b1.y) * i) / 8);
|
||||
}
|
||||
await page.mouse.move(b3.x + b3.width - 2, b3.y + b3.height / 2);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
// sélection multi-blocs au CLAVIER : caret en fin de bloc 1, Maj+↓ Maj+↓
|
||||
async function shiftExtend(page) {
|
||||
// VRAI geste : un clic met le caret dans le bloc, End va à sa fin,
|
||||
// Maj+↓ l'étend ensuite dans les blocs suivants.
|
||||
const el = page.locator('#_blocksCt [data-bid]').first();
|
||||
const b = await el.boundingBox();
|
||||
await page.mouse.click(b.x + 2, b.y + b.height / 2);
|
||||
await page.keyboard.press('End');
|
||||
await page.keyboard.press('Shift+ArrowDown');
|
||||
await page.keyboard.press('Shift+ArrowDown');
|
||||
await page.waitForTimeout(80);
|
||||
}
|
||||
|
||||
// état de la sélection côté éditeur
|
||||
const clipState = (page) => page.evaluate(() => window.E._clipState());
|
||||
|
||||
test('colonnes : slash + Turn into 2 colonnes', async ({ page }) => {
|
||||
await login(page);
|
||||
await newPage(page, [{ id: 'b1', type: 'paragraph', content: '' }]);
|
||||
@@ -116,26 +132,29 @@ test('Ctrl+C / Ctrl+X / Ctrl+V sur une sélection multi-blocs', async ({ page })
|
||||
{ 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'));
|
||||
// sélection multi-blocs au CLAVIER (Maj+↓) — geste réel
|
||||
await shiftExtend(page);
|
||||
const st = await clipState(page);
|
||||
expect(st.si).toBe(0);
|
||||
expect(st.ei).toBe(2);
|
||||
// VRAI raccourci clavier (pas execCommand) : c'est lui qui était cassé
|
||||
await page.keyboard.press('Control+c');
|
||||
await page.waitForTimeout(250);
|
||||
const clip = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(clip).toContain('Premier bloc');
|
||||
expect(clip).toContain('## Titre deux'); // le titre sort en markdown
|
||||
expect(clip).toContain('Troisieme bloc');
|
||||
|
||||
// coupe : les 3 blocs deviennent 2 blocs vides (tête + queue)
|
||||
await selectAcross(page);
|
||||
await page.evaluate(() => document.execCommand('cut'));
|
||||
// coupe (raccourci Ctrl+X) : on recouvre les 3 blocs du début à la fin
|
||||
await dragAcross(page);
|
||||
await page.keyboard.press('Control+x');
|
||||
await page.waitForTimeout(300);
|
||||
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 page.keyboard.press('Control+v');
|
||||
await page.waitForTimeout(300);
|
||||
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');
|
||||
@@ -147,8 +166,12 @@ test('clic droit avec une sélection → groupe Clipboard', async ({ 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 } });
|
||||
await dragAcross(page, 1);
|
||||
// la sélection multi-blocs doit SURVIVRE au clic droit (le navigateur
|
||||
// déplacerait sinon le curseur dans le bloc cliqué)
|
||||
expect((await clipState(page)).ei).toBe(1);
|
||||
await page.locator('#_blocksCt [data-bid]').nth(1).click({ button: 'right', position: { x: 30, y: 6 } });
|
||||
expect((await clipState(page)).ei).toBe(1);
|
||||
const menu = page.locator('#blockMenu');
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.locator('[data-bm-act="clip-copy"]')).toBeVisible();
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// v7.69.0 — la sélection multi-blocs SURVIT aux gestes qui l'effaçaient :
|
||||
// 1) glisser la souris d'un bloc à l'autre
|
||||
// 2) Ctrl+C sur cette sélection
|
||||
// 3) clic droit → le menu n'efface plus le surlignage
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
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) {
|
||||
await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } });
|
||||
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=sel ${Date.now()}`, {
|
||||
headers: { 'X-CSRF-Token': csrf },
|
||||
});
|
||||
const { id } = await created.json();
|
||||
if (body) {
|
||||
await page.request.post(`${FD_BASE}/board/api/pages/${id}/blocks`, {
|
||||
headers: { 'X-CSRF-Token': csrf },
|
||||
data: { title: 'sel', blocks: body },
|
||||
});
|
||||
}
|
||||
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
|
||||
}
|
||||
|
||||
const BLOCKS = [
|
||||
{ id: 'b1', type: 'paragraph', content: 'Premier bloc' },
|
||||
{ id: 'b2', type: 'heading_2', content: 'Titre deux' },
|
||||
{ id: 'b3', type: 'paragraph', content: 'Troisieme bloc' },
|
||||
];
|
||||
|
||||
// état natif de la sélection
|
||||
const nativeSel = (page) =>
|
||||
page.evaluate(() => {
|
||||
const s = window.getSelection();
|
||||
return { rc: s.rangeCount, coll: s.isCollapsed, txt: s.toString().slice(0, 40) };
|
||||
});
|
||||
const clipState = (page) => page.evaluate(() => window.E._clipState());
|
||||
|
||||
// VRAI glisser de souris : le navigateur seul ne franchit jamais deux
|
||||
// contenteditable — c'est _buildSelAt qui reconstruit la plage.
|
||||
async function dragAcross(page, last = 2) {
|
||||
const b1 = await page.locator('#_blocksCt [data-bid]').first().boundingBox();
|
||||
const bEnd = await page.locator('#_blocksCt [data-bid]').nth(last).boundingBox();
|
||||
// on part du tout début du 1er bloc et on va jusqu'à la fin du dernier
|
||||
await page.mouse.move(b1.x + 2, b1.y + b1.height / 2);
|
||||
await page.mouse.down();
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
await page.mouse.move(b1.x + 2, b1.y + b1.height / 2 + ((bEnd.y - b1.y) * i) / 8);
|
||||
}
|
||||
await page.mouse.move(bEnd.x + bEnd.width - 2, bEnd.y + bEnd.height / 2);
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(80);
|
||||
}
|
||||
|
||||
test('glisser la souris sur 3 blocs construit une sélection multi-blocs qui survit', async ({ page }) => {
|
||||
await login(page);
|
||||
await newPage(page, BLOCKS);
|
||||
await dragAcross(page);
|
||||
|
||||
// la plage reconstruite ne doit PAS être effacée par le « click » suivant
|
||||
const s = await nativeSel(page);
|
||||
expect(s.coll).toBe(false);
|
||||
expect(s.rc).toBe(1);
|
||||
|
||||
const st = await clipState(page);
|
||||
expect(st.si).toBe(0);
|
||||
expect(st.ei).toBe(2);
|
||||
await page.screenshot({ path: 'shots/v769-drag.png' });
|
||||
});
|
||||
|
||||
// VRAI raccourci clavier (pas execCommand) : c'est ce qui était cassé.
|
||||
// Maj+↓ depuis la fin du bloc 1 → la sélection traverse les 3 blocs.
|
||||
async function keyboardExtend(page) {
|
||||
const b1 = await page.locator('#_blocksCt [data-bid]').first().boundingBox();
|
||||
await page.mouse.click(b1.x + 2, b1.y + b1.height / 2);
|
||||
await page.keyboard.press('End');
|
||||
await page.keyboard.press('Shift+ArrowDown');
|
||||
await page.keyboard.press('Shift+ArrowDown');
|
||||
await page.waitForTimeout(80);
|
||||
}
|
||||
|
||||
test('Ctrl+C copie la sélection multi-blocs en markdown', async ({ page }) => {
|
||||
await login(page);
|
||||
await newPage(page, BLOCKS);
|
||||
await keyboardExtend(page);
|
||||
expect((await clipState(page)).ei).toBe(2);
|
||||
|
||||
await page.keyboard.press('Control+c'); // le vrai raccourci
|
||||
await page.waitForTimeout(250);
|
||||
const clip = await page.evaluate(() => navigator.clipboard.readText());
|
||||
|
||||
expect(clip).toContain('## Titre deux'); // le titre sort en markdown
|
||||
expect(clip).toContain('Troisieme bloc');
|
||||
|
||||
// Ctrl+X retire les blocs traversés (tête + queue restent)
|
||||
await page.keyboard.press('Control+x');
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.locator('#_blocksCt [data-bid]')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test("clic droit : le menu s'ouvre SANS effacer le surlignage", async ({ page }) => {
|
||||
await login(page);
|
||||
await newPage(page, BLOCKS);
|
||||
await dragAcross(page);
|
||||
expect((await nativeSel(page)).coll).toBe(false);
|
||||
|
||||
await page.locator('#_blocksCt [data-bid]').nth(1).click({ button: 'right', position: { x: 30, y: 6 } });
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
// le surlignage reste visible
|
||||
const s = await nativeSel(page);
|
||||
expect(s.coll).toBe(false);
|
||||
expect(s.txt.length).toBeGreaterThan(0);
|
||||
|
||||
// …et le groupe Clipboard est bien proposé
|
||||
const menu = page.locator('#blockMenu');
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu).toContainText('Clipboard');
|
||||
await expect(menu.locator('[data-bm-act="clip-copy"]')).toBeVisible();
|
||||
await page.screenshot({ path: 'shots/v769-rightclick.png' });
|
||||
});
|
||||
|
||||
test('clic droit → Copy via le menu colle bien le markdown figé', async ({ page }) => {
|
||||
await login(page);
|
||||
await newPage(page, BLOCKS);
|
||||
await dragAcross(page);
|
||||
await page.locator('#_blocksCt [data-bid]').nth(1).click({ button: 'right', position: { x: 30, y: 6 } });
|
||||
const menu = page.locator('#blockMenu');
|
||||
await expect(menu.locator('[data-bm-act="clip-copy"]')).toBeVisible();
|
||||
await menu.locator('[data-bm-act="clip-copy"]').click();
|
||||
await page.waitForTimeout(200);
|
||||
const clip = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(clip).toContain('Premier bloc');
|
||||
expect(clip).toContain('## Titre deux');
|
||||
});
|
||||
|
||||
test('un clic simple dans un bloc place quand même le curseur (pas de sélection fantôme)', async ({ page }) => {
|
||||
await login(page);
|
||||
await newPage(page, BLOCKS);
|
||||
await dragAcross(page);
|
||||
// le clic suivant est un NOUVEL geste : il doit effacer la sélection
|
||||
const b1 = await page.locator('#_blocksCt [data-bid]').first().boundingBox();
|
||||
await page.mouse.click(b1.x + 15, b1.y + b1.height / 2);
|
||||
await page.waitForTimeout(120);
|
||||
const st = await clipState(page);
|
||||
expect(st).toBeNull();
|
||||
});
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#2383E2"/>
|
||||
<text x="16" y="22" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#fff">FD</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 253 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 999 B After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 624 B After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 767 B After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
@@ -163,6 +163,15 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
}
|
||||
return t;
|
||||
}
|
||||
// v7.69 : position de caret à des coordonnées — la sélection native ne
|
||||
// franchit pas les blocs (chacun a son contenteditable), on la rebâtit.
|
||||
function _caretAt(x,y){
|
||||
if(document.caretPositionFromPoint){const q=document.caretPositionFromPoint(x,y);
|
||||
if(q&&q.offsetNode){const r=document.createRange();r.setStart(q.offsetNode,q.offset||0);r.collapse(true);return r;}}
|
||||
if(document.caretRangeFromPoint)return document.caretRangeFromPoint(x,y);
|
||||
return null;
|
||||
}
|
||||
function _blkOf(n){let e=n&&(n.nodeType===1?n:n.parentElement);return e&&e.closest?e.closest('[data-bid]'):null;}
|
||||
function _emojiHtml(v){
|
||||
const s=String(v||'');
|
||||
if(/^(https?:|\/)/.test(s))return '<img src="'+esc(s)+'" alt="">';
|
||||
@@ -1121,6 +1130,8 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
|
||||
// ═══════════ v5.10.0 — Interactions de bloc : sélection multiple, menu ⋮⋮, undo/redo ═══════════
|
||||
let _selSet=new Set();
|
||||
var _mdSelDown=null; // v7.69 : point de départ du glisser de sélection
|
||||
var _mdSelRange=null; // v7.69 : plage reconstruite, à restaurer après le collapse du focus
|
||||
function _selHas(bid){return _selSet.has(bid);}
|
||||
function _selToggle(bid){if(!bid)return;if(_selSet.has(bid))_selSet.delete(bid);else _selSet.add(bid);
|
||||
document.querySelectorAll('.block-wrapper[data-id="'+bid+'"]').forEach(function(w){w.classList.toggle('selected',_selSet.has(bid));});}
|
||||
@@ -1294,7 +1305,13 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
x=Math.min(Math.max(8,x),window.innerWidth-r.width-8);
|
||||
y=Math.min(Math.max(8,y),window.innerHeight-r.height-8);
|
||||
d.style.left=x+'px';d.style.top=y+'px';
|
||||
var si=d.querySelector('.bm-search');if(si)setTimeout(function(){si.focus();},10);
|
||||
// v7.69 : on ne vole PAS le focus quand un texte est sélectionné — sinon
|
||||
// le navigateur efface le surlignage dès que le menu apparaît (les
|
||||
// actions Clipboard gardent de toute façon la sélection figée ci-dessus).
|
||||
var si=d.querySelector('.bm-search');
|
||||
var sLive=window.getSelection();
|
||||
var hasSel=sLive&&sLive.rangeCount&&!sLive.isCollapsed;
|
||||
if(si&&!hasSel)setTimeout(function(){si.focus();},10);
|
||||
}
|
||||
// Panneau de droite : coquille commune des 3 sous-menus
|
||||
function _blockSubShell(x,y){
|
||||
@@ -1503,8 +1520,51 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
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');});
|
||||
ct.addEventListener('copy',function(ev){const E=window.E;if(!E||!E._clipState)return;const st=E._clipState();if(!st||st.si>=st.ei)return;const md=E.clipCopy(st);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._clipState)return;const st=E._clipState();if(!st||st.si>=st.ei)return;const md=E.clipCut(st);if(md==null)return;ev.preventDefault();ev.clipboardData.setData('text/plain',md);E.showToast('Sélection coupée');});
|
||||
// v7.69 : Ctrl+C/X sur une sélection MULTI-BLOCS. Le navigateur ne
|
||||
// sait pas sérialiser une sélection qui traverse deux contenteditable
|
||||
// : il n'émet AUCUN événement copy et le raccourci ne fait rien. On
|
||||
// prend donc le raccourci à la main. (Un menu contextuel ne peut pas
|
||||
// faire confiance à getSelection(), d'où d._clipSt figé à l'ouverture.)
|
||||
document.addEventListener('keydown',function(ev){
|
||||
if(!(ev.ctrlKey||ev.metaKey))return;
|
||||
const k=(ev.key||'').toLowerCase();
|
||||
if(k!=='c'&&k!=='x')return;
|
||||
if(!ev.target||!ev.target.closest||!ev.target.closest('#_blocksCt'))return; // pas dans la recherche d'un menu
|
||||
const E=window.E;if(!E||!E._clipState)return;
|
||||
const st=E._clipState();if(!st||st.si>=st.ei)return; // sélection mono-bloc → natif
|
||||
const md=(k==='x'?E.clipCut(st):E.clipCopy(st));
|
||||
if(md==null)return;
|
||||
ev.preventDefault();ev.stopPropagation();
|
||||
const done=()=>E.showToast(k==='x'?'Sélection coupée':'Sélection copiée');
|
||||
if(navigator.clipboard&&navigator.clipboard.writeText)navigator.clipboard.writeText(md).then(done).catch(()=>{});else done();
|
||||
},true);
|
||||
// v7.69 : glisser de sélection entre blocs + clic droit préservant la sélection
|
||||
ct.addEventListener('mousedown',function(ev){
|
||||
_mdSelRange=null; // un nouveau geste remet à zéro la sélection en attente
|
||||
if(ev.button===2){const s=window.getSelection();if(s&&s.rangeCount&&!s.isCollapsed)ev.preventDefault();return;}
|
||||
if(ev.button===0)_mdSelDown={x:ev.clientX,y:ev.clientY};
|
||||
},true);
|
||||
// en CAPTURE : SortableJS (initié sur ce même conteneur) peut
|
||||
// stopPropagation en fin de glisser → le bubble n'atteint plus document
|
||||
document.addEventListener('mouseup',function(ev){
|
||||
const d=_mdSelDown;_mdSelDown=null;
|
||||
if(!d||ev.button!==0)return;
|
||||
if(Math.abs(ev.clientX-d.x)<4&&Math.abs(ev.clientY-d.y)<4)return;
|
||||
const E=window.E;if(E&&E._buildSelAt)E._buildSelAt(d.x,d.y,ev.clientX,ev.clientY);
|
||||
},true);
|
||||
// v7.69 : le « click » d'un contenteditable efface toute sélection
|
||||
// existante (focusin → collapse). On la remet juste après.
|
||||
document.addEventListener('focusin',function(){
|
||||
if(!_mdSelRange)return;
|
||||
var r=_mdSelRange;_mdSelRange=null;
|
||||
setTimeout(function(){
|
||||
var s=window.getSelection();
|
||||
if(s&&s.rangeCount&&!s.isCollapsed)return; // survécue : rien à faire
|
||||
try{s.removeAllRanges();s.addRange(r);}catch(e){}
|
||||
},0);
|
||||
},true);
|
||||
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);
|
||||
@@ -2002,6 +2062,45 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
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(){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.69 : glisser la souris de bloc A vers bloc B — le navigateur ne
|
||||
// sélectionne jamais à travers deux contenteditable, on reconstruit la
|
||||
// plage au relâchement avec les carets sous le pointeur.
|
||||
_buildSelAt(x0,y0,x1,y1){
|
||||
const r0=_caretAt(x0,y0),r1=_caretAt(x1,y1);
|
||||
if(!r0||!r1)return;
|
||||
const b0=_blkOf(r0.startContainer),b1=_blkOf(r1.startContainer);
|
||||
if(!b0||!b1||b0===b1)return; // même bloc → la sélection native suffit
|
||||
const a=document.createRange();
|
||||
if(r0.compareBoundaryPoints(Range.START_TO_START,r1)<=0){
|
||||
a.setStart(r0.startContainer,r0.startOffset);a.setEnd(r1.startContainer,r1.startOffset);
|
||||
}else{
|
||||
a.setStart(r1.startContainer,r1.startOffset);a.setEnd(r0.startContainer,r0.startOffset);
|
||||
}
|
||||
if(a.collapsed)return;
|
||||
const s=window.getSelection();s.removeAllRanges();s.addRange(a);
|
||||
_mdSelRange=a; // le « click » suivant va effacer ça : on garde la copie
|
||||
},
|
||||
// v7.69 : Maj+↓ / Maj+↑ au début/fin d'un bloc → la sélection saute
|
||||
// dans le bloc voisin (extension native impossible : blocs séparés).
|
||||
_shiftBlockExtend(dir){
|
||||
// le bloc de départ est celui du BOUT MOBILE de la sélection (le
|
||||
// focus), pas l'élément focalisé : après une première extension le
|
||||
// caret vit déjà dans le bloc voisin.
|
||||
const s=window.getSelection();if(!s||!s.rangeCount)return false;
|
||||
const fn=s.focusNode;if(!fn)return false;
|
||||
const sbl=_blkOf(fn);if(!sbl)return false;
|
||||
const si=this.getIdx(sbl.dataset.bid);if(si<0)return false;
|
||||
const off=_srcOffset(sbl,fn,s.focusOffset),len=_srcNodeLen(sbl);
|
||||
if(dir>0&&off<len)return false; // le navigateur étend encore dans le bloc
|
||||
if(dir<0&&off>0)return false;
|
||||
const nb=this.blocks[si+dir];if(!nb)return false;
|
||||
const nel=this.getEl(nb.id);if(!nel)return false;
|
||||
const r=s.getRangeAt(0).cloneRange();
|
||||
if(dir>0)r.setEnd(nel,nel.childNodes.length);else r.setStart(nel,0);
|
||||
if(r.collapsed)return false;
|
||||
s.removeAllRanges();s.addRange(r);
|
||||
return true;
|
||||
},
|
||||
// ── 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).
|
||||
@@ -2015,7 +2114,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
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;
|
||||
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)};
|
||||
@@ -2043,6 +2142,13 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
st=st||this._clipState();if(!st)return null;
|
||||
this.sync();this.pushHistory();
|
||||
const md=this._clipMarkdown(st);
|
||||
if(st.si===st.ei){
|
||||
const b=this.blocks[st.si],bel=this.getEl(b.id);
|
||||
b.content=this._anchorText(b.id,0,st.startOff)+this._anchorText(b.id,st.endOff,bel?_srcNodeLen(bel):0);
|
||||
this.dirty=true;this.autoSave();this.render();_rtSync();
|
||||
this._focusBid(b.id);
|
||||
return md;
|
||||
}
|
||||
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);
|
||||
@@ -2510,6 +2616,11 @@ applyAIBlocks(text){
|
||||
}
|
||||
return;
|
||||
}
|
||||
// v7.69 : Maj+↓ / Maj+↑ franchit le bloc (les blocs sont des
|
||||
// contenteditable distincts : le navigateur ne peut pas étendre)
|
||||
if(e.shiftKey&&(e.key==='ArrowDown'||e.key==='ArrowUp')){
|
||||
if(this._shiftBlockExtend(e.key==='ArrowDown'?1:-1)){e.preventDefault();return;}
|
||||
}
|
||||
if(window.AIAC&&window.AIAC.active){
|
||||
if(e.key==='Tab'){e.preventDefault();e.stopPropagation();window.AIAC.accept();return;}
|
||||
if(e.key==='Escape'){e.preventDefault();window.AIAC.clear();return;}
|
||||
|
||||
@@ -30,7 +30,7 @@ const PRECACHE_URLS = [
|
||||
'/static/js/katex.min.js?v=0.16.11',
|
||||
'/static/js/prism.min.js',
|
||||
// Icônes & métadonnées
|
||||
'/static/favicon.svg',
|
||||
'/static/favicon.ico',
|
||||
'/static/manifest.json',
|
||||
'/static/icons/icon-192x192.png',
|
||||
'/static/icons/icon-512x512.png',
|
||||
|
||||
@@ -34,7 +34,8 @@ def test_gfm_tables_without_leading_pipe():
|
||||
|
||||
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)",
|
||||
for tok in ("_clipState(){", "!s||!s.rangeCount||s.isCollapsed)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
|
||||
@@ -42,10 +43,15 @@ def test_clipboard_multi_block_selection():
|
||||
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)
|
||||
# copy/cut restent branchés (menu contextuel, execCommand)…
|
||||
assert "ct.addEventListener('copy'," in SRC
|
||||
assert "ct.addEventListener('cut'," in SRC
|
||||
assert "ev.clipboardData.setData('text/plain',md)" in SRC
|
||||
# …mais le VRAI raccourci clavier est intercepté au keydown : le navigateur
|
||||
# n'émet aucun événement copy pour une sélection à travers deux
|
||||
# contenteditable (v7.69).
|
||||
assert "document.addEventListener('keydown'," in SRC
|
||||
assert "if(k!=='c'&&k!=='x')return;" 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"):
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""v7.69.0 — sélection multi-blocs qui survit aux gestes + identité visuelle.
|
||||
|
||||
Trois bugs d'éditeur, une cause chacune :
|
||||
1. Ctrl+C multi-blocs : le navigateur ne sérialise PAS une sélection qui
|
||||
traverse deux contenteditable → il n'émet aucun événement copy → le
|
||||
raccourci ne fait rien. On l'intercepte au keydown.
|
||||
2. Glisser la souris : le « click » du contenteditable efface la sélection
|
||||
juste après qu'on l'a reconstruite (mouseup → click → focusin → collapse).
|
||||
3. Clic droit : la recherche du menu prenait le focus et effaçait le surlignage.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
JS = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _norm(src: str) -> str:
|
||||
return "".join(src.split())
|
||||
|
||||
|
||||
# ── 1. Ctrl+C / Ctrl+X multi-blocs : interception clavier ──
|
||||
|
||||
|
||||
class TestKeyboardShortcut:
|
||||
def test_ctrl_shortcut_is_bound_on_document(self):
|
||||
# capture sur document : le focus peut être n'importe où dans l'éditeur
|
||||
assert _norm("document.addEventListener('keydown',function(ev){") in _norm(JS)
|
||||
|
||||
def test_shortcut_handles_both_c_and_x(self):
|
||||
assert _norm("if(k!=='c'&&k!=='x')return;") in _norm(JS)
|
||||
|
||||
def test_shortcut_guards_outside_the_editor(self):
|
||||
# ne doit JAMAIS voler Ctrl+C quand le focus est dans un champ du menu
|
||||
assert "closest('#_blocksCt')" in JS
|
||||
|
||||
def test_shortcut_falls_back_to_native_for_single_block(self):
|
||||
# sélection mono-bloc → on laisse le navigateur faire son travail
|
||||
assert _norm("const st=E._clipState();if(!st||st.si>=st.ei)return;") in _norm(JS)
|
||||
|
||||
def test_shortcut_writes_to_clipboard_and_prevents_default(self):
|
||||
assert _norm("ev.preventDefault();ev.stopPropagation();") in _norm(JS)
|
||||
assert "navigator.clipboard.writeText(md)" in JS
|
||||
|
||||
def test_shortcut_is_registered_next_to_the_copy_listeners(self):
|
||||
# le handler clavier vit au même endroit que copy/cut (bloc ct de v7.68)
|
||||
cut = JS.index("ct.addEventListener('cut'")
|
||||
shortcut = JS.index("closest('#_blocksCt')")
|
||||
assert cut < shortcut < JS.index("ct.addEventListener('mousedown'")
|
||||
|
||||
|
||||
# ── 2. La sélection reconstruite survit au collapse du focus ──
|
||||
|
||||
|
||||
class TestSelectionPersistence:
|
||||
def test_pending_range_variable_exists(self):
|
||||
assert "var _mdSelRange=null;" in JS
|
||||
|
||||
def test_build_sel_at_stashes_the_range(self):
|
||||
# sans cette copie, le « click » suivant détruit la sélection
|
||||
assert _norm("s.removeAllRanges();s.addRange(a);") in _norm(JS)
|
||||
assert _norm("_mdSelRange=a;") in _norm(JS)
|
||||
|
||||
def test_new_gesture_clears_the_pending_range(self):
|
||||
# un clic ultérieur doit placer un caret normal, pas ressusciter l'ancien
|
||||
assert _norm("_mdSelRange=null;") in _norm(JS)
|
||||
|
||||
def test_focusin_restores_after_the_synchronous_collapse(self):
|
||||
# focusin se déclenche AVANT le collapse : la restauration doit être
|
||||
# reportée au tour de boucle suivant (setTimeout 0)
|
||||
assert "document.addEventListener('focusin'" in JS
|
||||
restore = JS[JS.index("document.addEventListener('focusin'") :]
|
||||
assert "setTimeout(function(){" in restore.split("document.addEventListener('mousedown'")[0]
|
||||
assert "s.addRange(r)" in restore
|
||||
|
||||
def test_restore_is_a_noop_when_the_selection_survived(self):
|
||||
assert _norm("if(s&&s.rangeCount&&!s.isCollapsed)return;") in _norm(JS)
|
||||
|
||||
|
||||
# ── 3. Le menu contextuel n'efface plus le surlignage ──
|
||||
|
||||
|
||||
class TestContextMenuKeepsHighlight:
|
||||
def test_search_field_only_steals_focus_without_a_selection(self):
|
||||
assert _norm("if(si&&!hasSel)setTimeout(function(){si.focus();},10);") in _norm(JS)
|
||||
|
||||
def test_right_click_prevents_default_to_freeze_the_selection(self):
|
||||
# le preventDefault doit être sur mousedown (contextmenu arrive trop tard)
|
||||
assert _norm("if(ev.button===2){const s=window.getSelection();if(s&&s.rangeCount&&!s.isCollapsed)ev.preventDefault();return;}") in _norm(JS)
|
||||
|
||||
def test_menu_freezes_the_clipboard_state_at_open_time(self):
|
||||
# getSelection() n'est plus fiable une fois le menu ouvert
|
||||
assert _norm("var clipSt=(E._clipState?E._clipState():null);") in _norm(JS)
|
||||
assert "d._clipSt=clipSt;" in JS
|
||||
|
||||
|
||||
# ── 4. Identité visuelle (logo + bannière du projet) ──
|
||||
|
||||
|
||||
class TestBranding:
|
||||
def test_banner_and_logo_assets_exist_and_are_optimised(self):
|
||||
img = ROOT / "static" / "img"
|
||||
banner, logo = img / "banner.webp", img / "logo.webp"
|
||||
assert banner.exists() and logo.exists()
|
||||
# les originaux pèsent des Mo : les assets servis doivent rester légers
|
||||
assert banner.stat().st_size < 200_000
|
||||
assert logo.stat().st_size < 100_000
|
||||
# un JPEG de secours existe pour les crawlers qui n'aiment pas le WebP
|
||||
assert (img / "banner.jpg").exists()
|
||||
assert (img / "logo-512.jpg").exists()
|
||||
|
||||
def test_source_images_are_kept_for_regeneration(self):
|
||||
src = ROOT / "assets"
|
||||
assert (src / "banner.jpg").exists()
|
||||
assert (src / "logo.jpg").exists()
|
||||
|
||||
def test_pwa_icons_generated_from_the_real_logo(self):
|
||||
icons = ROOT / "static" / "icons"
|
||||
for size in (72, 96, 128, 144, 152, 192, 384, 512):
|
||||
f = icons / f"icon-{size}x{size}.png"
|
||||
assert f.exists(), f"icône {size} manquante"
|
||||
assert (icons / "apple-touch-icon.png").exists()
|
||||
|
||||
def test_base_head_declares_open_graph(self):
|
||||
base = (ROOT / "app" / "templates" / "base.html").read_text(encoding="utf-8")
|
||||
# URL ABSOLUE : les crawlers ignorent les URL relatives
|
||||
assert 'content="{{ app_base_url() }}/static/img/logo-512.jpg"' in base
|
||||
assert 'name="twitter:card" content="summary_large_image"' in base
|
||||
assert '<meta name="description"' in base
|
||||
|
||||
def test_og_image_url_is_absolute_via_base_url(self):
|
||||
# le global expose la même source que les liens de partage de pages
|
||||
templating = (ROOT / "app" / "templating.py").read_text(encoding="utf-8")
|
||||
assert 'ENV.globals["app_base_url"]' in templating
|
||||
for tpl in ("base.html", "landing.html"):
|
||||
src = (ROOT / "app" / "templates" / tpl).read_text(encoding="utf-8")
|
||||
assert 'content="{{ app_base_url() }}/static/img/logo-512.jpg"' in src, tpl
|
||||
# forme RELATIVE interdite (le guillemet avant la barre est ce qui
|
||||
# distingue : la bonne valeur contient l'ancienne en sous-chaîne)
|
||||
assert 'content="/static/img/' not in src, tpl
|
||||
|
||||
def test_favicon_is_the_real_logo(self):
|
||||
"""L'ancien favicon était un placeholder « FD » en SVG — pas la marque."""
|
||||
ico = ROOT / "static" / "favicon.ico"
|
||||
assert ico.exists()
|
||||
assert ico.stat().st_size < 20_000 # un favicon pèse des Ko, pas des Mo
|
||||
# 16/32/48 : la taille de l'onglet ET le repli rétro
|
||||
from PIL import Image
|
||||
|
||||
assert Image.open(ico).info.get("sizes") >= {(16, 16), (32, 32), (48, 48)}
|
||||
# le placeholder SVG doit avoir disparu
|
||||
assert not (ROOT / "static" / "favicon.svg").exists()
|
||||
|
||||
def test_every_template_points_at_the_real_favicon(self):
|
||||
for tpl in ("base.html", "import.html", "landing.html", "public_page.html", "welcome.html"):
|
||||
src = (ROOT / "app" / "templates" / tpl).read_text(encoding="utf-8")
|
||||
assert 'rel="icon" href="/static/favicon.ico"' in src, tpl
|
||||
assert "favicon.svg" not in src, tpl
|
||||
# le service worker pré-met en cache la bonne URL
|
||||
sw = (ROOT / "static" / "sw.js").read_text(encoding="utf-8")
|
||||
assert "'/static/favicon.ico'," in sw
|
||||
assert "favicon.svg" not in sw
|
||||
|
||||
def test_landing_shows_logo_and_banner(self):
|
||||
landing = (ROOT / "app" / "templates" / "landing.html").read_text(encoding="utf-8")
|
||||
# le logo remplace l'icône générique dans la barre de navigation
|
||||
assert 'class="brand-mark" src="/static/img/logo.webp"' in landing
|
||||
# la bannière est servie en WebP avec secours JPEG
|
||||
assert '<source srcset="/static/img/banner.webp" type="image/webp">' in landing
|
||||
assert 'src="/static/img/banner.jpg"' in landing
|
||||
# l'image ne doit pas déclencher de refont : dimensions déclarées
|
||||
assert 'width="2400" height="793"' in landing
|
||||