Writing a PrimitiveTool
This tutorial, while self-contained, also serves as context to the article, The Provider-Local Class Pattern. If you’re an experienced programmer, I’d suggest checking out that article after for some discussion on scaling iModel.js applications.
Together in this article we’ll build a primitive iModel.js app with a PrimitiveTool.
We’ll load an iModel, and have our tool place a ‘pin’ in the viewport where the user chooses. All of the added pins will be listed in a sidebar, and the user can “manage” their pins. We’ll fork a starting point from the iModel.js basic viewport app sample to make things quick. Each set of changes I make will feature a commit in my fork, and will be mentioned with an accompanying link. If you want to skip ahead, or you can’t get what I showed to work, you can git checkout
specific commits or see the diffs online on GitHub.
While writing applications like these, I found a pattern for using vanilla JavaScript APIs (like iModel.js) in React that has helped me vastly simplify a lot of code. We’re not going to use this pattern here since it relies on an advanced knowledge of JavaScript programming and is out of scope of this tutorial, but if you’re experienced with React or just plain intrigued, check out the article on said pattern. It intentionally starts where this tutorial ends so it makes a good sequel if you’re looking for some advanced React action.
With that in mind, let’s get started. If you haven’t tried developing an iModel.js application yet, fret not, check out the instructions in the README.md of the sample apps repository. I had never set it up before writing this, and it was seriously easy!
Starting the App
To get started, we’ll add some state to the base app to store our pins. In src/frontend/components/App.tsx
, we'll import a class for dealing with some geometry (Point3d
) and add a pinLocations
property to the AppState
interface like below. If you're more of a fan of hooks, functional components, advanced state management solutions (Redux, React Context, and more), etc, you're not alone, but we'll keep to simple stuff (like excessive prop passing) and you can go be as opinionated as you want when building your own apps.
Let’s add that new state property:
We’ll also need to update the App
component's state initialization in its constructor, as TypeScript ought to thoroughly remind you:
Next we’ll add a file under src/frontend/components
which we'll call Pin.tsx
and start implementing our tool for placing pins, and a marker to show the pins in the viewport. Markers require a Decorator
to manage them in iModel.js, and we need to register our decorator and tool with iModel.js so it can know about them, manage, and use them.
To register these classes upon startup, we add register calls in src/frontend/api/BasicViewportApp.ts
right after IModelApp.startup()
in the aptly named startup
function:
Tools needs to have a registered translation namespace, but we’re not focusing on that so registerNamespace
above is arbitrary, though necessary¹.
Next, download the following public domain pin image I made (showing off my e̶l̶i̶t̶e̶ SVG skills), and place it in your repository next to your Pin.tsx
file, making sure it is named pin-marker.svg
to match the import in the code we added.
Alright, so we’ve created and registered a tool, and a decorator, with the decorator controlling a list of PinMarker
s and drawing them. So now all we need to do is glue this to our app state in React and have pinDecorator.decorate
use those pins from the app state, as well as have PlacePin
push a new pin to the state when a user clicks with the tool.
Let’s add our new tool and props to src/frontend/components/Toolbar.tsx
:
We add a button for our new PlacePin
tool there. The buttons are set up in the Toolbar
component, where onClick
for each button runs the corresponding tool in the registry. We add another child element to the div.toolbar
root element in the Toolbar
component for our new tool. Notice that each button is actually a link including a span using an icon icon-*
CSS class from the @bentley/icons-generic-webfont
package. While there are more advanced (and much better) ways to reference CSS, we will keep it simple, for our new button we'll use the icon icon-map
CSS class, for a nice fancy icon like the rest. We'll inline our passed onClick
function since it'll need to reference new props in Toolbar
that we're adding: getPins
and setPins
; these props will be passed through our component hierarchy like so: App -> IModelComponents -> Toolbar
. When we run the tool, we will pass it these functions so that it can be aware of React state even if it changes.
Notice that the IModelApp.tools.run
method constructs a new tool instance of the tool class registered with a given id, and runs — literally calling its run
method — it, passing any additional arguments to that tool's run
method.
In App.tsx
we pass our necessary props in App.render
and down the IModelComponents
class to give it to our Toolbar
.
We pass state glue functions down the prop chain for our tool so it can mutate React state. When our tool eventually calls setPins
to add a pin, React will notice and update all affected components, synchronizing the state between our classes and our UI, which will be important when we add the sidebar. You might already see some ugliness, or not, the grossness of prop passing (through IModelComponents
) is out of scope for this article, but it does definitely suck to be passing props through a component that doesn't care about them or how Toolbar
works. More so, a not-yet-obvious nuisance is brewing here as we tie our React UI to our tool. As our application grows in size and we share more sources of state between our classes and UI, it will begin to reveal itself more.
Back to work, now we can finally change our PlacePin
class to expect the state glue and update the state in our vanilla JS accordingly.
And that’s actually it for our first major feature. Now when you run the app, you should see in the top-right corner a ‘map’ tool in the toolbar.
When clicked on, the cursor changes to indicate a user can now place a pin in the viewport with a left click. What we’ve done until now is the first commit on my fork, so you can see the total changes we’ve made up to this point.
Adding the React UI
Now it’s time to add our miniscule UI (UI is a generous term here) to supplement our viewport pins with some buttons to manipulate them. We’ll add a div to the App component with some styling to get a sidebar, and render our pin data there. Just a warning kids, don’t try what you’re about to see in a project you need to maintain, this is about as far from good styling practices as you can get. For our purposes it centralizes and minimizes the changes needed. Just edit the return statement at the end of App.render
to be:
Now when you place markers, you should see their world coordinates appear on the left, and you can click the X
link to delete them — except that deleting doesn't seem to work. Try adding a marker after deleting, suddenly the deletion registered! This is because the decorator's pin state is only synced with the react state when a new marker is added. The pin is deleted in React but not in the decorator. Let's fix that by giving pinDecorator
access to getPins
from the Tool so it doesn't need to wait for PlacePin
to be ran.
Since decorate is called every frame, and iModel.js needs marker instances to persist between frames, we store a persistent PinMarker
instance for each location in a fancy Map
.
We also have to give pinDecorator the getPins
reference like we do with the tool, we could do it when the App
component mounts, but doing it on PlacePin.run
is a bit easier to show even though it's not where it probably ought to be. So let's change our methods in PlacePin
to help us out:
One last bug :) When you delete a Marker, it won’t go away again! But if you move your mouse into the viewport which will cause it to re-render, it’ll disappear as it should. To fix this, we’ll just invalidate the decorations after the state change. So where we delete the pins in App.render
, we'll also invalidate the decorations, and the viewport should finally be in sync with our React UI completely.
Alright, and that’s it! 🥳 In a handful of lines of code we added a real PrimitiveTool
for our users to do something, and we can access the results of that tool in our React UI and application, as well as manage it in a way that iModel.js’ tools and decorators can now be aware of. At this point, we are now at the final commit in the demo repository.
If you’re highly-attentive, or perhaps just cynical, you may have noticed that what we did today wasn’t perfectly elegant. That’s not to say it can’t be, but you need to be aware of some higher-level concepts first, which we were avoiding here. In fact, I want you to think this is inelegant, because I wrote another aforementioned article about fixing it. You can see this inelegance best when you:
- Start scaling your application’s features.
- Start using functional components with hooks.
If you’re curious about discussion (and solutions!) for scaling your application’s frontend architecture, (with special care in the case of beautiful-yet-subtly-evil React hooks), please visit me in part 2 of this article.
If you find that something needs more depth in explanation, I made a mistake, or you want more content, please start a thread on the iModel.js discussions page or email me at mikemikeb@protonmail.com. Thanks for reading!
Footnotes:
- In fact, you may notice the comment over the
flyover
property of ourPrimitiveTool
,PlacePin
, mentioning that we're abusing the translation system here. If we had the time, we'd setup an English translation and flyover would return a key to the label we need, a key like"tools.placepin.flyover"
, which would allow us to reference a translatable concept. Instead, we pretend there is a key"Place Pin"
, and the translation system will fail to find it and default to the key text itself ("Place Pin"
!), letting us skip setting up English translation.