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

  1. Requirements
  2. Change History
    1. 204072001 -> 2024073001
  3. Model
    1. GUIDs
  4. Flow
  5. Enabling the feature
  6. Unsupported Uses
    1. Alternatives
  7. Subscribe to the events
  8. Get or set the page IDs
    1. GetPages
    2. SetPages
    3. Example: Combining GetPages and SetPages
  9. Showing the correct content
  10. The pageChanged event
  11. The pagesChanged event
  12. Communicating between peer instances
    1. SendMessageToPeers
    2. RequestPageChange
    3. The peerMessage event
  13. Testing
  14. A full example

Requirements

This feature requires:

  • OpenKneeboard v1.9 or above
  • the experimental feature PageBasedContent version 2024073001

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 with Crypto.randomUUID()

Within a single ‘web dashboard’ tab:

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 the GetPages() 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

  1. Enable the feature
  2. Subscribe to the events
  3. Get or set the page IDs
  4. 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 in GetPages() and related events.
  • width and height must be whole numbers that are greater than or equal to 1
  • extraData 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:

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: