Reduce Office to document ownership
Keep _office focused on document artifacts, Markdown sessions, LibreOffice-compatible file actions, and document persistence. Route binary document editing through explicit Desktop requests instead of cold-opening the live Desktop surface from artifact results.
Alessandro committed
May 7, 2026 at 00:15 UTC
0c08fa65f31c0bcd6be14c983dfe0c4498f4eb55
14 files changed
+586
-2655
plugins/_office/api/office_session.py
+44
-19
@@ -1,7 +1,8 @@
1
from __future__ import annotations
2
3
from helpers.api import ApiHandler, Request
4
-from plugins._office.helpers import document_store, libreoffice, libreoffice_desktop, markdown_sessions
4
+from plugins._desktop.helpers import desktop_session
5
+from plugins._office.helpers import document_store, libreoffice, markdown_sessions
6
7
8
class OfficeSession(ApiHandler):
@@ -14,6 +15,7 @@ class OfficeSession(ApiHandler):
15
if action == "home":
16
return {"ok": True, "path": document_store.default_open_path(context_id)}
17
if action == "desktop":
18
+ # Compatibility only. New Desktop callers use /plugins/_desktop/desktop_session.
19
return self._desktop()
20
if action == "close":
21
closed = document_store.close_session(
@@ -58,30 +60,47 @@ class OfficeSession(ApiHandler):
60
if action == "renamed":
61
return self._renamed(input, context_id)
62
if action == "desktop_save":
63
+ # Compatibility only. New Desktop callers use /plugins/_desktop/desktop_session.
64
return self._desktop_save(input)
65
if action == "desktop_sync":
66
+ # Compatibility only. New Desktop callers use /plugins/_desktop/desktop_session.
67
return self._desktop_sync(input)
68
if action == "desktop_state":
69
+ # Compatibility only. New Desktop callers use /plugins/_desktop/desktop_session.
70
return self._desktop_state(input)
71
if action == "desktop_shutdown":
72
+ # Compatibility only. New Desktop callers use /plugins/_desktop/desktop_session.
73
return self._desktop_shutdown(input)
74
return {"ok": False, "error": f"Unsupported office session action: {action}"}
75
76
async def _open_document(self, doc: dict, input: dict, request: Request) -> dict:
77
mode = "edit" if str(input.get("mode") or "edit").lower() == "edit" else "view"
72
- store_session = document_store.create_session(
73
- doc["file_id"],
74
- user_id=str(input.get("user_id") or "agent-zero-user"),
75
- permission="write" if mode == "edit" else "read",
76
- origin=self._origin(request),
77
- )
78
- if str(doc.get("extension") or "").lower() in libreoffice_desktop.OFFICIAL_EXTENSIONS:
79
- desktop = libreoffice_desktop.get_manager().open(doc, refresh=input.get("refresh") is True)
78
+ if str(doc.get("extension") or "").lower() in desktop_session.OFFICIAL_EXTENSIONS:
79
+ if input.get("open_in_desktop") is not True:
80
+ return {
81
+ "ok": True,
82
+ "requires_desktop": True,
83
+ "file_id": doc["file_id"],
84
+ "title": doc["basename"],
85
+ "extension": doc["extension"],
86
+ "path": doc["path"],
87
+ "text": "",
88
+ "document": _public_doc(doc),
89
+ "version": document_store.item_version(doc),
90
+ "mode": mode,
91
+ }
92
+ store_session = document_store.create_session(
93
+ doc["file_id"],
94
+ user_id=str(input.get("user_id") or "agent-zero-user"),
95
+ permission="write" if mode == "edit" else "read",
96
+ origin=self._origin(request),
97
+ )
98
+ desktop = desktop_session.get_manager().open(doc, refresh=input.get("refresh") is True)
99
if not desktop.get("available"):
100
document_store.close_session(session_id=store_session["session_id"])
101
return {
102
"ok": False,
84
- "error": desktop.get("error") or desktop.get("reason") or "Official LibreOffice desktop session is unavailable.",
103
+ "error": desktop.get("error") or desktop.get("reason") or "Desktop session is unavailable.",
104
"desktop": desktop,
105
"libreoffice": libreoffice.collect_status(),
106
}
@@ -100,6 +119,12 @@ class OfficeSession(ApiHandler):
119
"store_session_id": store_session["session_id"],
120
"mode": mode,
121
}
122
+ store_session = document_store.create_session(
123
+ doc["file_id"],
124
+ user_id=str(input.get("user_id") or "agent-zero-user"),
125
+ permission="write" if mode == "edit" else "read",
126
+ origin=self._origin(request),
127
+ )
128
try:
129
editor = markdown_sessions.get_manager().open(doc, sid="")
130
except ValueError as exc:
@@ -135,8 +160,8 @@ class OfficeSession(ApiHandler):
160
except Exception as exc:
161
return {"ok": False, "error": str(exc)}
162
desktop = None
138
- if str(updated.get("extension") or "").lower() in libreoffice_desktop.OFFICIAL_EXTENSIONS:
139
- desktop = libreoffice_desktop.get_manager().retarget_document(file_id, updated)
163
+ if str(updated.get("extension") or "").lower() in desktop_session.OFFICIAL_EXTENSIONS:
164
+ desktop = desktop_session.get_manager().retarget_document(file_id, updated)
165
return {
166
"ok": True,
167
"document": _public_doc(updated),
@@ -146,7 +171,7 @@ class OfficeSession(ApiHandler):
171
}
172
173
def _desktop(self) -> dict:
149
- desktop = libreoffice_desktop.get_manager().ensure_system_desktop()
174
+ desktop = desktop_session.get_manager().ensure_system_desktop()
175
if not desktop.get("available"):
176
return {
177
"ok": False,
@@ -155,7 +180,7 @@ class OfficeSession(ApiHandler):
180
"libreoffice": libreoffice.collect_status(),
181
}
182
document = {
158
- "file_id": libreoffice_desktop.SYSTEM_FILE_ID,
183
+ "file_id": desktop_session.SYSTEM_FILE_ID,
184
"path": desktop["path"],
185
"basename": desktop["title"],
186
"title": desktop["title"],
@@ -167,7 +192,7 @@ class OfficeSession(ApiHandler):
192
"ok": True,
193
"session_id": desktop["session_id"],
194
"desktop_session_id": desktop["session_id"],
170
- "file_id": libreoffice_desktop.SYSTEM_FILE_ID,
195
+ "file_id": desktop_session.SYSTEM_FILE_ID,
196
"title": desktop["title"],
197
"extension": "desktop",
198
"path": desktop["path"],
@@ -183,24 +208,24 @@ class OfficeSession(ApiHandler):
208
session_id = str(input.get("desktop_session_id") or input.get("session_id") or "").strip()
209
if not session_id:
210
return {"ok": False, "error": "desktop_session_id is required."}
186
- return libreoffice_desktop.get_manager().save(
211
+ return desktop_session.get_manager().save(
212
session_id,
213
file_id=str(input.get("file_id") or ""),
214
)
215
216
def _desktop_sync(self, input: dict) -> dict:
192
- return libreoffice_desktop.get_manager().sync(
217
+ return desktop_session.get_manager().sync(
218
session_id=str(input.get("desktop_session_id") or input.get("session_id") or ""),
219
file_id=str(input.get("file_id") or ""),
220
)
221
222
def _desktop_state(self, input: dict) -> dict:
223
include_screenshot = bool(input.get("include_screenshot") is True)
199
- return libreoffice_desktop.get_manager().state(include_screenshot=include_screenshot)
224
+ return desktop_session.get_manager().state(include_screenshot=include_screenshot)
225
226
def _desktop_shutdown(self, input: dict) -> dict:
227
save_first = input.get("save_first") is not False
203
- return libreoffice_desktop.get_manager().shutdown_system_desktop(
228
+ return desktop_session.get_manager().shutdown_system_desktop(
229
save_first=save_first,
230
source=str(input.get("source") or "api"),
231
)
plugins/_office/extensions/webui/lib/document-actions.js
+34
-5
@@ -2,6 +2,9 @@ import {
2
createActionButton,
3
copyToClipboard,
4
} from "/components/messages/action-buttons/simple-action-buttons.js";
5
+import { ensureModalOpen } from "/js/modals.js";
6
+import { open as openSurface } from "/js/surfaces.js";
7
+import { store as officeStore } from "/plugins/_office/webui/office-store.js";
8
9
function basename(path = "") {
10
const value = String(path || "").split("?")[0].split("#")[0];
@@ -33,10 +36,8 @@ export function documentFromLog(args = {}, result = {}) {
36
};
37
}
38
36
-export async function openOfficeCanvas(kvps = {}) {
37
- const canvas = globalThis.Alpine?.store?.("rightCanvas")
38
- || (await import("/components/canvas/right-canvas-store.js")).store;
39
- await canvas?.open?.("office", {
39
+export async function openDocumentInDesktop(kvps = {}) {
40
+ await openSurface("desktop", {
41
path: kvps.path || "",
42
file_id: kvps.file_id || "",
43
refresh: true,
@@ -44,6 +45,33 @@ export async function openOfficeCanvas(kvps = {}) {
45
});
46
}
47
48
+export async function openDocumentArtifact(kvps = {}) {
49
+ if (usesDesktop(kvps)) {
50
+ await openDocumentInDesktop(kvps);
51
+ return;
52
+ }
53
+ await ensureModalOpen("/plugins/_office/webui/main.html");
54
+ await officeStore.openSession?.({
55
+ path: kvps.path || "",
56
+ file_id: kvps.file_id || "",
57
+ refresh: true,
58
+ source: "message-action",
59
+ });
60
+}
61
+
62
+function usesDesktop(doc = {}) {
63
+ const format = String(doc.format || doc.extension || "").toLowerCase();
64
+ return ["odt", "ods", "odp", "docx", "xlsx", "pptx"].includes(format);
65
+}
66
+
67
+function desktopActionLabel(doc = {}) {
68
+ const format = String(doc.format || doc.extension || "").toLowerCase();
69
+ if (["odt", "docx"].includes(format)) return "Edit in Writer";
70
+ if (["ods", "xlsx"].includes(format)) return "Edit in Calc";
71
+ if (["odp", "pptx"].includes(format)) return "Edit in Impress";
72
+ return "Open Document";
73
+}
74
+
75
export function downloadDocument(doc = {}) {
76
const path = String(doc.path || "");
77
if (!path) return;
@@ -59,7 +87,8 @@ export function buildDocumentFileActionButtons(document = {}) {
87
const hasTarget = Boolean(document?.path || document?.file_id);
88
const buttons = [];
89
if (hasTarget) {
62
- buttons.push(createActionButton("dock_to_right", "Open in canvas", () => openOfficeCanvas(document)));
90
+ const icon = usesDesktop(document) ? "desktop_windows" : "article";
91
+ buttons.push(createActionButton(icon, desktopActionLabel(document), () => openDocumentArtifact(document)));
92
}
93
if (document?.path) {
94
buttons.push(
plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js
+65
-38
@@ -1,14 +1,18 @@
1
+import { store as officeStore } from "/plugins/_office/webui/office-store.js";
2
+import { ensureModalOpen } from "/js/modals.js";
3
+import { open as openSurface } from "/js/surfaces.js";
4
+
5
const SYNC_WINDOW_MS = 10 * 60 * 1000;
2
-const DESKTOP_OFFICE_FORMATS = new Set(["odt", "ods", "odp", "docx", "xlsx", "pptx"]);
6
+const DESKTOP_DOCUMENT_EXTENSIONS = new Set(["odt", "ods", "odp", "docx", "xlsx", "pptx"]);
7
const syncedDocumentResults = new Set();
8
5
-export default async function syncDocumentResultsIntoOpenCanvas(context) {
9
+export default async function syncDocumentResultsIntoOpenOfficeModal(context) {
10
if (!context?.results?.length || context.historyEmpty) return;
11
12
for (const { args } of context.results) {
13
const payload = getDocumentPayload(args);
14
if (getToolName(payload) !== "document_artifact") continue;
11
- if (!shouldSyncOpenOfficeCanvas(args, payload)) continue;
15
+ if (!shouldSyncOpenOfficeModal(args, payload)) continue;
16
17
const document = payload.document && typeof payload.document === "object" ? payload.document : {};
18
const path = payload.path || document.path || "";
@@ -25,14 +29,16 @@ export default async function syncDocumentResultsIntoOpenCanvas(context) {
29
if (syncedDocumentResults.has(key)) continue;
30
syncedDocumentResults.add(key);
31
28
- if (!isOfficeCanvasOrModalOpen() && shouldColdOpenOfficeCanvas(payload, document)) {
29
- await openOfficeCanvasFromResult({ path, file_id: fileId });
32
+ if (shouldOpenDocumentUiFromResult(payload, document)) {
33
+ globalThis.setTimeout(() => {
34
+ void openDocumentUiFromResult({ path, file_id: fileId }, payload, document);
35
+ }, 0);
36
continue;
37
}
38
39
globalThis.setTimeout(async () => {
34
- if (!isOfficeCanvasOrModalOpen()) return;
35
- const office = globalThis.Alpine?.store?.("office");
40
+ if (!isOfficeModalOpen()) return;
41
+ const office = officeStore;
42
if (!office || isDirtySameDocument(office, { path, file_id: fileId })) return;
43
await office.openSession?.({
44
path,
@@ -63,8 +69,14 @@ function pickPayloadFields(args = {}) {
69
"tool_name",
70
"action",
71
"canvas_surface",
72
+ "extension",
73
"file_id",
74
"format",
75
+ "open_canvas",
76
+ "open_document",
77
+ "open_desktop",
78
+ "open_in_canvas",
79
+ "open_in_desktop",
80
"path",
81
"version",
82
"last_modified",
@@ -78,54 +90,62 @@ function getToolName(payload = {}) {
90
return String(payload._tool_name || payload.tool_name || "").trim();
91
}
92
81
-function shouldSyncOpenOfficeCanvas(args = {}, payload = {}) {
93
+function shouldSyncOpenOfficeModal(args = {}, payload = {}) {
94
if (!isFresh(args.timestamp, payload.last_modified || payload.document?.last_modified)) return false;
95
const action = String(payload.action || "").trim().toLowerCase().replace("-", "_");
96
return ["create", "open", "edit", "restore_version"].includes(action);
97
}
98
87
-function shouldColdOpenOfficeCanvas(payload = {}, document = {}) {
88
- const action = String(payload.action || "").trim().toLowerCase().replace("-", "_");
89
- if (!["create", "open"].includes(action)) return false;
90
- return DESKTOP_OFFICE_FORMATS.has(documentFormat(payload, document));
99
+function shouldOpenDocumentUiFromResult(payload = {}, document = {}) {
100
+ if (!isExplicitDocumentUiRequest(payload)) return false;
101
+ return Boolean(documentExtension(payload, document));
102
}
103
93
-async function openOfficeCanvasFromResult(document = {}) {
94
- const canvas = globalThis.Alpine?.store?.("rightCanvas")
95
- || (await import("/components/canvas/right-canvas-store.js")).store;
96
- await canvas?.open?.("office", {
97
- path: document.path || "",
98
- file_id: document.file_id || "",
104
+function isExplicitDocumentUiRequest(payload = {}) {
105
+ const action = String(payload.action || "").trim().toLowerCase().replace("-", "_");
106
+ return action === "open"
107
+ || truthy(payload.open_in_canvas)
108
+ || truthy(payload.open_canvas)
109
+ || truthy(payload.open_document)
110
+ || truthy(payload.open_in_desktop)
111
+ || truthy(payload.open_desktop);
112
+}
113
+
114
+async function openDocumentUiFromResult(target = {}, payload = {}, document = {}) {
115
+ if (isDesktopDocument(payload, document)) {
116
+ await openSurface("desktop", {
117
+ path: target.path || "",
118
+ file_id: target.file_id || "",
119
+ refresh: true,
120
+ source: "tool-result-open",
121
+ });
122
+ return;
123
+ }
124
+
125
+ await ensureModalOpen("/plugins/_office/webui/main.html");
126
+ await officeStore.openSession?.({
127
+ path: target.path || "",
128
+ file_id: target.file_id || "",
129
refresh: true,
100
- source: "tool-result-sync",
130
+ source: "tool-result-open",
131
});
132
}
133
104
-function documentFormat(payload = {}, document = {}) {
134
+function isDesktopDocument(payload = {}, document = {}) {
135
+ return DESKTOP_DOCUMENT_EXTENSIONS.has(documentExtension(payload, document));
136
+}
137
+
138
+function documentExtension(payload = {}, document = {}) {
139
return String(
140
payload.format
141
|| payload.extension
142
|| document.extension
109
- || extensionOf(payload.path || document.path || ""),
110
- ).trim().toLowerCase().replace(/^\./, "");
143
+ || document.format
144
+ || "",
145
+ ).toLowerCase();
146
}
147
113
-function extensionOf(path = "") {
114
- const name = String(path || "").split("?")[0].split("#")[0].split("/").filter(Boolean).pop() || "";
115
- const index = name.lastIndexOf(".");
116
- return index >= 0 ? name.slice(index + 1) : "";
117
-}
118
-
119
-function isOfficeCanvasAlreadyOpen() {
120
- const canvas = globalThis.Alpine?.store?.("rightCanvas");
121
- return Boolean(canvas?.isOpen && canvas?.activeSurfaceId === "office");
122
-}
123
-
124
-function isOfficeCanvasOrModalOpen() {
125
- return Boolean(isOfficeCanvasAlreadyOpen() || isOfficeModalAlreadyOpen());
126
-}
127
-
128
-function isOfficeModalAlreadyOpen() {
148
+function isOfficeModalOpen() {
149
return Boolean(
150
globalThis.isModalOpen?.("/plugins/_office/webui/main.html")
151
|| globalThis.isModalOpen?.("plugins/_office/webui/main.html")
@@ -143,6 +163,13 @@ function isDirtySameDocument(office, document = {}) {
163
);
164
}
165
166
+function truthy(value) {
167
+ if (value === true) return true;
168
+ if (value === false || value == null) return false;
169
+ if (typeof value === "number") return value !== 0;
170
+ return ["1", "true", "yes", "y", "on"].includes(String(value).trim().toLowerCase());
171
+}
172
+
173
function isFresh(...timestamps) {
174
const now = Date.now();
175
for (const value of timestamps) {
plugins/_office/helpers/artifact_editor.py
+2
-2
@@ -141,9 +141,9 @@ def _refresh_open_editor_sessions(file_id: str) -> None:
141
# Direct artifact edits should never fail just because no canvas is open.
142
pass
143
try:
144
- from plugins._office.helpers import libreoffice_desktop
144
+ from plugins._desktop.helpers import desktop_session
145
146
- libreoffice_desktop.get_manager().refresh_document(file_id)
146
+ desktop_session.get_manager().refresh_document(file_id)
147
except Exception:
148
pass
149
plugins/_office/helpers/canvas_context.py
+3
-3
@@ -2,7 +2,7 @@ from __future__ import annotations
2
3
from typing import Any
4
5
-from plugins._office.helpers import desktop_state
5
+from plugins._desktop.helpers import desktop_state
6
from plugins._office.helpers import document_store
7
8
@@ -13,7 +13,7 @@ def build_context(max_items: int = 6) -> str:
13
return desktop_context
14
15
lines = [
16
- "These document artifacts have active canvas sessions. Content is omitted; load skill `office-artifacts` for edit workflow, then use document_artifact:read before content-sensitive edits.",
16
+ "These document artifacts have active document sessions. Content is omitted; load skill `document-artifacts` for edit workflow, then use document_artifact:read before content-sensitive edits.",
17
]
18
for doc in documents:
19
lines.append(format_document_line(doc))
@@ -46,5 +46,5 @@ def build_desktop_context() -> str:
46
return (
47
"[DESKTOP STATE]\n"
48
f"- unavailable={exc}\n"
49
- "- next=Open the Desktop canvas manually, then run plugins/_office/skills/linux-desktop/scripts/desktopctl.sh observe --json."
49
+ "- next=Open the Desktop surface manually, then run plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh observe --json."
50
)
plugins/_office/helpers/document_store.py
+1
-1
@@ -40,7 +40,7 @@ ODF_MIMETYPES = {
40
"odp": "application/vnd.oasis.opendocument.presentation",
41
}
42
43
-STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME, "documents"))
43
+STATE_DIR = Path(files.get_abs_path("usr", PLUGIN_NAME, "documents"))
44
DB_PATH = STATE_DIR / "documents.sqlite3"
45
BACKUP_DIR = STATE_DIR / "backups"
46
WORKDIR = Path(files.get_abs_path("usr", "workdir"))
plugins/_office/helpers/libreoffice.py
+2
-2
@@ -36,9 +36,9 @@ def collect_status() -> dict[str, Any]:
36
"message": "LibreOffice is available." if soffice else "LibreOffice is not installed in this runtime.",
37
}
38
try:
39
- from plugins._office.helpers import libreoffice_desktop
39
+ from plugins._desktop.helpers import desktop_session
40
41
- status["desktop"] = libreoffice_desktop.collect_desktop_status()
41
+ status["desktop"] = desktop_session.collect_desktop_status()
42
except Exception as exc:
43
status["desktop"] = {"ok": False, "healthy": False, "error": str(exc)}
44
return status
plugins/_office/plugin.yaml
+1
-1
@@ -1,6 +1,6 @@
1
name: _office
2
title: LibreOffice
3
-description: Markdown writing and ODF-first LibreOffice document artifacts in the right canvas.
3
+description: Markdown writing and ODF-first LibreOffice document artifacts.
4
version: "0.1"
5
settings_sections:
6
- developer
plugins/_office/prompts/agent.system.tool.document_artifact.md
+7
-5
@@ -1,15 +1,17 @@
1
### document_artifact
2
-create/open/read/edit reusable document artifacts in the Agent Zero canvas
2
+create/open/read/edit reusable document artifacts in Agent Zero
3
formats: md odt ods odp docx xlsx pptx
4
default format: md
5
methods: create open read edit inspect export version_history restore_version status
6
common args: method action kind title format content path file_id
7
+optional UI intent args: open_in_canvas open_in_desktop
8
`method` is accepted as an alias for action when the tool_name has no suffix
8
-tool results save or update artifacts only; they do not open the canvas automatically
9
-created/updated artifacts are shown with explicit Download and Open in canvas message actions
10
-ODF is first-class for LibreOffice: use ODT for Writer, ODS for Spreadsheet/Calc, and ODP for Presentation/Impress unless the user explicitly requests Microsoft compatibility
9
+create/read/edit results save or update artifacts only; they do not open a surface automatically unless the user explicitly asks to open the document UI
10
+use action `open`, `open_in_canvas: true`, or `open_in_desktop: true` only when the user explicitly asks to open the document/editor/Desktop
11
+created/updated artifacts are shown with explicit Download, Open Document, or Desktop edit message actions
12
+ODF is first-class for LibreOffice: use ODT for Writer, ODS for Spreadsheet/Calc, and ODP for Presentation/Impress unless the user explicitly requests OOXML compatibility
13
DOCX/XLSX/PPTX are compatibility formats, not defaults
14
XLSX charts: use edit operation `create_chart` with `chart` object instead of code execution for embedded spreadsheet charts when an embedded chart is required
15
chart types: line bar column pie area scatter stock ohlc candlestick
16
ODS/XLSX create/edit tabular content: CSV, TSV, Markdown tables, or rows arrays become real spreadsheet cells
15
-for nontrivial document artifact work, load skill `office-artifacts` or the specific Markdown/Writer/Calc/Impress skill first
17
+for nontrivial document artifact work, load skill `document-artifacts` or the specific Markdown/Writer/Calc/Impress skill first
plugins/_office/tools/document_artifact.py
+144
-16
@@ -28,10 +28,22 @@ class DocumentArtifact(Tool):
28
chart: Any = None,
29
slides: Any = None,
30
max_chars: int | str = 12000,
31
+ open_in_canvas: bool = False,
32
+ open_in_desktop: bool = False,
33
method: str = "",
34
**kwargs: Any,
35
) -> Response:
36
action = str(action or method or self.method or "status").strip().lower().replace("-", "_")
37
+ open_in_canvas = _truthy(
38
+ open_in_canvas
39
+ or kwargs.get("open_canvas")
40
+ or kwargs.get("open_document")
41
+ )
42
+ open_in_desktop = _truthy(
43
+ open_in_desktop
44
+ or kwargs.get("open_desktop")
45
+ or kwargs.get("desktop")
46
+ )
47
try:
48
if action == "create":
49
doc = document_store.create_document(
@@ -56,10 +68,22 @@ class DocumentArtifact(Tool):
68
message=f"document_artifact create failed: {validation.get('error')}",
69
break_loop=False,
70
)
59
- return self._document_response("Created document artifact.", doc, action=action)
71
+ return self._document_response(
72
+ "Created document artifact.",
73
+ doc,
74
+ action=action,
75
+ open_in_canvas=open_in_canvas,
76
+ open_in_desktop=open_in_desktop,
77
+ )
78
if action == "open":
79
doc = self._document_from_input(file_id=file_id, path=path)
62
- return self._document_response("Opened document artifact.", doc, action=action)
80
+ return self._document_response(
81
+ "Opened document artifact.",
82
+ doc,
83
+ action=action,
84
+ open_in_canvas=open_in_canvas,
85
+ open_in_desktop=open_in_desktop,
86
+ )
87
if action in {"read", "extract"}:
88
doc = self._document_from_input(file_id=file_id, path=path)
89
payload = {
@@ -68,7 +92,13 @@ class DocumentArtifact(Tool):
92
"document": self._public_doc(doc),
93
"content": artifact_editor.read_artifact(doc, max_chars=int(max_chars or 12000)),
94
}
71
- return self._json_response(payload, doc=doc, action="read")
95
+ return self._json_response(
96
+ payload,
97
+ doc=doc,
98
+ action="read",
99
+ open_in_canvas=open_in_canvas,
100
+ open_in_desktop=open_in_desktop,
101
+ )
102
if action in {"edit", "update", "patch"}:
103
doc = self._document_from_input(file_id=file_id, path=path)
104
updated_doc, payload = artifact_editor.edit_artifact(
@@ -85,20 +115,44 @@ class DocumentArtifact(Tool):
115
**kwargs,
116
)
117
payload["document"] = self._public_doc(updated_doc)
88
- return self._json_response(payload, doc=updated_doc, action="edit")
118
+ return self._json_response(
119
+ payload,
120
+ doc=updated_doc,
121
+ action="edit",
122
+ open_in_canvas=open_in_canvas,
123
+ open_in_desktop=open_in_desktop,
124
+ )
125
if action == "inspect":
126
doc = self._document_from_input(file_id=file_id, path=path)
91
- return self._json_response({"ok": True, "action": action, "document": self._public_doc(doc)}, doc=doc, action=action)
127
+ return self._json_response(
128
+ {"ok": True, "action": action, "document": self._public_doc(doc)},
129
+ doc=doc,
130
+ action=action,
131
+ open_in_canvas=open_in_canvas,
132
+ open_in_desktop=open_in_desktop,
133
+ )
134
if action == "version_history":
135
doc = self._document_from_input(file_id=file_id, path=path)
136
versions = document_store.version_history(doc["file_id"])
95
- return self._json_response({"ok": True, "action": action, "versions": versions}, doc=doc, action=action)
137
+ return self._json_response(
138
+ {"ok": True, "action": action, "versions": versions},
139
+ doc=doc,
140
+ action=action,
141
+ open_in_canvas=open_in_canvas,
142
+ open_in_desktop=open_in_desktop,
143
+ )
144
if action == "restore_version":
145
if version_id is None or str(version_id).strip() == "":
146
return Response(message="version_id is required for restore_version.", break_loop=False)
147
doc = self._document_from_input(file_id=file_id, path=path)
148
restored = document_store.restore_version(doc["file_id"], int(version_id))
101
- return self._document_response("Restored document artifact version.", restored, action=action)
149
+ return self._document_response(
150
+ "Restored document artifact version.",
151
+ restored,
152
+ action=action,
153
+ open_in_canvas=open_in_canvas,
154
+ open_in_desktop=open_in_desktop,
155
+ )
156
if action == "export":
157
doc = self._document_from_input(file_id=file_id, path=path)
158
target_format = str(kwargs.get("target_format") or kwargs.get("export_format") or "").lower().lstrip(".")
@@ -111,13 +165,30 @@ class DocumentArtifact(Tool):
165
"path": document_store.display_path(result["path"]),
166
"document": self._public_doc(doc),
167
}
114
- return self._json_response(payload, doc=doc, action=action)
168
+ return self._json_response(
169
+ payload,
170
+ doc=doc,
171
+ action=action,
172
+ open_in_canvas=open_in_canvas,
173
+ open_in_desktop=open_in_desktop,
174
+ )
175
return Response(
176
message=f"document_artifact export failed: {result.get('error')}",
177
break_loop=False,
118
- additional=self._additional(doc, action=action),
178
+ additional=self._additional(
179
+ doc,
180
+ action=action,
181
+ open_in_canvas=open_in_canvas,
182
+ open_in_desktop=open_in_desktop,
183
+ ),
184
)
120
- return self._document_response("Document artifact export path is ready.", doc, action=action)
185
+ return self._document_response(
186
+ "Document artifact export path is ready.",
187
+ doc,
188
+ action=action,
189
+ open_in_canvas=open_in_canvas,
190
+ open_in_desktop=open_in_desktop,
191
+ )
192
if action == "status":
193
return self._json_response({"ok": True, "action": action, "status": libreoffice.collect_status()}, action=action)
194
return Response(message=f"Unknown document_artifact action: {action}", break_loop=False)
@@ -143,28 +214,75 @@ class DocumentArtifact(Tool):
214
def _context_id(self) -> str:
215
return self.agent.context.id if self.agent and self.agent.context else ""
216
146
- def _document_response(self, message: str, doc: dict[str, Any], action: str = "") -> Response:
217
+ def _document_response(
218
+ self,
219
+ message: str,
220
+ doc: dict[str, Any],
221
+ action: str = "",
222
+ *,
223
+ open_in_canvas: bool = False,
224
+ open_in_desktop: bool = False,
225
+ ) -> Response:
226
payload = {"ok": True, "action": action, "message": message, "document": self._public_doc(doc)}
227
return Response(
228
message=json.dumps(payload, indent=2, ensure_ascii=False),
229
break_loop=False,
151
- additional=self._additional(doc, action=action),
230
+ additional=self._additional(
231
+ doc,
232
+ action=action,
233
+ open_in_canvas=open_in_canvas,
234
+ open_in_desktop=open_in_desktop,
235
+ ),
236
)
237
154
- def _json_response(self, payload: dict[str, Any], doc: dict[str, Any] | None = None, action: str = "") -> Response:
238
+ def _json_response(
239
+ self,
240
+ payload: dict[str, Any],
241
+ doc: dict[str, Any] | None = None,
242
+ action: str = "",
243
+ *,
244
+ open_in_canvas: bool = False,
245
+ open_in_desktop: bool = False,
246
+ ) -> Response:
247
return Response(
248
message=json.dumps(payload, indent=2, ensure_ascii=False, default=str),
249
break_loop=False,
158
- additional=self._additional(doc, action=action) if doc else {"_tool_name": self.name, "canvas_surface": "office", "action": action},
250
+ additional=self._additional(
251
+ doc,
252
+ action=action,
253
+ open_in_canvas=open_in_canvas,
254
+ open_in_desktop=open_in_desktop,
255
+ ) if doc else {
256
+ "_tool_name": self.name,
257
+ "canvas_surface": "office",
258
+ "action": action,
259
+ "open_in_canvas": bool(open_in_canvas),
260
+ "open_in_desktop": bool(open_in_desktop),
261
+ },
262
)
263
161
- def _additional(self, doc: dict[str, Any] | None, action: str = "") -> dict[str, Any]:
264
+ def _additional(
265
+ self,
266
+ doc: dict[str, Any] | None,
267
+ action: str = "",
268
+ *,
269
+ open_in_canvas: bool = False,
270
+ open_in_desktop: bool = False,
271
+ ) -> dict[str, Any]:
272
if not doc:
163
- return {"_tool_name": self.name, "canvas_surface": "office", "action": action}
273
+ return {
274
+ "_tool_name": self.name,
275
+ "canvas_surface": "office",
276
+ "action": action,
277
+ "open_in_canvas": bool(open_in_canvas),
278
+ "open_in_desktop": bool(open_in_desktop),
279
+ }
280
return {
281
"_tool_name": self.name,
282
"canvas_surface": "office",
283
"action": action,
284
+ "open_in_canvas": bool(open_in_canvas),
285
+ "open_in_desktop": bool(open_in_desktop),
286
"file_id": doc["file_id"],
287
"title": doc["basename"],
288
"format": doc["extension"],
@@ -183,3 +301,13 @@ class DocumentArtifact(Tool):
301
"last_modified": doc["last_modified"],
302
"exists": Path(doc["path"]).exists(),
303
}
304
+
305
+
306
+def _truthy(value: Any) -> bool:
307
+ if isinstance(value, bool):
308
+ return value
309
+ if value is None:
310
+ return False
311
+ if isinstance(value, (int, float)):
312
+ return value != 0
313
+ return str(value).strip().lower() in {"1", "true", "yes", "y", "on"}
plugins/_office/webui/main.html
+2
-8
@@ -1,12 +1,6 @@
1
-<html
2
- class="office-modal modal-no-backdrop"
3
- data-canvas-surface="office"
4
- data-canvas-modal-path="/plugins/_office/webui/main.html"
5
- data-canvas-dock-title="Open Desktop in canvas"
6
- data-canvas-dock-icon="dock_to_right"
7
->
1
+<html class="office-modal">
2
<head>
9
- <title>Desktop</title>
3
+ <title>Documents</title>
4
<script type="module">
5
import { store } from "/plugins/_office/webui/office-store.js";
6
</script>
plugins/_office/webui/office-panel.html
+210
-595
@@ -5,123 +5,130 @@
5
</script>
6
</head>
7
<body>
8
- <div class="office-panel" x-data x-create="$store.office.onMount($el, xAttrs($el) || {})" x-destroy="$store.office.cleanup()">
8
+ <div x-data>
9
<template x-if="$store.office">
10
- <div class="office-shell">
11
- <div class="office-document-header" x-show="$store.office.hasActiveFile()" style="display: none;">
12
- <div class="office-document-title" :title="$store.office.tabLabel($store.office.session)">
13
- <span class="material-symbols-outlined office-document-icon" aria-hidden="true" x-text="$store.office.tabIcon($store.office.session)"></span>
14
- <span class="office-document-name" x-text="$store.office.tabTitle($store.office.session)"></span>
15
- <span class="office-document-dirty" x-show="$store.office.dirty" aria-hidden="true">*</span>
16
- </div>
10
+ <div class="office-panel" x-create="$store.office.onMount($el, xAttrs($el) || {})" x-destroy="$store.office.cleanup()">
11
+ <div class="office-shell">
12
+ <div class="office-document-header" x-show="$store.office.hasActiveFile()" style="display: none;">
13
+ <div class="office-document-title" :title="$store.office.tabLabel($store.office.session)">
14
+ <span class="material-symbols-outlined office-document-icon" aria-hidden="true" x-text="$store.office.tabIcon($store.office.session)"></span>
15
+ <span class="office-document-name" x-text="$store.office.tabTitle($store.office.session)"></span>
16
+ <span class="office-document-dirty" x-show="$store.office.dirty" aria-hidden="true">*</span>
17
+ </div>
18
18
- <button
19
- type="button"
20
- class="office-icon-button office-document-save-button"
21
- title="Save"
22
- aria-label="Save"
23
- :class="{ 'is-primary': $store.office.dirty }"
24
- :disabled="$store.office.saving"
25
- @click="$store.office.save()"
26
- >
27
- <span class="material-symbols-outlined" :class="{ spinning: $store.office.saving }" x-text="$store.office.saving ? 'progress_activity' : 'save'"></span>
28
- </button>
29
-
30
- <div class="office-file-actions" x-data="{ open: false }" @click.outside="open = false" @keydown.escape.window="open = false">
19
<button
20
type="button"
33
- class="office-icon-button office-file-menu-button"
34
- title="File actions"
35
- aria-label="File actions"
36
- aria-haspopup="menu"
37
- :aria-expanded="open.toString()"
21
+ class="office-icon-button office-document-save-button"
22
+ title="Save"
23
+ aria-label="Save"
24
+ :class="{ 'is-primary': $store.office.dirty }"
25
:disabled="$store.office.saving"
39
- @click.stop="open = !open"
26
+ @click="$store.office.save()"
27
>
41
- <span class="material-symbols-outlined">more_vert</span>
28
+ <span class="material-symbols-outlined" :class="{ spinning: $store.office.saving }" x-text="$store.office.saving ? 'progress_activity' : 'save'"></span>
29
</button>
43
- <div class="office-new-menu office-file-menu" role="menu" x-show="open" @click.stop>
44
- <button type="button" class="office-new-menu-item" role="menuitem" :disabled="$store.office.saving" @click="open = false; $store.office.renameActiveFile()">
45
- <span class="material-symbols-outlined" aria-hidden="true">edit</span>
46
- <span>Rename</span>
47
- </button>
48
- <button type="button" class="office-new-menu-item" role="menuitem" :disabled="$store.office.loading" @click="open = false; $store.office.closeActiveFile()">
49
- <span class="material-symbols-outlined" aria-hidden="true">close</span>
50
- <span>Close File</span>
30
+
31
+ <div class="office-file-actions" x-data="{ open: false }" @click.outside="open = false" @keydown.escape.window="open = false">
32
+ <button
33
+ type="button"
34
+ class="office-icon-button office-file-menu-button"
35
+ title="File actions"
36
+ aria-label="File actions"
37
+ aria-haspopup="menu"
38
+ :aria-expanded="open.toString()"
39
+ :disabled="$store.office.saving"
40
+ @click.stop="open = !open"
41
+ >
42
+ <span class="material-symbols-outlined">more_vert</span>
43
</button>
44
+ <div class="office-new-menu office-file-menu" role="menu" x-show="open" @click.stop>
45
+ <button type="button" class="office-new-menu-item" role="menuitem" :disabled="$store.office.saving" @click="open = false; $store.office.renameActiveFile()">
46
+ <span class="material-symbols-outlined" aria-hidden="true">edit</span>
47
+ <span>Rename</span>
48
+ </button>
49
+ <button type="button" class="office-new-menu-item" role="menuitem" :disabled="$store.office.loading" @click="open = false; $store.office.closeActiveFile()">
50
+ <span class="material-symbols-outlined" aria-hidden="true">close</span>
51
+ <span>Close File</span>
52
+ </button>
53
+ </div>
54
</div>
55
</div>
54
- </div>
56
56
- <div class="office-toolbar" x-show="$store.office.session && $store.office.isMarkdown()" style="display: none;">
57
- <div class="office-toolbar-row">
58
- <div class="office-tool-group office-editor-tools" x-show="$store.office.session && $store.office.isMarkdown()" style="display: none;">
59
- <button type="button" class="office-icon-button" title="Undo" aria-label="Undo" :disabled="!$store.office.canUndo()" @click="$store.office.undo()">
60
- <span class="material-symbols-outlined">undo</span>
61
- </button>
62
- <button type="button" class="office-icon-button" title="Redo" aria-label="Redo" :disabled="!$store.office.canRedo()" @click="$store.office.redo()">
63
- <span class="material-symbols-outlined">redo</span>
64
- </button>
65
- <button type="button" class="office-icon-button" title="Bold" aria-label="Bold" @click="$store.office.format('bold')">
66
- <span class="material-symbols-outlined">format_bold</span>
67
- </button>
68
- <button type="button" class="office-icon-button" title="Italic" aria-label="Italic" @click="$store.office.format('italic')">
69
- <span class="material-symbols-outlined">format_italic</span>
70
- </button>
71
- <button type="button" class="office-icon-button" title="List" aria-label="List" @click="$store.office.format('list')">
72
- <span class="material-symbols-outlined">format_list_bulleted</span>
73
- </button>
74
- <button type="button" class="office-icon-button" title="Numbered list" aria-label="Numbered list" @click="$store.office.format('numbered')">
75
- <span class="material-symbols-outlined">format_list_numbered</span>
76
- </button>
77
- <button type="button" class="office-icon-button" title="Table" aria-label="Table" @click="$store.office.format('table')">
78
- <span class="material-symbols-outlined">table</span>
79
- </button>
57
+ <div class="office-toolbar" x-show="$store.office.session && $store.office.isMarkdown()" style="display: none;">
58
+ <div class="office-toolbar-row">
59
+ <div class="office-tool-group office-editor-tools">
60
+ <button type="button" class="office-icon-button" title="Undo" aria-label="Undo" :disabled="!$store.office.canUndo()" @click="$store.office.undo()">
61
+ <span class="material-symbols-outlined">undo</span>
62
+ </button>
63
+ <button type="button" class="office-icon-button" title="Redo" aria-label="Redo" :disabled="!$store.office.canRedo()" @click="$store.office.redo()">
64
+ <span class="material-symbols-outlined">redo</span>
65
+ </button>
66
+ <button type="button" class="office-icon-button" title="Bold" aria-label="Bold" @click="$store.office.format('bold')">
67
+ <span class="material-symbols-outlined">format_bold</span>
68
+ </button>
69
+ <button type="button" class="office-icon-button" title="Italic" aria-label="Italic" @click="$store.office.format('italic')">
70
+ <span class="material-symbols-outlined">format_italic</span>
71
+ </button>
72
+ <button type="button" class="office-icon-button" title="List" aria-label="List" @click="$store.office.format('list')">
73
+ <span class="material-symbols-outlined">format_list_bulleted</span>
74
+ </button>
75
+ <button type="button" class="office-icon-button" title="Numbered list" aria-label="Numbered list" @click="$store.office.format('numbered')">
76
+ <span class="material-symbols-outlined">format_list_numbered</span>
77
+ </button>
78
+ <button type="button" class="office-icon-button" title="Table" aria-label="Table" @click="$store.office.format('table')">
79
+ <span class="material-symbols-outlined">table</span>
80
+ </button>
81
+ </div>
82
+ <span class="office-toolbar-spacer"></span>
83
</div>
81
-
82
- <span class="office-toolbar-spacer"></span>
84
</div>
84
- </div>
85
86
- <div class="office-state-line" x-show="$store.office.message || $store.office.error || $store.office.loading" style="display: none;">
87
- <span class="material-symbols-outlined" :class="{ spinning: $store.office.loading }" x-text="$store.office.loading ? 'progress_activity' : ($store.office.error ? 'error' : 'check_circle')"></span>
88
- <span x-text="$store.office.error || $store.office.message || 'Working'"></span>
89
- </div>
90
-
91
- <div class="office-body" :class="{ 'is-source': $store.office.isMarkdown() }">
92
- <div class="office-editor-wrap" x-show="$store.office.session" style="display: none;">
93
- <div class="office-editor-scroll" :class="{ 'is-desktop': $store.office.hasOfficialOffice(), 'is-source': $store.office.isMarkdown() }" @click.self="$store.office.focusEditor()">
94
- <template x-if="$store.office.hasOfficialOffice()">
95
- <div
96
- class="office-desktop-wrap"
97
- data-office-desktop-host
98
- x-init="$nextTick(() => $store.office.mountDesktopFrameHost($el))"
99
- >
100
- </div>
101
- </template>
102
-
103
- <textarea
104
- class="office-source-editor"
105
- data-office-source
106
- aria-label="Markdown source"
107
- x-show="$store.office.isMarkdown()"
108
- x-model="$store.office.editorText"
109
- @input="$store.office.onSourceInput()"
110
- @blur="$store.office.flushInput()"
111
- spellcheck="true"
112
- style="display: none;"
113
- ></textarea>
86
+ <div class="office-state-line" x-show="$store.office.message || $store.office.error || $store.office.loading" style="display: none;">
87
+ <span class="material-symbols-outlined" :class="{ spinning: $store.office.loading }" x-text="$store.office.loading ? 'progress_activity' : ($store.office.error ? 'error' : 'check_circle')"></span>
88
+ <span x-text="$store.office.error || $store.office.message || 'Working'"></span>
89
+ </div>
90
91
+ <div class="office-body" :class="{ 'is-source': $store.office.isMarkdown() }">
92
+ <div class="office-editor-wrap" x-show="$store.office.session" style="display: none;">
93
+ <div class="office-editor-scroll is-source" @click.self="$store.office.focusEditor()">
94
+ <textarea
95
+ class="office-source-editor"
96
+ data-office-source
97
+ aria-label="Markdown source"
98
+ x-show="$store.office.isMarkdown()"
99
+ x-model="$store.office.editorText"
100
+ @input="$store.office.onSourceInput()"
101
+ @blur="$store.office.flushInput()"
102
+ spellcheck="true"
103
+ style="display: none;"
104
+ ></textarea>
105
+ </div>
106
</div>
116
- </div>
107
118
- <div class="office-desktop-empty" x-show="$store.office.shouldShowDesktopEmptyState()" style="display: none;">
119
- <span class="material-symbols-outlined" aria-hidden="true">power_settings_new</span>
120
- <span class="office-desktop-empty-title">Desktop is shut down</span>
121
- <button type="button" class="office-icon-button office-command-button" @click="$store.office.restartDesktopSession()">
122
- <span class="material-symbols-outlined" aria-hidden="true">restart_alt</span>
123
- <span class="office-button-label">Restart Desktop</span>
124
- </button>
108
+ <div class="office-empty" x-show="!$store.office.session && !$store.office.loading" style="display: none;">
109
+ <div class="office-empty-actions">
110
+ <button type="button" class="office-icon-button office-command-button" @click="$store.office.runNewMenuAction('open')">
111
+ <span class="material-symbols-outlined" aria-hidden="true">folder_open</span>
112
+ <span class="office-button-label">Open</span>
113
+ </button>
114
+ <button type="button" class="office-icon-button office-command-button" @click="$store.office.runNewMenuAction('markdown')">
115
+ <span class="material-symbols-outlined" aria-hidden="true">article</span>
116
+ <span class="office-button-label">Markdown</span>
117
+ </button>
118
+ <button type="button" class="office-icon-button office-command-button" @click="$store.office.runNewMenuAction('writer')">
119
+ <span class="material-symbols-outlined" aria-hidden="true">description</span>
120
+ <span class="office-button-label">Writer</span>
121
+ </button>
122
+ <button type="button" class="office-icon-button office-command-button" @click="$store.office.runNewMenuAction('spreadsheet')">
123
+ <span class="material-symbols-outlined" aria-hidden="true">table_chart</span>
124
+ <span class="office-button-label">Calc</span>
125
+ </button>
126
+ <button type="button" class="office-icon-button office-command-button" @click="$store.office.runNewMenuAction('presentation')">
127
+ <span class="material-symbols-outlined" aria-hidden="true">co_present</span>
128
+ <span class="office-button-label">Impress</span>
129
+ </button>
130
+ </div>
131
+ </div>
132
</div>
133
</div>
134
</div>
@@ -148,24 +155,13 @@
155
156
.modal-inner.office-modal {
157
box-sizing: border-box;
151
- width: min(1120px, calc(100vw - 32px));
152
- height: min(820px, calc(100vh - 32px));
153
- min-width: min(720px, calc(100vw - 16px));
154
- min-height: min(520px, calc(100vh - 16px));
158
+ width: min(1040px, calc(100vw - 32px));
159
+ height: min(760px, calc(100vh - 32px));
160
+ min-width: min(640px, calc(100vw - 16px));
161
+ min-height: min(460px, calc(100vh - 16px));
162
max-width: none;
163
max-height: none;
157
- resize: none;
164
overflow: hidden;
159
- will-change: width, height, left, top;
160
- }
161
-
162
- .modal-inner.office-modal.is-resizing,
163
- .modal-inner.office-modal.is-dragging {
164
- user-select: none;
165
- }
166
-
167
- .modal-inner.office-modal.is-focus-mode {
168
- border-radius: 6px;
165
}
166
167
.modal-inner.office-modal .modal-scroll {
@@ -178,592 +174,211 @@
174
}
175
176
.modal-inner.office-modal .modal-header {
181
- grid-template-columns: minmax(0, 1fr) repeat(5, auto);
182
- }
183
-
184
- .office-modal-input-shield {
185
- position: absolute;
186
- inset: 42px 0 0 0;
187
- z-index: 4;
188
- display: none;
189
- background: transparent;
190
- }
191
-
192
- .office-modal-resizer {
193
- position: absolute;
194
- z-index: 5;
195
- display: block;
196
- touch-action: none;
197
- }
198
-
199
- .office-modal-resizer.is-right {
200
- top: 42px;
201
- right: -4px;
202
- bottom: 12px;
203
- width: 10px;
204
- cursor: ew-resize;
205
- }
206
-
207
- .office-modal-resizer.is-bottom {
208
- right: 12px;
209
- bottom: -4px;
210
- left: 0;
211
- height: 10px;
212
- cursor: ns-resize;
213
- }
214
-
215
- .office-modal-resizer.is-corner {
216
- right: 0;
217
- bottom: 0;
218
- width: 22px;
219
- height: 22px;
220
- cursor: nwse-resize;
221
- }
222
-
223
- .office-modal-resizer.is-corner::after {
224
- content: "";
225
- position: absolute;
226
- right: 6px;
227
- bottom: 6px;
228
- width: 9px;
229
- height: 9px;
230
- border-right: 2px solid color-mix(in srgb, var(--color-text) 42%, transparent);
231
- border-bottom: 2px solid color-mix(in srgb, var(--color-text) 42%, transparent);
232
- border-radius: 1px;
233
- }
234
-
235
- .modal-inner.office-modal.is-focus-mode .office-modal-resizer {
236
- display: none;
237
- }
238
-
239
- .modal-inner.office-modal .modal-bd.office-modal-body,
240
- .modal-inner.office-modal .modal-bd.office-modal-body > x-component,
241
- .modal-inner.office-modal .modal-bd.office-modal-body > x-component > .office-panel {
242
- display: flex;
243
- flex: 1 1 auto;
244
- min-height: 0;
245
- height: 100%;
246
- padding: 0;
247
- }
248
-
249
- .office-toolbar {
250
- display: flex;
251
- flex-direction: row;
252
- align-items: stretch;
253
- flex-wrap: nowrap;
254
- min-height: 0;
255
- padding: 6px 10px;
256
- overflow: hidden;
257
- border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 20%);
258
- background: color-mix(in srgb, var(--color-background), var(--color-panel) 48%);
259
- }
260
-
261
- .office-toolbar-row {
262
- display: flex;
263
- align-items: center;
264
- flex: 0 0 auto;
265
- flex-wrap: nowrap;
266
- gap: 6px;
267
- width: 100%;
268
- min-width: 0;
269
- min-height: 32px;
270
- overflow-x: auto;
271
- overflow-y: hidden;
272
- scrollbar-width: thin;
177
+ grid-template-columns: minmax(0, 1fr) auto auto;
178
}
179
275
- .office-tool-group {
180
+ .office-document-header,
181
+ .office-toolbar,
182
+ .office-state-line {
183
display: flex;
184
align-items: center;
278
- flex-wrap: nowrap;
279
- flex: 0 0 auto;
280
- gap: 4px;
185
+ gap: 8px;
186
+ border-bottom: 1px solid var(--color-border);
187
+ padding: 8px 10px;
188
min-width: 0;
189
}
190
284
- .office-editor-tools {
285
- gap: 3px;
286
- }
287
-
288
- .office-tool-actions {
289
- justify-content: flex-end;
290
- }
291
-
292
- .office-toolbar-spacer {
293
- flex: 1 1 16px;
294
- min-width: 8px;
295
- }
296
-
297
- .office-toolbar-divider {
298
- flex: 0 0 auto;
299
- width: 1px;
300
- height: 22px;
301
- margin-inline: 2px;
302
- background: color-mix(in srgb, var(--color-border), transparent 18%);
303
- }
304
-
305
- .office-document-header {
306
- display: flex;
307
- align-items: center;
308
- gap: 10px;
309
- min-height: 38px;
310
- padding: 5px 10px 5px 12px;
311
- border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 22%);
312
- background: color-mix(in srgb, var(--color-panel), var(--color-background) 30%);
313
- }
314
-
191
.office-document-title {
192
display: flex;
193
align-items: center;
318
- gap: 7px;
194
+ gap: 8px;
195
min-width: 0;
196
flex: 1 1 auto;
321
- color: var(--color-text);
322
- }
323
-
324
- .office-document-icon {
325
- flex: 0 0 auto;
326
- font-size: 19px;
327
- line-height: 1;
197
+ font-size: 14px;
198
+ font-weight: 600;
199
}
200
201
.office-document-name {
331
- min-width: 0;
202
overflow: hidden;
203
text-overflow: ellipsis;
204
white-space: nowrap;
335
- font-size: 13px;
336
- font-weight: 750;
337
- letter-spacing: 0;
338
- line-height: 1.2;
205
}
206
207
.office-document-dirty {
342
- flex: 0 0 auto;
343
- color: #2ca58d;
344
- font-size: 14px;
345
- font-weight: 800;
346
- line-height: 1;
347
- }
348
-
349
- .office-file-actions {
350
- position: relative;
351
- display: inline-flex;
352
- align-items: center;
353
- flex: 0 0 auto;
354
- }
355
-
356
- .office-document-save-button,
357
- .office-file-menu-button {
358
- width: 30px;
359
- height: 30px;
360
- min-width: 30px;
361
- }
362
-
363
- .office-header-actions {
364
- position: relative;
365
- display: inline-flex;
366
- align-items: center;
367
- flex: 0 0 auto;
208
+ color: var(--color-accent);
209
}
210
370
- .office-header-new-button {
371
- appearance: none;
211
+ .office-icon-button {
212
display: inline-flex;
213
align-items: center;
214
justify-content: center;
375
- gap: 4px;
376
- height: 34px;
377
- min-height: 34px;
378
- padding: 0 9px 0 8px;
379
- border: 1px solid transparent;
380
- border-radius: 7px;
381
- background: transparent;
215
+ gap: 6px;
216
+ height: 32px;
217
+ min-width: 32px;
218
+ border: 1px solid var(--color-border);
219
+ border-radius: 6px;
220
+ background: var(--color-background);
221
color: var(--color-text);
222
cursor: pointer;
384
- font: inherit;
385
- font-size: 12px;
386
- font-weight: 750;
387
- letter-spacing: 0;
388
- line-height: 1;
389
- opacity: 0.82;
390
- white-space: nowrap;
391
- transition: background-color 0.16s ease, border-color 0.16s ease, opacity 0.16s ease;
223
}
224
394
- .office-header-new-button:hover,
395
- .office-header-actions.is-open .office-header-new-button {
396
- opacity: 1;
397
- border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
398
- background: color-mix(in srgb, var(--color-background-hover) 72%, transparent);
225
+ .office-icon-button:hover:not(:disabled) {
226
+ background: var(--color-surface);
227
+ }
228
+
229
+ .office-icon-button:disabled {
230
+ cursor: default;
231
+ opacity: 0.55;
232
}
233
401
- .office-header-new-button .material-symbols-outlined {
402
- font-size: 18px;
403
- line-height: 1;
234
+ .office-document-save-button.is-primary {
235
+ border-color: var(--color-accent);
236
+ color: var(--color-accent);
237
}
238
406
- .office-header-new-button .office-new-chevron {
407
- margin-left: -2px;
408
- font-size: 16px;
409
- opacity: 0.8;
239
+ .office-file-actions {
240
+ position: relative;
241
}
242
243
.office-new-menu {
244
position: absolute;
245
top: calc(100% + 6px);
246
right: 0;
416
- z-index: 100;
417
- min-width: 184px;
418
- padding: 5px;
419
- border: 1px solid color-mix(in srgb, var(--color-border), transparent 10%);
420
- border-radius: 8px;
421
- background: color-mix(in srgb, var(--color-panel), var(--color-background) 10%);
422
- box-shadow: 0 14px 34px rgba(0, 0, 0, 0.34);
423
- }
424
-
425
- .office-new-menu[hidden] {
426
- display: none;
247
+ z-index: 15;
248
+ display: grid;
249
+ min-width: 172px;
250
+ padding: 6px;
251
+ border: 1px solid var(--color-border);
252
+ border-radius: 6px;
253
+ background: var(--color-background);
254
+ box-shadow: 0 16px 36px rgba(0, 0, 0, 0.18);
255
}
256
257
.office-new-menu-item {
430
- appearance: none;
258
display: flex;
259
align-items: center;
260
gap: 8px;
261
width: 100%;
435
- height: 32px;
436
- padding: 0 8px;
437
- border: 1px solid transparent;
438
- border-radius: 6px;
262
+ border: 0;
263
+ border-radius: 5px;
264
background: transparent;
265
color: var(--color-text);
441
- cursor: pointer;
442
- font: inherit;
443
- font-size: 12px;
444
- font-weight: 650;
445
- letter-spacing: 0;
446
- line-height: 1;
266
+ padding: 8px;
267
text-align: left;
448
- white-space: nowrap;
268
+ cursor: pointer;
269
}
270
271
.office-new-menu-item:hover {
452
- border-color: color-mix(in srgb, var(--color-primary) 22%, transparent);
453
- background: color-mix(in srgb, var(--color-background-hover) 76%, transparent);
272
+ background: var(--color-surface);
273
}
274
456
- .office-new-menu-item:disabled {
457
- cursor: default;
458
- opacity: 0.46;
459
- }
460
-
461
- .office-new-menu-item .material-symbols-outlined {
462
- flex: 0 0 auto;
463
- width: 18px;
464
- font-size: 18px;
465
- line-height: 1;
466
- text-align: center;
467
- }
468
-
469
- .office-icon-button,
470
- .office-tab,
471
- .office-tab-close {
472
- border: 1px solid color-mix(in srgb, var(--color-border), transparent 12%);
473
- border-radius: 8px;
474
- background: color-mix(in srgb, var(--color-panel), var(--color-background) 16%);
475
- color: inherit;
476
- transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
477
- }
478
-
479
- .office-icon-button {
480
- display: inline-grid;
481
- place-items: center;
482
- width: 32px;
483
- height: 32px;
484
- min-width: 32px;
485
- padding: 0;
486
- }
487
-
488
- .office-command-button {
489
- display: inline-flex;
490
- align-items: center;
491
- justify-content: center;
492
- gap: 5px;
493
- width: auto;
494
- max-width: 126px;
495
- padding: 0 8px;
496
- white-space: nowrap;
497
- }
498
-
499
- .office-command-button .office-button-label {
500
- min-width: 0;
501
- overflow: hidden;
502
- text-overflow: ellipsis;
503
- font-size: 11px;
504
- font-weight: 700;
505
- line-height: 1;
506
- }
507
-
508
- .office-icon-button.is-primary {
509
- border-color: color-mix(in srgb, #2c7be5, var(--color-border) 20%);
510
- background: color-mix(in srgb, #2c7be5, var(--color-panel) 82%);
511
- }
512
-
513
- .office-icon-button.is-active {
514
- border-color: color-mix(in srgb, #2ca58d, var(--color-border) 24%);
515
- background: color-mix(in srgb, #2ca58d, var(--color-panel) 84%);
516
- }
517
-
518
- .office-icon-button:hover:not(:disabled),
519
- .office-tab:hover,
520
- .office-tab-close:hover {
521
- border-color: color-mix(in srgb, #2c7be5, var(--color-border) 45%);
522
- background: color-mix(in srgb, var(--color-panel), #2c7be5 8%);
523
- }
524
-
525
- .office-icon-button:disabled {
526
- cursor: default;
527
- opacity: 0.42;
528
- }
529
-
530
- .office-icon-button .material-symbols-outlined,
531
- .office-tab-icon {
532
- font-size: 19px;
533
- line-height: 1;
534
- }
535
-
536
- .office-tabs {
275
+ .office-toolbar-row {
276
display: flex;
538
- gap: 6px;
539
- min-height: 42px;
540
- padding: 7px 10px;
541
- overflow-x: auto;
542
- border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 22%);
543
- background: color-mix(in srgb, var(--color-panel), var(--color-background) 28%);
544
- }
545
-
546
- .office-tab-shell {
547
- display: grid;
548
- grid-template-columns: minmax(0, 1fr) 28px;
277
align-items: center;
550
- min-width: 150px;
551
- max-width: 240px;
552
- }
553
-
554
- .office-tab-shell.is-system {
555
- grid-template-columns: minmax(0, 1fr);
556
- min-width: 172px;
557
- }
558
-
559
- .office-tab,
560
- .office-tab-close {
561
- height: 28px;
562
- min-height: 28px;
563
- border-radius: 7px;
278
+ gap: 8px;
279
+ width: 100%;
280
+ min-width: 0;
281
}
282
566
- .office-tab {
283
+ .office-tool-group,
284
+ .office-empty-actions {
285
display: flex;
286
align-items: center;
287
+ flex-wrap: wrap;
288
gap: 6px;
570
- min-width: 0;
571
- border-top-right-radius: 0;
572
- border-bottom-right-radius: 0;
573
- padding: 0 8px;
574
- text-align: left;
575
- }
576
-
577
- .office-tab-shell.is-system .office-tab {
578
- border-radius: 7px;
579
- }
580
-
581
- .office-tab-close {
582
- display: grid;
583
- place-items: center;
584
- border-left: 0;
585
- border-top-left-radius: 0;
586
- border-bottom-left-radius: 0;
587
- padding: 0;
588
- }
589
-
590
- .office-tab-close .material-symbols-outlined {
591
- font-size: 17px;
592
- }
593
-
594
- .office-tab-shell.is-active .office-tab,
595
- .office-tab-shell.is-active .office-tab-close {
596
- border-color: color-mix(in srgb, #2c7be5, var(--color-border) 36%);
597
- background: color-mix(in srgb, #2c7be5, var(--color-panel) 88%);
289
}
290
600
- .office-tab-shell.is-dirty .office-tab-title::after {
601
- content: " *";
602
- color: #2ca58d;
603
- }
604
-
605
- .office-tab-title {
606
- min-width: 0;
607
- overflow: hidden;
608
- text-overflow: ellipsis;
609
- white-space: nowrap;
610
- font-size: 12px;
611
- line-height: 1;
291
+ .office-toolbar-spacer {
292
+ flex: 1 1 auto;
293
}
294
295
.office-state-line {
615
- display: flex;
616
- align-items: center;
617
- gap: 8px;
618
- min-height: 34px;
619
- padding: 6px 12px;
620
- border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 28%);
621
- color: var(--color-text-secondary);
622
- font-size: 12px;
296
+ font-size: 13px;
297
+ color: var(--color-muted);
298
}
299
300
.office-body {
626
- position: relative;
301
display: flex;
302
flex: 1 1 auto;
303
min-height: 0;
630
- overflow: hidden;
631
- background: var(--color-background);
632
- }
633
-
634
- .office-body.is-source {
635
- background: transparent;
304
}
305
638
- .office-editor-wrap {
306
+ .office-editor-wrap,
307
+ .office-editor-scroll {
308
display: flex;
309
flex: 1 1 auto;
641
- flex-direction: column;
310
min-width: 0;
311
min-height: 0;
312
}
313
646
- .office-editor-scroll {
647
- flex: 1 1 auto;
648
- min-height: 0;
649
- overflow: auto;
650
- padding: 30px 24px;
651
- }
652
-
653
- .office-editor-scroll.is-source {
654
- display: flex;
655
- overflow: hidden;
656
- padding: var(--spacing-md);
657
- background: transparent;
658
- }
659
-
660
- .office-editor-scroll.is-desktop {
661
- display: flex;
662
- overflow: hidden;
663
- padding: 0;
664
- background: #1f2329;
665
- }
666
-
667
- .office-desktop-wrap {
668
- display: flex;
314
+ .office-source-editor {
315
flex: 1 1 auto;
316
width: 100%;
317
height: 100%;
318
min-width: 0;
319
min-height: 0;
674
- aspect-ratio: auto;
675
- background: #1f2329;
320
+ resize: none;
321
+ border: 0;
322
+ outline: none;
323
+ padding: 16px;
324
+ background: var(--color-background);
325
+ color: var(--color-text);
326
+ font: 14px/1.55 var(--font-mono, monospace);
327
}
328
678
- .office-desktop-empty {
679
- display: grid;
329
+ .office-empty {
330
+ display: flex;
331
flex: 1 1 auto;
681
- place-items: center;
682
- align-content: center;
683
- gap: 12px;
684
- min-width: 0;
685
- min-height: 0;
332
+ align-items: center;
333
+ justify-content: center;
334
padding: 24px;
687
- color: var(--color-text-secondary);
688
- text-align: center;
335
}
336
691
- .office-desktop-empty > .material-symbols-outlined {
692
- font-size: 32px;
693
- color: color-mix(in srgb, var(--color-text) 68%, transparent);
694
- }
695
-
696
- .office-desktop-empty-title {
697
- font-size: 13px;
698
- font-weight: 700;
699
- }
700
-
701
- .office-desktop-frame {
702
- flex: 1 1 auto;
703
- width: 100%;
704
- height: 100%;
705
- min-height: 0;
706
- aspect-ratio: auto;
707
- border: 0;
708
- background: #20242a;
709
- }
710
-
711
- .office-source-editor {
712
- box-sizing: border-box;
713
- flex: 1 1 auto;
714
- width: 100%;
715
- height: 100%;
716
- min-width: 0;
717
- min-height: 0;
718
- margin: 0;
719
- padding: 0;
720
- border: 0;
721
- outline: none;
722
- background: transparent;
723
- box-shadow: none;
724
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
725
- font-size: 13px;
726
- line-height: 1.65;
727
- resize: none;
337
+ .office-command-button {
338
+ min-width: 108px;
339
+ justify-content: flex-start;
340
+ padding: 0 10px;
341
}
342
730
- textarea:focus {
731
- background: transparent;
732
- filter: brightness(1) !important;
343
+ .office-header-actions {
344
+ position: relative;
345
+ display: inline-flex;
346
+ align-items: center;
347
}
348
735
- .office-panel .spinning {
736
- animation: office-spin 0.8s linear infinite;
349
+ .office-header-new-button {
350
+ display: inline-flex;
351
+ align-items: center;
352
+ gap: 4px;
353
+ height: 32px;
354
+ border: 1px solid var(--color-border);
355
+ border-radius: 6px;
356
+ background: var(--color-background);
357
+ color: var(--color-text);
358
+ padding: 0 9px;
359
+ cursor: pointer;
360
}
361
739
- @keyframes office-spin {
740
- to { transform: rotate(360deg); }
362
+ .office-header-actions .office-new-menu {
363
+ right: 0;
364
}
365
743
- @container (max-width: 680px) {
744
- .office-toolbar {
745
- padding-inline: 8px;
366
+ @container (max-width: 560px) {
367
+ .office-document-header,
368
+ .office-toolbar,
369
+ .office-state-line {
370
+ padding: 7px;
371
}
372
748
- .office-toolbar-row {
749
- gap: 5px;
373
+ .office-button-label,
374
+ .office-header-new-button span:not(.material-symbols-outlined) {
375
+ display: none;
376
}
377
378
.office-command-button {
753
- width: 32px;
754
- max-width: 32px;
755
- padding-inline: 0;
756
- }
757
-
758
- .office-command-button .office-button-label {
759
- position: absolute;
760
- width: 1px;
761
- height: 1px;
762
- overflow: hidden;
763
- clip: rect(0 0 0 0);
764
- white-space: nowrap;
379
+ min-width: 32px;
380
+ justify-content: center;
381
}
766
-
382
}
383
</style>
384
</body>
plugins/_office/webui/office-store.js
+71
-1960
@@ -2,25 +2,15 @@ import { createStore } from "/js/AlpineStore.js";
2
import { callJsonApi } from "/js/api.js";
3
import { getNamespacedClient } from "/js/websocket.js";
4
import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
5
-import { store as rightCanvasStore } from "/components/canvas/right-canvas-store.js";
6
-import { store as browserStore } from "/plugins/_browser/webui/browser-store.js";
5
+import { open as openSurface } from "/js/surfaces.js";
6
7
const officeSocket = getNamespacedClient("/ws");
8
officeSocket.addHandlers(["ws_webui"]);
9
10
const SAVE_MESSAGE_MS = 1800;
11
const INPUT_PUSH_DELAY_MS = 650;
13
-const DESKTOP_HEARTBEAT_MS = 3500;
14
-const DESKTOP_RESIZE_DELAY_MS = 80;
15
-const DESKTOP_START_MESSAGE = "Starting Agent Zero Desktop environment";
16
-const XPRA_DESKTOP_PRIME_INTERVAL_MS = 220;
17
-const XPRA_DESKTOP_PRIME_ATTEMPTS = 120;
18
-const SYSTEM_DESKTOP_FILE_ID = "system-desktop";
19
-const BROWSER_MODAL_PATH = "/plugins/_browser/webui/main.html";
20
-const OFFICE_MODAL_PATH = "/plugins/_office/webui/main.html";
21
-const URL_INTENT_PANEL_TIMEOUT_MS = 5000;
22
-const DESKTOP_SHUTDOWN_STORAGE_KEY = "a0.office.desktopShutdown";
12
const MAX_HISTORY = 80;
13
+const DESKTOP_DOCUMENT_EXTENSIONS = new Set(["odt", "ods", "odp", "docx", "xlsx", "pptx"]);
14
15
function currentContextId() {
16
try {
@@ -57,55 +47,6 @@ function editorContainsFocus(element) {
47
return Boolean(element && active && (element === active || element.contains(active)));
48
}
49
60
-function isEditableInputTarget(target) {
61
- const element = target?.nodeType === 1 ? target : target?.parentElement;
62
- const editable = element?.closest?.("input, textarea, select, [contenteditable='true'], [contenteditable=''], [role='textbox']");
63
- if (!editable) return false;
64
- if (editable.tagName !== "INPUT") return true;
65
- const type = String(editable.getAttribute("type") || "text").toLowerCase();
66
- return !["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"].includes(type);
67
-}
68
-
69
-function normalizeModalPath(path = "") {
70
- return String(path || "").replace(/^\/+/, "");
71
-}
72
-
73
-function isModalPathOpen(path = "") {
74
- const normalized = normalizeModalPath(path);
75
- return Boolean(
76
- globalThis.isModalOpen?.(path)
77
- || globalThis.isModalOpen?.(`/${normalized}`)
78
- || globalThis.isModalOpen?.(normalized)
79
- );
80
-}
81
-
82
-function waitForElementByPredicate(predicate, timeoutMs = URL_INTENT_PANEL_TIMEOUT_MS) {
83
- const found = predicate();
84
- if (found) return Promise.resolve(found);
85
- return new Promise((resolve) => {
86
- const timeout = globalThis.setTimeout(() => {
87
- observer.disconnect();
88
- resolve(predicate());
89
- }, timeoutMs);
90
- const observer = new MutationObserver(() => {
91
- const element = predicate();
92
- if (!element) return;
93
- globalThis.clearTimeout(timeout);
94
- observer.disconnect();
95
- resolve(element);
96
- });
97
- observer.observe(document.body, { childList: true, subtree: true });
98
- });
99
-}
100
-
101
-function browserPanelForMode(mode = "modal") {
102
- const panels = Array.from(document.querySelectorAll(".browser-panel"));
103
- if (mode === "canvas") {
104
- return panels.find((panel) => panel.closest?.('[data-surface-id="browser"]')) || null;
105
- }
106
- return panels.find((panel) => panel.closest?.(".modal")) || null;
107
-}
108
-
50
function placeCaretAtEnd(element) {
51
if (!element) return;
52
if (element.tagName === "TEXTAREA" || element.tagName === "INPUT") {
@@ -137,22 +78,23 @@ function normalizeDocument(doc = {}) {
78
79
function normalizeSession(payload = {}) {
80
const document = normalizeDocument(payload.document || payload);
140
- const extension = String(payload.extension || document.extension || "").toLowerCase();
81
return {
82
...payload,
83
document,
144
- extension,
84
+ extension: String(payload.extension || document.extension || "").toLowerCase(),
85
file_id: payload.file_id || document.file_id || "",
86
path: document.path || payload.path || "",
87
title: payload.title || document.title || document.basename || basename(document.path),
88
tab_id: uniqueTabId(payload),
89
text: String(payload.text || ""),
150
- desktop: payload.desktop || null,
151
- desktop_session_id: payload.desktop_session_id || payload.desktop?.session_id || "",
90
dirty: false,
91
};
92
}
93
94
+function documentLabel(document = {}) {
95
+ return document.title || document.basename || basename(document.path);
96
+}
97
+
98
async function callOffice(action, payload = {}) {
99
return await callJsonApi("/plugins/_office/office_session", {
100
action,
@@ -187,8 +129,6 @@ function isOfficeSocketData(data) {
129
|| Object.prototype.hasOwnProperty.call(data, "ok")
130
|| Object.prototype.hasOwnProperty.call(data, "session_id")
131
|| Object.prototype.hasOwnProperty.call(data, "document")
190
- || Object.prototype.hasOwnProperty.call(data, "desktop")
191
- || Object.prototype.hasOwnProperty.call(data, "closed")
132
);
133
}
134
@@ -204,7 +144,8 @@ const model = {
144
message: "",
145
editorText: "",
146
_root: null,
207
- _mode: "canvas",
147
+ _mode: "modal",
148
+ _initialized: false,
149
_saveMessageTimer: null,
150
_inputTimer: null,
151
_history: [],
@@ -212,57 +153,24 @@ const model = {
153
_pendingFocus: false,
154
_pendingFocusEnd: true,
155
_focusAttempts: 0,
215
- _floatingCleanup: null,
216
- _desktopHeartbeatTimer: null,
217
- _desktopHeartbeatSessionId: "",
218
- _desktopHeartbeatTabId: "",
219
- _desktopHeartbeatMisses: 0,
220
- _desktopResizeCleanup: null,
221
- _desktopResizeTarget: null,
222
- _desktopResizeTimer: null,
223
- _desktopResizeKey: "",
224
- _desktopResizePendingKey: "",
225
- _desktopResizeSuspended: false,
226
- _desktopResizePending: false,
227
- _desktopViewportSyncTimers: [],
228
- _desktopHostVisible: false,
229
- _desktopPrimeTimer: null,
230
- _desktopPrimeAttempts: 0,
231
- _desktopKeyboardActive: false,
232
- _desktopFocusInProgress: false,
233
- _desktopBridgeReady: false,
234
- _desktopKeyboardCaptureState: { ready: false, active: false, capture: false, focused: false },
235
- _desktopLastState: null,
236
- _desktopKeyboardCleanup: null,
237
- _desktopClipboardCleanup: null,
238
- _desktopStarting: null,
239
- _desktopUrlIntentBusy: false,
240
- _desktopUrlIntentQueue: [],
241
- _desktopFrame: null,
242
- _desktopFrameHost: null,
243
- _desktopFrameLoadHandler: null,
244
- _desktopKeepaliveHost: null,
245
- _desktopIntentionalShutdown: false,
246
-
247
- async init(element = null) {
248
- this.restoreDesktopShutdownState();
249
- return await this.onMount(element, { mode: "canvas" });
156
+ _headerCleanup: null,
157
+
158
+ async init() {
159
+ if (this._initialized) return;
160
+ this._initialized = true;
161
+ await this.refresh();
162
},
163
164
async onMount(element = null, options = {}) {
165
+ await this.init();
166
if (element) this._root = element;
254
- this._mode = options?.mode === "modal" ? "modal" : "canvas";
255
- if (this._mode === "modal") {
256
- this._desktopHostVisible = true;
257
- this.setupFloatingModal(element);
258
- await this.onOpen({ source: "modal" });
259
- return;
260
- }
167
+ this._mode = options?.mode === "canvas" ? "canvas" : "modal";
168
+ if (this._mode === "modal") this.setupDocumentModal(element);
169
this.queueRender();
170
},
171
172
async onOpen(payload = {}) {
265
- this.restoreDesktopShutdownState();
173
+ await this.init();
174
await this.refresh();
175
if (payload?.path || payload?.file_id) {
176
await this.openSession({
@@ -271,39 +179,17 @@ const model = {
179
refresh: payload.refresh === true,
180
source: payload.source || "",
181
});
274
- } else if (this._desktopIntentionalShutdown) {
275
- this.session = null;
276
- this.activeTabId = "";
277
- this.editorText = "";
278
- this.dirty = false;
279
- } else {
280
- await this.ensureDesktopSession({ select: !this.session });
182
}
282
- this.restoreDesktopFrames();
283
- this.requestDesktopViewportSync({ force: true });
183
},
184
286
- beforeHostHidden(options = {}) {
287
- this._desktopHostVisible = false;
185
+ beforeHostHidden() {
186
this.flushInput();
289
- this.clearDesktopViewportSyncTimers();
290
- this.stopDesktopMonitor();
291
- this.stopDesktopKeyboardBridge();
292
- this.stopDesktopClipboardBridge();
293
- this.unloadDesktopFrames();
187
},
188
189
cleanup() {
190
this.flushInput();
298
- this.stopDesktopMonitor();
299
- this.stopDesktopResizeObserver();
300
- this.clearDesktopViewportSyncTimers();
301
- this.stopXpraDesktopPrime();
302
- this.stopDesktopKeyboardBridge();
303
- this.stopDesktopClipboardBridge();
304
- if (!this._desktopIntentionalShutdown) this.moveDesktopFrameToKeepalive();
305
- this._floatingCleanup?.();
306
- this._floatingCleanup = null;
191
+ this._headerCleanup?.();
192
+ this._headerCleanup = null;
193
if (this._mode === "modal") this._root = null;
194
},
195
@@ -317,176 +203,6 @@ const model = {
203
}
204
},
205
320
- restoreDesktopShutdownState() {
321
- try {
322
- this._desktopIntentionalShutdown = localStorage.getItem(DESKTOP_SHUTDOWN_STORAGE_KEY) === "1";
323
- } catch {
324
- this._desktopIntentionalShutdown = Boolean(this._desktopIntentionalShutdown);
325
- }
326
- },
327
-
328
- persistDesktopShutdownState() {
329
- try {
330
- if (this._desktopIntentionalShutdown) {
331
- localStorage.setItem(DESKTOP_SHUTDOWN_STORAGE_KEY, "1");
332
- } else {
333
- localStorage.removeItem(DESKTOP_SHUTDOWN_STORAGE_KEY);
334
- }
335
- } catch {
336
- // Shutdown state is still correct for this page even without storage.
337
- }
338
- },
339
-
340
- setDesktopIntentionalShutdown(value) {
341
- this._desktopIntentionalShutdown = Boolean(value);
342
- this.persistDesktopShutdownState();
343
- },
344
-
345
- isDesktopShutdown() {
346
- return Boolean(this._desktopIntentionalShutdown);
347
- },
348
-
349
- shouldShowDesktopEmptyState() {
350
- return Boolean(this._desktopIntentionalShutdown && !this.session);
351
- },
352
-
353
- async restartDesktopSession() {
354
- this.error = "";
355
- const session = await this.ensureDesktopSession({
356
- force: true,
357
- restart: true,
358
- select: true,
359
- message: "Restarting Agent Zero Desktop environment",
360
- });
361
- if (!session) {
362
- this.setDesktopIntentionalShutdown(true);
363
- return null;
364
- }
365
- this.restoreDesktopFrames();
366
- this.requestDesktopViewportSync({ force: true });
367
- return session;
368
- },
369
-
370
- async shutdownDesktop(options = {}) {
371
- this.loading = options.progress !== false;
372
- this.message = this.loading ? "Shutting down Desktop" : this.message;
373
- this.error = "";
374
- try {
375
- const response = await callOffice("desktop_shutdown", {
376
- save_first: options.saveFirst !== false,
377
- source: options.source || "ui",
378
- });
379
- await this.handleIntentionalDesktopShutdown(response);
380
- return response;
381
- } catch (error) {
382
- this.error = error instanceof Error ? error.message : String(error);
383
- return null;
384
- } finally {
385
- if (options.progress !== false) {
386
- this.loading = false;
387
- if (this.message === "Shutting down Desktop") this.message = "";
388
- }
389
- }
390
- },
391
-
392
- async handleIntentionalDesktopShutdown(response = {}) {
393
- this.setDesktopIntentionalShutdown(true);
394
- this.stopDesktopMonitor();
395
- this.stopDesktopResizeObserver();
396
- this.clearDesktopViewportSyncTimers();
397
- this.stopXpraDesktopPrime();
398
- this.stopDesktopKeyboardBridge();
399
- this.stopDesktopClipboardBridge();
400
- this.destroyDesktopFrame();
401
- const activeTabId = this.activeTabId;
402
- this.tabs = this.tabs.filter((tab) => !this.isDesktopSession(tab) && !this.hasOfficialOffice(tab));
403
- if (!this.tabs.some((tab) => tab.tab_id === activeTabId)) {
404
- this.session = null;
405
- this.activeTabId = "";
406
- this.editorText = "";
407
- this.dirty = false;
408
- this.resetHistory("");
409
- }
410
- this._desktopStarting = null;
411
- this._desktopHeartbeatMisses = 0;
412
- this.message = response?.source === "tray" ? "Desktop shut down from system tray" : "Desktop is shut down";
413
- await this.refresh();
414
- },
415
-
416
- async ensureDesktopSession(options = {}) {
417
- if (this._desktopIntentionalShutdown && options.restart !== true) {
418
- return null;
419
- }
420
- if (options.restart === true) {
421
- this.setDesktopIntentionalShutdown(false);
422
- this.destroyDesktopFrame();
423
- }
424
- const existing = this.tabs.find((tab) => this.isDesktopSession(tab));
425
- if (existing && !options.force) {
426
- if (options.select) this.selectTab(existing.tab_id, { focus: false });
427
- this.updateDesktopMonitor();
428
- return existing;
429
- }
430
- const showProgress = options.progress !== false;
431
- const progressMessage = String(options.message || DESKTOP_START_MESSAGE);
432
- if (this._desktopStarting) {
433
- if (showProgress) {
434
- this.loading = true;
435
- this.message = progressMessage;
436
- }
437
- return await this._desktopStarting;
438
- }
439
-
440
- this._desktopStarting = (async () => {
441
- try {
442
- if (showProgress) {
443
- this.loading = true;
444
- this.message = progressMessage;
445
- this.error = "";
446
- }
447
- const response = await callOffice("desktop");
448
- if (response?.ok === false) throw new Error(response.error || "Desktop session could not be opened.");
449
- this.setDesktopIntentionalShutdown(false);
450
- const session = normalizeSession(response);
451
- const existingIndex = this.tabs.findIndex((tab) => this.isDesktopSession(tab));
452
- let desktopTabId = session.tab_id;
453
- if (existingIndex >= 0) {
454
- desktopTabId = this.tabs[existingIndex].tab_id;
455
- this.tabs.splice(existingIndex, 1, { ...this.tabs[existingIndex], ...session, tab_id: desktopTabId });
456
- } else {
457
- this.tabs.unshift(session);
458
- }
459
- this.tabs = this.tabs.map((tab) => (
460
- this.hasOfficialOffice(tab)
461
- ? {
462
- ...tab,
463
- desktop: session.desktop,
464
- desktop_session_id: session.desktop_session_id,
465
- session_id: this.isDesktopSession(tab) ? session.session_id : tab.session_id,
466
- }
467
- : tab
468
- ));
469
- if (options.select || !this.session) {
470
- this.selectTab(desktopTabId, { focus: false });
471
- } else {
472
- this.updateDesktopMonitor();
473
- }
474
- this.restoreDesktopFrames();
475
- return { ...session, tab_id: desktopTabId };
476
- } catch (error) {
477
- this.error = error instanceof Error ? error.message : String(error);
478
- return null;
479
- } finally {
480
- if (showProgress) {
481
- this.loading = false;
482
- if (this.message === progressMessage) this.message = "";
483
- }
484
- this._desktopStarting = null;
485
- }
486
- })();
487
- return await this._desktopStarting;
488
- },
489
-
206
async create(kind = "document", format = "") {
207
const fmt = String(format || (kind === "spreadsheet" ? "ods" : kind === "presentation" ? "odp" : "md")).toLowerCase();
208
const title = this.defaultTitle(kind, fmt);
@@ -527,6 +243,12 @@ const model = {
243
this.error = response.error || "Document could not be opened.";
244
return null;
245
}
246
+ if (response?.requires_desktop || this.isDesktopDocument(response)) {
247
+ const document = normalizeDocument(response.document || response);
248
+ this.setMessage(`${documentLabel(document)} is ready. Use Open in Desktop to edit it.`);
249
+ await this.refresh();
250
+ return response;
251
+ }
252
const session = normalizeSession(response);
253
this.installSession(session);
254
await this.refresh();
@@ -540,10 +262,6 @@ const model = {
262
},
263
264
installSession(session) {
543
- if (this.isDesktopOfficeDocument(session)) {
544
- this.installDesktopDocumentSession(session);
545
- return;
546
- }
265
const existingIndex = this.tabs.findIndex((tab) => (
266
(session.file_id && tab.file_id === session.file_id)
267
|| (session.path && tab.path === session.path)
@@ -558,67 +276,14 @@ const model = {
276
this.selectTab(this.activeTabId);
277
},
278
561
- installDesktopDocumentSession(session) {
562
- this.setDesktopIntentionalShutdown(false);
563
- this.tabs = this.tabs.filter((tab) => !this.isDesktopOfficeDocument(tab));
564
- let desktopTab = this.tabs.find((tab) => this.isDesktopSession(tab));
565
- if (!desktopTab) {
566
- desktopTab = {
567
- ...session,
568
- tab_id: SYSTEM_DESKTOP_FILE_ID,
569
- file_id: SYSTEM_DESKTOP_FILE_ID,
570
- extension: "desktop",
571
- title: "Desktop",
572
- path: session.desktop?.desktop_path || "/desktop/session",
573
- mode: "desktop",
574
- document: {
575
- file_id: SYSTEM_DESKTOP_FILE_ID,
576
- path: session.desktop?.desktop_path || "/desktop/session",
577
- basename: "Desktop",
578
- title: "Desktop",
579
- extension: "desktop",
580
- },
581
- dirty: false,
582
- };
583
- this.tabs.unshift(desktopTab);
584
- }
585
- const documentSession = { ...session, tab_id: session.tab_id || uniqueTabId(session) };
586
- const existingIndex = this.tabs.findIndex((tab) => (
587
- (documentSession.file_id && tab.file_id === documentSession.file_id)
588
- || (documentSession.path && tab.path === documentSession.path)
589
- ));
590
- if (existingIndex >= 0) {
591
- this.tabs.splice(existingIndex, 1, documentSession);
592
- } else {
593
- this.tabs.push(documentSession);
594
- }
595
- this.session = documentSession;
596
- this.activeTabId = documentSession.tab_id;
597
- this.editorText = "";
598
- this.dirty = false;
599
- this.resetHistory("");
600
- this.queueRender({ focus: true });
601
- this.restoreDesktopFrames();
602
- this.requestDesktopViewportSync({ force: true });
603
- this.updateDesktopMonitor();
604
- },
605
-
279
selectTab(tabId, options = {}) {
280
const tab = this.tabs.find((item) => item.tab_id === tabId) || this.tabs[0] || null;
608
- if (this.hasOfficialOffice(this.session) && !this.hasOfficialOffice(tab)) {
609
- this.moveDesktopFrameToKeepalive();
610
- }
281
this.session = tab;
282
this.activeTabId = tab?.tab_id || "";
283
this.editorText = String(tab?.text || "");
284
this.dirty = Boolean(tab?.dirty);
285
this.resetHistory(this.editorText);
286
this.queueRender({ focus: Boolean(tab) && options.focus !== false });
617
- if (this.hasOfficialOffice(tab)) {
618
- this.restoreDesktopFrames();
619
- this.requestDesktopViewportSync({ force: true });
620
- }
621
- this.updateDesktopMonitor();
287
},
288
289
ensureActiveTab() {
@@ -633,21 +298,12 @@ const model = {
298
async closeTab(tabId) {
299
const tab = this.tabs.find((item) => item.tab_id === tabId);
300
if (!tab) return;
636
- if (this.isDesktopSession(tab)) {
637
- this.selectTab(tab.tab_id, { focus: false });
638
- return;
639
- }
640
- if (!this.hasOfficialOffice(tab) && (tab.dirty || (this.isActiveTab(tab) && this.dirty))) {
301
+ if (tab.dirty || (this.isActiveTab(tab) && this.dirty)) {
302
const shouldSave = globalThis.confirm?.("Save changes?") ?? true;
303
if (shouldSave) await this.save();
304
}
305
try {
645
- if (this.hasOfficialOffice(tab)) {
646
- await callOffice("desktop_save", {
647
- desktop_session_id: tab.desktop_session_id || tab.session_id,
648
- file_id: tab.file_id || "",
649
- }).catch(() => null);
650
- } else if (tab.session_id) {
306
+ if (tab.session_id) {
307
await requestOffice("office_close", { session_id: tab.session_id }, 2500).catch(() => null);
308
}
309
await callOffice("close", {
@@ -665,48 +321,17 @@ const model = {
321
this.dirty = false;
322
this.ensureActiveTab();
323
}
668
- this.updateDesktopMonitor();
324
this.ensureActiveTab();
325
await this.refresh();
326
},
327
328
async closeActiveFile() {
674
- if (!this.session || this.isDesktopSession() || this.loading) return;
329
+ if (!this.session || this.loading) return;
330
await this.closeTab(this.session.tab_id);
331
},
332
333
async save() {
679
- if (!this.session || this.saving) return;
680
- if (this.isDesktopSession()) return;
681
- if (this.hasOfficialOffice()) {
682
- this.saving = true;
683
- this.error = "";
684
- try {
685
- const response = await callOffice("desktop_save", {
686
- desktop_session_id: this.session.desktop_session_id || this.session.session_id,
687
- file_id: this.session.file_id || "",
688
- });
689
- if (response?.ok === false) throw new Error(response.error || "Save failed.");
690
- const document = normalizeDocument(response.document || this.session.document || {});
691
- const updated = {
692
- ...this.session,
693
- dirty: false,
694
- document,
695
- path: document.path || this.session.path,
696
- file_id: document.file_id || this.session.file_id,
697
- version: document.version || response.version || this.session.version,
698
- };
699
- this.replaceActiveSession(updated);
700
- this.dirty = false;
701
- this.setMessage("Saved");
702
- await this.refresh();
703
- } catch (error) {
704
- this.error = error instanceof Error ? error.message : String(error);
705
- } finally {
706
- this.saving = false;
707
- }
708
- return;
709
- }
334
+ if (!this.session || this.saving || !this.isMarkdown()) return;
335
this.syncEditorText();
336
this.saving = true;
337
this.error = "";
@@ -741,8 +366,7 @@ const model = {
366
},
367
368
async renameActiveFile() {
744
- if (!this.session || this.isDesktopSession() || this.saving) return;
745
-
369
+ if (!this.session || this.saving) return;
370
const session = this.session;
371
const path = session.path || session.document?.path || "";
372
if (!path) {
@@ -800,7 +424,6 @@ const model = {
424
extension: document.extension || session.extension,
425
file_id: document.file_id || session.file_id,
426
version: document.version || response.version || session.version,
803
- desktop: response.desktop?.desktop || session.desktop,
427
text: this.session?.tab_id === session.tab_id ? this.editorText : session.text,
428
dirty: false,
429
};
@@ -820,7 +443,6 @@ const model = {
443
const index = this.tabs.findIndex((tab) => tab.tab_id === (previous?.tab_id || next.tab_id));
444
if (index >= 0) this.tabs.splice(index, 1, next);
445
this.queueRender();
823
- this.updateDesktopMonitor();
446
},
447
448
setMessage(value) {
@@ -889,12 +511,11 @@ const model = {
511
512
syncEditorText() {
513
if (!this.session) return;
892
- if (this.hasOfficialOffice()) return;
514
this.session.text = this.editorText;
515
},
516
517
scheduleInputPush() {
897
- if (!this.session?.session_id) return;
518
+ if (!this.session?.session_id || !this.isMarkdown()) return;
519
if (this._inputTimer) globalThis.clearTimeout(this._inputTimer);
520
this._inputTimer = globalThis.setTimeout(() => {
521
this._inputTimer = null;
@@ -903,8 +524,7 @@ const model = {
524
},
525
526
flushInput() {
906
- if (!this.session?.session_id) return;
907
- if (this.hasOfficialOffice()) return;
527
+ if (!this.session?.session_id || !this.isMarkdown()) return;
528
this.syncEditorText();
529
requestOffice("office_input", {
530
session_id: this.session.session_id,
@@ -913,12 +533,7 @@ const model = {
533
},
534
535
format(command) {
916
- if (!this.session) return;
917
- if (!this.isMarkdown()) return;
918
- this.applySourceFormat(command);
919
- },
920
-
921
- applySourceFormat(command) {
536
+ if (!this.session || !this.isMarkdown()) return;
537
const textarea = this._root?.querySelector?.("[data-office-source]");
538
if (!textarea) return;
539
const start = textarea.selectionStart || 0;
@@ -941,7 +556,6 @@ const model = {
556
},
557
558
queueRender(options = {}) {
944
- const force = Boolean(options.force);
559
if (options.focus) {
560
this._pendingFocus = true;
561
this._pendingFocusEnd = options.end !== false;
@@ -964,12 +578,9 @@ const model = {
578
},
579
580
focusEditor(options = {}) {
967
- if (!this.session) return false;
968
- if (this.hasOfficialOffice()) {
969
- return this.focusDesktopFrame(this.desktopFrame(), { arm: true });
970
- }
581
+ if (!this.session || !this.isMarkdown()) return false;
582
const source = this._root?.querySelector?.("[data-office-source]");
972
- if (!this.isMarkdown() || !source) return false;
583
+ if (!source) return false;
584
source.focus?.({ preventScroll: true });
585
if (!editorContainsFocus(source)) return false;
586
if (options.end !== false) placeCaretAtEnd(source);
@@ -981,1275 +592,17 @@ const model = {
592
return ext === "md";
593
},
594
984
- isBinaryOffice(tab = this.session) {
595
+ isDesktopDocument(tab = this.session) {
596
const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase();
986
- return ["odt", "ods", "odp", "docx", "xlsx", "pptx"].includes(ext);
987
- },
988
-
989
- hasOfficialOffice(tab = this.session) {
990
- return Boolean(tab?.desktop?.available && tab.desktop.url);
991
- },
992
-
993
- isDesktopSession(tab = this.session) {
994
- return Boolean(
995
- tab
996
- && (
997
- tab.file_id === SYSTEM_DESKTOP_FILE_ID
998
- || tab.extension === "desktop"
999
- || tab.mode === "desktop"
1000
- )
1001
- );
1002
- },
1003
-
1004
- isDesktopOfficeDocument(tab = this.session) {
1005
- return Boolean(tab && this.hasOfficialOffice(tab) && !this.isDesktopSession(tab) && this.isBinaryOffice(tab));
597
+ return DESKTOP_DOCUMENT_EXTENSIONS.has(ext);
598
},
599
600
hasActiveFile(tab = this.session) {
1009
- return Boolean(tab && !this.isDesktopSession(tab) && (this.isMarkdown(tab) || this.isDesktopOfficeDocument(tab)));
1010
- },
1011
-
1012
- isVisibleOfficeTab(tab = {}) {
1013
- return Boolean(this.hasActiveFile(tab));
601
+ return Boolean(tab && this.isMarkdown(tab));
602
},
603
604
visibleTabs() {
1017
- return this.tabs.filter((tab) => this.isVisibleOfficeTab(tab));
1018
- },
1019
-
1020
- officialOfficeUrl(tab = this.session) {
1021
- const url = tab?.desktop?.url || "";
1022
- if (!url) return "";
1023
- try {
1024
- const parsed = new URL(url, window.location.href);
1025
- const secureContext = globalThis.isSecureContext === true;
1026
- parsed.searchParams.set("offscreen", secureContext ? "true" : "false");
1027
- parsed.searchParams.set("clipboard_poll", secureContext ? "true" : "false");
1028
- if (parsed.origin === window.location.origin) return `${parsed.pathname}${parsed.search}${parsed.hash}`;
1029
- return parsed.href;
1030
- } catch {
1031
- return url;
1032
- }
1033
- },
1034
-
1035
- isDesktopHostVisible() {
1036
- if (this._mode === "modal") return true;
1037
- const canvas = rightCanvasStore;
1038
- return Boolean(canvas?.isOpen && (canvas.isSurfaceMounted?.("office") ?? canvas.activeSurfaceId === "office"));
1039
- },
1040
-
1041
- setDesktopHostVisible(visible) {
1042
- const next = Boolean(visible);
1043
- if (!next && this._mode === "modal") return;
1044
- if (this._desktopHostVisible === next) return;
1045
- this._desktopHostVisible = next;
1046
- if (next) {
1047
- this.afterDesktopHostShown({ source: "canvas-visibility" });
1048
- } else {
1049
- this.beforeHostHidden({ reason: "hidden" });
1050
- }
1051
- },
1052
-
1053
- desktopFrames() {
1054
- const frames = [];
1055
- if (this._desktopFrame) frames.push(this._desktopFrame);
1056
- for (const frame of Array.from(document.querySelectorAll("[data-office-desktop-frame]"))) {
1057
- if (!frames.includes(frame)) frames.push(frame);
1058
- }
1059
- return frames;
1060
- },
1061
-
1062
- isUsableDesktopFrame(frame) {
1063
- if (!frame?.contentWindow) return false;
1064
- const rect = frame.getBoundingClientRect?.();
1065
- return Boolean(rect && rect.width >= 120 && rect.height >= 80);
1066
- },
1067
-
1068
- desktopFrame(preferred = null) {
1069
- if (this.isUsableDesktopFrame(preferred)) return preferred;
1070
- const rootFrame = this._root?.querySelector?.("[data-office-desktop-frame]");
1071
- if (this.isUsableDesktopFrame(rootFrame)) return rootFrame;
1072
- const frames = this.desktopFrames();
1073
- return frames
1074
- .filter((frame) => this.isUsableDesktopFrame(frame))
1075
- .sort((left, right) => {
1076
- const leftRect = left.getBoundingClientRect();
1077
- const rightRect = right.getBoundingClientRect();
1078
- return (rightRect.width * rightRect.height) - (leftRect.width * leftRect.height);
1079
- })[0] || null;
1080
- },
1081
-
1082
- isUsableDesktopHost(host) {
1083
- if (!host?.appendChild) return false;
1084
- const rect = host.getBoundingClientRect?.();
1085
- return Boolean(rect && rect.width >= 120 && rect.height >= 80);
1086
- },
1087
-
1088
- desktopHost(preferred = null) {
1089
- if (preferred?.matches?.("[data-office-desktop-host]")) return preferred;
1090
- const rootHost = this._root?.querySelector?.("[data-office-desktop-host]");
1091
- if (this.isUsableDesktopHost(rootHost)) return rootHost;
1092
- const hosts = Array.from(document.querySelectorAll("[data-office-desktop-host]"));
1093
- return hosts
1094
- .filter((host) => this.isUsableDesktopHost(host))
1095
- .sort((left, right) => {
1096
- const leftRect = left.getBoundingClientRect();
1097
- const rightRect = right.getBoundingClientRect();
1098
- return (rightRect.width * rightRect.height) - (leftRect.width * leftRect.height);
1099
- })[0] || rootHost || hosts[0] || null;
1100
- },
1101
-
1102
- ensureDesktopKeepaliveHost() {
1103
- if (this._desktopKeepaliveHost?.isConnected) return this._desktopKeepaliveHost;
1104
- const host = document.createElement("div");
1105
- host.className = "office-desktop-keepalive";
1106
- host.dataset.officeDesktopKeepalive = "true";
1107
- Object.assign(host.style, {
1108
- position: "fixed",
1109
- left: "-10000px",
1110
- top: "-10000px",
1111
- width: "720px",
1112
- height: "480px",
1113
- overflow: "hidden",
1114
- pointerEvents: "none",
1115
- visibility: "hidden",
1116
- });
1117
- document.body?.appendChild(host);
1118
- this._desktopKeepaliveHost = host;
1119
- return host;
1120
- },
1121
-
1122
- rememberDesktopFrameSize() {
1123
- const frame = this._desktopFrame;
1124
- const rect = frame?.getBoundingClientRect?.();
1125
- const hostRect = this._desktopFrameHost?.getBoundingClientRect?.();
1126
- const width = Math.round(rect?.width || hostRect?.width || 720);
1127
- const height = Math.round(rect?.height || hostRect?.height || 480);
1128
- const keepalive = this.ensureDesktopKeepaliveHost();
1129
- keepalive.style.width = `${Math.max(320, width)}px`;
1130
- keepalive.style.height = `${Math.max(220, height)}px`;
1131
- return keepalive;
1132
- },
1133
-
1134
- ensureDesktopFrame() {
1135
- if (this._desktopFrame) return this._desktopFrame;
1136
- const frame = document.createElement("iframe");
1137
- frame.className = "office-desktop-frame";
1138
- frame.dataset.officeDesktopFrame = "true";
1139
- frame.dataset.officePersistentDesktopFrame = "true";
1140
- frame.setAttribute("tabindex", "0");
1141
- frame.setAttribute("aria-label", "Desktop");
1142
- frame.setAttribute("allow", "clipboard-read; clipboard-write; autoplay");
1143
- this._desktopFrameLoadHandler = (event) => this.onDesktopFrameLoaded(event);
1144
- frame.addEventListener("load", this._desktopFrameLoadHandler);
1145
- this._desktopFrame = frame;
1146
- return frame;
1147
- },
1148
-
1149
- desktopFrameSrcMatches(frame, url) {
1150
- const current = frame?.getAttribute?.("src") || frame?.src || "";
1151
- if (!current && !url) return true;
1152
- try {
1153
- return new URL(current, window.location.href).href === new URL(url, window.location.href).href;
1154
- } catch {
1155
- return current === url;
1156
- }
1157
- },
1158
-
1159
- attachDesktopFrame(host = null) {
1160
- if (!this.hasOfficialOffice()) return false;
1161
- const target = this.desktopHost(host);
1162
- if (!target) return false;
1163
- const frame = this.ensureDesktopFrame();
1164
- if (frame.parentElement !== target) {
1165
- frame.parentElement?.removeAttribute?.("data-office-desktop-attached");
1166
- target.appendChild(frame);
1167
- }
1168
- target.dataset.officeDesktopAttached = "true";
1169
- if (this._desktopFrameHost !== target) this._desktopFrameHost = target;
1170
- const url = this.officialOfficeUrl();
1171
- if (url && !this.desktopFrameSrcMatches(frame, url)) {
1172
- frame.setAttribute("src", url);
1173
- }
1174
- return true;
1175
- },
1176
-
1177
- mountDesktopFrameHost(host = null) {
1178
- const attached = this.attachDesktopFrame(host);
1179
- if (attached && this.isDesktopHostVisible()) {
1180
- this.requestDesktopViewportSync({ force: true, frame: this._desktopFrame, followup: true });
1181
- }
1182
- return attached;
1183
- },
1184
-
1185
- moveDesktopFrameToKeepalive() {
1186
- const frame = this._desktopFrame;
1187
- if (!frame) return false;
1188
- const keepalive = this.rememberDesktopFrameSize();
1189
- if (frame.parentElement !== keepalive) {
1190
- frame.parentElement?.removeAttribute?.("data-office-desktop-attached");
1191
- keepalive.appendChild(frame);
1192
- }
1193
- this._desktopFrameHost = keepalive;
1194
- this._desktopKeyboardActive = false;
1195
- this.updateDesktopKeyboardCaptureState(frame);
1196
- return true;
1197
- },
1198
-
1199
- destroyDesktopFrame() {
1200
- const frame = this._desktopFrame;
1201
- if (!frame) return;
1202
- if (this._desktopFrameLoadHandler) {
1203
- frame.removeEventListener("load", this._desktopFrameLoadHandler);
1204
- }
1205
- frame.setAttribute("src", "about:blank");
1206
- frame.remove();
1207
- this._desktopFrame = null;
1208
- this._desktopFrameHost = null;
1209
- this._desktopFrameLoadHandler = null;
1210
- this._desktopBridgeReady = false;
1211
- this.updateDesktopKeyboardCaptureState();
1212
- this._desktopKeepaliveHost?.remove?.();
1213
- this._desktopKeepaliveHost = null;
1214
- },
1215
-
1216
- unloadDesktopFrames() {
1217
- this.stopDesktopResizeObserver();
1218
- this.stopXpraDesktopPrime();
1219
- this.moveDesktopFrameToKeepalive();
1220
- },
1221
-
1222
- restoreDesktopFrames() {
1223
- if (!this.isDesktopHostVisible()) return;
1224
- this.attachDesktopFrame();
1225
- },
1226
-
1227
- afterDesktopHostShown() {
1228
- if (!this.hasOfficialOffice()) return;
1229
- this._desktopHostVisible = true;
1230
- this._desktopResizeKey = "";
1231
- this._desktopResizePendingKey = "";
1232
- this._desktopResizeSuspended = false;
1233
- this._desktopResizePending = false;
1234
- this.restoreDesktopFrames();
1235
- this.requestDesktopViewportSync({ force: true, frame: this.desktopFrame() });
1236
- },
1237
-
1238
- beforeDesktopHostHandoff() {
1239
- this.stopDesktopResizeObserver();
1240
- this.clearDesktopViewportSyncTimers();
1241
- this.stopXpraDesktopPrime();
1242
- this._desktopResizeKey = "";
1243
- this._desktopResizePendingKey = "";
1244
- this._desktopResizeSuspended = true;
1245
- this._desktopResizePending = true;
1246
- },
1247
-
1248
- cancelDesktopHostHandoff() {
1249
- this._desktopResizeSuspended = false;
1250
- this._desktopResizePending = false;
1251
- this.requestDesktopViewportSync({ force: true, frame: this.desktopFrame() });
1252
- },
1253
-
1254
- onDesktopFrameLoaded(event = null) {
1255
- if (event?.target?.getAttribute?.("src") === "about:blank") return;
1256
- if (!this.isDesktopHostVisible()) return;
1257
- this.error = "";
1258
- this.queueDesktopFrameFocus(event?.target || null);
1259
- this.requestDesktopViewportSync({ force: true, frame: event?.target || null });
1260
- },
1261
-
1262
- queueDesktopFrameFocus(frame = null) {
1263
- for (const delay of [0, 80, 260]) {
1264
- globalThis.setTimeout(() => {
1265
- if (!this.hasOfficialOffice()) return;
1266
- if (isEditableInputTarget(document.activeElement)) return;
1267
- this.focusDesktopFrame(frame || this.desktopFrame(), { arm: true });
1268
- }, delay);
1269
- }
1270
- },
1271
-
1272
- focusDesktopFrame(frame = null, options = {}) {
1273
- if (this._desktopFocusInProgress) return false;
1274
- const target = this.desktopFrame(frame);
1275
- if (!target) return false;
1276
- if (options.arm !== false) this._desktopKeyboardActive = true;
1277
- this._desktopFocusInProgress = true;
1278
- try {
1279
- target.setAttribute("tabindex", "0");
1280
- target.focus?.({ preventScroll: true });
1281
- target.contentWindow?.focus?.();
1282
- if (target.contentDocument?.body && !target.contentDocument.body.hasAttribute("tabindex")) {
1283
- target.contentDocument.body.tabIndex = -1;
1284
- }
1285
- target.contentDocument?.body?.focus?.({ preventScroll: true });
1286
- if (target.contentWindow?.client) target.contentWindow.client.capture_keyboard = true;
1287
- } catch {
1288
- target.focus?.({ preventScroll: true });
1289
- } finally {
1290
- this._desktopFocusInProgress = false;
1291
- }
1292
- const focused = Boolean(document.activeElement === target || target.contentDocument?.hasFocus?.());
1293
- this.updateDesktopKeyboardCaptureState(target);
1294
- return focused;
1295
- },
1296
-
1297
- updateDesktopMonitor() {
1298
- if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) {
1299
- this.stopDesktopMonitor();
1300
- this.stopDesktopResizeObserver();
1301
- this._desktopKeyboardActive = false;
1302
- this._desktopBridgeReady = false;
1303
- this.updateDesktopKeyboardCaptureState();
1304
- return;
1305
- }
1306
- const sessionId = this.session?.desktop_session_id || this.session?.session_id || "";
1307
- const tabId = this.session?.tab_id || "";
1308
- if (
1309
- sessionId
1310
- && tabId
1311
- && this._desktopHeartbeatTimer
1312
- && this._desktopHeartbeatSessionId === sessionId
1313
- && this._desktopHeartbeatTabId === tabId
1314
- ) return;
1315
- this.startDesktopMonitor();
1316
- this.startDesktopResizeObserver();
1317
- },
1318
-
1319
- startDesktopResizeObserver() {
1320
- if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) {
1321
- this.stopDesktopResizeObserver();
1322
- return;
1323
- }
1324
- const frame = this.desktopFrame();
1325
- const target = frame?.parentElement || frame;
1326
- if (!target) {
1327
- this.stopDesktopResizeObserver();
1328
- return;
1329
- }
1330
- if (this._desktopResizeCleanup && this._desktopResizeTarget === target) return;
1331
- this.stopDesktopResizeObserver();
1332
-
1333
- const resize = () => this.queueDesktopResize();
1334
- const resizeStart = () => this.suspendDesktopResize();
1335
- const resizeEnd = () => this.resumeDesktopResize();
1336
- const cleanup = [];
1337
- if (typeof ResizeObserver !== "undefined") {
1338
- const observer = new ResizeObserver(resize);
1339
- observer.observe(target);
1340
- cleanup.push(() => observer.disconnect());
1341
- }
1342
- globalThis.addEventListener?.("resize", resize);
1343
- cleanup.push(() => globalThis.removeEventListener?.("resize", resize));
1344
- globalThis.addEventListener?.("right-canvas-resize-start", resizeStart);
1345
- cleanup.push(() => globalThis.removeEventListener?.("right-canvas-resize-start", resizeStart));
1346
- globalThis.addEventListener?.("right-canvas-resize-end", resizeEnd);
1347
- cleanup.push(() => globalThis.removeEventListener?.("right-canvas-resize-end", resizeEnd));
1348
- this._desktopResizeTarget = target;
1349
- this._desktopResizeCleanup = () => cleanup.splice(0).reverse().forEach((entry) => entry());
1350
- resize();
1351
- },
1352
-
1353
- stopDesktopResizeObserver() {
1354
- if (this._desktopResizeTimer) {
1355
- globalThis.clearTimeout(this._desktopResizeTimer);
1356
- }
1357
- this._desktopResizeTimer = null;
1358
- this._desktopResizeCleanup?.();
1359
- this._desktopResizeCleanup = null;
1360
- this._desktopResizeTarget = null;
1361
- this._desktopResizeKey = "";
1362
- this._desktopResizePendingKey = "";
1363
- this._desktopResizeSuspended = false;
1364
- this._desktopResizePending = false;
1365
- },
1366
-
1367
- suspendDesktopResize() {
1368
- this._desktopResizeSuspended = true;
1369
- if (this._desktopResizeTimer) {
1370
- globalThis.clearTimeout(this._desktopResizeTimer);
1371
- this._desktopResizeTimer = null;
1372
- }
1373
- this._desktopResizePendingKey = "";
1374
- },
1375
-
1376
- resumeDesktopResize() {
1377
- const hadPendingResize = this._desktopResizePending;
1378
- this._desktopResizeSuspended = false;
1379
- this._desktopResizePending = false;
1380
- if (hadPendingResize || this.hasOfficialOffice()) {
1381
- this.queueDesktopResize({ force: true });
1382
- }
1383
- },
1384
-
1385
- shouldDeferDesktopResize() {
1386
- return Boolean(
1387
- this._desktopResizeSuspended
1388
- || document.body?.classList?.contains("right-canvas-resizing")
1389
- || document.querySelector?.(".modal-inner.office-modal.is-resizing")
1390
- );
1391
- },
1392
-
1393
- clearDesktopViewportSyncTimers() {
1394
- for (const timer of this._desktopViewportSyncTimers.splice(0)) {
1395
- globalThis.clearTimeout(timer);
1396
- }
1397
- },
1398
-
1399
- requestDesktopViewportSync(options = {}) {
1400
- if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) return;
1401
- if (options.force) this.clearDesktopViewportSyncTimers();
1402
- const run = (force = false) => {
1403
- this.syncDesktopViewport({ ...options, force });
1404
- };
1405
- if (globalThis.requestAnimationFrame) {
1406
- globalThis.requestAnimationFrame(() => run(Boolean(options.force)));
1407
- } else {
1408
- globalThis.setTimeout(() => run(Boolean(options.force)), 0);
1409
- }
1410
- if (options.followup === false) return;
1411
- const timer = globalThis.setTimeout(() => {
1412
- this._desktopViewportSyncTimers = this._desktopViewportSyncTimers.filter((item) => item !== timer);
1413
- run(false);
1414
- }, options.force ? 260 : 180);
1415
- this._desktopViewportSyncTimers.push(timer);
1416
- },
1417
-
1418
- syncDesktopViewport(options = {}) {
1419
- if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) return false;
1420
- const frame = this.desktopFrame(options.frame || null);
1421
- if (!frame) return false;
1422
- this.startDesktopResizeObserver();
1423
- this.primeXpraDesktopFrame({ reset: true, frame });
1424
- this.queueDesktopResize({
1425
- force: Boolean(options.force),
1426
- serverResize: options.serverResize !== false,
1427
- frame,
1428
- });
1429
- this.updateDesktopMonitor();
1430
- return true;
1431
- },
1432
-
1433
- primeXpraDesktopFrame(options = {}) {
1434
- if (options.reset) {
1435
- this.stopXpraDesktopPrime();
1436
- this._desktopPrimeAttempts = 0;
1437
- }
1438
- if (this.applyXpraDesktopFrameMode(options.frame || null)) return;
1439
- if (this._desktopPrimeAttempts >= XPRA_DESKTOP_PRIME_ATTEMPTS) return;
1440
- this._desktopPrimeAttempts += 1;
1441
- if (this._desktopPrimeTimer) globalThis.clearTimeout(this._desktopPrimeTimer);
1442
- this._desktopPrimeTimer = globalThis.setTimeout(() => {
1443
- this._desktopPrimeTimer = null;
1444
- this.primeXpraDesktopFrame();
1445
- }, XPRA_DESKTOP_PRIME_INTERVAL_MS);
1446
- },
1447
-
1448
- stopXpraDesktopPrime() {
1449
- if (this._desktopPrimeTimer) globalThis.clearTimeout(this._desktopPrimeTimer);
1450
- this._desktopPrimeTimer = null;
1451
- },
1452
-
1453
- applyXpraDesktopFrameMode(preferredFrame = null, options = {}) {
1454
- const frame = this.desktopFrame(preferredFrame);
1455
- const remoteWindow = frame?.contentWindow;
1456
- if (!remoteWindow) return false;
1457
- const requestServerResize = options.requestServerResize === true;
1458
- const requestRefresh = options.requestRefresh !== false;
1459
- try {
1460
- const remoteDocument = frame.contentDocument || remoteWindow.document;
1461
- this.installXpraDesktopFrameCss(remoteDocument);
1462
- this.installXpraDesktopFramePatches(remoteWindow, remoteDocument);
1463
- const client = remoteWindow.client;
1464
- if (!client) return false;
1465
- this.installXpraDesktopClientPatches(remoteWindow, client);
1466
- this.installXpraDesktopCursorPatches(remoteWindow, remoteDocument, client);
1467
- this.installXpraDesktopKeyboardBridge(frame, remoteWindow, remoteDocument, client);
1468
- this.installXpraDesktopClipboardBridge(frame, remoteWindow, remoteDocument, client);
1469
- const container = client.container || remoteDocument?.querySelector?.("#screen");
1470
- if (!container) return false;
1471
-
1472
- client.server_is_desktop = true;
1473
- client.server_resize_exact = true;
1474
- remoteDocument?.body?.classList?.add("desktop");
1475
-
1476
- const windows = Object.values(client.id_to_window || {});
1477
- if (!client.connected || !windows.length) return false;
1478
-
1479
- const width = Math.round(container.clientWidth || remoteWindow.innerWidth || 0);
1480
- const height = Math.round(container.clientHeight || remoteWindow.innerHeight || 0);
1481
- if (width > 0 && height > 0) {
1482
- client.desktop_width = width;
1483
- client.desktop_height = height;
1484
- }
1485
- if (requestServerResize && width > 0 && height > 0 && typeof client._screen_resized === "function") {
1486
- client.desktop_width = 0;
1487
- client.desktop_height = 0;
1488
- client.__a0AllowScreenResize = true;
1489
- try {
1490
- client._screen_resized(new remoteWindow.Event("resize"));
1491
- } finally {
1492
- client.__a0AllowScreenResize = false;
1493
- }
1494
- }
1495
-
1496
- for (const xpraWindow of windows) {
1497
- this.normalizeXpraDesktopWindow(xpraWindow, width, height);
1498
- xpraWindow.screen_resized?.();
1499
- this.normalizeXpraDesktopWindow(xpraWindow, width, height);
1500
- xpraWindow.updateCSSGeometry?.();
1501
- this.fitXpraDesktopWindowElement(xpraWindow, width, height);
1502
- this.installXpraDesktopWheelBridge(remoteWindow, xpraWindow);
1503
- if (requestRefresh && xpraWindow.wid != null) client.request_refresh?.(xpraWindow.wid);
1504
- }
1505
- this.installXpraDesktopAgentBridge(frame, remoteWindow, remoteDocument, client, container);
1506
- return true;
1507
- } catch (error) {
1508
- console.warn("Xpra desktop viewport prime skipped", error);
1509
- return false;
1510
- }
1511
- },
1512
-
1513
- installXpraDesktopAgentBridge(frame, remoteWindow, remoteDocument, client, container) {
1514
- if (!frame || !remoteWindow || !remoteDocument || !client) return null;
1515
- const store = this;
1516
- const finite = (value, fallback = 0) => {
1517
- const number = Number(value);
1518
- return Number.isFinite(number) ? number : fallback;
1519
- };
1520
- const metrics = () => {
1521
- const desktopWidth = Math.max(1, finite(client.desktop_width || container?.clientWidth || remoteWindow.innerWidth, 1));
1522
- const desktopHeight = Math.max(1, finite(client.desktop_height || container?.clientHeight || remoteWindow.innerHeight, 1));
1523
- const clientWidth = Math.max(1, finite(container?.clientWidth || remoteWindow.innerWidth, desktopWidth));
1524
- const clientHeight = Math.max(1, finite(container?.clientHeight || remoteWindow.innerHeight, desktopHeight));
1525
- return {
1526
- desktopWidth,
1527
- desktopHeight,
1528
- clientWidth,
1529
- clientHeight,
1530
- scaleX: clientWidth / desktopWidth,
1531
- scaleY: clientHeight / desktopHeight,
1532
- };
1533
- };
1534
- const bridge = frame.__agentZeroDesktopBridge || {};
1535
- Object.assign(bridge, {
1536
- ready: true,
1537
- state: async (options = {}) => {
1538
- const result = await callOffice("desktop_state", {
1539
- include_screenshot: options.includeScreenshot === true || options.include_screenshot === true,
1540
- });
1541
- store._desktopLastState = result;
1542
- return result;
1543
- },
1544
- focus: (options = {}) => store.focusDesktopFrame(frame, { ...options, arm: options.arm !== false }),
1545
- requestRefresh: () => {
1546
- for (const xpraWindow of Object.values(client.id_to_window || {})) {
1547
- if (xpraWindow?.wid != null) client.request_refresh?.(xpraWindow.wid);
1548
- }
1549
- return true;
1550
- },
1551
- desktopToClient: (x, y) => {
1552
- const value = metrics();
1553
- return {
1554
- x: Math.round(finite(x) * value.scaleX),
1555
- y: Math.round(finite(y) * value.scaleY),
1556
- scale_x: value.scaleX,
1557
- scale_y: value.scaleY,
1558
- };
1559
- },
1560
- clientToDesktop: (x, y) => {
1561
- const value = metrics();
1562
- return {
1563
- x: Math.round(finite(x) / value.scaleX),
1564
- y: Math.round(finite(y) / value.scaleY),
1565
- scale_x: value.scaleX,
1566
- scale_y: value.scaleY,
1567
- };
1568
- },
1569
- diagnostics: () => store.desktopBridgeDiagnostics(frame),
1570
- });
1571
- frame.agentZeroDesktop = bridge;
1572
- frame.__agentZeroDesktopBridge = bridge;
1573
- remoteWindow.agentZeroDesktop = bridge;
1574
- remoteWindow.__agentZeroDesktopBridge = bridge;
1575
- this._desktopBridgeReady = true;
1576
- this.updateDesktopKeyboardCaptureState(frame);
1577
- return bridge;
1578
- },
1579
-
1580
- desktopBridgeDiagnostics(frame = null) {
1581
- return {
1582
- ready: this._desktopBridgeReady,
1583
- keyboard: this.updateDesktopKeyboardCaptureState(frame),
1584
- lastStateOk: this._desktopLastState?.ok ?? null,
1585
- };
1586
- },
1587
-
1588
- updateDesktopKeyboardCaptureState(frame = null) {
1589
- const target = this.desktopFrame(frame);
1590
- const client = target?.contentWindow?.client;
1591
- const state = {
1592
- ready: Boolean(target?.__agentZeroDesktopBridge || target?.contentWindow?.__agentZeroDesktopBridge),
1593
- active: Boolean(this._desktopKeyboardActive),
1594
- capture: Boolean(client?.capture_keyboard),
1595
- focused: Boolean(target && (document.activeElement === target || target.contentDocument?.hasFocus?.())),
1596
- };
1597
- this._desktopKeyboardCaptureState = state;
1598
- return state;
1599
- },
1600
-
1601
- normalizeXpraDesktopWindow(xpraWindow, width, height) {
1602
- if (!xpraWindow) return;
1603
- const normalizedWidth = Math.max(1, Math.round(Number(width || 0)));
1604
- const normalizedHeight = Math.max(1, Math.round(Number(height || 0)));
1605
- xpraWindow.x = 0;
1606
- xpraWindow.y = 0;
1607
- xpraWindow.w = normalizedWidth;
1608
- xpraWindow.h = normalizedHeight;
1609
- xpraWindow.resizable = false;
1610
- xpraWindow.decorations = false;
1611
- xpraWindow.decorated = false;
1612
- xpraWindow.metadata = { ...(xpraWindow.metadata || {}), decorations: false };
1613
- xpraWindow._set_decorated?.(false);
1614
- xpraWindow.configure_border_class?.();
1615
- xpraWindow.leftoffset = 0;
1616
- xpraWindow.rightoffset = 0;
1617
- xpraWindow.topoffset = 0;
1618
- xpraWindow.bottomoffset = 0;
1619
- },
1620
-
1621
- fitXpraDesktopWindowElement(xpraWindow, width, height) {
1622
- const cssWidth = `${Math.max(1, Number(width || 0))}px`;
1623
- const cssHeight = `${Math.max(1, Number(height || 0))}px`;
1624
- const windowElement = xpraWindow?.div;
1625
- const canvas = xpraWindow?.canvas;
1626
- windowElement?.style?.setProperty("left", "0px", "important");
1627
- windowElement?.style?.setProperty("top", "0px", "important");
1628
- windowElement?.style?.setProperty("position", "absolute", "important");
1629
- windowElement?.style?.setProperty("width", cssWidth, "important");
1630
- windowElement?.style?.setProperty("height", cssHeight, "important");
1631
- windowElement?.style?.setProperty("transform", "none", "important");
1632
- windowElement?.style?.setProperty("margin", "0", "important");
1633
- canvas?.style?.setProperty("width", cssWidth, "important");
1634
- canvas?.style?.setProperty("height", cssHeight, "important");
1635
- canvas?.style?.setProperty("display", "block", "important");
1636
- canvas?.style?.setProperty("margin", "0", "important");
1637
- },
1638
-
1639
- installXpraDesktopWheelBridge(remoteWindow, xpraWindow) {
1640
- const canvas = xpraWindow?.canvas;
1641
- if (!remoteWindow || !canvas || canvas.__a0XpraWheelBridgeInstalled) return;
1642
- if (typeof xpraWindow.mouse_scroll_cb !== "function") return;
1643
- canvas.__a0XpraWheelBridgeInstalled = true;
1644
- canvas.addEventListener("wheel", (event) => {
1645
- event.stopImmediatePropagation?.();
1646
- event.stopPropagation?.();
1647
- event.preventDefault?.();
1648
- const normalizedEvent = this.xpraDesktopWheelEvent(remoteWindow, canvas, event);
1649
- xpraWindow.mouse_scroll_cb(normalizedEvent, xpraWindow);
1650
- }, { passive: false, capture: true });
1651
- },
1652
-
1653
- xpraDesktopWheelEvent(remoteWindow, canvas, event) {
1654
- const finite = (value, fallback = 0) => {
1655
- const number = Number(value);
1656
- return Number.isFinite(number) ? number : fallback;
1657
- };
1658
- const deltaMode = finite(event.deltaMode, 0);
1659
- const lineHeight = 16;
1660
- const pageHeight = Math.max(1, remoteWindow.innerHeight || canvas.clientHeight || 800);
1661
- const deltaScale = deltaMode === 1 ? lineHeight : deltaMode === 2 ? pageHeight : 1;
1662
- const deltaX = finite(event.deltaX) * deltaScale;
1663
- const deltaY = finite(event.deltaY) * deltaScale;
1664
- const deltaZ = finite(event.deltaZ) * deltaScale;
1665
- const wheelDeltaX = finite(event.wheelDeltaX, -deltaX);
1666
- const wheelDeltaY = finite(event.wheelDeltaY, -deltaY);
1667
- const wheelDelta = finite(event.wheelDelta, wheelDeltaY || wheelDeltaX);
1668
- const getModifierState = (key) => {
1669
- if (typeof event.getModifierState === "function") return event.getModifierState(key);
1670
- const normalizedKey = String(key || "").toLowerCase();
1671
- if (normalizedKey === "alt") return Boolean(event.altKey);
1672
- if (normalizedKey === "control") return Boolean(event.ctrlKey);
1673
- if (normalizedKey === "meta") return Boolean(event.metaKey);
1674
- if (normalizedKey === "shift") return Boolean(event.shiftKey);
1675
- return false;
1676
- };
1677
- const normalizedEvent = Object.create(event);
1678
- Object.defineProperties(normalizedEvent, {
1679
- target: { value: event.target || canvas },
1680
- currentTarget: { value: canvas },
1681
- clientX: { value: finite(event.clientX) },
1682
- clientY: { value: finite(event.clientY) },
1683
- pageX: { value: finite(event.pageX, finite(event.clientX)) },
1684
- pageY: { value: finite(event.pageY, finite(event.clientY)) },
1685
- screenX: { value: finite(event.screenX) },
1686
- screenY: { value: finite(event.screenY) },
1687
- offsetX: { value: finite(event.offsetX) },
1688
- offsetY: { value: finite(event.offsetY) },
1689
- movementX: { value: finite(event.movementX) },
1690
- movementY: { value: finite(event.movementY) },
1691
- button: { value: finite(event.button) },
1692
- buttons: { value: finite(event.buttons) },
1693
- which: { value: finite(event.which) },
1694
- detail: { value: finite(event.detail) },
1695
- deltaX: { value: deltaX },
1696
- deltaY: { value: deltaY },
1697
- deltaZ: { value: deltaZ },
1698
- deltaMode: { value: 0 },
1699
- wheelDeltaX: { value: wheelDeltaX },
1700
- wheelDeltaY: { value: wheelDeltaY },
1701
- wheelDelta: { value: wheelDelta },
1702
- altKey: { value: Boolean(event.altKey) },
1703
- ctrlKey: { value: Boolean(event.ctrlKey) },
1704
- metaKey: { value: Boolean(event.metaKey) },
1705
- shiftKey: { value: Boolean(event.shiftKey) },
1706
- getModifierState: { value: getModifierState },
1707
- preventDefault: { value: () => event.preventDefault?.() },
1708
- stopPropagation: { value: () => event.stopPropagation?.() },
1709
- stopImmediatePropagation: { value: () => event.stopImmediatePropagation?.() },
1710
- });
1711
- return normalizedEvent;
1712
- },
1713
-
1714
- installXpraDesktopFrameCss(remoteDocument) {
1715
- if (!remoteDocument || remoteDocument.getElementById("a0-xpra-desktop-frame-css")) return;
1716
- const style = remoteDocument.createElement("style");
1717
- style.id = "a0-xpra-desktop-frame-css";
1718
- style.textContent = `
1719
- html, body, #screen {
1720
- width: 100% !important;
1721
- height: 100% !important;
1722
- overflow: hidden !important;
1723
- }
1724
- #float_menu,
1725
- .windowhead,
1726
- .windowbuttons {
1727
- display: none !important;
1728
- }
1729
- #shadow_pointer {
1730
- display: none !important;
1731
- visibility: hidden !important;
1732
- opacity: 0 !important;
1733
- }
1734
- .window,
1735
- .window.border,
1736
- .window.desktop,
1737
- .undecorated,
1738
- .undecorated.border,
1739
- .undecorated.desktop {
1740
- left: 0 !important;
1741
- top: 0 !important;
1742
- position: absolute !important;
1743
- width: 100% !important;
1744
- height: 100% !important;
1745
- transform: none !important;
1746
- margin: 0 !important;
1747
- border: 0 !important;
1748
- border-radius: 0 !important;
1749
- box-shadow: none !important;
1750
- }
1751
- .window canvas,
1752
- .undecorated canvas {
1753
- display: block !important;
1754
- width: 100% !important;
1755
- height: 100% !important;
1756
- margin: 0 !important;
1757
- border: 0 !important;
1758
- border-radius: 0 !important;
1759
- box-shadow: none !important;
1760
- }
1761
- `;
1762
- remoteDocument.head?.appendChild(style);
1763
- },
1764
-
1765
- installXpraDesktopCursorPatches(remoteWindow, remoteDocument, client) {
1766
- if (!remoteWindow || !remoteDocument || !client) return;
1767
- const hideShadowPointer = () => {
1768
- const pointer = remoteDocument.getElementById?.("shadow_pointer");
1769
- pointer?.style?.setProperty("display", "none", "important");
1770
- pointer?.style?.setProperty("visibility", "hidden", "important");
1771
- pointer?.style?.setProperty("opacity", "0", "important");
1772
- };
1773
- hideShadowPointer();
1774
-
1775
- const pointerPacket = remoteWindow.PACKET_TYPES?.pointer_position || "pointer-position";
1776
- if (!client.__a0XpraDesktopCursorPatched) {
1777
- if (typeof client._process_pointer_position === "function") {
1778
- client.__a0OriginalProcessPointerPosition = client._process_pointer_position;
1779
- }
1780
- client._process_pointer_position = function patchedProcessPointerPosition(packet) {
1781
- hideShadowPointer();
1782
- this.__a0LastPointerPosition = packet;
1783
- return false;
1784
- };
1785
- client.__a0XpraDesktopCursorPatched = true;
1786
- }
1787
- if (client.packet_handlers && pointerPacket) {
1788
- client.packet_handlers[pointerPacket] = client._process_pointer_position;
1789
- }
1790
- },
1791
-
1792
- installXpraDesktopFramePatches(remoteWindow, remoteDocument) {
1793
- if (!remoteWindow || !remoteDocument) return;
1794
- remoteWindow.__a0XpraDesktopFramePatches ||= {};
1795
- const patches = remoteWindow.__a0XpraDesktopFramePatches;
1796
- if (!patches.noWindowList && typeof remoteWindow.noWindowList === "function") {
1797
- const originalNoWindowList = remoteWindow.noWindowList;
1798
- remoteWindow.noWindowList = function patchedNoWindowList(...args) {
1799
- if (!remoteDocument.querySelector("#open_windows")) return undefined;
1800
- return originalNoWindowList.apply(this, args);
1801
- };
1802
- patches.noWindowList = true;
1803
- }
1804
- if (!patches.addWindowListItem && typeof remoteWindow.addWindowListItem === "function") {
1805
- const originalAddWindowListItem = remoteWindow.addWindowListItem;
1806
- remoteWindow.addWindowListItem = function patchedAddWindowListItem(...args) {
1807
- if (!remoteDocument.querySelector("#open_windows_list")) return undefined;
1808
- return originalAddWindowListItem.apply(this, args);
1809
- };
1810
- patches.addWindowListItem = true;
1811
- }
1812
- },
1813
-
1814
- installXpraDesktopClientPatches(remoteWindow, client) {
1815
- if (!remoteWindow || !client || client.__a0XpraDesktopClientPatched) return;
1816
- if (typeof client._screen_resized === "function") {
1817
- const originalScreenResized = client._screen_resized.bind(client);
1818
- client.__a0OriginalScreenResized = originalScreenResized;
1819
- client._screen_resized = function patchedScreenResized(event) {
1820
- if (client.__a0AllowScreenResize === true) return originalScreenResized(event);
1821
- return false;
1822
- };
1823
- }
1824
- client.__a0XpraDesktopClientPatched = true;
1825
- },
1826
-
1827
- installXpraDesktopClipboardBridge(frame, remoteWindow, remoteDocument, client) {
1828
- if (!frame || !remoteWindow || !remoteDocument || !client) return;
1829
- this.ensureDesktopClipboardBridge();
1830
- if (remoteWindow.__a0XpraDesktopClipboardBridgeInstalled) return;
1831
-
1832
- const onPaste = (event) => {
1833
- this.handleDesktopPasteEvent(event, frame, remoteWindow, client);
1834
- };
1835
- const onKeydown = (event) => {
1836
- if (this.isDesktopPasteShortcut(event)) {
1837
- void this.syncHostClipboardToDesktop(frame);
1838
- }
1839
- };
1840
- remoteWindow.addEventListener("paste", onPaste, true);
1841
- remoteDocument.addEventListener("paste", onPaste, true);
1842
- remoteWindow.addEventListener("keydown", onKeydown, true);
1843
- remoteDocument.addEventListener("keydown", onKeydown, true);
1844
- remoteWindow.__a0XpraDesktopClipboardBridgeInstalled = true;
1845
- remoteWindow.__a0XpraDesktopClipboardBridgeCleanup = () => {
1846
- remoteWindow.removeEventListener("paste", onPaste, true);
1847
- remoteDocument.removeEventListener("paste", onPaste, true);
1848
- remoteWindow.removeEventListener("keydown", onKeydown, true);
1849
- remoteDocument.removeEventListener("keydown", onKeydown, true);
1850
- remoteWindow.__a0XpraDesktopClipboardBridgeInstalled = false;
1851
- };
1852
- },
1853
-
1854
- ensureDesktopClipboardBridge() {
1855
- if (this._desktopClipboardCleanup) return;
1856
-
1857
- const onPaste = (event) => {
1858
- if (!this._desktopKeyboardActive || !this.hasOfficialOffice()) return;
1859
- if (isEditableInputTarget(event.target)) return;
1860
- const frame = this.desktopFrame();
1861
- const remoteWindow = frame?.contentWindow;
1862
- const client = remoteWindow?.client;
1863
- if (!frame || !remoteWindow || !client) return;
1864
- this.handleDesktopPasteEvent(event, frame, remoteWindow, client);
1865
- };
1866
-
1867
- document.addEventListener("paste", onPaste, true);
1868
- this._desktopClipboardCleanup = () => {
1869
- document.removeEventListener("paste", onPaste, true);
1870
- this._desktopClipboardCleanup = null;
1871
- };
1872
- },
1873
-
1874
- stopDesktopClipboardBridge() {
1875
- this._desktopClipboardCleanup?.();
1876
- },
1877
-
1878
- handleDesktopPasteEvent(event, frame, remoteWindow, client) {
1879
- const text = this.desktopClipboardTextFromEvent(event);
1880
- if (!text) return false;
1881
- if (!this.syncXpraClipboardText(client, text, remoteWindow)) return false;
1882
- event.preventDefault?.();
1883
- event.stopImmediatePropagation?.();
1884
- event.stopPropagation?.();
1885
- this.focusDesktopFrame(frame, { arm: true });
1886
- return true;
1887
- },
1888
-
1889
- desktopClipboardTextFromEvent(event) {
1890
- const data = (event?.originalEvent || event)?.clipboardData;
1891
- if (!data?.getData) return "";
1892
- for (const type of ["text/plain", "text", "Text", "STRING", "UTF8_STRING"]) {
1893
- const value = data.getData(type);
1894
- if (value) return value;
1895
- }
1896
- return "";
1897
- },
1898
-
1899
- syncXpraClipboardText(client, text, remoteWindow = null) {
1900
- const value = String(text ?? "");
1901
- if (!client || !value || typeof client.send_clipboard_token !== "function") return false;
1902
- const textPlain = remoteWindow?.TEXT_PLAIN || "text/plain";
1903
- const utf8String = remoteWindow?.UTF8_STRING || "UTF8_STRING";
1904
- const utilities = remoteWindow?.Utilities;
1905
- const payload = utilities?.StringToUint8 ? utilities.StringToUint8(value) : value;
1906
- client.clipboard_enabled = true;
1907
- client.clipboard_direction = "both";
1908
- client.clipboard_buffer = value;
1909
- client.clipboard_pending = false;
1910
- client.send_clipboard_token(payload, [textPlain, utf8String, "TEXT", "STRING"]);
1911
- return true;
1912
- },
1913
-
1914
- async syncHostClipboardToDesktop(frame = null) {
1915
- const target = this.desktopFrame(frame);
1916
- const remoteWindow = target?.contentWindow;
1917
- const client = remoteWindow?.client;
1918
- if (!client || !navigator.clipboard?.readText) return false;
1919
- try {
1920
- const text = await navigator.clipboard.readText();
1921
- return this.syncXpraClipboardText(client, text, remoteWindow);
1922
- } catch {
1923
- return false;
1924
- }
1925
- },
1926
-
1927
- isDesktopPasteShortcut(event) {
1928
- const key = String(event?.key || "").toLowerCase();
1929
- return key === "v" && (event?.ctrlKey || event?.metaKey) && !event?.altKey;
1930
- },
1931
-
1932
- installXpraDesktopKeyboardBridge(frame, remoteWindow, remoteDocument, client) {
1933
- if (!frame || !remoteWindow || !remoteDocument || !client) return;
1934
- this.ensureDesktopKeyboardBridge();
1935
- frame.setAttribute("tabindex", "0");
1936
- if (remoteWindow.__a0XpraDesktopKeyboardBridgeInstalled) return;
1937
-
1938
- const activate = () => {
1939
- if (this._desktopFocusInProgress) return;
1940
- this.focusDesktopFrame(frame, { arm: true });
1941
- };
1942
- const events = ["pointerdown", "mousedown", "touchstart", "focusin"];
1943
- for (const eventName of events) {
1944
- remoteDocument.addEventListener(eventName, activate, true);
1945
- }
1946
- remoteWindow.addEventListener("focus", activate, true);
1947
- remoteWindow.__a0XpraDesktopKeyboardBridgeInstalled = true;
1948
- remoteWindow.__a0XpraDesktopKeyboardBridgeCleanup = () => {
1949
- for (const eventName of events) {
1950
- remoteDocument.removeEventListener(eventName, activate, true);
1951
- }
1952
- remoteWindow.removeEventListener("focus", activate, true);
1953
- remoteWindow.__a0XpraDesktopKeyboardBridgeInstalled = false;
1954
- };
1955
- },
1956
-
1957
- ensureDesktopKeyboardBridge() {
1958
- if (this._desktopKeyboardCleanup) return;
1959
-
1960
- const deactivateWhenOutsideDesktop = (event) => {
1961
- const target = event.target;
1962
- if (target?.closest?.(".office-desktop-wrap") || target?.matches?.("[data-office-desktop-frame]")) return;
1963
- this._desktopKeyboardActive = false;
1964
- };
1965
- const forwardKeyboardEvent = (event, pressed) => {
1966
- if (!this._desktopKeyboardActive || !this.hasOfficialOffice()) return;
1967
- if (event.defaultPrevented || isEditableInputTarget(event.target)) return;
1968
-
1969
- const frame = this.desktopFrame();
1970
- if (!frame || document.activeElement === frame) return;
1971
- const client = frame.contentWindow?.client;
1972
- const handler = pressed ? client?._keyb_onkeydown : client?._keyb_onkeyup;
1973
- if (!client?.capture_keyboard || typeof handler !== "function") return;
1974
- if (pressed && this.isDesktopPasteShortcut(event)) {
1975
- void this.syncHostClipboardToDesktop(frame);
1976
- }
1977
-
1978
- const allowDefault = handler.call(client, event);
1979
- if (!allowDefault) {
1980
- event.preventDefault();
1981
- event.stopPropagation();
1982
- }
1983
- };
1984
- const onKeydown = (event) => forwardKeyboardEvent(event, true);
1985
- const onKeyup = (event) => forwardKeyboardEvent(event, false);
1986
-
1987
- document.addEventListener("pointerdown", deactivateWhenOutsideDesktop, true);
1988
- document.addEventListener("keydown", onKeydown, true);
1989
- document.addEventListener("keyup", onKeyup, true);
1990
- this._desktopKeyboardCleanup = () => {
1991
- document.removeEventListener("pointerdown", deactivateWhenOutsideDesktop, true);
1992
- document.removeEventListener("keydown", onKeydown, true);
1993
- document.removeEventListener("keyup", onKeyup, true);
1994
- this._desktopKeyboardActive = false;
1995
- this._desktopKeyboardCleanup = null;
1996
- };
1997
- },
1998
-
1999
- stopDesktopKeyboardBridge() {
2000
- this._desktopKeyboardCleanup?.();
2001
- },
2002
-
2003
- queueDesktopResize(options = {}) {
2004
- if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) return;
2005
- const token = this.session?.desktop?.token || "";
2006
- const frame = this.desktopFrame(options.frame || null);
2007
- const target = frame?.parentElement || frame;
2008
- if (!token || !target) return;
2009
- const force = Boolean(options.force);
2010
- const serverResize = options.serverResize !== false;
2011
- const rect = target.getBoundingClientRect();
2012
- const width = Math.round(rect.width);
2013
- const height = Math.round(rect.height);
2014
- if (width < 320 || height < 220) return;
2015
- const key = `${token}:${width}x${height}`;
2016
- const refreshFrameOnly = () => {
2017
- this.applyXpraDesktopFrameMode(frame, { requestServerResize: false, requestRefresh: false });
2018
- };
2019
- if (!serverResize) {
2020
- refreshFrameOnly();
2021
- return;
2022
- }
2023
- if (key === this._desktopResizeKey || key === this._desktopResizePendingKey) {
2024
- refreshFrameOnly();
2025
- return;
2026
- }
2027
- refreshFrameOnly();
2028
- if (!force && this.shouldDeferDesktopResize()) {
2029
- this._desktopResizePending = true;
2030
- return;
2031
- }
2032
- if (this._desktopResizeTimer) globalThis.clearTimeout(this._desktopResizeTimer);
2033
- this._desktopResizePendingKey = key;
2034
- this._desktopResizeTimer = globalThis.setTimeout(async () => {
2035
- this._desktopResizeTimer = null;
2036
- if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) {
2037
- if (this._desktopResizePendingKey === key) this._desktopResizePendingKey = "";
2038
- return;
2039
- }
2040
- if (!force && this.shouldDeferDesktopResize()) {
2041
- if (this._desktopResizePendingKey === key) this._desktopResizePendingKey = "";
2042
- this._desktopResizePending = true;
2043
- return;
2044
- }
2045
- try {
2046
- const params = new URLSearchParams({ token, width: String(width), height: String(height) });
2047
- const response = await fetch(`/desktop/resize?${params.toString()}`, { credentials: "same-origin" });
2048
- if (response.ok) {
2049
- const result = await response.json().catch(() => ({}));
2050
- this._desktopResizeKey = key;
2051
- const activeFrame = this.desktopFrame(frame);
2052
- const activeTarget = activeFrame?.parentElement || activeFrame;
2053
- const activeRect = activeTarget?.getBoundingClientRect?.();
2054
- const activeWidth = Math.round(activeRect?.width || 0);
2055
- const activeHeight = Math.round(activeRect?.height || 0);
2056
- if (activeWidth >= 320 && activeHeight >= 220) {
2057
- const activeKey = `${token}:${activeWidth}x${activeHeight}`;
2058
- if (activeKey !== key) {
2059
- this.queueDesktopResize({ force: true, serverResize: true, frame: activeFrame });
2060
- return;
2061
- }
2062
- }
2063
- if (result?.reload) this.reloadDesktopFrame(activeFrame || frame);
2064
- this.primeXpraDesktopFrame({ reset: true, frame: activeFrame || frame });
2065
- }
2066
- } catch (error) {
2067
- console.warn("Desktop resize skipped", error);
2068
- } finally {
2069
- if (this._desktopResizePendingKey === key) this._desktopResizePendingKey = "";
2070
- }
2071
- }, DESKTOP_RESIZE_DELAY_MS);
2072
- },
2073
-
2074
- reloadDesktopFrame(frame = null) {
2075
- const target = this.desktopFrame(frame);
2076
- if (!target) return;
2077
- const current = target.getAttribute("src") || target.src || this.officialOfficeUrl();
2078
- if (!current) return;
2079
- try {
2080
- const url = new URL(current, window.location.href);
2081
- url.searchParams.set("a0_reload", String(Date.now()));
2082
- target.setAttribute("src", `${url.pathname}${url.search}`);
2083
- } catch {
2084
- target.setAttribute("src", current);
2085
- }
2086
- },
2087
-
2088
- async handleDesktopUrlIntents(intents = []) {
2089
- const incoming = Array.isArray(intents)
2090
- ? intents.filter((intent) => intent && typeof intent === "object")
2091
- : [];
2092
- if (!incoming.length) return;
2093
- this._desktopUrlIntentQueue.push(...incoming);
2094
- if (this._desktopUrlIntentBusy) return;
2095
-
2096
- this._desktopUrlIntentBusy = true;
2097
- try {
2098
- while (this._desktopUrlIntentQueue.length) {
2099
- const intent = this._desktopUrlIntentQueue.shift();
2100
- await this.openDesktopUrlIntent(intent);
2101
- }
2102
- } finally {
2103
- this._desktopUrlIntentBusy = false;
2104
- }
2105
- },
2106
-
2107
- async openDesktopUrlIntent(intent = {}) {
2108
- const url = String(intent?.url || "").trim();
2109
- const destination = this.browserDestinationForDesktopUrl();
2110
- if (destination === "canvas") {
2111
- await this.openBrowserCanvasForDesktopUrl(url);
2112
- } else {
2113
- await this.openBrowserModalForDesktopUrl(url);
2114
- }
2115
- this.setMessage(url ? "Opened link in Browser" : "Opened Browser");
2116
- },
2117
-
2118
- browserDestinationForDesktopUrl() {
2119
- if (this.isDesktopInModal()) return "canvas";
2120
- return "modal";
2121
- },
2122
-
2123
- isDesktopInModal() {
2124
- if (isModalPathOpen(OFFICE_MODAL_PATH)) return true;
2125
- const modalDesktop = Array.from(document.querySelectorAll(".office-panel"))
2126
- .some((panel) => panel.closest?.(".modal") && panel.querySelector?.("[data-office-desktop-frame]"));
2127
- if (modalDesktop) return true;
2128
- if (rightCanvasStore?.isOpen && rightCanvasStore.activeSurfaceId === "office") return false;
2129
- return this._mode === "modal";
2130
- },
2131
-
2132
- async openBrowserCanvasForDesktopUrl(url = "") {
2133
- if (rightCanvasStore?.isMobileMode) {
2134
- await this.openBrowserModalForDesktopUrl(url);
2135
- return;
2136
- }
2137
- const payload = { url, source: "desktop-url" };
2138
- let opened = false;
2139
- if (isModalPathOpen(BROWSER_MODAL_PATH)) {
2140
- opened = await rightCanvasStore.dockSurface?.("browser", {
2141
- ...payload,
2142
- modalPath: BROWSER_MODAL_PATH,
2143
- sourceModalPath: BROWSER_MODAL_PATH,
2144
- });
2145
- } else {
2146
- opened = await rightCanvasStore.open?.("browser", payload);
2147
- }
2148
- if (!opened) {
2149
- await this.openBrowserModalForDesktopUrl(url);
2150
- return;
2151
- }
2152
- if (browserStore?.openUrlIntent) {
2153
- await browserStore.openUrlIntent(url);
2154
- }
2155
- },
2156
-
2157
- async openBrowserModalForDesktopUrl(url = "") {
2158
- if (rightCanvasStore?.isOpen && rightCanvasStore.activeSurfaceId === "browser") {
2159
- await rightCanvasStore.openModalSurface?.("browser", { modalPath: BROWSER_MODAL_PATH });
2160
- } else {
2161
- const openModal = globalThis.ensureModalOpen || globalThis.openModal;
2162
- const modalPromise = openModal?.(BROWSER_MODAL_PATH);
2163
- if (modalPromise?.catch) {
2164
- modalPromise.catch((error) => console.error("Browser modal open failed", error));
2165
- }
2166
- }
2167
- const panel = await waitForElementByPredicate(() => browserPanelForMode("modal"));
2168
- if (panel && browserStore?.onOpen) {
2169
- await browserStore.onOpen(panel, { mode: "modal" });
2170
- }
2171
- if (browserStore?.openUrlIntent) {
2172
- await browserStore.openUrlIntent(url);
2173
- }
2174
- },
2175
-
2176
- startDesktopMonitor() {
2177
- this.stopDesktopMonitor();
2178
- if (!this.hasOfficialOffice() || !this.isDesktopHostVisible()) return;
2179
- const tabId = this.session?.tab_id || "";
2180
- const sessionId = this.session?.desktop_session_id || this.session?.session_id || "";
2181
- if (!tabId || !sessionId) return;
2182
- this._desktopHeartbeatSessionId = sessionId;
2183
- this._desktopHeartbeatTabId = tabId;
2184
- this._desktopHeartbeatMisses = 0;
2185
-
2186
- const tick = async () => {
2187
- if (!this.session || this.session.tab_id !== tabId || !this.hasOfficialOffice() || !this.isDesktopHostVisible()) return;
2188
- try {
2189
- const response = await callOffice("desktop_sync", {
2190
- desktop_session_id: sessionId,
2191
- file_id: this.session.file_id || "",
2192
- });
2193
- if (response?.intentional_shutdown || response?.shutdown) {
2194
- await this.handleIntentionalDesktopShutdown(response);
2195
- return;
2196
- }
2197
- if (response?.ok === false) throw new Error(response.error || "Desktop session closed.");
2198
- this._desktopHeartbeatMisses = 0;
2199
- await this.handleDesktopUrlIntents(response?.url_intents);
2200
- if (response?.document) {
2201
- const document = normalizeDocument(response.document);
2202
- this.replaceActiveSession({
2203
- ...this.session,
2204
- document,
2205
- path: document.path || this.session.path,
2206
- file_id: document.file_id || this.session.file_id,
2207
- version: document.version || this.session.version,
2208
- });
2209
- }
2210
- } catch {
2211
- if (!this.session || this.session.tab_id !== tabId) return;
2212
- this._desktopHeartbeatMisses += 1;
2213
- if (this._desktopHeartbeatMisses >= 2) {
2214
- await this.handleOfficialOfficeClosed(tabId);
2215
- }
2216
- }
2217
- };
2218
-
2219
- this._desktopHeartbeatTimer = globalThis.setInterval(tick, DESKTOP_HEARTBEAT_MS);
2220
- globalThis.setTimeout(tick, Math.min(1200, DESKTOP_HEARTBEAT_MS));
2221
- },
2222
-
2223
- stopDesktopMonitor() {
2224
- if (this._desktopHeartbeatTimer) {
2225
- globalThis.clearInterval(this._desktopHeartbeatTimer);
2226
- }
2227
- this._desktopHeartbeatTimer = null;
2228
- this._desktopHeartbeatSessionId = "";
2229
- this._desktopHeartbeatTabId = "";
2230
- this._desktopHeartbeatMisses = 0;
2231
- },
2232
-
2233
- async handleOfficialOfficeClosed(tabId) {
2234
- if (this._desktopIntentionalShutdown) return;
2235
- const tab = this.tabs.find((item) => item.tab_id === tabId);
2236
- const hiddenDesktopDocument = !tab && this.session?.tab_id === tabId && this.isDesktopOfficeDocument(this.session)
2237
- ? this.session
2238
- : null;
2239
- const target = tab || hiddenDesktopDocument;
2240
- if (!target || target._desktopClosed) return;
2241
- target._desktopClosed = true;
2242
- this.stopDesktopMonitor();
2243
- this.stopDesktopResizeObserver();
2244
- this.stopXpraDesktopPrime();
2245
- this.message = "Desktop is restarting";
2246
- await this.ensureDesktopSession({
2247
- force: true,
2248
- select: this.activeTabId === tabId || Boolean(hiddenDesktopDocument),
2249
- message: "Desktop is restarting",
2250
- });
2251
- target._desktopClosed = false;
2252
- await this.refresh();
605
+ return this.tabs.filter((tab) => this.hasActiveFile(tab));
606
},
607
608
defaultTitle(kind, fmt) {
@@ -2276,7 +629,6 @@ const model = {
629
tabIcon(tab = {}) {
630
tab = tab || {};
631
const ext = String(tab.extension || tab.document?.extension || "").toLowerCase();
2279
- if (this.isDesktopSession(tab)) return "desktop_windows";
632
if (ext === "md") return "article";
633
if (ext === "odt" || ext === "docx") return "description";
634
if (ext === "ods" || ext === "xlsx") return "table_chart";
@@ -2284,6 +636,17 @@ const model = {
636
return "draft";
637
},
638
639
+ async openActiveInDesktop() {
640
+ const target = this.session?.document || this.session;
641
+ if (!target?.path && !target?.file_id) return;
642
+ await openSurface("desktop", {
643
+ path: target.path || "",
644
+ file_id: target.file_id || "",
645
+ refresh: true,
646
+ source: "office-explicit-action",
647
+ });
648
+ },
649
+
650
async runNewMenuAction(action = "") {
651
const normalized = String(action || "").trim().toLowerCase();
652
if (normalized === "open") return await this.openFileBrowser();
@@ -2297,7 +660,7 @@ const model = {
660
installHeaderNewMenu(header = null) {
661
if (!header || header.querySelector(".office-header-actions")) return () => {};
662
2300
- const root = globalThis.document.createElement("div");
663
+ const root = document.createElement("div");
664
root.className = "office-header-actions";
665
root.innerHTML = `
666
<button type="button" class="office-header-new-button" aria-haspopup="menu" aria-expanded="false">
@@ -2358,12 +721,10 @@ const model = {
721
await this.runNewMenuAction(action);
722
});
723
}
2361
- globalThis.document.addEventListener("click", onDocumentClick);
2362
- globalThis.document.addEventListener("keydown", onDocumentKeydown);
724
+ document.addEventListener("click", onDocumentClick);
725
+ document.addEventListener("keydown", onDocumentKeydown);
726
2364
- const firstHeaderAction = header.querySelector(
2365
- ".modal-surface-switcher, .modal-dock-button, .office-modal-focus-button, .modal-close",
2366
- );
727
+ const firstHeaderAction = header.querySelector(".modal-close");
728
if (firstHeaderAction) {
729
firstHeaderAction.insertAdjacentElement("beforebegin", root);
730
} else {
@@ -2373,278 +734,28 @@ const model = {
734
setOpen(false);
735
return () => {
736
button?.removeEventListener("click", onButtonClick);
2376
- globalThis.document.removeEventListener("click", onDocumentClick);
2377
- globalThis.document.removeEventListener("keydown", onDocumentKeydown);
737
+ document.removeEventListener("click", onDocumentClick);
738
+ document.removeEventListener("keydown", onDocumentKeydown);
739
root.remove();
740
};
741
},
742
2382
- setupFloatingModal(element = null) {
2383
- const root = element || globalThis.document?.querySelector(".office-panel");
2384
- const modal = root?.closest?.(".modal");
743
+ setupDocumentModal(element = null) {
744
+ const root = element || document.querySelector(".office-panel");
745
const inner = root?.closest?.(".modal-inner");
2386
- const body = root?.closest?.(".modal-bd");
746
const header = inner?.querySelector?.(".modal-header");
2388
- if (!inner || !body || !header || inner.dataset.officeModalReady === "1") return;
2389
-
747
+ if (!inner || !header || inner.dataset.officeModalReady === "1") return;
748
inner.dataset.officeModalReady = "1";
2391
- modal?.classList?.add("modal-floating", "modal-no-backdrop");
2392
- inner.classList.add("office-modal", "modal-no-backdrop");
2393
- body.classList.add("office-modal-body");
2394
- header.style.cursor = "move";
2395
-
2396
- const inset = 8;
2397
- const minWidth = 720;
2398
- const minHeight = 520;
2399
- const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
2400
- const cleanup = [];
2401
- let beforeFocusBounds = null;
2402
- let dragging = false;
2403
- let resizing = false;
2404
- let pointerId = 0;
2405
- let startX = 0;
2406
- let startY = 0;
2407
- let startLeft = 0;
2408
- let startTop = 0;
2409
- let startWidth = 0;
2410
- let startHeight = 0;
2411
- let resizeMode = "";
2412
-
2413
- const newMenuCleanup = this.installHeaderNewMenu(header);
2414
-
2415
- const currentBounds = () => {
2416
- const rect = inner.getBoundingClientRect();
2417
- return {
2418
- left: rect.left,
2419
- top: rect.top,
2420
- width: rect.width,
2421
- height: rect.height,
2422
- };
2423
- };
2424
-
2425
- const normalizedBounds = (bounds) => {
2426
- const maxWidth = Math.max(320, globalThis.innerWidth - inset * 2);
2427
- const maxHeight = Math.max(320, globalThis.innerHeight - inset * 2);
2428
- const safeMinWidth = Math.min(minWidth, maxWidth);
2429
- const safeMinHeight = Math.min(minHeight, maxHeight);
2430
- const width = clamp(bounds.width, safeMinWidth, maxWidth);
2431
- const height = clamp(bounds.height, safeMinHeight, maxHeight);
2432
- return {
2433
- width,
2434
- height,
2435
- left: clamp(bounds.left, inset, Math.max(inset, globalThis.innerWidth - width - inset)),
2436
- top: clamp(bounds.top, inset, Math.max(inset, globalThis.innerHeight - height - inset)),
2437
- };
2438
- };
2439
-
2440
- const setBounds = (bounds) => {
2441
- const next = normalizedBounds(bounds);
2442
- inner.style.position = "fixed";
2443
- inner.style.transform = "none";
2444
- inner.style.left = `${Math.round(next.left)}px`;
2445
- inner.style.top = `${Math.round(next.top)}px`;
2446
- inner.style.width = `${Math.round(next.width)}px`;
2447
- inner.style.height = `${Math.round(next.height)}px`;
2448
- inner.style.right = "auto";
2449
- inner.style.bottom = "auto";
2450
- inner.style.margin = "0";
2451
- };
2452
-
2453
- const ensurePosition = () => {
2454
- setBounds(currentBounds());
2455
- };
2456
-
2457
- const shield = globalThis.document.createElement("div");
2458
- shield.className = "office-modal-input-shield";
2459
- inner.appendChild(shield);
2460
- cleanup.push(() => shield.remove());
2461
-
2462
- const setShield = (visible, cursor = "") => {
2463
- shield.style.display = visible ? "block" : "none";
2464
- shield.style.cursor = cursor;
2465
- };
2466
-
2467
- const focusButton = globalThis.document.createElement("button");
2468
- focusButton.type = "button";
2469
- focusButton.className = "modal-dock-button office-modal-focus-button";
2470
- focusButton.innerHTML = '<span class="material-symbols-outlined" aria-hidden="true">fullscreen</span>';
2471
- const updateFocusButton = (active) => {
2472
- const label = active ? "Restore size" : "Focus mode";
2473
- focusButton.setAttribute("aria-label", label);
2474
- focusButton.querySelector(".material-symbols-outlined").textContent = active ? "fullscreen_exit" : "fullscreen";
2475
- };
2476
- updateFocusButton(false);
2477
- const closeButton = inner.querySelector(".modal-close");
2478
- if (closeButton) {
2479
- closeButton.insertAdjacentElement("beforebegin", focusButton);
2480
- } else {
2481
- header.appendChild(focusButton);
2482
- }
2483
- cleanup.push(() => focusButton.remove());
2484
-
2485
- const setFocusMode = (enabled) => {
2486
- ensurePosition();
2487
- if (enabled) {
2488
- beforeFocusBounds = currentBounds();
2489
- inner.classList.add("is-focus-mode");
2490
- setBounds({
2491
- left: inset,
2492
- top: inset,
2493
- width: globalThis.innerWidth - inset * 2,
2494
- height: globalThis.innerHeight - inset * 2,
2495
- });
2496
- updateFocusButton(true);
2497
- return;
2498
- }
2499
- inner.classList.remove("is-focus-mode");
2500
- setBounds(beforeFocusBounds || currentBounds());
2501
- beforeFocusBounds = null;
2502
- updateFocusButton(false);
2503
- };
2504
-
2505
- const onFocusClick = () => setFocusMode(!inner.classList.contains("is-focus-mode"));
2506
- focusButton.addEventListener("click", onFocusClick);
2507
- cleanup.push(() => focusButton.removeEventListener("click", onFocusClick));
2508
-
2509
- const onPointerDown = (event) => {
2510
- if (event.button !== 0) return;
2511
- if (event.target?.closest?.("button,a,input,textarea,select")) return;
2512
- if (inner.classList.contains("is-focus-mode")) return;
2513
- ensurePosition();
2514
- const rect = inner.getBoundingClientRect();
2515
- dragging = true;
2516
- pointerId = event.pointerId;
2517
- startX = event.clientX;
2518
- startY = event.clientY;
2519
- startLeft = rect.left;
2520
- startTop = rect.top;
2521
- startWidth = rect.width;
2522
- startHeight = rect.height;
2523
- inner.classList.add("is-dragging");
2524
- setShield(true, "move");
2525
- header.setPointerCapture?.(pointerId);
2526
- event.preventDefault();
2527
- };
2528
-
2529
- const onPointerMove = (event) => {
2530
- if (!dragging || event.pointerId !== pointerId) return;
2531
- setBounds({
2532
- left: startLeft + event.clientX - startX,
2533
- top: startTop + event.clientY - startY,
2534
- width: startWidth,
2535
- height: startHeight,
2536
- });
2537
- };
2538
-
2539
- const onPointerUp = (event) => {
2540
- if (!dragging || event.pointerId !== pointerId) return;
2541
- dragging = false;
2542
- inner.classList.remove("is-dragging");
2543
- setShield(false);
2544
- header.releasePointerCapture?.(pointerId);
2545
- };
2546
-
2547
- const createResizeHandle = (mode) => {
2548
- const handle = globalThis.document.createElement("div");
2549
- handle.className = `office-modal-resizer is-${mode}`;
2550
- handle.dataset.officeResize = mode;
2551
- inner.appendChild(handle);
2552
- cleanup.push(() => handle.remove());
2553
- return handle;
2554
- };
2555
-
2556
- const onResizeDown = (event) => {
2557
- if (event.button !== 0 || inner.classList.contains("is-focus-mode")) return;
2558
- ensurePosition();
2559
- const rect = inner.getBoundingClientRect();
2560
- resizing = true;
2561
- resizeMode = event.currentTarget.dataset.officeResize || "";
2562
- pointerId = event.pointerId;
2563
- startX = event.clientX;
2564
- startY = event.clientY;
2565
- startLeft = rect.left;
2566
- startTop = rect.top;
2567
- startWidth = rect.width;
2568
- startHeight = rect.height;
2569
- inner.classList.add("is-resizing");
2570
- this.suspendDesktopResize();
2571
- setShield(true, resizeMode === "right" ? "ew-resize" : resizeMode === "bottom" ? "ns-resize" : "nwse-resize");
2572
- event.currentTarget.setPointerCapture?.(pointerId);
2573
- event.preventDefault();
2574
- event.stopPropagation();
2575
- };
2576
-
2577
- const onResizeMove = (event) => {
2578
- if (!resizing || event.pointerId !== pointerId) return;
2579
- const dx = event.clientX - startX;
2580
- const dy = event.clientY - startY;
2581
- setBounds({
2582
- left: startLeft,
2583
- top: startTop,
2584
- width: resizeMode === "bottom" ? startWidth : startWidth + dx,
2585
- height: resizeMode === "right" ? startHeight : startHeight + dy,
2586
- });
2587
- };
2588
-
2589
- const onResizeUp = (event) => {
2590
- if (!resizing || event.pointerId !== pointerId) return;
2591
- resizing = false;
2592
- resizeMode = "";
2593
- inner.classList.remove("is-resizing");
2594
- setShield(false);
2595
- event.currentTarget.releasePointerCapture?.(pointerId);
2596
- this.resumeDesktopResize();
2597
- };
2598
-
2599
- header.addEventListener("pointerdown", onPointerDown);
2600
- header.addEventListener("pointermove", onPointerMove);
2601
- header.addEventListener("pointerup", onPointerUp);
2602
- header.addEventListener("pointercancel", onPointerUp);
2603
- cleanup.push(() => header.removeEventListener("pointerdown", onPointerDown));
2604
- cleanup.push(() => header.removeEventListener("pointermove", onPointerMove));
2605
- cleanup.push(() => header.removeEventListener("pointerup", onPointerUp));
2606
- cleanup.push(() => header.removeEventListener("pointercancel", onPointerUp));
2607
-
2608
- for (const mode of ["right", "bottom", "corner"]) {
2609
- const handle = createResizeHandle(mode);
2610
- handle.addEventListener("pointerdown", onResizeDown);
2611
- handle.addEventListener("pointermove", onResizeMove);
2612
- handle.addEventListener("pointerup", onResizeUp);
2613
- handle.addEventListener("pointercancel", onResizeUp);
2614
- cleanup.push(() => handle.removeEventListener("pointerdown", onResizeDown));
2615
- cleanup.push(() => handle.removeEventListener("pointermove", onResizeMove));
2616
- cleanup.push(() => handle.removeEventListener("pointerup", onResizeUp));
2617
- cleanup.push(() => handle.removeEventListener("pointercancel", onResizeUp));
2618
- }
2619
-
2620
- const onWindowResize = () => {
2621
- if (inner.classList.contains("is-focus-mode")) {
2622
- setBounds({
2623
- left: inset,
2624
- top: inset,
2625
- width: globalThis.innerWidth - inset * 2,
2626
- height: globalThis.innerHeight - inset * 2,
2627
- });
2628
- return;
2629
- }
2630
- ensurePosition();
2631
- };
2632
- globalThis.addEventListener("resize", onWindowResize);
2633
- cleanup.push(() => globalThis.removeEventListener("resize", onWindowResize));
2634
-
2635
- if (globalThis.requestAnimationFrame) {
2636
- globalThis.requestAnimationFrame(ensurePosition);
2637
- } else {
2638
- globalThis.setTimeout(ensurePosition, 0);
2639
- }
2640
- this._floatingCleanup = () => {
2641
- newMenuCleanup?.();
2642
- cleanup.splice(0).reverse().forEach((entry) => entry());
2643
- modal?.classList?.remove("modal-floating", "modal-no-backdrop");
2644
- inner.classList.remove("is-dragging", "is-resizing", "is-focus-mode");
2645
- this._desktopResizeSuspended = false;
2646
- this._desktopResizePending = false;
749
+ inner.classList.add("office-modal");
750
+ this._headerCleanup = () => {
751
delete inner.dataset.officeModalReady;
752
+ inner.classList.remove("office-modal");
753
+ };
754
+ const menuCleanup = this.installHeaderNewMenu(header);
755
+ const previousCleanup = this._headerCleanup;
756
+ this._headerCleanup = () => {
757
+ menuCleanup?.();
758
+ previousCleanup?.();
759
};
760
},
761
};
plugins/_office/webui/thumbnail.jpg
Binary files a/plugins/_office/webui/thumbnail.jpg and b/plugins/_office/webui/thumbnail.jpg differ