feat: add CodeMirror-based file editor with modal interface and syntax highlighting

- Import CodeMirror 6 modules via ESM with importmap for @codemirror/state
- Add editor modal UI with header, cancel/save buttons, and editor body container
- Implement openEditor function to load file content and initialize CodeMirror with language-specific syntax highlighting
- Support 20+ file extensions including markdown, python, javascript, html, css, json, xml, sql, php, cpp, java, rust, and shell scripts
This commit is contained in:
Bruno Charest 2026-03-31 11:48:14 -04:00
parent f2de0d9456
commit 309f945751

View File

@ -12,6 +12,64 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" id="hljs-theme-light" disabled>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="https://unpkg.com/lucide@0.344.0/dist/umd/lucide.min.js"></script>
<script type="importmap">
{
"imports": {
"@codemirror/state": "https://esm.sh/@codemirror/state@6.2.0"
}
}
</script>
<script type="module">
import { EditorView, keymap, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, rectangularSelection, crosshairCursor, highlightActiveLine } from "https://esm.sh/@codemirror/view@6.9.0?external=@codemirror/state";
import { EditorState } from "@codemirror/state";
import { defaultHighlightStyle, syntaxHighlighting, indentOnInput, bracketMatching, foldGutter, foldKeymap } from "https://esm.sh/@codemirror/language@6.6.0?external=@codemirror/state";
import { defaultKeymap, history, historyKeymap } from "https://esm.sh/@codemirror/commands@6.2.3?external=@codemirror/state";
import { searchKeymap, highlightSelectionMatches } from "https://esm.sh/@codemirror/search@6.3.0?external=@codemirror/state";
import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap } from "https://esm.sh/@codemirror/autocomplete@6.5.0?external=@codemirror/state";
import { markdown } from "https://esm.sh/@codemirror/lang-markdown@6.1.0?external=@codemirror/state";
import { python } from "https://esm.sh/@codemirror/lang-python@6.1.3?external=@codemirror/state";
import { javascript } from "https://esm.sh/@codemirror/lang-javascript@6.1.7?external=@codemirror/state";
import { html } from "https://esm.sh/@codemirror/lang-html@6.4.3?external=@codemirror/state";
import { css } from "https://esm.sh/@codemirror/lang-css@6.2.0?external=@codemirror/state";
import { json } from "https://esm.sh/@codemirror/lang-json@6.0.1?external=@codemirror/state";
import { xml } from "https://esm.sh/@codemirror/lang-xml@6.0.2?external=@codemirror/state";
import { sql } from "https://esm.sh/@codemirror/lang-sql@6.5.0?external=@codemirror/state";
import { php } from "https://esm.sh/@codemirror/lang-php@6.0.1?external=@codemirror/state";
import { cpp } from "https://esm.sh/@codemirror/lang-cpp@6.0.2?external=@codemirror/state";
import { java } from "https://esm.sh/@codemirror/lang-java@6.0.1?external=@codemirror/state";
import { rust } from "https://esm.sh/@codemirror/lang-rust@6.0.1?external=@codemirror/state";
import { oneDark } from "https://esm.sh/@codemirror/theme-one-dark@6.1.0?external=@codemirror/state";
const basicSetup = [
lineNumbers(),
highlightActiveLineGutter(),
highlightSpecialChars(),
history(),
foldGutter(),
drawSelection(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
bracketMatching(),
closeBrackets(),
autocompletion(),
rectangularSelection(),
crosshairCursor(),
highlightActiveLine(),
highlightSelectionMatches(),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
])
];
window.CodeMirror = { EditorView, EditorState, basicSetup, markdown, python, javascript, html, css, json, xml, sql, php, cpp, java, rust, oneDark, keymap };
</script>
<style>
body, html {
margin: 0;
@ -36,6 +94,22 @@
</head>
<body class="popup-mode">
<div class="popout-loading" id="loading">Chargement...</div>
<div class="editor-modal" id="editor-modal">
<div class="editor-container">
<div class="editor-header">
<div class="editor-title" id="editor-title">Édition</div>
<div class="editor-actions">
<button class="editor-btn" id="editor-cancel" title="Annuler" aria-label="Annuler">
<i data-lucide="x" style="width:16px;height:16px"></i>
</button>
<button class="editor-btn primary" id="editor-save" title="Sauvegarder" aria-label="Sauvegarder">
<i data-lucide="check" style="width:16px;height:16px"></i>
</button>
</div>
</div>
<div class="editor-body" id="editor-body"></div>
</div>
</div>
<div class="main-layout" style="height: 100vh; display: flex; position: relative; overflow: hidden;">
<main class="content-area" id="content-area" style="margin-top: 15px; flex: 1; overflow-y: auto; overflow-x: hidden;">
@ -84,6 +158,12 @@
let rightSidebarWidth = 280;
let headingsCache = [];
let activeHeadingId = null;
let showingSource = false;
let cachedRawSource = null;
let editorView = null;
let fallbackEditorEl = null;
let editorVault = null;
let editorPath = null;
function el(tag, attrs, children) {
const e = document.createElement(tag);
@ -506,6 +586,8 @@ const RightSidebarManager = {
const pathParts = window.location.pathname.split('/');
const vault = decodeURIComponent(pathParts[2]);
const path = decodeURIComponent(pathParts.slice(3).join('/'));
showingSource = false;
cachedRawSource = null;
if (!vault || !path) {
document.getElementById('loading').textContent = "Paramètres invalides";
@ -580,6 +662,36 @@ const RightSidebarManager = {
});
actionsDiv.appendChild(copyBtn);
const sourceBtn = document.createElement("button");
sourceBtn.className = "btn-action";
sourceBtn.title = "Voir la source";
sourceBtn.innerHTML = '<i data-lucide="code" style="width:14px;height:14px"></i> Source';
sourceBtn.addEventListener("click", async () => {
const rendered = document.getElementById("file-rendered-content");
const raw = document.getElementById("file-raw-content");
if (!rendered || !raw) return;
showingSource = !showingSource;
if (showingSource) {
sourceBtn.classList.add("active");
if (!cachedRawSource) {
const rawUrl = `/api/file/${encodeURIComponent(vault)}/raw?path=${encodeURIComponent(path)}`;
const rawResponse = await fetch(rawUrl, { credentials: 'include' });
if (!rawResponse.ok) throw new Error("Erreur lors du chargement de la source");
const rawData = await rawResponse.json();
cachedRawSource = rawData.raw;
}
raw.textContent = cachedRawSource;
rendered.style.display = "none";
raw.style.display = "block";
} else {
sourceBtn.classList.remove("active");
rendered.style.display = "block";
raw.style.display = "none";
}
});
actionsDiv.appendChild(sourceBtn);
const dlBtn = document.createElement("button");
dlBtn.className = "btn-action";
dlBtn.title = "Télécharger";
@ -594,6 +706,15 @@ const RightSidebarManager = {
});
actionsDiv.appendChild(dlBtn);
const editBtn = document.createElement("button");
editBtn.className = "btn-action";
editBtn.title = "Éditer";
editBtn.innerHTML = '<i data-lucide="edit" style="width:14px;height:14px"></i> Éditer';
editBtn.addEventListener("click", () => {
openEditor(vault, path);
});
actionsDiv.appendChild(editBtn);
const tocBtn = document.createElement("button");
tocBtn.className = "btn-action";
tocBtn.id = "toc-toggle-btn";
@ -618,6 +739,12 @@ const RightSidebarManager = {
mdDiv.innerHTML = data.html;
area.appendChild(mdDiv);
const rawDiv = document.createElement("div");
rawDiv.className = "raw-source-view";
rawDiv.id = "file-raw-content";
rawDiv.style.display = "none";
area.appendChild(rawDiv);
// Render lucide icons
if (window.lucide) {
window.lucide.createIcons();
@ -641,6 +768,171 @@ const RightSidebarManager = {
}
}
async function waitForCodeMirror() {
let attempts = 0;
while (!window.CodeMirror && attempts < 50) {
await new Promise((resolve) => setTimeout(resolve, 100));
attempts++;
}
if (!window.CodeMirror) {
throw new Error("CodeMirror failed to load");
}
}
async function openEditor(vaultName, filePath) {
editorVault = vaultName;
editorPath = filePath;
const modal = document.getElementById("editor-modal");
const titleEl = document.getElementById("editor-title");
const bodyEl = document.getElementById("editor-body");
titleEl.textContent = `Édition: ${filePath.split("/").pop()}`;
const rawUrl = `/api/file/${encodeURIComponent(vaultName)}/raw?path=${encodeURIComponent(filePath)}`;
const rawResponse = await fetch(rawUrl, { credentials: 'include' });
if (!rawResponse.ok) {
throw new Error("Erreur lors du chargement du contenu à éditer");
}
const rawData = await rawResponse.json();
cachedRawSource = rawData.raw;
bodyEl.innerHTML = "";
if (editorView) {
editorView.destroy();
editorView = null;
}
fallbackEditorEl = null;
try {
await waitForCodeMirror();
const { EditorView, EditorState, basicSetup, markdown, python, javascript, html, css, json, xml, sql, php, cpp, java, rust, oneDark, keymap } = window.CodeMirror;
const currentTheme = document.documentElement.getAttribute("data-theme");
const fileExt = filePath.split(".").pop().toLowerCase();
const extensions = [
basicSetup,
keymap.of([
{
key: "Mod-s",
run: () => {
saveFile();
return true;
},
},
]),
EditorView.lineWrapping,
];
const langMap = {
md: markdown,
markdown: markdown,
py: python,
js: javascript,
jsx: javascript,
ts: javascript,
tsx: javascript,
mjs: javascript,
cjs: javascript,
html: html,
htm: html,
css: css,
scss: css,
less: css,
json: json,
xml: xml,
svg: xml,
sql: sql,
php: php,
cpp: cpp,
cc: cpp,
cxx: cpp,
c: cpp,
h: cpp,
hpp: cpp,
java: java,
rs: rust,
sh: javascript,
bash: javascript,
zsh: javascript,
};
const langMode = langMap[fileExt];
if (langMode) {
extensions.push(langMode());
}
if (currentTheme === "dark") {
extensions.push(oneDark);
}
const state = EditorState.create({
doc: rawData.raw,
extensions: extensions,
});
editorView = new EditorView({
state: state,
parent: bodyEl,
});
} catch (err) {
console.error("CodeMirror init failed, falling back to textarea:", err);
fallbackEditorEl = document.createElement("textarea");
fallbackEditorEl.className = "fallback-editor";
fallbackEditorEl.value = rawData.raw;
bodyEl.appendChild(fallbackEditorEl);
}
modal.classList.add("active");
safeCreateIcons();
}
function closeEditor() {
const modal = document.getElementById("editor-modal");
modal.classList.remove("active");
if (editorView) {
editorView.destroy();
editorView = null;
}
fallbackEditorEl = null;
editorVault = null;
editorPath = null;
}
async function saveFile() {
if ((!editorView && !fallbackEditorEl) || !editorVault || !editorPath) return;
const content = editorView ? editorView.state.doc.toString() : fallbackEditorEl.value;
const saveBtn = document.getElementById("editor-save");
const originalHTML = saveBtn.innerHTML;
try {
saveBtn.disabled = true;
saveBtn.innerHTML = '<i data-lucide="loader" style="width:16px;height:16px"></i>';
safeCreateIcons();
const response = await fetch(`/api/file/${encodeURIComponent(editorVault)}/save?path=${encodeURIComponent(editorPath)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
credentials: 'include'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || "Erreur de sauvegarde");
}
saveBtn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px"></i>';
safeCreateIcons();
closeEditor();
await initPopout();
} catch (err) {
console.error("Save error:", err);
saveBtn.innerHTML = originalHTML;
safeCreateIcons();
alert(err.message || "Erreur lors de la sauvegarde");
} finally {
saveBtn.disabled = false;
}
}
function createBreadcrumbSep() {
const sep = document.createElement("span");
sep.className = "sep";
@ -841,6 +1133,8 @@ const RightSidebarManager = {
applyTheme(event.newValue);
}
});
document.getElementById("editor-cancel").addEventListener("click", closeEditor);
document.getElementById("editor-save").addEventListener("click", saveFile);
initPopout();
</script>
</body>