Uxtly Internals
Desktop-like SPA Architecture
Uxtly is a single-page application that:
- Has a data driven UI
- Works offline; it’s a PWA
- Has unlimited undo
- Saves to files using File System Access API
- Automatically saves in the IndexedDB
Let’s start with the continuous saving mechanism, for which there are three general options:
- Option 1: Collecting how to reproduce each action.
- Option 2: Saving a complete snapshot after every action.
- Option 3: A hybrid, such as saving on immutable data structures, for instance, Immutable.js.
We’ll discuss Option 1 as it’s the most simple and versatile.
Reproducing Actions
Let’s call Undo Frame to the data needed for replaying an action. For example,
the following frame updates the title
field of a Card with a certain id.
[ [BASE.setCard, 'idABC', CF.title, 'The New Title'] ]
This way undo is a matter of reverting to a previous snapshot, and replaying each change up to the penultimate.
Some undo steps have many actions. For example, moving an Entry to another Card.
Behind the scenes that function has a removeEntry('cardA', …)
and an insertEntry('cardB', …)
. Therefore, we
use a transaction to collect all the calls to the underlying
BASE
setters into a single undo frame.
function moveEntryToAnotherCard(fromCardId, entryId, toCardId) { const endTx = TransactionRecorder(); // … _removeEntry(fromCardId, entryId); _insertEntry(toCardId, …); endTx(); }
Caveats
There’s an important part missing in the previous snippet. How to copy the properties of the removed Entry?
In contrast to an immutable data structure, Option 3, we can’t simply edit non-primitives, such as objects and arrays. Instead, we have to create a new one. Otherwise, the edit would inadvertently change them in the undo stack as well.
For example, outputLinks
is an array of Entry Pointer
objects. So to move an Entry to another Card, we have to deep
clone that array, and assign it to the newly inserted Entry.
Pros
The undo frames are extendable with metadata. For example, for squashing many frames into one, such as a burst of a slider.
As a side note, they could also be used for synchronizing multiple users in real-time. It’s not implemented in UI Drafter, but it’s the basis of Meteor’s Distributed Data Protocol.
Data Flow
Frontend
These are the UI components, for example:
- card/Card.js
- toolbar/PreviewButton.js
The frontend memory objects, UI States, are non-undoable actions,
for example whichMenuIsOpen
or whatIsSelected
.
Some of them are persisted to the browser’s localStorage
,
like toolbarIsVisible
and scrollbarsAreVisible
.
React.js
React-wise, the state-controlled components need static getDerivedStateFromProps(props, state)
in order to restore a previous value when undoing, and for real-time updates.
Open Source
The reactive-state
library has more details.
Layout Rendering
In the main board, Cards are absolutely positioned. For instance, it could be
painted in an HTML <canvas>
. The main reason is performance. This
way there’s no need to query the DOM, which is a slow operation, because we can
compute the position of the connectors, among other elements from plain JS objects.
Middleware (Business Layer)
The middleware files are the ‘app-specific’ algorithms. I think of them as the engine, or as the fun algorithms to write. The rule is that they don’t query or need the UI; they are exclusively fed from the stores. Here are some examples:
connectors/
locateConnectorPoints.js
It’s used for drawing the connections, and for finding the Entries within a marquee region.
numeric/
computeAllCards.js
Determines the dependency order for computing formulas and evaluating user-JavaScript. For example, Nested Cards are like parentheses, so the deepest ones get computed first.
links/
LoopDetection.js
The loop detection algorithms prevent creating connections that would cause infinite loops. For example, when trying to link a total that depends on another Card into that Card.
Memory
The middleware engine has some memory objects too. For example, for the connector points, and caching payload relevant fields for Cards with JavaScript code.
Stores API
The stores memory contains the end-user File. Its data is JSON-safe, and it’s
saved automatically in the browser’s IndexedDB
after every change,
Option 2. Therefore, if there are many tabs with the same file open, the
one on the last edited tab wins. Also, the corresponding undo stack is saved
there too, so it can be restored across sessions for unlimited undo levels.
Although Uxtly doesn’t save to the server-side, that wouldn’t be too different from saving to the local file system.
By convention, these files are under **/api
directories. For example:
- app/api/BaseSetters.js
- card/api/CardGetters.js
- card/api/internal/_updateCardTitle.js
Our indexedDB
library is open-source.
Compressor
The compressor is mainly for removing the default Card and Entry fields. You can see that compressed JSON file by clicking File → Save.
Sanitization
To prevent prototype pollution attacks, all the constructors
are instantiated with their defaults, and sealed with
Object.seal
, before assigning or merging new properties.
End
Don’t forget to check out Uxtly, uxtly.com