From 925b05f64e3c50f7ecfd9d6cfd0b82c77c0dba50 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Thu, 11 Dec 2025 14:59:09 +0100 Subject: [PATCH 01/17] Move zoom tool to right side of the toolbar --- code/core/src/manager/container/Preview.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/code/core/src/manager/container/Preview.tsx b/code/core/src/manager/container/Preview.tsx index e410ca0aecac..25d4d83e14b4 100644 --- a/code/core/src/manager/container/Preview.tsx +++ b/code/core/src/manager/container/Preview.tsx @@ -22,8 +22,8 @@ import { zoomTool } from '../components/preview/tools/zoom'; import type { PreviewProps } from '../components/preview/utils/types'; const defaultTabs = [createCanvasTab()]; -const defaultTools = [menuTool, remountTool, zoomTool]; -const defaultToolsExtra = [addonsTool, fullScreenTool, shareTool, openInEditorTool]; +const defaultTools = [menuTool, remountTool]; +const defaultToolsExtra = [zoomTool, addonsTool, fullScreenTool, shareTool, openInEditorTool]; const emptyTabsList: Addon_BaseType[] = []; From 9cc40e7bd4d992e7ff8108a27152b91d55132ef1 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Thu, 11 Dec 2025 15:00:19 +0100 Subject: [PATCH 02/17] Pull Shortcut component out of Menu, so it can be reused --- .../ActionList/ActionList.stories.tsx | 2 +- code/core/src/manager/components/Shortcut.tsx | 36 +++++++++++++++++++ .../components/preview/tools/share.tsx | 2 +- .../components/sidebar/ContextMenu.tsx | 2 +- .../src/manager/container/Menu.stories.tsx | 2 +- code/core/src/manager/container/Menu.tsx | 35 +----------------- 6 files changed, 41 insertions(+), 38 deletions(-) create mode 100644 code/core/src/manager/components/Shortcut.tsx diff --git a/code/core/src/components/components/ActionList/ActionList.stories.tsx b/code/core/src/components/components/ActionList/ActionList.stories.tsx index 46d626aab1b2..096f04352e88 100644 --- a/code/core/src/components/components/ActionList/ActionList.stories.tsx +++ b/code/core/src/components/components/ActionList/ActionList.stories.tsx @@ -2,7 +2,7 @@ import { CheckIcon, EllipsisIcon, PlayAllHollowIcon } from '@storybook/icons'; import { Badge, Form, ProgressSpinner } from '../..'; import preview from '../../../../../.storybook/preview'; -import { Shortcut } from '../../../manager/container/Menu'; +import { Shortcut } from '../../../manager/components/Shortcut'; import { ActionList } from './ActionList'; const meta = preview.meta({ diff --git a/code/core/src/manager/components/Shortcut.tsx b/code/core/src/manager/components/Shortcut.tsx new file mode 100644 index 000000000000..91ef54aaf9fb --- /dev/null +++ b/code/core/src/manager/components/Shortcut.tsx @@ -0,0 +1,36 @@ +import React from 'react'; + +import { shortcutToHumanString } from 'storybook/manager-api'; +import { styled } from 'storybook/theming'; + +const Wrapper = styled.span(({ theme }) => ({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + height: 16, + fontSize: '11px', + fontWeight: theme.typography.weight.regular, + background: theme.base === 'light' ? 'rgba(0,0,0,0.05)' : 'rgba(255,255,255,0.05)', + color: theme.base === 'light' ? theme.color.dark : theme.textMutedColor, + borderRadius: 2, + userSelect: 'none', + pointerEvents: 'none', + padding: '0 4px', +})); + +const Key = styled.code(({ theme }) => ({ + padding: 0, + fontFamily: theme.typography.fonts.base, + verticalAlign: 'middle', + '& + &': { + marginLeft: 6, + }, +})); + +export const Shortcut = ({ keys }: { keys: string[] }) => ( + + {keys.map((key) => ( + {shortcutToHumanString([key])} + ))} + +); diff --git a/code/core/src/manager/components/preview/tools/share.tsx b/code/core/src/manager/components/preview/tools/share.tsx index b7dd9720cccc..6c9863df3647 100644 --- a/code/core/src/manager/components/preview/tools/share.tsx +++ b/code/core/src/manager/components/preview/tools/share.tsx @@ -12,7 +12,7 @@ import { Consumer, types } from 'storybook/manager-api'; import type { API, Combo } from 'storybook/manager-api'; import { styled, useTheme } from 'storybook/theming'; -import { Shortcut } from '../../../container/Menu'; +import { Shortcut } from '../../Shortcut'; const mapper = ({ api, state }: Combo) => { const { storyId, refId } = state; diff --git a/code/core/src/manager/components/sidebar/ContextMenu.tsx b/code/core/src/manager/components/sidebar/ContextMenu.tsx index c6d1ed365988..55bd6b9f34da 100644 --- a/code/core/src/manager/components/sidebar/ContextMenu.tsx +++ b/code/core/src/manager/components/sidebar/ContextMenu.tsx @@ -18,8 +18,8 @@ import type { API } from 'storybook/manager-api'; import { styled } from 'storybook/theming'; import type { Link } from '../../../components/components/tooltip/TooltipLinkList'; -import { Shortcut } from '../../container/Menu'; import { getMostCriticalStatusValue } from '../../utils/status'; +import { Shortcut } from '../Shortcut'; import { UseSymbol } from './IconSymbols'; import { StatusButton } from './StatusButton'; import { StatusContext } from './StatusContext'; diff --git a/code/core/src/manager/container/Menu.stories.tsx b/code/core/src/manager/container/Menu.stories.tsx index 4c3758a574b3..5c81f339a7c9 100644 --- a/code/core/src/manager/container/Menu.stories.tsx +++ b/code/core/src/manager/container/Menu.stories.tsx @@ -7,8 +7,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { action } from 'storybook/actions'; import { initialState } from '../../shared/checklist-store/checklistData.state'; +import { Shortcut } from '../components/Shortcut'; import { internal_universalChecklistStore as mockStore } from '../manager-stores.mock'; -import { Shortcut } from './Menu'; const onLinkClick = action('onLinkClick'); diff --git a/code/core/src/manager/container/Menu.tsx b/code/core/src/manager/container/Menu.tsx index 08bc657f2711..2590c40e694c 100644 --- a/code/core/src/manager/container/Menu.tsx +++ b/code/core/src/manager/container/Menu.tsx @@ -1,4 +1,3 @@ -import type { FC } from 'react'; import React, { useCallback, useMemo } from 'react'; import { ActionList, ProgressSpinner } from 'storybook/internal/components'; @@ -15,52 +14,20 @@ import { } from '@storybook/icons'; import type { API } from 'storybook/manager-api'; -import { shortcutToHumanString } from 'storybook/manager-api'; import { styled } from 'storybook/theming'; import type { NormalLink } from '../../components/components/tooltip/TooltipLinkList'; +import { Shortcut } from '../components/Shortcut'; import { useChecklist } from '../components/sidebar/useChecklist'; export type MenuItem = NormalLink & { closeOnClick?: boolean; }; -const Key = styled.span(({ theme }) => ({ - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - height: 16, - fontSize: '11px', - fontWeight: theme.typography.weight.regular, - background: theme.base === 'light' ? 'rgba(0,0,0,0.05)' : 'rgba(255,255,255,0.05)', - color: theme.base === 'light' ? theme.color.dark : theme.textMutedColor, - borderRadius: 2, - userSelect: 'none', - pointerEvents: 'none', - padding: '0 4px', -})); - -const KeyChild = styled.code(({ theme }) => ({ - padding: 0, - fontFamily: theme.typography.fonts.base, - verticalAlign: 'middle', - '& + &': { - marginLeft: 6, - }, -})); - const ProgressCircle = styled(ProgressSpinner)(({ theme }) => ({ color: theme.color.secondary, })); -export const Shortcut: FC<{ keys: string[] }> = ({ keys }) => ( - - {keys.map((key) => ( - {shortcutToHumanString([key])} - ))} - -); - export const useMenu = ({ api, showToolbar, From 7b4e9fe717ba7c8cb8576d97903d7bb1c60ad0bd Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Thu, 11 Dec 2025 15:03:09 +0100 Subject: [PATCH 03/17] Refactor Zoom component to improve zoom functionality and UI interactions - Updated ZoomIFrame to correctly apply scaling transformations. - Enhanced ZoomProvider to manage zoom levels with predefined constants. - Refactored Zoom component to utilize ActionList for zoom controls and added keyboard shortcuts for zoom actions. - Improved ZoomWrapper to dynamically adjust zoom levels based on user interactions. --- .../components/components/Zoom/ZoomIFrame.tsx | 12 +- .../manager/components/preview/tools/zoom.tsx | 186 +++++++++++------- 2 files changed, 124 insertions(+), 74 deletions(-) diff --git a/code/core/src/components/components/Zoom/ZoomIFrame.tsx b/code/core/src/components/components/Zoom/ZoomIFrame.tsx index d166d3b96c28..b44f2ebb1b65 100644 --- a/code/core/src/components/components/Zoom/ZoomIFrame.tsx +++ b/code/core/src/components/components/Zoom/ZoomIFrame.tsx @@ -39,9 +39,9 @@ export class ZoomIFrame extends Component { try { // @ts-expect-error (non strict) Object.assign(this.iframe.contentDocument.body.style, { - width: `${scale * 100}%`, - height: `${scale * 100}%`, - transform: `scale(${1 / scale})`, + width: `${(1 / scale) * 100}%`, + height: `${(1 / scale) * 100}%`, + transform: `scale(${scale})`, transformOrigin: 'top left', }); } catch (e) { @@ -51,9 +51,9 @@ export class ZoomIFrame extends Component { setIframeZoom(scale: number) { Object.assign(this.iframe.style, { - width: `${scale * 100}%`, - height: `${scale * 100}%`, - transform: `scale(${1 / scale})`, + width: `${(1 / scale) * 100}%`, + height: `${(1 / scale) * 100}%`, + transform: `scale(${scale})`, transformOrigin: 'top left', }); } diff --git a/code/core/src/manager/components/preview/tools/zoom.tsx b/code/core/src/manager/components/preview/tools/zoom.tsx index bc11e58a87fc..4e015b5fc6ed 100644 --- a/code/core/src/manager/components/preview/tools/zoom.tsx +++ b/code/core/src/manager/components/preview/tools/zoom.tsx @@ -1,23 +1,31 @@ -import type { EventHandler, PropsWithChildren, SyntheticEvent } from 'react'; -import React, { Component, createContext, memo, useCallback } from 'react'; +import type { PropsWithChildren } from 'react'; +import React, { Component, createContext, memo, useCallback, useEffect, useRef } from 'react'; -import { Button, Separator } from 'storybook/internal/components'; +import { ActionList, Button, PopoverProvider } from 'storybook/internal/components'; import type { Addon_BaseType } from 'storybook/internal/types'; -import { ZoomIcon, ZoomOutIcon, ZoomResetIcon } from '@storybook/icons'; +import { types, useStorybookApi } from 'storybook/manager-api'; +import { styled } from 'storybook/theming'; -import { types } from 'storybook/manager-api'; +import { Shortcut } from '../../Shortcut'; -const initialZoom = 1 as const; +const BASE_ZOOM_LEVELS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const; +const INITIAL_ZOOM_LEVEL = 1; -const Context = createContext({ value: initialZoom, set: (v: number) => {} }); +const ZoomButton = styled(Button)({ + minWidth: 48, +}); + +const Context = createContext({ value: INITIAL_ZOOM_LEVEL, set: (v: number) => {} }); -class ZoomProvider extends Component< +export const ZoomConsumer = Context.Consumer; + +export class ZoomProvider extends Component< PropsWithChildren<{ shouldScale: boolean }>, { value: number } > { state = { - value: initialZoom, + value: INITIAL_ZOOM_LEVEL, }; set = (value: number) => this.setState({ value }); @@ -27,84 +35,126 @@ class ZoomProvider extends Component< const { set } = this; const { value } = this.state; return ( - + {children} ); } } -const { Consumer: ZoomConsumer } = Context; +export const Zoom = memo<{ + value: number; + zoomIn: () => void; + zoomOut: () => void; + zoomTo: (value: number) => void; +}>(function Zoom({ value, zoomIn, zoomOut, zoomTo }) { + const inputRef = useRef(null); -const Zoom = memo<{ - zoomIn: EventHandler; - zoomOut: EventHandler; - reset: EventHandler; -}>(function Zoom({ zoomIn, zoomOut, reset }) { return ( - <> - - - - + { + if (isVisible) { + requestAnimationFrame(() => inputRef.current?.select()); + } + }} + popover={ +
+ + + + Zoom in + + + + + + Zoom out + + + + + zoomTo(0.5)} ariaLabel={false}> + Zoom to 50% + + + + zoomTo(1)} ariaLabel={false}> + Reset to 100% + + + + + zoomTo(2)} ariaLabel={false}> + Zoom to 200% + + + +
+ } + > + + {Math.round(value * 100)}% + +
); }); -export { Zoom, ZoomConsumer, ZoomProvider }; +const ZoomWrapper = memo<{ + set: (zoomLevel: number) => void; + value: number; +}>(function ZoomWrapper({ set, value }) { + const api = useStorybookApi(); -const ZoomWrapper = memo<{ set: (zoomLevel: number) => void; value: number }>(function ZoomWrapper({ - set, - value, -}) { - const zoomIn = useCallback( - (e: SyntheticEvent) => { - e.preventDefault(); - set(0.8 * value); - }, - [set, value] - ); - const zoomOut = useCallback( - (e: SyntheticEvent) => { - e.preventDefault(); - set(1.25 * value); - }, - [set, value] - ); - const reset = useCallback( - (e: SyntheticEvent) => { - e.preventDefault(); - set(initialZoom); + const zoomIn = useCallback(() => { + const higherZoomLevel = BASE_ZOOM_LEVELS.find((level) => level > value); + if (higherZoomLevel) { + set(higherZoomLevel); + } + }, [set, value]); + + const zoomOut = useCallback(() => { + const lowerZoomLevel = BASE_ZOOM_LEVELS.findLast((level) => level < value); + if (lowerZoomLevel) { + set(lowerZoomLevel); + } + }, [set, value]); + + const zoomTo = useCallback( + (value: number) => { + set(value); }, - [set, initialZoom] + [set] ); - return ; -}); -function ZoomToolRenderer() { - return ( - <> - {({ set, value }) => } - - - ); -} + useEffect(() => { + api.setAddonShortcut('zoom', { + label: 'Zoom to 100%', + defaultShortcut: ['alt', '0'], + actionName: 'zoomReset', + action: () => zoomTo(1), + }); + api.setAddonShortcut('zoom', { + label: 'Zoom in', + defaultShortcut: ['alt', '='], + actionName: 'zoomIn', + action: zoomIn, + }); + api.setAddonShortcut('zoom', { + label: 'Zoom out', + defaultShortcut: ['alt', '-'], + actionName: 'zoomOut', + action: zoomOut, + }); + }, [api, zoomIn, zoomOut, zoomTo]); + + return ; +}); export const zoomTool: Addon_BaseType = { title: 'zoom', id: 'zoom', type: types.TOOL, match: ({ viewMode, tabId }) => viewMode === 'story' && !tabId, - render: ZoomToolRenderer, + render: () => {(zoomContext) => }, }; From f78ab9774e389a5ded51eb7c94f2f1ee8ef8b341 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 16 Dec 2025 10:50:32 +0100 Subject: [PATCH 04/17] Add custom zoom level input, fix aria labels and add stories --- .../preview/NumericInput.stories.tsx | 80 +++++++++ .../components/preview/NumericInput.tsx | 152 ++++++++++++++++++ .../components/preview/tools/zoom.stories.tsx | 77 +++++++++ .../manager/components/preview/tools/zoom.tsx | 80 +++++++-- 4 files changed, 375 insertions(+), 14 deletions(-) create mode 100644 code/core/src/manager/components/preview/NumericInput.stories.tsx create mode 100644 code/core/src/manager/components/preview/NumericInput.tsx create mode 100644 code/core/src/manager/components/preview/tools/zoom.stories.tsx diff --git a/code/core/src/manager/components/preview/NumericInput.stories.tsx b/code/core/src/manager/components/preview/NumericInput.stories.tsx new file mode 100644 index 000000000000..5a580e26dd88 --- /dev/null +++ b/code/core/src/manager/components/preview/NumericInput.stories.tsx @@ -0,0 +1,80 @@ +import { expect, fireEvent, fn } from 'storybook/test'; + +import preview from '../../../../../.storybook/preview'; +import { NumericInput } from './NumericInput'; + +const meta = preview.meta({ + component: NumericInput, + tags: ['autodocs'], + args: { + setValue: fn(), + }, +}); + +export default meta; + +export const Default = meta.story({ + args: { + value: '10', + }, + play: async ({ args, canvas }) => { + const input = await canvas.findByRole('textbox'); + fireEvent.keyDown(input, { key: 'ArrowUp' }); + fireEvent.keyDown(input, { key: 'ArrowUp' }); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + expect(input).toHaveValue('11'); + expect(args.setValue).toHaveBeenNthCalledWith(1, '11'); + expect(args.setValue).toHaveBeenNthCalledWith(2, '12'); + expect(args.setValue).toHaveBeenNthCalledWith(3, '11'); + }, +}); + +export const WithImplicitUnit = meta.story({ + args: { + value: '10em', + }, + play: async ({ args, canvas }) => { + const input = await canvas.findByRole('textbox'); + fireEvent.keyDown(input, { key: 'ArrowUp' }); + fireEvent.keyDown(input, { key: 'ArrowUp' }); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + expect(input).toHaveValue('11em'); + expect(args.setValue).toHaveBeenNthCalledWith(1, '11em'); + expect(args.setValue).toHaveBeenNthCalledWith(2, '12em'); + expect(args.setValue).toHaveBeenNthCalledWith(3, '11em'); + }, +}); + +export const WithExplicitUnit = meta.story({ + args: { + value: '10', + unit: 'vw', + }, + play: async ({ args, canvas }) => { + const input = await canvas.findByRole('textbox'); + fireEvent.keyDown(input, { key: 'ArrowUp' }); + fireEvent.keyDown(input, { key: 'ArrowUp' }); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + expect(input).toHaveValue('11vw'); + expect(args.setValue).toHaveBeenNthCalledWith(1, '11vw'); + expect(args.setValue).toHaveBeenNthCalledWith(2, '12vw'); + expect(args.setValue).toHaveBeenNthCalledWith(3, '11vw'); + }, +}); + +export const WithBaseUnit = meta.story({ + args: { + value: '10em', + baseUnit: 'em', + }, + play: async ({ args, canvas }) => { + const input = await canvas.findByRole('textbox'); + fireEvent.keyDown(input, { key: 'ArrowUp' }); + fireEvent.keyDown(input, { key: 'ArrowUp' }); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + expect(input).toHaveValue('11'); + expect(args.setValue).toHaveBeenNthCalledWith(1, '11em'); + expect(args.setValue).toHaveBeenNthCalledWith(2, '12em'); + expect(args.setValue).toHaveBeenNthCalledWith(3, '11em'); + }, +}); diff --git a/code/core/src/manager/components/preview/NumericInput.tsx b/code/core/src/manager/components/preview/NumericInput.tsx new file mode 100644 index 000000000000..82a785881426 --- /dev/null +++ b/code/core/src/manager/components/preview/NumericInput.tsx @@ -0,0 +1,152 @@ +import type { ChangeEvent, ComponentProps, ReactNode } from 'react'; +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; + +import { Form } from 'storybook/internal/components'; + +import { styled } from 'storybook/theming'; + +const Wrapper = styled.div<{ after?: ReactNode; before?: ReactNode }>( + ({ after, before, theme }) => ({ + position: 'relative', + display: 'flex', + alignItems: 'center', + width: '100%', + height: 32, + paddingInline: 9, + fontSize: theme.typography.size.s1, + color: theme.textMutedColor, + background: theme.input.background, + boxShadow: `${theme.input.border} 0 0 0 1px inset`, + borderRadius: theme.input.borderRadius, + svg: { + display: 'block', + }, + input: { + height: '100%', + minHeight: '100%', + flex: '1 1 auto', + paddingInline: 0, + fontSize: 'inherit', + background: 'transparent', + border: 'none', + boxShadow: 'none', + color: theme.input.color, + '&:focus, &:focus-visible': { + boxShadow: 'none', + outline: 'none', + }, + }, + '&:has(input:focus-visible)': { + outline: `2px solid ${theme.color.secondary}`, + outlineOffset: -2, + }, + ...(after && { paddingRight: 2 }), + ...(before && { paddingLeft: 2 }), + }) +); + +interface NumericInputProps extends Omit, 'value'> { + label?: string; + before?: ReactNode; + after?: ReactNode; + value: string; + setValue: (value: string) => void; + unit?: string; + baseUnit?: string; +} + +export const NumericInput = forwardRef<{ select: () => void }, NumericInputProps>( + function NumericInput( + { label, before, after, value, setValue, unit, baseUnit, className, style, ...props }, + forwardedRef + ) { + const baseUnitRegex = useMemo(() => baseUnit && new RegExp(`${baseUnit}$`), [baseUnit]); + const inputRef = useRef(null); + const [inputValue, setInputValue] = useState( + baseUnitRegex ? value.replace(baseUnitRegex, '') : value + ); + + useImperativeHandle(forwardedRef, () => inputRef.current!); + + const onChange = useCallback( + (e: ChangeEvent) => { + setInputValue(e.target.value); + setValue( + !baseUnit || Number.isNaN(Number(e.target.value)) + ? e.target.value + : `${e.target.value}${baseUnit}` + ); + }, + [setValue, baseUnit] + ); + + const setInputSelection = useCallback(() => { + requestAnimationFrame(() => { + const input = inputRef.current; + const index = input?.value.search(/[^\d.]/) ?? -1; + if (input && index > 0) { + input.setSelectionRange(index, index); + } + }); + }, []); + + const updateInputValue = useCallback( + () => setInputValue(baseUnitRegex ? value.replace(baseUnitRegex, '') : value), + [value, baseUnitRegex] + ); + + useEffect(() => { + if (inputRef.current !== document.activeElement) { + updateInputValue(); + } + }, [updateInputValue]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') { + return; + } + e.preventDefault(); + const num = parseInt(inputValue, 10); + const update = e.key === 'ArrowUp' ? num + 1 : num - 1; + if (!Number.isNaN(num) && update >= 0) { + const inputUnit = + inputValue.match(/(\d+(?:\.\d+)?)(\%|[a-z]{0,4})?$/)?.[2] || unit || baseUnit || ''; + setInputValue(`${update}${inputUnit === baseUnit ? '' : inputUnit}`); + setValue(`${update}${inputUnit}`); + setInputSelection(); + } + }; + + const input = inputRef.current; + if (input) { + input.addEventListener('keydown', handleKeyDown); + return () => input.removeEventListener('keydown', handleKeyDown); + } + }, [inputValue, setValue, unit, baseUnit, inputRef, setInputSelection]); + + return ( + + {before &&
{before}
} + {label && {label}} + + {after &&
{after}
} +
+ ); + } +); diff --git a/code/core/src/manager/components/preview/tools/zoom.stories.tsx b/code/core/src/manager/components/preview/tools/zoom.stories.tsx new file mode 100644 index 000000000000..f5119ba627b0 --- /dev/null +++ b/code/core/src/manager/components/preview/tools/zoom.stories.tsx @@ -0,0 +1,77 @@ +import { useState } from 'react'; + +import type { StoryContext } from '@storybook/react-vite'; + +import { screen, within } from 'storybook/test'; + +import preview from '../../../../../../.storybook/preview'; +import { Zoom } from './zoom'; + +const openDialog = async (context: StoryContext) => { + const zoom = await context.canvas.findByRole('button', { name: 'Change zoom level' }); + await context.userEvent.click(zoom); + return screen.findByRole('dialog'); +}; + +const meta = preview.meta({ + component: Zoom, + args: { + value: 1, + }, + render: (args: Parameters[0]) => { + const [value, setValue] = useState(args.value); + return ( + setValue(value + 0.5), + zoomOut: () => setValue(value - 0.5), + zoomTo: setValue, + }} + /> + ); + }, + play: openDialog, +}); + +export default meta; + +export const Default = meta.story({}); + +export const ZoomIn = meta.story({ + play: async (context: StoryContext) => { + const dialog = await openDialog(context); + const zoomIn = await within(dialog).findByRole('button', { name: 'Zoom in' }); + await context.userEvent.click(zoomIn); + }, +}); + +export const ZoomOut = meta.story({ + play: async (context: StoryContext) => { + const dialog = await openDialog(context); + const zoomOut = await within(dialog).findByRole('button', { name: 'Zoom out' }); + await context.userEvent.click(zoomOut); + }, +}); + +export const Undo = meta.story({ + play: async (context: StoryContext) => { + const dialog = await openDialog(context); + const zoomIn = await within(dialog).findByRole('button', { name: 'Zoom in' }); + await context.userEvent.click(zoomIn); + const undo = await within(dialog).findByRole('button', { name: 'Reset zoom' }); + await context.userEvent.click(undo); + }, +}); + +export const MaxZoom = meta.story({ + args: { + value: 4, + }, +}); + +export const MinZoom = meta.story({ + args: { + value: 0.25, + }, +}); diff --git a/code/core/src/manager/components/preview/tools/zoom.tsx b/code/core/src/manager/components/preview/tools/zoom.tsx index 4e015b5fc6ed..8fc6cf953d11 100644 --- a/code/core/src/manager/components/preview/tools/zoom.tsx +++ b/code/core/src/manager/components/preview/tools/zoom.tsx @@ -4,12 +4,15 @@ import React, { Component, createContext, memo, useCallback, useEffect, useRef } import { ActionList, Button, PopoverProvider } from 'storybook/internal/components'; import type { Addon_BaseType } from 'storybook/internal/types'; +import { UndoIcon, ZoomIcon } from '@storybook/icons'; + import { types, useStorybookApi } from 'storybook/manager-api'; import { styled } from 'storybook/theming'; import { Shortcut } from '../../Shortcut'; +import { NumericInput } from '../NumericInput'; -const BASE_ZOOM_LEVELS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const; +const ZOOM_LEVELS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4] as const; const INITIAL_ZOOM_LEVEL = 1; const ZoomButton = styled(Button)({ @@ -18,6 +21,12 @@ const ZoomButton = styled(Button)({ const Context = createContext({ value: INITIAL_ZOOM_LEVEL, set: (v: number) => {} }); +const ZoomInput = styled(NumericInput)({ + input: { + width: 100, + }, +}); + export const ZoomConsumer = Context.Consumer; export class ZoomProvider extends Component< @@ -59,41 +68,84 @@ export const Zoom = memo<{ } }} popover={ -
+ <> + + + + + + } + after={ + zoomTo(1)} + ariaLabel="Reset zoom" + > + + + } + value={`${Math.round(value * 100)}%`} + setValue={(value: string) => { + const zoomLevel = parseInt(value, 10) / 100; + if (!Number.isNaN(zoomLevel)) { + zoomTo(zoomLevel); + } + }} + /> + + - + = ZOOM_LEVELS.at(-1)!} + > Zoom in - + Zoom out - zoomTo(0.5)} ariaLabel={false}> - Zoom to 50% + zoomTo(0.5)} ariaLabel="Zoom to 50%"> + 50% - zoomTo(1)} ariaLabel={false}> - Reset to 100% + zoomTo(1)} ariaLabel="Zoom to 100%"> + 100% - zoomTo(2)} ariaLabel={false}> - Zoom to 200% + zoomTo(2)} ariaLabel="Zoom to 200%"> + 200% -
+ } > - + {Math.round(value * 100)}% @@ -107,14 +159,14 @@ const ZoomWrapper = memo<{ const api = useStorybookApi(); const zoomIn = useCallback(() => { - const higherZoomLevel = BASE_ZOOM_LEVELS.find((level) => level > value); + const higherZoomLevel = ZOOM_LEVELS.find((level) => level > value); if (higherZoomLevel) { set(higherZoomLevel); } }, [set, value]); const zoomOut = useCallback(() => { - const lowerZoomLevel = BASE_ZOOM_LEVELS.findLast((level) => level < value); + const lowerZoomLevel = ZOOM_LEVELS.findLast((level) => level < value); if (lowerZoomLevel) { set(lowerZoomLevel); } From 291dc4d8cc020f3d2435ecd762b1587b6858e832 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 16 Dec 2025 11:11:12 +0100 Subject: [PATCH 05/17] Make viewports handle scaling properly, and reuse shared NumericInput component --- .../manager/components/preview/SizeInput.tsx | 94 ----------- .../manager/components/preview/Viewport.tsx | 157 +++++++++++++----- 2 files changed, 111 insertions(+), 140 deletions(-) delete mode 100644 code/core/src/manager/components/preview/SizeInput.tsx diff --git a/code/core/src/manager/components/preview/SizeInput.tsx b/code/core/src/manager/components/preview/SizeInput.tsx deleted file mode 100644 index 421d5d0937dd..000000000000 --- a/code/core/src/manager/components/preview/SizeInput.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import type { ChangeEvent, ComponentProps } from 'react'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; - -import { Form } from 'storybook/internal/components'; - -import { styled } from 'storybook/theming'; - -const Wrapper = styled.span<{ prefix?: string }>(({ theme, prefix }) => ({ - position: 'relative', - fontSize: theme.typography.size.s1, - input: { - width: 70, - height: 28, - minHeight: 28, - paddingLeft: 25, - paddingRight: 0, - fontSize: 'inherit', - '&:focus': { - boxShadow: 'none', - outline: `2px solid ${theme.color.secondary}`, - outlineOffset: -2, - }, - }, - ...(prefix && { - '&::before': { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - content: `"${prefix}"`, - position: 'absolute', - left: 5, - top: 0, - bottom: 0, - width: 20, - zIndex: 1, - color: theme.textMutedColor, - }, - }), -})); - -export const SizeInput = ({ - label, - prefix, - value, - setValue, - ...props -}: { - label?: string; - prefix?: string; - value: string; - setValue: (value: string) => void; -} & Omit, 'value'>) => { - const inputRef = useRef(null); - const [inputValue, setInputValue] = useState(value.replace(/px$/, '')); - - useEffect(() => setInputValue(value.replace(/px$/, '')), [value]); - - const onChange = useCallback( - (e: ChangeEvent) => { - setInputValue(e.target.value); - setValue(Number.isNaN(Number(e.target.value)) ? e.target.value : `${e.target.value}px`); - }, - [setValue] - ); - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') { - return; - } - e.preventDefault(); - const num = parseInt(inputValue, 10); - const update = e.key === 'ArrowUp' ? num + 1 : num - 1; - if (!Number.isNaN(num) && update >= 0) { - const unit = inputValue.match(/[0-9]{1,4}(%|[a-z]{0,4})?$/)?.[1] || 'px'; - setInputValue(`${update}${unit === 'px' ? '' : unit}`); - setValue(`${update}${unit}`); - } - }; - - const input = inputRef.current; - if (input) { - input.addEventListener('keydown', handleKeyDown); - return () => input.removeEventListener('keydown', handleKeyDown); - } - }, [inputValue, setValue]); - - return ( - - {label && {label}} - - - ); -}; diff --git a/code/core/src/manager/components/preview/Viewport.tsx b/code/core/src/manager/components/preview/Viewport.tsx index 24dbe5843383..0fa6b92a5d03 100644 --- a/code/core/src/manager/components/preview/Viewport.tsx +++ b/code/core/src/manager/components/preview/Viewport.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { ActionList } from 'storybook/internal/components'; @@ -12,7 +12,7 @@ import { useViewport, } from '../../../viewport/useViewport'; import { IFrame } from './Iframe'; -import { SizeInput } from './SizeInput'; +import { NumericInput } from './NumericInput'; type DragSide = 'none' | 'both' | 'bottom' | 'right'; @@ -104,40 +104,63 @@ const FrameWrapper = styled.div<{ rgba(0,0,0,0) 100%)`, }, iframe: { - borderRadius: 'inherit', pointerEvents: dragging === 'none' ? 'auto' : 'none', }, })); const DragHandle = styled.div<{ + isDefault: boolean; 'data-side': DragSide; -}>( - { position: 'absolute' }, - ({ 'data-side': side }) => - side === 'both' && { - right: -12, - bottom: -12, - width: 25, - height: 25, - cursor: 'nwse-resize', - }, - ({ 'data-side': side }) => - side === 'bottom' && { - left: 0, - right: 13, - bottom: -12, - height: 20, - cursor: 'row-resize', - }, - ({ 'data-side': side }) => - side === 'right' && { - top: 0, - right: -12, - bottom: 13, - width: 20, - cursor: 'col-resize', - } -); +}>(({ isDefault }) => ({ + display: isDefault ? 'none' : 'block', + position: 'absolute', + '&[data-side="both"]': { + right: -12, + bottom: -12, + width: 25, + height: 25, + cursor: 'nwse-resize', + }, + '&[data-side="bottom"]': { + left: 0, + right: 13, + bottom: -12, + height: 20, + cursor: 'row-resize', + }, + '&[data-side="right"]': { + top: 0, + right: -12, + bottom: 13, + width: 20, + cursor: 'col-resize', + }, +})); + +const ScrollEdge = styled.div<{ 'data-edge': DragSide }>({ + position: 'absolute', + pointerEvents: 'none', + width: 0, + height: 0, + '&[data-edge="right"]': { + right: -40, + height: '100%', + }, + '&[data-edge="bottom"]': { + bottom: -40, + width: '100%', + }, + '&[data-edge="both"]': { + right: -40, + bottom: -40, + }, +}); + +const SizeInput = styled(NumericInput)({ + width: 85, + height: 28, + minHeight: 28, +}); export const Viewport = ({ active, @@ -162,6 +185,10 @@ export const Viewport = ({ const dragStart = useRef<[number, number] | undefined>(); useEffect(() => { + const scrollRight = targetRef.current?.querySelector('[data-edge="right"]'); + const scrollBottom = targetRef.current?.querySelector('[data-edge="bottom"]'); + const scrollBoth = targetRef.current?.querySelector('[data-edge="both"]'); + const onDrag = (e: MouseEvent) => { if (dragSide.current === 'both' || dragSide.current === 'right') { targetRef.current!.style.width = `${dragStart.current![0] + e.clientX}px`; @@ -169,13 +196,24 @@ export const Viewport = ({ if (dragSide.current === 'both' || dragSide.current === 'bottom') { targetRef.current!.style.height = `${dragStart.current![1] + e.clientY}px`; } + if (dragSide.current === 'both') { + scrollBoth?.scrollIntoView({ block: 'center', inline: 'center' }); + } + if (dragSide.current === 'right') { + scrollRight?.scrollIntoView({ block: 'center', inline: 'center' }); + } + if (dragSide.current === 'bottom') { + scrollBottom?.scrollIntoView({ block: 'center', inline: 'center' }); + } }; const onEnd = () => { window.removeEventListener('mouseup', onEnd); window.removeEventListener('mousemove', onDrag); setDragging('none'); - resize(`${targetRef.current!.clientWidth}px`, `${targetRef.current!.clientHeight}px`); + const { clientWidth, clientHeight } = targetRef.current!; + const scale = Number(targetRef.current!.dataset.scale) || 1; + resize(`${Math.round(clientWidth / scale)}px`, `${Math.round(clientHeight / scale)}px`); dragStart.current = undefined; }; @@ -196,6 +234,20 @@ export const Viewport = ({ return () => handles.forEach((el) => el?.removeEventListener('mousedown', onStart)); }, [resize]); + const parseNumber = (value: string) => { + const [match, number, unit] = value.match(/^(\d+(?:\.\d+)?)(\%|[a-z]{0,4})?$/) || []; + return match ? { number: Number(number), unit } : undefined; + }; + + const frameStyles = useMemo(() => { + const { number: nx, unit: ux = 'px' } = parseNumber(width) ?? { number: 0, unit: 'px' }; + const { number: ny, unit: uy = 'px' } = parseNumber(height) ?? { number: 0, unit: 'px' }; + return { + width: `${nx * scale}${ux}`, + height: `${ny * scale}${uy}`, + }; + }, [width, height, scale]); + return ( {!isDefault && ( @@ -204,7 +256,11 @@ export const Viewport = ({ + W + + } value={width} setValue={(value) => resize(value, height)} /> @@ -220,7 +276,11 @@ export const Viewport = ({ + H + + } value={height} setValue={(value) => resize(width, value)} /> @@ -241,21 +301,26 @@ export const Viewport = ({ -