From 9a85cb8219ed252b93aed3c15519a8f7bd1d9ae4 Mon Sep 17 00:00:00 2001 From: alexharri Date: Sun, 28 Jun 2020 18:43:18 +0000 Subject: [PATCH 1/7] work on implementing nested properties --- src/composition/compositionTypes.ts | 17 +- src/composition/state/compositionReducer.ts | 114 +++-------- .../timeline/CompositionTimeline.tsx | 9 +- .../timeline/CompositionTimelineLayer.tsx | 17 +- ... => CompositionTimelineProperty.styles.ts} | 0 ...ty.tsx => CompositionTimelineProperty.tsx} | 16 +- .../timeline/compositionTimelineHandlers.ts | 34 +++- .../util/compositionPropertyUtils.ts | 189 ++++++++++++++++++ src/composition/util/layerPropertyUtils.ts | 62 ------ .../workspace/CompositionWorkspaceLayer.tsx | 40 ++-- src/nodeEditor/graph/computeLayerGraph.ts | 14 +- src/nodeEditor/graph/computeNode.ts | 16 +- src/nodeEditor/graph/createLayerGraph.ts | 15 +- src/nodeEditor/nodeEditorIO.ts | 12 +- src/nodeEditor/nodeEditorReducers.ts | 21 +- src/nodeEditor/nodeEditorUtils.ts | 57 ++++++ src/nodeEditor/nodes/nodeHandlers.ts | 2 +- src/nodeEditor/util/nodeEditorContextMenu.ts | 39 ++-- src/types.ts | 22 +- src/util/mapUtils.ts | 32 ++- 20 files changed, 472 insertions(+), 256 deletions(-) rename src/composition/timeline/{CompositionTimelineLayerProperty.styles.ts => CompositionTimelineProperty.styles.ts} (100%) rename src/composition/timeline/{CompositionTimelineLayerProperty.tsx => CompositionTimelineProperty.tsx} (91%) create mode 100644 src/composition/util/compositionPropertyUtils.ts delete mode 100644 src/composition/util/layerPropertyUtils.ts diff --git a/src/composition/compositionTypes.ts b/src/composition/compositionTypes.ts index 5011d60..32cc675 100644 --- a/src/composition/compositionTypes.ts +++ b/src/composition/compositionTypes.ts @@ -1,4 +1,4 @@ -import { ValueType } from "~/types"; +import { ValueType, PropertyName, PropertyGroupName } from "~/types"; export interface Composition { id: string; @@ -20,13 +20,20 @@ export interface CompositionLayer { properties: string[]; } -export interface CompositionLayerProperty { +export interface CompositionPropertyGroup { + type: "group"; + name: PropertyGroupName; + id: string; + properties: string[]; +} + +export interface CompositionProperty { + type: "property"; id: string; layerId: string; compositionId: string; - label: string; - name: string; - type: ValueType; + name: PropertyName; + valueType: ValueType; value: number; timelineId: string; color?: string; diff --git a/src/composition/state/compositionReducer.ts b/src/composition/state/compositionReducer.ts index 7cc4786..da8efa1 100644 --- a/src/composition/state/compositionReducer.ts +++ b/src/composition/state/compositionReducer.ts @@ -2,16 +2,21 @@ import { ActionType, createAction, getType } from "typesafe-actions"; import { Composition, CompositionLayer, - CompositionLayerProperty, + CompositionProperty, + CompositionPropertyGroup, } from "~/composition/compositionTypes"; -import { TimelineColors } from "~/constants"; -import { ValueType } from "~/types"; -import { getDefaultLayerProperties } from "~/composition/util/layerPropertyUtils"; -import { removeKeysFromMap, addListToMap, modifyItemInMap } from "~/util/mapUtils"; +import { getDefaultLayerProperties } from "~/composition/util/compositionPropertyUtils"; +import { + removeKeysFromMap, + addListToMap, + modifyItemInMap, + modifyItemInUnionMap, +} from "~/util/mapUtils"; const createLayerId = (layers: CompositionState["layers"]) => ( Math.max( + 0, ...Object.keys(layers) .map((x) => parseInt(x)) .filter((x) => !isNaN(x)), @@ -38,6 +43,7 @@ const createPropertyIdFn = (properties: CompositionState["properties"]) => { n++; return ( Math.max( + 0, ...Object.keys(properties) .map((x) => parseInt(x)) .filter((x) => !isNaN(x)), @@ -54,7 +60,7 @@ export interface CompositionState { [layerId: string]: CompositionLayer; }; properties: { - [propertyId: string]: CompositionLayerProperty; + [propertyId: string]: CompositionProperty | CompositionPropertyGroup; }; } @@ -69,66 +75,8 @@ export const initialCompositionState: CompositionState = { height: 400, }, }, - layers: { - "0": { - id: "0", - compositionId: "0", - graphId: "0", - type: "rect", - name: "Rect", - index: 10, - length: 50, - properties: ["0", "1", "2", "3"], - }, - }, - properties: { - "0": { - id: "0", - layerId: "0", - compositionId: "0", - name: "x", - timelineId: "0", - label: "X Position", - type: ValueType.Number, - value: 100, - color: TimelineColors.XPosition, - }, - "1": { - id: "1", - layerId: "0", - compositionId: "0", - name: "y", - timelineId: "", - label: "Y Position", - type: ValueType.Number, - value: 50, - color: TimelineColors.YPosition, - }, - "2": { - id: "2", - layerId: "0", - compositionId: "0", - name: "width", - timelineId: "", - label: "Width", - type: ValueType.Number, - color: TimelineColors.Width, - value: 200, - min: 0, - }, - "3": { - id: "3", - layerId: "0", - compositionId: "0", - name: "height", - timelineId: "", - label: "Height", - type: ValueType.Number, - color: TimelineColors.Height, - value: 75, - min: 0, - }, - }, + layers: {}, + properties: {}, }; export const compositionActions = { @@ -213,13 +161,14 @@ export const compositionReducer = ( const { propertyId, value } = action.payload; return { ...state, - properties: { - ...state.properties, - [propertyId]: { - ...state.properties[propertyId], + properties: modifyItemInUnionMap( + state.properties, + propertyId, + (item: CompositionProperty) => ({ + ...item, value, - }, - }, + }), + ), }; } @@ -227,13 +176,14 @@ export const compositionReducer = ( const { propertyId, timelineId } = action.payload; return { ...state, - properties: { - ...state.properties, - [propertyId]: { - ...state.properties[propertyId], + properties: modifyItemInUnionMap( + state.properties, + propertyId, + (item: CompositionProperty) => ({ + ...item, timelineId, - }, - }, + }), + ), }; } @@ -244,7 +194,7 @@ export const compositionReducer = ( const layerId = createLayerId(state.layers); - const properties = getDefaultLayerProperties({ + const { nestedProperties, topLevelProperties } = getDefaultLayerProperties({ compositionId, layerId, createId: createPropertyIdFn(state.properties), @@ -260,10 +210,12 @@ export const compositionReducer = ( "Rect Layer", composition.layers.map((id) => state.layers[id]), ), - properties: properties.map((p) => p.id), + properties: topLevelProperties.map((p) => p.id), type: "rect", }; + const propertiesToAdd = [...topLevelProperties, ...nestedProperties]; + return { ...state, compositions: { @@ -277,7 +229,7 @@ export const compositionReducer = ( ...state.layers, [layer.id]: layer, }, - properties: addListToMap(state.properties, properties, "id"), + properties: addListToMap(state.properties, propertiesToAdd, "id"), }; } diff --git a/src/composition/timeline/CompositionTimeline.tsx b/src/composition/timeline/CompositionTimeline.tsx index 53b0a78..d904116 100644 --- a/src/composition/timeline/CompositionTimeline.tsx +++ b/src/composition/timeline/CompositionTimeline.tsx @@ -7,7 +7,7 @@ import { compositionTimelineAreaActions, } from "~/composition/timeline/compositionTimelineAreaReducer"; import styles from "~/composition/timeline/CompositionTimeline.styles"; -import { Composition, CompositionLayerProperty } from "~/composition/compositionTypes"; +import { Composition, CompositionProperty } from "~/composition/compositionTypes"; import { splitRect, capToRange } from "~/util/math"; import { RequestActionCallback, requestAction } from "~/listener/requestAction"; import { separateLeftRightMouse } from "~/util/mouse"; @@ -20,6 +20,7 @@ import { TimelineEditor } from "~/timeline/TimelineEditor"; import { createToTimelineViewportX } from "~/timeline/renderTimeline"; import { CompositionState } from "~/composition/state/compositionReducer"; import { CompositionSelectionState } from "~/composition/state/compositionSelectionReducer"; +import { getLayerCompositionProperties } from "~/composition/util/compositionPropertyUtils"; const s = compileStylesheetLabelled(styles); @@ -80,7 +81,7 @@ const CompositionTimelineComponent: React.FC = (props) => { if (props.selection.compositionId === props.composition.id) { const layers = props.composition.layers.map((id) => props.compositionState.layers[id]); - const properties: CompositionLayerProperty[] = []; + const properties: CompositionProperty[] = []; for (let i = 0; i < layers.length; i += 1) { const propertyIds = layers[i].properties; @@ -88,7 +89,9 @@ const CompositionTimelineComponent: React.FC = (props) => { if (!props.selection.properties[propertyIds[j]]) { continue; } - properties.push(props.compositionState.properties[propertyIds[j]]); + properties.push( + ...getLayerCompositionProperties(layers[i].id, props.compositionState), + ); } } diff --git a/src/composition/timeline/CompositionTimelineLayer.tsx b/src/composition/timeline/CompositionTimelineLayer.tsx index e6e9473..8035987 100644 --- a/src/composition/timeline/CompositionTimelineLayer.tsx +++ b/src/composition/timeline/CompositionTimelineLayer.tsx @@ -2,7 +2,7 @@ import React from "react"; import { compileStylesheetLabelled } from "~/util/stylesheets"; import { CompositionLayer } from "~/composition/compositionTypes"; import styles from "~/composition/timeline/CompositionTimelineLayer.style"; -import { CompositionTimelineLayerProperty } from "~/composition/timeline/CompositionTimelineLayerProperty"; +import { CompositionTimelineLayerProperty } from "~/composition/timeline/CompositionTimelineProperty"; import { connectActionState } from "~/state/stateUtils"; import { separateLeftRightMouse } from "~/util/mouse"; import { compositionTimelineHandlers } from "~/composition/timeline/compositionTimelineHandlers"; @@ -13,6 +13,7 @@ import { useComputeHistory } from "~/hook/useComputeHistory"; import { useActionState } from "~/hook/useActionState"; import { ComputeNodeContext } from "~/nodeEditor/graph/computeNode"; import { OpenInAreaIcon } from "~/components/icons/OpenInAreaIcon"; +import { getLayerTransformProperties } from "~/composition/util/compositionPropertyUtils"; const s = compileStylesheetLabelled(styles); @@ -30,9 +31,9 @@ type Props = OwnProps & StateProps; const CompositionTimelineLayerComponent: React.FC = (props) => { const { layer, graph } = props; - const properties = useComputeHistory((state) => - layer.properties.map((id) => state.compositions.properties[id]), - ); + const properties = useComputeHistory((state) => { + return getLayerTransformProperties(layer.id, state.compositions); + }); const { computePropertyValues } = useComputeHistory(() => { return { computePropertyValues: computeLayerGraph(properties, graph) }; @@ -43,7 +44,7 @@ const CompositionTimelineLayerComponent: React.FC = (props) => { computed: {}, composition: actionState.compositions.compositions[layer.compositionId], layer, - properties: layer.properties.map((id) => actionState.compositions.properties[id]), + properties, timelines: actionState.timelines, timelineSelection: actionState.timelineSelection, }; @@ -94,12 +95,12 @@ const CompositionTimelineLayerComponent: React.FC = (props) => { )} - {layer.properties.map((propertyId, i) => { + {properties.map((property, i) => { return ( ); diff --git a/src/composition/timeline/CompositionTimelineLayerProperty.styles.ts b/src/composition/timeline/CompositionTimelineProperty.styles.ts similarity index 100% rename from src/composition/timeline/CompositionTimelineLayerProperty.styles.ts rename to src/composition/timeline/CompositionTimelineProperty.styles.ts diff --git a/src/composition/timeline/CompositionTimelineLayerProperty.tsx b/src/composition/timeline/CompositionTimelineProperty.tsx similarity index 91% rename from src/composition/timeline/CompositionTimelineLayerProperty.tsx rename to src/composition/timeline/CompositionTimelineProperty.tsx index 960d630..0879453 100644 --- a/src/composition/timeline/CompositionTimelineLayerProperty.tsx +++ b/src/composition/timeline/CompositionTimelineProperty.tsx @@ -1,7 +1,7 @@ import React, { useRef } from "react"; import { StopwatchIcon } from "~/components/icons/StopwatchIcon"; import { compileStylesheetLabelled } from "~/util/stylesheets"; -import { CompositionLayerProperty, Composition } from "~/composition/compositionTypes"; +import { CompositionProperty, Composition } from "~/composition/compositionTypes"; import { connectActionState } from "~/state/stateUtils"; import { requestAction, RequestActionParams } from "~/listener/requestAction"; import { compositionActions } from "~/composition/state/compositionReducer"; @@ -10,8 +10,10 @@ import { NumberInput } from "~/components/common/NumberInput"; import { Timeline, TimelineKeyframe } from "~/timeline/timelineTypes"; import { splitKeyframesAtIndex, createTimelineKeyframe } from "~/timeline/timelineUtils"; import { timelineActions } from "~/timeline/timelineActions"; -import styles from "~/composition/timeline/CompositionTimelineLayerProperty.styles"; +import styles from "~/composition/timeline/CompositionTimelineProperty.styles"; import { compositionTimelineHandlers } from "~/composition/timeline/compositionTimelineHandlers"; +import { getLayerPropertyLabel } from "~/composition/util/compositionPropertyUtils"; +import { PropertyName } from "~/types"; const s = compileStylesheetLabelled(styles); @@ -21,7 +23,7 @@ interface OwnProps { value: number; } interface StateProps { - property: CompositionLayerProperty; + property: CompositionProperty; isSelected: boolean; composition: Composition; timeline?: Timeline; @@ -125,9 +127,6 @@ const CompositionTimelineLayerPropertyComponent: React.FC = (props) => { onValueChangeEndFn.current = null; }; - // const value = timeline - // ? getTimelineValueAtIndex(timeline, composition.frameIndex) - // : property.value; const value = props.value; return ( @@ -159,7 +158,7 @@ const CompositionTimelineLayerPropertyComponent: React.FC = (props) => { ), })} > - {property.label} + {getLayerPropertyLabel(property.name)}
= (props) => { onChange={onValueChange} onChangeEnd={onValueChangeEnd} value={value} + tick={property.name === PropertyName.Scale ? 0.01 : 1} />
@@ -180,7 +180,7 @@ const mapStateToProps: MapActionState = ( { id, compositionId }, ) => { const composition = compositions.compositions[compositionId]; - const property = compositions.properties[id]; + const property = compositions.properties[id] as CompositionProperty; const isSelected = !!compositionSelection.properties[id]; const timeline = property.timelineId ? timelines[property.timelineId] : undefined; diff --git a/src/composition/timeline/compositionTimelineHandlers.ts b/src/composition/timeline/compositionTimelineHandlers.ts index bc2c0df..e9784f1 100644 --- a/src/composition/timeline/compositionTimelineHandlers.ts +++ b/src/composition/timeline/compositionTimelineHandlers.ts @@ -9,7 +9,7 @@ import { getTimelineValueAtIndex, createTimelineForLayerProperty, } from "~/timeline/timelineUtils"; -import { Composition } from "~/composition/compositionTypes"; +import { Composition, CompositionProperty } from "~/composition/compositionTypes"; import { compositionActions } from "~/composition/state/compositionReducer"; import { getActionState } from "~/state/stateUtils"; import { timelineActions } from "~/timeline/timelineActions"; @@ -21,6 +21,7 @@ import { computeAreaToViewport } from "~/area/util/areaToViewport"; import { AreaType } from "~/constants"; import { areaInitialStates } from "~/area/state/areaInitialStates"; import { getAreaToOpenTargetId } from "~/area/util/areaUtils"; +import { getLayerTransformProperties } from "~/composition/util/compositionPropertyUtils"; const ZOOM_FAC = 0.25; @@ -183,7 +184,7 @@ export const compositionTimelineHandlers = { ) => { const { compositions, timelines, timelineSelection } = getActionState(); const composition = compositions.compositions[compositionId]; - const property = compositions.properties[propertyId]; + const property = compositions.properties[propertyId] as CompositionProperty; if (timelineId) { // Delete timeline and make the value of the timeline at the current time @@ -268,19 +269,31 @@ export const compositionTimelineHandlers = { const removeLayer = () => { const compositionState = getActionState().compositions; const layer = compositionState.layers[layerId]; - const properties = layer.properties.map((id) => compositionState.properties[id]); // Remove all timelines referenced by properties of the deleted layer. // // In the future, timelines may be referenced in more ways than just by animated // properties. When that is the case we will have to check for other references to // the timelines we're deleting. - const timelineIdsToRemove = properties - .filter((p) => p.timelineId) - .map((p) => p.timelineId); - for (let i = 0; i < timelineIdsToRemove.length; i += 1) { - params.dispatch(timelineActions.removeTimeline(timelineIdsToRemove[i])); + const timelineIdsToRemove: string[] = []; + + function crawl(propertyId: string) { + const property = compositionState.properties[propertyId]; + + if (property.type === "group") { + property.properties.forEach(crawl); + return; + } + + if (property.timelineId) { + timelineIdsToRemove.push(property.timelineId); + } } + layer.properties.forEach(crawl); + + timelineIdsToRemove.forEach((id) => + params.dispatch(timelineActions.removeTimeline(id)), + ); params.dispatch(compositionActions.removeLayer(layer.id)); params.dispatch(contextMenuActions.closeContextMenu()); @@ -336,8 +349,9 @@ export const compositionTimelineHandlers = { return; } - const properties = layer.properties.map((id) => compositionState.properties[id]); - const graph = createLayerGraph(layerId, properties); + const transformProperties = getLayerTransformProperties(layer.id, compositionState); + const graph = createLayerGraph(layerId, transformProperties); + dispatch(compositionActions.setLayerGraphId(layerId, graph.id)); dispatch(nodeEditorActions.setGraph(graph)); submitAction("Create layer graph"); diff --git a/src/composition/util/compositionPropertyUtils.ts b/src/composition/util/compositionPropertyUtils.ts new file mode 100644 index 0000000..358c311 --- /dev/null +++ b/src/composition/util/compositionPropertyUtils.ts @@ -0,0 +1,189 @@ +import { CompositionProperty, CompositionPropertyGroup } from "~/composition/compositionTypes"; +import { ValueType, PropertyName, PropertyGroupName } from "~/types"; +import { TimelineColors } from "~/constants"; +import { CompositionState } from "~/composition/state/compositionReducer"; + +const propertyGroupNameToLabel: { [key in keyof typeof PropertyGroupName]: string } = { + Dimensions: "Dimensions", + Transform: "Transform", +}; + +const propertyNameToLabel: { [key in keyof typeof PropertyName]: string } = { + Scale: "Scale", + Rotation: "Rotation", + PositionX: "X Position", + PositionY: "Y Position", + Opacity: "Opacity", + + Height: "Height", + Width: "Width", +}; + +export const getLayerPropertyLabel = (name: PropertyName): string => { + const key = PropertyName[name] as keyof typeof PropertyName; + return propertyNameToLabel[key]; +}; + +export const getLayerPropertyGroupLabel = (name: PropertyGroupName): string => { + const key = PropertyGroupName[name] as keyof typeof PropertyGroupName; + return propertyGroupNameToLabel[key]; +}; + +interface Options { + createId: () => string; + compositionId: string; + layerId: string; +} + +const createDefaultTransformProperties = (opts: Options): CompositionProperty[] => { + const { compositionId, createId, layerId } = opts; + + return [ + { + type: "property", + id: createId(), + layerId, + compositionId, + name: PropertyName.PositionX, + timelineId: "", + valueType: ValueType.Number, + value: 0, + color: TimelineColors.XPosition, + }, + { + type: "property", + id: createId(), + layerId, + compositionId, + name: PropertyName.PositionY, + timelineId: "", + valueType: ValueType.Number, + value: 0, + color: TimelineColors.YPosition, + }, + { + type: "property", + id: createId(), + layerId, + compositionId, + name: PropertyName.Scale, + timelineId: "", + valueType: ValueType.Number, + value: 1, + color: TimelineColors.YPosition, + }, + { + type: "property", + id: createId(), + layerId, + compositionId, + name: PropertyName.Rotation, + timelineId: "", + valueType: ValueType.Number, + value: 0, + color: TimelineColors.YPosition, + }, + ]; +}; + +const createDefaultDimensionProperties = (opts: Options): CompositionProperty[] => { + const { compositionId, createId, layerId } = opts; + + return [ + { + type: "property", + id: createId(), + layerId, + compositionId, + name: PropertyName.Width, + timelineId: "", + valueType: ValueType.Number, + color: TimelineColors.Width, + value: 100, + min: 0, + }, + { + type: "property", + id: createId(), + layerId, + compositionId, + name: PropertyName.Height, + timelineId: "", + valueType: ValueType.Number, + color: TimelineColors.Height, + value: 100, + min: 0, + }, + ]; +}; + +export const getDefaultLayerProperties = ( + opts: Options, +): { + topLevelProperties: Array; + nestedProperties: Array; +} => { + const transformProperties = createDefaultTransformProperties(opts); + const transformGroup: CompositionPropertyGroup = { + type: "group", + name: PropertyGroupName.Transform, + id: opts.createId(), + properties: transformProperties.map((p) => p.id), + }; + + const dimensionProperties = createDefaultDimensionProperties(opts); + const dimensionsGroup: CompositionPropertyGroup = { + type: "group", + name: PropertyGroupName.Dimensions, + id: opts.createId(), + properties: dimensionProperties.map((p) => p.id), + }; + + return { + nestedProperties: [...transformProperties, ...dimensionProperties], + topLevelProperties: [dimensionsGroup, transformGroup], + }; +}; + +export const getLayerTransformProperties = ( + layerId: string, + compositionState: CompositionState, +): CompositionProperty[] => { + const layer = compositionState.layers[layerId]; + + const propertyGroups = layer.properties.map((id) => compositionState.properties[id]); + const transformGroup = propertyGroups.find((group): group is CompositionPropertyGroup => { + return group.type === "group" && group.name === PropertyGroupName.Transform; + }); + + if (!transformGroup) { + throw new Error("Layer does not contain Transform property group"); + } + + const transformProperties = transformGroup.properties.map((id) => { + return compositionState.properties[id] as CompositionProperty; + }); + + return transformProperties; +}; + +export function getLayerCompositionProperties( + layerId: string, + compositionState: CompositionState, +): CompositionProperty[] { + const properties: CompositionProperty[] = []; + + function crawl(propertyId: string) { + const property = compositionState.properties[propertyId]; + + if (property.type === "group") { + property.properties.forEach(crawl); + return; + } + + properties.push(property); + } + compositionState.layers[layerId].properties.forEach(crawl); + + return properties; +} diff --git a/src/composition/util/layerPropertyUtils.ts b/src/composition/util/layerPropertyUtils.ts deleted file mode 100644 index e086bb1..0000000 --- a/src/composition/util/layerPropertyUtils.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { CompositionLayerProperty } from "~/composition/compositionTypes"; -import { ValueType } from "~/types"; -import { TimelineColors } from "~/constants"; - -interface Options { - createId: () => string; - compositionId: string; - layerId: string; -} - -export const getDefaultLayerProperties = (opts: Options): CompositionLayerProperty[] => { - const { compositionId, createId, layerId } = opts; - - return [ - { - id: createId(), - layerId, - compositionId, - name: "x", - timelineId: "", - label: "X Position", - type: ValueType.Number, - value: 0, - color: TimelineColors.XPosition, - }, - { - id: createId(), - layerId, - compositionId, - name: "y", - timelineId: "", - label: "Y Position", - type: ValueType.Number, - value: 0, - color: TimelineColors.YPosition, - }, - { - id: createId(), - layerId, - compositionId, - name: "width", - timelineId: "", - label: "Width", - type: ValueType.Number, - color: TimelineColors.Width, - value: 100, - min: 0, - }, - { - id: createId(), - layerId, - compositionId, - name: "height", - timelineId: "", - label: "Height", - type: ValueType.Number, - color: TimelineColors.Height, - value: 100, - min: 0, - }, - ]; -}; diff --git a/src/composition/workspace/CompositionWorkspaceLayer.tsx b/src/composition/workspace/CompositionWorkspaceLayer.tsx index 6b5eabb..2b82fee 100644 --- a/src/composition/workspace/CompositionWorkspaceLayer.tsx +++ b/src/composition/workspace/CompositionWorkspaceLayer.tsx @@ -7,6 +7,11 @@ import { computeLayerGraph } from "~/nodeEditor/graph/computeLayerGraph"; import { NodeEditorGraphState } from "~/nodeEditor/nodeEditorReducers"; import { connectActionState } from "~/state/stateUtils"; import { CompositionLayer } from "~/composition/compositionTypes"; +import { PropertyName } from "~/types"; +import { + getLayerCompositionProperties, + getLayerTransformProperties, +} from "~/composition/util/compositionPropertyUtils"; const styles = ({ css }: StyleParams) => ({ element: css` @@ -30,14 +35,17 @@ type Props = OwnProps & StateProps; const CompositionWorkspaceLayerComponent: React.FC = (props) => { const { layer, graph } = props; - const allProperties = useActionState((state) => state.compositions.properties); - const properties = useComputeHistory((state) => - layer.properties.map((id) => state.compositions.properties[id]), - ); + const properties = useActionState((state) => { + return getLayerCompositionProperties(layer.id, state.compositions); + }); + + const transformProperties = useActionState((state) => { + return getLayerTransformProperties(layer.id, state.compositions); + }); const { computePropertyValues } = useComputeHistory(() => { - return { computePropertyValues: computeLayerGraph(properties, graph) }; + return { computePropertyValues: computeLayerGraph(transformProperties, graph) }; }); const propertyToValue = useActionState((actionState) => { @@ -45,7 +53,7 @@ const CompositionWorkspaceLayerComponent: React.FC = (props) => { computed: {}, composition: actionState.compositions.compositions[layer.compositionId], layer, - properties: layer.properties.map((id) => actionState.compositions.properties[id]), + properties: transformProperties, timelines: actionState.timelines, timelineSelection: actionState.timelineSelection, }; @@ -54,24 +62,24 @@ const CompositionWorkspaceLayerComponent: React.FC = (props) => { return computePropertyValues(context, mostRecentGraph); }); - const nameToProperty = layer.properties.reduce<{ [key: string]: number }>((obj, propertyId) => { - const p = allProperties[propertyId]; - const value = propertyToValue[propertyId]; - obj[p.name] = value; + const nameToProperty = properties.reduce((obj, p) => { + const value = propertyToValue[p.id] ?? p.value; + (obj as any)[PropertyName[p.name]] = value; return obj; - }, {}); + }, {} as { [key in keyof typeof PropertyName]: number }); - const { width, height, x, y } = nameToProperty; + const { Width, Height, PositionX, PositionY, Scale, Rotation } = nameToProperty; return (
); diff --git a/src/nodeEditor/graph/computeLayerGraph.ts b/src/nodeEditor/graph/computeLayerGraph.ts index 1b8c6cd..738e96a 100644 --- a/src/nodeEditor/graph/computeLayerGraph.ts +++ b/src/nodeEditor/graph/computeLayerGraph.ts @@ -1,12 +1,12 @@ import { NodeEditorGraphState } from "~/nodeEditor/nodeEditorReducers"; import { NodeEditorNode } from "~/nodeEditor/nodeEditorIO"; import { NodeEditorNodeType } from "~/types"; -import { CompositionLayerProperty } from "~/composition/compositionTypes"; +import { CompositionProperty } from "~/composition/compositionTypes"; import { ComputeNodeContext, computeNodeOutputArgs } from "~/nodeEditor/graph/computeNode"; import { getTimelineValueAtIndex } from "~/timeline/timelineUtils"; export const computeLayerGraph = ( - properties: CompositionLayerProperty[], + properties: CompositionProperty[], graph?: NodeEditorGraphState, ): (( context: ComputeNodeContext, @@ -35,16 +35,18 @@ export const computeLayerGraph = ( return computeRawPropertyValues; } - let outputNode: NodeEditorNode | undefined; + let outputNode: NodeEditorNode | undefined; const keys = Object.keys(graph.nodes); for (let i = 0; i < keys.length; i += 1) { const node = graph.nodes[keys[i]]; - if (node.type === NodeEditorNodeType.layer_output) { + if (node.type === NodeEditorNodeType.layer_transform_output) { if (outputNode) { - console.warn(`More than one '${NodeEditorNodeType.layer_output}' node in graph`); + console.warn( + `More than one '${NodeEditorNodeType.layer_transform_output}' node in graph`, + ); } else { - outputNode = node as NodeEditorNode; + outputNode = node as NodeEditorNode; } } } diff --git a/src/nodeEditor/graph/computeNode.ts b/src/nodeEditor/graph/computeNode.ts index b46b6ef..f1ee267 100644 --- a/src/nodeEditor/graph/computeNode.ts +++ b/src/nodeEditor/graph/computeNode.ts @@ -1,11 +1,7 @@ import { NodeEditorNode, NodeEditorNodeState } from "~/nodeEditor/nodeEditorIO"; import { NodeEditorNodeType, ValueType } from "~/types"; import { DEG_TO_RAD_FAC, RAD_TO_DEG_FAC } from "~/constants"; -import { - Composition, - CompositionLayer, - CompositionLayerProperty, -} from "~/composition/compositionTypes"; +import { Composition, CompositionLayer, CompositionProperty } from "~/composition/compositionTypes"; import { TimelineState } from "~/timeline/timelineReducer"; import { getTimelineValueAtIndex } from "~/timeline/timelineUtils"; import { interpolate } from "~/util/math"; @@ -24,7 +20,7 @@ export interface ComputeNodeContext { computed: { [nodeId: string]: ComputeNodeArg[] }; composition: Composition; layer: CompositionLayer; - properties: CompositionLayerProperty[]; + properties: CompositionProperty[]; timelines: TimelineState; timelineSelection: TimelineSelectionState; } @@ -158,9 +154,11 @@ const compute: { return [toArg.number(deg * RAD_TO_DEG_FAC)]; }, - [Type.layer_output]: (args) => args, + [Type.layer_transform_output]: (args) => { + return args; + }, - [Type.layer_input]: (_, ctx) => { + [Type.layer_transform_input]: (_, ctx) => { return ctx.properties.map((p) => { const value = p.timelineId ? getTimelineValueAtIndex( @@ -295,7 +293,7 @@ export const computeNodeOutputArgs = ( const value = mostRecentNode?.inputs[i].value ?? _value; let defaultValue = { type, value }; - if (node.type === Type.layer_output) { + if (node.type === Type.layer_transform_output) { const p = ctx.properties[i]; defaultValue = { type, diff --git a/src/nodeEditor/graph/createLayerGraph.ts b/src/nodeEditor/graph/createLayerGraph.ts index feebda1..5857bcc 100644 --- a/src/nodeEditor/graph/createLayerGraph.ts +++ b/src/nodeEditor/graph/createLayerGraph.ts @@ -2,12 +2,13 @@ import uuid from "uuid/v4"; import { NodeEditorGraphState } from "~/nodeEditor/nodeEditorReducers"; import { NodeEditorNodeInput, getNodeEditorNodeDefaultState } from "~/nodeEditor/nodeEditorIO"; import { NodeEditorNodeType } from "~/types"; -import { CompositionLayerProperty } from "~/composition/compositionTypes"; +import { CompositionProperty } from "~/composition/compositionTypes"; import { DEFAULT_NODE_EDITOR_NODE_WIDTH } from "~/constants"; +import { getLayerPropertyLabel } from "~/composition/util/compositionPropertyUtils"; export const createLayerGraph = ( layerId: string, - properties: CompositionLayerProperty[], + transformProperties: CompositionProperty[], ): NodeEditorGraphState => { const nodeId = "0"; return { @@ -16,14 +17,14 @@ export const createLayerGraph = ( [nodeId]: { id: nodeId, position: Vec2.new(0, 0), - state: getNodeEditorNodeDefaultState(NodeEditorNodeType.layer_input), - type: NodeEditorNodeType.layer_input, + state: getNodeEditorNodeDefaultState(NodeEditorNodeType.layer_transform_input), + type: NodeEditorNodeType.layer_transform_input, width: DEFAULT_NODE_EDITOR_NODE_WIDTH, outputs: [], - inputs: properties.map((property) => ({ - name: property.name, + inputs: transformProperties.map((property) => ({ + name: getLayerPropertyLabel(property.name), pointer: null, - type: property.type, + type: property.valueType, value: null, })), }, diff --git a/src/nodeEditor/nodeEditorIO.ts b/src/nodeEditor/nodeEditorIO.ts index 8b2873d..b03bbfe 100644 --- a/src/nodeEditor/nodeEditorIO.ts +++ b/src/nodeEditor/nodeEditorIO.ts @@ -154,10 +154,10 @@ export const getNodeEditorNodeDefaultInputs = (type: NodeEditorNodeType): NodeEd }, ]; - case NodeEditorNodeType.layer_input: + case NodeEditorNodeType.layer_transform_input: return []; - case NodeEditorNodeType.layer_output: + case NodeEditorNodeType.layer_transform_output: return []; case NodeEditorNodeType.expr: @@ -256,10 +256,10 @@ export const getNodeEditorNodeDefaultOutputs = ( }, ]; - case NodeEditorNodeType.layer_input: + case NodeEditorNodeType.layer_transform_input: return []; - case NodeEditorNodeType.layer_output: + case NodeEditorNodeType.layer_transform_output: return []; case NodeEditorNodeType.expr: @@ -312,8 +312,8 @@ type NodeEditorNodeStateMap = { [NodeEditorNodeType.vec2_input]: {}; [NodeEditorNodeType.empty]: {}; [NodeEditorNodeType.rect_translate]: {}; - [NodeEditorNodeType.layer_input]: {}; - [NodeEditorNodeType.layer_output]: {}; + [NodeEditorNodeType.layer_transform_input]: {}; + [NodeEditorNodeType.layer_transform_output]: {}; [NodeEditorNodeType.expr]: { expression: string; textareaHeight: number; diff --git a/src/nodeEditor/nodeEditorReducers.ts b/src/nodeEditor/nodeEditorReducers.ts index f8d0626..02db5ff 100644 --- a/src/nodeEditor/nodeEditorReducers.ts +++ b/src/nodeEditor/nodeEditorReducers.ts @@ -13,6 +13,7 @@ import { import { rectsIntersect } from "~/util/math"; import { calculateNodeHeight } from "~/nodeEditor/util/calculateNodeHeight"; import { removeKeysFromMap } from "~/util/mapUtils"; +import { removeNodeAndReferencesToItInGraph } from "~/nodeEditor/nodeEditorUtils"; type NodeEditorAction = ActionType; @@ -57,6 +58,7 @@ export interface NodeEditorGraphState { const createNodeId = (nodes: { [key: string]: any }) => ( Math.max( + 0, ...Object.keys(nodes) .map((x) => parseInt(x)) .filter((x) => !isNaN(x)), @@ -193,25 +195,12 @@ function graphReducer(state: NodeEditorGraphState, action: NodeEditorAction): No case getType(actions.removeNode): { const { nodeId } = action.payload; + return { - ...state, + ...removeNodeAndReferencesToItInGraph(nodeId, state), selection: { - nodes: Object.keys(state.selection.nodes).reduce((obj, key) => { - if (key !== nodeId) { - obj[key] = state.selection.nodes[key]; - } - return obj; - }, {}), + nodes: removeKeysFromMap(state.selection.nodes, [nodeId]), }, - nodes: Object.keys(state.nodes).reduce( - (obj, key) => { - if (key !== nodeId) { - obj[key] = state.nodes[key]; - } - return obj; - }, - {}, - ), }; } diff --git a/src/nodeEditor/nodeEditorUtils.ts b/src/nodeEditor/nodeEditorUtils.ts index b1c57e9..207faf4 100644 --- a/src/nodeEditor/nodeEditorUtils.ts +++ b/src/nodeEditor/nodeEditorUtils.ts @@ -1,3 +1,6 @@ +import { NodeEditorGraphState } from "~/nodeEditor/nodeEditorReducers"; +import { removeKeysFromMap } from "~/util/mapUtils"; + export const transformGlobalToNodeEditorPosition = ( globalPos: Vec2, viewport: Rect, @@ -45,3 +48,57 @@ export const nodeEditorPositionToViewport = ( .add(options.pan) .add(Vec2.new(options.viewport.width / 2, options.viewport.height / 2)); }; + +export const findInputsThatReferenceNodeOutputs = (nodeId: string, graph: NodeEditorGraphState) => { + const results: Array<{ nodeId: string; inputIndex: number }> = []; + + for (const key in graph.nodes) { + const node = graph.nodes[key]; + + for (let i = 0; i < node.inputs.length; i += 1) { + const input = node.inputs[i]; + + if (!input.pointer) { + continue; + } + + if (input.pointer.nodeId === nodeId) { + results.push({ inputIndex: i, nodeId: key }); + } + } + } + + return results; +}; + +export const removeNodeAndReferencesToItInGraph = ( + nodeId: string, + graph: NodeEditorGraphState, +): NodeEditorGraphState => { + const refs = findInputsThatReferenceNodeOutputs(nodeId, graph); + + const newGraph: NodeEditorGraphState = { + ...graph, + nodes: removeKeysFromMap(graph.nodes, [nodeId]), + }; + + for (let i = 0; i < refs.length; i += 1) { + const { inputIndex, nodeId } = refs[i]; + + const node = newGraph.nodes[nodeId]; + + newGraph.nodes[nodeId] = { + ...node, + inputs: node.inputs.map((input, i) => + i === inputIndex + ? { + ...input, + pointer: null, + } + : input, + ), + }; + } + + return newGraph; +}; diff --git a/src/nodeEditor/nodes/nodeHandlers.ts b/src/nodeEditor/nodes/nodeHandlers.ts index 009992c..2d90091 100644 --- a/src/nodeEditor/nodes/nodeHandlers.ts +++ b/src/nodeEditor/nodes/nodeHandlers.ts @@ -83,7 +83,7 @@ export const nodeHandlers = { "Node", [ { - label: "Delete", + label: "Delete node", onSelect: () => { dispatch(contextMenuActions.closeContextMenu()); dispatch(nodeEditorActions.removeNode(graphId, nodeId)); diff --git a/src/nodeEditor/util/nodeEditorContextMenu.ts b/src/nodeEditor/util/nodeEditorContextMenu.ts index 8dbb62c..21b3283 100644 --- a/src/nodeEditor/util/nodeEditorContextMenu.ts +++ b/src/nodeEditor/util/nodeEditorContextMenu.ts @@ -1,4 +1,4 @@ -import { NodeEditorNodeType } from "~/types"; +import { NodeEditorNodeType, PropertyGroupName } from "~/types"; import { RequestActionParams } from "~/listener/requestAction"; import { nodeEditorActions } from "~/nodeEditor/nodeEditorActions"; import { contextMenuActions } from "~/contextMenu/contextMenuActions"; @@ -6,6 +6,8 @@ import { transformGlobalToNodeEditorPosition } from "~/nodeEditor/nodeEditorUtil import { NodeEditorNodeInput, NodeEditorNodeOutput } from "~/nodeEditor/nodeEditorIO"; import { getActionState, getAreaActionState } from "~/state/stateUtils"; import { AreaType } from "~/constants"; +import { getLayerPropertyLabel } from "~/composition/util/compositionPropertyUtils"; +import { CompositionPropertyGroup, CompositionProperty } from "~/composition/compositionTypes"; interface Options { graphId: string; @@ -20,11 +22,22 @@ export const getNodeEditorContextMenuOptions = (options: Options) => { const { dispatch, submitAction } = params; const actionState = getActionState(); + const compositionState = actionState.compositions; const graph = actionState.nodeEditor.graphs[graphId]; - const layer = actionState.compositions.layers[graph.layerId]; - const properties = layer.properties.map( - (propertyId) => actionState.compositions.properties[propertyId], - ); + const layer = compositionState.layers[graph.layerId]; + + const propertyGroups = layer.properties.map((id) => compositionState.properties[id]); + const transformGroup = propertyGroups.find((group): group is CompositionPropertyGroup => { + return group.type === "group" && group.name === PropertyGroupName.Transform; + }); + + if (!transformGroup) { + throw new Error("Layer does not contain Transform property group"); + } + + const transformProperties = transformGroup.properties.map((id) => { + return compositionState.properties[id] as CompositionProperty; + }); const { scale, pan } = getAreaActionState(areaId); @@ -64,27 +77,27 @@ export const getNodeEditorContextMenuOptions = (options: Options) => { label: "Layer", options: [ createAddNodeOption({ - type: NodeEditorNodeType.layer_input, + type: NodeEditorNodeType.layer_transform_input, label: "Layer input", getIO: () => { return { - outputs: properties.map((property) => ({ - name: property.name, - type: property.type, + outputs: transformProperties.map((property) => ({ + name: getLayerPropertyLabel(property.name), + type: property.valueType, })), inputs: [], }; }, }), createAddNodeOption({ - type: NodeEditorNodeType.layer_output, + type: NodeEditorNodeType.layer_transform_output, label: "Layer output", getIO: () => { return { - inputs: properties.map((property) => ({ - name: property.name, + inputs: transformProperties.map((property) => ({ + name: getLayerPropertyLabel(property.name), pointer: null, - type: property.type, + type: property.valueType, value: null, })), outputs: [], diff --git a/src/types.ts b/src/types.ts index 03bd27f..82e69a0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,8 +22,8 @@ export enum NodeEditorNodeType { expr = "expr", - layer_output = "layer_output", - layer_input = "layer_input", + layer_transform_output = "layer_transform_output", + layer_transform_input = "layer_transform_input", } export enum ValueType { @@ -33,6 +33,24 @@ export enum ValueType { Any = "any", } +export enum PropertyGroupName { + Transform, + Dimensions, +} + +export enum PropertyName { + // Transform Properties + Scale, + PositionX, + PositionY, + Rotation, + Opacity, + + // Other Properties + Width, + Height, +} + export type Json = string | number | boolean | null | JsonObject | JsonArray | undefined; export interface JsonArray extends Array {} export interface JsonObject { diff --git a/src/util/mapUtils.ts b/src/util/mapUtils.ts index 1478e16..692c83c 100644 --- a/src/util/mapUtils.ts +++ b/src/util/mapUtils.ts @@ -7,10 +7,10 @@ export const removeKeysFromMap = (obj: T, keys }, {} as T); }; -export const addListToMap = ( +export const addListToMap = ( map: M, - items: T[], - idField: keyof T, + items: U[], + idField: keyof T & keyof U, ): M => { return { ...map, @@ -32,3 +32,29 @@ export const modifyItemInMap = ( [id]: fn(map[id]), }; }; + +export const modifyItemInUnionMap = < + M extends { [key: string]: T }, + T = M[string], + U extends T = T +>( + map: M, + id: string, + fn: (item: U) => U, +): M => { + return { + ...map, + [id]: fn(map[id] as U), + }; +}; + +export const reduceMap = ( + map: M, + fn: (item: T) => T, +): M => { + const keys = Object.keys(map); + return keys.reduce((obj, key) => { + (obj as any)[key] = fn(map[key]); + return obj; + }, {} as M); +}; From 8b130d7f9247ee1281db040ff5e935cf5f9b0c47 Mon Sep 17 00:00:00 2001 From: alexharri Date: Sun, 28 Jun 2020 19:08:30 +0000 Subject: [PATCH 2/7] add tests for map utils and node editor utils --- __tests__/nodeEditor/nodeEditorUtils.spec.ts | 139 +++++++++++++++++++ __tests__/util/mapUtils.spec.ts | 139 +++++++++++++++++++ src/util/mapUtils.ts | 16 ++- 3 files changed, 290 insertions(+), 4 deletions(-) create mode 100644 __tests__/nodeEditor/nodeEditorUtils.spec.ts create mode 100644 __tests__/util/mapUtils.spec.ts diff --git a/__tests__/nodeEditor/nodeEditorUtils.spec.ts b/__tests__/nodeEditor/nodeEditorUtils.spec.ts new file mode 100644 index 0000000..665cb80 --- /dev/null +++ b/__tests__/nodeEditor/nodeEditorUtils.spec.ts @@ -0,0 +1,139 @@ +import "~/globals"; + +import { + findInputsThatReferenceNodeOutputs, + removeNodeAndReferencesToItInGraph, +} from "~/nodeEditor/nodeEditorUtils"; +import { NodeEditorGraphState } from "~/nodeEditor/nodeEditorReducers"; +import { ValueType } from "~/types"; +import { NodeEditorNode } from "~/nodeEditor/nodeEditorIO"; + +const _graphBase: NodeEditorGraphState = { + _addNodeOfTypeOnClick: null, + _dragInputTo: null, + _dragOutputTo: null, + _dragSelectRect: null, + moveVector: Vec2.new(0, 0), + id: "0", + layerId: "", + nodes: {}, + selection: { nodes: {} }, +}; + +const _nodeBase: NodeEditorNode = { + id: "0", + inputs: [], + outputs: [], + position: Vec2.new(0, 0), + state: {}, + type: null, + width: 0, +}; + +describe("findInputsThatReferenceNodeOutputs", () => { + it("correctly finds references", () => { + const graph: NodeEditorGraphState = { + ..._graphBase, + nodes: { + a: { + ..._nodeBase, + id: "a", + inputs: [ + { + name: "Input 0", + pointer: null, + type: ValueType.Any, + value: null, + }, + { + name: "Input 1", + pointer: { nodeId: "b", outputIndex: 1 }, + type: ValueType.Any, + value: null, + }, + ], + }, + b: { + ..._nodeBase, + id: "b", + outputs: [ + { + type: ValueType.Any, + name: "Output 0", + }, + ], + }, + }, + }; + + const output: ReturnType = [ + { nodeId: "a", inputIndex: 1 }, + ]; + + expect(findInputsThatReferenceNodeOutputs("b", graph)).toEqual(output); + }); +}); + +describe("removeNodeAndReferencesToItInGraph", () => { + it("removes the node and references to it", () => { + const graph: NodeEditorGraphState = { + ..._graphBase, + nodes: { + a: { + ..._nodeBase, + id: "a", + inputs: [ + { + name: "Input 0", + pointer: null, + type: ValueType.Any, + value: null, + }, + { + name: "Input 1", + pointer: { nodeId: "b", outputIndex: 1 }, + type: ValueType.Any, + value: null, + }, + ], + }, + b: { + ..._nodeBase, + id: "b", + outputs: [ + { + type: ValueType.Any, + name: "Output 0", + }, + ], + }, + }, + }; + + const output: ReturnType = { + ..._graphBase, + nodes: { + a: { + ..._nodeBase, + id: "a", + inputs: [ + { + name: "Input 0", + pointer: null, + type: ValueType.Any, + value: null, + }, + { + name: "Input 1", + pointer: null, + type: ValueType.Any, + value: null, + }, + ], + }, + }, + }; + + expect(removeNodeAndReferencesToItInGraph("b", graph)).toEqual(output); + }); +}); diff --git a/__tests__/util/mapUtils.spec.ts b/__tests__/util/mapUtils.spec.ts new file mode 100644 index 0000000..952e869 --- /dev/null +++ b/__tests__/util/mapUtils.spec.ts @@ -0,0 +1,139 @@ +import { removeKeysFromMap, addListToMap, modifyItemInMap, reduceMap } from "~/util/mapUtils"; + +describe("removeKeysFromMap", () => { + test("it removes a single key", () => { + const input: { [key: string]: number } = { + a: 1, + b: 2, + c: 3, + }; + const output = { + a: 1, + c: 3, + }; + expect(removeKeysFromMap(input, ["b"])).toEqual(output); + }); + + test("it removes multiple keys", () => { + const input: { [key: string]: number } = { + a: 1, + b: 2, + c: 3, + }; + const output = { + a: 1, + }; + expect(removeKeysFromMap(input, ["b", "c"])).toEqual(output); + }); + + test("it does not modify the original object", () => { + const input: { [key: string]: number } = { + a: 1, + b: 2, + c: 3, + }; + const copyOfInput = { ...input }; + const output = { + a: 1, + c: 3, + }; + expect(removeKeysFromMap(input, ["b"])).toEqual(output); + expect(input).toEqual(copyOfInput); + }); +}); + +describe("addListToMap", () => { + test("it adds a list of items to a map", () => { + { + const input: { [key: string]: { id: string; value: number } } = { + a: { id: "a", value: 1 }, + }; + const items = [{ id: "b", value: 2 }]; + const output: { [key: string]: { id: string; value: number } } = { + a: { id: "a", value: 1 }, + b: { id: "b", value: 2 }, + }; + expect(addListToMap(input, items, "id")).toEqual(output); + } + + { + // With a different id field + const input: { [key: string]: { key: string; value: number } } = { + a: { key: "a", value: 1 }, + }; + const items = [{ key: "b", value: 2 }]; + const output: { [key: string]: { key: string; value: number } } = { + a: { key: "a", value: 1 }, + b: { key: "b", value: 2 }, + }; + expect(addListToMap(input, items, "key")).toEqual(output); + } + }); + + test("it overrides existing items in the map", () => { + const input: { [key: string]: { id: string; value: number } } = { + a: { id: "a", value: 1 }, + b: { id: "b", value: 3 }, + }; + const items = [{ id: "b", value: 2 }]; + const output: { [key: string]: { id: string; value: number } } = { + a: { id: "a", value: 1 }, + b: { id: "b", value: 2 }, + }; + expect(addListToMap(input, items, "id")).toEqual(output); + }); +}); + +describe("modifyItemInMap", () => { + test("it modifies a single item by id in the map", () => { + { + const input: { [key: string]: number } = { + a: 1, + b: 2, + }; + const output = { + a: 1, + b: 4, + }; + expect(modifyItemInMap(input, "b", (value) => value * 2)).toEqual(output); + } + + { + const input: { [key: string]: { id: string; value: number } } = { + a: { id: "a", value: 1 }, + b: { id: "b", value: 2 }, + }; + const output: { [key: string]: { id: string; value: number } } = { + a: { id: "a", value: 1 }, + b: { id: "b", value: 4 }, + }; + expect( + modifyItemInMap(input, "b", (item) => ({ + ...item, + value: item.value * 2, + })), + ).toEqual(output); + } + }); + + it("throws an error if the provided key does not exist in map", () => { + const input: { [key: string]: number } = { + a: 5, + }; + expect(() => modifyItemInMap(input, "b", (value) => value * 2)).toThrow(); + }); +}); + +describe("reduceMap", () => { + test("it correctly reduces a map", () => { + const input: { [key: string]: number } = { + a: 1, + b: 2, + }; + const output: { [key: string]: number } = { + a: 2, + b: 4, + }; + expect(reduceMap(input, (value) => value * 2)).toEqual(output); + }); +}); diff --git a/src/util/mapUtils.ts b/src/util/mapUtils.ts index 692c83c..f9abd68 100644 --- a/src/util/mapUtils.ts +++ b/src/util/mapUtils.ts @@ -24,12 +24,16 @@ export const addListToMap = export const modifyItemInMap = ( map: M, - id: string, + key: string, fn: (item: T) => T, ): M => { + if (!map.hasOwnProperty(key)) { + throw new Error(`Key '${key}' does not exist in map.`); + } + return { ...map, - [id]: fn(map[id]), + [key]: fn(map[key]), }; }; @@ -39,12 +43,16 @@ export const modifyItemInUnionMap = < U extends T = T >( map: M, - id: string, + key: string, fn: (item: U) => U, ): M => { + if (!map.hasOwnProperty(key)) { + throw new Error(`Key '${key}' does not exist in map.`); + } + return { ...map, - [id]: fn(map[id] as U), + [key]: fn(map[key] as U), }; }; From 6e9a59d4da492b401071ad82f612969235f55a41 Mon Sep 17 00:00:00 2001 From: alexharri Date: Sun, 28 Jun 2020 19:12:32 +0000 Subject: [PATCH 3/7] remove log statements --- src/nodeEditor/nodeEditorReducers.ts | 1 - src/nodeEditor/nodes/expression/expressionNodeHandlers.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/nodeEditor/nodeEditorReducers.ts b/src/nodeEditor/nodeEditorReducers.ts index 02db5ff..e7d28b3 100644 --- a/src/nodeEditor/nodeEditorReducers.ts +++ b/src/nodeEditor/nodeEditorReducers.ts @@ -452,7 +452,6 @@ function graphReducer(state: NodeEditorGraphState, action: NodeEditorAction): No case getType(actions.addNodeOutput): { const { nodeId, output } = action.payload; - console.log({ output }); const node = state.nodes[nodeId]; return { ...state, diff --git a/src/nodeEditor/nodes/expression/expressionNodeHandlers.ts b/src/nodeEditor/nodes/expression/expressionNodeHandlers.ts index 6ee9375..c8cd575 100644 --- a/src/nodeEditor/nodes/expression/expressionNodeHandlers.ts +++ b/src/nodeEditor/nodes/expression/expressionNodeHandlers.ts @@ -22,7 +22,6 @@ export const expressionNodeHandlers = { const graph = getActionState().nodeEditor.graphs[graphId]; const toUpdate = getExpressionUpdateIO(expression, graph, nodeId); - console.log(toUpdate); params.dispatch( nodeEditorActions.removeNodeInputs(graphId, nodeId, toUpdate.inputIndicesToRemove), From 04ed0063786fd403c5fec1346dd5731c580a6a09 Mon Sep 17 00:00:00 2001 From: alexharri Date: Tue, 7 Jul 2020 15:42:33 +0000 Subject: [PATCH 4/7] dispatch multiple actions at once --- .../timeline/CompositionTimeline.tsx | 19 +++++----- .../timeline/CompositionTimelineLayer.tsx | 11 ++++-- .../timeline/CompositionTimelineProperty.tsx | 7 +++- .../timeline/compositionTimelineHandlers.ts | 20 ++++++---- .../util/compositionPropertyUtils.ts | 13 +++++++ .../workspace/CompositionWorkspaceLayer.tsx | 24 +++++++----- src/listener/requestAction.ts | 21 ++++++++++- src/nodeEditor/graph/computeNode.ts | 5 +++ src/nodeEditor/graph/createLayerGraph.ts | 2 +- src/nodeEditor/util/nodeEditorContextMenu.ts | 2 +- src/state/history/actionBasedReducer.ts | 33 +++++++++++++++++ src/state/history/historyActions.ts | 10 ++++- src/state/history/historyReducer.ts | 37 +++++++++++++++++++ src/types.ts | 1 + 14 files changed, 168 insertions(+), 37 deletions(-) diff --git a/src/composition/timeline/CompositionTimeline.tsx b/src/composition/timeline/CompositionTimeline.tsx index d904116..0205a7c 100644 --- a/src/composition/timeline/CompositionTimeline.tsx +++ b/src/composition/timeline/CompositionTimeline.tsx @@ -20,7 +20,10 @@ import { TimelineEditor } from "~/timeline/TimelineEditor"; import { createToTimelineViewportX } from "~/timeline/renderTimeline"; import { CompositionState } from "~/composition/state/compositionReducer"; import { CompositionSelectionState } from "~/composition/state/compositionSelectionReducer"; -import { getLayerCompositionProperties } from "~/composition/util/compositionPropertyUtils"; +import { + getLayerCompositionProperties, + getLayerTransformProperties, +} from "~/composition/util/compositionPropertyUtils"; const s = compileStylesheetLabelled(styles); @@ -84,18 +87,14 @@ const CompositionTimelineComponent: React.FC = (props) => { const properties: CompositionProperty[] = []; for (let i = 0; i < layers.length; i += 1) { - const propertyIds = layers[i].properties; - for (let j = 0; j < propertyIds.length; j += 1) { - if (!props.selection.properties[propertyIds[j]]) { - continue; - } - properties.push( - ...getLayerCompositionProperties(layers[i].id, props.compositionState), - ); - } + properties.push(...getLayerCompositionProperties(layers[i].id, props.compositionState)); } for (let i = 0; i < properties.length; i += 1) { + if (!props.selection.properties[properties[i].id]) { + continue; + } + if (properties[i].timelineId) { timelineIds.push(properties[i].timelineId); colors[properties[i].timelineId] = properties[i].color; diff --git a/src/composition/timeline/CompositionTimelineLayer.tsx b/src/composition/timeline/CompositionTimelineLayer.tsx index 8035987..0786bd2 100644 --- a/src/composition/timeline/CompositionTimelineLayer.tsx +++ b/src/composition/timeline/CompositionTimelineLayer.tsx @@ -31,15 +31,16 @@ type Props = OwnProps & StateProps; const CompositionTimelineLayerComponent: React.FC = (props) => { const { layer, graph } = props; - const properties = useComputeHistory((state) => { - return getLayerTransformProperties(layer.id, state.compositions); - }); + const getProperties = (state: ActionState) => + getLayerTransformProperties(layer.id, state.compositions); - const { computePropertyValues } = useComputeHistory(() => { + const { computePropertyValues } = useComputeHistory((state) => { + const properties = getProperties(state); return { computePropertyValues: computeLayerGraph(properties, graph) }; }); const propertyToValue = useActionState((actionState) => { + const properties = getProperties(actionState); const context: ComputeNodeContext = { computed: {}, composition: actionState.compositions.compositions[layer.compositionId], @@ -52,6 +53,8 @@ const CompositionTimelineLayerComponent: React.FC = (props) => { return computePropertyValues(context); }); + const properties = useComputeHistory(getProperties); + return ( <>
= (props) => { onChange={onValueChange} onChangeEnd={onValueChangeEnd} value={value} - tick={property.name === PropertyName.Scale ? 0.01 : 1} + tick={ + property.name === PropertyName.Scale || + property.name === PropertyName.Opacity + ? 0.01 + : 1 + } />
diff --git a/src/composition/timeline/compositionTimelineHandlers.ts b/src/composition/timeline/compositionTimelineHandlers.ts index e9784f1..bacc4e3 100644 --- a/src/composition/timeline/compositionTimelineHandlers.ts +++ b/src/composition/timeline/compositionTimelineHandlers.ts @@ -197,9 +197,11 @@ export const compositionTimelineHandlers = { ); requestAction({ history: true }, ({ dispatch, submitAction }) => { - dispatch(timelineActions.removeTimeline(timelineId)); - dispatch(compositionActions.setPropertyValue(propertyId, value)); - dispatch(compositionActions.setPropertyTimelineId(propertyId, "")); + dispatch( + timelineActions.removeTimeline(timelineId), + compositionActions.setPropertyValue(propertyId, value), + compositionActions.setPropertyTimelineId(propertyId, ""), + ); submitAction("Remove timeline from property"); }); return; @@ -208,8 +210,10 @@ export const compositionTimelineHandlers = { // Create timeline with a single keyframe at the current time requestAction({ history: true }, ({ dispatch, submitAction }) => { const timeline = createTimelineForLayerProperty(property.value, composition.frameIndex); - dispatch(timelineActions.setTimeline(timeline.id, timeline)); - dispatch(compositionActions.setPropertyTimelineId(propertyId, timeline.id)); + dispatch( + timelineActions.setTimeline(timeline.id, timeline), + compositionActions.setPropertyTimelineId(propertyId, timeline.id), + ); submitAction("Add timeline to property"); }); }, @@ -295,8 +299,10 @@ export const compositionTimelineHandlers = { params.dispatch(timelineActions.removeTimeline(id)), ); - params.dispatch(compositionActions.removeLayer(layer.id)); - params.dispatch(contextMenuActions.closeContextMenu()); + params.dispatch( + compositionActions.removeLayer(layer.id), + contextMenuActions.closeContextMenu(), + ); params.submitAction("Delete layer"); }; diff --git a/src/composition/util/compositionPropertyUtils.ts b/src/composition/util/compositionPropertyUtils.ts index 358c311..9e2a937 100644 --- a/src/composition/util/compositionPropertyUtils.ts +++ b/src/composition/util/compositionPropertyUtils.ts @@ -83,6 +83,19 @@ const createDefaultTransformProperties = (opts: Options): CompositionProperty[] value: 0, color: TimelineColors.YPosition, }, + { + type: "property", + id: createId(), + layerId, + compositionId, + name: PropertyName.Opacity, + timelineId: "", + valueType: ValueType.Number, + value: 1, + color: TimelineColors.YPosition, + max: 1, + min: 0, + }, ]; }; diff --git a/src/composition/workspace/CompositionWorkspaceLayer.tsx b/src/composition/workspace/CompositionWorkspaceLayer.tsx index 2b82fee..276673c 100644 --- a/src/composition/workspace/CompositionWorkspaceLayer.tsx +++ b/src/composition/workspace/CompositionWorkspaceLayer.tsx @@ -36,19 +36,18 @@ type Props = OwnProps & StateProps; const CompositionWorkspaceLayerComponent: React.FC = (props) => { const { layer, graph } = props; - const properties = useActionState((state) => { - return getLayerCompositionProperties(layer.id, state.compositions); - }); + const getTransformProperties = (state: ActionState) => + getLayerTransformProperties(layer.id, state.compositions); - const transformProperties = useActionState((state) => { - return getLayerTransformProperties(layer.id, state.compositions); - }); + const { computePropertyValues } = useComputeHistory((state) => { + const transformProperties = getTransformProperties(state); - const { computePropertyValues } = useComputeHistory(() => { return { computePropertyValues: computeLayerGraph(transformProperties, graph) }; }); const propertyToValue = useActionState((actionState) => { + const transformProperties = getTransformProperties(actionState); + const context: ComputeNodeContext = { computed: {}, composition: actionState.compositions.compositions[layer.compositionId], @@ -62,13 +61,17 @@ const CompositionWorkspaceLayerComponent: React.FC = (props) => { return computePropertyValues(context, mostRecentGraph); }); + const properties = useActionState((state) => { + return getLayerCompositionProperties(layer.id, state.compositions); + }); + const nameToProperty = properties.reduce((obj, p) => { const value = propertyToValue[p.id] ?? p.value; (obj as any)[PropertyName[p.name]] = value; return obj; }, {} as { [key in keyof typeof PropertyName]: number }); - const { Width, Height, PositionX, PositionY, Scale, Rotation } = nameToProperty; + const { Width, Height, PositionX, PositionY, Scale, Opacity, Rotation } = nameToProperty; return (
= (props) => { style={{ width: Width, height: Height, - left: PositionX, - top: PositionY, + left: 0, + top: 0, + opacity: Opacity, border: props.isSelected ? "1px solid cyan" : undefined, transform: `translateX(${PositionX}px) translateY(${PositionY}px) scale(${Scale}) rotate(${Rotation}deg)`, }} diff --git a/src/listener/requestAction.ts b/src/listener/requestAction.ts index 8665e71..b9e8214 100644 --- a/src/listener/requestAction.ts +++ b/src/listener/requestAction.ts @@ -15,7 +15,8 @@ interface Options { } export interface RequestActionParams { - dispatch: (action: any) => void; + dispatch: (action: any | any[], ...otherActions: any[]) => void; + dispatchBatch: (action: any[]) => void; cancelAction: () => void; submitAction: (name?: string) => void; addListener: typeof _addListener; @@ -66,10 +67,26 @@ export const requestAction = ( store.dispatch(historyActions.startAction(actionId)); callback({ - dispatch: (action) => { + dispatch: (action, ...args) => { + if (Array.isArray(action)) { + store.dispatch(historyActions.dispatchBatchToAction(actionId, action, history)); + return; + } + + if (args.length) { + store.dispatch( + historyActions.dispatchBatchToAction(actionId, [action, ...args], history), + ); + return; + } + store.dispatch(historyActions.dispatchToAction(actionId, action, history)); }, + dispatchBatch: (actionBatch) => { + store.dispatch(historyActions.dispatchBatchToAction(actionId, actionBatch, history)); + }, + submitAction: (name = "Unknown action") => { if ( typeof shouldAddToStack === "function" diff --git a/src/nodeEditor/graph/computeNode.ts b/src/nodeEditor/graph/computeNode.ts index f1ee267..92c8531 100644 --- a/src/nodeEditor/graph/computeNode.ts +++ b/src/nodeEditor/graph/computeNode.ts @@ -295,6 +295,11 @@ export const computeNodeOutputArgs = ( if (node.type === Type.layer_transform_output) { const p = ctx.properties[i]; + + if (p.timelineId && !ctx.timelines[p.timelineId]) { + // console.log(ctx, p); + } + defaultValue = { type, value: p.timelineId diff --git a/src/nodeEditor/graph/createLayerGraph.ts b/src/nodeEditor/graph/createLayerGraph.ts index 5857bcc..3b29aea 100644 --- a/src/nodeEditor/graph/createLayerGraph.ts +++ b/src/nodeEditor/graph/createLayerGraph.ts @@ -18,7 +18,7 @@ export const createLayerGraph = ( id: nodeId, position: Vec2.new(0, 0), state: getNodeEditorNodeDefaultState(NodeEditorNodeType.layer_transform_input), - type: NodeEditorNodeType.layer_transform_input, + type: NodeEditorNodeType.layer_transform_output, width: DEFAULT_NODE_EDITOR_NODE_WIDTH, outputs: [], inputs: transformProperties.map((property) => ({ diff --git a/src/nodeEditor/util/nodeEditorContextMenu.ts b/src/nodeEditor/util/nodeEditorContextMenu.ts index 21b3283..9618aeb 100644 --- a/src/nodeEditor/util/nodeEditorContextMenu.ts +++ b/src/nodeEditor/util/nodeEditorContextMenu.ts @@ -98,7 +98,7 @@ export const getNodeEditorContextMenuOptions = (options: Options) => { name: getLayerPropertyLabel(property.name), pointer: null, type: property.valueType, - value: null, + value: property.value, })), outputs: [], }; diff --git a/src/state/history/actionBasedReducer.ts b/src/state/history/actionBasedReducer.ts index 6e981c8..76540b1 100644 --- a/src/state/history/actionBasedReducer.ts +++ b/src/state/history/actionBasedReducer.ts @@ -39,6 +39,39 @@ export function createActionBasedReducer( }; } + case "history/DISPATCH_BATCH_TO_ACTION": { + const { actionId, actionBatch } = action.payload; + + if (!state.action) { + console.warn("Attempted to dispatch to an action that does not exist."); + return state; + } + + if (state.action.id !== actionId) { + console.warn("Attempted to dispatch with the wrong action id."); + return state; + } + + let newState = state.action.state; + + for (let i = 0; i < actionBatch.length; i += 1) { + newState = reducer(newState, actionBatch[i]); + } + + if (newState === state.action.state) { + // State was not modified + return state; + } + + return { + ...state, + action: { + ...state.action, + state: newState, + }, + }; + } + case "history/DISPATCH_TO_ACTION": { const { actionId, actionToDispatch } = action.payload; diff --git a/src/state/history/historyActions.ts b/src/state/history/historyActions.ts index a530a2c..de56578 100644 --- a/src/state/history/historyActions.ts +++ b/src/state/history/historyActions.ts @@ -7,7 +7,7 @@ export const historyActions = { return action("history/START_ACTION", { actionId }); }, - dispatchToAction: (actionId: string, actionToDispatch: number, modifiesHistory: boolean) => { + dispatchToAction: (actionId: string, actionToDispatch: any, modifiesHistory: boolean) => { return action("history/DISPATCH_TO_ACTION", { actionId, actionToDispatch, @@ -15,6 +15,14 @@ export const historyActions = { }); }, + dispatchBatchToAction: (actionId: string, actionBatch: any[], modifiesHistory: boolean) => { + return action("history/DISPATCH_BATCH_TO_ACTION", { + actionId, + actionBatch, + modifiesHistory, + }); + }, + submitAction: ( actionId: string, name: string, diff --git a/src/state/history/historyReducer.ts b/src/state/history/historyReducer.ts index f62c961..09a3a55 100644 --- a/src/state/history/historyReducer.ts +++ b/src/state/history/historyReducer.ts @@ -71,6 +71,43 @@ export function createReducerWithHistory( }; } + case "history/DISPATCH_BATCH_TO_ACTION": { + const { actionId, actionBatch, modifiesHistory } = action.payload; + + if (!modifiesHistory) { + return state; + } + + if (!state.action) { + console.warn("Attempted to dispatch to an action that does not exist."); + return state; + } + + if (state.action.id !== actionId) { + console.warn("Attempted to dispatch with the wrong action id."); + return state; + } + + let newState = state.action.state; + + for (let i = 0; i < actionBatch.length; i += 1) { + newState = reducer(newState, actionBatch[i]); + } + + if (newState === state.action.state) { + // State was not modified + return state; + } + + return { + ...state, + action: { + ...state.action, + state: newState, + }, + }; + } + case "history/DISPATCH_TO_ACTION": { const { actionId, actionToDispatch, modifiesHistory } = action.payload; diff --git a/src/types.ts b/src/types.ts index 82e69a0..588de59 100644 --- a/src/types.ts +++ b/src/types.ts @@ -50,6 +50,7 @@ export enum PropertyName { Width, Height, } +console.log({ PropertyName }); export type Json = string | number | boolean | null | JsonObject | JsonArray | undefined; export interface JsonArray extends Array {} From 9cb18d2b6487d3196a95dca9da548834bec56030 Mon Sep 17 00:00:00 2001 From: alexharri Date: Wed, 8 Jul 2020 13:19:51 +0000 Subject: [PATCH 5/7] work on property input and output nodes --- src/App.tsx | 2 + .../hook/usePropertyNumberInput.tsx | 109 ++++++++ .../timeline/CompositionTimeline.tsx | 5 +- .../timeline/CompositionTimelineLayer.tsx | 15 +- .../CompositionTimelineProperty.styles.ts | 41 ++- .../timeline/CompositionTimelineProperty.tsx | 164 ++++-------- src/constants.ts | 3 +- src/contextMenu/ContextMenu.styles.ts | 10 +- src/contextMenu/CustomContextMenu.tsx | 84 ++++++ src/contextMenu/contextMenuActions.ts | 9 +- src/contextMenu/contextMenuReducer.ts | 12 + src/contextMenu/contextMenuTypes.ts | 12 + src/hook/useMouseEventOutside.ts | 19 ++ src/hook/useRefRect.ts | 17 +- src/nodeEditor/NodeEditor.tsx | 11 + src/nodeEditor/graph/computeNode.ts | 18 +- src/nodeEditor/nodeEditorActions.ts | 11 + src/nodeEditor/nodeEditorIO.ts | 14 + src/nodeEditor/nodeEditorReducers.ts | 50 +++- src/nodeEditor/nodeEditorUtils.ts | 18 +- src/nodeEditor/nodes/nodeHandlers.ts | 6 +- .../nodes/property/PropertyInputNode.tsx | 125 +++++++++ .../property/PropertyNodeSelectProperty.tsx | 246 ++++++++++++++++++ .../nodes/property/PropertyOutputNode.tsx | 138 ++++++++++ src/nodeEditor/util/calculateNodeHeight.ts | 26 +- src/nodeEditor/util/nodeEditorContextMenu.ts | 15 +- src/types.ts | 4 +- 27 files changed, 1040 insertions(+), 144 deletions(-) create mode 100644 src/composition/hook/usePropertyNumberInput.tsx create mode 100644 src/contextMenu/CustomContextMenu.tsx create mode 100644 src/contextMenu/contextMenuTypes.ts create mode 100644 src/hook/useMouseEventOutside.ts create mode 100644 src/nodeEditor/nodes/property/PropertyInputNode.tsx create mode 100644 src/nodeEditor/nodes/property/PropertyNodeSelectProperty.tsx create mode 100644 src/nodeEditor/nodes/property/PropertyOutputNode.tsx diff --git a/src/App.tsx b/src/App.tsx index e0a47c6..11dd1f5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { AreaRoot } from "~/area/components/AreaRoot"; import { ContextMenu } from "~/contextMenu/ContextMenu"; import { addListener, removeListener } from "~/listener/addListener"; import { isKeyCodeOf } from "~/listener/keyboard"; +import { CustomContextMenu } from "~/contextMenu/CustomContextMenu"; export const AppComponent: React.FC = () => { useEffect(() => { @@ -27,6 +28,7 @@ export const AppComponent: React.FC = () => { <> + diff --git a/src/composition/hook/usePropertyNumberInput.tsx b/src/composition/hook/usePropertyNumberInput.tsx new file mode 100644 index 0000000..50d9eb1 --- /dev/null +++ b/src/composition/hook/usePropertyNumberInput.tsx @@ -0,0 +1,109 @@ +import { splitKeyframesAtIndex, createTimelineKeyframe } from "~/timeline/timelineUtils"; +import { timelineActions } from "~/timeline/timelineActions"; +import { useRef } from "react"; +import { RequestActionParams, requestAction } from "~/listener/requestAction"; +import { TimelineKeyframe, Timeline } from "~/timeline/timelineTypes"; +import { Composition, CompositionProperty } from "~/composition/compositionTypes"; +import { compositionActions } from "~/composition/state/compositionReducer"; + +export const usePropertyNumberInput = ( + timeline: Timeline | undefined, + property: CompositionProperty, + composition: Composition, +) => { + const paramsRef = useRef(null); + const onValueChangeFn = useRef<((value: number) => void) | null>(null); + const onValueChangeEndFn = useRef<(() => void) | null>(null); + + const onValueChange = (value: number): void => { + if (onValueChangeFn.current) { + onValueChangeFn.current(value); + return; + } + + requestAction({ history: true }, (params) => { + paramsRef.current = params; + + let keyframe: TimelineKeyframe | null = null; + + if (timeline) { + for (let i = 0; i < timeline.keyframes.length; i += 1) { + if (timeline.keyframes[i].index === composition.frameIndex) { + keyframe = timeline.keyframes[i]; + } + } + } + + onValueChangeFn.current = (value) => { + if (!timeline) { + params.dispatch(compositionActions.setPropertyValue(property.id, value)); + return; + } + + if (keyframe) { + params.dispatch( + timelineActions.setKeyframe(timeline.id, { + ...keyframe, + value, + }), + ); + return; + } + + const index = composition.frameIndex; + const keyframes = timeline.keyframes; + + if (index < keyframes[0].index) { + const k = createTimelineKeyframe(value, index); + params.dispatch(timelineActions.setKeyframe(timeline.id, k)); + return; + } + + if (index > keyframes[keyframes.length - 1].index) { + const k = createTimelineKeyframe(value, index); + params.dispatch(timelineActions.setKeyframe(timeline.id, k)); + return; + } + + for (let i = 0; i < keyframes.length; i += 1) { + if (keyframes[i].index > index) { + continue; + } + + if (keyframes[i].index === index) { + return keyframes[i].value; + } + + if (index > keyframes[i + 1].index) { + continue; + } + + const [k0, k, k1] = splitKeyframesAtIndex( + keyframes[i], + keyframes[i + 1], + index, + ); + keyframe = k; + params.dispatch(timelineActions.setKeyframe(timeline.id, k0)); + params.dispatch(timelineActions.setKeyframe(timeline.id, k)); + params.dispatch(timelineActions.setKeyframe(timeline.id, k1)); + } + }; + onValueChangeFn.current(value); + + onValueChangeEndFn.current = () => { + paramsRef.current?.submitAction("Update value"); + }; + }); + }; + + const onValueChangeEnd = (_type: "relative" | "absolute") => { + onValueChangeEndFn.current?.(); + + paramsRef.current = null; + onValueChangeFn.current = null; + onValueChangeEndFn.current = null; + }; + + return [onValueChange, onValueChangeEnd] as const; +}; diff --git a/src/composition/timeline/CompositionTimeline.tsx b/src/composition/timeline/CompositionTimeline.tsx index 0205a7c..85bc43b 100644 --- a/src/composition/timeline/CompositionTimeline.tsx +++ b/src/composition/timeline/CompositionTimeline.tsx @@ -20,10 +20,7 @@ import { TimelineEditor } from "~/timeline/TimelineEditor"; import { createToTimelineViewportX } from "~/timeline/renderTimeline"; import { CompositionState } from "~/composition/state/compositionReducer"; import { CompositionSelectionState } from "~/composition/state/compositionSelectionReducer"; -import { - getLayerCompositionProperties, - getLayerTransformProperties, -} from "~/composition/util/compositionPropertyUtils"; +import { getLayerCompositionProperties } from "~/composition/util/compositionPropertyUtils"; const s = compileStylesheetLabelled(styles); diff --git a/src/composition/timeline/CompositionTimelineLayer.tsx b/src/composition/timeline/CompositionTimelineLayer.tsx index 0786bd2..3656397 100644 --- a/src/composition/timeline/CompositionTimelineLayer.tsx +++ b/src/composition/timeline/CompositionTimelineLayer.tsx @@ -13,7 +13,7 @@ import { useComputeHistory } from "~/hook/useComputeHistory"; import { useActionState } from "~/hook/useActionState"; import { ComputeNodeContext } from "~/nodeEditor/graph/computeNode"; import { OpenInAreaIcon } from "~/components/icons/OpenInAreaIcon"; -import { getLayerTransformProperties } from "~/composition/util/compositionPropertyUtils"; +import { getLayerCompositionProperties } from "~/composition/util/compositionPropertyUtils"; const s = compileStylesheetLabelled(styles); @@ -32,7 +32,7 @@ const CompositionTimelineLayerComponent: React.FC = (props) => { const { layer, graph } = props; const getProperties = (state: ActionState) => - getLayerTransformProperties(layer.id, state.compositions); + getLayerCompositionProperties(layer.id, state.compositions); const { computePropertyValues } = useComputeHistory((state) => { const properties = getProperties(state); @@ -53,8 +53,6 @@ const CompositionTimelineLayerComponent: React.FC = (props) => { return computePropertyValues(context); }); - const properties = useComputeHistory(getProperties); - return ( <>
= (props) => {
)}
- {properties.map((property, i) => { + {layer.properties.map((id) => { return ( ); })} diff --git a/src/composition/timeline/CompositionTimelineProperty.styles.ts b/src/composition/timeline/CompositionTimelineProperty.styles.ts index a241033..8d2dffa 100644 --- a/src/composition/timeline/CompositionTimelineProperty.styles.ts +++ b/src/composition/timeline/CompositionTimelineProperty.styles.ts @@ -3,20 +3,53 @@ import { cssVariables } from "~/cssVariables"; export default ({ css }: StyleParams) => ({ container: css` - display: flex; height: 17px; padding: 0 24px 0 0; - margin-left: 24px; + margin-left: 0; border-bottom: 1px solid rgba(0, 0, 0, 0.1); background: ${cssVariables.dark700}; - box-sizing: border-box; - align-items: stretch; &:last-of-type { border-bottom: none; } `, + contentContainer: css` + height: 16px; + display: flex; + align-items: stretch; + `, + + collapsedArrow: css` + width: 16px; + height: 16px; + position: relative; + + &--open { + transform: translate(0, 0px) rotate(90deg); + } + + &:before, + &:after { + content: ""; + position: absolute; + top: 7px; + left: 4px; + right: 6px; + height: 1px; + transform-origin: 100% 50%; + background: ${cssVariables.light300}; + } + + &:before { + transform: rotate(45deg); + } + + &:after { + transform: rotate(-45deg); + } + `, + name: css` width: 80px; font-size: 11px; diff --git a/src/composition/timeline/CompositionTimelineProperty.tsx b/src/composition/timeline/CompositionTimelineProperty.tsx index c551a94..4a1dc5c 100644 --- a/src/composition/timeline/CompositionTimelineProperty.tsx +++ b/src/composition/timeline/CompositionTimelineProperty.tsx @@ -1,29 +1,36 @@ -import React, { useRef } from "react"; +import React, { useState } from "react"; import { StopwatchIcon } from "~/components/icons/StopwatchIcon"; import { compileStylesheetLabelled } from "~/util/stylesheets"; -import { CompositionProperty, Composition } from "~/composition/compositionTypes"; +import { + CompositionProperty, + Composition, + CompositionPropertyGroup, +} from "~/composition/compositionTypes"; import { connectActionState } from "~/state/stateUtils"; -import { requestAction, RequestActionParams } from "~/listener/requestAction"; -import { compositionActions } from "~/composition/state/compositionReducer"; import { separateLeftRightMouse } from "~/util/mouse"; import { NumberInput } from "~/components/common/NumberInput"; -import { Timeline, TimelineKeyframe } from "~/timeline/timelineTypes"; -import { splitKeyframesAtIndex, createTimelineKeyframe } from "~/timeline/timelineUtils"; -import { timelineActions } from "~/timeline/timelineActions"; +import { Timeline } from "~/timeline/timelineTypes"; import styles from "~/composition/timeline/CompositionTimelineProperty.styles"; import { compositionTimelineHandlers } from "~/composition/timeline/compositionTimelineHandlers"; -import { getLayerPropertyLabel } from "~/composition/util/compositionPropertyUtils"; +import { + getLayerPropertyLabel, + getLayerPropertyGroupLabel, +} from "~/composition/util/compositionPropertyUtils"; import { PropertyName } from "~/types"; +import { usePropertyNumberInput } from "~/composition/hook/usePropertyNumberInput"; const s = compileStylesheetLabelled(styles); interface OwnProps { compositionId: string; id: string; - value: number; + propertyToValue: { + [propertyId: string]: number; + }; + depth: number; } interface StateProps { - property: CompositionProperty; + property: CompositionProperty | CompositionPropertyGroup; isSelected: boolean; composition: Composition; timeline?: Timeline; @@ -33,105 +40,46 @@ type Props = OwnProps & StateProps; const CompositionTimelineLayerPropertyComponent: React.FC = (props) => { const { property, composition, timeline } = props; - const paramsRef = useRef(null); - const onValueChangeFn = useRef<((value: number) => void) | null>(null); - const onValueChangeEndFn = useRef<(() => void) | null>(null); - - const onValueChange = (value: number) => { - if (onValueChangeFn.current) { - onValueChangeFn.current(value); - return; - } - - requestAction({ history: true }, (params) => { - paramsRef.current = params; - - let keyframe: TimelineKeyframe | null = null; - - if (timeline) { - for (let i = 0; i < timeline.keyframes.length; i += 1) { - if (timeline.keyframes[i].index === composition.frameIndex) { - keyframe = timeline.keyframes[i]; - } - } - } - - onValueChangeFn.current = (value) => { - if (!timeline) { - params.dispatch(compositionActions.setPropertyValue(property.id, value)); - return; - } - - if (keyframe) { - params.dispatch( - timelineActions.setKeyframe(timeline.id, { - ...keyframe, - value, - }), - ); - return; - } - - const index = composition.frameIndex; - const keyframes = timeline.keyframes; - - if (index < keyframes[0].index) { - const k = createTimelineKeyframe(value, index); - params.dispatch(timelineActions.setKeyframe(timeline.id, k)); - return; - } - - if (index > keyframes[keyframes.length - 1].index) { - const k = createTimelineKeyframe(value, index); - params.dispatch(timelineActions.setKeyframe(timeline.id, k)); - return; - } - - for (let i = 0; i < keyframes.length; i += 1) { - if (keyframes[i].index > index) { - continue; - } - - if (keyframes[i].index === index) { - return keyframes[i].value; - } - - if (index > keyframes[i + 1].index) { - continue; - } - - const [k0, k, k1] = splitKeyframesAtIndex( - keyframes[i], - keyframes[i + 1], - index, - ); - keyframe = k; - params.dispatch(timelineActions.setKeyframe(timeline.id, k0)); - params.dispatch(timelineActions.setKeyframe(timeline.id, k)); - params.dispatch(timelineActions.setKeyframe(timeline.id, k1)); - } - }; - onValueChangeFn.current(value); - - onValueChangeEndFn.current = () => { - paramsRef.current?.submitAction("Update value"); - }; - }); - }; - - const onValueChangeEnd = () => { - onValueChangeEndFn.current?.(); - - paramsRef.current = null; - onValueChangeFn.current = null; - onValueChangeEndFn.current = null; - }; - - const value = props.value; + const value = props.propertyToValue[props.id]; + + const [open, setOpen] = useState(true); + + if (property.type === "group") { + const { properties } = property; + return ( + <> +
setOpen(!open)}> +
+
+
{getLayerPropertyGroupLabel(property.name)}
+
+
+ {open && + properties.map((id) => ( + + ))} + + ); + } + + const [onValueChange, onValueChangeEnd] = usePropertyNumberInput( + timeline, + property, + composition, + ); return ( - <> -
+
+
= (props) => { />
- +
); }; diff --git a/src/constants.ts b/src/constants.ts index e855c92..131919d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -96,7 +96,8 @@ export const NODE_EDITOR_EXPRESSION_NODE_MIN_TEXTAREA_HEIGHT = 24; export const NODE_EDITOR_NODE_H_PADDING = 12; export const DEFAULT_CONTEXT_MENU_WIDTH = 180; -export const CONTEXT_MENU_OPTION_HEIGHT = 22; +export const CONTEXT_MENU_OPTION_HEIGHT = 20; +export const CONTEXT_MENU_OPTION_PADDING_LEFT = 32; export const DEG_TO_RAD_FAC = 0.0174533; export const RAD_TO_DEG_FAC = 57.2958; diff --git a/src/contextMenu/ContextMenu.styles.ts b/src/contextMenu/ContextMenu.styles.ts index 82b3bae..03e19d4 100644 --- a/src/contextMenu/ContextMenu.styles.ts +++ b/src/contextMenu/ContextMenu.styles.ts @@ -1,6 +1,10 @@ import { StyleParams } from "~/util/stylesheets"; import { cssZIndex, cssVariables } from "~/cssVariables"; -import { DEFAULT_CONTEXT_MENU_WIDTH, CONTEXT_MENU_OPTION_HEIGHT } from "~/constants"; +import { + DEFAULT_CONTEXT_MENU_WIDTH, + CONTEXT_MENU_OPTION_HEIGHT, + CONTEXT_MENU_OPTION_PADDING_LEFT, +} from "~/constants"; export default ({ css }: StyleParams) => ({ background: css` @@ -27,7 +31,7 @@ export default ({ css }: StyleParams) => ({ name: css` color: ${cssVariables.light400}; - padding-left: 32px; + padding-left: ${CONTEXT_MENU_OPTION_PADDING_LEFT}px; line-height: ${CONTEXT_MENU_OPTION_HEIGHT}px; font-size: 12px; font-family: ${cssVariables.fontFamily}; @@ -43,7 +47,7 @@ export default ({ css }: StyleParams) => ({ option: css` padding: 0; - padding-left: 32px; + padding-left: ${CONTEXT_MENU_OPTION_PADDING_LEFT}px; border: none; background: transparent; display: block; diff --git a/src/contextMenu/CustomContextMenu.tsx b/src/contextMenu/CustomContextMenu.tsx new file mode 100644 index 0000000..ee3876a --- /dev/null +++ b/src/contextMenu/CustomContextMenu.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { OpenCustomContextMenuOptions } from "~/contextMenu/contextMenuTypes"; +import { compileStylesheetLabelled } from "~/util/stylesheets"; +import { cssZIndex } from "~/cssVariables"; +import { useState } from "react"; +import { connectActionState } from "~/state/stateUtils"; + +const CLOSE_MENU_BUFFER = 100; + +const s = compileStylesheetLabelled(({ css }) => ({ + background: css` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: ${cssZIndex.contextMenuBackground}; + cursor: default; + `, + + wrapper: css` + position: fixed; + top: 0; + left: 0; + z-index: ${cssZIndex.contextMenu}; + `, +})); + +interface StateProps { + options: OpenCustomContextMenuOptions | null; +} +type Props = StateProps; + +const CustomContextMenuComponent: React.FC = (props) => { + const { options } = props; + + const [rect, setRect] = useState(); + + if (!options) { + return null; + } + + const onMouseMove = (e: React.MouseEvent) => { + const vec = Vec2.fromEvent(e); + const { x, y } = vec; + + if (!rect) { + return; + } + + if ( + x < rect.left - CLOSE_MENU_BUFFER || + x > rect.left + rect.width + CLOSE_MENU_BUFFER || + y < rect.top - CLOSE_MENU_BUFFER || + y > rect.top + rect.height + CLOSE_MENU_BUFFER + ) { + options.close(); + } + }; + + const Component = options.component; + + return ( + <> +
options.close()} + /> +
+ +
+ + ); +}; + +const mapState: MapActionState = ({ contextMenu }) => ({ + options: contextMenu.customContextMenu, +}); + +export const CustomContextMenu = connectActionState(mapState)(CustomContextMenuComponent); diff --git a/src/contextMenu/contextMenuActions.ts b/src/contextMenu/contextMenuActions.ts index 82aa4c4..e1e1c78 100644 --- a/src/contextMenu/contextMenuActions.ts +++ b/src/contextMenu/contextMenuActions.ts @@ -1,13 +1,18 @@ import { createAction } from "typesafe-actions"; import { ContextMenuOption } from "~/contextMenu/contextMenuReducer"; +import { OpenCustomContextMenuOptions } from "~/contextMenu/contextMenuTypes"; export const contextMenuActions = { - openContextMenu: createAction("contextMenu/OPEN", action => { + openContextMenu: createAction("contextMenu/OPEN", (action) => { return (name: string, options: ContextMenuOption[], position: Vec2, close: () => void) => action({ name, options, position, close }); }), - closeContextMenu: createAction("contextMenu/CLOSE", action => { + openCustomContextMenu: createAction("contextMenu/OPEN_CUSTOM", (action) => { + return (options: OpenCustomContextMenuOptions) => action({ options }); + }), + + closeContextMenu: createAction("contextMenu/CLOSE", (action) => { return () => action({}); }), }; diff --git a/src/contextMenu/contextMenuReducer.ts b/src/contextMenu/contextMenuReducer.ts index 26456a9..dde7609 100644 --- a/src/contextMenu/contextMenuReducer.ts +++ b/src/contextMenu/contextMenuReducer.ts @@ -1,5 +1,6 @@ import { getType, ActionType } from "typesafe-actions"; import { contextMenuActions } from "~/contextMenu/contextMenuActions"; +import { OpenCustomContextMenuOptions } from "~/contextMenu/contextMenuTypes"; type ContextMenuAction = ActionType; @@ -25,6 +26,7 @@ export interface ContextMenuState { options: ContextMenuOption[]; position: Vec2; close: (() => void) | null; + customContextMenu: null | OpenCustomContextMenuOptions; } export const initialContextMenuState: ContextMenuState = { @@ -33,6 +35,7 @@ export const initialContextMenuState: ContextMenuState = { options: [], position: Vec2.new(0, 0), close: null, + customContextMenu: null, }; export const contextMenuReducer = ( @@ -52,6 +55,14 @@ export const contextMenuReducer = ( }; } + case getType(contextMenuActions.openCustomContextMenu): { + const { options } = action.payload; + return { + ...state, + customContextMenu: options, + }; + } + case getType(contextMenuActions.closeContextMenu): { return { ...state, @@ -60,6 +71,7 @@ export const contextMenuReducer = ( options: [], position: Vec2.new(0, 0), close: null, + customContextMenu: null, }; } diff --git a/src/contextMenu/contextMenuTypes.ts b/src/contextMenu/contextMenuTypes.ts new file mode 100644 index 0000000..3b17c1a --- /dev/null +++ b/src/contextMenu/contextMenuTypes.ts @@ -0,0 +1,12 @@ +export interface ContextMenuBaseProps { + updateRect: (rect: Rect) => void; +} + +export interface OpenCustomContextMenuOptions< + T extends ContextMenuBaseProps = ContextMenuBaseProps +> { + component: React.ComponentType; + props: Omit; + position: Vec2; + close: () => void; +} diff --git a/src/hook/useMouseEventOutside.ts b/src/hook/useMouseEventOutside.ts new file mode 100644 index 0000000..a596e50 --- /dev/null +++ b/src/hook/useMouseEventOutside.ts @@ -0,0 +1,19 @@ +import { useEffect } from "react"; + +export const useMouseEventOutside = ( + eventType: "mousedown" | "mouseup" | "click", + ref: React.RefObject, + cb: (e: MouseEvent) => void, +) => { + const listener = (e: MouseEvent) => { + if (ref.current?.contains(e.target as HTMLElement)) { + cb(e); + } + }; + + useEffect(() => { + window.addEventListener(eventType, listener); + + return () => window.removeEventListener(eventType, listener); + }); +}; diff --git a/src/hook/useRefRect.ts b/src/hook/useRefRect.ts index f9ee21e..549887f 100644 --- a/src/hook/useRefRect.ts +++ b/src/hook/useRefRect.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useLayoutEffect } from "react"; const getRefRect = (ref: React.RefObject): Rect => { if (!ref.current) { @@ -19,7 +19,7 @@ const getRefRect = (ref: React.RefObject): Rect => { export const useRefRect = (ref: React.RefObject): Rect | null => { const [rect, setRect] = useState(ref.current ? getRefRect(ref) : null); - useEffect(() => { + useLayoutEffect(() => { const onResize = () => { setRect(ref.current ? getRefRect(ref) : null); }; @@ -40,3 +40,16 @@ export const useRefRect = (ref: React.RefObject): Rect return rect; }; + +export const useGetRefRectFn = ( + ref: React.RefObject, +): (() => Rect | null) => { + const getRect = () => { + if (!ref.current) { + return null; + } + return ref.current.getBoundingClientRect(); + }; + + return getRect; +}; diff --git a/src/nodeEditor/NodeEditor.tsx b/src/nodeEditor/NodeEditor.tsx index c4131d6..1961ef9 100644 --- a/src/nodeEditor/NodeEditor.tsx +++ b/src/nodeEditor/NodeEditor.tsx @@ -18,6 +18,7 @@ import { NodeEditorGraphState } from "~/nodeEditor/nodeEditorReducers"; import { NodeEditorNodeType } from "~/types"; import { Vec2LerpNode } from "~/nodeEditor/nodes/Vec2LerpNode"; import { Vec2InputNode } from "~/nodeEditor/nodes/Vec2InputNode"; +import { PropertyInputNode } from "~/nodeEditor/nodes/property/PropertyInputNode"; const s = compileStylesheetLabelled(styles); @@ -146,6 +147,16 @@ const NodeEditorComponent: React.FC = (props) => { break; } + case NodeEditorNodeType.property_input: { + NodeComponent = PropertyInputNode; + break; + } + + case NodeEditorNodeType.property_output: { + NodeComponent = PropertyInputNode; + break; + } + case NodeEditorNodeType.num_input: { NodeComponent = NumberInputNode; break; diff --git a/src/nodeEditor/graph/computeNode.ts b/src/nodeEditor/graph/computeNode.ts index 92c8531..bf99ce0 100644 --- a/src/nodeEditor/graph/computeNode.ts +++ b/src/nodeEditor/graph/computeNode.ts @@ -279,11 +279,24 @@ const compute: { return [resolve(res)]; }, + [Type.property_input]: () => { + throw new Error("Not implemented"); + }, + + [Type.property_output]: () => { + throw new Error("Not implemented"); + }, + [Type.empty]: () => { return []; }, }; +/** + * + * @returns an array representing the computed values of the node's + * outputs according to the context + */ export const computeNodeOutputArgs = ( node: NodeEditorNode, ctx: ComputeNodeContext, @@ -296,10 +309,6 @@ export const computeNodeOutputArgs = ( if (node.type === Type.layer_transform_output) { const p = ctx.properties[i]; - if (p.timelineId && !ctx.timelines[p.timelineId]) { - // console.log(ctx, p); - } - defaultValue = { type, value: p.timelineId @@ -318,5 +327,6 @@ export const computeNodeOutputArgs = ( return pointer ? ctx.computed[pointer.nodeId][pointer.outputIndex] : defaultValue; }); + return compute[node.type](inputs, ctx, mostRecentNode?.state || node.state); }; diff --git a/src/nodeEditor/nodeEditorActions.ts b/src/nodeEditor/nodeEditorActions.ts index 5c3d42b..9982e76 100644 --- a/src/nodeEditor/nodeEditorActions.ts +++ b/src/nodeEditor/nodeEditorActions.ts @@ -164,10 +164,21 @@ export const nodeEditorActions = { removeNode: createAction("nodeEditorGraph/REMOVE_NODE", (action) => { return (graphId: string, nodeId: string) => action({ graphId, nodeId }); }), + removeReferencesToNodeInGraph: createAction("nodeEditorGraph/REMOVE_NODE_REFS", (action) => { + return (graphId: string, nodeId: string) => action({ graphId, nodeId }); + }), /** * Node IO */ + setNodeInputs: createAction("nodeEditorGraph/SET_INPUTS", (action) => { + return (graphId: string, nodeId: string, inputs: NodeEditorNodeInput[]) => + action({ graphId, nodeId, inputs }); + }), + setNodeOutputs: createAction("nodeEditorGraph/SET_OUTPUTS", (action) => { + return (graphId: string, nodeId: string, outputs: NodeEditorNodeOutput[]) => + action({ graphId, nodeId, outputs }); + }), addNodeInput: createAction("nodeEditorGraph/ADD_INPUT", (action) => { return (graphId: string, nodeId: string, input: NodeEditorNodeInput) => action({ graphId, nodeId, input }); diff --git a/src/nodeEditor/nodeEditorIO.ts b/src/nodeEditor/nodeEditorIO.ts index b03bbfe..825b434 100644 --- a/src/nodeEditor/nodeEditorIO.ts +++ b/src/nodeEditor/nodeEditorIO.ts @@ -160,6 +160,12 @@ export const getNodeEditorNodeDefaultInputs = (type: NodeEditorNodeType): NodeEd case NodeEditorNodeType.layer_transform_output: return []; + case NodeEditorNodeType.property_input: + return []; + + case NodeEditorNodeType.property_output: + return []; + case NodeEditorNodeType.expr: return []; @@ -262,6 +268,12 @@ export const getNodeEditorNodeDefaultOutputs = ( case NodeEditorNodeType.layer_transform_output: return []; + case NodeEditorNodeType.property_input: + return []; + + case NodeEditorNodeType.property_output: + return []; + case NodeEditorNodeType.expr: return []; @@ -314,6 +326,8 @@ type NodeEditorNodeStateMap = { [NodeEditorNodeType.rect_translate]: {}; [NodeEditorNodeType.layer_transform_input]: {}; [NodeEditorNodeType.layer_transform_output]: {}; + [NodeEditorNodeType.property_input]: { propertyId: string }; + [NodeEditorNodeType.property_output]: { propertyId: string }; [NodeEditorNodeType.expr]: { expression: string; textareaHeight: number; diff --git a/src/nodeEditor/nodeEditorReducers.ts b/src/nodeEditor/nodeEditorReducers.ts index e7d28b3..188a5f6 100644 --- a/src/nodeEditor/nodeEditorReducers.ts +++ b/src/nodeEditor/nodeEditorReducers.ts @@ -13,7 +13,10 @@ import { import { rectsIntersect } from "~/util/math"; import { calculateNodeHeight } from "~/nodeEditor/util/calculateNodeHeight"; import { removeKeysFromMap } from "~/util/mapUtils"; -import { removeNodeAndReferencesToItInGraph } from "~/nodeEditor/nodeEditorUtils"; +import { + removeNodeAndReferencesToItInGraph, + removeReferencesToNodeInGraph, +} from "~/nodeEditor/nodeEditorUtils"; type NodeEditorAction = ActionType; @@ -204,6 +207,15 @@ function graphReducer(state: NodeEditorGraphState, action: NodeEditorAction): No }; } + case getType(actions.removeReferencesToNodeInGraph): { + const { nodeId } = action.payload; + + return { + ...removeReferencesToNodeInGraph(nodeId, state), + selection: state.selection, + }; + } + case getType(actions.startAddNode): { const { type, io } = action.payload; return { ...state, _addNodeOfTypeOnClick: { type, io } }; @@ -438,6 +450,42 @@ function graphReducer(state: NodeEditorGraphState, action: NodeEditorAction): No }; } + case getType(actions.setNodeOutputs): { + const { nodeId, outputs } = action.payload; + const node = state.nodes[nodeId]; + return { + ...state, + nodes: { + ...state.nodes, + [nodeId]: { ...node, outputs }, + }, + }; + } + + case getType(actions.setNodeInputs): { + const { nodeId, inputs } = action.payload; + const node = state.nodes[nodeId]; + return { + ...state, + nodes: { + ...state.nodes, + [nodeId]: { ...node, inputs }, + }, + }; + } + + case getType(actions.addNodeOutput): { + const { nodeId, output } = action.payload; + const node = state.nodes[nodeId]; + return { + ...state, + nodes: { + ...state.nodes, + [nodeId]: { ...node, outputs: [...node.outputs, output] }, + }, + }; + } + case getType(actions.addNodeInput): { const { nodeId, input } = action.payload; const node = state.nodes[nodeId]; diff --git a/src/nodeEditor/nodeEditorUtils.ts b/src/nodeEditor/nodeEditorUtils.ts index 207faf4..dd82407 100644 --- a/src/nodeEditor/nodeEditorUtils.ts +++ b/src/nodeEditor/nodeEditorUtils.ts @@ -71,7 +71,7 @@ export const findInputsThatReferenceNodeOutputs = (nodeId: string, graph: NodeEd return results; }; -export const removeNodeAndReferencesToItInGraph = ( +export const removeReferencesToNodeInGraph = ( nodeId: string, graph: NodeEditorGraphState, ): NodeEditorGraphState => { @@ -79,7 +79,7 @@ export const removeNodeAndReferencesToItInGraph = ( const newGraph: NodeEditorGraphState = { ...graph, - nodes: removeKeysFromMap(graph.nodes, [nodeId]), + nodes: { ...graph.nodes }, }; for (let i = 0; i < refs.length; i += 1) { @@ -102,3 +102,17 @@ export const removeNodeAndReferencesToItInGraph = ( return newGraph; }; + +export const removeNodeAndReferencesToItInGraph = ( + nodeId: string, + graph: NodeEditorGraphState, +): NodeEditorGraphState => { + let newGraph = removeReferencesToNodeInGraph(nodeId, graph); + + newGraph = { + ...graph, + nodes: removeKeysFromMap(newGraph.nodes, [nodeId]), + }; + + return newGraph; +}; diff --git a/src/nodeEditor/nodes/nodeHandlers.ts b/src/nodeEditor/nodes/nodeHandlers.ts index 2d90091..95c2671 100644 --- a/src/nodeEditor/nodes/nodeHandlers.ts +++ b/src/nodeEditor/nodes/nodeHandlers.ts @@ -85,8 +85,10 @@ export const nodeHandlers = { { label: "Delete node", onSelect: () => { - dispatch(contextMenuActions.closeContextMenu()); - dispatch(nodeEditorActions.removeNode(graphId, nodeId)); + dispatch( + contextMenuActions.closeContextMenu(), + nodeEditorActions.removeNode(graphId, nodeId), + ); submitAction("Remove node"); }, default: true, diff --git a/src/nodeEditor/nodes/property/PropertyInputNode.tsx b/src/nodeEditor/nodes/property/PropertyInputNode.tsx new file mode 100644 index 0000000..721722a --- /dev/null +++ b/src/nodeEditor/nodes/property/PropertyInputNode.tsx @@ -0,0 +1,125 @@ +import React from "react"; +import { connectActionState, getActionState } from "~/state/stateUtils"; +import { compileStylesheetLabelled } from "~/util/stylesheets"; +import NodeStyles from "~/nodeEditor/nodes/Node.styles"; +import { nodeHandlers } from "~/nodeEditor/nodes/nodeHandlers"; +import { + NodeEditorNodeState, + NodeEditorNodeInput, + NodeEditorNodeOutput, +} from "~/nodeEditor/nodeEditorIO"; +import { NodeEditorNodeType } from "~/types"; +import { NodeBody } from "~/nodeEditor/components/NodeBody"; +import { PropertyNodeSelectProperty } from "~/nodeEditor/nodes/property/PropertyNodeSelectProperty"; +import { requestAction } from "~/listener/requestAction"; +import { nodeEditorActions } from "~/nodeEditor/nodeEditorActions"; +import { CompositionProperty } from "~/composition/compositionTypes"; +import { getLayerPropertyLabel } from "~/composition/util/compositionPropertyUtils"; + +const s = compileStylesheetLabelled(NodeStyles); + +interface OwnProps { + areaId: string; + graphId: string; + nodeId: string; +} +interface StateProps { + layerId: string; + inputs: NodeEditorNodeInput[]; + outputs: NodeEditorNodeOutput[]; + state: NodeEditorNodeState; +} + +type Props = OwnProps & StateProps; + +function PropertyInputNodeComponent(props: Props) { + const { areaId, graphId, nodeId, outputs } = props; + + const onSelectProperty = (propertyId: string) => { + requestAction({ history: true }, (params) => { + params.dispatch( + nodeEditorActions.updateNodeState( + props.graphId, + props.nodeId, + { propertyId }, + ), + ); + + const properties = getActionState().compositions.properties; + let propertyIds: string[]; + + const property = properties[propertyId]; + if (property.type === "group") { + propertyIds = property.properties; + } else { + propertyIds = [property.id]; + } + + const outputs = propertyIds + .filter((id) => properties[id].type === "property") + .map((id) => { + const property = properties[id] as CompositionProperty; + return { + name: getLayerPropertyLabel(property.name), + type: property.valueType, + }; + }); + + params.dispatch( + nodeEditorActions.updateNodeState( + props.graphId, + props.nodeId, + { propertyId }, + ), + nodeEditorActions.removeReferencesToNodeInGraph(props.graphId, props.nodeId), + nodeEditorActions.setNodeOutputs(props.graphId, props.nodeId, outputs), + ); + params.submitAction("Update selected PropertyInputNode property"); + }); + }; + + return ( + + + {outputs.map((output, i) => { + return ( +
+
+ nodeHandlers.onOutputMouseDown( + e, + props.areaId, + props.graphId, + props.nodeId, + i, + ) + } + /> +
{output.name}
+
+ ); + })} + + ); +} + +const mapStateToProps: MapActionState = ( + { nodeEditor }, + { graphId, nodeId }, +) => { + const graph = nodeEditor.graphs[graphId]; + const node = graph.nodes[nodeId]; + return { + layerId: graph.layerId, + inputs: node.inputs, + outputs: node.outputs, + state: node.state as StateProps["state"], + }; +}; + +export const PropertyInputNode = connectActionState(mapStateToProps)(PropertyInputNodeComponent); diff --git a/src/nodeEditor/nodes/property/PropertyNodeSelectProperty.tsx b/src/nodeEditor/nodes/property/PropertyNodeSelectProperty.tsx new file mode 100644 index 0000000..72cacf6 --- /dev/null +++ b/src/nodeEditor/nodes/property/PropertyNodeSelectProperty.tsx @@ -0,0 +1,246 @@ +import React, { useRef, useEffect } from "react"; +import { StyleParams, compileStylesheetLabelled } from "~/util/stylesheets"; +import { cssVariables } from "~/cssVariables"; +import { CompositionState } from "~/composition/state/compositionReducer"; +import { connectActionState } from "~/state/stateUtils"; +import { + getLayerPropertyLabel, + getLayerPropertyGroupLabel, +} from "~/composition/util/compositionPropertyUtils"; +import { requestAction } from "~/listener/requestAction"; +import { contextMenuActions } from "~/contextMenu/contextMenuActions"; +import { OpenCustomContextMenuOptions, ContextMenuBaseProps } from "~/contextMenu/contextMenuTypes"; +import { useRefRect, useGetRefRectFn } from "~/hook/useRefRect"; +import { + NODE_EDITOR_NODE_H_PADDING, + DEFAULT_CONTEXT_MENU_WIDTH, + CONTEXT_MENU_OPTION_HEIGHT, + CONTEXT_MENU_OPTION_PADDING_LEFT, +} from "~/constants"; +import { NODE_HEIGHT_CONSTANTS } from "~/nodeEditor/util/calculateNodeHeight"; + +const styles = ({ css }: StyleParams) => ({ + wrapper: css` + position: relative; + margin-bottom: ${NODE_HEIGHT_CONSTANTS.spacing}px; + `, + + select: css` + height: ${NODE_HEIGHT_CONSTANTS.selectHeight}px; + background: ${cssVariables.gray600}; + color: ${cssVariables.white500}; + font: 400 12px/18px ${cssVariables.fontFamily}; + border: none; + display: block; + width: calc(100% - ${NODE_EDITOR_NODE_H_PADDING * 2}px); + margin: 0 ${NODE_EDITOR_NODE_H_PADDING}; + text-align: left; + padding: 0 6px; + border-radius: 4px; + `, + + dropdownContainer: css` + background: ${cssVariables.dark300}; + border: 1px solid ${cssVariables.gray800}; + min-width: ${DEFAULT_CONTEXT_MENU_WIDTH}px; + padding: 2px; + border-radius: 4px; + `, + + container: css` + height: ${CONTEXT_MENU_OPTION_HEIGHT}px; + position: relative; + + &:hover { + background: ${cssVariables.primary500}; + } + + &:last-of-type { + border-bottom: none; + } + `, + + contentContainer: css` + height: ${CONTEXT_MENU_OPTION_HEIGHT}px; + display: flex; + align-items: stretch; + `, + + activeDot: css` + position: absolute; + top: ${CONTEXT_MENU_OPTION_HEIGHT / 2}px; + width: 4px; + height: 4px; + border-radius: 50%; + background: ${cssVariables.white500}; + transform: translate(-50%, -50%); + `, + + name: css` + color: ${cssVariables.white500}; + font-size: 12px; + font-weight: 400; + line-height: ${CONTEXT_MENU_OPTION_HEIGHT}px; + font-family: ${cssVariables.fontFamily}; + cursor: default; + + &--active { + background-color: ${cssVariables.gray700}; + } + `, +}); + +const s = compileStylesheetLabelled(styles); + +interface PropertyProps { + selectedPropertyId: string; + propertyId: string; + onSelectProperty: (propertyId: string) => void; + properties: CompositionState["properties"]; + depth: number; +} + +const Property: React.FC = (props) => { + const { depth, properties, propertyId, selectedPropertyId } = props; + + const property = properties[propertyId]; + + const onClick = () => props.onSelectProperty(propertyId); + + const depthLeft = 16 * depth; + + const activeDot = propertyId === selectedPropertyId && ( +
+ ); + + if (property.type === "group") { + return ( + <> +
+ {activeDot} +
+
{getLayerPropertyGroupLabel(property.name)}
+
+
+ {property.properties.map((propertyId) => ( + + ))} + + ); + } + + return ( +
+ {activeDot} +
+
{getLayerPropertyLabel(property.name)}
+
+
+ ); +}; + +interface OwnProps { + layerId: string; + selectedPropertyId: string; + onSelectProperty: (propertyId: string) => void; +} +interface StateProps { + properties: CompositionState["properties"]; + propertyIds: string[]; +} +type Props = OwnProps & StateProps; + +const PropertyNodeSelectPropertyComponent: React.FC = (props) => { + const selectRef = useRef(null); + const getSelectRect = useGetRefRectFn(selectRef); + + const selectedProperty = props.properties[props.selectedPropertyId]; + + const openContextMenu = () => { + requestAction({ history: false }, (params) => { + const onSelectProperty = (propertyId: string) => { + params.cancelAction(); + props.onSelectProperty(propertyId); + }; + + const Component: React.FC = ({ updateRect }) => { + const ref = useRef(null); + const rect = useRefRect(ref); + + useEffect(() => { + updateRect(rect!); + }, [rect]); + + return ( +
+ {props.propertyIds.map((propertyId) => ( + + ))} +
+ ); + }; + + const selectRect = getSelectRect()!; + + const options: OpenCustomContextMenuOptions = { + component: Component, + props: {}, + position: Vec2.new(selectRect.left, selectRect.top), + close: () => params.cancelAction(), + }; + params.dispatch(contextMenuActions.openCustomContextMenu(options)); + }); + }; + + return ( +
+ +
+ ); +}; + +const mapState: MapActionState = ({ compositions }, { layerId }) => ({ + properties: compositions.properties, + propertyIds: compositions.layers[layerId].properties, +}); + +export const PropertyNodeSelectProperty = connectActionState(mapState)( + PropertyNodeSelectPropertyComponent, +); diff --git a/src/nodeEditor/nodes/property/PropertyOutputNode.tsx b/src/nodeEditor/nodes/property/PropertyOutputNode.tsx new file mode 100644 index 0000000..780258f --- /dev/null +++ b/src/nodeEditor/nodes/property/PropertyOutputNode.tsx @@ -0,0 +1,138 @@ +import React from "react"; +import { connectActionState, getActionState } from "~/state/stateUtils"; +import { compileStylesheetLabelled } from "~/util/stylesheets"; +import NodeStyles from "~/nodeEditor/nodes/Node.styles"; +import { nodeHandlers } from "~/nodeEditor/nodes/nodeHandlers"; +import { + NodeEditorNodeState, + NodeEditorNodeInput, + NodeEditorNodeOutput, +} from "~/nodeEditor/nodeEditorIO"; +import { NodeEditorNodeType } from "~/types"; +import { NodeBody } from "~/nodeEditor/components/NodeBody"; +import { PropertyNodeSelectProperty } from "~/nodeEditor/nodes/property/PropertyNodeSelectProperty"; +import { requestAction } from "~/listener/requestAction"; +import { nodeEditorActions } from "~/nodeEditor/nodeEditorActions"; +import { CompositionProperty } from "~/composition/compositionTypes"; +import { getLayerPropertyLabel } from "~/composition/util/compositionPropertyUtils"; +import { separateLeftRightMouse } from "~/util/mouse"; +import { null } from "mathjs"; + +const s = compileStylesheetLabelled(NodeStyles); + +interface OwnProps { + areaId: string; + graphId: string; + nodeId: string; +} +interface StateProps { + layerId: string; + inputs: NodeEditorNodeInput[]; + outputs: NodeEditorNodeOutput[]; + state: NodeEditorNodeState; +} + +type Props = OwnProps & StateProps; + +function PropertyInputNodeComponent(props: Props) { + const { areaId, graphId, nodeId, inputs } = props; + + const onSelectProperty = (propertyId: string) => { + requestAction({ history: true }, (params) => { + params.dispatch( + nodeEditorActions.updateNodeState( + props.graphId, + props.nodeId, + { propertyId }, + ), + ); + + const properties = getActionState().compositions.properties; + let propertyIds: string[]; + + const property = properties[propertyId]; + if (property.type === "group") { + propertyIds = property.properties; + } else { + propertyIds = [property.id]; + } + + const inputs = propertyIds + .filter((id) => properties[id].type === "property") + .map((id) => { + const property = properties[id] as CompositionProperty; + return { + name: getLayerPropertyLabel(property.name), + type: property.valueType, + pointer: null, + value: null, + }; + }); + + params.dispatch( + nodeEditorActions.updateNodeState( + props.graphId, + props.nodeId, + { propertyId }, + ), + nodeEditorActions.setNodeInputs(props.graphId, props.nodeId, inputs), + ); + params.submitAction("Update selected PropertyInputNode property"); + }); + }; + + return ( + + + {inputs.map((input, i) => { + return ( +
+
+ nodeHandlers.onInputWithPointerMouseDown( + e, + props.areaId, + props.graphId, + props.nodeId, + i, + ) + : (e) => + nodeHandlers.onInputMouseDown( + e, + props.areaId, + props.graphId, + props.nodeId, + i, + ), + })} + /> +
{input.name}
+
+ ); + })} + + ); +} + +const mapStateToProps: MapActionState = ( + { nodeEditor }, + { graphId, nodeId }, +) => { + const graph = nodeEditor.graphs[graphId]; + const node = graph.nodes[nodeId]; + return { + layerId: graph.layerId, + inputs: node.inputs, + outputs: node.outputs, + state: node.state as StateProps["state"], + }; +}; + +export const PropertyInputNode = connectActionState(mapStateToProps)(PropertyInputNodeComponent); diff --git a/src/nodeEditor/util/calculateNodeHeight.ts b/src/nodeEditor/util/calculateNodeHeight.ts index 642cf23..09f8a74 100644 --- a/src/nodeEditor/util/calculateNodeHeight.ts +++ b/src/nodeEditor/util/calculateNodeHeight.ts @@ -7,6 +7,7 @@ const borderWidth = 1; const headerHeight = 20; const spacing = 8; const bottomPadding = 16; +const selectHeight = 18; export const NODE_HEIGHT_CONSTANTS = { inputHeight, @@ -15,6 +16,7 @@ export const NODE_HEIGHT_CONSTANTS = { headerHeight, spacing, bottomPadding, + selectHeight, }; const getVec2InputHeight = (node: NodeEditorNode, index: number) => { @@ -48,6 +50,18 @@ const getCombinedInputsHeight = ( return out; }; +const aboveOutputs: Partial< + { [key in NodeEditorNodeType]: (node: NodeEditorNode) => number } +> = { + [NodeEditorNodeType.property_input]: () => { + return selectHeight + spacing; + }, +}; + +export const getAboveOutputs = (node: NodeEditorNode): number => { + return aboveOutputs[node.type]?.(node) ?? 0; +}; + const aboveInputs: Partial< { [key in NodeEditorNodeType]: (node: NodeEditorNode) => number } > = { @@ -67,6 +81,7 @@ export const calculateNodeHeight = (node: NodeEditorNode): n borderWidth * 2 + headerHeight + spacing + + getAboveOutputs(node) + outputs.length * outputHeight + spacing + getAboveInputs(node) + @@ -84,6 +99,7 @@ export const calculateNodeInputY = ( borderWidth + headerHeight + spacing + + getAboveOutputs(node) + outputs.length * outputHeight + (outputs.length ? spacing : 0) + getAboveInputs(node) + @@ -102,7 +118,14 @@ export const calculateNodeOutputPosition = ( ): Vec2 => { return node.position .addX(node.width) - .addY(borderWidth + headerHeight + spacing + outputIndex * outputHeight + outputHeight / 2); + .addY( + borderWidth + + headerHeight + + spacing + + getAboveOutputs(node) + + outputIndex * outputHeight + + outputHeight / 2, + ); }; export const calculateNodeInputPosition = ( @@ -113,6 +136,7 @@ export const calculateNodeInputPosition = ( borderWidth + headerHeight + spacing + + getAboveOutputs(node) + node.outputs.length * outputHeight + (node.outputs.length ? spacing : 0) + getAboveInputs(node) + diff --git a/src/nodeEditor/util/nodeEditorContextMenu.ts b/src/nodeEditor/util/nodeEditorContextMenu.ts index 9618aeb..219a0ab 100644 --- a/src/nodeEditor/util/nodeEditorContextMenu.ts +++ b/src/nodeEditor/util/nodeEditorContextMenu.ts @@ -73,6 +73,20 @@ export const getNodeEditorContextMenuOptions = (options: Options) => { }); return [ + { + label: "Property", + options: [ + createAddNodeOption({ + type: NodeEditorNodeType.property_input, + label: "Property input", + }), + createAddNodeOption({ + type: NodeEditorNodeType.property_output, + label: "Property output", + }), + ], + default: true, + }, { label: "Layer", options: [ @@ -105,7 +119,6 @@ export const getNodeEditorContextMenuOptions = (options: Options) => { }, }), ], - default: true, }, { label: "Number", diff --git a/src/types.ts b/src/types.ts index 588de59..f3eb3ae 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,6 +24,9 @@ export enum NodeEditorNodeType { layer_transform_output = "layer_transform_output", layer_transform_input = "layer_transform_input", + + property_output = "property_output", + property_input = "property_input", } export enum ValueType { @@ -50,7 +53,6 @@ export enum PropertyName { Width, Height, } -console.log({ PropertyName }); export type Json = string | number | boolean | null | JsonObject | JsonArray | undefined; export interface JsonArray extends Array {} From 5de8e3d8c198e87dd4a724c5a334ebbfc330da7a Mon Sep 17 00:00:00 2001 From: alexharri Date: Wed, 8 Jul 2020 22:25:49 +0000 Subject: [PATCH 6/7] finalize property input and output --- src/area/components/Area.styles.ts | 4 +- src/components/common/NumberInput.styles.ts | 4 + src/components/common/NumberInput.tsx | 11 +- src/composition/compositionTypes.ts | 1 + src/composition/state/compositionReducer.ts | 19 +++ .../timeline/CompositionTimeline.styles.ts | 4 +- .../CompositionTimelineLayer.style.ts | 2 +- .../timeline/CompositionTimelineLayer.tsx | 18 +-- .../CompositionTimelineProperty.styles.ts | 2 +- .../timeline/CompositionTimelineProperty.tsx | 49 +++++-- .../timeline/compositionTimelineHandlers.ts | 4 +- .../util/compositionPropertyUtils.ts | 24 +-- .../workspace/CompositionWorkspace.styles.ts | 2 +- .../workspace/CompositionWorkspaceLayer.tsx | 24 +-- src/cssVariables.ts | 3 +- src/historyEditor/HistoryEditor.styles.ts | 2 +- src/nodeEditor/NodeEditor.styles.ts | 2 +- src/nodeEditor/NodeEditor.tsx | 3 +- src/nodeEditor/graph/computeLayerGraph.ts | 138 +++++++++++++----- src/nodeEditor/graph/computeNode.ts | 81 +++++----- src/nodeEditor/graph/createLayerGraph.ts | 19 +-- src/nodeEditor/nodeEditorIO.ts | 17 +-- src/nodeEditor/nodes/NumberInputNode.tsx | 12 +- .../nodes/property/PropertyOutputNode.tsx | 13 +- src/nodeEditor/util/calculateNodeHeight.ts | 3 + src/nodeEditor/util/nodeEditorContextMenu.ts | 40 +---- src/toolbar/Toolbar.styles.ts | 2 +- src/types.ts | 3 - src/vectorEditor/VectorEditor.styles.ts | 2 +- 29 files changed, 267 insertions(+), 241 deletions(-) diff --git a/src/area/components/Area.styles.ts b/src/area/components/Area.styles.ts index db67d9a..2dc4f17 100644 --- a/src/area/components/Area.styles.ts +++ b/src/area/components/Area.styles.ts @@ -82,7 +82,7 @@ export default ({ css }: StyleParams) => ({ selectArea__inner: css` border: 1px solid ${cssVariables.gray800}; - background: ${cssVariables.dark700}; + background: ${cssVariables.dark800}; `, selectArea__item: css` @@ -90,7 +90,7 @@ export default ({ css }: StyleParams) => ({ border: none; border-radius: 4px; padding: 0 24px; - background: ${cssVariables.dark700}; + background: ${cssVariables.dark800}; display: block; width: 128px; `, diff --git a/src/components/common/NumberInput.styles.ts b/src/components/common/NumberInput.styles.ts index bb4f2fe..45853ac 100644 --- a/src/components/common/NumberInput.styles.ts +++ b/src/components/common/NumberInput.styles.ts @@ -29,6 +29,10 @@ export default ({ css }: StyleParams) => ({ &--fillWidth { width: 100%; } + + &--computed { + color: red; + } `, button__label: css` diff --git a/src/components/common/NumberInput.tsx b/src/components/common/NumberInput.tsx index 90565f1..3e35d77 100644 --- a/src/components/common/NumberInput.tsx +++ b/src/components/common/NumberInput.tsx @@ -31,6 +31,7 @@ interface Props { tick?: number; pxPerTick?: number; value: number | number[]; + showValue?: number; onChange: (value: number) => void; onChangeEnd?: (type: "relative" | "absolute") => void; shiftSnap?: number; @@ -133,7 +134,7 @@ export class NumberInput extends React.Component { ? "Mixed" : (this.state.useState ? this.state.value - : getUnmixedValue(this.props.value) + : this.props.showValue ?? getUnmixedValue(this.props.value) ).toFixed( typeof this.props.decimalPlaces === "number" ? this.props.decimalPlaces : 1, ); @@ -141,7 +142,13 @@ export class NumberInput extends React.Component { return (