From b4d91e54152eabf4c01e1a99ddb6946609740fbc Mon Sep 17 00:00:00 2001 From: FMS-Cat Date: Tue, 13 Apr 2021 00:45:34 +0900 Subject: [PATCH 01/11] feature (gui): Multiple selection of items, the very first step --- .vscode/launch.json | 15 ++ .../src/view/components/Label.tsx | 68 ++++--- .../view/components/TimelineItemConstant.tsx | 147 ++++++++------- .../src/view/components/TimelineItemCurve.tsx | 147 ++++++++------- .../src/view/states/Timeline.ts | 7 +- .../src/view/states/store.tsx | 6 +- .../src/view/utils/useMoveEntities.ts | 175 ++++++++++++++++++ 7 files changed, 397 insertions(+), 168 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 packages/automaton-with-gui/src/view/utils/useMoveEntities.ts diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..c8359a85 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "pwa-chrome", + "request": "launch", + "name": "Launch Chrome against localhost", + "url": "http://localhost:10001", + "webRoot": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/packages/automaton-with-gui/src/view/components/Label.tsx b/packages/automaton-with-gui/src/view/components/Label.tsx index ed5ce941..1b21e757 100644 --- a/packages/automaton-with-gui/src/view/components/Label.tsx +++ b/packages/automaton-with-gui/src/view/components/Label.tsx @@ -1,11 +1,12 @@ import { Colors } from '../constants/Colors'; import { MouseComboBit, mouseCombo } from '../utils/mouseCombo'; import { Resolution } from '../utils/Resolution'; -import { TimeRange, dx2dt, snapTime, t2x } from '../utils/TimeValueRange'; +import { TimeRange, t2x } from '../utils/TimeValueRange'; import { arraySetHas } from '../utils/arraySet'; import { registerMouseEvent } from '../utils/registerMouseEvent'; import { useDispatch, useSelector } from '../states/store'; import { useDoubleClick } from '../utils/useDoubleClick'; +import { useMoveEntites } from '../utils/useMoveEntities'; import { useRect } from '../utils/useRect'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import styled from 'styled-components'; @@ -39,13 +40,13 @@ const Label = ( { name, time, range, size }: { const dispatch = useDispatch(); const { automaton, - guiSettings, selectedLabels } = useSelector( ( state ) => ( { automaton: state.automaton.instance, guiSettings: state.automaton.guiSettings, selectedLabels: state.timeline.selected.labels } ) ); + const { moveEntities } = useMoveEntites( size ); const checkDoubleClick = useDoubleClick(); const [ width, setWidth ] = useState( 0.0 ); const x = t2x( time, range, size.width ); @@ -70,49 +71,39 @@ const Label = ( { name, time, range, size }: { dispatch( { type: 'Timeline/SelectLabels', - labels: [ name ] + labels: [ name ], } ); - const timePrev = time; - let newTime = time; - let x = 0.0; - let hasMoved = false; - - registerMouseEvent( - ( event, movementSum ) => { - hasMoved = true; - x += movementSum.x; + moveEntities( { moveValue: false, snapOriginTime: time } ); + }, + [ automaton, dispatch, name, moveEntities, time ] + ); - const ignoreSnap = event.altKey; - newTime = timePrev + dx2dt( x, range, size.width ); + const grabLabelCtrl = useCallback( + (): void => { + dispatch( { + type: 'Timeline/SelectLabelsAdd', + labels: [ name ], + } ); - if ( !ignoreSnap ) { - newTime = snapTime( newTime, range, size.width, guiSettings ); - } + moveEntities( { moveValue: false, snapOriginTime: time } ); - automaton.setLabel( name, newTime ); + let isMoved = false; + registerMouseEvent( + () => { + isMoved = true; }, () => { - if ( !hasMoved ) { return; } - - automaton.setLabel( name, newTime ); - - dispatch( { - type: 'History/Push', - description: 'Move Label', - commands: [ - { - type: 'automaton/moveLabel', - name, - time: newTime, - timePrev - } - ] - } ); - } + if ( !isMoved && isSelected ) { + dispatch( { + type: 'Timeline/SelectLabelsSub', + labels: [ name ], + } ); + } + }, ); }, - [ automaton, time, name, range, size, guiSettings, dispatch ] + [ dispatch, isSelected, moveEntities, name, time ] ); const renameLabel = useCallback( @@ -181,6 +172,9 @@ const Label = ( { name, time, range, size }: { const handleMouseDown = useCallback( ( event ) => mouseCombo( event, { + [ MouseComboBit.Ctrl + MouseComboBit.LMB ]: () => { + grabLabelCtrl(); + }, [ MouseComboBit.LMB ]: () => { if ( checkDoubleClick() ) { deleteLabel(); @@ -189,7 +183,7 @@ const Label = ( { name, time, range, size }: { } } } ), - [ checkDoubleClick, deleteLabel, grabLabel ] + [ checkDoubleClick, deleteLabel, grabLabel, grabLabelCtrl ] ); const handleContextMenu = useCallback( diff --git a/packages/automaton-with-gui/src/view/components/TimelineItemConstant.tsx b/packages/automaton-with-gui/src/view/components/TimelineItemConstant.tsx index fcc4f586..9974992d 100644 --- a/packages/automaton-with-gui/src/view/components/TimelineItemConstant.tsx +++ b/packages/automaton-with-gui/src/view/components/TimelineItemConstant.tsx @@ -2,12 +2,13 @@ import { Colors } from '../constants/Colors'; import { Icons } from '../icons/Icons'; import { MouseComboBit, mouseCombo } from '../utils/mouseCombo'; import { Resolution } from '../utils/Resolution'; -import { TimeValueRange, dt2dx, dx2dt, snapTime, snapValue, t2x, v2y, x2t, y2v } from '../utils/TimeValueRange'; +import { TimeValueRange, dt2dx, dx2dt, snapTime, t2x, v2y } from '../utils/TimeValueRange'; import { objectMapHas } from '../utils/objectMap'; import { registerMouseEvent } from '../utils/registerMouseEvent'; import { useDispatch, useSelector } from '../states/store'; import { useDoubleClick } from '../utils/useDoubleClick'; import { useID } from '../utils/useID'; +import { useMoveEntites } from '../utils/useMoveEntities'; import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import type { StateChannelItem } from '../../types/StateChannelItem'; @@ -81,6 +82,8 @@ const TimelineItemConstant = ( props: TimelineItemConstantProps ): JSX.Element = guiSettings: state.automaton.guiSettings } ) ); + const { moveEntities } = useMoveEntites( size ); + let x = useMemo( () => t2x( item.time, range, size.width ), [ item, range, size ] ); let w = useMemo( () => dt2dx( item.length, range, size.width ), [ item, range, size ] ); const y = useMemo( @@ -100,65 +103,82 @@ const TimelineItemConstant = ( props: TimelineItemConstantProps ): JSX.Element = (): void => { if ( !channel ) { return; } - const timePrev = item.time; - const valuePrev = item.value; - let x = t2x( timePrev, range, size.width ); - let y = v2y( valuePrev, range, size.height ); - let time = timePrev; - let value = valuePrev; - let hasMoved = false; + dispatch( { + type: 'Timeline/SelectItems', + items: [ { + id: item.$id, + channel: channelName + } ] + } ); - registerMouseEvent( - ( event, movementSum ) => { - hasMoved = true; - x += movementSum.x; - y += movementSum.y; + dispatch( { + type: 'Timeline/SelectChannel', + channel: channelName + } ); - const holdTime = event.ctrlKey || event.metaKey; - const holdValue = dopeSheetMode || event.shiftKey; - const ignoreSnap = event.altKey; + moveEntities( { + moveValue: !dopeSheetMode, + snapOriginTime: item.time, + snapOriginValue: item.value, + } ); + }, + [ + channel, + dispatch, + item.$id, + item.time, + item.value, + channelName, + moveEntities, + dopeSheetMode, + ] + ); - time = holdTime ? timePrev : x2t( x, range, size.width ); - value = holdValue ? valuePrev : y2v( y, range, size.height ); + const grabBodyCtrl = useCallback( + (): void => { + dispatch( { + type: 'Timeline/SelectItemsAdd', + items: [ { + id: item.$id, + channel: channelName, + } ], + } ); - if ( !ignoreSnap ) { - if ( !holdTime ) { time = snapTime( time, range, size.width, guiSettings ); } - if ( !holdValue ) { value = snapValue( value, range, size.height, guiSettings ); } - } + moveEntities( { + moveValue: !dopeSheetMode, + snapOriginTime: item.time, + snapOriginValue: item.value, + } ); - channel.moveItem( item.$id, time ); - channel.changeItemValue( item.$id, value ); + let isMoved = false; + registerMouseEvent( + () => { + isMoved = true; }, () => { - if ( !hasMoved ) { return; } - - channel.moveItem( item.$id, time ); - channel.changeItemValue( item.$id, value ); - - dispatch( { - type: 'History/Push', - description: 'Move Constant', - commands: [ - { - type: 'channel/moveItem', + if ( !isMoved && isSelected ) { + dispatch( { + type: 'Timeline/SelectItemsSub', + items: [ { + id: item.$id, channel: channelName, - item: item.$id, - time, - timePrev - }, - { - type: 'channel/changeItemValue', - channel: channelName, - item: item.$id, - value, - valuePrev - } - ], - } ); - } + } ], + } ); + } + }, ); + }, - [ channel, item, range, size, dopeSheetMode, guiSettings, dispatch, channelName ] + [ + channelName, + dispatch, + dopeSheetMode, + isSelected, + item.$id, + item.time, + item.value, + moveEntities, + ] ); const grabLeft = useCallback( @@ -288,22 +308,12 @@ const TimelineItemConstant = ( props: TimelineItemConstantProps ): JSX.Element = if ( checkDoubleClick() ) { removeItem(); } else { - dispatch( { - type: 'Timeline/SelectItems', - items: [ { - id: item.$id, - channel: channelName - } ] - } ); - - dispatch( { - type: 'Timeline/SelectChannel', - channel: channelName - } ); - grabBody(); } }, + [ MouseComboBit.LMB + MouseComboBit.Ctrl ]: () => { + grabBodyCtrl(); + }, [ MouseComboBit.LMB + MouseComboBit.Shift ]: () => { if ( !channel ) { return; } const newItem = channel.repeatItem( item.$id ); @@ -335,7 +345,16 @@ const TimelineItemConstant = ( props: TimelineItemConstantProps ): JSX.Element = }, [ MouseComboBit.LMB + MouseComboBit.Alt ]: false, // give a way to seek! } ), - [ checkDoubleClick, removeItem, dispatch, item.$id, channelName, grabBody, channel ] + [ + checkDoubleClick, + removeItem, + grabBody, + grabBodyCtrl, + channel, + item.$id, + dispatch, + channelName, + ] ); const handleClickLeft = useCallback( diff --git a/packages/automaton-with-gui/src/view/components/TimelineItemCurve.tsx b/packages/automaton-with-gui/src/view/components/TimelineItemCurve.tsx index 7e73e222..df0b313a 100644 --- a/packages/automaton-with-gui/src/view/components/TimelineItemCurve.tsx +++ b/packages/automaton-with-gui/src/view/components/TimelineItemCurve.tsx @@ -2,7 +2,7 @@ import { Colors } from '../constants/Colors'; import { Icons } from '../icons/Icons'; import { MouseComboBit, mouseCombo } from '../utils/mouseCombo'; import { Resolution } from '../utils/Resolution'; -import { TimeValueRange, dt2dx, dx2dt, dy2dv, snapTime, snapValue, t2x, v2y, x2t, y2v } from '../utils/TimeValueRange'; +import { TimeValueRange, dt2dx, dx2dt, dy2dv, snapTime, snapValue, t2x, v2y } from '../utils/TimeValueRange'; import { genID } from '@fms-cat/automaton-with-gui/src/utils/genID'; import { jsonCopy } from '@fms-cat/automaton-with-gui/src/utils/jsonCopy'; import { objectMapHas } from '../utils/objectMap'; @@ -10,6 +10,7 @@ import { registerMouseEvent } from '../utils/registerMouseEvent'; import { useDispatch, useSelector } from '../states/store'; import { useDoubleClick } from '../utils/useDoubleClick'; import { useID } from '../utils/useID'; +import { useMoveEntites } from '../utils/useMoveEntities'; import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import type { StateChannelItem } from '../../types/StateChannelItem'; @@ -92,6 +93,8 @@ const TimelineItemCurve = ( props: TimelineItemCurveProps ): JSX.Element => { guiSettings: state.automaton.guiSettings } ) ); + const { moveEntities } = useMoveEntites( size ); + const curve = curves[ item.curveId! ]; const { path, length: curveLength } = curve; @@ -126,65 +129,82 @@ const TimelineItemCurve = ( props: TimelineItemCurveProps ): JSX.Element => { (): void => { if ( !channel ) { return; } - const timePrev = item.time; - const valuePrev = item.value; - let x = t2x( timePrev, range, size.width ); - let y = v2y( valuePrev, range, size.height ); - let time = timePrev; - let value = valuePrev; - let hasMoved = false; + dispatch( { + type: 'Timeline/SelectItems', + items: [ { + id: item.$id, + channel: channelName + } ] + } ); - registerMouseEvent( - ( event, movementSum ) => { - hasMoved = true; - x += movementSum.x; - y += movementSum.y; + dispatch( { + type: 'Timeline/SelectChannel', + channel: channelName + } ); - const holdTime = event.ctrlKey || event.metaKey; - const holdValue = dopeSheetMode || event.shiftKey; - const ignoreSnap = event.altKey; + moveEntities( { + moveValue: !dopeSheetMode, + snapOriginTime: item.time, + snapOriginValue: item.value, + } ); + }, + [ + channel, + dispatch, + item.$id, + item.time, + item.value, + channelName, + moveEntities, + dopeSheetMode, + ] + ); - time = holdTime ? timePrev : x2t( x, range, size.width ); - value = holdValue ? valuePrev : y2v( y, range, size.height ); + const grabBodyCtrl = useCallback( + (): void => { + dispatch( { + type: 'Timeline/SelectItemsAdd', + items: [ { + id: item.$id, + channel: channelName, + } ], + } ); - if ( !ignoreSnap ) { - if ( !holdTime ) { time = snapTime( time, range, size.width, guiSettings ); } - if ( !holdValue ) { value = snapValue( value, range, size.height, guiSettings ); } - } + moveEntities( { + moveValue: !dopeSheetMode, + snapOriginTime: item.time, + snapOriginValue: item.value, + } ); - channel.moveItem( item.$id, time ); - channel.changeItemValue( item.$id, value ); + let isMoved = false; + registerMouseEvent( + () => { + isMoved = true; }, () => { - if ( !hasMoved ) { return; } - - channel.moveItem( item.$id, time ); - channel.changeItemValue( item.$id, value ); - - dispatch( { - type: 'History/Push', - description: 'Move Curve', - commands: [ - { - type: 'channel/moveItem', + if ( !isMoved && isSelected ) { + dispatch( { + type: 'Timeline/SelectItemsSub', + items: [ { + id: item.$id, channel: channelName, - item: item.$id, - time, - timePrev - }, - { - type: 'channel/changeItemValue', - channel: channelName, - item: item.$id, - value, - valuePrev - } - ], - } ); - } + } ], + } ); + } + }, ); + }, - [ channel, item, range, size, guiSettings, dopeSheetMode, dispatch, channelName ] + [ + channelName, + dispatch, + dopeSheetMode, + isSelected, + item.$id, + item.time, + item.value, + moveEntities, + ] ); const grabTop = useCallback( @@ -420,22 +440,12 @@ const TimelineItemCurve = ( props: TimelineItemCurveProps ): JSX.Element => { if ( checkDoubleClick() ) { removeItem(); } else { - dispatch( { - type: 'Timeline/SelectItems', - items: [ { - id: item.$id, - channel: channelName - } ] - } ); - - dispatch( { - type: 'Timeline/SelectChannel', - channel: channelName - } ); - grabBody(); } }, + [ MouseComboBit.LMB + MouseComboBit.Ctrl ]: () => { + grabBodyCtrl(); + }, [ MouseComboBit.LMB + MouseComboBit.Shift ]: () => { if ( !channel ) { return; } const newItem = channel.repeatItem( item.$id ); @@ -467,7 +477,16 @@ const TimelineItemCurve = ( props: TimelineItemCurveProps ): JSX.Element => { }, [ MouseComboBit.LMB + MouseComboBit.Alt ]: false, // give a way to seek! } ), - [ checkDoubleClick, removeItem, dispatch, item.$id, channelName, grabBody, channel ] + [ + checkDoubleClick, + removeItem, + grabBody, + grabBodyCtrl, + channel, + item.$id, + dispatch, + channelName, + ], ); const handleClickLeft = useCallback( diff --git a/packages/automaton-with-gui/src/view/states/Timeline.ts b/packages/automaton-with-gui/src/view/states/Timeline.ts index 4a443f55..d8653450 100644 --- a/packages/automaton-with-gui/src/view/states/Timeline.ts +++ b/packages/automaton-with-gui/src/view/states/Timeline.ts @@ -58,7 +58,10 @@ export type Action = { }>; } | { type: 'Timeline/SelectItemsSub'; - items: string[]; + items: Array<{ + id: string; + channel: string; + }>; } | { type: 'Timeline/SelectLabels'; labels: string[]; @@ -120,7 +123,7 @@ export const reducer: Reducer = ( state = initialState, ac } ); } else if ( action.type === 'Timeline/SelectItemsSub' ) { action.items.forEach( ( item ) => { - delete newState.selected.items[ item ]; + delete newState.selected.items[ item.id ]; } ); } else if ( action.type === 'Timeline/UnselectItemsOfOtherChannels' ) { newState.selected.items = jsonCopy( initialState.selected.items ); diff --git a/packages/automaton-with-gui/src/view/states/store.tsx b/packages/automaton-with-gui/src/view/states/store.tsx index 8759b545..b4551cc1 100644 --- a/packages/automaton-with-gui/src/view/states/store.tsx +++ b/packages/automaton-with-gui/src/view/states/store.tsx @@ -11,7 +11,7 @@ import * as Timeline from './Timeline'; import * as Toasty from './Toasty'; import * as Workspace from './Workspace'; import { Dispatch, Store, combineReducers, createStore as createReduxStore } from 'redux'; -import { shallowEqual, useDispatch as useReduxDispatch, useSelector as useReduxSelector } from 'react-redux'; +import { shallowEqual, useDispatch as useReduxDispatch, useSelector as useReduxSelector, useStore as useReduxStore } from 'react-redux'; // == state ======================================================================================== export interface State { @@ -83,3 +83,7 @@ export function useSelector( selector: ( state: State ) => T ): T { export function useDispatch(): Dispatch { return useReduxDispatch>(); } + +export function useStore(): Store { + return useReduxStore(); +} diff --git a/packages/automaton-with-gui/src/view/utils/useMoveEntities.ts b/packages/automaton-with-gui/src/view/utils/useMoveEntities.ts new file mode 100644 index 00000000..c2580f33 --- /dev/null +++ b/packages/automaton-with-gui/src/view/utils/useMoveEntities.ts @@ -0,0 +1,175 @@ +import { HistoryCommand } from '../history/HistoryCommand'; +import { dx2dt, dy2dv, snapTime, snapValue } from './TimeValueRange'; +import { registerMouseEvent } from './registerMouseEvent'; +import { useCallback } from 'react'; +import { useDispatch, useStore } from '../states/store'; + +export function useMoveEntites( { width, height }: { width: number, height: number } ): { + moveEntities: ( options: { + moveValue: boolean; + snapOriginTime?: number; + snapOriginValue?: number; + } ) => void, +} { + const dispatch = useDispatch(); + const store = useStore(); + + const moveEntities = useCallback( + ( { moveValue, snapOriginTime, snapOriginValue }: { + moveValue: boolean; + snapOriginTime?: number; + snapOriginValue?: number; + } ): void => { + const state = store.getState(); + + const automaton = state.automaton.instance; + if ( !automaton ) { return; } + + const selected = state.timeline.selected; + const selectedItemsAsc = Object.values( selected.items ).map( ( { id, channel } ) => ( + { ...state.automaton.channels[ channel ].items[ id ], channel } + ) ).sort( ( a, b ) => a.time - b.time ); + const selectedItemsDesc = selectedItemsAsc.concat().reverse(); + + // -- decide history description ------------------------------------------------------------- + let historyDescription = ''; + if ( selected.labels.length > 0 ) { + if ( selectedItemsAsc.length > 0 ) { + historyDescription = 'Move Timeline Entities'; + } else if ( selected.labels.length > 1 ) { + historyDescription = 'Move Labels'; + } else { + historyDescription = `Move Label: ${ selected.labels[ 0 ] }`; + } + } else if ( selectedItemsAsc.length > 0 ) { + if ( selectedItemsAsc.length > 1 ) { + historyDescription = 'Move Items'; + } else { + historyDescription = `Move Item: ${ selectedItemsAsc[ 0 ].channel }`; + } + } + + // if nothing is selected, abort + if ( historyDescription === '' ) { + return; + } + + // -- things needed for items ---------------------------------------------------------------- + const itemsState0Map = new Map( selectedItemsAsc.map( ( item ) => ( + [ item.$id, item ] + ) ) ); + const itemsNewTimeMap = new Map(); + const itemsNewValueMap = new Map(); + + // -- things needed for labels --------------------------------------------------------------- + const labelsTime0Map = new Map( selected.labels.map( ( name ) => ( + [ name, state.automaton.labels[ name ] ] + ) ) ); + const labelsNewTimeMap = new Map(); + + // -- common stuff --------------------------------------------------------------------------- + const range = state.timeline.range; + const guiSettings = state.automaton.guiSettings; + + let dx = 0.0; + let dt = 0.0; + let dy = 0.0; + let dv = 0.0; + + const originTime = snapOriginTime ?? 0.0; + const originValue = snapOriginValue ?? 0.0; + + // -- do the move ---------------------------------------------------------------------------- + registerMouseEvent( + ( event, movementSum ) => { + dx += movementSum.x; + dy += movementSum.y; + + // -- calc leftmost / bottommost --------------------------------------------------------- + let t = originTime + dx2dt( dx, range, width ); + let v = originValue + dy2dv( dy, range, height ); + + const ignoreSnap = event.altKey; + if ( !ignoreSnap ) { + if ( snapOriginTime != null ) { + t = snapTime( t, range, width, guiSettings ); + } + + if ( snapOriginValue != null ) { + v = snapValue( v, range, height, guiSettings ); + } + } + + dt = t - originTime; + dv = moveValue ? v - originValue : 0.0; + + // -- move items ------------------------------------------------------------------------- + ( movementSum.x > 0.0 ? selectedItemsDesc : selectedItemsAsc ).forEach( ( item ) => { + const newTime = itemsState0Map.get( item.$id )!.time + dt; + const newValue = itemsState0Map.get( item.$id )!.value + dv; + + itemsNewTimeMap.set( item.$id, newTime ); + itemsNewValueMap.set( item.$id, newValue ); + + const channel = automaton.getChannel( item.channel )!; + channel.moveItem( item.$id, newTime ); + channel.changeItemValue( item.$id, newValue ); + } ); + + // -- move labels ------------------------------------------------------------------------ + selected.labels.forEach( ( name ) => { + const newTime = labelsTime0Map.get( name )! + dt; + + labelsNewTimeMap.set( name, newTime ); + + automaton.setLabel( name, newTime ); + } ); + }, + () => { + if ( dt === 0.0 && dv === 0.0 ) { return; } + + const commands: HistoryCommand[] = []; + + // -- push item commands ----------------------------------------------------------------- + ( dt > 0.0 ? selectedItemsDesc : selectedItemsAsc ).forEach( ( item ) => { + commands.push( { + type: 'channel/moveItem', + channel: item.channel, + item: item.$id, + time: itemsNewTimeMap.get( item.$id )!, + timePrev: itemsState0Map.get( item.$id )!.time, + } ); + + commands.push( { + type: 'channel/changeItemValue', + channel: item.channel, + item: item.$id, + value: itemsNewValueMap.get( item.$id )!, + valuePrev: itemsState0Map.get( item.$id )!.value, + } ); + } ); + + // -- push label commands ---------------------------------------------------------------- + selected.labels.forEach( ( name ) => { + commands.push( { + type: 'automaton/moveLabel', + name, + time: labelsNewTimeMap.get( name )!, + timePrev: labelsTime0Map.get( name )!, + } ); + } ); + + // -- dispatch history command ----------------------------------------------------------- + dispatch( { + type: 'History/Push', + description: historyDescription, + commands, + } ); + } + ); + }, + [ dispatch, height, store, width ] + ); + + return { moveEntities }; +} From 853e2c5b95af7952ae732f07df48355001b57639 Mon Sep 17 00:00:00 2001 From: FMS-Cat Date: Tue, 13 Apr 2021 01:21:57 +0900 Subject: [PATCH 02/11] feature (gui): Multiple selection of items - fix holding time/value feature - slightly changing LMB+NoKeys behavior --- .../src/view/components/Label.tsx | 29 +++++++++--- .../view/components/TimelineItemConstant.tsx | 44 ++++++++++++------ .../src/view/components/TimelineItemCurve.tsx | 45 +++++++++++++------ .../src/view/utils/useMoveEntities.ts | 28 +++++++----- 4 files changed, 102 insertions(+), 44 deletions(-) diff --git a/packages/automaton-with-gui/src/view/components/Label.tsx b/packages/automaton-with-gui/src/view/components/Label.tsx index 1b21e757..590851be 100644 --- a/packages/automaton-with-gui/src/view/components/Label.tsx +++ b/packages/automaton-with-gui/src/view/components/Label.tsx @@ -67,16 +67,31 @@ const Label = ( { name, time, range, size }: { const grabLabel = useCallback( (): void => { - if ( !automaton ) { return; } - - dispatch( { - type: 'Timeline/SelectLabels', - labels: [ name ], - } ); + if ( !isSelected ) { + dispatch( { + type: 'Timeline/SelectLabels', + labels: [ name ], + } ); + } moveEntities( { moveValue: false, snapOriginTime: time } ); + + let isMoved = false; + registerMouseEvent( + () => { + isMoved = true; + }, + () => { + if ( !isMoved ) { + dispatch( { + type: 'Timeline/SelectLabels', + labels: [ name ], + } ); + } + }, + ); }, - [ automaton, dispatch, name, moveEntities, time ] + [ isSelected, moveEntities, time, dispatch, name ] ); const grabLabelCtrl = useCallback( diff --git a/packages/automaton-with-gui/src/view/components/TimelineItemConstant.tsx b/packages/automaton-with-gui/src/view/components/TimelineItemConstant.tsx index 9974992d..615cf61a 100644 --- a/packages/automaton-with-gui/src/view/components/TimelineItemConstant.tsx +++ b/packages/automaton-with-gui/src/view/components/TimelineItemConstant.tsx @@ -101,29 +101,46 @@ const TimelineItemConstant = ( props: TimelineItemConstantProps ): JSX.Element = const grabBody = useCallback( (): void => { - if ( !channel ) { return; } + if ( !isSelected ) { + dispatch( { + type: 'Timeline/SelectItems', + items: [ { + id: item.$id, + channel: channelName + } ] + } ); - dispatch( { - type: 'Timeline/SelectItems', - items: [ { - id: item.$id, + dispatch( { + type: 'Timeline/SelectChannel', channel: channelName - } ] - } ); - - dispatch( { - type: 'Timeline/SelectChannel', - channel: channelName - } ); + } ); + } moveEntities( { moveValue: !dopeSheetMode, snapOriginTime: item.time, snapOriginValue: item.value, } ); + + let isMoved = false; + registerMouseEvent( + () => { + isMoved = true; + }, + () => { + if ( !isMoved ) { + dispatch( { + type: 'Timeline/SelectItems', + items: [ { + id: item.$id, + channel: channelName + } ] + } ); + } + }, + ); }, [ - channel, dispatch, item.$id, item.time, @@ -131,6 +148,7 @@ const TimelineItemConstant = ( props: TimelineItemConstantProps ): JSX.Element = channelName, moveEntities, dopeSheetMode, + isSelected, ] ); diff --git a/packages/automaton-with-gui/src/view/components/TimelineItemCurve.tsx b/packages/automaton-with-gui/src/view/components/TimelineItemCurve.tsx index df0b313a..68c66115 100644 --- a/packages/automaton-with-gui/src/view/components/TimelineItemCurve.tsx +++ b/packages/automaton-with-gui/src/view/components/TimelineItemCurve.tsx @@ -127,29 +127,46 @@ const TimelineItemCurve = ( props: TimelineItemCurveProps ): JSX.Element => { const grabBody = useCallback( (): void => { - if ( !channel ) { return; } + if ( !isSelected ) { + dispatch( { + type: 'Timeline/SelectItems', + items: [ { + id: item.$id, + channel: channelName + } ] + } ); - dispatch( { - type: 'Timeline/SelectItems', - items: [ { - id: item.$id, + dispatch( { + type: 'Timeline/SelectChannel', channel: channelName - } ] - } ); - - dispatch( { - type: 'Timeline/SelectChannel', - channel: channelName - } ); + } ); + } moveEntities( { moveValue: !dopeSheetMode, snapOriginTime: item.time, snapOriginValue: item.value, } ); + + let isMoved = false; + registerMouseEvent( + () => { + isMoved = true; + }, + () => { + if ( !isMoved ) { + dispatch( { + type: 'Timeline/SelectItems', + items: [ { + id: item.$id, + channel: channelName + } ] + } ); + } + }, + ); }, [ - channel, dispatch, item.$id, item.time, @@ -157,6 +174,7 @@ const TimelineItemCurve = ( props: TimelineItemCurveProps ): JSX.Element => { channelName, moveEntities, dopeSheetMode, + isSelected, ] ); @@ -193,7 +211,6 @@ const TimelineItemCurve = ( props: TimelineItemCurveProps ): JSX.Element => { } }, ); - }, [ channelName, diff --git a/packages/automaton-with-gui/src/view/utils/useMoveEntities.ts b/packages/automaton-with-gui/src/view/utils/useMoveEntities.ts index c2580f33..026cb927 100644 --- a/packages/automaton-with-gui/src/view/utils/useMoveEntities.ts +++ b/packages/automaton-with-gui/src/view/utils/useMoveEntities.ts @@ -85,23 +85,31 @@ export function useMoveEntites( { width, height }: { width: number, height: numb dx += movementSum.x; dy += movementSum.y; - // -- calc leftmost / bottommost --------------------------------------------------------- - let t = originTime + dx2dt( dx, range, width ); - let v = originValue + dy2dv( dy, range, height ); - + // -- keyboards -------------------------------------------------------------------------- + const holdTime = event.ctrlKey || event.metaKey; + const holdValue = event.shiftKey; const ignoreSnap = event.altKey; - if ( !ignoreSnap ) { - if ( snapOriginTime != null ) { + + // -- calc dt / dv ----------------------------------------------------------------------- + if ( !holdTime ) { + let t = originTime + dx2dt( dx, range, width ); + + if ( !ignoreSnap && snapOriginTime != null ) { t = snapTime( t, range, width, guiSettings ); } - if ( snapOriginValue != null ) { + dt = t - originTime; + } + + if ( !holdValue && moveValue ) { + let v = originValue + dy2dv( dy, range, height ); + + if ( !ignoreSnap && snapOriginValue != null ) { v = snapValue( v, range, height, guiSettings ); } - } - dt = t - originTime; - dv = moveValue ? v - originValue : 0.0; + dv = v - originValue; + } // -- move items ------------------------------------------------------------------------- ( movementSum.x > 0.0 ? selectedItemsDesc : selectedItemsAsc ).forEach( ( item ) => { From f8e5b12051fac6159722b68e7fa568de6d697a88 Mon Sep 17 00:00:00 2001 From: FMS-Cat Date: Tue, 13 Apr 2021 01:36:29 +0900 Subject: [PATCH 03/11] feature (gui): Multiple selection of items - holdTime key should work only when moveValue is enabled --- packages/automaton-with-gui/src/view/utils/useMoveEntities.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/automaton-with-gui/src/view/utils/useMoveEntities.ts b/packages/automaton-with-gui/src/view/utils/useMoveEntities.ts index 026cb927..dbd6032e 100644 --- a/packages/automaton-with-gui/src/view/utils/useMoveEntities.ts +++ b/packages/automaton-with-gui/src/view/utils/useMoveEntities.ts @@ -86,7 +86,7 @@ export function useMoveEntites( { width, height }: { width: number, height: numb dy += movementSum.y; // -- keyboards -------------------------------------------------------------------------- - const holdTime = event.ctrlKey || event.metaKey; + const holdTime = ( event.ctrlKey || event.metaKey ) && moveValue; const holdValue = event.shiftKey; const ignoreSnap = event.altKey; From a4d7bcbca709bbf562c9359441938afc8abf5c7d Mon Sep 17 00:00:00 2001 From: FMS-Cat Date: Wed, 14 Apr 2021 07:35:16 +0900 Subject: [PATCH 04/11] refactor: registerMouseNoDragEvent --- .../src/view/components/Label.tsx | 44 +++++---------- .../view/components/TimelineItemConstant.tsx | 56 +++++++------------ .../src/view/components/TimelineItemCurve.tsx | 55 +++++++----------- .../view/utils/registerMouseNoDragEvent.ts | 18 ++++++ 4 files changed, 75 insertions(+), 98 deletions(-) create mode 100644 packages/automaton-with-gui/src/view/utils/registerMouseNoDragEvent.ts diff --git a/packages/automaton-with-gui/src/view/components/Label.tsx b/packages/automaton-with-gui/src/view/components/Label.tsx index 590851be..e582b52c 100644 --- a/packages/automaton-with-gui/src/view/components/Label.tsx +++ b/packages/automaton-with-gui/src/view/components/Label.tsx @@ -3,7 +3,7 @@ import { MouseComboBit, mouseCombo } from '../utils/mouseCombo'; import { Resolution } from '../utils/Resolution'; import { TimeRange, t2x } from '../utils/TimeValueRange'; import { arraySetHas } from '../utils/arraySet'; -import { registerMouseEvent } from '../utils/registerMouseEvent'; +import { registerMouseNoDragEvent } from '../utils/registerMouseNoDragEvent'; import { useDispatch, useSelector } from '../states/store'; import { useDoubleClick } from '../utils/useDoubleClick'; import { useMoveEntites } from '../utils/useMoveEntities'; @@ -76,20 +76,12 @@ const Label = ( { name, time, range, size }: { moveEntities( { moveValue: false, snapOriginTime: time } ); - let isMoved = false; - registerMouseEvent( - () => { - isMoved = true; - }, - () => { - if ( !isMoved ) { - dispatch( { - type: 'Timeline/SelectLabels', - labels: [ name ], - } ); - } - }, - ); + registerMouseNoDragEvent( () => { + dispatch( { + type: 'Timeline/SelectLabels', + labels: [ name ], + } ); + } ); }, [ isSelected, moveEntities, time, dispatch, name ] ); @@ -103,20 +95,14 @@ const Label = ( { name, time, range, size }: { moveEntities( { moveValue: false, snapOriginTime: time } ); - let isMoved = false; - registerMouseEvent( - () => { - isMoved = true; - }, - () => { - if ( !isMoved && isSelected ) { - dispatch( { - type: 'Timeline/SelectLabelsSub', - labels: [ name ], - } ); - } - }, - ); + registerMouseNoDragEvent( () => { + if ( isSelected ) { + dispatch( { + type: 'Timeline/SelectLabelsSub', + labels: [ name ], + } ); + } + } ); }, [ dispatch, isSelected, moveEntities, name, time ] ); diff --git a/packages/automaton-with-gui/src/view/components/TimelineItemConstant.tsx b/packages/automaton-with-gui/src/view/components/TimelineItemConstant.tsx index 615cf61a..e4fa9d0f 100644 --- a/packages/automaton-with-gui/src/view/components/TimelineItemConstant.tsx +++ b/packages/automaton-with-gui/src/view/components/TimelineItemConstant.tsx @@ -5,6 +5,7 @@ import { Resolution } from '../utils/Resolution'; import { TimeValueRange, dt2dx, dx2dt, snapTime, t2x, v2y } from '../utils/TimeValueRange'; import { objectMapHas } from '../utils/objectMap'; import { registerMouseEvent } from '../utils/registerMouseEvent'; +import { registerMouseNoDragEvent } from '../utils/registerMouseNoDragEvent'; import { useDispatch, useSelector } from '../states/store'; import { useDoubleClick } from '../utils/useDoubleClick'; import { useID } from '../utils/useID'; @@ -122,23 +123,15 @@ const TimelineItemConstant = ( props: TimelineItemConstantProps ): JSX.Element = snapOriginValue: item.value, } ); - let isMoved = false; - registerMouseEvent( - () => { - isMoved = true; - }, - () => { - if ( !isMoved ) { - dispatch( { - type: 'Timeline/SelectItems', - items: [ { - id: item.$id, - channel: channelName - } ] - } ); - } - }, - ); + registerMouseNoDragEvent( () => { + dispatch( { + type: 'Timeline/SelectItems', + items: [ { + id: item.$id, + channel: channelName + } ] + } ); + } ); }, [ dispatch, @@ -168,24 +161,17 @@ const TimelineItemConstant = ( props: TimelineItemConstantProps ): JSX.Element = snapOriginValue: item.value, } ); - let isMoved = false; - registerMouseEvent( - () => { - isMoved = true; - }, - () => { - if ( !isMoved && isSelected ) { - dispatch( { - type: 'Timeline/SelectItemsSub', - items: [ { - id: item.$id, - channel: channelName, - } ], - } ); - } - }, - ); - + registerMouseNoDragEvent( () => { + if ( isSelected ) { + dispatch( { + type: 'Timeline/SelectItemsSub', + items: [ { + id: item.$id, + channel: channelName, + } ], + } ); + } + } ); }, [ channelName, diff --git a/packages/automaton-with-gui/src/view/components/TimelineItemCurve.tsx b/packages/automaton-with-gui/src/view/components/TimelineItemCurve.tsx index 68c66115..601bdf3f 100644 --- a/packages/automaton-with-gui/src/view/components/TimelineItemCurve.tsx +++ b/packages/automaton-with-gui/src/view/components/TimelineItemCurve.tsx @@ -7,6 +7,7 @@ import { genID } from '@fms-cat/automaton-with-gui/src/utils/genID'; import { jsonCopy } from '@fms-cat/automaton-with-gui/src/utils/jsonCopy'; import { objectMapHas } from '../utils/objectMap'; import { registerMouseEvent } from '../utils/registerMouseEvent'; +import { registerMouseNoDragEvent } from '../utils/registerMouseNoDragEvent'; import { useDispatch, useSelector } from '../states/store'; import { useDoubleClick } from '../utils/useDoubleClick'; import { useID } from '../utils/useID'; @@ -148,23 +149,15 @@ const TimelineItemCurve = ( props: TimelineItemCurveProps ): JSX.Element => { snapOriginValue: item.value, } ); - let isMoved = false; - registerMouseEvent( - () => { - isMoved = true; - }, - () => { - if ( !isMoved ) { - dispatch( { - type: 'Timeline/SelectItems', - items: [ { - id: item.$id, - channel: channelName - } ] - } ); - } - }, - ); + registerMouseNoDragEvent( () => { + dispatch( { + type: 'Timeline/SelectItems', + items: [ { + id: item.$id, + channel: channelName + } ] + } ); + } ); }, [ dispatch, @@ -194,23 +187,17 @@ const TimelineItemCurve = ( props: TimelineItemCurveProps ): JSX.Element => { snapOriginValue: item.value, } ); - let isMoved = false; - registerMouseEvent( - () => { - isMoved = true; - }, - () => { - if ( !isMoved && isSelected ) { - dispatch( { - type: 'Timeline/SelectItemsSub', - items: [ { - id: item.$id, - channel: channelName, - } ], - } ); - } - }, - ); + registerMouseNoDragEvent( () => { + if ( isSelected ) { + dispatch( { + type: 'Timeline/SelectItemsSub', + items: [ { + id: item.$id, + channel: channelName, + } ], + } ); + } + } ); }, [ channelName, diff --git a/packages/automaton-with-gui/src/view/utils/registerMouseNoDragEvent.ts b/packages/automaton-with-gui/src/view/utils/registerMouseNoDragEvent.ts new file mode 100644 index 00000000..4761d26f --- /dev/null +++ b/packages/automaton-with-gui/src/view/utils/registerMouseNoDragEvent.ts @@ -0,0 +1,18 @@ +import { registerMouseEvent } from './registerMouseEvent'; + +export function registerMouseNoDragEvent( + handler: ( event: MouseEvent ) => void +): void { + let isMoved = false; + + registerMouseEvent( + () => { + isMoved = true; + }, + ( event ) => { + if ( !isMoved ) { + handler( event ); + } + }, + ); +} From ba06b989d46aaef5a98b06985d264e7c1b48a3ad Mon Sep 17 00:00:00 2001 From: FMS-Cat Date: Wed, 14 Apr 2021 23:17:19 +0900 Subject: [PATCH 05/11] feature (gui): Multiple selection of items, rect selection --- .../src/view/components/ChannelEditor.tsx | 201 ++++++++++++++---- .../components/ChannelListAndDopeSheet.tsx | 1 + .../src/view/components/ChannelListEntry.tsx | 5 +- .../src/view/components/DopeSheet.tsx | 144 +++++++++++-- .../src/view/components/DopeSheetEntry.tsx | 125 ++++++++--- .../src/view/components/Label.tsx | 11 +- .../src/view/components/RectSelectView.tsx | 51 +++++ .../src/view/components/TimelineItem.tsx | 184 +++++++++++++++- .../view/components/TimelineItemConstant.tsx | 115 +--------- .../src/view/components/TimelineItemCurve.tsx | 115 +--------- .../src/view/constants/Metrics.ts | 1 + .../src/view/utils/mouseCombo.ts | 28 ++- .../src/view/utils/testRectIntersection.ts | 12 ++ .../src/view/utils/useMoveEntities.ts | 20 +- .../src/view/utils/useRect.ts | 1 + .../src/view/utils/useTimeValueRange.ts | 126 +++++++++++ 16 files changed, 801 insertions(+), 339 deletions(-) create mode 100644 packages/automaton-with-gui/src/view/components/RectSelectView.tsx create mode 100644 packages/automaton-with-gui/src/view/utils/testRectIntersection.ts create mode 100644 packages/automaton-with-gui/src/view/utils/useTimeValueRange.ts diff --git a/packages/automaton-with-gui/src/view/components/ChannelEditor.tsx b/packages/automaton-with-gui/src/view/components/ChannelEditor.tsx index aed68501..c1c84497 100644 --- a/packages/automaton-with-gui/src/view/components/ChannelEditor.tsx +++ b/packages/automaton-with-gui/src/view/components/ChannelEditor.tsx @@ -2,23 +2,35 @@ import { Colors } from '../constants/Colors'; import { Labels } from './Labels'; import { MouseComboBit, mouseCombo } from '../utils/mouseCombo'; import { RangeBar } from './RangeBar'; +import { RectSelectView } from './RectSelectView'; import { Resolution } from '../utils/Resolution'; import { TimeLoopRegion } from './TimeLoopRegion'; import { TimeValueGrid } from './TimeValueGrid'; import { TimeValueLines } from './TimeValueLines'; -import { TimeValueRange, dt2dx, dx2dt, dy2dv, snapTime, snapValue, x2t, y2v } from '../utils/TimeValueRange'; +import { TimeValueRange } from '../utils/TimeValueRange'; import { TimelineItem } from './TimelineItem'; import { binarySearch } from '../utils/binarySearch'; import { hasOverwrap } from '../../utils/hasOverwrap'; import { registerMouseEvent } from '../utils/registerMouseEvent'; import { showToasty } from '../states/Toasty'; import { useDispatch, useSelector } from '../states/store'; +import { useDoubleClick } from '../utils/useDoubleClick'; import { useRect } from '../utils/useRect'; +import { useTimeValueRangeFuncs } from '../utils/useTimeValueRange'; import { useWheelEvent } from '../utils/useWheelEvent'; -import React, { useCallback, useMemo, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import styled from 'styled-components'; import type { StateChannelItem } from '../../types/StateChannelItem'; +// == rect select - interface ====================================================================== +export interface ChannelEditorRectSelectState { + isSelecting: boolean; + t0: number; + v0: number; + t1: number; + v1: number; +} + // == microcomponent =============================================================================== const Lines = ( { channel, range, size }: { channel: string | null; @@ -39,28 +51,36 @@ const Lines = ( { channel, range, size }: { />; }; -const Items = ( { channel, range, size }: { +const Items = ( { channel, range, size, rectSelect }: { channel: string; range: TimeValueRange; size: Resolution; + rectSelect: { + isSelecting: boolean; + t0: number; + t1: number; + v0: number; + v1: number; + }; } ): JSX.Element => { const { sortedItems } = useSelector( ( state ) => ( { sortedItems: state.automaton.channels[ channel ].sortedItems, } ) ); + const { dt2dx } = useTimeValueRangeFuncs( range, size ); const itemsInRange = useMemo( () => { const i0 = binarySearch( sortedItems, - ( item ) => dt2dx( ( item.time + item.length ) - range.t0, range, size.width ) < -20.0, + ( item ) => dt2dx( ( item.time + item.length ) - range.t0 ) < -20.0, ); const i1 = binarySearch( sortedItems, - ( item ) => dt2dx( item.time - range.t1, range, size.width ) < 20.0, + ( item ) => dt2dx( item.time - range.t1 ) < 20.0, ); return sortedItems.slice( i0, i1 ); }, - [ range, size.width, sortedItems ] + [ dt2dx, range.t0, range.t1, sortedItems ] ); return <> @@ -71,6 +91,7 @@ const Items = ( { channel, range, size }: { item={ item } range={ range } size={ size } + rectSelectState={ rectSelect } /> ) ) } ; @@ -100,6 +121,8 @@ const StyledRangeBar = styled( RangeBar )` `; const Root = styled.div` + position: relative; + overflow: hidden; `; // == props ======================================================================================== @@ -114,7 +137,6 @@ const ChannelEditor = ( { className }: Props ): JSX.Element => { automaton, selectedChannel, range, - guiSettings, automatonLength, lastSelectedItem, selectedCurve @@ -122,15 +144,25 @@ const ChannelEditor = ( { className }: Props ): JSX.Element => { automaton: state.automaton.instance, selectedChannel: state.timeline.selectedChannel, range: state.timeline.range, - guiSettings: state.automaton.guiSettings, automatonLength: state.automaton.length, lastSelectedItem: state.timeline.lastSelectedItem, selectedCurve: state.curveEditor.selectedCurve } ) ); const channel = selectedChannel != null && automaton?.getChannel( selectedChannel ); + const checkDoubleClick = useDoubleClick(); const refBody = useRef( null ); const rect = useRect( refBody ); + const { + x2t, + y2v, + dx2dt, + t2x, + v2y, + dy2dv, + snapTime, + snapValue, + } = useTimeValueRangeFuncs( range, rect ); const move = useCallback( ( dx: number, dy: number ): void => { @@ -169,8 +201,8 @@ const ChannelEditor = ( { className }: Props ): JSX.Element => { return; } - const t = x2t( x - rect.left, range, rect.width ); - const v = y2v( y - rect.top, range, rect.height ); + const t = x2t( x - rect.left ); + const v = y2v( y - rect.top ); const thereAreNoOtherItemsHere = channel.items.every( ( item ) => ( !hasOverwrap( item.time, item.length, t, 0.0 ) @@ -209,7 +241,7 @@ const ChannelEditor = ( { className }: Props ): JSX.Element => { ], } ); }, - [ range, rect, selectedChannel, channel, dispatch ] + [ selectedChannel, channel, x2t, rect.left, rect.top, y2v, dispatch ] ); const createNewCurve = useCallback( @@ -224,7 +256,7 @@ const ChannelEditor = ( { className }: Props ): JSX.Element => { return; } - const t = x2t( x - rect.left, range, rect.width ); + const t = x2t( x - rect.left ); const thereAreNoOtherItemsHere = channel.items.every( ( item ) => ( !hasOverwrap( item.time, item.length, t, 0.0 ) @@ -267,14 +299,14 @@ const ChannelEditor = ( { className }: Props ): JSX.Element => { ], } ); }, - [ automaton, range, rect, selectedChannel, channel, dispatch ] + [ automaton, selectedChannel, channel, x2t, rect.left, dispatch ] ); const createLabel = useCallback( ( x: number, y: number ): void => { if ( !automaton ) { return; } - const time = x2t( x - rect.left, range, rect.width ); + const time = x2t( x - rect.left ); dispatch( { type: 'TextPrompt/Open', @@ -307,14 +339,14 @@ const ChannelEditor = ( { className }: Props ): JSX.Element => { } } ); }, - [ automaton, range, rect, dispatch ] + [ automaton, x2t, rect.left, dispatch ] ); const createItemAndGrab = useCallback( ( x: number, y: number ): void => { if ( !automaton || !selectedChannel || !channel ) { return; } - const t0 = x2t( x - rect.left, range, rect.width ); + const t0 = x2t( x - rect.left ); const thereAreNoOtherItemsHere = channel.items.every( ( item ) => ( !hasOverwrap( item.time, item.length, t0, 0.0 ) @@ -324,7 +356,7 @@ const ChannelEditor = ( { className }: Props ): JSX.Element => { let data: StateChannelItem | null = null; - let v0 = y2v( y - rect.top, range, rect.height ); + let v0 = y2v( y - rect.top ); // try last selected item if ( lastSelectedItem ) { @@ -374,12 +406,12 @@ const ChannelEditor = ( { className }: Props ): JSX.Element => { const holdValue = event.shiftKey; const ignoreSnap = event.altKey; - time = holdTime ? t0 : ( t0 + dx2dt( dx, range, rect.width ) ); - value = holdValue ? v0 : ( v0 + dy2dv( dy, range, rect.height ) ); + time = holdTime ? t0 : ( t0 + dx2dt( dx ) ); + value = holdValue ? v0 : ( v0 + dy2dv( dy ) ); if ( !ignoreSnap ) { - if ( !holdTime ) { time = snapTime( time, range, rect.width, guiSettings ); } - if ( !holdValue ) { value = snapValue( value, range, rect.height, guiSettings ); } + if ( !holdTime ) { time = snapTime( time ); } + if ( !holdValue ) { value = snapValue( value ); } } channel.moveItem( confirmedData.$id, time ); @@ -407,14 +439,19 @@ const ChannelEditor = ( { className }: Props ): JSX.Element => { }, [ automaton, - lastSelectedItem, - selectedCurve, - range, - rect, - guiSettings, selectedChannel, channel, - dispatch + x2t, + rect.left, + rect.top, + y2v, + lastSelectedItem, + dispatch, + selectedCurve, + dx2dt, + dy2dv, + snapTime, + snapValue, ] ); @@ -425,7 +462,7 @@ const ChannelEditor = ( { className }: Props ): JSX.Element => { const isPlaying = automaton.isPlaying; automaton.pause(); - const t0 = x2t( x - rect.left, range, rect.width ); + const t0 = x2t( x - rect.left ); automaton.seek( t0 ); let dx = 0.0; @@ -434,7 +471,7 @@ const ChannelEditor = ( { className }: Props ): JSX.Element => { registerMouseEvent( ( event, movementSum ) => { dx += movementSum.x; - t = t0 + dx2dt( dx, range, rect.width ); + t = t0 + dx2dt( dx ); automaton.seek( t ); }, () => { @@ -443,15 +480,15 @@ const ChannelEditor = ( { className }: Props ): JSX.Element => { } ); }, - [ automaton, range, rect ] + [ automaton, dx2dt, rect.left, x2t ] ); const startSetLoopRegion = useCallback( ( x: number ): void => { if ( !automaton ) { return; } - const t0Raw = x2t( x - rect.left, range, rect.width ); - const t0 = snapTime( t0Raw, range, rect.width, guiSettings ); + const t0Raw = x2t( x - rect.left ); + const t0 = snapTime( t0Raw ); let dx = 0.0; let t = t0; @@ -460,8 +497,8 @@ const ChannelEditor = ( { className }: Props ): JSX.Element => { ( event, movementSum ) => { dx += movementSum.x; - const tRaw = t0 + dx2dt( dx, range, rect.width ); - t = snapTime( tRaw, range, rect.width, guiSettings ); + const tRaw = t0 + dx2dt( dx ); + t = snapTime( tRaw ); if ( t - t0 === 0.0 ) { automaton.setLoopRegion( null ); @@ -482,16 +519,83 @@ const ChannelEditor = ( { className }: Props ): JSX.Element => { } ); }, - [ automaton, range, rect, guiSettings ] + [ automaton, x2t, rect.left, snapTime, dx2dt ] + ); + + const refX2t = useRef( x2t ); + const refY2v = useRef( y2v ); + useEffect( () => { refX2t.current = x2t; }, [ x2t ] ); + useEffect( () => { refY2v.current = y2v; }, [ y2v ] ); + + const [ rectSelectState, setRectSelectState ] = useState( { + isSelecting: false, + t0: Infinity, + t1: -Infinity, + v0: Infinity, + v1: -Infinity, + } ); + + const startRectSelect = useCallback( + ( x: number, y: number ) => { + const t0 = refX2t.current( x - rect.left ); + const v0 = refY2v.current( y - rect.top ); + let t1 = t0; + let v1 = v0; + + setRectSelectState( { + isSelecting: true, + t0, + t1, + v0, + v1, + } ); + + registerMouseEvent( + ( event ) => { + t1 = refX2t.current( event.clientX - rect.left ); + v1 = refY2v.current( event.clientY - rect.top ); + + setRectSelectState( { + isSelecting: true, + t0: Math.min( t0, t1 ), + t1: Math.max( t0, t1 ), + v0: Math.min( v0, v1 ), + v1: Math.max( v0, v1 ), + } ); + }, + () => { + setRectSelectState( { + isSelecting: false, + t0: Infinity, + t1: -Infinity, + v0: Infinity, + v1: -Infinity, + } ); + }, + ); + }, + [ rect.left, rect.top ] ); const handleMouseDown = useCallback( ( event ) => mouseCombo( event, { [ MouseComboBit.LMB ]: ( event ) => { - createItemAndGrab( - event.clientX, - event.clientY - ); + if ( checkDoubleClick() ) { + createItemAndGrab( + event.clientX, + event.clientY + ); + } else { + dispatch( { + type: 'Timeline/SelectItems', + items: [], + } ); + + startRectSelect( event.clientX, event.clientY ); + } + }, + [ MouseComboBit.LMB + MouseComboBit.Ctrl ]: ( event ) => { + startRectSelect( event.clientX, event.clientY ); }, [ MouseComboBit.LMB + MouseComboBit.Alt ]: ( event ) => { startSeek( event.clientX ); @@ -505,7 +609,15 @@ const ChannelEditor = ( { className }: Props ): JSX.Element => { ); } } ), - [ createItemAndGrab, startSeek, move, startSetLoopRegion ] + [ + checkDoubleClick, + createItemAndGrab, + dispatch, + startRectSelect, + startSeek, + startSetLoopRegion, + move, + ] ); const handleContextMenu = useCallback( @@ -579,6 +691,7 @@ const ChannelEditor = ( { className }: Props ): JSX.Element => { channel={ selectedChannel } range={ range } size={ rect } + rectSelect={ rectSelectState } /> } { width={ rect.width } length={ automatonLength } /> + { rectSelectState.isSelecting && ( + + ) } ); }; diff --git a/packages/automaton-with-gui/src/view/components/ChannelListAndDopeSheet.tsx b/packages/automaton-with-gui/src/view/components/ChannelListAndDopeSheet.tsx index d73b5a9e..8b1a0bac 100644 --- a/packages/automaton-with-gui/src/view/components/ChannelListAndDopeSheet.tsx +++ b/packages/automaton-with-gui/src/view/components/ChannelListAndDopeSheet.tsx @@ -173,6 +173,7 @@ const ChannelListAndDopeSheet = ( props: { /> } { mode === 'dope' && ( ) } diff --git a/packages/automaton-with-gui/src/view/components/ChannelListEntry.tsx b/packages/automaton-with-gui/src/view/components/ChannelListEntry.tsx index 110587ee..2d2c915f 100644 --- a/packages/automaton-with-gui/src/view/components/ChannelListEntry.tsx +++ b/packages/automaton-with-gui/src/view/components/ChannelListEntry.tsx @@ -1,4 +1,5 @@ import { Colors } from '../constants/Colors'; +import { Metrics } from '../constants/Metrics'; import { StatusIcon } from './StatusIcon'; import { duplicateName } from '../utils/duplicateName'; import { registerMouseEvent } from '../utils/registerMouseEvent'; @@ -52,7 +53,7 @@ const StyledValue = styled( Value )` const Root = styled.div<{ isSelected: boolean }>` position: relative; - height: 18px; + height: ${ Metrics.channelListEntyHeight - 2 }px; background: ${ ( { isSelected } ) => ( isSelected ? Colors.back4 : Colors.back3 ) }; box-shadow: ${ ( { isSelected } ) => ( isSelected ? `0 0 0 1px ${ Colors.accent }` : 'none' ) }; `; @@ -118,7 +119,7 @@ const ChannelListEntry = ( props: ChannelListEntryProps ): JSX.Element => { registerMouseEvent( ( event ) => { const currentY = event.clientY - ( refScrollTop.current ?? 0.0 ); - deltaIndex = Math.round( ( currentY - initY ) / 20 ); // 🔥 hardcoded + deltaIndex = Math.round( ( currentY - initY ) / Metrics.channelListEntyHeight ); reorder( deltaIndex ); }, () => { diff --git a/packages/automaton-with-gui/src/view/components/DopeSheet.tsx b/packages/automaton-with-gui/src/view/components/DopeSheet.tsx index f5d4d2fe..83b482ea 100644 --- a/packages/automaton-with-gui/src/view/components/DopeSheet.tsx +++ b/packages/automaton-with-gui/src/view/components/DopeSheet.tsx @@ -1,29 +1,44 @@ import { DopeSheetEntry } from './DopeSheetEntry'; +import { Metrics } from '../constants/Metrics'; import { MouseComboBit, mouseCombo } from '../utils/mouseCombo'; -import { dx2dt, snapTime, x2t } from '../utils/TimeValueRange'; +import { RectSelectView } from './RectSelectView'; import { registerMouseEvent } from '../utils/registerMouseEvent'; import { useDispatch, useSelector } from '../states/store'; import { useRect } from '../utils/useRect'; +import { useTimeValueRangeFuncs } from '../utils/useTimeValueRange'; import { useWheelEvent } from '../utils/useWheelEvent'; -import React, { useCallback, useMemo, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import styled from 'styled-components'; +// == rect select - interface ====================================================================== +export interface DopeSheetRectSelectState { + isSelecting: boolean; + channels: string[]; + t0: number; + t1: number; +} + // == styles ======================================================================================= const StyledDopeSheetEntry = styled( DopeSheetEntry )` margin: 2px 0; `; const Root = styled.div` + position: relative; + overflow: hidden; `; // == props ======================================================================================== export interface DopeSheetProps { className?: string; intersectionRoot: HTMLElement | null; + refScrollTop: React.RefObject; } // == component ==================================================================================== -const DopeSheet = ( { className, intersectionRoot }: DopeSheetProps ): JSX.Element => { +const DopeSheet = ( + { className, refScrollTop, intersectionRoot }: DopeSheetProps +): JSX.Element => { const dispatch = useDispatch(); const refRoot = useRef( null ); const rect = useRect( refRoot ); @@ -31,14 +46,19 @@ const DopeSheet = ( { className, intersectionRoot }: DopeSheetProps ): JSX.Eleme automaton, channelNames, range, - guiSettings, } = useSelector( ( state ) => ( { automaton: state.automaton.instance, channelNames: state.automaton.channelNames, range: state.timeline.range, - guiSettings: state.automaton.guiSettings, } ) ); + const { + x2t, + t2x, + dx2dt, + snapTime, + } = useTimeValueRangeFuncs( range, rect ); + const timeRange = useMemo( () => ( { t0: range.t0, @@ -80,7 +100,7 @@ const DopeSheet = ( { className, intersectionRoot }: DopeSheetProps ): JSX.Eleme const isPlaying = automaton.isPlaying; automaton.pause(); - const t0 = x2t( x - rect.left, timeRange, rect.width ); + const t0 = x2t( x - rect.left ); automaton.seek( t0 ); let dx = 0.0; @@ -89,7 +109,7 @@ const DopeSheet = ( { className, intersectionRoot }: DopeSheetProps ): JSX.Eleme registerMouseEvent( ( event, movementSum ) => { dx += movementSum.x; - t = t0 + dx2dt( dx, timeRange, rect.width ); + t = t0 + dx2dt( dx ); automaton.seek( t ); }, () => { @@ -98,15 +118,15 @@ const DopeSheet = ( { className, intersectionRoot }: DopeSheetProps ): JSX.Eleme } ); }, - [ automaton, timeRange, rect ] + [ automaton, x2t, rect.left, dx2dt ] ); const startSetLoopRegion = useCallback( ( x: number ): void => { if ( !automaton ) { return; } - const t0Raw = x2t( x - rect.left, timeRange, rect.width ); - const t0 = snapTime( t0Raw, timeRange, rect.width, guiSettings ); + const t0Raw = x2t( x - rect.left ); + const t0 = snapTime( t0Raw ); let dx = 0.0; let t = t0; @@ -115,8 +135,8 @@ const DopeSheet = ( { className, intersectionRoot }: DopeSheetProps ): JSX.Eleme ( event, movementSum ) => { dx += movementSum.x; - const tRaw = t0 + dx2dt( dx, timeRange, rect.width ); - t = snapTime( tRaw, timeRange, rect.width, guiSettings ); + const tRaw = t0 + dx2dt( dx ); + t = snapTime( tRaw ); if ( t - t0 === 0.0 ) { automaton.setLoopRegion( null ); @@ -137,11 +157,92 @@ const DopeSheet = ( { className, intersectionRoot }: DopeSheetProps ): JSX.Eleme } ); }, - [ automaton, timeRange, rect, guiSettings ] + [ automaton, x2t, rect.left, snapTime, dx2dt ] + ); + + const refX2t = useRef( x2t ); + useEffect( () => { refX2t.current = x2t; }, [ x2t ] ); + + const [ rectSelectState, setRectSelectState ] = useState( { + isSelecting: false, + channels: [], + t0: Infinity, + t1: -Infinity, + } ); + const [ rectSelectYRange, setRectSelectYRange ] = useState<[ number, number ]>( [ 0.0, 0.0 ] ); + + const startRectSelect = useCallback( + ( x: number, y: number ) => { + const t0 = refX2t.current( x - rect.left ); + const y0 = y - rect.top - ( refScrollTop.current ?? 0.0 ); + let t1 = t0; + let y1 = y0; + let channels = channelNames.slice( + Math.max( 0, Math.floor( y0 / Metrics.channelListEntyHeight ) ), + Math.ceil( y1 / Metrics.channelListEntyHeight ), + ); + + setRectSelectState( { + isSelecting: true, + channels, + t0, + t1, + } ); + setRectSelectYRange( [ + Math.min( y0, y1 ), + Math.max( y0, y1 ), + ] ); + + registerMouseEvent( + ( event ) => { + t1 = refX2t.current( event.clientX - rect.left ); + y1 = event.clientY - rect.top - ( refScrollTop.current ?? 0.0 ); + channels = channelNames.slice( + Math.max( 0, Math.floor( Math.min( y0, y1 ) / Metrics.channelListEntyHeight ) ), + Math.ceil( Math.max( y0, y1 ) / Metrics.channelListEntyHeight ), + ); + + setRectSelectState( { + isSelecting: true, + channels, + t0: Math.min( t0, t1 ), + t1: Math.max( t0, t1 ), + } ); + setRectSelectYRange( [ + Math.min( y0, y1 ), + Math.max( y0, y1 ), + ] ); + }, + () => { + setRectSelectState( { + isSelecting: false, + channels: [], + t0: Infinity, + t1: -Infinity, + } ); + setRectSelectYRange( [ + Infinity, + -Infinity, + ] ); + }, + ); + }, + [ channelNames, rect.left, rect.top, refScrollTop ] ); const handleMouseDown = useCallback( ( event ) => mouseCombo( event, { + [ MouseComboBit.LMB ]: ( event ) => { + dispatch( { + type: 'Timeline/SelectItems', + items: [], + } ); + + startRectSelect( event.clientX, event.clientY ); + }, + [ MouseComboBit.LMB + MouseComboBit.Ctrl ]: ( event ) => { + startRectSelect( event.clientX, event.clientY ); + }, [ MouseComboBit.LMB + MouseComboBit.Alt ]: ( event ) => { startSeek( event.clientX ); }, @@ -154,14 +255,14 @@ const DopeSheet = ( { className, intersectionRoot }: DopeSheetProps ): JSX.Eleme ); } } ), - [ move, startSeek, startSetLoopRegion ] + [ dispatch, move, startRectSelect, startSeek, startSetLoopRegion ] ); const createLabel = useCallback( ( x: number, y: number ): void => { if ( !automaton ) { return; } - const time = x2t( x - rect.left, timeRange, rect.width ); + const time = x2t( x - rect.left ); dispatch( { type: 'TextPrompt/Open', @@ -189,7 +290,7 @@ const DopeSheet = ( { className, intersectionRoot }: DopeSheetProps ): JSX.Eleme } } ); }, - [ automaton, timeRange, rect, dispatch ] + [ automaton, x2t, rect.left, dispatch ] ); const handleContextMenu = useCallback( @@ -237,11 +338,12 @@ const DopeSheet = ( { className, intersectionRoot }: DopeSheetProps ): JSX.Eleme key={ channel } channel={ channel } range={ timeRange } + rectSelectState={ rectSelectState } intersectionRoot={ intersectionRoot } /> ) ) ), - [ channelNames, intersectionRoot, timeRange ] + [ channelNames, intersectionRoot, rectSelectState, timeRange ] ); return ( @@ -252,6 +354,14 @@ const DopeSheet = ( { className, intersectionRoot }: DopeSheetProps ): JSX.Eleme onContextMenu={ handleContextMenu } > { entries } + { rectSelectState.isSelecting && ( + + ) } ); }; diff --git a/packages/automaton-with-gui/src/view/components/DopeSheetEntry.tsx b/packages/automaton-with-gui/src/view/components/DopeSheetEntry.tsx index 4866c0be..bf521516 100644 --- a/packages/automaton-with-gui/src/view/components/DopeSheetEntry.tsx +++ b/packages/automaton-with-gui/src/view/components/DopeSheetEntry.tsx @@ -1,15 +1,21 @@ +import { ChannelEditorRectSelectState } from './ChannelEditor'; import { Colors } from '../constants/Colors'; +import { DopeSheetRectSelectState } from './DopeSheet'; +import { Metrics } from '../constants/Metrics'; import { MouseComboBit, mouseCombo } from '../utils/mouseCombo'; -import { TimeRange, dt2dx, dx2dt, snapTime, x2t } from '../utils/TimeValueRange'; +import { TimeRange } from '../utils/TimeValueRange'; import { TimelineItem } from './TimelineItem'; +import { arraySetHas } from '../utils/arraySet'; import { binarySearch } from '../utils/binarySearch'; import { hasOverwrap } from '../../utils/hasOverwrap'; import { registerMouseEvent } from '../utils/registerMouseEvent'; import { showToasty } from '../states/Toasty'; import { useDispatch, useSelector } from '../states/store'; +import { useDoubleClick } from '../utils/useDoubleClick'; import { useIntersection } from '../utils/useIntersection'; import { useRect } from '../utils/useRect'; -import React, { useCallback, useMemo, useRef } from 'react'; +import { useTimeValueRangeFuncs } from '../utils/useTimeValueRange'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import styled from 'styled-components'; import type { StateChannelItem } from '../../types/StateChannelItem'; @@ -42,7 +48,7 @@ const Proximity = styled.div` const Container = styled.div` position: absolute; width: 100%; - height: 18px; + height: ${ Metrics.channelListEntyHeight - 2 }px; overflow: hidden; &:hover ${ Underlay } { @@ -54,16 +60,17 @@ const Root = styled.div` display: block; position: relative; width: 100%; - height: 18px; + height: ${ Metrics.channelListEntyHeight - 2 }px; `; // == a content - if it isn't intersecting, return an empty div instead ============================ const Content = ( props: { channel: string; range: TimeRange; + rectSelectState: DopeSheetRectSelectState; refRoot: React.RefObject; } ): JSX.Element => { - const { range, refRoot } = props; + const { range, refRoot, rectSelectState } = props; const channelName = props.channel; const dispatch = useDispatch(); const { @@ -71,15 +78,14 @@ const Content = ( props: { lastSelectedItem, selectedCurve, sortedItems, - guiSettings } = useSelector( ( state ) => ( { automaton: state.automaton.instance, lastSelectedItem: state.timeline.lastSelectedItem, selectedCurve: state.curveEditor.selectedCurve, stateItems: state.automaton.channels[ channelName ].items, sortedItems: state.automaton.channels[ channelName ].sortedItems, - guiSettings: state.automaton.guiSettings } ) ); + const checkDoubleClick = useDoubleClick(); const channel = automaton?.getChannel( channelName ); const rect = useRect( refRoot ); @@ -95,26 +101,51 @@ const Content = ( props: { [ range.t0, range.t1 ] ); + const { + x2t, + dx2dt, + dt2dx, + snapTime, + } = useTimeValueRangeFuncs( timeValueRange, rect ); + + const channelIsRectSelected = useMemo( + () => arraySetHas( rectSelectState.channels, channelName ), + [ channelName, rectSelectState.channels ], + ); + + const rectSelectStateForItem: ChannelEditorRectSelectState = useMemo( + () => { + return { + isSelecting: rectSelectState.isSelecting, + t0: rectSelectState.t0, + t1: rectSelectState.t1, + v0: channelIsRectSelected ? -Infinity : Infinity, + v1: channelIsRectSelected ? Infinity : -Infinity, + }; + }, + [ channelIsRectSelected, rectSelectState.isSelecting, rectSelectState.t0, rectSelectState.t1 ], + ); + const itemsInRange = useMemo( () => { const i0 = binarySearch( sortedItems, - ( item ) => dt2dx( ( item.time + item.length ) - range.t0, range, rect.width ) < -20.0, + ( item ) => dt2dx( ( item.time + item.length ) - range.t0 ) < -20.0, ); const i1 = binarySearch( sortedItems, - ( item ) => dt2dx( item.time - range.t1, range, rect.width ) < 20.0, + ( item ) => dt2dx( item.time - range.t1 ) < 20.0, ); return sortedItems.slice( i0, i1 ); }, - [ range, rect.width, sortedItems ] + [ dt2dx, range.t0, range.t1, sortedItems ] ); const createConstant = useCallback( ( x: number ): void => { if ( !channel ) { return; } - const t = x2t( x - rect.left, range, rect.width ); + const t = x2t( x - rect.left ); const thereAreNoOtherItemsHere = channel.items.every( ( item ) => ( !hasOverwrap( item.time, item.length, t, 0.0 ) @@ -151,14 +182,14 @@ const Content = ( props: { ], } ); }, - [ range, rect, channelName, channel, dispatch ] + [ channel, x2t, rect.left, dispatch, channelName ] ); const createNewCurve = useCallback( ( x: number ): void => { if ( !automaton || !channel ) { return; } - const t = x2t( x - rect.left, range, rect.width ); + const t = x2t( x - rect.left ); const thereAreNoOtherItemsHere = channel.items.every( ( item ) => ( !hasOverwrap( item.time, item.length, t, 0.0 ) @@ -201,14 +232,14 @@ const Content = ( props: { ], } ); }, - [ automaton, range, rect, channelName, channel, dispatch ] + [ automaton, channel, x2t, rect.left, dispatch, channelName ] ); const createItemAndGrab = useCallback( ( x: number ): void => { if ( !automaton || !channel ) { return; } - const t0 = x2t( x - rect.left, range, rect.width ); + const t0 = x2t( x - rect.left ); const thereAreNoOtherItemsHere = channel.items.every( ( item ) => ( !hasOverwrap( item.time, item.length, t0, 0.0 ) @@ -258,10 +289,10 @@ const Content = ( props: { const ignoreSnap = event.altKey; - time = t0 + dx2dt( dx, range, rect.width ); + time = t0 + dx2dt( dx ); if ( !ignoreSnap ) { - time = snapTime( time, range, rect.width, guiSettings ); + time = snapTime( time ); } channel.moveItem( confirmedData.$id, time ); @@ -284,26 +315,32 @@ const Content = ( props: { } ); }, - [ automaton, + [ + automaton, + channel, + x2t, + rect.left, lastSelectedItem, - selectedCurve, - range, - rect, - guiSettings, + dispatch, channelName, - channel, - dispatch - ] + selectedCurve, + dx2dt, + snapTime, + ], ); const handleMouseDown = useCallback( ( event ) => mouseCombo( event, { [ MouseComboBit.LMB ]: ( event ) => { - createItemAndGrab( event.clientX ); + if ( checkDoubleClick() ) { + createItemAndGrab( event.clientX ); + } else { + return false; + } }, [ MouseComboBit.LMB + MouseComboBit.Alt ]: false, // give a way to seek! } ), - [ createItemAndGrab ] + [ checkDoubleClick, createItemAndGrab ] ); const handleContextMenu = useCallback( @@ -342,11 +379,12 @@ const Content = ( props: { item={ item } range={ timeValueRange } size={ rect } + rectSelectState={ rectSelectStateForItem } dopeSheetMode /> ) ) ), - [ channelName, itemsInRange, rect, timeValueRange ] + [ channelName, itemsInRange, rect, rectSelectStateForItem, timeValueRange ] ); return @@ -365,17 +403,39 @@ const DopeSheetEntry = ( props: { className?: string; channel: string; range: TimeRange; + rectSelectState: DopeSheetRectSelectState; intersectionRoot: HTMLElement | null; } ): JSX.Element => { - const { className, intersectionRoot } = props; + const { range, className, intersectionRoot, rectSelectState } = props; + const channelName = props.channel; const refRoot = useRef( null ); const refProximity = useRef( null ); + // whether the channel is out of screen or not const isIntersecting = useIntersection( refProximity, { root: intersectionRoot, } ); + // want to select out of screen channels properly + const channelIsRectSelected = useMemo( + () => arraySetHas( rectSelectState.channels, channelName ), + [ channelName, rectSelectState.channels ], + ); + + // a single update will be required to unselect out of screen channels properly + const [ prevChannelIsRectSelected, setPrevChannelIsRectSelected ] = useState( false ); + useEffect( + () => { + setPrevChannelIsRectSelected( channelIsRectSelected ); + }, + [ channelIsRectSelected ], + ); + + const shouldShowContent = isIntersecting + || channelIsRectSelected + || prevChannelIsRectSelected; + return ( - { isIntersecting ? : null } diff --git a/packages/automaton-with-gui/src/view/components/Label.tsx b/packages/automaton-with-gui/src/view/components/Label.tsx index e582b52c..4df16e29 100644 --- a/packages/automaton-with-gui/src/view/components/Label.tsx +++ b/packages/automaton-with-gui/src/view/components/Label.tsx @@ -46,7 +46,16 @@ const Label = ( { name, time, range, size }: { guiSettings: state.automaton.guiSettings, selectedLabels: state.timeline.selected.labels } ) ); - const { moveEntities } = useMoveEntites( size ); + const timeValueRange = useMemo( + () => ( { + t0: range.t0, + v0: 0.0, + t1: range.t1, + v1: 1.0, + } ), + [ range.t0, range.t1 ] + ); + const { moveEntities } = useMoveEntites( timeValueRange, size ); const checkDoubleClick = useDoubleClick(); const [ width, setWidth ] = useState( 0.0 ); const x = t2x( time, range, size.width ); diff --git a/packages/automaton-with-gui/src/view/components/RectSelectView.tsx b/packages/automaton-with-gui/src/view/components/RectSelectView.tsx new file mode 100644 index 00000000..ffe1d8af --- /dev/null +++ b/packages/automaton-with-gui/src/view/components/RectSelectView.tsx @@ -0,0 +1,51 @@ +import { Colors } from '../constants/Colors'; +import React, { useMemo } from 'react'; +import styled from 'styled-components'; + +// == styles ======================================================================================= +const Background = styled.div` + position: absolute; + width: 100%; + height: 100%; + background: ${ Colors.accent }; + opacity: 0.2; +`; + +const Root = styled.div` + position: absolute; + border: solid 1px ${ Colors.accent }; + pointer-events: none; +`; + +// == props ======================================================================================== +interface Props { + className?: string; + x0: number; + y0: number; + x1: number; + y1: number; +} + +// == component ==================================================================================== +const RectSelectView = ( { className, x0, y0, x1, y1 }: Props ): JSX.Element => { + const style = useMemo( + () => ( { + left: x0, + top: y0, + width: x1 - x0, + height: y1 - y0, + } ), + [ x0, y0, x1, y1 ], + ); + + return ( + + + + ); +}; + +export { RectSelectView }; diff --git a/packages/automaton-with-gui/src/view/components/TimelineItem.tsx b/packages/automaton-with-gui/src/view/components/TimelineItem.tsx index cf7f1d77..2b463c0e 100644 --- a/packages/automaton-with-gui/src/view/components/TimelineItem.tsx +++ b/packages/automaton-with-gui/src/view/components/TimelineItem.tsx @@ -2,7 +2,14 @@ import { Resolution } from '../utils/Resolution'; import { TimeValueRange } from '../utils/TimeValueRange'; import { TimelineItemConstant } from './TimelineItemConstant'; import { TimelineItemCurve } from './TimelineItemCurve'; -import React from 'react'; +import { objectMapHas } from '../utils/objectMap'; +import { registerMouseNoDragEvent } from '../utils/registerMouseNoDragEvent'; +import { testRectIntersection } from '../utils/testRectIntersection'; +import { useDispatch } from 'react-redux'; +import { useMoveEntites } from '../utils/useMoveEntities'; +import { useSelector } from '../states/store'; +import React, { useCallback, useEffect, useRef } from 'react'; +import type { ChannelEditorRectSelectState } from './ChannelEditor'; import type { StateChannelItem } from '../../types/StateChannelItem'; // == props ======================================================================================== @@ -11,30 +18,199 @@ export interface TimelineItemProps { item: StateChannelItem; range: TimeValueRange; size: Resolution; + rectSelectState?: ChannelEditorRectSelectState; dopeSheetMode?: boolean; } // == component ==================================================================================== const TimelineItem = ( props: TimelineItemProps ): JSX.Element => { - const { channel, item, range, size, dopeSheetMode } = props; + const { item, range, size, rectSelectState: rectSelect, dopeSheetMode } = props; + const channelName = props.channel; + const dispatch = useDispatch(); + const { moveEntities } = useMoveEntites( range, size ); + + const isSelected = useSelector( + ( state ) => objectMapHas( state.timeline.selected.items, item.$id ) + ); + + const automaton = useSelector( ( state ) => state.automaton.instance ); + const channel = channelName != null && automaton?.getChannel( channelName ) || null; + + const grabBody = useCallback( + (): void => { + if ( !isSelected ) { + dispatch( { + type: 'Timeline/SelectItems', + items: [ { + id: item.$id, + channel: channelName + } ] + } ); + + dispatch( { + type: 'Timeline/SelectChannel', + channel: channelName + } ); + } + + moveEntities( { + moveValue: !dopeSheetMode, + snapOriginTime: item.time, + snapOriginValue: item.value, + } ); + + registerMouseNoDragEvent( () => { + dispatch( { + type: 'Timeline/SelectItems', + items: [ { + id: item.$id, + channel: channelName + } ] + } ); + } ); + }, + [ + dispatch, + item.$id, + item.time, + item.value, + channelName, + moveEntities, + dopeSheetMode, + isSelected, + ] + ); + + const grabBodyCtrl = useCallback( + (): void => { + dispatch( { + type: 'Timeline/SelectItemsAdd', + items: [ { + id: item.$id, + channel: channelName, + } ], + } ); + + moveEntities( { + moveValue: !dopeSheetMode, + snapOriginTime: item.time, + snapOriginValue: item.value, + } ); + + registerMouseNoDragEvent( () => { + if ( isSelected ) { + dispatch( { + type: 'Timeline/SelectItemsSub', + items: [ { + id: item.$id, + channel: channelName, + } ], + } ); + } + } ); + }, + [ + channelName, + dispatch, + dopeSheetMode, + isSelected, + item.$id, + item.time, + item.value, + moveEntities, + ] + ); + + const removeItem = useCallback( + (): void => { + if ( !channel ) { return; } + + channel.removeItem( item.$id ); + + dispatch( { + type: 'History/Push', + description: 'Remove Constant', + commands: [ + { + type: 'channel/removeItem', + channel: channelName, + data: item + } + ], + } ); + }, + [ item, channel, dispatch, channelName ] + ); + + const refPrevIsIntersecting = useRef( false ); + useEffect( + (): void => { + if ( rectSelect?.isSelecting ) { + const v0 = item.value; + const v1 = item.value + ( item.curveId != null ? item.amp : 0.0 ); + + const isIntersecting = testRectIntersection( + item.time, + Math.min( v0, v1 ), + item.time + item.length, + Math.max( v0, v1 ), + rectSelect.t0, + rectSelect.v0, + rectSelect.t1, + rectSelect.v1, + ); + + if ( isIntersecting !== refPrevIsIntersecting.current ) { + dispatch( { + type: isIntersecting ? 'Timeline/SelectItemsAdd' : 'Timeline/SelectItemsSub', + items: [ { + id: item.$id, + channel: channelName, + } ], + } ); + } + + refPrevIsIntersecting.current = isIntersecting; + } else { + refPrevIsIntersecting.current = false; + } + }, + [ + item, + channel, + dispatch, + channelName, + rectSelect?.isSelecting, + rectSelect?.t0, + rectSelect?.v0, + rectSelect?.t1, + rectSelect?.v1, + ], + ); if ( item.curveId != null ) { return ( ); } else { return ( ); diff --git a/packages/automaton-with-gui/src/view/components/TimelineItemConstant.tsx b/packages/automaton-with-gui/src/view/components/TimelineItemConstant.tsx index e4fa9d0f..ad10fb54 100644 --- a/packages/automaton-with-gui/src/view/components/TimelineItemConstant.tsx +++ b/packages/automaton-with-gui/src/view/components/TimelineItemConstant.tsx @@ -5,11 +5,9 @@ import { Resolution } from '../utils/Resolution'; import { TimeValueRange, dt2dx, dx2dt, snapTime, t2x, v2y } from '../utils/TimeValueRange'; import { objectMapHas } from '../utils/objectMap'; import { registerMouseEvent } from '../utils/registerMouseEvent'; -import { registerMouseNoDragEvent } from '../utils/registerMouseNoDragEvent'; import { useDispatch, useSelector } from '../states/store'; import { useDoubleClick } from '../utils/useDoubleClick'; import { useID } from '../utils/useID'; -import { useMoveEntites } from '../utils/useMoveEntities'; import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import type { StateChannelItem } from '../../types/StateChannelItem'; @@ -61,12 +59,15 @@ export interface TimelineItemConstantProps { item: StateChannelItem; range: TimeValueRange; size: Resolution; + grabBody: () => void; + grabBodyCtrl: () => void; + removeItem: () => void; dopeSheetMode?: boolean; } // == component ==================================================================================== const TimelineItemConstant = ( props: TimelineItemConstantProps ): JSX.Element => { - const { item, range, size, dopeSheetMode } = props; + const { item, range, size, grabBody, grabBodyCtrl, removeItem, dopeSheetMode } = props; const channelName = props.channel; const dispatch = useDispatch(); @@ -83,8 +84,6 @@ const TimelineItemConstant = ( props: TimelineItemConstantProps ): JSX.Element = guiSettings: state.automaton.guiSettings } ) ); - const { moveEntities } = useMoveEntites( size ); - let x = useMemo( () => t2x( item.time, range, size.width ), [ item, range, size ] ); let w = useMemo( () => dt2dx( item.length, range, size.width ), [ item, range, size ] ); const y = useMemo( @@ -100,91 +99,6 @@ const TimelineItemConstant = ( props: TimelineItemConstantProps ): JSX.Element = const channel = channelName != null && automaton?.getChannel( channelName ) || null; - const grabBody = useCallback( - (): void => { - if ( !isSelected ) { - dispatch( { - type: 'Timeline/SelectItems', - items: [ { - id: item.$id, - channel: channelName - } ] - } ); - - dispatch( { - type: 'Timeline/SelectChannel', - channel: channelName - } ); - } - - moveEntities( { - moveValue: !dopeSheetMode, - snapOriginTime: item.time, - snapOriginValue: item.value, - } ); - - registerMouseNoDragEvent( () => { - dispatch( { - type: 'Timeline/SelectItems', - items: [ { - id: item.$id, - channel: channelName - } ] - } ); - } ); - }, - [ - dispatch, - item.$id, - item.time, - item.value, - channelName, - moveEntities, - dopeSheetMode, - isSelected, - ] - ); - - const grabBodyCtrl = useCallback( - (): void => { - dispatch( { - type: 'Timeline/SelectItemsAdd', - items: [ { - id: item.$id, - channel: channelName, - } ], - } ); - - moveEntities( { - moveValue: !dopeSheetMode, - snapOriginTime: item.time, - snapOriginValue: item.value, - } ); - - registerMouseNoDragEvent( () => { - if ( isSelected ) { - dispatch( { - type: 'Timeline/SelectItemsSub', - items: [ { - id: item.$id, - channel: channelName, - } ], - } ); - } - } ); - }, - [ - channelName, - dispatch, - dopeSheetMode, - isSelected, - item.$id, - item.time, - item.value, - moveEntities, - ] - ); - const grabLeft = useCallback( (): void => { if ( !channel ) { return; } @@ -285,27 +199,6 @@ const TimelineItemConstant = ( props: TimelineItemConstantProps ): JSX.Element = [ channel, item, range, size, guiSettings, dispatch, channelName ] ); - const removeItem = useCallback( - (): void => { - if ( !channel ) { return; } - - channel.removeItem( item.$id ); - - dispatch( { - type: 'History/Push', - description: 'Remove Constant', - commands: [ - { - type: 'channel/removeItem', - channel: channelName, - data: item - } - ], - } ); - }, - [ item, channel, dispatch, channelName ] - ); - const handleClickBody = useCallback( ( event ) => mouseCombo( event, { [ MouseComboBit.LMB ]: () => { diff --git a/packages/automaton-with-gui/src/view/components/TimelineItemCurve.tsx b/packages/automaton-with-gui/src/view/components/TimelineItemCurve.tsx index 601bdf3f..4f8da81a 100644 --- a/packages/automaton-with-gui/src/view/components/TimelineItemCurve.tsx +++ b/packages/automaton-with-gui/src/view/components/TimelineItemCurve.tsx @@ -7,11 +7,9 @@ import { genID } from '@fms-cat/automaton-with-gui/src/utils/genID'; import { jsonCopy } from '@fms-cat/automaton-with-gui/src/utils/jsonCopy'; import { objectMapHas } from '../utils/objectMap'; import { registerMouseEvent } from '../utils/registerMouseEvent'; -import { registerMouseNoDragEvent } from '../utils/registerMouseNoDragEvent'; import { useDispatch, useSelector } from '../states/store'; import { useDoubleClick } from '../utils/useDoubleClick'; import { useID } from '../utils/useID'; -import { useMoveEntites } from '../utils/useMoveEntities'; import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import type { StateChannelItem } from '../../types/StateChannelItem'; @@ -70,12 +68,15 @@ export interface TimelineItemCurveProps { item: StateChannelItem; range: TimeValueRange; size: Resolution; + grabBody: () => void; + grabBodyCtrl: () => void; + removeItem: () => void; dopeSheetMode?: boolean; } // == component ==================================================================================== const TimelineItemCurve = ( props: TimelineItemCurveProps ): JSX.Element => { - const { item, range, size, dopeSheetMode } = props; + const { item, range, size, grabBody, grabBodyCtrl, removeItem, dopeSheetMode } = props; const channelName = props.channel; const dispatch = useDispatch(); @@ -94,8 +95,6 @@ const TimelineItemCurve = ( props: TimelineItemCurveProps ): JSX.Element => { guiSettings: state.automaton.guiSettings } ) ); - const { moveEntities } = useMoveEntites( size ); - const curve = curves[ item.curveId! ]; const { path, length: curveLength } = curve; @@ -126,91 +125,6 @@ const TimelineItemCurve = ( props: TimelineItemCurveProps ): JSX.Element => { const channel = automaton?.getChannel( channelName ); - const grabBody = useCallback( - (): void => { - if ( !isSelected ) { - dispatch( { - type: 'Timeline/SelectItems', - items: [ { - id: item.$id, - channel: channelName - } ] - } ); - - dispatch( { - type: 'Timeline/SelectChannel', - channel: channelName - } ); - } - - moveEntities( { - moveValue: !dopeSheetMode, - snapOriginTime: item.time, - snapOriginValue: item.value, - } ); - - registerMouseNoDragEvent( () => { - dispatch( { - type: 'Timeline/SelectItems', - items: [ { - id: item.$id, - channel: channelName - } ] - } ); - } ); - }, - [ - dispatch, - item.$id, - item.time, - item.value, - channelName, - moveEntities, - dopeSheetMode, - isSelected, - ] - ); - - const grabBodyCtrl = useCallback( - (): void => { - dispatch( { - type: 'Timeline/SelectItemsAdd', - items: [ { - id: item.$id, - channel: channelName, - } ], - } ); - - moveEntities( { - moveValue: !dopeSheetMode, - snapOriginTime: item.time, - snapOriginValue: item.value, - } ); - - registerMouseNoDragEvent( () => { - if ( isSelected ) { - dispatch( { - type: 'Timeline/SelectItemsSub', - items: [ { - id: item.$id, - channel: channelName, - } ], - } ); - } - } ); - }, - [ - channelName, - dispatch, - dopeSheetMode, - isSelected, - item.$id, - item.time, - item.value, - moveEntities, - ] - ); - const grabTop = useCallback( (): void => { if ( !channel ) { return; } @@ -417,27 +331,6 @@ const TimelineItemCurve = ( props: TimelineItemCurveProps ): JSX.Element => { [ channel, item, range, size, guiSettings, dispatch, channelName ] ); - const removeItem = useCallback( - (): void => { - if ( !channel ) { return; } - - channel.removeItem( item.$id ); - - dispatch( { - type: 'History/Push', - description: 'Remove Curve', - commands: [ - { - type: 'channel/removeItem', - channel: channelName, - data: item - } - ], - } ); - }, - [ item, channel, dispatch, channelName ] - ); - const handleClickBody = useCallback( ( event ) => mouseCombo( event, { [ MouseComboBit.LMB ]: () => { diff --git a/packages/automaton-with-gui/src/view/constants/Metrics.ts b/packages/automaton-with-gui/src/view/constants/Metrics.ts index a3b6a558..ea4dfc44 100644 --- a/packages/automaton-with-gui/src/view/constants/Metrics.ts +++ b/packages/automaton-with-gui/src/view/constants/Metrics.ts @@ -2,6 +2,7 @@ export const Metrics = { rootFontSize: 16, headerHeight: 32, channelListWidth: 160, + channelListEntyHeight: 20, inspectorWidth: 192, curveListWidth: 160, modeSelectorWidth: 36, diff --git a/packages/automaton-with-gui/src/view/utils/mouseCombo.ts b/packages/automaton-with-gui/src/view/utils/mouseCombo.ts index 904e9a40..9f3ba472 100644 --- a/packages/automaton-with-gui/src/view/utils/mouseCombo.ts +++ b/packages/automaton-with-gui/src/view/utils/mouseCombo.ts @@ -4,7 +4,8 @@ export enum MouseComboBit { MMB = 4, Shift = 8, Ctrl = 16, - Alt = 32 + Alt = 32, + DoubleClick = 64, } /** @@ -12,12 +13,13 @@ export enum MouseComboBit { * It will event.preventDefault + event.stopPropagation automatically. * * @param event The mouse event - * @param callbacks A map of mouse button + key combination bits vs. callbacks. set `false` to bypass + * @param callbacks A map of mouse button + key combination bits vs. callbacks. set or return `false` to bypass + * @returns The return value of the callback it executed. If it couldn't execute any callbacks, returns `null` instead. */ -export function mouseCombo( +export function mouseCombo( event: React.MouseEvent, - callbacks: { [ combo: number ]: ( ( event: React.MouseEvent ) => void ) | false }, -): void { + callbacks: { [ combo: number ]: ( ( event: React.MouseEvent ) => T | false ) | false }, +): T | false | null { let bits = 0; // set bits @@ -37,15 +39,19 @@ export function mouseCombo( const cbBits = parseInt( cbBitsStr ); if ( ( cbBits & bits ) === cbBits ) { if ( cb ) { - event.preventDefault(); - event.stopPropagation(); + const ret = cb( event ); - cb( event ); - } + if ( ret !== false ) { + event.preventDefault(); + event.stopPropagation(); + } - return; + return ret; + } else { + return null; + } } } - return; + return null; } diff --git a/packages/automaton-with-gui/src/view/utils/testRectIntersection.ts b/packages/automaton-with-gui/src/view/utils/testRectIntersection.ts new file mode 100644 index 00000000..42bdaae9 --- /dev/null +++ b/packages/automaton-with-gui/src/view/utils/testRectIntersection.ts @@ -0,0 +1,12 @@ +export function testRectIntersection( + ax0: number, + ay0: number, + ax1: number, + ay1: number, + bx0: number, + by0: number, + bx1: number, + by1: number, +): boolean { + return !( bx0 > ax1 || bx1 < ax0 || by0 > ay1 || by1 < ay0 ); +} diff --git a/packages/automaton-with-gui/src/view/utils/useMoveEntities.ts b/packages/automaton-with-gui/src/view/utils/useMoveEntities.ts index dbd6032e..83b052f2 100644 --- a/packages/automaton-with-gui/src/view/utils/useMoveEntities.ts +++ b/packages/automaton-with-gui/src/view/utils/useMoveEntities.ts @@ -1,10 +1,12 @@ import { HistoryCommand } from '../history/HistoryCommand'; -import { dx2dt, dy2dv, snapTime, snapValue } from './TimeValueRange'; +import { Resolution } from './Resolution'; +import { TimeValueRange } from './TimeValueRange'; import { registerMouseEvent } from './registerMouseEvent'; import { useCallback } from 'react'; import { useDispatch, useStore } from '../states/store'; +import { useTimeValueRangeFuncs } from './useTimeValueRange'; -export function useMoveEntites( { width, height }: { width: number, height: number } ): { +export function useMoveEntites( range: TimeValueRange, size: Resolution ): { moveEntities: ( options: { moveValue: boolean; snapOriginTime?: number; @@ -13,6 +15,7 @@ export function useMoveEntites( { width, height }: { width: number, height: numb } { const dispatch = useDispatch(); const store = useStore(); + const { dx2dt, dy2dv, snapTime, snapValue } = useTimeValueRangeFuncs( range, size ); const moveEntities = useCallback( ( { moveValue, snapOriginTime, snapOriginValue }: { @@ -68,9 +71,6 @@ export function useMoveEntites( { width, height }: { width: number, height: numb const labelsNewTimeMap = new Map(); // -- common stuff --------------------------------------------------------------------------- - const range = state.timeline.range; - const guiSettings = state.automaton.guiSettings; - let dx = 0.0; let dt = 0.0; let dy = 0.0; @@ -92,20 +92,20 @@ export function useMoveEntites( { width, height }: { width: number, height: numb // -- calc dt / dv ----------------------------------------------------------------------- if ( !holdTime ) { - let t = originTime + dx2dt( dx, range, width ); + let t = originTime + dx2dt( dx ); if ( !ignoreSnap && snapOriginTime != null ) { - t = snapTime( t, range, width, guiSettings ); + t = snapTime( t ); } dt = t - originTime; } if ( !holdValue && moveValue ) { - let v = originValue + dy2dv( dy, range, height ); + let v = originValue + dy2dv( dy ); if ( !ignoreSnap && snapOriginValue != null ) { - v = snapValue( v, range, height, guiSettings ); + v = snapValue( v ); } dv = v - originValue; @@ -176,7 +176,7 @@ export function useMoveEntites( { width, height }: { width: number, height: numb } ); }, - [ dispatch, height, store, width ] + [ dispatch, dx2dt, dy2dv, snapTime, snapValue, store ] ); return { moveEntities }; diff --git a/packages/automaton-with-gui/src/view/utils/useRect.ts b/packages/automaton-with-gui/src/view/utils/useRect.ts index c54a47c8..c5c96076 100644 --- a/packages/automaton-with-gui/src/view/utils/useRect.ts +++ b/packages/automaton-with-gui/src/view/utils/useRect.ts @@ -52,6 +52,7 @@ export function useRect( const resizeObserver = new ResizeObserver( () => handleResize() ); resizeObserver.observe( element ); + resizeObserver.observe( document.body ); return () => { if ( !resizeObserver ) { return; } diff --git a/packages/automaton-with-gui/src/view/utils/useTimeValueRange.ts b/packages/automaton-with-gui/src/view/utils/useTimeValueRange.ts new file mode 100644 index 00000000..b161a9b4 --- /dev/null +++ b/packages/automaton-with-gui/src/view/utils/useTimeValueRange.ts @@ -0,0 +1,126 @@ +import { Resolution } from './Resolution'; +import { TimeValueRange } from './TimeValueRange'; +import { useCallback } from 'react'; +import { useSelector } from '../states/store'; + +export function useTimeValueRangeFuncs( + range: TimeValueRange, + size: Resolution, +): { + x2t: ( x: number ) => number, + t2x: ( t: number ) => number, + y2v: ( y: number ) => number, + v2y: ( v: number ) => number, + dx2dt: ( x: number ) => number, + dt2dx: ( t: number ) => number, + dy2dv: ( y: number ) => number, + dv2dy: ( v: number ) => number, + snapTime: ( t: number ) => number, + snapValue: ( v: number ) => number, + } { + const { + snapTimeActive, + snapBeatActive, + snapTimeInterval, + bpm, + beatOffset, + snapValueActive, + snapValueInterval, + } = useSelector( ( state ) => state.automaton.guiSettings ); + const { t0, t1, v0, v1 } = range; + const { width, height } = size; + + const x2t = useCallback( + ( x: number ) => ( x / width ) * ( t1 - t0 ) + t0, + [ t0, t1, width ] + ); + + const t2x = useCallback( + ( t: number ) => ( ( t - t0 ) / ( t1 - t0 ) ) * width, + [ t0, t1, width ] + ); + + const y2v = useCallback( + ( y: number ) => ( 1.0 - y / height ) * ( v1 - v0 ) + v0, + [ height, v0, v1 ] + ); + + const v2y = useCallback( + ( v: number ) => ( 1.0 - ( v - v0 ) / ( v1 - v0 ) ) * height, + [ height, v0, v1 ] + ); + + const dx2dt = useCallback( + ( dx: number ) => ( dx / width ) * ( t1 - t0 ), + [ t0, t1, width ] + ); + + const dt2dx = useCallback( + ( dt: number ) => dt / ( t1 - t0 ) * width, + [ t0, t1, width ] + ); + + const dy2dv = useCallback( + ( dy: number ) => -dy / height * ( v1 - v0 ), + [ height, v0, v1 ] + ); + + const dv2dy = useCallback( + ( dv: number ) => -dv / ( v1 - v0 ) * height, + [ height, v0, v1 ] + ); + + const snapTime = useCallback( + ( t: number ) => { + let result = t; + + if ( snapTimeActive ) { + const interval = snapTimeInterval; + const threshold = dx2dt( 5.0 ); + const nearest = Math.round( t / interval ) * interval; + result = Math.abs( t - nearest ) < threshold ? nearest : result; + } + + if ( snapBeatActive ) { + let interval = 60.0 / bpm; + const order = Math.floor( Math.log( ( t1 - t0 ) / interval ) / Math.log( 4.0 ) ); + interval *= Math.pow( 4.0, order - 2.0 ); + const threshold = dx2dt( 5.0 ); + const nearest = Math.round( ( t - beatOffset ) / interval ) * interval + beatOffset; + result = Math.abs( t - nearest ) < threshold ? nearest : result; + } + + return result; + }, + [ beatOffset, bpm, dx2dt, snapBeatActive, snapTimeActive, snapTimeInterval, t0, t1 ] + ); + + const snapValue = useCallback( + ( v: number ) => { + let result = v; + + if ( snapValueActive ) { + const interval = snapValueInterval; + const threshold = dy2dv( -5.0 ); + const nearest = Math.round( result / interval ) * interval; + result = Math.abs( result - nearest ) < threshold ? nearest : result; + } + + return result; + }, + [ dy2dv, snapValueActive, snapValueInterval ] + ); + + return { + x2t, + t2x, + y2v, + v2y, + dx2dt, + dt2dx, + dy2dv, + dv2dy, + snapTime, + snapValue, + }; +} From 6bd0c1a234a20ba0fa8688b0e989170576a92f2d Mon Sep 17 00:00:00 2001 From: FMS-Cat Date: Wed, 14 Apr 2021 23:45:50 +0900 Subject: [PATCH 06/11] performance (gui): batch automaton state dispatches --- .../components/AutomatonStateListener.tsx | 131 ++++++++++-------- 1 file changed, 73 insertions(+), 58 deletions(-) diff --git a/packages/automaton-with-gui/src/view/components/AutomatonStateListener.tsx b/packages/automaton-with-gui/src/view/components/AutomatonStateListener.tsx index b7479cd9..08e1f724 100644 --- a/packages/automaton-with-gui/src/view/components/AutomatonStateListener.tsx +++ b/packages/automaton-with-gui/src/view/components/AutomatonStateListener.tsx @@ -1,8 +1,10 @@ +import { Action, useDispatch } from '../states/store'; import { AutomatonWithGUI } from '../../AutomatonWithGUI'; import { ChannelWithGUI } from '../../ChannelWithGUI'; import { CurveWithGUI } from '../../CurveWithGUI'; -import { useDispatch } from '../states/store'; -import React, { useCallback, useEffect } from 'react'; +import { batch } from 'react-redux'; +import { useAnimationFrame } from '../utils/useAnimationFrame'; +import React, { useCallback, useEffect, useRef } from 'react'; import styled from 'styled-components'; // == utils ======================================================================================== @@ -36,28 +38,41 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme const dispatch = useDispatch(); const automaton = props.automaton; + const refAccumActions = useRef( [] ); + useAnimationFrame( + () => { + if ( refAccumActions.current.length > 0 ) { + batch( () => { + refAccumActions.current.forEach( ( action ) => dispatch( action ) ); + } ); + refAccumActions.current = []; + } + }, + [ dispatch ], + ); + const initChannelState = useCallback( ( name: string, channel: ChannelWithGUI, index: number ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/CreateChannel', channel: name, index, } ); - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateChannelValue', channel: name, value: channel.currentValue } ); - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateChannelStatus', channel: name, status: channel.status } ); channel.items.forEach( ( item ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateChannelItem', channel: name, id: item.$id, @@ -65,14 +80,14 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); } ); - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateChannelLength', channel: name, length: channel.length } ); channel.on( 'changeValue', ( { value } ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateChannelValue', channel: name, value @@ -80,7 +95,7 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); channel.on( 'updateStatus', () => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateChannelStatus', channel: name, status: channel.status @@ -88,7 +103,7 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); channel.on( 'createItem', ( { id, item } ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateChannelItem', channel: name, id, @@ -97,7 +112,7 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); channel.on( 'updateItem', ( { id, item } ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateChannelItem', channel: name, id, @@ -106,7 +121,7 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); channel.on( 'removeItem', ( { id } ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/RemoveChannelItem', channel: name, id @@ -114,33 +129,33 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); channel.on( 'changeLength', ( { length } ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateChannelLength', channel: name, length, } ); } ); }, - [ dispatch ] + [] ); const initCurveState = useCallback( ( curveId: string, curve: CurveWithGUI ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/CreateCurve', curveId, length: curve.length, path: genCurvePath( curve ) } ); - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateCurveStatus', curveId, status: curve.status } ); curve.nodes.forEach( ( node ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateCurveNode', curveId, id: node.$id, @@ -149,7 +164,7 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); curve.fxs.forEach( ( fx ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateCurveFx', curveId, id: fx.$id, @@ -158,7 +173,7 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); curve.on( 'precalc', () => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateCurvePath', curveId, path: genCurvePath( curve ) @@ -166,7 +181,7 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); curve.on( 'previewTime', ( { time, value, itemTime, itemSpeed, itemOffset } ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateCurvePreviewTimeValue', curveId, time, @@ -178,7 +193,7 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); curve.on( 'updateStatus', () => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateCurveStatus', curveId, status: curve.status @@ -186,7 +201,7 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); curve.on( 'createNode', ( { id, node } ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateCurveNode', curveId, id, @@ -195,7 +210,7 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); curve.on( 'updateNode', ( { id, node } ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateCurveNode', curveId, id, @@ -204,7 +219,7 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); curve.on( 'removeNode', ( { id } ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/RemoveCurveNode', curveId, id @@ -212,7 +227,7 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); curve.on( 'createFx', ( { id, fx } ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateCurveFx', curveId, id, @@ -221,7 +236,7 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); curve.on( 'updateFx', ( { id, fx } ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateCurveFx', curveId, id, @@ -230,7 +245,7 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); curve.on( 'removeFx', ( { id } ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/RemoveCurveFx', curveId, id @@ -238,52 +253,52 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); curve.on( 'changeLength', ( { length } ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateCurveLength', curveId, length, } ); } ); }, - [ dispatch ] + [] ); const initAutomaton = useCallback( () => { - dispatch( { + refAccumActions.current.push( { type: 'History/Drop' } ); - dispatch( { + refAccumActions.current.push( { type: 'ContextMenu/Close' } ); - dispatch( { + refAccumActions.current.push( { type: 'Reset' } ); - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateTime', time: automaton.time } ); - dispatch( { + refAccumActions.current.push( { type: 'Automaton/ChangeLength', length: automaton.length } ); - dispatch( { + refAccumActions.current.push( { type: 'Automaton/ChangeResolution', resolution: automaton.resolution } ); - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateIsPlaying', isPlaying: automaton.isPlaying } ); Object.entries( automaton.fxDefinitions ).forEach( ( [ name, fxDefinition ] ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/AddFxDefinition', name, fxDefinition @@ -300,34 +315,34 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); Object.entries( automaton.labels ).forEach( ( [ name, time ] ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/SetLabel', name, time } ); } ); - dispatch( { + refAccumActions.current.push( { type: 'Automaton/SetLoopRegion', loopRegion: automaton.loopRegion } ); - dispatch( { + refAccumActions.current.push( { type: 'Automaton/SetShouldSave', shouldSave: automaton.shouldSave } ); - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateGUISettings', settings: automaton.guiSettings } ); }, - [ automaton, dispatch, initChannelState, initCurveState ] + [ automaton, initChannelState, initCurveState ] ); useEffect( () => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/SetInstance', automaton } ); @@ -339,35 +354,35 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); const handleUpdate = automaton.on( 'update', ( { time } ): void => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateTime', time } ); } ); const handleChangeLength = automaton.on( 'changeLength', ( { length } ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/ChangeLength', length } ); } ); const handleChangeResolution = automaton.on( 'changeResolution', ( { resolution } ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/ChangeResolution', resolution } ); } ); const handlePlay = automaton.on( 'play', () => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateIsPlaying', isPlaying: true } ); } ); const handlePause = automaton.on( 'pause', () => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateIsPlaying', isPlaying: false } ); @@ -375,7 +390,7 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme const handleAddFxDefinitions = automaton.on( 'addFxDefinitions', ( { fxDefinitions } ) => { Object.entries( fxDefinitions ).forEach( ( [ name, fxDefinition ] ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/AddFxDefinition', name, fxDefinition @@ -384,7 +399,7 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); const handleUpdateGUISettings = automaton.on( 'updateGUISettings', ( { settings } ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateGUISettings', settings } ); @@ -395,14 +410,14 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); const handleRemoveChannel = automaton.on( 'removeChannel', ( event ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/RemoveChannel', channel: event.name } ); } ); const handleReorderChannels = automaton.on( 'reorderChannels', ( event ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/ReorderChannels', index: event.index, length: event.length, @@ -415,21 +430,21 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); const handleRemoveCurve = automaton.on( 'removeCurve', ( event ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/RemoveCurve', curveId: event.id } ); } ); const handleChangeShouldSave = automaton.on( 'changeShouldSave', ( event ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/SetShouldSave', shouldSave: event.shouldSave } ); } ); const handleSetLabel = automaton.on( 'setLabel', ( { name, time } ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/SetLabel', name, time @@ -437,14 +452,14 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); const handleDeleteLabel = automaton.on( 'deleteLabel', ( { name } ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/DeleteLabel', name } ); } ); const handleSetLoopRegion = automaton.on( 'setLoopRegion', ( { loopRegion } ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/SetLoopRegion', loopRegion } ); @@ -470,7 +485,7 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme automaton.off( 'changeShouldSave', handleChangeShouldSave ); }; }, - [ automaton, dispatch, initAutomaton, initChannelState, initCurveState ] + [ automaton, initAutomaton, initChannelState, initCurveState ] ); return ( From 4529ae2ea98b63e015962a6cea558149b52e4637 Mon Sep 17 00:00:00 2001 From: FMS-Cat Date: Thu, 15 Apr 2021 00:10:44 +0900 Subject: [PATCH 07/11] fix (gui): slightly improve useRect it now listens window resize event --- packages/automaton-with-gui/src/view/utils/useRect.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/automaton-with-gui/src/view/utils/useRect.ts b/packages/automaton-with-gui/src/view/utils/useRect.ts index c5c96076..76827986 100644 --- a/packages/automaton-with-gui/src/view/utils/useRect.ts +++ b/packages/automaton-with-gui/src/view/utils/useRect.ts @@ -50,13 +50,15 @@ export function useRect( handleResize(); - const resizeObserver = new ResizeObserver( () => handleResize() ); + const resizeObserver = new ResizeObserver( handleResize ); resizeObserver.observe( element ); - resizeObserver.observe( document.body ); + + window.addEventListener( 'resize', handleResize ); return () => { if ( !resizeObserver ) { return; } resizeObserver.disconnect(); + window.removeEventListener( 'resize', handleResize ); }; }, [ element, handleResize ] From 46f16f9cb15133e86a08e0114e9c585f5ae19b1b Mon Sep 17 00:00:00 2001 From: FMS-Cat Date: Thu, 15 Apr 2021 19:55:03 +0900 Subject: [PATCH 08/11] chore: update launch.json, add dev.html --- .vscode/launch.json | 4 ++-- dev.html | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 dev.html diff --git a/.vscode/launch.json b/.vscode/launch.json index c8359a85..f538f78f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,8 +8,8 @@ "type": "pwa-chrome", "request": "launch", "name": "Launch Chrome against localhost", - "url": "http://localhost:10001", + "url": "http://localhost:10001/dev.html", "webRoot": "${workspaceFolder}" } ] -} \ No newline at end of file +} diff --git a/dev.html b/dev.html new file mode 100644 index 00000000..c5d68ed8 --- /dev/null +++ b/dev.html @@ -0,0 +1,11 @@ + + + +Automaton development index!!!!!!!! + + +

haha

+

+ automaton-with-gui +

+ From 95479d5d162a58ddeed84ed53ab022028e7b4242 Mon Sep 17 00:00:00 2001 From: FMS-Cat Date: Thu, 15 Apr 2021 22:44:48 +0900 Subject: [PATCH 09/11] feature (gui): Multiple selection of items, select all from context menu --- .../src/view/components/ChannelEditor.tsx | 18 ++++++- .../src/view/components/ContextMenu.tsx | 14 ++++-- .../src/view/components/DopeSheet.tsx | 27 ++++++++++- .../src/view/components/DopeSheetEntry.tsx | 11 ++++- .../src/view/components/Label.tsx | 4 +- .../src/view/components/TimelineItem.tsx | 4 +- .../useMoveEntities.ts | 29 ++++++----- .../view/gui-operation-hooks/useSelectAll.ts | 48 +++++++++++++++++++ .../useSelectAllItemsInChannel.ts | 32 +++++++++++++ .../src/view/states/ContextMenu.ts | 12 ++++- 10 files changed, 171 insertions(+), 28 deletions(-) rename packages/automaton-with-gui/src/view/{utils => gui-operation-hooks}/useMoveEntities.ts (93%) create mode 100644 packages/automaton-with-gui/src/view/gui-operation-hooks/useSelectAll.ts create mode 100644 packages/automaton-with-gui/src/view/gui-operation-hooks/useSelectAllItemsInChannel.ts diff --git a/packages/automaton-with-gui/src/view/components/ChannelEditor.tsx b/packages/automaton-with-gui/src/view/components/ChannelEditor.tsx index c1c84497..36698861 100644 --- a/packages/automaton-with-gui/src/view/components/ChannelEditor.tsx +++ b/packages/automaton-with-gui/src/view/components/ChannelEditor.tsx @@ -16,6 +16,7 @@ import { showToasty } from '../states/Toasty'; import { useDispatch, useSelector } from '../states/store'; import { useDoubleClick } from '../utils/useDoubleClick'; import { useRect } from '../utils/useRect'; +import { useSelectAllItemsInChannel } from '../gui-operation-hooks/useSelectAllItemsInChannel'; import { useTimeValueRangeFuncs } from '../utils/useTimeValueRange'; import { useWheelEvent } from '../utils/useWheelEvent'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -150,6 +151,7 @@ const ChannelEditor = ( { className }: Props ): JSX.Element => { } ) ); const channel = selectedChannel != null && automaton?.getChannel( selectedChannel ); const checkDoubleClick = useDoubleClick(); + const selectAllItemsInChannel = useSelectAllItemsInChannel(); const refBody = useRef( null ); const rect = useRect( refBody ); @@ -645,11 +647,23 @@ const ChannelEditor = ( { className }: Props ): JSX.Element => { name: 'Create Label', description: 'Create a label.', callback: () => createLabel( x, y ) - } + }, + { + name: 'Select All Items', + description: 'Select all items in the channel.', + callback: () => selectAllItemsInChannel( selectedChannel! ), // TODO: separate the content of ChannelEditor from `selectedChannel &&` + }, ] } ); }, - [ dispatch, createConstant, createNewCurve, createLabel ] + [ + dispatch, + createConstant, + createNewCurve, + createLabel, + selectedChannel, + selectAllItemsInChannel, + ], ); const handleWheel = useCallback( diff --git a/packages/automaton-with-gui/src/view/components/ContextMenu.tsx b/packages/automaton-with-gui/src/view/components/ContextMenu.tsx index 28131640..fb79f8df 100644 --- a/packages/automaton-with-gui/src/view/components/ContextMenu.tsx +++ b/packages/automaton-with-gui/src/view/components/ContextMenu.tsx @@ -96,9 +96,17 @@ const ContextMenu = ( { className }: ContextMenuProps ): JSX.Element => { key={ iCommand } name={ command.name } description={ command.description } - onClick={ () => { - command.callback(); - dispatch( { type: 'ContextMenu/Close' } ); + onClick={ ( event ) => { + command.callback?.(); + if ( command.more != null ) { + dispatch( { + type: 'ContextMenu/More', + position: { x: event.clientX, y: event.clientY }, + commands: command.more, + } ); + } else { + dispatch( { type: 'ContextMenu/Close' } ); + } } } /> ) diff --git a/packages/automaton-with-gui/src/view/components/DopeSheet.tsx b/packages/automaton-with-gui/src/view/components/DopeSheet.tsx index 83b482ea..5da576eb 100644 --- a/packages/automaton-with-gui/src/view/components/DopeSheet.tsx +++ b/packages/automaton-with-gui/src/view/components/DopeSheet.tsx @@ -5,6 +5,7 @@ import { RectSelectView } from './RectSelectView'; import { registerMouseEvent } from '../utils/registerMouseEvent'; import { useDispatch, useSelector } from '../states/store'; import { useRect } from '../utils/useRect'; +import { useSelectAllEntities } from '../gui-operation-hooks/useSelectAll'; import { useTimeValueRangeFuncs } from '../utils/useTimeValueRange'; import { useWheelEvent } from '../utils/useWheelEvent'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -51,6 +52,7 @@ const DopeSheet = ( channelNames: state.automaton.channelNames, range: state.timeline.range, } ) ); + const selectAllEntities = useSelectAllEntities(); const { x2t, @@ -308,11 +310,32 @@ const DopeSheet = ( name: 'Create Label', description: 'Create a label.', callback: () => createLabel( x, y ) - } + }, + { + name: 'Select All...', + description: 'Select all items or labels.', + more: [ + { + name: 'Select All Items', + description: 'Select all items.', + callback: () => selectAllEntities( { items: true } ), + }, + { + name: 'Select All Labels', + description: 'Select all labels.', + callback: () => selectAllEntities( { labels: true } ), + }, + { + name: 'Select Everything', + description: 'Select all items and labels.', + callback: () => selectAllEntities( { items: true, labels: true } ), + }, + ], + }, ] } ); }, - [ dispatch, createLabel ] + [ dispatch, createLabel, selectAllEntities ] ); const handleWheel = useCallback( diff --git a/packages/automaton-with-gui/src/view/components/DopeSheetEntry.tsx b/packages/automaton-with-gui/src/view/components/DopeSheetEntry.tsx index bf521516..26adc209 100644 --- a/packages/automaton-with-gui/src/view/components/DopeSheetEntry.tsx +++ b/packages/automaton-with-gui/src/view/components/DopeSheetEntry.tsx @@ -14,6 +14,7 @@ import { useDispatch, useSelector } from '../states/store'; import { useDoubleClick } from '../utils/useDoubleClick'; import { useIntersection } from '../utils/useIntersection'; import { useRect } from '../utils/useRect'; +import { useSelectAllItemsInChannel } from '../gui-operation-hooks/useSelectAllItemsInChannel'; import { useTimeValueRangeFuncs } from '../utils/useTimeValueRange'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import styled from 'styled-components'; @@ -86,6 +87,7 @@ const Content = ( props: { sortedItems: state.automaton.channels[ channelName ].sortedItems, } ) ); const checkDoubleClick = useDoubleClick(); + const selectAllItemsInChannel = useSelectAllItemsInChannel(); const channel = automaton?.getChannel( channelName ); const rect = useRect( refRoot ); @@ -363,11 +365,16 @@ const Content = ( props: { name: 'Create New Curve', description: 'Create a new curve and an item.', callback: () => createNewCurve( x ) - } + }, + { + name: 'Select All Items In Channel', + description: 'Select all items in the channel.', + callback: () => selectAllItemsInChannel( channelName ) + }, ] } ); }, - [ dispatch, createConstant, createNewCurve ] + [ dispatch, createConstant, createNewCurve, selectAllItemsInChannel, channelName ] ); const items = useMemo( diff --git a/packages/automaton-with-gui/src/view/components/Label.tsx b/packages/automaton-with-gui/src/view/components/Label.tsx index 4df16e29..e0006e73 100644 --- a/packages/automaton-with-gui/src/view/components/Label.tsx +++ b/packages/automaton-with-gui/src/view/components/Label.tsx @@ -6,7 +6,7 @@ import { arraySetHas } from '../utils/arraySet'; import { registerMouseNoDragEvent } from '../utils/registerMouseNoDragEvent'; import { useDispatch, useSelector } from '../states/store'; import { useDoubleClick } from '../utils/useDoubleClick'; -import { useMoveEntites } from '../utils/useMoveEntities'; +import { useMoveEntites } from '../gui-operation-hooks/useMoveEntities'; import { useRect } from '../utils/useRect'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import styled from 'styled-components'; @@ -55,7 +55,7 @@ const Label = ( { name, time, range, size }: { } ), [ range.t0, range.t1 ] ); - const { moveEntities } = useMoveEntites( timeValueRange, size ); + const moveEntities = useMoveEntites( timeValueRange, size ); const checkDoubleClick = useDoubleClick(); const [ width, setWidth ] = useState( 0.0 ); const x = t2x( time, range, size.width ); diff --git a/packages/automaton-with-gui/src/view/components/TimelineItem.tsx b/packages/automaton-with-gui/src/view/components/TimelineItem.tsx index 2b463c0e..fe362f3f 100644 --- a/packages/automaton-with-gui/src/view/components/TimelineItem.tsx +++ b/packages/automaton-with-gui/src/view/components/TimelineItem.tsx @@ -6,7 +6,7 @@ import { objectMapHas } from '../utils/objectMap'; import { registerMouseNoDragEvent } from '../utils/registerMouseNoDragEvent'; import { testRectIntersection } from '../utils/testRectIntersection'; import { useDispatch } from 'react-redux'; -import { useMoveEntites } from '../utils/useMoveEntities'; +import { useMoveEntites } from '../gui-operation-hooks/useMoveEntities'; import { useSelector } from '../states/store'; import React, { useCallback, useEffect, useRef } from 'react'; import type { ChannelEditorRectSelectState } from './ChannelEditor'; @@ -27,7 +27,7 @@ const TimelineItem = ( props: TimelineItemProps ): JSX.Element => { const { item, range, size, rectSelectState: rectSelect, dopeSheetMode } = props; const channelName = props.channel; const dispatch = useDispatch(); - const { moveEntities } = useMoveEntites( range, size ); + const moveEntities = useMoveEntites( range, size ); const isSelected = useSelector( ( state ) => objectMapHas( state.timeline.selected.items, item.$id ) diff --git a/packages/automaton-with-gui/src/view/utils/useMoveEntities.ts b/packages/automaton-with-gui/src/view/gui-operation-hooks/useMoveEntities.ts similarity index 93% rename from packages/automaton-with-gui/src/view/utils/useMoveEntities.ts rename to packages/automaton-with-gui/src/view/gui-operation-hooks/useMoveEntities.ts index 83b052f2..7b572e58 100644 --- a/packages/automaton-with-gui/src/view/utils/useMoveEntities.ts +++ b/packages/automaton-with-gui/src/view/gui-operation-hooks/useMoveEntities.ts @@ -1,18 +1,21 @@ import { HistoryCommand } from '../history/HistoryCommand'; -import { Resolution } from './Resolution'; -import { TimeValueRange } from './TimeValueRange'; -import { registerMouseEvent } from './registerMouseEvent'; +import { Resolution } from '../utils/Resolution'; +import { TimeValueRange } from '../utils/TimeValueRange'; +import { registerMouseEvent } from '../utils/registerMouseEvent'; import { useCallback } from 'react'; import { useDispatch, useStore } from '../states/store'; -import { useTimeValueRangeFuncs } from './useTimeValueRange'; - -export function useMoveEntites( range: TimeValueRange, size: Resolution ): { - moveEntities: ( options: { - moveValue: boolean; - snapOriginTime?: number; - snapOriginValue?: number; - } ) => void, -} { +import { useTimeValueRangeFuncs } from '../utils/useTimeValueRange'; + +interface Options { + moveValue: boolean; + snapOriginTime?: number; + snapOriginValue?: number; +} + +export function useMoveEntites( + range: TimeValueRange, + size: Resolution, +): ( options: Options ) => void { const dispatch = useDispatch(); const store = useStore(); const { dx2dt, dy2dv, snapTime, snapValue } = useTimeValueRangeFuncs( range, size ); @@ -179,5 +182,5 @@ export function useMoveEntites( range: TimeValueRange, size: Resolution ): { [ dispatch, dx2dt, dy2dv, snapTime, snapValue, store ] ); - return { moveEntities }; + return moveEntities; } diff --git a/packages/automaton-with-gui/src/view/gui-operation-hooks/useSelectAll.ts b/packages/automaton-with-gui/src/view/gui-operation-hooks/useSelectAll.ts new file mode 100644 index 00000000..760c76d6 --- /dev/null +++ b/packages/automaton-with-gui/src/view/gui-operation-hooks/useSelectAll.ts @@ -0,0 +1,48 @@ +import { useCallback } from 'react'; +import { useDispatch, useStore } from '../states/store'; + +interface Options { + items?: boolean; + labels?: boolean; +} + +export function useSelectAllEntities(): ( options: Options ) => void { + const dispatch = useDispatch(); + const store = useStore(); + + const selectAllEntities = useCallback( + ( options: Options ): void => { + const state = store.getState(); + + dispatch( { + type: 'Timeline/SelectItems', + items: [], + } ); + + if ( options.items ) { + const items = Object.entries( state.automaton.channels ).map( + ( [ channel, { items } ] ) => ( + Object.keys( items ).map( ( id ) => ( { id, channel } ) ) + ) + ).flat(); + + dispatch( { + type: 'Timeline/SelectItemsAdd', + items, + } ); + } + + if ( options.labels ) { + const labels = Object.keys( state.automaton.labels ); + + dispatch( { + type: 'Timeline/SelectLabelsAdd', + labels, + } ); + } + }, + [ dispatch, store ] + ); + + return selectAllEntities; +} diff --git a/packages/automaton-with-gui/src/view/gui-operation-hooks/useSelectAllItemsInChannel.ts b/packages/automaton-with-gui/src/view/gui-operation-hooks/useSelectAllItemsInChannel.ts new file mode 100644 index 00000000..1b57eb10 --- /dev/null +++ b/packages/automaton-with-gui/src/view/gui-operation-hooks/useSelectAllItemsInChannel.ts @@ -0,0 +1,32 @@ +import { useCallback } from 'react'; +import { useDispatch, useStore } from '../states/store'; + +export function useSelectAllItemsInChannel(): ( channelName: string ) => void { + const dispatch = useDispatch(); + const store = useStore(); + + const selectAllItemsInChannel = useCallback( + ( channelName: string ): void => { + const state = store.getState(); + + const stateChannel = state.automaton.channels[ channelName ]; + + if ( stateChannel == null ) { + throw new Error( 'The specified channel is not found??????? why' ); + } + + const items = Object.keys( stateChannel.items ).map( ( id ) => ( { + id, + channel: channelName, + } ) ); + + dispatch( { + type: 'Timeline/SelectItems', + items, + } ); + }, + [ dispatch, store ] + ); + + return selectAllItemsInChannel; +} diff --git a/packages/automaton-with-gui/src/view/states/ContextMenu.ts b/packages/automaton-with-gui/src/view/states/ContextMenu.ts index da2f1a91..c6143d87 100644 --- a/packages/automaton-with-gui/src/view/states/ContextMenu.ts +++ b/packages/automaton-with-gui/src/view/states/ContextMenu.ts @@ -5,7 +5,8 @@ import { produce } from 'immer'; export interface ContextMenuCommand { name: string; description?: string; - callback: () => void; + callback?: () => void; + more?: Array; } export interface State { @@ -17,7 +18,7 @@ export interface State { export const initialState: Readonly = { isVisible: false, position: { x: 0, y: 0 }, - commands: [] + commands: [], }; // == action ======================================================================================= @@ -25,6 +26,10 @@ export type Action = { type: 'ContextMenu/Push'; position: { x: number; y: number }; commands: Array; +} | { + type: 'ContextMenu/More'; + position: { x: number; y: number }; + commands: Array; } | { type: 'ContextMenu/Close'; }; @@ -40,6 +45,9 @@ export const reducer: Reducer = ( state = initialState, action ) newState.commands.push( 'hr' ); } newState.commands.push( ...action.commands ); + } else if ( action.type === 'ContextMenu/More' ) { + newState.position = action.position; + newState.commands = action.commands; } else if ( action.type === 'ContextMenu/Close' ) { newState.isVisible = false; newState.commands = []; From 5d4b62d7f5a0af51e372e0035dd130dd5be46289 Mon Sep 17 00:00:00 2001 From: FMS-Cat Date: Fri, 16 Apr 2021 02:43:42 +0900 Subject: [PATCH 10/11] feature: Error Boundaries!! --- packages/automaton-with-gui/package.json | 1 + .../src/view/components/App.tsx | 44 ++-- .../components/ChannelListAndDopeSheet.tsx | 39 ++-- .../src/view/components/CurveEditor.tsx | 77 ++++--- .../src/view/components/ErrorBoundary.tsx | 155 +++++++++++++ .../src/view/components/Header.tsx | 213 ++++++++---------- .../src/view/components/Inspector.tsx | 15 +- .../src/view/components/ToastyEntry.tsx | 1 + .../src/view/constants/Colors.ts | 1 + .../src/view/gui-operation-hooks/useSave.ts | 80 +++++++ .../src/view/icons/Icons.ts | 2 + .../src/view/icons/retry.svg | 7 + yarn.lock | 14 ++ 13 files changed, 446 insertions(+), 203 deletions(-) create mode 100644 packages/automaton-with-gui/src/view/components/ErrorBoundary.tsx create mode 100644 packages/automaton-with-gui/src/view/gui-operation-hooks/useSave.ts create mode 100644 packages/automaton-with-gui/src/view/icons/retry.svg diff --git a/packages/automaton-with-gui/package.json b/packages/automaton-with-gui/package.json index 7c9eb7f3..08ef746b 100644 --- a/packages/automaton-with-gui/package.json +++ b/packages/automaton-with-gui/package.json @@ -66,6 +66,7 @@ "immer": "^7.0.8", "react": "^16.13.1", "react-dom": "^16.13.1", + "react-error-boundary": "^3.1.1", "react-redux": "^7.2.1", "redux": "^4.0.5", "resize-observer-polyfill": "^1.5.1", diff --git a/packages/automaton-with-gui/src/view/components/App.tsx b/packages/automaton-with-gui/src/view/components/App.tsx index ac30cee1..1d565a35 100644 --- a/packages/automaton-with-gui/src/view/components/App.tsx +++ b/packages/automaton-with-gui/src/view/components/App.tsx @@ -8,6 +8,7 @@ import { Colors } from '../constants/Colors'; import { ContextMenu } from './ContextMenu'; import { CurveEditor } from './CurveEditor'; import { CurveList } from './CurveList'; +import { ErrorBoundary } from './ErrorBoundary'; import { FxSpawner } from './FxSpawner'; import { GUIRemocon } from '../../GUIRemocon'; import { GUIRemoconListener } from './GUIRemoconListener'; @@ -145,24 +146,31 @@ const Fuck = ( { className, automaton, guiRemocon }: AppProps ): JSX.Element => className={ className } onContextMenu={ handleContextMenu } > - - - - - - { mode === 'channel' && } - { mode === 'curve' && <> - - - } - - { isFxSpawnerVisible && } - { isAboutVisible && } - { isContextMenuVisible && } - { isTextPromptVisible && } - - - + + + + + + + + + { mode === 'channel' && } + { mode === 'curve' && <> + + + } + + { isFxSpawnerVisible && } + { isAboutVisible && } + { isContextMenuVisible && } + { isTextPromptVisible && } + + + + + + + ); }; diff --git a/packages/automaton-with-gui/src/view/components/ChannelListAndDopeSheet.tsx b/packages/automaton-with-gui/src/view/components/ChannelListAndDopeSheet.tsx index 8b1a0bac..5cbfa803 100644 --- a/packages/automaton-with-gui/src/view/components/ChannelListAndDopeSheet.tsx +++ b/packages/automaton-with-gui/src/view/components/ChannelListAndDopeSheet.tsx @@ -2,6 +2,7 @@ import { ChannelList } from './ChannelList'; import { DopeSheet } from './DopeSheet'; import { DopeSheetOverlay } from './DopeSheetOverlay'; import { DopeSheetUnderlay } from './DopeSheetUnderlay'; +import { ErrorBoundary } from './ErrorBoundary'; import { Metrics } from '../constants/Metrics'; import { Scrollable } from './Scrollable'; import { useDispatch, useSelector } from '../states/store'; @@ -160,26 +161,28 @@ const ChannelListAndDopeSheet = ( props: { className={ className } onContextMenu={ handleContextMenu } > - { underlay } - - + { underlay } + - { shouldShowChannelList && } - { mode === 'dope' && ( - + { shouldShowChannelList && - ) } - - - { overlay } + /> } + { mode === 'dope' && ( + + ) } + + + { overlay } + ); }; diff --git a/packages/automaton-with-gui/src/view/components/CurveEditor.tsx b/packages/automaton-with-gui/src/view/components/CurveEditor.tsx index 5f2199f3..3fc6009a 100644 --- a/packages/automaton-with-gui/src/view/components/CurveEditor.tsx +++ b/packages/automaton-with-gui/src/view/components/CurveEditor.tsx @@ -3,6 +3,7 @@ import { CurveEditorFx } from './CurveEditorFx'; import { CurveEditorFxBg } from './CruveEditorFxBg'; import { CurveEditorGraph } from './CurveEditorGraph'; import { CurveEditorNode } from './CurveEditorNode'; +import { ErrorBoundary } from './ErrorBoundary'; import { MouseComboBit, mouseCombo } from '../utils/mouseCombo'; import { RangeBar } from './RangeBar'; import { Resolution } from '../utils/Resolution'; @@ -437,46 +438,48 @@ const CurveEditor = ( { className }: CurveEditorProps ): JSX.Element => { return ( - - - - { selectedCurve != null && <> - + + + - - - - } - - - { curveLength != null && ( - - ) } + { selectedCurve != null && <> + + + + + } + + + { curveLength != null && ( + + ) } + ); }; diff --git a/packages/automaton-with-gui/src/view/components/ErrorBoundary.tsx b/packages/automaton-with-gui/src/view/components/ErrorBoundary.tsx new file mode 100644 index 00000000..7e4678b8 --- /dev/null +++ b/packages/automaton-with-gui/src/view/components/ErrorBoundary.tsx @@ -0,0 +1,155 @@ +import { Colors } from '../constants/Colors'; +import { Icons } from '../icons/Icons'; +import { Metrics } from '../constants/Metrics'; +import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary'; +import { useSave } from '../gui-operation-hooks/useSave'; +import React, { useCallback, useEffect, useState } from 'react'; +import styled, { keyframes } from 'styled-components'; + +// == CONFIDENTIAL - DO NOT SHARE ================================================================== +const funnyTexts = [ + 'oh no', + 'ded', + 'why', + 'I\'m so sorry tbh', + 'Said you should save frequently', + 'Software development is hard', + 'Did you know this? This fancy error screen is the part I have to pay an extreme care to prevent the entire GUI from crashing', +]; + +// == styles ======================================================================================= +const keyframe = keyframes` + 0% { + background-position: 0 0; + } + + 100% { + background-position: 0 40px; + } +`; + +const BiggerOne = styled.div` +`; + +const SmallerOne = styled.div` + font-size: 10px; +`; + +const Box = styled.div` + padding: 8px 16px; + background: ${ Colors.back1 }; + color: ${ Colors.error }; + text-align: center; + box-shadow: 0px 0px 16px 0px ${ Colors.black }88; +`; + +const Button = styled.img` + width: ${ Metrics.headerHeight - 4 }px; + height: ${ Metrics.headerHeight - 4 }px; + fill: ${ Colors.errorBright }; + filter: drop-hadow( 0px 0px 4px ${ Colors.black }88 ); + cursor: pointer; + margin: 2px 4px; + + &:hover { + opacity: 0.8; + } +`; + +const Buttons = styled.div` + position: absolute; + right: 0; + top: 0; + display: flex; +`; + +const FallbackRoot = styled.div` + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + background: ${ Colors.back1 }; + background-color: ${ Colors.back1 }; + background-size: 40px 40px; + background-image: repeating-linear-gradient( + 45deg, + ${ Colors.error }44 0, + ${ Colors.error }44 25%, + ${ Colors.error }88 0, + ${ Colors.error }88 50% + ); + animation: ${ keyframe } 1s linear infinite; +`; + +// == fancy fallback =============================================================================== +interface ErrorBoundaryFallbackProps { + error: Error; + resetErrorBoundary: () => void; +} + +const ErrorBoundaryFallback = ( + { error, resetErrorBoundary }: ErrorBoundaryFallbackProps +): JSX.Element => { + const save = useSave(); + + const [ funnyText ] = useState( funnyTexts[ Math.floor( Math.random() * funnyTexts.length ) ] ); + + useEffect( + () => { + console.error( error ); + }, + [ error ], + ); + + const handleRetry = useCallback( + () => resetErrorBoundary(), + [ resetErrorBoundary ], + ); + + const handleSave = useCallback( + () => save( { emergencyMode: true } ), + [ save ] + ); + + return ( + + + Something went wrong + See the console for more info + + +