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:
committed by
Facebook GitHub Bot
parent
8cd38a6b49
commit
220ebbc601
@@ -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" />
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -369,7 +369,8 @@
|
|||||||
],
|
],
|
||||||
"code": [
|
"code": [
|
||||||
12,
|
12,
|
||||||
16
|
16,
|
||||||
|
20
|
||||||
],
|
],
|
||||||
"undo-outline": [
|
"undo-outline": [
|
||||||
16
|
16
|
||||||
|
|||||||
Reference in New Issue
Block a user