How to make friends Fabric.js and Redux?

2019-03-16 19:04发布

问题:

Is there any approach to use Fabric.js and Redux together? Fabric.js state should be used as part of store, but it isn't immutable and can mutate itself by user canvas interaction. Any idea? Thanks.

回答1:

I have extracted small example from my implementation of React-Redux and Fabric.js.

It works by simply getting whole fabric object by fabric.toObject(), saving it into state and revoking by fabric.loadFromJSON(). You can play around by using Redux DevTools and traveling through the state.

For any case, there is also jsfiddle available: https://jsfiddle.net/radomeer/74t5y1r0/

// don't be scared, just some initial objects to play with (fabric's serialized JSON)
const initialState = {
   canvasObject: {
      "objects": [{
         "type": "circle",
         "originX": "center",
         "originY": "center",
         "left": 50,
         "top": 50,
         "width": 100,
         "height": 100,
         "fill": "#FF00FF",
         "stroke": null,
         "strokeWidth": 1,
         "strokeDashArray": null,
         "strokeLineCap": "butt",
         "strokeLineJoin": "miter",
         "strokeMiterLimit": 10,
         "scaleX": 1,
         "scaleY": 1,
         "angle": 0,
         "flipX": false,
         "flipY": false,
         "opacity": 1,
         "shadow": null,
         "visible": true,
         "clipTo": null,
         "backgroundColor": "",
         "fillRule": "nonzero",
         "globalCompositeOperation": "source-over",
         "transformMatrix": null,
         "radius": 50,
         "startAngle": 0,
         "endAngle": 6.283185307179586
      }, {
         "type": "rect",
         "originX": "center",
         "originY": "center",
         "left": 126,
         "top": 210,
         "width": 100,
         "height": 100,
         "fill": "#FF0000",
         "stroke": null,
         "strokeWidth": 1,
         "strokeDashArray": null,
         "strokeLineCap": "butt",
         "strokeLineJoin": "miter",
         "strokeMiterLimit": 10,
         "scaleX": 1,
         "scaleY": 1,
         "angle": 0,
         "flipX": false,
         "flipY": false,
         "opacity": 1,
         "shadow": null,
         "visible": true,
         "clipTo": null,
         "backgroundColor": "",
         "fillRule": "nonzero",
         "globalCompositeOperation": "source-over",
         "transformMatrix": null,
         "radius": 50,
         "startAngle": 0,
         "endAngle": 6.283185307179586
      }, {
         "type": "triangle",
         "originX": "center",
         "originY": "center",
         "left": 250,
         "top": 100,
         "width": 100,
         "height": 100,
         "fill": "#00F00F",
         "stroke": null,
         "strokeWidth": 1,
         "strokeDashArray": null,
         "strokeLineCap": "butt",
         "strokeLineJoin": "miter",
         "strokeMiterLimit": 10,
         "scaleX": 1,
         "scaleY": 1,
         "angle": 0,
         "flipX": false,
         "flipY": false,
         "opacity": 1,
         "shadow": null,
         "visible": true,
         "clipTo": null,
         "backgroundColor": "",
         "fillRule": "nonzero",
         "globalCompositeOperation": "source-over",
         "transformMatrix": null,
         "radius": 50,
         "startAngle": 0,
         "endAngle": 6.283185307179586
      }],
      "background": ""
   }
};
// Redux part
const canvasObjectReducer = function(state = initialState, action) {
   switch (action.type) {
      case "OBJECTS_CANVAS_CHANGE":
         return Object.assign({}, state, {
            canvasObject: action.payload.canvasObject,
            selectedObject: action.payload.selectedObject
         });
      default:
         return state
   }
   return state;
}
// standard react-redux boilerplate
const reducers = Redux.combineReducers({
   canvasObjectState: canvasObjectReducer
});
const { createStore } = Redux;
const store = createStore(reducers, window.devToolsExtension && window.devToolsExtension());

const { Provider } = ReactRedux;
const { Component } = React;
const MyProvider = React.createClass({
   render: function() {
      return ( 
			<div>
				<Provider store={store}>
					<FabricCanvasReduxed/>
				</Provider>
			</div>
      );
   }
});

// Fabric part
var fabricCanvas = new fabric.Canvas();

// class which takes care about instantiating fabric and passing state to component with actual canvas
const FabricCanvas = React.createClass({
   componentDidMount() {
			// we need to get canvas element by ref to initialize fabric
         var el = this.refs.canvasContainer.refs.objectsCanvas;
         fabricCanvas.initialize(el, {
            height: 400,
            width: 400,
         });
			// initial call to load objects in store and render canvas
         this.refs.canvasContainer.loadAndRender();
			
         fabricCanvas.on('mouse:up', () => {
            store.dispatch({
               type: 'OBJECTS_CANVAS_CHANGE',
               payload: {
						// send complete fabric canvas object to store
                  canvasObject: fabricCanvas.toObject(),
						// also keep lastly active (selected) object
                  selectedObject: fabricCanvas.getObjects().indexOf(fabricCanvas.getActiveObject())
               }
            });
            this.refs.canvasContainer.loadAndRender();
         });
      },
      render: function() {
         return (
				<div>
					{/* send store and fabricInstance viac refs (maybe not the cleanest way, but I was not able to create global instance of fabric due to use of ES6 modules) */}
            	<CanvasContainer ref="canvasContainer" canvasObjectState={this.props.objects} fabricInstance={fabricCanvas}/>
				</div>
         )
      }
});
const mapStateToProps = function(store) {
   return {
      objects: store.canvasObjectState
   };
};

