main
js 2,729 lines 99 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import { callJsonApi } from "/js/api.js";
3 import { getNamespacedClient } from "/js/websocket.js";
4 import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
5 import { handleUrlIntent, placeSurfaceModalHeaderAction } from "/js/surfaces.js";
6
7 const officeSocket = getNamespacedClient("/ws");
8 officeSocket.addHandlers(["ws_webui"]);
9
10 const SAVE_MESSAGE_MS = 1800;
11 const INPUT_PUSH_DELAY_MS = 650;
12 const DESKTOP_HEARTBEAT_MS = 3500;
13 const DESKTOP_RESIZE_DELAY_MS = 80;
14 const DESKTOP_START_MESSAGE = "Starting Agent Zero Desktop environment";
15 const DESKTOP_RUNTIME_INSTALL_MESSAGE = "Installing Agent Zero Desktop runtime dependencies. This can take a few minutes after an update.";
16 const DESKTOP_RUNTIME_INSTALL_POLL_MS = 4000;
17 const DESKTOP_RUNTIME_INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
18 const XPRA_DESKTOP_PRIME_INTERVAL_MS = 220;
19 const XPRA_DESKTOP_PRIME_ATTEMPTS = 120;
20 const SYSTEM_DESKTOP_FILE_ID = "system-desktop";
21 const URL_INTENT_PANEL_TIMEOUT_MS = 5000;
22 const DESKTOP_SHUTDOWN_STORAGE_KEY = "a0.desktop.shutdown";
23 const MAX_HISTORY = 80;
24
25 function currentContextId() {
26 try {
27 return globalThis.getContext?.() || "";
28 } catch {
29 return "";
30 }
31 }
32
33 function basename(path = "") {
34 const value = String(path || "").split("?")[0].split("#")[0];
35 return value.split("/").filter(Boolean).pop() || "Untitled";
36 }
37
38 function extensionOf(path = "") {
39 const name = basename(path).toLowerCase();
40 const index = name.lastIndexOf(".");
41 return index >= 0 ? name.slice(index + 1) : "";
42 }
43
44 function isOfficialExtension(extension = "") {
45 return ["odt", "ods", "odp", "docx", "xlsx", "pptx", "txt"].includes(String(extension || "").toLowerCase());
46 }
47
48 function parentPath(path = "") {
49 const normalized = String(path || "").split("?")[0].split("#")[0].replace(/\/+$/, "");
50 const index = normalized.lastIndexOf("/");
51 if (index <= 0) return "/";
52 return normalized.slice(0, index);
53 }
54
55 function uniqueTabId(session = {}) {
56 return String(session.file_id || session.session_id || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`);
57 }
58
59 function editorContainsFocus(element) {
60 const active = document.activeElement;
61 return Boolean(element && active && (element === active || element.contains(active)));
62 }
63
64 function isEditableInputTarget(target) {
65 const element = target?.nodeType === 1 ? target : target?.parentElement;
66 const editable = element?.closest?.("input, textarea, select, [contenteditable='true'], [contenteditable=''], [role='textbox']");
67 if (!editable) return false;
68 if (editable.tagName !== "INPUT") return true;
69 const type = String(editable.getAttribute("type") || "text").toLowerCase();
70 return !["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"].includes(type);
71 }
72
73 function normalizeModalPath(path = "") {
74 return String(path || "").replace(/^\/+/, "");
75 }
76
77 function isModalPathOpen(path = "") {
78 const normalized = normalizeModalPath(path);
79 return Boolean(
80 globalThis.isModalOpen?.(path)
81 || globalThis.isModalOpen?.(`/${normalized}`)
82 || globalThis.isModalOpen?.(normalized)
83 );
84 }
85
86 function waitForElementByPredicate(predicate, timeoutMs = URL_INTENT_PANEL_TIMEOUT_MS) {
87 const found = predicate();
88 if (found) return Promise.resolve(found);
89 return new Promise((resolve) => {
90 const timeout = globalThis.setTimeout(() => {
91 observer.disconnect();
92 resolve(predicate());
93 }, timeoutMs);
94 const observer = new MutationObserver(() => {
95 const element = predicate();
96 if (!element) return;
97 globalThis.clearTimeout(timeout);
98 observer.disconnect();
99 resolve(element);
100 });
101 observer.observe(document.body, { childList: true, subtree: true });
102 });
103 }
104
105 function browserPanelForMode(mode = "modal") {
106 const panels = Array.from(document.querySelectorAll(".browser-panel"));
107 if (mode === "canvas") {
108 return panels.find((panel) => panel.closest?.('[data-surface-id="browser"]')) || null;
109 }
110 return panels.find((panel) => panel.closest?.(".modal")) || null;
111 }
112
113 function placeCaretAtEnd(element) {
114 if (!element) return;
115 if (element.tagName === "TEXTAREA" || element.tagName === "INPUT") {
116 const length = element.value?.length || 0;
117 element.selectionStart = length;
118 element.selectionEnd = length;
119 return;
120 }
121 const selection = globalThis.getSelection?.();
122 const range = document.createRange?.();
123 if (!selection || !range) return;
124 range.selectNodeContents(element);
125 range.collapse(false);
126 selection.removeAllRanges();
127 selection.addRange(range);
128 }
129
130 function sleep(ms) {
131 return new Promise((resolve) => globalThis.setTimeout(resolve, ms));
132 }
133
134 function normalizeDocument(doc = {}) {
135 const path = doc.path || "";
136 const extension = String(doc.extension || extensionOf(path)).toLowerCase();
137 return {
138 ...doc,
139 extension,
140 title: doc.title || doc.basename || basename(path),
141 basename: doc.basename || basename(path),
142 path,
143 };
144 }
145
146 function normalizeSession(payload = {}) {
147 const document = normalizeDocument(payload.document || payload);
148 const extension = String(payload.extension || document.extension || "").toLowerCase();
149 return {
150 ...payload,
151 document,
152 extension,
153 file_id: payload.file_id || document.file_id || "",
154 path: document.path || payload.path || "",
155 title: payload.title || document.title || document.basename || basename(document.path),
156 tab_id: uniqueTabId(payload),
157 text: String(payload.text || ""),
158 desktop: payload.desktop || null,
159 desktop_session_id: payload.desktop_session_id || payload.desktop?.session_id || "",
160 dirty: false,
161 };
162 }
163
164 async function callOffice(action, payload = {}) {
165 return await callJsonApi("/plugins/_office/office_session", {
166 action,
167 ctxid: currentContextId(),
168 ...payload,
169 });
170 }
171
172 async function callDesktop(action, payload = {}) {
173 return await callJsonApi("/plugins/_desktop/desktop_session", {
174 action,
175 ctxid: currentContextId(),
176 ...payload,
177 });
178 }
179
180 async function requestOffice(eventType, payload = {}, timeoutMs = 5000) {
181 const response = await officeSocket.request(eventType, {
182 ctxid: currentContextId(),
183 ...payload,
184 }, { timeoutMs });
185 const results = Array.isArray(response?.results) ? response.results : [];
186 const first = results.find((item) => item?.ok === true && isOfficeSocketData(item?.data))
187 || results.find((item) => item?.ok === true);
188 if (!first) {
189 const error = results.find((item) => item?.error)?.error;
190 throw new Error(error?.error || error?.code || `${eventType} failed`);
191 }
192 if (first.data?.office_error) {
193 const error = first.data.office_error;
194 throw new Error(error.error || error.code || `${eventType} failed`);
195 }
196 return first.data || {};
197 }
198
199 function isOfficeSocketData(data) {
200 if (!data || typeof data !== "object") return false;
201 return (
202 Object.prototype.hasOwnProperty.call(data, "office_error")
203 || Object.prototype.hasOwnProperty.call(data, "ok")
204 || Object.prototype.hasOwnProperty.call(data, "session_id")
205 || Object.prototype.hasOwnProperty.call(data, "document")
206 || Object.prototype.hasOwnProperty.call(data, "desktop")
207 || Object.prototype.hasOwnProperty.call(data, "closed")
208 );
209 }
210
211 const model = {
212 status: null,
213 tabs: [],
214 activeTabId: "",
215 session: null,
216 loading: false,
217 saving: false,
218 dirty: false,
219 error: "",
220 message: "",
221 editorText: "",
222 _root: null,
223 _mode: "canvas",
224 _saveMessageTimer: null,
225 _inputTimer: null,
226 _history: [],
227 _historyIndex: -1,
228 _pendingFocus: false,
229 _pendingFocusEnd: true,
230 _focusAttempts: 0,
231 _floatingCleanup: null,
232 _desktopHeartbeatTimer: null,
233 _desktopHeartbeatSessionId: "",
234 _desktopHeartbeatTabId: "",
235 _desktopHeartbeatMisses: 0,
236 _desktopResizeCleanup: null,
237 _desktopResizeTarget: null,
238 _desktopResizeTimer: null,
239 _desktopResizeKey: "",
240 _desktopResizePendingKey: "",
241 _desktopResizeSuspended: false,
242 _desktopResizePending: false,
243 _desktopViewportSyncTimers: [],
244 _desktopHostVisible: false,
245 _desktopPrimeTimer: null,
246 _desktopPrimeAttempts: 0,
247 _desktopKeyboardActive: false,
248 _desktopFocusInProgress: false,
249 _desktopBridgeReady: false,
250 _desktopKeyboardCaptureState: { ready: false, active: false, capture: false, focused: false },
251 _desktopLastState: null,
252 _desktopKeyboardCleanup: null,
253 _desktopClipboardCleanup: null,
254 _desktopStarting: null,
255 _desktopUrlIntentBusy: false,
256 _desktopUrlIntentQueue: [],
257 _desktopFrame: null,
258 _desktopFrameHost: null,
259 _desktopFrameLoadHandler: null,
260 _desktopKeepaliveHost: null,
261 _desktopDisplaySizes: {},
262 _desktopIntentionalShutdown: false,
263
264 async init(element = null) {
265 this.restoreDesktopShutdownState();
266 return await this.onMount(element, { mode: "canvas" });
267 },
268
269 async onMount(element = null, options = {}) {
270 if (element) this._root = element;
271 this._mode = options?.mode === "modal" ? "modal" : "canvas";
272 if (this._mode === "modal") {
273 this._desktopHostVisible = true;
274 this.setupFloatingModal(element);
275 await this.onOpen({ source: "modal" });
276 return;
277 }
278 this.queueRender();
279 },
280
281 async onOpen(payload = {}) {
282 this.restoreDesktopShutdownState();
283 await this.refresh();
284 if (payload?.path || payload?.file_id) {
285 await this.openSession({
286 path: payload.path || "",
287 file_id: payload.file_id || "",
288 refresh: payload.refresh === true,
289 source: payload.source || "",
290 });
291 } else if (this._desktopIntentionalShutdown) {
292 this.session = null;
293 this.activeTabId = "";
294 this.editorText = "";
295 this.dirty = false;
296 } else {
297 await this.ensureDesktopSession({ select: !this.session });
298 }
299 this.restoreDesktopFrames();
300 this.requestDesktopViewportSync({ force: true });
301 },
302
303 beforeHostHidden(options = {}) {
304 this._desktopHostVisible = false;
305 this.flushInput();
306 this.clearDesktopViewportSyncTimers();
307 this.stopDesktopMonitor();
308 this.stopDesktopKeyboardBridge();
309 this.stopDesktopClipboardBridge();
310 this.unloadDesktopFrames();
311 },
312
313 cleanup() {
314 const wasModal = this._mode === "modal";
315 this.flushInput();
316 this.stopDesktopMonitor();
317 this.stopDesktopResizeObserver();
318 this.clearDesktopViewportSyncTimers();
319 this.stopXpraDesktopPrime();
320 this.stopDesktopKeyboardBridge();
321 this.stopDesktopClipboardBridge();
322 if (!this._desktopIntentionalShutdown) this.moveDesktopFrameToKeepalive();
323 this._floatingCleanup?.();
324 this._floatingCleanup = null;
325 if (wasModal) {
326 this._root = null;
327 this._mode = "canvas";
328 this._desktopHostVisible = false;
329 }
330 },
331
332 async refresh() {
333 try {
334 const status = await callDesktop("status");
335 this.status = status || {};
336 this.error = "";
337 } catch (error) {
338 this.error = error instanceof Error ? error.message : String(error);
339 }
340 },
341
342 restoreDesktopShutdownState() {
343 try {
344 this._desktopIntentionalShutdown = localStorage.getItem(DESKTOP_SHUTDOWN_STORAGE_KEY) === "1";
345 } catch {
346 this._desktopIntentionalShutdown = Boolean(this._desktopIntentionalShutdown);
347 }
348 },
349
350 persistDesktopShutdownState() {
351 try {
352 if (this._desktopIntentionalShutdown) {
353 localStorage.setItem(DESKTOP_SHUTDOWN_STORAGE_KEY, "1");
354 } else {
355 localStorage.removeItem(DESKTOP_SHUTDOWN_STORAGE_KEY);
356 }
357 } catch {
358 // Shutdown state is still correct for this page even without storage.
359 }
360 },
361
362 setDesktopIntentionalShutdown(value) {
363 this._desktopIntentionalShutdown = Boolean(value);
364 this.persistDesktopShutdownState();
365 },
366
367 isDesktopShutdown() {
368 return Boolean(this._desktopIntentionalShutdown);
369 },
370
371 shouldShowDesktopEmptyState() {
372 return Boolean(this._desktopIntentionalShutdown && !this.session);
373 },
374
375 async restartDesktopSession() {
376 this.error = "";
377 const session = await this.ensureDesktopSession({
378 force: true,
379 restart: true,
380 select: true,
381 message: "Restarting Agent Zero Desktop environment",
382 });
383 if (!session) {
384 this.setDesktopIntentionalShutdown(true);
385 return null;
386 }
387 this.restoreDesktopFrames();
388 this.requestDesktopViewportSync({ force: true });
389 return session;
390 },
391
392 async shutdownDesktop(options = {}) {
393 this.loading = options.progress !== false;
394 this.message = this.loading ? "Shutting down Desktop" : this.message;
395 this.error = "";
396 try {
397 const response = await callDesktop("shutdown", {
398 save_first: options.saveFirst !== false,
399 source: options.source || "ui",
400 });
401 await this.handleIntentionalDesktopShutdown(response);
402 return response;
403 } catch (error) {
404 this.error = error instanceof Error ? error.message : String(error);
405 return null;
406 } finally {
407 if (options.progress !== false) {
408 this.loading = false;
409 if (this.message === "Shutting down Desktop") this.message = "";
410 }
411 }
412 },
413
414 async handleIntentionalDesktopShutdown(response = {}) {
415 this.setDesktopIntentionalShutdown(true);
416 this.stopDesktopMonitor();
417 this.stopDesktopResizeObserver();
418 this.clearDesktopViewportSyncTimers();
419 this.stopXpraDesktopPrime();
420 this.stopDesktopKeyboardBridge();
421 this.stopDesktopClipboardBridge();
422 this.destroyDesktopFrame();
423 const activeTabId = this.activeTabId;
424 this.tabs = this.tabs.filter((tab) => !this.isDesktopSession(tab) && !this.hasOfficialOffice(tab));
425 if (!this.tabs.some((tab) => tab.tab_id === activeTabId)) {
426 this.session = null;
427 this.activeTabId = "";
428 this.editorText = "";
429 this.dirty = false;
430 this.resetHistory("");
431 }
432 this._desktopStarting = null;
433 this._desktopHeartbeatMisses = 0;
434 this.message = response?.source === "tray" ? "Desktop shut down from system tray" : "Desktop is shut down";
435 await this.refresh();
436 },
437
438 async ensureDesktopSession(options = {}) {
439 if (this._desktopIntentionalShutdown && options.restart !== true) {
440 return null;
441 }
442 if (options.restart === true) {
443 this.setDesktopIntentionalShutdown(false);
444 this.destroyDesktopFrame();
445 }
446 const existing = this.tabs.find((tab) => this.isDesktopSession(tab));
447 if (existing && !options.force) {
448 if (options.select) this.selectTab(existing.tab_id, { focus: false });
449 this.updateDesktopMonitor();
450 return existing;
451 }
452 const showProgress = options.progress !== false;
453 const progressMessage = String(options.message || DESKTOP_START_MESSAGE);
454 if (this._desktopStarting) {
455 if (showProgress) {
456 this.loading = true;
457 this.message = progressMessage;
458 }
459 return await this._desktopStarting;
460 }
461
462 this._desktopStarting = (async () => {
463 try {
464 if (showProgress) {
465 this.loading = true;
466 this.message = progressMessage;
467 this.error = "";
468 }
469 const response = await this.openDesktopWhenRuntimeReady(showProgress);
470 if (response?.ok === false) throw new Error(response.error || "Desktop session could not be opened.");
471 this.setDesktopIntentionalShutdown(false);
472 const session = normalizeSession(response);
473 const existingIndex = this.tabs.findIndex((tab) => this.isDesktopSession(tab));
474 let desktopTabId = session.tab_id;
475 if (existingIndex >= 0) {
476 desktopTabId = this.tabs[existingIndex].tab_id;
477 this.tabs.splice(existingIndex, 1, { ...this.tabs[existingIndex], ...session, tab_id: desktopTabId });
478 } else {
479 this.tabs.unshift(session);
480 }
481 this.tabs = this.tabs.map((tab) => (
482 this.hasOfficialOffice(tab)
483 ? {
484 ...tab,
485 desktop: session.desktop,
486 desktop_session_id: session.desktop_session_id,
487 session_id: this.isDesktopSession(tab) ? session.session_id : tab.session_id,
488 }
489 : tab
490 ));
491 if (options.select || !this.session) {
492 this.selectTab(desktopTabId, { focus: false });
493 } else {
494 this.updateDesktopMonitor();
495 }
496 this.restoreDesktopFrames();
497 return { ...session, tab_id: desktopTabId };
498 } catch (error) {
499 this.error = error instanceof Error ? error.message : String(error);
500 return null;
501 } finally {
502 if (showProgress) {
503 this.loading = false;
504 if (this.message === progressMessage || this.message === DESKTOP_RUNTIME_INSTALL_MESSAGE) this.message = "";
505 }
506 this._desktopStarting = null;
507 }
508 })();
509 return await this._desktopStarting;
510 },
511
512 async openDesktopWhenRuntimeReady(showProgress = true) {
513 const startedAt = Date.now();
514 let response = await callDesktop("desktop");
515 while (response?.ok === false && this.isDesktopRuntimeInstalling(response)) {
516 if (showProgress) {
517 this.loading = true;
518 this.error = "";
519 this.message = this.desktopRuntimeInstallMessage(response);
520 }
521 if (Date.now() - startedAt > DESKTOP_RUNTIME_INSTALL_TIMEOUT_MS) {
522 return {
523 ...response,
524 error: "Agent Zero Desktop runtime installation is still running. Please try again in a moment.",
525 };
526 }
527 await sleep(DESKTOP_RUNTIME_INSTALL_POLL_MS);
528 response = await callDesktop("desktop");
529 }
530 return response;
531 },
532
533 isDesktopRuntimeInstalling(response = {}) {
534 const status = response?.status || response?.desktop?.status || response?.libreoffice?.desktop || {};
535 return Boolean(status.installing || status.state === "installing" || status.preparation?.preparing);
536 },
537
538 desktopRuntimeInstallMessage(response = {}) {
539 const status = response?.status || response?.desktop?.status || response?.libreoffice?.desktop || {};
540 return String(status.message || DESKTOP_RUNTIME_INSTALL_MESSAGE);
541 },
542
543 async create(kind = "document", format = "") {
544 const fmt = String(format || (kind === "spreadsheet" ? "ods" : kind === "presentation" ? "odp" : "odt")).toLowerCase();
545 const title = this.defaultTitle(kind, fmt);
546 this.loading = true;
547 this.error = "";
548 try {
549 const response = await callOffice("create", {
550 kind,
551 format: fmt,
552 title,
553 open_in_desktop: isOfficialExtension(fmt),
554 });
555 if (response?.ok === false) {
556 this.error = response.error || "Document could not be created.";
557 return null;
558 }
559 const session = normalizeSession(response);
560 this.installSession(session);
561 await this.refresh();
562 return session;
563 } catch (error) {
564 this.error = error instanceof Error ? error.message : String(error);
565 return null;
566 } finally {
567 this.loading = false;
568 }
569 },
570
571 async openFileBrowser() {
572 let workdirPath = "/a0/usr/workdir";
573 try {
574 const response = await callJsonApi("settings_get", null);
575 workdirPath = response?.settings?.workdir_path || workdirPath;
576 } catch {
577 try {
578 const home = await callOffice("home");
579 workdirPath = home?.path || workdirPath;
580 } catch {
581 // The file browser can still open with the static fallback.
582 }
583 }
584 await fileBrowserStore.open(workdirPath);
585 },
586
587 async openPath(path) {
588 return await this.openSession({ path: String(path || "") });
589 },
590
591 async openSession(payload = {}) {
592 this.loading = true;
593 this.error = "";
594 try {
595 const response = await callDesktop("open_document", payload);
596 if (response?.ok === false) {
597 this.error = response.error || "Document could not be opened.";
598 return null;
599 }
600 const session = normalizeSession(response);
601 this.installSession(session);
602 await this.refresh();
603 return session;
604 } catch (error) {
605 this.error = error instanceof Error ? error.message : String(error);
606 return null;
607 } finally {
608 this.loading = false;
609 }
610 },
611
612 installSession(session) {
613 if (this.isDesktopOfficeDocument(session)) {
614 this.installDesktopDocumentSession(session);
615 return;
616 }
617 const existingIndex = this.tabs.findIndex((tab) => (
618 (session.file_id && tab.file_id === session.file_id)
619 || (session.path && tab.path === session.path)
620 ));
621 if (existingIndex >= 0) {
622 this.tabs.splice(existingIndex, 1, { ...this.tabs[existingIndex], ...session, tab_id: this.tabs[existingIndex].tab_id });
623 this.activeTabId = this.tabs[existingIndex].tab_id;
624 } else {
625 this.tabs.push(session);
626 this.activeTabId = session.tab_id;
627 }
628 this.selectTab(this.activeTabId);
629 },
630
631 installDesktopDocumentSession(session) {
632 this.setDesktopIntentionalShutdown(false);
633 this.tabs = this.tabs.filter((tab) => !this.isDesktopOfficeDocument(tab));
634 let desktopTab = this.tabs.find((tab) => this.isDesktopSession(tab));
635 if (!desktopTab) {
636 desktopTab = {
637 ...session,
638 tab_id: SYSTEM_DESKTOP_FILE_ID,
639 file_id: SYSTEM_DESKTOP_FILE_ID,
640 extension: "desktop",
641 title: "Desktop",
642 path: session.desktop?.desktop_path || "/desktop/session",
643 mode: "desktop",
644 document: {
645 file_id: SYSTEM_DESKTOP_FILE_ID,
646 path: session.desktop?.desktop_path || "/desktop/session",
647 basename: "Desktop",
648 title: "Desktop",
649 extension: "desktop",
650 },
651 dirty: false,
652 };
653 this.tabs.unshift(desktopTab);
654 }
655 const documentSession = { ...session, tab_id: session.tab_id || uniqueTabId(session) };
656 const existingIndex = this.tabs.findIndex((tab) => (
657 (documentSession.file_id && tab.file_id === documentSession.file_id)
658 || (documentSession.path && tab.path === documentSession.path)
659 ));
660 if (existingIndex >= 0) {
661 this.tabs.splice(existingIndex, 1, documentSession);
662 } else {
663 this.tabs.push(documentSession);
664 }
665 this.session = documentSession;
666 this.activeTabId = documentSession.tab_id;
667 this.editorText = "";
668 this.dirty = false;
669 this.resetHistory("");
670 this.queueRender({ focus: true });
671 this.restoreDesktopFrames();
672 this.requestDesktopViewportSync({ force: true });
673 this.updateDesktopMonitor();
674 },
675
676 selectTab(tabId, options = {}) {
677 const tab = this.tabs.find((item) => item.tab_id === tabId) || this.tabs[0] || null;
678 if (this.hasOfficialOffice(this.session) && !this.hasOfficialOffice(tab)) {
679 this.moveDesktopFrameToKeepalive();
680 }
681 this.session = tab;
682 this.activeTabId = tab?.tab_id || "";
683 this.editorText = String(tab?.text || "");
684 this.dirty = Boolean(tab?.dirty);
685 this.resetHistory(this.editorText);
686 this.queueRender({ focus: Boolean(tab) && options.focus !== false });
687 if (this.hasOfficialOffice(tab)) {
688 this.restoreDesktopFrames();
689 this.requestDesktopViewportSync({ force: true });
690 }
691 this.updateDesktopMonitor();
692 },
693
694 ensureActiveTab() {
695 if (this.session && this.tabs.some((tab) => tab.tab_id === this.session.tab_id)) return;
696 if (this.tabs.length) this.selectTab(this.tabs[0].tab_id, { focus: false });
697 },
698
699 isActiveTab(tab) {
700 return Boolean(tab && tab.tab_id === this.activeTabId);
701 },
702
703 async closeTab(tabId) {
704 const tab = this.tabs.find((item) => item.tab_id === tabId);
705 if (!tab) return;
706 if (this.isDesktopSession(tab)) {
707 this.selectTab(tab.tab_id, { focus: false });
708 return;
709 }
710 if (!this.hasOfficialOffice(tab) && (tab.dirty || (this.isActiveTab(tab) && this.dirty))) {
711 const shouldSave = globalThis.confirm?.("Save changes?") ?? true;
712 if (shouldSave) await this.save();
713 }
714 try {
715 if (this.hasOfficialOffice(tab)) {
716 await callDesktop("save", {
717 desktop_session_id: tab.desktop_session_id || tab.session_id,
718 file_id: tab.file_id || "",
719 }).catch(() => null);
720 } else if (tab.session_id) {
721 await requestOffice("office_close", { session_id: tab.session_id }, 2500).catch(() => null);
722 }
723 await callOffice("close", {
724 session_id: tab.store_session_id || "",
725 file_id: tab.file_id || "",
726 });
727 } catch (error) {
728 console.warn("Document close skipped", error);
729 }
730 this.tabs = this.tabs.filter((item) => item.tab_id !== tabId);
731 if (this.activeTabId === tabId) {
732 this.session = null;
733 this.activeTabId = "";
734 this.editorText = "";
735 this.dirty = false;
736 this.ensureActiveTab();
737 }
738 this.updateDesktopMonitor();
739 this.ensureActiveTab();
740 await this.refresh();
741 },
742
743 async closeActiveFile() {
744 if (!this.session || this.isDesktopSession() || this.loading) return;
745 await this.closeTab(this.session.tab_id);
746 },
747
748 async save() {
749 if (!this.session || this.saving) return;
750 if (this.isDesktopSession()) return;
751 if (this.hasOfficialOffice()) {
752 this.saving = true;
753 this.error = "";
754 try {
755 const response = await callDesktop("save", {
756 desktop_session_id: this.session.desktop_session_id || this.session.session_id,
757 file_id: this.session.file_id || "",
758 });
759 if (response?.ok === false) throw new Error(response.error || "Save failed.");
760 const document = normalizeDocument(response.document || this.session.document || {});
761 const updated = {
762 ...this.session,
763 dirty: false,
764 document,
765 path: document.path || this.session.path,
766 file_id: document.file_id || this.session.file_id,
767 version: document.version || response.version || this.session.version,
768 };
769 this.replaceActiveSession(updated);
770 this.dirty = false;
771 this.setMessage("Saved");
772 await this.refresh();
773 } catch (error) {
774 this.error = error instanceof Error ? error.message : String(error);
775 } finally {
776 this.saving = false;
777 }
778 return;
779 }
780 this.syncEditorText();
781 this.saving = true;
782 this.error = "";
783 try {
784 let response;
785 const payload = { session_id: this.session.session_id, text: this.editorText };
786 try {
787 response = await requestOffice("office_save", payload, 10000);
788 } catch (_socketError) {
789 response = await callOffice("save", payload);
790 }
791 if (response?.ok === false) throw new Error(response.error || "Save failed.");
792 const document = normalizeDocument(response.document || this.session.document || {});
793 const updated = {
794 ...this.session,
795 text: this.editorText,
796 dirty: false,
797 document,
798 path: document.path || this.session.path,
799 file_id: document.file_id || this.session.file_id,
800 version: document.version || response.version || this.session.version,
801 };
802 this.replaceActiveSession(updated);
803 this.dirty = false;
804 this.setMessage("Saved");
805 await this.refresh();
806 } catch (error) {
807 this.error = error instanceof Error ? error.message : String(error);
808 } finally {
809 this.saving = false;
810 }
811 },
812
813 async renameActiveFile() {
814 if (!this.session || this.isDesktopSession() || this.saving) return;
815
816 const session = this.session;
817 const path = session.path || session.document?.path || "";
818 if (!path) {
819 this.error = "This document does not have a file path to rename.";
820 return;
821 }
822 const name = basename(path || session.title || "");
823 const extension = extensionOf(name);
824 await fileBrowserStore.openRenameModal(
825 {
826 name,
827 path,
828 is_dir: false,
829 size: session.document?.size || 0,
830 modified: session.document?.last_modified || "",
831 type: "document",
832 },
833 {
834 currentPath: parentPath(path),
835 validateName: (newName) => {
836 if (!extension) return true;
837 return extensionOf(newName) === extension || `Keep the .${extension} extension for this open document.`;
838 },
839 performRename: async ({ path: renamedPath }) => {
840 const payload = {
841 file_id: session.file_id || "",
842 path: renamedPath,
843 };
844 if (this.isMarkdown(session)) {
845 this.syncEditorText();
846 payload.text = this.session?.tab_id === session.tab_id ? this.editorText : session.text || "";
847 }
848 return await callOffice("renamed", payload);
849 },
850 onRenamed: async ({ path: renamedPath, response }) => {
851 await this.handleActiveFileRenamed(session, renamedPath, response);
852 },
853 },
854 );
855 },
856
857 async handleActiveFileRenamed(session, renamedPath, renameResponse = null) {
858 const response = renameResponse || await callOffice("renamed", {
859 file_id: session.file_id || "",
860 path: renamedPath,
861 });
862 if (response?.ok === false) throw new Error(response.error || "Rename failed.");
863
864 const document = normalizeDocument(response.document || session.document || {});
865 const updated = {
866 ...session,
867 document,
868 title: document.title || document.basename || basename(document.path),
869 path: document.path || renamedPath,
870 extension: document.extension || session.extension,
871 file_id: document.file_id || session.file_id,
872 version: document.version || response.version || session.version,
873 desktop: response.desktop?.desktop || session.desktop,
874 text: this.session?.tab_id === session.tab_id ? this.editorText : session.text,
875 dirty: false,
876 };
877 this.replaceSession(session, updated);
878 this.dirty = false;
879 this.setMessage("Renamed");
880 await this.refresh();
881 },
882
883 replaceActiveSession(next) {
884 if (!this.session) return;
885 this.replaceSession(this.session, next);
886 },
887
888 replaceSession(previous, next) {
889 this.session = next;
890 const index = this.tabs.findIndex((tab) => tab.tab_id === (previous?.tab_id || next.tab_id));
891 if (index >= 0) this.tabs.splice(index, 1, next);
892 this.queueRender();
893 this.updateDesktopMonitor();
894 },
895
896 setMessage(value) {
897 this.message = value;
898 if (this._saveMessageTimer) globalThis.clearTimeout(this._saveMessageTimer);
899 this._saveMessageTimer = globalThis.setTimeout(() => {
900 this.message = "";
901 this._saveMessageTimer = null;
902 }, SAVE_MESSAGE_MS);
903 },
904
905 resetHistory(text) {
906 this._history = [String(text || "")];
907 this._historyIndex = 0;
908 },
909
910 pushHistory(text) {
911 const value = String(text || "");
912 if (this._history[this._historyIndex] === value) return;
913 this._history = this._history.slice(0, this._historyIndex + 1);
914 this._history.push(value);
915 if (this._history.length > MAX_HISTORY) this._history.shift();
916 this._historyIndex = this._history.length - 1;
917 },
918
919 undo() {
920 if (this._historyIndex <= 0) return;
921 this._historyIndex -= 1;
922 this.applyEditorText(this._history[this._historyIndex], true);
923 },
924
925 redo() {
926 if (this._historyIndex >= this._history.length - 1) return;
927 this._historyIndex += 1;
928 this.applyEditorText(this._history[this._historyIndex], true);
929 },
930
931 canUndo() {
932 return this._historyIndex > 0;
933 },
934
935 canRedo() {
936 return this._historyIndex < this._history.length - 1;
937 },
938
939 applyEditorText(text, markDirty = false) {
940 this.editorText = String(text || "");
941 if (this.session) {
942 this.session.text = this.editorText;
943 this.session.dirty = markDirty || this.session.dirty;
944 }
945 if (markDirty) this.markDirty();
946 this.queueRender({ force: true, focus: true });
947 },
948
949 markDirty() {
950 this.dirty = true;
951 if (this.session) this.session.dirty = true;
952 },
953
954 onSourceInput() {
955 this.markDirty();
956 this.pushHistory(this.editorText);
957 this.scheduleInputPush();
958 },
959
960 syncEditorText() {
961 if (!this.session) return;
962 if (this.hasOfficialOffice()) return;
963 this.session.text = this.editorText;
964 },
965
966 scheduleInputPush() {
967 if (!this.session?.session_id) return;
968 if (this._inputTimer) globalThis.clearTimeout(this._inputTimer);
969 this._inputTimer = globalThis.setTimeout(() => {
970 this._inputTimer = null;
971 this.flushInput();
972 }, INPUT_PUSH_DELAY_MS);
973 },
974
975 flushInput() {
976 if (!this.session?.session_id) return;
977 if (this.hasOfficialOffice()) return;
978 this.syncEditorText();
979 requestOffice("office_input", {
980 session_id: this.session.session_id,
981 text: this.editorText,
982 }, 3000).catch(() => {});
983 },
984
985 format(command) {
986 if (!this.session) return;
987 if (!this.isMarkdown()) return;
988 this.applySourceFormat(command);
989 },
990
991 applySourceFormat(command) {
992 const textarea = this._root?.querySelector?.("[data-office-source]");
993 if (!textarea) return;
994 const start = textarea.selectionStart || 0;
995 const end = textarea.selectionEnd || start;
996 const selected = this.editorText.slice(start, end);
997 let replacement = selected;
998 if (command === "bold") replacement = `**${selected || "text"}**`;
999 if (command === "italic") replacement = `*${selected || "text"}*`;
1000 if (command === "list") replacement = (selected || "item").split("\n").map((line) => `- ${line.replace(/^[-*]\s+/, "")}`).join("\n");
1001 if (command === "numbered") replacement = (selected || "item").split("\n").map((line, index) => `${index + 1}. ${line.replace(/^\d+\.\s+/, "")}`).join("\n");
1002 if (command === "table") replacement = "| Column | Value |\n| --- | --- |\n| | |";
1003 if (replacement === selected) return;
1004 this.editorText = `${this.editorText.slice(0, start)}${replacement}${this.editorText.slice(end)}`;
1005 this.onSourceInput();
1006 globalThis.requestAnimationFrame?.(() => {
1007 textarea.focus();
1008 textarea.selectionStart = start;
1009 textarea.selectionEnd = start + replacement.length;
1010 });
1011 },
1012
1013 queueRender(options = {}) {
1014 const force = Boolean(options.force);
1015 if (options.focus) {
1016 this._pendingFocus = true;
1017 this._pendingFocusEnd = options.end !== false;
1018 this._focusAttempts = 0;
1019 }
1020 const render = () => {
1021 if (this._pendingFocus && this.focusEditor({ end: this._pendingFocusEnd })) {
1022 this._pendingFocus = false;
1023 this._focusAttempts = 0;
1024 } else if (this._pendingFocus && this._focusAttempts < 6) {
1025 this._focusAttempts += 1;
1026 globalThis.setTimeout(render, 45);
1027 }
1028 };
1029 if (globalThis.requestAnimationFrame) {
1030 globalThis.requestAnimationFrame(render);
1031 } else {
1032 globalThis.setTimeout(render, 0);
1033 }
1034 },
1035
1036 focusEditor(options = {}) {
1037 if (!this.session) return false;
1038 if (this.hasOfficialOffice()) {
1039 return this.focusDesktopFrame(this.desktopFrame(), { arm: true });
1040 }
1041 const source = this._root?.querySelector?.("[data-office-source]");
1042 if (!this.isMarkdown() || !source) return false;
1043 source.focus?.({ preventScroll: true });
1044 if (!editorContainsFocus(source)) return false;
1045 if (options.end !== false) placeCaretAtEnd(source);
1046 return true;
1047 },
1048
1049 isMarkdown(tab = this.session) {
1050 const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase();
1051 return ext === "md";
1052 },
1053
1054 isBinaryOffice(tab = this.session) {
1055 const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase();
1056 return ["odt", "ods", "odp", "docx", "xlsx", "pptx", "txt"].includes(ext);
1057 },
1058
1059 hasOfficialOffice(tab = this.session) {
1060 return Boolean(tab?.desktop?.available && tab.desktop.url);
1061 },
1062
1063 isDesktopSession(tab = this.session) {
1064 return Boolean(
1065 tab
1066 && (
1067 tab.file_id === SYSTEM_DESKTOP_FILE_ID
1068 || tab.extension === "desktop"
1069 || tab.mode === "desktop"
1070 )
1071 );
1072 },
1073
1074 isDesktopOfficeDocument(tab = this.session) {
1075 return Boolean(tab && this.hasOfficialOffice(tab) && !this.isDesktopSession(tab) && this.isBinaryOffice(tab));
1076 },
1077
1078 hasActiveFile(tab = this.session) {
1079 return Boolean(tab && !this.isDesktopSession(tab) && (this.isMarkdown(tab) || this.isDesktopOfficeDocument(tab)));
1080 },
1081
1082 isVisibleOfficeTab(tab = {}) {
1083 return Boolean(this.hasActiveFile(tab));
1084 },
1085
1086 visibleTabs() {
1087 return this.tabs.filter((tab) => this.isVisibleOfficeTab(tab));
1088 },
1089
1090 officialOfficeUrl(tab = this.session) {
1091 const url = tab?.desktop?.url || "";
1092 if (!url) return "";
1093 try {
1094 const parsed = new URL(url, window.location.href);
1095 const secureContext = globalThis.isSecureContext === true;
1096 parsed.searchParams.set("offscreen", secureContext ? "true" : "false");
1097 parsed.searchParams.set("clipboard_poll", secureContext ? "true" : "false");
1098 if (parsed.origin === window.location.origin) return `${parsed.pathname}${parsed.search}${parsed.hash}`;
1099 return parsed.href;
1100 } catch {
1101 return url;
1102 }
1103 },
1104
1105 isDesktopHostVisible() {
1106 if (this._mode === "modal") {
1107 return Boolean(this._root?.isConnected && this._root.closest?.(".modal"));
1108 }
1109 const surface = this._root?.closest?.('[data-surface-id="desktop"]');
1110 return Boolean(surface?.classList?.contains("is-mounted") || surface?.classList?.contains("is-active"));
1111 },
1112
1113 setDesktopHostVisible(visible) {
1114 const next = Boolean(visible);
1115 if (!next && this._mode === "modal") return;
1116 if (this._desktopHostVisible === next) return;
1117 this._desktopHostVisible = next;
1118 if (next) {
1119 this.afterDesktopHostShown({ source: "canvas-visibility" });
1120 } else {
1121 this.beforeHostHidden({ reason: "hidden" });
1122 }
1123 },
1124
1125 desktopFrames() {
1126 const frames = [];
1127 if (this._desktopFrame) frames.push(this._desktopFrame);
1128 for (const frame of Array.from(document.querySelectorAll("[data-office-desktop-frame]"))) {
1129 if (!frames.includes(frame)) frames.push(frame);
1130 }
1131 return frames;
1132 },
1133
1134 isUsableDesktopFrame(frame) {
1135 if (!frame?.contentWindow) return false;
1136 const rect = frame.getBoundingClientRect?.();
1137 return Boolean(rect && rect.width >= 120 && rect.height >= 80);
1138 },
1139
1140 desktopFrame(preferred = null) {
1141 if (this.isUsableDesktopFrame(preferred)) return preferred;
1142 const rootFrame = this._root?.querySelector?.("[data-office-desktop-frame]");
1143 if (this.isUsableDesktopFrame(rootFrame)) return rootFrame;
1144 const frames = this.desktopFrames();
1145 return frames
1146 .filter((frame) => this.isUsableDesktopFrame(frame))
1147 .sort((left, right) => {
1148 const leftRect = left.getBoundingClientRect();
1149 const rightRect = right.getBoundingClientRect();
1150 return (rightRect.width * rightRect.height) - (leftRect.width * leftRect.height);
1151 })[0] || null;
1152 },
1153
1154 isUsableDesktopHost(host) {
1155 if (!host?.appendChild) return false;
1156 const rect = host.getBoundingClientRect?.();
1157 return Boolean(rect && rect.width >= 120 && rect.height >= 80);
1158 },
1159
1160 desktopHost(preferred = null) {
1161 if (preferred?.matches?.("[data-office-desktop-host]")) return preferred;
1162 const rootHost = this._root?.querySelector?.("[data-office-desktop-host]");
1163 if (this.isUsableDesktopHost(rootHost)) return rootHost;
1164 const hosts = Array.from(document.querySelectorAll("[data-office-desktop-host]"));
1165 return hosts
1166 .filter((host) => this.isUsableDesktopHost(host))
1167 .sort((left, right) => {
1168 const leftRect = left.getBoundingClientRect();
1169 const rightRect = right.getBoundingClientRect();
1170 return (rightRect.width * rightRect.height) - (leftRect.width * leftRect.height);
1171 })[0] || rootHost || hosts[0] || null;
1172 },
1173
1174 ensureDesktopKeepaliveHost() {
1175 if (this._desktopKeepaliveHost?.isConnected) return this._desktopKeepaliveHost;
1176 const host = document.createElement("div");
1177 host.className = "office-desktop-keepalive";
1178 host.dataset.officeDesktopKeepalive = "true";
1179 Object.assign(host.style, {
1180 position: "fixed",
1181 left: "-10000px",
1182 top: "-10000px",
1183 width: "720px",
1184 height: "480px",
1185 overflow: "hidden",
1186 pointerEvents: "none",
1187 visibility: "hidden",
1188 });
1189 document.body?.appendChild(host);
1190 this._desktopKeepaliveHost = host;
1191 return host;
1192 },
1193
1194 rememberDesktopFrameSize() {
1195 const frame = this._desktopFrame;
1196 const rect = frame?.getBoundingClientRect?.();
1197 const hostRect = this._desktopFrameHost?.getBoundingClientRect?.();
1198 const width = Math.round(rect?.width || hostRect?.width || 720);
1199 const height = Math.round(rect?.height || hostRect?.height || 480);
1200 const keepalive = this.ensureDesktopKeepaliveHost();
1201 keepalive.style.width = `${Math.max(320, width)}px`;
1202 keepalive.style.height = `${Math.max(220, height)}px`;
1203 return keepalive;
1204 },
1205
1206 ensureDesktopFrame() {
1207 if (this._desktopFrame) return this._desktopFrame;
1208 const frame = document.createElement("iframe");
1209 frame.className = "office-desktop-frame";
1210 frame.dataset.officeDesktopFrame = "true";
1211 frame.dataset.officePersistentDesktopFrame = "true";
1212 frame.setAttribute("tabindex", "0");
1213 frame.setAttribute("aria-label", "Desktop");
1214 frame.setAttribute("allow", "clipboard-read; clipboard-write; autoplay");
1215 this._desktopFrameLoadHandler = (event) => this.onDesktopFrameLoaded(event);
1216 frame.addEventListener("load", this._desktopFrameLoadHandler);
1217 this._desktopFrame = frame;
1218 return frame;
1219 },
1220
1221 desktopFrameSrcMatches(frame, url) {
1222 const current = frame?.getAttribute?.("src") || frame?.src || "";
1223 if (!current && !url) return true;
1224 try {
1225 return new URL(current, window.location.href).href === new URL(url, window.location.href).href;
1226 } catch {
1227 return current === url;
1228 }
1229 },
1230
1231 attachDesktopFrame(host = null) {
1232 if (!this.hasOfficialOffice()) return false;
1233 const target = this.desktopHost(host);
1234 if (!target) return false;
1235 const frame = this.ensureDesktopFrame();
1236 if (frame.parentElement !== target) {
1237 frame.parentElement?.removeAttribute?.("data-office-desktop-attached");
1238 target.appendChild(frame);
1239 }
1240 target.dataset.officeDesktopAttached = "true";
1241 if (this._desktopFrameHost !== target) this._desktopFrameHost = target;
1242 const url = this.officialOfficeUrl();
1243 if (url && !this.desktopFrameSrcMatches(frame, url)) {
1244 frame.setAttribute("src", url);
1245 }
1246 return true;
1247 },
1248
1249 mountDesktopFrameHost(host = null) {
1250 const attached = this.attachDesktopFrame(host);
1251 if (attached && this.isDesktopHostVisible()) {
1252 this.requestDesktopViewportSync({ force: true, frame: this._desktopFrame, followup: true });
1253 }
1254 return attached;
1255 },
1256
1257 moveDesktopFrameToKeepalive() {
1258 const frame = this._desktopFrame;
1259 if (!frame) return false;
1260 const keepalive = this.rememberDesktopFrameSize();
1261 if (frame.parentElement !== keepalive) {
1262 frame.parentElement?.removeAttribute?.("data-office-desktop-attached");
1263 keepalive.appendChild(frame);
1264 }
1265 this._desktopFrameHost = keepalive;
1266 this._desktopKeyboardActive = false;
1267 this.updateDesktopKeyboardCaptureState(frame);
1268 return true;
1269 },
1270
1271 destroyDesktopFrame() {
1272 const frame = this._desktopFrame;
1273 if (!frame) return;
1274 if (this._desktopFrameLoadHandler) {
1275 frame.removeEventListener("load", this._desktopFrameLoadHandler);
1276 }
1277 frame.setAttribute("src", "about:blank");
1278 frame.remove();
1279 this._desktopFrame = null;
1280 this._desktopFrameHost = null;
1281 this._desktopFrameLoadHandler = null;
1282 this._desktopBridgeReady = false;
1283 this.updateDesktopKeyboardCaptureState();
1284 this._desktopKeepaliveHost?.remove?.();
1285 this._desktopKeepaliveHost = null;
1286 },
1287
1288 unloadDesktopFrames() {
1289 this.stopDesktopResizeObserver();
1290 this.stopXpraDesktopPrime();
1291 this.moveDesktopFrameToKeepalive();
1292 },
1293
1294 restoreDesktopFrames() {
1295 if (!this.isDesktopHostVisible()) return;
1296 this.attachDesktopFrame();
1297 },
1298
1299 afterDesktopHostShown() {
1300 if (!this.hasOfficialOffice()) return;
1301 this._desktopHostVisible = true;
1302 this._desktopResizeKey = "";
1303 this._desktopResizePendingKey = "";
1304 this._desktopResizeSuspended = false;
1305 this._desktopResizePending = false;
1306 this.restoreDesktopFrames();
1307 this.requestDesktopViewportSync({ force: true, frame: this.desktopFrame() });
1308 },
1309
1310 beforeDesktopHostHandoff() {
1311 this.stopDesktopResizeObserver();
1312 this.clearDesktopViewportSyncTimers();
1313 this.stopXpraDesktopPrime();
1314 this._desktopResizeKey = "";
1315 this._desktopResizePendingKey = "";
1316 this._desktopResizeSuspended = true;
1317 this._desktopResizePending = true;
1318 },
1319
1320 cancelDesktopHostHandoff() {
1321 this._desktopResizeSuspended = false;
1322 this._desktopResizePending = false;
1323 this.requestDesktopViewportSync({ force: true, frame: this.desktopFrame() });
1324 },
1325
1326 onDesktopFrameLoaded(event = null) {
1327 if (event?.target?.getAttribute?.("src") === "about:blank") return;
1328 if (!this.isDesktopHostVisible()) return;
1329 this.error = "";
1330 this.queueDesktopFrameFocus(event?.target || null);
1331 this.requestDesktopViewportSync({ force: true, frame: event?.target || null });
1332 },
1333
1334 queueDesktopFrameFocus(frame = null) {
1335 for (const delay of [0, 80, 260]) {
1336 globalThis.setTimeout(() => {
1337 if (!this.hasOfficialOffice()) return;
1338 if (isEditableInputTarget(document.activeElement)) return;
1339 this.focusDesktopFrame(frame || this.desktopFrame(), { arm: true });
1340 }, delay);
1341 }
1342 },
1343
1344 focusDesktopFrame(frame = null, options = {}) {
1345 if (this._desktopFocusInProgress) return false;
1346 const target = this.desktopFrame(frame);
1347 if (!target) return false;
1348 if (options.arm !== false) this._desktopKeyboardActive = true;
1349 this._desktopFocusInProgress = true;
1350 try {
1351 target.setAttribute("tabindex", "0");
1352 target.focus?.({ preventScroll: true });
1353 target.contentWindow?.focus?.();
1354 if (target.contentDocument?.body && !target.contentDocument.body.hasAttribute("tabindex")) {
1355 target.contentDocument.body.tabIndex = -1;
1356 }
1357 target.contentDocument?.body?.focus?.({ preventScroll: true });
1358 if (target.contentWindow?.client) target.contentWindow.client.capture_keyboard = true;
1359 } catch {
1360 target.focus?.({ preventScroll: true });
1361 } finally {
1362 this._desktopFocusInProgress = false;
1363 }
1364 const focused = Boolean(document.activeElement === target || target.contentDocument?.hasFocus?.());
1365 this.updateDesktopKeyboardCaptureState(target);
1366 return focused;
1367 },
1368
1369 updateDesktopMonitor() {
1370 if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) {
1371 this.stopDesktopMonitor();
1372 this.stopDesktopResizeObserver();
1373 this._desktopKeyboardActive = false;
1374 this._desktopBridgeReady = false;
1375 this.updateDesktopKeyboardCaptureState();
1376 return;
1377 }
1378 const sessionId = this.session?.desktop_session_id || this.session?.session_id || "";
1379 const tabId = this.session?.tab_id || "";
1380 if (
1381 sessionId
1382 && tabId
1383 && this._desktopHeartbeatTimer
1384 && this._desktopHeartbeatSessionId === sessionId
1385 && this._desktopHeartbeatTabId === tabId
1386 ) return;
1387 this.startDesktopMonitor();
1388 this.startDesktopResizeObserver();
1389 },
1390
1391 startDesktopResizeObserver() {
1392 if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) {
1393 this.stopDesktopResizeObserver();
1394 return;
1395 }
1396 const frame = this.desktopFrame();
1397 const target = frame?.parentElement || frame;
1398 if (!target) {
1399 this.stopDesktopResizeObserver();
1400 return;
1401 }
1402 if (this._desktopResizeCleanup && this._desktopResizeTarget === target) return;
1403 this.stopDesktopResizeObserver();
1404
1405 const resize = () => this.queueDesktopResize();
1406 const resizeStart = () => this.suspendDesktopResize();
1407 const resizeEnd = () => this.resumeDesktopResize();
1408 const cleanup = [];
1409 if (typeof ResizeObserver !== "undefined") {
1410 const observer = new ResizeObserver(resize);
1411 observer.observe(target);
1412 cleanup.push(() => observer.disconnect());
1413 }
1414 globalThis.addEventListener?.("resize", resize);
1415 cleanup.push(() => globalThis.removeEventListener?.("resize", resize));
1416 globalThis.addEventListener?.("right-canvas-resize-start", resizeStart);
1417 cleanup.push(() => globalThis.removeEventListener?.("right-canvas-resize-start", resizeStart));
1418 globalThis.addEventListener?.("right-canvas-resize-end", resizeEnd);
1419 cleanup.push(() => globalThis.removeEventListener?.("right-canvas-resize-end", resizeEnd));
1420 this._desktopResizeTarget = target;
1421 this._desktopResizeCleanup = () => cleanup.splice(0).reverse().forEach((entry) => entry());
1422 resize();
1423 },
1424
1425 stopDesktopResizeObserver() {
1426 if (this._desktopResizeTimer) {
1427 globalThis.clearTimeout(this._desktopResizeTimer);
1428 }
1429 this._desktopResizeTimer = null;
1430 this._desktopResizeCleanup?.();
1431 this._desktopResizeCleanup = null;
1432 this._desktopResizeTarget = null;
1433 this._desktopResizeKey = "";
1434 this._desktopResizePendingKey = "";
1435 this._desktopResizeSuspended = false;
1436 this._desktopResizePending = false;
1437 },
1438
1439 suspendDesktopResize() {
1440 this._desktopResizeSuspended = true;
1441 if (this._desktopResizeTimer) {
1442 globalThis.clearTimeout(this._desktopResizeTimer);
1443 this._desktopResizeTimer = null;
1444 }
1445 this._desktopResizePendingKey = "";
1446 },
1447
1448 resumeDesktopResize() {
1449 const hadPendingResize = this._desktopResizePending;
1450 this._desktopResizeSuspended = false;
1451 this._desktopResizePending = false;
1452 if (hadPendingResize || this.hasOfficialOffice()) {
1453 this.queueDesktopResize({ force: true });
1454 }
1455 },
1456
1457 shouldDeferDesktopResize() {
1458 return Boolean(
1459 this._desktopResizeSuspended
1460 || document.body?.classList?.contains("right-canvas-resizing")
1461 || document.querySelector?.(".modal-inner.office-modal.is-resizing")
1462 );
1463 },
1464
1465 clearDesktopViewportSyncTimers() {
1466 for (const timer of this._desktopViewportSyncTimers.splice(0)) {
1467 globalThis.clearTimeout(timer);
1468 }
1469 },
1470
1471 requestDesktopViewportSync(options = {}) {
1472 if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) return;
1473 if (options.force) this.clearDesktopViewportSyncTimers();
1474 const run = (force = false) => {
1475 this.syncDesktopViewport({ ...options, force });
1476 };
1477 if (globalThis.requestAnimationFrame) {
1478 globalThis.requestAnimationFrame(() => run(Boolean(options.force)));
1479 } else {
1480 globalThis.setTimeout(() => run(Boolean(options.force)), 0);
1481 }
1482 if (options.followup === false) return;
1483 const timer = globalThis.setTimeout(() => {
1484 this._desktopViewportSyncTimers = this._desktopViewportSyncTimers.filter((item) => item !== timer);
1485 run(false);
1486 }, options.force ? 260 : 180);
1487 this._desktopViewportSyncTimers.push(timer);
1488 },
1489
1490 syncDesktopViewport(options = {}) {
1491 if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) return false;
1492 const frame = this.desktopFrame(options.frame || null);
1493 if (!frame) return false;
1494 this.startDesktopResizeObserver();
1495 this.primeXpraDesktopFrame({ reset: true, frame });
1496 this.queueDesktopResize({
1497 force: Boolean(options.force),
1498 serverResize: options.serverResize !== false,
1499 frame,
1500 });
1501 this.updateDesktopMonitor();
1502 return true;
1503 },
1504
1505 primeXpraDesktopFrame(options = {}) {
1506 if (options.reset) {
1507 this.stopXpraDesktopPrime();
1508 this._desktopPrimeAttempts = 0;
1509 }
1510 if (this.applyXpraDesktopFrameMode(options.frame || null, options)) return;
1511 if (this._desktopPrimeAttempts >= XPRA_DESKTOP_PRIME_ATTEMPTS) return;
1512 this._desktopPrimeAttempts += 1;
1513 if (this._desktopPrimeTimer) globalThis.clearTimeout(this._desktopPrimeTimer);
1514 this._desktopPrimeTimer = globalThis.setTimeout(() => {
1515 this._desktopPrimeTimer = null;
1516 this.primeXpraDesktopFrame();
1517 }, XPRA_DESKTOP_PRIME_INTERVAL_MS);
1518 },
1519
1520 stopXpraDesktopPrime() {
1521 if (this._desktopPrimeTimer) globalThis.clearTimeout(this._desktopPrimeTimer);
1522 this._desktopPrimeTimer = null;
1523 },
1524
1525 applyXpraDesktopFrameMode(preferredFrame = null, options = {}) {
1526 const frame = this.desktopFrame(preferredFrame);
1527 const remoteWindow = frame?.contentWindow;
1528 if (!remoteWindow) return false;
1529 const requestServerResize = options.requestServerResize === true;
1530 const requestRefresh = options.requestRefresh !== false;
1531 try {
1532 const remoteDocument = frame.contentDocument || remoteWindow.document;
1533 this.installXpraDesktopFrameCss(remoteDocument);
1534 this.installXpraDesktopFramePatches(remoteWindow, remoteDocument);
1535 const client = remoteWindow.client;
1536 if (!client) return false;
1537 this.installXpraDesktopClientPatches(remoteWindow, client);
1538 this.installXpraDesktopCursorPatches(remoteWindow, remoteDocument, client);
1539 this.installXpraDesktopKeyboardBridge(frame, remoteWindow, remoteDocument, client);
1540 this.installXpraDesktopClipboardBridge(frame, remoteWindow, remoteDocument, client);
1541 const container = client.container || remoteDocument?.querySelector?.("#screen");
1542 if (!container) return false;
1543
1544 client.server_is_desktop = true;
1545 client.server_resize_exact = true;
1546 remoteDocument?.body?.classList?.add("desktop");
1547
1548 const windows = Object.values(client.id_to_window || {});
1549 if (!client.connected || !windows.length) return false;
1550
1551 const token = options.token || this.session?.desktop?.token || "";
1552 const displaySize = options.displaySize || this.desktopDisplaySizeForToken(token);
1553 const viewportWidth = Math.round(container.clientWidth || remoteWindow.innerWidth || 0);
1554 const viewportHeight = Math.round(container.clientHeight || remoteWindow.innerHeight || 0);
1555 const width = Math.round(displaySize?.width || viewportWidth || 0);
1556 const height = Math.round(displaySize?.height || viewportHeight || 0);
1557 if (width > 0 && height > 0) {
1558 client.desktop_width = width;
1559 client.desktop_height = height;
1560 }
1561 if (requestServerResize && width > 0 && height > 0 && typeof client._screen_resized === "function") {
1562 client.desktop_width = 0;
1563 client.desktop_height = 0;
1564 client.__a0AllowScreenResize = true;
1565 try {
1566 client._screen_resized(new remoteWindow.Event("resize"));
1567 } finally {
1568 client.__a0AllowScreenResize = false;
1569 }
1570 }
1571
1572 for (const xpraWindow of windows) {
1573 this.normalizeXpraDesktopWindow(xpraWindow, width, height);
1574 xpraWindow.screen_resized?.();
1575 this.normalizeXpraDesktopWindow(xpraWindow, width, height);
1576 xpraWindow.updateCSSGeometry?.();
1577 this.fitXpraDesktopWindowElement(xpraWindow, width, height);
1578 this.installXpraDesktopWheelBridge(remoteWindow, xpraWindow);
1579 if (requestRefresh && xpraWindow.wid != null) client.request_refresh?.(xpraWindow.wid);
1580 }
1581 this.installXpraDesktopAgentBridge(frame, remoteWindow, remoteDocument, client, container);
1582 return true;
1583 } catch (error) {
1584 console.warn("Xpra desktop viewport prime skipped", error);
1585 return false;
1586 }
1587 },
1588
1589 desktopDisplaySizeForToken(token = "") {
1590 const key = String(token || "").trim();
1591 const size = key ? this._desktopDisplaySizes?.[key] : null;
1592 const width = Math.round(Number(size?.width || 0));
1593 const height = Math.round(Number(size?.height || 0));
1594 return width > 0 && height > 0 ? { width, height } : null;
1595 },
1596
1597 rememberDesktopDisplaySize(token = "", width = 0, height = 0) {
1598 const key = String(token || "").trim();
1599 const normalizedWidth = Math.round(Number(width || 0));
1600 const normalizedHeight = Math.round(Number(height || 0));
1601 if (!key || normalizedWidth <= 0 || normalizedHeight <= 0) return null;
1602 this._desktopDisplaySizes = {
1603 ...(this._desktopDisplaySizes || {}),
1604 [key]: { width: normalizedWidth, height: normalizedHeight },
1605 };
1606 return this._desktopDisplaySizes[key];
1607 },
1608
1609 installXpraDesktopAgentBridge(frame, remoteWindow, remoteDocument, client, container) {
1610 if (!frame || !remoteWindow || !remoteDocument || !client) return null;
1611 const store = this;
1612 const finite = (value, fallback = 0) => {
1613 const number = Number(value);
1614 return Number.isFinite(number) ? number : fallback;
1615 };
1616 const metrics = () => {
1617 const desktopWidth = Math.max(1, finite(client.desktop_width || container?.clientWidth || remoteWindow.innerWidth, 1));
1618 const desktopHeight = Math.max(1, finite(client.desktop_height || container?.clientHeight || remoteWindow.innerHeight, 1));
1619 const primaryWindow = Object.values(client.id_to_window || {})[0];
1620 const canvas = primaryWindow?.canvas;
1621 const clientWidth = Math.max(1, finite(canvas?.clientWidth || canvas?.width || container?.clientWidth || remoteWindow.innerWidth, desktopWidth));
1622 const clientHeight = Math.max(1, finite(canvas?.clientHeight || canvas?.height || container?.clientHeight || remoteWindow.innerHeight, desktopHeight));
1623 return {
1624 desktopWidth,
1625 desktopHeight,
1626 clientWidth,
1627 clientHeight,
1628 scaleX: clientWidth / desktopWidth,
1629 scaleY: clientHeight / desktopHeight,
1630 };
1631 };
1632 const bridge = frame.__agentZeroDesktopBridge || {};
1633 Object.assign(bridge, {
1634 ready: true,
1635 state: async (options = {}) => {
1636 const result = await callDesktop("state", {
1637 include_screenshot: options.includeScreenshot === true || options.include_screenshot === true,
1638 });
1639 store._desktopLastState = result;
1640 return result;
1641 },
1642 focus: (options = {}) => store.focusDesktopFrame(frame, { ...options, arm: options.arm !== false }),
1643 requestRefresh: () => {
1644 for (const xpraWindow of Object.values(client.id_to_window || {})) {
1645 if (xpraWindow?.wid != null) client.request_refresh?.(xpraWindow.wid);
1646 }
1647 return true;
1648 },
1649 desktopToClient: (x, y) => {
1650 const value = metrics();
1651 return {
1652 x: Math.round(finite(x) * value.scaleX),
1653 y: Math.round(finite(y) * value.scaleY),
1654 scale_x: value.scaleX,
1655 scale_y: value.scaleY,
1656 };
1657 },
1658 clientToDesktop: (x, y) => {
1659 const value = metrics();
1660 return {
1661 x: Math.round(finite(x) / value.scaleX),
1662 y: Math.round(finite(y) / value.scaleY),
1663 scale_x: value.scaleX,
1664 scale_y: value.scaleY,
1665 };
1666 },
1667 diagnostics: () => store.desktopBridgeDiagnostics(frame),
1668 });
1669 frame.agentZeroDesktop = bridge;
1670 frame.__agentZeroDesktopBridge = bridge;
1671 remoteWindow.agentZeroDesktop = bridge;
1672 remoteWindow.__agentZeroDesktopBridge = bridge;
1673 this._desktopBridgeReady = true;
1674 this.updateDesktopKeyboardCaptureState(frame);
1675 return bridge;
1676 },
1677
1678 desktopBridgeDiagnostics(frame = null) {
1679 return {
1680 ready: this._desktopBridgeReady,
1681 keyboard: this.updateDesktopKeyboardCaptureState(frame),
1682 lastStateOk: this._desktopLastState?.ok ?? null,
1683 };
1684 },
1685
1686 updateDesktopKeyboardCaptureState(frame = null) {
1687 const target = this.desktopFrame(frame);
1688 const client = target?.contentWindow?.client;
1689 const state = {
1690 ready: Boolean(target?.__agentZeroDesktopBridge || target?.contentWindow?.__agentZeroDesktopBridge),
1691 active: Boolean(this._desktopKeyboardActive),
1692 capture: Boolean(client?.capture_keyboard),
1693 focused: Boolean(target && (document.activeElement === target || target.contentDocument?.hasFocus?.())),
1694 };
1695 this._desktopKeyboardCaptureState = state;
1696 return state;
1697 },
1698
1699 normalizeXpraDesktopWindow(xpraWindow, width, height) {
1700 if (!xpraWindow) return;
1701 const normalizedWidth = Math.max(1, Math.round(Number(width || 0)));
1702 const normalizedHeight = Math.max(1, Math.round(Number(height || 0)));
1703 xpraWindow.x = 0;
1704 xpraWindow.y = 0;
1705 xpraWindow.w = normalizedWidth;
1706 xpraWindow.h = normalizedHeight;
1707 xpraWindow.resizable = false;
1708 xpraWindow.decorations = false;
1709 xpraWindow.decorated = false;
1710 xpraWindow.metadata = { ...(xpraWindow.metadata || {}), decorations: false };
1711 xpraWindow._set_decorated?.(false);
1712 xpraWindow.configure_border_class?.();
1713 xpraWindow.leftoffset = 0;
1714 xpraWindow.rightoffset = 0;
1715 xpraWindow.topoffset = 0;
1716 xpraWindow.bottomoffset = 0;
1717 },
1718
1719 fitXpraDesktopWindowElement(xpraWindow, width, height) {
1720 const normalizedWidth = Math.max(1, Math.round(Number(width || 0)));
1721 const normalizedHeight = Math.max(1, Math.round(Number(height || 0)));
1722 const cssWidth = `${normalizedWidth}px`;
1723 const cssHeight = `${normalizedHeight}px`;
1724 const windowElement = xpraWindow?.div;
1725 const canvas = xpraWindow?.canvas;
1726 windowElement?.style?.setProperty("left", "0px", "important");
1727 windowElement?.style?.setProperty("top", "0px", "important");
1728 windowElement?.style?.setProperty("position", "absolute", "important");
1729 windowElement?.style?.setProperty("width", cssWidth, "important");
1730 windowElement?.style?.setProperty("height", cssHeight, "important");
1731 windowElement?.style?.setProperty("transform", "none", "important");
1732 windowElement?.style?.setProperty("margin", "0", "important");
1733 canvas?.style?.setProperty("width", cssWidth, "important");
1734 canvas?.style?.setProperty("height", cssHeight, "important");
1735 canvas?.style?.setProperty("display", "block", "important");
1736 canvas?.style?.setProperty("margin", "0", "important");
1737 if (canvas) {
1738 if (canvas.width !== normalizedWidth) canvas.width = normalizedWidth;
1739 if (canvas.height !== normalizedHeight) canvas.height = normalizedHeight;
1740 canvas.setAttribute("width", String(normalizedWidth));
1741 canvas.setAttribute("height", String(normalizedHeight));
1742 }
1743 },
1744
1745 installXpraDesktopWheelBridge(remoteWindow, xpraWindow) {
1746 const canvas = xpraWindow?.canvas;
1747 if (!remoteWindow || !canvas || canvas.__a0XpraWheelBridgeInstalled) return;
1748 if (typeof xpraWindow.mouse_scroll_cb !== "function") return;
1749 canvas.__a0XpraWheelBridgeInstalled = true;
1750 canvas.addEventListener("wheel", (event) => {
1751 event.stopImmediatePropagation?.();
1752 event.stopPropagation?.();
1753 event.preventDefault?.();
1754 const normalizedEvent = this.xpraDesktopWheelEvent(remoteWindow, canvas, event);
1755 xpraWindow.mouse_scroll_cb(normalizedEvent, xpraWindow);
1756 }, { passive: false, capture: true });
1757 },
1758
1759 xpraDesktopWheelEvent(remoteWindow, canvas, event) {
1760 const finite = (value, fallback = 0) => {
1761 const number = Number(value);
1762 return Number.isFinite(number) ? number : fallback;
1763 };
1764 const deltaMode = finite(event.deltaMode, 0);
1765 const lineHeight = 16;
1766 const pageHeight = Math.max(1, remoteWindow.innerHeight || canvas.clientHeight || 800);
1767 const deltaScale = deltaMode === 1 ? lineHeight : deltaMode === 2 ? pageHeight : 1;
1768 const deltaX = finite(event.deltaX) * deltaScale;
1769 const deltaY = finite(event.deltaY) * deltaScale;
1770 const deltaZ = finite(event.deltaZ) * deltaScale;
1771 const wheelDeltaX = finite(event.wheelDeltaX, -deltaX);
1772 const wheelDeltaY = finite(event.wheelDeltaY, -deltaY);
1773 const wheelDelta = finite(event.wheelDelta, wheelDeltaY || wheelDeltaX);
1774 const getModifierState = (key) => {
1775 if (typeof event.getModifierState === "function") return event.getModifierState(key);
1776 const normalizedKey = String(key || "").toLowerCase();
1777 if (normalizedKey === "alt") return Boolean(event.altKey);
1778 if (normalizedKey === "control") return Boolean(event.ctrlKey);
1779 if (normalizedKey === "meta") return Boolean(event.metaKey);
1780 if (normalizedKey === "shift") return Boolean(event.shiftKey);
1781 return false;
1782 };
1783 const normalizedEvent = Object.create(event);
1784 Object.defineProperties(normalizedEvent, {
1785 target: { value: event.target || canvas },
1786 currentTarget: { value: canvas },
1787 clientX: { value: finite(event.clientX) },
1788 clientY: { value: finite(event.clientY) },
1789 pageX: { value: finite(event.pageX, finite(event.clientX)) },
1790 pageY: { value: finite(event.pageY, finite(event.clientY)) },
1791 screenX: { value: finite(event.screenX) },
1792 screenY: { value: finite(event.screenY) },
1793 offsetX: { value: finite(event.offsetX) },
1794 offsetY: { value: finite(event.offsetY) },
1795 movementX: { value: finite(event.movementX) },
1796 movementY: { value: finite(event.movementY) },
1797 button: { value: finite(event.button) },
1798 buttons: { value: finite(event.buttons) },
1799 which: { value: finite(event.which) },
1800 detail: { value: finite(event.detail) },
1801 deltaX: { value: deltaX },
1802 deltaY: { value: deltaY },
1803 deltaZ: { value: deltaZ },
1804 deltaMode: { value: 0 },
1805 wheelDeltaX: { value: wheelDeltaX },
1806 wheelDeltaY: { value: wheelDeltaY },
1807 wheelDelta: { value: wheelDelta },
1808 altKey: { value: Boolean(event.altKey) },
1809 ctrlKey: { value: Boolean(event.ctrlKey) },
1810 metaKey: { value: Boolean(event.metaKey) },
1811 shiftKey: { value: Boolean(event.shiftKey) },
1812 getModifierState: { value: getModifierState },
1813 preventDefault: { value: () => event.preventDefault?.() },
1814 stopPropagation: { value: () => event.stopPropagation?.() },
1815 stopImmediatePropagation: { value: () => event.stopImmediatePropagation?.() },
1816 });
1817 return normalizedEvent;
1818 },
1819
1820 installXpraDesktopFrameCss(remoteDocument) {
1821 if (!remoteDocument || remoteDocument.getElementById("a0-xpra-desktop-frame-css")) return;
1822 const style = remoteDocument.createElement("style");
1823 style.id = "a0-xpra-desktop-frame-css";
1824 style.textContent = `
1825 html, body, #screen {
1826 width: 100% !important;
1827 height: 100% !important;
1828 overflow: auto !important;
1829 }
1830 #float_menu,
1831 .windowhead,
1832 .windowbuttons {
1833 display: none !important;
1834 }
1835 #shadow_pointer {
1836 display: none !important;
1837 visibility: hidden !important;
1838 opacity: 0 !important;
1839 }
1840 .window,
1841 .window.border,
1842 .window.desktop,
1843 .undecorated,
1844 .undecorated.border,
1845 .undecorated.desktop {
1846 left: 0 !important;
1847 top: 0 !important;
1848 position: absolute !important;
1849 width: 100% !important;
1850 height: 100% !important;
1851 transform: none !important;
1852 margin: 0 !important;
1853 border: 0 !important;
1854 border-radius: 0 !important;
1855 box-shadow: none !important;
1856 }
1857 .window canvas,
1858 .undecorated canvas {
1859 display: block !important;
1860 width: 100% !important;
1861 height: 100% !important;
1862 margin: 0 !important;
1863 border: 0 !important;
1864 border-radius: 0 !important;
1865 box-shadow: none !important;
1866 }
1867 `;
1868 remoteDocument.head?.appendChild(style);
1869 },
1870
1871 installXpraDesktopCursorPatches(remoteWindow, remoteDocument, client) {
1872 if (!remoteWindow || !remoteDocument || !client) return;
1873 const hideShadowPointer = () => {
1874 const pointer = remoteDocument.getElementById?.("shadow_pointer");
1875 pointer?.style?.setProperty("display", "none", "important");
1876 pointer?.style?.setProperty("visibility", "hidden", "important");
1877 pointer?.style?.setProperty("opacity", "0", "important");
1878 };
1879 hideShadowPointer();
1880
1881 const pointerPacket = remoteWindow.PACKET_TYPES?.pointer_position || "pointer-position";
1882 if (!client.__a0XpraDesktopCursorPatched) {
1883 if (typeof client._process_pointer_position === "function") {
1884 client.__a0OriginalProcessPointerPosition = client._process_pointer_position;
1885 }
1886 client._process_pointer_position = function patchedProcessPointerPosition(packet) {
1887 hideShadowPointer();
1888 this.__a0LastPointerPosition = packet;
1889 return false;
1890 };
1891 client.__a0XpraDesktopCursorPatched = true;
1892 }
1893 if (client.packet_handlers && pointerPacket) {
1894 client.packet_handlers[pointerPacket] = client._process_pointer_position;
1895 }
1896 },
1897
1898 installXpraDesktopFramePatches(remoteWindow, remoteDocument) {
1899 if (!remoteWindow || !remoteDocument) return;
1900 remoteWindow.__a0XpraDesktopFramePatches ||= {};
1901 const patches = remoteWindow.__a0XpraDesktopFramePatches;
1902 const isBenignXpraWarning = (args = []) => {
1903 const text = Array.from(args || []).map((value) => String(value || "")).join(" ");
1904 return text.includes("window does not fit in canvas, offsets")
1905 || (text.includes("decode error packet") && text.includes("not found"));
1906 };
1907 if (!patches.consoleWarn && typeof remoteWindow.console?.warn === "function") {
1908 const originalConsoleWarn = remoteWindow.console.warn.bind(remoteWindow.console);
1909 remoteWindow.console.warn = function patchedConsoleWarn(...args) {
1910 if (isBenignXpraWarning(args)) return undefined;
1911 return originalConsoleWarn(...args);
1912 };
1913 patches.consoleWarn = true;
1914 }
1915 if (!patches.noWindowList && typeof remoteWindow.noWindowList === "function") {
1916 const originalNoWindowList = remoteWindow.noWindowList;
1917 remoteWindow.noWindowList = function patchedNoWindowList(...args) {
1918 if (!remoteDocument.querySelector("#open_windows")) return undefined;
1919 return originalNoWindowList.apply(this, args);
1920 };
1921 patches.noWindowList = true;
1922 }
1923 if (!patches.addWindowListItem && typeof remoteWindow.addWindowListItem === "function") {
1924 const originalAddWindowListItem = remoteWindow.addWindowListItem;
1925 remoteWindow.addWindowListItem = function patchedAddWindowListItem(...args) {
1926 if (!remoteDocument.querySelector("#open_windows_list")) return undefined;
1927 return originalAddWindowListItem.apply(this, args);
1928 };
1929 patches.addWindowListItem = true;
1930 }
1931 },
1932
1933 installXpraDesktopClientPatches(remoteWindow, client) {
1934 if (!remoteWindow || !client) return;
1935 if (!client.__a0XpraOffsetWarnPatched && typeof client.warn === "function") {
1936 const originalClientWarn = client.warn.bind(client);
1937 client.warn = function patchedClientWarn(...args) {
1938 const text = Array.from(args || []).map((value) => String(value || "")).join(" ");
1939 if (
1940 text.includes("window does not fit in canvas, offsets")
1941 || (text.includes("decode error packet") && text.includes("not found"))
1942 ) {
1943 return undefined;
1944 }
1945 return originalClientWarn(...args);
1946 };
1947 client.__a0XpraOffsetWarnPatched = true;
1948 }
1949 if (client.__a0XpraDesktopClientPatched) return;
1950 if (typeof client._screen_resized === "function") {
1951 const originalScreenResized = client._screen_resized.bind(client);
1952 client.__a0OriginalScreenResized = originalScreenResized;
1953 client._screen_resized = function patchedScreenResized(event) {
1954 if (client.__a0AllowScreenResize === true) return originalScreenResized(event);
1955 return false;
1956 };
1957 }
1958 client.__a0XpraDesktopClientPatched = true;
1959 },
1960
1961 installXpraDesktopClipboardBridge(frame, remoteWindow, remoteDocument, client) {
1962 if (!frame || !remoteWindow || !remoteDocument || !client) return;
1963 this.ensureDesktopClipboardBridge();
1964 if (remoteWindow.__a0XpraDesktopClipboardBridgeInstalled) return;
1965
1966 const onPaste = (event) => {
1967 this.handleDesktopPasteEvent(event, frame, remoteWindow, client);
1968 };
1969 const onKeydown = (event) => {
1970 if (this.isDesktopPasteShortcut(event)) {
1971 void this.syncHostClipboardToDesktop(frame);
1972 }
1973 };
1974 remoteWindow.addEventListener("paste", onPaste, true);
1975 remoteDocument.addEventListener("paste", onPaste, true);
1976 remoteWindow.addEventListener("keydown", onKeydown, true);
1977 remoteDocument.addEventListener("keydown", onKeydown, true);
1978 remoteWindow.__a0XpraDesktopClipboardBridgeInstalled = true;
1979 remoteWindow.__a0XpraDesktopClipboardBridgeCleanup = () => {
1980 remoteWindow.removeEventListener("paste", onPaste, true);
1981 remoteDocument.removeEventListener("paste", onPaste, true);
1982 remoteWindow.removeEventListener("keydown", onKeydown, true);
1983 remoteDocument.removeEventListener("keydown", onKeydown, true);
1984 remoteWindow.__a0XpraDesktopClipboardBridgeInstalled = false;
1985 };
1986 },
1987
1988 ensureDesktopClipboardBridge() {
1989 if (this._desktopClipboardCleanup) return;
1990
1991 const onPaste = (event) => {
1992 if (!this._desktopKeyboardActive || !this.hasOfficialOffice()) return;
1993 if (isEditableInputTarget(event.target)) return;
1994 const frame = this.desktopFrame();
1995 const remoteWindow = frame?.contentWindow;
1996 const client = remoteWindow?.client;
1997 if (!frame || !remoteWindow || !client) return;
1998 this.handleDesktopPasteEvent(event, frame, remoteWindow, client);
1999 };
2000
2001 document.addEventListener("paste", onPaste, true);
2002 this._desktopClipboardCleanup = () => {
2003 document.removeEventListener("paste", onPaste, true);
2004 this._desktopClipboardCleanup = null;
2005 };
2006 },
2007
2008 stopDesktopClipboardBridge() {
2009 this._desktopClipboardCleanup?.();
2010 },
2011
2012 handleDesktopPasteEvent(event, frame, remoteWindow, client) {
2013 const text = this.desktopClipboardTextFromEvent(event);
2014 if (!text) return false;
2015 if (!this.syncXpraClipboardText(client, text, remoteWindow)) return false;
2016 event.preventDefault?.();
2017 event.stopImmediatePropagation?.();
2018 event.stopPropagation?.();
2019 this.focusDesktopFrame(frame, { arm: true });
2020 return true;
2021 },
2022
2023 desktopClipboardTextFromEvent(event) {
2024 const data = (event?.originalEvent || event)?.clipboardData;
2025 if (!data?.getData) return "";
2026 for (const type of ["text/plain", "text", "Text", "STRING", "UTF8_STRING"]) {
2027 const value = data.getData(type);
2028 if (value) return value;
2029 }
2030 return "";
2031 },
2032
2033 syncXpraClipboardText(client, text, remoteWindow = null) {
2034 const value = String(text ?? "");
2035 if (!client || !value || typeof client.send_clipboard_token !== "function") return false;
2036 const textPlain = remoteWindow?.TEXT_PLAIN || "text/plain";
2037 const utf8String = remoteWindow?.UTF8_STRING || "UTF8_STRING";
2038 const utilities = remoteWindow?.Utilities;
2039 const payload = utilities?.StringToUint8 ? utilities.StringToUint8(value) : value;
2040 client.clipboard_enabled = true;
2041 client.clipboard_direction = "both";
2042 client.clipboard_buffer = value;
2043 client.clipboard_pending = false;
2044 client.send_clipboard_token(payload, [textPlain, utf8String, "TEXT", "STRING"]);
2045 return true;
2046 },
2047
2048 async syncHostClipboardToDesktop(frame = null) {
2049 const target = this.desktopFrame(frame);
2050 const remoteWindow = target?.contentWindow;
2051 const client = remoteWindow?.client;
2052 if (!client || !navigator.clipboard?.readText) return false;
2053 try {
2054 const text = await navigator.clipboard.readText();
2055 return this.syncXpraClipboardText(client, text, remoteWindow);
2056 } catch {
2057 return false;
2058 }
2059 },
2060
2061 isDesktopPasteShortcut(event) {
2062 const key = String(event?.key || "").toLowerCase();
2063 return key === "v" && (event?.ctrlKey || event?.metaKey) && !event?.altKey;
2064 },
2065
2066 installXpraDesktopKeyboardBridge(frame, remoteWindow, remoteDocument, client) {
2067 if (!frame || !remoteWindow || !remoteDocument || !client) return;
2068 this.ensureDesktopKeyboardBridge();
2069 frame.setAttribute("tabindex", "0");
2070 if (remoteWindow.__a0XpraDesktopKeyboardBridgeInstalled) return;
2071
2072 const activate = () => {
2073 if (this._desktopFocusInProgress) return;
2074 this.focusDesktopFrame(frame, { arm: true });
2075 };
2076 const events = ["pointerdown", "mousedown", "touchstart", "focusin"];
2077 for (const eventName of events) {
2078 remoteDocument.addEventListener(eventName, activate, true);
2079 }
2080 remoteWindow.addEventListener("focus", activate, true);
2081 remoteWindow.__a0XpraDesktopKeyboardBridgeInstalled = true;
2082 remoteWindow.__a0XpraDesktopKeyboardBridgeCleanup = () => {
2083 for (const eventName of events) {
2084 remoteDocument.removeEventListener(eventName, activate, true);
2085 }
2086 remoteWindow.removeEventListener("focus", activate, true);
2087 remoteWindow.__a0XpraDesktopKeyboardBridgeInstalled = false;
2088 };
2089 },
2090
2091 ensureDesktopKeyboardBridge() {
2092 if (this._desktopKeyboardCleanup) return;
2093
2094 const deactivateWhenOutsideDesktop = (event) => {
2095 const target = event.target;
2096 if (target?.closest?.(".office-desktop-wrap") || target?.matches?.("[data-office-desktop-frame]")) return;
2097 this._desktopKeyboardActive = false;
2098 };
2099 const forwardKeyboardEvent = (event, pressed) => {
2100 if (!this._desktopKeyboardActive || !this.hasOfficialOffice()) return;
2101 if (event.defaultPrevented || isEditableInputTarget(event.target)) return;
2102
2103 const frame = this.desktopFrame();
2104 if (!frame || document.activeElement === frame) return;
2105 const client = frame.contentWindow?.client;
2106 const handler = pressed ? client?._keyb_onkeydown : client?._keyb_onkeyup;
2107 if (!client?.capture_keyboard || typeof handler !== "function") return;
2108 if (pressed && this.isDesktopPasteShortcut(event)) {
2109 void this.syncHostClipboardToDesktop(frame);
2110 }
2111
2112 const allowDefault = handler.call(client, event);
2113 if (!allowDefault) {
2114 event.preventDefault();
2115 event.stopPropagation();
2116 }
2117 };
2118 const onKeydown = (event) => forwardKeyboardEvent(event, true);
2119 const onKeyup = (event) => forwardKeyboardEvent(event, false);
2120
2121 document.addEventListener("pointerdown", deactivateWhenOutsideDesktop, true);
2122 document.addEventListener("keydown", onKeydown, true);
2123 document.addEventListener("keyup", onKeyup, true);
2124 this._desktopKeyboardCleanup = () => {
2125 document.removeEventListener("pointerdown", deactivateWhenOutsideDesktop, true);
2126 document.removeEventListener("keydown", onKeydown, true);
2127 document.removeEventListener("keyup", onKeyup, true);
2128 this._desktopKeyboardActive = false;
2129 this._desktopKeyboardCleanup = null;
2130 };
2131 },
2132
2133 stopDesktopKeyboardBridge() {
2134 this._desktopKeyboardCleanup?.();
2135 },
2136
2137 queueDesktopResize(options = {}) {
2138 if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) return;
2139 const token = this.session?.desktop?.token || "";
2140 const frame = this.desktopFrame(options.frame || null);
2141 const target = frame?.parentElement || frame;
2142 if (!token || !target) return;
2143 const force = Boolean(options.force);
2144 const serverResize = options.serverResize !== false;
2145 const rect = target.getBoundingClientRect();
2146 const width = Math.round(rect.width);
2147 const height = Math.round(rect.height);
2148 if (width < 320 || height < 220) return;
2149 const key = `${token}:${width}x${height}`;
2150 const refreshFrameOnly = () => {
2151 this.applyXpraDesktopFrameMode(frame, { requestServerResize: false, requestRefresh: false });
2152 };
2153 if (!serverResize) {
2154 refreshFrameOnly();
2155 return;
2156 }
2157 if (key === this._desktopResizeKey || key === this._desktopResizePendingKey) {
2158 refreshFrameOnly();
2159 return;
2160 }
2161 refreshFrameOnly();
2162 if (!force && this.shouldDeferDesktopResize()) {
2163 this._desktopResizePending = true;
2164 return;
2165 }
2166 if (this._desktopResizeTimer) globalThis.clearTimeout(this._desktopResizeTimer);
2167 this._desktopResizePendingKey = key;
2168 this._desktopResizeTimer = globalThis.setTimeout(async () => {
2169 this._desktopResizeTimer = null;
2170 if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) {
2171 if (this._desktopResizePendingKey === key) this._desktopResizePendingKey = "";
2172 return;
2173 }
2174 if (!force && this.shouldDeferDesktopResize()) {
2175 if (this._desktopResizePendingKey === key) this._desktopResizePendingKey = "";
2176 this._desktopResizePending = true;
2177 return;
2178 }
2179 try {
2180 const params = new URLSearchParams({ token, width: String(width), height: String(height) });
2181 const response = await fetch(`/desktop/resize?${params.toString()}`, { credentials: "same-origin" });
2182 if (response.ok) {
2183 const result = await response.json().catch(() => ({}));
2184 const displaySize = this.rememberDesktopDisplaySize(
2185 token,
2186 result?.width || width,
2187 result?.height || height,
2188 );
2189 this._desktopResizeKey = key;
2190 const activeFrame = this.desktopFrame(frame);
2191 const activeTarget = activeFrame?.parentElement || activeFrame;
2192 const activeRect = activeTarget?.getBoundingClientRect?.();
2193 const activeWidth = Math.round(activeRect?.width || 0);
2194 const activeHeight = Math.round(activeRect?.height || 0);
2195 if (activeWidth >= 320 && activeHeight >= 220) {
2196 const activeKey = `${token}:${activeWidth}x${activeHeight}`;
2197 if (activeKey !== key) {
2198 this.queueDesktopResize({ force: true, serverResize: true, frame: activeFrame });
2199 return;
2200 }
2201 }
2202 if (result?.reload) this.reloadDesktopFrame(activeFrame || frame);
2203 this.primeXpraDesktopFrame({ reset: true, frame: activeFrame || frame, token, displaySize });
2204 }
2205 } catch (error) {
2206 console.warn("Desktop resize skipped", error);
2207 } finally {
2208 if (this._desktopResizePendingKey === key) this._desktopResizePendingKey = "";
2209 }
2210 }, DESKTOP_RESIZE_DELAY_MS);
2211 },
2212
2213 reloadDesktopFrame(frame = null) {
2214 const target = this.desktopFrame(frame);
2215 if (!target) return;
2216 const current = target.getAttribute("src") || target.src || this.officialOfficeUrl();
2217 if (!current) return;
2218 try {
2219 const url = new URL(current, window.location.href);
2220 url.searchParams.set("a0_reload", String(Date.now()));
2221 target.setAttribute("src", `${url.pathname}${url.search}`);
2222 } catch {
2223 target.setAttribute("src", current);
2224 }
2225 },
2226
2227 async handleDesktopUrlIntents(intents = []) {
2228 const incoming = Array.isArray(intents)
2229 ? intents.filter((intent) => intent && typeof intent === "object")
2230 : [];
2231 if (!incoming.length) return;
2232 this._desktopUrlIntentQueue.push(...incoming);
2233 if (this._desktopUrlIntentBusy) return;
2234
2235 this._desktopUrlIntentBusy = true;
2236 try {
2237 while (this._desktopUrlIntentQueue.length) {
2238 const intent = this._desktopUrlIntentQueue.shift();
2239 await this.openDesktopUrlIntent(intent);
2240 }
2241 } finally {
2242 this._desktopUrlIntentBusy = false;
2243 }
2244 },
2245
2246 async openDesktopUrlIntent(intent = {}) {
2247 const url = String(intent?.url || "").trim();
2248 const handled = await handleUrlIntent({ url, source: "desktop-url" });
2249 const isEditorIntent = url.startsWith("a0-editor:");
2250 this.setMessage(
2251 handled
2252 ? (isEditorIntent ? "Opened text in Editor" : "Opened link in Browser")
2253 : (isEditorIntent ? "Editor is not available" : "Browser is not available"),
2254 );
2255 },
2256
2257 browserDestinationForDesktopUrl() {
2258 if (this.isDesktopInModal()) return "canvas";
2259 return "modal";
2260 },
2261
2262 isDesktopInModal() {
2263 const modalDesktop = Array.from(document.querySelectorAll(".office-panel"))
2264 .some((panel) => panel.closest?.(".modal") && panel.querySelector?.("[data-office-desktop-frame]"));
2265 if (modalDesktop) return true;
2266 return this._mode === "modal";
2267 },
2268
2269 startDesktopMonitor() {
2270 this.stopDesktopMonitor();
2271 if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) return;
2272 const tabId = this.session?.tab_id || "";
2273 const sessionId = this.session?.desktop_session_id || this.session?.session_id || "";
2274 if (!tabId || !sessionId) return;
2275 this._desktopHeartbeatSessionId = sessionId;
2276 this._desktopHeartbeatTabId = tabId;
2277 this._desktopHeartbeatMisses = 0;
2278
2279 const tick = async () => {
2280 if (!this.session || this.session.tab_id !== tabId || !this.hasOfficialOffice() || !this.isDesktopHostVisible()) return;
2281 try {
2282 const response = await callDesktop("sync", {
2283 desktop_session_id: sessionId,
2284 file_id: this.session.file_id || "",
2285 });
2286 if (response?.intentional_shutdown || response?.shutdown) {
2287 await this.handleIntentionalDesktopShutdown(response);
2288 return;
2289 }
2290 if (response?.ok === false) throw new Error(response.error || "Desktop session closed.");
2291 this._desktopHeartbeatMisses = 0;
2292 await this.handleDesktopUrlIntents(response?.url_intents);
2293 if (response?.document) {
2294 const document = normalizeDocument(response.document);
2295 this.replaceActiveSession({
2296 ...this.session,
2297 document,
2298 path: document.path || this.session.path,
2299 file_id: document.file_id || this.session.file_id,
2300 version: document.version || this.session.version,
2301 });
2302 }
2303 } catch {
2304 if (!this.session || this.session.tab_id !== tabId) return;
2305 this._desktopHeartbeatMisses += 1;
2306 if (this._desktopHeartbeatMisses >= 2) {
2307 await this.handleOfficialOfficeClosed(tabId);
2308 }
2309 }
2310 };
2311
2312 this._desktopHeartbeatTimer = globalThis.setInterval(tick, DESKTOP_HEARTBEAT_MS);
2313 globalThis.setTimeout(tick, Math.min(1200, DESKTOP_HEARTBEAT_MS));
2314 },
2315
2316 stopDesktopMonitor() {
2317 if (this._desktopHeartbeatTimer) {
2318 globalThis.clearInterval(this._desktopHeartbeatTimer);
2319 }
2320 this._desktopHeartbeatTimer = null;
2321 this._desktopHeartbeatSessionId = "";
2322 this._desktopHeartbeatTabId = "";
2323 this._desktopHeartbeatMisses = 0;
2324 },
2325
2326 async handleOfficialOfficeClosed(tabId) {
2327 if (this._desktopIntentionalShutdown) return;
2328 const tab = this.tabs.find((item) => item.tab_id === tabId);
2329 const hiddenDesktopDocument = !tab && this.session?.tab_id === tabId && this.isDesktopOfficeDocument(this.session)
2330 ? this.session
2331 : null;
2332 const target = tab || hiddenDesktopDocument;
2333 if (!target || target._desktopClosed) return;
2334 target._desktopClosed = true;
2335 this.stopDesktopMonitor();
2336 this.stopDesktopResizeObserver();
2337 this.stopXpraDesktopPrime();
2338 this.message = "Desktop is restarting";
2339 await this.ensureDesktopSession({
2340 force: true,
2341 select: this.activeTabId === tabId || Boolean(hiddenDesktopDocument),
2342 message: "Desktop is restarting",
2343 });
2344 target._desktopClosed = false;
2345 await this.refresh();
2346 },
2347
2348 defaultTitle(kind, fmt) {
2349 const date = new Date().toISOString().slice(0, 10);
2350 if (fmt === "odt") return `Writer ${date}`;
2351 if (fmt === "docx") return `DOCX ${date}`;
2352 if (kind === "spreadsheet") return `Spreadsheet ${date}`;
2353 if (kind === "presentation") return `Presentation ${date}`;
2354 return `Document ${date}`;
2355 },
2356
2357 tabTitle(tab = {}) {
2358 tab = tab || {};
2359 return tab.title || tab.document?.basename || basename(tab.path);
2360 },
2361
2362 tabLabel(tab = {}) {
2363 tab = tab || {};
2364 const title = this.tabTitle(tab);
2365 return tab.dirty ? `${title} unsaved` : title;
2366 },
2367
2368 tabIcon(tab = {}) {
2369 tab = tab || {};
2370 const ext = String(tab.extension || tab.document?.extension || "").toLowerCase();
2371 if (this.isDesktopSession(tab)) return "desktop_windows";
2372 if (ext === "md") return "article";
2373 if (ext === "odt" || ext === "docx") return "description";
2374 if (ext === "ods" || ext === "xlsx") return "table_chart";
2375 if (ext === "odp" || ext === "pptx") return "co_present";
2376 if (ext === "txt") return "notes";
2377 return "draft";
2378 },
2379
2380 async runNewMenuAction(action = "") {
2381 const normalized = String(action || "").trim().toLowerCase();
2382 if (normalized === "open") return await this.openFileBrowser();
2383 if (normalized === "writer") return await this.create("document", "odt");
2384 if (normalized === "spreadsheet") return await this.create("spreadsheet", "ods");
2385 if (normalized === "presentation") return await this.create("presentation", "odp");
2386 return null;
2387 },
2388
2389 installHeaderNewMenu(header = null) {
2390 if (!header || header.querySelector(".office-header-actions")) return () => {};
2391
2392 const root = globalThis.document.createElement("div");
2393 root.className = "office-header-actions surface-modal-new-action";
2394 root.innerHTML = `
2395 <button type="button" class="office-header-new-button surface-modal-new-button" aria-haspopup="menu" aria-expanded="false">
2396 <x-icon aria-hidden="true" name="add"></x-icon>
2397 <span>New</span>
2398 <x-icon class="office-new-chevron" aria-hidden="true" name="expand_more"></x-icon>
2399 </button>
2400 <div class="office-new-menu" role="menu" hidden>
2401 <button type="button" class="office-new-menu-item" role="menuitem" data-office-new-action="open">
2402 <x-icon aria-hidden="true" name="folder_open"></x-icon>
2403 <span>Open</span>
2404 </button>
2405 <button type="button" class="office-new-menu-item" role="menuitem" data-office-new-action="writer">
2406 <x-icon aria-hidden="true" name="description"></x-icon>
2407 <span>Writer</span>
2408 </button>
2409 <button type="button" class="office-new-menu-item" role="menuitem" data-office-new-action="spreadsheet">
2410 <x-icon aria-hidden="true" name="table_chart"></x-icon>
2411 <span>Spreadsheet</span>
2412 </button>
2413 <button type="button" class="office-new-menu-item" role="menuitem" data-office-new-action="presentation">
2414 <x-icon aria-hidden="true" name="co_present"></x-icon>
2415 <span>Presentation</span>
2416 </button>
2417 </div>
2418 `;
2419
2420 const button = root.querySelector(".office-header-new-button");
2421 const menu = root.querySelector(".office-new-menu");
2422 const setOpen = (open) => {
2423 root.classList.toggle("is-open", open);
2424 button?.setAttribute("aria-expanded", open.toString());
2425 if (menu) menu.hidden = !open;
2426 };
2427 const onButtonClick = (event) => {
2428 event.preventDefault();
2429 event.stopPropagation();
2430 setOpen(!root.classList.contains("is-open"));
2431 };
2432 const onDocumentClick = (event) => {
2433 if (!root.contains(event.target)) setOpen(false);
2434 };
2435 const onDocumentKeydown = (event) => {
2436 if (event.key === "Escape") setOpen(false);
2437 };
2438
2439 button?.addEventListener("click", onButtonClick);
2440 for (const item of root.querySelectorAll("[data-office-new-action]")) {
2441 item.addEventListener("click", async (event) => {
2442 event.preventDefault();
2443 event.stopPropagation();
2444 const action = event.currentTarget?.dataset?.officeNewAction || "";
2445 setOpen(false);
2446 await this.runNewMenuAction(action);
2447 });
2448 }
2449 globalThis.document.addEventListener("click", onDocumentClick);
2450 globalThis.document.addEventListener("keydown", onDocumentKeydown);
2451
2452 placeSurfaceModalHeaderAction(header, root, "new");
2453
2454 setOpen(false);
2455 return () => {
2456 button?.removeEventListener("click", onButtonClick);
2457 globalThis.document.removeEventListener("click", onDocumentClick);
2458 globalThis.document.removeEventListener("keydown", onDocumentKeydown);
2459 root.remove();
2460 };
2461 },
2462
2463 setupFloatingModal(element = null) {
2464 const root = element || globalThis.document?.querySelector(".office-panel");
2465 const modal = root?.closest?.(".modal");
2466 const inner = root?.closest?.(".modal-inner");
2467 const body = root?.closest?.(".modal-bd");
2468 const header = inner?.querySelector?.(".modal-header");
2469 if (!inner || !body || !header || inner.dataset.officeModalReady === "1") return;
2470
2471 inner.dataset.officeModalReady = "1";
2472 modal?.classList?.add("surface-floating", "modal-floating", "modal-no-backdrop");
2473 inner.classList.add("surface-modal", "office-modal", "modal-no-backdrop");
2474 body.classList.add("office-modal-body");
2475 header.style.cursor = "move";
2476
2477 const inset = 8;
2478 const minWidth = 720;
2479 const minHeight = 520;
2480 const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
2481 const cleanup = [];
2482 let beforeFocusBounds = null;
2483 let dragging = false;
2484 let resizing = false;
2485 let pointerId = 0;
2486 let startX = 0;
2487 let startY = 0;
2488 let startLeft = 0;
2489 let startTop = 0;
2490 let startWidth = 0;
2491 let startHeight = 0;
2492 let resizeMode = "";
2493
2494 const newMenuCleanup = this.installHeaderNewMenu(header);
2495
2496 const currentBounds = () => {
2497 const rect = inner.getBoundingClientRect();
2498 return {
2499 left: rect.left,
2500 top: rect.top,
2501 width: rect.width,
2502 height: rect.height,
2503 };
2504 };
2505
2506 const normalizedBounds = (bounds) => {
2507 const maxWidth = Math.max(320, globalThis.innerWidth - inset * 2);
2508 const maxHeight = Math.max(320, globalThis.innerHeight - inset * 2);
2509 const safeMinWidth = Math.min(minWidth, maxWidth);
2510 const safeMinHeight = Math.min(minHeight, maxHeight);
2511 const width = clamp(bounds.width, safeMinWidth, maxWidth);
2512 const height = clamp(bounds.height, safeMinHeight, maxHeight);
2513 return {
2514 width,
2515 height,
2516 left: clamp(bounds.left, inset, Math.max(inset, globalThis.innerWidth - width - inset)),
2517 top: clamp(bounds.top, inset, Math.max(inset, globalThis.innerHeight - height - inset)),
2518 };
2519 };
2520
2521 const setBounds = (bounds) => {
2522 const next = normalizedBounds(bounds);
2523 inner.style.position = "fixed";
2524 inner.style.transform = "none";
2525 inner.style.left = `${Math.round(next.left)}px`;
2526 inner.style.top = `${Math.round(next.top)}px`;
2527 inner.style.width = `${Math.round(next.width)}px`;
2528 inner.style.height = `${Math.round(next.height)}px`;
2529 inner.style.right = "auto";
2530 inner.style.bottom = "auto";
2531 inner.style.margin = "0";
2532 };
2533
2534 const ensurePosition = () => {
2535 setBounds(currentBounds());
2536 };
2537
2538 const shield = globalThis.document.createElement("div");
2539 shield.className = "office-modal-input-shield";
2540 inner.appendChild(shield);
2541 cleanup.push(() => shield.remove());
2542
2543 const setShield = (visible, cursor = "") => {
2544 shield.style.display = visible ? "block" : "none";
2545 shield.style.cursor = cursor;
2546 };
2547
2548 const focusButton = globalThis.document.createElement("button");
2549 focusButton.type = "button";
2550 focusButton.className = "surface-button office-modal-focus-button";
2551 focusButton.innerHTML = '<x-icon aria-hidden="true" name="fullscreen"></x-icon>';
2552 const updateFocusButton = (active) => {
2553 const label = active ? "Restore size" : "Focus mode";
2554 focusButton.setAttribute("aria-label", label);
2555 focusButton.setAttribute("title", label);
2556 focusButton.querySelector("x-icon").name = active ? "fullscreen_exit" : "fullscreen";
2557 };
2558 updateFocusButton(false);
2559 placeSurfaceModalHeaderAction(header, focusButton, "window");
2560 cleanup.push(() => focusButton.remove());
2561
2562 const setFocusMode = (enabled) => {
2563 ensurePosition();
2564 if (enabled) {
2565 beforeFocusBounds = currentBounds();
2566 inner.classList.add("is-focus-mode");
2567 setBounds({
2568 left: inset,
2569 top: inset,
2570 width: globalThis.innerWidth - inset * 2,
2571 height: globalThis.innerHeight - inset * 2,
2572 });
2573 updateFocusButton(true);
2574 return;
2575 }
2576 inner.classList.remove("is-focus-mode");
2577 setBounds(beforeFocusBounds || currentBounds());
2578 beforeFocusBounds = null;
2579 updateFocusButton(false);
2580 };
2581
2582 const onFocusClick = () => setFocusMode(!inner.classList.contains("is-focus-mode"));
2583 focusButton.addEventListener("click", onFocusClick);
2584 cleanup.push(() => focusButton.removeEventListener("click", onFocusClick));
2585
2586 const onPointerDown = (event) => {
2587 if (event.button !== 0) return;
2588 if (event.target?.closest?.("button,a,input,textarea,select")) return;
2589 if (inner.classList.contains("is-focus-mode")) return;
2590 ensurePosition();
2591 const rect = inner.getBoundingClientRect();
2592 dragging = true;
2593 pointerId = event.pointerId;
2594 startX = event.clientX;
2595 startY = event.clientY;
2596 startLeft = rect.left;
2597 startTop = rect.top;
2598 startWidth = rect.width;
2599 startHeight = rect.height;
2600 inner.classList.add("is-dragging");
2601 setShield(true, "move");
2602 header.setPointerCapture?.(pointerId);
2603 event.preventDefault();
2604 };
2605
2606 const onPointerMove = (event) => {
2607 if (!dragging || event.pointerId !== pointerId) return;
2608 setBounds({
2609 left: startLeft + event.clientX - startX,
2610 top: startTop + event.clientY - startY,
2611 width: startWidth,
2612 height: startHeight,
2613 });
2614 };
2615
2616 const onPointerUp = (event) => {
2617 if (!dragging || event.pointerId !== pointerId) return;
2618 dragging = false;
2619 inner.classList.remove("is-dragging");
2620 setShield(false);
2621 header.releasePointerCapture?.(pointerId);
2622 };
2623
2624 const createResizeHandle = (mode) => {
2625 const handle = globalThis.document.createElement("div");
2626 handle.className = `office-modal-resizer is-${mode}`;
2627 handle.dataset.officeResize = mode;
2628 inner.appendChild(handle);
2629 cleanup.push(() => handle.remove());
2630 return handle;
2631 };
2632
2633 const onResizeDown = (event) => {
2634 if (event.button !== 0 || inner.classList.contains("is-focus-mode")) return;
2635 ensurePosition();
2636 const rect = inner.getBoundingClientRect();
2637 resizing = true;
2638 resizeMode = event.currentTarget.dataset.officeResize || "";
2639 pointerId = event.pointerId;
2640 startX = event.clientX;
2641 startY = event.clientY;
2642 startLeft = rect.left;
2643 startTop = rect.top;
2644 startWidth = rect.width;
2645 startHeight = rect.height;
2646 inner.classList.add("is-resizing");
2647 this.suspendDesktopResize();
2648 setShield(true, resizeMode === "right" ? "ew-resize" : resizeMode === "bottom" ? "ns-resize" : "nwse-resize");
2649 event.currentTarget.setPointerCapture?.(pointerId);
2650 event.preventDefault();
2651 event.stopPropagation();
2652 };
2653
2654 const onResizeMove = (event) => {
2655 if (!resizing || event.pointerId !== pointerId) return;
2656 const dx = event.clientX - startX;
2657 const dy = event.clientY - startY;
2658 setBounds({
2659 left: startLeft,
2660 top: startTop,
2661 width: resizeMode === "bottom" ? startWidth : startWidth + dx,
2662 height: resizeMode === "right" ? startHeight : startHeight + dy,
2663 });
2664 };
2665
2666 const onResizeUp = (event) => {
2667 if (!resizing || event.pointerId !== pointerId) return;
2668 resizing = false;
2669 resizeMode = "";
2670 inner.classList.remove("is-resizing");
2671 setShield(false);
2672 event.currentTarget.releasePointerCapture?.(pointerId);
2673 this.resumeDesktopResize();
2674 };
2675
2676 header.addEventListener("pointerdown", onPointerDown);
2677 header.addEventListener("pointermove", onPointerMove);
2678 header.addEventListener("pointerup", onPointerUp);
2679 header.addEventListener("pointercancel", onPointerUp);
2680 cleanup.push(() => header.removeEventListener("pointerdown", onPointerDown));
2681 cleanup.push(() => header.removeEventListener("pointermove", onPointerMove));
2682 cleanup.push(() => header.removeEventListener("pointerup", onPointerUp));
2683 cleanup.push(() => header.removeEventListener("pointercancel", onPointerUp));
2684
2685 for (const mode of ["right", "bottom", "corner"]) {
2686 const handle = createResizeHandle(mode);
2687 handle.addEventListener("pointerdown", onResizeDown);
2688 handle.addEventListener("pointermove", onResizeMove);
2689 handle.addEventListener("pointerup", onResizeUp);
2690 handle.addEventListener("pointercancel", onResizeUp);
2691 cleanup.push(() => handle.removeEventListener("pointerdown", onResizeDown));
2692 cleanup.push(() => handle.removeEventListener("pointermove", onResizeMove));
2693 cleanup.push(() => handle.removeEventListener("pointerup", onResizeUp));
2694 cleanup.push(() => handle.removeEventListener("pointercancel", onResizeUp));
2695 }
2696
2697 const onWindowResize = () => {
2698 if (inner.classList.contains("is-focus-mode")) {
2699 setBounds({
2700 left: inset,
2701 top: inset,
2702 width: globalThis.innerWidth - inset * 2,
2703 height: globalThis.innerHeight - inset * 2,
2704 });
2705 return;
2706 }
2707 ensurePosition();
2708 };
2709 globalThis.addEventListener("resize", onWindowResize);
2710 cleanup.push(() => globalThis.removeEventListener("resize", onWindowResize));
2711
2712 if (globalThis.requestAnimationFrame) {
2713 globalThis.requestAnimationFrame(ensurePosition);
2714 } else {
2715 globalThis.setTimeout(ensurePosition, 0);
2716 }
2717 this._floatingCleanup = () => {
2718 newMenuCleanup?.();
2719 cleanup.splice(0).reverse().forEach((entry) => entry());
2720 modal?.classList?.remove("surface-floating", "modal-floating", "modal-no-backdrop");
2721 inner.classList.remove("is-dragging", "is-resizing", "is-focus-mode");
2722 this._desktopResizeSuspended = false;
2723 this._desktopResizePending = false;
2724 delete inner.dataset.officeModalReady;
2725 };
2726 },
2727 };
2728
2729 export const store = createStore("desktop", model);