8 Feb 2023

Building a Simple React Wrapper for the Viewer

Default blog image

Is there an official {popular UI framework name} wrapper for the viewer?

Every now and then this kind of question comes up, and the answer is always a disappointing "no"... There are obviously many different web UI frameworks, and even if we were to pick the top 3, developing and maintaining official wrappers for these frameworks would require considerable amount of resources. And on top of that, most of our customers still use the viewer in a vanilla HTML/CSS/JS context. That is why our policy has been to dedicate 100% of the team's resources to building and improving the core Viewer SDK, making it as rich and robust as possible, for everyone.

With that being said, we do understand that in certain situations the viewer has to be embedded in one of the modern frameworks. In these cases, you can either take advantage of one of the community-developed projects (although we cannot provide our support in these cases), or build your own wrapper. The latter might sound a bit scary but if you don't need to control every single part of the viewer state, implementing such a component is actually not that difficult, even if you're not an expert in the specific UI framework.

In this blog post we'll explore how one could go about implementing a custom React wrapper component for the viewer. The component will provide just a couple of features to demonstrate some of the important concepts of React, for example:

  • letting us specify the URN of a model to be loaded in the viewer (demonstrating the concept of props passing, or "passing data down to the component")
  • triggering an event when the viewer camera changes (demonstrating the concept of event handling, or "sending data out of the component")
  • controlling the viewer selection (demonstrating the concept of two-way data binding)
  • providing access to the internal Viewer API for any other "non-declarative" interaction

Ideally you could then take this component as a starting point, drop it into your own project, and start extending it to your heart's desire by reusing the same React concepts.

If this is your first time working with React, I'd strongly suggest that you look at the official tutorial. And there's also a great article on embedding other applications into React that is worth reading as we will follow it here.

Implementation

Basic Component

Let's start by defining a simple React component class that will render an empty <div>, and capture a ref to this element when the component is mounted to the DOM. Later we will instantiate the viewer inside this <div> element, and store its reference in a member variable called viewer.

import React from 'react';

class Viewer extends React.Component {
    constructor(props) {
        super(props);
        this.container = null;
        this.viewer = null;
    }

    render() {
        return <div ref={ref => this.container = ref}></div>;
    }
}

Viewer Runtime Initialization

In order to be able to use the viewer, we first have to initialize the global viewer runtime (using the Autodesk.Viewing.Initializer function), specifying the type of backend our viewer (or viewers) will communicate with, and the authentication details. In vanilla JavaScript you would typically wrap your application like this:

Autodesk.Viewing.Initializer(options, function () {
    const viewer = new Autodesk.Viewing.GuiViewer3D(container);
    viewer.start();
    // ... and now initialize the rest of your application
});

In the React world we may not always be able to wrap our entire application this way, so instead we'll create a helper function that will initialize the viewer runtime when needed. This function will make sure that the initialization is only called once, and more importantly, it'll warn us if we try to initialize the runtime with different options as this would be considered a bug:

import React from 'react';

const { Autodesk } = window;

const runtime = {
    options: null,
    ready: null
};

function initializeViewerRuntime(options) {
    if (!runtime.ready) {
        runtime.options = { ...options };
        runtime.ready = new Promise((resolve) => Autodesk.Viewing.Initializer(runtime.options, resolve));
    } else {
        if (['accessToken', 'getAccessToken', 'env', 'api', 'language'].some(prop => options[prop] !== runtime.options[prop])) {
            return Promise.reject('Cannot initialize another viewer runtime with different settings.')
        }
    }
    return runtime.ready;
}

class Viewer extends React.Component {
    constructor(props) {
        super(props);
        this.container = null;
        this.viewer = null;
    }

    render() {
        return <div ref={ref => this.container = ref}></div>;
    }
}

Viewer Setup

Now we can add logic for initializing and uninitializing the viewer. The best places to implement these are the lifecycle methods componentDidMount and componentWillUnmount, respectively:

// ...

class Viewer extends React.Component {
    // ...

    componentDidMount() {
        initializeViewerRuntime(this.props.runtime || {})
            .then(_ => {
                this.viewer = new Autodesk.Viewing.GuiViewer3D(this.container);
                this.viewer.start();
            })
            .catch(err => console.error(err));
    }

    componentWillUnmount() {
        if (this.viewer) {
            this.viewer.finish();
            this.viewer = null;
        }
    }

    // ...
}

