/**
* Common Error thrown in JSON requests with failures (via fetchJSON(...)
* The content is not guaranteed to be translated.
* @class HTTPError
*/
window.HTTPError = class extends Error {
constructor(message, response, textData) {
super();
this.message = message;
this.response = response;
this.textData = textData;
this.statusCode = response && response.status || 500;
}
};
/**
* Init loading system in xOpat. Do not use in the viewer, use only if you
* manually want to reuse plugins/modules elsewhere.
* IMPORTANT
* Use: const initPlugins = initXOpatLoader(PLUGINS, MODULES, PLUGINS_FOLDER, MODULES_FOLDER, VERSION);
* call when all ready: initPlugins();
* @param PLUGINS
* @param MODULES
* @param PLUGINS_FOLDER
* @param MODULES_FOLDER
* @param POST_DATA can be empty object if no data is supposed to be loaded
* @param version
* @param awaitPluginReady if true, returned handler awaits plugins
* @return {function} initializer function to call once ready
*/
function initXOpatLoader(PLUGINS, MODULES, PLUGINS_FOLDER, MODULES_FOLDER, POST_DATA, version, awaitPluginReady=false) {
if (window.XOpatPlugin) throw "XOpatLoader already initialized!";
//dummy translation function in case of no translation available
$.t = $.t || (x => x);
let REGISTERED_PLUGINS = [];
let LOADING_PLUGIN = false;
function showPluginError(id, e) {
if (!e) {
$(`#error-plugin-${id}`).html("");
$(`#load-plugin-${id}`).html("");
return;
}
$(`#error-plugin-${id}`).html(`<div class="p-1 rounded-2 error-container">${$.t('messages.pluginRemoved')}<br><code>[${e}]</code></div>`);
$(`#load-plugin-${id}`).html(`<button disabled class="btn">${$.t('common.Failed')}</button>`);
}
function cleanUpScripts(id) {
$(`#script-section-${id}`).remove();
LOADING_PLUGIN = false;
}
function cleanUpPlugin(id, e=$.t('error.unknown')) {
if (PLUGINS[id]) {
delete PLUGINS[id].instance;
PLUGINS[id].loaded = false;
PLUGINS[id].error = e;
}
showPluginError(id, e);
$(`.${id}-plugin-root`).remove();
cleanUpScripts(id);
}
function instantiatePlugin(id, PluginClass) {
if (!id) {
console.error("Plugin registered with no id defined!", id);
return;
}
if (!PLUGINS[id]) {
console.error("Plugin registered with invalid id: no such id present in 'include.json'.", id);
return;
}
let plugin;
try {
if (!APPLICATION_CONTEXT.config.plugins[id]) {
APPLICATION_CONTEXT.config.plugins[id] = {};
}
plugin = new PluginClass(id);
} catch (e) {
console.warn(`Failed to instantiate plugin ${PluginClass}.`, e);
/**
* @property {string} id plugin id
* @property {string} message
* @memberOf VIEWER
* @event plugin-failed
*/
window.VIEWER && VIEWER.raiseEvent('plugin-failed', {
id: id,
message: $.t('messages.pluginLoadFailedNamed', {plugin: id}),
});
cleanUpPlugin(id, e);
return;
}
plugin.id = id; //silently set
let possiblyExisting = PLUGINS[id].instance;
if (possiblyExisting) {
console.warn(`Plugin ${PluginClass} ID collides with existing instance!`, id, possiblyExisting);
/**
* @property {string} id plugin id
* @property {string} message
* @memberOf VIEWER
* @event plugin-failed
*/
window.VIEWER && VIEWER.raiseEvent('plugin-failed', {
id: plugin.id,
message: $.t('messages.pluginLoadFailedNamed', {plugin: plugin.name}),
});
cleanUpPlugin(plugin.id);
return;
}
PLUGINS[id].instance = plugin;
//clean up possible errors
showPluginError(id, null);
return plugin;
}
async function initializePlugin(plugin) {
if (!plugin) return false;
if (!plugin.pluginReady) return true;
try {
await plugin.pluginReady();
return true;
} catch (e) {
console.warn(`Failed to initialize plugin ${plugin.id}.`, e);
cleanUpPlugin(plugin.id, e);
}
return false;
}
/**
* Load a script at runtime. Plugin is REMOVED from the viewer
* if the script is faulty
*
* Enhancement: use Premise API instead
* @param pluginId plugin that uses particular script
* @param properties script attributes to set
* @param onload function to call on success
* @global
*/
window.attachScript = function(pluginId, properties, onload) {
let errHandler = function(e) {
window.onerror = null;
if (LOADING_PLUGIN) {
cleanUpPlugin(pluginId, e);
} else {
cleanUpScripts(pluginId);
}
};
if (!properties.hasOwnProperty('src')) {
errHandler($.t('messages.pluginScriptSrcMissing'));
return;
}
let container = document.getElementById(`script-section-${pluginId}`);
if (!container) {
container = document.createElement("div");
container.id = "script-section-" + pluginId;
document.body.append(container);
}
let script = document.createElement("script");
for (let key in properties) {
if (key === 'src') continue;
script[key] = properties[key];
}
script.async = false;
script.onload = function() {
window.onerror = null;
onload();
};
script.onerror = errHandler;
window.onerror = errHandler;
script.src = properties.src;
container.append(script);
return true;
};
/**
* Get plugin.
* @param id plugin id, should be unique in the system and match the id value in includes.json
* @global
*/
window.plugin = function(id) {
return PLUGINS[id]?.instance;
};
/**
* Get one of allowed plugin meta keys
* @param id
* @param {string} metaKey one of "name", "description", "author", "version"
*/
window.pluginMeta = function(id, metaKey) {
return ["name", "description", "author", "version"].includes(metaKey) ? PLUGINS[id]?.[metaKey] : undefined;
}
/**
* Get a module singleton reference if instantiated.
* @param id module id
* @return {XOpatModuleSingleton|undefined} module if it is a singleton and already instantiated
*/
window.singletonModule = function (id) {
return MODULES[id]?.instance;
};
/**
* Register plugin. Plugin can be instantiated and embedded into the viewer.
* @param id plugin id, should be unique in the system and match the id value in includes.json
* @param PluginClass class/class-like-function to register (not an instance!)
* @global
*/
window.addPlugin = function(id, PluginClass) {
let plugin = instantiatePlugin(id, PluginClass);
if (!plugin) return;
if (REGISTERED_PLUGINS !== undefined) {
if (plugin && typeof plugin["pluginReady"] === "function") {
REGISTERED_PLUGINS.push(plugin);
}
} //else do not initialize plugin, wait untill all files loaded dynamically
};
function extendIfContains(target, source, ...properties) {
for (let property of properties) {
if (source.hasOwnProperty(property)) target[property] = source[property];
}
}
function chainLoad(id, sources, index, onSuccess, folder=PLUGINS_FOLDER) {
if (index >= sources.includes.length) {
onSuccess();
} else {
let toLoad = sources.includes[index],
properties = {};
if (typeof toLoad === "string") {
properties.src = `${folder}${sources.directory}/${toLoad}?v=${version}`;
} else if (typeof toLoad === "object") {
extendIfContains(properties, toLoad,
'async', 'crossOrigin', 'defer', 'integrity', 'referrerPolicy', 'src');
} else {
throw "Invalid dependency: invalid type " + (typeof toLoad);
}
attachScript(id, properties,
_ => chainLoad(id, sources, index+1, onSuccess, folder));
}
}
function chainLoadModules(moduleList, index, onSuccess) {
if (index >= moduleList.length) {
onSuccess();
return;
}
let module = MODULES[moduleList[index]];
if (!module || module.loaded) {
chainLoadModules(moduleList, index+1, onSuccess);
return;
}
function loadSelf() {
//load self files and continue loading from modulelist
chainLoad(module.id + "-module", module, 0,
function() {
if (module.styleSheet) { //load css if necessary
$('head').append(`<link rel='stylesheet' href='${module.styleSheet}' type='text/css'/>`);
}
module.loaded = true;
chainLoadModules(moduleList, index+1, onSuccess);
}, MODULES_FOLDER);
}
//first dependencies, then self
chainLoadModules(module.requires || [], 0, loadSelf);
}
async function _getLocale(id, path, directory, data, locale) {
if (!$.i18n) return;
if (!locale) locale = $.i18n.language;
if (typeof data === "string" && directory) {
await fetch(`${path}${directory}/${data}`).then(response => {
if (!response.ok) {
throw new HTTPError("HTTP error " + response.status, response, '');
}
return response.json();
}).then(json => {
$.i18n.addResourceBundle(locale, id, json);
});
} else if (data) {
$.i18n.addResourceBundle(locale, id, data);
} else {
throw "Invalid translation for item " + id;
}
}
// POST DATA STORAGE - Always implemented via POST, support static IO.
/**
* @extends XOpatStorage.Data
* @type {PostDataStore}
*/
class PostDataStore extends XOpatStorage.Data {
/**
* @param options the options used in super class XOpatStorage.Data
* @param options.xoType type of the owner
*/
constructor(options) {
super({...options,
id: (options.id || "").split(".").filter((v, i) => i > 0).join(".")});
if (options.xoType !== "plugin" && options.xoType !== "module") throw "Invalid xoType for PostDataStore!";
this.contextType = options.xoType;
//write target
this.__storage._withReference(this.contextType);
}
/**
* The ability to export all relevant data is used mainly with current session exports/shares.
* This is used for immediate static export of the current state.
* @return {Promise<string>} serialized data
*/
async export() {
const exports = {};
//bit dirty, but we rely on keys implementation as we hardcode storage driver
for (let key of this.__storage._keys()) {
if (key.startsWith(this.id)) {
exports[key] = await this.__storage.get(key);
}
}
try {
return JSON.stringify(exports);
} catch (e) {
console.error("Error exporting post data for ", this.id, e);
return undefined;
}
}
/**
* @param Class ignored argument, this class hardcodes POST DATA 'driver'
*/
static register(Class) {
super.registerClass(class extends XOpatStorage.AsyncStorage {
async getItem(key) {
let storage = POST_DATA[this.ref];
// backward non-namespaced compatibility
return POST_DATA[key] || (storage && storage[key]);
}
async setItem(key, value) {
let storage = POST_DATA[this.ref];
if (!storage) {
storage = POST_DATA[this.ref] = {};
}
storage[key] = value;
}
async removeItem(key) {
delete POST_DATA[key];
let storage = POST_DATA[this.ref];
if (storage) {
delete storage[key];
}
}
async clear() {
if (POST_DATA[this.ref]) {
POST_DATA[this.ref] = {};
}
}
get length() {
let storage = POST_DATA[this.ref];
return Object.keys(storage || {}).length;
}
async key(index) {
let storage = POST_DATA[this.ref];
return Object.keys(storage || {})[index];
}
_keys() { //internal loader use
let storage = POST_DATA[this.ref];
return Object.keys(storage || {});
}
_withReference(ref) { //internal loader use
this.ref = ref;
}
});
}
}
PostDataStore.register(null);
const STORE_TOKEN = Symbol("XOpatElementDataStore");
const CACHE_TOKEN = Symbol("XOpatElementCacheStore");
/**
* Implements common interface for plugins and modules. Cannot
* be instantiated as it is hidden in closure. Private, but
* available in docs due to its API nature.
* @abstract
*/
class XOpatElement {
constructor(id, executionContextName) {
if (!id) throw `Trying to instantiate an element '${this.constructor.name || this.constructor}' - no id given.`;
this.__id = id;
this.__uid = `${executionContextName}.${id}`;
this.__xoContext = executionContextName;
this[CACHE_TOKEN] = new XOpatStorage.Cache({id: this.__uid});
}
/**
* @return {string} id element identifier
*/
get id() {
return this.__id;
}
/**
* @return {string} id unique element identifier in the application
*/
get uid() {
return this.__uid;
}
/**
* @return {string} context ID (plugin/module)
*/
get xoContext() {
return this.__xoContext;
}
/**
* @return {XOpatStorage.Cache} cache interface
*/
get cache() {
return this[CACHE_TOKEN];
}
/**
* @return {PostDataStore}
*/
get POSTStore() {
return this[STORE_TOKEN];
}
/**
* Relative locale file location as locales/[locale].json.
* Override for custom locales file location.
* @param locale
* @return {string} relative file path
*/
getLocaleFile(locale) {
return `locales/${locale}.json`;
}
/**
* Translate the string in given element context
* @param key
* @param options
* @return {*}
*/
t(key, options={}) {
options.ns = this.id;
return $.t(key, options);
}
/**
* Raise error event. If the module did register as event source,
* it is fired on the item instance. Otherwise, it is fired on the VIEWER.
* todo better warn mechanism:
* -> simple way of module/plugin level context warns and errors (no feedback)
* -> advanced way of event warnings (feedback with E code)
* @param e
* @param e.code
* @param e.message
* @param e.error
* @param {boolean} notifyUser fires error-user if true, error-system otherwise.
*/
error(e, notifyUser=true) {
/**
* Raise event from instance. Instances that register as event source fire on themselves.
* @property {string} originType `"module"`, `"plugin"` or other type of the source
* @property {string} originId
* @event error-user
* @event error-system
* @memberOf VIEWER
*/
/**
* Raise event from instance. Instances that register as event source fire on themselves.
* @property {string} originType `"module"`, `"plugin"` or other type of the source
* @property {string} originId
* @event error-user
* @event error-system
* @memberOf XOpatElement
*/
(this.__errorBindingOnViewer ? VIEWER : this).raiseEvent(notifyUser ? 'error-user' : 'error-system',
$.extend(e, {originType: this.xoContext, originId: this.id}));
}
/**
* Raise warning event. If the module did register as event source,
* it is fired on the item instance. Otherwise, it is fired on the VIEWER.
* todo better warn mechanism:
* -> simple way of module/plugin level context warns and errors (no feedback)
* -> advanced way of event warnings (feedback with E code)
* @param e
* @param e.code
* @param e.message
* @param e.error
* @param {boolean} notifyUser fires error-user if true, error-system otherwise.
*/
warn(e, notifyUser) {
/**
* Raise event from instance. Instances that register as event source fire on themselves.
* @property {string} originType `"module"`, `"plugin"` or other type of the source
* @property {string} originId
* @event warn-user
* @event warn-system
* @memberOf VIEWER
*/
/**
* Raise event from instance. Instances that register as event source fire on themselves.
* @property {string} originType `"module"`, `"plugin"` or other type of the source
* @property {string} originId
* @event warn-user
* @event warn-system
* @memberOf XOpatElement
*/
(this.__errorBindingOnViewer ? VIEWER : this).raiseEvent(notifyUser ? 'warn-user' : 'warn-system',
$.extend(e,
{originType: this.xoContext, originId: this.id}));
}
/**
* Initialize IO in the Element - enables use of export/import functions
* @param {XOpatStorage.StorageOptions?} options where id value is ignored (overridden)
* @param {string?} [options.exportKey=""] optional export key for the globally exported
* data through exportData
* @return {PostDataStore} data store reference, or false if import failed
*/
async initPostIO(options = {}) {
let store = this[STORE_TOKEN];
if (store) return store;
options.id = this.uid;
options.xoType = this.__xoContext;
const dataStore = this[STORE_TOKEN] = new PostDataStore(options);
try {
const exportKey = options.exportKey || "";
VIEWER.addHandler('export-data', async (e) => {
const data = await this.exportData();
if (data) {
await dataStore.set(exportKey, data);
}
});
const data = await dataStore.get(exportKey);
if (data !== undefined) await this.importData(data);
} catch (e) {
console.error('IO Failure:', this.constructor.name, e);
this.error({
error: e, code: "W_IO_INIT_ERROR",
message: $.t('error.pluginImportFail',
{plugin: this.id, action: "USER_INTERFACE.highlightElementId('global-export');"})
});
}
return dataStore;
}
/**
* Called to export data within 'export-data' event: automatically the post data store object
* (returned from initPostIO()) is given the output of this method:
* `await dataStore.set(options.exportKey || "", await this.exportData());`
* note: for multiple objects, you can either manually add custom keys to the `dataStore` reference
* upon the event 'export-data', or simply nest objects to fit a single output
* @return {Promise<any>}
*/
async exportData() {}
/**
* Called automatically within this.initPostIO if data available
* note: parseImportData return value decides if data is parsed data or passed as raw string
* @param data {(string|*)} data
*/
async importData(data) {}
/**
* TODO: this does not wait once module is fully loaded!
* @param moduleId
* @param callback
* @return {boolean} true if finished immediatelly, false if registered handler for the
* future possibility of the module being loaded
*/
integrateWithSingletonModule(moduleId, callback) {
const targetModule = singletonModule(moduleId);
if (targetModule) {
callback(targetModule);
return true;
}
VIEWER.addHandler('module-singleton-created', e => {
if (e.id === moduleId) callback(e.module);
});
return false;
}
/**
* Set the element as event-source class. Re-uses EventSource API from OpenSeadragon.
*/
registerAsEventSource(errorBindingOnViewer=true) {
//consider _errorHandlers that would listen for errors and warnings and provide handling instead of global scope VIEWER (at least for plugins)
const events = this.__eventSource = new OpenSeadragon.EventSource();
events.filters = {};
this.addHandler = events.addHandler.bind(events);
this.addOnceHandler = events.addOnceHandler.bind(events);
this.getHandler = events.getHandler.bind(events);
this.numberOfHandlers = events.numberOfHandlers.bind(events);
this.raiseEvent = events.raiseEvent.bind(events);
this.raiseAwaitEvent = VIEWER.tools.raiseAwaitEvent.bind(this, events);
this.removeAllHandlers = events.removeAllHandlers.bind(events);
this.removeHandler = events.removeHandler.bind(events);
this.__errorBindingOnViewer = errorBindingOnViewer;
this.addFilter = ( eventName, handler, priority ) => {
let filters = this.filters[ eventName ];
if ( !filters ) {
this.filters[ eventName ] = filters = [];
}
if ( handler && OpenSeadragon.isFunction( handler ) ) {
let index = filters.length,
filter = { handler: handler, priority: priority || 0 };
filters[ index ] = filter;
while ( index > 0 && filters[ index - 1 ].priority < filters[ index ].priority ) {
filters[ index ] = filters[ index - 1 ];
filters[ index - 1 ] = filter;
index--;
}
}
};
this.applyFilter = ( eventName, value ) => {
let filters = this.filters[ eventName ];
if ( !filters || !filters.length ) {
return null;
}
for ( let i = 0; i < length; i++ ) {
if ( filters[ i ] ) {
value = filters[ i ].handler( value );
}
}
return value;
};
this.removeFilter = ( eventName, handler ) => {
let filters = this.filters[ eventName ];
if ( !filters || !OpenSeadragon.isArray( filters ) ) {
return;
}
this.filters = filters.filter(f => f.handler !== handler);
};
}
/**
* Add an event handler for a given event. See OpenSeadragon.EventSource::addHandler
* Note: noop if registerAsEventSource() not called.
*/
addHandler() {}
/**
* Add an event handler to be triggered only once (or X times). See OpenSeadragon.EventSource::addOnceHandler
* Note: noop if registerAsEventSource() not called.
*/
addOnceHandler () {}
/**
* Get a function which iterates the list of all handlers registered for a given event, calling the handler for each.
* See OpenSeadragon.EventSource::getHandler
* Note: noop if registerAsEventSource() not called.
*/
getHandler () {}
/**
* Get the amount of handlers registered for a given event. See OpenSeadragon.EventSource::numberOfHandlers
* Note: noop if registerAsEventSource() not called.
*/
numberOfHandlers () {}
/**
* Trigger an event, optionally passing additional information. See OpenSeadragon.EventSource::raiseEvent
* Note: noop if registerAsEventSource() not called.
*/
raiseEvent () {}
/**
* Trigger an event, optionally passing additional information. See OpenSeadragon.EventSource::raiseAwaitEvent.
* Awaits async handlers.
* Note: noop if registerAsEventSource() not called.
*/
raiseAwaitEvent() {}
/**
* Remove all event handlers for a given event type. See OpenSeadragon.EventSource::removeAllHandlers
* Note: noop if registerAsEventSource() not called.
*/
removeAllHandlers () {}
/**
* Remove a specific event handler for a given event. See OpenSeadragon.EventSource::removeHandler
* Note: noop if registerAsEventSource() not called.
*/
removeHandler () {}
/**
* Remove a specific event handler for a given event. See OpenSeadragon.EventSource::removeHandler
* Note: noop if registerAsEventSource() not called.
*/
addFilter () {}
/**
* Remove a specific event handler for a given event. See OpenSeadragon.EventSource::removeHandler
* Note: noop if registerAsEventSource() not called.
*/
applyFilter () {}
/**
* Remove a specific event handler for a given event. See OpenSeadragon.EventSource::removeHandler
* Note: noop if registerAsEventSource() not called.
*/
removeFilter () {}
}
/**
* Basic Module API. Modules do not have to inherit from XOpatModule, but
* they loose the integration support.
* @class XOpatModule
* @extends XOpatElement
* @inheritDoc
*/
window.XOpatModule = class extends XOpatElement {
constructor(id) {
super(id, "module");
}
/**
* Load localization data
* @param locale the current locale if undefined
* @param data possibly custom locale data if not fetched from a file
* @return {Promise}
*/
async loadLocale(locale=undefined, data=undefined) {
return await _getLocale(this.id, MODULES_FOLDER, MODULES[this.id]?.directory,
data || this.getLocaleFile(locale || $.i18n.language), locale);
}
/**
* Read static metadata - include.json contents and additional meta attached at runtime
* @param metaKey key to read
* @param defaultValue
* @return {undefined|*}
*/
getStaticMeta(metaKey, defaultValue) {
if (metaKey === "instance") return undefined;
const value = MODULES[this.id]?.[metaKey];
if (value === undefined) return defaultValue;
return value;
}
/**
* Root path - the modules folder
*/
static ROOT = MODULES_FOLDER;
}
/**
* Singleton Module API, ready to run as an instance
* offering its features to all equally.
* @class XOpatModuleSingleton
* @extends XOpatModule
* @inheritDoc
*/
window.XOpatModuleSingleton = class extends XOpatModule {
/**
* Get instance of the annotations manger, a singleton
* (only one instance can run since it captures mouse events)
* @static
* @return {XOpatModuleSingleton} manager instance
*/
static instance() {
//this calls sub-class constructor, no args required
this.__self = this.__self || new this();
return this.__self;
}
/**
* Check if instantiated
* @return {boolean}
*/
static instantiated() {
return this.__self && true; //retype
}
static __self = undefined;
constructor(id) {
super(id);
const staticContext = this.constructor;
if (staticContext.__self) {
throw `Trying to instantiate a singleton. Instead, use ${staticContext.name}::instance().`;
}
staticContext.__self = this;
MODULES[id].instance = this;
// Await event necessary to fire after instantiation, do in async context
setTimeout(() => VIEWER.tools.raiseAwaitEvent(VIEWER, 'module-singleton-created', {
id: id,
module: this
}).catch(/*no-op*/));
}
}
/**
* xOpat Plugin API. Plugins must have a parent class that
* is registered and inherits from XOpatPlugin.
* JS String to use in DOM callbacks to access self instance.
* @class
* @extends XOpatElement
* @inheritDoc
*/
window.XOpatPlugin = class extends XOpatElement {
constructor(id) {
super(id, "plugin");
}
/**
* Function called once a viewer is fully loaded
*/
async pluginReady() {
}
/**
* Load localization data
* @param locale the current locale if undefined
* @param data possibly custom locale data if not fetched from a file
*/
async loadLocale(locale=undefined, data=undefined) {
return await _getLocale(this.id, PLUGINS_FOLDER, PLUGINS[this.id]?.directory,
data || this.getLocaleFile(locale || $.i18n.language), locale)
}
/**
* Read static metadata - include.json contents and additional meta attached at runtime
* @param metaKey key to read
* @param defaultValue
* @return {undefined|*}
*/
getStaticMeta(metaKey, defaultValue) {
if (metaKey === "instance") return undefined;
const value = PLUGINS[this.id]?.[metaKey];
if (value === undefined) return defaultValue;
return value;
}
/**
* Store the plugin online configuration parameters/options
* todo: options are not being documented, enforce
* @param {string} key
* @param {*} value
* @param {boolean} cache
*/
setOption(key, value, cache=true) {
if (cache) this.setLocalOption(key, value);
if (value === "false") value = false;
else if (value === "true") value = true;
APPLICATION_CONTEXT.config.plugins[this.id][key] = value;
}
/**
* Read the plugin online configuration parameters/options.
* The defaultValue is read from a static configuration if not provided.
* Note that this behavior will read static values such as 'permaLoad', 'includes' etc..
* @param {string} key
* @param {*} defaultValue
* @param {boolean} cache
* @return {*}
*/
getOption(key, defaultValue=undefined, cache=true) {
//todo allow APPLICATION_CONTEXT.getOption(...cache...) to disable cache globally
//options are stored only for plugins, so we store them at the lowest level
let value = cache ? this.cache.get(key, null) : null;
if (value === null) {
// read default value from static context if exists
if (defaultValue === undefined && key !== "instance") {
defaultValue = PLUGINS[this.id]?.[key];
}
value = APPLICATION_CONTEXT.config.plugins[this.id].hasOwnProperty(key) ?
APPLICATION_CONTEXT.config.plugins[this.id][key] : defaultValue;
}
if (value === "false") value = false;
else if (value === "true") value = true;
return value;
}
/**
* Ability to cache a value locally into the browser,
* the value can be retrieved using this.getOption(...)
* todo rename to setCacheOption
* @param key
* @param value
*/
setLocalOption(key, value) {
this.cache.set(key, value);
}
/**
* Read plugin configuration value - either from a static configuration or dynamic one.
* More generic function that reads any option available (configurable via dynamic JSON or include.json)
* @param {string} optKey dynamic param key, overrides anything
* @param {string} staticKey static param key, used if dynamic value is undefined
* @param {any} defaultValue
* @param {boolean} cache
*/
getOptionOrConfiguration(optKey, staticKey, defaultValue=undefined, cache=true) {
const value = this.getOption(optKey, undefined, cache);
return value === undefined ? this.getStaticMeta(staticKey, defaultValue) : value;
}
/**
* JS String to use in DOM callbacks to access self instance.
* @type {string}
*/
get THIS() {
if (!this.id) return "__undefined__";
//memoize
Object.defineProperty(this, "THIS", {
value: `plugin('${this.id}')`,
writable: false,
});
return `plugin('${this.id}')`;
}
/**
* To simplify plugin interaction, you can register a callback executed
* when a certain plugin gets loaded into the system.
* @param {string} pluginId
* @param {function} callback that receives the plugin instance
* @return {boolean} true if finished immediatelly, false if registered handler for the
* future possibility of plugin being loaded
*/
integrateWithPlugin(pluginId, callback) {
const targetPlugin = plugin(pluginId);
if (targetPlugin) {
callback(targetPlugin);
return true;
}
VIEWER.addHandler('plugin-loaded', e => {
if (e.id === pluginId) callback(e.plugin);
});
return false;
}
static ROOT = PLUGINS_FOLDER;
}
/**
* @namespace UTILITIES
*/
window.UTILITIES = /** @lends UTILITIES */ {
/**
* Send requests - both request and response format JSON
* with POST, the viewer meta is automatically included
* - makes the viewer flexible for integration within existing APIs
* @param url
* @param postData
* @param headers
* @throws HTTPError
* @return {Promise<string|any>}
*/
fetch: async function(url, postData=null, headers={}) {
let method = postData ? "POST" : "GET";
headers = $.extend({
'Access-Control-Allow-Origin': '*'
}, headers);
const response = await fetch(url, {
method: method,
mode: 'cors',
cache: 'no-cache',
credentials: 'same-origin',
headers: headers,
body: postData ? JSON.stringify(postData) : null
});
if (response.status < 200 || response.status > 299) {
return response.text().then(text => {
throw new HTTPError(`Server returned ${response.status}: ${text}`, response, text);
});
}
return response;
},
/**
* Send requests - both request and response format JSON
* with POST, the viewer meta is automatically included
* - makes the viewer flexible for integration within existing APIs
* @param url
* @param postData
* @param headers
* @throws HTTPError
* @return {Promise<string|any>}
*/
fetchJSON: async function(url, postData=null, headers=null) {
headers = headers || {};
headers['Content-Type'] = 'application/json';
const response = await this.fetch(url, postData, headers),
data = await response.text();
try {
return JSON.parse(data);
} catch (e) {
throw new HTTPError("Server returned non-JSON data!", response, data);
}
},
/**
* @param imageFilePath image path
* @param stripSuffix
*/
fileNameFromPath: function(imageFilePath, stripSuffix=true) {
let begin = imageFilePath.lastIndexOf('/');
if (begin === -1) return imageFilePath;
begin++;
if (stripSuffix) {
let end = imageFilePath.lastIndexOf('.');
if (end >= 0) return imageFilePath.substr(begin, end - begin);
}
return imageFilePath.substr(begin, imageFilePath.length - begin);
},
/**
* Strip path suffix
* @param {string} path
* @return {string}
*/
stripSuffix: function (path) {
let end = path.lastIndexOf('.');
if (end >= 0) return path.substr(0, end);
return path;
},
/**
* Load modules at runtime
* NOTE: in case of failure, loading such id no longer works unless the page is refreshed
* @param onload function to call on successful finish
* @param ids all modules id to be loaded (rest parameter syntax)
*/
loadModules: function(onload=_=>{}, ...ids) {
LOADING_PLUGIN = false;
chainLoadModules(ids, 0, () => {
/**
* Module loaded event. Fired only with dynamic loading.
* @property {string} id module id
* @memberOf VIEWER
* @event module-loaded
*/
window.VIEWER && ids.forEach(id => VIEWER.raiseEvent('module-loaded', {id: id}));
onload && onload();
});
},
/**
* Load a plugin at runtime
* NOTE: in case of failure, loading such id no longer works unless the page is refreshed
* @param id plugin to load
* @param onload function to call on successful finish
*/
loadPlugin: function(id, onload=_=>{}, force) {
let meta = PLUGINS[id];
if (!meta || (meta.loaded && meta.instance)) return;
if (!Array.isArray(meta.includes)) {
meta.includes = [];
}
if (REGISTERED_PLUGINS === undefined) {
/**
* Before a request to plugin loading is processed at runtime.
* @property {string} id plugin id
* @memberOf VIEWER
* @event before-plugin-load
*/
VIEWER.raiseEvent('before-plugin-load', {id: id});
}
let successLoaded = function() {
LOADING_PLUGIN = false;
function finishPluginLoad() {
if (meta.styleSheet) { //load css if necessary
$('head').append(`<link rel='stylesheet' href='${meta.styleSheet}' type='text/css'/>`);
}
meta.loaded = true;
if (APPLICATION_CONTEXT.getOption("permaLoadPlugins") && !APPLICATION_CONTEXT.getOption("bypassCookies")) {
let plugins = [];
for (let p in PLUGINS) {
if (PLUGINS[p].loaded) plugins.push(p);
}
APPLICATION_CONTEXT.AppCookies.set('_plugins', plugins.join(","));
}
}
//loaded after page load if REGISTERED_PLUGINS === undefined
const loadedAfterPluginInit = REGISTERED_PLUGINS === undefined;
if (loadedAfterPluginInit) {
initializePlugin(PLUGINS[id].instance).then(success => {
if (!success) {
/**
* @property {string} id plugin id
* @property {string} message
* @memberOf VIEWER
* @event plugin-failed
*/
window.VIEWER && VIEWER.raiseEvent('plugin-failed', {
id: plugin.id,
message: $.t('messages.pluginLoadFailedNamed', {plugin: PLUGINS[id].name}),
});
return;
}
finishPluginLoad();
/**
* Plugin was loaded dynamically at runtime.
* @property {string} id plugin id
* @memberOf VIEWER
* @event plugin-loaded
*/
VIEWER.raiseEvent('plugin-loaded', {id: id, plugin: PLUGINS[id].instance});
onload();
}).catch(e => {
console.error(e);
});
return;
}
finishPluginLoad();
onload();
};
LOADING_PLUGIN = true;
chainLoadModules(meta.modules || [], 0, _ => chainLoad(id, meta, 0, successLoaded));
},
/**
* Check whether component is loaded
* @param {string} id component id
* @param {boolean} isPlugin true if check for plugins
*/
isLoaded: function (id, isPlugin=false) {
if (isPlugin) {
let plugin = PLUGINS[id];
return plugin.loaded && plugin.instance;
}
return MODULES[id].loaded;
},
/**
* Serialize the Viewer
* @param includedPluginsList
* @param withCookies
* @param staticPreview Whether to mark the serialized app as static or not
* @return {Promise<{app: string, data: {}}>}
*/
serializeApp: async function(includedPluginsList=undefined, withCookies=false, staticPreview=false) {
//reconstruct active plugins
let pluginsData = APPLICATION_CONTEXT.config.plugins;
let includeEvaluator = includedPluginsList ?
(p, o) => includedPluginsList.includes(p) :
(p, o) => o.loaded || o.permaLoad;
for (let pid of APPLICATION_CONTEXT.pluginIds()) {
const plugin = APPLICATION_CONTEXT._dangerouslyAccessPlugin(pid);
if (!includeEvaluator(pid, plugin)) {
delete pluginsData[pid];
} else if (!pluginsData.hasOwnProperty(pid)) {
pluginsData[pid] = {};
}
}
/**
* Event to export your data within the viewer lifecycle
* Event handler can by <i>asynchronous</i>, the event can wait.
* todo OSD v5.0 will support also async events
*
* @property {function} setSerializedData callback to call,
* accepts 'key' (unique) and 'data' (string) to call with your data when ready
* @memberOf VIEWER
* @event export-data
*/
await VIEWER.tools.raiseAwaitEvent(VIEWER, 'export-data');
return {app: UTILITIES.serializeAppConfig(withCookies, staticPreview), data: POST_DATA};
}
};
return awaitPluginReady ? async function() {
//Notify plugins OpenSeadragon is ready
Promise.all(REGISTERED_PLUGINS.map(plugin => initializePlugin(plugin))).then(() => {
REGISTERED_PLUGINS = undefined;
});
} : function () {
REGISTERED_PLUGINS.forEach(plugin => initializePlugin(plugin));
REGISTERED_PLUGINS = undefined;
}
}