mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-05-17 04:01:13 +00:00
Add a builtin _editor plugin that owns Markdown API/WebSocket sessions, canvas and modal UI, live refresh, tabs, prompt Extras for active-context open files, inline close confirmation, and Close All handling. Route Markdown document artifacts to Editor while keeping Office/Desktop focused on LibreOffice formats, and update Desktop/Office prompts, menus, compatibility shims, and regression coverage.
2687 lines
97 KiB
JavaScript
2687 lines
97 KiB
JavaScript
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 } 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"].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,
|
|
_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() {
|
|
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 (this._mode === "modal") this._root = null;
|
|
},
|
|
|
|
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) {
|
|
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"].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 true;
|
|
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)) 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 width = Math.round(container.clientWidth || remoteWindow.innerWidth || 0);
|
|
const height = Math.round(container.clientHeight || remoteWindow.innerHeight || 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;
|
|
}
|
|
},
|
|
|
|
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 clientWidth = Math.max(1, finite(container?.clientWidth || remoteWindow.innerWidth, desktopWidth));
|
|
const clientHeight = Math.max(1, finite(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 cssWidth = `${Math.max(1, Number(width || 0))}px`;
|
|
const cssHeight = `${Math.max(1, Number(height || 0))}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");
|
|
},
|
|
|
|
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: hidden !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(() => ({}));
|
|
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 });
|
|
}
|
|
} 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" });
|
|
this.setMessage(handled ? "Opened link in Browser" : "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";
|
|
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";
|
|
root.innerHTML = `
|
|
<button type="button" class="office-header-new-button" aria-haspopup="menu" aria-expanded="false">
|
|
<span class="material-symbols-outlined" aria-hidden="true">add</span>
|
|
<span>New</span>
|
|
<span class="material-symbols-outlined office-new-chevron" aria-hidden="true">expand_more</span>
|
|
</button>
|
|
<div class="office-new-menu" role="menu" hidden>
|
|
<button type="button" class="office-new-menu-item" role="menuitem" data-office-new-action="open">
|
|
<span class="material-symbols-outlined" aria-hidden="true">folder_open</span>
|
|
<span>Open</span>
|
|
</button>
|
|
<button type="button" class="office-new-menu-item" role="menuitem" data-office-new-action="writer">
|
|
<span class="material-symbols-outlined" aria-hidden="true">description</span>
|
|
<span>Writer</span>
|
|
</button>
|
|
<button type="button" class="office-new-menu-item" role="menuitem" data-office-new-action="spreadsheet">
|
|
<span class="material-symbols-outlined" aria-hidden="true">table_chart</span>
|
|
<span>Spreadsheet</span>
|
|
</button>
|
|
<button type="button" class="office-new-menu-item" role="menuitem" data-office-new-action="presentation">
|
|
<span class="material-symbols-outlined" aria-hidden="true">co_present</span>
|
|
<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);
|
|
|
|
const firstHeaderAction = header.querySelector(
|
|
".modal-surface-switcher, .modal-dock-button, .office-modal-focus-button, .modal-close",
|
|
);
|
|
if (firstHeaderAction) {
|
|
firstHeaderAction.insertAdjacentElement("beforebegin", root);
|
|
} else {
|
|
header.appendChild(root);
|
|
}
|
|
|
|
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 = "modal-dock-button office-modal-focus-button";
|
|
focusButton.innerHTML = '<span class="material-symbols-outlined" aria-hidden="true">fullscreen</span>';
|
|
const updateFocusButton = (active) => {
|
|
const label = active ? "Restore size" : "Focus mode";
|
|
focusButton.setAttribute("aria-label", label);
|
|
focusButton.querySelector(".material-symbols-outlined").textContent = active ? "fullscreen_exit" : "fullscreen";
|
|
};
|
|
updateFocusButton(false);
|
|
const closeButton = inner.querySelector(".modal-close");
|
|
if (closeButton) {
|
|
closeButton.insertAdjacentElement("beforebegin", focusButton);
|
|
} else {
|
|
header.appendChild(focusButton);
|
|
}
|
|
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);
|