lazy-widgets
Version:
Typescript retained mode GUI for the HTML canvas API
286 lines • 13.8 kB
JavaScript
import { BaseXMLUIParser, XML_NAMESPACE_BASE } from './BaseXMLUIParser.js';
import * as widgets from '../widgets/concrete-widgets.js';
import { validateArray } from './validateArray.js';
import { validateBoolean } from './validateBoolean.js';
import { validateBox } from './validateBox.js';
import { validateFunction } from './validateFunction.js';
import { validateImageSource } from './validateImageSource.js';
import { validateKeyboardDriver } from './validateKeyboardDriver.js';
import { validateKeyContext } from './validateKeyContext.js';
import { validateLayerInit } from './validateLayerInit.js';
import { validateLayoutConstraints } from './validateLayoutConstraints.js';
import { validateNullable } from './validateNullable.js';
import { validateNumber } from './validateNumber.js';
import { validateObject } from './validateObject.js';
import { validateObservable } from './validateObservable.js';
import { validateString } from './validateString.js';
import { validateTheme } from './validateTheme.js';
import { validateValidatedBox } from './validateValidatedBox.js';
import { validateWidget } from './validateWidget.js';
import { fromKebabCase } from '../helpers/fromKebabCase.js';
import { WHITESPACE_REGEX } from '../helpers/whitespace-regex.js';
/**
* Deserializes an XML <layer> element, as a {@link LayerInit} value.
*
* @param parser - The parser that is calling this deserializer
* @param context - The current parser context, shared with all other initializations
* @param elem - The element to deserialize
* @returns Returns a new LayerInit instance, to be used as a parameter
*
* @category XML
*/
export function deserializeLayerElement(parser, context, elem) {
// parse attributes
let child, name, canExpand;
for (const attr of elem.attributes) {
// ignore attributes that arent using the default/wanted namespace
if (attr.namespaceURI !== null && attr.namespaceURI !== XML_NAMESPACE_BASE) {
continue;
}
// parse values
if (attr.localName === 'child') {
if (child !== undefined) {
throw new Error('Only one child can be specified per layer');
}
child = validateWidget(parser.parseAttributeValue(attr.value, context))[0];
}
else if (attr.localName === 'name') {
if (name !== undefined) {
throw new Error('Only one name can be specified per layer');
}
name = validateString(parser.parseAttributeValue(attr.value, context))[0];
}
else if (attr.localName === 'can-expand') {
if (canExpand !== undefined) {
throw new Error('Only one can-expand option can be specified per layer');
}
canExpand = validateBoolean(parser.parseAttributeValue(attr.value, context))[0];
}
else {
throw new Error(`Unknown layer attribute "${attr.localName}"`);
}
}
// parse children
for (const childNode of elem.childNodes) {
const nodeType = childNode.nodeType;
if (nodeType === Node.ELEMENT_NODE) {
// ignore non-lazy-widgets nodes
const childElem = childNode;
if (childElem.namespaceURI !== XML_NAMESPACE_BASE) {
continue;
}
if (child !== undefined) {
throw new Error('Only one child can be specified per layer');
}
child = parser.parseWidgetElem(context, childElem);
}
else if (nodeType === Node.COMMENT_NODE) {
continue;
}
else if (nodeType === Node.TEXT_NODE) {
if (!WHITESPACE_REGEX.test(childNode.data)) {
throw new Error('Unexpected text node as layer child');
}
}
else {
console.log(childNode);
throw new Error('Unexpected junk as layer node child');
}
}
// done
if (child === undefined) {
throw new Error('Layer must have a child. Either add it as an XML node, or pass it as the "child" attribute via a variable');
}
return { child, name, canExpand };
}
/**
* Deserializes an attribute with an options prefix. The value will be added as
* a field in an options object, or completely replace the options object.
*
* @param parser - The parser that is calling this deserializer
* @param context - The current parser context, shared with all other initializations
* @param instantiationContext - The current parser context, only available to this instantiation
* @param attribute - The attribute to deserialize - the attribute name decides the options object field, but `_` replaces the options object
*
* @category XML
*/
export function deserializeOptionsAttribute(parser, context, instantiationContext, attribute) {
// this attribute sets an options object's field. record it in the
// instantiation context so it can be added to the parameters list later
if (attribute.localName === '_') {
if (instantiationContext.optionsReplaced) {
throw new Error('Can\'t replace options object; options object has already been replaced with "option:_"');
}
if ('options' in instantiationContext && Object.getOwnPropertyNames(instantiationContext.options).length > 0) {
throw new Error("Can't replace options object; can't replace options object if individual options are being set with \"option:...\"");
}
instantiationContext.optionsReplaced = true;
const optionsValue = parser.parseAttributeValue(attribute.value, context);
instantiationContext.options = validateObject(optionsValue)[0];
}
else {
if (instantiationContext.optionsReplaced) {
throw new Error(`Can't add option "${attribute.localName}"; can't set individual options if the options object is replaced with "option:_"`);
}
const nameConverted = fromKebabCase(attribute.localName);
if ('options' in instantiationContext) {
const options = instantiationContext.options;
if (nameConverted in options) {
throw new Error(`Can't add option "${attribute.localName}"; the option has already been set`);
}
options[nameConverted] = parser.parseAttributeValue(attribute.value, context);
}
else {
instantiationContext.options = {
[nameConverted]: parser.parseAttributeValue(attribute.value, context)
};
}
}
}
/**
* Deserializes an attribute with an event on/once prefix. The attribute will
* decide which event listener to add to the instance post-initialization. The
* listeners will be added to the instantiation context for later use.
*
* @param once - Should `once` be set to true in {@link Widget#on}?
* @param parser - The parser that is calling this deserializer
* @param context - The current parser context, shared with all other initializations
* @param instantiationContext - The current parser context, only available to this instantiation
* @param attribute - The attribute to deserialize - the attribute name decides the event type to listen to
*
* @category XML
*/
export function deserializeEventAttribute(once, parser, context, instantiationContext, attribute) {
// get listener callback
const callback = validateFunction(parser.parseAttributeValue(attribute.value, context))[0];
// add to instantiation context so it can be added later
const listenTuple = [attribute.localName, callback, once];
const listeners = instantiationContext.listeners;
if (listeners === undefined) {
instantiationContext.listeners = [listenTuple];
}
else {
listeners.push(listenTuple);
}
}
/**
* Adds an options object to the end of an argument list.
*
* @param _parser - The parser that is calling this deserializer (unused)
* @param _context - The current parser context, shared with all other initializations (unused)
* @param instantiationContext - The current parser context, only available to this instantiation
* @param args - The argument list that will be modified
*
* @category XML
*/
export function addOptionsObjectToArguments(_parser, _context, instantiationContext, args) {
// add options object to end of argument list
if ('options' in instantiationContext) {
args.push(instantiationContext.options);
}
else {
args.push({});
}
}
/**
* Adds a list of event listeners to a widget instance. The event listeners will
* be retrieved from the instantiation context.
*
* @param _parser - The parser that is calling this deserializer (unused)
* @param _context - The current parser context, shared with all other initializations (unused)
* @param instantiationContext - The current parser context, only available to this instantiation
* @param instance - The widget instance for which the event listeners will be added to
*
* @category XML
*/
export function addEventListenersToWidget(_parser, _context, instantiationContext, instance) {
// add listeners to instance
if ('listeners' in instantiationContext) {
const listeners = instantiationContext.listeners;
for (const [eventType, callback, once] of listeners) {
instance.on(eventType, callback, once);
}
}
}
/**
* An XML UI parser.
*
* Unlike {@link BaseXMLUIParser}:
*
* - All default widgets are pre-registered
* - `<layer>` elements are treated as LayerInit parameters ("layer" parameter mode)
* - Attribute values starting with a backslash are always treated as strings, with the backslash removed
* - Attribute values starting with a dollar sign are always treated as variables
* - Attribute values starting with an at sign are always treated as JSON-encoded values
* - Attributes with the `lazy-widgets:options` namespace will be added to an options object and passed to a widget factory
* - Attributes with the `lazy-widgets:on` namespace will add event listeners to a widget
* - Similarly, attributes with the `lazy-widgets:once` namespace will add event listeners to a widget, but with `once` set to true
* - A lot of built-in validators are pre-registered
*
* @category XML
*/
export class XMLUIParser extends BaseXMLUIParser {
constructor() {
super();
// register parameter modes
// allow passing 'layer' mode parameters
this.registerParameterMode('layer', (_p, _c, paramConfig, value) => {
const layerInit = validateLayerInit(value)[0];
const validator = paramConfig.validator;
if (validator) {
return validator(layerInit);
}
else {
return layerInit;
}
}, true, true);
// register element deserializers
// allow having 'layer' child elements that act as layer arguments
this.registerElementDeserializer('layer', 'layer', deserializeLayerElement);
// register attribute value deserializers
// treat an argument as a string if it starts with a backslash
this.registerAttributeValueDeserializer('\\', (_p, _c, value) => value);
// treat an argument as JSON-encoded if it starts with an at sign
this.registerAttributeValueDeserializer('@', (_p, _c, value) => JSON.parse(value));
// treat an argument as a variable if it starts with a dollar sign
this.registerAttributeValueDeserializer('$', (_p, context, value) => {
if (!context.variableMap.has(value)) {
throw new Error(`Variable "${value}" does not exist`);
}
return context.variableMap.get(value);
});
// register attribute namespace deserializers
// allow options namespace to pass values to the options object. this
// also requires registering a parameter modifier
this.registerAttributeNamespaceHandler(`${XML_NAMESPACE_BASE}:options`, deserializeOptionsAttribute);
this.registerArgumentModifier(addOptionsObjectToArguments);
// allow the on and once namespaces to add an event listener. this also
// requires registering a post-init hook
this.registerAttributeNamespaceHandler(`${XML_NAMESPACE_BASE}:on`, deserializeEventAttribute.bind(this, false));
this.registerAttributeNamespaceHandler(`${XML_NAMESPACE_BASE}:once`, deserializeEventAttribute.bind(this, true));
this.registerPostInitHook(addEventListenersToWidget);
// register built-in validators
this.registerValidator('array', validateArray);
this.registerValidator('boolean', validateBoolean);
this.registerValidator('function', validateFunction);
this.registerValidator('image-source', validateImageSource);
this.registerValidator('keyboard-driver', validateKeyboardDriver);
this.registerValidator('key-context', validateKeyContext);
this.registerValidator('layer-init', validateLayerInit);
this.registerValidator('layout-constraints', validateLayoutConstraints);
this.registerValidator('nullable', validateNullable);
this.registerValidator('number', validateNumber);
this.registerValidator('object', validateObject);
this.registerValidator('observable', validateObservable);
this.registerValidator('string', validateString);
this.registerValidator('theme', validateTheme);
this.registerValidator('widget', validateWidget);
this.registerValidator('box', validateBox);
this.registerValidator('validated-box', validateValidatedBox);
// register factories for default widgets
for (const ctor of Object.values(widgets)) {
this.autoRegisterFactory(ctor);
}
}
}
//# sourceMappingURL=XMLUIParser.js.map