refactor: migration architecture clean (Rust + JS)
Côté JS : décompose main.js en modules ES6 (state, services, ui, router) pour séparer les responsabilités et faciliter la maintenance. Côté Rust : ajoute 7 tests sur is_relevant_path (notify_watcher) pour couvrir les cas cachés/non-md ; couverture totale 77 % > seuil 60 %. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -64,3 +64,44 @@ fn is_relevant_path(path: &Path) -> bool {
|
|||||||
}
|
}
|
||||||
path.extension().is_some_and(|e| e == "md")
|
path.extension().is_some_and(|e| e == "md")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relevant_path_md_file() {
|
||||||
|
assert!(is_relevant_path(Path::new("/docs/notes.md")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relevant_path_nested_md() {
|
||||||
|
assert!(is_relevant_path(Path::new("/wiki/section/page.md")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn irrelevant_path_txt_extension() {
|
||||||
|
assert!(!is_relevant_path(Path::new("/docs/readme.txt")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn irrelevant_path_no_extension() {
|
||||||
|
assert!(!is_relevant_path(Path::new("/docs/makefile")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn irrelevant_path_hidden_file() {
|
||||||
|
assert!(!is_relevant_path(Path::new("/docs/.hidden.md")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn irrelevant_path_hidden_dir() {
|
||||||
|
assert!(!is_relevant_path(Path::new("/docs/.git/file.md")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn irrelevant_path_hidden_dir_at_root() {
|
||||||
|
assert!(!is_relevant_path(Path::new(".hidden/file.md")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+5
-313
@@ -1,7 +1,6 @@
|
|||||||
let currentPath = null;
|
import { showHome, openPath } from './router.js';
|
||||||
let currentMode = null; // 'file' | 'dir'
|
import { initCssModal } from './ui/css-modal.js';
|
||||||
|
|
||||||
const titlebarTitle = document.getElementById('titlebar-title');
|
|
||||||
const win = window.__TAURI__.window.getCurrentWindow();
|
const win = window.__TAURI__.window.getCurrentWindow();
|
||||||
|
|
||||||
document.getElementById('titlebar').addEventListener('mousedown', (e) => {
|
document.getElementById('titlebar').addEventListener('mousedown', (e) => {
|
||||||
@@ -16,239 +15,6 @@ document.getElementById('titlebar-maximize').addEventListener('click', async ()
|
|||||||
});
|
});
|
||||||
document.getElementById('titlebar-close').addEventListener('click', () => win.close());
|
document.getElementById('titlebar-close').addEventListener('click', () => win.close());
|
||||||
|
|
||||||
const viewHome = document.getElementById('view-home');
|
|
||||||
const viewReader = document.getElementById('view-reader');
|
|
||||||
const sidebar = document.querySelector('#sidebar');
|
|
||||||
const content = document.querySelector('#content');
|
|
||||||
|
|
||||||
const RECENTS_KEY = 'pena_recents';
|
|
||||||
const RECENTS_MAX = 8;
|
|
||||||
|
|
||||||
function loadRecents() {
|
|
||||||
try { return JSON.parse(localStorage.getItem(RECENTS_KEY) ?? '[]'); }
|
|
||||||
catch { return []; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveRecent(path, mode) {
|
|
||||||
const name = path.replace(/\\/g, '/').split('/').pop().replace(/\.md$/i, '');
|
|
||||||
const parent = path.replace(/\\/g, '/').split('/').slice(0, -1).join('/');
|
|
||||||
const recents = loadRecents().filter(r => r.path !== path);
|
|
||||||
recents.unshift({ path, mode, name, parent });
|
|
||||||
localStorage.setItem(RECENTS_KEY, JSON.stringify(recents.slice(0, RECENTS_MAX)));
|
|
||||||
renderRecents();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRecents() {
|
|
||||||
const recents = loadRecents();
|
|
||||||
const container = document.getElementById('home-recents');
|
|
||||||
const list = document.getElementById('home-recents-list');
|
|
||||||
if (recents.length === 0) {
|
|
||||||
container.classList.add('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
container.classList.remove('hidden');
|
|
||||||
list.innerHTML = recents.map(r => {
|
|
||||||
const icon = r.mode === 'dir' ? '📁' : '📄';
|
|
||||||
const escaped = escapeHtml(r.path);
|
|
||||||
return `<li>
|
|
||||||
<button class="home-recents__item" data-path="${escaped}" data-mode="${r.mode}">
|
|
||||||
<span class="home-recents__item__icon">${icon}</span>
|
|
||||||
<span class="home-recents__item__info">
|
|
||||||
<span class="home-recents__item__name">${escapeHtml(r.name)}</span>
|
|
||||||
<span class="home-recents__item__path">${escapeHtml(r.parent)}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>`;
|
|
||||||
}).join('');
|
|
||||||
list.querySelectorAll('.home-recents__item').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => openPath(btn.dataset.path, btn.dataset.mode));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function showHome() {
|
|
||||||
viewReader.classList.add('hidden');
|
|
||||||
viewHome.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function showReader() {
|
|
||||||
viewHome.classList.add('hidden');
|
|
||||||
viewReader.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(str) {
|
|
||||||
return String(str)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTree(relPaths, sep) {
|
|
||||||
const tree = { _files: [], _dirs: {} };
|
|
||||||
relPaths.forEach((rel) => {
|
|
||||||
const parts = rel.split(sep);
|
|
||||||
let node = tree;
|
|
||||||
for (let i = 0; i < parts.length - 1; i++) {
|
|
||||||
const dir = parts[i];
|
|
||||||
if (!node._dirs[dir]) node._dirs[dir] = { _files: [], _dirs: {} };
|
|
||||||
node = node._dirs[dir];
|
|
||||||
}
|
|
||||||
node._files.push(rel);
|
|
||||||
});
|
|
||||||
return tree;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTree(node, prefix, sep, currentRel, depth) {
|
|
||||||
let html = '';
|
|
||||||
const depthClass = depth > 0 ? ` class="nav-depth-${Math.min(depth, 3)}"` : '';
|
|
||||||
|
|
||||||
node._files.slice().sort().forEach((rel) => {
|
|
||||||
const label = rel.split(sep).pop().replace(/\.md$/i, '').replace(/-/g, ' ');
|
|
||||||
const abs = prefix + rel;
|
|
||||||
const cls = rel === currentRel ? ' active' : '';
|
|
||||||
if (depth === 0) {
|
|
||||||
html += `\n<li><a href="#" class="nav-root-link${cls}" data-path="${escapeHtml(abs)}">${escapeHtml(label)}</a></li>`;
|
|
||||||
} else {
|
|
||||||
html += `\n<li${depthClass}><a href="#" class="nav-file-link${cls}" data-path="${escapeHtml(abs)}">${escapeHtml(label)}</a></li>`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.keys(node._dirs).sort().forEach((dirName) => {
|
|
||||||
const child = node._dirs[dirName];
|
|
||||||
const isOpen = currentRel && currentRel.split(sep).includes(dirName);
|
|
||||||
const openAttr = isOpen ? ' open' : '';
|
|
||||||
|
|
||||||
const homeFile = child._files.find((f) => /^home\.md$/i.test(f.split(sep).pop()));
|
|
||||||
let folderLabel;
|
|
||||||
if (homeFile) {
|
|
||||||
const abs = prefix + homeFile;
|
|
||||||
const cls = homeFile === currentRel ? ' active' : '';
|
|
||||||
folderLabel = `<a href="#" class="nav-folder-link${cls}" data-path="${escapeHtml(abs)}" onclick="event.stopPropagation()">${escapeHtml(dirName)}</a>`;
|
|
||||||
} else {
|
|
||||||
folderLabel = `<span class="nav-folder-name">${escapeHtml(dirName)}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const arrow = `<span class="nav-arrow" onclick="event.preventDefault();event.stopPropagation();var d=this.closest('details');d.open=!d.open">▶</span>`;
|
|
||||||
|
|
||||||
const childNode = {
|
|
||||||
_files: homeFile ? child._files.filter((f) => f !== homeFile) : child._files,
|
|
||||||
_dirs: child._dirs,
|
|
||||||
};
|
|
||||||
|
|
||||||
html += `\n<li${depthClass}><details class="nav-folder"${openAttr}>`;
|
|
||||||
html += `\n <summary>${arrow}${folderLabel}</summary>`;
|
|
||||||
html += `\n <ul class="nav-tree">`;
|
|
||||||
html += renderTree(childNode, prefix, sep, currentRel, depth + 1);
|
|
||||||
html += `\n </ul>\n</details></li>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSidebar(baseDir, files, currentFile) {
|
|
||||||
const sep = baseDir.includes('\\') ? '\\' : '/';
|
|
||||||
const dirName = baseDir.split(sep).pop();
|
|
||||||
const prefix = baseDir.endsWith(sep) ? baseDir : baseDir + sep;
|
|
||||||
const toRel = abs => abs.startsWith(prefix) ? abs.slice(prefix.length) : abs;
|
|
||||||
const currentRel = currentFile ? toRel(currentFile) : null;
|
|
||||||
const relPaths = files.map(toRel);
|
|
||||||
const tree = buildTree(relPaths, sep);
|
|
||||||
|
|
||||||
let html = `<div class="sidebar__title-block">
|
|
||||||
<h1 class="sidebar__title-block__title">${escapeHtml(dirName)}</h1>
|
|
||||||
</div>
|
|
||||||
<ul class="sidebar__menu nav-tree">`;
|
|
||||||
html += renderTree(tree, prefix, sep, currentRel, 0);
|
|
||||||
html += `\n </ul>`;
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSidebar(baseDir, files, currentFile) {
|
|
||||||
sidebar.innerHTML = buildSidebar(baseDir, files, currentFile);
|
|
||||||
sidebar.querySelectorAll('a[data-path]').forEach(a => {
|
|
||||||
a.addEventListener('click', e => {
|
|
||||||
e.preventDefault();
|
|
||||||
loadPage(a.dataset.path);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPage(filePath) {
|
|
||||||
try {
|
|
||||||
const html = await window.__TAURI__.core.invoke('convert_file', { path: filePath });
|
|
||||||
content.innerHTML = html;
|
|
||||||
|
|
||||||
const basename = filePath.split('/').pop().split('\\').pop();
|
|
||||||
const title = basename.replace(/\.md$/i, '');
|
|
||||||
await win.setTitle(title);
|
|
||||||
titlebarTitle.textContent = title;
|
|
||||||
|
|
||||||
if (currentMode === 'dir' || currentMode === 'file') {
|
|
||||||
sidebar.querySelectorAll('a').forEach(a => {
|
|
||||||
a.classList.toggle('active', a.dataset.path === filePath);
|
|
||||||
});
|
|
||||||
const active = sidebar.querySelector('a[data-path].active');
|
|
||||||
if (active) {
|
|
||||||
let el = active.parentElement;
|
|
||||||
while (el && el !== sidebar) {
|
|
||||||
if (el.tagName === 'DETAILS') el.open = true;
|
|
||||||
el = el.parentElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
content.innerHTML = `<p class="error">Impossible de charger le fichier : ${err}</p>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openPath(path, mode) {
|
|
||||||
currentPath = path;
|
|
||||||
currentMode = mode;
|
|
||||||
saveRecent(path, mode);
|
|
||||||
|
|
||||||
if (mode === 'file') {
|
|
||||||
showReader();
|
|
||||||
const sep = path.includes('\\') ? '\\' : '/';
|
|
||||||
const parentDir = path.split(sep).slice(0, -1).join(sep);
|
|
||||||
try {
|
|
||||||
const files = await window.__TAURI__.core.invoke('list_md_files', { dir: parentDir });
|
|
||||||
sidebar.classList.remove('hidden');
|
|
||||||
content.style.marginLeft = '';
|
|
||||||
renderSidebar(parentDir, files, path);
|
|
||||||
} catch {
|
|
||||||
sidebar.innerHTML = '';
|
|
||||||
sidebar.classList.add('hidden');
|
|
||||||
content.style.marginLeft = '0';
|
|
||||||
}
|
|
||||||
await loadPage(path);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// mode === 'dir'
|
|
||||||
sidebar.classList.remove('hidden');
|
|
||||||
content.style.marginLeft = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const files = await window.__TAURI__.core.invoke('list_md_files', { dir: path });
|
|
||||||
showReader();
|
|
||||||
|
|
||||||
const sep = path.includes('\\') ? '\\' : '/';
|
|
||||||
const home = files.find(f => {
|
|
||||||
const name = f.split(sep).pop();
|
|
||||||
return name === 'Home.md' || name === 'home.md';
|
|
||||||
});
|
|
||||||
|
|
||||||
const firstFile = home ?? files[0];
|
|
||||||
renderSidebar(path, files, firstFile);
|
|
||||||
await loadPage(firstFile);
|
|
||||||
} catch (err) {
|
|
||||||
showReader();
|
|
||||||
content.innerHTML = `<p class="error">Impossible d'ouvrir le dossier : ${err}</p>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
renderRecents();
|
|
||||||
|
|
||||||
document.getElementById('btn-open-file').addEventListener('click', async () => {
|
document.getElementById('btn-open-file').addEventListener('click', async () => {
|
||||||
const selected = await window.__TAURI__.dialog.open({
|
const selected = await window.__TAURI__.dialog.open({
|
||||||
multiple: false,
|
multiple: false,
|
||||||
@@ -264,81 +30,7 @@ document.getElementById('btn-open-dir').addEventListener('click', async () => {
|
|||||||
await openPath(selected, 'dir');
|
await openPath(selected, 'dir');
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('btn-back').addEventListener('click', () => {
|
document.getElementById('btn-back').addEventListener('click', showHome);
|
||||||
currentPath = null;
|
|
||||||
currentMode = null;
|
|
||||||
sidebar.innerHTML = '';
|
|
||||||
win.setTitle('Pena — Markdown Viewer');
|
|
||||||
titlebarTitle.textContent = 'Pena — Markdown Viewer';
|
|
||||||
showHome();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── CSS personnalisé ──
|
initCssModal();
|
||||||
const CSS_TABS = ['general', 'police', 'contenu', 'avance'];
|
showHome();
|
||||||
let activeTab = CSS_TABS[0];
|
|
||||||
|
|
||||||
function cssKey(tab) { return `pena_css_${tab}`; }
|
|
||||||
|
|
||||||
function loadTabCss(tab) { return localStorage.getItem(cssKey(tab)) ?? ''; }
|
|
||||||
|
|
||||||
function saveTabCss(tab, css) {
|
|
||||||
if (css.trim()) localStorage.setItem(cssKey(tab), css);
|
|
||||||
else localStorage.removeItem(cssKey(tab));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCombinedCss() {
|
|
||||||
return CSS_TABS.map(t => loadTabCss(t)).filter(Boolean).join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyCustomCss() {
|
|
||||||
let styleEl = document.getElementById('pena-custom-style');
|
|
||||||
if (!styleEl) {
|
|
||||||
styleEl = document.createElement('style');
|
|
||||||
styleEl.id = 'pena-custom-style';
|
|
||||||
document.head.appendChild(styleEl);
|
|
||||||
}
|
|
||||||
styleEl.textContent = buildCombinedCss();
|
|
||||||
}
|
|
||||||
|
|
||||||
applyCustomCss();
|
|
||||||
|
|
||||||
const cssModalOverlay = document.getElementById('css-modal-overlay');
|
|
||||||
const cssEditor = document.getElementById('css-editor');
|
|
||||||
|
|
||||||
function openCssModal() {
|
|
||||||
cssEditor.value = loadTabCss(activeTab);
|
|
||||||
cssModalOverlay.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeCssModal() {
|
|
||||||
cssModalOverlay.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('btn-customize-css').addEventListener('click', openCssModal);
|
|
||||||
document.getElementById('btn-css-close').addEventListener('click', closeCssModal);
|
|
||||||
document.getElementById('btn-css-cancel').addEventListener('click', closeCssModal);
|
|
||||||
|
|
||||||
cssModalOverlay.addEventListener('click', (e) => {
|
|
||||||
if (e.target === cssModalOverlay) closeCssModal();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('css-tabs').addEventListener('click', (e) => {
|
|
||||||
const tab = e.target.closest('.css-modal__tab');
|
|
||||||
if (!tab) return;
|
|
||||||
saveTabCss(activeTab, cssEditor.value);
|
|
||||||
activeTab = tab.dataset.tab;
|
|
||||||
document.querySelectorAll('.css-modal__tab').forEach(t => t.classList.toggle('active', t === tab));
|
|
||||||
cssEditor.value = loadTabCss(activeTab);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('btn-css-apply').addEventListener('click', () => {
|
|
||||||
saveTabCss(activeTab, cssEditor.value);
|
|
||||||
applyCustomCss();
|
|
||||||
closeCssModal();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('btn-css-reset').addEventListener('click', () => {
|
|
||||||
CSS_TABS.forEach(t => localStorage.removeItem(cssKey(t)));
|
|
||||||
cssEditor.value = '';
|
|
||||||
applyCustomCss();
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { appState } from './state/app-state.js';
|
||||||
|
import { listMdFiles } from './services/files.js';
|
||||||
|
import { renderSidebar } from './ui/sidebar.js';
|
||||||
|
import { loadPage } from './ui/reader.js';
|
||||||
|
import { saveRecent, renderRecents } from './ui/home.js';
|
||||||
|
|
||||||
|
const viewHome = document.getElementById('view-home');
|
||||||
|
const viewReader = document.getElementById('view-reader');
|
||||||
|
const sidebarEl = document.querySelector('#sidebar');
|
||||||
|
const content = document.querySelector('#content');
|
||||||
|
const win = window.__TAURI__.window.getCurrentWindow();
|
||||||
|
const titlebarTitle = document.getElementById('titlebar-title');
|
||||||
|
|
||||||
|
export function showHome() {
|
||||||
|
appState.currentPath = null;
|
||||||
|
appState.currentMode = null;
|
||||||
|
sidebarEl.innerHTML = '';
|
||||||
|
win.setTitle('Pena — Markdown Viewer');
|
||||||
|
titlebarTitle.textContent = 'Pena — Markdown Viewer';
|
||||||
|
viewReader.classList.add('hidden');
|
||||||
|
viewHome.classList.remove('hidden');
|
||||||
|
renderRecents(openPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showReader() {
|
||||||
|
viewHome.classList.add('hidden');
|
||||||
|
viewReader.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openPath(path, mode) {
|
||||||
|
appState.currentPath = path;
|
||||||
|
appState.currentMode = mode;
|
||||||
|
saveRecent(path, mode);
|
||||||
|
|
||||||
|
if (mode === 'file') {
|
||||||
|
showReader();
|
||||||
|
const sep = path.includes('\\') ? '\\' : '/';
|
||||||
|
const parentDir = path.split(sep).slice(0, -1).join(sep);
|
||||||
|
try {
|
||||||
|
const files = await listMdFiles(parentDir);
|
||||||
|
sidebarEl.classList.remove('hidden');
|
||||||
|
content.style.marginLeft = '';
|
||||||
|
renderSidebar(parentDir, files, path, filePath => loadPage(filePath, appState.currentMode, sidebarEl));
|
||||||
|
} catch {
|
||||||
|
sidebarEl.innerHTML = '';
|
||||||
|
sidebarEl.classList.add('hidden');
|
||||||
|
content.style.marginLeft = '0';
|
||||||
|
}
|
||||||
|
await loadPage(path, appState.currentMode, sidebarEl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// mode === 'dir'
|
||||||
|
sidebarEl.classList.remove('hidden');
|
||||||
|
content.style.marginLeft = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await listMdFiles(path);
|
||||||
|
showReader();
|
||||||
|
|
||||||
|
const sep = path.includes('\\') ? '\\' : '/';
|
||||||
|
const home = files.find(f => {
|
||||||
|
const name = f.split(sep).pop();
|
||||||
|
return name === 'Home.md' || name === 'home.md';
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstFile = home ?? files[0];
|
||||||
|
renderSidebar(path, files, firstFile, filePath => loadPage(filePath, appState.currentMode, sidebarEl));
|
||||||
|
await loadPage(firstFile, appState.currentMode, sidebarEl);
|
||||||
|
} catch (err) {
|
||||||
|
showReader();
|
||||||
|
content.innerHTML = `<p class="error">Impossible d'ouvrir le dossier : ${err}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function listMdFiles(dir) {
|
||||||
|
return window.__TAURI__.core.invoke('list_md_files', { dir });
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function renderMarkdown(content) {
|
||||||
|
return window.__TAURI__.core.invoke('render_markdown', { content });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertFile(path) {
|
||||||
|
return window.__TAURI__.core.invoke('convert_file', { path });
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export function startWatch(path) {
|
||||||
|
return window.__TAURI__.core.invoke('start_watch', { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopWatch() {
|
||||||
|
return window.__TAURI__.core.invoke('stop_watch');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onFileChanged(callback) {
|
||||||
|
return window.__TAURI__.event.listen('file-changed', callback);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const appState = { currentPath: null, currentMode: null };
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
const CSS_TABS = ['general', 'police', 'contenu', 'avance'];
|
||||||
|
let activeTab = CSS_TABS[0];
|
||||||
|
|
||||||
|
function cssKey(tab) { return `pena_css_${tab}`; }
|
||||||
|
|
||||||
|
function loadTabCss(tab) { return localStorage.getItem(cssKey(tab)) ?? ''; }
|
||||||
|
|
||||||
|
function saveTabCss(tab, css) {
|
||||||
|
if (css.trim()) localStorage.setItem(cssKey(tab), css);
|
||||||
|
else localStorage.removeItem(cssKey(tab));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCustomCss() {
|
||||||
|
let styleEl = document.getElementById('pena-custom-style');
|
||||||
|
if (!styleEl) {
|
||||||
|
styleEl = document.createElement('style');
|
||||||
|
styleEl.id = 'pena-custom-style';
|
||||||
|
document.head.appendChild(styleEl);
|
||||||
|
}
|
||||||
|
styleEl.textContent = CSS_TABS.map(t => loadTabCss(t)).filter(Boolean).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initCssModal() {
|
||||||
|
applyCustomCss();
|
||||||
|
|
||||||
|
const cssModalOverlay = document.getElementById('css-modal-overlay');
|
||||||
|
const cssEditor = document.getElementById('css-editor');
|
||||||
|
|
||||||
|
function openCssModal() {
|
||||||
|
cssEditor.value = loadTabCss(activeTab);
|
||||||
|
cssModalOverlay.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCssModal() {
|
||||||
|
cssModalOverlay.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-customize-css').addEventListener('click', openCssModal);
|
||||||
|
document.getElementById('btn-css-close').addEventListener('click', closeCssModal);
|
||||||
|
document.getElementById('btn-css-cancel').addEventListener('click', closeCssModal);
|
||||||
|
|
||||||
|
cssModalOverlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === cssModalOverlay) closeCssModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('css-tabs').addEventListener('click', (e) => {
|
||||||
|
const tab = e.target.closest('.css-modal__tab');
|
||||||
|
if (!tab) return;
|
||||||
|
saveTabCss(activeTab, cssEditor.value);
|
||||||
|
activeTab = tab.dataset.tab;
|
||||||
|
document.querySelectorAll('.css-modal__tab').forEach(t => t.classList.toggle('active', t === tab));
|
||||||
|
cssEditor.value = loadTabCss(activeTab);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-css-apply').addEventListener('click', () => {
|
||||||
|
saveTabCss(activeTab, cssEditor.value);
|
||||||
|
applyCustomCss();
|
||||||
|
closeCssModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-css-reset').addEventListener('click', () => {
|
||||||
|
CSS_TABS.forEach(t => localStorage.removeItem(cssKey(t)));
|
||||||
|
cssEditor.value = '';
|
||||||
|
applyCustomCss();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { escapeHtml } from '../utils.js';
|
||||||
|
|
||||||
|
const RECENTS_KEY = 'pena_recents';
|
||||||
|
const RECENTS_MAX = 8;
|
||||||
|
|
||||||
|
export function loadRecents() {
|
||||||
|
try { return JSON.parse(localStorage.getItem(RECENTS_KEY) ?? '[]'); }
|
||||||
|
catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveRecent(path, mode) {
|
||||||
|
const name = path.replace(/\\/g, '/').split('/').pop().replace(/\.md$/i, '');
|
||||||
|
const parent = path.replace(/\\/g, '/').split('/').slice(0, -1).join('/');
|
||||||
|
const recents = loadRecents().filter(r => r.path !== path);
|
||||||
|
recents.unshift({ path, mode, name, parent });
|
||||||
|
localStorage.setItem(RECENTS_KEY, JSON.stringify(recents.slice(0, RECENTS_MAX)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderRecents(onOpen) {
|
||||||
|
const recents = loadRecents();
|
||||||
|
const container = document.getElementById('home-recents');
|
||||||
|
const list = document.getElementById('home-recents-list');
|
||||||
|
if (recents.length === 0) {
|
||||||
|
container.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.classList.remove('hidden');
|
||||||
|
list.innerHTML = recents.map(r => {
|
||||||
|
const icon = r.mode === 'dir' ? '📁' : '📄';
|
||||||
|
const escaped = escapeHtml(r.path);
|
||||||
|
return `<li>
|
||||||
|
<button class="home-recents__item" data-path="${escaped}" data-mode="${r.mode}">
|
||||||
|
<span class="home-recents__item__icon">${icon}</span>
|
||||||
|
<span class="home-recents__item__info">
|
||||||
|
<span class="home-recents__item__name">${escapeHtml(r.name)}</span>
|
||||||
|
<span class="home-recents__item__path">${escapeHtml(r.parent)}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>`;
|
||||||
|
}).join('');
|
||||||
|
list.querySelectorAll('.home-recents__item').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => onOpen(btn.dataset.path, btn.dataset.mode));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { convertFile } from '../services/markdown.js';
|
||||||
|
|
||||||
|
const win = window.__TAURI__.window.getCurrentWindow();
|
||||||
|
const titlebarTitle = document.getElementById('titlebar-title');
|
||||||
|
const content = document.querySelector('#content');
|
||||||
|
|
||||||
|
export async function loadPage(filePath, mode, sidebarEl) {
|
||||||
|
try {
|
||||||
|
const html = await convertFile(filePath);
|
||||||
|
content.innerHTML = html;
|
||||||
|
|
||||||
|
const basename = filePath.split('/').pop().split('\\').pop();
|
||||||
|
const title = basename.replace(/\.md$/i, '');
|
||||||
|
await win.setTitle(title);
|
||||||
|
titlebarTitle.textContent = title;
|
||||||
|
|
||||||
|
if (mode === 'dir' || mode === 'file') {
|
||||||
|
sidebarEl.querySelectorAll('a').forEach(a => {
|
||||||
|
a.classList.toggle('active', a.dataset.path === filePath);
|
||||||
|
});
|
||||||
|
const active = sidebarEl.querySelector('a[data-path].active');
|
||||||
|
if (active) {
|
||||||
|
let el = active.parentElement;
|
||||||
|
while (el && el !== sidebarEl) {
|
||||||
|
if (el.tagName === 'DETAILS') el.open = true;
|
||||||
|
el = el.parentElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
content.innerHTML = `<p class="error">Impossible de charger le fichier : ${err}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { escapeHtml } from '../utils.js';
|
||||||
|
|
||||||
|
function buildTree(relPaths, sep) {
|
||||||
|
const tree = { _files: [], _dirs: {} };
|
||||||
|
relPaths.forEach((rel) => {
|
||||||
|
const parts = rel.split(sep);
|
||||||
|
let node = tree;
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
const dir = parts[i];
|
||||||
|
if (!node._dirs[dir]) node._dirs[dir] = { _files: [], _dirs: {} };
|
||||||
|
node = node._dirs[dir];
|
||||||
|
}
|
||||||
|
node._files.push(rel);
|
||||||
|
});
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTree(node, prefix, sep, currentRel, depth) {
|
||||||
|
let html = '';
|
||||||
|
const depthClass = depth > 0 ? ` class="nav-depth-${Math.min(depth, 3)}"` : '';
|
||||||
|
|
||||||
|
node._files.slice().sort().forEach((rel) => {
|
||||||
|
const label = rel.split(sep).pop().replace(/\.md$/i, '').replace(/-/g, ' ');
|
||||||
|
const abs = prefix + rel;
|
||||||
|
const cls = rel === currentRel ? ' active' : '';
|
||||||
|
if (depth === 0) {
|
||||||
|
html += `\n<li><a href="#" class="nav-root-link${cls}" data-path="${escapeHtml(abs)}">${escapeHtml(label)}</a></li>`;
|
||||||
|
} else {
|
||||||
|
html += `\n<li${depthClass}><a href="#" class="nav-file-link${cls}" data-path="${escapeHtml(abs)}">${escapeHtml(label)}</a></li>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.keys(node._dirs).sort().forEach((dirName) => {
|
||||||
|
const child = node._dirs[dirName];
|
||||||
|
const isOpen = currentRel && currentRel.split(sep).includes(dirName);
|
||||||
|
const openAttr = isOpen ? ' open' : '';
|
||||||
|
|
||||||
|
const homeFile = child._files.find((f) => /^home\.md$/i.test(f.split(sep).pop()));
|
||||||
|
let folderLabel;
|
||||||
|
if (homeFile) {
|
||||||
|
const abs = prefix + homeFile;
|
||||||
|
const cls = homeFile === currentRel ? ' active' : '';
|
||||||
|
folderLabel = `<a href="#" class="nav-folder-link${cls}" data-path="${escapeHtml(abs)}" onclick="event.stopPropagation()">${escapeHtml(dirName)}</a>`;
|
||||||
|
} else {
|
||||||
|
folderLabel = `<span class="nav-folder-name">${escapeHtml(dirName)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrow = `<span class="nav-arrow" onclick="event.preventDefault();event.stopPropagation();var d=this.closest('details');d.open=!d.open">▶</span>`;
|
||||||
|
|
||||||
|
const childNode = {
|
||||||
|
_files: homeFile ? child._files.filter((f) => f !== homeFile) : child._files,
|
||||||
|
_dirs: child._dirs,
|
||||||
|
};
|
||||||
|
|
||||||
|
html += `\n<li${depthClass}><details class="nav-folder"${openAttr}>`;
|
||||||
|
html += `\n <summary>${arrow}${folderLabel}</summary>`;
|
||||||
|
html += `\n <ul class="nav-tree">`;
|
||||||
|
html += renderTree(childNode, prefix, sep, currentRel, depth + 1);
|
||||||
|
html += `\n </ul>\n</details></li>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSidebar(baseDir, files, currentFile) {
|
||||||
|
const sep = baseDir.includes('\\') ? '\\' : '/';
|
||||||
|
const dirName = baseDir.split(sep).pop();
|
||||||
|
const prefix = baseDir.endsWith(sep) ? baseDir : baseDir + sep;
|
||||||
|
const toRel = abs => abs.startsWith(prefix) ? abs.slice(prefix.length) : abs;
|
||||||
|
const currentRel = currentFile ? toRel(currentFile) : null;
|
||||||
|
const relPaths = files.map(toRel);
|
||||||
|
const tree = buildTree(relPaths, sep);
|
||||||
|
|
||||||
|
let html = `<div class="sidebar__title-block">
|
||||||
|
<h1 class="sidebar__title-block__title">${escapeHtml(dirName)}</h1>
|
||||||
|
</div>
|
||||||
|
<ul class="sidebar__menu nav-tree">`;
|
||||||
|
html += renderTree(tree, prefix, sep, currentRel, 0);
|
||||||
|
html += `\n </ul>`;
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderSidebar(baseDir, files, currentFile, onNavigate) {
|
||||||
|
const sidebarEl = document.querySelector('#sidebar');
|
||||||
|
sidebarEl.innerHTML = buildSidebar(baseDir, files, currentFile);
|
||||||
|
sidebarEl.querySelectorAll('a[data-path]').forEach(a => {
|
||||||
|
a.addEventListener('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
onNavigate(a.dataset.path);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function escapeHtml(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user