From 978bae745bf439f0ba320092fe6187654d8bd033 Mon Sep 17 00:00:00 2001 From: Iddan Aaronsohn Date: Fri, 28 Apr 2023 02:00:48 +0300 Subject: [PATCH 1/3] AutoFill Handle Add auto-fill handle to autofill values --- src/ActiveCell.tsx | 5 +++ src/FloatingRect.tsx | 25 +++++++++---- src/Selected.tsx | 56 ++++++++++++++++++++++++++--- src/Spreadsheet.css | 23 +++++++++++- src/actions.ts | 18 +++++++++- src/reducer.test.ts | 34 ++++++++++++++++++ src/reducer.ts | 85 +++++++++++++++++++++++++++++++++++++++----- src/types.ts | 1 + src/util.test.ts | 1 + 9 files changed, 227 insertions(+), 21 deletions(-) diff --git a/src/ActiveCell.tsx b/src/ActiveCell.tsx index adb3d2d3..4e6074ac 100644 --- a/src/ActiveCell.tsx +++ b/src/ActiveCell.tsx @@ -16,20 +16,25 @@ const ActiveCell: React.FC = (props) => { const rootRef = React.useRef(null); const dispatch = useDispatch(); + const setCellData = React.useCallback( (active: Point.Point, data: Types.CellBase) => dispatch(Actions.setCellData(active, data)), [dispatch] ); + const edit = React.useCallback(() => dispatch(Actions.edit()), [dispatch]); + const commit = React.useCallback( (changes: Types.CommitChanges) => dispatch(Actions.commit(changes)), [dispatch] ); + const view = React.useCallback(() => { dispatch(Actions.view()); }, [dispatch]); + const active = useSelector((state) => state.active); const mode = useSelector((state) => state.mode); const cell = useSelector((state) => diff --git a/src/FloatingRect.tsx b/src/FloatingRect.tsx index 9bd256ea..3f2c6e55 100644 --- a/src/FloatingRect.tsx +++ b/src/FloatingRect.tsx @@ -7,24 +7,37 @@ export type Props = { dimensions?: Types.Dimensions | null | undefined; hidden?: boolean; dragging?: boolean; + autoFilling?: boolean; + className: string; + children?: React.ReactNode; }; const FloatingRect: React.FC = ({ dimensions, dragging, + autoFilling, hidden, variant, + className, + children, }) => { const { width, height, top, left } = dimensions || {}; return (
+ > + {children} +
); }; diff --git a/src/Selected.tsx b/src/Selected.tsx index f610847d..3327924e 100644 --- a/src/Selected.tsx +++ b/src/Selected.tsx @@ -1,11 +1,17 @@ import * as React from "react"; +import * as Actions from "./actions"; import * as Selection from "./selection"; import { getSelectedDimensions } from "./util"; import FloatingRect from "./FloatingRect"; import useSelector from "./use-selector"; +import useDispatch from "./use-dispatch"; +import classNames from "classnames"; const Selected: React.FC = () => { const selected = useSelector((state) => state.selected); + const selectedSize = useSelector((state) => + Selection.size(state.selected, state.model.data) + ); const dimensions = useSelector( (state) => selected && @@ -17,17 +23,59 @@ const Selected: React.FC = () => { ) ); const dragging = useSelector((state) => state.dragging); - const hidden = useSelector( - (state) => Selection.size(state.selected, state.model.data) < 2 - ); + const autoFilling = useSelector((state) => state.autoFilling); + const hidden = selectedSize === 0; + return ( ); }; export default Selected; + +const AutoFillHandle: React.FC = () => { + const dispatch = useDispatch(); + + const autoFillStart = React.useCallback(() => { + dispatch(Actions.autoFillStart()); + }, [dispatch]); + + const autoFillEnd = React.useCallback(() => { + dispatch(Actions.autoFillEnd()); + }, [dispatch]); + + const handleMouseDown = React.useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + + autoFillStart(); + + const handleMouseUp = () => { + autoFillEnd(); + window.removeEventListener("mouseup", handleMouseUp); + }; + + window.addEventListener("mouseup", handleMouseUp); + }, + [autoFillStart, autoFillEnd] + ); + + return ( +
+ ); +}; diff --git a/src/Spreadsheet.css b/src/Spreadsheet.css index 382ca671..9a46c667 100755 --- a/src/Spreadsheet.css +++ b/src/Spreadsheet.css @@ -120,10 +120,31 @@ border: 2px var(--outline-color) solid; } -.Spreadsheet__floating-rect--dragging { +.Spreadsheet__floating-rect--selected.Spreadsheet__selected-single { + background: none; border: none; } +.Spreadsheet__floating-rect--selected.Spreadsheet__floating-rect--auto-filling { + background: none; + border: 2px var(--readonly-text-color) dashed; +} + .Spreadsheet__floating-rect--copied { border: 2px var(--outline-color) dashed; } + +.Spreadsheet__auto-fill-handle { + position: absolute; + bottom: 0; + right: 0; + transform: translate(50%, 50%); + width: 8px; + height: 8px; + background: var(--outline-color); + border-radius: 50%; + box-shadow: var(--elevation); + cursor: pointer; + z-index: 10; + pointer-events: auto; +} diff --git a/src/actions.ts b/src/actions.ts index 044fcae4..32204a7c 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -22,6 +22,8 @@ export const KEY_DOWN = "KEY_DOWN"; export const DRAG_START = "DRAG_START"; export const DRAG_END = "DRAG_END"; export const COMMIT = "COMMIT"; +export const AUTO_FILL_START = "AUTO_FILL_START"; +export const AUTO_FILL_END = "AUTO_FILL_END"; export type BaseAction = { type: T; @@ -239,6 +241,18 @@ export function blur(): BlurAction { return { type: BLUR }; } +export type AutoFillStartAction = BaseAction; + +export function autoFillStart(): AutoFillStartAction { + return { type: AUTO_FILL_START }; +} + +export type AutoFillEndAction = BaseAction; + +export function autoFillEnd(): AutoFillEndAction { + return { type: AUTO_FILL_END }; +} + export type Action = | SetDataAction | SelectEntireRowAction @@ -259,4 +273,6 @@ export type Action = | EditAction | ViewAction | ClearAction - | BlurAction; + | BlurAction + | AutoFillStartAction + | AutoFillEndAction; diff --git a/src/reducer.test.ts b/src/reducer.test.ts index a9711729..127b37a3 100644 --- a/src/reducer.test.ts +++ b/src/reducer.test.ts @@ -3,6 +3,7 @@ import * as Types from "./types"; import * as Actions from "./actions"; import reducer, { INITIAL_STATE, + getNextPoint, hasKeyDownHandler, isActiveReadOnly, } from "./reducer"; @@ -253,3 +254,36 @@ describe("isActiveReadOnly", () => { expect(isActiveReadOnly(state)).toBe(expected); }); }); + +describe("getNextPoint", () => { + const cases: Array< + [ + name: string, + active: Point.Point, + range: PointRange.PointRange, + expected: Point.Point | undefined + ] + > = [ + [ + "returns undefined for single range", + Point.ORIGIN, + PointRange.create(Point.ORIGIN, Point.ORIGIN), + undefined, + ], + [ + "horizontal range", + Point.ORIGIN, + PointRange.create(Point.ORIGIN, { row: 0, column: 1 }), + { row: 0, column: 1 }, + ], + [ + "vertical range", + Point.ORIGIN, + PointRange.create(Point.ORIGIN, { row: 1, column: 0 }), + { row: 1, column: 0 }, + ], + ]; + test.each(cases)("%s", (name, active, range, expected) => { + expect(getNextPoint(active, range)).toEqual(expected); + }); +}); diff --git a/src/reducer.ts b/src/reducer.ts index edd5b775..adf501d0 100644 --- a/src/reducer.ts +++ b/src/reducer.ts @@ -22,6 +22,7 @@ export const INITIAL_STATE: Types.StoreState = { selected: null, copied: PointMap.from([]), lastCommit: null, + autoFilling: false, }; export default function reducer( @@ -276,18 +277,84 @@ export default function reducer( const { changes } = action.payload; return { ...state, ...commit(changes) }; } + case Actions.AUTO_FILL_START: { + return { ...state, autoFilling: true }; + } + case Actions.AUTO_FILL_END: { + const { active, selected } = state; + + const nextState = { ...state, autoFilling: false }; + + if (!active) { + return nextState; + } + if (!PointRange.is(selected)) { + return nextState; + } + const activeCell = Matrix.get(active, nextState.model.data); + if (!activeCell) { + return nextState; + } + const nextPoint = getNextPoint(active, selected); + if (!nextPoint) { + return nextState; + } + const nextCell = Matrix.get(nextPoint, nextState.model.data); + if (!nextCell) { + return nextState; + } + if (Number(activeCell.value) + 1 === Number(nextCell.value)) { + let nextData = nextState.model.data; + let value = Number(activeCell.value); + for (const point of PointRange.iterate(selected)) { + nextData = Matrix.set(point, { value }, nextData); + value++; + } + return { ...nextState, model: new Model(nextData) }; + } + if (Number(activeCell.value) - 1 === Number(nextCell.value)) { + let nextData = nextState.model.data; + let value = Number(activeCell.value); + for (const point of PointRange.iterate(selected)) { + nextData = Matrix.set(point, { value }, nextData); + value--; + } + return { ...nextState, model: new Model(nextData) }; + } + + return nextState; + } } } -// const reducer = createReducer(INITIAL_STATE, (builder) => { -// builder.addMatcher( -// (action) => -// action.type === Actions.copy.type || action.type === Actions.cut.type, -// (state, action) => { - -// } -// ); -// }); +/** Get the next point after active in range */ +export function getNextPoint( + active: Point.Point, + range: PointRange.PointRange +): Point.Point | undefined { + const { start, end } = range; + const isHorizontal = start.row === end.row; + const isVertical = start.column === end.column; + if ((isHorizontal && isVertical) || (!isHorizontal && !isVertical)) { + return undefined; + } + if (isHorizontal) { + const isForward = active.column < end.column; + const nextColumn = isForward ? active.column + 1 : active.column - 1; + const nextPoint = { row: active.row, column: nextColumn }; + if (PointRange.has(range, nextPoint)) { + return nextPoint; + } + } + if (isVertical) { + const isForward = active.row < end.row; + const nextRow = isForward ? active.row + 1 : active.row - 1; + const nextPoint = { row: nextRow, column: active.column }; + if (PointRange.has(range, nextPoint)) { + return nextPoint; + } + } +} // // Shared reducers diff --git a/src/types.ts b/src/types.ts index 9e5f0515..8e32f1bf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -58,6 +58,7 @@ export type StoreState = { dragging: boolean; lastChanged: Point | null; lastCommit: null | CellChange[]; + autoFilling: boolean; }; export type CellChange = { diff --git a/src/util.test.ts b/src/util.test.ts index 6d0a29dc..d9e1c33f 100644 --- a/src/util.test.ts +++ b/src/util.test.ts @@ -61,6 +61,7 @@ const EXAMPLE_STATE: Types.StoreState = { selected: null, copied: PointMap.from([]), lastCommit: null, + autoFilling: false, }; const EXAMPLE_STRING = "EXAMPLE_STRING"; const EXAMPLE_CELL: Types.CellBase = { From 2505d31d0f4cd121a881964ab296513243a2a9f2 Mon Sep 17 00:00:00 2001 From: Iddan Aaronsohn Date: Tue, 13 Jun 2023 16:05:57 +0300 Subject: [PATCH 2/3] Fix autofill integration --- src/reducer.ts | 62 +++++++++++++++++++++++++++++--------------------- 1 file changed, 36 insertions(+), 26 deletions(-) diff --git a/src/reducer.ts b/src/reducer.ts index adf501d0..6a6706a6 100644 --- a/src/reducer.ts +++ b/src/reducer.ts @@ -285,44 +285,28 @@ export default function reducer( const nextState = { ...state, autoFilling: false }; - if (!active) { - return nextState; - } - if (!PointRange.is(selected)) { + if (!active || !PointRange.is(selected)) { return nextState; } + const activeCell = Matrix.get(active, nextState.model.data); if (!activeCell) { return nextState; } + const nextPoint = getNextPoint(active, selected); if (!nextPoint) { return nextState; } const nextCell = Matrix.get(nextPoint, nextState.model.data); - if (!nextCell) { - return nextState; - } - if (Number(activeCell.value) + 1 === Number(nextCell.value)) { - let nextData = nextState.model.data; - let value = Number(activeCell.value); - for (const point of PointRange.iterate(selected)) { - nextData = Matrix.set(point, { value }, nextData); - value++; - } - return { ...nextState, model: new Model(nextData) }; - } - if (Number(activeCell.value) - 1 === Number(nextCell.value)) { - let nextData = nextState.model.data; - let value = Number(activeCell.value); - for (const point of PointRange.iterate(selected)) { - nextData = Matrix.set(point, { value }, nextData); - value--; - } - return { ...nextState, model: new Model(nextData) }; - } - return nextState; + const nextModel = autoFill( + nextState.model, + selected, + activeCell, + nextCell + ); + return { ...state, model: nextModel }; } } } @@ -558,3 +542,29 @@ export function getActive(state: Types.StoreState): Types.CellBase | null { const activeCell = state.active && Matrix.get(state.active, state.model.data); return activeCell || null; } + +export function autoFill( + model: Model, + selected: PointRange.PointRange, + activeCell: T, + nextCell: T | undefined +): Model { + let nextData = model.data; + let value = Number(activeCell.value); + for (const point of PointRange.iterate(selected)) { + const currentCell = Matrix.get(point, nextData); + let updatedCell; + if (Number(activeCell.value) + 1 === Number(nextCell?.value)) { + updatedCell = { ...currentCell, value } as T; + + value++; + } else if (Number(activeCell.value) - 1 === Number(nextCell?.value)) { + updatedCell = { ...currentCell, value } as T; + value--; + } else { + updatedCell = { ...currentCell, value } as T; + } + nextData = Matrix.set(point, updatedCell, nextData); + } + return new Model(nextData); +} From 41720715addeb5a7ce281181c7951c27d0594a06 Mon Sep 17 00:00:00 2001 From: Iddan Aaronsohn Date: Tue, 13 Jun 2023 16:20:12 +0300 Subject: [PATCH 3/3] Add basic tests for autofill --- src/reducer.test.ts | 38 +++++++++++++++ src/reducer.ts | 111 ++++++++++++++++++++++---------------------- 2 files changed, 94 insertions(+), 55 deletions(-) diff --git a/src/reducer.test.ts b/src/reducer.test.ts index 127b37a3..e9f8ac88 100644 --- a/src/reducer.test.ts +++ b/src/reducer.test.ts @@ -3,6 +3,7 @@ import * as Types from "./types"; import * as Actions from "./actions"; import reducer, { INITIAL_STATE, + autoFill, getNextPoint, hasKeyDownHandler, isActiveReadOnly, @@ -255,6 +256,43 @@ describe("isActiveReadOnly", () => { }); }); +describe("autoFill", () => { + const cases: Array< + [ + name: string, + data: Matrix.Matrix, + selected: PointRange.PointRange, + active: Point.Point, + expected: Matrix.Matrix + ] + > = [ + [ + "increasing series", + [[{ value: 1 }], [{ value: 2 }]], + PointRange.create(Point.ORIGIN, { row: 2, column: 0 }), + Point.ORIGIN, + [[{ value: 1 }], [{ value: 2 }], [{ value: 3 }]], + ], + [ + "decreasing series", + [[{ value: 2 }], [{ value: 1 }]], + PointRange.create(Point.ORIGIN, { row: 2, column: 0 }), + Point.ORIGIN, + [[{ value: 2 }], [{ value: 1 }], [{ value: 0 }]], + ], + [ + "same value", + [[{ value: 1 }]], + PointRange.create(Point.ORIGIN, { row: 2, column: 0 }), + Point.ORIGIN, + [[{ value: 1 }], [{ value: 1 }], [{ value: 1 }]], + ], + ]; + test.each(cases)("%s", (name, data, selected, active, expected) => { + expect(autoFill(data, selected, active)).toEqual(expected); + }); +}); + describe("getNextPoint", () => { const cases: Array< [ diff --git a/src/reducer.ts b/src/reducer.ts index 6a6706a6..a1199db3 100644 --- a/src/reducer.ts +++ b/src/reducer.ts @@ -289,53 +289,10 @@ export default function reducer( return nextState; } - const activeCell = Matrix.get(active, nextState.model.data); - if (!activeCell) { - return nextState; - } - - const nextPoint = getNextPoint(active, selected); - if (!nextPoint) { - return nextState; - } - const nextCell = Matrix.get(nextPoint, nextState.model.data); - - const nextModel = autoFill( - nextState.model, - selected, - activeCell, - nextCell - ); - return { ...state, model: nextModel }; - } - } -} - -/** Get the next point after active in range */ -export function getNextPoint( - active: Point.Point, - range: PointRange.PointRange -): Point.Point | undefined { - const { start, end } = range; - const isHorizontal = start.row === end.row; - const isVertical = start.column === end.column; - if ((isHorizontal && isVertical) || (!isHorizontal && !isVertical)) { - return undefined; - } - if (isHorizontal) { - const isForward = active.column < end.column; - const nextColumn = isForward ? active.column + 1 : active.column - 1; - const nextPoint = { row: active.row, column: nextColumn }; - if (PointRange.has(range, nextPoint)) { - return nextPoint; - } - } - if (isVertical) { - const isForward = active.row < end.row; - const nextRow = isForward ? active.row + 1 : active.row - 1; - const nextPoint = { row: nextRow, column: active.column }; - if (PointRange.has(range, nextPoint)) { - return nextPoint; + const nextData = autoFill(nextState.model.data, selected, active); + return nextData === nextState.model.data + ? nextState + : { ...nextState, model: new Model(nextData) }; } } } @@ -543,28 +500,72 @@ export function getActive(state: Types.StoreState): Types.CellBase | null { return activeCell || null; } +/** Autofill the given selected range in given data according to active */ export function autoFill( - model: Model, + data: Matrix.Matrix, selected: PointRange.PointRange, - activeCell: T, - nextCell: T | undefined -): Model { - let nextData = model.data; + active: Point.Point +): Matrix.Matrix { + const activeCell = Matrix.get(active, data); + if (!activeCell) { + return data; + } + const nextPoint = getNextPoint(active, selected); + if (!nextPoint) { + return data; + } + const nextCell = Matrix.get(nextPoint, data); + + let nextData = data; let value = Number(activeCell.value); for (const point of PointRange.iterate(selected)) { const currentCell = Matrix.get(point, nextData); let updatedCell; + // Increasing series if (Number(activeCell.value) + 1 === Number(nextCell?.value)) { updatedCell = { ...currentCell, value } as T; value++; - } else if (Number(activeCell.value) - 1 === Number(nextCell?.value)) { + } + // Decreasing series + else if (Number(activeCell.value) - 1 === Number(nextCell?.value)) { updatedCell = { ...currentCell, value } as T; value--; - } else { + } + // Same value + else { updatedCell = { ...currentCell, value } as T; } nextData = Matrix.set(point, updatedCell, nextData); } - return new Model(nextData); + return nextData; +} + +/** Get the next point after active in range */ +export function getNextPoint( + active: Point.Point, + range: PointRange.PointRange +): Point.Point | undefined { + const { start, end } = range; + const isHorizontal = start.row === end.row; + const isVertical = start.column === end.column; + if ((isHorizontal && isVertical) || (!isHorizontal && !isVertical)) { + return undefined; + } + if (isHorizontal) { + const isForward = active.column < end.column; + const nextColumn = isForward ? active.column + 1 : active.column - 1; + const nextPoint = { row: active.row, column: nextColumn }; + if (PointRange.has(range, nextPoint)) { + return nextPoint; + } + } + if (isVertical) { + const isForward = active.row < end.row; + const nextRow = isForward ? active.row + 1 : active.row - 1; + const nextPoint = { row: nextRow, column: active.column }; + if (PointRange.has(range, nextPoint)) { + return nextPoint; + } + } }