1- import React from 'react' ;
1+ import React , { useEffect , useRef , useState } from 'react' ;
22
33import { ActionList , PopoverProvider } from 'storybook/internal/components' ;
44
5- import { CloseAltIcon , TransferIcon } from '@storybook/icons' ;
5+ import { TransferIcon , UndoIcon } from '@storybook/icons' ;
66
77import { styled } from 'storybook/theming' ;
88
@@ -13,24 +13,23 @@ import { SizeInput } from './SizeInput';
1313
1414const ViewportWrapper = styled . div < {
1515 active : boolean ;
16- viewportWidth : string ;
17- viewportHeight : string ;
18- } > ( ( { active, viewportWidth, viewportHeight } ) => ( {
16+ isDefault : boolean ;
17+ } > ( ( { active, isDefault } ) => ( {
1918 gridArea : '1 / 1' ,
2019 alignSelf : 'start' ,
2120 justifySelf : 'start' ,
2221 display : active ? 'inline-flex' : 'none' ,
2322 flexDirection : 'column' ,
24- width : viewportWidth . endsWith ( '%' ) ? '100%' : 'auto' ,
25- height : viewportHeight . endsWith ( '%' ) ? '100%' : 'auto' ,
26- paddingTop : viewportHeight === '100%' ? 0 : 6 ,
27- paddingBottom : viewportHeight === '100%' ? 0 : 40 ,
28- paddingInline : viewportWidth === '100%' ? 0 : 40 ,
23+ gap : 6 ,
24+ width : '100%' ,
25+ height : '100%' ,
26+ paddingTop : isDefault ? 0 : 6 ,
27+ paddingBottom : isDefault ? 0 : 40 ,
28+ paddingInline : isDefault ? 0 : 40 ,
2929} ) ) ;
3030
3131const ViewportControls = styled . div ( {
3232 display : 'flex' ,
33- margin : '0 0 6px 6px' ,
3433 gap : 6 ,
3534} ) ;
3635
@@ -39,28 +38,48 @@ const ViewportDimensions = styled.div({
3938 gap : 2 ,
4039} ) ;
4140
42- const TimesIcon = styled ( CloseAltIcon ) ( {
43- padding : 2 ,
44- } ) ;
45-
4641const Dimensions = styled . div ( ( { theme } ) => ( {
4742 display : 'flex' ,
4843 gap : 2 ,
4944 fontFamily : theme . typography . fonts . mono ,
5045 fontSize : theme . typography . size . s1 - 1 ,
46+ fontWeight : theme . typography . weight . regular ,
5147 color : theme . textMutedColor ,
5248} ) ) ;
5349
54- const FrameWrapper = styled . div < { fullWidth : boolean ; fullHeight : boolean } > (
55- ( { fullWidth, fullHeight, theme } ) => ( {
56- display : 'inline-block' ,
57- border : `1px solid ${ theme . button . border } ` ,
58- borderRadius : fullWidth || fullHeight ? 0 : 4 ,
59- overflow : 'hidden' ,
60- ...( fullWidth && { borderLeftWidth : 0 , borderRightWidth : 0 , width : '100%' } ) ,
61- ...( fullHeight && { borderTopWidth : 0 , borderBottomWidth : 0 , height : '100%' } ) ,
62- } )
63- ) ;
50+ const FrameWrapper = styled . div < {
51+ isDefault : boolean ;
52+ 'data-dragging' : 'right' | 'bottom' | undefined ;
53+ } > ( ( { isDefault, 'data-dragging' : dragging , theme } ) => ( {
54+ position : 'relative' ,
55+ border : `1px solid ${ theme . button . border } ` ,
56+ borderWidth : isDefault ? 0 : 1 ,
57+ borderRadius : isDefault ? 0 : 4 ,
58+ transition : 'border-color 0.2s' ,
59+ '&:has([data-side="right"]:hover), &[data-dragging="right"]' : {
60+ borderRightColor : theme . color . secondary ,
61+ boxShadow : `4px 0 5px -2px ${ theme . background . hoverable } ` ,
62+ } ,
63+ '&:has([data-side="bottom"]:hover), &[data-dragging="bottom"]' : {
64+ borderBottomColor : theme . color . secondary ,
65+ boxShadow : `0 4px 5px -2px ${ theme . background . hoverable } ` ,
66+ } ,
67+ iframe : {
68+ borderRadius : 'inherit' ,
69+ pointerEvents : dragging ? 'none' : 'auto' ,
70+ } ,
71+ } ) ) ;
72+
73+ const DragHandle = styled . div < {
74+ 'data-side' : 'bottom' | 'right' ;
75+ } > ( ( { 'data-side' : side } ) => ( {
76+ position : 'absolute' ,
77+ right : side === 'right' ? - 12 : 0 ,
78+ bottom : side === 'bottom' ? - 12 : 0 ,
79+ width : side === 'right' ? 20 : '100%' ,
80+ height : side === 'bottom' ? 20 : '100%' ,
81+ cursor : side === 'right' ? 'col-resize' : 'row-resize' ,
82+ } ) ) ;
6483
6584export const Viewport = ( {
6685 active,
@@ -73,27 +92,81 @@ export const Viewport = ({
7392 src : string ;
7493 scale : number ;
7594} ) => {
76- const { name, type, width, height, isDefault, isLocked, options, select, rotate, resize } =
77- useViewport ( ) ;
95+ const {
96+ name,
97+ type,
98+ width,
99+ height,
100+ option,
101+ isCustom,
102+ isDefault,
103+ isLocked,
104+ options,
105+ lastSelectedOption,
106+ resize,
107+ rotate,
108+ select,
109+ } = useViewport ( ) ;
110+
111+ const [ dragging , setDragging ] = useState < undefined | 'right' | 'bottom' > ( undefined ) ;
112+ const targetRef = useRef < HTMLDivElement > ( null ) ;
113+ const dragRefX = useRef < HTMLDivElement > ( null ) ;
114+ const dragRefY = useRef < HTMLDivElement > ( null ) ;
115+ const dragSide = useRef < 'bottom' | 'right' > ( 'right' ) ;
116+ const dragStart = useRef < number | undefined > ( ) ;
117+
118+ useEffect ( ( ) => {
119+ const onDrag = ( e : MouseEvent ) => {
120+ if ( dragSide . current === 'right' ) {
121+ targetRef . current ! . style . width = `${ dragStart . current ! + e . clientX } px` ;
122+ } else {
123+ targetRef . current ! . style . height = `${ dragStart . current ! + e . clientY } px` ;
124+ }
125+ } ;
126+
127+ const onEnd = ( ) => {
128+ window . removeEventListener ( 'mouseup' , onEnd ) ;
129+ window . removeEventListener ( 'mousemove' , onDrag ) ;
130+ setDragging ( undefined ) ;
131+ resize ( targetRef . current ! . style . width , targetRef . current ! . style . height ) ;
132+ dragStart . current = undefined ;
133+ } ;
134+
135+ const onStart = ( e : MouseEvent ) => {
136+ e . preventDefault ( ) ;
137+ window . addEventListener ( 'mouseup' , onEnd ) ;
138+ window . addEventListener ( 'mousemove' , onDrag ) ;
139+ dragSide . current = ( e . currentTarget as HTMLElement ) . dataset . side as 'bottom' | 'right' ;
140+ dragStart . current =
141+ dragSide . current === 'right'
142+ ? ( targetRef . current ?. offsetWidth ?? 0 ) - e . clientX
143+ : ( targetRef . current ?. offsetHeight ?? 0 ) - e . clientY ;
144+ setDragging ( dragSide . current ) ;
145+ } ;
146+
147+ const handles = [ dragRefX . current , dragRefY . current ] ;
148+ handles . forEach ( ( el ) => el ?. addEventListener ( 'mousedown' , onStart ) ) ;
149+ return ( ) => handles . forEach ( ( el ) => el ?. removeEventListener ( 'mousedown' , onStart ) ) ;
150+ } , [ resize ] ) ;
78151
79152 return (
80- < ViewportWrapper key = { id } active = { active } viewportWidth = { width } viewportHeight = { height } >
81- { ! isDefault && height !== '100%' && (
82- < ViewportControls style = { width !== '100%' ? { marginLeft : 0 } : { } } >
153+ < ViewportWrapper key = { id } active = { active } isDefault = { isDefault } >
154+ { ! isDefault && (
155+ < ViewportControls >
83156 < PopoverProvider
84157 offset = { 4 }
85158 padding = { 0 }
86159 popover = { ( ) => (
87160 < ActionList style = { { minWidth : 240 } } >
88- { Object . entries ( options ) . map ( ( [ key , value ] ) => (
89- < ActionList . Item key = { key } >
161+ { Object . entries ( options ) . map ( ( [ key , { name , styles , type = 'other' } ] ) => (
162+ < ActionList . Item key = { key } active = { key === option } >
90163 < ActionList . Action ariaLabel = { false } onClick = { ( ) => select ( key ) } >
91- < ActionList . Icon > { iconsMap [ value . type ! ] } </ ActionList . Icon >
92- < ActionList . Text > { value . name } </ ActionList . Text >
164+ < ActionList . Icon > { iconsMap [ type ] } </ ActionList . Icon >
165+ < ActionList . Text > { name } </ ActionList . Text >
93166 < Dimensions >
94- < span > { value . styles . width . replace ( 'px' , '' ) } </ span >
167+ < span > { styles . width . replace ( 'px' , '' ) } </ span >
95168 < span > ×</ span >
96- < span > { value . styles . height . replace ( 'px' , '' ) } </ span >
169+ < span > { styles . height . replace ( 'px' , '' ) } </ span >
97170 </ Dimensions >
98171 </ ActionList . Action >
99172 </ ActionList . Item >
@@ -107,7 +180,7 @@ export const Viewport = ({
107180 disabled = { isLocked }
108181 readOnly = { isLocked }
109182 >
110- < ActionList . Icon > { iconsMap [ type ! ] } </ ActionList . Icon >
183+ < ActionList . Icon > { iconsMap [ type ] } </ ActionList . Icon >
111184 < ActionList . Text > { name } </ ActionList . Text >
112185 </ ActionList . Button >
113186 </ PopoverProvider >
@@ -119,33 +192,40 @@ export const Viewport = ({
119192 value = { width }
120193 setValue = { ( value ) => resize ( value , height ) }
121194 />
122-
123195 < ActionList . Button
124196 key = "viewport-rotate"
125197 size = "small"
126198 padding = "small"
127- variant = "ghost"
128- ariaLabel = { isLocked ? false : 'Rotate viewport' }
129- disabled = { isLocked }
130- readOnly = { isLocked }
199+ ariaLabel = "Rotate viewport"
131200 onClick = { rotate }
132201 >
133- { isLocked ? < TimesIcon /> : < TransferIcon /> }
202+ < TransferIcon />
134203 </ ActionList . Button >
135-
136204 < SizeInput
137205 label = "Viewport height:"
138206 prefix = "H"
139207 value = { height }
140208 setValue = { ( value ) => resize ( width , value ) }
141209 />
210+ { isCustom && lastSelectedOption && (
211+ < ActionList . Button
212+ key = "viewport-restore"
213+ size = "small"
214+ padding = "small"
215+ ariaLabel = "Restore viewport"
216+ onClick = { ( ) => select ( lastSelectedOption ) }
217+ >
218+ < UndoIcon />
219+ </ ActionList . Button >
220+ ) }
142221 </ ViewportDimensions >
143222 </ ViewportControls >
144223 ) }
145224 < FrameWrapper
146- fullWidth = { width === '100%' }
147- fullHeight = { height === '100%' }
225+ isDefault = { isDefault }
226+ data-dragging = { dragging }
148227 style = { { width, height } }
228+ ref = { targetRef }
149229 >
150230 < IFrame
151231 active = { active }
@@ -156,6 +236,8 @@ export const Viewport = ({
156236 allowFullScreen
157237 scale = { scale }
158238 />
239+ < DragHandle data-side = "right" ref = { dragRefX } />
240+ < DragHandle data-side = "bottom" ref = { dragRefY } />
159241 </ FrameWrapper >
160242 </ ViewportWrapper >
161243 ) ;
0 commit comments