From ba664dc5c8d3914828b4d38b77190f0fa525b183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 7 Aug 2026 02:58:25 +0000 Subject: [PATCH] per-panel content zoom for every panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/workspaces/helpers/measure-zoomed.ts | 19 +++ .../src/apps/Chat/components/MessageList.tsx | 5 +- .../officerdev/src/apps/Desktop/index.ts | 3 + .../src/apps/Soulseek/SoulseekView.tsx | 29 +---- .../src/apps/Soulseek/SoulseekViewHeader.tsx | 52 -------- .../officerdev/src/apps/Soulseek/index.ts | 10 +- .../officerdev/src/apps/Soulseek/shared.ts | 9 -- .../officerdev/src/apps/Terminal/index.tsx | 8 ++ .../src/components/Workspace/PanelSlot.tsx | 122 ++++++++++++++++-- .../components/Workspace/WorkspaceContext.ts | 2 + .../components/Workspace/WorkspaceLayout.tsx | 2 +- .../components/Workspace/WorkspaceView.tsx | 11 +- .../src/components/Workspace/layout-utils.ts | 44 ++++++- .../src/components/Workspace/types.ts | 8 ++ 14 files changed, 217 insertions(+), 107 deletions(-) create mode 100644 src/workspaces/helpers/measure-zoomed.ts delete mode 100644 src/workspaces/officerdev/src/apps/Soulseek/SoulseekViewHeader.tsx diff --git a/src/workspaces/helpers/measure-zoomed.ts b/src/workspaces/helpers/measure-zoomed.ts new file mode 100644 index 00000000..2d2681f6 --- /dev/null +++ b/src/workspaces/helpers/measure-zoomed.ts @@ -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); diff --git a/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx b/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx index 9c132819..796ad5f1 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx @@ -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 diff --git a/src/workspaces/officerdev/src/apps/Desktop/index.ts b/src/workspaces/officerdev/src/apps/Desktop/index.ts index fe5f203e..b344a9f3 100644 --- a/src/workspaces/officerdev/src/apps/Desktop/index.ts +++ b/src/workspaces/officerdev/src/apps/Desktop/index.ts @@ -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, }, ]; diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekView.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekView.tsx index 2f6dbac4..4badd9fe 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekView.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekView.tsx @@ -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(SOULSEEK_SECTION_CHANNEL, 'dashboard'); - const { value: zoom } = useDashboardState(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 ; - if (z === 1) return
{sectionView(section)}
; - - // 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 ( -
-
- {sectionView(section)} -
-
- ); + return
{sectionView(section)}
; }; diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekViewHeader.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekViewHeader.tsx deleted file mode 100644 index 7b4b0985..00000000 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekViewHeader.tsx +++ /dev/null @@ -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(soulseekZoomKey(panelId), 1); - const level = zoom ?? 1; - - return ( - <> - - Soulseek -
- - - -
- - ); -}; diff --git a/src/workspaces/officerdev/src/apps/Soulseek/index.ts b/src/workspaces/officerdev/src/apps/Soulseek/index.ts index 2cfdc74e..147d607d 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/index.ts +++ b/src/workspaces/officerdev/src/apps/Soulseek/index.ts @@ -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 }, ]; diff --git a/src/workspaces/officerdev/src/apps/Soulseek/shared.ts b/src/workspaces/officerdev/src/apps/Soulseek/shared.ts index f095afab..f4aa558c 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/shared.ts +++ b/src/workspaces/officerdev/src/apps/Soulseek/shared.ts @@ -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 }; diff --git a/src/workspaces/officerdev/src/apps/Terminal/index.tsx b/src/workspaces/officerdev/src/apps/Terminal/index.tsx index 4fbfd573..2457bbb6 100644 --- a/src/workspaces/officerdev/src/apps/Terminal/index.tsx +++ b/src/workspaces/officerdev/src/apps/Terminal/index.tsx @@ -34,6 +34,9 @@ const ClaudeCodeWrapper = ({ panelId }: { panelId: string }) => ( ); +// 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, }, ]; diff --git a/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx b/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx index 7ff27161..b45c3639 100644 --- a/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx +++ b/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx @@ -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 = ({ ) : ( setSwapSourceId(panelId)}>Swap with... )} + {zoom !== null && } ); @@ -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 ( +
+ + + +
+ ); +}; + +const ZoomMenuItems = ({ panelId, zoom }: { panelId: string; zoom: number }) => { + const { onSetZoom } = useWorkspace(); + + return ( + <> + + = ZOOM_MAX} onClick={() => onSetZoom(panelId, zoom + ZOOM_STEP)}> + Zoom in + + onSetZoom(panelId, zoom - ZOOM_STEP)}> + Zoom out + + onSetZoom(panelId, 1)}> + Reset zoom + + + ); +}; + +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: setMaximizedPanelId(isMaximized ? null : panelId)}> {isMaximized ? 'Restore' : 'Maximize'} + {zoom !== null && } ); @@ -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) => {content} + ? (content: React.ReactNode) => ( + + {content} + + ) : (content: React.ReactNode) => ( onSetApp(panel.id, null)} @@ -375,8 +455,11 @@ export const PanelSlot = ({ ) : null; + const zoomControls = + interactive && !isMobile && zoom !== null ? : null; + const headerContent = ( -
+
{mobileBackButton} {ResolvedHeader && } {!mobileBackButton && onClose && ( @@ -384,18 +467,28 @@ export const PanelSlot = ({ )} - {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. +
+ {zoomControls} + {trafficLights} +
+ )}
); const headerBar = interactive ? ( locked ? ( - {headerContent} + + {headerContent} + ) : ( 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 = ( -
+
- +
+ +
); diff --git a/src/workspaces/officerdev/src/components/Workspace/WorkspaceContext.ts b/src/workspaces/officerdev/src/components/Workspace/WorkspaceContext.ts index 4849abea..df5d083b 100644 --- a/src/workspaces/officerdev/src/components/Workspace/WorkspaceContext.ts +++ b/src/workspaces/officerdev/src/components/Workspace/WorkspaceContext.ts @@ -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({ dragSourceId: null, setDragSourceId: noop, onMove: noop, + onSetZoom: noop, maximizedPanelId: null, setMaximizedPanelId: noop, transitioningPanelId: null, diff --git a/src/workspaces/officerdev/src/components/Workspace/WorkspaceLayout.tsx b/src/workspaces/officerdev/src/components/Workspace/WorkspaceLayout.tsx index b128c3d7..017f0d01 100644 --- a/src/workspaces/officerdev/src/components/Workspace/WorkspaceLayout.tsx +++ b/src/workspaces/officerdev/src/components/Workspace/WorkspaceLayout.tsx @@ -33,7 +33,7 @@ export const WorkspaceLayout = ({ layout, onLayoutChange, registry: registryProp ); return ( - + { + 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, diff --git a/src/workspaces/officerdev/src/components/Workspace/layout-utils.ts b/src/workspaces/officerdev/src/components/Workspace/layout-utils.ts index 82e8c261..514e4700 100644 --- a/src/workspaces/officerdev/src/components/Workspace/layout-utils.ts +++ b/src/workspaces/officerdev/src/components/Workspace/layout-utils.ts @@ -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 }; } diff --git a/src/workspaces/officerdev/src/components/Workspace/types.ts b/src/workspaces/officerdev/src/components/Workspace/types.ts index 9eaf7a7e..27fbc16b 100644 --- a/src/workspaces/officerdev/src/components/Workspace/types.ts +++ b/src/workspaces/officerdev/src/components/Workspace/types.ts @@ -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;