// fileEditor.js
import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
import { buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}';
// thresholds for editor behavior
const EDITOR_PLAIN_THRESHOLD = 5 * 1024 * 1024; // >5 MiB => force plain text, lighter settings
const EDITOR_BLOCK_THRESHOLD = 10 * 1024 * 1024; // >10 MiB => block editing
// ==== CodeMirror lazy loader ===============================================
const CM_BASE = "/vendor/codemirror/5.65.5/";
// Stamp-friendly helpers (the stamper will replace {{APP_QVER}})
const coreUrl = (p) => `${CM_BASE}${p}?v={{APP_QVER}}`;
const CORE = {
js: coreUrl("codemirror.min.js"),
css: coreUrl("codemirror.min.css"),
themeCss: coreUrl("theme/material-darker.min.css"),
};
// Which mode file to load for a given name/mime
const MODE_URL = {
// core/common
"xml": "mode/xml/xml.min.js?v={{APP_QVER}}",
"css": "mode/css/css.min.js?v={{APP_QVER}}",
"javascript": "mode/javascript/javascript.min.js?v={{APP_QVER}}",
// meta / combos
"htmlmixed": "mode/htmlmixed/htmlmixed.min.js?v={{APP_QVER}}",
"application/x-httpd-php": "mode/php/php.min.js?v={{APP_QVER}}",
// docs / data
"markdown": "mode/markdown/markdown.min.js?v={{APP_QVER}}",
"yaml": "mode/yaml/yaml.min.js?v={{APP_QVER}}",
"properties": "mode/properties/properties.min.js?v={{APP_QVER}}",
"sql": "mode/sql/sql.min.js?v={{APP_QVER}}",
// shells
"shell": "mode/shell/shell.min.js?v={{APP_QVER}}",
// languages
"python": "mode/python/python.min.js?v={{APP_QVER}}",
"text/x-csrc": "mode/clike/clike.min.js?v={{APP_QVER}}",
"text/x-c++src": "mode/clike/clike.min.js?v={{APP_QVER}}",
"text/x-java": "mode/clike/clike.min.js?v={{APP_QVER}}",
"text/x-csharp": "mode/clike/clike.min.js?v={{APP_QVER}}",
"text/x-kotlin": "mode/clike/clike.min.js?v={{APP_QVER}}"
};
// Mode dependency graph
const MODE_DEPS = {
"htmlmixed": ["xml", "javascript", "css"],
"application/x-httpd-php": ["htmlmixed", "text/x-csrc"], // php overlays + clike bits
"markdown": ["xml"]
};
// Map any mime/alias to the key we use in MODE_URL
function normalizeModeName(modeOption) {
const name = typeof modeOption === "string" ? modeOption : (modeOption && modeOption.name);
if (!name) return null;
if (name === "text/html") return "htmlmixed"; // CodeMirror uses htmlmixed for HTML
if (name === "php") return "application/x-httpd-php"; // prefer the full mime
return name;
}
// ---- ONLYOFFICE integration -----------------------------------------------
function getExt(name) { const i = name.lastIndexOf('.'); return i >= 0 ? name.slice(i + 1).toLowerCase() : ''; }
// Cache OO capabilities (enabled flag + ext list) from /api/onlyoffice/status.php
let __ooCaps = { enabled: false, exts: new Set(), fetched: false };
async function fetchOnlyOfficeCapsOnce() {
if (__ooCaps.fetched) return __ooCaps;
try {
const r = await fetch('/api/onlyoffice/status.php', { credentials: 'include' });
if (r.ok) {
const j = await r.json();
__ooCaps.enabled = !!j.enabled;
__ooCaps.exts = new Set(Array.isArray(j.exts) ? j.exts : []);
}
} catch { /* ignore; keep defaults */ }
__ooCaps.fetched = true;
return __ooCaps;
}
async function shouldUseOnlyOffice(fileName) {
const { enabled, exts } = await fetchOnlyOfficeCapsOnce();
return enabled && exts.has(getExt(fileName));
}
function isAbsoluteHttpUrl(u) { return /^https?:\/\//i.test(u || ''); }
async function ensureOnlyOfficeApi(srcFromConfig, originFromConfig) {
let src =
srcFromConfig ||
(originFromConfig ? originFromConfig.replace(/\/$/, '') + '/web-apps/apps/api/documents/api.js'
: (window.ONLYOFFICE_API_SRC || '/onlyoffice/web-apps/apps/api/documents/api.js'));
if (window.DocsAPI && typeof window.DocsAPI.DocEditor === 'function') return;
await loadScriptOnce(src);
}
async function openOnlyOffice(fileName, folder) {
let editor; // make visible to the whole function
try {
const url = `/api/onlyoffice/config.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(fileName)}`;
const resp = await fetch(url, { credentials: 'include' });
const text = await resp.text();
let cfg;
try { cfg = JSON.parse(text); } catch {
throw new Error(`ONLYOFFICE config parse failed (HTTP ${resp.status}). First 120 chars: ${text.slice(0,120)}`);
}
if (!resp.ok) throw new Error(cfg.error || `ONLYOFFICE config HTTP ${resp.status}`);
// Must be absolute
const docUrl = cfg?.document?.url;
const cbUrl = cfg?.editorConfig?.callbackUrl;
if (!/^https?:\/\//i.test(docUrl || '') || !/^https?:\/\//i.test(cbUrl || '')) {
throw new Error(`Config URLs must be absolute. document.url='${docUrl}', callbackUrl='${cbUrl}'`);
}
// Load DocsAPI if needed
await ensureOnlyOfficeApi(cfg.docs_api_js, cfg.documentServerOrigin);
// Modal
const modal = document.createElement('div');
modal.id = 'ooEditorModal';
modal.classList.add('modal', 'editor-modal');
modal.setAttribute('tabindex', '-1');
modal.innerHTML = `
`;
document.body.appendChild(modal);
modal.style.display = 'block';
modal.focus();
// We’ll fill this after wiring the toggle, so destroy() can unhook it
let removeThemeListener = () => {};
const destroy = () => {
try { editor?.destroyEditor?.(); } catch {}
try { removeThemeListener(); } catch {}
try { modal.remove(); } catch {}
};
modal.addEventListener('keydown', e => { if (e.key === 'Escape') destroy(); });
document.getElementById('closeEditorX')?.addEventListener('click', destroy);
// Let DS request closing
cfg.events = Object.assign({}, cfg.events, { onRequestClose: destroy });
// Initial theme
const isDark =
document.documentElement.classList.contains('dark-mode') ||
/^(1|true)$/i.test(localStorage.getItem('darkMode') || '');
cfg.editorConfig = cfg.editorConfig || {};
cfg.editorConfig.customization = Object.assign(
{},
cfg.editorConfig.customization,
{ uiTheme: isDark ? 'theme-dark' : 'theme-light' } // <- correct key/value
);
// Launch editor
editor = new window.DocsAPI.DocEditor('oo-editor', cfg);
// Live theme switching (ONLYOFFICE v7.2+ supports setTheme)
const darkToggle = document.getElementById('darkModeToggle');
const onDarkToggle = () => {
const nowDark = document.documentElement.classList.contains('dark-mode');
if (editor && typeof editor.setTheme === 'function') {
editor.setTheme(nowDark ? 'dark' : 'light');
}
};
if (darkToggle) {
darkToggle.addEventListener('click', onDarkToggle);
removeThemeListener = () => darkToggle.removeEventListener('click', onDarkToggle);
}
} catch (e) {
console.error('[ONLYOFFICE] failed to open:', e);
showToast((e && e.message) ? e.message : 'Unable to open ONLYOFFICE editor.');
}
}
// ---- /ONLYOFFICE integration ----------------------------------------------
const _loadedScripts = new Set();
const _loadedCss = new Set();
let _corePromise = null;
function loadScriptOnce(url) {
return new Promise((resolve, reject) => {
if (_loadedScripts.has(url)) return resolve();
const s = document.createElement("script");
s.src = url;
s.async = true;
s.onload = () => { _loadedScripts.add(url); resolve(); };
s.onerror = () => reject(new Error(`Load failed: ${url}`));
document.head.appendChild(s);
});
}
function loadCssOnce(href) {
return new Promise((resolve, reject) => {
if (_loadedCss.has(href)) return resolve();
const l = document.createElement("link");
l.rel = "stylesheet";
l.href = href;
l.onload = () => { _loadedCss.add(href); resolve(); };
l.onerror = () => reject(new Error(`Load failed: ${href}`));
document.head.appendChild(l);
});
}
async function ensureCore() {
if (_corePromise) return _corePromise;
_corePromise = (async () => {
// load CSS first to avoid FOUC
await loadCssOnce(CORE.css);
await loadCssOnce(CORE.themeCss);
if (!window.CodeMirror) {
await loadScriptOnce(CORE.js);
}
})();
return _corePromise;
}
async function loadSingleMode(name) {
const rel = MODE_URL[name];
if (!rel) return;
// prepend base if needed
const url = rel.startsWith("http") ? rel : (rel.startsWith("/") ? rel : (CM_BASE + rel));
await loadScriptOnce(url);
}
function isModeRegistered(name) {
return !!(
(window.CodeMirror?.modes && window.CodeMirror.modes[name]) ||
(window.CodeMirror?.mimeModes && window.CodeMirror.mimeModes[name])
);
}
async function ensureModeLoaded(modeOption) {
await ensureCore();
const name = normalizeModeName(modeOption);
if (!name) return;
if (isModeRegistered(name)) return;
const deps = MODE_DEPS[name] || [];
for (const d of deps) {
if (!isModeRegistered(d)) await loadSingleMode(d);
}
await loadSingleMode(name);
}
// Public helper for callers (we keep your existing function name in use):
const MODE_LOAD_TIMEOUT_MS = 2500; // allow closing immediately; don't wait forever
// ==== /CodeMirror lazy loader ===============================================
function getModeForFile(fileName) {
const dot = fileName.lastIndexOf(".");
const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : "";
switch (ext) {
case "html":
case "htm": return "text/html";
case "xml": return "xml";
case "md":
case "markdown": return "markdown";
case "yml":
case "yaml": return "yaml";
case "css": return "css";
case "js": return "javascript";
case "json": return { name: "javascript", json: true };
case "php": return "application/x-httpd-php";
case "py": return "python";
case "sql": return "sql";
case "sh":
case "bash":
case "zsh":
case "bat": return "shell";
case "ini":
case "conf":
case "config":
case "properties": return "properties";
case "c":
case "h": return "text/x-csrc";
case "cpp":
case "cxx":
case "hpp":
case "hh":
case "hxx": return "text/x-c++src";
case "java": return "text/x-java";
case "cs": return "text/x-csharp";
case "kt":
case "kts": return "text/x-kotlin";
default: return "text/plain";
}
}
export { getModeForFile };
function adjustEditorSize() {
const modal = document.querySelector(".editor-modal");
if (modal && window.currentEditor) {
const headerHeight = 60; // adjust as needed
const availableHeight = modal.clientHeight - headerHeight;
window.currentEditor.setSize("100%", availableHeight + "px");
}
}
export { adjustEditorSize };
function observeModalResize(modal) {
if (!modal) return;
const resizeObserver = new ResizeObserver(() => adjustEditorSize());
resizeObserver.observe(modal);
}
export { observeModalResize };
export async function editFile(fileName, folder) {
// destroy any previous editor
let existingEditor = document.getElementById("editorContainer");
if (existingEditor) existingEditor.remove();
const folderUsed = folder || window.currentFolder || "root";
const fileUrl = buildPreviewUrl(folderUsed, fileName);
if (await shouldUseOnlyOffice(fileName)) {
await openOnlyOffice(fileName, folderUsed);
return;
}
// Probe size safely via API. Prefer HEAD; if missing Content-Length, fall back to a 1-byte Range GET.
async function probeSize(url) {
try {
const h = await fetch(url, { method: "HEAD", credentials: "include" });
const len = h.headers.get("content-length") ?? h.headers.get("Content-Length");
if (len && !Number.isNaN(parseInt(len, 10))) return parseInt(len, 10);
} catch { }
try {
const r = await fetch(url, {
method: "GET",
headers: { Range: "bytes=0-0" },
credentials: "include"
});
// Content-Range: bytes 0-0/12345
const cr = r.headers.get("content-range") ?? r.headers.get("Content-Range");
const m = cr && cr.match(/\/(\d+)\s*$/);
if (m) return parseInt(m[1], 10);
} catch { }
return null;
}
probeSize(fileUrl)
.then(sizeBytes => {
if (sizeBytes !== null && sizeBytes > EDITOR_BLOCK_THRESHOLD) {
showToast("This file is larger than 10 MB and cannot be edited in the browser.");
throw new Error("File too large.");
}
return fetch(fileUrl, { credentials: "include" });
})
.then(response => {
if (!response.ok) throw new Error("HTTP error! Status: " + response.status);
const lenHeader = response.headers.get("content-length") ?? response.headers.get("Content-Length");
const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null;
return Promise.all([response.text(), sizeBytes]);
})
.then(([content, sizeBytes]) => {
const forcePlainText = sizeBytes !== null && sizeBytes > EDITOR_PLAIN_THRESHOLD;
// --- Build modal immediately and wire close controls BEFORE any async loads ---
const modal = document.createElement("div");
modal.id = "editorContainer";
modal.classList.add("modal", "editor-modal");
modal.setAttribute("tabindex", "-1"); // for Escape handling
modal.innerHTML = `
`;
document.body.appendChild(modal);
modal.style.display = "block";
modal.focus();
let canceled = false;
const doClose = () => {
canceled = true;
window.currentEditor = null;
modal.remove();
};
// Wire close actions right away
modal.addEventListener("keydown", (e) => { if (e.key === "Escape") doClose(); });
document.getElementById("closeEditorX").addEventListener("click", doClose);
document.getElementById("closeBtn").addEventListener("click", doClose);
// Keep buttons responsive even before editor exists
const decBtn = document.getElementById("decreaseFont");
const incBtn = document.getElementById("increaseFont");
decBtn.addEventListener("click", () => { });
incBtn.addEventListener("click", () => { });
// Theme + mode selection
const isDarkMode = document.body.classList.contains("dark-mode");
const theme = isDarkMode ? "material-darker" : "default";
const desiredMode = forcePlainText ? "text/plain" : getModeForFile(fileName);
// Start core+mode loading (don’t block closing)
const modePromise = (async () => {
await ensureCore(); // load CM core + CSS
if (!forcePlainText) {
await ensureModeLoaded(desiredMode); // then load the needed mode + deps
}
})();
// Wait up to MODE_LOAD_TIMEOUT_MS; then proceed with whatever is available
const timeout = new Promise((res) => setTimeout(res, MODE_LOAD_TIMEOUT_MS));
Promise.race([modePromise, timeout]).then(() => {
if (canceled) return;
if (!window.CodeMirror) {
// Core not present: keep plain