import constrainNumeric from './helpers/constrainNumeric'; export const CanvasLogicalWidth = 2000; export const CanvasLogicalHeight = 500; export enum HoverState { Normal, OverSelectionStart, OverSelectionEnd, OverSelection, } export enum EmptySelectionAction { SelectNothing, SelectPrevious, } export interface Selection { start: number; end: number; } export enum SelectionMode { Normal, Selecting, Dragging, ResizingStart, ResizingEnd, } export interface State { width: number; emptySelectionAction: EmptySelectionAction; hoverX: number; selection: Selection; origSelection: Selection; mousedownX: number; mode: SelectionMode; prevMode: SelectionMode; cursorClass: string; hoverState: HoverState; shouldPublish: boolean; } interface SelectionAction { type: string; x: number; // TODO: selection is only used for the setselection SelectionAction. Improve // the typing here. selection?: Selection; } export const stateReducer = ( { selection: prevSelection, origSelection, mousedownX: prevMousedownX, mode: prevMode, width, emptySelectionAction, }: State, { x, type, selection: selectionToSet }: SelectionAction ): State => { let mode: SelectionMode; let newSelection: Selection; let hoverX: number; let mousedownX: number; let cursorClass: string; let hoverState: HoverState; let shouldPublish: boolean | null = null; switch (type) { case 'setselection': newSelection = selectionToSet || { start: 0, end: 0 }; mousedownX = prevMousedownX; mode = SelectionMode.Normal; cursorClass = 'cursor-auto'; hoverX = x; hoverState = HoverState.Normal; shouldPublish = false; break; case 'mousedown': mousedownX = x; cursorClass = 'cursor-auto'; hoverX = x; hoverState = HoverState.Normal; if (isHoveringSelectionStart(x, prevSelection)) { newSelection = prevSelection; mode = SelectionMode.ResizingStart; } else if (isHoveringSelectionEnd(x, prevSelection)) { newSelection = prevSelection; mode = SelectionMode.ResizingEnd; } else if (isHoveringSelection(x, prevSelection)) { newSelection = prevSelection; mode = SelectionMode.Dragging; cursorClass = 'cursor-move'; } else { newSelection = { start: x, end: x }; mode = SelectionMode.Selecting; cursorClass = 'cursor-col-resize'; } origSelection = newSelection; break; case 'mouseup': newSelection = prevSelection; mousedownX = prevMousedownX; mode = SelectionMode.Normal; cursorClass = 'cursor-auto'; hoverX = x; hoverState = HoverState.Normal; if ( newSelection.start == newSelection.end && emptySelectionAction == EmptySelectionAction.SelectNothing ) { newSelection = { start: x, end: x }; } break; case 'mouseleave': newSelection = prevSelection; mousedownX = prevMousedownX; mode = prevMode; cursorClass = 'cursor-auto'; hoverX = 0; hoverState = HoverState.Normal; break; case 'mousemove': mousedownX = prevMousedownX; hoverX = x; hoverState = HoverState.Normal; switch (prevMode) { case SelectionMode.Normal: { newSelection = prevSelection; mode = SelectionMode.Normal; if (isHoveringSelectionStart(x, prevSelection)) { cursorClass = 'cursor-col-resize'; hoverState = HoverState.OverSelectionStart; } else if (isHoveringSelectionEnd(x, prevSelection)) { cursorClass = 'cursor-col-resize'; hoverState = HoverState.OverSelectionEnd; } else if (isHoveringSelection(x, prevSelection)) { cursorClass = 'cursor-move'; hoverState = HoverState.OverSelection; } else { cursorClass = 'cursor-auto'; hoverState = HoverState.Normal; } break; } case SelectionMode.Selecting: { cursorClass = 'cursor-col-resize'; mode = SelectionMode.Selecting; if (x < prevMousedownX) { newSelection = { start: x, end: prevMousedownX, }; } else { newSelection = { start: prevMousedownX, end: x, }; } break; } case SelectionMode.Dragging: { mode = SelectionMode.Dragging; cursorClass = 'cursor-move'; const diff = x - prevMousedownX; const selectionWidth = origSelection.end - origSelection.start; let start = Math.max(0, origSelection.start + diff); let end = start + selectionWidth; if (end > width) { end = width; start = end - selectionWidth; } newSelection = { start: start, end: end }; break; } case SelectionMode.ResizingStart: { mode = SelectionMode.ResizingStart; cursorClass = 'cursor-col-resize'; const start = constrainNumeric(x, width); if (start > origSelection.end) { newSelection = { start: origSelection.end, end: start }; } else { newSelection = { ...origSelection, start: start }; } break; } case SelectionMode.ResizingEnd: { mode = SelectionMode.ResizingEnd; cursorClass = 'cursor-col-resize'; const end = constrainNumeric(x, width); if (end < origSelection.start) { newSelection = { start: end, end: origSelection.start, }; } else { newSelection = { ...origSelection, end: x }; } break; } } break; default: throw new Error(); } // by default, only trigger the callback if the selection or mode has changed. if (shouldPublish == null) { shouldPublish = newSelection != prevSelection || mode != prevMode; } return { width: width, emptySelectionAction: emptySelectionAction, hoverX: hoverX, selection: newSelection, origSelection: origSelection, mousedownX: mousedownX, mode: mode, prevMode: prevMode, cursorClass: cursorClass, hoverState: hoverState, shouldPublish: shouldPublish, }; }; // helpers const hoverOffset = 10; const isHoveringSelectionStart = (x: number, selection: Selection): boolean => { return x > selection.start - hoverOffset && x < selection.start + hoverOffset; }; const isHoveringSelectionEnd = (x: number, selection: Selection): boolean => { return x > selection.end - hoverOffset && x < selection.end + hoverOffset; }; const isHoveringSelection = (x: number, selection: Selection): boolean => { return x >= selection.start && x <= selection.end; };