@teipublisher/pb-components
Version:
Collection of webcomponents underlying TEI Publisher
470 lines (432 loc) • 15.3 kB
JavaScript
import { cmpVersion } from './utils.js';
if (!window.TeiPublisher) {
window.TeiPublisher = {};
TeiPublisher.url = new URL(window.location.href);
}
/**
* Global set to record the names of the channels for which a
* `pb-ready` event was fired.
*/
const readyEventsFired = new Set();
/**
* Gobal map to record the initialization events which have
* been received.
*/
const initEventsFired = new Map();
export const defaultChannel = '__default__';
export function clearPageEvents() {
initEventsFired.clear();
}
/**
* Wait until the global event identified by name
* has been fired once. This is mainly used to wait for initialization
* events like `pb-page-ready`.
*
* @param {string} name
* @param {Function} callback
*/
export function waitOnce(name, callback) {
if (initEventsFired.has(name)) {
callback(initEventsFired.get(name));
} else {
document.addEventListener(name, (ev) => {
initEventsFired.set(name, ev.detail);
callback(ev.detail);
}, {
once: true
});
}
}
/**
* Get the list of channels this element emits to.
*
* @param {HTMLElement} elem the emitting element
* @returns {String[]} an array of channel names
*/
export function getEmittedChannels(elem) {
const emitConfig = elem.getAttribute('emit-config');
if (emitConfig) {
const json = JSON.parse(emitConfig);
return Object.keys(json);
}
const emitAttr = elem.getAttribute('emit');
if (emitAttr) {
return [emitAttr]
}
return [defaultChannel];
}
/**
* Get the list of channels this element subscribes to.
*
* @param {HTMLElement} elem the subscribing element
* @returns {String[]} an array of channel names
*/
export function getSubscribedChannels(elem) {
const subscribeConfig = elem.getAttribute('subscribe-config');
if (subscribeConfig) {
const json = JSON.parse(subscribeConfig);
return Object.keys(json);
}
const subscribeAttr = elem.getAttribute('subscribe');
if (subscribeAttr) {
return [subscribeAttr];
}
return [defaultChannel];
}
/**
* Implements the core channel/event mechanism used by components in TEI Publisher
* to communicate.
*
* As there might be several documents/fragments being displayed on a page at the same time,
* a simple event mechanism is not enough for components to exchange messages. They need to
* be able to target a specific view. The mechanism implemented by this mixin thus combines
* events and channels. Components may emit an event into a named channel to which other
* components might subscribe. For example, there might be a view which subscribes to the
* channel *transcription* and another one subscribing to *translation*. By using distinct
* channels, other components can address only one of the two.
*
* @polymer
* @mixinFunction
*/
export const pbMixin = (superclass) => class PbMixin extends superclass {
static get properties() {
return {
/**
* The name of the channel to subscribe to. Only events on a channel corresponding
* to this property are listened to.
*/
subscribe: {
type: String
},
/**
* Configuration object to define a channel/event mapping. Every property
* in the object is interpreted as the name of a channel and its value should
* be an array of event names to listen to.
*/
subscribeConfig: {
type: Object,
attribute: 'subscribe-config'
},
/**
* The name of the channel to send events to.
*/
emit: {
type: String
},
/**
* Configuration object to define a channel/event mapping. Every property
* in the object is interpreted as the name of a channel and its value should
* be an array of event names to be dispatched.
*/
emitConfig: {
type: Object,
attribute: 'emit-config'
},
/**
* A selector pointing to other components this component depends on.
* When method `wait` is called, it will wait until all referenced
* components signal with a `pb-ready` event that they are ready and listening
* to events.
*/
waitFor: {
type: String,
attribute: 'wait-for'
},
_isReady: {
type: Boolean
},
/**
* Common property to disable the functionality associated with a component.
* `pb-highlight` and `pb-popover` react to this.
*/
disabled: {
type: Boolean,
reflect: true
},
_endpoint: {
type: String
},
_apiVersion: {
type: String
}
}
}
constructor() {
super();
this._isReady = false;
this.disabled = false;
this._subscriptions = new Map();
}
connectedCallback() {
super.connectedCallback();
waitOnce('pb-page-ready', (options) => {
this._endpoint = options.endpoint;
this._apiVersion = options.apiVersion;
});
}
disconnectedCallback() {
super.disconnectedCallback();
this._subscriptions.forEach((handlers, type) => {
handlers.forEach((handler) => {
document.removeEventListener(type, handler);
});
});
}
/**
* Enable or disable certain features of a component. Called by `pb-toggle-feature`
* and `pb-select-feature` to change the components behaviour.
*
* By default only one command is known: `disable` will disable any interactive feature
* of the component.
*
* @param {string} command name of an action to take or setting to be toggled
* @param {Boolean} state the state to set the setting to
*/
command(command, state) {
if (command === 'disable') {
this.disabled = state;
}
}
/**
* Wait for the components referenced by the selector given in property `waitFor`
* to signal that they are ready to respond to events. Only wait for elements which
* emit to one of the channels this component subscribes to.
*
* @param {Function} callback function to be called when all components are ready
*/
wait(callback) {
const _checkAndWait = () => {
const targetNodes = Array.from(document.querySelectorAll(this.waitFor));
const targets = targetNodes.filter(target => this.emitsOnSameChannel(target));
const targetCount = targets.length;
if (targetCount === 0) {
// selector did not return any targets
callback();
return;
}
let count = targetCount;
targets.forEach((target) => {
if (target._isReady) {
count -= 1;
if (count === 0) {
callback();
}
return;
}
const handler = target.addEventListener('pb-ready', (ev) => {
if (ev.detail.source === this) {
// same source: ignore
return;
}
count -= 1;
if (count === 0) {
target.removeEventListener('pb-ready', handler);
callback();
}
});
});
}
if (!this.waitFor) {
callback();
return;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
_checkAndWait();
});
} else {
_checkAndWait();
}
}
/**
* Wait until a `pb-ready` event is received from one of the channels
* this component subscribes to. Used internally by components which depend
* on a particular `pb-view` to be ready and listening to events.
*
* @param callback function to be called when `pb-ready` is received
*/
waitForChannel(callback) {
// check first if a `pb-ready` event has already been received on one of the channels
if (this.subscribeConfig) {
for (const key in this.subscribeConfig) {
this.subscribeConfig[key].forEach(t => {
if (t === 'pb-ready' && readyEventsFired.has(key)) {
return callback();
}
});
}
} else if (
(this.subscribe && readyEventsFired.has(this.subscribe)) ||
(!this.subscribe && readyEventsFired.has('__default__'))
) {
return callback();
}
const listeners = this.subscribeTo('pb-ready', (ev) => {
if (ev.detail._source == this) {
return;
}
listeners.forEach(listener => document.removeEventListener('pb-ready', listener));
callback();
});
}
/**
* Wait until the global event identified by name
* has been fired once. This is mainly used to wait for initialization
* events like `pb-page-ready`.
*
* @param {string} name
* @param {Function} callback
* @deprecated Use exported `waitOnce` function
*/
static waitOnce(name, callback) {
waitOnce(name, callback);
}
/**
* Signal that the component is ready to respond to events.
* Emits an event to all channels the component is registered with.
*/
signalReady(name = 'pb-ready', data) {
this._isReady = true;
initEventsFired.set(name, data);
this.dispatchEvent(new CustomEvent(name, { detail: { data, source: this } }));
this.emitTo(name, data);
}
/**
* Check if the other element emits to one of the channels this
* element subscribes to.
*
* @param {Element} other the other element to compare with
*/
emitsOnSameChannel(other) {
const myChannels = getSubscribedChannels(this);
const otherChannels = getEmittedChannels(other);
if (myChannels.length === 0 && otherChannels.length === 0) {
// both emit to the default channel
return true;
}
return myChannels.some((channel) => otherChannels.includes(channel));
}
/**
* Listen to the event defined by type. If property `subscribe` or `subscribe-config`
* is defined, this method will trigger the listener only if the event has a key
* equal to the key defined in `subscribe` or `subscribe-config`.
*
* @param {String} type Name of the event, usually starting with `pb-`
* @param {Function} listener Callback function
* @param {String[]} [channels] Optional: explicitely specify the channels to emit to. This overwrites
* the emit property. Pass empty array to target the default channel.
*/
subscribeTo(type, listener, channels) {
let chs;
if (channels) {
chs = channels.length === 0 ? [defaultChannel] : channels;
} else {
chs = getSubscribedChannels(this);
}
const handlers = chs.map(key => {
const handle = ev => {
if (!ev.detail || !ev.detail.key || ev.detail.key !== key) {
return;
}
listener(ev);
};
document.addEventListener(type, handle);
return handle;
});
// add new handlers to list of active subscriptions
this._subscriptions.set(type, handlers);
return handlers;
}
/**
* Dispatch an event of the given type. If the properties `emit` or `emit-config`
* are defined, the event will be limited to the channel specified there.
*
* @param {String} type Name of the event, usually starting with `pb-`
* @param {Object} [options] Options to be passed in ev.detail
* @param {String[]} [channels] Optional: explicitely specify the channels to emit to. This overwrites
* the 'emit' property setting. Pass empty array to target the default channel.
*/
emitTo(type, options, channels) {
let chs;
if (channels) {
chs = channels.length === 0 ? [defaultChannel] : channels;
} else {
chs = getEmittedChannels(this);
}
chs.forEach(ch => this._emit(ch, type, options));
}
_emit(key, type, options) {
if (type === 'pb-ready') {
readyEventsFired.add(key);
}
// eslint-disable-next-line prefer-object-spread
const detail = Object.assign({ key, _source: this }, options);
const ev = new CustomEvent(type, {
detail,
composed: true,
bubbles: true
});
this.dispatchEvent(ev);
}
/**
* Returns the `pb-document` element this component is connected to.
*
* @returns the document component or undefined if not set/found
*/
getDocument() {
if (this.src) {
const doc = document.getElementById(this.src);
if (doc) {
return doc;
}
}
return null;
}
getParameter(name, fallback) {
const params = TeiPublisher.url.searchParams && TeiPublisher.url.searchParams.getAll(name);
if (params && params.length == 1) {
return params[0];
} else if (params && params.length > 1) {
return params
}
return fallback;
}
getParameters() {
const params = {};
for (let key of TeiPublisher.url.searchParams.keys()) {
params[key] = this.getParameter(key);
}
return params;
}
getUrl() {
return TeiPublisher.url;
}
getEndpoint() {
return this._endpoint;
}
toAbsoluteURL(relative, server) {
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(relative)) {
return relative;
}
const endpoint = server === '' ? '' : (server || this.getEndpoint());
let base;
if (endpoint === '.') {
base = new URL(window.location.href);
// loaded in iframe
} else if (window.location.protocol === 'about:') {
base = document.baseURI
} else {
base = new URL(`${endpoint}/`, `${window.location.protocol}//${window.location.host}`);
}
return new URL(relative, base).href;
}
minApiVersion(requiredVersion) {
return cmpVersion(this._apiVersion, requiredVersion) >= 0;
}
lessThanApiVersion(requiredVersion) {
return cmpVersion(this._apiVersion, requiredVersion) < 0;
}
compareApiVersion(requiredVersion) {
return cmpVersion(this._apiVersion, requiredVersion);
}
}