mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-05-19 07:59:34 +00:00
Split live surfaces out of modals
Introduce the shared surfaces frontend service and stylesheet so Browser and Desktop can register docked or floating live UI without special cases in modals.js. Update Browser and right-canvas integration to preserve active viewers across canvas/modal switches and avoid creating blank tabs unless explicitly requested.
This commit is contained in:
parent
2613fac05f
commit
022b6f031f
20 changed files with 1002 additions and 428 deletions
|
|
@ -62,10 +62,14 @@ class WsBrowser(WsHandler):
|
|||
if not AgentContext.get(context_id):
|
||||
return self._error("CONTEXT_NOT_FOUND", f"Context '{context_id}' was not found", data)
|
||||
|
||||
runtime = await get_runtime(context_id)
|
||||
listing = await runtime.call("list")
|
||||
browsers = listing.get("browsers") or []
|
||||
if not browsers:
|
||||
create_browser = self._bool(data.get("create_browser", data.get("createBrowser")))
|
||||
runtime = await get_runtime(context_id, create=create_browser)
|
||||
listing = {"browsers": [], "last_interacted_browser_id": None}
|
||||
browsers: list[dict[str, Any]] = []
|
||||
if runtime:
|
||||
listing = await runtime.call("list")
|
||||
browsers = listing.get("browsers") or []
|
||||
if runtime and not browsers and create_browser:
|
||||
opened = await runtime.call("open", "")
|
||||
listing = await runtime.call("list")
|
||||
browsers = listing.get("browsers") or []
|
||||
|
|
@ -73,7 +77,7 @@ class WsBrowser(WsHandler):
|
|||
listing["last_interacted_browser_id"] = opened.get("id")
|
||||
active_id = self._active_browser_id(listing, data.get("browser_id"))
|
||||
initial_viewport = self._viewport_from_data(data)
|
||||
if active_id and initial_viewport:
|
||||
if runtime and active_id and initial_viewport:
|
||||
await runtime.call(
|
||||
"set_viewport",
|
||||
active_id,
|
||||
|
|
@ -88,9 +92,10 @@ class WsBrowser(WsHandler):
|
|||
if existing:
|
||||
existing.cancel()
|
||||
viewer_id = str(data.get("viewer_id") or "")
|
||||
self._streams[stream_key] = asyncio.create_task(
|
||||
self._stream_frames(sid, context_id, active_id, viewer_id)
|
||||
)
|
||||
if runtime:
|
||||
self._streams[stream_key] = asyncio.create_task(
|
||||
self._stream_frames(sid, context_id, active_id, viewer_id)
|
||||
)
|
||||
|
||||
return {
|
||||
"context_id": context_id,
|
||||
|
|
@ -498,6 +503,14 @@ class WsBrowser(WsHandler):
|
|||
def _context_id(data: dict[str, Any]) -> str:
|
||||
return str(data.get("context_id") or data.get("context") or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _bool(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
@staticmethod
|
||||
def _error(code: str, message: str, data: dict[str, Any]) -> WsResult:
|
||||
return WsResult.error(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ extension_paths: []
|
|||
# Page opened by new Browser sessions when no URL is provided.
|
||||
default_homepage: "about:blank"
|
||||
|
||||
# When the Browser canvas is already open, keep it synced to agent Browser tool results.
|
||||
# When the Browser surface is already open, keep it synced to agent Browser tool results.
|
||||
autofocus_active_page: true
|
||||
|
||||
# Optional _model_config preset used by Browser-owned model helpers.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
aria-label="Open Browser"
|
||||
data-bs-placement="top"
|
||||
data-bs-trigger="hover"
|
||||
@click="$store.rightCanvas ? $store.rightCanvas.open('browser') : (window.ensureModalOpen ? window.ensureModalOpen('/plugins/_browser/webui/main.html') : (window.openModal && window.openModal('/plugins/_browser/webui/main.html')))"
|
||||
@click="import('/js/surfaces.js').then(({ open }) => open('browser'))"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14" aria-hidden="true">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2"></rect>
|
||||
|
|
|
|||
|
|
@ -4,16 +4,15 @@ import {
|
|||
} from "/components/messages/action-buttons/simple-action-buttons.js";
|
||||
import { store as stepDetailStore } from "/components/modals/process-step-detail/step-detail-store.js";
|
||||
import { store as speechStore } from "/components/chat/speech/speech-store.js";
|
||||
import { store as rightCanvasStore } from "/components/canvas/right-canvas-store.js";
|
||||
import { store as browserStore } from "/plugins/_browser/webui/browser-store.js";
|
||||
import { getNamespacedClient } from "/js/websocket.js";
|
||||
import { open as openSurface } from "/js/surfaces.js";
|
||||
import {
|
||||
buildDetailPayload,
|
||||
cleanStepTitle,
|
||||
drawProcessStep,
|
||||
} from "/js/messages.js";
|
||||
|
||||
const BROWSER_MODAL = "/plugins/_browser/webui/main.html";
|
||||
const BROWSER_SCREENSHOT_KVP_KEY = "Screenshot";
|
||||
const BROWSER_SCREENSHOT_STYLE_ID = "a0-browser-screenshot-kvp-style";
|
||||
const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000;
|
||||
|
|
@ -34,19 +33,8 @@ export default async function registerBrowserToolHandler(extData) {
|
|||
}
|
||||
}
|
||||
|
||||
async function openBrowserCanvas(payload = {}) {
|
||||
if (rightCanvasStore?.open) {
|
||||
await rightCanvasStore.open("browser", payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.ensureModalOpen) {
|
||||
await window.ensureModalOpen(BROWSER_MODAL);
|
||||
return;
|
||||
}
|
||||
if (window.openModal) {
|
||||
await window.openModal(BROWSER_MODAL);
|
||||
}
|
||||
async function openBrowserSurface(payload = {}) {
|
||||
await openSurface("browser", payload);
|
||||
}
|
||||
|
||||
async function browserAllowsToolAutofocus() {
|
||||
|
|
@ -115,11 +103,7 @@ function isFreshToolMessage(timestamp) {
|
|||
}
|
||||
|
||||
function isBrowserCanvasAlreadyOpen() {
|
||||
return Boolean(
|
||||
rightCanvasStore?.isOpen
|
||||
&& rightCanvasStore?.activeSurfaceId === "browser"
|
||||
&& !rightCanvasStore?.isMobileMode,
|
||||
);
|
||||
return Boolean(document.querySelector('[data-surface-id="browser"].is-active .browser-panel'));
|
||||
}
|
||||
|
||||
// Allowlist: only these actions sync an already-open viewer to the target tab.
|
||||
|
|
@ -146,15 +130,16 @@ function syncOpenBrowserCanvas(args, result) {
|
|||
if (!shouldSyncOpenBrowserCanvas(args, result)) return;
|
||||
const kvps = args?.kvps || {};
|
||||
const browserId = browserIdFromResult(result, kvps);
|
||||
const key = `${args.id || ""}:${kvps.action || ""}:${browserId || ""}:${result.currentUrl || result.state?.currentUrl || kvps.url || ""}`;
|
||||
const contextId = browserContextIdFromResult(result, kvps);
|
||||
const key = `${args.id || ""}:${contextId || ""}:${kvps.action || ""}:${browserId || ""}:${result.currentUrl || result.state?.currentUrl || kvps.url || ""}`;
|
||||
if (syncedBrowserCanvases.has(key)) return;
|
||||
syncedBrowserCanvases.add(key);
|
||||
requestAnimationFrame(async () => {
|
||||
if (!isBrowserCanvasAlreadyOpen()) return;
|
||||
if (!(await browserAllowsToolAutofocus())) return;
|
||||
void rightCanvasStore.open("browser", {
|
||||
void openSurface("browser", {
|
||||
browserId,
|
||||
contextId: browserContextIdFromResult(result, kvps),
|
||||
contextId,
|
||||
source: "tool-sync",
|
||||
});
|
||||
});
|
||||
|
|
@ -344,7 +329,7 @@ function renderBrowserScreenshotKvp(kvpsTable, resolveBrowserPayload, label) {
|
|||
event.stopPropagation();
|
||||
const canvasPayload = resolveBrowserPayload();
|
||||
if (!canvasPayload) return;
|
||||
await openBrowserCanvas(canvasPayload);
|
||||
await openBrowserSurface(canvasPayload);
|
||||
});
|
||||
|
||||
cell.textContent = "";
|
||||
|
|
@ -465,15 +450,15 @@ function drawBrowserTool({
|
|||
const browserId = browserIdFromResult(browserResult, kvps);
|
||||
const browserCanvasPayload = buildBrowserCanvasPayload(browserResult, kvps);
|
||||
const browserPreviewLabel = browserId
|
||||
? `Open Browser canvas for Browser ${browserId}`
|
||||
: "Open Browser canvas from screenshot";
|
||||
? `Open Browser surface for Browser ${browserId}`
|
||||
: "Open Browser surface from screenshot";
|
||||
if (shouldRenderBrowserScreenshotKvp(browserResult, kvps)) {
|
||||
displayKvps[BROWSER_SCREENSHOT_KVP_KEY] = "";
|
||||
}
|
||||
const browserButton = createActionButton(
|
||||
"visibility",
|
||||
"Browser",
|
||||
() => openBrowserCanvas(
|
||||
() => openBrowserSurface(
|
||||
buildBrowserCanvasPayload(browserResult, kvps, "tool")
|
||||
|| {
|
||||
browserId,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { store as rightCanvasStore } from "/components/canvas/right-canvas-store.js";
|
||||
import { store as browserStore } from "/plugins/_browser/webui/browser-store.js";
|
||||
import { open as openSurface } from "/js/surfaces.js";
|
||||
|
||||
const AUTO_OPEN_WINDOW_MS = 10 * 60 * 1000;
|
||||
const syncedBrowserCanvases = new Set();
|
||||
|
|
@ -19,6 +19,7 @@ export default async function syncBrowserResultsIntoOpenCanvas(context) {
|
|||
const contextId = getBrowserContextId(payload, result);
|
||||
const key = [
|
||||
args?.id || "",
|
||||
contextId || "",
|
||||
browserId || "",
|
||||
result.currentUrl || result.state?.currentUrl || payload.url || "",
|
||||
].join(":");
|
||||
|
|
@ -53,6 +54,8 @@ function pickPayloadFields(args = {}) {
|
|||
"action",
|
||||
"browser_id",
|
||||
"browserId",
|
||||
"context_id",
|
||||
"contextId",
|
||||
"url",
|
||||
"last_modified",
|
||||
]) {
|
||||
|
|
@ -156,15 +159,11 @@ function hasOpened(key, persistedKey) {
|
|||
|
||||
async function syncOpenBrowserCanvas(payload = {}) {
|
||||
if (!isBrowserCanvasAlreadyOpen()) return;
|
||||
await rightCanvasStore.open("browser", payload);
|
||||
await openSurface("browser", payload);
|
||||
}
|
||||
|
||||
function isBrowserCanvasAlreadyOpen() {
|
||||
return Boolean(
|
||||
rightCanvasStore?.isOpen
|
||||
&& rightCanvasStore?.activeSurfaceId === "browser"
|
||||
&& !rightCanvasStore?.isMobileMode,
|
||||
);
|
||||
return Boolean(document.querySelector('[data-surface-id="browser"].is-active .browser-panel'));
|
||||
}
|
||||
|
||||
async function browserAllowsToolAutofocus() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
import { store as browserStore } from "/plugins/_browser/webui/browser-store.js";
|
||||
|
||||
function waitForElement(selector, timeoutMs = 3000) {
|
||||
const found = document.querySelector(selector);
|
||||
if (found) return Promise.resolve(found);
|
||||
return new Promise((resolve) => {
|
||||
const timeout = globalThis.setTimeout(() => {
|
||||
observer.disconnect();
|
||||
resolve(null);
|
||||
}, timeoutMs);
|
||||
const observer = new MutationObserver(() => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) return;
|
||||
globalThis.clearTimeout(timeout);
|
||||
observer.disconnect();
|
||||
resolve(element);
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
});
|
||||
}
|
||||
|
||||
function nextAnimationFrame() {
|
||||
return new Promise((resolve) => {
|
||||
const schedule = globalThis.requestAnimationFrame || ((callback) => globalThis.setTimeout(callback, 16));
|
||||
schedule(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
function isVisibleCanvasPanel(panel) {
|
||||
if (!panel?.isConnected) return false;
|
||||
const surface = panel.closest(".browser-canvas-surface");
|
||||
const stage = panel.querySelector(".browser-stage") || panel;
|
||||
const surfaceStyle = surface ? globalThis.getComputedStyle?.(surface) : null;
|
||||
const panelStyle = globalThis.getComputedStyle?.(panel);
|
||||
if (surfaceStyle?.display === "none" || surfaceStyle?.visibility === "hidden") return false;
|
||||
if (panelStyle?.display === "none" || panelStyle?.visibility === "hidden") return false;
|
||||
const rect = stage.getBoundingClientRect?.();
|
||||
return Boolean(rect && Math.round(rect.width || 0) >= 80 && Math.round(rect.height || 0) >= 80);
|
||||
}
|
||||
|
||||
async function waitForVisibleCanvasPanel(selector, timeoutMs = 3000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let stableKey = "";
|
||||
let stableCount = 0;
|
||||
|
||||
while (Date.now() <= deadline) {
|
||||
const panel = document.querySelector(selector);
|
||||
const visible = isVisibleCanvasPanel(panel);
|
||||
if (visible) {
|
||||
const stage = panel.querySelector(".browser-stage") || panel;
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const key = `${Math.round(rect.width || 0)}x${Math.round(rect.height || 0)}`;
|
||||
if (key === stableKey) {
|
||||
stableCount += 1;
|
||||
if (stableCount >= 2) {
|
||||
return panel;
|
||||
}
|
||||
} else {
|
||||
stableKey = key;
|
||||
stableCount = 0;
|
||||
}
|
||||
} else {
|
||||
stableKey = "";
|
||||
stableCount = 0;
|
||||
}
|
||||
await nextAnimationFrame();
|
||||
}
|
||||
|
||||
return document.querySelector(selector);
|
||||
}
|
||||
|
||||
export default async function registerBrowserSurface(canvas) {
|
||||
canvas.registerSurface({
|
||||
id: "browser",
|
||||
title: "Browser",
|
||||
icon: "language",
|
||||
order: 10,
|
||||
modalPath: "/plugins/_browser/webui/main.html",
|
||||
beginDockHandoff() {
|
||||
browserStore.beginSurfaceHandoff?.();
|
||||
},
|
||||
finishDockHandoff() {
|
||||
browserStore.finishSurfaceHandoff?.();
|
||||
},
|
||||
cancelDockHandoff() {
|
||||
browserStore.cancelSurfaceHandoff?.();
|
||||
},
|
||||
async open(payload = {}) {
|
||||
await waitForElement('[data-surface-id="browser"] .browser-panel');
|
||||
const panel = await waitForVisibleCanvasPanel('[data-surface-id="browser"] .browser-panel');
|
||||
const browser = browserStore;
|
||||
if (panel && browser?.onOpen) {
|
||||
await browser.onOpen(panel, {
|
||||
mode: "canvas",
|
||||
browserId: payload.browserId || payload.browser_id || null,
|
||||
contextId: payload.contextId || payload.context_id || null,
|
||||
});
|
||||
}
|
||||
},
|
||||
async close() {
|
||||
await browserStore.cleanup?.();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@ from helpers import files, plugins
|
|||
from plugins._browser.helpers.config import PLUGIN_NAME, get_browser_config
|
||||
|
||||
|
||||
EXTENSIONS_ROOT_DIR = ("usr", "plugins", PLUGIN_NAME, "extensions")
|
||||
EXTENSIONS_ROOT_DIR = ("usr", "_browser", "extensions")
|
||||
EXTENSION_ID_RE = re.compile(r"^[a-p]{32}$")
|
||||
WEB_STORE_ID_RE = re.compile(r"(?<![a-p])([a-p]{32})(?![a-p])")
|
||||
CHROME_VERSION_RE = re.compile(r"(\d+(?:\.\d+){0,3})")
|
||||
|
|
@ -41,9 +41,7 @@ WEB_STORE_DOWNLOAD_URL = (
|
|||
|
||||
|
||||
def get_extensions_root() -> Path:
|
||||
root = Path(files.get_abs_path(*EXTENSIONS_ROOT_DIR))
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
return Path(files.get_abs_path(*EXTENSIONS_ROOT_DIR))
|
||||
|
||||
|
||||
def parse_chrome_web_store_extension_id(value: str) -> str:
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ use for web browsing, page inspection, forms, downloads, and browser-only tasks
|
|||
state stays open per chat context
|
||||
refs come from content as typed markers: [link 3], [button 6], [image 1], [input text 8]
|
||||
|
||||
Browser tool actions must not open the right canvas automatically. Use the tool headlessly unless the user opens the Browser canvas or explicitly asks for a visible browser view; if the Browser canvas is already open, it may reflect the active page.
|
||||
Browser tool actions must not open a Browser surface automatically. Use the tool headlessly unless the user opens the Browser surface or explicitly asks for a visible browser view; if the Browser surface is already open, it may reflect the active page.
|
||||
|
||||
Browser does not automatically load screenshots or canvas images into model context. Screenshots are explicit only.
|
||||
Browser does not automatically load screenshots or surface images into model context. Screenshots are explicit only.
|
||||
|
||||
actions: open list state set_active navigate back forward reload content detail screenshot click hover double_click right_click drag type submit type_submit scroll evaluate key_chord mouse wheel keyboard clipboard set_viewport select_option set_checked upload_file multi close close_all
|
||||
common args: action browser_id url ref target_ref text selector selectors script modifiers keys key include_content focus_popup event_type x y to_x to_y offset_x offset_y target_offset_x target_offset_y delta_x delta_y button quality full_page path paths value values checked width height calls
|
||||
|
|
@ -38,7 +38,7 @@ pointer and raw input:
|
|||
- keyboard presses key or types text into the active page
|
||||
- clipboard is copy, cut, or paste; for browser:clipboard pass action: "paste" and optional text
|
||||
- set_viewport resizes the page viewport with width and height
|
||||
- coordinates are Chromium viewport CSS pixels and match screenshots/Browser canvas
|
||||
- coordinates are Chromium viewport CSS pixels and match screenshots/Browser surface
|
||||
- ref offsets are relative to the target element top-left; refs default to element center
|
||||
|
||||
forms:
|
||||
|
|
|
|||
|
|
@ -307,6 +307,17 @@
|
|||
background: color-mix(in srgb, var(--color-background) 94%, #000 6%);
|
||||
}
|
||||
|
||||
.modal-inner.browser-modal.is-focus-mode {
|
||||
resize: none;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.modal-inner.browser-modal.is-focus-mode .browser-modal-focus-button {
|
||||
color: var(--color-text);
|
||||
border-color: color-mix(in srgb, var(--color-primary) 42%, var(--color-border));
|
||||
background: color-mix(in srgb, var(--color-primary) 16%, var(--color-background));
|
||||
}
|
||||
|
||||
.modal.modal-floating {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@ import { store as chatInputStore } from "/components/chat/input/input-store.js";
|
|||
import { store as pluginSettingsStore } from "/components/plugins/plugin-settings-store.js";
|
||||
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
|
||||
import { store as rightCanvasStore } from "/components/canvas/right-canvas-store.js";
|
||||
import { openLatest as openLatestSurface, registerUrlHandler } from "/js/surfaces.js";
|
||||
|
||||
const websocket = getNamespacedClient("/ws");
|
||||
websocket.addHandlers(["ws_webui"]);
|
||||
|
||||
const EXTENSIONS_ROOT = "/a0/usr/plugins/_browser/extensions";
|
||||
const EXTENSIONS_ROOT = "/a0/usr/_browser/extensions";
|
||||
const BROWSER_SUBSCRIBE_TIMEOUT_MS = 60000;
|
||||
const BROWSER_FIRST_INSTALL_TIMEOUT_MS = 300000;
|
||||
const BROWSER_CONFIG_REFRESH_MS = 15000;
|
||||
|
|
@ -167,6 +168,7 @@ const model = {
|
|||
_contextCreatePromise: null,
|
||||
_lastSelectedContextId: "",
|
||||
_sessionRefreshPromise: null,
|
||||
_sessionRefreshContextId: "",
|
||||
extensionMenuOpen: false,
|
||||
extensionInstallUrl: "",
|
||||
extensionActionLoading: false,
|
||||
|
|
@ -264,18 +266,24 @@ const model = {
|
|||
if (selectedContextId === this._lastSelectedContextId) return;
|
||||
this._lastSelectedContextId = selectedContextId;
|
||||
if (!this._surfaceMounted) return;
|
||||
void this.refreshBrowserSessions(selectedContextId);
|
||||
void this.syncViewerToSelectedContext(selectedContextId);
|
||||
},
|
||||
|
||||
async refreshBrowserSessions(contextId = "") {
|
||||
const requestedContextId = this.normalizeContextId(contextId || this.resolveContextId());
|
||||
if (this._sessionRefreshPromise) {
|
||||
const inFlightContextId = this._sessionRefreshContextId;
|
||||
await this._sessionRefreshPromise;
|
||||
if (requestedContextId && requestedContextId !== inFlightContextId) {
|
||||
return await this.refreshBrowserSessions(requestedContextId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._sessionRefreshContextId = requestedContextId;
|
||||
this._sessionRefreshPromise = (async () => {
|
||||
const response = await websocket.request(
|
||||
"browser_viewer_sessions",
|
||||
{ context_id: this.normalizeContextId(contextId || this.resolveContextId()) },
|
||||
{ context_id: requestedContextId },
|
||||
{ timeoutMs: 10000 },
|
||||
);
|
||||
const data = firstOk(response);
|
||||
|
|
@ -289,6 +297,45 @@ const model = {
|
|||
console.warn("Browser session refresh failed", error);
|
||||
} finally {
|
||||
this._sessionRefreshPromise = null;
|
||||
this._sessionRefreshContextId = "";
|
||||
}
|
||||
},
|
||||
|
||||
async syncViewerToSelectedContext(contextId = "") {
|
||||
const selectedContextId = this.normalizeContextId(contextId || this.resolveContextId());
|
||||
if (!selectedContextId) return;
|
||||
await this.refreshBrowserSessions(selectedContextId);
|
||||
if (!this._surfaceMounted || !this.isVisibleBrowserSurface()) return;
|
||||
|
||||
const targetBrowserId = this.firstBrowserInContext(selectedContextId)?.id || null;
|
||||
if (
|
||||
this.normalizeContextId(this.contextId) === selectedContextId
|
||||
&& (
|
||||
!targetBrowserId
|
||||
|| this.sameBrowserTab(targetBrowserId, selectedContextId, this.activeBrowserId, this.activeBrowserContextId)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = "";
|
||||
this.resetRenderedFrame();
|
||||
this.resetViewportTracking();
|
||||
this._surfaceSwitching = Boolean(targetBrowserId);
|
||||
this.switchingBrowserId = targetBrowserId;
|
||||
try {
|
||||
await this.connectViewer({
|
||||
browserId: targetBrowserId,
|
||||
contextId: selectedContextId,
|
||||
initialViewport: this.currentViewportSize(),
|
||||
});
|
||||
await this.syncViewportAfterSurfaceOpen(this._surfaceOpenSequence);
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this._surfaceSwitching = false;
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -605,7 +652,9 @@ const model = {
|
|||
const requestedContextId = this.normalizeContextId(
|
||||
options.requestedContextId ?? options.contextId ?? options.context_id,
|
||||
);
|
||||
let targetContextId = requestedContextId;
|
||||
let targetContextId = requestedContextId
|
||||
|| this.contextIdForBrowserId(requestedBrowserId)
|
||||
|| this.resolveContextId();
|
||||
const nextMode = options?.nextMode || (options?.mode === "modal" ? "modal" : "canvas");
|
||||
if (nextMode === "canvas" && !this.isCanvasSurfaceVisible(element)) {
|
||||
this.loading = false;
|
||||
|
|
@ -719,15 +768,49 @@ const model = {
|
|||
return Boolean(rect && Math.round(rect.width || 0) >= 80 && Math.round(rect.height || 0) >= 80);
|
||||
},
|
||||
|
||||
isVisibleBrowserSurface() {
|
||||
if (!this._surfaceMounted) return false;
|
||||
if (this._mode === "canvas") {
|
||||
return Boolean(rightCanvasStore?.isSurfaceVisible?.("browser"))
|
||||
&& this.isCanvasSurfaceVisible(globalThis.document?.querySelector?.(".browser-canvas-surface .browser-panel"));
|
||||
}
|
||||
|
||||
const panel = globalThis.document?.querySelector?.(".modal .browser-panel");
|
||||
const modal = panel?.closest?.(".modal");
|
||||
if (!panel || !modal) return false;
|
||||
if (modal.classList.contains("modal-surface-parked") || modal.classList.contains("surface-modal-parked")) {
|
||||
return false;
|
||||
}
|
||||
const panelStyle = globalThis.getComputedStyle?.(panel);
|
||||
if (panelStyle?.display === "none" || panelStyle?.visibility === "hidden") return false;
|
||||
const rect = panel.getBoundingClientRect?.();
|
||||
return Boolean(rect && Math.round(rect.width || 0) >= 80 && Math.round(rect.height || 0) >= 80);
|
||||
},
|
||||
|
||||
prepareSurfaceOpen(nextMode, requestedBrowserId = null, requestedContextId = "") {
|
||||
const previousMode = this._mode;
|
||||
const modeChanged = this._surfaceMounted && previousMode && previousMode !== nextMode;
|
||||
const targetBrowserId = requestedBrowserId || this.activeBrowserId || this.firstBrowserId(requestedContextId);
|
||||
const targetContextId = this.normalizeContextId(
|
||||
requestedContextId
|
||||
|| this.contextIdForBrowserId(targetBrowserId)
|
||||
|| this.resolveContextId()
|
||||
|| this.activeBrowserContextId
|
||||
|| this.contextId,
|
||||
);
|
||||
const targetChanged = Boolean(
|
||||
targetBrowserId
|
||||
&& this.activeBrowserId
|
||||
&& !this.sameBrowserTab(targetBrowserId, targetContextId, this.activeBrowserId, this.activeBrowserContextId),
|
||||
);
|
||||
this._mode = nextMode;
|
||||
this._surfaceMounted = true;
|
||||
this._surfaceOpenedAt = Date.now();
|
||||
this._lastViewportKey = "";
|
||||
if (!modeChanged && (this.frameSrc || !targetBrowserId)) return;
|
||||
if (this.frameSrc && !targetChanged) {
|
||||
this._surfaceSwitching = false;
|
||||
this.switchingBrowserId = null;
|
||||
return;
|
||||
}
|
||||
if (!targetBrowserId) return;
|
||||
|
||||
this.resetRenderedFrame();
|
||||
this.resetViewportTracking();
|
||||
|
|
@ -756,7 +839,7 @@ const model = {
|
|||
|| Math.abs(this._lastViewport.height - viewport.height) > VIEWPORT_SYNC_SIZE_TOLERANCE;
|
||||
if (!changed) return;
|
||||
|
||||
this.resetRenderedFrame();
|
||||
this.cancelFrameRender();
|
||||
this.resetViewportTracking();
|
||||
this._surfaceSwitching = true;
|
||||
this.switchingBrowserId = targetBrowserId;
|
||||
|
|
@ -863,6 +946,7 @@ const model = {
|
|||
context_id: contextId,
|
||||
browser_id: requestedBrowserId,
|
||||
viewer_id: viewerToken,
|
||||
create_browser: Boolean(options.createBrowser || options.create_browser),
|
||||
viewport_width: initialViewport?.width,
|
||||
viewport_height: initialViewport?.height,
|
||||
},
|
||||
|
|
@ -1108,7 +1192,8 @@ const model = {
|
|||
const viewport = this.currentViewportSize();
|
||||
if (!this.frameSrc || !this._lastFrameDimensions || !viewport) return;
|
||||
if (this.frameMatchesViewport(this._lastFrameDimensions, viewport)) return;
|
||||
this.resetRenderedFrame();
|
||||
this.cancelFrameRender();
|
||||
this.resetViewportTracking();
|
||||
if (this.activeBrowserId) {
|
||||
this._surfaceSwitching = true;
|
||||
this.switchingBrowserId = this.activeBrowserId;
|
||||
|
|
@ -1400,6 +1485,12 @@ const model = {
|
|||
return browsers[0] || null;
|
||||
},
|
||||
|
||||
firstBrowserInContext(contextId = "") {
|
||||
const normalizedContextId = this.normalizeContextId(contextId);
|
||||
if (!normalizedContextId || !Array.isArray(this.browsers)) return null;
|
||||
return this.browsers.find((browser) => this.normalizeContextId(browser?.context_id) === normalizedContextId) || null;
|
||||
},
|
||||
|
||||
firstBrowserId(contextId = "") {
|
||||
return this.firstBrowser(contextId)?.id || null;
|
||||
},
|
||||
|
|
@ -2456,8 +2547,8 @@ const model = {
|
|||
const header = modal?.querySelector?.(".modal-header");
|
||||
const stage = root?.querySelector?.(".browser-stage");
|
||||
if (!modal || !inner || !header) return;
|
||||
modal.classList.add("modal-floating");
|
||||
inner.classList.add("browser-modal");
|
||||
modal.classList.add("surface-floating", "modal-floating");
|
||||
inner.classList.add("surface-modal", "browser-modal");
|
||||
body?.classList?.add("browser-modal-body");
|
||||
this._stageElement = stage || null;
|
||||
|
||||
|
|
@ -2468,7 +2559,54 @@ const model = {
|
|||
|
||||
let drag = null;
|
||||
let resizeObserver = null;
|
||||
let beforeFocusBounds = null;
|
||||
const viewportGap = 8;
|
||||
const currentBounds = () => {
|
||||
const bounds = inner.getBoundingClientRect();
|
||||
return {
|
||||
left: bounds.left,
|
||||
top: bounds.top,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
};
|
||||
};
|
||||
const normalizedBounds = (bounds = {}) => {
|
||||
const maxWidth = Math.max(320, globalThis.innerWidth - viewportGap * 2);
|
||||
const maxHeight = Math.max(300, globalThis.innerHeight - viewportGap * 2);
|
||||
const width = Math.min(Math.max(320, Number(bounds.width || 320)), maxWidth);
|
||||
const height = Math.min(Math.max(300, Number(bounds.height || 300)), maxHeight);
|
||||
return {
|
||||
left: Math.min(
|
||||
Math.max(viewportGap, Number(bounds.left || viewportGap)),
|
||||
Math.max(viewportGap, globalThis.innerWidth - width - viewportGap),
|
||||
),
|
||||
top: Math.min(
|
||||
Math.max(viewportGap, Number(bounds.top || viewportGap)),
|
||||
Math.max(viewportGap, globalThis.innerHeight - height - viewportGap),
|
||||
),
|
||||
width,
|
||||
height,
|
||||
};
|
||||
};
|
||||
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.maxWidth = `${Math.max(320, globalThis.innerWidth - viewportGap * 2)}px`;
|
||||
inner.style.maxHeight = `${Math.max(300, globalThis.innerHeight - viewportGap * 2)}px`;
|
||||
this.queueViewportSync();
|
||||
return next;
|
||||
};
|
||||
const focusBounds = () => ({
|
||||
left: viewportGap,
|
||||
top: viewportGap,
|
||||
width: globalThis.innerWidth - viewportGap * 2,
|
||||
height: globalThis.innerHeight - viewportGap * 2,
|
||||
});
|
||||
const clampPosition = (left, top) => {
|
||||
const bounds = inner.getBoundingClientRect();
|
||||
const maxLeft = Math.max(viewportGap, globalThis.innerWidth - bounds.width - viewportGap);
|
||||
|
|
@ -2479,25 +2617,47 @@ const model = {
|
|||
};
|
||||
};
|
||||
const clampGeometry = () => {
|
||||
const bounds = inner.getBoundingClientRect();
|
||||
const left = Math.max(viewportGap, bounds.left);
|
||||
const top = Math.max(viewportGap, bounds.top);
|
||||
const maxWidth = Math.max(320, globalThis.innerWidth - viewportGap * 2);
|
||||
const maxHeight = Math.max(300, globalThis.innerHeight - viewportGap * 2);
|
||||
if (bounds.width > maxWidth) {
|
||||
inner.style.width = `${maxWidth}px`;
|
||||
if (inner.classList.contains("is-focus-mode")) {
|
||||
setBounds(focusBounds());
|
||||
return;
|
||||
}
|
||||
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(320, globalThis.innerWidth - next.left - viewportGap)}px`;
|
||||
inner.style.maxHeight = `${Math.max(300, globalThis.innerHeight - next.top - viewportGap)}px`;
|
||||
this.queueViewportSync();
|
||||
setBounds(currentBounds());
|
||||
};
|
||||
clampGeometry();
|
||||
|
||||
const focusButton = globalThis.document.createElement("button");
|
||||
focusButton.type = "button";
|
||||
focusButton.className = "surface-button browser-modal-focus-button";
|
||||
focusButton.innerHTML = '<span class="material-symbols-outlined" aria-hidden="true">fullscreen</span>';
|
||||
const updateFocusButton = (active) => {
|
||||
const label = active ? "Restore size" : "Focus mode";
|
||||
focusButton.setAttribute("aria-label", label);
|
||||
focusButton.setAttribute("title", label);
|
||||
focusButton.querySelector(".material-symbols-outlined").textContent = active ? "fullscreen_exit" : "fullscreen";
|
||||
};
|
||||
const setFocusMode = (enabled) => {
|
||||
if (enabled) {
|
||||
beforeFocusBounds = currentBounds();
|
||||
inner.classList.add("is-focus-mode");
|
||||
setBounds(focusBounds());
|
||||
updateFocusButton(true);
|
||||
return;
|
||||
}
|
||||
inner.classList.remove("is-focus-mode");
|
||||
setBounds(beforeFocusBounds || currentBounds());
|
||||
beforeFocusBounds = null;
|
||||
updateFocusButton(false);
|
||||
};
|
||||
updateFocusButton(false);
|
||||
const closeButton = inner.querySelector(".modal-close");
|
||||
if (closeButton) {
|
||||
closeButton.insertAdjacentElement("beforebegin", focusButton);
|
||||
} else {
|
||||
header.appendChild(focusButton);
|
||||
}
|
||||
const onFocusClick = () => setFocusMode(!inner.classList.contains("is-focus-mode"));
|
||||
focusButton.addEventListener("click", onFocusClick);
|
||||
|
||||
globalThis.addEventListener("resize", clampGeometry);
|
||||
if (globalThis.ResizeObserver) {
|
||||
resizeObserver = new ResizeObserver(clampGeometry);
|
||||
|
|
@ -2537,6 +2697,7 @@ const model = {
|
|||
const onPointerDown = (event) => {
|
||||
if (event.button !== 0) return;
|
||||
if (event.target?.closest?.("button, input, select, textarea, a")) return;
|
||||
if (inner.classList.contains("is-focus-mode")) return;
|
||||
const current = inner.getBoundingClientRect();
|
||||
drag = {
|
||||
x: event.clientX,
|
||||
|
|
@ -2553,6 +2714,8 @@ const model = {
|
|||
header.addEventListener("pointerdown", onPointerDown);
|
||||
|
||||
this._floatingCleanup = () => {
|
||||
focusButton.removeEventListener("click", onFocusClick);
|
||||
focusButton.remove();
|
||||
header.removeEventListener("pointerdown", onPointerDown);
|
||||
globalThis.removeEventListener("pointermove", onPointerMove);
|
||||
globalThis.removeEventListener("pointerup", onPointerUp);
|
||||
|
|
@ -2560,6 +2723,7 @@ const model = {
|
|||
resizeObserver?.disconnect?.();
|
||||
this._stageResizeObserver?.disconnect?.();
|
||||
this._stageResizeObserver = null;
|
||||
inner.classList.remove("is-focus-mode");
|
||||
};
|
||||
},
|
||||
|
||||
|
|
@ -2601,3 +2765,10 @@ const model = {
|
|||
};
|
||||
|
||||
export const store = createStore("browserPage", model);
|
||||
|
||||
registerUrlHandler(async (intent = {}) => {
|
||||
const url = String(intent.url || "").trim();
|
||||
const payload = { url, source: intent.source || "surface-url-intent" };
|
||||
await openLatestSurface("browser", payload);
|
||||
return await store.openUrlIntent(url, { source: payload.source });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
<div class="browser-config-card">
|
||||
<div class="section-title">Browsing</div>
|
||||
<div class="section-description">
|
||||
Set how new Browser sessions start and how an already-open Browser canvas follows agent activity.
|
||||
Set how new Browser sessions start and how an already-open Browser surface follows agent activity.
|
||||
</div>
|
||||
|
||||
<label class="browser-config-field">
|
||||
|
|
@ -34,7 +34,7 @@
|
|||
<label class="browser-config-switch-row">
|
||||
<span class="browser-config-switch-copy">
|
||||
<span class="browser-config-field-label">Autofocus active page</span>
|
||||
<span class="browser-config-field-help">Update the visible Browser canvas for pages opened or changed by Browser tool results.</span>
|
||||
<span class="browser-config-field-help">Update the visible Browser surface for pages opened or changed by Browser tool results.</span>
|
||||
</span>
|
||||
<span class="browser-config-toggle-with-label">
|
||||
<span class="browser-config-toggle-label" x-text="$store.browserConfig.autofocusLabel()"></span>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
<html
|
||||
class="browser-modal"
|
||||
data-surface-id="browser"
|
||||
data-surface-modal-path="/plugins/_browser/webui/main.html"
|
||||
data-surface-dock-title="Open Browser in surface"
|
||||
data-surface-dock-icon="dock_to_right"
|
||||
data-canvas-surface="browser"
|
||||
data-canvas-modal-path="/plugins/_browser/webui/main.html"
|
||||
data-canvas-dock-title="Open Browser in canvas"
|
||||
|
|
|
|||
|
|
@ -1,13 +1,21 @@
|
|||
import { createStore } from "/js/AlpineStore.js";
|
||||
import { callJsExtensions } from "/js/extensions.js";
|
||||
import {
|
||||
SURFACE_MODE_DOCKED,
|
||||
SURFACE_MODE_FLOATING,
|
||||
closeSurfaceGroupModals,
|
||||
getRegisteredSurfaces,
|
||||
migratePersistedSurfaceState,
|
||||
normalizeSurfaceId,
|
||||
normalizeSurfaceMode,
|
||||
registerSurface as registerSurfaceDefinition,
|
||||
} from "/js/surfaces.js";
|
||||
|
||||
const STORAGE_KEY = "a0.rightCanvas";
|
||||
const DEFAULT_WIDTH = 720;
|
||||
const MIN_WIDTH = 0;
|
||||
const DESKTOP_BREAKPOINT = 1200;
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
const SURFACE_MODE_CANVAS = "canvas";
|
||||
const SURFACE_MODE_MODAL = "modal";
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
|
|
@ -23,10 +31,6 @@ function normalizeWidth(value, fallback = DEFAULT_WIDTH) {
|
|||
return Number.isFinite(width) ? Math.max(MIN_WIDTH, Math.round(width)) : fallback;
|
||||
}
|
||||
|
||||
function normalizeSurfaceMode(mode = "") {
|
||||
return mode === SURFACE_MODE_MODAL ? SURFACE_MODE_MODAL : SURFACE_MODE_CANVAS;
|
||||
}
|
||||
|
||||
const model = {
|
||||
surfaces: [],
|
||||
activeSurfaceId: "",
|
||||
|
|
@ -61,6 +65,7 @@ const model = {
|
|||
|
||||
if (!this._registering) {
|
||||
this._registering = true;
|
||||
await callJsExtensions("surfaces_register", this);
|
||||
await callJsExtensions("right_canvas_register_surfaces", this);
|
||||
this._registering = false;
|
||||
this.ensureActiveSurface();
|
||||
|
|
@ -69,6 +74,7 @@ const model = {
|
|||
|
||||
registerSurface(surface) {
|
||||
if (!surface?.id) return;
|
||||
const surfaceId = normalizeSurfaceId(surface.id);
|
||||
const normalized = {
|
||||
title: surface.id,
|
||||
icon: "web_asset",
|
||||
|
|
@ -80,6 +86,7 @@ const model = {
|
|||
modalPath: "",
|
||||
actionOnly: false,
|
||||
...surface,
|
||||
id: surfaceId,
|
||||
};
|
||||
|
||||
const index = this.surfaces.findIndex((item) => item.id === normalized.id);
|
||||
|
|
@ -89,8 +96,9 @@ const model = {
|
|||
this.surfaces.push(normalized);
|
||||
}
|
||||
if (!this.surfaceModes[normalized.id]) {
|
||||
this.surfaceModes[normalized.id] = SURFACE_MODE_CANVAS;
|
||||
this.surfaceModes[normalized.id] = SURFACE_MODE_DOCKED;
|
||||
}
|
||||
registerSurfaceDefinition(normalized);
|
||||
this.surfaces.sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
|
||||
if (!this._registering) {
|
||||
this.ensureActiveSurface();
|
||||
|
|
@ -109,7 +117,7 @@ const model = {
|
|||
},
|
||||
|
||||
async open(surfaceId = "", payload = {}) {
|
||||
const targetId = surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || "";
|
||||
const targetId = normalizeSurfaceId(surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || "");
|
||||
const surface = this.getSurface(targetId);
|
||||
if (!surface) {
|
||||
return false;
|
||||
|
|
@ -133,7 +141,7 @@ const model = {
|
|||
this.activeSurfaceId = targetId;
|
||||
this.markSurfaceMounted(targetId);
|
||||
this.isOpen = true;
|
||||
this.recordSurfaceMode(targetId, SURFACE_MODE_CANVAS, { persist: false });
|
||||
this.recordSurfaceMode(targetId, SURFACE_MODE_DOCKED, { persist: false });
|
||||
this._lastPayloadBySurface[targetId] = payload || {};
|
||||
this.persist();
|
||||
this.applyLayoutState();
|
||||
|
|
@ -147,7 +155,7 @@ const model = {
|
|||
},
|
||||
|
||||
markSurfaceMounted(surfaceId) {
|
||||
const targetId = String(surfaceId || "").trim();
|
||||
const targetId = normalizeSurfaceId(surfaceId);
|
||||
if (!targetId) return;
|
||||
this.mountedSurfaces = {
|
||||
...this.mountedSurfaces,
|
||||
|
|
@ -156,7 +164,7 @@ const model = {
|
|||
},
|
||||
|
||||
markSurfaceUnmounted(surfaceId) {
|
||||
const targetId = String(surfaceId || "").trim();
|
||||
const targetId = normalizeSurfaceId(surfaceId);
|
||||
if (!targetId || !this.mountedSurfaces[targetId]) return;
|
||||
const next = { ...this.mountedSurfaces };
|
||||
delete next[targetId];
|
||||
|
|
@ -170,7 +178,7 @@ const model = {
|
|||
},
|
||||
|
||||
isSurfaceMounted(id) {
|
||||
return Boolean(this.mountedSurfaces[String(id || "").trim()]);
|
||||
return Boolean(this.mountedSurfaces[normalizeSurfaceId(id)]);
|
||||
},
|
||||
|
||||
isSurfaceRendered(id) {
|
||||
|
|
@ -178,11 +186,12 @@ const model = {
|
|||
},
|
||||
|
||||
isSurfaceVisible(id) {
|
||||
return Boolean(this.isOpen && this.activeSurfaceId === id && this.isSurfaceMounted(id));
|
||||
const targetId = normalizeSurfaceId(id);
|
||||
return Boolean(this.isOpen && this.activeSurfaceId === targetId && this.isSurfaceMounted(targetId));
|
||||
},
|
||||
|
||||
recordSurfaceMode(surfaceId, mode = SURFACE_MODE_CANVAS, options = {}) {
|
||||
const targetId = String(surfaceId || "").trim();
|
||||
recordSurfaceMode(surfaceId, mode = SURFACE_MODE_DOCKED, options = {}) {
|
||||
const targetId = normalizeSurfaceId(surfaceId);
|
||||
if (!targetId) return;
|
||||
this.surfaceModes = {
|
||||
...this.surfaceModes,
|
||||
|
|
@ -192,37 +201,28 @@ const model = {
|
|||
},
|
||||
|
||||
latestSurfaceMode(surfaceId) {
|
||||
const targetId = String(surfaceId || "").trim();
|
||||
const targetId = normalizeSurfaceId(surfaceId);
|
||||
return normalizeSurfaceMode(this.surfaceModes[targetId]);
|
||||
},
|
||||
|
||||
async openLatest(surfaceId = "", payload = {}) {
|
||||
const targetId = surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || "";
|
||||
const targetId = normalizeSurfaceId(surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || "");
|
||||
if (!targetId) return false;
|
||||
if (this.latestSurfaceMode(targetId) === SURFACE_MODE_MODAL) {
|
||||
if (this.latestSurfaceMode(targetId) === SURFACE_MODE_FLOATING) {
|
||||
return await this.openModalSurface(targetId, payload);
|
||||
}
|
||||
return await this.open(targetId, payload);
|
||||
},
|
||||
|
||||
async close() {
|
||||
const mountedIds = this.mountedSurfaceIds();
|
||||
this.isOpen = false;
|
||||
this.mountedSurfaces = {};
|
||||
this.persist();
|
||||
this.applyLayoutState();
|
||||
|
||||
for (const surfaceId of mountedIds) {
|
||||
const surface = this.getSurface(surfaceId);
|
||||
try {
|
||||
await surface?.close?.(this._lastPayloadBySurface[surfaceId] || {});
|
||||
} catch (error) {
|
||||
console.error(`Canvas surface ${surfaceId} failed to close`, error);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
async dockSurface(surfaceId, payload = {}) {
|
||||
surfaceId = normalizeSurfaceId(surfaceId);
|
||||
if (this.isMobileMode) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -262,6 +262,11 @@ const model = {
|
|||
}
|
||||
|
||||
const sourceModalPath = payload.sourceModalPath || modalPath;
|
||||
if (sourceModalPath || modalPath) {
|
||||
const closed = await closeSurfaceGroupModals();
|
||||
if (closed === false) return false;
|
||||
if (!sourceModalPath || !globalThis.isModalOpen?.(sourceModalPath)) return true;
|
||||
}
|
||||
if (sourceModalPath && globalThis.isModalOpen?.(sourceModalPath)) {
|
||||
return (await globalThis.closeModal?.(sourceModalPath)) !== false;
|
||||
}
|
||||
|
|
@ -272,29 +277,18 @@ const model = {
|
|||
},
|
||||
|
||||
async undockSurface(surfaceId = "", payload = {}) {
|
||||
const targetId = surfaceId || this.activeSurfaceId;
|
||||
const targetId = normalizeSurfaceId(surfaceId || this.activeSurfaceId);
|
||||
const surface = this.getSurface(targetId);
|
||||
const modalPath = payload.modalPath || surface?.modalPath || "";
|
||||
if (!surface || !modalPath) return false;
|
||||
const openModal = globalThis.ensureModalOpen || globalThis.openModal;
|
||||
if (!openModal) return false;
|
||||
if (this.activeSurfaceId === targetId) {
|
||||
const mountedIds = this.mountedSurfaceIds();
|
||||
this.isOpen = false;
|
||||
this.mountedSurfaces = {};
|
||||
this.persist();
|
||||
this.applyLayoutState();
|
||||
|
||||
for (const mountedId of mountedIds) {
|
||||
const mountedSurface = this.getSurface(mountedId);
|
||||
try {
|
||||
await mountedSurface?.close?.(this._lastPayloadBySurface[mountedId] || {});
|
||||
} catch (error) {
|
||||
console.error(`Canvas surface ${mountedId} failed to close while undocking`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.recordSurfaceMode(targetId, SURFACE_MODE_MODAL);
|
||||
this.recordSurfaceMode(targetId, SURFACE_MODE_FLOATING);
|
||||
const modalPromise = openModal(modalPath);
|
||||
if (modalPromise?.catch) {
|
||||
modalPromise.catch((error) => console.error(`Canvas surface ${targetId} failed to undock`, error));
|
||||
|
|
@ -303,7 +297,7 @@ const model = {
|
|||
},
|
||||
|
||||
async openModalSurface(surfaceId = "", payload = {}) {
|
||||
const targetId = surfaceId || this.activeSurfaceId;
|
||||
const targetId = normalizeSurfaceId(surfaceId || this.activeSurfaceId);
|
||||
const surface = this.getSurface(targetId);
|
||||
const modalPath = payload.modalPath || surface?.modalPath || "";
|
||||
if (!surface || !modalPath) return false;
|
||||
|
|
@ -311,23 +305,12 @@ const model = {
|
|||
if (!openModal) return false;
|
||||
|
||||
if (this.isOpen && this.activeSurfaceId === targetId) {
|
||||
const mountedIds = this.mountedSurfaceIds();
|
||||
this.isOpen = false;
|
||||
this.mountedSurfaces = {};
|
||||
this.persist();
|
||||
this.applyLayoutState();
|
||||
|
||||
for (const mountedId of mountedIds) {
|
||||
const mountedSurface = this.getSurface(mountedId);
|
||||
try {
|
||||
await mountedSurface?.close?.(this._lastPayloadBySurface[mountedId] || {});
|
||||
} catch (error) {
|
||||
console.error(`Canvas surface ${mountedId} failed to close before modal open`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.recordSurfaceMode(targetId, SURFACE_MODE_MODAL);
|
||||
this.recordSurfaceMode(targetId, SURFACE_MODE_FLOATING);
|
||||
const modalPromise = openModal(modalPath);
|
||||
if (modalPromise?.catch) {
|
||||
modalPromise.catch((error) => console.error(`Canvas surface ${targetId} failed to open as modal`, error));
|
||||
|
|
@ -344,7 +327,7 @@ const model = {
|
|||
},
|
||||
|
||||
async toggle(surfaceId = "", payload = {}) {
|
||||
const targetId = surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || "";
|
||||
const targetId = normalizeSurfaceId(surfaceId || this.activeSurfaceId || this.panelSurfaces[0]?.id || "");
|
||||
if (this.isOpen && targetId === this.activeSurfaceId) {
|
||||
await this.close();
|
||||
return false;
|
||||
|
|
@ -444,7 +427,7 @@ const model = {
|
|||
restore() {
|
||||
this.width = this.defaultWidth();
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
|
||||
const saved = migratePersistedSurfaceState(JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}"));
|
||||
this.isOpen = false;
|
||||
this.activeSurfaceId = String(saved.activeSurfaceId || "");
|
||||
this.surfaceModes = Object.fromEntries(
|
||||
|
|
@ -502,7 +485,10 @@ const model = {
|
|||
},
|
||||
|
||||
getSurface(id) {
|
||||
return this.surfaces.find((surface) => surface.id === id) || null;
|
||||
const targetId = normalizeSurfaceId(id);
|
||||
return this.surfaces.find((surface) => surface.id === targetId)
|
||||
|| getRegisteredSurfaces().find((surface) => surface.id === targetId)
|
||||
|| null;
|
||||
},
|
||||
|
||||
get railSurfaces() {
|
||||
|
|
@ -518,7 +504,7 @@ const model = {
|
|||
},
|
||||
|
||||
isSurfaceActive(id) {
|
||||
return this.activeSurfaceId === id;
|
||||
return this.activeSurfaceId === normalizeSurfaceId(id);
|
||||
},
|
||||
|
||||
activeTitle() {
|
||||
|
|
|
|||
|
|
@ -142,6 +142,8 @@ body.right-canvas-resizing {
|
|||
}
|
||||
|
||||
.right-canvas-header {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
|
|
@ -150,6 +152,7 @@ body.right-canvas-resizing {
|
|||
padding: 7px 8px 0 10px;
|
||||
border-bottom: 1px solid var(--right-canvas-border);
|
||||
background: color-mix(in srgb, var(--color-background) 91%, #000 9%);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.right-canvas-tabs {
|
||||
|
|
@ -214,14 +217,18 @@ body.right-canvas-resizing {
|
|||
}
|
||||
|
||||
.right-canvas-toolbar {
|
||||
position: relative;
|
||||
z-index: 11;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding-bottom: 6px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.right-canvas-panels {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
|
|
|||
|
|
@ -18,24 +18,6 @@ the old and the new system. */
|
|||
display: block;
|
||||
}
|
||||
|
||||
.modal.modal-surface-parked {
|
||||
display: block;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.modal.modal-surface-parked .modal-inner {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.modal.modal-floating {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.modal.modal-floating .modal-inner {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.modal-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -162,54 +144,6 @@ the old and the new system. */
|
|||
background: color-mix(in srgb, var(--color-background-hover) 72%, transparent);
|
||||
}
|
||||
|
||||
.modal-surface-switcher {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 34px;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.modal-dock-button,
|
||||
.modal-surface-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
min-width: 34px;
|
||||
min-height: 34px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
opacity: 0.72;
|
||||
transition: background-color 0.16s ease, border-color 0.16s ease, opacity 0.16s ease;
|
||||
}
|
||||
|
||||
.modal-dock-button:hover,
|
||||
.modal-surface-button:hover,
|
||||
.modal-surface-button.is-active {
|
||||
opacity: 1;
|
||||
border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
|
||||
background: color-mix(in srgb, var(--color-background-hover) 72%, transparent);
|
||||
}
|
||||
|
||||
.modal-dock-button .material-symbols-outlined,
|
||||
.modal-surface-button .material-symbols-outlined {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.modal-surface-image {
|
||||
display: block;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Modal Description */
|
||||
.modal-description {
|
||||
padding: 0.8rem 1rem 0 1rem;
|
||||
|
|
|
|||
83
webui/css/surfaces.css
Normal file
83
webui/css/surfaces.css
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
.modal.surface-modal-parked,
|
||||
.modal.modal-surface-parked {
|
||||
display: block;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.modal.surface-modal-parked .modal-inner,
|
||||
.modal.modal-surface-parked .modal-inner {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.modal.surface-floating,
|
||||
.modal.modal-floating {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.modal.surface-floating .modal-inner,
|
||||
.modal.modal-floating .modal-inner {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.surface-switcher,
|
||||
.modal-surface-switcher {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 34px;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.surface-dock-button,
|
||||
.surface-button,
|
||||
.modal-dock-button,
|
||||
.modal-surface-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
min-width: 34px;
|
||||
min-height: 34px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
opacity: 0.72;
|
||||
transition: background-color 0.16s ease, border-color 0.16s ease, opacity 0.16s ease;
|
||||
}
|
||||
|
||||
.surface-dock-button:hover,
|
||||
.surface-button:hover,
|
||||
.surface-button.is-active,
|
||||
.modal-dock-button:hover,
|
||||
.modal-surface-button:hover,
|
||||
.modal-surface-button.is-active {
|
||||
opacity: 1;
|
||||
border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
|
||||
background: color-mix(in srgb, var(--color-background-hover) 72%, transparent);
|
||||
}
|
||||
|
||||
.surface-dock-button .material-symbols-outlined,
|
||||
.surface-button .material-symbols-outlined,
|
||||
.modal-dock-button .material-symbols-outlined,
|
||||
.modal-surface-button .material-symbols-outlined {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.surface-image,
|
||||
.modal-surface-image {
|
||||
display: block;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.surface-resize-handle {
|
||||
position: absolute;
|
||||
touch-action: none;
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
<link rel="stylesheet" href="css/toast.css">
|
||||
<link rel="stylesheet" href="css/settings.css">
|
||||
<link rel="stylesheet" href="css/modals.css">
|
||||
<link rel="stylesheet" href="css/surfaces.css">
|
||||
<link rel="stylesheet" href="css/speech.css">
|
||||
<link rel="stylesheet" href="css/scheduler-datepicker.css">
|
||||
<link rel="stylesheet" href="css/scheduler.css">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import * as initializer from "./initializer.js";
|
||||
import * as _modals from "./modals.js";
|
||||
import "./surfaces.js";
|
||||
import * as _components from "./components.js";
|
||||
import * as extensions from "./extensions.js";
|
||||
import { registerAlpineMagic } from "./confirmClick.js";
|
||||
|
|
@ -182,4 +183,4 @@ Alpine.directive(
|
|||
});
|
||||
|
||||
// process extensions
|
||||
await extensions.callJsExtensions("initFw_end")
|
||||
await extensions.callJsExtensions("initFw_end")
|
||||
|
|
|
|||
|
|
@ -1,86 +1,57 @@
|
|||
// Import the component loader and page utilities
|
||||
import { importComponent } from "/js/components.js";
|
||||
import { callJsExtensions } from "/js/extensions.js";
|
||||
import { store as rightCanvasStore } from "/components/canvas/right-canvas-store.js";
|
||||
|
||||
// Modal functionality
|
||||
const modalStack = [];
|
||||
const EXPLICIT_CLOSE_MODAL_PATHS = new Set([
|
||||
"plugins/_browser/webui/main.html",
|
||||
"plugins/_office/webui/main.html",
|
||||
]);
|
||||
const SINGLE_VISIBLE_MODAL_SURFACE_PATHS = new Set([
|
||||
"plugins/_browser/webui/main.html",
|
||||
"plugins/_office/webui/main.html",
|
||||
]);
|
||||
const CANVAS_SURFACE_MODAL_GROUP = "canvas-surfaces";
|
||||
const DEFAULT_MODAL_SURFACES = [
|
||||
{
|
||||
id: "browser",
|
||||
title: "Browser",
|
||||
icon: "language",
|
||||
modalPath: "/plugins/_browser/webui/main.html",
|
||||
},
|
||||
{
|
||||
id: "office",
|
||||
title: "Desktop",
|
||||
icon: "desktop_windows",
|
||||
modalPath: "/plugins/_office/webui/main.html",
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeModalPath(modalPath = "") {
|
||||
return String(modalPath || "").replace(/^\/+/, "");
|
||||
}
|
||||
|
||||
function sameModalPath(left = "", right = "") {
|
||||
return normalizeModalPath(left) === normalizeModalPath(right);
|
||||
return String(left || "").replace(/^\/+/, "") === String(right || "").replace(/^\/+/, "");
|
||||
}
|
||||
|
||||
function modalHasClass(modalOrElement, className) {
|
||||
const element = modalOrElement?.element || modalOrElement;
|
||||
return Boolean(
|
||||
element?.classList?.contains(className)
|
||||
|| element?.querySelector?.(".modal-inner")?.classList?.contains(className)
|
||||
);
|
||||
}
|
||||
|
||||
function modalDatasetFlag(modalOrElement, name) {
|
||||
const element = modalOrElement?.element || modalOrElement;
|
||||
const inner = element?.querySelector?.(".modal-inner");
|
||||
const value = element?.dataset?.[name] ?? inner?.dataset?.[name] ?? "";
|
||||
return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
|
||||
}
|
||||
|
||||
function modalRequiresExplicitClose(modalOrElement) {
|
||||
const element = modalOrElement?.element || modalOrElement;
|
||||
const path = normalizeModalPath(modalOrElement?.path || element?.path || "");
|
||||
return EXPLICIT_CLOSE_MODAL_PATHS.has(path)
|
||||
|| element?.classList?.contains("modal-explicit-close")
|
||||
|| element?.querySelector?.(".modal-inner")?.classList?.contains("modal-explicit-close");
|
||||
return modalHasClass(modalOrElement, "modal-explicit-close")
|
||||
|| modalDatasetFlag(modalOrElement, "modalExplicitClose");
|
||||
}
|
||||
|
||||
function modalSurfaceGroup(modalOrElement) {
|
||||
const element = modalOrElement?.element || modalOrElement;
|
||||
const path = normalizeModalPath(modalOrElement?.path || element?.path || "");
|
||||
return SINGLE_VISIBLE_MODAL_SURFACE_PATHS.has(path) ? CANVAS_SURFACE_MODAL_GROUP : "";
|
||||
function modalSuppressesBackdrop(modalOrElement) {
|
||||
return modalHasClass(modalOrElement, "modal-no-backdrop")
|
||||
|| modalDatasetFlag(modalOrElement, "modalNoBackdrop");
|
||||
}
|
||||
|
||||
function setModalParked(modal, parked = false) {
|
||||
const element = modal?.element;
|
||||
if (!element) return;
|
||||
element.classList.toggle("modal-surface-parked", parked);
|
||||
if (parked) {
|
||||
element.classList.remove("show");
|
||||
element.setAttribute("aria-hidden", "true");
|
||||
} else {
|
||||
element.classList.add("show");
|
||||
element.removeAttribute("aria-hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function parkSiblingSurfaceModals(activeModal) {
|
||||
const group = modalSurfaceGroup(activeModal);
|
||||
if (!group) {
|
||||
setModalParked(activeModal, false);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const modal of modalStack) {
|
||||
setModalParked(modal, modal !== activeModal && modalSurfaceGroup(modal) === group);
|
||||
}
|
||||
function dispatchModalEvent(name, modal, detail = {}) {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent(name, {
|
||||
detail: {
|
||||
modalPath: modal?.path ?? null,
|
||||
modal,
|
||||
modalStack: getModalStack(),
|
||||
...detail,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function activateModal(modal) {
|
||||
if (!modal) return;
|
||||
parkSiblingSurfaceModals(modal);
|
||||
updateModalZIndexes();
|
||||
restoreModalScrollSnapshot(modal);
|
||||
dispatchModalEvent("modal-activated", modal);
|
||||
}
|
||||
|
||||
function findModalIndexByPath(modalPath) {
|
||||
|
|
@ -133,17 +104,6 @@ backdrop.style.display = "none";
|
|||
backdrop.style.backdropFilter = "blur(8px) saturate(112%)";
|
||||
document.body.appendChild(backdrop);
|
||||
|
||||
function modalSuppressesBackdrop(modal) {
|
||||
const path = String(modal?.path || "");
|
||||
return path === "/plugins/_browser/webui/main.html"
|
||||
|| path === "plugins/_browser/webui/main.html"
|
||||
|| path === "/plugins/_office/webui/main.html"
|
||||
|| path === "plugins/_office/webui/main.html"
|
||||
|| modal?.element?.classList?.contains("modal-floating")
|
||||
|| modal?.element?.classList?.contains("modal-no-backdrop")
|
||||
|| modal?.inner?.classList?.contains("modal-no-backdrop");
|
||||
}
|
||||
|
||||
// Function to update z-index for all modals and backdrop
|
||||
function updateModalZIndexes() {
|
||||
// Base z-index for modals
|
||||
|
|
@ -186,6 +146,7 @@ function createModalElement(path) {
|
|||
const newModal = document.createElement("div");
|
||||
newModal.className = "modal";
|
||||
newModal.path = path; // save name to the object
|
||||
newModal.dataset.modalPath = path;
|
||||
|
||||
// Add click handlers to only close modal if both mousedown and mouseup are on the modal container
|
||||
let mouseDownTarget = null;
|
||||
|
|
@ -250,152 +211,6 @@ function createModalElement(path) {
|
|||
};
|
||||
}
|
||||
|
||||
function getDockMetadata(doc, modalPath) {
|
||||
const htmlDataset = doc?.documentElement?.dataset || {};
|
||||
const bodyDataset = doc?.body?.dataset || {};
|
||||
const surfaceId = htmlDataset.canvasSurface || bodyDataset.canvasSurface || "";
|
||||
if (!surfaceId) return null;
|
||||
return {
|
||||
surfaceId,
|
||||
modalPath: htmlDataset.canvasModalPath || bodyDataset.canvasModalPath || modalPath,
|
||||
title: htmlDataset.canvasDockTitle || bodyDataset.canvasDockTitle || "Open in canvas",
|
||||
icon: htmlDataset.canvasDockIcon || bodyDataset.canvasDockIcon || "dock_to_right",
|
||||
};
|
||||
}
|
||||
|
||||
function getModalSwitchSurfaces(metadata) {
|
||||
const surfacesById = new Map(DEFAULT_MODAL_SURFACES.map((surface) => [surface.id, surface]));
|
||||
const surfaces = Array.isArray(rightCanvasStore.panelSurfaces)
|
||||
? rightCanvasStore.panelSurfaces
|
||||
: [];
|
||||
|
||||
for (const surface of surfaces) {
|
||||
if (!surface?.id || !surface.modalPath || surface.actionOnly) continue;
|
||||
surfacesById.set(surface.id, {
|
||||
...surface,
|
||||
modalPath: surface.modalPath,
|
||||
});
|
||||
}
|
||||
|
||||
if (metadata?.surfaceId && !surfacesById.has(metadata.surfaceId)) {
|
||||
surfacesById.set(metadata.surfaceId, {
|
||||
id: metadata.surfaceId,
|
||||
title: metadata.title,
|
||||
icon: metadata.icon,
|
||||
modalPath: metadata.modalPath,
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(surfacesById.values())
|
||||
.filter((surface) => surface?.id && surface.modalPath && !surface.actionOnly)
|
||||
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
}
|
||||
|
||||
function createModalSurfaceButton(surface, metadata, modal) {
|
||||
const title = surface.title || surface.id;
|
||||
const targetModalPath = surface.modalPath || "";
|
||||
const isActive = surface.id === metadata.surfaceId || sameModalPath(targetModalPath, modal.path);
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "modal-surface-button";
|
||||
button.dataset.canvasSurface = surface.id;
|
||||
button.setAttribute("aria-label", title);
|
||||
button.setAttribute("aria-pressed", isActive.toString());
|
||||
if (isActive) button.classList.add("is-active");
|
||||
|
||||
if (surface.image) {
|
||||
const image = document.createElement("img");
|
||||
image.className = "modal-surface-image";
|
||||
image.src = surface.image;
|
||||
image.alt = "";
|
||||
image.setAttribute("aria-hidden", "true");
|
||||
button.appendChild(image);
|
||||
} else {
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "material-symbols-outlined";
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
icon.textContent = surface.icon || "web_asset";
|
||||
button.appendChild(icon);
|
||||
}
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
if (button.disabled || isActive || !targetModalPath) return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
rightCanvasStore.recordSurfaceMode?.(surface.id, "modal");
|
||||
const openPromise = ensureModalOpen(targetModalPath);
|
||||
if (openPromise?.catch) {
|
||||
openPromise.catch((error) => console.error(`Modal surface ${surface.id} failed to open`, error));
|
||||
}
|
||||
} finally {
|
||||
if (document.contains(button)) button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
function configureModalSurfaceSwitcher(modal, doc) {
|
||||
const metadata = getDockMetadata(doc, modal.path);
|
||||
if (!metadata || !modal.header || modal.header.querySelector(".modal-surface-switcher")) {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
const surfaces = getModalSwitchSurfaces(metadata);
|
||||
if (surfaces.length <= 1) return metadata;
|
||||
|
||||
const switcher = document.createElement("div");
|
||||
switcher.className = "modal-surface-switcher";
|
||||
switcher.setAttribute("role", "group");
|
||||
switcher.setAttribute("aria-label", "Modal surfaces");
|
||||
|
||||
for (const surface of surfaces) {
|
||||
switcher.appendChild(createModalSurfaceButton(surface, metadata, modal));
|
||||
}
|
||||
|
||||
modal.close?.insertAdjacentElement("beforebegin", switcher);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function configureModalDockButton(modal, doc) {
|
||||
const metadata = configureModalSurfaceSwitcher(modal, doc);
|
||||
if (!metadata || !modal.header || modal.header.querySelector(".modal-dock-button")) {
|
||||
return;
|
||||
}
|
||||
|
||||
rightCanvasStore.recordSurfaceMode?.(metadata.surfaceId, "modal");
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "modal-dock-button";
|
||||
button.setAttribute("aria-label", metadata.title);
|
||||
button.innerHTML = `<span class="material-symbols-outlined" aria-hidden="true">${metadata.icon}</span>`;
|
||||
button.addEventListener("click", async () => {
|
||||
if (button.disabled) return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await rightCanvasStore.dockSurface?.(metadata.surfaceId, {
|
||||
modalPath: metadata.modalPath,
|
||||
sourceModalPath: modal.path,
|
||||
source: "modal",
|
||||
closeSourceModal: async () => {
|
||||
const closed = await closeModal(modal.path);
|
||||
if (closed === false) return false;
|
||||
if (document.contains(modal.element)) {
|
||||
const fallbackClosed = await closeModal();
|
||||
if (fallbackClosed === false) return false;
|
||||
}
|
||||
return !document.contains(modal.element);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (document.contains(button)) button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
modal.close?.insertAdjacentElement("beforebegin", button);
|
||||
}
|
||||
|
||||
// Function to open modal with content from URL
|
||||
export async function openModal(modalPath, beforeClose = null) {
|
||||
const openCtx = { modalPath, modal: null, cancel: false };
|
||||
|
|
@ -432,7 +247,7 @@ export async function openModal(modalPath, beforeClose = null) {
|
|||
|
||||
// Use importComponent which now returns the parsed document
|
||||
importComponent(componentPath, modal.body)
|
||||
.then((doc) => {
|
||||
.then(async (doc) => {
|
||||
// Set the title from the document
|
||||
modal.title.innerHTML = doc.title || modalPath;
|
||||
if (doc.html && doc.html.classList) {
|
||||
|
|
@ -442,8 +257,13 @@ export async function openModal(modalPath, beforeClose = null) {
|
|||
if (doc.body && doc.body.classList) {
|
||||
modal.body.classList.add(...doc.body.classList);
|
||||
}
|
||||
configureModalDockButton(modal, doc);
|
||||
updateModalZIndexes();
|
||||
await callJsExtensions("modal_content_loaded", {
|
||||
modalPath,
|
||||
modal,
|
||||
doc,
|
||||
});
|
||||
dispatchModalEvent("modal-content-loaded", modal, { doc });
|
||||
refreshModalStack();
|
||||
|
||||
// Some modals have a footer. Check if it exists and move it to footer slot
|
||||
// Use requestAnimationFrame to let Alpine mount the component first
|
||||
|
|
@ -480,6 +300,18 @@ export function isModalOpen(modalPath) {
|
|||
return findModalIndexByPath(modalPath) !== -1;
|
||||
}
|
||||
|
||||
export function getModalStack() {
|
||||
return modalStack.slice();
|
||||
}
|
||||
|
||||
export function refreshModalStack() {
|
||||
if (modalStack.length === 0) {
|
||||
updateModalZIndexes();
|
||||
return;
|
||||
}
|
||||
activateModal(modalStack[modalStack.length - 1]);
|
||||
}
|
||||
|
||||
export async function ensureModalOpen(modalPath, beforeClose = null) {
|
||||
if (focusModal(modalPath)) return null;
|
||||
return openModal(modalPath, beforeClose);
|
||||
|
|
|
|||
445
webui/js/surfaces.js
Normal file
445
webui/js/surfaces.js
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
export const SURFACE_MODE_DOCKED = "canvas";
|
||||
export const SURFACE_MODE_FLOATING = "modal";
|
||||
export const SURFACE_MODAL_GROUP = "surfaces";
|
||||
|
||||
const LEGACY_SURFACE_IDS = new Map([
|
||||
["office", "desktop"],
|
||||
]);
|
||||
|
||||
const registeredSurfaces = new Map();
|
||||
const urlHandlers = new Set();
|
||||
|
||||
export const CORE_SURFACES = [
|
||||
{
|
||||
id: "browser",
|
||||
title: "Browser",
|
||||
icon: "language",
|
||||
order: 10,
|
||||
modalPath: "/plugins/_browser/webui/main.html",
|
||||
},
|
||||
{
|
||||
id: "desktop",
|
||||
title: "Desktop",
|
||||
icon: "desktop_windows",
|
||||
order: 20,
|
||||
modalPath: "/plugins/_desktop/webui/main.html",
|
||||
},
|
||||
];
|
||||
|
||||
export function normalizeSurfaceId(surfaceId = "") {
|
||||
const normalized = String(surfaceId || "").trim();
|
||||
return LEGACY_SURFACE_IDS.get(normalized) || normalized;
|
||||
}
|
||||
|
||||
export function normalizeSurfaceMode(mode = "") {
|
||||
return mode === SURFACE_MODE_FLOATING ? SURFACE_MODE_FLOATING : SURFACE_MODE_DOCKED;
|
||||
}
|
||||
|
||||
export function normalizeModalPath(modalPath = "") {
|
||||
return String(modalPath || "").replace(/^\/+/, "");
|
||||
}
|
||||
|
||||
export function sameModalPath(left = "", right = "") {
|
||||
return normalizeModalPath(left) === normalizeModalPath(right);
|
||||
}
|
||||
|
||||
export function migratePersistedSurfaceState(saved = {}) {
|
||||
const result = { ...(saved || {}) };
|
||||
result.activeSurfaceId = normalizeSurfaceId(result.activeSurfaceId || "");
|
||||
result.surfaceModes = migrateSurfaceModeMap(result.surfaceModes || {});
|
||||
return result;
|
||||
}
|
||||
|
||||
function migrateSurfaceModeMap(surfaceModes = {}) {
|
||||
const result = {};
|
||||
for (const [surfaceId, mode] of Object.entries(surfaceModes || {})) {
|
||||
const normalizedId = normalizeSurfaceId(surfaceId);
|
||||
if (!normalizedId) continue;
|
||||
if (result[normalizedId] && normalizedId !== surfaceId) continue;
|
||||
result[normalizedId] = normalizeSurfaceMode(mode);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function registerSurface(surface = {}) {
|
||||
const id = normalizeSurfaceId(surface.id || "");
|
||||
if (!id) return null;
|
||||
const normalized = {
|
||||
title: id,
|
||||
icon: "web_asset",
|
||||
image: "",
|
||||
order: 100,
|
||||
canOpen: () => true,
|
||||
open: () => {},
|
||||
close: () => {},
|
||||
modalPath: "",
|
||||
actionOnly: false,
|
||||
...surface,
|
||||
id,
|
||||
};
|
||||
registeredSurfaces.set(id, normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function getRegisteredSurfaces() {
|
||||
const surfacesById = new Map(CORE_SURFACES.map((surface) => [surface.id, surface]));
|
||||
for (const surface of registeredSurfaces.values()) {
|
||||
surfacesById.set(surface.id, surface);
|
||||
}
|
||||
return Array.from(surfacesById.values())
|
||||
.filter((surface) => surface?.id)
|
||||
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
}
|
||||
|
||||
export function getSurface(surfaceId = "") {
|
||||
const targetId = normalizeSurfaceId(surfaceId);
|
||||
return getRegisteredSurfaces().find((surface) => surface.id === targetId) || null;
|
||||
}
|
||||
|
||||
export function modalSurfaceMetadata(doc, modalPath = "") {
|
||||
const htmlDataset = doc?.documentElement?.dataset || {};
|
||||
const bodyDataset = doc?.body?.dataset || {};
|
||||
const surfaceId = normalizeSurfaceId(
|
||||
htmlDataset.surfaceId
|
||||
|| bodyDataset.surfaceId
|
||||
|| htmlDataset.canvasSurface
|
||||
|| bodyDataset.canvasSurface
|
||||
|| "",
|
||||
);
|
||||
if (!surfaceId) return null;
|
||||
return {
|
||||
surfaceId,
|
||||
modalPath: (
|
||||
htmlDataset.surfaceModalPath
|
||||
|| bodyDataset.surfaceModalPath
|
||||
|| htmlDataset.canvasModalPath
|
||||
|| bodyDataset.canvasModalPath
|
||||
|| modalPath
|
||||
),
|
||||
title: (
|
||||
htmlDataset.surfaceDockTitle
|
||||
|| bodyDataset.surfaceDockTitle
|
||||
|| htmlDataset.canvasDockTitle
|
||||
|| bodyDataset.canvasDockTitle
|
||||
|| "Open in surface"
|
||||
),
|
||||
icon: (
|
||||
htmlDataset.surfaceDockIcon
|
||||
|| bodyDataset.surfaceDockIcon
|
||||
|| htmlDataset.canvasDockIcon
|
||||
|| bodyDataset.canvasDockIcon
|
||||
|| "dock_to_right"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function modalHasSurfaceMetadata(modalOrElement) {
|
||||
const element = modalOrElement?.element || modalOrElement;
|
||||
return Boolean(
|
||||
element?.dataset?.surfaceId
|
||||
|| element?.dataset?.canvasSurface
|
||||
|| element?.querySelector?.(".modal-inner")?.dataset?.surfaceId
|
||||
|| element?.querySelector?.(".modal-inner")?.dataset?.canvasSurface
|
||||
|| modalPathMatchesSurface(modalOrElement?.path || element?.path || ""),
|
||||
);
|
||||
}
|
||||
|
||||
export function modalPathMatchesSurface(path = "") {
|
||||
return getRegisteredSurfaces().some((surface) => sameModalPath(surface.modalPath || "", path));
|
||||
}
|
||||
|
||||
function modalSurfaceDefinition(modalOrElement) {
|
||||
const element = modalOrElement?.element || modalOrElement;
|
||||
const path = typeof modalOrElement === "string"
|
||||
? modalOrElement
|
||||
: modalOrElement?.path || element?.path || element?.dataset?.modalPath || "";
|
||||
return getRegisteredSurfaces().find((surface) => sameModalPath(surface.modalPath || "", path)) || null;
|
||||
}
|
||||
|
||||
function modalSurfaceGroup(modalOrElement) {
|
||||
return modalSurfaceDefinition(modalOrElement) ? SURFACE_MODAL_GROUP : "";
|
||||
}
|
||||
|
||||
export function shouldSuppressBackdrop(modal) {
|
||||
return Boolean(
|
||||
modalHasSurfaceMetadata(modal)
|
||||
|| modal?.element?.classList?.contains("surface-floating")
|
||||
|| modal?.element?.classList?.contains("modal-floating")
|
||||
|| modal?.element?.classList?.contains("modal-no-backdrop")
|
||||
|| modal?.inner?.classList?.contains("surface-modal")
|
||||
|| modal?.inner?.classList?.contains("modal-no-backdrop")
|
||||
);
|
||||
}
|
||||
|
||||
function setModalParked(modal, parked = false) {
|
||||
const element = modal?.element;
|
||||
if (!element) return;
|
||||
element.classList.toggle("modal-surface-parked", parked);
|
||||
element.classList.toggle("surface-modal-parked", parked);
|
||||
if (parked) {
|
||||
element.classList.remove("show");
|
||||
element.setAttribute("aria-hidden", "true");
|
||||
} else {
|
||||
element.classList.add("show");
|
||||
element.removeAttribute("aria-hidden");
|
||||
}
|
||||
}
|
||||
|
||||
async function modalApi() {
|
||||
return await import("/js/modals.js");
|
||||
}
|
||||
|
||||
async function parkSiblingSurfaceModals(activeModal) {
|
||||
const group = modalSurfaceGroup(activeModal);
|
||||
if (!group) {
|
||||
setModalParked(activeModal, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { getModalStack } = await modalApi();
|
||||
for (const modal of getModalStack()) {
|
||||
setModalParked(modal, modal !== activeModal && modalSurfaceGroup(modal) === group);
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeSurfaceGroupModals(options = {}) {
|
||||
const { closeModal, getModalStack, isModalOpen } = await modalApi();
|
||||
const exceptPath = normalizeModalPath(options?.exceptPath || "");
|
||||
const targets = getModalStack()
|
||||
.filter((modal) => modalSurfaceGroup(modal) === SURFACE_MODAL_GROUP)
|
||||
.map((modal) => ({
|
||||
path: modal.path,
|
||||
surface: modalSurfaceDefinition(modal),
|
||||
}))
|
||||
.filter((target) => !exceptPath || normalizeModalPath(target.path) !== exceptPath)
|
||||
.reverse();
|
||||
const handoffPayload = { source: "modal-group-close" };
|
||||
const handoffs = [];
|
||||
let closedAll = false;
|
||||
|
||||
try {
|
||||
for (const target of targets) {
|
||||
if (!target.surface?.beginDockHandoff) continue;
|
||||
await target.surface.beginDockHandoff({ ...handoffPayload, modalPath: target.path });
|
||||
handoffs.push(target.surface);
|
||||
}
|
||||
|
||||
for (const target of targets) {
|
||||
if (!isModalOpen(target.path)) continue;
|
||||
const closed = await closeModal(target.path);
|
||||
if (closed === false) return false;
|
||||
}
|
||||
closedAll = true;
|
||||
return true;
|
||||
} finally {
|
||||
for (const surface of handoffs) {
|
||||
try {
|
||||
if (closedAll) {
|
||||
await surface.finishDockHandoff?.({ ...handoffPayload, opened: false });
|
||||
} else {
|
||||
await surface.cancelDockHandoff?.(handoffPayload);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Surface modal group handoff cleanup failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getModalSwitchSurfaces(metadata) {
|
||||
const surfacesById = new Map(CORE_SURFACES.map((surface) => [surface.id, surface]));
|
||||
for (const surface of getRegisteredSurfaces()) {
|
||||
if (!surface?.id || !surface.modalPath || surface.actionOnly) continue;
|
||||
surfacesById.set(surface.id, {
|
||||
...surface,
|
||||
modalPath: surface.modalPath,
|
||||
});
|
||||
}
|
||||
|
||||
if (metadata?.surfaceId && !surfacesById.has(metadata.surfaceId)) {
|
||||
surfacesById.set(metadata.surfaceId, {
|
||||
id: metadata.surfaceId,
|
||||
title: metadata.title,
|
||||
icon: metadata.icon,
|
||||
modalPath: metadata.modalPath,
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(surfacesById.values())
|
||||
.filter((surface) => surface?.id && surface.modalPath && !surface.actionOnly)
|
||||
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
}
|
||||
|
||||
function markSurfaceModal(modal, metadata) {
|
||||
const element = modal?.element;
|
||||
const inner = modal?.inner || element?.querySelector?.(".modal-inner");
|
||||
if (!element || !inner) return;
|
||||
element.dataset.surfaceId = metadata.surfaceId;
|
||||
element.classList.add("surface-floating", "modal-floating", "modal-no-backdrop", "modal-explicit-close");
|
||||
inner.classList.add("surface-modal", "modal-no-backdrop", "modal-explicit-close");
|
||||
}
|
||||
|
||||
function createModalSurfaceButton(surface, metadata, modal) {
|
||||
const title = surface.title || surface.id;
|
||||
const targetModalPath = surface.modalPath || "";
|
||||
const normalizedId = normalizeSurfaceId(surface.id);
|
||||
const isActive = normalizedId === metadata.surfaceId || sameModalPath(targetModalPath, modal.path);
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "surface-button modal-surface-button";
|
||||
button.dataset.surfaceId = normalizedId;
|
||||
button.dataset.canvasSurface = normalizedId;
|
||||
button.setAttribute("aria-label", title);
|
||||
button.setAttribute("aria-pressed", isActive.toString());
|
||||
if (isActive) button.classList.add("is-active");
|
||||
|
||||
if (surface.image) {
|
||||
const image = document.createElement("img");
|
||||
image.className = "modal-surface-image";
|
||||
image.src = surface.image;
|
||||
image.alt = "";
|
||||
image.setAttribute("aria-hidden", "true");
|
||||
button.appendChild(image);
|
||||
} else {
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "material-symbols-outlined";
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
icon.textContent = surface.icon || "web_asset";
|
||||
button.appendChild(icon);
|
||||
}
|
||||
|
||||
button.addEventListener("click", async () => {
|
||||
if (button.disabled || isActive || !targetModalPath) return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await recordMode(normalizedId, SURFACE_MODE_FLOATING);
|
||||
const { ensureModalOpen } = await modalApi();
|
||||
const openPromise = ensureModalOpen(targetModalPath);
|
||||
if (openPromise?.catch) {
|
||||
openPromise.catch((error) => console.error(`Modal surface ${surface.id} failed to open`, error));
|
||||
}
|
||||
} finally {
|
||||
if (document.contains(button)) button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
function configureModalSurfaceSwitcher(modal, metadata) {
|
||||
if (!metadata || !modal?.header || modal.header.querySelector(".surface-switcher, .modal-surface-switcher")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const surfaces = getModalSwitchSurfaces(metadata);
|
||||
if (surfaces.length <= 1) return;
|
||||
|
||||
const switcher = document.createElement("div");
|
||||
switcher.className = "surface-switcher modal-surface-switcher";
|
||||
switcher.setAttribute("role", "group");
|
||||
switcher.setAttribute("aria-label", "Modal surfaces");
|
||||
|
||||
for (const surface of surfaces) {
|
||||
switcher.appendChild(createModalSurfaceButton(surface, metadata, modal));
|
||||
}
|
||||
|
||||
modal.close?.insertAdjacentElement("beforebegin", switcher);
|
||||
}
|
||||
|
||||
function configureModalDockButton(modal, metadata) {
|
||||
if (!metadata || !modal?.header || modal.header.querySelector(".surface-dock-button, .modal-dock-button")) {
|
||||
return;
|
||||
}
|
||||
|
||||
void recordMode(metadata.surfaceId, SURFACE_MODE_FLOATING);
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "surface-dock-button modal-dock-button";
|
||||
button.setAttribute("aria-label", metadata.title);
|
||||
button.innerHTML = `<span class="material-symbols-outlined" aria-hidden="true">${metadata.icon}</span>`;
|
||||
button.addEventListener("click", async () => {
|
||||
if (button.disabled) return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await dock(metadata.surfaceId, {
|
||||
modalPath: metadata.modalPath,
|
||||
sourceModalPath: modal.path,
|
||||
source: "modal",
|
||||
closeSourceModal: async () => {
|
||||
const closed = await closeSurfaceGroupModals();
|
||||
if (closed === false) return false;
|
||||
return !document.contains(modal.element);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (document.contains(button)) button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
modal.close?.insertAdjacentElement("beforebegin", button);
|
||||
}
|
||||
|
||||
async function configureSurfaceModal(event) {
|
||||
const { modal, doc } = event?.detail || {};
|
||||
const metadata = modalSurfaceMetadata(doc, modal?.path || "");
|
||||
if (!metadata) return;
|
||||
markSurfaceModal(modal, metadata);
|
||||
configureModalSurfaceSwitcher(modal, metadata);
|
||||
configureModalDockButton(modal, metadata);
|
||||
const { refreshModalStack } = await modalApi();
|
||||
refreshModalStack();
|
||||
}
|
||||
|
||||
export async function open(surfaceId = "", payload = {}) {
|
||||
const { store } = await import("/components/canvas/right-canvas-store.js");
|
||||
return await store.open(normalizeSurfaceId(surfaceId), payload);
|
||||
}
|
||||
|
||||
export async function openLatest(surfaceId = "", payload = {}) {
|
||||
const { store } = await import("/components/canvas/right-canvas-store.js");
|
||||
return await store.openLatest(normalizeSurfaceId(surfaceId), payload);
|
||||
}
|
||||
|
||||
export async function dock(surfaceId = "", payload = {}) {
|
||||
const { store } = await import("/components/canvas/right-canvas-store.js");
|
||||
return await store.dockSurface(normalizeSurfaceId(surfaceId), payload);
|
||||
}
|
||||
|
||||
export async function recordMode(surfaceId = "", mode = SURFACE_MODE_DOCKED, options = {}) {
|
||||
const { store } = await import("/components/canvas/right-canvas-store.js");
|
||||
return store.recordSurfaceMode?.(normalizeSurfaceId(surfaceId), normalizeSurfaceMode(mode), options);
|
||||
}
|
||||
|
||||
export function registerUrlHandler(handler) {
|
||||
if (typeof handler !== "function") return () => {};
|
||||
urlHandlers.add(handler);
|
||||
return () => urlHandlers.delete(handler);
|
||||
}
|
||||
|
||||
export async function handleUrlIntent(intent = {}) {
|
||||
for (const handler of Array.from(urlHandlers)) {
|
||||
const handled = await handler(intent);
|
||||
if (handled) return true;
|
||||
}
|
||||
globalThis.dispatchEvent?.(new CustomEvent("surface-url-intent", { detail: intent }));
|
||||
return false;
|
||||
}
|
||||
|
||||
document.addEventListener("modal-content-loaded", (event) => {
|
||||
void configureSurfaceModal(event);
|
||||
});
|
||||
|
||||
document.addEventListener("modal-activated", (event) => {
|
||||
void parkSiblingSurfaceModals(event?.detail?.modal);
|
||||
});
|
||||
|
||||
document.addEventListener("modal-closed", async () => {
|
||||
const { getModalStack, refreshModalStack } = await modalApi();
|
||||
const stack = getModalStack();
|
||||
if (stack.length > 0) {
|
||||
refreshModalStack();
|
||||
}
|
||||
});
|
||||
|
||||
globalThis.closeSurfaceGroupModals = closeSurfaceGroupModals;
|
||||
Loading…
Add table
Add a link
Reference in a new issue