// 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, docsOrigin: null }; 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 : []); __ooCaps.docsOrigin = j.docsOrigin || null; // harmless if server doesn't send it } } 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 || ''); } // ---- script/css single-load with timeout guards ---- const _loadedScripts = new Set(); const _loadedCss = new Set(); let _corePromise = null; function loadScriptOnce(url, timeoutMs = 12000) { return new Promise((resolve, reject) => { if (_loadedScripts.has(url)) return resolve(); const s = document.createElement("script"); const timer = setTimeout(() => { try { s.remove(); } catch { } reject(new Error(`Timeout loading: ${url}`)); }, timeoutMs); s.src = url; s.async = true; s.onload = () => { clearTimeout(timer); _loadedScripts.add(url); resolve(); }; s.onerror = () => { clearTimeout(timer); 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; 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 = 300; // allow closing immediately; don't wait forever // ==== /CodeMirror lazy loader =============================================== // ---- OO preconnect / prewarm ---- function injectOOPreconnect(origin) { try { if (!origin || !isAbsoluteHttpUrl(origin)) return; const make = (rel) => { const l = document.createElement('link'); l.rel = rel; l.href = origin; return l; }; document.head.appendChild(make('dns-prefetch')); document.head.appendChild(make('preconnect')); } catch { } } async function ensureOnlyOfficeApi(srcFromConfig, originFromConfig) { // Prefer explicit src; else derive from origin; else fall back to window/global or default prefix path let src = srcFromConfig; if (!src) { if (originFromConfig && isAbsoluteHttpUrl(originFromConfig)) { src = originFromConfig.replace(/\/$/, '') + '/web-apps/apps/api/documents/api.js'; } else { src = window.ONLYOFFICE_API_SRC || '/onlyoffice/web-apps/apps/api/documents/api.js'; } } if (window.DocsAPI && typeof window.DocsAPI.DocEditor === 'function') return; // Try once; if it times out and we derived from origin, fall back to the default prefix path try { console.time('oo:api.js'); await loadScriptOnce(src); } catch (e) { if (src !== '/onlyoffice/web-apps/apps/api/documents/api.js') { await loadScriptOnce('/onlyoffice/web-apps/apps/api/documents/api.js'); } else { throw e; } } finally { console.timeEnd('oo:api.js'); } } // ===== ONLYOFFICE: full-screen modal + warm on every click ===== const ALWAYS_WARM_OO = true; // warm EVERY time const OO_WARM_MS = 300; function ensureOoModalCss() { const prev = document.getElementById('ooEditorModalCss'); if (prev) return; const style = document.createElement('style'); style.id = 'ooEditorModalCss'; style.textContent = ` #ooEditorModal{ --oo-header-h: 40px; --oo-header-pad-v: 12px; --oo-header-pad-h: 18px; --oo-logo-h: 26px; /* tweak logo size */ } #ooEditorModal{ position:fixed!important; inset:0!important; margin:0!important; padding:0!important; display:flex!important; flex-direction:column!important; z-index:2147483646!important; background:var(--oo-modal-bg,#111)!important; } /* Header: logo (left) + title (fill) + absolute close (right) */ #ooEditorModal .editor-header{ position:relative; display:flex; align-items:center; gap:12px; min-height:var(--oo-header-h); padding:var(--oo-header-pad-v) var(--oo-header-pad-h); padding-right: calc(var(--oo-header-pad-h) + 64px); /* room for 32px round close */ border-bottom:1px solid rgba(0,0,0,.15); box-sizing:border-box; } #ooEditorModal .editor-logo{ height:var(--oo-logo-h); width:auto; flex:0 0 auto; display:block; user-select:none; -webkit-user-drag:none; } #ooEditorModal .editor-title{ margin:0; font-size:18px; font-weight:700; line-height:1.2; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; flex:1 1 auto; } /* Your scoped close button style */ #ooEditorModal .editor-close-btn{ position:absolute; top:5px; right:10px; display:flex; justify-content:center; align-items:center; font-size:20px; font-weight:bold; cursor:pointer; z-index:1000; width:32px; height:32px; border-radius:50%; text-align:center; line-height:30px; color:#ff4d4d; background-color:rgba(255,255,255,.9); border:2px solid transparent; transition:all .3s ease-in-out; } #ooEditorModal .editor-close-btn:hover{ color:#fff; background-color:#ff4d4d; box-shadow:0 0 6px rgba(255,77,77,.8); transform:scale(1.05); } .dark-mode #ooEditorModal .editor-close-btn{ background-color:rgba(0,0,0,.7); color:#ff6666; } .dark-mode #ooEditorModal .editor-close-btn:hover{ background-color:#ff6666; color:#000; } #ooEditorModal .editor-body{ position:relative!important; flex:1 1 auto!important; min-height:0!important; overflow:hidden!important; } #ooEditorModal #oo-editor{ width:100%!important; height:100%!important; } #ooEditorModal .oo-warm-overlay{ position:absolute; inset:0; display:flex; align-items:center; justify-content:center; background:rgba(0,0,0,.14); z-index:5; font-weight:600; font-size:14px; } html.oo-lock, body.oo-lock{ height:100%!important; overflow:hidden!important; } `; document.head.appendChild(style); } // Theme-aware background so there’s no white/gray edge function applyModalBg(modal){ const isDark = document.documentElement.classList.contains('dark-mode') || /^(1|true)$/i.test(localStorage.getItem('darkMode') || ''); const cs = getComputedStyle(document.documentElement); const bg = (cs.getPropertyValue('--bg-color') || cs.getPropertyValue('--pre-bg') || '').trim() || (isDark ? '#121212' : '#ffffff'); modal.style.setProperty('--oo-modal-bg', bg); } function lockPageScroll(on){ [document.documentElement, document.body].forEach(el => el.classList.toggle('oo-lock', !!on)); } function ensureOoFullscreenModal(){ ensureOoModalCss(); let modal = document.getElementById('ooEditorModal'); if (!modal){ modal = document.createElement('div'); modal.id = 'ooEditorModal'; modal.innerHTML = `

