diff --git a/plugins/_office/extensions/webui/get_tool_message_handler/document-artifact-handler.js b/plugins/_office/extensions/webui/get_tool_message_handler/document-artifact-handler.js
index 3ee0f595d..37ed3a599 100644
--- a/plugins/_office/extensions/webui/get_tool_message_handler/document-artifact-handler.js
+++ b/plugins/_office/extensions/webui/get_tool_message_handler/document-artifact-handler.js
@@ -10,9 +10,6 @@ import {
drawProcessStep,
} from "/js/messages.js";
-const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000;
-const autoOpenedDocuments = new Set();
-
export default async function registerDocumentArtifactHandler(extData) {
if (extData?.tool_name === "document_artifact") {
extData.handler = drawDocumentArtifactTool;
@@ -25,6 +22,7 @@ async function openOfficeCanvas(kvps = {}) {
await canvas?.open?.("office", {
path: kvps.path || "",
file_id: kvps.file_id || "",
+ refresh: true,
source: "tool",
});
}
@@ -50,50 +48,10 @@ function documentFromArgs(args, result = {}) {
title: kvps.title || kvps.basename || document.basename || "",
format: kvps.format || kvps.extension || document.extension || "",
version: kvps.version || document.version || "",
+ last_modified: kvps.last_modified || document.last_modified || "",
};
}
-function shouldAutoOpenDocument(args, document) {
- const kvps = args?.kvps || {};
- if (kvps.canvas_surface && kvps.canvas_surface !== "office") return false;
- if (!document?.path) return false;
- const action = String(kvps.action || "").trim().toLowerCase();
- if (["status", "version_history", "inspect", "read", "extract"].includes(action)) return false;
- return isFreshToolMessage(args?.timestamp);
-}
-
-function isFreshToolMessage(timestamp) {
- const value = Number(timestamp);
- if (!Number.isFinite(value) || value <= 0) return true;
- const messageMs = value > 10_000_000_000 ? value : value * 1000;
- return Math.abs(Date.now() - messageMs) <= AUTO_OPEN_WINDOW_MS;
-}
-
-function autoOpenOfficeCanvas(args) {
- const document = documentFromArgs(args, parseDocumentResult(args?.content));
- if (!shouldAutoOpenDocument(args, document)) return;
- const key = `${args.id || ""}:${document.file_id || ""}:${document.path || ""}:${document.version || ""}`;
- const persistedKey = `a0.office.autoOpened.${key}`;
- if (hasOpenedDocument(key, persistedKey)) return;
- requestAnimationFrame(() => {
- void openOfficeCanvas(document);
- });
-}
-
-function hasOpenedDocument(key, persistedKey) {
- if (autoOpenedDocuments.has(key)) return true;
- autoOpenedDocuments.add(key);
-
- try {
- if (sessionStorage.getItem(persistedKey)) return true;
- sessionStorage.setItem(persistedKey, "1");
- } catch {
- // Best-effort persistence; the in-memory guard still prevents repeat opens.
- }
-
- return false;
-}
-
function drawDocumentArtifactTool({
id,
type,
@@ -116,7 +74,7 @@ function drawDocumentArtifactTool({
].filter(Boolean);
const actionButtons = [
- createActionButton("description", "Office", () => openOfficeCanvas(document)),
+ createActionButton("desktop_windows", "Desktop", () => openOfficeCanvas(document)),
];
if (document?.path) {
@@ -145,6 +103,5 @@ function drawDocumentArtifactTool({
actionButtons: actionButtons.filter(Boolean),
log: args,
});
- autoOpenOfficeCanvas(args);
return result;
}
diff --git a/plugins/_office/extensions/webui/right_canvas_register_surfaces/register-office.js b/plugins/_office/extensions/webui/right_canvas_register_surfaces/register-office.js
index 746384b2f..65d436c8d 100644
--- a/plugins/_office/extensions/webui/right_canvas_register_surfaces/register-office.js
+++ b/plugins/_office/extensions/webui/right_canvas_register_surfaces/register-office.js
@@ -1,3 +1,7 @@
+import { store as officeStore } from "/plugins/_office/webui/office-store.js";
+
+void officeStore;
+
function waitForElement(selector, timeoutMs = 3000) {
const found = document.querySelector(selector);
if (found) return Promise.resolve(found);
@@ -20,8 +24,8 @@ function waitForElement(selector, timeoutMs = 3000) {
export default async function registerOfficeSurface(canvas) {
canvas.registerSurface({
id: "office",
- title: "Office",
- icon: "description",
+ title: "Desktop",
+ icon: "desktop_windows",
order: 20,
modalPath: "/plugins/_office/webui/main.html",
async open(payload = {}) {
@@ -30,9 +34,9 @@ export default async function registerOfficeSurface(canvas) {
await office?.onMount?.(panel, { mode: "canvas" });
await office?.onOpen?.(payload);
},
- async close() {
+ async close(payload = {}) {
const office = globalThis.Alpine?.store?.("office");
- office?.beforeHostHidden?.();
+ office?.beforeHostHidden?.({ unloadDesktop: payload?.reason === "mobile" });
},
});
}
diff --git a/plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js b/plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js
index 27c77ff02..af8ce63ee 100644
--- a/plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js
+++ b/plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js
@@ -1,145 +1,3 @@
-const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000;
-const autoOpenedDocuments = new Set();
-
-export default async function autoOpenDocumentResults(context) {
- if (!context?.results?.length || context.historyEmpty) return;
-
- for (const { args } of context.results) {
- const payload = getToolResultPayload(args);
- if (getToolName(payload) !== "document_artifact") continue;
-
- const document = getDocumentPayload(payload);
- if (!document?.path) continue;
- if (payload.canvas_surface && payload.canvas_surface !== "office") continue;
- if (isReadOnlyAction(payload)) continue;
- if (!isFresh(args?.timestamp, document.last_modified)) continue;
-
- const key = [
- args?.id || "",
- document.file_id || "",
- document.path,
- document.version || "",
- ].join(":");
- const persistedKey = `a0.office.autoOpened.${key}`;
- if (hasOpened(key, persistedKey)) continue;
-
- requestAnimationFrame(() => {
- void openOfficeCanvas(document);
- });
- }
-}
-
-function getToolResultPayload(args = {}) {
- const topLevelPayload = pickPayloadFields(args);
- const contentPayload = parseMaybeJson(args.content);
- const kvpsPayload = parseMaybeJson(args.kvps);
- return {
- ...topLevelPayload,
- ...(contentPayload || {}),
- ...(kvpsPayload || {}),
- };
-}
-
-function pickPayloadFields(args = {}) {
- const payload = {};
- for (const key of [
- "_tool_name",
- "tool_name",
- "tool_result",
- "canvas_surface",
- "action",
- "file_id",
- "path",
- "title",
- "basename",
- "format",
- "extension",
- "version",
- "last_modified",
- ]) {
- if (args[key] != null && args[key] !== "") payload[key] = args[key];
- }
- return payload;
-}
-
-function getToolName(payload = {}) {
- return String(payload._tool_name || payload.tool_name || "").trim();
-}
-
-function getDocumentPayload(payload = {}) {
- const result = parseMaybeJson(payload.tool_result) || {};
- const document = result.document && typeof result.document === "object"
- ? result.document
- : {};
-
- return {
- file_id: payload.file_id || document.file_id || "",
- path: payload.path || document.path || "",
- title: payload.title || payload.basename || document.basename || "",
- format: payload.format || payload.extension || document.extension || "",
- version: payload.version || document.version || "",
- last_modified: payload.last_modified || document.last_modified || "",
- };
-}
-
-function isReadOnlyAction(payload = {}) {
- const action = String(payload.action || "").trim().toLowerCase();
- return ["status", "version_history", "inspect", "read", "extract"].includes(action);
-}
-
-function parseMaybeJson(value) {
- if (!value) return null;
- if (typeof value === "object") return value;
- if (typeof value !== "string") return null;
-
- const trimmed = value.trim();
- if (!trimmed.startsWith("{")) return null;
- try {
- const parsed = JSON.parse(trimmed);
- return parsed && typeof parsed === "object" ? parsed : null;
- } catch {
- return null;
- }
-}
-
-function isFresh(timestamp, fallbackTimestamp) {
- const messageMs = toMs(timestamp) || toMs(fallbackTimestamp);
- if (!messageMs) return true;
- return Math.abs(Date.now() - messageMs) <= AUTO_OPEN_WINDOW_MS;
-}
-
-function toMs(value) {
- if (value == null || value === "") return 0;
-
- const numeric = Number(value);
- if (Number.isFinite(numeric) && numeric > 0) {
- return numeric > 10_000_000_000 ? numeric : numeric * 1000;
- }
-
- const parsed = Date.parse(String(value));
- return Number.isFinite(parsed) ? parsed : 0;
-}
-
-function hasOpened(key, persistedKey) {
- if (autoOpenedDocuments.has(key)) return true;
- autoOpenedDocuments.add(key);
-
- try {
- if (sessionStorage.getItem(persistedKey)) return true;
- sessionStorage.setItem(persistedKey, "1");
- } catch {
- // Best-effort persistence; the in-memory guard still prevents repeat opens.
- }
-
- return false;
-}
-
-async function openOfficeCanvas(document) {
- const canvas = globalThis.Alpine?.store?.("rightCanvas")
- || (await import("/components/canvas/right-canvas-store.js")).store;
- await canvas?.open?.("office", {
- path: document.path || "",
- file_id: document.file_id || "",
- source: "tool-result",
- });
+export default async function autoOpenDocumentResults(_context) {
+ return;
}
diff --git a/plugins/_office/webui/main.html b/plugins/_office/webui/main.html
index d9f9701d1..8248c6d03 100644
--- a/plugins/_office/webui/main.html
+++ b/plugins/_office/webui/main.html
@@ -2,11 +2,14 @@
class="office-modal modal-no-backdrop"
data-canvas-surface="office"
data-canvas-modal-path="/plugins/_office/webui/main.html"
- data-canvas-dock-title="Open Office in canvas"
+ data-canvas-dock-title="Open Desktop in canvas"
data-canvas-dock-icon="dock_to_right"
>
- Office
+ Desktop
+
diff --git a/plugins/_office/webui/office-panel.html b/plugins/_office/webui/office-panel.html
index 5d98a9e17..5bca54b13 100644
--- a/plugins/_office/webui/office-panel.html
+++ b/plugins/_office/webui/office-panel.html
@@ -9,131 +9,153 @@
-
+
-
-
-
+
-
+
-
-
-
-
-
-
Agent Zero Office
-
-
-
-
-
-
-
-
- restart_alt
- Retry
-
-
- sync
- Refresh
-
-
-
-
-
-
- Create
+
+
+ New
-
+
article
- Document
+ Markdown
+ .md
-
+
+ description
+ DOCX
+ .docx
+
+
table_chart
- Spreadsheet
+ Sheet
+ .xlsx
-
+
co_present
- Presentation
+ Deck
+ .pptx
-
- Open files
+
+ Open
-
+
Open
@@ -141,34 +163,18 @@
-
-
-
-
+
-
-
-
+
-
-
-
+
@@ -178,44 +184,28 @@
-
- Recent files
+
+ Recent
-
+
-
-
-
-
+
-
-
-
+
-
-
-
+
@@ -226,12 +216,92 @@
-
@@ -249,6 +319,7 @@
min-width: 0;
min-height: 0;
background: var(--color-background);
+ color: var(--color-text);
}
.office-panel {
@@ -257,216 +328,124 @@
.modal-inner.office-modal {
box-sizing: border-box;
- container-type: inline-size;
- width: min(82vw, 1180px);
- height: min(88vh, 900px);
- min-width: min(340px, calc(100vw - 16px));
- min-height: min(500px, calc(100vh - 16px));
- max-width: calc(100vw - 16px);
- max-height: calc(100vh - 16px);
- resize: both;
- border: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent);
- border-radius: 7px;
- box-shadow: 0 18px 48px rgba(0, 0, 0, 0.32);
- background: color-mix(in srgb, var(--color-background) 94%, #000 6%);
+ width: min(1120px, calc(100vw - 32px));
+ height: min(820px, calc(100vh - 32px));
+ min-width: min(720px, calc(100vw - 16px));
+ min-height: min(520px, calc(100vh - 16px));
+ max-width: none;
+ max-height: none;
+ resize: none;
+ overflow: hidden;
+ will-change: width, height, left, top;
}
- .modal.modal-floating {
- pointer-events: none;
- }
-
- .modal.modal-floating .modal-inner {
- pointer-events: auto;
- }
-
- .modal-inner.office-modal .modal-header {
- min-height: 34px;
- padding: 0.35rem 0.75rem 0.35rem 1rem;
- cursor: move;
+ .modal-inner.office-modal.is-resizing,
+ .modal-inner.office-modal.is-dragging {
user-select: none;
- background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
- border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
}
-
- .modal-inner.office-modal .modal-close {
- font-size: 1.35rem;
- line-height: 1;
+
+ .modal-inner.office-modal.is-focus-mode {
+ border-radius: 6px;
}
.modal-inner.office-modal .modal-scroll {
display: flex;
- flex-direction: column;
flex: 1 1 auto;
min-height: 0;
+ max-height: none;
overflow: hidden;
padding: 0;
}
- .modal-inner.office-modal .modal-bd.office-modal-body {
- box-sizing: border-box;
- display: flex;
- flex-direction: column;
- flex: 1 1 auto;
- width: 100%;
- height: 100%;
- min-height: 0;
- padding: 0;
+ .modal-inner.office-modal .modal-header {
+ grid-template-columns: minmax(0, 1fr) auto auto auto;
}
+ .office-modal-input-shield {
+ position: absolute;
+ inset: 42px 0 0 0;
+ z-index: 4;
+ display: none;
+ background: transparent;
+ }
+
+ .office-modal-resizer {
+ position: absolute;
+ z-index: 5;
+ display: block;
+ touch-action: none;
+ }
+
+ .office-modal-resizer.is-right {
+ top: 42px;
+ right: -4px;
+ bottom: 12px;
+ width: 10px;
+ cursor: ew-resize;
+ }
+
+ .office-modal-resizer.is-bottom {
+ right: 12px;
+ bottom: -4px;
+ left: 0;
+ height: 10px;
+ cursor: ns-resize;
+ }
+
+ .office-modal-resizer.is-corner {
+ right: 0;
+ bottom: 0;
+ width: 22px;
+ height: 22px;
+ cursor: nwse-resize;
+ }
+
+ .office-modal-resizer.is-corner::after {
+ content: "";
+ position: absolute;
+ right: 6px;
+ bottom: 6px;
+ width: 9px;
+ height: 9px;
+ border-right: 2px solid color-mix(in srgb, var(--color-text) 42%, transparent);
+ border-bottom: 2px solid color-mix(in srgb, var(--color-text) 42%, transparent);
+ border-radius: 1px;
+ }
+
+ .modal-inner.office-modal.is-focus-mode .office-modal-resizer {
+ display: none;
+ }
+
+ .modal-inner.office-modal .modal-bd.office-modal-body,
.modal-inner.office-modal .modal-bd.office-modal-body > x-component,
- .modal-inner.office-modal .modal-bd.office-modal-body > div[x-data] {
+ .modal-inner.office-modal .modal-bd.office-modal-body > x-component > .office-panel {
display: flex;
flex: 1 1 auto;
- width: 100%;
- height: 100%;
min-height: 0;
- }
-
- .modal-inner.office-modal .modal-bd.office-modal-body > x-component > .office-panel {
- flex: 1 1 auto;
height: 100%;
- min-height: 0;
+ padding: 0;
}
.office-toolbar {
display: flex;
align-items: center;
- gap: 6px;
- min-height: 44px;
- padding: 7px 9px;
- border-bottom: 1px solid color-mix(in srgb, var(--color-border) 66%, transparent);
- background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
- overflow-x: auto;
- }
-
- .office-tabs {
- --office-tab-height: 34px;
- --office-tab-close-size: 27px;
- display: flex;
- align-items: end;
- gap: 4px;
- min-height: 39px;
- min-width: 0;
- padding: 5px 9px 0;
- border-bottom: 1px solid color-mix(in srgb, var(--color-border) 58%, transparent);
- background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
+ flex-wrap: nowrap;
+ gap: 10px;
+ min-height: 58px;
+ padding: 9px 12px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
+ border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 20%);
+ background: color-mix(in srgb, var(--color-background), var(--color-panel) 48%);
}
- .office-tabs::-webkit-scrollbar {
- height: 4px;
- }
-
- .office-tabs::-webkit-scrollbar-track {
- background: transparent;
- }
-
- .office-tabs::-webkit-scrollbar-thumb {
- background: color-mix(in srgb, var(--color-border) 76%, transparent);
- border-radius: 999px;
- }
-
- .office-tab-shell {
- flex: 0 1 220px;
- position: relative;
- display: grid;
- grid-template-columns: minmax(0, 1fr) var(--office-tab-close-size);
+ .office-tool-group {
+ display: flex;
align-items: center;
- gap: 3px;
- min-width: 132px;
- max-width: 260px;
- height: var(--office-tab-height);
- padding: 0 6px 0 10px;
- border: 1px solid transparent;
- border-radius: 7px 7px 0 0;
- opacity: 0.72;
- transition: border-color 0.16s ease, opacity 0.16s ease, background-color 0.16s ease;
- }
-
- .office-tab-shell:hover,
- .office-tab-shell:focus-within {
- opacity: 0.94;
- border-color: color-mix(in srgb, var(--color-border) 78%, transparent);
- }
-
- .office-tab-shell.is-active {
- z-index: 2;
- margin-bottom: -1px;
- opacity: 1;
- border-color: color-mix(in srgb, var(--color-border) 68%, transparent);
- background: color-mix(in srgb, var(--color-panel) 72%, transparent);
- }
-
- .office-tab,
- .office-tab-close {
- appearance: none;
- border: 0;
- background: transparent;
- color: inherit;
- font: inherit;
- cursor: pointer;
- }
-
- .office-tab {
- display: inline-flex;
- align-items: center;
- justify-content: flex-start;
- gap: 8px;
- min-width: 0;
- width: 100%;
- height: 100%;
- padding: 0;
- text-align: left;
- }
-
- .office-tab-icon {
flex: 0 0 auto;
- color: color-mix(in srgb, var(--color-text) 72%, var(--color-primary) 28%);
- font-size: 18px;
- line-height: 1;
- }
-
- .office-tab-title {
+ gap: 6px;
min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- font-size: 0.84rem;
- font-weight: 650;
- }
-
- .office-tab-close {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: var(--office-tab-close-size);
- min-width: var(--office-tab-close-size);
- height: var(--office-tab-close-size);
- min-height: var(--office-tab-close-size);
- padding: 0;
- border-radius: 6px;
- color: color-mix(in srgb, var(--color-text) 52%, var(--color-primary) 48%);
- opacity: 0.74;
- }
-
- .office-tab-close:hover,
- .office-tab-close.confirming {
- opacity: 1;
- background: color-mix(in srgb, var(--color-background-hover) 70%, transparent);
- color: var(--color-text);
- }
-
- .office-tab-close:focus-visible,
- .office-tab:focus-visible {
- outline: 1px solid color-mix(in srgb, var(--color-primary) 70%, transparent);
- outline-offset: 1px;
- }
-
- .office-tab-close .material-symbols-outlined {
- font-size: 15px;
- line-height: 1;
}
.office-toolbar-spacer {
@@ -474,468 +453,530 @@
min-width: 8px;
}
- .office-button,
+ .office-toolbar-divider {
+ width: 1px;
+ height: 24px;
+ margin: 0 4px;
+ background: color-mix(in srgb, var(--color-border), transparent 15%);
+ }
+
.office-icon-button,
- .office-health-pill,
- .office-create-tile,
- .office-document-card {
+ .office-tab,
+ .office-tab-close {
+ border: 1px solid color-mix(in srgb, var(--color-border), transparent 12%);
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--color-panel), var(--color-background) 16%);
+ color: inherit;
+ transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
+ }
+
+ .office-icon-button {
+ display: inline-grid;
+ place-items: center;
+ width: 40px;
+ height: 40px;
+ min-width: 40px;
+ padding: 0;
+ }
+
+ .office-command-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
- border: 1px solid color-mix(in srgb, var(--color-border) 64%, transparent);
- border-radius: 7px;
- background: color-mix(in srgb, var(--color-panel) 80%, transparent);
- color: var(--color-text);
- font: inherit;
- cursor: pointer;
- }
-
- .office-button {
- min-height: 32px;
- padding: 5px 9px;
- font-size: 0.8rem;
+ width: auto;
+ max-width: 152px;
+ padding: 0 11px;
white-space: nowrap;
}
- .office-icon-button {
- width: 32px;
- height: 32px;
- min-width: 32px;
- padding: 0;
+ .office-command-button .office-button-label {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-size: 12px;
+ font-weight: 700;
+ line-height: 1;
}
- .office-health-pill {
- min-height: 28px;
- padding: 4px 8px;
- cursor: default;
- font-size: 0.75rem;
- text-transform: capitalize;
- white-space: nowrap;
- color: var(--color-text-muted);
- background: color-mix(in srgb, var(--color-panel) 64%, transparent);
+ .office-icon-button.is-primary {
+ border-color: color-mix(in srgb, #2c7be5, var(--color-border) 20%);
+ background: color-mix(in srgb, #2c7be5, var(--color-panel) 82%);
}
- .office-health-pill.is-healthy {
- width: 28px;
- min-width: 28px;
- padding: 0;
- gap: 0;
+ .office-icon-button.is-active {
+ border-color: color-mix(in srgb, #2ca58d, var(--color-border) 24%);
+ background: color-mix(in srgb, #2ca58d, var(--color-panel) 84%);
}
- .office-health-dot {
- width: 7px;
- height: 7px;
- border-radius: 999px;
- background: color-mix(in srgb, var(--color-text-muted) 70%, transparent);
- }
-
- .office-health-pill.is-healthy .office-health-dot {
- background: #31c48d;
- }
-
- .office-health-pill.is-installing .office-health-dot {
- background: #f6ad55;
- }
-
- .office-health-pill.is-degraded .office-health-dot,
- .office-health-pill.is-failed .office-health-dot {
- background: #f05252;
- }
-
- .office-button:hover:not(:disabled),
.office-icon-button:hover:not(:disabled),
- .office-create-tile:hover,
- .office-document-card:hover {
- background: color-mix(in srgb, var(--color-background-hover) 70%, transparent);
- border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
+ .office-tab:hover,
+ .office-tab-close:hover {
+ border-color: color-mix(in srgb, #2c7be5, var(--color-border) 45%);
+ background: color-mix(in srgb, var(--color-panel), #2c7be5 8%);
}
- .office-button:disabled,
.office-icon-button:disabled {
- cursor: not-allowed;
+ cursor: default;
opacity: 0.42;
}
- .office-button .material-symbols-outlined,
- .office-icon-button .material-symbols-outlined {
- font-size: 18px;
+ .office-icon-button .material-symbols-outlined,
+ .office-tab-icon,
+ .office-create-tile .material-symbols-outlined {
+ font-size: 21px;
+ line-height: 1;
}
- .office-status-line {
+ .office-zoom {
+ min-width: 44px;
+ text-align: center;
+ font-size: 12px;
+ color: var(--color-text-secondary);
+ font-variant-numeric: tabular-nums;
+ }
+
+ .office-tabs {
+ display: flex;
+ gap: 6px;
+ min-height: 42px;
+ padding: 7px 10px;
+ overflow-x: auto;
+ border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 22%);
+ background: color-mix(in srgb, var(--color-panel), var(--color-background) 28%);
+ }
+
+ .office-tab-shell {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 28px;
+ align-items: center;
+ min-width: 150px;
+ max-width: 240px;
+ }
+
+ .office-tab-shell.is-system {
+ grid-template-columns: minmax(0, 1fr);
+ min-width: 172px;
+ }
+
+ .office-tab,
+ .office-tab-close {
+ height: 28px;
+ min-height: 28px;
+ border-radius: 7px;
+ }
+
+ .office-tab {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ padding: 0 8px;
+ text-align: left;
+ }
+
+ .office-tab-shell.is-system .office-tab {
+ border-radius: 7px;
+ }
+
+ .office-tab-close {
+ display: grid;
+ place-items: center;
+ border-left: 0;
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ padding: 0;
+ }
+
+ .office-tab-close .material-symbols-outlined {
+ font-size: 17px;
+ }
+
+ .office-tab-shell.is-active .office-tab,
+ .office-tab-shell.is-active .office-tab-close {
+ border-color: color-mix(in srgb, #2c7be5, var(--color-border) 36%);
+ background: color-mix(in srgb, #2c7be5, var(--color-panel) 88%);
+ }
+
+ .office-tab-shell.is-dirty .office-tab-title::after {
+ content: " *";
+ color: #2ca58d;
+ }
+
+ .office-tab-title {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 12px;
+ line-height: 1;
+ }
+
+ .office-state-line {
display: flex;
align-items: center;
gap: 8px;
- min-height: 32px;
- padding: 5px 10px;
- border-bottom: 1px solid color-mix(in srgb, var(--color-border) 44%, transparent);
- font-size: 0.82rem;
- color: var(--color-text);
+ min-height: 34px;
+ padding: 6px 12px;
+ border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 28%);
+ color: var(--color-text-secondary);
+ font-size: 12px;
}
.office-body {
position: relative;
display: flex;
flex: 1 1 auto;
- min-width: 0;
min-height: 0;
overflow: hidden;
+ background:
+ linear-gradient(90deg, rgba(44, 123, 229, 0.05), transparent 38%),
+ linear-gradient(180deg, rgba(44, 165, 141, 0.04), transparent 46%),
+ #eef2f7;
+ color: #172033;
}
.office-start {
- display: flex;
flex: 1 1 auto;
min-width: 0;
- min-height: 0;
- flex-direction: column;
- gap: 22px;
- padding: 18px;
overflow: auto;
- }
-
- .office-bootstrap {
- display: flex;
- flex: 1 1 auto;
- min-width: 0;
- min-height: 0;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 16px;
- padding: clamp(24px, 7cqi, 56px);
- overflow: auto;
- text-align: center;
- }
-
- .office-setup-mark {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 58px;
- height: 58px;
- border: 1px solid color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
- border-radius: 7px;
- color: color-mix(in srgb, var(--color-primary) 70%, var(--color-text));
- background: color-mix(in srgb, var(--color-panel) 78%, transparent);
- }
-
- .office-setup-mark.is-busy {
- border-color: color-mix(in srgb, var(--color-primary) 42%, var(--color-border));
- }
-
- .office-setup-mark.is-alert {
- border-color: color-mix(in srgb, #f05252 48%, var(--color-border));
- color: #f05252;
- }
-
- .office-setup-mark .material-symbols-outlined {
- font-size: 30px;
- line-height: 1;
- }
-
- .office-setup-copy {
- display: flex;
- align-items: center;
- min-width: 0;
- flex-direction: column;
- gap: 6px;
- max-width: 460px;
- line-height: 1.35;
- }
-
- .office-setup-copy > span {
- color: var(--color-text-muted);
- font-size: 0.74rem;
- font-weight: 700;
- letter-spacing: 0;
- text-transform: uppercase;
- }
-
- .office-setup-copy > strong {
- color: var(--color-text);
- font-size: clamp(1.05rem, 4cqi, 1.35rem);
- font-weight: 760;
- }
-
- .office-setup-copy > p {
- margin: 0;
- color: var(--color-text-muted);
- font-size: 0.9rem;
- }
-
- .office-setup-progress {
- position: relative;
- width: min(260px, 72cqi);
- height: 5px;
- overflow: hidden;
- border-radius: 999px;
- background: color-mix(in srgb, var(--color-border) 45%, transparent);
- }
-
- .office-setup-progress > span {
- position: absolute;
- inset: 0 auto 0 0;
- width: 42%;
- border-radius: inherit;
- background: color-mix(in srgb, var(--color-primary) 72%, var(--color-text) 28%);
- animation: office-setup-progress 1.45s ease-in-out infinite;
- }
-
- .office-setup-progress.is-paused > span {
- width: 100%;
- opacity: 0.42;
- animation: none;
- }
-
- .office-bootstrap-actions,
- .office-template-grid {
- display: flex;
- justify-content: center;
- flex-wrap: wrap;
- gap: 8px;
+ padding: 22px;
}
.office-dashboard-section {
- display: flex;
- flex-direction: column;
- gap: 10px;
- width: 100%;
- max-width: 1180px;
+ margin: 0 0 22px;
}
.office-dashboard-heading {
- color: var(--color-text-muted);
- font-size: 0.76rem;
+ margin: 0 0 9px;
+ color: #536274;
+ font-size: 12px;
font-weight: 700;
- letter-spacing: 0;
text-transform: uppercase;
}
- .office-template-grid {
- justify-content: flex-start;
- }
-
+ .office-template-grid,
.office-card-grid {
display: grid;
- grid-template-columns: repeat(auto-fill, minmax(min(190px, 100%), 1fr));
+ grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
gap: 10px;
- width: 100%;
}
.office-create-tile {
- min-width: 132px;
- min-height: 88px;
- flex-direction: column;
- padding: 12px;
- font-weight: 650;
+ display: grid;
+ grid-template-rows: 34px auto auto;
+ align-items: center;
+ min-height: 122px;
+ padding: 14px;
+ border: 1px solid #d9dee7;
+ border-radius: 8px;
+ background: #ffffff;
+ color: #172033;
+ box-shadow: 0 12px 30px rgba(35, 48, 68, 0.08);
+ text-align: left;
+ transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
}
- .office-create-tile .material-symbols-outlined {
- font-size: 28px;
+ .office-create-tile:hover,
+ .office-document-card:hover {
+ border-color: #8db5ef;
+ box-shadow: 0 18px 42px rgba(35, 48, 68, 0.13);
+ transform: translateY(-1px);
}
+ .office-create-tile strong,
+ .office-card-title {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 13px;
+ }
+
+ .office-create-tile small,
+ .office-document-card small {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: #536274;
+ font-size: 11px;
+ }
+
+ .office-create-tile.is-markdown .material-symbols-outlined { color: #2ca58d; }
+ .office-create-tile.is-docx .material-symbols-outlined { color: #2c7be5; }
+ .office-create-tile.is-sheet .material-symbols-outlined { color: #8f6f19; }
+ .office-create-tile.is-deck .material-symbols-outlined { color: #b84a62; }
+
.office-document-card {
position: relative;
display: grid;
- grid-template-rows: auto auto auto;
- align-content: start;
- justify-content: stretch;
- gap: 8px;
- min-width: 0;
- min-height: 196px;
+ grid-template-rows: 118px 18px 16px;
+ gap: 7px;
+ min-height: 172px;
padding: 10px;
+ border: 1px solid #d9dee7;
+ border-radius: 8px;
+ background: #ffffff;
+ color: #172033;
+ box-shadow: 0 12px 30px rgba(35, 48, 68, 0.08);
text-align: left;
- overflow: hidden;
- }
-
- .office-document-card.is-open {
- border-color: color-mix(in srgb, var(--color-primary) 36%, var(--color-border));
+ transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
}
.office-card-badge {
position: absolute;
- top: 8px;
- right: 8px;
- z-index: 2;
- max-width: calc(100% - 16px);
- overflow: hidden;
- padding: 2px 6px;
- border: 1px solid color-mix(in srgb, var(--color-primary) 42%, transparent);
+ top: 9px;
+ right: 9px;
+ z-index: 1;
border-radius: 999px;
- background: color-mix(in srgb, var(--color-background) 82%, transparent);
- color: var(--color-text);
- font-size: 0.68rem;
+ padding: 2px 7px;
+ background: color-mix(in srgb, #2ca58d, var(--color-panel) 20%);
+ color: white;
+ font-size: 10px;
font-weight: 700;
- line-height: 1.2;
- text-overflow: ellipsis;
- white-space: nowrap;
}
.office-card-preview {
- position: relative;
- display: grid;
- align-items: stretch;
- width: 100%;
- aspect-ratio: 16 / 10;
- min-height: 112px;
+ min-width: 0;
+ min-height: 0;
overflow: hidden;
- border: 1px solid color-mix(in srgb, var(--color-border) 58%, transparent);
+ border: 1px solid #d9dee7;
border-radius: 6px;
- background: color-mix(in srgb, var(--color-background) 76%, #fff 4%);
+ background: #f8fafc;
+ color: #172033;
+ }
+
+ .office-card-preview.is-large {
+ width: min(720px, 100%);
+ min-height: 340px;
+ border-color: #d2d8e3;
+ background: #ffffff;
+ box-shadow: 0 18px 44px rgba(35, 48, 68, 0.12);
}
.office-page-preview,
.office-sheet-preview,
.office-slide-preview,
.office-preview-fallback {
- min-width: 0;
- min-height: 0;
- }
-
- .office-page-preview {
display: flex;
flex-direction: column;
gap: 5px;
- padding: 12px 13px;
- background:
- linear-gradient(to bottom, transparent 0, transparent 21px, color-mix(in srgb, var(--color-border) 30%, transparent) 22px),
- color-mix(in srgb, var(--color-panel) 72%, transparent);
- background-size: 100% 22px;
- color: var(--color-text);
+ height: 100%;
+ padding: 10px;
+ font-size: 11px;
+ line-height: 1.35;
}
.office-page-preview span,
+ .office-sheet-row span,
.office-slide-line span,
- .office-slide-line strong,
- .office-sheet-row span {
+ .office-slide-line strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
- .office-page-preview span {
- font-size: 0.7rem;
- line-height: 1.25;
- }
-
- .office-sheet-preview {
- min-width: 0;
- padding: 8px;
- background: color-mix(in srgb, var(--color-panel) 74%, transparent);
- }
-
.office-sheet-row {
display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- min-height: 20px;
- }
-
- .office-sheet-row + .office-sheet-row {
- border-top: 1px solid color-mix(in srgb, var(--color-border) 34%, transparent);
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 5px;
}
.office-sheet-row span {
- padding: 4px 5px;
- border-right: 1px solid color-mix(in srgb, var(--color-border) 34%, transparent);
- color: var(--color-text-muted);
- font-size: 0.66rem;
- line-height: 1.1;
- }
-
- .office-sheet-row span:last-child {
- border-right: 0;
- }
-
- .office-slide-preview {
- display: flex;
- flex-direction: column;
- justify-content: center;
- gap: 10px;
- padding: 14px;
- background:
- linear-gradient(135deg, color-mix(in srgb, var(--color-panel) 80%, transparent), color-mix(in srgb, var(--color-background) 84%, var(--color-primary) 10%));
- }
-
- .office-slide-line {
- display: flex;
- min-width: 0;
- flex-direction: column;
- gap: 3px;
- }
-
- .office-slide-line strong {
- color: var(--color-text);
- font-size: 0.78rem;
- font-weight: 760;
- line-height: 1.15;
- }
-
- .office-slide-line span {
- color: var(--color-text-muted);
- font-size: 0.68rem;
- line-height: 1.15;
+ border-bottom: 1px solid #d9dee7;
+ padding-bottom: 2px;
}
.office-preview-fallback {
- display: flex;
align-items: center;
justify-content: center;
- color: color-mix(in srgb, var(--color-primary) 64%, var(--color-text));
- background: color-mix(in srgb, var(--color-panel) 76%, transparent);
+ color: #64748b;
}
.office-preview-fallback .material-symbols-outlined {
font-size: 36px;
}
- .office-card-title {
- display: block;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- color: var(--color-text);
- font-size: 0.86rem;
- font-weight: 720;
- line-height: 1.2;
- }
-
- .office-document-card small {
- display: block;
- min-width: 0;
- overflow: hidden;
- color: var(--color-text-muted);
- font-size: 0.7rem;
- line-height: 1.2;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .office-frame-wrap {
+ .office-editor-wrap {
display: flex;
- position: absolute;
- inset: 0;
flex: 1 1 auto;
- width: 100%;
- height: 100%;
+ flex-direction: column;
min-width: 0;
min-height: 0;
- background: #fff;
}
- .office-frame-wrap iframe {
+ .office-editor-scroll {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow: auto;
+ padding: 30px 24px;
+ }
+
+ .office-editor-scroll.is-desktop {
+ display: flex;
+ overflow: hidden;
+ padding: 0;
+ background: #1f2329;
+ }
+
+ .office-desktop-wrap {
+ display: flex;
flex: 1 1 auto;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
+ aspect-ratio: auto;
+ background: #1f2329;
+ }
+
+ .office-desktop-frame {
+ flex: 1 1 auto;
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+ aspect-ratio: auto;
border: 0;
- background: #fff;
+ background: #20242a;
+ }
+
+ .office-rich-editor,
+ .office-source-editor,
+ .office-docx-stage,
+ .office-preview-editor {
+ transform: scale(var(--office-zoom));
+ transform-origin: top center;
+ margin: 0 auto;
+ }
+
+ .office-rich-editor {
+ box-sizing: border-box;
+ width: min(760px, 100%);
+ min-height: min(980px, calc(100vh - 170px));
+ padding: 54px 58px;
+ border: 1px solid #d2d8e3;
+ border-radius: 8px;
+ outline: none;
+ background: #ffffff;
+ box-shadow: 0 18px 44px rgba(35, 48, 68, 0.16);
+ color: #1f2937;
+ font-size: 15px;
+ line-height: 1.7;
+ }
+
+ .office-rich-editor:focus,
+ .office-source-editor:focus,
+ .office-docx-pages:focus-within .office-docx-page:first-child,
+ .office-docx-pages:focus .office-docx-page:first-child {
+ border-color: #8db5ef;
+ box-shadow:
+ 0 18px 44px rgba(35, 48, 68, 0.16),
+ 0 0 0 3px rgba(44, 123, 229, 0.16);
+ }
+
+ .office-rich-editor h1,
+ .office-rich-editor h2,
+ .office-rich-editor h3 {
+ line-height: 1.25;
+ margin: 0 0 0.65em;
+ }
+
+ .office-rich-editor p,
+ .office-rich-editor ul,
+ .office-rich-editor table {
+ margin: 0 0 1em;
+ }
+
+ .office-rich-editor table {
+ width: 100%;
+ border-collapse: collapse;
+ }
+
+ .office-rich-editor td,
+ .office-rich-editor th {
+ border: 1px solid #d9dee7;
+ padding: 6px 8px;
+ }
+
+ .office-source-editor {
+ box-sizing: border-box;
+ width: min(920px, 100%);
+ min-height: min(980px, calc(100vh - 170px));
+ padding: 22px;
+ border: 1px solid #d2d8e3;
+ border-radius: 8px;
+ outline: none;
+ background: #ffffff;
+ color: #172033;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 13px;
+ line-height: 1.65;
+ resize: none;
+ }
+
+ .office-docx-stage {
+ width: min(860px, 100%);
+ outline: none;
+ }
+
+ .office-docx-pages {
+ outline: none;
+ }
+
+ .office-docx-pages.is-native {
+ display: grid;
+ gap: 20px;
+ justify-items: center;
+ }
+
+ .office-docx-page {
+ box-sizing: border-box;
+ width: min(760px, 100%);
+ min-height: 980px;
+ margin: 0 auto 20px;
+ padding: 70px 74px;
+ border: 1px solid #d2d8e3;
+ border-radius: 6px;
+ background: #ffffff;
+ box-shadow: 0 18px 44px rgba(35, 48, 68, 0.16);
+ color: #1f2937;
+ font-family: "Liberation Serif", "Times New Roman", serif;
+ font-size: 16px;
+ line-height: 1.55;
+ }
+
+ .office-docx-page.is-native-tile {
+ width: auto;
+ min-height: 0;
+ padding: 0;
+ overflow: hidden;
+ line-height: 0;
+ }
+
+ .office-docx-page.is-native-tile img {
+ display: block;
+ width: min(920px, 100%);
+ height: auto;
+ user-select: none;
+ }
+
+ .office-docx-page p {
+ margin: 0 0 0.85em;
+ }
+
+ .office-preview-editor {
+ display: grid;
+ place-items: start center;
+ width: min(860px, 100%);
+ min-height: 420px;
+ padding: 18px;
}
.office-panel .spinning {
- display: inline-block;
animation: office-spin 0.8s linear infinite;
}
@@ -943,48 +984,26 @@
to { transform: rotate(360deg); }
}
- @keyframes office-setup-progress {
- 0% { transform: translateX(-110%); }
- 55% { transform: translateX(85%); }
- 100% { transform: translateX(250%); }
- }
+ @container (max-width: 680px) {
+ .office-toolbar {
+ gap: 6px;
+ padding-inline: 8px;
+ }
- @media (prefers-reduced-motion: reduce) {
- .office-panel .spinning,
- .office-setup-progress > span {
- animation: none;
+ .office-command-button {
+ max-width: 132px;
+ padding-inline: 9px;
}
- }
- @media (max-width: 520px) {
- .office-button span:last-child {
- display: none;
+ .office-template-grid,
+ .office-card-grid {
+ grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
}
- .office-health-pill span:last-child {
- display: none;
- }
- .office-tab-shell {
- flex-basis: 152px;
- min-width: 116px;
- }
- .office-create-tile {
- min-width: 104px;
- }
- }
- @container (max-width: 560px) {
- .office-button span:last-child {
- display: none;
- }
- .office-health-pill span:last-child {
- display: none;
- }
- .office-tab-shell {
- flex-basis: 152px;
- min-width: 116px;
- }
- .office-create-tile {
- min-width: 104px;
+ .office-rich-editor,
+ .office-docx-page {
+ min-height: 720px;
+ padding: 34px 28px;
}
}
diff --git a/plugins/_office/webui/office-store.js b/plugins/_office/webui/office-store.js
index 6ea33e24b..054df7c61 100644
--- a/plugins/_office/webui/office-store.js
+++ b/plugins/_office/webui/office-store.js
@@ -1,53 +1,25 @@
import { createStore } from "/js/AlpineStore.js";
import { callJsonApi } from "/js/api.js";
+import { getNamespacedClient } from "/js/websocket.js";
-const FRAME_NAME_PREFIX = "a0-office-frame";
-const COLLABORA_STATE_VERSION = "2026-04-26.1";
-const COLLABORA_STATE_MARKER = "a0.office.collaboraStateVersion";
-const SERVICE_WORKER_CLEANUP_MARKER = "a0.office.serviceWorkerCleanupReloaded";
-const SETUP_POLL_INTERVAL_MS = 4000;
+const officeSocket = getNamespacedClient("/ws");
+officeSocket.addHandlers(["ws_webui"]);
-function makeFrameName() {
- const id = globalThis.crypto?.randomUUID?.()
- || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
- return `${FRAME_NAME_PREFIX}-${id}`;
-}
+const SAVE_MESSAGE_MS = 1800;
+const INPUT_PUSH_DELAY_MS = 650;
+const DESKTOP_HEARTBEAT_MS = 3500;
+const DESKTOP_RESIZE_DELAY_MS = 80;
+const XPRA_DESKTOP_PRIME_INTERVAL_MS = 220;
+const XPRA_DESKTOP_PRIME_ATTEMPTS = 120;
+const SYSTEM_DESKTOP_FILE_ID = "system-desktop";
+const MAX_HISTORY = 80;
-function parseMessage(data) {
- if (typeof data === "string") {
- try {
- return JSON.parse(data);
- } catch {
- return { MessageId: data };
- }
+function currentContextId() {
+ try {
+ return globalThis.getContext?.() || "";
+ } catch {
+ return "";
}
- return data && typeof data === "object" ? data : {};
-}
-
-function nextAnimationFrame() {
- return new Promise((resolve) => {
- const schedule = globalThis.requestAnimationFrame || ((callback) => globalThis.setTimeout(callback, 16));
- schedule(() => resolve());
- });
-}
-
-function normalizeTabId(value) {
- return String(value || "").trim();
-}
-
-function makeTabId(session) {
- return normalizeTabId(session?.session_id)
- || normalizeTabId(session?.file_id)
- || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
-}
-
-function sameDocument(left = {}, right = {}) {
- const leftFileId = normalizeTabId(left.file_id);
- const rightFileId = normalizeTabId(right.file_id);
- if (leftFileId && rightFileId) return leftFileId === rightFileId;
- const leftPath = String(left.path || "").trim();
- const rightPath = String(right.path || "").trim();
- return Boolean(leftPath && rightPath && leftPath === rightPath);
}
function formatBytes(value) {
@@ -64,6 +36,274 @@ function formatBytes(value) {
return `${amount.toFixed(digits)} ${units[index]}`;
}
+function basename(path = "") {
+ const value = String(path || "").split("?")[0].split("#")[0];
+ return value.split("/").filter(Boolean).pop() || "Untitled";
+}
+
+function extensionOf(path = "") {
+ const name = basename(path).toLowerCase();
+ const index = name.lastIndexOf(".");
+ return index >= 0 ? name.slice(index + 1) : "";
+}
+
+function uniqueTabId(session = {}) {
+ return String(session.file_id || session.session_id || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`);
+}
+
+function escapeHtml(value = "") {
+ return String(value)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """);
+}
+
+function inlineMarkdown(value = "") {
+ return escapeHtml(value)
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
+ .replace(/(^|[^*])\*([^*\n]+)\*/g, "$1$2")
+ .replace(/`([^`]+)`/g, "$1")
+ .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '$1');
+}
+
+function markdownToHtml(markdown = "") {
+ const normalized = String(markdown || "").replace(/\r\n?/g, "\n");
+ const lines = normalized.split("\n");
+ const html = [];
+ let paragraph = [];
+ let list = [];
+
+ const flushParagraph = () => {
+ if (!paragraph.length) return;
+ html.push(`${inlineMarkdown(paragraph.join(" "))}
`);
+ paragraph = [];
+ };
+ const flushList = () => {
+ if (!list.length) return;
+ html.push(`${list.map((line) => `- ${inlineMarkdown(line)}
`).join("")}
`);
+ list = [];
+ };
+
+ for (let index = 0; index < lines.length; index += 1) {
+ const raw = lines[index];
+ const line = raw.trimEnd();
+ if (!line.trim()) {
+ flushParagraph();
+ flushList();
+ continue;
+ }
+ const heading = /^(#{1,4})\s+(.+)$/.exec(line);
+ if (heading) {
+ flushParagraph();
+ flushList();
+ const level = Math.min(4, heading[1].length);
+ html.push(`${inlineMarkdown(heading[2])}`);
+ continue;
+ }
+ const bullet = /^\s*[-*]\s+(.+)$/.exec(line);
+ if (bullet) {
+ flushParagraph();
+ list.push(bullet[1]);
+ continue;
+ }
+ flushList();
+ paragraph.push(line.trim());
+ }
+
+ flushParagraph();
+ flushList();
+ if (!html.length || /\n\s*$/.test(normalized)) {
+ html.push("
");
+ }
+ return html.join("") || "";
+}
+
+function htmlToMarkdown(root) {
+ if (!root) return "";
+
+ const walk = (node) => {
+ if (node.nodeType === Node.TEXT_NODE) return node.textContent || "";
+ if (node.nodeType !== Node.ELEMENT_NODE) return "";
+ const tag = node.tagName.toLowerCase();
+ const childText = () => Array.from(node.childNodes).map(walk).join("");
+
+ if (tag === "br") return "\n";
+ if (tag === "strong" || tag === "b") return `**${childText().trim()}**`;
+ if (tag === "em" || tag === "i") return `*${childText().trim()}*`;
+ if (tag === "code") return `\`${childText().trim()}\``;
+ if (tag === "a") {
+ const href = node.getAttribute("href") || "";
+ const label = childText().trim() || href;
+ return href ? `[${label}](${href})` : label;
+ }
+ if (/^h[1-6]$/.test(tag)) return `\n${"#".repeat(Number(tag[1]))} ${childText().trim()}\n\n`;
+ if (tag === "li") return `- ${childText().trim()}\n`;
+ if (tag === "ul" || tag === "ol") return `\n${childText()}\n`;
+ if (tag === "tr") {
+ const cells = Array.from(node.children).map((cell) => cell.textContent?.trim() || "");
+ return `| ${cells.join(" | ")} |\n`;
+ }
+ if (tag === "table") return `\n${Array.from(node.querySelectorAll("tr")).map(walk).join("")}\n`;
+ if (tag === "p" || tag === "div" || tag === "section" || tag === "article") {
+ const text = childText().trim();
+ return text ? `${text}\n\n` : "";
+ }
+ return childText();
+ };
+
+ return Array.from(root.childNodes)
+ .map(walk)
+ .join("")
+ .replace(/\n{3,}/g, "\n\n")
+ .trimEnd();
+}
+
+function textToPageHtml(text = "") {
+ const paragraphs = String(text || "")
+ .replace(/\r\n?/g, "\n")
+ .split(/\n+/)
+ .map((line) => line.trim())
+ .filter(Boolean);
+ const lines = paragraphs.length ? paragraphs : [""];
+ const pages = [];
+ for (let index = 0; index < lines.length; index += 18) {
+ pages.push(lines.slice(index, index + 18));
+ }
+ return pages
+ .map((page, index) => (
+ ``
+ + page.map((line) => `${escapeHtml(line)}
`).join("")
+ + ""
+ ))
+ .join("");
+}
+
+function nativeTilesToHtml(tiles = []) {
+ return tiles
+ .filter((tile) => tile?.image)
+ .map((tile) => {
+ const twips = encodeURIComponent(JSON.stringify(tile.twips || {}));
+ const width = Number(tile.width || 1);
+ const height = Number(tile.height || 1);
+ return (
+ ``
+ + `
`
+ + ""
+ );
+ })
+ .join("");
+}
+
+function docxEditorText(element) {
+ if (!element) return "";
+ const pages = Array.from(element.querySelectorAll(".office-docx-page"));
+ if (!pages.length) return element.innerText || "";
+ return pages
+ .map((page) => Array.from(page.querySelectorAll("p"))
+ .map((p) => p.innerText.trim())
+ .filter(Boolean)
+ .join("\n"))
+ .filter(Boolean)
+ .join("\n\n");
+}
+
+function editorContainsFocus(element) {
+ const active = document.activeElement;
+ return Boolean(element && active && (element === active || element.contains(active)));
+}
+
+function placeCaretAtEnd(element) {
+ if (!element) return;
+ if (element.tagName === "TEXTAREA" || element.tagName === "INPUT") {
+ const length = element.value?.length || 0;
+ element.selectionStart = length;
+ element.selectionEnd = length;
+ return;
+ }
+ const selection = globalThis.getSelection?.();
+ const range = document.createRange?.();
+ if (!selection || !range) return;
+ range.selectNodeContents(element);
+ range.collapse(false);
+ selection.removeAllRanges();
+ selection.addRange(range);
+}
+
+function normalizeDocument(doc = {}) {
+ const path = doc.path || "";
+ const extension = String(doc.extension || extensionOf(path)).toLowerCase();
+ return {
+ ...doc,
+ extension,
+ title: doc.title || doc.basename || basename(path),
+ basename: doc.basename || basename(path),
+ path,
+ };
+}
+
+function normalizeSession(payload = {}) {
+ const document = normalizeDocument(payload.document || payload);
+ const extension = String(payload.extension || document.extension || "").toLowerCase();
+ return {
+ ...payload,
+ document,
+ extension,
+ file_id: payload.file_id || document.file_id || "",
+ path: document.path || payload.path || "",
+ title: payload.title || document.title || document.basename || basename(document.path),
+ tab_id: uniqueTabId(payload),
+ text: String(payload.text || ""),
+ tiles: Array.isArray(payload.tiles) ? payload.tiles : [],
+ preview: payload.preview || document.preview || {},
+ native: payload.native || {},
+ desktop: payload.desktop || null,
+ desktop_session_id: payload.desktop_session_id || payload.desktop?.session_id || "",
+ dirty: false,
+ };
+}
+
+async function callOffice(action, payload = {}) {
+ return await callJsonApi("/plugins/_office/office_session", {
+ action,
+ ctxid: currentContextId(),
+ ...payload,
+ });
+}
+
+async function requestOffice(eventType, payload = {}, timeoutMs = 5000) {
+ const response = await officeSocket.request(eventType, {
+ ctxid: currentContextId(),
+ ...payload,
+ }, { timeoutMs });
+ const results = Array.isArray(response?.results) ? response.results : [];
+ const first = results.find((item) => item?.ok === true && isOfficeSocketData(item?.data))
+ || results.find((item) => item?.ok === true);
+ if (!first) {
+ const error = results.find((item) => item?.error)?.error;
+ throw new Error(error?.error || error?.code || `${eventType} failed`);
+ }
+ if (first.data?.office_error) {
+ const error = first.data.office_error;
+ throw new Error(error.error || error.code || `${eventType} failed`);
+ }
+ return first.data || {};
+}
+
+function isOfficeSocketData(data) {
+ if (!data || typeof data !== "object") return false;
+ return (
+ Object.prototype.hasOwnProperty.call(data, "office_error")
+ || Object.prototype.hasOwnProperty.call(data, "ok")
+ || Object.prototype.hasOwnProperty.call(data, "session_id")
+ || Object.prototype.hasOwnProperty.call(data, "document")
+ || Object.prototype.hasOwnProperty.call(data, "tiles")
+ || Object.prototype.hasOwnProperty.call(data, "native")
+ || Object.prototype.hasOwnProperty.call(data, "desktop")
+ || Object.prototype.hasOwnProperty.call(data, "closed")
+ );
+}
+
const model = {
status: null,
recent: [],
@@ -72,21 +312,39 @@ const model = {
activeTabId: "",
session: null,
loading: false,
+ saving: false,
+ dirty: false,
error: "",
message: "",
- frameReady: false,
- frameName: FRAME_NAME_PREFIX,
+ sourceMode: false,
+ editorText: "",
+ zoom: 1,
_root: null,
- _messageBound: false,
- _frameTimer: null,
- _frameRecoveryTimer: null,
- _frameAttempt: 0,
- _frameRecoveryTried: false,
- _frameOrigin: "",
_mode: "canvas",
+ _saveMessageTimer: null,
+ _inputTimer: null,
+ _history: [],
+ _historyIndex: -1,
+ _rendering: false,
+ _pendingFocus: false,
+ _pendingFocusEnd: true,
+ _focusAttempts: 0,
+ _richEditor: null,
+ _docxEditor: null,
+ _nativeEventQueue: Promise.resolve(),
_floatingCleanup: null,
- _saveWaiters: [],
- _statusPollTimer: null,
+ _desktopHeartbeatTimer: null,
+ _desktopHeartbeatSessionId: "",
+ _desktopHeartbeatTabId: "",
+ _desktopHeartbeatMisses: 0,
+ _desktopResizeCleanup: null,
+ _desktopResizeTimer: null,
+ _desktopResizeKey: "",
+ _desktopResizeSuspended: false,
+ _desktopResizePending: false,
+ _desktopPrimeTimer: null,
+ _desktopPrimeAttempts: 0,
+ _desktopStarting: null,
async init(element = null) {
return await this.onMount(element, { mode: "canvas" });
@@ -94,193 +352,326 @@ const model = {
async onMount(element = null, options = {}) {
if (element) this._root = element;
- this.assignFrameName(element);
- globalThis.requestAnimationFrame?.(() => this.assignFrameName(element));
- if (!this._messageBound) {
- globalThis.addEventListener("message", (event) => this.onPostMessage(event));
- this._messageBound = true;
- }
this._mode = options?.mode === "modal" ? "modal" : "canvas";
- if (this._mode === "modal") {
- this.setupFloatingModal(element);
- } else {
- this.setupCanvasSurface(element);
- }
+ if (this._mode === "modal") this.setupFloatingModal(element);
await this.refresh();
+ await this.ensureDesktopSession({ select: !this.session });
this.ensureActiveTab();
- if (this.session && this._root) {
- await this.restartFrameLoad();
- }
+ this.queueRender();
},
async onOpen(payload = {}) {
await this.refresh();
if (payload?.path || payload?.file_id) {
await this.openSession({
- action: "open",
path: payload.path || "",
file_id: payload.file_id || "",
- mode: "edit",
});
- } else if (this.session && !this.frameReady) {
- await this.restartFrameLoad();
+ } else {
+ await this.ensureDesktopSession({ select: !this.session });
+ }
+ this.restoreDesktopFrames();
+ },
+
+ beforeHostHidden(options = {}) {
+ this.flushInput();
+ if (options?.unloadDesktop) {
+ this.unloadDesktopFrames();
}
},
cleanup() {
+ this.flushInput();
+ this.stopDesktopMonitor();
+ this.stopDesktopResizeObserver();
+ this.stopXpraDesktopPrime();
this._floatingCleanup?.();
this._floatingCleanup = null;
- this.clearStatusPoll();
- if (this._mode === "modal") {
- this._root = null;
- }
+ if (this._mode === "modal") this._root = null;
+ },
+
+ bindEditorElement(element, type) {
+ if (type === "markdown") this._richEditor = element;
+ if (type === "docx") this._docxEditor = element;
+ this.queueRender();
},
async refresh() {
try {
- this.status = await callJsonApi("/plugins/_office/office_session", { action: "status" });
- const recent = await callJsonApi("/plugins/_office/office_session", { action: "recent" });
- this.recent = recent?.documents || [];
- if (this.status?.healthy) {
- await this.syncOpenSessions();
- } else {
- this.openDocuments = [];
- }
+ const [status, recent, openDocuments] = await Promise.all([
+ callOffice("status"),
+ callOffice("recent"),
+ callOffice("open_documents"),
+ ]);
+ this.status = status || {};
+ this.recent = (recent?.documents || []).map(normalizeDocument);
+ this.openDocuments = (openDocuments?.documents || []).map(normalizeDocument);
+ this.error = "";
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
- } finally {
- this.scheduleStatusPoll();
}
},
- async syncOpenSessions() {
- const sessionIds = this.tabs
- .map((tab) => normalizeTabId(tab?.session_id))
- .filter(Boolean);
- const response = await callJsonApi("/plugins/_office/office_session", {
- action: "sync_open_sessions",
- session_ids: sessionIds,
- });
- this.openDocuments = response?.documents || [];
- return response;
- },
-
- async retry() {
- this.message = "Retrying Office setup...";
- this.status = await callJsonApi("/plugins/_office/office_session", { action: "retry" });
- this.scheduleStatusPoll();
- },
-
- clearStatusPoll() {
- if (!this._statusPollTimer) return;
- globalThis.clearTimeout(this._statusPollTimer);
- this._statusPollTimer = null;
- },
-
- scheduleStatusPoll() {
- this.clearStatusPoll();
- if (!this.shouldPollSetup()) return;
- this._statusPollTimer = globalThis.setTimeout(() => {
- this._statusPollTimer = null;
- void this.refresh();
- }, SETUP_POLL_INTERVAL_MS);
- },
-
- shouldPollSetup() {
- if (this.session || this.status?.healthy) return false;
- if (!this.status) return true;
- const state = String(this.status.state || "").toLowerCase();
- return Boolean(this.status.installing || state === "installing" || state === "idle");
- },
-
- setupState() {
- return String(this.status?.state || "installing").toLowerCase();
- },
-
- isSetupBusy() {
- const state = this.setupState();
- return !this.status || Boolean(this.status.installing) || state === "installing" || state === "idle";
- },
-
- isSetupBlocked() {
- const state = this.setupState();
- return state === "failed" || state === "degraded";
- },
-
- showSetupActions() {
- return this.isSetupBlocked() || (!this.isSetupBusy() && !this.status?.healthy);
- },
-
- setupIcon() {
- return this.isSetupBlocked() ? "error" : "progress_activity";
- },
-
- setupTitle() {
- if (this.isSetupBlocked()) return "Setup needs attention";
- return "Setup in progress";
- },
-
- setupMessage() {
- if (this.isSetupBlocked()) {
- return "Office could not finish setup. Retry when you are ready.";
+ async ensureDesktopSession(options = {}) {
+ const existing = this.tabs.find((tab) => this.isDesktopSession(tab));
+ if (existing && !options.force) {
+ if (options.select) this.selectTab(existing.tab_id, { focus: false });
+ this.updateDesktopMonitor();
+ return existing;
}
- return "Please wait while Office is prepared. This can take a few minutes the first time.";
+ if (this._desktopStarting) return await this._desktopStarting;
+
+ this._desktopStarting = (async () => {
+ try {
+ const response = await callOffice("desktop");
+ if (response?.ok === false) throw new Error(response.error || "Desktop session could not be opened.");
+ const session = normalizeSession(response);
+ const existingIndex = this.tabs.findIndex((tab) => this.isDesktopSession(tab));
+ let desktopTabId = session.tab_id;
+ if (existingIndex >= 0) {
+ desktopTabId = this.tabs[existingIndex].tab_id;
+ this.tabs.splice(existingIndex, 1, { ...this.tabs[existingIndex], ...session, tab_id: desktopTabId });
+ } else {
+ this.tabs.unshift(session);
+ }
+ this.tabs = this.tabs.map((tab) => (
+ this.hasOfficialOffice(tab)
+ ? {
+ ...tab,
+ desktop: session.desktop,
+ desktop_session_id: session.desktop_session_id,
+ session_id: this.isDesktopSession(tab) ? session.session_id : tab.session_id,
+ }
+ : tab
+ ));
+ if (options.select || !this.session) {
+ this.selectTab(desktopTabId, { focus: false });
+ } else {
+ this.updateDesktopMonitor();
+ }
+ return { ...session, tab_id: desktopTabId };
+ } catch (error) {
+ this.error = error instanceof Error ? error.message : String(error);
+ return null;
+ } finally {
+ this._desktopStarting = null;
+ }
+ })();
+ return await this._desktopStarting;
},
- healthTitle() {
- if (this.status?.healthy) return "Office is ready";
- if (this.isSetupBlocked()) return "Office setup needs attention";
- if (this.isSetupBusy()) return "Office setup is in progress";
- return "Office status";
- },
-
- healthText() {
- if (this.isSetupBlocked()) return "attention";
- if (this.isSetupBusy()) return "setup";
- return String(this.status?.state || "status");
- },
-
- async create(kind = "document") {
- const defaults = {
- document: ["Document", "docx"],
- spreadsheet: ["Spreadsheet", "xlsx"],
- presentation: ["Presentation", "pptx"],
- };
- const [title, format] = defaults[kind] || defaults.document;
+ async create(kind = "document", format = "") {
+ const fmt = String(format || (kind === "spreadsheet" ? "xlsx" : kind === "presentation" ? "pptx" : "md")).toLowerCase();
+ const title = this.defaultTitle(kind, fmt);
await this.openSession({
action: "create",
kind,
+ format: fmt,
title,
- format,
- content: "",
});
},
async openPrompt() {
- const path = globalThis.prompt?.("Open Office file path", "/a0/usr/workdir/documents/");
+ let defaultPath = "/a0/usr/workdir/";
+ try {
+ const home = await callOffice("home");
+ defaultPath = home?.path || defaultPath;
+ } catch {
+ // The prompt still works with the static fallback.
+ }
+ const path = globalThis.prompt?.("Path", defaultPath);
if (!path) return;
await this.openPath(path);
},
async openPath(path) {
- await this.openSession({ action: "open", path, mode: "edit" });
+ await this.openSession({ path: String(path || "") });
},
- async openSession(payload) {
+ async openSession(payload = {}) {
this.loading = true;
this.error = "";
- this.message = "";
try {
- await this.save({ wait: true, timeoutMs: 900 });
- await this.prepareBrowserHostForEditor();
- const response = await callJsonApi("/plugins/_office/office_session", payload);
- if (!response?.ok) {
- this.error = response?.error || "Office session could not be opened.";
- if (response?.status) this.status = response.status;
- return;
+ const response = await callOffice(payload.action || "open", payload);
+ if (response?.ok === false) {
+ this.error = response.error || "Document could not be opened.";
+ return null;
}
- await this.activateSession(response);
+ const session = normalizeSession(response);
+ this.installSession(session);
await this.refresh();
+ return session;
+ } catch (error) {
+ this.error = error instanceof Error ? error.message : String(error);
+ return null;
+ } finally {
+ this.loading = false;
+ }
+ },
+
+ installSession(session) {
+ const existingIndex = this.tabs.findIndex((tab) => (
+ (session.file_id && tab.file_id === session.file_id)
+ || (session.path && tab.path === session.path)
+ ));
+ if (existingIndex >= 0) {
+ this.tabs.splice(existingIndex, 1, { ...this.tabs[existingIndex], ...session, tab_id: this.tabs[existingIndex].tab_id });
+ this.activeTabId = this.tabs[existingIndex].tab_id;
+ } else {
+ this.tabs.push(session);
+ this.activeTabId = session.tab_id;
+ }
+ this.selectTab(this.activeTabId);
+ },
+
+ selectTab(tabId, options = {}) {
+ const tab = this.tabs.find((item) => item.tab_id === tabId) || this.tabs[0] || null;
+ this.session = tab;
+ this.activeTabId = tab?.tab_id || "";
+ this.sourceMode = false;
+ this.editorText = String(tab?.text || "");
+ this.dirty = Boolean(tab?.dirty);
+ this.resetHistory(this.editorText);
+ this.queueRender({ focus: Boolean(tab) && options.focus !== false });
+ this.updateDesktopMonitor();
+ },
+
+ ensureActiveTab() {
+ if (this.session && this.tabs.some((tab) => tab.tab_id === this.session.tab_id)) return;
+ if (this.tabs.length) this.selectTab(this.tabs[0].tab_id, { focus: false });
+ },
+
+ isActiveTab(tab) {
+ return Boolean(tab && tab.tab_id === this.activeTabId);
+ },
+
+ async closeFile() {
+ if (!this.session) return;
+ await this.closeTab(this.session.tab_id);
+ },
+
+ async closeTab(tabId) {
+ const tab = this.tabs.find((item) => item.tab_id === tabId);
+ if (!tab) return;
+ if (this.isDesktopSession(tab)) {
+ this.selectTab(tab.tab_id, { focus: false });
+ return;
+ }
+ if (!this.hasOfficialOffice(tab) && (tab.dirty || (this.isActiveTab(tab) && this.dirty))) {
+ const shouldSave = globalThis.confirm?.("Save changes?") ?? true;
+ if (shouldSave) await this.save();
+ }
+ try {
+ if (this.hasOfficialOffice(tab)) {
+ await callOffice("desktop_save", {
+ desktop_session_id: tab.desktop_session_id || tab.session_id,
+ file_id: tab.file_id || "",
+ }).catch(() => null);
+ } else if (tab.session_id) {
+ await requestOffice("office_close", { session_id: tab.session_id }, 2500).catch(() => null);
+ }
+ await callOffice("close", {
+ session_id: tab.store_session_id || "",
+ file_id: tab.file_id || "",
+ });
+ } catch (error) {
+ console.warn("Document close skipped", error);
+ }
+ this.tabs = this.tabs.filter((item) => item.tab_id !== tabId);
+ if (this.activeTabId === tabId) {
+ this.session = null;
+ this.activeTabId = "";
+ this.editorText = "";
+ this.dirty = false;
+ this.ensureActiveTab();
+ }
+ this.updateDesktopMonitor();
+ await this.ensureDesktopSession({ select: !this.session });
+ await this.refresh();
+ },
+
+ async save() {
+ if (!this.session || this.saving) return;
+ if (this.isDesktopSession()) return;
+ if (this.hasOfficialOffice()) {
+ this.saving = true;
+ this.error = "";
+ try {
+ const response = await callOffice("desktop_save", {
+ desktop_session_id: this.session.desktop_session_id || this.session.session_id,
+ file_id: this.session.file_id || "",
+ });
+ if (response?.ok === false) throw new Error(response.error || "Save failed.");
+ const document = normalizeDocument(response.document || this.session.document || {});
+ const updated = {
+ ...this.session,
+ dirty: false,
+ document,
+ path: document.path || this.session.path,
+ file_id: document.file_id || this.session.file_id,
+ version: document.version || response.version || this.session.version,
+ };
+ this.replaceActiveSession(updated);
+ this.dirty = false;
+ this.setMessage("Saved");
+ await this.refresh();
+ } catch (error) {
+ this.error = error instanceof Error ? error.message : String(error);
+ } finally {
+ this.saving = false;
+ }
+ return;
+ }
+ if (this.hasNativeDocxTiles()) await this.awaitNativeEvents();
+ if (!this.hasNativeDocxTiles()) this.syncEditorText();
+ this.saving = true;
+ this.error = "";
+ try {
+ let response;
+ const payload = { session_id: this.session.session_id };
+ if (!this.hasNativeDocxTiles()) payload.text = this.editorText;
+ try {
+ response = await requestOffice("office_save", payload, 10000);
+ } catch (_socketError) {
+ response = await callOffice("save", payload);
+ }
+ if (response?.ok === false) throw new Error(response.error || "Save failed.");
+ const document = normalizeDocument(response.document || this.session.document || {});
+ const updated = {
+ ...this.session,
+ text: this.editorText,
+ dirty: false,
+ document,
+ path: document.path || this.session.path,
+ file_id: document.file_id || this.session.file_id,
+ tiles: Array.isArray(response.tiles) ? response.tiles : this.session.tiles,
+ native: response.native || this.session.native || {},
+ version: document.version || response.version || this.session.version,
+ };
+ this.replaceActiveSession(updated);
+ this.dirty = false;
+ this.setMessage("Saved");
+ await this.refresh();
+ } catch (error) {
+ this.error = error instanceof Error ? error.message : String(error);
+ } finally {
+ this.saving = false;
+ }
+ },
+
+ async exportPdf() {
+ if (!this.session) return;
+ if (this.isDesktopSession()) return;
+ this.loading = true;
+ this.error = "";
+ try {
+ const response = await callOffice("export", {
+ file_id: this.session.file_id,
+ path: this.session.path,
+ target_format: "pdf",
+ });
+ if (response?.ok === false) throw new Error(response.error || "Export failed.");
+ this.setMessage(response.path ? `Exported ${response.path}` : "Exported");
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
} finally {
@@ -288,605 +679,1229 @@ const model = {
}
},
- async activateSession(response) {
- const tab = this.normalizeTab(response);
- const existingIndex = this.findTabIndexForSession(tab);
- if (existingIndex >= 0) {
- const previous = this.tabs[existingIndex];
- if (previous?.session_id && previous.session_id !== tab.session_id) {
- await this.closeBackendSession(previous);
- }
- this.tabs.splice(existingIndex, 1, tab);
- } else {
- this.tabs.push(tab);
- }
- this.activeTabId = tab.tab_id;
- this.syncActiveSession();
- this.frameReady = false;
- this._frameOrigin = "";
- this._frameAttempt = 0;
- this._frameRecoveryTried = false;
- this.clearFrameTimers();
- await this.submitFrame();
- this.scheduleFrameWatch();
- },
-
- async submitFrame() {
- await nextAnimationFrame();
- this.syncActiveSession();
- const session = this.session;
- const frame = this.activeFrame();
- if (!session || !frame?.name) return;
- const form = document.createElement("form");
- form.method = "post";
- form.action = this.frameAction(session.iframe_action);
- form.target = frame.name;
- form.style.display = "none";
- const fields = {
- access_token: session.access_token,
- access_token_ttl: String(session.access_token_ttl),
- ui_defaults: "UIMode=notebookbar;TextRuler=false",
- };
- for (const [name, value] of Object.entries(fields)) {
- const input = document.createElement("input");
- input.type = "hidden";
- input.name = name;
- input.value = value;
- form.appendChild(input);
- }
- document.body.appendChild(form);
- form.submit();
- form.remove();
- },
-
- async restartFrameLoad() {
- this.syncActiveSession();
+ replaceActiveSession(next) {
if (!this.session) return;
- this.frameReady = false;
- this._frameOrigin = "";
- this._frameAttempt = 0;
- this._frameRecoveryTried = false;
- this.clearFrameTimers();
- await this.submitFrame();
- this.scheduleFrameWatch();
+ this.session = next;
+ const index = this.tabs.findIndex((tab) => tab.tab_id === next.tab_id);
+ if (index >= 0) this.tabs.splice(index, 1, next);
+ this.queueRender();
+ this.updateDesktopMonitor();
},
- frameAction(action) {
- const url = new URL(action, globalThis.location.origin);
- url.searchParams.set("a0_frame_attempt", String(this._frameAttempt));
- return url.pathname + url.search;
+ setMessage(value) {
+ this.message = value;
+ if (this._saveMessageTimer) globalThis.clearTimeout(this._saveMessageTimer);
+ this._saveMessageTimer = globalThis.setTimeout(() => {
+ this.message = "";
+ this._saveMessageTimer = null;
+ }, SAVE_MESSAGE_MS);
},
- scheduleFrameWatch() {
- this.clearFrameTimers();
- this._frameTimer = setTimeout(() => {
- if (this.session && !this.frameReady) {
- this.message = "Still opening the editor...";
- this._frameRecoveryTimer = setTimeout(() => this.recoverFrameLoad(), 3000);
- }
- }, 20000);
+ resetHistory(text) {
+ this._history = [String(text || "")];
+ this._historyIndex = 0;
},
- async recoverFrameLoad() {
- if (!this.session || this.frameReady || this._frameRecoveryTried) return;
- this._frameRecoveryTried = true;
- this._frameAttempt += 1;
- this.resetCollaboraBrowserState({ force: true });
- this.message = "Still opening the editor... trying a fresh editor load.";
- await this.submitFrame();
- this._frameTimer = setTimeout(() => {
- if (this.session && !this.frameReady) {
- this.message = "Still opening the editor...";
- }
- }, 25000);
+ pushHistory(text) {
+ const value = String(text || "");
+ if (this._history[this._historyIndex] === value) return;
+ this._history = this._history.slice(0, this._historyIndex + 1);
+ this._history.push(value);
+ if (this._history.length > MAX_HISTORY) this._history.shift();
+ this._historyIndex = this._history.length - 1;
},
- clearFrameTimers() {
- if (this._frameTimer) {
- clearTimeout(this._frameTimer);
- this._frameTimer = null;
- }
- if (this._frameRecoveryTimer) {
- clearTimeout(this._frameRecoveryTimer);
- this._frameRecoveryTimer = null;
- }
+ undo() {
+ if (this._historyIndex <= 0) return;
+ this._historyIndex -= 1;
+ this.applyEditorText(this._history[this._historyIndex], true);
},
- beforeHostHidden() {
+ redo() {
+ if (this._historyIndex >= this._history.length - 1) return;
+ this._historyIndex += 1;
+ this.applyEditorText(this._history[this._historyIndex], true);
+ },
+
+ canUndo() {
+ return this._historyIndex > 0;
+ },
+
+ canRedo() {
+ return this._historyIndex < this._history.length - 1;
+ },
+
+ applyEditorText(text, markDirty = false) {
+ this.editorText = String(text || "");
if (this.session) {
- this.save();
- }
- this.frameReady = false;
- this._frameOrigin = "";
- this.clearFrameTimers();
- const frame = this.activeFrame();
- if (frame) {
- frame.src = "about:blank";
+ this.session.text = this.editorText;
+ this.session.dirty = markDirty || this.session.dirty;
}
+ if (markDirty) this.markDirty();
+ this.queueRender({ force: true, focus: true });
},
- postToFrame(message) {
- const frame = this.activeFrame();
- const targetOrigin = this._frameOrigin || this.session?.post_message_origin || globalThis.location.origin;
- frame?.contentWindow?.postMessage(JSON.stringify(message), targetOrigin);
+ markDirty() {
+ this.dirty = true;
+ if (this.session) this.session.dirty = true;
},
- async save(options = {}) {
- const { wait = false, timeoutMs = 1500 } = options;
- if (!this.session || !this.activeFrame() || !this.frameReady) return true;
- if (!wait) {
- this.postToFrame({
- MessageId: "Action_Save",
- Values: {
- DontTerminateEdit: true,
- DontSaveIfUnmodified: true,
- },
- });
- return true;
+ onSourceInput() {
+ this.markDirty();
+ this.pushHistory(this.editorText);
+ this.scheduleInputPush();
+ },
+
+ onRichInput(element) {
+ if (this._rendering) return;
+ this.editorText = htmlToMarkdown(element);
+ this.markDirty();
+ this.pushHistory(this.editorText);
+ this.scheduleInputPush();
+ },
+
+ onDocxInput(element) {
+ if (this.hasNativeDocxTiles()) return;
+ if (this._rendering) return;
+ this.editorText = docxEditorText(element);
+ this.markDirty();
+ this.pushHistory(this.editorText);
+ this.scheduleInputPush();
+ },
+
+ syncEditorText() {
+ if (!this.session) return;
+ if (this.hasOfficialOffice()) return;
+ if (this.hasNativeDocxTiles()) return;
+ if (this.isMarkdown() && !this.sourceMode && this._richEditor) {
+ this.editorText = htmlToMarkdown(this._richEditor);
+ } else if (this.isDocx() && this._docxEditor) {
+ this.editorText = docxEditorText(this._docxEditor);
}
- return await new Promise((resolve) => {
- const timeout = globalThis.setTimeout(() => {
- this._saveWaiters = this._saveWaiters.filter((waiter) => waiter !== done);
- resolve(false);
- }, timeoutMs);
- const done = (ok) => {
- globalThis.clearTimeout(timeout);
- resolve(ok);
- };
- this._saveWaiters.push(done);
- this.postToFrame({
- MessageId: "Action_Save",
- Values: {
- DontTerminateEdit: true,
- DontSaveIfUnmodified: true,
- },
- });
+ this.session.text = this.editorText;
+ },
+
+ scheduleInputPush() {
+ if (!this.session?.session_id) return;
+ if (this._inputTimer) globalThis.clearTimeout(this._inputTimer);
+ this._inputTimer = globalThis.setTimeout(() => {
+ this._inputTimer = null;
+ this.flushInput();
+ }, INPUT_PUSH_DELAY_MS);
+ },
+
+ flushInput() {
+ if (!this.session?.session_id) return;
+ if (this.hasOfficialOffice()) return;
+ this.syncEditorText();
+ requestOffice("office_input", {
+ session_id: this.session.session_id,
+ text: this.editorText,
+ }, 3000).catch(() => {});
+ },
+
+ toggleSource() {
+ if (!this.isMarkdown()) return;
+ if (!this.sourceMode) this.syncEditorText();
+ this.sourceMode = !this.sourceMode;
+ this.queueRender({ force: true, focus: true });
+ },
+
+ format(command) {
+ if (!this.session) return;
+ if (this.sourceMode) {
+ this.applySourceFormat(command);
+ return;
+ }
+ const editor = this.isDocx() ? this._docxEditor : this._richEditor;
+ editor?.focus?.();
+ const uno = this.unoCommand(command);
+ if (this.isDocx() && uno) {
+ void this.dispatchUnoCommand(uno.command, uno.arguments);
+ if (this.hasNativeDocxTiles()) {
+ this.markDirty();
+ return;
+ }
+ }
+ if (command === "bold") document.execCommand?.("bold");
+ if (command === "italic") document.execCommand?.("italic");
+ if (command === "underline") document.execCommand?.("underline");
+ if (command === "list") document.execCommand?.("insertUnorderedList");
+ if (command === "numbered") document.execCommand?.("insertOrderedList");
+ if (command === "alignLeft") document.execCommand?.("justifyLeft");
+ if (command === "alignCenter") document.execCommand?.("justifyCenter");
+ if (command === "alignRight") document.execCommand?.("justifyRight");
+ if (command === "table") {
+ document.execCommand?.(
+ "insertHTML",
+ false,
+ '',
+ );
+ }
+ this.syncEditorText();
+ this.markDirty();
+ this.pushHistory(this.editorText);
+ this.scheduleInputPush();
+ },
+
+ unoCommand(command) {
+ const commands = {
+ bold: { command: ".uno:Bold" },
+ italic: { command: ".uno:Italic" },
+ underline: { command: ".uno:Underline" },
+ list: { command: ".uno:DefaultBullet" },
+ numbered: { command: ".uno:DefaultNumbering" },
+ alignLeft: { command: ".uno:LeftPara" },
+ alignCenter: { command: ".uno:CenterPara" },
+ alignRight: { command: ".uno:RightPara" },
+ };
+ return commands[command] || null;
+ },
+
+ async dispatchUnoCommand(command, argumentsPayload = null) {
+ if (!this.session?.session_id || !command) return null;
+ return await this.queueNativeEvent(async () => {
+ try {
+ let response;
+ try {
+ response = await requestOffice("office_command", {
+ session_id: this.session.session_id,
+ command,
+ arguments: argumentsPayload,
+ notify: true,
+ }, 5000);
+ } catch (_socketError) {
+ response = await callOffice("command", {
+ session_id: this.session.session_id,
+ command,
+ arguments: argumentsPayload,
+ notify: true,
+ });
+ }
+ if (response?.ok === false) throw new Error(response.error || `${command} failed.`);
+ if (response?.metadata && this.session) {
+ this.session.native = { ...(this.session.native || {}), ...response.metadata, available: true };
+ }
+ if (Array.isArray(response?.tiles) && this.session) {
+ this.session.tiles = response.tiles;
+ this.queueRender({ force: true, focus: true });
+ }
+ return response;
+ } catch (error) {
+ console.warn("LibreOffice command skipped", command, error);
+ return null;
+ }
});
},
- resolveSaveWaiters(ok = true) {
- const waiters = this._saveWaiters.splice(0);
- for (const waiter of waiters) waiter(ok);
+ applySourceFormat(command) {
+ const textarea = this._root?.querySelector?.("[data-office-source]");
+ if (!textarea) return;
+ const start = textarea.selectionStart || 0;
+ const end = textarea.selectionEnd || start;
+ const selected = this.editorText.slice(start, end);
+ let replacement = selected;
+ if (command === "bold") replacement = `**${selected || "text"}**`;
+ if (command === "italic") replacement = `*${selected || "text"}*`;
+ if (command === "list") replacement = (selected || "item").split("\n").map((line) => `- ${line.replace(/^[-*]\s+/, "")}`).join("\n");
+ if (command === "numbered") replacement = (selected || "item").split("\n").map((line, index) => `${index + 1}. ${line.replace(/^\d+\.\s+/, "")}`).join("\n");
+ if (command === "table") replacement = "| Column | Value |\n| --- | --- |\n| | |";
+ if (replacement === selected) return;
+ this.editorText = `${this.editorText.slice(0, start)}${replacement}${this.editorText.slice(end)}`;
+ this.onSourceInput();
+ globalThis.requestAnimationFrame?.(() => {
+ textarea.focus();
+ textarea.selectionStart = start;
+ textarea.selectionEnd = start + replacement.length;
+ });
},
- closeFile() {
- return this.closeTab(this.activeTabId);
+ zoomIn() {
+ this.zoom = Math.min(1.6, Math.round((this.zoom + 0.1) * 10) / 10);
},
- blankFrame() {
- const frame = this.activeFrame();
- if (frame) {
- frame.src = "about:blank";
+ zoomOut() {
+ this.zoom = Math.max(0.7, Math.round((this.zoom - 0.1) * 10) / 10);
+ },
+
+ zoomLabel() {
+ return `${Math.round(this.zoom * 100)}%`;
+ },
+
+ queueRender(options = {}) {
+ const force = Boolean(options.force);
+ if (options.focus) {
+ this._pendingFocus = true;
+ this._pendingFocusEnd = options.end !== false;
+ this._focusAttempts = 0;
}
- },
-
- async closeTab(tabId = this.activeTabId, options = {}) {
- const normalized = normalizeTabId(tabId);
- const index = this.tabs.findIndex((tab) => tab.tab_id === normalized);
- if (index < 0) return;
-
- const tab = this.tabs[index];
- const wasActive = tab.tab_id === this.activeTabId;
- if (wasActive && !options.skipSave) {
- await this.save({ wait: true, timeoutMs: 1200 });
- }
- await this.closeBackendSession(tab);
- this.tabs.splice(index, 1);
-
- if (!this.tabs.length) {
- this.activeTabId = "";
- this.session = null;
- this.frameReady = false;
- this._frameOrigin = "";
- this._frameAttempt = 0;
- this._frameRecoveryTried = false;
- this.clearFrameTimers();
- this.blankFrame();
- await this.refresh();
- return;
- }
-
- if (wasActive) {
- const nextTab = this.tabs[Math.min(index, this.tabs.length - 1)];
- this.activeTabId = nextTab.tab_id;
- this.syncActiveSession();
- await this.restartFrameLoad();
- } else {
- this.syncActiveSession();
- }
- await this.refresh();
- },
-
- async closeBackendSession(tab) {
- if (!tab?.session_id && !tab?.file_id) return;
- try {
- await callJsonApi("/plugins/_office/office_session", {
- action: "close",
- session_id: tab.session_id || "",
- file_id: tab.session_id ? "" : (tab.file_id || ""),
- });
- } catch (error) {
- console.warn("Office session close skipped", error);
- }
- },
-
- async selectTab(tabId) {
- const tab = this.tabById(tabId);
- if (!tab) return;
- if (tab.tab_id === this.activeTabId && this.session) return;
- await this.save({ wait: true, timeoutMs: 900 });
- this.activeTabId = tab.tab_id;
- this.syncActiveSession();
- await this.restartFrameLoad();
- },
-
- normalizeTab(session) {
- const tabId = makeTabId(session);
- return {
- ...session,
- tab_id: tabId,
- session_id: normalizeTabId(session?.session_id) || tabId,
- title: String(session?.title || session?.basename || session?.path || "Office file"),
- opened_at: session?.opened_at || Date.now(),
+ const render = () => {
+ this.renderEditors(force);
+ if (this._pendingFocus && this.focusEditor({ end: this._pendingFocusEnd })) {
+ this._pendingFocus = false;
+ this._focusAttempts = 0;
+ } else if (this._pendingFocus && this._focusAttempts < 6) {
+ this._focusAttempts += 1;
+ globalThis.setTimeout(render, 45);
+ }
};
- },
-
- findTabIndexForSession(session) {
- return this.tabs.findIndex((tab) => sameDocument(tab, session));
- },
-
- tabById(tabId) {
- const normalized = normalizeTabId(tabId);
- return this.tabs.find((tab) => tab.tab_id === normalized) || null;
- },
-
- activeTab() {
- return this.tabById(this.activeTabId) || this.tabs[0] || null;
- },
-
- ensureActiveTab() {
- if (!this.tabs.length) {
- this.activeTabId = "";
- this.session = null;
- return;
+ if (globalThis.requestAnimationFrame) {
+ globalThis.requestAnimationFrame(render);
+ } else {
+ globalThis.setTimeout(render, 0);
}
- if (!this.tabById(this.activeTabId)) {
- this.activeTabId = this.tabs[0].tab_id;
+ },
+
+ renderEditors(force = false) {
+ if (!this.session) return;
+ if (this.hasOfficialOffice()) return;
+ this._rendering = true;
+ try {
+ if (this._richEditor && this.isMarkdown() && (!editorContainsFocus(this._richEditor) || force)) {
+ this._richEditor.innerHTML = markdownToHtml(this.editorText);
+ }
+ if (this._docxEditor && this.isDocx() && this.hasNativeDocxTiles() && (!editorContainsFocus(this._docxEditor) || force)) {
+ this._docxEditor.innerHTML = nativeTilesToHtml(this.session.tiles || []);
+ } else if (this._docxEditor && this.isDocx() && (!editorContainsFocus(this._docxEditor) || force)) {
+ this._docxEditor.innerHTML = textToPageHtml(this.editorText);
+ }
+ } finally {
+ this._rendering = false;
}
- this.syncActiveSession();
},
- syncActiveSession() {
- this.session = this.activeTab();
+ focusEditor(options = {}) {
+ if (!this.session || this.isPreviewOnly()) return false;
+ if (this.hasOfficialOffice()) {
+ const frame = this.desktopFrame();
+ frame?.focus?.({ preventScroll: true });
+ return Boolean(frame);
+ }
+ const source = this._root?.querySelector?.("[data-office-source]");
+ const editor = this.sourceMode ? source : (this.isDocx() ? this._docxEditor : this._richEditor);
+ if (!editor) return false;
+ editor.focus?.({ preventScroll: true });
+ if (!editorContainsFocus(editor)) return false;
+ if (options.end !== false) placeCaretAtEnd(editor);
+ return true;
},
- isActiveTab(tab) {
- return Boolean(tab?.tab_id && tab.tab_id === this.activeTabId);
+ isMarkdown() {
+ return this.session?.extension === "md";
},
- tabTitle(tab) {
- const title = String(tab?.title || tab?.basename || "").trim();
- if (title) return title;
- const path = String(tab?.path || "").trim();
- return path.split("/").filter(Boolean).pop() || "Office file";
+ isDocx() {
+ return this.session?.extension === "docx";
},
- tabLabel(tab) {
- const extension = String(tab?.extension || "").trim().toUpperCase();
- return extension ? `${this.tabTitle(tab)} (${extension})` : this.tabTitle(tab);
+ isBinaryOffice(tab = this.session) {
+ const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase();
+ return ext === "docx" || ext === "xlsx" || ext === "pptx";
},
- tabIcon(tab) {
- const extension = String(tab?.extension || "").toLowerCase();
- if (["xlsx", "ods"].includes(extension)) return "table_chart";
- if (["pptx", "odp"].includes(extension)) return "co_present";
- if (["docx", "odt"].includes(extension)) return "article";
- return "description";
+ hasOfficialOffice(tab = this.session) {
+ return Boolean(tab?.desktop?.available && tab.desktop.url);
},
- openDocumentLabel(doc) {
- const basename = String(doc?.basename || doc?.title || "").trim();
- const path = String(doc?.path || "").trim();
- return basename || path.split("/").filter(Boolean).pop() || "Office file";
- },
-
- openCards() {
- return this.tabs.map((tab) => ({ ...tab, dashboard_open: true }));
- },
-
- recentCards() {
- const openFileIds = new Set(this.tabs.map((tab) => normalizeTabId(tab?.file_id)).filter(Boolean));
- return (this.recent || []).filter((doc) => !openFileIds.has(normalizeTabId(doc?.file_id)));
- },
-
- dashboardTitle(doc) {
- return this.openDocumentLabel(doc);
- },
-
- dashboardMeta(doc) {
- const extension = String(doc?.extension || "").trim().toUpperCase();
- const size = formatBytes(doc?.size);
- return [extension, size].filter(Boolean).join(" / ");
- },
-
- previewKind(doc) {
- const kind = String(doc?.preview?.kind || "").trim();
- if (kind === "spreadsheet" && !doc?.preview?.rows?.length && doc?.preview?.lines?.length) return "document";
- if (kind === "presentation" && !doc?.preview?.slides?.length && doc?.preview?.lines?.length) return "document";
- if (kind) return kind;
- const extension = String(doc?.extension || "").toLowerCase();
- if (["xlsx", "ods"].includes(extension)) return "spreadsheet";
- if (["pptx", "odp"].includes(extension)) return "presentation";
- if (["docx", "odt"].includes(extension)) return "document";
- return "file";
- },
-
- hasPreview(doc) {
- const preview = doc?.preview || {};
+ isDesktopSession(tab = this.session) {
return Boolean(
- preview.available
+ tab
&& (
- preview.lines?.length
- || preview.rows?.length
- || preview.slides?.length
+ tab.file_id === SYSTEM_DESKTOP_FILE_ID
+ || tab.extension === "desktop"
+ || tab.mode === "desktop"
)
);
},
- previewLines(doc) {
- const lines = doc?.preview?.lines || [];
- if (lines.length) return lines.slice(0, 5).map((line) => String(line || ""));
- const slides = doc?.preview?.slides || [];
- if (slides.length) {
- return [slides[0]?.title, ...(slides[0]?.lines || [])].filter(Boolean).slice(0, 5);
- }
- return [];
+ officialOfficeUrl(tab = this.session) {
+ return tab?.desktop?.url || "";
},
- previewRows(doc) {
- return (doc?.preview?.rows || [])
- .slice(0, 5)
- .map((row) => {
- const cells = (Array.isArray(row) ? row : []).slice(0, 4).map((cell) => String(cell ?? ""));
- while (cells.length < 4) cells.push("");
- return cells;
- });
+ desktopFrames() {
+ const frames = Array.from(document.querySelectorAll("[data-office-desktop-frame]"));
+ const rootFrame = this._root?.querySelector?.("[data-office-desktop-frame]");
+ if (rootFrame && !frames.includes(rootFrame)) frames.push(rootFrame);
+ return frames;
},
- previewSlides(doc) {
- return (doc?.preview?.slides || []).slice(0, 2);
+ isUsableDesktopFrame(frame) {
+ if (!frame?.contentWindow) return false;
+ const rect = frame.getBoundingClientRect?.();
+ return Boolean(rect && rect.width >= 120 && rect.height >= 80);
},
- onPostMessage(event) {
- if (!this.session) return;
- if (!this.isAllowedFrameOrigin(event.origin)) return;
- this._frameOrigin = event.origin;
- const message = parseMessage(event.data);
- const id = message.MessageId || message.messageId || "";
- if (id === "App_LoadingStatus" && message.Values?.Status === "Frame_Ready") {
- this.frameReady = true;
- this.clearFrameTimers();
- if (this.message === "Still opening the editor...") this.message = "";
- if (this.message === "Still opening the editor... trying a fresh editor load.") this.message = "";
- this.postToFrame({ MessageId: "Host_PostmessageReady" });
- } else if (id === "UI_Close") {
- void this.closeTab(this.activeTabId, { skipSave: true });
- } else if (id === "Action_Save_Resp") {
- const ok = message.Values?.success !== false;
- this.message = ok ? "Saved" : "Save did not complete.";
- this.resolveSaveWaiters(ok);
+ desktopFrame(preferred = null) {
+ if (this.isUsableDesktopFrame(preferred)) return preferred;
+ const frames = this.desktopFrames();
+ return frames
+ .filter((frame) => this.isUsableDesktopFrame(frame))
+ .sort((left, right) => {
+ const leftRect = left.getBoundingClientRect();
+ const rightRect = right.getBoundingClientRect();
+ return (rightRect.width * rightRect.height) - (leftRect.width * leftRect.height);
+ })[0] || null;
+ },
+
+ unloadDesktopFrames() {
+ this.stopDesktopResizeObserver();
+ this.stopXpraDesktopPrime();
+ for (const frame of this.desktopFrames()) {
+ if (!frame?.getAttribute) continue;
+ const current = frame.getAttribute("src") || "";
+ if (!current || current === "about:blank") continue;
+ frame.dataset.officeDesktopUnloaded = "true";
+ frame.setAttribute("src", "about:blank");
}
},
- isAllowedFrameOrigin(origin) {
- const allowed = new Set([
- globalThis.location.origin,
- this.session?.post_message_origin,
- this.loopbackCounterpart(globalThis.location.origin),
- this.loopbackCounterpart(this.session?.post_message_origin),
- ].filter(Boolean));
- return allowed.has(origin);
+ restoreDesktopFrames() {
+ const url = this.officialOfficeUrl();
+ if (!url) return;
+ for (const frame of this.desktopFrames()) {
+ if (!frame?.getAttribute) continue;
+ const current = frame.getAttribute("src") || "";
+ if (current && current !== "about:blank" && frame.dataset.officeDesktopUnloaded !== "true") continue;
+ delete frame.dataset.officeDesktopUnloaded;
+ frame.setAttribute("src", url);
+ }
},
- loopbackCounterpart(origin) {
- if (!origin) return "";
+ onDesktopFrameLoaded(event = null) {
+ if (event?.target?.getAttribute?.("src") === "about:blank") return;
+ this.error = "";
+ this.focusEditor({ end: false });
+ this.startDesktopResizeObserver();
+ this.primeXpraDesktopFrame({ reset: true, frame: event?.target || null });
+ this.queueDesktopResize();
+ this.updateDesktopMonitor();
+ },
+
+ updateDesktopMonitor() {
+ if (!this.hasOfficialOffice()) {
+ this.stopDesktopMonitor();
+ this.stopDesktopResizeObserver();
+ return;
+ }
+ const sessionId = this.session?.desktop_session_id || this.session?.session_id || "";
+ const tabId = this.session?.tab_id || "";
+ if (
+ sessionId
+ && tabId
+ && this._desktopHeartbeatTimer
+ && this._desktopHeartbeatSessionId === sessionId
+ && this._desktopHeartbeatTabId === tabId
+ ) return;
+ this.startDesktopMonitor();
+ this.startDesktopResizeObserver();
+ },
+
+ startDesktopResizeObserver() {
+ this.stopDesktopResizeObserver();
+ if (!this.hasOfficialOffice()) return;
+ const frame = this.desktopFrame();
+ const target = frame?.parentElement || frame;
+ if (!target) return;
+
+ const resize = () => this.queueDesktopResize();
+ const resizeStart = () => this.suspendDesktopResize();
+ const resizeEnd = () => this.resumeDesktopResize();
+ const cleanup = [];
+ if (typeof ResizeObserver !== "undefined") {
+ const observer = new ResizeObserver(resize);
+ observer.observe(target);
+ cleanup.push(() => observer.disconnect());
+ }
+ globalThis.addEventListener?.("resize", resize);
+ cleanup.push(() => globalThis.removeEventListener?.("resize", resize));
+ globalThis.addEventListener?.("right-canvas-resize-start", resizeStart);
+ cleanup.push(() => globalThis.removeEventListener?.("right-canvas-resize-start", resizeStart));
+ globalThis.addEventListener?.("right-canvas-resize-end", resizeEnd);
+ cleanup.push(() => globalThis.removeEventListener?.("right-canvas-resize-end", resizeEnd));
+ this._desktopResizeCleanup = () => cleanup.splice(0).reverse().forEach((entry) => entry());
+ resize();
+ },
+
+ stopDesktopResizeObserver() {
+ if (this._desktopResizeTimer) {
+ globalThis.clearTimeout(this._desktopResizeTimer);
+ }
+ this._desktopResizeTimer = null;
+ this._desktopResizeCleanup?.();
+ this._desktopResizeCleanup = null;
+ this._desktopResizeKey = "";
+ this._desktopResizeSuspended = false;
+ this._desktopResizePending = false;
+ },
+
+ suspendDesktopResize() {
+ this._desktopResizeSuspended = true;
+ if (this._desktopResizeTimer) {
+ globalThis.clearTimeout(this._desktopResizeTimer);
+ this._desktopResizeTimer = null;
+ }
+ },
+
+ resumeDesktopResize() {
+ const hadPendingResize = this._desktopResizePending;
+ this._desktopResizeSuspended = false;
+ this._desktopResizePending = false;
+ if (hadPendingResize || this.hasOfficialOffice()) {
+ this.queueDesktopResize({ force: true });
+ }
+ },
+
+ shouldDeferDesktopResize() {
+ return Boolean(
+ this._desktopResizeSuspended
+ || document.body?.classList?.contains("right-canvas-resizing")
+ || document.querySelector?.(".modal-inner.office-modal.is-resizing")
+ );
+ },
+
+ primeXpraDesktopFrame(options = {}) {
+ if (options.reset) {
+ this.stopXpraDesktopPrime();
+ this._desktopPrimeAttempts = 0;
+ }
+ if (this.applyXpraDesktopFrameMode(options.frame || null)) return;
+ if (this._desktopPrimeAttempts >= XPRA_DESKTOP_PRIME_ATTEMPTS) return;
+ this._desktopPrimeAttempts += 1;
+ if (this._desktopPrimeTimer) globalThis.clearTimeout(this._desktopPrimeTimer);
+ this._desktopPrimeTimer = globalThis.setTimeout(() => {
+ this._desktopPrimeTimer = null;
+ this.primeXpraDesktopFrame();
+ }, XPRA_DESKTOP_PRIME_INTERVAL_MS);
+ },
+
+ stopXpraDesktopPrime() {
+ if (this._desktopPrimeTimer) globalThis.clearTimeout(this._desktopPrimeTimer);
+ this._desktopPrimeTimer = null;
+ },
+
+ applyXpraDesktopFrameMode(preferredFrame = null, options = {}) {
+ const frame = this.desktopFrame(preferredFrame);
+ const remoteWindow = frame?.contentWindow;
+ if (!remoteWindow) return false;
+ const requestServerResize = options.requestServerResize !== false;
+ const requestRefresh = options.requestRefresh !== false;
try {
- const url = new URL(origin);
- if (url.hostname === "127.0.0.1") {
- url.hostname = "localhost";
- return url.origin;
+ const remoteDocument = frame.contentDocument || remoteWindow.document;
+ this.installXpraDesktopFrameCss(remoteDocument);
+ const client = remoteWindow.client;
+ if (!client) return false;
+ const container = client.container || remoteDocument?.querySelector?.("#screen");
+ if (!container) return false;
+
+ client.server_is_desktop = true;
+ client.server_resize_exact = true;
+ remoteDocument?.body?.classList?.add("desktop");
+
+ const windows = Object.values(client.id_to_window || {});
+ if (!client.connected || !windows.length) return false;
+
+ const width = Math.round(container.clientWidth || remoteWindow.innerWidth || 0);
+ const height = Math.round(container.clientHeight || remoteWindow.innerHeight || 0);
+ if (requestServerResize && width > 0 && height > 0 && typeof client._screen_resized === "function") {
+ client.desktop_width = 0;
+ client.desktop_height = 0;
+ client._screen_resized(new remoteWindow.Event("resize"));
}
- if (url.hostname === "localhost") {
- url.hostname = "127.0.0.1";
- return url.origin;
+
+ for (const xpraWindow of windows) {
+ this.normalizeXpraDesktopWindow(xpraWindow, width, height);
+ xpraWindow.screen_resized?.();
+ this.normalizeXpraDesktopWindow(xpraWindow, width, height);
+ xpraWindow.updateCSSGeometry?.();
+ this.fitXpraDesktopWindowElement(xpraWindow, width, height);
+ if (requestRefresh && xpraWindow.wid != null) client.request_refresh?.(xpraWindow.wid);
}
- } catch {
- return "";
+ return true;
+ } catch (error) {
+ console.warn("Xpra desktop viewport prime skipped", error);
+ return false;
}
- return "";
},
- assignFrameName(element = null) {
- const root = element || this._root;
- if (!root) return this.frameName || FRAME_NAME_PREFIX;
- if (!root.dataset.officeFrameName) {
- root.dataset.officeFrameName = makeFrameName();
+ normalizeXpraDesktopWindow(xpraWindow, width, height) {
+ if (!xpraWindow) return;
+ const normalizedWidth = Math.max(1, Math.round(Number(width || 0)));
+ const normalizedHeight = Math.max(1, Math.round(Number(height || 0)));
+ xpraWindow.x = 0;
+ xpraWindow.y = 0;
+ xpraWindow.w = normalizedWidth;
+ xpraWindow.h = normalizedHeight;
+ xpraWindow.resizable = false;
+ xpraWindow.decorations = false;
+ xpraWindow.decorated = false;
+ xpraWindow.metadata = { ...(xpraWindow.metadata || {}), decorations: false };
+ xpraWindow._set_decorated?.(false);
+ xpraWindow.configure_border_class?.();
+ xpraWindow.leftoffset = 0;
+ xpraWindow.rightoffset = 0;
+ xpraWindow.topoffset = 0;
+ xpraWindow.bottomoffset = 0;
+ },
+
+ fitXpraDesktopWindowElement(xpraWindow, width, height) {
+ const cssWidth = `${Math.max(1, Number(width || 0))}px`;
+ const cssHeight = `${Math.max(1, Number(height || 0))}px`;
+ const windowElement = xpraWindow?.div;
+ const canvas = xpraWindow?.canvas;
+ windowElement?.style?.setProperty("left", "0px", "important");
+ windowElement?.style?.setProperty("top", "0px", "important");
+ windowElement?.style?.setProperty("position", "absolute", "important");
+ windowElement?.style?.setProperty("width", cssWidth, "important");
+ windowElement?.style?.setProperty("height", cssHeight, "important");
+ windowElement?.style?.setProperty("transform", "none", "important");
+ windowElement?.style?.setProperty("margin", "0", "important");
+ canvas?.style?.setProperty("width", cssWidth, "important");
+ canvas?.style?.setProperty("height", cssHeight, "important");
+ canvas?.style?.setProperty("display", "block", "important");
+ canvas?.style?.setProperty("margin", "0", "important");
+ },
+
+ installXpraDesktopFrameCss(remoteDocument) {
+ if (!remoteDocument || remoteDocument.getElementById("a0-xpra-desktop-frame-css")) return;
+ const style = remoteDocument.createElement("style");
+ style.id = "a0-xpra-desktop-frame-css";
+ style.textContent = `
+ html, body, #screen {
+ width: 100% !important;
+ height: 100% !important;
+ overflow: hidden !important;
+ }
+ #float_menu,
+ .windowhead,
+ .windowbuttons {
+ display: none !important;
+ }
+ .window,
+ .window.border,
+ .window.desktop,
+ .undecorated,
+ .undecorated.border,
+ .undecorated.desktop {
+ left: 0 !important;
+ top: 0 !important;
+ position: absolute !important;
+ width: 100% !important;
+ height: 100% !important;
+ transform: none !important;
+ margin: 0 !important;
+ border: 0 !important;
+ border-radius: 0 !important;
+ box-shadow: none !important;
+ }
+ .window canvas,
+ .undecorated canvas {
+ display: block !important;
+ width: 100% !important;
+ height: 100% !important;
+ margin: 0 !important;
+ border: 0 !important;
+ border-radius: 0 !important;
+ box-shadow: none !important;
+ }
+ `;
+ remoteDocument.head?.appendChild(style);
+ },
+
+ queueDesktopResize(options = {}) {
+ if (!this.hasOfficialOffice()) return;
+ const token = this.session?.desktop?.token || "";
+ const frame = this.desktopFrame();
+ const target = frame?.parentElement || frame;
+ if (!token || !target) return;
+ const force = Boolean(options.force);
+ const rect = target.getBoundingClientRect();
+ const width = Math.round(rect.width);
+ const height = Math.round(rect.height);
+ if (width < 320 || height < 220) return;
+ this.applyXpraDesktopFrameMode(frame, { requestServerResize: false, requestRefresh: false });
+ if (!force && this.shouldDeferDesktopResize()) {
+ this._desktopResizePending = true;
+ return;
}
- const frame = root.querySelector?.("iframe[data-office-frame]");
- if (frame) {
- frame.setAttribute("name", root.dataset.officeFrameName);
- frame.name = root.dataset.officeFrameName;
+ const key = `${token}:${width}x${height}`;
+ if (!force && key === this._desktopResizeKey) return;
+ if (options.serverResize !== true) {
+ this._desktopResizeKey = key;
+ return;
+ }
+ if (this._desktopResizeTimer) globalThis.clearTimeout(this._desktopResizeTimer);
+ this._desktopResizeTimer = globalThis.setTimeout(async () => {
+ this._desktopResizeTimer = null;
+ if (!force && this.shouldDeferDesktopResize()) {
+ this._desktopResizePending = true;
+ return;
+ }
try {
- frame.contentWindow.name = root.dataset.officeFrameName;
- } catch {}
- }
- this.frameName = root.dataset.officeFrameName;
- return this.frameName;
+ const params = new URLSearchParams({ token, width: String(width), height: String(height) });
+ const response = await fetch(`/desktop/resize?${params.toString()}`, { credentials: "same-origin" });
+ if (response.ok) {
+ const result = await response.json().catch(() => ({}));
+ this._desktopResizeKey = key;
+ if (result?.reload) {
+ this.reloadDesktopFrame(frame);
+ }
+ this.primeXpraDesktopFrame({ reset: true });
+ }
+ } catch (error) {
+ console.warn("Desktop resize skipped", error);
+ }
+ }, DESKTOP_RESIZE_DELAY_MS);
},
- activeFrame() {
- this.assignFrameName();
- return this._root?.querySelector?.("iframe[data-office-frame]") || null;
+ reloadDesktopFrame(frame = null) {
+ const target = this.desktopFrame(frame);
+ if (!target) return;
+ const current = target.getAttribute("src") || target.src || this.officialOfficeUrl();
+ if (!current) return;
+ try {
+ const url = new URL(current, window.location.href);
+ url.searchParams.set("a0_reload", String(Date.now()));
+ target.setAttribute("src", `${url.pathname}${url.search}`);
+ } catch {
+ target.setAttribute("src", current);
+ }
+ },
+
+ startDesktopMonitor() {
+ this.stopDesktopMonitor();
+ if (!this.hasOfficialOffice()) return;
+ const tabId = this.session?.tab_id || "";
+ const sessionId = this.session?.desktop_session_id || this.session?.session_id || "";
+ if (!tabId || !sessionId) return;
+ this._desktopHeartbeatSessionId = sessionId;
+ this._desktopHeartbeatTabId = tabId;
+ this._desktopHeartbeatMisses = 0;
+
+ const tick = async () => {
+ if (!this.session || this.session.tab_id !== tabId || !this.hasOfficialOffice()) return;
+ try {
+ const response = await callOffice("desktop_sync", {
+ desktop_session_id: sessionId,
+ file_id: this.session.file_id || "",
+ });
+ if (response?.ok === false) throw new Error(response.error || "Desktop session closed.");
+ this._desktopHeartbeatMisses = 0;
+ if (response?.document) {
+ const document = normalizeDocument(response.document);
+ this.replaceActiveSession({
+ ...this.session,
+ document,
+ path: document.path || this.session.path,
+ file_id: document.file_id || this.session.file_id,
+ version: document.version || this.session.version,
+ });
+ }
+ } catch {
+ if (!this.session || this.session.tab_id !== tabId) return;
+ this._desktopHeartbeatMisses += 1;
+ if (this._desktopHeartbeatMisses >= 2) {
+ await this.handleOfficialOfficeClosed(tabId);
+ }
+ }
+ };
+
+ this._desktopHeartbeatTimer = globalThis.setInterval(tick, DESKTOP_HEARTBEAT_MS);
+ globalThis.setTimeout(tick, Math.min(1200, DESKTOP_HEARTBEAT_MS));
+ },
+
+ stopDesktopMonitor() {
+ if (this._desktopHeartbeatTimer) {
+ globalThis.clearInterval(this._desktopHeartbeatTimer);
+ }
+ this._desktopHeartbeatTimer = null;
+ this._desktopHeartbeatSessionId = "";
+ this._desktopHeartbeatTabId = "";
+ this._desktopHeartbeatMisses = 0;
+ },
+
+ async handleOfficialOfficeClosed(tabId) {
+ const tab = this.tabs.find((item) => item.tab_id === tabId);
+ if (!tab || tab._desktopClosed) return;
+ tab._desktopClosed = true;
+ this.stopDesktopMonitor();
+ this.stopDesktopResizeObserver();
+ this.stopXpraDesktopPrime();
+ this.setMessage("Desktop is restarting");
+ await this.ensureDesktopSession({ force: true, select: this.activeTabId === tabId });
+ tab._desktopClosed = false;
+ await this.refresh();
+ },
+
+ hasNativeDocxTiles() {
+ return Boolean(
+ this.isDocx()
+ && this.session?.native?.available
+ && Array.isArray(this.session?.tiles)
+ && this.session.tiles.some((tile) => tile?.image),
+ );
+ },
+
+ async onNativeDocxClick(event) {
+ if (!this.hasNativeDocxTiles()) return;
+ const page = event.target?.closest?.(".office-docx-page.is-native-tile");
+ const image = page?.querySelector?.("img");
+ if (!page || !image) return;
+ const twips = this.decodeTileTwips(page);
+ const rect = image.getBoundingClientRect();
+ const ratioX = Math.max(0, Math.min(1, (event.clientX - rect.left) / Math.max(1, rect.width)));
+ const ratioY = Math.max(0, Math.min(1, (event.clientY - rect.top) / Math.max(1, rect.height)));
+ const x = Math.round((twips.x || 0) + ratioX * (twips.width || 0));
+ const y = Math.round((twips.y || 0) + ratioY * (twips.height || 0));
+ this._docxEditor?.focus?.({ preventScroll: true });
+ await this.sendNativeMouse({ type: "down", x, y, count: 1, buttons: 1, modifier: 0 });
+ await this.sendNativeMouse({ type: "up", x, y, count: 1, buttons: 1, modifier: 0 });
+ },
+
+ onNativeDocxKeydown(event) {
+ if (!this.hasNativeDocxTiles()) return;
+ if (event.ctrlKey || event.metaKey || event.altKey) return;
+ const key = event.key || "";
+ if (key.length === 1) {
+ event.preventDefault();
+ void this.sendNativeKey({ text: key });
+ return;
+ }
+ const special = {
+ Enter: { text: "\n" },
+ Tab: { text: "\t" },
+ Backspace: { char_code: 0, key_code: 8 },
+ Delete: { char_code: 0, key_code: 127 },
+ ArrowLeft: { char_code: 0, key_code: 37 },
+ ArrowUp: { char_code: 0, key_code: 38 },
+ ArrowRight: { char_code: 0, key_code: 39 },
+ ArrowDown: { char_code: 0, key_code: 40 },
+ }[key];
+ if (!special) return;
+ event.preventDefault();
+ if (special.text != null) {
+ void this.sendNativeKey({ text: special.text });
+ } else {
+ void this.sendNativeKey({ type: "down", ...special }).then(() => this.sendNativeKey({ type: "up", ...special }));
+ }
+ },
+
+ decodeTileTwips(page) {
+ try {
+ return JSON.parse(decodeURIComponent(page?.dataset?.twips || "{}"));
+ } catch {
+ return {};
+ }
+ },
+
+ async sendNativeKey(key) {
+ if (!this.session?.session_id) return null;
+ return await this.queueNativeEvent(async () => {
+ const response = await this.sendNativeEvent("office_key", "key", key, "key");
+ if (response?.ok) this.markDirty();
+ return response;
+ });
+ },
+
+ async sendNativeMouse(mouse) {
+ if (!this.session?.session_id) return null;
+ return await this.queueNativeEvent(() => this.sendNativeEvent("office_mouse", "mouse", mouse, "mouse"));
+ },
+
+ async queueNativeEvent(task) {
+ const run = this._nativeEventQueue.catch(() => null).then(task);
+ this._nativeEventQueue = run.catch(() => null);
+ return await run;
+ },
+
+ async awaitNativeEvents() {
+ await this._nativeEventQueue.catch(() => null);
+ },
+
+ async sendNativeEvent(socketEvent, apiAction, payload, key) {
+ try {
+ let response;
+ try {
+ response = await requestOffice(socketEvent, {
+ session_id: this.session.session_id,
+ [key]: payload,
+ }, 7000);
+ } catch (_socketError) {
+ response = await callOffice(apiAction, {
+ session_id: this.session.session_id,
+ [key]: payload,
+ });
+ }
+ if (response?.metadata && this.session) {
+ this.session.native = { ...(this.session.native || {}), ...response.metadata, available: true };
+ }
+ if (Array.isArray(response?.tiles) && this.session) {
+ this.session.tiles = response.tiles;
+ this.queueRender({ force: true, focus: true });
+ }
+ return response;
+ } catch (error) {
+ console.warn("LibreOffice native event skipped", socketEvent, error);
+ return null;
+ }
+ },
+
+ isPreviewOnly() {
+ return Boolean(this.session && !this.hasOfficialOffice() && !this.isMarkdown() && !this.isDocx());
+ },
+
+ defaultTitle(kind, fmt) {
+ const date = new Date().toISOString().slice(0, 10);
+ if (fmt === "md") return `Document ${date}`;
+ if (fmt === "docx") return `DOCX ${date}`;
+ if (kind === "spreadsheet") return `Spreadsheet ${date}`;
+ if (kind === "presentation") return `Presentation ${date}`;
+ return `Document ${date}`;
+ },
+
+ tabTitle(tab = {}) {
+ return tab.title || tab.document?.basename || basename(tab.path);
+ },
+
+ tabLabel(tab = {}) {
+ const title = this.tabTitle(tab);
+ return tab.dirty ? `${title} unsaved` : title;
+ },
+
+ tabIcon(tab = {}) {
+ const ext = String(tab.extension || tab.document?.extension || "").toLowerCase();
+ if (this.isDesktopSession(tab)) return "desktop_windows";
+ if (ext === "md") return "article";
+ if (ext === "docx") return "description";
+ if (ext === "xlsx") return "table_chart";
+ if (ext === "pptx") return "co_present";
+ return "draft";
+ },
+
+ documentPath() {
+ return this.session?.document?.path || this.session?.path || "";
+ },
+
+ documentMeta(doc = this.session?.document || this.session || {}) {
+ const parts = [String(doc.extension || "").toUpperCase(), formatBytes(doc.size)].filter(Boolean);
+ return parts.join(" · ");
+ },
+
+ openCards() {
+ return this.tabs
+ .filter((tab) => !this.isDesktopSession(tab))
+ .map((tab) => normalizeDocument({
+ ...tab.document,
+ ...tab,
+ open: true,
+ }));
+ },
+
+ recentCards() {
+ const openIds = new Set(this.tabs.map((tab) => tab.file_id).filter(Boolean));
+ return this.recent.filter((doc) => !openIds.has(doc.file_id)).slice(0, 8);
+ },
+
+ previewKind(doc = {}) {
+ const ext = String(doc.extension || "").toLowerCase();
+ if (ext === "xlsx") return "spreadsheet";
+ if (ext === "pptx") return "presentation";
+ if (ext === "md") return "markdown";
+ return "document";
+ },
+
+ hasPreview(doc = {}) {
+ const preview = doc.preview || {};
+ return Boolean(
+ (Array.isArray(preview.lines) && preview.lines.length)
+ || (Array.isArray(preview.rows) && preview.rows.length)
+ || (Array.isArray(preview.slides) && preview.slides.length)
+ );
+ },
+
+ previewLines(doc = {}) {
+ const preview = doc.preview || {};
+ return (preview.lines || []).slice(0, 8);
+ },
+
+ previewRows(doc = {}) {
+ const preview = doc.preview || {};
+ return (preview.rows || []).slice(0, 6);
+ },
+
+ previewSlides(doc = {}) {
+ const preview = doc.preview || {};
+ return (preview.slides || []).slice(0, 3);
+ },
+
+ dashboardTitle(doc = {}) {
+ return doc.title || doc.basename || basename(doc.path);
+ },
+
+ dashboardMeta(doc = {}) {
+ return [String(doc.extension || "").toUpperCase(), doc.open ? "Open" : "", formatBytes(doc.size)].filter(Boolean).join(" · ");
},
setupFloatingModal(element = null) {
- this._floatingCleanup?.();
const root = element || globalThis.document?.querySelector(".office-panel");
- const modal = root?.closest?.(".modal");
- const inner = modal?.querySelector?.(".modal-inner");
- const body = modal?.querySelector?.(".modal-bd");
- const header = modal?.querySelector?.(".modal-header");
- if (!modal || !inner || !header) return;
- modal.classList.add("modal-floating");
+ const inner = root?.closest?.(".modal-inner");
+ const body = root?.closest?.(".modal-bd");
+ const header = inner?.querySelector?.(".modal-header");
+ if (!inner || !body || !header || inner.dataset.officeModalReady === "1") return;
+
+ inner.dataset.officeModalReady = "1";
inner.classList.add("office-modal", "modal-no-backdrop");
- body?.classList?.add("office-modal-body");
+ body.classList.add("office-modal-body");
+ header.style.cursor = "move";
- const rect = inner.getBoundingClientRect();
- inner.style.left = `${Math.max(8, rect.left)}px`;
- inner.style.top = `${Math.max(8, rect.top)}px`;
- inner.style.transform = "none";
+ const inset = 8;
+ const minWidth = 720;
+ const minHeight = 520;
+ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
+ const cleanup = [];
+ let beforeFocusBounds = null;
+ let dragging = false;
+ let resizing = false;
+ let pointerId = 0;
+ let startX = 0;
+ let startY = 0;
+ let startLeft = 0;
+ let startTop = 0;
+ let startWidth = 0;
+ let startHeight = 0;
+ let resizeMode = "";
- let drag = null;
- let resizeObserver = null;
- const viewportGap = 8;
- const clampPosition = (left, top) => {
- const bounds = inner.getBoundingClientRect();
- const maxLeft = Math.max(viewportGap, globalThis.innerWidth - bounds.width - viewportGap);
- const maxTop = Math.max(viewportGap, globalThis.innerHeight - bounds.height - viewportGap);
+ const currentBounds = () => {
+ const rect = inner.getBoundingClientRect();
return {
- left: Math.min(Math.max(viewportGap, left), maxLeft),
- top: Math.min(Math.max(viewportGap, top), maxTop),
+ left: rect.left,
+ top: rect.top,
+ width: rect.width,
+ height: rect.height,
};
};
- const clampGeometry = () => {
- const bounds = inner.getBoundingClientRect();
- const left = Math.max(viewportGap, bounds.left);
- const top = Math.max(viewportGap, bounds.top);
- const maxWidth = Math.max(340, globalThis.innerWidth - viewportGap * 2);
- const maxHeight = Math.max(360, globalThis.innerHeight - viewportGap * 2);
- if (bounds.width > maxWidth) inner.style.width = `${maxWidth}px`;
- if (bounds.height > maxHeight) inner.style.height = `${maxHeight}px`;
- const next = clampPosition(left, top);
- inner.style.left = `${next.left}px`;
- inner.style.top = `${next.top}px`;
- inner.style.maxWidth = `${Math.max(340, globalThis.innerWidth - next.left - viewportGap)}px`;
- inner.style.maxHeight = `${Math.max(360, globalThis.innerHeight - next.top - viewportGap)}px`;
- };
- clampGeometry();
- globalThis.addEventListener("resize", clampGeometry);
- if (globalThis.ResizeObserver) {
- resizeObserver = new ResizeObserver(clampGeometry);
- resizeObserver.observe(inner);
- }
- const onPointerMove = (event) => {
- if (!drag) return;
- const next = clampPosition(
- drag.left + event.clientX - drag.x,
- drag.top + event.clientY - drag.y,
- );
- inner.style.left = `${next.left}px`;
- inner.style.top = `${next.top}px`;
- clampGeometry();
- };
- const onPointerUp = () => {
- drag = null;
- globalThis.removeEventListener("pointermove", onPointerMove);
- globalThis.removeEventListener("pointerup", onPointerUp);
- try {
- header.releasePointerCapture?.(header.__officePanelPointerId || 0);
- } catch {}
- };
- const onPointerDown = (event) => {
- if (event.button !== 0) return;
- if (event.target?.closest?.("button, input, select, textarea, a")) return;
- const current = inner.getBoundingClientRect();
- drag = {
- x: event.clientX,
- y: event.clientY,
- left: current.left,
- top: current.top,
+ const normalizedBounds = (bounds) => {
+ const maxWidth = Math.max(320, globalThis.innerWidth - inset * 2);
+ const maxHeight = Math.max(320, globalThis.innerHeight - inset * 2);
+ const safeMinWidth = Math.min(minWidth, maxWidth);
+ const safeMinHeight = Math.min(minHeight, maxHeight);
+ const width = clamp(bounds.width, safeMinWidth, maxWidth);
+ const height = clamp(bounds.height, safeMinHeight, maxHeight);
+ return {
+ width,
+ height,
+ left: clamp(bounds.left, inset, Math.max(inset, globalThis.innerWidth - width - inset)),
+ top: clamp(bounds.top, inset, Math.max(inset, globalThis.innerHeight - height - inset)),
};
- header.__officePanelPointerId = event.pointerId;
- header.setPointerCapture?.(event.pointerId);
- globalThis.addEventListener("pointermove", onPointerMove);
- globalThis.addEventListener("pointerup", onPointerUp);
- event.preventDefault();
};
- header.addEventListener("pointerdown", onPointerDown);
- this._floatingCleanup = () => {
- header.removeEventListener("pointerdown", onPointerDown);
- globalThis.removeEventListener("pointermove", onPointerMove);
- globalThis.removeEventListener("pointerup", onPointerUp);
- globalThis.removeEventListener("resize", clampGeometry);
- resizeObserver?.disconnect?.();
+ const setBounds = (bounds) => {
+ const next = normalizedBounds(bounds);
+ inner.style.position = "fixed";
+ inner.style.transform = "none";
+ inner.style.left = `${Math.round(next.left)}px`;
+ inner.style.top = `${Math.round(next.top)}px`;
+ inner.style.width = `${Math.round(next.width)}px`;
+ inner.style.height = `${Math.round(next.height)}px`;
+ inner.style.right = "auto";
+ inner.style.bottom = "auto";
+ inner.style.margin = "0";
};
- },
- setupCanvasSurface(element = null) {
- this._floatingCleanup?.();
- this._floatingCleanup = null;
- if (element) this._root = element;
- },
+ const ensurePosition = () => {
+ setBounds(currentBounds());
+ };
- async prepareBrowserHostForEditor() {
- await this.cleanupLegacyOfficeServiceWorkers();
- this.resetCollaboraBrowserState();
- },
+ const shield = globalThis.document.createElement("div");
+ shield.className = "office-modal-input-shield";
+ inner.appendChild(shield);
+ cleanup.push(() => shield.remove());
- async cleanupLegacyOfficeServiceWorkers() {
- const serviceWorker = globalThis.navigator?.serviceWorker;
- if (!serviceWorker?.getRegistrations) return;
- let removedController = false;
- try {
- const registrations = await serviceWorker.getRegistrations();
- const currentOrigin = globalThis.location.origin;
- const officePath = "/office/";
- for (const registration of registrations) {
- const scope = new URL(registration.scope);
- if (scope.origin !== currentOrigin) continue;
- const scopePath = scope.pathname.endsWith("/") ? scope.pathname : `${scope.pathname}/`;
- const affectsOffice = scopePath === "/" || scopePath.startsWith(officePath) || officePath.startsWith(scopePath);
- if (!affectsOffice) continue;
- const scriptUrl = registration.active?.scriptURL || "";
- if (scriptUrl.endsWith("/js/sw.js") && scopePath === "/js/") continue;
- removedController = await registration.unregister() || removedController;
- }
- const controllerUrl = serviceWorker.controller?.scriptURL || "";
- if (removedController && controllerUrl.startsWith(currentOrigin)) {
- const alreadyReloaded = sessionStorage.getItem(SERVICE_WORKER_CLEANUP_MARKER) === "1";
- if (!alreadyReloaded) {
- sessionStorage.setItem(SERVICE_WORKER_CLEANUP_MARKER, "1");
- globalThis.location.reload();
- }
- }
- } catch (error) {
- console.warn("Office service worker cleanup skipped", error);
+ const setShield = (visible, cursor = "") => {
+ shield.style.display = visible ? "block" : "none";
+ shield.style.cursor = cursor;
+ };
+
+ const focusButton = globalThis.document.createElement("button");
+ focusButton.type = "button";
+ focusButton.className = "modal-dock-button office-modal-focus-button";
+ focusButton.innerHTML = 'fullscreen';
+ const updateFocusButton = (active) => {
+ focusButton.title = active ? "Restore size" : "Focus mode";
+ focusButton.setAttribute("aria-label", focusButton.title);
+ focusButton.querySelector(".material-symbols-outlined").textContent = active ? "fullscreen_exit" : "fullscreen";
+ };
+ updateFocusButton(false);
+ const closeButton = inner.querySelector(".modal-close");
+ if (closeButton) {
+ closeButton.insertAdjacentElement("beforebegin", focusButton);
+ } else {
+ header.appendChild(focusButton);
}
- },
+ cleanup.push(() => focusButton.remove());
- resetCollaboraBrowserState(options = {}) {
- const force = Boolean(options.force);
- try {
- if (!force && localStorage.getItem(COLLABORA_STATE_MARKER) === COLLABORA_STATE_VERSION) {
+ const setFocusMode = (enabled) => {
+ ensurePosition();
+ if (enabled) {
+ beforeFocusBounds = currentBounds();
+ inner.classList.add("is-focus-mode");
+ setBounds({
+ left: inset,
+ top: inset,
+ width: globalThis.innerWidth - inset * 2,
+ height: globalThis.innerHeight - inset * 2,
+ });
+ updateFocusButton(true);
return;
}
- const exactKeys = new Set([
- "UIDefaults",
- "WSDFeedbackCount",
- "WSDFeedbackTimestamp",
- ]);
- const collaboraKeyPattern = /^(text|spreadsheet|presentation|drawing)\.[A-Za-z0-9_.-]+$/;
- for (const key of Object.keys(localStorage)) {
- if (exactKeys.has(key) || collaboraKeyPattern.test(key)) {
- localStorage.removeItem(key);
- }
- }
- localStorage.setItem(COLLABORA_STATE_MARKER, COLLABORA_STATE_VERSION);
- } catch (error) {
- console.warn("Office browser state cleanup skipped", error);
+ inner.classList.remove("is-focus-mode");
+ setBounds(beforeFocusBounds || currentBounds());
+ beforeFocusBounds = null;
+ updateFocusButton(false);
+ };
+
+ const onFocusClick = () => setFocusMode(!inner.classList.contains("is-focus-mode"));
+ focusButton.addEventListener("click", onFocusClick);
+ cleanup.push(() => focusButton.removeEventListener("click", onFocusClick));
+
+ const onPointerDown = (event) => {
+ if (event.button !== 0) return;
+ if (event.target?.closest?.("button,a,input,textarea,select")) return;
+ if (inner.classList.contains("is-focus-mode")) return;
+ ensurePosition();
+ const rect = inner.getBoundingClientRect();
+ dragging = true;
+ pointerId = event.pointerId;
+ startX = event.clientX;
+ startY = event.clientY;
+ startLeft = rect.left;
+ startTop = rect.top;
+ startWidth = rect.width;
+ startHeight = rect.height;
+ inner.classList.add("is-dragging");
+ setShield(true, "move");
+ header.setPointerCapture?.(pointerId);
+ event.preventDefault();
+ };
+
+ const onPointerMove = (event) => {
+ if (!dragging || event.pointerId !== pointerId) return;
+ setBounds({
+ left: startLeft + event.clientX - startX,
+ top: startTop + event.clientY - startY,
+ width: startWidth,
+ height: startHeight,
+ });
+ };
+
+ const onPointerUp = (event) => {
+ if (!dragging || event.pointerId !== pointerId) return;
+ dragging = false;
+ inner.classList.remove("is-dragging");
+ setShield(false);
+ header.releasePointerCapture?.(pointerId);
+ };
+
+ const createResizeHandle = (mode) => {
+ const handle = globalThis.document.createElement("div");
+ handle.className = `office-modal-resizer is-${mode}`;
+ handle.dataset.officeResize = mode;
+ inner.appendChild(handle);
+ cleanup.push(() => handle.remove());
+ return handle;
+ };
+
+ const onResizeDown = (event) => {
+ if (event.button !== 0 || inner.classList.contains("is-focus-mode")) return;
+ ensurePosition();
+ const rect = inner.getBoundingClientRect();
+ resizing = true;
+ resizeMode = event.currentTarget.dataset.officeResize || "";
+ pointerId = event.pointerId;
+ startX = event.clientX;
+ startY = event.clientY;
+ startLeft = rect.left;
+ startTop = rect.top;
+ startWidth = rect.width;
+ startHeight = rect.height;
+ inner.classList.add("is-resizing");
+ this.suspendDesktopResize();
+ setShield(true, resizeMode === "right" ? "ew-resize" : resizeMode === "bottom" ? "ns-resize" : "nwse-resize");
+ event.currentTarget.setPointerCapture?.(pointerId);
+ event.preventDefault();
+ event.stopPropagation();
+ };
+
+ const onResizeMove = (event) => {
+ if (!resizing || event.pointerId !== pointerId) return;
+ const dx = event.clientX - startX;
+ const dy = event.clientY - startY;
+ setBounds({
+ left: startLeft,
+ top: startTop,
+ width: resizeMode === "bottom" ? startWidth : startWidth + dx,
+ height: resizeMode === "right" ? startHeight : startHeight + dy,
+ });
+ };
+
+ const onResizeUp = (event) => {
+ if (!resizing || event.pointerId !== pointerId) return;
+ resizing = false;
+ resizeMode = "";
+ inner.classList.remove("is-resizing");
+ setShield(false);
+ event.currentTarget.releasePointerCapture?.(pointerId);
+ this.resumeDesktopResize();
+ };
+
+ header.addEventListener("pointerdown", onPointerDown);
+ header.addEventListener("pointermove", onPointerMove);
+ header.addEventListener("pointerup", onPointerUp);
+ header.addEventListener("pointercancel", onPointerUp);
+ cleanup.push(() => header.removeEventListener("pointerdown", onPointerDown));
+ cleanup.push(() => header.removeEventListener("pointermove", onPointerMove));
+ cleanup.push(() => header.removeEventListener("pointerup", onPointerUp));
+ cleanup.push(() => header.removeEventListener("pointercancel", onPointerUp));
+
+ for (const mode of ["right", "bottom", "corner"]) {
+ const handle = createResizeHandle(mode);
+ handle.addEventListener("pointerdown", onResizeDown);
+ handle.addEventListener("pointermove", onResizeMove);
+ handle.addEventListener("pointerup", onResizeUp);
+ handle.addEventListener("pointercancel", onResizeUp);
+ cleanup.push(() => handle.removeEventListener("pointerdown", onResizeDown));
+ cleanup.push(() => handle.removeEventListener("pointermove", onResizeMove));
+ cleanup.push(() => handle.removeEventListener("pointerup", onResizeUp));
+ cleanup.push(() => handle.removeEventListener("pointercancel", onResizeUp));
}
+
+ const onWindowResize = () => {
+ if (inner.classList.contains("is-focus-mode")) {
+ setBounds({
+ left: inset,
+ top: inset,
+ width: globalThis.innerWidth - inset * 2,
+ height: globalThis.innerHeight - inset * 2,
+ });
+ return;
+ }
+ ensurePosition();
+ };
+ globalThis.addEventListener("resize", onWindowResize);
+ cleanup.push(() => globalThis.removeEventListener("resize", onWindowResize));
+
+ if (globalThis.requestAnimationFrame) {
+ globalThis.requestAnimationFrame(ensurePosition);
+ } else {
+ globalThis.setTimeout(ensurePosition, 0);
+ }
+ this._floatingCleanup = () => {
+ cleanup.splice(0).reverse().forEach((entry) => entry());
+ inner.classList.remove("is-dragging", "is-resizing", "is-focus-mode");
+ this._desktopResizeSuspended = false;
+ this._desktopResizePending = false;
+ delete inner.dataset.officeModalReady;
+ };
},
};
diff --git a/webui/components/canvas/right-canvas-store.js b/webui/components/canvas/right-canvas-store.js
index 4d73538a7..f6fb5d5e2 100644
--- a/webui/components/canvas/right-canvas-store.js
+++ b/webui/components/canvas/right-canvas-store.js
@@ -51,11 +51,6 @@ const model = {
await callJsExtensions("right_canvas_register_surfaces", this);
this._registering = false;
this.ensureActiveSurface();
- if (this.isOpen && this.activeSurfaceId) {
- globalThis.requestAnimationFrame?.(() => {
- void this.open(this.activeSurfaceId, this._lastPayloadBySurface[this.activeSurfaceId] || {});
- });
- }
}
},
@@ -103,6 +98,9 @@ const model = {
if (!surface) {
return false;
}
+ if (this.isMobileMode && !surface.actionOnly) {
+ return false;
+ }
if (typeof surface.canOpen === "function" && surface.canOpen(payload) === false) {
return false;
}
@@ -143,6 +141,9 @@ const model = {
},
async dockSurface(surfaceId, payload = {}) {
+ if (this.isMobileMode) {
+ return false;
+ }
const surface = this.getSurface(surfaceId);
if (!surface) {
return false;
@@ -228,6 +229,9 @@ const model = {
},
async toggleCanvas() {
+ if (this.isMobileMode) {
+ return false;
+ }
if (this.isOpen) {
await this.close();
return false;
@@ -256,6 +260,7 @@ const model = {
if (this.isOverlayMode || this.isMobileMode || !this.isOpen) return;
if (event.button !== 0) return;
event.preventDefault();
+ this.dispatchResizeEvent("right-canvas-resize-start");
const onPointerMove = (moveEvent) => {
const nextWidth = viewportWidth() - moveEvent.clientX;
@@ -264,13 +269,29 @@ const model = {
const onPointerUp = () => {
globalThis.removeEventListener("pointermove", onPointerMove);
globalThis.removeEventListener("pointerup", onPointerUp);
+ globalThis.removeEventListener("pointercancel", onPointerUp);
document.body.classList.remove("right-canvas-resizing");
this.persist();
+ this.dispatchResizeEvent("right-canvas-resize-end");
};
document.body.classList.add("right-canvas-resizing");
globalThis.addEventListener("pointermove", onPointerMove);
globalThis.addEventListener("pointerup", onPointerUp);
+ globalThis.addEventListener("pointercancel", onPointerUp);
+ },
+
+ dispatchResizeEvent(name) {
+ try {
+ globalThis.dispatchEvent(new CustomEvent(name, {
+ detail: {
+ width: this.width,
+ activeSurfaceId: this.activeSurfaceId,
+ },
+ }));
+ } catch {
+ // Resize events are an optimization hook for embedded surfaces.
+ }
},
persist() {
@@ -292,7 +313,7 @@ const model = {
this.width = this.defaultWidth();
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
- this.isOpen = Boolean(saved.isOpen);
+ this.isOpen = false;
this.activeSurfaceId = String(saved.activeSurfaceId || "");
if (saved.width) this.width = Number(saved.width);
} catch (error) {
@@ -303,14 +324,28 @@ const model = {
updateLayoutMode() {
const width = viewportWidth();
+ const wasMobileMode = this.isMobileMode;
this.isOverlayMode = width < DESKTOP_BREAKPOINT;
this.isMobileMode = width <= MOBILE_BREAKPOINT;
+ if (this.isMobileMode) {
+ const wasOpen = this.isOpen;
+ const surface = wasOpen ? this.currentSurface() : null;
+ const payload = this._lastPayloadBySurface[this.activeSurfaceId] || {};
+ this.isOpen = false;
+ if (surface && wasOpen) {
+ globalThis.setTimeout?.(() => {
+ surface.close?.({ ...payload, reason: "mobile" });
+ }, 0);
+ }
+ } else if (wasMobileMode && this.width <= MIN_WIDTH) {
+ this.width = this.defaultWidth();
+ }
},
applyLayoutState() {
this.updateLayoutMode();
document.documentElement.style.setProperty("--right-canvas-width", `${this.width}px`);
- document.body.classList.toggle("right-canvas-open", this.isOpen);
+ document.body.classList.toggle("right-canvas-open", this.isOpen && !this.isMobileMode);
document.body.classList.toggle("right-canvas-overlay-mode", this.isOverlayMode);
document.body.classList.toggle("right-canvas-mobile-mode", this.isMobileMode);
},
@@ -347,6 +382,10 @@ const model = {
activeTitle() {
return this.currentSurface()?.title || "Canvas";
},
+
+ shouldRender() {
+ return !this.isMobileMode;
+ },
};
export const store = createStore("rightCanvas", model);
diff --git a/webui/components/canvas/right-canvas.css b/webui/components/canvas/right-canvas.css
index ed5767f19..3c80d09ba 100644
--- a/webui/components/canvas/right-canvas.css
+++ b/webui/components/canvas/right-canvas.css
@@ -299,35 +299,9 @@ body.right-canvas-overlay-mode .right-canvas-resize-handle {
display: none;
}
-body.right-canvas-mobile-mode .right-canvas {
- left: 0;
- width: 100vw !important;
- max-width: 100vw;
- min-width: 100vw;
- border-left: 0;
-}
-
-body.right-canvas-mobile-mode .right-canvas.is-closed {
- transform: translateX(100%);
-}
-
+body.right-canvas-mobile-mode .right-canvas,
body.right-canvas-mobile-mode .right-canvas-rail {
- display: flex;
-}
-
-body.right-canvas-mobile-mode .right-canvas.is-open .right-canvas-rail {
- display: none;
-}
-
-body.right-canvas-mobile-mode .right-canvas-header {
- min-height: 48px;
- padding: 7px 8px 0;
-}
-
-body.right-canvas-mobile-mode .right-canvas-tab {
- min-width: 42px;
- justify-content: center;
- padding: 0 9px;
+ display: none !important;
}
@media (max-width: 480px) {
diff --git a/webui/components/canvas/right-canvas.html b/webui/components/canvas/right-canvas.html
index 3a33d8e0f..d105dc5a5 100644
--- a/webui/components/canvas/right-canvas.html
+++ b/webui/components/canvas/right-canvas.html
@@ -17,6 +17,7 @@
'is-mobile': $store.rightCanvas.isMobileMode
}"
:style="$store.rightCanvas.widthStyle()"
+ x-show="$store.rightCanvas.shouldRender()"
x-init="$store.rightCanvas.init($el)"
x-effect="$store.rightCanvas.applyLayoutState()"
aria-label="Universal Canvas"