Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cf922106f |
@@ -1,5 +1,60 @@
|
|||||||
# Changelog - FlowDeck
|
# 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
|
## v7.67.0 (2026-10-08) — Réactions dans le tiroir Comments (+ suppression) et refonte du menu de sélection
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+21
@@ -1524,6 +1524,27 @@ Détails livrés :
|
|||||||
`pytest` **1390 passed / 0 failed**, `ruff` OK ; `VERSION` +
|
`pytest` **1390 passed / 0 failed**, `ruff` OK ; `VERSION` +
|
||||||
`app/main.py` = 7.67.0
|
`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)
|
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# WORKLOAD — FlowDeck Notion Clone
|
# WORKLOAD — FlowDeck Notion Clone
|
||||||
|
|
||||||
> **Début**: 2026-07-08 | **Version**: v7.67.0 (réactions dans le tiroir Comments + suppression, refonte du menu de sélection) | **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`
|
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
|
||||||
|
|
||||||
## Avancement Global
|
## Avancement Global
|
||||||
|
|||||||
+1
-1
@@ -186,7 +186,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="7.67.0",
|
version="7.68.0",
|
||||||
docs_url="/docs",
|
docs_url="/docs",
|
||||||
redoc_url="/redoc",
|
redoc_url="/redoc",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "FlowDeck",
|
"title": "FlowDeck",
|
||||||
"version": "7.67.0"
|
"version": "7.68.0"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/auth/register": {
|
"/auth/register": {
|
||||||
|
|||||||
@@ -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%;
|
border-radius: 50%;
|
||||||
line-height: 1;
|
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 {
|
.block-column {
|
||||||
outline: none;
|
outline: none;
|
||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
|
|||||||
@@ -236,8 +236,11 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
|||||||
|
|
||||||
/* ═══════════ Simple Table block (v4.8.0) ═══════════ */
|
/* ═══════════ 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 _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);});}
|
// v7.68 : le pipe de tête est OPTIONNEL en GFM — l'IA rend souvent
|
||||||
function _tblIsData(s){s=(s||'').trim();return /^\|/.test(s)&&s.indexOf('|',1)>=0;}
|
// « 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 _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 _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,'');});}
|
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==='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==='divider')return `<div class="block-wrapper" data-id="${b.id}"><hr class="block-divider"></div>`;
|
||||||
if(b.type==='button')return renderAutomationButton(b,idx);
|
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==='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'){
|
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>`;
|
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==='suggest'){E.suggestEdit(idx);}
|
||||||
else if(act==='ai'){E.askAIBlock(idx);}
|
else if(act==='ai'){E.askAIBlock(idx);}
|
||||||
else if(act==='skill'){E.runSkill(idx,t.getAttribute('data-prompt')||'');}
|
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==='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;}
|
else if(act==='turn-type'){E.turnInto(idx,t.getAttribute('data-type'),parseInt(t.getAttribute('data-cols')||'0',10)||0);_blockMenuClose();return;}
|
||||||
_blockMenuClose();
|
_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 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 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;
|
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">'
|
d.innerHTML='<input class="bm-search" type="text" placeholder="Search actions..." autocomplete="off" spellcheck="false">'
|
||||||
|
+clip
|
||||||
+'<div class="bm-group">Text</div>'
|
+'<div class="bm-group">Text</div>'
|
||||||
+_blockMenuItem('submenu','↩','Turn into',{sub:'turn'})
|
+_blockMenuItem('submenu','↩','Turn into',{sub:'turn'})
|
||||||
+_blockMenuItem('submenu','🎨','Color',{sub:'color'})
|
+_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('duplicate','▣','Duplicate',{shortcut:S.duplicate})
|
||||||
+_blockMenuItem('move-to','↪','Move to…',{shortcut:S.move})
|
+_blockMenuItem('move-to','↪','Move to…',{shortcut:S.move})
|
||||||
+_blockMenuItem('delete','✕','Delete',{shortcut:S.delete,danger:true})
|
+_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){
|
d.querySelectorAll('.bm-item').forEach(function(it){
|
||||||
it.style.display=(q&&it.textContent.toLowerCase().indexOf(q)<0)?'none':'';
|
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){
|
d.addEventListener('mouseover',function(ev){
|
||||||
var it=ev.target&&ev.target.closest?ev.target.closest('[data-sub]'):null;
|
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&&ev.target.closest))return;
|
||||||
if(ev.target.closest('#blockMenu')||ev.target.closest('#blockMenuSub')||ev.target.closest('#fdMoveOverlay'))return;
|
if(ev.target.closest('#blockMenu')||ev.target.closest('#blockMenuSub')||ev.target.closest('#fdMoveOverlay'))return;
|
||||||
if(ev.target.closest('.block-handle'))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();
|
if(_selSet.size)_selClear();
|
||||||
_blockMenuClose();
|
_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;
|
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;}
|
if(e.shiftKey){_selToggle(wid);return;}
|
||||||
_selClear();blockMenuOpen(e.clientX,e.clientY,wid);});
|
_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);
|
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);}
|
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);
|
},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');},
|
_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');}},
|
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');},
|
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;},
|
_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})});
|
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é');
|
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é');
|
||||||
@@ -2972,7 +3064,10 @@ applyAIBlocks(text){
|
|||||||
const btype=block.type||'paragraph';
|
const btype=block.type||'paragraph';
|
||||||
if(['code','table','math','image','embed','meeting','divider','table_of_contents','columns'].indexOf(btype)>=0)return fallback();
|
if(['code','table','math','image','embed','meeting','divider','table_of_contents','columns'].indexOf(btype)>=0)return fallback();
|
||||||
const parsed=this.paste2b(text);
|
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);
|
const sp=splitCaret(bel);
|
||||||
let before='',after='';
|
let before='',after='';
|
||||||
if(sp){before=sp.before;after=sp.after;}else{before=gt(bel)||'';}
|
if(sp){before=sp.before;after=sp.after;}else{before=gt(bel)||'';}
|
||||||
|
|||||||
@@ -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