feat: add download + copy file content to context menus and editor
- Add /api/pages/{id}/download and /api/pages/{id}/file-content endpoints
- Add Download + Copy content to shared context menu (_ctx_menu.html)
- Wire handlers in local-workspace and library context menus
- Add Download + Copy content to editor '...' menu for file pages
- Multi-block selection copy/cut/paste with Ctrl+C/X/V shortcuts
- Align page title with blocks start (CSS pseudo-element offset)
This commit is contained in:
+62
-6
@@ -543,16 +543,70 @@ Détails livrés :
|
||||
- **Fix chemins API** : l'éditeur appelait `/api/pages/...` alors que les routes sont `/board/api/pages/...` (cover, icon, versions, backlinks, import, move, OG) — corrigé
|
||||
- **47 tests** `tests/test_v55.py` ; suite complète **444 verte** ; `ruff check` OK
|
||||
|
||||
### v5.6.0 — Import de données (étendu)
|
||||
> **Objectif** : compléter l'import de base (Markdown/CSV/Notion, déjà dans v5.4.0) par les formats pro et l'inférence de types.
|
||||
### v5.6.0 — Import de données (étendu) — Plan par phases
|
||||
> **Objectif** : faire de FlowDeck la cible d'import universelle pour les outils réellement utilisés
|
||||
> (notes Markdown, bureautique, tableaux, signets, dev), en complétant l'import de base
|
||||
> (Markdown/CSV/Notion, déjà dans v5.4.0). **Priorité n°1 de la roadmap.**
|
||||
> **Sources retenues** : Obsidian, Notion, Logseq/Roam, Apple Notes/Bear/Ulysses, Google Keep,
|
||||
> OneNote, Word, Google Docs, HTML, PDF, CSV/Excel/Sheets/JSON, Gitea/GitHub, Raindrop, Pocket,
|
||||
> Readwise, Shaarli, `.ics`, OPML, Standard Notes.
|
||||
> **Sources exclues** : Confluence, Evernote, Asana, Trello (non utilisées).
|
||||
|
||||
- [ ] **Import CSV typé** — inférence automatique des types de propriétés (texte, nombre, date, select)
|
||||
- [ ] **Import Confluence / Evernote / Asana / Trello** — via leurs formats d'export (HTML/JSON)
|
||||
#### Phase 0 — Socle d'import unifié (prérequis)
|
||||
> Toutes les sources partagent le même pipeline ; à livrer avant les imports.
|
||||
- [ ] **Service `app/services/importers/`** — interface commune (`detect()`, `parse()`, `to_pages()`) + résultat normalisé (`ImportResult` : pages, pièces jointes, warnings, stats)
|
||||
- [ ] **Pipeline commun** — upload → parse → normalisation blocs + métadonnées → création (pages + hiérarchie `parent_id`) → rapport d'import
|
||||
- [ ] **Pièces jointes** — extraction (images/fichiers), upload, réécriture des liens dans les blocs
|
||||
- [ ] **Frontmatter YAML → propriétés** (title, tags, dates, champs custom)
|
||||
- [ ] **Assistant UI d'import** — choix de la source, mapping colonnes→types, preview/dry-run, barre de progression
|
||||
- [ ] **Import asynchrone** (job en arrière-plan + polling) pour les gros volumes (vaults, zips)
|
||||
- [ ] **Idempotence / déduplication** + reprise sur erreur partielle
|
||||
- [ ] **Tests** — harnais d'import + fixtures par source
|
||||
- [ ] **Réutilise** : `_md_to_blocks` (`app/services/export.py`), `PROPERTY_TYPES` + `validate_property_rule` (`property_types.py`), `db_templates.materialize_properties`, endpoints upload/cover existants
|
||||
|
||||
#### Phase 1 — Notes & Markdown (réutilise `_md_to_blocks`) — faible effort
|
||||
- [ ] **Obsidian** (vault `.zip`/dossier) — frontmatter YAML, `[[wikilinks]]`, `![[embeds]]`, attachments, hiérarchie de dossiers *(prépare v5.11.0)*
|
||||
- [ ] **Notion** (améliorer l'existant) — hiérarchie complète, databases (CSV) → collections, images
|
||||
- [ ] **Logseq / Roam Research** — markdown outliné (puces imbriquées), `((block refs))`, pages journal
|
||||
- [ ] **Apple Notes / Bear / Ulysses** — import export HTML/Markdown
|
||||
- [ ] **Google Keep** — Takeout JSON + HTML
|
||||
- [ ] **OneNote** — export HTML/PDF, best-effort (fidélité limitée)
|
||||
|
||||
#### Phase 2 — Données & tableaux (fort ROI databases) — effort faible/moyen
|
||||
- [ ] **CSV/TSV typé** — inférence auto (texte/nombre/date/bool/select/multi_select) + UI mapping + options
|
||||
- [ ] **Excel `.xlsx`** (openpyxl) — multi-feuilles → collections
|
||||
- [ ] **Google Sheets** — export CSV/XLSX
|
||||
- [ ] **JSON générique** — mapping configurable (JSONPath → propriétés)
|
||||
|
||||
#### Phase 3 — Documents & bureautique — effort moyen
|
||||
- [ ] **Word `.docx`** (mammoth/pandoc) — titres, listes, tableaux, images
|
||||
- [ ] **Google Docs** — Takeout `.docx`/HTML
|
||||
- [ ] **HTML** (fichiers/dossier, web clipper) — conversion HTML → blocs
|
||||
- [ ] **PDF** — extraction texte + images (fidélité limitée)
|
||||
|
||||
#### Phase 4 — Signets, dev & divers
|
||||
- [ ] **Gitea / GitHub issues + labels + milestones** → collection (via API — quasi natif)
|
||||
- [ ] **Raindrop.io** (CSV/HTML) → bookmark cards (réutilise v5.5.0)
|
||||
- [ ] **Pocket** (CSV/HTML)
|
||||
- [ ] **Readwise** (highlights CSV/Markdown)
|
||||
- [ ] **Shaarli** (API/export JSON) → bookmark cards
|
||||
- [ ] **Calendrier `.ics`** (Google/Outlook/Apple)
|
||||
- [ ] **OPML** (flux/outlines)
|
||||
- [ ] **Standard Notes / autres**
|
||||
|
||||
#### Priorisation
|
||||
| Ordre | Phase | Effort | Impact |
|
||||
|---|---|---|---|
|
||||
| 1 | Phase 0 — Socle unifié | M | 🔴 prérequis |
|
||||
| 2 | Phase 1 — Notes/Markdown | S–M | 🔴 fort (Obsidian, Notion) |
|
||||
| 3 | Phase 2 — Données/tableaux | S–M | 🔴 fort (databases) |
|
||||
| 4 | Phase 4 — Signets/dev/divers | S | 🟠 moyen (Gitea natif, Raindrop) |
|
||||
| 5 | Phase 3 — Documents | M | 🟠 moyen (docx, HTML) |
|
||||
|
||||
### v5.7.0 — Database Avancée (Pt. 2)
|
||||
> **Objectif** : compléter la parité sur les propriétés, les vues sauvegardées et le Kanban pro.
|
||||
|
||||
- [ ] **Types propriété manquants** — `person`, `created_time`, `created_by`, `last_edited_time`, `last_edited_by`
|
||||
- [ ] **Types propriété — 🟡 partiellement fait** — `person`, `created_time`, `created_by`, `last_edited_time`, `last_edited_by` sont **déjà déclarés** dans `PROPERTY_TYPES` (`app/services/property_types.py:43-98`) ; reste à câbler : auto-valeurs (`created_time/by`, `last_edited_time/by`), sélecteur `person` (membres du workspace) et rendu/édition dans l'UI
|
||||
- [ ] **Groupes de propriétés** — sections pliables dans le header de DB
|
||||
- [ ] **Vues sauvegardées par utilisateur** — save view (per-user, pas workspace-wide)
|
||||
- [ ] **Swimlanes Kanban** — sous-groupes horizontaux (2e dimension de groupement)
|
||||
@@ -647,6 +701,8 @@ Détails livrés :
|
||||
5. ~~**v5.10.0 → Interactions de bloc**~~ ✅ livré (drag&drop multi, undo/redo, duplicate, menu ⋮, en-têtes de tableau, 9 tests)
|
||||
6. ~~**v5.4.0 → Expérience éditeur**~~ ✅ livré (backlinks, page/collection duplicate, corbeille globale + purge 30 j, historique de version UI, import Markdown/CSV/Notion)
|
||||
7. ~~**v5.5.0 → Embeds & Média riche**~~ ✅ livré (embed universel 15 providers, bookmark cards OG, lightbox clavier, préviews PDF/vidéo/audio, cover & icône ; 47 tests dédiés)
|
||||
8. **v5.6.0 → Import de données (5 phases)** ⬅ **priorité n°1** — Phase 0 socle unifié → Phase 1 notes/Markdown (Obsidian, Notion, Logseq/Roam, Apple Notes/Bear, Google Keep, OneNote) → Phase 2 données/tableaux (CSV typé, Excel, Sheets, JSON) → Phase 4 signets/dev/divers (Gitea/GitHub, Raindrop, Pocket, Readwise, Shaarli, `.ics`, OPML, Standard Notes) → Phase 3 documents (Word, Google Docs, HTML, PDF)
|
||||
9. **v5.7.0 → v5.8.0 → v5.11.0 → v5.12.0 → v5.14.0 → v6.0.0** (database avancée, calendrier/rappels, wiki-links, templates/lock, synced blocks, pro)
|
||||
---
|
||||
|
||||
## Résumé des phases
|
||||
@@ -665,4 +721,4 @@ Quality DB views, Agent IA Palette → Realtime + E
|
||||
DB avancée, redo, drag&drop, bookmark,
|
||||
Calendrier, AI duplicate) lightbox…)
|
||||
|
||||
*Dernière mise à jour: 2026-09-12 — **v5.5.0 Embeds & Média riche validé** (47 tests dédiés, 444 tests suite complète, ruff vert) ; reste v5.6, v5.7, v5.8, v5.11, v5.12, v5.14 → v6.0*
|
||||
*Dernière mise à jour: 2026-09-12 — **v5.5.0 Embeds & Média riche validé** (47 tests dédiés, 444 tests suite complète, ruff vert) ; **plan d'import v5.6.0 détaillé en 5 phases** (socle unifié, notes/Markdown, données/tableaux, documents, signets/dev/divers) ; reste v5.6, v5.7, v5.8, v5.11, v5.12, v5.14 → v6.0*
|
||||
|
||||
@@ -1021,6 +1021,92 @@ async def get_page_content(page_id: int):
|
||||
return {"content": row["content"] or "", "format": fmt}
|
||||
|
||||
|
||||
def _file_page_disk_path(page: dict):
|
||||
"""Resolve the on-disk file behind a ``content_format == 'file'`` page.
|
||||
|
||||
Returns ``(abs_path: Path, filename: str, mime: str, size: int)`` or None
|
||||
when the row is not a file page, references a non-textual/missing file, or
|
||||
the path escapes the data root (path-traversal guard).
|
||||
"""
|
||||
if (page.get("content_format") or "") != "file":
|
||||
return None
|
||||
import json as _json
|
||||
try:
|
||||
meta = _json.loads(page.get("content", "{}"))
|
||||
except (_json.JSONDecodeError, TypeError):
|
||||
meta = {}
|
||||
if not isinstance(meta, dict):
|
||||
return None
|
||||
rel = (meta.get("file_path") or "").replace("\\", "/").strip()
|
||||
if not rel or not rel.startswith("uploads/"):
|
||||
return None
|
||||
parts = rel.split("/")
|
||||
if ".." in parts or "." in parts:
|
||||
return None
|
||||
import os as _os
|
||||
from pathlib import Path
|
||||
root = Path(_os.environ.get("FLOWDECK_DATA_DIR", "/data")).resolve()
|
||||
full = (root / rel).resolve()
|
||||
try:
|
||||
full.relative_to(root)
|
||||
except ValueError:
|
||||
return None
|
||||
if not full.exists() or not full.is_file():
|
||||
return None
|
||||
filename = parts[-1] or page.get("title", "file")
|
||||
mime = meta.get("mime_type") or "application/octet-stream"
|
||||
size = meta.get("size") or 0
|
||||
return (full, filename, mime, size)
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/download")
|
||||
async def download_page_file(page_id: int):
|
||||
"""Download the original uploaded file of a ``file`` page (attachment)."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, content, content_format FROM pages "
|
||||
"WHERE id=? AND deleted_at IS NULL",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
resolved = _file_page_disk_path(dict(row))
|
||||
if not resolved:
|
||||
return JSONResponse({"error": "No downloadable file"}, status_code=404)
|
||||
full, filename, mime, _size = resolved
|
||||
from fastapi.responses import FileResponse
|
||||
return FileResponse(
|
||||
str(full), media_type=mime or "application/octet-stream",
|
||||
filename=filename, content_disposition_type="attachment",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/file-content")
|
||||
async def page_file_content(page_id: int):
|
||||
"""Return the textual content of a ``file`` page (for copy to clipboard).
|
||||
|
||||
Binary files (PDF, images…) answer ``{ok: false}`` — the clipboard copy is
|
||||
only meaningful for plain-text / code / markdown files.
|
||||
"""
|
||||
from app.services.export import _file_text
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, content, content_format FROM pages "
|
||||
"WHERE id=? AND deleted_at IS NULL",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
page = dict(row)
|
||||
text = _file_text(page)
|
||||
if text is None:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Not a textual file", "name": page.get("title", "")},
|
||||
status_code=415,
|
||||
)
|
||||
return {"ok": True, "name": page.get("title") or "File", "content": text}
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/breadcrumb")
|
||||
async def local_workspace_breadcrumb(request: Request, folder: int):
|
||||
"""Return breadcrumb trail for a folder."""
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
avec la capacité respective de la page au clic.
|
||||
|
||||
Handlers attendus (tous optionnels) :
|
||||
open, openTab, peek, folder, rename, setIcon, duplicate, link, move,
|
||||
fav, recent, delete, tagExisting, tagAdd, tagRemove
|
||||
open, openTab, peek, folder, rename, setIcon, duplicate, link, download,
|
||||
copyContent, move, fav, recent, delete, tagExisting, tagAdd, tagRemove
|
||||
############################################################################}
|
||||
<script data-cfasync="false">
|
||||
/* ═════════════════════════════════════════════════════════════════════
|
||||
@@ -224,6 +224,18 @@
|
||||
<span style="flex:1;">Copy link</span>
|
||||
</div>
|
||||
|
||||
{# ── Download file (file pages) ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('download')" @click="$store.fdCtx.run('download')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
<span style="flex:1;">Download</span>
|
||||
</div>
|
||||
|
||||
{# ── Copy file content (textual file pages) ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('copyContent')" @click="$store.fdCtx.run('copyContent')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
<span style="flex:1;">Copy content</span>
|
||||
</div>
|
||||
|
||||
{# ── Move to ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('move')" @click="$store.fdCtx.run('move')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
|
||||
|
||||
@@ -457,6 +457,15 @@
|
||||
<div class="more-menu-item" @click="exportPage('site')"> Site statique (.zip)</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="contentFormat === 'file' && fileUrl">
|
||||
<div class="more-menu-sep"></div>
|
||||
<div class="more-menu-item" @click="downloadFile()">
|
||||
{{ fd_icon("download",14) }} Download
|
||||
</div>
|
||||
<div class="more-menu-item" @click="copyFileContent()">
|
||||
{{ fd_icon("copy",14) }} Copy content
|
||||
</div>
|
||||
</template>
|
||||
<div class="more-menu-item" @click="duplicatePage">
|
||||
{{ fd_icon("copy",14) }} Duplicate
|
||||
</div>
|
||||
|
||||
@@ -781,6 +781,9 @@
|
||||
if(act==='turn'){var m=document.getElementById('blockMenu');if(m){var r=m.getBoundingClientRect();_blockTurnMenuOpen(r.right+4,r.top,bid||'');}return;}
|
||||
if(act==='duplicate'){E.duplicateBlock(idx);}
|
||||
else if(act==='copy-link'){E.copyBlockLink(bid||'');}
|
||||
else if(act==='copy-blocks'){E.copySelectedBlocks();}
|
||||
else if(act==='cut-blocks'){E.cutSelectedBlocks();}
|
||||
else if(act==='paste-blocks'){E.pasteBlocks();}
|
||||
else if(act==='move-to'){_blockMoveToOpen(idx);}
|
||||
else if(act==='delete'){E.deleteSelected(idx);}
|
||||
else if(act==='color'){var key=t.getAttribute('data-color-key'),val=t.getAttribute('data-color-val');E.setBlockColor(idx,key,val);}
|
||||
@@ -800,6 +803,11 @@
|
||||
+'<div class="bm-sep"></div>'
|
||||
+'<div class="bm-item" data-bm-act="duplicate" data-bid="'+b.id+'"><span class="bm-ico">▣</span>Duplicate <span class="bm-hint">'+(_rtMac()?'⌘D':'Ctrl+D')+'</span></div>'
|
||||
+'<div class="bm-item" data-bm-act="copy-link" data-bid="'+b.id+'"><span class="bm-ico">🔗</span>Copy link to block</div>'
|
||||
+'<div class="bm-sep"></div>'
|
||||
+'<div class="bm-item" data-bm-act="copy-blocks" data-bid="'+b.id+'"><span class="bm-ico">⎘</span>Copy blocks <span class="bm-hint">'+(_rtMac()?'⌘C':'Ctrl+C')+'</span></div>'
|
||||
+'<div class="bm-item" data-bm-act="cut-blocks" data-bid="'+b.id+'"><span class="bm-ico">✂</span>Cut blocks <span class="bm-hint">'+(_rtMac()?'⌘X':'Ctrl+X')+'</span></div>'
|
||||
+'<div class="bm-item" data-bm-act="paste-blocks" data-bid="'+b.id+'"><span class="bm-ico">⎘</span>Paste blocks <span class="bm-hint">'+(_rtMac()?'⌘V':'Ctrl+V')+'</span></div>'
|
||||
+'<div class="bm-sep"></div>'
|
||||
+'<div class="bm-item" data-bm-act="move-to" data-bid="'+b.id+'"><span class="bm-ico">↪</span>Move to…</div>'
|
||||
+'<div class="bm-sep"></div>'
|
||||
+'<div class="bm-colors"><div class="bm-colors-label">Text</div><div class="bm-swatches">'+colorsText+'</div></div>'
|
||||
@@ -1245,6 +1253,12 @@ 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;});this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();},
|
||||
copyBlockLink(bid){const url=window.location.origin+window.location.pathname+'#fdblk-'+bid;if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(url).then(()=>this.showToast('Lien du bloc copié')).catch(()=>this.showToast('Copie impossible','error'));}else{this.showToast(url,'error');}},
|
||||
deleteSelected(idx){const indices=_selIndicesSorted();if(indices.length<=1)return this.removeBlock(idx);this.sync();this.pushHistory();const keep=this.blocks.filter((b,i)=>indices.indexOf(i)<0);this.blocks=keep.length?keep:[this.mkB('paragraph','')];const fi=Math.min(idx,this.blocks.length-1);this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);},
|
||||
// ── v5.11: Copy / Cut / Paste selected blocks ──
|
||||
_blocksToMarkdown(blocks){return blocks.map(blocksToMarkdown).join('\n\n');},
|
||||
copySelectedBlocks(){const indices=_selIndicesSorted();if(!indices.length)return;const selected=indices.map(i=>this.blocks[i]).filter(Boolean);if(!selected.length)return;const md=this._blocksToMarkdown(selected);if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(md).then(()=>this.showToast('Blocs copiés')).catch(()=>this.showToast('Copie impossible','error'));}else{this.showToast(md,'error');}},
|
||||
cutSelectedBlocks(){const indices=_selIndicesSorted();if(!indices.length)return;this.sync();this.pushHistory();const selected=indices.map(i=>this.blocks[i]).filter(Boolean);if(!selected.length)return;const md=this._blocksToMarkdown(selected);if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(md).then(()=>{});}this.blocks=this.blocks.filter((b,i)=>indices.indexOf(i)<0);if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();this.showToast('Blocs coupés');},
|
||||
async pasteBlocks(){const idx=this._focusedIdx();if(idx<0)return;try{const text=await navigator.clipboard.readText();if(!text||!text.trim())return;const parsed=this.md2b(text);if(!parsed.length)return;this.sync();this.pushHistory();this.blocks.splice(idx+1,0,...parsed);this.dirty=true;this.autoSave();this.render();_rtSync();this._focusBid(parsed[parsed.length-1].id);_selClear();}catch(e){if(e.name==='NotAllowedError'){this.showToast('Permission presse-papier refusée');}else{this.showToast('Coller impossible','error');}},
|
||||
_focusedIdx(){const a=this.getActiveBlock();return a?a.idx:-1;},
|
||||
async moveBlockToPage(idx,pageId){if(idx<0||idx>=this.blocks.length||pageId===this.pid)return;this.sync();this.pushHistory();const block=this.blocks[idx];const self=this;const csrf=document.cookie.match(/csrf_token=([^;]+)/);this.showToast('Déplacement du bloc…');try{const r=await fetch(`/board/api/pages/${pageId}`,{credentials:'same-origin'});const d=await r.json();let tblocks=[];if(d.content_format==='blocks'&&d.content){try{tblocks=JSON.parse(d.content);}catch(e){tblocks=[];}}tblocks.push(block);await fetch(`/board/api/pages/${pageId}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:d.title||'',blocks:tblocks})});
|
||||
if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();this.showToast('Bloc déplacé');
|
||||
}catch(e){this.showToast('Échec du déplacement','error');}},
|
||||
@@ -1558,6 +1572,8 @@ applyAIBlocks(text){
|
||||
.catch(function(){ self.showToast('Move failed'); });
|
||||
},
|
||||
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(()=>{window.location.href='/';}).catch(()=>{window.showToast('Failed','error');});},
|
||||
downloadFile(){this.moreOpen=false;if(!this.fileUrl)return;var a=document.createElement('a');a.href=this.fileUrl;a.download='';document.body.appendChild(a);a.click();document.body.removeChild(a);this.showToast('Download started','success');},
|
||||
async copyFileContent(){this.moreOpen=false;if(!this.fileUrl)return;try{var r=await fetch('/api/pages/'+this.pid+'/file-content');var d=await r.json();if(d.ok&&d.content!==undefined){await navigator.clipboard.writeText(d.content);this.showToast('Content copied to clipboard','success');}else{this.showToast('Not a text file','error');}}catch(e){this.showToast('Copy failed','error');}},
|
||||
|
||||
// ── v4.6.0 / v5.3.0: Get Started actions — Database (template picker) ──
|
||||
dbTplOpen:false, dbTplLoading:false, dbTemplates:[], dbTplTargetIdx:-1,
|
||||
@@ -1645,7 +1661,7 @@ applyAIBlocks(text){
|
||||
}
|
||||
},
|
||||
|
||||
onKd(e){
|
||||
async onKd(e){
|
||||
const ab=this.getActiveBlock();if(!ab)return;
|
||||
const{el,bid,idx}=ab;const block=this.blocks[idx];if(!block)return;
|
||||
if(SM.isOpen){
|
||||
@@ -1690,6 +1706,20 @@ applyAIBlocks(text){
|
||||
if(e.key.toLowerCase()==='z'){e.preventDefault();if(e.shiftKey)this.redo();else this.undo();return;}
|
||||
if(e.key.toLowerCase()==='y'){e.preventDefault();this.redo();return;}
|
||||
if(e.key.toLowerCase()==='d'){e.preventDefault();const ab2=this.getActiveBlock();if(ab2)this.duplicateBlock(ab2.idx);return;}
|
||||
if(e.key.toLowerCase()==='c'){
|
||||
e.preventDefault();
|
||||
const sel=_selIndicesSorted();
|
||||
if(sel.length>0){this.copySelectedBlocks();return;}
|
||||
}
|
||||
if(e.key.toLowerCase()==='x'){
|
||||
e.preventDefault();
|
||||
const sel=_selIndicesSorted();
|
||||
if(sel.length>0){this.cutSelectedBlocks();return;}
|
||||
}
|
||||
if(e.key.toLowerCase()==='v'){
|
||||
e.preventDefault();
|
||||
await this.pasteBlocks();return;
|
||||
}
|
||||
const m={b:'bold',i:'italic',u:'underline'};if(m[e.key]){e.preventDefault();document.execCommand(m[e.key]);this.dirty=true;this.autoSave();}}
|
||||
if(e.key==='Tab'&&block.type==='code'){e.preventDefault();document.execCommand('insertText',false,' ');}
|
||||
this.dirty=true;this.autoSave();
|
||||
|
||||
@@ -45,6 +45,15 @@
|
||||
.page-title-block {
|
||||
padding: 16px 8px;
|
||||
}
|
||||
.page-title-block::before {
|
||||
display: none;
|
||||
}
|
||||
.page-title-block .page-icon-emoji {
|
||||
margin-left: 0;
|
||||
}
|
||||
.page-title-block .page-title-input {
|
||||
padding-left: 0;
|
||||
}
|
||||
.blocks-container {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
@@ -718,6 +718,8 @@ function libraryPage() {
|
||||
setIcon: function (icon) { self.setItemIcon(item, icon); },
|
||||
duplicate: function (n) { self.duplicateItem(n); },
|
||||
link: function (n) { self.copyLink(n); },
|
||||
download: function (n) { self.downloadItem(n); },
|
||||
copyContent: function (n) { self.copyItemContent(n); },
|
||||
move: function (n) { self.openMovePicker([n.id]); },
|
||||
fav: function (n) { self.toggleFavoriteItem(n); },
|
||||
delete: function (n) { self.deleteItem(n); },
|
||||
@@ -731,6 +733,34 @@ function libraryPage() {
|
||||
store.openMenu(evt, item, 'library', handlers);
|
||||
},
|
||||
|
||||
downloadItem(node) {
|
||||
if (!node || node.is_folder) return;
|
||||
var url = '/api/pages/' + node.id + '/download';
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.style.display = 'none';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
if (window.showToast) window.showToast('Download started', 'success');
|
||||
},
|
||||
|
||||
async copyItemContent(node) {
|
||||
if (!node || node.is_folder) return;
|
||||
try {
|
||||
var r = await fetch('/api/pages/' + node.id + '/file-content');
|
||||
var d = await r.json();
|
||||
if (d.ok && d.content !== undefined) {
|
||||
await navigator.clipboard.writeText(d.content);
|
||||
if (window.showToast) window.showToast('Content copied to clipboard', 'success');
|
||||
} else {
|
||||
if (window.showToast) window.showToast('Not a text file', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
if (window.showToast) window.showToast('Copy failed', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ── Drag & drop (move / reparent) ──
|
||||
onRowDragStart(evt, id) {
|
||||
if (!evt.target.closest('.drag-handle')) { evt.preventDefault(); return; }
|
||||
|
||||
@@ -1025,6 +1025,8 @@ window._wsInitData = (function() {
|
||||
setIcon: function (icon) { self.setItemIcon(node, icon); },
|
||||
duplicate: function (n) { self.duplicateItem(n); },
|
||||
link: function (n) { self.copyItemLink(n); },
|
||||
download: function (n) { self.downloadItem(n); },
|
||||
copyContent: function (n) { self.copyItemContent(n); },
|
||||
move: function (n) { self._ctxMove(n); },
|
||||
fav: function (n) { self.toggleFavorite(n); },
|
||||
delete: function (n) { self.deleteWithUndo(n); },
|
||||
@@ -1035,6 +1037,34 @@ window._wsInitData = (function() {
|
||||
store.openMenu(ev, node, 'workspace', handlers);
|
||||
},
|
||||
|
||||
downloadItem(node) {
|
||||
if (!node || node.is_folder) return;
|
||||
var url = '/api/pages/' + node.id + '/download';
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.style.display = 'none';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
if (window.showToast) window.showToast('Download started', 'success');
|
||||
},
|
||||
|
||||
async copyItemContent(node) {
|
||||
if (!node || node.is_folder) return;
|
||||
try {
|
||||
var r = await fetch('/api/pages/' + node.id + '/file-content');
|
||||
var d = await r.json();
|
||||
if (d.ok && d.content !== undefined) {
|
||||
await navigator.clipboard.writeText(d.content);
|
||||
if (window.showToast) window.showToast('Content copied to clipboard', 'success');
|
||||
} else {
|
||||
if (window.showToast) window.showToast('Not a text file', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
if (window.showToast) window.showToast('Copy failed', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
_ctxAvailTags(node) {
|
||||
return (this.workspaceTags || []).filter(function(t) {
|
||||
return !((node && node.tags) || []).some(function(nt) { return nt.id === t.id; });
|
||||
|
||||
@@ -2213,6 +2213,7 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page-icon-emoji {
|
||||
@@ -2296,6 +2297,24 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
border-radius: 3px;
|
||||
fill: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Align page title content with blocks (accounting for drag handle offset) */
|
||||
.page-title-block::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -40px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.page-title-block .page-icon-emoji {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.page-title-block .page-title-input {
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.block-handle.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -3550,6 +3569,7 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
.page-cover-area { padding: 32px 32px 8px; }
|
||||
.blocks-container { padding: 8px 32px 80px; }
|
||||
.block-handle { left: -32px; }
|
||||
.page-title-block::before { left: -32px; }
|
||||
.page-title-input { font-size: 32px; }
|
||||
|
||||
.kanban-column { min-width: 240px; max-width: 300px; }
|
||||
@@ -3629,6 +3649,9 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
.blocks-container { padding: 8px 16px 96px; }
|
||||
.block-handle { display: none; }
|
||||
.block-actions { display: none; }
|
||||
.page-title-block::before { display: none; }
|
||||
.page-title-block .page-icon-emoji { margin-left: 0; }
|
||||
.page-title-block .page-title-input { padding-left: 0; }
|
||||
.editor-statusbar { left: 0; }
|
||||
|
||||
/* ── View Tabs ── */
|
||||
@@ -3738,6 +3761,9 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
.page-title-input { font-size: 22px; }
|
||||
.blocks-container { padding: 6px 12px 80px; }
|
||||
.page-icon-emoji { font-size: 28px; }
|
||||
.page-title-block::before { display: none; }
|
||||
.page-title-block .page-icon-emoji { margin-left: 0; }
|
||||
.page-title-block .page-title-input { padding-left: 0; }
|
||||
|
||||
.kanban-column {
|
||||
min-width: 92vw;
|
||||
|
||||
Reference in New Issue
Block a user