ajout de fonctionnalités
This commit is contained in:
parent
e1fcbe9ce7
commit
370420aa00
10
TODO.md
Normal file
10
TODO.md
Normal file
@ -0,0 +1,10 @@
|
||||
# Nouvelles fonctionnalités
|
||||
- [ ] la détection et l'ajout de nouveaux fichiers dans l'interface se fait bien. Cependant, il faut que l'indexation se fasse automatiquement aussi pour les nouveaux fichiers. Optimiser l'indexation pour ne pas réindexer tout le contenu déjà indexé mais seulement les nouveaux fichiers.
|
||||
- [ ] Dans le menu contextuel de l'arborescence, ajouter une option pour copier le path du répertoire courant ou du fichier courant dans le presse-papiers.
|
||||
- [] Ajouter dans le menu de configuration une option pour visialiser un panneau a propos (About) permettant d'afficher des informations sur l'application ainsi que les versions des composants utilisés et la version de l'application.
|
||||
- [ ] Dans le menu contextuel de l'arborescence sur n'importe quel voutes et dossiers, ajouter un bouton pour afficher un "graph view" en lien avec le path courant. Ce graph view doit afficher les relations entre les fichiers et les dossiers dans l'arborescence. Ce graph view doit être interactif et permettre de zoomer et de déplacer les nœuds comme dans celui de l'outils Obsidian.
|
||||
- [ ] Ajouter le mode tab permettant de visualiser plusieurs fichiers à la fois dans l'interface. ajouter les fonctionnalités courantes de la gestion des tabs (fermer, déplacer, ajouter, etc.) incluant l'utilisation de raccourcis clavier pour les opérations de tab (simple clic vs double clic).
|
||||
|
||||
# Corrections
|
||||
- [ ] quand l'on ouvre un fichier via l'url popout, https://xxx/popout/path/du/fichier/fichier.md et qu'il y a un requis d'authentification (quand le message indique "Erreur lors du chargement (Essayez de vous reconnecter sur le site principal)") proposer un login et une fois login rediriger vers https://xxx/popout/path/du/fichier/fichier.md.
|
||||
- [ ] Quand il y a une table des matières dans un fichier markdown, en clicant sur un titre de section, le scroll doit se déplacer automatiquement vers la section correspondante. Ca fonctionne avec certains liens mais pas sur d'autres. J'ai l'impression que le problème provient de lien avec des titre qui contient des lettres accentié.
|
||||
@ -582,6 +582,30 @@ def _remove_file_from_structures(vault_name: str, rel_path: str) -> Optional[Dic
|
||||
return removed
|
||||
|
||||
|
||||
def _ensure_parent_dirs_in_path_index(vault_name: str, rel_path: str, existing: set):
|
||||
"""Ensure all parent directories of a file path exist in path_index.
|
||||
|
||||
For a path like ``a/b/c/file.md``, ensures ``a``, ``a/b``, and ``a/b/c``
|
||||
directory entries exist in path_index.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
rel_path: Relative path of the file (e.g. ``a/b/c/file.md``).
|
||||
existing: Set of paths already in path_index for this vault.
|
||||
"""
|
||||
parts = rel_path.split("/")
|
||||
# Build parent directory paths
|
||||
for i in range(1, len(parts)):
|
||||
dir_path = "/".join(parts[:i])
|
||||
if dir_path and dir_path not in existing:
|
||||
existing.add(dir_path)
|
||||
path_index[vault_name].append({
|
||||
"path": dir_path,
|
||||
"name": parts[i - 1],
|
||||
"type": "directory",
|
||||
})
|
||||
|
||||
|
||||
def _add_file_to_structures(vault_name: str, file_info: Dict[str, Any]):
|
||||
"""Add a file entry to all index structures.
|
||||
|
||||
@ -608,9 +632,8 @@ def _add_file_to_structures(vault_name: str, file_info: Dict[str, Any]):
|
||||
_file_lookup[key] = []
|
||||
_file_lookup[key].append(entry)
|
||||
|
||||
# Add to path_index
|
||||
# Add to path_index — also ensures parent directories are present
|
||||
if vault_name in path_index:
|
||||
# Check if already present (avoid duplicates)
|
||||
existing = {p["path"] for p in path_index[vault_name]}
|
||||
if rel_path not in existing:
|
||||
path_index[vault_name].append({
|
||||
@ -618,6 +641,8 @@ def _add_file_to_structures(vault_name: str, file_info: Dict[str, Any]):
|
||||
"name": rel_path.rsplit("/", 1)[-1],
|
||||
"type": "file",
|
||||
})
|
||||
# Ensure all parent directories are in path_index
|
||||
_ensure_parent_dirs_in_path_index(vault_name, rel_path, existing)
|
||||
|
||||
_index_generation += 1
|
||||
|
||||
|
||||
261
backend/main.py
261
backend/main.py
@ -221,6 +221,30 @@ class TagSuggestResponse(BaseModel):
|
||||
suggestions: List[TagSuggestion]
|
||||
|
||||
|
||||
class GraphNode(BaseModel):
|
||||
"""A single node in the graph view."""
|
||||
id: str = Field(description="Unique node identifier")
|
||||
name: str = Field(description="Display name")
|
||||
type: str = Field(description="'vault', 'directory', or 'file'")
|
||||
path: str = Field(description="Relative path within vault")
|
||||
size: int = Field(default=0, description="File size in bytes")
|
||||
|
||||
|
||||
class GraphEdge(BaseModel):
|
||||
"""An edge between two nodes in the graph view."""
|
||||
source: str = Field(description="Source node ID")
|
||||
target: str = Field(description="Target node ID")
|
||||
relation: str = Field(description="'parent' or 'wikilink'")
|
||||
|
||||
|
||||
class GraphResponse(BaseModel):
|
||||
"""Graph data for a vault or directory."""
|
||||
vault: str
|
||||
path: str
|
||||
nodes: List[GraphNode]
|
||||
edges: List[GraphEdge]
|
||||
|
||||
|
||||
class ReloadResponse(BaseModel):
|
||||
"""Index reload confirmation with per-vault stats."""
|
||||
status: str
|
||||
@ -595,6 +619,71 @@ def _check_vault_writable(vault_root: Path) -> bool:
|
||||
# Markdown rendering helpers (singleton renderer)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import unicodedata
|
||||
|
||||
|
||||
def _heading_slugify(text: str) -> str:
|
||||
"""Generate a URL-safe slug from heading text.
|
||||
|
||||
Matches the JavaScript slugify algorithm exactly:
|
||||
1. Lowercase
|
||||
2. NFD normalize + strip combining marks
|
||||
3. Keep only Unicode letters, numbers, spaces, hyphens
|
||||
4. Replace spaces with hyphens, collapse multiple hyphens
|
||||
|
||||
Args:
|
||||
text: The heading text content.
|
||||
|
||||
Returns:
|
||||
A URL-safe slug string.
|
||||
"""
|
||||
text = text.lower()
|
||||
text = unicodedata.normalize("NFD", text)
|
||||
text = "".join(ch for ch in text if not unicodedata.combining(ch))
|
||||
# Keep only Unicode letters, numbers, spaces, and hyphens
|
||||
cleaned = []
|
||||
for ch in text:
|
||||
if ch.isalpha() or ch.isdigit() or ch in (" ", "-"):
|
||||
cleaned.append(ch)
|
||||
text = "".join(cleaned)
|
||||
text = re.sub(r"\s+", "-", text)
|
||||
text = re.sub(r"-+", "-", text)
|
||||
return text.strip("-") or "heading"
|
||||
|
||||
|
||||
def _add_heading_ids(html: str) -> str:
|
||||
"""Post-process rendered HTML to add IDs to heading tags.
|
||||
|
||||
Adds an ``id`` attribute to every ``<h1>`` through ``<h6>`` tag
|
||||
using a slug generated from the heading's text content.
|
||||
Duplicate slugs get a ``-2``, ``-3``, etc. suffix.
|
||||
|
||||
Args:
|
||||
html: Rendered HTML string.
|
||||
|
||||
Returns:
|
||||
HTML with heading IDs injected.
|
||||
"""
|
||||
used_ids: Dict[str, int] = {}
|
||||
|
||||
def _replace_heading(match):
|
||||
tag = match.group(1)
|
||||
content = match.group(2)
|
||||
slug = _heading_slugify(content)
|
||||
count = used_ids.get(slug, 0)
|
||||
used_ids[slug] = count + 1
|
||||
if count > 0:
|
||||
slug = f"{slug}-{count + 1}"
|
||||
return f'<{tag} id="{slug}">{content}</{tag}>'
|
||||
|
||||
# Match h1-h6 tags with text content (no existing id attribute)
|
||||
return re.sub(
|
||||
r'<(h[1-6])>([^<]*(?:<(?!/?h[1-6])[^<]*)*)</h[1-6]>',
|
||||
_replace_heading,
|
||||
html,
|
||||
)
|
||||
|
||||
|
||||
# Cached mistune renderer — avoids re-creating on every request
|
||||
_markdown_renderer = mistune.create_markdown(
|
||||
escape=False,
|
||||
@ -656,7 +745,12 @@ def _render_markdown(raw_md: str, vault_name: str, current_file_path: Optional[P
|
||||
# Convert wikilinks
|
||||
converted = _convert_wikilinks(raw_md, vault_name)
|
||||
|
||||
return _markdown_renderer(converted)
|
||||
rendered = _markdown_renderer(converted)
|
||||
|
||||
# Add heading IDs for TOC navigation
|
||||
rendered = _add_heading_ids(rendered)
|
||||
|
||||
return rendered
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -1165,6 +1259,26 @@ async def api_directory_create(
|
||||
dir_path.mkdir(parents=True, exist_ok=False)
|
||||
logger.info(f"Directory created: {vault_name}/{body.path}")
|
||||
|
||||
# Update path_index with the new directory
|
||||
from backend.indexer import path_index as _path_idx, _index_generation
|
||||
import threading
|
||||
from backend.indexer import _index_lock
|
||||
with _index_lock:
|
||||
if vault_name not in _path_idx:
|
||||
_path_idx[vault_name] = []
|
||||
existing = {p["path"] for p in _path_idx[vault_name]}
|
||||
# Build all parent segments
|
||||
parts = body.path.split("/")
|
||||
for i in range(1, len(parts) + 1):
|
||||
seg_path = "/".join(parts[:i])
|
||||
if seg_path and seg_path not in existing:
|
||||
existing.add(seg_path)
|
||||
_path_idx[vault_name].append({
|
||||
"path": seg_path,
|
||||
"name": parts[i - 1],
|
||||
"type": "directory",
|
||||
})
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("directory_created", {
|
||||
"vault": vault_name,
|
||||
@ -1744,6 +1858,151 @@ async def api_reload(current_user=Depends(require_admin)):
|
||||
return {"status": "ok", "vaults": stats}
|
||||
|
||||
|
||||
@app.get("/api/graph/{vault_name}", response_model=GraphResponse)
|
||||
async def api_graph(
|
||||
vault_name: str,
|
||||
path: str = Query("", description="Relative path to focus on"),
|
||||
depth: int = Query(1, ge=0, le=3, description="How many levels deep to expand"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return graph data (nodes and edges) for a vault or directory.
|
||||
|
||||
Nodes represent files and directories. Edges represent parent-child
|
||||
relationships and wikilinks between markdown files.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative directory path to focus on (empty = root).
|
||||
depth: Expansion depth (0 = only direct children, 1-3 = deeper).
|
||||
|
||||
Returns:
|
||||
``GraphResponse`` with nodes and edges.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
target = _resolve_safe_path(vault_root, path) if path else vault_root.resolve()
|
||||
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Path not found: {path}")
|
||||
|
||||
nodes: List[dict] = []
|
||||
edges: List[dict] = []
|
||||
node_ids: set = set()
|
||||
|
||||
def _add_node(name: str, ntype: str, npath: str, size: int = 0) -> str:
|
||||
nid = f"{vault_name}:{npath}"
|
||||
if nid not in node_ids:
|
||||
node_ids.add(nid)
|
||||
nodes.append({"id": nid, "name": name, "type": ntype, "path": npath, "size": size})
|
||||
return nid
|
||||
|
||||
def _add_edge(source: str, target: str, relation: str):
|
||||
edges.append({"source": source, "target": target, "relation": relation})
|
||||
|
||||
# Get vault settings for hidden files
|
||||
from backend.vault_settings import get_vault_setting
|
||||
settings = get_vault_setting(vault_name) or {}
|
||||
hide_hidden = settings.get("hideHiddenFiles", False)
|
||||
|
||||
# Add the focus node
|
||||
focus_name = path.split("/")[-1] if path else vault_name
|
||||
focus_type = "directory" if path else "vault"
|
||||
focus_id = _add_node(focus_name, focus_type, path)
|
||||
|
||||
# Walk directory tree up to depth levels
|
||||
def _walk_dir(dir_path: Path, parent_id: str, current_depth: int):
|
||||
if current_depth > depth:
|
||||
return
|
||||
try:
|
||||
for entry in sorted(dir_path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
|
||||
if hide_hidden and entry.name.startswith("."):
|
||||
continue
|
||||
rel = str(entry.relative_to(vault_root)).replace("\\", "/")
|
||||
if entry.is_dir():
|
||||
did = _add_node(entry.name, "directory", rel)
|
||||
_add_edge(parent_id, did, "parent")
|
||||
if current_depth < depth:
|
||||
_walk_dir(entry, did, current_depth + 1)
|
||||
elif entry.suffix.lower() in SUPPORTED_EXTENSIONS or entry.name.lower() in ("dockerfile", "makefile"):
|
||||
fid = _add_node(entry.name, "file", rel, entry.stat().st_size)
|
||||
_add_edge(parent_id, fid, "parent")
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
if target.is_dir():
|
||||
_walk_dir(target, focus_id, 0)
|
||||
elif target.is_file():
|
||||
# For a single file, show siblings in same directory
|
||||
_walk_dir(target.parent, focus_id, 0)
|
||||
|
||||
# Add wikilink edges between markdown files in the current scope
|
||||
_add_wikilink_edges(nodes, edges, node_ids, vault_name)
|
||||
|
||||
return {"vault": vault_name, "path": path, "nodes": nodes, "edges": edges}
|
||||
|
||||
|
||||
def _add_wikilink_edges(nodes: list, edges: list, node_ids: set, vault_name: str):
|
||||
"""Add edges for wikilinks between markdown files in the current graph scope."""
|
||||
from backend.indexer import find_file_in_index
|
||||
|
||||
# Only consider files nodes
|
||||
file_nodes = [n for n in nodes if n["type"] == "file" and n["path"].endswith(".md")]
|
||||
if len(file_nodes) < 2:
|
||||
return
|
||||
|
||||
# Build lookup: relative_path → node_id
|
||||
path_to_id = {n["path"]: n["id"] for n in file_nodes}
|
||||
|
||||
wikilink_pattern = re.compile(r"\[\[([^\]|#]+)(?:[|#][^\]]+)?\]\]")
|
||||
|
||||
for node in file_nodes:
|
||||
# Get the file content from index
|
||||
vault_data = index.get(vault_name)
|
||||
if not vault_data:
|
||||
continue
|
||||
file_entry = None
|
||||
for f in vault_data.get("files", []):
|
||||
if f["path"] == node["path"]:
|
||||
file_entry = f
|
||||
break
|
||||
if not file_entry:
|
||||
continue
|
||||
|
||||
content = file_entry.get("content", "")
|
||||
if not content:
|
||||
continue
|
||||
|
||||
# Find all wikilinks in content
|
||||
for match in wikilink_pattern.finditer(content):
|
||||
target = match.group(1).strip()
|
||||
# Try to find the target in our graph scope first
|
||||
target_lower = target.lower()
|
||||
if not target_lower.endswith(".md"):
|
||||
target_lower += ".md"
|
||||
|
||||
for target_path, target_id in path_to_id.items():
|
||||
if target_id == node["id"]:
|
||||
continue
|
||||
target_name = target_path.rsplit("/", 1)[-1].lower()
|
||||
if target_name == target_lower or target_path.lower() == target_lower:
|
||||
# Avoid duplicate edges
|
||||
edge_key = tuple(sorted([node["id"], target_id]))
|
||||
if edge_key not in {(e["source"], e["target"]) for e in edges} and \
|
||||
edge_key not in {(e["target"], e["source"]) for e in edges}:
|
||||
edges.append({
|
||||
"source": node["id"],
|
||||
"target": target_id,
|
||||
"relation": "wikilink"
|
||||
})
|
||||
break
|
||||
|
||||
|
||||
@app.get("/api/index/reload/{vault_name}")
|
||||
async def api_reload_vault(vault_name: str, current_user=Depends(require_admin)):
|
||||
"""Force a re-index of a single vault.
|
||||
|
||||
901
frontend/app.js
901
frontend/app.js
@ -769,7 +769,7 @@
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.trim() || "heading"
|
||||
@ -3723,6 +3723,7 @@
|
||||
renderConfigFilters();
|
||||
loadConfigFields();
|
||||
loadDiagnostics();
|
||||
loadAbout();
|
||||
await loadHiddenFilesSettings();
|
||||
safeCreateIcons();
|
||||
});
|
||||
@ -4017,6 +4018,70 @@
|
||||
});
|
||||
}
|
||||
|
||||
// --- About Section ---
|
||||
|
||||
function loadAbout() {
|
||||
const container = document.getElementById("config-about");
|
||||
if (!container) return;
|
||||
|
||||
// Fetch health info for version
|
||||
api("/api/health").then((health) => {
|
||||
container.innerHTML = "";
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: "Application",
|
||||
rows: [
|
||||
["Nom", "ObsiGate"],
|
||||
["Version", APP_VERSION],
|
||||
["Version API", health.version || "—"],
|
||||
["Statut", health.status || "—"],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Environnement",
|
||||
rows: [
|
||||
["Vaults configurés", health.vaults || "—"],
|
||||
["Fichiers indexés", health.total_files || "—"],
|
||||
["Navigateur", navigator.userAgent.split(" ").pop()],
|
||||
["Plateforme", navigator.platform || "—"],
|
||||
["Langue", navigator.language || "—"],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Composants",
|
||||
rows: [
|
||||
["Backend", "FastAPI (Python)"],
|
||||
["Rendu Markdown", "mistune"],
|
||||
["Surveillance fichiers", "watchdog"],
|
||||
["Frontend", "Vanilla JavaScript"],
|
||||
["Icônes", "Lucide Icons"],
|
||||
["Coloration syntaxe", "highlight.js"],
|
||||
["Éditeur", "CodeMirror 6"],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
sections.forEach((section) => {
|
||||
const div = document.createElement("div");
|
||||
div.className = "config-diag-section";
|
||||
const title = document.createElement("div");
|
||||
title.className = "config-diag-section-title";
|
||||
title.textContent = section.title;
|
||||
div.appendChild(title);
|
||||
section.rows.forEach(([label, value]) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "config-diag-row";
|
||||
row.innerHTML = `<span class="diag-label">${label}</span><span class="diag-value">${value}</span>`;
|
||||
div.appendChild(row);
|
||||
});
|
||||
container.appendChild(div);
|
||||
});
|
||||
}).catch(() => {
|
||||
container.innerHTML = '<div class="config-diag-loading">Erreur de chargement</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// --- Hidden Files Configuration ---
|
||||
|
||||
async function loadHiddenFilesSettings() {
|
||||
@ -5863,6 +5928,16 @@
|
||||
|
||||
this._menu.innerHTML = '';
|
||||
|
||||
// Copy path — available for all types
|
||||
const pathToCopy = type === 'vault' ? vault : `${vault}/${path}`;
|
||||
this._addItem('clipboard-copy', 'Copier le chemin', () => this._copyPath(pathToCopy), false);
|
||||
|
||||
// Graph view — available for all types
|
||||
const graphPath = type === 'vault' ? '' : path;
|
||||
this._addItem('git-graph', 'Vue Graphique', () => GraphViewManager.open(vault, graphPath, type), false);
|
||||
|
||||
this._addSeparator();
|
||||
|
||||
if (type === 'vault') {
|
||||
this._addItem('folder-plus', 'Nouveau dossier', () => this._createDirectory(), isReadonly);
|
||||
this._addItem('file-plus', 'Nouveau fichier', () => this._createFile(), isReadonly);
|
||||
@ -5951,6 +6026,14 @@
|
||||
|
||||
_deleteFile() {
|
||||
FileOperations.confirmDeleteFile(this._targetVault, this._targetPath);
|
||||
},
|
||||
|
||||
_copyPath(path) {
|
||||
navigator.clipboard.writeText(path).then(() => {
|
||||
showToast(`Chemin copié : ${path}`, 'success');
|
||||
}).catch(() => {
|
||||
showToast('Erreur lors de la copie', 'error');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -6989,4 +7072,820 @@
|
||||
init();
|
||||
registerServiceWorker();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab Manager — Multi-file tab support
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TabManager = {
|
||||
_tabs: [],
|
||||
_activeTabId: null,
|
||||
_tabCache: {}, // { tabId: { vault, path, title, data, rawSource, sourceView, scrollTop, icon } }
|
||||
_tabBar: null,
|
||||
_tabList: null,
|
||||
_dirtyTabs: new Set(),
|
||||
|
||||
init() {
|
||||
this._tabBar = document.getElementById("tab-bar");
|
||||
this._tabList = document.getElementById("tab-list");
|
||||
},
|
||||
|
||||
/** Open a file in a tab (or focus existing) */
|
||||
async open(vault, path, options = {}) {
|
||||
const tabId = `${vault}::${path}`;
|
||||
|
||||
// If already open, just focus it
|
||||
const existing = this._tabs.find(t => t.id === tabId);
|
||||
if (existing) {
|
||||
this.activate(tabId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new tab
|
||||
const name = path.split("/").pop().replace(/\.md$/i, "");
|
||||
const icon = getFileIcon(name + ".md");
|
||||
|
||||
this._tabs.push({ id: tabId, vault, path, name, icon });
|
||||
this._tabCache[tabId] = { vault, path, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon };
|
||||
|
||||
this._renderTabs();
|
||||
this.activate(tabId);
|
||||
},
|
||||
|
||||
/** Activate a specific tab */
|
||||
async activate(tabId) {
|
||||
if (this._activeTabId === tabId && this._tabs.length > 0) return;
|
||||
|
||||
// Save current tab state
|
||||
if (this._activeTabId && this._tabCache[this._activeTabId]) {
|
||||
this._saveCurrentTabState();
|
||||
}
|
||||
|
||||
this._activeTabId = tabId;
|
||||
this._renderTabs();
|
||||
|
||||
// Load tab content
|
||||
const cache = this._tabCache[tabId];
|
||||
if (!cache) return;
|
||||
|
||||
// Update global state
|
||||
currentVault = cache.vault;
|
||||
currentPath = cache.path;
|
||||
syncActiveFileTreeItem(cache.vault, cache.path);
|
||||
|
||||
const area = document.getElementById("content-area");
|
||||
|
||||
if (cache.data) {
|
||||
// Use cached data
|
||||
this._restoreTabContent(cache, area);
|
||||
} else {
|
||||
// Fetch file content
|
||||
area.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted)">Chargement...</div>';
|
||||
try {
|
||||
const data = await api(`/api/file/${encodeURIComponent(cache.vault)}?path=${encodeURIComponent(cache.path)}`);
|
||||
cache.data = data;
|
||||
cache.title = data.title;
|
||||
renderFile(cache.data);
|
||||
|
||||
// Restore source view if needed
|
||||
if (cache.sourceView) {
|
||||
await this._toggleSourceView(cache, area);
|
||||
}
|
||||
if (cache.scrollTop) {
|
||||
area.scrollTop = cache.scrollTop;
|
||||
}
|
||||
} catch (err) {
|
||||
area.innerHTML = `<div style="padding:40px;text-align:center;color:var(--text-error)">Erreur: ${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Update URL hash
|
||||
if (history.pushState) {
|
||||
history.pushState(null, "", `#/file/${encodeURIComponent(cache.vault)}/${encodeURIComponent(cache.path)}`);
|
||||
}
|
||||
|
||||
// Hide dashboard
|
||||
const dashboard = document.getElementById("dashboard-home");
|
||||
if (dashboard) dashboard.style.display = "none";
|
||||
},
|
||||
|
||||
/** Close a tab */
|
||||
close(tabId) {
|
||||
const idx = this._tabs.findIndex(t => t.id === tabId);
|
||||
if (idx === -1) return;
|
||||
|
||||
this._tabs.splice(idx, 1);
|
||||
delete this._tabCache[tabId];
|
||||
this._dirtyTabs.delete(tabId);
|
||||
|
||||
if (this._tabs.length === 0) {
|
||||
this._activeTabId = null;
|
||||
this._showDashboard();
|
||||
this._tabBar.hidden = true;
|
||||
} else if (this._activeTabId === tabId) {
|
||||
// Activate adjacent tab
|
||||
const newIdx = Math.min(idx, this._tabs.length - 1);
|
||||
this.activate(this._tabs[newIdx].id);
|
||||
}
|
||||
|
||||
this._renderTabs();
|
||||
},
|
||||
|
||||
/** Close all tabs */
|
||||
closeAll() {
|
||||
this._tabs = [];
|
||||
this._tabCache = {};
|
||||
this._dirtyTabs.clear();
|
||||
this._activeTabId = null;
|
||||
this._showDashboard();
|
||||
this._tabBar.hidden = true;
|
||||
},
|
||||
|
||||
/** Close tabs to the right */
|
||||
closeRight(tabId) {
|
||||
const idx = this._tabs.findIndex(t => t.id === tabId);
|
||||
if (idx === -1) return;
|
||||
const toClose = this._tabs.slice(idx + 1);
|
||||
for (const tab of toClose) {
|
||||
delete this._tabCache[tab.id];
|
||||
this._dirtyTabs.delete(tab.id);
|
||||
}
|
||||
this._tabs = this._tabs.slice(0, idx + 1);
|
||||
if (!this._tabs.find(t => t.id === this._activeTabId)) {
|
||||
this.activate(tabId);
|
||||
}
|
||||
this._renderTabs();
|
||||
},
|
||||
|
||||
/** Close other tabs */
|
||||
closeOthers(tabId) {
|
||||
const tab = this._tabs.find(t => t.id === tabId);
|
||||
if (!tab) return;
|
||||
for (const t of this._tabs) {
|
||||
if (t.id !== tabId) {
|
||||
delete this._tabCache[t.id];
|
||||
this._dirtyTabs.delete(t.id);
|
||||
}
|
||||
}
|
||||
this._tabs = [tab];
|
||||
this.activate(tabId);
|
||||
this._renderTabs();
|
||||
},
|
||||
|
||||
/** Reorder tabs by drag and drop */
|
||||
moveTab(fromIdx, toIdx) {
|
||||
if (fromIdx === toIdx || fromIdx < 0 || toIdx < 0) return;
|
||||
const tab = this._tabs.splice(fromIdx, 1)[0];
|
||||
this._tabs.splice(toIdx, 0, tab);
|
||||
this._renderTabs();
|
||||
},
|
||||
|
||||
/** Save current tab state before switching */
|
||||
_saveCurrentTabState() {
|
||||
const cache = this._tabCache[this._activeTabId];
|
||||
if (!cache) return;
|
||||
|
||||
const area = document.getElementById("content-area");
|
||||
const rendered = document.getElementById("file-rendered-content");
|
||||
|
||||
cache.scrollTop = area.scrollTop;
|
||||
cache.sourceView = rendered ? rendered.style.display === "none" : false;
|
||||
},
|
||||
|
||||
/** Restore tab content from cache */
|
||||
_restoreTabContent(cache, area) {
|
||||
renderFile(cache.data);
|
||||
if (cache.sourceView) {
|
||||
this._restoreSourceView(cache, area);
|
||||
}
|
||||
if (cache.scrollTop) {
|
||||
area.scrollTop = cache.scrollTop;
|
||||
}
|
||||
},
|
||||
|
||||
async _toggleSourceView(cache, area) {
|
||||
const rendered = document.getElementById("file-rendered-content");
|
||||
const raw = document.getElementById("file-raw-content");
|
||||
if (!rendered || !raw) return;
|
||||
|
||||
if (!cache.rawSource) {
|
||||
const rawData = await api(`/api/file/${encodeURIComponent(cache.vault)}/raw?path=${encodeURIComponent(cache.path)}`);
|
||||
cache.rawSource = rawData.raw;
|
||||
}
|
||||
raw.textContent = cache.rawSource;
|
||||
rendered.style.display = "none";
|
||||
raw.style.display = "block";
|
||||
},
|
||||
|
||||
_restoreSourceView(cache, area) {
|
||||
requestAnimationFrame(() => {
|
||||
const rendered = document.getElementById("file-rendered-content");
|
||||
const raw = document.getElementById("file-raw-content");
|
||||
if (rendered && raw && cache.rawSource) {
|
||||
raw.textContent = cache.rawSource;
|
||||
rendered.style.display = "none";
|
||||
raw.style.display = "block";
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_showDashboard() {
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = "";
|
||||
const dashboard = document.getElementById("dashboard-home");
|
||||
if (dashboard) {
|
||||
dashboard.style.display = "";
|
||||
area.appendChild(dashboard);
|
||||
}
|
||||
if (history.pushState) {
|
||||
history.pushState(null, "", "#");
|
||||
}
|
||||
},
|
||||
|
||||
/** Render the tab bar */
|
||||
_renderTabs() {
|
||||
if (!this._tabList) return;
|
||||
|
||||
this._tabList.innerHTML = "";
|
||||
|
||||
if (this._tabs.length === 0) {
|
||||
this._tabBar.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this._tabBar.hidden = false;
|
||||
|
||||
this._tabs.forEach((tab, idx) => {
|
||||
const el = document.createElement("div");
|
||||
el.className = "tab-item" + (tab.id === this._activeTabId ? " active" : "");
|
||||
el.draggable = true;
|
||||
el.dataset.tabId = tab.id;
|
||||
el.dataset.index = idx;
|
||||
|
||||
// Icon
|
||||
const iconEl = document.createElement("i");
|
||||
iconEl.setAttribute("data-lucide", tab.icon);
|
||||
iconEl.className = "tab-icon";
|
||||
iconEl.style.width = "14px";
|
||||
iconEl.style.height = "14px";
|
||||
el.appendChild(iconEl);
|
||||
|
||||
// Name
|
||||
const nameEl = document.createElement("span");
|
||||
nameEl.className = "tab-name";
|
||||
nameEl.textContent = tab.name;
|
||||
nameEl.title = `${tab.vault}/${tab.path}`;
|
||||
el.appendChild(nameEl);
|
||||
|
||||
// Close button
|
||||
const closeEl = document.createElement("span");
|
||||
closeEl.className = "tab-close";
|
||||
closeEl.innerHTML = '<i data-lucide="x" style="width:12px;height:12px"></i>';
|
||||
closeEl.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.close(tab.id);
|
||||
});
|
||||
el.appendChild(closeEl);
|
||||
|
||||
// Click to activate
|
||||
el.addEventListener("click", () => this.activate(tab.id));
|
||||
|
||||
// Double-click to close
|
||||
el.addEventListener("dblclick", (e) => {
|
||||
e.preventDefault();
|
||||
this.close(tab.id);
|
||||
});
|
||||
|
||||
// Middle-click to close
|
||||
el.addEventListener("mousedown", (e) => {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
this.close(tab.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Context menu on tab
|
||||
el.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
this._showTabContextMenu(e.clientX, e.clientY, tab.id);
|
||||
});
|
||||
|
||||
// Drag and drop
|
||||
el.addEventListener("dragstart", (e) => {
|
||||
e.dataTransfer.setData("text/plain", String(idx));
|
||||
el.classList.add("dragging");
|
||||
});
|
||||
el.addEventListener("dragend", () => {
|
||||
el.classList.remove("dragging");
|
||||
document.querySelectorAll(".tab-drop-indicator").forEach(d => d.remove());
|
||||
});
|
||||
el.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
const rect = el.getBoundingClientRect();
|
||||
const mid = rect.left + rect.width / 2;
|
||||
document.querySelectorAll(".tab-drop-indicator").forEach(d => d.remove());
|
||||
const indicator = document.createElement("div");
|
||||
indicator.className = "tab-drop-indicator";
|
||||
if (e.clientX < mid) {
|
||||
el.before(indicator);
|
||||
} else {
|
||||
el.after(indicator);
|
||||
}
|
||||
});
|
||||
el.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
document.querySelectorAll(".tab-drop-indicator").forEach(d => d.remove());
|
||||
const fromIdx = parseInt(e.dataTransfer.getData("text/plain"));
|
||||
const rect = el.getBoundingClientRect();
|
||||
const mid = rect.left + rect.width / 2;
|
||||
const toIdx = e.clientX < mid ? idx : idx + 1;
|
||||
if (fromIdx !== toIdx && fromIdx !== toIdx - 1) {
|
||||
this.moveTab(fromIdx, toIdx);
|
||||
}
|
||||
});
|
||||
|
||||
this._tabList.appendChild(el);
|
||||
});
|
||||
|
||||
safeCreateIcons();
|
||||
},
|
||||
|
||||
_showTabContextMenu(x, y, tabId) {
|
||||
const existing = document.getElementById("tab-context-menu");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const menu = document.createElement("div");
|
||||
menu.id = "tab-context-menu";
|
||||
menu.className = "context-menu active";
|
||||
menu.style.left = x + "px";
|
||||
menu.style.top = y + "px";
|
||||
menu.innerHTML = `
|
||||
<div class="context-menu-item" data-action="close"><i data-lucide="x" class="icon"></i><span>Fermer</span></div>
|
||||
<div class="context-menu-item" data-action="closeOthers"><i data-lucide="x-circle" class="icon"></i><span>Fermer les autres</span></div>
|
||||
<div class="context-menu-item" data-action="closeRight"><i data-lucide="arrow-right-circle" class="icon"></i><span>Fermer à droite</span></div>
|
||||
<div class="context-menu-separator"></div>
|
||||
<div class="context-menu-item" data-action="closeAll"><i data-lucide="trash-2" class="icon"></i><span>Fermer tout</span></div>
|
||||
`;
|
||||
document.body.appendChild(menu);
|
||||
safeCreateIcons();
|
||||
|
||||
menu.addEventListener("click", (e) => {
|
||||
const action = e.target.closest(".context-menu-item")?.dataset.action;
|
||||
if (action === "close") this.close(tabId);
|
||||
else if (action === "closeOthers") this.closeOthers(tabId);
|
||||
else if (action === "closeRight") this.closeRight(tabId);
|
||||
else if (action === "closeAll") this.closeAll();
|
||||
menu.remove();
|
||||
});
|
||||
|
||||
const closeMenu = () => menu.remove();
|
||||
document.addEventListener("click", closeMenu, { once: true });
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") { menu.remove(); } }, { once: true });
|
||||
},
|
||||
};
|
||||
|
||||
// ---- Modify openFile to use TabManager ----
|
||||
const _originalOpenFile = openFile;
|
||||
openFile = function(vault, path) {
|
||||
TabManager.open(vault, path);
|
||||
};
|
||||
|
||||
// ---- Keyboard shortcuts for tabs ----
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === "w" || e.key === "W") {
|
||||
e.preventDefault();
|
||||
if (TabManager._activeTabId) {
|
||||
TabManager.close(TabManager._activeTabId);
|
||||
}
|
||||
} else if (e.key === "Tab" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const tabs = TabManager._tabs;
|
||||
const currentIdx = tabs.findIndex(t => t.id === TabManager._activeTabId);
|
||||
if (currentIdx >= 0 && tabs.length > 1) {
|
||||
const nextIdx = (currentIdx + 1) % tabs.length;
|
||||
TabManager.activate(tabs[nextIdx].id);
|
||||
}
|
||||
} else if (e.key === "Tab" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const tabs = TabManager._tabs;
|
||||
const currentIdx = tabs.findIndex(t => t.id === TabManager._activeTabId);
|
||||
if (currentIdx >= 0 && tabs.length > 1) {
|
||||
const prevIdx = (currentIdx - 1 + tabs.length) % tabs.length;
|
||||
TabManager.activate(tabs[prevIdx].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Modify init to include TabManager ----
|
||||
const _origInit2 = init;
|
||||
init = function() {
|
||||
_origInit2();
|
||||
TabManager.init();
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph View Manager — Interactive file/folder relationship visualization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const GraphViewManager = {
|
||||
_canvas: null,
|
||||
_ctx: null,
|
||||
_nodes: [],
|
||||
_edges: [],
|
||||
_offsetX: 0,
|
||||
_offsetY: 0,
|
||||
_zoom: 1,
|
||||
_dragging: false,
|
||||
_dragNode: null,
|
||||
_panning: false,
|
||||
_panStartX: 0,
|
||||
_panStartY: 0,
|
||||
_animFrame: null,
|
||||
_vault: null,
|
||||
_path: null,
|
||||
_nodePositions: {},
|
||||
_width: 0,
|
||||
_height: 0,
|
||||
|
||||
async open(vault, path, type) {
|
||||
this._vault = vault;
|
||||
this._path = path;
|
||||
|
||||
const modal = document.getElementById("graph-modal");
|
||||
const title = document.getElementById("graph-title");
|
||||
const info = document.getElementById("graph-info");
|
||||
const canvas = document.getElementById("graph-canvas");
|
||||
|
||||
if (!modal || !canvas) return;
|
||||
|
||||
title.textContent = `Vue Graphique — ${vault}${path ? "/" + path : ""}`;
|
||||
info.textContent = "Chargement...";
|
||||
modal.classList.add("active");
|
||||
|
||||
this._canvas = canvas;
|
||||
this._ctx = canvas.getContext("2d");
|
||||
this._resetView();
|
||||
|
||||
// Fetch graph data
|
||||
try {
|
||||
const data = await api(`/api/graph/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}&depth=1`);
|
||||
this._nodes = data.nodes || [];
|
||||
this._edges = data.edges || [];
|
||||
info.textContent = `${this._nodes.length} nœuds, ${this._edges.length} liens`;
|
||||
this._initLayout();
|
||||
this._startRender();
|
||||
} catch (err) {
|
||||
info.textContent = "Erreur de chargement";
|
||||
console.error("Graph error:", err);
|
||||
}
|
||||
|
||||
safeCreateIcons();
|
||||
},
|
||||
|
||||
close() {
|
||||
const modal = document.getElementById("graph-modal");
|
||||
if (modal) modal.classList.remove("active");
|
||||
if (this._animFrame) {
|
||||
cancelAnimationFrame(this._animFrame);
|
||||
this._animFrame = null;
|
||||
}
|
||||
},
|
||||
|
||||
_resetView() {
|
||||
this._offsetX = 0;
|
||||
this._offsetY = 0;
|
||||
this._zoom = 1;
|
||||
this._nodePositions = {};
|
||||
},
|
||||
|
||||
_initLayout() {
|
||||
const w = this._canvas.parentElement.clientWidth;
|
||||
const h = this._canvas.parentElement.clientHeight;
|
||||
this._canvas.width = w * devicePixelRatio;
|
||||
this._canvas.height = h * devicePixelRatio;
|
||||
this._canvas.style.width = w + "px";
|
||||
this._canvas.style.height = h + "px";
|
||||
this._width = w;
|
||||
this._height = h;
|
||||
this._ctx.scale(devicePixelRatio, devicePixelRatio);
|
||||
|
||||
// Position nodes in a circle initially
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const radius = Math.min(w, h) * 0.35;
|
||||
|
||||
this._nodes.forEach((node, i) => {
|
||||
const angle = (2 * Math.PI * i) / this._nodes.length;
|
||||
this._nodePositions[node.id] = {
|
||||
x: cx + radius * Math.cos(angle),
|
||||
y: cy + radius * Math.sin(angle),
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
_startRender() {
|
||||
const self = this;
|
||||
let lastTime = 0;
|
||||
|
||||
const loop = (time) => {
|
||||
const dt = Math.min((time - lastTime) / 1000, 0.1);
|
||||
lastTime = time;
|
||||
self._simulate(dt);
|
||||
self._draw();
|
||||
self._animFrame = requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
this._animFrame = requestAnimationFrame(loop);
|
||||
},
|
||||
|
||||
_simulate(dt) {
|
||||
if (this._dragging || this._dragNode) return;
|
||||
|
||||
const positions = this._nodePositions;
|
||||
const cx = this._width / 2;
|
||||
const cy = this._height / 2;
|
||||
|
||||
// Spring forces (edges)
|
||||
for (const edge of this._edges) {
|
||||
const a = positions[edge.source];
|
||||
const b = positions[edge.target];
|
||||
if (!a || !b) continue;
|
||||
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const targetLen = 80;
|
||||
const force = (dist - targetLen) * 0.01;
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
|
||||
a.vx += fx;
|
||||
a.vy += fy;
|
||||
b.vx -= fx;
|
||||
b.vy -= fy;
|
||||
}
|
||||
|
||||
// Repulsion between all nodes
|
||||
for (const n1 of this._nodes) {
|
||||
for (const n2 of this._nodes) {
|
||||
if (n1.id === n2.id) continue;
|
||||
const a = positions[n1.id];
|
||||
const b = positions[n2.id];
|
||||
if (!a || !b) continue;
|
||||
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const force = 2000 / (dist * dist);
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
|
||||
a.vx -= fx;
|
||||
a.vy -= fy;
|
||||
}
|
||||
}
|
||||
|
||||
// Center gravity
|
||||
for (const node of this._nodes) {
|
||||
const p = positions[node.id];
|
||||
if (!p) continue;
|
||||
p.vx += (cx - p.x) * 0.001;
|
||||
p.vy += (cy - p.y) * 0.001;
|
||||
}
|
||||
|
||||
// Apply velocities with damping
|
||||
for (const node of this._nodes) {
|
||||
const p = positions[node.id];
|
||||
if (!p) continue;
|
||||
p.vx *= 0.9;
|
||||
p.vy *= 0.9;
|
||||
p.x += p.vx * dt * 60;
|
||||
p.y += p.vy * dt * 60;
|
||||
}
|
||||
},
|
||||
|
||||
_draw() {
|
||||
const ctx = this._ctx;
|
||||
const w = this._width;
|
||||
const h = this._height;
|
||||
|
||||
ctx.save();
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Apply transform (pan + zoom)
|
||||
ctx.translate(this._offsetX, this._offsetY);
|
||||
ctx.scale(this._zoom, this._zoom);
|
||||
|
||||
// Draw edges
|
||||
for (const edge of this._edges) {
|
||||
const a = this._nodePositions[edge.source];
|
||||
const b = this._nodePositions[edge.target];
|
||||
if (!a || !b) continue;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.strokeStyle = edge.relation === "wikilink" ? "var(--accent-color, #2563eb)" : "var(--text-muted, #888)";
|
||||
ctx.lineWidth = edge.relation === "wikilink" ? 2 : 1;
|
||||
ctx.setLineDash(edge.relation === "wikilink" ? [4, 4] : []);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Draw nodes
|
||||
for (const node of this._nodes) {
|
||||
const p = this._nodePositions[node.id];
|
||||
if (!p) continue;
|
||||
|
||||
const r = Math.max(5, Math.min(20, 6 + Math.sqrt(node.size || 100) / 100));
|
||||
|
||||
// Node circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
|
||||
|
||||
switch (node.type) {
|
||||
case "directory":
|
||||
ctx.fillStyle = "#5b9bd5";
|
||||
break;
|
||||
case "file":
|
||||
ctx.fillStyle = (node.path || "").endsWith(".md") ? "#70ad47" : "#c0c0c0";
|
||||
break;
|
||||
default:
|
||||
ctx.fillStyle = "#ffc000";
|
||||
break;
|
||||
}
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = "var(--bg-primary, #1e1e1e)";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// Node label
|
||||
const label = node.name.length > 20 ? node.name.slice(0, 18) + "..." : node.name;
|
||||
ctx.font = `${11 / this._zoom}px -apple-system, sans-serif`;
|
||||
ctx.fillStyle = "var(--text-primary, #ddd)";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(label, p.x, p.y + r + 12 / this._zoom);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
_getNodeAt(screenX, screenY) {
|
||||
const x = (screenX - this._offsetX) / this._zoom;
|
||||
const y = (screenY - this._offsetY) / this._zoom;
|
||||
|
||||
for (const node of this._nodes) {
|
||||
const p = this._nodePositions[node.id];
|
||||
if (!p) continue;
|
||||
const r = Math.max(5, Math.min(20, 6 + Math.sqrt(node.size || 100) / 100));
|
||||
const dx = x - p.x;
|
||||
const dy = y - p.y;
|
||||
if (dx * dx + dy * dy <= r * r + 100) {
|
||||
return { node, pos: p };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
_onMouseDown(e) {
|
||||
const rect = this._canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
|
||||
const hit = this._getNodeAt(mx, my);
|
||||
if (hit) {
|
||||
this._dragging = true;
|
||||
this._dragNode = hit;
|
||||
this._canvas.style.cursor = "grabbing";
|
||||
} else {
|
||||
this._panning = true;
|
||||
this._panStartX = e.clientX - this._offsetX;
|
||||
this._panStartY = e.clientY - this._offsetY;
|
||||
this._canvas.style.cursor = "grabbing";
|
||||
}
|
||||
},
|
||||
|
||||
_onMouseMove(e) {
|
||||
const rect = this._canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
|
||||
if (this._dragging && this._dragNode) {
|
||||
this._dragNode.pos.x = (mx - this._offsetX) / this._zoom;
|
||||
this._dragNode.pos.y = (my - this._offsetY) / this._zoom;
|
||||
this._dragNode.pos.vx = 0;
|
||||
this._dragNode.pos.vy = 0;
|
||||
} else if (this._panning) {
|
||||
this._offsetX = e.clientX - this._panStartX;
|
||||
this._offsetY = e.clientY - this._panStartY;
|
||||
} else {
|
||||
const hit = this._getNodeAt(mx, my);
|
||||
this._canvas.style.cursor = hit ? "pointer" : "grab";
|
||||
this._canvas.title = hit ? `Ouvrir: ${hit.node.path || hit.node.name}` : "";
|
||||
}
|
||||
},
|
||||
|
||||
_onMouseUp(e) {
|
||||
if (this._dragging && this._dragNode) {
|
||||
// Check if it was a click (not a drag)
|
||||
const rect = this._canvas.getBoundingClientRect();
|
||||
const node = this._dragNode.node;
|
||||
this._dragging = false;
|
||||
this._dragNode = null;
|
||||
this._canvas.style.cursor = "grab";
|
||||
|
||||
// If it's a file, open it on click
|
||||
if (node.type === "file") {
|
||||
this.close();
|
||||
openFile(this._vault, node.path);
|
||||
} else if (node.type === "directory" || node.type === "vault") {
|
||||
// Expand into this directory
|
||||
this.close();
|
||||
this.open(this._vault, node.path, node.type);
|
||||
}
|
||||
}
|
||||
this._panning = false;
|
||||
},
|
||||
|
||||
_onWheel(e) {
|
||||
e.preventDefault();
|
||||
const rect = this._canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
|
||||
const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9;
|
||||
const newZoom = Math.max(0.2, Math.min(3, this._zoom * zoomFactor));
|
||||
|
||||
this._offsetX = mx - (mx - this._offsetX) * (newZoom / this._zoom);
|
||||
this._offsetY = my - (my - this._offsetY) * (newZoom / this._zoom);
|
||||
this._zoom = newZoom;
|
||||
},
|
||||
|
||||
_onResize() {
|
||||
if (!this._canvas || !this._nodes.length) return;
|
||||
const w = this._canvas.parentElement.clientWidth;
|
||||
const h = this._canvas.parentElement.clientHeight;
|
||||
this._canvas.width = w * devicePixelRatio;
|
||||
this._canvas.height = h * devicePixelRatio;
|
||||
this._canvas.style.width = w + "px";
|
||||
this._canvas.style.height = h + "px";
|
||||
this._width = w;
|
||||
this._height = h;
|
||||
this._ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
this._ctx.scale(devicePixelRatio, devicePixelRatio);
|
||||
},
|
||||
};
|
||||
|
||||
// Init graph view event listeners after DOM ready
|
||||
function initGraphView() {
|
||||
const closeBtn = document.getElementById("graph-close");
|
||||
const zoomIn = document.getElementById("graph-zoom-in");
|
||||
const zoomOut = document.getElementById("graph-zoom-out");
|
||||
const reset = document.getElementById("graph-reset");
|
||||
const modal = document.getElementById("graph-modal");
|
||||
const canvas = document.getElementById("graph-canvas");
|
||||
|
||||
if (closeBtn) closeBtn.addEventListener("click", () => GraphViewManager.close());
|
||||
if (modal) modal.addEventListener("click", (e) => { if (e.target === modal) GraphViewManager.close(); });
|
||||
if (zoomIn) zoomIn.addEventListener("click", () => { GraphViewManager._zoom = Math.min(3, GraphViewManager._zoom * 1.2); });
|
||||
if (zoomOut) zoomOut.addEventListener("click", () => { GraphViewManager._zoom = Math.max(0.2, GraphViewManager._zoom * 0.8); });
|
||||
if (reset) reset.addEventListener("click", () => {
|
||||
GraphViewManager._offsetX = 0;
|
||||
GraphViewManager._offsetY = 0;
|
||||
GraphViewManager._zoom = 1;
|
||||
});
|
||||
|
||||
if (canvas) {
|
||||
canvas.addEventListener("mousedown", (e) => GraphViewManager._onMouseDown(e));
|
||||
canvas.addEventListener("mousemove", (e) => GraphViewManager._onMouseMove(e));
|
||||
canvas.addEventListener("mouseup", (e) => GraphViewManager._onMouseUp(e));
|
||||
canvas.addEventListener("mouseleave", () => {
|
||||
GraphViewManager._dragging = false;
|
||||
GraphViewManager._dragNode = null;
|
||||
GraphViewManager._panning = false;
|
||||
canvas.style.cursor = "grab";
|
||||
});
|
||||
canvas.addEventListener("wheel", (e) => GraphViewManager._onWheel(e), { passive: false });
|
||||
window.addEventListener("resize", () => GraphViewManager._onResize());
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (modal && modal.classList.contains("active") && e.key === "Escape") {
|
||||
GraphViewManager.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add to init
|
||||
const _origInit = init;
|
||||
init = function() {
|
||||
_origInit();
|
||||
initGraphView();
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
@ -348,6 +348,11 @@
|
||||
<!-- Sidebar resize handle -->
|
||||
<div class="sidebar-resize-handle" id="sidebar-resize-handle"></div>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div class="tab-bar" id="tab-bar" hidden>
|
||||
<div class="tab-list" id="tab-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<main class="content-area" id="content-area" aria-label="Contenu principal">
|
||||
<div id="dashboard-home" class="dashboard-home" role="region" aria-label="Tableau de bord">
|
||||
@ -620,6 +625,48 @@
|
||||
<button class="config-btn-secondary" id="cfg-refresh-diag" style="margin-top:8px">Rafraîchir</button>
|
||||
</section>
|
||||
|
||||
<!-- À propos -->
|
||||
<section class="config-section">
|
||||
<h2>📦 À propos</h2>
|
||||
<div id="config-about" class="config-diagnostics">
|
||||
<div class="config-diag-loading">Chargement...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Graph View Modal -->
|
||||
<div class="editor-modal" id="graph-modal">
|
||||
<div class="editor-container" style="max-width:90vw;width:900px;height:80vh;display:flex;flex-direction:column;">
|
||||
<div class="editor-header">
|
||||
<div class="editor-title" id="graph-title">Vue Graphique</div>
|
||||
<div class="editor-actions">
|
||||
<span id="graph-info" style="font-size:0.75rem;color:var(--text-muted);margin-right:12px"></span>
|
||||
<button class="editor-btn" id="graph-zoom-in" title="Zoom avant" aria-label="Zoom avant">
|
||||
<i data-lucide="zoom-in" style="width:16px;height:16px"></i>
|
||||
</button>
|
||||
<button class="editor-btn" id="graph-zoom-out" title="Zoom arrière" aria-label="Zoom arrière">
|
||||
<i data-lucide="zoom-out" style="width:16px;height:16px"></i>
|
||||
</button>
|
||||
<button class="editor-btn" id="graph-reset" title="Réinitialiser la vue" aria-label="Réinitialiser">
|
||||
<i data-lucide="maximize" style="width:16px;height:16px"></i>
|
||||
</button>
|
||||
<button class="editor-btn" id="graph-close" title="Fermer" aria-label="Fermer">
|
||||
<i data-lucide="x" style="width:16px;height:16px"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-body" style="flex:1;overflow:hidden;position:relative;padding:0">
|
||||
<canvas id="graph-canvas" style="width:100%;height:100%;cursor:grab"></canvas>
|
||||
<div id="graph-legend" style="position:absolute;bottom:12px;left:12px;display:flex;gap:16px;font-size:0.7rem;color:var(--text-muted);background:var(--bg-primary);padding:6px 12px;border-radius:6px;border:1px solid var(--border-color)">
|
||||
<span>🟦 Dossier</span>
|
||||
<span>🟩 Fichier .md</span>
|
||||
<span>⬜ Autre fichier</span>
|
||||
<span style="color:var(--text-muted)">── Parent</span>
|
||||
<span style="color:var(--accent-color,#2563eb)">┅┅ Wikilink</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -174,6 +174,57 @@
|
||||
|
||||
function safeCreateIcons() { if (window.lucide) window.lucide.createIcons(); }
|
||||
|
||||
// ---- Login form for popout auth redirect ----
|
||||
|
||||
function showLoginForm(vault, path) {
|
||||
const loading = document.getElementById('loading');
|
||||
loading.innerHTML = `
|
||||
<div class="popout-login-card" style="text-align:center;max-width:360px;padding:24px;background:var(--bg-secondary);border-radius:8px;box-shadow:0 4px 12px var(--shadow-color)">
|
||||
<div style="font-size:2rem;margin-bottom:8px">🔐</div>
|
||||
<h3 style="margin:0 0 4px;color:var(--text-primary)">Authentification requise</h3>
|
||||
<p style="margin:0 0 16px;color:var(--text-muted);font-size:0.85rem">Connectez-vous pour accéder à ce fichier</p>
|
||||
<form id="popout-login-form" style="display:flex;flex-direction:column;gap:10px">
|
||||
<input type="text" id="popout-username" placeholder="Nom d'utilisateur" required autocomplete="username"
|
||||
style="padding:10px;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary);font-size:0.9rem">
|
||||
<input type="password" id="popout-password" placeholder="Mot de passe" required autocomplete="current-password"
|
||||
style="padding:10px;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary);font-size:0.9rem">
|
||||
<div id="popout-login-error" style="color:var(--text-error,#e74c3c);font-size:0.8rem;display:none"></div>
|
||||
<button type="submit"
|
||||
style="padding:10px;background:var(--accent-color,#2563eb);color:white;border:none;border-radius:6px;cursor:pointer;font-size:0.9rem">Se connecter</button>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('popout-login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const username = document.getElementById('popout-username').value;
|
||||
const password = document.getElementById('popout-password').value;
|
||||
const errorEl = document.getElementById('popout-login-error');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
errorEl.textContent = err.detail || 'Identifiants invalides';
|
||||
errorEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
// Login successful — reload the page to try loading the file again
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
errorEl.textContent = 'Erreur réseau';
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Outline / TOC Manager ----
|
||||
|
||||
const OutlineManager = {
|
||||
/**
|
||||
* Slugify text to create valid IDs
|
||||
@ -183,7 +234,7 @@
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.trim() || 'heading';
|
||||
@ -598,6 +649,12 @@ const RightSidebarManager = {
|
||||
const response = await fetch(`/api/file/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
showLoginForm(vault, path);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) throw new Error("Erreur lors du chargement (Essayez de vous reconnecter sur le site principal)");
|
||||
|
||||
const data = await response.json();
|
||||
@ -772,7 +829,12 @@ const RightSidebarManager = {
|
||||
}, 300);
|
||||
|
||||
} catch (err) {
|
||||
document.getElementById('loading').textContent = err.message;
|
||||
// Check if this was an auth error from a later fetch
|
||||
if (err.message && err.message.includes('401')) {
|
||||
showLoginForm(vault, path);
|
||||
} else {
|
||||
document.getElementById('loading').textContent = err.message || 'Erreur de chargement';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -5365,3 +5365,107 @@ body.popup-mode .content-area {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ===== TAB BAR ===== */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.tab-bar[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-list {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
flex: 1;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.tab-list::-webkit-scrollbar {
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
border-right: 1px solid var(--border);
|
||||
background: transparent;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-item:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.tab-item .tab-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tab-item .tab-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.tab-item .tab-close {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.tab-item:hover .tab-close,
|
||||
.tab-item.active .tab-close {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tab-item .tab-close:hover {
|
||||
opacity: 1;
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.tab-item.dragging {
|
||||
opacity: 0.5;
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.tab-drop-indicator {
|
||||
width: 2px;
|
||||
background: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user