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, "&")
211
+ .replace(/</g, "<")
212
+ .replace(/>/g, ">")
213
+ .replace(/"/g, """);
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 & 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);