Polish native Markdown editor experience

Expand the dedicated Editor surface with safe rendered preview mode, ACE-backed source editing, browser-style tabs, toolbar/file actions, preview search, and richer Markdown rendering for code blocks, task lists, images, tables, math, local links, and footnotes. Keep open Markdown files synchronized with the active context and saved tool edits, including live refresh for document_artifact and text_editor results without routing Markdown through Desktop/Office. Add inline preview-page editing, clickable preview task-list checkboxes, source editor rehydration after preview-mode refreshes, and regression coverage for the new editor wiring and sync behavior.

Alessandro committed May 15, 2026 at 04:47 UTC 89901b64f090a980deac7847d4eb1195cdbbffe0
10 files changed +1715 -140
plugins/_editor/api/editor_session.py
+6 -1
@@ -97,7 +97,12 @@ class EditorSession(ApiHandler):
97 origin=self._origin(request),
98 )
99 try:
100 - editor = markdown_sessions.get_manager().open(doc, sid="", context_id=context_id)
100 + editor = markdown_sessions.get_manager().open(
101 + doc,
102 + sid="",
103 + context_id=context_id,
104 + refresh=input.get("refresh") is True,
105 + )
106 except ValueError as exc:
107 document_store.close_session(session_id=store_session["session_id"])
108 return {"ok": False, "error": str(exc)}
plugins/_editor/api/ws_editor.py
+6 -1
@@ -60,4 +60,9 @@ class WsEditor(WsHandler):
60 content=str(data.get("content") or ""),
61 context_id=context_id,
62 )
63 - return markdown_sessions.get_manager().open(doc, sid=sid, context_id=context_id)
63 + return markdown_sessions.get_manager().open(
64 + doc,
65 + sid=sid,
66 + context_id=context_id,
67 + refresh=data.get("refresh") is True,
68 + )
plugins/_editor/helpers/markdown_sessions.py
+11 -1
@@ -33,19 +33,29 @@ class MarkdownSessionManager:
33 self._sessions: dict[str, MarkdownSession] = {}
34 self._active_by_context: dict[str, str] = {}
35
36 - def open(self, doc: dict[str, Any], sid: str = "", context_id: str = "") -> dict[str, Any]:
36 + def open(self, doc: dict[str, Any], sid: str = "", context_id: str = "", refresh: bool = False) -> dict[str, Any]:
37 ext = str(doc["extension"]).lower()
38 if ext != "md":
39 raise ValueError(f"Editor is only available for Markdown. Open .{ext} files in the Desktop.")
40
41 normalized_context = str(context_id or "")
42 + if refresh:
43 + try:
44 + doc = document_store.register_document(doc["path"], context_id=normalized_context)
45 + except Exception:
46 + pass
47 +
48 for session in self._sessions.values():
49 if session.file_id != doc["file_id"] or session.context_id != normalized_context:
50 continue
51 if sid:
52 session.sid = sid
53 + if refresh and not session.dirty:
54 + session.text = document_store.read_text_for_editor(doc)
55 + session.dirty = False
56 session.path = doc["path"]
57 session.title = doc["basename"]
58 + session.updated_at = time.time()
59 self.activate(session.session_id)
60 return self._payload(session, doc)
61
plugins/_editor/webui/editor-panel.html
+536 -113
@@ -8,7 +8,7 @@
8 <div x-data>
9 <template x-if="$store.editor">
10 <div class="editor-panel" x-create="$store.editor.onMount($el, xAttrs($el) || {})" x-destroy="$store.editor.cleanup()">
11 - <div class="editor-shell">
11 + <div class="editor-shell" @keydown="$store.editor.handleEditorKeydown($event)">
12 <div class="editor-tabs" x-show="$store.editor.visibleTabs().length > 0" style="display: none;" role="tablist" aria-label="Open Markdown files">
13 <template x-for="tab in $store.editor.visibleTabs()" :key="tab.tab_id">
14 <div
@@ -31,12 +31,23 @@
31 class="editor-tab-close"
32 title="Close file"
33 aria-label="Close file"
34 + @pointerdown.stop
35 @click.stop="$store.editor.closeTab(tab.tab_id)"
36 >
37 <span class="material-symbols-outlined" aria-hidden="true">close</span>
38 </button>
39 </div>
40 </template>
41 + <button
42 + type="button"
43 + class="editor-new-tab"
44 + title="New Markdown"
45 + aria-label="New Markdown"
46 + :disabled="$store.editor.loading || $store.editor.saving"
47 + @click="$store.editor.runNewMenuAction('markdown')"
48 + >
49 + <span class="material-symbols-outlined" aria-hidden="true">add</span>
50 + </button>
51 </div>
52
53 <div class="editor-close-confirm" x-show="$store.editor.hasPendingClose()" style="display: none;" role="status">
@@ -66,58 +77,9 @@
77 </div>
78 </div>
79
69 - <div class="editor-document-header" x-show="$store.editor.hasActiveFile()" style="display: none;">
70 - <div class="editor-document-title" :title="$store.editor.tabLabel($store.editor.session)">
71 - <span class="material-symbols-outlined editor-document-icon" aria-hidden="true" x-text="$store.editor.tabIcon($store.editor.session)"></span>
72 - <span class="editor-document-name" x-text="$store.editor.tabTitle($store.editor.session)"></span>
73 - <span class="editor-document-dirty" x-show="$store.editor.dirty" aria-hidden="true">*</span>
74 - </div>
75 -
76 - <button
77 - type="button"
78 - class="editor-icon-button editor-document-save-button"
79 - title="Save"
80 - aria-label="Save"
81 - :class="{ 'is-primary': $store.editor.dirty }"
82 - :disabled="$store.editor.saving"
83 - @click="$store.editor.save()"
84 - >
85 - <span class="material-symbols-outlined" :class="{ spinning: $store.editor.saving }" x-text="$store.editor.saving ? 'progress_activity' : 'save'"></span>
86 - </button>
87 -
88 - <div class="editor-file-actions" x-data="{ open: false }" @click.outside="open = false" @keydown.escape.window="open = false">
89 - <button
90 - type="button"
91 - class="editor-icon-button editor-file-menu-button"
92 - title="File actions"
93 - aria-label="File actions"
94 - aria-haspopup="menu"
95 - :aria-expanded="open.toString()"
96 - :disabled="$store.editor.saving"
97 - @click.stop="open = !open"
98 - >
99 - <span class="material-symbols-outlined">more_vert</span>
100 - </button>
101 - <div class="editor-new-menu editor-file-menu" role="menu" x-show="open" @click.stop>
102 - <button type="button" class="editor-new-menu-item" role="menuitem" :disabled="$store.editor.saving" @click="open = false; $store.editor.renameActiveFile()">
103 - <span class="material-symbols-outlined" aria-hidden="true">edit</span>
104 - <span>Rename</span>
105 - </button>
106 - <button type="button" class="editor-new-menu-item" role="menuitem" :disabled="$store.editor.loading" @click="open = false; $store.editor.closeActiveFile()">
107 - <span class="material-symbols-outlined" aria-hidden="true">close</span>
108 - <span>Close File</span>
109 - </button>
110 - <button type="button" class="editor-new-menu-item" role="menuitem" :disabled="$store.editor.loading || $store.editor.visibleTabs().length === 0" @click="open = false; $store.editor.closeAllFiles()">
111 - <span class="material-symbols-outlined" aria-hidden="true">close</span>
112 - <span>Close All</span>
113 - </button>
114 - </div>
115 - </div>
116 - </div>
117 -
118 - <div class="editor-toolbar" x-show="$store.editor.session && $store.editor.isMarkdown()" style="display: none;">
80 + <div class="editor-toolbar" x-show="$store.editor.session" style="display: none;">
81 <div class="editor-toolbar-row">
120 - <div class="editor-tool-group editor-source-tools">
82 + <div class="editor-tool-group editor-source-tools" x-show="$store.editor.isMarkdown() && $store.editor.isSourceMode()" style="display: none;">
83 <button type="button" class="editor-icon-button" title="Undo" aria-label="Undo" :disabled="!$store.editor.canUndo()" @click="$store.editor.undo()">
84 <span class="material-symbols-outlined">undo</span>
85 </button>
@@ -140,28 +102,145 @@
102 <span class="material-symbols-outlined">table</span>
103 </button>
104 </div>
105 + <div class="editor-tool-group editor-preview-tools" x-show="$store.editor.isPreviewMode()" style="display: none;">
106 + <button type="button" class="editor-icon-button" title="Previous page" aria-label="Previous page" :disabled="$store.editor.previewEditing || $store.editor.activePageIndex <= 0" @click="$store.editor.previousPage()">
107 + <span class="material-symbols-outlined">chevron_left</span>
108 + </button>
109 + <span class="editor-page-count" x-text="$store.editor.pagePositionLabel()"></span>
110 + <button type="button" class="editor-icon-button" title="Next page" aria-label="Next page" :disabled="$store.editor.previewEditing || $store.editor.activePageIndex >= $store.editor.pages().length - 1" @click="$store.editor.nextPage()">
111 + <span class="material-symbols-outlined">chevron_right</span>
112 + </button>
113 + <button type="button" class="editor-icon-button" title="Edit page" aria-label="Edit page" x-show="!$store.editor.previewEditing" @click="$store.editor.startPreviewEdit()">
114 + <span class="material-symbols-outlined">edit_note</span>
115 + </button>
116 + <button type="button" class="editor-icon-button is-primary" title="Apply page edit" aria-label="Apply page edit" x-show="$store.editor.previewEditing" @click="$store.editor.applyPreviewEdit()">
117 + <span class="material-symbols-outlined">check</span>
118 + </button>
119 + <button type="button" class="editor-icon-button" title="Cancel page edit" aria-label="Cancel page edit" x-show="$store.editor.previewEditing" @click="$store.editor.cancelPreviewEdit()">
120 + <span class="material-symbols-outlined">close</span>
121 + </button>
122 + <button type="button" class="editor-icon-button" title="Search" aria-label="Search" :disabled="$store.editor.previewEditing" @click="$store.editor.openSearch()">
123 + <span class="material-symbols-outlined">search</span>
124 + </button>
125 + </div>
126 <span class="editor-toolbar-spacer"></span>
127 +
128 + <button
129 + type="button"
130 + class="editor-icon-button editor-mode-toggle"
131 + :title="$store.editor.viewModeTitle()"
132 + :aria-label="$store.editor.viewModeTitle()"
133 + @click="$store.editor.toggleViewMode()"
134 + >
135 + <span class="material-symbols-outlined" aria-hidden="true" x-text="$store.editor.viewModeIcon()"></span>
136 + </button>
137 +
138 + <div class="editor-file-actions" x-data="{ open: false }" @click.outside="open = false" @keydown.escape.window="open = false">
139 + <button
140 + type="button"
141 + class="editor-icon-button editor-file-menu-button"
142 + title="File actions"
143 + aria-label="File actions"
144 + aria-haspopup="menu"
145 + :aria-expanded="open.toString()"
146 + :disabled="$store.editor.saving"
147 + @click.stop="open = !open"
148 + >
149 + <span class="material-symbols-outlined">more_vert</span>
150 + </button>
151 + <div class="editor-new-menu editor-file-menu" role="menu" x-show="open" @click.stop>
152 + <button type="button" class="editor-new-menu-item" :class="{ 'is-emphasized': $store.editor.dirty }" role="menuitem" :disabled="$store.editor.saving" @click="open = false; $store.editor.save()">
153 + <span class="material-symbols-outlined" :class="{ spinning: $store.editor.saving }" x-text="$store.editor.saving ? 'progress_activity' : 'save'"></span>
154 + <span>Save</span>
155 + </button>
156 + <button type="button" class="editor-new-menu-item" role="menuitem" :disabled="$store.editor.saving" @click="open = false; $store.editor.renameActiveFile()">
157 + <span class="material-symbols-outlined" aria-hidden="true">edit</span>
158 + <span>Rename</span>
159 + </button>
160 + <button type="button" class="editor-new-menu-item" role="menuitem" :disabled="$store.editor.loading" @click="open = false; $store.editor.closeActiveFile()">
161 + <span class="material-symbols-outlined" aria-hidden="true">close</span>
162 + <span>Close File</span>
163 + </button>
164 + <button type="button" class="editor-new-menu-item" role="menuitem" :disabled="$store.editor.loading || $store.editor.visibleTabs().length === 0" @click="open = false; $store.editor.closeAllFiles()">
165 + <span class="material-symbols-outlined" aria-hidden="true">close</span>
166 + <span>Close All</span>
167 + </button>
168 + </div>
169 + </div>
170 </div>
171 </div>
172
173 + <div class="editor-search-bar" x-show="$store.editor.searchOpen" style="display: none;">
174 + <span class="material-symbols-outlined" aria-hidden="true">search</span>
175 + <input
176 + type="search"
177 + data-editor-search
178 + aria-label="Search preview"
179 + x-model="$store.editor.searchQuery"
180 + @input="$store.editor.runSearch()"
181 + @keydown.enter.prevent="$event.shiftKey ? $store.editor.previousSearchMatch() : $store.editor.nextSearchMatch()"
182 + @keydown.escape.prevent="$store.editor.closeSearch()"
183 + />
184 + <span class="editor-search-count" x-text="$store.editor.searchCountLabel()"></span>
185 + <button type="button" class="editor-icon-button" title="Previous match" aria-label="Previous match" :disabled="$store.editor.searchMatches.length === 0" @click="$store.editor.previousSearchMatch()">
186 + <span class="material-symbols-outlined">keyboard_arrow_up</span>
187 + </button>
188 + <button type="button" class="editor-icon-button" title="Next match" aria-label="Next match" :disabled="$store.editor.searchMatches.length === 0" @click="$store.editor.nextSearchMatch()">
189 + <span class="material-symbols-outlined">keyboard_arrow_down</span>
190 + </button>
191 + <button type="button" class="editor-icon-button" title="Close search" aria-label="Close search" @click="$store.editor.closeSearch()">
192 + <span class="material-symbols-outlined">close</span>
193 + </button>
194 + </div>
195 +
196 <div class="editor-state-line" x-show="$store.editor.message || $store.editor.error || $store.editor.loading" style="display: none;">
197 <span class="material-symbols-outlined" :class="{ spinning: $store.editor.loading }" x-text="$store.editor.loading ? 'progress_activity' : ($store.editor.error ? 'error' : 'check_circle')"></span>
198 <span x-text="$store.editor.error || $store.editor.message || 'Working'"></span>
199 </div>
200
201 <div class="editor-body">
153 - <div class="editor-wrap" x-show="$store.editor.session" style="display: none;">
202 + <div class="editor-wrap" x-show="$store.editor.session && $store.editor.isSourceMode()" style="display: none;">
203 <div class="editor-scroll" @click.self="$store.editor.focusEditor()">
204 + <div class="editor-ace" data-editor-ace x-show="!$store.editor.aceUnavailable"></div>
205 <textarea
206 class="editor-source-editor"
207 data-editor-source
208 aria-label="Markdown source"
209 + x-show="$store.editor.aceUnavailable"
210 x-model="$store.editor.editorText"
211 @input="$store.editor.onSourceInput()"
212 @blur="$store.editor.flushInput()"
213 spellcheck="true"
214 + style="display: none;"
215 + ></textarea>
216 + </div>
217 + </div>
218 +
219 + <div class="editor-preview-shell" x-show="$store.editor.session && $store.editor.isPreviewMode()" style="display: none;">
220 + <div class="editor-preview-title">
221 + <h1 x-text="$store.editor.pageTitle()"></h1>
222 + </div>
223 + <div class="editor-preview-edit-shell" x-show="$store.editor.previewEditing" style="display: none;">
224 + <textarea
225 + class="editor-preview-page-editor"
226 + data-editor-preview-source
227 + aria-label="Markdown page source"
228 + x-model="$store.editor.previewEditText"
229 + @input="$store.editor.onPreviewEditInput()"
230 + @keydown.meta.enter.prevent="$store.editor.applyPreviewEdit()"
231 + @keydown.ctrl.enter.prevent="$store.editor.applyPreviewEdit()"
232 + @keydown.escape.prevent="$store.editor.cancelPreviewEdit()"
233 + spellcheck="true"
234 ></textarea>
235 </div>
236 + <div
237 + class="editor-preview-content msg-content"
238 + data-editor-preview
239 + x-show="!$store.editor.previewEditing"
240 + x-html="$store.editor.previewHtml()"
241 + x-effect="$store.editor.activePageIndex; $store.editor.editorText; $store.editor.searchQuery; $store.editor.searchIndex; $store.editor.schedulePreviewEnhance()"
242 + @click="$store.editor.handlePreviewClick($event)"
243 + ></div>
244 </div>
245
246 <div class="editor-empty" x-show="!$store.editor.session && !$store.editor.loading" style="display: none;">
@@ -185,6 +264,12 @@
264 <style>
265 .editor-panel,
266 .editor-shell {
267 + --editor-chrome-surface: color-mix(in srgb, var(--color-background) 92%, #000 8%);
268 + --editor-chrome-border: color-mix(in srgb, var(--color-border) 58%, transparent);
269 + --editor-tab-hover-border: color-mix(in srgb, var(--color-border) 78%, transparent);
270 + --editor-tab-height: 36px;
271 + --editor-tab-close-size: 32px;
272 + --editor-control-radius: 0.55rem;
273 display: flex;
274 flex: 1 1 auto;
275 flex-direction: column;
@@ -242,25 +327,71 @@
327
328 .editor-tabs {
329 display: flex;
245 - gap: 6px;
246 - min-height: 42px;
247 - padding: 7px 10px;
330 + align-items: end;
331 + gap: 4px;
332 + min-height: 44px;
333 + padding: 7px 10px 0;
334 overflow-x: auto;
335 overflow-y: hidden;
250 - border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 22%);
251 - background: color-mix(in srgb, var(--color-panel), var(--color-background) 28%);
336 + border-bottom: 1px solid var(--editor-chrome-border);
337 + background: var(--editor-chrome-surface);
338 + scrollbar-width: thin;
339 + }
340 +
341 + .editor-tabs::-webkit-scrollbar {
342 + height: 4px;
343 + }
344 +
345 + .editor-tabs::-webkit-scrollbar-track {
346 + background: transparent;
347 + }
348 +
349 + .editor-tabs::-webkit-scrollbar-thumb {
350 + background: color-mix(in srgb, var(--color-border) 78%, transparent);
351 + border-radius: 999px;
352 }
353
354 .editor-tab-shell {
355 + flex: 0 1 210px;
356 + position: relative;
357 display: grid;
256 - grid-template-columns: minmax(0, 1fr) 28px;
358 + grid-template-columns: minmax(0, 1fr) var(--editor-tab-close-size);
359 align-items: center;
258 - min-width: 150px;
259 - max-width: 240px;
360 + gap: 3px;
361 + min-width: 128px;
362 + max-width: 250px;
363 + height: var(--editor-tab-height);
364 + padding: 0 7px 0 10px;
365 + border: 1px solid transparent;
366 + border-radius: var(--editor-control-radius) var(--editor-control-radius) 0 0;
367 + background: transparent;
368 + opacity: 0.72;
369 + transition: border-color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
370 + color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
371 + opacity 0.18s cubic-bezier(0.4, 0, 0.2, 1);
372 + }
373 +
374 + .editor-new-tab {
375 + display: inline-flex;
376 + align-items: center;
377 + justify-content: center;
378 + flex: 0 0 34px;
379 + width: 34px;
380 + min-width: 34px;
381 + height: 34px;
382 + min-height: 34px;
383 + padding: 0;
384 + border: 1px solid transparent;
385 + border-radius: var(--editor-control-radius);
386 + background: transparent;
387 + color: color-mix(in srgb, var(--color-text) 62%, var(--color-primary) 38%);
388 + cursor: pointer;
389 + transition: background-color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
390 + border-color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
391 + color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
392 + opacity 0.18s cubic-bezier(0.4, 0, 0.2, 1);
393 }
394
262 - .editor-tab,
263 - .editor-tab-close,
395 .editor-icon-button {
396 border: 1px solid color-mix(in srgb, var(--color-border), transparent 12%);
397 border-radius: 8px;
@@ -273,36 +404,64 @@
404 .editor-tab {
405 display: flex;
406 align-items: center;
276 - gap: 6px;
407 + gap: 8px;
408 min-width: 0;
278 - height: 28px;
279 - padding: 0 8px;
280 - border-top-right-radius: 0;
281 - border-bottom-right-radius: 0;
409 + width: 100%;
410 + height: 100%;
411 + padding: 0;
412 + border: 0;
413 + border-radius: 0;
414 + background: transparent;
415 + color: inherit;
416 + cursor: pointer;
417 + font: inherit;
418 text-align: left;
419 }
420
421 .editor-tab-close {
286 - display: grid;
287 - place-items: center;
288 - height: 28px;
422 + display: inline-flex;
423 + align-items: center;
424 + justify-content: center;
425 + width: var(--editor-tab-close-size);
426 + min-width: var(--editor-tab-close-size);
427 + height: var(--editor-tab-close-size);
428 + min-height: var(--editor-tab-close-size);
429 padding: 0;
290 - border-left: 0;
291 - border-top-left-radius: 0;
292 - border-bottom-left-radius: 0;
430 + border: 0;
431 + border-radius: 6px;
432 + background: transparent;
433 + color: color-mix(in srgb, var(--color-text) 52%, var(--color-primary) 48%);
434 + cursor: pointer;
435 + opacity: 0.72;
436 + transition: background-color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
437 + color 0.18s cubic-bezier(0.4, 0, 0.2, 1),
438 + opacity 0.18s cubic-bezier(0.4, 0, 0.2, 1);
439 + }
440 +
441 + .editor-tab-shell:hover,
442 + .editor-tab-shell:focus-within {
443 + border-color: var(--editor-tab-hover-border);
444 + opacity: 0.94;
445 + }
446 +
447 + .editor-tab-shell.is-active {
448 + z-index: 2;
449 + margin-bottom: -1px;
450 + border-color: var(--editor-chrome-border);
451 + background: transparent;
452 + opacity: 1;
453 + box-shadow: none;
454 }
455
295 - .editor-tab-shell.is-active .editor-tab,
296 - .editor-tab-shell.is-active .editor-tab-close,
456 .editor-icon-button.is-primary {
457 border-color: color-mix(in srgb, #2c7be5, var(--color-border) 36%);
458 background: color-mix(in srgb, #2c7be5, var(--color-panel) 88%);
459 }
460
302 - .editor-tab-shell.is-pending-close .editor-tab,
303 - .editor-tab-shell.is-pending-close .editor-tab-close {
461 + .editor-tab-shell.is-pending-close {
462 border-color: color-mix(in srgb, #d98b2b, var(--color-border) 30%);
463 background: color-mix(in srgb, #d98b2b, var(--color-panel) 88%);
464 + opacity: 1;
465 }
466
467 .editor-tab-shell.is-dirty .editor-tab-title::after {
@@ -310,8 +469,7 @@
469 color: #2ca58d;
470 }
471
313 - .editor-tab-title,
314 - .editor-document-name {
472 + .editor-tab-title {
473 min-width: 0;
474 overflow: hidden;
475 text-overflow: ellipsis;
@@ -319,15 +477,15 @@
477 }
478
479 .editor-tab-title {
322 - font-size: 12px;
323 - font-weight: 700;
480 + font-size: 0.88rem;
481 + font-weight: 600;
482 letter-spacing: 0;
483 }
484
327 - .editor-document-header,
485 .editor-toolbar,
486 .editor-state-line,
330 - .editor-close-confirm {
487 + .editor-close-confirm,
488 + .editor-search-bar {
489 display: flex;
490 align-items: center;
491 gap: 8px;
@@ -336,11 +494,6 @@
494 min-width: 0;
495 }
496
339 - .editor-document-header {
340 - min-height: 38px;
341 - background: color-mix(in srgb, var(--color-panel), var(--color-background) 30%);
342 - }
343 -
497 .editor-close-confirm {
498 min-height: 44px;
499 background: color-mix(in srgb, #d98b2b 9%, var(--color-background));
@@ -385,25 +538,12 @@
538 gap: 6px;
539 }
540
388 - .editor-document-title {
389 - display: flex;
390 - align-items: center;
391 - gap: 7px;
392 - min-width: 0;
393 - flex: 1 1 auto;
394 - font-size: 13px;
395 - font-weight: 750;
396 - letter-spacing: 0;
397 - }
398 -
399 - .editor-document-dirty {
400 - color: #2ca58d;
401 - font-weight: 800;
402 - }
403 -
541 .editor-toolbar {
542 + min-height: 46px;
543 padding: 6px 10px;
406 - overflow: hidden;
544 + position: relative;
545 + z-index: 20;
546 + overflow: visible;
547 background: color-mix(in srgb, var(--color-background), var(--color-panel) 48%);
548 }
549
@@ -417,8 +557,7 @@
557
558 .editor-toolbar-row {
559 width: 100%;
420 - overflow-x: auto;
421 - overflow-y: hidden;
560 + overflow: visible;
561 }
562
563 .editor-toolbar-spacer {
@@ -426,6 +565,35 @@
565 min-width: 8px;
566 }
567
568 + .editor-page-count,
569 + .editor-search-count {
570 + min-width: 54px;
571 + color: var(--color-text-secondary);
572 + font-size: 12px;
573 + font-weight: 700;
574 + letter-spacing: 0;
575 + text-align: center;
576 + white-space: nowrap;
577 + }
578 +
579 + .editor-search-bar {
580 + min-height: 40px;
581 + background: color-mix(in srgb, var(--color-background), var(--color-panel) 30%);
582 + }
583 +
584 + .editor-search-bar input {
585 + flex: 1 1 auto;
586 + min-width: 90px;
587 + height: 30px;
588 + border: 1px solid color-mix(in srgb, var(--color-border), transparent 12%);
589 + border-radius: 7px;
590 + background: var(--color-background);
591 + color: var(--color-text);
592 + padding: 0 9px;
593 + font: inherit;
594 + font-size: 13px;
595 + }
596 +
597 .editor-icon-button {
598 display: inline-grid;
599 place-items: center;
@@ -435,13 +603,55 @@
603 padding: 0;
604 }
605
438 - .editor-icon-button:hover:not(:disabled),
439 - .editor-tab:hover,
440 - .editor-tab-close:hover {
606 + .editor-icon-button:hover:not(:disabled) {
607 border-color: color-mix(in srgb, #2c7be5, var(--color-border) 45%);
608 background: color-mix(in srgb, var(--color-panel), #2c7be5 8%);
609 }
610
611 + .editor-tab:hover {
612 + background: transparent;
613 + }
614 +
615 + .editor-tab:focus-visible,
616 + .editor-tab-close:focus-visible {
617 + outline: 1px solid color-mix(in srgb, var(--color-primary) 70%, transparent);
618 + outline-offset: 1px;
619 + }
620 +
621 + .editor-tab-icon {
622 + flex: 0 0 auto;
623 + color: color-mix(in srgb, var(--color-text) 72%, var(--color-primary) 28%);
624 + font-size: 1.04rem;
625 + line-height: 1;
626 + }
627 +
628 + .editor-tab-close:hover {
629 + background: color-mix(in srgb, var(--color-background-hover) 70%, transparent);
630 + color: var(--color-text);
631 + opacity: 1;
632 + }
633 +
634 + .editor-new-tab:hover:not(:disabled) {
635 + background: color-mix(in srgb, var(--color-background-hover) 58%, transparent);
636 + border-color: color-mix(in srgb, var(--color-primary) 24%, transparent);
637 + color: var(--color-text);
638 + }
639 +
640 + .editor-new-tab:disabled {
641 + cursor: default;
642 + opacity: 0.42;
643 + }
644 +
645 + .editor-tab-close .material-symbols-outlined {
646 + font-size: 1rem;
647 + line-height: 1;
648 + }
649 +
650 + .editor-new-tab .material-symbols-outlined {
651 + font-size: 1.12rem;
652 + line-height: 1;
653 + }
654 +
655 .editor-icon-button:disabled {
656 cursor: default;
657 opacity: 0.42;
@@ -453,13 +663,14 @@
663 display: inline-flex;
664 align-items: center;
665 flex: 0 0 auto;
666 + z-index: 30;
667 }
668
669 .editor-new-menu {
670 position: absolute;
671 top: calc(100% + 6px);
672 right: 0;
462 - z-index: 4000;
673 + z-index: 10000;
674 min-width: 184px;
675 padding: 5px;
676 border: 1px solid color-mix(in srgb, var(--color-border), transparent 10%);
@@ -506,6 +717,11 @@
717 text-align: left;
718 }
719
720 + .editor-new-menu-item.is-emphasized {
721 + color: var(--color-text);
722 + background: color-mix(in srgb, #2c7be5 10%, transparent);
723 + }
724 +
725 .editor-text-button {
726 appearance: none;
727 height: 28px;
@@ -554,7 +770,8 @@
770
771 .editor-body,
772 .editor-wrap,
557 - .editor-scroll {
773 + .editor-scroll,
774 + .editor-preview-shell {
775 display: flex;
776 flex: 1 1 auto;
777 min-width: 0;
@@ -573,6 +790,27 @@
790 height: 100%;
791 }
792
793 + .editor-scroll {
794 + position: relative;
795 + }
796 +
797 + .editor-ace {
798 + flex: 1 1 auto;
799 + width: 100%;
800 + height: 100%;
801 + min-width: 0;
802 + min-height: 0;
803 + font: 13px/1.55 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
804 + }
805 +
806 + .editor-ace .ace_gutter {
807 + display: none !important;
808 + }
809 +
810 + .editor-ace .ace_scroller {
811 + left: 0 !important;
812 + }
813 +
814 .editor-source-editor {
815 box-sizing: border-box;
816 width: 100%;
@@ -589,6 +827,191 @@
827 letter-spacing: 0;
828 }
829
830 + .editor-preview-shell {
831 + flex-direction: column;
832 + overflow: hidden;
833 + background: var(--color-background);
834 + }
835 +
836 + .editor-preview-title {
837 + flex: 0 0 auto;
838 + padding: 20px 24px 10px;
839 + border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 30%);
840 + }
841 +
842 + .editor-preview-title h1 {
843 + margin: 0;
844 + color: var(--color-text);
845 + font-size: 24px;
846 + line-height: 1.2;
847 + letter-spacing: 0;
848 + }
849 +
850 + .editor-preview-content {
851 + flex: 1 1 auto;
852 + min-width: 0;
853 + min-height: 0;
854 + overflow: auto;
855 + padding: 20px 24px 36px;
856 + color: var(--color-text);
857 + line-height: 1.58;
858 + }
859 +
860 + .editor-preview-edit-shell {
861 + display: flex;
862 + flex: 1 1 auto;
863 + min-width: 0;
864 + min-height: 0;
865 + padding: 16px 24px 36px;
866 + background: var(--color-background);
867 + }
868 +
869 + .editor-preview-page-editor {
870 + box-sizing: border-box;
871 + flex: 1 1 auto;
872 + width: 100%;
873 + min-width: 0;
874 + min-height: 0;
875 + resize: none;
876 + border: 1px solid color-mix(in srgb, var(--color-border), transparent 12%);
877 + border-radius: 8px;
878 + outline: none;
879 + padding: 14px 16px;
880 + background: color-mix(in srgb, var(--color-panel), var(--color-background) 28%);
881 + color: var(--color-text);
882 + font: 13px/1.55 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
883 + letter-spacing: 0;
884 + }
885 +
886 + .editor-preview-page-editor:focus {
887 + border-color: color-mix(in srgb, #2c7be5, var(--color-border) 40%);
888 + box-shadow: 0 0 0 1px color-mix(in srgb, #2c7be5 24%, transparent);
889 + }
890 +
891 + .editor-preview-content img {
892 + display: block;
893 + max-width: 100%;
894 + height: auto;
895 + margin: 12px 0;
896 + }
897 +
898 + .editor-preview-content h1,
899 + .editor-preview-content h2,
900 + .editor-preview-content h3,
901 + .editor-preview-content h4,
902 + .editor-preview-content h5,
903 + .editor-preview-content h6 {
904 + scroll-margin-top: 16px;
905 + letter-spacing: 0;
906 + }
907 +
908 + .editor-preview-content blockquote {
909 + margin: 12px 0;
910 + padding: 1px 0 1px 14px;
911 + border-left: 3px solid color-mix(in srgb, var(--color-primary), var(--color-border) 45%);
912 + color: var(--color-text-secondary);
913 + }
914 +
915 + .editor-table-wrap {
916 + max-width: 100%;
917 + overflow-x: auto;
918 + margin: 14px 0;
919 + border: 1px solid color-mix(in srgb, var(--color-border), transparent 12%);
920 + border-radius: 8px;
921 + }
922 +
923 + .editor-preview-content table {
924 + width: 100%;
925 + border-collapse: collapse;
926 + min-width: 420px;
927 + }
928 +
929 + .editor-preview-content th,
930 + .editor-preview-content td {
931 + border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 22%);
932 + padding: 8px 10px;
933 + text-align: inherit;
934 + vertical-align: top;
935 + }
936 +
937 + .editor-preview-content th {
938 + background: color-mix(in srgb, var(--color-panel), var(--color-background) 24%);
939 + font-weight: 800;
940 + }
941 +
942 + .editor-preview-content input[type="checkbox"] {
943 + margin-right: 6px;
944 + cursor: pointer;
945 + }
946 +
947 + .editor-code-block {
948 + overflow: hidden;
949 + margin: 14px 0;
950 + border: 1px solid color-mix(in srgb, var(--color-border), transparent 12%);
951 + border-radius: 8px;
952 + background: color-mix(in srgb, var(--color-panel), var(--color-background) 16%);
953 + }
954 +
955 + .editor-code-header {
956 + display: flex;
957 + align-items: center;
958 + justify-content: space-between;
959 + gap: 10px;
960 + min-height: 32px;
961 + padding: 0 8px 0 10px;
962 + border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 22%);
963 + color: var(--color-text-secondary);
964 + font-size: 12px;
965 + font-weight: 700;
966 + }
967 +
968 + .editor-code-copy {
969 + height: 24px;
970 + border: 1px solid color-mix(in srgb, var(--color-border), transparent 12%);
971 + border-radius: 6px;
972 + background: var(--color-background);
973 + color: var(--color-text);
974 + cursor: pointer;
975 + font: inherit;
976 + font-size: 11px;
977 + font-weight: 750;
978 + }
979 +
980 + .editor-code-block pre {
981 + margin: 0;
982 + overflow: auto;
983 + padding: 12px;
984 + }
985 +
986 + .editor-code-block code {
987 + font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
988 + }
989 +
990 + .editor-math-display {
991 + margin: 14px 0;
992 + overflow-x: auto;
993 + text-align: center;
994 + }
995 +
996 + .editor-footnotes {
997 + margin-top: 24px;
998 + border-top: 1px solid color-mix(in srgb, var(--color-border), transparent 22%);
999 + color: var(--color-text-secondary);
1000 + font-size: 13px;
1001 + }
1002 +
1003 + mark.editor-search-mark {
1004 + border-radius: 3px;
1005 + background: color-mix(in srgb, #d9b12b 55%, transparent);
1006 + color: inherit;
1007 + padding: 0 1px;
1008 + }
1009 +
1010 + mark.editor-search-mark.is-current {
1011 + background: color-mix(in srgb, #2c7be5 55%, transparent);
1012 + outline: 1px solid color-mix(in srgb, #2c7be5, var(--color-border) 24%);
1013 + }
1014 +
1015 .editor-empty {
1016 display: grid;
1017 flex: 1 1 auto;
plugins/_editor/webui/editor-preview.js new
+214
@@ -0,0 +1,214 @@
1 +import { renderSafeMarkdown } from "/js/safe-markdown.js";
2 +
3 +const PAGE_HEADING_RE = /^(#{1,2})\s+(.+?)\s*#*\s*$/;
4 +const FOOTNOTE_DEF_RE = /^\[\^([^\]]+)\]:\s*(.*)$/;
5 +
6 +export function renderEditorPreviewMarkdown(markdown = "", fullMarkdown = markdown) {
7 + return renderSafeMarkdown(prepareFootnotes(markdown, fullMarkdown), {
8 + allowDataImages: true,
9 + allowLatex: true,
10 + openExternalLinksInNewTab: true,
11 + });
12 +}
13 +
14 +export function buildMarkdownPages(markdown = "", fallbackTitle = "Markdown") {
15 + const source = String(markdown || "");
16 + const lines = source.split("\n");
17 + const pages = [];
18 + let current = null;
19 + let intro = [];
20 + let introStart = 0;
21 + let fenced = false;
22 + let offset = 0;
23 +
24 + const startPage = (title, level, line, start) => {
25 + if (current) {
26 + current.end = start;
27 + pages.push(finalizePage(current, pages.length, source));
28 + }
29 + current = {
30 + title: cleanHeadingText(title) || fallbackTitle,
31 + level,
32 + lines: [line],
33 + start,
34 + end: source.length,
35 + };
36 + };
37 +
38 + for (const [index, line] of lines.entries()) {
39 + const lineStart = offset;
40 + const lineEnd = lineStart + line.length + (index < lines.length - 1 ? 1 : 0);
41 + if (/^\s*```/.test(line)) fenced = !fenced;
42 + const match = !fenced ? line.match(PAGE_HEADING_RE) : null;
43 + if (match) {
44 + if (!current && intro.join("\n").trim()) {
45 + pages.push(finalizePage({
46 + title: fallbackTitle,
47 + level: 0,
48 + lines: intro,
49 + start: introStart,
50 + end: lineStart,
51 + }, pages.length, source));
52 + intro = [];
53 + }
54 + startPage(match[2], match[1].length, line, lineStart);
55 + offset = lineEnd;
56 + continue;
57 + }
58 + if (current) current.lines.push(line);
59 + else intro.push(line);
60 + offset = lineEnd;
61 + }
62 +
63 + if (current) {
64 + current.end = source.length;
65 + pages.push(finalizePage(current, pages.length, source));
66 + }
67 + else if (intro.join("\n").trim() || !pages.length) {
68 + pages.push(finalizePage({
69 + title: fallbackTitle,
70 + level: 0,
71 + lines: intro,
72 + start: introStart,
73 + end: source.length,
74 + }, pages.length, source));
75 + }
76 +
77 + return pages;
78 +}
79 +
80 +export function slugifyHeading(text = "", used = new Map()) {
81 + const base = String(text || "")
82 + .toLowerCase()
83 + .replace(/<[^>]+>/g, "")
84 + .replace(/[`*_~[\]()]/g, "")
85 + .replace(/&[a-z0-9#]+;/gi, "")
86 + .replace(/[^a-z0-9\s-]/g, "")
87 + .trim()
88 + .replace(/\s+/g, "-")
89 + .replace(/-+/g, "-") || "section";
90 + const count = used.get(base) || 0;
91 + used.set(base, count + 1);
92 + return count ? `${base}-${count + 1}` : base;
93 +}
94 +
95 +export function resolveDocumentRelativePath(documentPath = "", target = "") {
96 + const value = String(target || "").trim();
97 + if (!value) return "";
98 + if (value.startsWith("/")) return normalizePath(value);
99 + const base = parentPath(documentPath);
100 + return normalizePath(`${base}/${value}`);
101 +}
102 +
103 +export function splitHref(href = "") {
104 + const value = String(href || "").trim();
105 + const hashIndex = value.indexOf("#");
106 + if (hashIndex < 0) return { path: value, fragment: "" };
107 + return {
108 + path: value.slice(0, hashIndex),
109 + fragment: decodeURIComponent(value.slice(hashIndex + 1) || ""),
110 + };
111 +}
112 +
113 +export function isExternalHref(href = "") {
114 + const value = String(href || "").trim();
115 + return /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value) || value.startsWith("//");
116 +}
117 +
118 +export function isMarkdownPath(path = "") {
119 + return /\.md(?:own)?$/i.test(String(path || "").split(/[?#]/, 1)[0]);
120 +}
121 +
122 +function finalizePage(page, index, source = "") {
123 + const start = Math.max(0, Number(page.start || 0));
124 + const end = Math.max(start, Number(page.end ?? String(source || "").length));
125 + const markdown = String(source || "").slice(start, end) || page.lines.join("\n");
126 + return {
127 + index,
128 + title: page.title,
129 + level: page.level,
130 + anchor: slugifyHeading(page.title),
131 + start,
132 + end,
133 + markdown,
134 + };
135 +}
136 +
137 +function cleanHeadingText(text = "") {
138 + return String(text || "")
139 + .replace(/\\([\\`*_[\]{}()#+.!-])/g, "$1")
140 + .replace(/[*_`~]/g, "")
141 + .trim();
142 +}
143 +
144 +function prepareFootnotes(markdown = "", fullMarkdown = markdown) {
145 + const definitions = [];
146 + const body = [];
147 + for (const line of String(fullMarkdown || "").split("\n")) {
148 + const match = line.match(FOOTNOTE_DEF_RE);
149 + if (match) {
150 + definitions.push({ id: match[1], text: match[2] });
151 + }
152 + }
153 + for (const line of String(markdown || "").split("\n")) {
154 + if (line.match(FOOTNOTE_DEF_RE)) {
155 + continue;
156 + }
157 + body.push(line);
158 + }
159 + if (!definitions.length) return markdown;
160 +
161 + const counts = new Map();
162 + let prepared = body.join("\n").replace(/\[\^([^\]]+)\]/g, (_all, id) => {
163 + const number = definitions.findIndex((item) => item.id === id) + 1;
164 + if (number <= 0) return `[^${id}]`;
165 + const count = (counts.get(id) || 0) + 1;
166 + counts.set(id, count);
167 + const safeId = footnoteId(id);
168 + return `<sup class="editor-footnote-ref"><a id="fnref-${safeId}-${count}" href="#fn-${safeId}">${number}</a></sup>`;
169 + });
170 +
171 + prepared += "\n\n<section class=\"editor-footnotes\" aria-label=\"Footnotes\">\n<ol>\n";
172 + for (const [index, definition] of definitions.entries()) {
173 + const safeId = footnoteId(definition.id);
174 + prepared += `<li id="fn-${safeId}">${escapeHtml(definition.text)} <a class="editor-footnote-backref" href="#fnref-${safeId}-1">Back</a></li>\n`;
175 + }
176 + prepared += "</ol>\n</section>";
177 + return prepared;
178 +}
179 +
180 +function footnoteId(id = "") {
181 + return String(id || "")
182 + .toLowerCase()
183 + .replace(/[^a-z0-9_-]+/g, "-")
184 + .replace(/^-+|-+$/g, "") || "note";
185 +}
186 +
187 +function parentPath(path = "") {
188 + const normalized = String(path || "").split(/[?#]/, 1)[0].replace(/\/+$/, "");
189 + const index = normalized.lastIndexOf("/");
190 + if (index <= 0) return "/";
191 + return normalized.slice(0, index);
192 +}
193 +
194 +function normalizePath(path = "") {
195 + const absolute = String(path || "").startsWith("/");
196 + const parts = [];
197 + for (const part of String(path || "").split("/")) {
198 + if (!part || part === ".") continue;
199 + if (part === "..") {
200 + parts.pop();
201 + continue;
202 + }
203 + parts.push(part);
204 + }
205 + return `${absolute ? "/" : ""}${parts.join("/")}`;
206 +}
207 +
208 +function escapeHtml(value = "") {
209 + return String(value || "")
210 + .replace(/&/g, "&amp;")
211 + .replace(/</g, "&lt;")
212 + .replace(/>/g, "&gt;")
213 + .replace(/"/g, "&quot;");
214 +}
plugins/_editor/webui/editor-store.js
+802 -16
@@ -2,6 +2,15 @@ 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 + buildMarkdownPages,
7 + isExternalHref,
8 + isMarkdownPath,
9 + renderEditorPreviewMarkdown,
10 + resolveDocumentRelativePath,
11 + slugifyHeading,
12 + splitHref,
13 +} from "/plugins/_editor/webui/editor-preview.js";
14
15 const editorSocket = getNamespacedClient("/ws");
16 editorSocket.addHandlers(["ws_webui"]);
@@ -9,6 +18,8 @@ editorSocket.addHandlers(["ws_webui"]);
18 const SAVE_MESSAGE_MS = 1800;
19 const INPUT_PUSH_DELAY_MS = 650;
20 const MAX_HISTORY = 80;
21 +const SOURCE_MODE = "source";
22 +const PREVIEW_MODE = "preview";
23
24 function currentContextId() {
25 try {
@@ -94,6 +105,48 @@ function documentLabel(document = {}) {
105 return document.title || document.basename || basename(document.path);
106 }
107
108 +function escapeRegExp(value = "") {
109 + return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
110 +}
111 +
112 +function textNodesUnder(root, skipSelector = "") {
113 + const nodes = [];
114 + if (!root) return nodes;
115 + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
116 + acceptNode(node) {
117 + if (!node.nodeValue) return NodeFilter.FILTER_REJECT;
118 + if (skipSelector && node.parentElement?.closest(skipSelector)) return NodeFilter.FILTER_REJECT;
119 + return NodeFilter.FILTER_ACCEPT;
120 + },
121 + });
122 + while (walker.nextNode()) nodes.push(walker.currentNode);
123 + return nodes;
124 +}
125 +
126 +function aceModeForLanguage(language = "") {
127 + const value = String(language || "").toLowerCase();
128 + const aliases = {
129 + bash: "sh",
130 + shell: "sh",
131 + zsh: "sh",
132 + py: "python",
133 + js: "javascript",
134 + jsx: "javascript",
135 + ts: "typescript",
136 + md: "markdown",
137 + yml: "yaml",
138 + };
139 + return aliases[value] || value || "text";
140 +}
141 +
142 +function taskLineIndexes(markdown = "") {
143 + const indexes = [];
144 + String(markdown || "").split("\n").forEach((line, index) => {
145 + if (/^\s*(?:[-*+]|\d+[.)])\s+\[[ xX]\](?:\s+|$)/.test(line)) indexes.push(index);
146 + });
147 + return indexes;
148 +}
149 +
150 async function callEditor(action, payload = {}) {
151 return await callJsonApi("/plugins/_editor/editor_session", {
152 action,
@@ -142,7 +195,19 @@ const model = {
195 error: "",
196 message: "",
197 pendingClose: null,
198 + viewMode: SOURCE_MODE,
199 + searchOpen: false,
200 + searchQuery: "",
201 + searchMatches: [],
202 + searchIndex: -1,
203 + activePageIndex: 0,
204 + previewEditing: false,
205 + previewEditDirty: false,
206 + previewEditText: "",
207 + previewEditPageIndex: -1,
208 + aceUnavailable: false,
209 editorText: "",
210 + sourceEditor: null,
211 _root: null,
212 _mode: "modal",
213 _initialized: false,
@@ -155,6 +220,12 @@ const model = {
220 _focusAttempts: 0,
221 _headerCleanup: null,
222 _surfaceHandoff: false,
223 + _settingSourceEditorValue: false,
224 + _sourceEditorChangeHandler: null,
225 + _previewEnhanceTimer: null,
226 + _staticHighlightPromise: null,
227 + _pendingPreviewFragment: "",
228 + _initialCreatePromise: null,
229
230 async init() {
231 if (this._initialized) return;
@@ -167,6 +238,7 @@ const model = {
238 if (element) this._root = element;
239 this._mode = options?.mode === "canvas" ? "canvas" : "modal";
240 if (this._mode === "modal") this.setupMarkdownModal(element);
241 + this.scheduleSourceEditorInit();
242 this.queueRender();
243 },
244
@@ -180,7 +252,9 @@ const model = {
252 refresh: payload.refresh === true,
253 source: payload.source || "",
254 });
255 + return;
256 }
257 + await this.ensureInitialMarkdownFile();
258 },
259
260 beforeHostHidden() {
@@ -189,6 +263,9 @@ const model = {
263
264 cleanup() {
265 this.flushInput();
266 + this.destroySourceEditor();
267 + if (this._previewEnhanceTimer) globalThis.clearTimeout(this._previewEnhanceTimer);
268 + this._previewEnhanceTimer = null;
269 this._headerCleanup?.();
270 this._headerCleanup = null;
271 if (this._mode === "modal") this._root = null;
@@ -217,10 +294,557 @@ const model = {
294 }
295 },
296
297 + isSourceMode() {
298 + return this.viewMode === SOURCE_MODE;
299 + },
300 +
301 + isPreviewMode() {
302 + return this.viewMode === PREVIEW_MODE;
303 + },
304 +
305 + async setViewMode(mode) {
306 + const next = mode === PREVIEW_MODE ? PREVIEW_MODE : SOURCE_MODE;
307 + if (this.viewMode === next) return;
308 + this.applyPreviewEdit({ silent: true });
309 + this.syncEditorText();
310 + this.viewMode = next;
311 + this.cancelPendingClose();
312 + if (next === SOURCE_MODE) {
313 + this.setSourceEditorText(this.editorText);
314 + this.scheduleSourceEditorInit();
315 + this.refreshSourceEditorLayout();
316 + this.queueRender({ focus: Boolean(this.session), end: false });
317 + return;
318 + }
319 + this.clampActivePage();
320 + this.schedulePreviewEnhance();
321 + },
322 +
323 + async toggleViewMode() {
324 + await this.setViewMode(this.isPreviewMode() ? SOURCE_MODE : PREVIEW_MODE);
325 + },
326 +
327 + viewModeIcon() {
328 + return this.isPreviewMode() ? "code" : "article";
329 + },
330 +
331 + viewModeTitle() {
332 + return this.isPreviewMode() ? "Source edit" : "Preview";
333 + },
334 +
335 + pages() {
336 + return buildMarkdownPages(this.editorText, this.tabTitle(this.session || {}));
337 + },
338 +
339 + currentPage() {
340 + const pages = this.pages();
341 + const index = Math.max(0, Math.min(this.activePageIndex, pages.length - 1));
342 + return pages[index] || pages[0] || { title: this.tabTitle(this.session || {}), markdown: "" };
343 + },
344 +
345 + pageTitle() {
346 + return this.currentPage().title || this.tabTitle(this.session || {});
347 + },
348 +
349 + pagePositionLabel() {
350 + const pages = this.pages();
351 + if (!pages.length) return "";
352 + return `${Math.min(this.activePageIndex + 1, pages.length)} of ${pages.length}`;
353 + },
354 +
355 + previewHtml() {
356 + return renderEditorPreviewMarkdown(this.currentPage().markdown || "", this.editorText);
357 + },
358 +
359 + selectPage(index) {
360 + if (this.previewEditing) return;
361 + const pages = this.pages();
362 + if (!pages.length) return;
363 + this.activePageIndex = Math.max(0, Math.min(Number(index) || 0, pages.length - 1));
364 + this.schedulePreviewEnhance();
365 + },
366 +
367 + nextPage() {
368 + this.selectPage(this.activePageIndex + 1);
369 + },
370 +
371 + previousPage() {
372 + this.selectPage(this.activePageIndex - 1);
373 + },
374 +
375 + startPreviewEdit() {
376 + if (!this.session || !this.isMarkdown() || !this.isPreviewMode()) return;
377 + const page = this.currentPage();
378 + this.previewEditing = true;
379 + this.previewEditDirty = false;
380 + this.previewEditPageIndex = this.activePageIndex;
381 + this.previewEditText = page.markdown || "";
382 + this.queueRender({ force: true, focus: false });
383 + globalThis.requestAnimationFrame?.(() => {
384 + const editor = this._root?.querySelector?.("[data-editor-preview-source]");
385 + editor?.focus?.({ preventScroll: true });
386 + });
387 + },
388 +
389 + onPreviewEditInput() {
390 + if (this.previewEditing) this.previewEditDirty = true;
391 + },
392 +
393 + cancelPreviewEdit() {
394 + this.previewEditing = false;
395 + this.previewEditDirty = false;
396 + this.previewEditText = "";
397 + this.previewEditPageIndex = -1;
398 + this.schedulePreviewEnhance();
399 + },
400 +
401 + applyPreviewEdit(options = {}) {
402 + if (!this.previewEditing) return false;
403 + if (!this.previewEditDirty && options.force !== true) {
404 + this.cancelPreviewEdit();
405 + return false;
406 + }
407 + const pages = this.pages();
408 + const index = Math.max(0, Math.min(
409 + this.previewEditPageIndex >= 0 ? this.previewEditPageIndex : this.activePageIndex,
410 + pages.length - 1,
411 + ));
412 + const page = pages[index];
413 + if (!page) {
414 + this.cancelPreviewEdit();
415 + return false;
416 + }
417 +
418 + let replacement = String(this.previewEditText || "");
419 + this.previewEditing = false;
420 + this.previewEditDirty = false;
421 + this.previewEditText = "";
422 + this.previewEditPageIndex = -1;
423 +
424 + return this.replacePageMarkdown(page, replacement, {
425 + message: "Page updated",
426 + silent: options.silent,
427 + });
428 + },
429 +
430 + replacePageMarkdown(page = null, markdown = "", options = {}) {
431 + if (!page) return false;
432 + const source = String(this.editorText || "");
433 + const start = Math.max(0, Number(page.start || 0));
434 + const end = Math.max(start, Number(page.end ?? source.length));
435 + const before = source.slice(0, start);
436 + const after = source.slice(end);
437 + let replacement = String(markdown || "");
438 + if (replacement && after && !replacement.endsWith("\n")) replacement += "\n";
439 + const next = before + replacement + after;
440 + if (next === source) {
441 + this.schedulePreviewEnhance();
442 + return false;
443 + }
444 +
445 + this.editorText = next;
446 + this.setSourceEditorText(next);
447 + if (this.session) {
448 + this.session.text = next;
449 + this.session.dirty = true;
450 + }
451 + this.dirty = true;
452 + this.pushHistory(next);
453 + this.scheduleInputPush();
454 + this.clampActivePage();
455 + this.schedulePreviewEnhance();
456 + if (!options.silent && options.message) this.setMessage(options.message);
457 + this.queueRender({ force: true, focus: false });
458 + return true;
459 + },
460 +
461 + togglePreviewTask(taskIndex, checked) {
462 + if (!this.session || !this.isMarkdown() || !this.isPreviewMode() || this.previewEditing) return false;
463 + const page = this.currentPage();
464 + const lines = String(page.markdown || "").split("\n");
465 + const indexes = taskLineIndexes(page.markdown || "");
466 + const lineIndex = indexes[Number(taskIndex)];
467 + if (lineIndex == null || !lines[lineIndex]) return false;
468 + const nextLine = lines[lineIndex].replace(
469 + /^(\s*(?:[-*+]|\d+[.)])\s+\[)[ xX](\](?:\s+|$))/,
470 + `$1${checked ? "x" : " "}$2`,
471 + );
472 + if (nextLine === lines[lineIndex]) return false;
473 + lines[lineIndex] = nextLine;
474 + return this.replacePageMarkdown(page, lines.join("\n"));
475 + },
476 +
477 + clampActivePage() {
478 + const pages = this.pages();
479 + this.activePageIndex = Math.max(0, Math.min(this.activePageIndex, Math.max(0, pages.length - 1)));
480 + },
481 +
482 + schedulePreviewEnhance() {
483 + if (!this.isPreviewMode()) return;
484 + if (this._previewEnhanceTimer) globalThis.clearTimeout(this._previewEnhanceTimer);
485 + this._previewEnhanceTimer = globalThis.setTimeout(() => {
486 + this._previewEnhanceTimer = null;
487 + this.enhancePreview();
488 + }, 0);
489 + },
490 +
491 + enhancePreview() {
492 + const root = this._root?.querySelector?.("[data-editor-preview]");
493 + if (!root) return;
494 + this.addHeadingIds(root);
495 + this.enhanceTables(root);
496 + this.enhanceTaskLists(root);
497 + this.enhanceImages(root);
498 + this.enhanceLinks(root);
499 + this.enhanceCodeBlocks(root);
500 + this.renderMath(root);
501 + this.applySearchHighlights(root);
502 + this.scrollPendingFragment(root);
503 + },
504 +
505 + addHeadingIds(root) {
506 + const used = new Map();
507 + root.querySelectorAll("h1,h2,h3,h4,h5,h6").forEach((heading) => {
508 + if (!heading.id) heading.id = slugifyHeading(heading.textContent || "", used);
509 + });
510 + },
511 +
512 + enhanceTables(root) {
513 + root.querySelectorAll("table").forEach((table) => {
514 + if (table.parentElement?.classList.contains("editor-table-wrap")) return;
515 + const wrapper = document.createElement("div");
516 + wrapper.className = "editor-table-wrap";
517 + table.parentNode?.insertBefore(wrapper, table);
518 + wrapper.appendChild(table);
519 + });
520 + },
521 +
522 + enhanceTaskLists(root) {
523 + root.querySelectorAll('input[type="checkbox"]').forEach((checkbox, index) => {
524 + if (checkbox.dataset.editorTaskEnhanced === "true") return;
525 + checkbox.dataset.editorTaskEnhanced = "true";
526 + checkbox.dataset.editorTaskIndex = String(index);
527 + checkbox.disabled = false;
528 + checkbox.removeAttribute("disabled");
529 + checkbox.addEventListener("change", (event) => {
530 + const target = event.currentTarget;
531 + this.togglePreviewTask(Number(target?.dataset?.editorTaskIndex || 0), Boolean(target?.checked));
532 + });
533 + });
534 + },
535 +
536 + enhanceImages(root) {
537 + const docPath = this.session?.path || this.session?.document?.path || "";
538 + root.querySelectorAll("img[src]").forEach((image) => {
539 + const src = image.getAttribute("src") || "";
540 + if (!src || isExternalHref(src) || src.startsWith("data:") || src.startsWith("/api/image_get")) return;
541 + const resolved = resolveDocumentRelativePath(docPath, src);
542 + image.setAttribute("src", `/api/image_get?path=${encodeURIComponent(resolved)}`);
543 + image.setAttribute("loading", "lazy");
544 + });
545 + },
546 +
547 + enhanceLinks(root) {
548 + const docPath = this.session?.path || this.session?.document?.path || "";
549 + root.querySelectorAll("a[href]").forEach((anchor) => {
550 + const href = anchor.getAttribute("href") || "";
551 + if (!href || isExternalHref(href)) return;
552 + const { path, fragment } = splitHref(href);
553 + if (!path && fragment) {
554 + anchor.dataset.editorFragment = fragment;
555 + return;
556 + }
557 + if (!isMarkdownPath(path)) return;
558 + anchor.dataset.editorMarkdownPath = resolveDocumentRelativePath(docPath, path);
559 + anchor.dataset.editorFragment = fragment;
560 + });
561 + },
562 +
563 + async enhanceCodeBlocks(root) {
564 + root.querySelectorAll("pre > code").forEach((code) => {
565 + const pre = code.parentElement;
566 + if (!pre || pre.parentElement?.classList.contains("editor-code-block")) return;
567 + const wrapper = document.createElement("div");
568 + wrapper.className = "editor-code-block";
569 + const header = document.createElement("div");
570 + header.className = "editor-code-header";
571 + const language = this.codeLanguage(code);
572 + const label = document.createElement("span");
573 + label.className = "editor-code-language";
574 + label.textContent = language || "text";
575 + const button = document.createElement("button");
576 + button.type = "button";
577 + button.className = "editor-code-copy";
578 + button.textContent = "Copy";
579 + button.addEventListener("click", async () => {
580 + await navigator.clipboard?.writeText(code.textContent || "");
581 + button.textContent = "Copied";
582 + globalThis.setTimeout(() => { button.textContent = "Copy"; }, 1200);
583 + });
584 + header.append(label, button);
585 + pre.parentNode?.insertBefore(wrapper, pre);
586 + wrapper.append(header, pre);
587 + this.highlightCodeBlock(code, language);
588 + });
589 + },
590 +
591 + codeLanguage(code) {
592 + for (const className of code.classList || []) {
593 + if (className.startsWith("language-")) return className.slice("language-".length);
594 + if (className.startsWith("lang-")) return className.slice("lang-".length);
595 + }
596 + return "";
597 + },
598 +
599 + async highlightCodeBlock(code, language) {
600 + if (!language || !globalThis.ace?.require) return;
601 + const source = code.textContent || "";
602 + try {
603 + const highlighter = await this.loadAceStaticHighlighter();
604 + const darkMode = globalThis.localStorage?.getItem("darkMode");
605 + const theme = darkMode !== "false" ? "ace/theme/github_dark" : "ace/theme/github";
606 + const mode = `ace/mode/${aceModeForLanguage(language)}`;
607 + highlighter.render(source, mode, theme, 1, true, (result) => {
608 + code.innerHTML = result.html;
609 + code.classList.add("is-highlighted");
610 + });
611 + } catch {
612 + // Fenced code still renders as preformatted text if highlighting is unavailable.
613 + }
614 + },
615 +
616 + loadAceStaticHighlighter() {
617 + if (this._staticHighlightPromise) return this._staticHighlightPromise;
618 + this._staticHighlightPromise = new Promise((resolve, reject) => {
619 + let existing = null;
620 + try {
621 + existing = globalThis.ace?.require?.("ace/ext/static_highlight");
622 + } catch {
623 + existing = null;
624 + }
625 + if (existing?.render) {
626 + resolve(existing);
627 + return;
628 + }
629 + const script = document.createElement("script");
630 + script.src = "/vendor/ace-min/ext-static_highlight.js";
631 + script.onload = () => {
632 + const loaded = globalThis.ace?.require?.("ace/ext/static_highlight");
633 + loaded?.render ? resolve(loaded) : reject(new Error("ACE highlighter unavailable"));
634 + };
635 + script.onerror = () => reject(new Error("ACE highlighter failed to load"));
636 + document.head.appendChild(script);
637 + });
638 + return this._staticHighlightPromise;
639 + },
640 +
641 + renderMath(root) {
642 + if (!globalThis.katex?.render) return;
643 + for (const node of textNodesUnder(root, "code,pre,.katex,.editor-code-block")) {
644 + this.replaceMathInTextNode(node);
645 + }
646 + },
647 +
648 + replaceMathInTextNode(node) {
649 + const text = node.nodeValue || "";
650 + const pattern = /(\$\$[^$]+\$\$|\$[^$\n]+\$)/g;
651 + if (!pattern.test(text)) return;
652 + pattern.lastIndex = 0;
653 + const fragment = document.createDocumentFragment();
654 + let lastIndex = 0;
655 + let match;
656 + while ((match = pattern.exec(text))) {
657 + if (match.index > lastIndex) fragment.append(document.createTextNode(text.slice(lastIndex, match.index)));
658 + const raw = match[0];
659 + const displayMode = raw.startsWith("$$");
660 + const expression = raw.slice(displayMode ? 2 : 1, displayMode ? -2 : -1);
661 + const span = document.createElement(displayMode ? "div" : "span");
662 + span.className = displayMode ? "editor-math-display" : "editor-math-inline";
663 + try {
664 + globalThis.katex.render(expression, span, { throwOnError: false, displayMode });
665 + } catch {
666 + span.textContent = raw;
667 + }
668 + fragment.append(span);
669 + lastIndex = match.index + raw.length;
670 + }
671 + if (lastIndex < text.length) fragment.append(document.createTextNode(text.slice(lastIndex)));
672 + node.parentNode?.replaceChild(fragment, node);
673 + },
674 +
675 + openSearch() {
676 + if (!this.isPreviewMode()) {
677 + this.setViewMode(PREVIEW_MODE);
678 + }
679 + this.searchOpen = true;
680 + this.runSearch();
681 + globalThis.requestAnimationFrame?.(() => {
682 + this._root?.querySelector?.("[data-editor-search]")?.focus?.();
683 + });
684 + },
685 +
686 + closeSearch() {
687 + this.searchOpen = false;
688 + this.searchQuery = "";
689 + this.searchMatches = [];
690 + this.searchIndex = -1;
691 + this.schedulePreviewEnhance();
692 + },
693 +
694 + searchCountLabel() {
695 + if (!this.searchQuery) return "";
696 + if (!this.searchMatches.length) return "0 of 0";
697 + return `${this.searchIndex + 1} of ${this.searchMatches.length}`;
698 + },
699 +
700 + runSearch() {
701 + const query = String(this.searchQuery || "");
702 + if (!query) {
703 + this.searchMatches = [];
704 + this.searchIndex = -1;
705 + this.schedulePreviewEnhance();
706 + return;
707 + }
708 + const lower = query.toLowerCase();
709 + const matches = [];
710 + for (const page of this.pages()) {
711 + const text = this.renderedTextForPage(page);
712 + let index = 0;
713 + let occurrence = 0;
714 + while ((index = text.toLowerCase().indexOf(lower, index)) >= 0) {
715 + matches.push({ pageIndex: page.index, occurrence, offset: index });
716 + occurrence += 1;
717 + index += Math.max(1, lower.length);
718 + }
719 + }
720 + this.searchMatches = matches;
721 + this.searchIndex = matches.length ? 0 : -1;
722 + this.goToCurrentSearchMatch();
723 + },
724 +
725 + nextSearchMatch() {
726 + if (!this.searchMatches.length) return;
727 + this.searchIndex = (this.searchIndex + 1) % this.searchMatches.length;
728 + this.goToCurrentSearchMatch();
729 + },
730 +
731 + previousSearchMatch() {
732 + if (!this.searchMatches.length) return;
733 + this.searchIndex = (this.searchIndex - 1 + this.searchMatches.length) % this.searchMatches.length;
734 + this.goToCurrentSearchMatch();
735 + },
736 +
737 + goToCurrentSearchMatch() {
738 + const match = this.searchMatches[this.searchIndex];
739 + if (!match) {
740 + this.schedulePreviewEnhance();
741 + return;
742 + }
743 + this.activePageIndex = match.pageIndex;
744 + this.schedulePreviewEnhance();
745 + },
746 +
747 + renderedTextForPage(page) {
748 + const html = renderEditorPreviewMarkdown(page.markdown || "", this.editorText);
749 + const doc = new DOMParser().parseFromString(html, "text/html");
750 + return doc.body.textContent || "";
751 + },
752 +
753 + applySearchHighlights(root) {
754 + root.querySelectorAll("mark.editor-search-mark").forEach((mark) => {
755 + mark.replaceWith(document.createTextNode(mark.textContent || ""));
756 + });
757 + const query = String(this.searchQuery || "");
758 + if (!query || !this.searchMatches.length) return;
759 + const regex = new RegExp(escapeRegExp(query), "gi");
760 + const current = this.searchMatches[this.searchIndex];
761 + let occurrence = 0;
762 + for (const node of textNodesUnder(root, "script,style")) {
763 + const text = node.nodeValue || "";
764 + if (!regex.test(text)) continue;
765 + regex.lastIndex = 0;
766 + const fragment = document.createDocumentFragment();
767 + let lastIndex = 0;
768 + let match;
769 + while ((match = regex.exec(text))) {
770 + if (match.index > lastIndex) fragment.append(document.createTextNode(text.slice(lastIndex, match.index)));
771 + const mark = document.createElement("mark");
772 + mark.className = "editor-search-mark";
773 + if (current?.pageIndex === this.activePageIndex && current.occurrence === occurrence) {
774 + mark.classList.add("is-current");
775 + }
776 + mark.textContent = match[0];
777 + fragment.append(mark);
778 + occurrence += 1;
779 + lastIndex = match.index + match[0].length;
780 + }
781 + if (lastIndex < text.length) fragment.append(document.createTextNode(text.slice(lastIndex)));
782 + node.parentNode?.replaceChild(fragment, node);
783 + }
784 + root.querySelector("mark.editor-search-mark.is-current")?.scrollIntoView?.({ block: "center" });
785 + },
786 +
787 + async handlePreviewClick(event) {
788 + const anchor = event.target?.closest?.("a[href]");
789 + if (!anchor) return;
790 + const markdownPath = anchor.dataset.editorMarkdownPath || "";
791 + const fragment = anchor.dataset.editorFragment || "";
792 + if (!markdownPath && fragment) {
793 + event.preventDefault();
794 + this.navigateToFragment(fragment);
795 + return;
796 + }
797 + if (!markdownPath) return;
798 + event.preventDefault();
799 + this._pendingPreviewFragment = fragment;
800 + const opened = await this.openSession({ path: markdownPath, refresh: true, source: "editor-preview-link" });
801 + if (!opened) return;
802 + if (this.isPreviewMode() && fragment) {
803 + this.navigateToFragment(fragment);
804 + }
805 + },
806 +
807 + navigateToFragment(fragment = "") {
808 + const target = String(fragment || "").replace(/^#/, "");
809 + if (!target) return;
810 + const pages = this.pages();
811 + const normalized = target.toLowerCase();
812 + for (const page of pages) {
813 + const doc = new DOMParser().parseFromString(renderEditorPreviewMarkdown(page.markdown || "", this.editorText), "text/html");
814 + const used = new Map();
815 + const headings = [...doc.body.querySelectorAll("h1,h2,h3,h4,h5,h6")];
816 + if (headings.some((heading) => (heading.id || slugifyHeading(heading.textContent || "", used)) === normalized)) {
817 + this.activePageIndex = page.index;
818 + this._pendingPreviewFragment = target;
819 + this.schedulePreviewEnhance();
820 + return;
821 + }
822 + }
823 + this._pendingPreviewFragment = target;
824 + this.schedulePreviewEnhance();
825 + },
826 +
827 + scrollPendingFragment(root) {
828 + const fragment = this._pendingPreviewFragment;
829 + if (!fragment) return;
830 + const target = root.querySelector(`#${CSS.escape(fragment)}`);
831 + if (target) {
832 + target.scrollIntoView({ block: "start" });
833 + this._pendingPreviewFragment = "";
834 + }
835 + },
836 +
837 + handleEditorKeydown(event) {
838 + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f") {
839 + event.preventDefault();
840 + this.openSearch();
841 + }
842 + },
843 +
844 async create(kind = "document", format = "") {
845 const fmt = "md";
846 const title = this.defaultTitle(kind, fmt);
223 - await this.openSession({
847 + return await this.openSession({
848 action: "create",
849 kind: "document",
850 format: fmt,
@@ -228,6 +852,15 @@ const model = {
852 });
853 },
854
855 + async ensureInitialMarkdownFile() {
856 + if (this.session || this.visibleTabs().length > 0 || this.loading) return null;
857 + if (!this._root || this._initialCreatePromise) return this._initialCreatePromise;
858 + this._initialCreatePromise = this.create("document", "md").finally(() => {
859 + this._initialCreatePromise = null;
860 + });
861 + return await this._initialCreatePromise;
862 + },
863 +
864 async openFileBrowser() {
865 let workdirPath = "/a0/usr/workdir";
866 try {
@@ -281,8 +914,15 @@ const model = {
914 || (session.path && tab.path === session.path)
915 ));
916 if (existingIndex >= 0) {
284 - this.tabs.splice(existingIndex, 1, { ...this.tabs[existingIndex], ...session, tab_id: this.tabs[existingIndex].tab_id });
285 - this.activeTabId = this.tabs[existingIndex].tab_id;
917 + const tabId = this.tabs[existingIndex].tab_id;
918 + const wasActive = this.activeTabId === tabId || this.session?.tab_id === tabId;
919 + const merged = { ...this.tabs[existingIndex], ...session, tab_id: tabId };
920 + this.tabs.splice(existingIndex, 1, merged);
921 + this.activeTabId = tabId;
922 + if (wasActive) {
923 + this.hydrateActiveSession(merged, { preservePage: true, focus: false });
924 + return;
925 + }
926 } else {
927 this.tabs.push(session);
928 this.activeTabId = session.tab_id;
@@ -290,18 +930,40 @@ const model = {
930 this.selectTab(this.activeTabId);
931 },
932
293 - selectTab(tabId, options = {}) {
294 - this.syncEditorText();
295 - const tab = this.tabs.find((item) => item.tab_id === tabId) || this.tabs[0] || null;
296 - this.session = tab;
933 + hydrateActiveSession(tab, options = {}) {
934 + this.session = tab || null;
935 this.activeTabId = tab?.tab_id || "";
936 this.editorText = String(tab?.text || "");
937 this.dirty = Boolean(tab?.dirty);
938 + if (this.previewEditing) this.cancelPreviewEdit();
939 + if (!options.preservePage) {
940 + this.activePageIndex = 0;
941 + } else {
942 + this.clampActivePage();
943 + }
944 + this.searchMatches = [];
945 + this.searchIndex = -1;
946 this.resetHistory(this.editorText);
947 + this.setSourceEditorText(this.editorText);
948 if (tab?.session_id) {
949 requestEditor("editor_activate", { session_id: tab.session_id }, 2500).catch(() => {});
950 }
304 - this.queueRender({ focus: Boolean(tab) && options.focus !== false });
951 + if (this.searchOpen && this.searchQuery) this.runSearch();
952 + else if (this.isSourceMode()) this.scheduleSourceEditorInit();
953 + else this.schedulePreviewEnhance();
954 + this.refreshSourceEditorLayout();
955 + this.queueRender({ focus: this.isSourceMode() && Boolean(tab) && options.focus !== false, end: false });
956 + },
957 +
958 + selectTab(tabId, options = {}) {
959 + this.applyPreviewEdit({ silent: true });
960 + this.syncEditorText();
961 + const tab = this.tabs.find((item) => item.tab_id === tabId) || this.tabs[0] || null;
962 + this.previewEditing = false;
963 + this.previewEditDirty = false;
964 + this.previewEditText = "";
965 + this.previewEditPageIndex = -1;
966 + this.hydrateActiveSession(tab, { preservePage: false, focus: options.focus !== false });
967 },
968
969 ensureActiveTab() {
@@ -314,7 +976,7 @@ const model = {
976 },
977
978 isTabDirty(tab) {
317 - return Boolean(tab?.dirty || (this.isActiveTab(tab) && this.dirty));
979 + return Boolean(tab?.dirty || (this.isActiveTab(tab) && (this.dirty || this.previewEditDirty)));
980 },
981
982 hasPendingClose() {
@@ -362,7 +1024,7 @@ const model = {
1024 totalCount: tabs.length,
1025 dirtyCount,
1026 };
365 - if (kind === "single" && ids[0]) {
1027 + if (kind === "single" && ids[0] && this.activeTabId !== ids[0]) {
1028 this.selectTab(ids[0], { focus: false });
1029 }
1030 },
@@ -400,6 +1062,7 @@ const model = {
1062 const saved = await this.saveTab(tab);
1063 if (!saved) return false;
1064 }
1065 + if (this.activeTabId === tabId && this.previewEditing) this.cancelPreviewEdit();
1066 try {
1067 if (tab.session_id) {
1068 await requestEditor("editor_close", { session_id: tab.session_id }, 2500).catch(() => null);
@@ -458,8 +1121,87 @@ const model = {
1121 }
1122 },
1123
1124 + scheduleSourceEditorInit() {
1125 + if (!this.isSourceMode()) return;
1126 + globalThis.requestAnimationFrame?.(() => {
1127 + globalThis.requestAnimationFrame?.(() => this.initSourceEditor());
1128 + });
1129 + },
1130 +
1131 + initSourceEditor() {
1132 + if (!this.isSourceMode() || !this._root) return;
1133 + const container = this._root.querySelector?.("[data-editor-ace]");
1134 + if (!container || this.sourceEditor) return;
1135 + if (!globalThis.ace?.edit) {
1136 + this.aceUnavailable = true;
1137 + return;
1138 + }
1139 +
1140 + const editor = globalThis.ace.edit(container);
1141 + const darkMode = globalThis.localStorage?.getItem("darkMode");
1142 + const theme = darkMode !== "false" ? "ace/theme/github_dark" : "ace/theme/github";
1143 + editor.setTheme(theme);
1144 + editor.session.setMode("ace/mode/markdown");
1145 + editor.session.setUseWrapMode(true);
1146 + editor.setOptions({
1147 + fontSize: "13px",
1148 + showGutter: false,
1149 + showPrintMargin: false,
1150 + useWorker: false,
1151 + });
1152 + editor.renderer.setShowGutter(false);
1153 + editor.renderer.setScrollMargin(14, 14, 0, 0);
1154 + editor.setValue(this.editorText || "", -1);
1155 + this._sourceEditorChangeHandler = () => {
1156 + if (this._settingSourceEditorValue) return;
1157 + this.editorText = editor.getValue();
1158 + this.onSourceInput();
1159 + };
1160 + editor.session.on("change", this._sourceEditorChangeHandler);
1161 + this.sourceEditor = editor;
1162 + this.aceUnavailable = false;
1163 + this.queueRender({ focus: Boolean(this.session), end: false });
1164 + },
1165 +
1166 + destroySourceEditor() {
1167 + if (this.sourceEditor?.session && this._sourceEditorChangeHandler) {
1168 + this.sourceEditor.session.off?.("change", this._sourceEditorChangeHandler);
1169 + }
1170 + const container = this.sourceEditor?.container;
1171 + this.sourceEditor?.destroy?.();
1172 + if (container) container.textContent = "";
1173 + this.sourceEditor = null;
1174 + this._sourceEditorChangeHandler = null;
1175 + },
1176 +
1177 + setSourceEditorText(text = "") {
1178 + if (!this.sourceEditor) return;
1179 + const value = String(text || "");
1180 + if (this.sourceEditor.getValue() === value) return;
1181 + this._settingSourceEditorValue = true;
1182 + this.sourceEditor.setValue(value, -1);
1183 + this._settingSourceEditorValue = false;
1184 + this.refreshSourceEditorLayout();
1185 + },
1186 +
1187 + refreshSourceEditorLayout() {
1188 + const editor = this.sourceEditor;
1189 + if (!editor) return;
1190 + const refresh = () => {
1191 + editor.resize?.(true);
1192 + editor.renderer?.updateFull?.();
1193 + editor.renderer?.updateText?.();
1194 + };
1195 + if (globalThis.requestAnimationFrame) {
1196 + globalThis.requestAnimationFrame(() => globalThis.requestAnimationFrame(refresh));
1197 + } else {
1198 + globalThis.setTimeout(refresh, 0);
1199 + }
1200 + },
1201 +
1202 async save() {
1203 if (!this.session || this.saving || !this.isMarkdown()) return;
1204 + this.applyPreviewEdit({ silent: true });
1205 this.syncEditorText();
1206 this.saving = true;
1207 this.error = "";
@@ -496,6 +1238,7 @@ const model = {
1238 async saveTab(tab) {
1239 if (!tab || this.saving || !this.isMarkdown(tab)) return false;
1240 if (this.isActiveTab(tab)) {
1241 + this.applyPreviewEdit({ silent: true });
1242 this.syncEditorText();
1243 }
1244 this.saving = true;
@@ -539,6 +1282,7 @@ const model = {
1282
1283 async renameActiveFile() {
1284 if (!this.session || this.saving) return;
1285 + this.applyPreviewEdit({ silent: true });
1286 const session = this.session;
1287 const path = session.path || session.document?.path || "";
1288 if (!path) {
@@ -642,12 +1386,24 @@ const model = {
1386 },
1387
1388 undo() {
1389 + if (this.sourceEditor && this.isSourceMode()) {
1390 + this.sourceEditor.undo();
1391 + this.editorText = this.sourceEditor.getValue();
1392 + this.syncEditorText();
1393 + return;
1394 + }
1395 if (this._historyIndex <= 0) return;
1396 this._historyIndex -= 1;
1397 this.applyEditorText(this._history[this._historyIndex], true);
1398 },
1399
1400 redo() {
1401 + if (this.sourceEditor && this.isSourceMode()) {
1402 + this.sourceEditor.redo();
1403 + this.editorText = this.sourceEditor.getValue();
1404 + this.syncEditorText();
1405 + return;
1406 + }
1407 if (this._historyIndex >= this._history.length - 1) return;
1408 this._historyIndex += 1;
1409 this.applyEditorText(this._history[this._historyIndex], true);
@@ -663,6 +1419,7 @@ const model = {
1419
1420 applyEditorText(text, markDirty = false) {
1421 this.editorText = String(text || "");
1422 + this.setSourceEditorText(this.editorText);
1423 if (this.session) {
1424 this.session.text = this.editorText;
1425 this.session.dirty = markDirty || this.session.dirty;
@@ -684,6 +1441,10 @@ const model = {
1441
1442 syncEditorText() {
1443 if (!this.session) return;
1444 + if (this.previewEditing) return;
1445 + if (this.sourceEditor && this.isSourceMode()) {
1446 + this.editorText = this.sourceEditor.getValue();
1447 + }
1448 this.session.text = this.editorText;
1449 },
1450
@@ -698,6 +1459,7 @@ const model = {
1459
1460 flushInput() {
1461 if (!this.session?.session_id || !this.isMarkdown()) return;
1462 + if (this.previewEditing) return;
1463 this.syncEditorText();
1464 requestEditor("editor_input", {
1465 session_id: this.session.session_id,
@@ -707,17 +1469,22 @@ const model = {
1469
1470 format(command) {
1471 if (!this.session || !this.isMarkdown()) return;
1472 + if (this.sourceEditor && this.isSourceMode()) {
1473 + const selected = this.sourceEditor.getSelectedText();
1474 + const replacement = this.formatReplacement(command, selected);
1475 + if (replacement === selected) return;
1476 + this.sourceEditor.session.replace(this.sourceEditor.getSelectionRange(), replacement);
1477 + this.editorText = this.sourceEditor.getValue();
1478 + this.onSourceInput();
1479 + this.sourceEditor.focus();
1480 + return;
1481 + }
1482 const textarea = this._root?.querySelector?.("[data-editor-source]");
1483 if (!textarea) return;
1484 const start = textarea.selectionStart || 0;
1485 const end = textarea.selectionEnd || start;
1486 const selected = this.editorText.slice(start, end);
715 - let replacement = selected;
716 - if (command === "bold") replacement = `**${selected || "text"}**`;
717 - if (command === "italic") replacement = `*${selected || "text"}*`;
718 - if (command === "list") replacement = (selected || "item").split("\n").map((line) => `- ${line.replace(/^[-*]\s+/, "")}`).join("\n");
719 - if (command === "numbered") replacement = (selected || "item").split("\n").map((line, index) => `${index + 1}. ${line.replace(/^\d+\.\s+/, "")}`).join("\n");
720 - if (command === "table") replacement = "| Column | Value |\n| --- | --- |\n| | |";
1487 + const replacement = this.formatReplacement(command, selected);
1488 if (replacement === selected) return;
1489 this.editorText = `${this.editorText.slice(0, start)}${replacement}${this.editorText.slice(end)}`;
1490 this.onSourceInput();
@@ -728,6 +1495,15 @@ const model = {
1495 });
1496 },
1497
1498 + formatReplacement(command, selected = "") {
1499 + if (command === "bold") return `**${selected || "text"}**`;
1500 + if (command === "italic") return `*${selected || "text"}*`;
1501 + if (command === "list") return (selected || "item").split("\n").map((line) => `- ${line.replace(/^[-*]\s+/, "")}`).join("\n");
1502 + if (command === "numbered") return (selected || "item").split("\n").map((line, index) => `${index + 1}. ${line.replace(/^\d+\.\s+/, "")}`).join("\n");
1503 + if (command === "table") return "| Column | Value |\n| --- | --- |\n| | |";
1504 + return selected;
1505 + },
1506 +
1507 queueRender(options = {}) {
1508 if (options.focus) {
1509 this._pendingFocus = true;
@@ -752,6 +1528,16 @@ const model = {
1528
1529 focusEditor(options = {}) {
1530 if (!this.session || !this.isMarkdown()) return false;
1531 + if (this.sourceEditor && this.isSourceMode()) {
1532 + this.sourceEditor.focus();
1533 + if (options.end !== false) {
1534 + const session = this.sourceEditor.session;
1535 + const row = Math.max(0, session.getLength() - 1);
1536 + const column = session.getLine(row).length;
1537 + this.sourceEditor.moveCursorTo(row, column);
1538 + }
1539 + return true;
1540 + }
1541 const source = this._root?.querySelector?.("[data-editor-source]");
1542 if (!source) return false;
1543 source.focus?.({ preventScroll: true });
plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js
+62 -7
@@ -11,12 +11,18 @@ export default async function syncDocumentResultsIntoOpenOfficeModal(context) {
11
12 for (const { args } of context.results) {
13 const payload = getDocumentPayload(args);
14 - if (getToolName(payload) !== "document_artifact") continue;
14 + const toolName = getToolName(payload);
15 + if (toolName === "text_editor") {
16 + syncTextEditorMarkdownResult(args, payload);
17 + continue;
18 + }
19 + if (toolName !== "document_artifact") continue;
20 if (!shouldSyncOpenOfficeModal(args, payload)) continue;
21
22 const document = payload.document && typeof payload.document === "object" ? payload.document : {};
18 - const path = payload.path || document.path || "";
19 - const fileId = payload.file_id || document.file_id || "";
23 + const target = documentTarget(payload, document);
24 + const path = target.path || "";
25 + const fileId = target.file_id || "";
26 if (!path && !fileId) continue;
27
28 const key = [
@@ -24,24 +30,47 @@ export default async function syncDocumentResultsIntoOpenOfficeModal(context) {
30 payload.action || "",
31 fileId || "",
32 path || "",
27 - payload.version || document.version || "",
33 + target.version || "",
34 ].join(":");
35 if (syncedDocumentResults.has(key)) continue;
36 syncedDocumentResults.add(key);
37
38 if (shouldOpenDocumentUiFromResult(payload, document)) {
39 globalThis.setTimeout(() => {
34 - void openDocumentUiFromResult({ path, file_id: fileId }, payload, document);
40 + void openDocumentUiFromResult(target, payload, document);
41 }, 0);
42 continue;
43 }
44
45 globalThis.setTimeout(() => {
40 - void syncOpenDocumentSurfaces({ path, file_id: fileId });
46 + void syncOpenDocumentSurfaces(target);
47 }, 0);
48 }
49 }
50
51 +function syncTextEditorMarkdownResult(args = {}, payload = {}) {
52 + const target = textEditorTarget(payload);
53 + if (!target.path || target.extension !== "md") return;
54 + if (!shouldSyncTextEditorResult(args, payload)) return;
55 +
56 + globalThis.setTimeout(() => {
57 + void syncOpenEditorSurface(target);
58 + }, 0);
59 +}
60 +
61 +function documentTarget(payload = {}, document = {}) {
62 + const extension = documentExtension(payload, document);
63 + return {
64 + ...document,
65 + path: payload.path || document.path || "",
66 + file_id: payload.file_id || document.file_id || "",
67 + format: payload.format || document.format || extension,
68 + extension,
69 + version: payload.version || document.version || "",
70 + last_modified: payload.last_modified || document.last_modified || "",
71 + };
72 +}
73 +
74 function getDocumentPayload(args = {}) {
75 const contentPayload = parseMaybeJson(args.content);
76 const kvpsPayload = args.kvps && typeof args.kvps === "object"
@@ -72,6 +101,7 @@ function pickPayloadFields(args = {}) {
101 "path",
102 "version",
103 "last_modified",
104 + "method",
105 ]) {
106 if (args[key] != null && args[key] !== "") payload[key] = args[key];
107 }
@@ -88,6 +118,12 @@ function shouldSyncOpenOfficeModal(args = {}, payload = {}) {
118 return ["create", "open", "edit", "restore_version"].includes(action);
119 }
120
121 +function shouldSyncTextEditorResult(args = {}, payload = {}) {
122 + if (!isFresh(args.timestamp, payload.last_modified)) return false;
123 + const action = String(payload.action || payload.method || "").trim().toLowerCase().replace("-", "_");
124 + return ["write", "patch"].includes(action);
125 +}
126 +
127 function shouldOpenDocumentUiFromResult(payload = {}, document = {}) {
128 if (!isExplicitDocumentUiRequest(payload)) return false;
129 return Boolean(documentExtension(payload, document));
@@ -122,6 +158,25 @@ function documentExtension(payload = {}, document = {}) {
158 ).toLowerCase();
159 }
160
161 +function textEditorTarget(payload = {}) {
162 + const path = String(payload.path || "").trim();
163 + return {
164 + path,
165 + file_id: "",
166 + extension: extensionFromPath(path),
167 + format: extensionFromPath(path),
168 + version: "",
169 + last_modified: payload.last_modified || "",
170 + };
171 +}
172 +
173 +function extensionFromPath(path = "") {
174 + const clean = String(path || "").split("?")[0].split("#")[0];
175 + const name = clean.split("/").filter(Boolean).pop() || "";
176 + const index = name.lastIndexOf(".");
177 + return index > 0 ? name.slice(index + 1).toLowerCase() : "";
178 +}
179 +
180 function surfaceForDocument(payload = {}, document = {}) {
181 return documentExtension(payload, document) === "md" ? "editor" : "desktop";
182 }
@@ -219,7 +274,7 @@ function isDirtySameDocument(store, document = {}) {
274 return documentEntries(store).some((entry) => {
275 if (!documentsMatch(entry, document)) return false;
276 const isActive = entry === store?.session || (entry.tab_id && entry.tab_id === store?.activeTabId);
222 - return Boolean(entry.dirty || (isActive && store?.dirty));
277 + return Boolean(entry.dirty || (isActive && (store?.dirty || store?.previewEditDirty)));
278 });
279 }
280
tests/test_office_canvas_setup.py
+51
@@ -102,6 +102,8 @@ def test_right_canvas_uses_desktop_surface_id_and_migrates_legacy_office_state()
102 editor_main = read("plugins", "_editor", "webui", "main.html")
103 editor_web_panel = read("plugins", "_editor", "webui", "editor-panel.html")
104 editor_store = read("plugins", "_editor", "webui", "editor-store.js")
105 + editor_preview = read("plugins", "_editor", "webui", "editor-preview.js")
106 + safe_markdown = read("webui", "js", "safe-markdown.js")
107
108 assert 'await callJsExtensions("surfaces_register", this);' in canvas_store
109 assert 'await callJsExtensions("right_canvas_register_surfaces", this);' in canvas_store
@@ -123,12 +125,52 @@ def test_right_canvas_uses_desktop_surface_id_and_migrates_legacy_office_state()
125 assert "editor-source-editor" in editor_web_panel
126 assert "data-editor-source" in editor_web_panel
127 assert "editor-tabs" in editor_web_panel
128 + assert "editor-new-tab" in editor_web_panel
129 + assert 'aria-label="New Markdown"' in editor_web_panel
130 assert "editor-close-confirm" in editor_web_panel
131 assert "Save &amp; Close" in editor_web_panel
132 assert "Close All" in editor_web_panel
133 + assert "editor-document-header" not in editor_web_panel
134 + assert "editor-document-save-button" not in editor_web_panel
135 + assert "data-editor-ace" in editor_web_panel
136 + assert "data-editor-preview" in editor_web_panel
137 + assert "data-editor-preview-source" in editor_web_panel
138 + assert "editor-mode-toggle" in editor_web_panel
139 + assert "editor-search-bar" in editor_web_panel
140 + assert "editor-preview-title" in editor_web_panel
141 + assert "editor-preview-page-editor" in editor_web_panel
142 + assert "editor-table-wrap" in editor_web_panel
143 assert "closeAllFiles" in editor_store
144 assert "confirmPendingClose" in editor_store
145 + assert "ensureInitialMarkdownFile" in editor_store
146 + assert "await this.ensureInitialMarkdownFile();" in editor_store
147 + assert "startPreviewEdit" in editor_store
148 + assert "applyPreviewEdit" in editor_store
149 + assert "previewEditDirty" in editor_store
150 + assert "replacePageMarkdown" in editor_store
151 + assert "enhanceTaskLists" in editor_store
152 + assert "togglePreviewTask" in editor_store
153 + assert 'input[type="checkbox"]' in editor_store
154 + assert "renderEditorPreviewMarkdown" in editor_store
155 + assert "buildMarkdownPages" in editor_store
156 + assert "hydrateActiveSession" in editor_store
157 + assert "refreshSourceEditorLayout" in editor_store
158 + assert "editor.resize?.(true)" in editor_store
159 + assert "openSearch" in editor_store
160 + assert "handlePreviewClick" in editor_store
161 + assert "ace.edit" in editor_store
162 + assert "showGutter: false" in editor_store
163 assert "globalThis.confirm" not in editor_store
164 + assert ".editor-toolbar" in editor_web_panel
165 + assert "overflow: visible;" in editor_web_panel
166 + assert "z-index: 10000;" in editor_web_panel
167 + assert "renderSafeMarkdown" in editor_preview
168 + assert "prepareFootnotes" in editor_preview
169 + assert "resolveDocumentRelativePath" in editor_preview
170 + assert "slice(start, end)" in editor_preview
171 + assert "allowDataImages: true" in editor_preview
172 + assert "allowLatex: true" in editor_preview
173 + assert "html = sanitizeHtml(html, options);" in safe_markdown
174 assert "right-canvas-desktop-actions" in desktop_new_menu
175 assert "isSurfaceActive('desktop')" in desktop_new_menu
176 assert "runNewMenuAction('writer')" in desktop_new_menu
@@ -296,11 +338,20 @@ def test_document_artifacts_only_open_desktop_from_explicit_document_ui_requests
338 assert "officeStore" in auto_open
339 assert "desktopStore" in auto_open
340 assert "editorStore" in auto_open
341 + assert "store?.previewEditDirty" in auto_open
342 assert "syncOpenEditorSurface" in auto_open
343 assert "isEditorSurfaceOpen" in auto_open
344 assert "syncOpenDesktopCanvas" in auto_open
345 assert "syncOpenOfficeModal" in auto_open
346 assert "isDesktopSurfaceOpen" in auto_open
347 + assert "function documentTarget(payload = {}, document = {})" in auto_open
348 + assert "syncTextEditorMarkdownResult" in auto_open
349 + assert "textEditorTarget" in auto_open
350 + assert 'toolName === "text_editor"' in auto_open
351 + assert 'return ["write", "patch"].includes(action);' in auto_open
352 + assert "void syncOpenDocumentSurfaces(target);" in auto_open
353 + assert "void syncOpenDocumentSurfaces({ path, file_id: fileId });" not in auto_open
354 + assert "return documentExtension(payload, document) === \"md\" ? \"editor\" : \"desktop\";" in auto_open
355 assert "hasSameDocument" in auto_open
356 assert 'source: "tool-result-sync"' in auto_open
357 assert '".modal .office-panel"' not in auto_open
tests/test_office_document_store.py
+26
@@ -508,6 +508,32 @@ def test_direct_markdown_edits_refresh_open_canvas_session(office_state, monkeyp
508 assert manager._sessions[session["session_id"]].text == "# Receiver\n\nSecond"
509
510
511 +def test_refresh_open_markdown_session_reloads_external_file_edits(office_state):
512 + manager = editor_markdown_sessions.MarkdownSessionManager()
513 + doc = document_store.create_document("document", "External Refresh", "md", "First")
514 + session = manager.open(doc, context_id="ctx-a")
515 +
516 + Path(doc["path"]).write_text("# External Refresh\n\nSecond\n", encoding="utf-8")
517 + refreshed = manager.open(doc, context_id="ctx-a", refresh=True)
518 +
519 + assert refreshed["session_id"] == session["session_id"]
520 + assert refreshed["text"] == "# External Refresh\n\nSecond\n"
521 + assert manager._sessions[session["session_id"]].dirty is False
522 +
523 +
524 +def test_refresh_open_markdown_session_preserves_dirty_editor_text(office_state):
525 + manager = editor_markdown_sessions.MarkdownSessionManager()
526 + doc = document_store.create_document("document", "Dirty External Refresh", "md", "First")
527 + session = manager.open(doc, context_id="ctx-a")
528 + manager.input(session["session_id"], text="Unsaved editor text")
529 +
530 + Path(doc["path"]).write_text("External disk text\n", encoding="utf-8")
531 + refreshed = manager.open(doc, context_id="ctx-a", refresh=True)
532 +
533 + assert refreshed["text"] == "Unsaved editor text"
534 + assert manager._sessions[session["session_id"]].dirty is True
535 +
536 +
537 def test_markdown_session_rejects_office_binaries(office_state):
538 manager = editor_markdown_sessions.MarkdownSessionManager()
539 doc = document_store.create_document("document", "Desktop Only", "odt", "Native text")
webui/js/safe-markdown.js
+1 -1
@@ -190,7 +190,7 @@ export function renderSafeMarkdown(markdown, options = {}) {
190 html = rebaseGithubReadmeHtml(html, githubUrl, branch);
191 }
192
193 - html = sanitizeHtml(html);
193 + html = sanitizeHtml(html, options);
194
195 if (openExternalLinksInNewTab) {
196 html = addBlankTargetsToLinks(html);