From c2016b4d18aa7d11101637e7c8ab5a1744731aa1 Mon Sep 17 00:00:00 2001 From: "Visal .In" Date: Fri, 29 Mar 2024 12:14:49 +0700 Subject: [PATCH] feat: navigate table via arrow key and tab (#56) --- src/components/query-result-table.tsx | 50 ++++++++++- .../table-cell/createEditableCell.tsx | 61 ++++++++++--- .../table-optimized/OptimizeTableState.tsx | 88 +++++++++++++++++++ src/components/table-optimized/index.tsx | 15 ++-- .../useTableVisibilityRecalculation.ts | 19 ++-- src/components/tabs/query-tab.tsx | 4 +- 6 files changed, 198 insertions(+), 39 deletions(-) diff --git a/src/components/query-result-table.tsx b/src/components/query-result-table.tsx index 2aa0f148..7ea410b7 100644 --- a/src/components/query-result-table.tsx +++ b/src/components/query-result-table.tsx @@ -136,6 +136,7 @@ export default function ResultTable({ const renderCell = useCallback( ({ y, x, state, header }: OptimizeTableCellRenderProps) => { const isFocus = state.hasFocus(y, x); + const editMode = isFocus && state.isInEditMode(); if (header.dataType === TableColumnDataType.TEXT) { const value = state.getValue(y, x) as DatabaseValue; @@ -147,8 +148,9 @@ export default function ResultTable({ return ( } focus={isFocus} isChanged={state.hasCellChange(y, x)} @@ -163,7 +165,8 @@ export default function ResultTable({ ) { return ( } focus={isFocus} isChanged={state.hasCellChange(y, x)} @@ -176,7 +179,7 @@ export default function ResultTable({ return ; }, - [tableName] + [] ); const onHeaderContextMenu = useCallback((e: React.MouseEvent) => { @@ -358,13 +361,54 @@ export default function ResultTable({ const onKeyDown = useCallback( (state: OptimizeTableState, e: React.KeyboardEvent) => { + if (state.isInEditMode()) return; + if (KEY_BINDING.copy.match(e as React.KeyboardEvent)) { copyCallback(state); } else if ( KEY_BINDING.paste.match(e as React.KeyboardEvent) ) { pasteCallback(state); + } else if (e.key === "ArrowRight") { + const focus = state.getFocus(); + if (focus && focus.x + 1 < state.getHeaderCount()) { + state.setFocus(focus.y, focus.x + 1); + state.scrollToFocusCell("right", "top"); + } + } else if (e.key === "ArrowLeft") { + const focus = state.getFocus(); + if (focus && focus.x - 1 >= 0) { + state.setFocus(focus.y, focus.x - 1); + state.scrollToFocusCell("left", "top"); + } + } else if (e.key === "ArrowUp") { + const focus = state.getFocus(); + if (focus && focus.y - 1 >= 0) { + state.setFocus(focus.y - 1, focus.x); + state.scrollToFocusCell("left", "top"); + } + } else if (e.key === "ArrowDown") { + const focus = state.getFocus(); + if (focus && focus.y + 1 < state.getRowsCount()) { + state.setFocus(focus.y + 1, focus.x); + state.scrollToFocusCell("left", "bottom"); + } + } else if (e.key === "Tab") { + const focus = state.getFocus(); + if (focus) { + const colCount = state.getHeaderCount(); + const n = focus.y * colCount + focus.x + 1; + const x = n % colCount; + const y = Math.floor(n / colCount); + if (y >= state.getRowsCount()) return; + state.setFocus(y, x); + state.scrollToFocusCell(x === 0 ? "left" : "right", "bottom"); + } + } else if (e.key === "Enter") { + state.enterEditMode(); } + + e.preventDefault(); }, [copyCallback, pasteCallback] ); diff --git a/src/components/table-cell/createEditableCell.tsx b/src/components/table-cell/createEditableCell.tsx index e3eb31a5..78555b76 100644 --- a/src/components/table-cell/createEditableCell.tsx +++ b/src/components/table-cell/createEditableCell.tsx @@ -1,15 +1,17 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import GenericCell from "./GenericCell"; import styles from "./styles.module.css"; import { DatabaseValue } from "@/drivers/base-driver"; import { useBlockEditor } from "@/context/block-editor-provider"; +import OptimizeTableState from "../table-optimized/OptimizeTableState"; export interface TableEditableCell { value: DatabaseValue; isChanged?: boolean; focus?: boolean; + editMode?: boolean; + state: OptimizeTableState; onChange?: (newValue: DatabaseValue) => void; - readOnly?: boolean; editor?: "input" | "blocknote"; } @@ -25,18 +27,31 @@ function InputCellEditor({ discardChange, applyChange, onChange, + state, }: Readonly<{ align?: "left" | "right"; - applyChange: (v: DatabaseValue) => void; + applyChange: (v: DatabaseValue, shouldExit?: boolean) => void; discardChange: () => void; value: DatabaseValue; onChange: (v: string) => void; + state: OptimizeTableState; }>) { + const inputRef = useRef(null); + const shouldExit = useRef(true); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.select(); + inputRef.current.focus(); + } + }, [inputRef]); + return ( { - applyChange(value); + applyChange(value, shouldExit.current); }} onChange={(e) => { onChange(e.currentTarget.value); @@ -44,8 +59,25 @@ function InputCellEditor({ onKeyDown={(e) => { if (e.key === "Enter") { applyChange(value); + e.stopPropagation(); } else if (e.key === "Escape") { discardChange(); + } else if (e.key === "Tab") { + // Enter the next cell + const focus = state.getFocus(); + if (focus) { + const colCount = state.getHeaderCount(); + const n = focus.y * colCount + focus.x + 1; + const x = n % colCount; + const y = Math.floor(n / colCount); + if (y >= state.getRowsCount()) return; + + shouldExit.current = false; + state.setFocus(y, x); + state.scrollToFocusCell(x === 0 ? "left" : "right", "bottom"); + e.preventDefault(); + e.stopPropagation(); + } } }} type="text" @@ -108,10 +140,10 @@ export default function createEditableCell({ isChanged, focus, onChange, - readOnly, + state, + editMode, editor, }: TableEditableCell) { - const [editMode, setEditMode] = useState(false); const [editValue, setEditValue] = useState>( toString(value) ); @@ -121,17 +153,19 @@ export default function createEditableCell({ }, [value]); const applyChange = useCallback( - (v: DatabaseValue) => { + (v: DatabaseValue, shouldExitEdit: boolean = true) => { if (onChange) onChange(toValue(v)); - setEditMode(false); + if (shouldExitEdit) { + state.exitEditMode(); + } }, - [onChange, setEditMode] + [onChange, state] ); const discardChange = useCallback(() => { setEditValue(toString(value)); - setEditMode(false); - }, [setEditValue, setEditMode, value]); + state.exitEditMode(); + }, [setEditValue, state, value]); const className = [ styles.cell, @@ -158,6 +192,7 @@ export default function createEditableCell({ return (
({ focus={focus} isChanged={isChanged} onDoubleClick={() => { - if (!readOnly) { - setEditMode(true); - } + state.enterEditMode(); }} /> ); diff --git a/src/components/table-optimized/OptimizeTableState.tsx b/src/components/table-optimized/OptimizeTableState.tsx index cadcee6d..07eaba73 100644 --- a/src/components/table-optimized/OptimizeTableState.tsx +++ b/src/components/table-optimized/OptimizeTableState.tsx @@ -22,6 +22,10 @@ export default class OptimizeTableState { protected selectedRows = new Set(); protected data: OptimizeTableRowValue[] = []; protected headers: OptimizeTableHeaderProps[] = []; + protected headerWidth: number[] = []; + protected editMode: boolean = false; + protected readOnlyMode: boolean = false; + protected container: HTMLDivElement | null = null; protected changeCallback: TableChangeEventCallback[] = []; protected changeDebounceTimerId: NodeJS.Timeout | null = null; @@ -78,6 +82,15 @@ export default class OptimizeTableState { this.data = data.map((row) => ({ raw: row, })); + this.headerWidth = headers.map((h) => h.initialSize); + } + + setReadOnlyMode(readOnly: boolean) { + this.readOnlyMode = readOnly; + } + + setContainer(div: HTMLDivElement | null) { + this.container = div; } // ------------------------------------------------ @@ -171,6 +184,10 @@ export default class OptimizeTableState { return this.data.length; } + getHeaderCount() { + return this.headers.length; + } + disardAllChange() { const newRows: OptimizeTableRowValue[] = []; @@ -296,11 +313,82 @@ export default class OptimizeTableState { this.broadcastChange(); } + isInEditMode() { + return this.editMode; + } + + enterEditMode() { + if (this.readOnlyMode) return; + this.editMode = true; + this.broadcastChange(); + } + + exitEditMode() { + this.editMode = false; + + if (this.container) { + this.container.focus(); + } + + this.broadcastChange(); + } + clearFocus() { this.focus = null; this.broadcastChange(); } + setHeaderWidth(idx: number, newWidth: number) { + return (this.headerWidth[idx] = newWidth); + } + + getHeaderWidth() { + return this.headerWidth; + } + + scrollToFocusCell(horizontal: "left" | "right", vertical: "top" | "bottom") { + if (this.container && this.focus) { + const cellX = this.focus[1]; + const cellY = this.focus[0]; + let cellLeft = 0; + let cellRight = 0; + const cellTop = (cellY + 1) * 38; + const cellBottom = cellTop + 38; + + for (let i = 0; i < cellX; i++) { + cellLeft += this.headerWidth[i]; + } + cellRight = cellLeft + this.headerWidth[cellX]; + + const width = this.container.clientWidth; + const height = this.container.clientHeight; + const containerLeft = this.container.scrollLeft; + const containerRight = containerLeft + this.container.clientWidth; + const containerTop = this.container.scrollTop; + const containerBottom = containerTop + height; + + if (horizontal === "right") { + if (cellRight > containerRight) { + this.container.scrollLeft = Math.max(0, cellRight - width); + } + } else { + if (cellLeft < containerLeft) { + this.container.scrollLeft = cellLeft; + } + } + + if (vertical === "bottom") { + if (cellBottom > containerBottom) { + this.container.scrollTop = Math.max(0, cellBottom - height); + } + } else { + if (cellTop - 38 < containerTop) { + this.container.scrollTop = Math.max(0, cellTop - 38); + } + } + } + } + // ------------------------------------------------ // Handle select row logic // ------------------------------------------------ diff --git a/src/components/table-optimized/index.tsx b/src/components/table-optimized/index.tsx index 65a90dba..f2f6e425 100644 --- a/src/components/table-optimized/index.tsx +++ b/src/components/table-optimized/index.tsx @@ -71,7 +71,6 @@ interface RenderCellListProps extends TableCellListCommonProps { headers: OptimizeTableHeaderWithIndexProps[]; rowEnd: number; rowStart: number; - headerSizes: number[]; colEnd: number; colStart: number; } @@ -115,7 +114,6 @@ function renderCellList({ hasSticky, headerIndex, customStyles, - headerSizes, headers, renderCell, rowEnd, @@ -128,6 +126,7 @@ function renderCellList({ internalState, onHeaderContextMenu, }: RenderCellListProps) { + const headerSizes = internalState.getHeaderWidth(); const headersWithIndex = headerIndex.map((idx) => headers[idx]); const templateSizes = headersWithIndex @@ -271,6 +270,10 @@ export default function OptimizeTable({ setRevision((prev) => prev + 1); }, [setRevision]); + useEffect(() => { + internalState.setContainer(containerRef.current); + }, [internalState, containerRef]); + useEffect(() => { const changeCallback = () => { rerender(); @@ -284,10 +287,6 @@ export default function OptimizeTable({ return internalState.getHeaders(); }, [internalState]); - const [headerSizes] = useState(() => { - return headers.map((header) => header.initialSize); - }); - const headerWithIndex = useMemo(() => { return headers.map((header, idx) => ({ ...header, @@ -298,11 +297,11 @@ export default function OptimizeTable({ const { visibileRange, onHeaderResize } = useTableVisibilityRecalculation({ containerRef, - headerSizes, headers: headerWithIndex, renderAhead, rowHeight, totalRowCount: internalState.getRowsCount(), + state: internalState, }); const { rowStart, rowEnd, colEnd, colStart } = visibileRange; @@ -319,7 +318,6 @@ export default function OptimizeTable({ return useMemo(() => { const common = { - headerSizes, headers: headerWithIndex, renderCell, rowEnd, @@ -367,7 +365,6 @@ export default function OptimizeTable({ colEnd, colStart, renderCell, - headerSizes, rowHeight, headerWithIndex, onHeaderResize, diff --git a/src/components/table-optimized/useTableVisibilityRecalculation.ts b/src/components/table-optimized/useTableVisibilityRecalculation.ts index 2b6fa1ad..bf4656b9 100644 --- a/src/components/table-optimized/useTableVisibilityRecalculation.ts +++ b/src/components/table-optimized/useTableVisibilityRecalculation.ts @@ -2,21 +2,22 @@ import { useCallback, useEffect, useState } from "react"; import { getVisibleCellRange } from "./helper"; import { OptimizeTableHeaderWithIndexProps } from "."; import useElementResize from "@/hooks/useElementResize"; +import OptimizeTableState from "./OptimizeTableState"; export default function useTableVisibilityRecalculation({ containerRef, totalRowCount, - headerSizes, rowHeight, renderAhead, headers, + state, }: { containerRef: React.RefObject; totalRowCount: number; - headerSizes: number[]; rowHeight: number; renderAhead: number; headers: OptimizeTableHeaderWithIndexProps[]; + state: OptimizeTableState; }) { const [visibleDebounce, setVisibleDebounce] = useState<{ rowStart: number; @@ -32,6 +33,7 @@ export default function useTableVisibilityRecalculation({ const recalculateVisible = useCallback( (e: HTMLDivElement) => { + const headerSizes = state.getHeaderWidth(); setVisibleDebounce( getVisibleCellRange( e, @@ -42,24 +44,17 @@ export default function useTableVisibilityRecalculation({ ) ); }, - [ - setVisibleDebounce, - totalRowCount, - rowHeight, - renderAhead, - headerSizes, - headers, - ] + [setVisibleDebounce, totalRowCount, rowHeight, renderAhead, headers, state] ); const onHeaderResize = useCallback( (idx: number, newWidth: number) => { if (containerRef.current) { - headerSizes[idx] = newWidth; + state.setHeaderWidth(idx, newWidth); recalculateVisible(containerRef.current); } }, - [headerSizes, recalculateVisible, containerRef] + [state, recalculateVisible, containerRef] ); // Recalculate the visibility again when we scroll the container diff --git a/src/components/tabs/query-tab.tsx b/src/components/tabs/query-tab.tsx index 22bba96b..b3679122 100644 --- a/src/components/tabs/query-tab.tsx +++ b/src/components/tabs/query-tab.tsx @@ -60,7 +60,9 @@ export default function QueryWindow() { }) .then(({ last }) => { if (last) { - setData(OptimizeTableState.createFromResult(last)); + const state = OptimizeTableState.createFromResult(last); + state.setReadOnlyMode(true); + setData(state); } }) .catch(console.error);