diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..f538f78f --- /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/dev.html", + "webRoot": "${workspaceFolder}" + } + ] +} diff --git a/dev.html b/dev.html new file mode 100644 index 00000000..c5d68ed8 --- /dev/null +++ b/dev.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> + +<meta charset="UTF-8"> +<title>Automaton development index!!!!!!!!</title> + +<body> + <h1>haha</h1> + <p> + <a href="./packages/automaton-with-gui/">automaton-with-gui</a> + </p> +</body> 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 } > - <AutomatonStateListener automaton={ automaton } /> - <GUIRemoconListener guiRemocon={ guiRemocon } /> - <StyledHeader /> - <StyledModeSelector /> - <StyledChannelListAndDopeSheet /> - { mode === 'channel' && <StyledChannelEditor /> } - { mode === 'curve' && <> - <StyledCurveList /> - <StyledCurveEditor /> - </> } - <StyledInspector /> - { isFxSpawnerVisible && <StyledFxSpawner /> } - { isAboutVisible && <StyledAbout /> } - { isContextMenuVisible && <ContextMenu /> } - { isTextPromptVisible && <TextPrompt /> } - - <Toasty /> - <Stalker /> + <ErrorBoundary> + <AutomatonStateListener automaton={ automaton } /> + <GUIRemoconListener guiRemocon={ guiRemocon } /> + + <ErrorBoundary> + <StyledHeader /> + <StyledModeSelector /> + <StyledChannelListAndDopeSheet /> + { mode === 'channel' && <StyledChannelEditor /> } + { mode === 'curve' && <> + <StyledCurveList /> + <StyledCurveEditor /> + </> } + <StyledInspector /> + { isFxSpawnerVisible && <StyledFxSpawner /> } + { isAboutVisible && <StyledAbout /> } + { isContextMenuVisible && <ContextMenu /> } + { isTextPromptVisible && <TextPrompt /> } + </ErrorBoundary> + + <ErrorBoundary> + <Toasty /> + <Stalker /> + </ErrorBoundary> + </ErrorBoundary> </Root> ); }; diff --git a/packages/automaton-with-gui/src/view/components/AutomatonStateListener.tsx b/packages/automaton-with-gui/src/view/components/AutomatonStateListener.tsx index b7479cd9..ee8bd626 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<Action[]>( [] ); + 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, @@ -107,6 +122,11 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme channel.on( 'removeItem', ( { id } ) => { dispatch( { + type: 'Timeline/SelectItemsSub', + items: [ { id } ], + } ); + + refAccumActions.current.push( { type: 'Automaton/RemoveChannelItem', channel: name, id @@ -114,7 +134,7 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); channel.on( 'changeLength', ( { length } ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateChannelLength', channel: name, length, @@ -126,21 +146,21 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme 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 +169,7 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); curve.fxs.forEach( ( fx ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateCurveFx', curveId, id: fx.$id, @@ -158,7 +178,7 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); curve.on( 'precalc', () => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateCurvePath', curveId, path: genCurvePath( curve ) @@ -166,7 +186,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 +198,7 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); curve.on( 'updateStatus', () => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateCurveStatus', curveId, status: curve.status @@ -186,7 +206,7 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); curve.on( 'createNode', ( { id, node } ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateCurveNode', curveId, id, @@ -195,7 +215,7 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); curve.on( 'updateNode', ( { id, node } ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateCurveNode', curveId, id, @@ -205,6 +225,11 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme curve.on( 'removeNode', ( { id } ) => { dispatch( { + type: 'CurveEditor/SelectItemsSub', + nodes: [ id ], + } ); + + refAccumActions.current.push( { type: 'Automaton/RemoveCurveNode', curveId, id @@ -212,7 +237,7 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); curve.on( 'createFx', ( { id, fx } ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateCurveFx', curveId, id, @@ -221,7 +246,7 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); curve.on( 'updateFx', ( { id, fx } ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateCurveFx', curveId, id, @@ -231,6 +256,11 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme curve.on( 'removeFx', ( { id } ) => { dispatch( { + type: 'CurveEditor/SelectItemsSub', + fxs: [ id ], + } ); + + refAccumActions.current.push( { type: 'Automaton/RemoveCurveFx', curveId, id @@ -238,7 +268,7 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); curve.on( 'changeLength', ( { length } ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateCurveLength', curveId, length, @@ -262,28 +292,28 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme 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 +330,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, dispatch, initChannelState, initCurveState ], ); useEffect( () => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/SetInstance', automaton } ); @@ -339,35 +369,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 +405,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 +414,7 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme } ); const handleUpdateGUISettings = automaton.on( 'updateGUISettings', ( { settings } ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/UpdateGUISettings', settings } ); @@ -396,13 +426,18 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme const handleRemoveChannel = automaton.on( 'removeChannel', ( event ) => { dispatch( { + type: 'Timeline/UnselectChannelIfSelected', + channel: event.name, + } ); + + 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, @@ -416,20 +451,25 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme const handleRemoveCurve = automaton.on( 'removeCurve', ( event ) => { dispatch( { + type: 'CurveEditor/SelectCurve', + curveId: null, + } ); + + 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 @@ -438,13 +478,18 @@ const AutomatonStateListener = ( props: AutomatonStateListenerProps ): JSX.Eleme const handleDeleteLabel = automaton.on( 'deleteLabel', ( { name } ) => { dispatch( { + type: 'Timeline/SelectLabelsSub', + labels: [ name ], + } ); + + refAccumActions.current.push( { type: 'Automaton/DeleteLabel', name } ); } ); const handleSetLoopRegion = automaton.on( 'setLoopRegion', ( { loopRegion } ) => { - dispatch( { + refAccumActions.current.push( { type: 'Automaton/SetLoopRegion', loopRegion } ); diff --git a/packages/automaton-with-gui/src/view/components/ChannelEditor.tsx b/packages/automaton-with-gui/src/view/components/ChannelEditor.tsx index aed68501..36698861 100644 --- a/packages/automaton-with-gui/src/view/components/ChannelEditor.tsx +++ b/packages/automaton-with-gui/src/view/components/ChannelEditor.tsx @@ -2,23 +2,36 @@ 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 { useSelectAllItemsInChannel } from '../gui-operation-hooks/useSelectAllItemsInChannel'; +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 +52,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 +92,7 @@ const Items = ( { channel, range, size }: { item={ item } range={ range } size={ size } + rectSelectState={ rectSelect } /> ) ) } </>; @@ -100,6 +122,8 @@ const StyledRangeBar = styled( RangeBar )` `; const Root = styled.div` + position: relative; + overflow: hidden; `; // == props ======================================================================================== @@ -114,7 +138,6 @@ const ChannelEditor = ( { className }: Props ): JSX.Element => { automaton, selectedChannel, range, - guiSettings, automatonLength, lastSelectedItem, selectedCurve @@ -122,15 +145,26 @@ 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 selectAllItemsInChannel = useSelectAllItemsInChannel(); const refBody = useRef<HTMLDivElement>( 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 +203,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 +243,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 +258,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 +301,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 +341,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 +358,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 +408,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 +441,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 +464,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 +473,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 +482,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 +499,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 +521,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<ChannelEditorRectSelectState>( { + 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 +611,15 @@ const ChannelEditor = ( { className }: Props ): JSX.Element => { ); } } ), - [ createItemAndGrab, startSeek, move, startSetLoopRegion ] + [ + checkDoubleClick, + createItemAndGrab, + dispatch, + startRectSelect, + startSeek, + startSetLoopRegion, + move, + ] ); const handleContextMenu = useCallback( @@ -533,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( @@ -579,6 +705,7 @@ const ChannelEditor = ( { className }: Props ): JSX.Element => { channel={ selectedChannel } range={ range } size={ rect } + rectSelect={ rectSelectState } /> } <Labels range={ range } @@ -600,6 +727,14 @@ const ChannelEditor = ( { className }: Props ): JSX.Element => { width={ rect.width } length={ automatonLength } /> + { rectSelectState.isSelecting && ( + <RectSelectView + x0={ t2x( rectSelectState.t0 ) } + x1={ t2x( rectSelectState.t1 ) } + y0={ v2y( rectSelectState.v1 ) } + y1={ v2y( rectSelectState.v0 ) } + /> + ) } </Root> ); }; diff --git a/packages/automaton-with-gui/src/view/components/ChannelListAndDopeSheet.tsx b/packages/automaton-with-gui/src/view/components/ChannelListAndDopeSheet.tsx index d73b5a9e..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,25 +161,28 @@ const ChannelListAndDopeSheet = ( props: { className={ className } onContextMenu={ handleContextMenu } > - { underlay } - <ChannelListAndDopeSheetScrollable - barPosition='left' - onScroll={ handleScroll } - > - <ChannelListAndDopeSheetContainer - style={ { minHeight: rect.height } } + <ErrorBoundary> + { underlay } + <ChannelListAndDopeSheetScrollable + barPosition='left' + onScroll={ handleScroll } > - { shouldShowChannelList && <StyledChannelList - refScrollTop={ refScrollTop } - /> } - { mode === 'dope' && ( - <StyledDopeSheet - intersectionRoot={ root } - /> - ) } - </ChannelListAndDopeSheetContainer> - </ChannelListAndDopeSheetScrollable> - { overlay } + <ChannelListAndDopeSheetContainer + style={ { minHeight: rect.height } } + > + { shouldShowChannelList && <StyledChannelList + refScrollTop={ refScrollTop } + /> } + { mode === 'dope' && ( + <StyledDopeSheet + refScrollTop={ refScrollTop } + intersectionRoot={ root } + /> + ) } + </ChannelListAndDopeSheetContainer> + </ChannelListAndDopeSheetScrollable> + { overlay } + </ErrorBoundary> </Root> ); }; 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/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/CurveEditor.tsx b/packages/automaton-with-gui/src/view/components/CurveEditor.tsx index 5f2199f3..75c5b4ae 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'; @@ -22,12 +23,14 @@ const Lines = ( { curveId, range, size }: { curveId: string; range: TimeValueRange; size: Resolution; -} ): JSX.Element => { +} ): JSX.Element | null => { const { time, value } = useSelector( ( state ) => ( { - time: state.automaton.curvesPreview[ curveId ].time, - value: state.automaton.curvesPreview[ curveId ].value, + time: state.automaton.curvesPreview[ curveId ]?.time, + value: state.automaton.curvesPreview[ curveId ]?.value, } ) ); + if ( time == null || value == null ) { return null; } + return <TimeValueLines range={ range } size={ size } @@ -40,15 +43,17 @@ const Nodes = ( { curveId, range, size }: { curveId: string; range: TimeValueRange; size: Resolution; -} ): JSX.Element => { +} ): JSX.Element | null => { const { nodes } = useSelector( ( state ) => ( { - nodes: state.automaton.curves[ curveId ].nodes + nodes: state.automaton.curves[ curveId ]?.nodes } ) ); + if ( nodes == null ) { return null; } + // 👾 See: https://github.com/yannickcr/eslint-plugin-react/issues/2584 // eslint-disable-next-line react/jsx-no-useless-fragment return <> - { nodes && Object.values( nodes ).map( ( node ) => ( + { Object.values( nodes ).map( ( node ) => ( <CurveEditorNode key={ node.$id } curveId={ curveId } @@ -64,13 +69,15 @@ const Fxs = ( { curveId, range, size }: { curveId: string; range: TimeValueRange; size: Resolution; -} ): JSX.Element => { +} ): JSX.Element | null => { const { fxs } = useSelector( ( state ) => ( { - fxs: state.automaton.curves[ curveId ].fxs + fxs: state.automaton.curves[ curveId ]?.fxs } ) ); + if ( fxs == null ) { return null; } + return <> - { fxs && Object.values( fxs ).map( ( fx ) => ( + { Object.values( fxs ).map( ( fx ) => ( <CurveEditorFxBg key={ fx.$id } fx={ fx } @@ -78,7 +85,7 @@ const Fxs = ( { curveId, range, size }: { size={ size } /> ) ) } - { fxs && Object.values( fxs ).map( ( fx ) => ( + { Object.values( fxs ).map( ( fx ) => ( <CurveEditorFx key={ fx.$id } curveId={ curveId } @@ -152,7 +159,7 @@ const CurveEditor = ( { className }: CurveEditorProps ): JSX.Element => { previewItemOffset: state.automaton.curvesPreview[ selectedCurve ?? -1 ]?.itemOffset ?? null, } ) ); - const curve = selectedCurve != null && automaton?.getCurveById( selectedCurve ) || null; + const curve = ( selectedCurve != null && automaton?.getCurveById( selectedCurve ) ) ?? null; const refBody = useRef<HTMLDivElement>( null ); const rect = useRect( refBody ); @@ -437,46 +444,48 @@ const CurveEditor = ( { className }: CurveEditorProps ): JSX.Element => { return ( <Root className={ className }> - <Body ref={ refBody }> - <SVGRoot - onMouseDown={ handleMouseDown } - onContextMenu={ handleContextMenu } - > - <TimeValueGrid - range={ range } - size={ rect } - /> - { selectedCurve != null && <> - <Fxs - curveId={ selectedCurve } - range={ range } - size={ rect } - /> - <CurveEditorGraph - curveId={ selectedCurve } + <ErrorBoundary> + <Body ref={ refBody }> + <SVGRoot + onMouseDown={ handleMouseDown } + onContextMenu={ handleContextMenu } + > + <TimeValueGrid range={ range } size={ rect } /> - <Nodes - curveId={ selectedCurve } - range={ range } - size={ rect } - /> - <StyledLines - curveId={ selectedCurve } - range={ range } - size={ rect } - /> - </> } - </SVGRoot> - </Body> - { curveLength != null && ( - <StyledRangeBar - range={ range } - width={ rect.width } - length={ curveLength } - /> - ) } + { selectedCurve != null && <> + <Fxs + curveId={ selectedCurve } + range={ range } + size={ rect } + /> + <CurveEditorGraph + curveId={ selectedCurve } + range={ range } + size={ rect } + /> + <Nodes + curveId={ selectedCurve } + range={ range } + size={ rect } + /> + <StyledLines + curveId={ selectedCurve } + range={ range } + size={ rect } + /> + </> } + </SVGRoot> + </Body> + { curveLength != null && ( + <StyledRangeBar + range={ range } + width={ rect.width } + length={ curveLength } + /> + ) } + </ErrorBoundary> </Root> ); }; diff --git a/packages/automaton-with-gui/src/view/components/DopeSheet.tsx b/packages/automaton-with-gui/src/view/components/DopeSheet.tsx index f5d4d2fe..5da576eb 100644 --- a/packages/automaton-with-gui/src/view/components/DopeSheet.tsx +++ b/packages/automaton-with-gui/src/view/components/DopeSheet.tsx @@ -1,29 +1,45 @@ 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 { useSelectAllEntities } from '../gui-operation-hooks/useSelectAll'; +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<number>; } // == component ==================================================================================== -const DopeSheet = ( { className, intersectionRoot }: DopeSheetProps ): JSX.Element => { +const DopeSheet = ( + { className, refScrollTop, intersectionRoot }: DopeSheetProps +): JSX.Element => { const dispatch = useDispatch(); const refRoot = useRef<HTMLDivElement>( null ); const rect = useRect( refRoot ); @@ -31,13 +47,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 selectAllEntities = useSelectAllEntities(); + + const { + x2t, + t2x, + dx2dt, + snapTime, + } = useTimeValueRangeFuncs( range, rect ); const timeRange = useMemo( () => ( { @@ -80,7 +102,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 +111,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 +120,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 +137,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 +159,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<DopeSheetRectSelectState>( { + 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 +257,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 +292,7 @@ const DopeSheet = ( { className, intersectionRoot }: DopeSheetProps ): JSX.Eleme } } ); }, - [ automaton, timeRange, rect, dispatch ] + [ automaton, x2t, rect.left, dispatch ] ); const handleContextMenu = useCallback( @@ -207,11 +310,32 @@ const DopeSheet = ( { className, intersectionRoot }: DopeSheetProps ): JSX.Eleme 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( @@ -237,11 +361,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 +377,14 @@ const DopeSheet = ( { className, intersectionRoot }: DopeSheetProps ): JSX.Eleme onContextMenu={ handleContextMenu } > { entries } + { rectSelectState.isSelecting && ( + <RectSelectView + x0={ t2x( rectSelectState.t0 ) } + x1={ t2x( rectSelectState.t1 ) } + y0={ rectSelectYRange[ 0 ] } + y1={ rectSelectYRange[ 1 ] } + /> + ) } </Root> ); }; diff --git a/packages/automaton-with-gui/src/view/components/DopeSheetEntry.tsx b/packages/automaton-with-gui/src/view/components/DopeSheetEntry.tsx index 4866c0be..26adc209 100644 --- a/packages/automaton-with-gui/src/view/components/DopeSheetEntry.tsx +++ b/packages/automaton-with-gui/src/view/components/DopeSheetEntry.tsx @@ -1,15 +1,22 @@ +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 { 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'; import type { StateChannelItem } from '../../types/StateChannelItem'; @@ -42,7 +49,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 +61,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<HTMLDivElement>; } ): JSX.Element => { - const { range, refRoot } = props; + const { range, refRoot, rectSelectState } = props; const channelName = props.channel; const dispatch = useDispatch(); const { @@ -71,15 +79,15 @@ 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 selectAllItemsInChannel = useSelectAllItemsInChannel(); const channel = automaton?.getChannel( channelName ); const rect = useRect( refRoot ); @@ -95,26 +103,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 +184,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 +234,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 +291,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 +317,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( @@ -326,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( @@ -342,11 +386,12 @@ const Content = ( props: { item={ item } range={ timeValueRange } size={ rect } + rectSelectState={ rectSelectStateForItem } dopeSheetMode /> ) ) ), - [ channelName, itemsInRange, rect, timeValueRange ] + [ channelName, itemsInRange, rect, rectSelectStateForItem, timeValueRange ] ); return <Container> @@ -365,17 +410,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<HTMLDivElement>( null ); const refProximity = useRef<HTMLDivElement>( 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 ( <Root className={ className } @@ -384,9 +451,10 @@ const DopeSheetEntry = ( props: { <Proximity ref={ refProximity } /> - { isIntersecting ? <Content - channel={ props.channel } - range={ props.range } + { shouldShowContent ? <Content + channel={ channelName } + range={ range } + rectSelectState={ rectSelectState } refRoot={ refRoot } /> : null } </Root> 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 ( + <FallbackRoot> + <Box + data-stalker={ funnyText } + > + <BiggerOne>Something went wrong</BiggerOne> + <SmallerOne>See the console for more info</SmallerOne> + </Box> + <Buttons> + <Button as={ Icons.Retry } + onClick={ handleRetry } + data-stalker="Retry" + /> + <Button as={ Icons.Save } + onClick={ handleSave } + data-stalker="Emergency Save" + /> + </Buttons> + </FallbackRoot> + ); +}; + +// == main component =============================================================================== +interface ErrorBoundaryProps { + children?: React.ReactNode; +} + +const ErrorBoundary = ( { children }: ErrorBoundaryProps ): JSX.Element => { + return ( + <ReactErrorBoundary + FallbackComponent={ ErrorBoundaryFallback } + > + { children } + </ReactErrorBoundary> + ); +}; + +export { ErrorBoundary }; diff --git a/packages/automaton-with-gui/src/view/components/Header.tsx b/packages/automaton-with-gui/src/view/components/Header.tsx index e19b5a1a..907eab30 100644 --- a/packages/automaton-with-gui/src/view/components/Header.tsx +++ b/packages/automaton-with-gui/src/view/components/Header.tsx @@ -1,12 +1,11 @@ import { Colors } from '../constants/Colors'; +import { ErrorBoundary } from './ErrorBoundary'; import { HeaderSeekbar } from './HeaderSeekbar'; import { Icons } from '../icons/Icons'; import { Metrics } from '../constants/Metrics'; -import { minimizeData } from '../../minimizeData'; import { performRedo, performUndo } from '../history/HistoryCommand'; -import { showToasty } from '../states/Toasty'; import { useDispatch, useSelector } from '../states/store'; -import { writeClipboard } from '../utils/clipboard'; +import { useSave } from '../gui-operation-hooks/useSave'; import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; @@ -113,23 +112,7 @@ const Header = ( { className }: HeaderProps ): JSX.Element => { cantUndoThis: state.history.cantUndoThis } ) ); - const save = useCallback( - ( data: string ): void => { - if ( !automaton ) { return; } - - writeClipboard( data ); - - automaton.shouldSave = false; - - showToasty( { - dispatch, - kind: 'info', - message: 'Copied to clipboard!', - timeout: 2.0 - } ); - }, - [ automaton, dispatch ] - ); + const save = useSave(); const handlePlay = useCallback( (): void => { @@ -181,17 +164,8 @@ const Header = ( { className }: HeaderProps ): JSX.Element => { ); const handleSave = useCallback( - () => { - if ( !automaton ) { return; } - - if ( automaton.overrideSave ) { - automaton.overrideSave(); - } else { - const data = automaton.serialize(); - save( JSON.stringify( data ) ); - } - }, - [ automaton, save ] + () => save(), + [ save ] ); const handleSaveContextMenu = useCallback( @@ -215,25 +189,14 @@ const Header = ( { className }: HeaderProps ): JSX.Element => { { name: 'Save', description: 'Copy its serialized data to clipboard.', - callback: () => { - const data = automaton.serialize(); - save( JSON.stringify( data ) ); - } + callback: () => save(), }, { name: 'Minimal Export', description: 'Same as Save, but way more minimized data.', - callback: () => { - const data = automaton.serialize(); - const options = { - precisionTime: automaton.guiSettings.minimizedPrecisionTime, - precisionValue: automaton.guiSettings.minimizedPrecisionValue - }; - const minimized = minimizeData( data, options ); - save( JSON.stringify( minimized ) ); - } - } - ] + callback: () => save( { minimize: true } ), + }, + ], } ); } }, @@ -260,84 +223,86 @@ const Header = ( { className }: HeaderProps ): JSX.Element => { return ( <Root className={ className }> - <Section> - <PlayPause - as={ isPlaying ? Icons.Pause : Icons.Play } - onClick={ handlePlay } - data-stalker="Play / Pause" - /> - <StyledHeaderSeekbar /> - </Section> - <Section> - <Logo as={ Icons.Automaton } - onClick={ () => dispatch( { type: 'About/Open' } ) } - data-stalker={ `Automaton v${ process.env.VERSION! }` } - /> - <StyledShouldSaveIndicator /> - </Section> - <Section> - <Button - as={ Icons.Undo } - onClick={ handleUndo } - disabled={ historyIndex === 0 } - data-stalker={ undoText } - /> - <Button - as={ Icons.Redo } - onClick={ handleRedo } - disabled={ historyIndex === historyEntries.length } - data-stalker={ redoText } - /> - <Button - as={ Icons.Snap } - onClick={ () => { - dispatch( { - type: 'Settings/ChangeMode', - mode: settingsMode === 'snapping' ? 'none' : 'snapping' - } ); - } } - active={ ( settingsMode === 'snapping' ? 1 : 0 ) as any as boolean } // fuck - data-stalker="Snapping" - /> - <Button - as={ Icons.Beat } - onClick={ () => { - dispatch( { - type: 'Settings/ChangeMode', - mode: settingsMode === 'beat' ? 'none' : 'beat' - } ); - } } - active={ ( settingsMode === 'beat' ? 1 : 0 ) as any as boolean } // fuck - data-stalker="Beat" - /> - <Button - as={ Icons.Cog } - onClick={ () => { - dispatch( { - type: 'Settings/ChangeMode', - mode: settingsMode === 'general' ? 'none' : 'general' - } ); - } } - active={ ( settingsMode === 'general' ? 1 : 0 ) as any as boolean } // fuck - data-stalker="General Settings" - /> - <Button - as={ Icons.Scale } - onClick={ () => { - dispatch( { - type: 'Settings/ChangeMode', - mode: settingsMode === 'stats' ? 'none' : 'stats' - } ); - } } - active={ ( settingsMode === 'stats' ? 1 : 0 ) as any as boolean } // fuck - data-stalker="Project Stats" - /> - <Button as={ Icons.Save } - onClick={ handleSave } - onContextMenu={ handleSaveContextMenu } - data-stalker={ 'Save' } - /> - </Section> + <ErrorBoundary> + <Section> + <PlayPause + as={ isPlaying ? Icons.Pause : Icons.Play } + onClick={ handlePlay } + data-stalker="Play / Pause" + /> + <StyledHeaderSeekbar /> + </Section> + <Section> + <Logo as={ Icons.Automaton } + onClick={ () => dispatch( { type: 'About/Open' } ) } + data-stalker={ `Automaton v${ process.env.VERSION! }` } + /> + <StyledShouldSaveIndicator /> + </Section> + <Section> + <Button + as={ Icons.Undo } + onClick={ handleUndo } + disabled={ historyIndex === 0 } + data-stalker={ undoText } + /> + <Button + as={ Icons.Redo } + onClick={ handleRedo } + disabled={ historyIndex === historyEntries.length } + data-stalker={ redoText } + /> + <Button + as={ Icons.Snap } + onClick={ () => { + dispatch( { + type: 'Settings/ChangeMode', + mode: settingsMode === 'snapping' ? 'none' : 'snapping' + } ); + } } + active={ ( settingsMode === 'snapping' ? 1 : 0 ) as any as boolean } // fuck + data-stalker="Snapping" + /> + <Button + as={ Icons.Beat } + onClick={ () => { + dispatch( { + type: 'Settings/ChangeMode', + mode: settingsMode === 'beat' ? 'none' : 'beat' + } ); + } } + active={ ( settingsMode === 'beat' ? 1 : 0 ) as any as boolean } // fuck + data-stalker="Beat" + /> + <Button + as={ Icons.Cog } + onClick={ () => { + dispatch( { + type: 'Settings/ChangeMode', + mode: settingsMode === 'general' ? 'none' : 'general' + } ); + } } + active={ ( settingsMode === 'general' ? 1 : 0 ) as any as boolean } // fuck + data-stalker="General Settings" + /> + <Button + as={ Icons.Scale } + onClick={ () => { + dispatch( { + type: 'Settings/ChangeMode', + mode: settingsMode === 'stats' ? 'none' : 'stats' + } ); + } } + active={ ( settingsMode === 'stats' ? 1 : 0 ) as any as boolean } // fuck + data-stalker="Project Stats" + /> + <Button as={ Icons.Save } + onClick={ handleSave } + onContextMenu={ handleSaveContextMenu } + data-stalker={ 'Save' } + /> + </Section> + </ErrorBoundary> </Root> ); }; diff --git a/packages/automaton-with-gui/src/view/components/Inspector.tsx b/packages/automaton-with-gui/src/view/components/Inspector.tsx index 99e23944..e04d072b 100644 --- a/packages/automaton-with-gui/src/view/components/Inspector.tsx +++ b/packages/automaton-with-gui/src/view/components/Inspector.tsx @@ -1,4 +1,5 @@ import { Colors } from '../constants/Colors'; +import { ErrorBoundary } from './ErrorBoundary'; import { Icons } from '../icons/Icons'; import { InspectorBeat } from './InspectorBeat'; import { InspectorChannelItem } from './InspectorChannelItem'; @@ -97,12 +98,14 @@ const Inspector = ( { className }: { } return <Root className={ className }> - <StyledScrollable> - <Container> - { content } - </Container> - </StyledScrollable> - { content == null && <Logo as={ Icons.AutomatonA } /> }; + <ErrorBoundary> + <StyledScrollable> + <Container> + { content } + </Container> + </StyledScrollable> + { content == null && <Logo as={ Icons.AutomatonA } /> }; + </ErrorBoundary> </Root>; }; diff --git a/packages/automaton-with-gui/src/view/components/InspectorChannelItem.tsx b/packages/automaton-with-gui/src/view/components/InspectorChannelItem.tsx index 75ae356b..8de63ece 100644 --- a/packages/automaton-with-gui/src/view/components/InspectorChannelItem.tsx +++ b/packages/automaton-with-gui/src/view/components/InspectorChannelItem.tsx @@ -131,13 +131,13 @@ const InspectorChannelItem = ( props: Props ): JSX.Element | null => { const dispatch = useDispatch(); const { automaton, stateItem, useBeatInGUI } = useSelector( ( state ) => ( { automaton: state.automaton.instance, - stateItem: state.automaton.channels[ channelName ].items[ itemId ], + stateItem: state.automaton.channels[ channelName ]?.items[ itemId ], // TODO: noUncheckedIndexedAccess ??? useBeatInGUI: state.automaton.guiSettings.useBeatInGUI, } ) ); const channel = automaton?.getChannel( channelName ) ?? null; const { displayToTime, timeToDisplay } = useTimeUnit(); - return ( automaton && channel && ( + return ( automaton && channel && stateItem && ( <Root className={ className }> <InspectorHeader text={ 'Curve' } /> diff --git a/packages/automaton-with-gui/src/view/components/InspectorCurveFx.tsx b/packages/automaton-with-gui/src/view/components/InspectorCurveFx.tsx index 7b3fa149..ca257dab 100644 --- a/packages/automaton-with-gui/src/view/components/InspectorCurveFx.tsx +++ b/packages/automaton-with-gui/src/view/components/InspectorCurveFx.tsx @@ -17,26 +17,25 @@ export interface InspectorCurveFxProps { const InspectorCurveFx = ( props: InspectorCurveFxProps ): JSX.Element | null => { const dispatch = useDispatch(); - const { automaton, curves, useBeatInGUI } = useSelector( ( state ) => ( { + const { automaton, stateFx, useBeatInGUI } = useSelector( ( state ) => ( { automaton: state.automaton.instance, - curves: state.automaton.curves, + stateFx: state.automaton.curves[ props.curveId ]?.fxs[ props.fx ], useBeatInGUI: state.automaton.guiSettings.useBeatInGUI, } ) ); const curve = automaton?.getCurveById( props.curveId ) || null; - const fx = curves[ props.curveId ].fxs[ props.fx ]; - const fxDefParams = automaton?.getFxDefinitionParams( fx.def ); + const fxDefParams = stateFx && automaton?.getFxDefinitionParams( stateFx.def ); const { displayToTime, timeToDisplay } = useTimeUnit(); - return ( automaton && curve && <> - <InspectorHeader text={ `Fx: ${ automaton.getFxDefinitionName( fx.def ) }` } /> + return ( automaton && curve && stateFx && <> + <InspectorHeader text={ `Fx: ${ automaton.getFxDefinitionName( stateFx.def ) }` } /> <InspectorHr /> <InspectorItem name={ useBeatInGUI ? 'Beat' : 'Time' }> <NumberParam type="float" - value={ timeToDisplay( fx.time ) } - onChange={ ( value ) => { curve.moveFx( fx.$id, displayToTime( value ) ); } } + value={ timeToDisplay( stateFx.time ) } + onChange={ ( value ) => { curve.moveFx( stateFx.$id, displayToTime( value ) ); } } onSettle={ ( value, valuePrev ) => { dispatch( { type: 'History/Push', @@ -45,7 +44,7 @@ const InspectorCurveFx = ( props: InspectorCurveFxProps ): JSX.Element | null => { type: 'curve/moveFx', curveId: props.curveId, - fx: fx.$id, + fx: stateFx.$id, time: displayToTime( value ), timePrev: displayToTime( valuePrev ), } @@ -57,8 +56,8 @@ const InspectorCurveFx = ( props: InspectorCurveFxProps ): JSX.Element | null => <InspectorItem name="Length"> <NumberParam type="float" - value={ timeToDisplay( fx.length ) } - onChange={ ( value ) => { curve.resizeFx( fx.$id, displayToTime( value ) ); } } + value={ timeToDisplay( stateFx.length ) } + onChange={ ( value ) => { curve.resizeFx( stateFx.$id, displayToTime( value ) ); } } onSettle={ ( value, valuePrev ) => { dispatch( { type: 'History/Push', @@ -67,7 +66,7 @@ const InspectorCurveFx = ( props: InspectorCurveFxProps ): JSX.Element | null => { type: 'curve/resizeFx', curveId: props.curveId, - fx: fx.$id, + fx: stateFx.$id, length: displayToTime( value ), lengthPrev: displayToTime( valuePrev ), } @@ -79,10 +78,10 @@ const InspectorCurveFx = ( props: InspectorCurveFxProps ): JSX.Element | null => <InspectorItem name="Row"> <NumberParam type="int" - value={ fx.row } + value={ stateFx.row } onChange={ ( row ) => { curve.changeFxRow( - fx.$id, + stateFx.$id, clamp( row, 0.0, CURVE_FX_ROW_MAX - 1 ) ); } } @@ -94,7 +93,7 @@ const InspectorCurveFx = ( props: InspectorCurveFxProps ): JSX.Element | null => { type: 'curve/changeFxRow', curveId: props.curveId, - fx: fx.$id, + fx: stateFx.$id, row: clamp( row, 0, CURVE_FX_ROW_MAX - 1 ), rowPrev } @@ -105,9 +104,9 @@ const InspectorCurveFx = ( props: InspectorCurveFxProps ): JSX.Element | null => </InspectorItem> <InspectorItem name="Bypass"> <BoolParam - value={ !!fx.bypass } + value={ !!stateFx.bypass } onChange={ ( value ) => { - curve.bypassFx( fx.$id, value ); + curve.bypassFx( stateFx.$id, value ); } } onSettle={ ( value ) => { dispatch( { @@ -117,7 +116,7 @@ const InspectorCurveFx = ( props: InspectorCurveFxProps ): JSX.Element | null => { type: 'curve/bypassFx', curveId: props.curveId, - fx: fx.$id, + fx: stateFx.$id, bypass: value } ] @@ -134,9 +133,9 @@ const InspectorCurveFx = ( props: InspectorCurveFxProps ): JSX.Element | null => { ( fxParam.type === 'float' || fxParam.type === 'int' ) && ( <NumberParam type={ fxParam.type } - value={ fx.params[ name ] } + value={ stateFx.params[ name ] } onChange={ ( value ) => { - curve.changeFxParam( fx.$id, name, value ); + curve.changeFxParam( stateFx.$id, name, value ); } } onSettle={ ( value, valuePrev ) => { dispatch( { @@ -146,7 +145,7 @@ const InspectorCurveFx = ( props: InspectorCurveFxProps ): JSX.Element | null => { type: 'curve/changeFxParam', curveId: props.curveId, - fx: fx.$id, + fx: stateFx.$id, key: name, value, valuePrev @@ -158,9 +157,9 @@ const InspectorCurveFx = ( props: InspectorCurveFxProps ): JSX.Element | null => ) } { ( fxParam.type === 'boolean' ) && ( <BoolParam - value={ fx.params[ name ] } + value={ stateFx.params[ name ] } onChange={ ( value ) => { - curve.changeFxParam( fx.$id, name, value ); + curve.changeFxParam( stateFx.$id, name, value ); } } onSettle={ ( value, valuePrev ) => { dispatch( { @@ -170,7 +169,7 @@ const InspectorCurveFx = ( props: InspectorCurveFxProps ): JSX.Element | null => { type: 'curve/changeFxParam', curveId: props.curveId, - fx: fx.$id, + fx: stateFx.$id, key: name, value, valuePrev diff --git a/packages/automaton-with-gui/src/view/components/InspectorCurveNode.tsx b/packages/automaton-with-gui/src/view/components/InspectorCurveNode.tsx index f6f85865..9ecf2e39 100644 --- a/packages/automaton-with-gui/src/view/components/InspectorCurveNode.tsx +++ b/packages/automaton-with-gui/src/view/components/InspectorCurveNode.tsx @@ -14,16 +14,15 @@ interface Props { const InspectorCurveNode = ( props: Props ): JSX.Element | null => { const dispatch = useDispatch(); - const { automaton, curves, useBeatInGUI } = useSelector( ( state ) => ( { + const { automaton, stateNode, useBeatInGUI } = useSelector( ( state ) => ( { automaton: state.automaton.instance, - curves: state.automaton.curves, + stateNode: state.automaton.curves[ props.curveId ]?.nodes[ props.node ], useBeatInGUI: state.automaton.guiSettings.useBeatInGUI, } ) ); const curve = automaton?.getCurveById( props.curveId ) || null; - const node = curves[ props.curveId ].nodes[ props.node ]; const { displayToTime, timeToDisplay } = useTimeUnit(); - return ( curve && <> + return ( curve && stateNode && <> <InspectorHeader text="Node" /> <InspectorHr /> @@ -31,8 +30,8 @@ const InspectorCurveNode = ( props: Props ): JSX.Element | null => { <InspectorItem name={ useBeatInGUI ? 'Beat' : 'Time' }> <NumberParam type="float" - value={ timeToDisplay( node.time ) } - onChange={ ( time ) => { curve.moveNodeTime( node.$id, displayToTime( time ) ); } } + value={ timeToDisplay( stateNode.time ) } + onChange={ ( time ) => { curve.moveNodeTime( stateNode.$id, displayToTime( time ) ); } } onSettle={ ( value, valuePrev ) => { dispatch( { type: 'History/Push', @@ -41,7 +40,7 @@ const InspectorCurveNode = ( props: Props ): JSX.Element | null => { { type: 'curve/moveNodeTime', curveId: props.curveId, - node: node.$id, + node: stateNode.$id, time: displayToTime( value ), timePrev: displayToTime( valuePrev ), } @@ -53,8 +52,8 @@ const InspectorCurveNode = ( props: Props ): JSX.Element | null => { <InspectorItem name="Value"> <NumberParam type="float" - value={ node.value } - onChange={ ( value ) => { curve.moveNodeValue( node.$id, value ); } } + value={ stateNode.value } + onChange={ ( value ) => { curve.moveNodeValue( stateNode.$id, value ); } } onSettle={ ( value, valuePrev ) => { dispatch( { type: 'History/Push', @@ -63,7 +62,7 @@ const InspectorCurveNode = ( props: Props ): JSX.Element | null => { { type: 'curve/moveNodeValue', curveId: props.curveId, - node: node.$id, + node: stateNode.$id, value, valuePrev, } @@ -78,8 +77,8 @@ const InspectorCurveNode = ( props: Props ): JSX.Element | null => { <InspectorItem name={ useBeatInGUI ? 'In Beat' : 'In Time' }> <NumberParam type="float" - value={ timeToDisplay( node.inTime ) } - onChange={ ( value ) => { curve.moveHandleTime( node.$id, 'in', displayToTime( value ) ); } } + value={ timeToDisplay( stateNode.inTime ) } + onChange={ ( value ) => { curve.moveHandleTime( stateNode.$id, 'in', displayToTime( value ) ); } } onSettle={ ( value, valuePrev ) => { dispatch( { type: 'History/Push', @@ -88,7 +87,7 @@ const InspectorCurveNode = ( props: Props ): JSX.Element | null => { { type: 'curve/moveHandleTime', curveId: props.curveId, - node: node.$id, + node: stateNode.$id, dir: 'in', time: displayToTime( value ), timePrev: displayToTime( valuePrev ), @@ -101,8 +100,8 @@ const InspectorCurveNode = ( props: Props ): JSX.Element | null => { <InspectorItem name="In Value"> <NumberParam type="float" - value={ node.inValue } - onChange={ ( value ) => { curve.moveHandleValue( node.$id, 'in', value ); } } + value={ stateNode.inValue } + onChange={ ( value ) => { curve.moveHandleValue( stateNode.$id, 'in', value ); } } onSettle={ ( value, valuePrev ) => { dispatch( { type: 'History/Push', @@ -111,7 +110,7 @@ const InspectorCurveNode = ( props: Props ): JSX.Element | null => { { type: 'curve/moveHandleValue', curveId: props.curveId, - node: node.$id, + node: stateNode.$id, dir: 'in', value, valuePrev, @@ -127,8 +126,8 @@ const InspectorCurveNode = ( props: Props ): JSX.Element | null => { <InspectorItem name={ useBeatInGUI ? 'Out Beat' : 'Out Time' }> <NumberParam type="float" - value={ timeToDisplay( node.outTime ) } - onChange={ ( value ) => { curve.moveHandleTime( node.$id, 'out', displayToTime( value ) ); } } + value={ timeToDisplay( stateNode.outTime ) } + onChange={ ( value ) => { curve.moveHandleTime( stateNode.$id, 'out', displayToTime( value ) ); } } onSettle={ ( value, valuePrev ) => { dispatch( { type: 'History/Push', @@ -137,7 +136,7 @@ const InspectorCurveNode = ( props: Props ): JSX.Element | null => { { type: 'curve/moveHandleTime', curveId: props.curveId, - node: node.$id, + node: stateNode.$id, dir: 'out', time: displayToTime( value ), timePrev: displayToTime( valuePrev ), @@ -150,8 +149,8 @@ const InspectorCurveNode = ( props: Props ): JSX.Element | null => { <InspectorItem name="Out Value"> <NumberParam type="float" - value={ node.outValue } - onChange={ ( value ) => { curve.moveHandleValue( node.$id, 'out', value ); } } + value={ stateNode.outValue } + onChange={ ( value ) => { curve.moveHandleValue( stateNode.$id, 'out', value ); } } onSettle={ ( value, valuePrev ) => { dispatch( { type: 'History/Push', @@ -160,7 +159,7 @@ const InspectorCurveNode = ( props: Props ): JSX.Element | null => { { type: 'curve/moveHandleValue', curveId: props.curveId, - node: node.$id, + node: stateNode.$id, dir: 'out', value, valuePrev, diff --git a/packages/automaton-with-gui/src/view/components/InspectorLabel.tsx b/packages/automaton-with-gui/src/view/components/InspectorLabel.tsx index 6153ecca..bfaa2b83 100644 --- a/packages/automaton-with-gui/src/view/components/InspectorLabel.tsx +++ b/packages/automaton-with-gui/src/view/components/InspectorLabel.tsx @@ -12,13 +12,12 @@ interface Props { const InspectorLabel = ( { name }: Props ): JSX.Element | null => { const dispatch = useDispatch(); - const { automaton, stateLabels } = useSelector( ( state ) => ( { + const { automaton, time } = useSelector( ( state ) => ( { automaton: state.automaton.instance, - stateLabels: state.automaton.labels + time: state.automaton.labels[ name ], } ) ); - const time = stateLabels[ name ]; - if ( !automaton ) { return null; } + if ( automaton == null || time == null ) { return null; } return <> <InspectorHeader text={ `Label: ${ name }` } /> diff --git a/packages/automaton-with-gui/src/view/components/Label.tsx b/packages/automaton-with-gui/src/view/components/Label.tsx index ed5ce941..e0006e73 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 { registerMouseNoDragEvent } from '../utils/registerMouseNoDragEvent'; import { useDispatch, useSelector } from '../states/store'; import { useDoubleClick } from '../utils/useDoubleClick'; +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'; @@ -39,13 +40,22 @@ 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 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 ); @@ -66,53 +76,44 @@ const Label = ( { name, time, range, size }: { const grabLabel = useCallback( (): void => { - if ( !automaton ) { return; } - - dispatch( { - type: 'Timeline/SelectLabels', - labels: [ name ] - } ); - - const timePrev = time; - let newTime = time; - let x = 0.0; - let hasMoved = false; - - registerMouseEvent( - ( event, movementSum ) => { - hasMoved = true; - x += movementSum.x; + if ( !isSelected ) { + dispatch( { + type: 'Timeline/SelectLabels', + labels: [ name ], + } ); + } - const ignoreSnap = event.altKey; - newTime = timePrev + dx2dt( x, range, size.width ); + moveEntities( { moveValue: false, snapOriginTime: time } ); - if ( !ignoreSnap ) { - newTime = snapTime( newTime, range, size.width, guiSettings ); - } + registerMouseNoDragEvent( () => { + dispatch( { + type: 'Timeline/SelectLabels', + labels: [ name ], + } ); + } ); + }, + [ isSelected, moveEntities, time, dispatch, name ] + ); - automaton.setLabel( name, newTime ); - }, - () => { - if ( !hasMoved ) { return; } + const grabLabelCtrl = useCallback( + (): void => { + dispatch( { + type: 'Timeline/SelectLabelsAdd', + labels: [ name ], + } ); - automaton.setLabel( name, newTime ); + moveEntities( { moveValue: false, snapOriginTime: time } ); + registerMouseNoDragEvent( () => { + if ( isSelected ) { dispatch( { - type: 'History/Push', - description: 'Move Label', - commands: [ - { - type: 'automaton/moveLabel', - name, - time: newTime, - timePrev - } - ] + type: 'Timeline/SelectLabelsSub', + labels: [ name ], } ); } - ); + } ); }, - [ automaton, time, name, range, size, guiSettings, dispatch ] + [ dispatch, isSelected, moveEntities, name, time ] ); const renameLabel = useCallback( @@ -181,6 +182,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 +193,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/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 ( + <Root + className={ className } + style={ style } + > + <Background /> + </Root> + ); +}; + +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..fe362f3f 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 '../gui-operation-hooks/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 ( <TimelineItemCurve - channel={ channel } + channel={ channelName } item={ item } range={ range } size={ size } + grabBody={ grabBody } + grabBodyCtrl={ grabBodyCtrl } + removeItem={ removeItem } dopeSheetMode={ dopeSheetMode } /> ); } else { return ( <TimelineItemConstant - channel={ channel } + channel={ channelName } item={ item } range={ range } size={ size } + grabBody={ grabBody } + grabBodyCtrl={ grabBodyCtrl } + removeItem={ removeItem } dopeSheetMode={ dopeSheetMode } /> ); diff --git a/packages/automaton-with-gui/src/view/components/TimelineItemConstant.tsx b/packages/automaton-with-gui/src/view/components/TimelineItemConstant.tsx index fcc4f586..ad10fb54 100644 --- a/packages/automaton-with-gui/src/view/components/TimelineItemConstant.tsx +++ b/packages/automaton-with-gui/src/view/components/TimelineItemConstant.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, 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'; @@ -59,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(); @@ -96,71 +99,6 @@ const TimelineItemConstant = ( props: TimelineItemConstantProps ): JSX.Element = const channel = channelName != null && automaton?.getChannel( channelName ) || null; - const grabBody = useCallback( - (): 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; - - registerMouseEvent( - ( event, movementSum ) => { - hasMoved = true; - x += movementSum.x; - y += movementSum.y; - - const holdTime = event.ctrlKey || event.metaKey; - const holdValue = dopeSheetMode || event.shiftKey; - const ignoreSnap = event.altKey; - - time = holdTime ? timePrev : x2t( x, range, size.width ); - value = holdValue ? valuePrev : y2v( y, range, size.height ); - - if ( !ignoreSnap ) { - if ( !holdTime ) { time = snapTime( time, range, size.width, guiSettings ); } - if ( !holdValue ) { value = snapValue( value, range, size.height, guiSettings ); } - } - - channel.moveItem( item.$id, time ); - channel.changeItemValue( item.$id, value ); - }, - () => { - if ( !hasMoved ) { return; } - - channel.moveItem( item.$id, time ); - channel.changeItemValue( item.$id, value ); - - dispatch( { - type: 'History/Push', - description: 'Move Constant', - commands: [ - { - type: 'channel/moveItem', - 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 ] - ); - const grabLeft = useCallback( (): void => { if ( !channel ) { return; } @@ -261,49 +199,18 @@ 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 ]: () => { 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 +242,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..4f8da81a 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'; @@ -68,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(); @@ -122,71 +125,6 @@ const TimelineItemCurve = ( props: TimelineItemCurveProps ): JSX.Element => { const channel = automaton?.getChannel( channelName ); - const grabBody = useCallback( - (): 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; - - registerMouseEvent( - ( event, movementSum ) => { - hasMoved = true; - x += movementSum.x; - y += movementSum.y; - - const holdTime = event.ctrlKey || event.metaKey; - const holdValue = dopeSheetMode || event.shiftKey; - const ignoreSnap = event.altKey; - - time = holdTime ? timePrev : x2t( x, range, size.width ); - value = holdValue ? valuePrev : y2v( y, range, size.height ); - - if ( !ignoreSnap ) { - if ( !holdTime ) { time = snapTime( time, range, size.width, guiSettings ); } - if ( !holdValue ) { value = snapValue( value, range, size.height, guiSettings ); } - } - - channel.moveItem( item.$id, time ); - channel.changeItemValue( item.$id, value ); - }, - () => { - if ( !hasMoved ) { return; } - - channel.moveItem( item.$id, time ); - channel.changeItemValue( item.$id, value ); - - dispatch( { - type: 'History/Push', - description: 'Move Curve', - commands: [ - { - type: 'channel/moveItem', - 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 ] - ); - const grabTop = useCallback( (): void => { if ( !channel ) { return; } @@ -393,49 +331,18 @@ 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 ]: () => { 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 +374,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/components/ToastyEntry.tsx b/packages/automaton-with-gui/src/view/components/ToastyEntry.tsx index 9e1b9837..8a0c9fd9 100644 --- a/packages/automaton-with-gui/src/view/components/ToastyEntry.tsx +++ b/packages/automaton-with-gui/src/view/components/ToastyEntry.tsx @@ -7,6 +7,7 @@ import styled, { css, keyframes } from 'styled-components'; // == styles ======================================================================================= const Content = styled.div` + white-space: pre-wrap; `; const Icon = styled.img` diff --git a/packages/automaton-with-gui/src/view/constants/Colors.ts b/packages/automaton-with-gui/src/view/constants/Colors.ts index 5b6b91b6..486f0f21 100644 --- a/packages/automaton-with-gui/src/view/constants/Colors.ts +++ b/packages/automaton-with-gui/src/view/constants/Colors.ts @@ -18,6 +18,7 @@ export const Colors = { accent: '#00aaff', accentdark: '#0080c0', fx: '#0fd895', + errorDark: '#a30a4a', error: '#ff0066', errorBright: '#ec5fa3', warning: '#fc9821', 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/gui-operation-hooks/useMoveEntities.ts b/packages/automaton-with-gui/src/view/gui-operation-hooks/useMoveEntities.ts new file mode 100644 index 00000000..7b572e58 --- /dev/null +++ b/packages/automaton-with-gui/src/view/gui-operation-hooks/useMoveEntities.ts @@ -0,0 +1,186 @@ +import { HistoryCommand } from '../history/HistoryCommand'; +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 '../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 ); + + 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<string, number>(); + const itemsNewValueMap = new Map<string, number>(); + + // -- things needed for labels --------------------------------------------------------------- + const labelsTime0Map = new Map( selected.labels.map( ( name ) => ( + [ name, state.automaton.labels[ name ] ] + ) ) ); + const labelsNewTimeMap = new Map<string, number>(); + + // -- common stuff --------------------------------------------------------------------------- + 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; + + // -- keyboards -------------------------------------------------------------------------- + const holdTime = ( event.ctrlKey || event.metaKey ) && moveValue; + const holdValue = event.shiftKey; + const ignoreSnap = event.altKey; + + // -- calc dt / dv ----------------------------------------------------------------------- + if ( !holdTime ) { + let t = originTime + dx2dt( dx ); + + if ( !ignoreSnap && snapOriginTime != null ) { + t = snapTime( t ); + } + + dt = t - originTime; + } + + if ( !holdValue && moveValue ) { + let v = originValue + dy2dv( dy ); + + if ( !ignoreSnap && snapOriginValue != null ) { + v = snapValue( v ); + } + + dv = v - originValue; + } + + // -- 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, dx2dt, dy2dv, snapTime, snapValue, store ] + ); + + return moveEntities; +} diff --git a/packages/automaton-with-gui/src/view/gui-operation-hooks/useSave.ts b/packages/automaton-with-gui/src/view/gui-operation-hooks/useSave.ts new file mode 100644 index 00000000..b687dd35 --- /dev/null +++ b/packages/automaton-with-gui/src/view/gui-operation-hooks/useSave.ts @@ -0,0 +1,80 @@ +import { minimizeData } from '../../minimizeData'; +import { showToasty } from '../states/Toasty'; +import { useCallback } from 'react'; +import { useDispatch, useStore } from '../states/store'; +import { writeClipboard } from '../utils/clipboard'; + +interface Options { + /** + * Use the emergency behavior instead. Intended to be used in {@link OhShit}. + */ + emergencyMode?: boolean; + + /** + * Use the minimal export. + */ + minimize?: boolean; +} + +export function useSave(): ( options?: Options ) => void { + const dispatch = useDispatch(); + const store = useStore(); + + const selectAllEntities = useCallback( + ( options?: Options ): void => { + const state = store.getState(); + const automaton = state.automaton.instance; + if ( !automaton ) { return; } + + if ( options?.emergencyMode ) { + const data = JSON.stringify( automaton.serialize() ); + + console.info( data ); + writeClipboard( data ); + + showToasty( { + dispatch, + kind: 'info', + message: 'Copied to clipboard. Also printed to the console.', + timeout: 2.0, + } ); + } else if ( options?.minimize ) { + const data = automaton.serialize(); + + const minimizeOptions = { + precisionTime: automaton.guiSettings.minimizedPrecisionTime, + precisionValue: automaton.guiSettings.minimizedPrecisionValue + }; + const minimized = minimizeData( data, minimizeOptions ); + + const json = JSON.stringify( minimized ); + writeClipboard( json ); + + showToasty( { + dispatch, + kind: 'info', + message: `Minimized export! +${ json.length.toLocaleString() } bytes`, + timeout: 2.0, + } ); + } else if ( automaton.overrideSave ) { + automaton.overrideSave(); + } else { + const data = automaton.serialize(); + + writeClipboard( JSON.stringify( data ) ); + automaton.shouldSave = false; + + showToasty( { + dispatch, + kind: 'info', + message: 'Copied to clipboard!', + timeout: 2.0, + } ); + } + }, + [ dispatch, store ], + ); + + return selectAllEntities; +} 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/icons/Icons.ts b/packages/automaton-with-gui/src/view/icons/Icons.ts index b4bc17e3..7db6a2b6 100644 --- a/packages/automaton-with-gui/src/view/icons/Icons.ts +++ b/packages/automaton-with-gui/src/view/icons/Icons.ts @@ -13,6 +13,7 @@ import Play from './play.svg'; import Plus from './plus.svg'; import Power from './power.svg'; import Redo from './redo.svg'; +import Retry from './retry.svg'; import Save from './save.svg'; import Scale from './scale.svg'; import Snap from './snap.svg'; @@ -35,6 +36,7 @@ export const Icons = { Plus, Power, Redo, + Retry, Save, Scale, Snap, diff --git a/packages/automaton-with-gui/src/view/icons/retry.svg b/packages/automaton-with-gui/src/view/icons/retry.svg new file mode 100644 index 00000000..72ec72b7 --- /dev/null +++ b/packages/automaton-with-gui/src/view/icons/retry.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"> + <g> + <path d="M16.93,10.92C16.424,8.239 14.067,6.209 11.239,6.209C8.043,6.209 5.448,8.804 5.448,12C5.448,15.196 8.043,17.791 11.239,17.791C12.238,17.791 13.178,17.538 13.999,17.092C14.193,16.986 14.434,17.021 14.591,17.177L16.354,18.94C16.46,19.047 16.513,19.195 16.498,19.345C16.482,19.495 16.4,19.63 16.274,19.712C14.826,20.66 13.097,21.211 11.239,21.211C6.156,21.211 2.029,17.083 2.029,12C2.029,6.917 6.156,2.789 11.239,2.789C15.958,2.789 19.852,6.345 20.387,10.92L21.861,10.92C22.064,10.92 22.246,11.042 22.323,11.229C22.401,11.416 22.358,11.631 22.215,11.774L18.965,15.024C18.77,15.219 18.453,15.219 18.258,15.024L15.008,11.774C14.865,11.631 14.822,11.416 14.9,11.229C14.977,11.042 15.159,10.92 15.361,10.92L16.93,10.92Z"/> + </g> +</svg> 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<ContextMenuCommand>; } export interface State { @@ -17,7 +18,7 @@ export interface State { export const initialState: Readonly<State> = { 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<ContextMenuCommand>; +} | { + type: 'ContextMenu/More'; + position: { x: number; y: number }; + commands: Array<ContextMenuCommand>; } | { type: 'ContextMenu/Close'; }; @@ -40,6 +45,9 @@ export const reducer: Reducer<State, Action> = ( 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 = []; diff --git a/packages/automaton-with-gui/src/view/states/CurveEditor.ts b/packages/automaton-with-gui/src/view/states/CurveEditor.ts index 77a87fcc..38779201 100644 --- a/packages/automaton-with-gui/src/view/states/CurveEditor.ts +++ b/packages/automaton-with-gui/src/view/states/CurveEditor.ts @@ -152,14 +152,6 @@ export const reducer: Reducer<State, ContextAction> = ( state = initialState, ac if ( length < newState.range.t1 ) { newState.range.t1 = length; } - } else if ( action.type === 'Automaton/RemoveCurve' ) { - if ( state.selectedCurve === action.curveId ) { - newState.selectedCurve = null; - } - } else if ( action.type === 'Automaton/RemoveCurveNode' ) { - newState.selected.nodes = arraySetDiff( newState.selected.nodes, [ action.id ] ); - } else if ( action.type === 'Automaton/RemoveCurveFx' ) { - newState.selected.fxs = arraySetDiff( newState.selected.fxs, [ action.id ] ); } } ); }; diff --git a/packages/automaton-with-gui/src/view/states/Timeline.ts b/packages/automaton-with-gui/src/view/states/Timeline.ts index 4a443f55..863243f9 100644 --- a/packages/automaton-with-gui/src/view/states/Timeline.ts +++ b/packages/automaton-with-gui/src/view/states/Timeline.ts @@ -2,7 +2,7 @@ import { Action as ContextAction } from './store'; import { Reducer } from 'redux'; import { Resolution } from '../utils/Resolution'; import { TimeValueRange, dx2dt, dy2dv, x2t, y2v } from '../utils/TimeValueRange'; -import { arraySetDelete, arraySetDiff, arraySetUnion } from '../utils/arraySet'; +import { arraySetDiff, arraySetUnion } from '../utils/arraySet'; import { jsonCopy } from '../../utils/jsonCopy'; import { produce } from 'immer'; @@ -58,7 +58,9 @@ export type Action = { }>; } | { type: 'Timeline/SelectItemsSub'; - items: string[]; + items: Array<{ + id: string; + }>; } | { type: 'Timeline/SelectLabels'; labels: string[]; @@ -84,6 +86,9 @@ export type Action = { dx: number; dy: number; tmax?: number; +} | { + type: 'Timeline/UnselectChannelIfSelected'; + channel: string; }; // == reducer ====================================================================================== @@ -120,7 +125,7 @@ export const reducer: Reducer<State, ContextAction> = ( 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 ); @@ -191,7 +196,7 @@ export const reducer: Reducer<State, ContextAction> = ( state = initialState, ac if ( length < newState.range.t1 ) { newState.range.t1 = length; } - } else if ( action.type === 'Automaton/RemoveChannel' ) { + } else if ( action.type === 'Timeline/UnselectChannelIfSelected' ) { newState.selected.items = jsonCopy( initialState.selected.items ); Object.entries( state.selected.items ).forEach( ( [ id, item ] ) => { @@ -203,10 +208,6 @@ export const reducer: Reducer<State, ContextAction> = ( state = initialState, ac if ( state.selectedChannel === action.channel ) { newState.selectedChannel = null; } - } else if ( action.type === 'Automaton/RemoveChannelItem' ) { - delete newState.selected.items[ action.id ]; - } else if ( action.type === 'Automaton/DeleteLabel' ) { - arraySetDelete( newState.selected.labels, action.name ); } else if ( action.type === 'CurveEditor/SelectCurve' ) { // WHOA WHOA newState.lastSelectedItem = null; } 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<T>( selector: ( state: State ) => T ): T { export function useDispatch(): Dispatch<Action> { return useReduxDispatch<Dispatch<Action>>(); } + +export function useStore(): Store<State, Action> { + return useReduxStore(); +} 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<T>( 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/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 ); + } + }, + ); +} 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/useRect.ts b/packages/automaton-with-gui/src/view/utils/useRect.ts index c54a47c8..76827986 100644 --- a/packages/automaton-with-gui/src/view/utils/useRect.ts +++ b/packages/automaton-with-gui/src/view/utils/useRect.ts @@ -50,12 +50,15 @@ export function useRect<T extends HTMLElement | SVGElement>( handleResize(); - const resizeObserver = new ResizeObserver( () => handleResize() ); + const resizeObserver = new ResizeObserver( handleResize ); resizeObserver.observe( element ); + window.addEventListener( 'resize', handleResize ); + return () => { if ( !resizeObserver ) { return; } resizeObserver.disconnect(); + window.removeEventListener( 'resize', handleResize ); }; }, [ element, handleResize ] 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, + }; +} diff --git a/yarn.lock b/yarn.lock index 3a57bc08..b9a63d6b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -897,6 +897,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.12.5": + version "7.13.10" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.13.10.tgz#47d42a57b6095f4468da440388fdbad8bebf0d7d" + integrity sha512-4QPkjJq6Ns3V/RgpEahRk+AGfL0eO6RHHtTWoNNr5mO49G6B5+X6d6THgWEAvTrznU5xYpbAlVKRYcsCgh/Akw== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.10.4", "@babel/template@^7.3.3": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.10.4.tgz#3251996c4200ebc71d1a8fc405fba940f36ba278" @@ -7680,6 +7687,13 @@ react-dom@^16.13.1: prop-types "^15.6.2" scheduler "^0.19.1" +react-error-boundary@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.1.tgz#932c5ca5cbab8ec4fe37fd7b415aa5c3a47597e7" + integrity sha512-W3xCd9zXnanqrTUeViceufD3mIW8Ut29BUD+S2f0eO2XCOU8b6UrJfY46RDGe5lxCJzfe4j0yvIfh0RbTZhKJw== + dependencies: + "@babel/runtime" "^7.12.5" + react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"