`; document.body.appendChild(modal); } else { modal.querySelector('.editor-body').innerHTML = `
`; // ensure logo exists and is placed before title when reusing const header = modal.querySelector('.editor-header'); if (!header.querySelector('.editor-logo')){ const img = document.createElement('img'); img.className = 'editor-logo'; img.src = '/assets/logo.svg'; img.alt = 'FileRise logo'; header.insertBefore(img, header.querySelector('.editor-title')); } else { // make sure order is logo -> title const logo = header.querySelector('.editor-logo'); const title = header.querySelector('.editor-title'); if (logo.nextElementSibling !== title){ header.insertBefore(logo, title); } } } applyModalBg(modal); modal.style.display = 'flex'; modal.focus(); lockPageScroll(true); return modal; } // Overlay lives INSIDE the modal body function setOoBusy(modal, on, label='Preparing editor…'){ if (!modal) return; const body = modal.querySelector('.editor-body'); let ov = body.querySelector('.oo-warm-overlay'); if (on){ if (!ov){ ov = document.createElement('div'); ov.className = 'oo-warm-overlay'; ov.textContent = label; body.appendChild(ov); } } else if (ov){ ov.remove(); } } // Hidden warm-up DocEditor (creates DS session/cache) then destroys async function warmDocServerOnce(cfg){ let host = null, warmEditor = null; try{ host = document.createElement('div'); host.id = 'oo-warm-' + Math.random().toString(36).slice(2); Object.assign(host.style, { position:'absolute', left:'-99999px', top:'0', width:'2px', height:'2px', overflow:'hidden' }); document.body.appendChild(host); const warmCfg = JSON.parse(JSON.stringify(cfg)); warmCfg.events = Object.assign({}, warmCfg.events, { onAppReady(){}, onDocumentReady(){} }); warmEditor = new window.DocsAPI.DocEditor(host.id, warmCfg); await new Promise(res => setTimeout(res, OO_WARM_MS)); }catch{} finally{ try{ warmEditor?.destroyEditor?.(); }catch{} try{ host?.remove(); }catch{} } } // Full-screen OO open with hidden warm-up EVERY click, then real editor async function openOnlyOffice(fileName, folder){ let editor = null; let removeThemeListener = () => {}; let cfg = null; let userClosed = false; // Build our full-screen modal const modal = ensureOoFullscreenModal(); const titleEl = modal.querySelector('.editor-title'); if (titleEl) titleEl.innerHTML = `${t("editing")}: ${escapeHTML(fileName)}`; const destroy = (removeModal = true) => { try { editor?.destroyEditor?.(); } catch {} try { removeThemeListener(); } catch {} if (removeModal) { try { modal.remove(); } catch {} } lockPageScroll(false); }; const onClose = () => { userClosed = true; destroy(true); }; modal.querySelector('#closeEditorX')?.addEventListener('click', onClose); modal.addEventListener('keydown', (e) => { if (e.key === 'Escape') onClose(); }); try{ // 1) Fetch config const url = `/api/onlyoffice/config.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(fileName)}`; const resp = await fetch(url, { credentials: 'include' }); const text = await resp.text(); 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}`); // 2) Preconnect + load DocsAPI injectOOPreconnect(cfg.documentServerOrigin || null); await ensureOnlyOfficeApi(cfg.docs_api_js, cfg.documentServerOrigin); // 3) Theme + base events const isDark = document.documentElement.classList.contains('dark-mode') || /^(1|true)$/i.test(localStorage.getItem('darkMode') || ''); cfg.events = (cfg.events && typeof cfg.events === 'object') ? cfg.events : {}; cfg.editorConfig = cfg.editorConfig || {}; cfg.editorConfig.customization = Object.assign( {}, cfg.editorConfig.customization, { uiTheme: isDark ? 'theme-dark' : 'theme-light' } ); cfg.events.onRequestClose = () => onClose(); // 4) Warm EVERY click if (ALWAYS_WARM_OO && !userClosed){ setOoBusy(modal, true); // overlay INSIDE modal body await warmDocServerOnce(cfg); if (userClosed) return; } // 5) Launch visible editor in full-screen modal cfg.events.onDocumentReady = () => { setOoBusy(modal, false); }; editor = new window.DocsAPI.DocEditor('oo-editor', cfg); // Live theme switching + keep modal bg in sync 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'); } applyModalBg(modal); }; 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.'); destroy(true); } } // ---- /ONLYOFFICE integration ---------------------------------------------- // ==== Editor (CodeMirror) path ============================================= 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 = `

${t("editing")}: ${escapeHTML(fileName)} ${forcePlainText ? " (plain text mode)" : ""}

`; 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