feat: add download + copy file content to context menus and editor
FlowDeck CI / lint (push) Successful in 45s
FlowDeck CI / test (push) Successful in 4m8s
FlowDeck CI / docker (push) Successful in 38s

- 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:
2026-09-12 12:53:17 -04:00
parent 3bb17eb984
commit 7a6c66a609
9 changed files with 297 additions and 9 deletions
+62 -6
View File
@@ -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*
+86
View File
@@ -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."""
+14 -2
View File
@@ -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>
+9
View File
@@ -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>
+31 -1
View File
@@ -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();
+9
View File
@@ -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;
}
+30
View File
@@ -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; }
+30
View File
@@ -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; });
+26
View File
@@ -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;