Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a6445ca4e |
@@ -1,5 +1,68 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.62.0 (2026-10-08) — Menu contextuel au highlight de texte (toolbar façon Notion)
|
||||
|
||||
### Added
|
||||
|
||||
- **Toolbar de sélection** — dès qu'un texte est surligné dans l'éditeur, un menu
|
||||
flottant apparaît **sous la sélection** (au-dessus si manque de place), centré et
|
||||
clampé au viewport, en 4 lignes comme dans Notion :
|
||||
1. **Style de texte** — `T` + libellé **dynamique** du type de bloc (Normal Text,
|
||||
Heading 1… ; le type courant) + `›` → ouvre le sous-menu **Turn into** existant
|
||||
(coche sur le type actuel, fermeture au survol sortant).
|
||||
2. **A** (sous-menu Color : texte/fond + dernière couleur), **B** gras, *I* italique,
|
||||
**U** souligné, **T** barré, **Tx** code inline.
|
||||
3. 🔗 lien, **S** surlignage (`<mark>`), `</>` bloc code, **√x** équation inline
|
||||
(prompt LaTeX → KaTeX), **…** → menu contextuel de bloc (recherche + 10 actions +
|
||||
pied « Last edited by … / N words, M characters »).
|
||||
4. 💬 Comment (`Ctrl+⇧+M`), ✨ Ask AI (`Ctrl+J`).
|
||||
puis la section grise **Skills** (Improve writing, Proofread, Explain, Reformat →
|
||||
`runSkill` sur le bloc) et le pied **Edit with AI** `Alt+⇧+E`.
|
||||
- **Info-bulle claire à droite** des éléments du menu — extension du système de
|
||||
tooltip existant (`app.js`) : `data-tooltip-html` (ex. en italique + description
|
||||
pour la ligne 1) + classe `.tooltip-light`, position à droite de l'élément (débord
|
||||
→ à gauche), sous l'élément hors toolbar.
|
||||
- **Persistance du formatage inline** : nouveau sérialiseur **`gtMd()`** (DOM formaté
|
||||
→ source markdown) appelé par `sync()` (blocs + enfants toggle/columns) — le
|
||||
formatage posé ressort en `**gras**`, `*ital*`, `__souligné__`, `~~barré~~`,
|
||||
`` `code` ``, `==surlignage==`, `[texte](url)` ; les chips wiki gardent leur token.
|
||||
`mdEsc()` rend en plus `__…__` (`<u>`), `==…==` (`<mark>`) et `$$…$$`
|
||||
(équation inline KaTeX, `data-tex` conservé pour l'aller-retour).
|
||||
- **Fermeture propre** : clic hors sélection → le menu se referme ; clic **dans** la
|
||||
toolbar → sélection et menu intacts (`@mousedown.prevent` + garde `mouseup`).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`askAI(idx)` était écrasé par `askAI()`** (même clé dans l'objet éditeur, définie
|
||||
plus bas) : « Ask AI » du menu de bloc et `Ctrl+J` ouvraient l'IA de **page** au
|
||||
lieu du composeur du bloc → renommé **`askAIBlock(idx)`** (appels : menu de bloc,
|
||||
`Ctrl+J`, `Alt+⇧+E`).
|
||||
- **`ed.__x` introuvable sous Alpine v3** — le data de `.page-editor-wrapper` était
|
||||
lu via `ed.__x` (API Alpine v2, plus exposée depuis le bundle CSP) : `showFmt`,
|
||||
`hideCommentBtn` et les handlers de mentions ne s'exécutaient **jamais** → la
|
||||
toolbar de sélection n'apparaissait pas. Accès unifié via `_xed()`
|
||||
(`_x_dataStack[0]`, repli `__x`), 4 sites éditeur.
|
||||
- **Ancre de sélection** — `closest('[data-bid]')` partait du parent de l'ancre :
|
||||
quand l'ancre est un nœud **élément** (sélection programmée, double-clic), le bloc
|
||||
n'était jamais trouvé → `showFmt` pas appelé. Gestion des 2 types de nœud.
|
||||
- **Barre d'espace = exception** — `checkMd()` recevait l'**id** du bloc au lieu de
|
||||
l'index : chaque espace levait `Cannot read properties of undefined (reading 'id')`
|
||||
et les raccourcis markdown (`# `, `- `) ne se déclenchaient jamais.
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v762_selection_toolbar.py` : ordre des 4 lignes + Skills + pied,
|
||||
câblage réel des actions, persistance `gtMd`/`mdEsc` (**roundtrip réel sous node**),
|
||||
placement/fermeture, styles ; `tests/test_v761_block_menu.py` adapté
|
||||
(`askAIBlock`).
|
||||
- **e2e Playwright** `e2e/v762_selection_toolbar.spec.js` (instance 8081) : structure
|
||||
des 4 lignes, sous-menu Turn into (coche du type courant), menu « … » (recherche +
|
||||
pied), géométrie (sous la sélection, dans le viewport), **persistance du gras et du
|
||||
surlignage après reload**, fermeture hors sélection → 1 passed (captures dans
|
||||
`e2e/shots/` — répertoire gitignoré).
|
||||
- **Suite complète** : `pytest tests/ -q -n auto` → **1368 passed / 0 failed**,
|
||||
`ruff check app tests` → All checks passed.
|
||||
|
||||
## v7.61.0 (2026-10-08) — Menu contextuel de bloc refondu façon Notion
|
||||
|
||||
### Added
|
||||
|
||||
+11
@@ -1408,6 +1408,17 @@ Détails livrés :
|
||||
- [x] **Tests follow-ups** — `tests/test_v73_wiki_polish.py` (58 → **72 tests**) : sidebar cross-workspace, page teamspace (owner 200 / outsider 404 API), auto-follow comment, notif throttlée, unfurl gitea/github + endpoint, KPI + dashboards multi-DB
|
||||
- [x] **Suite complète** — `python -m pytest -n auto` → **1016 passed** (était 981 passed / 21 failed en SSO)
|
||||
|
||||
### v7.62.0 — Menu contextuel au highlight de texte ✅ (2026-10-08)
|
||||
> **Objectif** : menu façon Notion (mode sombre) au surlignage de texte dans l'éditeur. **COMPLETED**.
|
||||
|
||||
- [x] **Toolbar de sélection 4 lignes** — `T` + type de bloc dynamique + `›` (sous-menu Turn into existant) / `A` `B` `I` `U` `T` barré `Tx` / 🔗 `S` `</>` `√x` `…` / 💬 `✨`, section grise **Skills** (Improve writing, Proofread, Explain, Reformat → `runSkill`) et pied **Edit with AI** `Alt+⇧+E` — affichée **sous** la sélection (au-dessus si pas de place), clampée au viewport, fermeture au clic hors sélection
|
||||
- [x] **`…` → menu contextuel de bloc** déjà existant (recherche « Search actions… », groupe Text, 10 actions + raccourcis, pied métadonnées auteur/heure/taille) — pas de doublon de menu
|
||||
- [x] **Persistance du formatage inline** — sérialiseur `gtMd()` (DOM → source markdown) branché sur `sync()` ; `mdEsc()` rend `__…__`, `==…==`, `$$…$$` (KaTeX inline) — avant, `sync()` relisait du texte brut et perdait tout le formatage au prochain rendu
|
||||
- [x] **Info-bulle claire à droite** — `data-tooltip-html` + `.tooltip-light` dans le tooltip global (`app.js`)
|
||||
- [x] **Fix `askAI(idx)` écrasé par `askAI()`** — « Ask AI » / `Ctrl+J` ouvraient l'IA de page → `askAIBlock(idx)`
|
||||
- [x] **Fix « toolbar muette sous Alpine v3 »** — `ed.__x` (API v2) n'existe plus : `showFmt`/`hideCommentBtn`/mentions ne s'exécutaient jamais → accès via `_xed()` (`_x_dataStack[0]`) ; ancre de sélection élément gérée ; `checkMd()` recevait un id au lieu d'un index (exception à chaque espace)
|
||||
- [x] **Tests** — `tests/test_v762_selection_toolbar.py` (roundtrip `gtMd`/`mdEsc` réel sous node) + **e2e Playwright** `e2e/v762_selection_toolbar.spec.js` (structure, sous-menus, géométrie, persistance bold/highlight après reload) ; `VERSION` + `app/main.py` = 7.62.0
|
||||
|
||||
---
|
||||
|
||||
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v7.50.2 (peek de Library nu — iframe pleine, comme local-workspace) | **Statut**: EN COURS 🔄
|
||||
> **Début**: 2026-07-08 | **Version**: v7.62.0 (menu contextuel au highlight de texte — toolbar 4 lignes façon Notion) | **Statut**: EN COURS 🔄
|
||||
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
|
||||
|
||||
## Avancement Global
|
||||
|
||||
+1
-1
@@ -186,7 +186,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.61.0",
|
||||
version="7.62.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
@@ -635,17 +635,59 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Menu contextuel au highlight de texte (v7.62.0, façon Notion) ═══════════ -->
|
||||
<div
|
||||
class="format-toolbar"
|
||||
x-show="fmt.open"
|
||||
x-cloak
|
||||
:style="{top:fmt.top+'px',left:fmt.left+'px'}"
|
||||
@mousedown.prevent
|
||||
>
|
||||
<button @click="fmtApply('bold')"><strong>B</strong></button>
|
||||
<button @click="fmtApply('italic')"><em>I</em></button>
|
||||
<button @click="fmtApply('underline')"><u>U</u></button>
|
||||
<button @click="fmtApply('strikeThrough')"><s>S</s></button>
|
||||
<button @click="fmtLink()">{{ fd_icon("link",14) }}</button>
|
||||
<!-- Ligne 1 : style de texte -->
|
||||
<div class="ft-row">
|
||||
<button type="button" class="ft-item ft-style"
|
||||
@click="fmtStyle($event)"
|
||||
data-tooltip-html="<em>To be the foremost driver…</em><br>Just start writing with plain text">
|
||||
<span class="ft-style-ico">T</span>
|
||||
<span class="ft-style-name" x-text="fmtStyleName()"></span>
|
||||
<span class="ft-caret">›</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Ligne 2 : formatage de base -->
|
||||
<div class="ft-row">
|
||||
<button type="button" class="ft-item ft-a" @click="fmtColor($event)" data-tooltip="Color — text and background colors"><span class="ft-a-letter">A</span></button>
|
||||
<button type="button" class="ft-item" @click="fmtApply('bold')" data-tooltip="Bold — Ctrl+B"><strong>B</strong></button>
|
||||
<button type="button" class="ft-item" @click="fmtApply('italic')" data-tooltip="Italic — Ctrl+I"><em>I</em></button>
|
||||
<button type="button" class="ft-item" @click="fmtApply('underline')" data-tooltip="Underline"><u>U</u></button>
|
||||
<button type="button" class="ft-item" @click="fmtApply('strikeThrough')" data-tooltip="Strikethrough"><s>T</s></button>
|
||||
<button type="button" class="ft-item ft-tx" @click="fmtCode()" data-tooltip="Inline code">Tx</button>
|
||||
</div>
|
||||
<!-- Ligne 3 : outils avancés -->
|
||||
<div class="ft-row">
|
||||
<button type="button" class="ft-item" @click="fmtLink()" data-tooltip="Link">{{ fd_icon("link",15) }}</button>
|
||||
<button type="button" class="ft-item" @click="fmtHighlight()" data-tooltip="Highlight"><span class="ft-hl">S</span></button>
|
||||
<button type="button" class="ft-item ft-code-ico" @click="fmtCodeBlock()" data-tooltip="Code block"></></button>
|
||||
<button type="button" class="ft-item" @click="fmtEquation()" data-tooltip="Equation">√x</button>
|
||||
<button type="button" class="ft-item ft-more" @click="fmtMore($event)" data-tooltip="More actions">…</button>
|
||||
</div>
|
||||
<!-- Ligne 4 : actions -->
|
||||
<div class="ft-row">
|
||||
<button type="button" class="ft-item" @click="fmtComment()" data-tooltip="Comment — Ctrl+⇧+M">{{ fd_icon("message-square",15) }}</button>
|
||||
<button type="button" class="ft-item" @click="fmtAskAI()" data-tooltip="Ask AI — Ctrl+J">{{ fd_icon("sparkles",15) }}</button>
|
||||
</div>
|
||||
<!-- Section « Skills » -->
|
||||
<div class="ft-sep"></div>
|
||||
<div class="ft-group">Skills</div>
|
||||
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Improve the writing of this text (wording, clarity, style). Keep the meaning, language and format. Reply ONLY with the improved text.')" data-tooltip="Run on this block">Improve writing</button>
|
||||
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Proofread this text: fix spelling and grammar mistakes only. Reply ONLY with the corrected text.')" data-tooltip="Run on this block">Proofread</button>
|
||||
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Explain this text in simpler terms, in the same language. Reply ONLY with the explanation.')" data-tooltip="Run on this block">Explain</button>
|
||||
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Reformat this text for readability (structure, spacing, lists where useful) without changing its meaning. Reply ONLY with the reformatted text.')" data-tooltip="Run on this block">Reformat</button>
|
||||
<!-- Pied de page -->
|
||||
<div class="ft-sep"></div>
|
||||
<button type="button" class="ft-item ft-foot" @click="fmtEditAI()" data-tooltip="Edit with AI">
|
||||
<span>Edit with AI</span>
|
||||
<kbd>Alt+⇧+E</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Copy Link Toast ═══════════ -->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "FlowDeck",
|
||||
"version": "7.61.0"
|
||||
"version": "7.62.0"
|
||||
},
|
||||
"paths": {
|
||||
"/auth/register": {
|
||||
@@ -30444,4 +30444,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
/**
|
||||
* v7.62 — menu contextuel au highlight de texte (toolbar façon Notion).
|
||||
* Vérifie : les 4 lignes + Skills + pied, le menu « … », le sous-menu
|
||||
* « Turn into », et surtout la PERSISTANCE du formatage après reload
|
||||
* (sync() relit le DOM formaté via gtMd → source markdown).
|
||||
* Instance de test attendue sur FD_BASE_URL (défaut 8081).
|
||||
*/
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
|
||||
const USER = process.env.FD_USER || '[email protected]';
|
||||
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||
|
||||
test.use({ serviceWorkers: 'block' });
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
const ok = await page
|
||||
.waitForURL('**/workspaces', { timeout: 8000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!ok) {
|
||||
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
|
||||
data: { email: USER, password: PASS, name: 'E2E' },
|
||||
});
|
||||
if (resp.status() !== 409) expect(resp.ok()).toBeTruthy();
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
}
|
||||
}
|
||||
|
||||
// Sélectionne tout le bloc et simule la fin du glisser-souris
|
||||
async function selectBlock(page) {
|
||||
await page.evaluate(() => {
|
||||
const el = document.querySelector('#_blocksCt [data-bid]');
|
||||
const r = document.createRange();
|
||||
r.selectNodeContents(el);
|
||||
const s = getSelection();
|
||||
s.removeAllRanges();
|
||||
s.addRange(r);
|
||||
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
||||
});
|
||||
await expect(page.locator('.format-toolbar')).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
|
||||
test('toolbar de sélection : structure, sous-menus, persistance', async ({ page }) => {
|
||||
await login(page);
|
||||
const cookies = await page.context().cookies();
|
||||
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
|
||||
const created = await page.request.post(
|
||||
`${FD_BASE}/board/api/pages?title=Menu highlight ${Date.now()}`,
|
||||
{ headers: { 'X-CSRF-Token': csrf } }
|
||||
);
|
||||
expect(created.status(), await created.text()).toBeLessThan(400);
|
||||
const { id } = await created.json();
|
||||
|
||||
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
|
||||
await page.click('#_blocksCt [data-bid]');
|
||||
await page.keyboard.type('Menu lors d un highlight de texte');
|
||||
|
||||
// ── 1. structure : 4 lignes + Skills + pied ──
|
||||
await selectBlock(page);
|
||||
const bar = page.locator('.format-toolbar');
|
||||
await expect(bar.locator('.ft-row')).toHaveCount(4);
|
||||
await expect(bar.locator('.ft-style')).toContainText('Normal Text');
|
||||
await expect(bar.locator('.ft-group')).toHaveText('Skills');
|
||||
await expect(bar.locator('.ft-skill')).toHaveCount(4);
|
||||
await expect(bar.locator('.ft-skill').first()).toHaveText('Improve writing');
|
||||
await expect(bar.locator('.ft-foot')).toContainText('Edit with AI');
|
||||
// géométrie : sous la sélection, entièrement dans le viewport
|
||||
const barBox = await bar.boundingBox();
|
||||
const selBox = await page.evaluate(() => {
|
||||
const r = getSelection().getRangeAt(0).getBoundingClientRect();
|
||||
return { bottom: r.bottom };
|
||||
});
|
||||
expect(barBox.y).toBeGreaterThanOrEqual(selBox.bottom);
|
||||
expect(barBox.y + barBox.height).toBeLessThanOrEqual(900);
|
||||
expect(barBox.x).toBeGreaterThanOrEqual(0);
|
||||
expect(barBox.x + barBox.width).toBeLessThanOrEqual(1440);
|
||||
await page.screenshot({ path: 'shots/v762-toolbar.png' });
|
||||
|
||||
// ── 2. ligne 1 → sous-menu « Turn into » ──
|
||||
await bar.locator('.ft-style').click();
|
||||
const sub = page.locator('#blockMenuSub');
|
||||
await expect(sub).toBeVisible();
|
||||
await expect(sub).toContainText('Heading 4');
|
||||
await expect(sub.locator('.bm-check')).toHaveCount(1); // type courant coché
|
||||
await page.screenshot({ path: 'shots/v762-turn-submenu.png' });
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// ── 3. « … » → menu contextuel de bloc (recherche + pied) ──
|
||||
await selectBlock(page);
|
||||
await bar.locator('.ft-more').click();
|
||||
const menu = page.locator('#blockMenu');
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.locator('.bm-search')).toHaveAttribute('placeholder', 'Search actions...');
|
||||
await expect(menu).toContainText('Copy link to block');
|
||||
await expect(menu.locator('.bm-foot')).toContainText('Last edited by');
|
||||
await page.screenshot({ path: 'shots/v762-more-menu.png' });
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(menu).toBeHidden();
|
||||
|
||||
// ── 4. persistance : B (gras) survit au reload ──
|
||||
await selectBlock(page);
|
||||
await page.getByRole('button', { name: 'B', exact: true }).click();
|
||||
await expect(page.locator('#_blocksCt [data-bid] b, #_blocksCt [data-bid] strong')).toHaveCount(1);
|
||||
await page.waitForTimeout(2500); // autosave 1,5 s
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
|
||||
await expect(page.locator('#_blocksCt [data-bid] strong')).toHaveCount(1); // rendu mdEsc
|
||||
|
||||
// ── 5. persistance : S (surlignage) survit au reload ──
|
||||
await selectBlock(page);
|
||||
await bar.locator('.ft-hl').click();
|
||||
await expect(page.locator('#_blocksCt [data-bid] mark')).toHaveCount(1);
|
||||
await page.waitForTimeout(2500);
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
|
||||
await expect(page.locator('#_blocksCt [data-bid] mark')).toHaveCount(1);
|
||||
|
||||
// ── 6. fermeture hors sélection ──
|
||||
await page.mouse.click(10, 400);
|
||||
await expect(page.locator('.format-toolbar')).toBeHidden();
|
||||
});
|
||||
+120
-9
@@ -2915,35 +2915,146 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* FORMAT TOOLBAR (FLOATING) */
|
||||
/* FORMAT TOOLBAR (FLOATING) — menu contextuel au highlight (v7.62, façon Notion) */
|
||||
.format-toolbar {
|
||||
position: fixed;
|
||||
z-index: 998;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1px;
|
||||
min-width: 216px;
|
||||
max-height: calc(100vh - 16px);
|
||||
overflow-y: auto;
|
||||
background: var(--bg-modal);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
padding: 4px 6px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.format-toolbar button {
|
||||
width: 30px;
|
||||
.format-toolbar .ft-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.format-toolbar .ft-item {
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
padding: 0 7px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.format-toolbar button:hover {
|
||||
.format-toolbar .ft-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.format-toolbar .ft-item svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Ligne 1 : style de texte (libellé du type de bloc + sous-menu) */
|
||||
.format-toolbar .ft-style {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.format-toolbar .ft-style-ico {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.format-toolbar .ft-style-name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
.format-toolbar .ft-caret {
|
||||
color: var(--text-dim);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* « A » : couleur du texte (barre colorée sous la lettre) */
|
||||
.format-toolbar .ft-a-letter {
|
||||
position: relative;
|
||||
font-weight: 600;
|
||||
}
|
||||
.format-toolbar .ft-a-letter::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 1px;
|
||||
right: 1px;
|
||||
bottom: -4px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
}
|
||||
.format-toolbar .ft-tx,
|
||||
.format-toolbar .ft-code-ico {
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
.format-toolbar .ft-hl {
|
||||
border-radius: 2px;
|
||||
padding: 0 3px;
|
||||
background: rgba(255,197,61,.28);
|
||||
}
|
||||
.format-toolbar .ft-more {
|
||||
font-size: 16px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* Séparateurs, groupe « Skills », pied de page */
|
||||
.format-toolbar .ft-sep {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 5px 2px;
|
||||
}
|
||||
.format-toolbar .ft-group {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
padding: 3px 7px 2px;
|
||||
cursor: default;
|
||||
}
|
||||
.format-toolbar .ft-skill {
|
||||
height: 26px;
|
||||
justify-content: flex-start;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.format-toolbar .ft-foot {
|
||||
height: 26px;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.format-toolbar .ft-foot kbd {
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Info-bulle des éléments du menu de sélection : bulle claire, à droite */
|
||||
.tooltip.tooltip-light {
|
||||
background: #ffffff;
|
||||
border-color: #e3e3e3;
|
||||
color: #191919;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
|
||||
white-space: normal;
|
||||
max-width: 240px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.tooltip.tooltip-light em {
|
||||
color: #6b6b6b;
|
||||
}
|
||||
|
||||
/* EDITOR STATUSBAR */
|
||||
.editor-statusbar {
|
||||
@@ -3643,7 +3754,7 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
.slash-menu { width: 90vw; max-width: 90vw; left: 5vw !important; }
|
||||
|
||||
/* ── Format Toolbar ── */
|
||||
.format-toolbar { left: 8px !important; right: 8px; flex-wrap: wrap; justify-content: center; }
|
||||
.format-toolbar { left: 8px !important; right: 8px; }
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
|
||||
+18
-5
@@ -84,19 +84,32 @@
|
||||
|
||||
// Tooltip system
|
||||
document.addEventListener('mouseover', function(e) {
|
||||
const target = e.target.closest('[data-tooltip]');
|
||||
const target = e.target.closest('[data-tooltip], [data-tooltip-html]');
|
||||
if (!target) return;
|
||||
const tip = document.getElementById('tooltip');
|
||||
if (!tip) return;
|
||||
tip.textContent = target.getAttribute('data-tooltip');
|
||||
const html = target.getAttribute('data-tooltip-html');
|
||||
if (html) tip.innerHTML = html;
|
||||
else tip.textContent = target.getAttribute('data-tooltip') || '';
|
||||
// v7.62 : éléments du menu de sélection → info-bulle claire à droite (façon Notion)
|
||||
const inToolbar = !!target.closest('.format-toolbar');
|
||||
tip.classList.toggle('tooltip-light', inToolbar);
|
||||
tip.style.display = 'block';
|
||||
const rect = target.getBoundingClientRect();
|
||||
tip.style.left = rect.left + 'px';
|
||||
tip.style.top = (rect.bottom + 4) + 'px';
|
||||
if (inToolbar) {
|
||||
tip.style.left = (rect.right + 8) + 'px';
|
||||
tip.style.top = Math.max(8, rect.top + rect.height / 2 - tip.offsetHeight / 2) + 'px';
|
||||
if (rect.right + 8 + tip.offsetWidth > window.innerWidth) {
|
||||
tip.style.left = Math.max(8, rect.left - tip.offsetWidth - 8) + 'px';
|
||||
}
|
||||
} else {
|
||||
tip.style.left = rect.left + 'px';
|
||||
tip.style.top = (rect.bottom + 4) + 'px';
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mouseout', function(e) {
|
||||
const target = e.target.closest('[data-tooltip]');
|
||||
const target = e.target.closest('[data-tooltip], [data-tooltip-html]');
|
||||
if (!target) return;
|
||||
const tip = document.getElementById('tooltip');
|
||||
if (tip) tip.style.display = 'none';
|
||||
|
||||
@@ -52,6 +52,57 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
return out;
|
||||
}
|
||||
window.gtTok=gtTok;
|
||||
// v7.62.0 : lecture d'un bloc vers sa source markdown, en passant par le
|
||||
// DOM formaté (bold/italic/underline/strike/code/mark/liens inline posés
|
||||
// par le menu de sélection). Sans ça, sync() relisait du texte brut et
|
||||
// perdait tout le formatage au prochain render(). Sans balise de mise en
|
||||
// forme, on retombe sur gtTok (comportement identique à l'ancien).
|
||||
function gtMd(e){
|
||||
if(!e) return '';
|
||||
if(!e.querySelector||!e.querySelector('strong,b,em,i,u,s,del,strike,code,mark,a[href]')) return gtTok(e);
|
||||
function w(node){
|
||||
var out='';
|
||||
for(var i=0;i<node.childNodes.length;i++){
|
||||
var n=node.childNodes[i];
|
||||
if(n.nodeType===3){out+=n.nodeValue;continue;}
|
||||
if(n.nodeType!==1)continue;
|
||||
var cl=n.classList;
|
||||
if(cl&&(cl.contains('fd-wiki-chip')||cl.contains('fd-wiki-date'))){out+=n.getAttribute('data-token')||'';continue;}
|
||||
if(n.tagName==='BR'){out+='\n';continue;}
|
||||
if(cl&&cl.contains('fd-math-inline')){out+='$$'+(n.getAttribute('data-tex')||'')+'$$';continue;}
|
||||
var t=n.tagName, inner=w(n);
|
||||
if((t==='STRONG'||t==='B')&&inner){out+='**'+inner+'**';continue;}
|
||||
if((t==='EM'||t==='I')&&inner){out+='*'+inner+'*';continue;}
|
||||
if(t==='U'&&inner){out+='__'+inner+'__';continue;}
|
||||
if((t==='S'||t==='DEL'||t==='STRIKE')&&inner){out+='~~'+inner+'~~';continue;}
|
||||
if(t==='CODE'&&inner&&!(n.hasAttribute&&n.hasAttribute('data-bid'))){out+='`'+inner+'`';continue;}
|
||||
if(t==='MARK'&&inner){out+='=='+inner+'==';continue;}
|
||||
if(t==='A'){var h=n.getAttribute('href')||'';out+=/^https?:/i.test(h)?'['+(inner||h)+']('+h+')':inner;continue;}
|
||||
out+=inner;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return w(e);
|
||||
}
|
||||
window.gtMd=gtMd;
|
||||
// Enveloppe/désenveloppe une sélection dans une balise inline (code, mark)
|
||||
function _selWrapHost(r,tag){
|
||||
var a=r.startContainer.nodeType===1?r.startContainer:r.startContainer.parentElement;
|
||||
var b=r.endContainer.nodeType===1?r.endContainer:r.endContainer.parentElement;
|
||||
a=a&&a.closest(tag);b=b&&b.closest(tag);
|
||||
if(!a||a!==b)return null;
|
||||
return (a.hasAttribute&&a.hasAttribute('data-bid'))?null:a; // <code> racine d'un bloc code : ne pas dépiler
|
||||
}
|
||||
function _selWrapToggle(tag){
|
||||
var s=window.getSelection();if(!s.rangeCount)return;
|
||||
var r=s.getRangeAt(0);
|
||||
var host=_selWrapHost(r,tag);
|
||||
if(host){var p=host.parentNode;while(host.firstChild)p.insertBefore(host.firstChild,host);p.removeChild(host);p.normalize();return;}
|
||||
var el=document.createElement(tag);
|
||||
try{r.surroundContents(el);}catch(e){el.appendChild(r.extractContents());r.insertNode(el);}
|
||||
var s2=window.getSelection(),nr=document.createRange();
|
||||
nr.selectNodeContents(el);s2.removeAllRanges();s2.addRange(nr);
|
||||
}
|
||||
function splitCaret(e){
|
||||
const s=window.getSelection(); if(!s.rangeCount||!s.rangeCount)return null; const sel=s.getRangeAt(0);
|
||||
const MK='\uF000FD'; const mark=document.createElement('span'); mark.textContent=MK;
|
||||
@@ -73,6 +124,9 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
.replace(/\*\*([^*]+)\*\*/g,'<strong>$1</strong>')
|
||||
.replace(/\*([^*]+)\*/g,'<em>$1</em>')
|
||||
.replace(/~~([^~]+)~~/g,'<s>$1</s>')
|
||||
.replace(/__([^_]+)__/g,'<u>$1</u>') // souligné (toolbar de sélection)
|
||||
.replace(/==([^=]+)==/g,'<mark>$1</mark>') // surlignage
|
||||
.replace(/\$\$([^$]+)\$\$/g,function(m,t){return '<span class="fd-math-inline" data-tex="'+t.replace(/"/g,'"')+'"></span>';}) // équation inline
|
||||
.replace(/\[\[fdpage:(\d+)\]\]/g,function(m,pid){
|
||||
return '<a class="fd-wiki-chip" contenteditable="false" href="/pages/'+pid+'" data-token="'+m+'" data-pid="'+pid+'" title="Linked page"><span class="fd-wiki-label">…</span></a>';})
|
||||
.replace(/\[\[fddate:([0-9]{4}-[0-9]{2}-[0-9]{2})(?:T[0-9:]{5,8})?\]\]/g,function(m,iso){
|
||||
@@ -962,6 +1016,8 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
s.addEventListener('mouseleave',function(){_blockSubCloseLater();});}
|
||||
}
|
||||
function _blockSubCloseLater(){clearTimeout(_bmSubTimer);_bmSubTimer=setTimeout(function(){var s=document.getElementById('blockMenuSub');if(s&&!s.matches(':hover')){s.remove();_bmSubKind='';}},260);}
|
||||
// Sous-menu ouvert depuis la toolbar de sélection (pas de #blockMenu parent)
|
||||
function _subHoverClose(){var s=document.getElementById('blockMenuSub');if(s)s.addEventListener('mouseleave',function(){_blockSubCloseLater();});}
|
||||
function _blockMenuClick(ev){
|
||||
var t=ev.target.closest&&ev.target.closest('[data-bm-act]');if(!t)return;
|
||||
var act=t.getAttribute('data-bm-act');var bid=t.getAttribute('data-bid')||_blockMenuBid();
|
||||
@@ -976,7 +1032,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
else if(act==='delete'){E.deleteSelected(idx);}
|
||||
else if(act==='comment'){E.commentBlock(idx);}
|
||||
else if(act==='suggest'){E.suggestEdit(idx);}
|
||||
else if(act==='ai'){E.askAI(idx);}
|
||||
else if(act==='ai'){E.askAIBlock(idx);}
|
||||
else if(act==='skill'){E.runSkill(idx,t.getAttribute('data-prompt')||'');}
|
||||
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;}
|
||||
@@ -1397,14 +1453,15 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
sync(){const ct=document.getElementById('_blocksCt');if(!ct)return;for(const b of this.blocks){if(b.type==='table'){const wrap=ct.querySelector(`[data-bid="${b.id}"].ftable-editor`);if(wrap){const rows=[];wrap.querySelectorAll('tr.ftable-row').forEach(function(tr){const cells=tr.querySelectorAll('th.ftable-cell,td.ftable-cell');if(!cells.length)return;const rowarr=[];cells.forEach(function(cell){rowarr.push(cell.textContent||'');});rows.push(rowarr);});if(rows.length)b.rows=rows;}continue;}
|
||||
if(b.type==='divider'||b.type==='image'||b.type==='embed'||b.type==='table_of_contents'||b.type==='button')continue;
|
||||
if(b.type==='math'){const te=ct.querySelector(`[data-math-bid="${b.id}"]`);if(te)b.content=te.value||'';continue;}
|
||||
const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el&&!el.classList.contains('toggle-title'))b.content=gtTok(el)||'';else if(el)b.content=gtTok(el)||'';
|
||||
if(b.type==='toggle'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gtTok(che)||'';});
|
||||
if(b.type==='columns'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gtTok(che)||'';});
|
||||
const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el&&!el.classList.contains('toggle-title'))b.content=gtMd(el)||'';else if(el)b.content=gtMd(el)||'';
|
||||
if(b.type==='toggle'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gtMd(che)||'';});
|
||||
if(b.type==='columns'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gtMd(che)||'';});
|
||||
}},
|
||||
render(){const ct=document.getElementById('_blocksCt');if(!ct)return;let html='';for(let i=0;i<this.blocks.length;i++)html+=renderBlock(this.blocks[i],i);ct.innerHTML=html;
|
||||
// Render math blocks with KaTeX
|
||||
var self=this;
|
||||
ct.querySelectorAll('.math-display').forEach(function(el){var bid=el.dataset.bid;var b=self.blocks.find(function(x){return x.id===bid;});self._renderKatex(el,b?b.content:'');});
|
||||
ct.querySelectorAll('.fd-math-inline').forEach(function(el){var tex=el.getAttribute('data-tex')||'';if(window.katex&&tex){try{el.innerHTML=window.katex.renderToString(tex,{displayMode:false,throwOnError:false});}catch(e){el.textContent=tex;}}else if(tex&&!el.textContent){el.textContent=tex;}});
|
||||
ct.querySelectorAll('.math-editor').forEach(function(ta){var disp=ct.querySelector(`.math-display[data-bid="${ta.dataset.mathBid}"]`);ta.addEventListener('input',function(){self._renderKatex(disp,ta.value);self.dirty=true;self.autoSave();});});
|
||||
// Initialize inline database blocks (scripts in innerHTML don't execute)
|
||||
setTimeout(()=>{ct.querySelectorAll('.block-embed-collection').forEach(el=>{const cid=parseInt(el.id.replace('inline-db-',''));if(cid&&window.FlowDeckDB)window.FlowDeckDB.renderInto(el,cid,null);});},50);
|
||||
@@ -1680,7 +1737,9 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
if(!this._commentSel||!this._commentSel.range){this.showToast('Sélectionnez du texte pour commenter','error');return;}
|
||||
this.commentOnSelection();
|
||||
},
|
||||
askAI(idx){const b=this.blocks[idx];if(!b)return;if(window.AIC)window.AIC.open(idx);},
|
||||
// v7.62 : renommé — « askAI() » (sans argument, plus bas) l'écrasait,
|
||||
// le menu de bloc et Ctrl+J ouvraient l'IA de page au lieu du bloc.
|
||||
askAIBlock(idx){const b=this.blocks[idx];if(!b)return;if(window.AIC)window.AIC.open(idx);},
|
||||
suggestEdit(idx){
|
||||
const b=this.blocks[idx];if(!b||!(b.content||'').trim()){this.showToast('Bloc vide','error');return;}
|
||||
this.showToast('FlowDeck AI : suggestion en cours…');
|
||||
@@ -2193,7 +2252,7 @@ applyAIBlocks(text){
|
||||
if(e.key==='ArrowUp'){const _sp=splitCaret(el);if(_sp&&!_sp.before.trim()&&idx>0){e.preventDefault();const prev=this.getEl(this.blocks[idx-1]?.id);if(prev){prev.focus();ce(prev);}}return;}
|
||||
if(e.key==='ArrowDown'){const _sp=splitCaret(el);if(_sp&&!_sp.after.trim()&&idx<this.blocks.length-1){e.preventDefault();const next=this.getEl(this.blocks[idx+1]?.id);if(next){next.focus();cs(next);}}return;}
|
||||
if(e.key==='Backspace'){if(!(el.textContent||'').trim()){this.sync();if(block.type!=='paragraph'){e.preventDefault();this.pushHistory();block.type='paragraph';block.content='';this.render();_rtSync();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}else if(this.blocks.length>1){e.preventDefault();this.removeBlock(idx);}}return;}
|
||||
if(e.key===' '){const b2=bid;setTimeout(()=>this.checkMd(b2),15);}
|
||||
if(e.key===' '){const i3=idx;setTimeout(()=>this.checkMd(i3),15);}
|
||||
if((e.ctrlKey||e.metaKey)&&!e.altKey){
|
||||
if(e.key.toLowerCase()==='z'){e.preventDefault();if(e.shiftKey)this.redo();else this.undo();return;}
|
||||
if(e.key.toLowerCase()==='y'){e.preventDefault();this.redo();return;}
|
||||
@@ -2315,9 +2374,48 @@ applyAIBlocks(text){
|
||||
this.render();this.dirty=true;this.autoSave();_rtSync();
|
||||
}catch(e){this.showToast('Failed to load synced blocks','error');}
|
||||
},
|
||||
showFmt(idx){const s=window.getSelection();if(!s.rangeCount||s.isCollapsed){this.fmt.open=false;return;}const r=s.getRangeAt(0).getBoundingClientRect();this.fmt.open=true;this.fmt.idx=idx;this.fmt.top=Math.max(r.top-44,0);this.fmt.left=Math.min(r.left+r.width/2-120,window.innerWidth-260);this._sel={range:s.getRangeAt(0).cloneRange(),idx};},
|
||||
fmtApply(f){this.fmt.open=false;if(!this._sel)return;const s=window.getSelection();s.removeAllRanges();s.addRange(this._sel.range);document.execCommand(f==='strikeThrough'?'strikeThrough':f);this._sel=null;this.dirty=true;this.autoSave();},
|
||||
fmtLink(){this.fmt.open=false;const u=prompt('URL:');if(u){document.execCommand('createLink',false,u);this.dirty=true;this.autoSave();}},
|
||||
// ── v7.62.0 : menu contextuel au highlight de texte (façon Notion) ──
|
||||
showFmt(idx){const s=window.getSelection();if(!s.rangeCount||s.isCollapsed){this.fmt.open=false;this._sel=null;return;}const r=s.getRangeAt(0).getBoundingClientRect();
|
||||
const H=300,W=230;let top=r.bottom+8;if(top+H>window.innerHeight-8)top=Math.max(8,r.top-H);
|
||||
this.fmt.open=true;this.fmt.idx=idx;this.fmt.top=top;
|
||||
this.fmt.left=Math.max(8,Math.min(r.left+r.width/2-W/2,window.innerWidth-W-8));
|
||||
this._sel={range:s.getRangeAt(0).cloneRange(),idx};},
|
||||
_fmtRestore(){const s=window.getSelection();if(this._sel&&this._sel.range){s.removeAllRanges();s.addRange(this._sel.range);}},
|
||||
_fmtBid(){const b=this.blocks[this.fmt.idx];return b?b.id:'';},
|
||||
_fmtSaved(){this.sync();this.dirty=true;this.autoSave();},
|
||||
// style de la 1ʳᵉ ligne : libellé du type de bloc courant
|
||||
fmtStyleName(){const b=this.blocks[this.fmt.idx];const t=b?b.type:'paragraph';
|
||||
const m={paragraph:'Normal Text',heading_1:'Heading 1',heading_2:'Heading 2',heading_3:'Heading 3',heading_4:'Heading 4',
|
||||
bulleted_list:'Bulleted list',numbered_list:'Numbered list',to_do:'To-do list',toggle:'Toggle list',quote:'Quote',
|
||||
callout:'Callout',code:'Code',math:'Block equation',divider:'Divider',table:'Table',image:'Image',embed:'Embed',button:'Button'};
|
||||
return m[t]||'Normal Text';},
|
||||
_fmtSubPos(ev){const r=ev.currentTarget.getBoundingClientRect();
|
||||
return {x:Math.min(r.right+4,window.innerWidth-250),y:Math.min(Math.max(8,r.top),window.innerHeight-140)};},
|
||||
// « Normal Text › » → sous-menu Turn into (menu de bloc existant)
|
||||
fmtStyle(ev){const bid=this._fmtBid();if(!bid)return;const p=this._fmtSubPos(ev);_blockTurnMenuOpen(p.x,p.y,bid);_subHoverClose();},
|
||||
// « A » → sous-menu Color
|
||||
fmtColor(ev){const bid=this._fmtBid();if(!bid)return;const p=this._fmtSubPos(ev);_blockColorMenuOpen(p.x,p.y,bid);_subHoverClose();},
|
||||
// mise en forme de base : le navigateur fait le toggle, sync() persiste (gtMd)
|
||||
fmtApply(f){if(!this._sel)return;const idx=this.fmt.idx;this._fmtRestore();document.execCommand(f);this._fmtSaved();this.showFmt(idx);},
|
||||
fmtCode(){if(!this._sel)return;this._fmtRestore();_selWrapToggle('code');this._fmtSaved();this.fmt.open=false;},
|
||||
fmtHighlight(){if(!this._sel)return;this._fmtRestore();_selWrapToggle('mark');this._fmtSaved();this.fmt.open=false;},
|
||||
fmtCodeBlock(){this.fmt.open=false;const i=this.fmt.idx;if(i>=0)this.turnInto(i,'code');},
|
||||
fmtLink(){if(!this._sel)return;this._fmtRestore();const u=prompt('URL:');if(!u)return;
|
||||
document.execCommand('createLink',false,u);this._fmtSaved();this.fmt.open=false;},
|
||||
fmtEquation(){if(!this._sel)return;this._fmtRestore();const s=window.getSelection();if(!s.rangeCount)return;
|
||||
const tex=prompt('Equation (LaTeX):',s.toString()||'x^2');if(tex===null||!tex.trim())return;
|
||||
const span=document.createElement('span');span.className='fd-math-inline';span.setAttribute('data-tex',tex.trim());
|
||||
if(window.katex){try{span.innerHTML=window.katex.renderToString(tex.trim(),{displayMode:false,throwOnError:false});}catch(e){span.textContent=tex;}}
|
||||
else span.textContent=tex;
|
||||
const r=s.getRangeAt(0);r.deleteContents();r.insertNode(span);
|
||||
this._fmtSaved();this.fmt.open=false;},
|
||||
// « … » → le menu contextuel de bloc (recherche + Text + pied métadonnées)
|
||||
fmtMore(ev){const bid=this._fmtBid();if(!bid)return;const r=ev.currentTarget.getBoundingClientRect();
|
||||
this.fmt.open=false;blockMenuOpen(Math.max(8,Math.min(r.left-190,window.innerWidth-250)),r.bottom+6,bid);},
|
||||
fmtComment(){this.fmt.open=false;const i=this.fmt.idx;if(i>=0)this.commentBlock(i);},
|
||||
fmtAskAI(){this.fmt.open=false;const i=this.fmt.idx;if(i>=0)this.askAIBlock(i);},
|
||||
fmtEditAI(){this.fmtAskAI();},
|
||||
fmtSkill(tpl){this.fmt.open=false;const i=this.fmt.idx;if(i>=0)this.runSkill(i,tpl);},
|
||||
// ── v4.9.0: Collaboration — comments & mentions ──
|
||||
toggleComments(){this.commentsOpen=!this.commentsOpen;if(this.commentsOpen)this.loadComments();},
|
||||
async loadComments(){try{const r=await fetch('/api/pages/'+this.pid+'/comments',{credentials:'same-origin'});const d=await r.json();this.comments=d.comments||[];this.commentCount=this.comments.length;}catch(e){this.comments=[];}},
|
||||
@@ -2686,11 +2784,27 @@ applyAIBlocks(text){
|
||||
}return bl;},
|
||||
};
|
||||
}
|
||||
document.addEventListener('mouseup',()=>{setTimeout(()=>{const s=window.getSelection();if(!s||s.isCollapsed)return;const ed=document.querySelector('.page-editor-wrapper');if(!ed?.__x)return;const d=ed.__x.$data;const ct=document.getElementById('_blocksCt');if(!ct?.contains(s.anchorNode))return;const bel=s.anchorNode.parentElement?.closest('[data-bid]');if(bel){const idx=d.getIdx(bel.dataset.bid);if(idx>=0)d.showFmt(idx);d.showCommentBtn();}else{d.hideCommentBtn();}},80);});
|
||||
document.addEventListener('mousedown',()=>{setTimeout(()=>{const ed=document.querySelector('.page-editor-wrapper');if(ed?.__x)ed.__x.$data.hideCommentBtn();},10);});
|
||||
// v7.62 : data Alpine du wrapper éditeur — Alpine v3 n'expose plus __x
|
||||
// (l'ancien accès rendait muets showFmt/hideCommentBtn/mentions).
|
||||
function _xed(el){ if(!el) return null; if(el.__x&&el.__x.$data) return el.__x.$data;
|
||||
return (el._x_dataStack&&el._x_dataStack[0])||null; }
|
||||
document.addEventListener('mouseup',(ev)=>{setTimeout(()=>{
|
||||
// v7.62 : clic dans la toolbar → on laisse la sélection (et le menu) intacts ;
|
||||
// sélection relâchée/cliquée ailleurs → le menu de sélection se ferme.
|
||||
if(ev.target&&ev.target.closest&&ev.target.closest('.format-toolbar'))return;
|
||||
const d=_xed(document.querySelector('.page-editor-wrapper'));if(!d)return;
|
||||
const s=window.getSelection();
|
||||
if(!s||s.isCollapsed){d.fmt.open=false;d._sel=null;return;}
|
||||
const ct=document.getElementById('_blocksCt');
|
||||
if(!ct?.contains(s.anchorNode)){d.fmt.open=false;d._sel=null;return;}
|
||||
const bel0=s.anchorNode&&(s.anchorNode.nodeType===1?s.anchorNode:s.anchorNode.parentElement);
|
||||
const bel=bel0?bel0.closest('[data-bid]'):null;
|
||||
if(bel){const idx=d.getIdx(bel.dataset.bid);if(idx>=0)d.showFmt(idx);d.showCommentBtn();}else{d.hideCommentBtn();}
|
||||
},80);});
|
||||
document.addEventListener('mousedown',()=>{setTimeout(()=>{const d=_xed(document.querySelector('.page-editor-wrapper'));if(d)d.hideCommentBtn();},10);});
|
||||
document.addEventListener('keyup',(e)=>{
|
||||
const ed=document.querySelector('.page-editor-wrapper');if(!ed?.__x)return;
|
||||
const d=ed.__x.$data;if(!d.mentionOpen){if(e.key!=='@')return;}
|
||||
const d=_xed(document.querySelector('.page-editor-wrapper'));if(!d)return;
|
||||
if(!d.mentionOpen){if(e.key!=='@')return;}
|
||||
const el=document.activeElement;if(!el||!el.hasAttribute('data-bid'))return;
|
||||
const ct=document.getElementById('_blocksCt');if(!ct||!ct.contains(el))return;
|
||||
if(d.mentionOpen){
|
||||
@@ -2701,8 +2815,8 @@ applyAIBlocks(text){
|
||||
d.openMention(el);
|
||||
},true);
|
||||
document.addEventListener('click',(e)=>{
|
||||
const ed=document.querySelector('.page-editor-wrapper');if(!ed?.__x)return;
|
||||
const d=ed.__x.$data;if(!d.mentionOpen)return;
|
||||
const d=_xed(document.querySelector('.page-editor-wrapper'));if(!d)return;
|
||||
if(!d.mentionOpen)return;
|
||||
const menu=document.getElementById('_mentionMenu');
|
||||
if(menu&&!menu.contains(e.target))d.closeMention();
|
||||
});
|
||||
@@ -2763,11 +2877,12 @@ applyAIBlocks(text){
|
||||
var ab = E.getActiveBlock();
|
||||
return ab && ab.idx >= 0 ? ab.idx : -1;
|
||||
}
|
||||
if (ctrl && !sh && !alt && k === 'j') { e.preventDefault(); var i1 = curIdx(); if (i1 >= 0) E.askAI(i1); return; }
|
||||
if (ctrl && !sh && !alt && k === 'j') { e.preventDefault(); var i1 = curIdx(); if (i1 >= 0) E.askAIBlock(i1); return; }
|
||||
if (ctrl && sh && !alt && k === 'h') { e.preventDefault(); var i2 = curIdx(); if (i2 >= 0) E.applyLastColor(i2); return; }
|
||||
if (ctrl && sh && !alt && k === 'm') { e.preventDefault(); var i3 = curIdx(); if (i3 >= 0) E.commentBlock(i3); return; }
|
||||
if (ctrl && sh && alt && k === 'x') { e.preventDefault(); var i4 = curIdx(); if (i4 >= 0) E.suggestEdit(i4); return; }
|
||||
if (alt && sh && !ctrl && k === 'l') { e.preventDefault(); var i5 = curIdx(); if (i5 >= 0) E.copyBlockLink(E.blocks[i5].id); return; }
|
||||
if (alt && sh && !ctrl && k === 'e') { e.preventDefault(); var i7 = curIdx(); if (i7 >= 0) E.askAIBlock(i7); return; }
|
||||
if (ctrl && sh && !alt && k === 'p') { e.preventDefault(); var i6 = curIdx(); if (i6 >= 0) _blockMoveToOpen(i6); return; }
|
||||
// Ctrl+D : déjà géré par la saisie quand le bloc a le focus (pas de doublon)
|
||||
if (ctrl && !sh && !alt && k === 'd') {
|
||||
|
||||
@@ -48,7 +48,8 @@ def test_actions_are_all_dispatched():
|
||||
"comment", "suggest", "ai", "skill", "color", "turn-type"):
|
||||
assert f"act==='{act}'" in dispatch, act
|
||||
# méthodes réelles côté éditeur
|
||||
for m in ("commentBlock(idx)", "askAI(idx)", "suggestEdit(idx)",
|
||||
# v7.62 : « askAI(idx) » renommé askAIBlock(idx) — « askAI() » l'écrasait
|
||||
for m in ("commentBlock(idx)", "askAIBlock(idx)", "suggestEdit(idx)",
|
||||
"runSkill(idx,tpl)", "applyLastColor(idx)",
|
||||
"copyBlockLink(bid)", "deleteSelected(idx)", "duplicateBlock("):
|
||||
assert m in SRC, m
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""7.62.0 — menu contextuel au highlight de texte (toolbar façon Notion)."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SRC = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
|
||||
TPL = (ROOT / "app" / "templates" / "_page_editor_content.html").read_text(encoding="utf-8")
|
||||
CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
|
||||
APPJS = (ROOT / "static" / "js" / "app.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _slice(src: str, start: str, end: str) -> str:
|
||||
i = src.index(start)
|
||||
return src[i:src.index(end, i)]
|
||||
|
||||
|
||||
# ── Markup : les 4 lignes + Skills + pied, dans l'ordre de la description ───
|
||||
|
||||
def test_toolbar_markup_matches_spec():
|
||||
chunk = _slice(TPL, 'class="format-toolbar"', "Copy Link Toast")
|
||||
order = [
|
||||
"fmtStyle($event)", # ligne 1 : Normal Text ›
|
||||
"fmtApply('bold')", "fmtApply('italic')", "fmtApply('underline')",
|
||||
"fmtApply('strikeThrough')", "fmtCode()", # ligne 2 : A B I U T Tx
|
||||
"fmtLink()", "fmtHighlight()", "fmtCodeBlock()", "fmtEquation()",
|
||||
"fmtMore($event)", # ligne 3 : 🔗 S </> √x …
|
||||
"fmtComment()", "fmtAskAI()", # ligne 4 : 💬 ✨
|
||||
"ft-group", "Improve writing", "Proofread", "Explain", "Reformat",
|
||||
"Edit with AI", "Alt+⇧+E", # pied de page
|
||||
]
|
||||
positions = [chunk.index(o) for o in order]
|
||||
assert positions == sorted(positions), positions
|
||||
# ligne 1 dynamique (type du bloc) + chevron de sous-menu
|
||||
assert "fmtStyleName()" in chunk and "ft-caret" in chunk
|
||||
# info-bulle HTML (exemple en italique + description)
|
||||
assert "data-tooltip-html=" in chunk
|
||||
# icônes réellement définies dans le jeu d'icônes
|
||||
assert 'fd_icon("message-square",15)' in chunk
|
||||
assert 'fd_icon("sparkles",15)' in chunk and 'fd_icon("link",15)' in chunk
|
||||
|
||||
|
||||
# ── Actions : câblées vers de vraies méthodes, sous-menus réutilisés ────────
|
||||
|
||||
def test_actions_are_wired_to_real_methods():
|
||||
for m in ("fmtStyle(ev)", "fmtColor(ev)", "fmtApply(f)", "fmtCode()",
|
||||
"fmtHighlight()", "fmtCodeBlock()", "fmtLink()", "fmtEquation()",
|
||||
"fmtMore(ev)", "fmtComment()", "fmtAskAI()", "fmtSkill(tpl)",
|
||||
"fmtEditAI()", "askAIBlock(idx)"):
|
||||
assert m in SRC, m
|
||||
# pas de doublon de menu : on rouvre les sous-menus/menus déjà existants
|
||||
assert "_blockTurnMenuOpen(p.x,p.y,bid)" in SRC
|
||||
assert "_blockColorMenuOpen(p.x,p.y,bid)" in SRC
|
||||
assert "blockMenuOpen(" in SRC
|
||||
# sous-menu ouvert depuis la toolbar : fermeture au survol sortant
|
||||
assert "function _subHoverClose()" in SRC
|
||||
# Ctrl+J et Alt+⇧+E ouvrent l'IA du BLOC (l'ancien askAI(idx) était écrasé
|
||||
# par askAI() défini plus bas dans le même objet)
|
||||
assert "E.askAIBlock(i1)" in SRC
|
||||
assert "k === 'e'" in SRC
|
||||
assert "askAI(idx)" not in SRC
|
||||
|
||||
|
||||
# ── Persistance du formatage : sync() relit le DOM formaté en markdown ─────
|
||||
|
||||
def test_formatting_survives_sync():
|
||||
assert "function gtMd(" in SRC
|
||||
assert "gtMd(el)" in SRC and "gtMd(che)" in SRC # sync() + enfants toggle/columns
|
||||
for tok in ("'**'+inner+'**'", "'*'+inner+'*'", "'__'+inner+'__'",
|
||||
"'~~'+inner+'~~'", "'`'+inner+'`'", "'=='+inner+'=='"):
|
||||
assert tok in SRC, tok
|
||||
# mdEsc rend les nouveaux marqueurs (souligné, surlignage, équation inline)
|
||||
for re_ in ("/__([^_]+)__/g", "/==([^=]+)==/g", "/\\$\\$([^$]+)\\$\\$/g"):
|
||||
assert re_ in SRC, re_
|
||||
# les chips wiki gardent leur token brut
|
||||
assert "fd-wiki-chip" in SRC
|
||||
|
||||
|
||||
# ── Placement & fermeture ──────────────────────────────────────────────────
|
||||
|
||||
def test_menu_position_and_close():
|
||||
assert "r.bottom+8" in SRC # sous la sélection (bas de l'écran : au-dessus)
|
||||
assert "d.fmt.open=false;d._sel=null" in SRC # clic ailleurs → fermeture
|
||||
assert "closest('.format-toolbar')" in SRC # clic dans la toolbar ≠ clic ailleurs
|
||||
assert "@mousedown.prevent" in TPL # la sélection survit au clic
|
||||
|
||||
|
||||
# ── Styles ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_menu_styles():
|
||||
for sel in (".format-toolbar .ft-row {", ".format-toolbar .ft-item {",
|
||||
".format-toolbar .ft-style {", ".format-toolbar .ft-group {",
|
||||
".format-toolbar .ft-sep {", ".format-toolbar .ft-foot {",
|
||||
".tooltip.tooltip-light {"):
|
||||
assert sel in CSS, sel
|
||||
# l'info-bulle claire s'applique aux éléments du menu de sélection
|
||||
assert "data-tooltip-html" in APPJS and "tooltip-light" in APPJS
|
||||
|
||||
|
||||
# ── Comportement réel : gtMd (DOM → source markdown) + mdEsc, sous node ────
|
||||
|
||||
def test_gt_md_and_md_esc_roundtrip_for_real():
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node absent : test comportemental ignoré")
|
||||
gtmd = _slice(SRC, "function gtMd(", " // Enveloppe/désenveloppe")
|
||||
mdesc = _slice(SRC, "function mdEsc(", " // v5.11.0: labels of [[fdpage:ID]]")
|
||||
script = f"""
|
||||
var window = {{}}; // gtMd fait window.gtMd=…
|
||||
function gtTok(e){{ return e.__raw || ''; }}
|
||||
function fdDateLabel(iso){{ return iso; }}
|
||||
{gtmd}
|
||||
{mdesc}
|
||||
// DOM minimal (gtMd ne lit que childNodes / tagName / classList / attributs)
|
||||
function T(s){{ return {{nodeType:3, nodeValue:s}}; }}
|
||||
function E(tag, attrs, kids){{
|
||||
attrs = attrs || {{}};
|
||||
const cls = String(attrs['class'] || '').split(/\\s+/);
|
||||
return {{nodeType:1, tagName:tag, childNodes:kids || [],
|
||||
classList:{{contains:c=>cls.indexOf(c) >= 0}},
|
||||
getAttribute:k=>(k in attrs ? attrs[k] : null),
|
||||
hasAttribute:k=>(k in attrs)}};
|
||||
}}
|
||||
// querySelector truthy → chemin « formaté » de gtMd (gtTok jamais appelé)
|
||||
const root = E('DIV', {{'class':''}}, [
|
||||
E('B', {{}}, [T('gras')]),
|
||||
E('EM', {{}}, [T('italique')]),
|
||||
E('U', {{}}, [T('souligne')]),
|
||||
E('S', {{}}, [T('barre')]),
|
||||
E('CODE', {{}}, [T('code')]),
|
||||
E('MARK', {{}}, [T('surligne')]),
|
||||
E('A', {{'href':'https://example.com/x'}}, [T('lien')]),
|
||||
E('SPAN', {{'class':'fd-wiki-chip', 'data-token':'[[fdpage:42]]'}}),
|
||||
E('SPAN', {{'class':'fd-math-inline', 'data-tex':'x^2'}}),
|
||||
T(' fin')
|
||||
]);
|
||||
root.querySelector = () => ({{}}); // force le chemin formaté
|
||||
const md = gtMd(root);
|
||||
const html = mdEsc(md);
|
||||
console.log(JSON.stringify({{md:md, html:html}}));
|
||||
"""
|
||||
out = subprocess.run([node, "-e", script], capture_output=True, text=True,
|
||||
timeout=30, encoding="utf-8")
|
||||
assert out.returncode == 0, out.stderr
|
||||
data = json.loads(out.stdout)
|
||||
assert data["md"] == ("**gras***italique*__souligne__~~barre~~`code`==surligne=="
|
||||
"[lien](https://example.com/x)[[fdpage:42]]$$x^2$$ fin"), data["md"]
|
||||
for token in ("<strong>gras</strong>", "<u>souligne</u>", "<mark>surligne</mark>",
|
||||
"<code>code</code>", 'href="https://example.com/x"',
|
||||
'class="fd-wiki-chip"', 'data-tex="x^2"'):
|
||||
assert token in data["html"], token
|
||||
Reference in New Issue
Block a user