Handwritten signing and approval of online documents under react framework

Time:2022-1-22

Yongzhong DCS document online preview software is a document processing software launched by Yongzhong Software Co., Ltd. in the field of mobile Internet based on more than ten years of core technology accumulation. Yongzhong DCS adopts independent and controllable core technology and has the ability of rapid technical and service response. It outputs the documents as they are as HTML, pictures, etc., that is, click to get them without downloading, protects the privacy of documents, is fast and efficient, and easily realizes the safe online reading of documents.

This blog mainly introduces the basic implementation principle of handwritten signing and approval on online preview documents based on react in Yongzhong DCS document online preview product.

First, let’s feel the effect of signing and approval as a whole, as shown in Figure 1 below. The whole signing and approval includes five functions: brush, wireframe, text, signature and seal. This blog takes the signing function as an example. Other functions can be experienced on the official website. Portal:Yongzhong DCS
Handwritten signing and approval of online documents under react framework
Figure 1 Overall effect display of handwritten endorsement

0. Some thoughts

Maybe you who are reading this blog are a front-end boss, so you should be rightReactA review of it. But if you are still a new comer in the front end, I believe you will feel full of dry goods after reading this blog ~. However, before reading this blog, I still hope you have some knowledgeReact HooksReducerTypeScript, andCanvasThe basis of. No more nonsense, let’s get straight to the point!

1. Introduction

You may be curious about “another collision” in the title. I believe you are interested in the mainstream framework of modern front-endVueorReactIt is no stranger that they are essentially closer to declarative programming, while canvas canvas drawing is more inclined to imperative programming, that is, each step must tell the browser how to draw with the exposed 2D context. After the above analysis, the two seem to belong to different categories, but when you think about it carefully, they actually have combined cases, and the use scenarios have been very extensive in the front-end field (such as large screen and big data display), right! That’s itEChartsEChartsDuring the initialization of, you can specify a rendererrenderer[1] , yesCanvasstillSVGTo render, but no matter what rendering method is based on, most coders still focus onEChartsWith the convenience of out of the box and the call of various rich APIs, few people pay attention to the underlying rendering logic. This blog will take you to appreciate the above two andEChartsCompletely different use scenarios – handwritten document signing and approval. Combined with the underlying rendering and data management logic, this paper expounds in detail how the two are perfectly combined.

2. Event monitoring

In the whole signing process, the mouse (PC end) and gesture (mobile end) involved are nothing more than three actions: falling, dragging and lifting. The changes of relevant States in signing are also caused by these three actions. Therefore, after creating the canvas, you first need to listen to the events involved in these three actions. The approximate code is as follows:

/* React Functional Component */
export default memo(function CanvasLayer(props: ICanvasLayerProps) {
    // ......
    //Monitor mouse / finger drop
    Const canvasref = useref ({} as htmlcanvaselement) // define an empty ref object and assert it as the canvas element type
    /*Function object corresponding to PC side event listening*/
    const onMouseDown = useCallback(() => {/* do something... */}, [...deps])
    const onMouseMove = useCallback(() => {/* do something... */}, [...deps])
    const onMouseUp = useCallback(() => {/* do something... */}, [...deps])
    /*Function object corresponding to mobile terminal event listening*/
    const onTouchStart = useCallback(() => {/* do something... */}, [...deps])
    const onTouchMove = useCallback(() => {/* do something... */}, [...deps])
    const onTouchEnd = useCallback(() => {/* do something... */}, [...deps])
    useEffect(() => {
        if (canvasRef && canvasRef.current) {
            const canvasEl = canvasRef. Current // get the canvas element
            /*Add the event handling functions defined above for the three action events*/
            canvasEl.onmousedown = onMouseDown
            canvasEl.onmousemove = onMouseMove
            canvasEl.onmouseup = onMouseUp
            canvasEl.ontouchstart = onTouchStart
            canvasEl.ontouchmove = onTouchMove
            canvasEl.ontouchend = onTouchEnd
        }
    }, [canvasref, onmousedown, onMouseMove, onmouseup, ontouchstart, ontouchmove, ontouchend]) // the dependency must be set correctly
    // ......
    return useMemo(() => {
        return (
            // JSX ......
            //Here, the canvasref object is bound to the canvas element. Pagewidth and pageheight are the width and height of a page to be signed and approved
            <CanvasWrapper ref={canvasRef} width={pageWidth} height={pageHeight} signState={false}  />
        )
    }, [...deps])
})

