970 lines
32 KiB
JavaScript
970 lines
32 KiB
JavaScript
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
|
|
".png": "file-image",
|
|
".jpg": "file-image",
|
|
".jpeg": "file-image",
|
|
".gif": "file-image",
|
|
".svg": "file-image",
|
|
".webp": "file-image",
|
|
".bmp": "file-image",
|
|
".ico": "file-image",
|
|
".tiff": "file-image",
|
|
".tif": "file-image",
|
|
|
|
// Audio files
|
|
".mp3": "file-music",
|
|
".wav": "file-music",
|
|
".flac": "file-music",
|
|
".aac": "file-music",
|
|
".ogg": "file-music",
|
|
".m4a": "file-music",
|
|
".wma": "file-music",
|
|
|
|
// Video files
|
|
".mp4": "play",
|
|
".avi": "play",
|
|
".mov": "play",
|
|
".wmv": "play",
|
|
".flv": "play",
|
|
".webm": "play",
|
|
".mkv": "play",
|
|
".m4v": "play",
|
|
".3gp": "play",
|
|
|
|
// 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, ">").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 = '<pre><code class="language-mermaid">' + code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') + '</code></pre>';
|
|
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) {
|
|
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' }, '*');
|
|
return;
|
|
}
|
|
if (!state.editorView) 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) {
|
|
_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 = '<i data-lucide="loader" style="width:16px;height:16px"></i>';
|
|
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");
|
|
}
|
|
|
|
// Update save dot to saved state
|
|
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 = '<i data-lucide="loader" style="width:16px;height:16px"></i>';
|
|
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 = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>';
|
|
const FS_EXIT_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/></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) || {};
|
|
reloadExternalWrite(detail.vault, detail.path);
|
|
});
|
|
}
|
|
|
|
/** 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,
|
|
};
|