diff --git a/app/client/lib/FocusLayer.ts b/app/client/lib/FocusLayer.ts index 4aea1f39..3d2330fc 100644 --- a/app/client/lib/FocusLayer.ts +++ b/app/client/lib/FocusLayer.ts @@ -186,7 +186,7 @@ export class FocusLayer extends Disposable implements FocusLayerOptions { * Because elements getting removed from the DOM don't always trigger 'blur' event, this also * uses MutationObserver to watch for the element to get removed from DOM. */ -function watchElementForBlur(elem: Element, callback: () => void) { +export function watchElementForBlur(elem: Element, callback: () => void) { const maybeDone = () => { if (document.activeElement !== elem) { lis.dispose(); diff --git a/app/client/lib/sessionObs.ts b/app/client/lib/sessionObs.ts index ab69281e..3c23c93d 100644 --- a/app/client/lib/sessionObs.ts +++ b/app/client/lib/sessionObs.ts @@ -5,6 +5,10 @@ import {safeJsonParse} from 'app/common/gutil'; import {IDisposableOwner, Observable} from 'grainjs'; +export interface SessionObs extends Observable { + pauseSaving(yesNo: boolean): void; +} + /** * Creates and returns an Observable tied to sessionStorage, to make its value stick across * reloads and navigation, but differ across browser tabs. E.g. whether a side pane is open. @@ -20,13 +24,19 @@ import {IDisposableOwner, Observable} from 'grainjs'; * import {StringUnion} from 'app/common/StringUnion'; * const SomeTab = StringUnion("foo", "bar", "baz"); * tab = createSessionObs(owner, "tab", "baz", SomeTab.guard); // Type Observable<"foo"|"bar"|"baz"> + * + * You can disable saving to sessionStorage: + * panelWidth.pauseSaving(true); + * doStuff(); + * panelWidth.pauseSaving(false); + * */ export function createSessionObs( owner: IDisposableOwner|null, key: string, _default: T, isValid: (val: any) => val is T, -) { +): SessionObs { function fromString(value: string|null): T { const parsed = value == null ? null : safeJsonParse(value, null); return isValid(parsed) ? parsed : _default; @@ -34,9 +44,10 @@ export function createSessionObs( function toString(value: T): string|null { return value === _default || !isValid(value) ? null : JSON.stringify(value); } - + let _pauseSaving = false; const obs = Observable.create(owner, fromString(window.sessionStorage.getItem(key))); obs.addListener((value: T) => { + if (_pauseSaving) { return; } const stored = toString(value); if (stored == null) { window.sessionStorage.removeItem(key); @@ -44,7 +55,7 @@ export function createSessionObs( window.sessionStorage.setItem(key, stored); } }); - return obs; + return Object.assign(obs, {pauseSaving(yesNo: boolean) { _pauseSaving = yesNo; }}); } /** Helper functions to check simple types, useful for the `isValid` argument to createSessionObs. */ diff --git a/app/client/ui/AppHeader.ts b/app/client/ui/AppHeader.ts index 43d3357e..d7a2bb07 100644 --- a/app/client/ui/AppHeader.ts +++ b/app/client/ui/AppHeader.ts @@ -138,7 +138,7 @@ const cssDropdownIcon = styled(icon, ` `); const cssOrg = styled('div', ` - display: flex; + display: none; flex-grow: 1; align-items: center; max-width: calc(100% - 48px); @@ -149,6 +149,10 @@ const cssOrg = styled('div', ` &:hover { background-color: ${colors.mediumGrey}; } + + .${cssLeftPane.className}-open & { + display: flex; + } `); const cssOrgName = styled('div', ` diff --git a/app/client/ui/PagePanels.ts b/app/client/ui/PagePanels.ts index a105aacb..8e39a00f 100644 --- a/app/client/ui/PagePanels.ts +++ b/app/client/ui/PagePanels.ts @@ -2,13 +2,23 @@ * Note that it assumes the presence of cssVars.cssRootVars on . */ 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)?.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.4s; + transition: width 0.4s; + 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 0px; `); 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: -1px; + z-index: 2; `); const cssPanelOpener = styled(icon, ` flex: none; @@ -423,3 +549,28 @@ const cssContentOverlay = styled('div', ` } } `); +const cssLeftPanelContainer = styled('div', ` + flex: 1 1 0px; + display: flex; + flex-direction: column; +`); +const cssHiddenInput = styled('input', ` + position: absolute; + top: -100px; + 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 field to the dom and give it focus. +function maybePatchDomAndChangeFocus() { + if (document.activeElement?.matches('body')) { + const hiddenInput = cssHiddenInput(); + document.body.appendChild(hiddenInput); + hiddenInput.focus(); + } +}