Polish Editor and Browser surface cleanup
Remove obsolete Office markdown editor UI and handoff code now that Markdown lives in the dedicated Editor surface. Harden the Editor modal so it opens directly into a Markdown draft and rebinds Ace to the visible root when switching surfaces. Make Browser address Enter navigation explicit and update the canvas setup expectations for the slimmer Office shell.
Alessandro committed
May 15, 2026 at 12:38 UTC
70adbe91a0e6f8acbb5988dd7a672e0e5439b5e3
10 files changed
+37
-597
plugins/_browser/webui/browser-panel.html
+1
@@ -177,6 +177,7 @@
177
<span class="material-symbols-outlined browser-address-icon">language</span>
178
<input class="browser-address" aria-label="Browser address" name="browser_address" x-model="$store.browserPage.address"
179
@focus="$store.browserPage.onAddressFocus()" @blur="$store.browserPage.onAddressBlur()"
180
+ @keydown.enter.prevent="$store.browserPage.go()"
181
:disabled="$store.browserPage.isBusy()"
182
placeholder="https://example.com" autocomplete="off" />
183
</form>
plugins/_editor/extensions/webui/right_canvas_register_surfaces/register-editor.js
-6
@@ -29,12 +29,6 @@ export default async function registerEditorSurface(surfaces) {
29
beginDockHandoff() {
30
editorStore.beginSurfaceHandoff?.();
31
},
32
- finishDockHandoff() {
33
- editorStore.finishSurfaceHandoff?.();
34
- },
35
- cancelDockHandoff() {
36
- editorStore.cancelSurfaceHandoff?.();
37
- },
32
async open(payload = {}) {
33
const panel = await waitForElement('[data-surface-id="editor"] .editor-panel');
34
if (!panel) throw new Error("Editor surface panel did not mount.");
plugins/_editor/webui/editor-panel.html
+6
-12
@@ -393,6 +393,12 @@
393
}
394
395
.editor-icon-button {
396
+ display: inline-grid;
397
+ place-items: center;
398
+ width: 32px;
399
+ height: 32px;
400
+ min-width: 32px;
401
+ padding: 0;
402
border: 1px solid color-mix(in srgb, var(--color-border), transparent 12%);
403
border-radius: 8px;
404
background: color-mix(in srgb, var(--color-panel), var(--color-background) 16%);
@@ -474,9 +480,6 @@
480
overflow: hidden;
481
text-overflow: ellipsis;
482
white-space: nowrap;
477
- }
478
-
479
- .editor-tab-title {
483
font-size: 0.88rem;
484
font-weight: 600;
485
letter-spacing: 0;
@@ -594,15 +597,6 @@
597
font-size: 13px;
598
}
599
597
- .editor-icon-button {
598
- display: inline-grid;
599
- place-items: center;
600
- width: 32px;
601
- height: 32px;
602
- min-width: 32px;
603
- padding: 0;
604
- }
605
-
600
.editor-icon-button:hover:not(:disabled) {
601
border-color: color-mix(in srgb, #2c7be5, var(--color-border) 45%);
602
background: color-mix(in srgb, var(--color-panel), #2c7be5 8%);
plugins/_editor/webui/editor-preview.js
+1
-1
@@ -169,7 +169,7 @@ function prepareFootnotes(markdown = "", fullMarkdown = markdown) {
169
});
170
171
prepared += "\n\n<section class=\"editor-footnotes\" aria-label=\"Footnotes\">\n<ol>\n";
172
- for (const [index, definition] of definitions.entries()) {
172
+ for (const definition of definitions) {
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
}
plugins/_editor/webui/editor-store.js
+14
-17
@@ -219,7 +219,6 @@ const model = {
219
_pendingFocusEnd: true,
220
_focusAttempts: 0,
221
_headerCleanup: null,
222
- _surfaceHandoff: false,
222
_settingSourceEditorValue: false,
223
_sourceEditorChangeHandler: null,
224
_previewEnhanceTimer: null,
@@ -235,11 +234,18 @@ const model = {
234
235
async onMount(element = null, options = {}) {
236
await this.init();
238
- if (element) this._root = element;
237
+ if (element && element !== this._root) {
238
+ if (this.sourceEditor && !element.contains?.(this.sourceEditor.container)) {
239
+ this.destroySourceEditor();
240
+ }
241
+ this._root = element;
242
+ }
243
this._mode = options?.mode === "canvas" ? "canvas" : "modal";
240
- if (this._mode === "modal") this.setupMarkdownModal(element);
244
+ if (this._mode === "modal") {
245
+ this.setupMarkdownModal(element);
246
+ await this.ensureInitialMarkdownFile();
247
+ }
248
this.scheduleSourceEditorInit();
242
- this.queueRender();
249
},
250
251
async onOpen(payload = {}) {
@@ -272,18 +278,9 @@ const model = {
278
},
279
280
beginSurfaceHandoff() {
275
- this._surfaceHandoff = true;
281
this.flushInput();
282
},
283
279
- finishSurfaceHandoff() {
280
- this._surfaceHandoff = false;
281
- },
282
-
283
- cancelSurfaceHandoff() {
284
- this._surfaceHandoff = false;
285
- },
286
-
284
async refresh() {
285
try {
286
const status = await callEditor("status");
@@ -379,7 +376,6 @@ const model = {
376
this.previewEditDirty = false;
377
this.previewEditPageIndex = this.activePageIndex;
378
this.previewEditText = page.markdown || "";
382
- this.queueRender({ force: true, focus: false });
379
globalThis.requestAnimationFrame?.(() => {
380
const editor = this._root?.querySelector?.("[data-editor-preview-source]");
381
editor?.focus?.({ preventScroll: true });
@@ -454,7 +450,6 @@ const model = {
450
this.clampActivePage();
451
this.schedulePreviewEnhance();
452
if (!options.silent && options.message) this.setMessage(options.message);
457
- this.queueRender({ force: true, focus: false });
453
return true;
454
},
455
@@ -1131,6 +1126,9 @@ const model = {
1126
initSourceEditor() {
1127
if (!this.isSourceMode() || !this._root) return;
1128
const container = this._root.querySelector?.("[data-editor-ace]");
1129
+ if (this.sourceEditor && !this._root.contains?.(this.sourceEditor.container)) {
1130
+ this.destroySourceEditor();
1131
+ }
1132
if (!container || this.sourceEditor) return;
1133
if (!globalThis.ace?.edit) {
1134
this.aceUnavailable = true;
@@ -1359,7 +1357,6 @@ const model = {
1357
if (wasActive) this.session = next;
1358
const index = this.tabs.findIndex((tab) => tab.tab_id === (previous?.tab_id || next.tab_id));
1359
if (index >= 0) this.tabs.splice(index, 1, next);
1362
- this.queueRender();
1360
},
1361
1362
setMessage(value) {
@@ -1425,7 +1422,7 @@ const model = {
1422
this.session.dirty = markDirty || this.session.dirty;
1423
}
1424
if (markDirty) this.markDirty();
1428
- this.queueRender({ force: true, focus: true });
1425
+ this.queueRender({ focus: true });
1426
},
1427
1428
markDirty() {
plugins/_office/api/ws_office.py
+1
-1
@@ -92,5 +92,5 @@ def _public_doc(doc: dict[str, Any]) -> dict[str, Any]:
92
"size": doc["size"],
93
"version": document_store.item_version(doc),
94
"last_modified": doc["last_modified"],
95
- "exists": Path(doc["path"]).exists(),
95
+ "exists": Path(doc["path"]).exists(),
96
}
plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js
+1
-1
@@ -6,7 +6,7 @@ import { open as openSurface } from "/js/surfaces.js";
6
const SYNC_WINDOW_MS = 10 * 60 * 1000;
7
const syncedDocumentResults = new Set();
8
9
-export default async function syncDocumentResultsIntoOpenOfficeModal(context) {
9
+export default async function syncDocumentResultsIntoOpenSurfaces(context) {
10
if (!context?.results?.length || context.historyEmpty) return;
11
12
for (const { args } of context.results) {
plugins/_office/webui/office-panel.html
+3
-84
@@ -9,58 +9,13 @@
9
<template x-if="$store.office">
10
<div class="office-panel" x-create="$store.office.onMount($el, xAttrs($el) || {})" x-destroy="$store.office.cleanup()">
11
<div class="office-shell">
12
- <div class="office-document-header" x-show="$store.office.hasActiveFile()" style="display: none;">
13
- <div class="office-document-title" :title="$store.office.tabLabel($store.office.session)">
14
- <span class="material-symbols-outlined office-document-icon" aria-hidden="true" x-text="$store.office.tabIcon($store.office.session)"></span>
15
- <span class="office-document-name" x-text="$store.office.tabTitle($store.office.session)"></span>
16
- <span class="office-document-dirty" x-show="$store.office.dirty" aria-hidden="true">*</span>
17
- </div>
18
-
19
- <button
20
- type="button"
21
- class="office-icon-button office-document-save-button"
22
- title="Save"
23
- aria-label="Save"
24
- :class="{ 'is-primary': $store.office.dirty }"
25
- :disabled="$store.office.saving"
26
- @click="$store.office.save()"
27
- >
28
- <span class="material-symbols-outlined" :class="{ spinning: $store.office.saving }" x-text="$store.office.saving ? 'progress_activity' : 'save'"></span>
29
- </button>
30
-
31
- <div class="office-file-actions" x-data="{ open: false }" @click.outside="open = false" @keydown.escape.window="open = false">
32
- <button
33
- type="button"
34
- class="office-icon-button office-file-menu-button"
35
- title="File actions"
36
- aria-label="File actions"
37
- aria-haspopup="menu"
38
- :aria-expanded="open.toString()"
39
- :disabled="$store.office.saving"
40
- @click.stop="open = !open"
41
- >
42
- <span class="material-symbols-outlined">more_vert</span>
43
- </button>
44
- <div class="office-new-menu office-file-menu" role="menu" x-show="open" @click.stop>
45
- <button type="button" class="office-new-menu-item" role="menuitem" :disabled="$store.office.saving" @click="open = false; $store.office.renameActiveFile()">
46
- <span class="material-symbols-outlined" aria-hidden="true">edit</span>
47
- <span>Rename</span>
48
- </button>
49
- <button type="button" class="office-new-menu-item" role="menuitem" :disabled="$store.office.loading" @click="open = false; $store.office.closeActiveFile()">
50
- <span class="material-symbols-outlined" aria-hidden="true">close</span>
51
- <span>Close File</span>
52
- </button>
53
- </div>
54
- </div>
55
- </div>
56
-
12
<div class="office-state-line" x-show="$store.office.message || $store.office.error || $store.office.loading" style="display: none;">
13
<span class="material-symbols-outlined" :class="{ spinning: $store.office.loading }" x-text="$store.office.loading ? 'progress_activity' : ($store.office.error ? 'error' : 'check_circle')"></span>
14
<span x-text="$store.office.error || $store.office.message || 'Working'"></span>
15
</div>
16
17
<div class="office-body">
63
- <div class="office-empty" x-show="!$store.office.session && !$store.office.loading" style="display: none;">
18
+ <div class="office-empty" x-show="!$store.office.loading" style="display: none;">
19
<div class="office-empty-actions">
20
<button type="button" class="office-icon-button office-command-button" @click="$store.office.runNewMenuAction('open')">
21
<span class="material-symbols-outlined" aria-hidden="true">folder_open</span>
@@ -128,8 +83,6 @@
83
grid-template-columns: minmax(0, 1fr) auto auto;
84
}
85
131
- .office-document-header,
132
- .office-toolbar,
86
.office-state-line {
87
display: flex;
88
align-items: center;
@@ -137,26 +90,8 @@
90
border-bottom: 1px solid var(--color-border);
91
padding: 8px 10px;
92
min-width: 0;
140
- }
141
-
142
- .office-document-title {
143
- display: flex;
144
- align-items: center;
145
- gap: 8px;
146
- min-width: 0;
147
- flex: 1 1 auto;
148
- font-size: 14px;
149
- font-weight: 600;
150
- }
151
-
152
- .office-document-name {
153
- overflow: hidden;
154
- text-overflow: ellipsis;
155
- white-space: nowrap;
156
- }
157
-
158
- .office-document-dirty {
159
- color: var(--color-accent);
93
+ font-size: 13px;
94
+ color: var(--color-muted);
95
}
96
97
.office-icon-button {
@@ -182,15 +117,6 @@
117
opacity: 0.55;
118
}
119
185
- .office-document-save-button.is-primary {
186
- border-color: var(--color-accent);
187
- color: var(--color-accent);
188
- }
189
-
190
- .office-file-actions {
191
- position: relative;
192
- }
193
-
120
.office-new-menu {
121
position: absolute;
122
top: calc(100% + 6px);
@@ -230,11 +156,6 @@
156
gap: 6px;
157
}
158
233
- .office-state-line {
234
- font-size: 13px;
235
- color: var(--color-muted);
236
- }
237
-
159
.office-body {
160
display: flex;
161
flex: 1 1 auto;
@@ -279,8 +200,6 @@
200
}
201
202
@container (max-width: 560px) {
282
- .office-document-header,
283
- .office-toolbar,
203
.office-state-line {
204
padding: 7px;
205
}
plugins/_office/webui/office-store.js
+2
-470
@@ -1,15 +1,9 @@
1
import { createStore } from "/js/AlpineStore.js";
2
import { callJsonApi } from "/js/api.js";
3
-import { getNamespacedClient } from "/js/websocket.js";
3
import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
4
import { open as openSurface } from "/js/surfaces.js";
5
7
-const officeSocket = getNamespacedClient("/ws");
8
-officeSocket.addHandlers(["ws_webui"]);
9
-
6
const SAVE_MESSAGE_MS = 1800;
11
-const INPUT_PUSH_DELAY_MS = 650;
12
-const MAX_HISTORY = 80;
7
const DESKTOP_DOCUMENT_EXTENSIONS = new Set(["odt", "ods", "odp", "docx", "xlsx", "pptx"]);
8
9
function currentContextId() {
@@ -31,39 +25,6 @@ function extensionOf(path = "") {
25
return index >= 0 ? name.slice(index + 1) : "";
26
}
27
34
-function parentPath(path = "") {
35
- const normalized = String(path || "").split("?")[0].split("#")[0].replace(/\/+$/, "");
36
- const index = normalized.lastIndexOf("/");
37
- if (index <= 0) return "/";
38
- return normalized.slice(0, index);
39
-}
40
-
41
-function uniqueTabId(session = {}) {
42
- return String(session.file_id || session.session_id || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`);
43
-}
44
-
45
-function editorContainsFocus(element) {
46
- const active = document.activeElement;
47
- return Boolean(element && active && (element === active || element.contains(active)));
48
-}
49
-
50
-function placeCaretAtEnd(element) {
51
- if (!element) return;
52
- if (element.tagName === "TEXTAREA" || element.tagName === "INPUT") {
53
- const length = element.value?.length || 0;
54
- element.selectionStart = length;
55
- element.selectionEnd = length;
56
- return;
57
- }
58
- const selection = globalThis.getSelection?.();
59
- const range = document.createRange?.();
60
- if (!selection || !range) return;
61
- range.selectNodeContents(element);
62
- range.collapse(false);
63
- selection.removeAllRanges();
64
- selection.addRange(range);
65
-}
66
-
28
function normalizeDocument(doc = {}) {
29
const path = doc.path || "";
30
const extension = String(doc.extension || extensionOf(path)).toLowerCase();
@@ -76,21 +37,6 @@ function normalizeDocument(doc = {}) {
37
};
38
}
39
79
-function normalizeSession(payload = {}) {
80
- const document = normalizeDocument(payload.document || payload);
81
- return {
82
- ...payload,
83
- document,
84
- extension: String(payload.extension || document.extension || "").toLowerCase(),
85
- file_id: payload.file_id || document.file_id || "",
86
- path: document.path || payload.path || "",
87
- title: payload.title || document.title || document.basename || basename(document.path),
88
- tab_id: uniqueTabId(payload),
89
- text: String(payload.text || ""),
90
- dirty: false,
91
- };
92
-}
93
-
40
function documentLabel(document = {}) {
41
return document.title || document.basename || basename(document.path);
42
}
@@ -103,56 +49,15 @@ async function callOffice(action, payload = {}) {
49
});
50
}
51
106
-async function requestOffice(eventType, payload = {}, timeoutMs = 5000) {
107
- const response = await officeSocket.request(eventType, {
108
- ctxid: currentContextId(),
109
- ...payload,
110
- }, { timeoutMs });
111
- const results = Array.isArray(response?.results) ? response.results : [];
112
- const first = results.find((item) => item?.ok === true && isOfficeSocketData(item?.data))
113
- || results.find((item) => item?.ok === true);
114
- if (!first) {
115
- const error = results.find((item) => item?.error)?.error;
116
- throw new Error(error?.error || error?.code || `${eventType} failed`);
117
- }
118
- if (first.data?.office_error) {
119
- const error = first.data.office_error;
120
- throw new Error(error.error || error.code || `${eventType} failed`);
121
- }
122
- return first.data || {};
123
-}
124
-
125
-function isOfficeSocketData(data) {
126
- if (!data || typeof data !== "object") return false;
127
- return (
128
- Object.prototype.hasOwnProperty.call(data, "office_error")
129
- || Object.prototype.hasOwnProperty.call(data, "ok")
130
- || Object.prototype.hasOwnProperty.call(data, "session_id")
131
- || Object.prototype.hasOwnProperty.call(data, "document")
132
- );
133
-}
134
-
52
const model = {
53
status: null,
137
- tabs: [],
138
- activeTabId: "",
139
- session: null,
54
loading: false,
141
- saving: false,
142
- dirty: false,
55
error: "",
56
message: "",
145
- editorText: "",
57
_root: null,
58
_mode: "modal",
59
_initialized: false,
60
_saveMessageTimer: null,
150
- _inputTimer: null,
151
- _history: [],
152
- _historyIndex: -1,
153
- _pendingFocus: false,
154
- _pendingFocusEnd: true,
155
- _focusAttempts: 0,
61
_headerCleanup: null,
62
63
async init() {
@@ -166,7 +71,6 @@ const model = {
71
if (element) this._root = element;
72
this._mode = options?.mode === "canvas" ? "canvas" : "modal";
73
if (this._mode === "modal") this.setupDocumentModal(element);
169
- this.queueRender();
74
},
75
76
async onOpen(payload = {}) {
@@ -182,12 +86,7 @@ const model = {
86
}
87
},
88
185
- beforeHostHidden() {
186
- this.flushInput();
187
- },
188
-
89
cleanup() {
190
- this.flushInput();
90
this._headerCleanup?.();
91
this._headerCleanup = null;
92
if (this._mode === "modal") this._root = null;
@@ -261,10 +160,8 @@ const model = {
160
await this.refresh();
161
return response;
162
}
264
- const session = normalizeSession(response);
265
- this.installSession(session);
163
await this.refresh();
267
- return session;
164
+ return response;
165
} catch (error) {
166
this.error = error instanceof Error ? error.message : String(error);
167
return null;
@@ -273,190 +170,6 @@ const model = {
170
}
171
},
172
276
- installSession(session) {
277
- const existingIndex = this.tabs.findIndex((tab) => (
278
- (session.file_id && tab.file_id === session.file_id)
279
- || (session.path && tab.path === session.path)
280
- ));
281
- if (existingIndex >= 0) {
282
- this.tabs.splice(existingIndex, 1, { ...this.tabs[existingIndex], ...session, tab_id: this.tabs[existingIndex].tab_id });
283
- this.activeTabId = this.tabs[existingIndex].tab_id;
284
- } else {
285
- this.tabs.push(session);
286
- this.activeTabId = session.tab_id;
287
- }
288
- this.selectTab(this.activeTabId);
289
- },
290
-
291
- selectTab(tabId, options = {}) {
292
- const tab = this.tabs.find((item) => item.tab_id === tabId) || this.tabs[0] || null;
293
- this.session = tab;
294
- this.activeTabId = tab?.tab_id || "";
295
- this.editorText = String(tab?.text || "");
296
- this.dirty = Boolean(tab?.dirty);
297
- this.resetHistory(this.editorText);
298
- this.queueRender({ focus: Boolean(tab) && options.focus !== false });
299
- },
300
-
301
- ensureActiveTab() {
302
- if (this.session && this.tabs.some((tab) => tab.tab_id === this.session.tab_id)) return;
303
- if (this.tabs.length) this.selectTab(this.tabs[0].tab_id, { focus: false });
304
- },
305
-
306
- isActiveTab(tab) {
307
- return Boolean(tab && tab.tab_id === this.activeTabId);
308
- },
309
-
310
- async closeTab(tabId) {
311
- const tab = this.tabs.find((item) => item.tab_id === tabId);
312
- if (!tab) return;
313
- if (tab.dirty || (this.isActiveTab(tab) && this.dirty)) {
314
- const shouldSave = globalThis.confirm?.("Save changes?") ?? true;
315
- if (shouldSave) await this.save();
316
- }
317
- try {
318
- if (tab.session_id) {
319
- await requestOffice("office_close", { session_id: tab.session_id }, 2500).catch(() => null);
320
- }
321
- await callOffice("close", {
322
- session_id: tab.store_session_id || "",
323
- file_id: tab.file_id || "",
324
- });
325
- } catch (error) {
326
- console.warn("Document close skipped", error);
327
- }
328
- this.tabs = this.tabs.filter((item) => item.tab_id !== tabId);
329
- if (this.activeTabId === tabId) {
330
- this.session = null;
331
- this.activeTabId = "";
332
- this.editorText = "";
333
- this.dirty = false;
334
- this.ensureActiveTab();
335
- }
336
- this.ensureActiveTab();
337
- await this.refresh();
338
- },
339
-
340
- async closeActiveFile() {
341
- if (!this.session || this.loading) return;
342
- await this.closeTab(this.session.tab_id);
343
- },
344
-
345
- async save() {
346
- if (!this.session || this.saving || !this.isMarkdown()) return;
347
- this.syncEditorText();
348
- this.saving = true;
349
- this.error = "";
350
- try {
351
- let response;
352
- const payload = { session_id: this.session.session_id, text: this.editorText };
353
- try {
354
- response = await requestOffice("office_save", payload, 10000);
355
- } catch (_socketError) {
356
- response = await callOffice("save", payload);
357
- }
358
- if (response?.ok === false) throw new Error(response.error || "Save failed.");
359
- const document = normalizeDocument(response.document || this.session.document || {});
360
- const updated = {
361
- ...this.session,
362
- text: this.editorText,
363
- dirty: false,
364
- document,
365
- path: document.path || this.session.path,
366
- file_id: document.file_id || this.session.file_id,
367
- version: document.version || response.version || this.session.version,
368
- };
369
- this.replaceActiveSession(updated);
370
- this.dirty = false;
371
- this.setMessage("Saved");
372
- await this.refresh();
373
- } catch (error) {
374
- this.error = error instanceof Error ? error.message : String(error);
375
- } finally {
376
- this.saving = false;
377
- }
378
- },
379
-
380
- async renameActiveFile() {
381
- if (!this.session || this.saving) return;
382
- const session = this.session;
383
- const path = session.path || session.document?.path || "";
384
- if (!path) {
385
- this.error = "This document does not have a file path to rename.";
386
- return;
387
- }
388
- const name = basename(path || session.title || "");
389
- const extension = extensionOf(name);
390
- await fileBrowserStore.openRenameModal(
391
- {
392
- name,
393
- path,
394
- is_dir: false,
395
- size: session.document?.size || 0,
396
- modified: session.document?.last_modified || "",
397
- type: "document",
398
- },
399
- {
400
- currentPath: parentPath(path),
401
- validateName: (newName) => {
402
- if (!extension) return true;
403
- return extensionOf(newName) === extension || `Keep the .${extension} extension for this open document.`;
404
- },
405
- performRename: async ({ path: renamedPath }) => {
406
- const payload = {
407
- file_id: session.file_id || "",
408
- path: renamedPath,
409
- };
410
- if (this.isMarkdown(session)) {
411
- this.syncEditorText();
412
- payload.text = this.session?.tab_id === session.tab_id ? this.editorText : session.text || "";
413
- }
414
- return await callOffice("renamed", payload);
415
- },
416
- onRenamed: async ({ path: renamedPath, response }) => {
417
- await this.handleActiveFileRenamed(session, renamedPath, response);
418
- },
419
- },
420
- );
421
- },
422
-
423
- async handleActiveFileRenamed(session, renamedPath, renameResponse = null) {
424
- const response = renameResponse || await callOffice("renamed", {
425
- file_id: session.file_id || "",
426
- path: renamedPath,
427
- });
428
- if (response?.ok === false) throw new Error(response.error || "Rename failed.");
429
-
430
- const document = normalizeDocument(response.document || session.document || {});
431
- const updated = {
432
- ...session,
433
- document,
434
- title: document.title || document.basename || basename(document.path),
435
- path: document.path || renamedPath,
436
- extension: document.extension || session.extension,
437
- file_id: document.file_id || session.file_id,
438
- version: document.version || response.version || session.version,
439
- text: this.session?.tab_id === session.tab_id ? this.editorText : session.text,
440
- dirty: false,
441
- };
442
- this.replaceSession(session, updated);
443
- this.dirty = false;
444
- this.setMessage("Renamed");
445
- await this.refresh();
446
- },
447
-
448
- replaceActiveSession(next) {
449
- if (!this.session) return;
450
- this.replaceSession(this.session, next);
451
- },
452
-
453
- replaceSession(previous, next) {
454
- this.session = next;
455
- const index = this.tabs.findIndex((tab) => tab.tab_id === (previous?.tab_id || next.tab_id));
456
- if (index >= 0) this.tabs.splice(index, 1, next);
457
- this.queueRender();
458
- },
459
-
173
setMessage(value) {
174
this.message = value;
175
if (this._saveMessageTimer) globalThis.clearTimeout(this._saveMessageTimer);
@@ -466,157 +179,11 @@ const model = {
179
}, SAVE_MESSAGE_MS);
180
},
181
469
- resetHistory(text) {
470
- this._history = [String(text || "")];
471
- this._historyIndex = 0;
472
- },
473
-
474
- pushHistory(text) {
475
- const value = String(text || "");
476
- if (this._history[this._historyIndex] === value) return;
477
- this._history = this._history.slice(0, this._historyIndex + 1);
478
- this._history.push(value);
479
- if (this._history.length > MAX_HISTORY) this._history.shift();
480
- this._historyIndex = this._history.length - 1;
481
- },
482
-
483
- undo() {
484
- if (this._historyIndex <= 0) return;
485
- this._historyIndex -= 1;
486
- this.applyEditorText(this._history[this._historyIndex], true);
487
- },
488
-
489
- redo() {
490
- if (this._historyIndex >= this._history.length - 1) return;
491
- this._historyIndex += 1;
492
- this.applyEditorText(this._history[this._historyIndex], true);
493
- },
494
-
495
- canUndo() {
496
- return this._historyIndex > 0;
497
- },
498
-
499
- canRedo() {
500
- return this._historyIndex < this._history.length - 1;
501
- },
502
-
503
- applyEditorText(text, markDirty = false) {
504
- this.editorText = String(text || "");
505
- if (this.session) {
506
- this.session.text = this.editorText;
507
- this.session.dirty = markDirty || this.session.dirty;
508
- }
509
- if (markDirty) this.markDirty();
510
- this.queueRender({ force: true, focus: true });
511
- },
512
-
513
- markDirty() {
514
- this.dirty = true;
515
- if (this.session) this.session.dirty = true;
516
- },
517
-
518
- onSourceInput() {
519
- this.markDirty();
520
- this.pushHistory(this.editorText);
521
- this.scheduleInputPush();
522
- },
523
-
524
- syncEditorText() {
525
- if (!this.session) return;
526
- this.session.text = this.editorText;
527
- },
528
-
529
- scheduleInputPush() {
530
- if (!this.session?.session_id || !this.isMarkdown()) return;
531
- if (this._inputTimer) globalThis.clearTimeout(this._inputTimer);
532
- this._inputTimer = globalThis.setTimeout(() => {
533
- this._inputTimer = null;
534
- this.flushInput();
535
- }, INPUT_PUSH_DELAY_MS);
536
- },
537
-
538
- flushInput() {
539
- if (!this.session?.session_id || !this.isMarkdown()) return;
540
- this.syncEditorText();
541
- requestOffice("office_input", {
542
- session_id: this.session.session_id,
543
- text: this.editorText,
544
- }, 3000).catch(() => {});
545
- },
546
-
547
- format(command) {
548
- if (!this.session || !this.isMarkdown()) return;
549
- const textarea = this._root?.querySelector?.("[data-office-source]");
550
- if (!textarea) return;
551
- const start = textarea.selectionStart || 0;
552
- const end = textarea.selectionEnd || start;
553
- const selected = this.editorText.slice(start, end);
554
- let replacement = selected;
555
- if (command === "bold") replacement = `**${selected || "text"}**`;
556
- if (command === "italic") replacement = `*${selected || "text"}*`;
557
- if (command === "list") replacement = (selected || "item").split("\n").map((line) => `- ${line.replace(/^[-*]\s+/, "")}`).join("\n");
558
- if (command === "numbered") replacement = (selected || "item").split("\n").map((line, index) => `${index + 1}. ${line.replace(/^\d+\.\s+/, "")}`).join("\n");
559
- if (command === "table") replacement = "| Column | Value |\n| --- | --- |\n| | |";
560
- if (replacement === selected) return;
561
- this.editorText = `${this.editorText.slice(0, start)}${replacement}${this.editorText.slice(end)}`;
562
- this.onSourceInput();
563
- globalThis.requestAnimationFrame?.(() => {
564
- textarea.focus();
565
- textarea.selectionStart = start;
566
- textarea.selectionEnd = start + replacement.length;
567
- });
568
- },
569
-
570
- queueRender(options = {}) {
571
- if (options.focus) {
572
- this._pendingFocus = true;
573
- this._pendingFocusEnd = options.end !== false;
574
- this._focusAttempts = 0;
575
- }
576
- const render = () => {
577
- if (this._pendingFocus && this.focusEditor({ end: this._pendingFocusEnd })) {
578
- this._pendingFocus = false;
579
- this._focusAttempts = 0;
580
- } else if (this._pendingFocus && this._focusAttempts < 6) {
581
- this._focusAttempts += 1;
582
- globalThis.setTimeout(render, 45);
583
- }
584
- };
585
- if (globalThis.requestAnimationFrame) {
586
- globalThis.requestAnimationFrame(render);
587
- } else {
588
- globalThis.setTimeout(render, 0);
589
- }
590
- },
591
-
592
- focusEditor(options = {}) {
593
- if (!this.session || !this.isMarkdown()) return false;
594
- const source = this._root?.querySelector?.("[data-office-source]");
595
- if (!source) return false;
596
- source.focus?.({ preventScroll: true });
597
- if (!editorContainsFocus(source)) return false;
598
- if (options.end !== false) placeCaretAtEnd(source);
599
- return true;
600
- },
601
-
602
- isMarkdown(tab = this.session) {
603
- const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase();
604
- return ext === "md";
605
- },
606
-
607
- isDesktopDocument(tab = this.session) {
182
+ isDesktopDocument(tab = {}) {
183
const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase();
184
return DESKTOP_DOCUMENT_EXTENSIONS.has(ext);
185
},
186
612
- hasActiveFile(tab = this.session) {
613
- return Boolean(tab && this.isMarkdown(tab));
614
- },
615
-
616
- visibleTabs() {
617
- return this.tabs.filter((tab) => this.hasActiveFile(tab));
618
- },
619
-
187
defaultTitle(kind, fmt) {
188
const date = new Date().toISOString().slice(0, 10);
189
if (fmt === "md") return `Document ${date}`;
@@ -627,44 +194,9 @@ const model = {
194
return `Document ${date}`;
195
},
196
630
- tabTitle(tab = {}) {
631
- tab = tab || {};
632
- return tab.title || tab.document?.basename || basename(tab.path);
633
- },
634
-
635
- tabLabel(tab = {}) {
636
- tab = tab || {};
637
- const title = this.tabTitle(tab);
638
- return tab.dirty ? `${title} unsaved` : title;
639
- },
640
-
641
- tabIcon(tab = {}) {
642
- tab = tab || {};
643
- const ext = String(tab.extension || tab.document?.extension || "").toLowerCase();
644
- if (ext === "md") return "article";
645
- if (ext === "odt" || ext === "docx") return "description";
646
- if (ext === "ods" || ext === "xlsx") return "table_chart";
647
- if (ext === "odp" || ext === "pptx") return "co_present";
648
- return "draft";
649
- },
650
-
651
- async openActiveInDesktop() {
652
- const target = this.session?.document || this.session;
653
- if (!target?.path && !target?.file_id) return;
654
- await openSurface("desktop", {
655
- path: target.path || "",
656
- file_id: target.file_id || "",
657
- refresh: true,
658
- source: "office-explicit-action",
659
- });
660
- },
661
-
197
async runNewMenuAction(action = "") {
198
const normalized = String(action || "").trim().toLowerCase();
199
if (normalized === "open") return await this.openFileBrowser();
665
- if (normalized === "markdown") {
666
- return await openSurface("editor", { source: "office-editor-handoff" });
667
- }
200
if (normalized === "writer") return await this.create("document", "odt");
201
if (normalized === "spreadsheet") return await this.create("spreadsheet", "ods");
202
if (normalized === "presentation") return await this.create("presentation", "odp");
tests/test_office_canvas_setup.py
+8
-5
@@ -222,13 +222,16 @@ def test_office_frontend_is_document_only_and_does_not_import_browser_or_desktop
222
223
assert "office-source-editor" not in office_panel
224
assert "data-office-source" not in office_panel
225
+ assert "office-document-header" not in office_panel
226
assert "runNewMenuAction('markdown')" not in office_panel
227
assert 'data-office-new-action="markdown"' not in office_store
227
- assert "openRenameModal" in office_store
228
- assert "office_save" in office_store
229
- assert 'callOffice("renamed"' in office_store
228
+ assert "openRenameModal" not in office_store
229
+ assert "office_save" not in office_store
230
+ assert 'callOffice("renamed"' not in office_store
231
+ assert "data-office-source" not in office_store
232
+ assert "office_input" not in office_store
233
assert "requires_desktop" in office_store
231
- assert "openSurface(\"desktop\"" in office_store
234
+ assert "openSurface(\"desktop\"" not in office_store
235
236
237
def test_desktop_plugin_owns_routes_runtime_surface_and_state_paths():
@@ -333,7 +336,7 @@ def test_document_artifacts_only_open_desktop_from_explicit_document_ui_requests
336
assert 'surfaces.open("desktop"' not in auto_open
337
assert "rightCanvas.open" not in auto_open
338
assert "globalThis.Alpine" not in auto_open
336
- assert "syncDocumentResultsIntoOpenOfficeModal" in auto_open
339
+ assert "syncDocumentResultsIntoOpenSurfaces" in auto_open
340
assert "isOfficeCanvas" not in auto_open
341
assert "officeStore" in auto_open
342
assert "desktopStore" in auto_open