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