From ab4e41374c83fe438464d26331d7f4842bb03be4 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Sat, 23 May 2026 22:08:36 +0800 Subject: [PATCH] fix: harden workbench sidebar resizing Merge #348: add right hardware sidebar resize state and expand source/build Playwright layout coverage for #278 without changing M3 control paths or claiming DEV-LIVE hardware acceptance. --- scripts/src/dev-cloud-workbench-smoke-lib.mjs | 354 +++++++++++++++++- web/hwlab-cloud-web/app.mjs | 121 +++++- web/hwlab-cloud-web/index.html | 15 +- web/hwlab-cloud-web/scripts/check.mjs | 26 +- web/hwlab-cloud-web/styles.css | 36 +- 5 files changed, 537 insertions(+), 15 deletions(-) diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 977106ef..abfbd258 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -1054,6 +1054,27 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { summary: "Mobile layout disables the drag handle as a hit target and keeps the drawer/collapse flow reachable without horizontal overflow.", observations: mobile.resize }, + { + id: "layout-right-sidebar-resize-desktop", + status: desktop.rightResize?.pass ? "pass" : "blocked", + viewport: desktop.rightResize?.viewport ?? desktop.expanded.viewport, + summary: "Desktop right hardware sidebar resize supports pointer drag, keyboard bounds, localStorage v1 restore, and keeps Code Agent plus M3 controls reachable.", + observations: desktop.rightResize + }, + { + id: "layout-right-sidebar-resize-narrow-desktop", + status: narrowDesktop.rightResize?.pass ? "pass" : "blocked", + viewport: narrowDesktop.rightResize?.viewport ?? narrowDesktop.expanded.viewport, + summary: "Narrow desktop disables the right-sidebar drag handle while the stacked M3 hardware area remains reachable without outer page scroll.", + observations: narrowDesktop.rightResize + }, + { + id: "layout-right-sidebar-resize-mobile", + status: mobile.rightResize?.pass ? "pass" : "blocked", + viewport: mobile.rightResize?.viewport ?? mobile.collapsed.viewport, + summary: "Mobile disables the right-sidebar drag handle so it cannot cover M3 tabs, Code Agent input, or the hardware status area.", + observations: mobile.rightResize + }, { id: "layout-gate-desktop", status: desktop.gate.currentRoute.pass ? "pass" : "blocked", @@ -2158,11 +2179,30 @@ function hasSidebarCollapseContract({ html, app, styles }) { /--explorer-width:\s*292px/u.test(styles) && /--explorer-min-width:\s*220px/u.test(styles) && /--explorer-max-width:\s*420px/u.test(styles) && - /--right-width:\s*clamp\(560px,\s*38vw,\s*740px\)/u.test(styles) && - /--right-width-expanded:\s*clamp\(560px,\s*45vw,\s*740px\)/u.test(styles) && + /id=["']hardware-sidebar["']/u.test(html) && + /id=["']right-sidebar-resize["']/u.test(html) && + /aria-label=["']拖拽调整右侧硬件状态栏宽度["']/u.test(html) && + /aria-valuemin=["']560["']/u.test(html) && + /aria-valuemax=["']760["']/u.test(html) && + /aria-valuenow=["']620["']/u.test(html) && + /RIGHT_SIDEBAR_DEFAULT_WIDTH\s*=\s*620/u.test(app) && + /RIGHT_SIDEBAR_MIN_WIDTH\s*=\s*560/u.test(app) && + /RIGHT_SIDEBAR_MAX_WIDTH\s*=\s*760/u.test(app) && + /function\s+initRightSidebarResize\s*\(/u.test(app) && + /function\s+clampRightSidebarWidth\s*\(/u.test(app) && + /function\s+rightSidebarResizeBounds\s*\(/u.test(app) && + /setRightSidebarWidth\(drag\.startWidth \+ drag\.startX - event\.clientX/u.test(app) && + /rightSidebarWidth:\s*state\.layout\.rightSidebarWidth/u.test(app) && + /--right-sidebar-width:\s*620px/u.test(styles) && + /--right-sidebar-min-width:\s*560px/u.test(styles) && + /--right-sidebar-max-width:\s*760px/u.test(styles) && + /--right-width:\s*clamp\(var\(--right-sidebar-min-width\),\s*var\(--right-sidebar-width\),\s*var\(--right-sidebar-max-width\)\)/u.test(styles) && + /--right-width-expanded:\s*clamp\(var\(--right-sidebar-min-width\),\s*var\(--right-sidebar-width\),\s*var\(--right-sidebar-max-width\)\)/u.test(styles) && /\.explorer-resize\s*\{[^}]*cursor:\s*col-resize;/su.test(styles) && + /\.right-sidebar-resize\s*\{[^}]*cursor:\s*col-resize;/su.test(styles) && /\.explorer-collapsed\s+\.explorer-resize\s*\{[^}]*display:\s*none;/su.test(styles) && /@media\s*\(max-width:\s*860px\)[\s\S]*?\.explorer-resize\s*\{[\s\S]*?display:\s*none;/u.test(styles) && + /@media\s*\(max-width:\s*1240px\)[\s\S]*?\.right-sidebar-resize\s*\{[\s\S]*?display:\s*none;/u.test(styles) && /\.explorer-collapsed\s*\{[^}]*--explorer-width:\s*0px;[^}]*grid-template-columns:\s*var\(--rail-width\)\s+0\s+minmax\(420px,\s*1fr\)\s+var\(--right-width\);/su.test(styles) && /\.side-panel\s*\{[^}]*overflow:\s*auto;[^}]*overscroll-behavior:\s*contain;/su.test(styles) ); @@ -4963,8 +5003,9 @@ async function inspectWorkbenchLayoutViewport(browser, url, viewport, options = }; drawer.pass = drawer.pass && recollapsed.explorerCollapsed === true && recollapsed.keyTargetsReachable; const resize = await inspectExplorerResizeMobile(page, { viewport }); + const rightResize = await inspectRightSidebarResizeDisabled(page, { viewport }); const gate = await inspectWorkbenchGateLayout(browser, url, viewport, options); - return { collapsed, drawer, resize, gate, artifacts }; + return { collapsed, drawer, resize, rightResize, gate, artifacts }; } const expanded = await inspectLayoutState(page, { @@ -4989,8 +5030,11 @@ async function inspectWorkbenchLayoutViewport(browser, url, viewport, options = compareTo: expanded }); const resize = await inspectExplorerResizeDesktop(page, { viewport }); + const rightResize = viewport.width > 1240 + ? await inspectRightSidebarResizeDesktop(page, { viewport }) + : await inspectRightSidebarResizeDisabled(page, { viewport }); const gate = await inspectWorkbenchGateLayout(browser, url, viewport, options); - return { expanded, collapsed, restored, resize, gate, artifacts }; + return { expanded, collapsed, restored, resize, rightResize, gate, artifacts }; } finally { await page.close(); } @@ -5305,6 +5349,306 @@ async function inspectExplorerResizeMobile(page, { viewport }) { }; } +async function inspectRightSidebarResizeDesktop(page, { viewport }) { + const before = await page.evaluate(() => { + const shell = document.querySelector("[data-app-shell]"); + const right = document.querySelector("#hardware-sidebar"); + const center = document.querySelector(".center-workspace"); + const handle = document.querySelector("#right-sidebar-resize"); + return { + rightWidth: right?.getBoundingClientRect().width ?? 0, + centerWidth: center?.getBoundingClientRect().width ?? 0, + cssWidth: Number.parseFloat(getComputedStyle(shell).getPropertyValue("--right-sidebar-width")), + storage: localStorage.getItem("hwlab.workbench.layout.v1"), + handle: { + ariaLabel: handle?.getAttribute("aria-label") ?? "", + title: handle?.getAttribute("title") ?? "", + role: handle?.getAttribute("role") ?? "", + tabIndex: handle?.tabIndex ?? null, + valueMin: handle?.getAttribute("aria-valuemin") ?? "", + valueMax: handle?.getAttribute("aria-valuemax") ?? "", + valueNow: handle?.getAttribute("aria-valuenow") ?? "", + valueText: handle?.getAttribute("aria-valuetext") ?? "" + } + }; + }); + const min = Number(before.handle.valueMin); + const max = Number(before.handle.valueMax); + const growTarget = Math.min(max, before.rightWidth + 56); + const shrinkTarget = Math.max(min, before.rightWidth - 48); + const dragTarget = growTarget - before.rightWidth >= 24 ? growTarget : shrinkTarget; + await dragRightSidebarResizeHandle(page, dragTarget); + const afterDrag = await rightSidebarResizeMetrics(page); + await page.locator("#right-sidebar-resize").press("ArrowRight"); + const afterArrowRight = await rightSidebarResizeMetrics(page); + await page.locator("#right-sidebar-resize").press("End"); + const afterEnd = await rightSidebarResizeMetrics(page); + await page.locator("#right-sidebar-resize").press("Home"); + const afterHome = await rightSidebarResizeMetrics(page); + await page.locator("#right-sidebar-resize").press("ArrowLeft"); + const afterArrowLeft = await rightSidebarResizeMetrics(page); + await page.locator("#explorer-toggle").click(); + await page.waitForFunction(() => document.querySelector("[data-app-shell]")?.classList.contains("explorer-collapsed"), null, { timeout: 8000 }); + const collapsedLeft = await rightSidebarResizeMetrics(page); + await page.locator("#explorer-toggle").click(); + await page.waitForFunction(() => !document.querySelector("[data-app-shell]")?.classList.contains("explorer-collapsed"), null, { timeout: 8000 }); + const restoredLeft = await rightSidebarResizeMetrics(page); + await page.reload({ waitUntil: "domcontentloaded", timeout: 15000 }); + await page.locator("#right-sidebar-resize").waitFor({ state: "visible", timeout: 12000 }); + await page.waitForTimeout(150); + const restoredFromStorage = await rightSidebarResizeMetrics(page); + const expectedArrowLeft = Math.min(afterHome.bounds.max, afterHome.bounds.min + 16); + const pass = + before.handle.role === "separator" && + before.handle.ariaLabel === "拖拽调整右侧硬件状态栏宽度" && + before.handle.title.includes("左右方向键") && + before.handle.tabIndex === 0 && + Number.isFinite(min) && + Number.isFinite(max) && + max >= min && + Math.abs(afterDrag.rightWidth - before.rightWidth) >= 20 && + afterDrag.rightWidth >= min && + afterDrag.rightWidth <= max && + afterArrowRight.rightWidth <= afterDrag.rightWidth - 12 && + Math.abs(afterEnd.rightWidth - afterEnd.bounds.max) <= 2 && + Math.abs(afterHome.rightWidth - afterHome.bounds.min) <= 2 && + Math.abs(afterArrowLeft.rightWidth - expectedArrowLeft) <= 2 && + collapsedLeft.leftExplorerCollapsed === true && + collapsedLeft.handleTabIndex === 0 && + collapsedLeft.keyTargetsReachable && + Math.abs(restoredLeft.rightWidth - afterArrowLeft.rightWidth) <= 2 && + restoredLeft.storage?.version === 1 && + restoredLeft.storage?.rightSidebarWidth === afterArrowLeft.rightWidth && + Math.abs(restoredFromStorage.rightWidth - afterArrowLeft.rightWidth) <= 2 && + restoredFromStorage.keyTargetsReachable && + restoredFromStorage.rootScrollLocked && + restoredFromStorage.noHorizontalOverflow; + return { + viewport, + pass, + targetWidth: dragTarget, + before, + afterDrag, + afterArrowRight, + afterEnd, + afterHome, + afterArrowLeft, + collapsedLeft, + restoredLeft, + restoredFromStorage + }; +} + +async function dragRightSidebarResizeHandle(page, targetWidth) { + const start = await page.evaluate(() => { + const right = document.querySelector("#hardware-sidebar"); + const handle = document.querySelector("#right-sidebar-resize"); + const rightBox = right.getBoundingClientRect(); + const handleBox = handle.getBoundingClientRect(); + return { + startX: handleBox.left + handleBox.width / 2, + startY: handleBox.top + Math.min(80, handleBox.height / 2), + currentWidth: rightBox.width + }; + }); + const targetX = start.startX - (targetWidth - start.currentWidth); + await page.mouse.move(start.startX, start.startY); + await page.mouse.down(); + await page.mouse.move(targetX, start.startY, { steps: 8 }); + await page.mouse.up(); +} + +async function rightSidebarResizeMetrics(page) { + return page.evaluate(async () => { + const shell = document.querySelector("[data-app-shell]"); + const explorer = document.querySelector("#resource-explorer"); + const center = document.querySelector(".center-workspace"); + const right = document.querySelector("#hardware-sidebar"); + const handle = document.querySelector("#right-sidebar-resize"); + const ownsHit = (element, hit) => hit === element || element.contains(hit); + const hitLabel = (hit) => hit ? `${hit.tagName.toLowerCase()}${hit.id ? `#${hit.id}` : ""}${hit.className ? `.${String(hit.className).trim().replace(/\s+/gu, ".")}` : ""}` : "none"; + const 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; + }; + const scrollIntoNearestContainer = async (element) => { + const container = nearestScrollableAncestor(element); + if (!container) { + element.scrollIntoView({ block: "center", inline: "nearest" }); + await new Promise((resolve) => requestAnimationFrame(resolve)); + return null; + } + 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; + await new Promise((resolve) => requestAnimationFrame(resolve)); + return container; + }; + const clipBoxForElement = (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; + }; + const targetSelectors = [ + "#command-input", + "#command-send", + "#m3-write-do", + "#m3-read-di", + "#tab-control", + "#tab-wiring", + "[data-hardware-tab='overview']", + "[data-hardware-tab='gateways']", + "[data-hardware-tab='box1']", + "[data-hardware-tab='box2']", + "[data-hardware-tab='patch']" + ]; + const keyTargets = []; + for (const selector of targetSelectors) { + const element = document.querySelector(selector); + if (!element) { + keyTargets.push({ selector, ok: false, missing: true }); + continue; + } + const scrollContainer = await scrollIntoNearestContainer(element); + const box = element.getBoundingClientRect(); + const clipBox = clipBoxForElement(element); + const visibleBox = { + left: Math.max(box.left, clipBox.left), + top: Math.max(box.top, clipBox.top), + right: Math.min(box.right, clipBox.right), + bottom: Math.min(box.bottom, clipBox.bottom) + }; + const visibleWidth = Math.max(0, visibleBox.right - visibleBox.left); + const visibleHeight = Math.max(0, visibleBox.bottom - visibleBox.top); + const x = visibleBox.left + visibleWidth / 2; + const y = visibleBox.top + visibleHeight / 2; + const stack = document.elementsFromPoint(x, y); + keyTargets.push({ + selector, + ok: + box.width > 0 && + box.height > 0 && + visibleWidth > 0 && + visibleHeight > 0 && + x >= 0 && + x <= window.innerWidth && + y >= 0 && + y <= window.innerHeight && + stack.some((candidate) => ownsHit(element, candidate)), + hit: hitLabel(stack[0] ?? null), + center: { x, y }, + visibleBox, + scrollContainer: scrollContainer ? scrollContainer.id ? `#${scrollContainer.id}` : scrollContainer.className ? `.${String(scrollContainer.className).trim().replace(/\s+/gu, ".")}` : scrollContainer.tagName.toLowerCase() : null + }); + } + let storage = null; + try { + storage = JSON.parse(localStorage.getItem("hwlab.workbench.layout.v1") ?? "null"); + } catch { + storage = "parse_failed"; + } + return { + leftExplorerCollapsed: shell?.classList.contains("explorer-collapsed") === true, + explorerWidth: explorer?.getBoundingClientRect().width ?? 0, + centerWidth: center?.getBoundingClientRect().width ?? 0, + rightWidth: right?.getBoundingClientRect().width ?? 0, + cssWidth: Number.parseFloat(getComputedStyle(shell).getPropertyValue("--right-sidebar-width")), + bounds: { + min: Number(handle?.getAttribute("aria-valuemin")), + max: Number(handle?.getAttribute("aria-valuemax")) + }, + handleTabIndex: handle?.tabIndex ?? null, + handleAriaDisabled: handle?.getAttribute("aria-disabled") ?? "", + handleAriaHidden: handle?.getAttribute("aria-hidden") ?? "", + handleAriaValueNow: Number(handle?.getAttribute("aria-valuenow")), + handleAriaValueText: handle?.getAttribute("aria-valuetext") ?? "", + storage, + keyTargets, + keyTargetsReachable: keyTargets.every((target) => target.ok), + noHorizontalOverflow: + document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 && + document.body.scrollWidth <= document.body.clientWidth + 2 && + (!right || 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 + }; + }); +} + +async function inspectRightSidebarResizeDisabled(page, { viewport }) { + const observation = await page.evaluate(() => { + const handle = document.querySelector("#right-sidebar-resize"); + const box = handle?.getBoundingClientRect(); + const style = handle ? getComputedStyle(handle) : null; + const right = document.querySelector("#hardware-sidebar"); + const command = document.querySelector("#command-form"); + const hardwareStatus = document.querySelector(".hardware-status"); + return { + viewport: { width: window.innerWidth, height: window.innerHeight }, + handlePresent: Boolean(handle), + handleDisplay: style?.display ?? "missing", + handleWidth: box?.width ?? 0, + handleTabIndex: handle?.tabIndex ?? null, + handleAriaDisabled: handle?.getAttribute("aria-disabled") ?? "", + handleAriaHidden: handle?.getAttribute("aria-hidden") ?? "", + rightWidth: right?.getBoundingClientRect().width ?? 0, + commandVisible: Boolean(command && command.getBoundingClientRect().width > 0 && command.getBoundingClientRect().height > 0), + hardwareVisible: Boolean(hardwareStatus && hardwareStatus.getBoundingClientRect().width > 0 && hardwareStatus.getBoundingClientRect().height > 0), + noHorizontalOverflow: + document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 && + document.body.scrollWidth <= document.body.clientWidth + 2 && + (!right || 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 + }; + }); + return { + viewport, + pass: + observation.handlePresent && + observation.handleDisplay === "none" && + observation.handleTabIndex === -1 && + observation.handleAriaDisabled === "true" && + observation.handleAriaHidden === "true" && + observation.commandVisible && + observation.hardwareVisible && + observation.noHorizontalOverflow && + observation.rootScrollLocked, + ...observation + }; +} + async function inspectLayoutState(page, { mode, viewport, compareTo }) { return page.evaluate(async ({ mode, viewport, compareTo }) => { const failures = []; @@ -5566,6 +5910,7 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { keyTargets.push(await inspectHitTarget(selector, label)); } const resizeHandle = await inspectHitTarget("#explorer-resize", "左侧资源树宽度调整手柄"); + const rightResizeHandle = await inspectHitTarget("#right-sidebar-resize", "右侧硬件状态栏宽度调整手柄"); const drawerTargets = []; for (const [selector, label] of drawerSelectorLabels) { drawerTargets.push(await inspectFirstHit(selector, label)); @@ -5984,6 +6329,7 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { keyTargetsRequirement, blockedKeyTargets, resizeHandle, + rightResizeHandle, drawerReachable, blockedDrawerTargets, sidePanelScroll, diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 45ca13ba..f83326cb 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -68,6 +68,9 @@ const LAYOUT_STORAGE_KEY = "hwlab.workbench.layout.v1"; const EXPLORER_DEFAULT_WIDTH = 292; const EXPLORER_MIN_WIDTH = 220; const EXPLORER_MAX_WIDTH = 420; +const RIGHT_SIDEBAR_DEFAULT_WIDTH = 620; +const RIGHT_SIDEBAR_MIN_WIDTH = 560; +const RIGHT_SIDEBAR_MAX_WIDTH = 760; const viewIds = new Set([...document.querySelectorAll("[data-view]")].map((view) => view.dataset.view)); let rpcSequence = 0; @@ -76,6 +79,7 @@ const el = { shell: query("[data-app-shell]"), explorerToggle: byId("explorer-toggle"), explorerResize: byId("explorer-resize"), + rightSidebarResize: byId("right-sidebar-resize"), explorerStatus: byId("explorer-status"), treeCount: byId("tree-count"), resourceTree: byId("resource-tree"), @@ -136,7 +140,9 @@ const state = { }, layout: { explorerWidth: EXPLORER_DEFAULT_WIDTH, - drag: null + rightSidebarWidth: RIGHT_SIDEBAR_DEFAULT_WIDTH, + drag: null, + rightDrag: null } }; @@ -145,6 +151,7 @@ initLayoutSizing(); initExplorerToggle(); syncMobileExplorer(); initExplorerResize(); +initRightSidebarResize(); initSideTabs(); initHardwareTabs(); initQuickActions(); @@ -191,8 +198,10 @@ function initRoutes() { function initLayoutSizing() { const persisted = readPersistedLayout(); - const width = clampExplorerWidth(persisted?.explorerWidth ?? EXPLORER_DEFAULT_WIDTH); - setExplorerWidth(width, { persist: false }); + const explorerWidth = clampExplorerWidth(persisted?.explorerWidth ?? EXPLORER_DEFAULT_WIDTH); + const rightSidebarWidth = clampRightSidebarWidth(persisted?.rightSidebarWidth ?? RIGHT_SIDEBAR_DEFAULT_WIDTH); + setExplorerWidth(explorerWidth, { persist: false }); + setRightSidebarWidth(rightSidebarWidth, { persist: false }); } function readPersistedLayout() { @@ -202,7 +211,8 @@ function readPersistedLayout() { const payload = JSON.parse(raw); if (payload?.version !== 1) return null; return { - explorerWidth: Number(payload.explorerWidth) + explorerWidth: Number(payload.explorerWidth), + rightSidebarWidth: Number(payload.rightSidebarWidth) }; } catch { return null; @@ -215,7 +225,8 @@ function persistLayout() { LAYOUT_STORAGE_KEY, JSON.stringify({ version: 1, - explorerWidth: state.layout.explorerWidth + explorerWidth: state.layout.explorerWidth, + rightSidebarWidth: state.layout.rightSidebarWidth }) ); } catch { @@ -228,6 +239,16 @@ function setExplorerWidth(width, options = {}) { state.layout.explorerWidth = clamped; el.shell.style.setProperty("--explorer-width", `${clamped}px`); syncExplorerResizeA11y(); + syncRightSidebarResizeA11y(); + if (options.persist !== false) persistLayout(); + return clamped; +} + +function setRightSidebarWidth(width, options = {}) { + const clamped = clampRightSidebarWidth(width); + state.layout.rightSidebarWidth = clamped; + el.shell.style.setProperty("--right-sidebar-width", `${clamped}px`); + syncRightSidebarResizeA11y(); if (options.persist !== false) persistLayout(); return clamped; } @@ -239,6 +260,13 @@ function clampExplorerWidth(value) { 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; + const bounds = rightSidebarResizeBounds(); + return Math.min(Math.max(Math.round(width), bounds.min), bounds.max); +} + function explorerResizeBounds() { const styles = getComputedStyle(el.shell); const cssMin = readCssPixel(styles.getPropertyValue("--explorer-min-width"), EXPLORER_MIN_WIDTH); @@ -263,6 +291,20 @@ function boundedResizeRange(min, max, dynamicMax) { return { min: safeMin, max: safeMax }; } +function rightSidebarResizeBounds() { + const styles = getComputedStyle(el.shell); + const cssMin = readCssPixel(styles.getPropertyValue("--right-sidebar-min-width"), RIGHT_SIDEBAR_MIN_WIDTH); + const cssMax = readCssPixel(styles.getPropertyValue("--right-sidebar-max-width"), RIGHT_SIDEBAR_MAX_WIDTH); + 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 explorerWidth = el.shell.classList.contains("explorer-collapsed") ? 0 : state.layout.explorerWidth; + const centerMinWidth = el.shell.classList.contains("explorer-collapsed") ? 420 : 360; + const dynamicMax = window.innerWidth - railWidth - explorerWidth - centerMinWidth - 4; + return boundedResizeRange(cssMin, cssMax, dynamicMax); +} + function readCssPixel(value, fallback) { const parsed = Number.parseFloat(String(value ?? "").trim()); return Number.isFinite(parsed) ? parsed : fallback; @@ -276,6 +318,18 @@ function syncExplorerResizeA11y() { el.explorerResize.setAttribute("aria-valuetext", `左侧资源树宽度 ${state.layout.explorerWidth} 像素`); } +function syncRightSidebarResizeA11y() { + const bounds = rightSidebarResizeBounds(); + const disabled = !canResizeRightSidebar(); + el.rightSidebarResize.tabIndex = disabled ? -1 : 0; + el.rightSidebarResize.setAttribute("aria-disabled", disabled ? "true" : "false"); + el.rightSidebarResize.setAttribute("aria-hidden", disabled ? "true" : "false"); + el.rightSidebarResize.setAttribute("aria-valuemin", String(bounds.min)); + el.rightSidebarResize.setAttribute("aria-valuemax", String(bounds.max)); + el.rightSidebarResize.setAttribute("aria-valuenow", String(state.layout.rightSidebarWidth)); + el.rightSidebarResize.setAttribute("aria-valuetext", `右侧硬件状态栏宽度 ${state.layout.rightSidebarWidth} 像素`); +} + function routeFromLocation() { const hashRoute = window.location.hash.replace(/^#\/?/, ""); if (viewIds.has(hashRoute)) return hashRoute; @@ -350,6 +404,50 @@ function initExplorerResize() { }); } +function initRightSidebarResize() { + window.addEventListener("resize", () => { + if (canResizeRightSidebar()) setRightSidebarWidth(state.layout.rightSidebarWidth, { persist: false }); + syncRightSidebarResizeA11y(); + }); + el.rightSidebarResize.addEventListener("pointerdown", (event) => { + if (!canResizeRightSidebar()) return; + event.preventDefault(); + el.rightSidebarResize.setPointerCapture?.(event.pointerId); + state.layout.rightDrag = { + pointerId: event.pointerId, + startX: event.clientX, + startWidth: state.layout.rightSidebarWidth + }; + el.shell.classList.add("is-resizing"); + }); + el.rightSidebarResize.addEventListener("pointermove", (event) => { + const drag = state.layout.rightDrag; + if (!drag || drag.pointerId !== event.pointerId) return; + setRightSidebarWidth(drag.startWidth + drag.startX - event.clientX, { persist: false }); + }); + el.rightSidebarResize.addEventListener("pointerup", finishRightSidebarResize); + el.rightSidebarResize.addEventListener("pointercancel", finishRightSidebarResize); + el.rightSidebarResize.addEventListener("keydown", (event) => { + if (!canResizeRightSidebar()) return; + const keySteps = { + ArrowLeft: 16, + ArrowRight: -16, + PageUp: 48, + PageDown: -48 + }; + if (Object.hasOwn(keySteps, event.key)) { + event.preventDefault(); + setRightSidebarWidth(state.layout.rightSidebarWidth + keySteps[event.key]); + } else if (event.key === "Home") { + event.preventDefault(); + setRightSidebarWidth(rightSidebarResizeBounds().min); + } else if (event.key === "End") { + event.preventDefault(); + setRightSidebarWidth(rightSidebarResizeBounds().max); + } + }); +} + function finishExplorerResize(event) { const drag = state.layout.drag; if (!drag || drag.pointerId !== event.pointerId) return; @@ -358,10 +456,22 @@ function finishExplorerResize(event) { persistLayout(); } +function finishRightSidebarResize(event) { + const drag = state.layout.rightDrag; + if (!drag || drag.pointerId !== event.pointerId) return; + state.layout.rightDrag = null; + el.shell.classList.remove("is-resizing"); + persistLayout(); +} + function canResizeExplorer() { return !window.matchMedia("(max-width: 860px)").matches && !el.shell.classList.contains("explorer-collapsed"); } +function canResizeRightSidebar() { + return !window.matchMedia("(max-width: 1240px)").matches; +} + function setExplorerCollapsed(collapsed) { if (!collapsed) { setExplorerWidth(state.layout.explorerWidth, { persist: false }); @@ -376,6 +486,7 @@ function setExplorerCollapsed(collapsed) { el.explorerResize.tabIndex = resizeDisabled ? -1 : 0; el.explorerResize.setAttribute("aria-disabled", resizeDisabled ? "true" : "false"); el.explorerResize.setAttribute("aria-hidden", resizeDisabled ? "true" : "false"); + setRightSidebarWidth(state.layout.rightSidebarWidth, { persist: false }); } function syncMobileExplorer() { diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index c8166b46..d1658683 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -176,7 +176,20 @@ -