import { createStore } from "/js/AlpineStore.js"; import { callJsonApi } from "/js/api.js"; import { getNamespacedClient } from "/js/websocket.js"; import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js"; import { buildMarkdownPages, isExternalHref, isMarkdownPath, renderEditorPreviewMarkdown, resolveDocumentRelativePath, slugifyHeading, splitHref, } from "/plugins/_editor/webui/editor-preview.js"; const editorSocket = getNamespacedClient("/ws"); editorSocket.addHandlers(["ws_webui"]); const SAVE_MESSAGE_MS = 1800; const INPUT_PUSH_DELAY_MS = 650; const MAX_HISTORY = 80; const SOURCE_MODE = "source"; const PREVIEW_MODE = "preview"; function currentContextId() { try { return globalThis.getContext?.() || ""; } catch { return ""; } } function basename(path = "") { const value = String(path || "").split("?")[0].split("#")[0]; return value.split("/").filter(Boolean).pop() || "Untitled"; } function extensionOf(path = "") { const name = basename(path).toLowerCase(); const index = name.lastIndexOf("."); return index >= 0 ? name.slice(index + 1) : ""; } function parentPath(path = "") { const normalized = String(path || "").split("?")[0].split("#")[0].replace(/\/+$/, ""); const index = normalized.lastIndexOf("/"); if (index <= 0) return "/"; return normalized.slice(0, index); } function uniqueTabId(session = {}) { return String(session.file_id || session.session_id || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`); } function editorContainsFocus(element) { const active = document.activeElement; return Boolean(element && active && (element === active || element.contains(active))); } function placeCaretAtEnd(element) { if (!element) return; if (element.tagName === "TEXTAREA" || element.tagName === "INPUT") { const length = element.value?.length || 0; element.selectionStart = length; element.selectionEnd = length; return; } const selection = globalThis.getSelection?.(); const range = document.createRange?.(); if (!selection || !range) return; range.selectNodeContents(element); range.collapse(false); selection.removeAllRanges(); selection.addRange(range); } function normalizeMarkdown(doc = {}) { const path = doc.path || ""; const extension = String(doc.extension || extensionOf(path)).toLowerCase(); return { ...doc, extension, title: doc.title || doc.basename || basename(path), basename: doc.basename || basename(path), path, }; } function normalizeSession(payload = {}) { const document = normalizeMarkdown(payload.document || payload); return { ...payload, document, extension: String(payload.extension || document.extension || "").toLowerCase(), file_id: payload.file_id || document.file_id || "", path: document.path || payload.path || "", title: payload.title || document.title || document.basename || basename(document.path), tab_id: uniqueTabId(payload), text: String(payload.text || ""), dirty: Boolean(payload.dirty), active: Boolean(payload.active), }; } function documentLabel(document = {}) { return document.title || document.basename || basename(document.path); } function escapeRegExp(value = "") { return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function textNodesUnder(root, skipSelector = "") { const nodes = []; if (!root) return nodes; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(node) { if (!node.nodeValue) return NodeFilter.FILTER_REJECT; if (skipSelector && node.parentElement?.closest(skipSelector)) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; }, }); while (walker.nextNode()) nodes.push(walker.currentNode); return nodes; } function aceModeForLanguage(language = "") { const value = String(language || "").toLowerCase(); const aliases = { bash: "sh", shell: "sh", zsh: "sh", py: "python", js: "javascript", jsx: "javascript", ts: "typescript", md: "markdown", yml: "yaml", }; return aliases[value] || value || "text"; } function taskLineIndexes(markdown = "") { const indexes = []; String(markdown || "").split("\n").forEach((line, index) => { if (/^\s*(?:[-*+]|\d+[.)])\s+\[[ xX]\](?:\s+|$)/.test(line)) indexes.push(index); }); return indexes; } async function callEditor(action, payload = {}) { return await callJsonApi("/plugins/_editor/editor_session", { action, ctxid: currentContextId(), ...payload, }); } async function requestEditor(eventType, payload = {}, timeoutMs = 5000) { const response = await editorSocket.request(eventType, { ctxid: currentContextId(), ...payload, }, { timeoutMs }); const results = Array.isArray(response?.results) ? response.results : []; const first = results.find((item) => item?.ok === true && isEditorSocketData(item?.data)) || results.find((item) => item?.ok === true); if (!first) { const error = results.find((item) => item?.error)?.error; throw new Error(error?.error || error?.code || `${eventType} failed`); } if (first.data?.editor_error) { const error = first.data.editor_error; throw new Error(error.error || error.code || `${eventType} failed`); } return first.data || {}; } function isEditorSocketData(data) { if (!data || typeof data !== "object") return false; return ( Object.prototype.hasOwnProperty.call(data, "editor_error") || Object.prototype.hasOwnProperty.call(data, "ok") || Object.prototype.hasOwnProperty.call(data, "session_id") || Object.prototype.hasOwnProperty.call(data, "document") ); } const model = { status: null, tabs: [], activeTabId: "", session: null, loading: false, saving: false, dirty: false, error: "", message: "", pendingClose: null, viewMode: SOURCE_MODE, searchOpen: false, searchQuery: "", searchMatches: [], searchIndex: -1, activePageIndex: 0, previewEditing: false, previewEditDirty: false, previewEditText: "", previewEditPageIndex: -1, aceUnavailable: false, editorText: "", sourceEditor: null, _root: null, _mode: "modal", _initialized: false, _saveMessageTimer: null, _inputTimer: null, _history: [], _historyIndex: -1, _pendingFocus: false, _pendingFocusEnd: true, _focusAttempts: 0, _headerCleanup: null, _settingSourceEditorValue: false, _sourceEditorChangeHandler: null, _previewEnhanceTimer: null, _staticHighlightPromise: null, _pendingPreviewFragment: "", _initialCreatePromise: null, async init() { if (this._initialized) return; this._initialized = true; await this.refresh(); }, async onMount(element = null, options = {}) { await this.init(); if (element && element !== this._root) { if (this.sourceEditor && !element.contains?.(this.sourceEditor.container)) { this.destroySourceEditor(); } this._root = element; } this._mode = options?.mode === "canvas" ? "canvas" : "modal"; if (this._mode === "modal") { this.setupMarkdownModal(element); await this.ensureInitialMarkdownFile(); } this.scheduleSourceEditorInit(); }, async onOpen(payload = {}) { await this.init(); await this.refresh(); if (payload?.path || payload?.file_id) { await this.openSession({ path: payload.path || "", file_id: payload.file_id || "", refresh: payload.refresh === true, source: payload.source || "", }); return; } await this.ensureInitialMarkdownFile(); }, beforeHostHidden() { this.flushInput(); }, cleanup() { this.flushInput(); this.destroySourceEditor(); if (this._previewEnhanceTimer) globalThis.clearTimeout(this._previewEnhanceTimer); this._previewEnhanceTimer = null; this._headerCleanup?.(); this._headerCleanup = null; if (this._mode === "modal") this._root = null; }, beginSurfaceHandoff() { this.flushInput(); }, async refresh() { try { const status = await callEditor("status"); this.status = status || {}; this.error = ""; } catch (error) { this.error = error instanceof Error ? error.message : String(error); } }, isSourceMode() { return this.viewMode === SOURCE_MODE; }, isPreviewMode() { return this.viewMode === PREVIEW_MODE; }, async setViewMode(mode) { const next = mode === PREVIEW_MODE ? PREVIEW_MODE : SOURCE_MODE; if (this.viewMode === next) return; this.applyPreviewEdit({ silent: true }); this.syncEditorText(); this.viewMode = next; this.cancelPendingClose(); if (next === SOURCE_MODE) { this.setSourceEditorText(this.editorText); this.scheduleSourceEditorInit(); this.refreshSourceEditorLayout(); this.queueRender({ focus: Boolean(this.session), end: false }); return; } this.clampActivePage(); this.schedulePreviewEnhance(); }, async toggleViewMode() { await this.setViewMode(this.isPreviewMode() ? SOURCE_MODE : PREVIEW_MODE); }, viewModeIcon() { return this.isPreviewMode() ? "code" : "article"; }, viewModeTitle() { return this.isPreviewMode() ? "Source edit" : "Preview"; }, pages() { return buildMarkdownPages(this.editorText, this.tabTitle(this.session || {})); }, currentPage() { const pages = this.pages(); const index = Math.max(0, Math.min(this.activePageIndex, pages.length - 1)); return pages[index] || pages[0] || { title: this.tabTitle(this.session || {}), markdown: "" }; }, pageTitle() { return this.currentPage().title || this.tabTitle(this.session || {}); }, pagePositionLabel() { const pages = this.pages(); if (!pages.length) return ""; return `${Math.min(this.activePageIndex + 1, pages.length)} of ${pages.length}`; }, previewHtml() { return renderEditorPreviewMarkdown(this.currentPage().markdown || "", this.editorText); }, selectPage(index) { if (this.previewEditing) return; const pages = this.pages(); if (!pages.length) return; this.activePageIndex = Math.max(0, Math.min(Number(index) || 0, pages.length - 1)); this.schedulePreviewEnhance(); }, nextPage() { this.selectPage(this.activePageIndex + 1); }, previousPage() { this.selectPage(this.activePageIndex - 1); }, startPreviewEdit() { if (!this.session || !this.isMarkdown() || !this.isPreviewMode()) return; const page = this.currentPage(); this.previewEditing = true; this.previewEditDirty = false; this.previewEditPageIndex = this.activePageIndex; this.previewEditText = page.markdown || ""; globalThis.requestAnimationFrame?.(() => { const editor = this._root?.querySelector?.("[data-editor-preview-source]"); editor?.focus?.({ preventScroll: true }); }); }, onPreviewEditInput() { if (this.previewEditing) this.previewEditDirty = true; }, cancelPreviewEdit() { this.previewEditing = false; this.previewEditDirty = false; this.previewEditText = ""; this.previewEditPageIndex = -1; this.schedulePreviewEnhance(); }, applyPreviewEdit(options = {}) { if (!this.previewEditing) return false; if (!this.previewEditDirty && options.force !== true) { this.cancelPreviewEdit(); return false; } const pages = this.pages(); const index = Math.max(0, Math.min( this.previewEditPageIndex >= 0 ? this.previewEditPageIndex : this.activePageIndex, pages.length - 1, )); const page = pages[index]; if (!page) { this.cancelPreviewEdit(); return false; } let replacement = String(this.previewEditText || ""); this.previewEditing = false; this.previewEditDirty = false; this.previewEditText = ""; this.previewEditPageIndex = -1; return this.replacePageMarkdown(page, replacement, { message: "Page updated", silent: options.silent, }); }, replacePageMarkdown(page = null, markdown = "", options = {}) { if (!page) return false; const source = String(this.editorText || ""); const start = Math.max(0, Number(page.start || 0)); const end = Math.max(start, Number(page.end ?? source.length)); const before = source.slice(0, start); const after = source.slice(end); let replacement = String(markdown || ""); if (replacement && after && !replacement.endsWith("\n")) replacement += "\n"; const next = before + replacement + after; if (next === source) { this.schedulePreviewEnhance(); return false; } this.editorText = next; this.setSourceEditorText(next); if (this.session) { this.session.text = next; this.session.dirty = true; } this.dirty = true; this.pushHistory(next); this.scheduleInputPush(); this.clampActivePage(); this.schedulePreviewEnhance(); if (!options.silent && options.message) this.setMessage(options.message); return true; }, togglePreviewTask(taskIndex, checked) { if (!this.session || !this.isMarkdown() || !this.isPreviewMode() || this.previewEditing) return false; const page = this.currentPage(); const lines = String(page.markdown || "").split("\n"); const indexes = taskLineIndexes(page.markdown || ""); const lineIndex = indexes[Number(taskIndex)]; if (lineIndex == null || !lines[lineIndex]) return false; const nextLine = lines[lineIndex].replace( /^(\s*(?:[-*+]|\d+[.)])\s+\[)[ xX](\](?:\s+|$))/, `$1${checked ? "x" : " "}$2`, ); if (nextLine === lines[lineIndex]) return false; lines[lineIndex] = nextLine; return this.replacePageMarkdown(page, lines.join("\n")); }, clampActivePage() { const pages = this.pages(); this.activePageIndex = Math.max(0, Math.min(this.activePageIndex, Math.max(0, pages.length - 1))); }, schedulePreviewEnhance() { if (!this.isPreviewMode()) return; if (this._previewEnhanceTimer) globalThis.clearTimeout(this._previewEnhanceTimer); this._previewEnhanceTimer = globalThis.setTimeout(() => { this._previewEnhanceTimer = null; this.enhancePreview(); }, 0); }, enhancePreview() { const root = this._root?.querySelector?.("[data-editor-preview]"); if (!root) return; this.addHeadingIds(root); this.enhanceTables(root); this.enhanceTaskLists(root); this.enhanceImages(root); this.enhanceLinks(root); this.enhanceCodeBlocks(root); this.renderMath(root); this.applySearchHighlights(root); this.scrollPendingFragment(root); }, addHeadingIds(root) { const used = new Map(); root.querySelectorAll("h1,h2,h3,h4,h5,h6").forEach((heading) => { if (!heading.id) heading.id = slugifyHeading(heading.textContent || "", used); }); }, enhanceTables(root) { root.querySelectorAll("table").forEach((table) => { if (table.parentElement?.classList.contains("editor-table-wrap")) return; const wrapper = document.createElement("div"); wrapper.className = "editor-table-wrap"; table.parentNode?.insertBefore(wrapper, table); wrapper.appendChild(table); }); }, enhanceTaskLists(root) { root.querySelectorAll('input[type="checkbox"]').forEach((checkbox, index) => { if (checkbox.dataset.editorTaskEnhanced === "true") return; checkbox.dataset.editorTaskEnhanced = "true"; checkbox.dataset.editorTaskIndex = String(index); checkbox.disabled = false; checkbox.removeAttribute("disabled"); checkbox.addEventListener("change", (event) => { const target = event.currentTarget; this.togglePreviewTask(Number(target?.dataset?.editorTaskIndex || 0), Boolean(target?.checked)); }); }); }, enhanceImages(root) { const docPath = this.session?.path || this.session?.document?.path || ""; root.querySelectorAll("img[src]").forEach((image) => { const src = image.getAttribute("src") || ""; if (!src || isExternalHref(src) || src.startsWith("data:") || src.startsWith("/api/image_get")) return; const resolved = resolveDocumentRelativePath(docPath, src); image.setAttribute("src", `/api/image_get?path=${encodeURIComponent(resolved)}`); image.setAttribute("loading", "lazy"); }); }, enhanceLinks(root) { const docPath = this.session?.path || this.session?.document?.path || ""; root.querySelectorAll("a[href]").forEach((anchor) => { const href = anchor.getAttribute("href") || ""; if (!href || isExternalHref(href)) return; const { path, fragment } = splitHref(href); if (!path && fragment) { anchor.dataset.editorFragment = fragment; return; } if (!isMarkdownPath(path)) return; anchor.dataset.editorMarkdownPath = resolveDocumentRelativePath(docPath, path); anchor.dataset.editorFragment = fragment; }); }, async enhanceCodeBlocks(root) { root.querySelectorAll("pre > code").forEach((code) => { const pre = code.parentElement; if (!pre || pre.parentElement?.classList.contains("editor-code-block")) return; const wrapper = document.createElement("div"); wrapper.className = "editor-code-block"; const header = document.createElement("div"); header.className = "editor-code-header"; const language = this.codeLanguage(code); const label = document.createElement("span"); label.className = "editor-code-language"; label.textContent = language || "text"; const button = document.createElement("button"); button.type = "button"; button.className = "editor-code-copy"; button.textContent = "Copy"; button.addEventListener("click", async () => { await navigator.clipboard?.writeText(code.textContent || ""); button.textContent = "Copied"; globalThis.setTimeout(() => { button.textContent = "Copy"; }, 1200); }); header.append(label, button); pre.parentNode?.insertBefore(wrapper, pre); wrapper.append(header, pre); this.highlightCodeBlock(code, language); }); }, codeLanguage(code) { for (const className of code.classList || []) { if (className.startsWith("language-")) return className.slice("language-".length); if (className.startsWith("lang-")) return className.slice("lang-".length); } return ""; }, async highlightCodeBlock(code, language) { if (!language || !globalThis.ace?.require) return; const source = code.textContent || ""; try { const highlighter = await this.loadAceStaticHighlighter(); const darkMode = globalThis.localStorage?.getItem("darkMode"); const theme = darkMode !== "false" ? "ace/theme/github_dark" : "ace/theme/github"; const mode = `ace/mode/${aceModeForLanguage(language)}`; highlighter.render(source, mode, theme, 1, true, (result) => { code.innerHTML = result.html; code.classList.add("is-highlighted"); }); } catch { // Fenced code still renders as preformatted text if highlighting is unavailable. } }, loadAceStaticHighlighter() { if (this._staticHighlightPromise) return this._staticHighlightPromise; this._staticHighlightPromise = new Promise((resolve, reject) => { let existing = null; try { existing = globalThis.ace?.require?.("ace/ext/static_highlight"); } catch { existing = null; } if (existing?.render) { resolve(existing); return; } const script = document.createElement("script"); script.src = "/vendor/ace-min/ext-static_highlight.js"; script.onload = () => { const loaded = globalThis.ace?.require?.("ace/ext/static_highlight"); loaded?.render ? resolve(loaded) : reject(new Error("ACE highlighter unavailable")); }; script.onerror = () => reject(new Error("ACE highlighter failed to load")); document.head.appendChild(script); }); return this._staticHighlightPromise; }, renderMath(root) { if (!globalThis.katex?.render) return; for (const node of textNodesUnder(root, "code,pre,.katex,.editor-code-block")) { this.replaceMathInTextNode(node); } }, replaceMathInTextNode(node) { const text = node.nodeValue || ""; const pattern = /(\$\$[^$]+\$\$|\$[^$\n]+\$)/g; if (!pattern.test(text)) return; pattern.lastIndex = 0; const fragment = document.createDocumentFragment(); let lastIndex = 0; let match; while ((match = pattern.exec(text))) { if (match.index > lastIndex) fragment.append(document.createTextNode(text.slice(lastIndex, match.index))); const raw = match[0]; const displayMode = raw.startsWith("$$"); const expression = raw.slice(displayMode ? 2 : 1, displayMode ? -2 : -1); const span = document.createElement(displayMode ? "div" : "span"); span.className = displayMode ? "editor-math-display" : "editor-math-inline"; try { globalThis.katex.render(expression, span, { throwOnError: false, displayMode }); } catch { span.textContent = raw; } fragment.append(span); lastIndex = match.index + raw.length; } if (lastIndex < text.length) fragment.append(document.createTextNode(text.slice(lastIndex))); node.parentNode?.replaceChild(fragment, node); }, openSearch() { if (!this.isPreviewMode()) { this.setViewMode(PREVIEW_MODE); } this.searchOpen = true; this.runSearch(); globalThis.requestAnimationFrame?.(() => { this._root?.querySelector?.("[data-editor-search]")?.focus?.(); }); }, closeSearch() { this.searchOpen = false; this.searchQuery = ""; this.searchMatches = []; this.searchIndex = -1; this.schedulePreviewEnhance(); }, searchCountLabel() { if (!this.searchQuery) return ""; if (!this.searchMatches.length) return "0 of 0"; return `${this.searchIndex + 1} of ${this.searchMatches.length}`; }, runSearch() { const query = String(this.searchQuery || ""); if (!query) { this.searchMatches = []; this.searchIndex = -1; this.schedulePreviewEnhance(); return; } const lower = query.toLowerCase(); const matches = []; for (const page of this.pages()) { const text = this.renderedTextForPage(page); let index = 0; let occurrence = 0; while ((index = text.toLowerCase().indexOf(lower, index)) >= 0) { matches.push({ pageIndex: page.index, occurrence, offset: index }); occurrence += 1; index += Math.max(1, lower.length); } } this.searchMatches = matches; this.searchIndex = matches.length ? 0 : -1; this.goToCurrentSearchMatch(); }, nextSearchMatch() { if (!this.searchMatches.length) return; this.searchIndex = (this.searchIndex + 1) % this.searchMatches.length; this.goToCurrentSearchMatch(); }, previousSearchMatch() { if (!this.searchMatches.length) return; this.searchIndex = (this.searchIndex - 1 + this.searchMatches.length) % this.searchMatches.length; this.goToCurrentSearchMatch(); }, goToCurrentSearchMatch() { const match = this.searchMatches[this.searchIndex]; if (!match) { this.schedulePreviewEnhance(); return; } this.activePageIndex = match.pageIndex; this.schedulePreviewEnhance(); }, renderedTextForPage(page) { const html = renderEditorPreviewMarkdown(page.markdown || "", this.editorText); const doc = new DOMParser().parseFromString(html, "text/html"); return doc.body.textContent || ""; }, applySearchHighlights(root) { root.querySelectorAll("mark.editor-search-mark").forEach((mark) => { mark.replaceWith(document.createTextNode(mark.textContent || "")); }); const query = String(this.searchQuery || ""); if (!query || !this.searchMatches.length) return; const regex = new RegExp(escapeRegExp(query), "gi"); const current = this.searchMatches[this.searchIndex]; let occurrence = 0; for (const node of textNodesUnder(root, "script,style")) { const text = node.nodeValue || ""; if (!regex.test(text)) continue; regex.lastIndex = 0; const fragment = document.createDocumentFragment(); let lastIndex = 0; let match; while ((match = regex.exec(text))) { if (match.index > lastIndex) fragment.append(document.createTextNode(text.slice(lastIndex, match.index))); const mark = document.createElement("mark"); mark.className = "editor-search-mark"; if (current?.pageIndex === this.activePageIndex && current.occurrence === occurrence) { mark.classList.add("is-current"); } mark.textContent = match[0]; fragment.append(mark); occurrence += 1; lastIndex = match.index + match[0].length; } if (lastIndex < text.length) fragment.append(document.createTextNode(text.slice(lastIndex))); node.parentNode?.replaceChild(fragment, node); } root.querySelector("mark.editor-search-mark.is-current")?.scrollIntoView?.({ block: "center" }); }, async handlePreviewClick(event) { const anchor = event.target?.closest?.("a[href]"); if (!anchor) return; const markdownPath = anchor.dataset.editorMarkdownPath || ""; const fragment = anchor.dataset.editorFragment || ""; if (!markdownPath && fragment) { event.preventDefault(); this.navigateToFragment(fragment); return; } if (!markdownPath) return; event.preventDefault(); this._pendingPreviewFragment = fragment; const opened = await this.openSession({ path: markdownPath, refresh: true, source: "editor-preview-link" }); if (!opened) return; if (this.isPreviewMode() && fragment) { this.navigateToFragment(fragment); } }, navigateToFragment(fragment = "") { const target = String(fragment || "").replace(/^#/, ""); if (!target) return; const pages = this.pages(); const normalized = target.toLowerCase(); for (const page of pages) { const doc = new DOMParser().parseFromString(renderEditorPreviewMarkdown(page.markdown || "", this.editorText), "text/html"); const used = new Map(); const headings = [...doc.body.querySelectorAll("h1,h2,h3,h4,h5,h6")]; if (headings.some((heading) => (heading.id || slugifyHeading(heading.textContent || "", used)) === normalized)) { this.activePageIndex = page.index; this._pendingPreviewFragment = target; this.schedulePreviewEnhance(); return; } } this._pendingPreviewFragment = target; this.schedulePreviewEnhance(); }, scrollPendingFragment(root) { const fragment = this._pendingPreviewFragment; if (!fragment) return; const target = root.querySelector(`#${CSS.escape(fragment)}`); if (target) { target.scrollIntoView({ block: "start" }); this._pendingPreviewFragment = ""; } }, handleEditorKeydown(event) { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f") { event.preventDefault(); this.openSearch(); } }, async create(kind = "document", format = "") { const fmt = "md"; const title = this.defaultTitle(kind, fmt); return await this.openSession({ action: "create", kind: "document", format: fmt, title, }); }, async ensureInitialMarkdownFile() { if (this.session || this.visibleTabs().length > 0 || this.loading) return null; if (!this._root || this._initialCreatePromise) return this._initialCreatePromise; this._initialCreatePromise = this.create("document", "md").finally(() => { this._initialCreatePromise = null; }); return await this._initialCreatePromise; }, async openFileBrowser() { let workdirPath = "/a0/usr/workdir"; try { const response = await callJsonApi("settings_get", null); workdirPath = response?.settings?.workdir_path || workdirPath; } catch { try { const home = await callEditor("home"); workdirPath = home?.path || workdirPath; } catch { // The file browser can still open with the static fallback. } } await fileBrowserStore.open(workdirPath); }, async openPath(path) { await this.openSession({ path: String(path || "") }); }, async openSession(payload = {}) { this.loading = true; this.error = ""; try { const response = await callEditor(payload.action || "open", payload); if (response?.ok === false) { this.error = response.error || "Markdown could not be opened."; return null; } if (response?.requires_desktop) { const document = normalizeMarkdown(response.document || response); this.setMessage(`${documentLabel(document)} uses the Desktop surface.`); await this.refresh(); return response; } const session = normalizeSession(response); this.installSession(session); await this.refresh(); return session; } catch (error) { this.error = error instanceof Error ? error.message : String(error); return null; } finally { this.loading = false; } }, installSession(session) { const existingIndex = this.tabs.findIndex((tab) => ( (session.file_id && tab.file_id === session.file_id) || (session.path && tab.path === session.path) )); if (existingIndex >= 0) { const tabId = this.tabs[existingIndex].tab_id; const wasActive = this.activeTabId === tabId || this.session?.tab_id === tabId; const merged = { ...this.tabs[existingIndex], ...session, tab_id: tabId }; this.tabs.splice(existingIndex, 1, merged); this.activeTabId = tabId; if (wasActive) { this.hydrateActiveSession(merged, { preservePage: true, focus: false }); return; } } else { this.tabs.push(session); this.activeTabId = session.tab_id; } this.selectTab(this.activeTabId); }, hydrateActiveSession(tab, options = {}) { this.session = tab || null; this.activeTabId = tab?.tab_id || ""; this.editorText = String(tab?.text || ""); this.dirty = Boolean(tab?.dirty); if (this.previewEditing) this.cancelPreviewEdit(); if (!options.preservePage) { this.activePageIndex = 0; } else { this.clampActivePage(); } this.searchMatches = []; this.searchIndex = -1; this.resetHistory(this.editorText); this.setSourceEditorText(this.editorText); if (tab?.session_id) { requestEditor("editor_activate", { session_id: tab.session_id }, 2500).catch(() => {}); } if (this.searchOpen && this.searchQuery) this.runSearch(); else if (this.isSourceMode()) this.scheduleSourceEditorInit(); else this.schedulePreviewEnhance(); this.refreshSourceEditorLayout(); this.queueRender({ focus: this.isSourceMode() && Boolean(tab) && options.focus !== false, end: false }); }, selectTab(tabId, options = {}) { this.applyPreviewEdit({ silent: true }); this.syncEditorText(); const tab = this.tabs.find((item) => item.tab_id === tabId) || this.tabs[0] || null; this.previewEditing = false; this.previewEditDirty = false; this.previewEditText = ""; this.previewEditPageIndex = -1; this.hydrateActiveSession(tab, { preservePage: false, focus: options.focus !== false }); }, ensureActiveTab() { if (this.session && this.tabs.some((tab) => tab.tab_id === this.session.tab_id)) return; if (this.tabs.length) this.selectTab(this.tabs[0].tab_id, { focus: false }); }, isActiveTab(tab) { return Boolean(tab && tab.tab_id === this.activeTabId); }, isTabDirty(tab) { return Boolean(tab?.dirty || (this.isActiveTab(tab) && (this.dirty || this.previewEditDirty))); }, hasPendingClose() { return Boolean(this.pendingClose); }, pendingCloseTitle() { const pending = this.pendingClose; if (!pending) return ""; if (pending.kind === "all") { return `Close ${pending.totalCount || 0} open files?`; } const tab = this.tabs.find((item) => item.tab_id === pending.tabId); return `Close ${this.tabTitle(tab || {})}?`; }, pendingCloseMessage() { const pending = this.pendingClose; if (!pending) return ""; const dirtyCount = Number(pending.dirtyCount || 0); if (pending.kind === "all") { if (dirtyCount === 0) return "All open Markdown files will be closed."; return `${dirtyCount} open ${dirtyCount === 1 ? "file has" : "files have"} unsaved changes.`; } if (dirtyCount > 0) return "This file has unsaved changes."; return "This file will be closed."; }, pendingCloseHasDirty() { return Number(this.pendingClose?.dirtyCount || 0) > 0; }, pendingCloseDiscardLabel() { return this.pendingCloseHasDirty() ? "Discard" : "Close"; }, beginCloseConfirmation(kind, tabIds = []) { const ids = tabIds.filter(Boolean); const tabs = ids.map((id) => this.tabs.find((tab) => tab.tab_id === id)).filter(Boolean); const dirtyCount = tabs.filter((tab) => this.isTabDirty(tab)).length; this.pendingClose = { kind, tabId: kind === "single" ? ids[0] || "" : "", tabIds: ids, totalCount: tabs.length, dirtyCount, }; if (kind === "single" && ids[0] && this.activeTabId !== ids[0]) { this.selectTab(ids[0], { focus: false }); } }, cancelPendingClose() { this.pendingClose = null; }, async confirmPendingClose(options = {}) { const pending = this.pendingClose; if (!pending || this.loading) return; this.pendingClose = null; const save = options.save === true; if (pending.kind === "all") { await this.closeAllFiles({ confirm: false, save, tabIds: pending.tabIds || [] }); return; } await this.closeTab(pending.tabId, { confirm: false, save }); }, async closeTab(tabId, options = {}) { const tab = this.tabs.find((item) => item.tab_id === tabId); if (!tab) return; if (this.isTabDirty(tab) && options.confirm !== false) { this.beginCloseConfirmation("single", [tab.tab_id]); return; } await this.closeTabNow(tab, { save: options.save === true }); }, async closeTabNow(tab, options = {}) { if (!tab || this.loading) return false; const tabId = tab.tab_id; if (options.save === true && this.isTabDirty(tab)) { const saved = await this.saveTab(tab); if (!saved) return false; } if (this.activeTabId === tabId && this.previewEditing) this.cancelPreviewEdit(); try { if (tab.session_id) { await requestEditor("editor_close", { session_id: tab.session_id }, 2500).catch(() => null); } await callEditor("close", { session_id: tab.session_id || "", store_session_id: tab.store_session_id || "", file_id: tab.file_id || "", }); } catch (error) { console.warn("Markdown close skipped", error); } this.tabs = this.tabs.filter((item) => item.tab_id !== tabId); if (this.pendingClose?.tabId === tabId || this.pendingClose?.tabIds?.includes(tabId)) { this.pendingClose = null; } if (this.activeTabId === tabId) { this.session = null; this.activeTabId = ""; this.editorText = ""; this.dirty = false; this.ensureActiveTab(); } this.ensureActiveTab(); await this.refresh(); return true; }, async closeActiveFile() { if (!this.session || this.loading) return; await this.closeTab(this.session.tab_id); }, async closeAllFiles(options = {}) { if (this.loading) return; const requestedIds = Array.isArray(options.tabIds) && options.tabIds.length ? options.tabIds : this.visibleTabs().map((tab) => tab.tab_id); const tabs = requestedIds.map((id) => this.tabs.find((tab) => tab.tab_id === id)).filter(Boolean); if (!tabs.length) return; const dirtyTabs = tabs.filter((tab) => this.isTabDirty(tab)); if (dirtyTabs.length && options.confirm !== false) { this.beginCloseConfirmation("all", tabs.map((tab) => tab.tab_id)); return; } this.pendingClose = null; for (const tab of [...tabs]) { const current = this.tabs.find((item) => item.tab_id === tab.tab_id); if (!current) continue; const closed = await this.closeTabNow(current, { save: options.save === true && this.isTabDirty(current), }); if (!closed) break; } }, scheduleSourceEditorInit() { if (!this.isSourceMode()) return; globalThis.requestAnimationFrame?.(() => { globalThis.requestAnimationFrame?.(() => this.initSourceEditor()); }); }, initSourceEditor() { if (!this.isSourceMode() || !this._root) return; const container = this._root.querySelector?.("[data-editor-ace]"); if (this.sourceEditor && !this._root.contains?.(this.sourceEditor.container)) { this.destroySourceEditor(); } if (!container || this.sourceEditor) return; if (!globalThis.ace?.edit) { this.aceUnavailable = true; return; } const editor = globalThis.ace.edit(container); const darkMode = globalThis.localStorage?.getItem("darkMode"); const theme = darkMode !== "false" ? "ace/theme/github_dark" : "ace/theme/github"; editor.setTheme(theme); editor.session.setMode("ace/mode/markdown"); editor.session.setUseWrapMode(true); editor.setOptions({ fontSize: "13px", showGutter: false, showPrintMargin: false, useWorker: false, }); editor.renderer.setShowGutter(false); editor.renderer.setScrollMargin(14, 14, 0, 0); editor.setValue(this.editorText || "", -1); this._sourceEditorChangeHandler = () => { if (this._settingSourceEditorValue) return; this.editorText = editor.getValue(); this.onSourceInput(); }; editor.session.on("change", this._sourceEditorChangeHandler); this.sourceEditor = editor; this.aceUnavailable = false; this.queueRender({ focus: Boolean(this.session), end: false }); }, destroySourceEditor() { if (this.sourceEditor?.session && this._sourceEditorChangeHandler) { this.sourceEditor.session.off?.("change", this._sourceEditorChangeHandler); } const container = this.sourceEditor?.container; this.sourceEditor?.destroy?.(); if (container) container.textContent = ""; this.sourceEditor = null; this._sourceEditorChangeHandler = null; }, setSourceEditorText(text = "") { if (!this.sourceEditor) return; const value = String(text || ""); if (this.sourceEditor.getValue() === value) return; this._settingSourceEditorValue = true; this.sourceEditor.setValue(value, -1); this._settingSourceEditorValue = false; this.refreshSourceEditorLayout(); }, refreshSourceEditorLayout() { const editor = this.sourceEditor; if (!editor) return; const refresh = () => { editor.resize?.(true); editor.renderer?.updateFull?.(); editor.renderer?.updateText?.(); }; if (globalThis.requestAnimationFrame) { globalThis.requestAnimationFrame(() => globalThis.requestAnimationFrame(refresh)); } else { globalThis.setTimeout(refresh, 0); } }, async save() { if (!this.session || this.saving || !this.isMarkdown()) return; this.applyPreviewEdit({ silent: true }); this.syncEditorText(); this.saving = true; this.error = ""; try { let response; const payload = { session_id: this.session.session_id, text: this.editorText }; try { response = await requestEditor("editor_save", payload, 10000); } catch (_socketError) { response = await callEditor("save", payload); } if (response?.ok === false) throw new Error(response.error || "Save failed."); const document = normalizeMarkdown(response.document || this.session.document || {}); const updated = { ...this.session, text: this.editorText, dirty: false, document, path: document.path || this.session.path, file_id: document.file_id || this.session.file_id, version: document.version || response.version || this.session.version, }; this.replaceActiveSession(updated); this.dirty = false; this.setMessage("Saved"); await this.refresh(); } catch (error) { this.error = error instanceof Error ? error.message : String(error); } finally { this.saving = false; } }, async saveTab(tab) { if (!tab || this.saving || !this.isMarkdown(tab)) return false; if (this.isActiveTab(tab)) { this.applyPreviewEdit({ silent: true }); this.syncEditorText(); } this.saving = true; this.error = ""; try { let response; const payload = { session_id: tab.session_id, text: this.isActiveTab(tab) ? this.editorText : String(tab.text || ""), }; try { response = await requestEditor("editor_save", payload, 10000); } catch (_socketError) { response = await callEditor("save", payload); } if (response?.ok === false) throw new Error(response.error || "Save failed."); const document = normalizeMarkdown(response.document || tab.document || {}); const updated = { ...tab, text: payload.text, dirty: false, document, path: document.path || tab.path, file_id: document.file_id || tab.file_id, version: document.version || response.version || tab.version, }; this.replaceSession(tab, updated); if (this.isActiveTab(updated)) { this.dirty = false; } this.setMessage("Saved"); await this.refresh(); return true; } catch (error) { this.error = error instanceof Error ? error.message : String(error); return false; } finally { this.saving = false; } }, async renameActiveFile() { if (!this.session || this.saving) return; this.applyPreviewEdit({ silent: true }); const session = this.session; const path = session.path || session.document?.path || ""; if (!path) { this.error = "This document does not have a file path to rename."; return; } const name = basename(path || session.title || ""); const extension = extensionOf(name); await fileBrowserStore.openRenameModal( { name, path, is_dir: false, size: session.document?.size || 0, modified: session.document?.last_modified || "", type: "document", }, { currentPath: parentPath(path), validateName: (newName) => { if (!extension) return true; return extensionOf(newName) === extension || `Keep the .${extension} extension for this open document.`; }, performRename: async ({ path: renamedPath }) => { const payload = { file_id: session.file_id || "", path: renamedPath, }; if (this.isMarkdown(session)) { this.syncEditorText(); payload.text = this.session?.tab_id === session.tab_id ? this.editorText : session.text || ""; } return await callEditor("renamed", payload); }, onRenamed: async ({ path: renamedPath, response }) => { await this.handleActiveFileRenamed(session, renamedPath, response); }, }, ); }, async handleActiveFileRenamed(session, renamedPath, renameResponse = null) { const response = renameResponse || await callEditor("renamed", { file_id: session.file_id || "", path: renamedPath, }); if (response?.ok === false) throw new Error(response.error || "Rename failed."); const document = normalizeMarkdown(response.document || session.document || {}); const updated = { ...session, document, title: document.title || document.basename || basename(document.path), path: document.path || renamedPath, extension: document.extension || session.extension, file_id: document.file_id || session.file_id, version: document.version || response.version || session.version, text: this.session?.tab_id === session.tab_id ? this.editorText : session.text, dirty: false, }; this.replaceSession(session, updated); this.dirty = false; this.setMessage("Renamed"); await this.refresh(); }, replaceActiveSession(next) { if (!this.session) return; this.replaceSession(this.session, next); }, replaceSession(previous, next) { const wasActive = this.activeTabId === (previous?.tab_id || next.tab_id); if (wasActive) this.session = next; const index = this.tabs.findIndex((tab) => tab.tab_id === (previous?.tab_id || next.tab_id)); if (index >= 0) this.tabs.splice(index, 1, next); }, setMessage(value) { this.message = value; if (this._saveMessageTimer) globalThis.clearTimeout(this._saveMessageTimer); this._saveMessageTimer = globalThis.setTimeout(() => { this.message = ""; this._saveMessageTimer = null; }, SAVE_MESSAGE_MS); }, resetHistory(text) { this._history = [String(text || "")]; this._historyIndex = 0; }, pushHistory(text) { const value = String(text || ""); if (this._history[this._historyIndex] === value) return; this._history = this._history.slice(0, this._historyIndex + 1); this._history.push(value); if (this._history.length > MAX_HISTORY) this._history.shift(); this._historyIndex = this._history.length - 1; }, undo() { if (this.sourceEditor && this.isSourceMode()) { this.sourceEditor.undo(); this.editorText = this.sourceEditor.getValue(); this.syncEditorText(); return; } if (this._historyIndex <= 0) return; this._historyIndex -= 1; this.applyEditorText(this._history[this._historyIndex], true); }, redo() { if (this.sourceEditor && this.isSourceMode()) { this.sourceEditor.redo(); this.editorText = this.sourceEditor.getValue(); this.syncEditorText(); return; } if (this._historyIndex >= this._history.length - 1) return; this._historyIndex += 1; this.applyEditorText(this._history[this._historyIndex], true); }, canUndo() { return this._historyIndex > 0; }, canRedo() { return this._historyIndex < this._history.length - 1; }, applyEditorText(text, markDirty = false) { this.editorText = String(text || ""); this.setSourceEditorText(this.editorText); if (this.session) { this.session.text = this.editorText; this.session.dirty = markDirty || this.session.dirty; } if (markDirty) this.markDirty(); this.queueRender({ focus: true }); }, markDirty() { this.dirty = true; if (this.session) this.session.dirty = true; }, onSourceInput() { this.markDirty(); this.pushHistory(this.editorText); this.scheduleInputPush(); }, syncEditorText() { if (!this.session) return; if (this.previewEditing) return; if (this.sourceEditor && this.isSourceMode()) { this.editorText = this.sourceEditor.getValue(); } this.session.text = this.editorText; }, scheduleInputPush() { if (!this.session?.session_id || !this.isMarkdown()) return; if (this._inputTimer) globalThis.clearTimeout(this._inputTimer); this._inputTimer = globalThis.setTimeout(() => { this._inputTimer = null; this.flushInput(); }, INPUT_PUSH_DELAY_MS); }, flushInput() { if (!this.session?.session_id || !this.isMarkdown()) return; if (this.previewEditing) return; this.syncEditorText(); requestEditor("editor_input", { session_id: this.session.session_id, text: this.editorText, }, 3000).catch(() => {}); }, format(command) { if (!this.session || !this.isMarkdown()) return; if (this.sourceEditor && this.isSourceMode()) { const selected = this.sourceEditor.getSelectedText(); const replacement = this.formatReplacement(command, selected); if (replacement === selected) return; this.sourceEditor.session.replace(this.sourceEditor.getSelectionRange(), replacement); this.editorText = this.sourceEditor.getValue(); this.onSourceInput(); this.sourceEditor.focus(); return; } const textarea = this._root?.querySelector?.("[data-editor-source]"); if (!textarea) return; const start = textarea.selectionStart || 0; const end = textarea.selectionEnd || start; const selected = this.editorText.slice(start, end); const replacement = this.formatReplacement(command, selected); if (replacement === selected) return; this.editorText = `${this.editorText.slice(0, start)}${replacement}${this.editorText.slice(end)}`; this.onSourceInput(); globalThis.requestAnimationFrame?.(() => { textarea.focus(); textarea.selectionStart = start; textarea.selectionEnd = start + replacement.length; }); }, formatReplacement(command, selected = "") { if (command === "bold") return `**${selected || "text"}**`; if (command === "italic") return `*${selected || "text"}*`; if (command === "list") return (selected || "item").split("\n").map((line) => `- ${line.replace(/^[-*]\s+/, "")}`).join("\n"); if (command === "numbered") return (selected || "item").split("\n").map((line, index) => `${index + 1}. ${line.replace(/^\d+\.\s+/, "")}`).join("\n"); if (command === "table") return "| Column | Value |\n| --- | --- |\n| | |"; return selected; }, queueRender(options = {}) { if (options.focus) { this._pendingFocus = true; this._pendingFocusEnd = options.end !== false; this._focusAttempts = 0; } const render = () => { if (this._pendingFocus && this.focusEditor({ end: this._pendingFocusEnd })) { this._pendingFocus = false; this._focusAttempts = 0; } else if (this._pendingFocus && this._focusAttempts < 6) { this._focusAttempts += 1; globalThis.setTimeout(render, 45); } }; if (globalThis.requestAnimationFrame) { globalThis.requestAnimationFrame(render); } else { globalThis.setTimeout(render, 0); } }, focusEditor(options = {}) { if (!this.session || !this.isMarkdown()) return false; if (this.sourceEditor && this.isSourceMode()) { this.sourceEditor.focus(); if (options.end !== false) { const session = this.sourceEditor.session; const row = Math.max(0, session.getLength() - 1); const column = session.getLine(row).length; this.sourceEditor.moveCursorTo(row, column); } return true; } const source = this._root?.querySelector?.("[data-editor-source]"); if (!source) return false; source.focus?.({ preventScroll: true }); if (!editorContainsFocus(source)) return false; if (options.end !== false) placeCaretAtEnd(source); return true; }, isMarkdown(tab = this.session) { const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase(); return ext === "md"; }, hasActiveFile(tab = this.session) { return Boolean(tab && this.isMarkdown(tab)); }, visibleTabs() { return this.tabs.filter((tab) => this.hasActiveFile(tab)); }, defaultTitle(kind, fmt) { const date = new Date().toISOString().slice(0, 10); if (fmt === "md") return `Markdown ${date}`; return `Markdown ${date}`; }, tabTitle(tab = {}) { tab = tab || {}; return tab.title || tab.document?.basename || basename(tab.path); }, tabLabel(tab = {}) { tab = tab || {}; const title = this.tabTitle(tab); return tab.dirty ? `${title} unsaved` : title; }, tabIcon(tab = {}) { tab = tab || {}; const ext = String(tab.extension || tab.document?.extension || "").toLowerCase(); if (ext === "md") return "article"; return "draft"; }, async runNewMenuAction(action = "") { const normalized = String(action || "").trim().toLowerCase(); if (normalized === "open") return await this.openFileBrowser(); if (normalized === "markdown") return await this.create("document", "md"); return null; }, installHeaderNewMenu(header = null) { if (!header || header.querySelector(".editor-header-actions")) return () => {}; const root = document.createElement("div"); root.className = "editor-header-actions"; root.innerHTML = ` `; const button = root.querySelector(".editor-header-new-button"); const menu = root.querySelector(".editor-new-menu"); const setOpen = (open) => { root.classList.toggle("is-open", open); button?.setAttribute("aria-expanded", open.toString()); if (menu) menu.hidden = !open; }; const onButtonClick = (event) => { event.preventDefault(); event.stopPropagation(); setOpen(!root.classList.contains("is-open")); }; const onMarkdownClick = (event) => { if (!root.contains(event.target)) setOpen(false); }; const onMarkdownKeydown = (event) => { if (event.key === "Escape") setOpen(false); }; button?.addEventListener("click", onButtonClick); for (const item of root.querySelectorAll("[data-editor-new-action]")) { item.addEventListener("click", async (event) => { event.preventDefault(); event.stopPropagation(); const action = event.currentTarget?.dataset?.editorNewAction || ""; setOpen(false); await this.runNewMenuAction(action); }); } document.addEventListener("click", onMarkdownClick); document.addEventListener("keydown", onMarkdownKeydown); const firstHeaderAction = header.querySelector(".modal-close"); if (firstHeaderAction) { firstHeaderAction.insertAdjacentElement("beforebegin", root); } else { header.appendChild(root); } setOpen(false); return () => { button?.removeEventListener("click", onButtonClick); document.removeEventListener("click", onMarkdownClick); document.removeEventListener("keydown", onMarkdownKeydown); root.remove(); }; }, setupMarkdownModal(element = null) { const root = element || document.querySelector(".editor-panel"); const inner = root?.closest?.(".modal-inner"); const header = inner?.querySelector?.(".modal-header"); if (!inner || !header || inner.dataset.editorModalReady === "1") return; inner.dataset.editorModalReady = "1"; inner.classList.add("editor-modal"); const cleanup = []; const closeButton = inner.querySelector(".modal-close"); const focusButton = document.createElement("button"); focusButton.type = "button"; focusButton.className = "modal-dock-button editor-modal-focus-button"; focusButton.innerHTML = ''; const updateFocusButton = (active) => { const label = active ? "Restore size" : "Focus mode"; focusButton.setAttribute("aria-label", label); focusButton.setAttribute("title", label); focusButton.querySelector(".material-symbols-outlined").textContent = active ? "fullscreen_exit" : "fullscreen"; }; updateFocusButton(false); const onFocusClick = () => { const active = !inner.classList.contains("is-focus-mode"); inner.classList.toggle("is-focus-mode", active); updateFocusButton(active); }; focusButton.addEventListener("click", onFocusClick); if (closeButton) { closeButton.insertAdjacentElement("beforebegin", focusButton); } else { header.appendChild(focusButton); } cleanup.push(() => focusButton.removeEventListener("click", onFocusClick)); cleanup.push(() => focusButton.remove()); this._headerCleanup = () => { cleanup.splice(0).reverse().forEach((entry) => entry()); delete inner.dataset.editorModalReady; inner.classList.remove("editor-modal", "is-focus-mode"); }; const menuCleanup = this.installHeaderNewMenu(header); const previousCleanup = this._headerCleanup; this._headerCleanup = () => { menuCleanup?.(); previousCleanup?.(); }; }, }; export const store = createStore("editor", model);