per-panel content zoom for every panel
soulseek had a zoom control wired into its own panel header, persisted under its own screens/ key. move it into the framework so every panel has it, and drop the soulseek-specific copy (its header was then identical to the default, so that goes too, along with the orphan db row). the factor lives on the LayoutPanel node rather than in its own useDashboardState key: the layout is already persisted per panel, and a separate key would seed a server row per panel on mount. absent at 1, so an untouched panel adds nothing to the stored layout. uses css zoom, not transform: scale. a transform repaints at a different size without re-laying out, so the panel keeps its 100% geometry and anything anchored or percentage-sized lands wrong — chat's composer made that obvious. zoom scales used lengths instead: children reflow, h-full still resolves to the panel, and rem-based tailwind text scales with it. @container moves onto the zoomed element so container queries respond to the effective width, the way they would in a genuinely narrower panel. zoomable: false opts out the terminals (xterm measures its own cell grid) and remote desktop (novnc does its own scaling and pointer mapping). fixes chat's virtualiser under zoom: it measured bubbles with getBoundingClientRect (rendered px) but positions them with translateY (layout px), so at 70% every bubble was placed too early and they stacked. new helpers/measure-zoomed divides the element's currentCSSZoom back out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
// Panel content is scaled with CSS `zoom` (see PanelSlot), and that splits measurement into two units.
|
||||
// Layout — percentages, `transform: translateY(Npx)`, anything you write into a style — is in *layout*
|
||||
// px, which zoom leaves alone. `getBoundingClientRect()` is in *rendered* px, already multiplied by the
|
||||
// zoom. Feed one into the other and every offset is wrong by the zoom factor: at 0.7 a virtualiser
|
||||
// measures each row as 70% of the height it will actually occupy, places the next row that much too
|
||||
// early, and the list collapses into itself.
|
||||
//
|
||||
// `offsetHeight` is in layout px and would also fix it, but it rounds to whole pixels, which a
|
||||
// virtualiser accumulates into visible drift. So keep the subpixel rect and divide the zoom back out.
|
||||
// `currentCSSZoom` is the element's effective zoom including every zoomed ancestor; it is absent on
|
||||
// older engines, where zoom is not applied either, so 1 is the right fallback.
|
||||
|
||||
const effectiveZoom = (element: Element) => (element as Element & { currentCSSZoom?: number }).currentCSSZoom || 1;
|
||||
|
||||
/** An element's height in layout px — the unit its own positioning styles are written in. */
|
||||
export const measureHeight = (element: Element) => element.getBoundingClientRect().height / effectiveZoom(element);
|
||||
|
||||
/** An element's width in layout px. */
|
||||
export const measureWidth = (element: Element) => element.getBoundingClientRect().width / effectiveZoom(element);
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
import { ArrowDown, Loader2 } from 'lucide-react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { measureHeight } from 'helpers/measure-zoomed';
|
||||
import type { UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat';
|
||||
import type { ChatMessage } from '../types';
|
||||
import { MessageBubble, StreamingBubble } from './MessageBubble';
|
||||
@@ -40,7 +41,9 @@ export const MessageList = ({ manager }: MessageListProps) => {
|
||||
getScrollElement: () => scrollViewportRef.current,
|
||||
estimateSize: () => 150,
|
||||
overscan: OVERSCAN,
|
||||
measureElement: (element) => element.getBoundingClientRect().height,
|
||||
// Measured height must be in the same unit as the translateY below — layout px, which is what the
|
||||
// panel zoom leaves alone. A raw getBoundingClientRect is in rendered px and stacks the bubbles.
|
||||
measureElement: measureHeight,
|
||||
});
|
||||
|
||||
// Keep the viewport anchored when older messages are prepended: the message that used to be first
|
||||
|
||||
@@ -13,5 +13,8 @@ export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
component: DesktopWrapper,
|
||||
header: DesktopHeader,
|
||||
availableOnPanel: false,
|
||||
// noVNC scales the remote framebuffer itself and maps pointer coordinates against that; a wrapping
|
||||
// CSS transform would leave every click landing somewhere other than where it was aimed.
|
||||
zoomable: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Construction } from 'lucide-react';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { SOULSEEK_SECTION_CHANNEL, SOULSEEK_SECTIONS, soulseekZoomKey, type SoulseekSectionId } from './shared';
|
||||
import { SOULSEEK_SECTION_CHANNEL, SOULSEEK_SECTIONS, type SoulseekSectionId } from './shared';
|
||||
import { SearchView } from './SearchView';
|
||||
import { SoulseekTransfers } from './SoulseekTransfers';
|
||||
import { SoulseekUploads } from './SoulseekUploads';
|
||||
@@ -13,10 +12,8 @@ import { SoulseekSystem } from './SoulseekSystem';
|
||||
import { SoulseekConnection } from './SoulseekConnection';
|
||||
import { useServiceConnection } from '../../hooks/useServiceConnection';
|
||||
|
||||
// Right panel of the /soulseek workspace — renders the UI for the section the nav selected. The panel
|
||||
// header's +/- controls set a per-panel zoom factor, persisted via useDashboardState (same store as the
|
||||
// layout config); we read it here and apply it as a CSS scale, with a compensating width/height so the
|
||||
// scaled content still fills the panel and inner scroll keeps working.
|
||||
// Right panel of the /soulseek workspace — renders the UI for the section the nav selected. Content
|
||||
// zoom used to live here; it is now a framework-level per-panel control in PanelSlot.
|
||||
|
||||
const Placeholder = ({ id }: { id: SoulseekSectionId }) => {
|
||||
const label = SOULSEEK_SECTIONS.find((s) => s.id === id)?.label ?? id;
|
||||
@@ -58,27 +55,13 @@ const sectionView = (section: SoulseekSectionId) => {
|
||||
}
|
||||
};
|
||||
|
||||
type SoulseekViewProps = { panelId: string };
|
||||
|
||||
export const SoulseekView = ({ panelId }: SoulseekViewProps) => {
|
||||
export const SoulseekView = () => {
|
||||
const [section] = usePanelChannel<SoulseekSectionId>(SOULSEEK_SECTION_CHANNEL, 'dashboard');
|
||||
const { value: zoom } = useDashboardState<number>(soulseekZoomKey(panelId), 1);
|
||||
const { data: connection, isLoading } = useServiceConnection('slskd');
|
||||
const z = zoom ?? 1;
|
||||
|
||||
// Nothing connected yet: the setup form takes over every section, because none of them can do anything
|
||||
// without a daemon. Zoom is skipped for it too — it is a form, not a dense slskd panel.
|
||||
// without a daemon.
|
||||
if (!isLoading && !connection?.configured) return <SoulseekConnection />;
|
||||
|
||||
if (z === 1) return <div className="h-full w-full">{sectionView(section)}</div>;
|
||||
|
||||
// transform: scale doesn't reflow, so size the box to 1/z and let the scale bring it back to 100%.
|
||||
const inv = `${100 / z}%`;
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<div style={{ width: inv, height: inv, transform: `scale(${z})`, transformOrigin: 'top left' }}>
|
||||
{sectionView(section)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <div className="h-full w-full">{sectionView(section)}</div>;
|
||||
};
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { LayoutGrid, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { soulseekZoomKey, SOULSEEK_ZOOM_MIN, SOULSEEK_ZOOM_MAX, SOULSEEK_ZOOM_STEP } from './shared';
|
||||
|
||||
// Panel header for the right (soulseek-view) panel — icon + title plus +/- zoom controls. The zoom
|
||||
// factor is persisted per-panel via useDashboardState (same store as the layout config) so it survives
|
||||
// reloads; SoulseekView reads the same key to scale its content. This is the Workspace panel-header API:
|
||||
// a `header` component (registered alongside `component`) rendered by PanelSlot with { panelId }.
|
||||
|
||||
const clamp = (z: number) => Math.min(SOULSEEK_ZOOM_MAX, Math.max(SOULSEEK_ZOOM_MIN, Math.round(z * 10) / 10));
|
||||
|
||||
type SoulseekViewHeaderProps = { panelId: string };
|
||||
|
||||
export const SoulseekViewHeader = ({ panelId }: SoulseekViewHeaderProps) => {
|
||||
const { value: zoom, setValue: setZoom } = useDashboardState<number>(soulseekZoomKey(panelId), 1);
|
||||
const level = zoom ?? 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LayoutGrid className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate text-xs font-medium">Soulseek</span>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoom((z) => clamp((z ?? 1) - SOULSEEK_ZOOM_STEP))}
|
||||
disabled={level <= SOULSEEK_ZOOM_MIN}
|
||||
title="Zoom out"
|
||||
className="cursor-pointer rounded p-1 transition-colors hover:bg-black/10 disabled:cursor-default disabled:opacity-30"
|
||||
>
|
||||
<ZoomOut className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoom(1)}
|
||||
title="Reset zoom"
|
||||
className="w-9 cursor-pointer rounded px-1 text-center text-[11px] tabular-nums text-black/60 transition-colors hover:bg-black/10 hover:text-black"
|
||||
>
|
||||
{Math.round(level * 100)}%
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoom((z) => clamp((z ?? 1) + SOULSEEK_ZOOM_STEP))}
|
||||
disabled={level >= SOULSEEK_ZOOM_MAX}
|
||||
title="Zoom in"
|
||||
className="cursor-pointer rounded p-1 transition-colors hover:bg-black/10 disabled:cursor-default disabled:opacity-30"
|
||||
>
|
||||
<ZoomIn className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -2,18 +2,10 @@ import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { PanelLeft, LayoutGrid } from 'lucide-react';
|
||||
import { SoulseekNav } from './SoulseekNav';
|
||||
import { SoulseekView } from './SoulseekView';
|
||||
import { SoulseekViewHeader } from './SoulseekViewHeader';
|
||||
|
||||
export { SoulseekNav, SoulseekView };
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'soulseek-nav', name: 'Soulseek', icon: PanelLeft, component: SoulseekNav, availableOnPanel: false },
|
||||
{
|
||||
key: 'soulseek-view',
|
||||
name: 'Soulseek',
|
||||
icon: LayoutGrid,
|
||||
component: SoulseekView,
|
||||
header: SoulseekViewHeader,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
{ key: 'soulseek-view', name: 'Soulseek', icon: LayoutGrid, component: SoulseekView, availableOnPanel: false },
|
||||
];
|
||||
|
||||
@@ -5,15 +5,6 @@
|
||||
// panel refetches immediately instead of waiting for its next poll tick.
|
||||
export const SLSKD_REFRESH_CHANNEL = 'soulseek:refresh';
|
||||
|
||||
// Per-panel content zoom for the soulseek-view (right) panel. The panel header (+/- buttons) writes the
|
||||
// factor here; SoulseekView reads it and scales its content. Keyed by panelId so each panel instance
|
||||
// zooms independently. Stored under the `screens/` namespace via useDashboardState — the same persisted
|
||||
// dashboards store that holds the layout config — so the zoom level survives reloads.
|
||||
export const soulseekZoomKey = (panelId: string) => `screens/soulseek-zoom/${panelId}`;
|
||||
export const SOULSEEK_ZOOM_MIN = 0.7;
|
||||
export const SOULSEEK_ZOOM_MAX = 1.6;
|
||||
export const SOULSEEK_ZOOM_STEP = 0.1;
|
||||
|
||||
export type SlskdApplication = {
|
||||
version?: { current?: string; full?: string; latest?: string; isUpdateAvailable?: boolean };
|
||||
server?: { state?: string; address?: string; isConnected?: boolean };
|
||||
|
||||
@@ -34,6 +34,9 @@ const ClaudeCodeWrapper = ({ panelId }: { panelId: string }) => (
|
||||
<CommandTerminalWrapper panelId={panelId} command="claude --dangerously-skip-permissions" statePrefix="claude-code" />
|
||||
);
|
||||
|
||||
// zoomable: false on every real terminal — xterm sizes its own font and reflows to a measured cell grid,
|
||||
// so a wrapping CSS scale both blurs the glyphs and desyncs the cols/rows it reports to the pty. The
|
||||
// per-terminal font-size control in the panel header is the zoom for these.
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'officerdev/terminal',
|
||||
@@ -41,6 +44,7 @@ export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
icon: TerminalSquare,
|
||||
component: TerminalWrapper,
|
||||
header: TerminalHeader,
|
||||
zoomable: false,
|
||||
},
|
||||
{
|
||||
key: 'officerdev/terminal-host',
|
||||
@@ -49,6 +53,7 @@ export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
component: HostTerminalWrapper,
|
||||
header: HostTerminalHeader,
|
||||
availableOnPanel: false,
|
||||
zoomable: false,
|
||||
},
|
||||
{
|
||||
key: 'officerdev/tmux',
|
||||
@@ -56,6 +61,7 @@ export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
icon: Columns2,
|
||||
component: TmuxWrapper,
|
||||
header: TmuxHeader,
|
||||
zoomable: false,
|
||||
},
|
||||
{
|
||||
key: 'officerdev/nvim',
|
||||
@@ -63,6 +69,7 @@ export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
icon: PenLine,
|
||||
component: NvimWrapper,
|
||||
header: NvimHeader,
|
||||
zoomable: false,
|
||||
},
|
||||
{
|
||||
key: 'officerdev/running-shells',
|
||||
@@ -76,5 +83,6 @@ export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
icon: Sparkles,
|
||||
component: ClaudeCodeWrapper,
|
||||
header: ClaudeCodeHeader,
|
||||
zoomable: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { ArrowLeftRight, ChevronLeft, X, Minus } from 'lucide-react';
|
||||
import { ArrowLeftRight, ChevronLeft, X, Minus, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import type { LayoutPanel, AppRegistryMap, PanelComponents, PanelComponentEntry } from './types';
|
||||
import { useWorkspace } from './WorkspaceContext';
|
||||
import { ZOOM_MIN, ZOOM_MAX, ZOOM_STEP } from './layout-utils';
|
||||
import { Card } from '@/components/Card';
|
||||
import { AppPicker } from './AppPicker';
|
||||
import {
|
||||
@@ -30,6 +31,8 @@ type PanelContextMenuProps = {
|
||||
panelId: string;
|
||||
hasApp: boolean;
|
||||
isLastPanel: boolean;
|
||||
// Current content scale, or null when the app opted out of zoom (no zoom entries in the menu).
|
||||
zoom: number | null;
|
||||
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||
onRemove: (panelId: string) => void;
|
||||
onClearApp: () => void;
|
||||
@@ -43,6 +46,7 @@ const PanelContextMenu = ({
|
||||
panelId,
|
||||
hasApp,
|
||||
isLastPanel,
|
||||
zoom,
|
||||
onSplit,
|
||||
onRemove,
|
||||
onClearApp,
|
||||
@@ -73,6 +77,7 @@ const PanelContextMenu = ({
|
||||
) : (
|
||||
<ContextMenuItem onClick={() => setSwapSourceId(panelId)}>Swap with...</ContextMenuItem>
|
||||
)}
|
||||
{zoom !== null && <ZoomMenuItems panelId={panelId} zoom={zoom} />}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
@@ -221,7 +226,73 @@ const MaximizeButton = ({ panelId }: { panelId: string }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const MaximizeContextMenu = ({ panelId, children }: { panelId: string; children: React.ReactNode }) => {
|
||||
// Per-panel content scale. The percentage only appears once the panel is off 100%, so an untouched
|
||||
// header looks exactly as it did before; the +/- buttons reveal on header hover. Clicking the
|
||||
// percentage resets to 100%.
|
||||
const ZoomControls = ({ panelId, zoom }: { panelId: string; zoom: number }) => {
|
||||
const { onSetZoom } = useWorkspace();
|
||||
const atDefault = zoom === 1;
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSetZoom(panelId, zoom - ZOOM_STEP)}
|
||||
disabled={zoom <= ZOOM_MIN}
|
||||
title="Zoom out"
|
||||
className="cursor-pointer rounded p-1 opacity-0 transition-all group-hover/header:opacity-100 hover:bg-black/10 focus-visible:opacity-100 disabled:cursor-default disabled:opacity-0 disabled:group-hover/header:opacity-30"
|
||||
>
|
||||
<ZoomOut className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSetZoom(panelId, 1)}
|
||||
title="Reset zoom"
|
||||
className={`w-9 cursor-pointer rounded px-1 text-center text-[11px] tabular-nums text-black/60 transition-all hover:bg-black/10 hover:text-black ${atDefault ? 'opacity-0 group-hover/header:opacity-100' : ''}`}
|
||||
>
|
||||
{Math.round(zoom * 100)}%
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSetZoom(panelId, zoom + ZOOM_STEP)}
|
||||
disabled={zoom >= ZOOM_MAX}
|
||||
title="Zoom in"
|
||||
className="cursor-pointer rounded p-1 opacity-0 transition-all group-hover/header:opacity-100 hover:bg-black/10 focus-visible:opacity-100 disabled:cursor-default disabled:opacity-0 disabled:group-hover/header:opacity-30"
|
||||
>
|
||||
<ZoomIn className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ZoomMenuItems = ({ panelId, zoom }: { panelId: string; zoom: number }) => {
|
||||
const { onSetZoom } = useWorkspace();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem disabled={zoom >= ZOOM_MAX} onClick={() => onSetZoom(panelId, zoom + ZOOM_STEP)}>
|
||||
Zoom in
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={zoom <= ZOOM_MIN} onClick={() => onSetZoom(panelId, zoom - ZOOM_STEP)}>
|
||||
Zoom out
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={zoom === 1} onClick={() => onSetZoom(panelId, 1)}>
|
||||
Reset zoom
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MaximizeContextMenu = ({
|
||||
panelId,
|
||||
zoom,
|
||||
children,
|
||||
}: {
|
||||
panelId: string;
|
||||
zoom: number | null;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const { maximizedPanelId, setMaximizedPanelId } = useWorkspace();
|
||||
const isMaximized = maximizedPanelId === panelId;
|
||||
|
||||
@@ -232,6 +303,7 @@ const MaximizeContextMenu = ({ panelId, children }: { panelId: string; children:
|
||||
<ContextMenuItem onClick={() => setMaximizedPanelId(isMaximized ? null : panelId)}>
|
||||
{isMaximized ? 'Restore' : 'Maximize'}
|
||||
</ContextMenuItem>
|
||||
{zoom !== null && <ZoomMenuItems panelId={panelId} zoom={zoom} />}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
@@ -282,14 +354,22 @@ export const PanelSlot = ({
|
||||
const ProviderComponent = panelEntry?.provider ?? entry?.provider;
|
||||
const onClose = panelEntry?.onClose;
|
||||
|
||||
// null = this app opted out of zoom, so no control and no menu entries.
|
||||
const zoom = entry?.zoomable === false ? null : (panel.zoom ?? 1);
|
||||
|
||||
const contextMenu = interactive
|
||||
? locked
|
||||
? (content: React.ReactNode) => <MaximizeContextMenu panelId={panel.id}>{content}</MaximizeContextMenu>
|
||||
? (content: React.ReactNode) => (
|
||||
<MaximizeContextMenu panelId={panel.id} zoom={zoom}>
|
||||
{content}
|
||||
</MaximizeContextMenu>
|
||||
)
|
||||
: (content: React.ReactNode) => (
|
||||
<PanelContextMenu
|
||||
panelId={panel.id}
|
||||
hasApp={!!AppComponent}
|
||||
isLastPanel={isLastPanel}
|
||||
zoom={zoom}
|
||||
onSplit={onSplit}
|
||||
onRemove={onRemove}
|
||||
onClearApp={() => onSetApp(panel.id, null)}
|
||||
@@ -375,8 +455,11 @@ export const PanelSlot = ({
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
const zoomControls =
|
||||
interactive && !isMobile && zoom !== null ? <ZoomControls panelId={panel.id} zoom={zoom} /> : null;
|
||||
|
||||
const headerContent = (
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 border-b border-black/10 text-black font-semibold">
|
||||
<div className="group/header shrink-0 flex items-center gap-2 px-3 py-1.5 border-b border-black/10 text-black font-semibold">
|
||||
{mobileBackButton}
|
||||
{ResolvedHeader && <ResolvedHeader panelId={panel.id} />}
|
||||
{!mobileBackButton && onClose && (
|
||||
@@ -384,18 +467,28 @@ export const PanelSlot = ({
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{trafficLights}
|
||||
{(zoomControls || trafficLights) && (
|
||||
// One shrink-to-fit container so TrafficLights' own ml-auto has no free space left to claim —
|
||||
// two competing auto margins would split the header's slack and strand the zoom control mid-row.
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
{zoomControls}
|
||||
{trafficLights}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const headerBar = interactive ? (
|
||||
locked ? (
|
||||
<MaximizeContextMenu panelId={panel.id}>{headerContent}</MaximizeContextMenu>
|
||||
<MaximizeContextMenu panelId={panel.id} zoom={zoom}>
|
||||
{headerContent}
|
||||
</MaximizeContextMenu>
|
||||
) : (
|
||||
<PanelContextMenu
|
||||
panelId={panel.id}
|
||||
hasApp={!!AppComponent}
|
||||
isLastPanel={isLastPanel}
|
||||
zoom={zoom}
|
||||
onSplit={onSplit}
|
||||
onRemove={onRemove}
|
||||
onClearApp={() => onSetApp(panel.id, null)}
|
||||
@@ -407,10 +500,23 @@ export const PanelSlot = ({
|
||||
headerContent
|
||||
);
|
||||
|
||||
// CSS `zoom`, not `transform: scale`. A transform paints the content at a different size without
|
||||
// re-laying it out, so the panel keeps the geometry it had at 100% and anything sized against the
|
||||
// viewport or a percentage lands in the wrong place — visible immediately in Chat, whose composer is
|
||||
// pinned to the bottom of a box the transform no longer agrees with. `zoom` scales the used lengths
|
||||
// instead: children reflow, `h-full` and percentages still resolve to the panel, and rem-based
|
||||
// Tailwind text sizes scale too (a container font-size would not — rem is root-relative). Percentages
|
||||
// resolve across the zoom boundary, so the wrapper still fills the panel exactly at any factor.
|
||||
//
|
||||
// @container sits on the zoomed element on purpose: at 1.4 a 700px panel measures 500 layout px, so
|
||||
// container queries collapse the same way they would in a genuinely narrower panel. That is the point
|
||||
// of the control — bigger content, same layout rules.
|
||||
const body = (
|
||||
<div className="@container flex-1 min-h-0">
|
||||
<div className="flex-1 min-h-0">
|
||||
<Card className="h-full w-full overflow-hidden p-0 rounded-none border-0 shadow-none">
|
||||
<AppComponent panelId={panel.id} />
|
||||
<div className="@container h-full w-full" style={zoom !== null && zoom !== 1 ? { zoom } : undefined}>
|
||||
<AppComponent panelId={panel.id} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ type WorkspaceContextValue = {
|
||||
dragSourceId: string | null;
|
||||
setDragSourceId: (id: string | null) => void;
|
||||
onMove: (sourceId: string, targetId: string, position: DropPosition) => void;
|
||||
onSetZoom: (panelId: string, zoom: number) => void;
|
||||
maximizedPanelId: string | null;
|
||||
setMaximizedPanelId: (id: string | null) => void;
|
||||
transitioningPanelId: string | null;
|
||||
@@ -38,6 +39,7 @@ const WorkspaceContext = createContext<WorkspaceContextValue>({
|
||||
dragSourceId: null,
|
||||
setDragSourceId: noop,
|
||||
onMove: noop,
|
||||
onSetZoom: noop,
|
||||
maximizedPanelId: null,
|
||||
setMaximizedPanelId: noop,
|
||||
transitioningPanelId: null,
|
||||
|
||||
@@ -33,7 +33,7 @@ export const WorkspaceLayout = ({ layout, onLayoutChange, registry: registryProp
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceProvider value={{ dashboardId: dashboardId ?? null, cwd: cwd ?? '~', promptPrefix, swapSourceId: null, setSwapSourceId: noop, onSwap: noop, dragSourceId: null, setDragSourceId: noop, onMove: noop, maximizedPanelId: null, setMaximizedPanelId: noop, transitioningPanelId: null, isMobile: isMobile ?? false, onMobileBack: onMobileBack ?? null }}>
|
||||
<WorkspaceProvider value={{ dashboardId: dashboardId ?? null, cwd: cwd ?? '~', promptPrefix, swapSourceId: null, setSwapSourceId: noop, onSwap: noop, dragSourceId: null, setDragSourceId: noop, onMove: noop, onSetZoom: noop, maximizedPanelId: null, setMaximizedPanelId: noop, transitioningPanelId: null, isMobile: isMobile ?? false, onMobileBack: onMobileBack ?? null }}>
|
||||
<WorkspaceRenderer
|
||||
layout={layout}
|
||||
registry={registry}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useIsMobile } from 'hooks/useIsMobile';
|
||||
import type { LayoutNode, DashboardState, EphemeralPanels, PanelComponents } from './types';
|
||||
import type { DefaultFileSort } from './WorkspaceContext';
|
||||
import type { DropPosition } from './layout-utils';
|
||||
import { splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, countPanels } from './layout-utils';
|
||||
import { splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, countPanels, setZoom } from './layout-utils';
|
||||
import { WorkspaceProvider } from './WorkspaceContext';
|
||||
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
||||
import { useAppRegistry } from '../../AppRegistry/useAppRegistry';
|
||||
@@ -80,6 +80,14 @@ export const WorkspaceView = ({ workspace, locked, cwd = '~', root, initialFileP
|
||||
[layout, onLayoutChange],
|
||||
);
|
||||
|
||||
const handleSetZoom = useCallback(
|
||||
(panelId: string, zoom: number) => {
|
||||
const next = setZoom(layout, panelId, zoom);
|
||||
if (next !== layout) onLayoutChange(next);
|
||||
},
|
||||
[layout, onLayoutChange],
|
||||
);
|
||||
|
||||
const handleSwap = useCallback(
|
||||
(sourceId: string, targetId: string) => {
|
||||
onLayoutChange(swapPanels(layout, sourceId, targetId));
|
||||
@@ -155,6 +163,7 @@ export const WorkspaceView = ({ workspace, locked, cwd = '~', root, initialFileP
|
||||
dragSourceId,
|
||||
setDragSourceId: startDrag,
|
||||
onMove: handleMove,
|
||||
onSetZoom: handleSetZoom,
|
||||
maximizedPanelId,
|
||||
setMaximizedPanelId: setMaximizedAnimated,
|
||||
transitioningPanelId,
|
||||
|
||||
@@ -102,6 +102,32 @@ export function setApp(root: LayoutNode, panelId: string, appType: string | null
|
||||
return { ...root, children: newChildren };
|
||||
}
|
||||
|
||||
export const ZOOM_MIN = 0.7;
|
||||
export const ZOOM_MAX = 1.6;
|
||||
export const ZOOM_STEP = 0.1;
|
||||
|
||||
export const clampZoom = (z: number) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(z * 10) / 10));
|
||||
|
||||
// Set a panel's content scale. Stores nothing at 1 — the default — so an untouched panel adds no key to
|
||||
// the persisted layout and a reset returns the node to its original shape.
|
||||
export function setZoom(root: LayoutNode, panelId: string, zoom: number): LayoutNode {
|
||||
if (root.type === 'panel') {
|
||||
if (root.id !== panelId) return root;
|
||||
const next = clampZoom(zoom);
|
||||
if ((root.zoom ?? 1) === next) return root;
|
||||
if (next === 1) {
|
||||
const { zoom: _drop, ...rest } = root;
|
||||
return rest;
|
||||
}
|
||||
return { ...root, zoom: next };
|
||||
}
|
||||
const newChildren = root.children.map((child) => {
|
||||
const node = setZoom(child.node, panelId, zoom);
|
||||
return node === child.node ? child : { ...child, node };
|
||||
});
|
||||
return newChildren.some((c, i) => c !== root.children[i]) ? { ...root, children: newChildren } : root;
|
||||
}
|
||||
|
||||
export function updateSizes(root: LayoutNode, groupId: string, sizes: number[]): LayoutNode {
|
||||
if (root.type === 'panel') return root;
|
||||
if (root.id === groupId) {
|
||||
@@ -154,13 +180,25 @@ export function movePanel(root: LayoutNode, sourceId: string, targetId: string,
|
||||
return insertPanel(result, targetId, direction, before, sourceApp);
|
||||
}
|
||||
|
||||
function insertPanel(node: LayoutNode, targetId: string, direction: 'horizontal' | 'vertical', before: boolean, appType: string | null): LayoutNode {
|
||||
function insertPanel(
|
||||
node: LayoutNode,
|
||||
targetId: string,
|
||||
direction: 'horizontal' | 'vertical',
|
||||
before: boolean,
|
||||
appType: string | null,
|
||||
): LayoutNode {
|
||||
if (node.type === 'panel') {
|
||||
if (node.id !== targetId) return node;
|
||||
const newPanel: LayoutPanel = { type: 'panel', id: uid(), appType };
|
||||
const children = before
|
||||
? [{ node: newPanel, size: 50 }, { node, size: 50 }]
|
||||
: [{ node, size: 50 }, { node: newPanel, size: 50 }];
|
||||
? [
|
||||
{ node: newPanel, size: 50 },
|
||||
{ node, size: 50 },
|
||||
]
|
||||
: [
|
||||
{ node, size: 50 },
|
||||
{ node: newPanel, size: 50 },
|
||||
];
|
||||
return { type: 'group', id: uid(), direction, children };
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ export type LayoutPanel = {
|
||||
id: string;
|
||||
appType: string | null;
|
||||
fitContent?: boolean;
|
||||
// Per-panel content scale (1 = 100%). Lives on the node rather than in its own useDashboardState key
|
||||
// because the layout is already persisted per panel — a separate key would seed a row per panel on
|
||||
// mount (useDashboardState seeds defaults to the server), and would need its own dispatcher branch.
|
||||
zoom?: number;
|
||||
};
|
||||
|
||||
export type LayoutNode = LayoutGroup | LayoutPanel;
|
||||
@@ -44,6 +48,10 @@ export type AppRegistryEntry = {
|
||||
transparent?: boolean;
|
||||
fixedHeight?: number;
|
||||
availableOnPanel?: boolean;
|
||||
// Opt out of the panel zoom control. For apps that scale badly under a CSS transform: a terminal
|
||||
// (its own font sizing, and scale blurs the glyph grid) or a remote-desktop canvas (noVNC does its
|
||||
// own autoscale, and a wrapping transform desyncs its pointer mapping).
|
||||
zoomable?: boolean;
|
||||
};
|
||||
|
||||
export type AppRegistryMap = Record<string, AppRegistryEntry>;
|
||||
|
||||
Reference in New Issue
Block a user