Improve mouse tracking (#1061)

* Improve mouse tracking

* Fix lint

* Fix regression on Filters

* Fix according to review
This commit is contained in:
Charles Bochet
2023-08-03 10:36:11 -07:00
committed by GitHub
parent 21e3d8fcac
commit 2b21e05524
27 changed files with 208 additions and 141 deletions

View File

@ -14,9 +14,9 @@ import { PersonChip } from '@/people/components/PersonChip';
import { useFilteredSearchPeopleQuery } from '@/people/queries';
import { MultipleEntitySelect } from '@/ui/input/relation-picker/components/MultipleEntitySelect';
import { RelationPickerHotkeyScope } from '@/ui/input/relation-picker/types/RelationPickerHotkeyScope';
import { useListenClickOutside } from '@/ui/utilities/click-outside/hooks/useListenClickOutside';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { Activity, ActivityTarget, CommentableType } from '~/generated/graphql';
import { assertNotNull } from '~/utils/assert';

View File

@ -3,7 +3,7 @@ import { useSetRecoilState } from 'recoil';
import { useSetEntityTableData } from '@/ui/table/hooks/useSetEntityTableData';
import { entityTableDimensionsState } from '@/ui/table/states/entityTableDimensionsState';
import { viewFieldsFamilyState } from '@/ui/table/states/viewFieldsState';
import { viewFieldsState } from '@/ui/table/states/viewFieldsState';
import { companyViewFields } from '../../constants/companyViewFields';
@ -13,7 +13,7 @@ export function CompanyTableMockData() {
const setEntityTableDimensions = useSetRecoilState(
entityTableDimensionsState,
);
const setViewFields = useSetRecoilState(viewFieldsFamilyState);
const setViewFields = useSetRecoilState(viewFieldsState);
const setEntityTableData = useSetEntityTableData();
setEntityTableData(mockedCompaniesData, []);

View File

@ -2,8 +2,8 @@ import { ReactElement, useRef } from 'react';
import styled from '@emotion/styled';
import { overlayBackground } from '@/ui/theme/constants/effects';
import { useListenClickOutside } from '@/ui/utilities/click-outside/hooks/useListenClickOutside';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { BoardCardFieldHotkeyScope } from '../types/BoardCardFieldHotkeyScope';

View File

@ -7,7 +7,7 @@ import { DropdownMenuItemsContainer } from '@/ui/dropdown/components/DropdownMen
import { DropdownMenuSelectableItem } from '@/ui/dropdown/components/DropdownMenuSelectableItem';
import DropdownButton from '@/ui/filter-n-sort/components/DropdownButton';
import { icon } from '@/ui/theme/constants/icon';
import { useListenClickOutside } from '@/ui/utilities/click-outside/hooks/useListenClickOutside';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { BoardColumnEditTitleMenu } from './BoardColumnEditTitleMenu';

View File

@ -63,18 +63,20 @@ export function BoardHeader<SortField>({
{viewName}
</>
}
rightComponents={[
<FilterDropdownButton
context={context}
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
/>,
<SortDropdownButton<SortField>
isSortSelected={sorts.length > 0}
availableSorts={availableSorts || []}
onSortSelect={sortSelect}
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
/>,
]}
rightComponent={
<>
<FilterDropdownButton
context={context}
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
/>
<SortDropdownButton<SortField>
isSortSelected={sorts.length > 0}
availableSorts={availableSorts || []}
onSortSelect={sortSelect}
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
/>
</>
}
bottomComponent={
<SortAndFilterBar
context={context}

View File

@ -1,5 +1,5 @@
import { useListenClickOutside } from '@/ui/utilities/click-outside/hooks/useListenClickOutside';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { EditableFieldHotkeyScope } from '../types/EditableFieldHotkeyScope';

View File

@ -1,7 +1,7 @@
import { useRef } from 'react';
import styled from '@emotion/styled';
import { useListenClickOutside } from '@/ui/utilities/click-outside/hooks/useListenClickOutside';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { DropdownMenu } from '../../dropdown/components/DropdownMenu';

View File

@ -7,7 +7,7 @@ import { DropdownMenuItem } from '@/ui/dropdown/components/DropdownMenuItem';
import { DropdownMenuItemsContainer } from '@/ui/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearch } from '@/ui/dropdown/components/DropdownMenuSearch';
import { DropdownMenuSeparator } from '@/ui/dropdown/components/DropdownMenuSeparator';
import { useListenClickOutside } from '@/ui/utilities/click-outside/hooks/useListenClickOutside';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { Avatar } from '@/users/components/Avatar';
import { isNonEmptyString } from '~/utils/isNonEmptyString';

View File

@ -7,7 +7,7 @@ import { DropdownMenuItemsContainer } from '@/ui/dropdown/components/DropdownMen
import { DropdownMenuSearch } from '@/ui/dropdown/components/DropdownMenuSearch';
import { DropdownMenuSeparator } from '@/ui/dropdown/components/DropdownMenuSeparator';
import { IconPlus } from '@/ui/icon';
import { useListenClickOutside } from '@/ui/utilities/click-outside/hooks/useListenClickOutside';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from '~/utils/isDefined';
import { useEntitySelectSearch } from '../hooks/useEntitySelectSearch';

View File

@ -5,7 +5,7 @@ import { motion } from 'framer-motion';
import {
ClickOutsideMode,
useListenClickOutside,
} from '@/ui/utilities/click-outside/hooks/useListenClickOutside';
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
const StyledContainer = styled.div`
align-items: center;

View File

@ -5,11 +5,11 @@ import { motion } from 'framer-motion';
import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import {
ClickOutsideMode,
useListenClickOutside,
} from '@/ui/utilities/click-outside/hooks/useListenClickOutside';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { isDefined } from '~/utils/isDefined';

View File

@ -1,15 +1,12 @@
import { useCallback, useRef } from 'react';
import { useRef } from 'react';
import styled from '@emotion/styled';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { SelectedSortType, SortType } from '@/ui/filter-n-sort/types/interface';
import { useListenClickOutside } from '@/ui/utilities/click-outside/hooks/useListenClickOutside';
import { useUpdateViewFieldMutation } from '~/generated/graphql';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useLeaveTableFocus } from '../hooks/useLeaveTableFocus';
import { useMapKeyboardToSoftFocus } from '../hooks/useMapKeyboardToSoftFocus';
import { EntityUpdateMutationHookContext } from '../states/EntityUpdateMutationHookContext';
import { viewFieldsFamilyState } from '../states/viewFieldsState';
import { TableHeader } from '../table-header/components/TableHeader';
import { EntityTableBody } from './EntityTableBody';
@ -102,11 +99,6 @@ export function EntityTable<SortField>({
onSortsUpdate,
useUpdateEntityMutation,
}: OwnProps<SortField>) {
const viewFields = useRecoilValue(viewFieldsFamilyState);
const setViewFields = useSetRecoilState(viewFieldsFamilyState);
const [updateViewFieldMutation] = useUpdateViewFieldMutation();
const tableBodyRef = useRef<HTMLDivElement>(null);
useMapKeyboardToSoftFocus();
@ -120,25 +112,6 @@ export function EntityTable<SortField>({
},
});
const handleColumnResize = useCallback(
(resizedFieldId: string, width: number) => {
setViewFields((previousViewFields) =>
previousViewFields.map((viewField) =>
viewField.id === resizedFieldId
? { ...viewField, columnSize: width }
: viewField,
),
);
updateViewFieldMutation({
variables: {
data: { sizeInPx: width },
where: { id: resizedFieldId },
},
});
},
[setViewFields, updateViewFieldMutation],
);
return (
<EntityUpdateMutationHookContext.Provider value={useUpdateEntityMutation}>
<StyledTableWithHeader>
@ -150,15 +123,10 @@ export function EntityTable<SortField>({
onSortsUpdate={onSortsUpdate}
/>
<StyledTableWrapper>
{viewFields.length > 0 && (
<StyledTable>
<EntityTableHeader
onColumnResize={handleColumnResize}
viewFields={viewFields}
/>
<EntityTableBody />
</StyledTable>
)}
<StyledTable>
<EntityTableHeader />
<EntityTableBody />
</StyledTable>
</StyledTableWrapper>
</StyledTableContainer>
</StyledTableWithHeader>

View File

@ -34,14 +34,7 @@ export function EntityTableCell({ cellIndex }: { cellIndex: number }) {
return (
<RecoilScope>
<ColumnIndexContext.Provider value={cellIndex}>
<td
onContextMenu={(event) => handleContextMenu(event)}
style={{
width: viewField.columnSize,
minWidth: viewField.columnSize,
maxWidth: viewField.columnSize,
}}
>
<td onContextMenu={(event) => handleContextMenu(event)}>
<GenericEditableCell viewField={viewField} />
</td>
</ColumnIndexContext.Provider>

View File

@ -1,10 +1,17 @@
import { PointerEvent, useCallback, useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import styled from '@emotion/styled';
import {
useRecoilCallback,
useRecoilState,
useRecoilValue,
useSetRecoilState,
} from 'recoil';
import type {
ViewFieldDefinition,
ViewFieldMetadata,
} from '../types/ViewField';
import { useTrackPointer } from '@/ui/utilities/pointer-event/hooks/useTrackPointer';
import { useUpdateViewFieldMutation } from '~/generated/graphql';
import { resizeFieldOffsetState } from '../states/resizeFieldOffsetState';
import { viewFieldsState } from '../states/viewFieldsState';
import { ColumnHead } from './ColumnHead';
import { SelectAllCheckbox } from './SelectAllCheckbox';
@ -42,12 +49,11 @@ const StyledResizeHandler = styled.div`
z-index: 1;
`;
type OwnProps = {
onColumnResize: (resizedFieldId: string, width: number) => void;
viewFields: ViewFieldDefinition<ViewFieldMetadata>[];
};
export function EntityTableHeader() {
const viewFields = useRecoilValue(viewFieldsState);
const setViewFields = useSetRecoilState(viewFieldsState);
export function EntityTableHeader({ onColumnResize, viewFields }: OwnProps) {
const [updateViewFieldMutation] = useUpdateViewFieldMutation();
const columnWidths = useMemo(
() =>
viewFields.reduce<Record<string, number>>(
@ -59,46 +65,75 @@ export function EntityTableHeader({ onColumnResize, viewFields }: OwnProps) {
),
[viewFields],
);
const [isResizing, setIsResizing] = useState(false);
const [initialPointerPositionX, setInitialPointerPositionX] = useState<
number | null
>(null);
const [resizedFieldId, setResizedFieldId] = useState<string | null>(null);
const [offset, setOffset] = useState(0);
const [offset, setOffset] = useRecoilState(resizeFieldOffsetState);
const handleResizeHandlerDragStart = useCallback(
(event: PointerEvent<HTMLDivElement>, fieldId: string) => {
setIsResizing(true);
setResizedFieldId(fieldId);
setInitialPointerPositionX(event.clientX);
const handleColumnResize = useCallback(
(resizedFieldId: string, width: number) => {
setViewFields((previousViewFields) =>
previousViewFields.map((viewField) =>
viewField.id === resizedFieldId
? { ...viewField, columnSize: width }
: viewField,
),
);
updateViewFieldMutation({
variables: {
data: { sizeInPx: width },
where: { id: resizedFieldId },
},
});
},
[setIsResizing, setResizedFieldId, setInitialPointerPositionX],
[setViewFields, updateViewFieldMutation],
);
const handleResizeHandlerDrag = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (!isResizing || initialPointerPositionX === null) return;
setOffset(event.clientX - initialPointerPositionX);
const handleResizeHandlerStart = useCallback(
(positionX: number, _: number) => {
setInitialPointerPositionX(positionX);
},
[isResizing, initialPointerPositionX],
[],
);
const handleResizeHandlerDragEnd = useCallback(() => {
setIsResizing(false);
if (!resizedFieldId) return;
const handleResizeHandlerMove = useCallback(
(positionX: number, _positionY: number) => {
if (!initialPointerPositionX) return;
setOffset(positionX - initialPointerPositionX);
},
[setOffset, initialPointerPositionX],
);
const nextWidth = Math.round(
Math.max(columnWidths[resizedFieldId] + offset, COLUMN_MIN_WIDTH),
);
const handleResizeHandlerEnd = useRecoilCallback(
({ snapshot, set }) =>
(_positionX: number, _positionY: number) => {
if (!resizedFieldId) return;
const nextWidth = Math.round(
Math.max(
columnWidths[resizedFieldId] +
snapshot.getLoadable(resizeFieldOffsetState).valueOrThrow(),
COLUMN_MIN_WIDTH,
),
);
if (nextWidth !== columnWidths[resizedFieldId]) {
onColumnResize(resizedFieldId, nextWidth);
}
if (nextWidth !== columnWidths[resizedFieldId]) {
handleColumnResize(resizedFieldId, nextWidth);
}
set(resizeFieldOffsetState, 0);
setInitialPointerPositionX(null);
setResizedFieldId(null);
},
[resizedFieldId, columnWidths, setResizedFieldId, handleColumnResize],
);
setOffset(0);
}, [resizedFieldId, columnWidths, offset, onColumnResize]);
useTrackPointer({
shouldTrackPointer: resizedFieldId !== null,
onMouseDown: handleResizeHandlerStart,
onMouseMove: handleResizeHandlerMove,
onMouseUp: handleResizeHandlerEnd,
});
return (
<thead>
@ -116,7 +151,7 @@ export function EntityTableHeader({ onColumnResize, viewFields }: OwnProps) {
{viewFields.map((viewField) => (
<StyledColumnHeaderCell
key={viewField.columnOrder.toString()}
isResizing={isResizing && resizedFieldId === viewField.id}
isResizing={resizedFieldId === viewField.id}
style={{
width: Math.max(
columnWidths[viewField.id] +
@ -132,12 +167,9 @@ export function EntityTableHeader({ onColumnResize, viewFields }: OwnProps) {
<StyledResizeHandler
className="cursor-col-resize"
role="separator"
onPointerDown={(event) =>
handleResizeHandlerDragStart(event, viewField.id)
}
onPointerMove={handleResizeHandlerDrag}
onPointerOut={handleResizeHandlerDragEnd}
onPointerUp={handleResizeHandlerDragEnd}
onPointerDown={() => {
setResizedFieldId(viewField.id);
}}
/>
</StyledColumnHeaderCell>
))}

View File

@ -2,7 +2,7 @@ import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { ViewFieldContext } from '../states/ViewFieldContext';
import { viewFieldsFamilyState } from '../states/viewFieldsState';
import { viewFieldsState } from '../states/viewFieldsState';
import { CheckboxCell } from './CheckboxCell';
import { EntityTableCell } from './EntityTableCell';
@ -13,7 +13,7 @@ const StyledRow = styled.tr<{ selected: boolean }>`
`;
export function EntityTableRow({ rowId }: { rowId: string }) {
const viewFields = useRecoilValue(viewFieldsFamilyState);
const viewFields = useRecoilValue(viewFieldsState);
return (
<StyledRow data-testid={`row-id-${rowId}`} selected={false}>

View File

@ -33,7 +33,6 @@ export function GenericEntityTableData({
variables: { orderBy, where: whereFilters },
onCompleted: (data: any) => {
const entities = data[getRequestResultKey] ?? [];
setEntityTableData(entities, filterDefinitionArray);
},
});

View File

@ -1,5 +1,5 @@
import { useListenClickOutside } from '@/ui/utilities/click-outside/hooks/useListenClickOutside';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useMoveSoftFocus } from '../../hooks/useMoveSoftFocus';
import { TableHotkeyScope } from '../../types/TableHotkeyScope';

View File

@ -4,8 +4,8 @@ import { Key } from 'ts-key-enum';
import { DateInputEdit } from '@/ui/input/date/components/DateInputEdit';
import { TableHotkeyScope } from '@/ui/table/types/TableHotkeyScope';
import { useListenClickOutside } from '@/ui/utilities/click-outside/hooks/useListenClickOutside';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useEditableCell } from '../../hooks/useEditableCell';

View File

@ -9,7 +9,7 @@ import {
} from '~/generated/graphql';
import { entityTableDimensionsState } from '../states/entityTableDimensionsState';
import { viewFieldsFamilyState } from '../states/viewFieldsState';
import { viewFieldsState } from '../states/viewFieldsState';
import {
ViewFieldDefinition,
ViewFieldMetadata,
@ -32,7 +32,7 @@ export const useLoadView = ({
const setEntityTableDimensions = useSetRecoilState(
entityTableDimensionsState,
);
const setViewFields = useSetRecoilState(viewFieldsFamilyState);
const setViewFields = useSetRecoilState(viewFieldsState);
const [createViewFieldsMutation] = useCreateViewFieldsMutation();

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const resizeFieldOffsetState = atom<number>({
key: 'resizeFieldOffsetState',
default: 0,
});

View File

@ -2,9 +2,7 @@ import { atom } from 'recoil';
import { ViewFieldDefinition, ViewFieldMetadata } from '../types/ViewField';
export const viewFieldsFamilyState = atom<
ViewFieldDefinition<ViewFieldMetadata>[]
>({
key: 'viewFieldsFamilyState',
export const viewFieldsState = atom<ViewFieldDefinition<ViewFieldMetadata>[]>({
key: 'viewFieldsState',
default: [],
});

View File

@ -64,18 +64,20 @@ export function TableHeader<SortField>({
</>
}
displayBottomBorder={false}
rightComponents={[
<FilterDropdownButton
context={TableContext}
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
/>,
<SortDropdownButton<SortField>
isSortSelected={sorts.length > 0}
availableSorts={availableSorts || []}
onSortSelect={sortSelect}
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
/>,
]}
rightComponent={
<>
<FilterDropdownButton
context={TableContext}
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
/>
<SortDropdownButton<SortField>
isSortSelected={sorts.length > 0}
availableSorts={availableSorts || []}
onSortSelect={sortSelect}
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
/>
</>
}
bottomComponent={
<SortAndFilterBar
context={TableContext}

View File

@ -3,7 +3,7 @@ import styled from '@emotion/styled';
type OwnProps = {
leftComponent?: ReactNode;
rightComponents?: ReactNode[];
rightComponent?: ReactNode;
bottomComponent?: ReactNode;
displayBottomBorder?: boolean;
};
@ -40,7 +40,7 @@ const StyledRightSection = styled.div`
export function TopBar({
leftComponent,
rightComponents,
rightComponent,
bottomComponent,
displayBottomBorder = true,
}: OwnProps) {
@ -48,7 +48,7 @@ export function TopBar({
<StyledContainer>
<StyledTopBar displayBottomBorder={displayBottomBorder}>
<StyledLeftSection>{leftComponent}</StyledLeftSection>
<StyledRightSection>{rightComponents}</StyledRightSection>
<StyledRightSection>{rightComponent}</StyledRightSection>
</StyledTopBar>
{bottomComponent}
</StyledContainer>

View File

@ -0,0 +1,67 @@
import { useCallback, useEffect } from 'react';
type MouseListener = (positionX: number, positionY: number) => void;
export function useTrackPointer({
shouldTrackPointer = true,
onMouseMove,
onMouseDown,
onMouseUp,
}: {
shouldTrackPointer?: boolean;
onMouseMove?: MouseListener;
onMouseDown?: MouseListener;
onMouseUp?: MouseListener;
}) {
const extractPosition = useCallback((event: MouseEvent | TouchEvent) => {
const clientX =
'clientX' in event ? event.clientX : event.changedTouches[0].clientX;
const clientY =
'clientY' in event ? event.clientY : event.changedTouches[0].clientY;
return { clientX, clientY };
}, []);
const onInternalMouseMove = useCallback(
(event: MouseEvent | TouchEvent) => {
const { clientX, clientY } = extractPosition(event);
onMouseMove?.(clientX, clientY);
},
[onMouseMove, extractPosition],
);
const onInternalMouseDown = useCallback(
(event: MouseEvent | TouchEvent) => {
const { clientX, clientY } = extractPosition(event);
onMouseDown?.(clientX, clientY);
},
[onMouseDown, extractPosition],
);
const onInternalMouseUp = useCallback(
(event: MouseEvent | TouchEvent) => {
const { clientX, clientY } = extractPosition(event);
onMouseUp?.(clientX, clientY);
},
[onMouseUp, extractPosition],
);
useEffect(() => {
if (shouldTrackPointer) {
document.addEventListener('mousemove', onInternalMouseMove);
document.addEventListener('mousedown', onInternalMouseDown);
document.addEventListener('mouseup', onInternalMouseUp);
return () => {
document.removeEventListener('mousemove', onInternalMouseMove);
document.removeEventListener('mousedown', onInternalMouseDown);
document.removeEventListener('mouseup', onInternalMouseUp);
};
}
}, [
shouldTrackPointer,
onInternalMouseMove,
onInternalMouseDown,
onInternalMouseUp,
]);
}

View File

@ -54,13 +54,13 @@ export function Tasks() {
<TabList context={TasksContext} tabs={TASK_TABS} />
</StyledTabListContainer>
}
rightComponents={[
rightComponent={
<FilterDropdownButton
key="tasks-filter-dropdown-button"
context={TasksContext}
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
/>,
]}
/>
}
/>
<TaskGroups />
</RecoilScope>