Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
674baf6954 | ||
|
|
3d3b46b58b | ||
|
|
7cf922106f | ||
|
|
d58c321c07 | ||
|
|
db6bf03a00 | ||
|
|
c031bf67de | ||
|
|
ab6977733d | ||
|
|
00c5e2a60d | ||
|
|
858649e3c3 | ||
|
|
400a827e3f | ||
|
|
699165469d | ||
|
|
3a6445ca4e | ||
|
|
033490c464 | ||
|
|
f132885b08 | ||
|
|
a79bdc1641 |
@@ -23,6 +23,7 @@ uv.lock
|
||||
*.db-*
|
||||
test-commit.md
|
||||
upload_test.txt
|
||||
commit_msg.txt
|
||||
e2e/node_modules/
|
||||
e2e/shots/
|
||||
e2e/test-results/
|
||||
|
||||
@@ -1,5 +1,511 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.69.1 (2026-10-08) — `og:image` en URL absolue
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`og:image` / `twitter:image` étaient des URL relatives** — les crawlers
|
||||
(LinkedIn, Slack, Discord, iMessage…) ignorent une URL relative : la carte de
|
||||
partage était vide. → global Jinja `app_base_url()` construit à partir de
|
||||
`settings.app_base_url`, **la même source que les liens de partage de pages**
|
||||
(`collaboration.py`) : si elle est mal renseignée, ces liens l'étaient déjà.
|
||||
Lecture paresseuse, comme `_plugin_enabled`, pour éviter l'import circulaire.
|
||||
|
||||
## v7.69.0 (2026-10-08) — La sélection multi-blocs fonctionne enfin (+ identité visuelle)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ctrl+C / Ctrl+X multi-blocs ne faisaient RIEN** — cause racine : le navigateur
|
||||
ne sait pas sérialiser une sélection qui traverse deux `contenteditable` (chaque
|
||||
bloc en a un). Résultat : `getSelection().toString()` renvoie `""` et Chromium
|
||||
**n'émet aucun événement `copy`** — nos handlers `ct.addEventListener('copy')`
|
||||
n'étaient donc jamais atteints, et le raccourci mourait en silence.
|
||||
→ le raccourci est maintenant intercepté au `keydown` (capture sur `document`,
|
||||
garde `closest('#_blocksCt')` pour ne pas voler Ctrl+C ailleurs) : on écrit le
|
||||
markdown dans le presse-papier nous-mêmes. La sélection mono-bloc reste native.
|
||||
- `Ctrl+X` supprime les blocs traversés en conservant la tête du premier et la
|
||||
queue du dernier.
|
||||
- **Glisser la souris d'un bloc à l'autre sélectionnait un seul bloc** — cause
|
||||
racine : la séquence complète est `mouseup` (on reconstruit la plage) → `click`
|
||||
→ `focusin` → **le navigateur efface la sélection**. La plage était donc
|
||||
reconstruite puis détruite dans la foulée. → la plage reconstruite est mise en
|
||||
attente (`_mdSelRange`) et restaurée au tour de boucle suivant, si le focus
|
||||
l'a effectivement effacée. Un nouveau geste (mousedown) remet l'attente à zéro,
|
||||
donc un clic pour placer le curseur reste normal.
|
||||
- **Le clic droit effaçait le surlignage** — la recherche du menu prenait le
|
||||
focus 10 ms après son ouverture, ce qui détruisait la sélection. → on ne vole
|
||||
plus le focus quand un texte est sélectionné. (Les actions Clipboard utilisaient
|
||||
déjà la sélection figée à l'ouverture du menu : `d._clipSt`.)
|
||||
- Le `preventDefault` du clic droit est bien sur `mousedown` (sur `contextmenu`
|
||||
il arrive trop tard — le curseur a déjà déplacé la sélection).
|
||||
|
||||
### Added
|
||||
|
||||
- **Identité visuelle** : la bannière et le logo du projet sont intégrés.
|
||||
- assets optimisés : `static/img/banner.webp` **22,5 Ko** (l'original fait
|
||||
1,6 Mo) + `banner.jpg` de secours, `logo.webp` **5,3 Ko** + `logo-512.jpg` ;
|
||||
originaux conservés dans `assets/` pour régénération.
|
||||
- icônes PWA régénérées à partir du vrai logo (72 → 512 px, marge maskable
|
||||
sur 192/512) + `apple-touch-icon`.
|
||||
- landing : logo dans la barre de navigation, bannière en `<picture>`
|
||||
(WebP + JPEG) avec dimensions déclarées pour éviter tout décalage de mise en
|
||||
page ; `og:image`, `twitter:card` et `meta description` ajoutés au `<head>`.
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v769_selection_persistence.py` (19 tests statiques) +
|
||||
`e2e/v769_selection_persistence.spec.js` (5 tests, **vrais gestes** : glisser
|
||||
de souris, `Ctrl+C`/`Ctrl+X` au clavier, clic droit).
|
||||
- `e2e/v768_columns_clipboard.spec.js` : les gestes simulés (`execCommand`,
|
||||
plage fabriquée en `evaluate`) sont remplacés par de vrais gestes.
|
||||
|
||||
## v7.68.0 (2026-10-08) — Colonnes rendues, tables IA, copier/couper/coller multi-blocs
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Colonnes / équation / TOC n'étaient JAMAIS rendus** : `renderBlock()`
|
||||
n'appelait pas `renderColumnsBlock()` / `renderMathBlock()` / `renderTOC()`
|
||||
(3 renderers morts depuis l'extraction v7.22) → un bloc colonnes — créé par
|
||||
le slash « Column list », par **Turn into → 2/3/4/5 colonnes** — tombait dans
|
||||
le rendu générique : un paragraphe vide. Dispatch ajouté.
|
||||
- la **× de suppression de colonne** était en `opacity:0` **sans règle de
|
||||
survol** (invisible mais cliquable) → visible au survol du bloc colonne.
|
||||
- **Tables rendues par l'IA** : `_tblIsData`/`_tblIsSep` exigeaient un `|` de
|
||||
tête → le GFM sans pipe (`A | B` / `--- | ---`, ce que produit souvent le
|
||||
modèle) devenait des paragraphes. Le pipe de tête est **optionnel** en GFM ;
|
||||
garde conservée : une ligne de séparation doit faire ≥2 cellules (un « texte »
|
||||
suivi d'un « --- » reste un séparateur de section). `md2b` et `paste2b`
|
||||
partagent les helpers → alignés d'office.
|
||||
- **Coller un SEUL bloc structuré** (table, code, titre) retombait en texte
|
||||
brut (`if(parsed.length<=1) return fallback()`) → seul un bloc paragraphe
|
||||
(une ligne) garde le comportement inline.
|
||||
- **« Copy link to block »** du menu de bloc : `data-bm-act="link"` alors que
|
||||
le handler attend `'copy-link'` → l'action ne faisait rien.
|
||||
|
||||
### Added
|
||||
|
||||
- **Copier / couper une sélection de texte MULTI-BLOCS** :
|
||||
- `_clipState()` (sélection vivante traversant ≥2 blocs) + `_clipMarkdown()`
|
||||
(début partiel → blocs entiers sérialisés par `blocksToMarkdown` → fin
|
||||
partielle) ; `clipCopy()` / `clipCut()` (la coupure garde la tête du 1ᵉʳ
|
||||
bloc et la queue du dernier, le milieu part) ;
|
||||
- listeners **`copy` / `cut`** sur `#_blocksCt` : Ctrl+C / Ctrl+X passent par
|
||||
les événements natifs du navigateur ; dans un seul bloc, on laisse le
|
||||
navigateur faire (copie native) ;
|
||||
- groupe **Clipboard** (`Cut Ctrl+X` / `Copy Ctrl+C` / `Paste Ctrl+V`) en
|
||||
tête du menu du clic droit, avec la sélection **figée à l'ouverture** du
|
||||
menu (la recherche prend le focus et détruit la sélection) ;
|
||||
- clic droit **sur un bloc sélectionné** → la sélection multi-blocs (handle +
|
||||
Maj-clic) est conservée et le Clipboard agit dessus (`copySelectedBlocks`) ;
|
||||
- `pasteBlocks()` colle après le dernier bloc quand aucun bloc n'est focalisé.
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v768_columns_clipboard.py` (4).
|
||||
- **e2e** `e2e/v768_columns_clipboard.spec.js` (4) : slash « Column list » +
|
||||
« Add column » + **Turn into → 2 colonnes** (bloc `.block-column` réellement
|
||||
rendu), table IA **sans pipe de tête** (vrai `.ftable-editor` + garde
|
||||
« texte »/« --- »), copier→`readText` → couper (blocs réduits à la tête +
|
||||
queue) → coller (blocs revenus), clic droit → groupe Clipboard → Copy.
|
||||
- le raccourci clavier n'est pas déclenché en headless Chromium (pas de
|
||||
presse-papier OS) : le test passe par `document.execCommand('copy'/'cut')`,
|
||||
qui émet les **mêmes** événements que Ctrl+C/X.
|
||||
- `pytest tests/ -q -n auto` → **1394 passed, 0 failed**, `ruff check app tests`
|
||||
→ OK.
|
||||
|
||||
|
||||
## v7.67.0 (2026-10-08) — Réactions dans le tiroir Comments (+ suppression) et refonte du menu de sélection
|
||||
|
||||
### Added
|
||||
|
||||
- **Les réactions vivent dans le tiroir « Comments »** : section **Reactions**
|
||||
au-dessus des fils — émoji, compte (si >1) et **texte ancré** lu dans le DOM
|
||||
(`reactionList()` / `_anchorText()`, offsets source hors pastilles). L'état
|
||||
vide « No comments yet » ne s'affiche plus s'il reste des réactions.
|
||||
- **Suppression d'une réaction — deux entrées, un seul chemin** :
|
||||
- **✕** sur la ligne de la section Reactions (visible uniquement sur **mes**
|
||||
réactions) ;
|
||||
- au **survol de la pastille**, l'émoji que j'ai posé devient une **croix**
|
||||
(`::after`, aucun nœud ajouté → offsets source inchangés), un clic retire.
|
||||
- les deux appellent `removeReaction()` → `POST /reactions` (toggle) →
|
||||
`loadReactions()` + repeindre les ancres.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Refonte du menu de sélection façon capture** :
|
||||
- ligne 2 `[A] B I U T Tx` (le barré est conservé — absent de la maquette,
|
||||
décision volontaire pour ne pas perdre la fonction) ;
|
||||
- ligne 3 `🔗 S </> √x …` (la réaction en est sortie) ;
|
||||
- **ligne 4 = une rangée scannée** : `💬 Comment` (libellé) à gauche,
|
||||
`🙂+` réaction à droite (`.ft-row-split`) ;
|
||||
- entête **Skills** avec `≡` à droite (`.ft-skills-head::after`), liste
|
||||
**scrollable** (`.ft-skills`, `max-height` + `overflow-y`) ;
|
||||
- **✨ Ask AI retiré** de la toolbar : doublon exact de « Edit with AI » du
|
||||
pied (même `fmtAskAI()`).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Flake de gate corrigé (racine)** : `test_v55.py::TestOGParser::
|
||||
test_fetch_og_metadata_success` dépendait d'un **vrai** `socket.getaddrinfo
|
||||
("example.com")` via le garde SSRF `_is_public_host` ; sous `pytest -n auto`
|
||||
un `socket.timeout` (non couvert par `except socket.gaierror`) remontait
|
||||
dans le `except Exception` de `fetch_og_metadata` → repli silencieux
|
||||
(`title='example.com'`) → AssertionError, 3 fois sur 5 runs.
|
||||
- `_is_public_host` catch désormais **`OSError`** : le garde renvoie un
|
||||
booléen, il ne lève jamais (les autres appelants gagnent aussi) ;
|
||||
- fixture `stub_dns` sur `TestOGParser` (même motif que
|
||||
`test_agent_web_tools.stub_dns`) : zéro DNS dans les tests OG.
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v767_reactions_drawer.py` (3) ; `tests/test_v762_selection_toolbar.py`
|
||||
(ligne 4 + styles) ; `tests/test_v764_comments_reactions.py` (position de
|
||||
`fmtReact`).
|
||||
- **e2e** : `v764_comments_reactions` étendu — section Reactions dans le tiroir
|
||||
(texte ancré), **suppression A** (✕ du tiroir → pastille disparue),
|
||||
**suppression B** (croix `::after` visible seulement au survol, puis clic) ;
|
||||
`v762_selection_toolbar` — `Comment` + `ft-row-split .ft-react`,
|
||||
`ft-skills-head` = « Skills », plus de bouton ✨.
|
||||
- `pytest tests/ -q -n auto` → **1390 passed, 0 failed** (flake corrigé),
|
||||
`ruff check app tests` → OK.
|
||||
|
||||
|
||||
## v7.66.0 (2026-10-08) — Escape du menu slash retire le « / » + bouton « commenter » à droite du bloc
|
||||
|
||||
### Added
|
||||
|
||||
- **Bouton « commenter ce bloc » à droite du bloc** (`.block-comment-btn`,
|
||||
gouttière 64px, visible au survol/focus comme le handle) : icône **bulle de
|
||||
conversation avec un +**, `onclick="E.commentBlock(idx)"` → tiroir de
|
||||
commentaires ancré sur le bloc (ou sur la sélection vivante s'il y en a une).
|
||||
Icône = tracé `message-square` (Feather) + `+` centré.
|
||||
Masqué sur mobile (2 breakpoints, comme le handle) et sur page verrouillée.
|
||||
- remplace l'ancien `.block-actions` de `renderBlock` : rendu en `opacity:0`
|
||||
**sans règle de survol** (jamais de classe `.visible`) → un `+` invisible
|
||||
qui capait les clics dans la gouttière droite.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Escape ferme le menu slash ET retire le « / »** : nouvelle `SM.escClose()`
|
||||
appelée par les **deux** chemins de fermeture (focus dans le bloc → `onKd`,
|
||||
focus dans l'entrée du menu → listener `document`). Ne touche au caractère
|
||||
que si c'est le `/` d'ouverture, remet le caret et sauvegarde (`sync` +
|
||||
`autoSave`).
|
||||
- **`fmtComment()` restaure la sélection de la toolbar** (`_fmtRestore()`)
|
||||
avant d'ancrer : `commentBlock()` ne se replie plus sur `this._sel` (plage
|
||||
potentiellement périmée reprise par le bouton de droite / le menu de bloc).
|
||||
|
||||
### Tests
|
||||
|
||||
- **e2e** `e2e/v766_slash_esc_comment_btn.spec.js` : « / » → menu visible →
|
||||
Escape → menu fermé et bloc vide ; bouton présent, `opacity` 0 → 1 au
|
||||
survol, **à droite** du wrapper (boîte mesurée), clic → tiroir → ancre = le
|
||||
bloc entier.
|
||||
- `tests/test_v766_slash_esc_comment_btn.py` (3) ; `pytest tests/ -q -n auto`
|
||||
→ **1386 passed**, `ruff check app tests` → OK.
|
||||
|
||||
|
||||
## v7.65.0 (2026-10-08) — Comment ancré sur la SÉLECTION de texte (pas le bloc) + retrait du bouton bleu « 💬 Comment »
|
||||
|
||||
### Changed
|
||||
|
||||
- **`commentBlock()` ancre sur la sélection de texte** : la plage vivante dans le
|
||||
bloc est utilisée d'abord (plage de la toolbar de sélection en repli, le clic
|
||||
sur le bouton ayant pu la faire disparaître du DOM), sinon tout le bloc.
|
||||
Les offsets passent par `_srcOffset()` → `anchor_start/anchor_end` = offsets
|
||||
dans le texte source, donc l'ancre jaune ne couvre que le texte sélectionné
|
||||
(`_paintAnchors` / réactions inchangés).
|
||||
- **Retiré le bouton bleu flottant « 💬 Comment »** (`#_commentSelBtn`) qui
|
||||
apparaissait au-dessus de la sélection : le déclencheur est le bouton 💬 de
|
||||
la toolbar de sélection, le menu de bloc (💬 Comment, Ctrl+⇧+M) et le raccourci.
|
||||
`showCommentBtn()`/`hideCommentBtn()` et le listener `mousedown` associés sont
|
||||
supprimés.
|
||||
- Fermer le tiroir (`toggleComments`) remet `_commentSel` à zéro : un brouillon
|
||||
abandonné ne peut plus attacher le commentaire suivant à une ancienne plage.
|
||||
|
||||
### Tests
|
||||
|
||||
- **e2e** `e2e/v764_comments_reactions.spec.js` : sélection d'une **sous-chaîne**
|
||||
(`reaction a un`) → toolbar 💬 → tiroir → envoi ; assert `#_commentSelBtn`
|
||||
absent et `.fd-anchor[data-cid]` = **uniquement** la plage sélectionnée
|
||||
(avant **et** après reload).
|
||||
- `pytest tests/ -q -n auto` → **1383 passed**, `ruff check app tests` → OK.
|
||||
|
||||
|
||||
## v7.64.1 (2026-10-08) — Fix : Delete sur un bloc vide supprime le bloc
|
||||
|
||||
### Fixed
|
||||
|
||||
- **La touche Delete ne supprimait pas le bloc vide** : seul `Backspace` avait la
|
||||
garde « bloc sans texte → supprimer le bloc ». La branche couvre désormais les
|
||||
**deux touches** (`onKd`, une seule garde partagée) — Delete vide le bloc puis
|
||||
le supprime au coup suivant, comme Backspace ; le dernier bloc de la page n'est
|
||||
jamais supprimé (l'éditeur garde un paragraphe).
|
||||
- Le vide est jugé avec **`gtTok(el)`** (le texte lu à la sauvegarde) et non
|
||||
`textContent` : une pastille de réaction seule dans le bloc ne l'empêche plus
|
||||
d'être supprimé.
|
||||
|
||||
### Tests
|
||||
|
||||
- **e2e** `e2e/v7641_delete_key.spec.js` : texte → Delete (bloc vidé, pas
|
||||
supprimé) → Delete (bloc supprimé, le précédent reste) → dernier bloc vide
|
||||
jamais supprimé.
|
||||
|
||||
|
||||
## v7.64.0 (2026-10-08) — Commentaires façon Notion : ancres jaunes, tiroir refondu, réactions sur texte
|
||||
|
||||
### Added
|
||||
|
||||
- **Réactions emoji sur un texte sélectionné** :
|
||||
- bouton **🙂+** dans la 3ᵉ ligne de la toolbar de sélection, infobulle
|
||||
« React to selected text » → ouvre le **sélecteur d'émoji existant** en mode
|
||||
réaction : en-tête = texte sélectionné (+ ✋), onglets masqués (emoji only),
|
||||
ligne **Recent** au-dessus de la grille **People**, Filter… et barre de
|
||||
catégories inchangées (dont `+` émoji custom).
|
||||
- **stockage** : migration 37 (`text_reactions`, une ligne par plage × émoji ×
|
||||
utilisateur, `UNIQUE` qui rend le POST idempotent) + endpoints
|
||||
`GET/POST /api/pages/{id}/reactions` (GET groupé par plage : `emoji/count/mine`,
|
||||
POST = toggle avec validation de plage non vide).
|
||||
- **affichage** : la plage est peinte en jaune (`.fd-anchor-react`) avec une
|
||||
**pastille** juste après (`😀` + compte si >1) ; clic sur la pastille →
|
||||
réouvre le sélecteur pour cette plage (ajout/suppression).
|
||||
- les pastilles ne sont **pas** du contenu : `gtTok`/`gtMd` les ignorent et les
|
||||
offsets `_srcOffset/_srcNodeLen` les excluent → `sync()` conserve le texte
|
||||
d'origine (vérifié dans le e2e) et les ancres ne décalent jamais.
|
||||
- **Ancres de commentaires visibles** — `_paintAnchors()` (appelé après chaque
|
||||
`render()`, après `loadComments` et `loadReactions`) entoure la plage commentée
|
||||
d'un **jaune** (`.fd-anchor`, gris si résolu) ; **clic sur l'ancre → le tiroir
|
||||
s'ouvre et centre le fil** (flash de surlignage).
|
||||
- **Tiroir de commentaires refondu** (image 1) : fil en carte arrondie avec
|
||||
**avatar coloré + nom + horodatage + ✓ (resolve/reopen) + ⋯** (menu Copy link /
|
||||
Delete), étiquette « 💬 on selected text », et champ de saisie **« Add a
|
||||
comment... »** avec **@** (insère la mention) et **bouton circulaire ↑** (envoi,
|
||||
`Entrée` aussi) — remplace le bouton « Comment ».
|
||||
- **Liens de commentaire** : `Copy link` copie `…#cmt-<id>` ; au chargement d'une
|
||||
page avec ce hash, le fil correspondant s'ouvre et se centre.
|
||||
- **Chargement initial** : `loadComments()` + `loadReactions()` à l'ouverture de la
|
||||
page → les ancres/pastilles sont peintes dès le premier rendu (et `commentCount`
|
||||
du tiroir est enfin renseigné).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`resolveComment` ne pouvait pas rouvrir** : il envoyait toujours
|
||||
`resolved:true` → accepte désormais l'objet du fil et bascule
|
||||
(`resolveComment(c)`), le bouton ✓ du fil est donc réellement un toggle.
|
||||
- **`showCommentBtn` ignorait les sélections à ancre « élément »** (double-clic,
|
||||
sélection programmatique) → `closest('[data-bid]')` partait du wrapper et le
|
||||
bouton 💬 n'apparaissait pas → gère les 2 types de nœud (même fix que `showFmt`
|
||||
en v7.62).
|
||||
- **`gtTok` chemin rapide (`innerText`) incluait les pastilles de réaction** →
|
||||
sélecteur enrichi (`.fd-react-chip`) pour basculer sur le marcheur qui les
|
||||
exclut : le texte sauvé reste identique.
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v764_comments_reactions.py` (8 tests) : table + migration 37,
|
||||
endpoints enregistrés, **toggle API réel** (added → removed → added, grouping,
|
||||
400 sur plage vide), markup toolbar/tiroir, peinture des ancres, mode réaction
|
||||
du sélecteur.
|
||||
- **e2e Playwright** `e2e/v764_comments_reactions.spec.js` : bouton 🙂+ avec la
|
||||
bonne infobulle → sélecteur (en-tête = texte, Filter…, onglets masqués) →
|
||||
pastille + plage jaune **avec contenu inchangé après `sync()`** → 💬 → tiroir
|
||||
(avatar, nom, ✓, ⋯, Copy link) → ancre jaune cliquable → ✓ résout → reload :
|
||||
ancres et pastilles rechargées depuis la base ; **3/3 specs e2e verts**
|
||||
(`--workers=1`).
|
||||
- **Suite complète** : `pytest tests/ -q -n auto` → **1383 passed / 0 failed**,
|
||||
`ruff check app tests` → All checks passed.
|
||||
|
||||
## v7.63.0 (2026-10-08) — Menus mobiles façon Notion (barre au « / » + Insert block plein écran)
|
||||
|
||||
### Added
|
||||
|
||||
- **Barre horizontale mobile** — sur un viewport ≤ 768 px, taper `/` sur une ligne
|
||||
vide affiche une **barre flottante défilante sous le curseur** (au lieu de la popup
|
||||
desktop), 15 boutons dans l'ordre Notion : `+▾`, `Turn into▾`, `@`, 💬, 🖼, 🗑,
|
||||
`←`, `→`, `↑`, `↓`, `↩`, `↪`, `Color▾`, `More▾`, `⌨` :
|
||||
- `+▾` → **feuille plein écran « Insert block »** : titre centré, bouton bleu
|
||||
« Cancel », liste verticale par groupes (Basic blocks / AI writing / Media /
|
||||
Data / Synced / Actions) avec icône, nom et **raccourci markdown** à droite
|
||||
(`#`, `##`, `-`, `1.`, `[]`, `>`, `"`, `---`…) — items partagés avec la popup
|
||||
desktop (`SM._slashItemHtml`), choix → `applySlash` → tout se referme.
|
||||
- `Turn into▾` / `Color▾` → feuilles réutilisant **les mêmes générateurs d'items**
|
||||
que les sous-menus du menu de bloc (`_blockTurnItems` / `_blockColorItems`
|
||||
extraits de `_blockTurnMenuOpen` / `_blockColorMenuOpen`).
|
||||
- `More▾` → menu contextuel de bloc existant (recherche + 10 actions + pied).
|
||||
- `@` insère `@` et ouvre le sélecteur de mention ; 💬 commentaire ; 🖼 insère un
|
||||
bloc image ; 🗑 supprime ; `⌨` masque le clavier ; `↩ ↪` annuler/rétablir.
|
||||
- **Déplacement de bloc `moveBlock(bid, dir)`** — `↑↓` réordonne (y compris dans un
|
||||
parent toggle/columns), `→` indente dans le frère précédent (toggle/columns),
|
||||
`←` ressort à la racine ; garde-fous avec toast (déjà en haut/bas, déjà à la
|
||||
racine, pas de cible).
|
||||
- **Fermeture automatique** de la barre quand le `/` n'est plus au début du bloc ;
|
||||
taps dans la barre/la feuille n'essaient plus de fermer le menu (exclusion
|
||||
`MTB.contains` sur le handler mousedown hors-zone).
|
||||
- **Raccourcis slash alignés sur les préfixes markdown** (description Notion) :
|
||||
`-` (puces), `[]` (à faire), `>` (toggle), `---` (séparateur) — partagés avec la
|
||||
popup desktop ; groupe `SYNCED` libellé « Synced ».
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v763_mobile_menus.py` : ordre des 15 boutons, câblage de chaque
|
||||
action, structure de la feuille (titre/Cancel/groupes), branchement mobile,
|
||||
styles — et **`moveBlock` exécuté pour de vrai sous node** (ordre, indent,
|
||||
outdent, garde-fous).
|
||||
- **e2e Playwright mobile** `e2e/v763_mobile_menus.spec.js` (viewport 390×844,
|
||||
`hasTouch`) : barre visible au `/` avec les 15 boutons dans l'ordre + `overflow-x:auto`
|
||||
et popup desktop masquée, « Insert block » (groupes, raccourci `#` de Heading 1)
|
||||
→ le bloc devient `heading_1`, « Turn into » (coche du type courant), « Color »
|
||||
(3 groupes), « More » (menu de bloc + pied), `↑` qui réordonne réellement les blocs.
|
||||
- `tests/test_v761_block_menu.py` adapté aux extractions `_blockTurnItems` /
|
||||
`_blockColorItems`.
|
||||
|
||||
## 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
|
||||
|
||||
- **Panneau de gauche** : barre de recherche « Search actions... » (filtre en
|
||||
direct), titre de section « Text », puis les 10 actions dans l'ordre Notion :
|
||||
Turn into ›, Color ›, Copy link to block (`Alt+⇧+L`), Duplicate (`Ctrl+D`),
|
||||
Move to (`Ctrl+⇧+P`), Delete (`Del`), Comment (`Ctrl+⇧+M`), Suggest edits
|
||||
(`Ctrl+⇧+Alt+X`), Ask AI (`Ctrl+J`), Skills › — avec les raccourcis
|
||||
affichés, et le pied de page « Last edited by … / Today at H:MM / N words,
|
||||
M characters » (auteur lu dans le compte, heure = `updatedAt` du bloc page,
|
||||
taille calculée sur le contenu du bloc).
|
||||
- **Sous-menu « Turn into »** (survol du panneau de gauche → panneau de
|
||||
droite, fermeture différée) : Text ✓ (type actuel coché), Heading 1-4,
|
||||
Bulleted list, Numbered list, To-do list, Toggle list, Code, Quote,
|
||||
Callout, Block equation, Synced block, et **2/3/4/5 columns** avec
|
||||
info-bulle « Create N columns of blocks » ; `turnInto(idx, type, cols)`
|
||||
crée désormais N colonnes et ouvre le sélecteur de source pour les blocs
|
||||
synchronisés.
|
||||
- **Sous-menu « Color »** : sections **Last used** (raccourci `Ctrl+⇧+H`),
|
||||
**Text color** (Default, Gray, Brown, Orange, Yellow ✓, Green, Blue,
|
||||
Purple, Pink, Red — glyphe « A » à la couleur) et **Background color**
|
||||
(même palette avec carré de couleur) ; la couleur utilisée est cochée, la
|
||||
dernière est mémorisée (`localStorage fdLastBlockColor`).
|
||||
- **Sous-menu « Skills »** : liste réelle des compétences de l'espace
|
||||
(`GET /api/agent/skills`) → applique le `prompt_template` au contenu du
|
||||
bloc via l'IA et insère le résultat dessous.
|
||||
- **Raccourcis** — handler clavier dédié : `Ctrl+J` (Ask AI), `Ctrl+⇧+H`
|
||||
(dernière couleur), `Ctrl+⇧+M` (commenter le bloc : sélection du contenu +
|
||||
tiroir de commentaires), `Ctrl+⇧+Alt+X` (suggérer une édition → résultat
|
||||
inséré dessous), `Alt+⇧+L` (copier le lien), `Ctrl+⇧+P` (déplacer vers…),
|
||||
`Ctrl+D` (dupliquer — déjà géré par la saisie, pas de double), `Del`
|
||||
(**seulement avec le menu ouvert**, jamais pendant la saisie), `Échap`
|
||||
(fermer).
|
||||
- **Actions Suggest edits / Ask AI / Comment / Skills** réelles côté éditeur
|
||||
(`suggestEdit`, `askAI`, `commentBlock`, `runSkill`) — Ask AI ouvre le
|
||||
composeur inline sans effacer le contenu du bloc.
|
||||
- **Styles** : `.bm-group`, `.bm-foot`, `.bm-check`, `.bm-current`,
|
||||
`.bm-dot`, sous-menu `60vh` défilable, recherche focalisée à l'ouverture.
|
||||
- **Tests** — `tests/test_v761_block_menu.py` : **9 tests** (structure et
|
||||
ordre du panneau, dispatch des 10 actions, 3 sous-menus, survol/fermeture,
|
||||
les 9 raccourcis, comportement node : `turnInto(…, 'columns', 3)` → 3
|
||||
colonnes, styles) + `test_v7593` adapté à la nouvelle signature.
|
||||
|
||||
### Removed
|
||||
|
||||
- Entrées **Copy / Cut / Paste blocks** du menu (absentes de la spécification
|
||||
Notion) — les raccourcis `Ctrl+C/X/V` sur blocs sélectionnés sont conservés.
|
||||
- Pastilles de couleurs inline : remplacées par le sous-menu Color nommé
|
||||
(palette portée à 10 couleurs alignées Notion : +Brown, Pink réel).
|
||||
|
||||
## v7.60.0 (2026-10-07) — Éditeur : bouton + à gauche du handle de bloc
|
||||
|
||||
### Added
|
||||
|
||||
- **Bouton `+` à gauche du handle** (les 6 points qui ouvrent le menu ⋮⋮ /
|
||||
servent au drag) : clique → **nouveau bloc inséré sous le bloc courant**,
|
||||
via le mécanisme déjà en place (`E.addAfter(idx)` → `addAt(idx+1)`).
|
||||
- Positionné dans la gouttière (`left:-64px`, gouttière `.blocks-container`
|
||||
de 64px, `-56px` quand elle passe à 32px), apparaît au survol/focus du bloc
|
||||
comme le handle, masqué avec lui sur écran étroit (≤768px et le second
|
||||
breakpoint — aucun débordement horizontal ajouté au conteneur.
|
||||
- `aria-label` + `title` « Add block below » (accessibilité).
|
||||
- **Tests** — `tests/test_v760_block_insert_btn.py` : **2 tests** (ordre
|
||||
dans le wrapper, câblage `E.addAfter`, positions/masquages CSS).
|
||||
|
||||
## v7.59.3 (2026-10-07) — Correction : « Turn into » du menu contextuel
|
||||
|
||||
### Fixed
|
||||
|
||||
- **« Turn into… » ne faisait rien** — la garde validait le type avec
|
||||
`_BLOCK_TURN_TYPES.indexOf(type)` alors que ce tableau contient des **objets**
|
||||
`{id, name}` : `indexOf` d'une chaîne renvoyait toujours `-1` → `turnInto()`
|
||||
retournait immédiatement après l'ouverture du sous-menu. Correction :
|
||||
`_BLOCK_TURN_TYPES.some(t=>t.id===type)`.
|
||||
- **Texte conservé** — convertir un bloc en **To-do** ou **Toggle** vidait le
|
||||
contenu (ces deux types étaient exclus de la conservation par erreur) ; seul
|
||||
un type sans champ texte (`image`, `embed`, `divider`, `button`, `columns`,
|
||||
…) le vide désormais, comme dans `Notion`.
|
||||
- **Tests** — `tests/test_v7593_turn_into.py` : **4 tests**, dont 1
|
||||
comportemental **node** qui exécute la vraie `turnInto()` extraite de la
|
||||
source : conversion effective du type, texte conservé (To-do = étiquette +
|
||||
case décochée), contenu vidé pour un type sans champ texte.
|
||||
|
||||
## v7.59.2 (2026-10-07) — Corrections : aide, « Insert below », listes numérotées
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1408,6 +1408,179 @@ 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
|
||||
|
||||
### v7.63.0 — Menus mobiles façon Notion ✅ (2026-10-08)
|
||||
> **Objectif** : parité des menus mobiles (Android sombre) — barre contextuelle au
|
||||
> « / » + menu « Insert block » plein écran. **COMPLETED**.
|
||||
|
||||
- [x] **Barre horizontale défilante** sous le curseur (mobile ≤ 768 px) — 15 boutons
|
||||
dans l'ordre Notion : `+▾`, `Turn into▾`, `@`, 💬, 🖼, 🗑, `←→↑↓`, `↩↪`, `Color▾`,
|
||||
`More▾`, `⌨` ; `overflow-x:auto`, fermeture auto quand le `/` disparaît, taps
|
||||
exclus du handler « clic hors-zone »
|
||||
- [x] **Feuille plein écran « Insert block »** (`+`) — titre centré + « Cancel » bleu,
|
||||
groupes CMDS avec raccourcis markdown (items partagés avec la popup desktop) ;
|
||||
`Turn into` / `Color` réutilisent les générateurs extraits (`_blockTurnItems`,
|
||||
`_blockColorItems`) ; `More` ouvre le menu de bloc existant
|
||||
- [x] **`moveBlock(bid, dir)`** — ↑↓ ordre (racine + enfants toggle/columns),
|
||||
→ indent dans le frère précédent, ← outdent, garde-fous toast
|
||||
- [x] **Raccourcis slash** alignés sur les préfixes markdown (`-`, `[]`, `>`, `---`)
|
||||
- [x] **Tests** — `tests/test_v763_mobile_menus.py` (+ `moveBlock` exécuté sous node),
|
||||
e2e mobile `e2e/v763_mobile_menus.spec.js` (390×844) ; `VERSION` + `app/main.py`
|
||||
= 7.63.0
|
||||
|
||||
### v7.64.0 — Commentaires & réactions façon Notion ✅ (2026-10-08)
|
||||
> **Objectif** : affichage/prise de commentaire identique à Notion + réaction emoji
|
||||
> sur le texte sélectionné. **COMPLETED**.
|
||||
|
||||
- [x] **Réaction sur texte** — bouton 🙂+ (« React to selected text ») dans la
|
||||
toolbar de sélection → sélecteur d'émoji **existant** en mode réaction (en-tête =
|
||||
texte sélectionné + ✋, onglets masqués, Recent au-dessus de People, Filter…,
|
||||
catégories + `+` custom) ; plage jaune + pastille (emoji + compte), clic pastille
|
||||
= rouvrir le sélecteur pour la plage
|
||||
- [x] **Backend** — migration 37 `text_reactions` (UNIQUE plage × emoji × user) +
|
||||
`GET/POST /api/pages/{id}/reactions` (GET groupé, POST toggle) ; `gtTok`/`gtMd`
|
||||
ignorent les pastilles et `_srcOffset` exclut les annotations des offsets → le
|
||||
texte sauvé est identique
|
||||
- [x] **Ancres jaunes** — `_paintAnchors()` après chaque `render()`/chargement ;
|
||||
clic sur l'ancre → tiroir ouvert + fil centré (flash) ; résolu = gris
|
||||
- [x] **Tiroir refondu** — carte de fil (avatar, nom, horodatage, ✓ toggle
|
||||
resolve/reopen, ⋯ → Copy link `#cmt-<id>` / Delete), saisie « Add a comment... »
|
||||
avec `@` et envoi `↑`/Entrée ; `resolveComment(c)` corrige l'impossibilité de
|
||||
rouvrir
|
||||
- [x] **Tests** — `tests/test_v764_comments_reactions.py` (8, dont toggle API réel),
|
||||
e2e `e2e/v764_comments_reactions.spec.js` ; `VERSION` + `app/main.py` = 7.64.0
|
||||
- [x] **v7.64.1 — Fix Delete sur bloc vide** : la garde « texte vide → supprimer
|
||||
le bloc » couvre `Backspace` **et** `Delete` (une seule branche dans `onKd`),
|
||||
vide jugé via `gtTok()` (ignore les pastilles) ; e2e
|
||||
`e2e/v7641_delete_key.spec.js`
|
||||
|
||||
### v7.65.0 — Comment ancré sur la sélection de texte ✅ (2026-10-08)
|
||||
> **Objectif** : le commentaire porte sur le texte sélectionné dans le bloc,
|
||||
> pas sur le bloc entier, et le bouton bleu flottant disparaît. **COMPLETED**.
|
||||
|
||||
- [x] **Ancrage sur la plage** — `commentBlock()` prend la sélection vivante dans
|
||||
le bloc (repli : la plage mémorisée par la toolbar, qui survit au clic du
|
||||
bouton), sinon tout le bloc ; offsets calculés par `_srcOffset()` →
|
||||
`anchor_start/anchor_end` exacts dans le texte source
|
||||
- [x] **Bouton bleu « 💬 Comment » retiré** — `#_commentSelBtn`,
|
||||
`showCommentBtn()`, `hideCommentBtn()` et leur listener `mousedown` supprimés ;
|
||||
déclencheurs restants = 💬 de la toolbar de sélection, menu de bloc,
|
||||
Ctrl+⇧+M
|
||||
- [x] **Tiroir** — fermer remet `_commentSel` à zéro (pas d'ancrage périmé)
|
||||
- [x] **Tests** — e2e `e2e/v764_comments_reactions.spec.js` : sous-sélection
|
||||
`reaction a un` → `.fd-anchor[data-cid]` = la plage seule, avant/après
|
||||
reload, `#_commentSelBtn` absent ; `pytest` 1383 passed, `ruff` OK ;
|
||||
`VERSION` + `app/main.py` = 7.65.0
|
||||
|
||||
### v7.66.0 — Escape du menu slash + bouton commenter à droite ✅ (2026-10-08)
|
||||
> **Objectif** : Esc efface le `/` qui a ouvert le menu slash, et chaque bloc
|
||||
> gagne un bouton de commentaire à droite. **COMPLETED**.
|
||||
|
||||
- [x] **`SM.escClose()`** — un seul implément, appelé par les deux chemins
|
||||
(focus dans le bloc → `onKd`, focus dans l'entrée du menu → listener
|
||||
`document`) ; retire le `/` d'ouverture seulement, remet le caret, `sync` +
|
||||
`autoSave`
|
||||
- [x] **Bouton à droite du bloc** — `.block-comment-btn` (bulle + `+`,
|
||||
`E.commentBlock(idx)`), gouttière 64px, visible au survol, masqué mobile +
|
||||
page verrouillée ; remplace l'ancien `.block-actions` mort (opacity:0 sans
|
||||
survol → `+` invisible qui capait les clics)
|
||||
- [x] **`fmtComment()`** restaure la plage de la toolbar avant d'ancrer ;
|
||||
`commentBlock()` ne se replie plus sur `this._sel` périmé
|
||||
- [x] **Tests** — e2e `e2e/v766_slash_esc_comment_btn.spec.js` (Esc → menu
|
||||
fermé + bloc vide ; bouton à droite mesuré, ancre = bloc entier),
|
||||
`tests/test_v766_slash_esc_comment_btn.py` (3) ; `pytest` **1386 passed**,
|
||||
`ruff` OK ; `VERSION` + `app/main.py` = 7.66.0
|
||||
|
||||
### v7.67.0 — Réactions dans le tiroir + suppression + refonte du menu de sélection ✅ (2026-10-08)
|
||||
> **Objectif** : une réaction s'ajoute/supprime depuis le tiroir Comments, et
|
||||
> le menu de sélection correspond à la maquette ASCII fournie. **COMPLETED**.
|
||||
|
||||
- [x] **Section « Reactions » du tiroir** — `reactionList()` + `_anchorText()`
|
||||
(texte de la plage lu dans le DOM, hors pastilles), compte si >1, état vide
|
||||
adapté ; `removeReaction()` = POST toggle
|
||||
- [x] **Suppression** — ✕ dans le tiroir (mes réactions seulement) **et**
|
||||
croix `::after` au survol de la pastille sur l'émoji que j'ai posé (aucun
|
||||
nœud ajouté → offsets source intacts)
|
||||
- [x] **Refonte du menu** — ligne 4 scannée `💬 Comment | 🙂 réaction`,
|
||||
réaction sortie de la ligne 3, entête `Skills ≡`, liste Skills scrollable,
|
||||
`✨ Ask AI` retiré (doublon exact du pied)
|
||||
- écart volontaire à la maquette : le **barré (T)** de la ligne 2 est
|
||||
conservé (il n'y figure pas) pour ne pas perdre la fonction
|
||||
- [x] **Flake de gate corrigé** — `_is_public_host` attrape `OSError` (le
|
||||
garde SSRF ne lève plus) + fixture `stub_dns` sur `TestOGParser`
|
||||
(le test faisait un vrai `getaddrinfo("example.com")`, échec sous `-n auto`)
|
||||
- [x] **Tests** — `tests/test_v767_reactions_drawer.py` (3), e2e v764 étendu
|
||||
(réactions dans le tiroir + suppression A/B), e2e v762 (nouvelle ligne 4) ;
|
||||
`pytest` **1390 passed / 0 failed**, `ruff` OK ; `VERSION` +
|
||||
`app/main.py` = 7.67.0
|
||||
|
||||
### v7.68.0 — Colonnes rendues, tables IA, copier/couper/coller multi-blocs ✅ (2026-10-08)
|
||||
> **Objectif** : les colonnes (slash + Turn into) marchent, l'IA crée de vrais
|
||||
> tableaux, et une sélection multi-blocs se copie/coupe/colle. **COMPLETED**.
|
||||
|
||||
- [x] **Renderers morts branchés** — `renderBlock()` n'appelait jamais
|
||||
`renderColumnsBlock()` / `renderMathBlock()` / `renderTOC()` → bloc colonnes,
|
||||
équation et TOC rendaient un paragraphe vide (bug rapporté) ; × de
|
||||
suppression de colonne visible au survol (était invisible mais cliquable)
|
||||
- [x] **Tables IA** — `_tblIsData`/`_tblIsSep` acceptent le GFM **sans pipe de
|
||||
tête** (séparateur ≥2 cellules pour ne pas avaler « texte » + « --- ») ;
|
||||
`md2b` et `paste2b` alignés d'office
|
||||
- [x] **Coller** un seul bloc structuré (table/code/titre) = vraie insertion ;
|
||||
« Copy link to block » réparé (`link` → `copy-link`)
|
||||
- [x] **Copier/couper multi-blocs** — `_clipState()`/`_clipMarkdown()`/
|
||||
`clipCopy()`/`clipCut()`, listeners `copy`/`cut` (Ctrl+C/X), groupe
|
||||
**Clipboard** dans le menu du clic droit (sélection figée à l'ouverture),
|
||||
clic droit sur un bloc sélectionné = sélection multi-blocs conservée
|
||||
- [x] **Tests** — `tests/test_v768_columns_clipboard.py` (4), e2e
|
||||
`v768_columns_clipboard.spec.js` (4) ; `pytest` **1394 passed / 0 failed**,
|
||||
`ruff` OK ; `VERSION` + `app/main.py` = 7.68.0
|
||||
|
||||
### v7.69.0 — La sélection multi-blocs fonctionne enfin ✅ (2026-10-08)
|
||||
|
||||
**Contexte** : v7.68.0 livrait la mécanique Clipboard (plage multi-blocs, handlers
|
||||
`copy`/`cut`, groupe Clipboard du menu contextuel) mais elle était **inutilisable en
|
||||
pratique** — trois rapports d'usage : Ctrl+C ne copie rien, le glisser souris ne
|
||||
sélectionne qu'un bloc, le clic droit efface le surlignage.
|
||||
|
||||
**Causes racines trouvées (trace d'événements à l'appui, pas de supposition)** :
|
||||
|
||||
| Symptôme | Cause réelle |
|
||||
|----------|--------------|
|
||||
| Ctrl+C/X muet | le navigateur **n'émet aucun événement `copy`** quand la sélection traverse deux `contenteditable` : `getSelection().toString()` renvoie `""`, donc nos handlers n'étaient jamais atteints |
|
||||
| Glisser = 1 bloc | séquence `mouseup` → `click` → `focusin` → **collapse** : la plage reconstruite mourait dans la foulée |
|
||||
| Clic droit efface | `.bm-search` prenait le focus 10 ms après l'ouverture du menu |
|
||||
|
||||
**Corrections** : interception du raccourci au `keydown` (capture `document`,
|
||||
garde `closest('#_blocksCt')`) ; plage reconstruite mise en attente
|
||||
(`_mdSelRange`) et restaurée au tour suivant, remise à zéro à chaque nouveau
|
||||
geste ; le menu ne vole plus le focus quand un texte est sélectionné ;
|
||||
`preventDefault` du clic droit remonté sur `mousedown` (sur `contextmenu` c'est
|
||||
trop tard).
|
||||
|
||||
**Identité visuelle** : bannière + logo intégrés — WebP 22,5 Ko / 5,3 Ko (les
|
||||
originaux faisaient 1,6 Mo / 1,1 Mo), JPEG de secours, icônes PWA régénérées à
|
||||
partir du vrai logo, `<picture>` sur la landing, `og:image`/`twitter:card`.
|
||||
|
||||
**Vérification** : `tests/test_v769_selection_persistence.py` (19 statiques) +
|
||||
`e2e/v769_selection_persistence.spec.js` (5, **vrais gestes**) ; le spec v768 a
|
||||
été corrigé pour remplacer `execCommand` et les plages fabriquées par de vrais
|
||||
gestes.
|
||||
|
||||
**Piège de test relevé au passage** : les `···` dans les échecs Playwright sont
|
||||
le marqueur de troncature du message d'erreur, **pas** un bug produit — une heure
|
||||
passée à chercher une fuite inexistante. Vérifier `JSON.stringify()` de la valeur
|
||||
réelle avant de croire un affichage tronqué.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v7.50.2 (peek de Library nu — iframe pleine, comme local-workspace) | **Statut**: EN COURS 🔄
|
||||
> **Début**: 2026-07-08 | **Version**: v7.69.1 (sélection multi-blocs fonctionnelle : drag souris + Maj+↓ + Ctrl+C/X, clic droit qui préserve le surlignage ; identité visuelle logo/bannière) | **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
|
||||
|
||||
@@ -186,7 +186,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.59.2",
|
||||
version="7.69.1",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
@@ -1680,6 +1680,32 @@ def _migration_agent_memory(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
|
||||
|
||||
@register(37, "v7.64.0: réactions emoji sur un texte sélectionné")
|
||||
def _migration_text_reactions(conn: sqlite3.Connection) -> None:
|
||||
"""Réactions emoji ancrées sur une plage de texte (façon Notion) : miroir des
|
||||
ancres de commentaires (block_id + offsets dans le texte rendu). Une ligne par
|
||||
(plage, emoji, utilisateur) → le POST agit comme un toggle et le GET regroupe
|
||||
par plage pour afficher la pastille d'émojis."""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS text_reactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
block_id TEXT NOT NULL,
|
||||
anchor_start INTEGER NOT NULL,
|
||||
anchor_end INTEGER NOT NULL,
|
||||
emoji TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(page_id, block_id, anchor_start, anchor_end, emoji, user_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_text_reactions_page ON text_reactions(page_id)"
|
||||
)
|
||||
|
||||
|
||||
@register(36, "v7.59.1: mistral — modèles par défaut hors plan basique")
|
||||
def _migration_mistral_default_model(conn: sqlite3.Connection) -> None:
|
||||
"""mistral-large-latest / pixtral-large-latest renvoient 403
|
||||
|
||||
@@ -206,3 +206,67 @@ def delete_comment(request: Request, comment_id: int):
|
||||
conn.execute("DELETE FROM comments WHERE id=? OR parent_id=?", (comment_id, comment_id))
|
||||
conn.commit()
|
||||
return {"id": comment_id, "status": "deleted"}
|
||||
|
||||
|
||||
# ── v7.64.0 : réactions emoji sur un texte sélectionné ──────────────────────
|
||||
|
||||
@router.get("/pages/{page_id}/reactions")
|
||||
def list_reactions(request: Request, page_id: int):
|
||||
"""Réactions regroupées par plage de texte : {block_id, start, end, emoji, count, mine}."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
|
||||
raise HTTPException(404, "Page not found")
|
||||
rows = conn.execute(
|
||||
"""SELECT block_id, anchor_start, anchor_end, emoji,
|
||||
COUNT(*) AS count,
|
||||
MAX(CASE WHEN user_id=? THEN 1 ELSE 0 END) AS mine
|
||||
FROM text_reactions
|
||||
WHERE page_id=?
|
||||
GROUP BY block_id, anchor_start, anchor_end, emoji
|
||||
ORDER BY MIN(id)""",
|
||||
(user["id"], page_id),
|
||||
).fetchall()
|
||||
return {"page_id": page_id, "reactions": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/reactions")
|
||||
def toggle_reaction(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""Ajoute ou retire la réaction de l'utilisateur sur une plage de texte (toggle)."""
|
||||
user = _current_user(request)
|
||||
block = (body.get("block_id") or "").strip()
|
||||
emoji = (body.get("emoji") or "").strip()
|
||||
try:
|
||||
start = int(body.get("anchor_start"))
|
||||
end = int(body.get("anchor_end"))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "anchor offsets required") from None
|
||||
if not block or not emoji or start < 0 or end <= start:
|
||||
raise HTTPException(400, "block_id, emoji and a non-empty range are required")
|
||||
uid = user["id"]
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
|
||||
raise HTTPException(404, "Page not found")
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
|
||||
(uid, user.get("login", "admin"), user.get("full_name", "Admin")),
|
||||
)
|
||||
existing = conn.execute(
|
||||
"""SELECT id FROM text_reactions
|
||||
WHERE page_id=? AND block_id=? AND anchor_start=? AND anchor_end=?
|
||||
AND emoji=? AND user_id=?""",
|
||||
(page_id, block, start, end, emoji, uid),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute("DELETE FROM text_reactions WHERE id=?", (existing["id"],))
|
||||
status = "removed"
|
||||
else:
|
||||
conn.execute(
|
||||
"""INSERT INTO text_reactions
|
||||
(page_id, block_id, anchor_start, anchor_end, emoji, user_id)
|
||||
VALUES (?,?,?,?,?,?)""",
|
||||
(page_id, block, start, end, emoji, uid),
|
||||
)
|
||||
status = "added"
|
||||
conn.commit()
|
||||
return {"status": status, "emoji": emoji}
|
||||
|
||||
@@ -26,7 +26,10 @@ def _is_public_host(host: str) -> bool:
|
||||
return False
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, None)
|
||||
except socket.gaierror:
|
||||
except OSError:
|
||||
# gaierror, socket.timeout, herror… : le garde-fou doit TOUJOURS
|
||||
# renvoyer un booléen, jamais lever — sinon l'appelant tombe dans son
|
||||
# `except Exception` (bookmarks) au lieu d'un refus explicite.
|
||||
return False
|
||||
for info in infos:
|
||||
address = info[4][0]
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
|
||||
<style>
|
||||
.fd-icon-picker{position:fixed;top:0;left:0;width:344px;background:var(--bg-modal);border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,.28);z-index:2200;display:flex;flex-direction:column;max-height:440px;overflow:hidden;padding:0;}
|
||||
.fd-icon-picker .fdip-title{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--border);}
|
||||
.fd-icon-picker .fdip-title-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:600;color:var(--text-primary);}
|
||||
.fd-icon-picker .fdip-title-grip{opacity:.6;font-size:14px;}
|
||||
.fd-icon-picker .fdip-tabs{display:flex;align-items:center;gap:2px;padding:8px 10px 6px;border-bottom:1px solid var(--border);}
|
||||
.fd-icon-picker .fdip-tab{background:transparent;border:none;color:var(--text-secondary);font-size:13px;padding:5px 10px;border-radius:6px;cursor:pointer;}
|
||||
.fd-icon-picker .fdip-tab:hover{background:var(--bg-hover);}
|
||||
@@ -70,7 +73,11 @@
|
||||
@click.outside="$store.fdIconPicker.customModal || $store.fdIconPicker.close()"
|
||||
@keydown.escape.window="$store.fdIconPicker.close()"
|
||||
:style="'top:' + $store.fdIconPicker.y + 'px;left:' + $store.fdIconPicker.x + 'px'">
|
||||
<div class="fdip-tabs">
|
||||
<div class="fdip-title" x-show="$store.fdIconPicker.title" x-cloak>
|
||||
<span class="fdip-title-text" x-text="$store.fdIconPicker.title"></span>
|
||||
<span class="fdip-title-grip" title="Drag">✋</span>
|
||||
</div>
|
||||
<div class="fdip-tabs" x-show="!$store.fdIconPicker.title">
|
||||
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'emoji' }" @click="$store.fdIconPicker.setTab('emoji')">Emoji</button>
|
||||
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'icons' }" @click="$store.fdIconPicker.setTab('icons')">Icons</button>
|
||||
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'upload' }" @click="$store.fdIconPicker.setTab('upload')">Upload</button>
|
||||
@@ -93,6 +100,15 @@
|
||||
<div class="fdip-body">
|
||||
<template x-if="$store.fdIconPicker.tab === 'emoji'">
|
||||
<div>
|
||||
<!-- v7.64 : mode réaction — les récents au-dessus de la grille (façon Notion) -->
|
||||
<div x-show="$store.fdIconPicker.title && $store.fdIconPicker.category !== 'recent' && !$store.fdIconPicker.query && $store.fdIconPicker.recent.length">
|
||||
<div class="fdip-section">Recent</div>
|
||||
<div class="fdip-grid">
|
||||
<template x-for="e in $store.fdIconPicker.recent.slice(0, 8)" :key="'rt' + e">
|
||||
<button class="fdip-emoji" @click="$store.fdIconPicker.pick($store.fdIconPicker.withTone(e))" x-text="$store.fdIconPicker.withTone(e)"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fdip-section" x-show="$store.fdIconPicker.category === 'recent' && !$store.fdIconPicker.query">Recent</div>
|
||||
<div class="fdip-grid">
|
||||
<template x-for="e in $store.fdIconPicker.items()" :key="e">
|
||||
|
||||
@@ -578,74 +578,132 @@
|
||||
background:var(--bg-modal,#1f1f1f);border:1px solid var(--border,#333);border-radius:10px;
|
||||
box-shadow:var(--shadow-modal);padding:6px;"></div>
|
||||
|
||||
<!-- ═══════════ Inline comment trigger on selection (v4.9.0) ═══════════ -->
|
||||
<button type="button" id="_commentSelBtn"
|
||||
style="display:none;position:fixed;z-index:1100;padding:7px 12px;font-size:13px;font-weight:600;
|
||||
color:#fff;background:var(--accent,#2383E2);border:none;border-radius:8px;cursor:pointer;
|
||||
box-shadow:0 4px 16px rgba(0,0,0,.35);" title="Comment on selection"
|
||||
@click="edCall('commentOnSelection')">
|
||||
💬 Comment
|
||||
</button>
|
||||
|
||||
<!-- ═══════════ Comments drawer (v4.9.0) ═══════════ -->
|
||||
<!-- ═══════════ Comments drawer (v4.9.0 · refonte v7.64 façon Notion) ═══════════ -->
|
||||
<div class="comments-drawer" x-show="commentsOpen" x-cloak x-transition
|
||||
style="position:fixed;top:var(--topbar-height,44px);right:0;bottom:0;width:320px;max-width:92vw;
|
||||
style="position:fixed;top:var(--topbar-height,44px);right:0;bottom:0;width:340px;max-width:92vw;
|
||||
background:var(--bg-primary,#1c1c1c);border-left:1px solid var(--border,#333);
|
||||
box-shadow:-8px 0 30px rgba(0,0,0,.25);z-index:900;display:flex;flex-direction:column;">
|
||||
<div class="comments-drawer-header"
|
||||
style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;
|
||||
border-bottom:1px solid var(--border,#333);font-weight:600;font-size:14px;">
|
||||
<span>Comments</span>
|
||||
<span>Comments <span style="color:var(--text-dim);font-weight:400;" x-text="'(' + commentCount + ')'"></span></span>
|
||||
<button type="button" class="topbar-btn" @click="toggleComments()" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="comments-drawer-list" style="flex:1;overflow-y:auto;padding:12px 16px;">
|
||||
<div x-show="comments.length === 0" style="text-align:center;color:var(--text-dim,#999);padding:32px 0;">
|
||||
<!-- v7.67 : les réactions emoji du texte vivent aussi ici (✕ = retirer) -->
|
||||
<div class="ct-reactions" x-show="reactionList().length" x-cloak style="margin-bottom:14px;">
|
||||
<div class="ct-section-title">Reactions</div>
|
||||
<template x-for="r in reactionList()" :key="r.key">
|
||||
<div class="ct-reaction">
|
||||
<span class="ct-re-emoji" x-text="r.emoji"></span>
|
||||
<span class="ct-re-count" x-show="r.count > 1" x-text="r.count"></span>
|
||||
<span class="ct-re-text" x-text="r.text"></span>
|
||||
<button type="button" class="ct-act" title="Remove reaction"
|
||||
x-show="r.mine" @click="removeReaction(r)">✕</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div x-show="comments.length === 0 && !reactionList().length" style="text-align:center;color:var(--text-dim,#999);padding:32px 0;">
|
||||
No comments yet. Select some text and click 💬 Comment.
|
||||
</div>
|
||||
<template x-for="c in comments" :key="c.id">
|
||||
<div class="comment-thread" style="margin-bottom:18px;"
|
||||
:style="c.anchor_block_id ? 'border-left:3px solid var(--accent);padding-left:10px;' : ''">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
|
||||
<div style="width:22px;height:22px;border-radius:50%;background:#3A3A3A;color:#fff;
|
||||
display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;"
|
||||
<div class="comment-thread" :data-thread="c.id"
|
||||
:class="{ 'is-resolved': c.resolved, 'has-anchor': !!c.anchor_block_id }">
|
||||
<div class="ct-head">
|
||||
<div class="ct-avatar"
|
||||
:style="{ background: c.author && c.author.avatar_color ? c.author.avatar_color : '#3A3A3A' }">
|
||||
<span x-text="(c.author ? (c.author.full_name || c.author.login || '?') : '?').charAt(0).toUpperCase()"></span>
|
||||
</div>
|
||||
<span style="font-size:12px;font-weight:600;" x-text="c.author ? (c.author.full_name || c.author.login) : 'Unknown'"></span>
|
||||
<span style="font-size:11px;color:var(--text-dim,#999);margin-left:auto;" x-text="fmtTime(c.created_at)"></span>
|
||||
</div>
|
||||
<div style="font-size:13px;white-space:pre-wrap;margin-bottom:6px;" x-text="c.body"></div>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<button class="btn-sm" style="font-size:11px;" @click="resolveComment(c.id)"
|
||||
x-text="c.resolved ? '↪ Reopen' : '✔ Resolve'"></button>
|
||||
<button class="btn-sm" style="font-size:11px;" x-show="c.user_id === currentUserId"
|
||||
@click="deleteComment(c.id)">🗑 Delete</button>
|
||||
<span class="ct-name" x-text="c.author ? (c.author.full_name || c.author.login) : 'Unknown'"></span>
|
||||
<span class="ct-time" x-text="fmtTime(c.created_at)"></span>
|
||||
<div class="ct-actions">
|
||||
<button type="button" class="ct-act" :title="c.resolved ? 'Reopen' : 'Resolve'"
|
||||
@click="resolveComment(c)"
|
||||
:class="{ 'is-on': c.resolved }">✓</button>
|
||||
<button type="button" class="ct-act" title="More"
|
||||
x-show="c.user_id === currentUserId"
|
||||
@click.stop="commentMenu = commentMenu === c.id ? null : c.id">⋯</button>
|
||||
</div>
|
||||
<div class="ct-menu" x-cloak x-show="commentMenu === c.id" @click.outside="commentMenu = null">
|
||||
<button type="button" class="ct-menu-item" @click="copyCommentLink(c.id)">🔗 Copy link</button>
|
||||
<button type="button" class="ct-menu-item is-danger" @click="deleteComment(c.id)">🗑 Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ct-body" x-text="c.body"></div>
|
||||
<div class="ct-anchor-label" x-show="!!c.anchor_block_id && !c.resolved">💬 on selected text</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="comments-drawer-input" style="border-top:1px solid var(--border,#333);padding:12px 16px;">
|
||||
<textarea x-model="commentDraft" rows="2"
|
||||
placeholder="Add a comment... Use @ to mention someone."
|
||||
style="width:100%;background:var(--bg-secondary,#2a2a2a);border:1px solid var(--border,#333);
|
||||
border-radius:8px;color:var(--text);font-size:13px;padding:8px 10px;resize:none;outline:none;"></textarea>
|
||||
<div style="display:flex;justify-content:flex-end;margin-top:8px;">
|
||||
<button class="btn btn-primary" style="font-size:12px;padding:6px 14px;" @click="addPageComment()">Comment</button>
|
||||
<div class="comments-drawer-input">
|
||||
<div class="ct-input-row">
|
||||
<textarea x-model="commentDraft" rows="1"
|
||||
placeholder="Add a comment..."
|
||||
@keydown.enter.exact.prevent="addPageComment()"></textarea>
|
||||
<div class="ct-input-actions">
|
||||
<button type="button" class="ct-act" title="Mention" @click="commentAt()">@</button>
|
||||
<button type="button" class="ct-send" title="Send"
|
||||
:disabled="!(commentDraft || '').trim()" @click="addPageComment()">↑</button>
|
||||
</div>
|
||||
</div>
|
||||
</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 : Comment à gauche, réaction à droite (le ✨ Ask AI sauté :
|
||||
doublon exact du bouton du pied, même action) -->
|
||||
<div class="ft-row ft-row-split">
|
||||
<button type="button" class="ft-item ft-comment" @click="fmtComment()" data-tooltip="Comment — Ctrl+⇧+M">{{ fd_icon("message-square",15) }}<span>Comment</span></button>
|
||||
<button type="button" class="ft-item ft-react" @click="fmtReact()" data-tooltip="React to selected text"><span class="ft-react-ico">🙂<span class="ft-react-plus">+</span></span></button>
|
||||
</div>
|
||||
<!-- Section « Skills » -->
|
||||
<div class="ft-sep"></div>
|
||||
<div class="ft-group ft-skills-head">Skills</div>
|
||||
<div class="ft-skills">
|
||||
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Improve the writing of this text (wording, clarity, style). Keep the meaning, language and format. Reply ONLY with the improved text.')" data-tooltip="Run on this block">Improve writing</button>
|
||||
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Proofread this text: fix spelling and grammar mistakes only. Reply ONLY with the corrected text.')" data-tooltip="Run on this block">Proofread</button>
|
||||
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Explain this text in simpler terms, in the same language. Reply ONLY with the explanation.')" data-tooltip="Run on this block">Explain</button>
|
||||
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Reformat this text for readability (structure, spacing, lists where useful) without changing its meaning. Reply ONLY with the reformatted text.')" data-tooltip="Run on this block">Reformat</button>
|
||||
</div>
|
||||
<!-- Pied de page -->
|
||||
<div class="ft-sep"></div>
|
||||
<button type="button" class="ft-item ft-foot" @click="fmtEditAI()" data-tooltip="Edit with AI">
|
||||
<span>Edit with AI</span>
|
||||
<kbd>Alt+⇧+E</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Copy Link Toast ═══════════ -->
|
||||
|
||||
@@ -8,6 +8,17 @@
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<meta name="theme-color" content="#191919">
|
||||
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
|
||||
<!-- v7.69 : identité visuelle (logo + bannière du projet) -->
|
||||
<meta name="description" content="FlowDeck — espace de travail style Notion auto-hébergé, intégré à Gitea/GitHub.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="FlowDeck">
|
||||
<meta property="og:title" content="FlowDeck — votre espace Notion auto-hébergé">
|
||||
<meta property="og:description" content="Éditeur de blocs, Kanban, bases de données et intégration Gitea/GitHub, sur votre propre serveur.">
|
||||
<meta property="og:image" content="{{ app_base_url() }}/static/img/logo-512.jpg">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="FlowDeck — votre espace Notion auto-hébergé">
|
||||
<meta name="twitter:description" content="Éditeur de blocs, Kanban, bases de données et intégration Gitea/GitHub.">
|
||||
<meta name="twitter:image" content="{{ app_base_url() }}/static/img/logo-512.jpg">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="stylesheet" href="/static/css/app.css?v={{ asset_version }}">
|
||||
|
||||
@@ -11,6 +11,17 @@
|
||||
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<!-- v7.69 : identité visuelle — la landing est la page la plus partagée -->
|
||||
<meta name="description" content="FlowDeck — espace de travail style Notion auto-hébergé, intégré à Gitea/GitHub. Éditeur de blocs, Kanban, bases de données.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="FlowDeck">
|
||||
<meta property="og:title" content="FlowDeck — votre espace Notion auto-hébergé">
|
||||
<meta property="og:description" content="Éditeur de blocs, Kanban, bases de données et intégration Gitea/GitHub, sur votre propre serveur.">
|
||||
<meta property="og:image" content="{{ app_base_url() }}/static/img/logo-512.jpg">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="FlowDeck — votre espace Notion auto-hébergé">
|
||||
<meta name="twitter:description" content="Éditeur de blocs, Kanban, bases de données et intégration Gitea/GitHub.">
|
||||
<meta name="twitter:image" content="{{ app_base_url() }}/static/img/logo-512.jpg">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #191919;
|
||||
@@ -112,6 +123,22 @@
|
||||
padding:12px 28px;
|
||||
}
|
||||
|
||||
/* v7.69 : identité visuelle */
|
||||
.brand-mark{border-radius:7px;flex:none;}
|
||||
.hero-visual{
|
||||
max-width:1060px;
|
||||
margin:0 auto 72px;
|
||||
padding:0 32px;
|
||||
}
|
||||
.hero-visual img{
|
||||
width:100%;
|
||||
height:auto;
|
||||
display:block;
|
||||
border-radius:14px;
|
||||
border:1px solid var(--border);
|
||||
box-shadow:0 30px 70px -30px rgba(0,0,0,.5);
|
||||
}
|
||||
|
||||
/* Features grid */
|
||||
.features{
|
||||
display:grid;
|
||||
@@ -148,7 +175,7 @@
|
||||
|
||||
<nav class="landing-nav">
|
||||
<a href="/" class="landing-logo">
|
||||
<span>{{ fd_icon("book",20) }}</span> FlowDeck
|
||||
<img class="brand-mark" src="/static/img/logo.webp" alt="" width="26" height="26"> FlowDeck
|
||||
</a>
|
||||
<div class="landing-nav-actions">
|
||||
<a href="/auth/login?provider=local" class="btn btn-secondary">Log in</a>
|
||||
@@ -169,6 +196,14 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- v7.69 : bannière du projet (asset optimisé : WebP 22 KB, JPEG en secours) -->
|
||||
<div class="hero-visual">
|
||||
<picture>
|
||||
<source srcset="/static/img/banner.webp" type="image/webp">
|
||||
<img src="/static/img/banner.jpg" alt="FlowDeck — éditeur de blocs, Kanban et bases de données" width="2400" height="793" loading="lazy" decoding="async">
|
||||
</picture>
|
||||
</div>
|
||||
|
||||
<div class="features">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">{{ fd_icon("edit",24) }}</div>
|
||||
|
||||
@@ -79,6 +79,18 @@ ENV.globals["csp_nonce"] = lambda: CSP_NONCE.get()
|
||||
ENV.globals["csrf_token"] = lambda: CSRF_TOKEN.get()
|
||||
# Plugins activés : conditionne les blocs de UI rendus côté serveur.
|
||||
ENV.globals["plugin_enabled"] = lambda slug: _plugin_enabled(slug)
|
||||
# v7.69 : URL de base absolue pour og:image / twitter:image — les crawlers
|
||||
# ignorent les URL relatives. Lecture paresseuse comme _plugin_enabled
|
||||
# (évite l'import circulaire au chargement du module). Même source que les
|
||||
# liens de partage de pages (collaboration.py) : si elle est mal renseignée,
|
||||
# ces liens le sont déjà.
|
||||
ENV.globals["app_base_url"] = lambda: _app_base_url()
|
||||
|
||||
|
||||
def _app_base_url() -> str:
|
||||
from app.config import settings
|
||||
|
||||
return (settings.app_base_url or "").rstrip("/")
|
||||
|
||||
|
||||
def _plugin_enabled(slug: str) -> bool:
|
||||
|
||||
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "FlowDeck",
|
||||
"version": "7.59.2"
|
||||
"version": "7.69.1"
|
||||
},
|
||||
"paths": {
|
||||
"/auth/register": {
|
||||
@@ -15368,6 +15368,97 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/pages/{page_id}/reactions": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"collaboration"
|
||||
],
|
||||
"summary": "List Reactions",
|
||||
"description": "Réactions regroupées par plage de texte : {block_id, start, end, emoji, count, mine}.",
|
||||
"operationId": "list_reactions_api_pages__page_id__reactions_get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "page_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"title": "Page Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"collaboration"
|
||||
],
|
||||
"summary": "Toggle Reaction",
|
||||
"description": "Ajoute ou retire la réaction de l'utilisateur sur une plage de texte (toggle).",
|
||||
"operationId": "toggle_reaction_api_pages__page_id__reactions_post",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "page_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"title": "Page Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/custom-emojis": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -30444,4 +30535,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
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');
|
||||
// refonte v7.67 : ligne 4 = Comment (libellé) à gauche, réaction à droite ;
|
||||
// entête Skills avec ≡ ; plus de bouton ✨ Ask AI (doublon du pied)
|
||||
await expect(bar.locator('.ft-comment')).toContainText('Comment');
|
||||
await expect(bar.locator('.ft-row-split .ft-react')).toHaveCount(1);
|
||||
await expect(bar.locator('.ft-skills-head')).toHaveText('Skills');
|
||||
await expect(bar.locator('[data-tooltip^="Ask AI"]')).toHaveCount(0);
|
||||
// géométrie : sous la sélection, entièrement dans le viewport
|
||||
const barBox = await bar.boundingBox();
|
||||
const selBox = await page.evaluate(() => {
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
/**
|
||||
* v7.63 — menus mobiles façon Notion (viewport 390×844) :
|
||||
* - « / » → barre horizontale défilante sous le curseur (15 boutons, ordre du doc)
|
||||
* - « + » → feuille plein écran « Insert block » (titre centré + Cancel, groupes,
|
||||
* raccourcis) → choix d'un type = applySlash
|
||||
* - « Turn into » / « Color » → feuilles réutilisant les sous-menus de bloc
|
||||
* - « More » → menu contextuel de bloc (desktop)
|
||||
* 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', viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
|
||||
|
||||
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) {
|
||||
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=Mobile menus ${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 });
|
||||
return id;
|
||||
}
|
||||
|
||||
test('barre mobile au / + feuilles Insert block / Turn into / More', async ({ page }) => {
|
||||
await login(page);
|
||||
await newPage(page);
|
||||
await page.click('#_blocksCt [data-bid]');
|
||||
await page.keyboard.type('/');
|
||||
|
||||
// ── 1. la barre apparaît, 15 boutons dans l'ordre de la description ──
|
||||
const bar = page.locator('#fdMobileBar');
|
||||
await expect(bar).toBeVisible({ timeout: 5000 });
|
||||
const keys = await page.evaluate(() =>
|
||||
Array.from(document.querySelectorAll('#fdMobileBar [data-mtb]')).map((b) => b.dataset.mtb));
|
||||
expect(keys).toEqual(['plus', 'turn', 'at', 'comment', 'image', 'trash',
|
||||
'left', 'right', 'up', 'down', 'undo', 'redo', 'color', 'more', 'kbd']);
|
||||
// défilement latéral
|
||||
const overflowX = await bar.evaluate((el) => getComputedStyle(el).overflowX);
|
||||
expect(overflowX).toBe('auto');
|
||||
// pas de popup desktop en parallèle
|
||||
await expect(page.locator('#_slashMenu')).toBeHidden();
|
||||
await page.screenshot({ path: 'shots/v763-bar.png' });
|
||||
|
||||
// ── 2. « + » → feuille plein écran « Insert block » ──
|
||||
await bar.locator('[data-mtb="plus"]').click();
|
||||
const sheet = page.locator('.fd-msheet');
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(sheet.locator('.fd-msheet-title')).toHaveText('Insert block');
|
||||
await expect(sheet.locator('.fd-msheet-cancel')).toHaveText('Cancel');
|
||||
await expect(sheet.locator('.sm-group').first()).toHaveText('Basic blocks');
|
||||
await expect(sheet.locator('.slash-item', { hasText: 'Heading 1' })).toHaveCount(1);
|
||||
await page.screenshot({ path: 'shots/v763-insert.png' });
|
||||
|
||||
// ── 3. choix d'un type → applySlash (le bloc devient un titre) ──
|
||||
await sheet.locator('.slash-item', { hasText: 'Heading 1' }).click();
|
||||
await expect(sheet).toHaveCount(0);
|
||||
await expect(bar).toBeHidden();
|
||||
expect(await page.evaluate(() => window.E.blocks[0].type)).toBe('heading_1');
|
||||
|
||||
// ── 4. « Turn into » → feuille réutilisant les items du sous-menu de bloc ──
|
||||
await page.keyboard.type('/');
|
||||
await expect(bar).toBeVisible();
|
||||
await bar.locator('[data-mtb="turn"]').click();
|
||||
await expect(sheet.locator('.fd-msheet-title')).toHaveText('Turn into');
|
||||
await expect(sheet.locator('.bm-item', { hasText: 'Bulleted list' })).toHaveCount(1);
|
||||
await expect(sheet.locator('.bm-check')).toHaveCount(1); // type courant coché
|
||||
await page.screenshot({ path: 'shots/v763-turn.png' });
|
||||
await sheet.locator('.fd-msheet-cancel').click();
|
||||
await expect(sheet).toHaveCount(0);
|
||||
expect(await page.evaluate(() => window.E.blocks[0].type)).toBe('heading_1');
|
||||
|
||||
// ── 5. « Color » → feuille avec les 3 groupes ──
|
||||
await page.evaluate(() => localStorage.setItem('fdLastBlockColor', 'color|#E5484D'));
|
||||
await bar.locator('[data-mtb="color"]').click();
|
||||
for (const g of ['Last used', 'Text color', 'Background color']) {
|
||||
await expect(sheet.locator('.bm-group', { hasText: g })).toHaveCount(1);
|
||||
}
|
||||
await sheet.locator('.fd-msheet-cancel').click();
|
||||
|
||||
// ── 6. « More » → menu contextuel de bloc (recherche + pied) ──
|
||||
await bar.locator('[data-mtb="more"]').click();
|
||||
const menu = page.locator('#blockMenu');
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.locator('.bm-search')).toHaveAttribute('placeholder', 'Search actions...');
|
||||
await expect(menu.locator('.bm-foot')).toContainText('Last edited by');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(menu).toBeHidden();
|
||||
|
||||
// ── 7. ↑ déplace réellement le bloc ── (bloc 0 = titre, bloc 1 = paragraphe)
|
||||
await page.click('#_blocksCt [data-bid]'); // bloc 0 (contient '/')
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(150); // le focus du nouveau bloc est différé (60 ms)
|
||||
await page.keyboard.type('/'); // bloc neuf et vide → slash exact = ouverture
|
||||
await expect(bar).toBeVisible(); // SM._i = 1 (bloc du bas)
|
||||
await bar.locator('[data-mtb="up"]').click();
|
||||
expect(await page.evaluate(() => window.E.blocks.map((b) => b.type).join(',')))
|
||||
.toBe('paragraph,heading_1');
|
||||
await page.screenshot({ path: 'shots/v763-moved.png' });
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
/**
|
||||
* v7.64.1 — Delete sur un bloc vide supprime le bloc (comme Backspace).
|
||||
* Instance de test attendue sur FD_BASE_URL (défaut 8081).
|
||||
*/
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
|
||||
const USER = process.env.FD_USER || '[email protected]';
|
||||
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||
|
||||
test.use({ serviceWorkers: 'block' });
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
const ok = await page
|
||||
.waitForURL('**/workspaces', { timeout: 8000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!ok) {
|
||||
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
|
||||
data: { email: USER, password: PASS, name: 'E2E' },
|
||||
});
|
||||
if (resp.status() !== 409) expect(resp.ok()).toBeTruthy();
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
}
|
||||
}
|
||||
|
||||
test('Delete : vide le bloc puis supprime le bloc vide', async ({ page }) => {
|
||||
await login(page);
|
||||
const cookies = await page.context().cookies();
|
||||
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
|
||||
const created = await page.request.post(
|
||||
`${FD_BASE}/board/api/pages?title=Delete key ${Date.now()}`,
|
||||
{ headers: { 'X-CSRF-Token': csrf } }
|
||||
);
|
||||
expect(created.status(), await created.text()).toBeLessThan(400);
|
||||
const { id } = await created.json();
|
||||
const bs = await page.request.post(`${FD_BASE}/board/api/pages/${id}/blocks`, {
|
||||
headers: { 'X-CSRF-Token': csrf },
|
||||
data: { title: 'Delete key', blocks: [
|
||||
{ id: 'b1', type: 'paragraph', content: 'hello' },
|
||||
{ id: 'b2', type: 'paragraph', content: 'world' },
|
||||
] },
|
||||
});
|
||||
expect(bs.status(), await bs.text()).toBeLessThan(400);
|
||||
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
|
||||
|
||||
// ── 1. Delete avec du texte : suppression avant le curseur (comportement natif) ──
|
||||
await page.locator('#_blocksCt [data-bid]').nth(1).click();
|
||||
await page.keyboard.press('Control+a');
|
||||
await page.keyboard.press('Delete');
|
||||
await page.waitForTimeout(200);
|
||||
const contents = await page.evaluate(() => {
|
||||
window.E.sync();
|
||||
return window.E.blocks.map((b) => b.content);
|
||||
});
|
||||
expect(contents[0]).toBe('hello');
|
||||
expect(contents[1].trim()).toBe(''); // bloc vidé (un <br> résiduel), mais toujours là
|
||||
|
||||
// ── 2. Delete sur le bloc vide : le bloc est supprimé ──
|
||||
await page.keyboard.press('Delete');
|
||||
await page.waitForTimeout(300);
|
||||
expect(await page.evaluate(() => window.E.blocks.length)).toBe(1);
|
||||
expect(await page.evaluate(() => window.E.blocks[0].content)).toBe('hello');
|
||||
await page.screenshot({ path: 'shots/v7641-delete.png' });
|
||||
|
||||
// ── 3. dernier bloc vide : jamais supprimé (l'éditeur garde un bloc) ──
|
||||
await page.locator('#_blocksCt [data-bid]').first().click();
|
||||
await page.keyboard.press('Control+a');
|
||||
await page.keyboard.press('Delete');
|
||||
await page.waitForTimeout(150);
|
||||
await page.keyboard.press('Delete');
|
||||
await page.waitForTimeout(300);
|
||||
expect(await page.evaluate(() => window.E.blocks.length)).toBe(1);
|
||||
expect(await page.evaluate(() => window.E.blocks[0].type)).toBe('paragraph');
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
/**
|
||||
* v7.64 — commentaires & réactions façon Notion :
|
||||
* - sélection → bouton 🙂+ (« React to selected text ») → sélecteur d'émoji avec
|
||||
* le texte sélectionné en en-tête → pastille + plage jaune, contenu inchangé
|
||||
* - sélection → 💬 → tiroir refondu (avatar, nom, ✓, ⋯, saisie avec @ et ↑) →
|
||||
* fil ancré + plage jaune, clic sur l'ancre rouvre le fil, ✓ résout
|
||||
* - reload : ancres et pastilles repeintes depuis la base
|
||||
* Instance de test attendue sur FD_BASE_URL (défaut 8081).
|
||||
*/
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
|
||||
const USER = process.env.FD_USER || '[email protected]';
|
||||
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||
|
||||
test.use({ serviceWorkers: 'block' });
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
const ok = await page
|
||||
.waitForURL('**/workspaces', { timeout: 8000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!ok) {
|
||||
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
|
||||
data: { email: USER, password: PASS, name: 'E2E' },
|
||||
});
|
||||
if (resp.status() !== 409) expect(resp.ok()).toBeTruthy();
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
}
|
||||
}
|
||||
|
||||
async function newPage(page, body) {
|
||||
const cookies = await page.context().cookies();
|
||||
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
|
||||
const created = await page.request.post(
|
||||
`${FD_BASE}/board/api/pages?title=Comments ${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: 'Comments', 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électionne une SOUS-chaîne du bloc (ancre = plage, pas le bloc entier)
|
||||
async function selectSub(page, sub) {
|
||||
await page.evaluate((sub) => {
|
||||
const el = document.querySelector('#_blocksCt [data-bid]');
|
||||
const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
let n;
|
||||
while ((n = w.nextNode())) {
|
||||
if (n.parentElement && n.parentElement.closest('.fd-react-chip')) continue;
|
||||
const i = n.nodeValue.indexOf(sub);
|
||||
if (i < 0) continue;
|
||||
const r = document.createRange();
|
||||
r.setStart(n, i);
|
||||
r.setEnd(n, i + sub.length);
|
||||
const s = getSelection();
|
||||
s.removeAllRanges();
|
||||
s.addRange(r);
|
||||
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
||||
return;
|
||||
}
|
||||
throw new Error('sous-chaîne introuvable : ' + sub);
|
||||
}, sub);
|
||||
await expect(page.locator('.format-toolbar')).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
|
||||
// sélectionne tout le bloc et simule la fin du glisser-souris
|
||||
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('réaction emoji + commentaires ancrés', async ({ page }) => {
|
||||
await login(page);
|
||||
await newPage(page, [{ id: 'b1', type: 'paragraph', content: 'Prise de reaction a un texte' }]);
|
||||
await expect(page.locator('#_blocksCt [data-bid]')).toHaveText('Prise de reaction a un texte');
|
||||
|
||||
// ── 1. bouton réaction dans la toolbar ──
|
||||
await selectBlock(page);
|
||||
const react = page.locator('.format-toolbar .ft-react');
|
||||
await expect(react).toHaveAttribute('data-tooltip', 'React to selected text');
|
||||
await page.screenshot({ path: 'shots/v764-toolbar-react.png' });
|
||||
await react.click();
|
||||
|
||||
// ── 2. sélecteur : en-tête = texte sélectionné, Filter…, Recent/People ──
|
||||
const picker = page.locator('.fd-icon-picker');
|
||||
await expect(picker).toBeVisible();
|
||||
await expect(picker.locator('.fdip-title-text')).toHaveText('Prise de reaction a un texte');
|
||||
await expect(picker.locator('.fdip-search')).toHaveAttribute('placeholder', 'Filter...');
|
||||
await expect(picker.locator('.fdip-tabs')).toBeHidden(); // mode réaction : emoji only
|
||||
await page.screenshot({ path: 'shots/v764-picker.png' });
|
||||
await picker.locator('.fdip-emoji').first().click();
|
||||
await expect(picker).toBeHidden();
|
||||
|
||||
// ── 3. pastille + plage jaune, contenu du bloc inchangé ──
|
||||
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1);
|
||||
await expect(page.locator('#_blocksCt .fd-anchor-react')).toHaveCount(1);
|
||||
const content = await page.evaluate(() => {
|
||||
window.E.sync();
|
||||
return window.E.blocks[0].content;
|
||||
});
|
||||
expect(content).toBe('Prise de reaction a un texte'); // la pastille n'est pas du contenu
|
||||
await page.screenshot({ path: 'shots/v764-chip.png' });
|
||||
|
||||
// ── 4. commentaire sur une SOUS-sélection : toolbar 💬 → tiroir refondu → envoi ──
|
||||
await expect(page.locator('#_commentSelBtn')).toHaveCount(0); // bouton bleu retiré
|
||||
await selectSub(page, 'reaction a un');
|
||||
await page.locator('.format-toolbar .ft-item[data-tooltip="Comment — Ctrl+⇧+M"]').click();
|
||||
const drawer = page.locator('.comments-drawer');
|
||||
await expect(drawer).toBeVisible();
|
||||
await drawer.locator('textarea').fill('Ceci est mon premier commentaire');
|
||||
await expect(drawer.locator('.ct-send')).toBeEnabled();
|
||||
await drawer.locator('.ct-send').click();
|
||||
const thread = page.locator('.comment-thread');
|
||||
await expect(thread).toHaveCount(1);
|
||||
await expect(thread.locator('.ct-avatar')).toHaveCount(1);
|
||||
await expect(thread.locator('.ct-name')).not.toHaveText('');
|
||||
await expect(thread.locator('.ct-act')).toHaveCount(2); // ✓ + ⋯
|
||||
await expect(thread.locator('.ct-body')).toHaveText('Ceci est mon premier commentaire');
|
||||
// l'ancre jaune = UNIQUEMENT la plage sélectionnée, pas le bloc
|
||||
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveCount(1);
|
||||
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveText('reaction a un');
|
||||
await page.screenshot({ path: 'shots/v764-drawer.png' });
|
||||
|
||||
// ── 5. menu ⋯ du fil (Copy link / Delete) ──
|
||||
await thread.locator('.ct-act').nth(1).click();
|
||||
await expect(thread.locator('.ct-menu')).toBeVisible();
|
||||
await expect(thread.locator('.ct-menu-item').first()).toContainText('Copy link');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.locator('.comments-drawer-header .topbar-btn').click();
|
||||
await expect(drawer).toBeHidden();
|
||||
|
||||
// ── 6. clic sur l'ancre jaune → le fil se rouvre ──
|
||||
await page.locator('#_blocksCt .fd-anchor[data-cid]').click();
|
||||
await expect(drawer).toBeVisible();
|
||||
await expect(page.locator('.comment-thread')).toHaveCount(1);
|
||||
|
||||
// ── 7. ✓ résout le fil (ancre en gris) ──
|
||||
await page.locator('.comment-thread .ct-act').first().click();
|
||||
await expect(page.locator('.comment-thread.is-resolved')).toHaveCount(1);
|
||||
await expect(page.locator('#_blocksCt .fd-anchor.fd-anchor-done')).toHaveCount(1);
|
||||
|
||||
// ── 8. reload : ancres + pastilles rechargées depuis la base ──
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
|
||||
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveCount(1, { timeout: 10000 });
|
||||
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveText('reaction a un');
|
||||
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1, { timeout: 10000 });
|
||||
|
||||
// ── 9. v7.67 : la réaction vit aussi dans le tiroir Comments ──
|
||||
await page.locator('.topbar-btn[title="Comments"]').click();
|
||||
const re = page.locator('.ct-reactions');
|
||||
await expect(re).toBeVisible();
|
||||
await expect(re.locator('.ct-re-emoji')).toHaveCount(1);
|
||||
await expect(re.locator('.ct-re-text')).toHaveText('Prise de reaction a un texte');
|
||||
await page.screenshot({ path: 'shots/v764-drawer-reactions.png' });
|
||||
|
||||
// ── 10. suppression A : ✕ de la section Réactions ──
|
||||
await re.locator('.ct-reaction .ct-act').click();
|
||||
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(0);
|
||||
await expect(re).toBeHidden();
|
||||
|
||||
// ── 11. suppression B : la croix survolée de la pastille ──
|
||||
await page.locator('.topbar-btn[title="Comments"]').click(); // referme le tiroir
|
||||
await selectBlock(page);
|
||||
await page.locator('.format-toolbar .ft-react').click();
|
||||
const picker2 = page.locator('.fd-icon-picker');
|
||||
await expect(picker2).toBeVisible();
|
||||
await picker2.locator('.fdip-emoji').first().click();
|
||||
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1);
|
||||
const emo = page.locator('#_blocksCt .fd-react-emo.is-mine');
|
||||
await expect(emo).toHaveCount(1);
|
||||
// la croix n'apparaît qu'au survol (::after)
|
||||
await expect.poll(() => emo.evaluate((el) => getComputedStyle(el, '::after').display)).toBe('none');
|
||||
await page.locator('#_blocksCt .fd-react-chip').hover();
|
||||
await expect.poll(() => emo.evaluate((el) => getComputedStyle(el, '::after').display)).toBe('flex');
|
||||
await emo.click();
|
||||
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
/**
|
||||
* v7.66 — deux ajustements éditeur :
|
||||
* - « / » ouvre le menu slash → Escape ferme le menu ET retire le « / »
|
||||
* - survol d'un bloc → bouton à droite (bulle + ) qui commente le bloc
|
||||
* Instance de test attendue sur FD_BASE_URL (défaut 8081).
|
||||
*/
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
|
||||
const USER = process.env.FD_USER || '[email protected]';
|
||||
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||
|
||||
test.use({ serviceWorkers: 'block' });
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
const ok = await page
|
||||
.waitForURL('**/workspaces', { timeout: 8000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!ok) {
|
||||
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
|
||||
data: { email: USER, password: PASS, name: 'E2E' },
|
||||
});
|
||||
if (resp.status() !== 409) expect(resp.ok()).toBeTruthy();
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
}
|
||||
}
|
||||
|
||||
async function newPage(page, body) {
|
||||
const cookies = await page.context().cookies();
|
||||
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
|
||||
const created = await page.request.post(
|
||||
`${FD_BASE}/board/api/pages?title=v766 ${Date.now()}`,
|
||||
{ headers: { 'X-CSRF-Token': csrf } }
|
||||
);
|
||||
expect(created.status(), await created.text()).toBeLessThan(400);
|
||||
const { id } = await created.json();
|
||||
if (body) {
|
||||
const bs = await page.request.post(`${FD_BASE}/board/api/pages/${id}/blocks`, {
|
||||
headers: { 'X-CSRF-Token': csrf },
|
||||
data: { title: 'v766', blocks: body },
|
||||
});
|
||||
expect(bs.status(), await bs.text()).toBeLessThan(400);
|
||||
}
|
||||
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
|
||||
return id;
|
||||
}
|
||||
|
||||
test('Escape du menu slash retire le caractère /', async ({ page }) => {
|
||||
await login(page);
|
||||
await newPage(page, [{ id: 'b1', type: 'paragraph', content: '' }]);
|
||||
const blk = page.locator('#_blocksCt [data-bid]').first();
|
||||
await blk.click();
|
||||
await page.keyboard.type('/');
|
||||
await expect(page.locator('#_slashMenu')).toBeVisible({ timeout: 5000 });
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#_slashMenu')).toBeHidden();
|
||||
// le « / » a été retiré — pas de caractère résiduel
|
||||
await expect(blk).toHaveText('');
|
||||
await page.screenshot({ path: 'shots/v766-slash-esc.png' });
|
||||
});
|
||||
|
||||
test('bouton commenter à droite du bloc', async ({ page }) => {
|
||||
await login(page);
|
||||
// 2 blocs : l'éditeur focalise le 1er au chargement (`:focus-within` le
|
||||
// montre) → on mesure sur le 2e, ni focalisé ni survolé.
|
||||
await newPage(page, [
|
||||
{ id: 'b1', type: 'paragraph', content: 'Premier bloc' },
|
||||
{ id: 'b2', type: 'paragraph', content: 'Un bloc a commenter' },
|
||||
]);
|
||||
await expect(page.locator('#_blocksCt .block-comment-btn')).toHaveCount(2);
|
||||
const wrap = page.locator('#_blocksCt .block-wrapper').nth(1);
|
||||
const btn = page.locator('#_blocksCt .block-comment-btn').nth(1);
|
||||
|
||||
// masqué tant qu'on ne survole pas / ne focalise pas le bloc
|
||||
await expect.poll(() => btn.evaluate((el) => getComputedStyle(el).opacity)).toBe('0');
|
||||
await wrap.hover();
|
||||
await expect.poll(() => btn.evaluate((el) => getComputedStyle(el).opacity)).toBe('1');
|
||||
|
||||
// bien à droite du bloc (gouttière 64px)
|
||||
const wb = await wrap.boundingBox();
|
||||
const bb = await btn.boundingBox();
|
||||
expect(bb.x).toBeGreaterThan(wb.x + wb.width - 1);
|
||||
await page.screenshot({ path: 'shots/v766-comment-btn.png' });
|
||||
|
||||
// clic → tiroir → envoi → ancre = le bloc visé (et pas le 1er)
|
||||
await btn.click();
|
||||
const drawer = page.locator('.comments-drawer');
|
||||
await expect(drawer).toBeVisible();
|
||||
await drawer.locator('textarea').fill('Commentaire via le bouton de droite');
|
||||
await drawer.locator('.ct-send').click();
|
||||
await expect(page.locator('.comment-thread')).toHaveCount(1);
|
||||
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveText('Un bloc a commenter');
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// VRAIE sélection multi-blocs : glisser la souris du 1er bloc jusqu'au 3e
|
||||
// (le navigateur seul ne franchit jamais deux contenteditable)
|
||||
async function dragAcross(page, last = 2) {
|
||||
const b1 = await page.locator('#_blocksCt [data-bid]').first().boundingBox();
|
||||
const b3 = await page.locator('#_blocksCt [data-bid]').nth(last).boundingBox();
|
||||
// on part du tout début du 1er bloc et on va jusqu'à la fin du dernier
|
||||
await page.mouse.move(b1.x + 2, b1.y + b1.height / 2);
|
||||
await page.mouse.down();
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
await page.mouse.move(b1.x + 2, b1.y + b1.height / 2 + ((b3.y - b1.y) * i) / 8);
|
||||
}
|
||||
await page.mouse.move(b3.x + b3.width - 2, b3.y + b3.height / 2);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
// sélection multi-blocs au CLAVIER : caret en fin de bloc 1, Maj+↓ Maj+↓
|
||||
async function shiftExtend(page) {
|
||||
// VRAI geste : un clic met le caret dans le bloc, End va à sa fin,
|
||||
// Maj+↓ l'étend ensuite dans les blocs suivants.
|
||||
const el = page.locator('#_blocksCt [data-bid]').first();
|
||||
const b = await el.boundingBox();
|
||||
await page.mouse.click(b.x + 2, b.y + b.height / 2);
|
||||
await page.keyboard.press('End');
|
||||
await page.keyboard.press('Shift+ArrowDown');
|
||||
await page.keyboard.press('Shift+ArrowDown');
|
||||
await page.waitForTimeout(80);
|
||||
}
|
||||
|
||||
// état de la sélection côté éditeur
|
||||
const clipState = (page) => page.evaluate(() => window.E._clipState());
|
||||
|
||||
test('colonnes : slash + Turn into 2 colonnes', async ({ page }) => {
|
||||
await login(page);
|
||||
await newPage(page, [{ id: 'b1', type: 'paragraph', content: '' }]);
|
||||
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' },
|
||||
]);
|
||||
// sélection multi-blocs au CLAVIER (Maj+↓) — geste réel
|
||||
await shiftExtend(page);
|
||||
const st = await clipState(page);
|
||||
expect(st.si).toBe(0);
|
||||
expect(st.ei).toBe(2);
|
||||
// VRAI raccourci clavier (pas execCommand) : c'est lui qui était cassé
|
||||
await page.keyboard.press('Control+c');
|
||||
await page.waitForTimeout(250);
|
||||
const clip = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(clip).toContain('## Titre deux'); // le titre sort en markdown
|
||||
expect(clip).toContain('Troisieme bloc');
|
||||
|
||||
// coupe (raccourci Ctrl+X) : on recouvre les 3 blocs du début à la fin
|
||||
await dragAcross(page);
|
||||
await page.keyboard.press('Control+x');
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.locator('#_blocksCt [data-bid]')).toHaveCount(2);
|
||||
await expect(page.locator('#_blocksCt [data-bid]').first()).toHaveText('');
|
||||
|
||||
// colle : les 3 blocs reviennent APRÈS le bloc du curseur (comme l'entrée
|
||||
// Paste du menu) → 2 blocs restants + 3 collés = 5
|
||||
await page.keyboard.press('Control+v');
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.locator('#_blocksCt [data-bid]')).toHaveCount(5, { timeout: 5000 });
|
||||
await expect(page.locator('#_blocksCt [data-bid]').nth(1)).toContainText('Premier bloc');
|
||||
await expect(page.locator('#_blocksCt [data-bid]').nth(2)).toContainText('Titre deux');
|
||||
});
|
||||
|
||||
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 dragAcross(page, 1);
|
||||
// la sélection multi-blocs doit SURVIVRE au clic droit (le navigateur
|
||||
// déplacerait sinon le curseur dans le bloc cliqué)
|
||||
expect((await clipState(page)).ei).toBe(1);
|
||||
await page.locator('#_blocksCt [data-bid]').nth(1).click({ button: 'right', position: { x: 30, y: 6 } });
|
||||
expect((await clipState(page)).ei).toBe(1);
|
||||
const menu = page.locator('#blockMenu');
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.locator('[data-bm-act="clip-copy"]')).toBeVisible();
|
||||
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');
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
// v7.69.0 — la sélection multi-blocs SURVIT aux gestes qui l'effaçaient :
|
||||
// 1) glisser la souris d'un bloc à l'autre
|
||||
// 2) Ctrl+C sur cette sélection
|
||||
// 3) clic droit → le menu n'efface plus le surlignage
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
|
||||
const USER = process.env.FD_USER || '[email protected]';
|
||||
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||
|
||||
test.use({ serviceWorkers: 'block', permissions: ['clipboard-read', 'clipboard-write'] });
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
const ok = await page.waitForURL('**/workspaces', { timeout: 8000 }).then(() => true).catch(() => false);
|
||||
if (!ok) {
|
||||
await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } });
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
}
|
||||
}
|
||||
|
||||
async function newPage(page, body) {
|
||||
const cookies = await page.context().cookies();
|
||||
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
|
||||
const created = await page.request.post(`${FD_BASE}/board/api/pages?title=sel ${Date.now()}`, {
|
||||
headers: { 'X-CSRF-Token': csrf },
|
||||
});
|
||||
const { id } = await created.json();
|
||||
if (body) {
|
||||
await page.request.post(`${FD_BASE}/board/api/pages/${id}/blocks`, {
|
||||
headers: { 'X-CSRF-Token': csrf },
|
||||
data: { title: 'sel', blocks: body },
|
||||
});
|
||||
}
|
||||
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
|
||||
}
|
||||
|
||||
const BLOCKS = [
|
||||
{ id: 'b1', type: 'paragraph', content: 'Premier bloc' },
|
||||
{ id: 'b2', type: 'heading_2', content: 'Titre deux' },
|
||||
{ id: 'b3', type: 'paragraph', content: 'Troisieme bloc' },
|
||||
];
|
||||
|
||||
// état natif de la sélection
|
||||
const nativeSel = (page) =>
|
||||
page.evaluate(() => {
|
||||
const s = window.getSelection();
|
||||
return { rc: s.rangeCount, coll: s.isCollapsed, txt: s.toString().slice(0, 40) };
|
||||
});
|
||||
const clipState = (page) => page.evaluate(() => window.E._clipState());
|
||||
|
||||
// VRAI glisser de souris : le navigateur seul ne franchit jamais deux
|
||||
// contenteditable — c'est _buildSelAt qui reconstruit la plage.
|
||||
async function dragAcross(page, last = 2) {
|
||||
const b1 = await page.locator('#_blocksCt [data-bid]').first().boundingBox();
|
||||
const bEnd = await page.locator('#_blocksCt [data-bid]').nth(last).boundingBox();
|
||||
// on part du tout début du 1er bloc et on va jusqu'à la fin du dernier
|
||||
await page.mouse.move(b1.x + 2, b1.y + b1.height / 2);
|
||||
await page.mouse.down();
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
await page.mouse.move(b1.x + 2, b1.y + b1.height / 2 + ((bEnd.y - b1.y) * i) / 8);
|
||||
}
|
||||
await page.mouse.move(bEnd.x + bEnd.width - 2, bEnd.y + bEnd.height / 2);
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(80);
|
||||
}
|
||||
|
||||
test('glisser la souris sur 3 blocs construit une sélection multi-blocs qui survit', async ({ page }) => {
|
||||
await login(page);
|
||||
await newPage(page, BLOCKS);
|
||||
await dragAcross(page);
|
||||
|
||||
// la plage reconstruite ne doit PAS être effacée par le « click » suivant
|
||||
const s = await nativeSel(page);
|
||||
expect(s.coll).toBe(false);
|
||||
expect(s.rc).toBe(1);
|
||||
|
||||
const st = await clipState(page);
|
||||
expect(st.si).toBe(0);
|
||||
expect(st.ei).toBe(2);
|
||||
await page.screenshot({ path: 'shots/v769-drag.png' });
|
||||
});
|
||||
|
||||
// VRAI raccourci clavier (pas execCommand) : c'est ce qui était cassé.
|
||||
// Maj+↓ depuis la fin du bloc 1 → la sélection traverse les 3 blocs.
|
||||
async function keyboardExtend(page) {
|
||||
const b1 = await page.locator('#_blocksCt [data-bid]').first().boundingBox();
|
||||
await page.mouse.click(b1.x + 2, b1.y + b1.height / 2);
|
||||
await page.keyboard.press('End');
|
||||
await page.keyboard.press('Shift+ArrowDown');
|
||||
await page.keyboard.press('Shift+ArrowDown');
|
||||
await page.waitForTimeout(80);
|
||||
}
|
||||
|
||||
test('Ctrl+C copie la sélection multi-blocs en markdown', async ({ page }) => {
|
||||
await login(page);
|
||||
await newPage(page, BLOCKS);
|
||||
await keyboardExtend(page);
|
||||
expect((await clipState(page)).ei).toBe(2);
|
||||
|
||||
await page.keyboard.press('Control+c'); // le vrai raccourci
|
||||
await page.waitForTimeout(250);
|
||||
const clip = await page.evaluate(() => navigator.clipboard.readText());
|
||||
|
||||
expect(clip).toContain('## Titre deux'); // le titre sort en markdown
|
||||
expect(clip).toContain('Troisieme bloc');
|
||||
|
||||
// Ctrl+X retire les blocs traversés (tête + queue restent)
|
||||
await page.keyboard.press('Control+x');
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.locator('#_blocksCt [data-bid]')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test("clic droit : le menu s'ouvre SANS effacer le surlignage", async ({ page }) => {
|
||||
await login(page);
|
||||
await newPage(page, BLOCKS);
|
||||
await dragAcross(page);
|
||||
expect((await nativeSel(page)).coll).toBe(false);
|
||||
|
||||
await page.locator('#_blocksCt [data-bid]').nth(1).click({ button: 'right', position: { x: 30, y: 6 } });
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
// le surlignage reste visible
|
||||
const s = await nativeSel(page);
|
||||
expect(s.coll).toBe(false);
|
||||
expect(s.txt.length).toBeGreaterThan(0);
|
||||
|
||||
// …et le groupe Clipboard est bien proposé
|
||||
const menu = page.locator('#blockMenu');
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu).toContainText('Clipboard');
|
||||
await expect(menu.locator('[data-bm-act="clip-copy"]')).toBeVisible();
|
||||
await page.screenshot({ path: 'shots/v769-rightclick.png' });
|
||||
});
|
||||
|
||||
test('clic droit → Copy via le menu colle bien le markdown figé', async ({ page }) => {
|
||||
await login(page);
|
||||
await newPage(page, BLOCKS);
|
||||
await dragAcross(page);
|
||||
await page.locator('#_blocksCt [data-bid]').nth(1).click({ button: 'right', position: { x: 30, y: 6 } });
|
||||
const menu = page.locator('#blockMenu');
|
||||
await expect(menu.locator('[data-bm-act="clip-copy"]')).toBeVisible();
|
||||
await menu.locator('[data-bm-act="clip-copy"]').click();
|
||||
await page.waitForTimeout(200);
|
||||
const clip = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(clip).toContain('Premier bloc');
|
||||
expect(clip).toContain('## Titre deux');
|
||||
});
|
||||
|
||||
test('un clic simple dans un bloc place quand même le curseur (pas de sélection fantôme)', async ({ page }) => {
|
||||
await login(page);
|
||||
await newPage(page, BLOCKS);
|
||||
await dragAcross(page);
|
||||
// le clic suivant est un NOUVEL geste : il doit effacer la sélection
|
||||
const b1 = await page.locator('#_blocksCt [data-bid]').first().boundingBox();
|
||||
await page.mouse.click(b1.x + 15, b1.y + b1.height / 2);
|
||||
await page.waitForTimeout(120);
|
||||
const st = await clipState(page);
|
||||
expect(st).toBeNull();
|
||||
});
|
||||
@@ -2341,6 +2341,65 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* + « ajouter un bloc dessous » — juste à gauche du handle (gouttière 64px) */
|
||||
.block-insert-btn {
|
||||
position: absolute;
|
||||
left: -64px;
|
||||
top: 5px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: var(--text-dim);
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.block-wrapper:hover .block-insert-btn,
|
||||
.block-wrapper:focus-within .block-insert-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
.block-insert-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── v7.66 : bouton « commenter ce bloc » — à droite du bloc (gouttière 64px),
|
||||
bulle de conversation + , visible au survol comme le handle ── */
|
||||
.block-comment-btn {
|
||||
position: absolute;
|
||||
right: -60px;
|
||||
top: 4px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.block-wrapper:hover .block-comment-btn,
|
||||
.block-wrapper:focus-within .block-comment-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
.block-comment-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── v5.10.0: selection multiple + menu contexte bloc ── */
|
||||
.block-wrapper.selected {
|
||||
background: rgba(35,131,226,0.06);
|
||||
@@ -2376,6 +2435,30 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
.block-menu .bm-item.bm-danger .bm-ico { color: #e5484d; }
|
||||
.block-menu .bm-item.bm-danger:hover { background: rgba(229,72,77,.12); }
|
||||
.block-menu .bm-sep { height: 1px; background: var(--border, #2a2a2a); margin: 5px 4px; }
|
||||
/* ── v7.61 : menu contextuel type Notion — recherche, sections, pied, sous-menus ── */
|
||||
.block-menu .bm-group {
|
||||
padding: 9px 9px 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
}
|
||||
.block-menu .bm-foot {
|
||||
border-top: 1px solid var(--border, #2a2a2a);
|
||||
margin-top: 6px;
|
||||
padding: 8px 9px 6px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.block-menu .bm-check { margin-left: auto; color: var(--accent, #2383e2); font-size: 12px; }
|
||||
.block-menu .bm-item.bm-current { background: var(--bg-secondary); }
|
||||
.block-menu .bm-dot {
|
||||
display: inline-block; width: 12px; height: 12px; border-radius: 3px;
|
||||
border: 1px solid var(--border, #333); vertical-align: -2px;
|
||||
}
|
||||
.block-menu .bm-search:focus { border-color: var(--accent, #2383e2); }
|
||||
.block-menu .bm-colors { padding: 6px 9px; }
|
||||
.block-menu .bm-colors-label { font-size: 10px; font-weight: 600; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: .4px; margin-bottom: 6px; }
|
||||
.block-menu .bm-swatches { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
@@ -2406,7 +2489,7 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
color: var(--text-secondary); cursor: pointer; font-size: 12px; border-radius: 6px;
|
||||
}
|
||||
.block-menu .bm-cancel:hover { background: var(--bg-hover); }
|
||||
.block-menu-sub { min-width: 180px; max-height: 320px; overflow-y: auto; }
|
||||
.block-menu-sub { min-width: 230px; max-height: 60vh; overflow-y: auto; }
|
||||
|
||||
.ftable-hdr-toggle { margin-left: 12px; font-size: 12px; color: var(--text-tertiary); display:inline-flex; align-items:center; gap:2px; }
|
||||
.ftable-hdr-toggle label { display:inline-flex; align-items:center; gap:4px; cursor:pointer; }
|
||||
@@ -2782,6 +2865,11 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
border-radius: 50%;
|
||||
line-height: 1;
|
||||
}
|
||||
/* v7.68 : la × de suppression de colonne était en opacity:0 SANS règle de
|
||||
survol → invisible et pourtant cliquable (même piège que .block-actions) */
|
||||
.block-column-wrapper:hover .block-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
.block-column {
|
||||
outline: none;
|
||||
min-height: 24px;
|
||||
@@ -2861,35 +2949,410 @@ 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;
|
||||
}
|
||||
|
||||
/* ── v7.67 : refonte façon capture — ligne Comment | réaction aux 2 extrémités,
|
||||
entête « Skills » avec ≡ à droite, liste Skills scrollable ── */
|
||||
.format-toolbar .ft-row-split {
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
}
|
||||
.format-toolbar .ft-comment {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.format-toolbar .ft-skills-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.format-toolbar .ft-skills-head::after {
|
||||
content: '≡';
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.format-toolbar .ft-skills {
|
||||
max-height: 132px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Séparateurs, groupe « Skills », pied de page */
|
||||
.format-toolbar .ft-sep {
|
||||
height: 1px;
|
||||
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;
|
||||
}
|
||||
|
||||
/* ══ v7.63 — Menus mobiles façon Notion : barre horizontale au « / » ══
|
||||
(défilement latéral) + feuille plein écran Insert block / Turn into / Color ══ */
|
||||
.fd-mtb {
|
||||
position: fixed;
|
||||
z-index: 1300;
|
||||
display: none;
|
||||
gap: 2px;
|
||||
padding: 5px 6px;
|
||||
max-width: calc(100vw - 16px);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
background: var(--bg-modal);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,.45);
|
||||
}
|
||||
.fd-mtb::-webkit-scrollbar { display: none; }
|
||||
.fd-mtb .fd-mtb-item {
|
||||
flex: 0 0 auto;
|
||||
min-width: 38px;
|
||||
height: 38px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.fd-mtb .fd-mtb-item:active { background: var(--bg-hover); }
|
||||
.fd-mtb .fd-mtb-wide { font-size: 13px; font-weight: 600; padding: 0 10px; }
|
||||
.fd-mtb .fd-mtb-caret { font-size: 10px; color: var(--text-dim); }
|
||||
|
||||
/* Feuille plein écran (titre centré + Cancel bleu à droite, liste qui défile) */
|
||||
.fd-msheet {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1500;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary, #191919);
|
||||
}
|
||||
.fd-msheet-head {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.fd-msheet-title { font-size: 15px; font-weight: 600; color: var(--text-primary); }
|
||||
.fd-msheet-cancel {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent, #2383E2);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* la liste porte la classe block-menu (hérite des styles .bm-item/.bm-group)
|
||||
mais doit couvrir tout l'écran, sans la chrome du menu flottant */
|
||||
.fd-msheet-list.block-menu {
|
||||
max-width: none;
|
||||
min-width: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 6px 8px 40px;
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.fd-msheet .bm-item { padding: 12px 10px; font-size: 15px; }
|
||||
.fd-msheet .bm-group { padding: 14px 10px 4px; }
|
||||
.fd-msheet .sm-group {
|
||||
padding: 14px 10px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .4px;
|
||||
}
|
||||
.fd-msheet .slash-item { padding: 12px 10px; font-size: 15px; }
|
||||
.fd-msheet .slash-item:active { background: var(--bg-hover); }
|
||||
|
||||
/* ══ v7.64 — Commentaires & réactions façon Notion ══
|
||||
Ancres jaunes (texte commenté), pastilles de réaction, tiroir refondu ══ */
|
||||
.fd-anchor {
|
||||
background: rgba(255, 213, 0, .32);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.fd-anchor-done { background: rgba(255, 255, 255, .10); }
|
||||
.fd-anchor-react { background: rgba(255, 213, 0, .28); }
|
||||
.fd-react-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: 4px;
|
||||
padding: 0 5px;
|
||||
height: 19px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-secondary);
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
.fd-react-chip:hover { background: var(--bg-hover); }
|
||||
.fd-react-chip img { width: 12px; height: 12px; object-fit: contain; }
|
||||
.fd-react-n { font-size: 10px; color: var(--text-dim); }
|
||||
|
||||
/* v7.67 : suppression d'une réaction — au survol de la pastille, l'émoji que
|
||||
J'ai posé devient une croix (un seul clic = retirer). Pas de reflow : c'est
|
||||
un ::after, les offsets source ne bougent pas. */
|
||||
.fd-react-emo { position: relative; display: inline-flex; align-items: center; }
|
||||
.fd-react-emo.is-mine::after {
|
||||
content: '✕';
|
||||
position: absolute;
|
||||
inset: -3px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
color: var(--text-dim);
|
||||
background: var(--bg-modal, #1f1f1f);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.fd-react-chip:hover .fd-react-emo.is-mine::after { display: flex; }
|
||||
.fd-react-emo.is-mine:hover::after { color: #e5484d; border-color: #e5484d; }
|
||||
|
||||
/* Tiroir de commentaires (fil + saisie) */
|
||||
.comment-thread {
|
||||
position: relative;
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-secondary, #2a2a2a);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.comment-thread.has-anchor { border-left: 3px solid var(--accent, #2383E2); }
|
||||
.comment-thread.is-resolved { opacity: .55; }
|
||||
.thread-flash { outline: 2px solid var(--accent, #2383E2); outline-offset: 2px; }
|
||||
.ct-head { display: flex; align-items: center; gap: 8px; position: relative; }
|
||||
.ct-avatar {
|
||||
width: 24px; height: 24px; border-radius: 50%; color: #fff; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700;
|
||||
}
|
||||
.ct-name { font-size: 12px; font-weight: 600; color: var(--text-primary); }
|
||||
.ct-time { font-size: 11px; color: var(--text-dim); margin-left: auto; }
|
||||
.ct-actions { display: flex; gap: 2px; margin-left: 2px; }
|
||||
.ct-act {
|
||||
width: 24px; height: 24px; border: none; background: transparent; border-radius: 5px;
|
||||
color: var(--text-dim); font-size: 13px; line-height: 1; cursor: pointer;
|
||||
}
|
||||
.ct-act:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
.ct-act.is-on { color: var(--accent, #2383E2); }
|
||||
.ct-menu {
|
||||
position: absolute; right: 0; top: 28px; z-index: 6; min-width: 156px; padding: 4px;
|
||||
background: var(--bg-modal); border: 1px solid var(--border); border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.4);
|
||||
}
|
||||
.ct-menu-item {
|
||||
display: block; width: 100%; text-align: left; padding: 7px 10px; font-size: 13px;
|
||||
color: var(--text-primary); background: none; border: none; border-radius: 5px; cursor: pointer;
|
||||
}
|
||||
.ct-menu-item:hover { background: var(--bg-hover); }
|
||||
.ct-menu-item.is-danger { color: #e5484d; }
|
||||
.ct-body { font-size: 13px; white-space: pre-wrap; margin-top: 6px; color: var(--text-primary); }
|
||||
.ct-anchor-label { font-size: 10px; color: var(--text-dim); margin-top: 6px; }
|
||||
|
||||
/* v7.67 : section Réactions du tiroir (liste des plages réagies + retirer) */
|
||||
.ct-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ct-reaction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 6px;
|
||||
background: var(--bg-secondary, #2a2a2a);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.ct-re-emoji { font-size: 14px; line-height: 1; flex-shrink: 0; }
|
||||
.ct-re-count { font-size: 11px; color: var(--text-dim); }
|
||||
.ct-re-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.comments-drawer-input { border-top: 1px solid var(--border, #333); padding: 12px 16px; }
|
||||
.ct-input-row { display: flex; gap: 6px; align-items: flex-end; }
|
||||
.ct-input-row textarea {
|
||||
flex: 1; min-width: 0; background: var(--bg-secondary, #2a2a2a);
|
||||
border: 1px solid var(--border, #333); border-radius: 8px; color: var(--text-primary);
|
||||
font-size: 13px; font-family: inherit; line-height: 1.4; padding: 8px 10px; resize: none; outline: none;
|
||||
}
|
||||
.ct-input-row textarea:focus { border-color: var(--accent, #2383E2); }
|
||||
.ct-input-actions { display: flex; gap: 4px; align-items: center; padding-bottom: 2px; }
|
||||
.ct-send {
|
||||
width: 30px; height: 30px; border-radius: 50%; background: var(--accent, #2383E2); color: #fff;
|
||||
border: none; font-size: 15px; line-height: 1; cursor: pointer; flex-shrink: 0;
|
||||
}
|
||||
.ct-send:disabled { opacity: .4; cursor: default; }
|
||||
|
||||
/* Bouton « React to selected text » de la toolbar de sélection */
|
||||
.ft-react-ico { position: relative; font-size: 15px; line-height: 1; }
|
||||
.ft-react-plus {
|
||||
position: absolute; right: -5px; top: -4px; font-size: 9px; font-weight: 700; color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* EDITOR STATUSBAR */
|
||||
.editor-statusbar {
|
||||
@@ -2934,7 +3397,9 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
.page-title-input { font-size: 28px; }
|
||||
.blocks-container { padding: 8px 16px 96px; }
|
||||
.block-handle { display: none; }
|
||||
.block-insert-btn { display: none; }
|
||||
.block-actions { display: none; }
|
||||
.block-comment-btn { display: none; }
|
||||
.editor-statusbar { left: 0; }
|
||||
}
|
||||
|
||||
@@ -3409,6 +3874,7 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
.page-cover-area { padding: 32px 32px 8px; }
|
||||
.blocks-container { padding: 8px 32px 80px; }
|
||||
.block-handle { left: -32px; }
|
||||
.block-insert-btn { left: -56px; }
|
||||
.page-title-block::before { left: -32px; }
|
||||
.page-title-input { font-size: 32px; }
|
||||
|
||||
@@ -3499,7 +3965,9 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
.page-title-input { font-size: 24px; }
|
||||
.blocks-container { padding: 8px 16px 96px; }
|
||||
.block-handle { display: none; }
|
||||
.block-insert-btn { display: none; }
|
||||
.block-actions { display: none; }
|
||||
.block-comment-btn { display: none; }
|
||||
.page-title-block::before { display: none; }
|
||||
.page-title-block .page-icon-emoji { margin-left: 0; }
|
||||
.page-title-block .page-title-input { padding-left: 0; }
|
||||
@@ -3586,7 +4054,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; }
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
@@ -4643,6 +5111,7 @@ a.fd-wiki-link:hover { border-bottom-color: var(--accent, #2383e2); }
|
||||
font-weight: 500;
|
||||
}
|
||||
body.page-locked .block-handle,
|
||||
body.page-locked .block-comment-btn,
|
||||
body.page-locked .block-add-btn,
|
||||
body.page-locked .format-toolbar,
|
||||
body.page-locked .get-started-toolbar { display: none !important; }
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 999 B After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 624 B After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 767 B After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
@@ -40,7 +40,7 @@
|
||||
if (window.Alpine) window.Alpine.__fdIconPicker = true;
|
||||
|
||||
Alpine.store('fdIconPicker', {
|
||||
open: false, x: 0, y: 0, tab: 'emoji', query: '', category: 'people',
|
||||
open: false, x: 0, y: 0, tab: 'emoji', query: '', category: 'people', title: '',
|
||||
skinTone: 0, toneOpen: false, recent: [], custom: [], customLoaded: false,
|
||||
customModal: false, customName: '', customPreview: '', customFile: null, customBusy: false,
|
||||
onPick: null, onRemove: null, _cx: 0, _cy: 0,
|
||||
@@ -50,6 +50,9 @@
|
||||
opts = opts || {};
|
||||
this.onPick = opts.onPick || null;
|
||||
this.onRemove = opts.onRemove || null;
|
||||
// v7.64 : mode « réaction » — en-tête = texte sélectionné, emoji only
|
||||
this.title = opts.title || '';
|
||||
if (this.title) { this.tab = 'emoji'; this.category = 'people'; }
|
||||
this.query = '';
|
||||
this.toneOpen = false;
|
||||
this.customModal = false;
|
||||
@@ -78,6 +81,7 @@
|
||||
|
||||
close() {
|
||||
this.open = false;
|
||||
this.title = '';
|
||||
this.customModal = false;
|
||||
this.toneOpen = false;
|
||||
this.onPick = null;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -10,8 +10,10 @@ Covers the five roadmap points:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import json
|
||||
import secrets
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
@@ -209,7 +211,27 @@ _OG_HTML = """<!doctype html><html><head>
|
||||
</head><body>hi</body></html>"""
|
||||
|
||||
|
||||
#: IP publique factice — TestOGParser ne doit dépendre ni du réseau HTTP
|
||||
#: (MockTransport) ni du DNS : `_is_public_host` résout l'hôte pour décider si
|
||||
#: l'IP est publique, et cette résolution échoue sous `pytest -n auto` (même
|
||||
#: piège que test_agent_web_tools.stub_dns — d'où un flake récurrent ici).
|
||||
PUBLIC_IP = "93.184.216.34"
|
||||
|
||||
|
||||
class TestOGParser:
|
||||
@pytest.fixture(autouse=True)
|
||||
def stub_dns(self, monkeypatch):
|
||||
real = socket.getaddrinfo
|
||||
|
||||
def fake_getaddrinfo(host, *args, **kwargs):
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (PUBLIC_IP, 0))]
|
||||
return real(host, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
|
||||
|
||||
def test_parse_og_handles_attribute_order(self):
|
||||
from app.services.og_fetcher import parse_og
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""7.59.3 — « Turn into » du menu contextuel : garde cassée + texte conservé."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
EDITOR_JS = ROOT / "static" / "js" / "page_editor_scripts.js"
|
||||
SRC = EDITOR_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _slice(start: str, end: str) -> str:
|
||||
i = SRC.index(start)
|
||||
j = SRC.index(end, i)
|
||||
return SRC[i:j].rstrip()
|
||||
|
||||
|
||||
def test_turn_into_guard_matches_objects_not_strings():
|
||||
# BUG : indexOf() sur un tableau d'objets → toujours -1 → retour immédiat.
|
||||
assert "_BLOCK_TURN_TYPES.indexOf(type)" not in SRC
|
||||
assert "_BLOCK_TURN_TYPES.some(t=>t.id===type)" in SRC
|
||||
|
||||
|
||||
def test_turn_into_menu_wiring():
|
||||
# menu ⋮⋮ → « Turn into » → sous-menu → choix de type
|
||||
assert "_blockMenuItem('submenu','↩','Turn into',{sub:'turn'})" in SRC
|
||||
assert "'turn-type'" in SRC # construit par _blockMenuItem
|
||||
assert "act==='turn-type'" in SRC
|
||||
assert "_blockTurnMenuOpen(" in SRC
|
||||
|
||||
|
||||
def test_turn_into_keeps_text_for_to_do_and_toggle():
|
||||
# Convertir un texte en To-do / Toggle ne doit plus effacer le contenu.
|
||||
line = [ln for ln in SRC.splitlines()
|
||||
if ln.strip().startswith("turnInto(idx,type")][0]
|
||||
assert "type!=='to_do'&&type!=='toggle'" not in line
|
||||
assert "if(keep&&['image','embed','meeting','divider'" in line
|
||||
|
||||
|
||||
# ── Comportement réel : la méthode extraite de la source, exécutée sous node ─
|
||||
|
||||
def test_turn_into_converts_blocks_for_real():
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node absent : test comportemental turnInto ignoré")
|
||||
types = _slice("const _BLOCK_TURN_TYPES=[", "];")
|
||||
method = _slice("turnInto(idx,type", "setBlockColor(idx,key,val){")
|
||||
script = f"""
|
||||
function _rtSync(){{}}
|
||||
function _selClear(){{}}
|
||||
{types}];
|
||||
const E = {{
|
||||
blocks: [{{id:'b1',type:'paragraph',content:'Bonjour'}}],
|
||||
_blockTargets(i){{return [i];}},
|
||||
sync(){{}}, pushHistory(){{}}, dirty:false,
|
||||
autoSave(){{}}, render(){{}}, getEl(){{return null;}},
|
||||
{method}
|
||||
}};
|
||||
E.turnInto(0,'heading_2');
|
||||
const heading = {{type:E.blocks[0].type, content:E.blocks[0].content}};
|
||||
E.turnInto(0,'to_do');
|
||||
const to_do = {{type:E.blocks[0].type, content:E.blocks[0].content, checked:E.blocks[0].checked}};
|
||||
E.turnInto(0,'button'); // type sans texte → contenu vidé
|
||||
const button = {{type:E.blocks[0].type, content:E.blocks[0].content}};
|
||||
console.log(JSON.stringify({{heading:heading, to_do:to_do, button:button}}));
|
||||
"""
|
||||
out = subprocess.run([node, "-e", script], capture_output=True, text=True,
|
||||
timeout=30, encoding="utf-8")
|
||||
assert out.returncode == 0, out.stderr
|
||||
res = json.loads(out.stdout)
|
||||
|
||||
# 1) le changement de format a lieu (avant la correction : aucune conversion)
|
||||
assert res["heading"] == {"type": "heading_2", "content": "Bonjour"}
|
||||
|
||||
# 2) To-do : texte conservé comme étiquette, case décochée
|
||||
assert res["to_do"] == {"type": "to_do", "content": "Bonjour", "checked": False}
|
||||
|
||||
# 3) Button (type sans texte) → contenu vidé
|
||||
assert res["button"] == {"type": "button", "content": ""}
|
||||
@@ -0,0 +1,29 @@
|
||||
"""7.60.0 — bouton + à gauche du handle : ajoute un bloc sous le courant."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
EDITOR_JS = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
|
||||
APP_CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_button_rendered_left_of_the_handle():
|
||||
# le + est émis AVANT le handle dans le wrapper du bloc
|
||||
i_btn = EDITOR_JS.index("class=\"block-insert-btn\"")
|
||||
i_handle = EDITOR_JS.index("<div class=\"block-handle\">", i_btn)
|
||||
assert i_btn < i_handle
|
||||
# clic → insertion sous le bloc courant (mécanisme déjà existant)
|
||||
assert 'class="block-insert-btn" title="Add block below" aria-label="Add block below" onclick="E.addAfter(${idx})"' in EDITOR_JS
|
||||
# réservé aux blocs de premier niveau (idx valide), comme .block-actions
|
||||
assert "const insertBtn=idx>=0?" in EDITOR_JS
|
||||
assert "addAfter(idx,type){return this.addAt(idx+1,type);}" in EDITOR_JS
|
||||
|
||||
|
||||
def test_button_style_and_visibility():
|
||||
assert ".block-insert-btn {" in APP_CSS
|
||||
assert "left: -64px;" in APP_CSS # gouttière .blocks-container (64px)
|
||||
assert ".block-wrapper:hover .block-insert-btn," in APP_CSS
|
||||
assert ".block-insert-btn:hover {" in APP_CSS
|
||||
# suit le handle quand la gouttière passe à 32px, masqué partout où il l'est
|
||||
assert ".block-insert-btn { left: -56px; }" in APP_CSS
|
||||
assert APP_CSS.count(".block-insert-btn { display: none; }") == 2
|
||||
@@ -0,0 +1,172 @@
|
||||
"""7.61.0 — menu contextuel de bloc refondu façon Notion (actions + raccourcis)."""
|
||||
|
||||
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")
|
||||
CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _slice(start: str, end: str) -> str:
|
||||
i = SRC.index(start)
|
||||
j = SRC.index(end, i)
|
||||
return SRC[i:j].rstrip()
|
||||
|
||||
|
||||
# ── Panneau de gauche ───────────────────────────────────────────────────────
|
||||
|
||||
def test_left_panel_matches_spec():
|
||||
# recherche, groupe « Text », les 10 actions dans l'ordre, pied métadonnées
|
||||
assert 'placeholder="Search actions..."' in SRC
|
||||
assert "'<div class=\"bm-group\">Text</div>'" in SRC
|
||||
order = ["'↩','Turn into'", "'🎨','Color'", "'🔗','Copy link to block'",
|
||||
"'▣','Duplicate'", "'↪','Move to…'", "'✕','Delete'",
|
||||
"'💬','Comment'", "'✎','Suggest edits'", "'✨','Ask AI'",
|
||||
"'📚','Skills'"]
|
||||
chunk = SRC[src_index("d.innerHTML='<input"):src_index("+_blockMenuMeta(b);")]
|
||||
positions = [chunk.index(o) for o in order]
|
||||
assert positions == sorted(positions), positions
|
||||
# pied de page : auteur / heure / taille
|
||||
meta = _slice("function _blockMenuMeta(", "\n function blockMenuOpen(")
|
||||
for token in ("Last edited by ", "word", "character", "Today at ",
|
||||
".um-name", "updatedAt"):
|
||||
assert token in meta, token
|
||||
|
||||
|
||||
def src_index(needle: str) -> int:
|
||||
return SRC.index(needle)
|
||||
|
||||
|
||||
def test_actions_are_all_dispatched():
|
||||
dispatch = _slice("function _blockMenuClick(", "\n function _blockMenuItem(")
|
||||
for act in ("submenu", "duplicate", "copy-link", "move-to", "delete",
|
||||
"comment", "suggest", "ai", "skill", "color", "turn-type"):
|
||||
assert f"act==='{act}'" in dispatch, act
|
||||
# méthodes réelles côté éditeur
|
||||
# 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
|
||||
# menu : pas d'entrée « copier/couper/coller les blocs » (raccourcis conservés)
|
||||
assert "act==='copy-blocks'" not in dispatch
|
||||
|
||||
|
||||
# ── Sous-menus ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_turn_into_submenu():
|
||||
# v7.63 : le générateur d'items a été extrait (_blockTurnItems) pour être
|
||||
# réutilisé par la feuille mobile — le test vise le générateur.
|
||||
turn = _slice("function _blockTurnItems(", "\n function _blockColorItems(")
|
||||
assert "_BLOCK_TURN_MENU" in turn and "check:on" in turn # ✓ du type actuel
|
||||
assert "'<span class=\"bm-check\">✓</span>'" in SRC
|
||||
for name in ("Text", "Heading 1", "Heading 4", "Bulleted list",
|
||||
"Numbered list", "To-do list", "Toggle list", "Code", "Quote",
|
||||
"Callout", "Block equation", "Synced block",
|
||||
"2 columns", "5 columns"):
|
||||
assert f"'{name}'" in SRC, name
|
||||
assert "Create '+t.cols+' columns of blocks" in SRC # info-bulle
|
||||
assert "data-cols" in SRC and "cols>=2" in SRC # 2..5 colonnes
|
||||
# Page / Page in / Toggle heading : types inexistants dans FlowDeck → absents
|
||||
assert "'Page in'" not in SRC and "'Toggle heading 1'" not in SRC
|
||||
|
||||
|
||||
def test_color_submenu():
|
||||
color = _slice("function _blockColorItems(", "\n function _blockSkillsMenuOpen(")
|
||||
for group in ("Last used", "Text color", "Background color"):
|
||||
assert f">{group}</div>" in color, group
|
||||
names = ["'Default'", "'Gray'", "'Brown'", "'Orange'", "'Yellow'",
|
||||
"'Green'", "'Blue'", "'Purple'", "'Pink'", "'Red'"]
|
||||
arr = _slice("const _BLOCK_COLOR_NAMES=", "];")
|
||||
for n in names:
|
||||
assert n in arr, n
|
||||
assert "bm-dot" in color # carré de couleur (fond)
|
||||
assert "_BM_SHORTCUTS.color" in color # raccourci « Last used »
|
||||
assert "fdLastBlockColor" in SRC # mémoire de la dernière couleur
|
||||
|
||||
|
||||
def test_skills_submenu_uses_real_skills():
|
||||
skills = _slice("function _blockSkillsMenuOpen(", "\n function _rtMac(")
|
||||
assert "fetch('/api/agent/skills'" in skills
|
||||
assert "act==='skill'" in SRC or "'skill'" in skills
|
||||
assert "prompt_template" in skills
|
||||
assert "_aiWritingRun('write',idx" in SRC # exécution réelle via l'IA
|
||||
|
||||
|
||||
# ── Survol / fermeture ──────────────────────────────────────────────────────
|
||||
|
||||
def test_hover_submenus_and_close():
|
||||
for token in ("_blockSubOpen(kind,bid,anchor)", "data-sub",
|
||||
"closest('[data-sub]')", "mouseleave", "_blockSubCloseLater",
|
||||
"function _blockMenuClose()", "_blockDocDown"):
|
||||
assert token in SRC, token
|
||||
assert "si.focus()" in SRC # recherche focalisée à l'ouverture
|
||||
|
||||
|
||||
# ── Raccourcis ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_shortcuts_implemented():
|
||||
keys = _slice("// ── v7.61 : raccourcis du menu contextuel de bloc ──",
|
||||
"document.addEventListener('keydown', function (e) {\n if (!(e.ctrlKey || e.metaKey)")
|
||||
combos = {
|
||||
"k === 'j'": "Ctrl+J (Ask AI)",
|
||||
"k === 'h'": "Ctrl+⇧+H (dernière couleur)",
|
||||
"k === 'm'": "Ctrl+⇧+M (commenter)",
|
||||
"k === 'x'": "Ctrl+⇧+Alt+X (suggest edits)",
|
||||
"k === 'l'": "Alt+⇧+L (copier le lien)",
|
||||
"k === 'p'": "Ctrl+⇧+P (déplacer vers…)",
|
||||
"k === 'd'": "Ctrl+D (dupliquer)",
|
||||
"k === 'delete'": "Del (supprimer, menu ouvert uniquement)",
|
||||
"k === 'escape'": "Échap (fermer)",
|
||||
}
|
||||
for token, label in combos.items():
|
||||
assert token in keys, label
|
||||
# Del n'agit jamais pendant la saisie
|
||||
assert "tgt.isContentEditable" in keys
|
||||
# le libellé affiché dans le menu correspond aux combinaisons réelles
|
||||
shortcuts = _slice("const _BM_SHORTCUTS=", ";")
|
||||
for hint in ("Alt+⇧+L", "Ctrl+D", "Ctrl+⇧+P", "Del", "Ctrl+⇧+M",
|
||||
"Ctrl+⇧+Alt+X", "Ctrl+J", "Ctrl+⇧+H"):
|
||||
assert hint in shortcuts, hint
|
||||
|
||||
|
||||
# ── Comportement réel : conversion en colonnes (node) ───────────────────────
|
||||
|
||||
def test_turn_into_columns_creates_children_for_real():
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node absent : test comportemental ignoré")
|
||||
types = _slice("const _BLOCK_TURN_TYPES=[", "];")
|
||||
method = _slice("turnInto(idx,type", "setBlockColor(idx,key,val){")
|
||||
script = f"""
|
||||
function _rtSync(){{}}
|
||||
function _selClear(){{}}
|
||||
var _n=0; function genId(){{return 'g'+(++_n);}}
|
||||
{types}];
|
||||
const E = {{
|
||||
blocks:[{{id:'b1',type:'paragraph',content:'Titre'}}],
|
||||
_blockTargets(i){{return [i];}}, sync(){{}}, pushHistory(){{}}, dirty:false,
|
||||
autoSave(){{}}, render(){{}}, getEl(){{return null;}}, _pickSyncedBlock(){{}},
|
||||
{method}
|
||||
}};
|
||||
E.turnInto(0,'columns',3);
|
||||
console.log(JSON.stringify({{type:E.blocks[0].type, kids:E.blocks[0].children.length}}));
|
||||
"""
|
||||
out = subprocess.run([node, "-e", script], capture_output=True, text=True,
|
||||
timeout=30, encoding="utf-8")
|
||||
assert out.returncode == 0, out.stderr
|
||||
assert json.loads(out.stdout) == {"type": "columns", "kids": 3}
|
||||
|
||||
|
||||
# ── Styles ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_menu_styles():
|
||||
for sel in (".block-menu .bm-group {", ".block-menu .bm-foot {",
|
||||
".block-menu .bm-check {", ".block-menu .bm-dot {",
|
||||
".block-menu-sub { min-width: 230px; max-height: 60vh;"):
|
||||
assert sel in CSS, sel
|
||||
@@ -0,0 +1,171 @@
|
||||
"""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()", "fmtReact()", # ligne 4 : 💬 Comment | 🙂 réaction
|
||||
"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("link",15)' in chunk
|
||||
|
||||
|
||||
def test_toolbar_row4_matches_reference_layout():
|
||||
"""v7.67 — ligne Comment à gauche, réaction à droite, Skills avec ≡."""
|
||||
chunk = _slice(TPL, 'class="format-toolbar"', "Copy Link Toast")
|
||||
# ligne 4 = une seule rangée scannée, boutons aux 2 extrémités
|
||||
assert 'ft-row-split' in chunk
|
||||
assert '<span>Comment</span>' in chunk # libellé, pas juste l'icône
|
||||
assert chunk.index("fmtComment()") < chunk.index("fmtReact()")
|
||||
# l'✨ Ask AI a sauté : doublon exact du « Edit with AI » du pied
|
||||
assert '"fmtAskAI()"' not in chunk
|
||||
# entête Skills (≡ en CSS) + liste scrollable
|
||||
assert 'ft-skills-head' in chunk and 'ft-skills' in chunk
|
||||
for sel in (".format-toolbar .ft-row-split {", ".format-toolbar .ft-comment {",
|
||||
".format-toolbar .ft-skills-head::after {", ".format-toolbar .ft-skills {"):
|
||||
assert sel in CSS, sel
|
||||
|
||||
|
||||
# ── Actions : câblées vers de vraies méthodes, sous-menus réutilisés ────────
|
||||
|
||||
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
|
||||
@@ -0,0 +1,136 @@
|
||||
"""7.63.0 — menus mobiles façon Notion : barre horizontale au « / » + feuille plein écran."""
|
||||
|
||||
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")
|
||||
CSS = (ROOT / "static" / "css" / "app.css").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)]
|
||||
|
||||
|
||||
# ── Barre horizontale : les 15 boutons, dans l'ordre de la description ─────
|
||||
|
||||
def test_bar_items_in_order():
|
||||
chunk = _slice(SRC, "const MTB={", "open(i,el){const d=this.el()")
|
||||
order = ["['plus'", "['turn'", "['at'", "['comment'", "['image'", "['trash'",
|
||||
"['left'", "['right'", "['up'", "['down'", "['undo'", "['redo'",
|
||||
"['color'", "['more'", "['kbd'"]
|
||||
positions = [chunk.index(o) for o in order]
|
||||
assert positions == sorted(positions), positions
|
||||
# les 4 entrées « texte + chevron » de la description
|
||||
for w in ("'plus'", "'turn'", "'color'", "'more'"):
|
||||
assert w in chunk, w
|
||||
assert "fd-mtb-wide" in chunk and "fd-mtb-caret" in chunk
|
||||
|
||||
|
||||
# ── Chaque bouton → une action réelle (pas de bouton mort) ─────────────────
|
||||
|
||||
def test_bar_actions_are_real():
|
||||
act = _slice(SRC, "act(k,btn){", "\n };")
|
||||
for token in ("sheet('Insert block'", "sheet('Turn into'", "sheet('Color'",
|
||||
"blockMenuOpen(", "a.blur()", "E.openMention(el)",
|
||||
"E.commentBlock(idx)", "E.applySlash('image')", "E.deleteSelected(idx)",
|
||||
"E.moveBlock(bid,k)", "E.undo()", "E.redo()"):
|
||||
assert token in act, token
|
||||
# « More » ouvre bien le menu contextuel de bloc existant (desktop)
|
||||
assert "this.close();blockMenuOpen(" in act
|
||||
|
||||
|
||||
# ── Feuille plein écran : titre centré + Cancel + listes réutilisées ───────
|
||||
|
||||
def test_fullscreen_sheet():
|
||||
sheet = _slice(SRC, "sheet(title,html,handler){", "_insertHtml(){")
|
||||
for token in ("fd-msheet-head", "fd-msheet-title", "fd-msheet-cancel", "Cancel",
|
||||
"fd-msheet-list block-menu"):
|
||||
assert token in sheet, token
|
||||
# contenu : items slash partagés + sous-menus de bloc partagés
|
||||
ins = _slice(SRC, "_insertHtml(){", "_bmAct(")
|
||||
assert "_GROUP_LABEL[g.name]" in ins and "_slashItemHtml" in ins
|
||||
assert "function _blockTurnItems(bid)" in SRC and "function _blockColorItems(bid)" in SRC
|
||||
# choix d'un item → applySlash via SM.sel (ferme tout)
|
||||
assert "SM.sel(it.dataset.sid)" in SRC
|
||||
|
||||
|
||||
# ── Branchement sur le slash : mobile = barre, desktop = popup intacte ─────
|
||||
|
||||
def test_mobile_hook():
|
||||
for token in ("function _fdMobile()", "max-width: 768px",
|
||||
"if(_fdMobile()){SM._o=true;MTB.open(i,el);return;}",
|
||||
"MTB.close();", "!MTB.contains(ev.target)",
|
||||
"else if(SM.isOpen)"):
|
||||
assert token in SRC, token
|
||||
# le popup desktop (positionnement + focus de la recherche) est toujours là
|
||||
assert "SM._e.style.display='flex'" in SRC and "SM._in.focus()" in SRC
|
||||
# la barre se referme quand le / disparaît
|
||||
assert "if(t.charAt(0)!=='/')SM.close();" in SRC
|
||||
|
||||
|
||||
# ── Déplacement de bloc (↑↓ ordre, → indent, ← outdent) ────────────────────
|
||||
|
||||
def test_move_block_logic():
|
||||
m = _slice(SRC, "moveBlock(bid,dir){", "applyLastColor(idx){")
|
||||
for token in ("dir==='up'", "dir==='down'", "dir==='right'", "parent.children",
|
||||
"pushHistory()", "this.sync()", "_focusBid(bid)"):
|
||||
assert token in m, token
|
||||
|
||||
|
||||
# ── Styles ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_styles():
|
||||
for sel in (".fd-mtb {", ".fd-mtb .fd-mtb-item {", ".fd-mtb .fd-mtb-wide {",
|
||||
".fd-msheet {", ".fd-msheet-head {", ".fd-msheet-cancel {",
|
||||
".fd-msheet-list.block-menu {", ".fd-msheet .bm-item {",
|
||||
".fd-msheet .slash-item {"):
|
||||
assert sel in CSS, sel
|
||||
|
||||
|
||||
# ── Comportement réel : moveBlock sous node ───────────────────────────────
|
||||
|
||||
def test_move_block_for_real():
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node absent : test comportemental ignoré")
|
||||
method = _slice(SRC, "moveBlock(bid,dir){", "applyLastColor(idx){")
|
||||
script = f"""
|
||||
function _rtSync(){{}}
|
||||
const E = {{
|
||||
blocks:[{{id:'a',type:'paragraph',content:'A'}},
|
||||
{{id:'tg',type:'toggle',content:'T',expanded:true,children:[]}},
|
||||
{{id:'c',type:'paragraph',content:'C'}}],
|
||||
sync(){{}}, pushHistory(){{}}, dirty:false, autoSave(){{}}, render(){{}},
|
||||
_focusBid(){{}}, showToast(){{}}, msgs:[],
|
||||
{method}
|
||||
}};
|
||||
E.moveBlock('c','right'); // C s'indente dans le toggle
|
||||
const indent = {{kids:E.blocks[1].children.map(b=>b.id).join(','), top:E.blocks.map(b=>b.id).join(',')}};
|
||||
E.moveBlock('c','left'); // C ressort à la racine : a,tg,c
|
||||
const top2 = E.blocks.map(b=>b.id).join(',');
|
||||
E.moveBlock('a','down'); // A descend : tg,a,c
|
||||
const ordre1 = E.blocks.map(b=>b.id).join(',');
|
||||
E.moveBlock('c','up'); // C remonte : tg,c,a
|
||||
const ordre2 = E.blocks.map(b=>b.id).join(',');
|
||||
E.moveBlock('c','up'); // encore : c,tg,a
|
||||
const ordre3 = E.blocks.map(b=>b.id).join(',');
|
||||
E.moveBlock('c','up'); // déjà en haut → toast, inchangé
|
||||
const ordre4 = E.blocks.map(b=>b.id).join(',');
|
||||
console.log(JSON.stringify({{indent:indent, top2:top2, ordre1:ordre1, ordre2:ordre2, ordre3:ordre3, ordre4:ordre4}}));
|
||||
"""
|
||||
out = subprocess.run([node, "-e", script], capture_output=True, text=True,
|
||||
timeout=30, encoding="utf-8")
|
||||
assert out.returncode == 0, out.stderr
|
||||
d = json.loads(out.stdout)
|
||||
assert d["indent"] == {"kids": "c", "top": "a,tg"}, d
|
||||
assert d["top2"] == "a,tg,c", d
|
||||
assert d["ordre1"] == "tg,a,c", d
|
||||
assert d["ordre2"] == "tg,c,a", d
|
||||
assert d["ordre3"] == "c,tg,a", d
|
||||
assert d["ordre4"] == "c,tg,a", d # garde « déjà en haut »
|
||||
@@ -0,0 +1,139 @@
|
||||
"""7.64.0 — commentaires façon Notion (ancres jaunes + tiroir) & réactions sur texte."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SRC = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
|
||||
TPL = (ROOT / "app" / "templates" / "_page_editor_content.html").read_text(encoding="utf-8")
|
||||
CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
|
||||
PICKER = (ROOT / "app" / "templates" / "_icon_picker.html").read_text(encoding="utf-8")
|
||||
PSTORE = (ROOT / "static" / "js" / "_icon_picker_2.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ── Backend : migration + endpoints ────────────────────────────────────────
|
||||
|
||||
def test_text_reactions_table_exists(client): # le fixture client initialise le schéma
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(text_reactions)").fetchall()}
|
||||
assert {"id", "page_id", "block_id", "anchor_start", "anchor_end", "emoji",
|
||||
"user_id", "created_at"} <= cols, cols
|
||||
|
||||
|
||||
def test_reactions_endpoints_registered():
|
||||
from app.routers.collaboration import router
|
||||
|
||||
methods = {(r.path, m) for r in router.routes for m in r.methods}
|
||||
assert ("/api/pages/{page_id}/reactions", "GET") in methods
|
||||
assert ("/api/pages/{page_id}/reactions", "POST") in methods
|
||||
|
||||
|
||||
def test_toggle_reaction_api(client):
|
||||
"""Le POST bascule (added → removed → added) et le GET regroupe par plage."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT INTO users (login, full_name, email) VALUES (?,?,?)",
|
||||
("v764u", "V764 User", "[email protected]"))
|
||||
uid = conn.execute("SELECT id FROM users WHERE login='v764u'").fetchone()["id"]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format) VALUES (?,?,?,?)",
|
||||
("u764", "React Page", "[]", "blocks"))
|
||||
pid = cur.lastrowid
|
||||
conn.commit()
|
||||
cookies = {"flowdeck_session": SessionManager.create_session(
|
||||
{"id": uid, "login": "v764u", "is_admin": 0})}
|
||||
payload = {"block_id": "b1", "anchor_start": 0, "anchor_end": 5, "emoji": "👍"}
|
||||
|
||||
r = client.post(f"/api/pages/{pid}/reactions", json=payload, cookies=cookies)
|
||||
assert r.status_code == 200 and r.json()["status"] == "added"
|
||||
r = client.post(f"/api/pages/{pid}/reactions", json=payload, cookies=cookies)
|
||||
assert r.status_code == 200 and r.json()["status"] == "removed"
|
||||
r = client.post(f"/api/pages/{pid}/reactions", json=payload, cookies=cookies)
|
||||
assert r.json()["status"] == "added"
|
||||
|
||||
r = client.get(f"/api/pages/{pid}/reactions", cookies=cookies)
|
||||
assert r.status_code == 200
|
||||
rows = r.json()["reactions"]
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["emoji"] == "👍" and rows[0]["count"] == 1 and rows[0]["mine"] == 1
|
||||
|
||||
# plage vide → 400
|
||||
bad = dict(payload, anchor_start=4, anchor_end=4)
|
||||
r = client.post(f"/api/pages/{pid}/reactions", json=bad, cookies=cookies)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ── Toolbar : bouton réaction ──────────────────────────────────────────────
|
||||
|
||||
def test_toolbar_has_react_button():
|
||||
assert 'fmtReact()' in TPL
|
||||
assert 'data-tooltip="React to selected text"' in TPL
|
||||
# position (v7.67) : 4ᵉ ligne, à droite de « Comment », après les outils
|
||||
chunk = TPL[TPL.index('class="format-toolbar"'):TPL.index("Copy Link Toast")]
|
||||
order = ["fmtMore($event)", "fmtComment()", "fmtReact()", "ft-group"]
|
||||
positions = [chunk.index(o) for o in order]
|
||||
assert positions == sorted(positions), positions
|
||||
|
||||
|
||||
def test_fmt_react_opens_picker_with_selection():
|
||||
js = SRC[SRC.index("fmtReact(){"):SRC.index("toggleComments(){")]
|
||||
for token in ("FDIconPicker.openFor", "title:text", "onPick:",
|
||||
"toggleReaction(bid,span.start,span.end,emoji)", "_selSpan(bel,this._sel.range)"):
|
||||
assert token in js, token
|
||||
|
||||
|
||||
# ── Ancres commentées + pastilles ──────────────────────────────────────────
|
||||
|
||||
def test_anchors_painting():
|
||||
for token in ("function _wrapAnchored(", "_paintAnchors()", "fd-anchor",
|
||||
"fd-react-chip", "data-react-bid", "data-react-start", "openThread(cid)",
|
||||
"loadReactions()", "toggleReaction(bid,start,end,emoji)"):
|
||||
assert token in SRC, token
|
||||
# repeint après chaque reconstruction du bloc ET après le chargement
|
||||
assert "this._paintAnchors();}catch(e){}" in SRC # fin de render()
|
||||
assert "this._paintAnchors();\n" in SRC # fin de loadComments/loadReactions
|
||||
# la pastille n'est pas du contenu lu/sauvé
|
||||
assert "n.classList.contains('fd-react-chip')" in SRC # gtTok
|
||||
assert "cl.contains('fd-react-chip')" in SRC # gtMd
|
||||
# offsets source (les annotations ne décalent pas les ancres)
|
||||
assert "function _srcOffset(" in SRC and "_srcNodeLen(" in SRC
|
||||
# clic ancre → fil ; clic pastille → sélecteur
|
||||
assert "t.closest('.fd-react-chip')" in SRC
|
||||
assert "t.closest('.fd-anchor[data-cid]')" in SRC
|
||||
|
||||
|
||||
# ── Tiroir de commentaires (image 1) ───────────────────────────────────────
|
||||
|
||||
def test_drawer_markup():
|
||||
for token in ('class="ct-head"', 'class="ct-avatar"', 'class="ct-name"',
|
||||
'class="ct-time"', 'class="ct-actions"', 'class="ct-menu"',
|
||||
"copyCommentLink(c.id)", "deleteComment(c.id)", "resolveComment(c)",
|
||||
':data-thread="c.id"', 'placeholder="Add a comment..."',
|
||||
"commentAt()", "class=\"ct-send\"", "addPageComment()"):
|
||||
assert token in TPL, token
|
||||
# le bouton ✓ bascule resolve/reopen (l'objet, pas l'id seul)
|
||||
assert "resolveComment(c)" in TPL and "async resolveComment(c)" in SRC
|
||||
# hash #cmt- → centrage du fil
|
||||
assert "#cmt-" in SRC and "scrollIntoView" in SRC
|
||||
# styles du tiroir
|
||||
for sel in (".comment-thread {", ".ct-head {", ".ct-avatar {", ".ct-menu {",
|
||||
".ct-input-row {", ".ct-send {", ".fd-anchor {", ".fd-react-chip {"):
|
||||
assert sel in CSS, sel
|
||||
|
||||
|
||||
# ── Sélecteur d'émoji (image 3) : mode réaction ────────────────────────────
|
||||
|
||||
def test_picker_reaction_mode():
|
||||
for token in ("fdip-title", "fdip-title-text", "fdip-title-grip",
|
||||
"$store.fdIconPicker.title", "Filter...", "Recent"):
|
||||
assert token in PICKER, token
|
||||
for token in ("this.title = opts.title || ''", "this.category = 'people'",
|
||||
"this.title = ''"):
|
||||
assert token in PSTORE, token
|
||||
# la ligne « Recent » au-dessus de la grille n'apparaît qu'en mode réaction
|
||||
assert "$store.fdIconPicker.title && $store.fdIconPicker.category !== 'recent'" in PICKER
|
||||
# onglets masqués en mode réaction (emoji only)
|
||||
assert 'x-show="!$store.fdIconPicker.title"' in PICKER
|
||||
@@ -0,0 +1,41 @@
|
||||
"""7.66.0 — Escape du menu slash retire le « / » + bouton commenter à droite du bloc."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
EDITOR_JS = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
|
||||
APP_CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_slash_escape_removes_the_slash():
|
||||
# une seule implémentation, appelée par les deux chemins (focus dans le bloc
|
||||
# → onKd ; focus dans l'entrée du menu → listener document)
|
||||
assert "escClose(){" in EDITOR_JS
|
||||
assert EDITOR_JS.count("SM.escClose();") == 2
|
||||
# ne retire que le « / » d'ouverture, remet le caret et sauvegarde
|
||||
assert "n.nodeValue=v.slice(0,at)+v.slice(at+1);" in EDITOR_JS
|
||||
assert "E.sync();E.dirty=true;E.autoSave();" in EDITOR_JS
|
||||
|
||||
|
||||
def test_comment_button_on_the_right_of_the_block():
|
||||
# émis APRÈS le contenu du bloc → à droite dans le flex du wrapper
|
||||
i_tpl = EDITOR_JS.index("const cmtBtn=")
|
||||
i_content = EDITOR_JS.index('class="${cls}">${inner}</${tag}>', i_tpl)
|
||||
i_btn = EDITOR_JS.index("${cmtBtn}", i_tpl)
|
||||
assert i_content < i_btn
|
||||
# icône bulle de conversation +, ancre = commentaire du bloc
|
||||
assert 'onclick="E.commentBlock(${idx})"' in EDITOR_JS
|
||||
assert EDITOR_JS.count('<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5') == 1 # bulle (message-square)
|
||||
assert '<path d="M12 8v5M9.5 10.5h5"/>' in EDITOR_JS # le + dans la bulle
|
||||
# l'ancien .block-actions mort (opacity:0 sans survol) a disparu de renderBlock
|
||||
assert "const actions=idx>=0?" not in EDITOR_JS
|
||||
|
||||
|
||||
def test_comment_button_style_and_visibility():
|
||||
assert ".block-comment-btn {" in APP_CSS
|
||||
assert "right: -60px;" in APP_CSS # gouttière 64px, comme .block-actions
|
||||
assert ".block-wrapper:hover .block-comment-btn," in APP_CSS
|
||||
assert ".block-comment-btn:hover {" in APP_CSS
|
||||
# masqué partout où le handle l'est : mobile (2 breakpoints) + page verrouillée
|
||||
assert APP_CSS.count(".block-comment-btn { display: none; }") == 2
|
||||
assert "body.page-locked .block-comment-btn," in APP_CSS
|
||||
@@ -0,0 +1,44 @@
|
||||
"""7.67.0 — réactions emoji visibles dans le tiroir Comments + suppression."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SRC = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
|
||||
TPL = (ROOT / "app" / "templates" / "_page_editor_content.html").read_text(encoding="utf-8")
|
||||
CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_drawer_lists_reactions():
|
||||
# section « Reactions » du tiroir : une ligne par plage × émoji
|
||||
assert 'class="ct-reactions"' in TPL
|
||||
assert 'x-for="r in reactionList()"' in TPL
|
||||
assert '<span class="ct-re-emoji" x-text="r.emoji"></span>' in TPL
|
||||
assert '<span class="ct-re-text" x-text="r.text"></span>' in TPL
|
||||
# l'état vide ne s'affiche plus s'il reste des réactions
|
||||
assert "comments.length === 0 && !reactionList().length" in TPL
|
||||
# méthode : groupes déjà prêts côté API, texte source lu dans le DOM
|
||||
assert "reactionList(){" in SRC
|
||||
assert "_anchorText(bid,start,end)" in SRC
|
||||
# le texte source ignore les pastilles (offsets identiques à sync())
|
||||
assert "closest('.fd-react-chip'))continue;" in SRC
|
||||
|
||||
|
||||
def test_reaction_removable_from_the_drawer():
|
||||
assert 'title="Remove reaction"' in TPL
|
||||
# le ✕ n'apparaît que sur MA réaction (les autres comptes sont en lecture)
|
||||
assert 'x-show="r.mine" @click="removeReaction(r)"' in TPL
|
||||
# suppression = POST toggle (le backend DELETE si ma ligne existe déjà)
|
||||
assert "removeReaction(r){if(!r)return;this.toggleReaction(r.bid,r.start,r.end,r.emoji);}" in SRC
|
||||
for sel in (".ct-section-title {", ".ct-reaction {", ".ct-re-emoji {", ".ct-re-text {"):
|
||||
assert sel in CSS, sel
|
||||
|
||||
|
||||
def test_reaction_removable_from_the_chip():
|
||||
# chaque émoji de la pastille porte son data-emoji, `is-mine` = j'ai réagi
|
||||
assert 'data-emoji="' in SRC
|
||||
assert "parseInt(i.mine,10)?' is-mine':''" in SRC
|
||||
assert ("d.removeReaction({bid:bid,start:start,end:end,"
|
||||
"emoji:emo.getAttribute('data-emoji')})") in SRC
|
||||
# la croix est un ::after (aucun nœud ajouté → offsets source inchangés)
|
||||
assert ".fd-react-emo.is-mine::after {" in CSS
|
||||
assert ".fd-react-chip:hover .fd-react-emo.is-mine::after { display: flex; }" in CSS
|
||||
@@ -0,0 +1,66 @@
|
||||
"""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(){", "!s||!s.rangeCount||s.isCollapsed)return null;",
|
||||
"_srcOffset(sblk,r.startContainer,r.startOffset)",
|
||||
"_srcOffset(eblk,r.endContainer,r.endOffset)"):
|
||||
assert tok in SRC, tok
|
||||
# markdown : début partiel → blocs entiers → fin partielle
|
||||
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
|
||||
# copy/cut restent branchés (menu contextuel, execCommand)…
|
||||
assert "ct.addEventListener('copy'," in SRC
|
||||
assert "ct.addEventListener('cut'," in SRC
|
||||
assert "ev.clipboardData.setData('text/plain',md)" in SRC
|
||||
# …mais le VRAI raccourci clavier est intercepté au keydown : le navigateur
|
||||
# n'émet aucun événement copy pour une sélection à travers deux
|
||||
# contenteditable (v7.69).
|
||||
assert "document.addEventListener('keydown'," in SRC
|
||||
assert "if(k!=='c'&&k!=='x')return;" in SRC
|
||||
# menu clic droit : groupe Clipboard Cut/Copy/Paste
|
||||
assert "'<div class=\"bm-group\">Clipboard</div>'" in SRC
|
||||
for act in ("clip-cut", "clip-copy", "clip-paste"):
|
||||
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
|
||||
@@ -0,0 +1,150 @@
|
||||
"""v7.69.0 — sélection multi-blocs qui survit aux gestes + identité visuelle.
|
||||
|
||||
Trois bugs d'éditeur, une cause chacune :
|
||||
1. Ctrl+C multi-blocs : le navigateur ne sérialise PAS une sélection qui
|
||||
traverse deux contenteditable → il n'émet aucun événement copy → le
|
||||
raccourci ne fait rien. On l'intercepte au keydown.
|
||||
2. Glisser la souris : le « click » du contenteditable efface la sélection
|
||||
juste après qu'on l'a reconstruite (mouseup → click → focusin → collapse).
|
||||
3. Clic droit : la recherche du menu prenait le focus et effaçait le surlignage.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
JS = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _norm(src: str) -> str:
|
||||
return "".join(src.split())
|
||||
|
||||
|
||||
# ── 1. Ctrl+C / Ctrl+X multi-blocs : interception clavier ──
|
||||
|
||||
|
||||
class TestKeyboardShortcut:
|
||||
def test_ctrl_shortcut_is_bound_on_document(self):
|
||||
# capture sur document : le focus peut être n'importe où dans l'éditeur
|
||||
assert _norm("document.addEventListener('keydown',function(ev){") in _norm(JS)
|
||||
|
||||
def test_shortcut_handles_both_c_and_x(self):
|
||||
assert _norm("if(k!=='c'&&k!=='x')return;") in _norm(JS)
|
||||
|
||||
def test_shortcut_guards_outside_the_editor(self):
|
||||
# ne doit JAMAIS voler Ctrl+C quand le focus est dans un champ du menu
|
||||
assert "closest('#_blocksCt')" in JS
|
||||
|
||||
def test_shortcut_falls_back_to_native_for_single_block(self):
|
||||
# sélection mono-bloc → on laisse le navigateur faire son travail
|
||||
assert _norm("const st=E._clipState();if(!st||st.si>=st.ei)return;") in _norm(JS)
|
||||
|
||||
def test_shortcut_writes_to_clipboard_and_prevents_default(self):
|
||||
assert _norm("ev.preventDefault();ev.stopPropagation();") in _norm(JS)
|
||||
assert "navigator.clipboard.writeText(md)" in JS
|
||||
|
||||
def test_shortcut_is_registered_next_to_the_copy_listeners(self):
|
||||
# le handler clavier vit au même endroit que copy/cut (bloc ct de v7.68)
|
||||
cut = JS.index("ct.addEventListener('cut'")
|
||||
shortcut = JS.index("closest('#_blocksCt')")
|
||||
assert cut < shortcut < JS.index("ct.addEventListener('mousedown'")
|
||||
|
||||
|
||||
# ── 2. La sélection reconstruite survit au collapse du focus ──
|
||||
|
||||
|
||||
class TestSelectionPersistence:
|
||||
def test_pending_range_variable_exists(self):
|
||||
assert "var _mdSelRange=null;" in JS
|
||||
|
||||
def test_build_sel_at_stashes_the_range(self):
|
||||
# sans cette copie, le « click » suivant détruit la sélection
|
||||
assert _norm("s.removeAllRanges();s.addRange(a);") in _norm(JS)
|
||||
assert _norm("_mdSelRange=a;") in _norm(JS)
|
||||
|
||||
def test_new_gesture_clears_the_pending_range(self):
|
||||
# un clic ultérieur doit placer un caret normal, pas ressusciter l'ancien
|
||||
assert _norm("_mdSelRange=null;") in _norm(JS)
|
||||
|
||||
def test_focusin_restores_after_the_synchronous_collapse(self):
|
||||
# focusin se déclenche AVANT le collapse : la restauration doit être
|
||||
# reportée au tour de boucle suivant (setTimeout 0)
|
||||
assert "document.addEventListener('focusin'" in JS
|
||||
restore = JS[JS.index("document.addEventListener('focusin'") :]
|
||||
assert "setTimeout(function(){" in restore.split("document.addEventListener('mousedown'")[0]
|
||||
assert "s.addRange(r)" in restore
|
||||
|
||||
def test_restore_is_a_noop_when_the_selection_survived(self):
|
||||
assert _norm("if(s&&s.rangeCount&&!s.isCollapsed)return;") in _norm(JS)
|
||||
|
||||
|
||||
# ── 3. Le menu contextuel n'efface plus le surlignage ──
|
||||
|
||||
|
||||
class TestContextMenuKeepsHighlight:
|
||||
def test_search_field_only_steals_focus_without_a_selection(self):
|
||||
assert _norm("if(si&&!hasSel)setTimeout(function(){si.focus();},10);") in _norm(JS)
|
||||
|
||||
def test_right_click_prevents_default_to_freeze_the_selection(self):
|
||||
# le preventDefault doit être sur mousedown (contextmenu arrive trop tard)
|
||||
assert _norm("if(ev.button===2){const s=window.getSelection();if(s&&s.rangeCount&&!s.isCollapsed)ev.preventDefault();return;}") in _norm(JS)
|
||||
|
||||
def test_menu_freezes_the_clipboard_state_at_open_time(self):
|
||||
# getSelection() n'est plus fiable une fois le menu ouvert
|
||||
assert _norm("var clipSt=(E._clipState?E._clipState():null);") in _norm(JS)
|
||||
assert "d._clipSt=clipSt;" in JS
|
||||
|
||||
|
||||
# ── 4. Identité visuelle (logo + bannière du projet) ──
|
||||
|
||||
|
||||
class TestBranding:
|
||||
def test_banner_and_logo_assets_exist_and_are_optimised(self):
|
||||
img = ROOT / "static" / "img"
|
||||
banner, logo = img / "banner.webp", img / "logo.webp"
|
||||
assert banner.exists() and logo.exists()
|
||||
# les originaux pèsent des Mo : les assets servis doivent rester légers
|
||||
assert banner.stat().st_size < 200_000
|
||||
assert logo.stat().st_size < 100_000
|
||||
# un JPEG de secours existe pour les crawlers qui n'aiment pas le WebP
|
||||
assert (img / "banner.jpg").exists()
|
||||
assert (img / "logo-512.jpg").exists()
|
||||
|
||||
def test_source_images_are_kept_for_regeneration(self):
|
||||
src = ROOT / "assets"
|
||||
assert (src / "banner.jpg").exists()
|
||||
assert (src / "logo.jpg").exists()
|
||||
|
||||
def test_pwa_icons_generated_from_the_real_logo(self):
|
||||
icons = ROOT / "static" / "icons"
|
||||
for size in (72, 96, 128, 144, 152, 192, 384, 512):
|
||||
f = icons / f"icon-{size}x{size}.png"
|
||||
assert f.exists(), f"icône {size} manquante"
|
||||
assert (icons / "apple-touch-icon.png").exists()
|
||||
|
||||
def test_base_head_declares_open_graph(self):
|
||||
base = (ROOT / "app" / "templates" / "base.html").read_text(encoding="utf-8")
|
||||
# URL ABSOLUE : les crawlers ignorent les URL relatives
|
||||
assert 'content="{{ app_base_url() }}/static/img/logo-512.jpg"' in base
|
||||
assert 'name="twitter:card" content="summary_large_image"' in base
|
||||
assert '<meta name="description"' in base
|
||||
|
||||
def test_og_image_url_is_absolute_via_base_url(self):
|
||||
# le global expose la même source que les liens de partage de pages
|
||||
templating = (ROOT / "app" / "templating.py").read_text(encoding="utf-8")
|
||||
assert 'ENV.globals["app_base_url"]' in templating
|
||||
for tpl in ("base.html", "landing.html"):
|
||||
src = (ROOT / "app" / "templates" / tpl).read_text(encoding="utf-8")
|
||||
assert 'content="{{ app_base_url() }}/static/img/logo-512.jpg"' in src, tpl
|
||||
# forme RELATIVE interdite (le guillemet avant la barre est ce qui
|
||||
# distingue : la bonne valeur contient l'ancienne en sous-chaîne)
|
||||
assert 'content="/static/img/' not in src, tpl
|
||||
|
||||
def test_landing_shows_logo_and_banner(self):
|
||||
landing = (ROOT / "app" / "templates" / "landing.html").read_text(encoding="utf-8")
|
||||
# le logo remplace l'icône générique dans la barre de navigation
|
||||
assert 'class="brand-mark" src="/static/img/logo.webp"' in landing
|
||||
# la bannière est servie en WebP avec secours JPEG
|
||||
assert '<source srcset="/static/img/banner.webp" type="image/webp">' in landing
|
||||
assert 'src="/static/img/banner.jpg"' in landing
|
||||
# l'image ne doit pas déclencher de refont : dimensions déclarées
|
||||
assert 'width="2400" height="793"' in landing
|
||||