// ...

As you can see, we're introducing the first component propertyruntime, used to specify the options for initializing the viewer runtime. This way, you'll be able to pass the specific options to the viewer component, for example, like so:

<Viewer runtime={{ accessToken: '...', api: 'derivativeV2' }} />

To learn more about the different runtime initialization options, refer to https://aps.autodesk.com/en/docs/viewer/v7/reference/Viewing/#initializer-options-callback.

Updating Viewer State

We will need to update the state of our viewer at different points in time, for example, when the viewer is first instantiated, or whenever the properties of our React component get updated. Let's add a helper method updateViewerState that will compare the current props with the viewer state (or with previous prop values), and update the viewer if necessary. This method will be called right after the viewer is instantiated in the componentDidMount method, and then whenever the props of our component change (using another lifecycle method called componentDidUpdate).

// ...

class Viewer extends React.Component {
    // ...

    componentDidMount() {
        initializeViewerRuntime(this.props.runtime || {})
            .then(_ => {
                this.viewer = new Autodesk.Viewing.GuiViewer3D(this.container);
                this.viewer.start();
                this.updateViewerState({});
            })
            .catch(err => console.error(err));
    }

    // ...

    componentDidUpdate(prevProps) {
        if (this.viewer) {
            this.updateViewerState(prevProps);
        }
    }

    updateViewerState(prevProps) {
        if (this.props.urn && this.props.urn !== prevProps.urn) {
            Autodesk.Viewing.Document.load(
                'urn:' + this.props.urn,
                (doc) => this.viewer.loadDocumentNode(doc, doc.getRoot().getDefaultGeometry()),
                (code, message, errors) => console.error(code, message, errors)
            );
        } else if (!this.props.urn && this.viewer.model) {
            this.viewer.unloadModel(this.viewer.model);
        }

        const selectedIds = this.viewer.getSelection();
        if (JSON.stringify(this.props.selectedIds || []) !== JSON.stringify(selectedIds)) {
            this.viewer.select(this.props.selectedIds);
        }
    }

    // ...
}

// ...

Here we're introducing two more component props:

  • urn - used to specify the URN of the model we want to load into the viewer
  • selectedIds - used to specify list of object IDs to select in the viewer

Exposing Viewer Events

Finally, let's expose some of the viewer events from our wrapper component so that the application can react to them as well.

// ...

class Viewer extends React.Component {
    // ...

    componentDidMount() {
        initializeViewerRuntime(this.props.runtime || {})
            .then(_ => {
                this.viewer = new Autodesk.Viewing.GuiViewer3D(this.container);
                this.viewer.start();
                this.viewer.addEventListener(Autodesk.Viewing.CAMERA_CHANGE_EVENT, this.onViewerCameraChange);
                this.viewer.addEventListener(Autodesk.Viewing.SELECTION_CHANGED_EVENT, this.onViewerSelectionChange);
                this.updateViewerState();
            })
            .catch(err => console.error(err));
    }

    componentWillUnmount() {
        if (this.viewer) {
            this.viewer.removeEventListener(Autodesk.Viewing.CAMERA_CHANGE_EVENT, this.onViewerCameraChange);
            this.viewer.removeEventListener(Autodesk.Viewing.SELECTION_CHANGED_EVENT, this.onViewerSelectionChange);
            this.viewer.finish();
            this.viewer = null;
        }
    }

    onViewerCameraChange = () => {
        if (this.props.onCameraChange) {
            this.props.onCameraChange({ viewer: this.viewer, camera: this.viewer.getCamera() });
        }
    }

    onViewerSelectionChange = () => {
        if (this.props.onSelectionChange) {
            this.props.onSelectionChange({ viewer: this.viewer, ids: this.viewer.getSelection() });
        }
    }

    // ...
}

// ...

Note the different syntax of the onViewerCameraChange and onViewerSelectionChange methods. Instead of using the standard someMethod(someArgs) { ... } syntax we use someMethod = (someArgs) => {} to define the methods as public class fields. That way we can immediately use them as event listeners without having to worry about binding them to the instance of our React component.

Usage

And with that your <Viewer /> component is now ready to be used in a React app. Here's a simple example of how the component could be setup if we had an existing access token, and a URN of a model we want to load in the viewer:

class App extends React.Component {
    constructor(props) {
        super(props);
    }

