From b6166842fe2db1dc7995da7ea81044443725b1a0 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 8 Jun 2026 15:41:52 +0200 Subject: [PATCH] feat: add resizable side panels --- .changeset/resizable-side-panels.md | 5 + .../src/appShell/panelCollapseController.ts | 6 + .../appShell/panelResizeController.test.ts | 115 +++++++++ .../src/appShell/panelResizeController.ts | 221 ++++++++++++++++++ src/client/src/components/PiWebApp.ts | 110 ++++++++- .../appShell/AppPanelEdgeControl.ts | 171 +++++++++++++- src/client/src/components/shared.ts | 2 +- 7 files changed, 624 insertions(+), 6 deletions(-) create mode 100644 .changeset/resizable-side-panels.md create mode 100644 src/client/src/appShell/panelResizeController.test.ts create mode 100644 src/client/src/appShell/panelResizeController.ts diff --git a/.changeset/resizable-side-panels.md b/.changeset/resizable-side-panels.md new file mode 100644 index 0000000..8d8a604 --- /dev/null +++ b/.changeset/resizable-side-panels.md @@ -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. diff --git a/src/client/src/appShell/panelCollapseController.ts b/src/client/src/appShell/panelCollapseController.ts index 668f97b..7356420 100644 --- a/src/client/src/appShell/panelCollapseController.ts +++ b/src/client/src/appShell/panelCollapseController.ts @@ -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", diff --git a/src/client/src/appShell/panelResizeController.test.ts b/src/client/src/appShell/panelResizeController.test.ts new file mode 100644 index 0000000..4730ab8 --- /dev/null +++ b/src/client/src/appShell/panelResizeController.test.ts @@ -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(); + + constructor(seed: Record = {}) { + 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"); + } +} diff --git a/src/client/src/appShell/panelResizeController.ts b/src/client/src/appShell/panelResizeController.ts new file mode 100644 index 0000000..ff21d92 --- /dev/null +++ b/src/client/src/appShell/panelResizeController.ts @@ -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; +export type PanelResizeConstraintsBySide = Partial>; + +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; + +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 { + return typeof value === "object" && value !== null; +} diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index e842917..6d7118d 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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` { 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"); }} > `; } private renderWorkspacePanelEdgeControl() { + const constraints = this.resizablePanelConstraints("workspace"); return html` { 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"); }} > `; } + 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` { 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` -
+
${this.renderNavigationPanelEdgeControl()}
diff --git a/src/client/src/components/appShell/AppPanelEdgeControl.ts b/src/client/src/components/appShell/AppPanelEdgeControl.ts index 2f9594f..b030246 100644 --- a/src/client/src/components/appShell/AppPanelEdgeControl.ts +++ b/src/client/src/components/appShell/AppPanelEdgeControl.ts @@ -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()}
+ `; + } + + 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)); } diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index da0005b..3a98ead 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -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); }