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("&", "&amp;")
57 + .replaceAll("<", "&lt;")
58 + .replaceAll(">", "&gt;")
59 + .replaceAll('"', "&quot;");
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"