| 1 | import { createStore } from "/js/AlpineStore.js"; |
| 2 | import { callJsonApi } from "/js/api.js"; |
| 3 | import { getNamespacedClient } from "/js/websocket.js"; |
| 4 | import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js"; |
| 5 | import { |
| 6 | openLatest as openLatestSurface, |
| 7 | placeSurfaceModalHeaderAction, |
| 8 | registerUrlHandler, |
| 9 | setupFloatingSurfaceModalChrome, |
| 10 | } from "/js/surfaces.js"; |
| 11 | import { |
| 12 | buildMarkdownPages, |
| 13 | isExternalHref, |
| 14 | isMarkdownPath, |
| 15 | renderEditorPreviewMarkdown, |
| 16 | resolveDocumentRelativePath, |
| 17 | slugifyHeading, |
| 18 | splitHref, |
| 19 | } from "/plugins/_editor/webui/editor-preview.js"; |
| 20 | |
| 21 | const editorSocket = getNamespacedClient("/ws"); |
| 22 | editorSocket.addHandlers(["ws_webui"]); |
| 23 | |
| 24 | const SAVE_MESSAGE_MS = 1800; |
| 25 | const INPUT_PUSH_DELAY_MS = 650; |
| 26 | const MAX_HISTORY = 80; |
| 27 | const SOURCE_MODE = "source"; |
| 28 | const PREVIEW_MODE = "preview"; |
| 29 | const EDITOR_TEXT_EXTENSIONS = new Set(["md", "txt"]); |
| 30 | |
| 31 | function currentContextId() { |
| 32 | try { |
| 33 | return globalThis.getContext?.() || ""; |
| 34 | } catch { |
| 35 | return ""; |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | function basename(path = "") { |
| 40 | const value = String(path || "").split("?")[0].split("#")[0]; |
| 41 | return value.split("/").filter(Boolean).pop() || "Untitled"; |
| 42 | } |
| 43 | |
| 44 | function extensionOf(path = "") { |
| 45 | const name = basename(path).toLowerCase(); |
| 46 | const index = name.lastIndexOf("."); |
| 47 | return index >= 0 ? name.slice(index + 1) : ""; |
| 48 | } |
| 49 | |
| 50 | function parentPath(path = "") { |
| 51 | const normalized = String(path || "").split("?")[0].split("#")[0].replace(/\/+$/, ""); |
| 52 | const index = normalized.lastIndexOf("/"); |
| 53 | if (index <= 0) return "/"; |
| 54 | return normalized.slice(0, index); |
| 55 | } |
| 56 | |
| 57 | function textDocumentFilename(path = "", fallback = "Untitled.md") { |
| 58 | const name = basename(path || fallback || "Untitled.md"); |
| 59 | const ext = extensionOf(name); |
| 60 | if (EDITOR_TEXT_EXTENSIONS.has(ext)) return name; |
| 61 | return `${name.replace(/\.+$/, "") || "Untitled"}.md`; |
| 62 | } |
| 63 | |
| 64 | function textDocumentDefaultExtension(path = "") { |
| 65 | const ext = extensionOf(path); |
| 66 | return EDITOR_TEXT_EXTENSIONS.has(ext) ? ext : "md"; |
| 67 | } |
| 68 | |
| 69 | function editorIntent(url = "") { |
| 70 | const raw = String(url || "").trim(); |
| 71 | if (!raw) return null; |
| 72 | try { |
| 73 | const parsed = new URL(raw); |
| 74 | if (parsed.protocol !== "a0-editor:") return null; |
| 75 | if (parsed.hostname === "open") { |
| 76 | return { path: parsed.searchParams.get("path") || "" }; |
| 77 | } |
| 78 | return null; |
| 79 | } catch { |
| 80 | return null; |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | function uniqueTabId(session = {}) { |
| 85 | return String(session.file_id || session.session_id || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`); |
| 86 | } |
| 87 | |
| 88 | function editorContainsFocus(element) { |
| 89 | const active = document.activeElement; |
| 90 | return Boolean(element && active && (element === active || element.contains(active))); |
| 91 | } |
| 92 | |
| 93 | function placeCaretAtEnd(element) { |
| 94 | if (!element) return; |
| 95 | if (element.tagName === "TEXTAREA" || element.tagName === "INPUT") { |
| 96 | const length = element.value?.length || 0; |
| 97 | element.selectionStart = length; |
| 98 | element.selectionEnd = length; |
| 99 | return; |
| 100 | } |
| 101 | const selection = globalThis.getSelection?.(); |
| 102 | const range = document.createRange?.(); |
| 103 | if (!selection || !range) return; |
| 104 | range.selectNodeContents(element); |
| 105 | range.collapse(false); |
| 106 | selection.removeAllRanges(); |
| 107 | selection.addRange(range); |
| 108 | } |
| 109 | |
| 110 | function normalizeTextDocument(doc = {}) { |
| 111 | const path = doc.path || ""; |
| 112 | const extension = String(doc.extension || extensionOf(path)).toLowerCase(); |
| 113 | return { |
| 114 | ...doc, |
| 115 | extension, |
| 116 | title: doc.title || doc.basename || basename(path), |
| 117 | basename: doc.basename || basename(path), |
| 118 | path, |
| 119 | }; |
| 120 | } |
| 121 | |
| 122 | function normalizeSession(payload = {}) { |
| 123 | const document = normalizeTextDocument(payload.document || payload); |
| 124 | return { |
| 125 | ...payload, |
| 126 | document, |
| 127 | extension: String(payload.extension || document.extension || "").toLowerCase(), |
| 128 | file_id: payload.file_id || document.file_id || "", |
| 129 | path: document.path || payload.path || "", |
| 130 | title: payload.title || document.title || document.basename || basename(document.path), |
| 131 | tab_id: uniqueTabId(payload), |
| 132 | text: String(payload.text || ""), |
| 133 | dirty: Boolean(payload.dirty), |
| 134 | active: Boolean(payload.active), |
| 135 | }; |
| 136 | } |
| 137 | |
| 138 | function documentLabel(document = {}) { |
| 139 | return document.title || document.basename || basename(document.path); |
| 140 | } |
| 141 | |
| 142 | function escapeRegExp(value = "") { |
| 143 | return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); |
| 144 | } |
| 145 | |
| 146 | function textNodesUnder(root, skipSelector = "") { |
| 147 | const nodes = []; |
| 148 | if (!root) return nodes; |
| 149 | const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { |
| 150 | acceptNode(node) { |
| 151 | if (!node.nodeValue) return NodeFilter.FILTER_REJECT; |
| 152 | if (skipSelector && node.parentElement?.closest(skipSelector)) return NodeFilter.FILTER_REJECT; |
| 153 | return NodeFilter.FILTER_ACCEPT; |
| 154 | }, |
| 155 | }); |
| 156 | while (walker.nextNode()) nodes.push(walker.currentNode); |
| 157 | return nodes; |
| 158 | } |
| 159 | |
| 160 | function aceModeForLanguage(language = "") { |
| 161 | const value = String(language || "").toLowerCase(); |
| 162 | const aliases = { |
| 163 | bash: "sh", |
| 164 | shell: "sh", |
| 165 | zsh: "sh", |
| 166 | py: "python", |
| 167 | js: "javascript", |
| 168 | jsx: "javascript", |
| 169 | ts: "typescript", |
| 170 | md: "markdown", |
| 171 | yml: "yaml", |
| 172 | }; |
| 173 | return aliases[value] || value || "text"; |
| 174 | } |
| 175 | |
| 176 | function taskLineIndexes(markdown = "") { |
| 177 | const indexes = []; |
| 178 | String(markdown || "").split("\n").forEach((line, index) => { |
| 179 | if (/^\s*(?:[-*+]|\d+[.)])\s+\[[ xX]\](?:\s+|$)/.test(line)) indexes.push(index); |
| 180 | }); |
| 181 | return indexes; |
| 182 | } |
| 183 | |
| 184 | async function callEditor(action, payload = {}) { |
| 185 | const explicitContextId = String(payload.ctxid || payload.context_id || "").trim(); |
| 186 | return await callJsonApi("/plugins/_editor/editor_session", { |
| 187 | action, |
| 188 | ...payload, |
| 189 | ctxid: explicitContextId || currentContextId(), |
| 190 | }); |
| 191 | } |
| 192 | |
| 193 | async function requestEditor(eventType, payload = {}, timeoutMs = 5000) { |
| 194 | const explicitContextId = String(payload.ctxid || payload.context_id || "").trim(); |
| 195 | const response = await editorSocket.request(eventType, { |
| 196 | ...payload, |
| 197 | ctxid: explicitContextId || currentContextId(), |
| 198 | }, { timeoutMs }); |
| 199 | const results = Array.isArray(response?.results) ? response.results : []; |
| 200 | const first = results.find((item) => item?.ok === true && isEditorSocketData(item?.data)) |
| 201 | || results.find((item) => item?.ok === true); |
| 202 | if (!first) { |
| 203 | const error = results.find((item) => item?.error)?.error; |
| 204 | throw new Error(error?.error || error?.code || `${eventType} failed`); |
| 205 | } |
| 206 | if (first.data?.editor_error) { |
| 207 | const error = first.data.editor_error; |
| 208 | throw new Error(error.error || error.code || `${eventType} failed`); |
| 209 | } |
| 210 | return first.data || {}; |
| 211 | } |
| 212 | |
| 213 | function isEditorSocketData(data) { |
| 214 | if (!data || typeof data !== "object") return false; |
| 215 | return ( |
| 216 | Object.prototype.hasOwnProperty.call(data, "editor_error") |
| 217 | || Object.prototype.hasOwnProperty.call(data, "ok") |
| 218 | || Object.prototype.hasOwnProperty.call(data, "session_id") |
| 219 | || Object.prototype.hasOwnProperty.call(data, "document") |
| 220 | ); |
| 221 | } |
| 222 | |
| 223 | const model = { |
| 224 | status: null, |
| 225 | tabs: [], |
| 226 | activeTabId: "", |
| 227 | session: null, |
| 228 | loading: false, |
| 229 | saving: false, |
| 230 | dirty: false, |
| 231 | error: "", |
| 232 | message: "", |
| 233 | pendingClose: null, |
| 234 | viewMode: SOURCE_MODE, |
| 235 | searchOpen: false, |
| 236 | searchQuery: "", |
| 237 | searchMatches: [], |
| 238 | searchIndex: -1, |
| 239 | activePageIndex: 0, |
| 240 | previewEditing: false, |
| 241 | previewEditDirty: false, |
| 242 | previewEditText: "", |
| 243 | previewEditPageIndex: -1, |
| 244 | aceUnavailable: false, |
| 245 | editorText: "", |
| 246 | sourceEditor: null, |
| 247 | _root: null, |
| 248 | _mode: "modal", |
| 249 | _initialized: false, |
| 250 | _saveMessageTimer: null, |
| 251 | _inputTimer: null, |
| 252 | _history: [], |
| 253 | _historyIndex: -1, |
| 254 | _historyPushedAt: 0, |
| 255 | _pendingFocus: false, |
| 256 | _pendingFocusEnd: true, |
| 257 | _focusAttempts: 0, |
| 258 | _headerCleanup: null, |
| 259 | _settingSourceEditorValue: false, |
| 260 | _sourceEditorChangeHandler: null, |
| 261 | _previewEnhanceTimer: null, |
| 262 | _staticHighlightPromise: null, |
| 263 | _pendingPreviewFragment: "", |
| 264 | |
| 265 | async init() { |
| 266 | if (this._initialized) return; |
| 267 | this._initialized = true; |
| 268 | await this.refresh(); |
| 269 | }, |
| 270 | |
| 271 | async onMount(element = null, options = {}) { |
| 272 | await this.init(); |
| 273 | if (element && element !== this._root) { |
| 274 | if (this.sourceEditor && !element.contains?.(this.sourceEditor.container)) { |
| 275 | this.destroySourceEditor(); |
| 276 | } |
| 277 | this._root = element; |
| 278 | } |
| 279 | this._mode = options?.mode === "canvas" ? "canvas" : "modal"; |
| 280 | if (this._mode === "modal") { |
| 281 | this.setupMarkdownModal(element); |
| 282 | } |
| 283 | this.scheduleSourceEditorInit(); |
| 284 | }, |
| 285 | |
| 286 | async onOpen(payload = {}) { |
| 287 | await this.init(); |
| 288 | await this.refresh(); |
| 289 | if (payload?.path || payload?.file_id) { |
| 290 | const contextId = String(payload.ctxid || payload.context_id || "").trim(); |
| 291 | await this.openSession({ |
| 292 | path: payload.path || "", |
| 293 | file_id: payload.file_id || "", |
| 294 | ctxid: contextId, |
| 295 | context_id: contextId, |
| 296 | refresh: payload.refresh === true, |
| 297 | source: payload.source || "", |
| 298 | }); |
| 299 | return; |
| 300 | } |
| 301 | }, |
| 302 | |
| 303 | beforeHostHidden() { |
| 304 | this.flushInput(); |
| 305 | }, |
| 306 | |
| 307 | cleanup() { |
| 308 | this.flushInput(); |
| 309 | this.destroySourceEditor(); |
| 310 | if (this._previewEnhanceTimer) globalThis.clearTimeout(this._previewEnhanceTimer); |
| 311 | this._previewEnhanceTimer = null; |
| 312 | this._headerCleanup?.(); |
| 313 | this._headerCleanup = null; |
| 314 | if (this._mode === "modal") this._root = null; |
| 315 | }, |
| 316 | |
| 317 | beginSurfaceHandoff() { |
| 318 | this.flushInput(); |
| 319 | }, |
| 320 | |
| 321 | async refresh() { |
| 322 | try { |
| 323 | const status = await callEditor("status"); |
| 324 | this.status = status || {}; |
| 325 | this.error = ""; |
| 326 | } catch (error) { |
| 327 | this.error = error instanceof Error ? error.message : String(error); |
| 328 | } |
| 329 | }, |
| 330 | |
| 331 | isSourceMode() { |
| 332 | return this.viewMode === SOURCE_MODE; |
| 333 | }, |
| 334 | |
| 335 | isPreviewMode() { |
| 336 | return this.viewMode === PREVIEW_MODE; |
| 337 | }, |
| 338 | |
| 339 | async setViewMode(mode) { |
| 340 | const next = mode === PREVIEW_MODE && this.isTextDocument() ? PREVIEW_MODE : SOURCE_MODE; |
| 341 | if (this.viewMode === next) return; |
| 342 | this.applyPreviewEdit({ silent: true }); |
| 343 | this.syncEditorText(); |
| 344 | this.viewMode = next; |
| 345 | this.cancelPendingClose(); |
| 346 | if (next === SOURCE_MODE) { |
| 347 | this.setSourceEditorText(this.editorText); |
| 348 | this.scheduleSourceEditorInit(); |
| 349 | this.refreshSourceEditorLayout(); |
| 350 | this.queueRender({ focus: Boolean(this.session), end: false }); |
| 351 | return; |
| 352 | } |
| 353 | this.clampActivePage(); |
| 354 | this.schedulePreviewEnhance(); |
| 355 | }, |
| 356 | |
| 357 | async toggleViewMode() { |
| 358 | if (!this.isTextDocument()) return; |
| 359 | await this.setViewMode(this.isPreviewMode() ? SOURCE_MODE : PREVIEW_MODE); |
| 360 | }, |
| 361 | |
| 362 | viewModeIcon() { |
| 363 | return this.isPreviewMode() ? "code" : "article"; |
| 364 | }, |
| 365 | |
| 366 | viewModeTitle() { |
| 367 | return this.isPreviewMode() ? "Source edit" : "Preview"; |
| 368 | }, |
| 369 | |
| 370 | pages() { |
| 371 | if (!this.isTextDocument()) return []; |
| 372 | return buildMarkdownPages(this.editorText, this.tabTitle(this.session || {})); |
| 373 | }, |
| 374 | |
| 375 | currentPage() { |
| 376 | const pages = this.pages(); |
| 377 | const index = Math.max(0, Math.min(this.activePageIndex, pages.length - 1)); |
| 378 | return pages[index] || pages[0] || { title: this.tabTitle(this.session || {}), markdown: "" }; |
| 379 | }, |
| 380 | |
| 381 | pageTitle() { |
| 382 | return this.currentPage().title || this.tabTitle(this.session || {}); |
| 383 | }, |
| 384 | |
| 385 | previewHtml() { |
| 386 | if (!this.isTextDocument()) return ""; |
| 387 | return renderEditorPreviewMarkdown(this.currentPage().markdown || "", this.editorText); |
| 388 | }, |
| 389 | |
| 390 | startPreviewEdit() { |
| 391 | if (!this.session || !this.isTextDocument() || !this.isPreviewMode()) return; |
| 392 | const page = this.currentPage(); |
| 393 | this.previewEditing = true; |
| 394 | this.previewEditDirty = false; |
| 395 | this.previewEditPageIndex = this.activePageIndex; |
| 396 | this.previewEditText = page.markdown || ""; |
| 397 | globalThis.requestAnimationFrame?.(() => { |
| 398 | const editor = this._root?.querySelector?.("[data-editor-preview-source]"); |
| 399 | editor?.focus?.({ preventScroll: true }); |
| 400 | }); |
| 401 | }, |
| 402 | |
| 403 | onPreviewEditInput() { |
| 404 | if (this.previewEditing) this.previewEditDirty = true; |
| 405 | }, |
| 406 | |
| 407 | cancelPreviewEdit() { |
| 408 | this.previewEditing = false; |
| 409 | this.previewEditDirty = false; |
| 410 | this.previewEditText = ""; |
| 411 | this.previewEditPageIndex = -1; |
| 412 | this.schedulePreviewEnhance(); |
| 413 | }, |
| 414 | |
| 415 | applyPreviewEdit(options = {}) { |
| 416 | if (!this.previewEditing) return false; |
| 417 | if (!this.previewEditDirty && options.force !== true) { |
| 418 | this.cancelPreviewEdit(); |
| 419 | return false; |
| 420 | } |
| 421 | const pages = this.pages(); |
| 422 | const index = Math.max(0, Math.min( |
| 423 | this.previewEditPageIndex >= 0 ? this.previewEditPageIndex : this.activePageIndex, |
| 424 | pages.length - 1, |
| 425 | )); |
| 426 | const page = pages[index]; |
| 427 | if (!page) { |
| 428 | this.cancelPreviewEdit(); |
| 429 | return false; |
| 430 | } |
| 431 | |
| 432 | let replacement = String(this.previewEditText || ""); |
| 433 | this.previewEditing = false; |
| 434 | this.previewEditDirty = false; |
| 435 | this.previewEditText = ""; |
| 436 | this.previewEditPageIndex = -1; |
| 437 | |
| 438 | return this.replacePageMarkdown(page, replacement, { |
| 439 | message: "Document updated", |
| 440 | silent: options.silent, |
| 441 | }); |
| 442 | }, |
| 443 | |
| 444 | replacePageMarkdown(page = null, markdown = "", options = {}) { |
| 445 | if (!page) return false; |
| 446 | const source = String(this.editorText || ""); |
| 447 | const start = Math.max(0, Number(page.start || 0)); |
| 448 | const end = Math.max(start, Number(page.end ?? source.length)); |
| 449 | const before = source.slice(0, start); |
| 450 | const after = source.slice(end); |
| 451 | let replacement = String(markdown || ""); |
| 452 | if (replacement && after && !replacement.endsWith("\n")) replacement += "\n"; |
| 453 | const next = before + replacement + after; |
| 454 | if (next === source) { |
| 455 | this.schedulePreviewEnhance(); |
| 456 | return false; |
| 457 | } |
| 458 | |
| 459 | this.editorText = next; |
| 460 | this.setSourceEditorText(next); |
| 461 | if (this.session) { |
| 462 | this.session.text = next; |
| 463 | this.session.dirty = true; |
| 464 | } |
| 465 | this.dirty = true; |
| 466 | this.pushHistory(next); |
| 467 | this.scheduleInputPush(); |
| 468 | this.clampActivePage(); |
| 469 | this.schedulePreviewEnhance(); |
| 470 | if (!options.silent && options.message) this.setMessage(options.message); |
| 471 | return true; |
| 472 | }, |
| 473 | |
| 474 | togglePreviewTask(taskIndex, checked) { |
| 475 | if (!this.session || !this.isTextDocument() || !this.isPreviewMode() || this.previewEditing) return false; |
| 476 | const page = this.currentPage(); |
| 477 | const lines = String(page.markdown || "").split("\n"); |
| 478 | const indexes = taskLineIndexes(page.markdown || ""); |
| 479 | const lineIndex = indexes[Number(taskIndex)]; |
| 480 | if (lineIndex == null || !lines[lineIndex]) return false; |
| 481 | const nextLine = lines[lineIndex].replace( |
| 482 | /^(\s*(?:[-*+]|\d+[.)])\s+\[)[ xX](\](?:\s+|$))/, |
| 483 | `$1${checked ? "x" : " "}$2`, |
| 484 | ); |
| 485 | if (nextLine === lines[lineIndex]) return false; |
| 486 | lines[lineIndex] = nextLine; |
| 487 | return this.replacePageMarkdown(page, lines.join("\n")); |
| 488 | }, |
| 489 | |
| 490 | clampActivePage() { |
| 491 | const pages = this.pages(); |
| 492 | this.activePageIndex = Math.max(0, Math.min(this.activePageIndex, Math.max(0, pages.length - 1))); |
| 493 | }, |
| 494 | |
| 495 | schedulePreviewEnhance() { |
| 496 | if (!this.isTextDocument() || !this.isPreviewMode()) return; |
| 497 | if (this._previewEnhanceTimer) globalThis.clearTimeout(this._previewEnhanceTimer); |
| 498 | this._previewEnhanceTimer = globalThis.setTimeout(() => { |
| 499 | this._previewEnhanceTimer = null; |
| 500 | this.enhancePreview(); |
| 501 | }, 0); |
| 502 | }, |
| 503 | |
| 504 | enhancePreview() { |
| 505 | const root = this._root?.querySelector?.("[data-editor-preview]"); |
| 506 | if (!root) return; |
| 507 | this.addHeadingIds(root); |
| 508 | this.enhanceTables(root); |
| 509 | this.enhanceTaskLists(root); |
| 510 | this.enhanceImages(root); |
| 511 | this.enhanceLinks(root); |
| 512 | this.enhanceCodeBlocks(root); |
| 513 | this.renderMath(root); |
| 514 | this.applySearchHighlights(root); |
| 515 | this.scrollPendingFragment(root); |
| 516 | }, |
| 517 | |
| 518 | addHeadingIds(root) { |
| 519 | const used = new Map(); |
| 520 | root.querySelectorAll("h1,h2,h3,h4,h5,h6").forEach((heading) => { |
| 521 | if (!heading.id) heading.id = slugifyHeading(heading.textContent || "", used); |
| 522 | }); |
| 523 | }, |
| 524 | |
| 525 | enhanceTables(root) { |
| 526 | root.querySelectorAll("table").forEach((table) => { |
| 527 | if (table.parentElement?.classList.contains("editor-table-wrap")) return; |
| 528 | const wrapper = document.createElement("div"); |
| 529 | wrapper.className = "editor-table-wrap"; |
| 530 | table.parentNode?.insertBefore(wrapper, table); |
| 531 | wrapper.appendChild(table); |
| 532 | }); |
| 533 | }, |
| 534 | |
| 535 | enhanceTaskLists(root) { |
| 536 | root.querySelectorAll('input[type="checkbox"]').forEach((checkbox, index) => { |
| 537 | if (checkbox.dataset.editorTaskEnhanced === "true") return; |
| 538 | checkbox.dataset.editorTaskEnhanced = "true"; |
| 539 | checkbox.dataset.editorTaskIndex = String(index); |
| 540 | checkbox.disabled = false; |
| 541 | checkbox.removeAttribute("disabled"); |
| 542 | checkbox.addEventListener("change", (event) => { |
| 543 | const target = event.currentTarget; |
| 544 | this.togglePreviewTask(Number(target?.dataset?.editorTaskIndex || 0), Boolean(target?.checked)); |
| 545 | }); |
| 546 | }); |
| 547 | }, |
| 548 | |
| 549 | enhanceImages(root) { |
| 550 | const docPath = this.session?.path || this.session?.document?.path || ""; |
| 551 | root.querySelectorAll("img[src]").forEach((image) => { |
| 552 | const src = image.getAttribute("src") || ""; |
| 553 | if (!src || isExternalHref(src) || src.startsWith("data:") || src.startsWith("/api/image_get")) return; |
| 554 | const resolved = resolveDocumentRelativePath(docPath, src); |
| 555 | image.setAttribute("src", `/api/image_get?path=${encodeURIComponent(resolved)}`); |
| 556 | image.setAttribute("loading", "lazy"); |
| 557 | }); |
| 558 | }, |
| 559 | |
| 560 | enhanceLinks(root) { |
| 561 | const docPath = this.session?.path || this.session?.document?.path || ""; |
| 562 | root.querySelectorAll("a[href]").forEach((anchor) => { |
| 563 | const href = anchor.getAttribute("href") || ""; |
| 564 | if (!href || isExternalHref(href)) return; |
| 565 | const { path, fragment } = splitHref(href); |
| 566 | if (!path && fragment) { |
| 567 | anchor.dataset.editorFragment = fragment; |
| 568 | return; |
| 569 | } |
| 570 | if (!isMarkdownPath(path)) return; |
| 571 | anchor.dataset.editorMarkdownPath = resolveDocumentRelativePath(docPath, path); |
| 572 | anchor.dataset.editorFragment = fragment; |
| 573 | }); |
| 574 | }, |
| 575 | |
| 576 | async enhanceCodeBlocks(root) { |
| 577 | root.querySelectorAll("pre > code").forEach((code) => { |
| 578 | const pre = code.parentElement; |
| 579 | if (!pre || pre.parentElement?.classList.contains("editor-code-block")) return; |
| 580 | const wrapper = document.createElement("div"); |
| 581 | wrapper.className = "editor-code-block"; |
| 582 | const header = document.createElement("div"); |
| 583 | header.className = "editor-code-header"; |
| 584 | const language = this.codeLanguage(code); |
| 585 | const label = document.createElement("span"); |
| 586 | label.className = "editor-code-language"; |
| 587 | label.textContent = language || "text"; |
| 588 | const button = document.createElement("button"); |
| 589 | button.type = "button"; |
| 590 | button.className = "editor-code-copy"; |
| 591 | button.textContent = "Copy"; |
| 592 | button.addEventListener("click", async () => { |
| 593 | await navigator.clipboard?.writeText(code.textContent || ""); |
| 594 | button.textContent = "Copied"; |
| 595 | globalThis.setTimeout(() => { button.textContent = "Copy"; }, 1200); |
| 596 | }); |
| 597 | header.append(label, button); |
| 598 | pre.parentNode?.insertBefore(wrapper, pre); |
| 599 | wrapper.append(header, pre); |
| 600 | this.highlightCodeBlock(code, language); |
| 601 | }); |
| 602 | }, |
| 603 | |
| 604 | codeLanguage(code) { |
| 605 | for (const className of code.classList || []) { |
| 606 | if (className.startsWith("language-")) return className.slice("language-".length); |
| 607 | if (className.startsWith("lang-")) return className.slice("lang-".length); |
| 608 | } |
| 609 | return ""; |
| 610 | }, |
| 611 | |
| 612 | async highlightCodeBlock(code, language) { |
| 613 | if (!language || !globalThis.ace?.require) return; |
| 614 | const source = code.textContent || ""; |
| 615 | try { |
| 616 | const highlighter = await this.loadAceStaticHighlighter(); |
| 617 | const darkMode = globalThis.localStorage?.getItem("darkMode"); |
| 618 | const theme = darkMode !== "false" ? "ace/theme/github_dark" : "ace/theme/github"; |
| 619 | const mode = `ace/mode/${aceModeForLanguage(language)}`; |
| 620 | highlighter.render(source, mode, theme, 1, true, (result) => { |
| 621 | code.innerHTML = result.html; |
| 622 | code.classList.add("is-highlighted"); |
| 623 | }); |
| 624 | } catch { |
| 625 | // Fenced code still renders as preformatted text if highlighting is unavailable. |
| 626 | } |
| 627 | }, |
| 628 | |
| 629 | loadAceStaticHighlighter() { |
| 630 | if (this._staticHighlightPromise) return this._staticHighlightPromise; |
| 631 | this._staticHighlightPromise = new Promise((resolve, reject) => { |
| 632 | let existing = null; |
| 633 | try { |
| 634 | existing = globalThis.ace?.require?.("ace/ext/static_highlight"); |
| 635 | } catch { |
| 636 | existing = null; |
| 637 | } |
| 638 | if (existing?.render) { |
| 639 | resolve(existing); |
| 640 | return; |
| 641 | } |
| 642 | const script = document.createElement("script"); |
| 643 | script.src = "/vendor/ace-min/ext-static_highlight.js"; |
| 644 | script.onload = () => { |
| 645 | const loaded = globalThis.ace?.require?.("ace/ext/static_highlight"); |
| 646 | loaded?.render ? resolve(loaded) : reject(new Error("ACE highlighter unavailable")); |
| 647 | }; |
| 648 | script.onerror = () => reject(new Error("ACE highlighter failed to load")); |
| 649 | document.head.appendChild(script); |
| 650 | }); |
| 651 | return this._staticHighlightPromise; |
| 652 | }, |
| 653 | |
| 654 | renderMath(root) { |
| 655 | if (!globalThis.katex?.render) return; |
| 656 | for (const node of textNodesUnder(root, "code,pre,.katex,.editor-code-block")) { |
| 657 | this.replaceMathInTextNode(node); |
| 658 | } |
| 659 | }, |
| 660 | |
| 661 | replaceMathInTextNode(node) { |
| 662 | const text = node.nodeValue || ""; |
| 663 | const pattern = /(\$\$[^$]+\$\$|\$[^$\n]+\$)/g; |
| 664 | if (!pattern.test(text)) return; |
| 665 | pattern.lastIndex = 0; |
| 666 | const fragment = document.createDocumentFragment(); |
| 667 | let lastIndex = 0; |
| 668 | let match; |
| 669 | while ((match = pattern.exec(text))) { |
| 670 | if (match.index > lastIndex) fragment.append(document.createTextNode(text.slice(lastIndex, match.index))); |
| 671 | const raw = match[0]; |
| 672 | const displayMode = raw.startsWith("$$"); |
| 673 | const expression = raw.slice(displayMode ? 2 : 1, displayMode ? -2 : -1); |
| 674 | const span = document.createElement(displayMode ? "div" : "span"); |
| 675 | span.className = displayMode ? "editor-math-display" : "editor-math-inline"; |
| 676 | try { |
| 677 | globalThis.katex.render(expression, span, { throwOnError: false, displayMode }); |
| 678 | } catch { |
| 679 | span.textContent = raw; |
| 680 | } |
| 681 | fragment.append(span); |
| 682 | lastIndex = match.index + raw.length; |
| 683 | } |
| 684 | if (lastIndex < text.length) fragment.append(document.createTextNode(text.slice(lastIndex))); |
| 685 | node.parentNode?.replaceChild(fragment, node); |
| 686 | }, |
| 687 | |
| 688 | openSearch() { |
| 689 | if (!this.isTextDocument()) return; |
| 690 | if (!this.isPreviewMode()) { |
| 691 | this.setViewMode(PREVIEW_MODE); |
| 692 | } |
| 693 | this.searchOpen = true; |
| 694 | this.runSearch(); |
| 695 | globalThis.requestAnimationFrame?.(() => { |
| 696 | this._root?.querySelector?.("[data-editor-search]")?.focus?.(); |
| 697 | }); |
| 698 | }, |
| 699 | |
| 700 | closeSearch() { |
| 701 | this.searchOpen = false; |
| 702 | this.searchQuery = ""; |
| 703 | this.searchMatches = []; |
| 704 | this.searchIndex = -1; |
| 705 | this.schedulePreviewEnhance(); |
| 706 | }, |
| 707 | |
| 708 | searchCountLabel() { |
| 709 | if (!this.searchQuery) return ""; |
| 710 | if (!this.searchMatches.length) return "0 of 0"; |
| 711 | return `${this.searchIndex + 1} of ${this.searchMatches.length}`; |
| 712 | }, |
| 713 | |
| 714 | runSearch() { |
| 715 | const query = String(this.searchQuery || ""); |
| 716 | if (!query) { |
| 717 | this.searchMatches = []; |
| 718 | this.searchIndex = -1; |
| 719 | this.schedulePreviewEnhance(); |
| 720 | return; |
| 721 | } |
| 722 | const lower = query.toLowerCase(); |
| 723 | const matches = []; |
| 724 | for (const page of this.pages()) { |
| 725 | const text = this.renderedTextForPage(page); |
| 726 | let index = 0; |
| 727 | let occurrence = 0; |
| 728 | while ((index = text.toLowerCase().indexOf(lower, index)) >= 0) { |
| 729 | matches.push({ pageIndex: page.index, occurrence, offset: index }); |
| 730 | occurrence += 1; |
| 731 | index += Math.max(1, lower.length); |
| 732 | } |
| 733 | } |
| 734 | this.searchMatches = matches; |
| 735 | this.searchIndex = matches.length ? 0 : -1; |
| 736 | this.goToCurrentSearchMatch(); |
| 737 | }, |
| 738 | |
| 739 | nextSearchMatch() { |
| 740 | if (!this.searchMatches.length) return; |
| 741 | this.searchIndex = (this.searchIndex + 1) % this.searchMatches.length; |
| 742 | this.goToCurrentSearchMatch(); |
| 743 | }, |
| 744 | |
| 745 | previousSearchMatch() { |
| 746 | if (!this.searchMatches.length) return; |
| 747 | this.searchIndex = (this.searchIndex - 1 + this.searchMatches.length) % this.searchMatches.length; |
| 748 | this.goToCurrentSearchMatch(); |
| 749 | }, |
| 750 | |
| 751 | goToCurrentSearchMatch() { |
| 752 | const match = this.searchMatches[this.searchIndex]; |
| 753 | if (!match) { |
| 754 | this.schedulePreviewEnhance(); |
| 755 | return; |
| 756 | } |
| 757 | this.activePageIndex = match.pageIndex; |
| 758 | this.schedulePreviewEnhance(); |
| 759 | }, |
| 760 | |
| 761 | renderedTextForPage(page) { |
| 762 | const html = renderEditorPreviewMarkdown(page.markdown || "", this.editorText); |
| 763 | const doc = new DOMParser().parseFromString(html, "text/html"); |
| 764 | return doc.body.textContent || ""; |
| 765 | }, |
| 766 | |
| 767 | applySearchHighlights(root) { |
| 768 | root.querySelectorAll("mark.editor-search-mark").forEach((mark) => { |
| 769 | mark.replaceWith(document.createTextNode(mark.textContent || "")); |
| 770 | }); |
| 771 | const query = String(this.searchQuery || ""); |
| 772 | if (!query || !this.searchMatches.length) return; |
| 773 | const regex = new RegExp(escapeRegExp(query), "gi"); |
| 774 | const current = this.searchMatches[this.searchIndex]; |
| 775 | let occurrence = 0; |
| 776 | for (const node of textNodesUnder(root, "script,style")) { |
| 777 | const text = node.nodeValue || ""; |
| 778 | if (!regex.test(text)) continue; |
| 779 | regex.lastIndex = 0; |
| 780 | const fragment = document.createDocumentFragment(); |
| 781 | let lastIndex = 0; |
| 782 | let match; |
| 783 | while ((match = regex.exec(text))) { |
| 784 | if (match.index > lastIndex) fragment.append(document.createTextNode(text.slice(lastIndex, match.index))); |
| 785 | const mark = document.createElement("mark"); |
| 786 | mark.className = "editor-search-mark"; |
| 787 | if (current?.pageIndex === this.activePageIndex && current.occurrence === occurrence) { |
| 788 | mark.classList.add("is-current"); |
| 789 | } |
| 790 | mark.textContent = match[0]; |
| 791 | fragment.append(mark); |
| 792 | occurrence += 1; |
| 793 | lastIndex = match.index + match[0].length; |
| 794 | } |
| 795 | if (lastIndex < text.length) fragment.append(document.createTextNode(text.slice(lastIndex))); |
| 796 | node.parentNode?.replaceChild(fragment, node); |
| 797 | } |
| 798 | root.querySelector("mark.editor-search-mark.is-current")?.scrollIntoView?.({ block: "center" }); |
| 799 | }, |
| 800 | |
| 801 | async handlePreviewClick(event) { |
| 802 | const anchor = event.target?.closest?.("a[href]"); |
| 803 | if (!anchor) return; |
| 804 | const markdownPath = anchor.dataset.editorMarkdownPath || ""; |
| 805 | const fragment = anchor.dataset.editorFragment || ""; |
| 806 | if (!markdownPath && fragment) { |
| 807 | event.preventDefault(); |
| 808 | this.navigateToFragment(fragment); |
| 809 | return; |
| 810 | } |
| 811 | if (!markdownPath) return; |
| 812 | event.preventDefault(); |
| 813 | this._pendingPreviewFragment = fragment; |
| 814 | const opened = await this.openSession({ path: markdownPath, refresh: true, source: "editor-preview-link" }); |
| 815 | if (!opened) return; |
| 816 | if (this.isPreviewMode() && fragment) { |
| 817 | this.navigateToFragment(fragment); |
| 818 | } |
| 819 | }, |
| 820 | |
| 821 | navigateToFragment(fragment = "") { |
| 822 | const target = String(fragment || "").replace(/^#/, ""); |
| 823 | if (!target) return; |
| 824 | const pages = this.pages(); |
| 825 | const normalized = target.toLowerCase(); |
| 826 | for (const page of pages) { |
| 827 | const doc = new DOMParser().parseFromString(renderEditorPreviewMarkdown(page.markdown || "", this.editorText), "text/html"); |
| 828 | const used = new Map(); |
| 829 | const headings = [...doc.body.querySelectorAll("h1,h2,h3,h4,h5,h6")]; |
| 830 | if (headings.some((heading) => (heading.id || slugifyHeading(heading.textContent || "", used)) === normalized)) { |
| 831 | this.activePageIndex = page.index; |
| 832 | this._pendingPreviewFragment = target; |
| 833 | this.schedulePreviewEnhance(); |
| 834 | return; |
| 835 | } |
| 836 | } |
| 837 | this._pendingPreviewFragment = target; |
| 838 | this.schedulePreviewEnhance(); |
| 839 | }, |
| 840 | |
| 841 | scrollPendingFragment(root) { |
| 842 | const fragment = this._pendingPreviewFragment; |
| 843 | if (!fragment) return; |
| 844 | const target = root.querySelector(`#${CSS.escape(fragment)}`); |
| 845 | if (target) { |
| 846 | target.scrollIntoView({ block: "start" }); |
| 847 | this._pendingPreviewFragment = ""; |
| 848 | } |
| 849 | }, |
| 850 | |
| 851 | handleEditorKeydown(event) { |
| 852 | if (!(event.metaKey || event.ctrlKey) || !this.isTextDocument()) return; |
| 853 | const key = event.key.toLowerCase(); |
| 854 | const historyAction = key === "y" || (key === "z" && event.shiftKey) ? "redo" : key === "z" ? "undo" : ""; |
| 855 | const nativeEditing = event.target?.matches?.("input, textarea, [contenteditable='true']") |
| 856 | && !event.target.closest?.("[data-editor-ace], [data-editor-source]"); |
| 857 | if (nativeEditing) return; |
| 858 | if (historyAction && !this.previewEditing) { |
| 859 | event.preventDefault(); |
| 860 | event.stopPropagation(); |
| 861 | this[historyAction](); |
| 862 | return; |
| 863 | } |
| 864 | if (key === "f") { |
| 865 | event.preventDefault(); |
| 866 | this.openSearch(); |
| 867 | } |
| 868 | }, |
| 869 | |
| 870 | async create(kind = "document", format = "") { |
| 871 | const requested = String(format || "md").toLowerCase().replace(/^\./, ""); |
| 872 | const fmt = EDITOR_TEXT_EXTENSIONS.has(requested) ? requested : "md"; |
| 873 | const title = this.defaultTitle(kind, fmt); |
| 874 | return await this.openSession({ |
| 875 | action: "create", |
| 876 | kind: "document", |
| 877 | format: fmt, |
| 878 | title, |
| 879 | }); |
| 880 | }, |
| 881 | |
| 882 | async openFileBrowser() { |
| 883 | let workdirPath = "/a0/usr/workdir"; |
| 884 | try { |
| 885 | const home = await callEditor("home"); |
| 886 | if (home?.path) { |
| 887 | workdirPath = home.path; |
| 888 | } else { |
| 889 | const response = await callJsonApi("settings_get", null); |
| 890 | workdirPath = response?.settings?.workdir_path || workdirPath; |
| 891 | } |
| 892 | } catch { |
| 893 | try { |
| 894 | const response = await callJsonApi("settings_get", null); |
| 895 | workdirPath = response?.settings?.workdir_path || workdirPath; |
| 896 | } catch { |
| 897 | // The file browser can still open with the static fallback. |
| 898 | } |
| 899 | } |
| 900 | await fileBrowserStore.openTextPicker(workdirPath, async ({ selectedFiles = [] } = {}) => { |
| 901 | const files = selectedFiles.filter((file) => file?.path); |
| 902 | if (!files.length) return false; |
| 903 | for (const file of files) { |
| 904 | const session = await this.openPath(fileBrowserStore.normalizePath(file.path), { source: "file-browser", refresh: true }); |
| 905 | if (!session || session.ok === false) { |
| 906 | throw new Error(this.error || `Could not open ${file.name || file.path}`); |
| 907 | } |
| 908 | } |
| 909 | return true; |
| 910 | }); |
| 911 | }, |
| 912 | |
| 913 | async openPath(path, options = {}) { |
| 914 | return await this.openSession({ |
| 915 | path: String(path || ""), |
| 916 | source: options?.source || "", |
| 917 | refresh: options?.refresh === true, |
| 918 | }); |
| 919 | }, |
| 920 | |
| 921 | async openSession(payload = {}) { |
| 922 | this.loading = true; |
| 923 | this.error = ""; |
| 924 | try { |
| 925 | const response = await callEditor(payload.action || "open", payload); |
| 926 | if (response?.ok === false) { |
| 927 | this.error = response.error || "Text document could not be opened."; |
| 928 | return null; |
| 929 | } |
| 930 | if (response?.requires_desktop) { |
| 931 | const document = normalizeTextDocument(response.document || response); |
| 932 | this.setMessage(`${documentLabel(document)} uses the Desktop surface.`); |
| 933 | await this.refresh(); |
| 934 | return response; |
| 935 | } |
| 936 | const session = normalizeSession(response); |
| 937 | this.installSession(session); |
| 938 | await this.refresh(); |
| 939 | return session; |
| 940 | } catch (error) { |
| 941 | this.error = error instanceof Error ? error.message : String(error); |
| 942 | return null; |
| 943 | } finally { |
| 944 | this.loading = false; |
| 945 | } |
| 946 | }, |
| 947 | |
| 948 | installSession(session) { |
| 949 | const existingIndex = this.tabs.findIndex((tab) => ( |
| 950 | (session.file_id && tab.file_id === session.file_id) |
| 951 | || (session.path && tab.path === session.path) |
| 952 | )); |
| 953 | if (existingIndex >= 0) { |
| 954 | const tabId = this.tabs[existingIndex].tab_id; |
| 955 | const wasActive = this.activeTabId === tabId || this.session?.tab_id === tabId; |
| 956 | const merged = { ...this.tabs[existingIndex], ...session, tab_id: tabId }; |
| 957 | this.tabs.splice(existingIndex, 1, merged); |
| 958 | this.activeTabId = tabId; |
| 959 | if (wasActive) { |
| 960 | this.hydrateActiveSession(merged, { preservePage: true, focus: false }); |
| 961 | return; |
| 962 | } |
| 963 | } else { |
| 964 | this.tabs.push(session); |
| 965 | this.activeTabId = session.tab_id; |
| 966 | } |
| 967 | this.selectTab(this.activeTabId); |
| 968 | }, |
| 969 | |
| 970 | hydrateActiveSession(tab, options = {}) { |
| 971 | this.session = tab || null; |
| 972 | this.activeTabId = tab?.tab_id || ""; |
| 973 | this.editorText = String(tab?.text || ""); |
| 974 | this.dirty = Boolean(tab?.dirty); |
| 975 | if (this.previewEditing) this.cancelPreviewEdit(); |
| 976 | if (!options.preservePage) { |
| 977 | this.activePageIndex = 0; |
| 978 | } else { |
| 979 | this.clampActivePage(); |
| 980 | } |
| 981 | this.searchMatches = []; |
| 982 | this.searchIndex = -1; |
| 983 | this.resetHistory(this.editorText); |
| 984 | this.setSourceEditorText(this.editorText); |
| 985 | this.updateSourceEditorMode(); |
| 986 | if (tab?.session_id) { |
| 987 | requestEditor("editor_activate", { session_id: tab.session_id }, 2500).catch(() => {}); |
| 988 | } |
| 989 | if (this.searchOpen && this.searchQuery) this.runSearch(); |
| 990 | else if (this.isSourceMode()) this.scheduleSourceEditorInit(); |
| 991 | else this.schedulePreviewEnhance(); |
| 992 | this.refreshSourceEditorLayout(); |
| 993 | this.queueRender({ focus: this.isSourceMode() && Boolean(tab) && options.focus !== false, end: false }); |
| 994 | }, |
| 995 | |
| 996 | selectTab(tabId, options = {}) { |
| 997 | this.applyPreviewEdit({ silent: true }); |
| 998 | this.syncEditorText(); |
| 999 | const tab = this.tabs.find((item) => item.tab_id === tabId) || this.tabs[0] || null; |
| 1000 | this.previewEditing = false; |
| 1001 | this.previewEditDirty = false; |
| 1002 | this.previewEditText = ""; |
| 1003 | this.previewEditPageIndex = -1; |
| 1004 | this.hydrateActiveSession(tab, { preservePage: false, focus: options.focus !== false }); |
| 1005 | }, |
| 1006 | |
| 1007 | ensureActiveTab() { |
| 1008 | if (this.session && this.tabs.some((tab) => tab.tab_id === this.session.tab_id)) return; |
| 1009 | if (this.tabs.length) this.selectTab(this.tabs[0].tab_id, { focus: false }); |
| 1010 | }, |
| 1011 | |
| 1012 | isActiveTab(tab) { |
| 1013 | return Boolean(tab && tab.tab_id === this.activeTabId); |
| 1014 | }, |
| 1015 | |
| 1016 | isTabDirty(tab) { |
| 1017 | return Boolean(tab?.dirty || (this.isActiveTab(tab) && (this.dirty || this.previewEditDirty))); |
| 1018 | }, |
| 1019 | |
| 1020 | hasPendingClose() { |
| 1021 | return Boolean(this.pendingClose); |
| 1022 | }, |
| 1023 | |
| 1024 | pendingCloseTitle() { |
| 1025 | const pending = this.pendingClose; |
| 1026 | if (!pending) return ""; |
| 1027 | if (pending.kind === "all") { |
| 1028 | return `Close ${pending.totalCount || 0} open files?`; |
| 1029 | } |
| 1030 | const tab = this.tabs.find((item) => item.tab_id === pending.tabId); |
| 1031 | return `Close ${this.tabTitle(tab || {})}?`; |
| 1032 | }, |
| 1033 | |
| 1034 | pendingCloseMessage() { |
| 1035 | const pending = this.pendingClose; |
| 1036 | if (!pending) return ""; |
| 1037 | const dirtyCount = Number(pending.dirtyCount || 0); |
| 1038 | if (pending.kind === "all") { |
| 1039 | if (dirtyCount === 0) return "All open text files will be closed."; |
| 1040 | return `${dirtyCount} open ${dirtyCount === 1 ? "file has" : "files have"} unsaved changes.`; |
| 1041 | } |
| 1042 | if (dirtyCount > 0) return "This file has unsaved changes."; |
| 1043 | return "This file will be closed."; |
| 1044 | }, |
| 1045 | |
| 1046 | pendingCloseHasDirty() { |
| 1047 | return Number(this.pendingClose?.dirtyCount || 0) > 0; |
| 1048 | }, |
| 1049 | |
| 1050 | pendingCloseDiscardLabel() { |
| 1051 | return this.pendingCloseHasDirty() ? "Discard" : "Close"; |
| 1052 | }, |
| 1053 | |
| 1054 | beginCloseConfirmation(kind, tabIds = []) { |
| 1055 | const ids = tabIds.filter(Boolean); |
| 1056 | const tabs = ids.map((id) => this.tabs.find((tab) => tab.tab_id === id)).filter(Boolean); |
| 1057 | const dirtyCount = tabs.filter((tab) => this.isTabDirty(tab)).length; |
| 1058 | this.pendingClose = { |
| 1059 | kind, |
| 1060 | tabId: kind === "single" ? ids[0] || "" : "", |
| 1061 | tabIds: ids, |
| 1062 | totalCount: tabs.length, |
| 1063 | dirtyCount, |
| 1064 | }; |
| 1065 | if (kind === "single" && ids[0] && this.activeTabId !== ids[0]) { |
| 1066 | this.selectTab(ids[0], { focus: false }); |
| 1067 | } |
| 1068 | }, |
| 1069 | |
| 1070 | cancelPendingClose() { |
| 1071 | this.pendingClose = null; |
| 1072 | }, |
| 1073 | |
| 1074 | async confirmPendingClose(options = {}) { |
| 1075 | const pending = this.pendingClose; |
| 1076 | if (!pending || this.loading) return; |
| 1077 | this.pendingClose = null; |
| 1078 | const save = options.save === true; |
| 1079 | if (pending.kind === "all") { |
| 1080 | await this.closeAllFiles({ confirm: false, save, tabIds: pending.tabIds || [] }); |
| 1081 | return; |
| 1082 | } |
| 1083 | await this.closeTab(pending.tabId, { confirm: false, save }); |
| 1084 | }, |
| 1085 | |
| 1086 | async closeTab(tabId, options = {}) { |
| 1087 | const tab = this.tabs.find((item) => item.tab_id === tabId); |
| 1088 | if (!tab) return; |
| 1089 | if (this.isTabDirty(tab) && options.confirm !== false) { |
| 1090 | this.beginCloseConfirmation("single", [tab.tab_id]); |
| 1091 | return; |
| 1092 | } |
| 1093 | await this.closeTabNow(tab, { save: options.save === true }); |
| 1094 | }, |
| 1095 | |
| 1096 | async closeTabNow(tab, options = {}) { |
| 1097 | if (!tab || this.loading) return false; |
| 1098 | const tabId = tab.tab_id; |
| 1099 | if (options.save === true && this.isTabDirty(tab)) { |
| 1100 | const saved = await this.saveTab(tab); |
| 1101 | if (!saved) return false; |
| 1102 | } |
| 1103 | if (this.activeTabId === tabId && this.previewEditing) this.cancelPreviewEdit(); |
| 1104 | try { |
| 1105 | if (tab.session_id) { |
| 1106 | await requestEditor("editor_close", { session_id: tab.session_id }, 2500).catch(() => null); |
| 1107 | } |
| 1108 | await callEditor("close", { |
| 1109 | session_id: tab.session_id || "", |
| 1110 | store_session_id: tab.store_session_id || "", |
| 1111 | file_id: tab.file_id || "", |
| 1112 | }); |
| 1113 | } catch (error) { |
| 1114 | console.warn("Editor close skipped", error); |
| 1115 | } |
| 1116 | this.tabs = this.tabs.filter((item) => item.tab_id !== tabId); |
| 1117 | if (this.pendingClose?.tabId === tabId || this.pendingClose?.tabIds?.includes(tabId)) { |
| 1118 | this.pendingClose = null; |
| 1119 | } |
| 1120 | if (this.activeTabId === tabId) { |
| 1121 | this.session = null; |
| 1122 | this.activeTabId = ""; |
| 1123 | this.editorText = ""; |
| 1124 | this.dirty = false; |
| 1125 | this.ensureActiveTab(); |
| 1126 | } |
| 1127 | this.ensureActiveTab(); |
| 1128 | await this.refresh(); |
| 1129 | return true; |
| 1130 | }, |
| 1131 | |
| 1132 | async closeActiveFile() { |
| 1133 | if (!this.session || this.loading) return; |
| 1134 | await this.closeTab(this.session.tab_id); |
| 1135 | }, |
| 1136 | |
| 1137 | async closeAllFiles(options = {}) { |
| 1138 | if (this.loading) return; |
| 1139 | const requestedIds = Array.isArray(options.tabIds) && options.tabIds.length |
| 1140 | ? options.tabIds |
| 1141 | : this.visibleTabs().map((tab) => tab.tab_id); |
| 1142 | const tabs = requestedIds.map((id) => this.tabs.find((tab) => tab.tab_id === id)).filter(Boolean); |
| 1143 | if (!tabs.length) return; |
| 1144 | |
| 1145 | const dirtyTabs = tabs.filter((tab) => this.isTabDirty(tab)); |
| 1146 | if (dirtyTabs.length && options.confirm !== false) { |
| 1147 | this.beginCloseConfirmation("all", tabs.map((tab) => tab.tab_id)); |
| 1148 | return; |
| 1149 | } |
| 1150 | |
| 1151 | this.pendingClose = null; |
| 1152 | for (const tab of [...tabs]) { |
| 1153 | const current = this.tabs.find((item) => item.tab_id === tab.tab_id); |
| 1154 | if (!current) continue; |
| 1155 | const closed = await this.closeTabNow(current, { |
| 1156 | save: options.save === true && this.isTabDirty(current), |
| 1157 | }); |
| 1158 | if (!closed) break; |
| 1159 | } |
| 1160 | }, |
| 1161 | |
| 1162 | scheduleSourceEditorInit() { |
| 1163 | if (!this.isSourceMode()) return; |
| 1164 | globalThis.requestAnimationFrame?.(() => { |
| 1165 | globalThis.requestAnimationFrame?.(() => this.initSourceEditor()); |
| 1166 | }); |
| 1167 | }, |
| 1168 | |
| 1169 | initSourceEditor() { |
| 1170 | if (!this.isSourceMode() || !this._root) return; |
| 1171 | const container = this._root.querySelector?.("[data-editor-ace]"); |
| 1172 | if (this.sourceEditor && !this._root.contains?.(this.sourceEditor.container)) { |
| 1173 | this.destroySourceEditor(); |
| 1174 | } |
| 1175 | if (!container || this.sourceEditor) return; |
| 1176 | if (!globalThis.ace?.edit) { |
| 1177 | this.aceUnavailable = true; |
| 1178 | return; |
| 1179 | } |
| 1180 | |
| 1181 | const editor = globalThis.ace.edit(container); |
| 1182 | const darkMode = globalThis.localStorage?.getItem("darkMode"); |
| 1183 | const theme = darkMode !== "false" ? "ace/theme/github_dark" : "ace/theme/github"; |
| 1184 | editor.setTheme(theme); |
| 1185 | editor.session.setMode(this.sourceEditorMode()); |
| 1186 | editor.session.setUseWrapMode(true); |
| 1187 | editor.setOptions({ |
| 1188 | fontSize: "13px", |
| 1189 | showGutter: false, |
| 1190 | showPrintMargin: false, |
| 1191 | useWorker: false, |
| 1192 | }); |
| 1193 | editor.renderer.setShowGutter(false); |
| 1194 | editor.renderer.setScrollMargin(14, 14, 0, 0); |
| 1195 | editor.setValue(this.editorText || "", -1); |
| 1196 | this._sourceEditorChangeHandler = () => { |
| 1197 | if (this._settingSourceEditorValue) return; |
| 1198 | this.editorText = editor.getValue(); |
| 1199 | this.onSourceInput(); |
| 1200 | }; |
| 1201 | editor.session.on("change", this._sourceEditorChangeHandler); |
| 1202 | this.sourceEditor = editor; |
| 1203 | this.aceUnavailable = false; |
| 1204 | this.updateSourceEditorMode(); |
| 1205 | this.queueRender({ focus: Boolean(this.session), end: false }); |
| 1206 | }, |
| 1207 | |
| 1208 | sourceEditorMode(tab = this.session) { |
| 1209 | return this.isMarkdown(tab) ? "ace/mode/markdown" : "ace/mode/text"; |
| 1210 | }, |
| 1211 | |
| 1212 | updateSourceEditorMode(tab = this.session) { |
| 1213 | try { |
| 1214 | this.sourceEditor?.session?.setMode(this.sourceEditorMode(tab)); |
| 1215 | } catch {} |
| 1216 | }, |
| 1217 | |
| 1218 | destroySourceEditor() { |
| 1219 | if (this.sourceEditor?.session && this._sourceEditorChangeHandler) { |
| 1220 | this.sourceEditor.session.off?.("change", this._sourceEditorChangeHandler); |
| 1221 | } |
| 1222 | const container = this.sourceEditor?.container; |
| 1223 | this.sourceEditor?.destroy?.(); |
| 1224 | if (container) container.textContent = ""; |
| 1225 | this.sourceEditor = null; |
| 1226 | this._sourceEditorChangeHandler = null; |
| 1227 | }, |
| 1228 | |
| 1229 | setSourceEditorText(text = "") { |
| 1230 | if (!this.sourceEditor) return; |
| 1231 | const value = String(text || ""); |
| 1232 | if (this.sourceEditor.getValue() === value) return; |
| 1233 | this._settingSourceEditorValue = true; |
| 1234 | this.sourceEditor.setValue(value, -1); |
| 1235 | this._settingSourceEditorValue = false; |
| 1236 | this.refreshSourceEditorLayout(); |
| 1237 | }, |
| 1238 | |
| 1239 | refreshSourceEditorLayout() { |
| 1240 | const editor = this.sourceEditor; |
| 1241 | if (!editor) return; |
| 1242 | const refresh = () => { |
| 1243 | editor.resize?.(true); |
| 1244 | editor.renderer?.updateFull?.(); |
| 1245 | editor.renderer?.updateText?.(); |
| 1246 | }; |
| 1247 | if (globalThis.requestAnimationFrame) { |
| 1248 | globalThis.requestAnimationFrame(() => globalThis.requestAnimationFrame(refresh)); |
| 1249 | } else { |
| 1250 | globalThis.setTimeout(refresh, 0); |
| 1251 | } |
| 1252 | }, |
| 1253 | |
| 1254 | async save() { |
| 1255 | if (!this.session || this.saving || !this.isTextDocument()) return; |
| 1256 | this.applyPreviewEdit({ silent: true }); |
| 1257 | this.syncEditorText(); |
| 1258 | this.saving = true; |
| 1259 | this.error = ""; |
| 1260 | try { |
| 1261 | let response; |
| 1262 | const payload = { session_id: this.session.session_id, text: this.editorText }; |
| 1263 | try { |
| 1264 | response = await requestEditor("editor_save", payload, 10000); |
| 1265 | } catch (_socketError) { |
| 1266 | response = await callEditor("save", payload); |
| 1267 | } |
| 1268 | if (response?.ok === false) throw new Error(response.error || "Save failed."); |
| 1269 | const document = normalizeTextDocument(response.document || this.session.document || {}); |
| 1270 | const updated = { |
| 1271 | ...this.session, |
| 1272 | text: this.editorText, |
| 1273 | dirty: false, |
| 1274 | document, |
| 1275 | path: document.path || this.session.path, |
| 1276 | file_id: document.file_id || this.session.file_id, |
| 1277 | extension: document.extension || this.session.extension, |
| 1278 | version: document.version || response.version || this.session.version, |
| 1279 | }; |
| 1280 | this.replaceActiveSession(updated); |
| 1281 | this.dirty = false; |
| 1282 | this.setMessage("Saved"); |
| 1283 | await this.refresh(); |
| 1284 | } catch (error) { |
| 1285 | this.error = error instanceof Error ? error.message : String(error); |
| 1286 | } finally { |
| 1287 | this.saving = false; |
| 1288 | } |
| 1289 | }, |
| 1290 | |
| 1291 | async downloadActiveFile() { |
| 1292 | if (!this.session || this.saving || !this.isTextDocument()) return; |
| 1293 | if (this.dirty) await this.save(); |
| 1294 | if (this.dirty) return; |
| 1295 | const path = this.session.path || this.session.document?.path; |
| 1296 | if (path) fileBrowserStore.downloadFile({ path, name: this.tabTitle() }); |
| 1297 | }, |
| 1298 | |
| 1299 | async saveAs() { |
| 1300 | if (!this.session || this.saving || !this.isTextDocument()) return; |
| 1301 | this.applyPreviewEdit({ silent: true }); |
| 1302 | this.syncEditorText(); |
| 1303 | |
| 1304 | let startPath = parentPath(this.session.path || this.session.document?.path || ""); |
| 1305 | if (!startPath || startPath === "/") { |
| 1306 | try { |
| 1307 | const home = await callEditor("home"); |
| 1308 | startPath = home?.path || startPath || "/a0/usr/workdir"; |
| 1309 | } catch { |
| 1310 | startPath = "/a0/usr/workdir"; |
| 1311 | } |
| 1312 | } |
| 1313 | |
| 1314 | await fileBrowserStore.openSaveAsPicker(startPath, { |
| 1315 | filename: textDocumentFilename(this.session.path || this.session.title || "Untitled.md"), |
| 1316 | defaultExtension: textDocumentDefaultExtension(this.session.path || this.session.title || "Untitled.md"), |
| 1317 | onConfirm: async ({ path } = {}) => { |
| 1318 | if (!path) return false; |
| 1319 | await this.saveAsPath(path); |
| 1320 | return true; |
| 1321 | }, |
| 1322 | }); |
| 1323 | }, |
| 1324 | |
| 1325 | async saveAsPath(path) { |
| 1326 | if (!this.session || this.saving || !this.isTextDocument()) return null; |
| 1327 | this.saving = true; |
| 1328 | this.error = ""; |
| 1329 | try { |
| 1330 | const payload = { |
| 1331 | session_id: this.session.session_id, |
| 1332 | store_session_id: this.session.store_session_id || "", |
| 1333 | path, |
| 1334 | text: this.editorText, |
| 1335 | }; |
| 1336 | const response = await callEditor("save_as", payload); |
| 1337 | if (response?.ok === false) throw new Error(response.error || "Save As failed."); |
| 1338 | const document = normalizeTextDocument(response.document || this.session.document || {}); |
| 1339 | const updated = { |
| 1340 | ...this.session, |
| 1341 | text: this.editorText, |
| 1342 | dirty: false, |
| 1343 | document, |
| 1344 | title: document.title || document.basename || basename(document.path), |
| 1345 | path: document.path || path, |
| 1346 | file_id: document.file_id || this.session.file_id, |
| 1347 | extension: document.extension || this.session.extension, |
| 1348 | store_session_id: response.store_session_id || this.session.store_session_id, |
| 1349 | version: document.version || response.version || this.session.version, |
| 1350 | }; |
| 1351 | this.replaceActiveSession(updated); |
| 1352 | this.dirty = false; |
| 1353 | this.setMessage("Saved As"); |
| 1354 | await this.refresh(); |
| 1355 | return updated; |
| 1356 | } catch (error) { |
| 1357 | this.error = error instanceof Error ? error.message : String(error); |
| 1358 | throw error; |
| 1359 | } finally { |
| 1360 | this.saving = false; |
| 1361 | } |
| 1362 | }, |
| 1363 | |
| 1364 | async saveTab(tab) { |
| 1365 | if (!tab || this.saving || !this.isTextDocument(tab)) return false; |
| 1366 | if (this.isActiveTab(tab)) { |
| 1367 | this.applyPreviewEdit({ silent: true }); |
| 1368 | this.syncEditorText(); |
| 1369 | } |
| 1370 | this.saving = true; |
| 1371 | this.error = ""; |
| 1372 | try { |
| 1373 | let response; |
| 1374 | const payload = { |
| 1375 | session_id: tab.session_id, |
| 1376 | text: this.isActiveTab(tab) ? this.editorText : String(tab.text || ""), |
| 1377 | }; |
| 1378 | try { |
| 1379 | response = await requestEditor("editor_save", payload, 10000); |
| 1380 | } catch (_socketError) { |
| 1381 | response = await callEditor("save", payload); |
| 1382 | } |
| 1383 | if (response?.ok === false) throw new Error(response.error || "Save failed."); |
| 1384 | const document = normalizeTextDocument(response.document || tab.document || {}); |
| 1385 | const updated = { |
| 1386 | ...tab, |
| 1387 | text: payload.text, |
| 1388 | dirty: false, |
| 1389 | document, |
| 1390 | path: document.path || tab.path, |
| 1391 | file_id: document.file_id || tab.file_id, |
| 1392 | extension: document.extension || tab.extension, |
| 1393 | version: document.version || response.version || tab.version, |
| 1394 | }; |
| 1395 | this.replaceSession(tab, updated); |
| 1396 | if (this.isActiveTab(updated)) { |
| 1397 | this.dirty = false; |
| 1398 | } |
| 1399 | this.setMessage("Saved"); |
| 1400 | await this.refresh(); |
| 1401 | return true; |
| 1402 | } catch (error) { |
| 1403 | this.error = error instanceof Error ? error.message : String(error); |
| 1404 | return false; |
| 1405 | } finally { |
| 1406 | this.saving = false; |
| 1407 | } |
| 1408 | }, |
| 1409 | |
| 1410 | async renameActiveFile() { |
| 1411 | if (!this.session || this.saving) return; |
| 1412 | this.applyPreviewEdit({ silent: true }); |
| 1413 | const session = this.session; |
| 1414 | const path = session.path || session.document?.path || ""; |
| 1415 | if (!path) { |
| 1416 | this.error = "This document does not have a file path to rename."; |
| 1417 | return; |
| 1418 | } |
| 1419 | const name = basename(path || session.title || ""); |
| 1420 | const extension = extensionOf(name); |
| 1421 | await fileBrowserStore.openRenameModal( |
| 1422 | { |
| 1423 | name, |
| 1424 | path, |
| 1425 | is_dir: false, |
| 1426 | size: session.document?.size || 0, |
| 1427 | modified: session.document?.last_modified || "", |
| 1428 | type: "document", |
| 1429 | }, |
| 1430 | { |
| 1431 | currentPath: parentPath(path), |
| 1432 | validateName: (newName) => { |
| 1433 | if (!extension) return true; |
| 1434 | return extensionOf(newName) === extension || `Keep the .${extension} extension for this open document.`; |
| 1435 | }, |
| 1436 | performRename: async ({ path: renamedPath }) => { |
| 1437 | const payload = { |
| 1438 | file_id: session.file_id || "", |
| 1439 | path: renamedPath, |
| 1440 | }; |
| 1441 | if (this.isTextDocument(session)) { |
| 1442 | this.syncEditorText(); |
| 1443 | payload.text = this.session?.tab_id === session.tab_id ? this.editorText : session.text || ""; |
| 1444 | } |
| 1445 | return await callEditor("renamed", payload); |
| 1446 | }, |
| 1447 | onRenamed: async ({ path: renamedPath, response }) => { |
| 1448 | await this.handleActiveFileRenamed(session, renamedPath, response); |
| 1449 | }, |
| 1450 | }, |
| 1451 | ); |
| 1452 | }, |
| 1453 | |
| 1454 | async handleActiveFileRenamed(session, renamedPath, renameResponse = null) { |
| 1455 | const response = renameResponse || await callEditor("renamed", { |
| 1456 | file_id: session.file_id || "", |
| 1457 | path: renamedPath, |
| 1458 | }); |
| 1459 | if (response?.ok === false) throw new Error(response.error || "Rename failed."); |
| 1460 | |
| 1461 | const document = normalizeTextDocument(response.document || session.document || {}); |
| 1462 | const updated = { |
| 1463 | ...session, |
| 1464 | document, |
| 1465 | title: document.title || document.basename || basename(document.path), |
| 1466 | path: document.path || renamedPath, |
| 1467 | extension: document.extension || session.extension, |
| 1468 | file_id: document.file_id || session.file_id, |
| 1469 | version: document.version || response.version || session.version, |
| 1470 | text: this.session?.tab_id === session.tab_id ? this.editorText : session.text, |
| 1471 | dirty: false, |
| 1472 | }; |
| 1473 | this.replaceSession(session, updated); |
| 1474 | this.dirty = false; |
| 1475 | this.setMessage("Renamed"); |
| 1476 | await this.refresh(); |
| 1477 | }, |
| 1478 | |
| 1479 | replaceActiveSession(next) { |
| 1480 | if (!this.session) return; |
| 1481 | this.replaceSession(this.session, next); |
| 1482 | }, |
| 1483 | |
| 1484 | replaceSession(previous, next) { |
| 1485 | const wasActive = this.activeTabId === (previous?.tab_id || next.tab_id); |
| 1486 | if (wasActive) { |
| 1487 | this.session = next; |
| 1488 | this.updateSourceEditorMode(next); |
| 1489 | } |
| 1490 | const index = this.tabs.findIndex((tab) => tab.tab_id === (previous?.tab_id || next.tab_id)); |
| 1491 | if (index >= 0) this.tabs.splice(index, 1, next); |
| 1492 | }, |
| 1493 | |
| 1494 | setMessage(value) { |
| 1495 | this.message = value; |
| 1496 | if (this._saveMessageTimer) globalThis.clearTimeout(this._saveMessageTimer); |
| 1497 | this._saveMessageTimer = globalThis.setTimeout(() => { |
| 1498 | this.message = ""; |
| 1499 | this._saveMessageTimer = null; |
| 1500 | }, SAVE_MESSAGE_MS); |
| 1501 | }, |
| 1502 | |
| 1503 | resetHistory(text) { |
| 1504 | this._history = [String(text || "")]; |
| 1505 | this._historyIndex = 0; |
| 1506 | this._historyPushedAt = 0; |
| 1507 | }, |
| 1508 | |
| 1509 | pushHistory(text, coalesce = false) { |
| 1510 | const value = String(text || ""); |
| 1511 | if (this._history[this._historyIndex] === value) return; |
| 1512 | const now = Date.now(); |
| 1513 | if ( |
| 1514 | coalesce |
| 1515 | && this._historyPushedAt |
| 1516 | && now - this._historyPushedAt <= INPUT_PUSH_DELAY_MS |
| 1517 | && this._historyIndex === this._history.length - 1 |
| 1518 | && this._historyIndex > 0 |
| 1519 | ) { |
| 1520 | this._history[this._historyIndex] = value; |
| 1521 | this._historyPushedAt = now; |
| 1522 | return; |
| 1523 | } |
| 1524 | this._history = this._history.slice(0, this._historyIndex + 1); |
| 1525 | this._history.push(value); |
| 1526 | if (this._history.length > MAX_HISTORY) this._history.shift(); |
| 1527 | this._historyIndex = this._history.length - 1; |
| 1528 | this._historyPushedAt = coalesce ? now : 0; |
| 1529 | }, |
| 1530 | |
| 1531 | undo() { |
| 1532 | if (this._historyIndex <= 0) return; |
| 1533 | this._historyPushedAt = 0; |
| 1534 | this._historyIndex -= 1; |
| 1535 | this.applyEditorText(this._history[this._historyIndex], true); |
| 1536 | }, |
| 1537 | |
| 1538 | redo() { |
| 1539 | if (this._historyIndex >= this._history.length - 1) return; |
| 1540 | this._historyPushedAt = 0; |
| 1541 | this._historyIndex += 1; |
| 1542 | this.applyEditorText(this._history[this._historyIndex], true); |
| 1543 | }, |
| 1544 | |
| 1545 | canUndo() { |
| 1546 | return this._historyIndex > 0; |
| 1547 | }, |
| 1548 | |
| 1549 | canRedo() { |
| 1550 | return this._historyIndex < this._history.length - 1; |
| 1551 | }, |
| 1552 | |
| 1553 | applyEditorText(text, markDirty = false) { |
| 1554 | this.editorText = String(text || ""); |
| 1555 | this.setSourceEditorText(this.editorText); |
| 1556 | if (this.session) { |
| 1557 | this.session.text = this.editorText; |
| 1558 | this.session.dirty = markDirty || this.session.dirty; |
| 1559 | } |
| 1560 | if (markDirty) this.markDirty(); |
| 1561 | this.queueRender({ focus: true }); |
| 1562 | }, |
| 1563 | |
| 1564 | markDirty() { |
| 1565 | this.dirty = true; |
| 1566 | if (this.session) this.session.dirty = true; |
| 1567 | }, |
| 1568 | |
| 1569 | onSourceInput() { |
| 1570 | this.markDirty(); |
| 1571 | this.pushHistory(this.editorText, true); |
| 1572 | this.scheduleInputPush(); |
| 1573 | }, |
| 1574 | |
| 1575 | syncEditorText() { |
| 1576 | if (!this.session) return; |
| 1577 | if (this.previewEditing) return; |
| 1578 | if (this.sourceEditor && this.isSourceMode()) { |
| 1579 | this.editorText = this.sourceEditor.getValue(); |
| 1580 | } |
| 1581 | this.session.text = this.editorText; |
| 1582 | }, |
| 1583 | |
| 1584 | scheduleInputPush() { |
| 1585 | if (!this.session?.session_id || !this.isTextDocument()) return; |
| 1586 | if (this._inputTimer) globalThis.clearTimeout(this._inputTimer); |
| 1587 | this._inputTimer = globalThis.setTimeout(() => { |
| 1588 | this._inputTimer = null; |
| 1589 | this.flushInput(); |
| 1590 | }, INPUT_PUSH_DELAY_MS); |
| 1591 | }, |
| 1592 | |
| 1593 | flushInput() { |
| 1594 | if (!this.session?.session_id || !this.isTextDocument()) return; |
| 1595 | if (this.previewEditing) return; |
| 1596 | this.syncEditorText(); |
| 1597 | requestEditor("editor_input", { |
| 1598 | session_id: this.session.session_id, |
| 1599 | text: this.editorText, |
| 1600 | }, 3000).catch(() => {}); |
| 1601 | }, |
| 1602 | |
| 1603 | format(command) { |
| 1604 | if (!this.session || !this.isTextDocument()) return; |
| 1605 | if (this.sourceEditor && this.isSourceMode()) { |
| 1606 | const selected = this.sourceEditor.getSelectedText(); |
| 1607 | const replacement = this.formatReplacement(command, selected); |
| 1608 | if (replacement === selected) return; |
| 1609 | this.sourceEditor.session.replace(this.sourceEditor.getSelectionRange(), replacement); |
| 1610 | this.editorText = this.sourceEditor.getValue(); |
| 1611 | this.onSourceInput(); |
| 1612 | this.sourceEditor.focus(); |
| 1613 | return; |
| 1614 | } |
| 1615 | const textarea = this._root?.querySelector?.("[data-editor-source]"); |
| 1616 | if (!textarea) return; |
| 1617 | const start = textarea.selectionStart || 0; |
| 1618 | const end = textarea.selectionEnd || start; |
| 1619 | const selected = this.editorText.slice(start, end); |
| 1620 | const replacement = this.formatReplacement(command, selected); |
| 1621 | if (replacement === selected) return; |
| 1622 | this.editorText = `${this.editorText.slice(0, start)}${replacement}${this.editorText.slice(end)}`; |
| 1623 | this.onSourceInput(); |
| 1624 | globalThis.requestAnimationFrame?.(() => { |
| 1625 | textarea.focus(); |
| 1626 | textarea.selectionStart = start; |
| 1627 | textarea.selectionEnd = start + replacement.length; |
| 1628 | }); |
| 1629 | }, |
| 1630 | |
| 1631 | formatReplacement(command, selected = "") { |
| 1632 | if (command === "bold") return `**${selected || "text"}**`; |
| 1633 | if (command === "italic") return `*${selected || "text"}*`; |
| 1634 | if (command === "list") return (selected || "item").split("\n").map((line) => `- ${line.replace(/^[-*]\s+/, "")}`).join("\n"); |
| 1635 | if (command === "numbered") return (selected || "item").split("\n").map((line, index) => `${index + 1}. ${line.replace(/^\d+\.\s+/, "")}`).join("\n"); |
| 1636 | if (command === "table") return "| Column | Value |\n| --- | --- |\n| | |"; |
| 1637 | return selected; |
| 1638 | }, |
| 1639 | |
| 1640 | queueRender(options = {}) { |
| 1641 | if (options.focus) { |
| 1642 | this._pendingFocus = true; |
| 1643 | this._pendingFocusEnd = options.end !== false; |
| 1644 | this._focusAttempts = 0; |
| 1645 | } |
| 1646 | const render = () => { |
| 1647 | if (this._pendingFocus && this.focusEditor({ end: this._pendingFocusEnd })) { |
| 1648 | this._pendingFocus = false; |
| 1649 | this._focusAttempts = 0; |
| 1650 | } else if (this._pendingFocus && this._focusAttempts < 6) { |
| 1651 | this._focusAttempts += 1; |
| 1652 | globalThis.setTimeout(render, 45); |
| 1653 | } |
| 1654 | }; |
| 1655 | if (globalThis.requestAnimationFrame) { |
| 1656 | globalThis.requestAnimationFrame(render); |
| 1657 | } else { |
| 1658 | globalThis.setTimeout(render, 0); |
| 1659 | } |
| 1660 | }, |
| 1661 | |
| 1662 | focusEditor(options = {}) { |
| 1663 | if (!this.session || !this.isTextDocument()) return false; |
| 1664 | if (this.sourceEditor && this.isSourceMode()) { |
| 1665 | this.sourceEditor.focus(); |
| 1666 | if (options.end !== false) { |
| 1667 | const session = this.sourceEditor.session; |
| 1668 | const row = Math.max(0, session.getLength() - 1); |
| 1669 | const column = session.getLine(row).length; |
| 1670 | this.sourceEditor.moveCursorTo(row, column); |
| 1671 | } |
| 1672 | return true; |
| 1673 | } |
| 1674 | const source = this._root?.querySelector?.("[data-editor-source]"); |
| 1675 | if (!source) return false; |
| 1676 | source.focus?.({ preventScroll: true }); |
| 1677 | if (!editorContainsFocus(source)) return false; |
| 1678 | if (options.end !== false) placeCaretAtEnd(source); |
| 1679 | return true; |
| 1680 | }, |
| 1681 | |
| 1682 | isMarkdown(tab = this.session) { |
| 1683 | const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase(); |
| 1684 | return ext === "md"; |
| 1685 | }, |
| 1686 | |
| 1687 | isTextDocument(tab = this.session) { |
| 1688 | const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase(); |
| 1689 | return EDITOR_TEXT_EXTENSIONS.has(ext); |
| 1690 | }, |
| 1691 | |
| 1692 | hasActiveFile(tab = this.session) { |
| 1693 | return Boolean(tab && this.isTextDocument(tab)); |
| 1694 | }, |
| 1695 | |
| 1696 | visibleTabs() { |
| 1697 | return this.tabs.filter((tab) => this.hasActiveFile(tab)); |
| 1698 | }, |
| 1699 | |
| 1700 | defaultTitle(kind, fmt) { |
| 1701 | const date = new Date().toISOString().slice(0, 10); |
| 1702 | if (fmt === "md") return `Markdown ${date}`; |
| 1703 | if (fmt === "txt") return `Text ${date}`; |
| 1704 | return `Text ${date}`; |
| 1705 | }, |
| 1706 | |
| 1707 | tabTitle(tab = {}) { |
| 1708 | tab = tab || {}; |
| 1709 | return tab.title || tab.document?.basename || basename(tab.path); |
| 1710 | }, |
| 1711 | |
| 1712 | tabLabel(tab = {}) { |
| 1713 | tab = tab || {}; |
| 1714 | const title = this.tabTitle(tab); |
| 1715 | return tab.dirty ? `${title} unsaved` : title; |
| 1716 | }, |
| 1717 | |
| 1718 | tabIcon(tab = {}) { |
| 1719 | tab = tab || {}; |
| 1720 | const ext = String(tab.extension || tab.document?.extension || "").toLowerCase(); |
| 1721 | if (ext === "md") return "article"; |
| 1722 | if (ext === "txt") return "description"; |
| 1723 | return "draft"; |
| 1724 | }, |
| 1725 | |
| 1726 | async runNewMenuAction(action = "") { |
| 1727 | const normalized = String(action || "").trim().toLowerCase(); |
| 1728 | if (normalized === "open") return await this.openFileBrowser(); |
| 1729 | if (normalized === "markdown") return await this.create("document", "md"); |
| 1730 | if (normalized === "text") return await this.create("document", "txt"); |
| 1731 | return null; |
| 1732 | }, |
| 1733 | |
| 1734 | installHeaderNewMenu(header = null) { |
| 1735 | if (!header || header.querySelector(".editor-header-actions")) return () => {}; |
| 1736 | |
| 1737 | const root = document.createElement("div"); |
| 1738 | root.className = "editor-header-actions surface-modal-new-action"; |
| 1739 | root.innerHTML = ` |
| 1740 | <button type="button" class="editor-header-new-button surface-modal-new-button" aria-haspopup="menu" aria-expanded="false"> |
| 1741 | <x-icon aria-hidden="true" name="add"></x-icon> |
| 1742 | <span>New</span> |
| 1743 | <x-icon class="editor-new-chevron" aria-hidden="true" name="expand_more"></x-icon> |
| 1744 | </button> |
| 1745 | <div class="editor-new-menu" role="menu" hidden> |
| 1746 | <button type="button" class="editor-new-menu-item" role="menuitem" data-editor-new-action="open"> |
| 1747 | <x-icon aria-hidden="true" name="folder_open"></x-icon> |
| 1748 | <span>Open</span> |
| 1749 | </button> |
| 1750 | <button type="button" class="editor-new-menu-item" role="menuitem" data-editor-new-action="markdown"> |
| 1751 | <x-icon aria-hidden="true" name="article"></x-icon> |
| 1752 | <span>Markdown</span> |
| 1753 | </button> |
| 1754 | <button type="button" class="editor-new-menu-item" role="menuitem" data-editor-new-action="text"> |
| 1755 | <x-icon aria-hidden="true" name="description"></x-icon> |
| 1756 | <span>Text</span> |
| 1757 | </button> |
| 1758 | </div> |
| 1759 | `; |
| 1760 | |
| 1761 | const button = root.querySelector(".editor-header-new-button"); |
| 1762 | const menu = root.querySelector(".editor-new-menu"); |
| 1763 | const setOpen = (open) => { |
| 1764 | root.classList.toggle("is-open", open); |
| 1765 | button?.setAttribute("aria-expanded", open.toString()); |
| 1766 | if (menu) menu.hidden = !open; |
| 1767 | }; |
| 1768 | const onButtonClick = (event) => { |
| 1769 | event.preventDefault(); |
| 1770 | event.stopPropagation(); |
| 1771 | setOpen(!root.classList.contains("is-open")); |
| 1772 | }; |
| 1773 | const onMarkdownClick = (event) => { |
| 1774 | if (!root.contains(event.target)) setOpen(false); |
| 1775 | }; |
| 1776 | const onMarkdownKeydown = (event) => { |
| 1777 | if (event.key === "Escape") setOpen(false); |
| 1778 | }; |
| 1779 | |
| 1780 | button?.addEventListener("click", onButtonClick); |
| 1781 | for (const item of root.querySelectorAll("[data-editor-new-action]")) { |
| 1782 | item.addEventListener("click", async (event) => { |
| 1783 | event.preventDefault(); |
| 1784 | event.stopPropagation(); |
| 1785 | const action = event.currentTarget?.dataset?.editorNewAction || ""; |
| 1786 | setOpen(false); |
| 1787 | await this.runNewMenuAction(action); |
| 1788 | }); |
| 1789 | } |
| 1790 | document.addEventListener("click", onMarkdownClick); |
| 1791 | document.addEventListener("keydown", onMarkdownKeydown); |
| 1792 | |
| 1793 | placeSurfaceModalHeaderAction(header, root, "new"); |
| 1794 | |
| 1795 | setOpen(false); |
| 1796 | return () => { |
| 1797 | button?.removeEventListener("click", onButtonClick); |
| 1798 | document.removeEventListener("click", onMarkdownClick); |
| 1799 | document.removeEventListener("keydown", onMarkdownKeydown); |
| 1800 | root.remove(); |
| 1801 | }; |
| 1802 | }, |
| 1803 | |
| 1804 | setupMarkdownModal(element = null) { |
| 1805 | const root = element || document.querySelector(".editor-panel"); |
| 1806 | const inner = root?.closest?.(".modal-inner"); |
| 1807 | const header = inner?.querySelector?.(".modal-header"); |
| 1808 | if (!inner || !header || inner.dataset.editorModalReady === "1") return; |
| 1809 | inner.dataset.editorModalReady = "1"; |
| 1810 | inner.classList.add("editor-modal"); |
| 1811 | const floatingCleanup = setupFloatingSurfaceModalChrome({ |
| 1812 | root, |
| 1813 | modalClass: "editor-modal", |
| 1814 | focusButtonClass: "editor-modal-focus-button", |
| 1815 | minWidth: 640, |
| 1816 | minHeight: 460, |
| 1817 | onBoundsChange: () => this.refreshSourceEditorLayout(), |
| 1818 | onFocusChange: () => this.refreshSourceEditorLayout(), |
| 1819 | }); |
| 1820 | const menuCleanup = this.installHeaderNewMenu(header); |
| 1821 | this._headerCleanup = () => { |
| 1822 | menuCleanup?.(); |
| 1823 | floatingCleanup?.(); |
| 1824 | delete inner.dataset.editorModalReady; |
| 1825 | inner.classList.remove("editor-modal", "is-focus-mode"); |
| 1826 | }; |
| 1827 | }, |
| 1828 | |
| 1829 | async handleEditorUrlIntent(intent = {}) { |
| 1830 | const editor = editorIntent(intent?.url || ""); |
| 1831 | if (!editor) return false; |
| 1832 | await openLatestSurface("editor", { |
| 1833 | path: editor.path, |
| 1834 | refresh: true, |
| 1835 | source: intent?.source || "desktop-open", |
| 1836 | }); |
| 1837 | return true; |
| 1838 | }, |
| 1839 | }; |
| 1840 | |
| 1841 | export const store = createStore("editor", model); |
| 1842 | |
| 1843 | registerUrlHandler((intent) => model.handleEditorUrlIntent(intent)); |