DEV Community

Cover image for Fabric.js Wasn't the Problem. Our Architecture Was.
Aleksandr Gusev
Aleksandr Gusev

Posted on

Fabric.js Wasn't the Problem. Our Architecture Was.

We build an interactive office map.

There is a floor plan in the background, desks, meeting rooms, parking spaces, lockers, points of interest, and pods — groups of desks that can move together.

Admins drag objects around, rotate them, change their shapes, print the floor plan, and save drafts.

The canvas is built with Fabric.js 5. The application around it is Angular 19.

And for a long time, Fabric.js wasn't just the rendering layer.

It was basically the architecture.

That was the problem.


The Old Map Was a Fabric Application That Happened to Have Entities

In the old implementation, a DeskPresenter extended fabric.Group.

Its constructor took entityData.viewConfig and passed properties such as:

left
top
scaleX
angle
Enter fullscreen mode Exit fullscreen mode

directly into Fabric.

The presenter kept a reference to the Desk.

The Desk kept a reference back to the presenter.

So instead of having:

Domain Entity
      +
Canvas Object
Enter fullscreen mode Exit fullscreen mode

we effectively had:

Desk
  ↕
DeskPresenter
  ↕
Fabric.Group
Enter fullscreen mode Exit fullscreen mode

The distinction between our domain model and the canvas object became increasingly blurry.

It was convenient at first.

Then the side effects started appearing.


When Fabric Became Too Global

We had a FabricExtension that modified fabric.Object.prototype at application startup.

It added things like custom corners, caching behavior, snapAngle, centeredRotation, and custom rendering.

There was also older map code modifying:

fabric.Canvas.prototype.setCursor
Enter fullscreen mode Exit fullscreen mode

and even adding functionality to:

CanvasRenderingContext2D
Enter fullscreen mode Exit fullscreen mode

Some custom selection controls lived on those prototypes too.

That means these changes weren't limited to the office map.

They were effectively process-global.

Open another screen that happened to use Fabric and it could inherit behavior that was originally created specifically for the office map.

That was a pretty good sign that the boundaries weren't where they should be.


Events Had the Same Problem

The old AngularJS implementation also had a ToParentEventsConnector.

Its job was to translate Fabric-related events into $scope events.

The class had a large switch over things like:

DeskHoveredEvent
ParkingMouseOutEvent
CreateDragBookingEvent
...
Enter fullscreen mode Exit fullscreen mode

Every new map object could mean another event class and another branch.

Again, Fabric events, application events, and AngularJS communication were tightly connected.

The map wasn't just rendering objects.

It was becoming responsible for communicating with the rest of the application.


Why We Kept Fabric.js

We did consider whether rebuilding the rendering layer from scratch would give us a cleaner architecture.

We decided not to.

Fabric already solves many difficult problems:

  • object selection;
  • transformations;
  • zooming;
  • hit testing;
  • object caching;
  • grouping;
  • rotation;
  • serialization.

Our desks aren't simple rectangles either.

They're assembled from multiple paths and wrapped in Fabric groups.

Pods are also groups of desk presenters.

Reimplementing all of that with raw Canvas would mean spending our time rebuilding functionality that Fabric already handles well.

So the decision became:

Keep Fabric. Stop letting Fabric define the application architecture.

That was the reason for introducing MapEngine.


Introducing MapEngine

The new architecture puts an application-level boundary between Angular and Fabric.

Conceptually:

Angular
   │
   ▼
MapEditor
   │
   ▼
MapEngine
   ├── MapStage
   ├── MapViewport
   ├── ObjectRegistry
   └── ChangesTracker
   │
   ▼
Fabric.js
Enter fullscreen mode Exit fullscreen mode

The responsibilities are deliberately separated.

MapStage owns the Fabric canvas.

MapViewport owns the camera.

ObjectRegistry maps application entities to their canvas representations.

ChangesTracker tracks what needs to be saved.

That separation turned out to be much more important than simply moving code into smaller files.


MapStage Is the Canvas

MapStage owns the actual Fabric canvas.

It deals with things like:

  • adding objects;
  • removing objects;
  • canvas size;
  • rendering;
  • active objects.

It doesn't need to know how the application thinks about zoom.

That's the viewport's job.

This distinction sounds small, but it gives us a useful rule:

The canvas renders. The engine decides what the canvas means.


The Camera Is a Matrix

One of the decisions I'm particularly happy with is how we handle panning and zooming.

The tempting approach is:

"The user panned the map, so move every object."

We deliberately don't do that.

MapViewport works with Fabric's:

viewportTransform
Enter fullscreen mode Exit fullscreen mode

The objects stay in their original plan coordinates.

The camera moves.

              Camera
                │
                ▼
        viewportTransform
                │
                ▼
        ┌───────────────┐
        │ Office Floor  │
        │               │
        │  Desk   Desk  │
        │       Room    │
        └───────────────┘
