Archived
feat: add resizable side panels
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add draggable, persistent side panel resizing for the web UI navigation and workspace panels, including reset actions.
|
||||
@@ -29,6 +29,12 @@ export class PanelCollapseController implements ReactiveController {
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
expandWorkspacePanel(): void {
|
||||
if (!this.workspacePanelCollapsed) return;
|
||||
this.workspacePanelCollapsed = false;
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
shellClass(mainView: AppState["mainView"]): string {
|
||||
return [
|
||||
"shell",
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
clampPanelWidth,
|
||||
PANEL_SIZE_STORAGE_KEY,
|
||||
panelResizeDelta,
|
||||
panelWidthFromDrag,
|
||||
panelWidthFromKeyboard,
|
||||
readStoredPanelSizes,
|
||||
writeStoredPanelSizes,
|
||||
} from "./panelResizeController";
|
||||
|
||||
describe("panel resize behavior", () => {
|
||||
it("resizes left and right panels in opposite drag directions", () => {
|
||||
expect(panelResizeDelta("navigation", 100, 140)).toBe(40);
|
||||
expect(panelWidthFromDrag("navigation", 300, 100, 140)).toBe(340);
|
||||
|
||||
expect(panelResizeDelta("workspace", 100, 140)).toBe(-40);
|
||||
expect(panelWidthFromDrag("workspace", 500, 100, 140)).toBe(460);
|
||||
});
|
||||
|
||||
it("clamps panel widths to broad fallback bounds", () => {
|
||||
expect(clampPanelWidth("navigation", 50)).toBe(180);
|
||||
expect(clampPanelWidth("navigation", 9000)).toBe(4096);
|
||||
expect(clampPanelWidth("workspace", 50)).toBe(240);
|
||||
expect(clampPanelWidth("workspace", 9000)).toBe(4096);
|
||||
});
|
||||
|
||||
it("supports narrower viewport-aware constraints", () => {
|
||||
const constraints = { minWidth: 200, maxWidth: 900, defaultWidth: 340, keyboardStep: 24, largeKeyboardStep: 72 };
|
||||
|
||||
expect(clampPanelWidth("navigation", 100, constraints)).toBe(200);
|
||||
expect(clampPanelWidth("navigation", 1200, constraints)).toBe(900);
|
||||
});
|
||||
|
||||
it("supports keyboard resizing in panel-relative directions", () => {
|
||||
expect(panelWidthFromKeyboard("navigation", 300, "ArrowRight")).toBe(324);
|
||||
expect(panelWidthFromKeyboard("navigation", 300, "ArrowLeft")).toBe(276);
|
||||
expect(panelWidthFromKeyboard("workspace", 500, "ArrowLeft")).toBe(524);
|
||||
expect(panelWidthFromKeyboard("workspace", 500, "ArrowRight")).toBe(476);
|
||||
expect(panelWidthFromKeyboard("workspace", 500, "Home")).toBe(240);
|
||||
expect(panelWidthFromKeyboard("workspace", 500, "End")).toBe(4096);
|
||||
expect(panelWidthFromKeyboard("workspace", 500, "Enter")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads, writes, and clears stored panel widths", () => {
|
||||
const storage = new FakeStorage();
|
||||
|
||||
expect(readStoredPanelSizes(storage)).toEqual({});
|
||||
writeStoredPanelSizes({ navigationPanelWidth: 260, workspacePanelWidth: 640 }, storage);
|
||||
|
||||
expect(JSON.parse(storage.value(PANEL_SIZE_STORAGE_KEY) ?? "{}")).toEqual({
|
||||
version: 1,
|
||||
navigationPanelWidth: 260,
|
||||
workspacePanelWidth: 640,
|
||||
});
|
||||
expect(readStoredPanelSizes(storage)).toEqual({ navigationPanelWidth: 260, workspacePanelWidth: 640 });
|
||||
|
||||
writeStoredPanelSizes({}, storage);
|
||||
expect(storage.value(PANEL_SIZE_STORAGE_KEY)).toBeUndefined();
|
||||
expect(readStoredPanelSizes(storage)).toEqual({});
|
||||
});
|
||||
|
||||
it("clamps stored panel widths and ignores invalid values", () => {
|
||||
const storage = new FakeStorage({
|
||||
[PANEL_SIZE_STORAGE_KEY]: JSON.stringify({ version: 1, navigationPanelWidth: 9999, workspacePanelWidth: "wide" }),
|
||||
});
|
||||
|
||||
expect(readStoredPanelSizes(storage)).toEqual({ navigationPanelWidth: 4096 });
|
||||
});
|
||||
|
||||
it("ignores storage failures", () => {
|
||||
const storage = new ThrowingStorage();
|
||||
|
||||
expect(readStoredPanelSizes(storage)).toEqual({});
|
||||
expect(() => { writeStoredPanelSizes({ navigationPanelWidth: 260 }, storage); }).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
class FakeStorage {
|
||||
private readonly values = new Map<string, string>();
|
||||
|
||||
constructor(seed: Record<string, string> = {}) {
|
||||
for (const [key, value] of Object.entries(seed)) this.values.set(key, value);
|
||||
}
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.values.set(key, value);
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.values.delete(key);
|
||||
}
|
||||
|
||||
value(key: string): string | undefined {
|
||||
return this.values.get(key);
|
||||
}
|
||||
}
|
||||
|
||||
class ThrowingStorage {
|
||||
getItem(): string | null {
|
||||
throw new Error("blocked");
|
||||
}
|
||||
|
||||
setItem(): void {
|
||||
throw new Error("blocked");
|
||||
}
|
||||
|
||||
removeItem(): void {
|
||||
throw new Error("blocked");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
||||
|
||||
export type ResizablePanelSide = "navigation" | "workspace";
|
||||
|
||||
export interface PanelResizeConstraints {
|
||||
minWidth: number;
|
||||
maxWidth: number;
|
||||
defaultWidth: number;
|
||||
keyboardStep: number;
|
||||
largeKeyboardStep: number;
|
||||
}
|
||||
|
||||
export interface PanelSizePreferences {
|
||||
navigationPanelWidth?: number;
|
||||
workspacePanelWidth?: number;
|
||||
}
|
||||
|
||||
export interface PanelResizeControllerOptions {
|
||||
storage?: PanelSizeStorage;
|
||||
}
|
||||
|
||||
export interface PanelResizeOptions {
|
||||
persist?: boolean;
|
||||
}
|
||||
|
||||
export interface PanelResetOptions {
|
||||
persist?: boolean;
|
||||
}
|
||||
|
||||
export interface PanelKeyboardResizeOptions {
|
||||
largeStep?: boolean;
|
||||
constraints?: PanelResizeConstraints;
|
||||
}
|
||||
|
||||
export type PanelSizeStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
||||
export type PanelResizeConstraintsBySide = Partial<Record<ResizablePanelSide, PanelResizeConstraints>>;
|
||||
|
||||
export const PANEL_SIZE_STORAGE_KEY = "pi-web:panel-sizes:v1";
|
||||
export const PANEL_RESIZE_CONSTRAINTS = {
|
||||
navigation: { minWidth: 180, maxWidth: 4096, defaultWidth: 340, keyboardStep: 24, largeKeyboardStep: 72 },
|
||||
workspace: { minWidth: 240, maxWidth: 4096, defaultWidth: 480, keyboardStep: 24, largeKeyboardStep: 72 },
|
||||
} as const satisfies Record<ResizablePanelSide, PanelResizeConstraints>;
|
||||
|
||||
interface StoredPanelSizeEnvelope {
|
||||
version: 1;
|
||||
navigationPanelWidth?: number;
|
||||
workspacePanelWidth?: number;
|
||||
}
|
||||
|
||||
export class PanelResizeController implements ReactiveController {
|
||||
private readonly storage: PanelSizeStorage | undefined;
|
||||
private panelSizes: PanelSizePreferences;
|
||||
|
||||
constructor(private readonly host: ReactiveControllerHost, options: PanelResizeControllerOptions = {}) {
|
||||
host.addController(this);
|
||||
this.storage = options.storage ?? browserPanelSizeStorage();
|
||||
this.panelSizes = readStoredPanelSizes(this.storage);
|
||||
}
|
||||
|
||||
hostConnected(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
constraints(side: ResizablePanelSide): PanelResizeConstraints {
|
||||
return panelResizeConstraints(side);
|
||||
}
|
||||
|
||||
panelWidth(side: ResizablePanelSide, measuredWidth?: number): number {
|
||||
return clampPanelWidth(side, measuredWidth ?? this.storedPanelWidth(side) ?? this.constraints(side).defaultWidth);
|
||||
}
|
||||
|
||||
resizePanel(side: ResizablePanelSide, width: number, options: PanelResizeOptions = {}): void {
|
||||
const nextWidth = clampPanelWidth(side, width);
|
||||
if (this.storedPanelWidth(side) === nextWidth) return;
|
||||
this.panelSizes = panelSizesWithWidth(this.panelSizes, side, nextWidth);
|
||||
if (options.persist !== false) this.persistPanelSizes();
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
resetPanel(side: ResizablePanelSide, options: PanelResetOptions = {}): void {
|
||||
if (this.storedPanelWidth(side) === undefined) return;
|
||||
this.panelSizes = panelSizesWithoutSide(this.panelSizes, side);
|
||||
if (options.persist !== false) this.persistPanelSizes();
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
resetPanels(options: PanelResetOptions = {}): void {
|
||||
if (this.panelSizes.navigationPanelWidth === undefined && this.panelSizes.workspacePanelWidth === undefined) return;
|
||||
this.panelSizes = {};
|
||||
if (options.persist !== false) this.persistPanelSizes();
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
persistPanelSizes(): void {
|
||||
writeStoredPanelSizes(this.panelSizes, this.storage);
|
||||
}
|
||||
|
||||
shellStyle(constraintsBySide: PanelResizeConstraintsBySide = {}): string {
|
||||
const declarations: string[] = [];
|
||||
if (this.panelSizes.navigationPanelWidth !== undefined) {
|
||||
declarations.push(`--navigation-panel-size: ${formatPanelWidth(clampPanelWidth("navigation", this.panelSizes.navigationPanelWidth, constraintsBySide.navigation))};`);
|
||||
}
|
||||
if (this.panelSizes.workspacePanelWidth !== undefined) {
|
||||
declarations.push(`--workspace-panel-size: ${formatPanelWidth(clampPanelWidth("workspace", this.panelSizes.workspacePanelWidth, constraintsBySide.workspace))};`);
|
||||
}
|
||||
return declarations.join(" ");
|
||||
}
|
||||
|
||||
private storedPanelWidth(side: ResizablePanelSide): number | undefined {
|
||||
return side === "navigation" ? this.panelSizes.navigationPanelWidth : this.panelSizes.workspacePanelWidth;
|
||||
}
|
||||
}
|
||||
|
||||
export function panelResizeConstraints(side: ResizablePanelSide): PanelResizeConstraints {
|
||||
return PANEL_RESIZE_CONSTRAINTS[side];
|
||||
}
|
||||
|
||||
export function panelResizeDelta(side: ResizablePanelSide, startClientX: number, currentClientX: number): number {
|
||||
return side === "navigation" ? currentClientX - startClientX : startClientX - currentClientX;
|
||||
}
|
||||
|
||||
export function panelWidthFromDrag(side: ResizablePanelSide, startWidth: number, startClientX: number, currentClientX: number, constraints = panelResizeConstraints(side)): number {
|
||||
return clampPanelWidth(side, startWidth + panelResizeDelta(side, startClientX, currentClientX), constraints);
|
||||
}
|
||||
|
||||
export function panelWidthFromKeyboard(side: ResizablePanelSide, currentWidth: number, key: string, options: PanelKeyboardResizeOptions = {}): number | undefined {
|
||||
const constraints = options.constraints ?? panelResizeConstraints(side);
|
||||
if (key === "Home") return constraints.minWidth;
|
||||
if (key === "End") return constraints.maxWidth;
|
||||
|
||||
const step = options.largeStep === true ? constraints.largeKeyboardStep : constraints.keyboardStep;
|
||||
const delta = keyboardResizeDelta(side, key, step);
|
||||
if (delta === undefined) return undefined;
|
||||
return clampPanelWidth(side, currentWidth + delta, constraints);
|
||||
}
|
||||
|
||||
export function clampPanelWidth(side: ResizablePanelSide, width: number, constraints = panelResizeConstraints(side)): number {
|
||||
if (!Number.isFinite(width)) return constraints.defaultWidth;
|
||||
return Math.round(Math.min(Math.max(width, constraints.minWidth), constraints.maxWidth));
|
||||
}
|
||||
|
||||
export function readStoredPanelSizes(storage: PanelSizeStorage | undefined = browserPanelSizeStorage()): PanelSizePreferences {
|
||||
try {
|
||||
const raw = storage?.getItem(PANEL_SIZE_STORAGE_KEY);
|
||||
if (raw === undefined || raw === null || raw === "") return {};
|
||||
const value: unknown = JSON.parse(raw);
|
||||
return parseStoredPanelSizes(value);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredPanelSizes(panelSizes: PanelSizePreferences, storage: PanelSizeStorage | undefined = browserPanelSizeStorage()): void {
|
||||
if (storage === undefined) return;
|
||||
try {
|
||||
if (panelSizes.navigationPanelWidth === undefined && panelSizes.workspacePanelWidth === undefined) {
|
||||
storage.removeItem(PANEL_SIZE_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
const envelope: StoredPanelSizeEnvelope = { version: 1 };
|
||||
if (panelSizes.navigationPanelWidth !== undefined) envelope.navigationPanelWidth = clampPanelWidth("navigation", panelSizes.navigationPanelWidth);
|
||||
if (panelSizes.workspacePanelWidth !== undefined) envelope.workspacePanelWidth = clampPanelWidth("workspace", panelSizes.workspacePanelWidth);
|
||||
storage.setItem(PANEL_SIZE_STORAGE_KEY, JSON.stringify(envelope));
|
||||
} catch {
|
||||
// Ignore localStorage quota/privacy errors; the resized layout still applies in memory for this tab.
|
||||
}
|
||||
}
|
||||
|
||||
function parseStoredPanelSizes(value: unknown): PanelSizePreferences {
|
||||
if (!isRecord(value) || value["version"] !== 1) return {};
|
||||
const panelSizes: PanelSizePreferences = {};
|
||||
const navigationWidth = parseStoredPanelWidth(value["navigationPanelWidth"]);
|
||||
const workspaceWidth = parseStoredPanelWidth(value["workspacePanelWidth"]);
|
||||
if (navigationWidth !== undefined) panelSizes.navigationPanelWidth = clampPanelWidth("navigation", navigationWidth);
|
||||
if (workspaceWidth !== undefined) panelSizes.workspacePanelWidth = clampPanelWidth("workspace", workspaceWidth);
|
||||
return panelSizes;
|
||||
}
|
||||
|
||||
function parseStoredPanelWidth(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function panelSizesWithWidth(panelSizes: PanelSizePreferences, side: ResizablePanelSide, width: number): PanelSizePreferences {
|
||||
if (side === "navigation") return { ...panelSizes, navigationPanelWidth: width };
|
||||
return { ...panelSizes, workspacePanelWidth: width };
|
||||
}
|
||||
|
||||
function panelSizesWithoutSide(panelSizes: PanelSizePreferences, side: ResizablePanelSide): PanelSizePreferences {
|
||||
if (side === "navigation") {
|
||||
return panelSizes.workspacePanelWidth === undefined ? {} : { workspacePanelWidth: panelSizes.workspacePanelWidth };
|
||||
}
|
||||
return panelSizes.navigationPanelWidth === undefined ? {} : { navigationPanelWidth: panelSizes.navigationPanelWidth };
|
||||
}
|
||||
|
||||
function keyboardResizeDelta(side: ResizablePanelSide, key: string, step: number): number | undefined {
|
||||
if (side === "navigation") {
|
||||
if (key === "ArrowRight") return step;
|
||||
if (key === "ArrowLeft") return -step;
|
||||
return undefined;
|
||||
}
|
||||
if (key === "ArrowLeft") return step;
|
||||
if (key === "ArrowRight") return -step;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatPanelWidth(width: number): string {
|
||||
return `${String(Math.round(width))}px`;
|
||||
}
|
||||
|
||||
function browserPanelSizeStorage(): PanelSizeStorage | undefined {
|
||||
try {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { queryNamespace, readNamespacedString, setNamespacedQueryKey } from "../
|
||||
import { AppShellController } from "../appShell/appShellController";
|
||||
import { NavigationSectionsController, type NavigationSection } from "../appShell/navigationState";
|
||||
import { PanelCollapseController, mainViewClass } from "../appShell/panelCollapseController";
|
||||
import { PanelResizeController, type PanelResizeConstraints, type ResizablePanelSide } from "../appShell/panelResizeController";
|
||||
import { readRoute, writeRoute, type AppRoute } from "../route";
|
||||
import { readSettingsSection, writeSettingsSection, type SettingsSection } from "../settingsRoute";
|
||||
import { applyActiveShortcutPreferences } from "../shortcutPreferences";
|
||||
@@ -69,6 +70,9 @@ const THEME_OPTION_PREFIX = "theme:";
|
||||
const FILES_ROUTE_NAMESPACE = queryNamespace("core:workspace.files");
|
||||
const GIT_ROUTE_NAMESPACE = queryNamespace("core:workspace.git");
|
||||
const TERMINAL_ROUTE_NAMESPACE = queryNamespace("core:workspace.terminal");
|
||||
const MIN_RESIZABLE_CHAT_WIDTH_PX = 320;
|
||||
const PANEL_EDGE_COLUMNS_WIDTH_PX = 2;
|
||||
const DESKTOP_SIDE_BY_SIDE_MEDIA_QUERY = "(min-width: 1181px)";
|
||||
|
||||
@customElement("pi-web-app")
|
||||
export class PiWebApp extends LitElement {
|
||||
@@ -76,6 +80,8 @@ export class PiWebApp extends LitElement {
|
||||
@query("chat-view") private chatView?: ChatView;
|
||||
@query("prompt-editor") private promptEditor?: PromptEditor;
|
||||
@query("app-navigation-panel") private navigationPanel?: AppNavigationPanel;
|
||||
@query("#navigation-panel") private navigationPanelFrame?: HTMLElement;
|
||||
@query("#workspace-panel") private workspacePanelFrame?: HTMLElement;
|
||||
|
||||
private readonly sessions = new SessionController(
|
||||
() => this.state,
|
||||
@@ -128,6 +134,7 @@ export class PiWebApp extends LitElement {
|
||||
private readonly terminalSelection = new SessionStorageTerminalSelectionMemory();
|
||||
private readonly appShell = new AppShellController(this);
|
||||
private readonly panelCollapse = new PanelCollapseController(this);
|
||||
private readonly panelResize = new PanelResizeController(this);
|
||||
private readonly navigationSections = new NavigationSectionsController(
|
||||
this,
|
||||
() => this.state,
|
||||
@@ -742,31 +749,104 @@ export class PiWebApp extends LitElement {
|
||||
}
|
||||
|
||||
private renderNavigationPanelEdgeControl() {
|
||||
const constraints = this.resizablePanelConstraints("navigation");
|
||||
return html`
|
||||
<app-panel-edge-control
|
||||
side="navigation"
|
||||
controls="navigation-panel"
|
||||
resizeLabel="Resize navigation panel"
|
||||
expandLabel="Expand navigation panel"
|
||||
collapseLabel="Collapse navigation panel"
|
||||
.collapsed=${this.panelCollapse.navigationPanelCollapsed}
|
||||
.resizable=${!this.appShell.isMobileNavigationLayout}
|
||||
.panelWidth=${this.panelResize.panelWidth("navigation")}
|
||||
.minWidth=${constraints.minWidth}
|
||||
.maxWidth=${constraints.maxWidth}
|
||||
.onToggle=${() => { this.panelCollapse.toggleNavigationPanel(); }}
|
||||
.onResizeStart=${() => this.startPanelResize("navigation")}
|
||||
.onResize=${(width: number) => { this.panelResize.resizePanel("navigation", width, { persist: false }); }}
|
||||
.onResizeEnd=${() => { this.panelResize.persistPanelSizes(); }}
|
||||
.onReset=${() => { this.resetResizablePanel("navigation"); }}
|
||||
></app-panel-edge-control>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderWorkspacePanelEdgeControl() {
|
||||
const constraints = this.resizablePanelConstraints("workspace");
|
||||
return html`
|
||||
<app-panel-edge-control
|
||||
side="workspace"
|
||||
controls="workspace-panel"
|
||||
resizeLabel="Resize workspace panel"
|
||||
expandLabel="Expand workspace panel"
|
||||
collapseLabel="Collapse workspace panel"
|
||||
.collapsed=${this.panelCollapse.workspacePanelCollapsed}
|
||||
.resizable=${!this.appShell.isMobileNavigationLayout}
|
||||
.panelWidth=${this.panelResize.panelWidth("workspace")}
|
||||
.minWidth=${constraints.minWidth}
|
||||
.maxWidth=${constraints.maxWidth}
|
||||
.onToggle=${() => { this.panelCollapse.toggleWorkspacePanel(); }}
|
||||
.onResizeStart=${() => this.startPanelResize("workspace")}
|
||||
.onResize=${(width: number) => { this.panelResize.resizePanel("workspace", width, { persist: false }); }}
|
||||
.onResizeEnd=${() => { this.panelResize.persistPanelSizes(); }}
|
||||
.onReset=${() => { this.resetResizablePanel("workspace"); }}
|
||||
></app-panel-edge-control>
|
||||
`;
|
||||
}
|
||||
|
||||
private startPanelResize(side: ResizablePanelSide): number {
|
||||
if (side === "navigation") this.panelCollapse.expandNavigationPanel();
|
||||
else this.panelCollapse.expandWorkspacePanel();
|
||||
return this.measuredPanelWidth(side) ?? this.panelResize.panelWidth(side);
|
||||
}
|
||||
|
||||
private resizablePanelConstraints(side: ResizablePanelSide): PanelResizeConstraints {
|
||||
const constraints = this.panelResize.constraints(side);
|
||||
return {
|
||||
...constraints,
|
||||
maxWidth: this.resizablePanelMaxWidth(side, constraints),
|
||||
};
|
||||
}
|
||||
|
||||
private resizablePanelMaxWidth(side: ResizablePanelSide, constraints: PanelResizeConstraints): number {
|
||||
const shellWidth = this.getBoundingClientRect().width || (typeof window === "undefined" ? 0 : window.innerWidth);
|
||||
if (shellWidth <= 0) return constraints.maxWidth;
|
||||
|
||||
const otherPanelWidth = this.oppositeResizablePanelWidth(side);
|
||||
const maxWidth = Math.floor(shellWidth - otherPanelWidth - PANEL_EDGE_COLUMNS_WIDTH_PX - MIN_RESIZABLE_CHAT_WIDTH_PX);
|
||||
return Math.max(constraints.minWidth, Math.min(constraints.maxWidth, maxWidth));
|
||||
}
|
||||
|
||||
private oppositeResizablePanelWidth(side: ResizablePanelSide): number {
|
||||
const otherSide: ResizablePanelSide = side === "navigation" ? "workspace" : "navigation";
|
||||
if (this.isResizablePanelCollapsedOrStacked(otherSide)) return 0;
|
||||
return this.measuredPanelWidth(otherSide) ?? this.panelResize.panelWidth(otherSide);
|
||||
}
|
||||
|
||||
private isResizablePanelCollapsedOrStacked(side: ResizablePanelSide): boolean {
|
||||
if (side === "navigation") return this.panelCollapse.navigationPanelCollapsed;
|
||||
return this.panelCollapse.workspacePanelCollapsed || !this.isDesktopSideBySideLayout();
|
||||
}
|
||||
|
||||
private isDesktopSideBySideLayout(): boolean {
|
||||
if (typeof window === "undefined" || !("matchMedia" in window)) return true;
|
||||
return window.matchMedia(DESKTOP_SIDE_BY_SIDE_MEDIA_QUERY).matches;
|
||||
}
|
||||
|
||||
private measuredPanelWidth(side: ResizablePanelSide): number | undefined {
|
||||
const element = side === "navigation" ? this.navigationPanelFrame : this.workspacePanelFrame;
|
||||
const width = element?.getBoundingClientRect().width;
|
||||
return width === undefined || width <= 0 ? undefined : width;
|
||||
}
|
||||
|
||||
private resetResizablePanel(side: ResizablePanelSide): void {
|
||||
this.panelResize.resetPanel(side);
|
||||
}
|
||||
|
||||
private resetResizablePanels(): void {
|
||||
this.panelResize.resetPanels();
|
||||
}
|
||||
|
||||
private renderNavigationPanel() {
|
||||
return html`
|
||||
<app-navigation-panel
|
||||
@@ -999,7 +1079,33 @@ export class PiWebApp extends LitElement {
|
||||
}
|
||||
|
||||
private getDefaultActions(): AppAction[] {
|
||||
return [...this.plugins.getActions(this.createPluginRuntimeContext()), ...this.navigationFocusActions()];
|
||||
return [...this.plugins.getActions(this.createPluginRuntimeContext()), ...this.navigationFocusActions(), ...this.panelLayoutActions()];
|
||||
}
|
||||
|
||||
private panelLayoutActions(): AppAction[] {
|
||||
return [
|
||||
{
|
||||
id: "app.layout.reset-navigation-panel-size",
|
||||
title: "Reset Navigation Panel Size",
|
||||
description: "Restore the navigation panel to its default width",
|
||||
group: "View",
|
||||
run: () => { this.resetResizablePanel("navigation"); },
|
||||
},
|
||||
{
|
||||
id: "app.layout.reset-workspace-panel-size",
|
||||
title: "Reset Workspace Panel Size",
|
||||
description: "Restore the workspace panel to its default width",
|
||||
group: "View",
|
||||
run: () => { this.resetResizablePanel("workspace"); },
|
||||
},
|
||||
{
|
||||
id: "app.layout.reset-panel-sizes",
|
||||
title: "Reset Panel Sizes",
|
||||
description: "Restore all side panels to their default widths",
|
||||
group: "View",
|
||||
run: () => { this.resetResizablePanels(); },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private navigationFocusActions(): AppAction[] {
|
||||
@@ -1438,7 +1544,7 @@ export class PiWebApp extends LitElement {
|
||||
override render() {
|
||||
const state = this.state;
|
||||
return html`
|
||||
<div class=${this.panelCollapse.shellClass(state.mainView)}>
|
||||
<div class=${this.panelCollapse.shellClass(state.mainView)} style=${this.panelResize.shellStyle({ navigation: this.resizablePanelConstraints("navigation"), workspace: this.resizablePanelConstraints("workspace") })}>
|
||||
<aside id="navigation-panel">${this.appShell.isMobileNavigationLayout ? null : this.renderNavigationPanel()}</aside>
|
||||
${this.renderNavigationPanelEdgeControl()}
|
||||
<main class=${mainViewClass(state.mainView)}>
|
||||
|
||||
@@ -1,20 +1,51 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { clampPanelWidth, panelResizeConstraints, panelWidthFromDrag, panelWidthFromKeyboard, type PanelResizeConstraints, type ResizablePanelSide } from "../../appShell/panelResizeController";
|
||||
|
||||
export type PanelEdgeSide = "navigation" | "workspace";
|
||||
export type PanelEdgeSide = ResizablePanelSide;
|
||||
|
||||
interface ActivePanelResize {
|
||||
pointerId: number;
|
||||
startClientX: number;
|
||||
startWidth: number;
|
||||
handle: HTMLElement;
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
const RESIZE_KEYS = new Set(["ArrowLeft", "ArrowRight", "Home", "End"]);
|
||||
const DOUBLE_TAP_RESET_MS = 420;
|
||||
const TAP_MOVE_TOLERANCE_PX = 4;
|
||||
|
||||
@customElement("app-panel-edge-control")
|
||||
export class AppPanelEdgeControl extends LitElement {
|
||||
@property({ reflect: true }) side: PanelEdgeSide = "navigation";
|
||||
@property({ type: Boolean, reflect: true }) collapsed = false;
|
||||
@property({ type: Boolean }) resizable = false;
|
||||
@property({ type: Number }) panelWidth?: number;
|
||||
@property({ type: Number }) minWidth?: number;
|
||||
@property({ type: Number }) maxWidth?: number;
|
||||
@property() controls = "";
|
||||
@property() resizeLabel = "Resize panel";
|
||||
@property() expandLabel = "Expand panel";
|
||||
@property() collapseLabel = "Collapse panel";
|
||||
@property({ attribute: false }) onToggle?: () => void;
|
||||
@property({ attribute: false }) onResizeStart?: () => number | undefined;
|
||||
@property({ attribute: false }) onResize?: (width: number) => void;
|
||||
@property({ attribute: false }) onResizeEnd?: () => void;
|
||||
@property({ attribute: false }) onReset?: () => void;
|
||||
|
||||
private activeResize: ActivePanelResize | undefined;
|
||||
private lastTapAt = 0;
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
this.finishActiveResize();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
override render() {
|
||||
const label = this.collapsed ? this.expandLabel : this.collapseLabel;
|
||||
return html`
|
||||
${this.renderResizeHandle()}
|
||||
<button
|
||||
type="button"
|
||||
class="edge-button"
|
||||
@@ -27,6 +58,35 @@ export class AppPanelEdgeControl extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderResizeHandle() {
|
||||
if (!this.resizable) return nothing;
|
||||
const constraints = this.resizeConstraints();
|
||||
return html`
|
||||
<div
|
||||
class="resize-handle"
|
||||
role="separator"
|
||||
tabindex="0"
|
||||
aria-label=${this.resizeLabel}
|
||||
title=${`${this.resizeLabel}. Double-click or double-tap to reset.`}
|
||||
aria-controls=${this.controls}
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin=${String(constraints.minWidth)}
|
||||
aria-valuemax=${String(constraints.maxWidth)}
|
||||
aria-valuenow=${this.resizeAriaValueNow()}
|
||||
@pointerdown=${(event: PointerEvent) => { this.onResizePointerDown(event); }}
|
||||
@pointermove=${(event: PointerEvent) => { this.onResizePointerMove(event); }}
|
||||
@pointerup=${(event: PointerEvent) => { this.onResizePointerUp(event); }}
|
||||
@pointercancel=${(event: PointerEvent) => { this.onResizePointerCancel(event); }}
|
||||
@dblclick=${(event: MouseEvent) => { this.onResizeDoubleClick(event); }}
|
||||
@keydown=${(event: KeyboardEvent) => { this.onResizeKeyDown(event); }}
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
|
||||
private resizeAriaValueNow() {
|
||||
return this.panelWidth === undefined ? nothing : String(Math.round(this.panelWidth));
|
||||
}
|
||||
|
||||
private renderIcon() {
|
||||
const direction = this.iconDirection();
|
||||
const path = direction === "left" ? "M15 18l-6-6 6-6" : "M9 18l6-6-6-6";
|
||||
@@ -38,10 +98,115 @@ export class AppPanelEdgeControl extends LitElement {
|
||||
return this.collapsed ? "left" : "right";
|
||||
}
|
||||
|
||||
private onResizePointerDown(event: PointerEvent): void {
|
||||
if (!this.resizable || event.button !== 0) return;
|
||||
const handle = event.currentTarget;
|
||||
if (!(handle instanceof HTMLElement)) return;
|
||||
const startWidth = this.resizeStartWidth();
|
||||
if (startWidth === undefined) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handle.setPointerCapture(event.pointerId);
|
||||
this.activeResize = { pointerId: event.pointerId, startClientX: event.clientX, startWidth, handle, moved: false };
|
||||
this.toggleAttribute("resizing", true);
|
||||
}
|
||||
|
||||
private onResizePointerMove(event: PointerEvent): void {
|
||||
const activeResize = this.activeResize;
|
||||
if (activeResize?.pointerId !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
if (Math.abs(event.clientX - activeResize.startClientX) <= TAP_MOVE_TOLERANCE_PX) return;
|
||||
activeResize.moved = true;
|
||||
this.commitPanelWidth(panelWidthFromDrag(this.side, activeResize.startWidth, activeResize.startClientX, event.clientX, this.resizeConstraints()));
|
||||
}
|
||||
|
||||
private onResizePointerUp(event: PointerEvent): void {
|
||||
const activeResize = this.activeResize;
|
||||
if (activeResize?.pointerId !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
this.finishActiveResize();
|
||||
if (!activeResize.moved) this.registerTapForReset();
|
||||
}
|
||||
|
||||
private onResizePointerCancel(event: PointerEvent): void {
|
||||
if (this.activeResize?.pointerId !== event.pointerId) return;
|
||||
this.finishActiveResize();
|
||||
}
|
||||
|
||||
private onResizeDoubleClick(event: MouseEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.resetPanelSize();
|
||||
}
|
||||
|
||||
private onResizeKeyDown(event: KeyboardEvent): void {
|
||||
if (!this.resizable || !RESIZE_KEYS.has(event.key)) return;
|
||||
const currentWidth = this.resizeStartWidth();
|
||||
if (currentWidth === undefined) return;
|
||||
const nextWidth = panelWidthFromKeyboard(this.side, currentWidth, event.key, { largeStep: event.shiftKey, constraints: this.resizeConstraints() });
|
||||
if (nextWidth === undefined) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.commitPanelWidth(nextWidth);
|
||||
this.onResizeEnd?.();
|
||||
}
|
||||
|
||||
private registerTapForReset(): void {
|
||||
const now = Date.now();
|
||||
if (now - this.lastTapAt <= DOUBLE_TAP_RESET_MS) {
|
||||
this.lastTapAt = 0;
|
||||
this.resetPanelSize();
|
||||
return;
|
||||
}
|
||||
this.lastTapAt = now;
|
||||
}
|
||||
|
||||
private resetPanelSize(): void {
|
||||
this.finishActiveResize();
|
||||
this.onReset?.();
|
||||
}
|
||||
|
||||
private resizeStartWidth(): number | undefined {
|
||||
const width = this.onResizeStart?.() ?? this.panelWidth;
|
||||
if (width === undefined) return undefined;
|
||||
return clampPanelWidth(this.side, width, this.resizeConstraints());
|
||||
}
|
||||
|
||||
private commitPanelWidth(width: number): void {
|
||||
this.onResize?.(clampPanelWidth(this.side, width, this.resizeConstraints()));
|
||||
}
|
||||
|
||||
private finishActiveResize(): void {
|
||||
const activeResize = this.activeResize;
|
||||
if (activeResize === undefined) return;
|
||||
try {
|
||||
activeResize.handle.releasePointerCapture(activeResize.pointerId);
|
||||
} catch {
|
||||
// Pointer capture may already be gone if the browser canceled the drag.
|
||||
}
|
||||
this.activeResize = undefined;
|
||||
this.toggleAttribute("resizing", false);
|
||||
this.onResizeEnd?.();
|
||||
}
|
||||
|
||||
private resizeConstraints(): PanelResizeConstraints {
|
||||
const defaults = panelResizeConstraints(this.side);
|
||||
return {
|
||||
...defaults,
|
||||
minWidth: this.minWidth ?? defaults.minWidth,
|
||||
maxWidth: this.maxWidth ?? defaults.maxWidth,
|
||||
};
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
:host { min-width: 0; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: visible; background: var(--pi-border-muted); z-index: 2; }
|
||||
:host { position: relative; min-width: 0; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: visible; background: var(--pi-border-muted); z-index: 2; }
|
||||
:host([side="navigation"]) { grid-column: 2; }
|
||||
:host([side="workspace"]) { grid-column: 4; }
|
||||
.resize-handle { position: absolute; inset: 0 -6px; z-index: 0; cursor: col-resize; touch-action: none; outline: none; }
|
||||
.resize-handle::after { content: ""; position: absolute; top: 0; bottom: 0; left: 50%; width: 1px; transform: translateX(-50%); background: transparent; transition: width .12s ease, background .12s ease, opacity .12s ease; }
|
||||
.resize-handle:hover::after, .resize-handle:focus-visible::after, :host([resizing]) .resize-handle::after { width: 3px; background: var(--pi-accent); opacity: .72; }
|
||||
.edge-button { position: relative; z-index: 1; box-sizing: border-box; display: grid; place-items: center; width: 18px; height: 48px; padding: 0; border: 1px solid var(--pi-border-muted); border-radius: 999px; background: var(--pi-bg); color: var(--pi-muted); opacity: .75; cursor: pointer; }
|
||||
.edge-button:hover, .edge-button:focus-visible { color: var(--pi-text); background: var(--pi-surface-hover); opacity: 1; }
|
||||
:host([side="navigation"][collapsed]) .edge-button { transform: translateX(calc(50% - .5px)); }
|
||||
|
||||
@@ -56,7 +56,7 @@ export const appStyles = css`
|
||||
@media (display-mode: standalone), (display-mode: fullscreen), (display-mode: minimal-ui) {
|
||||
:host { --pi-app-safe-area-bottom: env(safe-area-inset-bottom); }
|
||||
}
|
||||
.shell { --navigation-panel-width: 340px; --workspace-panel-width: minmax(360px, 42vw); display: grid; grid-template-columns: var(--navigation-panel-width) 1px minmax(420px, 1fr) 1px var(--workspace-panel-width); height: 100%; min-height: 0; }
|
||||
.shell { --navigation-panel-size: 340px; --workspace-panel-size: minmax(360px, 42vw); --navigation-panel-width: var(--navigation-panel-size); --workspace-panel-width: var(--workspace-panel-size); display: grid; grid-template-columns: var(--navigation-panel-width) 1px minmax(320px, 1fr) 1px var(--workspace-panel-width); height: 100%; min-height: 0; }
|
||||
aside { grid-column: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
||||
aside app-navigation-panel { flex: 1 1 auto; min-height: 0; }
|
||||
header { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-bottom: 1px solid var(--pi-border); }
|
||||
|
||||
Reference in New Issue
Block a user