    render() {
        return (
            <div>
                <div style={{ position: 'relative', width: '800px', height: '600px' }}>
                    <Viewer
                        runtime={{ accessToken: '<your-access-token>' }}
                        urn={'<your-model-urn>'}
                    />
                </div>
            </div>
        );
    }
}

Note that we're wrapping the viewer inside a <div> element with a custom CSS style. This is because the viewer should typically be contained inside an element that has the CSS position set to either relative or absolute, as explained in this blog post: https://aps.autodesk.com/blog/viewer-does-not-stay-its-container.

Also, don't forget to add the viewer dependencies to your HTML:

  • <link rel="stylesheet" href="https://developer.api.autodesk.com/modelderivative/v2/viewers/7.*/style.css">
  • <script src="https://developer.api.autodesk.com/modelderivative/v2/viewers/7.*/viewer3D.js"></script>

Bottom-up Data Flow

If we want to get some information out of the viewer, we can do so through callback props, such as the onCameraChange property we added to our component:

class App extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            camera: null,
        };
    }

    render() {
        return (
            <div>
                <div style={{ position: 'relative', width: '800px', height: '600px' }}>
                    <Viewer
                        runtime={{ accessToken: '<your-access-token>' }}
                        urn={'<your-model-urn>'}
                        onCameraChange={({ viewer, camera }) => this.setState({ camera: camera.getWorldPosition() })}
                    />
                </div>
                <div>
                    Camera Position: {this.state.camera && `${this.state.camera.x.toFixed(2)} ${this.state.camera.y.toFixed(2)} ${this.state.camera.z.toFixed(2)}`}
                </div>
            </div>
        );
    }
}

Two-way Data Binding

Now what if we wanted our application to control a part of the viewer state that could be changed by the user as well? Consider the viewer selection for example - the application can control the selection using the selectedIds prop of our React component, but at the same time the user can change the viewer state by (un)selecting model elements in the viewport. In this case we're talking about two-way data binding. According to React documentation, the recommended approach in this case is to set the value via a component prop as usual, and sync any updates from within the component using a change handler.

The following sample app lets the user control the viewer selection by entering a list of object IDs separated by commas into an <input type="text"> field. The selection is kept as part of the application state, it is passed down to the viewer (selectedIds prop) and the text field (value prop), and it is also updated whenever the viewer selection changes (onSelectionChange callback) and whenever the value of the text field changes (onChange callback):

class App extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            selectedIds: []
        };
    }

    onInputChange = (ev) => {
        const val = ev.target.value.trim();
        this.setState({ selectedIds: val.split(',').filter(e => e.length > 0).map(e => parseInt(e)).filter(e => Number.isInteger(e)) });
    }

    render() {
        return (
            <div>
                <div style={{ position: 'relative', width: '800px', height: '600px' }}>
                    <Viewer
                        runtime={{ accessToken: '<your-access-token>' }}
                        urn={'<your-model-urn>'}
                        selectedIds={this.state.selectedIds}
                        onSelectionChange={({ viewer, ids }) => this.setState({ selectedIds: ids })}
                    />
                </div>
                <div>
                    Selected IDs: <input type="text" value={this.state.selectedIds.join(',')} onChange={this.onInputChange}></input>
                </div>
            </div>
        );
    }
}

This way, no matter how you modify the viewer selection - whether it's by editing the text field, or inside the viewer directly - it will always remain in sync across all components.

Accessing Viewer API

Finally, what if we want to access the Viewer API from our React application? One option would be to include the viewer instance in the events triggered by your component (as we did with onCameraChange and onSelectionChange above). Alternatively, we can once again use React's ref to grab a reference to our wrapper component when it's mounted to the DOM, and then access its viewer property whenever needed:

class App extends React.Component {
    constructor(props) {
        super(props);
        this.wrapper = null;
    }

    render() {
        return (
            <div className="app">
                <div style={{ position: 'relative', width: '800px', height: '600px' }}>
                    <Viewer
                        ref={ref => this.wrapper = ref}
                        runtime={{ accessToken: '<your-access-token>' }}
                        urn={'<your-model-urn>'}
                    />
                </div>
                <button onClick={() => this.wrapper.viewer.autocam.goHome()}>Reset View</button>
            </div>
        );
    }
}

That's a Wrap!

And that's it for our React wrapping adventure today. You can find a complete code sample on our GitHub: https://github.com/autodesk-platform-services/viewer-react-sample.

Related Article