Enter fullscreen mode Exit fullscreen mode

That gives us a useful separation:

Object position = position on the floor

Viewport transform = what the user currently sees
Enter fullscreen mode Exit fullscreen mode

This matters later when we need to save, print, or calculate coordinates.

The desk shouldn't suddenly have different coordinates just because the user zoomed to 200%.


Zoom Isn't Just canvas.setZoom()

The viewport logic also has to deal with constraints.

For example, fitBackground() calculates the scale required to fit the floor plan.

The editor allows zooming below that level, but only within a configured minimum.

The maximum zoom is also limited.

When zooming out, the viewport is gradually pulled back toward the center of the floor plan so the map doesn't drift away into empty space.

relativePan() converts wheel movement into an absolute translation based on the current viewport state.

And after zooming, the transform is normalized again so small numerical errors don't accumulate.

None of this belongs in an Angular component.

It belongs in the viewport.


Interaction Is Another Layer

PanInteraction sits on top of the viewport.

The interaction rules are roughly:

Wheel / trackpad
       ↓
      Pan

Ctrl/Cmd + wheel
       ↓
      Zoom

Space + drag
       ↓
      Pan
Enter fullscreen mode Exit fullscreen mode

While space-dragging is active, we also disable target finding so the pointer doesn't accidentally select a desk while the user is trying to move the camera.

That's another useful property of the architecture.

The interaction logic doesn't need to know how a desk is rendered.

It only needs to know what the viewport can do.


The Registry Is a Pair, Not a Subclass

One of the biggest changes was separating the application entity from the Fabric object.

The new ObjectRegistry stores:

export interface IMapCanvasObject {
  entity: IEntityData;
  presenter: fabric.Object;
  label?: fabric.IText;
}
Enter fullscreen mode Exit fullscreen mode

That's intentionally small.

The entity is our application data:

id
type
name
assignment
viewConfig
...
Enter fullscreen mode Exit fullscreen mode

The presenter is the Fabric representation.

The label is separate as well.

So instead of:

Desk = Fabric.Group
Enter fullscreen mode Exit fullscreen mode

we now have:

Desk entity
    │
    └── Fabric presenter
Enter fullscreen mode Exit fullscreen mode

This is a subtle change, but it gives us something very important:

the domain object doesn't have to be a canvas object.


ObjectRegistry Doesn't Know How to Draw a Desk

The registry only provides operations such as:

register()
getById()
removeById()
getAll()
Enter fullscreen mode Exit fullscreen mode

It doesn't know how to create a desk.

That's handled by MapObjectFactory.

The factory decides which Fabric representation belongs to a particular SpaceType.

This keeps object creation away from the registry and keeps the registry away from rendering details.

It's a small abstraction.

But small abstractions are often the useful ones.


Serialization Is Where the Boundary Gets Interesting

We still have to move data between the application model and Fabric.

When an object changes, serializeMapPresenter() reads properties such as:

left
top
scale
angle
Enter fullscreen mode Exit fullscreen mode

and writes them back into the entity's viewConfig.

Then ObjectModificationInteraction can notify the ChangesTracker that the entity changed.

This means the flow is roughly:

User moves desk
       ↓
Fabric event
       ↓
ObjectModificationInteraction
       ↓
serialize presenter
       ↓
update entity
       ↓
ChangesTracker
Enter fullscreen mode Exit fullscreen mode

Fabric is involved.

But it isn't the source of truth for the whole application.

That's the boundary we were missing before.


There Is Still Some Leakage

The architecture isn't perfect.

For example, presenters still contain:

presenter.data.mapObjectId
presenter.data.mapObjectType
Enter fullscreen mode Exit fullscreen mode

Those values are used to identify the application entity when a Fabric event arrives.

It's a practical lookup key.

But it's also a leak.

Fabric still needs to know which application object generated an event.

We haven't found a better way to completely remove that connection without making event handling more complicated than it needs to be.

And that's okay.

An abstraction doesn't have to hide every implementation detail.

It just needs to hide the details that would otherwise spread everywhere.


ActiveSelection Instead of Group

This was one of those small Fabric.js details that caused a surprisingly important architectural decision.

Fabric normally turns multiple selected objects into a:

fabric.Group
Enter fullscreen mode Exit fullscreen mode

We override that behavior and use:

fabric.ActiveSelection
Enter fullscreen mode Exit fullscreen mode

instead.

Why?

Because a selection and a group are not the same thing in our domain.

A group represents a real relationship between objects.

A pod is a group of desks.

A temporary selection of three desks is not a new domain object.

If we turned the selection into a Group, we'd be changing the structure of the map just because the user selected several objects.

That's not what we want.

So our UnCanvas creates an ActiveSelection instead:

