mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-05-23 04:17:34 +00:00
Let File Browser-sourced Editor opens register existing Markdown files under the Agent Zero runtime root while preserving the stricter document artifact sandbox for ordinary document operations. Pass the file-browser source through the Editor store and cover the /a0/AGENTS.md-style path with regression tests.
1704 lines
56 KiB
JavaScript
1704 lines
56 KiB
JavaScript
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 { placeSurfaceModalHeaderAction } from "/js/surfaces.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, options = {}) {
|
|
return await this.openSession({
|
|
path: String(path || ""),
|
|
source: options?.source || "",
|
|
refresh: options?.refresh === true,
|
|
});
|
|
},
|
|
|
|
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 surface-modal-new-action";
|
|
root.innerHTML = `
|
|
<button type="button" class="editor-header-new-button surface-modal-new-button" aria-haspopup="menu" aria-expanded="false">
|
|
<span class="material-symbols-outlined" aria-hidden="true">add</span>
|
|
<span>New</span>
|
|
<span class="material-symbols-outlined editor-new-chevron" aria-hidden="true">expand_more</span>
|
|
</button>
|
|
<div class="editor-new-menu" role="menu" hidden>
|
|
<button type="button" class="editor-new-menu-item" role="menuitem" data-editor-new-action="open">
|
|
<span class="material-symbols-outlined" aria-hidden="true">folder_open</span>
|
|
<span>Open</span>
|
|
</button>
|
|
<button type="button" class="editor-new-menu-item" role="menuitem" data-editor-new-action="markdown">
|
|
<span class="material-symbols-outlined" aria-hidden="true">article</span>
|
|
<span>Markdown</span>
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
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);
|
|
|
|
placeSurfaceModalHeaderAction(header, root, "new");
|
|
|
|
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 focusButton = document.createElement("button");
|
|
focusButton.type = "button";
|
|
focusButton.className = "surface-button editor-modal-focus-button";
|
|
focusButton.innerHTML = '<span class="material-symbols-outlined" aria-hidden="true">fullscreen</span>';
|
|
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);
|
|
placeSurfaceModalHeaderAction(header, focusButton, "window");
|
|
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);
|