mirror of
https://github.com/docmost/docmost.git
synced 2026-07-24 12:42:55 +10:00
ad00efe317
Cells could only enter edit mode via double-click or a physical keyboard, so touch devices had no way to edit a cell. Treat a touch/pen tap as the edit gesture, distinguishing a tap from a scroll by movement and branching per pointer type so mouse double-click stays unchanged. Also reveal the row expand button on hover-less devices so the row detail view stays reachable.
344 lines
10 KiB
TypeScript
344 lines
10 KiB
TypeScript
import { memo, useCallback, useMemo, useRef } from "react";
|
|
import { Cell } from "@tanstack/react-table";
|
|
import { Popover, Tooltip } from "@mantine/core";
|
|
import { IconArrowsDiagonal } from "@tabler/icons-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useAtom, useAtomValue, useSetAtom, type PrimitiveAtom } from "jotai";
|
|
import { selectAtom } from "jotai/utils";
|
|
import { IBaseRow, EditingCell, FocusedCell } from "@/ee/base/types/base.types";
|
|
import {
|
|
editingCellAtomFamily,
|
|
focusedCellAtomFamily,
|
|
activeFormulaEditorAtomFamily,
|
|
FormulaEditorTarget,
|
|
} from "@/ee/base/atoms/base-atoms";
|
|
import { FormulaPropertyEditor } from "@/ee/base/components/formula/formula-property-editor";
|
|
import {
|
|
isSystemPropertyType,
|
|
getDescriptor,
|
|
} from "@/ee/base/property-types/property-type.registry";
|
|
import { cellValuesEqual } from "@/ee/base/components/cells/cell-value-equal";
|
|
import { computeNextCell } from "@/ee/base/utils/grid-cell-nav";
|
|
import { useBaseEditable } from "@/ee/base/context/base-editable";
|
|
import { useRowExpand } from "@/ee/base/context/row-expand";
|
|
import { RowNumberCell } from "./row-number-cell";
|
|
import classes from "@/ee/base/styles/grid.module.css";
|
|
|
|
const TOUCH_TAP_SLOP_PX = 10;
|
|
|
|
type GridCellProps = {
|
|
cell: Cell<IBaseRow, unknown>;
|
|
rowIndex: number;
|
|
colIndex?: number;
|
|
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
|
pageId: string;
|
|
};
|
|
|
|
export const GridCell = memo(function GridCell({
|
|
cell,
|
|
rowIndex,
|
|
colIndex,
|
|
onCellUpdate,
|
|
pageId,
|
|
}: GridCellProps) {
|
|
const property = cell.column.columnDef.meta?.property;
|
|
const isRowNumber = cell.column.id === "__row_number";
|
|
const isPinned = cell.column.getIsPinned();
|
|
const pinOffset = isPinned ? cell.column.getStart("left") : undefined;
|
|
|
|
const [editingCell, setEditingCell] = useAtom(editingCellAtomFamily(pageId)) as unknown as [EditingCell, (val: EditingCell) => void];
|
|
const [activeFormulaEditor, setActiveFormulaEditor] = useAtom(
|
|
activeFormulaEditorAtomFamily(pageId),
|
|
) as unknown as [FormulaEditorTarget, (val: FormulaEditorTarget) => void];
|
|
|
|
const setFocusedCell = useSetAtom(focusedCellAtomFamily(pageId) as PrimitiveAtom<FocusedCell>);
|
|
const isFocused = useAtomValue(
|
|
useMemo(
|
|
() =>
|
|
selectAtom(
|
|
focusedCellAtomFamily(pageId),
|
|
(fc) => fc?.rowId === cell.row.id && fc?.propertyId === property?.id,
|
|
),
|
|
[pageId, cell.row.id, property?.id],
|
|
),
|
|
);
|
|
|
|
const { t } = useTranslation();
|
|
const editable = useBaseEditable();
|
|
const readOnly = !editable;
|
|
const onExpandRow = useRowExpand();
|
|
|
|
const rowId = cell.row.id;
|
|
const isEditing =
|
|
editingCell?.rowId === rowId &&
|
|
editingCell?.propertyId === property?.id &&
|
|
(editable || property?.type === "file");
|
|
|
|
const tapStartRef = useRef<{ x: number; y: number } | null>(null);
|
|
const suppressClickRef = useRef(false);
|
|
|
|
const handleDoubleClick = useCallback(() => {
|
|
if (!property || isRowNumber) return;
|
|
if (property.type === "checkbox") return;
|
|
if (readOnly) {
|
|
// Read-only: only the file cell opens (a download-only popover) so
|
|
// attachments stay reachable.
|
|
if (property.type === "file") {
|
|
setEditingCell({ rowId, propertyId: property.id });
|
|
}
|
|
return;
|
|
}
|
|
if (property.type === "formula") {
|
|
setActiveFormulaEditor({ propertyId: property.id, rowId });
|
|
return;
|
|
}
|
|
if (isSystemPropertyType(property.type)) return;
|
|
setEditingCell({ rowId, propertyId: property.id });
|
|
}, [property, isRowNumber, rowId, readOnly, setEditingCell, setActiveFormulaEditor]);
|
|
|
|
const handleMouseDown = useCallback(
|
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
if (!property || e.button !== 0) return;
|
|
setFocusedCell({ rowId, propertyId: property.id });
|
|
},
|
|
[property, rowId, setFocusedCell],
|
|
);
|
|
|
|
const handleClick = useCallback(
|
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
if (!property) return;
|
|
if (suppressClickRef.current) {
|
|
suppressClickRef.current = false;
|
|
return;
|
|
}
|
|
setFocusedCell({ rowId, propertyId: property.id });
|
|
(e.currentTarget.closest('[role="grid"]') as HTMLElement | null)?.focus({
|
|
preventScroll: true,
|
|
});
|
|
},
|
|
[property, rowId, setFocusedCell],
|
|
);
|
|
|
|
const handlePointerDown = useCallback(
|
|
(e: React.PointerEvent<HTMLDivElement>) => {
|
|
if (e.pointerType === "mouse") return;
|
|
suppressClickRef.current = false;
|
|
tapStartRef.current = { x: e.clientX, y: e.clientY };
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handlePointerUp = useCallback(
|
|
(e: React.PointerEvent<HTMLDivElement>) => {
|
|
if (e.pointerType === "mouse") return;
|
|
const start = tapStartRef.current;
|
|
tapStartRef.current = null;
|
|
if (!start) return;
|
|
if (
|
|
Math.abs(e.clientX - start.x) > TOUCH_TAP_SLOP_PX ||
|
|
Math.abs(e.clientY - start.y) > TOUCH_TAP_SLOP_PX
|
|
) {
|
|
return;
|
|
}
|
|
if ((e.target as HTMLElement).closest("button, a, input")) return;
|
|
suppressClickRef.current = true;
|
|
handleDoubleClick();
|
|
},
|
|
[handleDoubleClick],
|
|
);
|
|
|
|
const handlePointerCancel = useCallback(() => {
|
|
tapStartRef.current = null;
|
|
}, []);
|
|
|
|
const cellReadOnly = property
|
|
? readOnly || isSystemPropertyType(property.type)
|
|
: false;
|
|
|
|
const closeFormulaEditor = useCallback(
|
|
() => setActiveFormulaEditor(null),
|
|
[setActiveFormulaEditor],
|
|
);
|
|
|
|
const handleValueChange = useCallback(
|
|
(value: unknown) => {
|
|
if (!property) return;
|
|
if (!cellValuesEqual(value, cell.getValue())) {
|
|
onCellUpdate(rowId, property.id, value);
|
|
}
|
|
},
|
|
[property, rowId, cell, onCellUpdate],
|
|
);
|
|
|
|
const handleCommit = useCallback(
|
|
(value: unknown) => {
|
|
handleValueChange(value);
|
|
setEditingCell(null);
|
|
},
|
|
[handleValueChange, setEditingCell],
|
|
);
|
|
|
|
const handleCancel = useCallback(() => {
|
|
setEditingCell(null);
|
|
}, [setEditingCell]);
|
|
|
|
const handleTabNavigate = useCallback(
|
|
(shiftKey: boolean) => {
|
|
if (!property) return;
|
|
const tableInstance = cell.getContext().table;
|
|
const colIds = tableInstance
|
|
.getVisibleLeafColumns()
|
|
.filter((c) => c.id !== "__row_number")
|
|
.map((c) => c.id);
|
|
const rowIds = tableInstance.getRowModel().rows.map((r) => r.id);
|
|
const next = computeNextCell(
|
|
rowIds,
|
|
colIds,
|
|
{ rowId, propertyId: property.id },
|
|
0,
|
|
shiftKey ? -1 : 1,
|
|
true,
|
|
);
|
|
if (next) {
|
|
setEditingCell(next);
|
|
setFocusedCell(next);
|
|
}
|
|
},
|
|
[cell, rowId, property, setEditingCell, setFocusedCell],
|
|
);
|
|
|
|
if (isRowNumber) {
|
|
return (
|
|
<RowNumberCell
|
|
rowId={rowId}
|
|
rowIndex={rowIndex}
|
|
isPinned={Boolean(isPinned)}
|
|
pinOffset={pinOffset}
|
|
pageId={pageId}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (!property) return null;
|
|
|
|
const CellComponent = getDescriptor(property.type)?.cellComponent;
|
|
if (!CellComponent) return null;
|
|
|
|
const value = cell.getValue();
|
|
|
|
const cellInner = (
|
|
<div
|
|
id={`base-cell-${rowId}-${property.id}`}
|
|
role="gridcell"
|
|
aria-colindex={colIndex != null ? colIndex + 1 : undefined}
|
|
aria-readonly={cellReadOnly || undefined}
|
|
className={`${classes.cell} ${isPinned ? classes.cellPinned : ""} ${isEditing ? classes.cellEditing : ""} ${isFocused && !isEditing ? classes.cellFocused : ""} ${property.isPrimary ? classes.primaryCell : ""}`}
|
|
style={
|
|
isPinned
|
|
? ({ "--pin-offset": `${pinOffset}px` } as React.CSSProperties)
|
|
: undefined
|
|
}
|
|
onClick={handleClick}
|
|
onMouseDown={handleMouseDown}
|
|
onDoubleClick={handleDoubleClick}
|
|
onPointerDown={handlePointerDown}
|
|
onPointerUp={handlePointerUp}
|
|
onPointerCancel={handlePointerCancel}
|
|
>
|
|
<CellComponent
|
|
value={value}
|
|
property={property}
|
|
rowId={rowId}
|
|
isEditing={isEditing}
|
|
readOnly={readOnly}
|
|
onCommit={handleCommit}
|
|
onValueChange={handleValueChange}
|
|
onCancel={handleCancel}
|
|
onTabNavigate={handleTabNavigate}
|
|
/>
|
|
{property.isPrimary && onExpandRow && !isEditing && (
|
|
<span className={classes.rowExpandAnchor}>
|
|
<Tooltip label={t("Expand")} position="bottom" openDelay={400}>
|
|
<button
|
|
type="button"
|
|
tabIndex={-1}
|
|
className={classes.rowExpandButton}
|
|
onClick={() => onExpandRow(rowId)}
|
|
onDoubleClick={(e) => e.stopPropagation()}
|
|
aria-label={t("Expand row {{number}}", { number: rowIndex + 1 })}
|
|
>
|
|
<IconArrowsDiagonal size={13} />
|
|
</button>
|
|
</Tooltip>
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
if (property.type !== "formula") return cellInner;
|
|
|
|
const formulaEditorOpen =
|
|
activeFormulaEditor?.propertyId === property.id &&
|
|
activeFormulaEditor?.rowId === rowId;
|
|
|
|
return (
|
|
<Popover
|
|
opened={formulaEditorOpen}
|
|
onChange={(o) => {
|
|
if (!o) closeFormulaEditor();
|
|
}}
|
|
position="bottom-start"
|
|
width={460}
|
|
shadow="md"
|
|
withinPortal
|
|
closeOnClickOutside
|
|
closeOnEscape={false}
|
|
trapFocus
|
|
>
|
|
<Popover.Target>{cellInner}</Popover.Target>
|
|
<Popover.Dropdown
|
|
p={0}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onKeyDown={(e) => {
|
|
e.stopPropagation();
|
|
if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
closeFormulaEditor();
|
|
}
|
|
}}
|
|
style={{ maxWidth: "calc(100vw - 32px)" }}
|
|
>
|
|
{formulaEditorOpen && (
|
|
<FormulaPropertyEditor
|
|
property={property}
|
|
pageId={pageId}
|
|
onClose={closeFormulaEditor}
|
|
/>
|
|
)}
|
|
</Popover.Dropdown>
|
|
</Popover>
|
|
);
|
|
},
|
|
gridCellPropsEqual);
|
|
|
|
// Cell instances are re-created whenever the table data identity changes;
|
|
// compare by coordinates + value so unchanged cells skip re-rendering.
|
|
function gridCellPropsEqual(prev: GridCellProps, next: GridCellProps) {
|
|
if (
|
|
prev.rowIndex !== next.rowIndex ||
|
|
prev.colIndex !== next.colIndex ||
|
|
prev.pageId !== next.pageId ||
|
|
prev.onCellUpdate !== next.onCellUpdate
|
|
) {
|
|
return false;
|
|
}
|
|
if (prev.cell === next.cell) return true;
|
|
return (
|
|
prev.cell.row.id === next.cell.row.id &&
|
|
prev.cell.column.id === next.cell.column.id &&
|
|
prev.cell.column.columnDef.meta?.property ===
|
|
next.cell.column.columnDef.meta?.property &&
|
|
cellValuesEqual(prev.cell.getValue(), next.cell.getValue())
|
|
);
|
|
}
|