// we can not use export default on jsfiddle so we need react class with mapped state in separate constant 
const FabricCanvasReduxed = ReactRedux.connect(mapStateToProps)(FabricCanvas);

const CanvasContainer = React.createClass({
   loadAndRender: function() {
      var fabricCanvas = this.props.fabricInstance;
		fabricCanvas.loadFromJSON(this.props.canvasObjectState.canvasObject);
		fabricCanvas.renderAll();
		// if there is any previously active object, we need to re-set it after rendering canvas
		var selectedObject = this.props.canvasObjectState.selectedObject;
		if (selectedObject > -1) {
			fabricCanvas.setActiveObject(fabricCanvas.getObjects()[this.props.canvasObjectState.selectedObject]);
		}

   },
   render: function() {
      this.loadAndRender();
      return ( 
			<canvas ref="objectsCanvas">
         </canvas>
      );
   }
});

var App = React.createClass({
         render: function() {
            return ( 
					<div>
	               <MyProvider/>
               </div>
				);
			}
	});
	
ReactDOM.render( <App/>, document.getElementById('container'));
<!-- 
	Please use Redux DevTools for Chrome or Firefox to see the store changes and time traveling
	https://github.com/zalmoxisus/redux-devtools-extension
	Inspired by https://jsfiddle.net/STHayden/2pncoLb5/
-->
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react-dom.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/1.6.4/fabric.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-redux/4.4.5/react-redux.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux/3.6.0/redux.js"></script>
<div id="container">
</div>



回答2:

I found some solution. I try to describe, but sorry for english. Because there is no immutabity in Fabric.js it's hard to implement state management with redux. As far I understand default solution is to use fabric.loadFromJson function for push new state and serialization for pull and store for next manipulations such as actions history. But in this case JSON parsing will be bottleneck if you want to work with images, because they will be stored in Base64 data-uri.

The way is a bit hacky, but it works for me. I was replacing inner array of objects of fabric.js (fabric._objects) and invoking render everytime when something happens on canvas, e.g. moving objects by mouse.

First of all, my state is immutable now via Immutable.js, i.e. i have to return immutable List in my reducers. But elements of these lists are not immutable, it is just fabric.js objects stored in order that they should render. My state consist of objects list, selection list and several helpers objects that represent e.g viewport state (zoom, panning). Object state list keys used as ID of objects in actions. There is structure of my root scene reducer.

const sceneReducer = composeReducers(
  whetherRecordCurrentState,
  combineReducers({
    project: undoable(
      composeReducers(
        projectActions,
        combineReducers({
          objects,
          params,
          counters
        }),
      ),
      {
        limit: historyLimit,
        filter: combineFilters(
          recordFilter,
          excludeAction([
            'CREATE_SELECTION',
            'CLEAR_SELECTION',
            'SET_WORKSPACE_NAME',
            'SET_WORKSPACE_ID',
            'SET_WORKSPACE_TYPE',
            'SET_TAGS',
          ]),
        )
      }
    ),
    selection,
    meta,
    viewport,
    recording
  }),
  selectJustCreatedObject
);

It implements any fabric.js possibilities including async functions such as applying filters. Also I use redux-undoable package and it allows implement unlimitted undo/redo history. It also allows implement not stored actions, such as opacity changing by slider (all intermediate states will be not stored). Since I use immutability I can push new history state with only one changed object to save memory. There is my state

https://i.gyazo.com/fcef421e9ccfa965946a6e5930e42edf.png

See how it works: in fabric.js I handle event with new object state. Then I dispatch action with that state as payload. In my actions I can create new fabric objects or pass updated objects. All async operations (filtering, changing image source) performing in actions and pass to reducer ready new object. In reducers there is access to my fabric.js objects factory that creates deep copy of object with one distinction. I patched fabric.js (monkey patching, but you can use prototype extending) and it does not serialize images to base64 anymore. I implement it by overriding method Object.toDatalessObject(), that returns same json without images data. Instead source data-uri image data it storing link to HTMLElement object by manually setting Image._element. I.e. after changing images coordinates new image object will have same _element. It allows to save memory and accelerate application.

After all, my container for fabric.js is React component. It connects with redux and after commiting change invokes componentWillRecievProps method. In method I catch new state, create copy with my factory (yes, there is double copying, it should be optimized, but it works fine for me) and pass it to fabric._objects and then I invoke render. I hope it helps.