Unify surface modal header actions

Add a shared grouped action rail for Browser, Desktop, and Editor floating surface modals so modality switches, canvas docking, focus mode, New, and close controls appear in a consistent order with separators. Fix the Desktop focus button class collision that hid the canvas docking button, and align dock button labels with canvas terminology.
This commit is contained in:
Alessandro 2026-05-22 14:19:35 +02:00
parent 8601f0d10c
commit b64c43e736
10 changed files with 234 additions and 48 deletions

View file

@ -7,7 +7,11 @@ 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";
import {
openLatest as openLatestSurface,
placeSurfaceModalHeaderAction,
registerUrlHandler,
} from "/js/surfaces.js";
const websocket = getNamespacedClient("/ws");
websocket.addHandlers(["ws_webui"]);
@ -2634,6 +2638,27 @@ const model = {
};
clampGeometry();
const newAction = globalThis.document.createElement("div");
newAction.className = "browser-header-actions surface-modal-new-action";
newAction.innerHTML = `
<button type="button" class="browser-header-new-button surface-modal-new-button" title="New Browser" aria-label="New Browser">
<span class="material-symbols-outlined" aria-hidden="true">add</span>
<span>New</span>
</button>
`;
const newButton = newAction.querySelector(".browser-header-new-button");
const onNewClick = async () => {
if (!newButton || newButton.disabled || this.isBusy()) return;
newButton.disabled = true;
try {
await this.openNewBrowser();
} finally {
if (globalThis.document?.contains?.(newButton)) newButton.disabled = false;
}
};
newButton?.addEventListener("click", onNewClick);
placeSurfaceModalHeaderAction(header, newAction, "new");
const focusButton = globalThis.document.createElement("button");
focusButton.type = "button";
focusButton.className = "surface-button browser-modal-focus-button";
@ -2658,12 +2683,7 @@ const model = {
updateFocusButton(false);
};
updateFocusButton(false);
const closeButton = inner.querySelector(".modal-close");
if (closeButton) {
closeButton.insertAdjacentElement("beforebegin", focusButton);
} else {
header.appendChild(focusButton);
}
placeSurfaceModalHeaderAction(header, focusButton, "window");
const onFocusClick = () => setFocusMode(!inner.classList.contains("is-focus-mode"));
focusButton.addEventListener("click", onFocusClick);
@ -2723,6 +2743,8 @@ const model = {
header.addEventListener("pointerdown", onPointerDown);
this._floatingCleanup = () => {
newButton?.removeEventListener("click", onNewClick);
newAction.remove();
focusButton.removeEventListener("click", onFocusClick);
focusButton.remove();
header.removeEventListener("pointerdown", onPointerDown);

View file

@ -2,7 +2,7 @@
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-title="Open Browser in canvas"
data-surface-dock-icon="dock_to_right"
data-canvas-surface="browser"
data-canvas-modal-path="/plugins/_browser/webui/main.html"

View file

@ -137,7 +137,7 @@
}
.modal-inner.office-modal .modal-header {
grid-template-columns: minmax(0, 1fr) repeat(5, auto);
grid-template-columns: minmax(0, 1fr) auto auto;
}
.office-modal-input-shield {

View file

@ -2,7 +2,7 @@ import { createStore } from "/js/AlpineStore.js";
import { callJsonApi } from "/js/api.js";
import { getNamespacedClient } from "/js/websocket.js";
import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
import { handleUrlIntent } from "/js/surfaces.js";
import { handleUrlIntent, placeSurfaceModalHeaderAction } from "/js/surfaces.js";
const officeSocket = getNamespacedClient("/ws");
officeSocket.addHandlers(["ws_webui"]);
@ -2337,9 +2337,9 @@ const model = {
if (!header || header.querySelector(".office-header-actions")) return () => {};
const root = globalThis.document.createElement("div");
root.className = "office-header-actions";
root.className = "office-header-actions surface-modal-new-action";
root.innerHTML = `
<button type="button" class="office-header-new-button" aria-haspopup="menu" aria-expanded="false">
<button type="button" class="office-header-new-button surface-modal-new-button" aria-haspopup="menu" aria-expanded="false">
<span class="material-symbols-outlined" aria-hidden="true">add</span>
<span>New</span>
<span class="material-symbols-outlined office-new-chevron" aria-hidden="true">expand_more</span>
@ -2396,14 +2396,7 @@ const model = {
globalThis.document.addEventListener("click", onDocumentClick);
globalThis.document.addEventListener("keydown", onDocumentKeydown);
const firstHeaderAction = header.querySelector(
".modal-surface-switcher, .modal-dock-button, .office-modal-focus-button, .modal-close",
);
if (firstHeaderAction) {
firstHeaderAction.insertAdjacentElement("beforebegin", root);
} else {
header.appendChild(root);
}
placeSurfaceModalHeaderAction(header, root, "new");
setOpen(false);
return () => {
@ -2501,20 +2494,16 @@ const model = {
const focusButton = globalThis.document.createElement("button");
focusButton.type = "button";
focusButton.className = "modal-dock-button office-modal-focus-button";
focusButton.className = "surface-button office-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";
};
updateFocusButton(false);
const closeButton = inner.querySelector(".modal-close");
if (closeButton) {
closeButton.insertAdjacentElement("beforebegin", focusButton);
} else {
header.appendChild(focusButton);
}
placeSurfaceModalHeaderAction(header, focusButton, "window");
cleanup.push(() => focusButton.remove());
const setFocusMode = (enabled) => {

View file

@ -2,7 +2,7 @@
class="surface-modal office-modal modal-no-backdrop"
data-surface-id="desktop"
data-surface-modal-path="/plugins/_desktop/webui/main.html"
data-surface-dock-title="Open Desktop in surface"
data-surface-dock-title="Open Desktop in canvas"
data-surface-dock-icon="dock_to_right"
data-canvas-surface="desktop"
data-canvas-modal-path="/plugins/_desktop/webui/main.html"

View file

@ -322,7 +322,7 @@
}
.modal-inner.editor-modal .modal-header {
grid-template-columns: minmax(0, 1fr) repeat(4, auto);
grid-template-columns: minmax(0, 1fr) auto auto;
}
.editor-tabs {

View file

@ -2,6 +2,7 @@ import { createStore } from "/js/AlpineStore.js";
import { callJsonApi } from "/js/api.js";
import { getNamespacedClient } from "/js/websocket.js";
import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
import { placeSurfaceModalHeaderAction } from "/js/surfaces.js";
import {
buildMarkdownPages,
isExternalHref,
@ -1591,9 +1592,9 @@ const model = {
if (!header || header.querySelector(".editor-header-actions")) return () => {};
const root = document.createElement("div");
root.className = "editor-header-actions";
root.className = "editor-header-actions surface-modal-new-action";
root.innerHTML = `
<button type="button" class="editor-header-new-button" aria-haspopup="menu" aria-expanded="false">
<button type="button" class="editor-header-new-button surface-modal-new-button" aria-haspopup="menu" aria-expanded="false">
<span class="material-symbols-outlined" aria-hidden="true">add</span>
<span>New</span>
<span class="material-symbols-outlined editor-new-chevron" aria-hidden="true">expand_more</span>
@ -1642,12 +1643,7 @@ const model = {
document.addEventListener("click", onMarkdownClick);
document.addEventListener("keydown", onMarkdownKeydown);
const firstHeaderAction = header.querySelector(".modal-close");
if (firstHeaderAction) {
firstHeaderAction.insertAdjacentElement("beforebegin", root);
} else {
header.appendChild(root);
}
placeSurfaceModalHeaderAction(header, root, "new");
setOpen(false);
return () => {
@ -1666,10 +1662,9 @@ const model = {
inner.dataset.editorModalReady = "1";
inner.classList.add("editor-modal");
const cleanup = [];
const closeButton = inner.querySelector(".modal-close");
const focusButton = document.createElement("button");
focusButton.type = "button";
focusButton.className = "modal-dock-button editor-modal-focus-button";
focusButton.className = "surface-button editor-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";
@ -1684,11 +1679,7 @@ const model = {
updateFocusButton(active);
};
focusButton.addEventListener("click", onFocusClick);
if (closeButton) {
closeButton.insertAdjacentElement("beforebegin", focusButton);
} else {
header.appendChild(focusButton);
}
placeSurfaceModalHeaderAction(header, focusButton, "window");
cleanup.push(() => focusButton.removeEventListener("click", onFocusClick));
cleanup.push(() => focusButton.remove());

View file

@ -2,7 +2,7 @@
class="surface-modal editor-modal modal-no-backdrop"
data-surface-id="editor"
data-surface-modal-path="/plugins/_editor/webui/main.html"
data-surface-dock-title="Open Editor in surface"
data-surface-dock-title="Open Editor in canvas"
data-surface-dock-icon="dock_to_right"
data-canvas-surface="editor"
data-canvas-modal-path="/plugins/_editor/webui/main.html"

View file

@ -29,6 +29,42 @@
gap: 5px;
}
.surface-modal-actions {
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 0;
}
.surface-modal-action-group {
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 0;
}
.surface-modal-action-group[hidden],
.surface-modal-action-separator[hidden] {
display: none;
}
.surface-modal-action-separator {
display: block;
flex: 0 0 1px;
width: 1px;
height: 20px;
margin: 0 5px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-border) 72%, transparent);
}
.surface-modal-new-action {
position: relative;
display: inline-flex;
align-items: center;
flex: 0 0 auto;
}
.surface-dock-button,
.surface-button,
.modal-dock-button,
@ -68,6 +104,62 @@
font-size: 19px;
}
.surface-modal-new-button {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
height: 34px;
min-height: 34px;
padding: 0 9px 0 8px;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
color: var(--color-text);
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 750;
letter-spacing: 0;
line-height: 1;
opacity: 0.82;
white-space: nowrap;
transition: background-color 0.16s ease, border-color 0.16s ease, opacity 0.16s ease;
}
.surface-modal-new-button:hover:not(:disabled),
.surface-modal-new-action.is-open .surface-modal-new-button {
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-modal-new-button:disabled {
cursor: default;
opacity: 0.45;
}
.surface-modal-new-button .material-symbols-outlined {
font-size: 18px;
line-height: 1;
}
@media (max-width: 560px) {
.surface-modal-actions,
.surface-modal-action-group {
gap: 3px;
}
.surface-modal-action-separator {
margin: 0 3px;
}
.surface-modal-new-button span:not(.material-symbols-outlined) {
display: none;
}
}
.surface-image,
.modal-surface-image {
display: block;

View file

@ -8,6 +8,7 @@ const LEGACY_SURFACE_IDS = new Map([
const registeredSurfaces = new Map();
const urlHandlers = new Set();
const SURFACE_MODAL_ACTION_GROUPS = ["surfaces", "window", "new"];
export const CORE_SURFACES = [
{
@ -277,6 +278,97 @@ function getModalSwitchSurfaces(metadata) {
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
}
function directChildByClass(parent, className) {
return Array.from(parent?.children || []).find((child) => child.classList?.contains(className)) || null;
}
function ensureSurfaceModalActionRail(header) {
if (!header) return null;
let rail = directChildByClass(header, "surface-modal-actions");
if (!rail) {
rail = document.createElement("div");
rail.className = "surface-modal-actions";
rail.setAttribute("aria-label", "Surface modal actions");
const closeButton = directChildByClass(header, "modal-close") || header.querySelector?.(".modal-close");
if (closeButton) {
closeButton.insertAdjacentElement("beforebegin", rail);
} else {
header.appendChild(rail);
}
}
for (const [index, groupName] of SURFACE_MODAL_ACTION_GROUPS.entries()) {
if (!rail.querySelector(`[data-surface-modal-action-group="${groupName}"]`)) {
if (index > 0 && !rail.querySelector(`[data-surface-modal-separator-before="${groupName}"]`)) {
const separator = document.createElement("span");
separator.className = "surface-modal-action-separator";
separator.dataset.surfaceModalSeparatorBefore = groupName;
separator.setAttribute("aria-hidden", "true");
rail.appendChild(separator);
}
const group = document.createElement("div");
group.className = `surface-modal-action-group surface-modal-action-group-${groupName}`;
group.dataset.surfaceModalActionGroup = groupName;
rail.appendChild(group);
}
}
refreshSurfaceModalActionRail(header);
return rail;
}
function surfaceModalActionGroup(header, groupName) {
const rail = ensureSurfaceModalActionRail(header);
return rail?.querySelector?.(`[data-surface-modal-action-group="${groupName}"]`) || null;
}
export function refreshSurfaceModalActionRail(header) {
const rail = directChildByClass(header, "surface-modal-actions");
if (!rail) return;
const groups = Object.fromEntries(
SURFACE_MODAL_ACTION_GROUPS.map((groupName) => [
groupName,
rail.querySelector(`[data-surface-modal-action-group="${groupName}"]`),
]),
);
const hasActions = Object.fromEntries(
Object.entries(groups).map(([groupName, group]) => [
groupName,
Boolean(group?.children?.length),
]),
);
for (const [groupName, group] of Object.entries(groups)) {
if (group) group.hidden = !hasActions[groupName];
}
const beforeWindow = rail.querySelector('[data-surface-modal-separator-before="window"]');
if (beforeWindow) beforeWindow.hidden = !(hasActions.surfaces && (hasActions.window || hasActions.new));
const beforeNew = rail.querySelector('[data-surface-modal-separator-before="new"]');
if (beforeNew) beforeNew.hidden = !(hasActions.window && hasActions.new);
}
export function placeSurfaceModalHeaderAction(header, element, groupName = "window", options = {}) {
if (!header || !element) return;
const normalizedGroup = SURFACE_MODAL_ACTION_GROUPS.includes(groupName) ? groupName : "window";
const group = surfaceModalActionGroup(header, normalizedGroup);
if (!group) return;
if (options.prepend) {
if (element.parentElement !== group || group.firstElementChild !== element) {
group.insertBefore(element, group.firstElementChild);
}
} else if (element.parentElement !== group) {
group.appendChild(element);
}
refreshSurfaceModalActionRail(header);
}
function markSurfaceModal(modal, metadata) {
const element = modal?.element;
const inner = modal?.inner || element?.querySelector?.(".modal-inner");
@ -350,11 +442,11 @@ function configureModalSurfaceSwitcher(modal, metadata) {
switcher.appendChild(createModalSurfaceButton(surface, metadata, modal));
}
modal.close?.insertAdjacentElement("beforebegin", switcher);
placeSurfaceModalHeaderAction(modal.header, switcher, "surfaces");
}
function configureModalDockButton(modal, metadata) {
if (!metadata || !modal?.header || modal.header.querySelector(".surface-dock-button, .modal-dock-button")) {
if (!metadata || !modal?.header || modal.header.querySelector(".surface-dock-button")) {
return;
}
@ -384,7 +476,7 @@ function configureModalDockButton(modal, metadata) {
}
});
modal.close?.insertAdjacentElement("beforebegin", button);
placeSurfaceModalHeaderAction(modal.header, button, "window", { prepend: true });
}
async function configureSurfaceModal(event) {