mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-07-09 17:08:29 +00:00
Promote Files into shared canvas surface
Add Files to the universal canvas rail and sidebar flow, with a reusable floating surface modal chrome that matches Browser/Desktop behavior. Make the File Browser modal draggable/resizable with Focus mode, keep Editor on the same draggable modal helper, and preserve dock/undock handoff state. Harden File Browser startup so empty paths resolve to the default workdir, restyle the Up control, compact New file/New folder actions, and hide Modified before Name/Size in narrow containers. Update DOX contracts and focused regression coverage for the new Files surface, modal chrome, default-path fallback, and compact layout behavior.
This commit is contained in:
parent
51f3ed00a1
commit
ffddc3ebcb
20 changed files with 879 additions and 110 deletions
|
|
@ -9,7 +9,7 @@ class GetWorkDirFiles(ApiHandler):
|
||||||
return ["GET"]
|
return ["GET"]
|
||||||
|
|
||||||
async def process(self, input: dict, request: Request) -> dict | Response:
|
async def process(self, input: dict, request: Request) -> dict | Response:
|
||||||
current_path = request.args.get("path", "")
|
current_path = request.args.get("path", "") or "$WORK_DIR"
|
||||||
if current_path == "$WORK_DIR":
|
if current_path == "$WORK_DIR":
|
||||||
# if runtime.is_development():
|
# if runtime.is_development():
|
||||||
# current_path = "work_dir"
|
# current_path = "work_dir"
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@
|
||||||
## Key Concepts
|
## Key Concepts
|
||||||
|
|
||||||
- Important called helpers/classes observed in the source: `FileBrowser`, `browser.get_files`, `runtime.call_development_function`.
|
- Important called helpers/classes observed in the source: `FileBrowser`, `browser.get_files`, `runtime.call_development_function`.
|
||||||
|
- Empty `path` requests and explicit `$WORK_DIR` requests resolve to the default workdir path before `FileBrowser` is called, so the WebUI never receives an empty startup path for the default file browser view.
|
||||||
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
|
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
|
||||||
|
|
||||||
## Work Guidance
|
## Work Guidance
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ Direct child DOX files:
|
||||||
| [initFw_end/AGENTS.md](initFw_end/AGENTS.md) | Post-WebUI-framework-initialization extensions. |
|
| [initFw_end/AGENTS.md](initFw_end/AGENTS.md) | Post-WebUI-framework-initialization extensions. |
|
||||||
| [json_api_call_after/AGENTS.md](json_api_call_after/AGENTS.md) | Frontend hooks after `callJsonApi()` calls. |
|
| [json_api_call_after/AGENTS.md](json_api_call_after/AGENTS.md) | Frontend hooks after `callJsonApi()` calls. |
|
||||||
| [json_api_call_before/AGENTS.md](json_api_call_before/AGENTS.md) | Frontend hooks before `callJsonApi()` calls. |
|
| [json_api_call_before/AGENTS.md](json_api_call_before/AGENTS.md) | Frontend hooks before `callJsonApi()` calls. |
|
||||||
|
| [right-canvas-panels/AGENTS.md](right-canvas-panels/AGENTS.md) | Built-in right-canvas panel HTML contributions. |
|
||||||
| [right_canvas_register_surfaces/AGENTS.md](right_canvas_register_surfaces/AGENTS.md) | Built-in right-canvas surface registrations. |
|
| [right_canvas_register_surfaces/AGENTS.md](right_canvas_register_surfaces/AGENTS.md) | Built-in right-canvas surface registrations. |
|
||||||
| [set_messages_after_loop/AGENTS.md](set_messages_after_loop/AGENTS.md) | Frontend hooks after message DOM updates. |
|
| [set_messages_after_loop/AGENTS.md](set_messages_after_loop/AGENTS.md) | Frontend hooks after message DOM updates. |
|
||||||
| [set_messages_before_loop/AGENTS.md](set_messages_before_loop/AGENTS.md) | Frontend hooks before message DOM updates. |
|
| [set_messages_before_loop/AGENTS.md](set_messages_before_loop/AGENTS.md) | Frontend hooks before message DOM updates. |
|
||||||
|
|
|
||||||
28
extensions/webui/right-canvas-panels/AGENTS.md
Normal file
28
extensions/webui/right-canvas-panels/AGENTS.md
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Right Canvas Panel Extensions DOX
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
- Own built-in HTML panel contributions for the right-canvas surface area.
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
- `.html` files mount WebUI components into the `right-canvas-panels` extension point.
|
||||||
|
- Panel wrappers own `data-surface-id` anchors and active/mounted visibility bindings.
|
||||||
|
|
||||||
|
## Local Contracts
|
||||||
|
|
||||||
|
- Each panel must correspond to a registered right-canvas surface ID.
|
||||||
|
- Use `<x-component>` for reusable component content instead of duplicating panel implementations.
|
||||||
|
- Keep canvas panels compatible with `.right-canvas-surface-panel` layout semantics.
|
||||||
|
|
||||||
|
## Work Guidance
|
||||||
|
|
||||||
|
- Prefer thin wrappers that delegate lifecycle and state to the owning component store.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Smoke-test opening the matching surface from the right-canvas rail.
|
||||||
|
|
||||||
|
## Child DOX Index
|
||||||
|
|
||||||
|
No child DOX files.
|
||||||
11
extensions/webui/right-canvas-panels/files-panel.html
Normal file
11
extensions/webui/right-canvas-panels/files-panel.html
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<div
|
||||||
|
class="right-canvas-surface-panel files-canvas-surface"
|
||||||
|
data-surface-id="files"
|
||||||
|
:class="{
|
||||||
|
'is-active': $store.rightCanvas?.isSurfaceVisible('files'),
|
||||||
|
'is-mounted': $store.rightCanvas?.isSurfaceRendered('files')
|
||||||
|
}"
|
||||||
|
:aria-hidden="(!$store.rightCanvas?.isSurfaceVisible('files')).toString()"
|
||||||
|
>
|
||||||
|
<x-component path="modals/file-browser/file-browser.html" mode="canvas"></x-component>
|
||||||
|
</div>
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
## Ownership
|
## Ownership
|
||||||
|
|
||||||
- JavaScript files own registration of remote link, space agent, and future core canvas surfaces.
|
- JavaScript files own registration of remote link, space agent, file browser, and future core canvas surfaces.
|
||||||
|
|
||||||
## Local Contracts
|
## Local Contracts
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { store as fileBrowserStore } from "/components/modals/file-browser/file-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(document.querySelector(selector));
|
||||||
|
}, 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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function registerFilesSurface(surfaces) {
|
||||||
|
surfaces.registerSurface({
|
||||||
|
id: "files",
|
||||||
|
title: "Files",
|
||||||
|
icon: "folder",
|
||||||
|
order: 5,
|
||||||
|
modalPath: "modals/file-browser/file-browser.html",
|
||||||
|
beginDockHandoff() {
|
||||||
|
fileBrowserStore.beginSurfaceHandoff?.();
|
||||||
|
},
|
||||||
|
finishDockHandoff(payload = {}) {
|
||||||
|
fileBrowserStore.finishSurfaceHandoff?.(payload);
|
||||||
|
},
|
||||||
|
cancelDockHandoff() {
|
||||||
|
fileBrowserStore.cancelSurfaceHandoff?.();
|
||||||
|
},
|
||||||
|
async open(payload = {}) {
|
||||||
|
const panel = await waitForElement('[data-surface-id="files"] .file-browser-root');
|
||||||
|
if (!panel) throw new Error("Files surface panel did not mount.");
|
||||||
|
await fileBrowserStore.openSurface(payload.path || payload.filePath || payload.directory || "");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
- Keep editor session state synchronized across API, WebSocket, and WebUI panel behavior.
|
- Keep editor session state synchronized across API, WebSocket, and WebUI panel behavior.
|
||||||
- Do not expose unsaved content or local paths beyond intended chat/context surfaces.
|
- Do not expose unsaved content or local paths beyond intended chat/context surfaces.
|
||||||
|
- Keep the floating Editor modal on the shared surface modal chrome so the header remains draggable while existing Focus mode continues to work.
|
||||||
|
|
||||||
## Work Guidance
|
## Work Guidance
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ import { createStore } from "/js/AlpineStore.js";
|
||||||
import { callJsonApi } from "/js/api.js";
|
import { callJsonApi } from "/js/api.js";
|
||||||
import { getNamespacedClient } from "/js/websocket.js";
|
import { getNamespacedClient } from "/js/websocket.js";
|
||||||
import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
|
import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
|
||||||
import { placeSurfaceModalHeaderAction } from "/js/surfaces.js";
|
import {
|
||||||
|
placeSurfaceModalHeaderAction,
|
||||||
|
setupFloatingSurfaceModalChrome,
|
||||||
|
} from "/js/surfaces.js";
|
||||||
import {
|
import {
|
||||||
buildMarkdownPages,
|
buildMarkdownPages,
|
||||||
isExternalHref,
|
isExternalHref,
|
||||||
|
|
@ -1658,38 +1661,21 @@ const model = {
|
||||||
if (!inner || !header || inner.dataset.editorModalReady === "1") return;
|
if (!inner || !header || inner.dataset.editorModalReady === "1") return;
|
||||||
inner.dataset.editorModalReady = "1";
|
inner.dataset.editorModalReady = "1";
|
||||||
inner.classList.add("editor-modal");
|
inner.classList.add("editor-modal");
|
||||||
const cleanup = [];
|
const floatingCleanup = setupFloatingSurfaceModalChrome({
|
||||||
const focusButton = document.createElement("button");
|
root,
|
||||||
focusButton.type = "button";
|
modalClass: "editor-modal",
|
||||||
focusButton.className = "surface-button editor-modal-focus-button";
|
focusButtonClass: "editor-modal-focus-button",
|
||||||
focusButton.innerHTML = '<span class="material-symbols-outlined" aria-hidden="true">fullscreen</span>';
|
minWidth: 640,
|
||||||
const updateFocusButton = (active) => {
|
minHeight: 460,
|
||||||
const label = active ? "Restore size" : "Focus mode";
|
onBoundsChange: () => this.refreshSourceEditorLayout(),
|
||||||
focusButton.setAttribute("aria-label", label);
|
onFocusChange: () => this.refreshSourceEditorLayout(),
|
||||||
focusButton.setAttribute("title", label);
|
});
|
||||||
focusButton.querySelector(".material-symbols-outlined").textContent = active ? "fullscreen_exit" : "fullscreen";
|
|
||||||
};
|
|
||||||
updateFocusButton(false);
|
|
||||||
const onFocusClick = () => {
|
|
||||||
const active = !inner.classList.contains("is-focus-mode");
|
|
||||||
inner.classList.toggle("is-focus-mode", active);
|
|
||||||
updateFocusButton(active);
|
|
||||||
};
|
|
||||||
focusButton.addEventListener("click", onFocusClick);
|
|
||||||
placeSurfaceModalHeaderAction(header, focusButton, "window");
|
|
||||||
cleanup.push(() => focusButton.removeEventListener("click", onFocusClick));
|
|
||||||
cleanup.push(() => focusButton.remove());
|
|
||||||
|
|
||||||
this._headerCleanup = () => {
|
|
||||||
cleanup.splice(0).reverse().forEach((entry) => entry());
|
|
||||||
delete inner.dataset.editorModalReady;
|
|
||||||
inner.classList.remove("editor-modal", "is-focus-mode");
|
|
||||||
};
|
|
||||||
const menuCleanup = this.installHeaderNewMenu(header);
|
const menuCleanup = this.installHeaderNewMenu(header);
|
||||||
const previousCleanup = this._headerCleanup;
|
|
||||||
this._headerCleanup = () => {
|
this._headerCleanup = () => {
|
||||||
menuCleanup?.();
|
menuCleanup?.();
|
||||||
previousCleanup?.();
|
floatingCleanup?.();
|
||||||
|
delete inner.dataset.editorModalReady;
|
||||||
|
inner.classList.remove("editor-modal", "is-focus-mode");
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,13 @@ def test_file_browser_editable_path_bar_and_remembered_directory_contract() -> N
|
||||||
workdir_settings = read("webui", "components", "settings", "agent", "workdir.html")
|
workdir_settings = read("webui", "components", "settings", "agent", "workdir.html")
|
||||||
|
|
||||||
assert 'class="path-navigator"' in html
|
assert 'class="path-navigator"' in html
|
||||||
|
assert 'class="nav-button back-button"' in html
|
||||||
|
assert 'class="text-button back-button"' not in html
|
||||||
|
assert ".nav-button:focus-visible" in html
|
||||||
|
assert ".nav-button .material-symbols-outlined" in html
|
||||||
|
assert 'class="nav-button-label">Up</span>' in html
|
||||||
|
assert "flex-direction: column;" in html
|
||||||
|
assert ".nav-button-label" in html
|
||||||
assert 'x-model="$store.fileBrowser.pathInput"' in html
|
assert 'x-model="$store.fileBrowser.pathInput"' in html
|
||||||
assert '@submit.prevent="$store.fileBrowser.submitPath()"' in html
|
assert '@submit.prevent="$store.fileBrowser.submitPath()"' in html
|
||||||
assert "Go to directory" in html
|
assert "Go to directory" in html
|
||||||
|
|
@ -39,6 +46,12 @@ def test_file_browser_editable_path_bar_and_remembered_directory_contract() -> N
|
||||||
assert "getRememberedDirectory()" in store
|
assert "getRememberedDirectory()" in store
|
||||||
assert "rememberCurrentDirectory(this.browser.currentPath)" in store
|
assert "rememberCurrentDirectory(this.browser.currentPath)" in store
|
||||||
assert "clearRememberedDirectory()" in store
|
assert "clearRememberedDirectory()" in store
|
||||||
|
assert "scheduleMountedDefaultLoad()" in store
|
||||||
|
assert 'this.browser.currentPath = "";' in store
|
||||||
|
assert 'this.browser.parentPath = "";' in store
|
||||||
|
assert 'const requestedPath = this.normalizeOpeningPath(path) || "$WORK_DIR";' in store
|
||||||
|
assert "`/get_work_dir_files?path=${encodeURIComponent(requestedPath)}`" in store
|
||||||
|
assert 'result.current_path || (requestedPath === "$WORK_DIR" ? "/a0" : requestedPath)' in store
|
||||||
|
|
||||||
explicit_path_index = store.index("const explicitPath = this.normalizeOpeningPath")
|
explicit_path_index = store.index("const explicitPath = this.normalizeOpeningPath")
|
||||||
remembered_path_index = store.index("const rememberedPath = !explicitPath")
|
remembered_path_index = store.index("const rememberedPath = !explicitPath")
|
||||||
|
|
@ -48,6 +61,71 @@ def test_file_browser_editable_path_bar_and_remembered_directory_contract() -> N
|
||||||
assert "$store.settings.settings.file_browser_remember_last_directory" in workdir_settings
|
assert "$store.settings.settings.file_browser_remember_last_directory" in workdir_settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_browser_compact_controls_and_narrow_layout_contract() -> None:
|
||||||
|
html = read("webui", "components", "modals", "file-browser", "file-browser.html")
|
||||||
|
dox = read("webui", "components", "modals", "file-browser", "AGENTS.md")
|
||||||
|
|
||||||
|
assert 'aria-label="New file"' in html
|
||||||
|
assert 'title="New file"' in html
|
||||||
|
assert 'aria-label="New folder"' in html
|
||||||
|
assert 'title="New folder"' in html
|
||||||
|
assert ">New File<" not in html
|
||||||
|
assert ">New Folder<" not in html
|
||||||
|
assert ".btn-new-item" in html
|
||||||
|
assert "width: 2.8rem;" in html
|
||||||
|
assert "height: 2.8rem;" in html
|
||||||
|
|
||||||
|
assert "container: file-browser / inline-size;" in html
|
||||||
|
assert "@container file-browser (max-width: 620px)" in html
|
||||||
|
assert "grid-template-columns: 2.25rem minmax(0, 1fr) minmax(4.25rem, max-content) 5.25rem;" in html
|
||||||
|
assert ".file-cell-date,\n .file-date {\n display: none;" in html
|
||||||
|
assert ".file-cell-size,\n .file-size" not in html
|
||||||
|
|
||||||
|
assert "hiding the Modified date column" in dox
|
||||||
|
assert "New file and New folder controls icon-only" in dox
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_browser_empty_api_path_uses_default_workdir_contract() -> None:
|
||||||
|
api_source = read("api", "get_work_dir_files.py")
|
||||||
|
api_dox = read("api", "get_work_dir_files.py.dox.md")
|
||||||
|
|
||||||
|
assert 'current_path = request.args.get("path", "") or "$WORK_DIR"' in api_source
|
||||||
|
assert 'current_path = "/a0"' in api_source
|
||||||
|
assert "Empty `path` requests and explicit `$WORK_DIR` requests resolve" in api_dox
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_browser_is_registered_as_right_canvas_surface() -> None:
|
||||||
|
html = read("webui", "components", "modals", "file-browser", "file-browser.html")
|
||||||
|
store = read("webui", "components", "modals", "file-browser", "file-browser-store.js")
|
||||||
|
surfaces = read("webui", "js", "surfaces.js")
|
||||||
|
register = read("extensions", "webui", "right_canvas_register_surfaces", "register-files.js")
|
||||||
|
panel = read("extensions", "webui", "right-canvas-panels", "files-panel.html")
|
||||||
|
input_store = read("webui", "components", "chat", "input", "input-store.js")
|
||||||
|
|
||||||
|
assert 'id: "files"' in surfaces
|
||||||
|
assert 'title: "Files"' in surfaces
|
||||||
|
assert 'modalPath: "modals/file-browser/file-browser.html"' in surfaces
|
||||||
|
assert 'await store.openSurface(payload.path || payload.filePath || payload.directory || "")' in surfaces
|
||||||
|
assert 'data-surface-id="files"' in html
|
||||||
|
assert 'data-surface-modal-path="modals/file-browser/file-browser.html"' in html
|
||||||
|
assert 'class="surface-modal file-browser-modal modal-no-backdrop"' in html
|
||||||
|
assert 'class="file-browser-modal-body"' in html
|
||||||
|
assert 'x-create="$store.fileBrowser.onMount($el, xAttrs($el) || {})"' in html
|
||||||
|
assert 'x-destroy="$store.fileBrowser.onUnmount(xAttrs($el) || {})"' in html
|
||||||
|
assert ".modal-inner.file-browser-modal" in html
|
||||||
|
assert "resize: both" in html
|
||||||
|
assert "openSurface(path" in store
|
||||||
|
assert "setupFloatingSurfaceModalChrome" in store
|
||||||
|
assert 'focusButtonClass: "file-browser-modal-focus-button"' in store
|
||||||
|
assert "beginSurfaceHandoff()" in store
|
||||||
|
assert "finishSurfaceHandoff()" in store
|
||||||
|
assert 'id: "files"' in register
|
||||||
|
assert "fileBrowserStore.openSurface" in register
|
||||||
|
assert 'data-surface-id="files"' in panel
|
||||||
|
assert 'path="modals/file-browser/file-browser.html" mode="canvas"' in panel
|
||||||
|
assert 'openLatestSurface("files"' in input_store
|
||||||
|
|
||||||
|
|
||||||
def test_file_browser_reports_missing_directory(tmp_path: Path) -> None:
|
def test_file_browser_reports_missing_directory(tmp_path: Path) -> None:
|
||||||
missing_directory = tmp_path / "missing"
|
missing_directory = tmp_path / "missing"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,36 @@ def test_browser_surface_restores_focus_mode_chrome():
|
||||||
assert ".modal-inner.browser-modal.is-focus-mode" in browser_panel
|
assert ".modal-inner.browser-modal.is-focus-mode" in browser_panel
|
||||||
|
|
||||||
|
|
||||||
|
def test_files_and_editor_surface_modals_have_draggable_focus_chrome():
|
||||||
|
surfaces_js = read("webui", "js", "surfaces.js")
|
||||||
|
surfaces_css = read("webui", "css", "surfaces.css")
|
||||||
|
file_store = read("webui", "components", "modals", "file-browser", "file-browser-store.js")
|
||||||
|
file_modal = read("webui", "components", "modals", "file-browser", "file-browser.html")
|
||||||
|
editor_store = read("plugins", "_editor", "webui", "editor-store.js")
|
||||||
|
editor_panel = read("plugins", "_editor", "webui", "editor-panel.html")
|
||||||
|
|
||||||
|
assert "setupFloatingSurfaceModalChrome" in surfaces_js
|
||||||
|
assert "is-draggable-surface-modal" in surfaces_js
|
||||||
|
assert "surface-modal-focus-button" in surfaces_js
|
||||||
|
assert "fullscreen_exit" in surfaces_js
|
||||||
|
assert "Focus mode" in surfaces_js
|
||||||
|
assert "Restore size" in surfaces_js
|
||||||
|
assert ".modal-inner.surface-modal.is-draggable-surface-modal .modal-header" in surfaces_css
|
||||||
|
assert "cursor: move" in surfaces_css
|
||||||
|
assert ".surface-modal-focus-button.is-active" in surfaces_css
|
||||||
|
|
||||||
|
assert "setupFloatingSurfaceModalChrome" in file_store
|
||||||
|
assert 'focusButtonClass: "file-browser-modal-focus-button"' in file_store
|
||||||
|
assert ".modal-inner.file-browser-modal" in file_modal
|
||||||
|
assert "resize: both" in file_modal
|
||||||
|
|
||||||
|
assert "setupFloatingSurfaceModalChrome" in editor_store
|
||||||
|
assert 'focusButtonClass: "editor-modal-focus-button"' in editor_store
|
||||||
|
assert "onBoundsChange: () => this.refreshSourceEditorLayout()" in editor_store
|
||||||
|
assert "editor.resize?.(true)" in editor_store
|
||||||
|
assert ".modal-inner.editor-modal.is-focus-mode" in editor_panel
|
||||||
|
|
||||||
|
|
||||||
def test_office_frontend_is_document_only_and_does_not_import_browser_or_desktop_runtime_code():
|
def test_office_frontend_is_document_only_and_does_not_import_browser_or_desktop_runtime_code():
|
||||||
office_store = read("plugins", "_office", "webui", "office-store.js")
|
office_store = read("plugins", "_office", "webui", "office-store.js")
|
||||||
office_panel = read("plugins", "_office", "webui", "office-panel.html")
|
office_panel = read("plugins", "_office", "webui", "office-panel.html")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { createStore } from "/js/AlpineStore.js";
|
import { createStore } from "/js/AlpineStore.js";
|
||||||
import * as shortcuts from "/js/shortcuts.js";
|
import * as shortcuts from "/js/shortcuts.js";
|
||||||
import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
|
import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
|
||||||
|
import { openLatest as openLatestSurface } from "/js/surfaces.js";
|
||||||
import { store as messageQueueStore } from "/components/chat/message-queue/message-queue-store.js";
|
import { store as messageQueueStore } from "/components/chat/message-queue/message-queue-store.js";
|
||||||
import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
|
import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
|
||||||
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
|
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
|
||||||
|
|
@ -330,7 +331,13 @@ const model = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await fileBrowserStore.open(path);
|
let opened = false;
|
||||||
|
try {
|
||||||
|
opened = await openLatestSurface("files", { path, source: "sidebar" });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error opening Files surface", error);
|
||||||
|
}
|
||||||
|
if (!opened) await fileBrowserStore.open(path);
|
||||||
},
|
},
|
||||||
|
|
||||||
focus() {
|
focus() {
|
||||||
|
|
|
||||||
|
|
@ -27,4 +27,8 @@
|
||||||
|
|
||||||
## Child DOX Index
|
## Child DOX Index
|
||||||
|
|
||||||
No child DOX files.
|
Direct child DOX files:
|
||||||
|
|
||||||
|
| Child | Scope |
|
||||||
|
| --- | --- |
|
||||||
|
| [file-browser/AGENTS.md](file-browser/AGENTS.md) | File browser modal and right-canvas Files surface workflow. |
|
||||||
|
|
|
||||||
36
webui/components/modals/file-browser/AGENTS.md
Normal file
36
webui/components/modals/file-browser/AGENTS.md
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# File Browser Modal DOX
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
- Own the WebUI file browser workflow for modal and right-canvas Files surface entry points.
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
- `file-browser.html` owns file list markup, path/search controls, scoped styles, and modal/canvas footer behavior.
|
||||||
|
- `file-browser-store.js` owns directory loading, remembered-location state, selection, upload/download/delete actions, and surface handoff state.
|
||||||
|
- `rename-modal.html` owns rename and create-folder prompts that reuse the file-browser store.
|
||||||
|
|
||||||
|
## Local Contracts
|
||||||
|
|
||||||
|
- Keep `open(path)` as the modal entry point for workflows that await browser close.
|
||||||
|
- Keep `openSurface(path)` as the right-canvas entry point; it must load files without opening or awaiting a modal.
|
||||||
|
- The floating file-browser modal must use the shared surface modal chrome so it remains draggable/resizable and exposes Focus mode.
|
||||||
|
- Preserve remembered-directory behavior: explicit paths win, then remembered path, then `$WORK_DIR`.
|
||||||
|
- Empty mounted startup states must self-heal to the `$WORK_DIR` default instead of rendering a blank path and empty list.
|
||||||
|
- Keep the file list readable in narrow canvas/modal containers by hiding the Modified date column before sacrificing the Name or Size columns.
|
||||||
|
- Keep New file and New folder controls icon-only across canvas and modal modes while preserving accessible labels.
|
||||||
|
- Preserve surface actions that route supported files to Browser, Desktop, or Editor.
|
||||||
|
|
||||||
|
## Work Guidance
|
||||||
|
|
||||||
|
- Share markup and store behavior between modal and canvas modes; branch only on explicit component `mode`.
|
||||||
|
- Keep modal footer relocation compatible with `data-modal-footer` while allowing canvas mode to render inline controls.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Smoke-test opening Files as a modal and from the right-canvas rail.
|
||||||
|
- Run targeted file-browser tests after behavior changes.
|
||||||
|
|
||||||
|
## Child DOX Index
|
||||||
|
|
||||||
|
No child DOX files.
|
||||||
|
|
@ -2,8 +2,12 @@ import { createStore } from "/js/AlpineStore.js";
|
||||||
import { callJsonApi, fetchApi } from "/js/api.js";
|
import { callJsonApi, fetchApi } from "/js/api.js";
|
||||||
import { formatDateTime } from "/js/time-utils.js";
|
import { formatDateTime } from "/js/time-utils.js";
|
||||||
import { store as fileEditorStore } from "/components/modals/file-editor/file-editor-store.js";
|
import { store as fileEditorStore } from "/components/modals/file-editor/file-editor-store.js";
|
||||||
import { openLatest as openLatestSurface } from "/js/surfaces.js";
|
import {
|
||||||
|
openLatest as openLatestSurface,
|
||||||
|
setupFloatingSurfaceModalChrome,
|
||||||
|
} from "/js/surfaces.js";
|
||||||
|
|
||||||
|
const FILE_BROWSER_MODAL_PATH = "modals/file-browser/file-browser.html";
|
||||||
const FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY = "fileBrowser.lastDirectory";
|
const FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY = "fileBrowser.lastDirectory";
|
||||||
const DEFAULT_REMEMBER_LAST_DIRECTORY = true;
|
const DEFAULT_REMEMBER_LAST_DIRECTORY = true;
|
||||||
const MARKDOWN_EXTENSIONS = new Set(["md", "markdown", "mdown"]);
|
const MARKDOWN_EXTENSIONS = new Set(["md", "markdown", "mdown"]);
|
||||||
|
|
@ -61,6 +65,8 @@ const model = {
|
||||||
history: [], // navigation stack
|
history: [], // navigation stack
|
||||||
initialPath: "", // Store path for open() call
|
initialPath: "", // Store path for open() call
|
||||||
closePromise: null,
|
closePromise: null,
|
||||||
|
isSurfaceHandoff: false,
|
||||||
|
surfaceHandoffPath: "",
|
||||||
error: null,
|
error: null,
|
||||||
pathInput: "",
|
pathInput: "",
|
||||||
pathError: "",
|
pathError: "",
|
||||||
|
|
@ -68,6 +74,8 @@ const model = {
|
||||||
rememberLastDirectory: DEFAULT_REMEMBER_LAST_DIRECTORY,
|
rememberLastDirectory: DEFAULT_REMEMBER_LAST_DIRECTORY,
|
||||||
settingsLoadPromise: null,
|
settingsLoadPromise: null,
|
||||||
settingsUpdatedHandler: null,
|
settingsUpdatedHandler: null,
|
||||||
|
_floatingCleanup: null,
|
||||||
|
_mountedDefaultLoadTimer: null,
|
||||||
renameTarget: null,
|
renameTarget: null,
|
||||||
renameName: "",
|
renameName: "",
|
||||||
renameMode: "rename",
|
renameMode: "rename",
|
||||||
|
|
@ -92,43 +100,36 @@ const model = {
|
||||||
document.addEventListener("settings-updated", this.settingsUpdatedHandler);
|
document.addEventListener("settings-updated", this.settingsUpdatedHandler);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onMount(element = null, options = {}) {
|
||||||
|
this._floatingCleanup?.();
|
||||||
|
this._floatingCleanup = null;
|
||||||
|
const mode = options?.mode === "canvas" ? "canvas" : "modal";
|
||||||
|
if (mode === "modal") {
|
||||||
|
this.setupFloatingModal(element);
|
||||||
|
} else {
|
||||||
|
this.scheduleMountedDefaultLoad();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onUnmount() {
|
||||||
|
this._floatingCleanup?.();
|
||||||
|
this._floatingCleanup = null;
|
||||||
|
this.cancelMountedDefaultLoad();
|
||||||
|
},
|
||||||
|
|
||||||
// --- Public API (called from button/link) --------------------------------
|
// --- Public API (called from button/link) --------------------------------
|
||||||
async open(path = "") {
|
async open(path = "") {
|
||||||
if (this.isLoading) return; // Prevent double-open
|
if (this.isLoading) return; // Prevent double-open
|
||||||
this.isLoading = true;
|
this.resetOpenState();
|
||||||
this.error = null;
|
|
||||||
this.history = [];
|
|
||||||
this.searchQuery = "";
|
|
||||||
this.isBulkBusy = false;
|
|
||||||
this.pathError = "";
|
|
||||||
this.isPathSubmitting = false;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Open modal FIRST (immediate UI feedback)
|
// Open modal FIRST (immediate UI feedback)
|
||||||
this.closePromise = window.openModal(
|
this.closePromise = window.openModal(FILE_BROWSER_MODAL_PATH);
|
||||||
"modals/file-browser/file-browser.html"
|
await this.loadOpeningPath(path);
|
||||||
);
|
|
||||||
|
|
||||||
await this.loadDirectoryPreference();
|
|
||||||
const explicitPath = this.normalizeOpeningPath(path || this.initialPath);
|
|
||||||
const rememberedPath = !explicitPath ? this.getRememberedDirectory() : "";
|
|
||||||
path = explicitPath || rememberedPath || "$WORK_DIR";
|
|
||||||
this.browser.currentPath = path;
|
|
||||||
this.syncPathInput();
|
|
||||||
|
|
||||||
// Fetch files
|
|
||||||
const loaded = await this.fetchFiles(this.browser.currentPath, {
|
|
||||||
preserveOnError: Boolean(rememberedPath && path === rememberedPath),
|
|
||||||
suppressErrorToast: Boolean(rememberedPath && path === rememberedPath),
|
|
||||||
});
|
|
||||||
if (!loaded && rememberedPath && path === rememberedPath) {
|
|
||||||
this.clearRememberedDirectory();
|
|
||||||
await this.fetchFiles("$WORK_DIR");
|
|
||||||
}
|
|
||||||
|
|
||||||
// await modal close
|
// await modal close
|
||||||
await this.closePromise;
|
await this.closePromise;
|
||||||
this.destroy();
|
if (!this.isSurfaceHandoff) this.destroy();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("File browser error:", error);
|
console.error("File browser error:", error);
|
||||||
|
|
@ -137,17 +138,45 @@ const model = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async openSurface(path = "") {
|
||||||
|
if (this.isLoading) return false;
|
||||||
|
this.resetOpenState();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const retainedPath = this.normalizeOpeningPath(
|
||||||
|
path
|
||||||
|
|| this.surfaceHandoffPath
|
||||||
|
|| this.browser.currentPath
|
||||||
|
|| this.initialPath
|
||||||
|
);
|
||||||
|
return await this.loadOpeningPath(retainedPath);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("File browser surface error:", error);
|
||||||
|
this.error = error?.message || "Failed to load files";
|
||||||
|
this.isLoading = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
handleClose() {
|
handleClose() {
|
||||||
// Close the modal manually
|
// Close the modal manually
|
||||||
this.disposeScopedTooltips();
|
this.disposeScopedTooltips();
|
||||||
window.closeModal();
|
window.closeModal(FILE_BROWSER_MODAL_PATH);
|
||||||
},
|
},
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
|
this._floatingCleanup?.();
|
||||||
|
this._floatingCleanup = null;
|
||||||
|
this.cancelMountedDefaultLoad();
|
||||||
// Reset state when modal closes
|
// Reset state when modal closes
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
this.history = [];
|
this.history = [];
|
||||||
this.initialPath = "";
|
this.initialPath = "";
|
||||||
|
this.closePromise = null;
|
||||||
|
this.isSurfaceHandoff = false;
|
||||||
|
this.surfaceHandoffPath = "";
|
||||||
|
this.browser.currentPath = "";
|
||||||
|
this.browser.parentPath = "";
|
||||||
this.browser.entries = [];
|
this.browser.entries = [];
|
||||||
this.openDropdownPath = null;
|
this.openDropdownPath = null;
|
||||||
this.searchQuery = "";
|
this.searchQuery = "";
|
||||||
|
|
@ -158,7 +187,87 @@ const model = {
|
||||||
this.resetRenameState();
|
this.resetRenameState();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setupFloatingModal(element = null) {
|
||||||
|
this._floatingCleanup?.();
|
||||||
|
this._floatingCleanup = setupFloatingSurfaceModalChrome({
|
||||||
|
root: element,
|
||||||
|
modalClass: "file-browser-modal",
|
||||||
|
focusButtonClass: "file-browser-modal-focus-button",
|
||||||
|
minWidth: 420,
|
||||||
|
minHeight: 360,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelMountedDefaultLoad() {
|
||||||
|
if (!this._mountedDefaultLoadTimer) return;
|
||||||
|
globalThis.clearTimeout(this._mountedDefaultLoadTimer);
|
||||||
|
this._mountedDefaultLoadTimer = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
scheduleMountedDefaultLoad() {
|
||||||
|
this.cancelMountedDefaultLoad();
|
||||||
|
this._mountedDefaultLoadTimer = globalThis.setTimeout(async () => {
|
||||||
|
this._mountedDefaultLoadTimer = null;
|
||||||
|
if (this.isLoading) return;
|
||||||
|
const targetPath = this.browser.currentPath || "";
|
||||||
|
if (targetPath && this.browser.entries.length) {
|
||||||
|
this.syncPathInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.loadOpeningPath(targetPath);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("File browser default path load failed:", error);
|
||||||
|
}
|
||||||
|
}, 120);
|
||||||
|
},
|
||||||
|
|
||||||
// --- Helpers -------------------------------------------------------------
|
// --- Helpers -------------------------------------------------------------
|
||||||
|
resetOpenState() {
|
||||||
|
this.cancelMountedDefaultLoad();
|
||||||
|
this.isLoading = true;
|
||||||
|
this.error = null;
|
||||||
|
this.history = [];
|
||||||
|
this.searchQuery = "";
|
||||||
|
this.isBulkBusy = false;
|
||||||
|
this.pathError = "";
|
||||||
|
this.isPathSubmitting = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadOpeningPath(path = "") {
|
||||||
|
await this.loadDirectoryPreference();
|
||||||
|
const explicitPath = this.normalizeOpeningPath(path || this.initialPath);
|
||||||
|
const rememberedPath = !explicitPath ? this.getRememberedDirectory() : "";
|
||||||
|
const targetPath = explicitPath || rememberedPath || "$WORK_DIR";
|
||||||
|
this.browser.currentPath = targetPath;
|
||||||
|
this.syncPathInput();
|
||||||
|
|
||||||
|
const loaded = await this.fetchFiles(this.browser.currentPath, {
|
||||||
|
preserveOnError: Boolean(rememberedPath && targetPath === rememberedPath),
|
||||||
|
suppressErrorToast: Boolean(rememberedPath && targetPath === rememberedPath),
|
||||||
|
});
|
||||||
|
if (!loaded && rememberedPath && targetPath === rememberedPath) {
|
||||||
|
this.clearRememberedDirectory();
|
||||||
|
return await this.fetchFiles("$WORK_DIR");
|
||||||
|
}
|
||||||
|
return loaded;
|
||||||
|
},
|
||||||
|
|
||||||
|
beginSurfaceHandoff() {
|
||||||
|
this.isSurfaceHandoff = true;
|
||||||
|
this.surfaceHandoffPath = this.browser.currentPath || this.pathInput || "";
|
||||||
|
},
|
||||||
|
|
||||||
|
finishSurfaceHandoff() {
|
||||||
|
this.isSurfaceHandoff = false;
|
||||||
|
this.surfaceHandoffPath = "";
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelSurfaceHandoff() {
|
||||||
|
this.isSurfaceHandoff = false;
|
||||||
|
this.surfaceHandoffPath = "";
|
||||||
|
},
|
||||||
|
|
||||||
isArchive(filename) {
|
isArchive(filename) {
|
||||||
const archiveExts = ["zip", "tar", "gz", "rar", "7z"];
|
const archiveExts = ["zip", "tar", "gz", "rar", "7z"];
|
||||||
const ext = filename.split(".").pop().toLowerCase();
|
const ext = filename.split(".").pop().toLowerCase();
|
||||||
|
|
@ -493,11 +602,13 @@ const model = {
|
||||||
async fetchFiles(path = "", options = {}) {
|
async fetchFiles(path = "", options = {}) {
|
||||||
const preserveOnError = options?.preserveOnError === true;
|
const preserveOnError = options?.preserveOnError === true;
|
||||||
const suppressErrorToast = options?.suppressErrorToast === true;
|
const suppressErrorToast = options?.suppressErrorToast === true;
|
||||||
|
const requestedPath = this.normalizeOpeningPath(path) || "$WORK_DIR";
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
|
|
||||||
// Preserve scroll position if refreshing the same path
|
// Preserve scroll position if refreshing the same path
|
||||||
const isSamePath = this.browser.currentPath === path ||
|
const isSamePath =
|
||||||
(!path && !this.browser.currentPath);
|
this.browser.currentPath === requestedPath ||
|
||||||
|
(requestedPath === "$WORK_DIR" && ["/a0", "$WORK_DIR", ""].includes(this.browser.currentPath));
|
||||||
const scrollPos = isSamePath ? this.saveScrollPosition() : null;
|
const scrollPos = isSamePath ? this.saveScrollPosition() : null;
|
||||||
const selectedPaths = isSamePath
|
const selectedPaths = isSamePath
|
||||||
? new Set(this.selectedFiles.map((file) => file.path))
|
? new Set(this.selectedFiles.map((file) => file.path))
|
||||||
|
|
@ -505,12 +616,14 @@ const model = {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetchApi(
|
const response = await fetchApi(
|
||||||
`/get_work_dir_files?path=${encodeURIComponent(path)}`
|
`/get_work_dir_files?path=${encodeURIComponent(requestedPath)}`
|
||||||
);
|
);
|
||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
const result = data.data || {};
|
const result = data.data || {};
|
||||||
const requestedPath = String(path || "");
|
const entries = result.entries || [];
|
||||||
|
const resolvedCurrentPath =
|
||||||
|
result.current_path || (requestedPath === "$WORK_DIR" ? "/a0" : requestedPath);
|
||||||
const resultError =
|
const resultError =
|
||||||
data.error ||
|
data.error ||
|
||||||
result.error ||
|
result.error ||
|
||||||
|
|
@ -518,7 +631,7 @@ const model = {
|
||||||
requestedPath &&
|
requestedPath &&
|
||||||
requestedPath !== "$WORK_DIR" &&
|
requestedPath !== "$WORK_DIR" &&
|
||||||
!result.current_path &&
|
!result.current_path &&
|
||||||
!(result.entries || []).length
|
!entries.length
|
||||||
? "Directory not found or not accessible"
|
? "Directory not found or not accessible"
|
||||||
: ""
|
: ""
|
||||||
);
|
);
|
||||||
|
|
@ -526,10 +639,10 @@ const model = {
|
||||||
if (response.ok && !resultError) {
|
if (response.ok && !resultError) {
|
||||||
if (!isSamePath) this.searchQuery = "";
|
if (!isSamePath) this.searchQuery = "";
|
||||||
this.browser.entries = this.decorateEntries(
|
this.browser.entries = this.decorateEntries(
|
||||||
result.entries || [],
|
entries,
|
||||||
selectedPaths
|
selectedPaths
|
||||||
);
|
);
|
||||||
this.browser.currentPath = result.current_path;
|
this.browser.currentPath = resolvedCurrentPath;
|
||||||
this.browser.parentPath = result.parent_path;
|
this.browser.parentPath = result.parent_path;
|
||||||
this.syncPathInput();
|
this.syncPathInput();
|
||||||
this.pathError = "";
|
this.pathError = "";
|
||||||
|
|
@ -1020,7 +1133,7 @@ const model = {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.disposeScopedTooltips();
|
this.disposeScopedTooltips();
|
||||||
await window.closeModal?.("modals/file-browser/file-browser.html");
|
await window.closeModal?.(FILE_BROWSER_MODAL_PATH);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.toastFrontendError?.(
|
window.toastFrontendError?.(
|
||||||
error?.message || "Could not open file",
|
error?.message || "Could not open file",
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,30 @@
|
||||||
<html>
|
<html
|
||||||
|
class="surface-modal file-browser-modal modal-no-backdrop"
|
||||||
|
data-surface-id="files"
|
||||||
|
data-surface-modal-path="modals/file-browser/file-browser.html"
|
||||||
|
data-surface-dock-title="Open Files in canvas"
|
||||||
|
data-surface-dock-icon="dock_to_right"
|
||||||
|
data-canvas-surface="files"
|
||||||
|
data-canvas-modal-path="modals/file-browser/file-browser.html"
|
||||||
|
data-canvas-dock-title="Open Files in canvas"
|
||||||
|
data-canvas-dock-icon="dock_to_right"
|
||||||
|
>
|
||||||
<head>
|
<head>
|
||||||
<title>File Browser</title>
|
<title>File Browser</title>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { store } from "/components/modals/file-browser/file-browser-store.js";
|
import { store } from "/components/modals/file-browser/file-browser-store.js";
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="file-browser-modal-body">
|
||||||
<div x-data>
|
<div
|
||||||
|
x-data
|
||||||
|
class="file-browser-shell"
|
||||||
|
:class="{ 'is-surface': xAttrs($el)?.mode === 'canvas' }"
|
||||||
|
x-create="$store.fileBrowser.onMount($el, xAttrs($el) || {})"
|
||||||
|
x-destroy="$store.fileBrowser.onUnmount(xAttrs($el) || {})"
|
||||||
|
>
|
||||||
<template x-if="$store.fileBrowser">
|
<template x-if="$store.fileBrowser">
|
||||||
<div class="file-browser-root">
|
<div class="file-browser-root" :class="{ 'is-surface': xAttrs($el)?.mode === 'canvas' }">
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div x-show="$store.fileBrowser.isLoading" class="loading-state">
|
<div x-show="$store.fileBrowser.isLoading" class="loading-state">
|
||||||
|
|
@ -21,11 +37,16 @@
|
||||||
<!-- Path navigator -->
|
<!-- Path navigator -->
|
||||||
<div class="path-navigator-wrap">
|
<div class="path-navigator-wrap">
|
||||||
<form class="path-navigator" @submit.prevent="$store.fileBrowser.submitPath()">
|
<form class="path-navigator" @submit.prevent="$store.fileBrowser.submitPath()">
|
||||||
<button type="button" class="text-button back-button" @click="$store.fileBrowser.navigateUp()" aria-label="Navigate Up">
|
<button
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10.5 15">
|
type="button"
|
||||||
<path d="m.75,5.25L5.25.75m0,0l4.5,4.5M5.25.75v13.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
class="nav-button back-button"
|
||||||
</svg>
|
@click="$store.fileBrowser.navigateUp()"
|
||||||
Up
|
:disabled="!$store.fileBrowser.browser.parentPath || $store.fileBrowser.isLoading"
|
||||||
|
aria-label="Navigate up"
|
||||||
|
title="Navigate up"
|
||||||
|
>
|
||||||
|
<span class="material-symbols-outlined" aria-hidden="true">arrow_upward</span>
|
||||||
|
<span class="nav-button-label">Up</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="path-input-shell" :class="{ 'has-error': $store.fileBrowser.pathError }">
|
<div class="path-input-shell" :class="{ 'has-error': $store.fileBrowser.pathError }">
|
||||||
<span class="material-symbols-outlined path-input-icon" aria-hidden="true">folder_open</span>
|
<span class="material-symbols-outlined path-input-icon" aria-hidden="true">folder_open</span>
|
||||||
|
|
@ -79,13 +100,23 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="new-item-buttons">
|
<div class="new-item-buttons">
|
||||||
<button class="btn btn-ok btn-new-item" @click="$store.fileBrowser.openNewFile()">
|
<button
|
||||||
<span class="material-symbols-outlined">note_add</span>
|
type="button"
|
||||||
New File
|
class="btn btn-ok btn-new-item"
|
||||||
|
@click="$store.fileBrowser.openNewFile()"
|
||||||
|
aria-label="New file"
|
||||||
|
title="New file"
|
||||||
|
>
|
||||||
|
<span class="material-symbols-outlined" aria-hidden="true">note_add</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-ok btn-new-item" @click="$store.fileBrowser.openNewFolderModal()">
|
<button
|
||||||
<span class="material-symbols-outlined">create_new_folder</span>
|
type="button"
|
||||||
New Folder
|
class="btn btn-ok btn-new-item"
|
||||||
|
@click="$store.fileBrowser.openNewFolderModal()"
|
||||||
|
aria-label="New folder"
|
||||||
|
title="New folder"
|
||||||
|
>
|
||||||
|
<span class="material-symbols-outlined" aria-hidden="true">create_new_folder</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -297,20 +328,81 @@
|
||||||
|
|
||||||
<!-- Modal Footer (outside template x-if so it exists immediately) -->
|
<!-- Modal Footer (outside template x-if so it exists immediately) -->
|
||||||
<template x-if="$store.fileBrowser">
|
<template x-if="$store.fileBrowser">
|
||||||
<div class="modal-footer" data-modal-footer>
|
<div class="modal-footer file-browser-footer" data-modal-footer :class="{ 'is-surface': xAttrs($el)?.mode === 'canvas' }">
|
||||||
<label class="btn btn-upload">
|
<label class="btn btn-upload">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"/></svg>
|
||||||
Upload Files
|
Upload Files
|
||||||
<input type="file" multiple accept="*" @change="$store.fileBrowser.handleFileUpload" style="display:none;" />
|
<input type="file" multiple accept="*" @change="$store.fileBrowser.handleFileUpload" style="display:none;" />
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn-cancel" @click="$store.fileBrowser.handleClose()">Close Browser</button>
|
<button class="btn btn-cancel" x-show="xAttrs($el)?.mode !== 'canvas'" @click="$store.fileBrowser.handleClose()">Close Browser</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.file-browser-shell {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-inner.file-browser-modal {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: min(78vw, 1040px);
|
||||||
|
height: min(82vh, 820px);
|
||||||
|
min-width: min(420px, calc(100vw - 16px));
|
||||||
|
min-height: min(360px, calc(100vh - 16px));
|
||||||
|
max-width: calc(100vw - 16px);
|
||||||
|
max-height: calc(100vh - 16px);
|
||||||
|
resize: both;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-inner.file-browser-modal.is-focus-mode {
|
||||||
|
resize: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-inner.file-browser-modal .modal-header {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0.35rem 0.75rem 0.35rem 1rem;
|
||||||
|
background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-inner.file-browser-modal .modal-scroll,
|
||||||
|
.modal-inner.file-browser-modal .modal-bd.file-browser-modal-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: none;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-inner.file-browser-modal .modal-bd.file-browser-modal-body > div[x-data],
|
||||||
|
.modal-inner.file-browser-modal .file-browser-shell {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-browser-shell.is-surface {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* File Browser Root */
|
/* File Browser Root */
|
||||||
.file-browser-root {
|
.file-browser-root {
|
||||||
|
container: file-browser / inline-size;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -318,6 +410,16 @@
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-browser-root.is-surface {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-inner.file-browser-modal .file-browser-root {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Loading State */
|
/* Loading State */
|
||||||
.loading-state {
|
.loading-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -354,6 +456,17 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: var(--spacing-sm) var(--spacing-sm);
|
padding: var(--spacing-sm) var(--spacing-sm);
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-sm);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-browser-shell.is-surface .file-browser-content {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-inner.file-browser-modal .file-browser-content {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* File Browser Styles */
|
/* File Browser Styles */
|
||||||
|
|
@ -363,6 +476,34 @@
|
||||||
/* Removed overflow: hidden to allow dropdown menus to be visible */
|
/* Removed overflow: hidden to allow dropdown menus to be visible */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-browser-shell.is-surface .files-list {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: color-mix(in srgb, var(--color-panel) 82%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-inner.file-browser-modal .files-list {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: color-mix(in srgb, var(--color-panel) 82%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-browser-shell.is-surface .file-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-inner.file-browser-modal .file-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
/* Header Styles */
|
/* Header Styles */
|
||||||
.file-header {
|
.file-header {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -633,23 +774,51 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-button {
|
.nav-button {
|
||||||
padding: 4px 12px;
|
appearance: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.1rem;
|
||||||
|
min-height: 2.35rem;
|
||||||
|
padding: 0.22rem 0.62rem 0.24rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
background: var(--color-background);
|
background: color-mix(in srgb, var(--color-input) 86%, var(--color-panel) 14%);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1;
|
||||||
|
box-shadow: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, opacity 0.15s ease;
|
||||||
}
|
|
||||||
.nav-button:hover {
|
|
||||||
background: var(--hover-bg);
|
|
||||||
}
|
}
|
||||||
.nav-button.back-button {
|
.nav-button.back-button {
|
||||||
background-color: var(--color-secondary);
|
flex: 0 0 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 3.1rem;
|
||||||
|
}
|
||||||
|
.nav-button .material-symbols-outlined {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.nav-button-label {
|
||||||
|
font-size: 0.66rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.nav-button:hover:not(:disabled) {
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary) 34%, var(--color-border));
|
||||||
|
background: color-mix(in srgb, var(--color-input-focus) 84%, var(--color-primary) 16%);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
.nav-button.back-button:hover {
|
.nav-button:focus-visible {
|
||||||
background-color: var(--color-secondary-dark);
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary) 22%, transparent);
|
||||||
|
}
|
||||||
|
.nav-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
/* Folder Specific Styles */
|
/* Folder Specific Styles */
|
||||||
.file-item[data-is-dir="true"] {
|
.file-item[data-is-dir="true"] {
|
||||||
|
|
@ -681,6 +850,16 @@
|
||||||
background-color: #2b309c;
|
background-color: #2b309c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-browser-footer.is-surface {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
background: color-mix(in srgb, var(--color-panel) 90%, var(--color-background) 10%);
|
||||||
|
}
|
||||||
|
|
||||||
/* File Actions */
|
/* File Actions */
|
||||||
.file-actions {
|
.file-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -697,8 +876,17 @@
|
||||||
.btn-new-item {
|
.btn-new-item {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
padding: 8px 14px;
|
width: 2.8rem;
|
||||||
|
height: 2.8rem;
|
||||||
|
min-width: 2.8rem;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.btn-new-item .material-symbols-outlined {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: var(--color-secondary);
|
background: var(--color-secondary);
|
||||||
|
|
@ -729,13 +917,6 @@
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.new-item-buttons {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.new-item-buttons .btn-new-item {
|
|
||||||
flex: 1;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.file-header {
|
.file-header {
|
||||||
grid-template-columns: 2.25rem minmax(0, 1fr) minmax(5rem, 0.52fr) 7.5rem;
|
grid-template-columns: 2.25rem minmax(0, 1fr) minmax(5rem, 0.52fr) 7.5rem;
|
||||||
}
|
}
|
||||||
|
|
@ -750,15 +931,26 @@
|
||||||
@media (max-width: 540px) {
|
@media (max-width: 540px) {
|
||||||
.file-header,
|
.file-header,
|
||||||
.file-item {
|
.file-item {
|
||||||
grid-template-columns: 2.25rem minmax(0, 1fr) 7.5rem;
|
grid-template-columns: 2.25rem minmax(0, 1fr) minmax(4.25rem, max-content) 5.25rem;
|
||||||
}
|
}
|
||||||
.file-cell-size,
|
|
||||||
.file-size,
|
|
||||||
.file-cell-date,
|
.file-cell-date,
|
||||||
.file-date {
|
.file-date {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@container file-browser (max-width: 620px) {
|
||||||
|
.file-header,
|
||||||
|
.file-item {
|
||||||
|
grid-template-columns: 2.25rem minmax(0, 1fr) minmax(4.25rem, max-content) 5.25rem;
|
||||||
|
}
|
||||||
|
.file-cell-date,
|
||||||
|
.file-date {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.file-actions {
|
||||||
|
padding-right: 0.35rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
- Each CSS file owns a named surface or primitive family such as buttons, messages, modals, notifications, scheduler, settings, surfaces, tables, or toast.
|
- Each CSS file owns a named surface or primitive family such as buttons, messages, modals, notifications, scheduler, settings, surfaces, tables, or toast.
|
||||||
- Component-specific styles should usually stay inside the component HTML unless they are intentionally shared.
|
- Component-specific styles should usually stay inside the component HTML unless they are intentionally shared.
|
||||||
- `modals.css` owns the shared stacked modal shell, backdrop, scroll area, footer slot, modal button classes, floating/no-backdrop modal behavior, and shared modal section primitives.
|
- `modals.css` owns the shared stacked modal shell, backdrop, scroll area, footer slot, modal button classes, floating/no-backdrop modal behavior, and shared modal section primitives.
|
||||||
|
- `surfaces.css` owns surface modal switchers, action rails, draggable header affordances, focus-button state, and right-canvas surface primitives.
|
||||||
- `index.css` defines global theme variables such as `--color-*`, `--spacing-*`, `--font-size-*`, and `--transition-speed`.
|
- `index.css` defines global theme variables such as `--color-*`, `--spacing-*`, `--font-size-*`, and `--transition-speed`.
|
||||||
|
|
||||||
## Local Contracts
|
## Local Contracts
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,22 @@
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-inner.surface-modal.is-draggable-surface-modal .modal-header {
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-inner.surface-modal.is-focus-mode {
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-inner.surface-modal.is-focus-mode .surface-modal-focus-button,
|
||||||
|
.surface-modal-focus-button.is-active {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
.surface-switcher,
|
.surface-switcher,
|
||||||
.modal-surface-switcher {
|
.modal-surface-switcher {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
- `extensions.js` owns frontend extension loading.
|
- `extensions.js` owns frontend extension loading.
|
||||||
- `components.js` owns `<x-component>` loading, component caching, module injection, nested component processing, and `globalThis.xAttrs`.
|
- `components.js` owns `<x-component>` loading, component caching, module injection, nested component processing, and `globalThis.xAttrs`.
|
||||||
- `modals.js` owns the stacked modal shell, `openModal`, `closeModal`, `scrollModal`, footer relocation, backdrop, and modal z-index behavior.
|
- `modals.js` owns the stacked modal shell, `openModal`, `closeModal`, `scrollModal`, footer relocation, backdrop, and modal z-index behavior.
|
||||||
|
- `surfaces.js` owns shared surface registration, right-canvas/modal mode routing, surface modal action rails, and reusable draggable/focus modal chrome.
|
||||||
- `initFw.js` owns Alpine bootstrap and custom lifecycle directives such as `x-create`, `x-destroy`, and periodic `x-every-*` hooks.
|
- `initFw.js` owns Alpine bootstrap and custom lifecycle directives such as `x-create`, `x-destroy`, and periodic `x-every-*` hooks.
|
||||||
- Other modules own focused UI utilities such as modals, messages, safe markdown, shortcuts, TTS/STT, surfaces, and initialization.
|
- Other modules own focused UI utilities such as modals, messages, safe markdown, shortcuts, TTS/STT, surfaces, and initialization.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,29 @@ const urlHandlers = new Set();
|
||||||
const SURFACE_MODAL_ACTION_GROUPS = ["surfaces", "window", "new"];
|
const SURFACE_MODAL_ACTION_GROUPS = ["surfaces", "window", "new"];
|
||||||
|
|
||||||
export const CORE_SURFACES = [
|
export const CORE_SURFACES = [
|
||||||
|
{
|
||||||
|
id: "files",
|
||||||
|
title: "Files",
|
||||||
|
icon: "folder",
|
||||||
|
order: 5,
|
||||||
|
modalPath: "modals/file-browser/file-browser.html",
|
||||||
|
async beginDockHandoff() {
|
||||||
|
const { store } = await import("/components/modals/file-browser/file-browser-store.js");
|
||||||
|
store.beginSurfaceHandoff?.();
|
||||||
|
},
|
||||||
|
async finishDockHandoff(payload = {}) {
|
||||||
|
const { store } = await import("/components/modals/file-browser/file-browser-store.js");
|
||||||
|
store.finishSurfaceHandoff?.(payload);
|
||||||
|
},
|
||||||
|
async cancelDockHandoff() {
|
||||||
|
const { store } = await import("/components/modals/file-browser/file-browser-store.js");
|
||||||
|
store.cancelSurfaceHandoff?.();
|
||||||
|
},
|
||||||
|
async open(payload = {}) {
|
||||||
|
const { store } = await import("/components/modals/file-browser/file-browser-store.js");
|
||||||
|
await store.openSurface(payload.path || payload.filePath || payload.directory || "");
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "browser",
|
id: "browser",
|
||||||
title: "Browser",
|
title: "Browser",
|
||||||
|
|
@ -369,6 +392,202 @@ export function placeSurfaceModalHeaderAction(header, element, groupName = "wind
|
||||||
refreshSurfaceModalActionRail(header);
|
refreshSurfaceModalActionRail(header);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setupFloatingSurfaceModalChrome(options = {}) {
|
||||||
|
const root = options.root || null;
|
||||||
|
const modal = options.modal || root?.closest?.(".modal") || null;
|
||||||
|
const inner = options.inner || modal?.querySelector?.(".modal-inner") || root?.closest?.(".modal-inner") || null;
|
||||||
|
const header = options.header || inner?.querySelector?.(".modal-header") || null;
|
||||||
|
if (!modal || !inner || !header) return () => {};
|
||||||
|
|
||||||
|
const viewportGap = Number.isFinite(Number(options.viewportGap)) ? Number(options.viewportGap) : 8;
|
||||||
|
const minWidth = Number.isFinite(Number(options.minWidth)) ? Number(options.minWidth) : 320;
|
||||||
|
const minHeight = Number.isFinite(Number(options.minHeight)) ? Number(options.minHeight) : 300;
|
||||||
|
const modalClass = String(options.modalClass || "").trim();
|
||||||
|
const focusButtonClass = String(options.focusButtonClass || "").trim();
|
||||||
|
const focusEnabled = options.focus !== false;
|
||||||
|
const focusLabel = options.focusLabel || "Focus mode";
|
||||||
|
const restoreLabel = options.restoreLabel || "Restore size";
|
||||||
|
const onBoundsChange = typeof options.onBoundsChange === "function" ? options.onBoundsChange : null;
|
||||||
|
const onFocusChange = typeof options.onFocusChange === "function" ? options.onFocusChange : null;
|
||||||
|
|
||||||
|
modal.classList.add("surface-floating", "modal-floating");
|
||||||
|
inner.classList.add("surface-modal", "is-draggable-surface-modal");
|
||||||
|
if (modalClass) inner.classList.add(modalClass);
|
||||||
|
|
||||||
|
const viewportWidth = () => Math.max(document.documentElement.clientWidth || 0, globalThis.innerWidth || 0);
|
||||||
|
const viewportHeight = () => Math.max(document.documentElement.clientHeight || 0, globalThis.innerHeight || 0);
|
||||||
|
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(minWidth, viewportWidth() - viewportGap * 2);
|
||||||
|
const maxHeight = Math.max(minHeight, viewportHeight() - viewportGap * 2);
|
||||||
|
const width = Math.min(Math.max(minWidth, Number(bounds.width || minWidth)), maxWidth);
|
||||||
|
const height = Math.min(Math.max(minHeight, Number(bounds.height || minHeight)), maxHeight);
|
||||||
|
return {
|
||||||
|
left: Math.min(
|
||||||
|
Math.max(viewportGap, Number(bounds.left || viewportGap)),
|
||||||
|
Math.max(viewportGap, viewportWidth() - width - viewportGap),
|
||||||
|
),
|
||||||
|
top: Math.min(
|
||||||
|
Math.max(viewportGap, Number(bounds.top || viewportGap)),
|
||||||
|
Math.max(viewportGap, viewportHeight() - height - viewportGap),
|
||||||
|
),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const notifyBoundsChange = () => {
|
||||||
|
try {
|
||||||
|
onBoundsChange?.({
|
||||||
|
...currentBounds(),
|
||||||
|
focus: inner.classList.contains("is-focus-mode"),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Surface modal bounds callback failed", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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(minWidth, viewportWidth() - viewportGap * 2)}px`;
|
||||||
|
inner.style.maxHeight = `${Math.max(minHeight, viewportHeight() - viewportGap * 2)}px`;
|
||||||
|
notifyBoundsChange();
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
const focusBounds = () => ({
|
||||||
|
left: viewportGap,
|
||||||
|
top: viewportGap,
|
||||||
|
width: viewportWidth() - viewportGap * 2,
|
||||||
|
height: viewportHeight() - viewportGap * 2,
|
||||||
|
});
|
||||||
|
const clampGeometry = () => {
|
||||||
|
if (inner.classList.contains("is-focus-mode")) {
|
||||||
|
setBounds(focusBounds());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBounds(currentBounds());
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialBounds = currentBounds();
|
||||||
|
inner.style.left = `${Math.max(viewportGap, initialBounds.left)}px`;
|
||||||
|
inner.style.top = `${Math.max(viewportGap, initialBounds.top)}px`;
|
||||||
|
inner.style.transform = "none";
|
||||||
|
clampGeometry();
|
||||||
|
|
||||||
|
let drag = null;
|
||||||
|
let resizeObserver = null;
|
||||||
|
let beforeFocusBounds = null;
|
||||||
|
let focusButton = null;
|
||||||
|
|
||||||
|
const updateFocusButton = (active) => {
|
||||||
|
if (!focusButton) return;
|
||||||
|
const label = active ? restoreLabel : focusLabel;
|
||||||
|
focusButton.setAttribute("aria-label", label);
|
||||||
|
focusButton.setAttribute("title", label);
|
||||||
|
focusButton.classList.toggle("is-active", active);
|
||||||
|
const icon = focusButton.querySelector(".material-symbols-outlined");
|
||||||
|
if (icon) icon.textContent = active ? "fullscreen_exit" : "fullscreen";
|
||||||
|
};
|
||||||
|
const setFocusMode = (enabled) => {
|
||||||
|
const active = Boolean(enabled);
|
||||||
|
if (active === inner.classList.contains("is-focus-mode")) return;
|
||||||
|
if (active) {
|
||||||
|
beforeFocusBounds = currentBounds();
|
||||||
|
inner.classList.add("is-focus-mode");
|
||||||
|
setBounds(focusBounds());
|
||||||
|
} else {
|
||||||
|
inner.classList.remove("is-focus-mode");
|
||||||
|
setBounds(beforeFocusBounds || currentBounds());
|
||||||
|
beforeFocusBounds = null;
|
||||||
|
}
|
||||||
|
updateFocusButton(active);
|
||||||
|
try {
|
||||||
|
onFocusChange?.(active);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Surface modal focus callback failed", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (event) => {
|
||||||
|
if (!drag) return;
|
||||||
|
setBounds({
|
||||||
|
...currentBounds(),
|
||||||
|
left: drag.left + event.clientX - drag.x,
|
||||||
|
top: drag.top + event.clientY - drag.y,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const onPointerUp = () => {
|
||||||
|
drag = null;
|
||||||
|
globalThis.removeEventListener("pointermove", onPointerMove);
|
||||||
|
globalThis.removeEventListener("pointerup", onPointerUp);
|
||||||
|
try {
|
||||||
|
header.releasePointerCapture?.(header.__surfaceModalPointerId || 0);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
const onPointerDown = (event) => {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
if (event.target?.closest?.("button, input, select, textarea, a, [data-no-modal-drag], .surface-modal-actions")) return;
|
||||||
|
if (inner.classList.contains("is-focus-mode")) return;
|
||||||
|
const bounds = currentBounds();
|
||||||
|
drag = {
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
left: bounds.left,
|
||||||
|
top: bounds.top,
|
||||||
|
};
|
||||||
|
header.__surfaceModalPointerId = event.pointerId;
|
||||||
|
header.setPointerCapture?.(event.pointerId);
|
||||||
|
globalThis.addEventListener("pointermove", onPointerMove);
|
||||||
|
globalThis.addEventListener("pointerup", onPointerUp);
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
header.addEventListener("pointerdown", onPointerDown);
|
||||||
|
|
||||||
|
if (focusEnabled) {
|
||||||
|
focusButton = globalThis.document.createElement("button");
|
||||||
|
focusButton.type = "button";
|
||||||
|
focusButton.className = ["surface-button", "surface-modal-focus-button", focusButtonClass]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
focusButton.innerHTML = '<span class="material-symbols-outlined" aria-hidden="true">fullscreen</span>';
|
||||||
|
const onFocusClick = () => setFocusMode(!inner.classList.contains("is-focus-mode"));
|
||||||
|
updateFocusButton(false);
|
||||||
|
focusButton.addEventListener("click", onFocusClick);
|
||||||
|
focusButton.__surfaceModalFocusCleanup = () => focusButton.removeEventListener("click", onFocusClick);
|
||||||
|
placeSurfaceModalHeaderAction(header, focusButton, "window");
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.addEventListener("resize", clampGeometry);
|
||||||
|
if (globalThis.ResizeObserver) {
|
||||||
|
resizeObserver = new ResizeObserver(clampGeometry);
|
||||||
|
resizeObserver.observe(inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
focusButton?.__surfaceModalFocusCleanup?.();
|
||||||
|
focusButton?.remove();
|
||||||
|
refreshSurfaceModalActionRail(header);
|
||||||
|
header.removeEventListener("pointerdown", onPointerDown);
|
||||||
|
globalThis.removeEventListener("pointermove", onPointerMove);
|
||||||
|
globalThis.removeEventListener("pointerup", onPointerUp);
|
||||||
|
globalThis.removeEventListener("resize", clampGeometry);
|
||||||
|
resizeObserver?.disconnect?.();
|
||||||
|
inner.classList.remove("is-focus-mode", "is-draggable-surface-modal");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function markSurfaceModal(modal, metadata) {
|
function markSurfaceModal(modal, metadata) {
|
||||||
const element = modal?.element;
|
const element = modal?.element;
|
||||||
const inner = modal?.inner || element?.querySelector?.(".modal-inner");
|
const inner = modal?.inner || element?.querySelector?.(".modal-inner");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue