diff --git a/v3/src/components/case-table/collection-table-model.ts b/v3/src/components/case-table/collection-table-model.ts index 51358f1a5..c332772f7 100644 --- a/v3/src/components/case-table/collection-table-model.ts +++ b/v3/src/components/case-table/collection-table-model.ts @@ -4,7 +4,7 @@ import { getNumericCssVariable } from "../../utilities/css-utils" import { symParent } from "../../models/data/data-set-types" const kDefaultRowHeaderHeight = 30 -const kDefaultRowHeight = 18 +export const kDefaultRowHeight = 18 const kDefaultRowCount = 12 const kDefaultGridHeight = kDefaultRowHeaderHeight + (kDefaultRowCount * kDefaultRowHeight) diff --git a/v3/src/components/case-table/collection-table.tsx b/v3/src/components/case-table/collection-table.tsx index e704e5fa6..b633a35ec 100644 --- a/v3/src/components/case-table/collection-table.tsx +++ b/v3/src/components/case-table/collection-table.tsx @@ -1,6 +1,6 @@ import { comparer } from "mobx" import { observer } from "mobx-react-lite" -import React, { useCallback, useEffect, useMemo, useRef } from "react" +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import DataGrid, { CellKeyboardEvent, DataGridHandle } from "react-data-grid" import { kCollectionTableBodyDropZoneBaseId } from "./case-table-drag-drop" import { @@ -33,6 +33,9 @@ import { t } from "../../utilities/translation/translate" import { useCaseTableModel } from "./use-case-table-model" import { useCollectionTableModel } from "./use-collection-table-model" import { useWhiteSpaceClick } from "./use-white-space-click" +import { collectionCaseIdFromIndex, collectionCaseIndexFromId, selectCases, setSelectedCases } + from "../../models/data/data-set-utils" +import { kDefaultRowHeight } from "./collection-table-model" import "react-data-grid/lib/styles.css" import styles from "./case-table-shared.scss" @@ -60,9 +63,14 @@ export const CollectionTable = observer(function CollectionTable(props: IProps) .getPropertyValue("--rdg-row-selected-background-color") || undefined const visibleAttributes = useVisibleAttributes(collectionId) const { selectedRows, setSelectedRows, handleCellClick } = useSelectedRows({ gridRef, onScrollClosestRowIntoView }) - const { handleWhiteSpaceClick } = useWhiteSpaceClick({ gridRef }) + const { clearCurrentSelection } = useWhiteSpaceClick({ gridRef }) const forceUpdate = useForceUpdate() const { isTileSelected } = useTileModelContext() + const [isSelectDragging, setIsSelectDragging] = useState(false) + const [isDragging, setIsDragging] = useState(false) //This prevents the grid click handler from firing on mouse up + const [initialMousePosition, setInitialMousePosition] = useState({ x: 0, y: 0 }) + const [lastMousePosition, setLastMousePosition] = useState({ x: 0, y: 0 }) // Track last mouse position + const [initialDirection, setInitialDirection] = useState<'up' | 'down' | null>(null) useEffect(function setGridElement() { const element = gridRef.current?.element @@ -192,22 +200,139 @@ export const CollectionTable = observer(function CollectionTable(props: IProps) event.preventGridDefault() navigateToNextRow(event.shiftKey) } + if ((event.key === "ArrowDown" || event.key === "ArrowUp")) { + const caseId = args.row.__id__ + const isCaseSelected = data?.isCaseSelected(caseId) + const isExtending = event.shiftKey || event.altKey || event.metaKey + const currentSelectionIdx = collectionCaseIndexFromId(caseId, data, collectionId) + + if (currentSelectionIdx !== undefined && currentSelectionIdx !== null) { + const nextIndex = event.key === "ArrowDown" ? currentSelectionIdx + 1 : currentSelectionIdx - 1 + const nextCaseId = collectionCaseIdFromIndex(nextIndex, data, collectionId) + if (nextCaseId) { + const isNextCaseSelected = data?.isCaseSelected(nextCaseId) + if (isExtending) { + if (isNextCaseSelected) { + selectCases([caseId], data, !isCaseSelected) + } else { + selectCases([nextCaseId], data) + } + } else { + setSelectedCases([nextCaseId], data) + } + } + } + } } const handleClick = (event: React.MouseEvent) => { + if (isDragging) { + setIsDragging(false) + return + } // the grid element is the target when clicking outside the cells (otherwise, the cell is the target) if (isTileSelected() && event.target === gridRef.current?.element) { - handleWhiteSpaceClick() + clearCurrentSelection() + } + } + + const handleMouseDown = (event: React.MouseEvent) => { + const isExtending = event.shiftKey || event.altKey || event.metaKey + setIsSelectDragging(true) + setInitialMousePosition({ x: event.clientX, y: event.clientY }) + setLastMousePosition({ x: event.clientX, y: event.clientY }) // Initialize last mouse position + setInitialDirection(null) // Reset the initial direction + + if (!isExtending) { + clearCurrentSelection() // clear current selection + } + const target = event.target as HTMLDivElement + const closestDataCell = target.closest('.codap-data-cell') + const className = closestDataCell ? closestDataCell.className : "" + const caseId = className.split(" ").find(c => c.startsWith("rowId-"))?.split("-")[1] + if (caseId) { + if (selectedRows.size > 0) { + selectCases([caseId], data) + } else { + setSelectedCases([caseId], data) + } } } + const handleMouseMove = (event: React.MouseEvent) => { + const mouseMoveThreshold = Math.max(kDefaultRowHeight / 3, 5) + if (isSelectDragging) { + setIsDragging(true) + // Check if the mouse is inside the grid's boundaries + const gridElement = gridRef.current?.element + if (gridElement) { + const gridBounds = gridElement.getBoundingClientRect() + const { clientX, clientY } = event + if ( + clientX >= gridBounds.left && + clientX <= gridBounds.right && + clientY >= gridBounds.top && + clientY <= gridBounds.bottom + ) { + const yDiffFromInitial = clientY - initialMousePosition.y + const yDiffFromLast = clientY - lastMousePosition.y // Difference from the last mouse position + const currentDirection = yDiffFromLast > mouseMoveThreshold + ? 'down' + : yDiffFromLast < -mouseMoveThreshold ? 'up' + : null + if (!initialDirection && Math.abs(yDiffFromInitial) > mouseMoveThreshold) { + setInitialDirection(currentDirection) + } + + if (currentDirection) { + const target = event.target as HTMLDivElement + const closestDataCell = target.closest('.codap-data-cell') + const className = closestDataCell ? closestDataCell.className : "" + const caseId = className.split(" ").find(c => c.startsWith("rowId-"))?.split("-")[1] + + if (caseId) { + const caseIndex = collectionCaseIndexFromId(caseId, data, collectionId) + // Reset the initial direction to allow for reverse selection in the case of a user first + // moving the mouse up and then down or vice versa past the initially selected case + if ( + (initialDirection === 'down' && currentDirection === 'up' && clientY < initialMousePosition.y) || + (initialDirection === 'up' && currentDirection === 'down' && clientY > initialMousePosition.y) + ) { + setInitialDirection(currentDirection) + } + + if (currentDirection === initialDirection) { + selectCases([caseId], data) + } else { + // Deselect if moving in the opposite direction + if (initialDirection === 'down') { + const nextCaseId = caseIndex && collectionCaseIdFromIndex(caseIndex + 1, data, collectionId) + nextCaseId && selectCases([nextCaseId], data, false) + } else if (initialDirection === 'up') { + const prevCaseId = caseIndex && collectionCaseIdFromIndex(caseIndex - 1, data, collectionId) + prevCaseId && selectCases([prevCaseId], data, false) + } + } + } + setLastMousePosition({ x: clientX, y: clientY }) + } + } + } + } + } + + const handleMouseUp = (event: React.MouseEvent) => { + setIsSelectDragging(false) + } + if (!data || !rows || !visibleAttributes.length) return null return (
-
+ onWhiteSpaceClick={clearCurrentSelection} onDrop={handleNewCollectionDrop} /> +
{ resizable: true, headerCellClass: `codap-column-header`, renderHeaderCell: ColumnHeader, - cellClass: "codap-data-cell", + cellClass: row => `codap-data-cell rowId-${row.__id__}`, renderCell: AttributeValueCell, editable: row => isCaseEditable(data, row.__id__), renderEditCell: isEditable diff --git a/v3/src/components/case-table/use-white-space-click.ts b/v3/src/components/case-table/use-white-space-click.ts index 3a6ff6aaa..809d0caac 100644 --- a/v3/src/components/case-table/use-white-space-click.ts +++ b/v3/src/components/case-table/use-white-space-click.ts @@ -36,7 +36,7 @@ export function useWhiteSpaceClick({ gridRef }: IProps) { }) }, [componentRef, data]) - const handleWhiteSpaceClick = useCallback(() => { + const clearCurrentSelection = useCallback(() => { if (!wasFocusedTileRef.current && isFocusedTileRef.current) { // Focused the table, do nothing with the selection wasFocusedTileRef.current = true @@ -45,5 +45,5 @@ export function useWhiteSpaceClick({ gridRef }: IProps) { clearCaseSelection() }, [clearCaseSelection]) - return { handleWhiteSpaceClick } + return { clearCurrentSelection } } diff --git a/v3/src/components/case-tile-common/index-menu-list.tsx b/v3/src/components/case-tile-common/index-menu-list.tsx index 10c289d93..0c0135563 100644 --- a/v3/src/components/case-tile-common/index-menu-list.tsx +++ b/v3/src/components/case-tile-common/index-menu-list.tsx @@ -58,7 +58,7 @@ export const IndexMenuList = ({caseId, index}: IProps) => { itemKey: `DG.CaseTable.indexMenu.delete${deletableSelectedItems.length === 1 ? "Case" : "Cases" }`, isEnabled: () => deletableSelectedItems.length >= 1, handleClick: () => { - if (data?.selection.size) { + if (deletableSelectedItems && data) { removeCasesWithCustomUndoRedo(data, deletableSelectedItems) } }