agent-zero/plugins/_editor/webui/editor-store.js
Alessandro 4f2d996ac8 Allow file browser to open root Markdown in Editor
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.
2026-05-22 15:00:03 +02:00

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