Make autoScrolling explicit

Summary:
Changelog: Added an explicit autoscroll indicator in logs and fixed snapping

We got several reports that auto scrolling was to aggressive, so revisited the implementation and the new one is a lot more reliable. Also added an explicit indicator / button to toggle tailing.

Exposed ant's active color as well in our theme, as it gives better contrast on the buttons than Flipper purple.

Reviewed By: passy

Differential Revision: D27397506

fbshipit-source-id: 5e82939de4b2f8b89380bd55009e3fa2a7c10ec9
This commit is contained in:
Michel Weststrate
2021-03-31 03:42:59 -07:00
committed by Facebook GitHub Bot
parent 8cd38a6b49
commit 220ebbc601
9 changed files with 79 additions and 33 deletions

View File

@@ -66,6 +66,7 @@ export default function SandyDesignSystem() {
<ColorPreview name="textColorPrimary" /> <ColorPreview name="textColorPrimary" />
<ColorPreview name="textColorSecondary" /> <ColorPreview name="textColorSecondary" />
<ColorPreview name="textColorPlaceholder" /> <ColorPreview name="textColorPlaceholder" />
<ColorPreview name="textColorActive" />
<ColorPreview name="disabledColor" /> <ColorPreview name="disabledColor" />
<ColorPreview name="backgroundDefault" /> <ColorPreview name="backgroundDefault" />
<ColorPreview name="backgroundWash" /> <ColorPreview name="backgroundWash" />

View File

@@ -159,7 +159,7 @@ function TruncateHelper({
type="text" type="text"
style={truncateButtonStyle} style={truncateButtonStyle}
icon={collapsed ? <CaretRightOutlined /> : <CaretUpOutlined />}> icon={collapsed ? <CaretRightOutlined /> : <CaretUpOutlined />}>
{`(and ${value.length - maxLength} more...)`} {collapsed ? `and ${value.length - maxLength} more` : 'collapse'}
</Button> </Button>
<Button <Button
icon={<CopyOutlined />} icon={<CopyOutlined />}
@@ -169,13 +169,13 @@ function TruncateHelper({
size="small" size="small"
type="text" type="text"
style={truncateButtonStyle}> style={truncateButtonStyle}>
Copy copy
</Button> </Button>
</> </>
); );
} }
const truncateButtonStyle = { const truncateButtonStyle = {
color: theme.textColorPrimary, color: theme.primaryColor,
marginLeft: 4, marginLeft: 4,
}; };

View File

@@ -12,7 +12,7 @@ import styled from '@emotion/styled';
import React from 'react'; import React from 'react';
import {Button, Checkbox, Dropdown, Menu, Typography, Input} from 'antd'; import {Button, Checkbox, Dropdown, Menu, Typography, Input} from 'antd';
import { import {
FilterFilled, FilterOutlined,
MinusCircleOutlined, MinusCircleOutlined,
PlusCircleOutlined, PlusCircleOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
@@ -167,7 +167,7 @@ export function FilterIcon({
return ( return (
<Dropdown overlay={menu} trigger={['click']}> <Dropdown overlay={menu} trigger={['click']}>
<FilterButton isActive={isActive}> <FilterButton isActive={isActive}>
<FilterFilled /> <FilterOutlined />
</FilterButton> </FilterButton>
</Dropdown> </Dropdown>
); );
@@ -176,12 +176,12 @@ export function FilterIcon({
export const FilterButton = styled.div<{isActive?: boolean}>(({isActive}) => ({ export const FilterButton = styled.div<{isActive?: boolean}>(({isActive}) => ({
backgroundColor: theme.backgroundWash, backgroundColor: theme.backgroundWash,
visibility: isActive ? 'visible' : 'hidden', visibility: isActive ? 'visible' : 'hidden',
color: isActive ? theme.primaryColor : theme.disabledColor, color: isActive ? theme.textColorActive : theme.disabledColor,
cursor: 'pointer', cursor: 'pointer',
marginRight: 4, marginRight: 4,
zIndex: 1, zIndex: 1,
'&:hover': { '&:hover': {
color: theme.primaryColor, color: theme.textColorActive,
backgroundColor: theme.backgroundWash, backgroundColor: theme.backgroundWash,
}, },
})); }));

View File

@@ -66,6 +66,7 @@ type DataSourceProps<T extends object, C> = {
total: number, total: number,
offset: number, offset: number,
): void; ): void;
onUpdateAutoScroll?(autoScroll: boolean): void;
emptyRenderer?(dataSource: DataSource<T>): React.ReactElement; emptyRenderer?(dataSource: DataSource<T>): React.ReactElement;
_testHeight?: number; // exposed for unit testing only _testHeight?: number; // exposed for unit testing only
}; };
@@ -86,6 +87,7 @@ export const DataSourceRenderer: <T extends object, C>(
onKeyDown, onKeyDown,
virtualizerRef, virtualizerRef,
onRangeChange, onRangeChange,
onUpdateAutoScroll,
emptyRenderer, emptyRenderer,
_testHeight, _testHeight,
}: DataSourceProps<any, any>) { }: DataSourceProps<any, any>) {
@@ -211,7 +213,7 @@ export const DataSourceRenderer: <T extends object, C>(
useLayoutEffect(function updateWindow() { useLayoutEffect(function updateWindow() {
const start = virtualizer.virtualItems[0]?.index ?? 0; const start = virtualizer.virtualItems[0]?.index ?? 0;
const end = start + virtualizer.virtualItems.length; const end = start + virtualizer.virtualItems.length;
if (start !== dataSource.view.windowStart && !followOutput.current) { if (start !== dataSource.view.windowStart && !autoScroll) {
onRangeChange?.( onRangeChange?.(
start, start,
end, end,
@@ -225,29 +227,21 @@ export const DataSourceRenderer: <T extends object, C>(
/** /**
* Scrolling * Scrolling
*/ */
// if true, scroll if new items are appended
const followOutput = useRef(false);
// if true, the next scroll event will be fired as result of a size change,
// ignore it
const suppressScroll = useRef(false);
suppressScroll.current = true;
const onScroll = useCallback(() => { const onScroll = useCallback(() => {
// scroll event is firing as a result of painting new items? const elem = parentRef.current;
if (suppressScroll.current || !autoScroll) { if (!elem) {
return; return;
} }
const elem = parentRef.current!; const fromEnd = elem.scrollHeight - elem.scrollTop - elem.clientHeight;
// make bottom 1/3 of screen sticky if (autoScroll && fromEnd >= 1) {
if (elem.scrollTop < elem.scrollHeight - elem.clientHeight * 1.3) { onUpdateAutoScroll?.(false);
followOutput.current = false; } else if (!autoScroll && fromEnd < 1) {
} else { onUpdateAutoScroll?.(true);
followOutput.current = true;
} }
}, [autoScroll, parentRef]); }, [onUpdateAutoScroll, autoScroll]);
useLayoutEffect(function scrollToEnd() { useLayoutEffect(function scrollToEnd() {
if (followOutput.current && autoScroll) { if (autoScroll) {
virtualizer.scrollToIndex( virtualizer.scrollToIndex(
dataSource.view.size - 1, dataSource.view.size - 1,
/* smooth is not typed by react-virtual, but passed on to the DOM as it should*/ /* smooth is not typed by react-virtual, but passed on to the DOM as it should*/
@@ -263,7 +257,6 @@ export const DataSourceRenderer: <T extends object, C>(
* Render finalization * Render finalization
*/ */
useEffect(function renderCompleted() { useEffect(function renderCompleted() {
suppressScroll.current = false;
renderPending.current = UpdatePrio.NONE; renderPending.current = UpdatePrio.NONE;
lastRender.current = Date.now(); lastRender.current = Date.now();
}); });
@@ -295,7 +288,7 @@ export const DataSourceRenderer: <T extends object, C>(
*/ */
return ( return (
<RedrawContext.Provider value={redraw}> <RedrawContext.Provider value={redraw}>
<TableContainer onScroll={onScroll} ref={parentRef}> <TableContainer ref={parentRef} onScroll={onScroll}>
{virtualizer.virtualItems.length === 0 {virtualizer.virtualItems.length === 0
? emptyRenderer?.(dataSource) ? emptyRenderer?.(dataSource)
: null} : null}

View File

@@ -41,7 +41,7 @@ import styled from '@emotion/styled';
import {theme} from '../theme'; import {theme} from '../theme';
import {tableContextMenuFactory} from './TableContextMenu'; import {tableContextMenuFactory} from './TableContextMenu';
import {Typography} from 'antd'; import {Typography} from 'antd';
import {CoffeeOutlined, SearchOutlined} from '@ant-design/icons'; import {CoffeeOutlined, SearchOutlined, PushpinFilled} from '@ant-design/icons';
import {useAssertStableRef} from '../../utils/useAssertStableRef'; import {useAssertStableRef} from '../../utils/useAssertStableRef';
import {Formatter} from '../DataFormatter'; import {Formatter} from '../DataFormatter';
import {usePluginInstance} from '../../plugin/PluginContext'; import {usePluginInstance} from '../../plugin/PluginContext';
@@ -115,6 +115,7 @@ export function DataTable<T extends object>(
onSelect, onSelect,
scope, scope,
virtualizerRef, virtualizerRef,
autoScroll: props.autoScroll,
}), }),
); );
@@ -307,6 +308,7 @@ export function DataTable<T extends object>(
type: 'appliedInitialScroll', type: 'appliedInitialScroll',
}); });
} else if (selection && selection.current >= 0) { } else if (selection && selection.current >= 0) {
dispatch({type: 'setAutoScroll', autoScroll: false});
virtualizerRef.current?.scrollToIndex(selection!.current, { virtualizerRef.current?.scrollToIndex(selection!.current, {
align: 'auto', align: 'auto',
}); });
@@ -334,6 +336,15 @@ export function DataTable<T extends object>(
[], [],
); );
const onUpdateAutoScroll = useCallback(
(autoScroll: boolean) => {
if (props.autoScroll) {
dispatch({type: 'setAutoScroll', autoScroll});
}
},
[props.autoScroll],
);
/** Context menu */ /** Context menu */
const contexMenu = props._testHeight const contexMenu = props._testHeight
? undefined ? undefined
@@ -390,7 +401,7 @@ export function DataTable<T extends object>(
</Layout.Container> </Layout.Container>
<DataSourceRenderer<T, RenderContext<T>> <DataSourceRenderer<T, RenderContext<T>>
dataSource={dataSource} dataSource={dataSource}
autoScroll={props.autoScroll && !dragging.current} autoScroll={tableState.autoScroll && !dragging.current}
useFixedRowHeight={!tableState.usesWrapping} useFixedRowHeight={!tableState.usesWrapping}
defaultRowHeight={DEFAULT_ROW_HEIGHT} defaultRowHeight={DEFAULT_ROW_HEIGHT}
context={renderingConfig} context={renderingConfig}
@@ -398,10 +409,23 @@ export function DataTable<T extends object>(
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
virtualizerRef={virtualizerRef} virtualizerRef={virtualizerRef}
onRangeChange={onRangeChange} onRangeChange={onRangeChange}
onUpdateAutoScroll={onUpdateAutoScroll}
emptyRenderer={emptyRenderer} emptyRenderer={emptyRenderer}
_testHeight={props._testHeight} _testHeight={props._testHeight}
/> />
</Layout.Top> </Layout.Top>
{props.autoScroll && (
<AutoScroller>
<PushpinFilled
style={{
color: tableState.autoScroll ? theme.textColorActive : undefined,
}}
onClick={() => {
dispatch({type: 'toggleAutoScroll'});
}}
/>
</AutoScroller>
)}
{range && <RangeFinder>{range}</RangeFinder>} {range && <RangeFinder>{range}</RangeFinder>}
</Layout.Container> </Layout.Container>
); );
@@ -436,9 +460,20 @@ function EmptyTable({dataSource}: {dataSource: DataSource<any>}) {
const RangeFinder = styled.div({ const RangeFinder = styled.div({
backgroundColor: theme.backgroundWash, backgroundColor: theme.backgroundWash,
position: 'absolute', position: 'absolute',
right: 40, right: 64,
bottom: 20, bottom: 20,
padding: '4px 8px', padding: '4px 8px',
color: theme.textColorSecondary, color: theme.textColorSecondary,
fontSize: '0.8em', fontSize: '0.8em',
}); });
const AutoScroller = styled.div({
backgroundColor: theme.backgroundWash,
position: 'absolute',
right: 40,
bottom: 20,
width: 24,
padding: '4px 8px',
color: theme.textColorSecondary,
fontSize: '0.8em',
});

View File

@@ -40,6 +40,7 @@ type PersistedState = {
/** The default columns, but normalized */ /** The default columns, but normalized */
columns: Pick<DataTableColumn, 'key' | 'width' | 'filters' | 'visible'>[]; columns: Pick<DataTableColumn, 'key' | 'width' | 'filters' | 'visible'>[];
scrollOffset: number; scrollOffset: number;
autoScroll: boolean;
}; };
type Action<Name extends string, Args = {}> = {type: Name} & Args; type Action<Name extends string, Args = {}> = {type: Name} & Args;
@@ -79,7 +80,9 @@ type DataManagerActions<T> =
| Action<'toggleColumnFilter', {column: keyof T; index: number}> | Action<'toggleColumnFilter', {column: keyof T; index: number}>
| Action<'setColumnFilterFromSelection', {column: keyof T}> | Action<'setColumnFilterFromSelection', {column: keyof T}>
| Action<'appliedInitialScroll'> | Action<'appliedInitialScroll'>
| Action<'toggleUseRegex'>; | Action<'toggleUseRegex'>
| Action<'toggleAutoScroll'>
| Action<'setAutoScroll', {autoScroll: boolean}>;
type DataManagerConfig<T> = { type DataManagerConfig<T> = {
dataSource: DataSource<T>; dataSource: DataSource<T>;
@@ -87,6 +90,7 @@ type DataManagerConfig<T> = {
scope: string; scope: string;
onSelect: undefined | ((item: T | undefined, items: T[]) => void); onSelect: undefined | ((item: T | undefined, items: T[]) => void);
virtualizerRef: MutableRefObject<DataSourceVirtualizer | undefined>; virtualizerRef: MutableRefObject<DataSourceVirtualizer | undefined>;
autoScroll?: boolean;
}; };
type DataManagerState<T> = { type DataManagerState<T> = {
@@ -99,6 +103,7 @@ type DataManagerState<T> = {
selection: Selection; selection: Selection;
searchValue: string; searchValue: string;
useRegex: boolean; useRegex: boolean;
autoScroll: boolean;
}; };
export type DataTableReducer<T> = Reducer< export type DataTableReducer<T> = Reducer<
@@ -208,6 +213,14 @@ export const dataTableManagerReducer = produce<
draft.initialOffset = 0; draft.initialOffset = 0;
break; break;
} }
case 'toggleAutoScroll': {
draft.autoScroll = !draft.autoScroll;
break;
}
case 'setAutoScroll': {
draft.autoScroll = action.autoScroll;
break;
}
default: { default: {
throw new Error('Unknown action ' + (action as any).type); throw new Error('Unknown action ' + (action as any).type);
} }
@@ -307,6 +320,7 @@ export function createInitialState<T>(
: emptySelection, : emptySelection,
searchValue: prefs?.search ?? '', searchValue: prefs?.search ?? '',
useRegex: prefs?.useRegex ?? false, useRegex: prefs?.useRegex ?? false,
autoScroll: prefs?.autoScroll ?? config.autoScroll ?? false,
}; };
// @ts-ignore // @ts-ignore
res.config[immerable] = false; // optimization: never proxy anything in config res.config[immerable] = false; // optimization: never proxy anything in config
@@ -382,6 +396,7 @@ export function savePreferences(
visible: c.visible, visible: c.visible,
})), })),
scrollOffset, scrollOffset,
autoScroll: state.autoScroll,
}; };
localStorage.setItem(state.storageKey, JSON.stringify(prefs)); localStorage.setItem(state.storageKey, JSON.stringify(prefs));
} }

View File

@@ -72,7 +72,7 @@ const SortIconsContainer = styled.span<{direction?: 'asc' | 'desc'}>(
cursor: 'pointer', cursor: 'pointer',
color: theme.disabledColor, color: theme.disabledColor,
'.ant-table-column-sorter-up:hover, .ant-table-column-sorter-down:hover': { '.ant-table-column-sorter-up:hover, .ant-table-column-sorter-down:hover': {
color: theme.primaryColor, color: theme.textColorActive,
}, },
}), }),
); );

View File

@@ -18,6 +18,7 @@ export const theme = {
textColorPrimary: 'var(--flipper-text-color-primary)', textColorPrimary: 'var(--flipper-text-color-primary)',
textColorSecondary: 'var(--flipper-text-color-secondary)', textColorSecondary: 'var(--flipper-text-color-secondary)',
textColorPlaceholder: 'var(--flipper-text-color-placeholder)', textColorPlaceholder: 'var(--flipper-text-color-placeholder)',
textColorActive: 'var(--light-color-button-active)',
disabledColor: 'var(--flipper-disabled-color)', disabledColor: 'var(--flipper-disabled-color)',
backgroundDefault: 'var(--flipper-background-default)', backgroundDefault: 'var(--flipper-background-default)',
backgroundWash: 'var(--flipper-background-wash)', backgroundWash: 'var(--flipper-background-wash)',

View File

@@ -369,7 +369,8 @@
], ],
"code": [ "code": [
12, 12,
16 16,
20
], ],
"undo-outline": [ "undo-outline": [
16 16