1256 lines
40 KiB
JavaScript
1256 lines
40 KiB
JavaScript
/* ObsiGate — Viewer module: Outline, ScrollSpy, ReadingProgress, file viewer, frontmatter card, editor init */
|
|
import { state } from './state.js';
|
|
import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon } from "./utils.js";
|
|
|
|
// initEditor is defined in utils.js — re-exported below.
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 = document.getElementById("content-area");
|
|
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 = document.getElementById("content-area");
|
|
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 = document.getElementById("content-area");
|
|
if (!contentArea) return;
|
|
|
|
this.scrollHandler = this.throttle(() => {
|
|
this.updateProgress();
|
|
}, 100);
|
|
|
|
contentArea.addEventListener("scroll", this.scrollHandler);
|
|
this.updateProgress();
|
|
},
|
|
|
|
updateProgress() {
|
|
const contentArea = document.getElementById("content-area");
|
|
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 = document.getElementById("content-area");
|
|
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
|
|
// ---------------------------------------------------------------------------
|
|
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 = document.getElementById("content-area");
|
|
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);
|
|
renderFile(data);
|
|
} catch (err) {
|
|
area.innerHTML = '<div class="welcome"><p style="color:var(--text-muted)">Impossible de charger le fichier.</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);
|
|
}
|
|
}
|
|
|
|
function renderFile(data) {
|
|
const area = document.getElementById("content-area");
|
|
|
|
// 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>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
|
|
const copyBtn = el("button", { class: "btn-action", title: "Copier la source" }, [icon("copy", 14), document.createTextNode("Copier")]);
|
|
copyBtn.addEventListener("click", async () => {
|
|
try {
|
|
// Fetch raw content if not already cached
|
|
if (!state.cachedRawSource) {
|
|
const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;
|
|
const rawData = await api(rawUrl);
|
|
state.cachedRawSource = rawData.raw;
|
|
}
|
|
await navigator.clipboard.writeText(state.cachedRawSource);
|
|
copyBtn.lastChild.textContent = "Copié !";
|
|
setTimeout(() => (copyBtn.lastChild.textContent = "Copier"), 1500);
|
|
} catch (err) {
|
|
console.error("Copy error:", err);
|
|
showToast("Erreur lors de la copie", "error");
|
|
}
|
|
});
|
|
|
|
const sourceBtn = el("button", { class: "btn-action", title: "Voir la source" }, [icon("code", 14), document.createTextNode("Source")]);
|
|
|
|
// MD download button
|
|
const mdBtn = el("button", { class: "btn-action", title: "Télécharger en .md" }, [icon("file-text", 14), document.createTextNode(".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élécharger en 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");
|
|
});
|
|
|
|
const editBtn = el("button", { class: "btn-action", title: "Éditer" }, [icon("edit", 14), document.createTextNode("Éditer")]);
|
|
editBtn.addEventListener("click", () => {
|
|
openEditor(data.vault, data.path);
|
|
});
|
|
|
|
const openNewWindowBtn = el("button", { class: "btn-action", title: "Ouvrir dans une nouvelle fenêtre" }, [icon("external-link", 14), document.createTextNode("pop-out")]);
|
|
openNewWindowBtn.addEventListener("click", () => {
|
|
const popoutUrl = `/popout/${encodeURIComponent(data.vault)}/${encodeURIComponent(data.path)}`;
|
|
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 = "Document partagé — cliquer pour gérer";
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
})();
|
|
shareBtn.addEventListener("click",
|
|
|
|
... [OUTPUT TRUNCATED - 13907 chars omitted out of 63907 total] ...
|
|
|
|
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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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)));
|
|
}
|
|
}
|
|
|
|
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)));
|
|
}
|
|
}
|
|
|
|
function showWelcome() {
|
|
hideProgressBar();
|
|
|
|
// Restore or rebuild the dashboard with tabbed sections
|
|
const area = document.getElementById("content-area");
|
|
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">
|
|
<i data-lucide="bar-chart-3" style="width:14px;height:14px"></i> Statistiques
|
|
</button>
|
|
<button class="dashboard-tab" data-tab="bookmarks">
|
|
<i data-lucide="bookmark" style="width:14px;height:14px"></i> Bookmarks
|
|
</button>
|
|
<button class="dashboard-tab" data-tab="recent">
|
|
<i data-lucide="clock" style="width:14px;height:14px"></i> Récents
|
|
</button>
|
|
<button class="dashboard-tab" data-tab="shared">
|
|
<i data-lucide="share-2" style="width:14px;height:14px"></i> Partagés
|
|
</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">Chargement...</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>Aucun bookmark</span>
|
|
<p>Épinglez des fichiers pour les retrouver ici.</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>Aucun fichier récent</span>
|
|
<p>Ouvrez un fichier pour le voir apparaître ici</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>Aucun document partagé</span>
|
|
<p>Partagez un document pour le voir apparaître ici</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();
|
|
}
|
|
|
|
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");
|
|
if (!searches.length) {
|
|
list.innerHTML = "";
|
|
if (empty) empty.style.display = "";
|
|
return;
|
|
}
|
|
if (empty) empty.style.display = "none";
|
|
list.innerHTML = searches.map(s => {
|
|
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">
|
|
<div class="saved-search-query">${escapeHtml(s.query)}</div>
|
|
<div class="saved-search-meta">
|
|
${badges.join("")}
|
|
${vaultStr}
|
|
</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("");
|
|
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 */ }
|
|
}
|
|
|
|
function showLoading() {
|
|
const area = document.getElementById("content-area");
|
|
area.innerHTML = `
|
|
<div class="loading-indicator">
|
|
<div class="loading-spinner"></div>
|
|
<div>Recherche en cours...</div>
|
|
</div>`;
|
|
showProgressBar();
|
|
}
|
|
|
|
function showProgressBar() {
|
|
const bar = document.getElementById("search-progress-bar");
|
|
if (bar) bar.classList.add("active");
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
|
|
// initEditor wires up the editor modal — editor functions (openEditor, closeEditor, saveFile, deleteFile) are in utils.js
|
|
function initEditor() {
|
|
const cancelBtn = document.getElementById("editor-cancel");
|
|
const deleteBtn = document.getElementById("editor-delete");
|
|
const saveBtn = document.getElementById("editor-save");
|
|
const modal = document.getElementById("editor-modal");
|
|
|
|
cancelBtn.addEventListener("click", closeEditor);
|
|
deleteBtn.addEventListener("click", deleteFile);
|
|
saveBtn.addEventListener("click", saveFile);
|
|
|
|
// Close on overlay click
|
|
modal.addEventListener("click", (e) => {
|
|
if (e.target === modal) {
|
|
closeEditor();
|
|
}
|
|
});
|
|
|
|
// ESC to close
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape" && modal.classList.contains("active")) {
|
|
closeEditor();
|
|
}
|
|
});
|
|
|
|
// Fix mouse wheel scrolling in editor
|
|
modal.addEventListener(
|
|
"wheel",
|
|
(e) => {
|
|
const editorBody = document.getElementById("editor-body");
|
|
if (editorBody && editorBody.contains(e.target)) {
|
|
// Let the editor handle the scroll
|
|
return;
|
|
}
|
|
// Prevent modal from scrolling if not in editor area
|
|
e.preventDefault();
|
|
},
|
|
{ passive: false },
|
|
);
|
|
}
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 (currentVault && state.currentPath) {
|
|
const changed = (data.changes || []).some((c) => c.vault === 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("Index complet rechargé", "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: "Mise à jour...",
|
|
};
|
|
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: "Mise à jour",
|
|
index_reloaded: "Rechargement",
|
|
vault_added: "Vault ajouté",
|
|
vault_removed: "Vault supprimé",
|
|
index_start: "Démarrage index.",
|
|
index_progress: "Vault indexé",
|
|
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>`;
|
|
}
|
|
|
|
panel.innerHTML = html;
|
|
}
|
|
|
|
export { OutlineManager, ScrollSpyManager, ReadingProgressManager, openFile, buildFrontmatterCard, initEditor };
|
|
|