@ -2,13 +2,23 @@
* Note that it assumes the presence of cssVars . cssRootVars on < body > .
* /
import * as commands from 'app/client/components/commands' ;
import { watchElementForBlur } from 'app/client/lib/FocusLayer' ;
import { urlState } from "app/client/models/gristUrlState" ;
import { resizeFlexVHandle } from 'app/client/ui/resizeHandle' ;
import { transition , TransitionWatcher } from 'app/client/ui/transitions' ;
import { colors , cssHideForNarrowScreen , mediaNotSmall , mediaSmall } from 'app/client/ui2018/cssVars' ;
import { isNarrowScreenObs } from 'app/client/ui2018/cssVars' ;
import { icon } from 'app/client/ui2018/icons' ;
import { dom , DomArg , noTestId , Observable , styled , subscribe , TestId } from "grainjs" ;
import { dom , DomArg , MultiHolder , noTestId , Observable , styled , subscribe , TestId } from "grainjs" ;
import noop from 'lodash/noop' ;
import once from 'lodash/once' ;
import { SessionObs } from 'app/client/lib/sessionObs' ;
import debounce from 'lodash/debounce' ;
const AUTO_EXPAND_TIMEOUT_MS = 400 ;
// delay must be greater than the time needed for transientInput to update focus (ie: 10ms);
const DELAY_BEFORE_TESTING_FOCUS_CHANGE_MS = 12 ;
export interface PageSidePanel {
// Note that widths need to start out with a correct default in JS (having them in CSS is not
@ -38,10 +48,13 @@ export function pagePanels(page: PageContents) {
const left = page . leftPanel ;
const right = page . rightPanel ;
const onResize = page . onResize || ( ( ) = > null ) ;
const leftOverlap = Observable . create ( null , false ) ;
const dragResizer = Observable . create ( null , false ) ;
let lastLeftOpen = left . panelOpen . get ( ) ;
let lastRightOpen = right ? . panelOpen . get ( ) || false ;
let leftPaneDom : HTMLElement ;
let onLeftTransitionFinish = noop ;
// When switching to mobile mode, close panels; when switching to desktop, restore the
// last desktop state.
@ -62,6 +75,10 @@ export function pagePanels(page: PageContents) {
}
} ) ;
const pauseSavingLeft = ( yesNo : boolean ) = > {
( left . panelOpen as SessionObs < boolean > ) ? . pauseSaving ? . ( yesNo ) ;
} ;
const commandsGroup = commands . createGroup ( {
leftPanelOpen : ( ) = > new Promise ( ( resolve ) = > {
const watcher = new TransitionWatcher ( leftPaneDom ) ;
@ -69,40 +86,125 @@ export function pagePanels(page: PageContents) {
left . panelOpen . set ( true ) ;
} ) ,
} , null , true ) ;
let contentWrapper : HTMLElement ;
return cssPageContainer (
dom . autoDispose ( sub1 ) ,
dom . autoDispose ( sub2 ) ,
dom . autoDispose ( commandsGroup ) ,
dom . autoDispose ( leftOverlap ) ,
page . contentTop ,
cssContentMain (
leftPaneDom = cssLeftPane (
testId ( 'left-panel' ) ,
cssTopHeader ( left . header ) ,
left . content ,
cssOverflowContainer (
contentWrapper = cssLeftPanelContainer (
cssTopHeader ( left . header ) ,
left . content ,
) ,
) ,
// Show plain border when the resize handle is hidden.
cssResizeDisabledBorder (
dom . hide ( ( use ) = > use ( left . panelOpen ) && ! use ( leftOverlap ) ) ,
cssHideForNarrowScreen . cls ( '' ) ,
testId ( 'left-disabled-resizer' ) ,
) ,
dom . style ( 'width' , ( use ) = > use ( left . panelOpen ) ? use ( left . panelWidth ) + 'px' : '' ) ,
// Opening/closing the left pane, with transitions.
cssLeftPane . cls ( '-open' , left . panelOpen ) ,
transition ( use = > ( use ( isNarrowScreenObs ( ) ) ? false : use ( left . panelOpen ) ) , {
prepare ( elem , open ) { elem . style . marginRight = ( open ? - 1 : 1 ) * ( left . panelWidth . get ( ) - 48 ) + 'px' ; } ,
run ( elem , open ) { elem . style . marginRight = '' ; } ,
finish : onResize ,
prepare ( elem , open ) {
elem . style . width = ( open ? 48 : left.panelWidth.get ( ) ) + 'px' ;
} ,
run ( elem , open ) {
elem . style . width = contentWrapper . style . width = ( open ? left . panelWidth . get ( ) : 48 ) + 'px' ;
} ,
finish() {
onResize ( ) ;
contentWrapper . style . width = '' ;
onLeftTransitionFinish ( ) ;
} ,
} ) ,
// opening left panel on over
dom . on ( 'mouseenter' , ( _ev , elem ) = > {
if ( left . panelOpen . get ( ) ) { return ; }
let isMouseInsideLeftPane = true ;
let isFocusInsideLeftPane = false ;
let isMouseDragging = false ;
const owner = new MultiHolder ( ) ;
const startExpansion = ( ) = > {
leftOverlap . set ( true ) ;
pauseSavingLeft ( true ) ; // prevents from updating state in the window storage
left . panelOpen . set ( true ) ;
onLeftTransitionFinish = noop ;
watchBlur ( ) ;
} ;
const startCollapse = ( ) = > {
left . panelOpen . set ( false ) ;
pauseSavingLeft ( false ) ;
// turns overlap off only when the transition finishes
onLeftTransitionFinish = once ( ( ) = > leftOverlap . set ( false ) ) ;
clear ( ) ;
} ;
const clear = ( ) = > {
if ( owner . isDisposed ( ) ) { return ; }
clearTimeout ( timeoutId ) ;
owner . dispose ( ) ;
} ;
dom . onDisposeElem ( elem , clear ) ;
// updates isFocusInsideLeftPane and starts watch for blur on activeElement.
const watchBlur = debounce ( ( ) = > {
if ( owner . isDisposed ( ) ) { return ; }
// console.warn('watchBlur', document.activeElement);
isFocusInsideLeftPane = Boolean ( leftPaneDom . contains ( document . activeElement ) ||
document . activeElement ? . closest ( '.grist-floating-menu' ) ) ;
maybeStartCollapse ( ) ;
if ( document . activeElement ) {
maybePatchDomAndChangeFocus ( ) ; // This is to support projects test environment
watchElementForBlur ( document . activeElement , watchBlur ) ;
}
} , DELAY_BEFORE_TESTING_FOCUS_CHANGE_MS ) ;
// starts collapsed only if neither mouse nor focus are inside the left pane. Return true
// if started collapsed, false otherwise.
const maybeStartCollapse = ( ) = > {
if ( ! isMouseInsideLeftPane && ! isFocusInsideLeftPane && ! isMouseDragging ) {
startCollapse ( ) ;
}
} ;
// mouse events
const onMouseEvt = ( evt : MouseEvent ) = > {
const rect = leftPaneDom . getBoundingClientRect ( ) ;
isMouseInsideLeftPane = evt . clientX <= rect . right ;
isMouseDragging = evt . buttons !== 0 ;
maybeStartCollapse ( ) ;
} ;
owner . autoDispose ( dom . onElem ( document , 'mousemove' , onMouseEvt ) ) ;
owner . autoDispose ( dom . onElem ( document , 'mouseup' , onMouseEvt ) ) ;
// schedule start of expansion
const timeoutId = setTimeout ( startExpansion , AUTO_EXPAND_TIMEOUT_MS ) ;
} ) ,
cssLeftPane . cls ( '-overlap' , leftOverlap ) ,
cssLeftPane . cls ( '-dragging' , dragResizer ) ,
) ,
// Resizer for the left pane.
// TODO: resizing to small size should collapse. possibly should allow expanding too
cssResizeFlexVHandle (
{ target : 'left' , onSave : ( val ) = > { left . panelWidth . set ( val ) ; onResize ( ) ; } } ,
{ target : 'left' , onSave : ( val ) = > { left . panelWidth . set ( val ) ; onResize ( ) ;
leftPaneDom . style [ 'width' ] = val + 'px' ;
setTimeout ( ( ) = > dragResizer . set ( false ) , 0 ) ; } ,
onDrag : ( val ) = > { dragResizer . set ( true ) ; } } ,
testId ( 'left-resizer' ) ,
dom . show ( left . panelOpen ) ,
cssHideForNarrowScreen . cls ( '' ) ) ,
// Show plain border when the resize handle is hidden.
cssResizeDisabledBorder (
dom . hide ( left . panelOpen ) ,
dom . show ( ( use ) = > use ( left . panelOpen ) && ! use ( leftOverlap ) ) ,
cssHideForNarrowScreen . cls ( '' ) ) ,
cssMainPane (
@ -126,6 +228,7 @@ export function pagePanels(page: PageContents) {
) ,
) ,
page . contentMain ,
cssMainPane . cls ( '-left-overlap' , leftOverlap ) ,
testId ( 'main-pane' ) ,
) ,
( right ? [
@ -236,8 +339,8 @@ export const cssLeftPane = styled(cssVBox, `
background - color : $ { colors . lightGrey } ;
width : 48px ;
margin - right : 0px ;
overflow: hidden ;
transition: margin - right 0.4 s ;
transition: width 0.4 s ;
will- change : width ;
@media $ { mediaSmall } {
& {
width : 240px ;
@ -258,8 +361,6 @@ export const cssLeftPane = styled(cssVBox, `
}
& - open {
width : 240px ;
min - width : 160px ;
max - width : 320px ;
}
@media print {
& {
@ -269,6 +370,23 @@ export const cssLeftPane = styled(cssVBox, `
. interface - light & {
display : none ;
}
& - overlap {
position : fixed ;
z - index : 10 ;
top : 0 ;
bottom : 0 ;
left : 0 ;
min - width : unset ;
}
& - dragging {
transition : unset ;
min - width : 160px ;
max - width : 320px ;
}
` );
const cssOverflowContainer = styled ( cssVBox , `
overflow : hidden ;
flex : 1 1 0 px ;
` );
const cssMainPane = styled ( cssVBox , `
position : relative ;
@ -276,6 +394,9 @@ const cssMainPane = styled(cssVBox, `
min - width : 0px ;
background - color : white ;
z - index : 1 ;
& - left - overlap {
margin - left : 48px ;
}
` );
const cssRightPane = styled ( cssVBox , `
position : relative ;
@ -378,6 +499,11 @@ const cssResizeDisabledBorder = styled('div', `
width : 1px ;
height : 100 % ;
background - color : $ { colors . mediumGrey } ;
position : absolute ;
top : 0 ;
bottom : 0 ;
right : - 1 px ;
z - index : 2 ;
` );
const cssPanelOpener = styled ( icon , `
flex : none ;
@ -423,3 +549,28 @@ const cssContentOverlay = styled('div', `
}
}
` );
const cssLeftPanelContainer = styled ( 'div' , `
flex : 1 1 0 px ;
display : flex ;
flex - direction : column ;
` );
const cssHiddenInput = styled ( 'input' , `
position : absolute ;
top : - 100 px ;
left : 0 ;
width : 10px ;
height : 10px ;
font - size : 1 ;
z - index : - 1 ;
` );
// watchElementForBlur does not work if focus is on body. Which never happens when running in Grist
// because focus is constantly given to the copypasteField. But it does happen when running inside a
// projects test. For that latter case we had a hidden <input> field to the dom and give it focus.
function maybePatchDomAndChangeFocus() {
if ( document . activeElement ? . matches ( 'body' ) ) {
const hiddenInput = cssHiddenInput ( ) ;
document . body . appendChild ( hiddenInput ) ;
hiddenInput . focus ( ) ;
}
}