Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00c5e2a60d | ||
|
|
858649e3c3 | ||
|
|
400a827e3f | ||
|
|
699165469d |
+154
@@ -1,5 +1,159 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## 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
|
||||
|
||||
+63
@@ -1419,6 +1419,69 @@ Détails livrés :
|
||||
- [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
|
||||
|
||||
---
|
||||
|
||||
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v7.62.0 (menu contextuel au highlight de texte — toolbar 4 lignes façon Notion) | **Statut**: EN COURS 🔄
|
||||
> **Début**: 2026-07-08 | **Version**: v7.65.0 (comment ancré sur la sélection de texte, bouton bleu retiré) | **Statut**: EN COURS 🔄
|
||||
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
|
||||
|
||||
## Avancement Global
|
||||
|
||||
+1
-1
@@ -186,7 +186,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.62.0",
|
||||
version="7.65.0",
|
||||
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}
|
||||
|
||||
@@ -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,24 +578,15 @@
|
||||
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;">
|
||||
@@ -603,34 +594,43 @@
|
||||
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>
|
||||
@@ -666,6 +666,7 @@
|
||||
<div class="ft-row">
|
||||
<button type="button" class="ft-item" @click="fmtLink()" data-tooltip="Link">{{ fd_icon("link",15) }}</button>
|
||||
<button type="button" class="ft-item" @click="fmtHighlight()" data-tooltip="Highlight"><span class="ft-hl">S</span></button>
|
||||
<button type="button" class="ft-item ft-react" @click="fmtReact()" data-tooltip="React to selected text"><span class="ft-react-ico">🙂<span class="ft-react-plus">+</span></span></button>
|
||||
<button type="button" class="ft-item ft-code-ico" @click="fmtCodeBlock()" data-tooltip="Code block"></></button>
|
||||
<button type="button" class="ft-item" @click="fmtEquation()" data-tooltip="Equation">√x</button>
|
||||
<button type="button" class="ft-item ft-more" @click="fmtMore($event)" data-tooltip="More actions">…</button>
|
||||
|
||||
+92
-1
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "FlowDeck",
|
||||
"version": "7.62.0"
|
||||
"version": "7.65.0"
|
||||
},
|
||||
"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": [
|
||||
|
||||
@@ -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,171 @@
|
||||
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 });
|
||||
});
|
||||
@@ -3056,6 +3056,193 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
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); }
|
||||
|
||||
/* 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; }
|
||||
.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 {
|
||||
position: fixed;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -36,7 +36,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
// [[fdpage:ID]] / [[fddate:YYYY-MM-DD]] tokens.
|
||||
function gtTok(e){
|
||||
if(!e) return '';
|
||||
if(!e.querySelector||!e.querySelector('.fd-wiki-chip,.fd-wiki-date')) return gt(e);
|
||||
if(!e.querySelector||!e.querySelector('.fd-wiki-chip,.fd-wiki-date,.fd-react-chip')) return gt(e); // le chemin rapide (innerText) inclurait les pastilles
|
||||
let out='';
|
||||
(function walk(node){
|
||||
for(const n of node.childNodes){
|
||||
@@ -44,6 +44,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
if(n.nodeType===1){
|
||||
if(n.classList&&n.classList.contains('fd-wiki-chip')){out+=n.getAttribute('data-token')||'';continue;}
|
||||
if(n.classList&&n.classList.contains('fd-wiki-date')){out+=n.getAttribute('data-token')||'';continue;}
|
||||
if(n.classList&&n.classList.contains('fd-react-chip')){continue;} // annotation, pas du contenu
|
||||
if(n.tagName==='BR'){out+='\n';continue;}
|
||||
walk(n);
|
||||
}
|
||||
@@ -67,6 +68,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
if(n.nodeType===3){out+=n.nodeValue;continue;}
|
||||
if(n.nodeType!==1)continue;
|
||||
var cl=n.classList;
|
||||
if(cl&&cl.contains('fd-react-chip')){continue;} // annotation, pas du contenu
|
||||
if(cl&&(cl.contains('fd-wiki-chip')||cl.contains('fd-wiki-date'))){out+=n.getAttribute('data-token')||'';continue;}
|
||||
if(n.tagName==='BR'){out+='\n';continue;}
|
||||
if(cl&&cl.contains('fd-math-inline')){out+='$$'+(n.getAttribute('data-tex')||'')+'$$';continue;}
|
||||
@@ -103,6 +105,69 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
var s2=window.getSelection(),nr=document.createRange();
|
||||
nr.selectNodeContents(el);s2.removeAllRanges();s2.addRange(nr);
|
||||
}
|
||||
// v7.64 : marquage d'une plage commentée/réagie (jaune). Ne change pas le
|
||||
// texte lu par sync() : les spans n'ajoutent que des nœuds d'annotation.
|
||||
function _wrapAnchored(el,start,end,cls,attrs){
|
||||
if(!(end>start))return null;
|
||||
let pos=0,sN=null,sO=0,eN=null,eO=0;
|
||||
const walker=document.createTreeWalker(el,NodeFilter.SHOW_TEXT);
|
||||
let n=walker.nextNode();
|
||||
while(n){
|
||||
const len=n.nodeValue.length;
|
||||
if(!sN&&pos+len>start){sN=n;sO=start-pos;}
|
||||
if(sN&&pos+len>=end){eN=n;eO=end-pos;break;}
|
||||
pos+=len;n=walker.nextNode();
|
||||
}
|
||||
if(!sN||!eN)return null;
|
||||
const r=document.createRange();
|
||||
try{r.setStart(sN,sO);r.setEnd(eN,eO);}catch(e){return null;}
|
||||
const span=document.createElement('span');span.className=cls;
|
||||
span.setAttribute('contenteditable','false');
|
||||
if(attrs)for(const k in attrs)span.setAttribute('data-'+k,attrs[k]);
|
||||
try{r.surroundContents(span);}catch(e){try{span.appendChild(r.extractContents());r.insertNode(span);}catch(e2){return null;}}
|
||||
return span;
|
||||
}
|
||||
// Offsets dans le texte SOURCE d'un bloc : les pastilles .fd-react-chip
|
||||
// (annotations) ne comptent pas, comme dans gtTok/gtMd.
|
||||
function _srcNodeLen(node){
|
||||
if(!node)return 0;
|
||||
if(node.nodeType===3){
|
||||
const pe=node.parentElement;
|
||||
if(pe&&pe.closest&&pe.closest('.fd-react-chip'))return 0;
|
||||
return node.nodeValue.length;
|
||||
}
|
||||
if(node.nodeType!==1)return 0;
|
||||
if(node.classList&&node.classList.contains('fd-react-chip'))return 0;
|
||||
let t=0;const kids=node.childNodes;
|
||||
for(let i=0;i<kids.length;i++)t+=_srcNodeLen(kids[i]);
|
||||
return t;
|
||||
}
|
||||
function _srcOffset(root,container,offset){
|
||||
if(!container)return 0;
|
||||
if(container.nodeType===1){
|
||||
let t=0;const kids=container.childNodes;
|
||||
for(let i=0;i<offset&&i<kids.length;i++)t+=_srcNodeLen(kids[i]);
|
||||
return t;
|
||||
}
|
||||
if(container.nodeType!==3)return 0;
|
||||
let t=0;
|
||||
const walker=document.createTreeWalker(root,NodeFilter.SHOW_TEXT);
|
||||
let n;
|
||||
while((n=walker.nextNode())){
|
||||
if(n===container){
|
||||
const pe=n.parentElement;
|
||||
if(pe&&pe.closest&&pe.closest('.fd-react-chip'))return t;
|
||||
return t+Math.max(0,Math.min(offset,n.nodeValue.length));
|
||||
}
|
||||
t+=_srcNodeLen(n);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
function _emojiHtml(v){
|
||||
const s=String(v||'');
|
||||
if(/^(https?:|\/)/.test(s))return '<img src="'+esc(s)+'" alt="">';
|
||||
return esc(s);
|
||||
}
|
||||
function splitCaret(e){
|
||||
const s=window.getSelection(); if(!s.rangeCount||!s.rangeCount)return null; const sel=s.getRangeAt(0);
|
||||
const MK='\uF000FD'; const mark=document.createElement('span'); mark.textContent=MK;
|
||||
@@ -320,8 +385,9 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
}
|
||||
|
||||
// ── Block type shortcuts (shown on the right of each slash item) ──
|
||||
const _SHORTCUT={text:'T',heading_1:'#',heading_2:'##',heading_3:'###',heading_4:'####',bulleted_list:'•',numbered_list:'1.',to_do:'[ ]',toggle:'▸',quote:'"',callout:'!',table_of_contents:'TOC',math:'∑',columns:'‖',divider:'———',code:'<>',table:'|',image:'[img]',embed:'[🔗]',bookmark:'[🔖]',video:'[🎬]',audio:'[🎵]',database:'DB',ai_write:'/ai',ai_summarize:'/ai',ai_translate:'/ai',ai_continue:'/ai',duplicate:'Ctrl+D',turn_into:'⋯',copy_link:'#',delete:'⌫'};
|
||||
const _GROUP_LABEL={BASIC:'Basic blocks',AI:'AI writing',MEDIA:'Media',DATA:'Data',ACTIONS:'Actions'};
|
||||
// Raccourcis = préfixes markdown (alignés sur la description mobile Notion : -, [], >, ---)
|
||||
const _SHORTCUT={text:'T',heading_1:'#',heading_2:'##',heading_3:'###',heading_4:'####',bulleted_list:'-',numbered_list:'1.',to_do:'[]',toggle:'>',quote:'"',callout:'!',table_of_contents:'TOC',math:'∑',columns:'‖',divider:'---',code:'<>',table:'|',image:'[img]',embed:'[🔗]',bookmark:'[🔖]',video:'[🎬]',audio:'[🎵]',database:'DB',ai_write:'/ai',ai_summarize:'/ai',ai_translate:'/ai',ai_continue:'/ai',duplicate:'Ctrl+D',turn_into:'⋯',copy_link:'#',delete:'⌫'};
|
||||
const _GROUP_LABEL={BASIC:'Basic blocks',AI:'AI writing',MEDIA:'Media',DATA:'Data',ACTIONS:'Actions',SYNCED:'Synced'};
|
||||
// ── Hover preview card (matches Notion's visual hint) ──
|
||||
function _previewHtml(blockId){
|
||||
const p=function(inner,cls){return '<div class="slash-prev" style="padding:0">'+inner+'</div>';};
|
||||
@@ -365,7 +431,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
inp.oninput=()=>SM.flt();
|
||||
e.appendChild(list);e.appendChild(footer);e.appendChild(inp);
|
||||
SM._e=e;SM._in=inp;SM._li=list;SM._f=footer;
|
||||
document.addEventListener('mousedown',ev=>{if(!e.contains(ev.target))SM.close();});
|
||||
document.addEventListener('mousedown',ev=>{if(!e.contains(ev.target)&&!MTB.contains(ev.target))SM.close();});
|
||||
if(!SM._doc){
|
||||
SM._doc=true;
|
||||
document.addEventListener('keydown',function(ev){
|
||||
@@ -390,19 +456,22 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
SM._pv.style.left=left+'px'; SM._pv.style.top=top+'px'; SM._pv.style.display='block';
|
||||
},
|
||||
open(i,el){if(!SM._e)SM.init();SM.close();SM._i=i;SM._s=0;SM._in.value='';SM.flt();
|
||||
// v7.63 : mobile → barre horizontale façon Notion sous le curseur
|
||||
if(_fdMobile()){SM._o=true;MTB.open(i,el);return;}
|
||||
const r=el.getBoundingClientRect();SM._e.style.display='flex';
|
||||
SM._e.style.top=Math.max(4,(r.bottom+4>window.innerHeight-360)?r.top-364:r.bottom+4)+'px';
|
||||
SM._e.style.left=Math.min(r.left,window.innerWidth-340)+'px';
|
||||
SM._rs();setTimeout(()=>SM._in.focus(),10);SM._o=true;},
|
||||
close(){if(SM._e)SM._e.style.display='none';SM._o=false;SM._in&&(SM._in.value='');if(SM._pv)SM._pv.style.display='none';},
|
||||
close(){if(SM._e)SM._e.style.display='none';SM._o=false;SM._in&&(SM._in.value='');if(SM._pv)SM._pv.style.display='none';MTB.close();},
|
||||
// Ligne d'item du slash (partagée par la popup desktop et la feuille mobile)
|
||||
_slashItemHtml(c){return `<div class="slash-item" data-sid="${c.id}" style="display:flex;align-items:center;gap:10px;padding:7px 10px;font-size:14px;color:var(--text-primary);cursor:pointer;border-radius:5px">
|
||||
<span class="slash-item-icon" style="width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-size:15px;background:var(--bg-secondary);border-radius:4px;flex-shrink:0">${c.icon}</span>
|
||||
<span class="slash-item-name" style="flex:1;font-weight:500">${c.name}</span>
|
||||
<span class="slash-item-hint" style="font-size:12px;color:var(--text-dim);font-family:var(--font-mono);opacity:.7">${_SHORTCUT[c.id]||''}</span></div>`;},
|
||||
flt(){if(!SM._li)return;SM._it=[];const q=(SM._in.value||'').toLowerCase();SM._q=q;let h='';
|
||||
for(const g of CMDS){let items=g.items;if(q)items=items.filter(c=>c.name.toLowerCase().includes(q)||c.id.includes(q));if(!items.length)continue;
|
||||
h+=`<div class="sm-group" style="padding:6px 10px 3px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.4px">${_GROUP_LABEL[g.name]||g.name}</div>`;
|
||||
items.forEach(c=>{SM._it.push(c);
|
||||
h+=`<div class="slash-item" data-sid="${c.id}" style="display:flex;align-items:center;gap:10px;padding:7px 10px;font-size:14px;color:var(--text-primary);cursor:pointer;border-radius:5px">
|
||||
<span class="slash-item-icon" style="width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-size:15px;background:var(--bg-secondary);border-radius:4px;flex-shrink:0">${c.icon}</span>
|
||||
<span class="slash-item-name" style="flex:1;font-weight:500">${c.name}</span>
|
||||
<span class="slash-item-hint" style="font-size:12px;color:var(--text-dim);font-family:var(--font-mono);opacity:.7">${_SHORTCUT[c.id]||''}</span></div>`;});}
|
||||
items.forEach(c=>{SM._it.push(c);h+=SM._slashItemHtml(c);});}
|
||||
SM._li.innerHTML=h;SM._rs();},
|
||||
_kd(e){if(e.key==='ArrowDown'){e.preventDefault();SM._s=Math.min(SM._s+1,SM._it.length-1);SM._rs();}
|
||||
else if(e.key==='ArrowUp'){e.preventDefault();SM._s=Math.max(SM._s-1,0);SM._rs();}
|
||||
@@ -412,6 +481,64 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
sel(id){if(window.E)window.E.applySlash(id);SM.close();},
|
||||
get isOpen(){return SM._o;}};
|
||||
|
||||
// ═══════════ v7.63.0 — Menus mobiles façon Notion : barre horizontale sous le curseur
|
||||
// + feuille plein écran « Insert block » / « Turn into » / « Color ». Tout est
|
||||
// branché sur les actions existantes (applySlash, turnInto, blockMenu, moveBlock…).
|
||||
function _fdMobile(){try{return window.matchMedia('(max-width: 768px)').matches;}catch(e){return false;}}
|
||||
const MTB={
|
||||
_b:null,_sheet:null,
|
||||
contains(t){return !!t&&((this._b&&this._b.contains(t))||(this._sheet&&this._sheet.contains(t)));},
|
||||
el(){ if(this._b)return this._b;
|
||||
const wide=['plus','turn','color','more'];
|
||||
const items=[['plus','+<span class="fd-mtb-caret">▾</span>'],['turn','Turn into<span class="fd-mtb-caret">▾</span>'],
|
||||
['at','@'],['comment','💬'],['image','🖼️'],['trash','🗑️'],
|
||||
['left','←'],['right','→'],['up','↑'],['down','↓'],['undo','↩'],['redo','↪'],
|
||||
['color','Color<span class="fd-mtb-caret">▾</span>'],['more','More<span class="fd-mtb-caret">▾</span>'],['kbd','⌨️']];
|
||||
const d=document.createElement('div');d.id='fdMobileBar';d.className='fd-mtb';
|
||||
d.innerHTML=items.map(it=>'<button type="button" class="fd-mtb-item'+(wide.indexOf(it[0])>=0?' fd-mtb-wide':'')+'" data-mtb="'+it[0]+'">'+it[1]+'</button>').join('');
|
||||
d.addEventListener('click',ev=>{const bt=ev.target.closest('[data-mtb]');if(bt)MTB.act(bt.getAttribute('data-mtb'),bt);});
|
||||
document.body.appendChild(d);this._b=d;return d;},
|
||||
open(i,el){const d=this.el();d.style.display='flex';
|
||||
const r=el.getBoundingClientRect();
|
||||
const w=Math.min(d.scrollWidth||280,window.innerWidth-16);
|
||||
d.style.left=Math.max(8,Math.min(r.left,window.innerWidth-w-8))+'px';
|
||||
let top=r.bottom+6;if(top+54>window.innerHeight-8)top=Math.max(8,r.top-54);
|
||||
d.style.top=top+'px';d.scrollLeft=0;},
|
||||
close(){if(this._b)this._b.style.display='none';this.closeSheet();},
|
||||
closeSheet(){if(this._sheet){this._sheet.remove();this._sheet=null;}},
|
||||
sheet(title,html,handler){
|
||||
this.closeSheet();
|
||||
const s=document.createElement('div');s.className='fd-msheet';
|
||||
s.innerHTML='<div class="fd-msheet-head"><span class="fd-msheet-title">'+title+'</span><button type="button" class="fd-msheet-cancel">Cancel</button></div><div class="fd-msheet-list block-menu">'+html+'</div>';
|
||||
s.querySelector('.fd-msheet-cancel').addEventListener('click',()=>{const E=window.E;this.closeSheet();if(E&&SM._i>=0&&E.blocks[SM._i])E._focusBid(E.blocks[SM._i].id);});
|
||||
s.addEventListener('click',handler);
|
||||
document.body.appendChild(s);this._sheet=s;},
|
||||
_insertHtml(){let h='';for(const g of CMDS){if(!g.items||!g.items.length)continue;
|
||||
h+='<div class="sm-group">'+(_GROUP_LABEL[g.name]||g.name)+'</div>';
|
||||
g.items.forEach(c=>{h+=SM._slashItemHtml(c);});}return h;},
|
||||
_bmAct(ev){if(!ev.target||!ev.target.closest||!ev.target.closest('[data-bm-act]'))return;_blockMenuClick(ev);this.closeSheet();},
|
||||
act(k,btn){
|
||||
const E=window.E;if(!E)return;
|
||||
const idx=SM._i;const b=(idx>=0&&idx<E.blocks.length)?E.blocks[idx]:null;
|
||||
const bid=b?b.id:'';
|
||||
if(k==='plus'){this.sheet('Insert block',this._insertHtml(),ev=>{const it=ev.target.closest('.slash-item');if(it)SM.sel(it.dataset.sid);});return;}
|
||||
if(k==='turn'){this.sheet('Turn into',_blockTurnItems(bid),ev=>this._bmAct(ev));return;}
|
||||
if(k==='color'){this.sheet('Color',_blockColorItems(bid),ev=>this._bmAct(ev));return;}
|
||||
if(k==='more'){const el=E.getEl(bid);const r=el?el.getBoundingClientRect():btn.getBoundingClientRect();
|
||||
this.close();blockMenuOpen(Math.max(8,Math.min(r.left,window.innerWidth-250)),Math.min(r.bottom+6,window.innerHeight-120),bid);return;}
|
||||
if(k==='kbd'){const a=document.activeElement;if(a&&a.blur)a.blur();return;}
|
||||
if(!b)return;
|
||||
if(k==='at'){const el=E.getEl(bid);if(!el)return;el.focus();document.execCommand('insertText',false,'@');
|
||||
E.dirty=true;E.autoSave();E.openMention(el);return;}
|
||||
if(k==='comment'){E.commentBlock(idx);return;}
|
||||
if(k==='image'){E.applySlash('image');return;}
|
||||
if(k==='trash'){E.deleteSelected(idx);return;}
|
||||
if(k==='up'||k==='down'||k==='left'||k==='right'){E.moveBlock(bid,k);return;}
|
||||
if(k==='undo'){E.undo();return;}
|
||||
if(k==='redo'){E.redo();return;}
|
||||
}
|
||||
};
|
||||
|
||||
// ═══════════ v5.11.0 Wiki-link picker ([[ → page search) ═══════════
|
||||
const WM={_o:false,_idx:-1,_s:0,_it:[],_root:null,_in:null,_li:null,_pv:null,
|
||||
mk(){ if(this._root)return this._root;
|
||||
@@ -1125,8 +1252,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
d.style.left=x+'px';d.style.top=y+'px';
|
||||
return d;
|
||||
}
|
||||
function _blockTurnMenuOpen(x,y,bid){
|
||||
var d=_blockSubShell(x,y);
|
||||
function _blockTurnItems(bid){
|
||||
var E=window.E;var cur='';
|
||||
if(E){var i=E.getIdx(bid);if(i>=0&&E.blocks[i])cur=E.blocks[i].type||'';}
|
||||
var html='';
|
||||
@@ -1136,10 +1262,13 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
title:t.cols?('Create '+t.cols+' columns of blocks'):'',
|
||||
check:on,current:on});
|
||||
});
|
||||
d.innerHTML=html;
|
||||
return html;
|
||||
}
|
||||
function _blockColorMenuOpen(x,y,bid){
|
||||
function _blockTurnMenuOpen(x,y,bid){
|
||||
var d=_blockSubShell(x,y);
|
||||
d.innerHTML=_blockTurnItems(bid);
|
||||
}
|
||||
function _blockColorItems(bid){
|
||||
var E=window.E;var idx=E?E.getIdx(bid):-1;var b=idx>=0?E.blocks[idx]:null;
|
||||
var curT=(b&&b.style&&b.style.color)||'default';
|
||||
var curB=(b&&b.style&&b.style.bgColor)||'default';
|
||||
@@ -1167,7 +1296,11 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
_BLOCK_COLOR_NAMES[n]+' background',{bid:bid,colorKey:'bgColor',colorVal:v,
|
||||
check:v===curB,current:v===curB});
|
||||
});
|
||||
d.innerHTML=html;
|
||||
return html;
|
||||
}
|
||||
function _blockColorMenuOpen(x,y,bid){
|
||||
var d=_blockSubShell(x,y);
|
||||
d.innerHTML=_blockColorItems(bid);
|
||||
}
|
||||
function _blockSkillsMenuOpen(x,y,bid){
|
||||
var d=_blockSubShell(x,y);
|
||||
@@ -1243,7 +1376,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
inviteEmail:'',invitePermission:'edit',inviteUserId:null,inviteGroupId:null,inviteGroups:[],inviteUsers:[],inviteSuggestOpen:false,inviteSel:-1,accessList:[],
|
||||
toastVisible:false,toastMsg:'',
|
||||
// ── v4.9.0: Collaboration — comments & mentions ──
|
||||
commentsOpen:false,commentDraft:'',comments:[],commentCount:0,
|
||||
commentsOpen:false,commentDraft:'',comments:[],commentCount:0,reactions:[],commentMenu:null,
|
||||
currentUserId:PD.user_id||0,
|
||||
_commentSel:{range:null,bid:null,start:0,end:0},
|
||||
_notifiedMentions:{},
|
||||
@@ -1295,6 +1428,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
this.insertTable=function(){this.sync();this.pushHistory();var nb={id:genId(),type:'table',content:'',rows:_tblNew(3,3)};this.blocks.push(nb);this.dirty=true;this.autoSave();this.render();_rtSync();setTimeout(function(){_tblFocus(nb.id,0,0);},60);};
|
||||
SM.init();_tblInitDelegate();this.render();
|
||||
this._applyLockMode();this._applyLayout();_wikiResolve();
|
||||
this.loadComments();this.loadReactions(); // ancres jaunes + pastilles (v7.64)
|
||||
const tEl=document.getElementById('_titleEl');
|
||||
if(tEl){const syncT=()=>{tEl.classList.toggle('empty',!(tEl.textContent||'').trim());};syncT();tEl.addEventListener('input',syncT);tEl.addEventListener('blur',syncT);}
|
||||
if(this.contentFormat==='blocks'&&window.__fdRT&&window.__fdRT.start){setTimeout(()=>{try{window.__fdRT.start(this);}catch(e){}},150);}
|
||||
@@ -1470,6 +1604,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
ct.querySelectorAll('[data-bid]').forEach(el=>{el.addEventListener('input',()=>{self.dirty=true;self.autoSave();if(el.hasAttribute&&el.hasAttribute('data-placeholder'))el.classList.toggle('empty',!(el.textContent||'').trim());if(window.AIAC)window.AIAC.schedule(el.getAttribute('data-bid'));})});
|
||||
ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/<br[^>]*>/gi,'').trim()!==''?el.innerHTML:'';el.classList.toggle('empty',!el.textContent.trim());});
|
||||
_selApply();
|
||||
try{this._paintAnchors();}catch(e){} // ancres jaunes + pastilles (v7.64)
|
||||
var self2=this;
|
||||
setTimeout(function(){
|
||||
if(window._wikiResolve)_wikiResolve();
|
||||
@@ -1730,11 +1865,24 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
setBlockColor(idx,key,val){if(idx<0||idx>=this.blocks.length)return;const targets=this._blockTargets(idx);const v=(!val||val==='default')?null:val;this.sync();this.pushHistory();targets.forEach(i=>{const b=this.blocks[i];if(!b)return;if(!b.style)b.style={};if(v==null)delete b.style[key];else b.style[key]=v;});if(v!=null){try{localStorage.setItem('fdLastBlockColor',key+'|'+v);}catch(e){}}this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();},
|
||||
// ── v7.61 : actions du menu contextuel de bloc ──
|
||||
commentBlock(idx){
|
||||
// L'ancre est la SÉLECTION de texte si elle vit dans ce bloc (la
|
||||
// sélection de la toolbar aussi : le clic sur le bouton peut l'avoir
|
||||
// fait disparaître du DOM), sinon tout le bloc.
|
||||
const b=this.blocks[idx];if(!b)return;const el=this.getEl(b.id);if(!el)return;
|
||||
const r=document.createRange();r.selectNodeContents(el);
|
||||
const s=window.getSelection();s.removeAllRanges();s.addRange(r);
|
||||
this.showCommentBtn();
|
||||
if(!this._commentSel||!this._commentSel.range){this.showToast('Sélectionnez du texte pour commenter','error');return;}
|
||||
let r=null;const s=window.getSelection();
|
||||
if(s&&s.rangeCount&&!s.isCollapsed){
|
||||
const rr=s.getRangeAt(0);
|
||||
if(el.contains(rr.startContainer)&&el.contains(rr.endContainer))r=rr.cloneRange();
|
||||
}
|
||||
if(!r&&this._sel&&this._sel.range&&this._sel.idx===idx
|
||||
&&el.contains(this._sel.range.startContainer)&&el.contains(this._sel.range.endContainer)){
|
||||
r=this._sel.range.cloneRange();
|
||||
}
|
||||
if(!r){r=document.createRange();r.selectNodeContents(el);}
|
||||
const start=_srcOffset(el,r.startContainer,r.startOffset);
|
||||
const end=_srcOffset(el,r.endContainer,r.endOffset);
|
||||
if(!(end>start)){this.showToast('Sélectionnez du texte pour commenter','error');return;}
|
||||
this._commentSel={range:r,bid:el.dataset.bid,start:start,end:end};
|
||||
this.commentOnSelection();
|
||||
},
|
||||
// v7.62 : renommé — « askAI() » (sans argument, plus bas) l'écrasait,
|
||||
@@ -1750,6 +1898,35 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
this.showToast('FlowDeck AI : compétence en cours…');
|
||||
this._aiWritingRun('write',idx,{prompt:String(tpl||'')+'\n\nContenu du bloc :\n'+b.content},'insert');
|
||||
},
|
||||
// v7.63 : déplacement de bloc depuis la barre mobile
|
||||
// (↑↓ = ordre, → = indent dans le frère précédent, ← = sortir du parent)
|
||||
moveBlock(bid,dir){
|
||||
const top=this.blocks.findIndex(b=>b.id===bid);
|
||||
const pi=this.blocks.findIndex(b=>Array.isArray(b.children)&&b.children.some(c=>c.id===bid));
|
||||
const parent=pi>=0?this.blocks[pi]:null;
|
||||
const list=parent?parent.children:this.blocks;
|
||||
const k=list.findIndex(b=>b.id===bid);
|
||||
if(k<0){this.showToast('Bloc introuvable','error');return;}
|
||||
if(dir==='up'||dir==='down'){
|
||||
const j=dir==='up'?k-1:k+1;
|
||||
if(j<0||j>=list.length){this.showToast(dir==='up'?'Déjà en haut':'Déjà en bas','error');return;}
|
||||
this.sync();this.pushHistory();
|
||||
const mv=list.splice(k,1)[0];list.splice(j,0,mv);
|
||||
}else if(dir==='right'){
|
||||
if(parent){this.showToast('Déjà indenté','error');return;}
|
||||
const prev=this.blocks[top-1];
|
||||
if(!prev||!Array.isArray(prev.children)||(prev.type!=='toggle'&&prev.type!=='columns')){this.showToast('Pas de bloc cible à droite','error');return;}
|
||||
this.sync();this.pushHistory();
|
||||
if(prev.type==='toggle')prev.expanded=true;
|
||||
const mv=this.blocks.splice(top,1)[0];prev.children.push(mv);
|
||||
}else{
|
||||
if(!parent){this.showToast('Déjà à la racine','error');return;}
|
||||
this.sync();this.pushHistory();
|
||||
const mv=parent.children.splice(k,1)[0];
|
||||
this.blocks.splice(pi+1,0,mv);
|
||||
}
|
||||
this.dirty=true;this.autoSave();this.render();_rtSync();this._focusBid(bid);
|
||||
},
|
||||
applyLastColor(idx){
|
||||
let v='';try{v=localStorage.getItem('fdLastBlockColor')||'';}catch(e){}
|
||||
const i=v.indexOf('|');
|
||||
@@ -2225,7 +2402,9 @@ applyAIBlocks(text){
|
||||
if(e.key==='Escape'){e.preventDefault();window.AIAC.clear();return;}
|
||||
if(e.key==='Enter'||e.key==='Backspace'){window.AIAC.clear();}
|
||||
}
|
||||
if(e.key.length===1||e.key==='Backspace'||e.key==='Delete'){const i2=idx;setTimeout(()=>{const e2=this.getEl(this.blocks[i2]?.id);if(e2&&e2.textContent?.trim()==='/'&&!SM.isOpen&&!WM.isOpen)SM.open(i2,e2);
|
||||
if(e.key.length===1||e.key==='Backspace'||e.key==='Delete'){const i2=idx;setTimeout(()=>{const e2=this.getEl(this.blocks[i2]?.id);
|
||||
if(e2&&e2.textContent?.trim()==='/'&&!SM.isOpen&&!WM.isOpen){SM.open(i2,e2);}
|
||||
else if(SM.isOpen){const t=(e2&&e2.textContent)||'';if(t.charAt(0)!=='/')SM.close();}
|
||||
if(e2&&!SM.isOpen&&!WM.isOpen){const t=gtTok(e2)||'';const mm=t.match(/\[\[$/);if(mm){WM.open(i2,e2);}}},15);}
|
||||
if(e.key===' '){
|
||||
if(block.type==='paragraph'&&!(block.content||'').trim()&&!(el.textContent||'').trim()&&!SM.isOpen&&window.AIC){
|
||||
@@ -2251,7 +2430,10 @@ applyAIBlocks(text){
|
||||
}
|
||||
if(e.key==='ArrowUp'){const _sp=splitCaret(el);if(_sp&&!_sp.before.trim()&&idx>0){e.preventDefault();const prev=this.getEl(this.blocks[idx-1]?.id);if(prev){prev.focus();ce(prev);}}return;}
|
||||
if(e.key==='ArrowDown'){const _sp=splitCaret(el);if(_sp&&!_sp.after.trim()&&idx<this.blocks.length-1){e.preventDefault();const next=this.getEl(this.blocks[idx+1]?.id);if(next){next.focus();cs(next);}}return;}
|
||||
if(e.key==='Backspace'){if(!(el.textContent||'').trim()){this.sync();if(block.type!=='paragraph'){e.preventDefault();this.pushHistory();block.type='paragraph';block.content='';this.render();_rtSync();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}else if(this.blocks.length>1){e.preventDefault();this.removeBlock(idx);}}return;}
|
||||
// Backspace / Delete sur un bloc vide → supprimer le bloc (même garde
|
||||
// pour les deux touches ; le texte restant est lu comme à la sauvegarde
|
||||
// pour ignorer les annotations .fd-react-chip)
|
||||
if(e.key==='Backspace'||e.key==='Delete'){if(!(gtTok(el)||'').trim()){this.sync();if(block.type!=='paragraph'){e.preventDefault();this.pushHistory();block.type='paragraph';block.content='';this.render();_rtSync();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}else if(this.blocks.length>1){e.preventDefault();this.removeBlock(idx);}}return;}
|
||||
if(e.key===' '){const i3=idx;setTimeout(()=>this.checkMd(i3),15);}
|
||||
if((e.ctrlKey||e.metaKey)&&!e.altKey){
|
||||
if(e.key.toLowerCase()==='z'){e.preventDefault();if(e.shiftKey)this.redo();else this.undo();return;}
|
||||
@@ -2416,9 +2598,41 @@ applyAIBlocks(text){
|
||||
fmtAskAI(){this.fmt.open=false;const i=this.fmt.idx;if(i>=0)this.askAIBlock(i);},
|
||||
fmtEditAI(){this.fmtAskAI();},
|
||||
fmtSkill(tpl){this.fmt.open=false;const i=this.fmt.idx;if(i>=0)this.runSkill(i,tpl);},
|
||||
// offsets d'une Range dans le texte rendu d'un bloc (partagé bouton 💬 + réaction)
|
||||
_selSpan(bel,range){return {start:_srcOffset(bel,range.startContainer,range.startOffset),
|
||||
end:_srcOffset(bel,range.endContainer,range.endOffset)};},
|
||||
// « React to selected text » (v7.64) : sélecteur d'émoji avec le texte
|
||||
// sélectionné en en-tête → toggle de la réaction ancrée sur la plage
|
||||
fmtReact(){
|
||||
if(!this._sel)return;
|
||||
const E=this;const bid=this._fmtBid();const bel=this.getEl(bid);
|
||||
if(!bel||!window.FDIconPicker)return;
|
||||
const span=this._selSpan(bel,this._sel.range);
|
||||
if(span.end<=span.start)return;
|
||||
const text=this._sel.range.toString();
|
||||
const r=this._sel.range.getBoundingClientRect();
|
||||
this.fmt.open=false;
|
||||
window.FDIconPicker.openFor({
|
||||
x:Math.min(Math.max(8,r.left-40),window.innerWidth-360),
|
||||
y:Math.min(Math.max(8,r.bottom+8),window.innerHeight-260),
|
||||
title:text.length>90?text.slice(0,90)+'…':text,
|
||||
onPick:function(emoji){E.toggleReaction(bid,span.start,span.end,emoji);}
|
||||
});
|
||||
},
|
||||
// ── v4.9.0: Collaboration — comments & mentions ──
|
||||
toggleComments(){this.commentsOpen=!this.commentsOpen;if(this.commentsOpen)this.loadComments();},
|
||||
async loadComments(){try{const r=await fetch('/api/pages/'+this.pid+'/comments',{credentials:'same-origin'});const d=await r.json();this.comments=d.comments||[];this.commentCount=this.comments.length;}catch(e){this.comments=[];}},
|
||||
toggleComments(){if(this.commentsOpen){this.commentsOpen=false;this._commentSel={range:null,bid:null,start:0,end:0};return;}this.commentsOpen=true;this.loadComments();},
|
||||
async loadComments(){
|
||||
try{const r=await fetch('/api/pages/'+this.pid+'/comments',{credentials:'same-origin'});const d=await r.json();
|
||||
this.comments=d.comments||[];this.commentCount=this.comments.length;}
|
||||
catch(e){this.comments=[];}
|
||||
this._paintAnchors();
|
||||
// #cmt-<id> (copié depuis le menu « … » du fil) → ouvre et centre le fil
|
||||
if(!this._cmtHashDone&&location.hash.indexOf('#cmt-')===0){
|
||||
this._cmtHashDone=true;
|
||||
const cid=parseInt(location.hash.slice(5),10);
|
||||
if(!isNaN(cid))setTimeout(()=>this.openThread(cid),300);
|
||||
}
|
||||
},
|
||||
fmtTime(s){if(!s)return '';const t=new Date((String(s).includes('T')||String(s).includes('Z'))?s:(s+'Z'));if(isNaN(t.getTime()))t=new Date(s);const diff=Math.floor((Date.now()-t.getTime())/1000);if(diff<60)return 'just now';if(diff<3600)return Math.floor(diff/60)+'m ago';if(diff<86400)return Math.floor(diff/3600)+'h ago';return Math.floor(diff/86400)+'d ago';},
|
||||
csrfTok(){return getCsrf();},
|
||||
async addPageComment(){
|
||||
@@ -2429,38 +2643,103 @@ applyAIBlocks(text){
|
||||
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},
|
||||
body:JSON.stringify({body:text,anchor_block_id:sel.bid||null,anchor_start:sel.bid?sel.start:null,anchor_end:sel.bid?sel.end:null})});
|
||||
const d=await r.json();
|
||||
if(d.status==='created'){this.commentDraft='';this._commentSel={range:null,bid:null,start:0,end:0};this.hideCommentBtn();await this.loadComments();this.showToast('Comment added');}
|
||||
if(d.status==='created'){this.commentDraft='';this._commentSel={range:null,bid:null,start:0,end:0};await this.loadComments();this.showToast('Comment added');}
|
||||
else{this.showToast(d.detail||'Failed to add comment');}
|
||||
}catch(e){this.showToast('Failed to add comment');}
|
||||
},
|
||||
async resolveComment(id){try{await fetch('/api/comments/'+id,{method:'PUT',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({resolved:true})});await this.loadComments();}catch(e){}},
|
||||
async deleteComment(id){if(!confirm('Delete this comment?'))return;try{await fetch('/api/comments/'+id,{method:'DELETE',credentials:'same-origin',headers:{'X-CSRF-Token':this.csrfTok()}});await this.loadComments();}catch(e){}},
|
||||
async resolveComment(c){
|
||||
// accepte l'objet (le fil → bascule resolve/reopen) ou un id brut
|
||||
let id=c,resolved=true;
|
||||
if(c&&typeof c==='object'){id=c.id;resolved=!c.resolved;}
|
||||
if(!id)return;
|
||||
try{await fetch('/api/comments/'+id,{method:'PUT',credentials:'same-origin',
|
||||
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},
|
||||
body:JSON.stringify({resolved:resolved})});
|
||||
this.commentMenu=null;await this.loadComments();}catch(e){}
|
||||
},
|
||||
async deleteComment(id){if(!confirm('Delete this comment?'))return;try{await fetch('/api/comments/'+id,{method:'DELETE',credentials:'same-origin',headers:{'X-CSRF-Token':this.csrfTok()}});this.commentMenu=null;await this.loadComments();}catch(e){}},
|
||||
// ── v7.64 : réactions emoji sur texte + ancres commentées ──
|
||||
async loadReactions(){
|
||||
try{const r=await fetch('/api/pages/'+this.pid+'/reactions',{credentials:'same-origin'});
|
||||
const d=await r.json();this.reactions=(d&&d.reactions)||[];}catch(e){this.reactions=[];}
|
||||
this._paintAnchors();
|
||||
},
|
||||
async toggleReaction(bid,start,end,emoji){
|
||||
try{
|
||||
const r=await fetch('/api/pages/'+this.pid+'/reactions',{method:'POST',credentials:'same-origin',
|
||||
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},
|
||||
body:JSON.stringify({block_id:bid,anchor_start:start,anchor_end:end,emoji:emoji})});
|
||||
const d=await r.json();
|
||||
if(d.status==='added'||d.status==='removed'){await this.loadReactions();}
|
||||
else this.showToast((d&&d.detail)||'Réaction impossible','error');
|
||||
}catch(e){this.showToast('Réaction impossible','error');}
|
||||
},
|
||||
openThread(cid){
|
||||
this.commentsOpen=true;this.commentMenu=null;
|
||||
const self=this;
|
||||
this.loadComments().then(function(){self.$nextTick(function(){
|
||||
const el=document.querySelector('[data-thread="'+cid+'"]');
|
||||
if(el&&el.scrollIntoView){el.scrollIntoView({block:'center',behavior:'smooth'});
|
||||
el.classList.add('thread-flash');setTimeout(function(){el.classList.remove('thread-flash');},1500);}
|
||||
});});
|
||||
},
|
||||
commentAt(){
|
||||
this.commentDraft=(this.commentDraft||'')+'@';
|
||||
this.$nextTick(function(){const ta=document.querySelector('.comments-drawer textarea');
|
||||
if(ta){ta.focus();ta.selectionStart=ta.selectionEnd=ta.value.length;}});
|
||||
},
|
||||
copyCommentLink(id){
|
||||
const url=location.origin+location.pathname+'#cmt-'+id;
|
||||
this.commentMenu=null;
|
||||
if(navigator.clipboard&&navigator.clipboard.writeText){
|
||||
navigator.clipboard.writeText(url).then(()=>this.showToast('Lien du commentaire copié')).catch(()=>this.showToast(url,'error'));
|
||||
} else this.showToast(url,'error');
|
||||
},
|
||||
// Recalcule les ancres jaunes (commentaires) + pastilles de réaction.
|
||||
// Le texte des blocs est inchangé : sync() lit la même chaîne.
|
||||
_paintAnchors(){
|
||||
const ct=document.getElementById('_blocksCt');if(!ct)return;
|
||||
ct.querySelectorAll('.fd-react-chip').forEach(function(el){el.remove();});
|
||||
ct.querySelectorAll('.fd-anchor').forEach(function(el){
|
||||
const par=el.parentNode;if(!par)return;
|
||||
while(el.firstChild)par.insertBefore(el.firstChild,el);
|
||||
par.removeChild(el);par.normalize();
|
||||
});
|
||||
const self=this;
|
||||
(this.comments||[]).forEach(function(c){
|
||||
if(!c.anchor_block_id||c.anchor_start==null||c.anchor_end==null)return;
|
||||
const el=self.getEl(c.anchor_block_id);if(!el)return;
|
||||
_wrapAnchored(el,parseInt(c.anchor_start,10),parseInt(c.anchor_end,10),
|
||||
'fd-anchor'+(c.resolved?' fd-anchor-done':''),{cid:c.id});
|
||||
});
|
||||
const groups={};
|
||||
(this.reactions||[]).forEach(function(r){
|
||||
const k=r.block_id+':'+r.anchor_start+':'+r.anchor_end;
|
||||
if(!groups[k])groups[k]={bid:r.block_id,start:parseInt(r.anchor_start,10),end:parseInt(r.anchor_end,10),items:[]};
|
||||
groups[k].items.push(r);
|
||||
});
|
||||
Object.keys(groups).forEach(function(k){
|
||||
const g=groups[k];
|
||||
const el=self.getEl(g.bid);if(!el)return;
|
||||
const sp=_wrapAnchored(el,g.start,g.end,'fd-anchor fd-anchor-react',null);
|
||||
if(!sp||!sp.parentNode)return;
|
||||
const n=g.items.reduce(function(a,i){return a+(parseInt(i.count,10)||1);},0);
|
||||
const chip=document.createElement('span');
|
||||
chip.className='fd-react-chip';
|
||||
chip.setAttribute('contenteditable','false');
|
||||
chip.setAttribute('data-react-bid',g.bid);
|
||||
chip.setAttribute('data-react-start',String(g.start));
|
||||
chip.setAttribute('data-react-end',String(g.end));
|
||||
chip.innerHTML=g.items.map(function(i){return '<span class="fd-react-emo">'+_emojiHtml(i.emoji)+'</span>';}).join('')
|
||||
+(n>1?'<span class="fd-react-n">'+n+'</span>':'');
|
||||
sp.parentNode.insertBefore(chip,sp.nextSibling);
|
||||
});
|
||||
},
|
||||
commentOnSelection(){
|
||||
const sel=this._commentSel;if(!sel.range)return;
|
||||
const bid=sel.bid||'';this.commentsOpen=true;this.commentDraft='';
|
||||
const btn=document.getElementById('_commentSelBtn');if(btn)btn.style.display='none';
|
||||
this.commentsOpen=true;this.commentDraft='';
|
||||
this.$nextTick(()=>{const ta=document.querySelector('.comments-drawer textarea');if(ta)ta.focus();});
|
||||
},
|
||||
showCommentBtn(){
|
||||
const s=window.getSelection();if(!s||s.isCollapsed)return;
|
||||
const ct=document.getElementById('_blocksCt');if(!ct||!ct.contains(s.anchorNode))return;
|
||||
const bel=s.anchorNode.parentElement? s.anchorNode.parentElement.closest('[data-bid]'):null;
|
||||
if(!bel)return;
|
||||
const bid=bel.dataset.bid;
|
||||
const range=s.getRangeAt(0).cloneRange();range.selectNodeContents(bel);range.setEnd(s.getRangeAt(0).startContainer,s.getRangeAt(0).startOffset);
|
||||
const start=range.toString().length;
|
||||
const end=start+s.toString().length;
|
||||
if(end<=start)return;
|
||||
this._commentSel={range:s.getRangeAt(0).cloneRange(),bid:bid,start:start,end:end};
|
||||
const r=s.getRangeAt(0).getBoundingClientRect();
|
||||
const btn=document.getElementById('_commentSelBtn');if(!btn)return;
|
||||
btn.style.display='block';
|
||||
const bw=btn.offsetWidth||110,bh=btn.offsetHeight||34;
|
||||
let x=r.left+r.width/2-bw/2,y=r.top-bh-8;
|
||||
x=Math.min(Math.max(8,x),window.innerWidth-bw-8);if(y<8)y=r.bottom+8;
|
||||
btn.style.left=x+'px';btn.style.top=y+'px';
|
||||
},
|
||||
hideCommentBtn(){const btn=document.getElementById('_commentSelBtn');if(btn)btn.style.display='none';},
|
||||
// ── @mention autocomplete (v4.9.0 users + v5.11.0 pages & dates) ──
|
||||
mentionOpen:false,mentionQuery:'',mentionResults:[],mentionPages:[],mentionEl:null,
|
||||
openMention(el){
|
||||
@@ -2785,9 +3064,35 @@ applyAIBlocks(text){
|
||||
};
|
||||
}
|
||||
// v7.62 : data Alpine du wrapper éditeur — Alpine v3 n'expose plus __x
|
||||
// (l'ancien accès rendait muets showFmt/hideCommentBtn/mentions).
|
||||
// (l'ancien accès rendait muets showFmt/mentions).
|
||||
function _xed(el){ if(!el) return null; if(el.__x&&el.__x.$data) return el.__x.$data;
|
||||
return (el._x_dataStack&&el._x_dataStack[0])||null; }
|
||||
// v7.64 : clic sur une ancre jaune → ouvre et centre le fil de discussion ;
|
||||
// clic sur une pastille de réaction → réouvre le sélecteur pour cette plage.
|
||||
document.addEventListener('click',function(ev){
|
||||
const t=ev.target;if(!t||!t.closest)return;
|
||||
const d=_xed(document.querySelector('.page-editor-wrapper'));if(!d)return;
|
||||
const chip=t.closest('.fd-react-chip');
|
||||
if(chip){
|
||||
const bid=chip.getAttribute('data-react-bid');
|
||||
const start=parseInt(chip.getAttribute('data-react-start'),10);
|
||||
const end=parseInt(chip.getAttribute('data-react-end'),10);
|
||||
const el=d.getEl(bid);const rect=chip.getBoundingClientRect();
|
||||
if(!window.FDIconPicker||!el||isNaN(start)||isNaN(end))return;
|
||||
window.FDIconPicker.openFor({
|
||||
x:Math.min(Math.max(8,rect.left-40),window.innerWidth-360),
|
||||
y:Math.min(Math.max(8,rect.bottom+8),window.innerHeight-260),
|
||||
title:(el.textContent||'').slice(start,end),
|
||||
onPick:function(emoji){d.toggleReaction(bid,start,end,emoji);}
|
||||
});
|
||||
return;
|
||||
}
|
||||
const anch=t.closest('.fd-anchor[data-cid]');
|
||||
if(anch&&d.openThread){
|
||||
const cid=parseInt(anch.getAttribute('data-cid'),10);
|
||||
if(!isNaN(cid))d.openThread(cid);
|
||||
}
|
||||
});
|
||||
document.addEventListener('mouseup',(ev)=>{setTimeout(()=>{
|
||||
// v7.62 : clic dans la toolbar → on laisse la sélection (et le menu) intacts ;
|
||||
// sélection relâchée/cliquée ailleurs → le menu de sélection se ferme.
|
||||
@@ -2799,9 +3104,8 @@ applyAIBlocks(text){
|
||||
if(!ct?.contains(s.anchorNode)){d.fmt.open=false;d._sel=null;return;}
|
||||
const bel0=s.anchorNode&&(s.anchorNode.nodeType===1?s.anchorNode:s.anchorNode.parentElement);
|
||||
const bel=bel0?bel0.closest('[data-bid]'):null;
|
||||
if(bel){const idx=d.getIdx(bel.dataset.bid);if(idx>=0)d.showFmt(idx);d.showCommentBtn();}else{d.hideCommentBtn();}
|
||||
if(bel){const idx=d.getIdx(bel.dataset.bid);if(idx>=0)d.showFmt(idx);}
|
||||
},80);});
|
||||
document.addEventListener('mousedown',()=>{setTimeout(()=>{const d=_xed(document.querySelector('.page-editor-wrapper'));if(d)d.hideCommentBtn();},10);});
|
||||
document.addEventListener('keyup',(e)=>{
|
||||
const d=_xed(document.querySelector('.page-editor-wrapper'));if(!d)return;
|
||||
if(!d.mentionOpen){if(e.key!=='@')return;}
|
||||
|
||||
@@ -60,7 +60,9 @@ def test_actions_are_all_dispatched():
|
||||
# ── Sous-menus ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_turn_into_submenu():
|
||||
turn = _slice("function _blockTurnMenuOpen(", "\n function _blockColorMenuOpen(")
|
||||
# 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",
|
||||
@@ -75,7 +77,7 @@ def test_turn_into_submenu():
|
||||
|
||||
|
||||
def test_color_submenu():
|
||||
color = _slice("function _blockColorMenuOpen(", "\n function _blockSkillsMenuOpen(")
|
||||
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'",
|
||||
|
||||
@@ -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 : 3ᵉ ligne, après le surlignage, avant le code
|
||||
chunk = TPL[TPL.index('class="format-toolbar"'):TPL.index("Copy Link Toast")]
|
||||
order = ["fmtHighlight()", "fmtReact()", "fmtCodeBlock()", "fmtMore($event)"]
|
||||
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
|
||||
Reference in New Issue
Block a user