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.
This commit is contained in:
+112
-1
@@ -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 });
|
||||
|
||||
@@ -46,6 +46,19 @@
|
||||
title="折叠左侧导航"
|
||||
>收起</button>
|
||||
</aside>
|
||||
<div
|
||||
class="resize-handle left-sidebar-resize"
|
||||
id="left-sidebar-resize"
|
||||
role="separator"
|
||||
tabindex="0"
|
||||
aria-label="拖拽调整左侧导航宽度"
|
||||
aria-controls="activity-rail"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin="72"
|
||||
aria-valuemax="180"
|
||||
aria-valuenow="92"
|
||||
title="拖拽调整左侧导航宽度;用左右方向键微调,Home/End 到最小或最大。"
|
||||
></div>
|
||||
|
||||
<section class="center-workspace">
|
||||
<header class="topbar">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user