Page-Based Web Applications
THIS IS AN EXPERIMENTAL FEATURE and may change between any two versions without notice; feedback and questions are very welcome in #code-talk
on Discord.
This experimental API allows a web page to work like a native OpenKneeboard tab type with multiple pages of varying sizes (like a PDF document), instead of a potentially-scrollable content that must fit a variable-sized browser.
Table of Contents
- Requirements
- Change History
- Model
- Flow
- Enabling the feature
- Unsupported Uses
- Subscribe to the events
- Get or set the page IDs
- Showing the correct content
- The pageChanged event
- The pagesChanged event
- Communicating between peer instances
- Testing
- A full example
Requirements
This feature requires:
- OpenKneeboard v1.9 or above
- the experimental feature
PageBasedContent
version2024073001
Change History
204072001 -> 2024073001
- Added
OpenKneeboard.RequestPageChange()
Model
Page-based web applications in OpenKneeboard are essentially a Single Page Application (SPA), with several key differences:
- like a PDF, the document is formed of multiple discrete pages, rather than a continuously-scrollable canvas in a standard web page
- like a PDF, every page in the document can be a different size and aspect ratio, rather than fitting the size of the browser window
- OpenKneeboard-specific APIs must be used
- If the user has multiple views (e.g. ‘dual kneeboards’), each view will have it’s own browser instance running your page. This is similar to having the same page open in multiple browsers at the same time - there is no implicit synchronization, however, standard techniques like local storage and websockets are available. Additionally, OpenKneeboard provides a basic message-passing API.
Multiple instances of your app - corresponding to multiple OpenKneeboard views - are called ‘peers’ in this API.
For this API, pages are identified by GUIDs, a.k.a. UUIDs, not by page number or index; this is a little more work to get started with, but avoids complicated issues if you choose to remove pages, or insert pages other than at the end.
GUIDs
- OpenKneeboard accepts GUIDs with or without braces, e.g.
12c5b710-bae4-4c60-b5dc-1a8c32a0797a
and{12c5b710-bae4-4c60-b5dc-1a8c32a0797a}
are considered equivalent - For the JS APIs, OpenKneeboard will always return GUIDs without braces, e.g.
12c5b710-bae4-4c60-b5dc-1a8c32a0797a
. This is to allow easy interoperability withCrypto.randomUUID()
Within a single ‘web dashboard’ tab:
- a specific page must have the same GUID in all browser instances; this is managed via the
GetPages()
andSetPages()
APIs andpagesChanged
event - each page must have a unique GUID
If your page is added as multiple web dashboard tabs, these instances are isolated, and there is no need for page GUIDs to be unique between multiple web dashboard instances.
In practical terms:
- if you have a fixed set of pages, you can hard-code GUIDs - but they should be unique to your project, i.e. freshly generated at random while you are writing your code
- if you have a variable number of pages (e.g. if you implemented something like the ‘endless notebook tabs), you can use
Crypto.randomUUID()
to generate a unique ID for each page - but you must use theGetPages()
API to re-use the same GUID between multiple browser instances in the same web dashboard - you can also use the
Crypto.randomUUID()
approach if you have a fixed set of pages; hard-coding may be simpler but isn’t required
Flow
- Enable the feature
- Subscribe to the events
- Get or set the page IDs
- Adjust displayed content in response to events
Enabling the feature
As this is an experimental feature, it may change without notice, and it must be explicitly enabled before use:
try {
await OpenKneeboard.EnableExperimentalFeatures([
{ name: 'PageBasedContent', version: 2024073001 },
]);
} catch (ex) {
FallbackWithoutUsingThisFeature();
}
Unsupported Uses
You MUST NOT:
- consider the page changed until the
pageChanged
event is triggered - use pages/events as a generic input (‘I want a binding in JS’ method). It is not a usable generic way to get two bindable buttons in your page
- automatically create new pages in response to page changes, i.e. you can not have an ‘infinite’ set of pages
Breaking these requirements may lead to:
- your app crashing OpenKneeboard when the built-in navigation page is opened
- making OpenKneeboard unusable for people who use the remote controls/API, e.g. with VoiceAttack
- making OpenKneeboard unusable for people who have ‘next page at end of tab -> next tab’ behavior enabled
- other features/expectations breaking, crashes, undefined behavior.
Please follow these restrictions; for now, I am trusting plugin authors, because adding restrictions will also make it less useful for valid use cases. If this trust-first approach leads to an increased support workload for OpenKneeboard, restrictions are likely. For example, if plugin misuse of RequestPageChanged()
leads to increased support time, future versions may ignore that API unless it is within 100ms of a cursor event or custom action.
Alternatives
- For “I want more user input”, use plugin custom actions
- While you must not have infinite pages, or create new pages in reaction to page changes events, you can increase the set of pages in response to other actions. For example, the built-in endless notebook tab type adds a new page when you draw on the last page.
- A common desire is to use pages for zoom levels; this works fine for a fixed number of zoom levels. If you want infinite zoom, you mus use plugin custom actions instead.
Subscribe to the events
You should subscribe to the events immediately after enabling the feature - especially before making any SetPages()
or GetPages()
API calls.
OpenKneeboard.addEventListener('pageChanged', MyPageChangedCallback);
OpenKneeboard.addEventListener('pagesChanged', MyPagesChangedCallback);
OpenKneeboard.addEventListener('peerMessage', MyPeerMessageCallback);
The pageChanged
event is always required; the other two events will only be required by some implementations.
For details, see:
Get or set the page IDs
A page is defined with an object of this form:
{
guid: string,
pixelSize: { width: integer, height: integer },
extraData: any,
}
guid
must be a valid GUID. Brackets may be included, but they will be stripped inGetPages()
and related events.width
andheight
must be whole numbers that are greater than or equal to 1extraData
is optional, and can contain any JSON-encodable value. It will not be processed by OpenKneeboard, but will be returned verbatim in the various other APIs. This is useful if you want to internally identify pages by something other than a GUID - for example, an image filename
If the user has configured multiple views in OpenKneeboard, there may be multiple browsers running your application, and you must make sure that the page IDs are shared. For this reason, you should always call GetPages()
before SetPages()
, and use the pre-existing IDs if any. You should only call SetPages()
if GetPages()
did not return any page IDs - this implies that the current brower instance is the first browser instance for your application.
GetPages
OpenKneeboard.GetPages()
returns a Promise
, which resolves to a structure of this form:
{
havePages: Boolean,
?pages: array<Page>
}
The pages
key is present if havePages
is true
; you should check havePages
before checking the pages
key.
SetPages
SetPages()
takes an array of pages, and returns a Promise<any>
.
You may call SetPages()
multiple times, e.g. to append, insert, remove, or replace pages.
Example: Combining GetPages and SetPages
const existingPages = await OpenKneeboard.GetPages();
if (existingPages.havePages) {
MyApp_InitializeWithPageIDs(existingPages.pages);
} else {
const pages = [
{
guid: crypto.randomUUID(),
pixelSize: { width: 1024, height: 768 },
}
];
await OpenKneeboard.SetPages(pages);
MyApp_InitializeWithPageIDs(pages);
}
Your app may want to initalize pages once it have the IDs, store a mapping between existing DOM elements and page IDs, or any other approach that makes sense for your app.
Showing the correct content
When the page is changed in a view, OpenKneeboard will send a pageChanged
event; you should show/hide/otherwise-update what your application displays in response to this event; for example:
async function OnPageChanged(ev) {
const guid = ev.detail.page.guid;
for (const pageDiv of document.getElementsByClassName('page')) {
pageDiv.classList.toggle('hidden', guid == pageDiv.id);
}
}
OpenKneeboard.addEventListener('pageChanged', OnPageChanged);
The pageChanged event
This event on the OpenKneeboard
object is a CustomEvent
; the detail
property of the event contains a page
element, containing a page with guid
, pixelSize
, and optional extraData
, as provided in a call to SetPage()
by this instance of your application or a peer instance.
The pagesChanged event
This event on the OpenKneeboard
object is a CustomEvent
; the detail
property contains an object with a pages
property, which is an array of page structures, each with guid
, pixelSize
, and extraData
.
You most likely do not need to handle this event unless your application calls SetPages()
multiple times.
Communicating between peer instances
OpenKneeboard does not provide any implicit synchronization between peer instances - if any synchronization is required, you must do this in your application code.
Some possible approaches include:
- standard web technologies like websockets and local storage
OpenKneeboard.SendMessageToPeers()
combined with thepeerMessage
event
SendMessageToPeers
This function sends a message to all peer instances, which they can choose to receive by listening for the peerMessage
event.
// Syntax:
OpenKneeboard.SendMessageToPeers(message: any): Promise<any>;
// Example:
await OpenKneeboard.SendMesageToPeers("hello, world");
- The
message
parameter is any JSON-encodable value. - This is a ‘fire-and-forget’ function; no indication is given whether or not peers received the message, or encountered any issues
RequestPageChange
This function requests a page change; OpenKneeboard might or might not honor the request, depending on the current state, and user preferences. If it is honored, the pageChanged
event will be triggered.
// Syntax:
OpenKneeboard.RequestPageChange(guid: string): Promise<any>;
// Example:
pages = (await OpenKneeboard.GetPages()).pages;
await OpenKneeboard.RequestPageChange(pages[1].guid);
The peerMessage event
This is a CustomEvent
; the detail
property contains an object with a message
property, containing the message that was passed to SendMessageToPeers()
, verbatim.
// Example:
OpenKneeboard.addEventListener(
'peerMessage',
(ev) => { console.log(ev.detail.message); }
);
Testing
You should test your dashboard works correctly with multiple views; you can either do this in VR, or with the test viewer application.
To use the test viewer:
- hit ‘i’ to show/hide information such as resolution and the current view
- hit ‘v’ to switch between mirroring the VR and non-VR views
- hit ‘1’ - ‘9’ to switch between views
Keep in mind:
- the reported resolution will usually not exactly match the pixel size you requested, as it will also include OpenKneeboard’s in-game UI, such as the toolbar and footer
- as of 2024-07-21, only the first view is available in non-VR, so you will usually want the viewer to be mirroring the VR views
Testing multiple VR views via the test viewer does not require a VR headset or drivers.
A full example
api-example-PageBasedContent.html
is included in:
- the
data
subfolder of the OpenKneeboard source tree - the
share\doc\
subfolder of an OpenKneeboard installation