/* Styled Component */
/*CSS module or styled component can be used for style definition. The philosophy of the latter follows all in JS, that is, react writes HTML as JSX, while styled component writes CSS as JS, which can be perfectly integrated with react components. Therefore, I use the latter, If you are interested, you can refer to the official website [2] and learn about the new features of ES6's [tag module string], which will not be repeated here*/
import styled from 'styled-component'
interface IProps {
  width: number;
  height: number;
}
export const CanvasLayerWrapper = styled.canvas.attrs<IProps>((props) => ({
  width: props.width,
  height: props.height,
}))<{ signState: boolean }>`
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  //Raise the canvas level during drawing
  z-index: ${props => props.signState ? '99' : '9'};
`;

It can be seen that once the external state (including the type, color, thickness and size of batch drawing) changes (corresponding touseCallbackThishookThe event handling function will also change in response to achieve various rendering effects, which also reflects the advantages of combining modern frameworks.

Of course, you might think that the event handler is wrapped around a layeruseCallbackSome superfluous, every time the componentstatecontextpropsreduxWhen the data in the event handler is re rendered due to the change of data, doesn’t the reference of the event handler object also change? That’s true, butuseCallbackThe meaning of existence is to optimize performance, which will be mentioned later in this blog~

3. Data structure design

A well-designed data structure can minimize the subsequent maintenance cost and improve the overall robustness.

Then, how should the data structure be designed for the handwritten endorsement of the front-end H5 page? First of all, because the project is fully usedTypeScript, the fields in the data structure should also beTypeScriptType constraints. From a differential point of view, a curve on a plane coordinate axis is actually composed of many(x, y)A point set consisting of coordinate points. If you have usedpythonMediummatplotlib.pyplotAfter drawing a curve, the simplest demo also needs to define two groups of one-dimensional arrays, that is, the coordinate points corresponding to the x-axis and y-axis respectively. More options like curve color, curve thickness and curve type are optional. When you need to draw multiple curves on a coordinate axis, it is best to define two sets of two-dimensional arrays. After careful analysis, handwritten endorsement has many similarities with it:

  • CanvasIt also has its own coordinate axis, which can be manipulatedctxArbitrary rotation and translation;
  • CanvasYou also need to set the fill color for paintingfillStyleOr thicknesslineWidth
  • A single stroke drawn in a batch can be regarded asCanvasA curve on the coordinate axis, but this curve is very irregular, and multiple strokes naturally correspond to multiple curves.

It is necessary to implement signing and approval on multiple pages (but for ease of description, the following defaults to a single page), and each page corresponds to multiple strokes, and each stroke corresponds to its own(x, y)Therefore, the final data structure is a three-dimensional array. In the innermost (lowest dimension) layer, each element is an object (description of each attribute of the drawn point). After the above analysis, I define the interface type of the object as follows:

interface Point {
  x: number;  //  x. The Y coordinate value is relative to the coordinates of the upper left corner of the canvas
  y: number;
  t?:  number;  //  The timestamp (optional) is taken from the events of the three actions to calculate and determine the speed of drawing
  f?:  number;  //  Function type (optional), because not all points are recorded during drawing or during dragging, but this blog does not describe dragging. Its essence is to monitor the above three actions
}

In the array corresponding to the outer layer, each element is a stroke. I also define the interface type corresponding to each stroke as follows:

interface Figure {
  initX: number;  //  Coordinates of the upper left corner of the smallest circumscribed rectangle enclosed by each point of the stroke
  initY: number;
  initW: number;
  initH: number;
  paintStyle: PaintStyle;  //  The default drawing type of this blog is the brush
  points: Point[];  // *** A point set consisting of points of the above point type***
  color: string;  //  stroke color 
  thickness: number; //  stroke weight 
  active: boolean;  //  Whether the box is selected to activate
  timeStamp: number;  //  Timestamp when the minimum bounding rectangle is generated
}

Therefore, the data in a single page isFigure[]Stored as a type.

4. Status management*

ReactHas its own status management tools:Redux。 In general, in a single page rich applicationSPAIn, cross component data and requested data are generally stored in the above state management tool, and the browser development tool plug-in can track the change of state. In view of the convenience brought to development, there is no doubt about using it for state management. butReactThe framework itself provides a way of cross component data sharing, that is, contextcontext(it is essentially similar to the internal state of components, but it can be directly shared to sub components and deep components without layer by layer transfer). Therefore, in the community, it has become a major topic about which kind of shared state management to choose. Personally, I think this is determined by the specific business scenario. How to manage the status of handwritten signing and approval can be analyzed as follows:

  • First, in order to ensure the smoothness of the drawn curve (note that the throttling function is essentially limited here)throttleThe painting must be frame by frame(frame-wise / tick-wise)Drawn, that is, roughly16msThe real-time rate, which means that each state change is quite frequent;
  • secondly,ReduxThe data state stored in is traceable. When debugging a bug, someUIWhen and where to switch other key states such as visual state is the key to troubleshooting. However, once the above frequent state changes are stored inReduxObviously, the useful state switching records that need to be seen will be deletedoverwhelm。 And frequent visitsReduxItself is not recommended.

Therefore, handwritten endorsement uses contextcontextTo share data:

/*Sign off component entry*/
interface ISignContext {
  paintColor: string;
  paintThickness: number;
  figures: Figure[];
  /*The actual state that needs to be stored is much more than the above*/
  paintStyle: -1 as PaintStyle,
  configPanelShown: false,
  paintFontsize: 16,
  paintShape: LineBBoxShape.RECT,
  signNames: [],
  signStamps: [],
  signModalShown: false,
  historyRecords: [],
  historyStages: [],
}
export const SignContext = createContext({} as ISignContext)
export default memo(function PcSign(props: ISignProps) {
    // ...
    return useMemo(() => {
        return (
            <SignContext.Provider value={{ paintColor: xxx, paintThickness: xxx, ...... }}>
                {/* JSX...... */}
            </SignContext.Provider>
        )
    }, [...deps])
}

Despite the above usecontextThe process does achieve cross component data sharing, and consumer components can get responsive data. However, there are too many managed status fields to be managed centrally, and the content provided by the provider component is also piled up, which is not conducive to later maintenance.

So, how to retain bothcontextThe use of is consistent withreduxWhat about the concept of centralized data management? The answer is to useuseReducerThisReactAnother big extra that can be used inside the componenthook。 ShouldhookNeed to pass in areducerreducerIt defines the status of management and which are dispatchedactionTo change, how to change, whether the change depends on other states, and whether the initial value of the state needs to be passed in and whether it is lazy initialization (optional).useReducerThe current value of the status will be returned and used formutateStatus dispatcherdispatcher。 For specific rules, please refer to the official document [3].

/* in reducer.ts */
/*Is the initial state defined exactly the same as the reducer defined in Redux*/
export const initState: ISignState = {
  Drawable: false, // whether to allow the switch of drawing
  Origin: {} as point, // the starting point of the point set during a drawing process
  Points: [] as point [], // the point set formed by one drawing process
  Figurearr: [] as array < {points: point []} >, // corresponding strokes on a single canvas
  /*The fields corresponding to more complex business logic are omitted below*/
  // ......
};
/*Ensure that the reducer must be a pure function*/
export default function reducer(state = initState, action: IAction): ISignState {
  const { payload } = action;  //  Get the load carried by the distributed action
  switch (action.type) {
    case actionType. TOGGLE_ Drawable: // all actiontype names are constants defined in advance
      return { ...state, drawable: payload };  //  Trigger react response through shallow copy (can optimize points)
    case actionType.CHANGE_ORIGIN:
      return { ...state, origin: payload };
    case actionType.ADD_POINT:
      return { ...state, points: [...state.points, payload] };
    case actionType.CLEAR_POINTS:
      return { ...state, points: [] };
    case actionType.PUSH_FIGURE:
      return { ...state, figureArr: [...state.figureArr, payload] };
    case actionType.CLEAR_FIGURE:
      return { ...state, figureArr: [] };
    default:
      return state;
  }
}

SinceuseReducerIt returns to the state of centralized management, and puts a powerful way to change the statedispatcherGet it, why not talk to himcontextMake a perfect match and pass them in as the provided value of the component? Compared with the large number of fields passed in above, isn’t this method wonderful? Combined with object enhanced writing, it is simple to explode! Moreover, because the returned state itself is responsive, it can change accordinglycontextThe shared overall object ensures that the value obtained in the consumer component is always responsive. If you puthooksThe dependencies of are set correctly and can be used safely.

The improvements are as follows:

/*Provider component*/
interface ISignContext {
  state: ISignState; //  Centrally managed state
  dispatcher: React. Dispatch<IAction>;  //  Change the dispatcher of state
}
export const SignContext = createContext({} as ISignContext)
export default memo(function PcSign(props: ISignProps) {
    // ......
    const [state, dispatcher] = useReducer(reducer, initState);
    // ......
    return useMemo(() => {
        return (
            <SignContext.Provider value={{ state, dispatcher }}>
                {/* JSX...... */}
            </SignContext.Provider>
        )
    }, [...deps])
})
/*Consumer component (any deep level) to extract shared data*/
const {state, dispatcher} = useContext(SignContext)
const {/*...*/} = state

5. Complete one-time drawing process

Now that the event listening processing function has been added, the data structure has been defined, and the centralized management state and the method to change the state have been obtained, let’s see how to realize the complete drawing process in combination with the above contents.

5.1 things to do when mouse down / finger down

First, we need to get the corresponding event object, which is exactly the same as the general processing of click events. We won’t repeat it here. Through this event object, we can obtain a lot of valuable information:

Const {clientx, clienty} = event // relative to the coordinate value of the upper left corner of the screen, the mobile terminal needs to get the finger to click, touch = event touches[0];  const {clientX, clientY} = touch
Const {timestamp} = event // the corresponding timestamp when the event occurs. It is used to judge the speed of drawing later

However, if only based onclientXandclientYTo draw, that’s a big mistake. Because they are always coordinate values relative to the upper left corner of the screen. Finally, the coordinates actually drawn should be relative toCanvasThe origin of the initial axis. Well, if you don’t understand, go straight to the picture above! As shown in Figure 2 below, taking the y-axis coordinate as an example, the y value of the drawn point should beclientYsubtractcanvasDistance of the element from the top of the screenoffsetY (the specific scenario may involve more complex calculations. Here is just a simple demo).
Handwritten signing and approval of online documents under react framework
Figure 2 Draw the schematic diagram of coordinate calculation

Finally, the event handling function corresponding to this action is as follows:

const {state, dispatcher} = useContext(SignContext)
const onMouseDown = useCallback(
  (e: any) => {
    const { offsetLeft, offsetTop } = initializePaint(e);  //  This encapsulated function is to calculate the distance between the canvas element and the top of the screen
    const origin = {
      x: e.clientX - offsetLeft,
      y: e.clientY - offsetTop,
      t: e.timeStamp.toFixed(0),
    } as Point;  //  Determines the starting point of the drawing process
    dispatcher({ type: _actionType.TOGGLE_DRAWABLE, payload: true });  //  Change state to paintable state
    dispatcher({ type: _actionType.CHANGE_ORIGIN, payload: origin });  //  Record the starting point of drawing
    dispatcher({ type: _actionType.ADD_POINT, payload: origin });  //  Add plot points to the cache point set
  },
  [dispatcher, state],
);

After defining the above centralized management status and its dispatcher, you can change the status and directly distribute it in a few lines. Everything becomes so simple!

5.2 things to do when dragging / sliding the mouse / finger

This action is the core of the whole drawing.

Const onMouseMove = usecallback (// it should be noted that every time 1px is moved, the function here will be recalled
  (e: any) => {
    if (!paintState.drawable) return;  //  If it is not paintable, exit directly
    const { offsetLeft, offsetTop } = initializePaint(e);  //  Also get the distance of the canvas element relative to the upper left corner of the screen
    const endInTick = {
      x: e.clientX - offsetLeft,
      y: e.clientY - offsetTop,
      t: e.timeStamp.toFixed(0),
    } as Point;  //  Determine the points passed in the drawing process, that is, the points recorded at the end of each frame
    /*Draw the core frame by frame [tick wise painting]*/
    const ctx = canvasRef. current. getContext('2d')!;  //  Get the 2d rendering context object corresponding to the canvas element
    paint(ctx, state.origin, endInTick);  //  Pass in the point at the end of the previous frame and the point at the end of the current frame to draw
    dispatcher({ type: _actionType.ADD_POINT, payload: endInTick });  //  Adds a passing point to the cache point set
    dispatcher({ type: _actionType.CHANGE_ORIGIN, payload: endInTick });  //  Taking the end point of this frame as the starting point of the next frame (if any) is a differential idea
  },
  [dispatcher, state],
);
/* utils. In TS*/
Export function paint() // Note! This function is called between frames, and the frequency is very high
  ctx: CanvasRenderingContext2D,
  origin: Point,
  end: Point,
  lineWidth: number = 2,
  color: string = 'black') {
      // ......  Key source code omitted
      ctx. beginPath();  //  Path start
      ctx. fillStyle = color;  //  Fill color
      for (let i = 0; i <= curve.d; i++) {
        let x = origin. x + (i * (end.x - origin.x)) / curve. d;  //  Calculate the next drawing point (circle center)
        let y = origin.y + (i * (end.y - origin.y)) / curve.d;
        ctx. moveTo(x, y);  //  Move drawing start point
        ctx. arc(x, y, curve.w, 0, 2 * Math.PI, false);  //  Draw small dots with the line width as the radius
      }
      ctx. closePath();  //  End of path
      ctx. fill();  //  Fill path
}
5.3 things to do when releasing the mouse / lifting the finger

In the event handling function corresponding to this action, the main work is to finish.

const onMouseUp = useCallback(
  (e: any) => {
    dispatcher({
      type: _actionType.PUSH_FIGURE,
      payload: { points: state.points },
    });  //  Add the cache point set to the point set of the stroke object to form a stroke
    dispatcher({ type: _actionType.CHANGE_ORIGIN, payload: {} });  //  Reset drawing start point
    dispatcher({ type: _actionType.CLEAR_POINTS });  //  Clear cache point set
    dispatcher({ type: _actionType.TOGGLE_DRAWABLE, payload: false });  //  Change state to non paintable state
  },
  [dispatcher, state],
);

6. Performance optimization

If you see here patiently and carefully, I’m sure you will find that I always emphasize hooks in this blog (includinguseMemo, useCallback)If the dependencies are filled in correctly, why do all reference data types in the component need to be wrapped aroundhooksAnd? The answer is to optimize performance. Of course, inGPUStrong computing power, that is, when the browser has strong redrawing and rendering ability, combined with the newer versionChromeinV8With the blessing of the engine, the drawing of handwritten signatures and batches must be quite smooth and smooth. In this case, there is really no need to do too much performance optimization, which is time-consuming and laborious. However, when a user uses aCPUAnd onlyCPUofPCAnd use a lower version ofChromeEven evilIEWhen, there is bound to be a drawn carton.

In Section 4 status management of this blog, the problem of frequent (frame by frame) status update is mentioned. stayReactIn, even if usedmemoWrapping functional components can not fundamentally avoid the unnecessary re rendering problem caused by different shallow comparison. If the re rendering of sub components or deeper components is caused by frequent update of state, it is quite fatal for performance. However, manuallyuseMemoanduseCallbackthese two items.hooksBy adding dependencies, you cancoderYou can specify which responsive object changes will lead to the change of the returned object reference, so as to trigger the re rendering of sub components on demand / deterministically.

In fact, the handwritten endorsement introduced in this blog can be further optimized. for examplereducerFunction, passed ininitStateThis change must be triggered by shallow copyReactAnd some excellent libraries in the front-end community, such asimmer[4] AndimmutableJS[5] Can speed up the decision of react for different objects, so as to avoid the performance loss caused by a large number of shallow copies or large and complex objects.

7. Summary

Through handwritten signing and approval of this small case, I believe you areCanvasin especialReactMore or less have a new understanding. Yeah,ReactCompared toVueIt has higher flexibility and good utilizationJSXCan play a lot of tricks. But everything is a double-edged sword. The price of high flexibility is a higher threshold and stronger flexibilityJSTo better “control” it. Finally, if there are deficiencies in this blog, please give me more advice~

8. Reference links