import { state } from './state.js'; import { api } from './auth.js'; import { t } from './i18n.js'; import { openFile, showWelcome } from './viewer.js'; import { refreshSidebarForContext, refreshTagsForContext } from './sidebar.js'; import { createAIToolbar } from './ai.js'; import { startCollab, stopCollab } from './collab.js'; import { getInlineHostArea, getEditorContainer, mountEditorInline, unmountEditorInline, isInlineEditorActive, queryEditor, } from './editor-inline.js'; // --------------------------------------------------------------------------- // Inline edition (#93) // --------------------------------------------------------------------------- /** * Set just before a programmatic document replacement (external AI write) so * the autosave update listener does not schedule a redundant save of the * freshly reloaded content. */ let suppressAutoSaveOnce = false; /** * Inline edition: the editor replaces the document read view in the content * area instead of covering it with the `#editor-modal` overlay. Only possible * when the editor targets the document currently read (the pane area is what * gets taken over); otherwise the overlay is kept as a fallback (e.g. a file * just created from the command palette and not opened in the viewer yet). * * @returns {boolean} true when the editor is hosted inline. */ export function activateInlineEditor(vaultName, filePath) { if (!vaultName || !filePath) return false; if (state.currentVault !== vaultName || state.currentPath !== filePath) return false; const area = getInlineHostArea(); if (!area) return false; return mountEditorInline(area); } /** * Drop an inline edition session without re-rendering the document: used when * the read view is about to be re-rendered for another file (tab switch, * sidebar click, wikilink), which owns the same content area. */ export function detachInlineEditor() { if (!isInlineEditorActive()) return; const modal = document.getElementById("editor-modal"); stopCollab(); if (state.editorView) { state.editorView.destroy(); state.editorView = null; } state.fallbackEditorEl = null; clearTimeout(window._obsigateAutoSaveTimer); var forgeFrame = document.getElementById("forge-iframe"); if (forgeFrame) forgeFrame.remove(); if (modal) { modal.classList.remove("active"); var brand = modal.querySelector('.editor-brand'); if (brand) brand.textContent = 'ObsiGate'; } unmountEditorInline(); state.editorVault = null; state.editorPath = null; } // --------------------------------------------------------------------------- // File extension → Lucide icon mapping // --------------------------------------------------------------------------- const EXT_ICONS = { // Text files ".md": "file-text", ".txt": "file-text", ".log": "file-text", ".readme": "file-text", ".rst": "file-text", ".adoc": "file-text", // Web development ".html": "file-code", ".htm": "file-code", ".css": "file-code", ".scss": "file-code", ".sass": "file-code", ".less": "file-code", ".js": "file-code", ".jsx": "file-code", ".ts": "file-code", ".tsx": "file-code", ".vue": "file-code", ".svelte": "file-code", // Programming languages ".py": "file-code", ".java": "file-code", ".c": "file-code", ".cpp": "file-code", ".cc": "file-code", ".cxx": "file-code", ".h": "file-code", ".hpp": "file-code", ".cs": "file-code", ".go": "file-code", ".rs": "file-code", ".rb": "file-code", ".php": "file-code", ".swift": "file-code", ".kt": "file-code", ".scala": "file-code", ".r": "file-code", ".m": "file-code", ".pl": "file-code", ".lua": "file-code", ".dart": "file-code", ".nim": "file-code", ".zig": "file-code", ".odin": "file-code", ".v": "file-code", ".cr": "file-code", ".ex": "file-code", ".exs": "file-code", ".elm": "file-code", ".purs": "file-code", ".hs": "file-code", ".ml": "file-code", ".ocaml": "file-code", ".fs": "file-code", ".fsx": "file-code", ".vb": "file-code", ".pas": "file-code", ".pp": "file-code", ".inc": "file-code", // Data formats ".json": "file-json", ".yaml": "file-cog", ".yml": "file-cog", ".toml": "file-cog", ".xml": "file-code", ".csv": "table", ".tsv": "table", ".excalidraw": "pen-tool", ".excalidraw.md": "pen-tool", ".sql": "database", ".db": "database", ".sqlite": "database", ".sqlite3": "database", ".parquet": "database", ".avro": "database", // Configuration files ".ini": "file-cog", ".cfg": "file-cog", ".conf": "file-cog", ".env": "file-cog", ".dockerfile": "file-cog", ".gitignore": "file-cog", ".gitattributes": "file-cog", ".editorconfig": "file-cog", ".eslintrc": "file-cog", ".prettierrc": "file-cog", ".babelrc": "file-cog", ".tsconfig": "file-cog", "package.json": "file-cog", "package-lock.json": "file-cog", "yarn.lock": "file-cog", "composer.json": "file-cog", "requirements.txt": "file-cog", "pipfile": "file-cog", "gemfile": "file-cog", "cargo.toml": "file-cog", "go.mod": "file-cog", "go.sum": "file-cog", "pom.xml": "file-cog", "build.gradle": "file-cog", "cmakelists.txt": "file-cog", "makefile": "file-cog", // Shell scripts ".sh": "terminal", ".bash": "terminal", ".zsh": "terminal", ".fish": "terminal", ".bat": "terminal", ".cmd": "terminal", ".ps1": "terminal", ".psm1": "terminal", ".psd1": "terminal", // Document formats ".pdf": "file-text", ".doc": "file-text", ".docx": "file-text", ".rtf": "file-text", ".odt": "file-text", ".tex": "file-text", ".latex": "file-text", // Image files (roadmap #108-D1) ".png": "image", ".jpg": "image", ".jpeg": "image", ".gif": "image", ".svg": "image", ".webp": "image", ".bmp": "image", ".ico": "image", ".tiff": "image", ".tif": "image", // Audio files (roadmap #109-B2) ".mp3": "audio-lines", ".wav": "audio-lines", ".flac": "audio-lines", ".aac": "audio-lines", ".ogg": "audio-lines", ".oga": "audio-lines", ".opus": "audio-lines", ".m4a": "audio-lines", ".wma": "audio-lines", // Video files (roadmap #109-B2) ".mp4": "video", ".avi": "video", ".mov": "video", ".wmv": "video", ".flv": "video", ".webm": "video", ".mkv": "video", ".m4v": "video", ".3gp": "video", // Archive files ".zip": "file-archive", ".rar": "file-archive", ".7z": "file-archive", ".tar": "file-archive", ".gz": "file-archive", ".tgz": "file-archive", ".bz2": "file-archive", ".xz": "file-archive", ".deb": "file-archive", ".rpm": "file-archive", ".dmg": "file-archive", ".pkg": "file-archive", ".msi": "file-archive", ".exe": "file-archive", // Font files ".ttf": "file-type", ".otf": "file-type", ".woff": "file-type", ".woff2": "file-type", ".eot": "file-type", // Other common files ".key": "file-cog", ".pem": "file-cog", ".crt": "file-cog", ".cert": "file-cog", ".p12": "file-cog", ".pfx": "file-cog", ".lock": "file-cog", ".tmp": "file", ".bak": "file", ".old": "file", ".orig": "file", ".save": "file", }; function getFileIcon(name) { const lower = name.toLowerCase(); // Check for double extensions first (e.g., .excalidraw.md) if (lower.endsWith(".excalidraw.md")) return EXT_ICONS[".excalidraw.md"] || "pen-tool"; if (lower.endsWith(".md.excalidraw")) return EXT_ICONS[".excalidraw"] || "pen-tool"; const ext = "." + name.split(".").pop().toLowerCase(); return EXT_ICONS[ext] || "file"; } // --------------------------------------------------------------------------- // Safe CDN helpers // --------------------------------------------------------------------------- let _iconDebounceTimer = null; /** * Debounced icon creation — batches multiple rapid calls into one * DOM scan to avoid excessive reflows when building large trees. */ function safeCreateIcons() { if (typeof lucide === "undefined" || !lucide.createIcons) return; if (state._iconDebounceTimer) return; // already scheduled state._iconDebounceTimer = requestAnimationFrame(() => { state._iconDebounceTimer = null; try { lucide.createIcons(); } catch (e) { /* CDN not loaded */ } }); } /** Force-flush icon creation immediately (use sparingly). */ function flushIcons() { if (state._iconDebounceTimer) { cancelAnimationFrame(state._iconDebounceTimer); state._iconDebounceTimer = null; } if (typeof lucide !== "undefined" && lucide.createIcons) { try { lucide.createIcons(); } catch (e) { /* CDN not loaded */ } } } function safeHighlight(block) { // Skip mermaid blocks — they're rendered by Mermaid.js, not highlighted if (block.className && block.className.indexOf('language-mermaid') !== -1) return; if (typeof hljs !== "undefined" && hljs.highlightElement) { try { // Alias pour les extensions que highlight.js (bundle common) ne connaît pas const PP_LANG_ALIAS = { md: "markdown", markdown: "markdown", rst: "plaintext", pl: "perl", lua: "lua", dart: "dart", vue: "html", svelte: "html", astro: "html", graphql: "graphql", prisma: "sql", proto: "c", vb: "basic", vba: "basic", asm: "x86asm", tf: "haskell", gradle: "groovy", groovy: "groovy", properties: "ini", gitignore: "plaintext", hosts: "ini", fish: "bash", ksh: "bash", systemd: "ini", batch: "dos", bat: "dos", shell: "bash", golang: "go", toml: "ini", avsc: "json", jsonc: "json", json5: "json", }; const m = /\blanguage-([\w-]+)/.exec(block.className || ""); if (m) { const lang = m[1]; const alias = PP_LANG_ALIAS[lang]; if (alias && hljs.getLanguage && !hljs.getLanguage(lang) && hljs.getLanguage(alias)) { block.className = block.className.replace("language-" + lang, "language-" + alias); } else if (hljs.getLanguage && !hljs.getLanguage(lang) && !hljs.getLanguage(alias || "")) { block.className = block.className.replace("language-" + lang, "language-plaintext"); } } hljs.highlightElement(block); } catch (e) { /* CDN not loaded */ } } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function escapeHtml(str) { if (!str) return ""; return String(str).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } // --------------------------------------------------------------------------- // Editor (CodeMirror) // --------------------------------------------------------------------------- async function openEditor(vaultName, filePath) { state.editorVault = vaultName; state.editorPath = filePath; // A previous session may have left the shared save button in its spinner // state (manual save, Forge, failed request). BUG-054. resetSaveButton(); const modal = document.getElementById("editor-modal"); const titleInput = document.getElementById("editor-title-input"); const filenameInput = document.getElementById("editor-filename-input"); const statFile = document.getElementById("editor-stat-file"); const statWords = document.getElementById("editor-stat-words"); const bodyEl = document.getElementById("editor-body"); // Restore header/footer (may be hidden by Forge) var headerEl = queryEditor(".editor-header"); var footerEl = queryEditor(".editor-footer"); var container = getEditorContainer(); if (headerEl) headerEl.style.display = ""; if (footerEl) footerEl.style.display = ""; if (container) container.style.maxHeight = ""; // #93 — Inline mode: when the edited file is the document currently read, the // editor container takes the place of the read view (no overlay covering the // document and the AI assistant panel). activateInlineEditor(vaultName, filePath); filenameInput.value = filePath.split("/").pop(); statFile.textContent = vaultName + "/" + filePath; // Fetch raw content const rawUrl = `/api/file/${encodeURIComponent(vaultName)}/raw?path=${encodeURIComponent(filePath)}`; const rawData = await api(rawUrl); // Clear previous editor bodyEl.innerHTML = ""; if (state.editorView) { state.editorView.destroy(); state.editorView = null; } state.fallbackEditorEl = null; // Re-create AI toolbar container (was cleared by innerHTML above) const aiContainer = document.createElement('div'); aiContainer.id = 'ai-toolbar-container'; bodyEl.appendChild(aiContainer); 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, // Auto-save: debounced save after 2s of inactivity EditorView.updateListener.of((update) => { if (update.docChanged) { // Skip the autosave scheduled by a programmatic reload of the // document (external write by the AI assistant, #93). if (suppressAutoSaveOnce) { suppressAutoSaveOnce = false; return; } clearTimeout(window._obsigateAutoSaveTimer); window._obsigateAutoSaveTimer = setTimeout(() => saveFile(true), 2000); // Mark dirty var sd = document.getElementById("editor-save-dot"); var sl = document.getElementById("editor-save-label"); if (sd) sd.className = "editor-save-dot dirty"; if (sl) sl.textContent = "Unsaved"; } }), ]; // Add language support based on file extension 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, // Using javascript for shell scripts as fallback bash: javascript, zsh: javascript, }; const langMode = langMap[fileExt]; if (langMode) { extensions.push(langMode()); } if (currentTheme === "dark") { extensions.push(oneDark); } const cmState = EditorState.create({ doc: rawData.raw, extensions: extensions, }); state.editorView = new EditorView({ state: cmState, parent: bodyEl, }); } catch (err) { console.error("CodeMirror init failed, falling back to textarea:", err); state.fallbackEditorEl = document.createElement("textarea"); state.fallbackEditorEl.className = "fallback-editor"; state.fallbackEditorEl.value = rawData.raw; bodyEl.appendChild(state.fallbackEditorEl); } // Real-time collaboration (ROADMAP #62) — bind Yjs to the CodeMirror view. if (state.editorView) { startCollab({ view: state.editorView, vault: vaultName, path: filePath }).catch((err) => { console.warn("Collab start failed:", err); }); } // Set up AI toolbar (recreate each time editor opens) var container = document.getElementById('ai-toolbar-container'); if (container && !container.querySelector('.ai-toolbar') && !container.querySelector('div')) { createAIToolbar(container, function() { return state.editorView || null; }); } // Mermaid live preview panel (#26) var existingPreview = document.getElementById('mermaid-live-preview'); if (existingPreview) existingPreview.remove(); var previewPanel = document.createElement('div'); previewPanel.id = 'mermaid-live-preview'; previewPanel.className = 'mermaid-live-preview'; previewPanel.style.display = 'none'; bodyEl.appendChild(previewPanel); // Debounced live preview on content change var mermaidTimer = null; var updateMermaidPreview = function() { clearTimeout(mermaidTimer); mermaidTimer = setTimeout(function() { var content = state.editorView ? state.editorView.state.doc.toString() : (state.fallbackEditorEl ? state.fallbackEditorEl.value : ''); var match = content.match(/```mermaid\n([\s\S]*?)```/); if (match) { previewPanel.style.display = 'block'; previewPanel.innerHTML = ''; var code = match[1]; import('./mermaid-viewer.js').then(function(m) { // Create a temp container and render var tmp = document.createElement('div'); tmp.innerHTML = '
' + code.replace(/&/g,'&').replace(//g,'>') + '
'; previewPanel.appendChild(tmp); m.renderMermaidBlocks(previewPanel); }); } else { previewPanel.style.display = 'none'; } }, 500); }; if (state.editorView) { // Attach to CodeMirror var extUpdate = state.editorView.state.updateListener; // Use the existing update listener approach - just add debounce on any change } modal.classList.add("active"); safeCreateIcons(); // Initial preview if content has mermaid updateMermaidPreview(); } 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"); } } function closeEditor() { const vault = state.editorVault; const path = state.editorPath; const modal = document.getElementById("editor-modal"); if (!modal) return; modal.classList.remove("active"); resetSaveButton(); // Leaving the editor must also leave native fullscreen. #101 if (isEditorFullscreen() && document.exitFullscreen) { const fsPromise = document.exitFullscreen(); if (fsPromise && fsPromise.catch) fsPromise.catch(() => {}); } stopCollab(); if (state.editorView) { state.editorView.destroy(); state.editorView = null; } state.fallbackEditorEl = null; clearTimeout(window._obsigateAutoSaveTimer); // Remove forge iframe if present var forgeFrame = document.getElementById("forge-iframe"); if (forgeFrame) forgeFrame.remove(); // #93 — hand the editor container back to the modal BEFORE restoring the // header/footer/brand: they live inside the container and therefore travel // with it in inline mode (a `modal.querySelector` would miss them). const host = unmountEditorInline(); var brand = queryEditor('.editor-brand'); if (brand) brand.textContent = 'ObsiGate'; var headerEl = queryEditor(".editor-header"); var footerEl = queryEditor(".editor-footer"); if (headerEl) headerEl.style.display = ""; if (footerEl) footerEl.style.display = ""; _invalidateActiveTabCache(vault, path); state.editorVault = null; state.editorPath = null; if (host) { const rereadVault = state.currentVault || vault; const rereadPath = state.currentPath || path; if (rereadVault && rereadPath) openFile(rereadVault, rereadPath); } else if (vault && path && state.currentVault === vault && state.currentPath === path) { // Refresh file content if we're viewing the edited file openFile(vault, path); } } /** * Drop the cached render of a file in the active tab/panel so that activating * it again re-fetches it from disk instead of restoring the pre-edit HTML. */ function _invalidateActiveTabCache(vault, path) { if (!vault || !path) return; try { const split = window.PaneManager && typeof window.PaneManager.isSplit === 'function' && window.PaneManager.isSplit(); const tm = split && typeof window.getActiveTabManager === 'function' ? window.getActiveTabManager() : window.TabManager; if (!tm || !tm._tabCache) return; const tab = (tm._tabs || []).find((tb) => tb.vault === vault && tb.path === path); const key = tab ? tab.id : `${vault}::${path}`; if (tm._tabCache[key]) tm._tabCache[key].data = null; } catch (err) { /* best effort — a stale cache only means a stale re-render */ } } /** * Reload the displayed document after an external write (AI assistant tool * `edit_file` / `append_to_file` / `create_file`, #93). * * Without it the outcome is wrong in two ways: the read view keeps showing the * pre-write content, and — worse — an open editor holds the old text in memory * and its 2s autosave would overwrite the assistant's change with it. */ async function reloadExternalWrite(vault, path, force = false) { if (!vault || !path) return; const isEdited = state.editorVault === vault && state.editorPath === path; if (isEdited) { // Forge hosts its own buffer inside an iframe: ask it to reload from disk. var forgeFrame = document.getElementById("forge-iframe"); if (forgeFrame && forgeFrame.contentWindow) { forgeFrame.contentWindow.postMessage({ type: 'parent-reload', force: !!force }, '*'); return; } if (!state.editorView) return; // BUG-055 — a non-forced reload (SSE `index_updated`) is often caused by the // editor's own autosave: reloading then would clobber edits made after the // save. Skip while the save dot reports unsaved local changes. var dirtyDot = document.getElementById("editor-save-dot"); if (!force && dirtyDot && dirtyDot.classList.contains("dirty")) return; try { const rawUrl = `/api/file/${encodeURIComponent(vault)}/raw?path=${encodeURIComponent(path)}`; const rawData = await api(rawUrl); const view = state.editorView; if (!view || !view.state) return; const raw = rawData && rawData.raw != null ? rawData.raw : ""; if (view.state.doc.toString() === raw) return; clearTimeout(window._obsigateAutoSaveTimer); suppressAutoSaveOnce = true; view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: raw }, selection: { anchor: Math.min(view.state.selection.main.anchor, raw.length) }, }); var sd = document.getElementById("editor-save-dot"); var sl = document.getElementById("editor-save-label"); if (sd) sd.className = "editor-save-dot ok"; if (sl) sl.textContent = "Saved"; } catch (err) { console.warn("Inline editor: reload after external write failed", err); } return; } // Not editing: refresh the read view when it shows the written document. if (state.currentVault === vault && state.currentPath === path) { // The spreadsheet viewer applies its own writes dynamically (values, // styles and structure stay in the DOM): reloading it here would wipe the // grid behind a loading spinner on every save. Skip the reload when the // event is the watcher echo of our own write (recent stamp) or when // unsaved edits are still on screen (they would be clobbered). Forced // reloads (assistant writes) still go through. const xlsxOpen = !force && document.querySelector("#content-area .xlsx-viewer"); if (xlsxOpen) { const own = window.__xlsxLastOwnWrite; const recentOwn = own && own.vault === vault && own.path === path && (Date.now() - (own.at || 0)) < 15000; const hasDirty = Boolean(document.querySelector("#content-area td.xlsx-dirty")); if (recentOwn || hasDirty) return; } // Excalidraw owns its iframe: re-rendering would recreate it (visible page // refresh) and discard the in-editor scene. Its own save already persisted // the file, so there is nothing to reload here. const openExcalidraw = Array.from( document.querySelectorAll("iframe[data-excalidraw-path]") ).some((f) => f.dataset.excalidrawVault === vault && f.dataset.excalidrawPath === path); if (openExcalidraw) return; _invalidateActiveTabCache(vault, path); openFile(vault, path); } } /** * Restore the save button to its idle state (checkmark, enabled). * * The button (`#editor-save`) is a single shared DOM node reused across every * edition session, including inline mode where it travels with the editor * container. A manual save swaps its content for a spinner and disables it; if * that state is not cleared on success/failure, the spinner leaks into the next * session and only a full page reload (which re-parses index.html) brings the * checkmark back. BUG-054. */ function resetSaveButton() { const saveBtn = document.getElementById("editor-save"); if (!saveBtn) return; saveBtn.disabled = false; saveBtn.innerHTML = '✓'; saveBtn.style.background = ''; saveBtn.style.color = ''; saveBtn.style.borderColor = ''; } async function saveFile(silent = false) { // If Forge is open, delegate save to the iframe var forgeFrame = document.getElementById("forge-iframe"); if (forgeFrame && forgeFrame.contentWindow) { if (!state.editorVault || !state.editorPath) return; var fSaveBtn = document.getElementById("editor-save"); var fSaveDot = document.getElementById("editor-save-dot"); var fSaveLabel = document.getElementById("editor-save-label"); if (fSaveDot) { fSaveDot.className = 'editor-save-dot saving'; } if (fSaveLabel) fSaveLabel.textContent = 'Saving...'; if (fSaveBtn) { fSaveBtn.disabled = true; } forgeFrame.contentWindow.postMessage({ type: 'parent-save' }, '*'); return; } if ((!state.editorView && !state.fallbackEditorEl) || !state.editorVault || !state.editorPath) return; const content = state.editorView ? state.editorView.state.doc.toString() : state.fallbackEditorEl.value; const saveBtn = document.getElementById("editor-save"); const saveDot = document.getElementById("editor-save-dot"); const saveLabel = document.getElementById("editor-save-label"); try { // Update save dot to saving state if (saveDot) { saveDot.className = 'editor-save-dot saving'; } if (saveLabel) saveLabel.textContent = 'Saving...'; if (saveBtn && !silent) { saveBtn.disabled = true; saveBtn.innerHTML = ''; safeCreateIcons(); } const backupParam = silent ? '&backup=false' : ''; const response = await fetch(`/api/file/${encodeURIComponent(state.editorVault)}/save?path=${encodeURIComponent(state.editorPath)}${backupParam}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content }), }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || "Erreur de sauvegarde"); } // BUG-055 — only mark clean when nothing changed while saving; otherwise // keep the unsaved state and schedule another autosave (an SSE reload of // the file must not drop edits typed during the request). const currentContent = state.editorView ? state.editorView.state.doc.toString() : state.fallbackEditorEl ? state.fallbackEditorEl.value : content; if (silent && currentContent !== content) { if (saveDot) { saveDot.className = 'editor-save-dot dirty'; } if (saveLabel) saveLabel.textContent = 'Unsaved'; clearTimeout(window._obsigateAutoSaveTimer); window._obsigateAutoSaveTimer = setTimeout(() => saveFile(true), 2000); } else { if (saveDot) { saveDot.className = 'editor-save-dot ok'; } if (saveLabel) saveLabel.textContent = 'Saved'; } if (silent) { // Auto-save: brief green flash on save button if (saveBtn) { saveBtn.style.background = '#22c55e'; saveBtn.style.color = '#fff'; saveBtn.style.borderColor = '#22c55e'; } setTimeout(() => { if (saveBtn) { saveBtn.style.background = ''; saveBtn.style.color = ''; saveBtn.style.borderColor = ''; } }, 1500); } else { closeEditor(); } } catch (err) { console.error("Save error:", err); resetSaveButton(); if (saveDot) { saveDot.className = 'editor-save-dot err'; } if (saveLabel) saveLabel.textContent = 'Erreur'; // If offline, queue the save for later sync if (!navigator.onLine) { try { const { queueOfflineSave } = await import('./offline.js'); await queueOfflineSave(state.editorVault, state.editorPath, content); alert('Vous etes hors-ligne. La modification a ete enregistree localement.'); } catch (e) { alert('Erreur: ' + err.message); } } else { alert('Erreur: ' + err.message); } } } async function deleteFile() { // If Forge is open, delegate delete to the iframe var forgeFrame = document.getElementById("forge-iframe"); if (forgeFrame && forgeFrame.contentWindow) { if (!state.editorVault || !state.editorPath) return; forgeFrame.contentWindow.postMessage({ type: 'parent-delete' }, '*'); return; } if (!state.editorVault || !state.editorPath) return; const deleteBtn = document.getElementById("editor-delete"); const originalHTML = deleteBtn.innerHTML; let success = false; try { deleteBtn.disabled = true; deleteBtn.innerHTML = ''; safeCreateIcons(); const response = await fetch(`/api/file/${encodeURIComponent(state.editorVault)}?path=${encodeURIComponent(state.editorPath)}`, { method: "DELETE" }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || "Erreur de suppression"); } success = true; closeEditor(); showWelcome(); await refreshSidebarForContext(); await refreshTagsForContext(); } catch (err) { console.error("Delete error:", err); alert(`Erreur: ${err.message}`); } finally { deleteBtn.innerHTML = originalHTML; deleteBtn.disabled = false; if (success) safeCreateIcons(); } } // --------------------------------------------------------------------------- // Fullscreen (#101) // --------------------------------------------------------------------------- const FS_ENTER_SVG = ''; const FS_EXIT_SVG = ''; /** True when the editor container currently owns the fullscreen viewport. */ function isEditorFullscreen() { if (!document.fullscreenElement) return false; const container = getEditorContainer(); return !!(container && (document.fullscreenElement === container || container.contains(document.fullscreenElement))); } /** Toggle native fullscreen on the editor container (modal or inline). */ function toggleEditorFullscreen() { const container = getEditorContainer(); if (!container) return; if (document.fullscreenElement) { if (document.exitFullscreen) { const p = document.exitFullscreen(); if (p && p.catch) p.catch(() => {}); } return; } if (container.requestFullscreen) { const p = container.requestFullscreen(); if (p && p.catch) p.catch(() => {}); } } /** Sync the fullscreen button icon/label with the current fullscreen state. */ function updateFullscreenButton() { const btn = document.getElementById("editor-fullscreen"); if (!btn) return; const on = isEditorFullscreen(); btn.innerHTML = on ? FS_EXIT_SVG : FS_ENTER_SVG; const label = t(on ? "editor.exit_fullscreen" : "editor.fullscreen"); btn.title = label; btn.setAttribute("aria-label", label); btn.classList.toggle("active", on); } function initEditor() { const cancelBtn = document.getElementById("editor-cancel"); const deleteBtn = document.getElementById("editor-delete"); const saveBtn = document.getElementById("editor-save"); const fullscreenBtn = document.getElementById("editor-fullscreen"); const modal = document.getElementById("editor-modal"); cancelBtn.addEventListener("click", closeEditor); deleteBtn.addEventListener("click", deleteFile); saveBtn.addEventListener("click", () => saveFile()); if (fullscreenBtn) fullscreenBtn.addEventListener("click", toggleEditorFullscreen); document.addEventListener("fullscreenchange", updateFullscreenButton); // ESC to close — unless we are in native fullscreen, where Escape exits // fullscreen first (the editor must stay open). #101 document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !document.fullscreenElement && 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 }, ); // #93 — The AI assistant wrote to a vault file: reload the displayed // document (inline editor, Forge iframe or read view) so the change is // visible right away and never overwritten by the editor's autosave. window.addEventListener("obsigate:file-written", (e) => { const detail = (e && e.detail) || {}; // force: the assistant's write must win over the stale local buffer. reloadExternalWrite(detail.vault, detail.path, true); }); } /** Copy text to clipboard — works on both HTTP and HTTPS */ function copyToClipboard(text) { // Try modern API first (HTTPS-only) if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text).catch(() => {}); return true; } // Fallback for HTTP contexts const ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.left = "-9999px"; ta.style.top = "-9999px"; document.body.appendChild(ta); ta.focus(); ta.select(); try { document.execCommand("copy"); return true; } catch (e) { return false; } finally { document.body.removeChild(ta); } } export { getFileIcon, safeCreateIcons, flushIcons, safeHighlight, escapeHtml, EXT_ICONS, openEditor, closeEditor, reloadExternalWrite, saveFile, deleteFile, waitForCodeMirror, initEditor, copyToClipboard, };