From 606156f604e507f8725f6ae9040cea8204375a86 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Sun, 24 May 2026 06:07:27 +0800 Subject: [PATCH] feat: add workbench sidebar resize Adds a controlled desktop left activity rail resize handle with persisted width, collapse-aware disabled state, narrow/mobile disablement, and focused Playwright coverage. No DEV rollout requested. --- web/hwlab-cloud-web/app.mjs | 113 +++++- web/hwlab-cloud-web/index.html | 13 + web/hwlab-cloud-web/package.json | 2 +- .../scripts/sidebar-resize.test.mjs | 338 ++++++++++++++++++ web/hwlab-cloud-web/styles.css | 26 ++ 5 files changed, 490 insertions(+), 2 deletions(-) create mode 100644 web/hwlab-cloud-web/scripts/sidebar-resize.test.mjs diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 1a3ecdb1..328c6292 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -84,6 +84,9 @@ const CODEX_STDIO_BLOCKER_MARKERS = Object.freeze([ ]); const UNTRUSTED_CODE_AGENT_PROVIDER_PATTERN = /\b(?:echo|mock|fixture|stub|sample)\b/iu; const LAYOUT_STORAGE_KEY = "hwlab.workbench.layout.v1"; +const LEFT_SIDEBAR_DEFAULT_WIDTH = 92; +const LEFT_SIDEBAR_MIN_WIDTH = 72; +const LEFT_SIDEBAR_MAX_WIDTH = 180; const RIGHT_SIDEBAR_DEFAULT_WIDTH = 620; const RIGHT_SIDEBAR_MIN_WIDTH = 560; const RIGHT_SIDEBAR_MAX_WIDTH = 760; @@ -96,6 +99,7 @@ const el = { shell: query("[data-app-shell]"), logoutButton: byId("logout-button"), leftSidebarToggle: byId("left-sidebar-toggle"), + leftSidebarResize: byId("left-sidebar-resize"), rightSidebarResize: byId("right-sidebar-resize"), routePath: byId("route-path"), liveStatus: byId("live-status"), @@ -166,6 +170,8 @@ const state = { }, layout: { leftSidebarCollapsed: false, + leftSidebarWidth: LEFT_SIDEBAR_DEFAULT_WIDTH, + leftDrag: null, rightSidebarWidth: RIGHT_SIDEBAR_DEFAULT_WIDTH, rightDrag: null } @@ -174,6 +180,7 @@ const state = { initRoutes(); initLayoutSizing(); initLeftSidebarToggle(); +initLeftSidebarResize(); initRightSidebarResize(); initSideTabs(); initHardwareTabs(); @@ -223,7 +230,9 @@ function initRoutes() { function initLayoutSizing() { const persisted = readPersistedLayout(); + const leftSidebarWidth = clampLeftSidebarWidth(persisted?.leftSidebarWidth ?? LEFT_SIDEBAR_DEFAULT_WIDTH); const rightSidebarWidth = clampRightSidebarWidth(persisted?.rightSidebarWidth ?? RIGHT_SIDEBAR_DEFAULT_WIDTH); + setLeftSidebarWidth(leftSidebarWidth, { persist: false }); setLeftSidebarCollapsed(persisted?.leftSidebarCollapsed === true, { persist: false }); setRightSidebarWidth(rightSidebarWidth, { persist: false }); } @@ -236,6 +245,7 @@ function readPersistedLayout() { if (payload?.version !== 1) return null; return { leftSidebarCollapsed: payload.leftSidebarCollapsed === true, + leftSidebarWidth: Number(payload.leftSidebarWidth), rightSidebarWidth: Number(payload.rightSidebarWidth) }; } catch { @@ -250,6 +260,7 @@ function persistLayout() { JSON.stringify({ version: 1, leftSidebarCollapsed: state.layout.leftSidebarCollapsed, + leftSidebarWidth: state.layout.leftSidebarWidth, rightSidebarWidth: state.layout.rightSidebarWidth }) ); @@ -274,9 +285,19 @@ function setLeftSidebarCollapsed(collapsed, options = {}) { ); el.leftSidebarToggle.title = state.layout.leftSidebarCollapsed ? "展开左侧导航" : "折叠左侧导航"; el.leftSidebarToggle.textContent = state.layout.leftSidebarCollapsed ? "展开" : "收起"; + syncLeftSidebarResizeA11y(); if (options.persist !== false) persistLayout(); } +function setLeftSidebarWidth(width, options = {}) { + const clamped = clampLeftSidebarWidth(width); + state.layout.leftSidebarWidth = clamped; + el.shell.style.setProperty("--left-sidebar-width", `${clamped}px`); + syncLeftSidebarResizeA11y(); + if (options.persist !== false) persistLayout(); + return clamped; +} + function setRightSidebarWidth(width, options = {}) { const clamped = clampRightSidebarWidth(width); state.layout.rightSidebarWidth = clamped; @@ -286,6 +307,13 @@ function setRightSidebarWidth(width, options = {}) { return clamped; } +function clampLeftSidebarWidth(value) { + const numeric = Number(value); + const width = Number.isFinite(numeric) ? numeric : LEFT_SIDEBAR_DEFAULT_WIDTH; + const bounds = leftSidebarResizeBounds(); + return Math.min(Math.max(Math.round(width), bounds.min), bounds.max); +} + function clampRightSidebarWidth(value) { const numeric = Number(value); const width = Number.isFinite(numeric) ? numeric : RIGHT_SIDEBAR_DEFAULT_WIDTH; @@ -299,6 +327,19 @@ function boundedResizeRange(min, max, dynamicMax) { return { min: safeMin, max: safeMax }; } +function leftSidebarResizeBounds() { + const styles = getComputedStyle(el.shell); + const cssMin = readCssPixel(styles.getPropertyValue("--left-sidebar-min-width"), LEFT_SIDEBAR_MIN_WIDTH); + const cssMax = readCssPixel(styles.getPropertyValue("--left-sidebar-max-width"), LEFT_SIDEBAR_MAX_WIDTH); + if (window.matchMedia("(max-width: 1240px)").matches) { + const fixedRailWidth = readCssPixel(styles.getPropertyValue("--rail-width"), LEFT_SIDEBAR_DEFAULT_WIDTH); + return { min: Math.round(fixedRailWidth), max: Math.round(fixedRailWidth) }; + } + const rightWidth = state.layout.rightSidebarWidth; + const dynamicMax = window.innerWidth - rightWidth - 420 - 4; + return boundedResizeRange(cssMin, cssMax, dynamicMax); +} + function rightSidebarResizeBounds() { const styles = getComputedStyle(el.shell); const cssMin = readCssPixel(styles.getPropertyValue("--right-sidebar-min-width"), RIGHT_SIDEBAR_MIN_WIDTH); @@ -306,7 +347,9 @@ function rightSidebarResizeBounds() { if (window.matchMedia("(max-width: 1240px)").matches) { return { min: 0, max: Math.max(0, window.innerWidth) }; } - const railWidth = readCssPixel(styles.getPropertyValue("--rail-width"), 92); + const railWidth = state.layout.leftSidebarCollapsed + ? readCssPixel(styles.getPropertyValue("--rail-collapsed-width"), LEFT_SIDEBAR_DEFAULT_WIDTH) + : state.layout.leftSidebarWidth; const dynamicMax = window.innerWidth - railWidth - 420 - 4; return boundedResizeRange(cssMin, cssMax, dynamicMax); } @@ -328,6 +371,18 @@ function syncRightSidebarResizeA11y() { el.rightSidebarResize.setAttribute("aria-valuetext", `右侧硬件状态栏宽度 ${state.layout.rightSidebarWidth} 像素`); } +function syncLeftSidebarResizeA11y() { + const bounds = leftSidebarResizeBounds(); + const disabled = !canResizeLeftSidebar(); + el.leftSidebarResize.tabIndex = disabled ? -1 : 0; + el.leftSidebarResize.setAttribute("aria-disabled", disabled ? "true" : "false"); + el.leftSidebarResize.setAttribute("aria-hidden", disabled ? "true" : "false"); + el.leftSidebarResize.setAttribute("aria-valuemin", String(bounds.min)); + el.leftSidebarResize.setAttribute("aria-valuemax", String(bounds.max)); + el.leftSidebarResize.setAttribute("aria-valuenow", String(state.layout.leftSidebarWidth)); + el.leftSidebarResize.setAttribute("aria-valuetext", `左侧导航宽度 ${state.layout.leftSidebarWidth} 像素`); +} + function routeFromLocation() { const hashRoute = window.location.hash.replace(/^#\/?/, ""); if (viewIds.has(hashRoute)) return hashRoute; @@ -351,6 +406,62 @@ function showView(route) { } } +function initLeftSidebarResize() { + window.addEventListener("resize", () => { + if (canResizeLeftSidebar()) setLeftSidebarWidth(state.layout.leftSidebarWidth, { persist: false }); + syncLeftSidebarResizeA11y(); + }); + el.leftSidebarResize.addEventListener("pointerdown", (event) => { + if (!canResizeLeftSidebar()) return; + event.preventDefault(); + el.leftSidebarResize.setPointerCapture?.(event.pointerId); + state.layout.leftDrag = { + pointerId: event.pointerId, + startX: event.clientX, + startWidth: state.layout.leftSidebarWidth + }; + el.shell.classList.add("is-resizing"); + }); + el.leftSidebarResize.addEventListener("pointermove", (event) => { + const drag = state.layout.leftDrag; + if (!drag || drag.pointerId !== event.pointerId) return; + setLeftSidebarWidth(drag.startWidth + event.clientX - drag.startX, { persist: false }); + }); + el.leftSidebarResize.addEventListener("pointerup", finishLeftSidebarResize); + el.leftSidebarResize.addEventListener("pointercancel", finishLeftSidebarResize); + el.leftSidebarResize.addEventListener("keydown", (event) => { + if (!canResizeLeftSidebar()) return; + const keySteps = { + ArrowLeft: -8, + ArrowRight: 8, + PageUp: 24, + PageDown: -24 + }; + if (Object.hasOwn(keySteps, event.key)) { + event.preventDefault(); + setLeftSidebarWidth(state.layout.leftSidebarWidth + keySteps[event.key]); + } else if (event.key === "Home") { + event.preventDefault(); + setLeftSidebarWidth(leftSidebarResizeBounds().min); + } else if (event.key === "End") { + event.preventDefault(); + setLeftSidebarWidth(leftSidebarResizeBounds().max); + } + }); +} + +function finishLeftSidebarResize(event) { + const drag = state.layout.leftDrag; + if (!drag || drag.pointerId !== event.pointerId) return; + state.layout.leftDrag = null; + el.shell.classList.remove("is-resizing"); + persistLayout(); +} + +function canResizeLeftSidebar() { + return !state.layout.leftSidebarCollapsed && !window.matchMedia("(max-width: 1240px)").matches; +} + function initRightSidebarResize() { window.addEventListener("resize", () => { if (canResizeRightSidebar()) setRightSidebarWidth(state.layout.rightSidebarWidth, { persist: false }); diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index 8bb62102..306fbd77 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -46,6 +46,19 @@ title="折叠左侧导航" >收起 +
diff --git a/web/hwlab-cloud-web/package.json b/web/hwlab-cloud-web/package.json index 2dd970a0..804018c0 100644 --- a/web/hwlab-cloud-web/package.json +++ b/web/hwlab-cloud-web/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "m3-readonly": "node scripts/m3-readonly-contract.mjs", - "check": "node --test code-agent-facts.test.mjs code-agent-status.test.mjs wiring-status.test.mjs code-agent-m3-evidence.test.mjs && node scripts/check.mjs", + "check": "node --test code-agent-facts.test.mjs code-agent-status.test.mjs wiring-status.test.mjs code-agent-m3-evidence.test.mjs scripts/sidebar-resize.test.mjs && node scripts/check.mjs", "build": "node scripts/build.mjs", "layout": "node ../../scripts/dev-cloud-workbench-layout-smoke.mjs --static --report ../../reports/dev-gate/dev-cloud-workbench-layout.json", "layout:build": "node ../../scripts/dev-cloud-workbench-layout-smoke.mjs --build --report ../../reports/dev-gate/dev-cloud-workbench-layout-build.json", diff --git a/web/hwlab-cloud-web/scripts/sidebar-resize.test.mjs b/web/hwlab-cloud-web/scripts/sidebar-resize.test.mjs new file mode 100644 index 00000000..15451c44 --- /dev/null +++ b/web/hwlab-cloud-web/scripts/sidebar-resize.test.mjs @@ -0,0 +1,338 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { chromium } from "playwright"; + +const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const defaultCredentials = Object.freeze({ + username: "admin", + password: "hwlab2026" +}); + +test("left activity rail resize is controlled, clamped, persisted, and disabled while collapsed", async () => { + const server = await startStaticServer(webRoot); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage({ + viewport: { width: 1366, height: 768 }, + deviceScaleFactor: 1 + }); + await page.goto(server.url, { waitUntil: "domcontentloaded", timeout: 15000 }); + await login(page); + await page.locator("#left-sidebar-resize").waitFor({ state: "visible", timeout: 12000 }); + + const before = await sidebarMetrics(page); + assert.equal(before.handle.role, "separator"); + assert.equal(before.handle.ariaLabel, "拖拽调整左侧导航宽度"); + assert.match(before.handle.title, /左右方向键/); + assert.equal(before.handle.tabIndex, 0); + assert.equal(before.handle.ariaDisabled, "false"); + assert.equal(before.handle.ariaHidden, "false"); + assert.equal(before.railWidth, 92); + assert.equal(before.handle.min, 72); + assert.equal(before.handle.max, 180); + assert.equal(before.keyTargetsReachable, true); + + await dragLeftResize(page, before.railWidth + 48); + const afterDrag = await sidebarMetrics(page); + assert.ok(afterDrag.railWidth >= before.railWidth + 40, `rail width did not grow: ${afterDrag.railWidth}`); + assert.ok(afterDrag.centerWidth <= before.centerWidth - 40, `center width did not shrink: ${afterDrag.centerWidth}`); + assert.equal(afterDrag.storage.leftSidebarWidth, afterDrag.railWidth); + assert.equal(afterDrag.keyTargetsReachable, true); + assert.equal(afterDrag.noHorizontalOverflow, true); + assert.equal(afterDrag.rootScrollLocked, true); + + await page.locator("#left-sidebar-resize").press("End"); + const afterEnd = await sidebarMetrics(page); + assert.equal(afterEnd.railWidth, afterEnd.handle.max); + assert.equal(afterEnd.keyTargetsReachable, true); + + await dragLeftResize(page, 999); + const afterOverMaxDrag = await sidebarMetrics(page); + assert.equal(afterOverMaxDrag.railWidth, afterOverMaxDrag.handle.max); + + await page.locator("#left-sidebar-resize").press("Home"); + const afterHome = await sidebarMetrics(page); + assert.equal(afterHome.railWidth, afterHome.handle.min); + assert.equal(afterHome.keyTargetsReachable, true); + + await dragLeftResize(page, 10); + const afterUnderMinDrag = await sidebarMetrics(page); + assert.equal(afterUnderMinDrag.railWidth, afterUnderMinDrag.handle.min); + + await page.locator("#left-sidebar-resize").press("ArrowRight"); + const afterArrowRight = await sidebarMetrics(page); + assert.equal(afterArrowRight.railWidth, afterUnderMinDrag.railWidth + 8); + + await page.locator("#left-sidebar-resize").press("ArrowLeft"); + const afterArrowLeft = await sidebarMetrics(page); + assert.equal(afterArrowLeft.railWidth, afterUnderMinDrag.railWidth); + + await page.reload({ waitUntil: "domcontentloaded", timeout: 15000 }); + await page.locator("#left-sidebar-resize").waitFor({ state: "visible", timeout: 12000 }); + const restored = await sidebarMetrics(page); + assert.equal(restored.railWidth, afterArrowLeft.railWidth); + assert.equal(restored.storage.leftSidebarWidth, afterArrowLeft.railWidth); + + await page.locator("#left-sidebar-toggle").click(); + const collapsed = await sidebarMetrics(page); + assert.equal(collapsed.collapsed, true); + assert.equal(collapsed.railWidth, 44); + assert.equal(collapsed.handle.display, "none"); + assert.equal(collapsed.handle.tabIndex, -1); + assert.equal(collapsed.handle.ariaDisabled, "true"); + assert.equal(collapsed.handle.ariaHidden, "true"); + assert.equal(collapsed.keyTargetsReachable, true); + + await page.locator("#left-sidebar-toggle").click(); + const expandedAgain = await sidebarMetrics(page); + assert.equal(expandedAgain.collapsed, false); + assert.equal(expandedAgain.railWidth, afterArrowLeft.railWidth); + assert.equal(expandedAgain.handle.tabIndex, 0); + assert.equal(expandedAgain.keyTargetsReachable, true); + + await page.close(); + } finally { + await browser.close(); + await server.close(); + } +}); + +test("left resize handle is disabled on narrow and mobile workbench layouts", async () => { + const server = await startStaticServer(webRoot); + const browser = await chromium.launch({ headless: true }); + try { + for (const viewport of [ + { width: 1024, height: 768, isMobile: false }, + { width: 390, height: 844, isMobile: true } + ]) { + const page = await browser.newPage({ + viewport: { width: viewport.width, height: viewport.height }, + deviceScaleFactor: 1, + isMobile: viewport.isMobile + }); + await page.goto(server.url, { waitUntil: "domcontentloaded", timeout: 15000 }); + await login(page); + const metrics = await sidebarMetrics(page); + assert.equal(metrics.handle.display, "none", `${viewport.width}x${viewport.height} handle display`); + assert.equal(metrics.handle.tabIndex, -1, `${viewport.width}x${viewport.height} tabindex`); + assert.equal(metrics.handle.ariaDisabled, "true", `${viewport.width}x${viewport.height} aria-disabled`); + assert.equal(metrics.handle.ariaHidden, "true", `${viewport.width}x${viewport.height} aria-hidden`); + assert.equal(metrics.disabledTargetsReachable, true, `${viewport.width}x${viewport.height} workbench areas`); + assert.equal(metrics.noHorizontalOverflow, true, `${viewport.width}x${viewport.height} overflow`); + await page.close(); + } + } finally { + await browser.close(); + await server.close(); + } +}); + +async function login(page) { + await page.waitForSelector("#login-shell, [data-app-shell]", { timeout: 12000 }); + const loginVisible = await page.locator("#login-shell").isVisible().catch(() => false); + if (!loginVisible) { + await page.waitForFunction(() => document.body.dataset.authState === "authenticated", null, { timeout: 12000 }); + return; + } + await page.locator("#login-username").fill(defaultCredentials.username); + await page.locator("#login-password").fill(defaultCredentials.password); + await page.locator("#login-submit").click(); + await page.waitForFunction(() => document.body.dataset.authState === "authenticated", null, { timeout: 12000 }); +} + +async function dragLeftResize(page, targetWidth) { + const start = await page.evaluate(() => { + const rail = document.querySelector("#activity-rail"); + const handle = document.querySelector("#left-sidebar-resize"); + const railBox = rail.getBoundingClientRect(); + const handleBox = handle.getBoundingClientRect(); + return { + currentWidth: railBox.width, + x: handleBox.left + handleBox.width / 2, + y: handleBox.top + Math.min(80, handleBox.height / 2) + }; + }); + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + await page.mouse.move(start.x + targetWidth - start.currentWidth, start.y, { steps: 8 }); + await page.mouse.up(); +} + +async function sidebarMetrics(page) { + return page.evaluate(() => { + const shell = document.querySelector("[data-app-shell]"); + const rail = document.querySelector("#activity-rail"); + const center = document.querySelector(".center-workspace"); + const right = document.querySelector("#hardware-sidebar"); + const handle = document.querySelector("#left-sidebar-resize"); + const handleStyle = getComputedStyle(handle); + const handleBox = handle.getBoundingClientRect(); + const storage = JSON.parse(localStorage.getItem("hwlab.workbench.layout.v1") ?? "null"); + const targetSelectors = [ + "#command-input", + "#command-send", + ".hardware-status", + "#hardware-list", + "#m3-write-do", + "#m3-read-di", + "[data-hardware-tab='overview']", + "[data-hardware-tab='gateways']", + "[data-hardware-tab='box1']", + "[data-hardware-tab='box2']", + "[data-hardware-tab='patch']" + ]; + const keyTargets = targetSelectors.map((selector) => inspectTargetVisibility(selector)); + const disabledTargets = ["#command-form", ".hardware-status"].map((selector) => inspectTargetVisibility(selector)); + return { + collapsed: shell.classList.contains("is-left-sidebar-collapsed"), + railWidth: Math.round(rail.getBoundingClientRect().width), + centerWidth: Math.round(center.getBoundingClientRect().width), + rightWidth: Math.round(right.getBoundingClientRect().width), + storage, + handle: { + display: handleStyle.display, + width: handleBox.width, + role: handle.getAttribute("role"), + ariaLabel: handle.getAttribute("aria-label"), + title: handle.getAttribute("title"), + tabIndex: handle.tabIndex, + ariaDisabled: handle.getAttribute("aria-disabled"), + ariaHidden: handle.getAttribute("aria-hidden"), + min: Number(handle.getAttribute("aria-valuemin")), + max: Number(handle.getAttribute("aria-valuemax")), + now: Number(handle.getAttribute("aria-valuenow")), + valueText: handle.getAttribute("aria-valuetext") + }, + keyTargets, + keyTargetsReachable: keyTargets.every((target) => target.ok), + disabledTargets, + disabledTargetsReachable: disabledTargets.every((target) => target.ok), + noHorizontalOverflow: + document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 && + document.body.scrollWidth <= document.body.clientWidth + 2 && + shell.scrollWidth <= shell.clientWidth + 2 && + right.scrollWidth <= right.clientWidth + 2, + rootScrollLocked: + getComputedStyle(document.documentElement).overflow === "hidden" && + getComputedStyle(document.body).overflow === "hidden" && + document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 && + document.body.scrollHeight <= document.body.clientHeight + 2 + }; + + function inspectTargetVisibility(selector) { + const element = document.querySelector(selector); + if (!element) return { selector, ok: false, missing: true }; + scrollIntoNearestContainer(element); + const box = element.getBoundingClientRect(); + const clip = clipBoxFor(element); + const visibleBox = { + left: Math.max(box.left, clip.left), + top: Math.max(box.top, clip.top), + right: Math.min(box.right, clip.right), + bottom: Math.min(box.bottom, clip.bottom) + }; + const visibleWidth = Math.max(0, visibleBox.right - visibleBox.left); + const visibleHeight = Math.max(0, visibleBox.bottom - visibleBox.top); + const x = Math.min(Math.max(visibleBox.left + visibleWidth / 2, 0), window.innerWidth - 1); + const y = Math.min(Math.max(visibleBox.top + Math.min(visibleHeight / 2, 32), 0), window.innerHeight - 1); + const stack = document.elementsFromPoint(x, y); + const visible = box.width > 0 && box.height > 0 && visibleWidth > 0 && visibleHeight > 0; + return { + selector, + ok: visible && stack.some((candidate) => candidate === element || element.contains(candidate)), + box: { left: box.left, top: box.top, right: box.right, bottom: box.bottom, width: box.width, height: box.height }, + visibleBox, + hit: stack[0]?.id || stack[0]?.className || stack[0]?.tagName || "none" + }; + } + + function scrollIntoNearestContainer(element) { + const container = nearestScrollableAncestor(element); + if (!container) { + element.scrollIntoView({ block: "center", inline: "nearest" }); + return; + } + const elementBox = element.getBoundingClientRect(); + const containerBox = container.getBoundingClientRect(); + const inset = Math.max(6, Math.min(24, (container.clientHeight - Math.min(elementBox.height, container.clientHeight)) / 2)); + container.scrollTop += elementBox.top - containerBox.top - inset; + } + + function nearestScrollableAncestor(element) { + let current = element.parentElement; + while (current && current !== document.documentElement) { + const style = getComputedStyle(current); + const scrollable = current.scrollHeight > current.clientHeight + 2 && !["visible", "clip"].includes(style.overflowY); + if (scrollable) return current; + current = current.parentElement; + } + return null; + } + + function clipBoxFor(element) { + let visible = { + left: 0, + top: 0, + right: window.innerWidth, + bottom: window.innerHeight + }; + let current = element.parentElement; + while (current && current !== document.documentElement) { + const style = getComputedStyle(current); + if (!["visible", "clip"].includes(style.overflow) || !["visible", "clip"].includes(style.overflowY) || !["visible", "clip"].includes(style.overflowX)) { + const box = current.getBoundingClientRect(); + visible = { + left: Math.max(visible.left, box.left), + top: Math.max(visible.top, box.top), + right: Math.min(visible.right, box.right), + bottom: Math.min(visible.bottom, box.bottom) + }; + } + current = current.parentElement; + } + return visible; + } + }); +} + +async function startStaticServer(rootDir) { + const server = createServer(async (request, response) => { + try { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + const pathname = url.pathname === "/" ? "/index.html" : url.pathname; + const resolved = path.resolve(rootDir, `.${pathname}`); + if (!resolved.startsWith(rootDir)) { + response.writeHead(403).end("forbidden"); + return; + } + const body = await readFile(resolved); + response.writeHead(200, { "content-type": contentType(resolved) }); + response.end(body); + } catch { + response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); + response.end("not found"); + } + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + return { + url: `http://127.0.0.1:${address.port}/`, + close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) + }; +} + +function contentType(filePath) { + if (filePath.endsWith(".html")) return "text/html; charset=utf-8"; + if (filePath.endsWith(".css")) return "text/css; charset=utf-8"; + if (filePath.endsWith(".mjs") || filePath.endsWith(".js")) return "text/javascript; charset=utf-8"; + if (filePath.endsWith(".md")) return "text/markdown; charset=utf-8"; + return "application/octet-stream"; +} diff --git a/web/hwlab-cloud-web/styles.css b/web/hwlab-cloud-web/styles.css index 6e3591fd..435140c6 100644 --- a/web/hwlab-cloud-web/styles.css +++ b/web/hwlab-cloud-web/styles.css @@ -17,6 +17,9 @@ --warn: #dfaa55; --bad: #e76e5e; --info: #7ea6c9; + --left-sidebar-width: 92px; + --left-sidebar-min-width: 72px; + --left-sidebar-max-width: 180px; --rail-width: 92px; --right-sidebar-width: 620px; --right-sidebar-min-width: 560px; @@ -84,8 +87,10 @@ ul { } .workbench-shell { + --rail-width: clamp(var(--left-sidebar-min-width), var(--left-sidebar-width), var(--left-sidebar-max-width)); --right-width: clamp(var(--right-sidebar-min-width), var(--right-sidebar-width), var(--right-sidebar-max-width)); --right-width-expanded: clamp(var(--right-sidebar-min-width), var(--right-sidebar-width), var(--right-sidebar-max-width)); + position: relative; height: 100vh; height: 100dvh; max-height: 100dvh; @@ -333,6 +338,25 @@ body > [data-app-shell][hidden] { opacity: 0.56; } +.left-sidebar-resize { + top: 0; + left: calc(var(--rail-width) - 6px); + width: 12px; + height: 100%; + cursor: col-resize; +} + +.left-sidebar-resize::before { + top: 10px; + bottom: 10px; + left: 5px; + width: 2px; +} + +.workbench-shell.is-left-sidebar-collapsed .left-sidebar-resize { + display: none; +} + .right-sidebar-resize { top: 0; left: 0; @@ -1879,6 +1903,7 @@ tbody tr:last-child td { border-left: 0; } + .left-sidebar-resize, .right-sidebar-resize { display: none; } @@ -1907,6 +1932,7 @@ tbody tr:last-child td { min-height: 36px; } + .left-sidebar-resize, .right-sidebar-resize { display: none; }