Spaces:
Running
Running
Download plugins/_desktop/webui/desktop-store.js from Leon4gr45/openoperator: direct link, hf CLI and curl.
- Browser
- Download file 101 kB
-
https://huggingface.co/spaces/Leon4gr45/openoperator/resolve/main/plugins/_desktop/webui/desktop-store.js
- Command line
-
hf download hf://spaces/Leon4gr45/openoperator/plugins/_desktop/webui/desktop-store.js
-
curl -L -o desktop-store.js https://huggingface.co/spaces/Leon4gr45/openoperator/resolve/main/plugins/_desktop/webui/desktop-store.js
101 kB
| import { createStore } from "/js/AlpineStore.js"; | |
| import { callJsonApi } from "/js/api.js"; | |
| import { getNamespacedClient } from "/js/websocket.js"; | |
| import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js"; | |
| import { handleUrlIntent, placeSurfaceModalHeaderAction } from "/js/surfaces.js"; | |
| const officeSocket = getNamespacedClient("/ws"); | |
| officeSocket.addHandlers(["ws_webui"]); | |
| const SAVE_MESSAGE_MS = 1800; | |
| const INPUT_PUSH_DELAY_MS = 650; | |
| const DESKTOP_HEARTBEAT_MS = 3500; | |
| const DESKTOP_RESIZE_DELAY_MS = 80; | |
| const DESKTOP_START_MESSAGE = "Starting Agent Zero Desktop environment"; | |
| const DESKTOP_RUNTIME_INSTALL_MESSAGE = "Installing Agent Zero Desktop runtime dependencies. This can take a few minutes after an update."; | |
| const DESKTOP_RUNTIME_INSTALL_POLL_MS = 4000; | |
| const DESKTOP_RUNTIME_INSTALL_TIMEOUT_MS = 10 * 60 * 1000; | |
| const XPRA_DESKTOP_PRIME_INTERVAL_MS = 220; | |
| const XPRA_DESKTOP_PRIME_ATTEMPTS = 120; | |
| const SYSTEM_DESKTOP_FILE_ID = "system-desktop"; | |
| const URL_INTENT_PANEL_TIMEOUT_MS = 5000; | |
| const DESKTOP_SHUTDOWN_STORAGE_KEY = "a0.desktop.shutdown"; | |
| const MAX_HISTORY = 80; | |
| function currentContextId() { | |
| try { | |
| return globalThis.getContext?.() || ""; | |
| } catch { | |
| return ""; | |
| } | |
| } | |
| function basename(path = "") { | |
| const value = String(path || "").split("?")[0].split("#")[0]; | |
| return value.split("/").filter(Boolean).pop() || "Untitled"; | |
| } | |
| function extensionOf(path = "") { | |
| const name = basename(path).toLowerCase(); | |
| const index = name.lastIndexOf("."); | |
| return index >= 0 ? name.slice(index + 1) : ""; | |
| } | |
| function isOfficialExtension(extension = "") { | |
| return ["odt", "ods", "odp", "docx", "xlsx", "pptx", "txt"].includes(String(extension || "").toLowerCase()); | |
| } | |
| function parentPath(path = "") { | |
| const normalized = String(path || "").split("?")[0].split("#")[0].replace(/\/+$/, ""); | |
| const index = normalized.lastIndexOf("/"); | |
| if (index <= 0) return "/"; | |
| return normalized.slice(0, index); | |
| } | |
| function uniqueTabId(session = {}) { | |
| return String(session.file_id || session.session_id || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`); | |
| } | |
| function editorContainsFocus(element) { | |
| const active = document.activeElement; | |
| return Boolean(element && active && (element === active || element.contains(active))); | |
| } | |
| function isEditableInputTarget(target) { | |
| const element = target?.nodeType === 1 ? target : target?.parentElement; | |
| const editable = element?.closest?.("input, textarea, select, [contenteditable='true'], [contenteditable=''], [role='textbox']"); | |
| if (!editable) return false; | |
| if (editable.tagName !== "INPUT") return true; | |
| const type = String(editable.getAttribute("type") || "text").toLowerCase(); | |
| return !["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"].includes(type); | |
| } | |
| function normalizeModalPath(path = "") { | |
| return String(path || "").replace(/^\/+/, ""); | |
| } | |
| function isModalPathOpen(path = "") { | |
| const normalized = normalizeModalPath(path); | |
| return Boolean( | |
| globalThis.isModalOpen?.(path) | |
| || globalThis.isModalOpen?.(`/${normalized}`) | |
| || globalThis.isModalOpen?.(normalized) | |
| ); | |
| } | |
| function waitForElementByPredicate(predicate, timeoutMs = URL_INTENT_PANEL_TIMEOUT_MS) { | |
| const found = predicate(); | |
| if (found) return Promise.resolve(found); | |
| return new Promise((resolve) => { | |
| const timeout = globalThis.setTimeout(() => { | |
| observer.disconnect(); | |
| resolve(predicate()); | |
| }, timeoutMs); | |
| const observer = new MutationObserver(() => { | |
| const element = predicate(); | |
| if (!element) return; | |
| globalThis.clearTimeout(timeout); | |
| observer.disconnect(); | |
| resolve(element); | |
| }); | |
| observer.observe(document.body, { childList: true, subtree: true }); | |
| }); | |
| } | |
| function browserPanelForMode(mode = "modal") { | |
| const panels = Array.from(document.querySelectorAll(".browser-panel")); | |
| if (mode === "canvas") { | |
| return panels.find((panel) => panel.closest?.('[data-surface-id="browser"]')) || null; | |
| } | |
| return panels.find((panel) => panel.closest?.(".modal")) || null; | |
| } | |
| function placeCaretAtEnd(element) { | |
| if (!element) return; | |
| if (element.tagName === "TEXTAREA" || element.tagName === "INPUT") { | |
| const length = element.value?.length || 0; | |
| element.selectionStart = length; | |
| element.selectionEnd = length; | |
| return; | |
| } | |
| const selection = globalThis.getSelection?.(); | |
| const range = document.createRange?.(); | |
| if (!selection || !range) return; | |
| range.selectNodeContents(element); | |
| range.collapse(false); | |
| selection.removeAllRanges(); | |
| selection.addRange(range); | |
| } | |
| function sleep(ms) { | |
| return new Promise((resolve) => globalThis.setTimeout(resolve, ms)); | |
| } | |
| function normalizeDocument(doc = {}) { | |
| const path = doc.path || ""; | |
| const extension = String(doc.extension || extensionOf(path)).toLowerCase(); | |
| return { | |
| ...doc, | |
| extension, | |
| title: doc.title || doc.basename || basename(path), | |
| basename: doc.basename || basename(path), | |
| path, | |
| }; | |
| } | |
| function normalizeSession(payload = {}) { | |
| const document = normalizeDocument(payload.document || payload); | |
| const extension = String(payload.extension || document.extension || "").toLowerCase(); | |
| return { | |
| ...payload, | |
| document, | |
| extension, | |
| file_id: payload.file_id || document.file_id || "", | |
| path: document.path || payload.path || "", | |
| title: payload.title || document.title || document.basename || basename(document.path), | |
| tab_id: uniqueTabId(payload), | |
| text: String(payload.text || ""), | |
| desktop: payload.desktop || null, | |
| desktop_session_id: payload.desktop_session_id || payload.desktop?.session_id || "", | |
| dirty: false, | |
| }; | |
| } | |
| async function callOffice(action, payload = {}) { | |
| return await callJsonApi("/plugins/_office/office_session", { | |
| action, | |
| ctxid: currentContextId(), | |
| ...payload, | |
| }); | |
| } | |
| async function callDesktop(action, payload = {}) { | |
| return await callJsonApi("/plugins/_desktop/desktop_session", { | |
| action, | |
| ctxid: currentContextId(), | |
| ...payload, | |
| }); | |
| } | |
| async function requestOffice(eventType, payload = {}, timeoutMs = 5000) { | |
| const response = await officeSocket.request(eventType, { | |
| ctxid: currentContextId(), | |
| ...payload, | |
| }, { timeoutMs }); | |
| const results = Array.isArray(response?.results) ? response.results : []; | |
| const first = results.find((item) => item?.ok === true && isOfficeSocketData(item?.data)) | |
| || results.find((item) => item?.ok === true); | |
| if (!first) { | |
| const error = results.find((item) => item?.error)?.error; | |
| throw new Error(error?.error || error?.code || `${eventType} failed`); | |
| } | |
| if (first.data?.office_error) { | |
| const error = first.data.office_error; | |
| throw new Error(error.error || error.code || `${eventType} failed`); | |
| } | |
| return first.data || {}; | |
| } | |
| function isOfficeSocketData(data) { | |
| if (!data || typeof data !== "object") return false; | |
| return ( | |
| Object.prototype.hasOwnProperty.call(data, "office_error") | |
| || Object.prototype.hasOwnProperty.call(data, "ok") | |
| || Object.prototype.hasOwnProperty.call(data, "session_id") | |
| || Object.prototype.hasOwnProperty.call(data, "document") | |
| || Object.prototype.hasOwnProperty.call(data, "desktop") | |
| || Object.prototype.hasOwnProperty.call(data, "closed") | |
| ); | |
| } | |
| const model = { | |
| status: null, | |
| tabs: [], | |
| activeTabId: "", | |
| session: null, | |
| loading: false, | |
| saving: false, | |
| dirty: false, | |
| error: "", | |
| message: "", | |
| editorText: "", | |
| _root: null, | |
| _mode: "canvas", | |
| _saveMessageTimer: null, | |
| _inputTimer: null, | |
| _history: [], | |
| _historyIndex: -1, | |
| _pendingFocus: false, | |
| _pendingFocusEnd: true, | |
| _focusAttempts: 0, | |
| _floatingCleanup: null, | |
| _desktopHeartbeatTimer: null, | |
| _desktopHeartbeatSessionId: "", | |
| _desktopHeartbeatTabId: "", | |
| _desktopHeartbeatMisses: 0, | |
| _desktopResizeCleanup: null, | |
| _desktopResizeTarget: null, | |
| _desktopResizeTimer: null, | |
| _desktopResizeKey: "", | |
| _desktopResizePendingKey: "", | |
| _desktopResizeSuspended: false, | |
| _desktopResizePending: false, | |
| _desktopViewportSyncTimers: [], | |
| _desktopHostVisible: false, | |
| _desktopPrimeTimer: null, | |
| _desktopPrimeAttempts: 0, | |
| _desktopKeyboardActive: false, | |
| _desktopFocusInProgress: false, | |
| _desktopBridgeReady: false, | |
| _desktopKeyboardCaptureState: { ready: false, active: false, capture: false, focused: false }, | |
| _desktopLastState: null, | |
| _desktopKeyboardCleanup: null, | |
| _desktopClipboardCleanup: null, | |
| _desktopStarting: null, | |
| _desktopUrlIntentBusy: false, | |
| _desktopUrlIntentQueue: [], | |
| _desktopFrame: null, | |
| _desktopFrameHost: null, | |
| _desktopFrameLoadHandler: null, | |
| _desktopKeepaliveHost: null, | |
| _desktopDisplaySizes: {}, | |
| _desktopIntentionalShutdown: false, | |
| async init(element = null) { | |
| this.restoreDesktopShutdownState(); | |
| return await this.onMount(element, { mode: "canvas" }); | |
| }, | |
| async onMount(element = null, options = {}) { | |
| if (element) this._root = element; | |
| this._mode = options?.mode === "modal" ? "modal" : "canvas"; | |
| if (this._mode === "modal") { | |
| this._desktopHostVisible = true; | |
| this.setupFloatingModal(element); | |
| await this.onOpen({ source: "modal" }); | |
| return; | |
| } | |
| this.queueRender(); | |
| }, | |
| async onOpen(payload = {}) { | |
| this.restoreDesktopShutdownState(); | |
| await this.refresh(); | |
| if (payload?.path || payload?.file_id) { | |
| await this.openSession({ | |
| path: payload.path || "", | |
| file_id: payload.file_id || "", | |
| refresh: payload.refresh === true, | |
| source: payload.source || "", | |
| }); | |
| } else if (this._desktopIntentionalShutdown) { | |
| this.session = null; | |
| this.activeTabId = ""; | |
| this.editorText = ""; | |
| this.dirty = false; | |
| } else { | |
| await this.ensureDesktopSession({ select: !this.session }); | |
| } | |
| this.restoreDesktopFrames(); | |
| this.requestDesktopViewportSync({ force: true }); | |
| }, | |
| beforeHostHidden(options = {}) { | |
| this._desktopHostVisible = false; | |
| this.flushInput(); | |
| this.clearDesktopViewportSyncTimers(); | |
| this.stopDesktopMonitor(); | |
| this.stopDesktopKeyboardBridge(); | |
| this.stopDesktopClipboardBridge(); | |
| this.unloadDesktopFrames(); | |
| }, | |
| cleanup() { | |
| const wasModal = this._mode === "modal"; | |
| this.flushInput(); | |
| this.stopDesktopMonitor(); | |
| this.stopDesktopResizeObserver(); | |
| this.clearDesktopViewportSyncTimers(); | |
| this.stopXpraDesktopPrime(); | |
| this.stopDesktopKeyboardBridge(); | |
| this.stopDesktopClipboardBridge(); | |
| if (!this._desktopIntentionalShutdown) this.moveDesktopFrameToKeepalive(); | |
| this._floatingCleanup?.(); | |
| this._floatingCleanup = null; | |
| if (wasModal) { | |
| this._root = null; | |
| this._mode = "canvas"; | |
| this._desktopHostVisible = false; | |
| } | |
| }, | |
| async refresh() { | |
| try { | |
| const status = await callDesktop("status"); | |
| this.status = status || {}; | |
| this.error = ""; | |
| } catch (error) { | |
| this.error = error instanceof Error ? error.message : String(error); | |
| } | |
| }, | |
| restoreDesktopShutdownState() { | |
| try { | |
| this._desktopIntentionalShutdown = localStorage.getItem(DESKTOP_SHUTDOWN_STORAGE_KEY) === "1"; | |
| } catch { | |
| this._desktopIntentionalShutdown = Boolean(this._desktopIntentionalShutdown); | |
| } | |
| }, | |
| persistDesktopShutdownState() { | |
| try { | |
| if (this._desktopIntentionalShutdown) { | |
| localStorage.setItem(DESKTOP_SHUTDOWN_STORAGE_KEY, "1"); | |
| } else { | |
| localStorage.removeItem(DESKTOP_SHUTDOWN_STORAGE_KEY); | |
| } | |
| } catch { | |
| // Shutdown state is still correct for this page even without storage. | |
| } | |
| }, | |
| setDesktopIntentionalShutdown(value) { | |
| this._desktopIntentionalShutdown = Boolean(value); | |
| this.persistDesktopShutdownState(); | |
| }, | |
| isDesktopShutdown() { | |
| return Boolean(this._desktopIntentionalShutdown); | |
| }, | |
| shouldShowDesktopEmptyState() { | |
| return Boolean(this._desktopIntentionalShutdown && !this.session); | |
| }, | |
| async restartDesktopSession() { | |
| this.error = ""; | |
| const session = await this.ensureDesktopSession({ | |
| force: true, | |
| restart: true, | |
| select: true, | |
| message: "Restarting Agent Zero Desktop environment", | |
| }); | |
| if (!session) { | |
| this.setDesktopIntentionalShutdown(true); | |
| return null; | |
| } | |
| this.restoreDesktopFrames(); | |
| this.requestDesktopViewportSync({ force: true }); | |
| return session; | |
| }, | |
| async shutdownDesktop(options = {}) { | |
| this.loading = options.progress !== false; | |
| this.message = this.loading ? "Shutting down Desktop" : this.message; | |
| this.error = ""; | |
| try { | |
| const response = await callDesktop("shutdown", { | |
| save_first: options.saveFirst !== false, | |
| source: options.source || "ui", | |
| }); | |
| await this.handleIntentionalDesktopShutdown(response); | |
| return response; | |
| } catch (error) { | |
| this.error = error instanceof Error ? error.message : String(error); | |
| return null; | |
| } finally { | |
| if (options.progress !== false) { | |
| this.loading = false; | |
| if (this.message === "Shutting down Desktop") this.message = ""; | |
| } | |
| } | |
| }, | |
| async handleIntentionalDesktopShutdown(response = {}) { | |
| this.setDesktopIntentionalShutdown(true); | |
| this.stopDesktopMonitor(); | |
| this.stopDesktopResizeObserver(); | |
| this.clearDesktopViewportSyncTimers(); | |
| this.stopXpraDesktopPrime(); | |
| this.stopDesktopKeyboardBridge(); | |
| this.stopDesktopClipboardBridge(); | |
| this.destroyDesktopFrame(); | |
| const activeTabId = this.activeTabId; | |
| this.tabs = this.tabs.filter((tab) => !this.isDesktopSession(tab) && !this.hasOfficialOffice(tab)); | |
| if (!this.tabs.some((tab) => tab.tab_id === activeTabId)) { | |
| this.session = null; | |
| this.activeTabId = ""; | |
| this.editorText = ""; | |
| this.dirty = false; | |
| this.resetHistory(""); | |
| } | |
| this._desktopStarting = null; | |
| this._desktopHeartbeatMisses = 0; | |
| this.message = response?.source === "tray" ? "Desktop shut down from system tray" : "Desktop is shut down"; | |
| await this.refresh(); | |
| }, | |
| async ensureDesktopSession(options = {}) { | |
| if (this._desktopIntentionalShutdown && options.restart !== true) { | |
| return null; | |
| } | |
| if (options.restart === true) { | |
| this.setDesktopIntentionalShutdown(false); | |
| this.destroyDesktopFrame(); | |
| } | |
| const existing = this.tabs.find((tab) => this.isDesktopSession(tab)); | |
| if (existing && !options.force) { | |
| if (options.select) this.selectTab(existing.tab_id, { focus: false }); | |
| this.updateDesktopMonitor(); | |
| return existing; | |
| } | |
| const showProgress = options.progress !== false; | |
| const progressMessage = String(options.message || DESKTOP_START_MESSAGE); | |
| if (this._desktopStarting) { | |
| if (showProgress) { | |
| this.loading = true; | |
| this.message = progressMessage; | |
| } | |
| return await this._desktopStarting; | |
| } | |
| this._desktopStarting = (async () => { | |
| try { | |
| if (showProgress) { | |
| this.loading = true; | |
| this.message = progressMessage; | |
| this.error = ""; | |
| } | |
| const response = await this.openDesktopWhenRuntimeReady(showProgress); | |
| if (response?.ok === false) throw new Error(response.error || "Desktop session could not be opened."); | |
| this.setDesktopIntentionalShutdown(false); | |
| const session = normalizeSession(response); | |
| const existingIndex = this.tabs.findIndex((tab) => this.isDesktopSession(tab)); | |
| let desktopTabId = session.tab_id; | |
| if (existingIndex >= 0) { | |
| desktopTabId = this.tabs[existingIndex].tab_id; | |
| this.tabs.splice(existingIndex, 1, { ...this.tabs[existingIndex], ...session, tab_id: desktopTabId }); | |
| } else { | |
| this.tabs.unshift(session); | |
| } | |
| this.tabs = this.tabs.map((tab) => ( | |
| this.hasOfficialOffice(tab) | |
| ? { | |
| ...tab, | |
| desktop: session.desktop, | |
| desktop_session_id: session.desktop_session_id, | |
| session_id: this.isDesktopSession(tab) ? session.session_id : tab.session_id, | |
| } | |
| : tab | |
| )); | |
| if (options.select || !this.session) { | |
| this.selectTab(desktopTabId, { focus: false }); | |
| } else { | |
| this.updateDesktopMonitor(); | |
| } | |
| this.restoreDesktopFrames(); | |
| return { ...session, tab_id: desktopTabId }; | |
| } catch (error) { | |
| this.error = error instanceof Error ? error.message : String(error); | |
| return null; | |
| } finally { | |
| if (showProgress) { | |
| this.loading = false; | |
| if (this.message === progressMessage || this.message === DESKTOP_RUNTIME_INSTALL_MESSAGE) this.message = ""; | |
| } | |
| this._desktopStarting = null; | |
| } | |
| })(); | |
| return await this._desktopStarting; | |
| }, | |
| async openDesktopWhenRuntimeReady(showProgress = true) { | |
| const startedAt = Date.now(); | |
| let response = await callDesktop("desktop"); | |
| while (response?.ok === false && this.isDesktopRuntimeInstalling(response)) { | |
| if (showProgress) { | |
| this.loading = true; | |
| this.error = ""; | |
| this.message = this.desktopRuntimeInstallMessage(response); | |
| } | |
| if (Date.now() - startedAt > DESKTOP_RUNTIME_INSTALL_TIMEOUT_MS) { | |
| return { | |
| ...response, | |
| error: "Agent Zero Desktop runtime installation is still running. Please try again in a moment.", | |
| }; | |
| } | |
| await sleep(DESKTOP_RUNTIME_INSTALL_POLL_MS); | |
| response = await callDesktop("desktop"); | |
| } | |
| return response; | |
| }, | |
| isDesktopRuntimeInstalling(response = {}) { | |
| const status = response?.status || response?.desktop?.status || response?.libreoffice?.desktop || {}; | |
| return Boolean(status.installing || status.state === "installing" || status.preparation?.preparing); | |
| }, | |
| desktopRuntimeInstallMessage(response = {}) { | |
| const status = response?.status || response?.desktop?.status || response?.libreoffice?.desktop || {}; | |
| return String(status.message || DESKTOP_RUNTIME_INSTALL_MESSAGE); | |
| }, | |
| async create(kind = "document", format = "") { | |
| const fmt = String(format || (kind === "spreadsheet" ? "ods" : kind === "presentation" ? "odp" : "odt")).toLowerCase(); | |
| const title = this.defaultTitle(kind, fmt); | |
| this.loading = true; | |
| this.error = ""; | |
| try { | |
| const response = await callOffice("create", { | |
| kind, | |
| format: fmt, | |
| title, | |
| open_in_desktop: isOfficialExtension(fmt), | |
| }); | |
| if (response?.ok === false) { | |
| this.error = response.error || "Document could not be created."; | |
| return null; | |
| } | |
| const session = normalizeSession(response); | |
| this.installSession(session); | |
| await this.refresh(); | |
| return session; | |
| } catch (error) { | |
| this.error = error instanceof Error ? error.message : String(error); | |
| return null; | |
| } finally { | |
| this.loading = false; | |
| } | |
| }, | |
| async openFileBrowser() { | |
| let workdirPath = "/a0/usr/workdir"; | |
| try { | |
| const response = await callJsonApi("settings_get", null); | |
| workdirPath = response?.settings?.workdir_path || workdirPath; | |
| } catch { | |
| try { | |
| const home = await callOffice("home"); | |
| workdirPath = home?.path || workdirPath; | |
| } catch { | |
| // The file browser can still open with the static fallback. | |
| } | |
| } | |
| await fileBrowserStore.open(workdirPath); | |
| }, | |
| async openPath(path) { | |
| return await this.openSession({ path: String(path || "") }); | |
| }, | |
| async openSession(payload = {}) { | |
| this.loading = true; | |
| this.error = ""; | |
| try { | |
| const response = await callDesktop("open_document", payload); | |
| if (response?.ok === false) { | |
| this.error = response.error || "Document could not be opened."; | |
| return null; | |
| } | |
| const session = normalizeSession(response); | |
| this.installSession(session); | |
| await this.refresh(); | |
| return session; | |
| } catch (error) { | |
| this.error = error instanceof Error ? error.message : String(error); | |
| return null; | |
| } finally { | |
| this.loading = false; | |
| } | |
| }, | |
| installSession(session) { | |
| if (this.isDesktopOfficeDocument(session)) { | |
| this.installDesktopDocumentSession(session); | |
| return; | |
| } | |
| const existingIndex = this.tabs.findIndex((tab) => ( | |
| (session.file_id && tab.file_id === session.file_id) | |
| || (session.path && tab.path === session.path) | |
| )); | |
| if (existingIndex >= 0) { | |
| this.tabs.splice(existingIndex, 1, { ...this.tabs[existingIndex], ...session, tab_id: this.tabs[existingIndex].tab_id }); | |
| this.activeTabId = this.tabs[existingIndex].tab_id; | |
| } else { | |
| this.tabs.push(session); | |
| this.activeTabId = session.tab_id; | |
| } | |
| this.selectTab(this.activeTabId); | |
| }, | |
| installDesktopDocumentSession(session) { | |
| this.setDesktopIntentionalShutdown(false); | |
| this.tabs = this.tabs.filter((tab) => !this.isDesktopOfficeDocument(tab)); | |
| let desktopTab = this.tabs.find((tab) => this.isDesktopSession(tab)); | |
| if (!desktopTab) { | |
| desktopTab = { | |
| ...session, | |
| tab_id: SYSTEM_DESKTOP_FILE_ID, | |
| file_id: SYSTEM_DESKTOP_FILE_ID, | |
| extension: "desktop", | |
| title: "Desktop", | |
| path: session.desktop?.desktop_path || "/desktop/session", | |
| mode: "desktop", | |
| document: { | |
| file_id: SYSTEM_DESKTOP_FILE_ID, | |
| path: session.desktop?.desktop_path || "/desktop/session", | |
| basename: "Desktop", | |
| title: "Desktop", | |
| extension: "desktop", | |
| }, | |
| dirty: false, | |
| }; | |
| this.tabs.unshift(desktopTab); | |
| } | |
| const documentSession = { ...session, tab_id: session.tab_id || uniqueTabId(session) }; | |
| const existingIndex = this.tabs.findIndex((tab) => ( | |
| (documentSession.file_id && tab.file_id === documentSession.file_id) | |
| || (documentSession.path && tab.path === documentSession.path) | |
| )); | |
| if (existingIndex >= 0) { | |
| this.tabs.splice(existingIndex, 1, documentSession); | |
| } else { | |
| this.tabs.push(documentSession); | |
| } | |
| this.session = documentSession; | |
| this.activeTabId = documentSession.tab_id; | |
| this.editorText = ""; | |
| this.dirty = false; | |
| this.resetHistory(""); | |
| this.queueRender({ focus: true }); | |
| this.restoreDesktopFrames(); | |
| this.requestDesktopViewportSync({ force: true }); | |
| this.updateDesktopMonitor(); | |
| }, | |
| selectTab(tabId, options = {}) { | |
| const tab = this.tabs.find((item) => item.tab_id === tabId) || this.tabs[0] || null; | |
| if (this.hasOfficialOffice(this.session) && !this.hasOfficialOffice(tab)) { | |
| this.moveDesktopFrameToKeepalive(); | |
| } | |
| this.session = tab; | |
| this.activeTabId = tab?.tab_id || ""; | |
| this.editorText = String(tab?.text || ""); | |
| this.dirty = Boolean(tab?.dirty); | |
| this.resetHistory(this.editorText); | |
| this.queueRender({ focus: Boolean(tab) && options.focus !== false }); | |
| if (this.hasOfficialOffice(tab)) { | |
| this.restoreDesktopFrames(); | |
| this.requestDesktopViewportSync({ force: true }); | |
| } | |
| this.updateDesktopMonitor(); | |
| }, | |
| ensureActiveTab() { | |
| if (this.session && this.tabs.some((tab) => tab.tab_id === this.session.tab_id)) return; | |
| if (this.tabs.length) this.selectTab(this.tabs[0].tab_id, { focus: false }); | |
| }, | |
| isActiveTab(tab) { | |
| return Boolean(tab && tab.tab_id === this.activeTabId); | |
| }, | |
| async closeTab(tabId) { | |
| const tab = this.tabs.find((item) => item.tab_id === tabId); | |
| if (!tab) return; | |
| if (this.isDesktopSession(tab)) { | |
| this.selectTab(tab.tab_id, { focus: false }); | |
| return; | |
| } | |
| if (!this.hasOfficialOffice(tab) && (tab.dirty || (this.isActiveTab(tab) && this.dirty))) { | |
| const shouldSave = globalThis.confirm?.("Save changes?") ?? true; | |
| if (shouldSave) await this.save(); | |
| } | |
| try { | |
| if (this.hasOfficialOffice(tab)) { | |
| await callDesktop("save", { | |
| desktop_session_id: tab.desktop_session_id || tab.session_id, | |
| file_id: tab.file_id || "", | |
| }).catch(() => null); | |
| } else if (tab.session_id) { | |
| await requestOffice("office_close", { session_id: tab.session_id }, 2500).catch(() => null); | |
| } | |
| await callOffice("close", { | |
| session_id: tab.store_session_id || "", | |
| file_id: tab.file_id || "", | |
| }); | |
| } catch (error) { | |
| console.warn("Document close skipped", error); | |
| } | |
| this.tabs = this.tabs.filter((item) => item.tab_id !== tabId); | |
| if (this.activeTabId === tabId) { | |
| this.session = null; | |
| this.activeTabId = ""; | |
| this.editorText = ""; | |
| this.dirty = false; | |
| this.ensureActiveTab(); | |
| } | |
| this.updateDesktopMonitor(); | |
| this.ensureActiveTab(); | |
| await this.refresh(); | |
| }, | |
| async closeActiveFile() { | |
| if (!this.session || this.isDesktopSession() || this.loading) return; | |
| await this.closeTab(this.session.tab_id); | |
| }, | |
| async save() { | |
| if (!this.session || this.saving) return; | |
| if (this.isDesktopSession()) return; | |
| if (this.hasOfficialOffice()) { | |
| this.saving = true; | |
| this.error = ""; | |
| try { | |
| const response = await callDesktop("save", { | |
| desktop_session_id: this.session.desktop_session_id || this.session.session_id, | |
| file_id: this.session.file_id || "", | |
| }); | |
| if (response?.ok === false) throw new Error(response.error || "Save failed."); | |
| const document = normalizeDocument(response.document || this.session.document || {}); | |
| const updated = { | |
| ...this.session, | |
| dirty: false, | |
| document, | |
| path: document.path || this.session.path, | |
| file_id: document.file_id || this.session.file_id, | |
| version: document.version || response.version || this.session.version, | |
| }; | |
| this.replaceActiveSession(updated); | |
| this.dirty = false; | |
| this.setMessage("Saved"); | |
| await this.refresh(); | |
| } catch (error) { | |
| this.error = error instanceof Error ? error.message : String(error); | |
| } finally { | |
| this.saving = false; | |
| } | |
| return; | |
| } | |
| this.syncEditorText(); | |
| this.saving = true; | |
| this.error = ""; | |
| try { | |
| let response; | |
| const payload = { session_id: this.session.session_id, text: this.editorText }; | |
| try { | |
| response = await requestOffice("office_save", payload, 10000); | |
| } catch (_socketError) { | |
| response = await callOffice("save", payload); | |
| } | |
| if (response?.ok === false) throw new Error(response.error || "Save failed."); | |
| const document = normalizeDocument(response.document || this.session.document || {}); | |
| const updated = { | |
| ...this.session, | |
| text: this.editorText, | |
| dirty: false, | |
| document, | |
| path: document.path || this.session.path, | |
| file_id: document.file_id || this.session.file_id, | |
| version: document.version || response.version || this.session.version, | |
| }; | |
| this.replaceActiveSession(updated); | |
| this.dirty = false; | |
| this.setMessage("Saved"); | |
| await this.refresh(); | |
| } catch (error) { | |
| this.error = error instanceof Error ? error.message : String(error); | |
| } finally { | |
| this.saving = false; | |
| } | |
| }, | |
| async renameActiveFile() { | |
| if (!this.session || this.isDesktopSession() || this.saving) return; | |
| const session = this.session; | |
| const path = session.path || session.document?.path || ""; | |
| if (!path) { | |
| this.error = "This document does not have a file path to rename."; | |
| return; | |
| } | |
| const name = basename(path || session.title || ""); | |
| const extension = extensionOf(name); | |
| await fileBrowserStore.openRenameModal( | |
| { | |
| name, | |
| path, | |
| is_dir: false, | |
| size: session.document?.size || 0, | |
| modified: session.document?.last_modified || "", | |
| type: "document", | |
| }, | |
| { | |
| currentPath: parentPath(path), | |
| validateName: (newName) => { | |
| if (!extension) return true; | |
| return extensionOf(newName) === extension || `Keep the .${extension} extension for this open document.`; | |
| }, | |
| performRename: async ({ path: renamedPath }) => { | |
| const payload = { | |
| file_id: session.file_id || "", | |
| path: renamedPath, | |
| }; | |
| if (this.isMarkdown(session)) { | |
| this.syncEditorText(); | |
| payload.text = this.session?.tab_id === session.tab_id ? this.editorText : session.text || ""; | |
| } | |
| return await callOffice("renamed", payload); | |
| }, | |
| onRenamed: async ({ path: renamedPath, response }) => { | |
| await this.handleActiveFileRenamed(session, renamedPath, response); | |
| }, | |
| }, | |
| ); | |
| }, | |
| async handleActiveFileRenamed(session, renamedPath, renameResponse = null) { | |
| const response = renameResponse || await callOffice("renamed", { | |
| file_id: session.file_id || "", | |
| path: renamedPath, | |
| }); | |
| if (response?.ok === false) throw new Error(response.error || "Rename failed."); | |
| const document = normalizeDocument(response.document || session.document || {}); | |
| const updated = { | |
| ...session, | |
| document, | |
| title: document.title || document.basename || basename(document.path), | |
| path: document.path || renamedPath, | |
| extension: document.extension || session.extension, | |
| file_id: document.file_id || session.file_id, | |
| version: document.version || response.version || session.version, | |
| desktop: response.desktop?.desktop || session.desktop, | |
| text: this.session?.tab_id === session.tab_id ? this.editorText : session.text, | |
| dirty: false, | |
| }; | |
| this.replaceSession(session, updated); | |
| this.dirty = false; | |
| this.setMessage("Renamed"); | |
| await this.refresh(); | |
| }, | |
| replaceActiveSession(next) { | |
| if (!this.session) return; | |
| this.replaceSession(this.session, next); | |
| }, | |
| replaceSession(previous, next) { | |
| this.session = next; | |
| const index = this.tabs.findIndex((tab) => tab.tab_id === (previous?.tab_id || next.tab_id)); | |
| if (index >= 0) this.tabs.splice(index, 1, next); | |
| this.queueRender(); | |
| this.updateDesktopMonitor(); | |
| }, | |
| setMessage(value) { | |
| this.message = value; | |
| if (this._saveMessageTimer) globalThis.clearTimeout(this._saveMessageTimer); | |
| this._saveMessageTimer = globalThis.setTimeout(() => { | |
| this.message = ""; | |
| this._saveMessageTimer = null; | |
| }, SAVE_MESSAGE_MS); | |
| }, | |
| resetHistory(text) { | |
| this._history = [String(text || "")]; | |
| this._historyIndex = 0; | |
| }, | |
| pushHistory(text) { | |
| const value = String(text || ""); | |
| if (this._history[this._historyIndex] === value) return; | |
| this._history = this._history.slice(0, this._historyIndex + 1); | |
| this._history.push(value); | |
| if (this._history.length > MAX_HISTORY) this._history.shift(); | |
| this._historyIndex = this._history.length - 1; | |
| }, | |
| undo() { | |
| if (this._historyIndex <= 0) return; | |
| this._historyIndex -= 1; | |
| this.applyEditorText(this._history[this._historyIndex], true); | |
| }, | |
| redo() { | |
| if (this._historyIndex >= this._history.length - 1) return; | |
| this._historyIndex += 1; | |
| this.applyEditorText(this._history[this._historyIndex], true); | |
| }, | |
| canUndo() { | |
| return this._historyIndex > 0; | |
| }, | |
| canRedo() { | |
| return this._historyIndex < this._history.length - 1; | |
| }, | |
| applyEditorText(text, markDirty = false) { | |
| this.editorText = String(text || ""); | |
| if (this.session) { | |
| this.session.text = this.editorText; | |
| this.session.dirty = markDirty || this.session.dirty; | |
| } | |
| if (markDirty) this.markDirty(); | |
| this.queueRender({ force: true, focus: true }); | |
| }, | |
| markDirty() { | |
| this.dirty = true; | |
| if (this.session) this.session.dirty = true; | |
| }, | |
| onSourceInput() { | |
| this.markDirty(); | |
| this.pushHistory(this.editorText); | |
| this.scheduleInputPush(); | |
| }, | |
| syncEditorText() { | |
| if (!this.session) return; | |
| if (this.hasOfficialOffice()) return; | |
| this.session.text = this.editorText; | |
| }, | |
| scheduleInputPush() { | |
| if (!this.session?.session_id) return; | |
| if (this._inputTimer) globalThis.clearTimeout(this._inputTimer); | |
| this._inputTimer = globalThis.setTimeout(() => { | |
| this._inputTimer = null; | |
| this.flushInput(); | |
| }, INPUT_PUSH_DELAY_MS); | |
| }, | |
| flushInput() { | |
| if (!this.session?.session_id) return; | |
| if (this.hasOfficialOffice()) return; | |
| this.syncEditorText(); | |
| requestOffice("office_input", { | |
| session_id: this.session.session_id, | |
| text: this.editorText, | |
| }, 3000).catch(() => {}); | |
| }, | |
| format(command) { | |
| if (!this.session) return; | |
| if (!this.isMarkdown()) return; | |
| this.applySourceFormat(command); | |
| }, | |
| applySourceFormat(command) { | |
| const textarea = this._root?.querySelector?.("[data-office-source]"); | |
| if (!textarea) return; | |
| const start = textarea.selectionStart || 0; | |
| const end = textarea.selectionEnd || start; | |
| const selected = this.editorText.slice(start, end); | |
| let replacement = selected; | |
| if (command === "bold") replacement = `**${selected || "text"}**`; | |
| if (command === "italic") replacement = `*${selected || "text"}*`; | |
| if (command === "list") replacement = (selected || "item").split("\n").map((line) => `- ${line.replace(/^[-*]\s+/, "")}`).join("\n"); | |
| if (command === "numbered") replacement = (selected || "item").split("\n").map((line, index) => `${index + 1}. ${line.replace(/^\d+\.\s+/, "")}`).join("\n"); | |
| if (command === "table") replacement = "| Column | Value |\n| --- | --- |\n| | |"; | |
| if (replacement === selected) return; | |
| this.editorText = `${this.editorText.slice(0, start)}${replacement}${this.editorText.slice(end)}`; | |
| this.onSourceInput(); | |
| globalThis.requestAnimationFrame?.(() => { | |
| textarea.focus(); | |
| textarea.selectionStart = start; | |
| textarea.selectionEnd = start + replacement.length; | |
| }); | |
| }, | |
| queueRender(options = {}) { | |
| const force = Boolean(options.force); | |
| if (options.focus) { | |
| this._pendingFocus = true; | |
| this._pendingFocusEnd = options.end !== false; | |
| this._focusAttempts = 0; | |
| } | |
| const render = () => { | |
| if (this._pendingFocus && this.focusEditor({ end: this._pendingFocusEnd })) { | |
| this._pendingFocus = false; | |
| this._focusAttempts = 0; | |
| } else if (this._pendingFocus && this._focusAttempts < 6) { | |
| this._focusAttempts += 1; | |
| globalThis.setTimeout(render, 45); | |
| } | |
| }; | |
| if (globalThis.requestAnimationFrame) { | |
| globalThis.requestAnimationFrame(render); | |
| } else { | |
| globalThis.setTimeout(render, 0); | |
| } | |
| }, | |
| focusEditor(options = {}) { | |
| if (!this.session) return false; | |
| if (this.hasOfficialOffice()) { | |
| return this.focusDesktopFrame(this.desktopFrame(), { arm: true }); | |
| } | |
| const source = this._root?.querySelector?.("[data-office-source]"); | |
| if (!this.isMarkdown() || !source) return false; | |
| source.focus?.({ preventScroll: true }); | |
| if (!editorContainsFocus(source)) return false; | |
| if (options.end !== false) placeCaretAtEnd(source); | |
| return true; | |
| }, | |
| isMarkdown(tab = this.session) { | |
| const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase(); | |
| return ext === "md"; | |
| }, | |
| isBinaryOffice(tab = this.session) { | |
| const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase(); | |
| return ["odt", "ods", "odp", "docx", "xlsx", "pptx", "txt"].includes(ext); | |
| }, | |
| hasOfficialOffice(tab = this.session) { | |
| return Boolean(tab?.desktop?.available && tab.desktop.url); | |
| }, | |
| isDesktopSession(tab = this.session) { | |
| return Boolean( | |
| tab | |
| && ( | |
| tab.file_id === SYSTEM_DESKTOP_FILE_ID | |
| || tab.extension === "desktop" | |
| || tab.mode === "desktop" | |
| ) | |
| ); | |
| }, | |
| isDesktopOfficeDocument(tab = this.session) { | |
| return Boolean(tab && this.hasOfficialOffice(tab) && !this.isDesktopSession(tab) && this.isBinaryOffice(tab)); | |
| }, | |
| hasActiveFile(tab = this.session) { | |
| return Boolean(tab && !this.isDesktopSession(tab) && (this.isMarkdown(tab) || this.isDesktopOfficeDocument(tab))); | |
| }, | |
| isVisibleOfficeTab(tab = {}) { | |
| return Boolean(this.hasActiveFile(tab)); | |
| }, | |
| visibleTabs() { | |
| return this.tabs.filter((tab) => this.isVisibleOfficeTab(tab)); | |
| }, | |
| officialOfficeUrl(tab = this.session) { | |
| const url = tab?.desktop?.url || ""; | |
| if (!url) return ""; | |
| try { | |
| const parsed = new URL(url, window.location.href); | |
| const secureContext = globalThis.isSecureContext === true; | |
| parsed.searchParams.set("offscreen", secureContext ? "true" : "false"); | |
| parsed.searchParams.set("clipboard_poll", secureContext ? "true" : "false"); | |
| if (parsed.origin === window.location.origin) return `${parsed.pathname}${parsed.search}${parsed.hash}`; | |
| return parsed.href; | |
| } catch { | |
| return url; | |
| } | |
| }, | |
| isDesktopHostVisible() { | |
| if (this._mode === "modal") { | |
| return Boolean(this._root?.isConnected && this._root.closest?.(".modal")); | |
| } | |
| const surface = this._root?.closest?.('[data-surface-id="desktop"]'); | |
| return Boolean(surface?.classList?.contains("is-mounted") || surface?.classList?.contains("is-active")); | |
| }, | |
| setDesktopHostVisible(visible) { | |
| const next = Boolean(visible); | |
| if (!next && this._mode === "modal") return; | |
| if (this._desktopHostVisible === next) return; | |
| this._desktopHostVisible = next; | |
| if (next) { | |
| this.afterDesktopHostShown({ source: "canvas-visibility" }); | |
| } else { | |
| this.beforeHostHidden({ reason: "hidden" }); | |
| } | |
| }, | |
| desktopFrames() { | |
| const frames = []; | |
| if (this._desktopFrame) frames.push(this._desktopFrame); | |
| for (const frame of Array.from(document.querySelectorAll("[data-office-desktop-frame]"))) { | |
| if (!frames.includes(frame)) frames.push(frame); | |
| } | |
| return frames; | |
| }, | |
| isUsableDesktopFrame(frame) { | |
| if (!frame?.contentWindow) return false; | |
| const rect = frame.getBoundingClientRect?.(); | |
| return Boolean(rect && rect.width >= 120 && rect.height >= 80); | |
| }, | |
| desktopFrame(preferred = null) { | |
| if (this.isUsableDesktopFrame(preferred)) return preferred; | |
| const rootFrame = this._root?.querySelector?.("[data-office-desktop-frame]"); | |
| if (this.isUsableDesktopFrame(rootFrame)) return rootFrame; | |
| const frames = this.desktopFrames(); | |
| return frames | |
| .filter((frame) => this.isUsableDesktopFrame(frame)) | |
| .sort((left, right) => { | |
| const leftRect = left.getBoundingClientRect(); | |
| const rightRect = right.getBoundingClientRect(); | |
| return (rightRect.width * rightRect.height) - (leftRect.width * leftRect.height); | |
| })[0] || null; | |
| }, | |
| isUsableDesktopHost(host) { | |
| if (!host?.appendChild) return false; | |
| const rect = host.getBoundingClientRect?.(); | |
| return Boolean(rect && rect.width >= 120 && rect.height >= 80); | |
| }, | |
| desktopHost(preferred = null) { | |
| if (preferred?.matches?.("[data-office-desktop-host]")) return preferred; | |
| const rootHost = this._root?.querySelector?.("[data-office-desktop-host]"); | |
| if (this.isUsableDesktopHost(rootHost)) return rootHost; | |
| const hosts = Array.from(document.querySelectorAll("[data-office-desktop-host]")); | |
| return hosts | |
| .filter((host) => this.isUsableDesktopHost(host)) | |
| .sort((left, right) => { | |
| const leftRect = left.getBoundingClientRect(); | |
| const rightRect = right.getBoundingClientRect(); | |
| return (rightRect.width * rightRect.height) - (leftRect.width * leftRect.height); | |
| })[0] || rootHost || hosts[0] || null; | |
| }, | |
| ensureDesktopKeepaliveHost() { | |
| if (this._desktopKeepaliveHost?.isConnected) return this._desktopKeepaliveHost; | |
| const host = document.createElement("div"); | |
| host.className = "office-desktop-keepalive"; | |
| host.dataset.officeDesktopKeepalive = "true"; | |
| Object.assign(host.style, { | |
| position: "fixed", | |
| left: "-10000px", | |
| top: "-10000px", | |
| width: "720px", | |
| height: "480px", | |
| overflow: "hidden", | |
| pointerEvents: "none", | |
| visibility: "hidden", | |
| }); | |
| document.body?.appendChild(host); | |
| this._desktopKeepaliveHost = host; | |
| return host; | |
| }, | |
| rememberDesktopFrameSize() { | |
| const frame = this._desktopFrame; | |
| const rect = frame?.getBoundingClientRect?.(); | |
| const hostRect = this._desktopFrameHost?.getBoundingClientRect?.(); | |
| const width = Math.round(rect?.width || hostRect?.width || 720); | |
| const height = Math.round(rect?.height || hostRect?.height || 480); | |
| const keepalive = this.ensureDesktopKeepaliveHost(); | |
| keepalive.style.width = `${Math.max(320, width)}px`; | |
| keepalive.style.height = `${Math.max(220, height)}px`; | |
| return keepalive; | |
| }, | |
| ensureDesktopFrame() { | |
| if (this._desktopFrame) return this._desktopFrame; | |
| const frame = document.createElement("iframe"); | |
| frame.className = "office-desktop-frame"; | |
| frame.dataset.officeDesktopFrame = "true"; | |
| frame.dataset.officePersistentDesktopFrame = "true"; | |
| frame.setAttribute("tabindex", "0"); | |
| frame.setAttribute("aria-label", "Desktop"); | |
| frame.setAttribute("allow", "clipboard-read; clipboard-write; autoplay"); | |
| this._desktopFrameLoadHandler = (event) => this.onDesktopFrameLoaded(event); | |
| frame.addEventListener("load", this._desktopFrameLoadHandler); | |
| this._desktopFrame = frame; | |
| return frame; | |
| }, | |
| desktopFrameSrcMatches(frame, url) { | |
| const current = frame?.getAttribute?.("src") || frame?.src || ""; | |
| if (!current && !url) return true; | |
| try { | |
| return new URL(current, window.location.href).href === new URL(url, window.location.href).href; | |
| } catch { | |
| return current === url; | |
| } | |
| }, | |
| attachDesktopFrame(host = null) { | |
| if (!this.hasOfficialOffice()) return false; | |
| const target = this.desktopHost(host); | |
| if (!target) return false; | |
| const frame = this.ensureDesktopFrame(); | |
| if (frame.parentElement !== target) { | |
| frame.parentElement?.removeAttribute?.("data-office-desktop-attached"); | |
| target.appendChild(frame); | |
| } | |
| target.dataset.officeDesktopAttached = "true"; | |
| if (this._desktopFrameHost !== target) this._desktopFrameHost = target; | |
| const url = this.officialOfficeUrl(); | |
| if (url && !this.desktopFrameSrcMatches(frame, url)) { | |
| frame.setAttribute("src", url); | |
| } | |
| return true; | |
| }, | |
| mountDesktopFrameHost(host = null) { | |
| const attached = this.attachDesktopFrame(host); | |
| if (attached && this.isDesktopHostVisible()) { | |
| this.requestDesktopViewportSync({ force: true, frame: this._desktopFrame, followup: true }); | |
| } | |
| return attached; | |
| }, | |
| moveDesktopFrameToKeepalive() { | |
| const frame = this._desktopFrame; | |
| if (!frame) return false; | |
| const keepalive = this.rememberDesktopFrameSize(); | |
| if (frame.parentElement !== keepalive) { | |
| frame.parentElement?.removeAttribute?.("data-office-desktop-attached"); | |
| keepalive.appendChild(frame); | |
| } | |
| this._desktopFrameHost = keepalive; | |
| this._desktopKeyboardActive = false; | |
| this.updateDesktopKeyboardCaptureState(frame); | |
| return true; | |
| }, | |
| destroyDesktopFrame() { | |
| const frame = this._desktopFrame; | |
| if (!frame) return; | |
| if (this._desktopFrameLoadHandler) { | |
| frame.removeEventListener("load", this._desktopFrameLoadHandler); | |
| } | |
| frame.setAttribute("src", "about:blank"); | |
| frame.remove(); | |
| this._desktopFrame = null; | |
| this._desktopFrameHost = null; | |
| this._desktopFrameLoadHandler = null; | |
| this._desktopBridgeReady = false; | |
| this.updateDesktopKeyboardCaptureState(); | |
| this._desktopKeepaliveHost?.remove?.(); | |
| this._desktopKeepaliveHost = null; | |
| }, | |
| unloadDesktopFrames() { | |
| this.stopDesktopResizeObserver(); | |
| this.stopXpraDesktopPrime(); | |
| this.moveDesktopFrameToKeepalive(); | |
| }, | |
| restoreDesktopFrames() { | |
| if (!this.isDesktopHostVisible()) return; | |
| this.attachDesktopFrame(); | |
| }, | |
| afterDesktopHostShown() { | |
| if (!this.hasOfficialOffice()) return; | |
| this._desktopHostVisible = true; | |
| this._desktopResizeKey = ""; | |
| this._desktopResizePendingKey = ""; | |
| this._desktopResizeSuspended = false; | |
| this._desktopResizePending = false; | |
| this.restoreDesktopFrames(); | |
| this.requestDesktopViewportSync({ force: true, frame: this.desktopFrame() }); | |
| }, | |
| beforeDesktopHostHandoff() { | |
| this.stopDesktopResizeObserver(); | |
| this.clearDesktopViewportSyncTimers(); | |
| this.stopXpraDesktopPrime(); | |
| this._desktopResizeKey = ""; | |
| this._desktopResizePendingKey = ""; | |
| this._desktopResizeSuspended = true; | |
| this._desktopResizePending = true; | |
| }, | |
| cancelDesktopHostHandoff() { | |
| this._desktopResizeSuspended = false; | |
| this._desktopResizePending = false; | |
| this.requestDesktopViewportSync({ force: true, frame: this.desktopFrame() }); | |
| }, | |
| onDesktopFrameLoaded(event = null) { | |
| if (event?.target?.getAttribute?.("src") === "about:blank") return; | |
| if (!this.isDesktopHostVisible()) return; | |
| this.error = ""; | |
| this.queueDesktopFrameFocus(event?.target || null); | |
| this.requestDesktopViewportSync({ force: true, frame: event?.target || null }); | |
| }, | |
| queueDesktopFrameFocus(frame = null) { | |
| for (const delay of [0, 80, 260]) { | |
| globalThis.setTimeout(() => { | |
| if (!this.hasOfficialOffice()) return; | |
| if (isEditableInputTarget(document.activeElement)) return; | |
| this.focusDesktopFrame(frame || this.desktopFrame(), { arm: true }); | |
| }, delay); | |
| } | |
| }, | |
| focusDesktopFrame(frame = null, options = {}) { | |
| if (this._desktopFocusInProgress) return false; | |
| const target = this.desktopFrame(frame); | |
| if (!target) return false; | |
| if (options.arm !== false) this._desktopKeyboardActive = true; | |
| this._desktopFocusInProgress = true; | |
| try { | |
| target.setAttribute("tabindex", "0"); | |
| target.focus?.({ preventScroll: true }); | |
| target.contentWindow?.focus?.(); | |
| if (target.contentDocument?.body && !target.contentDocument.body.hasAttribute("tabindex")) { | |
| target.contentDocument.body.tabIndex = -1; | |
| } | |
| target.contentDocument?.body?.focus?.({ preventScroll: true }); | |
| if (target.contentWindow?.client) target.contentWindow.client.capture_keyboard = true; | |
| } catch { | |
| target.focus?.({ preventScroll: true }); | |
| } finally { | |
| this._desktopFocusInProgress = false; | |
| } | |
| const focused = Boolean(document.activeElement === target || target.contentDocument?.hasFocus?.()); | |
| this.updateDesktopKeyboardCaptureState(target); | |
| return focused; | |
| }, | |
| updateDesktopMonitor() { | |
| if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) { | |
| this.stopDesktopMonitor(); | |
| this.stopDesktopResizeObserver(); | |
| this._desktopKeyboardActive = false; | |
| this._desktopBridgeReady = false; | |
| this.updateDesktopKeyboardCaptureState(); | |
| return; | |
| } | |
| const sessionId = this.session?.desktop_session_id || this.session?.session_id || ""; | |
| const tabId = this.session?.tab_id || ""; | |
| if ( | |
| sessionId | |
| && tabId | |
| && this._desktopHeartbeatTimer | |
| && this._desktopHeartbeatSessionId === sessionId | |
| && this._desktopHeartbeatTabId === tabId | |
| ) return; | |
| this.startDesktopMonitor(); | |
| this.startDesktopResizeObserver(); | |
| }, | |
| startDesktopResizeObserver() { | |
| if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) { | |
| this.stopDesktopResizeObserver(); | |
| return; | |
| } | |
| const frame = this.desktopFrame(); | |
| const target = frame?.parentElement || frame; | |
| if (!target) { | |
| this.stopDesktopResizeObserver(); | |
| return; | |
| } | |
| if (this._desktopResizeCleanup && this._desktopResizeTarget === target) return; | |
| this.stopDesktopResizeObserver(); | |
| const resize = () => this.queueDesktopResize(); | |
| const resizeStart = () => this.suspendDesktopResize(); | |
| const resizeEnd = () => this.resumeDesktopResize(); | |
| const cleanup = []; | |
| if (typeof ResizeObserver !== "undefined") { | |
| const observer = new ResizeObserver(resize); | |
| observer.observe(target); | |
| cleanup.push(() => observer.disconnect()); | |
| } | |
| globalThis.addEventListener?.("resize", resize); | |
| cleanup.push(() => globalThis.removeEventListener?.("resize", resize)); | |
| globalThis.addEventListener?.("right-canvas-resize-start", resizeStart); | |
| cleanup.push(() => globalThis.removeEventListener?.("right-canvas-resize-start", resizeStart)); | |
| globalThis.addEventListener?.("right-canvas-resize-end", resizeEnd); | |
| cleanup.push(() => globalThis.removeEventListener?.("right-canvas-resize-end", resizeEnd)); | |
| this._desktopResizeTarget = target; | |
| this._desktopResizeCleanup = () => cleanup.splice(0).reverse().forEach((entry) => entry()); | |
| resize(); | |
| }, | |
| stopDesktopResizeObserver() { | |
| if (this._desktopResizeTimer) { | |
| globalThis.clearTimeout(this._desktopResizeTimer); | |
| } | |
| this._desktopResizeTimer = null; | |
| this._desktopResizeCleanup?.(); | |
| this._desktopResizeCleanup = null; | |
| this._desktopResizeTarget = null; | |
| this._desktopResizeKey = ""; | |
| this._desktopResizePendingKey = ""; | |
| this._desktopResizeSuspended = false; | |
| this._desktopResizePending = false; | |
| }, | |
| suspendDesktopResize() { | |
| this._desktopResizeSuspended = true; | |
| if (this._desktopResizeTimer) { | |
| globalThis.clearTimeout(this._desktopResizeTimer); | |
| this._desktopResizeTimer = null; | |
| } | |
| this._desktopResizePendingKey = ""; | |
| }, | |
| resumeDesktopResize() { | |
| const hadPendingResize = this._desktopResizePending; | |
| this._desktopResizeSuspended = false; | |
| this._desktopResizePending = false; | |
| if (hadPendingResize || this.hasOfficialOffice()) { | |
| this.queueDesktopResize({ force: true }); | |
| } | |
| }, | |
| shouldDeferDesktopResize() { | |
| return Boolean( | |
| this._desktopResizeSuspended | |
| || document.body?.classList?.contains("right-canvas-resizing") | |
| || document.querySelector?.(".modal-inner.office-modal.is-resizing") | |
| ); | |
| }, | |
| clearDesktopViewportSyncTimers() { | |
| for (const timer of this._desktopViewportSyncTimers.splice(0)) { | |
| globalThis.clearTimeout(timer); | |
| } | |
| }, | |
| requestDesktopViewportSync(options = {}) { | |
| if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) return; | |
| if (options.force) this.clearDesktopViewportSyncTimers(); | |
| const run = (force = false) => { | |
| this.syncDesktopViewport({ ...options, force }); | |
| }; | |
| if (globalThis.requestAnimationFrame) { | |
| globalThis.requestAnimationFrame(() => run(Boolean(options.force))); | |
| } else { | |
| globalThis.setTimeout(() => run(Boolean(options.force)), 0); | |
| } | |
| if (options.followup === false) return; | |
| const timer = globalThis.setTimeout(() => { | |
| this._desktopViewportSyncTimers = this._desktopViewportSyncTimers.filter((item) => item !== timer); | |
| run(false); | |
| }, options.force ? 260 : 180); | |
| this._desktopViewportSyncTimers.push(timer); | |
| }, | |
| syncDesktopViewport(options = {}) { | |
| if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) return false; | |
| const frame = this.desktopFrame(options.frame || null); | |
| if (!frame) return false; | |
| this.startDesktopResizeObserver(); | |
| this.primeXpraDesktopFrame({ reset: true, frame }); | |
| this.queueDesktopResize({ | |
| force: Boolean(options.force), | |
| serverResize: options.serverResize !== false, | |
| frame, | |
| }); | |
| this.updateDesktopMonitor(); | |
| return true; | |
| }, | |
| primeXpraDesktopFrame(options = {}) { | |
| if (options.reset) { | |
| this.stopXpraDesktopPrime(); | |
| this._desktopPrimeAttempts = 0; | |
| } | |
| if (this.applyXpraDesktopFrameMode(options.frame || null, options)) return; | |
| if (this._desktopPrimeAttempts >= XPRA_DESKTOP_PRIME_ATTEMPTS) return; | |
| this._desktopPrimeAttempts += 1; | |
| if (this._desktopPrimeTimer) globalThis.clearTimeout(this._desktopPrimeTimer); | |
| this._desktopPrimeTimer = globalThis.setTimeout(() => { | |
| this._desktopPrimeTimer = null; | |
| this.primeXpraDesktopFrame(); | |
| }, XPRA_DESKTOP_PRIME_INTERVAL_MS); | |
| }, | |
| stopXpraDesktopPrime() { | |
| if (this._desktopPrimeTimer) globalThis.clearTimeout(this._desktopPrimeTimer); | |
| this._desktopPrimeTimer = null; | |
| }, | |
| applyXpraDesktopFrameMode(preferredFrame = null, options = {}) { | |
| const frame = this.desktopFrame(preferredFrame); | |
| const remoteWindow = frame?.contentWindow; | |
| if (!remoteWindow) return false; | |
| const requestServerResize = options.requestServerResize === true; | |
| const requestRefresh = options.requestRefresh !== false; | |
| try { | |
| const remoteDocument = frame.contentDocument || remoteWindow.document; | |
| this.installXpraDesktopFrameCss(remoteDocument); | |
| this.installXpraDesktopFramePatches(remoteWindow, remoteDocument); | |
| const client = remoteWindow.client; | |
| if (!client) return false; | |
| this.installXpraDesktopClientPatches(remoteWindow, client); | |
| this.installXpraDesktopCursorPatches(remoteWindow, remoteDocument, client); | |
| this.installXpraDesktopKeyboardBridge(frame, remoteWindow, remoteDocument, client); | |
| this.installXpraDesktopClipboardBridge(frame, remoteWindow, remoteDocument, client); | |
| const container = client.container || remoteDocument?.querySelector?.("#screen"); | |
| if (!container) return false; | |
| client.server_is_desktop = true; | |
| client.server_resize_exact = true; | |
| remoteDocument?.body?.classList?.add("desktop"); | |
| const windows = Object.values(client.id_to_window || {}); | |
| if (!client.connected || !windows.length) return false; | |
| const token = options.token || this.session?.desktop?.token || ""; | |
| const displaySize = options.displaySize || this.desktopDisplaySizeForToken(token); | |
| const viewportWidth = Math.round(container.clientWidth || remoteWindow.innerWidth || 0); | |
| const viewportHeight = Math.round(container.clientHeight || remoteWindow.innerHeight || 0); | |
| const width = Math.round(displaySize?.width || viewportWidth || 0); | |
| const height = Math.round(displaySize?.height || viewportHeight || 0); | |
| if (width > 0 && height > 0) { | |
| client.desktop_width = width; | |
| client.desktop_height = height; | |
| } | |
| if (requestServerResize && width > 0 && height > 0 && typeof client._screen_resized === "function") { | |
| client.desktop_width = 0; | |
| client.desktop_height = 0; | |
| client.__a0AllowScreenResize = true; | |
| try { | |
| client._screen_resized(new remoteWindow.Event("resize")); | |
| } finally { | |
| client.__a0AllowScreenResize = false; | |
| } | |
| } | |
| for (const xpraWindow of windows) { | |
| this.normalizeXpraDesktopWindow(xpraWindow, width, height); | |
| xpraWindow.screen_resized?.(); | |
| this.normalizeXpraDesktopWindow(xpraWindow, width, height); | |
| xpraWindow.updateCSSGeometry?.(); | |
| this.fitXpraDesktopWindowElement(xpraWindow, width, height); | |
| this.installXpraDesktopWheelBridge(remoteWindow, xpraWindow); | |
| if (requestRefresh && xpraWindow.wid != null) client.request_refresh?.(xpraWindow.wid); | |
| } | |
| this.installXpraDesktopAgentBridge(frame, remoteWindow, remoteDocument, client, container); | |
| return true; | |
| } catch (error) { | |
| console.warn("Xpra desktop viewport prime skipped", error); | |
| return false; | |
| } | |
| }, | |
| desktopDisplaySizeForToken(token = "") { | |
| const key = String(token || "").trim(); | |
| const size = key ? this._desktopDisplaySizes?.[key] : null; | |
| const width = Math.round(Number(size?.width || 0)); | |
| const height = Math.round(Number(size?.height || 0)); | |
| return width > 0 && height > 0 ? { width, height } : null; | |
| }, | |
| rememberDesktopDisplaySize(token = "", width = 0, height = 0) { | |
| const key = String(token || "").trim(); | |
| const normalizedWidth = Math.round(Number(width || 0)); | |
| const normalizedHeight = Math.round(Number(height || 0)); | |
| if (!key || normalizedWidth <= 0 || normalizedHeight <= 0) return null; | |
| this._desktopDisplaySizes = { | |
| ...(this._desktopDisplaySizes || {}), | |
| [key]: { width: normalizedWidth, height: normalizedHeight }, | |
| }; | |
| return this._desktopDisplaySizes[key]; | |
| }, | |
| installXpraDesktopAgentBridge(frame, remoteWindow, remoteDocument, client, container) { | |
| if (!frame || !remoteWindow || !remoteDocument || !client) return null; | |
| const store = this; | |
| const finite = (value, fallback = 0) => { | |
| const number = Number(value); | |
| return Number.isFinite(number) ? number : fallback; | |
| }; | |
| const metrics = () => { | |
| const desktopWidth = Math.max(1, finite(client.desktop_width || container?.clientWidth || remoteWindow.innerWidth, 1)); | |
| const desktopHeight = Math.max(1, finite(client.desktop_height || container?.clientHeight || remoteWindow.innerHeight, 1)); | |
| const primaryWindow = Object.values(client.id_to_window || {})[0]; | |
| const canvas = primaryWindow?.canvas; | |
| const clientWidth = Math.max(1, finite(canvas?.clientWidth || canvas?.width || container?.clientWidth || remoteWindow.innerWidth, desktopWidth)); | |
| const clientHeight = Math.max(1, finite(canvas?.clientHeight || canvas?.height || container?.clientHeight || remoteWindow.innerHeight, desktopHeight)); | |
| return { | |
| desktopWidth, | |
| desktopHeight, | |
| clientWidth, | |
| clientHeight, | |
| scaleX: clientWidth / desktopWidth, | |
| scaleY: clientHeight / desktopHeight, | |
| }; | |
| }; | |
| const bridge = frame.__agentZeroDesktopBridge || {}; | |
| Object.assign(bridge, { | |
| ready: true, | |
| state: async (options = {}) => { | |
| const result = await callDesktop("state", { | |
| include_screenshot: options.includeScreenshot === true || options.include_screenshot === true, | |
| }); | |
| store._desktopLastState = result; | |
| return result; | |
| }, | |
| focus: (options = {}) => store.focusDesktopFrame(frame, { ...options, arm: options.arm !== false }), | |
| requestRefresh: () => { | |
| for (const xpraWindow of Object.values(client.id_to_window || {})) { | |
| if (xpraWindow?.wid != null) client.request_refresh?.(xpraWindow.wid); | |
| } | |
| return true; | |
| }, | |
| desktopToClient: (x, y) => { | |
| const value = metrics(); | |
| return { | |
| x: Math.round(finite(x) * value.scaleX), | |
| y: Math.round(finite(y) * value.scaleY), | |
| scale_x: value.scaleX, | |
| scale_y: value.scaleY, | |
| }; | |
| }, | |
| clientToDesktop: (x, y) => { | |
| const value = metrics(); | |
| return { | |
| x: Math.round(finite(x) / value.scaleX), | |
| y: Math.round(finite(y) / value.scaleY), | |
| scale_x: value.scaleX, | |
| scale_y: value.scaleY, | |
| }; | |
| }, | |
| diagnostics: () => store.desktopBridgeDiagnostics(frame), | |
| }); | |
| frame.agentZeroDesktop = bridge; | |
| frame.__agentZeroDesktopBridge = bridge; | |
| remoteWindow.agentZeroDesktop = bridge; | |
| remoteWindow.__agentZeroDesktopBridge = bridge; | |
| this._desktopBridgeReady = true; | |
| this.updateDesktopKeyboardCaptureState(frame); | |
| return bridge; | |
| }, | |
| desktopBridgeDiagnostics(frame = null) { | |
| return { | |
| ready: this._desktopBridgeReady, | |
| keyboard: this.updateDesktopKeyboardCaptureState(frame), | |
| lastStateOk: this._desktopLastState?.ok ?? null, | |
| }; | |
| }, | |
| updateDesktopKeyboardCaptureState(frame = null) { | |
| const target = this.desktopFrame(frame); | |
| const client = target?.contentWindow?.client; | |
| const state = { | |
| ready: Boolean(target?.__agentZeroDesktopBridge || target?.contentWindow?.__agentZeroDesktopBridge), | |
| active: Boolean(this._desktopKeyboardActive), | |
| capture: Boolean(client?.capture_keyboard), | |
| focused: Boolean(target && (document.activeElement === target || target.contentDocument?.hasFocus?.())), | |
| }; | |
| this._desktopKeyboardCaptureState = state; | |
| return state; | |
| }, | |
| normalizeXpraDesktopWindow(xpraWindow, width, height) { | |
| if (!xpraWindow) return; | |
| const normalizedWidth = Math.max(1, Math.round(Number(width || 0))); | |
| const normalizedHeight = Math.max(1, Math.round(Number(height || 0))); | |
| xpraWindow.x = 0; | |
| xpraWindow.y = 0; | |
| xpraWindow.w = normalizedWidth; | |
| xpraWindow.h = normalizedHeight; | |
| xpraWindow.resizable = false; | |
| xpraWindow.decorations = false; | |
| xpraWindow.decorated = false; | |
| xpraWindow.metadata = { ...(xpraWindow.metadata || {}), decorations: false }; | |
| xpraWindow._set_decorated?.(false); | |
| xpraWindow.configure_border_class?.(); | |
| xpraWindow.leftoffset = 0; | |
| xpraWindow.rightoffset = 0; | |
| xpraWindow.topoffset = 0; | |
| xpraWindow.bottomoffset = 0; | |
| }, | |
| fitXpraDesktopWindowElement(xpraWindow, width, height) { | |
| const normalizedWidth = Math.max(1, Math.round(Number(width || 0))); | |
| const normalizedHeight = Math.max(1, Math.round(Number(height || 0))); | |
| const cssWidth = `${normalizedWidth}px`; | |
| const cssHeight = `${normalizedHeight}px`; | |
| const windowElement = xpraWindow?.div; | |
| const canvas = xpraWindow?.canvas; | |
| windowElement?.style?.setProperty("left", "0px", "important"); | |
| windowElement?.style?.setProperty("top", "0px", "important"); | |
| windowElement?.style?.setProperty("position", "absolute", "important"); | |
| windowElement?.style?.setProperty("width", cssWidth, "important"); | |
| windowElement?.style?.setProperty("height", cssHeight, "important"); | |
| windowElement?.style?.setProperty("transform", "none", "important"); | |
| windowElement?.style?.setProperty("margin", "0", "important"); | |
| canvas?.style?.setProperty("width", cssWidth, "important"); | |
| canvas?.style?.setProperty("height", cssHeight, "important"); | |
| canvas?.style?.setProperty("display", "block", "important"); | |
| canvas?.style?.setProperty("margin", "0", "important"); | |
| if (canvas) { | |
| if (canvas.width !== normalizedWidth) canvas.width = normalizedWidth; | |
| if (canvas.height !== normalizedHeight) canvas.height = normalizedHeight; | |
| canvas.setAttribute("width", String(normalizedWidth)); | |
| canvas.setAttribute("height", String(normalizedHeight)); | |
| } | |
| }, | |
| installXpraDesktopWheelBridge(remoteWindow, xpraWindow) { | |
| const canvas = xpraWindow?.canvas; | |
| if (!remoteWindow || !canvas || canvas.__a0XpraWheelBridgeInstalled) return; | |
| if (typeof xpraWindow.mouse_scroll_cb !== "function") return; | |
| canvas.__a0XpraWheelBridgeInstalled = true; | |
| canvas.addEventListener("wheel", (event) => { | |
| event.stopImmediatePropagation?.(); | |
| event.stopPropagation?.(); | |
| event.preventDefault?.(); | |
| const normalizedEvent = this.xpraDesktopWheelEvent(remoteWindow, canvas, event); | |
| xpraWindow.mouse_scroll_cb(normalizedEvent, xpraWindow); | |
| }, { passive: false, capture: true }); | |
| }, | |
| xpraDesktopWheelEvent(remoteWindow, canvas, event) { | |
| const finite = (value, fallback = 0) => { | |
| const number = Number(value); | |
| return Number.isFinite(number) ? number : fallback; | |
| }; | |
| const deltaMode = finite(event.deltaMode, 0); | |
| const lineHeight = 16; | |
| const pageHeight = Math.max(1, remoteWindow.innerHeight || canvas.clientHeight || 800); | |
| const deltaScale = deltaMode === 1 ? lineHeight : deltaMode === 2 ? pageHeight : 1; | |
| const deltaX = finite(event.deltaX) * deltaScale; | |
| const deltaY = finite(event.deltaY) * deltaScale; | |
| const deltaZ = finite(event.deltaZ) * deltaScale; | |
| const wheelDeltaX = finite(event.wheelDeltaX, -deltaX); | |
| const wheelDeltaY = finite(event.wheelDeltaY, -deltaY); | |
| const wheelDelta = finite(event.wheelDelta, wheelDeltaY || wheelDeltaX); | |
| const getModifierState = (key) => { | |
| if (typeof event.getModifierState === "function") return event.getModifierState(key); | |
| const normalizedKey = String(key || "").toLowerCase(); | |
| if (normalizedKey === "alt") return Boolean(event.altKey); | |
| if (normalizedKey === "control") return Boolean(event.ctrlKey); | |
| if (normalizedKey === "meta") return Boolean(event.metaKey); | |
| if (normalizedKey === "shift") return Boolean(event.shiftKey); | |
| return false; | |
| }; | |
| const normalizedEvent = Object.create(event); | |
| Object.defineProperties(normalizedEvent, { | |
| target: { value: event.target || canvas }, | |
| currentTarget: { value: canvas }, | |
| clientX: { value: finite(event.clientX) }, | |
| clientY: { value: finite(event.clientY) }, | |
| pageX: { value: finite(event.pageX, finite(event.clientX)) }, | |
| pageY: { value: finite(event.pageY, finite(event.clientY)) }, | |
| screenX: { value: finite(event.screenX) }, | |
| screenY: { value: finite(event.screenY) }, | |
| offsetX: { value: finite(event.offsetX) }, | |
| offsetY: { value: finite(event.offsetY) }, | |
| movementX: { value: finite(event.movementX) }, | |
| movementY: { value: finite(event.movementY) }, | |
| button: { value: finite(event.button) }, | |
| buttons: { value: finite(event.buttons) }, | |
| which: { value: finite(event.which) }, | |
| detail: { value: finite(event.detail) }, | |
| deltaX: { value: deltaX }, | |
| deltaY: { value: deltaY }, | |
| deltaZ: { value: deltaZ }, | |
| deltaMode: { value: 0 }, | |
| wheelDeltaX: { value: wheelDeltaX }, | |
| wheelDeltaY: { value: wheelDeltaY }, | |
| wheelDelta: { value: wheelDelta }, | |
| altKey: { value: Boolean(event.altKey) }, | |
| ctrlKey: { value: Boolean(event.ctrlKey) }, | |
| metaKey: { value: Boolean(event.metaKey) }, | |
| shiftKey: { value: Boolean(event.shiftKey) }, | |
| getModifierState: { value: getModifierState }, | |
| preventDefault: { value: () => event.preventDefault?.() }, | |
| stopPropagation: { value: () => event.stopPropagation?.() }, | |
| stopImmediatePropagation: { value: () => event.stopImmediatePropagation?.() }, | |
| }); | |
| return normalizedEvent; | |
| }, | |
| installXpraDesktopFrameCss(remoteDocument) { | |
| if (!remoteDocument || remoteDocument.getElementById("a0-xpra-desktop-frame-css")) return; | |
| const style = remoteDocument.createElement("style"); | |
| style.id = "a0-xpra-desktop-frame-css"; | |
| style.textContent = ` | |
| html, body, #screen { | |
| width: 100% !important; | |
| height: 100% !important; | |
| overflow: auto !important; | |
| } | |
| #float_menu, | |
| .windowhead, | |
| .windowbuttons { | |
| display: none !important; | |
| } | |
| #shadow_pointer { | |
| display: none !important; | |
| visibility: hidden !important; | |
| opacity: 0 !important; | |
| } | |
| .window, | |
| .window.border, | |
| .window.desktop, | |
| .undecorated, | |
| .undecorated.border, | |
| .undecorated.desktop { | |
| left: 0 !important; | |
| top: 0 !important; | |
| position: absolute !important; | |
| width: 100% !important; | |
| height: 100% !important; | |
| transform: none !important; | |
| margin: 0 !important; | |
| border: 0 !important; | |
| border-radius: 0 !important; | |
| box-shadow: none !important; | |
| } | |
| .window canvas, | |
| .undecorated canvas { | |
| display: block !important; | |
| width: 100% !important; | |
| height: 100% !important; | |
| margin: 0 !important; | |
| border: 0 !important; | |
| border-radius: 0 !important; | |
| box-shadow: none !important; | |
| } | |
| `; | |
| remoteDocument.head?.appendChild(style); | |
| }, | |
| installXpraDesktopCursorPatches(remoteWindow, remoteDocument, client) { | |
| if (!remoteWindow || !remoteDocument || !client) return; | |
| const hideShadowPointer = () => { | |
| const pointer = remoteDocument.getElementById?.("shadow_pointer"); | |
| pointer?.style?.setProperty("display", "none", "important"); | |
| pointer?.style?.setProperty("visibility", "hidden", "important"); | |
| pointer?.style?.setProperty("opacity", "0", "important"); | |
| }; | |
| hideShadowPointer(); | |
| const pointerPacket = remoteWindow.PACKET_TYPES?.pointer_position || "pointer-position"; | |
| if (!client.__a0XpraDesktopCursorPatched) { | |
| if (typeof client._process_pointer_position === "function") { | |
| client.__a0OriginalProcessPointerPosition = client._process_pointer_position; | |
| } | |
| client._process_pointer_position = function patchedProcessPointerPosition(packet) { | |
| hideShadowPointer(); | |
| this.__a0LastPointerPosition = packet; | |
| return false; | |
| }; | |
| client.__a0XpraDesktopCursorPatched = true; | |
| } | |
| if (client.packet_handlers && pointerPacket) { | |
| client.packet_handlers[pointerPacket] = client._process_pointer_position; | |
| } | |
| }, | |
| installXpraDesktopFramePatches(remoteWindow, remoteDocument) { | |
| if (!remoteWindow || !remoteDocument) return; | |
| remoteWindow.__a0XpraDesktopFramePatches ||= {}; | |
| const patches = remoteWindow.__a0XpraDesktopFramePatches; | |
| const isBenignXpraWarning = (args = []) => { | |
| const text = Array.from(args || []).map((value) => String(value || "")).join(" "); | |
| return text.includes("window does not fit in canvas, offsets") | |
| || (text.includes("decode error packet") && text.includes("not found")); | |
| }; | |
| if (!patches.consoleWarn && typeof remoteWindow.console?.warn === "function") { | |
| const originalConsoleWarn = remoteWindow.console.warn.bind(remoteWindow.console); | |
| remoteWindow.console.warn = function patchedConsoleWarn(...args) { | |
| if (isBenignXpraWarning(args)) return undefined; | |
| return originalConsoleWarn(...args); | |
| }; | |
| patches.consoleWarn = true; | |
| } | |
| if (!patches.noWindowList && typeof remoteWindow.noWindowList === "function") { | |
| const originalNoWindowList = remoteWindow.noWindowList; | |
| remoteWindow.noWindowList = function patchedNoWindowList(...args) { | |
| if (!remoteDocument.querySelector("#open_windows")) return undefined; | |
| return originalNoWindowList.apply(this, args); | |
| }; | |
| patches.noWindowList = true; | |
| } | |
| if (!patches.addWindowListItem && typeof remoteWindow.addWindowListItem === "function") { | |
| const originalAddWindowListItem = remoteWindow.addWindowListItem; | |
| remoteWindow.addWindowListItem = function patchedAddWindowListItem(...args) { | |
| if (!remoteDocument.querySelector("#open_windows_list")) return undefined; | |
| return originalAddWindowListItem.apply(this, args); | |
| }; | |
| patches.addWindowListItem = true; | |
| } | |
| }, | |
| installXpraDesktopClientPatches(remoteWindow, client) { | |
| if (!remoteWindow || !client) return; | |
| if (!client.__a0XpraOffsetWarnPatched && typeof client.warn === "function") { | |
| const originalClientWarn = client.warn.bind(client); | |
| client.warn = function patchedClientWarn(...args) { | |
| const text = Array.from(args || []).map((value) => String(value || "")).join(" "); | |
| if ( | |
| text.includes("window does not fit in canvas, offsets") | |
| || (text.includes("decode error packet") && text.includes("not found")) | |
| ) { | |
| return undefined; | |
| } | |
| return originalClientWarn(...args); | |
| }; | |
| client.__a0XpraOffsetWarnPatched = true; | |
| } | |
| if (client.__a0XpraDesktopClientPatched) return; | |
| if (typeof client._screen_resized === "function") { | |
| const originalScreenResized = client._screen_resized.bind(client); | |
| client.__a0OriginalScreenResized = originalScreenResized; | |
| client._screen_resized = function patchedScreenResized(event) { | |
| if (client.__a0AllowScreenResize === true) return originalScreenResized(event); | |
| return false; | |
| }; | |
| } | |
| client.__a0XpraDesktopClientPatched = true; | |
| }, | |
| installXpraDesktopClipboardBridge(frame, remoteWindow, remoteDocument, client) { | |
| if (!frame || !remoteWindow || !remoteDocument || !client) return; | |
| this.ensureDesktopClipboardBridge(); | |
| if (remoteWindow.__a0XpraDesktopClipboardBridgeInstalled) return; | |
| const onPaste = (event) => { | |
| this.handleDesktopPasteEvent(event, frame, remoteWindow, client); | |
| }; | |
| const onKeydown = (event) => { | |
| if (this.isDesktopPasteShortcut(event)) { | |
| void this.syncHostClipboardToDesktop(frame); | |
| } | |
| }; | |
| remoteWindow.addEventListener("paste", onPaste, true); | |
| remoteDocument.addEventListener("paste", onPaste, true); | |
| remoteWindow.addEventListener("keydown", onKeydown, true); | |
| remoteDocument.addEventListener("keydown", onKeydown, true); | |
| remoteWindow.__a0XpraDesktopClipboardBridgeInstalled = true; | |
| remoteWindow.__a0XpraDesktopClipboardBridgeCleanup = () => { | |
| remoteWindow.removeEventListener("paste", onPaste, true); | |
| remoteDocument.removeEventListener("paste", onPaste, true); | |
| remoteWindow.removeEventListener("keydown", onKeydown, true); | |
| remoteDocument.removeEventListener("keydown", onKeydown, true); | |
| remoteWindow.__a0XpraDesktopClipboardBridgeInstalled = false; | |
| }; | |
| }, | |
| ensureDesktopClipboardBridge() { | |
| if (this._desktopClipboardCleanup) return; | |
| const onPaste = (event) => { | |
| if (!this._desktopKeyboardActive || !this.hasOfficialOffice()) return; | |
| if (isEditableInputTarget(event.target)) return; | |
| const frame = this.desktopFrame(); | |
| const remoteWindow = frame?.contentWindow; | |
| const client = remoteWindow?.client; | |
| if (!frame || !remoteWindow || !client) return; | |
| this.handleDesktopPasteEvent(event, frame, remoteWindow, client); | |
| }; | |
| document.addEventListener("paste", onPaste, true); | |
| this._desktopClipboardCleanup = () => { | |
| document.removeEventListener("paste", onPaste, true); | |
| this._desktopClipboardCleanup = null; | |
| }; | |
| }, | |
| stopDesktopClipboardBridge() { | |
| this._desktopClipboardCleanup?.(); | |
| }, | |
| handleDesktopPasteEvent(event, frame, remoteWindow, client) { | |
| const text = this.desktopClipboardTextFromEvent(event); | |
| if (!text) return false; | |
| if (!this.syncXpraClipboardText(client, text, remoteWindow)) return false; | |
| event.preventDefault?.(); | |
| event.stopImmediatePropagation?.(); | |
| event.stopPropagation?.(); | |
| this.focusDesktopFrame(frame, { arm: true }); | |
| return true; | |
| }, | |
| desktopClipboardTextFromEvent(event) { | |
| const data = (event?.originalEvent || event)?.clipboardData; | |
| if (!data?.getData) return ""; | |
| for (const type of ["text/plain", "text", "Text", "STRING", "UTF8_STRING"]) { | |
| const value = data.getData(type); | |
| if (value) return value; | |
| } | |
| return ""; | |
| }, | |
| syncXpraClipboardText(client, text, remoteWindow = null) { | |
| const value = String(text ?? ""); | |
| if (!client || !value || typeof client.send_clipboard_token !== "function") return false; | |
| const textPlain = remoteWindow?.TEXT_PLAIN || "text/plain"; | |
| const utf8String = remoteWindow?.UTF8_STRING || "UTF8_STRING"; | |
| const utilities = remoteWindow?.Utilities; | |
| const payload = utilities?.StringToUint8 ? utilities.StringToUint8(value) : value; | |
| client.clipboard_enabled = true; | |
| client.clipboard_direction = "both"; | |
| client.clipboard_buffer = value; | |
| client.clipboard_pending = false; | |
| client.send_clipboard_token(payload, [textPlain, utf8String, "TEXT", "STRING"]); | |
| return true; | |
| }, | |
| async syncHostClipboardToDesktop(frame = null) { | |
| const target = this.desktopFrame(frame); | |
| const remoteWindow = target?.contentWindow; | |
| const client = remoteWindow?.client; | |
| if (!client || !navigator.clipboard?.readText) return false; | |
| try { | |
| const text = await navigator.clipboard.readText(); | |
| return this.syncXpraClipboardText(client, text, remoteWindow); | |
| } catch { | |
| return false; | |
| } | |
| }, | |
| isDesktopPasteShortcut(event) { | |
| const key = String(event?.key || "").toLowerCase(); | |
| return key === "v" && (event?.ctrlKey || event?.metaKey) && !event?.altKey; | |
| }, | |
| installXpraDesktopKeyboardBridge(frame, remoteWindow, remoteDocument, client) { | |
| if (!frame || !remoteWindow || !remoteDocument || !client) return; | |
| this.ensureDesktopKeyboardBridge(); | |
| frame.setAttribute("tabindex", "0"); | |
| if (remoteWindow.__a0XpraDesktopKeyboardBridgeInstalled) return; | |
| const activate = () => { | |
| if (this._desktopFocusInProgress) return; | |
| this.focusDesktopFrame(frame, { arm: true }); | |
| }; | |
| const events = ["pointerdown", "mousedown", "touchstart", "focusin"]; | |
| for (const eventName of events) { | |
| remoteDocument.addEventListener(eventName, activate, true); | |
| } | |
| remoteWindow.addEventListener("focus", activate, true); | |
| remoteWindow.__a0XpraDesktopKeyboardBridgeInstalled = true; | |
| remoteWindow.__a0XpraDesktopKeyboardBridgeCleanup = () => { | |
| for (const eventName of events) { | |
| remoteDocument.removeEventListener(eventName, activate, true); | |
| } | |
| remoteWindow.removeEventListener("focus", activate, true); | |
| remoteWindow.__a0XpraDesktopKeyboardBridgeInstalled = false; | |
| }; | |
| }, | |
| ensureDesktopKeyboardBridge() { | |
| if (this._desktopKeyboardCleanup) return; | |
| const deactivateWhenOutsideDesktop = (event) => { | |
| const target = event.target; | |
| if (target?.closest?.(".office-desktop-wrap") || target?.matches?.("[data-office-desktop-frame]")) return; | |
| this._desktopKeyboardActive = false; | |
| }; | |
| const forwardKeyboardEvent = (event, pressed) => { | |
| if (!this._desktopKeyboardActive || !this.hasOfficialOffice()) return; | |
| if (event.defaultPrevented || isEditableInputTarget(event.target)) return; | |
| const frame = this.desktopFrame(); | |
| if (!frame || document.activeElement === frame) return; | |
| const client = frame.contentWindow?.client; | |
| const handler = pressed ? client?._keyb_onkeydown : client?._keyb_onkeyup; | |
| if (!client?.capture_keyboard || typeof handler !== "function") return; | |
| if (pressed && this.isDesktopPasteShortcut(event)) { | |
| void this.syncHostClipboardToDesktop(frame); | |
| } | |
| const allowDefault = handler.call(client, event); | |
| if (!allowDefault) { | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| } | |
| }; | |
| const onKeydown = (event) => forwardKeyboardEvent(event, true); | |
| const onKeyup = (event) => forwardKeyboardEvent(event, false); | |
| document.addEventListener("pointerdown", deactivateWhenOutsideDesktop, true); | |
| document.addEventListener("keydown", onKeydown, true); | |
| document.addEventListener("keyup", onKeyup, true); | |
| this._desktopKeyboardCleanup = () => { | |
| document.removeEventListener("pointerdown", deactivateWhenOutsideDesktop, true); | |
| document.removeEventListener("keydown", onKeydown, true); | |
| document.removeEventListener("keyup", onKeyup, true); | |
| this._desktopKeyboardActive = false; | |
| this._desktopKeyboardCleanup = null; | |
| }; | |
| }, | |
| stopDesktopKeyboardBridge() { | |
| this._desktopKeyboardCleanup?.(); | |
| }, | |
| queueDesktopResize(options = {}) { | |
| if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) return; | |
| const token = this.session?.desktop?.token || ""; | |
| const frame = this.desktopFrame(options.frame || null); | |
| const target = frame?.parentElement || frame; | |
| if (!token || !target) return; | |
| const force = Boolean(options.force); | |
| const serverResize = options.serverResize !== false; | |
| const rect = target.getBoundingClientRect(); | |
| const width = Math.round(rect.width); | |
| const height = Math.round(rect.height); | |
| if (width < 320 || height < 220) return; | |
| const key = `${token}:${width}x${height}`; | |
| const refreshFrameOnly = () => { | |
| this.applyXpraDesktopFrameMode(frame, { requestServerResize: false, requestRefresh: false }); | |
| }; | |
| if (!serverResize) { | |
| refreshFrameOnly(); | |
| return; | |
| } | |
| if (key === this._desktopResizeKey || key === this._desktopResizePendingKey) { | |
| refreshFrameOnly(); | |
| return; | |
| } | |
| refreshFrameOnly(); | |
| if (!force && this.shouldDeferDesktopResize()) { | |
| this._desktopResizePending = true; | |
| return; | |
| } | |
| if (this._desktopResizeTimer) globalThis.clearTimeout(this._desktopResizeTimer); | |
| this._desktopResizePendingKey = key; | |
| this._desktopResizeTimer = globalThis.setTimeout(async () => { | |
| this._desktopResizeTimer = null; | |
| if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) { | |
| if (this._desktopResizePendingKey === key) this._desktopResizePendingKey = ""; | |
| return; | |
| } | |
| if (!force && this.shouldDeferDesktopResize()) { | |
| if (this._desktopResizePendingKey === key) this._desktopResizePendingKey = ""; | |
| this._desktopResizePending = true; | |
| return; | |
| } | |
| try { | |
| const params = new URLSearchParams({ token, width: String(width), height: String(height) }); | |
| const response = await fetch(`/desktop/resize?${params.toString()}`, { credentials: "same-origin" }); | |
| if (response.ok) { | |
| const result = await response.json().catch(() => ({})); | |
| const displaySize = this.rememberDesktopDisplaySize( | |
| token, | |
| result?.width || width, | |
| result?.height || height, | |
| ); | |
| this._desktopResizeKey = key; | |
| const activeFrame = this.desktopFrame(frame); | |
| const activeTarget = activeFrame?.parentElement || activeFrame; | |
| const activeRect = activeTarget?.getBoundingClientRect?.(); | |
| const activeWidth = Math.round(activeRect?.width || 0); | |
| const activeHeight = Math.round(activeRect?.height || 0); | |
| if (activeWidth >= 320 && activeHeight >= 220) { | |
| const activeKey = `${token}:${activeWidth}x${activeHeight}`; | |
| if (activeKey !== key) { | |
| this.queueDesktopResize({ force: true, serverResize: true, frame: activeFrame }); | |
| return; | |
| } | |
| } | |
| if (result?.reload) this.reloadDesktopFrame(activeFrame || frame); | |
| this.primeXpraDesktopFrame({ reset: true, frame: activeFrame || frame, token, displaySize }); | |
| } | |
| } catch (error) { | |
| console.warn("Desktop resize skipped", error); | |
| } finally { | |
| if (this._desktopResizePendingKey === key) this._desktopResizePendingKey = ""; | |
| } | |
| }, DESKTOP_RESIZE_DELAY_MS); | |
| }, | |
| reloadDesktopFrame(frame = null) { | |
| const target = this.desktopFrame(frame); | |
| if (!target) return; | |
| const current = target.getAttribute("src") || target.src || this.officialOfficeUrl(); | |
| if (!current) return; | |
| try { | |
| const url = new URL(current, window.location.href); | |
| url.searchParams.set("a0_reload", String(Date.now())); | |
| target.setAttribute("src", `${url.pathname}${url.search}`); | |
| } catch { | |
| target.setAttribute("src", current); | |
| } | |
| }, | |
| async handleDesktopUrlIntents(intents = []) { | |
| const incoming = Array.isArray(intents) | |
| ? intents.filter((intent) => intent && typeof intent === "object") | |
| : []; | |
| if (!incoming.length) return; | |
| this._desktopUrlIntentQueue.push(...incoming); | |
| if (this._desktopUrlIntentBusy) return; | |
| this._desktopUrlIntentBusy = true; | |
| try { | |
| while (this._desktopUrlIntentQueue.length) { | |
| const intent = this._desktopUrlIntentQueue.shift(); | |
| await this.openDesktopUrlIntent(intent); | |
| } | |
| } finally { | |
| this._desktopUrlIntentBusy = false; | |
| } | |
| }, | |
| async openDesktopUrlIntent(intent = {}) { | |
| const url = String(intent?.url || "").trim(); | |
| const handled = await handleUrlIntent({ url, source: "desktop-url" }); | |
| const isEditorIntent = url.startsWith("a0-editor:"); | |
| this.setMessage( | |
| handled | |
| ? (isEditorIntent ? "Opened text in Editor" : "Opened link in Browser") | |
| : (isEditorIntent ? "Editor is not available" : "Browser is not available"), | |
| ); | |
| }, | |
| browserDestinationForDesktopUrl() { | |
| if (this.isDesktopInModal()) return "canvas"; | |
| return "modal"; | |
| }, | |
| isDesktopInModal() { | |
| const modalDesktop = Array.from(document.querySelectorAll(".office-panel")) | |
| .some((panel) => panel.closest?.(".modal") && panel.querySelector?.("[data-office-desktop-frame]")); | |
| if (modalDesktop) return true; | |
| return this._mode === "modal"; | |
| }, | |
| startDesktopMonitor() { | |
| this.stopDesktopMonitor(); | |
| if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) return; | |
| const tabId = this.session?.tab_id || ""; | |
| const sessionId = this.session?.desktop_session_id || this.session?.session_id || ""; | |
| if (!tabId || !sessionId) return; | |
| this._desktopHeartbeatSessionId = sessionId; | |
| this._desktopHeartbeatTabId = tabId; | |
| this._desktopHeartbeatMisses = 0; | |
| const tick = async () => { | |
| if (!this.session || this.session.tab_id !== tabId || !this.hasOfficialOffice() || !this.isDesktopHostVisible()) return; | |
| try { | |
| const response = await callDesktop("sync", { | |
| desktop_session_id: sessionId, | |
| file_id: this.session.file_id || "", | |
| }); | |
| if (response?.intentional_shutdown || response?.shutdown) { | |
| await this.handleIntentionalDesktopShutdown(response); | |
| return; | |
| } | |
| if (response?.ok === false) throw new Error(response.error || "Desktop session closed."); | |
| this._desktopHeartbeatMisses = 0; | |
| await this.handleDesktopUrlIntents(response?.url_intents); | |
| if (response?.document) { | |
| const document = normalizeDocument(response.document); | |
| this.replaceActiveSession({ | |
| ...this.session, | |
| document, | |
| path: document.path || this.session.path, | |
| file_id: document.file_id || this.session.file_id, | |
| version: document.version || this.session.version, | |
| }); | |
| } | |
| } catch { | |
| if (!this.session || this.session.tab_id !== tabId) return; | |
| this._desktopHeartbeatMisses += 1; | |
| if (this._desktopHeartbeatMisses >= 2) { | |
| await this.handleOfficialOfficeClosed(tabId); | |
| } | |
| } | |
| }; | |
| this._desktopHeartbeatTimer = globalThis.setInterval(tick, DESKTOP_HEARTBEAT_MS); | |
| globalThis.setTimeout(tick, Math.min(1200, DESKTOP_HEARTBEAT_MS)); | |
| }, | |
| stopDesktopMonitor() { | |
| if (this._desktopHeartbeatTimer) { | |
| globalThis.clearInterval(this._desktopHeartbeatTimer); | |
| } | |
| this._desktopHeartbeatTimer = null; | |
| this._desktopHeartbeatSessionId = ""; | |
| this._desktopHeartbeatTabId = ""; | |
| this._desktopHeartbeatMisses = 0; | |
| }, | |
| async handleOfficialOfficeClosed(tabId) { | |
| if (this._desktopIntentionalShutdown) return; | |
| const tab = this.tabs.find((item) => item.tab_id === tabId); | |
| const hiddenDesktopDocument = !tab && this.session?.tab_id === tabId && this.isDesktopOfficeDocument(this.session) | |
| ? this.session | |
| : null; | |
| const target = tab || hiddenDesktopDocument; | |
| if (!target || target._desktopClosed) return; | |
| target._desktopClosed = true; | |
| this.stopDesktopMonitor(); | |
| this.stopDesktopResizeObserver(); | |
| this.stopXpraDesktopPrime(); | |
| this.message = "Desktop is restarting"; | |
| await this.ensureDesktopSession({ | |
| force: true, | |
| select: this.activeTabId === tabId || Boolean(hiddenDesktopDocument), | |
| message: "Desktop is restarting", | |
| }); | |
| target._desktopClosed = false; | |
| await this.refresh(); | |
| }, | |
| defaultTitle(kind, fmt) { | |
| const date = new Date().toISOString().slice(0, 10); | |
| if (fmt === "odt") return `Writer ${date}`; | |
| if (fmt === "docx") return `DOCX ${date}`; | |
| if (kind === "spreadsheet") return `Spreadsheet ${date}`; | |
| if (kind === "presentation") return `Presentation ${date}`; | |
| return `Document ${date}`; | |
| }, | |
| tabTitle(tab = {}) { | |
| tab = tab || {}; | |
| return tab.title || tab.document?.basename || basename(tab.path); | |
| }, | |
| tabLabel(tab = {}) { | |
| tab = tab || {}; | |
| const title = this.tabTitle(tab); | |
| return tab.dirty ? `${title} unsaved` : title; | |
| }, | |
| tabIcon(tab = {}) { | |
| tab = tab || {}; | |
| const ext = String(tab.extension || tab.document?.extension || "").toLowerCase(); | |
| if (this.isDesktopSession(tab)) return "desktop_windows"; | |
| if (ext === "md") return "article"; | |
| if (ext === "odt" || ext === "docx") return "description"; | |
| if (ext === "ods" || ext === "xlsx") return "table_chart"; | |
| if (ext === "odp" || ext === "pptx") return "co_present"; | |
| if (ext === "txt") return "notes"; | |
| return "draft"; | |
| }, | |
| async runNewMenuAction(action = "") { | |
| const normalized = String(action || "").trim().toLowerCase(); | |
| if (normalized === "open") return await this.openFileBrowser(); | |
| if (normalized === "writer") return await this.create("document", "odt"); | |
| if (normalized === "spreadsheet") return await this.create("spreadsheet", "ods"); | |
| if (normalized === "presentation") return await this.create("presentation", "odp"); | |
| return null; | |
| }, | |
| installHeaderNewMenu(header = null) { | |
| if (!header || header.querySelector(".office-header-actions")) return () => {}; | |
| const root = globalThis.document.createElement("div"); | |
| root.className = "office-header-actions surface-modal-new-action"; | |
| root.innerHTML = ` | |
| <button type="button" class="office-header-new-button surface-modal-new-button" aria-haspopup="menu" aria-expanded="false"> | |
| <x-icon aria-hidden="true" name="add"></x-icon> | |
| <span>New</span> | |
| <x-icon class="office-new-chevron" aria-hidden="true" name="expand_more"></x-icon> | |
| </button> | |
| <div class="office-new-menu" role="menu" hidden> | |
| <button type="button" class="office-new-menu-item" role="menuitem" data-office-new-action="open"> | |
| <x-icon aria-hidden="true" name="folder_open"></x-icon> | |
| <span>Open</span> | |
| </button> | |
| <button type="button" class="office-new-menu-item" role="menuitem" data-office-new-action="writer"> | |
| <x-icon aria-hidden="true" name="description"></x-icon> | |
| <span>Writer</span> | |
| </button> | |
| <button type="button" class="office-new-menu-item" role="menuitem" data-office-new-action="spreadsheet"> | |
| <x-icon aria-hidden="true" name="table_chart"></x-icon> | |
| <span>Spreadsheet</span> | |
| </button> | |
| <button type="button" class="office-new-menu-item" role="menuitem" data-office-new-action="presentation"> | |
| <x-icon aria-hidden="true" name="co_present"></x-icon> | |
| <span>Presentation</span> | |
| </button> | |
| </div> | |
| `; | |
| const button = root.querySelector(".office-header-new-button"); | |
| const menu = root.querySelector(".office-new-menu"); | |
| const setOpen = (open) => { | |
| root.classList.toggle("is-open", open); | |
| button?.setAttribute("aria-expanded", open.toString()); | |
| if (menu) menu.hidden = !open; | |
| }; | |
| const onButtonClick = (event) => { | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| setOpen(!root.classList.contains("is-open")); | |
| }; | |
| const onDocumentClick = (event) => { | |
| if (!root.contains(event.target)) setOpen(false); | |
| }; | |
| const onDocumentKeydown = (event) => { | |
| if (event.key === "Escape") setOpen(false); | |
| }; | |
| button?.addEventListener("click", onButtonClick); | |
| for (const item of root.querySelectorAll("[data-office-new-action]")) { | |
| item.addEventListener("click", async (event) => { | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| const action = event.currentTarget?.dataset?.officeNewAction || ""; | |
| setOpen(false); | |
| await this.runNewMenuAction(action); | |
| }); | |
| } | |
| globalThis.document.addEventListener("click", onDocumentClick); | |
| globalThis.document.addEventListener("keydown", onDocumentKeydown); | |
| placeSurfaceModalHeaderAction(header, root, "new"); | |
| setOpen(false); | |
| return () => { | |
| button?.removeEventListener("click", onButtonClick); | |
| globalThis.document.removeEventListener("click", onDocumentClick); | |
| globalThis.document.removeEventListener("keydown", onDocumentKeydown); | |
| root.remove(); | |
| }; | |
| }, | |
| setupFloatingModal(element = null) { | |
| const root = element || globalThis.document?.querySelector(".office-panel"); | |
| const modal = root?.closest?.(".modal"); | |
| const inner = root?.closest?.(".modal-inner"); | |
| const body = root?.closest?.(".modal-bd"); | |
| const header = inner?.querySelector?.(".modal-header"); | |
| if (!inner || !body || !header || inner.dataset.officeModalReady === "1") return; | |
| inner.dataset.officeModalReady = "1"; | |
| modal?.classList?.add("surface-floating", "modal-floating", "modal-no-backdrop"); | |
| inner.classList.add("surface-modal", "office-modal", "modal-no-backdrop"); | |
| body.classList.add("office-modal-body"); | |
| header.style.cursor = "move"; | |
| const inset = 8; | |
| const minWidth = 720; | |
| const minHeight = 520; | |
| const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); | |
| const cleanup = []; | |
| let beforeFocusBounds = null; | |
| let dragging = false; | |
| let resizing = false; | |
| let pointerId = 0; | |
| let startX = 0; | |
| let startY = 0; | |
| let startLeft = 0; | |
| let startTop = 0; | |
| let startWidth = 0; | |
| let startHeight = 0; | |
| let resizeMode = ""; | |
| const newMenuCleanup = this.installHeaderNewMenu(header); | |
| const currentBounds = () => { | |
| const rect = inner.getBoundingClientRect(); | |
| return { | |
| left: rect.left, | |
| top: rect.top, | |
| width: rect.width, | |
| height: rect.height, | |
| }; | |
| }; | |
| const normalizedBounds = (bounds) => { | |
| const maxWidth = Math.max(320, globalThis.innerWidth - inset * 2); | |
| const maxHeight = Math.max(320, globalThis.innerHeight - inset * 2); | |
| const safeMinWidth = Math.min(minWidth, maxWidth); | |
| const safeMinHeight = Math.min(minHeight, maxHeight); | |
| const width = clamp(bounds.width, safeMinWidth, maxWidth); | |
| const height = clamp(bounds.height, safeMinHeight, maxHeight); | |
| return { | |
| width, | |
| height, | |
| left: clamp(bounds.left, inset, Math.max(inset, globalThis.innerWidth - width - inset)), | |
| top: clamp(bounds.top, inset, Math.max(inset, globalThis.innerHeight - height - inset)), | |
| }; | |
| }; | |
| const setBounds = (bounds) => { | |
| const next = normalizedBounds(bounds); | |
| inner.style.position = "fixed"; | |
| inner.style.transform = "none"; | |
| inner.style.left = `${Math.round(next.left)}px`; | |
| inner.style.top = `${Math.round(next.top)}px`; | |
| inner.style.width = `${Math.round(next.width)}px`; | |
| inner.style.height = `${Math.round(next.height)}px`; | |
| inner.style.right = "auto"; | |
| inner.style.bottom = "auto"; | |
| inner.style.margin = "0"; | |
| }; | |
| const ensurePosition = () => { | |
| setBounds(currentBounds()); | |
| }; | |
| const shield = globalThis.document.createElement("div"); | |
| shield.className = "office-modal-input-shield"; | |
| inner.appendChild(shield); | |
| cleanup.push(() => shield.remove()); | |
| const setShield = (visible, cursor = "") => { | |
| shield.style.display = visible ? "block" : "none"; | |
| shield.style.cursor = cursor; | |
| }; | |
| const focusButton = globalThis.document.createElement("button"); | |
| focusButton.type = "button"; | |
| focusButton.className = "surface-button office-modal-focus-button"; | |
| focusButton.innerHTML = '<x-icon aria-hidden="true" name="fullscreen"></x-icon>'; | |
| const updateFocusButton = (active) => { | |
| const label = active ? "Restore size" : "Focus mode"; | |
| focusButton.setAttribute("aria-label", label); | |
| focusButton.setAttribute("title", label); | |
| focusButton.querySelector("x-icon").name = active ? "fullscreen_exit" : "fullscreen"; | |
| }; | |
| updateFocusButton(false); | |
| placeSurfaceModalHeaderAction(header, focusButton, "window"); | |
| cleanup.push(() => focusButton.remove()); | |
| const setFocusMode = (enabled) => { | |
| ensurePosition(); | |
| if (enabled) { | |
| beforeFocusBounds = currentBounds(); | |
| inner.classList.add("is-focus-mode"); | |
| setBounds({ | |
| left: inset, | |
| top: inset, | |
| width: globalThis.innerWidth - inset * 2, | |
| height: globalThis.innerHeight - inset * 2, | |
| }); | |
| updateFocusButton(true); | |
| return; | |
| } | |
| inner.classList.remove("is-focus-mode"); | |
| setBounds(beforeFocusBounds || currentBounds()); | |
| beforeFocusBounds = null; | |
| updateFocusButton(false); | |
| }; | |
| const onFocusClick = () => setFocusMode(!inner.classList.contains("is-focus-mode")); | |
| focusButton.addEventListener("click", onFocusClick); | |
| cleanup.push(() => focusButton.removeEventListener("click", onFocusClick)); | |
| const onPointerDown = (event) => { | |
| if (event.button !== 0) return; | |
| if (event.target?.closest?.("button,a,input,textarea,select")) return; | |
| if (inner.classList.contains("is-focus-mode")) return; | |
| ensurePosition(); | |
| const rect = inner.getBoundingClientRect(); | |
| dragging = true; | |
| pointerId = event.pointerId; | |
| startX = event.clientX; | |
| startY = event.clientY; | |
| startLeft = rect.left; | |
| startTop = rect.top; | |
| startWidth = rect.width; | |
| startHeight = rect.height; | |
| inner.classList.add("is-dragging"); | |
| setShield(true, "move"); | |
| header.setPointerCapture?.(pointerId); | |
| event.preventDefault(); | |
| }; | |
| const onPointerMove = (event) => { | |
| if (!dragging || event.pointerId !== pointerId) return; | |
| setBounds({ | |
| left: startLeft + event.clientX - startX, | |
| top: startTop + event.clientY - startY, | |
| width: startWidth, | |
| height: startHeight, | |
| }); | |
| }; | |
| const onPointerUp = (event) => { | |
| if (!dragging || event.pointerId !== pointerId) return; | |
| dragging = false; | |
| inner.classList.remove("is-dragging"); | |
| setShield(false); | |
| header.releasePointerCapture?.(pointerId); | |
| }; | |
| const createResizeHandle = (mode) => { | |
| const handle = globalThis.document.createElement("div"); | |
| handle.className = `office-modal-resizer is-${mode}`; | |
| handle.dataset.officeResize = mode; | |
| inner.appendChild(handle); | |
| cleanup.push(() => handle.remove()); | |
| return handle; | |
| }; | |
| const onResizeDown = (event) => { | |
| if (event.button !== 0 || inner.classList.contains("is-focus-mode")) return; | |
| ensurePosition(); | |
| const rect = inner.getBoundingClientRect(); | |
| resizing = true; | |
| resizeMode = event.currentTarget.dataset.officeResize || ""; | |
| pointerId = event.pointerId; | |
| startX = event.clientX; | |
| startY = event.clientY; | |
| startLeft = rect.left; | |
| startTop = rect.top; | |
| startWidth = rect.width; | |
| startHeight = rect.height; | |
| inner.classList.add("is-resizing"); | |
| this.suspendDesktopResize(); | |
| setShield(true, resizeMode === "right" ? "ew-resize" : resizeMode === "bottom" ? "ns-resize" : "nwse-resize"); | |
| event.currentTarget.setPointerCapture?.(pointerId); | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| }; | |
| const onResizeMove = (event) => { | |
| if (!resizing || event.pointerId !== pointerId) return; | |
| const dx = event.clientX - startX; | |
| const dy = event.clientY - startY; | |
| setBounds({ | |
| left: startLeft, | |
| top: startTop, | |
| width: resizeMode === "bottom" ? startWidth : startWidth + dx, | |
| height: resizeMode === "right" ? startHeight : startHeight + dy, | |
| }); | |
| }; | |
| const onResizeUp = (event) => { | |
| if (!resizing || event.pointerId !== pointerId) return; | |
| resizing = false; | |
| resizeMode = ""; | |
| inner.classList.remove("is-resizing"); | |
| setShield(false); | |
| event.currentTarget.releasePointerCapture?.(pointerId); | |
| this.resumeDesktopResize(); | |
| }; | |
| header.addEventListener("pointerdown", onPointerDown); | |
| header.addEventListener("pointermove", onPointerMove); | |
| header.addEventListener("pointerup", onPointerUp); | |
| header.addEventListener("pointercancel", onPointerUp); | |
| cleanup.push(() => header.removeEventListener("pointerdown", onPointerDown)); | |
| cleanup.push(() => header.removeEventListener("pointermove", onPointerMove)); | |
| cleanup.push(() => header.removeEventListener("pointerup", onPointerUp)); | |
| cleanup.push(() => header.removeEventListener("pointercancel", onPointerUp)); | |
| for (const mode of ["right", "bottom", "corner"]) { | |
| const handle = createResizeHandle(mode); | |
| handle.addEventListener("pointerdown", onResizeDown); | |
| handle.addEventListener("pointermove", onResizeMove); | |
| handle.addEventListener("pointerup", onResizeUp); | |
| handle.addEventListener("pointercancel", onResizeUp); | |
| cleanup.push(() => handle.removeEventListener("pointerdown", onResizeDown)); | |
| cleanup.push(() => handle.removeEventListener("pointermove", onResizeMove)); | |
| cleanup.push(() => handle.removeEventListener("pointerup", onResizeUp)); | |
| cleanup.push(() => handle.removeEventListener("pointercancel", onResizeUp)); | |
| } | |
| const onWindowResize = () => { | |
| if (inner.classList.contains("is-focus-mode")) { | |
| setBounds({ | |
| left: inset, | |
| top: inset, | |
| width: globalThis.innerWidth - inset * 2, | |
| height: globalThis.innerHeight - inset * 2, | |
| }); | |
| return; | |
| } | |
| ensurePosition(); | |
| }; | |
| globalThis.addEventListener("resize", onWindowResize); | |
| cleanup.push(() => globalThis.removeEventListener("resize", onWindowResize)); | |
| if (globalThis.requestAnimationFrame) { | |
| globalThis.requestAnimationFrame(ensurePosition); | |
| } else { | |
| globalThis.setTimeout(ensurePosition, 0); | |
| } | |
| this._floatingCleanup = () => { | |
| newMenuCleanup?.(); | |
| cleanup.splice(0).reverse().forEach((entry) => entry()); | |
| modal?.classList?.remove("surface-floating", "modal-floating", "modal-no-backdrop"); | |
| inner.classList.remove("is-dragging", "is-resizing", "is-focus-mode"); | |
| this._desktopResizeSuspended = false; | |
| this._desktopResizePending = false; | |
| delete inner.dataset.officeModalReady; | |
| }; | |
| }, | |
| }; | |
| export const store = createStore("desktop", model); | |