_createGroup(target: fabric.Object) {
  const groupObjects = [
    this._activeObject,
    target,
  ];

  return new fabric.ActiveSelection(groupObjects, {
    canvas: this,
    ...MAP_CONTROL_STYLE,
  });
}
Enter fullscreen mode Exit fullscreen mode

The selected objects remain independent.

The selection is temporary.

The domain model stays intact.


And Then There Are Coordinates

This is another place where Fabric's internals become important.

Inside an ActiveSelection, the selected object's left and top are relative to the selection.

Scale and rotation can also exist on the selection rather than directly on the desk.

So before saving a modified object, we have to flatten the transform.

Our implementation uses:

calcTransformMatrix()
Enter fullscreen mode Exit fullscreen mode

and:

qrDecompose()
Enter fullscreen mode Exit fullscreen mode

to extract the actual transform that belongs to the object.

This is one of those problems that looks trivial until you have a real editor.

The user sees:

"I moved the desk."

Fabric sees:

"A selection with a transformed child was modified."

The application needs to turn the second statement back into the first one.


ChangesTracker: Don't Save the Whole Map

The new architecture also gives us a better place for change tracking.

The old implementation already had a ChangesTracker.

It maintained:

initialState
currentState

CREATE
UPDATE
DELETE
Enter fullscreen mode Exit fullscreen mode

So the idea of sending changes instead of the entire map wasn't new.

What changed was the boundary.

The canvas can change without becoming the application's source of truth.

The flow is now conceptually:

User changes object
        ↓
Fabric
        ↓
MapEngine
        ↓
Entity updated
        ↓
ChangesTracker
        ↓
CREATE / UPDATE / DELETE
        ↓
Save draft
Enter fullscreen mode Exit fullscreen mode

That makes the save operation about what changed, rather than about serializing everything currently sitting on the canvas.


Angular Doesn't Need to Know Fabric

This is probably the biggest architectural improvement.

MapCanvasComponent owns a MapEngine.

It can ask the engine to:

  • initialize;
  • zoom;
  • print;
  • convert client coordinates to canvas coordinates.

MapEditorComponent owns the higher-level editor behavior.

It creates the ChangesTracker, hydrates it from the draft or published map, and attaches it to the engine.

The editor can also provide callbacks for things such as:

  • opening an edit modal;
  • confirming deletion;
  • building a pod;
  • updating sidebar state.

Fabric events stop at the engine boundary.

Angular receives application-level information.

That's the part we wanted.


The Workspace Doesn't Need to Know About Fabric Either

The editor has several workspace modes:

Settings
Arrangement
Print
Enter fullscreen mode Exit fullscreen mode

Each mode has different capabilities.

For example:

Arrangement
├── move
├── delete
├── group
└── add

Print
└── view only
Enter fullscreen mode Exit fullscreen mode

We resolve these capabilities separately and let the engine apply the required locks.

That means we don't need to sprinkle:

if (mode === 'print') ...
Enter fullscreen mode Exit fullscreen mode

through every presenter.

The mode affects capabilities.

It doesn't redefine the canvas architecture.


Was the New Architecture Perfect?

No.

And that's probably the most useful part of the story.

There are still places where Fabric concepts leak through.

The presenter still carries an object ID.

Serialization still has to understand Fabric transforms.

Some legacy coordinate assumptions still exist.

And the old map is still in the repository, which means the new implementation has to understand its coordinate language.

But the important difference is that these problems now have a place to live.

They don't automatically become problems for every Angular component.


What Actually Changed

The biggest improvement wasn't fewer lines of code.

It was the direction of dependencies.

Before:

AngularJS
   ↕
Domain objects
   ↕
Fabric
   ↕
Events
   ↕
Services
Enter fullscreen mode Exit fullscreen mode

Everything knew about everything.

Now:

Angular
   ↓
MapEngine
   ↓
Fabric
Enter fullscreen mode Exit fullscreen mode

with the domain model and change tracking sitting behind explicit boundaries.

That doesn't make the system magically simple.

It makes complexity local.

And that is usually what we actually want from architecture.


Final Thoughts

We didn't replace Fabric.js.

We stopped asking Fabric.js to be our application architecture.

That distinction turned out to be more important than the choice of canvas library itself.

Fabric is very good at being a canvas engine.

It doesn't need to know what a desk means to our business.

It doesn't need to know how drafts are saved.

It doesn't need to know what a workspace mode is.

And Angular doesn't need to know how Fabric calculates a transform matrix.

The goal of MapEngine isn't to hide every Fabric API.

It's to create a boundary where both sides can do what they're good at.

We're still refining that boundary.

But the map editor is now a system we can continue to evolve instead of a collection of UI code sitting directly on top of a canvas.

And honestly, that's probably the biggest difference between the old map and the new one.


This article is based on our experience building and maintaining a production interactive map editor. Code examples have been simplified or anonymized where necessary.

Top comments (0)