feat(office-ui): introduce the Desktop document canvas
Rework the Office canvas into the Desktop surface, with Markdown editing for text documents and official LibreOffice/Xpra sessions for DOCX, XLSX, and PPTX. The panel now presents Desktop-oriented actions, named header buttons, persistent session tabs, adaptive modal/canvas sizing, and fast client-side Xpra frame fitting during resize. Stop auto-opening the canvas from document tool results, hide the canvas on mobile-width layouts, and emit resize lifecycle events so embedded desktop surfaces can pause expensive work while the user drags.
Alessandro committed
May 2, 2026 at 12:20 UTC
24dd548ebf221e397323b5aa3a509f037fb1b9ae
9 files changed
+2432
-1562
plugins/_office/extensions/webui/get_tool_message_handler/document-artifact-handler.js
+3
-46
@@ -10,9 +10,6 @@ import {
10
drawProcessStep,
11
} from "/js/messages.js";
12
13
-const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000;
14
-const autoOpenedDocuments = new Set();
15
-
13
export default async function registerDocumentArtifactHandler(extData) {
14
if (extData?.tool_name === "document_artifact") {
15
extData.handler = drawDocumentArtifactTool;
@@ -25,6 +22,7 @@ async function openOfficeCanvas(kvps = {}) {
22
await canvas?.open?.("office", {
23
path: kvps.path || "",
24
file_id: kvps.file_id || "",
25
+ refresh: true,
26
source: "tool",
27
});
28
}
@@ -50,50 +48,10 @@ function documentFromArgs(args, result = {}) {
48
title: kvps.title || kvps.basename || document.basename || "",
49
format: kvps.format || kvps.extension || document.extension || "",
50
version: kvps.version || document.version || "",
51
+ last_modified: kvps.last_modified || document.last_modified || "",
52
};
53
}
54
56
-function shouldAutoOpenDocument(args, document) {
57
- const kvps = args?.kvps || {};
58
- if (kvps.canvas_surface && kvps.canvas_surface !== "office") return false;
59
- if (!document?.path) return false;
60
- const action = String(kvps.action || "").trim().toLowerCase();
61
- if (["status", "version_history", "inspect", "read", "extract"].includes(action)) return false;
62
- return isFreshToolMessage(args?.timestamp);
63
-}
64
-
65
-function isFreshToolMessage(timestamp) {
66
- const value = Number(timestamp);
67
- if (!Number.isFinite(value) || value <= 0) return true;
68
- const messageMs = value > 10_000_000_000 ? value : value * 1000;
69
- return Math.abs(Date.now() - messageMs) <= AUTO_OPEN_WINDOW_MS;
70
-}
71
-
72
-function autoOpenOfficeCanvas(args) {
73
- const document = documentFromArgs(args, parseDocumentResult(args?.content));
74
- if (!shouldAutoOpenDocument(args, document)) return;
75
- const key = `${args.id || ""}:${document.file_id || ""}:${document.path || ""}:${document.version || ""}`;
76
- const persistedKey = `a0.office.autoOpened.${key}`;
77
- if (hasOpenedDocument(key, persistedKey)) return;
78
- requestAnimationFrame(() => {
79
- void openOfficeCanvas(document);
80
- });
81
-}
82
-
83
-function hasOpenedDocument(key, persistedKey) {
84
- if (autoOpenedDocuments.has(key)) return true;
85
- autoOpenedDocuments.add(key);
86
-
87
- try {
88
- if (sessionStorage.getItem(persistedKey)) return true;
89
- sessionStorage.setItem(persistedKey, "1");
90
- } catch {
91
- // Best-effort persistence; the in-memory guard still prevents repeat opens.
92
- }
93
-
94
- return false;
95
-}
96
-
55
function drawDocumentArtifactTool({
56
id,
57
type,
@@ -116,7 +74,7 @@ function drawDocumentArtifactTool({
74
].filter(Boolean);
75
76
const actionButtons = [
119
- createActionButton("description", "Office", () => openOfficeCanvas(document)),
77
+ createActionButton("desktop_windows", "Desktop", () => openOfficeCanvas(document)),
78
];
79
80
if (document?.path) {
@@ -145,6 +103,5 @@ function drawDocumentArtifactTool({
103
actionButtons: actionButtons.filter(Boolean),
104
log: args,
105
});
148
- autoOpenOfficeCanvas(args);
106
return result;
107
}
plugins/_office/extensions/webui/right_canvas_register_surfaces/register-office.js
+8
-4
@@ -1,3 +1,7 @@
1
+import { store as officeStore } from "/plugins/_office/webui/office-store.js";
2
+
3
+void officeStore;
4
+
5
function waitForElement(selector, timeoutMs = 3000) {
6
const found = document.querySelector(selector);
7
if (found) return Promise.resolve(found);
@@ -20,8 +24,8 @@ function waitForElement(selector, timeoutMs = 3000) {
24
export default async function registerOfficeSurface(canvas) {
25
canvas.registerSurface({
26
id: "office",
23
- title: "Office",
24
- icon: "description",
27
+ title: "Desktop",
28
+ icon: "desktop_windows",
29
order: 20,
30
modalPath: "/plugins/_office/webui/main.html",
31
async open(payload = {}) {
@@ -30,9 +34,9 @@ export default async function registerOfficeSurface(canvas) {
34
await office?.onMount?.(panel, { mode: "canvas" });
35
await office?.onOpen?.(payload);
36
},
33
- async close() {
37
+ async close(payload = {}) {
38
const office = globalThis.Alpine?.store?.("office");
35
- office?.beforeHostHidden?.();
39
+ office?.beforeHostHidden?.({ unloadDesktop: payload?.reason === "mobile" });
40
},
41
});
42
}
plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js
+2
-144
@@ -1,145 +1,3 @@
1
-const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000;
2
-const autoOpenedDocuments = new Set();
3
-
4
-export default async function autoOpenDocumentResults(context) {
5
- if (!context?.results?.length || context.historyEmpty) return;
6
-
7
- for (const { args } of context.results) {
8
- const payload = getToolResultPayload(args);
9
- if (getToolName(payload) !== "document_artifact") continue;
10
-
11
- const document = getDocumentPayload(payload);
12
- if (!document?.path) continue;
13
- if (payload.canvas_surface && payload.canvas_surface !== "office") continue;
14
- if (isReadOnlyAction(payload)) continue;
15
- if (!isFresh(args?.timestamp, document.last_modified)) continue;
16
-
17
- const key = [
18
- args?.id || "",
19
- document.file_id || "",
20
- document.path,
21
- document.version || "",
22
- ].join(":");
23
- const persistedKey = `a0.office.autoOpened.${key}`;
24
- if (hasOpened(key, persistedKey)) continue;
25
-
26
- requestAnimationFrame(() => {
27
- void openOfficeCanvas(document);
28
- });
29
- }
30
-}
31
-
32
-function getToolResultPayload(args = {}) {
33
- const topLevelPayload = pickPayloadFields(args);
34
- const contentPayload = parseMaybeJson(args.content);
35
- const kvpsPayload = parseMaybeJson(args.kvps);
36
- return {
37
- ...topLevelPayload,
38
- ...(contentPayload || {}),
39
- ...(kvpsPayload || {}),
40
- };
41
-}
42
-
43
-function pickPayloadFields(args = {}) {
44
- const payload = {};
45
- for (const key of [
46
- "_tool_name",
47
- "tool_name",
48
- "tool_result",
49
- "canvas_surface",
50
- "action",
51
- "file_id",
52
- "path",
53
- "title",
54
- "basename",
55
- "format",
56
- "extension",
57
- "version",
58
- "last_modified",
59
- ]) {
60
- if (args[key] != null && args[key] !== "") payload[key] = args[key];
61
- }
62
- return payload;
63
-}
64
-
65
-function getToolName(payload = {}) {
66
- return String(payload._tool_name || payload.tool_name || "").trim();
67
-}
68
-
69
-function getDocumentPayload(payload = {}) {
70
- const result = parseMaybeJson(payload.tool_result) || {};
71
- const document = result.document && typeof result.document === "object"
72
- ? result.document
73
- : {};
74
-
75
- return {
76
- file_id: payload.file_id || document.file_id || "",
77
- path: payload.path || document.path || "",
78
- title: payload.title || payload.basename || document.basename || "",
79
- format: payload.format || payload.extension || document.extension || "",
80
- version: payload.version || document.version || "",
81
- last_modified: payload.last_modified || document.last_modified || "",
82
- };
83
-}
84
-
85
-function isReadOnlyAction(payload = {}) {
86
- const action = String(payload.action || "").trim().toLowerCase();
87
- return ["status", "version_history", "inspect", "read", "extract"].includes(action);
88
-}
89
-
90
-function parseMaybeJson(value) {
91
- if (!value) return null;
92
- if (typeof value === "object") return value;
93
- if (typeof value !== "string") return null;
94
-
95
- const trimmed = value.trim();
96
- if (!trimmed.startsWith("{")) return null;
97
- try {
98
- const parsed = JSON.parse(trimmed);
99
- return parsed && typeof parsed === "object" ? parsed : null;
100
- } catch {
101
- return null;
102
- }
103
-}
104
-
105
-function isFresh(timestamp, fallbackTimestamp) {
106
- const messageMs = toMs(timestamp) || toMs(fallbackTimestamp);
107
- if (!messageMs) return true;
108
- return Math.abs(Date.now() - messageMs) <= AUTO_OPEN_WINDOW_MS;
109
-}
110
-
111
-function toMs(value) {
112
- if (value == null || value === "") return 0;
113
-
114
- const numeric = Number(value);
115
- if (Number.isFinite(numeric) && numeric > 0) {
116
- return numeric > 10_000_000_000 ? numeric : numeric * 1000;
117
- }
118
-
119
- const parsed = Date.parse(String(value));
120
- return Number.isFinite(parsed) ? parsed : 0;
121
-}
122
-
123
-function hasOpened(key, persistedKey) {
124
- if (autoOpenedDocuments.has(key)) return true;
125
- autoOpenedDocuments.add(key);
126
-
127
- try {
128
- if (sessionStorage.getItem(persistedKey)) return true;
129
- sessionStorage.setItem(persistedKey, "1");
130
- } catch {
131
- // Best-effort persistence; the in-memory guard still prevents repeat opens.
132
- }
133
-
134
- return false;
135
-}
136
-
137
-async function openOfficeCanvas(document) {
138
- const canvas = globalThis.Alpine?.store?.("rightCanvas")
139
- || (await import("/components/canvas/right-canvas-store.js")).store;
140
- await canvas?.open?.("office", {
141
- path: document.path || "",
142
- file_id: document.file_id || "",
143
- source: "tool-result",
144
- });
1
+export default async function autoOpenDocumentResults(_context) {
2
+ return;
3
}
plugins/_office/webui/main.html
+5
-2
@@ -2,11 +2,14 @@
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 Office in canvas"
5
+ data-canvas-dock-title="Open Desktop in canvas"
6
data-canvas-dock-icon="dock_to_right"
7
>
8
<head>
9
- <title>Office</title>
9
+ <title>Desktop</title>
10
+ <script type="module">
11
+ import { store } from "/plugins/_office/webui/office-store.js";
12
+ </script>
13
</head>
14
<body class="office-modal-body">
15
<x-component path="/plugins/_office/webui/office-panel.html" mode="modal"></x-component>
plugins/_office/webui/office-panel.html
+684
-665
@@ -9,131 +9,153 @@
9
<template x-if="$store.office">
10
<div class="office-shell">
11
<div class="office-toolbar">
12
- <button type="button" class="office-button" title="Open" @click="$store.office.openPrompt()">
13
- <span class="material-symbols-outlined">folder_open</span>
14
- <span>Open</span>
15
- </button>
16
- <button type="button" class="office-button" title="New document" @click="$store.office.create('document')">
17
- <span class="material-symbols-outlined">note_add</span>
18
- <span>Doc</span>
19
- </button>
20
- <button type="button" class="office-button" title="New spreadsheet" @click="$store.office.create('spreadsheet')">
21
- <span class="material-symbols-outlined">table</span>
22
- <span>Sheet</span>
23
- </button>
24
- <button type="button" class="office-button" title="New presentation" @click="$store.office.create('presentation')">
25
- <span class="material-symbols-outlined">slideshow</span>
26
- <span>Presentation</span>
27
- </button>
12
+ <div class="office-tool-group">
13
+ <button type="button" class="office-icon-button office-command-button" aria-label="Open" @click="$store.office.openPrompt()">
14
+ <span class="material-symbols-outlined">folder_open</span>
15
+ <span class="office-button-label">Open</span>
16
+ </button>
17
+ </div>
18
+
19
+ <div class="office-tool-group">
20
+ <button type="button" class="office-icon-button office-command-button" aria-label="New Markdown" @click="$store.office.create('document', 'md')">
21
+ <span class="material-symbols-outlined">article</span>
22
+ <span class="office-button-label">Markdown</span>
23
+ </button>
24
+ <button type="button" class="office-icon-button office-command-button" aria-label="New DOCX" @click="$store.office.create('document', 'docx')">
25
+ <span class="material-symbols-outlined">description</span>
26
+ <span class="office-button-label">DOCX</span>
27
+ </button>
28
+ <button type="button" class="office-icon-button office-command-button" aria-label="New spreadsheet" @click="$store.office.create('spreadsheet', 'xlsx')">
29
+ <span class="material-symbols-outlined">table_chart</span>
30
+ <span class="office-button-label">Spreadsheet</span>
31
+ </button>
32
+ <button type="button" class="office-icon-button office-command-button" aria-label="New presentation" @click="$store.office.create('presentation', 'pptx')">
33
+ <span class="material-symbols-outlined">co_present</span>
34
+ <span class="office-button-label">Presentation</span>
35
+ </button>
36
+ </div>
37
+
38
+ <div class="office-tool-group" x-show="$store.office.session && !$store.office.hasOfficialOffice() && !$store.office.isPreviewOnly()" style="display: none;">
39
+ <button type="button" class="office-icon-button" title="Undo" aria-label="Undo" :disabled="!$store.office.canUndo()" @click="$store.office.undo()">
40
+ <span class="material-symbols-outlined">undo</span>
41
+ </button>
42
+ <button type="button" class="office-icon-button" title="Redo" aria-label="Redo" :disabled="!$store.office.canRedo()" @click="$store.office.redo()">
43
+ <span class="material-symbols-outlined">redo</span>
44
+ </button>
45
+ </div>
46
+
47
+ <div class="office-tool-group" x-show="$store.office.session && !$store.office.hasOfficialOffice() && !$store.office.isPreviewOnly()" style="display: none;">
48
+ <button type="button" class="office-icon-button" title="Bold" aria-label="Bold" @click="$store.office.format('bold')">
49
+ <span class="material-symbols-outlined">format_bold</span>
50
+ </button>
51
+ <button type="button" class="office-icon-button" title="Italic" aria-label="Italic" @click="$store.office.format('italic')">
52
+ <span class="material-symbols-outlined">format_italic</span>
53
+ </button>
54
+ <button type="button" class="office-icon-button" title="Underline" aria-label="Underline" @click="$store.office.format('underline')">
55
+ <span class="material-symbols-outlined">format_underlined</span>
56
+ </button>
57
+ <button type="button" class="office-icon-button" title="List" aria-label="List" @click="$store.office.format('list')">
58
+ <span class="material-symbols-outlined">format_list_bulleted</span>
59
+ </button>
60
+ <button type="button" class="office-icon-button" title="Numbered list" aria-label="Numbered list" @click="$store.office.format('numbered')">
61
+ <span class="material-symbols-outlined">format_list_numbered</span>
62
+ </button>
63
+ <button type="button" class="office-icon-button" title="Table" aria-label="Table" @click="$store.office.format('table')">
64
+ <span class="material-symbols-outlined">table</span>
65
+ </button>
66
+ <button type="button" class="office-icon-button" title="Align left" aria-label="Align left" @click="$store.office.format('alignLeft')">
67
+ <span class="material-symbols-outlined">format_align_left</span>
68
+ </button>
69
+ <button type="button" class="office-icon-button" title="Align center" aria-label="Align center" @click="$store.office.format('alignCenter')">
70
+ <span class="material-symbols-outlined">format_align_center</span>
71
+ </button>
72
+ <button type="button" class="office-icon-button" title="Align right" aria-label="Align right" @click="$store.office.format('alignRight')">
73
+ <span class="material-symbols-outlined">format_align_right</span>
74
+ </button>
75
+ <button type="button" class="office-icon-button" title="Source" aria-label="Source" :class="{ 'is-active': $store.office.sourceMode }" x-show="$store.office.isMarkdown()" @click="$store.office.toggleSource()">
76
+ <span class="material-symbols-outlined">code</span>
77
+ </button>
78
+ </div>
79
+
80
+ <div class="office-tool-group" x-show="$store.office.session && !$store.office.hasOfficialOffice()" style="display: none;">
81
+ <button type="button" class="office-icon-button" title="Zoom out" aria-label="Zoom out" @click="$store.office.zoomOut()">
82
+ <span class="material-symbols-outlined">zoom_out</span>
83
+ </button>
84
+ <span class="office-zoom" x-text="$store.office.zoomLabel()"></span>
85
+ <button type="button" class="office-icon-button" title="Zoom in" aria-label="Zoom in" @click="$store.office.zoomIn()">
86
+ <span class="material-symbols-outlined">zoom_in</span>
87
+ </button>
88
+ </div>
89
+
90
<span class="office-toolbar-spacer"></span>
29
- <span
30
- class="office-health-pill"
31
- :class="`is-${$store.office.status?.state || 'unknown'}`"
32
- :title="$store.office.healthTitle()"
33
- :aria-label="$store.office.healthTitle()"
34
- >
35
- <span class="office-health-dot"></span>
36
- <span x-show="$store.office.status?.state !== 'healthy'" x-text="$store.office.healthText()"></span>
37
- </span>
38
- <button type="button" class="office-icon-button" title="Save" @click="$store.office.save()" :disabled="!$store.office.session">
39
- <span class="material-symbols-outlined">save</span>
40
- </button>
41
- <button
42
- type="button"
43
- class="office-icon-button"
44
- title="Close file"
45
- aria-label="Close file"
46
- :disabled="!$store.office.session"
47
- @click="$confirmClick($event, () => $store.office.closeFile())"
48
- >
49
- <span class="material-symbols-outlined">close</span>
50
- </button>
51
- <button type="button" class="office-icon-button" title="Refresh status" @click="$store.office.refresh()">
52
- <span class="material-symbols-outlined">refresh</span>
53
- </button>
91
+
92
+ <div class="office-tool-group" x-show="$store.office.session && !$store.office.isDesktopSession()" style="display: none;">
93
+ <button type="button" class="office-icon-button office-command-button" aria-label="Export PDF" :disabled="$store.office.loading" @click="$store.office.exportPdf()">
94
+ <span class="material-symbols-outlined">picture_as_pdf</span>
95
+ <span class="office-button-label">Export PDF</span>
96
+ </button>
97
+ <button type="button" class="office-icon-button office-command-button" aria-label="Save" :class="{ 'is-primary': $store.office.dirty }" :disabled="$store.office.saving" @click="$store.office.save()">
98
+ <span class="material-symbols-outlined" :class="{ spinning: $store.office.saving }" x-text="$store.office.saving ? 'progress_activity' : 'save'"></span>
99
+ <span class="office-button-label">Save</span>
100
+ </button>
101
+ <button type="button" class="office-icon-button office-command-button" aria-label="Close" @click="$confirmClick($event, () => $store.office.closeFile())">
102
+ <span class="material-symbols-outlined">close</span>
103
+ <span class="office-button-label">Close</span>
104
+ </button>
105
+ </div>
106
</div>
107
56
- <div class="office-tabs" x-show="$store.office.tabs.length" role="tablist" aria-label="Open Office files" style="display: none;">
108
+ <div class="office-tabs" x-show="$store.office.tabs.length" role="tablist" aria-label="Open documents" style="display: none;">
109
<template x-for="tab in $store.office.tabs" :key="tab.tab_id">
58
- <div class="office-tab-shell" :class="{ 'is-active': $store.office.isActiveTab(tab) }">
59
- <button
60
- type="button"
61
- class="office-tab"
62
- role="tab"
63
- :aria-selected="$store.office.isActiveTab(tab).toString()"
64
- :title="$store.office.tabLabel(tab)"
65
- @click="$store.office.selectTab(tab.tab_id)"
66
- >
110
+ <div class="office-tab-shell" :class="{ 'is-active': $store.office.isActiveTab(tab), 'is-dirty': tab.dirty, 'is-system': $store.office.isDesktopSession(tab) }">
111
+ <button type="button" class="office-tab" role="tab" :aria-selected="$store.office.isActiveTab(tab).toString()" :title="$store.office.tabLabel(tab)" @click="$store.office.selectTab(tab.tab_id)">
112
<span class="material-symbols-outlined office-tab-icon" aria-hidden="true" x-text="$store.office.tabIcon(tab)"></span>
113
<span class="office-tab-title" x-text="$store.office.tabTitle(tab)"></span>
114
</button>
70
- <button
71
- type="button"
72
- class="office-tab-close"
73
- :title="'Close ' + $store.office.tabLabel(tab)"
74
- :aria-label="'Close ' + $store.office.tabLabel(tab)"
75
- :disabled="$store.office.loading"
76
- @click.stop="$confirmClick($event, () => $store.office.closeTab(tab.tab_id))"
77
- >
115
+ <button type="button" class="office-tab-close" x-show="!$store.office.isDesktopSession(tab)" :title="'Close ' + $store.office.tabLabel(tab)" :aria-label="'Close ' + $store.office.tabLabel(tab)" :disabled="$store.office.loading" @click.stop="$confirmClick($event, () => $store.office.closeTab(tab.tab_id))">
116
<span class="material-symbols-outlined">close</span>
117
</button>
118
</div>
119
</template>
120
</div>
121
84
- <div class="office-status-line" x-show="$store.office.message || $store.office.error || $store.office.loading" style="display: none;">
122
+ <div class="office-state-line" x-show="$store.office.message || $store.office.error || $store.office.loading" style="display: none;">
123
<span class="material-symbols-outlined" :class="{ spinning: $store.office.loading }" x-text="$store.office.loading ? 'progress_activity' : ($store.office.error ? 'error' : 'check_circle')"></span>
86
- <span x-text="$store.office.error || $store.office.message || 'Working...'"></span>
124
+ <span x-text="$store.office.error || $store.office.message || 'Working'"></span>
125
</div>
126
127
<div class="office-body">
90
- <div class="office-bootstrap" x-show="!$store.office.session && (!$store.office.status || !$store.office.status.healthy)" style="display: none;">
91
- <div class="office-setup-mark" :class="{ 'is-busy': $store.office.isSetupBusy(), 'is-alert': $store.office.isSetupBlocked() }">
92
- <span class="material-symbols-outlined" :class="{ spinning: $store.office.isSetupBusy() }" x-text="$store.office.setupIcon()"></span>
93
- </div>
94
- <div class="office-setup-copy">
95
- <span>Agent Zero Office</span>
96
- <strong x-text="$store.office.setupTitle()"></strong>
97
- <p x-text="$store.office.setupMessage()"></p>
98
- </div>
99
- <div class="office-setup-progress" :class="{ 'is-paused': !$store.office.isSetupBusy() }" aria-hidden="true">
100
- <span></span>
101
- </div>
102
- <div class="office-bootstrap-actions" x-show="$store.office.showSetupActions()" style="display: none;">
103
- <button type="button" class="office-button" @click="$store.office.retry()" x-show="$store.office.isSetupBlocked()" style="display: none;">
104
- <span class="material-symbols-outlined">restart_alt</span>
105
- <span>Retry</span>
106
- </button>
107
- <button type="button" class="office-button" @click="$store.office.refresh()">
108
- <span class="material-symbols-outlined">sync</span>
109
- <span>Refresh</span>
110
- </button>
111
- </div>
112
- </div>
113
-
114
- <div class="office-start" x-show="!$store.office.session && $store.office.status?.healthy" style="display: none;">
115
- <section class="office-dashboard-section" aria-label="Create Office file">
116
- <div class="office-dashboard-heading">Create</div>
128
+ <div class="office-start" x-show="!$store.office.session" style="display: none;">
129
+ <section class="office-dashboard-section" aria-label="Create">
130
+ <div class="office-dashboard-heading">New</div>
131
<div class="office-template-grid">
118
- <button type="button" class="office-create-tile" @click="$store.office.create('document')">
132
+ <button type="button" class="office-create-tile is-markdown" @click="$store.office.create('document', 'md')">
133
<span class="material-symbols-outlined">article</span>
120
- <span>Document</span>
134
+ <strong>Markdown</strong>
135
+ <small>.md</small>
136
</button>
122
- <button type="button" class="office-create-tile" @click="$store.office.create('spreadsheet')">
137
+ <button type="button" class="office-create-tile is-docx" @click="$store.office.create('document', 'docx')">
138
+ <span class="material-symbols-outlined">description</span>
139
+ <strong>DOCX</strong>
140
+ <small>.docx</small>
141
+ </button>
142
+ <button type="button" class="office-create-tile is-sheet" @click="$store.office.create('spreadsheet', 'xlsx')">
143
<span class="material-symbols-outlined">table_chart</span>
124
- <span>Spreadsheet</span>
144
+ <strong>Sheet</strong>
145
+ <small>.xlsx</small>
146
</button>
126
- <button type="button" class="office-create-tile" @click="$store.office.create('presentation')">
147
+ <button type="button" class="office-create-tile is-deck" @click="$store.office.create('presentation', 'pptx')">
148
<span class="material-symbols-outlined">co_present</span>
128
- <span>Presentation</span>
149
+ <strong>Deck</strong>
150
+ <small>.pptx</small>
151
</button>
152
</div>
153
</section>
154
133
- <section class="office-dashboard-section" x-show="$store.office.openCards().length" aria-label="Open Office files" style="display: none;">
134
- <div class="office-dashboard-heading">Open files</div>
155
+ <section class="office-dashboard-section" x-show="$store.office.openCards().length" aria-label="Open" style="display: none;">
156
+ <div class="office-dashboard-heading">Open</div>
157
<div class="office-card-grid">
136
- <template x-for="doc in $store.office.openCards()" :key="doc.tab_id">
158
+ <template x-for="doc in $store.office.openCards()" :key="doc.tab_id || doc.file_id || doc.path">
159
<button type="button" class="office-document-card is-open" :title="doc.path" @click="$store.office.selectTab(doc.tab_id)">
160
<span class="office-card-badge">Open</span>
161
<div class="office-card-preview" :class="`is-${$store.office.previewKind(doc)}`">
@@ -141,34 +163,18 @@
163
<div class="office-sheet-preview">
164
<template x-for="(row, rowIndex) in $store.office.previewRows(doc)" :key="rowIndex">
165
<div class="office-sheet-row">
144
- <template x-for="(cell, cellIndex) in row" :key="cellIndex">
145
- <span x-text="cell"></span>
146
- </template>
166
+ <template x-for="(cell, cellIndex) in row" :key="cellIndex"><span x-text="cell"></span></template>
167
</div>
168
</template>
169
</div>
170
</template>
151
- <template x-if="$store.office.previewKind(doc) === 'presentation' && $store.office.hasPreview(doc)">
152
- <div class="office-slide-preview">
153
- <template x-for="(slide, index) in $store.office.previewSlides(doc)" :key="index">
154
- <div class="office-slide-line">
155
- <strong x-text="slide.title"></strong>
156
- <span x-text="(slide.lines || []).join(' / ')"></span>
157
- </div>
158
- </template>
159
- </div>
160
- </template>
161
- <template x-if="$store.office.previewKind(doc) === 'document' && $store.office.hasPreview(doc)">
171
+ <template x-if="$store.office.previewKind(doc) !== 'spreadsheet' && $store.office.hasPreview(doc)">
172
<div class="office-page-preview">
163
- <template x-for="(line, index) in $store.office.previewLines(doc)" :key="index">
164
- <span x-text="line"></span>
165
- </template>
173
+ <template x-for="(line, index) in $store.office.previewLines(doc)" :key="index"><span x-text="line"></span></template>
174
</div>
175
</template>
176
<template x-if="!$store.office.hasPreview(doc)">
169
- <div class="office-preview-fallback">
170
- <span class="material-symbols-outlined" x-text="$store.office.tabIcon(doc)"></span>
171
- </div>
177
+ <div class="office-preview-fallback"><span class="material-symbols-outlined" x-text="$store.office.tabIcon(doc)"></span></div>
178
</template>
179
</div>
180
<span class="office-card-title" x-text="$store.office.dashboardTitle(doc)"></span>
@@ -178,44 +184,28 @@
184
</div>
185
</section>
186
181
- <section class="office-dashboard-section" x-show="$store.office.recentCards().length" aria-label="Recent Office files" style="display: none;">
182
- <div class="office-dashboard-heading">Recent files</div>
187
+ <section class="office-dashboard-section" x-show="$store.office.recentCards().length" aria-label="Recent" style="display: none;">
188
+ <div class="office-dashboard-heading">Recent</div>
189
<div class="office-card-grid">
184
- <template x-for="doc in $store.office.recentCards()" :key="doc.file_id">
190
+ <template x-for="doc in $store.office.recentCards()" :key="doc.file_id || doc.path">
191
<button type="button" class="office-document-card" :title="doc.path" @click="$store.office.openPath(doc.path)">
192
<div class="office-card-preview" :class="`is-${$store.office.previewKind(doc)}`">
193
<template x-if="$store.office.previewKind(doc) === 'spreadsheet' && $store.office.hasPreview(doc)">
194
<div class="office-sheet-preview">
195
<template x-for="(row, rowIndex) in $store.office.previewRows(doc)" :key="rowIndex">
196
<div class="office-sheet-row">
191
- <template x-for="(cell, cellIndex) in row" :key="cellIndex">
192
- <span x-text="cell"></span>
193
- </template>
197
+ <template x-for="(cell, cellIndex) in row" :key="cellIndex"><span x-text="cell"></span></template>
198
</div>
199
</template>
200
</div>
201
</template>
198
- <template x-if="$store.office.previewKind(doc) === 'presentation' && $store.office.hasPreview(doc)">
199
- <div class="office-slide-preview">
200
- <template x-for="(slide, index) in $store.office.previewSlides(doc)" :key="index">
201
- <div class="office-slide-line">
202
- <strong x-text="slide.title"></strong>
203
- <span x-text="(slide.lines || []).join(' / ')"></span>
204
- </div>
205
- </template>
206
- </div>
207
- </template>
208
- <template x-if="$store.office.previewKind(doc) === 'document' && $store.office.hasPreview(doc)">
202
+ <template x-if="$store.office.previewKind(doc) !== 'spreadsheet' && $store.office.hasPreview(doc)">
203
<div class="office-page-preview">
210
- <template x-for="(line, index) in $store.office.previewLines(doc)" :key="index">
211
- <span x-text="line"></span>
212
- </template>
204
+ <template x-for="(line, index) in $store.office.previewLines(doc)" :key="index"><span x-text="line"></span></template>
205
</div>
206
</template>
207
<template x-if="!$store.office.hasPreview(doc)">
216
- <div class="office-preview-fallback">
217
- <span class="material-symbols-outlined" x-text="$store.office.tabIcon(doc)"></span>
218
- </div>
208
+ <div class="office-preview-fallback"><span class="material-symbols-outlined" x-text="$store.office.tabIcon(doc)"></span></div>
209
</template>
210
</div>
211
<span class="office-card-title" x-text="$store.office.dashboardTitle(doc)"></span>
@@ -226,12 +216,92 @@
216
</section>
217
</div>
218
229
- <div class="office-frame-wrap" x-show="$store.office.session" style="display: none;">
230
- <iframe
231
- data-office-frame
232
- allow="clipboard-read *; clipboard-write *; fullscreen *"
233
- allowfullscreen
234
- ></iframe>
219
+ <div class="office-editor-wrap" x-show="$store.office.session" style="display: none;">
220
+ <div class="office-editor-scroll" :class="{ 'is-desktop': $store.office.hasOfficialOffice() }" :style="`--office-zoom: ${$store.office.zoom}`" @click.self="$store.office.focusEditor()">
221
+ <template x-if="$store.office.hasOfficialOffice()">
222
+ <div class="office-desktop-wrap">
223
+ <iframe
224
+ class="office-desktop-frame"
225
+ data-office-desktop-frame
226
+ :src="$store.office.officialOfficeUrl()"
227
+ aria-label="Desktop"
228
+ allow="clipboard-read; clipboard-write; autoplay"
229
+ @load="$store.office.onDesktopFrameLoaded($event)"
230
+ ></iframe>
231
+ </div>
232
+ </template>
233
+
234
+ <textarea
235
+ class="office-source-editor"
236
+ data-office-source
237
+ aria-label="Markdown source"
238
+ x-show="$store.office.isMarkdown() && $store.office.sourceMode"
239
+ x-model="$store.office.editorText"
240
+ @input="$store.office.onSourceInput()"
241
+ @blur="$store.office.flushInput()"
242
+ spellcheck="true"
243
+ style="display: none;"
244
+ ></textarea>
245
+
246
+ <article
247
+ class="office-rich-editor"
248
+ x-show="$store.office.isMarkdown() && !$store.office.sourceMode"
249
+ x-init="$store.office.bindEditorElement($el, 'markdown')"
250
+ contenteditable="true"
251
+ tabindex="0"
252
+ role="textbox"
253
+ aria-label="Markdown document"
254
+ spellcheck="true"
255
+ @input="$store.office.onRichInput($el)"
256
+ @blur="$store.office.flushInput()"
257
+ style="display: none;"
258
+ ></article>
259
+
260
+ <div
261
+ class="office-docx-stage"
262
+ x-show="$store.office.isDocx() && !$store.office.hasOfficialOffice()"
263
+ style="display: none;"
264
+ >
265
+ <div
266
+ class="office-docx-pages"
267
+ :class="{ 'is-native': $store.office.hasNativeDocxTiles() }"
268
+ x-init="$store.office.bindEditorElement($el, 'docx')"
269
+ :contenteditable="$store.office.hasNativeDocxTiles() ? 'false' : 'true'"
270
+ tabindex="0"
271
+ role="textbox"
272
+ aria-label="DOCX document"
273
+ spellcheck="true"
274
+ @click="$store.office.onNativeDocxClick($event)"
275
+ @keydown="$store.office.onNativeDocxKeydown($event)"
276
+ @input="$store.office.onDocxInput($el)"
277
+ @blur="$store.office.flushInput()"
278
+ ></div>
279
+ </div>
280
+
281
+ <div class="office-preview-editor" x-show="$store.office.isPreviewOnly() && !$store.office.hasOfficialOffice()" style="display: none;">
282
+ <div class="office-card-preview is-large" :class="`is-${$store.office.previewKind($store.office.session || {})}`">
283
+ <template x-if="$store.office.previewKind($store.office.session || {}) === 'spreadsheet' && $store.office.hasPreview($store.office.session || {})">
284
+ <div class="office-sheet-preview">
285
+ <template x-for="(row, rowIndex) in $store.office.previewRows($store.office.session || {})" :key="rowIndex">
286
+ <div class="office-sheet-row">
287
+ <template x-for="(cell, cellIndex) in row" :key="cellIndex"><span x-text="cell"></span></template>
288
+ </div>
289
+ </template>
290
+ </div>
291
+ </template>
292
+ <template x-if="$store.office.previewKind($store.office.session || {}) === 'presentation'">
293
+ <div class="office-slide-preview">
294
+ <template x-for="(slide, index) in $store.office.previewSlides($store.office.session || {})" :key="index">
295
+ <div class="office-slide-line">
296
+ <strong x-text="slide.title"></strong>
297
+ <span x-text="(slide.lines || []).join(' / ')"></span>
298
+ </div>
299
+ </template>
300
+ </div>
301
+ </template>
302
+ </div>
303
+ </div>
304
+ </div>
305
</div>
306
</div>
307
</div>
@@ -249,6 +319,7 @@
319
min-width: 0;
320
min-height: 0;
321
background: var(--color-background);
322
+ color: var(--color-text);
323
}
324
325
.office-panel {
@@ -257,734 +328,682 @@
328
329
.modal-inner.office-modal {
330
box-sizing: border-box;
260
- container-type: inline-size;
261
- width: min(82vw, 1180px);
262
- height: min(88vh, 900px);
263
- min-width: min(340px, calc(100vw - 16px));
264
- min-height: min(500px, calc(100vh - 16px));
265
- max-width: calc(100vw - 16px);
266
- max-height: calc(100vh - 16px);
267
- resize: both;
268
- border: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent);
269
- border-radius: 7px;
270
- box-shadow: 0 18px 48px rgba(0, 0, 0, 0.32);
271
- background: color-mix(in srgb, var(--color-background) 94%, #000 6%);
272
- }
273
-
274
- .modal.modal-floating {
275
- pointer-events: none;
276
- }
277
-
278
- .modal.modal-floating .modal-inner {
279
- pointer-events: auto;
331
+ width: min(1120px, calc(100vw - 32px));
332
+ height: min(820px, calc(100vh - 32px));
333
+ min-width: min(720px, calc(100vw - 16px));
334
+ min-height: min(520px, calc(100vh - 16px));
335
+ max-width: none;
336
+ max-height: none;
337
+ resize: none;
338
+ overflow: hidden;
339
+ will-change: width, height, left, top;
340
}
341
282
- .modal-inner.office-modal .modal-header {
283
- min-height: 34px;
284
- padding: 0.35rem 0.75rem 0.35rem 1rem;
285
- cursor: move;
342
+ .modal-inner.office-modal.is-resizing,
343
+ .modal-inner.office-modal.is-dragging {
344
user-select: none;
287
- background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
288
- border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
345
}
290
-
291
- .modal-inner.office-modal .modal-close {
292
- font-size: 1.35rem;
293
- line-height: 1;
346
+
347
+ .modal-inner.office-modal.is-focus-mode {
348
+ border-radius: 6px;
349
}
350
351
.modal-inner.office-modal .modal-scroll {
352
display: flex;
298
- flex-direction: column;
353
flex: 1 1 auto;
354
min-height: 0;
355
+ max-height: none;
356
overflow: hidden;
357
padding: 0;
358
}
359
305
- .modal-inner.office-modal .modal-bd.office-modal-body {
306
- box-sizing: border-box;
307
- display: flex;
308
- flex-direction: column;
309
- flex: 1 1 auto;
310
- width: 100%;
311
- height: 100%;
312
- min-height: 0;
313
- padding: 0;
360
+ .modal-inner.office-modal .modal-header {
361
+ grid-template-columns: minmax(0, 1fr) auto auto auto;
362
}
363
316
- .modal-inner.office-modal .modal-bd.office-modal-body > x-component,
317
- .modal-inner.office-modal .modal-bd.office-modal-body > div[x-data] {
318
- display: flex;
319
- flex: 1 1 auto;
320
- width: 100%;
321
- height: 100%;
322
- min-height: 0;
364
+ .office-modal-input-shield {
365
+ position: absolute;
366
+ inset: 42px 0 0 0;
367
+ z-index: 4;
368
+ display: none;
369
+ background: transparent;
370
+ }
371
+
372
+ .office-modal-resizer {
373
+ position: absolute;
374
+ z-index: 5;
375
+ display: block;
376
+ touch-action: none;
377
+ }
378
+
379
+ .office-modal-resizer.is-right {
380
+ top: 42px;
381
+ right: -4px;
382
+ bottom: 12px;
383
+ width: 10px;
384
+ cursor: ew-resize;
385
+ }
386
+
387
+ .office-modal-resizer.is-bottom {
388
+ right: 12px;
389
+ bottom: -4px;
390
+ left: 0;
391
+ height: 10px;
392
+ cursor: ns-resize;
393
}
394
395
+ .office-modal-resizer.is-corner {
396
+ right: 0;
397
+ bottom: 0;
398
+ width: 22px;
399
+ height: 22px;
400
+ cursor: nwse-resize;
401
+ }
402
+
403
+ .office-modal-resizer.is-corner::after {
404
+ content: "";
405
+ position: absolute;
406
+ right: 6px;
407
+ bottom: 6px;
408
+ width: 9px;
409
+ height: 9px;
410
+ border-right: 2px solid color-mix(in srgb, var(--color-text) 42%, transparent);
411
+ border-bottom: 2px solid color-mix(in srgb, var(--color-text) 42%, transparent);
412
+ border-radius: 1px;
413
+ }
414
+
415
+ .modal-inner.office-modal.is-focus-mode .office-modal-resizer {
416
+ display: none;
417
+ }
418
+
419
+ .modal-inner.office-modal .modal-bd.office-modal-body,
420
+ .modal-inner.office-modal .modal-bd.office-modal-body > x-component,
421
.modal-inner.office-modal .modal-bd.office-modal-body > x-component > .office-panel {
422
+ display: flex;
423
flex: 1 1 auto;
327
- height: 100%;
424
min-height: 0;
425
+ height: 100%;
426
+ padding: 0;
427
}
428
429
.office-toolbar {
430
display: flex;
431
align-items: center;
334
- gap: 6px;
335
- min-height: 44px;
336
- padding: 7px 9px;
337
- border-bottom: 1px solid color-mix(in srgb, var(--color-border) 66%, transparent);
338
- background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
339
- overflow-x: auto;
340
- }
341
-
342
- .office-tabs {
343
- --office-tab-height: 34px;
344
- --office-tab-close-size: 27px;
345
- display: flex;
346
- align-items: end;
347
- gap: 4px;
348
- min-height: 39px;
349
- min-width: 0;
350
- padding: 5px 9px 0;
351
- border-bottom: 1px solid color-mix(in srgb, var(--color-border) 58%, transparent);
352
- background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
432
+ flex-wrap: nowrap;
433
+ gap: 10px;
434
+ min-height: 58px;
435
+ padding: 9px 12px;
436
overflow-x: auto;
437
overflow-y: hidden;
438
scrollbar-width: thin;
439
+ border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 20%);
440
+ background: color-mix(in srgb, var(--color-background), var(--color-panel) 48%);
441
}
442
358
- .office-tabs::-webkit-scrollbar {
359
- height: 4px;
360
- }
361
-
362
- .office-tabs::-webkit-scrollbar-track {
363
- background: transparent;
364
- }
365
-
366
- .office-tabs::-webkit-scrollbar-thumb {
367
- background: color-mix(in srgb, var(--color-border) 76%, transparent);
368
- border-radius: 999px;
369
- }
370
-
371
- .office-tab-shell {
372
- flex: 0 1 220px;
373
- position: relative;
374
- display: grid;
375
- grid-template-columns: minmax(0, 1fr) var(--office-tab-close-size);
443
+ .office-tool-group {
444
+ display: flex;
445
align-items: center;
377
- gap: 3px;
378
- min-width: 132px;
379
- max-width: 260px;
380
- height: var(--office-tab-height);
381
- padding: 0 6px 0 10px;
382
- border: 1px solid transparent;
383
- border-radius: 7px 7px 0 0;
384
- opacity: 0.72;
385
- transition: border-color 0.16s ease, opacity 0.16s ease, background-color 0.16s ease;
446
+ flex: 0 0 auto;
447
+ gap: 6px;
448
+ min-width: 0;
449
}
450
388
- .office-tab-shell:hover,
389
- .office-tab-shell:focus-within {
390
- opacity: 0.94;
391
- border-color: color-mix(in srgb, var(--color-border) 78%, transparent);
451
+ .office-toolbar-spacer {
452
+ flex: 1 1 auto;
453
+ min-width: 8px;
454
}
455
394
- .office-tab-shell.is-active {
395
- z-index: 2;
396
- margin-bottom: -1px;
397
- opacity: 1;
398
- border-color: color-mix(in srgb, var(--color-border) 68%, transparent);
399
- background: color-mix(in srgb, var(--color-panel) 72%, transparent);
456
+ .office-toolbar-divider {
457
+ width: 1px;
458
+ height: 24px;
459
+ margin: 0 4px;
460
+ background: color-mix(in srgb, var(--color-border), transparent 15%);
461
}
462
463
+ .office-icon-button,
464
.office-tab,
465
.office-tab-close {
404
- appearance: none;
405
- border: 0;
406
- background: transparent;
466
+ border: 1px solid color-mix(in srgb, var(--color-border), transparent 12%);
467
+ border-radius: 8px;
468
+ background: color-mix(in srgb, var(--color-panel), var(--color-background) 16%);
469
color: inherit;
408
- font: inherit;
409
- cursor: pointer;
470
+ transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
471
}
472
412
- .office-tab {
413
- display: inline-flex;
414
- align-items: center;
415
- justify-content: flex-start;
416
- gap: 8px;
417
- min-width: 0;
418
- width: 100%;
419
- height: 100%;
473
+ .office-icon-button {
474
+ display: inline-grid;
475
+ place-items: center;
476
+ width: 40px;
477
+ height: 40px;
478
+ min-width: 40px;
479
padding: 0;
421
- text-align: left;
480
}
481
424
- .office-tab-icon {
425
- flex: 0 0 auto;
426
- color: color-mix(in srgb, var(--color-text) 72%, var(--color-primary) 28%);
427
- font-size: 18px;
428
- line-height: 1;
482
+ .office-command-button {
483
+ display: inline-flex;
484
+ align-items: center;
485
+ justify-content: center;
486
+ gap: 7px;
487
+ width: auto;
488
+ max-width: 152px;
489
+ padding: 0 11px;
490
+ white-space: nowrap;
491
}
492
431
- .office-tab-title {
493
+ .office-command-button .office-button-label {
494
min-width: 0;
495
overflow: hidden;
496
text-overflow: ellipsis;
435
- white-space: nowrap;
436
- font-size: 0.84rem;
437
- font-weight: 650;
497
+ font-size: 12px;
498
+ font-weight: 700;
499
+ line-height: 1;
500
}
501
440
- .office-tab-close {
441
- display: inline-flex;
442
- align-items: center;
443
- justify-content: center;
444
- width: var(--office-tab-close-size);
445
- min-width: var(--office-tab-close-size);
446
- height: var(--office-tab-close-size);
447
- min-height: var(--office-tab-close-size);
448
- padding: 0;
449
- border-radius: 6px;
450
- color: color-mix(in srgb, var(--color-text) 52%, var(--color-primary) 48%);
451
- opacity: 0.74;
502
+ .office-icon-button.is-primary {
503
+ border-color: color-mix(in srgb, #2c7be5, var(--color-border) 20%);
504
+ background: color-mix(in srgb, #2c7be5, var(--color-panel) 82%);
505
}
506
454
- .office-tab-close:hover,
455
- .office-tab-close.confirming {
456
- opacity: 1;
457
- background: color-mix(in srgb, var(--color-background-hover) 70%, transparent);
458
- color: var(--color-text);
507
+ .office-icon-button.is-active {
508
+ border-color: color-mix(in srgb, #2ca58d, var(--color-border) 24%);
509
+ background: color-mix(in srgb, #2ca58d, var(--color-panel) 84%);
510
}
511
461
- .office-tab-close:focus-visible,
462
- .office-tab:focus-visible {
463
- outline: 1px solid color-mix(in srgb, var(--color-primary) 70%, transparent);
464
- outline-offset: 1px;
512
+ .office-icon-button:hover:not(:disabled),
513
+ .office-tab:hover,
514
+ .office-tab-close:hover {
515
+ border-color: color-mix(in srgb, #2c7be5, var(--color-border) 45%);
516
+ background: color-mix(in srgb, var(--color-panel), #2c7be5 8%);
517
}
518
467
- .office-tab-close .material-symbols-outlined {
468
- font-size: 15px;
469
- line-height: 1;
519
+ .office-icon-button:disabled {
520
+ cursor: default;
521
+ opacity: 0.42;
522
}
523
472
- .office-toolbar-spacer {
473
- flex: 1 1 auto;
474
- min-width: 8px;
524
+ .office-icon-button .material-symbols-outlined,
525
+ .office-tab-icon,
526
+ .office-create-tile .material-symbols-outlined {
527
+ font-size: 21px;
528
+ line-height: 1;
529
}
530
477
- .office-button,
478
- .office-icon-button,
479
- .office-health-pill,
480
- .office-create-tile,
481
- .office-document-card {
482
- display: inline-flex;
483
- align-items: center;
484
- justify-content: center;
485
- gap: 7px;
486
- border: 1px solid color-mix(in srgb, var(--color-border) 64%, transparent);
487
- border-radius: 7px;
488
- background: color-mix(in srgb, var(--color-panel) 80%, transparent);
489
- color: var(--color-text);
490
- font: inherit;
491
- cursor: pointer;
531
+ .office-zoom {
532
+ min-width: 44px;
533
+ text-align: center;
534
+ font-size: 12px;
535
+ color: var(--color-text-secondary);
536
+ font-variant-numeric: tabular-nums;
537
}
538
494
- .office-button {
495
- min-height: 32px;
496
- padding: 5px 9px;
497
- font-size: 0.8rem;
498
- white-space: nowrap;
539
+ .office-tabs {
540
+ display: flex;
541
+ gap: 6px;
542
+ min-height: 42px;
543
+ padding: 7px 10px;
544
+ overflow-x: auto;
545
+ border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 22%);
546
+ background: color-mix(in srgb, var(--color-panel), var(--color-background) 28%);
547
}
548
501
- .office-icon-button {
502
- width: 32px;
503
- height: 32px;
504
- min-width: 32px;
505
- padding: 0;
549
+ .office-tab-shell {
550
+ display: grid;
551
+ grid-template-columns: minmax(0, 1fr) 28px;
552
+ align-items: center;
553
+ min-width: 150px;
554
+ max-width: 240px;
555
}
556
508
- .office-health-pill {
509
- min-height: 28px;
510
- padding: 4px 8px;
511
- cursor: default;
512
- font-size: 0.75rem;
513
- text-transform: capitalize;
514
- white-space: nowrap;
515
- color: var(--color-text-muted);
516
- background: color-mix(in srgb, var(--color-panel) 64%, transparent);
557
+ .office-tab-shell.is-system {
558
+ grid-template-columns: minmax(0, 1fr);
559
+ min-width: 172px;
560
}
561
519
- .office-health-pill.is-healthy {
520
- width: 28px;
521
- min-width: 28px;
522
- padding: 0;
523
- gap: 0;
562
+ .office-tab,
563
+ .office-tab-close {
564
+ height: 28px;
565
+ min-height: 28px;
566
+ border-radius: 7px;
567
}
568
526
- .office-health-dot {
527
- width: 7px;
528
- height: 7px;
529
- border-radius: 999px;
530
- background: color-mix(in srgb, var(--color-text-muted) 70%, transparent);
569
+ .office-tab {
570
+ display: flex;
571
+ align-items: center;
572
+ gap: 6px;
573
+ min-width: 0;
574
+ border-top-right-radius: 0;
575
+ border-bottom-right-radius: 0;
576
+ padding: 0 8px;
577
+ text-align: left;
578
}
579
533
- .office-health-pill.is-healthy .office-health-dot {
534
- background: #31c48d;
580
+ .office-tab-shell.is-system .office-tab {
581
+ border-radius: 7px;
582
}
583
537
- .office-health-pill.is-installing .office-health-dot {
538
- background: #f6ad55;
584
+ .office-tab-close {
585
+ display: grid;
586
+ place-items: center;
587
+ border-left: 0;
588
+ border-top-left-radius: 0;
589
+ border-bottom-left-radius: 0;
590
+ padding: 0;
591
}
592
541
- .office-health-pill.is-degraded .office-health-dot,
542
- .office-health-pill.is-failed .office-health-dot {
543
- background: #f05252;
593
+ .office-tab-close .material-symbols-outlined {
594
+ font-size: 17px;
595
}
596
546
- .office-button:hover:not(:disabled),
547
- .office-icon-button:hover:not(:disabled),
548
- .office-create-tile:hover,
549
- .office-document-card:hover {
550
- background: color-mix(in srgb, var(--color-background-hover) 70%, transparent);
551
- border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
597
+ .office-tab-shell.is-active .office-tab,
598
+ .office-tab-shell.is-active .office-tab-close {
599
+ border-color: color-mix(in srgb, #2c7be5, var(--color-border) 36%);
600
+ background: color-mix(in srgb, #2c7be5, var(--color-panel) 88%);
601
}
602
554
- .office-button:disabled,
555
- .office-icon-button:disabled {
556
- cursor: not-allowed;
557
- opacity: 0.42;
603
+ .office-tab-shell.is-dirty .office-tab-title::after {
604
+ content: " *";
605
+ color: #2ca58d;
606
}
607
560
- .office-button .material-symbols-outlined,
561
- .office-icon-button .material-symbols-outlined {
562
- font-size: 18px;
608
+ .office-tab-title {
609
+ min-width: 0;
610
+ overflow: hidden;
611
+ text-overflow: ellipsis;
612
+ white-space: nowrap;
613
+ font-size: 12px;
614
+ line-height: 1;
615
}
616
565
- .office-status-line {
617
+ .office-state-line {
618
display: flex;
619
align-items: center;
620
gap: 8px;
569
- min-height: 32px;
570
- padding: 5px 10px;
571
- border-bottom: 1px solid color-mix(in srgb, var(--color-border) 44%, transparent);
572
- font-size: 0.82rem;
573
- color: var(--color-text);
621
+ min-height: 34px;
622
+ padding: 6px 12px;
623
+ border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 28%);
624
+ color: var(--color-text-secondary);
625
+ font-size: 12px;
626
}
627
628
.office-body {
629
position: relative;
630
display: flex;
631
flex: 1 1 auto;
580
- min-width: 0;
632
min-height: 0;
633
overflow: hidden;
634
+ background:
635
+ linear-gradient(90deg, rgba(44, 123, 229, 0.05), transparent 38%),
636
+ linear-gradient(180deg, rgba(44, 165, 141, 0.04), transparent 46%),
637
+ #eef2f7;
638
+ color: #172033;
639
}
640
641
.office-start {
586
- display: flex;
642
flex: 1 1 auto;
643
min-width: 0;
589
- min-height: 0;
590
- flex-direction: column;
591
- gap: 22px;
592
- padding: 18px;
644
overflow: auto;
594
- }
595
-
596
- .office-bootstrap {
597
- display: flex;
598
- flex: 1 1 auto;
599
- min-width: 0;
600
- min-height: 0;
601
- flex-direction: column;
602
- align-items: center;
603
- justify-content: center;
604
- gap: 16px;
605
- padding: clamp(24px, 7cqi, 56px);
606
- overflow: auto;
607
- text-align: center;
608
- }
609
-
610
- .office-setup-mark {
611
- display: inline-flex;
612
- align-items: center;
613
- justify-content: center;
614
- width: 58px;
615
- height: 58px;
616
- border: 1px solid color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
617
- border-radius: 7px;
618
- color: color-mix(in srgb, var(--color-primary) 70%, var(--color-text));
619
- background: color-mix(in srgb, var(--color-panel) 78%, transparent);
620
- }
621
-
622
- .office-setup-mark.is-busy {
623
- border-color: color-mix(in srgb, var(--color-primary) 42%, var(--color-border));
624
- }
625
-
626
- .office-setup-mark.is-alert {
627
- border-color: color-mix(in srgb, #f05252 48%, var(--color-border));
628
- color: #f05252;
629
- }
630
-
631
- .office-setup-mark .material-symbols-outlined {
632
- font-size: 30px;
633
- line-height: 1;
634
- }
635
-
636
- .office-setup-copy {
637
- display: flex;
638
- align-items: center;
639
- min-width: 0;
640
- flex-direction: column;
641
- gap: 6px;
642
- max-width: 460px;
643
- line-height: 1.35;
644
- }
645
-
646
- .office-setup-copy > span {
647
- color: var(--color-text-muted);
648
- font-size: 0.74rem;
649
- font-weight: 700;
650
- letter-spacing: 0;
651
- text-transform: uppercase;
652
- }
653
-
654
- .office-setup-copy > strong {
655
- color: var(--color-text);
656
- font-size: clamp(1.05rem, 4cqi, 1.35rem);
657
- font-weight: 760;
658
- }
659
-
660
- .office-setup-copy > p {
661
- margin: 0;
662
- color: var(--color-text-muted);
663
- font-size: 0.9rem;
664
- }
665
-
666
- .office-setup-progress {
667
- position: relative;
668
- width: min(260px, 72cqi);
669
- height: 5px;
670
- overflow: hidden;
671
- border-radius: 999px;
672
- background: color-mix(in srgb, var(--color-border) 45%, transparent);
673
- }
674
-
675
- .office-setup-progress > span {
676
- position: absolute;
677
- inset: 0 auto 0 0;
678
- width: 42%;
679
- border-radius: inherit;
680
- background: color-mix(in srgb, var(--color-primary) 72%, var(--color-text) 28%);
681
- animation: office-setup-progress 1.45s ease-in-out infinite;
682
- }
683
-
684
- .office-setup-progress.is-paused > span {
685
- width: 100%;
686
- opacity: 0.42;
687
- animation: none;
688
- }
689
-
690
- .office-bootstrap-actions,
691
- .office-template-grid {
692
- display: flex;
693
- justify-content: center;
694
- flex-wrap: wrap;
695
- gap: 8px;
645
+ padding: 22px;
646
}
647
648
.office-dashboard-section {
699
- display: flex;
700
- flex-direction: column;
701
- gap: 10px;
702
- width: 100%;
703
- max-width: 1180px;
649
+ margin: 0 0 22px;
650
}
651
652
.office-dashboard-heading {
707
- color: var(--color-text-muted);
708
- font-size: 0.76rem;
653
+ margin: 0 0 9px;
654
+ color: #536274;
655
+ font-size: 12px;
656
font-weight: 700;
710
- letter-spacing: 0;
657
text-transform: uppercase;
658
}
659
714
- .office-template-grid {
715
- justify-content: flex-start;
716
- }
717
-
660
+ .office-template-grid,
661
.office-card-grid {
662
display: grid;
720
- grid-template-columns: repeat(auto-fill, minmax(min(190px, 100%), 1fr));
663
+ grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
664
gap: 10px;
722
- width: 100%;
665
}
666
667
.office-create-tile {
726
- min-width: 132px;
727
- min-height: 88px;
728
- flex-direction: column;
729
- padding: 12px;
730
- font-weight: 650;
668
+ display: grid;
669
+ grid-template-rows: 34px auto auto;
670
+ align-items: center;
671
+ min-height: 122px;
672
+ padding: 14px;
673
+ border: 1px solid #d9dee7;
674
+ border-radius: 8px;
675
+ background: #ffffff;
676
+ color: #172033;
677
+ box-shadow: 0 12px 30px rgba(35, 48, 68, 0.08);
678
+ text-align: left;
679
+ transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
680
}
681
733
- .office-create-tile .material-symbols-outlined {
734
- font-size: 28px;
682
+ .office-create-tile:hover,
683
+ .office-document-card:hover {
684
+ border-color: #8db5ef;
685
+ box-shadow: 0 18px 42px rgba(35, 48, 68, 0.13);
686
+ transform: translateY(-1px);
687
+ }
688
+
689
+ .office-create-tile strong,
690
+ .office-card-title {
691
+ min-width: 0;
692
+ overflow: hidden;
693
+ text-overflow: ellipsis;
694
+ white-space: nowrap;
695
+ font-size: 13px;
696
+ }
697
+
698
+ .office-create-tile small,
699
+ .office-document-card small {
700
+ min-width: 0;
701
+ overflow: hidden;
702
+ text-overflow: ellipsis;
703
+ white-space: nowrap;
704
+ color: #536274;
705
+ font-size: 11px;
706
}
707
708
+ .office-create-tile.is-markdown .material-symbols-outlined { color: #2ca58d; }
709
+ .office-create-tile.is-docx .material-symbols-outlined { color: #2c7be5; }
710
+ .office-create-tile.is-sheet .material-symbols-outlined { color: #8f6f19; }
711
+ .office-create-tile.is-deck .material-symbols-outlined { color: #b84a62; }
712
+
713
.office-document-card {
714
position: relative;
715
display: grid;
740
- grid-template-rows: auto auto auto;
741
- align-content: start;
742
- justify-content: stretch;
743
- gap: 8px;
744
- min-width: 0;
745
- min-height: 196px;
716
+ grid-template-rows: 118px 18px 16px;
717
+ gap: 7px;
718
+ min-height: 172px;
719
padding: 10px;
720
+ border: 1px solid #d9dee7;
721
+ border-radius: 8px;
722
+ background: #ffffff;
723
+ color: #172033;
724
+ box-shadow: 0 12px 30px rgba(35, 48, 68, 0.08);
725
text-align: left;
748
- overflow: hidden;
749
- }
750
-
751
- .office-document-card.is-open {
752
- border-color: color-mix(in srgb, var(--color-primary) 36%, var(--color-border));
726
+ transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
727
}
728
729
.office-card-badge {
730
position: absolute;
757
- top: 8px;
758
- right: 8px;
759
- z-index: 2;
760
- max-width: calc(100% - 16px);
761
- overflow: hidden;
762
- padding: 2px 6px;
763
- border: 1px solid color-mix(in srgb, var(--color-primary) 42%, transparent);
731
+ top: 9px;
732
+ right: 9px;
733
+ z-index: 1;
734
border-radius: 999px;
765
- background: color-mix(in srgb, var(--color-background) 82%, transparent);
766
- color: var(--color-text);
767
- font-size: 0.68rem;
735
+ padding: 2px 7px;
736
+ background: color-mix(in srgb, #2ca58d, var(--color-panel) 20%);
737
+ color: white;
738
+ font-size: 10px;
739
font-weight: 700;
769
- line-height: 1.2;
770
- text-overflow: ellipsis;
771
- white-space: nowrap;
740
}
741
742
.office-card-preview {
775
- position: relative;
776
- display: grid;
777
- align-items: stretch;
778
- width: 100%;
779
- aspect-ratio: 16 / 10;
780
- min-height: 112px;
743
+ min-width: 0;
744
+ min-height: 0;
745
overflow: hidden;
782
- border: 1px solid color-mix(in srgb, var(--color-border) 58%, transparent);
746
+ border: 1px solid #d9dee7;
747
border-radius: 6px;
784
- background: color-mix(in srgb, var(--color-background) 76%, #fff 4%);
748
+ background: #f8fafc;
749
+ color: #172033;
750
+ }
751
+
752
+ .office-card-preview.is-large {
753
+ width: min(720px, 100%);
754
+ min-height: 340px;
755
+ border-color: #d2d8e3;
756
+ background: #ffffff;
757
+ box-shadow: 0 18px 44px rgba(35, 48, 68, 0.12);
758
}
759
760
.office-page-preview,
761
.office-sheet-preview,
762
.office-slide-preview,
763
.office-preview-fallback {
791
- min-width: 0;
792
- min-height: 0;
793
- }
794
-
795
- .office-page-preview {
764
display: flex;
765
flex-direction: column;
766
gap: 5px;
799
- padding: 12px 13px;
800
- background:
801
- linear-gradient(to bottom, transparent 0, transparent 21px, color-mix(in srgb, var(--color-border) 30%, transparent) 22px),
802
- color-mix(in srgb, var(--color-panel) 72%, transparent);
803
- background-size: 100% 22px;
804
- color: var(--color-text);
767
+ height: 100%;
768
+ padding: 10px;
769
+ font-size: 11px;
770
+ line-height: 1.35;
771
}
772
773
.office-page-preview span,
774
+ .office-sheet-row span,
775
.office-slide-line span,
809
- .office-slide-line strong,
810
- .office-sheet-row span {
776
+ .office-slide-line strong {
777
min-width: 0;
778
overflow: hidden;
779
text-overflow: ellipsis;
780
white-space: nowrap;
781
}
782
817
- .office-page-preview span {
818
- font-size: 0.7rem;
819
- line-height: 1.25;
820
- }
821
-
822
- .office-sheet-preview {
823
- min-width: 0;
824
- padding: 8px;
825
- background: color-mix(in srgb, var(--color-panel) 74%, transparent);
826
- }
827
-
783
.office-sheet-row {
784
display: grid;
830
- grid-template-columns: repeat(4, minmax(0, 1fr));
831
- min-height: 20px;
832
- }
833
-
834
- .office-sheet-row + .office-sheet-row {
835
- border-top: 1px solid color-mix(in srgb, var(--color-border) 34%, transparent);
785
+ grid-template-columns: repeat(3, minmax(0, 1fr));
786
+ gap: 5px;
787
}
788
789
.office-sheet-row span {
839
- padding: 4px 5px;
840
- border-right: 1px solid color-mix(in srgb, var(--color-border) 34%, transparent);
841
- color: var(--color-text-muted);
842
- font-size: 0.66rem;
843
- line-height: 1.1;
844
- }
845
-
846
- .office-sheet-row span:last-child {
847
- border-right: 0;
848
- }
849
-
850
- .office-slide-preview {
851
- display: flex;
852
- flex-direction: column;
853
- justify-content: center;
854
- gap: 10px;
855
- padding: 14px;
856
- background:
857
- linear-gradient(135deg, color-mix(in srgb, var(--color-panel) 80%, transparent), color-mix(in srgb, var(--color-background) 84%, var(--color-primary) 10%));
858
- }
859
-
860
- .office-slide-line {
861
- display: flex;
862
- min-width: 0;
863
- flex-direction: column;
864
- gap: 3px;
865
- }
866
-
867
- .office-slide-line strong {
868
- color: var(--color-text);
869
- font-size: 0.78rem;
870
- font-weight: 760;
871
- line-height: 1.15;
872
- }
873
-
874
- .office-slide-line span {
875
- color: var(--color-text-muted);
876
- font-size: 0.68rem;
877
- line-height: 1.15;
790
+ border-bottom: 1px solid #d9dee7;
791
+ padding-bottom: 2px;
792
}
793
794
.office-preview-fallback {
881
- display: flex;
795
align-items: center;
796
justify-content: center;
884
- color: color-mix(in srgb, var(--color-primary) 64%, var(--color-text));
885
- background: color-mix(in srgb, var(--color-panel) 76%, transparent);
797
+ color: #64748b;
798
}
799
800
.office-preview-fallback .material-symbols-outlined {
801
font-size: 36px;
802
}
803
892
- .office-card-title {
893
- display: block;
804
+ .office-editor-wrap {
805
+ display: flex;
806
+ flex: 1 1 auto;
807
+ flex-direction: column;
808
min-width: 0;
895
- overflow: hidden;
896
- text-overflow: ellipsis;
897
- white-space: nowrap;
898
- color: var(--color-text);
899
- font-size: 0.86rem;
900
- font-weight: 720;
901
- line-height: 1.2;
809
+ min-height: 0;
810
}
811
904
- .office-document-card small {
905
- display: block;
906
- min-width: 0;
812
+ .office-editor-scroll {
813
+ flex: 1 1 auto;
814
+ min-height: 0;
815
+ overflow: auto;
816
+ padding: 30px 24px;
817
+ }
818
+
819
+ .office-editor-scroll.is-desktop {
820
+ display: flex;
821
overflow: hidden;
908
- color: var(--color-text-muted);
909
- font-size: 0.7rem;
910
- line-height: 1.2;
911
- text-overflow: ellipsis;
912
- white-space: nowrap;
822
+ padding: 0;
823
+ background: #1f2329;
824
}
825
915
- .office-frame-wrap {
826
+ .office-desktop-wrap {
827
display: flex;
917
- position: absolute;
918
- inset: 0;
828
flex: 1 1 auto;
829
width: 100%;
830
height: 100%;
831
min-width: 0;
832
min-height: 0;
924
- background: #fff;
833
+ aspect-ratio: auto;
834
+ background: #1f2329;
835
}
836
927
- .office-frame-wrap iframe {
837
+ .office-desktop-frame {
838
flex: 1 1 auto;
839
width: 100%;
840
height: 100%;
931
- min-width: 0;
841
min-height: 0;
842
+ aspect-ratio: auto;
843
border: 0;
934
- background: #fff;
844
+ background: #20242a;
845
}
846
937
- .office-panel .spinning {
938
- display: inline-block;
939
- animation: office-spin 0.8s linear infinite;
847
+ .office-rich-editor,
848
+ .office-source-editor,
849
+ .office-docx-stage,
850
+ .office-preview-editor {
851
+ transform: scale(var(--office-zoom));
852
+ transform-origin: top center;
853
+ margin: 0 auto;
854
}
855
942
- @keyframes office-spin {
943
- to { transform: rotate(360deg); }
856
+ .office-rich-editor {
857
+ box-sizing: border-box;
858
+ width: min(760px, 100%);
859
+ min-height: min(980px, calc(100vh - 170px));
860
+ padding: 54px 58px;
861
+ border: 1px solid #d2d8e3;
862
+ border-radius: 8px;
863
+ outline: none;
864
+ background: #ffffff;
865
+ box-shadow: 0 18px 44px rgba(35, 48, 68, 0.16);
866
+ color: #1f2937;
867
+ font-size: 15px;
868
+ line-height: 1.7;
869
}
870
946
- @keyframes office-setup-progress {
947
- 0% { transform: translateX(-110%); }
948
- 55% { transform: translateX(85%); }
949
- 100% { transform: translateX(250%); }
871
+ .office-rich-editor:focus,
872
+ .office-source-editor:focus,
873
+ .office-docx-pages:focus-within .office-docx-page:first-child,
874
+ .office-docx-pages:focus .office-docx-page:first-child {
875
+ border-color: #8db5ef;
876
+ box-shadow:
877
+ 0 18px 44px rgba(35, 48, 68, 0.16),
878
+ 0 0 0 3px rgba(44, 123, 229, 0.16);
879
}
880
952
- @media (prefers-reduced-motion: reduce) {
953
- .office-panel .spinning,
954
- .office-setup-progress > span {
955
- animation: none;
956
- }
881
+ .office-rich-editor h1,
882
+ .office-rich-editor h2,
883
+ .office-rich-editor h3 {
884
+ line-height: 1.25;
885
+ margin: 0 0 0.65em;
886
}
887
959
- @media (max-width: 520px) {
960
- .office-button span:last-child {
961
- display: none;
962
- }
963
- .office-health-pill span:last-child {
964
- display: none;
965
- }
966
- .office-tab-shell {
967
- flex-basis: 152px;
968
- min-width: 116px;
969
- }
970
- .office-create-tile {
971
- min-width: 104px;
972
- }
888
+ .office-rich-editor p,
889
+ .office-rich-editor ul,
890
+ .office-rich-editor table {
891
+ margin: 0 0 1em;
892
+ }
893
+
894
+ .office-rich-editor table {
895
+ width: 100%;
896
+ border-collapse: collapse;
897
+ }
898
+
899
+ .office-rich-editor td,
900
+ .office-rich-editor th {
901
+ border: 1px solid #d9dee7;
902
+ padding: 6px 8px;
903
+ }
904
+
905
+ .office-source-editor {
906
+ box-sizing: border-box;
907
+ width: min(920px, 100%);
908
+ min-height: min(980px, calc(100vh - 170px));
909
+ padding: 22px;
910
+ border: 1px solid #d2d8e3;
911
+ border-radius: 8px;
912
+ outline: none;
913
+ background: #ffffff;
914
+ color: #172033;
915
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
916
+ font-size: 13px;
917
+ line-height: 1.65;
918
+ resize: none;
919
+ }
920
+
921
+ .office-docx-stage {
922
+ width: min(860px, 100%);
923
+ outline: none;
924
+ }
925
+
926
+ .office-docx-pages {
927
+ outline: none;
928
+ }
929
+
930
+ .office-docx-pages.is-native {
931
+ display: grid;
932
+ gap: 20px;
933
+ justify-items: center;
934
+ }
935
+
936
+ .office-docx-page {
937
+ box-sizing: border-box;
938
+ width: min(760px, 100%);
939
+ min-height: 980px;
940
+ margin: 0 auto 20px;
941
+ padding: 70px 74px;
942
+ border: 1px solid #d2d8e3;
943
+ border-radius: 6px;
944
+ background: #ffffff;
945
+ box-shadow: 0 18px 44px rgba(35, 48, 68, 0.16);
946
+ color: #1f2937;
947
+ font-family: "Liberation Serif", "Times New Roman", serif;
948
+ font-size: 16px;
949
+ line-height: 1.55;
950
+ }
951
+
952
+ .office-docx-page.is-native-tile {
953
+ width: auto;
954
+ min-height: 0;
955
+ padding: 0;
956
+ overflow: hidden;
957
+ line-height: 0;
958
+ }
959
+
960
+ .office-docx-page.is-native-tile img {
961
+ display: block;
962
+ width: min(920px, 100%);
963
+ height: auto;
964
+ user-select: none;
965
+ }
966
+
967
+ .office-docx-page p {
968
+ margin: 0 0 0.85em;
969
+ }
970
+
971
+ .office-preview-editor {
972
+ display: grid;
973
+ place-items: start center;
974
+ width: min(860px, 100%);
975
+ min-height: 420px;
976
+ padding: 18px;
977
+ }
978
+
979
+ .office-panel .spinning {
980
+ animation: office-spin 0.8s linear infinite;
981
}
982
975
- @container (max-width: 560px) {
976
- .office-button span:last-child {
977
- display: none;
983
+ @keyframes office-spin {
984
+ to { transform: rotate(360deg); }
985
+ }
986
+
987
+ @container (max-width: 680px) {
988
+ .office-toolbar {
989
+ gap: 6px;
990
+ padding-inline: 8px;
991
}
979
- .office-health-pill span:last-child {
980
- display: none;
992
+
993
+ .office-command-button {
994
+ max-width: 132px;
995
+ padding-inline: 9px;
996
}
982
- .office-tab-shell {
983
- flex-basis: 152px;
984
- min-width: 116px;
997
+
998
+ .office-template-grid,
999
+ .office-card-grid {
1000
+ grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
1001
}
986
- .office-create-tile {
987
- min-width: 104px;
1002
+
1003
+ .office-rich-editor,
1004
+ .office-docx-page {
1005
+ min-height: 720px;
1006
+ padding: 34px 28px;
1007
}
1008
}
1009
</style>
plugins/_office/webui/office-store.js
+1681
-666
@@ -1,67 +1,307 @@
1
import { createStore } from "/js/AlpineStore.js";
2
import { callJsonApi } from "/js/api.js";
3
+import { getNamespacedClient } from "/js/websocket.js";
4
+
5
+const officeSocket = getNamespacedClient("/ws");
6
+officeSocket.addHandlers(["ws_webui"]);
7
+
8
+const SAVE_MESSAGE_MS = 1800;
9
+const INPUT_PUSH_DELAY_MS = 650;
10
+const DESKTOP_HEARTBEAT_MS = 3500;
11
+const DESKTOP_RESIZE_DELAY_MS = 80;
12
+const XPRA_DESKTOP_PRIME_INTERVAL_MS = 220;
13
+const XPRA_DESKTOP_PRIME_ATTEMPTS = 120;
14
+const SYSTEM_DESKTOP_FILE_ID = "system-desktop";
15
+const MAX_HISTORY = 80;
16
+
17
+function currentContextId() {
18
+ try {
19
+ return globalThis.getContext?.() || "";
20
+ } catch {
21
+ return "";
22
+ }
23
+}
24
+
25
+function formatBytes(value) {
26
+ const size = Number(value || 0);
27
+ if (!Number.isFinite(size) || size <= 0) return "";
28
+ const units = ["B", "KB", "MB", "GB"];
29
+ let amount = size;
30
+ let index = 0;
31
+ while (amount >= 1024 && index < units.length - 1) {
32
+ amount /= 1024;
33
+ index += 1;
34
+ }
35
+ const digits = amount >= 10 || index === 0 ? 0 : 1;
36
+ return `${amount.toFixed(digits)} ${units[index]}`;
37
+}
38
4
-const FRAME_NAME_PREFIX = "a0-office-frame";
5
-const COLLABORA_STATE_VERSION = "2026-04-26.1";
6
-const COLLABORA_STATE_MARKER = "a0.office.collaboraStateVersion";
7
-const SERVICE_WORKER_CLEANUP_MARKER = "a0.office.serviceWorkerCleanupReloaded";
8
-const SETUP_POLL_INTERVAL_MS = 4000;
39
+function basename(path = "") {
40
+ const value = String(path || "").split("?")[0].split("#")[0];
41
+ return value.split("/").filter(Boolean).pop() || "Untitled";
42
+}
43
10
-function makeFrameName() {
11
- const id = globalThis.crypto?.randomUUID?.()
12
- || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
13
- return `${FRAME_NAME_PREFIX}-${id}`;
44
+function extensionOf(path = "") {
45
+ const name = basename(path).toLowerCase();
46
+ const index = name.lastIndexOf(".");
47
+ return index >= 0 ? name.slice(index + 1) : "";
48
}
49
16
-function parseMessage(data) {
17
- if (typeof data === "string") {
18
- try {
19
- return JSON.parse(data);
20
- } catch {
21
- return { MessageId: data };
50
+function uniqueTabId(session = {}) {
51
+ return String(session.file_id || session.session_id || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`);
52
+}
53
+
54
+function escapeHtml(value = "") {
55
+ return String(value)
56
+ .replaceAll("&", "&")
57
+ .replaceAll("<", "<")
58
+ .replaceAll(">", ">")
59
+ .replaceAll('"', """);
60
+}
61
+
62
+function inlineMarkdown(value = "") {
63
+ return escapeHtml(value)
64
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
65
+ .replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>")
66
+ .replace(/`([^`]+)`/g, "<code>$1</code>")
67
+ .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
68
+}
69
+
70
+function markdownToHtml(markdown = "") {
71
+ const normalized = String(markdown || "").replace(/\r\n?/g, "\n");
72
+ const lines = normalized.split("\n");
73
+ const html = [];
74
+ let paragraph = [];
75
+ let list = [];
76
+
77
+ const flushParagraph = () => {
78
+ if (!paragraph.length) return;
79
+ html.push(`<p>${inlineMarkdown(paragraph.join(" "))}</p>`);
80
+ paragraph = [];
81
+ };
82
+ const flushList = () => {
83
+ if (!list.length) return;
84
+ html.push(`<ul>${list.map((line) => `<li>${inlineMarkdown(line)}</li>`).join("")}</ul>`);
85
+ list = [];
86
+ };
87
+
88
+ for (let index = 0; index < lines.length; index += 1) {
89
+ const raw = lines[index];
90
+ const line = raw.trimEnd();
91
+ if (!line.trim()) {
92
+ flushParagraph();
93
+ flushList();
94
+ continue;
95
}
96
+ const heading = /^(#{1,4})\s+(.+)$/.exec(line);
97
+ if (heading) {
98
+ flushParagraph();
99
+ flushList();
100
+ const level = Math.min(4, heading[1].length);
101
+ html.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`);
102
+ continue;
103
+ }
104
+ const bullet = /^\s*[-*]\s+(.+)$/.exec(line);
105
+ if (bullet) {
106
+ flushParagraph();
107
+ list.push(bullet[1]);
108
+ continue;
109
+ }
110
+ flushList();
111
+ paragraph.push(line.trim());
112
+ }
113
+
114
+ flushParagraph();
115
+ flushList();
116
+ if (!html.length || /\n\s*$/.test(normalized)) {
117
+ html.push("<p><br></p>");
118
}
24
- return data && typeof data === "object" ? data : {};
119
+ return html.join("") || "<p></p>";
120
}
121
27
-function nextAnimationFrame() {
28
- return new Promise((resolve) => {
29
- const schedule = globalThis.requestAnimationFrame || ((callback) => globalThis.setTimeout(callback, 16));
30
- schedule(() => resolve());
31
- });
122
+function htmlToMarkdown(root) {
123
+ if (!root) return "";
124
+
125
+ const walk = (node) => {
126
+ if (node.nodeType === Node.TEXT_NODE) return node.textContent || "";
127
+ if (node.nodeType !== Node.ELEMENT_NODE) return "";
128
+ const tag = node.tagName.toLowerCase();
129
+ const childText = () => Array.from(node.childNodes).map(walk).join("");
130
+
131
+ if (tag === "br") return "\n";
132
+ if (tag === "strong" || tag === "b") return `**${childText().trim()}**`;
133
+ if (tag === "em" || tag === "i") return `*${childText().trim()}*`;
134
+ if (tag === "code") return `\`${childText().trim()}\``;
135
+ if (tag === "a") {
136
+ const href = node.getAttribute("href") || "";
137
+ const label = childText().trim() || href;
138
+ return href ? `[${label}](${href})` : label;
139
+ }
140
+ if (/^h[1-6]$/.test(tag)) return `\n${"#".repeat(Number(tag[1]))} ${childText().trim()}\n\n`;
141
+ if (tag === "li") return `- ${childText().trim()}\n`;
142
+ if (tag === "ul" || tag === "ol") return `\n${childText()}\n`;
143
+ if (tag === "tr") {
144
+ const cells = Array.from(node.children).map((cell) => cell.textContent?.trim() || "");
145
+ return `| ${cells.join(" | ")} |\n`;
146
+ }
147
+ if (tag === "table") return `\n${Array.from(node.querySelectorAll("tr")).map(walk).join("")}\n`;
148
+ if (tag === "p" || tag === "div" || tag === "section" || tag === "article") {
149
+ const text = childText().trim();
150
+ return text ? `${text}\n\n` : "";
151
+ }
152
+ return childText();
153
+ };
154
+
155
+ return Array.from(root.childNodes)
156
+ .map(walk)
157
+ .join("")
158
+ .replace(/\n{3,}/g, "\n\n")
159
+ .trimEnd();
160
}
161
34
-function normalizeTabId(value) {
35
- return String(value || "").trim();
162
+function textToPageHtml(text = "") {
163
+ const paragraphs = String(text || "")
164
+ .replace(/\r\n?/g, "\n")
165
+ .split(/\n+/)
166
+ .map((line) => line.trim())
167
+ .filter(Boolean);
168
+ const lines = paragraphs.length ? paragraphs : [""];
169
+ const pages = [];
170
+ for (let index = 0; index < lines.length; index += 18) {
171
+ pages.push(lines.slice(index, index + 18));
172
+ }
173
+ return pages
174
+ .map((page, index) => (
175
+ `<section class="office-docx-page" data-page="${index + 1}">`
176
+ + page.map((line) => `<p>${escapeHtml(line)}</p>`).join("")
177
+ + "</section>"
178
+ ))
179
+ .join("");
180
}
181
38
-function makeTabId(session) {
39
- return normalizeTabId(session?.session_id)
40
- || normalizeTabId(session?.file_id)
41
- || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
182
+function nativeTilesToHtml(tiles = []) {
183
+ return tiles
184
+ .filter((tile) => tile?.image)
185
+ .map((tile) => {
186
+ const twips = encodeURIComponent(JSON.stringify(tile.twips || {}));
187
+ const width = Number(tile.width || 1);
188
+ const height = Number(tile.height || 1);
189
+ return (
190
+ `<section class="office-docx-page is-native-tile" data-tile-index="${Number(tile.index || 0)}" data-twips="${twips}">`
191
+ + `<img src="${escapeHtml(tile.image)}" width="${width}" height="${height}" alt="" draggable="false">`
192
+ + "</section>"
193
+ );
194
+ })
195
+ .join("");
196
}
197
44
-function sameDocument(left = {}, right = {}) {
45
- const leftFileId = normalizeTabId(left.file_id);
46
- const rightFileId = normalizeTabId(right.file_id);
47
- if (leftFileId && rightFileId) return leftFileId === rightFileId;
48
- const leftPath = String(left.path || "").trim();
49
- const rightPath = String(right.path || "").trim();
50
- return Boolean(leftPath && rightPath && leftPath === rightPath);
198
+function docxEditorText(element) {
199
+ if (!element) return "";
200
+ const pages = Array.from(element.querySelectorAll(".office-docx-page"));
201
+ if (!pages.length) return element.innerText || "";
202
+ return pages
203
+ .map((page) => Array.from(page.querySelectorAll("p"))
204
+ .map((p) => p.innerText.trim())
205
+ .filter(Boolean)
206
+ .join("\n"))
207
+ .filter(Boolean)
208
+ .join("\n\n");
209
}
210
53
-function formatBytes(value) {
54
- const size = Number(value || 0);
55
- if (!Number.isFinite(size) || size <= 0) return "";
56
- const units = ["B", "KB", "MB", "GB"];
57
- let amount = size;
58
- let index = 0;
59
- while (amount >= 1024 && index < units.length - 1) {
60
- amount /= 1024;
61
- index += 1;
211
+function editorContainsFocus(element) {
212
+ const active = document.activeElement;
213
+ return Boolean(element && active && (element === active || element.contains(active)));
214
+}
215
+
216
+function placeCaretAtEnd(element) {
217
+ if (!element) return;
218
+ if (element.tagName === "TEXTAREA" || element.tagName === "INPUT") {
219
+ const length = element.value?.length || 0;
220
+ element.selectionStart = length;
221
+ element.selectionEnd = length;
222
+ return;
223
}
63
- const digits = amount >= 10 || index === 0 ? 0 : 1;
64
- return `${amount.toFixed(digits)} ${units[index]}`;
224
+ const selection = globalThis.getSelection?.();
225
+ const range = document.createRange?.();
226
+ if (!selection || !range) return;
227
+ range.selectNodeContents(element);
228
+ range.collapse(false);
229
+ selection.removeAllRanges();
230
+ selection.addRange(range);
231
+}
232
+
233
+function normalizeDocument(doc = {}) {
234
+ const path = doc.path || "";
235
+ const extension = String(doc.extension || extensionOf(path)).toLowerCase();
236
+ return {
237
+ ...doc,
238
+ extension,
239
+ title: doc.title || doc.basename || basename(path),
240
+ basename: doc.basename || basename(path),
241
+ path,
242
+ };
243
+}
244
+
245
+function normalizeSession(payload = {}) {
246
+ const document = normalizeDocument(payload.document || payload);
247
+ const extension = String(payload.extension || document.extension || "").toLowerCase();
248
+ return {
249
+ ...payload,
250
+ document,
251
+ extension,
252
+ file_id: payload.file_id || document.file_id || "",
253
+ path: document.path || payload.path || "",
254
+ title: payload.title || document.title || document.basename || basename(document.path),
255
+ tab_id: uniqueTabId(payload),
256
+ text: String(payload.text || ""),
257
+ tiles: Array.isArray(payload.tiles) ? payload.tiles : [],
258
+ preview: payload.preview || document.preview || {},
259
+ native: payload.native || {},
260
+ desktop: payload.desktop || null,
261
+ desktop_session_id: payload.desktop_session_id || payload.desktop?.session_id || "",
262
+ dirty: false,
263
+ };
264
+}
265
+
266
+async function callOffice(action, payload = {}) {
267
+ return await callJsonApi("/plugins/_office/office_session", {
268
+ action,
269
+ ctxid: currentContextId(),
270
+ ...payload,
271
+ });
272
+}
273
+
274
+async function requestOffice(eventType, payload = {}, timeoutMs = 5000) {
275
+ const response = await officeSocket.request(eventType, {
276
+ ctxid: currentContextId(),
277
+ ...payload,
278
+ }, { timeoutMs });
279
+ const results = Array.isArray(response?.results) ? response.results : [];
280
+ const first = results.find((item) => item?.ok === true && isOfficeSocketData(item?.data))
281
+ || results.find((item) => item?.ok === true);
282
+ if (!first) {
283
+ const error = results.find((item) => item?.error)?.error;
284
+ throw new Error(error?.error || error?.code || `${eventType} failed`);
285
+ }
286
+ if (first.data?.office_error) {
287
+ const error = first.data.office_error;
288
+ throw new Error(error.error || error.code || `${eventType} failed`);
289
+ }
290
+ return first.data || {};
291
+}
292
+
293
+function isOfficeSocketData(data) {
294
+ if (!data || typeof data !== "object") return false;
295
+ return (
296
+ Object.prototype.hasOwnProperty.call(data, "office_error")
297
+ || Object.prototype.hasOwnProperty.call(data, "ok")
298
+ || Object.prototype.hasOwnProperty.call(data, "session_id")
299
+ || Object.prototype.hasOwnProperty.call(data, "document")
300
+ || Object.prototype.hasOwnProperty.call(data, "tiles")
301
+ || Object.prototype.hasOwnProperty.call(data, "native")
302
+ || Object.prototype.hasOwnProperty.call(data, "desktop")
303
+ || Object.prototype.hasOwnProperty.call(data, "closed")
304
+ );
305
}
306
307
const model = {
@@ -72,21 +312,39 @@ const model = {
312
activeTabId: "",
313
session: null,
314
loading: false,
315
+ saving: false,
316
+ dirty: false,
317
error: "",
318
message: "",
77
- frameReady: false,
78
- frameName: FRAME_NAME_PREFIX,
319
+ sourceMode: false,
320
+ editorText: "",
321
+ zoom: 1,
322
_root: null,
80
- _messageBound: false,
81
- _frameTimer: null,
82
- _frameRecoveryTimer: null,
83
- _frameAttempt: 0,
84
- _frameRecoveryTried: false,
85
- _frameOrigin: "",
323
_mode: "canvas",
324
+ _saveMessageTimer: null,
325
+ _inputTimer: null,
326
+ _history: [],
327
+ _historyIndex: -1,
328
+ _rendering: false,
329
+ _pendingFocus: false,
330
+ _pendingFocusEnd: true,
331
+ _focusAttempts: 0,
332
+ _richEditor: null,
333
+ _docxEditor: null,
334
+ _nativeEventQueue: Promise.resolve(),
335
_floatingCleanup: null,
88
- _saveWaiters: [],
89
- _statusPollTimer: null,
336
+ _desktopHeartbeatTimer: null,
337
+ _desktopHeartbeatSessionId: "",
338
+ _desktopHeartbeatTabId: "",
339
+ _desktopHeartbeatMisses: 0,
340
+ _desktopResizeCleanup: null,
341
+ _desktopResizeTimer: null,
342
+ _desktopResizeKey: "",
343
+ _desktopResizeSuspended: false,
344
+ _desktopResizePending: false,
345
+ _desktopPrimeTimer: null,
346
+ _desktopPrimeAttempts: 0,
347
+ _desktopStarting: null,
348
349
async init(element = null) {
350
return await this.onMount(element, { mode: "canvas" });
@@ -94,799 +352,1556 @@ const model = {
352
353
async onMount(element = null, options = {}) {
354
if (element) this._root = element;
97
- this.assignFrameName(element);
98
- globalThis.requestAnimationFrame?.(() => this.assignFrameName(element));
99
- if (!this._messageBound) {
100
- globalThis.addEventListener("message", (event) => this.onPostMessage(event));
101
- this._messageBound = true;
102
- }
355
this._mode = options?.mode === "modal" ? "modal" : "canvas";
104
- if (this._mode === "modal") {
105
- this.setupFloatingModal(element);
106
- } else {
107
- this.setupCanvasSurface(element);
108
- }
356
+ if (this._mode === "modal") this.setupFloatingModal(element);
357
await this.refresh();
358
+ await this.ensureDesktopSession({ select: !this.session });
359
this.ensureActiveTab();
111
- if (this.session && this._root) {
112
- await this.restartFrameLoad();
113
- }
360
+ this.queueRender();
361
},
362
363
async onOpen(payload = {}) {
364
await this.refresh();
365
if (payload?.path || payload?.file_id) {
366
await this.openSession({
120
- action: "open",
367
path: payload.path || "",
368
file_id: payload.file_id || "",
123
- mode: "edit",
369
});
125
- } else if (this.session && !this.frameReady) {
126
- await this.restartFrameLoad();
370
+ } else {
371
+ await this.ensureDesktopSession({ select: !this.session });
372
+ }
373
+ this.restoreDesktopFrames();
374
+ },
375
+
376
+ beforeHostHidden(options = {}) {
377
+ this.flushInput();
378
+ if (options?.unloadDesktop) {
379
+ this.unloadDesktopFrames();
380
}
381
},
382
383
cleanup() {
384
+ this.flushInput();
385
+ this.stopDesktopMonitor();
386
+ this.stopDesktopResizeObserver();
387
+ this.stopXpraDesktopPrime();
388
this._floatingCleanup?.();
389
this._floatingCleanup = null;
133
- this.clearStatusPoll();
134
- if (this._mode === "modal") {
135
- this._root = null;
136
- }
390
+ if (this._mode === "modal") this._root = null;
391
+ },
392
+
393
+ bindEditorElement(element, type) {
394
+ if (type === "markdown") this._richEditor = element;
395
+ if (type === "docx") this._docxEditor = element;
396
+ this.queueRender();
397
},
398
399
async refresh() {
400
try {
141
- this.status = await callJsonApi("/plugins/_office/office_session", { action: "status" });
142
- const recent = await callJsonApi("/plugins/_office/office_session", { action: "recent" });
143
- this.recent = recent?.documents || [];
144
- if (this.status?.healthy) {
145
- await this.syncOpenSessions();
146
- } else {
147
- this.openDocuments = [];
401
+ const [status, recent, openDocuments] = await Promise.all([
402
+ callOffice("status"),
403
+ callOffice("recent"),
404
+ callOffice("open_documents"),
405
+ ]);
406
+ this.status = status || {};
407
+ this.recent = (recent?.documents || []).map(normalizeDocument);
408
+ this.openDocuments = (openDocuments?.documents || []).map(normalizeDocument);
409
+ this.error = "";
410
+ } catch (error) {
411
+ this.error = error instanceof Error ? error.message : String(error);
412
+ }
413
+ },
414
+
415
+ async ensureDesktopSession(options = {}) {
416
+ const existing = this.tabs.find((tab) => this.isDesktopSession(tab));
417
+ if (existing && !options.force) {
418
+ if (options.select) this.selectTab(existing.tab_id, { focus: false });
419
+ this.updateDesktopMonitor();
420
+ return existing;
421
+ }
422
+ if (this._desktopStarting) return await this._desktopStarting;
423
+
424
+ this._desktopStarting = (async () => {
425
+ try {
426
+ const response = await callOffice("desktop");
427
+ if (response?.ok === false) throw new Error(response.error || "Desktop session could not be opened.");
428
+ const session = normalizeSession(response);
429
+ const existingIndex = this.tabs.findIndex((tab) => this.isDesktopSession(tab));
430
+ let desktopTabId = session.tab_id;
431
+ if (existingIndex >= 0) {
432
+ desktopTabId = this.tabs[existingIndex].tab_id;
433
+ this.tabs.splice(existingIndex, 1, { ...this.tabs[existingIndex], ...session, tab_id: desktopTabId });
434
+ } else {
435
+ this.tabs.unshift(session);
436
+ }
437
+ this.tabs = this.tabs.map((tab) => (
438
+ this.hasOfficialOffice(tab)
439
+ ? {
440
+ ...tab,
441
+ desktop: session.desktop,
442
+ desktop_session_id: session.desktop_session_id,
443
+ session_id: this.isDesktopSession(tab) ? session.session_id : tab.session_id,
444
+ }
445
+ : tab
446
+ ));
447
+ if (options.select || !this.session) {
448
+ this.selectTab(desktopTabId, { focus: false });
449
+ } else {
450
+ this.updateDesktopMonitor();
451
+ }
452
+ return { ...session, tab_id: desktopTabId };
453
+ } catch (error) {
454
+ this.error = error instanceof Error ? error.message : String(error);
455
+ return null;
456
+ } finally {
457
+ this._desktopStarting = null;
458
}
459
+ })();
460
+ return await this._desktopStarting;
461
+ },
462
+
463
+ async create(kind = "document", format = "") {
464
+ const fmt = String(format || (kind === "spreadsheet" ? "xlsx" : kind === "presentation" ? "pptx" : "md")).toLowerCase();
465
+ const title = this.defaultTitle(kind, fmt);
466
+ await this.openSession({
467
+ action: "create",
468
+ kind,
469
+ format: fmt,
470
+ title,
471
+ });
472
+ },
473
+
474
+ async openPrompt() {
475
+ let defaultPath = "/a0/usr/workdir/";
476
+ try {
477
+ const home = await callOffice("home");
478
+ defaultPath = home?.path || defaultPath;
479
+ } catch {
480
+ // The prompt still works with the static fallback.
481
+ }
482
+ const path = globalThis.prompt?.("Path", defaultPath);
483
+ if (!path) return;
484
+ await this.openPath(path);
485
+ },
486
+
487
+ async openPath(path) {
488
+ await this.openSession({ path: String(path || "") });
489
+ },
490
+
491
+ async openSession(payload = {}) {
492
+ this.loading = true;
493
+ this.error = "";
494
+ try {
495
+ const response = await callOffice(payload.action || "open", payload);
496
+ if (response?.ok === false) {
497
+ this.error = response.error || "Document could not be opened.";
498
+ return null;
499
+ }
500
+ const session = normalizeSession(response);
501
+ this.installSession(session);
502
+ await this.refresh();
503
+ return session;
504
} catch (error) {
505
this.error = error instanceof Error ? error.message : String(error);
506
+ return null;
507
} finally {
152
- this.scheduleStatusPoll();
508
+ this.loading = false;
509
}
510
},
511
156
- async syncOpenSessions() {
157
- const sessionIds = this.tabs
158
- .map((tab) => normalizeTabId(tab?.session_id))
159
- .filter(Boolean);
160
- const response = await callJsonApi("/plugins/_office/office_session", {
161
- action: "sync_open_sessions",
162
- session_ids: sessionIds,
163
- });
164
- this.openDocuments = response?.documents || [];
165
- return response;
512
+ installSession(session) {
513
+ const existingIndex = this.tabs.findIndex((tab) => (
514
+ (session.file_id && tab.file_id === session.file_id)
515
+ || (session.path && tab.path === session.path)
516
+ ));
517
+ if (existingIndex >= 0) {
518
+ this.tabs.splice(existingIndex, 1, { ...this.tabs[existingIndex], ...session, tab_id: this.tabs[existingIndex].tab_id });
519
+ this.activeTabId = this.tabs[existingIndex].tab_id;
520
+ } else {
521
+ this.tabs.push(session);
522
+ this.activeTabId = session.tab_id;
523
+ }
524
+ this.selectTab(this.activeTabId);
525
+ },
526
+
527
+ selectTab(tabId, options = {}) {
528
+ const tab = this.tabs.find((item) => item.tab_id === tabId) || this.tabs[0] || null;
529
+ this.session = tab;
530
+ this.activeTabId = tab?.tab_id || "";
531
+ this.sourceMode = false;
532
+ this.editorText = String(tab?.text || "");
533
+ this.dirty = Boolean(tab?.dirty);
534
+ this.resetHistory(this.editorText);
535
+ this.queueRender({ focus: Boolean(tab) && options.focus !== false });
536
+ this.updateDesktopMonitor();
537
},
538
168
- async retry() {
169
- this.message = "Retrying Office setup...";
170
- this.status = await callJsonApi("/plugins/_office/office_session", { action: "retry" });
171
- this.scheduleStatusPoll();
539
+ ensureActiveTab() {
540
+ if (this.session && this.tabs.some((tab) => tab.tab_id === this.session.tab_id)) return;
541
+ if (this.tabs.length) this.selectTab(this.tabs[0].tab_id, { focus: false });
542
+ },
543
+
544
+ isActiveTab(tab) {
545
+ return Boolean(tab && tab.tab_id === this.activeTabId);
546
+ },
547
+
548
+ async closeFile() {
549
+ if (!this.session) return;
550
+ await this.closeTab(this.session.tab_id);
551
+ },
552
+
553
+ async closeTab(tabId) {
554
+ const tab = this.tabs.find((item) => item.tab_id === tabId);
555
+ if (!tab) return;
556
+ if (this.isDesktopSession(tab)) {
557
+ this.selectTab(tab.tab_id, { focus: false });
558
+ return;
559
+ }
560
+ if (!this.hasOfficialOffice(tab) && (tab.dirty || (this.isActiveTab(tab) && this.dirty))) {
561
+ const shouldSave = globalThis.confirm?.("Save changes?") ?? true;
562
+ if (shouldSave) await this.save();
563
+ }
564
+ try {
565
+ if (this.hasOfficialOffice(tab)) {
566
+ await callOffice("desktop_save", {
567
+ desktop_session_id: tab.desktop_session_id || tab.session_id,
568
+ file_id: tab.file_id || "",
569
+ }).catch(() => null);
570
+ } else if (tab.session_id) {
571
+ await requestOffice("office_close", { session_id: tab.session_id }, 2500).catch(() => null);
572
+ }
573
+ await callOffice("close", {
574
+ session_id: tab.store_session_id || "",
575
+ file_id: tab.file_id || "",
576
+ });
577
+ } catch (error) {
578
+ console.warn("Document close skipped", error);
579
+ }
580
+ this.tabs = this.tabs.filter((item) => item.tab_id !== tabId);
581
+ if (this.activeTabId === tabId) {
582
+ this.session = null;
583
+ this.activeTabId = "";
584
+ this.editorText = "";
585
+ this.dirty = false;
586
+ this.ensureActiveTab();
587
+ }
588
+ this.updateDesktopMonitor();
589
+ await this.ensureDesktopSession({ select: !this.session });
590
+ await this.refresh();
591
+ },
592
+
593
+ async save() {
594
+ if (!this.session || this.saving) return;
595
+ if (this.isDesktopSession()) return;
596
+ if (this.hasOfficialOffice()) {
597
+ this.saving = true;
598
+ this.error = "";
599
+ try {
600
+ const response = await callOffice("desktop_save", {
601
+ desktop_session_id: this.session.desktop_session_id || this.session.session_id,
602
+ file_id: this.session.file_id || "",
603
+ });
604
+ if (response?.ok === false) throw new Error(response.error || "Save failed.");
605
+ const document = normalizeDocument(response.document || this.session.document || {});
606
+ const updated = {
607
+ ...this.session,
608
+ dirty: false,
609
+ document,
610
+ path: document.path || this.session.path,
611
+ file_id: document.file_id || this.session.file_id,
612
+ version: document.version || response.version || this.session.version,
613
+ };
614
+ this.replaceActiveSession(updated);
615
+ this.dirty = false;
616
+ this.setMessage("Saved");
617
+ await this.refresh();
618
+ } catch (error) {
619
+ this.error = error instanceof Error ? error.message : String(error);
620
+ } finally {
621
+ this.saving = false;
622
+ }
623
+ return;
624
+ }
625
+ if (this.hasNativeDocxTiles()) await this.awaitNativeEvents();
626
+ if (!this.hasNativeDocxTiles()) this.syncEditorText();
627
+ this.saving = true;
628
+ this.error = "";
629
+ try {
630
+ let response;
631
+ const payload = { session_id: this.session.session_id };
632
+ if (!this.hasNativeDocxTiles()) payload.text = this.editorText;
633
+ try {
634
+ response = await requestOffice("office_save", payload, 10000);
635
+ } catch (_socketError) {
636
+ response = await callOffice("save", payload);
637
+ }
638
+ if (response?.ok === false) throw new Error(response.error || "Save failed.");
639
+ const document = normalizeDocument(response.document || this.session.document || {});
640
+ const updated = {
641
+ ...this.session,
642
+ text: this.editorText,
643
+ dirty: false,
644
+ document,
645
+ path: document.path || this.session.path,
646
+ file_id: document.file_id || this.session.file_id,
647
+ tiles: Array.isArray(response.tiles) ? response.tiles : this.session.tiles,
648
+ native: response.native || this.session.native || {},
649
+ version: document.version || response.version || this.session.version,
650
+ };
651
+ this.replaceActiveSession(updated);
652
+ this.dirty = false;
653
+ this.setMessage("Saved");
654
+ await this.refresh();
655
+ } catch (error) {
656
+ this.error = error instanceof Error ? error.message : String(error);
657
+ } finally {
658
+ this.saving = false;
659
+ }
660
},
661
174
- clearStatusPoll() {
175
- if (!this._statusPollTimer) return;
176
- globalThis.clearTimeout(this._statusPollTimer);
177
- this._statusPollTimer = null;
662
+ async exportPdf() {
663
+ if (!this.session) return;
664
+ if (this.isDesktopSession()) return;
665
+ this.loading = true;
666
+ this.error = "";
667
+ try {
668
+ const response = await callOffice("export", {
669
+ file_id: this.session.file_id,
670
+ path: this.session.path,
671
+ target_format: "pdf",
672
+ });
673
+ if (response?.ok === false) throw new Error(response.error || "Export failed.");
674
+ this.setMessage(response.path ? `Exported ${response.path}` : "Exported");
675
+ } catch (error) {
676
+ this.error = error instanceof Error ? error.message : String(error);
677
+ } finally {
678
+ this.loading = false;
679
+ }
680
},
681
180
- scheduleStatusPoll() {
181
- this.clearStatusPoll();
182
- if (!this.shouldPollSetup()) return;
183
- this._statusPollTimer = globalThis.setTimeout(() => {
184
- this._statusPollTimer = null;
185
- void this.refresh();
186
- }, SETUP_POLL_INTERVAL_MS);
682
+ replaceActiveSession(next) {
683
+ if (!this.session) return;
684
+ this.session = next;
685
+ const index = this.tabs.findIndex((tab) => tab.tab_id === next.tab_id);
686
+ if (index >= 0) this.tabs.splice(index, 1, next);
687
+ this.queueRender();
688
+ this.updateDesktopMonitor();
689
},
690
189
- shouldPollSetup() {
190
- if (this.session || this.status?.healthy) return false;
191
- if (!this.status) return true;
192
- const state = String(this.status.state || "").toLowerCase();
193
- return Boolean(this.status.installing || state === "installing" || state === "idle");
691
+ setMessage(value) {
692
+ this.message = value;
693
+ if (this._saveMessageTimer) globalThis.clearTimeout(this._saveMessageTimer);
694
+ this._saveMessageTimer = globalThis.setTimeout(() => {
695
+ this.message = "";
696
+ this._saveMessageTimer = null;
697
+ }, SAVE_MESSAGE_MS);
698
},
699
196
- setupState() {
197
- return String(this.status?.state || "installing").toLowerCase();
700
+ resetHistory(text) {
701
+ this._history = [String(text || "")];
702
+ this._historyIndex = 0;
703
},
704
200
- isSetupBusy() {
201
- const state = this.setupState();
202
- return !this.status || Boolean(this.status.installing) || state === "installing" || state === "idle";
705
+ pushHistory(text) {
706
+ const value = String(text || "");
707
+ if (this._history[this._historyIndex] === value) return;
708
+ this._history = this._history.slice(0, this._historyIndex + 1);
709
+ this._history.push(value);
710
+ if (this._history.length > MAX_HISTORY) this._history.shift();
711
+ this._historyIndex = this._history.length - 1;
712
},
713
205
- isSetupBlocked() {
206
- const state = this.setupState();
207
- return state === "failed" || state === "degraded";
714
+ undo() {
715
+ if (this._historyIndex <= 0) return;
716
+ this._historyIndex -= 1;
717
+ this.applyEditorText(this._history[this._historyIndex], true);
718
},
719
210
- showSetupActions() {
211
- return this.isSetupBlocked() || (!this.isSetupBusy() && !this.status?.healthy);
720
+ redo() {
721
+ if (this._historyIndex >= this._history.length - 1) return;
722
+ this._historyIndex += 1;
723
+ this.applyEditorText(this._history[this._historyIndex], true);
724
},
725
214
- setupIcon() {
215
- return this.isSetupBlocked() ? "error" : "progress_activity";
726
+ canUndo() {
727
+ return this._historyIndex > 0;
728
},
729
218
- setupTitle() {
219
- if (this.isSetupBlocked()) return "Setup needs attention";
220
- return "Setup in progress";
730
+ canRedo() {
731
+ return this._historyIndex < this._history.length - 1;
732
},
733
223
- setupMessage() {
224
- if (this.isSetupBlocked()) {
225
- return "Office could not finish setup. Retry when you are ready.";
734
+ applyEditorText(text, markDirty = false) {
735
+ this.editorText = String(text || "");
736
+ if (this.session) {
737
+ this.session.text = this.editorText;
738
+ this.session.dirty = markDirty || this.session.dirty;
739
}
227
- return "Please wait while Office is prepared. This can take a few minutes the first time.";
740
+ if (markDirty) this.markDirty();
741
+ this.queueRender({ force: true, focus: true });
742
},
743
230
- healthTitle() {
231
- if (this.status?.healthy) return "Office is ready";
232
- if (this.isSetupBlocked()) return "Office setup needs attention";
233
- if (this.isSetupBusy()) return "Office setup is in progress";
234
- return "Office status";
744
+ markDirty() {
745
+ this.dirty = true;
746
+ if (this.session) this.session.dirty = true;
747
},
748
237
- healthText() {
238
- if (this.isSetupBlocked()) return "attention";
239
- if (this.isSetupBusy()) return "setup";
240
- return String(this.status?.state || "status");
749
+ onSourceInput() {
750
+ this.markDirty();
751
+ this.pushHistory(this.editorText);
752
+ this.scheduleInputPush();
753
},
754
243
- async create(kind = "document") {
244
- const defaults = {
245
- document: ["Document", "docx"],
246
- spreadsheet: ["Spreadsheet", "xlsx"],
247
- presentation: ["Presentation", "pptx"],
248
- };
249
- const [title, format] = defaults[kind] || defaults.document;
250
- await this.openSession({
251
- action: "create",
252
- kind,
253
- title,
254
- format,
255
- content: "",
256
- });
755
+ onRichInput(element) {
756
+ if (this._rendering) return;
757
+ this.editorText = htmlToMarkdown(element);
758
+ this.markDirty();
759
+ this.pushHistory(this.editorText);
760
+ this.scheduleInputPush();
761
},
762
259
- async openPrompt() {
260
- const path = globalThis.prompt?.("Open Office file path", "/a0/usr/workdir/documents/");
261
- if (!path) return;
262
- await this.openPath(path);
763
+ onDocxInput(element) {
764
+ if (this.hasNativeDocxTiles()) return;
765
+ if (this._rendering) return;
766
+ this.editorText = docxEditorText(element);
767
+ this.markDirty();
768
+ this.pushHistory(this.editorText);
769
+ this.scheduleInputPush();
770
},
771
265
- async openPath(path) {
266
- await this.openSession({ action: "open", path, mode: "edit" });
772
+ syncEditorText() {
773
+ if (!this.session) return;
774
+ if (this.hasOfficialOffice()) return;
775
+ if (this.hasNativeDocxTiles()) return;
776
+ if (this.isMarkdown() && !this.sourceMode && this._richEditor) {
777
+ this.editorText = htmlToMarkdown(this._richEditor);
778
+ } else if (this.isDocx() && this._docxEditor) {
779
+ this.editorText = docxEditorText(this._docxEditor);
780
+ }
781
+ this.session.text = this.editorText;
782
},
783
269
- async openSession(payload) {
270
- this.loading = true;
271
- this.error = "";
272
- this.message = "";
273
- try {
274
- await this.save({ wait: true, timeoutMs: 900 });
275
- await this.prepareBrowserHostForEditor();
276
- const response = await callJsonApi("/plugins/_office/office_session", payload);
277
- if (!response?.ok) {
278
- this.error = response?.error || "Office session could not be opened.";
279
- if (response?.status) this.status = response.status;
784
+ scheduleInputPush() {
785
+ if (!this.session?.session_id) return;
786
+ if (this._inputTimer) globalThis.clearTimeout(this._inputTimer);
787
+ this._inputTimer = globalThis.setTimeout(() => {
788
+ this._inputTimer = null;
789
+ this.flushInput();
790
+ }, INPUT_PUSH_DELAY_MS);
791
+ },
792
+
793
+ flushInput() {
794
+ if (!this.session?.session_id) return;
795
+ if (this.hasOfficialOffice()) return;
796
+ this.syncEditorText();
797
+ requestOffice("office_input", {
798
+ session_id: this.session.session_id,
799
+ text: this.editorText,
800
+ }, 3000).catch(() => {});
801
+ },
802
+
803
+ toggleSource() {
804
+ if (!this.isMarkdown()) return;
805
+ if (!this.sourceMode) this.syncEditorText();
806
+ this.sourceMode = !this.sourceMode;
807
+ this.queueRender({ force: true, focus: true });
808
+ },
809
+
810
+ format(command) {
811
+ if (!this.session) return;
812
+ if (this.sourceMode) {
813
+ this.applySourceFormat(command);
814
+ return;
815
+ }
816
+ const editor = this.isDocx() ? this._docxEditor : this._richEditor;
817
+ editor?.focus?.();
818
+ const uno = this.unoCommand(command);
819
+ if (this.isDocx() && uno) {
820
+ void this.dispatchUnoCommand(uno.command, uno.arguments);
821
+ if (this.hasNativeDocxTiles()) {
822
+ this.markDirty();
823
return;
824
}
282
- await this.activateSession(response);
283
- await this.refresh();
284
- } catch (error) {
285
- this.error = error instanceof Error ? error.message : String(error);
286
- } finally {
287
- this.loading = false;
825
}
826
+ if (command === "bold") document.execCommand?.("bold");
827
+ if (command === "italic") document.execCommand?.("italic");
828
+ if (command === "underline") document.execCommand?.("underline");
829
+ if (command === "list") document.execCommand?.("insertUnorderedList");
830
+ if (command === "numbered") document.execCommand?.("insertOrderedList");
831
+ if (command === "alignLeft") document.execCommand?.("justifyLeft");
832
+ if (command === "alignCenter") document.execCommand?.("justifyCenter");
833
+ if (command === "alignRight") document.execCommand?.("justifyRight");
834
+ if (command === "table") {
835
+ document.execCommand?.(
836
+ "insertHTML",
837
+ false,
838
+ '<table><tbody><tr><th>Column</th><th>Value</th></tr><tr><td></td><td></td></tr></tbody></table>',
839
+ );
840
+ }
841
+ this.syncEditorText();
842
+ this.markDirty();
843
+ this.pushHistory(this.editorText);
844
+ this.scheduleInputPush();
845
+ },
846
+
847
+ unoCommand(command) {
848
+ const commands = {
849
+ bold: { command: ".uno:Bold" },
850
+ italic: { command: ".uno:Italic" },
851
+ underline: { command: ".uno:Underline" },
852
+ list: { command: ".uno:DefaultBullet" },
853
+ numbered: { command: ".uno:DefaultNumbering" },
854
+ alignLeft: { command: ".uno:LeftPara" },
855
+ alignCenter: { command: ".uno:CenterPara" },
856
+ alignRight: { command: ".uno:RightPara" },
857
+ };
858
+ return commands[command] || null;
859
},
860
291
- async activateSession(response) {
292
- const tab = this.normalizeTab(response);
293
- const existingIndex = this.findTabIndexForSession(tab);
294
- if (existingIndex >= 0) {
295
- const previous = this.tabs[existingIndex];
296
- if (previous?.session_id && previous.session_id !== tab.session_id) {
297
- await this.closeBackendSession(previous);
861
+ async dispatchUnoCommand(command, argumentsPayload = null) {
862
+ if (!this.session?.session_id || !command) return null;
863
+ return await this.queueNativeEvent(async () => {
864
+ try {
865
+ let response;
866
+ try {
867
+ response = await requestOffice("office_command", {
868
+ session_id: this.session.session_id,
869
+ command,
870
+ arguments: argumentsPayload,
871
+ notify: true,
872
+ }, 5000);
873
+ } catch (_socketError) {
874
+ response = await callOffice("command", {
875
+ session_id: this.session.session_id,
876
+ command,
877
+ arguments: argumentsPayload,
878
+ notify: true,
879
+ });
880
+ }
881
+ if (response?.ok === false) throw new Error(response.error || `${command} failed.`);
882
+ if (response?.metadata && this.session) {
883
+ this.session.native = { ...(this.session.native || {}), ...response.metadata, available: true };
884
+ }
885
+ if (Array.isArray(response?.tiles) && this.session) {
886
+ this.session.tiles = response.tiles;
887
+ this.queueRender({ force: true, focus: true });
888
+ }
889
+ return response;
890
+ } catch (error) {
891
+ console.warn("LibreOffice command skipped", command, error);
892
+ return null;
893
+ }
894
+ });
895
+ },
896
+
897
+ applySourceFormat(command) {
898
+ const textarea = this._root?.querySelector?.("[data-office-source]");
899
+ if (!textarea) return;
900
+ const start = textarea.selectionStart || 0;
901
+ const end = textarea.selectionEnd || start;
902
+ const selected = this.editorText.slice(start, end);
903
+ let replacement = selected;
904
+ if (command === "bold") replacement = `**${selected || "text"}**`;
905
+ if (command === "italic") replacement = `*${selected || "text"}*`;
906
+ if (command === "list") replacement = (selected || "item").split("\n").map((line) => `- ${line.replace(/^[-*]\s+/, "")}`).join("\n");
907
+ if (command === "numbered") replacement = (selected || "item").split("\n").map((line, index) => `${index + 1}. ${line.replace(/^\d+\.\s+/, "")}`).join("\n");
908
+ if (command === "table") replacement = "| Column | Value |\n| --- | --- |\n| | |";
909
+ if (replacement === selected) return;
910
+ this.editorText = `${this.editorText.slice(0, start)}${replacement}${this.editorText.slice(end)}`;
911
+ this.onSourceInput();
912
+ globalThis.requestAnimationFrame?.(() => {
913
+ textarea.focus();
914
+ textarea.selectionStart = start;
915
+ textarea.selectionEnd = start + replacement.length;
916
+ });
917
+ },
918
+
919
+ zoomIn() {
920
+ this.zoom = Math.min(1.6, Math.round((this.zoom + 0.1) * 10) / 10);
921
+ },
922
+
923
+ zoomOut() {
924
+ this.zoom = Math.max(0.7, Math.round((this.zoom - 0.1) * 10) / 10);
925
+ },
926
+
927
+ zoomLabel() {
928
+ return `${Math.round(this.zoom * 100)}%`;
929
+ },
930
+
931
+ queueRender(options = {}) {
932
+ const force = Boolean(options.force);
933
+ if (options.focus) {
934
+ this._pendingFocus = true;
935
+ this._pendingFocusEnd = options.end !== false;
936
+ this._focusAttempts = 0;
937
+ }
938
+ const render = () => {
939
+ this.renderEditors(force);
940
+ if (this._pendingFocus && this.focusEditor({ end: this._pendingFocusEnd })) {
941
+ this._pendingFocus = false;
942
+ this._focusAttempts = 0;
943
+ } else if (this._pendingFocus && this._focusAttempts < 6) {
944
+ this._focusAttempts += 1;
945
+ globalThis.setTimeout(render, 45);
946
}
299
- this.tabs.splice(existingIndex, 1, tab);
300
- } else {
301
- this.tabs.push(tab);
302
- }
303
- this.activeTabId = tab.tab_id;
304
- this.syncActiveSession();
305
- this.frameReady = false;
306
- this._frameOrigin = "";
307
- this._frameAttempt = 0;
308
- this._frameRecoveryTried = false;
309
- this.clearFrameTimers();
310
- await this.submitFrame();
311
- this.scheduleFrameWatch();
312
- },
313
-
314
- async submitFrame() {
315
- await nextAnimationFrame();
316
- this.syncActiveSession();
317
- const session = this.session;
318
- const frame = this.activeFrame();
319
- if (!session || !frame?.name) return;
320
- const form = document.createElement("form");
321
- form.method = "post";
322
- form.action = this.frameAction(session.iframe_action);
323
- form.target = frame.name;
324
- form.style.display = "none";
325
- const fields = {
326
- access_token: session.access_token,
327
- access_token_ttl: String(session.access_token_ttl),
328
- ui_defaults: "UIMode=notebookbar;TextRuler=false",
947
};
330
- for (const [name, value] of Object.entries(fields)) {
331
- const input = document.createElement("input");
332
- input.type = "hidden";
333
- input.name = name;
334
- input.value = value;
335
- form.appendChild(input);
948
+ if (globalThis.requestAnimationFrame) {
949
+ globalThis.requestAnimationFrame(render);
950
+ } else {
951
+ globalThis.setTimeout(render, 0);
952
}
337
- document.body.appendChild(form);
338
- form.submit();
339
- form.remove();
953
},
954
342
- async restartFrameLoad() {
343
- this.syncActiveSession();
955
+ renderEditors(force = false) {
956
if (!this.session) return;
345
- this.frameReady = false;
346
- this._frameOrigin = "";
347
- this._frameAttempt = 0;
348
- this._frameRecoveryTried = false;
349
- this.clearFrameTimers();
350
- await this.submitFrame();
351
- this.scheduleFrameWatch();
352
- },
353
-
354
- frameAction(action) {
355
- const url = new URL(action, globalThis.location.origin);
356
- url.searchParams.set("a0_frame_attempt", String(this._frameAttempt));
357
- return url.pathname + url.search;
358
- },
359
-
360
- scheduleFrameWatch() {
361
- this.clearFrameTimers();
362
- this._frameTimer = setTimeout(() => {
363
- if (this.session && !this.frameReady) {
364
- this.message = "Still opening the editor...";
365
- this._frameRecoveryTimer = setTimeout(() => this.recoverFrameLoad(), 3000);
957
+ if (this.hasOfficialOffice()) return;
958
+ this._rendering = true;
959
+ try {
960
+ if (this._richEditor && this.isMarkdown() && (!editorContainsFocus(this._richEditor) || force)) {
961
+ this._richEditor.innerHTML = markdownToHtml(this.editorText);
962
}
367
- }, 20000);
368
- },
369
-
370
- async recoverFrameLoad() {
371
- if (!this.session || this.frameReady || this._frameRecoveryTried) return;
372
- this._frameRecoveryTried = true;
373
- this._frameAttempt += 1;
374
- this.resetCollaboraBrowserState({ force: true });
375
- this.message = "Still opening the editor... trying a fresh editor load.";
376
- await this.submitFrame();
377
- this._frameTimer = setTimeout(() => {
378
- if (this.session && !this.frameReady) {
379
- this.message = "Still opening the editor...";
963
+ if (this._docxEditor && this.isDocx() && this.hasNativeDocxTiles() && (!editorContainsFocus(this._docxEditor) || force)) {
964
+ this._docxEditor.innerHTML = nativeTilesToHtml(this.session.tiles || []);
965
+ } else if (this._docxEditor && this.isDocx() && (!editorContainsFocus(this._docxEditor) || force)) {
966
+ this._docxEditor.innerHTML = textToPageHtml(this.editorText);
967
}
381
- }, 25000);
968
+ } finally {
969
+ this._rendering = false;
970
+ }
971
},
972
384
- clearFrameTimers() {
385
- if (this._frameTimer) {
386
- clearTimeout(this._frameTimer);
387
- this._frameTimer = null;
973
+ focusEditor(options = {}) {
974
+ if (!this.session || this.isPreviewOnly()) return false;
975
+ if (this.hasOfficialOffice()) {
976
+ const frame = this.desktopFrame();
977
+ frame?.focus?.({ preventScroll: true });
978
+ return Boolean(frame);
979
}
389
- if (this._frameRecoveryTimer) {
390
- clearTimeout(this._frameRecoveryTimer);
391
- this._frameRecoveryTimer = null;
980
+ const source = this._root?.querySelector?.("[data-office-source]");
981
+ const editor = this.sourceMode ? source : (this.isDocx() ? this._docxEditor : this._richEditor);
982
+ if (!editor) return false;
983
+ editor.focus?.({ preventScroll: true });
984
+ if (!editorContainsFocus(editor)) return false;
985
+ if (options.end !== false) placeCaretAtEnd(editor);
986
+ return true;
987
+ },
988
+
989
+ isMarkdown() {
990
+ return this.session?.extension === "md";
991
+ },
992
+
993
+ isDocx() {
994
+ return this.session?.extension === "docx";
995
+ },
996
+
997
+ isBinaryOffice(tab = this.session) {
998
+ const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase();
999
+ return ext === "docx" || ext === "xlsx" || ext === "pptx";
1000
+ },
1001
+
1002
+ hasOfficialOffice(tab = this.session) {
1003
+ return Boolean(tab?.desktop?.available && tab.desktop.url);
1004
+ },
1005
+
1006
+ isDesktopSession(tab = this.session) {
1007
+ return Boolean(
1008
+ tab
1009
+ && (
1010
+ tab.file_id === SYSTEM_DESKTOP_FILE_ID
1011
+ || tab.extension === "desktop"
1012
+ || tab.mode === "desktop"
1013
+ )
1014
+ );
1015
+ },
1016
+
1017
+ officialOfficeUrl(tab = this.session) {
1018
+ return tab?.desktop?.url || "";
1019
+ },
1020
+
1021
+ desktopFrames() {
1022
+ const frames = Array.from(document.querySelectorAll("[data-office-desktop-frame]"));
1023
+ const rootFrame = this._root?.querySelector?.("[data-office-desktop-frame]");
1024
+ if (rootFrame && !frames.includes(rootFrame)) frames.push(rootFrame);
1025
+ return frames;
1026
+ },
1027
+
1028
+ isUsableDesktopFrame(frame) {
1029
+ if (!frame?.contentWindow) return false;
1030
+ const rect = frame.getBoundingClientRect?.();
1031
+ return Boolean(rect && rect.width >= 120 && rect.height >= 80);
1032
+ },
1033
+
1034
+ desktopFrame(preferred = null) {
1035
+ if (this.isUsableDesktopFrame(preferred)) return preferred;
1036
+ const frames = this.desktopFrames();
1037
+ return frames
1038
+ .filter((frame) => this.isUsableDesktopFrame(frame))
1039
+ .sort((left, right) => {
1040
+ const leftRect = left.getBoundingClientRect();
1041
+ const rightRect = right.getBoundingClientRect();
1042
+ return (rightRect.width * rightRect.height) - (leftRect.width * leftRect.height);
1043
+ })[0] || null;
1044
+ },
1045
+
1046
+ unloadDesktopFrames() {
1047
+ this.stopDesktopResizeObserver();
1048
+ this.stopXpraDesktopPrime();
1049
+ for (const frame of this.desktopFrames()) {
1050
+ if (!frame?.getAttribute) continue;
1051
+ const current = frame.getAttribute("src") || "";
1052
+ if (!current || current === "about:blank") continue;
1053
+ frame.dataset.officeDesktopUnloaded = "true";
1054
+ frame.setAttribute("src", "about:blank");
1055
}
1056
},
1057
395
- beforeHostHidden() {
396
- if (this.session) {
397
- this.save();
1058
+ restoreDesktopFrames() {
1059
+ const url = this.officialOfficeUrl();
1060
+ if (!url) return;
1061
+ for (const frame of this.desktopFrames()) {
1062
+ if (!frame?.getAttribute) continue;
1063
+ const current = frame.getAttribute("src") || "";
1064
+ if (current && current !== "about:blank" && frame.dataset.officeDesktopUnloaded !== "true") continue;
1065
+ delete frame.dataset.officeDesktopUnloaded;
1066
+ frame.setAttribute("src", url);
1067
+ }
1068
+ },
1069
+
1070
+ onDesktopFrameLoaded(event = null) {
1071
+ if (event?.target?.getAttribute?.("src") === "about:blank") return;
1072
+ this.error = "";
1073
+ this.focusEditor({ end: false });
1074
+ this.startDesktopResizeObserver();
1075
+ this.primeXpraDesktopFrame({ reset: true, frame: event?.target || null });
1076
+ this.queueDesktopResize();
1077
+ this.updateDesktopMonitor();
1078
+ },
1079
+
1080
+ updateDesktopMonitor() {
1081
+ if (!this.hasOfficialOffice()) {
1082
+ this.stopDesktopMonitor();
1083
+ this.stopDesktopResizeObserver();
1084
+ return;
1085
+ }
1086
+ const sessionId = this.session?.desktop_session_id || this.session?.session_id || "";
1087
+ const tabId = this.session?.tab_id || "";
1088
+ if (
1089
+ sessionId
1090
+ && tabId
1091
+ && this._desktopHeartbeatTimer
1092
+ && this._desktopHeartbeatSessionId === sessionId
1093
+ && this._desktopHeartbeatTabId === tabId
1094
+ ) return;
1095
+ this.startDesktopMonitor();
1096
+ this.startDesktopResizeObserver();
1097
+ },
1098
+
1099
+ startDesktopResizeObserver() {
1100
+ this.stopDesktopResizeObserver();
1101
+ if (!this.hasOfficialOffice()) return;
1102
+ const frame = this.desktopFrame();
1103
+ const target = frame?.parentElement || frame;
1104
+ if (!target) return;
1105
+
1106
+ const resize = () => this.queueDesktopResize();
1107
+ const resizeStart = () => this.suspendDesktopResize();
1108
+ const resizeEnd = () => this.resumeDesktopResize();
1109
+ const cleanup = [];
1110
+ if (typeof ResizeObserver !== "undefined") {
1111
+ const observer = new ResizeObserver(resize);
1112
+ observer.observe(target);
1113
+ cleanup.push(() => observer.disconnect());
1114
}
399
- this.frameReady = false;
400
- this._frameOrigin = "";
401
- this.clearFrameTimers();
402
- const frame = this.activeFrame();
403
- if (frame) {
404
- frame.src = "about:blank";
1115
+ globalThis.addEventListener?.("resize", resize);
1116
+ cleanup.push(() => globalThis.removeEventListener?.("resize", resize));
1117
+ globalThis.addEventListener?.("right-canvas-resize-start", resizeStart);
1118
+ cleanup.push(() => globalThis.removeEventListener?.("right-canvas-resize-start", resizeStart));
1119
+ globalThis.addEventListener?.("right-canvas-resize-end", resizeEnd);
1120
+ cleanup.push(() => globalThis.removeEventListener?.("right-canvas-resize-end", resizeEnd));
1121
+ this._desktopResizeCleanup = () => cleanup.splice(0).reverse().forEach((entry) => entry());
1122
+ resize();
1123
+ },
1124
+
1125
+ stopDesktopResizeObserver() {
1126
+ if (this._desktopResizeTimer) {
1127
+ globalThis.clearTimeout(this._desktopResizeTimer);
1128
+ }
1129
+ this._desktopResizeTimer = null;
1130
+ this._desktopResizeCleanup?.();
1131
+ this._desktopResizeCleanup = null;
1132
+ this._desktopResizeKey = "";
1133
+ this._desktopResizeSuspended = false;
1134
+ this._desktopResizePending = false;
1135
+ },
1136
+
1137
+ suspendDesktopResize() {
1138
+ this._desktopResizeSuspended = true;
1139
+ if (this._desktopResizeTimer) {
1140
+ globalThis.clearTimeout(this._desktopResizeTimer);
1141
+ this._desktopResizeTimer = null;
1142
}
1143
},
1144
408
- postToFrame(message) {
409
- const frame = this.activeFrame();
410
- const targetOrigin = this._frameOrigin || this.session?.post_message_origin || globalThis.location.origin;
411
- frame?.contentWindow?.postMessage(JSON.stringify(message), targetOrigin);
1145
+ resumeDesktopResize() {
1146
+ const hadPendingResize = this._desktopResizePending;
1147
+ this._desktopResizeSuspended = false;
1148
+ this._desktopResizePending = false;
1149
+ if (hadPendingResize || this.hasOfficialOffice()) {
1150
+ this.queueDesktopResize({ force: true });
1151
+ }
1152
},
1153
414
- async save(options = {}) {
415
- const { wait = false, timeoutMs = 1500 } = options;
416
- if (!this.session || !this.activeFrame() || !this.frameReady) return true;
417
- if (!wait) {
418
- this.postToFrame({
419
- MessageId: "Action_Save",
420
- Values: {
421
- DontTerminateEdit: true,
422
- DontSaveIfUnmodified: true,
423
- },
424
- });
1154
+ shouldDeferDesktopResize() {
1155
+ return Boolean(
1156
+ this._desktopResizeSuspended
1157
+ || document.body?.classList?.contains("right-canvas-resizing")
1158
+ || document.querySelector?.(".modal-inner.office-modal.is-resizing")
1159
+ );
1160
+ },
1161
+
1162
+ primeXpraDesktopFrame(options = {}) {
1163
+ if (options.reset) {
1164
+ this.stopXpraDesktopPrime();
1165
+ this._desktopPrimeAttempts = 0;
1166
+ }
1167
+ if (this.applyXpraDesktopFrameMode(options.frame || null)) return;
1168
+ if (this._desktopPrimeAttempts >= XPRA_DESKTOP_PRIME_ATTEMPTS) return;
1169
+ this._desktopPrimeAttempts += 1;
1170
+ if (this._desktopPrimeTimer) globalThis.clearTimeout(this._desktopPrimeTimer);
1171
+ this._desktopPrimeTimer = globalThis.setTimeout(() => {
1172
+ this._desktopPrimeTimer = null;
1173
+ this.primeXpraDesktopFrame();
1174
+ }, XPRA_DESKTOP_PRIME_INTERVAL_MS);
1175
+ },
1176
+
1177
+ stopXpraDesktopPrime() {
1178
+ if (this._desktopPrimeTimer) globalThis.clearTimeout(this._desktopPrimeTimer);
1179
+ this._desktopPrimeTimer = null;
1180
+ },
1181
+
1182
+ applyXpraDesktopFrameMode(preferredFrame = null, options = {}) {
1183
+ const frame = this.desktopFrame(preferredFrame);
1184
+ const remoteWindow = frame?.contentWindow;
1185
+ if (!remoteWindow) return false;
1186
+ const requestServerResize = options.requestServerResize !== false;
1187
+ const requestRefresh = options.requestRefresh !== false;
1188
+ try {
1189
+ const remoteDocument = frame.contentDocument || remoteWindow.document;
1190
+ this.installXpraDesktopFrameCss(remoteDocument);
1191
+ const client = remoteWindow.client;
1192
+ if (!client) return false;
1193
+ const container = client.container || remoteDocument?.querySelector?.("#screen");
1194
+ if (!container) return false;
1195
+
1196
+ client.server_is_desktop = true;
1197
+ client.server_resize_exact = true;
1198
+ remoteDocument?.body?.classList?.add("desktop");
1199
+
1200
+ const windows = Object.values(client.id_to_window || {});
1201
+ if (!client.connected || !windows.length) return false;
1202
+
1203
+ const width = Math.round(container.clientWidth || remoteWindow.innerWidth || 0);
1204
+ const height = Math.round(container.clientHeight || remoteWindow.innerHeight || 0);
1205
+ if (requestServerResize && width > 0 && height > 0 && typeof client._screen_resized === "function") {
1206
+ client.desktop_width = 0;
1207
+ client.desktop_height = 0;
1208
+ client._screen_resized(new remoteWindow.Event("resize"));
1209
+ }
1210
+
1211
+ for (const xpraWindow of windows) {
1212
+ this.normalizeXpraDesktopWindow(xpraWindow, width, height);
1213
+ xpraWindow.screen_resized?.();
1214
+ this.normalizeXpraDesktopWindow(xpraWindow, width, height);
1215
+ xpraWindow.updateCSSGeometry?.();
1216
+ this.fitXpraDesktopWindowElement(xpraWindow, width, height);
1217
+ if (requestRefresh && xpraWindow.wid != null) client.request_refresh?.(xpraWindow.wid);
1218
+ }
1219
return true;
1220
+ } catch (error) {
1221
+ console.warn("Xpra desktop viewport prime skipped", error);
1222
+ return false;
1223
}
427
- return await new Promise((resolve) => {
428
- const timeout = globalThis.setTimeout(() => {
429
- this._saveWaiters = this._saveWaiters.filter((waiter) => waiter !== done);
430
- resolve(false);
431
- }, timeoutMs);
432
- const done = (ok) => {
433
- globalThis.clearTimeout(timeout);
434
- resolve(ok);
435
- };
436
- this._saveWaiters.push(done);
437
- this.postToFrame({
438
- MessageId: "Action_Save",
439
- Values: {
440
- DontTerminateEdit: true,
441
- DontSaveIfUnmodified: true,
442
- },
443
- });
444
- });
1224
},
1225
447
- resolveSaveWaiters(ok = true) {
448
- const waiters = this._saveWaiters.splice(0);
449
- for (const waiter of waiters) waiter(ok);
1226
+ normalizeXpraDesktopWindow(xpraWindow, width, height) {
1227
+ if (!xpraWindow) return;
1228
+ const normalizedWidth = Math.max(1, Math.round(Number(width || 0)));
1229
+ const normalizedHeight = Math.max(1, Math.round(Number(height || 0)));
1230
+ xpraWindow.x = 0;
1231
+ xpraWindow.y = 0;
1232
+ xpraWindow.w = normalizedWidth;
1233
+ xpraWindow.h = normalizedHeight;
1234
+ xpraWindow.resizable = false;
1235
+ xpraWindow.decorations = false;
1236
+ xpraWindow.decorated = false;
1237
+ xpraWindow.metadata = { ...(xpraWindow.metadata || {}), decorations: false };
1238
+ xpraWindow._set_decorated?.(false);
1239
+ xpraWindow.configure_border_class?.();
1240
+ xpraWindow.leftoffset = 0;
1241
+ xpraWindow.rightoffset = 0;
1242
+ xpraWindow.topoffset = 0;
1243
+ xpraWindow.bottomoffset = 0;
1244
+ },
1245
+
1246
+ fitXpraDesktopWindowElement(xpraWindow, width, height) {
1247
+ const cssWidth = `${Math.max(1, Number(width || 0))}px`;
1248
+ const cssHeight = `${Math.max(1, Number(height || 0))}px`;
1249
+ const windowElement = xpraWindow?.div;
1250
+ const canvas = xpraWindow?.canvas;
1251
+ windowElement?.style?.setProperty("left", "0px", "important");
1252
+ windowElement?.style?.setProperty("top", "0px", "important");
1253
+ windowElement?.style?.setProperty("position", "absolute", "important");
1254
+ windowElement?.style?.setProperty("width", cssWidth, "important");
1255
+ windowElement?.style?.setProperty("height", cssHeight, "important");
1256
+ windowElement?.style?.setProperty("transform", "none", "important");
1257
+ windowElement?.style?.setProperty("margin", "0", "important");
1258
+ canvas?.style?.setProperty("width", cssWidth, "important");
1259
+ canvas?.style?.setProperty("height", cssHeight, "important");
1260
+ canvas?.style?.setProperty("display", "block", "important");
1261
+ canvas?.style?.setProperty("margin", "0", "important");
1262
+ },
1263
+
1264
+ installXpraDesktopFrameCss(remoteDocument) {
1265
+ if (!remoteDocument || remoteDocument.getElementById("a0-xpra-desktop-frame-css")) return;
1266
+ const style = remoteDocument.createElement("style");
1267
+ style.id = "a0-xpra-desktop-frame-css";
1268
+ style.textContent = `
1269
+ html, body, #screen {
1270
+ width: 100% !important;
1271
+ height: 100% !important;
1272
+ overflow: hidden !important;
1273
+ }
1274
+ #float_menu,
1275
+ .windowhead,
1276
+ .windowbuttons {
1277
+ display: none !important;
1278
+ }
1279
+ .window,
1280
+ .window.border,
1281
+ .window.desktop,
1282
+ .undecorated,
1283
+ .undecorated.border,
1284
+ .undecorated.desktop {
1285
+ left: 0 !important;
1286
+ top: 0 !important;
1287
+ position: absolute !important;
1288
+ width: 100% !important;
1289
+ height: 100% !important;
1290
+ transform: none !important;
1291
+ margin: 0 !important;
1292
+ border: 0 !important;
1293
+ border-radius: 0 !important;
1294
+ box-shadow: none !important;
1295
+ }
1296
+ .window canvas,
1297
+ .undecorated canvas {
1298
+ display: block !important;
1299
+ width: 100% !important;
1300
+ height: 100% !important;
1301
+ margin: 0 !important;
1302
+ border: 0 !important;
1303
+ border-radius: 0 !important;
1304
+ box-shadow: none !important;
1305
+ }
1306
+ `;
1307
+ remoteDocument.head?.appendChild(style);
1308
},
1309
452
- closeFile() {
453
- return this.closeTab(this.activeTabId);
1310
+ queueDesktopResize(options = {}) {
1311
+ if (!this.hasOfficialOffice()) return;
1312
+ const token = this.session?.desktop?.token || "";
1313
+ const frame = this.desktopFrame();
1314
+ const target = frame?.parentElement || frame;
1315
+ if (!token || !target) return;
1316
+ const force = Boolean(options.force);
1317
+ const rect = target.getBoundingClientRect();
1318
+ const width = Math.round(rect.width);
1319
+ const height = Math.round(rect.height);
1320
+ if (width < 320 || height < 220) return;
1321
+ this.applyXpraDesktopFrameMode(frame, { requestServerResize: false, requestRefresh: false });
1322
+ if (!force && this.shouldDeferDesktopResize()) {
1323
+ this._desktopResizePending = true;
1324
+ return;
1325
+ }
1326
+ const key = `${token}:${width}x${height}`;
1327
+ if (!force && key === this._desktopResizeKey) return;
1328
+ if (options.serverResize !== true) {
1329
+ this._desktopResizeKey = key;
1330
+ return;
1331
+ }
1332
+ if (this._desktopResizeTimer) globalThis.clearTimeout(this._desktopResizeTimer);
1333
+ this._desktopResizeTimer = globalThis.setTimeout(async () => {
1334
+ this._desktopResizeTimer = null;
1335
+ if (!force && this.shouldDeferDesktopResize()) {
1336
+ this._desktopResizePending = true;
1337
+ return;
1338
+ }
1339
+ try {
1340
+ const params = new URLSearchParams({ token, width: String(width), height: String(height) });
1341
+ const response = await fetch(`/desktop/resize?${params.toString()}`, { credentials: "same-origin" });
1342
+ if (response.ok) {
1343
+ const result = await response.json().catch(() => ({}));
1344
+ this._desktopResizeKey = key;
1345
+ if (result?.reload) {
1346
+ this.reloadDesktopFrame(frame);
1347
+ }
1348
+ this.primeXpraDesktopFrame({ reset: true });
1349
+ }
1350
+ } catch (error) {
1351
+ console.warn("Desktop resize skipped", error);
1352
+ }
1353
+ }, DESKTOP_RESIZE_DELAY_MS);
1354
},
1355
456
- blankFrame() {
457
- const frame = this.activeFrame();
458
- if (frame) {
459
- frame.src = "about:blank";
1356
+ reloadDesktopFrame(frame = null) {
1357
+ const target = this.desktopFrame(frame);
1358
+ if (!target) return;
1359
+ const current = target.getAttribute("src") || target.src || this.officialOfficeUrl();
1360
+ if (!current) return;
1361
+ try {
1362
+ const url = new URL(current, window.location.href);
1363
+ url.searchParams.set("a0_reload", String(Date.now()));
1364
+ target.setAttribute("src", `${url.pathname}${url.search}`);
1365
+ } catch {
1366
+ target.setAttribute("src", current);
1367
}
1368
},
1369
463
- async closeTab(tabId = this.activeTabId, options = {}) {
464
- const normalized = normalizeTabId(tabId);
465
- const index = this.tabs.findIndex((tab) => tab.tab_id === normalized);
466
- if (index < 0) return;
1370
+ startDesktopMonitor() {
1371
+ this.stopDesktopMonitor();
1372
+ if (!this.hasOfficialOffice()) return;
1373
+ const tabId = this.session?.tab_id || "";
1374
+ const sessionId = this.session?.desktop_session_id || this.session?.session_id || "";
1375
+ if (!tabId || !sessionId) return;
1376
+ this._desktopHeartbeatSessionId = sessionId;
1377
+ this._desktopHeartbeatTabId = tabId;
1378
+ this._desktopHeartbeatMisses = 0;
1379
468
- const tab = this.tabs[index];
469
- const wasActive = tab.tab_id === this.activeTabId;
470
- if (wasActive && !options.skipSave) {
471
- await this.save({ wait: true, timeoutMs: 1200 });
1380
+ const tick = async () => {
1381
+ if (!this.session || this.session.tab_id !== tabId || !this.hasOfficialOffice()) return;
1382
+ try {
1383
+ const response = await callOffice("desktop_sync", {
1384
+ desktop_session_id: sessionId,
1385
+ file_id: this.session.file_id || "",
1386
+ });
1387
+ if (response?.ok === false) throw new Error(response.error || "Desktop session closed.");
1388
+ this._desktopHeartbeatMisses = 0;
1389
+ if (response?.document) {
1390
+ const document = normalizeDocument(response.document);
1391
+ this.replaceActiveSession({
1392
+ ...this.session,
1393
+ document,
1394
+ path: document.path || this.session.path,
1395
+ file_id: document.file_id || this.session.file_id,
1396
+ version: document.version || this.session.version,
1397
+ });
1398
+ }
1399
+ } catch {
1400
+ if (!this.session || this.session.tab_id !== tabId) return;
1401
+ this._desktopHeartbeatMisses += 1;
1402
+ if (this._desktopHeartbeatMisses >= 2) {
1403
+ await this.handleOfficialOfficeClosed(tabId);
1404
+ }
1405
+ }
1406
+ };
1407
+
1408
+ this._desktopHeartbeatTimer = globalThis.setInterval(tick, DESKTOP_HEARTBEAT_MS);
1409
+ globalThis.setTimeout(tick, Math.min(1200, DESKTOP_HEARTBEAT_MS));
1410
+ },
1411
+
1412
+ stopDesktopMonitor() {
1413
+ if (this._desktopHeartbeatTimer) {
1414
+ globalThis.clearInterval(this._desktopHeartbeatTimer);
1415
}
473
- await this.closeBackendSession(tab);
474
- this.tabs.splice(index, 1);
1416
+ this._desktopHeartbeatTimer = null;
1417
+ this._desktopHeartbeatSessionId = "";
1418
+ this._desktopHeartbeatTabId = "";
1419
+ this._desktopHeartbeatMisses = 0;
1420
+ },
1421
+
1422
+ async handleOfficialOfficeClosed(tabId) {
1423
+ const tab = this.tabs.find((item) => item.tab_id === tabId);
1424
+ if (!tab || tab._desktopClosed) return;
1425
+ tab._desktopClosed = true;
1426
+ this.stopDesktopMonitor();
1427
+ this.stopDesktopResizeObserver();
1428
+ this.stopXpraDesktopPrime();
1429
+ this.setMessage("Desktop is restarting");
1430
+ await this.ensureDesktopSession({ force: true, select: this.activeTabId === tabId });
1431
+ tab._desktopClosed = false;
1432
+ await this.refresh();
1433
+ },
1434
476
- if (!this.tabs.length) {
477
- this.activeTabId = "";
478
- this.session = null;
479
- this.frameReady = false;
480
- this._frameOrigin = "";
481
- this._frameAttempt = 0;
482
- this._frameRecoveryTried = false;
483
- this.clearFrameTimers();
484
- this.blankFrame();
485
- await this.refresh();
1435
+ hasNativeDocxTiles() {
1436
+ return Boolean(
1437
+ this.isDocx()
1438
+ && this.session?.native?.available
1439
+ && Array.isArray(this.session?.tiles)
1440
+ && this.session.tiles.some((tile) => tile?.image),
1441
+ );
1442
+ },
1443
+
1444
+ async onNativeDocxClick(event) {
1445
+ if (!this.hasNativeDocxTiles()) return;
1446
+ const page = event.target?.closest?.(".office-docx-page.is-native-tile");
1447
+ const image = page?.querySelector?.("img");
1448
+ if (!page || !image) return;
1449
+ const twips = this.decodeTileTwips(page);
1450
+ const rect = image.getBoundingClientRect();
1451
+ const ratioX = Math.max(0, Math.min(1, (event.clientX - rect.left) / Math.max(1, rect.width)));
1452
+ const ratioY = Math.max(0, Math.min(1, (event.clientY - rect.top) / Math.max(1, rect.height)));
1453
+ const x = Math.round((twips.x || 0) + ratioX * (twips.width || 0));
1454
+ const y = Math.round((twips.y || 0) + ratioY * (twips.height || 0));
1455
+ this._docxEditor?.focus?.({ preventScroll: true });
1456
+ await this.sendNativeMouse({ type: "down", x, y, count: 1, buttons: 1, modifier: 0 });
1457
+ await this.sendNativeMouse({ type: "up", x, y, count: 1, buttons: 1, modifier: 0 });
1458
+ },
1459
+
1460
+ onNativeDocxKeydown(event) {
1461
+ if (!this.hasNativeDocxTiles()) return;
1462
+ if (event.ctrlKey || event.metaKey || event.altKey) return;
1463
+ const key = event.key || "";
1464
+ if (key.length === 1) {
1465
+ event.preventDefault();
1466
+ void this.sendNativeKey({ text: key });
1467
return;
1468
}
488
-
489
- if (wasActive) {
490
- const nextTab = this.tabs[Math.min(index, this.tabs.length - 1)];
491
- this.activeTabId = nextTab.tab_id;
492
- this.syncActiveSession();
493
- await this.restartFrameLoad();
1469
+ const special = {
1470
+ Enter: { text: "\n" },
1471
+ Tab: { text: "\t" },
1472
+ Backspace: { char_code: 0, key_code: 8 },
1473
+ Delete: { char_code: 0, key_code: 127 },
1474
+ ArrowLeft: { char_code: 0, key_code: 37 },
1475
+ ArrowUp: { char_code: 0, key_code: 38 },
1476
+ ArrowRight: { char_code: 0, key_code: 39 },
1477
+ ArrowDown: { char_code: 0, key_code: 40 },
1478
+ }[key];
1479
+ if (!special) return;
1480
+ event.preventDefault();
1481
+ if (special.text != null) {
1482
+ void this.sendNativeKey({ text: special.text });
1483
} else {
495
- this.syncActiveSession();
1484
+ void this.sendNativeKey({ type: "down", ...special }).then(() => this.sendNativeKey({ type: "up", ...special }));
1485
}
497
- await this.refresh();
1486
},
1487
500
- async closeBackendSession(tab) {
501
- if (!tab?.session_id && !tab?.file_id) return;
1488
+ decodeTileTwips(page) {
1489
try {
503
- await callJsonApi("/plugins/_office/office_session", {
504
- action: "close",
505
- session_id: tab.session_id || "",
506
- file_id: tab.session_id ? "" : (tab.file_id || ""),
507
- });
508
- } catch (error) {
509
- console.warn("Office session close skipped", error);
1490
+ return JSON.parse(decodeURIComponent(page?.dataset?.twips || "{}"));
1491
+ } catch {
1492
+ return {};
1493
}
1494
},
1495
513
- async selectTab(tabId) {
514
- const tab = this.tabById(tabId);
515
- if (!tab) return;
516
- if (tab.tab_id === this.activeTabId && this.session) return;
517
- await this.save({ wait: true, timeoutMs: 900 });
518
- this.activeTabId = tab.tab_id;
519
- this.syncActiveSession();
520
- await this.restartFrameLoad();
521
- },
522
-
523
- normalizeTab(session) {
524
- const tabId = makeTabId(session);
525
- return {
526
- ...session,
527
- tab_id: tabId,
528
- session_id: normalizeTabId(session?.session_id) || tabId,
529
- title: String(session?.title || session?.basename || session?.path || "Office file"),
530
- opened_at: session?.opened_at || Date.now(),
531
- };
1496
+ async sendNativeKey(key) {
1497
+ if (!this.session?.session_id) return null;
1498
+ return await this.queueNativeEvent(async () => {
1499
+ const response = await this.sendNativeEvent("office_key", "key", key, "key");
1500
+ if (response?.ok) this.markDirty();
1501
+ return response;
1502
+ });
1503
},
1504
534
- findTabIndexForSession(session) {
535
- return this.tabs.findIndex((tab) => sameDocument(tab, session));
1505
+ async sendNativeMouse(mouse) {
1506
+ if (!this.session?.session_id) return null;
1507
+ return await this.queueNativeEvent(() => this.sendNativeEvent("office_mouse", "mouse", mouse, "mouse"));
1508
},
1509
538
- tabById(tabId) {
539
- const normalized = normalizeTabId(tabId);
540
- return this.tabs.find((tab) => tab.tab_id === normalized) || null;
1510
+ async queueNativeEvent(task) {
1511
+ const run = this._nativeEventQueue.catch(() => null).then(task);
1512
+ this._nativeEventQueue = run.catch(() => null);
1513
+ return await run;
1514
},
1515
543
- activeTab() {
544
- return this.tabById(this.activeTabId) || this.tabs[0] || null;
1516
+ async awaitNativeEvents() {
1517
+ await this._nativeEventQueue.catch(() => null);
1518
},
1519
547
- ensureActiveTab() {
548
- if (!this.tabs.length) {
549
- this.activeTabId = "";
550
- this.session = null;
551
- return;
552
- }
553
- if (!this.tabById(this.activeTabId)) {
554
- this.activeTabId = this.tabs[0].tab_id;
1520
+ async sendNativeEvent(socketEvent, apiAction, payload, key) {
1521
+ try {
1522
+ let response;
1523
+ try {
1524
+ response = await requestOffice(socketEvent, {
1525
+ session_id: this.session.session_id,
1526
+ [key]: payload,
1527
+ }, 7000);
1528
+ } catch (_socketError) {
1529
+ response = await callOffice(apiAction, {
1530
+ session_id: this.session.session_id,
1531
+ [key]: payload,
1532
+ });
1533
+ }
1534
+ if (response?.metadata && this.session) {
1535
+ this.session.native = { ...(this.session.native || {}), ...response.metadata, available: true };
1536
+ }
1537
+ if (Array.isArray(response?.tiles) && this.session) {
1538
+ this.session.tiles = response.tiles;
1539
+ this.queueRender({ force: true, focus: true });
1540
+ }
1541
+ return response;
1542
+ } catch (error) {
1543
+ console.warn("LibreOffice native event skipped", socketEvent, error);
1544
+ return null;
1545
}
556
- this.syncActiveSession();
1546
},
1547
559
- syncActiveSession() {
560
- this.session = this.activeTab();
1548
+ isPreviewOnly() {
1549
+ return Boolean(this.session && !this.hasOfficialOffice() && !this.isMarkdown() && !this.isDocx());
1550
},
1551
563
- isActiveTab(tab) {
564
- return Boolean(tab?.tab_id && tab.tab_id === this.activeTabId);
1552
+ defaultTitle(kind, fmt) {
1553
+ const date = new Date().toISOString().slice(0, 10);
1554
+ if (fmt === "md") return `Document ${date}`;
1555
+ if (fmt === "docx") return `DOCX ${date}`;
1556
+ if (kind === "spreadsheet") return `Spreadsheet ${date}`;
1557
+ if (kind === "presentation") return `Presentation ${date}`;
1558
+ return `Document ${date}`;
1559
},
1560
567
- tabTitle(tab) {
568
- const title = String(tab?.title || tab?.basename || "").trim();
569
- if (title) return title;
570
- const path = String(tab?.path || "").trim();
571
- return path.split("/").filter(Boolean).pop() || "Office file";
1561
+ tabTitle(tab = {}) {
1562
+ return tab.title || tab.document?.basename || basename(tab.path);
1563
},
1564
574
- tabLabel(tab) {
575
- const extension = String(tab?.extension || "").trim().toUpperCase();
576
- return extension ? `${this.tabTitle(tab)} (${extension})` : this.tabTitle(tab);
1565
+ tabLabel(tab = {}) {
1566
+ const title = this.tabTitle(tab);
1567
+ return tab.dirty ? `${title} unsaved` : title;
1568
},
1569
579
- tabIcon(tab) {
580
- const extension = String(tab?.extension || "").toLowerCase();
581
- if (["xlsx", "ods"].includes(extension)) return "table_chart";
582
- if (["pptx", "odp"].includes(extension)) return "co_present";
583
- if (["docx", "odt"].includes(extension)) return "article";
584
- return "description";
1570
+ tabIcon(tab = {}) {
1571
+ const ext = String(tab.extension || tab.document?.extension || "").toLowerCase();
1572
+ if (this.isDesktopSession(tab)) return "desktop_windows";
1573
+ if (ext === "md") return "article";
1574
+ if (ext === "docx") return "description";
1575
+ if (ext === "xlsx") return "table_chart";
1576
+ if (ext === "pptx") return "co_present";
1577
+ return "draft";
1578
},
1579
587
- openDocumentLabel(doc) {
588
- const basename = String(doc?.basename || doc?.title || "").trim();
589
- const path = String(doc?.path || "").trim();
590
- return basename || path.split("/").filter(Boolean).pop() || "Office file";
1580
+ documentPath() {
1581
+ return this.session?.document?.path || this.session?.path || "";
1582
},
1583
593
- openCards() {
594
- return this.tabs.map((tab) => ({ ...tab, dashboard_open: true }));
1584
+ documentMeta(doc = this.session?.document || this.session || {}) {
1585
+ const parts = [String(doc.extension || "").toUpperCase(), formatBytes(doc.size)].filter(Boolean);
1586
+ return parts.join(" · ");
1587
},
1588
597
- recentCards() {
598
- const openFileIds = new Set(this.tabs.map((tab) => normalizeTabId(tab?.file_id)).filter(Boolean));
599
- return (this.recent || []).filter((doc) => !openFileIds.has(normalizeTabId(doc?.file_id)));
600
- },
601
-
602
- dashboardTitle(doc) {
603
- return this.openDocumentLabel(doc);
1589
+ openCards() {
1590
+ return this.tabs
1591
+ .filter((tab) => !this.isDesktopSession(tab))
1592
+ .map((tab) => normalizeDocument({
1593
+ ...tab.document,
1594
+ ...tab,
1595
+ open: true,
1596
+ }));
1597
},
1598
606
- dashboardMeta(doc) {
607
- const extension = String(doc?.extension || "").trim().toUpperCase();
608
- const size = formatBytes(doc?.size);
609
- return [extension, size].filter(Boolean).join(" / ");
1599
+ recentCards() {
1600
+ const openIds = new Set(this.tabs.map((tab) => tab.file_id).filter(Boolean));
1601
+ return this.recent.filter((doc) => !openIds.has(doc.file_id)).slice(0, 8);
1602
},
1603
612
- previewKind(doc) {
613
- const kind = String(doc?.preview?.kind || "").trim();
614
- if (kind === "spreadsheet" && !doc?.preview?.rows?.length && doc?.preview?.lines?.length) return "document";
615
- if (kind === "presentation" && !doc?.preview?.slides?.length && doc?.preview?.lines?.length) return "document";
616
- if (kind) return kind;
617
- const extension = String(doc?.extension || "").toLowerCase();
618
- if (["xlsx", "ods"].includes(extension)) return "spreadsheet";
619
- if (["pptx", "odp"].includes(extension)) return "presentation";
620
- if (["docx", "odt"].includes(extension)) return "document";
621
- return "file";
1604
+ previewKind(doc = {}) {
1605
+ const ext = String(doc.extension || "").toLowerCase();
1606
+ if (ext === "xlsx") return "spreadsheet";
1607
+ if (ext === "pptx") return "presentation";
1608
+ if (ext === "md") return "markdown";
1609
+ return "document";
1610
},
1611
624
- hasPreview(doc) {
625
- const preview = doc?.preview || {};
1612
+ hasPreview(doc = {}) {
1613
+ const preview = doc.preview || {};
1614
return Boolean(
627
- preview.available
628
- && (
629
- preview.lines?.length
630
- || preview.rows?.length
631
- || preview.slides?.length
632
- )
1615
+ (Array.isArray(preview.lines) && preview.lines.length)
1616
+ || (Array.isArray(preview.rows) && preview.rows.length)
1617
+ || (Array.isArray(preview.slides) && preview.slides.length)
1618
);
1619
},
1620
636
- previewLines(doc) {
637
- const lines = doc?.preview?.lines || [];
638
- if (lines.length) return lines.slice(0, 5).map((line) => String(line || ""));
639
- const slides = doc?.preview?.slides || [];
640
- if (slides.length) {
641
- return [slides[0]?.title, ...(slides[0]?.lines || [])].filter(Boolean).slice(0, 5);
642
- }
643
- return [];
1621
+ previewLines(doc = {}) {
1622
+ const preview = doc.preview || {};
1623
+ return (preview.lines || []).slice(0, 8);
1624
},
1625
646
- previewRows(doc) {
647
- return (doc?.preview?.rows || [])
648
- .slice(0, 5)
649
- .map((row) => {
650
- const cells = (Array.isArray(row) ? row : []).slice(0, 4).map((cell) => String(cell ?? ""));
651
- while (cells.length < 4) cells.push("");
652
- return cells;
653
- });
1626
+ previewRows(doc = {}) {
1627
+ const preview = doc.preview || {};
1628
+ return (preview.rows || []).slice(0, 6);
1629
},
1630
656
- previewSlides(doc) {
657
- return (doc?.preview?.slides || []).slice(0, 2);
1631
+ previewSlides(doc = {}) {
1632
+ const preview = doc.preview || {};
1633
+ return (preview.slides || []).slice(0, 3);
1634
},
1635
660
- onPostMessage(event) {
661
- if (!this.session) return;
662
- if (!this.isAllowedFrameOrigin(event.origin)) return;
663
- this._frameOrigin = event.origin;
664
- const message = parseMessage(event.data);
665
- const id = message.MessageId || message.messageId || "";
666
- if (id === "App_LoadingStatus" && message.Values?.Status === "Frame_Ready") {
667
- this.frameReady = true;
668
- this.clearFrameTimers();
669
- if (this.message === "Still opening the editor...") this.message = "";
670
- if (this.message === "Still opening the editor... trying a fresh editor load.") this.message = "";
671
- this.postToFrame({ MessageId: "Host_PostmessageReady" });
672
- } else if (id === "UI_Close") {
673
- void this.closeTab(this.activeTabId, { skipSave: true });
674
- } else if (id === "Action_Save_Resp") {
675
- const ok = message.Values?.success !== false;
676
- this.message = ok ? "Saved" : "Save did not complete.";
677
- this.resolveSaveWaiters(ok);
678
- }
679
- },
680
-
681
- isAllowedFrameOrigin(origin) {
682
- const allowed = new Set([
683
- globalThis.location.origin,
684
- this.session?.post_message_origin,
685
- this.loopbackCounterpart(globalThis.location.origin),
686
- this.loopbackCounterpart(this.session?.post_message_origin),
687
- ].filter(Boolean));
688
- return allowed.has(origin);
689
- },
690
-
691
- loopbackCounterpart(origin) {
692
- if (!origin) return "";
693
- try {
694
- const url = new URL(origin);
695
- if (url.hostname === "127.0.0.1") {
696
- url.hostname = "localhost";
697
- return url.origin;
698
- }
699
- if (url.hostname === "localhost") {
700
- url.hostname = "127.0.0.1";
701
- return url.origin;
702
- }
703
- } catch {
704
- return "";
705
- }
706
- return "";
1636
+ dashboardTitle(doc = {}) {
1637
+ return doc.title || doc.basename || basename(doc.path);
1638
},
1639
709
- assignFrameName(element = null) {
710
- const root = element || this._root;
711
- if (!root) return this.frameName || FRAME_NAME_PREFIX;
712
- if (!root.dataset.officeFrameName) {
713
- root.dataset.officeFrameName = makeFrameName();
714
- }
715
- const frame = root.querySelector?.("iframe[data-office-frame]");
716
- if (frame) {
717
- frame.setAttribute("name", root.dataset.officeFrameName);
718
- frame.name = root.dataset.officeFrameName;
719
- try {
720
- frame.contentWindow.name = root.dataset.officeFrameName;
721
- } catch {}
722
- }
723
- this.frameName = root.dataset.officeFrameName;
724
- return this.frameName;
725
- },
726
-
727
- activeFrame() {
728
- this.assignFrameName();
729
- return this._root?.querySelector?.("iframe[data-office-frame]") || null;
1640
+ dashboardMeta(doc = {}) {
1641
+ return [String(doc.extension || "").toUpperCase(), doc.open ? "Open" : "", formatBytes(doc.size)].filter(Boolean).join(" · ");
1642
},
1643
1644
setupFloatingModal(element = null) {
733
- this._floatingCleanup?.();
1645
const root = element || globalThis.document?.querySelector(".office-panel");
735
- const modal = root?.closest?.(".modal");
736
- const inner = modal?.querySelector?.(".modal-inner");
737
- const body = modal?.querySelector?.(".modal-bd");
738
- const header = modal?.querySelector?.(".modal-header");
739
- if (!modal || !inner || !header) return;
740
- modal.classList.add("modal-floating");
1646
+ const inner = root?.closest?.(".modal-inner");
1647
+ const body = root?.closest?.(".modal-bd");
1648
+ const header = inner?.querySelector?.(".modal-header");
1649
+ if (!inner || !body || !header || inner.dataset.officeModalReady === "1") return;
1650
+
1651
+ inner.dataset.officeModalReady = "1";
1652
inner.classList.add("office-modal", "modal-no-backdrop");
742
- body?.classList?.add("office-modal-body");
743
-
744
- const rect = inner.getBoundingClientRect();
745
- inner.style.left = `${Math.max(8, rect.left)}px`;
746
- inner.style.top = `${Math.max(8, rect.top)}px`;
747
- inner.style.transform = "none";
748
-
749
- let drag = null;
750
- let resizeObserver = null;
751
- const viewportGap = 8;
752
- const clampPosition = (left, top) => {
753
- const bounds = inner.getBoundingClientRect();
754
- const maxLeft = Math.max(viewportGap, globalThis.innerWidth - bounds.width - viewportGap);
755
- const maxTop = Math.max(viewportGap, globalThis.innerHeight - bounds.height - viewportGap);
1653
+ body.classList.add("office-modal-body");
1654
+ header.style.cursor = "move";
1655
+
1656
+ const inset = 8;
1657
+ const minWidth = 720;
1658
+ const minHeight = 520;
1659
+ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
1660
+ const cleanup = [];
1661
+ let beforeFocusBounds = null;
1662
+ let dragging = false;
1663
+ let resizing = false;
1664
+ let pointerId = 0;
1665
+ let startX = 0;
1666
+ let startY = 0;
1667
+ let startLeft = 0;
1668
+ let startTop = 0;
1669
+ let startWidth = 0;
1670
+ let startHeight = 0;
1671
+ let resizeMode = "";
1672
+
1673
+ const currentBounds = () => {
1674
+ const rect = inner.getBoundingClientRect();
1675
return {
757
- left: Math.min(Math.max(viewportGap, left), maxLeft),
758
- top: Math.min(Math.max(viewportGap, top), maxTop),
1676
+ left: rect.left,
1677
+ top: rect.top,
1678
+ width: rect.width,
1679
+ height: rect.height,
1680
};
1681
};
761
- const clampGeometry = () => {
762
- const bounds = inner.getBoundingClientRect();
763
- const left = Math.max(viewportGap, bounds.left);
764
- const top = Math.max(viewportGap, bounds.top);
765
- const maxWidth = Math.max(340, globalThis.innerWidth - viewportGap * 2);
766
- const maxHeight = Math.max(360, globalThis.innerHeight - viewportGap * 2);
767
- if (bounds.width > maxWidth) inner.style.width = `${maxWidth}px`;
768
- if (bounds.height > maxHeight) inner.style.height = `${maxHeight}px`;
769
- const next = clampPosition(left, top);
770
- inner.style.left = `${next.left}px`;
771
- inner.style.top = `${next.top}px`;
772
- inner.style.maxWidth = `${Math.max(340, globalThis.innerWidth - next.left - viewportGap)}px`;
773
- inner.style.maxHeight = `${Math.max(360, globalThis.innerHeight - next.top - viewportGap)}px`;
1682
+
1683
+ const normalizedBounds = (bounds) => {
1684
+ const maxWidth = Math.max(320, globalThis.innerWidth - inset * 2);
1685
+ const maxHeight = Math.max(320, globalThis.innerHeight - inset * 2);
1686
+ const safeMinWidth = Math.min(minWidth, maxWidth);
1687
+ const safeMinHeight = Math.min(minHeight, maxHeight);
1688
+ const width = clamp(bounds.width, safeMinWidth, maxWidth);
1689
+ const height = clamp(bounds.height, safeMinHeight, maxHeight);
1690
+ return {
1691
+ width,
1692
+ height,
1693
+ left: clamp(bounds.left, inset, Math.max(inset, globalThis.innerWidth - width - inset)),
1694
+ top: clamp(bounds.top, inset, Math.max(inset, globalThis.innerHeight - height - inset)),
1695
+ };
1696
};
775
- clampGeometry();
776
- globalThis.addEventListener("resize", clampGeometry);
777
- if (globalThis.ResizeObserver) {
778
- resizeObserver = new ResizeObserver(clampGeometry);
779
- resizeObserver.observe(inner);
780
- }
1697
782
- const onPointerMove = (event) => {
783
- if (!drag) return;
784
- const next = clampPosition(
785
- drag.left + event.clientX - drag.x,
786
- drag.top + event.clientY - drag.y,
787
- );
788
- inner.style.left = `${next.left}px`;
789
- inner.style.top = `${next.top}px`;
790
- clampGeometry();
1698
+ const setBounds = (bounds) => {
1699
+ const next = normalizedBounds(bounds);
1700
+ inner.style.position = "fixed";
1701
+ inner.style.transform = "none";
1702
+ inner.style.left = `${Math.round(next.left)}px`;
1703
+ inner.style.top = `${Math.round(next.top)}px`;
1704
+ inner.style.width = `${Math.round(next.width)}px`;
1705
+ inner.style.height = `${Math.round(next.height)}px`;
1706
+ inner.style.right = "auto";
1707
+ inner.style.bottom = "auto";
1708
+ inner.style.margin = "0";
1709
};
792
- const onPointerUp = () => {
793
- drag = null;
794
- globalThis.removeEventListener("pointermove", onPointerMove);
795
- globalThis.removeEventListener("pointerup", onPointerUp);
796
- try {
797
- header.releasePointerCapture?.(header.__officePanelPointerId || 0);
798
- } catch {}
1710
+
1711
+ const ensurePosition = () => {
1712
+ setBounds(currentBounds());
1713
};
1714
+
1715
+ const shield = globalThis.document.createElement("div");
1716
+ shield.className = "office-modal-input-shield";
1717
+ inner.appendChild(shield);
1718
+ cleanup.push(() => shield.remove());
1719
+
1720
+ const setShield = (visible, cursor = "") => {
1721
+ shield.style.display = visible ? "block" : "none";
1722
+ shield.style.cursor = cursor;
1723
+ };
1724
+
1725
+ const focusButton = globalThis.document.createElement("button");
1726
+ focusButton.type = "button";
1727
+ focusButton.className = "modal-dock-button office-modal-focus-button";
1728
+ focusButton.innerHTML = '<span class="material-symbols-outlined" aria-hidden="true">fullscreen</span>';
1729
+ const updateFocusButton = (active) => {
1730
+ focusButton.title = active ? "Restore size" : "Focus mode";
1731
+ focusButton.setAttribute("aria-label", focusButton.title);
1732
+ focusButton.querySelector(".material-symbols-outlined").textContent = active ? "fullscreen_exit" : "fullscreen";
1733
+ };
1734
+ updateFocusButton(false);
1735
+ const closeButton = inner.querySelector(".modal-close");
1736
+ if (closeButton) {
1737
+ closeButton.insertAdjacentElement("beforebegin", focusButton);
1738
+ } else {
1739
+ header.appendChild(focusButton);
1740
+ }
1741
+ cleanup.push(() => focusButton.remove());
1742
+
1743
+ const setFocusMode = (enabled) => {
1744
+ ensurePosition();
1745
+ if (enabled) {
1746
+ beforeFocusBounds = currentBounds();
1747
+ inner.classList.add("is-focus-mode");
1748
+ setBounds({
1749
+ left: inset,
1750
+ top: inset,
1751
+ width: globalThis.innerWidth - inset * 2,
1752
+ height: globalThis.innerHeight - inset * 2,
1753
+ });
1754
+ updateFocusButton(true);
1755
+ return;
1756
+ }
1757
+ inner.classList.remove("is-focus-mode");
1758
+ setBounds(beforeFocusBounds || currentBounds());
1759
+ beforeFocusBounds = null;
1760
+ updateFocusButton(false);
1761
+ };
1762
+
1763
+ const onFocusClick = () => setFocusMode(!inner.classList.contains("is-focus-mode"));
1764
+ focusButton.addEventListener("click", onFocusClick);
1765
+ cleanup.push(() => focusButton.removeEventListener("click", onFocusClick));
1766
+
1767
const onPointerDown = (event) => {
1768
if (event.button !== 0) return;
802
- if (event.target?.closest?.("button, input, select, textarea, a")) return;
803
- const current = inner.getBoundingClientRect();
804
- drag = {
805
- x: event.clientX,
806
- y: event.clientY,
807
- left: current.left,
808
- top: current.top,
809
- };
810
- header.__officePanelPointerId = event.pointerId;
811
- header.setPointerCapture?.(event.pointerId);
812
- globalThis.addEventListener("pointermove", onPointerMove);
813
- globalThis.addEventListener("pointerup", onPointerUp);
1769
+ if (event.target?.closest?.("button,a,input,textarea,select")) return;
1770
+ if (inner.classList.contains("is-focus-mode")) return;
1771
+ ensurePosition();
1772
+ const rect = inner.getBoundingClientRect();
1773
+ dragging = true;
1774
+ pointerId = event.pointerId;
1775
+ startX = event.clientX;
1776
+ startY = event.clientY;
1777
+ startLeft = rect.left;
1778
+ startTop = rect.top;
1779
+ startWidth = rect.width;
1780
+ startHeight = rect.height;
1781
+ inner.classList.add("is-dragging");
1782
+ setShield(true, "move");
1783
+ header.setPointerCapture?.(pointerId);
1784
event.preventDefault();
1785
};
816
- header.addEventListener("pointerdown", onPointerDown);
1786
818
- this._floatingCleanup = () => {
819
- header.removeEventListener("pointerdown", onPointerDown);
820
- globalThis.removeEventListener("pointermove", onPointerMove);
821
- globalThis.removeEventListener("pointerup", onPointerUp);
822
- globalThis.removeEventListener("resize", clampGeometry);
823
- resizeObserver?.disconnect?.();
1787
+ const onPointerMove = (event) => {
1788
+ if (!dragging || event.pointerId !== pointerId) return;
1789
+ setBounds({
1790
+ left: startLeft + event.clientX - startX,
1791
+ top: startTop + event.clientY - startY,
1792
+ width: startWidth,
1793
+ height: startHeight,
1794
+ });
1795
};
825
- },
1796
827
- setupCanvasSurface(element = null) {
828
- this._floatingCleanup?.();
829
- this._floatingCleanup = null;
830
- if (element) this._root = element;
831
- },
1797
+ const onPointerUp = (event) => {
1798
+ if (!dragging || event.pointerId !== pointerId) return;
1799
+ dragging = false;
1800
+ inner.classList.remove("is-dragging");
1801
+ setShield(false);
1802
+ header.releasePointerCapture?.(pointerId);
1803
+ };
1804
833
- async prepareBrowserHostForEditor() {
834
- await this.cleanupLegacyOfficeServiceWorkers();
835
- this.resetCollaboraBrowserState();
836
- },
1805
+ const createResizeHandle = (mode) => {
1806
+ const handle = globalThis.document.createElement("div");
1807
+ handle.className = `office-modal-resizer is-${mode}`;
1808
+ handle.dataset.officeResize = mode;
1809
+ inner.appendChild(handle);
1810
+ cleanup.push(() => handle.remove());
1811
+ return handle;
1812
+ };
1813
838
- async cleanupLegacyOfficeServiceWorkers() {
839
- const serviceWorker = globalThis.navigator?.serviceWorker;
840
- if (!serviceWorker?.getRegistrations) return;
841
- let removedController = false;
842
- try {
843
- const registrations = await serviceWorker.getRegistrations();
844
- const currentOrigin = globalThis.location.origin;
845
- const officePath = "/office/";
846
- for (const registration of registrations) {
847
- const scope = new URL(registration.scope);
848
- if (scope.origin !== currentOrigin) continue;
849
- const scopePath = scope.pathname.endsWith("/") ? scope.pathname : `${scope.pathname}/`;
850
- const affectsOffice = scopePath === "/" || scopePath.startsWith(officePath) || officePath.startsWith(scopePath);
851
- if (!affectsOffice) continue;
852
- const scriptUrl = registration.active?.scriptURL || "";
853
- if (scriptUrl.endsWith("/js/sw.js") && scopePath === "/js/") continue;
854
- removedController = await registration.unregister() || removedController;
855
- }
856
- const controllerUrl = serviceWorker.controller?.scriptURL || "";
857
- if (removedController && controllerUrl.startsWith(currentOrigin)) {
858
- const alreadyReloaded = sessionStorage.getItem(SERVICE_WORKER_CLEANUP_MARKER) === "1";
859
- if (!alreadyReloaded) {
860
- sessionStorage.setItem(SERVICE_WORKER_CLEANUP_MARKER, "1");
861
- globalThis.location.reload();
862
- }
863
- }
864
- } catch (error) {
865
- console.warn("Office service worker cleanup skipped", error);
1814
+ const onResizeDown = (event) => {
1815
+ if (event.button !== 0 || inner.classList.contains("is-focus-mode")) return;
1816
+ ensurePosition();
1817
+ const rect = inner.getBoundingClientRect();
1818
+ resizing = true;
1819
+ resizeMode = event.currentTarget.dataset.officeResize || "";
1820
+ pointerId = event.pointerId;
1821
+ startX = event.clientX;
1822
+ startY = event.clientY;
1823
+ startLeft = rect.left;
1824
+ startTop = rect.top;
1825
+ startWidth = rect.width;
1826
+ startHeight = rect.height;
1827
+ inner.classList.add("is-resizing");
1828
+ this.suspendDesktopResize();
1829
+ setShield(true, resizeMode === "right" ? "ew-resize" : resizeMode === "bottom" ? "ns-resize" : "nwse-resize");
1830
+ event.currentTarget.setPointerCapture?.(pointerId);
1831
+ event.preventDefault();
1832
+ event.stopPropagation();
1833
+ };
1834
+
1835
+ const onResizeMove = (event) => {
1836
+ if (!resizing || event.pointerId !== pointerId) return;
1837
+ const dx = event.clientX - startX;
1838
+ const dy = event.clientY - startY;
1839
+ setBounds({
1840
+ left: startLeft,
1841
+ top: startTop,
1842
+ width: resizeMode === "bottom" ? startWidth : startWidth + dx,
1843
+ height: resizeMode === "right" ? startHeight : startHeight + dy,
1844
+ });
1845
+ };
1846
+
1847
+ const onResizeUp = (event) => {
1848
+ if (!resizing || event.pointerId !== pointerId) return;
1849
+ resizing = false;
1850
+ resizeMode = "";
1851
+ inner.classList.remove("is-resizing");
1852
+ setShield(false);
1853
+ event.currentTarget.releasePointerCapture?.(pointerId);
1854
+ this.resumeDesktopResize();
1855
+ };
1856
+
1857
+ header.addEventListener("pointerdown", onPointerDown);
1858
+ header.addEventListener("pointermove", onPointerMove);
1859
+ header.addEventListener("pointerup", onPointerUp);
1860
+ header.addEventListener("pointercancel", onPointerUp);
1861
+ cleanup.push(() => header.removeEventListener("pointerdown", onPointerDown));
1862
+ cleanup.push(() => header.removeEventListener("pointermove", onPointerMove));
1863
+ cleanup.push(() => header.removeEventListener("pointerup", onPointerUp));
1864
+ cleanup.push(() => header.removeEventListener("pointercancel", onPointerUp));
1865
+
1866
+ for (const mode of ["right", "bottom", "corner"]) {
1867
+ const handle = createResizeHandle(mode);
1868
+ handle.addEventListener("pointerdown", onResizeDown);
1869
+ handle.addEventListener("pointermove", onResizeMove);
1870
+ handle.addEventListener("pointerup", onResizeUp);
1871
+ handle.addEventListener("pointercancel", onResizeUp);
1872
+ cleanup.push(() => handle.removeEventListener("pointerdown", onResizeDown));
1873
+ cleanup.push(() => handle.removeEventListener("pointermove", onResizeMove));
1874
+ cleanup.push(() => handle.removeEventListener("pointerup", onResizeUp));
1875
+ cleanup.push(() => handle.removeEventListener("pointercancel", onResizeUp));
1876
}
867
- },
1877
869
- resetCollaboraBrowserState(options = {}) {
870
- const force = Boolean(options.force);
871
- try {
872
- if (!force && localStorage.getItem(COLLABORA_STATE_MARKER) === COLLABORA_STATE_VERSION) {
1878
+ const onWindowResize = () => {
1879
+ if (inner.classList.contains("is-focus-mode")) {
1880
+ setBounds({
1881
+ left: inset,
1882
+ top: inset,
1883
+ width: globalThis.innerWidth - inset * 2,
1884
+ height: globalThis.innerHeight - inset * 2,
1885
+ });
1886
return;
1887
}
875
- const exactKeys = new Set([
876
- "UIDefaults",
877
- "WSDFeedbackCount",
878
- "WSDFeedbackTimestamp",
879
- ]);
880
- const collaboraKeyPattern = /^(text|spreadsheet|presentation|drawing)\.[A-Za-z0-9_.-]+$/;
881
- for (const key of Object.keys(localStorage)) {
882
- if (exactKeys.has(key) || collaboraKeyPattern.test(key)) {
883
- localStorage.removeItem(key);
884
- }
885
- }
886
- localStorage.setItem(COLLABORA_STATE_MARKER, COLLABORA_STATE_VERSION);
887
- } catch (error) {
888
- console.warn("Office browser state cleanup skipped", error);
1888
+ ensurePosition();
1889
+ };
1890
+ globalThis.addEventListener("resize", onWindowResize);
1891
+ cleanup.push(() => globalThis.removeEventListener("resize", onWindowResize));
1892
+
1893
+ if (globalThis.requestAnimationFrame) {
1894
+ globalThis.requestAnimationFrame(ensurePosition);
1895
+ } else {
1896
+ globalThis.setTimeout(ensurePosition, 0);
1897
}
1898
+ this._floatingCleanup = () => {
1899
+ cleanup.splice(0).reverse().forEach((entry) => entry());
1900
+ inner.classList.remove("is-dragging", "is-resizing", "is-focus-mode");
1901
+ this._desktopResizeSuspended = false;
1902
+ this._desktopResizePending = false;
1903
+ delete inner.dataset.officeModalReady;
1904
+ };
1905
},
1906
};
1907
webui/components/canvas/right-canvas-store.js
+46
-7
@@ -51,11 +51,6 @@ const model = {
51
await callJsExtensions("right_canvas_register_surfaces", this);
52
this._registering = false;
53
this.ensureActiveSurface();
54
- if (this.isOpen && this.activeSurfaceId) {
55
- globalThis.requestAnimationFrame?.(() => {
56
- void this.open(this.activeSurfaceId, this._lastPayloadBySurface[this.activeSurfaceId] || {});
57
- });
58
- }
54
}
55
},
56
@@ -103,6 +98,9 @@ const model = {
98
if (!surface) {
99
return false;
100
}
101
+ if (this.isMobileMode && !surface.actionOnly) {
102
+ return false;
103
+ }
104
if (typeof surface.canOpen === "function" && surface.canOpen(payload) === false) {
105
return false;
106
}
@@ -143,6 +141,9 @@ const model = {
141
},
142
143
async dockSurface(surfaceId, payload = {}) {
144
+ if (this.isMobileMode) {
145
+ return false;
146
+ }
147
const surface = this.getSurface(surfaceId);
148
if (!surface) {
149
return false;
@@ -228,6 +229,9 @@ const model = {
229
},
230
231
async toggleCanvas() {
232
+ if (this.isMobileMode) {
233
+ return false;
234
+ }
235
if (this.isOpen) {
236
await this.close();
237
return false;
@@ -256,6 +260,7 @@ const model = {
260
if (this.isOverlayMode || this.isMobileMode || !this.isOpen) return;
261
if (event.button !== 0) return;
262
event.preventDefault();
263
+ this.dispatchResizeEvent("right-canvas-resize-start");
264
265
const onPointerMove = (moveEvent) => {
266
const nextWidth = viewportWidth() - moveEvent.clientX;
@@ -264,13 +269,29 @@ const model = {
269
const onPointerUp = () => {
270
globalThis.removeEventListener("pointermove", onPointerMove);
271
globalThis.removeEventListener("pointerup", onPointerUp);
272
+ globalThis.removeEventListener("pointercancel", onPointerUp);
273
document.body.classList.remove("right-canvas-resizing");
274
this.persist();
275
+ this.dispatchResizeEvent("right-canvas-resize-end");
276
};
277
278
document.body.classList.add("right-canvas-resizing");
279
globalThis.addEventListener("pointermove", onPointerMove);
280
globalThis.addEventListener("pointerup", onPointerUp);
281
+ globalThis.addEventListener("pointercancel", onPointerUp);
282
+ },
283
+
284
+ dispatchResizeEvent(name) {
285
+ try {
286
+ globalThis.dispatchEvent(new CustomEvent(name, {
287
+ detail: {
288
+ width: this.width,
289
+ activeSurfaceId: this.activeSurfaceId,
290
+ },
291
+ }));
292
+ } catch {
293
+ // Resize events are an optimization hook for embedded surfaces.
294
+ }
295
},
296
297
persist() {
@@ -292,7 +313,7 @@ const model = {
313
this.width = this.defaultWidth();
314
try {
315
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
295
- this.isOpen = Boolean(saved.isOpen);
316
+ this.isOpen = false;
317
this.activeSurfaceId = String(saved.activeSurfaceId || "");
318
if (saved.width) this.width = Number(saved.width);
319
} catch (error) {
@@ -303,14 +324,28 @@ const model = {
324
325
updateLayoutMode() {
326
const width = viewportWidth();
327
+ const wasMobileMode = this.isMobileMode;
328
this.isOverlayMode = width < DESKTOP_BREAKPOINT;
329
this.isMobileMode = width <= MOBILE_BREAKPOINT;
330
+ if (this.isMobileMode) {
331
+ const wasOpen = this.isOpen;
332
+ const surface = wasOpen ? this.currentSurface() : null;
333
+ const payload = this._lastPayloadBySurface[this.activeSurfaceId] || {};
334
+ this.isOpen = false;
335
+ if (surface && wasOpen) {
336
+ globalThis.setTimeout?.(() => {
337
+ surface.close?.({ ...payload, reason: "mobile" });
338
+ }, 0);
339
+ }
340
+ } else if (wasMobileMode && this.width <= MIN_WIDTH) {
341
+ this.width = this.defaultWidth();
342
+ }
343
},
344
345
applyLayoutState() {
346
this.updateLayoutMode();
347
document.documentElement.style.setProperty("--right-canvas-width", `${this.width}px`);
313
- document.body.classList.toggle("right-canvas-open", this.isOpen);
348
+ document.body.classList.toggle("right-canvas-open", this.isOpen && !this.isMobileMode);
349
document.body.classList.toggle("right-canvas-overlay-mode", this.isOverlayMode);
350
document.body.classList.toggle("right-canvas-mobile-mode", this.isMobileMode);
351
},
@@ -347,6 +382,10 @@ const model = {
382
activeTitle() {
383
return this.currentSurface()?.title || "Canvas";
384
},
385
+
386
+ shouldRender() {
387
+ return !this.isMobileMode;
388
+ },
389
};
390
391
export const store = createStore("rightCanvas", model);
webui/components/canvas/right-canvas.css
+2
-28
@@ -299,35 +299,9 @@ body.right-canvas-overlay-mode .right-canvas-resize-handle {
299
display: none;
300
}
301
302
-body.right-canvas-mobile-mode .right-canvas {
303
- left: 0;
304
- width: 100vw !important;
305
- max-width: 100vw;
306
- min-width: 100vw;
307
- border-left: 0;
308
-}
309
-
310
-body.right-canvas-mobile-mode .right-canvas.is-closed {
311
- transform: translateX(100%);
312
-}
313
-
302
+body.right-canvas-mobile-mode .right-canvas,
303
body.right-canvas-mobile-mode .right-canvas-rail {
315
- display: flex;
316
-}
317
-
318
-body.right-canvas-mobile-mode .right-canvas.is-open .right-canvas-rail {
319
- display: none;
320
-}
321
-
322
-body.right-canvas-mobile-mode .right-canvas-header {
323
- min-height: 48px;
324
- padding: 7px 8px 0;
325
-}
326
-
327
-body.right-canvas-mobile-mode .right-canvas-tab {
328
- min-width: 42px;
329
- justify-content: center;
330
- padding: 0 9px;
304
+ display: none !important;
305
}
306
307
@media (max-width: 480px) {
webui/components/canvas/right-canvas.html
+1
@@ -17,6 +17,7 @@
17
'is-mobile': $store.rightCanvas.isMobileMode
18
}"
19
:style="$store.rightCanvas.widthStyle()"
20
+ x-show="$store.rightCanvas.shouldRender()"
21
x-init="$store.rightCanvas.init($el)"
22
x-effect="$store.rightCanvas.applyLayoutState()"
23
aria-label="Universal Canvas"