A15 — le rendu tableur lit les metadonnees du classeur (une charge en mode
normal par feuille) et les restitue : styles inline data-driven (couleur de
police en premier, fond, gras/italique, format numerique signale en mono),
alignements non-defaut, plages fusionnees (rowSpan/colSpan cote client) et
ancrage des volets figes (sticky rows/cols). Le chargement lazy replie les
metadonnees de chaque fenetre. Contrat API : styles/aligns = {ref: css},
merges = [ranges], freeze = ancre. 9 tests backend + contre-preuve (5 echecs
sur neutralisation), JSDOM 33/33.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
3153 lines
122 KiB
JavaScript
3153 lines
122 KiB
JavaScript
/* ObsiGate — Viewer module */
|
||
import { api, AuthManager } from './auth.js';
|
||
import { state } from './state.js';
|
||
import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon, openEditor, copyToClipboard, activateInlineEditor, detachInlineEditor } from './utils.js';
|
||
import { isInlineEditorActive, queryEditor } from './editor-inline.js';
|
||
import { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard } from './ui.js';
|
||
import { syncActiveFileTreeItem, searchByTag, TagFilterService, refreshSidebarTreePreservingState, focusPathInSidebar } from './sidebar.js';
|
||
import { AutocompleteDropdown, performAdvancedSearch } from './search.js';
|
||
import { initDashboardTabs } from './sync.js';
|
||
import { renderMermaidBlocks } from './mermaid-viewer.js';
|
||
import { renderExcalidraw } from './excalidraw-viewer.js';
|
||
import { DashboardStatsWidget, DashboardRecentWidget, DashboardBookmarkWidget, DashboardSharedWidget, DashboardConflictsWidget } from './dashboard.js';
|
||
import { openShareDialog } from './config.js';
|
||
import { cacheViewedFile, getCachedFile } from './offline.js';
|
||
import { t } from './i18n.js';
|
||
import { onFileRender } from './plugins.js';
|
||
import { NowPlaying } from './now-playing.js';
|
||
|
||
// ── Multi-format export ────────────────────────────────────────────────────
|
||
// Downloads a file export (HTML / MD bundle / ePub) via the authenticated
|
||
// export endpoints, showing a loading toast while the request is in flight.
|
||
async function downloadExport(vault, path, format, fallbackName) {
|
||
showToast(t("viewer.export_start"), "info");
|
||
const url = `/api/export/${format}?vault=${encodeURIComponent(vault)}&path=${encodeURIComponent(path)}`;
|
||
try {
|
||
const headers = AuthManager.getAuthHeaders() || {};
|
||
const res = await fetch(url, { credentials: "include", headers });
|
||
if (!res.ok) {
|
||
let detail = "";
|
||
try { detail = (await res.json()).detail || ""; } catch (_) { /* ignore */ }
|
||
throw new Error(detail || "HTTP " + res.status);
|
||
}
|
||
const blob = await res.blob();
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = fallbackName;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||
showToast(t("viewer.export_done"), "success");
|
||
} catch (err) {
|
||
console.error("Export error:", err);
|
||
showToast(t("viewer.export_error") + " " + err.message, "error");
|
||
}
|
||
}
|
||
|
||
// Close any open export dropdown when clicking elsewhere.
|
||
document.addEventListener("click", function (e) {
|
||
if (e.target && e.target.closest && e.target.closest(".export-dropdown")) return;
|
||
document.querySelectorAll(".export-menu").forEach(function (m) { m.style.display = "none"; });
|
||
});
|
||
|
||
// Pane-aware content area helper — delegates to PaneManager when split
|
||
function getContentArea() {
|
||
// Override set by PaneTabManager._renderFileInPane for cross-module rendering
|
||
if (window._activePaneContentArea) return window._activePaneContentArea;
|
||
if (window.PaneManager && window.PaneManager.isSplit() && window.getActiveContentArea) {
|
||
return window.getActiveContentArea();
|
||
}
|
||
return document.getElementById('content-area');
|
||
}
|
||
// ---------------------------------------------------------------------------
|
||
// Outline/TOC Manager
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const OutlineManager = {
|
||
/**
|
||
* Slugify text to create valid IDs
|
||
*/
|
||
slugify(text) {
|
||
return (
|
||
text
|
||
.toLowerCase()
|
||
.normalize("NFD")
|
||
.replace(/[\u0300-\u036f]/g, "")
|
||
.replace(/[^\p{L}\p{N}\s-]/gu, "")
|
||
.replace(/\s+/g, "-")
|
||
.replace(/-+/g, "-")
|
||
.trim() || "heading"
|
||
);
|
||
},
|
||
|
||
/**
|
||
* Parse headings from markdown content
|
||
*/
|
||
parseHeadings() {
|
||
const contentArea = document.querySelector(".md-content");
|
||
if (!contentArea) return [];
|
||
|
||
const headings = [];
|
||
const h2s = contentArea.querySelectorAll("h2");
|
||
const h3s = contentArea.querySelectorAll("h3");
|
||
const allHeadings = [...h2s, ...h3s].sort((a, b) => {
|
||
return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
|
||
});
|
||
|
||
const usedIds = new Map();
|
||
|
||
allHeadings.forEach((heading) => {
|
||
const text = heading.textContent.trim();
|
||
if (!text) return;
|
||
|
||
const level = parseInt(heading.tagName[1]);
|
||
let id = this.slugify(text);
|
||
|
||
// Handle duplicate IDs
|
||
if (usedIds.has(id)) {
|
||
const count = usedIds.get(id) + 1;
|
||
usedIds.set(id, count);
|
||
id = `${id}-${count}`;
|
||
} else {
|
||
usedIds.set(id, 1);
|
||
}
|
||
|
||
// Inject ID into heading if not present
|
||
if (!heading.id) {
|
||
heading.id = id;
|
||
} else {
|
||
id = heading.id;
|
||
}
|
||
|
||
headings.push({
|
||
id,
|
||
level,
|
||
text,
|
||
element: heading,
|
||
});
|
||
});
|
||
|
||
return headings;
|
||
},
|
||
|
||
/**
|
||
* Render outline list
|
||
*/
|
||
renderOutline(headings) {
|
||
const outlineList = document.getElementById("outline-list");
|
||
const outlineEmpty = document.getElementById("outline-empty");
|
||
|
||
if (!outlineList) return;
|
||
|
||
outlineList.innerHTML = "";
|
||
|
||
if (!headings || headings.length === 0) {
|
||
outlineList.hidden = true;
|
||
if (outlineEmpty) {
|
||
outlineEmpty.hidden = false;
|
||
safeCreateIcons();
|
||
}
|
||
return;
|
||
}
|
||
|
||
outlineList.hidden = false;
|
||
if (outlineEmpty) outlineEmpty.hidden = true;
|
||
|
||
headings.forEach((heading) => {
|
||
const item = el(
|
||
"a",
|
||
{
|
||
class: `outline-item level-${heading.level}`,
|
||
href: `#${heading.id}`,
|
||
"data-heading-id": heading.id,
|
||
role: "link",
|
||
},
|
||
[document.createTextNode(heading.text)],
|
||
);
|
||
|
||
item.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
this.scrollToHeading(heading.id);
|
||
});
|
||
|
||
outlineList.appendChild(item);
|
||
});
|
||
|
||
state.headingsCache = headings;
|
||
},
|
||
|
||
/**
|
||
* Scroll to heading with smooth behavior
|
||
*/
|
||
scrollToHeading(headingId) {
|
||
const heading = document.getElementById(headingId);
|
||
if (!heading) return;
|
||
|
||
const contentArea = getContentArea();
|
||
if (!contentArea) return;
|
||
|
||
// Calculate offset for fixed header (if any)
|
||
const headerHeight = 80;
|
||
const headingTop = heading.offsetTop;
|
||
|
||
contentArea.scrollTo({
|
||
top: headingTop - headerHeight,
|
||
behavior: "smooth",
|
||
});
|
||
|
||
// Update active state immediately
|
||
this.setActiveHeading(headingId);
|
||
},
|
||
|
||
/**
|
||
* Set active heading in outline
|
||
*/
|
||
setActiveHeading(headingId) {
|
||
if (state.activeHeadingId === headingId) return;
|
||
|
||
state.activeHeadingId = headingId;
|
||
|
||
const items = document.querySelectorAll(".outline-item");
|
||
items.forEach((item) => {
|
||
if (item.getAttribute("data-heading-id") === headingId) {
|
||
item.classList.add("active");
|
||
item.setAttribute("aria-current", "location");
|
||
// Scroll outline item into view
|
||
item.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||
} else {
|
||
item.classList.remove("active");
|
||
item.removeAttribute("aria-current");
|
||
}
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Initialize outline for current document
|
||
*/
|
||
init() {
|
||
const headings = this.parseHeadings();
|
||
this.renderOutline(headings);
|
||
ScrollSpyManager.init(headings);
|
||
ReadingProgressManager.init();
|
||
},
|
||
|
||
/**
|
||
* Cleanup
|
||
*/
|
||
destroy() {
|
||
ScrollSpyManager.destroy();
|
||
ReadingProgressManager.destroy();
|
||
state.headingsCache = [];
|
||
state.activeHeadingId = null;
|
||
},
|
||
};
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Scroll Spy Manager
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const ScrollSpyManager = {
|
||
observer: null,
|
||
headings: [],
|
||
|
||
init(headings) {
|
||
this.destroy();
|
||
this.headings = headings;
|
||
|
||
if (!headings || headings.length === 0) return;
|
||
|
||
const contentArea = getContentArea();
|
||
if (!contentArea) return;
|
||
|
||
const options = {
|
||
root: contentArea,
|
||
rootMargin: "-20% 0px -70% 0px",
|
||
threshold: [0, 0.3, 0.5, 1.0],
|
||
};
|
||
|
||
this.observer = new IntersectionObserver((entries) => {
|
||
// Find the most visible heading
|
||
let mostVisible = null;
|
||
let maxRatio = 0;
|
||
|
||
entries.forEach((entry) => {
|
||
if (entry.isIntersecting && entry.intersectionRatio > maxRatio) {
|
||
maxRatio = entry.intersectionRatio;
|
||
mostVisible = entry.target;
|
||
}
|
||
});
|
||
|
||
if (mostVisible && mostVisible.id) {
|
||
OutlineManager.setActiveHeading(mostVisible.id);
|
||
}
|
||
}, options);
|
||
|
||
// Observe all headings
|
||
headings.forEach((heading) => {
|
||
if (heading.element) {
|
||
this.observer.observe(heading.element);
|
||
}
|
||
});
|
||
},
|
||
|
||
destroy() {
|
||
if (this.observer) {
|
||
this.observer.disconnect();
|
||
this.observer = null;
|
||
}
|
||
this.headings = [];
|
||
},
|
||
};
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Reading Progress Manager
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const ReadingProgressManager = {
|
||
scrollHandler: null,
|
||
|
||
init() {
|
||
this.destroy();
|
||
|
||
const contentArea = getContentArea();
|
||
if (!contentArea) return;
|
||
|
||
this.scrollHandler = this.throttle(() => {
|
||
this.updateProgress();
|
||
}, 100);
|
||
|
||
contentArea.addEventListener("scroll", this.scrollHandler);
|
||
this.updateProgress();
|
||
},
|
||
|
||
updateProgress() {
|
||
const contentArea = getContentArea();
|
||
const progressFill = document.getElementById("reading-progress-fill");
|
||
const progressText = document.getElementById("reading-progress-text");
|
||
|
||
if (!contentArea || !progressFill || !progressText) return;
|
||
|
||
const scrollTop = contentArea.scrollTop;
|
||
const scrollHeight = contentArea.scrollHeight;
|
||
const clientHeight = contentArea.clientHeight;
|
||
|
||
const maxScroll = scrollHeight - clientHeight;
|
||
const percentage = maxScroll > 0 ? Math.round((scrollTop / maxScroll) * 100) : 0;
|
||
|
||
progressFill.style.width = `${percentage}%`;
|
||
progressText.textContent = `${percentage}%`;
|
||
},
|
||
|
||
throttle(func, delay) {
|
||
let lastCall = 0;
|
||
return function (...args) {
|
||
const now = Date.now();
|
||
if (now - lastCall >= delay) {
|
||
lastCall = now;
|
||
func.apply(this, args);
|
||
}
|
||
};
|
||
},
|
||
|
||
destroy() {
|
||
const contentArea = getContentArea();
|
||
if (contentArea && this.scrollHandler) {
|
||
contentArea.removeEventListener("scroll", this.scrollHandler);
|
||
}
|
||
this.scrollHandler = null;
|
||
|
||
// Reset progress
|
||
const progressFill = document.getElementById("reading-progress-fill");
|
||
const progressText = document.getElementById("reading-progress-text");
|
||
if (progressFill) progressFill.style.width = "0%";
|
||
if (progressText) progressText.textContent = "0%";
|
||
},
|
||
};
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// File viewer
|
||
// ---------------------------------------------------------------------------
|
||
export async function openFile(vaultName, filePath) {
|
||
state.currentVault = vaultName;
|
||
state.currentPath = filePath;
|
||
state.showingSource = false;
|
||
state.cachedRawSource = null;
|
||
|
||
// Highlight active
|
||
syncActiveFileTreeItem(vaultName, filePath);
|
||
|
||
// Show loading state while fetching
|
||
const area = getContentArea();
|
||
area.innerHTML = '<div class="loading-indicator"><div class="loading-spinner"></div><div>Chargement...</div></div>';
|
||
|
||
try {
|
||
const url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`;
|
||
const data = await api(url);
|
||
// Cache the file for offline use
|
||
if (data && !data.unsupported && data.content) {
|
||
cacheViewedFile(vaultName, filePath, data.content, data.modified);
|
||
}
|
||
renderFile(data);
|
||
} catch (err) {
|
||
// Try IndexedDB fallback
|
||
console.log('[Viewer] Network failed, trying offline cache:', err);
|
||
const cached = await getCachedFile(vaultName, filePath);
|
||
if (cached && cached.content) {
|
||
const fakeData = {
|
||
vault: vaultName,
|
||
path: filePath,
|
||
title: filePath.split('/').pop().replace(/\.md$/, ''),
|
||
content: cached.content,
|
||
html: null,
|
||
raw_size: cached.content.length,
|
||
modified: cached.modified || '',
|
||
tags: [],
|
||
offline: true
|
||
};
|
||
renderFile(fakeData);
|
||
showToast(t('viewer.loaded_from_cache'), 'info');
|
||
} else {
|
||
area.innerHTML = '<div class="welcome"><p style="color:var(--text-muted)">Impossible de charger le fichier — vous etes hors-ligne et ce fichier n\'est pas en cache.</p></div>';
|
||
}
|
||
}
|
||
}
|
||
|
||
async function renderBacklinksPanel(vault, path, container) {
|
||
try {
|
||
const data = await api(`/api/file/${encodeURIComponent(vault)}/backlinks?path=${encodeURIComponent(path)}`);
|
||
if (!data.backlinks || data.backlinks.length === 0) return;
|
||
|
||
const panel = el("div", { class: "backlinks-panel" });
|
||
const header = el("div", { class: "backlinks-header" }, [
|
||
icon("link", 14),
|
||
document.createTextNode(` ${data.total} lien(s) entrant(s)`),
|
||
]);
|
||
panel.appendChild(header);
|
||
|
||
const list = el("div", { class: "backlinks-list" });
|
||
data.backlinks.forEach((bl) => {
|
||
const item = el("div", { class: "backlink-item" });
|
||
const vaultBadge = el("span", { class: "backlink-vault" }, [document.createTextNode(bl.vault)]);
|
||
const titleEl = el("span", { class: "backlink-title" }, [document.createTextNode(bl.title || bl.path.split("/").pop().replace(/\.md$/i, ""))]);
|
||
item.appendChild(icon(getFileIcon(bl.path), 12));
|
||
item.appendChild(vaultBadge);
|
||
item.appendChild(titleEl);
|
||
item.addEventListener("click", () => TabManager.openPreview(bl.vault, bl.path));
|
||
item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(bl.vault, bl.path); });
|
||
list.appendChild(item);
|
||
});
|
||
panel.appendChild(list);
|
||
container.appendChild(panel);
|
||
} catch (err) {
|
||
// Silently ignore — backlinks are optional
|
||
console.debug("Backlinks fetch failed:", err);
|
||
}
|
||
}
|
||
|
||
// ── Pretty print (mode lecture enrichi pour fichiers texte non-markdown) ──
|
||
// Detecte le langage depuis le HTML deja rendu par le backend (classe
|
||
// language-xxx), formate le JSON, puis genere un bloc avec gouttiere de
|
||
// numeros de ligne. La coloration est appliquee ensuite via highlight.js.
|
||
function detectPrettyLang(data) {
|
||
if (data.is_json || data.extension === ".json") return "json";
|
||
const m = /class="language-([\w-]+)/.exec(data.html || "");
|
||
return m ? m[1] : "plaintext";
|
||
}
|
||
|
||
function buildPrettyHtml(data, raw) {
|
||
let text = raw;
|
||
const lang = detectPrettyLang(data);
|
||
if (lang === "json") {
|
||
try { text = JSON.stringify(JSON.parse(raw), null, 2); } catch (_) { /* JSON invalide: on affiche brut */ }
|
||
}
|
||
const lines = text.split("\n");
|
||
const gutter = lines.map((_, i) => `<span>${i + 1}</span>`).join("");
|
||
return `<div class="pretty-view"><div class="pp-gutter" aria-hidden="true">${gutter}</div><pre class="pp-pre"><code class="language-${lang}">${escapeHtml(text)}</code></pre></div>`;
|
||
}
|
||
|
||
// Colorateur générique de secours: utilisé quand highlight.js ne produit
|
||
// aucun token (langage plaintext — .txt/.log —, langue absente du bundle,
|
||
// ou CDN hljs indisponible). Réutilise les classes hljs existantes (le
|
||
// thème CDN colore string/comment/number/attr) + classes .pp-* maison
|
||
// pour les niveaux de log qui n'ont pas d'équivalent sémantique hljs.
|
||
const PP_TOKEN_RE = /("[^"\n]*"?|'[^'\n]*'?|`[^`\n]*`?)|(^\s*(?:#|\/\/|;)\s.*$|\s+(?:#|\/\/)\s.*$)|\b(\d+(?:\.\d+)?)\b|\b(ERROR|FATAL|CRITICAL|SEVERE|WARN(?:ING)?|INFO|DEBUG|TRACE|NOTICE)\b|^(\s*[\w.\-\/ ]+)=(?=\S)|\b(true|false|null|None|TRUE|FALSE|NULL)\b/gm;
|
||
|
||
function ppClassFor(m) {
|
||
if (m[1] !== undefined) return "hljs-string";
|
||
if (m[2] !== undefined) return "hljs-comment";
|
||
if (m[3] !== undefined) return "hljs-number";
|
||
if (m[4] !== undefined) {
|
||
const lvl = m[4];
|
||
if (/ERROR|FATAL|CRITICAL|SEVERE/.test(lvl)) return "pp-level pp-err";
|
||
if (/WARN/.test(lvl)) return "pp-level pp-warn";
|
||
if (/INFO|NOTICE/.test(lvl)) return "pp-level pp-info";
|
||
return "pp-level pp-debug";
|
||
}
|
||
if (m[5] !== undefined) return "hljs-attr";
|
||
if (m[6] !== undefined) return "hljs-literal";
|
||
return null;
|
||
}
|
||
|
||
function fallbackTokenize(text) {
|
||
let out = "";
|
||
let last = 0;
|
||
PP_TOKEN_RE.lastIndex = 0;
|
||
let m;
|
||
while ((m = PP_TOKEN_RE.exec(text)) !== null) {
|
||
if (m.index > last) out += escapeHtml(text.slice(last, m.index));
|
||
const cls = ppClassFor(m);
|
||
out += cls ? `<span class="${cls}">${escapeHtml(m[0])}</span>` : escapeHtml(m[0]);
|
||
last = m.index + (m[0].length || 1);
|
||
if (m[0].length === 0) PP_TOKEN_RE.lastIndex++; // garde anti-boucle infinie
|
||
}
|
||
out += escapeHtml(text.slice(last));
|
||
return out;
|
||
}
|
||
|
||
// Applique la coloration: hljs (avec aliases de languages) si des tokens
|
||
// sortent, sinon fallback regex generique.
|
||
function applyPrettyHighlight(codeEl, lang, text) {
|
||
safeHighlight(codeEl); // gerne les alias language-xxx inconnus de hljs
|
||
const tokens = codeEl.querySelectorAll('[class^="hljs-"]').length;
|
||
if (!tokens && text.trim()) {
|
||
codeEl.innerHTML = fallbackTokenize(text);
|
||
codeEl.classList.add("hljs");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Jump the inline PDF viewer to a specific page.
|
||
*
|
||
* The browser's built-in PDF viewer lives in an ``about:blank`` content window
|
||
* (so ``contentWindow.location.hash`` never reaches the document) **and** it
|
||
* ignores a same-document fragment navigation: changing only ``#page=N`` on the
|
||
* iframe ``src`` does not move the page. A changing query parameter forces a
|
||
* real reload, and the ``#page=N`` fragment is then honoured at load — the only
|
||
* reliable way to target a page with the native viewer.
|
||
*
|
||
* @param {HTMLElement} area - Content area containing the ``.pdf-iframe``.
|
||
* @param {string|number} page - 1-based page number from the PDF outline.
|
||
*/
|
||
export function navigatePdfToPage(area, page) {
|
||
const iframe = area && area.querySelector('.pdf-iframe');
|
||
if (!iframe || page === null || page === undefined || page === '') return;
|
||
const base = iframe.getAttribute('data-pdf-url') || iframe.src.split('#')[0];
|
||
iframe.setAttribute('data-pdf-url', base);
|
||
const sep = base.includes('?') ? '&' : '?';
|
||
iframe.src = `${base}${sep}_pdfpage=${Date.now()}#page=${page}`;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Image viewer (roadmap #108-D)
|
||
// ---------------------------------------------------------------------------
|
||
const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"]);
|
||
const IMAGE_ZOOM_MIN = 0.1;
|
||
const IMAGE_ZOOM_MAX = 8;
|
||
let _imageViewerCleanup = null;
|
||
// BUG-072 — lightbox / metadata-panel state, reset as soon as a non-image file
|
||
// is rendered. #111 — image→image navigation now happens **in place**, so the
|
||
// viewer (and these states) is no longer destroyed between siblings.
|
||
const _imageViewerState = { lightbox: false, meta: false };
|
||
|
||
/** Clamp a zoom factor into the supported [0.1, 8] range. */
|
||
export function clampImageZoom(value) {
|
||
if (!Number.isFinite(value)) return 1;
|
||
return Math.min(IMAGE_ZOOM_MAX, Math.max(IMAGE_ZOOM_MIN, value));
|
||
}
|
||
|
||
/** True when *p* has a viewable image extension. */
|
||
export function isImagePath(p) {
|
||
const lower = (p || "").toLowerCase();
|
||
const dot = lower.lastIndexOf(".");
|
||
return dot !== -1 && IMAGE_EXTS.has(lower.slice(dot));
|
||
}
|
||
|
||
/** Build the byte-serving URL used by <img> / thumbnails. */
|
||
export function buildImageUrl(vault, path) {
|
||
return `/api/image/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`;
|
||
}
|
||
|
||
function buildThumbUrl(vault, path, size) {
|
||
return `/api/media/${encodeURIComponent(vault)}/thumb?path=${encodeURIComponent(path)}&size=${size}`;
|
||
}
|
||
|
||
// #111 — directory listing cache. Opening / switching to another image must not
|
||
// hammer `/api/browse` (the filmstrip and the sibling list share one listing).
|
||
const _imageDirCache = new Map();
|
||
const IMAGE_DIR_CACHE_TTL = 15000;
|
||
|
||
/** List the viewable image siblings of *dir*, memoised for a short TTL. */
|
||
async function listImageSiblings(vault, dir) {
|
||
const key = `${vault}\u0000${dir}`;
|
||
const cached = _imageDirCache.get(key);
|
||
if (cached && Date.now() - cached.ts < IMAGE_DIR_CACHE_TTL) return cached.items;
|
||
const res = await api(`/api/browse/${encodeURIComponent(vault)}?path=${encodeURIComponent(dir)}`);
|
||
const items = (res.items || []).filter((it) => it.type === "file" && isImagePath(it.path));
|
||
_imageDirCache.set(key, { items, ts: Date.now() });
|
||
return items;
|
||
}
|
||
|
||
const IMAGE_MIME = {
|
||
".png": "image/png",
|
||
".jpg": "image/jpeg",
|
||
".jpeg": "image/jpeg",
|
||
".gif": "image/gif",
|
||
".svg": "image/svg+xml",
|
||
".webp": "image/webp",
|
||
".bmp": "image/bmp",
|
||
".ico": "image/x-icon",
|
||
};
|
||
|
||
/** Best-effort MIME type for an image path (metadata panel). */
|
||
function imageMimeFor(path) {
|
||
const lower = (path || "").toLowerCase();
|
||
const dot = lower.lastIndexOf(".");
|
||
return dot === -1 ? "" : IMAGE_MIME[lower.slice(dot)] || "";
|
||
}
|
||
|
||
function formatBytes(n) {
|
||
if (!n && n !== 0) return "";
|
||
if (n < 1024) return `${n} o`;
|
||
if (n < 1048576) return `${(n / 1024).toFixed(1)} Ko`;
|
||
return `${(n / 1048576).toFixed(1)} Mo`;
|
||
}
|
||
|
||
/**
|
||
* Render a full image viewer: centered image, wheel zoom (0.1×–8×), drag pan,
|
||
* double-click reset, ←/→ navigation between siblings, thumbnail filmstrip,
|
||
* collapsible metadata panel and a full-viewport lightbox.
|
||
*/
|
||
export function renderImageViewer(area, data) {
|
||
const vault = data.vault;
|
||
// #111 — mutable current-image state: navigating swaps these in place instead
|
||
// of re-rendering the whole viewer (which used to refetch the file + listing).
|
||
let currentPath = data.path;
|
||
let currentTitle = data.title || (currentPath || "").split("/").pop();
|
||
let imgUrl = buildImageUrl(vault, currentPath);
|
||
let currentMeta = {
|
||
image_mime: data.image_mime || imageMimeFor(currentPath),
|
||
size_bytes: data.size_bytes,
|
||
modified: data.modified,
|
||
};
|
||
|
||
area.innerHTML = "";
|
||
const container = el("div", { class: "image-viewer-container", tabindex: "0" });
|
||
|
||
// ── Toolbar ────────────────────────────────────────────────────────────
|
||
const toolbar = el("div", { class: "image-toolbar" });
|
||
const titleEl = el("span", { class: "image-title", title: currentPath }, [
|
||
document.createTextNode(currentTitle),
|
||
]);
|
||
toolbar.appendChild(titleEl);
|
||
toolbar.appendChild(el("div", { class: "image-toolbar-spacer" }));
|
||
|
||
const counter = el("span", { class: "image-counter", hidden: true }, [document.createTextNode("")]);
|
||
toolbar.appendChild(counter);
|
||
const zoomBadge = el("span", { class: "image-zoom-badge" }, [document.createTextNode("100%")]);
|
||
toolbar.appendChild(zoomBadge);
|
||
|
||
const mkBtn = (iconName, label, cls) => {
|
||
const b = el("button", { class: `btn-action${cls ? " " + cls : ""}`, type: "button", title: label, "aria-label": label }, [icon(iconName, 14)]);
|
||
return b;
|
||
};
|
||
|
||
const zoomOutBtn = mkBtn("zoom-out", t("viewer.image_zoom_out"));
|
||
const zoomInBtn = mkBtn("zoom-in", t("viewer.image_zoom_in"));
|
||
const zoomResetBtn = mkBtn("rotate-ccw", t("viewer.image_zoom_reset"));
|
||
const prevBtn = mkBtn("chevron-left", t("viewer.image_prev"));
|
||
const nextBtn = mkBtn("chevron-right", t("viewer.image_next"));
|
||
const originalBtn = mkBtn("external-link", t("viewer.image_open_original"));
|
||
const downloadBtn = mkBtn("download", t("viewer.download"));
|
||
const metaBtn = mkBtn("info", t("viewer.image_metadata"), "image-btn-metadata");
|
||
const lightboxBtn = mkBtn("maximize", t("viewer.image_fullscreen"), "image-btn-lightbox");
|
||
[prevBtn, nextBtn, zoomOutBtn, zoomInBtn, zoomResetBtn, metaBtn, originalBtn, downloadBtn, lightboxBtn]
|
||
.forEach((b) => toolbar.appendChild(b));
|
||
container.appendChild(toolbar);
|
||
|
||
// ── Body: image stage + metadata sidebar (right) ───────────────────────
|
||
const body = el("div", { class: "image-viewer-body" });
|
||
const stage = el("div", { class: "image-stage" });
|
||
const img = el("img", { class: "image-main", src: imgUrl, alt: currentTitle, draggable: "false" });
|
||
stage.appendChild(img);
|
||
|
||
// ── Overlay arrows along the frame edges (#111) ────────────────────────
|
||
const prevArrow = el("button", {
|
||
class: "image-nav-arrow image-nav-arrow-prev",
|
||
type: "button",
|
||
title: t("viewer.image_prev"),
|
||
"aria-label": t("viewer.image_prev"),
|
||
hidden: true,
|
||
}, [icon("chevron-left", 32)]);
|
||
const nextArrow = el("button", {
|
||
class: "image-nav-arrow image-nav-arrow-next",
|
||
type: "button",
|
||
title: t("viewer.image_next"),
|
||
"aria-label": t("viewer.image_next"),
|
||
hidden: true,
|
||
}, [icon("chevron-right", 32)]);
|
||
stage.appendChild(prevArrow);
|
||
stage.appendChild(nextArrow);
|
||
body.appendChild(stage);
|
||
|
||
// ── Metadata sidebar (kept open across ←/→ navigation) ─────────────────
|
||
const metaPanel = el("div", { class: "image-meta-panel" });
|
||
metaPanel.hidden = !_imageViewerState.meta;
|
||
body.appendChild(metaPanel);
|
||
container.appendChild(body);
|
||
|
||
// ── Thumbnail filmstrip (navigation) ───────────────────────────────────
|
||
// #112 — the film scrolls with the mouse wheel and two translucent edge
|
||
// arrows; the scrollable strip is wrapped so the arrows stay fixed.
|
||
const navWrap = el("div", { class: "image-nav", hidden: true });
|
||
const strip = el("div", { class: "image-nav-strip" });
|
||
const stripPrev = el("button", {
|
||
class: "image-strip-arrow image-strip-arrow-prev",
|
||
type: "button",
|
||
title: t("viewer.image_strip_prev"),
|
||
"aria-label": t("viewer.image_strip_prev"),
|
||
}, [icon("chevron-left", 20)]);
|
||
const stripNext = el("button", {
|
||
class: "image-strip-arrow image-strip-arrow-next",
|
||
type: "button",
|
||
title: t("viewer.image_strip_next"),
|
||
"aria-label": t("viewer.image_strip_next"),
|
||
}, [icon("chevron-right", 20)]);
|
||
navWrap.appendChild(strip);
|
||
navWrap.appendChild(stripPrev);
|
||
navWrap.appendChild(stripNext);
|
||
container.appendChild(navWrap);
|
||
|
||
let scale = 1;
|
||
let tx = 0;
|
||
let ty = 0;
|
||
|
||
const applyTransform = () => {
|
||
img.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`;
|
||
zoomBadge.textContent = `${Math.round(scale * 100)}%`;
|
||
};
|
||
const resetView = () => { scale = 1; tx = 0; ty = 0; applyTransform(); };
|
||
const setZoom = (next) => {
|
||
scale = clampImageZoom(next);
|
||
if (scale === 1) { tx = 0; ty = 0; }
|
||
applyTransform();
|
||
};
|
||
|
||
const renderMeta = () => {
|
||
const rows = [
|
||
[t("viewer.image_type"), currentMeta.image_mime || ""],
|
||
[t("viewer.image_dimensions"), img.naturalWidth ? `${img.naturalWidth} × ${img.naturalHeight}` : ""],
|
||
[t("viewer.metadata_size"), formatBytes(currentMeta.size_bytes)],
|
||
[t("viewer.metadata_path"), currentPath],
|
||
];
|
||
if (currentMeta.modified) rows.push([t("viewer.metadata_modified"), currentMeta.modified]);
|
||
metaPanel.innerHTML = "";
|
||
const dl = el("dl", {});
|
||
rows.forEach(([k, v]) => {
|
||
dl.appendChild(el("dt", {}, [document.createTextNode(k)]));
|
||
dl.appendChild(el("dd", {}, [document.createTextNode(v || "—")]));
|
||
});
|
||
metaPanel.appendChild(dl);
|
||
};
|
||
|
||
// ── Sibling navigation (in place, #111) ────────────────────────────────
|
||
let siblings = [];
|
||
let currentIndex = -1;
|
||
let thumbs = [];
|
||
const preloaded = new Set();
|
||
|
||
const preload = (i) => {
|
||
if (!siblings.length) return;
|
||
const it = siblings[(i % siblings.length + siblings.length) % siblings.length];
|
||
if (!it || preloaded.has(it.path)) return;
|
||
preloaded.add(it.path);
|
||
const pre = new Image();
|
||
pre.src = buildImageUrl(vault, it.path);
|
||
};
|
||
|
||
const updateStripActive = () => {
|
||
thumbs.forEach((thumb, i) => thumb.classList.toggle("active", i === currentIndex));
|
||
const active = thumbs[currentIndex];
|
||
if (!active) return;
|
||
// Scroll the filmstrip only (never the document) to reveal the active thumb.
|
||
const left = active.offsetLeft;
|
||
const right = left + active.offsetWidth;
|
||
if (left < strip.scrollLeft) strip.scrollLeft = Math.max(0, left - 8);
|
||
else if (right > strip.scrollLeft + strip.clientWidth) strip.scrollLeft = right - strip.clientWidth + 8;
|
||
};
|
||
|
||
const updateArrows = () => {
|
||
const multi = siblings.length > 1;
|
||
prevArrow.hidden = nextArrow.hidden = prevBtn.disabled = nextBtn.disabled = !multi;
|
||
prevArrow.disabled = nextArrow.disabled = !multi;
|
||
counter.hidden = !multi;
|
||
counter.textContent = multi ? `${currentIndex + 1} / ${siblings.length}` : "";
|
||
};
|
||
|
||
/** Swap to the sibling at *index* without rebuilding the viewer. */
|
||
const showSibling = (index) => {
|
||
const item = siblings[index];
|
||
if (!item || item.path === currentPath) return;
|
||
currentIndex = index;
|
||
currentPath = item.path;
|
||
currentTitle = item.name;
|
||
imgUrl = buildImageUrl(vault, currentPath);
|
||
currentMeta = {
|
||
image_mime: imageMimeFor(currentPath),
|
||
size_bytes: item.size,
|
||
modified: null,
|
||
};
|
||
scale = 1; tx = 0; ty = 0;
|
||
applyTransform();
|
||
img.style.display = "";
|
||
const errEl = stage.querySelector(".image-error");
|
||
if (errEl) errEl.remove();
|
||
img.alt = currentTitle;
|
||
img.src = imgUrl;
|
||
titleEl.textContent = currentTitle;
|
||
titleEl.title = currentPath;
|
||
updateStripActive();
|
||
updateArrows();
|
||
state.currentVault = vault;
|
||
state.currentPath = currentPath;
|
||
try { syncActiveFileTreeItem(vault, currentPath); } catch (_) { /* best-effort */ }
|
||
preload(index - 1);
|
||
preload(index + 1);
|
||
if (!metaPanel.hidden) renderMeta();
|
||
};
|
||
|
||
const go = (delta) => {
|
||
if (siblings.length < 2 || currentIndex < 0) return;
|
||
showSibling((currentIndex + delta + siblings.length) % siblings.length);
|
||
};
|
||
|
||
const renderStrip = () => {
|
||
if (siblings.length < 2) { navWrap.hidden = true; return; }
|
||
navWrap.hidden = false;
|
||
strip.innerHTML = "";
|
||
thumbs = siblings.map((s, i) => {
|
||
const thumb = el("img", {
|
||
class: `image-thumb${i === currentIndex ? " active" : ""}`,
|
||
src: buildThumbUrl(vault, s.path, 96),
|
||
alt: s.name,
|
||
title: s.name,
|
||
loading: "lazy",
|
||
decoding: "async",
|
||
});
|
||
thumb.addEventListener("click", () => showSibling(i));
|
||
strip.appendChild(thumb);
|
||
return thumb;
|
||
});
|
||
updateStripActive();
|
||
};
|
||
|
||
// #112 — scroll the film with the wheel (vertical → horizontal) and the
|
||
// two overlay arrows; the active image never changes, only the strip view.
|
||
const scrollStrip = (dir) => {
|
||
strip.scrollBy({ left: dir * Math.max(140, strip.clientWidth * 0.8), behavior: "smooth" });
|
||
};
|
||
stripPrev.addEventListener("click", () => scrollStrip(-1));
|
||
stripNext.addEventListener("click", () => scrollStrip(1));
|
||
strip.addEventListener("wheel", (e) => {
|
||
if (strip.scrollWidth <= strip.clientWidth) return;
|
||
const delta = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
|
||
strip.scrollLeft += delta;
|
||
e.preventDefault();
|
||
}, { passive: false });
|
||
|
||
(async () => {
|
||
try {
|
||
const dir = currentPath.includes("/") ? currentPath.slice(0, currentPath.lastIndexOf("/")) : "";
|
||
siblings = await listImageSiblings(vault, dir);
|
||
currentIndex = siblings.findIndex((it) => it.path === currentPath);
|
||
renderStrip();
|
||
updateArrows();
|
||
preload(currentIndex - 1);
|
||
preload(currentIndex + 1);
|
||
} catch (_) { /* navigation is best-effort */ }
|
||
})();
|
||
|
||
// ── Interactions ───────────────────────────────────────────────────────
|
||
stage.addEventListener("wheel", (e) => {
|
||
e.preventDefault();
|
||
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
|
||
setZoom(scale * factor);
|
||
}, { passive: false });
|
||
|
||
const isArrow = (e) => !!(e.target && e.target.closest && e.target.closest(".image-nav-arrow"));
|
||
|
||
let dragging = false;
|
||
let startX = 0;
|
||
let startY = 0;
|
||
let startTx = 0;
|
||
let startTy = 0;
|
||
stage.addEventListener("pointerdown", (e) => {
|
||
if (e.button !== 0 || isArrow(e)) return;
|
||
dragging = true;
|
||
startX = e.clientX; startY = e.clientY; startTx = tx; startTy = ty;
|
||
stage.classList.add("panning");
|
||
if (stage.setPointerCapture) stage.setPointerCapture(e.pointerId);
|
||
});
|
||
stage.addEventListener("pointermove", (e) => {
|
||
if (!dragging) return;
|
||
tx = startTx + (e.clientX - startX);
|
||
ty = startTy + (e.clientY - startY);
|
||
applyTransform();
|
||
});
|
||
const endDrag = (e) => {
|
||
dragging = false;
|
||
stage.classList.remove("panning");
|
||
if (stage.releasePointerCapture && e.pointerId != null) {
|
||
try { stage.releasePointerCapture(e.pointerId); } catch (_) { /* already released */ }
|
||
}
|
||
};
|
||
stage.addEventListener("pointerup", endDrag);
|
||
stage.addEventListener("pointercancel", endDrag);
|
||
stage.addEventListener("dblclick", (e) => { if (!isArrow(e)) resetView(); });
|
||
|
||
const stopArrowPointer = (e) => e.stopPropagation();
|
||
prevArrow.addEventListener("pointerdown", stopArrowPointer);
|
||
nextArrow.addEventListener("pointerdown", stopArrowPointer);
|
||
prevArrow.addEventListener("dblclick", stopArrowPointer);
|
||
nextArrow.addEventListener("dblclick", stopArrowPointer);
|
||
prevArrow.addEventListener("click", (e) => { e.stopPropagation(); go(-1); });
|
||
nextArrow.addEventListener("click", (e) => { e.stopPropagation(); go(1); });
|
||
|
||
zoomInBtn.addEventListener("click", () => setZoom(scale * 1.25));
|
||
zoomOutBtn.addEventListener("click", () => setZoom(scale / 1.25));
|
||
zoomResetBtn.addEventListener("click", resetView);
|
||
prevBtn.addEventListener("click", () => go(-1));
|
||
nextBtn.addEventListener("click", () => go(1));
|
||
originalBtn.addEventListener("click", () => window.open(imgUrl, "_blank"));
|
||
downloadBtn.addEventListener("click", () => {
|
||
const dlUrl = `/api/file/${encodeURIComponent(vault)}/download?path=${encodeURIComponent(currentPath)}`;
|
||
window.open(dlUrl, "_blank");
|
||
});
|
||
metaBtn.setAttribute("aria-pressed", _imageViewerState.meta ? "true" : "false");
|
||
metaBtn.addEventListener("click", () => {
|
||
_imageViewerState.meta = !_imageViewerState.meta;
|
||
metaPanel.hidden = !_imageViewerState.meta;
|
||
metaBtn.setAttribute("aria-pressed", _imageViewerState.meta ? "true" : "false");
|
||
if (_imageViewerState.meta) renderMeta();
|
||
});
|
||
const syncLightbox = () => {
|
||
container.classList.toggle("lightbox", _imageViewerState.lightbox);
|
||
lightboxBtn.setAttribute("aria-pressed", _imageViewerState.lightbox ? "true" : "false");
|
||
};
|
||
syncLightbox();
|
||
lightboxBtn.addEventListener("click", () => {
|
||
_imageViewerState.lightbox = !_imageViewerState.lightbox;
|
||
syncLightbox();
|
||
});
|
||
|
||
img.addEventListener("load", () => {
|
||
img.style.display = "";
|
||
const errEl = stage.querySelector(".image-error");
|
||
if (errEl) errEl.remove();
|
||
if (!metaPanel.hidden) renderMeta();
|
||
});
|
||
img.addEventListener("error", () => {
|
||
img.style.display = "none";
|
||
if (!stage.querySelector(".image-error")) {
|
||
stage.appendChild(el("div", { class: "image-error" }, [document.createTextNode(t("viewer.image_error"))]));
|
||
}
|
||
});
|
||
|
||
const onKey = (e) => {
|
||
if (e.key === "ArrowLeft") { e.preventDefault(); go(-1); }
|
||
else if (e.key === "ArrowRight") { e.preventDefault(); go(1); }
|
||
else if (e.key === "+" || e.key === "=") { e.preventDefault(); setZoom(scale * 1.25); }
|
||
else if (e.key === "-") { e.preventDefault(); setZoom(scale / 1.25); }
|
||
else if (e.key === "0") { e.preventDefault(); resetView(); }
|
||
else if (e.key === "Escape") {
|
||
_imageViewerState.lightbox = false;
|
||
syncLightbox();
|
||
}
|
||
};
|
||
document.addEventListener("keydown", onKey);
|
||
_imageViewerCleanup = () => document.removeEventListener("keydown", onKey);
|
||
|
||
area.appendChild(container);
|
||
safeCreateIcons();
|
||
applyTransform();
|
||
if (_imageViewerState.meta) renderMeta();
|
||
try { container.focus({ preventScroll: true }); } catch (_) { /* non-fatal */ }
|
||
}
|
||
|
||
// #110 — Audio/video are handled by the global Now Playing controller
|
||
// (frontend/js/now-playing.js): a single shared media element is teleported
|
||
// between this inline view and a body-level dock, so playback survives tab,
|
||
// pane and page navigation. The inline branches below just hand over the
|
||
// surface; the legacy "stop on re-render" cleanup no longer applies.
|
||
export { formatMediaDuration, buildMediaUrl, renderFallback as renderMediaFallback } from "./now-playing.js";
|
||
|
||
/** Audio viewer (roadmap #109-B, made persistent by #110). */
|
||
export function renderAudioViewer(area, data) {
|
||
NowPlaying.attachInline(area, data);
|
||
}
|
||
|
||
/** Video viewer (roadmap #109-C, made persistent by #110). */
|
||
export function renderVideoViewer(area, data) {
|
||
NowPlaying.attachInline(area, data);
|
||
}
|
||
|
||
|
||
// ── Excel .xlsx — sheet tabs + editable cells ─────────────────────────────
|
||
// Cells are contenteditable; edits are collected per sheet and sent to
|
||
// PUT /api/file/{vault}/xlsx/save. Formula cells show their text and are
|
||
// never recalculated here.
|
||
//
|
||
// #153 A1/A4 — the read response carries `xlsx_lossy_features` (parts openpyxl
|
||
// drops on save): a banner lists them and the first save asks for an explicit
|
||
// confirmation before retrying with `force: true`. A value starting with "=" or
|
||
// "@" is stored as text unless the user turns the formula toggle on, so a typed
|
||
// `=cmd|…` cannot execute when the file is later opened in Excel.
|
||
// #153 A8 — a sheet bigger than the render caps used to be silently cut: the
|
||
// user saw a short table and no way to tell the rest of the workbook still
|
||
// existed. The backend now reports the real dimensions of every sheet, so the
|
||
// note states exactly what is hidden (and that those cells are not editable
|
||
// here — the workbook itself is untouched). A payload without those fields
|
||
// (older cache) simply shows no note.
|
||
function truncationNote(sheet) {
|
||
// The cap is the real "shown" figure, not `rows`/`cols`: those are post-trim
|
||
// (a sparse sheet renders 1x1) while the note must say how far the view
|
||
// reaches.
|
||
const cap = { rows: Number(sheet.max_rows) || 0, cols: Number(sheet.max_cols) || 0 };
|
||
const reasons = [];
|
||
if (Number(sheet.total_rows) > cap.rows) {
|
||
reasons.push(t("xlsx.truncated_rows", { shown: cap.rows, total: Number(sheet.total_rows) }));
|
||
}
|
||
if (Number(sheet.total_cols) > cap.cols) {
|
||
reasons.push(t("xlsx.truncated_cols", { shown: cap.cols, total: Number(sheet.total_cols) }));
|
||
}
|
||
if (!reasons.length) return "";
|
||
return `<div class="xlsx-truncated" role="note">
|
||
<i data-lucide="scissors" class="xlsx-truncated-icon"></i>
|
||
<div class="xlsx-warning-body">
|
||
<strong>${escapeHtml(t("xlsx.truncated_title"))}</strong>
|
||
<span>${escapeHtml(reasons.join(" "))}</span>
|
||
<span class="xlsx-warning-hint">${escapeHtml(t("xlsx.truncated_hint"))}</span>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
export function renderXlsxViewer(area, data) {
|
||
const sheets = data.xlsx_sheets || [];
|
||
const lossy = data.xlsx_lossy_features || [];
|
||
// Session-scoped state: once the lossy write is confirmed, the rest of the
|
||
// session saves without asking again (never persisted — a confirmation is
|
||
// per workbook, not a global preference).
|
||
let lossyConfirmed = false;
|
||
let allowFormula = false;
|
||
const tabs = sheets.length > 1
|
||
? `<div class="xlsx-tabs">${sheets.map((s, i) =>
|
||
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}">${escapeHtml(s.name)}</button>`
|
||
).join("")}</div>`
|
||
: "";
|
||
// #153 A15 — inline styles (bold/italic/colors from the file itself), merged
|
||
// ranges and the freeze anchor are applied AFTER the panels are in the DOM
|
||
// (the HTML string alone cannot carry them: col/row spans need the table).
|
||
const applySheetMeta = (panel) => {
|
||
const meta = sheets[Number(panel.dataset.sheet)] || {};
|
||
const styleMap = meta.styles || {};
|
||
const alignMap = meta.aligns || {};
|
||
panel.querySelectorAll(".xlsx-table td[data-cell]").forEach((td) => {
|
||
const ref = td.dataset.cell;
|
||
const style = styleMap[ref];
|
||
if (style) td.setAttribute("style", style);
|
||
const align = alignMap[ref];
|
||
if (align) td.style.textAlign = align;
|
||
});
|
||
// Merged ranges: span the anchor cell over the range and hide the covered
|
||
// cells (rowSpan/colSpan survive a re-render; hidden cells are skipped by
|
||
// the save collector because their text was already blank in openpyxl).
|
||
(meta.merges || []).forEach((range) => {
|
||
const [from, to] = range.split(":");
|
||
const anchor = panel.querySelector(`td[data-cell="${from}"]`);
|
||
const last = panel.querySelector(`td[data-cell="${to}"]`);
|
||
if (!anchor || !last || anchor === last) return;
|
||
const a = parseRef(from);
|
||
const b = parseRef(to);
|
||
if (!a || !b) return;
|
||
anchor.rowSpan = Math.max(1, b.row - a.row + 1);
|
||
anchor.colSpan = Math.max(1, b.col - a.col + 1);
|
||
// Hide every covered cell of the block.
|
||
for (let r = a.row; r <= b.row; r++) {
|
||
for (let c = a.col; c <= b.col; c++) {
|
||
if (r === a.row && c === a.col) continue;
|
||
const covered = panel.querySelector(`td[data-cell="${columnName(c)}${r}"]`);
|
||
if (covered) covered.style.display = "none";
|
||
}
|
||
}
|
||
});
|
||
// Freeze panes: sticky the rows/cols left of and above the anchor.
|
||
const freeze = meta.freeze;
|
||
if (freeze && freeze !== "A1") {
|
||
const f = parseRef(freeze);
|
||
if (f) {
|
||
panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => {
|
||
const ref = tr.querySelector("td")?.dataset.cell || "";
|
||
const p = parseRef(ref);
|
||
if (p && p.row < f.row) tr.classList.add("xlsx-frozen-row");
|
||
});
|
||
panel.querySelectorAll(".xlsx-table td[data-cell]").forEach((td) => {
|
||
const p = parseRef(td.dataset.cell);
|
||
if (p && p.col < f.col) td.classList.add("xlsx-frozen-col");
|
||
});
|
||
}
|
||
}
|
||
};
|
||
const panels = sheets.map((s, i) =>
|
||
`<div class="xlsx-panel" data-sheet="${i}"${i === 0 ? "" : ' style="display:none"'}>${truncationNote(s)}${s.html}</div>`
|
||
).join("");
|
||
const lossWarning = lossy.length
|
||
? `<div class="xlsx-warning" role="note">
|
||
<i data-lucide="alert-triangle" class="xlsx-warning-icon"></i>
|
||
<div class="xlsx-warning-body">
|
||
<strong>${escapeHtml(t("xlsx.lossy_title"))}</strong>
|
||
<span class="xlsx-warning-list">${lossy
|
||
.map((f) => `<span class="xlsx-warning-tag">${escapeHtml(t("xlsx.feature_" + f))}</span>`)
|
||
.join("")}</span>
|
||
<span class="xlsx-warning-hint">${escapeHtml(t("xlsx.lossy_hint"))}</span>
|
||
</div>
|
||
</div>`
|
||
: "";
|
||
|
||
area.innerHTML = `
|
||
<div class="xlsx-viewer">
|
||
<div class="xlsx-toolbar">
|
||
${tabs}
|
||
<span class="xlsx-toolbar-actions">
|
||
<button class="btn-action xlsx-formula-toggle" id="xlsx-formula-btn" type="button"
|
||
aria-pressed="false" title="${escapeHtml(t("xlsx.formula_toggle_title"))}">f(x)</button>
|
||
<button class="btn-action" id="xlsx-save-btn" disabled>${t("common.save")}</button>
|
||
<button class="btn-action" id="xlsx-download-btn">
|
||
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
|
||
</button>
|
||
<button class="btn-action" id="xlsx-csv-btn" title="${escapeHtml(t("xlsx.csv_export"))}">
|
||
<i data-lucide="file-spreadsheet" style="width:14px;height:14px"></i> CSV
|
||
</button>
|
||
<button class="btn-action" id="xlsx-structure-btn" title="${escapeHtml(t("xlsx.structure_btn"))}">
|
||
<i data-lucide="table-properties" style="width:14px;height:14px"></i>
|
||
</button>
|
||
</span>
|
||
</div>
|
||
${lossWarning}
|
||
<div class="xlsx-formula-bar">
|
||
<span class="xlsx-active-cell" id="xlsx-active-cell">A1</span>
|
||
<i data-lucide="chevron-right" class="xlsx-formula-sep"></i>
|
||
<input type="text" class="xlsx-formula-input" id="xlsx-formula-input"
|
||
spellcheck="false" placeholder="${escapeHtml(t("xlsx.formula_bar_placeholder"))}" />
|
||
<span class="xlsx-find-group">
|
||
<input type="text" class="xlsx-find-input" id="xlsx-find-input"
|
||
spellcheck="false" placeholder="${escapeHtml(t("xlsx.find_placeholder"))}" />
|
||
<span class="xlsx-find-count" id="xlsx-find-count"></span>
|
||
<button class="btn-action xlsx-find-btn" id="xlsx-find-prev" title="${escapeHtml(t("xlsx.find_prev"))}">↑</button>
|
||
<button class="btn-action xlsx-find-btn" id="xlsx-find-next" title="${escapeHtml(t("xlsx.find_next"))}">↓</button>
|
||
<button class="btn-action xlsx-find-case" id="xlsx-find-case" aria-pressed="false" title="${escapeHtml(t("xlsx.find_case"))}">Aa</button>
|
||
<button class="btn-action xlsx-sort-reset" id="xlsx-sort-reset" title="${escapeHtml(t("xlsx.sort_reset"))}" style="display:none">
|
||
<i data-lucide="rotate-ccw" style="width:14px;height:14px"></i>
|
||
</button>
|
||
</span>
|
||
</div>
|
||
<div class="xlsx-panels">${panels}</div>
|
||
</div>`;
|
||
|
||
const saveBtn = area.querySelector("#xlsx-save-btn");
|
||
const panelEls = [...area.querySelectorAll(".xlsx-panel")];
|
||
const dirtyCount = () => area.querySelectorAll("td.xlsx-dirty").length;
|
||
const refreshSaveState = () => { saveBtn.disabled = dirtyCount() === 0; };
|
||
|
||
// #153 A9bis — the first render stops at MAX_ROWS/MAX_COLS; the tail is
|
||
// fetched window by window from GET …/xlsx/sheet when the user reaches the
|
||
// end of a truncated sheet (scroll sentinel) or clicks « Charger la suite ».
|
||
// Appended rows reuse the exact same edit pipeline as the initial render.
|
||
const wireLazyRows = (panel) => {
|
||
const meta = sheets[Number(panel.dataset.sheet)] || {};
|
||
if (!meta.truncated) return;
|
||
const wrapper = panel.querySelector(".csv-table-wrapper");
|
||
const table = panel.querySelector(".xlsx-table tbody");
|
||
if (!wrapper || !table) return;
|
||
|
||
let offset = Number(meta.rows) || 0;
|
||
const total = Number(meta.total_rows) || 0;
|
||
let loading = false;
|
||
let done = offset >= total;
|
||
|
||
const foot = document.createElement("div");
|
||
foot.className = "xlsx-load-more";
|
||
const refreshFoot = () => {
|
||
foot.textContent = done
|
||
? ""
|
||
: `${t("xlsx.truncated_rows", { shown: offset, total })} · ${t("xlsx.load_more")}`;
|
||
foot.classList.toggle("done", done);
|
||
};
|
||
refreshFoot();
|
||
foot.addEventListener("click", () => { if (!done) loadMore(); });
|
||
wrapper.insertAdjacentElement("afterend", foot);
|
||
|
||
const appendWindow = (win) => {
|
||
const doc = new DOMParser().parseFromString(`<table>${win.html}</table>`, "text/html");
|
||
// No `tbody` selector: the fragment embeds its own wrapper div, so the
|
||
// parse yields bare `<tr>` inside `<table>` (the rows we want) — while
|
||
// any `tbody` in the fragment belongs to the *embedded* wrapper table.
|
||
doc.querySelectorAll("tr").forEach((tr) => table.appendChild(tr));
|
||
offset = win.offset + win.rows;
|
||
done = !win.has_more;
|
||
refreshFoot();
|
||
// The new rows must behave like the initial ones: contenteditable,
|
||
// dirty tracking, cached-value tooltip, icons in the fresh footnote.
|
||
panel.querySelectorAll("tbody tr:not([data-wired]) td").forEach(setupCell);
|
||
panel.querySelectorAll("tbody tr").forEach((tr) => tr.setAttribute("data-wired", "1"));
|
||
const cachedEls = panel.querySelectorAll(".xlsx-cached[data-cached-value]");
|
||
cachedEls.forEach((el) => { if (!el.title) el.title = t("xlsx.cached_value_title"); });
|
||
// #153 A15 — the window carries its own style/merge metadata: fold it
|
||
// into the sheet meta and re-apply, so lazy rows look like the initial
|
||
// render (applySheetMeta is idempotent over already-processed cells).
|
||
const sheetMeta = sheets[Number(panel.dataset.sheet)];
|
||
if (sheetMeta) {
|
||
Object.assign(sheetMeta.styles || (sheetMeta.styles = {}), win.styles || {});
|
||
Object.assign(sheetMeta.aligns || (sheetMeta.aligns = {}), win.aligns || {});
|
||
if (!sheetMeta.merges) sheetMeta.merges = [];
|
||
(win.merges || []).forEach((m) => {
|
||
if (!sheetMeta.merges.includes(m)) sheetMeta.merges.push(m);
|
||
});
|
||
if (win.freeze && !sheetMeta.freeze) sheetMeta.freeze = win.freeze;
|
||
applySheetMeta(panel);
|
||
}
|
||
safeCreateIcons();
|
||
};
|
||
|
||
const loadMore = async () => {
|
||
if (loading || done) return;
|
||
loading = true;
|
||
const label = foot.textContent;
|
||
foot.textContent = t("xlsx.loading_more");
|
||
try {
|
||
const win = await api(
|
||
`/api/file/${encodeURIComponent(data.vault)}/xlsx/sheet?path=${encodeURIComponent(data.path)}` +
|
||
`&sheet=${encodeURIComponent(sheets[Number(panel.dataset.sheet)].name)}` +
|
||
`&offset=${offset}&limit=500`,
|
||
);
|
||
appendWindow(win);
|
||
} catch (err) {
|
||
foot.textContent = label; // restore: a failed fetch must not eat the button
|
||
showToast(`${t("xlsx.load_error")}: ${err.message || err}`, "error");
|
||
} finally {
|
||
loading = false;
|
||
}
|
||
};
|
||
|
||
// Scroll sentinel: near the bottom of the wrapper, pull the next window.
|
||
wrapper.addEventListener("scroll", () => {
|
||
if (loading || done) return;
|
||
if (wrapper.scrollTop + wrapper.clientHeight >= wrapper.scrollHeight - 120) loadMore();
|
||
});
|
||
};
|
||
|
||
// ── #153 A7 — keyboard navigation & formula bar ────────────────────────
|
||
// One active cell per viewer: clicking or arrowing into a cell shows its
|
||
// A1 name in the bar; the input mirrors the cell text and typing there
|
||
// edits the cell live (Enter commits, Escape reverts, then re-focuses).
|
||
let activeTd = null;
|
||
const activeCellEl = area.querySelector("#xlsx-active-cell");
|
||
const formulaInput = area.querySelector("#xlsx-formula-input");
|
||
const syncing = { value: false }; // guard against input-event feedback loops
|
||
|
||
const cellName = (td) => td?.dataset.cell || "";
|
||
|
||
const setActiveCell = (td) => {
|
||
activeTd = td || null;
|
||
if (area.querySelector("td.xlsx-active")) area.querySelector("td.xlsx-active").classList.remove("xlsx-active");
|
||
if (!td) {
|
||
activeCellEl.textContent = "—";
|
||
formulaInput.value = "";
|
||
formulaInput.disabled = true;
|
||
return;
|
||
}
|
||
td.classList.add("xlsx-active");
|
||
activeCellEl.textContent = cellName(td);
|
||
formulaInput.disabled = false;
|
||
formulaInput.value = td.textContent;
|
||
};
|
||
|
||
const syncFormulaBar = (td) => {
|
||
if (td !== activeTd) return;
|
||
if (!syncing.value) formulaInput.value = td.textContent;
|
||
};
|
||
|
||
// Parse an A1 reference into its (row, col) parts.
|
||
const parseRef = (ref) => {
|
||
const m = /^([A-Z]+)(\d+)$/.exec(ref || "");
|
||
if (!m) return null;
|
||
let col = 0;
|
||
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
|
||
return { row: Number(m[2]), col };
|
||
};
|
||
|
||
const findTd = (panel, row, col) =>
|
||
panel.querySelector(`td[data-cell="${columnName(col)}${row}"]`);
|
||
|
||
const columnName = (col) => {
|
||
let name = "";
|
||
while (col > 0) {
|
||
const rem = (col - 1) % 26;
|
||
name = String.fromCharCode(65 + rem) + name;
|
||
col = Math.floor((col - 1) / 26);
|
||
}
|
||
return name;
|
||
};
|
||
|
||
const moveActive = (td, key, forward = true) => {
|
||
const ref = parseRef(cellName(td));
|
||
if (!ref) return;
|
||
let { row, col } = ref;
|
||
if (key === "Tab") col += forward ? 1 : -1;
|
||
else if (key === "ArrowRight") col += 1;
|
||
else if (key === "ArrowLeft") col -= 1;
|
||
else if (key === "ArrowDown") row += 1;
|
||
else if (key === "ArrowUp") row -= 1;
|
||
if (row < 1 || col < 1) return;
|
||
const panel = td.closest(".xlsx-panel");
|
||
const next = findTd(panel, row, col);
|
||
if (!next) return; // edge of the rendered window: no wrap
|
||
td.blur();
|
||
next.focus();
|
||
setActiveCell(next);
|
||
};
|
||
|
||
formulaInput.addEventListener("input", () => {
|
||
if (!activeTd) return;
|
||
syncing.value = true;
|
||
activeTd.textContent = formulaInput.value;
|
||
syncing.value = false;
|
||
activeTd.classList.add("xlsx-dirty");
|
||
refreshSaveState();
|
||
});
|
||
formulaInput.addEventListener("keydown", (e) => {
|
||
if (!activeTd) return;
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
activeTd.blur();
|
||
activeTd.focus();
|
||
} else if (e.key === "Escape") {
|
||
e.preventDefault();
|
||
activeTd.textContent = activeTd.dataset.orig;
|
||
activeTd.classList.remove("xlsx-dirty");
|
||
syncFormulaBar(activeTd);
|
||
refreshSaveState();
|
||
activeTd.focus();
|
||
}
|
||
});
|
||
|
||
const setupCell = (td) => {
|
||
td.contentEditable = "true";
|
||
td.spellcheck = false;
|
||
// Focusable without a pointing device: JSDOM requires it to fire focus
|
||
// events on contenteditable cells, and a keyboard user tabbing into the
|
||
// table from outside lands on the first cell thanks to it.
|
||
td.tabIndex = 0;
|
||
td.dataset.orig = td.textContent;
|
||
td.addEventListener("input", () => {
|
||
td.classList.add("xlsx-dirty");
|
||
syncFormulaBar(td);
|
||
refreshSaveState();
|
||
});
|
||
td.addEventListener("focus", () => setActiveCell(td));
|
||
td.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); td.blur(); }
|
||
if (e.key === "Escape") {
|
||
td.textContent = td.dataset.orig;
|
||
td.classList.remove("xlsx-dirty");
|
||
syncFormulaBar(td);
|
||
refreshSaveState();
|
||
}
|
||
if (e.key === "Tab" || e.key === "ArrowUp" || e.key === "ArrowDown"
|
||
|| e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||
// Excel-like: arrows and Tab move to the neighbour cell. Direction is
|
||
// the key's own (Left/Up = back, Right/Down = forward); Tab follows
|
||
// Shift. Prevented so Tab never leaves the table and arrows never
|
||
// move the caret (they move the SELECTION instead).
|
||
e.preventDefault();
|
||
const forward = e.key === "Tab"
|
||
? !e.shiftKey
|
||
: (e.key === "ArrowRight" || e.key === "ArrowDown");
|
||
moveActive(td, e.key, forward);
|
||
}
|
||
});
|
||
td.addEventListener("paste", (e) => {
|
||
e.preventDefault();
|
||
const text = (e.clipboardData || window.clipboardData).getData("text").replace(/\r?\n/g, " ");
|
||
document.execCommand("insertText", false, text);
|
||
});
|
||
};
|
||
|
||
// #153 A12 — the backend marks the last value Excel computed; the wording is
|
||
// translated here so the tooltip follows the UI language.
|
||
area.querySelectorAll(".xlsx-cached[data-cached-value]").forEach((el) => {
|
||
el.title = t("xlsx.cached_value_title");
|
||
});
|
||
|
||
// Editable cells: Enter blurs, Escape reverts, arrows/Tab navigate.
|
||
area.querySelectorAll(".xlsx-table td").forEach(setupCell);
|
||
panelEls.forEach(wireLazyRows);
|
||
panelEls.forEach(applySheetMeta);
|
||
// The formula bar starts disabled: nothing is selected yet.
|
||
setActiveCell(null);
|
||
|
||
area.querySelectorAll(".xlsx-tab").forEach((tab) => {
|
||
tab.addEventListener("click", () => {
|
||
const idx = tab.dataset.sheet;
|
||
area.querySelectorAll(".xlsx-tab").forEach((x) => x.classList.toggle("active", x === tab));
|
||
panelEls.forEach((p) => { p.style.display = p.dataset.sheet === idx ? "" : "none"; });
|
||
});
|
||
});
|
||
|
||
// Formula toggle (#153 A4) — opt-in for this viewing session only.
|
||
const formulaBtn = area.querySelector("#xlsx-formula-btn");
|
||
formulaBtn.addEventListener("click", () => {
|
||
allowFormula = !allowFormula;
|
||
formulaBtn.setAttribute("aria-pressed", String(allowFormula));
|
||
formulaBtn.classList.toggle("active", allowFormula);
|
||
});
|
||
|
||
const putSheet = (job, force) => api(
|
||
`/api/file/${encodeURIComponent(data.vault)}/xlsx/save?path=${encodeURIComponent(data.path)}`,
|
||
{
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ ...job, allow_formula: allowFormula, force }),
|
||
},
|
||
);
|
||
|
||
saveBtn.addEventListener("click", async () => {
|
||
// One PUT per sheet (dirty cells can span tabs before a save).
|
||
const jobs = panelEls
|
||
.map((panel) => {
|
||
const cells = {};
|
||
panel.querySelectorAll("td.xlsx-dirty").forEach((td) => { cells[td.dataset.cell] = td.textContent; });
|
||
return { sheet: sheets[Number(panel.dataset.sheet)].name, cells };
|
||
})
|
||
.filter((job) => Object.keys(job.cells).length);
|
||
if (!jobs.length) return;
|
||
saveBtn.disabled = true;
|
||
try {
|
||
for (const job of jobs) {
|
||
// 409 xlsx_lossy_content → confirm once, then retry with force: true.
|
||
// (Also covers a workbook that became lossy while it was open.)
|
||
let force = lossyConfirmed;
|
||
for (;;) {
|
||
try {
|
||
await putSheet(job, force);
|
||
break;
|
||
} catch (err) {
|
||
if (err && err.code === "xlsx_lossy_content" && !lossyConfirmed) {
|
||
const features = (err.details && err.details.features) || lossy;
|
||
const labels = features.map((f) => t("xlsx.feature_" + f)).join(", ");
|
||
if (!confirm(t("xlsx.lossy_confirm", { features: labels }))) throw err;
|
||
lossyConfirmed = true;
|
||
force = true;
|
||
continue;
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
}
|
||
area.querySelectorAll("td.xlsx-dirty").forEach((td) => {
|
||
td.classList.remove("xlsx-dirty");
|
||
td.dataset.orig = td.textContent;
|
||
});
|
||
if (activeTd) syncFormulaBar(activeTd);
|
||
refreshSaveState();
|
||
showToast(t("editor.saved"), "success");
|
||
} catch (err) {
|
||
refreshSaveState();
|
||
// A refused confirmation is a decision, not a failure: neutral toast.
|
||
if (err && err.code === "xlsx_lossy_content") {
|
||
showToast(t("xlsx.lossy_cancelled"), "info");
|
||
} else {
|
||
showToast(`${t("editor.save_error")}: ${err.message || err}`, "error");
|
||
}
|
||
}
|
||
});
|
||
|
||
area.querySelector("#xlsx-download-btn").addEventListener("click", () => {
|
||
window.open(`/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`, "_blank");
|
||
});
|
||
|
||
// ── #153 A13 — sort / filter / find in the sheet + CSV export ──────────
|
||
// ALL of these act on the RENDERED grid only: the workbook is never
|
||
// rewritten by a sort or a filter (the save pipeline stays the only write
|
||
// path, and the note in xlsx.sort_applied says so).
|
||
const visiblePanel = () =>
|
||
panelEls.find((p) => p.style.display !== "none") || panelEls[0];
|
||
|
||
// Sorting reorders <tr> rows by the text of one column. Dirty cells travel
|
||
// with their row, so a sort never loses an unsaved edit.
|
||
let sortState = null; // { col, dir } on the visible panel
|
||
|
||
const applySort = (panel, col, dir) => {
|
||
const tbody = panel.querySelector(".xlsx-table tbody");
|
||
if (!tbody) return;
|
||
const rows = [...tbody.querySelectorAll("tr")];
|
||
const numeric = rows.every((tr) => {
|
||
const td = tr.querySelector(`td[data-cell^="${columnName(col)}"]`);
|
||
const v = td ? td.textContent.trim() : "";
|
||
return v === "" || !isNaN(Number(v));
|
||
});
|
||
rows.sort((a, b) => {
|
||
const ta = a.querySelector(`td[data-cell^="${columnName(col)}"]`);
|
||
const tb = b.querySelector(`td[data-cell^="${columnName(col)}"]`);
|
||
const va = ta ? ta.textContent.trim() : "";
|
||
const vb = tb ? tb.textContent.trim() : "";
|
||
const cmp = numeric
|
||
? (parseFloat(va) || 0) - (parseFloat(vb) || 0)
|
||
: va.localeCompare(vb, "fr");
|
||
return dir === "asc" ? cmp : -cmp;
|
||
});
|
||
rows.forEach((tr) => tbody.appendChild(tr));
|
||
sortState = { col, dir };
|
||
sortResetBtn.style.display = "";
|
||
showToast(t("xlsx.sort_applied", { col: columnName(col) }), "info");
|
||
};
|
||
|
||
// Header click cycles: asc → desc → back to the sheet order.
|
||
const wireHeaderSort = (panel) => {
|
||
const thead = panel.querySelector(".xlsx-table thead");
|
||
if (!thead || thead.dataset.sortWired) return;
|
||
thead.dataset.sortWired = "1";
|
||
thead.querySelectorAll("th:not(.xlsx-corner)").forEach((th) => {
|
||
th.style.cursor = "pointer";
|
||
th.title = t("xlsx.sort_asc");
|
||
th.addEventListener("click", () => {
|
||
// The corner th is column 0, so the child index IS the column number.
|
||
const col = [...th.parentElement.children].indexOf(th);
|
||
const current = sortState && sortState.col === col ? sortState.dir : null;
|
||
const dir = current === "asc" ? "desc" : "asc";
|
||
applySort(panel, col, dir);
|
||
th.title = dir === "asc" ? t("xlsx.sort_desc") : t("xlsx.sort_asc");
|
||
});
|
||
});
|
||
};
|
||
|
||
// Filter: rows whose cell text lacks the needle are hidden.
|
||
const applyFilter = (panel, needle) => {
|
||
const n = needle.trim().toLowerCase();
|
||
panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => {
|
||
const text = tr.textContent.toLowerCase();
|
||
tr.style.display = !n || text.includes(n) ? "" : "none";
|
||
});
|
||
if (n) sortResetBtn.style.display = "";
|
||
};
|
||
|
||
panelEls.forEach(wireHeaderSort);
|
||
|
||
const sortResetBtn = area.querySelector("#xlsx-sort-reset");
|
||
sortResetBtn.addEventListener("click", () => {
|
||
// Full reset: re-render the current file (server truth), clear find too.
|
||
sortResetBtn.style.display = "none";
|
||
renderXlsxViewer(area, data);
|
||
});
|
||
|
||
// Find-in-sheet: highlight matches, navigate with ↑/↓.
|
||
let findMatches = [];
|
||
let findIndex = -1;
|
||
const findInput = area.querySelector("#xlsx-find-input");
|
||
const findCount = area.querySelector("#xlsx-find-count");
|
||
let findCase = false;
|
||
|
||
const clearFind = () => {
|
||
findMatches.forEach(({ td, mark }) => {
|
||
mark.replaceWith(document.createTextNode(mark.textContent));
|
||
td.normalize();
|
||
});
|
||
findMatches = [];
|
||
findIndex = -1;
|
||
findCount.textContent = "";
|
||
};
|
||
|
||
const runFind = () => {
|
||
clearFind();
|
||
const needle = findInput.value;
|
||
if (!needle.trim()) return;
|
||
const panel = visiblePanel();
|
||
const hay = findCase ? (s) => s : (s) => s.toLowerCase();
|
||
const n = hay(needle);
|
||
panel.querySelectorAll(".xlsx-table td").forEach((td) => {
|
||
const text = td.textContent;
|
||
const pos = hay(text).indexOf(n);
|
||
if (pos === -1 || !text) return;
|
||
// Wrap the match in a <mark> by splitting the cell's first text node at
|
||
// the hit (cells are text-first; the cached-value span stays untouched).
|
||
const first = td.firstChild;
|
||
if (first && first.nodeType === 3) {
|
||
const mark = document.createElement("mark");
|
||
mark.className = "xlsx-find-hit";
|
||
mark.textContent = text.substr(pos, needle.length);
|
||
const tail = first.splitText(pos);
|
||
tail.splitText(needle.length);
|
||
td.replaceChild(mark, tail);
|
||
findMatches.push({ td, mark });
|
||
}
|
||
});
|
||
findCount.textContent = findMatches.length
|
||
? t("xlsx.find_count", { index: 1, count: findMatches.length })
|
||
: t("xlsx.find_no_match");
|
||
if (findMatches.length) focusMatch(0);
|
||
};
|
||
|
||
const focusMatch = (i) => {
|
||
findMatches.forEach(({ mark }) => mark.classList.remove("xlsx-find-current"));
|
||
findIndex = (i + findMatches.length) % findMatches.length;
|
||
const { td, mark } = findMatches[findIndex];
|
||
mark.classList.add("xlsx-find-current");
|
||
// scrollIntoView is missing in JSDOM; guard it (real browsers have it).
|
||
if (typeof td.scrollIntoView === "function") td.scrollIntoView({ block: "nearest" });
|
||
findCount.textContent = t("xlsx.find_count", { index: findIndex + 1, count: findMatches.length });
|
||
};
|
||
|
||
// One input drives both the highlight (find) and the row filter (A13):
|
||
// typing narrows the sheet to the matching rows AND highlights the hits.
|
||
findInput.addEventListener("input", () => {
|
||
panelEls.forEach((p) => applyFilter(p, findInput.value));
|
||
runFind();
|
||
});
|
||
findInput.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") { e.preventDefault(); focusMatch(findIndex + (e.shiftKey ? -1 : 1)); }
|
||
});
|
||
area.querySelector("#xlsx-find-prev").addEventListener("click", () => focusMatch(findIndex - 1));
|
||
area.querySelector("#xlsx-find-next").addEventListener("click", () => focusMatch(findIndex + 1));
|
||
const caseBtn = area.querySelector("#xlsx-find-case");
|
||
caseBtn.addEventListener("click", () => {
|
||
findCase = !findCase;
|
||
caseBtn.setAttribute("aria-pressed", String(findCase));
|
||
caseBtn.classList.toggle("active", findCase);
|
||
runFind();
|
||
});
|
||
|
||
// ── #153 A14 — workbook structure menu (sheets, rows, columns) ─────────
|
||
// Every action is an explicit user gesture (prompt/confirm) and goes to
|
||
// PUT …/xlsx/structure — one locked, atomic rewrite with a backup.
|
||
const visibleSheetIndex = () =>
|
||
Number((visiblePanel() || panelEls[0])?.dataset.sheet) || 0;
|
||
|
||
const putStructure = async (actions, force = false) => {
|
||
await api(
|
||
`/api/file/${encodeURIComponent(data.vault)}/xlsx/structure?path=${encodeURIComponent(data.path)}`,
|
||
{
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ actions, force }),
|
||
},
|
||
);
|
||
showToast(t("xlsx.structure_saved"), "success");
|
||
// Re-render from the server so the viewer shows the new structure.
|
||
const fresh = await api(
|
||
`/api/file/${encodeURIComponent(data.vault)}?path=${encodeURIComponent(data.path)}`,
|
||
);
|
||
renderXlsxViewer(area, fresh);
|
||
};
|
||
|
||
const structureError = (err, retryActions) => {
|
||
if (err && err.code === "xlsx_lossy_content") {
|
||
const features = (err.details && err.details.features) || lossy;
|
||
const labels = features.map((f) => t("xlsx.feature_" + f)).join(", ");
|
||
if (confirm(t("xlsx.lossy_confirm", { features: labels }))) {
|
||
return putStructure(retryActions, true); // re-emitted with force
|
||
}
|
||
showToast(t("xlsx.lossy_cancelled"), "info");
|
||
return null;
|
||
}
|
||
showToast(`${t("xlsx.structure_error")}: ${err.message || err}`, "error");
|
||
return null;
|
||
};
|
||
|
||
// Structure menu: built on demand, positioned under the button.
|
||
area.querySelector("#xlsx-structure-btn").addEventListener("click", (e) => {
|
||
const old = area.querySelector(".xlsx-structure-menu");
|
||
if (old) { old.remove(); return; }
|
||
const idx = visibleSheetIndex();
|
||
const sheetName = sheets[idx]?.name || "";
|
||
const activeRef = cellName(activeTd && activeTd.closest(".xlsx-panel") === visiblePanel() ? activeTd : null);
|
||
const parsed = parseRef(activeRef);
|
||
const menu = document.createElement("div");
|
||
menu.className = "xlsx-structure-menu";
|
||
const item = (label, fn) => {
|
||
const b = document.createElement("button");
|
||
b.type = "button";
|
||
b.className = "btn-action xlsx-structure-item";
|
||
b.textContent = label;
|
||
b.addEventListener("click", () => { menu.remove(); fn(); });
|
||
menu.appendChild(b);
|
||
};
|
||
item(t("xlsx.sheet_add"), async () => {
|
||
const name = prompt(t("xlsx.structure_prompt_add"));
|
||
if (!name) return;
|
||
const actions = [{ op: "sheet_add", name }];
|
||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||
});
|
||
item(t("xlsx.sheet_rename"), async () => {
|
||
const to = prompt(t("xlsx.structure_prompt_rename"), sheetName);
|
||
if (!to || to === sheetName) return;
|
||
const actions = [{ op: "sheet_rename", from: sheetName, to }];
|
||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||
});
|
||
item(t("xlsx.sheet_duplicate"), async () => {
|
||
const as = prompt(t("xlsx.structure_prompt_add"), `${sheetName} (copie)`);
|
||
if (!as) return;
|
||
const actions = [{ op: "sheet_duplicate", name: sheetName, as }];
|
||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||
});
|
||
item(t("xlsx.sheet_delete"), async () => {
|
||
if (sheets.length <= 1) { showToast(t("xlsx.last_sheet"), "info"); return; }
|
||
if (!confirm(t("xlsx.structure_confirm_delete_sheet", { name: sheetName }))) return;
|
||
const actions = [{ op: "sheet_delete", name: sheetName }];
|
||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||
});
|
||
if (parsed) {
|
||
menu.appendChild(Object.assign(document.createElement("div"), { className: "xlsx-structure-sep" }));
|
||
item(t("xlsx.row_insert"), async () => {
|
||
const actions = [{ op: "row_insert", sheet: sheetName, at: parsed.row }];
|
||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||
});
|
||
item(t("xlsx.row_delete"), async () => {
|
||
if (!confirm(t("xlsx.structure_confirm_row", { n: parsed.row }))) return;
|
||
const actions = [{ op: "row_delete", sheet: sheetName, at: parsed.row }];
|
||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||
});
|
||
item(t("xlsx.col_insert"), async () => {
|
||
const actions = [{ op: "col_insert", sheet: sheetName, at: parsed.col }];
|
||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||
});
|
||
item(t("xlsx.col_delete"), async () => {
|
||
if (!confirm(t("xlsx.structure_confirm_col", { n: columnName(parsed.col) }))) return;
|
||
const actions = [{ op: "col_delete", sheet: sheetName, at: parsed.col }];
|
||
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||
});
|
||
}
|
||
e.target.closest(".xlsx-toolbar").appendChild(menu);
|
||
});
|
||
|
||
// CSV export of the visible sheet (post-trim, pre-save data).
|
||
area.querySelector("#xlsx-csv-btn").addEventListener("click", () => {
|
||
const panel = visiblePanel();
|
||
const idx = Number(panel.dataset.sheet);
|
||
const rows = [];
|
||
panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => {
|
||
rows.push(
|
||
[...tr.querySelectorAll("td")].map((td) => {
|
||
const clone = td.cloneNode(true);
|
||
clone.querySelectorAll(".xlsx-cached").forEach((el) => el.remove());
|
||
return clone.textContent;
|
||
}),
|
||
);
|
||
});
|
||
const csv = rows
|
||
.map((r) => r.map((v) => (/[";\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v)).join(";"))
|
||
.join("\n");
|
||
const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" });
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `${sheets[idx]?.name || "feuille"}.csv`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(a.href);
|
||
});
|
||
|
||
safeCreateIcons();
|
||
}
|
||
|
||
export function renderFile(data) {
|
||
// #93 — An inline edition session (#editor-container mounted in the content
|
||
// area) is destroyed by this very re-render: release it first so the editor
|
||
// state (CodeMirror view, Forge iframe, Yjs session) is torn down cleanly
|
||
// instead of being wiped mid-session by a tab switch / sidebar click.
|
||
if (isInlineEditorActive()) detachInlineEditor();
|
||
// #108 — release the image viewer's document-level shortcuts before swapping
|
||
// the content area (otherwise they leak on every re-render).
|
||
if (_imageViewerCleanup) { _imageViewerCleanup(); _imageViewerCleanup = null; }
|
||
// BUG-072 / #111 — any render that is not an image viewer starts from a clean
|
||
// viewer. Image→image navigation now happens in place (see renderImageViewer),
|
||
// so the viewer is never rebuilt between siblings.
|
||
if (!data.is_image) {
|
||
_imageViewerState.lightbox = false;
|
||
_imageViewerState.meta = false;
|
||
}
|
||
const area = getContentArea();
|
||
// #110 — if this render is about to replace the surface that currently hosts
|
||
// the shared media element, hand it back to the persistent dock first.
|
||
NowPlaying.handleRender(area, data);
|
||
|
||
// Handle PDF files — render in iframe with TOC sidebar
|
||
if (data.is_pdf) {
|
||
const pdfUrl = `/api/file/${encodeURIComponent(data.vault)}/pdf/stream?path=${encodeURIComponent(data.path)}`;
|
||
const pages = data.pdf_metadata && data.pdf_metadata.pages ? data.pdf_metadata.pages : '?';
|
||
const toc = data.pdf_toc || [];
|
||
|
||
let tocHtml = '';
|
||
if (toc.length > 0) {
|
||
tocHtml = '<div class="pdf-toc"><h3>Table des matières</h3><ul>';
|
||
for (const item of toc) {
|
||
const indent = (item.level - 1) * 16;
|
||
tocHtml += `<li style="padding-left:${indent}px"><a href="#" data-page="${item.page}">${escapeHtml(item.title)}</a> <span class="toc-page">p.${item.page}</span></li>`;
|
||
}
|
||
tocHtml += '</ul></div>';
|
||
}
|
||
|
||
area.innerHTML = `
|
||
<div class="pdf-viewer-container">
|
||
<div class="pdf-toolbar">
|
||
<span class="pdf-info">PDF — ${pages} pages</span>
|
||
<button class="btn-action" data-pdf-url="${pdfUrl}">
|
||
<i data-lucide="external-link" style="width:14px;height:14px"></i> Plein écran
|
||
</button>
|
||
<button class="btn-action" data-download-url="/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}">
|
||
<i data-lucide="download" style="width:14px;height:14px"></i> Télécharger
|
||
</button>
|
||
</div>
|
||
<div class="pdf-body">
|
||
${tocHtml}
|
||
<iframe src="${pdfUrl}" data-pdf-url="${pdfUrl}" class="pdf-iframe" title="${escapeHtml(data.title)}"></iframe>
|
||
</div>
|
||
</div>`;
|
||
area.querySelectorAll('.pdf-toolbar .btn-action').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
window.open(btn.dataset.pdfUrl || btn.dataset.downloadUrl, '_blank');
|
||
});
|
||
});
|
||
area.querySelectorAll('.pdf-toc a[data-page]').forEach((link) => {
|
||
link.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
navigatePdfToPage(area, link.getAttribute('data-page'));
|
||
});
|
||
});
|
||
lucide.createIcons();
|
||
return;
|
||
}
|
||
|
||
// Handle Excel .xlsx — editable table view (display / edit / download)
|
||
if (data.is_xlsx) {
|
||
renderXlsxViewer(area, data);
|
||
return;
|
||
}
|
||
|
||
// Handle Excalidraw — render in iframe editor
|
||
if (data.is_excalidraw) {
|
||
renderExcalidraw(area, data, data.vault, data.path);
|
||
return;
|
||
}
|
||
|
||
// Handle images — dedicated zoom/pan viewer (roadmap #108-D)
|
||
if (data.is_image) {
|
||
renderImageViewer(area, data);
|
||
return;
|
||
}
|
||
|
||
// Handle audio / video — native HTML5 players (roadmap #109)
|
||
if (data.is_audio) {
|
||
renderAudioViewer(area, data);
|
||
return;
|
||
}
|
||
if (data.is_video) {
|
||
renderVideoViewer(area, data);
|
||
return;
|
||
}
|
||
|
||
// Handle unsupported (binary) files
|
||
if (data.unsupported) {
|
||
const sizeStr = data.size_bytes
|
||
? data.size_bytes < 1024 ? `${data.size_bytes} o`
|
||
: data.size_bytes < 1048576 ? `${(data.size_bytes / 1024).toFixed(1)} Ko`
|
||
: `${(data.size_bytes / 1048576).toFixed(1)} Mo`
|
||
: "";
|
||
area.innerHTML = `
|
||
<div class="unsupported-file">
|
||
<i data-lucide="file" style="width:48px;height:48px"></i>
|
||
<div class="filename">${escapeHtml(data.path.split("/").pop())}</div>
|
||
<div>${data.media_too_large ? escapeHtml(t("viewer.media_too_large")) : "Ce fichier est binaire et ne peut pas être affiché."}</div>
|
||
${sizeStr ? `<div style="font-size:0.85rem;margin-top:4px">Taille : ${sizeStr}</div>` : ""}
|
||
<button class="btn-action" id="unsupported-download-btn">
|
||
<i data-lucide="download" style="width:14px;height:14px"></i> Télécharger
|
||
</button>
|
||
</div>`;
|
||
lucide.createIcons();
|
||
document.getElementById("unsupported-download-btn").addEventListener("click", () => {
|
||
const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`;
|
||
window.open(dlUrl, "_blank");
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Breadcrumb
|
||
const parts = data.path.split("/");
|
||
const breadcrumbEls = [];
|
||
breadcrumbEls.push(
|
||
makeBreadcrumbSpan(data.vault, () => {
|
||
focusPathInSidebar(data.vault, "", { alignToTop: "center" });
|
||
}),
|
||
);
|
||
let accumulated = "";
|
||
parts.forEach((part, i) => {
|
||
breadcrumbEls.push(el("span", { class: "sep" }, [document.createTextNode(" / ")]));
|
||
accumulated += (accumulated ? "/" : "") + part;
|
||
const p = accumulated;
|
||
if (i < parts.length - 1) {
|
||
breadcrumbEls.push(
|
||
makeBreadcrumbSpan(part, () => {
|
||
focusPathInSidebar(data.vault, p, { alignToTop: "center" });
|
||
}),
|
||
);
|
||
} else {
|
||
breadcrumbEls.push(
|
||
makeBreadcrumbSpan(part.replace(/\.md$/i, ""), () => {
|
||
focusPathInSidebar(data.vault, data.path, { alignToTop: "center" });
|
||
}),
|
||
);
|
||
}
|
||
});
|
||
|
||
const breadcrumb = el("div", { class: "breadcrumb" }, breadcrumbEls);
|
||
|
||
// Tags
|
||
const tagsDiv = el("div", { class: "file-tags" });
|
||
(data.tags || []).forEach((tag) => {
|
||
if (!TagFilterService.isTagFiltered(tag)) {
|
||
const t = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]);
|
||
t.addEventListener("click", () => searchByTag(tag));
|
||
tagsDiv.appendChild(t);
|
||
}
|
||
});
|
||
|
||
// Action buttons
|
||
// Cache local au rendu courant: state.cachedRawSource global est fiable
|
||
// uniquement quand openFile() initialise le fichier (il le réinitialise).
|
||
// Or le contenu d'un onglet peut être rendu via renderFile() direct sans
|
||
// passer par openFile() — un cache partagé transporterait le contenu du
|
||
// fichier précédent (bug pretty print sur fichiers successifs).
|
||
let localRaw = null;
|
||
const getRaw = async () => {
|
||
if (localRaw != null) return localRaw;
|
||
if (state.currentVault === data.vault && state.currentPath === data.path && state.cachedRawSource) {
|
||
localRaw = state.cachedRawSource;
|
||
return localRaw;
|
||
}
|
||
const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;
|
||
const rawData = await api(rawUrl);
|
||
localRaw = rawData.raw;
|
||
return localRaw;
|
||
};
|
||
const copyBtn = el("button", { class: "btn-action", title: "Copier la source" }, [icon("copy", 14), document.createTextNode("Copier")]);
|
||
copyBtn.addEventListener("click", async () => {
|
||
try {
|
||
const rawSrc = await getRaw();
|
||
if (navigator.clipboard) {
|
||
await navigator.clipboard.writeText(rawSrc);
|
||
} else {
|
||
// Fallback for non-secure contexts
|
||
const ta = document.createElement("textarea");
|
||
ta.value = rawSrc;
|
||
ta.style.position = "fixed"; ta.style.opacity = "0";
|
||
document.body.appendChild(ta); ta.select();
|
||
document.execCommand("copy");
|
||
document.body.removeChild(ta);
|
||
}
|
||
copyBtn.lastChild.textContent = t('viewer.copied');
|
||
setTimeout(() => (copyBtn.lastChild.textContent = t('viewer.copy')), 1500);
|
||
} catch (err) {
|
||
console.error("Copy error:", err);
|
||
showToast(t("viewer.copy_error"), "error");
|
||
}
|
||
});
|
||
|
||
const sourceBtn = el("button", { class: "btn-action", title: "Voir la source" }, [icon("code", 14), document.createTextNode("Source")]);
|
||
|
||
// Download raw file button (extension shown; .md label for markdown)
|
||
const mdBtn = el("button", { class: "btn-action", title: t("viewer.download_file") || t("viewer.download_md") }, [icon("file-text", 14), document.createTextNode(data.extension || ".md")]);
|
||
mdBtn.addEventListener("click", () => {
|
||
const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`;
|
||
const a = document.createElement("a");
|
||
a.href = dlUrl;
|
||
a.download = data.path.split("/").pop();
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
});
|
||
|
||
// PDF download button
|
||
const pdfBtn = el("button", { class: "btn-action", title: t("viewer.download_pdf") }, [icon("file", 14), document.createTextNode("PDF")]);
|
||
pdfBtn.addEventListener("click", () => {
|
||
const pdfUrl = `/api/file/${encodeURIComponent(data.vault)}/pdf?path=${encodeURIComponent(data.path)}`;
|
||
window.open(pdfUrl, "_blank");
|
||
});
|
||
|
||
// Export dropdown button (HTML / MD bundle / ePub)
|
||
const exportWrap = el("div", { class: "export-dropdown", style: "position:relative;display:inline-block" });
|
||
const exportBtn = el("button", { class: "btn-action", title: t("viewer.export_title") }, [icon("download", 14), document.createTextNode(t("viewer.export"))]);
|
||
const exportMenu = el("div", { class: "export-menu", style: "display:none;position:absolute;right:0;top:100%;z-index:1000;min-width:200px;padding:4px;background:var(--bg,#fff);border:1px solid #ccc;border-radius:6px;box-shadow:0 6px 16px rgba(0,0,0,.18)" });
|
||
const exportOptions = [
|
||
{ format: "html", icon: "file-code", label: t("viewer.export_html"), ext: ".html" },
|
||
{ format: "md-bundle", icon: "archive", label: t("viewer.export_md_bundle"), ext: ".zip" },
|
||
{ format: "epub", icon: "book-open", label: t("viewer.export_epub"), ext: ".epub" },
|
||
];
|
||
exportOptions.forEach((opt) => {
|
||
const item = el("button", { class: "export-menu-item", style: "display:flex;align-items:center;gap:8px;width:100%;padding:8px 10px;border:none;background:none;cursor:pointer;border-radius:4px;text-align:left;font-size:13px" }, [
|
||
icon(opt.icon, 14),
|
||
document.createTextNode(opt.label),
|
||
]);
|
||
item.addEventListener("mouseenter", () => { item.style.background = "rgba(128,128,128,.15)"; });
|
||
item.addEventListener("mouseleave", () => { item.style.background = "none"; });
|
||
item.addEventListener("click", () => {
|
||
exportMenu.style.display = "none";
|
||
const baseName = data.path.split("/").pop().replace(/\.[^.]+$/, "") || "document";
|
||
downloadExport(data.vault, data.path, opt.format, baseName + opt.ext);
|
||
});
|
||
exportMenu.appendChild(item);
|
||
});
|
||
exportBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
exportMenu.style.display = exportMenu.style.display === "block" ? "none" : "block";
|
||
});
|
||
exportWrap.appendChild(exportBtn);
|
||
exportWrap.appendChild(exportMenu);
|
||
|
||
const editBtn = el("button", { class: "btn-action", title: "Editer" }, [icon("edit", 14), document.createTextNode("Editer")]);
|
||
editBtn.addEventListener("click", () => {
|
||
openEditor(data.vault, data.path);
|
||
});
|
||
|
||
const forgeBtn = el("button", { class: "btn-action btn-forge", title: t("viewer.forge_title") }, [icon("hammer", 14), document.createTextNode("Forge")]);
|
||
forgeBtn.addEventListener("click", () => {
|
||
var modal = document.getElementById("editor-modal");
|
||
var bodyEl = document.getElementById("editor-body");
|
||
// Set editor state so parent buttons work
|
||
state.editorVault = data.vault;
|
||
state.editorPath = data.path;
|
||
// #93 — Forge replaces the document read view when this file is the one
|
||
// currently displayed; otherwise it falls back to the overlay.
|
||
var inline = activateInlineEditor(data.vault, data.path);
|
||
if (!inline) {
|
||
// A stale inline session (another document) must release the area first.
|
||
detachInlineEditor();
|
||
state.editorVault = data.vault;
|
||
state.editorPath = data.path;
|
||
}
|
||
// Update header: show "Forge" brand + filename (the header travels with the
|
||
// container in inline mode — query it wherever it lives, #93)
|
||
var brand = queryEditor(".editor-brand");
|
||
var titleInp = document.getElementById("editor-title-input");
|
||
var fileInp = document.getElementById("editor-filename-input");
|
||
if (brand) brand.textContent = t("viewer.forge_brand");
|
||
if (titleInp) { titleInp.value = data.title || ''; titleInp.placeholder = t('forge.title_placeholder'); }
|
||
if (fileInp) fileInp.value = data.path.split('/').pop();
|
||
bodyEl.innerHTML = "";
|
||
bodyEl.style.overflow = "hidden";
|
||
var iframe = document.createElement("iframe");
|
||
iframe.id = "forge-iframe";
|
||
iframe.src = "/editor-poc?vault=" + encodeURIComponent(data.vault) + "&path=" + encodeURIComponent(data.path) + "&_ts=" + Date.now();
|
||
// #101 — allow the Forge editor's own Fullscreen button to work.
|
||
iframe.setAttribute("allow", "fullscreen");
|
||
iframe.setAttribute("allowfullscreen", "");
|
||
// Inline: the iframe fills the content area; overlay: the original 82vh.
|
||
iframe.style.cssText = "width:100%;height:" + (inline ? "100%" : "82vh") + ";border:none;display:block;";
|
||
bodyEl.appendChild(iframe);
|
||
modal.classList.add("active");
|
||
safeCreateIcons();
|
||
});
|
||
|
||
const openNewWindowBtn = el("button", { class: "btn-action", title: t("forge.popout") }, [icon("external-link", 14), document.createTextNode(t("forge.popout_label"))]);
|
||
openNewWindowBtn.addEventListener("click", () => {
|
||
const theme = document.documentElement.getAttribute('data-theme') || 'dark';
|
||
const popoutUrl = `/popout/${encodeURIComponent(data.vault)}/${encodeURIComponent(data.path)}?theme=${theme}`;
|
||
window.open(popoutUrl, `popout_${data.vault}_${data.path.replace(/[^a-zA-Z0-9]/g, "_")}`, "width=1000,height=700,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=no");
|
||
});
|
||
|
||
const tocBtn = el("button", { class: "btn-action", id: "toc-toggle-btn", title: "Afficher/Masquer le sommaire" }, [icon("list", 14), document.createTextNode("TOC")]);
|
||
tocBtn.addEventListener("click", () => {
|
||
RightSidebarManager.toggle();
|
||
});
|
||
|
||
// Share button — check if already shared
|
||
const shareBtn = el("button", { class: "btn-action btn-share", title: "Partager ce document" }, [icon("share-2", 14), document.createTextNode("Partager")]);
|
||
// Check if already shared and color the button
|
||
(async () => {
|
||
try {
|
||
const shares = await api("/api/shares");
|
||
if (shares.some(s => s.vault === data.vault && s.path === data.path)) {
|
||
shareBtn.classList.add("shared");
|
||
shareBtn.title = t('viewer.shared_doc_title');
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
})();
|
||
shareBtn.addEventListener("click", () => openShareDialog(data.vault, data.path));
|
||
|
||
// Bookmark button — check if already bookmarked
|
||
const bookmarkBtn = el("button", { class: "btn-action btn-bookmark", title: t("viewer.bookmark_toggle") }, [icon("bookmark-plus", 14), document.createTextNode("Bookmark")]);
|
||
// Check bookmark status and color the button
|
||
(async () => {
|
||
try {
|
||
const bms = await api("/api/bookmarks");
|
||
if (Array.isArray(bms) && bms.some(b => b.vault === data.vault && b.path === data.path)) {
|
||
bookmarkBtn.classList.add("active");
|
||
bookmarkBtn.title = t("viewer.bookmark_remove");
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
})();
|
||
bookmarkBtn.addEventListener("click", async () => {
|
||
try {
|
||
const res = await api("/api/bookmarks/toggle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ vault: data.vault, path: data.path, title: data.title }) });
|
||
bookmarkBtn.classList.toggle("active", res.bookmarked);
|
||
bookmarkBtn.title = res.bookmarked ? t("viewer.bookmark_remove") : t("viewer.bookmark_add");
|
||
showToast(res.bookmarked ? "Ajouté aux bookmarks" : "Retiré des bookmarks", "success");
|
||
if (typeof DashboardBookmarkWidget !== "undefined") DashboardBookmarkWidget.load();
|
||
} catch (err) { showToast("Erreur: " + err.message, "error"); }
|
||
});
|
||
|
||
// Frontmatter — Accent Card
|
||
let fmSection = null;
|
||
if (data.frontmatter && Object.keys(data.frontmatter).length > 0) {
|
||
fmSection = buildFrontmatterCard(data.frontmatter);
|
||
}
|
||
|
||
// Content container (rendered HTML)
|
||
const mdDiv = el("div", { class: "md-content", id: "file-rendered-content" });
|
||
mdDiv.innerHTML = data.html;
|
||
|
||
// Raw source container (hidden initially)
|
||
const rawDiv = el("div", { class: "raw-source-view", id: "file-raw-content", style: "display:none" });
|
||
|
||
// Source button toggle logic
|
||
sourceBtn.addEventListener("click", async () => {
|
||
const rendered = document.getElementById("file-rendered-content");
|
||
const raw = document.getElementById("file-raw-content");
|
||
if (!rendered || !raw) return;
|
||
|
||
state.showingSource = !state.showingSource;
|
||
if (state.showingSource) {
|
||
sourceBtn.classList.add("active");
|
||
raw.textContent = await getRaw();
|
||
rendered.style.display = "none";
|
||
raw.style.display = "block";
|
||
} else {
|
||
sourceBtn.classList.remove("active");
|
||
rendered.style.display = "block";
|
||
raw.style.display = "none";
|
||
}
|
||
});
|
||
|
||
// Pretty print — mode lecture enrichi (numeros de ligne + coloration)
|
||
// pour les fichiers texte non-markdown (code, JSON, configs, logs...)
|
||
let prettyBtn = null;
|
||
const isTextViewerFile = !data.is_markdown && !data.is_csv && !data.unsupported && !data.is_pdf && !data.is_image && !data.is_excalidraw;
|
||
if (isTextViewerFile) {
|
||
prettyBtn = el("button", { class: "btn-action", title: t("viewer.pretty_title") || "Mode formaté avec couleurs et numéros de ligne" }, [icon("sparkles", 14), document.createTextNode(t("viewer.pretty") || "Pretty")]);
|
||
prettyBtn.addEventListener("click", async () => {
|
||
const on = !prettyBtn.classList.contains("active");
|
||
if (on) {
|
||
let rawText = "";
|
||
try {
|
||
rawText = await getRaw();
|
||
} catch (_) {
|
||
rawText = mdDiv.textContent || "";
|
||
}
|
||
mdDiv.innerHTML = buildPrettyHtml(data, rawText);
|
||
const codeEl = mdDiv.querySelector("code");
|
||
applyPrettyHighlight(codeEl, detectPrettyLang(data), rawText);
|
||
prettyBtn.classList.add("active");
|
||
} else {
|
||
mdDiv.innerHTML = data.html;
|
||
mdDiv.querySelectorAll("pre code").forEach((block) => safeHighlight(block));
|
||
prettyBtn.classList.remove("active");
|
||
}
|
||
});
|
||
}
|
||
|
||
// Separateur visuel entre les groupes d'actions (#90)
|
||
const actionSep = () => el("span", { class: "action-sep" });
|
||
// Ordre des boutons par groupes fonctionnels (#90) :
|
||
// markdown : [TOC][pop-out][Bookmark] | [Editer][Source][.md][Forge] | [Copier][PDF][Export] | [Partager]
|
||
// texte autre : [pop-out][Bookmark] | [Editer][Source][.ext][Pretty] | [Copier] | [Partager]
|
||
// Boutons non applicables au type de fichier (TOC hors markdown, Pretty hors
|
||
// texte, PDF/Export hors markdown, ...) restent ancres a leur groupe ;
|
||
// spacer de groupe vide retire.
|
||
const navBtns = [];
|
||
if (data.is_markdown) navBtns.push(tocBtn);
|
||
navBtns.push(openNewWindowBtn, bookmarkBtn);
|
||
const editBtns = [];
|
||
if (!data.unsupported) editBtns.push(editBtn);
|
||
editBtns.push(sourceBtn);
|
||
if (!data.is_image) editBtns.push(mdBtn);
|
||
if (data.is_markdown) editBtns.push(forgeBtn);
|
||
if (prettyBtn) editBtns.push(prettyBtn);
|
||
const exportBtns = [copyBtn];
|
||
if (data.is_markdown) exportBtns.push(pdfBtn, exportWrap);
|
||
const shareBtns = [shareBtn];
|
||
const fileActions = [];
|
||
[navBtns, editBtns, exportBtns, shareBtns].forEach((group) => {
|
||
if (group.length === 0) return;
|
||
if (fileActions.length > 0) fileActions.push(actionSep());
|
||
group.forEach((b) => fileActions.push(b));
|
||
});
|
||
|
||
// Assemble
|
||
area.innerHTML = "";
|
||
area.appendChild(breadcrumb);
|
||
area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv]));
|
||
// #115 — the action bar is a direct child of the scroll container (wrapped in
|
||
// `.file-toolbar`) so it can stay pinned while long documents scroll; nested
|
||
// inside `.file-header` it would stop sticking as soon as the header left the
|
||
// viewport.
|
||
area.appendChild(el("div", { class: "file-toolbar" }, [el("div", { class: "file-actions" }, fileActions)]));
|
||
if (fmSection) area.appendChild(fmSection);
|
||
area.appendChild(mdDiv);
|
||
area.appendChild(rawDiv);
|
||
|
||
// Backlinks panel
|
||
if (data.is_markdown) {
|
||
renderBacklinksPanel(data.vault, data.path, area);
|
||
}
|
||
|
||
// Highlight code blocks
|
||
area.querySelectorAll("pre code").forEach((block) => {
|
||
safeHighlight(block);
|
||
});
|
||
|
||
// Add copy buttons to code blocks
|
||
area.querySelectorAll("pre").forEach((pre) => {
|
||
if (pre.parentElement?.classList.contains("code-block-wrapper")) return;
|
||
const wrapper = el("div", { class: "code-block-wrapper" }, []);
|
||
wrapper.style.position = "relative";
|
||
pre.replaceWith(wrapper);
|
||
wrapper.appendChild(pre);
|
||
|
||
const btn = el("button", { class: "copy-code-btn", title: "Copier le code" }, [document.createTextNode("Copier")]);
|
||
btn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
const code = pre.querySelector("code")?.textContent || pre.textContent;
|
||
const copied = copyToClipboard(code);
|
||
if (copied) {
|
||
btn.textContent = "✓ Copié";
|
||
btn.classList.add("copied");
|
||
if (typeof showToast === "function") showToast("Code copié dans le presse-papiers", "success");
|
||
setTimeout(() => { btn.textContent = "Copier"; btn.classList.remove("copied"); }, 2000);
|
||
} else {
|
||
if (typeof showToast === "function") showToast("Erreur lors de la copie", "error");
|
||
}
|
||
});
|
||
wrapper.appendChild(btn);
|
||
});
|
||
|
||
// Render Mermaid diagrams
|
||
renderMermaidBlocks(area);
|
||
|
||
// Wire up wikilinks
|
||
area.querySelectorAll(".wikilink").forEach((link) => {
|
||
link.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
const v = link.getAttribute("data-vault");
|
||
const p = link.getAttribute("data-path");
|
||
if (v && p) TabManager.openPreview(v, p);
|
||
});
|
||
});
|
||
|
||
safeCreateIcons();
|
||
area.scrollTop = 0;
|
||
|
||
// Initialize outline/TOC for this document
|
||
OutlineManager.init();
|
||
|
||
// Plugin hooks: onFileRender — allow plugins to transform the rendered
|
||
// content or append custom widgets beneath it. Async + non-blocking so a
|
||
// slow/failed plugin never delays the normal file render.
|
||
if (data.content && data.is_markdown) {
|
||
onFileRender(data.vault, data.content, data.path).then((fr) => {
|
||
if (!fr) return;
|
||
if (typeof fr.replace === 'string' && fr.replace !== data.content) {
|
||
const rendered = document.getElementById('file-rendered-content');
|
||
if (rendered) rendered.innerHTML = fr.replace;
|
||
}
|
||
if (Array.isArray(fr.append) && fr.append.length > 0) {
|
||
const out = el('div', { class: 'plugin-render-output' });
|
||
fr.append.forEach((html) => {
|
||
const w = el('div', { class: 'plugin-widget' });
|
||
w.innerHTML = html;
|
||
out.appendChild(w);
|
||
});
|
||
area.appendChild(out);
|
||
safeCreateIcons();
|
||
}
|
||
}).catch(() => { /* plugins must never break rendering */ });
|
||
}
|
||
}
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers (escapeHtml imported from utils.js)
|
||
// ---------------------------------------------------------------------------
|
||
export function el(tag, attrs, children) {
|
||
const e = document.createElement(tag);
|
||
if (attrs) {
|
||
Object.entries(attrs).forEach(([k, v]) => {
|
||
// Skip boolean false for standard HTML boolean attributes to avoid setAttribute("checked", "false") bug
|
||
if (v === false && (k === "checked" || k === "disabled" || k === "hidden" || k === "required" || k === "readonly")) {
|
||
return;
|
||
}
|
||
e.setAttribute(k, v);
|
||
});
|
||
}
|
||
if (children) {
|
||
children.forEach((c) => {
|
||
if (c) e.appendChild(c);
|
||
});
|
||
}
|
||
return e;
|
||
}
|
||
|
||
export function icon(name, size) {
|
||
const i = document.createElement("i");
|
||
i.setAttribute("data-lucide", name);
|
||
i.style.width = size + "px";
|
||
i.style.height = size + "px";
|
||
i.classList.add("icon");
|
||
return i;
|
||
}
|
||
|
||
export function smallBadge(count) {
|
||
const s = document.createElement("span");
|
||
s.className = "badge-small";
|
||
s.style.cssText = "font-size:0.68rem;color:var(--text-muted);margin-left:4px";
|
||
s.textContent = `(${count})`;
|
||
return s;
|
||
}
|
||
|
||
function getContextMenuPositionFromElement(target) {
|
||
const rect = target.getBoundingClientRect();
|
||
return {
|
||
x: Math.min(rect.right - 8, window.innerWidth - 16),
|
||
y: Math.min(rect.top + rect.height / 2, window.innerHeight - 16),
|
||
};
|
||
}
|
||
|
||
export function attachTreeItemActionButton(itemEl, vault, path, type, isReadonly) {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "tree-item-action-btn";
|
||
button.setAttribute("aria-label", "Afficher le menu d’actions");
|
||
button.setAttribute("title", "Actions");
|
||
const iconEl = icon("more-vertical", 16);
|
||
button.appendChild(iconEl);
|
||
button.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const pos = getContextMenuPositionFromElement(button);
|
||
ContextMenuManager.show(pos.x, pos.y, vault, path, type, isReadonly);
|
||
});
|
||
itemEl.appendChild(button);
|
||
// Ensure Lucide icons are rendered for the button
|
||
setTimeout(() => {
|
||
safeCreateIcons();
|
||
}, 0);
|
||
}
|
||
|
||
export function attachTreeItemLongPress(itemEl, getMenuData) {
|
||
let pressTimer = null;
|
||
let pressHandled = false;
|
||
let startX = 0;
|
||
let startY = 0;
|
||
const longPressDelay = 550;
|
||
const moveThreshold = 10;
|
||
|
||
const clearPressTimer = () => {
|
||
if (pressTimer) {
|
||
clearTimeout(pressTimer);
|
||
pressTimer = null;
|
||
}
|
||
};
|
||
|
||
itemEl.addEventListener("touchstart", (e) => {
|
||
if (!e.touches || e.touches.length !== 1) return;
|
||
pressHandled = false;
|
||
startX = e.touches[0].clientX;
|
||
startY = e.touches[0].clientY;
|
||
clearPressTimer();
|
||
pressTimer = setTimeout(() => {
|
||
const data = getMenuData();
|
||
if (!data) return;
|
||
pressHandled = true;
|
||
ContextMenuManager.show(startX, startY, data.vault, data.path, data.type, data.isReadonly);
|
||
}, longPressDelay);
|
||
}, { passive: true });
|
||
|
||
itemEl.addEventListener("touchmove", (e) => {
|
||
if (!e.touches || e.touches.length !== 1) return;
|
||
const dx = Math.abs(e.touches[0].clientX - startX);
|
||
const dy = Math.abs(e.touches[0].clientY - startY);
|
||
if (dx > moveThreshold || dy > moveThreshold) {
|
||
clearPressTimer();
|
||
}
|
||
}, { passive: true });
|
||
|
||
itemEl.addEventListener("touchend", () => {
|
||
clearPressTimer();
|
||
}, { passive: true });
|
||
|
||
itemEl.addEventListener("touchcancel", () => {
|
||
clearPressTimer();
|
||
}, { passive: true });
|
||
|
||
itemEl.addEventListener("click", (e) => {
|
||
if (pressHandled) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setTimeout(() => {
|
||
pressHandled = false;
|
||
}, 0);
|
||
}
|
||
}, true);
|
||
}
|
||
|
||
export function getVaultIcon(vaultName, size = 16) {
|
||
const v = state.allVaults.find((val) => val.name === vaultName);
|
||
const type = v ? v.type : "VAULT";
|
||
|
||
if (type === "DIR") {
|
||
const i = icon("folder", size);
|
||
i.style.color = "#eab308"; // yellow tint
|
||
return i;
|
||
} else {
|
||
const purple = "#8b5cf6";
|
||
const svgNS = "http://www.w3.org/2000/svg";
|
||
const svg = document.createElementNS(svgNS, "svg");
|
||
svg.setAttribute("xmlns", svgNS);
|
||
svg.setAttribute("width", size);
|
||
svg.setAttribute("height", size);
|
||
svg.setAttribute("viewBox", "0 0 24 24");
|
||
svg.setAttribute("fill", "none");
|
||
svg.setAttribute("stroke", purple);
|
||
svg.setAttribute("stroke-width", "2");
|
||
svg.setAttribute("stroke-linecap", "round");
|
||
svg.setAttribute("stroke-linejoin", "round");
|
||
svg.classList.add("icon");
|
||
|
||
const path1 = document.createElementNS(svgNS, "path");
|
||
path1.setAttribute("d", "M6 3h12l4 6-10 12L2 9z");
|
||
const path2 = document.createElementNS(svgNS, "path");
|
||
path2.setAttribute("d", "M11 3 8 9l4 12");
|
||
const path3 = document.createElementNS(svgNS, "path");
|
||
path3.setAttribute("d", "M12 21l4-12-3-6");
|
||
const path4 = document.createElementNS(svgNS, "path");
|
||
path4.setAttribute("d", "M2 9h20");
|
||
|
||
svg.appendChild(path1);
|
||
svg.appendChild(path2);
|
||
svg.appendChild(path3);
|
||
svg.appendChild(path4);
|
||
return svg;
|
||
}
|
||
}
|
||
|
||
function makeBreadcrumbSpan(text, onClick) {
|
||
const s = document.createElement("span");
|
||
s.textContent = text;
|
||
if (onClick) {
|
||
s.addEventListener("click", async (event) => {
|
||
event.preventDefault();
|
||
if (s.dataset.busy === "true") return;
|
||
s.dataset.busy = "true";
|
||
s.style.pointerEvents = "none";
|
||
try {
|
||
await onClick(event);
|
||
} finally {
|
||
s.dataset.busy = "false";
|
||
s.style.pointerEvents = "";
|
||
}
|
||
});
|
||
}
|
||
return s;
|
||
}
|
||
|
||
export function appendHighlightedText(container, text, query, caseSensitive) {
|
||
container.textContent = "";
|
||
if (!query) {
|
||
container.appendChild(document.createTextNode(text));
|
||
return;
|
||
}
|
||
|
||
const source = caseSensitive ? text : text.toLowerCase();
|
||
const needle = caseSensitive ? query : query.toLowerCase();
|
||
let start = 0;
|
||
let index = source.indexOf(needle, start);
|
||
|
||
if (index === -1) {
|
||
container.appendChild(document.createTextNode(text));
|
||
return;
|
||
}
|
||
|
||
while (index !== -1) {
|
||
if (index > start) {
|
||
container.appendChild(document.createTextNode(text.slice(start, index)));
|
||
}
|
||
const mark = el("mark", { class: "filter-highlight" }, [document.createTextNode(text.slice(index, index + query.length))]);
|
||
container.appendChild(mark);
|
||
start = index + query.length;
|
||
index = source.indexOf(needle, start);
|
||
}
|
||
|
||
if (start < text.length) {
|
||
container.appendChild(document.createTextNode(text.slice(start)));
|
||
}
|
||
}
|
||
|
||
export function highlightSearchText(container, text, query, caseSensitive) {
|
||
container.textContent = "";
|
||
if (!query || !text) {
|
||
container.appendChild(document.createTextNode(text || ""));
|
||
return;
|
||
}
|
||
|
||
const source = caseSensitive ? text : text.toLowerCase();
|
||
const needle = caseSensitive ? query : query.toLowerCase();
|
||
let start = 0;
|
||
let index = source.indexOf(needle, start);
|
||
|
||
if (index === -1) {
|
||
container.appendChild(document.createTextNode(text));
|
||
return;
|
||
}
|
||
|
||
while (index !== -1) {
|
||
if (index > start) {
|
||
container.appendChild(document.createTextNode(text.slice(start, index)));
|
||
}
|
||
const mark = el("mark", { class: "search-highlight" }, [document.createTextNode(text.slice(index, index + query.length))]);
|
||
container.appendChild(mark);
|
||
start = index + query.length;
|
||
index = source.indexOf(needle, start);
|
||
}
|
||
|
||
if (start < text.length) {
|
||
container.appendChild(document.createTextNode(text.slice(start)));
|
||
}
|
||
}
|
||
|
||
export function showWelcome() {
|
||
hideProgressBar();
|
||
|
||
// Restore or rebuild the dashboard with tabbed sections
|
||
const area = getContentArea();
|
||
// #110 — keep playing media alive if the dashboard replaces its surface.
|
||
NowPlaying.handleRender(area, null);
|
||
const home = document.getElementById("dashboard-home");
|
||
|
||
if (area && !home) {
|
||
area.innerHTML = `
|
||
<div id="dashboard-home" class="dashboard-home" role="region" aria-label="Tableau de bord">
|
||
<!-- Dashboard Tabs -->
|
||
<div class="dashboard-tabs">
|
||
<button class="dashboard-tab active" data-tab="stats" data-i18n="dashboard.stats">
|
||
<i data-lucide="bar-chart-3" style="width:14px;height:14px"></i> ${t('dashboard.stats')}
|
||
</button>
|
||
<button class="dashboard-tab" data-tab="bookmarks" data-i18n="dashboard.bookmarks">
|
||
<i data-lucide="bookmark" style="width:14px;height:14px"></i> ${t('dashboard.bookmarks')}
|
||
</button>
|
||
<button class="dashboard-tab" data-tab="recent" data-i18n="dashboard.tab_recent">
|
||
<i data-lucide="clock" style="width:14px;height:14px"></i> ${t('dashboard.tab_recent')}
|
||
</button>
|
||
<button class="dashboard-tab" data-tab="shared" data-i18n="dashboard.tab_shared">
|
||
<i data-lucide="share-2" style="width:14px;height:14px"></i> ${t('dashboard.tab_shared')}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Stats Panel -->
|
||
<div id="dashboard-panel-stats" class="dashboard-panel active">
|
||
<div id="dashboard-stats-grid" class="dashboard-stats-grid">
|
||
<div class="dashboard-stats-loading" data-i18n="common.loading">${t('common.loading')}</div>
|
||
</div>
|
||
<div id="dashboard-conflicts-container" style="margin-top:16px"></div>
|
||
</div>
|
||
|
||
<!-- Bookmarks Panel -->
|
||
<div id="dashboard-panel-bookmarks" class="dashboard-panel">
|
||
<div id="dashboard-bookmarks-grid" class="dashboard-recent-grid"></div>
|
||
<div id="dashboard-bookmarks-empty" class="dashboard-recent-empty">
|
||
<i data-lucide="pin"></i>
|
||
<span data-i18n="dashboard.no_bookmarks">${t('dashboard.no_bookmarks')}</span>
|
||
<p data-i18n="dashboard.pin_hint">${t('dashboard.pin_hint')}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Recent Panel -->
|
||
<div id="dashboard-panel-recent" class="dashboard-panel">
|
||
<div class="dashboard-header">
|
||
<div class="dashboard-title-row">
|
||
<span id="dashboard-count" class="dashboard-badge"></span>
|
||
</div>
|
||
</div>
|
||
<div id="dashboard-recent-grid" class="dashboard-recent-grid"></div>
|
||
<div id="dashboard-loading" class="dashboard-loading">
|
||
<div class="skeleton-card"></div><div class="skeleton-card"></div><div class="skeleton-card"></div>
|
||
<div class="skeleton-card"></div><div class="skeleton-card"></div><div class="skeleton-card"></div>
|
||
</div>
|
||
<div id="dashboard-recent-empty" class="dashboard-recent-empty hidden">
|
||
<i data-lucide="inbox"></i>
|
||
<span data-i18n="dashboard.no_recent">${t('dashboard.no_recent')}</span>
|
||
<p data-i18n="dashboard.recent_hint">${t('dashboard.recent_hint')}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Shared Panel -->
|
||
<div id="dashboard-panel-shared" class="dashboard-panel">
|
||
<div id="dashboard-shared-grid" class="dashboard-recent-grid"></div>
|
||
<div id="dashboard-shared-empty" class="dashboard-recent-empty">
|
||
<i data-lucide="share-2"></i>
|
||
<span data-i18n="dashboard.no_shares">${t('dashboard.no_shares')}</span>
|
||
<p data-i18n="dashboard.shared_hint">${t('dashboard.shared_hint')}</p>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
// Re-initialize widgets and dashboard tabs
|
||
if (typeof DashboardRecentWidget !== "undefined") {
|
||
DashboardRecentWidget.init();
|
||
}
|
||
initDashboardTabs();
|
||
safeCreateIcons();
|
||
} else if (home) {
|
||
// Dashboard already exists, show it with default tab
|
||
home.style.display = "";
|
||
// Reset tabs to default
|
||
document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active"));
|
||
document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active"));
|
||
const defaultTab = document.querySelector('.dashboard-tab[data-tab="stats"]');
|
||
const defaultPanel = document.getElementById("dashboard-panel-stats");
|
||
if (defaultTab) defaultTab.classList.add("active");
|
||
if (defaultPanel) defaultPanel.classList.add("active");
|
||
}
|
||
|
||
// Load all widgets (they handle missing elements gracefully)
|
||
if (typeof DashboardStatsWidget !== "undefined") {
|
||
DashboardStatsWidget.load();
|
||
}
|
||
if (typeof DashboardConflictsWidget !== "undefined") {
|
||
DashboardConflictsWidget.load();
|
||
}
|
||
if (typeof DashboardRecentWidget !== "undefined") {
|
||
DashboardRecentWidget.load(state.selectedContextVault);
|
||
}
|
||
if (typeof DashboardBookmarkWidget !== "undefined") {
|
||
DashboardBookmarkWidget.load(state.selectedContextVault);
|
||
}
|
||
if (typeof DashboardSharedWidget !== "undefined") {
|
||
DashboardSharedWidget.load();
|
||
}
|
||
|
||
// Load saved searches sidebar
|
||
loadSavedSearches();
|
||
}
|
||
|
||
export async function loadSavedSearches() {
|
||
const list = document.getElementById("saved-searches-list");
|
||
const empty = document.getElementById("saved-searches-empty");
|
||
if (!list) return;
|
||
try {
|
||
const searches = await api("/api/saved-searches");
|
||
// Initialise la barre de filtres même quand la liste est vide
|
||
// (sinon les pills n'ont pas de listener au clic).
|
||
_initSavedFilter();
|
||
if (!searches.length) {
|
||
list.innerHTML = "";
|
||
if (empty) empty.style.display = "";
|
||
return;
|
||
}
|
||
if (empty) empty.style.display = "none";
|
||
list.innerHTML = searches.map(s => {
|
||
// Detect directory-based saved search (empty query + include_paths)
|
||
var isDirSearch = (!s.query || s.query.trim() === '') && s.include_paths;
|
||
var displayQuery = isDirSearch ? (s.include_paths || 'Dossier') : escapeHtml(s.query);
|
||
// Icon: folder-search for directories, search for regular queries
|
||
var typeIcon = isDirSearch
|
||
? '<i data-lucide="folder-search" style="width:14px;height:14px;color:var(--accent)" class="saved-search-type-icon"></i>'
|
||
: '<i data-lucide="search" style="width:14px;height:14px;color:var(--text-muted)" class="saved-search-type-icon"></i>';
|
||
const badges = [];
|
||
if (s.case_sensitive) badges.push('<span class="search-filter-badge">Aa</span>');
|
||
if (s.whole_word) badges.push('<span class="search-filter-badge">wd</span>');
|
||
if (s.regex) badges.push('<span class="search-filter-badge">.*</span>');
|
||
const pathFilters = [];
|
||
if (s.include_paths) pathFilters.push(`<span class="saved-search-path" title="Inclure: ${escapeHtml(s.include_paths)}">📥 ${escapeHtml(s.include_paths)}</span>`);
|
||
if (s.exclude_paths) pathFilters.push(`<span class="saved-search-path" title="Exclure: ${escapeHtml(s.exclude_paths)}">📤 ${escapeHtml(s.exclude_paths)}</span>`);
|
||
const vaultStr = s.vault && s.vault !== "all" ? `<span class="saved-search-vault">📁 ${escapeHtml(s.vault)}</span>` : "";
|
||
return `
|
||
<div class="saved-search-item${isDirSearch ? ' saved-search-dir' : ''}" data-type="${isDirSearch ? 'directory' : 'search'}">
|
||
<div class="saved-search-query">${displayQuery}</div>
|
||
<div class="saved-search-meta">
|
||
<span class="saved-search-badges">${badges.join("")} ${vaultStr}</span>
|
||
${typeIcon}
|
||
</div>
|
||
${pathFilters.length ? '<div class="saved-search-filters">' + pathFilters.join(" ") + '</div>' : ""}
|
||
<button class="saved-search-delete" data-id="${s.id}" title="Supprimer">✕</button>
|
||
</div>
|
||
`}).join("");
|
||
// Apply saved filter
|
||
_applySavedFilter();
|
||
// Set up filter bar (once)
|
||
_initSavedFilter();
|
||
list.querySelectorAll(".saved-search-item").forEach(item => {
|
||
item.addEventListener("click", (e) => {
|
||
if (e.target.classList.contains("saved-search-delete")) return;
|
||
const idx = Array.from(list.children).indexOf(item);
|
||
const s = searches[idx];
|
||
if (!s) return;
|
||
// Apply the saved search
|
||
const input = document.getElementById("search-input");
|
||
if (input) input.value = s.query;
|
||
state.searchCaseSensitive = s.case_sensitive || false;
|
||
state.searchWholeWord = s.whole_word || false;
|
||
state.searchRegex = s.regex || false;
|
||
if (typeof _updateToggleUI === "function") _updateToggleUI();
|
||
if (s.include_paths) {
|
||
const incl = document.getElementById("search-include-input");
|
||
if (incl) incl.value = s.include_paths;
|
||
}
|
||
if (s.exclude_paths) {
|
||
const excl = document.getElementById("search-exclude-input");
|
||
if (excl) excl.value = s.exclude_paths;
|
||
}
|
||
// Execute the search — suppress dropdown from appearing
|
||
AutocompleteDropdown.hide();
|
||
AutocompleteDropdown._suppressNext = true;
|
||
const vault = s.vault || "all";
|
||
if (input) { input.dispatchEvent(new Event("input")); }
|
||
clearTimeout(state.searchTimeout);
|
||
state.advancedSearchOffset = 0;
|
||
performAdvancedSearch(s.query, vault, null);
|
||
});
|
||
});
|
||
list.querySelectorAll(".saved-search-delete").forEach(b => b.addEventListener("click", async (e) => {
|
||
e.stopPropagation();
|
||
await api(`/api/saved-searches/${b.dataset.id}`, { method: "DELETE" });
|
||
loadSavedSearches();
|
||
}));
|
||
safeCreateIcons();
|
||
} catch (err) { /* silently ignore */ }
|
||
}
|
||
|
||
export function showLoading() {
|
||
const area = getContentArea();
|
||
area.innerHTML = `
|
||
<div class="loading-indicator">
|
||
<div class="loading-spinner"></div>
|
||
<div>Recherche en cours...</div>
|
||
</div>`;
|
||
showProgressBar();
|
||
}
|
||
|
||
export function showProgressBar() {
|
||
const bar = document.getElementById("search-progress-bar");
|
||
if (bar) bar.classList.add("active");
|
||
}
|
||
|
||
export function hideProgressBar() {
|
||
const bar = document.getElementById("search-progress-bar");
|
||
if (bar) bar.classList.remove("active");
|
||
}
|
||
|
||
function goHome() {
|
||
const searchInput = document.getElementById("search-input");
|
||
if (searchInput) searchInput.value = "";
|
||
|
||
document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active"));
|
||
|
||
state.currentVault = null;
|
||
state.currentPath = null;
|
||
state.showingSource = false;
|
||
state.cachedRawSource = null;
|
||
|
||
closeMobileSidebar();
|
||
showWelcome();
|
||
}
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SSE Client — IndexUpdateManager
|
||
// ---------------------------------------------------------------------------
|
||
const IndexUpdateManager = (() => {
|
||
let eventSource = null;
|
||
let reconnectTimer = null;
|
||
let reconnectDelay = 1000;
|
||
const MAX_RECONNECT_DELAY = 30000;
|
||
let recentEvents = [];
|
||
const MAX_RECENT_EVENTS = 20;
|
||
let connectionState = "disconnected"; // disconnected | connecting | connected
|
||
|
||
function connect() {
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
}
|
||
connectionState = "connecting";
|
||
_updateBadge();
|
||
|
||
eventSource = new EventSource("/api/events");
|
||
|
||
eventSource.addEventListener("connected", (e) => {
|
||
connectionState = "connected";
|
||
reconnectDelay = 1000;
|
||
_updateBadge();
|
||
});
|
||
|
||
eventSource.addEventListener("index_updated", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("index_updated", data);
|
||
_onIndexUpdated(data);
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.addEventListener("index_reloaded", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("index_reloaded", data);
|
||
_onIndexReloaded(data);
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.addEventListener("vault_added", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("vault_added", data);
|
||
showToast(`Vault "${data.vault}" ajouté (${data.stats.file_count} fichiers)`, "info");
|
||
loadVaults();
|
||
loadTags();
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.addEventListener("vault_removed", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("vault_removed", data);
|
||
showToast(`Vault "${data.vault}" supprimé`, "info");
|
||
loadVaults();
|
||
loadTags();
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.addEventListener("index_start", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("index_start", data);
|
||
connectionState = "syncing";
|
||
_updateBadge();
|
||
showToast(`Indexation démarrée (${data.total_vaults} vaults)`, "info");
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.addEventListener("index_progress", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("index_progress", data);
|
||
connectionState = "syncing";
|
||
_updateBadge();
|
||
loadVaults();
|
||
loadTags();
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.addEventListener("index_complete", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("index_complete", data);
|
||
connectionState = "connected";
|
||
_updateBadge();
|
||
showToast(`Indexation terminée (${data.total_files} fichiers)`, "success");
|
||
loadVaults();
|
||
loadTags();
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.onerror = () => {
|
||
connectionState = "disconnected";
|
||
_updateBadge();
|
||
eventSource.close();
|
||
eventSource = null;
|
||
_scheduleReconnect();
|
||
};
|
||
}
|
||
|
||
function _scheduleReconnect() {
|
||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||
reconnectTimer = setTimeout(() => {
|
||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
||
connect();
|
||
}, reconnectDelay);
|
||
}
|
||
|
||
function _addEvent(type, data) {
|
||
recentEvents.unshift({
|
||
type,
|
||
data,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
if (recentEvents.length > MAX_RECENT_EVENTS) {
|
||
recentEvents = recentEvents.slice(0, MAX_RECENT_EVENTS);
|
||
}
|
||
}
|
||
|
||
async function _onIndexUpdated(data) {
|
||
// Brief syncing state
|
||
connectionState = "syncing";
|
||
_updateBadge();
|
||
|
||
const n = data.total_changes || 0;
|
||
const vaults = (data.vaults || []).join(", ");
|
||
// Toast removed: silent auto-indexing — no notification needed
|
||
|
||
// Refresh sidebar and tags if affected vault matches current context
|
||
const affectsCurrentVault = state.selectedContextVault === "all" || (data.vaults || []).includes(state.selectedContextVault);
|
||
if (affectsCurrentVault) {
|
||
try {
|
||
await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]);
|
||
// Refresh current file if it was updated
|
||
if (state.currentVault && state.currentPath) {
|
||
const changed = (data.changes || []).some((c) => c.vault === state.currentVault && c.path === state.currentPath);
|
||
if (changed) {
|
||
openFile(state.currentVault, state.currentPath);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error("Error refreshing after index update:", err);
|
||
}
|
||
}
|
||
|
||
// Refresh recent tab if it is active
|
||
if (state.activeSidebarTab === "recent") {
|
||
const vaultFilter = document.getElementById("recent-vault-filter");
|
||
loadRecentFiles(vaultFilter ? vaultFilter.value || null : null);
|
||
}
|
||
|
||
setTimeout(() => {
|
||
connectionState = "connected";
|
||
_updateBadge();
|
||
}, 1500);
|
||
}
|
||
|
||
async function _onIndexReloaded(data) {
|
||
connectionState = "syncing";
|
||
_updateBadge();
|
||
showToast(t("sync.index_reloaded"), "info");
|
||
try {
|
||
await Promise.all([loadVaults(), loadTags()]);
|
||
} catch (err) {
|
||
console.error("Error refreshing after full reload:", err);
|
||
}
|
||
setTimeout(() => {
|
||
connectionState = "connected";
|
||
_updateBadge();
|
||
}, 1500);
|
||
}
|
||
|
||
function _updateBadge() {
|
||
const badge = document.getElementById("sync-badge");
|
||
if (!badge) return;
|
||
badge.className = "sync-badge sync-badge--" + connectionState;
|
||
const labels = {
|
||
disconnected: "Déconnecté",
|
||
connecting: "Connexion...",
|
||
connected: "Synchronisé",
|
||
syncing: t("viewer.syncing"),
|
||
};
|
||
badge.title = labels[connectionState] || connectionState;
|
||
}
|
||
|
||
function disconnect() {
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
eventSource = null;
|
||
}
|
||
if (reconnectTimer) {
|
||
clearTimeout(reconnectTimer);
|
||
reconnectTimer = null;
|
||
}
|
||
connectionState = "disconnected";
|
||
_updateBadge();
|
||
}
|
||
|
||
function getState() {
|
||
return connectionState;
|
||
}
|
||
|
||
function getRecentEvents() {
|
||
return recentEvents;
|
||
}
|
||
|
||
return { connect, disconnect, getState, getRecentEvents };
|
||
})();
|
||
|
||
function initSyncStatus() {
|
||
const badge = document.getElementById("sync-badge");
|
||
if (!badge) return;
|
||
|
||
badge.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
toggleSyncPanel();
|
||
});
|
||
|
||
IndexUpdateManager.connect();
|
||
}
|
||
|
||
function toggleSyncPanel() {
|
||
let panel = document.getElementById("sync-panel");
|
||
if (panel) {
|
||
panel.remove();
|
||
return;
|
||
}
|
||
// Auto reconnect if disconnected when user opens the panel
|
||
if (IndexUpdateManager.getState() === "disconnected") {
|
||
IndexUpdateManager.connect();
|
||
}
|
||
panel = document.createElement("div");
|
||
panel.id = "sync-panel";
|
||
panel.className = "sync-panel";
|
||
_renderSyncPanel(panel);
|
||
document.body.appendChild(panel);
|
||
|
||
// Close on outside click
|
||
setTimeout(() => {
|
||
document.addEventListener("click", _closeSyncPanelOutside, { once: true });
|
||
}, 0);
|
||
}
|
||
|
||
function _closeSyncPanelOutside(e) {
|
||
const panel = document.getElementById("sync-panel");
|
||
if (panel && !panel.contains(e.target) && e.target.id !== "sync-badge") {
|
||
panel.remove();
|
||
}
|
||
}
|
||
|
||
function _renderSyncPanel(panel) {
|
||
const state = IndexUpdateManager.getState();
|
||
const events = IndexUpdateManager.getRecentEvents();
|
||
|
||
const stateLabels = {
|
||
disconnected: "Déconnecté",
|
||
connecting: "Connexion...",
|
||
connected: "Connecté",
|
||
syncing: "Synchronisation...",
|
||
};
|
||
|
||
let html = `<div class="sync-panel__header">
|
||
<span class="sync-panel__title">Synchronisation</span>
|
||
<span class="sync-panel__state sync-panel__state--${state}">${stateLabels[state] || state}</span>
|
||
</div>`;
|
||
|
||
if (events.length === 0) {
|
||
html += `<div class="sync-panel__empty">Aucun événement récent</div>`;
|
||
} else {
|
||
html += `<div class="sync-panel__events">`;
|
||
events.slice(0, 10).forEach((ev) => {
|
||
const time = new Date(ev.timestamp).toLocaleTimeString();
|
||
const typeLabels = {
|
||
index_updated: t("viewer.index_updated"),
|
||
index_reloaded: "Rechargement",
|
||
vault_added: t("sync.vault_added_label"),
|
||
vault_removed: t("sync.vault_removed_label"),
|
||
index_start: t("viewer.index_start"),
|
||
index_progress: t("sync.vault_indexed_label"),
|
||
index_complete: "Indexation tech.",
|
||
};
|
||
const label = typeLabels[ev.type] || ev.type;
|
||
let detail = ev.data.vaults ? ev.data.vaults.join(", ") : ev.data.vault || "";
|
||
if (ev.type === "index_start") detail = `${ev.data.total_vaults} vaults à traiter`;
|
||
if (ev.type === "index_progress") detail = `${ev.data.vault} (${ev.data.files} fichiers)`;
|
||
if (ev.type === "index_complete" && ev.data.total_files !== undefined) detail = `${ev.data.total_files} fichiers total`;
|
||
html += `<div class="sync-panel__event">
|
||
<span class="sync-panel__event-type">${label}</span>
|
||
<span class="sync-panel__event-detail">${detail}</span>
|
||
<span class="sync-panel__event-time">${time}</span>
|
||
</div>`;
|
||
});
|
||
html += `</div>`;
|
||
}
|
||
}
|
||
|
||
// ── Saved searches filter ──
|
||
var _savedFilterType = 'all';
|
||
var _savedFilterInitDone = false;
|
||
var _savedQuery = '';
|
||
|
||
function _savedNorm(value) {
|
||
return String(value || '')
|
||
.normalize('NFD')
|
||
.replace(/[\u0300-\u036f]/g, '')
|
||
.toLowerCase();
|
||
}
|
||
|
||
function _initSavedFilter() {
|
||
if (_savedFilterInitDone) return;
|
||
var bar = document.getElementById('saved-filter-bar');
|
||
if (!bar) return;
|
||
bar.querySelectorAll('.saved-filter-pill').forEach(function(btn) {
|
||
btn.addEventListener('click', function() {
|
||
bar.querySelectorAll('.saved-filter-pill').forEach(function(b) { b.classList.remove('active'); });
|
||
btn.classList.add('active');
|
||
_savedFilterType = btn.dataset.filter;
|
||
_applySavedFilter();
|
||
});
|
||
});
|
||
_savedFilterInitDone = true;
|
||
}
|
||
|
||
/** #99 — Filter the saved-searches list by the global sidebar filter input. */
|
||
export function filterSavedSearches(query) {
|
||
_savedQuery = (query || '').trim();
|
||
_applySavedFilter();
|
||
}
|
||
|
||
function _applySavedFilter() {
|
||
var items = document.querySelectorAll('.saved-search-item');
|
||
var list = document.getElementById('saved-searches-list');
|
||
var q = _savedNorm(_savedQuery);
|
||
var hasVisible = false;
|
||
items.forEach(function(item) {
|
||
var type = item.dataset.type;
|
||
var typeOk = _savedFilterType === 'all' || type === _savedFilterType;
|
||
var textOk = !q || _savedNorm(item.textContent).includes(q);
|
||
var show = typeOk && textOk;
|
||
item.style.display = show ? '' : 'none';
|
||
if (show) hasVisible = true;
|
||
});
|
||
// Remove a previous "no match" hint before deciding whether to add one.
|
||
if (list) {
|
||
var prev = list.querySelector('.sidebar-filter-empty');
|
||
if (prev) prev.remove();
|
||
}
|
||
// Show/hide empty state
|
||
var empty = document.getElementById('saved-searches-empty');
|
||
if (empty) empty.style.display = hasVisible ? 'none' : '';
|
||
// Explain an empty result caused by the global search rather than by an
|
||
// actually empty saved-searches list.
|
||
if (!hasVisible && q && list && items.length) {
|
||
var hint = document.createElement('div');
|
||
hint.className = 'sidebar-filter-empty';
|
||
hint.textContent = t('sidebar.no_results');
|
||
list.appendChild(hint);
|
||
}
|
||
}
|
||
|
||
|